diff --git a/.env.mac-docker.example b/.env.mac-docker.example index 4b5ca4db..56de090c 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -64,9 +64,5 @@ KUASHOU_INDUSTRY_APP_SECRET= KUASHOU_INDUSTRY_SIGN_SECRET= KUASHOU_INDUSTRY_MESSAGE_SECRET= -# Cloudtentacles -CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc -CLOUDTENTACLES_DEVICE_TYPE=1 - # 接单平台短信验证码(打手注册,开发默认 mock,验证码打印到服务日志) WORKER_SMS_PROVIDER=mock diff --git a/.env.server.example b/.env.server.example index 59500167..01573881 100644 --- a/.env.server.example +++ b/.env.server.example @@ -110,10 +110,6 @@ KUASHOU_INDUSTRY_APP_SECRET= KUASHOU_INDUSTRY_SIGN_SECRET= KUASHOU_INDUSTRY_MESSAGE_SECRET= -# Cloudtentacles -CLOUDTENTACLES_DEVICE_ID=08bc9d8c-fd15-48ea-bc00-8d754076cafc -CLOUDTENTACLES_DEVICE_TYPE=1 - # 接单平台短信验证码(打手注册) WORKER_SMS_PROVIDER=mock WORKER_SMS_ACCESS_KEY_ID= diff --git a/apps/backend/package.json b/apps/backend/package.json index 6a3cf2ea..7b7a553f 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -18,7 +18,6 @@ "lint": "eslint \"src/**/*.ts\" \"scripts/**/*.ts\"", "lint:check": "npm run lint -- --quiet", "lint:fix": "npm run lint -- --fix", - "mock:claim": "tsx scripts/mock-kuaishou-cloud-claim.ts", "mock:open91": "tsx scripts/mock-open91-order.ts", "test": "node --import tsx --test $(find src \\( -name '*.test.ts' -o -name '*.test.js' \\) -print)", "test:industry": "tsx scripts/test-kuaishou-industry.ts", @@ -28,8 +27,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build", "start": "node dist/index.js", - "start:src": "tsx src/index.ts", - "mock:feifei": "tsx scripts/mock-feifei-claim.ts" + "start:src": "tsx src/index.ts" }, "dependencies": { "@alicloud/dysmsapi20170525": "^4.6.0", diff --git a/apps/backend/scripts/mock-feifei-claim.ts b/apps/backend/scripts/mock-feifei-claim.ts deleted file mode 100644 index c8c2912a..00000000 --- a/apps/backend/scripts/mock-feifei-claim.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * CLI 入口:开发环境生成 feifei 领取 mock。 - */ -import process from 'node:process' - -import { closeDb } from '../src/db/client.js' -import { - createFeifeiMockClaim, - isDevMockEnabled, -} from '../src/services/dev-mock/dev-mock-service.js' - -type JsonObject = Record - -const args = parseArgs(process.argv.slice(2)) - -if (args.help) { - console.log(` -生成 feifei 领取 mock(CLI)。推荐使用后台 /admin/dev-mock。 - - npm run mock:feifei -- --step=ready --uid=166909256 -`) - process.exit(0) -} - -if (!isDevMockEnabled() && args.force !== true) { - console.error('当前是生产环境,已拒绝生成 mock 数据。') - process.exit(1) -} - -if (args.force === true) { - process.env.ENABLE_DEV_MOCK = '1' -} - -try { - const result = await createFeifeiMockClaim({ - step: args.step, - orderNo: args.orderNo, - productName: args.productName, - productCode: args.productCode, - uid: args.uid, - h5Url: args.h5Url, - frontendBaseUrl: args.frontendBaseUrl, - }) - - console.log('已生成 kuaishou-feifei 领取 mock 数据:') - console.log(` 91订单号:${result.orderNo}`) - console.log(` 商品:${result.productName}`) - console.log(` 当前步骤:${result.step}`) - console.log(` 期望 UID:${result.expectedUid}`) - console.log(` 领取链接:${result.claimUrl}`) - for (const tip of result.tips) { - console.log(` · ${tip}`) - } -} catch (error) { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 -} finally { - await closeDb() -} - -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 - } - if (arg === '--force') { - parsed.force = 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 -} diff --git a/apps/backend/scripts/mock-kuaishou-cloud-claim.ts b/apps/backend/scripts/mock-kuaishou-cloud-claim.ts deleted file mode 100644 index ca0ddc05..00000000 --- a/apps/backend/scripts/mock-kuaishou-cloud-claim.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** - * CLI 入口:开发环境生成 lewan 领取 mock。 - * 推荐使用后台「开发 Mock」页面。 - */ -import process from 'node:process' - -import { closeDb } from '../src/db/client.js' -import { - createLewanMockClaim, - isDevMockEnabled, -} from '../src/services/dev-mock/dev-mock-service.js' - -type JsonObject = Record - -const args = parseArgs(process.argv.slice(2)) - -if (args.help) { - printHelp() - process.exit(0) -} - -if (!isDevMockEnabled() && args.force !== true) { - console.error( - '当前是生产环境,已拒绝生成 mock 数据。如确实要执行,请追加 --force,或设置 ENABLE_DEV_MOCK=1。', - ) - process.exit(1) -} - -if (args.force === true) { - process.env.ENABLE_DEV_MOCK = '1' -} - -try { - const result = await createLewanMockClaim({ - step: args.step, - orderNo: args.orderNo, - productNo: args.productNo, - uid: args.uid, - items: args.items, - frontendBaseUrl: args.frontendBaseUrl, - }) - - console.log('已生成 kuaishou-lewan 领取 mock 数据:') - console.log(` 91订单号:${result.orderNo}`) - console.log(` 商品:${result.productName}`) - console.log(` 当前步骤:${result.step}`) - console.log(` 期望 UID:${result.expectedUid}`) - console.log(` 领取链接:${result.claimUrl}`) - console.log(` 前端本地链接:${result.frontendClaimUrl}`) - console.log('') - for (const tip of result.tips) { - console.log(` · ${tip}`) - } - console.log(` ${result.open91QueryHint}`) -} catch (error) { - console.error(error instanceof Error ? error.message : error) - process.exitCode = 1 -} finally { - await closeDb() -} - -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 - } - if (arg === '--force') { - parsed.force = 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 printHelp() { - console.log(` -生成 lewan 领取 mock(CLI)。推荐使用后台 /admin/dev-mock。 - - npm run mock:claim -- --step=binding --uid=10001 - -参数: - --step uid | binding | confirm | result(默认 uid) - --uid 游戏 UID(binding 起默认 10001) - --orderNo 订单号,默认自动生成 - --productNo 91 商品名,默认 套餐_1----3676797936 - --frontendBaseUrl 前端 base,用于打印本地 claim 链接 - --force 允许在 production 执行(仍需 ENABLE_DEV_MOCK 或 force 打开开关) -`) -} diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index f50e8ed9..22070a03 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -7,7 +7,6 @@ import affiliateDashRouter from './routes/affiliate-dash.js' import collectRouter from './routes/collect.js' import filesRouter from './routes/files.js' import claimsRouter from './routes/claims.js' -import kuaishouFeifeiRouter from './routes/kuaishou-feifei.js' import kuaishouIndustryRouter from './routes/kuaishou-industry.js' import open91Router from './routes/open-91.js' import pddExtensionRouter from './routes/pdd-extension.js' @@ -103,7 +102,6 @@ export function createApp({ startupState, isShutdownStarted, config }: CreateApp app.use('/api/v1/open/affiliate-dash', affiliateDashRouter) app.use('/api/v1/open/91', open91Router) app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter) - app.use('/api/v1/open/kuaishou-feifei', kuaishouFeifeiRouter) app.use('/api/v1/claim', claimsRouter) app.use('/api/v1/collect', collectRouter) app.use('/api/v1/pdd/extension', pddExtensionRouter) diff --git a/apps/backend/src/config/app-config-keys.ts b/apps/backend/src/config/app-config-keys.ts index 44624511..cc7f4206 100644 --- a/apps/backend/src/config/app-config-keys.ts +++ b/apps/backend/src/config/app-config-keys.ts @@ -8,10 +8,6 @@ export const APP_CONFIG_KEYS = { workerAnnouncement: 'worker_announcement', workerHall: 'worker_hall', workerMockShops: 'worker_mock_shops', - cloudtentaclesSources: 'cloudtentacles_sources', - cloudtentaclesSession: 'cloudtentacles_session', - cloudtentaclesOverrideRules: 'cloudtentacles_override_rules', - kuaishouFeifei: 'kuaishou_feifei', kuaishouIndustrySource: 'kuaishou_industry_source', affiliateDash: 'affiliate_dash', pddSource: 'pdd_source', diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 0382871b..e73e187c 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -91,15 +91,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { shopName: '快手行业电子凭证', version: '1', }, - kuaishouFeifei: { - enabled: true, - baseUrl: 'http://skin-exchange.yiquyou.icu', - appKey: '', - appSecret: '', - timeoutMs: 10000, - notifyUrl: '', - productRules: [], - }, affiliateDash: { enabled: true, baseUrl: '', @@ -112,52 +103,6 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { preferredMatchEnabled: true, skuMapping: {}, }, - cloudtentacles: { - baseUrl: 'https://123.207.217.176', - timeoutMs: 5000, - sendSmsPath: '/public/verif_code', - loginPath: '/public/login', - userInfoPath: '/user/info', - assetPath: '/user/get_asset', - permissionPath: '/user/get_permission', - categoriesPath: '/categories/get', - skuListPath: '/sku/list', - skuBuyPath: '/sku/buy', - skuUsePath: '/sku/use', - knapsackPath: '/user/get_knapsack', - vnListPath: '/vn/list', - vnAppointPath: '/vn/appoint', - vnGenerateLoginCodePath: '/vn/generate_login_code', - vnVerifCodePath: '/public/vn_verif_code', - vnVerifyLoginCodePath: '/vn/verify_login_code', - vnBindUrlPath: '/vn/bind_url', - vnBindInfoPath: '/vn/bind_info', - vnBackPath: '/vn/back', - bindUrlTtlSeconds: 600, - bindUrlProbeIntervalSeconds: 30, - bindUrlProbeTimeoutMs: 5000, - bindUrlProbeUserAgent: - 'Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000', - bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', - bindUrlProbeChartId: '323794', - bindUrlProbeSubChartId: '323794', - bindUrlProbeIdeToken: 'z90Syo', - bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F', - bindUrlProbeReferer: 'https://gp.qq.com/', - bindUrlProbeExtraCookie: '', - clientSource: 'ct-client', - deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', - deviceType: 1, - publicKeyPem: `-----BEGIN PUBLIC KEY----- -MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5 -F7K27ZR2IvzxTr2w4zgZAS2+BRdgMvXUOITdFQT+ytYF/r3pIgTkL7MF9UQuylmb -S57DiqGBLNBS7FzKyYzWTOc9yYA3+KrarbvpUn/Bm3UK+KKB17viPy+0pnNAHysJ -pzqadmAtQhI1ZDujJ2sW+FkV/pC8KIFahiyPIHjncy1ZG/BbRF3BDhvDcr6O7scO -GsHstGf9XsZBIITS5LYmlai7OdY4ZIVxUtQbfWzibihO+YDJJzFgt47drX0Ajo85 -PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 -0wIDAQAB ------END PUBLIC KEY-----`, - }, }, cors: { diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index 8bf7d920..1e905708 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -18,7 +18,6 @@ test('applyEnvOverrides maps typed environment values without mutating base conf ADMIN_DEFAULT_USERS_JSON: '[{"username":"admin","password":"secret","role":"admin"}]', KAQUAN91_USER_ID: 'kaquan-user', KAQUAN91_SECRET: 'kaquan-secret', - CLOUDTENTACLES_DEVICE_TYPE: '3', }) assert.equal(config.server.port, 4000) @@ -32,7 +31,6 @@ test('applyEnvOverrides maps typed environment values without mutating base conf ]) assert.equal(config.platforms.ninetyone.userId, 'kaquan-user') assert.equal(config.platforms.ninetyone.secret, 'kaquan-secret') - assert.equal(config.platforms.cloudtentacles.deviceType, 3) assert.equal(baseConfig.server.port, 3000) assert.deepEqual(baseConfig.admin.defaultUsers, []) @@ -97,11 +95,33 @@ function createRuntimeConfig(): RuntimeConfig { integrationLevel: 'warn', retentionDays: 30, }, + retention: { + rawPayloadDays: 180, + taskEventDays: 180, + webhookEventDays: 180, + auditLogDays: 365, + }, database: { url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', ssl: false, maxConnections: 10, }, + storage: { + mode: 'minio', + endpoint: 'http://127.0.0.1:9000', + bucket: 'order-site', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + region: 'us-east-1', + maxUploadSizeMb: 10, + oss: { + endpoint: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + region: 'oss-cn-hangzhou', + }, + }, orders: { claimBaseUrl: 'http://127.0.0.1:5173/#/claim', tokenTtlHours: 24, @@ -121,43 +141,45 @@ function createRuntimeConfig(): RuntimeConfig { timestampToleranceSeconds: 600, cardsEncoding: 'aes-256-ecb-base64', }, - cloudtentacles: { - baseUrl: 'https://123.207.217.176', - timeoutMs: 5000, - sendSmsPath: '/public/verif_code', - loginPath: '/public/login', - userInfoPath: '/user/info', - assetPath: '/user/get_asset', - permissionPath: '/user/get_permission', - categoriesPath: '/categories/get', - skuListPath: '/sku/list', - skuBuyPath: '/sku/buy', - skuUsePath: '/sku/use', - knapsackPath: '/user/get_knapsack', - vnListPath: '/vn/list', - vnAppointPath: '/vn/appoint', - vnGenerateLoginCodePath: '/vn/generate_login_code', - vnVerifCodePath: '/public/vn_verif_code', - vnVerifyLoginCodePath: '/vn/verify_login_code', - vnBindUrlPath: '/vn/bind_url', - vnBindInfoPath: '/vn/bind_info', - vnBackPath: '/vn/back', - bindUrlTtlSeconds: 600, - bindUrlProbeIntervalSeconds: 30, - bindUrlProbeTimeoutMs: 5000, - bindUrlProbeUserAgent: '', - bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', - bindUrlProbeChartId: '323794', - bindUrlProbeSubChartId: '323794', - bindUrlProbeIdeToken: 'z90Syo', - bindUrlProbeActivityUrl: '', - bindUrlProbeReferer: 'https://gp.qq.com/', - bindUrlProbeExtraCookie: '', - publicKeyPem: '', - clientSource: 'ct-client', - deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', - deviceType: 1, + kuaishouIndustry: { + appKey: '', + appSecret: '', + signSecret: '', + messageSecret: '', + provider: 'kuaishou-industry', + platform: 'kuaishou', + shopId: 'kuaishou-industry', + shopName: '快手行业电子凭证', + version: '1', }, + affiliateDash: { + enabled: true, + baseUrl: '', + appKey: '', + appSecret: '', + callbackSecret: '', + timeoutMs: 10000, + notifyUrl: '', + timestampToleranceSeconds: 300, + preferredMatchEnabled: true, + skuMapping: {}, + }, + }, + worker: { + sms: { + provider: 'mock', + accessKeyId: '', + accessKeySecret: '', + signName: '', + templateCode: '', + codeTtlSeconds: 300, + cooldownSeconds: 60, + dailyLimit: 10, + }, + orderTimelineVisible: false, + }, + cors: { + allowedOrigins: ['*'], }, } } diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index cb0d6844..f071dd95 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -3,7 +3,6 @@ import process from 'node:process' import type { AdminDefaultUser, - KuaishouFeifeiProductRule, RuntimeConfig, } from '../types/runtime-config.js' import { parseBoolean, parseInteger, parseJsonArray } from './runtime-env.js' @@ -15,7 +14,6 @@ type RuntimeConfigValue = | number | boolean | AdminDefaultUser[] - | KuaishouFeifeiProductRule[] | string[] | Record type RuntimeEnv = Record @@ -73,96 +71,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ 'timestampToleranceSeconds', ]), stringEnv('KAQUAN91_CARDS_ENCODING', ['platforms', 'ninetyone', 'cardsEncoding']), - stringEnv('CLOUDTENTACLES_BASE_URL', ['platforms', 'cloudtentacles', 'baseUrl']), - integerEnv('CLOUDTENTACLES_TIMEOUT_MS', ['platforms', 'cloudtentacles', 'timeoutMs']), - stringEnv('CLOUDTENTACLES_SEND_SMS_PATH', ['platforms', 'cloudtentacles', 'sendSmsPath']), - stringEnv('CLOUDTENTACLES_LOGIN_PATH', ['platforms', 'cloudtentacles', 'loginPath']), - stringEnv('CLOUDTENTACLES_USER_INFO_PATH', ['platforms', 'cloudtentacles', 'userInfoPath']), - stringEnv('CLOUDTENTACLES_ASSET_PATH', ['platforms', 'cloudtentacles', 'assetPath']), - stringEnv('CLOUDTENTACLES_PERMISSION_PATH', ['platforms', 'cloudtentacles', 'permissionPath']), - stringEnv('CLOUDTENTACLES_CATEGORIES_PATH', ['platforms', 'cloudtentacles', 'categoriesPath']), - stringEnv('CLOUDTENTACLES_SKU_LIST_PATH', ['platforms', 'cloudtentacles', 'skuListPath']), - stringEnv('CLOUDTENTACLES_SKU_BUY_PATH', ['platforms', 'cloudtentacles', 'skuBuyPath']), - stringEnv('CLOUDTENTACLES_KNAPSACK_PATH', ['platforms', 'cloudtentacles', 'knapsackPath']), - stringEnv('CLOUDTENTACLES_VN_LIST_PATH', ['platforms', 'cloudtentacles', 'vnListPath']), - stringEnv('CLOUDTENTACLES_VN_APPOINT_PATH', ['platforms', 'cloudtentacles', 'vnAppointPath']), - stringEnv('CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH', [ - 'platforms', - 'cloudtentacles', - 'vnGenerateLoginCodePath', - ]), - stringEnv('CLOUDTENTACLES_VN_VERIF_CODE_PATH', [ - 'platforms', - 'cloudtentacles', - 'vnVerifCodePath', - ]), - stringEnv('CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH', [ - 'platforms', - 'cloudtentacles', - 'vnVerifyLoginCodePath', - ]), - stringEnv('CLOUDTENTACLES_VN_BIND_URL_PATH', ['platforms', 'cloudtentacles', 'vnBindUrlPath']), - stringEnv('CLOUDTENTACLES_VN_BIND_INFO_PATH', ['platforms', 'cloudtentacles', 'vnBindInfoPath']), - stringEnv('CLOUDTENTACLES_VN_BACK_PATH', ['platforms', 'cloudtentacles', 'vnBackPath']), - integerEnv('CLOUDTENTACLES_BIND_URL_TTL_SECONDS', [ - 'platforms', - 'cloudtentacles', - 'bindUrlTtlSeconds', - ]), - integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeIntervalSeconds', - ]), - integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeTimeoutMs', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeUserAgent', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeEndpoint', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeChartId', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeSubChartId', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeIdeToken', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeActivityUrl', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_REFERER', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeReferer', - ]), - stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE', [ - 'platforms', - 'cloudtentacles', - 'bindUrlProbeExtraCookie', - ]), - stringEnv('CLOUDTENTACLES_PUBLIC_KEY_PEM', ['platforms', 'cloudtentacles', 'publicKeyPem']), - stringEnv('CLOUDTENTACLES_CLIENT_SOURCE', ['platforms', 'cloudtentacles', 'clientSource']), - stringEnv('CLOUDTENTACLES_DEVICE_ID', ['platforms', 'cloudtentacles', 'deviceId']), - integerEnv('CLOUDTENTACLES_DEVICE_TYPE', ['platforms', 'cloudtentacles', 'deviceType']), stringEnv('KUASHOU_INDUSTRY_APP_KEY', ['platforms', 'kuaishouIndustry', 'appKey']), stringEnv('KUASHOU_INDUSTRY_APP_SECRET', ['platforms', 'kuaishouIndustry', 'appSecret']), stringEnv('KUASHOU_INDUSTRY_SIGN_SECRET', ['platforms', 'kuaishouIndustry', 'signSecret']), @@ -171,9 +79,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ stringEnv('KUASHOU_INDUSTRY_SHOP_ID', ['platforms', 'kuaishouIndustry', 'shopId']), stringEnv('KUASHOU_INDUSTRY_SHOP_NAME', ['platforms', 'kuaishouIndustry', 'shopName']), stringEnv('KUASHOU_INDUSTRY_VERSION', ['platforms', 'kuaishouIndustry', 'version']), - stringEnv('KUAISHOU_FEIFEI_BASE_URL', ['platforms', 'kuaishouFeifei', 'baseUrl']), - stringEnv('KUAISHOU_FEIFEI_APP_KEY', ['platforms', 'kuaishouFeifei', 'appKey']), - stringEnv('KUAISHOU_FEIFEI_APP_SECRET', ['platforms', 'kuaishouFeifei', 'appSecret']), stringEnv('WORKER_SMS_PROVIDER', ['worker', 'sms', 'provider']), stringEnv('WORKER_SMS_ACCESS_KEY_ID', ['worker', 'sms', 'accessKeyId']), stringEnv('WORKER_SMS_ACCESS_KEY_SECRET', ['worker', 'sms', 'accessKeySecret']), @@ -183,13 +88,6 @@ export const ENV_OVERRIDES: readonly EnvOverride[] = [ integerEnv('WORKER_SMS_COOLDOWN_SECONDS', ['worker', 'sms', 'cooldownSeconds']), integerEnv('WORKER_SMS_DAILY_LIMIT', ['worker', 'sms', 'dailyLimit']), booleanEnv('WORKER_ORDER_TIMELINE_VISIBLE', ['worker', 'orderTimelineVisible']), - integerEnv('KUAISHOU_FEIFEI_TIMEOUT_MS', ['platforms', 'kuaishouFeifei', 'timeoutMs']), - stringEnv('KUAISHOU_FEIFEI_NOTIFY_URL', ['platforms', 'kuaishouFeifei', 'notifyUrl']), - kuaishouFeifeiProductRulesEnv('KUAISHOU_FEIFEI_PRODUCT_RULES_JSON', [ - 'platforms', - 'kuaishouFeifei', - 'productRules', - ]), stringEnv('AFFILIATE_DASH_BASE_URL', ['platforms', 'affiliateDash', 'baseUrl']), stringEnv('AFFILIATE_DASH_APP_KEY', ['platforms', 'affiliateDash', 'appKey']), stringEnv('AFFILIATE_DASH_APP_SECRET', ['platforms', 'affiliateDash', 'appSecret']), @@ -306,16 +204,6 @@ function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverr } } -function kuaishouFeifeiProductRulesEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { - return { - env, - path: configPath, - read(rawValue) { - const parsed = parseJsonArray(rawValue) - return parsed === null ? null : parsed - }, - } -} function affiliateDashSkuMappingEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index e31f33b6..6e677212 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -159,18 +159,32 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo level: 'info', integrationLevel: 'warn', }, + retention: { + rawPayloadDays: 180, + taskEventDays: 180, + webhookEventDays: 180, + auditLogDays: 365, + }, database: { url: 'postgres://postgres:postgres@postgres:5432/order_site', ssl: false, maxConnections: 10, }, storage: { + mode: 'minio', endpoint: 'http://minio:9000', bucket: 'order-site', accessKeyId: 'minioadmin', secretAccessKey: 'minioadmin', region: 'us-east-1', maxUploadSizeMb: 10, + oss: { + endpoint: '', + bucket: '', + accessKeyId: '', + secretAccessKey: '', + region: 'oss-cn-hangzhou', + }, }, orders: { claimBaseUrl: 'https://order.example.test/#/claim', @@ -197,43 +211,42 @@ function createRuntimeConfig(overrides: Record = {}): RuntimeCo timestampToleranceSeconds: 600, cardsEncoding: 'aes-256-ecb-base64', }, - cloudtentacles: { - baseUrl: 'https://123.207.217.176', - timeoutMs: 5000, - sendSmsPath: '/public/verif_code', - loginPath: '/public/login', - userInfoPath: '/user/info', - assetPath: '/user/get_asset', - permissionPath: '/user/get_permission', - categoriesPath: '/categories/get', - skuListPath: '/sku/list', - skuBuyPath: '/sku/buy', - skuUsePath: '/sku/use', - knapsackPath: '/user/get_knapsack', - vnListPath: '/vn/list', - vnAppointPath: '/vn/appoint', - vnGenerateLoginCodePath: '/vn/generate_login_code', - vnVerifCodePath: '/public/vn_verif_code', - vnVerifyLoginCodePath: '/vn/verify_login_code', - vnBindUrlPath: '/vn/bind_url', - vnBindInfoPath: '/vn/bind_info', - vnBackPath: '/vn/back', - bindUrlTtlSeconds: 600, - bindUrlProbeIntervalSeconds: 30, - bindUrlProbeTimeoutMs: 5000, - bindUrlProbeUserAgent: '', - bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', - bindUrlProbeChartId: '323794', - bindUrlProbeSubChartId: '323794', - bindUrlProbeIdeToken: 'z90Syo', - bindUrlProbeActivityUrl: '', - bindUrlProbeReferer: 'https://gp.qq.com/', - bindUrlProbeExtraCookie: '', - publicKeyPem: '', - clientSource: 'ct-client', - deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', - deviceType: 1, + kuaishouIndustry: { + appKey: '', + appSecret: '', + signSecret: '', + messageSecret: '', + provider: 'kuaishou-industry', + platform: 'kuaishou', + shopId: 'kuaishou-industry', + shopName: '快手行业电子凭证', + version: '1', }, + affiliateDash: { + enabled: true, + baseUrl: '', + appKey: '', + appSecret: '', + callbackSecret: '', + timeoutMs: 10000, + notifyUrl: '', + timestampToleranceSeconds: 300, + preferredMatchEnabled: true, + skuMapping: {}, + }, + }, + worker: { + sms: { + provider: 'mock', + accessKeyId: '', + accessKeySecret: '', + signName: '', + templateCode: '', + codeTtlSeconds: 300, + cooldownSeconds: 60, + dailyLimit: 10, + }, + orderTimelineVisible: false, }, }, overrides, diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 494dd6f2..0bff6af6 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -98,38 +98,6 @@ export function validateRuntimeConfig( } requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 0 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) - requireInteger( - issues, - 'platforms.cloudtentacles.timeoutMs', - config.platforms?.cloudtentacles?.timeoutMs, - { min: 1 }, - ) - if (config.platforms?.kuaishouFeifei) { - requireInteger( - issues, - 'platforms.kuaishouFeifei.timeoutMs', - config.platforms.kuaishouFeifei.timeoutMs, - { min: 1 }, - ) - } - requireInteger( - issues, - 'platforms.cloudtentacles.bindUrlTtlSeconds', - config.platforms?.cloudtentacles?.bindUrlTtlSeconds, - { min: 1 }, - ) - requireInteger( - issues, - 'platforms.cloudtentacles.bindUrlProbeIntervalSeconds', - config.platforms?.cloudtentacles?.bindUrlProbeIntervalSeconds, - { min: 1 }, - ) - requireInteger( - issues, - 'platforms.cloudtentacles.bindUrlProbeTimeoutMs', - config.platforms?.cloudtentacles?.bindUrlProbeTimeoutMs, - { min: 1 }, - ) validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) diff --git a/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.test.ts b/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.test.ts deleted file mode 100644 index c8a250b7..00000000 --- a/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js' - -test('syncKuaishouCloudTaskStateForTask 将 Date 更新时间规范化为 ISO 字符串', async () => { - const calls: Array<{ text: string; params?: unknown[] }> = [] - const executor = async (text: string, params?: unknown[]) => { - calls.push({ text, params }) - return { rows: [] } - } - - await syncKuaishouCloudTaskStateForTask( - { - taskId: 9, - executorKey: 'kuaishou_ct_assisted', - contextJson: { - kuaishouCloudFulfillment: { - ticket: { - status: 'verified', - code: '597172E91CF18417', - }, - binding: { - bindUrl: 'https://example.test/bind', - vnPhone: '14062706492', - roleName: '测试角色', - roleId: '4212063544', - resolvedSourceKey: 'account_test', - }, - dispatch: { - status: 'pending', - }, - consume: { - status: 'pending', - }, - }, - }, - updatedAt: new Date('2026-05-29T09:05:46.658Z'), - }, - executor, - ) - - assert.equal(calls.length, 1) - assert.match(calls[0].text, /INSERT INTO kuaishou_cloud_task_states/) - assert.equal(calls[0].params?.[10], '2026-05-29T09:05:46.658Z') -}) diff --git a/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.ts b/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.ts deleted file mode 100644 index 7def7475..00000000 --- a/apps/backend/src/repositories/kuaishou-cloud-task-state-repo.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { query } from '../db/client.js' -import { TASK_STATUS } from '../domain/task-status.js' -import { maskCode } from '../utils/masking.js' -import { parseTaskContext } from '../utils/task-json.js' -import { normalizeTimestampIso } from '../utils/time.js' -import type { JsonRecord } from '../types/json.js' - -type QueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> -export type KuaishouCloudSourceLoadStat = { - sourceKey: string - activeCount: number - redeemingCount: number -} - -export type KuaishouCloudTaskStateSyncInput = { - taskId: number | string - executorKey?: unknown - contextJson?: unknown - updatedAt?: unknown -} - -export async function syncKuaishouCloudTaskStateForTask( - input: KuaishouCloudTaskStateSyncInput, - executor: QueryExecutor = query, -): Promise { - const taskId = Number(input.taskId || 0) - if (!Number.isFinite(taskId) || taskId <= 0) { - return - } - - if (String(input.executorKey || '').trim() !== 'kuaishou_ct_assisted') { - await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId]) - return - } - - const flow = resolveKuaishouCloudFlow(input.contextJson) - if (!flow) { - await executor('DELETE FROM kuaishou_cloud_task_states WHERE task_id = $1', [taskId]) - return - } - - const ticket = asRecord(flow.ticket) - const binding = asRecord(flow.binding) - const role = asRecord(flow.role) - const dispatch = asRecord(flow.dispatch) - const consume = asRecord(flow.consume) - const roleName = pickFirstNonEmpty([role.name, binding.roleName]) - const roleId = pickFirstNonEmpty([role.rid, binding.roleId]) - const updatedAt = normalizeTimestampIso(input.updatedAt) - - await executor( - ` - INSERT INTO kuaishou_cloud_task_states ( - task_id, - ticket_status, - ticket_code_masked, - bind_url, - vn_phone, - role_name, - role_id, - dispatch_status, - consume_status, - source_key, - updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) - ON CONFLICT (task_id) DO UPDATE - SET - ticket_status = EXCLUDED.ticket_status, - ticket_code_masked = EXCLUDED.ticket_code_masked, - bind_url = EXCLUDED.bind_url, - vn_phone = EXCLUDED.vn_phone, - role_name = EXCLUDED.role_name, - role_id = EXCLUDED.role_id, - dispatch_status = EXCLUDED.dispatch_status, - consume_status = EXCLUDED.consume_status, - source_key = EXCLUDED.source_key, - updated_at = EXCLUDED.updated_at - `, - [ - taskId, - normalizeStatus(ticket.status), - maskCode(ticket.code), - String(binding.bindUrl || '').trim(), - String(binding.vnPhone || '').trim(), - roleName, - roleId, - normalizeStatus(dispatch.status), - normalizeStatus(consume.status), - String(binding.resolvedSourceKey || '').trim(), - updatedAt, - ], - ) -} - -export async function listKuaishouCloudSourceLoadStats( - sourceKeys: unknown[] = [], - executor: QueryExecutor = query, -): Promise { - const candidates = normalizeStringArray(sourceKeys) - if (candidates.length === 0) { - return [] - } - - const activeStatuses = [ - TASK_STATUS.WAITING_BINDING, - TASK_STATUS.ROLE_CONFIRMED, - TASK_STATUS.REDEEMING, - TASK_STATUS.DISPATCHED_PENDING_RETURN, - ] - const result = await executor( - ` - SELECT - kcts.source_key, - COUNT(*) FILTER (WHERE ft.task_status = ANY($2::text[])) AS active_count, - COUNT(*) FILTER (WHERE ft.task_status = $3) AS redeeming_count - FROM fulfillment_tasks ft - JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id - WHERE ft.executor_key = 'kuaishou_ct_assisted' - AND kcts.source_key = ANY($1::text[]) - AND ft.task_status = ANY($2::text[]) - GROUP BY kcts.source_key - `, - [candidates, activeStatuses, TASK_STATUS.REDEEMING], - ) - - return result.rows - .map((row) => { - const source = asRecord(row) - return { - sourceKey: String(source.source_key || '').trim(), - activeCount: Number(source.active_count || 0) || 0, - redeemingCount: Number(source.redeeming_count || 0) || 0, - } - }) - .filter((row) => row.sourceKey) -} - -function resolveKuaishouCloudFlow(contextJson: unknown): JsonRecord | null { - const context = parseTaskContext({ context_json: contextJson }) - const flow = asRecord(context.kuaishouCloudFulfillment) - return Object.keys(flow).length > 0 ? flow : null -} - -function asRecord(value: unknown): JsonRecord { - return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonRecord) : {} -} - -function normalizeStatus(value: unknown): string { - return String(value || 'pending').trim() || 'pending' -} - -function normalizeStringArray(value: unknown): string[] { - if (Array.isArray(value)) { - return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) - } - - if (typeof value === 'string') { - return Array.from( - new Set( - value - .split(',') - .map((item) => item.trim()) - .filter(Boolean), - ), - ) - } - - return [] -} - -function pickFirstNonEmpty(values: unknown[]): string { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} diff --git a/apps/backend/src/repositories/order-repo.test.ts b/apps/backend/src/repositories/order-repo.test.ts index b6d7146d..e7ec4de3 100644 --- a/apps/backend/src/repositories/order-repo.test.ts +++ b/apps/backend/src/repositories/order-repo.test.ts @@ -12,7 +12,7 @@ test('订单列表综合筛选下推到总数和分页查询', async () => { pageSize: 10, keyword: '皮肤店', provider: '91kaquan', - executorKey: 'kuaishou_ct_assisted', + executorKey: 'affiliate_dash', payStatus: 'paid', taskStatus: 'manual_review', dateFrom: '2026-09-01T00:00:00.000Z', @@ -35,7 +35,7 @@ test('订单列表综合筛选下推到总数和分页查询', async () => { '91kaquan', 'paid', 'manual_review', - 'kuaishou_ct_assisted', + 'affiliate_dash', '2026-09-01T00:00:00.000Z', '2026-09-02T23:59:59.999Z', ]) diff --git a/apps/backend/src/repositories/task-list-filter.test.ts b/apps/backend/src/repositories/task-list-filter.test.ts index ce4424e4..6d7647c4 100644 --- a/apps/backend/src/repositories/task-list-filter.test.ts +++ b/apps/backend/src/repositories/task-list-filter.test.ts @@ -6,7 +6,7 @@ import { buildTaskListFilters } from './task-repo.js' test('任务列表综合关键词覆盖任务订单商品与角色字段', () => { const result = buildTaskListFilters({ keyword: '指挥官密钥', - executorKey: 'kuaishou_ct_assisted', + executorKey: 'affiliate_dash', status: 'manual_review', dateFrom: '2026-09-01T00:00:00.000Z', dateTo: '2026-09-02T23:59:59.999Z', @@ -25,7 +25,7 @@ test('任务列表综合关键词覆盖任务订单商品与角色字段', () => assert.match(result.whereClause, /ft\.created_at <= \$5/) assert.deepEqual(result.params, [ '%指挥官密钥%', - 'kuaishou_ct_assisted', + 'affiliate_dash', 'manual_review', '2026-09-01T00:00:00.000Z', '2026-09-02T23:59:59.999Z', diff --git a/apps/backend/src/repositories/task-repo.ts b/apps/backend/src/repositories/task-repo.ts index ad4a2db6..66c172bb 100644 --- a/apps/backend/src/repositories/task-repo.ts +++ b/apps/backend/src/repositories/task-repo.ts @@ -1,7 +1,6 @@ import { query, withTransaction } from '../db/client.js' import type { PoolClient } from 'pg' -import { syncKuaishouCloudTaskStateForTask } from './kuaishou-cloud-task-state-repo.js' import { normalizeTimestampIso } from '../utils/time.js' import type { TaskCreateInput, @@ -12,7 +11,6 @@ import type { import type { TaskListQueryResult, TaskRow } from '../types/repository/rows.js' type TaskQueryExecutor = (text: string, params?: unknown[]) => Promise<{ rows: unknown[] }> -const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei' const AFFILIATE_DASH_EXECUTOR_KEY = 'affiliate_dash' type TaskRuntimeContextRow = { @@ -168,16 +166,6 @@ export async function createTask(input: TaskCreateInput): Promise listAdminCloudtentaclesRecordSources(), { - successMessage: 'ok', - errorMessage: '读取 cloudtentacles 账号列表失败', - scope: '[admin/cloudtentacles-records/sources]', - }), -) - -router.post( - '/cloudtentacles-records', - requireAdminRoles(['admin', 'operator', 'support']), - createJsonHandler( - (req) => - listAdminCloudtentaclesDeliveryRecords( - req.body as AdminCloudtentaclesDeliveryRecordQueryRouteBody, - ), - { - successMessage: 'cloudtentacles 发货记录查询成功', - errorMessage: 'cloudtentacles 发货记录查询失败', - scope: '[admin/cloudtentacles-records]', - }, - ), -) - -router.get( - '/cloudtentacles-records/image', - requireAdminRoles(['admin', 'operator', 'support']), - async (req, res) => { - try { - const imageUrl = normalizeAllowedImageUrl(req.query.url) - const cachedImage = await getCachedRecordImage(imageUrl) - res.setHeader('Content-Type', cachedImage.contentType) - res.setHeader('Cache-Control', 'public, max-age=86400') - res.send(cachedImage.buffer) - } catch (error) { - sendRouteError( - res, - error, - '读取 cloudtentacles 商品图片失败', - '[admin/cloudtentacles-records/image]', - ) - } - }, -) - -export default router - -function normalizeAllowedImageUrl(value: unknown) { - const raw = String(value || '').trim() - if (!raw) { - throw createHttpError('缺少商品图片地址', { - statusCode: 400, - errorCode: 'cloudtentacles_record_image_url_required', - }) - } - - const url = new URL(raw) - if (!['https:', 'http:'].includes(url.protocol) || url.hostname !== 'gp.playinjoy.com') { - throw createHttpError('商品图片地址不在允许范围内', { - statusCode: 400, - errorCode: 'cloudtentacles_record_image_url_not_allowed', - }) - } - - return url.toString() -} - -async function getCachedRecordImage(imageUrl: string) { - const cacheMeta = buildRecordImageCacheMeta(imageUrl) - const cached = await readCachedRecordImage(cacheMeta) - if (cached) { - return cached - } - - const downloaded = await downloadRecordImage(imageUrl) - const filePath = cacheMeta.filePathForContentType(downloaded.contentType) - await fs.mkdir(RECORD_IMAGE_CACHE_DIR, { recursive: true }) - await fs.writeFile(filePath, downloaded.buffer) - return downloaded -} - -async function readCachedRecordImage(cacheMeta: ReturnType) { - for (const candidate of cacheMeta.candidateFilePaths) { - try { - const buffer = await fs.readFile(candidate.filePath) - return { - buffer, - contentType: candidate.contentType, - } - } catch (error) { - const code = String((error as NodeJS.ErrnoException).code || '') - if (code !== 'ENOENT') { - throw error - } - } - } - - return null -} - -async function downloadRecordImage(imageUrl: string) { - const response = await fetch(imageUrl, { - headers: { - accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8', - referer: 'https://gp.playinjoy.com/', - 'user-agent': - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36', - }, - }) - - if (!response.ok) { - throw createHttpError(`商品图片读取失败,HTTP ${response.status}`, { - statusCode: 502, - errorCode: 'cloudtentacles_record_image_fetch_failed', - }) - } - - const contentType = response.headers.get('content-type') || 'image/jpeg' - if (!contentType.startsWith('image/')) { - throw createHttpError('商品图片响应格式无效', { - statusCode: 502, - errorCode: 'cloudtentacles_record_image_invalid_content_type', - }) - } - - return { - buffer: Buffer.from(await response.arrayBuffer()), - contentType: normalizeImageContentType(contentType), - } -} - -function buildRecordImageCacheMeta(imageUrl: string) { - const digest = crypto.createHash('sha256').update(imageUrl).digest('hex') - const candidateTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'] - return { - candidateFilePaths: candidateTypes.map((contentType) => ({ - contentType, - filePath: path.join( - RECORD_IMAGE_CACHE_DIR, - `${digest}.${extensionForContentType(contentType)}`, - ), - })), - filePathForContentType(contentType: string) { - return path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`) - }, - } -} - -function normalizeImageContentType(contentType: string) { - return ( - String(contentType || 'image/jpeg') - .split(';')[0] - ?.trim() - .toLowerCase() || 'image/jpeg' - ) -} - -function extensionForContentType(contentType: string) { - const normalized = normalizeImageContentType(contentType) - if (normalized === 'image/png') return 'png' - if (normalized === 'image/webp') return 'webp' - if (normalized === 'image/gif') return 'gif' - return 'jpg' -} diff --git a/apps/backend/src/routes/admin/dev-mock.ts b/apps/backend/src/routes/admin/dev-mock.ts index b559407a..9fb3f979 100644 --- a/apps/backend/src/routes/admin/dev-mock.ts +++ b/apps/backend/src/routes/admin/dev-mock.ts @@ -3,9 +3,7 @@ import { Router } from 'express' import { buildOpen91QueryMock, createAffiliateDashMockClaim, - createFeifeiMockClaim, createKuaishouIndustryVoucherMockData, - createLewanMockClaim, getDevMockStatus, isDevMockEnabled, } from '../../services/dev-mock/dev-mock-service.js' @@ -41,44 +39,6 @@ router.get( router.use('/dev-mock', requireAdminRoles(['admin', 'operator']), requireDevMockEnabled()) -router.post( - '/dev-mock/lewan', - createJsonHandler((req) => createLewanMockClaim(req.body || {}), { - successMessage: 'lewan mock 已生成', - errorMessage: '生成 lewan mock 失败', - scope: '[admin/dev-mock/lewan]', - audit: (_req, data) => ({ - action: 'dev_mock_create_lewan', - targetType: 'dev_mock', - targetId: String((data as { orderNo?: string })?.orderNo || ''), - data: { - platform: 'lewan', - orderNo: (data as { orderNo?: string })?.orderNo, - step: (data as { step?: string })?.step, - }, - }), - }), -) - -router.post( - '/dev-mock/feifei', - createJsonHandler((req) => createFeifeiMockClaim(req.body || {}), { - successMessage: 'feifei mock 已生成', - errorMessage: '生成 feifei mock 失败', - scope: '[admin/dev-mock/feifei]', - audit: (_req, data) => ({ - action: 'dev_mock_create_feifei', - targetType: 'dev_mock', - targetId: String((data as { orderNo?: string })?.orderNo || ''), - data: { - platform: 'feifei', - orderNo: (data as { orderNo?: string })?.orderNo, - step: (data as { step?: string })?.step, - }, - }), - }), -) - router.post( '/dev-mock/affiliate-dash', createJsonHandler((req) => createAffiliateDashMockClaim(req.body || {}), { diff --git a/apps/backend/src/routes/admin/orders.ts b/apps/backend/src/routes/admin/orders.ts index 4bf40079..8bdddb8b 100644 --- a/apps/backend/src/routes/admin/orders.ts +++ b/apps/backend/src/routes/admin/orders.ts @@ -1,7 +1,7 @@ import { Router } from 'express' import { getAdminOrderDetail, getAdminOrders } from '../../services/admin/admin-read-service.js' -import { resendAdminOrderKuaishouIndustryVoucherCodes } from '../../services/admin/write/kuaishou-cloud-actions.js' +import { resendAdminOrderKuaishouIndustryVoucherCodes } from '../../services/admin/write/kuaishou-industry-actions.js' import { createJsonHandler, requireAdminRoles } from './session.js' import type { AdminOrderRouteParams, AdminOrderRouteQuery } from '../../types/admin/route-inputs.js' diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index 9faae830..ab2e648c 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -1,9 +1,7 @@ import { Router } from 'express' import { requireAdminRoles } from './session.js' -import cloudtentaclesRouter from './platform-config/cloudtentacles.js' import kuaishouIndustryRouter from './platform-config/kuaishou-industry.js' -import kuaishouFeifeiRouter from './platform-config/kuaishou-feifei.js' import ninetyoneRouter from './platform-config/ninetyone.js' import notificationsRouter from './platform-config/notifications.js' import fulfillmentRoutingRouter from './platform-config/fulfillment-routing.js' @@ -15,10 +13,8 @@ const router = Router() router.use('/platform-config', kuaishouIndustryRouter) router.use('/platform-config', requireAdminRoles(['admin'])) router.use('/platform-config', notificationsRouter) -router.use('/platform-config', kuaishouFeifeiRouter) router.use('/platform-config', ninetyoneRouter) router.use('/platform-config', fulfillmentRoutingRouter) -router.use('/platform-config', cloudtentaclesRouter) router.use('/platform-config', affiliateDashRouter) router.use('/platform-config', pddRouter) diff --git a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts b/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts deleted file mode 100644 index 7e21a78e..00000000 --- a/apps/backend/src/routes/admin/platform-config/cloudtentacles.ts +++ /dev/null @@ -1,387 +0,0 @@ -import { Router } from 'express' - -import { - appointAdminCloudtentaclesVirtualNumber, - backAdminCloudtentaclesVirtualNumber, - buyAdminCloudtentaclesSku, - deleteAdminCloudtentaclesSource, - fetchAdminCloudtentaclesVirtualNumberCode, - generateAdminCloudtentaclesLoginCode, - getAdminCloudtentaclesOverrideRules, - getAdminCloudtentaclesAsset, - getAdminCloudtentaclesBindUrl, - getAdminCloudtentaclesCategories, - getAdminCloudtentaclesKnapsack, - getAdminCloudtentaclesSkuList, - listAdminCloudtentaclesSources, - listAdminCloudtentaclesVirtualNumbers, - runAdminCloudtentaclesFullFlow, - sendAdminCloudtentaclesSmsCode, - testAdminCloudtentaclesLogin, - updateAdminCloudtentaclesOverrideRules, - updateAdminCloudtentaclesSourceConfig, - useAdminCloudtentaclesSku, - validateAdminCloudtentaclesSession, - verifyAdminCloudtentaclesLoginCode, -} from '../../../services/admin/platform-config/cloudtentacles/index.js' -import type { - AdminCloudtentaclesCatalogQueryRouteBody, - AdminCloudtentaclesFullFlowRouteBody, - AdminCloudtentaclesOverrideRuleConfigRouteBody, - AdminCloudtentaclesSendSmsCodeRouteBody, - AdminCloudtentaclesSkuBuyRouteBody, - AdminCloudtentaclesSkuUseRouteBody, - AdminCloudtentaclesSourceConfigRouteBody, - AdminCloudtentaclesTestLoginRouteBody, - AdminCloudtentaclesValidateSessionRouteBody, - AdminCloudtentaclesVirtualNumberRouteBody, -} from '../../../types/admin/route-inputs.js' -import { createJsonHandler } from '../session.js' -import type { JsonRecord } from '../../../types/json.js' - -const router = Router() - -router.get( - '/cloudtentacles-source', - createJsonHandler(() => listAdminCloudtentaclesSources(), { - successMessage: 'ok', - errorMessage: '读取 cloudtentacles 履约平台配置失败', - scope: '[admin/platform-config/cloudtentacles-source]', - }), -) - -router.delete( - '/cloudtentacles-source/:sourceKey', - createJsonHandler( - (req) => deleteAdminCloudtentaclesSource(String(req.params.sourceKey || '').trim()), - { - successMessage: 'cloudtentacles 履约平台配置已删除', - errorMessage: '删除 cloudtentacles 履约平台配置失败', - scope: '[admin/platform-config/cloudtentacles-source/:sourceKey]', - audit: (req) => ({ - action: 'platform_cloudtentacles_source_deleted', - targetType: 'platform_config', - targetId: String(req.params.sourceKey || '').trim(), - data: {}, - }), - }, - ), -) - -router.post( - '/cloudtentacles-source', - createJsonHandler( - (req) => - updateAdminCloudtentaclesSourceConfig(req.body as AdminCloudtentaclesSourceConfigRouteBody), - { - successMessage: 'cloudtentacles 履约平台配置已保存', - errorMessage: '保存 cloudtentacles 履约平台配置失败', - scope: '[admin/platform-config/cloudtentacles-source]', - audit: (_req, data) => { - const result = data as JsonRecord - return { - action: 'platform_cloudtentacles_source_updated', - targetType: 'platform_config', - targetId: 'cloudtentacles_source', - data: { - filePath: String(result.filePath || '').trim(), - username: String(result.source?.username || '').trim(), - enabled: Boolean(result.source?.enabled), - }, - } - }, - }, - ), -) - -router.get( - '/cloudtentacles/override-rules', - createJsonHandler(() => getAdminCloudtentaclesOverrideRules(), { - successMessage: 'ok', - errorMessage: '读取 cloudtentacles 覆盖规则失败', - scope: '[admin/platform-config/cloudtentacles/override-rules]', - }), -) - -router.post( - '/cloudtentacles/override-rules', - createJsonHandler( - (req) => - updateAdminCloudtentaclesOverrideRules( - req.body as AdminCloudtentaclesOverrideRuleConfigRouteBody, - ), - { - successMessage: 'cloudtentacles 覆盖规则已保存', - errorMessage: '保存 cloudtentacles 覆盖规则失败', - scope: '[admin/platform-config/cloudtentacles/override-rules]', - audit: (_req, data) => { - const result = data as JsonRecord - return { - action: 'platform_cloudtentacles_override_rules_updated', - targetType: 'platform_config', - targetId: 'cloudtentacles_override_rules', - data: { - filePath: String(result.filePath || '').trim(), - enabled: result.enabled !== false, - ruleCount: Array.isArray(result.rules) ? result.rules.length : 0, - }, - } - }, - }, - ), -) - -router.post( - '/cloudtentacles/send-sms-code', - createJsonHandler( - (req) => sendAdminCloudtentaclesSmsCode(req.body as AdminCloudtentaclesSendSmsCodeRouteBody), - { - successMessage: 'cloudtentacles 短信验证码已发送', - errorMessage: 'cloudtentacles 发送短信验证码失败', - scope: '[admin/platform-config/cloudtentacles/send-sms-code]', - audit: (req, data) => { - const body = req.body as AdminCloudtentaclesSendSmsCodeRouteBody - const result = data as JsonRecord - return { - action: 'platform_cloudtentacles_send_sms_code', - targetType: 'platform_config', - targetId: String(result.username || body.username || '').trim() || 'cloudtentacles', - data: { - baseUrl: result.baseUrl || String(body.baseUrl || '').trim(), - phoneMasked: result.phoneMasked || '', - }, - } - }, - }, - ), -) - -router.post( - '/cloudtentacles/test-login', - createJsonHandler( - (req) => testAdminCloudtentaclesLogin(req.body as AdminCloudtentaclesTestLoginRouteBody), - { - successMessage: 'cloudtentacles 登录测试成功', - errorMessage: 'cloudtentacles 登录测试失败', - scope: '[admin/platform-config/cloudtentacles/test-login]', - audit: (req, data) => { - const body = req.body as AdminCloudtentaclesTestLoginRouteBody - const result = data as JsonRecord - return { - action: 'platform_cloudtentacles_test_login', - targetType: 'platform_config', - targetId: String(result.username || body.username || '').trim() || 'cloudtentacles', - data: { - baseUrl: result.baseUrl || String(body.baseUrl || '').trim(), - permissionCount: Number(result.session?.permissionCount || 0), - }, - } - }, - }, - ), -) - -router.post( - '/cloudtentacles/validate-session', - createJsonHandler( - (req) => - validateAdminCloudtentaclesSession(req.body as AdminCloudtentaclesValidateSessionRouteBody), - { - successMessage: 'cloudtentacles 会话校验成功', - errorMessage: 'cloudtentacles 会话校验失败', - scope: '[admin/platform-config/cloudtentacles/validate-session]', - audit: (_req, data) => { - const result = data as JsonRecord - return { - action: 'platform_cloudtentacles_validate_session', - targetType: 'platform_config', - targetId: 'cloudtentacles_session', - data: { - baseUrl: String(result.baseUrl || '').trim(), - permissionCount: Number(result.session?.permissionCount || 0), - }, - } - }, - }, - ), -) - -router.post( - '/cloudtentacles/asset', - createJsonHandler( - (req) => getAdminCloudtentaclesAsset(req.body as AdminCloudtentaclesCatalogQueryRouteBody), - { - successMessage: 'cloudtentacles 余额查询成功', - errorMessage: 'cloudtentacles 余额查询失败', - scope: '[admin/platform-config/cloudtentacles/asset]', - }, - ), -) - -router.post( - '/cloudtentacles/categories', - createJsonHandler( - (req) => getAdminCloudtentaclesCategories(req.body as AdminCloudtentaclesCatalogQueryRouteBody), - { - successMessage: 'cloudtentacles 分类查询成功', - errorMessage: 'cloudtentacles 分类查询失败', - scope: '[admin/platform-config/cloudtentacles/categories]', - }, - ), -) - -router.post( - '/cloudtentacles/sku/list', - createJsonHandler( - (req) => getAdminCloudtentaclesSkuList(req.body as AdminCloudtentaclesCatalogQueryRouteBody), - { - successMessage: 'cloudtentacles SKU 列表查询成功', - errorMessage: 'cloudtentacles SKU 列表查询失败', - scope: '[admin/platform-config/cloudtentacles/sku/list]', - }, - ), -) - -router.post( - '/cloudtentacles/sku/buy', - createJsonHandler( - (req) => buyAdminCloudtentaclesSku(req.body as AdminCloudtentaclesSkuBuyRouteBody), - { - successMessage: 'cloudtentacles SKU 购买成功', - errorMessage: 'cloudtentacles SKU 购买失败', - scope: '[admin/platform-config/cloudtentacles/sku/buy]', - }, - ), -) - -router.post( - '/cloudtentacles/sku/use', - createJsonHandler( - (req) => useAdminCloudtentaclesSku(req.body as AdminCloudtentaclesSkuUseRouteBody), - { - successMessage: 'cloudtentacles 发货成功', - errorMessage: 'cloudtentacles 发货失败', - scope: '[admin/platform-config/cloudtentacles/sku/use]', - }, - ), -) - -router.post( - '/cloudtentacles/knapsack', - createJsonHandler( - (req) => getAdminCloudtentaclesKnapsack(req.body as AdminCloudtentaclesCatalogQueryRouteBody), - { - successMessage: 'cloudtentacles 背包查询成功', - errorMessage: 'cloudtentacles 背包查询失败', - scope: '[admin/platform-config/cloudtentacles/knapsack]', - }, - ), -) - -router.post( - '/cloudtentacles/vn/list', - createJsonHandler( - (req) => - listAdminCloudtentaclesVirtualNumbers(req.body as AdminCloudtentaclesVirtualNumberRouteBody), - { - successMessage: 'cloudtentacles 虚拟号列表查询成功', - errorMessage: 'cloudtentacles 虚拟号列表查询失败', - scope: '[admin/platform-config/cloudtentacles/vn/list]', - }, - ), -) - -router.post( - '/cloudtentacles/vn/appoint', - createJsonHandler( - (req) => - appointAdminCloudtentaclesVirtualNumber( - req.body as AdminCloudtentaclesVirtualNumberRouteBody, - ), - { - successMessage: 'cloudtentacles 虚拟号申请成功', - errorMessage: 'cloudtentacles 虚拟号申请失败', - scope: '[admin/platform-config/cloudtentacles/vn/appoint]', - }, - ), -) - -router.post( - '/cloudtentacles/vn/generate-login-code', - createJsonHandler( - (req) => - generateAdminCloudtentaclesLoginCode(req.body as AdminCloudtentaclesVirtualNumberRouteBody), - { - successMessage: 'cloudtentacles 登录码生成成功', - errorMessage: 'cloudtentacles 登录码生成失败', - scope: '[admin/platform-config/cloudtentacles/vn/generate-login-code]', - }, - ), -) - -router.post( - '/cloudtentacles/vn/fetch-code', - createJsonHandler( - (req) => - fetchAdminCloudtentaclesVirtualNumberCode( - req.body as AdminCloudtentaclesVirtualNumberRouteBody, - ), - { - successMessage: 'cloudtentacles 验证码获取成功', - errorMessage: 'cloudtentacles 验证码获取失败', - scope: '[admin/platform-config/cloudtentacles/vn/fetch-code]', - }, - ), -) - -router.post( - '/cloudtentacles/vn/verify-code', - createJsonHandler( - (req) => - verifyAdminCloudtentaclesLoginCode(req.body as AdminCloudtentaclesVirtualNumberRouteBody), - { - successMessage: 'cloudtentacles 登录码校验成功', - errorMessage: 'cloudtentacles 登录码校验失败', - scope: '[admin/platform-config/cloudtentacles/vn/verify-code]', - }, - ), -) - -router.post( - '/cloudtentacles/vn/bind-url', - createJsonHandler( - (req) => getAdminCloudtentaclesBindUrl(req.body as AdminCloudtentaclesVirtualNumberRouteBody), - { - successMessage: 'cloudtentacles 兑换链接获取成功', - errorMessage: 'cloudtentacles 兑换链接获取失败', - scope: '[admin/platform-config/cloudtentacles/vn/bind-url]', - }, - ), -) - -router.post( - '/cloudtentacles/vn/back', - createJsonHandler( - (req) => - backAdminCloudtentaclesVirtualNumber(req.body as AdminCloudtentaclesVirtualNumberRouteBody), - { - successMessage: 'cloudtentacles 号码退还成功', - errorMessage: 'cloudtentacles 号码退还失败', - scope: '[admin/platform-config/cloudtentacles/vn/back]', - }, - ), -) - -router.post( - '/cloudtentacles/debug/full-flow', - createJsonHandler( - (req) => runAdminCloudtentaclesFullFlow(req.body as AdminCloudtentaclesFullFlowRouteBody), - { - successMessage: 'cloudtentacles 完整调试流程执行成功', - errorMessage: 'cloudtentacles 完整调试流程执行失败', - scope: '[admin/platform-config/cloudtentacles/debug/full-flow]', - }, - ), -) - -export default router diff --git a/apps/backend/src/routes/admin/platform-config/kuaishou-feifei.ts b/apps/backend/src/routes/admin/platform-config/kuaishou-feifei.ts deleted file mode 100644 index 3b9917bf..00000000 --- a/apps/backend/src/routes/admin/platform-config/kuaishou-feifei.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { Router } from 'express' - -import { - createAdminKuaishouFeifeiTestOrder, - getAdminKuaishouFeifeiConfig, - listAdminKuaishouFeifeiProducts, - matchAdminKuaishouFeifeiProduct, - queryAdminKuaishouFeifeiTestOrder, - syncAdminKuaishouFeifeiProductRules, - updateAdminKuaishouFeifeiConfig, -} from '../../../services/admin/platform-config/kuaishou-feifei-service.js' -import { createJsonHandler } from '../session.js' -import type { JsonRecord } from '../../../types/json.js' - -const router = Router() - -router.get( - '/kuaishou-feifei', - createJsonHandler(() => getAdminKuaishouFeifeiConfig(), { - successMessage: 'ok', - errorMessage: '读取 kuaishou-feifei 配置失败', - scope: '[admin/platform-config/kuaishou-feifei]', - }), -) - -router.post( - '/kuaishou-feifei', - createJsonHandler((req) => updateAdminKuaishouFeifeiConfig(req.body as JsonRecord), { - successMessage: 'kuaishou-feifei 配置已保存', - errorMessage: '保存 kuaishou-feifei 配置失败', - scope: '[admin/platform-config/kuaishou-feifei]', - audit: (_req, data) => { - const result = data as JsonRecord - const source = result.source as JsonRecord | undefined - return { - action: 'platform_kuaishou_feifei_config_updated', - targetType: 'platform_config', - targetId: 'kuaishou_feifei', - data: { - filePath: String(result.filePath || '').trim(), - enabled: source?.enabled !== false, - ruleCount: Array.isArray(source?.productRules) ? source.productRules.length : 0, - }, - } - }, - }), -) - -router.post( - '/kuaishou-feifei/match', - createJsonHandler((req) => matchAdminKuaishouFeifeiProduct(req.body as JsonRecord), { - successMessage: 'ok', - errorMessage: '匹配 kuaishou-feifei 商品失败', - scope: '[admin/platform-config/kuaishou-feifei/match]', - }), -) - -router.post( - '/kuaishou-feifei/products', - createJsonHandler((req) => listAdminKuaishouFeifeiProducts(req.body as JsonRecord), { - successMessage: 'ok', - errorMessage: '查询 kuaishou-feifei 商品失败', - scope: '[admin/platform-config/kuaishou-feifei/products]', - }), -) - -router.post( - '/kuaishou-feifei/sync-products', - createJsonHandler((req) => syncAdminKuaishouFeifeiProductRules(req.body as JsonRecord), { - successMessage: 'kuaishou-feifei 商品映射已同步', - errorMessage: '同步 kuaishou-feifei 商品映射失败', - scope: '[admin/platform-config/kuaishou-feifei/sync-products]', - audit: (_req, data) => { - const result = data as JsonRecord - const sync = result.sync as JsonRecord | undefined - - return { - action: 'platform_kuaishou_feifei_products_synced', - targetType: 'platform_config', - targetId: 'kuaishou_feifei', - data: { - productCount: Number(sync?.productCount || 0) || 0, - ruleCount: Number(sync?.ruleCount || 0) || 0, - status: String(sync?.status || '').trim(), - }, - } - }, - }), -) - -router.post( - '/kuaishou-feifei/test-order', - createJsonHandler((req) => createAdminKuaishouFeifeiTestOrder(req.body as JsonRecord), { - successMessage: 'kuaishou-feifei 测试单已创建', - errorMessage: '创建 kuaishou-feifei 测试单失败', - scope: '[admin/platform-config/kuaishou-feifei/test-order]', - audit: (_req, data) => { - const result = data as JsonRecord - - return { - action: 'platform_kuaishou_feifei_test_order_created', - targetType: 'platform_config', - targetId: 'kuaishou_feifei', - data: { - orderNo: String(result.orderNo || '').trim(), - platformOrderNo: String(result.platformOrderNo || '').trim(), - productCode: String(result.productCode || '').trim(), - rechargeStatus: Number(result.rechargeStatus || 0) || 0, - }, - } - }, - }), -) - -router.post( - '/kuaishou-feifei/query-order', - createJsonHandler((req) => queryAdminKuaishouFeifeiTestOrder(req.body as JsonRecord), { - successMessage: 'ok', - errorMessage: '查询 kuaishou-feifei 订单失败', - scope: '[admin/platform-config/kuaishou-feifei/query-order]', - }), -) - -export default router diff --git a/apps/backend/src/routes/admin/tasks.ts b/apps/backend/src/routes/admin/tasks.ts index d985e709..d4ada657 100644 --- a/apps/backend/src/routes/admin/tasks.ts +++ b/apps/backend/src/routes/admin/tasks.ts @@ -5,13 +5,8 @@ import { getAdminTaskDetail, getAdminTasks } from '../../services/admin/admin-re import { closeAdminTask } from '../../services/admin/write/task-actions.js' import { consumeAdminTaskKuaishouIndustryVoucher, - dispatchAdminTaskKuaishouCloudFulfillment, - prepareAdminTaskKuaishouCloudFulfillment, - rebindAdminTaskKuaishouCloudRole, - refreshAdminTaskKuaishouCloudRoleInfo, resendAdminTaskKuaishouIndustryVoucherCode, - returnNumberAdminTaskKuaishouCloudFulfillment, -} from '../../services/admin/write/kuaishou-cloud-actions.js' +} from '../../services/admin/write/kuaishou-industry-actions.js' import { createJsonHandler, requireAdminRoles } from './session.js' import type { AdminTaskKuaishouIndustryConsumeRouteBody, @@ -60,92 +55,6 @@ router.post( }), ) -router.post( - '/tasks/:taskId/kuaishou-cloud/prepare', - requireAdminRoles(['admin', 'operator']), - createJsonHandler( - (req) => prepareAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null), - { - successMessage: '绑定资源已准备完成', - errorMessage: '准备绑定资源失败', - scope: '[admin/tasks/:taskId/kuaishou-cloud/prepare]', - audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_prepare', req, data), - }, - ), -) - -router.post( - '/tasks/:taskId/kuaishou-cloud/refresh-role-info', - requireAdminRoles(['admin', 'operator', 'support']), - createJsonHandler( - (req) => refreshAdminTaskKuaishouCloudRoleInfo(getTaskId(req), req.adminSession || null), - { - successMessage: '角色信息已刷新', - errorMessage: '刷新角色信息失败', - scope: '[admin/tasks/:taskId/kuaishou-cloud/refresh-role-info]', - audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_role_info_refreshed', req, data), - }, - ), -) - -router.post( - '/tasks/:taskId/kuaishou-cloud/rebind-role', - requireAdminRoles(['admin', 'operator', 'support']), - createJsonHandler( - (req) => rebindAdminTaskKuaishouCloudRole(getTaskId(req), req.adminSession || null), - { - // 旧号已回收时会自动跳过退号并新取号出链 - successMessage: '换绑完成:已生成新的绑定链接(原领取链接可继续使用)', - errorMessage: '换绑角色失败', - scope: '[admin/tasks/:taskId/kuaishou-cloud/rebind-role]', - audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_rebind_role', req, data), - }, - ), -) - -router.post( - '/tasks/:taskId/kuaishou-cloud/dispatch', - requireAdminRoles(['admin', 'operator']), - createJsonHandler( - (req) => dispatchAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null), - { - successMessage: '已完成绑定确认并发货', - errorMessage: '执行发货失败', - scope: '[admin/tasks/:taskId/kuaishou-cloud/dispatch]', - audit: (req, data) => { - const result = data as AdminTaskActionResponse - - return { - action: 'task_kuaishou_cloud_dispatch', - targetType: 'task', - targetId: String(getTaskId(req)), - data: { - taskId: result.task.taskId, - taskNo: result.task.taskNo, - status: result.task.status, - deliveryStatus: result.task.deliveryStatus, - }, - } - }, - }, - ), -) - -router.post( - '/tasks/:taskId/kuaishou-cloud/return-number', - requireAdminRoles(['admin', 'operator']), - createJsonHandler( - (req) => - returnNumberAdminTaskKuaishouCloudFulfillment(getTaskId(req), req.adminSession || null), - { - successMessage: '号码已退还', - errorMessage: '退还号码失败', - scope: '[admin/tasks/:taskId/kuaishou-cloud/return-number]', - audit: (req, data) => buildTaskAudit('task_kuaishou_cloud_return_number', req, data), - }, - ), -) - router.post( '/tasks/:taskId/kuaishou-industry/resend-code', requireAdminRoles(['admin', 'operator', 'support']), diff --git a/apps/backend/src/routes/claims.ts b/apps/backend/src/routes/claims.ts index bb1cec14..0b96edfb 100644 --- a/apps/backend/src/routes/claims.ts +++ b/apps/backend/src/routes/claims.ts @@ -2,10 +2,7 @@ import { Router } from 'express' import { createRateLimitMiddleware, getParamRateLimitKey } from '../middleware/rate-limit.js' import { - confirmKuaishouCloudClaimRole, getKuaishouCloudClaimDetail, - rebindKuaishouCloudClaimRole, - redeemKuaishouCloudClaim, submitAffiliateDashClaim, submitClaimUid, } from '../services/claim/kuaishou-cloud-claim-service.js' @@ -24,12 +21,6 @@ const claimWriteRateLimit = createRateLimitMiddleware({ max: 30, key: getParamRateLimitKey('token'), }) -const claimRebindRateLimit = createRateLimitMiddleware({ - scope: 'claim:rebind', - windowMs: 60_000, - max: 3, - key: getParamRateLimitKey('token'), -}) router.get( '/:token', @@ -60,36 +51,6 @@ router.post( }), ) -router.post( - '/:token/kuaishou-cloud/confirm-role', - claimWriteRateLimit, - createRouteHandler((req) => confirmKuaishouCloudClaimRole(req.params.token), { - successMessage: '角色已确认', - errorMessage: '确认角色失败', - scope: '[claims/:token/kuaishou-cloud/confirm-role]', - }), -) - -router.post( - '/:token/kuaishou-cloud/rebind-role', - claimRebindRateLimit, - createRouteHandler((req) => rebindKuaishouCloudClaimRole(req.params.token), { - successMessage: '角色换绑资源已准备完成', - errorMessage: '换绑角色失败', - scope: '[claims/:token/kuaishou-cloud/rebind-role]', - }), -) - -router.post( - '/:token/kuaishou-cloud/redeem', - claimWriteRateLimit, - createRouteHandler((req) => redeemKuaishouCloudClaim(req.params.token), { - successMessage: '兑换请求已提交', - errorMessage: '兑换失败', - scope: '[claims/:token/kuaishou-cloud/redeem]', - }), -) - router.use((req, res) => { res.status(404).json(buildNotFoundPayload(req)) }) diff --git a/apps/backend/src/routes/kuaishou-feifei.ts b/apps/backend/src/routes/kuaishou-feifei.ts deleted file mode 100644 index a82b3153..00000000 --- a/apps/backend/src/routes/kuaishou-feifei.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Router } from 'express' - -import { createRateLimitMiddleware } from '../middleware/rate-limit.js' -import { handleKuaishouFeifeiNotify } from '../services/platforms/kuaishou-feifei/notify-service.js' -import { createRequestId, logIntegration } from '../utils/logger.js' -import { sendRouteError } from '../utils/http.js' - -const router = Router() - -const notifyRateLimit = createRateLimitMiddleware({ - scope: 'kuaishouFeifeiNotify', - windowMs: 60_000, - max: 300, -}) - -router.post('/notify', notifyRateLimit, async (req, res) => { - const requestId = createRequestId('ffn') - const startedAt = Date.now() - - logIntegration('[kuaishou-feifei/notify]', '收到 kuaishou-feifei 订单通知', { - requestId, - method: req.method, - originalUrl: req.originalUrl, - ip: req.ip, - headers: { - 'content-type': req.headers['content-type'], - 'x-app-key': req.headers['x-app-key'], - 'x-timestamp': req.headers['x-timestamp'], - 'x-sign': req.headers['x-sign'], - }, - body: req.body, - rawBody: req.rawBody, - }) - - try { - const result = await handleKuaishouFeifeiNotify({ - body: req.body, - headers: req.headers, - rawBody: req.rawBody, - }) - logIntegration('[kuaishou-feifei/notify]', '订单通知处理完成', { - requestId, - durationMs: Date.now() - startedAt, - result, - }) - res.status(200).json({ code: 0 }) - } catch (error) { - logIntegration( - '[kuaishou-feifei/notify]', - '订单通知处理失败', - { - requestId, - durationMs: Date.now() - startedAt, - error, - }, - { level: 'error' }, - ) - sendRouteError(res, error, 'kuaishou-feifei 通知处理失败', '[kuaishou-feifei/notify]') - } -}) - -export default router diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 0cb79051..4bf22b38 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -19,7 +19,6 @@ import { listKuaishouIndustryVouchersByOids, listKuaishouIndustryVouchersByTaskId, } from '../../repositories/kuaishou-industry-voucher-repo.js' -import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js' import { createHttpError } from '../../utils/http.js' import { formatFenToAmount, normalizeFen } from '../../utils/money.js' @@ -31,11 +30,7 @@ import { } from './admin-query-utils.js' import { canCompleteManualDispatchTaskStatus, - canDispatchKuaishouCloudFulfillmentStatus, canMarkManualReviewTaskStatus, - canPrepareKuaishouCloudFulfillmentStatus, - canRebindKuaishouCloudRoleStatus, - canReturnKuaishouCloudFulfillmentStatus, canRetryTaskStatus, } from '../../domain/task-status.js' import { @@ -399,30 +394,11 @@ export async function getAdminTaskDetail( viewerContext.canManageTaskLifecycle && isManualDispatchTask(task) && canCompleteManualDispatchTaskStatus(task.task_status), - canPrepareKuaishouCloudFulfillment: - viewerContext.canManageTaskLifecycle && - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && - String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() === 'pending' && - String(kuaishouCloudFulfillment?.returnNumber.status || 'pending').trim() === 'pending' && - canPrepareKuaishouCloudFulfillmentStatus(task.task_status), - canRefreshKuaishouCloudRoleInfo: - viewerContext.canOperateAssistedTask && - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && - Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, - canRebindKuaishouCloudRole: - viewerContext.canOperateAssistedTask && - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && - canRebindKuaishouCloudRoleStatus(task.task_status) && - String(kuaishouCloudFulfillment?.dispatch.status || 'pending').trim() !== 'success' && - Number(kuaishouCloudFulfillment?.binding.vnId || 0) > 0, - canDispatchKuaishouCloudFulfillment: - viewerContext.canManageTaskLifecycle && - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && - canDispatchKuaishouCloudFulfillmentStatus(task.task_status), - canReturnKuaishouCloudFulfillment: - viewerContext.canManageTaskLifecycle && - String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' && - canReturnKuaishouCloudFulfillmentStatus(task.task_status), + canPrepareKuaishouCloudFulfillment: false, + canRefreshKuaishouCloudRoleInfo: false, + canRebindKuaishouCloudRole: false, + canDispatchKuaishouCloudFulfillment: false, + canReturnKuaishouCloudFulfillment: false, canResendKuaishouIndustryVoucherCode: viewerContext.canOperateKuaishouIndustryVoucher && hasKuaishouIndustryVoucher, canConsumeKuaishouIndustryVoucher: @@ -517,17 +493,7 @@ function resolveKuaishouIndustryVoucherForTask( } function buildCloudSourceLabelMap() { - const labelMap = new Map() - - for (const source of listCloudtentaclesSources().sources) { - const key = String(source.key || '').trim() - if (!key) continue - - const label = String(source.label || source.username || key).trim() || key - labelMap.set(key, label) - } - - return labelMap + return new Map() } export async function getAdminTaskScreenshotPath( diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/context.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.test.ts deleted file mode 100644 index 702dc17b..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/context.test.ts +++ /dev/null @@ -1,74 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - hasCloudtentaclesCredentialContextChanged, - isPlainObject, - pickFirstNonEmpty, - resolveCloudtentaclesAdminContext, -} from './context.js' - -test('pickFirstNonEmpty returns first trimmed non-empty string value', () => { - assert.equal(pickFirstNonEmpty(['', ' ', ' value ', 'fallback']), 'value') - assert.equal(pickFirstNonEmpty([null, undefined, 0, '']), '') -}) - -test('isPlainObject only accepts non-array objects', () => { - assert.equal(isPlainObject({ a: 1 }), true) - assert.equal(isPlainObject([]), false) - assert.equal(isPlainObject(null), false) - assert.equal(isPlainObject('x'), false) -}) - -test('resolveCloudtentaclesAdminContext merges payload source and persisted session', () => { - const context = resolveCloudtentaclesAdminContext( - { - token: ' token-1 ', - deviceType: 3, - }, - { - savedSource: { - baseUrl: ' https://saved.example.com ', - deviceId: ' device-saved ', - deviceType: 1, - }, - persistedSession: { - token: 'token-persisted', - deviceId: 'device-persisted', - deviceType: 2, - }, - }, - ) - - assert.deepEqual(context, { - sourceKey: 'default', - baseUrl: 'https://saved.example.com', - token: 'token-1', - deviceId: 'device-saved', - deviceType: 3, - }) -}) - -test('hasCloudtentaclesCredentialContextChanged detects normalized credential changes', () => { - assert.equal( - hasCloudtentaclesCredentialContextChanged( - { - baseUrl: ' https://a ', - username: 'u1', - phone: '13812345678', - deviceId: 'd1', - deviceType: 1, - }, - { baseUrl: 'https://a', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 }, - ), - false, - ) - - assert.equal( - hasCloudtentaclesCredentialContextChanged( - { baseUrl: 'https://a', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 }, - { baseUrl: 'https://b', username: 'u1', phone: '13812345678', deviceId: 'd1', deviceType: 1 }, - ), - true, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts deleted file mode 100644 index 263c831a..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/context.ts +++ /dev/null @@ -1,57 +0,0 @@ -export function pickFirstNonEmpty(values: unknown[]) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -} - -export function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -import { getCloudtentaclesSourceByKey } from '../../../platforms/cloudtentacles/source-config-service.js' -import { getCloudtentaclesSessionStateByKey } from '../../../platforms/cloudtentacles/session-state-service.js' -import type { JsonObject } from '../../../../types/json.js' -import { - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from '../../../platforms/cloudtentacles/defaults.js' - -export function resolveCloudtentaclesAdminContext( - payload: JsonObject = {}, - options: JsonObject = {}, -) { - const sourceKey = String(payload.sourceKey || options.sourceKey || 'default').trim() - const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {} - const persistedSession = - options.persistedSession || getCloudtentaclesSessionStateByKey(sourceKey) || {} - const defaultBaseUrl = String(options.defaultBaseUrl || 'https://123.207.217.176').trim() - return { - sourceKey, - baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]), - token: pickFirstNonEmpty([payload.token, persistedSession.token]), - deviceId: normalizeCloudtentaclesDeviceId( - pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, persistedSession.deviceId]), - ), - deviceType: normalizeCloudtentaclesDeviceType( - payload.deviceType ?? savedSource.deviceType ?? persistedSession.deviceType, - ), - } -} - -export function hasCloudtentaclesCredentialContextChanged( - current: JsonObject = {}, - next: JsonObject = {}, -) { - return ( - String(current.baseUrl || '').trim() !== String(next.baseUrl || '').trim() || - String(current.username || '').trim() !== String(next.username || '').trim() || - String(current.phone || '').trim() !== String(next.phone || '').trim() || - String(current.deviceId || '').trim() !== String(next.deviceId || '').trim() || - Number(current.deviceType || 0) !== Number(next.deviceType || 0) - ) -} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts deleted file mode 100644 index 00aeeb1c..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/index.ts +++ /dev/null @@ -1,883 +0,0 @@ -import type { JsonObject } from '../../../../types/json.js' -import { - getCloudtentaclesSourceByKey, - getCloudtentaclesSourcesFilePath, - listCloudtentaclesSources, - saveCloudtentaclesSourceByKey, - saveCloudtentaclesSourcesList, - deleteCloudtentaclesSourceByKey, -} from '../../../platforms/cloudtentacles/source-config-service.js' -import { - clearCloudtentaclesSessionStateByKey, - deleteCloudtentaclesSessionStateByKey, - getCloudtentaclesSessionFilePath, - getCloudtentaclesSessionStateByKey, - saveCloudtentaclesSessionStateByKey, - getAllCloudtentaclesSessionStates, - pruneCloudtentaclesSessionStates, -} from '../../../platforms/cloudtentacles/session-state-service.js' -import { - loginCloudtentaclesSession, - sendCloudtentaclesSmsCode, - validateCloudtentaclesSession, -} from '../../../platforms/cloudtentacles/session-service.js' -import { - buyCloudtentaclesSku, - getCloudtentaclesAsset, - getCloudtentaclesCategories, - listCloudtentaclesSku, - useCloudtentaclesSku, -} from '../../../platforms/cloudtentacles/catalog-service.js' -import { getCloudtentaclesKnapsack } from '../../../platforms/cloudtentacles/knapsack-service.js' -import { listCloudtentaclesDeliveryRecords } from '../../../platforms/cloudtentacles/record-service.js' -import { - appointCloudtentaclesVirtualNumber, - backCloudtentaclesVirtualNumber, - fetchCloudtentaclesVirtualNumberCode, - generateCloudtentaclesLoginCode, - getCloudtentaclesBindUrl, - listCloudtentaclesVirtualNumbers, - verifyCloudtentaclesLoginCode, -} from '../../../platforms/cloudtentacles/virtual-number-service.js' -import { runCloudtentaclesFullDebugFlow } from '../../../platforms/cloudtentacles/debug-flow-service.js' -import { - getCloudtentaclesOverrideRuleConfig, - getCloudtentaclesOverrideRuleFilePath, - saveCloudtentaclesOverrideRuleConfig, -} from '../../../order/cloudtentacles-override-rule-service.js' -import { hasCloudtentaclesCredentialContextChanged, pickFirstNonEmpty } from './context.js' -import { - buildAdminCloudtentaclesLoginResult, - buildAdminCloudtentaclesPersistedSessionPayload, - buildAdminCloudtentaclesValidateResult, - resolveAdminCloudtentaclesCredentialPayload, - resolveAdminCloudtentaclesSessionPayload, -} from './payloads.js' -import { normalizeAdminCloudtentaclesSourceConfigPayload } from './writes.js' -import { - mapAdminCloudtentaclesSession, - mapAdminCloudtentaclesSourceConfig, - maskPhone, -} from './mappers.js' -import { createHttpError } from '../../../../utils/http.js' -import { query as dbQuery } from '../../../../db/client.js' -import { parseJsonObject } from '../../../../utils/task-json.js' - -import type { - AdminCloudtentaclesCatalogQueryInput, - AdminCloudtentaclesDeliveryRecordQueryInput, - AdminCloudtentaclesFullFlowInput, - AdminCloudtentaclesOverrideRuleConfigInput, - AdminCloudtentaclesSendSmsCodeInput, - AdminCloudtentaclesSkuBuyInput, - AdminCloudtentaclesSkuUseInput, - AdminCloudtentaclesSourceConfigInput, - AdminCloudtentaclesTestLoginInput, - AdminCloudtentaclesValidateSessionInput, - AdminCloudtentaclesVirtualNumberInput, -} from '../../../../types/admin/write-inputs.js' - -type LocalCloudtentaclesTask = { - taskId: number - taskNo: string - platformOrderId: string - orderShopId: string - orderShopName: string - taskStatus: string - deliveryStatus: string - resultCode: string - resultMessage: string - createdAt: string - updatedAt: string - skuCode: string - skuName: string - sourceKey: string - vnId: number - vnPhone: string - roleName: string - roleId: string - localDispatchStatus: string - localDispatchAt: string - consumeShopId: string - consumeShopName: string -} - -type LocalCloudtentaclesTaskRow = { - id: number - task_no: string - platform_order_id: string - shop_id: string - shop_name: string - task_status: string - delivery_status: string - result_code: string - result_message: string - context_json: unknown - created_at: string - updated_at: string - sku_code?: string - sku_name?: string - kuaishou_source_key?: string - kuaishou_vn_phone?: string - kuaishou_role_name?: string - kuaishou_role_id?: string - kuaishou_dispatch_status?: string - kuaishou_consume_status?: string -} - -export function listAdminCloudtentaclesSources() { - const list = listCloudtentaclesSources() - const sessions = getAllCloudtentaclesSessionStates() - - const sources = Array.isArray(list.sources) - ? list.sources.map((s) => mapAdminCloudtentaclesSourceConfig(s)) - : [] - - const mappedSessions: JsonObject = {} - for (const [key, session] of Object.entries(sessions.sessions || {})) { - mappedSessions[key] = mapAdminCloudtentaclesSession(session) - } - - return { - filePath: getCloudtentaclesSourcesFilePath(), - sessionFilePath: getCloudtentaclesSessionFilePath(), - enabled: list.enabled, - sources, - sessions: mappedSessions, - } -} - -export function listAdminCloudtentaclesRecordSources() { - const list = listCloudtentaclesSources() - const sessions = getAllCloudtentaclesSessionStates() - const sources = Array.isArray(list.sources) ? list.sources : [] - - return { - sources: sources.map((source) => { - const key = String(source.key || '').trim() - const session = (sessions.sessions?.[key] || {}) as JsonObject - return { - key, - label: String(source.label || source.username || key || '未命名账号').trim(), - enabled: source.enabled !== false, - hasToken: Boolean(String(session.token || '').trim()), - loggedInAt: String(session.loggedInAt || '').trim(), - } - }), - } -} - -export function getAdminCloudtentaclesOverrideRules() { - return { - filePath: getCloudtentaclesOverrideRuleFilePath(), - ...getCloudtentaclesOverrideRuleConfig(), - } -} - -export async function updateAdminCloudtentaclesOverrideRules( - payload: AdminCloudtentaclesOverrideRuleConfigInput = {}, -) { - return { - filePath: getCloudtentaclesOverrideRuleFilePath(), - ...(await saveCloudtentaclesOverrideRuleConfig(payload)), - } -} - -export async function updateAdminCloudtentaclesSourceConfig( - payload: AdminCloudtentaclesSourceConfigInput = {}, -) { - // 批量保存:payload.sources 是数组时替换整份账号列表。 - if (Array.isArray(payload.sources)) { - const normalizedList = payload.sources.map((item) => - normalizeAdminCloudtentaclesSourceConfigPayload(item), - ) - const savedList = await saveCloudtentaclesSourcesList({ - enabled: payload.enabled !== false, - sources: normalizedList, - }) - const sessions = await pruneCloudtentaclesSessionStates(normalizedList.map((item) => item.key)) - - const sources = Array.isArray(savedList.sources) - ? savedList.sources.map((s) => mapAdminCloudtentaclesSourceConfig(s)) - : [] - const mappedSessions: JsonObject = {} - for (const [key, session] of Object.entries(sessions.sessions || {})) { - mappedSessions[key] = mapAdminCloudtentaclesSession(session) - } - - return { - filePath: getCloudtentaclesSourcesFilePath(), - sessionFilePath: getCloudtentaclesSessionFilePath(), - enabled: savedList.enabled, - sources, - sessions: mappedSessions, - } - } - - // 单账号保存。 - const sourceKey = String(payload.sourceKey || '').trim() || 'default' - const current = getCloudtentaclesSourceByKey(sourceKey) - const normalized = normalizeAdminCloudtentaclesSourceConfigPayload({ - ...payload, - sourceKey, - }) - const saved = await saveCloudtentaclesSourceByKey(sourceKey, normalized) - const shouldClearSession = hasCloudtentaclesCredentialContextChanged(current || undefined, saved) - const session = shouldClearSession - ? (await clearCloudtentaclesSessionStateByKey(sourceKey), - getCloudtentaclesSessionStateByKey(sourceKey)) - : getCloudtentaclesSessionStateByKey(sourceKey) - - return { - filePath: getCloudtentaclesSourcesFilePath(), - sessionFilePath: getCloudtentaclesSessionFilePath(), - source: mapAdminCloudtentaclesSourceConfig(saved), - session: mapAdminCloudtentaclesSession(session || undefined), - } -} - -export async function deleteAdminCloudtentaclesSource(sourceKey: string) { - const key = String(sourceKey || '').trim() - if (!key) { - throw createHttpError('cloudtentacles sourceKey 不能为空', { - statusCode: 400, - errorCode: 'cloudtentacles_source_key_required', - }) - } - await deleteCloudtentaclesSessionStateByKey(key) - await deleteCloudtentaclesSourceByKey(key) -} - -export async function sendAdminCloudtentaclesSmsCode( - payload: AdminCloudtentaclesSendSmsCodeInput = {}, -) { - const result = await sendCloudtentaclesSmsCode( - resolveAdminCloudtentaclesCredentialPayload(payload), - ) - - return { - ...result, - phoneMasked: maskPhone(result.phone), - } -} - -export async function testAdminCloudtentaclesLogin( - payload: AdminCloudtentaclesTestLoginInput = {}, -) { - const sourceKey = String(payload.sourceKey || '').trim() || 'default' - const savedSource = getCloudtentaclesSourceByKey(sourceKey) || {} - const credentialContext = resolveAdminCloudtentaclesCredentialPayload(payload, { savedSource }) - const session = await loginCloudtentaclesSession({ - ...credentialContext, - code: String(payload.code || '').trim(), - }) - const savedSession = await saveCloudtentaclesSessionStateByKey( - sourceKey, - buildAdminCloudtentaclesPersistedSessionPayload(session, { - deviceId: credentialContext.deviceId, - deviceType: credentialContext.deviceType, - }), - ) - - return buildAdminCloudtentaclesLoginResult(session, savedSession, sourceKey) -} - -export async function validateAdminCloudtentaclesSession( - payload: AdminCloudtentaclesValidateSessionInput = {}, -) { - const sourceKey = String(payload.sourceKey || '').trim() || 'default' - const savedSource: JsonObject = getCloudtentaclesSourceByKey(sourceKey) || {} - const persistedSession: JsonObject = getCloudtentaclesSessionStateByKey(sourceKey) || {} - const sessionContext = resolveAdminCloudtentaclesSessionPayload(payload, { - savedSource, - persistedSession, - }) - const session = await validateCloudtentaclesSession(sessionContext) - const savedSession = await saveCloudtentaclesSessionStateByKey( - sourceKey, - buildAdminCloudtentaclesPersistedSessionPayload(session, { - username: pickFirstNonEmpty([persistedSession.username, savedSource.username]), - phone: pickFirstNonEmpty([persistedSession.phone, savedSource.phone]), - deviceId: sessionContext.deviceId, - deviceType: sessionContext.deviceType, - }), - ) - - return buildAdminCloudtentaclesValidateResult(session, savedSession, sourceKey) -} - -export async function getAdminCloudtentaclesAsset( - payload: AdminCloudtentaclesCatalogQueryInput = {}, -) { - return getCloudtentaclesAsset(resolveAdminCloudtentaclesSessionPayload(payload)) -} - -export async function getAdminCloudtentaclesCategories( - payload: AdminCloudtentaclesCatalogQueryInput = {}, -) { - return getCloudtentaclesCategories(resolveAdminCloudtentaclesSessionPayload(payload)) -} - -export async function getAdminCloudtentaclesSkuList( - payload: AdminCloudtentaclesCatalogQueryInput = {}, -) { - return listCloudtentaclesSku(resolveAdminCloudtentaclesSessionPayload(payload)) -} - -export async function buyAdminCloudtentaclesSku(payload: AdminCloudtentaclesSkuBuyInput = {}) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return buyCloudtentaclesSku({ - ...context, - id: payload.id, - count: payload.count, - }) -} - -export async function useAdminCloudtentaclesSku(payload: AdminCloudtentaclesSkuUseInput = {}) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return useCloudtentaclesSku({ - ...context, - id: payload.id, - virtualNumberId: payload.virtualNumberId, - phone: payload.phone, - }) -} - -export async function getAdminCloudtentaclesKnapsack( - payload: AdminCloudtentaclesCatalogQueryInput = {}, -) { - return getCloudtentaclesKnapsack(resolveAdminCloudtentaclesSessionPayload(payload)) -} - -export async function listAdminCloudtentaclesDeliveryRecords( - payload: AdminCloudtentaclesDeliveryRecordQueryInput = {}, -) { - const platformOrderId = normalizeText(payload.platformOrderId) - const preferredSourceKey = normalizeText(payload.sourceKey) - const orderMatchedTasks = platformOrderId - ? await listLocalCloudtentaclesTasks({ - platformOrderId, - limit: 50, - }) - : [] - const orderSourceKey = - orderMatchedTasks.find((task) => !preferredSourceKey || task.sourceKey === preferredSourceKey) - ?.sourceKey || orderMatchedTasks.find((task) => task.sourceKey)?.sourceKey - const resolvedSourceKey = orderSourceKey || preferredSourceKey || 'default' - const context = resolveAdminCloudtentaclesSessionPayload({ - ...payload, - sourceKey: resolvedSourceKey, - }) - const recordWindow = resolveRecordQueryWindow(payload, orderMatchedTasks) - const recordPage = platformOrderId ? 1 : payload.page - const recordSize = platformOrderId ? 1000 : payload.size - const query = { - ...context, - ...pickDefined({ - page: recordPage, - size: recordSize, - startDate: recordWindow.startDate, - endDate: recordWindow.endDate, - recordCode: payload.recordCode, - }), - } - const [records, skuList] = await Promise.all([ - listCloudtentaclesDeliveryRecords(query), - listCloudtentaclesSku(context), - ]) - const skuImageByName = new Map< - string, - { - skuId: number - skuName: string - skuImage: string - } - >() - for (const sku of Array.isArray(skuList.items) ? skuList.items : []) { - const name = normalizeSkuName(sku.name) - const skuImage = String(sku.image || '').trim() - if (!name || !skuImage) { - continue - } - - skuImageByName.set(name, { - skuId: Number(sku.id || 0), - skuName: String(sku.name || '').trim(), - skuImage, - }) - } - - const recordItems: JsonObject[] = Array.isArray(records.items) ? records.items : [] - const localTasks = - platformOrderId || recordItems.length === 0 - ? orderMatchedTasks - : await listLocalCloudtentaclesTasks({ - sourceKey: resolvedSourceKey, - startDate: recordWindow.startDate, - endDate: recordWindow.endDate, - limit: 800, - }) - const enrichedItems = recordItems - .map((record) => { - const localMatch = platformOrderId - ? findStrictLocalTaskForOrderRecord(record, localTasks) - : findBestLocalTaskForRecord(record, localTasks) - return { - ...record, - ...(skuImageByName.get(normalizeSkuName(record.name)) || { - skuId: 0, - skuName: '', - skuImage: '', - }), - ...mapLocalTaskRecordEnhancement(localMatch?.task || null, localMatch?.confidence || ''), - } - }) - .filter((record) => { - if (!platformOrderId) { - return true - } - return normalizeText(record.platformOrderId) === platformOrderId - }) - const fallbackItems = platformOrderId - ? orderMatchedTasks - .filter( - (task) => !enrichedItems.some((record) => Number(record.taskId || 0) === task.taskId), - ) - .map((task) => ({ - ...createLocalTaskFallbackRecord(task), - ...(skuImageByName.get(normalizeSkuName(task.skuName)) || { - skuId: 0, - skuName: '', - skuImage: '', - }), - ...mapLocalTaskRecordEnhancement(task, 'local'), - })) - : [] - const finalItems = platformOrderId ? [...enrichedItems, ...fallbackItems] : enrichedItems - - return { - ...records, - sourceKey: resolvedSourceKey, - queryStartDate: recordWindow.startDate, - queryEndDate: recordWindow.endDate, - total: platformOrderId ? finalItems.length : records.total, - page: platformOrderId ? 1 : records.page, - size: platformOrderId ? recordSize : records.size, - localOrderMatched: platformOrderId ? orderMatchedTasks.length > 0 : false, - items: finalItems, - } -} - -export async function listAdminCloudtentaclesVirtualNumbers( - payload: AdminCloudtentaclesVirtualNumberInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return listCloudtentaclesVirtualNumbers({ - ...context, - key: payload.key, - }) -} - -export async function appointAdminCloudtentaclesVirtualNumber( - payload: AdminCloudtentaclesVirtualNumberInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return appointCloudtentaclesVirtualNumber({ - ...context, - key: payload.key, - }) -} - -export async function generateAdminCloudtentaclesLoginCode( - payload: AdminCloudtentaclesVirtualNumberInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return generateCloudtentaclesLoginCode({ - ...context, - key: payload.key, - id: payload.id, - }) -} - -export async function fetchAdminCloudtentaclesVirtualNumberCode( - payload: AdminCloudtentaclesVirtualNumberInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return fetchCloudtentaclesVirtualNumberCode({ - ...context, - key: payload.key, - phone: payload.phone, - }) -} - -export async function verifyAdminCloudtentaclesLoginCode( - payload: AdminCloudtentaclesVirtualNumberInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return verifyCloudtentaclesLoginCode({ - ...context, - key: payload.key, - id: payload.id, - code: payload.code, - }) -} - -export async function getAdminCloudtentaclesBindUrl( - payload: AdminCloudtentaclesVirtualNumberInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return getCloudtentaclesBindUrl({ - ...context, - key: payload.key, - id: payload.id, - }) -} - -export async function backAdminCloudtentaclesVirtualNumber( - payload: AdminCloudtentaclesVirtualNumberInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return backCloudtentaclesVirtualNumber({ - ...context, - key: payload.key, - id: payload.id, - }) -} - -export async function runAdminCloudtentaclesFullFlow( - payload: AdminCloudtentaclesFullFlowInput = {}, -) { - const context = resolveAdminCloudtentaclesSessionPayload(payload) - return runCloudtentaclesFullDebugFlow({ - ...context, - skuId: payload.skuId, - skuCount: payload.skuCount, - vnKey: payload.vnKey, - }) -} - -function pickDefined(values: JsonObject = {}) { - return Object.fromEntries( - Object.entries(values).filter(([, value]) => typeof value !== 'undefined'), - ) -} - -function normalizeSkuName(value: unknown) { - return String(value || '') - .trim() - .replace(/\s+/g, '') - .toLowerCase() -} - -function normalizeText(value: unknown) { - return String(value || '').trim() -} - -function normalizePhone(value: unknown) { - return normalizeText(value).replace(/\D+/g, '') -} - -function normalizeNumber(value: unknown) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? parsed : 0 -} - -function asRecord(value: unknown): JsonObject { - return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} -} - -function pickFirstNonEmptyText(values: unknown[]) { - for (const value of values) { - const normalized = normalizeText(value) - if (normalized) { - return normalized - } - } - - return '' -} - -function resolveRecordQueryWindow( - payload: AdminCloudtentaclesDeliveryRecordQueryInput = {}, - tasks: LocalCloudtentaclesTask[] = [], -) { - if (tasks.length === 0) { - return { - startDate: payload.startDate, - endDate: payload.endDate, - } - } - - const timestamps = tasks - .flatMap((task) => [task.createdAt, task.updatedAt, task.localDispatchAt]) - .map((value) => new Date(value).getTime()) - .filter((value) => Number.isFinite(value)) - if (timestamps.length === 0) { - return { - startDate: payload.startDate, - endDate: payload.endDate, - } - } - - const hourMs = 60 * 60 * 1000 - return { - startDate: new Date(Math.min(...timestamps) - hourMs).toISOString(), - endDate: new Date(Math.max(...timestamps) + 6 * hourMs).toISOString(), - } -} - -async function listLocalCloudtentaclesTasks( - options: { - platformOrderId?: string | undefined - sourceKey?: string | undefined - startDate?: string | undefined - endDate?: string | undefined - limit?: number - } = {}, -): Promise { - const filters: string[] = ["ft.executor_key = 'kuaishou_ct_assisted'"] - const params: unknown[] = [] - const platformOrderId = normalizeText(options.platformOrderId) - const sourceKey = normalizeText(options.sourceKey) - const startDate = normalizeText(options.startDate) - const endDate = normalizeText(options.endDate) - - if (platformOrderId) { - params.push(`%${platformOrderId}%`) - filters.push(`ft.platform_order_id ILIKE $${params.length}`) - } - - if (sourceKey) { - params.push(sourceKey) - filters.push( - `COALESCE(NULLIF(kcts.source_key, ''), ft.context_json #>> '{kuaishouCloudFulfillment,binding,resolvedSourceKey}', '') = $${params.length}`, - ) - } - - if (startDate) { - params.push(startDate) - filters.push(`ft.updated_at >= ($${params.length})::timestamptz - interval '1 day'`) - } - - if (endDate) { - params.push(endDate) - filters.push(`ft.created_at <= ($${params.length})::timestamptz + interval '1 day'`) - } - - params.push(Math.min(Math.max(Number(options.limit || 500), 1), 1000)) - const result = await dbQuery( - ` - SELECT - ft.id, - ft.task_no, - ft.platform_order_id, - ft.shop_id, - ft.shop_name, - ft.task_status, - ft.delivery_status, - ft.result_code, - ft.result_message, - ft.context_json, - ft.created_at, - ft.updated_at, - oi.sku_code, - oi.sku_name, - kcts.source_key AS kuaishou_source_key, - kcts.vn_phone AS kuaishou_vn_phone, - kcts.role_name AS kuaishou_role_name, - kcts.role_id AS kuaishou_role_id, - kcts.dispatch_status AS kuaishou_dispatch_status, - kcts.consume_status AS kuaishou_consume_status - FROM fulfillment_tasks ft - LEFT JOIN order_items oi ON oi.id = ft.order_item_id - LEFT JOIN kuaishou_cloud_task_states kcts ON kcts.task_id = ft.id - WHERE ${filters.join(' AND ')} - ORDER BY ft.id DESC - LIMIT $${params.length} - `, - params, - ) - - return result.rows.map(mapLocalCloudtentaclesTask) -} - -function mapLocalCloudtentaclesTask(row: LocalCloudtentaclesTaskRow): LocalCloudtentaclesTask { - const context = parseJsonObject(row.context_json) - const flow = asRecord(context.kuaishouCloudFulfillment) - const binding = asRecord(flow.binding) - const role = asRecord(flow.role) - const dispatch = asRecord(flow.dispatch) - const consume = asRecord(flow.consume) - - return { - taskId: Number(row.id || 0), - taskNo: normalizeText(row.task_no), - platformOrderId: normalizeText(row.platform_order_id), - orderShopId: normalizeText(row.shop_id), - orderShopName: normalizeText(row.shop_name), - taskStatus: normalizeText(row.task_status), - deliveryStatus: normalizeText(row.delivery_status), - resultCode: normalizeText(row.result_code), - resultMessage: normalizeText(row.result_message), - createdAt: normalizeText(row.created_at), - updatedAt: normalizeText(row.updated_at), - skuCode: normalizeText(row.sku_code), - skuName: pickFirstNonEmptyText([row.sku_name, flow.internalSkuName]), - sourceKey: pickFirstNonEmptyText([row.kuaishou_source_key, binding.resolvedSourceKey]), - vnId: normalizeNumber(binding.vnId), - vnPhone: pickFirstNonEmptyText([row.kuaishou_vn_phone, binding.vnPhone]), - roleName: pickFirstNonEmptyText([row.kuaishou_role_name, role.name, binding.roleName]), - roleId: pickFirstNonEmptyText([row.kuaishou_role_id, role.rid, binding.roleId]), - localDispatchStatus: pickFirstNonEmptyText([row.kuaishou_dispatch_status, dispatch.status]), - localDispatchAt: normalizeText(dispatch.dispatchAt), - consumeShopId: normalizeText(consume.shopId), - consumeShopName: normalizeText(consume.shopName), - } -} - -function findBestLocalTaskForRecord(record: JsonObject, tasks: LocalCloudtentaclesTask[] = []) { - let best: { task: LocalCloudtentaclesTask; score: number; confidence: string } | null = null - - for (const task of tasks) { - const score = scoreLocalTaskRecordMatch(record, task) - if (score <= 0 || (best && score <= best.score)) { - continue - } - - best = { - task, - score, - confidence: score >= 80 ? 'high' : score >= 45 ? 'medium' : 'low', - } - } - - return best && best.score >= 35 ? best : null -} - -function findStrictLocalTaskForOrderRecord( - record: JsonObject, - tasks: LocalCloudtentaclesTask[] = [], -) { - let best: { task: LocalCloudtentaclesTask; score: number; confidence: string } | null = null - - for (const task of tasks) { - if (!isSameDispatchedResource(record, task)) { - continue - } - - const score = scoreLocalTaskRecordMatch(record, task) - if (score <= 0 || (best && score <= best.score)) { - continue - } - - best = { - task, - score, - confidence: score >= 80 ? 'high' : 'medium', - } - } - - return best -} - -function isSameDispatchedResource(record: JsonObject, task: LocalCloudtentaclesTask) { - const recordVnId = normalizeNumber(record.virtualNumberId) - const recordPhone = normalizePhone(record.phone) - const taskPhone = normalizePhone(task.vnPhone) - - if (recordVnId > 0 && task.vnId > 0 && recordVnId === task.vnId) { - return true - } - - return Boolean(recordPhone && taskPhone && recordPhone === taskPhone) -} - -function scoreLocalTaskRecordMatch(record: JsonObject, task: LocalCloudtentaclesTask) { - let score = 0 - const recordVnId = normalizeNumber(record.virtualNumberId) - const recordPhone = normalizePhone(record.phone) - const taskPhone = normalizePhone(task.vnPhone) - const recordName = normalizeSkuName(record.name) - const taskSkuName = normalizeSkuName(task.skuName) - const recordRoleName = normalizeSkuName(record.fulfillUser) - const taskRoleName = normalizeSkuName(task.roleName) - - if (recordVnId > 0 && task.vnId > 0 && recordVnId === task.vnId) { - score += 50 - } - if (recordPhone && taskPhone && recordPhone === taskPhone) { - score += 30 - } - if (recordName && taskSkuName && recordName === taskSkuName) { - score += 20 - } - if (recordRoleName && taskRoleName && recordRoleName.includes(taskRoleName)) { - score += 10 - } - - const recordTime = new Date(record.createdAt).getTime() - const dispatchTime = new Date(task.localDispatchAt || task.updatedAt || task.createdAt).getTime() - if (Number.isFinite(recordTime) && Number.isFinite(dispatchTime)) { - const distanceHours = Math.abs(recordTime - dispatchTime) / (60 * 60 * 1000) - if (distanceHours <= 6) { - score += 15 - } else if (distanceHours <= 24) { - score += 6 - } - } - - return score -} - -function mapLocalTaskRecordEnhancement(task: LocalCloudtentaclesTask | null, confidence = '') { - return { - platformOrderId: task?.platformOrderId || '', - taskId: task?.taskId || 0, - taskNo: task?.taskNo || '', - orderShopId: task?.orderShopId || '', - orderShopName: task?.orderShopName || '', - consumeShopId: task?.consumeShopId || '', - consumeShopName: task?.consumeShopName || '', - roleName: task?.roleName || '', - roleId: task?.roleId || '', - localTaskStatus: task?.taskStatus || '', - localDeliveryStatus: task?.deliveryStatus || '', - localDispatchStatus: task?.localDispatchStatus || '', - localDispatchAt: task?.localDispatchAt || '', - localResultCode: task?.resultCode || '', - localResultMessage: task?.resultMessage || '', - matchConfidence: confidence, - } -} - -function createLocalTaskFallbackRecord(task: LocalCloudtentaclesTask) { - return { - createdAt: task.localDispatchAt || task.updatedAt || task.createdAt, - recordId: '', - virtualNumberId: task.vnId, - userId: 0, - count: 1, - cdk: '', - exchangeUrl: '', - name: task.skuName, - phone: task.vnPhone, - status: mapLocalDispatchStatusToCloudStatus(task.localDispatchStatus), - fulfillUser: task.roleName, - operatorName: '', - raw: {}, - } -} - -function mapLocalDispatchStatusToCloudStatus(status: string) { - const normalized = normalizeText(status) - if (normalized === 'success') { - return 4 - } - if (normalized === 'failed') { - return 3 - } - if (normalized === 'pending') { - return 2 - } - return 0 -} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts deleted file mode 100644 index 27c37b7c..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { mapAdminCloudtentaclesSession, maskPhone, maskSecret } from './mappers.js' - -test('maskSecret preserves edges while hiding middle characters', () => { - assert.equal(maskSecret('abcdef1234567890'), 'abcdef****567890') - assert.equal(maskSecret('12345678'), '12****78') - assert.equal(maskSecret(''), '') -}) - -test('maskPhone hides middle digits for mobile numbers', () => { - assert.equal(maskPhone('13812345678'), '138****5678') - assert.equal(maskPhone('123456'), '123456') - assert.equal(maskPhone(''), '') -}) - -test('mapAdminCloudtentaclesSession derives masked fields and token presence', () => { - assert.deepEqual( - mapAdminCloudtentaclesSession({ - token: 'abcdef1234567890', - baseUrl: ' https://123.207.217.176 ', - username: ' admin ', - phone: '13812345678', - loggedInAt: ' 2026-05-04T10:00:00.000Z ', - deviceId: ' dev-1 ', - deviceType: '2', - }), - { - token: 'abcdef1234567890', - tokenMasked: 'abcdef****567890', - baseUrl: 'https://123.207.217.176', - username: 'admin', - phoneMasked: '138****5678', - loggedInAt: '2026-05-04T10:00:00.000Z', - deviceId: 'dev-1', - deviceType: 2, - hasToken: true, - }, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts deleted file mode 100644 index 295bc060..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/mappers.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { JsonObject } from '../../../../types/json.js' -import { - maskPhone as maskPhoneValue, - maskSecret as maskSecretValue, -} from '../../../../utils/masking.js' -import { - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from '../../../platforms/cloudtentacles/defaults.js' - -export function maskSecret(value: unknown) { - return maskSecretValue(value) -} - -export function maskPhone(value: unknown) { - return maskPhoneValue(value, { maskShort: false }) -} - -export function mapAdminCloudtentaclesSourceConfig(config: JsonObject = {}) { - return { - key: String(config.key || '').trim(), - label: String(config.label || '').trim(), - enabled: config.enabled !== false, - baseUrl: String(config.baseUrl || '').trim(), - username: String(config.username || '').trim(), - password: String(config.password || '').trim(), - phone: String(config.phone || '').trim(), - deviceId: normalizeCloudtentaclesDeviceId(config.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(config.deviceType), - } -} - -export function mapAdminCloudtentaclesSession(session: JsonObject = {}) { - return { - token: String(session.token || '').trim(), - tokenMasked: maskSecret(session.token), - baseUrl: String(session.baseUrl || '').trim(), - username: String(session.username || '').trim(), - phoneMasked: maskPhone(session.phone), - loggedInAt: String(session.loggedInAt || '').trim(), - deviceId: normalizeCloudtentaclesDeviceId(session.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(session.deviceType), - hasToken: Boolean(String(session.token || '').trim()), - } -} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts deleted file mode 100644 index a7612bda..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildAdminCloudtentaclesLoginResult, - buildAdminCloudtentaclesPersistedSessionPayload, - buildAdminCloudtentaclesSessionSummary, - buildAdminCloudtentaclesValidateResult, - resolveAdminCloudtentaclesCredentialPayload, - resolveAdminCloudtentaclesSessionPayload, -} from './payloads.js' - -test('resolveAdminCloudtentaclesCredentialPayload merges payload and saved source defaults', () => { - assert.deepEqual( - resolveAdminCloudtentaclesCredentialPayload( - { - baseUrl: ' https://override.example.com ', - phone: ' 13800138000 ', - deviceId: ' custom-device ', - }, - { - savedSource: { - baseUrl: 'https://saved.example.com', - username: 'saved-user', - password: 'saved-pass', - phone: '13900000000', - deviceId: 'saved-device', - deviceType: 2, - }, - }, - ), - { - sourceKey: 'default', - baseUrl: 'https://override.example.com', - username: 'saved-user', - password: 'saved-pass', - phone: '13800138000', - deviceId: 'custom-device', - deviceType: 2, - }, - ) -}) - -test('resolveAdminCloudtentaclesSessionPayload merges payload source and persisted session defaults', () => { - assert.deepEqual( - resolveAdminCloudtentaclesSessionPayload( - { - token: ' payload-token ', - }, - { - savedSource: { - baseUrl: 'https://saved.example.com', - deviceId: 'saved-device', - deviceType: 3, - }, - persistedSession: { - token: 'session-token', - deviceId: 'session-device', - deviceType: 5, - }, - }, - ), - { - sourceKey: 'default', - baseUrl: 'https://saved.example.com', - token: 'payload-token', - deviceId: 'saved-device', - deviceType: 3, - }, - ) -}) - -test('buildAdminCloudtentaclesPersistedSessionPayload prefers explicit overrides', () => { - assert.deepEqual( - buildAdminCloudtentaclesPersistedSessionPayload( - { - token: ' token-1 ', - baseUrl: ' https://cloud.example.com ', - username: ' session-user ', - phone: ' 13900000000 ', - loggedInAt: ' 2026-05-04T12:00:00.000Z ', - deviceId: ' session-device ', - deviceType: 9, - }, - { - username: 'saved-user', - phone: '13800138000', - deviceId: 'saved-device', - deviceType: 3, - }, - ), - { - token: 'token-1', - baseUrl: 'https://cloud.example.com', - username: 'saved-user', - phone: '13800138000', - loggedInAt: '2026-05-04T12:00:00.000Z', - deviceId: 'saved-device', - deviceType: 3, - }, - ) -}) - -test('buildAdminCloudtentaclesSessionSummary masks token and reports permission count', () => { - assert.deepEqual( - buildAdminCloudtentaclesSessionSummary( - { - token: 'abcdef1234567890', - permissions: ['asset', 'sku'], - }, - { - token: 'persisted-token', - }, - ), - { - sourceKey: 'default', - tokenMasked: 'abcdef****567890', - permissionCount: 2, - permissions: ['asset', 'sku'], - persisted: true, - }, - ) -}) - -test('buildAdminCloudtentaclesLoginResult assembles login response payload', () => { - assert.deepEqual( - buildAdminCloudtentaclesLoginResult( - { - token: 'abcdef1234567890', - baseUrl: ' https://cloud.example.com ', - username: ' test-user ', - phone: '13812345678', - loggedInAt: ' 2026-05-04T12:00:00.000Z ', - responseMessage: ' success ', - permissions: ['asset'], - userInfo: { id: 1 }, - asset: { balance: 10 }, - }, - { - token: 'persisted-token', - }, - ), - { - sourceKey: 'default', - baseUrl: 'https://cloud.example.com', - username: 'test-user', - phoneMasked: '138****5678', - loggedInAt: '2026-05-04T12:00:00.000Z', - responseMessage: 'success', - token: 'abcdef1234567890', - session: { - sourceKey: 'default', - tokenMasked: 'abcdef****567890', - permissionCount: 1, - permissions: ['asset'], - persisted: true, - }, - userInfo: { id: 1 }, - asset: { balance: 10 }, - }, - ) -}) - -test('buildAdminCloudtentaclesValidateResult assembles validate response payload', () => { - assert.deepEqual( - buildAdminCloudtentaclesValidateResult( - { - token: 'abcdef1234567890', - baseUrl: ' https://cloud.example.com ', - loggedInAt: ' 2026-05-04T12:00:00.000Z ', - permissions: ['asset', 'sku'], - userInfo: { id: 2 }, - asset: { balance: 20 }, - }, - {}, - ), - { - sourceKey: 'default', - baseUrl: 'https://cloud.example.com', - loggedInAt: '2026-05-04T12:00:00.000Z', - session: { - sourceKey: 'default', - tokenMasked: 'abcdef****567890', - permissionCount: 2, - permissions: ['asset', 'sku'], - persisted: false, - }, - userInfo: { id: 2 }, - asset: { balance: 20 }, - }, - ) -}) - -test('buildAdminCloudtentaclesSessionSummary preserves sourceKey', () => { - assert.deepEqual( - buildAdminCloudtentaclesSessionSummary( - { - token: 'abcdef1234567890', - permissions: [], - }, - {}, - 'account2', - ), - { - sourceKey: 'account2', - tokenMasked: 'abcdef****567890', - permissionCount: 0, - permissions: [], - persisted: false, - }, - ) -}) diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts deleted file mode 100644 index 389e507a..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/payloads.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { getCloudtentaclesSourceByKey } from '../../../platforms/cloudtentacles/source-config-service.js' -import { getCloudtentaclesSessionStateByKey } from '../../../platforms/cloudtentacles/session-state-service.js' -import type { JsonObject } from '../../../../types/json.js' -import { - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from '../../../platforms/cloudtentacles/defaults.js' -import { pickFirstNonEmpty, resolveCloudtentaclesAdminContext } from './context.js' -import { maskPhone, maskSecret } from './mappers.js' - -const DEFAULT_CLOUDTENTACLES_BASE_URL = 'https://123.207.217.176' - -function _resolveSourceKey(payload: JsonObject = {}) { - return String(payload.sourceKey || '').trim() || 'default' -} - -export function resolveAdminCloudtentaclesCredentialPayload( - payload: JsonObject = {}, - options: JsonObject = {}, -) { - const sourceKey = _resolveSourceKey(payload) - const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {} - const defaultBaseUrl = String(options.defaultBaseUrl || DEFAULT_CLOUDTENTACLES_BASE_URL).trim() - - return { - sourceKey, - baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]), - username: pickFirstNonEmpty([payload.username, savedSource.username]), - password: pickFirstNonEmpty([payload.password, savedSource.password]), - phone: pickFirstNonEmpty([payload.phone, savedSource.phone]), - deviceId: normalizeCloudtentaclesDeviceId( - pickFirstNonEmpty([payload.deviceId, savedSource.deviceId]), - ), - deviceType: normalizeCloudtentaclesDeviceType(payload.deviceType ?? savedSource.deviceType), - } -} - -export function resolveAdminCloudtentaclesSessionPayload( - payload: JsonObject = {}, - options: JsonObject = {}, -) { - const sourceKey = _resolveSourceKey(payload) - const savedSource = options.savedSource || getCloudtentaclesSourceByKey(sourceKey) || {} - const persistedSession = - options.persistedSession || getCloudtentaclesSessionStateByKey(sourceKey) || {} - - return resolveCloudtentaclesAdminContext(payload, { - savedSource, - persistedSession, - sourceKey, - defaultBaseUrl: options.defaultBaseUrl || DEFAULT_CLOUDTENTACLES_BASE_URL, - }) -} - -export function buildAdminCloudtentaclesPersistedSessionPayload( - session: JsonObject = {}, - options: JsonObject = {}, -) { - return { - token: String(session.token || '').trim(), - baseUrl: String(session.baseUrl || '').trim(), - username: pickFirstNonEmpty([options.username, session.username]), - phone: pickFirstNonEmpty([options.phone, session.phone]), - loggedInAt: String(session.loggedInAt || '').trim(), - deviceId: normalizeCloudtentaclesDeviceId( - pickFirstNonEmpty([options.deviceId, session.deviceId]), - ), - deviceType: normalizeCloudtentaclesDeviceType(options.deviceType ?? session.deviceType), - } -} - -export function buildAdminCloudtentaclesSessionSummary( - session: JsonObject = {}, - savedSession: JsonObject = {}, - sourceKey = 'default', -) { - const permissions = Array.isArray(session.permissions) ? session.permissions : [] - - return { - sourceKey, - tokenMasked: maskSecret(session.token), - permissionCount: permissions.length, - permissions, - persisted: Boolean(savedSession.token), - } -} - -export function buildAdminCloudtentaclesLoginResult( - session: JsonObject = {}, - savedSession: JsonObject = {}, - sourceKey = 'default', -) { - return { - sourceKey, - baseUrl: String(session.baseUrl || '').trim(), - username: String(session.username || '').trim(), - phoneMasked: maskPhone(session.phone), - loggedInAt: String(session.loggedInAt || '').trim(), - responseMessage: String(session.responseMessage || '').trim(), - token: String(session.token || '').trim(), - session: buildAdminCloudtentaclesSessionSummary(session, savedSession, sourceKey), - userInfo: session.userInfo, - asset: session.asset, - } -} - -export function buildAdminCloudtentaclesValidateResult( - session: JsonObject = {}, - savedSession: JsonObject = {}, - sourceKey = 'default', -) { - return { - sourceKey, - baseUrl: String(session.baseUrl || '').trim(), - loggedInAt: String(session.loggedInAt || '').trim(), - session: buildAdminCloudtentaclesSessionSummary(session, savedSession, sourceKey), - userInfo: session.userInfo, - asset: session.asset, - } -} diff --git a/apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts b/apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts deleted file mode 100644 index 30f994b9..00000000 --- a/apps/backend/src/services/admin/platform-config/cloudtentacles/writes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { JsonObject } from '../../../../types/json.js' -import { - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from '../../../platforms/cloudtentacles/defaults.js' - -export function normalizeAdminCloudtentaclesSourceConfigPayload(payload: JsonObject = {}) { - const sourceKey = String(payload.sourceKey || payload.key || '').trim() || 'default' - return { - key: sourceKey, - label: String(payload.label || '').trim(), - enabled: payload.enabled !== false, - baseUrl: String(payload.baseUrl || '').trim() || 'https://123.207.217.176', - username: String(payload.username || '').trim(), - password: String(payload.password || '').trim(), - phone: String(payload.phone || '').trim(), - deviceId: normalizeCloudtentaclesDeviceId(payload.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(payload.deviceType), - } -} diff --git a/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts b/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts index 8afa082a..98717135 100644 --- a/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts +++ b/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts @@ -4,7 +4,7 @@ import { saveFulfillmentRoutingConfig, } from '../../fulfillment/routing-config-service.js' import { resolveOrderItemForFulfillment } from '../../fulfillment/product-resolution-service.js' -import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js' +import { normalizeProductMatchName } from '../../order/product-match-utils.js' import type { JsonRecord } from '../../../types/json.js' export function getAdminFulfillmentRoutingConfig() { @@ -40,12 +40,11 @@ export async function previewAdminFulfillmentRouting(payload: JsonRecord = {}) { return { productName, - normalizedProductName: normalizeCloudtentaclesMatchName(productName), + normalizedProductName: normalizeProductMatchName(productName), resolvedSkuCode: item.skuCode, resolvedSkuName: item.skuName, isConfigured: item.isConfigured, fulfillmentRoute: snapshot.fulfillmentRoute || null, - cloudtentacles: snapshot.cloudtentacles || null, - kuaishouFeifei: snapshot.kuaishouFeifei || null, + affiliateDash: snapshot.affiliateDash || null, } } diff --git a/apps/backend/src/services/admin/platform-config/kuaishou-feifei-service.ts b/apps/backend/src/services/admin/platform-config/kuaishou-feifei-service.ts deleted file mode 100644 index 4fd46136..00000000 --- a/apps/backend/src/services/admin/platform-config/kuaishou-feifei-service.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { getKuaishouFeifeiConfig } from '../../platforms/kuaishou-feifei/config.js' -import type { JsonObject } from '../../../types/json.js' -import { - createKuaishouFeifeiOrder, - listKuaishouFeifeiProducts, - queryKuaishouFeifeiOrder, - type KuaishouFeifeiProductListResult, -} from '../../platforms/kuaishou-feifei/order-service.js' -import { resolveKuaishouFeifeiProductByName } from '../../platforms/kuaishou-feifei/product-rule-service.js' -import { - getKuaishouFeifeiConfigFilePath, - getKuaishouFeifeiSourceConfig, - hasKuaishouFeifeiConfigFile, - normalizeKuaishouFeifeiSourceConfig, - saveKuaishouFeifeiSourceConfig, -} from '../../platforms/kuaishou-feifei/source-config-service.js' -import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js' -import { createHttpError } from '../../../utils/http.js' - -export function getAdminKuaishouFeifeiConfig() { - const source = getAdminEditableKuaishouFeifeiConfig() - const effective = getKuaishouFeifeiConfig() - - return { - filePath: getKuaishouFeifeiConfigFilePath(), - source, - effective: mapEffectiveKuaishouFeifeiConfig(effective), - } -} - -export async function updateAdminKuaishouFeifeiConfig(payload: JsonObject = {}) { - const saved = await saveKuaishouFeifeiSourceConfig(payload) - const effective = getKuaishouFeifeiConfig() - - return { - filePath: getKuaishouFeifeiConfigFilePath(), - source: saved, - effective: mapEffectiveKuaishouFeifeiConfig(effective), - } -} - -export function matchAdminKuaishouFeifeiProduct(payload: JsonObject = {}) { - const productName = String(payload.productName || '').trim() - const source = getAdminEditableKuaishouFeifeiConfig() - const match = resolveKuaishouFeifeiProductByName(productName, { - enabled: source.enabled, - rules: source.productRules, - }) - - return { - productName, - normalizedProductName: normalizeCloudtentaclesMatchName(productName), - matched: Boolean(match), - match, - } -} - -export async function listAdminKuaishouFeifeiProducts(payload: JsonObject = {}) { - return listKuaishouFeifeiProducts({ - page: Number(payload.page || 1) || 1, - perPage: Number(payload.perPage || payload.per_page || 20) || 20, - status: String(payload.status || 'on_sale').trim(), - supplyProductName: String( - payload.supplyProductName || payload.supply_product_name || '', - ).trim(), - }) -} - -export async function syncAdminKuaishouFeifeiProductRules(payload: JsonObject = {}) { - const source = getAdminEditableKuaishouFeifeiConfig() - const status = String(payload.status || 'on_sale').trim() || 'on_sale' - const supplyProductName = String( - payload.supplyProductName || payload.supply_product_name || '', - ).trim() - const products = await listAllKuaishouFeifeiProducts({ - status, - supplyProductName, - }) - const productRules = products - .map((product) => { - const productName = String(product.name || '').trim() - const productCode = String(product.productCode || '').trim() - if (!productName || !productCode) { - return null - } - - return { - id: normalizeCloudtentaclesMatchName(productName) || productCode, - enabled: product.status !== 'off_sale' && product.supplyStatus !== 'off_sale', - productName, - normalizedProductName: normalizeCloudtentaclesMatchName(productName), - productCode, - skuName: productName, - notes: `feifei 商品目录同步:${product.channel || 'unknown'}`, - } - }) - .filter((rule): rule is NonNullable => Boolean(rule)) - const saved = await saveKuaishouFeifeiSourceConfig({ - ...source, - productRules, - }) - const effective = getKuaishouFeifeiConfig() - - return { - filePath: getKuaishouFeifeiConfigFilePath(), - source: saved, - effective: mapEffectiveKuaishouFeifeiConfig(effective), - sync: { - productCount: products.length, - ruleCount: saved.productRules.length, - status, - supplyProductName, - }, - } -} - -export async function createAdminKuaishouFeifeiTestOrder(payload: JsonObject = {}) { - const platformOrderNo = String(payload.platformOrderNo || payload.platform_order_no || '').trim() - const productCode = String(payload.productCode || payload.product_code || '').trim() - - if (!platformOrderNo) { - throw createHttpError('测试业务单号不能为空', { - statusCode: 400, - errorCode: 'kuaishou_feifei_missing_platform_order_no', - }) - } - - if (!productCode) { - throw createHttpError('feifei 商品编码不能为空', { - statusCode: 400, - errorCode: 'kuaishou_feifei_missing_product_code', - }) - } - - const orderInput: Parameters[0] = { - platformOrderNo, - productCode, - platformBuyNum: Number(payload.platformBuyNum || payload.platform_buy_num || 1) || 1, - playerAccount: String(payload.playerAccount || payload.player_account || '').trim(), - playerGameRegion: String(payload.playerGameRegion || payload.player_game_region || '').trim(), - playerGameSrv: String(payload.playerGameSrv || payload.player_game_srv || '').trim(), - playerGameRole: String(payload.playerGameRole || payload.player_game_role || '').trim(), - submitPlayer: payload.submitPlayer === true || payload.submit_player === true, - notifyUrl: String(payload.notifyUrl || payload.notify_url || '').trim(), - } - const platformAmount = normalizeOptionalNumber(payload.platformAmount ?? payload.platform_amount) - - if (platformAmount !== undefined) { - orderInput.platformAmount = platformAmount - } - - return createKuaishouFeifeiOrder(orderInput) -} - -export async function queryAdminKuaishouFeifeiTestOrder(payload: JsonObject = {}) { - const platformOrderNo = String(payload.platformOrderNo || payload.platform_order_no || '').trim() - const orderNo = String(payload.orderNo || payload.order_no || '').trim() - - if (!platformOrderNo && !orderNo) { - throw createHttpError('请填写 feifei 平台单号或测试业务单号', { - statusCode: 400, - errorCode: 'kuaishou_feifei_missing_order_query', - }) - } - - return queryKuaishouFeifeiOrder({ - platformOrderNo, - orderNo, - }) -} - -function getAdminEditableKuaishouFeifeiConfig() { - if (hasKuaishouFeifeiConfigFile()) { - return getKuaishouFeifeiSourceConfig() - } - - return normalizeKuaishouFeifeiSourceConfig(getKuaishouFeifeiConfig()) -} - -function mapEffectiveKuaishouFeifeiConfig(config: ReturnType) { - return { - enabled: config.enabled !== false, - baseUrl: config.baseUrl, - timeoutMs: config.timeoutMs, - notifyUrl: config.notifyUrl, - hasAppKey: Boolean(config.appKey), - hasAppSecret: Boolean(config.appSecret), - productRuleCount: config.productRules.length, - } -} - -async function listAllKuaishouFeifeiProducts(input: { status: string; supplyProductName: string }) { - const perPage = 100 - const firstPage = await listKuaishouFeifeiProducts({ - page: 1, - perPage, - status: input.status, - supplyProductName: input.supplyProductName, - }) - const products = [...firstPage.items] - const total = Number(firstPage.total || products.length) || products.length - const pageCount = Math.max(1, Math.ceil(total / perPage)) - - for (let page = 2; page <= pageCount; page += 1) { - const result: KuaishouFeifeiProductListResult = await listKuaishouFeifeiProducts({ - page, - perPage, - status: input.status, - supplyProductName: input.supplyProductName, - }) - products.push(...result.items) - } - - return products -} - -function normalizeOptionalNumber(value: unknown) { - if (value == null || String(value).trim() === '') { - return undefined - } - - const numberValue = Number(value) - return Number.isFinite(numberValue) ? numberValue : undefined -} diff --git a/apps/backend/src/services/admin/platform-config/notification-service.ts b/apps/backend/src/services/admin/platform-config/notification-service.ts index 42ef857c..fe515c3c 100644 --- a/apps/backend/src/services/admin/platform-config/notification-service.ts +++ b/apps/backend/src/services/admin/platform-config/notification-service.ts @@ -5,8 +5,6 @@ import { saveNotificationConfig, } from '../../notification/config-service.js' import { sendInternalNotification } from '../../notification/notification-service.js' -import { listCloudtentaclesSources } from '../../platforms/cloudtentacles/source-config-service.js' -import { getAllCloudtentaclesSessionStates } from '../../platforms/cloudtentacles/session-state-service.js' import { getScheduledJobsConfig, getScheduledJobsFilePath, @@ -17,7 +15,7 @@ import { reloadScheduledJobs, runScheduledJobNow, } from '../../scheduler/scheduler-service.js' -import { maskPhone, maskSecret } from './cloudtentacles/mappers.js' +import { maskSecret } from '../../../utils/masking.js' import type { AdminNotificationConfigInput, @@ -59,7 +57,7 @@ export function getAdminScheduledJobsConfig() { filePath: getScheduledJobsFilePath(), source: mapAdminScheduledJobsConfig(config), runtime: getScheduledJobRuntimeStates(), - cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(), + cloudtentaclesAccounts: [] as Array>, } } @@ -71,7 +69,7 @@ export async function updateAdminScheduledJobsConfig(payload: AdminScheduledJobs filePath: getScheduledJobsFilePath(), source: mapAdminScheduledJobsConfig(saved), runtime: getScheduledJobRuntimeStates(), - cloudtentaclesAccounts: listAdminCloudtentaclesMonitorAccounts(), + cloudtentaclesAccounts: [] as Array>, } } @@ -121,10 +119,6 @@ function mapAdminNotificationConfig(config: JsonObject = {}) { } function mapAdminScheduledJobsConfig(config: JsonObject = {}) { - const cloudtentaclesAccountMap = new Map( - listAdminCloudtentaclesMonitorAccounts().map((item) => [item.sourceKey, item]), - ) - return { enabled: config.enabled !== false, jobs: (Array.isArray(config.jobs) ? config.jobs : []).map((item) => ({ @@ -135,115 +129,15 @@ function mapAdminScheduledJobsConfig(config: JsonObject = {}) { cooldownSeconds: Number(item.cooldownSeconds || 1800), config: { assetThreshold: normalizeNonNegativeNumber(item.config?.assetThreshold, 500), - accounts: mapScheduledJobCloudtentaclesAccounts( - item.config?.accounts, - normalizeNonNegativeNumber(item.config?.assetThreshold, 500), - cloudtentaclesAccountMap, - ), + accounts: Array.isArray(item.config?.accounts) ? item.config.accounts : [], + pageSize: Number(item.config?.pageSize || 0) || undefined, + scanLimit: Number(item.config?.scanLimit || 0) || undefined, }, })), } } -function listAdminCloudtentaclesMonitorAccounts() { - const sourcesConfig = listCloudtentaclesSources() - const sessionsConfig = getAllCloudtentaclesSessionStates() - const sessions: Record = sessionsConfig.sessions || {} - - return (Array.isArray(sourcesConfig.sources) ? sourcesConfig.sources : []) - .map((source) => { - const sourceKey = String(source.key || '').trim() - const session = sessions[sourceKey] || {} - const label = String(source.label || source.username || sourceKey).trim() || sourceKey - - return { - sourceKey, - label, - enabled: source.enabled !== false, - username: String(source.username || '').trim(), - phoneMasked: maskPhone(source.phone || session.phone), - hasToken: Boolean(String(session.token || '').trim()), - loggedInAt: String(session.loggedInAt || '').trim(), - } - }) - .filter((item) => item.sourceKey) -} - -function mapScheduledJobCloudtentaclesAccounts( - rawAccounts: unknown, - defaultAssetThreshold: number, - cloudtentaclesAccountMap: Map, -) { - const accounts = Array.isArray(rawAccounts) ? rawAccounts : [] - const configuredMap = new Map( - accounts - .map((item) => { - const source = isPlainObject(item) ? item : {} - const sourceKey = String(source.sourceKey || source.key || '').trim() - if (!sourceKey) { - return null - } - - const option = cloudtentaclesAccountMap.get(sourceKey) || {} - return [ - sourceKey, - { - sourceKey, - label: String(source.label || option.label || sourceKey).trim(), - enabled: source.enabled !== false, - assetThreshold: normalizeNonNegativeNumber( - source.assetThreshold ?? source.threshold, - defaultAssetThreshold, - ), - }, - ] as const - }) - .filter(Boolean) as Array< - readonly [ - string, - { - sourceKey: string - label: string - enabled: boolean - assetThreshold: number - }, - ] - >, - ) - - // 读配置时合并全部 kuaishou-lewan 账号,避免前端/任务只看到历史 default。 - const merged = Array.from(cloudtentaclesAccountMap.values()) - .map((option) => { - const sourceKey = String(option.sourceKey || '').trim() - const configured = configuredMap.get(sourceKey) - return { - sourceKey, - label: String(configured?.label || option.label || sourceKey).trim(), - enabled: configured ? configured.enabled !== false : option.enabled !== false, - assetThreshold: normalizeNonNegativeNumber( - configured?.assetThreshold, - defaultAssetThreshold, - ), - } - }) - .filter((item) => item.sourceKey) - - const mergedKeys = new Set(merged.map((item) => item.sourceKey)) - for (const [sourceKey, configured] of configuredMap.entries()) { - if (mergedKeys.has(sourceKey)) { - continue - } - merged.push(configured) - } - - return merged -} - function normalizeNonNegativeNumber(value: unknown, fallback: number) { const parsed = Number(value) return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback } - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.ts b/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.ts deleted file mode 100644 index fb68da4a..00000000 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import type { HttpErrorLike } from '../../../utils/http.js' -import type { JsonObject } from '../../../types/json.js' -import { resolvePersistedCloudtentaclesContextBySourceKeys } from '../../fulfillment/kuaishou-cloud/cloudtentacles-context.js' - -export type { JsonObject } - -export { - KUAISHOU_CLOUD_FIXED_VN_KEY, - isKuaishouCloudTask, - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - resolveKuaishouCloudBindUrlExpiresAt, -} from '../../fulfillment/kuaishou-cloud/domain.js' -export { - prepareKuaishouCloudBindResourceWithFallback, - resolveKuaishouCloudBindingResources, - resolveKuaishouCloudVnKeyCandidates, -} from '../../fulfillment/kuaishou-cloud/binding-resources.js' - -export type CloudtentaclesContext = { - baseUrl: string - token: string - deviceId: string - deviceType: number - resolvedSourceKey: string -} - -export type CloudSkuLikeItem = { - id?: number | string - name?: string - price?: number | string - count?: number | string - [key: string]: unknown -} - -export type { KuaishouCloudFlow } from '../../fulfillment/kuaishou-cloud/domain.js' - -export type KuaishouCloudBindingResources = { - skuId: number - skuName: string - vnKey: string - skuItem: CloudSkuLikeItem | null - knapsackItem: CloudSkuLikeItem | null - resolvedByName: boolean -} - -export type PreparedKuaishouCloudBindResource = { - vnKey: string - vnId: number - vnPhone: string - bindUrl: string -} - -export function resolvePersistedCloudtentaclesContext( - sourceKeys: unknown[], -): CloudtentaclesContext { - try { - return resolvePersistedCloudtentaclesContextBySourceKeys(sourceKeys) - } catch (error) { - if (isMissingCloudtentaclesTokenError(error)) { - throw createHttpError('当前 cloudtentacles 没有可用 token,请先到平台配置完成登录校验', { - statusCode: 409, - errorCode: 'admin_task_kuaishou_cloud_missing_cloud_token', - cause: error, - }) - } - - throw error - } -} - -function isMissingCloudtentaclesTokenError(error: unknown): boolean { - const current = error as HttpErrorLike | null - return String(current?.errorCode || '').trim() === 'kuaishou_cloud_missing_cloud_token' -} diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts b/apps/backend/src/services/admin/write/kuaishou-industry-actions.ts similarity index 62% rename from apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts rename to apps/backend/src/services/admin/write/kuaishou-industry-actions.ts index 0f3ada3e..f3421879 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.ts +++ b/apps/backend/src/services/admin/write/kuaishou-industry-actions.ts @@ -18,15 +18,6 @@ import { nowIso } from '../../../utils/time.js' import { TASK_STATUS } from '../../../domain/task-status.js' import { createAdminViewerContext, parseTaskContext } from '../admin-read-shared-helpers.js' import { getRequiredTask, mapTaskActionPayload } from '../admin-task-read-helpers.js' -import { - dispatchFulfillmentTask, - prepareFulfillmentBinding, - rebindFulfillmentRole, - refreshFulfillmentRole, - returnFulfillmentNumber, -} from '../../fulfillment/executors/registry.js' -import { isKuaishouCloudTask, normalizeKuaishouCloudFlow } from './kuaishou-cloud-helpers.js' - import type { AdminEntityIdInput, AdminViewerSessionInput, @@ -35,195 +26,6 @@ import type { AdminTaskKuaishouIndustryConsumeInput } from '../../../types/admin import type { AdminTaskActionResponse } from '../../../types/admin/write-models.js' import type { KuaishouIndustryVoucherRow, TaskRow } from '../../../types/repository/rows.js' -export async function prepareAdminTaskKuaishouCloudFulfillment( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - - if (!viewerContext.canManageTaskLifecycle) { - throw createHttpError('当前账号没有权限准备绑定资源', { - statusCode: 403, - errorCode: 'admin_task_prepare_kuaishou_cloud_forbidden', - }) - } - - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_kuaishou_cloud', - }) - } - - const result = await prepareFulfillmentBinding(task, { - source: 'admin_task_prepare', - actor: buildAdminActionActor(session), - force: true, - }) - if (!result?.task) { - throw createHttpError('当前任务不支持准备绑定资源', { - statusCode: 409, - errorCode: 'admin_task_prepare_not_supported', - }) - } - - return { - task: mapTaskActionPayload(result.task), - claimUrl: String(result.claimUrl || ''), - token: String(result.token || ''), - } -} - -export async function dispatchAdminTaskKuaishouCloudFulfillment( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - - if (!viewerContext.canManageTaskLifecycle) { - throw createHttpError('当前账号没有权限执行发货', { - statusCode: 403, - errorCode: 'admin_task_dispatch_kuaishou_cloud_forbidden', - }) - } - - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_kuaishou_cloud', - }) - } - - const result = await dispatchFulfillmentTask(task, { - source: 'admin_task_dispatch', - actor: buildAdminActionActor(session), - autoFinalize: false, - }) - if (!result?.task) { - throw createHttpError('当前任务不支持 executor 发货', { - statusCode: 409, - errorCode: 'admin_task_dispatch_not_supported', - }) - } - - return { - task: mapTaskActionPayload(result.task), - } -} - -export async function refreshAdminTaskKuaishouCloudRoleInfo( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - - if (!viewerContext.canOperateAssistedTask) { - throw createHttpError('当前账号没有权限刷新角色信息', { - statusCode: 403, - errorCode: 'admin_task_refresh_kuaishou_cloud_role_forbidden', - }) - } - - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_kuaishou_cloud', - }) - } - - const result = await refreshFulfillmentRole(task, { - source: 'admin_task_refresh_role', - actor: buildAdminActionActor(session), - forceProbe: true, - recordEvent: true, - }) - if (!result?.task) { - throw createHttpError('当前任务不支持刷新角色信息', { - statusCode: 409, - errorCode: 'admin_task_refresh_not_supported', - }) - } - - return { - task: mapTaskActionPayload(result.task), - } -} - -export async function rebindAdminTaskKuaishouCloudRole( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - - // 与刷新角色一致:客服可换绑协助处理 UID 绑错,不开放完整任务生命周期 - if (!viewerContext.canOperateAssistedTask) { - throw createHttpError('当前账号没有权限换绑角色', { - statusCode: 403, - errorCode: 'admin_task_rebind_kuaishou_cloud_forbidden', - }) - } - - const result = await rebindFulfillmentRole(task, { - source: 'admin_task_rebind_role', - actor: buildAdminActionActor(session), - }) - if (!result?.task) { - throw createHttpError('当前任务不支持换绑角色', { - statusCode: 409, - errorCode: 'admin_task_rebind_not_supported', - }) - } - - return { - task: mapTaskActionPayload(result.task), - claimUrl: String(result.claimUrl || ''), - token: String(result.token || ''), - } -} - -export async function returnNumberAdminTaskKuaishouCloudFulfillment( - taskId: AdminEntityIdInput, - session: AdminViewerSessionInput | null = null, -): Promise { - const task = await getRequiredTask(taskId) - const viewerContext = createAdminViewerContext(session) - - if (!viewerContext.canManageTaskLifecycle) { - throw createHttpError('当前账号没有权限退还号码', { - statusCode: 403, - errorCode: 'admin_task_return_kuaishou_cloud_forbidden', - }) - } - - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'admin_task_not_kuaishou_cloud', - }) - } - - // admin 退号 = 清理虚拟号占位,不核销电子凭证 - const result = await returnFulfillmentNumber(task, { - source: 'admin_task_return_number', - actor: buildAdminActionActor(session), - consumeIndustryVoucher: false, - }) - if (!result?.task) { - throw createHttpError('当前任务不支持退还号码', { - statusCode: 409, - errorCode: 'admin_task_return_not_supported', - }) - } - - return { - task: mapTaskActionPayload(result.task), - } -} - export async function resendAdminTaskKuaishouIndustryVoucherCode( taskId: AdminEntityIdInput, session: AdminViewerSessionInput | null = null, @@ -250,7 +52,7 @@ export async function resendAdminTaskKuaishouIndustryVoucherCode( if (!result.success) { const message = String(result.error || '电子凭证发码回调重发失败').trim() - const failedTask = await updateTask(task.id, { + await updateTask(task.id, { task_status: TASK_STATUS.MANUAL_REVIEW, result_code: 'kuaishou_industry_send_callback_resend_failed', result_message: message, @@ -268,7 +70,6 @@ export async function resendAdminTaskKuaishouIndustryVoucherCode( }, now, ) - void failedTask throw createHttpError(message, { statusCode: 409, errorCode: 'admin_task_kuaishou_industry_resend_failed', @@ -405,7 +206,7 @@ export async function consumeAdminTaskKuaishouIndustryVoucher( if (!result.ok || !result.voucher) { const message = String(result.errorMessage || '电子凭证核销失败').trim() - const failedTask = await updateTask(task.id, { + await updateTask(task.id, { task_status: TASK_STATUS.MANUAL_REVIEW, result_code: 'kuaishou_industry_manual_consume_failed', result_message: message, @@ -423,7 +224,6 @@ export async function consumeAdminTaskKuaishouIndustryVoucher( }, now, ) - void failedTask throw createHttpError(message, { statusCode: 409, errorCode: 'admin_task_kuaishou_industry_consume_failed', @@ -436,30 +236,6 @@ export async function consumeAdminTaskKuaishouIndustryVoucher( }) const consumeTargetTask = attachedConsumeTask || task const taskContext = parseTaskContext(consumeTargetTask) - const hasCloudFulfillmentContext = Boolean( - taskContext.kuaishouCloudFulfillment && - typeof taskContext.kuaishouCloudFulfillment === 'object' && - !Array.isArray(taskContext.kuaishouCloudFulfillment), - ) - const flow = hasCloudFulfillmentContext - ? normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - : null - const nextContext = { - ...taskContext, - ...(flow - ? { - kuaishouCloudFulfillment: { - ...flow, - consume: { - ...flow.consume, - status: 'success', - consumedAt: result.voucher.consumed_at || now, - errorMessage: '', - }, - }, - } - : {}), - } const updatedTask = await updateTask(consumeTargetTask.id, { task_status: TASK_STATUS.COMPLETED, delivery_status: 'delivered', @@ -467,7 +243,7 @@ export async function consumeAdminTaskKuaishouIndustryVoucher( result_message: '电子凭证已手动核销完成', redeemed_at: result.voucher.consumed_at || now, last_error: '', - context_json: JSON.stringify(nextContext), + context_json: JSON.stringify(taskContext), updated_at: now, }) diff --git a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts index 068a99c3..0b4ae571 100644 --- a/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts +++ b/apps/backend/src/services/bootstrap/fulfillment-bootstrap-service.ts @@ -27,24 +27,6 @@ const CORE_PROFILES: CoreProfile[] = [ inventoryStrategy: 'static_pool', requirements: [], }, - { - profileKey: 'kuaishou_ct_assisted', - name: 'kuaishou-lewan 履约', - executorKey: 'kuaishou_ct_assisted', - requiresClaim: false, - autoDispatch: false, - inventoryStrategy: 'external_platform', - requirements: [], - }, - { - profileKey: 'kuaishou_feifei', - name: 'kuaishou-feifei 履约', - executorKey: 'kuaishou_feifei', - requiresClaim: true, - autoDispatch: false, - inventoryStrategy: 'external_platform', - requirements: [], - }, { profileKey: 'affiliate_dash', name: 'affiliate-dash 履约', diff --git a/apps/backend/src/services/claim/claim-identity.test.ts b/apps/backend/src/services/claim/claim-identity.test.ts index 17125cfc..3c1b0380 100644 --- a/apps/backend/src/services/claim/claim-identity.test.ts +++ b/apps/backend/src/services/claim/claim-identity.test.ts @@ -3,7 +3,6 @@ import test from 'node:test' import { appendUidToUrl, - assertLewanAutoFulfillmentUidReady, assertValidClaimUid, buildClaimIdentityAdminSummary, isClaimUidMatched, @@ -56,39 +55,6 @@ test('appendUidToUrl overwrites existing uid', () => { assert.equal(new URL(next).searchParams.get('uid'), 'new-uid') }) -test('assertLewanAutoFulfillmentUidReady rejects legacy task without uid', () => { - assert.throws( - () => - assertLewanAutoFulfillmentUidReady({ - context_json: JSON.stringify({ kuaishouCloudFulfillment: {} }), - }), - /尚未填写游戏 UID/, - ) -}) - -test('assertLewanAutoFulfillmentUidReady allows mock without uid', () => { - assert.equal( - assertLewanAutoFulfillmentUidReady({ - context_json: JSON.stringify({ - kuaishouCloudFulfillment: { mock: { enabled: true } }, - }), - }), - '', - ) -}) - -test('assertLewanAutoFulfillmentUidReady accepts submitted uid', () => { - assert.equal( - assertLewanAutoFulfillmentUidReady({ - context_json: JSON.stringify({ - claimIdentity: { expectedUid: '10001', submittedAt: '2026-07-10T00:00:00.000Z' }, - kuaishouCloudFulfillment: {}, - }), - }), - '10001', - ) -}) - test('buildClaimIdentityAdminSummary reports match state', () => { const summary = buildClaimIdentityAdminSummary( { diff --git a/apps/backend/src/services/claim/claim-identity.ts b/apps/backend/src/services/claim/claim-identity.ts index bdc9a283..d311a12f 100644 --- a/apps/backend/src/services/claim/claim-identity.ts +++ b/apps/backend/src/services/claim/claim-identity.ts @@ -108,38 +108,6 @@ export function assertClaimExpectedUidReady(task: Partial | null | unde return expectedUid } -/** - * lewan 自动发货前:必须已有 expectedUid(不再回落「仅确认角色」旧路径)。 - * mock 任务可跳过。 - */ -export function assertLewanAutoFulfillmentUidReady( - task: Partial | null | undefined, - options: { allowMockSkip?: boolean; errorCodePrefix?: string } = {}, -): string { - const context = parseTaskContext(task) - const flow = - context.kuaishouCloudFulfillment && typeof context.kuaishouCloudFulfillment === 'object' - ? (context.kuaishouCloudFulfillment as JsonObject) - : {} - const mock = flow.mock && typeof flow.mock === 'object' ? (flow.mock as JsonObject) : null - if (options.allowMockSkip !== false && mock?.enabled === true) { - return getClaimIdentityFromTask(task).expectedUid - } - - const expectedUid = getClaimIdentityFromTask(task).expectedUid - if (!expectedUid) { - const prefix = String(options.errorCodePrefix || 'kuaishou_cloud').trim() || 'kuaishou_cloud' - throw createHttpError( - '该 lewan 任务尚未填写游戏 UID,请用户打开领取页提交 UID 后再发货(旧单不再支持无 UID 自动发货)', - { - statusCode: 409, - errorCode: `${prefix}_expected_uid_required`, - }, - ) - } - return expectedUid -} - export function buildClaimIdentityAdminSummary( context: unknown, options: { flowLike?: unknown; taskRoleId?: unknown; taskRoleName?: unknown } = {}, diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index f862c596..a10240ec 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -8,13 +8,18 @@ import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json import { nowIso } from '../../utils/time.js' import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js' import { buildClaimUrl } from './claim-service.js' -import { buildClaimIdentityPayload, getClaimIdentityFromContext } from './claim-identity.js' -import { resolveKuaishouFeifeiH5UrlWithUid } from '../fulfillment/kuaishou-feifei/index.js' +import { buildClaimIdentityPayload } from './claim-identity.js' import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js' import type { ClaimTokenRow, OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' -import { asJsonObject, type JsonObject } from '../../types/json.js' +import type { JsonObject } from '../../types/json.js' export const CLAIM_TERMINAL_STATUSES = new Set([TASK_STATUS.EXPIRED, TASK_STATUS.CLOSED]) + +const RETIRED_CHANNEL_MESSAGE: Record = { + kuaishou_feifei: '飞飞履约渠道已下线,本链接仅可查看历史信息,无法继续领取', + kuaishou_ct_assisted: '乐玩履约渠道已下线,本链接仅可查看历史信息,无法继续领取', +} + type ClaimContext = { claimToken: ClaimTokenRow task: TaskRow @@ -88,73 +93,17 @@ export async function getClaimContext(token: unknown) { } export function buildClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { - if (String(task.executor_key || '').trim() === 'kuaishou_feifei') { - return buildKuaishouFeifeiClaimDetailPayload({ claimToken, task, order, orderItem }) - } + const executorKey = String(task.executor_key || '').trim() - if (String(task.executor_key || '').trim() === 'affiliate_dash') { + if (executorKey === 'affiliate_dash') { return buildAffiliateDashClaimDetailPayload({ claimToken, task, order, orderItem }) } - const taskContext = parseTaskContext(task) - const claimIdentity = buildClaimIdentityPayload(taskContext.claimIdentity) - const kuaishouCloudSource = resolveClaimKuaishouCloudSource(task) - const kuaishouCloudFulfillment = mapClaimKuaishouCloudFulfillment(task, order) - const displaySkuName = resolveClaimOrderItemDisplaySkuName( - orderItem, - kuaishouCloudFulfillment, - kuaishouCloudSource, - ) - const product = buildClaimProductPayload(displaySkuName, orderItem, kuaishouCloudSource) - - return { - tokenStatus: claimToken.status, - claimUrl: buildClaimUrl(claimToken.token), - flowType: 'kuaishou_cloud', - claimIdentity, - task: { - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - executorKey: task.executor_key || '', - requiresSupportReview: false, - expiresAt: claimToken.expired_at, - claimedAt: task.claimed_at, - roleConfirmedAt: task.role_confirmed_at, - redeemedAt: task.redeemed_at, - loginType: task.login_type, - lastError: task.last_error, - runtimeSessionId: task.runtime_session_id, - }, - order: { - orderId: order.id, - platform: order.platform, - platformOrderId: order.platform_order_id, - payStatus: order.pay_status, - orderStatus: order.order_status, - totalAmount: formatFenToAmount(order.total_amount), - totalAmountFen: normalizeFen(order.total_amount), - currency: order.currency, - }, - orderItem: { - orderItemId: orderItem.id, - skuCode: orderItem.sku_code, - skuName: displaySkuName, - quantity: orderItem.quantity, - }, - product, - session: null as null, - kuaishouCloudFulfillment, - kuaishouFeifei: null as null, - result: task.redeemed_at - ? { - resultCode: String(task.result_code || ''), - resultMessage: String(task.result_message || ''), - screenshotReady: false, - screenshotUrl: '', - } - : null, + if (executorKey === 'kuaishou_feifei' || executorKey === 'kuaishou_ct_assisted') { + return buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }) } + + return buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }) } function buildAffiliateDashClaimDetailPayload({ @@ -186,32 +135,12 @@ function buildAffiliateDashClaimDetailPayload({ return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), - flowType: 'affiliate_dash', + flowType: 'affiliate_dash' as const, + channelOffline: false, + channelOfflineMessage: '', claimIdentity, - task: { - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - executorKey: task.executor_key || '', - requiresSupportReview: false, - expiresAt: claimToken.expired_at, - claimedAt: task.claimed_at, - roleConfirmedAt: task.role_confirmed_at, - redeemedAt: task.redeemed_at, - loginType: task.login_type, - lastError: task.last_error, - runtimeSessionId: task.runtime_session_id, - }, - order: { - orderId: order.id, - platform: order.platform, - platformOrderId: order.platform_order_id, - payStatus: order.pay_status, - orderStatus: order.order_status, - totalAmount: formatFenToAmount(order.total_amount), - totalAmountFen: normalizeFen(order.total_amount), - currency: order.currency, - }, + task: buildClaimTaskPayload(task, claimToken), + order: buildClaimOrderPayload(order), orderItem: { orderItemId: orderItem.id, skuCode: orderItem.sku_code, @@ -223,313 +152,140 @@ function buildAffiliateDashClaimDetailPayload({ kuaishouCloudFulfillment: null as null, kuaishouFeifei: null as null, affiliateDash: flow, - result: task.redeemed_at - ? { - resultCode: String(task.result_code || ''), - resultMessage: String(task.result_message || ''), - screenshotReady: false, - screenshotUrl: '', - } - : null, + result: buildClaimResultPayload(task), } } -function buildKuaishouFeifeiClaimDetailPayload({ - claimToken, - task, - order, - orderItem, -}: ClaimContext) { +function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { + const executorKey = String(task.executor_key || '').trim() const context = parseTaskContext(task) const claimIdentity = buildClaimIdentityPayload(context.claimIdentity) - const expectedUid = getClaimIdentityFromContext(context).expectedUid - const flow = mapClaimKuaishouFeifeiFulfillment(context.kuaishouFeifei, expectedUid) - const product = { - title: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(), - skuCode: String(orderItem.sku_code || '').trim(), - quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), - isBundle: false, - items: [ - { - cloudSkuId: 0, - name: String(flow.productName || orderItem.sku_name || orderItem.sku_code || '').trim(), - quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), - }, - ], - } + const productTitle = String(orderItem.sku_name || orderItem.sku_code || '').trim() + const offlineMessage = + RETIRED_CHANNEL_MESSAGE[executorKey] || '该履约渠道已下线,本链接仅可查看历史信息' return { tokenStatus: claimToken.status, claimUrl: buildClaimUrl(claimToken.token), - flowType: 'kuaishou_feifei', + flowType: executorKey === 'kuaishou_feifei' ? ('kuaishou_feifei' as const) : ('kuaishou_cloud' as const), + channelOffline: true, + channelOfflineMessage: offlineMessage, claimIdentity, - task: { - taskId: task.id, - taskNo: task.task_no, - status: task.task_status, - executorKey: task.executor_key || '', - requiresSupportReview: false, - expiresAt: claimToken.expired_at, - claimedAt: task.claimed_at, - roleConfirmedAt: task.role_confirmed_at, - redeemedAt: task.redeemed_at, - loginType: task.login_type, - lastError: task.last_error, - runtimeSessionId: task.runtime_session_id, - }, - order: { - orderId: order.id, - platform: order.platform, - platformOrderId: order.platform_order_id, - payStatus: order.pay_status, - orderStatus: order.order_status, - totalAmount: formatFenToAmount(order.total_amount), - totalAmountFen: normalizeFen(order.total_amount), - currency: order.currency, - }, + task: buildClaimTaskPayload(task, claimToken), + order: buildClaimOrderPayload(order), orderItem: { orderItemId: orderItem.id, skuCode: orderItem.sku_code, - skuName: product.title, + skuName: productTitle, quantity: orderItem.quantity, }, - product, + product: { + title: productTitle, + skuCode: String(orderItem.sku_code || '').trim(), + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + isBundle: false, + items: [ + { + cloudSkuId: 0, + name: productTitle, + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + }, + ], + }, session: null as null, kuaishouCloudFulfillment: null as null, - kuaishouFeifei: flow, - result: task.redeemed_at - ? { - resultCode: String(task.result_code || ''), - resultMessage: String(task.result_message || ''), - screenshotReady: false, - screenshotUrl: '', - } - : null, - } -} - -function mapClaimKuaishouFeifeiFulfillment(value: unknown, expectedUid = '') { - const source = isPlainObject(value) ? value : {} - const h5 = isPlainObject(source.h5) ? source.h5 : {} - const entryUrl = String(h5.entryUrl || '').trim() - const rechargeUrl = String(h5.rechargeUrl || '').trim() - const h5UrlWithUid = resolveKuaishouFeifeiH5UrlWithUid(source, expectedUid) - - return { - flowType: 'kuaishou_feifei', - productCode: String(source.productCode || '').trim(), - productName: String(source.productName || '').trim(), - platformOrderNo: String(source.platformOrderNo || '').trim(), - orderNo: String(source.orderNo || '').trim(), - rechargeStatus: Number(source.rechargeStatus || 0) || 0, - rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(), - rechargeResultMessage: String(source.rechargeResultMessage || '').trim(), - claimUrl: String(source.claimUrl || '').trim(), - consumeStatus: String(source.consumeStatus || 'pending').trim(), - h5: { - entryUrl, - rechargeUrl, + kuaishouFeifei: null as null, + affiliateDash: null as null, + result: buildClaimResultPayload(task) || { + resultCode: 'channel_offline', + resultMessage: offlineMessage, + screenshotReady: false, + screenshotUrl: '', }, - h5UrlWithUid: expectedUid ? h5UrlWithUid : '', - lastSyncedAt: source.lastSyncedAt || null, } } -function resolveClaimOrderItemDisplaySkuName( - orderItem: OrderItemRow, - kuaishouCloudFulfillment: JsonObject | null, - kuaishouCloudSource: JsonObject = {}, -) { - const fulfillment = isPlainObject(kuaishouCloudFulfillment) ? kuaishouCloudFulfillment : {} - const binding = isPlainObject(fulfillment.binding) ? fulfillment.binding : {} - const ticket = isPlainObject(fulfillment.ticket) ? fulfillment.ticket : {} - const skuCode = String(orderItem.sku_code || '').trim() - const rawSkuName = String(orderItem.sku_name || '').trim() - const candidates = [ - kuaishouCloudSource.internalSkuName, - rawSkuName, - ticket.goodsTitle, - binding.skuName, - skuCode, - ] - - for (const value of candidates) { - const normalized = String(value || '').trim() - if (normalized && !isOrderItemIdentifierName(normalized, skuCode)) { - return normalized - } - } - - return rawSkuName || skuCode -} - -function buildClaimProductPayload( - displaySkuName: string, - orderItem: OrderItemRow, - kuaishouCloudSource: JsonObject = {}, -) { - const binding = isPlainObject(kuaishouCloudSource.binding) ? kuaishouCloudSource.binding : {} - const deliveryItems = normalizeClaimDeliveryItems(kuaishouCloudSource.deliveryItems, binding) - .map((item) => ({ - cloudSkuId: item.cloudSkuId, - name: item.cloudSkuName, - quantity: item.quantity, - })) - .filter((item) => item.name) - const normalizedDeliveryItems = - deliveryItems.length > 0 - ? mergeClaimProductItems(deliveryItems) - : [ - { - cloudSkuId: 0, - name: displaySkuName, - quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), - }, - ] +function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }: ClaimContext) { + const context = parseTaskContext(task) + const claimIdentity = buildClaimIdentityPayload(context.claimIdentity) + const productTitle = String(orderItem.sku_name || orderItem.sku_code || '').trim() return { - title: displaySkuName, - skuCode: String(orderItem.sku_code || '').trim(), - quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), - isBundle: normalizedDeliveryItems.length > 1, - items: normalizedDeliveryItems, + tokenStatus: claimToken.status, + claimUrl: buildClaimUrl(claimToken.token), + flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch', + channelOffline: false, + channelOfflineMessage: '', + claimIdentity, + task: buildClaimTaskPayload(task, claimToken), + order: buildClaimOrderPayload(order), + orderItem: { + orderItemId: orderItem.id, + skuCode: orderItem.sku_code, + skuName: productTitle, + quantity: orderItem.quantity, + }, + product: { + title: productTitle, + skuCode: String(orderItem.sku_code || '').trim(), + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + isBundle: false, + items: [ + { + cloudSkuId: 0, + name: productTitle, + quantity: Math.max(1, Number(orderItem.quantity || 1) || 1), + }, + ], + }, + session: null as null, + kuaishouCloudFulfillment: null as null, + kuaishouFeifei: null as null, + affiliateDash: null as null, + result: buildClaimResultPayload(task), } } -function mergeClaimProductItems( - items: Array<{ cloudSkuId: number; name: string; quantity: number }>, -) { - const merged = new Map() - - for (const item of items) { - const key = item.cloudSkuId > 0 ? `id:${item.cloudSkuId}` : `name:${item.name}` - const existing = merged.get(key) - if (existing) { - existing.quantity += item.quantity - existing.name = existing.name || item.name - continue - } - - merged.set(key, { ...item }) +function buildClaimTaskPayload(task: TaskRow, claimToken: ClaimTokenRow) { + return { + taskId: task.id, + taskNo: task.task_no, + status: task.task_status, + executorKey: task.executor_key || '', + requiresSupportReview: false, + expiresAt: claimToken.expired_at, + claimedAt: task.claimed_at, + roleConfirmedAt: task.role_confirmed_at, + redeemedAt: task.redeemed_at, + loginType: task.login_type, + lastError: task.last_error, + runtimeSessionId: task.runtime_session_id, } - - return Array.from(merged.values()) } -function resolveClaimKuaishouCloudSource(task: TaskRow): JsonObject { - if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { - return {} +function buildClaimOrderPayload(order: OrderRow) { + return { + orderId: order.id, + platform: order.platform, + platformOrderId: order.platform_order_id, + payStatus: order.pay_status, + orderStatus: order.order_status, + totalAmount: formatFenToAmount(order.total_amount), + totalAmountFen: normalizeFen(order.total_amount), + currency: order.currency, } - - const source = parseTaskContext(task).kuaishouCloudFulfillment - return isPlainObject(source) ? source : {} } -export function mapClaimKuaishouCloudFulfillment(task: TaskRow, order: OrderRow) { - if (String(task?.executor_key || '').trim() !== 'kuaishou_ct_assisted') { +function buildClaimResultPayload(task: TaskRow) { + if (!task.redeemed_at) { return null } - const source = parseTaskContext(task).kuaishouCloudFulfillment - if (!source || typeof source !== 'object') { - return null - } - - const binding = asJsonObject(source.binding) - const role = asJsonObject(source.role) - const purchase = asJsonObject(source.purchase) - const ticket = asJsonObject(source.ticket) - const dispatch = asJsonObject(source.dispatch) - const returnNumber = asJsonObject(source.returnNumber) - const consume = asJsonObject(source.consume) - const roleName = String(role.name || binding.roleName || '').trim() - const roleId = String(role.rid || binding.roleId || '').trim() - const defaultRoleName = String(role.defaultName || role.defaultRoleName || '').trim() - const defaultRoleId = String(role.defaultRid || role.defaultRoleId || '').trim() - const isDefaultRole = - Boolean(roleName || roleId) && - isSameClaimKuaishouCloudRole( - { name: roleName, rid: roleId }, - { name: defaultRoleName, rid: defaultRoleId }, - ) - return { - flowType: 'kuaishou_cloud', - shopId: String(order?.shop_id || '').trim(), - shopName: String(order?.shop_name || '').trim(), - guideImages: [], - ticket: { - code: String(ticket.code || '').trim(), - status: String(ticket.status || 'pending').trim() || 'pending', - capturedAt: ticket.capturedAt || null, - verifiedAt: ticket.verifiedAt || null, - goodsTitle: String(ticket.goodsTitle || '').trim(), - leftCount: Number(ticket.leftCount || 0) || 0, - }, - binding: { - prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', - cloudSourceKeys: Array.isArray(binding.cloudSourceKeys) - ? binding.cloudSourceKeys - .map((value: unknown) => String(value || '').trim()) - .filter(Boolean) - : [], - resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), - skuId: Number(binding.skuId || 0) || 0, - skuName: String(binding.skuName || '').trim(), - vnKey: String(binding.vnKey || '').trim(), - vnId: Number(binding.vnId || 0) || 0, - bindUrl: String(binding.bindUrl || '').trim(), - bindPreparedAt: binding.bindPreparedAt || null, - bindExpiresAt: binding.bindExpiresAt || null, - bindProbeAt: binding.bindProbeAt || null, - bindProbeStatus: String(binding.bindProbeStatus || '').trim(), - bindProbeMessage: String(binding.bindProbeMessage || '').trim(), - vnPhone: String(binding.vnPhone || '').trim(), - roleName: String(binding.roleName || '').trim(), - roleId: String(binding.roleId || '').trim(), - }, - role: { - status: String(role.status || 'pending').trim() || 'pending', - name: roleName, - rid: roleId, - refreshedAt: role.refreshedAt || null, - errorMessage: String(role.errorMessage || '').trim(), - defaultName: defaultRoleName, - defaultRid: defaultRoleId, - defaultCapturedAt: role.defaultCapturedAt || null, - defaultCaptureStatus: String(role.defaultCaptureStatus || '').trim(), - defaultErrorMessage: String(role.defaultErrorMessage || '').trim(), - isDefaultRole, - }, - purchase: { - autoBuyEnabled: purchase.autoBuyEnabled !== false, - minAssetReserve: Number(purchase.minAssetReserve || 0) || 0, - usedKnapsack: purchase.usedKnapsack === true, - purchaseTriggered: purchase.purchaseTriggered === true, - assetBefore: Number(purchase.assetBefore || 0) || 0, - assetAfter: Number(purchase.assetAfter || 0) || 0, - purchaseAt: purchase.purchaseAt || null, - }, - dispatch: { - status: String(dispatch.status || 'pending').trim() || 'pending', - dispatchAt: dispatch.dispatchAt || null, - failedAt: dispatch.failedAt || null, - failedStage: String(dispatch.failedStage || '').trim(), - errorCode: String(dispatch.errorCode || '').trim(), - errorMessage: String(dispatch.errorMessage || '').trim(), - note: String(dispatch.note || '').trim(), - }, - returnNumber: { - status: String(returnNumber.status || 'pending').trim() || 'pending', - returnedAt: returnNumber.returnedAt || null, - }, - consume: { - status: String(consume.status || 'pending').trim() || 'pending', - shopId: String(consume.shopId || '').trim(), - shopName: String(consume.shopName || '').trim(), - consumedAt: consume.consumedAt || null, - errorMessage: String(consume.errorMessage || '').trim(), - }, + resultCode: String(task.result_code || ''), + resultMessage: String(task.result_message || ''), + screenshotReady: false, + screenshotUrl: '', } } @@ -537,115 +293,6 @@ function parseTaskContext(task: TaskRow): JsonObject { return parseTaskContextValue(task) } -function isOrderItemIdentifierName(value: string, skuCode: string) { - const normalized = String(value || '').trim() - if (!normalized) { - return true - } - - if (isClaimPlaceholderSkuName(normalized)) { - return true - } - - const normalizedSkuCode = String(skuCode || '').trim() - if (normalizedSkuCode && normalized === normalizedSkuCode && /^\d{8,}$/.test(normalizedSkuCode)) { - return true - } - - return /^\d{8,}$/.test(normalized) -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} - -function isSameClaimKuaishouCloudRole( - left: { name?: unknown; rid?: unknown }, - right: { name?: unknown; rid?: unknown }, -) { - const leftRid = String(left.rid || '').trim() - const rightRid = String(right.rid || '').trim() - if (leftRid && rightRid) { - return leftRid === rightRid - } - - const leftName = normalizeClaimRoleNameForCompare(left.name) - const rightName = normalizeClaimRoleNameForCompare(right.name) - return Boolean(leftName && rightName && leftName === rightName) -} - -function normalizeClaimRoleNameForCompare(value: unknown) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/\s+/g, '') -} - -function normalizeClaimDeliveryItems(value: unknown, binding: JsonObject) { - const rawItems = Array.isArray(value) ? value : [] - const items = rawItems - .map((item) => normalizeClaimDeliveryItem(item)) - .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => - Boolean(item), - ) - - if (items.length > 0) { - return mergeClaimDeliveryItems(items) - } - - const cloudSkuId = Number(binding.skuId || 0) || 0 - if (!cloudSkuId) { - return [] - } - - return [ - { - cloudSkuId, - cloudSkuName: String(binding.skuName || '').trim(), - quantity: 1, - }, - ] -} - -function normalizeClaimDeliveryItem(value: unknown) { - const source = isPlainObject(value) ? value : {} - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 - - if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { - return null - } - - const quantity = Number(source.quantity || 1) || 1 - return { - cloudSkuId, - cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), - quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, - } -} - -function mergeClaimDeliveryItems( - items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, -) { - const merged = new Map() - - for (const item of items) { - const existing = merged.get(item.cloudSkuId) - if (existing) { - existing.quantity += item.quantity - existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName - continue - } - - merged.set(item.cloudSkuId, { ...item }) - } - - return Array.from(merged.values()) -} - -function isClaimPlaceholderSkuName(value: string) { - return ['测试链接专用', '商品领取', '测试商品'].includes(String(value || '').trim()) -} - async function expireClaimContext(claimToken: ClaimTokenRow, task: TaskRow) { const now = nowIso() const nextClaimToken = await updateClaimToken(claimToken.id, { diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts index 04700298..09de4030 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.ts @@ -5,17 +5,6 @@ import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json import { nowIso } from '../../utils/time.js' import type { JsonObject } from '../../types/json.js' import { TASK_STATUS } from '../../domain/task-status.js' -import { - confirmFulfillmentRole, - prepareFulfillmentBinding, - rebindFulfillmentRole, - redeemFulfillmentTask, -} from '../fulfillment/executors/registry.js' -import { - isKuaishouCloudMockTask, - normalizeKuaishouCloudFlow, -} from '../fulfillment/kuaishou-cloud/index.js' -import { syncKuaishouFeifeiTaskStatus } from '../fulfillment/kuaishou-feifei/index.js' import { normalizeAffiliateDashFlow, syncAffiliateDashTaskStatus, @@ -31,79 +20,23 @@ import { getClaimIdentityFromTask, } from './claim-identity.js' import { buildClaimDetailPayload, getClaimContext } from './kuaishou-cloud-claim-context.js' -import { syncKuaishouCloudRoleInfo } from './kuaishou-cloud-sync-service.js' import type { TaskRow } from '../../types/repository/rows.js' type ClaimDetailPayload = ReturnType -function assertLewanClaimTask(task: TaskRow) { - if (String(task.executor_key || '').trim() !== 'kuaishou_ct_assisted') { - throw createHttpError('当前领取链接不是 kuaishou-lewan 客户领取流程', { - statusCode: 409, - errorCode: 'claim_not_kuaishou_cloud', - }) - } +const RETIRED_EXECUTOR_KEYS = new Set(['kuaishou_feifei', 'kuaishou_ct_assisted']) + +function isRetiredExecutor(executorKey: unknown) { + return RETIRED_EXECUTOR_KEYS.has(String(executorKey || '').trim()) } -async function requireExecutorAction( - result: Promise | T | null, - errorMessage: string, - errorCode: string, -): Promise> { - const value = await result - if (!value) { - throw createHttpError(errorMessage, { - statusCode: 409, - errorCode, - }) - } - return value -} +export async function getKuaishouCloudClaimDetail(token: unknown): Promise { + const context = await getClaimContext(token) + let task = context.task + const executorKey = String(task.executor_key || '').trim() -function isKuaishouCloudBindingReady(flow: ReturnType) { - return ( - flow.binding.prepareStatus === 'ready' && Boolean(String(flow.binding.bindUrl || '').trim()) - ) -} - -/** - * 确保 Cloud 绑定资源已准备(取号 + 绑链)。 - * 提前核销后仍可能未 prepare,不能因 consume.success 跳过。 - */ -async function ensureKuaishouCloudBindingPrepared( - task: TaskRow, - options: { source: string; actorSource?: string } = { source: 'system_auto_prepare_binding' }, -): Promise { - const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) - if (isKuaishouCloudBindingReady(flow)) { - return task - } - - try { - const prepared = await prepareFulfillmentBinding(task, { - source: options.source, - actor: { source: options.actorSource || options.source }, - }) - return prepared?.task || task - } catch { - return task - } -} - -async function verifyIndustryVoucherTicket( - context: Awaited>, - now: string, -): Promise { - const taskContext = parseTaskContext(context.task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - - // 提前核销后 consume 已是 success,但仍可能未准备绑定资源;禁止整段短路。 - // 注意:不要再调 getKuaishouCloudClaimDetail,避免 prepare 失败时递归。 - if (flow.consume.status === 'success') { - const task = await ensureKuaishouCloudBindingPrepared(context.task, { - source: 'claim_after_early_consume_prepare', - actorSource: 'system', - }) + // 已下线渠道:只读详情,不主动同步上游 + if (isRetiredExecutor(executorKey)) { return buildClaimDetailPayload({ claimToken: context.claimToken, task, @@ -112,127 +45,6 @@ async function verifyIndustryVoucherTicket( }) } - const industryContext = typeof taskContext === 'object' ? taskContext : {} - const voucherContext = normalizeIndustryVoucherContext(industryContext.kuaishouIndustryVoucher) - if (voucherContext.status === 'DESTROYED') { - throw createHttpError('当前电子凭证已销毁,无法继续领取', { - statusCode: 409, - errorCode: 'claim_kuaishou_industry_voucher_destroyed', - }) - } - - const token = String(voucherContext.token || industryContext.token || '').trim() - const voucherCode = String(voucherContext.voucherCode || voucherContext.eticketId || '').trim() - const certExpireType = Number(industryContext.certExpireType || 0) - const certActualStartTime = Number( - voucherContext.validStartTime || industryContext.certActualStartTime || 0, - ) - const certActualEndTime = Number( - voucherContext.validEndTime || industryContext.certActualEndTime || 0, - ) - - const nextContext = { - ...taskContext, - kuaishouIndustryVoucher: { - ...voucherContext, - oid: voucherContext.oid || context.order.platform_order_id, - token, - eticketId: voucherCode, - voucherCode, - status: voucherContext.status || 'UNUSED', - verifiedAt: voucherContext.verifiedAt || now, - }, - kuaishouCloudFulfillment: { - ...flow, - ticket: { - ...flow.ticket, - code: voucherCode, - status: 'verified', - capturedAt: flow.ticket.capturedAt || now, - capturedBy: flow.ticket.capturedBy || { source: 'send_code_callback' }, - verifiedAt: now, - oid: voucherContext.oid || context.order.platform_order_id, - formToken: token, - leftCount: voucherContext.status === 'CONSUMED' ? 0 : 1, - goodsTitle: context.orderItem.sku_name || context.orderItem.sku_code || '', - }, - consume: { - ...flow.consume, - status: voucherContext.status === 'CONSUMED' ? 'success' : 'pending', - shopId: context.order.shop_id, - shopName: context.order.shop_name, - autoConsumeEnabled: true, - consumedAt: - voucherContext.status === 'CONSUMED' - ? voucherContext.consumedAt || flow.consume.consumedAt || now - : flow.consume.consumedAt, - }, - certInfo: { - certExpireType, - certActualStartTime, - certActualEndTime, - }, - }, - } - - const taskWithTicket = (await updateTask(context.task.id, { - claim_token: context.claimToken.token, - claim_expires_at: context.claimToken.expired_at, - context_json: JSON.stringify(nextContext), - claimed_at: context.task.claimed_at || now, - task_status: TASK_STATUS.WAITING_BINDING, - user_action_status: 'pending_claim', - last_error: '', - updated_at: now, - })) || { - ...context.task, - context_json: JSON.stringify(nextContext), - claimed_at: context.task.claimed_at || now, - task_status: TASK_STATUS.WAITING_BINDING, - user_action_status: 'pending_claim', - last_error: '', - updated_at: now, - } - - const preparedFlow = normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment) - let taskAfterPrepare = taskWithTicket - if (!isKuaishouCloudBindingReady(preparedFlow)) { - taskAfterPrepare = await ensureKuaishouCloudBindingPrepared(taskWithTicket, { - source: 'send_code_callback', - actorSource: 'send_code_callback', - }) - } - - await createTaskEvent( - context.task.id, - 'kuaishou_cloud_ticket_verified', - { - ticketCodeMasked: '', - source: 'send_code_callback', - industryVoucher: true, - verifiedAt: now, - }, - now, - ) - - return buildClaimDetailPayload({ - claimToken: context.claimToken, - task: taskAfterPrepare, - order: context.order, - orderItem: context.orderItem, - }) -} - -export async function getKuaishouCloudClaimDetail(token: unknown): Promise { - const context = await getClaimContext(token) - let task = context.task - - const executorKey = String(task.executor_key || '').trim() - - if (executorKey === 'kuaishou_feifei') { - task = (await syncKuaishouFeifeiTaskStatus(task)) || task - } - if (executorKey === 'affiliate_dash') { task = (await syncAffiliateDashTaskStatus(task)) || task if (shouldRefreshAffiliateDashBindState(task)) { @@ -240,64 +52,6 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise null) - if (prepared) { - return prepared - } - } - } - - if (executorKey === 'kuaishou_ct_assisted') { - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const needsIndustryVoucherPrepare = - hasUsableIndustryVoucher(taskContext) && - (flow.ticket.status !== 'verified' || !isKuaishouCloudBindingReady(flow)) - - if (needsIndustryVoucherPrepare) { - const now = nowIso() - const prepared: ClaimDetailPayload | null = await verifyIndustryVoucherTicket( - context, - now, - ).catch(() => null) - if (prepared) { - return prepared - } - } - - // 兜底:不依赖 UID;凭证已确认但绑定未就绪时自动补 prepare(覆盖提前核销场景) - if (!isKuaishouCloudMockTask(task)) { - const latestFlow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) - if ( - (latestFlow.ticket.status === 'verified' || - hasUsableIndustryVoucher(parseTaskContext(task))) && - !isKuaishouCloudBindingReady(latestFlow) - ) { - task = await ensureKuaishouCloudBindingPrepared(task, { - source: 'claim_page_auto_binding_prepare', - actorSource: 'system', - }) - } - } - } - - // 已提交 UID 的 lewan 任务:详情查询时顺带刷新角色,便于前端轮询匹配状态 - if ( - executorKey === 'kuaishou_ct_assisted' && - !isKuaishouCloudMockTask(task) && - getClaimIdentityFromTask(task).expectedUid - ) { - task = (await syncKuaishouCloudRoleInfo(task)) || task - } - return buildClaimDetailPayload({ claimToken: context.claimToken, task, @@ -308,12 +62,22 @@ export async function getKuaishouCloudClaimDetail(token: unknown): Promise { const context = await getClaimContext(token) + const executorKey = String(context.task.executor_key || '').trim() + + if (isRetiredExecutor(executorKey)) { + throw createHttpError('该履约渠道已下线,无法继续提交 UID', { + statusCode: 409, + errorCode: 'claim_channel_offline', + }) + } + const expectedUid = assertValidClaimUid(payload.uid) const now = nowIso() @@ -326,7 +90,6 @@ export async function submitClaimUid( const taskContext = parseTaskContext(context.task) const previous = getClaimIdentityFromTask(context.task) - const executorKey = String(context.task.executor_key || '').trim() if (executorKey === 'affiliate_dash') { return submitAffiliateDashClaimUid(context, expectedUid, previous, taskContext, now, token) } @@ -336,17 +99,12 @@ export async function submitClaimUid( claimIdentity: buildClaimIdentityContext(previous, expectedUid, now), } - let updatedTask = (await updateTask(context.task.id, { + await updateTask(context.task.id, { context_json: JSON.stringify(nextContext), claimed_at: context.task.claimed_at || now, last_error: '', updated_at: now, - })) || { - ...context.task, - context_json: JSON.stringify(nextContext), - claimed_at: context.task.claimed_at || now, - updated_at: now, - } + }) await createClaimUidSubmittedEventIfChanged( context.task.id, @@ -355,20 +113,6 @@ export async function submitClaimUid( now, ) - if (executorKey === 'kuaishou_ct_assisted' && !isKuaishouCloudMockTask(updatedTask)) { - try { - const prepared = await prepareFulfillmentBinding(updatedTask, { - source: 'claim_page_submit_uid', - actor: { source: 'claim_page' }, - }) - if (prepared?.task) { - updatedTask = prepared.task - } - } catch { - // 绑定资源准备失败时仍保留 UID,详情页可继续重试/轮询 - } - } - return getKuaishouCloudClaimDetail(token) } @@ -586,8 +330,6 @@ export async function submitAffiliateDashClaim( const latestFlow = flow if (!isMock && flow.bindUuid) { - // 提交前不再额外请求 bind-result:平台侧曾出现 GET 后 submit 的 bind_verify 异常。 - // 这里仅使用详情轮询写入的本地快照做强拦截,真正提交仍交给平台二次校验。 if (isAffiliateDashBindSnapshotStale(flow, expectedGameAccount)) { throw createHttpError('绑定二维码与当前 UID 不一致,请更新 UID 后重新绑定', { statusCode: 409, @@ -664,8 +406,6 @@ export async function submitAffiliateDashClaim( now, ) - // 平台已返回 delivered:立即用本地快照收敛任务为成功,不等平台回调(避免结果页等数秒)。 - // delivered 回调到达时 sync 幂等短路(已 REDEEMED 且 delivered 仍会刷新,无副作用)。 if (result.status === 'delivered') { const refreshedTask = await getTaskById(task.id) if (refreshedTask) { @@ -789,7 +529,6 @@ async function refreshAffiliateDashBindState(task: TaskRow): Promise | null | undefined): JsonObject { return parseTaskContextValue(task) } - -function hasUsableIndustryVoucher(context: JsonObject = {}) { - const voucher = normalizeIndustryVoucherContext(context.kuaishouIndustryVoucher) - if (!voucher.voucherCode && !voucher.eticketId) { - return false - } - - return voucher.status !== 'DESTROYED' -} - -function normalizeIndustryVoucherContext(value: unknown): JsonObject { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - const status = String(source.status || 'UNUSED') - .trim() - .toUpperCase() - - return { - ...source, - oid: String(source.oid || '').trim(), - token: String(source.token || '').trim(), - eticketId: String(source.eticketId || source.voucherCode || '').trim(), - voucherCode: String(source.voucherCode || source.eticketId || '').trim(), - status: status === 'CONSUMED' || status === 'DESTROYED' ? status : 'UNUSED', - validStartTime: Number(source.validStartTime || 0) || 0, - validEndTime: Number(source.validEndTime || 0) || 0, - verifiedAt: source.verifiedAt || null, - consumedAt: source.consumedAt || null, - } -} diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts deleted file mode 100644 index b2f1adbe..00000000 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - prepareFulfillmentBinding, - refreshFulfillmentRole, -} from '../fulfillment/executors/registry.js' -import { - isKuaishouCloudBindingMutationFrozen, - normalizeKuaishouCloudFlow, -} from '../fulfillment/kuaishou-cloud/index.js' -import type { TaskRow } from '../../types/repository/rows.js' -import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' - -export async function syncKuaishouCloudRoleInfo(task: TaskRow) { - const flow = normalizeKuaishouCloudFlow(parseTaskContext(task).kuaishouCloudFulfillment) - - // 已发货/兑换中:禁止 claim 轮询换号或覆盖角色(脏单 status 可能已回退 waiting_binding) - if (isKuaishouCloudBindingMutationFrozen(task, flow)) { - return task - } - - if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.vnPhone) { - // ticket verified 或绑定尚未 ready:补 prepare(覆盖提前核销后未取号场景) - if ( - flow.ticket.status === 'verified' || - flow.binding.prepareStatus !== 'ready' || - !flow.binding.bindUrl - ) { - try { - const prepared = await prepareFulfillmentBinding(task, { - source: 'claim_page_retry_binding_prepare', - actor: { source: 'system' }, - }) - return prepared?.task || task - } catch { - return task - } - } - - return task - } - - try { - // 只走 refresh:内部会按间隔 probe 绑链,必要时再查 bind_info - // 不再 forceProbe + 前置 probe,避免同轮重复打上游 - const result = await refreshFulfillmentRole(task, { - source: 'claim_page_polling', - actor: { source: 'system' }, - recordEvent: false, - forceProbe: false, - }) - return result?.task || task - } catch { - return task - } -} - -function parseTaskContext(task: Partial | null | undefined) { - return parseTaskContextValue(task) -} diff --git a/apps/backend/src/services/config/json-config-migration-service.ts b/apps/backend/src/services/config/json-config-migration-service.ts index b142be3a..978b7885 100644 --- a/apps/backend/src/services/config/json-config-migration-service.ts +++ b/apps/backend/src/services/config/json-config-migration-service.ts @@ -7,10 +7,6 @@ import { upsertAppConfigEntry } from '../../repositories/app-config-repo.js' import { logInfo, logWarn } from '../../utils/logger.js' import { normalizeFulfillmentRoutingConfig } from '../fulfillment/routing-config-service.js' import { normalizeNotificationConfig } from '../notification/config-service.js' -import { normalizeCloudtentaclesOverrideRuleConfig } from '../order/cloudtentacles-override-rule-service.js' -import { normalizeCloudtentaclesSourcesConfig } from '../platforms/cloudtentacles/source-config-service.js' -import { normalizeSessionStatesFile } from '../platforms/cloudtentacles/session-state-service.js' -import { normalizeKuaishouFeifeiSourceConfig } from '../platforms/kuaishou-feifei/source-config-service.js' import { normalizeKuaishouIndustrySourceConfig } from '../platforms/kuaishou-industry/source-config-service.js' import { normalizeScheduledJobsConfig } from '../scheduler/config-service.js' import { loadAppConfigCacheFromDatabase } from './app-config-store.js' @@ -39,26 +35,6 @@ const JSON_CONFIG_MIGRATION_ITEMS: JsonConfigMigrationItem[] = [ fileName: 'fulfillment-routing-config.json', normalize: normalizeFulfillmentRoutingConfig, }, - { - configKey: APP_CONFIG_KEYS.cloudtentaclesSources, - fileName: 'cloudtentacles-sources.json', - normalize: normalizeCloudtentaclesSourcesConfig, - }, - { - configKey: APP_CONFIG_KEYS.cloudtentaclesSession, - fileName: 'cloudtentacles-session.json', - normalize: normalizeSessionStatesFile, - }, - { - configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules, - fileName: 'cloudtentacles-override-rules.json', - normalize: normalizeCloudtentaclesOverrideRuleConfig, - }, - { - configKey: APP_CONFIG_KEYS.kuaishouFeifei, - fileName: 'kuaishou-feifei-config.json', - normalize: normalizeKuaishouFeifeiSourceConfig, - }, { configKey: APP_CONFIG_KEYS.kuaishouIndustrySource, fileName: 'kuaishou-industry-source.json', diff --git a/apps/backend/src/services/dev-mock/dev-mock-service.ts b/apps/backend/src/services/dev-mock/dev-mock-service.ts index ee8e2892..a27d7904 100644 --- a/apps/backend/src/services/dev-mock/dev-mock-service.ts +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -15,22 +15,18 @@ import { import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js' import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js' -import { parseOpen91ProductNo } from '../platforms/ninetyone/order-service.js' import { getKuaishouIndustrySourceConfig, listKuaishouIndustryShopConfigs, } from '../platforms/kuaishou-industry/source-config-service.js' import { isProductionLike } from '../../config/runtime-validation.js' import { createHttpError } from '../../utils/http.js' -import { addHours, nowIso } from '../../utils/time.js' +import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' import { TASK_STATUS } from '../../domain/task-status.js' -export type LewanMockStep = 'uid' | 'binding' | 'confirm' | 'result' -export type FeifeiMockStep = 'uid' | 'ready' | 'completed' | 'failed' - export type DevMockCreateResult = { - platform: 'lewan' | 'feifei' | 'affiliate_dash' + platform: 'affiliate_dash' orderId: number orderNo: string taskId: number @@ -55,12 +51,6 @@ export type DevMockKuaishouIndustryVoucherResult = { }> } -type DeliveryItem = { - cloudSkuId: number - cloudSkuName: string - quantity: number -} - export function isDevMockEnabled(env: NodeJS.ProcessEnv = process.env): boolean { if (String(env.ENABLE_DEV_MOCK || '').trim() === '1') { return true @@ -91,18 +81,6 @@ export function getDevMockStatus() { nodeEnv: String(process.env.NODE_ENV || 'development'), platforms: enabled ? [ - { - key: 'lewan', - name: 'kuaishou-lewan', - description: '本站 claim + 强制 UID 匹配 + mock 兑换(不调 CloudTentacles)', - steps: ['uid', 'binding', 'confirm', 'result'], - }, - { - key: 'feifei', - name: 'kuaishou-feifei', - description: '本站 claim + 拼 uid 的 H5 链接(不调真实 feifei 下单)', - steps: ['uid', 'ready', 'completed', 'failed'], - }, { key: 'affiliate_dash', name: 'affiliate-dash', @@ -115,287 +93,6 @@ export function getDevMockStatus() { } } -export async function createLewanMockClaim( - input: { - step?: unknown - orderNo?: unknown - productNo?: unknown - uid?: unknown - items?: unknown - frontendBaseUrl?: unknown - } = {}, -): Promise { - assertDevMockEnabled() - - const step = normalizeLewanStep(input.step) - const orderNo = String(input.orderNo || `MOCK91${Date.now()}`).trim() - const productNo = String(input.productNo || '套餐_1----3676797936').trim() - const productInfo = parseOpen91ProductNo(productNo) - const productName = productInfo.productName || productInfo.rawProductNo || '套餐_1' - const consumeShopId = productInfo.shopId || '3676797936' - const expectedUid = resolveMockUid(input.uid, step === 'uid' ? '' : '10001') - const deliveryItems = parseDeliveryItems(input.items) - const now = nowIso() - - await assertOrderNoAvailable(orderNo) - - const profile = await ensureProfile('kuaishou_ct_assisted', 'kuaishou-lewan 履约', false) - const order = await createBaseOrder({ - orderNo, - productNo, - productName, - now, - platformTag: 'lewan', - }) - const orderItem = await createBaseOrderItem({ - orderId: order.id, - orderNo, - productNo, - productName, - consumeShopId, - deliveryItems, - now, - executor: 'lewan', - }) - - const task = await createTask({ - orderId: order.id, - orderItemId: orderItem.id, - unitIndex: 1, - provider: OPEN_91_PROVIDER, - platform: OPEN_91_PLATFORM, - shopId: OPEN_91_PROVIDER, - shopName: '91卡券', - platformOrderId: orderNo, - taskNo: randomId('DT'), - profileId: profile.id, - executorKey: 'kuaishou_ct_assisted', - taskStatus: resolveLewanTaskStatus(step), - deliveryStatus: step === 'result' ? 'success' : 'pending', - resultCode: step === 'result' ? 'mock_success' : '', - resultMessage: step === 'result' ? '开发 mock 已模拟兑换成功' : '', - claimToken: '', - claimExpiresAt: null, - automationMode: 'manual', - requiresClaim: true, - userActionStatus: step === 'result' ? 'not_required' : 'pending_claim', - attemptCount: 0, - lastError: '', - contextJson: JSON.stringify( - buildLewanMockContext({ - step, - orderNo, - productName, - productNo, - consumeShopId, - deliveryItems, - expectedUid, - timestamp: now, - }), - ), - createdAt: now, - updatedAt: now, - }) - - if (!task) { - throw createHttpError('履约任务创建失败', { - statusCode: 500, - errorCode: 'dev_mock_task_failed', - }) - } - - const claimToken = await createTaskClaimToken(task.id) - await updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - claimed_at: step === 'uid' ? null : now, - role_confirmed_at: step === 'confirm' || step === 'result' ? now : null, - redeemed_at: step === 'result' ? now : null, - role_id: ['binding', 'confirm', 'result'].includes(step) ? expectedUid || '10001' : '', - role_name: ['binding', 'confirm', 'result'].includes(step) ? '测试角色' : '', - updated_at: now, - }) - - return buildCreateResult({ - platform: 'lewan', - orderId: order.id, - orderNo, - taskId: task.id, - taskNo: task.task_no, - claimToken: claimToken.token, - claimUrl: buildClaimUrl(claimToken.token), - frontendBaseUrl: input.frontendBaseUrl, - expectedUid: expectedUid || '10001', - step, - productName, - tips: buildLewanTips(step, expectedUid || '10001'), - }) -} - -export async function createFeifeiMockClaim( - input: { - step?: unknown - orderNo?: unknown - productName?: unknown - productCode?: unknown - uid?: unknown - h5Url?: unknown - frontendBaseUrl?: unknown - } = {}, -): Promise { - assertDevMockEnabled() - - const step = normalizeFeifeiStep(input.step) - const orderNo = String(input.orderNo || `MOCKFF${Date.now()}`).trim() - const productName = String(input.productName || '套装-浪漫天命').trim() - const productCode = String(input.productCode || `MOCK-FF-${Date.now()}`).trim() - const expectedUid = resolveMockUid(input.uid, step === 'uid' ? '' : '166909256') - const rawH5 = - String(input.h5Url || '').trim() || - `http://skin-exchange.yiquyou.icu/h5/bind?code=mock_${encodeURIComponent(orderNo)}&product_name=${encodeURIComponent(productName)}` - const now = nowIso() - - await assertOrderNoAvailable(orderNo) - - const profile = await ensureProfile('kuaishou_feifei', 'kuaishou-feifei 履约', true) - const order = await createBaseOrder({ - orderNo, - productNo: productName, - productName, - now, - platformTag: 'feifei', - }) - const orderItem = await createBaseOrderItem({ - orderId: order.id, - orderNo, - productNo: productName, - productName, - consumeShopId: '', - deliveryItems: [], - now, - executor: 'feifei', - productCode, - }) - - const rechargeStatus = - step === 'completed' ? 30 : step === 'failed' ? 40 : step === 'ready' ? 15 : 15 - const rechargeStatusLabel = - step === 'completed' - ? '充值成功' - : step === 'failed' - ? '充值失败' - : step === 'ready' - ? '待绑定' - : '待领取' - - const task = await createTask({ - orderId: order.id, - orderItemId: orderItem.id, - unitIndex: 1, - provider: OPEN_91_PROVIDER, - platform: OPEN_91_PLATFORM, - shopId: OPEN_91_PROVIDER, - shopName: '91卡券', - platformOrderId: orderNo, - taskNo: randomId('DT'), - profileId: profile.id, - executorKey: 'kuaishou_feifei', - taskStatus: - step === 'completed' - ? TASK_STATUS.COMPLETED - : step === 'failed' - ? TASK_STATUS.MANUAL_REVIEW - : TASK_STATUS.LINK_GENERATED, - deliveryStatus: step === 'completed' ? 'delivered' : 'pending', - resultCode: - step === 'completed' - ? 'kuaishou_feifei_completed' - : step === 'failed' - ? 'kuaishou_feifei_status_40' - : '', - resultMessage: rechargeStatusLabel, - claimToken: '', - claimExpiresAt: null, - automationMode: 'manual', - requiresClaim: true, - userActionStatus: step === 'completed' ? 'not_required' : 'pending_claim', - attemptCount: 0, - lastError: step === 'failed' ? '开发 mock 模拟 feifei 失败' : '', - contextJson: JSON.stringify({ - profileKey: 'kuaishou_feifei', - profileName: 'kuaishou-feifei 履约', - skuCode: productName, - skuName: productName, - claimIdentity: expectedUid - ? { - expectedUid, - submittedAt: now, - source: 'dev_mock', - } - : { - expectedUid: '', - submittedAt: null, - source: '', - }, - kuaishouFeifei: { - flowType: 'kuaishou_feifei', - productCode, - productName, - platformOrderNo: orderNo, - orderNo: `FF-${orderNo}`, - rechargeStatus, - rechargeStatusLabel, - rechargeResultMessage: step === 'failed' ? '开发 mock 失败' : '', - claimUrl: '', - consumeStatus: step === 'completed' ? 'not_required' : 'pending', - h5: { - entryUrl: rawH5, - rechargeUrl: rawH5, - }, - lastSyncedAt: now, - mock: { - enabled: true, - orderNo, - createdAt: now, - }, - }, - }), - createdAt: now, - updatedAt: now, - }) - - if (!task) { - throw createHttpError('履约任务创建失败', { - statusCode: 500, - errorCode: 'dev_mock_task_failed', - }) - } - - const claimToken = await createTaskClaimToken(task.id) - await updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - claimed_at: expectedUid ? now : null, - redeemed_at: step === 'completed' ? now : null, - updated_at: now, - }) - - return buildCreateResult({ - platform: 'feifei', - orderId: order.id, - orderNo, - taskId: task.id, - taskNo: task.task_no, - claimToken: claimToken.token, - claimUrl: buildClaimUrl(claimToken.token), - frontendBaseUrl: input.frontendBaseUrl, - expectedUid: expectedUid || '166909256', - step, - productName, - tips: buildFeifeiTips(step, expectedUid || '166909256'), - }) -} - type AffiliateDashMockStep = 'uid' | 'bind' | 'submitted' | 'completed' | 'failed' /** @@ -450,10 +147,7 @@ export async function createAffiliateDashMockClaim( orderNo, productNo: productSku, productName, - consumeShopId: '', - deliveryItems: [], now, - executor: 'affiliate_dash', productCode: productSku, }) @@ -848,51 +542,20 @@ async function createBaseOrderItem(input: { orderNo: string productNo: string productName: string - consumeShopId: string - deliveryItems: DeliveryItem[] now: string - executor: 'lewan' | 'feifei' | 'affiliate_dash' productCode?: string }) { - const snapshot = - input.executor === 'feifei' - ? { - source: OPEN_91_PROVIDER, - orderNo: input.orderNo, - productNo: input.productNo, - productName: input.productName, - kuaishouFeifei: { - productCode: input.productCode || '', - productName: input.productName, - matchMode: 'mock', - }, - } - : input.executor === 'affiliate_dash' - ? { - source: OPEN_91_PROVIDER, - orderNo: input.orderNo, - productNo: input.productNo, - productName: input.productName, - affiliateDash: { - sku: input.productCode || input.productNo, - productName: input.productName, - matchMode: 'mock', - }, - } - : { - source: OPEN_91_PROVIDER, - orderNo: input.orderNo, - productNo: input.productNo, - productName: input.productName, - shopId: input.consumeShopId, - cloudtentacles: { - matchMode: 'mock', - normalizedProductName: input.productName, - cloudSourceKeys: ['mock-cloudtentacles'], - resolvedSourceKey: 'mock-cloudtentacles', - deliveryItems: input.deliveryItems, - }, - } + const snapshot = { + source: OPEN_91_PROVIDER, + orderNo: input.orderNo, + productNo: input.productNo, + productName: input.productName, + affiliateDash: { + sku: input.productCode || input.productNo, + productName: input.productName, + matchMode: 'mock', + }, + } const [orderItem] = await replaceOrderItems(input.orderId, [ { @@ -920,157 +583,6 @@ async function createBaseOrderItem(input: { return orderItem } -function buildLewanMockContext(input: { - step: LewanMockStep - orderNo: string - productName: string - productNo: string - consumeShopId: string - deliveryItems: DeliveryItem[] - expectedUid: string - timestamp: string -}) { - const roleReady = ['binding', 'confirm', 'result'].includes(input.step) - const completed = input.step === 'result' - const boundUid = roleReady ? input.expectedUid || '10001' : '' - const primaryItem = input.deliveryItems[0] || { - cloudSkuId: 910001, - cloudSkuName: 'Mock 商品', - quantity: 1, - } - - return { - profileKey: 'kuaishou_ct_assisted', - profileName: 'kuaishou-lewan 履约', - skuCode: input.productName, - skuName: input.productName, - claimIdentity: input.expectedUid - ? { - expectedUid: input.expectedUid, - submittedAt: input.timestamp, - source: 'dev_mock', - } - : { - expectedUid: '', - submittedAt: null, - source: '', - }, - kuaishouCloudFulfillment: { - flowType: 'kuaishou_cloud_fulfillment', - configId: `mock:${input.productName}`, - internalSkuCode: input.productName, - internalSkuName: input.productName, - deliveryItems: input.deliveryItems.length ? input.deliveryItems : [primaryItem], - mock: { - enabled: true, - orderNo: input.orderNo, - productNo: input.productNo, - createdAt: input.timestamp, - }, - ticket: { - code: roleReady ? `MOCK-${input.orderNo}` : '', - status: roleReady ? 'verified' : 'pending', - capturedAt: roleReady ? input.timestamp : null, - capturedBy: roleReady ? { source: 'dev_mock' } : null, - verifiedAt: roleReady ? input.timestamp : null, - oid: roleReady ? `MOCK-OID-${input.orderNo}` : '', - formToken: roleReady ? `MOCK-FORM-${input.orderNo}` : '', - leftCount: roleReady ? 1 : 0, - goodsTitle: roleReady ? input.productName : '', - }, - binding: { - prepareStatus: roleReady ? 'ready' : 'pending', - cloudSourceKeys: ['mock-cloudtentacles'], - resolvedSourceKey: 'mock-cloudtentacles', - skuId: primaryItem.cloudSkuId, - skuName: primaryItem.cloudSkuName, - vnKey: '1', - vnId: roleReady ? 900001 : 0, - vnPhone: roleReady ? '13800000000' : '', - bindUrl: roleReady ? `https://example.com/mock-kuaishou-cloud-bind/${input.orderNo}` : '', - bindPreparedAt: roleReady ? input.timestamp : null, - bindExpiresAt: roleReady ? addHours(input.timestamp, 24) : null, - bindProbeAt: roleReady ? input.timestamp : null, - bindProbeStatus: roleReady ? 'success' : '', - bindProbeMessage: '', - roleName: roleReady ? '测试角色' : '', - roleId: boundUid, - }, - role: { - status: roleReady ? 'ready' : 'pending', - name: roleReady ? '测试角色' : '', - rid: boundUid, - refreshedAt: roleReady ? input.timestamp : null, - errorMessage: '', - rawInfo: roleReady ? { mock: true } : null, - defaultName: roleReady ? '默认机位角色' : '', - defaultRid: roleReady ? 'DEFAULT-000' : '', - defaultCapturedAt: roleReady ? input.timestamp : null, - defaultCaptureStatus: roleReady ? 'captured' : '', - defaultErrorMessage: '', - isDefaultRole: false, - }, - purchase: { - autoBuyEnabled: true, - minAssetReserve: 0, - usedKnapsack: false, - purchaseTriggered: false, - assetBefore: 0, - assetAfter: 0, - purchaseAt: null, - items: [], - }, - dispatch: { - status: completed ? 'success' : 'pending', - dispatchAt: completed ? input.timestamp : null, - dispatchBy: completed ? { source: 'dev_mock' } : null, - sendType: 0, - note: completed ? '开发 mock 已模拟发货成功' : '', - items: completed ? input.deliveryItems : [], - }, - returnNumber: { - status: completed ? 'success' : 'pending', - returnedAt: completed ? input.timestamp : null, - returnedBy: completed ? { source: 'dev_mock' } : null, - autoReturnEnabled: true, - }, - consume: { - status: completed ? 'success' : 'pending', - shopId: input.consumeShopId, - shopName: '', - autoConsumeEnabled: true, - consumedAt: completed ? input.timestamp : null, - errorMessage: '', - }, - }, - } -} - -function resolveLewanTaskStatus(step: LewanMockStep) { - if (step === 'result') return TASK_STATUS.COMPLETED - if (step === 'confirm') return TASK_STATUS.ROLE_CONFIRMED - if (step === 'binding') return TASK_STATUS.WAITING_BINDING - return TASK_STATUS.LINK_GENERATED -} - -function normalizeLewanStep(value: unknown): LewanMockStep { - const step = String(value || 'uid').trim() - if (['uid', 'binding', 'confirm', 'result'].includes(step)) { - return step as LewanMockStep - } - // 兼容旧 ticket 命名 - if (step === 'ticket') return 'uid' - return 'uid' -} - -function normalizeFeifeiStep(value: unknown): FeifeiMockStep { - const step = String(value || 'uid').trim() - if (['uid', 'ready', 'completed', 'failed'].includes(step)) { - return step as FeifeiMockStep - } - return 'uid' -} - function normalizeAffiliateDashStep(value: unknown): AffiliateDashMockStep { const step = String(value || 'uid').trim() if (['uid', 'bind', 'submitted', 'completed', 'failed'].includes(step)) { @@ -1099,48 +611,8 @@ function resolveMockIndustrySellerId(value: unknown): string { return String(shop?.sellerId || shop?.shopId || '141242642').trim() } -function parseDeliveryItems(value: unknown): DeliveryItem[] { - if (!value) { - return [ - { - cloudSkuId: 910001, - cloudSkuName: 'Mock 商品', - quantity: 1, - }, - ] - } - - if (typeof value === 'string') { - try { - return parseDeliveryItems(JSON.parse(value)) - } catch { - return parseDeliveryItems(null) - } - } - - if (!Array.isArray(value)) { - return parseDeliveryItems(null) - } - - const items = value - .map((item) => { - const source = item && typeof item === 'object' ? (item as Record) : {} - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 - const quantity = Number(source.quantity || 1) || 1 - if (!cloudSkuId) return null - return { - cloudSkuId, - cloudSkuName: String(source.cloudSkuName || source.skuName || 'Mock 商品').trim(), - quantity: quantity > 0 ? quantity : 1, - } - }) - .filter((item): item is DeliveryItem => Boolean(item)) - - return items.length > 0 ? items : parseDeliveryItems(null) -} - function buildCreateResult(input: { - platform: 'lewan' | 'feifei' | 'affiliate_dash' + platform: 'affiliate_dash' orderId: number orderNo: string taskId: number @@ -1178,38 +650,6 @@ function buildFrontendClaimUrl(token: string, frontendBaseUrl?: unknown) { return `http://127.0.0.1/#/claim/${token}` } -function buildLewanTips(step: LewanMockStep, uid: string) { - if (step === 'uid') { - return [ - `打开领取链接后,Step1 填写 UID:${uid}`, - '提交后进入绑定步;mock 绑定链接仅作展示,点击刷新/确认前请保证 UID 与角色 ID 一致', - ] - } - if (step === 'binding') { - return [ - `已预填 UID=${uid} 且角色 ID 已匹配`, - '可直接点「UID 已匹配,下一步」→ 确认兑换(mock 不会真实发货)', - ] - } - if (step === 'confirm') { - return ['已进入确认兑换步,点击确认兑换即可模拟成功'] - } - return ['已模拟兑换完成,可直接看结果页'] -} - -function buildFeifeiTips(step: FeifeiMockStep, uid: string) { - if (step === 'uid') { - return [`打开领取链接,Step1 填 UID:${uid}`, '提交后点「打开领取链接」,URL 应带 uid 参数'] - } - if (step === 'ready') { - return [`已预填 UID=${uid},可直接打开带 uid 的 H5(mock 链接,无需真实平台)`] - } - if (step === 'failed') { - return ['已模拟 feifei 失败态,用于看领取页结果展示'] - } - return ['已模拟 feifei 成功完成态'] -} - function buildAffiliateDashTips(step: AffiliateDashMockStep, uid: string) { if (step === 'uid') { return [`打开领取链接,Step1 填 UID:${uid}(或自定义)`, '提交后进入绑定步,mock 会给出二维码'] diff --git a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts index c03fb102..5d9f3668 100644 --- a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts +++ b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts @@ -5,56 +5,55 @@ import { buildClaimUrl } from '../claim/claim-service.js' import { resolveTaskDeliveryLink } from './delivery-link-service.js' import type { TaskRow } from '../../types/repository/rows.js' -test('resolveTaskDeliveryLink 为 cloud 任务返回内部领取链接', async () => { +test('resolveTaskDeliveryLink 为 affiliate_dash 任务返回本站领取链接', async () => { const result = await resolveTaskDeliveryLink( createTask({ - executor_key: 'kuaishou_ct_assisted', - primary_claim_token: 'cloud-token', + executor_key: 'affiliate_dash', + primary_claim_token: 'affiliate-token', primary_claim_expires_at: '2026-07-09T00:00:00.000Z', }), ) assert.deepEqual(result, { - claimUrl: buildClaimUrl('cloud-token'), + claimUrl: buildClaimUrl('affiliate-token'), expireTime: '2026-07-09T00:00:00.000Z', }) }) -test('resolveTaskDeliveryLink 兼容历史 kuaishou-industry cloud 任务', async () => { +test('resolveTaskDeliveryLink 兼容 claim_token 回落字段', async () => { const result = await resolveTaskDeliveryLink( createTask({ - executor_key: 'kuaishou-industry', - claim_token: 'industry-token', + executor_key: 'affiliate_dash', + claim_token: 'legacy-token', claim_expires_at: '2026-07-09T08:00:00.000Z', }), ) assert.deepEqual(result, { - claimUrl: buildClaimUrl('industry-token'), + claimUrl: buildClaimUrl('legacy-token'), expireTime: '2026-07-09T08:00:00.000Z', }) }) -test('resolveTaskDeliveryLink 为 feifei 任务返回本站领取链接', async () => { - const result = await resolveTaskDeliveryLink( - createTask({ - executor_key: 'kuaishou_feifei', - primary_claim_token: 'feifei-token', - primary_claim_expires_at: '2026-07-09T12:00:00.000Z', - context_json: { - kuaishouFeifei: { - h5: { - rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', - }, - }, - }, - }), +test('resolveTaskDeliveryLink 对已下线 lewan/feifei 任务返回 null', async () => { + assert.equal( + await resolveTaskDeliveryLink( + createTask({ + executor_key: 'kuaishou_ct_assisted', + primary_claim_token: 'cloud-token', + }), + ), + null, + ) + assert.equal( + await resolveTaskDeliveryLink( + createTask({ + executor_key: 'kuaishou_feifei', + primary_claim_token: 'feifei-token', + }), + ), + null, ) - - assert.deepEqual(result, { - claimUrl: buildClaimUrl('feifei-token'), - expireTime: '2026-07-09T12:00:00.000Z', - }) }) test('resolveTaskDeliveryLink 对人工履约任务返回 null', async () => { @@ -76,8 +75,8 @@ function createTask(patch: Partial = {}): TaskRow { platform_order_id: '2614900602069169', profile_id: 0, task_no: 'DT-test', - executor_key: 'kuaishou_ct_assisted', - task_status: 'pending_binding_prepare', + executor_key: 'affiliate_dash', + task_status: 'waiting_binding', delivery_status: 'pending', result_code: '', result_message: '', diff --git a/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts b/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts deleted file mode 100644 index 944c491a..00000000 --- a/apps/backend/src/services/fulfillment/executors/kuaishou-cloud-executor.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { buildClaimUrl } from '../../claim/claim-service.js' -import { shouldEnsureKuaishouCloudClaimLink, TASK_STATUS } from '../../../domain/task-status.js' -import { - confirmKuaishouCloudTaskRole, - dispatchKuaishouCloudFulfillmentTask, - ensureTaskClaimLink, - prepareKuaishouCloudFulfillmentTask, - rebindKuaishouCloudTaskRole, - redeemKuaishouCloudTask, - refreshKuaishouCloudTaskRoleInfo, - returnKuaishouCloudFulfillmentTask, -} from '../kuaishou-cloud/index.js' -import { - FULFILLMENT_EXECUTOR_KEYS, - type FulfillmentActionOptions, - type FulfillmentActionResult, - type FulfillmentDeliveryLink, - type FulfillmentExecutor, - type FulfillmentPrepareDeps, -} from './types.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -export const kuaishouCloudExecutor: FulfillmentExecutor = { - key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, - preparePaidTask, - resolveDeliveryLink, - prepareBinding, - rebindRole, - refreshRole, - confirmRole, - redeemTask, - dispatchTask, - returnNumber, -} - -async function preparePaidTask( - task: TaskRow, - deps: FulfillmentPrepareDeps, -): Promise { - const now = deps.nowIso() - - if (shouldEnsureKuaishouCloudClaimLink(task.task_status)) { - if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { - const claimToken = await deps.createTaskClaimToken(task.id) - return deps.updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - updated_at: now, - }) - } - - return task - } - - const claimToken = await deps.createTaskClaimToken(task.id) - - return deps.updateTask(task.id, { - task_status: TASK_STATUS.PENDING_BINDING_PREPARE, - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - last_error: task.last_error || '领取链接已生成,等待电子凭证流程推进', - updated_at: now, - }) -} - -async function resolveDeliveryLink(task: TaskRow): Promise { - const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim() - const expireTime = task.primary_claim_expires_at || task.claim_expires_at || '' - - if (primaryToken) { - return { - claimUrl: buildClaimUrl(primaryToken), - expireTime, - } - } - - try { - const ensured = await ensureTaskClaimLink(task) - return { - claimUrl: String(ensured.claimUrl || '').trim(), - expireTime: ensured.expiredAt || expireTime, - } - } catch { - return null - } -} - -async function prepareBinding( - task: TaskRow, - options: FulfillmentActionOptions = {}, -): Promise { - // 后台/显式 force:旧号可被 CT 回收,忽略退号失败并取新号+新绑链 - const force = options.force === true || String(options.source || '').includes('admin_') - const result = await prepareKuaishouCloudFulfillmentTask(task, { - source: options.source || 'executor_prepare_binding', - actor: options.actor, - force, - }) - return { - task: result.task || task, - claimUrl: result.claimUrl, - token: result.token, - flow: result.flow, - previousVnRelease: (result as { previousVnRelease?: string }).previousVnRelease, - } -} - -async function rebindRole( - task: TaskRow, - options: FulfillmentActionOptions = {}, -): Promise { - const result = await rebindKuaishouCloudTaskRole(task, { - source: options.source || 'executor_rebind_role', - actor: options.actor, - }) - return { - task: result.task || task, - claimUrl: result.claimUrl, - token: result.token, - } -} - -async function refreshRole( - task: TaskRow, - options: FulfillmentActionOptions = {}, -): Promise { - const result = await refreshKuaishouCloudTaskRoleInfo(task, { - source: options.source || 'executor_refresh_role', - actor: options.actor, - recordEvent: options.recordEvent !== false, - forceProbe: options.forceProbe === true, - }) - return { - task: result.task || task, - roleInfo: result.roleInfo, - } -} - -async function confirmRole( - task: TaskRow, - options: FulfillmentActionOptions = {}, -): Promise { - const result = await confirmKuaishouCloudTaskRole(task, { - source: options.source || 'executor_confirm_role', - actor: options.actor, - errorCodePrefix: options.errorCodePrefix, - forceProbe: options.forceProbe !== false, - }) - return { - task: result.task, - alreadySettled: result.alreadySettled, - } -} - -async function redeemTask( - task: TaskRow, - options: FulfillmentActionOptions = {}, -): Promise { - const result = await redeemKuaishouCloudTask(task, { - source: options.source || 'executor_redeem', - actor: options.actor, - autoFinalize: options.autoFinalize !== false, - errorCodePrefix: options.errorCodePrefix, - }) - return { - task: result.task, - alreadySettled: result.alreadySettled, - concurrentSkipped: result.concurrentSkipped, - } -} - -async function dispatchTask( - task: TaskRow, - options: FulfillmentActionOptions = {}, -): Promise { - const result = await dispatchKuaishouCloudFulfillmentTask(task, { - source: options.source || 'executor_dispatch', - actor: options.actor, - autoFinalize: options.autoFinalize === true, - }) - return { - task: result.task || task, - flow: result.flow, - } -} - -async function returnNumber( - task: TaskRow, - options: FulfillmentActionOptions = {}, -): Promise { - const result = await returnKuaishouCloudFulfillmentTask(task, { - source: options.source || 'executor_return_number', - actor: options.actor, - // 未显式传入时保持默认 true(发货收尾);admin 清理会传 false - consumeIndustryVoucher: options.consumeIndustryVoucher, - }) - return { - task: result.task || task, - flow: result.flow, - } -} diff --git a/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts b/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts deleted file mode 100644 index 82d54bef..00000000 --- a/apps/backend/src/services/fulfillment/executors/kuaishou-feifei-executor.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { buildClaimUrl } from '../../claim/claim-service.js' -import { TASK_STATUS } from '../../../domain/task-status.js' -import { prepareKuaishouFeifeiTask } from '../kuaishou-feifei/index.js' -import { - FULFILLMENT_EXECUTOR_KEYS, - type FulfillmentDeliveryLink, - type FulfillmentExecutor, - type FulfillmentPrepareDeps, -} from './types.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -/** - * @deprecated 待移除,快手飞飞直充渠道已废弃,不再使用。 - */ -export const kuaishouFeifeiExecutor: FulfillmentExecutor = { - key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, - preparePaidTask, - resolveDeliveryLink, -} - -async function preparePaidTask( - task: TaskRow, - deps: FulfillmentPrepareDeps, -): Promise { - try { - let workingTask = task - if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { - const claimToken = await deps.createTaskClaimToken(task.id) - workingTask = - (await deps.updateTask(task.id, { - claim_token: claimToken.token, - claim_expires_at: claimToken.expired_at, - user_action_status: 'pending_claim', - updated_at: deps.nowIso(), - })) || task - } - - return await prepareKuaishouFeifeiTask(workingTask) - } catch (error) { - const message = error instanceof Error ? error.message : 'kuaishou-feifei 订单创建失败' - const updatedTask = await deps.updateTask(task.id, { - task_status: TASK_STATUS.MANUAL_REVIEW, - user_action_status: 'not_required', - last_error: message, - result_code: 'kuaishou_feifei_prepare_failed', - result_message: message, - updated_at: deps.nowIso(), - }) - await deps.notifyTaskAutoManualReview({ - task: updatedTask || task, - reason: message, - source: 'kuaishou_feifei_prepare_failed', - }) - return updatedTask - } -} - -/** - * 对外(91 等)统一返回本站领取链接,不再返回 feifei H5 或短链。 - */ -async function resolveDeliveryLink(task: TaskRow): Promise { - const primaryToken = String(task.primary_claim_token || task.claim_token || '').trim() - const expireTime = String(task.primary_claim_expires_at || task.claim_expires_at || '').trim() - - if (!primaryToken) { - return null - } - - return { - claimUrl: buildClaimUrl(primaryToken), - expireTime, - } -} diff --git a/apps/backend/src/services/fulfillment/executors/legacy-retired-executor.ts b/apps/backend/src/services/fulfillment/executors/legacy-retired-executor.ts new file mode 100644 index 00000000..dc0f9d6f --- /dev/null +++ b/apps/backend/src/services/fulfillment/executors/legacy-retired-executor.ts @@ -0,0 +1,31 @@ +import { FULFILLMENT_EXECUTOR_KEYS, type FulfillmentExecutor } from './types.js' +import type { TaskRow } from '../../../types/repository/rows.js' + +const RETIRED_MESSAGE = '该履约通道已下线,请转人工处理' + +/** + * 历史飞飞 / 乐玩任务只读 stub: + * - prepare 幂等 no-op(避免订单重同步因 throw 整单失败) + * - 不提供发货 / 领取链接 + */ +function createLegacyRetiredExecutor(key: string): FulfillmentExecutor { + return { + key, + async preparePaidTask(task: TaskRow) { + return task + }, + async resolveDeliveryLink() { + return null + }, + } +} + +export const legacyLewanExecutor = createLegacyRetiredExecutor( + FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, +) + +export const legacyFeifeiExecutor = createLegacyRetiredExecutor( + FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, +) + +export const RETIRED_FULFILLMENT_MESSAGE = RETIRED_MESSAGE diff --git a/apps/backend/src/services/fulfillment/executors/registry.test.ts b/apps/backend/src/services/fulfillment/executors/registry.test.ts index 705a9ffe..0799863f 100644 --- a/apps/backend/src/services/fulfillment/executors/registry.test.ts +++ b/apps/backend/src/services/fulfillment/executors/registry.test.ts @@ -1,35 +1,15 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { - getFulfillmentExecutor, - prepareFulfillmentBinding, - redeemFulfillmentTask, - dispatchFulfillmentTask, - confirmFulfillmentRole, - rebindFulfillmentRole, - refreshFulfillmentRole, - returnFulfillmentNumber, -} from './registry.js' +import { getFulfillmentExecutor } from './registry.js' import { FULFILLMENT_EXECUTOR_KEYS } from './types.js' -import type { TaskRow } from '../../../types/repository/rows.js' -function makeTask(executorKey: string): TaskRow { - return { - id: 1, - executor_key: executorKey, - } as TaskRow -} - -test('getFulfillmentExecutor 映射 lewan / industry / feifei / affiliate_dash / manual', () => { +test('getFulfillmentExecutor 映射 affiliate_dash / manual / retired stubs', () => { assert.equal( getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD)?.key, FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, ) - assert.equal( - getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY)?.key, - FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, - ) + assert.equal(getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY), null) assert.equal( getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI)?.key, FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, @@ -50,35 +30,21 @@ test('affiliate_dash executor 暴露 preparePaidTask 与 resolveDeliveryLink', ( assert.ok(executor) assert.equal(typeof executor.preparePaidTask, 'function') assert.equal(typeof executor.resolveDeliveryLink, 'function') - // affiliate_dash 不需要 lewan 专属动作 - assert.equal(executor.prepareBinding, undefined) - assert.equal(executor.redeemTask, undefined) }) -test('lewan executor 暴露完整履约动作', () => { - const executor = getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) - assert.ok(executor) - assert.equal(typeof executor.prepareBinding, 'function') - assert.equal(typeof executor.rebindRole, 'function') - assert.equal(typeof executor.refreshRole, 'function') - assert.equal(typeof executor.confirmRole, 'function') - assert.equal(typeof executor.redeemTask, 'function') - assert.equal(typeof executor.dispatchTask, 'function') - assert.equal(typeof executor.returnNumber, 'function') -}) +test('retired lewan/feifei stubs prepare 幂等 no-op,不返回领取链接', async () => { + const lewan = getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) + const feifei = getFulfillmentExecutor(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) + assert.ok(lewan?.preparePaidTask) + assert.ok(feifei?.resolveDeliveryLink) -test('feifei / manual 不支持 lewan 专属动作时 registry 返回 null', async () => { - const feifei = makeTask(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) - const manual = makeTask(FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) - - assert.equal(await prepareFulfillmentBinding(feifei), null) - assert.equal(await rebindFulfillmentRole(feifei), null) - assert.equal(await refreshFulfillmentRole(feifei), null) - assert.equal(await confirmFulfillmentRole(feifei), null) - assert.equal(await redeemFulfillmentTask(feifei), null) - assert.equal(await dispatchFulfillmentTask(feifei), null) - assert.equal(await returnFulfillmentNumber(feifei), null) - - assert.equal(await redeemFulfillmentTask(manual), null) - assert.equal(await dispatchFulfillmentTask(manual), null) + const task = { id: 1, executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD } as any + assert.equal(await lewan.preparePaidTask!(task, {} as any), task) + assert.equal( + await feifei.resolveDeliveryLink!({ + id: 2, + executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, + } as any), + null, + ) }) diff --git a/apps/backend/src/services/fulfillment/executors/registry.ts b/apps/backend/src/services/fulfillment/executors/registry.ts index 97603093..2842807e 100644 --- a/apps/backend/src/services/fulfillment/executors/registry.ts +++ b/apps/backend/src/services/fulfillment/executors/registry.ts @@ -1,13 +1,11 @@ import { isPaidPreparationStableStatus } from '../../../domain/task-status.js' -import { kuaishouCloudExecutor } from './kuaishou-cloud-executor.js' -import { kuaishouFeifeiExecutor } from './kuaishou-feifei-executor.js' -import { manualDispatchExecutor } from './manual-executor.js' import { affiliateDashExecutor } from './affiliate-dash-executor.js' +import { legacyFeifeiExecutor, legacyLewanExecutor } from './legacy-retired-executor.js' +import { manualDispatchExecutor } from './manual-executor.js' import { FULFILLMENT_EXECUTOR_KEYS, isManualDispatchExecutor, normalizeExecutorKey, - type FulfillmentActionOptions, type FulfillmentDeliveryLink, type FulfillmentExecutor, type FulfillmentPrepareDeps, @@ -15,11 +13,10 @@ import { import type { TaskRow } from '../../../types/repository/rows.js' const EXECUTORS = new Map([ - [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, kuaishouCloudExecutor], - [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY, kuaishouCloudExecutor], - [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, kuaishouFeifeiExecutor], [FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, affiliateDashExecutor], [FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, manualDispatchExecutor], + [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, legacyLewanExecutor], + [FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, legacyFeifeiExecutor], ]) export function getFulfillmentExecutor(executorKey: unknown): FulfillmentExecutor | null { @@ -56,52 +53,3 @@ export async function resolveFulfillmentDeliveryLink( return executor.resolveDeliveryLink(task) } - -async function runExecutorAction( - task: TaskRow, - method: - | 'prepareBinding' - | 'rebindRole' - | 'refreshRole' - | 'confirmRole' - | 'redeemTask' - | 'dispatchTask' - | 'returnNumber', - options: FulfillmentActionOptions = {}, -) { - const executor = getFulfillmentExecutor(task.executor_key) - const handler = executor?.[method] - if (!handler) { - return null - } - - return handler(task, options) -} - -export function prepareFulfillmentBinding(task: TaskRow, options: FulfillmentActionOptions = {}) { - return runExecutorAction(task, 'prepareBinding', options) -} - -export function rebindFulfillmentRole(task: TaskRow, options: FulfillmentActionOptions = {}) { - return runExecutorAction(task, 'rebindRole', options) -} - -export function refreshFulfillmentRole(task: TaskRow, options: FulfillmentActionOptions = {}) { - return runExecutorAction(task, 'refreshRole', options) -} - -export function confirmFulfillmentRole(task: TaskRow, options: FulfillmentActionOptions = {}) { - return runExecutorAction(task, 'confirmRole', options) -} - -export function redeemFulfillmentTask(task: TaskRow, options: FulfillmentActionOptions = {}) { - return runExecutorAction(task, 'redeemTask', options) -} - -export function dispatchFulfillmentTask(task: TaskRow, options: FulfillmentActionOptions = {}) { - return runExecutorAction(task, 'dispatchTask', options) -} - -export function returnFulfillmentNumber(task: TaskRow, options: FulfillmentActionOptions = {}) { - return runExecutorAction(task, 'returnNumber', options) -} diff --git a/apps/backend/src/services/fulfillment/executors/types.ts b/apps/backend/src/services/fulfillment/executors/types.ts index d225fbeb..c7d45002 100644 --- a/apps/backend/src/services/fulfillment/executors/types.ts +++ b/apps/backend/src/services/fulfillment/executors/types.ts @@ -3,10 +3,13 @@ import type { TaskRow } from '../../../types/repository/rows.js' export const FULFILLMENT_EXECUTOR_KEYS = { MANUAL_DISPATCH: 'manual_dispatch', - KUAISHOU_CLOUD: 'kuaishou_ct_assisted', - KUAISHOU_INDUSTRY: 'kuaishou-industry', - KUAISHOU_FEIFEI: 'kuaishou_feifei', AFFILIATE_DASH: 'affiliate_dash', + /** @deprecated 已下线,仅历史任务 / stub 识别 */ + KUAISHOU_CLOUD: 'kuaishou_ct_assisted', + /** @deprecated 已下线,仅历史任务识别 */ + KUAISHOU_INDUSTRY: 'kuaishou-industry', + /** @deprecated 已下线,仅历史任务 / stub 识别 */ + KUAISHOU_FEIFEI: 'kuaishou_feifei', } as const export type FulfillmentExecutorKey = @@ -38,11 +41,6 @@ export type FulfillmentActionOptions = { actor?: unknown autoFinalize?: boolean errorCodePrefix?: string - /** - * 退号时是否顺带核销行业电子凭证。 - * - true/默认:发货收尾(claim autoFinalize) - * - false:仅清理虚拟号(admin 退号),与核销无关 - */ consumeIndustryVoucher?: boolean [key: string]: unknown } @@ -56,62 +54,12 @@ export type FulfillmentExecutor = { key: FulfillmentExecutorKey preparePaidTask?: (task: TaskRow, deps: FulfillmentPrepareDeps) => Promise resolveDeliveryLink?: (task: TaskRow) => Promise - /** lewan:准备绑定资源(虚拟号 / bindUrl) */ - prepareBinding?: ( - task: TaskRow, - options?: FulfillmentActionOptions, - ) => Promise - /** lewan:换绑角色 */ - rebindRole?: ( - task: TaskRow, - options?: FulfillmentActionOptions, - ) => Promise - /** lewan:刷新角色信息 */ - refreshRole?: ( - task: TaskRow, - options?: FulfillmentActionOptions, - ) => Promise - /** lewan:确认角色(UID 匹配后落 ROLE_CONFIRMED) */ - confirmRole?: ( - task: TaskRow, - options?: FulfillmentActionOptions, - ) => Promise - /** lewan:一键兑换(状态机 + dispatch) */ - redeemTask?: ( - task: TaskRow, - options?: FulfillmentActionOptions, - ) => Promise - /** lewan:后台/系统发货(不经 claim 一键兑换) */ - dispatchTask?: ( - task: TaskRow, - options?: FulfillmentActionOptions, - ) => Promise - /** - * lewan:退还虚拟号。 - * 默认可顺带核销;admin 清理请传 consumeIndustryVoucher: false。 - */ - returnNumber?: ( - task: TaskRow, - options?: FulfillmentActionOptions, - ) => Promise } export function normalizeExecutorKey(value: unknown): FulfillmentExecutorKey { return String(value || '').trim() as FulfillmentExecutorKey } -export function isKuaishouCloudExecutor(value: unknown): boolean { - const executorKey = normalizeExecutorKey(value) - return ( - executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD || - executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY - ) -} - -export function isKuaishouFeifeiExecutor(value: unknown): boolean { - return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI -} - export function isAffiliateDashExecutor(value: unknown): boolean { return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH } @@ -119,3 +67,12 @@ export function isAffiliateDashExecutor(value: unknown): boolean { export function isManualDispatchExecutor(value: unknown): boolean { return normalizeExecutorKey(value) === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH } + +export function isRetiredFulfillmentExecutor(value: unknown): boolean { + const executorKey = normalizeExecutorKey(value) + return ( + executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD || + executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI || + executorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_INDUSTRY + ) +} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts deleted file mode 100644 index 8067bf18..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.test.ts +++ /dev/null @@ -1,260 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { selectCloudtentaclesSourceForFulfillment } from './account-selector.js' - -const contexts = { - 'account-a': { - baseUrl: 'https://cloud.example.com', - token: 'token-a', - deviceId: '-', - deviceType: 0, - resolvedSourceKey: 'account-a', - }, - 'account-b': { - baseUrl: 'https://cloud.example.com', - token: 'token-b', - deviceId: '-', - deviceType: 0, - resolvedSourceKey: 'account-b', - }, -} - -const listEmptyVirtualNumbers = async () => ({ - baseUrl: 'https://cloud.example.com', - key: '1', - itemCount: 0, - occupiedItemCount: 0, - items: [], - rawItems: [], -}) - -test('selectCloudtentaclesSourceForFulfillment 已固定账号时不按压力切换', async () => { - const selection = await selectCloudtentaclesSourceForFulfillment( - { - binding: { - resolvedSourceKey: 'account-a', - cloudSourceKeys: ['account-a', 'account-b'], - vnId: 123, - }, - }, - { - resolveContextBySourceKeys: (sourceKeys) => { - const key = String(sourceKeys[0] || '').trim() - return contexts[key as keyof typeof contexts] - }, - listSourceLoadStats: async () => [ - { sourceKey: 'account-a', activeCount: 99, redeemingCount: 99 }, - { sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 }, - ], - listVirtualNumbers: listEmptyVirtualNumbers, - }, - ) - - assert.equal(selection.sourceKey, 'account-a') - assert.equal(selection.fixed, true) -}) - -test('selectCloudtentaclesSourceForFulfillment 无号码时忽略残留固定账号', async () => { - const selection = await selectCloudtentaclesSourceForFulfillment( - { - binding: { - resolvedSourceKey: 'account-a', - cloudSourceKeys: ['account-a', 'account-b'], - vnId: 0, - }, - }, - { - resolveContextBySourceKeys: (sourceKeys) => - contexts[String(sourceKeys[0]) as keyof typeof contexts], - listSourceLoadStats: async () => [ - { sourceKey: 'account-a', activeCount: 20, redeemingCount: 0 }, - { sourceKey: 'account-b', activeCount: 1, redeemingCount: 0 }, - ], - listVirtualNumbers: listEmptyVirtualNumbers, - }, - ) - - try { - assert.equal(selection.sourceKey, 'account-b') - assert.equal(selection.fixed, false) - } finally { - selection.release() - } -}) - -test('selectCloudtentaclesSourceForFulfillment 跳过真实占用已达20的账号', async () => { - const selection = await selectCloudtentaclesSourceForFulfillment( - { binding: { cloudSourceKeys: ['account-a', 'account-b'] } }, - { - resolveContextBySourceKeys: (sourceKeys) => - contexts[String(sourceKeys[0]) as keyof typeof contexts], - listSourceLoadStats: async () => [], - listVirtualNumbers: async (payload) => { - const count = payload.sourceKey === 'account-a' ? 20 : 3 - return { - baseUrl: 'https://cloud.example.com', - key: '1', - itemCount: count, - occupiedItemCount: count, - items: Array.from({ length: count }, (_, id) => ({ id: id + 1, status: 1 })), - rawItems: [], - } - }, - }, - ) - - try { - assert.equal(selection.sourceKey, 'account-b') - assert.equal(selection.occupiedCount, 3) - assert.equal(selection.availableCount, 17) - } finally { - selection.release() - } -}) - -test('selectCloudtentaclesSourceForFulfillment 不把 status=0 的空闲号码计入占用', async () => { - const selection = await selectCloudtentaclesSourceForFulfillment( - { binding: { cloudSourceKeys: ['account-a', 'account-b'] } }, - { - resolveContextBySourceKeys: (sourceKeys) => - contexts[String(sourceKeys[0]) as keyof typeof contexts], - listSourceLoadStats: async () => [], - listVirtualNumbers: async (payload) => { - const items = - payload.sourceKey === 'account-a' - ? Array.from({ length: 20 }, (_, id) => ({ id: id + 1, status: 0 })) - : Array.from({ length: 3 }, (_, id) => ({ id: id + 1, status: 1 })) - return { - baseUrl: 'https://cloud.example.com', - key: '1', - itemCount: items.length, - occupiedItemCount: items.filter((item) => item.status !== 0).length, - items, - rawItems: [], - } - }, - }, - ) - - try { - assert.equal(selection.sourceKey, 'account-a') - assert.equal(selection.occupiedCount, 0) - } finally { - selection.release() - } -}) - -test('selectCloudtentaclesSourceForFulfillment 真实占用优先于数据库历史压力', async () => { - const selection = await selectCloudtentaclesSourceForFulfillment( - { binding: { cloudSourceKeys: ['account-a', 'account-b'] } }, - { - resolveContextBySourceKeys: (sourceKeys) => - contexts[String(sourceKeys[0]) as keyof typeof contexts], - listSourceLoadStats: async () => [ - { sourceKey: 'account-a', activeCount: 99, redeemingCount: 10 }, - { sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 }, - ], - listVirtualNumbers: async (payload) => { - const count = payload.sourceKey === 'account-a' ? 5 : 6 - return { - baseUrl: 'https://cloud.example.com', - key: '1', - itemCount: count, - occupiedItemCount: count, - items: Array.from({ length: count }, (_, id) => ({ id: id + 1, status: 1 })), - rawItems: [], - } - }, - }, - ) - - try { - assert.equal(selection.sourceKey, 'account-a') - assert.equal(selection.occupiedCount, 5) - } finally { - selection.release() - } -}) - -test('selectCloudtentaclesSourceForFulfillment 多候选时选择压力最低账号', async () => { - const selection = await selectCloudtentaclesSourceForFulfillment( - { - binding: { - cloudSourceKeys: ['account-a', 'account-b'], - }, - }, - { - resolveContextBySourceKeys: (sourceKeys) => { - const key = String(sourceKeys[0] || '').trim() - return contexts[key as keyof typeof contexts] - }, - listSourceLoadStats: async () => [ - { sourceKey: 'account-a', activeCount: 3, redeemingCount: 1 }, - { sourceKey: 'account-b', activeCount: 0, redeemingCount: 0 }, - ], - listVirtualNumbers: listEmptyVirtualNumbers, - }, - ) - - try { - assert.equal(selection.sourceKey, 'account-b') - assert.equal(selection.fixed, false) - } finally { - selection.release() - } -}) - -test('selectCloudtentaclesSourceForFulfillment 会跳过不可用账号', async () => { - const selection = await selectCloudtentaclesSourceForFulfillment( - { - binding: { - cloudSourceKeys: ['account-a', 'account-b'], - }, - }, - { - resolveContextBySourceKeys: (sourceKeys) => { - const key = String(sourceKeys[0] || '').trim() - if (key === 'account-a') { - throw new Error('账号已停用') - } - return contexts[key as keyof typeof contexts] - }, - listSourceLoadStats: async () => [], - listVirtualNumbers: listEmptyVirtualNumbers, - }, - ) - - try { - assert.equal(selection.sourceKey, 'account-b') - assert.equal(selection.candidateCount, 1) - } finally { - selection.release() - } -}) - -test('selectCloudtentaclesSourceForFulfillment 用进程内占位分散并发准备', async () => { - const deps = { - resolveContextBySourceKeys: (sourceKeys: unknown[] = []) => { - const key = String(sourceKeys[0] || '').trim() - return contexts[key as keyof typeof contexts] - }, - listSourceLoadStats: async () => [], - listVirtualNumbers: listEmptyVirtualNumbers, - } - const flow = { - binding: { - cloudSourceKeys: ['account-a', 'account-b'], - }, - } - const first = await selectCloudtentaclesSourceForFulfillment(flow, deps) - const second = await selectCloudtentaclesSourceForFulfillment(flow, deps) - - try { - assert.equal(first.sourceKey, 'account-a') - assert.equal(second.sourceKey, 'account-b') - } finally { - first.release() - second.release() - } -}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts deleted file mode 100644 index 5c78c3a9..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/account-selector.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { listKuaishouCloudSourceLoadStats } from '../../../repositories/kuaishou-cloud-task-state-repo.js' -import { - KUAISHOU_CLOUD_FIXED_VN_KEY, - normalizeKuaishouCloudFlow, - normalizeStringArray, - type JsonObject, -} from './domain.js' -import { resolvePersistedCloudtentaclesContextWithFallback } from './cloudtentacles-context.js' -import { listCloudtentaclesVirtualNumbers } from '../../platforms/cloudtentacles/virtual-number-service.js' -import { createHttpError } from '../../../utils/http.js' - -type CloudtentaclesContext = ReturnType - -type CloudtentaclesSourceSelectionDeps = { - resolveContextBySourceKeys?: typeof resolvePersistedCloudtentaclesContextWithFallback - listSourceLoadStats?: typeof listKuaishouCloudSourceLoadStats - listVirtualNumbers?: typeof listCloudtentaclesVirtualNumbers -} - -export type CloudtentaclesSourceSelection = { - context: CloudtentaclesContext - sourceKey: string - candidateCount: number - activeCount: number - redeemingCount: number - inProcessCount: number - score: number - occupiedCount: number - capacity: number - availableCount: number - fixed: boolean - release: () => void -} - -const inProcessSelections = new Map() -let selectionLockTail: Promise = Promise.resolve() - -export async function selectCloudtentaclesSourceForFulfillment( - flowLike: unknown, - deps: CloudtentaclesSourceSelectionDeps = {}, -): Promise { - const flow = normalizeKuaishouCloudFlow(flowLike) - const resolveContext = - deps.resolveContextBySourceKeys || resolvePersistedCloudtentaclesContextWithFallback - const listSourceLoadStats = deps.listSourceLoadStats || listKuaishouCloudSourceLoadStats - const listVirtualNumbers = deps.listVirtualNumbers || listCloudtentaclesVirtualNumbers - const persistedSourceKey = String(flow.binding.resolvedSourceKey || '').trim() - const fixedSourceKey = flow.binding.vnId > 0 ? persistedSourceKey : '' - const excludedSourceKeys = new Set(normalizeStringArray((flow as JsonObject).excludedSourceKeys)) - const candidates = normalizeCloudtentaclesSourceCandidates( - fixedSourceKey - ? [fixedSourceKey, ...flow.binding.cloudSourceKeys] - : [...flow.binding.cloudSourceKeys, persistedSourceKey], - ).filter((key) => !excludedSourceKeys.has(key)) - - if (fixedSourceKey) { - const context = resolveContext([fixedSourceKey, ...flow.binding.cloudSourceKeys]) - return { - context, - sourceKey: context.resolvedSourceKey, - candidateCount: candidates.length, - activeCount: 0, - redeemingCount: 0, - inProcessCount: 0, - score: 0, - occupiedCount: 0, - capacity: 20, - availableCount: 20, - fixed: true, - release: () => {}, - } - } - - return withCloudtentaclesSourceSelectionLock(async () => { - const contexts = [] - let lastError: unknown = null - - for (const sourceKey of candidates) { - try { - contexts.push(resolveContext([sourceKey])) - } catch (error) { - lastError = error - } - } - - if (contexts.length === 0) { - if (lastError) { - throw lastError - } - resolveContext(candidates) - throw new Error('所有 cloudtentacles 账号均不可用') - } - - const stats = await listSourceLoadStats(contexts.map((context) => context.resolvedSourceKey)) - const statsBySourceKey = new Map(stats.map((stat) => [stat.sourceKey, stat])) - const ranked = [] - let lastCapacityError: unknown = null - for (const [index, context] of contexts.entries()) { - let occupiedCount - try { - // CT's VN list is the authoritative count; DB counters can lag after crashes/restarts. - const listed = await listVirtualNumbers({ - ...context, - key: KUAISHOU_CLOUD_FIXED_VN_KEY, - sourceKey: context.resolvedSourceKey, - accountLabel: context.accountLabel, - }) - occupiedCount = Number(listed.occupiedItemCount || 0) || 0 - } catch (error) { - lastCapacityError = error - continue - } - const capacity = 20 - const inProcessCount = normalizeCount(inProcessSelections.get(context.resolvedSourceKey)) - if (occupiedCount + inProcessCount >= capacity) continue - const stat = statsBySourceKey.get(context.resolvedSourceKey) || { - sourceKey: context.resolvedSourceKey, - activeCount: 0, - redeemingCount: 0, - } - const activeCount = normalizeCount(stat.activeCount) - const redeemingCount = normalizeCount(stat.redeemingCount) - const score = occupiedCount + inProcessCount - - ranked.push({ - context, - sourceKey: context.resolvedSourceKey, - index, - activeCount, - redeemingCount, - inProcessCount, - score, - occupiedCount, - capacity, - availableCount: Math.max(0, capacity - occupiedCount - inProcessCount), - }) - } - if (ranked.length === 0 && lastCapacityError && contexts.length > 0) throw lastCapacityError - ranked.sort( - (left, right) => - left.score - right.score || - left.redeemingCount - right.redeemingCount || - left.activeCount - right.activeCount || - left.index - right.index || - left.sourceKey.localeCompare(right.sourceKey), - ) - - const selected = ranked[0] - if (!selected) { - throw createHttpError('所有 cloudtentacles 账号虚拟号配额均已满', { - statusCode: 409, - errorCode: 'cloudtentacles_all_sources_at_capacity', - }) - } - - reserveCloudtentaclesSource(selected.sourceKey) - - return { - context: selected.context, - sourceKey: selected.sourceKey, - candidateCount: contexts.length, - activeCount: selected.activeCount, - redeemingCount: selected.redeemingCount, - inProcessCount: selected.inProcessCount, - score: selected.score, - occupiedCount: selected.occupiedCount, - capacity: selected.capacity, - availableCount: selected.availableCount, - fixed: false, - release: () => releaseCloudtentaclesSource(selected.sourceKey), - } - }) -} - -export function buildCloudtentaclesSourceSelectionEventDetail( - selection: CloudtentaclesSourceSelection, -): JsonObject { - return { - sourceKey: selection.sourceKey, - candidateCount: selection.candidateCount, - activeCount: selection.activeCount, - redeemingCount: selection.redeemingCount, - inProcessCount: selection.inProcessCount, - score: selection.score, - occupiedCount: selection.occupiedCount, - capacity: selection.capacity, - availableCount: selection.availableCount, - fixed: selection.fixed, - } -} - -function normalizeCloudtentaclesSourceCandidates(sourceKeys: unknown[] = []) { - return Array.from(new Set(normalizeStringArray(sourceKeys))) -} - -async function withCloudtentaclesSourceSelectionLock(fn: () => Promise): Promise { - let releaseLock: () => void = () => {} - const waitForTurn = selectionLockTail - selectionLockTail = new Promise((resolve) => { - releaseLock = resolve - }) - - await waitForTurn - - try { - return await fn() - } finally { - releaseLock() - } -} - -function reserveCloudtentaclesSource(sourceKey: string) { - inProcessSelections.set(sourceKey, normalizeCount(inProcessSelections.get(sourceKey)) + 1) -} - -function releaseCloudtentaclesSource(sourceKey: string) { - const nextCount = normalizeCount(inProcessSelections.get(sourceKey)) - 1 - if (nextCount > 0) { - inProcessSelections.set(sourceKey, nextCount) - return - } - - inProcessSelections.delete(sourceKey) -} - -function normalizeCount(value: unknown) { - const count = Number(value || 0) || 0 - return Number.isFinite(count) && count > 0 ? count : 0 -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-freeze.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-freeze.test.ts deleted file mode 100644 index c9274d73..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-freeze.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { TASK_STATUS } from '../../../domain/task-status.js' -import { - buildKuaishouCloudShippedSnapshot, - isKuaishouCloudBindingMutationFrozen, - isKuaishouCloudDispatchSucceeded, - normalizeKuaishouCloudFlow, -} from './domain.js' - -test('isKuaishouCloudDispatchSucceeded 识别 dispatch.success', () => { - assert.equal(isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'success' } }), true) - assert.equal(isKuaishouCloudDispatchSucceeded({ dispatch: { status: 'pending' } }), false) -}) - -test('isKuaishouCloudBindingMutationFrozen:dispatch.success 即使 status=waiting_binding 也冻结', () => { - assert.equal( - isKuaishouCloudBindingMutationFrozen( - { task_status: TASK_STATUS.WAITING_BINDING }, - { dispatch: { status: 'success' } }, - ), - true, - ) -}) - -test('isKuaishouCloudBindingMutationFrozen:兑换中/已完成冻结', () => { - assert.equal( - isKuaishouCloudBindingMutationFrozen( - { task_status: TASK_STATUS.REDEEMING }, - { dispatch: { status: 'pending' } }, - ), - true, - ) - assert.equal( - isKuaishouCloudBindingMutationFrozen( - { task_status: TASK_STATUS.COMPLETED }, - { dispatch: { status: 'pending' } }, - ), - true, - ) -}) - -test('isKuaishouCloudBindingMutationFrozen:待绑定未发货不冻结', () => { - assert.equal( - isKuaishouCloudBindingMutationFrozen( - { task_status: TASK_STATUS.WAITING_BINDING }, - { dispatch: { status: 'pending' } }, - ), - false, - ) -}) - -test('normalizeKuaishouCloudFlow 保留 shipped 快照', () => { - const flow = normalizeKuaishouCloudFlow({ - dispatch: { status: 'success' }, - shipped: { - expectedUid: '3781015014', - roleId: '3781015014', - roleName: 'l迈', - vnId: 189363, - vnPhone: '18641685924', - vnKey: '1', - dispatchedAt: '2026-07-16T06:25:13.000Z', - }, - binding: { roleId: '3805904834', roleName: '他爆炸' }, - }) - assert.equal(flow.shipped?.roleId, '3781015014') - assert.equal(flow.shipped?.expectedUid, '3781015014') - assert.equal(flow.binding.roleId, '3805904834') -}) - -test('buildKuaishouCloudShippedSnapshot 固化发货身份', () => { - const snapshot = buildKuaishouCloudShippedSnapshot({ - expectedUid: '3781015014', - roleId: '3781015014', - roleName: '安卓-微信-l迈', - vnId: 189363, - vnPhone: '18641685924', - dispatchedAt: 'now', - }) - assert.deepEqual(snapshot, { - expectedUid: '3781015014', - roleId: '3781015014', - roleName: '安卓-微信-l迈', - vnId: 189363, - vnPhone: '18641685924', - vnKey: '1', - dispatchedAt: 'now', - }) -}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts deleted file mode 100644 index 6ccb8eae..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/binding-resources.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { normalizeProductName } from '../product-resolution-service.js' -import { - appointCloudtentaclesVirtualNumber, - backCloudtentaclesVirtualNumber, - fetchCloudtentaclesVirtualNumberCode, - generateCloudtentaclesLoginCode, - getCloudtentaclesBindUrl, - verifyCloudtentaclesLoginCode, -} from '../../platforms/cloudtentacles/virtual-number-service.js' -import { KUAISHOU_CLOUD_FIXED_VN_KEY, type JsonObject } from './domain.js' -import { logInfo } from '../../../utils/logger.js' - -/** 账号号码配额占满后冷却:避免领取页轮询 / open-91 交付每轮都狂打上游 */ -const APPOINT_QUOTA_COOLDOWN_MS = 60_000 -const appointQuotaCooldowns = new Map() - -export function resolveKuaishouCloudBindingResources( - flow: JsonObject, - { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[]; knapsackItems?: unknown[] } = {}, -) { - const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : [] - const normalizedKnapsackItems = Array.isArray(knapsackItems) - ? knapsackItems.filter(isCloudSkuLikeItem) - : [] - const currentSkuId = Number(flow?.binding?.skuId || 0) || 0 - const currentSkuName = String(flow?.binding?.skuName || '').trim() - const nameCandidates = collectKuaishouCloudNameCandidates(flow) - - const skuItemById = - currentSkuId > 0 - ? normalizedSkuItems.find((item) => Number(item.id || 0) === currentSkuId) || null - : null - const knapsackItemById = - currentSkuId > 0 - ? normalizedKnapsackItems.find((item) => Number(item.id || 0) === currentSkuId) || null - : null - - if (skuItemById || knapsackItemById) { - const matchedItem = skuItemById || knapsackItemById - return { - skuId: Number(matchedItem?.id || 0) || 0, - skuName: String(currentSkuName || matchedItem?.name || '').trim(), - vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, - skuItem: skuItemById, - knapsackItem: knapsackItemById, - resolvedByName: false, - } - } - - const matchedSkuItem = findCloudItemByNames(normalizedSkuItems, nameCandidates) - const matchedKnapsackItem = findCloudItemByNames( - normalizedKnapsackItems, - nameCandidates, - matchedSkuItem ? Number(matchedSkuItem.id || 0) : 0, - ) - const matchedItem = matchedSkuItem || matchedKnapsackItem - - return { - skuId: Number(matchedItem?.id || 0) || 0, - skuName: String(currentSkuName || matchedItem?.name || '').trim(), - vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, - skuItem: matchedSkuItem, - knapsackItem: matchedKnapsackItem, - resolvedByName: Boolean(matchedItem), - } -} - -export function resolveKuaishouCloudVnKeyCandidates(_input: JsonObject = {}) { - return [KUAISHOU_CLOUD_FIXED_VN_KEY] -} - -export async function prepareKuaishouCloudBindResourceWithFallback(input: JsonObject = {}) { - const { cloudContext = {}, vnKeyCandidates = [] } = input - const candidates = Array.isArray(vnKeyCandidates) ? vnKeyCandidates : [] - const sourceKey = String(cloudContext.resolvedSourceKey || '').trim() - let lastError = null - - for (const vnKey of candidates) { - let vnId = 0 - let vnPhone - const cooldownKey = `${sourceKey}|${vnKey}` - - const cooldownUntil = appointQuotaCooldowns.get(cooldownKey) || 0 - if (cooldownUntil > Date.now()) { - throw createHttpError('账号虚拟号配额已满,请先退回已占用号码或稍后重试', { - statusCode: 409, - errorCode: 'cloudtentacles_vn_quota_cooldown', - }) - } - - try { - const appointed = await appointCloudtentaclesVirtualNumber({ - ...cloudContext, - key: vnKey, - }) - vnId = Number(appointed.item?.id || 0) - vnPhone = String(appointed.item?.phone || '').trim() - - logInfo('[kuaishou-cloud/binding]', '虚拟号申请成功', { - sourceKey, - accountLabel: cloudContext.accountLabel, - purpose: String(input.purpose || 'prepare_binding'), - vnKey, - vnId, - }) - - if (!vnId || !vnPhone) { - throw createHttpError('申请虚拟号成功但返回数据不完整', { - statusCode: 502, - errorCode: 'kuaishou_cloud_invalid_vn', - }) - } - - await generateCloudtentaclesLoginCode({ - ...cloudContext, - key: vnKey, - id: vnId, - }) - - const fetchedCode = await fetchCloudtentaclesVirtualNumberCode({ - ...cloudContext, - key: vnKey, - phone: vnPhone, - }) - - await verifyCloudtentaclesLoginCode({ - ...cloudContext, - key: vnKey, - id: vnId, - code: fetchedCode.code, - }) - - const bindUrlResult = await getCloudtentaclesBindUrl({ - ...cloudContext, - key: vnKey, - id: vnId, - }) - - return { - vnKey, - vnId, - vnPhone, - bindUrl: String(bindUrlResult.bindUrl || '').trim(), - } - } catch (error) { - lastError = error - - if (isAppointQuotaExhaustedError(error)) { - appointQuotaCooldowns.set(cooldownKey, Date.now() + APPOINT_QUOTA_COOLDOWN_MS) - } - - if (vnId > 0) { - try { - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: vnKey, - id: vnId, - }) - } catch { - // 退号失败保留主错误 - } - } - - if (!isRecoverableKuaishouCloudVnKeyError(error)) { - throw error - } - } - } - - throw ( - lastError || - createHttpError('没有找到可用的 VN Key', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_binding_config', - }) - ) -} - -function collectKuaishouCloudNameCandidates(flow: JsonObject) { - return Array.from( - new Set( - [ - String(flow?.binding?.skuName || '').trim(), - String(flow?.internalSkuName || '').trim(), - String(flow?.internalSkuCode || '').trim(), - ].filter(Boolean), - ), - ) -} - -function findCloudItemByNames(items: JsonObject[], nameCandidates: string[], preferredId = 0) { - const normalizedItems = Array.isArray(items) ? items : [] - const normalizedNames = nameCandidates - .map((item: string) => ({ - raw: String(item || '').trim(), - normalized: normalizeProductName(item), - })) - .filter((item) => item.raw && item.normalized) - - if (normalizedNames.length === 0 || normalizedItems.length === 0) { - return preferredId > 0 - ? normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null - : null - } - - if (preferredId > 0) { - const preferred = - normalizedItems.find((item: JsonObject) => Number(item.id || 0) === preferredId) || null - if (preferred) { - return preferred - } - } - - const exactMatches = normalizedItems.filter((item: JsonObject) => { - const itemName = normalizeProductName(item.name) - return normalizedNames.some( - (candidate: { normalized: string }) => candidate.normalized === itemName, - ) - }) - if (exactMatches.length > 0) { - return exactMatches[0] - } - - const partialMatches = normalizedItems.filter((item: JsonObject) => { - const itemName = normalizeProductName(item.name) - return normalizedNames.some( - (candidate: { normalized: string }) => - itemName.includes(candidate.normalized) || candidate.normalized.includes(itemName), - ) - }) - if (partialMatches.length > 0) { - return partialMatches.sort( - (left, right) => String(left.name || '').length - String(right.name || '').length, - )[0] - } - - return null -} - -function isCloudSkuLikeItem(item: unknown): item is JsonObject { - const current = item && typeof item === 'object' ? (item as JsonObject) : {} - return Number(current.id || 0) > 0 -} - -function isAppointQuotaExhaustedError(error: unknown) { - const current = error && typeof error === 'object' ? (error as JsonObject) : {} - return ( - String(current.errorCode || current.code || '').trim() === 'cloudtentacles_vn_appoint_failed' && - String(current.message || '') - .trim() - .includes('最多同时占用') - ) -} - -function isRecoverableKuaishouCloudVnKeyError(error: unknown) { - const current = error && typeof error === 'object' ? (error as JsonObject) : {} - const errorCode = String(current.errorCode || current.code || '').trim() - const errorMessage = String(current.message || '').trim() - return ( - errorCode === 'cloudtentacles_vn_bind_url_failed' && errorMessage.includes('不支持的游戏类型') - ) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts deleted file mode 100644 index 12ccb2a4..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-context.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from '../../platforms/cloudtentacles/defaults.js' -import { getCloudtentaclesSourceByKey } from '../../platforms/cloudtentacles/source-config-service.js' -import { getCloudtentaclesSessionStateByKey } from '../../platforms/cloudtentacles/session-state-service.js' -import { normalizeStringArray } from './domain.js' - -/** - * 严格模式:只解析列表中第一个账号(调用方均把任务实际取号账号 resolvedSourceKey 放首位), - * 该账号不可用时直接报错,绝不静默 fallback 到其他账号—— - * 否则会用「非取号账号」去退号/取链,上游会因号码不归属当前会话而报权限不足。 - * 退号、取链、发货等「操作任务已有号码」的场景必须使用此函数。 - * 将实际使用的 resolvedSourceKey 也返回,确保后续操作使用同一个 sourceKey。 - */ -export function resolvePersistedCloudtentaclesContextBySourceKeys(sourceKeys: unknown[] = []) { - const candidates = [...new Set(normalizeStringArray(sourceKeys))] - - if (candidates.length === 0) { - throw createHttpError('cloudtentacles 没有可用账号,请先到平台配置完成账号配置', { - statusCode: 409, - errorCode: 'kuaishou_cloud_no_source_keys', - }) - } - - const sourceKey = String(candidates[0] || '').trim() - - return resolveSingleCloudtentaclesAccountContext(sourceKey) -} - -/** - * 宽松模式:按顺序尝试候选账号,找到第一个有可用 token 的账号。 - * 仅用于「取新号」场景(新号码归属被选中的账号,不会产生跨账号孤儿), - * 以及 account-selector 单候选解析。严禁用于退号/取链/发货等操作已有号码的场景。 - */ -export function resolvePersistedCloudtentaclesContextWithFallback(sourceKeys: unknown[] = []) { - const candidates = [...new Set(normalizeStringArray(sourceKeys))] - - let lastError: unknown = null - - for (const sourceKey of candidates) { - try { - return resolveSingleCloudtentaclesAccountContext(sourceKey) - } catch (error) { - lastError = error - } - } - - throw ( - lastError || - createHttpError('所有 cloudtentacles 账号均不可用,请先到平台配置完成登录校验', { - statusCode: 409, - errorCode: 'kuaishou_cloud_all_source_keys_exhausted', - }) - ) -} - -function resolveSingleCloudtentaclesAccountContext(sourceKey: string) { - const source = getCloudtentaclesSourceByKey(sourceKey) - const session = getCloudtentaclesSessionStateByKey(sourceKey) - - if (!source) { - throw createHttpError( - `cloudtentacles 账号 ${sourceKey} 不存在(取号账号已变更或被删除,无法继续操作其虚拟号)`, - { statusCode: 409, errorCode: 'kuaishou_cloud_source_missing' }, - ) - } - - if (source.enabled === false) { - throw createHttpError(`cloudtentacles 账号 ${sourceKey} 已停用`, { - statusCode: 409, - errorCode: 'kuaishou_cloud_source_disabled', - }) - } - - if (!session) { - throw createHttpError(`cloudtentacles 账号 ${sourceKey} 没有可用 token`, { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_cloud_token', - }) - } - - const token = String(session.token || '').trim() - if (!token) { - throw createHttpError(`cloudtentacles 账号 ${sourceKey} 没有可用 token`, { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_cloud_token', - }) - } - - return { - baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', - token, - deviceId: normalizeCloudtentaclesDeviceId(session.deviceId || source.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(session.deviceType ?? source.deviceType), - // 透传给 http-client / 错误包装,便于定位跨账号问题 - sourceKey, - resolvedSourceKey: sourceKey, - accountLabel: String(source.label || sourceKey).trim() || sourceKey, - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.test.ts deleted file mode 100644 index e3273894..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { - isCloudtentaclesPermissionLikeError, - wrapCloudtentaclesOperationError, -} from './cloudtentacles-errors.js' - -test('wrapCloudtentaclesOperationError 把权限不足包装成可定位文案', () => { - const upstream = Object.assign(new Error('权限不足'), { - statusCode: 401, - errorCode: 'cloudtentacles_vn_back_failed', - context: { - upstreamCode: -1, - upstreamMessage: '权限不足', - upstreamPayload: { code: -1, message: '权限不足' }, - }, - }) - - const wrapped = wrapCloudtentaclesOperationError(upstream, { - action: 'vn_back', - actionLabel: '退还旧虚拟号', - sourceKey: 'account_day', - vnKey: '1', - vnId: 12345, - vnPhone: '13800000000', - }) as Error & { statusCode?: number; errorCode?: string } - - assert.equal(wrapped.statusCode, 409) - assert.match(wrapped.message, /cloudtentacles 退还旧虚拟号失败/) - assert.match(wrapped.message, /上游:权限不足/) - assert.match(wrapped.message, /账号=account_day/) - assert.match(wrapped.message, /vnId=12345/) - assert.match(wrapped.message, /平台配置/) - assert.equal(isCloudtentaclesPermissionLikeError(upstream), true) -}) - -test('isCloudtentaclesPermissionLikeError 识别权限类文案', () => { - assert.equal(isCloudtentaclesPermissionLikeError(new Error('权限不足')), true) - assert.equal(isCloudtentaclesPermissionLikeError(new Error('余额不足')), false) -}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.ts deleted file mode 100644 index 3d64ecd9..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/cloudtentacles-errors.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import type { JsonObject } from './domain.js' - -/** - * 将 cloudtentacles 上游错误包装成可运维定位的文案。 - * 避免只回「权限不足」让人误判成后台角色权限。 - */ -export function wrapCloudtentaclesOperationError( - error: unknown, - operation: { - action: string - actionLabel: string - sourceKey?: unknown - vnKey?: unknown - vnId?: unknown - vnPhone?: unknown - }, -) { - const upstreamMessage = resolveErrorMessage(error) - const upstreamCode = - resolveErrorField(error, 'errorCode') || - resolveUpstreamCode(error) || - 'cloudtentacles_operation_failed' - const sourceKey = String(operation.sourceKey || '').trim() || 'unknown' - const vnKey = String(operation.vnKey || '').trim() - const vnId = Number(operation.vnId || 0) || 0 - const vnPhone = String(operation.vnPhone || '').trim() - - const parts = [ - `cloudtentacles ${operation.actionLabel}失败`, - upstreamMessage ? `上游:${upstreamMessage}` : '', - `账号=${sourceKey}`, - vnId > 0 ? `vnId=${vnId}` : '', - vnKey ? `vnKey=${vnKey}` : '', - vnPhone ? `phone=${vnPhone}` : '', - '请到「平台配置-cloudtentacles」校验该账号登录态;若账号与取号时不一致会导致退号/取链权限不足', - ].filter(Boolean) - - const statusCode = resolveStatusCode(error) - // 业务失败不要用 401,避免前端当成后台未登录/无权限 - const normalizedStatus = - statusCode === 401 || statusCode === 403 ? 409 : statusCode >= 400 ? statusCode : 409 - - return createHttpError(parts.join(';'), { - statusCode: normalizedStatus, - errorCode: String(upstreamCode || `cloudtentacles_${operation.action}_failed`).trim(), - cause: error instanceof Error ? error : undefined, - context: { - cloudtentacles: { - action: operation.action, - actionLabel: operation.actionLabel, - sourceKey, - vnKey, - vnId, - vnPhone, - upstreamMessage, - upstreamCode: resolveUpstreamCode(error), - upstreamPayload: resolveUpstreamPayload(error), - }, - }, - }) -} - -export function isCloudtentaclesPermissionLikeError(error: unknown): boolean { - const message = resolveErrorMessage(error) - const code = resolveUpstreamCode(error) - return ( - message.includes('权限不足') || - message.includes('没有权限') || - message.includes('无权限') || - String(code) === '-1' - ) -} - -function resolveErrorMessage(error: unknown) { - if (error instanceof Error && error.message) { - return error.message.trim() - } - return String(error || '未知错误').trim() -} - -function resolveErrorField(error: unknown, field: string) { - if (!error || typeof error !== 'object') { - return '' - } - return String((error as JsonObject)[field] || '').trim() -} - -function resolveStatusCode(error: unknown) { - if (!error || typeof error !== 'object') { - return 409 - } - const statusCode = Number((error as JsonObject).statusCode || 0) - return Number.isFinite(statusCode) && statusCode > 0 ? statusCode : 409 -} - -function resolveUpstreamCode(error: unknown) { - if (!error || typeof error !== 'object') { - return '' - } - const context = (error as { context?: unknown }).context - if (!context || typeof context !== 'object') { - return '' - } - return String((context as JsonObject).upstreamCode ?? '').trim() -} - -function resolveUpstreamPayload(error: unknown) { - if (!error || typeof error !== 'object') { - return null - } - const context = (error as { context?: unknown }).context - if (!context || typeof context !== 'object') { - return null - } - return (context as JsonObject).upstreamPayload ?? null -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/confirm-role.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/confirm-role.ts deleted file mode 100644 index 2d703912..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/confirm-role.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { - TASK_STATUS, - isKuaishouCloudRoleConfirmSettledStatus, -} from '../../../domain/task-status.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { - assertBoundUidMatchesExpected, - assertClaimExpectedUidReady, -} from '../../claim/claim-identity.js' -import { isKuaishouCloudTask, normalizeKuaishouCloudFlow, type JsonObject } from './domain.js' -import { isKuaishouCloudMockContext, isKuaishouCloudMockTask } from './mock-helpers.js' -import { refreshKuaishouCloudTaskRoleInfo } from './refresh-role-info.js' -import { normalizeActor, parseTaskContext } from './task-context.js' - -export type ConfirmKuaishouCloudRoleResult = { - task: TaskRow - alreadySettled: boolean -} - -/** - * lewan 确认角色用例:刷新角色 → UID 闸门 → ROLE_CONFIRMED。 - * 一键兑换路径里 WAITING_BINDING 的自动晋升在 redeem 内完成,不必先调本函数。 - */ -export async function confirmKuaishouCloudTaskRole( - task: TaskRow, - options: JsonObject = {}, -): Promise { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const now = String(options.now || '').trim() || nowIso() - const source = String(options.source || 'system_confirm_role').trim() || 'system_confirm_role' - const actor = normalizeActor(options.actor) - const errorCodePrefix = - String(options.errorCodePrefix || 'kuaishou_cloud_confirm').trim() || 'kuaishou_cloud_confirm' - - if (isKuaishouCloudRoleConfirmSettledStatus(task.task_status)) { - return { task, alreadySettled: true } - } - - const expectedUid = assertClaimExpectedUidReady(task) - const mockMode = - isKuaishouCloudMockTask(task) || isKuaishouCloudMockContext(parseTaskContext(task)) - - const refreshed = mockMode - ? { task } - : await refreshKuaishouCloudTaskRoleInfo(task, { - source, - actor, - recordEvent: options.recordEvent === true, - forceProbe: options.forceProbe !== false, - }) - - const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment) - - if (!flow.binding.vnPhone || !flow.binding.roleName || !flow.binding.roleId) { - throw createHttpError('角色信息还未刷新到系统,请完成绑定后稍等片刻再试', { - statusCode: 409, - errorCode: `${errorCodePrefix}_role_not_ready`, - }) - } - - if (!mockMode) { - assertBoundUidMatchesExpected(refreshed.task, flow, { - errorCodePrefix, - }) - } - - const updatedTask = - (await updateTask(task.id, { - task_status: TASK_STATUS.ROLE_CONFIRMED, - role_id: flow.binding.roleId, - role_name: flow.binding.roleName, - user_action_status: TASK_STATUS.ROLE_CONFIRMED, - role_confirmed_at: now, - last_error: '', - updated_at: now, - })) || - refreshed.task || - task - - await createTaskEvent( - task.id, - 'kuaishou_cloud_role_confirmed', - { - expectedUid, - vnPhone: flow.binding.vnPhone, - roleName: flow.binding.roleName, - roleId: flow.binding.roleId, - source, - actor, - }, - now, - ) - - return { task: updatedTask, alreadySettled: false } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/delivery-plan.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/delivery-plan.ts deleted file mode 100644 index 4bbd9a67..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/delivery-plan.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { JsonObject } from './domain.js' - -export type CloudDeliveryPlanItem = { - cloudSkuId: number - cloudSkuName: string - quantity: number - skuItem: JsonObject | null - knapsackItem: JsonObject | null - knapsackCount: number - missingCount: number -} - -export function resolveKuaishouCloudDeliveryPlan( - flow: JsonObject, - { skuItems = [], knapsackItems = [] }: { skuItems?: unknown[]; knapsackItems?: unknown[] } = {}, -): { items: CloudDeliveryPlanItem[] } { - const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : [] - const normalizedKnapsackItems = Array.isArray(knapsackItems) - ? knapsackItems.filter(isCloudSkuLikeItem) - : [] - const deliveryItems = Array.isArray(flow.deliveryItems) ? flow.deliveryItems : [] - - return { - items: deliveryItems - .map((item: unknown) => { - const source = item && typeof item === 'object' ? (item as JsonObject) : {} - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 - const quantity = Number(source.quantity || 1) || 1 - if (!cloudSkuId || !Number.isInteger(quantity) || quantity <= 0) { - return null - } - - const skuItem = normalizedSkuItems.find((sku) => Number(sku.id || 0) === cloudSkuId) || null - const knapsackItem = - normalizedKnapsackItems.find((sku) => Number(sku.id || 0) === cloudSkuId) || null - const knapsackCount = Math.max(0, Number(knapsackItem?.count || 0) || 0) - const cloudSkuName = String( - source.cloudSkuName || source.skuName || skuItem?.name || knapsackItem?.name || '', - ).trim() - - return { - cloudSkuId, - cloudSkuName, - quantity, - skuItem, - knapsackItem, - knapsackCount, - missingCount: Math.max(0, quantity - knapsackCount), - } - }) - .filter((item): item is CloudDeliveryPlanItem => Boolean(item)), - } -} - -function isCloudSkuLikeItem(item: unknown): item is JsonObject { - const current = item && typeof item === 'object' ? (item as JsonObject) : {} - return Number(current.id || 0) > 0 -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-failure.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-failure.ts deleted file mode 100644 index 5bbcb159..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-failure.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { TASK_STATUS } from '../../../domain/task-status.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import type { JsonObject } from './domain.js' -import type { DispatchStockResult } from './dispatch-types.js' - -export async function markKuaishouCloudDispatchFailed( - task: TaskRow, - taskContext: JsonObject, - flow: JsonObject, - error: unknown, - options: JsonObject = {}, -) { - const now = nowIso() - const errorMessage = resolveErrorMessage(error) - const errorCode = resolveErrorCode(error) || 'kuaishou_cloud_dispatch_failed' - const failureContext = resolveDispatchFailureContext(error) - const stockResult = isPlainObject(failureContext.stockResult) - ? (failureContext.stockResult as Partial) - : null - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - dispatch: { - ...(isPlainObject(flow.dispatch) ? flow.dispatch : {}), - status: 'failed', - failedAt: now, - failedStage: String(failureContext.stage || '').trim(), - errorCode, - errorMessage, - note: buildDispatchFailedMessage(errorMessage), - }, - purchase: { - ...(isPlainObject(flow.purchase) ? flow.purchase : {}), - ...(stockResult - ? { - usedKnapsack: stockResult.usedKnapsack, - purchaseTriggered: stockResult.purchaseTriggered, - assetBefore: stockResult.assetBefore, - assetAfter: stockResult.assetAfter, - items: stockResult.items, - } - : {}), - }, - }, - } - - await updateTask(task.id, { - task_status: TASK_STATUS.MANUAL_REVIEW, - user_action_status: 'not_required', - last_error: errorMessage, - result_code: errorCode, - result_message: buildDispatchFailedMessage(errorMessage), - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_dispatch_failed', - { - source: String(options.source || 'system').trim() || 'system', - actor: options.actor, - errorCode, - errorMessage, - failureContext, - }, - now, - ) -} - -export function enrichCloudtentaclesDispatchError(error: unknown, context: JsonObject) { - const currentError = - error instanceof Error - ? (error as Error & { context?: unknown }) - : (createHttpError(resolveErrorMessage(error), { - statusCode: 500, - errorCode: 'kuaishou_cloud_dispatch_failed', - }) as Error & { context?: unknown }) - const currentContext = isPlainObject(currentError.context) ? currentError.context : {} - currentError.context = { - ...currentContext, - dispatchFailure: { - ...(isPlainObject((currentContext as JsonObject).dispatchFailure) - ? ((currentContext as JsonObject).dispatchFailure as JsonObject) - : {}), - ...context, - }, - } - - return currentError -} - -export function resolveDispatchFailureContext(error: unknown): JsonObject { - if (!error || typeof error !== 'object') { - return {} - } - - const context = (error as { context?: unknown }).context - if (!isPlainObject(context)) { - return {} - } - - const dispatchFailure = context.dispatchFailure - return isPlainObject(dispatchFailure) ? dispatchFailure : {} -} - -export function resolveErrorCode(error: unknown) { - if (!error || typeof error !== 'object') { - return '' - } - - return String((error as { errorCode?: unknown }).errorCode || '').trim() -} - -export function resolveErrorMessage(error: unknown) { - return error instanceof Error ? error.message : String(error || 'CloudTentacles 发货失败') -} - -export function buildDispatchFailedMessage(message: unknown) { - const reason = String(message || '').trim() || '未知错误' - return `CloudTentacles 发货失败:${reason}` -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts deleted file mode 100644 index b3783a8f..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-fulfillment.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { getClaimIdentityFromTask } from '../../claim/claim-identity.js' -import { - buildKuaishouCloudShippedSnapshot, - isIndustryEVoucherTask, - isKuaishouCloudTask, - maskCode, - maskPhone, - normalizeKuaishouCloudFlow, - type JsonObject, -} from './domain.js' -import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' -import { syncKuaishouCloudRoleInfoBeforeDispatch } from './dispatch-role-sync.js' -import { markKuaishouCloudDispatchFailed } from './dispatch-failure.js' -import { - prepareStockAndDispatch, - resolveCloudSkuDispatchLockKeys, - withCloudSkuDispatchLocks, -} from './dispatch-stock.js' -import { - resolveDispatchDeliveryItems, - type DispatchResultItem, - type DispatchStockResult, -} from './dispatch-types.js' -import { returnKuaishouCloudFulfillmentTask } from './return-fulfillment.js' -import { normalizeActor, parseTaskContext } from './task-context.js' - -/** - * lewan 自动/后台发货主用例: - * UID 闸门同步角色 → 库存与采购 → cloudtentacles 下发 → 可选自动退号收尾。 - */ -export async function dispatchKuaishouCloudFulfillmentTask( - task: TaskRow, - options: JsonObject = {}, -) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const actor = normalizeActor(options.actor) - const now = nowIso() - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ - flow.binding.resolvedSourceKey, - ...flow.binding.cloudSourceKeys, - ]) - - if (!flow.binding.skuId || !flow.binding.vnId || !flow.binding.vnPhone) { - throw createHttpError('当前任务还没有准备好绑定资源,请先完成绑定资源准备', { - statusCode: 409, - errorCode: 'kuaishou_cloud_not_prepared', - }) - } - - const persistedTicketCode = String(flow.ticket.code || '').trim() - const voucherContext = isPlainObject(taskContext.kuaishouIndustryVoucher) - ? taskContext.kuaishouIndustryVoucher - : {} - const industryVoucherCode = String( - voucherContext.voucherCode || voucherContext.eticketId || '', - ).trim() - const hasIndustryVoucherForDispatch = Boolean(industryVoucherCode) || isIndustryEVoucherTask(task) - const resolvedTicketCode = persistedTicketCode || industryVoucherCode - - if (!resolvedTicketCode && !hasIndustryVoucherForDispatch) { - throw createHttpError('旧快手小店核销流程已停用,请改用行业电子凭证处理', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_ticket_code', - }) - } - - if ( - flow.dispatch.status === 'success' && - normalizeTaskStatus(task.task_status) === TASK_STATUS.DISPATCHED_PENDING_RETURN - ) { - return { task, flow } - } - - const synced = await syncKuaishouCloudRoleInfoBeforeDispatch(task, { - now, - actor, - source: options.source || 'system_before_dispatch', - cloudContext, - taskContext, - flow, - }) - const syncedFlow = synced.flow - const syncedTaskContext = synced.taskContext - - const deliveryItems = resolveDispatchDeliveryItems(syncedFlow as JsonObject) - if (deliveryItems.length === 0) { - throw createHttpError('当前任务缺少 cloud 发货物品配置', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_delivery_items', - }) - } - - let dispatchResults: DispatchResultItem[] - let stockResult: DispatchStockResult - try { - ;({ dispatchResults, stockResult } = await withCloudSkuDispatchLocks( - resolveCloudSkuDispatchLockKeys(cloudContext.resolvedSourceKey, deliveryItems), - () => - prepareStockAndDispatch({ - task, - flow: syncedFlow as JsonObject, - cloudContext, - deliveryItems, - }), - )) - } catch (error) { - await markKuaishouCloudDispatchFailed( - task, - syncedTaskContext as JsonObject, - syncedFlow as JsonObject, - error, - { - actor, - source: options.source || 'system', - }, - ) - throw error - } - - const firstDispatchResult: DispatchResultItem = dispatchResults[0] || { - cloudSkuId: syncedFlow.binding.skuId, - cloudSkuName: syncedFlow.binding.skuName, - quantity: 1, - unitIndex: 1, - sendType: 0, - note: '', - responseMessage: '', - } - const dispatchSummary = - dispatchResults.length > 1 - ? `cloudtentacles 发货成功,共 ${dispatchResults.length} 次` - : String( - firstDispatchResult.responseMessage || - firstDispatchResult.note || - 'cloudtentacles 发货成功', - ).trim() - - const claimIdentity = getClaimIdentityFromTask(synced.task || task) - const shippedSnapshot = buildKuaishouCloudShippedSnapshot({ - expectedUid: claimIdentity.expectedUid, - roleId: syncedFlow.binding.roleId || syncedFlow.role.rid, - roleName: syncedFlow.binding.roleName || syncedFlow.role.name, - vnId: syncedFlow.binding.vnId, - vnPhone: syncedFlow.binding.vnPhone, - vnKey: syncedFlow.binding.vnKey, - dispatchedAt: now, - }) - - const nextContext = { - ...syncedTaskContext, - kuaishouCloudFulfillment: { - ...syncedFlow, - ticket: { - ...syncedFlow.ticket, - code: resolvedTicketCode, - capturedAt: resolvedTicketCode - ? syncedFlow.ticket.capturedAt || now - : syncedFlow.ticket.capturedAt, - capturedBy: syncedFlow.ticket.capturedBy, - }, - dispatch: { - ...syncedFlow.dispatch, - status: 'success', - dispatchAt: now, - dispatchBy: actor, - sendType: Number(firstDispatchResult.sendType || 0) || 0, - note: dispatchSummary, - items: dispatchResults, - }, - // 冻结发货身份:后续 claim 轮询/过期换号不得改写展示与匹配依据 - shipped: syncedFlow.shipped || shippedSnapshot, - purchase: { - ...syncedFlow.purchase, - usedKnapsack: stockResult.usedKnapsack, - purchaseTriggered: stockResult.purchaseTriggered, - assetBefore: stockResult.assetBefore, - assetAfter: stockResult.assetAfter, - purchaseAt: stockResult.purchaseTriggered ? now : syncedFlow.purchase.purchaseAt, - items: stockResult.items, - }, - }, - } - - let updatedTask = await updateTask(task.id, { - task_status: TASK_STATUS.DISPATCHED_PENDING_RETURN, - delivery_status: 'delivered', - result_code: 'kuaishou_cloud_dispatched', - result_message: dispatchSummary, - user_action_status: 'not_required', - last_error: '', - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - if (!updatedTask) { - throw createHttpError('kuaishou-lewan 发货状态更新失败', { - statusCode: 500, - errorCode: 'kuaishou_cloud_dispatch_update_failed', - }) - } - - await createTaskEvent( - task.id, - 'kuaishou_cloud_dispatched', - { - source: String(options.source || 'system').trim() || 'system', - ticketCodeMasked: maskCode(resolvedTicketCode), - skuId: syncedFlow.binding.skuId, - vnId: syncedFlow.binding.vnId, - vnPhoneMasked: maskPhone(syncedFlow.binding.vnPhone), - expectedUid: shippedSnapshot.expectedUid, - roleId: shippedSnapshot.roleId, - roleName: shippedSnapshot.roleName, - sendType: firstDispatchResult.sendType, - note: dispatchSummary, - deliveryItems, - dispatchResults, - stockResult, - shipped: shippedSnapshot, - actor, - }, - now, - ) - - const shouldAutoFinalize = - options.autoFinalize === true && - normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment).returnNumber - .autoReturnEnabled === true - - if (shouldAutoFinalize) { - const finalizeResult = await returnKuaishouCloudFulfillmentTask(updatedTask, { - actor, - source: options.source || 'system_auto_finalize', - // 发货后自动收尾:退号并尝试核销(与 admin 清理退号不同) - consumeIndustryVoucher: true, - }) - updatedTask = finalizeResult.task - } - - return { - task: updatedTask, - flow: normalizeKuaishouCloudFlow(parseTaskContext(updatedTask).kuaishouCloudFulfillment), - } -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts deleted file mode 100644 index 7cd4f59b..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-role-sync.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { createHttpError } from '../../../utils/http.js' -import { getCloudtentaclesBindInfo } from '../../platforms/cloudtentacles/virtual-number-service.js' -import { - assertLewanAutoFulfillmentUidReady, - isClaimUidMatched, - normalizeClaimUid, -} from '../../claim/claim-identity.js' -import { asJsonObject } from '../../../types/json.js' -import { - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - type JsonObject, -} from './domain.js' - -export async function syncKuaishouCloudRoleInfoBeforeDispatch( - task: TaskRow, - input: JsonObject = {}, -) { - const now = String(input.now || '').trim() || new Date().toISOString() - const source = String(input.source || 'system_before_dispatch').trim() || 'system_before_dispatch' - const errorCodePrefix = - String(input.errorCodePrefix || 'kuaishou_cloud').trim() || 'kuaishou_cloud' - const cloudContext = asJsonObject(input.cloudContext) - const taskContext = asJsonObject(input.taskContext) - const flow = normalizeKuaishouCloudFlow(input.flow || taskContext.kuaishouCloudFulfillment) - - // 新策略:lewan 自动发货必须有 expectedUid,旧单无 UID 不可静默回落 - const expectedUid = assertLewanAutoFulfillmentUidReady(task, { - allowMockSkip: true, - errorCodePrefix, - }) - - if (!flow.binding.vnId || !flow.binding.vnKey) { - throw createHttpError('当前任务缺少可同步角色的虚拟号信息,暂时不能发货', { - statusCode: 409, - errorCode: `${errorCodePrefix}_dispatch_missing_bind_info_context`, - }) - } - - const bindInfoResult = await getCloudtentaclesBindInfo({ - ...cloudContext, - key: flow.binding.vnKey, - id: flow.binding.vnId, - }) - const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo) - - if (!roleInfo.name || !roleInfo.rid) { - throw createHttpError('cloudtentacles 还没有同步到客户绑定角色,请稍后刷新角色信息后再发货', { - statusCode: 409, - errorCode: `${errorCodePrefix}_dispatch_role_not_ready`, - }) - } - - const liveBoundUid = normalizeClaimUid(roleInfo.rid) - - if (expectedUid && !isClaimUidMatched(expectedUid, liveBoundUid)) { - await createTaskEvent( - task.id, - 'kuaishou_cloud_dispatch_uid_mismatch', - { - source, - vnId: flow.binding.vnId, - expectedUid, - cloudtentaclesRoleName: roleInfo.name, - cloudtentaclesRoleId: roleInfo.rid, - actor: input.actor || null, - }, - now, - ) - - throw createHttpError( - `cloudtentacles 当前绑定角色 ID(${roleInfo.rid})与用户填写 UID(${expectedUid})不一致,不能发货`, - { - statusCode: 409, - errorCode: `${errorCodePrefix}_dispatch_uid_mismatch`, - }, - ) - } - - const nextFlow = normalizeKuaishouCloudFlow({ - ...flow, - binding: { - ...flow.binding, - roleName: roleInfo.name, - roleId: roleInfo.rid, - }, - role: { - ...flow.role, - status: 'ready', - name: roleInfo.name, - rid: roleInfo.rid, - refreshedAt: now, - errorMessage: '', - rawInfo: roleInfo.rawInfo, - }, - }) - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: nextFlow, - } - - const updatedTask = await updateTask(task.id, { - role_id: roleInfo.rid, - role_name: roleInfo.name, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - if (!updatedTask) { - throw createHttpError('发货前角色信息同步失败', { - statusCode: 500, - errorCode: `${errorCodePrefix}_dispatch_role_update_failed`, - }) - } - - await createTaskEvent( - task.id, - 'kuaishou_cloud_role_info_synced_before_dispatch', - { - source, - roleName: roleInfo.name, - roleId: roleInfo.rid, - vnId: flow.binding.vnId, - actor: input.actor || null, - }, - now, - ) - - return { - task: updatedTask, - taskContext: nextContext, - flow: nextFlow, - roleInfo, - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-stock.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-stock.ts deleted file mode 100644 index c30d6a2f..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-stock.ts +++ /dev/null @@ -1,366 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { notifyKuaishouCloudAssetNotEnough } from '../../notification/domain-notifications.js' -import { - buyCloudtentaclesSku, - getCloudtentaclesAsset, - listCloudtentaclesSku, - useCloudtentaclesSku, -} from '../../platforms/cloudtentacles/catalog-service.js' -import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { maskPhone, type JsonObject } from './domain.js' -import { - enrichCloudtentaclesDispatchError, - resolveErrorCode, - resolveErrorMessage, -} from './dispatch-failure.js' -import type { - DispatchDeliveryItem, - DispatchResultItem, - DispatchStockItem, - DispatchStockResult, -} from './dispatch-types.js' - -const cloudSkuDispatchLocks = new Map>() - -/** - * 计算库存缺口 → 必要时采购 → 按数量逐次 use SKU 下发。 - * 调用方应在 withCloudSkuDispatchLocks 内执行,避免同账号同 SKU 并发抢库存。 - */ -export async function prepareStockAndDispatch({ - task, - flow, - cloudContext, - deliveryItems, -}: { - task: TaskRow - flow: JsonObject - cloudContext: JsonObject - deliveryItems: DispatchDeliveryItem[] -}): Promise<{ dispatchResults: DispatchResultItem[]; stockResult: DispatchStockResult }> { - const [knapsack, skuList] = await Promise.all([ - getCloudtentaclesKnapsack(cloudContext), - listCloudtentaclesSku(cloudContext), - ]) - const skuItems = Array.isArray(skuList.items) ? (skuList.items as JsonObject[]) : [] - const knapsackItems = Array.isArray(knapsack.items) ? (knapsack.items as JsonObject[]) : [] - const stockItems = buildDispatchStockItems(deliveryItems, { - skuItems, - knapsackItems, - }) - const missingItems = stockItems.filter((item) => item.purchasedCount > 0) - - let assetBefore = 0 - let assetAfter = 0 - let purchaseTriggered = false - - const purchase = isPlainObject(flow.purchase) ? flow.purchase : {} - const binding = isPlainObject(flow.binding) ? flow.binding : {} - - if (missingItems.length > 0) { - if (purchase.autoBuyEnabled === false) { - throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', { - statusCode: 409, - errorCode: 'kuaishou_cloud_auto_buy_disabled', - }) - } - - const missingSku = missingItems.find((item) => !findCloudSkuItem(skuItems, item.cloudSkuId)) - if (missingSku) { - throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, { - statusCode: 404, - errorCode: 'kuaishou_cloud_sku_not_found', - }) - } - - const asset = await getCloudtentaclesAsset(cloudContext) - assetBefore = Number(asset.asset || 0) || 0 - const targetPrice = missingItems.reduce((sum, item) => { - const skuItem = findCloudSkuItem(skuItems, item.cloudSkuId) - return sum + item.purchasedCount * Number(skuItem?.price || 0) - }, 0) - const requiredAsset = targetPrice + Number(purchase.minAssetReserve || 0) - - if (assetBefore < requiredAsset) { - await notifyKuaishouCloudAssetNotEnough({ - task, - flow, - assetBefore, - requiredAsset, - skuName: missingItems - .map((item) => item.cloudSkuName || `SKU ${item.cloudSkuId}`) - .join('、'), - }) - throw createHttpError(`余额不足,当前 ${assetBefore},至少需要 ${requiredAsset}`, { - statusCode: 409, - errorCode: 'kuaishou_cloud_asset_not_enough', - }) - } - - for (const item of missingItems) { - await createTaskEvent( - task.id, - 'cloudtentacles_sku_buy_started', - { - source: 'kuaishou_cloud_dispatch', - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - count: item.purchasedCount, - assetBefore, - }, - nowIso(), - ) - - try { - const buyResult = await buyCloudtentaclesSku({ - ...cloudContext, - id: item.cloudSkuId, - count: item.purchasedCount, - }) - await createTaskEvent( - task.id, - 'cloudtentacles_sku_buy_succeeded', - { - source: 'kuaishou_cloud_dispatch', - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - count: item.purchasedCount, - responseMessage: buyResult.responseMessage, - }, - nowIso(), - ) - } catch (error) { - await createTaskEvent( - task.id, - 'cloudtentacles_sku_buy_failed', - { - source: 'kuaishou_cloud_dispatch', - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - count: item.purchasedCount, - errorCode: resolveErrorCode(error), - errorMessage: resolveErrorMessage(error), - }, - nowIso(), - ) - throw enrichCloudtentaclesDispatchError(error, { - stage: 'purchase', - stockResult: buildCurrentStockResult({ - stockItems, - purchaseTriggered, - assetBefore, - assetAfter, - }), - item, - }) - } - } - - purchaseTriggered = true - const assetResult = await getCloudtentaclesAsset(cloudContext) - assetAfter = Number(assetResult.asset || 0) || 0 - } - - const dispatchResults: DispatchResultItem[] = [] - const vnId = Number(binding.vnId || 0) || 0 - const vnPhone = String(binding.vnPhone || '').trim() - - for (const item of deliveryItems) { - for (let index = 0; index < item.quantity; index += 1) { - await createTaskEvent( - task.id, - 'cloudtentacles_sku_use_started', - { - source: 'kuaishou_cloud_dispatch', - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - unitIndex: index + 1, - quantity: item.quantity, - vnId, - vnPhoneMasked: maskPhone(vnPhone), - }, - nowIso(), - ) - - let dispatchResult: Awaited> - try { - dispatchResult = await useCloudtentaclesSku({ - ...cloudContext, - id: item.cloudSkuId, - virtualNumberId: vnId, - phone: vnPhone, - }) - } catch (error) { - await createTaskEvent( - task.id, - 'cloudtentacles_sku_use_failed', - { - source: 'kuaishou_cloud_dispatch', - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - unitIndex: index + 1, - quantity: item.quantity, - vnId, - vnPhoneMasked: maskPhone(vnPhone), - errorCode: resolveErrorCode(error), - errorMessage: resolveErrorMessage(error), - }, - nowIso(), - ) - throw enrichCloudtentaclesDispatchError(error, { - stage: 'dispatch', - stockResult: buildCurrentStockResult({ - stockItems, - purchaseTriggered, - assetBefore, - assetAfter, - }), - item, - unitIndex: index + 1, - vnId, - vnPhoneMasked: maskPhone(vnPhone), - }) - } - - await createTaskEvent( - task.id, - 'cloudtentacles_sku_use_succeeded', - { - source: 'kuaishou_cloud_dispatch', - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - unitIndex: index + 1, - quantity: item.quantity, - vnId, - vnPhoneMasked: maskPhone(vnPhone), - sendType: Number(dispatchResult.sendType || 0) || 0, - note: String(dispatchResult.note || '').trim(), - responseMessage: String(dispatchResult.responseMessage || '').trim(), - }, - nowIso(), - ) - dispatchResults.push({ - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - unitIndex: index + 1, - quantity: item.quantity, - sendType: Number(dispatchResult.sendType || 0) || 0, - note: String(dispatchResult.note || '').trim(), - responseMessage: String(dispatchResult.responseMessage || '').trim(), - }) - } - } - - return { - dispatchResults, - stockResult: { - usedKnapsack: stockItems.every((item) => item.purchasedCount <= 0), - purchaseTriggered, - assetBefore, - assetAfter, - items: stockItems, - }, - } -} - -export function buildDispatchStockItems( - deliveryItems: DispatchDeliveryItem[], - { - skuItems = [], - knapsackItems = [], - }: { skuItems?: JsonObject[]; knapsackItems?: JsonObject[] } = {}, -): DispatchStockItem[] { - return deliveryItems.map((item) => { - const knapsackItem = findCloudSkuItem(knapsackItems, item.cloudSkuId) - const skuItem = findCloudSkuItem(skuItems, item.cloudSkuId) - const knapsackCount = Math.max(0, Number(knapsackItem?.count || 0) || 0) - - return { - ...item, - cloudSkuName: item.cloudSkuName || String(skuItem?.name || knapsackItem?.name || '').trim(), - requiredCount: item.quantity, - knapsackCount, - purchasedCount: Math.max(0, item.quantity - knapsackCount), - } - }) -} - -export function resolveCloudSkuDispatchLockKeys( - sourceKey: unknown, - deliveryItems: DispatchDeliveryItem[], -) { - const normalizedSourceKey = String(sourceKey || 'default').trim() || 'default' - return deliveryItems.map((item) => `${normalizedSourceKey}:${item.cloudSkuId}`) -} - -export async function withCloudSkuDispatchLocks( - keys: string[], - callback: () => Promise, -): Promise { - const normalizedKeys = [ - ...new Set(keys.map((key) => String(key || '').trim()).filter(Boolean)), - ].sort() - - async function run(index: number): Promise { - const key = normalizedKeys[index] - if (!key) { - return callback() - } - - return withCloudSkuDispatchLock(key, () => run(index + 1)) - } - - return run(0) -} - -function buildCurrentStockResult({ - stockItems, - purchaseTriggered, - assetBefore, - assetAfter, -}: { - stockItems: DispatchStockItem[] - purchaseTriggered: boolean - assetBefore: number - assetAfter: number -}): DispatchStockResult { - return { - usedKnapsack: stockItems.every((item) => item.purchasedCount <= 0), - purchaseTriggered, - assetBefore, - assetAfter, - items: stockItems, - } -} - -function findCloudSkuItem(items: JsonObject[], cloudSkuId: number) { - return items.find((item) => Number(item.id || 0) === cloudSkuId) || null -} - -async function withCloudSkuDispatchLock(key: string, callback: () => Promise): Promise { - const previous = cloudSkuDispatchLocks.get(key) || Promise.resolve() - let release: () => void = () => {} - const current = new Promise((resolve) => { - release = resolve - }) - const queued = previous.catch(() => {}).then(() => current) - - cloudSkuDispatchLocks.set(key, queued) - - await previous.catch(() => {}) - - try { - return await callback() - } finally { - release() - if (cloudSkuDispatchLocks.get(key) === queued) { - cloudSkuDispatchLocks.delete(key) - } - } -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-types.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-types.ts deleted file mode 100644 index 1fba208c..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/dispatch-types.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { JsonObject } from './domain.js' - -/** lewan 发货单元:一次下发的 cloud SKU */ -export type DispatchDeliveryItem = { - cloudSkuId: number - cloudSkuName: string - quantity: number -} - -export type DispatchResultItem = DispatchDeliveryItem & { - unitIndex: number - sendType: number - note: string - responseMessage: string -} - -export type DispatchStockItem = DispatchDeliveryItem & { - requiredCount: number - knapsackCount: number - purchasedCount: number -} - -export type DispatchStockResult = { - usedKnapsack: boolean - purchaseTriggered: boolean - assetBefore: number - assetAfter: number - items: DispatchStockItem[] -} - -export function resolveDispatchDeliveryItems(flow: JsonObject): DispatchDeliveryItem[] { - const rawItems = Array.isArray(flow.deliveryItems) ? flow.deliveryItems : [] - const items = rawItems - .map((item: unknown) => { - const source = item && typeof item === 'object' ? (item as JsonObject) : {} - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 - const quantity = Number(source.quantity || 1) || 1 - if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { - return null - } - - return { - cloudSkuId, - cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), - quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, - } - }) - .filter((item): item is DispatchDeliveryItem => Boolean(item)) - - if (items.length > 0) { - return items - } - - const binding = - flow.binding && typeof flow.binding === 'object' ? (flow.binding as JsonObject) : {} - const fallbackSkuId = Number(binding.skuId || 0) || 0 - if (!fallbackSkuId) { - return [] - } - - return [ - { - cloudSkuId: fallbackSkuId, - cloudSkuName: String(binding.skuName || '').trim(), - quantity: 1, - }, - ] -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts deleted file mode 100644 index 8524787c..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - hasKuaishouCloudCustomerRole, - hasKuaishouCloudDefaultRoleSnapshot, - isSameKuaishouCloudRoleIdentity, -} from './domain.js' -import { buildRoleStateFromCurrentInfo } from './role-state.js' -import { normalizeKuaishouCloudFlow } from './domain.js' - -test('hasKuaishouCloudCustomerRole 只要求已有绑定角色信息(UID 主闸后默认角色不再作门槛)', () => { - const defaultRoleFlow = { - binding: { - vnPhone: '13800000000', - roleName: '默认机位角色', - roleId: 'DEFAULT-000', - }, - role: { - defaultName: '默认机位角色', - defaultRid: 'DEFAULT-000', - }, - } - - assert.equal(hasKuaishouCloudCustomerRole(defaultRoleFlow), true) - assert.equal(hasKuaishouCloudDefaultRoleSnapshot(defaultRoleFlow), true) - - const emptyFlow = { - binding: { - vnPhone: '13800000000', - roleName: '', - roleId: '', - }, - } - assert.equal(hasKuaishouCloudCustomerRole(emptyFlow), false) -}) - -test('buildRoleStateFromCurrentInfo 即使接近默认角色也 ready 并保留 rid', () => { - const flow = normalizeKuaishouCloudFlow({ - role: { - defaultName: '默认机位角色', - defaultRid: 'DEFAULT-000', - }, - binding: {}, - }) - const state = buildRoleStateFromCurrentInfo({ - flow, - roleInfo: { name: '默认机位角色', rid: 'DEFAULT-000' }, - now: '2026-07-10T00:00:00.000Z', - emptyMessage: 'empty', - }) - - assert.equal(state.hasRoleInfo, true) - assert.equal(state.isDefaultRole, true) - assert.equal(state.role.status, 'ready') - assert.equal(state.bindingRoleId, 'DEFAULT-000') - assert.match(state.role.errorMessage, /UID/) -}) - -test('isSameKuaishouCloudRoleIdentity prefers rid', () => { - assert.equal( - isSameKuaishouCloudRoleIdentity({ name: 'a', rid: '1' }, { name: 'b', rid: '1' }), - true, - ) - assert.equal( - isSameKuaishouCloudRoleIdentity({ name: 'a', rid: '1' }, { name: 'a', rid: '2' }), - false, - ) -}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts deleted file mode 100644 index 14ddf2c1..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/domain.ts +++ /dev/null @@ -1,505 +0,0 @@ -import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js' -import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../../utils/masking.js' -import { isTaskFinalStatus, normalizeTaskStatus, TASK_STATUS } from '../../../domain/task-status.js' -import { asJsonObject, type JsonObject } from '../../../types/json.js' - -export type { JsonObject } - -export const KUAISHOU_CLOUD_FIXED_VN_KEY = '1' - -const KUAISHOU_CLOUD_EXECUTOR_KEYS = new Set(['kuaishou_ct_assisted', 'kuaishou-industry']) - -export function isKuaishouCloudTask(task: unknown) { - const source = task && typeof task === 'object' ? (task as JsonObject) : {} - return KUAISHOU_CLOUD_EXECUTOR_KEYS.has(String(source.executor_key || '').trim()) -} - -export function isIndustryEVoucherTask(task: unknown) { - const source = task && typeof task === 'object' ? (task as JsonObject) : {} - return String(source.executor_key || '').trim() === 'kuaishou-industry' -} - -export type KuaishouCloudDeliveryItem = { - cloudSkuId: number - cloudSkuName: string - quantity: number -} - -/** normalizeKuaishouCloudFlow 出口类型(履约主路径应经此 normalize) */ -export type KuaishouCloudFlow = { - configId: string - internalSkuCode: string - internalSkuName: string - deliveryItems: KuaishouCloudDeliveryItem[] - ticket: { - code: string - status: string - capturedAt: unknown - capturedBy: unknown - verifiedAt: unknown - oid: string - formToken: string - leftCount: number - goodsTitle: string - } - binding: { - prepareStatus: string - cloudSourceKeys: string[] - resolvedSourceKey: string - skuId: number - skuName: string - vnKey: string - vnId: number - vnPhone: string - bindUrl: string - bindPreparedAt: unknown - bindExpiresAt: unknown - bindProbeAt: unknown - bindProbeStatus: string - bindProbeMessage: string - roleName: string - roleId: string - } - role: { - status: string - name: string - rid: string - refreshedAt: unknown - errorMessage: string - rawInfo: unknown - defaultName: string - defaultRid: string - defaultCapturedAt: unknown - defaultCaptureStatus: string - defaultErrorMessage: string - isDefaultRole: boolean - } - purchase: { - autoBuyEnabled: boolean - minAssetReserve: number - usedKnapsack: boolean - purchaseTriggered: boolean - assetBefore: number - assetAfter: number - purchaseAt: unknown - items: unknown[] - } - dispatch: { - status: string - dispatchAt: unknown - dispatchBy: unknown - sendType: number - note: string - items: unknown[] - } - /** - * 发货成功瞬间冻结的身份快照。 - * claim 轮询 / 过期换号不得覆盖;后台 UID 匹配以本快照为准。 - */ - shipped: { - expectedUid: string - roleId: string - roleName: string - vnId: number - vnPhone: string - vnKey: string - dispatchedAt: string | null - } | null - returnNumber: { - status: string - returnedAt: unknown - returnedBy: unknown - autoReturnEnabled: boolean - } - consume: { - status: string - shopId: string - shopName: string - autoConsumeEnabled: boolean - consumedAt: unknown - errorMessage: string - } - rebind: { - currentAttempt: number - history: unknown[] - } - notes: string - /** 未建模扩展(mock 等)仍可落库 */ - [key: string]: unknown -} - -export function normalizeKuaishouCloudFlow(value: unknown): KuaishouCloudFlow { - const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {} - const binding = asJsonObject(source.binding) - const role = asJsonObject(source.role) - const purchase = asJsonObject(source.purchase) - const dispatch = asJsonObject(source.dispatch) - const returnNumber = asJsonObject(source.returnNumber) - const consume = asJsonObject(source.consume) - const ticket = asJsonObject(source.ticket) - const rebind = asJsonObject(source.rebind) - const shippedSource = asJsonObject(source.shipped) - const deliveryItems = normalizeDeliveryItems(source, binding) - - const roleName = String(role.name || binding.roleName || '').trim() - const roleId = String(role.rid || binding.roleId || '').trim() - const defaultRoleName = String(role.defaultName || role.defaultRoleName || '').trim() - const defaultRoleId = String(role.defaultRid || role.defaultRoleId || '').trim() - const hasDefaultRole = Boolean(defaultRoleName || defaultRoleId) - const isDefaultRole = - Boolean(roleName || roleId) && - hasDefaultRole && - isSameKuaishouCloudRoleIdentity( - { name: roleName, rid: roleId }, - { name: defaultRoleName, rid: defaultRoleId }, - ) - const bindPreparedAt = binding.bindPreparedAt || null - const bindExpiresAt = - binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt) - - return { - ...source, - configId: String(source.configId || '').trim(), - internalSkuCode: String(source.internalSkuCode || '').trim(), - internalSkuName: String(source.internalSkuName || '').trim(), - deliveryItems, - ticket: { - code: String(ticket.code || '').trim(), - status: String(ticket.status || 'pending').trim() || 'pending', - capturedAt: ticket.capturedAt || null, - capturedBy: ticket.capturedBy || null, - verifiedAt: ticket.verifiedAt || null, - oid: String(ticket.oid || '').trim(), - formToken: String(ticket.formToken || '').trim(), - leftCount: Number(ticket.leftCount || 0) || 0, - goodsTitle: String(ticket.goodsTitle || '').trim(), - }, - binding: { - prepareStatus: String(binding.prepareStatus || 'pending').trim() || 'pending', - cloudSourceKeys: normalizeStringArray(binding.cloudSourceKeys), - resolvedSourceKey: String(binding.resolvedSourceKey || '').trim(), - skuId: Number(binding.skuId || 0) || 0, - skuName: String(binding.skuName || '').trim(), - vnKey: - String(binding.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY, - vnId: Number(binding.vnId || 0) || 0, - vnPhone: String(binding.vnPhone || '').trim(), - bindUrl: String(binding.bindUrl || '').trim(), - bindPreparedAt, - bindExpiresAt, - bindProbeAt: binding.bindProbeAt || null, - bindProbeStatus: String(binding.bindProbeStatus || '').trim(), - bindProbeMessage: String(binding.bindProbeMessage || '').trim(), - roleName, - roleId, - }, - role: { - status: String(role.status || (roleName || roleId ? 'ready' : 'pending')).trim() || 'pending', - name: roleName, - rid: roleId, - refreshedAt: role.refreshedAt || null, - errorMessage: String(role.errorMessage || '').trim(), - rawInfo: role.rawInfo && typeof role.rawInfo === 'object' ? role.rawInfo : null, - defaultName: defaultRoleName, - defaultRid: defaultRoleId, - defaultCapturedAt: role.defaultCapturedAt || null, - defaultCaptureStatus: String(role.defaultCaptureStatus || '').trim(), - defaultErrorMessage: String(role.defaultErrorMessage || '').trim(), - isDefaultRole, - }, - purchase: { - autoBuyEnabled: purchase.autoBuyEnabled !== false, - minAssetReserve: Number(purchase.minAssetReserve || 0) || 0, - usedKnapsack: purchase.usedKnapsack === true, - purchaseTriggered: purchase.purchaseTriggered === true, - assetBefore: Number(purchase.assetBefore || 0) || 0, - assetAfter: Number(purchase.assetAfter || 0) || 0, - purchaseAt: purchase.purchaseAt || null, - items: Array.isArray(purchase.items) ? purchase.items : [], - }, - dispatch: { - status: String(dispatch.status || 'pending').trim() || 'pending', - dispatchAt: dispatch.dispatchAt || null, - dispatchBy: dispatch.dispatchBy || null, - sendType: Number(dispatch.sendType || 0) || 0, - note: String(dispatch.note || '').trim(), - items: Array.isArray(dispatch.items) ? dispatch.items : [], - }, - shipped: normalizeKuaishouCloudShippedSnapshot(shippedSource), - returnNumber: { - status: String(returnNumber.status || 'pending').trim() || 'pending', - returnedAt: returnNumber.returnedAt || null, - returnedBy: returnNumber.returnedBy || null, - autoReturnEnabled: returnNumber.autoReturnEnabled === true, - }, - consume: { - status: String(consume.status || 'pending').trim() || 'pending', - shopId: String(consume.shopId || '').trim(), - shopName: String(consume.shopName || '').trim(), - autoConsumeEnabled: consume.autoConsumeEnabled === true, - consumedAt: consume.consumedAt || null, - errorMessage: String(consume.errorMessage || '').trim(), - }, - rebind: { - currentAttempt: Math.max(0, Number(rebind.currentAttempt || 0) || 0), - history: Array.isArray(rebind.history) ? rebind.history : [], - }, - notes: String(source.notes || '').trim(), - } -} - -export function hasKuaishouCloudDefaultRoleSnapshot(flowLike: unknown) { - const flow = normalizeKuaishouCloudFlow(flowLike) - return Boolean(flow.role.defaultName || flow.role.defaultRid) -} - -export type KuaishouCloudShippedSnapshot = NonNullable - -export function normalizeKuaishouCloudShippedSnapshot( - value: unknown, -): KuaishouCloudShippedSnapshot | null { - const source = asJsonObject(value) - const roleId = String(source.roleId || source.rid || '').trim() - const expectedUid = String(source.expectedUid || '').trim() - const vnId = Number(source.vnId || 0) || 0 - const dispatchedAt = source.dispatchedAt ? String(source.dispatchedAt) : null - - // 至少要有发货角色或 UID 之一,否则视为无快照 - if (!roleId && !expectedUid && !vnId && !dispatchedAt) { - return null - } - - return { - expectedUid, - roleId, - roleName: String(source.roleName || source.name || '').trim(), - vnId, - vnPhone: String(source.vnPhone || '').trim(), - vnKey: - String(source.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY, - dispatchedAt, - } -} - -export function isKuaishouCloudDispatchSucceeded(flowLike: unknown): boolean { - const flow = normalizeKuaishouCloudFlow(flowLike) - return String(flow.dispatch.status || '').trim() === 'success' -} - -/** - * 发货后禁止再改绑定资源/角色(claim 轮询换号、刷角色、过期重取等)。 - * 以 dispatch.success 为准(兼容状态机被污染回 waiting_binding 的脏单)。 - */ -export function isKuaishouCloudBindingMutationFrozen( - task: { task_status?: unknown } | null | undefined, - flowLike?: unknown, -): boolean { - const status = normalizeTaskStatus(task?.task_status) - if ( - status === TASK_STATUS.REDEEMING || - status === TASK_STATUS.DISPATCHED_PENDING_RETURN || - status === TASK_STATUS.COMPLETED || - status === TASK_STATUS.REDEEMED || - isTaskFinalStatus(status) - ) { - return true - } - - if (flowLike !== undefined) { - return isKuaishouCloudDispatchSucceeded(flowLike) - } - - return false -} - -export function buildKuaishouCloudShippedSnapshot(input: { - expectedUid?: unknown - roleId?: unknown - roleName?: unknown - vnId?: unknown - vnPhone?: unknown - vnKey?: unknown - dispatchedAt?: unknown -}): KuaishouCloudShippedSnapshot { - return { - expectedUid: String(input.expectedUid || '').trim(), - roleId: String(input.roleId || '').trim(), - roleName: String(input.roleName || '').trim(), - vnId: Number(input.vnId || 0) || 0, - vnPhone: String(input.vnPhone || '').trim(), - vnKey: String(input.vnKey || KUAISHOU_CLOUD_FIXED_VN_KEY).trim() || KUAISHOU_CLOUD_FIXED_VN_KEY, - dispatchedAt: input.dispatchedAt ? String(input.dispatchedAt) : null, - } -} - -/** - * 是否已有可展示的绑定角色信息(诊断用)。 - * - * 注意:履约主闸已改为 claimIdentity.expectedUid 匹配; - * 本函数不再要求「当前角色必须不同于虚拟机默认角色」。 - */ -export function hasKuaishouCloudCustomerRole(flowLike: unknown) { - const flow = normalizeKuaishouCloudFlow(flowLike) - if (!flow.binding.vnPhone) { - return false - } - - return Boolean(flow.binding.roleName || flow.binding.roleId) -} - -export function isSameKuaishouCloudRoleIdentity( - left: { name?: unknown; rid?: unknown }, - right: { name?: unknown; rid?: unknown }, -) { - const leftRid = String(left.rid || '').trim() - const rightRid = String(right.rid || '').trim() - if (leftRid && rightRid) { - return leftRid === rightRid - } - - const leftName = normalizeRoleNameForCompare(left.name) - const rightName = normalizeRoleNameForCompare(right.name) - return Boolean(leftName && rightName && leftName === rightName) -} - -function normalizeRoleNameForCompare(value: unknown) { - return String(value || '') - .trim() - .toLowerCase() - .replace(/\s+/g, '') -} - -export function normalizeKuaishouCloudDeliveryItems(value: unknown) { - const source: JsonObject = value && typeof value === 'object' ? (value as JsonObject) : {} - const binding = asJsonObject(source.binding) - return normalizeDeliveryItems(source, binding) -} - -export function normalizeKuaishouCloudRoleInfo(value: unknown) { - const rawInfo: JsonObject | null = - value && typeof value === 'object' ? (value as JsonObject) : null - const nestedBindInfo = - rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null - const source = nestedBindInfo || rawInfo - - return { - name: String( - source?.name || source?.roleName || source?.nickname || source?.sRoleName || '', - ).trim(), - rid: String( - source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '', - ).trim(), - rawInfo, - } -} - -export function maskPhone(value: unknown) { - return maskPhoneValue(value) -} - -export function maskCode(value: unknown) { - return maskCodeValue(value) -} - -export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt: unknown) { - const preparedTime = Date.parse(String(preparedAt || '')) - if (!Number.isFinite(preparedTime)) { - return null - } - - const config = resolveCloudtentaclesConfig() - const ttlSeconds = Number(config.bindUrlTtlSeconds || 600) - return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString() -} - -export function isKuaishouCloudBindUrlFresh(flow: unknown, now = new Date()) { - const normalizedFlow = normalizeKuaishouCloudFlow(flow) - if (!normalizedFlow.binding.bindUrl) { - return false - } - - const expiresAt = normalizedFlow.binding.bindExpiresAt - if (!expiresAt) { - return false - } - - const expiresTime = Date.parse(String(expiresAt || '')) - if (!Number.isFinite(expiresTime)) { - return false - } - - return expiresTime > now.getTime() -} - -export function normalizeStringArray(value: unknown) { - if (Array.isArray(value)) { - return value.map((v) => String(v || '').trim()).filter(Boolean) - } - if (typeof value === 'string') { - return value - .split(',') - .map((v) => v.trim()) - .filter(Boolean) - } - return [] -} - -function normalizeDeliveryItems(source: JsonObject, binding: JsonObject) { - const rawItems = Array.isArray(source.deliveryItems) ? source.deliveryItems : [] - const items = rawItems.map((item) => normalizeDeliveryItem(item)).filter(Boolean) as Array<{ - cloudSkuId: number - cloudSkuName: string - quantity: number - }> - - if (items.length > 0) { - return mergeDeliveryItems(items) - } - - const skuId = Number(binding.skuId || 0) || 0 - if (!skuId) { - return [] - } - - return [ - { - cloudSkuId: skuId, - cloudSkuName: String(binding.skuName || '').trim(), - quantity: 1, - }, - ] -} - -function normalizeDeliveryItem(value: unknown) { - const source = value && typeof value === 'object' ? (value as JsonObject) : {} - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 - if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { - return null - } - - const quantity = Number(source.quantity || 1) || 1 - return { - cloudSkuId, - cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), - quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, - } -} - -function mergeDeliveryItems( - items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, -) { - const merged = new Map() - - for (const item of items) { - const existing = merged.get(item.cloudSkuId) - if (existing) { - existing.quantity += item.quantity - existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName - continue - } - - merged.set(item.cloudSkuId, { ...item }) - } - - return Array.from(merged.values()) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/ensure-claim-link.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/ensure-claim-link.ts deleted file mode 100644 index 9c25bfd8..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/ensure-claim-link.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { buildClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { getTaskClaimExpiresAt, isClaimExpired } from './task-context.js' - -export async function ensureTaskClaimLink(task: TaskRow) { - const tokenStatus = String(task?.primary_claim_token_status || '').trim() - const token = String(task?.primary_claim_token || task?.claim_token || '').trim() - const expiredAt = getTaskClaimExpiresAt(task) - - if (tokenStatus === 'active' && token && !isClaimExpired(expiredAt)) { - return { - token, - expiredAt, - claimUrl: buildClaimUrl(token), - } - } - - const claimToken = await createTaskClaimToken(task.id) - return { - token: claimToken.token, - expiredAt: claimToken.expired_at, - claimUrl: claimToken.claimUrl, - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts deleted file mode 100644 index 4062e0bc..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * kuaishou-lewan(业务名)履约入口。 - * 模块路径仍为 kuaishou-cloud;executor_key = kuaishou_ct_assisted; - * 外部平台 API 在 platforms/cloudtentacles。 - * - * 实现按职责拆分:prepare / redeem / dispatch / return / role 等。 - */ -export { - isKuaishouCloudBindUrlFresh, - isKuaishouCloudTask, - hasKuaishouCloudCustomerRole, - hasKuaishouCloudDefaultRoleSnapshot, - isKuaishouCloudDispatchSucceeded, - isKuaishouCloudBindingMutationFrozen, - buildKuaishouCloudShippedSnapshot, - normalizeKuaishouCloudShippedSnapshot, - maskCode, - maskPhone, - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - isSameKuaishouCloudRoleIdentity, - resolveKuaishouCloudBindUrlExpiresAt, -} from './domain.js' -export type { - KuaishouCloudDeliveryItem, - KuaishouCloudFlow, - KuaishouCloudShippedSnapshot, -} from './domain.js' -export { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' -export { ensureTaskClaimLink } from './ensure-claim-link.js' -export { - prepareKuaishouCloudFulfillmentTask, - refreshKuaishouCloudTaskBindUrl, -} from './prepare-fulfillment.js' -export { rebindKuaishouCloudTaskRole } from './rebind-role.js' -export { probeKuaishouCloudTaskBindUrl } from './probe-bind-url.js' -export { refreshKuaishouCloudTaskRoleInfo } from './refresh-role-info.js' -export { confirmKuaishouCloudTaskRole } from './confirm-role.js' -export type { ConfirmKuaishouCloudRoleResult } from './confirm-role.js' -export { dispatchKuaishouCloudFulfillmentTask } from './dispatch-fulfillment.js' -export { returnKuaishouCloudFulfillmentTask } from './return-fulfillment.js' -export { redeemKuaishouCloudTask } from './redeem-fulfillment.js' -export type { RedeemKuaishouCloudTaskResult } from './redeem-fulfillment.js' -export { - isKuaishouCloudMockTask, - isKuaishouCloudMockContext, - buildMockVerifiedKuaishouCloudFlow, - completeMockKuaishouCloudTask, -} from './mock-helpers.js' diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/mock-helpers.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/mock-helpers.ts deleted file mode 100644 index 5c02d681..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/mock-helpers.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { TASK_STATUS } from '../../../domain/task-status.js' -import { asJsonObject, type JsonObject } from '../../../types/json.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { addHours } from '../../../utils/time.js' -import { normalizeKuaishouCloudFlow } from './domain.js' -import { parseTaskContext } from './task-context.js' - -export function isKuaishouCloudMockTask(task: Partial | null | undefined) { - return isKuaishouCloudMockContext(parseTaskContext(task)) -} - -export function isKuaishouCloudMockContext(context: JsonObject = {}) { - const flow = asJsonObject(context.kuaishouCloudFulfillment) - const mock = flow.mock && typeof flow.mock === 'object' ? flow.mock : context.mock - - return Boolean(mock && typeof mock === 'object' && (mock as JsonObject).enabled === true) -} - -export function buildMockVerifiedKuaishouCloudFlow(value: unknown, timestamp: string) { - const flow = normalizeKuaishouCloudFlow(value) - const source = flow as JsonObject - const bindUrl = - flow.binding.bindUrl || - `https://example.com/mock-kuaishou-cloud-bind?task=mock&ts=${encodeURIComponent(timestamp)}` - const vnPhone = flow.binding.vnPhone || '13800000000' - const roleName = flow.binding.roleName || flow.role.name || '测试角色' - const roleId = flow.binding.roleId || flow.role.rid || '10001' - - return { - ...flow, - mock: { - ...asJsonObject(source.mock), - enabled: true, - }, - binding: { - ...flow.binding, - prepareStatus: 'ready', - vnId: flow.binding.vnId || 900001, - vnPhone, - bindUrl, - bindPreparedAt: flow.binding.bindPreparedAt || timestamp, - bindExpiresAt: flow.binding.bindExpiresAt || addHours(timestamp, 24), - roleName, - roleId, - }, - role: { - ...flow.role, - status: 'ready', - name: roleName, - rid: roleId, - refreshedAt: flow.role.refreshedAt || timestamp, - errorMessage: '', - rawInfo: flow.role.rawInfo || { - mock: true, - }, - }, - } -} - -/** 开发 mock:跳过真实 CT,直接落成 completed */ -export async function completeMockKuaishouCloudTask(task: TaskRow, timestamp: string) { - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const source = flow as JsonObject - const nextFlow = { - ...flow, - mock: { - ...asJsonObject(source.mock), - enabled: true, - }, - dispatch: { - ...flow.dispatch, - status: 'success', - dispatchAt: timestamp, - dispatchBy: { - source: 'claim_page_mock', - }, - note: '开发 mock 已模拟发货成功', - items: flow.deliveryItems, - }, - returnNumber: { - ...flow.returnNumber, - status: 'success', - returnedAt: timestamp, - returnedBy: { - source: 'claim_page_mock', - }, - }, - consume: { - ...flow.consume, - status: 'success', - consumedAt: timestamp, - errorMessage: '', - }, - } - - await updateTask(task.id, { - task_status: TASK_STATUS.COMPLETED, - delivery_status: 'success', - result_code: 'mock_success', - result_message: '开发 mock 已模拟兑换成功', - user_action_status: 'not_required', - last_error: '', - context_json: JSON.stringify({ - ...taskContext, - kuaishouCloudFulfillment: nextFlow, - }), - redeemed_at: timestamp, - updated_at: timestamp, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_mock_redeemed', - { - source: 'claim_page_mock', - deliveryItems: flow.deliveryItems, - }, - timestamp, - ) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts deleted file mode 100644 index 7a0b944e..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/prepare-fulfillment.ts +++ /dev/null @@ -1,925 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { buildClaimUrl } from '../../claim/claim-service.js' -import { listCloudtentaclesSku } from '../../platforms/cloudtentacles/catalog-service.js' -import { getCloudtentaclesKnapsack } from '../../platforms/cloudtentacles/knapsack-service.js' -import { - backCloudtentaclesVirtualNumber, - getCloudtentaclesBindUrl, -} from '../../platforms/cloudtentacles/virtual-number-service.js' -import { notifyKuaishouCloudBindUrlRefreshFailed } from '../../notification/domain-notifications.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' -import { - KUAISHOU_CLOUD_FIXED_VN_KEY, - isKuaishouCloudBindUrlFresh, - isKuaishouCloudBindingMutationFrozen, - isKuaishouCloudTask, - maskPhone, - normalizeKuaishouCloudFlow, - normalizeStringArray, - resolveKuaishouCloudBindUrlExpiresAt, - type JsonObject, -} from './domain.js' -import { - prepareKuaishouCloudBindResourceWithFallback, - resolveKuaishouCloudBindingResources, - resolveKuaishouCloudVnKeyCandidates, -} from './binding-resources.js' -import { - buildCloudtentaclesSourceSelectionEventDetail, - selectCloudtentaclesSourceForFulfillment, -} from './account-selector.js' -import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' -import { wrapCloudtentaclesOperationError } from './cloudtentacles-errors.js' -import { getTaskClaimExpiresAt, normalizeActor, parseTaskContext } from './task-context.js' -import { resolveKuaishouCloudDeliveryPlan } from './delivery-plan.js' -import { ensureTaskClaimLink } from './ensure-claim-link.js' -import { - buildPendingRoleWithDefaultSnapshot, - captureKuaishouCloudDefaultRoleSnapshot, -} from './role-state.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -/** - * 准备 / 重建绑定资源(不退款、不重建订单/claim)。 - * - 默认:已 ready 且绑链未过期则直接复用 - * - force=true:旧号 best-effort 退还(已被系统回收则忽略),再取新号+新绑链 - */ -export async function prepareKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const now = nowIso() - const actor = normalizeActor(options.actor) - const force = options.force === true - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const claimLinkState = await ensureTaskClaimLink(task) - const source = String(options.source || 'system').trim() || 'system' - - // 已发货:禁止 prepare / 过期换号把状态打回 waiting_binding - if (isKuaishouCloudBindingMutationFrozen(task, flow)) { - return { - task, - claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), - token: String(task.primary_claim_token || task.claim_token || ''), - flow, - } - } - - if ( - !force && - flow.binding.prepareStatus === 'ready' && - flow.binding.vnId > 0 && - flow.binding.vnPhone && - flow.binding.bindUrl - ) { - if (!isKuaishouCloudBindUrlFresh(flow)) { - return refreshKuaishouCloudTaskBindUrl(task, { - source: options.source || 'system_refresh_expired_bind_url', - actor, - claimLinkState, - }) - } - - let readyTask = task - - if (normalizeTaskStatus(task.task_status) !== TASK_STATUS.WAITING_BINDING) { - readyTask = - (await updateTask(task.id, { - task_status: TASK_STATUS.WAITING_BINDING, - claim_token: claimLinkState.token || task.claim_token || '', - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - updated_at: now, - })) || task - } - - return { - task: readyTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow, - } - } - - // force 重建:旧号可能已被 CT 回收,退号失败直接忽略,下面重新取号 - let previousVnRelease: 'none' | 'returned' | 'already_reclaimed' | 'skipped' = 'none' - if (force && flow.binding.vnId > 0 && flow.binding.vnKey) { - previousVnRelease = await tryReleasePreviousVirtualNumber({ - task, - flow, - actor, - source, - now, - }) - } - - const excludedSourceKeys = normalizeStringArray(options.excludedSourceKeys) - const sourceSelectionAttempts = normalizeStringArray(options.sourceSelectionAttempts) - const sourceCandidateCount = new Set( - normalizeStringArray([...flow.binding.cloudSourceKeys, flow.binding.resolvedSourceKey]), - ).size - const selectedCloud = await selectCloudtentaclesSourceForFulfillment({ - ...flow, - excludedSourceKeys, - }) - let retriedOnAnotherSource = false - try { - const cloudContext = selectedCloud.context - const [knapsack, skuList] = await Promise.all([ - getCloudtentaclesKnapsack(cloudContext), - listCloudtentaclesSku(cloudContext), - ]) - const resolvedBinding = resolveKuaishouCloudBindingResources(flow, { - skuItems: Array.isArray(skuList.items) ? skuList.items : [], - knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], - }) - const vnKeyCandidates = resolveKuaishouCloudVnKeyCandidates({ - flow, - binding: resolvedBinding, - }) - - if (!resolvedBinding.skuId || vnKeyCandidates.length === 0) { - throw createHttpError('当前任务缺少可用 cloud 资源,无法自动准备绑定', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_binding_config', - }) - } - - const flowWithResolvedBinding = { - ...flow, - binding: { - ...flow.binding, - skuId: resolvedBinding.skuId, - skuName: resolvedBinding.skuName, - vnKey: KUAISHOU_CLOUD_FIXED_VN_KEY, - }, - } - - const deliveryPlan = resolveKuaishouCloudDeliveryPlan(flowWithResolvedBinding, { - skuItems: Array.isArray(skuList.items) ? skuList.items : [], - knapsackItems: Array.isArray(knapsack.items) ? knapsack.items : [], - }) - if (deliveryPlan.items.length === 0) { - throw createHttpError('当前任务缺少 cloud 发货物品配置', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_delivery_items', - }) - } - - const usedKnapsack = deliveryPlan.items.every((item) => item.missingCount <= 0) - if (!usedKnapsack && !flowWithResolvedBinding.purchase.autoBuyEnabled) { - throw createHttpError('背包中没有现成库存,且当前配置未开启自动购买', { - statusCode: 409, - errorCode: 'kuaishou_cloud_auto_buy_disabled', - }) - } - - const missingSku = deliveryPlan.items.find((item) => item.missingCount > 0 && !item.skuItem) - if (missingSku) { - throw createHttpError(`cloudtentacles 未找到 SKU ${missingSku.cloudSkuId}`, { - statusCode: 404, - errorCode: 'kuaishou_cloud_sku_not_found', - }) - } - - const purchaseTriggered = false - const assetBefore = 0 - const assetAfter = 0 - - const preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ - cloudContext, - vnKeyCandidates, - purpose: 'new_fulfillment', - }) - const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({ - cloudContext, - preparedBinding, - now, - }) - - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flowWithResolvedBinding, - binding: { - ...flowWithResolvedBinding.binding, - resolvedSourceKey: cloudContext.resolvedSourceKey, - vnKey: preparedBinding.vnKey, - prepareStatus: 'ready', - vnId: preparedBinding.vnId, - vnPhone: preparedBinding.vnPhone, - bindUrl: preparedBinding.bindUrl, - bindPreparedAt: now, - bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null as null, - bindProbeStatus: 'pending', - bindProbeMessage: '', - roleName: '', - roleId: '', - }, - role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot), - purchase: { - ...flowWithResolvedBinding.purchase, - usedKnapsack, - purchaseTriggered, - assetBefore, - assetAfter, - items: deliveryPlan.items.map((item) => ({ - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - requiredCount: item.quantity, - knapsackCount: item.knapsackCount, - purchasedCount: 0, - })), - purchaseAt: flowWithResolvedBinding.purchase.purchaseAt, - }, - }, - } - - const lastErrorNote = - previousVnRelease === 'already_reclaimed' || previousVnRelease === 'skipped' - ? '已重新取号并生成绑定链接(旧号可能已被 CT 回收,已忽略退号失败)' - : force - ? '已重新取号并生成绑定链接' - : '' - - const updatedTask = await updateTask(task.id, { - task_status: TASK_STATUS.WAITING_BINDING, - user_action_status: 'pending_claim', - claim_token: claimLinkState.token || task.claim_token || '', - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - role_id: '', - role_name: '', - last_error: lastErrorNote, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_binding_prepared', - { - source, - force, - previousVnRelease, - skuId: flowWithResolvedBinding.binding.skuId, - skuName: flowWithResolvedBinding.binding.skuName, - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhoneMasked: maskPhone(preparedBinding.vnPhone), - purchaseTriggered, - usedKnapsack, - deliveryItems: deliveryPlan.items.map((item) => ({ - cloudSkuId: item.cloudSkuId, - cloudSkuName: item.cloudSkuName, - quantity: item.quantity, - knapsackCount: item.knapsackCount, - purchasedCount: 0, - })), - resolvedByName: resolvedBinding.resolvedByName, - accountSelection: { - ...buildCloudtentaclesSourceSelectionEventDetail(selectedCloud), - attemptedSourceKeys: [...sourceSelectionAttempts, selectedCloud.sourceKey], - }, - defaultRoleName: defaultRoleSnapshot.defaultName, - defaultRoleId: defaultRoleSnapshot.defaultRid, - defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus, - actor, - }, - now, - ) - - return { - task: updatedTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - previousVnRelease, - } - } catch (error) { - // Before a VN exists, account-scoped failures are safe to retry on another - // candidate. Once a VN is acquired, switching accounts would orphan it. - if ( - flow.binding.vnId <= 0 && - isRetryableCloudtentaclesSourceError(error) && - excludedSourceKeys.length + 1 < sourceCandidateCount - ) { - retriedOnAnotherSource = true - selectedCloud.release() - return prepareKuaishouCloudFulfillmentTask(task, { - ...options, - excludedSourceKeys: [...excludedSourceKeys, selectedCloud.sourceKey], - sourceSelectionAttempts: [...sourceSelectionAttempts, selectedCloud.sourceKey], - }) - } - throw error - } finally { - if (!retriedOnAnotherSource) { - selectedCloud.release() - } - } -} - -function isRetryableCloudtentaclesSourceError(error: unknown) { - const current = error && typeof error === 'object' ? (error as JsonObject) : {} - const code = String(current.errorCode || current.code || '').trim() - return ( - code === 'cloudtentacles_vn_quota_cooldown' || - (code === 'cloudtentacles_vn_appoint_failed' && - String(current.message || '') - .trim() - .includes('最多同时占用')) || - code === 'cloudtentacles_vn_list_failed' || - code === 'cloudtentacles_sku_list_failed' || - code === 'cloudtentacles_knapsack_failed' || - code === 'cloudtentacles_asset_failed' - ) -} - -/** 旧虚拟号 best-effort 释放:已被回收/权限不足时不算失败 */ -async function tryReleasePreviousVirtualNumber({ - task, - flow, - actor, - source, - now, -}: { - task: TaskRow - flow: ReturnType - actor: unknown - source: string - now: string -}): Promise<'returned' | 'already_reclaimed' | 'skipped'> { - try { - const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ - flow.binding.resolvedSourceKey, - ...flow.binding.cloudSourceKeys, - ]) - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: flow.binding.vnKey, - id: flow.binding.vnId, - }) - await createTaskEvent( - task.id, - 'kuaishou_cloud_previous_number_returned_before_reprepare', - { - source, - vnId: flow.binding.vnId, - vnKey: flow.binding.vnKey, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), - cloudSourceKey: cloudContext.resolvedSourceKey, - actor, - }, - now, - ) - return 'returned' - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '') - const reclaimed = - message.includes('权限不足') || - message.includes('不存在') || - message.includes('已释放') || - message.includes('已回收') - await createTaskEvent( - task.id, - reclaimed - ? 'kuaishou_cloud_previous_number_already_reclaimed' - : 'kuaishou_cloud_previous_number_release_skipped', - { - source, - vnId: flow.binding.vnId, - vnKey: flow.binding.vnKey, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), - errorMessage: message, - actor, - }, - now, - ) - return reclaimed ? 'already_reclaimed' : 'skipped' - } -} - -/** - * 刷新绑定链接(不退订单/不重建 claim): - * 1. 优先同 vn 调 bind_url 重取链接(不退号) - * 2. 失败再退号 + 取新号 - * 3. 退号失败可软跳过继续取新号(allowSkipBack,默认 true) - */ -export async function refreshKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const now = nowIso() - const actor = normalizeActor(options.actor) - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const source = String(options.source || 'system').trim() || 'system' - // 默认退号失败即中止:避免旧号没退掉又占新号,导致账号号码配额被净泄漏 - const allowSkipBack = options.allowSkipBack === true - const preferReuseVn = options.preferReuseVn !== false - - if (isKuaishouCloudBindingMutationFrozen(task, flow)) { - throw createHttpError('当前任务已经发货,不能刷新或更换绑定链接', { - statusCode: 409, - errorCode: 'kuaishou_cloud_bind_url_refresh_after_dispatch_forbidden', - }) - } - - if (!flow.binding.vnId || !flow.binding.vnKey) { - throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_bind_url_context', - }) - } - - let cloudContext: JsonObject - try { - cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ - flow.binding.resolvedSourceKey, - ...flow.binding.cloudSourceKeys, - ]) - } catch (error) { - throw wrapCloudtentaclesOperationError(error, { - action: 'resolve_source', - actionLabel: '解析取号账号', - sourceKey: flow.binding.resolvedSourceKey || flow.binding.cloudSourceKeys[0], - vnKey: flow.binding.vnKey, - vnId: flow.binding.vnId, - vnPhone: flow.binding.vnPhone, - }) - } - - const oldVnKey = flow.binding.vnKey - const oldVnId = flow.binding.vnId - const oldVnPhone = flow.binding.vnPhone - const claimLinkState = options.claimLinkState || (await ensureTaskClaimLink(task)) - - // 路径 1:复用现有虚拟号,只重取 bindUrl(不退号、不占新号) - if (preferReuseVn) { - try { - const bindUrlResult = await getCloudtentaclesBindUrl({ - ...cloudContext, - key: oldVnKey, - id: oldVnId, - }) - const bindUrl = String(bindUrlResult.bindUrl || '').trim() - if (bindUrl) { - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'ready', - resolvedSourceKey: String( - cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '', - ).trim(), - bindUrl, - bindPreparedAt: now, - bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null as null, - bindProbeStatus: 'pending', - bindProbeMessage: '', - }, - }, - } - const updatedTask = await updateTask(task.id, { - task_status: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED - ? task.task_status - : TASK_STATUS.WAITING_BINDING, - claim_token: claimLinkState.token || task.claim_token || '', - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - last_error: '', - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_bind_url_refreshed', - { - source, - mode: 'reuse_existing_vn', - vnKey: oldVnKey, - vnId: oldVnId, - vnPhoneMasked: maskPhone(oldVnPhone), - cloudSourceKey: cloudContext.resolvedSourceKey, - actor, - }, - now, - ) - - return { - task: updatedTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - mode: 'reuse_existing_vn', - } - } - } catch (error) { - // 号码已退/已回收/无权限:直接清空绑定,不再走退号换号(避免反复打上游) - if (isNumberAlreadyReleasedError(error)) { - return clearKuaishouCloudStaleBinding(task, { - taskContext, - flow, - now, - actor, - source, - error, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token || task.claim_token || '', - }) - } - // 其他错误走退号换号 - } - } - - // 路径 2:退旧号 + 取新号 - let oldNumberBackStatus: 'success' | 'skipped' = 'success' - let oldNumberBackError = '' - try { - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: oldVnKey, - id: oldVnId, - }) - await createTaskEvent( - task.id, - 'kuaishou_cloud_expired_bind_number_returned', - { - source, - vnKey: oldVnKey, - vnId: oldVnId, - vnPhoneMasked: maskPhone(oldVnPhone), - cloudSourceKey: cloudContext.resolvedSourceKey, - actor, - }, - now, - ) - } catch (error) { - // 号码已退/已回收/无权限:直接清空绑定(不再尝试取新号),避免反复打上游 - if (isNumberAlreadyReleasedError(error)) { - return clearKuaishouCloudStaleBinding(task, { - taskContext, - flow, - now, - actor, - source, - error, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token || task.claim_token || '', - }) - } - oldNumberBackError = error instanceof Error ? error.message : String(error || '退号失败') - const wrapped = wrapCloudtentaclesOperationError(error, { - action: 'vn_back', - actionLabel: '退还过期虚拟号', - sourceKey: cloudContext.resolvedSourceKey || cloudContext.sourceKey, - vnKey: oldVnKey, - vnId: oldVnId, - vnPhone: oldVnPhone, - }) - await createTaskEvent( - task.id, - 'kuaishou_cloud_expired_bind_number_back_failed', - { - source, - vnKey: oldVnKey, - vnId: oldVnId, - errorMessage: wrapped.message, - cloudSourceKey: cloudContext.resolvedSourceKey, - actor, - }, - now, - ) - if (!allowSkipBack) { - throw wrapped - } - oldNumberBackStatus = 'skipped' - } - - let preparedBinding - try { - preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ - cloudContext, - purpose: 'expired_binding_replacement', - vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ - flow, - binding: flow.binding, - }), - }) - - if (!String(preparedBinding.bindUrl || '').trim()) { - throw createHttpError('cloudtentacles 未返回新的绑定链接', { - statusCode: 502, - errorCode: 'kuaishou_cloud_empty_bind_url', - }) - } - } catch (error) { - const failedTask = await markKuaishouCloudBindUrlRefreshFailed(task, { - taskContext, - flow, - now, - actor, - error, - oldNumberBackStatus, - oldNumberBackError, - }) - - return { - task: failedTask, - claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), - token: String(task.primary_claim_token || task.claim_token || ''), - flow: normalizeKuaishouCloudFlow(parseTaskContext(failedTask).kuaishouCloudFulfillment), - } - } - - const defaultRoleSnapshot = - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED - ? null - : await captureKuaishouCloudDefaultRoleSnapshot({ - cloudContext, - preparedBinding, - now, - }) - const skipNote = - oldNumberBackStatus === 'skipped' - ? `旧号 vnId=${oldVnId} 退还失败已跳过(账号=${cloudContext.resolvedSourceKey}),已换新号绑链` - : '' - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'ready', - resolvedSourceKey: String( - cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '', - ).trim(), - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhone: preparedBinding.vnPhone, - bindUrl: preparedBinding.bindUrl, - bindPreparedAt: now, - bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now), - bindProbeAt: null as null, - bindProbeStatus: 'pending', - bindProbeMessage: '', - roleName: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED - ? flow.binding.roleName - : '', - roleId: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED - ? flow.binding.roleId - : '', - }, - role: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED - ? flow.role - : buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot!), - }, - } - - const updatedTask = await updateTask(task.id, { - task_status: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED - ? task.task_status - : TASK_STATUS.WAITING_BINDING, - claim_token: claimLinkState.token || task.claim_token || '', - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - role_id: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_id : '', - role_name: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED ? task.role_name : '', - last_error: skipNote, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_bind_url_refreshed', - { - source, - mode: 'replace_vn', - oldVnId, - oldVnPhoneMasked: maskPhone(oldVnPhone), - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhoneMasked: maskPhone(preparedBinding.vnPhone), - oldNumberBackStatus, - oldNumberBackError, - cloudSourceKey: cloudContext.resolvedSourceKey, - actor, - }, - now, - ) - - return { - task: updatedTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - mode: 'replace_vn', - oldNumberBackStatus, - } -} - -async function markKuaishouCloudBindUrlRefreshFailed( - task: TaskRow, - { - taskContext, - flow, - now, - actor, - error, - oldNumberBackStatus = 'unknown', - oldNumberBackError = '', - }: JsonObject = {}, -) { - const errorMessage = - error instanceof Error ? error.message : String(error || '新绑定链接准备失败') - const backNote = - oldNumberBackStatus === 'skipped' - ? `旧号退还失败(${oldNumberBackError || '未知'})且` - : oldNumberBackStatus === 'success' - ? '旧号码已退还,' - : '' - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'pending', - vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding?.vnId || 0, - vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding?.vnPhone || '', - bindUrl: '', - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: now, - bindProbeStatus: 'refresh_failed', - bindProbeMessage: errorMessage, - roleName: '', - roleId: '', - }, - role: { - status: 'pending', - name: '', - rid: '', - refreshedAt: now, - errorMessage: `绑定链接刷新失败:${backNote}新链接准备失败,请稍后重试或联系客服`, - rawInfo: null, - }, - }, - } - - const updatedTask = await updateTask(task.id, { - task_status: TASK_STATUS.PENDING_BINDING_PREPARE, - role_id: '', - role_name: '', - last_error: `绑定链接刷新失败:${backNote}新链接准备失败:${errorMessage}`, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_bind_url_refresh_failed', - { - source: 'system_refresh_expired_bind_url', - errorMessage, - actor, - }, - now, - ) - - await notifyKuaishouCloudBindUrlRefreshFailed({ - task: updatedTask, - errorMessage, - }) - - return updatedTask -} - -/** 号码已被上游回收/手动退回/账号换 token 后无操作权限:重试无意义,应清空任务绑定 */ -function isNumberAlreadyReleasedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error || '') - return ( - message.includes('权限不足') || - message.includes('没有权限') || - message.includes('不存在') || - message.includes('已释放') || - message.includes('已回收') || - message.includes('已退还') - ) -} - -/** - * 清空任务侧已失效的旧绑定(号码已退/已回收/无权限),任务下次领取时自动重新取号。 - * 所有调用路径(claim 轮询 probe、open-91 交付、回收任务、后台刷新)统一收敛, - * 避免对失效号码反复打上游。 - */ -async function clearKuaishouCloudStaleBinding( - task: TaskRow, - { - taskContext, - flow, - now, - actor, - source, - error, - claimUrl, - token, - }: { - taskContext: JsonObject - flow: ReturnType - now: string - actor: unknown - source: string - error: unknown - claimUrl: string - token: string - }, -) { - const errorMessage = error instanceof Error ? error.message : String(error || '绑定已失效') - const oldVnId = flow.binding.vnId - const oldVnPhone = flow.binding.vnPhone - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'pending', - vnId: 0, - vnPhone: '', - bindUrl: '', - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: now, - bindProbeStatus: 'stale_cleared', - bindProbeMessage: errorMessage, - roleName: '', - roleId: '', - }, - role: { - status: 'pending', - name: '', - rid: '', - refreshedAt: now, - errorMessage: '旧绑定号码已失效,已清空绑定,将重新取号', - rawInfo: null, - }, - }, - } - - const updatedTask = await updateTask(task.id, { - task_status: - normalizeTaskStatus(task.task_status) === TASK_STATUS.ROLE_CONFIRMED - ? task.task_status - : TASK_STATUS.PENDING_BINDING_PREPARE, - role_id: '', - role_name: '', - last_error: `旧绑定号码已失效(${errorMessage}),已清空绑定`, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_stale_binding_cleared', - { - source, - oldVnId, - oldVnPhoneMasked: maskPhone(oldVnPhone), - cloudSourceKey: flow.binding.resolvedSourceKey || flow.binding.cloudSourceKeys[0] || '', - errorMessage, - actor, - }, - now, - ) - - return { - task: updatedTask || task, - claimUrl, - token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - mode: 'stale_cleared', - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts deleted file mode 100644 index 4126aa75..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/probe-bind-url.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { updateTask } from '../../../repositories/task-repo.js' -import { probeCloudtentaclesBindUrl } from '../../platforms/cloudtentacles/virtual-number-service.js' -import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/helpers.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { - isKuaishouCloudBindUrlFresh, - isKuaishouCloudBindingMutationFrozen, - isKuaishouCloudTask, - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - type JsonObject, -} from './domain.js' -import { parseTaskContext } from './task-context.js' -import { buildRoleStateFromCurrentInfo } from './role-state.js' -import { refreshKuaishouCloudTaskBindUrl } from './prepare-fulfillment.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -export async function probeKuaishouCloudTaskBindUrl(task: TaskRow, options: JsonObject = {}) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const now = nowIso() - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - - // 已发货:禁止 probe 触发过期换号 / 覆盖角色 - if (isKuaishouCloudBindingMutationFrozen(task, flow)) { - return { - task, - flow, - probe: null as null, - } - } - - if (!flow.binding.bindUrl) { - return { - task, - flow, - probe: null as null, - } - } - - const probeIntervalMs = - Number(resolveCloudtentaclesConfig().bindUrlProbeIntervalSeconds || 30) * 1000 - const lastProbeAt = flow.binding.bindProbeAt ? Date.parse(String(flow.binding.bindProbeAt)) : NaN - if ( - !options.force && - Number.isFinite(lastProbeAt) && - Date.now() - lastProbeAt < probeIntervalMs - ) { - return { - task, - flow, - probe: null as null, - } - } - - const probe = await probeCloudtentaclesBindUrl({ - bindUrl: flow.binding.bindUrl, - }) - - if (probe.expired || !isKuaishouCloudBindUrlFresh(flow)) { - const refreshed = await refreshKuaishouCloudTaskBindUrl(task, { - source: options.source || 'claim_page_bind_url_expired', - actor: options.actor || { source: 'system' }, - }) - return { - ...refreshed, - probe, - } - } - - const probeRoleInfo = normalizeKuaishouCloudRoleInfo(probe.roleInfo) - const hasRoleInfo = Boolean(probeRoleInfo.name || probeRoleInfo.rid) - const roleState = hasRoleInfo - ? buildRoleStateFromCurrentInfo({ - flow, - roleInfo: probeRoleInfo, - now, - emptyMessage: '当前还没有查询到角色信息,请完成绑定后稍等片刻再试', - }) - : null - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - bindProbeAt: now, - bindProbeStatus: probe.valid ? 'valid' : 'invalid', - bindProbeMessage: String(probe.message || probe.reason || '').trim(), - roleName: roleState ? roleState.bindingRoleName : flow.binding.roleName, - roleId: roleState ? roleState.bindingRoleId : flow.binding.roleId, - }, - role: roleState ? roleState.role : flow.role, - }, - } - - const updatedTask = await updateTask(task.id, { - role_id: roleState && !roleState.isDefaultRole ? probeRoleInfo.rid : task.role_id, - role_name: roleState && !roleState.isDefaultRole ? probeRoleInfo.name : task.role_name, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - return { - task: updatedTask, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - probe, - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts deleted file mode 100644 index 0c4b2a02..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/rebind-role.ts +++ /dev/null @@ -1,567 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { buildClaimUrl } from '../../claim/claim-service.js' -import { - backCloudtentaclesVirtualNumber, - getCloudtentaclesBindUrl, -} from '../../platforms/cloudtentacles/virtual-number-service.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { TASK_STATUS, normalizeTaskStatus } from '../../../domain/task-status.js' -import { - isKuaishouCloudTask, - maskPhone, - normalizeKuaishouCloudFlow, - resolveKuaishouCloudBindUrlExpiresAt, - type JsonObject, -} from './domain.js' -import { - prepareKuaishouCloudBindResourceWithFallback, - resolveKuaishouCloudVnKeyCandidates, -} from './binding-resources.js' -import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' -import { - isCloudtentaclesPermissionLikeError, - wrapCloudtentaclesOperationError, -} from './cloudtentacles-errors.js' -import { getTaskClaimExpiresAt, normalizeActor, parseTaskContext } from './task-context.js' -import { ensureTaskClaimLink } from './ensure-claim-link.js' -import { - buildPendingRoleWithDefaultSnapshot, - captureKuaishouCloudDefaultRoleSnapshot, -} from './role-state.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -/** - * 换绑角色:尽量退旧号后取新号+新绑链。 - * - 不涉及订单退款;claimUrl 复用 - * - 旧号退还失败时默认软跳过继续取新号(避免卡死),并在事件/文案中标明需人工清理旧号 - * - 也可先尝试同 vn 重取 bindUrl(reuseExistingVn) - */ -export async function rebindKuaishouCloudTaskRole(task: TaskRow, options: JsonObject = {}) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const normalizedStatus = normalizeTaskStatus(task.task_status) - const rebindAllowedStatuses = new Set([ - TASK_STATUS.WAITING_BINDING, - TASK_STATUS.ROLE_CONFIRMED, - TASK_STATUS.MANUAL_REVIEW, - TASK_STATUS.RETRY_PENDING, - ]) - if (!rebindAllowedStatuses.has(normalizedStatus)) { - throw createHttpError('当前任务状态不可换绑角色', { - statusCode: 409, - errorCode: 'kuaishou_cloud_rebind_not_allowed', - }) - } - - const now = nowIso() - const actor = normalizeActor(options.actor) - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - - if ( - flow.dispatch.status === 'success' || - normalizeTaskStatus(task.task_status) === TASK_STATUS.DISPATCHED_PENDING_RETURN - ) { - throw createHttpError('当前任务已经发货,不能换绑角色', { - statusCode: 409, - errorCode: 'kuaishou_cloud_rebind_after_dispatch_forbidden', - }) - } - - if (!flow.binding.vnId || !flow.binding.vnKey) { - throw createHttpError('当前任务缺少可退还的虚拟号信息,请先准备绑定资源', { - statusCode: 409, - errorCode: 'kuaishou_cloud_rebind_missing_binding_context', - }) - } - - const source = String(options.source || 'system_rebind_role').trim() || 'system_rebind_role' - // 优先 resolvedSourceKey,避免静默落到别的账号导致「权限不足」 - const preferredSourceKeys = [ - flow.binding.resolvedSourceKey, - ...flow.binding.cloudSourceKeys, - ].filter(Boolean) - let cloudContext: JsonObject - try { - cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys(preferredSourceKeys) - } catch (error) { - throw wrapCloudtentaclesOperationError(error, { - action: 'resolve_source', - actionLabel: '解析取号账号', - sourceKey: flow.binding.resolvedSourceKey || flow.binding.cloudSourceKeys[0], - vnKey: flow.binding.vnKey, - vnId: flow.binding.vnId, - vnPhone: flow.binding.vnPhone, - }) - } - - const oldBinding = { - vnKey: flow.binding.vnKey, - vnId: flow.binding.vnId, - vnPhone: flow.binding.vnPhone, - bindUrl: flow.binding.bindUrl, - bindPreparedAt: flow.binding.bindPreparedAt, - bindExpiresAt: flow.binding.bindExpiresAt, - roleName: flow.binding.roleName || flow.role.name || task.role_name || '', - roleId: flow.binding.roleId || flow.role.rid || task.role_id || '', - resolvedSourceKey: - flow.binding.resolvedSourceKey || String(cloudContext.resolvedSourceKey || ''), - } - const previousRebind: JsonObject = - flow.rebind && typeof flow.rebind === 'object' ? (flow.rebind as JsonObject) : {} - const history = Array.isArray(previousRebind.history) ? previousRebind.history : [] - const attempt = Math.max(1, Number(previousRebind.currentAttempt || history.length || 0) + 1) - const baseHistoryItem = { - attempt, - source, - requestedAt: now, - requestedBy: actor, - oldBinding, - cloudSourceKey: String(cloudContext.resolvedSourceKey || ''), - } - - await createTaskEvent( - task.id, - 'kuaishou_cloud_rebind_requested', - { - source, - attempt, - oldVnId: oldBinding.vnId, - oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), - oldRoleName: oldBinding.roleName, - oldRoleId: oldBinding.roleId, - cloudSourceKey: baseHistoryItem.cloudSourceKey, - actor, - }, - now, - ) - - // 路径 A:仅重取同 vn 绑链(不退号、不换号)——适合链接过期但角色还要重绑同一会话时 - if (options.reuseExistingVn === true) { - return rebindByRefreshingExistingVnBindUrl({ - task, - taskContext, - flow, - cloudContext, - oldBinding, - previousRebind, - history, - baseHistoryItem, - attempt, - actor, - source, - now, - }) - } - - // 标准换绑:先退旧号;旧号已被 CT 回收(权限不足等)→ 自动跳过并新取号出链 - // 唯一入口给客服用,不另开按钮 - const allowSkipBack = options.allowSkipBack !== false - let oldNumberBackStatus: 'success' | 'skipped' | 'failed' = 'success' - let oldNumberBackError = '' - - try { - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: oldBinding.vnKey, - id: oldBinding.vnId, - }) - await createTaskEvent( - task.id, - 'kuaishou_cloud_rebind_old_number_returned', - { - source, - attempt, - vnKey: oldBinding.vnKey, - vnId: oldBinding.vnId, - vnPhoneMasked: maskPhone(oldBinding.vnPhone), - cloudSourceKey: baseHistoryItem.cloudSourceKey, - actor, - }, - now, - ) - } catch (error) { - oldNumberBackError = error instanceof Error ? error.message : String(error || '退号失败') - const wrapped = wrapCloudtentaclesOperationError(error, { - action: 'vn_back', - actionLabel: '退还旧虚拟号', - sourceKey: cloudContext.resolvedSourceKey || cloudContext.sourceKey, - vnKey: oldBinding.vnKey, - vnId: oldBinding.vnId, - vnPhone: oldBinding.vnPhone, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_rebind_old_number_back_failed', - { - source, - attempt, - vnKey: oldBinding.vnKey, - vnId: oldBinding.vnId, - vnPhoneMasked: maskPhone(oldBinding.vnPhone), - cloudSourceKey: baseHistoryItem.cloudSourceKey, - errorMessage: wrapped.message, - permissionLike: isCloudtentaclesPermissionLikeError(error), - actor, - }, - now, - ) - - // 默认继续新取号(旧号被系统回收时最常见);仅显式 allowSkipBack=false 才硬失败 - if (!allowSkipBack) { - throw wrapped - } - - oldNumberBackStatus = 'skipped' - } - - let preparedBinding - try { - preparedBinding = await prepareKuaishouCloudBindResourceWithFallback({ - cloudContext, - purpose: 'claim_rebind', - vnKeyCandidates: resolveKuaishouCloudVnKeyCandidates({ - flow, - binding: flow.binding, - }), - }) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : '新绑定资源准备失败' - const failedContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'pending', - vnId: oldNumberBackStatus === 'success' ? 0 : flow.binding.vnId, - vnPhone: oldNumberBackStatus === 'success' ? '' : flow.binding.vnPhone, - bindUrl: '', - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: now, - bindProbeStatus: 'rebind_failed', - bindProbeMessage: errorMessage, - roleName: '', - roleId: '', - }, - role: { - status: 'pending', - name: '', - rid: '', - refreshedAt: now, - errorMessage: - oldNumberBackStatus === 'success' - ? `旧虚拟号已退还,新绑定资源准备失败:${errorMessage}` - : `旧号退还未成功且新号准备失败:${errorMessage}`, - rawInfo: null, - }, - rebind: { - ...previousRebind, - currentAttempt: attempt, - history: [ - ...history, - { - ...baseHistoryItem, - status: 'failed', - oldNumberBackStatus, - oldNumberBackError, - errorMessage, - }, - ], - }, - }, - } - const failedTask = await updateTask(task.id, { - task_status: TASK_STATUS.PENDING_BINDING_PREPARE, - role_id: '', - role_name: '', - role_confirmed_at: null, - last_error: - oldNumberBackStatus === 'success' - ? `换绑失败,旧虚拟号已退还,新绑定资源准备失败:${errorMessage}` - : `换绑失败:旧号退还失败(${oldNumberBackError}),且新号准备失败:${errorMessage}`, - context_json: JSON.stringify(failedContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_rebind_failed', - { - source, - attempt, - errorMessage, - oldNumberBackStatus, - oldNumberBackError, - actor, - }, - now, - ) - - return { - task: failedTask || task, - claimUrl: buildClaimUrl(String(task.primary_claim_token || task.claim_token || '')), - token: String(task.primary_claim_token || task.claim_token || ''), - flow: normalizeKuaishouCloudFlow( - parseTaskContext(failedTask || task).kuaishouCloudFulfillment, - ), - } - } - - const claimLinkState = await ensureTaskClaimLink(task) - const nextBindExpiresAt = resolveKuaishouCloudBindUrlExpiresAt(now) - const defaultRoleSnapshot = await captureKuaishouCloudDefaultRoleSnapshot({ - cloudContext, - preparedBinding, - now, - }) - const skipNote = - oldNumberBackStatus === 'skipped' - ? `(旧号 vnId=${oldBinding.vnId} 退还失败已跳过,账号=${baseHistoryItem.cloudSourceKey},请稍后在 CT 人工清理)` - : '' - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'ready', - resolvedSourceKey: String( - cloudContext.resolvedSourceKey || flow.binding.resolvedSourceKey || '', - ).trim(), - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhone: preparedBinding.vnPhone, - bindUrl: preparedBinding.bindUrl, - bindPreparedAt: now, - bindExpiresAt: nextBindExpiresAt, - bindProbeAt: null, - bindProbeStatus: 'pending', - bindProbeMessage: '', - roleName: '', - roleId: '', - }, - role: buildPendingRoleWithDefaultSnapshot(defaultRoleSnapshot), - rebind: { - ...previousRebind, - currentAttempt: attempt, - history: [ - ...history, - { - ...baseHistoryItem, - newBinding: { - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhone: preparedBinding.vnPhone, - bindUrl: preparedBinding.bindUrl, - bindPreparedAt: now, - bindExpiresAt: nextBindExpiresAt, - }, - status: 'success', - oldNumberBackStatus, - oldNumberBackError, - errorMessage: '', - }, - ], - }, - }, - } - - const updatedTask = await updateTask(task.id, { - task_status: TASK_STATUS.WAITING_BINDING, - user_action_status: 'pending_claim', - claim_token: claimLinkState.token || task.claim_token || '', - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - role_id: '', - role_name: '', - role_confirmed_at: null, - last_error: skipNote, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_rebind_prepared', - { - source, - attempt, - oldVnId: oldBinding.vnId, - oldVnPhoneMasked: maskPhone(oldBinding.vnPhone), - vnKey: preparedBinding.vnKey, - vnId: preparedBinding.vnId, - vnPhoneMasked: maskPhone(preparedBinding.vnPhone), - bindUrl: preparedBinding.bindUrl, - oldNumberBackStatus, - oldNumberBackError, - cloudSourceKey: baseHistoryItem.cloudSourceKey, - defaultRoleName: defaultRoleSnapshot.defaultName, - defaultRoleId: defaultRoleSnapshot.defaultRid, - defaultRoleCaptureStatus: defaultRoleSnapshot.defaultCaptureStatus, - actor, - }, - now, - ) - - return { - task: updatedTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - oldNumberBackStatus, - } -} - -async function rebindByRefreshingExistingVnBindUrl(input: { - task: TaskRow - taskContext: JsonObject - flow: ReturnType - cloudContext: JsonObject - oldBinding: JsonObject - previousRebind: JsonObject - history: unknown[] - baseHistoryItem: JsonObject - attempt: number - actor: unknown - source: string - now: string -}) { - const { - task, - taskContext, - flow, - cloudContext, - oldBinding, - previousRebind, - history, - baseHistoryItem, - attempt, - actor, - source, - now, - } = input - - let bindUrl - try { - const bindUrlResult = await getCloudtentaclesBindUrl({ - ...cloudContext, - key: oldBinding.vnKey, - id: oldBinding.vnId, - }) - bindUrl = String(bindUrlResult.bindUrl || '').trim() - if (!bindUrl) { - throw createHttpError('cloudtentacles 未返回绑定链接', { - statusCode: 502, - errorCode: 'kuaishou_cloud_empty_bind_url', - }) - } - } catch (error) { - throw wrapCloudtentaclesOperationError(error, { - action: 'vn_bind_url', - actionLabel: '复用原虚拟号重取绑定链接', - sourceKey: cloudContext.resolvedSourceKey || cloudContext.sourceKey, - vnKey: oldBinding.vnKey, - vnId: oldBinding.vnId, - vnPhone: oldBinding.vnPhone, - }) - } - - const claimLinkState = await ensureTaskClaimLink(task) - const nextBindExpiresAt = resolveKuaishouCloudBindUrlExpiresAt(now) - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'ready', - bindUrl, - bindPreparedAt: now, - bindExpiresAt: nextBindExpiresAt, - bindProbeAt: null, - bindProbeStatus: 'pending', - bindProbeMessage: '', - // 清空角色,要求用户用正确 UID 重新绑定 - roleName: '', - roleId: '', - }, - role: { - ...flow.role, - status: 'pending', - name: '', - rid: '', - refreshedAt: now, - errorMessage: '', - rawInfo: null, - }, - rebind: { - ...previousRebind, - currentAttempt: attempt, - history: [ - ...history, - { - ...baseHistoryItem, - status: 'success', - mode: 'reuse_existing_vn', - newBinding: { - vnKey: oldBinding.vnKey, - vnId: oldBinding.vnId, - vnPhone: oldBinding.vnPhone, - bindUrl, - bindPreparedAt: now, - bindExpiresAt: nextBindExpiresAt, - }, - errorMessage: '', - }, - ], - }, - }, - } - - const updatedTask = await updateTask(task.id, { - task_status: TASK_STATUS.WAITING_BINDING, - user_action_status: 'pending_claim', - claim_token: claimLinkState.token || task.claim_token || '', - claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task), - role_id: '', - role_name: '', - role_confirmed_at: null, - last_error: '已复用原虚拟号刷新绑定链接,请用户使用正确角色重新扫码绑定', - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_rebind_prepared', - { - source, - attempt, - mode: 'reuse_existing_vn', - vnId: oldBinding.vnId, - vnPhoneMasked: maskPhone(oldBinding.vnPhone), - bindUrl, - cloudSourceKey: baseHistoryItem.cloudSourceKey, - actor, - }, - now, - ) - - return { - task: updatedTask, - claimUrl: claimLinkState.claimUrl, - token: claimLinkState.token, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - oldNumberBackStatus: 'skipped' as const, - mode: 'reuse_existing_vn', - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/redeem-fulfillment.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/redeem-fulfillment.test.ts deleted file mode 100644 index d434fdd7..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/redeem-fulfillment.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { - TASK_STATUS, - canRedeemKuaishouCloudClaimStatus, - isKuaishouCloudRedeemSettledStatus, -} from '../../../domain/task-status.js' -import { - assertBoundUidMatchesExpected, - assertClaimExpectedUidReady, -} from '../../claim/claim-identity.js' - -/** - * redeem 用例前置条件单测(不打 DB / CT)。 - * 完整 dispatch 走集成路径;这里锁定状态机与 UID 闸门契约。 - */ -function canEnterRedeem(options: { - status: unknown - expectedUid: string - boundUid: string - mock?: boolean -}) { - if (isKuaishouCloudRedeemSettledStatus(options.status)) { - return { ok: true as const, alreadySettled: true } - } - if (!canRedeemKuaishouCloudClaimStatus(options.status)) { - return { ok: false as const, reason: 'status_not_redeemable' } - } - if (!options.mock && !options.expectedUid) { - return { ok: false as const, reason: 'uid_missing' } - } - if (!options.mock && options.expectedUid !== options.boundUid) { - return { ok: false as const, reason: 'uid_mismatch' } - } - return { ok: true as const, alreadySettled: false } -} - -test('redeem 前置:waiting_binding + uid 匹配可进入', () => { - const result = canEnterRedeem({ - status: TASK_STATUS.WAITING_BINDING, - expectedUid: '10001', - boundUid: '10001', - }) - assert.deepEqual(result, { ok: true, alreadySettled: false }) -}) - -test('redeem 前置:role_confirmed + uid 匹配可进入', () => { - const result = canEnterRedeem({ - status: TASK_STATUS.ROLE_CONFIRMED, - expectedUid: '10001', - boundUid: '10001', - }) - assert.deepEqual(result, { ok: true, alreadySettled: false }) -}) - -test('redeem 前置:已 completed 视为 settled', () => { - const result = canEnterRedeem({ - status: TASK_STATUS.COMPLETED, - expectedUid: '10001', - boundUid: '10001', - }) - assert.deepEqual(result, { ok: true, alreadySettled: true }) -}) - -test('redeem 前置:uid 不一致拒绝', () => { - const result = canEnterRedeem({ - status: TASK_STATUS.WAITING_BINDING, - expectedUid: '10001', - boundUid: '99999', - }) - assert.equal(result.ok, false) - if (!result.ok) { - assert.equal(result.reason, 'uid_mismatch') - } -}) - -test('redeem 闸门函数与 claim-identity 一致', () => { - assert.throws(() => assertClaimExpectedUidReady({ context_json: '{}' }), /填写游戏 UID/) - assert.throws( - () => - assertBoundUidMatchesExpected( - { - context_json: JSON.stringify({ - claimIdentity: { expectedUid: '10001' }, - }), - }, - { - binding: { roleId: 'x', roleName: 'n', vnPhone: '1' }, - role: { rid: 'x', name: 'n' }, - }, - ), - /不一致/, - ) -}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/redeem-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/redeem-fulfillment.ts deleted file mode 100644 index 44a161ad..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/redeem-fulfillment.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { - getTaskById, - updateTask, - updateTaskStatusIfCurrent, -} from '../../../repositories/task-repo.js' -import { - TASK_STATUS, - canRedeemKuaishouCloudClaimStatus, - isKuaishouCloudRedeemSettledStatus, - normalizeTaskStatus, -} from '../../../domain/task-status.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { - assertBoundUidMatchesExpected, - assertClaimExpectedUidReady, - getClaimIdentityFromTask, -} from '../../claim/claim-identity.js' -import { isKuaishouCloudTask, normalizeKuaishouCloudFlow, type JsonObject } from './domain.js' -import { dispatchKuaishouCloudFulfillmentTask } from './dispatch-fulfillment.js' -import { completeMockKuaishouCloudTask, isKuaishouCloudMockTask } from './mock-helpers.js' -import { normalizeActor, parseTaskContext } from './task-context.js' - -export type RedeemKuaishouCloudTaskResult = { - task: TaskRow - /** 是否已处于终态/已兑换,调用方无需再 dispatch */ - alreadySettled: boolean - /** 并发抢锁失败(他人已推进) */ - concurrentSkipped: boolean -} - -/** - * lewan 一键兑换用例(履约引擎入口): - * waiting_binding + UID 匹配 → role_confirmed → redeeming → dispatch(+autoFinalize) - * - * claim / admin 应只调此函数,不要直接拼状态机。 - */ -export async function redeemKuaishouCloudTask( - task: TaskRow, - options: JsonObject = {}, -): Promise { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const now = String(options.now || '').trim() || nowIso() - const source = String(options.source || 'system_redeem').trim() || 'system_redeem' - const actor = normalizeActor(options.actor) || { source } - const autoFinalize = options.autoFinalize !== false - const errorCodePrefix = - String(options.errorCodePrefix || 'kuaishou_cloud_redeem').trim() || 'kuaishou_cloud_redeem' - - let workingTask = task - const currentStatus = normalizeTaskStatus(workingTask.task_status) - - if (isKuaishouCloudRedeemSettledStatus(currentStatus)) { - return { task: workingTask, alreadySettled: true, concurrentSkipped: false } - } - - if (!canRedeemKuaishouCloudClaimStatus(currentStatus)) { - throw createHttpError('当前状态不可兑换,请先完成绑定并匹配 UID', { - statusCode: 409, - errorCode: `${errorCodePrefix}_not_ready`, - }) - } - - // WAITING_BINDING + UID 匹配:自动升为 ROLE_CONFIRMED,实现一键兑换 - if (currentStatus === TASK_STATUS.WAITING_BINDING) { - const flow = normalizeKuaishouCloudFlow(parseTaskContext(workingTask).kuaishouCloudFulfillment) - if (!isKuaishouCloudMockTask(workingTask)) { - assertBoundUidMatchesExpected(workingTask, flow, { - errorCodePrefix, - }) - } else { - assertClaimExpectedUidReady(workingTask) - } - - const confirmed = await updateTask(workingTask.id, { - task_status: TASK_STATUS.ROLE_CONFIRMED, - role_id: flow.binding.roleId || workingTask.role_id || '', - role_name: flow.binding.roleName || workingTask.role_name || '', - user_action_status: TASK_STATUS.ROLE_CONFIRMED, - role_confirmed_at: now, - last_error: '', - updated_at: now, - }) - if (confirmed) { - workingTask = confirmed - } - await createTaskEvent( - workingTask.id, - 'kuaishou_cloud_role_confirmed', - { - expectedUid: getClaimIdentityFromTask(workingTask).expectedUid, - vnPhone: flow.binding.vnPhone, - roleName: flow.binding.roleName, - roleId: flow.binding.roleId, - source: `${source}_one_click`, - }, - now, - ) - } - - const flowBeforeRedeem = normalizeKuaishouCloudFlow( - parseTaskContext(workingTask).kuaishouCloudFulfillment, - ) - if (!isKuaishouCloudMockTask(workingTask)) { - assertBoundUidMatchesExpected(workingTask, flowBeforeRedeem, { - errorCodePrefix, - }) - } else { - assertClaimExpectedUidReady(workingTask) - } - - const lockedTask = await updateTaskStatusIfCurrent(workingTask.id, TASK_STATUS.ROLE_CONFIRMED, { - task_status: TASK_STATUS.REDEEMING, - user_action_status: 'not_required', - last_error: '', - updated_at: now, - }) - - if (!lockedTask) { - const latest = (await getTaskById(workingTask.id)) || workingTask - return { - task: latest, - alreadySettled: isKuaishouCloudRedeemSettledStatus(latest.task_status), - concurrentSkipped: true, - } - } - - if (isKuaishouCloudMockTask(lockedTask)) { - await completeMockKuaishouCloudTask(lockedTask, now) - const completed = (await getTaskById(lockedTask.id)) || lockedTask - return { task: completed, alreadySettled: false, concurrentSkipped: false } - } - - try { - const result = await dispatchKuaishouCloudFulfillmentTask(lockedTask, { - source, - actor, - autoFinalize, - }) - return { - task: result.task || lockedTask, - alreadySettled: false, - concurrentSkipped: false, - } - } catch (error) { - const latestTask = await getTaskById(lockedTask.id) - const latestStatus = latestTask ? normalizeTaskStatus(latestTask.task_status) : '' - - if (latestTask && latestStatus !== TASK_STATUS.REDEEMING) { - if ( - latestStatus === TASK_STATUS.DISPATCHED_PENDING_RETURN || - latestStatus === TASK_STATUS.COMPLETED || - latestStatus === TASK_STATUS.REDEEMED - ) { - return { task: latestTask, alreadySettled: true, concurrentSkipped: false } - } - throw error - } - - const message = error instanceof Error ? error.message : '兑换请求提交失败,请联系客服处理' - await updateTask(lockedTask.id, { - task_status: TASK_STATUS.MANUAL_REVIEW, - user_action_status: 'not_required', - last_error: message, - result_code: 'kuaishou_cloud_redeem_failed', - result_message: message, - updated_at: nowIso(), - }) - - await createTaskEvent( - lockedTask.id, - 'kuaishou_cloud_redeem_failed', - { - source, - errorMessage: message, - }, - nowIso(), - ) - - throw error - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts deleted file mode 100644 index 6c12fc34..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/refresh-role-info.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { getCloudtentaclesBindInfo } from '../../platforms/cloudtentacles/virtual-number-service.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { - isKuaishouCloudBindingMutationFrozen, - isKuaishouCloudTask, - normalizeKuaishouCloudFlow, - normalizeKuaishouCloudRoleInfo, - type JsonObject, -} from './domain.js' -import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' -import { normalizeActor, parseTaskContext } from './task-context.js' -import { buildRoleStateFromCurrentInfo } from './role-state.js' -import { probeKuaishouCloudTaskBindUrl } from './probe-bind-url.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -export async function refreshKuaishouCloudTaskRoleInfo(task: TaskRow, options: JsonObject = {}) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const now = nowIso() - const actor = normalizeActor(options.actor) - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - - // 已发货:禁止 live 刷角色覆盖发货快照(claim 轮询 / 后台误触) - if (isKuaishouCloudBindingMutationFrozen(task, flow)) { - return { - task, - roleInfo: { - name: flow.shipped?.roleName || flow.binding.roleName || flow.role.name, - rid: flow.shipped?.roleId || flow.binding.roleId || flow.role.rid, - rawInfo: flow.role.rawInfo, - }, - flow, - } - } - - if (!flow.binding.vnId || !flow.binding.vnKey) { - throw createHttpError('当前任务还没有可查询的绑定角色信息,请先准备绑定资源', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_bind_info_context', - }) - } - - if (flow.binding.bindUrl) { - const probed = await probeKuaishouCloudTaskBindUrl(task, { - source: options.source || 'system_role_refresh_bind_url_probe', - actor, - force: options.forceProbe === true, - }) - const probedFlow = normalizeKuaishouCloudFlow( - parseTaskContext(probed.task).kuaishouCloudFulfillment, - ) - if (probed.probe?.expired) { - return { - task: probed.task, - roleInfo: normalizeKuaishouCloudRoleInfo(null), - flow: probedFlow, - } - } - - if ( - (probed.probe === null || probed.probe?.valid) && - (probedFlow.binding.roleName || probedFlow.binding.roleId) - ) { - return { - task: probed.task, - roleInfo: { - name: probedFlow.binding.roleName, - rid: probedFlow.binding.roleId, - rawInfo: probedFlow.role.rawInfo, - }, - flow: probedFlow, - } - } - } - - const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ - flow.binding.resolvedSourceKey, - ...flow.binding.cloudSourceKeys, - ]) - const bindInfoResult = await getCloudtentaclesBindInfo({ - ...cloudContext, - key: flow.binding.vnKey, - id: flow.binding.vnId, - }) - - const bindInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo) - const roleState = buildRoleStateFromCurrentInfo({ - flow, - roleInfo: bindInfo, - now, - emptyMessage: '当前还没有查询到角色信息,请完成绑定后稍等片刻再试', - }) - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - roleName: roleState.bindingRoleName, - roleId: roleState.bindingRoleId, - }, - role: roleState.role, - }, - } - - const updatedTask = await updateTask(task.id, { - // UID 主闸:有绑定角色即落库,默认角色仅诊断 - role_id: roleState.hasRoleInfo ? bindInfo.rid : '', - role_name: roleState.hasRoleInfo ? bindInfo.name : '', - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - if (options.recordEvent !== false) { - await createTaskEvent( - task.id, - 'kuaishou_cloud_role_info_refreshed', - { - source: String(options.source || 'system').trim() || 'system', - roleName: bindInfo.name, - roleId: bindInfo.rid, - vnId: flow.binding.vnId, - actor, - }, - now, - ) - } - - return { - task: updatedTask, - roleInfo: bindInfo, - flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment), - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/return-fulfillment.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/return-fulfillment.ts deleted file mode 100644 index 7552969f..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/return-fulfillment.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { TASK_STATUS, normalizeTaskStatus, type TaskStatus } from '../../../domain/task-status.js' -import { backCloudtentaclesVirtualNumber } from '../../platforms/cloudtentacles/virtual-number-service.js' -import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import { - isIndustryEVoucherTask, - isKuaishouCloudTask, - maskCode, - maskPhone, - normalizeKuaishouCloudFlow, - type JsonObject, -} from './domain.js' -import { resolvePersistedCloudtentaclesContextBySourceKeys } from './cloudtentacles-context.js' -import { normalizeActor, parseTaskContext } from './task-context.js' - -/** - * lewan 退还虚拟号。 - * - * - 默认(发货后 autoFinalize):退号 + 尝试行业电子凭证核销收尾 - * - `consumeIndustryVoucher: false`(admin 清理):只退虚拟号,避免占号;与核销无关 - */ -export async function returnKuaishouCloudFulfillmentTask(task: TaskRow, options: JsonObject = {}) { - if (!isKuaishouCloudTask(task)) { - throw createHttpError('当前任务不是 kuaishou-lewan 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_cloud_task_invalid', - }) - } - - const actor = normalizeActor(options.actor) - const now = nowIso() - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const cloudContext = resolvePersistedCloudtentaclesContextBySourceKeys([ - flow.binding.resolvedSourceKey, - ...flow.binding.cloudSourceKeys, - ]) - // admin 退号是清理虚拟号;只有发货收尾路径才顺带核销 - const allowConsumeIndustryVoucher = options.consumeIndustryVoucher !== false - - if (!flow.binding.vnId || !flow.binding.vnKey) { - throw createHttpError('当前任务缺少可退还的虚拟号信息', { - statusCode: 409, - errorCode: 'kuaishou_cloud_missing_return_context', - }) - } - - const taskStatus = normalizeTaskStatus(task.task_status) - if ( - flow.returnNumber.status === 'success' && - (taskStatus === TASK_STATUS.COMPLETED || taskStatus === TASK_STATUS.MANUAL_REVIEW) - ) { - return { task, flow } - } - - try { - await backCloudtentaclesVirtualNumber({ - ...cloudContext, - key: flow.binding.vnKey, - id: flow.binding.vnId, - }) - } catch (error) { - // 号码已被上游回收/手动退回/账号换 token 无权限:视为已释放,继续完成核销收尾 - if (isNumberAlreadyReleasedError(error)) { - await createTaskEvent( - task.id, - 'kuaishou_cloud_number_already_released', - { - source: String(options.source || 'system').trim() || 'system', - vnId: flow.binding.vnId, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), - errorMessage: error instanceof Error ? error.message : String(error || ''), - actor, - }, - now, - ) - } else { - throw error - } - } - - const ticketCode = String(flow.ticket.code || '').trim() - let consumeStatus - let consumeErrorMessage = '' - let consumedAt: string | null = null - let nextTaskStatus: TaskStatus = TASK_STATUS.COMPLETED - let nextResultCode = 'kuaishou_cloud_completed' - const consumeAlreadyCompleted = flow.consume.status === 'success' - const hasIndustryVoucher = hasKuaishouIndustryVoucherContext(taskContext) - const isIndustryTask = isIndustryEVoucherTask(task) - const shouldConsumeIndustryVoucher = - allowConsumeIndustryVoucher && (hasIndustryVoucher || isIndustryTask) - let nextResultMessage = shouldConsumeIndustryVoucher - ? 'cloudtentacles 发货、退号并完成电子凭证核销' - : allowConsumeIndustryVoucher - ? 'cloudtentacles 发货、退号并收口' - : '虚拟号已退还(清理占号,未执行核销)' - let industryVoucherContextPatch: JsonObject | null = null - - if (consumeAlreadyCompleted) { - consumeStatus = 'success' - consumedAt = (flow.consume.consumedAt as string | null) || now - if (!allowConsumeIndustryVoucher) { - nextResultMessage = '虚拟号已退还;电子凭证此前已核销' - } - } else if (shouldConsumeIndustryVoucher) { - const industryResult = hasIndustryVoucher - ? await consumeKuaishouIndustryVouchersForTask(task, { - source: String(options.source || 'system_auto_finalize').trim() || 'system_auto_finalize', - token: String( - isPlainObject(taskContext.kuaishouIndustryVoucher) - ? taskContext.kuaishouIndustryVoucher.token || '' - : '', - ).trim(), - consumeTime: Date.now(), - }) - : { - ok: true, - consumed: [] as Array>, - failed: [] as Array<{ errorMessage?: string }>, - } - - if (industryResult.ok) { - consumeStatus = 'success' - consumedAt = now - const consumedVoucher = industryResult.consumed[0] || null - if (consumedVoucher) { - industryVoucherContextPatch = { - oid: consumedVoucher.oid, - token: consumedVoucher.token, - eticketId: consumedVoucher.voucher_code, - voucherCode: consumedVoucher.voucher_code, - unitIndex: Number(consumedVoucher.unit_index || 0) || 0, - status: 'CONSUMED', - validStartTime: Number(consumedVoucher.valid_start_time || 0) || 0, - validEndTime: Number(consumedVoucher.valid_end_time || 0) || 0, - consumedAt: now, - consumeSerialNum: - consumedVoucher.consume_serial_num || `CONSUME-${consumedVoucher.voucher_code}`, - } - } - } else { - consumeStatus = 'failed' - consumeErrorMessage = - industryResult.failed[0]?.errorMessage || '电子凭证核销回调失败,请人工处理' - } - } else { - // cleanup 或无凭证:不碰核销 - consumeStatus = 'skipped' - consumeErrorMessage = '' - } - - if (consumeStatus !== 'success') { - if (shouldConsumeIndustryVoucher) { - nextTaskStatus = TASK_STATUS.MANUAL_REVIEW - nextResultCode = 'kuaishou_cloud_consume_failed' - nextResultMessage = consumeErrorMessage || '号码已退还,但电子凭证核销未完成,请人工处理' - } else { - nextResultCode = allowConsumeIndustryVoucher - ? 'kuaishou_cloud_completed_without_eticket_consume' - : 'kuaishou_cloud_number_returned_cleanup' - } - } - - const nextContext = { - ...taskContext, - ...(industryVoucherContextPatch - ? { - kuaishouIndustryVoucher: { - ...(isPlainObject(taskContext.kuaishouIndustryVoucher) - ? taskContext.kuaishouIndustryVoucher - : {}), - ...industryVoucherContextPatch, - }, - } - : {}), - kuaishouCloudFulfillment: { - ...flow, - returnNumber: { - ...flow.returnNumber, - status: 'success', - returnedAt: now, - returnedBy: actor, - }, - consume: { - ...flow.consume, - status: consumeStatus, - shopId: flow.consume.shopId, - shopName: flow.consume.shopName, - autoConsumeEnabled: flow.consume.autoConsumeEnabled === true, - consumedAt, - errorMessage: consumeErrorMessage, - }, - }, - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - delivery_status: 'delivered', - result_code: nextResultCode, - result_message: nextResultMessage, - redeemed_at: consumeStatus === 'failed' ? task.redeemed_at : now, - last_error: consumeErrorMessage, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_number_returned', - { - source: String(options.source || 'system').trim() || 'system', - vnId: flow.binding.vnId, - vnPhoneMasked: maskPhone(flow.binding.vnPhone), - actor, - }, - now, - ) - - const consumeEventType = consumeAlreadyCompleted - ? 'kuaishou_cloud_consume_already_completed' - : consumeStatus === 'success' - ? 'kuaishou_cloud_consumed' - : consumeStatus === 'skipped' - ? 'kuaishou_cloud_consume_skipped' - : 'kuaishou_cloud_consume_failed' - - await createTaskEvent( - task.id, - consumeEventType, - { - source: String(options.source || 'system').trim() || 'system', - ticketCodeMasked: maskCode(ticketCode), - shopId: flow.consume.shopId, - shopName: flow.consume.shopName, - consumeStatus, - consumeMode: shouldConsumeIndustryVoucher - ? 'industry_voucher' - : allowConsumeIndustryVoucher - ? 'legacy_writeoff_disabled' - : 'cleanup_skip_consume', - errorMessage: consumeErrorMessage, - actor, - }, - now, - ) - - return { - task: updatedTask, - flow: normalizeKuaishouCloudFlow(parseTaskContext(updatedTask).kuaishouCloudFulfillment), - } -} - -function hasKuaishouIndustryVoucherContext(value: JsonObject): boolean { - const voucher = isPlainObject(value.kuaishouIndustryVoucher) ? value.kuaishouIndustryVoucher : {} - const voucherCode = String(voucher.voucherCode || voucher.eticketId || '').trim() - const status = String(voucher.status || 'UNUSED') - .trim() - .toUpperCase() - const sendCallbackStatus = String(voucher.sendCallbackStatus || 'success') - .trim() - .toLowerCase() - return Boolean(voucherCode && status !== 'DESTROYED' && sendCallbackStatus === 'success') -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -/** 号码已被上游回收/手动退回/账号换 token 后无操作权限:退号视为已释放 */ -function isNumberAlreadyReleasedError(error: unknown) { - const message = error instanceof Error ? error.message : String(error || '') - return ( - message.includes('权限不足') || - message.includes('没有权限') || - message.includes('不存在') || - message.includes('已释放') || - message.includes('已回收') || - message.includes('已退还') - ) -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/role-state.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/role-state.ts deleted file mode 100644 index 5741431f..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/role-state.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * 角色状态与默认角色快照(诊断用)。 - * 履约主闸已改为 claimIdentity.expectedUid;默认角色不再作为 ready 硬门槛。 - */ -import { getCloudtentaclesBindInfo } from '../../platforms/cloudtentacles/virtual-number-service.js' -import { - hasKuaishouCloudDefaultRoleSnapshot, - isSameKuaishouCloudRoleIdentity, - normalizeKuaishouCloudRoleInfo, - type JsonObject, - type KuaishouCloudFlow, -} from './domain.js' - -export type KuaishouCloudDefaultRoleSnapshot = { - defaultName: string - defaultRid: string - defaultCapturedAt: string | null - defaultCaptureStatus: string - defaultErrorMessage: string -} - -export async function captureKuaishouCloudDefaultRoleSnapshot({ - cloudContext, - preparedBinding, - now, -}: { - cloudContext: JsonObject - preparedBinding: JsonObject - now: string -}): Promise { - try { - const bindInfoResult = await getCloudtentaclesBindInfo({ - ...cloudContext, - key: preparedBinding.vnKey, - id: preparedBinding.vnId, - }) - const roleInfo = normalizeKuaishouCloudRoleInfo(bindInfoResult.bindInfo) - const hasRole = Boolean(roleInfo.name || roleInfo.rid) - - return { - defaultName: roleInfo.name, - defaultRid: roleInfo.rid, - defaultCapturedAt: hasRole ? now : null, - defaultCaptureStatus: hasRole ? 'captured' : 'empty', - defaultErrorMessage: hasRole ? '' : '未获取到虚拟机默认角色信息,请稍后刷新', - } - } catch (error) { - return { - defaultName: '', - defaultRid: '', - defaultCapturedAt: null, - defaultCaptureStatus: 'failed', - defaultErrorMessage: - error instanceof Error ? error.message : '获取虚拟机默认角色信息失败,请稍后刷新', - } - } -} - -export function buildPendingRoleWithDefaultSnapshot(snapshot: KuaishouCloudDefaultRoleSnapshot) { - return { - status: 'pending', - name: '', - rid: '', - refreshedAt: null as null, - errorMessage: snapshot.defaultErrorMessage || '', - rawInfo: null as null, - defaultName: snapshot.defaultName, - defaultRid: snapshot.defaultRid, - defaultCapturedAt: snapshot.defaultCapturedAt, - defaultCaptureStatus: snapshot.defaultCaptureStatus, - defaultErrorMessage: snapshot.defaultErrorMessage, - isDefaultRole: false, - } -} - -export function buildRoleStateFromCurrentInfo({ - flow, - roleInfo, - now, - emptyMessage, -}: { - flow: KuaishouCloudFlow - roleInfo: { name: string; rid: string; rawInfo?: unknown } - now: string - emptyMessage: string -}) { - const hasRoleInfo = Boolean(roleInfo.name || roleInfo.rid) - const hasDefaultRole = hasKuaishouCloudDefaultRoleSnapshot(flow) - const isDefaultRole = - hasRoleInfo && - hasDefaultRole && - isSameKuaishouCloudRoleIdentity( - { name: roleInfo.name, rid: roleInfo.rid }, - { name: flow.role.defaultName, rid: flow.role.defaultRid }, - ) - - // UID 主闸:只要有绑定角色信息就视为 ready 并落库 rid; - // isDefaultRole 仅作诊断文案,不再清空 roleId。 - return { - hasRoleInfo, - isDefaultRole, - bindingRoleName: hasRoleInfo ? roleInfo.name : '', - bindingRoleId: hasRoleInfo ? roleInfo.rid : '', - role: { - ...flow.role, - status: hasRoleInfo ? 'ready' : 'pending', - name: hasRoleInfo ? roleInfo.name : '', - rid: hasRoleInfo ? roleInfo.rid : '', - refreshedAt: now, - errorMessage: hasRoleInfo - ? isDefaultRole - ? '当前绑定接近虚拟机默认角色,请确认已绑定自己的角色,并与填写 UID 一致' - : '' - : emptyMessage, - rawInfo: roleInfo.rawInfo || null, - isDefaultRole, - }, - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts deleted file mode 100644 index b6da5ac6..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-context.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { parseTaskContext as parseTaskContextValue } from '../../../utils/task-json.js' -import type { TaskRow } from '../../../types/repository/rows.js' -import type { JsonObject } from '../../../types/json.js' - -export function normalizeActor(actor: unknown) { - if (!actor || typeof actor !== 'object') { - return null - } - - const current = actor as JsonObject - const source = String(current.source || '').trim() - const userId = Number(current.userId || 0) || 0 - const username = String(current.username || '').trim() - const role = String(current.role || '').trim() - - if (!source && !userId && !username && !role) { - return null - } - - return { - source, - userId, - username, - role, - } -} - -export function parseTaskContext(task: Partial | null | undefined) { - return parseTaskContextValue(task) -} - -export function getTaskClaimExpiresAt(task: Partial | null | undefined) { - return task?.claim_expires_at || task?.primary_claim_expires_at || null -} - -export function isClaimExpired(expiredAt: unknown) { - if (!expiredAt) { - return false - } - - const timestamp = new Date( - expiredAt instanceof Date ? expiredAt : String(expiredAt || ''), - ).getTime() - return Number.isFinite(timestamp) && timestamp <= Date.now() -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.test.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.test.ts deleted file mode 100644 index 0f7bfe83..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import assert from 'node:assert/strict' -import test from 'node:test' - -import { buildDispatchStockItems } from './task-finalization.js' - -test('buildDispatchStockItems 只计算兑换阶段需要补买的库存缺口', () => { - const result = buildDispatchStockItems( - [ - { - cloudSkuId: 101, - cloudSkuName: '', - quantity: 3, - }, - ], - { - skuItems: [ - { - id: 101, - name: '套装-暗影哥特', - price: 88, - }, - ], - knapsackItems: [ - { - id: 101, - name: '套装-暗影哥特', - count: 1, - }, - ], - }, - ) - - assert.deepEqual(result, [ - { - cloudSkuId: 101, - cloudSkuName: '套装-暗影哥特', - quantity: 3, - requiredCount: 3, - knapsackCount: 1, - purchasedCount: 2, - }, - ]) -}) - -test('buildDispatchStockItems 背包足够时不需要补买', () => { - const result = buildDispatchStockItems( - [ - { - cloudSkuId: 202, - cloudSkuName: '套装-浪漫天命', - quantity: 1, - }, - ], - { - knapsackItems: [ - { - id: 202, - name: '套装-浪漫天命', - count: 5, - }, - ], - }, - ) - - assert.equal(result[0]?.knapsackCount, 5) - assert.equal(result[0]?.purchasedCount, 0) -}) diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts b/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts deleted file mode 100644 index 9474a4a0..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud/task-finalization.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * 兼容入口:原 task-finalization 已拆为 - * - dispatch-fulfillment(发货编排) - * - dispatch-stock(库存/采购/下发) - * - dispatch-failure(失败落库) - * - return-fulfillment(退号 + 核销收尾) - * - * 新代码请优先从 index.js 或对应子模块 import。 - */ -export { dispatchKuaishouCloudFulfillmentTask } from './dispatch-fulfillment.js' -export { returnKuaishouCloudFulfillmentTask } from './return-fulfillment.js' -export { buildDispatchStockItems } from './dispatch-stock.js' diff --git a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts deleted file mode 100644 index 43cb5c1c..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - buildFeifeiPlatformOrderNo, - resolveKuaishouFeifeiClaimUrl, - resolveKuaishouFeifeiH5UrlWithUid, - resolveKuaishouFeifeiPlatformAmount, -} from './index.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -test('resolveKuaishouFeifeiClaimUrl prefers feifei h5 url over local claim url', () => { - assert.equal( - resolveKuaishouFeifeiClaimUrl({ - claimUrl: 'https://ks.khhao.com/#/claim/local-token', - h5: { - entryUrl: 'https://feifei.example.com/h5/exchange', - rechargeUrl: 'https://feifei.example.com/h5/bind?code=abc', - }, - }), - 'https://feifei.example.com/h5/bind?code=abc', - ) -}) - -test('resolveKuaishouFeifeiClaimUrl does not expose local claim token fallback', () => { - assert.equal( - resolveKuaishouFeifeiClaimUrl({ - claimUrl: 'local-token', - h5: {}, - }), - '', - ) -}) - -test('resolveKuaishouFeifeiClaimUrl prefers direct h5 over historical short link url', () => { - assert.equal( - resolveKuaishouFeifeiClaimUrl({ - shortLink: { - code: 'AbCd1234', - url: 'https://ks.khhao.com/s/AbCd1234', - targetUrl: 'https://feifei.example.com/h5/bind?code=very-long', - }, - h5: { - rechargeUrl: 'https://feifei.example.com/h5/bind?code=very-long', - }, - }), - 'https://feifei.example.com/h5/bind?code=very-long', - ) -}) - -test('resolveKuaishouFeifeiH5UrlWithUid appends uid query', () => { - const next = resolveKuaishouFeifeiH5UrlWithUid( - { - h5: { - rechargeUrl: 'http://skin-exchange.yiquyou.icu/h5/bind?code=eyJpdi&product_name=demo', - }, - }, - '166909256', - ) - const url = new URL(next) - assert.equal(url.searchParams.get('uid'), '166909256') - assert.equal(url.searchParams.get('code'), 'eyJpdi') -}) - -test('buildFeifeiPlatformOrderNo prefers source order number', () => { - assert.equal( - buildFeifeiPlatformOrderNo( - createTask({ - platform_order_id: 'KS202607080001', - task_no: 'DT600a8bd23b8', - }), - ), - 'KS202607080001', - ) -}) - -test('buildFeifeiPlatformOrderNo falls back to task number', () => { - assert.equal( - buildFeifeiPlatformOrderNo( - createTask({ - platform_order_id: '', - task_no: 'DT600a8bd23b8', - }), - ), - 'DT600a8bd23b8', - ) -}) - -test('resolveKuaishouFeifeiPlatformAmount converts order fen amount to yuan', () => { - assert.equal( - resolveKuaishouFeifeiPlatformAmount({ - totalAmountFen: 3800, - quantity: 1, - }), - 38, - ) -}) - -test('resolveKuaishouFeifeiPlatformAmount splits amount by item quantity', () => { - assert.equal( - resolveKuaishouFeifeiPlatformAmount({ - totalAmountFen: 7600, - quantity: 2, - }), - 38, - ) -}) - -test('resolveKuaishouFeifeiPlatformAmount skips empty amount', () => { - assert.equal( - resolveKuaishouFeifeiPlatformAmount({ - totalAmountFen: 0, - quantity: 1, - }), - 0, - ) -}) - -function createTask(patch: Partial = {}): TaskRow { - return { - id: 123, - order_id: 456, - order_item_id: 789, - unit_index: 0, - platform_order_id: '', - profile_id: 0, - task_no: '', - executor_key: 'kuaishou_feifei', - task_status: 'pending', - delivery_status: 'pending', - result_code: '', - result_message: '', - automation_mode: 'auto', - requires_claim: true, - user_action_status: 'not_required', - attempt_count: 0, - runtime_session_id: '', - login_type: '', - nickname: '', - role_name: '', - role_id: '', - area: '', - partition_name: '', - claim_token: '', - primary_claim_token: '', - primary_claim_token_id: null, - primary_claim_token_status: '', - artifacts_json: '{}', - context_json: '{}', - screenshot_path: '', - last_error: '', - retry_count: 0, - created_at: '2026-07-08T00:00:00.000Z', - updated_at: '2026-07-08T00:00:00.000Z', - claimed_at: null, - role_confirmed_at: null, - redeemed_at: null, - ...patch, - } -} diff --git a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts b/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts deleted file mode 100644 index 31ae6801..00000000 --- a/apps/backend/src/services/fulfillment/kuaishou-feifei/index.ts +++ /dev/null @@ -1,397 +0,0 @@ -import { getOrderItemById } from '../../../repositories/order-item-repo.js' -import { getOrderById } from '../../../repositories/order-repo.js' -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { updateTask } from '../../../repositories/task-repo.js' -import { createHttpError } from '../../../utils/http.js' -import { logIntegration } from '../../../utils/logger.js' -import { parseTaskContext } from '../../../utils/task-json.js' -import { nowIso } from '../../../utils/time.js' -import { TASK_STATUS } from '../../../domain/task-status.js' -import { appendUidToUrl, getClaimIdentityFromContext } from '../../claim/claim-identity.js' -import type { JsonObject } from '../../../types/json.js' -import { - createKuaishouFeifeiOrder, - queryKuaishouFeifeiOrder, -} from '../../platforms/kuaishou-feifei/order-service.js' -import { consumeKuaishouIndustryVouchersForTask } from '../../platforms/kuaishou-industry/voucher-service.js' -import { buildKuaishouIndustryVoucherContext } from '../../platforms/kuaishou-industry/voucher-binding-service.js' -import { normalizeShortLinkPayload } from '../../short-links/short-link-service.js' -import type { TaskRow } from '../../../types/repository/rows.js' - -/** - * @deprecated 待移除,快手飞飞直充渠道已废弃,不再使用。 - */ -export function isKuaishouFeifeiTask(task: Partial | null | undefined) { - return String(task?.executor_key || '').trim() === 'kuaishou_feifei' -} - -export async function prepareKuaishouFeifeiTask(task: TaskRow) { - if (!isKuaishouFeifeiTask(task)) { - throw createHttpError('当前任务不是 kuaishou-feifei 履约任务', { - statusCode: 409, - errorCode: 'kuaishou_feifei_task_invalid', - }) - } - - const now = nowIso() - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei) - - const existingClaimUrl = resolveKuaishouFeifeiClaimUrl(flow) - if (flow.orderNo && existingClaimUrl) { - return updateTask(task.id, { - task_status: TASK_STATUS.LINK_GENERATED, - user_action_status: 'pending_claim', - context_json: JSON.stringify({ - ...taskContext, - kuaishouFeifei: flow, - }), - updated_at: now, - }) - } - - if (!flow.productCode) { - throw createHttpError('kuaishou-feifei 商品编码缺失,请检查商品规则配置', { - statusCode: 409, - errorCode: 'kuaishou_feifei_product_code_missing', - }) - } - - const platformOrderNo = flow.platformOrderNo || buildFeifeiPlatformOrderNo(task) - const platformAmount = await resolveKuaishouFeifeiTaskPlatformAmount(task) - const orderInput: Parameters[0] = { - platformOrderNo, - productCode: flow.productCode, - platformBuyNum: 1, - } - if (platformAmount > 0) { - orderInput.platformAmount = platformAmount - } - - const order = await createKuaishouFeifeiOrder(orderInput) - const feifeiClaimUrl = resolveKuaishouFeifeiOrderClaimUrl(order) - if (!feifeiClaimUrl) { - throw createHttpError('kuaishou-feifei 未返回领取链接', { - statusCode: 502, - errorCode: 'kuaishou_feifei_missing_claim_url', - }) - } - - const nextFlow = mergeKuaishouFeifeiOrder(flow, order, { - platformOrderNo, - claimUrl: feifeiClaimUrl, - syncedAt: now, - }) - const updatedTask = await updateTask(task.id, { - task_status: TASK_STATUS.LINK_GENERATED, - user_action_status: 'pending_claim', - result_code: 'kuaishou_feifei_order_created', - result_message: order.rechargeStatusLabel || 'kuaishou-feifei 订单已创建', - context_json: JSON.stringify({ - ...taskContext, - kuaishouFeifei: nextFlow, - }), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_feifei_order_created', - { - platformOrderNo, - orderNo: order.orderNo, - productCode: flow.productCode, - platformAmount, - rechargeStatus: order.rechargeStatus, - rechargeStatusLabel: order.rechargeStatusLabel, - rechargeUrl: order.h5.rechargeUrl, - }, - now, - ) - - return updatedTask -} - -export async function syncKuaishouFeifeiTaskStatus(task: TaskRow) { - if (!isKuaishouFeifeiTask(task)) { - return task - } - - const taskContext = parseTaskContext(task) - const flow = normalizeKuaishouFeifeiFlow(taskContext.kuaishouFeifei) - if (!flow.platformOrderNo && !flow.orderNo) { - return task - } - - const now = nowIso() - const order = await queryKuaishouFeifeiOrder({ - platformOrderNo: flow.platformOrderNo, - orderNo: flow.orderNo, - }) - let nextTaskStatus = task.task_status - let deliveryStatus = task.delivery_status - let redeemedAt = task.redeemed_at - let resultCode = task.result_code - let resultMessage = task.result_message - let lastError = task.last_error - const nextFlow = mergeKuaishouFeifeiOrder(flow, order, { - syncedAt: now, - claimUrl: resolveKuaishouFeifeiOrderClaimUrl(order) || resolveKuaishouFeifeiClaimUrl(flow), - }) - let nextIndustryVoucher = taskContext.kuaishouIndustryVoucher - - if (order.rechargeStatus === 30) { - const consumeResult = await consumeKuaishouIndustryVouchersForTask(task, { - source: 'kuaishou_feifei_completed', - consumeTime: Date.now(), - }) - const consumedVoucher = consumeResult.consumed[0] || null - - if (consumeResult.ok || consumeResult.vouchers.length === 0) { - nextTaskStatus = TASK_STATUS.COMPLETED - deliveryStatus = 'delivered' - redeemedAt = redeemedAt || now - resultCode = 'kuaishou_feifei_completed' - resultMessage = order.rechargeStatusLabel || 'kuaishou-feifei 履约成功' - lastError = '' - nextFlow.consumeStatus = consumeResult.vouchers.length > 0 ? 'success' : 'not_required' - if (consumedVoucher) { - nextIndustryVoucher = buildKuaishouIndustryVoucherContext( - consumedVoucher, - taskContext.kuaishouIndustryVoucher, - now, - ) - } - logIntegration('[kuaishou-feifei]', '飞飞履约完成,行业电子凭证核销处理完成', { - taskId: task.id, - platformOrderNo: nextFlow.platformOrderNo, - orderNo: nextFlow.orderNo, - voucherCount: consumeResult.vouchers.length, - consumedCount: consumeResult.consumed.length, - consumeStatus: nextFlow.consumeStatus, - }) - } else { - nextTaskStatus = TASK_STATUS.MANUAL_REVIEW - resultCode = 'kuaishou_feifei_industry_consume_failed' - resultMessage = consumeResult.failed[0]?.errorMessage || '电子凭证核销失败,请人工处理' - lastError = resultMessage - nextFlow.consumeStatus = 'failed' - logIntegration( - '[kuaishou-feifei]', - '飞飞履约完成,但行业电子凭证核销失败', - { - taskId: task.id, - platformOrderNo: nextFlow.platformOrderNo, - orderNo: nextFlow.orderNo, - voucherCount: consumeResult.vouchers.length, - failedCount: consumeResult.failed.length, - errorMessage: resultMessage, - }, - { level: 'warn' }, - ) - } - } else if ([40, 50, 60].includes(order.rechargeStatus)) { - nextTaskStatus = order.rechargeStatus === 60 ? TASK_STATUS.CLOSED : TASK_STATUS.MANUAL_REVIEW - resultCode = `kuaishou_feifei_status_${order.rechargeStatus}` - resultMessage = - order.rechargeResultMessage || order.rechargeStatusLabel || 'kuaishou-feifei 履约异常' - lastError = resultMessage - } - - const updatedTask = await updateTask(task.id, { - task_status: nextTaskStatus, - delivery_status: deliveryStatus, - redeemed_at: redeemedAt, - result_code: resultCode, - result_message: resultMessage, - last_error: lastError, - context_json: JSON.stringify({ - ...taskContext, - kuaishouFeifei: nextFlow, - kuaishouIndustryVoucher: nextIndustryVoucher, - }), - updated_at: now, - }) - - return updatedTask || task -} - -/** normalizeKuaishouFeifeiFlow 出口类型 */ -export type KuaishouFeifeiFlow = { - flowType: 'kuaishou_feifei' - productCode: string - productName: string - platformOrderNo: string - orderNo: string - rechargeStatus: number - rechargeStatusLabel: string - rechargeResultMessage: string - claimUrl: string - consumeStatus: string - shortLink: ReturnType - h5: { - entryUrl: string - rechargeUrl: string - } - lastSyncedAt: unknown - raw: unknown -} - -export function normalizeKuaishouFeifeiFlow(value: unknown): KuaishouFeifeiFlow { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - const h5 = source.h5 && typeof source.h5 === 'object' ? (source.h5 as JsonObject) : {} - - return { - flowType: 'kuaishou_feifei', - productCode: String(source.productCode || '').trim(), - productName: String(source.productName || '').trim(), - platformOrderNo: String(source.platformOrderNo || '').trim(), - orderNo: String(source.orderNo || '').trim(), - rechargeStatus: Number(source.rechargeStatus || 0) || 0, - rechargeStatusLabel: String(source.rechargeStatusLabel || '').trim(), - rechargeResultMessage: String(source.rechargeResultMessage || '').trim(), - claimUrl: String(source.claimUrl || '').trim(), - consumeStatus: String(source.consumeStatus || 'pending').trim(), - // 历史字段保留读取,新流程不再生成 shortLink - shortLink: normalizeShortLinkPayload(source.shortLink), - h5: { - entryUrl: String(h5.entryUrl || '').trim(), - rechargeUrl: String(h5.rechargeUrl || '').trim(), - }, - lastSyncedAt: source.lastSyncedAt || null, - raw: source.raw && typeof source.raw === 'object' ? source.raw : null, - } -} - -/** 平台原始 H5 链接(不拼 uid、不用短链)。 */ -export function resolveKuaishouFeifeiClaimUrl(value: unknown) { - const flow = normalizeKuaishouFeifeiFlow(value) - return resolveKuaishouFeifeiDirectClaimUrl(flow) -} - -/** 在已有 expectedUid 时返回拼好 uid 的 H5 链接。 */ -export function resolveKuaishouFeifeiH5UrlWithUid(value: unknown, expectedUid?: unknown) { - const directUrl = resolveKuaishouFeifeiClaimUrl(value) - if (!directUrl) { - return '' - } - - const uid = String(expectedUid || '').trim() - if (!uid) { - return directUrl - } - - return appendUidToUrl(directUrl, uid) -} - -export function resolveKuaishouFeifeiH5UrlWithTaskUid( - task: Partial | null | undefined, - flowValue?: unknown, -) { - const context = parseTaskContext(task) - const identity = getClaimIdentityFromContext(context) - const flow = flowValue === undefined ? context.kuaishouFeifei : flowValue - return resolveKuaishouFeifeiH5UrlWithUid(flow, identity.expectedUid) -} - -function mergeKuaishouFeifeiOrder( - flow: KuaishouFeifeiFlow, - order: Awaited>, - patch: { - platformOrderNo?: string - claimUrl?: string - syncedAt: string - }, -) { - return { - ...flow, - productCode: order.productCode || flow.productCode, - productName: order.productName || flow.productName, - platformOrderNo: order.platformOrderNo || patch.platformOrderNo || flow.platformOrderNo, - orderNo: order.orderNo || flow.orderNo, - rechargeStatus: order.rechargeStatus, - rechargeStatusLabel: order.rechargeStatusLabel, - rechargeResultMessage: order.rechargeResultMessage, - claimUrl: patch.claimUrl || flow.claimUrl, - shortLink: flow.shortLink, - h5: { - entryUrl: order.h5.entryUrl || flow.h5.entryUrl, - rechargeUrl: order.h5.rechargeUrl || flow.h5.rechargeUrl, - }, - lastSyncedAt: patch.syncedAt, - raw: order.raw, - } -} - -function resolveKuaishouFeifeiDirectClaimUrl(flow: KuaishouFeifeiFlow) { - const h5ClaimUrl = flow.h5.rechargeUrl || flow.h5.entryUrl - if (h5ClaimUrl) { - return h5ClaimUrl - } - - // 兼容历史 shortLink 中的原始 target - if (flow.shortLink?.targetUrl) { - return flow.shortLink.targetUrl - } - - return isLocalClaimUrl(flow.claimUrl) ? '' : flow.claimUrl -} - -function resolveKuaishouFeifeiOrderClaimUrl( - order: Awaited>, -) { - return String(order.h5.rechargeUrl || order.h5.entryUrl || '').trim() -} - -function isLocalClaimUrl(value: unknown) { - const url = String(value || '').trim() - return !/^https?:\/\//i.test(url) || url.includes('/#/claim/') -} - -export function buildFeifeiPlatformOrderNo(task: TaskRow) { - const platformOrderId = String(task.platform_order_id || '').trim() - if (platformOrderId && platformOrderId.length <= 64) { - return platformOrderId - } - - const taskNo = String(task.task_no || '').trim() - if (taskNo && taskNo.length <= 64) { - return taskNo - } - - return `OS-FEIFEI-${task.id}` -} - -export async function resolveKuaishouFeifeiTaskPlatformAmount(task: TaskRow) { - const [order, orderItem] = await Promise.all([ - getOrderById(task.order_id), - getOrderItemById(task.order_item_id), - ]) - - return resolveKuaishouFeifeiPlatformAmount({ - totalAmountFen: order?.total_amount, - quantity: orderItem?.quantity, - }) -} - -export function resolveKuaishouFeifeiPlatformAmount({ - totalAmountFen, - quantity, -}: { - totalAmountFen?: unknown - quantity?: unknown -} = {}) { - const totalFen = normalizePositiveFen(totalAmountFen) - if (totalFen <= 0) { - return 0 - } - - const normalizedQuantity = Math.max(1, Math.trunc(Number(quantity || 1)) || 1) - return Number((totalFen / normalizedQuantity / 100).toFixed(2)) -} - -function normalizePositiveFen(value: unknown) { - const parsed = Number(value) - return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0 -} diff --git a/apps/backend/src/services/fulfillment/planner.test.ts b/apps/backend/src/services/fulfillment/planner.test.ts index 1f801f95..191c480c 100644 --- a/apps/backend/src/services/fulfillment/planner.test.ts +++ b/apps/backend/src/services/fulfillment/planner.test.ts @@ -4,104 +4,27 @@ import test from 'node:test' import { planFulfillmentTaskForOrderItem } from './planner.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' -test('planFulfillmentTaskForOrderItem 为 cloudtentacles 商品生成 kuaishou-lewan 履约计划', async () => { +test('planFulfillmentTaskForOrderItem 为 affiliate-dash 商品生成履约计划', async () => { const plan = await planFulfillmentTaskForOrderItem({ order: createOrder(), item: createOrderItem({ - sku_code: '荣耀勋章礼包(30个)', - sku_name: '荣耀勋章礼包(30个)', - item_snapshot_json: { - cloudtentacles: { - matchMode: 'cloudtentacles_name', - normalizedProductName: '荣耀勋章礼包(30个)', - cloudSourceKeys: ['account-a'], - cloudSkuId: 74, - cloudSkuName: '荣耀勋章礼包(30个)', - }, - }, - }), - getProfileByKey: async (profileKey) => ({ - id: 9, - profile_key: profileKey, - name: 'kuaishou-lewan 履约', - executor_key: profileKey, - requires_claim: true, - auto_dispatch: true, - }), - }) - - assert.ok(plan) - assert.equal(plan.executorKey, 'kuaishou_ct_assisted') - assert.equal(plan.requiresClaim, false) - assert.equal(plan.autoDispatch, false) - assert.equal(plan.context.profileKey, 'kuaishou_ct_assisted') - assert.equal( - (plan.context.kuaishouCloudFulfillment as any).binding.resolvedSourceKey, - 'account-a', - ) - assert.equal((plan.context.kuaishouCloudFulfillment as any).binding.skuId, 74) - assert.equal(plan.context.kuaishouFeifei, null) -}) - -test('planFulfillmentTaskForOrderItem 为 feifei 商品生成 feifei 履约计划', async () => { - const plan = await planFulfillmentTaskForOrderItem({ - order: createOrder(), - item: createOrderItem({ - sku_code: 'FF-1001', - sku_name: '测试皮肤', - item_snapshot_json: { - kuaishouFeifei: { - productCode: 'FF-1001', - skuName: '测试皮肤', - matchMode: 'kuaishou_feifei_name', - }, - }, - }), - getProfileByKey: async (profileKey) => ({ - id: 10, - profile_key: profileKey, - name: 'kuaishou-feifei 履约', - executor_key: profileKey, - requires_claim: false, - auto_dispatch: true, - }), - }) - - assert.ok(plan) - assert.equal(plan.executorKey, 'kuaishou_feifei') - assert.equal(plan.requiresClaim, true) - assert.equal(plan.autoDispatch, false) - assert.equal(plan.context.kuaishouCloudFulfillment, null) - assert.equal((plan.context.kuaishouFeifei as any).productCode, 'FF-1001') - assert.equal((plan.context.kuaishouFeifei as any).productName, '测试皮肤') -}) - -test('planFulfillmentTaskForOrderItem 尊重履约路由选择 feifei', async () => { - const plan = await planFulfillmentTaskForOrderItem({ - order: createOrder(), - item: createOrderItem({ - sku_code: 'FF-ROUTE', - sku_name: '同时命中商品', + sku_code: 'AFF-1001', + sku_name: '幸运币90个', item_snapshot_json: { fulfillmentRoute: { - selectedExecutorKey: 'kuaishou_feifei', + selectedExecutorKey: 'affiliate_dash', }, - cloudtentacles: { - cloudSourceKeys: ['account-a'], - cloudSkuId: 74, - cloudSkuName: 'Cloud 商品', - }, - kuaishouFeifei: { - productCode: 'FF-ROUTE', - skuName: 'feifei 商品', - matchMode: 'kuaishou_feifei_name', + affiliateDash: { + sku: 'AFF-1001', + skuName: '幸运币90个', + matchMode: 'affiliate_dash_sku_mapping', }, }, }), getProfileByKey: async (profileKey) => ({ - id: 10, + id: 11, profile_key: profileKey, - name: '测试履约', + name: 'affiliate-dash 履约', executor_key: profileKey, requires_claim: false, auto_dispatch: true, @@ -109,9 +32,12 @@ test('planFulfillmentTaskForOrderItem 尊重履约路由选择 feifei', async () }) assert.ok(plan) - assert.equal(plan.executorKey, 'kuaishou_feifei') - assert.equal(plan.context.kuaishouCloudFulfillment, null) - assert.equal((plan.context.kuaishouFeifei as any).productCode, 'FF-ROUTE') + assert.equal(plan.executorKey, 'affiliate_dash') + assert.equal(plan.requiresClaim, true) + assert.equal(plan.autoDispatch, false) + assert.equal(plan.context.profileKey, 'affiliate_dash') + assert.equal((plan.context.affiliateDash as { sku?: string }).sku, 'AFF-1001') + assert.equal((plan.context.affiliateDash as { productName?: string }).productName, '幸运币90个') }) test('planFulfillmentTaskForOrderItem 支持履约路由转人工', async () => { @@ -140,8 +66,7 @@ test('planFulfillmentTaskForOrderItem 支持履约路由转人工', async () => assert.equal(plan.executorKey, 'manual_dispatch') assert.equal(plan.requiresClaim, false) assert.equal(plan.autoDispatch, false) - assert.equal(plan.context.kuaishouCloudFulfillment, null) - assert.equal(plan.context.kuaishouFeifei, null) + assert.equal(plan.context.affiliateDash, null) }) test('planFulfillmentTaskForOrderItem 未命中平台配置时返回 null', async () => { @@ -149,8 +74,7 @@ test('planFulfillmentTaskForOrderItem 未命中平台配置时返回 null', asyn order: createOrder(), item: createOrderItem({ item_snapshot_json: { - cloudtentacles: null, - kuaishouFeifei: null, + affiliateDash: null, }, }), getProfileByKey: async () => null, @@ -159,6 +83,32 @@ test('planFulfillmentTaskForOrderItem 未命中平台配置时返回 null', asyn assert.equal(plan, null) }) +test('planFulfillmentTaskForOrderItem affiliate 缺 sku 时返回 null', async () => { + const plan = await planFulfillmentTaskForOrderItem({ + order: createOrder(), + item: createOrderItem({ + item_snapshot_json: { + fulfillmentRoute: { + selectedExecutorKey: 'affiliate_dash', + }, + affiliateDash: { + skuName: '无 sku 商品', + }, + }, + }), + getProfileByKey: async (profileKey) => ({ + id: 11, + profile_key: profileKey, + name: 'affiliate-dash 履约', + executor_key: profileKey, + requires_claim: true, + auto_dispatch: false, + }), + }) + + assert.equal(plan, null) +}) + function createOrder(patch: Partial = {}): OrderRow { return { id: 1, diff --git a/apps/backend/src/services/fulfillment/planner.ts b/apps/backend/src/services/fulfillment/planner.ts index 2533b30b..04892798 100644 --- a/apps/backend/src/services/fulfillment/planner.ts +++ b/apps/backend/src/services/fulfillment/planner.ts @@ -4,8 +4,6 @@ import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { FULFILLMENT_EXECUTOR_KEYS, isAffiliateDashExecutor, - isKuaishouCloudExecutor, - isKuaishouFeifeiExecutor, } from './executors/types.js' export type FulfillmentBindingLike = { @@ -81,7 +79,6 @@ export async function planFulfillmentTaskForOrderItem({ } function buildFulfillmentTaskContext({ - order, item, profileKey, profileName, @@ -96,135 +93,13 @@ function buildFulfillmentTaskContext({ executorKey: string fulfillmentConfig: JsonObject }) { - const itemSnapshot = parseJsonObject(item.item_snapshot_json) - const cloudtentaclesConfig = parseJsonObject(fulfillmentConfig.cloudtentacles) - const kuaishouFeifeiConfig = parseJsonObject(fulfillmentConfig.kuaishouFeifei) const affiliateDashConfig = parseJsonObject(fulfillmentConfig.affiliateDash) - const kuaishouConsumeConfig = parseJsonObject(fulfillmentConfig.kuaishouConsume) - const kuaishouShopConfig = parseJsonObject(fulfillmentConfig.kuaishouShop) - const cloudSourceKeys = normalizeStringArray(cloudtentaclesConfig.cloudSourceKeys) - const resolvedCloudSourceKey = - cloudSourceKeys.length === 1 - ? String(cloudtentaclesConfig.resolvedSourceKey || cloudSourceKeys[0] || '').trim() - : '' - const deliveryItems = normalizeCloudDeliveryItems(cloudtentaclesConfig) - const primaryDeliveryItem = deliveryItems[0] || { - cloudSkuId: Number(cloudtentaclesConfig.skuId || 0) || 0, - cloudSkuName: String(cloudtentaclesConfig.skuName || '').trim(), - quantity: 1, - } return { profileKey, profileName, skuCode: item.sku_code, skuName: item.sku_name, - kuaishouCloudFulfillment: isKuaishouCloudExecutor(executorKey) - ? { - flowType: 'kuaishou_cloud_fulfillment', - configId: String(fulfillmentConfig.configId || '').trim(), - internalSkuCode: item.sku_code, - internalSkuName: item.sku_name, - deliveryItems, - ticket: { - code: '', - status: 'pending', - capturedAt: null, - capturedBy: null, - verifiedAt: null, - oid: '', - formToken: '', - leftCount: 0, - goodsTitle: '', - }, - binding: { - prepareStatus: 'pending', - cloudSourceKeys, - resolvedSourceKey: resolvedCloudSourceKey, - skuId: primaryDeliveryItem.cloudSkuId, - skuName: primaryDeliveryItem.cloudSkuName, - vnKey: '1', - vnId: 0, - vnPhone: '', - bindUrl: '', - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: null, - bindProbeStatus: '', - bindProbeMessage: '', - }, - role: { - status: 'pending', - name: '', - rid: '', - refreshedAt: null, - errorMessage: '', - rawInfo: null, - }, - purchase: { - autoBuyEnabled: cloudtentaclesConfig.autoBuyEnabled !== false, - minAssetReserve: Number(cloudtentaclesConfig.minAssetReserve || 0) || 0, - usedKnapsack: false, - purchaseTriggered: false, - assetBefore: 0, - assetAfter: 0, - purchaseAt: null, - }, - dispatch: { - status: 'pending', - dispatchAt: null, - dispatchBy: null, - sendType: 0, - note: '', - }, - returnNumber: { - status: 'pending', - returnedAt: null, - returnedBy: null, - autoReturnEnabled: cloudtentaclesConfig.autoReturnNumberAfterDispatch === true, - }, - consume: { - status: 'pending', - shopId: String( - kuaishouConsumeConfig.shopId || - kuaishouShopConfig.shopId || - itemSnapshot.shopId || - order.shop_id || - '', - ).trim(), - shopName: String( - kuaishouConsumeConfig.shopName || - kuaishouShopConfig.kshopName || - itemSnapshot.shopName || - order.shop_name || - '', - ).trim(), - autoConsumeEnabled: kuaishouConsumeConfig.autoConsumeAfterDispatch === true, - consumedAt: null, - errorMessage: '', - }, - notes: String(fulfillmentConfig.notes || '').trim(), - } - : null, - kuaishouFeifei: isKuaishouFeifeiExecutor(executorKey) - ? { - flowType: 'kuaishou_feifei', - productCode: String(kuaishouFeifeiConfig.productCode || '').trim(), - productName: String(kuaishouFeifeiConfig.productName || item.sku_name || '').trim(), - platformOrderNo: '', - orderNo: '', - rechargeStatus: 0, - rechargeStatusLabel: '', - rechargeResultMessage: '', - claimUrl: '', - consumeStatus: 'pending', - h5: { - entryUrl: '', - rechargeUrl: '', - }, - lastSyncedAt: null, - } - : null, affiliateDash: isAffiliateDashExecutor(executorKey) ? { flowType: 'affiliate_dash', @@ -258,135 +133,15 @@ async function resolveDynamicFulfillmentProfile( getProfileByKey: FulfillmentProfileResolver, ): Promise { const selectedExecutorKey = resolveSelectedFulfillmentExecutorKey(item) - if (selectedExecutorKey) { - if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) { - return resolveDynamicCloudtentaclesProfile(item, getProfileByKey) - } - - if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) { - return resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey) - } - - if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) { - return resolveDynamicAffiliateDashProfile(item, getProfileByKey) - } - - if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { - return resolveDynamicManualDispatchProfile(item, getProfileByKey) - } + if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) { + return resolveDynamicAffiliateDashProfile(item, getProfileByKey) } - return ( - (await resolveDynamicCloudtentaclesProfile(item, getProfileByKey)) || - (await resolveDynamicKuaishouFeifeiProfile(item, getProfileByKey)) || - (await resolveDynamicAffiliateDashProfile(item, getProfileByKey)) - ) -} - -async function resolveDynamicCloudtentaclesProfile( - item: OrderItemRow, - getProfileByKey: FulfillmentProfileResolver, -): Promise { - const snapshot = parseJsonObject(item.item_snapshot_json) - const cloudtentacles = parseJsonObject(snapshot.cloudtentacles) - const cloudSourceKeys = normalizeStringArray(cloudtentacles.cloudSourceKeys) - const deliveryItems = normalizeCloudDeliveryItems({ - deliveryItems: cloudtentacles.deliveryItems, - skuId: cloudtentacles.cloudSkuId, - skuName: cloudtentacles.cloudSkuName || item.sku_name, - }) - const primaryDeliveryItem = deliveryItems[0] || { - cloudSkuId: 0, - cloudSkuName: '', - quantity: 1, + if (selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) { + return resolveDynamicManualDispatchProfile(item, getProfileByKey) } - if ( - !primaryDeliveryItem.cloudSkuId || - !primaryDeliveryItem.cloudSkuName || - cloudSourceKeys.length === 0 - ) { - return null - } - - const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD) - if (!profile) { - return null - } - - return { - ...profile, - profile_id: Number(profile.id || profile.profile_id || 0), - profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, - profile_name: String(profile.profile_name || profile.name || 'kuaishou-lewan 履约').trim(), - executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, - requires_claim: false, - auto_dispatch: false, - config_json: { - flowType: 'kuaishou_cloud_fulfillment', - configId: `${String(cloudtentacles.matchMode || 'cloudtentacles_name').trim()}:${String(cloudtentacles.normalizedProductName || primaryDeliveryItem.cloudSkuId).trim()}`, - cloudtentacles: { - cloudSourceKeys, - skuId: primaryDeliveryItem.cloudSkuId, - skuName: primaryDeliveryItem.cloudSkuName, - resolvedSourceKey: - cloudSourceKeys.length === 1 - ? String(cloudtentacles.resolvedSourceKey || cloudSourceKeys[0] || '').trim() - : '', - deliveryItems, - vnKey: '1', - autoBuyEnabled: true, - minAssetReserve: 0, - autoReturnNumberAfterDispatch: true, - }, - kuaishouConsume: { - shopId: String(snapshot.shopId || '').trim(), - shopName: String(snapshot.shopName || '').trim(), - autoConsumeAfterDispatch: false, - }, - notes: - cloudtentacles.matchMode === 'cloudtentacles_override' - ? '91卡券商品名命中 cloudtentacles 覆盖规则' - : '91卡券商品名自动匹配 cloudtentacles 商品', - }, - } -} - -async function resolveDynamicKuaishouFeifeiProfile( - item: OrderItemRow, - getProfileByKey: FulfillmentProfileResolver, -): Promise { - const snapshot = parseJsonObject(item.item_snapshot_json) - const feifei = parseJsonObject(snapshot.kuaishouFeifei) - const productCode = String(feifei.productCode || '').trim() - if (!productCode) { - return null - } - - const profile = await getProfileByKey(FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI) - if (!profile) { - return null - } - - return { - ...profile, - profile_id: Number(profile.id || profile.profile_id || 0), - profile_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, - profile_name: String(profile.profile_name || profile.name || 'kuaishou-feifei 履约').trim(), - executor_key: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, - requires_claim: true, - auto_dispatch: false, - config_json: { - flowType: 'kuaishou_feifei', - configId: `kuaishou_feifei:${productCode}`, - kuaishouFeifei: { - productCode, - productName: String(feifei.skuName || feifei.productName || item.sku_name || '').trim(), - matchMode: String(feifei.matchMode || 'kuaishou_feifei_name').trim(), - }, - notes: '91卡券商品名自动匹配 kuaishou-feifei 商品映射', - }, - } + return resolveDynamicAffiliateDashProfile(item, getProfileByKey) } async function resolveDynamicAffiliateDashProfile( @@ -482,76 +237,3 @@ function parseJsonObject(value: unknown): JsonObject { return {} } } - -function normalizeStringArray(value: unknown): string[] { - if (!Array.isArray(value)) { - return [] - } - - return Array.from(new Set(value.map((item) => String(item || '').trim()).filter(Boolean))) -} - -function normalizeCloudDeliveryItems(value: JsonObject): Array<{ - cloudSkuId: number - cloudSkuName: string - quantity: number -}> { - const rawItems = Array.isArray(value.deliveryItems) ? value.deliveryItems : [] - const items = rawItems - .map((item) => normalizeCloudDeliveryItem(item)) - .filter((item): item is { cloudSkuId: number; cloudSkuName: string; quantity: number } => - Boolean(item), - ) - - if (items.length > 0) { - return mergeCloudDeliveryItems(items) - } - - const cloudSkuId = Number(value.skuId || 0) || 0 - if (!cloudSkuId) { - return [] - } - - return [ - { - cloudSkuId, - cloudSkuName: String(value.skuName || '').trim(), - quantity: 1, - }, - ] -} - -function normalizeCloudDeliveryItem(value: unknown) { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - const cloudSkuId = Number(source.cloudSkuId || source.skuId || 0) || 0 - if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0) { - return null - } - - const quantity = Number(source.quantity || 1) || 1 - return { - cloudSkuId, - cloudSkuName: String(source.cloudSkuName || source.skuName || '').trim(), - quantity: Number.isInteger(quantity) && quantity > 0 ? quantity : 1, - } -} - -function mergeCloudDeliveryItems( - items: Array<{ cloudSkuId: number; cloudSkuName: string; quantity: number }>, -) { - const merged = new Map() - - for (const item of items) { - const existing = merged.get(item.cloudSkuId) - if (existing) { - existing.quantity += item.quantity - existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName - continue - } - - merged.set(item.cloudSkuId, { ...item }) - } - - return Array.from(merged.values()) -} diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 3060d954..675b236d 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -1,17 +1,9 @@ -import { - normalizeCloudtentaclesMatchName, - resolveCloudtentaclesSkuByProductName, - type CloudtentaclesNameMatchResult, -} from '../order/cloudtentacles-name-match-service.js' -import { - resolveKuaishouFeifeiProductByName, - type KuaishouFeifeiProductMatch, -} from '../platforms/kuaishou-feifei/product-rule-service.js' import { resolveFulfillmentRoute, type FulfillmentRouteResult } from './routing-config-service.js' import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js' import { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js' import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js' import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js' +import { normalizeProductMatchName } from '../order/product-match-utils.js' import { logIntegration } from '../../utils/logger.js' export type FulfillmentItem = { @@ -50,8 +42,6 @@ type FulfillmentItemCandidate = { externalSkuName: string externalSkuNameNormalized: string resolvedSkuCode: string - cloudtentaclesNameMatch: CloudtentaclesNameMatchResult | null - kuaishouFeifeiMatch: KuaishouFeifeiProductMatch | null affiliateDashMatch: AffiliateDashSkuMatch | null fulfillmentRoute: FulfillmentRouteResult | null isConfigured: boolean @@ -83,28 +73,17 @@ export async function resolveOrderItemForFulfillment({ externalSkuCode, externalSkuName, externalSkuNameNormalized, - cloudtentaclesNameMatch, - kuaishouFeifeiMatch, affiliateDashMatch, fulfillmentRoute, } = candidate const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || '' const resolvedSkuCode = pickFirstNonEmpty([ - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI - ? kuaishouFeifeiMatch?.productCode - : '', selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH ? affiliateDashMatch?.sku : '', externalSkuCode, externalItemId, ]) const resolvedSkuName = pickFirstNonEmpty([ - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD - ? cloudtentaclesNameMatch?.cloudSkuName - : '', - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI - ? kuaishouFeifeiMatch?.skuName - : '', selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH ? affiliateDashMatch?.sku : '', item.skuName, externalSkuName, @@ -118,38 +97,10 @@ export async function resolveOrderItemForFulfillment({ externalSkuNameNormalized, resolvedSkuCode, matchMode: - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD - ? cloudtentaclesNameMatch?.matchMode || '' - : selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI - ? kuaishouFeifeiMatch?.matchMode || '' - : selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH - ? affiliateDashMatch?.matchMode || '' - : '', + selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH + ? affiliateDashMatch?.matchMode || '' + : '', fulfillmentRoute, - 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, - deliveryItems: cloudtentaclesNameMatch.deliveryItems, - skuSnapshot: cloudtentaclesNameMatch.skuSnapshot, - } - : null, - kuaishouFeifei: kuaishouFeifeiMatch - ? { - matchMode: kuaishouFeifeiMatch.matchMode, - productName: kuaishouFeifeiMatch.productName, - normalizedProductName: kuaishouFeifeiMatch.normalizedProductName, - productCode: kuaishouFeifeiMatch.productCode, - skuName: kuaishouFeifeiMatch.skuName, - } - : null, affiliateDash: affiliateDashMatch ? { matchMode: affiliateDashMatch.matchMode, @@ -193,7 +144,7 @@ export async function hasConfiguredOrderItems({ } export function normalizeProductName(value: unknown): string { - return normalizeCloudtentaclesMatchName(value) + return normalizeProductMatchName(value) } function pickFirstNonEmpty(values: unknown[]): string { @@ -218,10 +169,7 @@ async function resolveConfiguredItemCandidate({ const externalSkuNameNormalized = normalizeProductName(externalSkuName) if (isOpen91KuaishouOrder(provider, platform)) { - const cloudtentaclesNameMatch = await resolveCloudtentaclesSkuByProductName(externalSkuName) - const kuaishouFeifeiMatch = resolveKuaishouFeifeiProductByName(externalSkuName) const affiliateDashMatch = await resolveAffiliateDashSkuByProductNo(item) - // 指定匹配(显式映射)命中时优先走 affiliate_dash(可配置关闭,默认开) const affiliateDashConfig = getAffiliateDashConfig() const preferredExecutorKey = affiliateDashMatch && affiliateDashConfig.preferredMatchEnabled !== false @@ -231,16 +179,6 @@ async function resolveConfiguredItemCandidate({ productName: externalSkuName, preferredExecutorKey, candidates: [ - { - executorKey: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, - available: Boolean(cloudtentaclesNameMatch), - reason: cloudtentaclesNameMatch ? '' : '未命中 cloudtentacles 商品或账号不可用', - }, - { - executorKey: FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, - available: Boolean(kuaishouFeifeiMatch), - reason: kuaishouFeifeiMatch ? '' : '未命中 kuaishou-feifei 商品规则', - }, { executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, available: Boolean(affiliateDashMatch), @@ -250,12 +188,6 @@ async function resolveConfiguredItemCandidate({ }) const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey const resolvedSkuCode = pickFirstNonEmpty([ - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD - ? cloudtentaclesNameMatch?.cloudSkuName - : '', - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI - ? kuaishouFeifeiMatch?.productCode - : '', selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH ? affiliateDashMatch?.sku : '', @@ -269,8 +201,6 @@ async function resolveConfiguredItemCandidate({ externalSkuName, externalSkuNameNormalized, resolvedSkuCode, - cloudtentaclesNameMatch, - kuaishouFeifeiMatch, affiliateDashMatch, fulfillmentRoute, isConfigured: Boolean(selectedExecutorKey), @@ -285,8 +215,6 @@ async function resolveConfiguredItemCandidate({ externalSkuName, externalSkuNameNormalized, resolvedSkuCode, - cloudtentaclesNameMatch: null, - kuaishouFeifeiMatch: null, affiliateDashMatch: null, fulfillmentRoute: null, isConfigured: false, @@ -311,7 +239,6 @@ async function resolveAffiliateDashSkuByProductNo( return null } - // ① 指定映射(运营显式配置) const mappedSku = matchAffiliateDashSku(config.skuMapping, String(productNo)) if (mappedSku) { return { @@ -322,7 +249,6 @@ async function resolveAffiliateDashSkuByProductNo( } } - // ② 透传:91 商品编码 == affiliate_dash 商品 sku 时直接命中(需商品列表校验,缓存 5 分钟) const rawProductNo = String(productNo).trim() const skuSet = await getAffiliateDashSkuSet() if (matchAffiliateDashSkuPassthrough(rawProductNo, skuSet)) { @@ -366,8 +292,6 @@ async function getAffiliateDashSkuSet(): Promise> { affiliateDashSkuCache = { skus, fetchedAt: now } return skus } catch (error) { - // 拉取失败:返回上次缓存(即使过期)或空集合,匹配 miss 走其他通道,不阻塞下单; - // warn 日志便于线上排查(密钥未配/平台不可用都会导致透传降级为 miss)。 logIntegration( '[affiliate-dash]', 'affiliate-dash 商品列表拉取失败,透传降级为未命中', diff --git a/apps/backend/src/services/fulfillment/routing-config-service.test.ts b/apps/backend/src/services/fulfillment/routing-config-service.test.ts index 2ad4f45f..8330b2c4 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.test.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.test.ts @@ -6,25 +6,20 @@ import { resolveFulfillmentRoute, } from './routing-config-service.js' -test('normalizeFulfillmentRoutingConfig adds channels missing from historical priority configs', () => { +test('normalizeFulfillmentRoutingConfig drops retired executors and keeps affiliate_dash default', () => { const config = normalizeFulfillmentRoutingConfig({ defaultExecutorPriority: ['kuaishou_ct_assisted', 'kuaishou_feifei'], }) - assert.deepEqual(config.defaultExecutorPriority, [ - 'kuaishou_ct_assisted', - 'kuaishou_feifei', - 'affiliate_dash', - ]) + assert.deepEqual(config.defaultExecutorPriority, ['affiliate_dash']) + assert.deepEqual(Object.keys(config.executors).sort(), ['affiliate_dash', 'manual_dispatch']) + assert.equal(config.unmatchedExecutorKey, 'manual_dispatch') }) test('resolveFulfillmentRoute 总开关关闭后不再回退默认配置,返回未选中', () => { const result = resolveFulfillmentRoute({ productName: '测试商品', - candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: true }, - ], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config: normalizeFulfillmentRoutingConfig({ enabled: false, }), @@ -34,72 +29,59 @@ test('resolveFulfillmentRoute 总开关关闭后不再回退默认配置,返 assert.equal(result.reason, '履约路由未启用,自动发货已停止') }) -test('resolveFulfillmentRoute 默认保持 kuaishou-lewan 优先', () => { +test('resolveFulfillmentRoute 默认选择 affiliate_dash', () => { + const result = resolveFulfillmentRoute({ + productName: '测试商品', + candidates: [{ executorKey: 'affiliate_dash', available: true }], + config: normalizeFulfillmentRoutingConfig({}), + }) + + assert.equal(result.selectedExecutorKey, 'affiliate_dash') + assert.equal(result.reason, '按全局优先级命中') +}) + +test('resolveFulfillmentRoute affiliate 不可用时回退人工', () => { const result = resolveFulfillmentRoute({ productName: '测试商品', candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: true }, + { executorKey: 'affiliate_dash', available: false, reason: '未命中 affiliate-dash sku 映射' }, ], config: normalizeFulfillmentRoutingConfig({}), }) - assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted') - assert.equal(result.reason, '按全局优先级命中') -}) - -test('resolveFulfillmentRoute 全局关闭 kuaishou-lewan 后选择 feifei', () => { - const result = resolveFulfillmentRoute({ - productName: '测试商品', - candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: true }, - ], - config: normalizeFulfillmentRoutingConfig({ - executors: { - kuaishou_ct_assisted: { enabled: false }, - kuaishou_feifei: { enabled: true }, - }, - }), - }) - - assert.equal(result.selectedExecutorKey, 'kuaishou_feifei') + assert.equal(result.selectedExecutorKey, 'manual_dispatch') + assert.equal(result.reason, '未命中自动履约通道,进入人工履约') assert.deepEqual(result.skipped[0], { - executorKey: 'kuaishou_ct_assisted', - reason: '履约通道已停用', + executorKey: 'affiliate_dash', + reason: '未命中 affiliate-dash sku 映射', }) }) -test('resolveFulfillmentRoute 商品规则可以强制走 feifei', () => { +test('resolveFulfillmentRoute 商品规则可以强制走 affiliate_dash', () => { const result = resolveFulfillmentRoute({ productName: '荣耀套装', - candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: true }, - ], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config: normalizeFulfillmentRoutingConfig({ rules: [ { enabled: true, productName: '荣耀套装', - executorKey: 'kuaishou_feifei', + executorKey: 'affiliate_dash', priority: 200, }, ], }), }) - assert.equal(result.selectedExecutorKey, 'kuaishou_feifei') - assert.equal(result.selectedRuleId, '荣耀套装:kuaishou_feifei') + assert.equal(result.selectedExecutorKey, 'affiliate_dash') + assert.equal(result.selectedRuleId, '荣耀套装:affiliate_dash') assert.equal(result.reason, '命中商品路由规则') }) -test('resolveFulfillmentRoute 商品规则目标不可用时回退全局优先级', () => { +test('resolveFulfillmentRoute 商品规则目标不可用时不再静默改通道', () => { const result = resolveFulfillmentRoute({ productName: '套装-糯粉咩咩', candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: true }, { executorKey: 'affiliate_dash', available: false, reason: '未命中 affiliate-dash sku 映射' }, ], config: normalizeFulfillmentRoutingConfig({ @@ -114,8 +96,8 @@ test('resolveFulfillmentRoute 商品规则目标不可用时回退全局优先 }), }) - assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted') - assert.equal(result.reason, '商品路由规则目标不可用,按全局优先级回退') + assert.equal(result.selectedExecutorKey, '') + assert.equal(result.reason, '商品路由规则目标通道不可用,且未命中其他履约通道') assert.deepEqual(result.skipped[0], { executorKey: 'affiliate_dash', reason: '未命中 affiliate-dash sku 映射', @@ -125,10 +107,7 @@ test('resolveFulfillmentRoute 商品规则目标不可用时回退全局优先 test('resolveFulfillmentRoute 商品规则可以强制转人工', () => { const result = resolveFulfillmentRoute({ productName: '临时停用商品', - candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: false }, - ], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config: normalizeFulfillmentRoutingConfig({ rules: [ { @@ -150,11 +129,7 @@ test('resolveFulfillmentRoute 指定匹配(显式映射)最高优先', () => const result = resolveFulfillmentRoute({ productName: '幸运币90个', preferredExecutorKey: 'affiliate_dash', - candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true, reason: 'cloud 名字命中' }, - { executorKey: 'kuaishou_feifei', available: true, reason: 'feifei 名字命中' }, - { executorKey: 'affiliate_dash', available: true }, - ], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config: normalizeFulfillmentRoutingConfig({}), }) @@ -162,15 +137,11 @@ test('resolveFulfillmentRoute 指定匹配(显式映射)最高优先', () => assert.equal(result.reason, '命中指定匹配(显式映射),优先选择') }) -test('resolveFulfillmentRoute 指定匹配命中但通道停用时回退全局优先级', () => { +test('resolveFulfillmentRoute 指定匹配命中但通道停用时回退人工', () => { const result = resolveFulfillmentRoute({ productName: '幸运币90个', preferredExecutorKey: 'affiliate_dash', - candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: true }, - { executorKey: 'affiliate_dash', available: true }, - ], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config: normalizeFulfillmentRoutingConfig({ executors: { affiliate_dash: { enabled: false }, @@ -178,23 +149,21 @@ test('resolveFulfillmentRoute 指定匹配命中但通道停用时回退全局 }), }) - assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted') + assert.equal(result.selectedExecutorKey, 'manual_dispatch') assert.ok(result.skipped.some((item) => item.executorKey === 'affiliate_dash')) }) -test('resolveFulfillmentRoute 指定匹配命中但商品不可用时回退全局优先级', () => { +test('resolveFulfillmentRoute 指定匹配命中但商品不可用时回退人工', () => { const result = resolveFulfillmentRoute({ productName: '幸运币90个', preferredExecutorKey: 'affiliate_dash', candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: false }, { executorKey: 'affiliate_dash', available: false, reason: '未命中 sku 映射' }, ], config: normalizeFulfillmentRoutingConfig({}), }) - assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted') + assert.equal(result.selectedExecutorKey, 'manual_dispatch') assert.ok(result.skipped.some((item) => item.executorKey === 'affiliate_dash')) }) @@ -202,14 +171,10 @@ test('resolveFulfillmentRoute 无指定匹配时按全局优先级选择', () => const result = resolveFulfillmentRoute({ productName: '幸运币90个', preferredExecutorKey: '', - candidates: [ - { executorKey: 'kuaishou_ct_assisted', available: true }, - { executorKey: 'kuaishou_feifei', available: false }, - { executorKey: 'affiliate_dash', available: true }, - ], + candidates: [{ executorKey: 'affiliate_dash', available: true }], config: normalizeFulfillmentRoutingConfig({}), }) - assert.equal(result.selectedExecutorKey, 'kuaishou_ct_assisted') + assert.equal(result.selectedExecutorKey, 'affiliate_dash') assert.equal(result.reason, '按全局优先级命中') }) diff --git a/apps/backend/src/services/fulfillment/routing-config-service.ts b/apps/backend/src/services/fulfillment/routing-config-service.ts index 64462e62..3c7584bf 100644 --- a/apps/backend/src/services/fulfillment/routing-config-service.ts +++ b/apps/backend/src/services/fulfillment/routing-config-service.ts @@ -3,7 +3,7 @@ import path from 'node:path' import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' import { PROJECT_ROOT } from '../../config/runtime.js' import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' -import { normalizeCloudtentaclesMatchName } from '../order/cloudtentacles-match-utils.js' +import { normalizeProductMatchName } from '../order/product-match-utils.js' import type { JsonObject } from '../../types/json.js' import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js' @@ -62,17 +62,11 @@ const FULFILLMENT_ROUTING_CONFIG_FILE_PATH = path.join( ) const ROUTABLE_EXECUTOR_KEYS: string[] = [ - FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, - FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, ] -const DEFAULT_EXECUTOR_PRIORITY = [ - FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_CLOUD, - FULFILLMENT_EXECUTOR_KEYS.KUAISHOU_FEIFEI, - FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, -] +const DEFAULT_EXECUTOR_PRIORITY = [FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH] export function getFulfillmentRoutingConfigFilePath() { return FULFILLMENT_ROUTING_CONFIG_FILE_PATH @@ -103,16 +97,19 @@ export function normalizeFulfillmentRoutingConfig(rawValue: unknown): Fulfillmen const defaultExecutorPriority = normalizeExecutorPriority( source.defaultExecutorPriority || source.executorPriority, ) + const hasExplicitUnmatched = Object.prototype.hasOwnProperty.call(source, 'unmatchedExecutorKey') const unmatchedExecutorKey = normalizeExecutorKey(source.unmatchedExecutorKey) return { enabled: source.enabled !== false, defaultExecutorPriority: defaultExecutorPriority.length > 0 ? defaultExecutorPriority : [...DEFAULT_EXECUTOR_PRIORITY], - unmatchedExecutorKey: - unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH + // 缺省回落人工;显式传非 manual 值则关闭未命中回落。 + unmatchedExecutorKey: hasExplicitUnmatched + ? unmatchedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH ? unmatchedExecutorKey - : '', + : '' + : FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, executors: ROUTABLE_EXECUTOR_KEYS.reduce>( (result, executorKey) => { const executorConfig = isPlainObject(sourceExecutors[executorKey]) @@ -377,7 +374,7 @@ function createEmptyRouteResult( } function findMatchedRoutingRule(productName: unknown, rules: FulfillmentRoutingRule[]) { - const normalizedProductName = normalizeCloudtentaclesMatchName(productName) + const normalizedProductName = normalizeProductMatchName(productName) if (!normalizedProductName) { return null } @@ -401,8 +398,8 @@ function normalizeFulfillmentRoutingRule(rawValue: unknown): FulfillmentRoutingR const source = isPlainObject(rawValue) ? rawValue : {} const productName = String(source.productName || source.name || '').trim() const normalizedProductName = - normalizeCloudtentaclesMatchName(source.normalizedProductName) || - normalizeCloudtentaclesMatchName(productName) + normalizeProductMatchName(source.normalizedProductName) || + normalizeProductMatchName(productName) const executorKey = normalizeExecutorKey(source.executorKey) if (!productName || !normalizedProductName || !executorKey) { @@ -484,7 +481,7 @@ function createDefaultFulfillmentRoutingConfig(): FulfillmentRoutingConfig { return { enabled: true, defaultExecutorPriority: [...DEFAULT_EXECUTOR_PRIORITY], - unmatchedExecutorKey: '', + unmatchedExecutorKey: FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH, executors: ROUTABLE_EXECUTOR_KEYS.reduce>( (result, executorKey) => { result[executorKey] = { enabled: true } diff --git a/apps/backend/src/services/open-91/consume-on-deliver.ts b/apps/backend/src/services/open-91/consume-on-deliver.ts index 27fafb91..49de935f 100644 --- a/apps/backend/src/services/open-91/consume-on-deliver.ts +++ b/apps/backend/src/services/open-91/consume-on-deliver.ts @@ -5,16 +5,11 @@ * * 语义:核销 = 领取链接已交付(售出闭环),≠ 游戏内道具已到账。 * - * 交付后会 best-effort 自动准备 Cloud 绑定资源(取号/绑链),避免 - * 「已核销却停在待准备资源」只能靠后台手动点「准备资源」。 */ import { attachKuaishouIndustryVoucherToTask } from '../platforms/kuaishou-industry/voucher-binding-service.js' import { consumeKuaishouIndustryVoucher } from '../platforms/kuaishou-industry/voucher-service.js' -import { prepareFulfillmentBinding } from '../fulfillment/executors/registry.js' -import { normalizeKuaishouCloudFlow } from '../fulfillment/kuaishou-cloud/index.js' import { listKuaishouIndustryVouchersByOid } from '../../repositories/kuaishou-industry-voucher-repo.js' import { logIntegration, logWarn } from '../../utils/logger.js' -import { parseTaskContext } from '../../utils/task-json.js' import type { KuaishouIndustryVoucherRow, OrderRow, TaskRow } from '../../types/repository/rows.js' export const OPEN_91_DELIVER_CONSUME_SOURCE = 'open91_query_deliver' @@ -133,18 +128,8 @@ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: { continue } - const attachedTask = - (await attachKuaishouIndustryVoucherToTask(task, result.voucher, { - source, - })) || task - - // 核销后立刻补准备绑定资源;失败不阻断 cards(领取页仍会再兜底 prepare) - await ensureBindingPreparedAfterDeliver({ - task: attachedTask, + await attachKuaishouIndustryVoucherToTask(task, result.voucher, { source, - requestId: options.requestId || '', - orderId: options.order.id, - orderNo: oid, }) if (alreadyConsumed) { @@ -192,48 +177,3 @@ export async function consumeIndustryVouchersBeforeOpen91Deliver(options: { alreadyConsumedTaskIds, } } - -async function ensureBindingPreparedAfterDeliver(options: { - task: TaskRow - source: string - requestId: string - orderId: number - orderNo: string -}) { - const executorKey = String(options.task.executor_key || '').trim() - // lewan / 行业凭证任务才需要 Cloud 绑定资源 - if ( - executorKey !== 'kuaishou_ct_assisted' && - executorKey !== 'kuaishou-industry' && - !parseTaskContext(options.task).kuaishouCloudFulfillment - ) { - return - } - - const flow = normalizeKuaishouCloudFlow(parseTaskContext(options.task).kuaishouCloudFulfillment) - if (flow.binding.prepareStatus === 'ready' && String(flow.binding.bindUrl || '').trim()) { - return - } - - try { - await prepareFulfillmentBinding(options.task, { - source: `${options.source}_prepare_binding`, - actor: { source: options.source }, - }) - logIntegration('[open-91/query]', '91交付后自动准备绑定资源成功', { - requestId: options.requestId, - orderId: options.orderId, - orderNo: options.orderNo, - taskId: options.task.id, - source: options.source, - }) - } catch (error) { - logWarn('[open-91/query]', '91交付后自动准备绑定资源失败(不阻断 cards,领取页将再试)', { - requestId: options.requestId, - orderId: options.orderId, - orderNo: options.orderNo, - taskId: options.task.id, - error: error instanceof Error ? error.message : String(error || ''), - }) - } -} diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts deleted file mode 100644 index 7cad45b5..00000000 --- a/apps/backend/src/services/order/cloudtentacles-name-match-service.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -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') -}) - -test('resolveCloudtentaclesSkuByProductName 多账号匹配不提前固定账号', async () => { - const result = await resolveCloudtentaclesSkuByProductName('商品A', { - listCloudtentaclesSources: () => ({ - enabled: true, - sources: [ - { key: 'account-a', enabled: true }, - { key: 'account-b', enabled: true }, - ], - }), - getCloudtentaclesSessionStateByKey: (key) => ({ token: `token-${key}` }), - listCloudtentaclesSku: async () => ({ - items: [{ id: 28, name: '商品A', inventory: 10, price: 100 }], - }), - }) - - assert.deepEqual(result?.cloudSourceKeys, ['account-a', 'account-b']) - assert.equal(result?.resolvedSourceKey, '') -}) - -test('resolveCloudtentaclesSkuByProductName prefers override rule with multiple delivery items', async () => { - const result = await resolveCloudtentaclesSkuByProductName('套装1', { - 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: 3, - rawItems: [], - items: [ - { - id: 28, - name: '商品A', - inventory: 10, - price: 360, - }, - { - id: 29, - name: '商品B', - inventory: 20, - price: 888, - }, - { - id: 30, - name: '套装1', - inventory: 1, - price: 999, - }, - ], - }), - resolveCloudtentaclesOverrideRule: () => ({ - id: 'combo-1', - enabled: true, - productName: '套装1', - normalizedProductName: normalizeCloudtentaclesMatchName('套装1'), - sourceKey: '', - deliveryItems: [ - { - cloudSkuId: 28, - cloudSkuName: '商品A', - quantity: 1, - }, - { - cloudSkuId: 0, - cloudSkuName: '商品B', - quantity: 20, - }, - ], - notes: '', - }), - }) - - assert.equal(result?.matchMode, 'cloudtentacles_override') - assert.equal(result?.cloudSkuId, 28) - assert.equal(result?.cloudSkuName, '商品A') - assert.equal(result?.resolvedSourceKey, '') - assert.deepEqual(result?.deliveryItems, [ - { - cloudSkuId: 28, - cloudSkuName: '商品A', - quantity: 1, - }, - { - cloudSkuId: 29, - cloudSkuName: '商品B', - quantity: 20, - }, - ]) -}) diff --git a/apps/backend/src/services/order/cloudtentacles-name-match-service.ts b/apps/backend/src/services/order/cloudtentacles-name-match-service.ts deleted file mode 100644 index f7009720..00000000 --- a/apps/backend/src/services/order/cloudtentacles-name-match-service.ts +++ /dev/null @@ -1,302 +0,0 @@ -import type { JsonObject } from '../../types/json.js' -import { listCloudtentaclesSources } from '../platforms/cloudtentacles/source-config-service.js' -import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js' -import { - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from '../platforms/cloudtentacles/defaults.js' -import { listCloudtentaclesSku } from '../platforms/cloudtentacles/catalog-service.js' -import { - resolveCloudtentaclesOverrideRule, - type CloudtentaclesOverrideRule, -} from './cloudtentacles-override-rule-service.js' -import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js' - -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 - resolveCloudtentaclesOverrideRule?: typeof resolveCloudtentaclesOverrideRule -} - -export type CloudtentaclesResolvedDeliveryItem = { - cloudSkuId: number - cloudSkuName: string - quantity: number -} - -export type CloudtentaclesNameMatchResult = { - matchMode: 'cloudtentacles_name' | 'cloudtentacles_override' - productName: string - normalizedProductName: string - cloudSkuId: number - cloudSkuName: string - cloudSkuPrice: number - cloudSkuInventory: number - cloudSourceKeys: string[] - resolvedSourceKey: string - deliveryItems: CloudtentaclesResolvedDeliveryItem[] - skuSnapshot: JsonObject -} - -export async function resolveCloudtentaclesSkuByProductName( - productName: unknown, - deps: CloudtentaclesNameMatchDeps = {}, -): Promise { - const normalizedProductName = normalizeCloudtentaclesMatchName(productName) - if (!normalizedProductName) { - return null - } - - const listSources = deps.listCloudtentaclesSources || listCloudtentaclesSources - const getSessionByKey = - deps.getCloudtentaclesSessionStateByKey || getCloudtentaclesSessionStateByKey - const listSku = deps.listCloudtentaclesSku || listCloudtentaclesSku - const resolveOverrideRule = - deps.resolveCloudtentaclesOverrideRule || resolveCloudtentaclesOverrideRule - 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> => - Boolean(context), - ) - - if (sourceContexts.length === 0) { - return null - } - - const overrideRule = resolveOverrideRule(productName) - const overrideMatch = overrideRule - ? await resolveOverrideMatch( - productName, - normalizedProductName, - overrideRule, - sourceContexts, - listSku, - ) - : null - if (overrideMatch) { - return overrideMatch - } - - 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, - // Matching only discovers compatible accounts. The fulfillment selector - // chooses the least-loaded account immediately before taking a number. - resolvedSourceKey: '', - deliveryItems: [ - { - cloudSkuId, - cloudSkuName, - quantity: 1, - }, - ], - skuSnapshot: { ...matchedSku }, - } - } catch { - // 当前账号不可用时继续尝试下一个已登录账号。 - } - } - - return null -} - -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, - accountLabel: String(source.label || sourceKey).trim() || sourceKey, - baseUrl: String(session?.baseUrl || source.baseUrl || '').trim(), - token, - deviceId: normalizeCloudtentaclesDeviceId(session?.deviceId || source.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(session?.deviceType ?? source.deviceType), - } -} - -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 - ) -} - -async function resolveOverrideMatch( - productName: unknown, - normalizedProductName: string, - rule: CloudtentaclesOverrideRule, - sourceContexts: Array>>, - listSku: typeof listCloudtentaclesSku, -): Promise { - const candidates = rule.sourceKey - ? sourceContexts.filter((context) => context.sourceKey === rule.sourceKey) - : sourceContexts - - if (candidates.length === 0) { - return null - } - - for (const context of candidates) { - try { - const skuList = await listSku(context) - const items = Array.isArray(skuList.items) ? skuList.items : [] - const deliveryItems = resolveOverrideDeliveryItems(rule, items) - - if (deliveryItems.length !== rule.deliveryItems.length || !deliveryItems[0]) { - continue - } - - const firstDeliveryItem = deliveryItems[0] - const firstSku = - items.find((item) => Number(item.id || 0) === firstDeliveryItem.cloudSkuId) || {} - const cloudSourceKeys = rule.sourceKey - ? [context.sourceKey] - : sourceContexts.map((sourceContext) => sourceContext.sourceKey) - - return { - matchMode: 'cloudtentacles_override', - productName: String(productName || '').trim(), - normalizedProductName, - cloudSkuId: firstDeliveryItem.cloudSkuId, - cloudSkuName: firstDeliveryItem.cloudSkuName, - cloudSkuPrice: Number(firstSku.price || 0) || 0, - cloudSkuInventory: Number(firstSku.inventory || 0) || 0, - cloudSourceKeys, - resolvedSourceKey: rule.sourceKey ? context.sourceKey : '', - deliveryItems, - skuSnapshot: { - overrideRuleId: rule.id, - overrideRuleProductName: rule.productName, - deliveryItems, - firstSku, - }, - } - } catch { - // 当前账号不可用时继续尝试下一个已登录账号。 - } - } - - return null -} - -function resolveOverrideDeliveryItems( - rule: CloudtentaclesOverrideRule, - skuItems: CloudtentaclesSku[], -) { - const resolvedItems: CloudtentaclesResolvedDeliveryItem[] = [] - - for (const ruleItem of rule.deliveryItems) { - const matchedSku = - ruleItem.cloudSkuId > 0 - ? skuItems.find((item) => Number(item.id || 0) === ruleItem.cloudSkuId) - : findCloudtentaclesSkuByName(skuItems, ruleItem.cloudSkuName) - const cloudSkuId = Number(matchedSku?.id || 0) - const cloudSkuName = String(matchedSku?.name || '').trim() - - if (!Number.isInteger(cloudSkuId) || cloudSkuId <= 0 || !cloudSkuName) { - return [] - } - - resolvedItems.push({ - cloudSkuId, - cloudSkuName, - quantity: Math.max(1, Number(ruleItem.quantity || 1)), - }) - } - - return mergeResolvedDeliveryItems(resolvedItems) -} - -function mergeResolvedDeliveryItems(items: CloudtentaclesResolvedDeliveryItem[]) { - const merged = new Map() - - for (const item of items) { - const existing = merged.get(item.cloudSkuId) - if (existing) { - existing.quantity += item.quantity - existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName - continue - } - - merged.set(item.cloudSkuId, { ...item }) - } - - return Array.from(merged.values()) -} - -export { normalizeCloudtentaclesMatchName } diff --git a/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts b/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts deleted file mode 100644 index 9cf89d25..00000000 --- a/apps/backend/src/services/order/cloudtentacles-override-rule-service.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { normalizeCloudtentaclesOverrideRuleConfig } from './cloudtentacles-override-rule-service.js' - -test('normalizeCloudtentaclesOverrideRuleConfig merges duplicated delivery items', () => { - const config = normalizeCloudtentaclesOverrideRuleConfig({ - enabled: true, - rules: [ - { - productName: '套装1', - deliveryItems: [ - { - cloudSkuId: 28, - cloudSkuName: '商品A', - quantity: 1, - }, - { - cloudSkuId: 28, - cloudSkuName: '商品A', - quantity: 2, - }, - { - cloudSkuName: '商品B', - quantity: 20, - }, - ], - }, - ], - }) - - assert.equal(config.enabled, true) - assert.equal(config.rules.length, 1) - assert.deepEqual(config.rules[0]?.deliveryItems, [ - { - cloudSkuId: 28, - cloudSkuName: '商品A', - quantity: 3, - }, - { - cloudSkuId: 0, - cloudSkuName: '商品B', - quantity: 20, - }, - ]) -}) - -test('normalizeCloudtentaclesOverrideRuleConfig drops invalid rules', () => { - const config = normalizeCloudtentaclesOverrideRuleConfig({ - rules: [ - { - productName: '', - deliveryItems: [ - { - cloudSkuId: 28, - quantity: 1, - }, - ], - }, - { - productName: '有效商品', - deliveryItems: [], - }, - ], - }) - - assert.deepEqual(config.rules, []) -}) diff --git a/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts b/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts deleted file mode 100644 index 7254ca91..00000000 --- a/apps/backend/src/services/order/cloudtentacles-override-rule-service.ts +++ /dev/null @@ -1,185 +0,0 @@ -import path from 'node:path' -import type { JsonObject } from '../../types/json.js' - -import { APP_CONFIG_KEYS } from '../../config/app-config-keys.js' -import { PROJECT_ROOT } from '../../config/runtime.js' -import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' -import { normalizeCloudtentaclesMatchName } from './cloudtentacles-match-utils.js' - -const CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH = path.join( - PROJECT_ROOT, - 'data', - 'cloudtentacles-override-rules.json', -) - -export type CloudtentaclesOverrideDeliveryItem = { - cloudSkuId: number - cloudSkuName: string - quantity: number -} - -export type CloudtentaclesOverrideRule = { - id: string - enabled: boolean - productName: string - normalizedProductName: string - sourceKey: string - deliveryItems: CloudtentaclesOverrideDeliveryItem[] - notes: string -} - -export type CloudtentaclesOverrideRuleConfig = { - enabled: boolean - rules: CloudtentaclesOverrideRule[] -} - -export function getCloudtentaclesOverrideRuleFilePath() { - return CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH -} - -export function getCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig { - return readAppConfigEntry({ - configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules, - legacyFilePath: CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, - fallback: createDefaultCloudtentaclesOverrideRuleConfig, - normalize: normalizeCloudtentaclesOverrideRuleConfig, - }) -} - -export function saveCloudtentaclesOverrideRuleConfig( - rawValue: unknown, -): Promise { - return saveAppConfigEntry({ - configKey: APP_CONFIG_KEYS.cloudtentaclesOverrideRules, - legacyFilePath: CLOUDTENTACLES_OVERRIDE_RULE_FILE_PATH, - value: rawValue, - normalize: normalizeCloudtentaclesOverrideRuleConfig, - }) -} - -export function resolveCloudtentaclesOverrideRule(productName: unknown) { - const config = getCloudtentaclesOverrideRuleConfig() - if (config.enabled === false) { - return null - } - - const normalizedProductName = normalizeCloudtentaclesMatchName(productName) - if (!normalizedProductName) { - return null - } - - return ( - config.rules.find( - (rule) => - rule.enabled !== false && - rule.normalizedProductName === normalizedProductName && - rule.deliveryItems.length > 0, - ) || null - ) -} - -export function normalizeCloudtentaclesOverrideRuleConfig( - rawValue: unknown, -): CloudtentaclesOverrideRuleConfig { - const source = isPlainObject(rawValue) ? rawValue : {} - const rules = Array.isArray(source.rules) ? source.rules : [] - - return { - enabled: source.enabled !== false, - rules: rules - .map((item: unknown) => normalizeCloudtentaclesOverrideRule(item)) - .filter((item): item is CloudtentaclesOverrideRule => Boolean(item)), - } -} - -function normalizeCloudtentaclesOverrideRule(rawValue: unknown): CloudtentaclesOverrideRule | null { - if (!isPlainObject(rawValue)) { - return null - } - - const productName = String(rawValue.productName || rawValue.name || '').trim() - const normalizedProductName = normalizeCloudtentaclesMatchName(productName) - const deliveryItems = normalizeDeliveryItems(rawValue.deliveryItems) - - if (!productName || !normalizedProductName || deliveryItems.length === 0) { - return null - } - - return { - id: String(rawValue.id || normalizedProductName).trim() || normalizedProductName, - enabled: rawValue.enabled !== false, - productName, - normalizedProductName, - sourceKey: String(rawValue.sourceKey || '').trim(), - deliveryItems, - notes: String(rawValue.notes || '').trim(), - } -} - -function normalizeDeliveryItems(value: unknown) { - const rawItems = Array.isArray(value) ? value : [] - const items = rawItems - .map((item) => normalizeDeliveryItem(item)) - .filter((item): item is CloudtentaclesOverrideDeliveryItem => Boolean(item)) - - return mergeDeliveryItems(items) -} - -function normalizeDeliveryItem(value: unknown): CloudtentaclesOverrideDeliveryItem | null { - const source = isPlainObject(value) ? value : {} - const cloudSkuId = normalizePositiveInteger(source.cloudSkuId || source.skuId, 0) - const cloudSkuName = String(source.cloudSkuName || source.skuName || '').trim() - const quantity = normalizePositiveInteger(source.quantity, 1) - - if (!cloudSkuId && !cloudSkuName) { - return null - } - - return { - cloudSkuId, - cloudSkuName, - quantity, - } -} - -function mergeDeliveryItems(items: CloudtentaclesOverrideDeliveryItem[]) { - const merged = new Map() - - for (const item of items) { - const key = - item.cloudSkuId > 0 - ? `id:${item.cloudSkuId}` - : `name:${normalizeCloudtentaclesMatchName(item.cloudSkuName)}` - const existing = merged.get(key) - if (existing) { - existing.quantity += item.quantity - existing.cloudSkuName = existing.cloudSkuName || item.cloudSkuName - existing.cloudSkuId = existing.cloudSkuId || item.cloudSkuId - continue - } - - merged.set(key, { ...item }) - } - - return Array.from(merged.values()) -} - -function normalizePositiveInteger(value: unknown, fallback: number) { - const parsed = Number(value) - if (!Number.isInteger(parsed) || parsed <= 0) { - return fallback - } - - return parsed -} - -function createDefaultCloudtentaclesOverrideRuleConfig(): CloudtentaclesOverrideRuleConfig { - return { - enabled: true, - rules: [], - } -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/order/delivery-task-service.test.ts b/apps/backend/src/services/order/delivery-task-service.test.ts index 698e6ecf..ee1a2278 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -100,7 +100,7 @@ function stubCreatedTask(id: number, input: CreatedTaskInput): TaskRow { } as TaskRow } -test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles 账号', async () => { +test('syncDeliveryTasksForOrder 为 affiliate_dash 商品创建任务上下文', async () => { const createdInputs: CreatedTaskInput[] = [] await syncDeliveryTasksForOrderWithDeps( @@ -113,34 +113,15 @@ test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles stubOrderItem({ id: 10, order_id: 1, - sku_code: 'SKU-1', - sku_name: '荣耀勋章礼包(30个)', + sku_code: 'AFF-1001', + sku_name: '幸运币90个', quantity: 1, - item_snapshot_json: { - cloudtentacles: { - cloudSourceKeys: ['account-a', 'account-b'], - resolvedSourceKey: 'account-a', - cloudSkuId: 74, - cloudSkuName: '荣耀勋章礼包(30个)', - deliveryItems: [ - { - cloudSkuId: 74, - cloudSkuName: '荣耀勋章礼包(30个)', - quantity: 1, - }, - ], - }, - } as JsonObject, + item_snapshot_json: buildAffiliateSnapshot('AFF-1001', '幸运币90个'), }), ], { listTasksByOrderId: async () => [], - getFulfillmentProfileByKey: async () => ({ - id: 9, - profile_key: 'kuaishou_ct_assisted', - profile_name: 'kuaishou-lewan 履约', - executor_key: 'kuaishou_ct_assisted', - }), + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async (input) => { createdInputs.push(input as CreatedTaskInput) return stubCreatedTask(100, input as CreatedTaskInput) @@ -151,61 +132,12 @@ test('syncDeliveryTasksForOrder 多账号候选时不提前固定 cloudtentacles }, ) + assert.equal(createdInputs[0]?.executorKey, 'affiliate_dash') const context = JSON.parse(String(createdInputs[0]?.contextJson || '{}')) as JsonObject - const fulfillment = context.kuaishouCloudFulfillment as JsonObject - const binding = fulfillment.binding as JsonObject - assert.deepEqual(binding.cloudSourceKeys, ['account-a', 'account-b']) - assert.equal(binding.resolvedSourceKey, '') -}) - -test('syncDeliveryTasksForOrder 单账号候选时保留固定 cloudtentacles 账号', async () => { - const createdInputs: CreatedTaskInput[] = [] - - await syncDeliveryTasksForOrderWithDeps( - stubOrder({ - id: 2, - pay_status: 'pending_payment', - platform_order_id: '2614900602069170', - }), - [ - stubOrderItem({ - id: 11, - order_id: 2, - sku_code: 'SKU-1', - sku_name: '荣耀勋章礼包(30个)', - quantity: 1, - item_snapshot_json: { - cloudtentacles: { - cloudSourceKeys: ['account-a'], - cloudSkuId: 74, - cloudSkuName: '荣耀勋章礼包(30个)', - }, - } as JsonObject, - }), - ], - { - listTasksByOrderId: async () => [], - getFulfillmentProfileByKey: async () => ({ - id: 9, - profile_key: 'kuaishou_ct_assisted', - profile_name: 'kuaishou-lewan 履约', - executor_key: 'kuaishou_ct_assisted', - }), - createTask: async (input) => { - createdInputs.push(input as CreatedTaskInput) - return stubCreatedTask(101, input as CreatedTaskInput) - }, - nowIso: () => '2026-05-29T09:05:46.658Z', - randomId: () => 'DT-test', - createTaskEvent: async () => null, - }, - ) - - const context = JSON.parse(String(createdInputs[0]?.contextJson || '{}')) as JsonObject - const fulfillment = context.kuaishouCloudFulfillment as JsonObject - const binding = fulfillment.binding as JsonObject - assert.deepEqual(binding.cloudSourceKeys, ['account-a']) - assert.equal(binding.resolvedSourceKey, 'account-a') + const affiliateDash = context.affiliateDash as JsonObject + assert.equal(affiliateDash.sku, 'AFF-1001') + assert.equal(affiliateDash.productName, '幸运币90个') + assert.equal(affiliateDash.flowType, 'affiliate_dash') }) test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任务', async () => { @@ -213,7 +145,7 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任 orderId: 3, orderItemId: 20, unitIndex: 1, - executorKey: 'kuaishou_ct_assisted', + executorKey: 'affiliate_dash', }) const createdInputs: CreatedTaskInput[] = [] const taskEvents: Array<{ taskId: number; eventType: string }> = [] @@ -225,18 +157,18 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任 id: 20, order_id: 3, sku_code: 'SKU-existing', - item_snapshot_json: buildCloudSnapshot(74, '商品 A'), + item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'), }), stubOrderItem({ id: 21, order_id: 3, sku_code: 'SKU-missing', - item_snapshot_json: buildCloudSnapshot(75, '商品 B'), + item_snapshot_json: buildAffiliateSnapshot('AFF-B', '商品 B'), }), ], { listTasksByOrderId: async () => [existingTask], - getFulfillmentProfileByKey: async () => buildCloudProfile(), + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async (input) => { createdInputs.push(input as CreatedTaskInput) return stubCreatedTask(202, input as CreatedTaskInput) @@ -268,7 +200,7 @@ test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async () orderId: 4, orderItemId: 30, unitIndex: 1, - executorKey: 'kuaishou_ct_assisted', + executorKey: 'affiliate_dash', }) const createdInputs: CreatedTaskInput[] = [] @@ -280,12 +212,12 @@ test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async () order_id: 4, sku_code: 'SKU-quantity', quantity: 2, - item_snapshot_json: buildCloudSnapshot(74, '商品 A'), + item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'), }), ], { listTasksByOrderId: async () => [existingTask], - getFulfillmentProfileByKey: async () => buildCloudProfile(), + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async (input) => { createdInputs.push(input as CreatedTaskInput) return stubCreatedTask(302, input as CreatedTaskInput) @@ -321,20 +253,18 @@ test('syncDeliveryTasksForOrder 规划失败时记录可定位的路由诊断', item_snapshot_json: { isConfigured: true, fulfillmentRoute: { - selectedExecutorKey: 'kuaishou_ct_assisted', - reason: '规则指定云履约', + selectedExecutorKey: 'affiliate_dash', + reason: '规则指定 affiliate', }, - cloudtentacles: { - cloudSkuId: 74, - cloudSkuName: '商品 A', - cloudSourceKeys: [], + affiliateDash: { + skuName: '商品 A', }, } as JsonObject, }), ], { listTasksByOrderId: async () => [], - getFulfillmentProfileByKey: async () => buildCloudProfile(), + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTaskEvent: async () => null, logInfo: () => null, logWarn: (_scope, message, detail) => { @@ -354,13 +284,13 @@ test('syncDeliveryTasksForOrder 规划失败时记录可定位的路由诊断', assert.deepEqual(warnings[0]?.detail.missingUnitIndexes, [1]) assert.deepEqual(warnings[0]?.detail.planningDiagnostic, { snapshotIsConfigured: true, - selectedExecutorKey: 'kuaishou_ct_assisted', - routeReason: '规则指定云履约', + selectedExecutorKey: 'affiliate_dash', + routeReason: '规则指定 affiliate', candidateAvailability: [], sourceItemTitle: '', sourceSkuNick: '', - cloudSkuId: 74, - cloudSkuName: '商品 A', + cloudSkuId: 0, + cloudSkuName: '', cloudSourceKeys: [], feifeiProductCode: '', affiliateDashSku: '', @@ -372,7 +302,7 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy orderId: 6, orderItemId: 50, unitIndex: 1, - executorKey: 'kuaishou_ct_assisted', + executorKey: 'affiliate_dash', }) let listCount = 0 let eventCount = 0 @@ -384,12 +314,12 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy id: 50, order_id: 6, sku_code: 'SKU-concurrent', - item_snapshot_json: buildCloudSnapshot(74, '商品 A'), + item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'), }), ], { listTasksByOrderId: async () => (listCount++ === 0 ? [] : [concurrentTask]), - getFulfillmentProfileByKey: async () => buildCloudProfile(), + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async () => { throw new Error('duplicate key value violates unique constraint') }, @@ -411,25 +341,25 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy assert.equal(eventCount, 0) }) -function buildCloudSnapshot(cloudSkuId: number, cloudSkuName: string): JsonObject { +function buildAffiliateSnapshot(sku: string, productName: string): JsonObject { return { isConfigured: true, fulfillmentRoute: { - selectedExecutorKey: 'kuaishou_ct_assisted', + selectedExecutorKey: 'affiliate_dash', }, - cloudtentacles: { - cloudSourceKeys: ['account-a'], - cloudSkuId, - cloudSkuName, + affiliateDash: { + sku, + skuName: productName, + matchMode: 'affiliate_dash_sku_mapping', }, } } -function buildCloudProfile() { +function buildAffiliateProfile() { return { - id: 9, - profile_key: 'kuaishou_ct_assisted', - profile_name: 'kuaishou-lewan 履约', - executor_key: 'kuaishou_ct_assisted', + id: 11, + profile_key: 'affiliate_dash', + profile_name: 'affiliate-dash 履约', + executor_key: 'affiliate_dash', } } diff --git a/apps/backend/src/services/order/cloudtentacles-match-utils.ts b/apps/backend/src/services/order/product-match-utils.ts similarity index 58% rename from apps/backend/src/services/order/cloudtentacles-match-utils.ts rename to apps/backend/src/services/order/product-match-utils.ts index 56a7503f..f5bd1d0c 100644 --- a/apps/backend/src/services/order/cloudtentacles-match-utils.ts +++ b/apps/backend/src/services/order/product-match-utils.ts @@ -1,4 +1,5 @@ -export function normalizeCloudtentaclesMatchName(value: unknown) { +/** 商品名归一化:用于履约路由规则匹配等。 */ +export function normalizeProductMatchName(value: unknown) { return String(value || '') .toLowerCase() .replace(/[【】[\]()()]/g, ' ') @@ -7,3 +8,8 @@ export function normalizeCloudtentaclesMatchName(value: unknown) { .replace(/\s+/g, ' ') .trim() } + +/** @deprecated 使用 normalizeProductMatchName */ +export function normalizeCloudtentaclesMatchName(value: unknown) { + return normalizeProductMatchName(value) +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts b/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts deleted file mode 100644 index 098417a1..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/catalog-service.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { createHttpError, type HttpErrorLike } from '../../../utils/http.js' -import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './helpers.js' -import type { JsonObject } from '../../../types/json.js' - -export async function getCloudtentaclesAsset(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 余额查询缺少 token', - 'cloudtentacles_asset_missing_token', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.assetPath, { - ...config, - method: 'GET', - token, - contentType: 'application/json', - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'asset_get', - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_asset_failed', - }) - - return { - baseUrl: config.baseUrl, - asset: Number(result.payload?.data || 0), - raw: result.payload?.data ?? null, - } -} - -export async function getCloudtentaclesCategories(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 分类查询缺少 token', - 'cloudtentacles_categories_missing_token', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.categoriesPath, { - ...config, - method: 'GET', - token, - contentType: 'application/json', - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'categories_list', - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_categories_failed', - }) - - const items = Array.isArray(result.payload?.data) ? result.payload.data.map(mapCategoryItem) : [] - - return { - baseUrl: config.baseUrl, - itemCount: items.length, - items, - rawItems: Array.isArray(result.payload?.data) ? result.payload.data : [], - } -} - -export async function listCloudtentaclesSku(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles SKU 列表查询缺少 token', - 'cloudtentacles_sku_list_missing_token', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.skuListPath, { - ...config, - method: 'GET', - token, - contentType: 'application/json', - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'sku_list', - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_sku_list_failed', - }) - - const items = Array.isArray(result.payload?.data) ? result.payload.data.map(mapSkuItem) : [] - - return { - baseUrl: config.baseUrl, - itemCount: items.length, - items, - rawItems: Array.isArray(result.payload?.data) ? result.payload.data : [], - } -} - -export async function buyCloudtentaclesSku(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 购买 SKU 缺少 token', - 'cloudtentacles_sku_buy_missing_token', - ) - const skuId = requireId( - payload.id, - 'cloudtentacles 购买 SKU 缺少商品 id', - 'cloudtentacles_sku_buy_missing_id', - ) - const count = requireCount(payload.count) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.skuBuyPath, { - ...config, - method: 'POST', - token, - body: { - id: skuId, - count, - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_sku_buy_failed', - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'sku_buy', - skuId, - count, - sourceKey: payload.sourceKey, - accountLabel: payload.accountLabel, - }, - }) - - return { - baseUrl: config.baseUrl, - id: skuId, - count, - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - } -} - -export async function useCloudtentaclesSku(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 发货缺少 token', - 'cloudtentacles_sku_use_missing_token', - ) - const skuId = requireId( - payload.id, - 'cloudtentacles 发货缺少商品 id', - 'cloudtentacles_sku_use_missing_id', - ) - const virtualNumberId = requireId( - payload.virtualNumberId, - 'cloudtentacles 发货缺少虚拟号 id', - 'cloudtentacles_sku_use_missing_virtual_number_id', - ) - const phone = String(payload.phone || '').trim() - if (!phone) { - throw createHttpError('cloudtentacles 发货缺少手机号', { - statusCode: 400, - errorCode: 'cloudtentacles_sku_use_missing_phone', - }) - } - - const config = resolveCloudtentaclesConfig(payload) - const result = await useCloudtentaclesSkuRequest({ - ...config, - token, - skuId, - virtualNumberId, - phone, - sourceKey: payload.sourceKey, - accountLabel: payload.accountLabel, - }) - - const responseData = isPlainObject(result.payload?.data) ? result.payload.data : {} - - return { - baseUrl: config.baseUrl, - id: skuId, - virtualNumberId, - phone, - sendType: Number(responseData.send_type || 0), - note: String(responseData.note || '').trim(), - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - raw: result.payload ?? null, - } -} - -async function useCloudtentaclesSkuRequest(payload: JsonObject) { - try { - return await cloudtentaclesRequest(payload.skuUsePath, { - ...payload, - method: 'POST', - token: payload.token, - body: { - id: payload.skuId, - virtual_number_id: payload.virtualNumberId, - phone: payload.phone, - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_sku_use_failed', - context: { - operation: 'sku_use', - skuId: payload.skuId, - virtualNumberId: payload.virtualNumberId, - phoneMasked: maskPhone(payload.phone), - sourceKey: payload.sourceKey, - accountLabel: payload.accountLabel, - }, - }) - } catch (error) { - if (isCloudtentaclesSkuUseLimitMessage(error instanceof Error ? error.message : '')) { - const currentError = error as Error & HttpErrorLike - currentError.statusCode = 409 - currentError.errorCode = 'cloudtentacles_sku_use_limit_reached' - } - - throw error - } -} - -function isCloudtentaclesSkuUseLimitMessage(message: unknown) { - const normalized = String(message || '').trim() - return normalized.includes('最大领取次数') || normalized.includes('领取次数') -} - -function maskPhone(value: unknown) { - const text = String(value || '').trim() - if (text.length <= 4) { - return text ? '****' : '' - } - - return `${text.slice(0, 3)}****${text.slice(-4)}` -} - -function mapCategoryItem(item: unknown) { - const source = isPlainObject(item) ? item : {} - - return { - id: Number(source.id || 0), - name: String(source.name || '').trim(), - supplierTag: String(source.supplier_tag || '').trim(), - description: String(source.description || '').trim(), - notes: String(source.notes || '').trim(), - expire: Number(source.expire || 0), - sendType: Number(source.send_type || 0), - exchangeUrl: String(source.exchange_url || '').trim(), - createdAt: String(source.c_time || '').trim(), - updatedAt: String(source.l_update_time || '').trim(), - raw: source, - } -} - -function mapSkuItem(item: unknown) { - const source = isPlainObject(item) ? item : {} - - return { - id: Number(source.id || 0), - categoriesId: Number(source.categories_id || 0), - name: String(source.name || '').trim(), - description: String(source.desc || '').trim(), - image: String(source.image || '').trim(), - inventory: Number(source.inventory || 0), - price: Number(source.price || 0), - listingTime: String(source.listing_time || '').trim(), - delistingTime: String(source.delisting_time || '').trim(), - buyLimitMin: Number(source.buy_limit_min || 0), - buyLimitMax: Number(source.buy_limit_max || 0), - raw: source, - } -} - -function requireToken(value: unknown, message: string, errorCode: string) { - const token = String(value || '').trim() - if (!token) { - throw createHttpError(message, { - statusCode: 400, - errorCode, - }) - } - - return token -} - -function requireId(value: unknown, message: string, errorCode: string) { - const id = Number(value) - if (!Number.isInteger(id) || id <= 0) { - throw createHttpError(message, { - statusCode: 400, - errorCode, - }) - } - - return id -} - -function requireCount(value: unknown) { - const count = Number(value) - if (!Number.isInteger(count) || count <= 0) { - throw createHttpError('cloudtentacles 购买数量无效', { - statusCode: 400, - errorCode: 'cloudtentacles_sku_buy_invalid_count', - }) - } - - return count -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts b/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts deleted file mode 100644 index cb6d50ac..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/crypto-service.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { createHash, constants, publicEncrypt } from 'node:crypto' -import type { JsonObject } from '../../../types/json.js' - -import { createHttpError } from '../../../utils/http.js' -import { resolveCloudtentaclesConfig } from './helpers.js' - -export function md5CloudtentaclesPassword(password: unknown) { - return createHash('md5') - .update(String(password || ''), 'utf8') - .digest('hex') -} - -export function encryptCloudtentaclesPayload(payload: JsonObject = {}, options: JsonObject = {}) { - const config = resolveCloudtentaclesConfig(options) - const publicKeyPem = String(config.publicKeyPem || '').trim() - - if (!publicKeyPem) { - throw createHttpError('cloudtentacles 公钥未配置', { - statusCode: 500, - errorCode: 'cloudtentacles_public_key_missing', - }) - } - - const timestamp = Number(options.timestamp || Date.now()) - const randomValue = - String(options.randomValue || Math.random().toString(16)).trim() || Math.random().toString(16) - const normalizedPayload = removeEmptyFields(payload) - const plaintext = JSON.stringify({ - t: timestamp, - r: randomValue, - s: String(options.clientSource || config.clientSource || 'ct-client').trim() || 'ct-client', - ...normalizedPayload, - }) - - try { - const encrypted = publicEncrypt( - { - key: publicKeyPem, - padding: constants.RSA_PKCS1_OAEP_PADDING, - oaepHash: 'sha256', - }, - Buffer.from(plaintext, 'utf8'), - ) - - return { - covert: encrypted.toString('base64'), - t: timestamp, - r: randomValue, - plaintext, - } - } catch { - throw createHttpError('cloudtentacles 加密失败', { - statusCode: 500, - errorCode: 'cloudtentacles_encrypt_failed', - }) - } -} - -function removeEmptyFields(payload: unknown) { - if (!payload || typeof payload !== 'object') { - return {} - } - - return Object.fromEntries( - Object.entries(payload).filter( - ([, value]) => value !== '' && value !== null && typeof value !== 'undefined', - ), - ) -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts b/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts deleted file mode 100644 index d29fd90b..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/debug-flow-service.ts +++ /dev/null @@ -1,291 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { - getCloudtentaclesAsset, - listCloudtentaclesSku, - buyCloudtentaclesSku, -} from './catalog-service.js' -import { getCloudtentaclesKnapsack } from './knapsack-service.js' -import type { JsonObject } from '../../../types/json.js' -import { - appointCloudtentaclesVirtualNumber, - fetchCloudtentaclesVirtualNumberCode, - generateCloudtentaclesLoginCode, - getCloudtentaclesBindUrl, - verifyCloudtentaclesLoginCode, -} from './virtual-number-service.js' - -type FlowStepOptions = { - retries?: number - retryDelayMs?: number -} - -export async function runCloudtentaclesFullDebugFlow(payload: JsonObject = {}) { - const skuId = normalizePositiveInteger(payload.skuId, 0) - const skuCount = normalizePositiveInteger(payload.skuCount, 1) - const vnKey = String(payload.vnKey || '').trim() - - if (!skuId) { - throw createHttpError('cloudtentacles 完整流程缺少 SKU ID', { - statusCode: 400, - errorCode: 'cloudtentacles_full_flow_missing_sku_id', - }) - } - - if (!vnKey) { - throw createHttpError('cloudtentacles 完整流程缺少 VN Key', { - statusCode: 400, - errorCode: 'cloudtentacles_full_flow_missing_vn_key', - }) - } - - const beforeAsset = await runFlowStep('查询购买前余额', () => getCloudtentaclesAsset(payload)) - await sleep(350) - const skuList = await runFlowStep('查询 SKU 列表', () => listCloudtentaclesSku(payload)) - const targetSku = skuList.items.find((item: JsonObject) => Number(item.id || 0) === skuId) || null - - if (!targetSku) { - throw createHttpError(`cloudtentacles 未找到 SKU ${skuId}`, { - statusCode: 404, - errorCode: 'cloudtentacles_full_flow_sku_not_found', - }) - } - - const requiredAsset = Number(targetSku.price || 0) * skuCount - if (beforeAsset.asset < requiredAsset) { - throw createHttpError(`余额不足,当前 ${beforeAsset.asset},需要 ${requiredAsset}`, { - statusCode: 409, - errorCode: 'cloudtentacles_full_flow_insufficient_asset', - }) - } - - await sleep(350) - const beforeKnapsack = await runFlowStep('查询购买前背包', () => - getCloudtentaclesKnapsack(payload), - ) - const beforeItem = findKnapsackItem(beforeKnapsack.items, skuId) - - await sleep(500) - const buyResult = await runFlowStep( - '购买 SKU', - () => - buyCloudtentaclesSku({ - ...payload, - id: skuId, - count: skuCount, - }), - { retries: 1, retryDelayMs: 1200 }, - ) - - await sleep(1200) - const afterAsset = await runFlowStep('查询购买后余额', () => getCloudtentaclesAsset(payload), { - retries: 2, - retryDelayMs: 1200, - }) - await sleep(1200) - const afterKnapsack = await runFlowStep( - '查询购买后背包', - () => getCloudtentaclesKnapsack(payload), - { - retries: 2, - retryDelayMs: 1200, - }, - ) - const afterItem = findKnapsackItem(afterKnapsack.items, skuId) - - const beforeCount = Number(beforeItem?.count || 0) - const afterCount = Number(afterItem?.count || 0) - const knapsackIncreased = afterCount >= beforeCount + skuCount - - if (!knapsackIncreased) { - throw createHttpError( - `购买后背包校验失败,购买前 ${beforeCount},购买后 ${afterCount},期望至少 ${beforeCount + skuCount}`, - { - statusCode: 409, - errorCode: 'cloudtentacles_full_flow_knapsack_not_updated', - }, - ) - } - - await sleep(700) - const appointed = await runFlowStep( - '申请虚拟号', - () => - appointCloudtentaclesVirtualNumber({ - ...payload, - key: vnKey, - }), - { retries: 1, retryDelayMs: 1200 }, - ) - const appointedId = Number(appointed.item?.id || 0) - const appointedPhone = String(appointed.item?.phone || '').trim() - - if (!appointedId || !appointedPhone) { - throw createHttpError('cloudtentacles 申请虚拟号成功但返回数据不完整', { - statusCode: 502, - errorCode: 'cloudtentacles_full_flow_invalid_appointed_vn', - }) - } - - await sleep(1200) - const generateCodeResult = await runFlowStep( - '生成登录码', - () => - generateCloudtentaclesLoginCode({ - ...payload, - key: vnKey, - id: appointedId, - }), - { retries: 1, retryDelayMs: 1200 }, - ) - - await sleep(1600) - const fetchedCode = await runFlowStep( - '获取验证码', - () => - fetchCloudtentaclesVirtualNumberCode({ - ...payload, - key: vnKey, - phone: appointedPhone, - }), - { retries: 2, retryDelayMs: 1500 }, - ) - - await sleep(1000) - const verified = await runFlowStep( - '校验验证码', - () => - verifyCloudtentaclesLoginCode({ - ...payload, - key: vnKey, - id: appointedId, - code: fetchedCode.code, - }), - { retries: 1, retryDelayMs: 1200 }, - ) - - await sleep(1000) - const bindUrlResult = await runFlowStep( - '获取兑换链接', - () => - getCloudtentaclesBindUrl({ - ...payload, - key: vnKey, - id: appointedId, - }), - { retries: 1, retryDelayMs: 1200 }, - ) - - return { - sku: { - id: skuId, - count: skuCount, - name: String(targetSku.name || '').trim(), - price: Number(targetSku.price || 0), - requiredAsset, - }, - assetCheck: { - before: beforeAsset.asset, - after: afterAsset.asset, - enough: beforeAsset.asset >= requiredAsset, - spent: beforeAsset.asset - afterAsset.asset, - }, - knapsackCheck: { - beforeCount, - afterCount, - expectedIncrease: skuCount, - increased: knapsackIncreased, - }, - purchase: buyResult, - virtualNumber: { - key: vnKey, - id: appointedId, - phone: appointedPhone, - appoint: appointed, - generateLoginCode: generateCodeResult, - fetchedCode, - verify: verified, - }, - bindUrl: bindUrlResult.bindUrl, - bindUrlResult, - } -} - -function normalizePositiveInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback -} - -function findKnapsackItem(items: unknown, skuId: unknown) { - return ( - (Array.isArray(items) ? items : []).find((item) => Number(item.id || 0) === Number(skuId)) || - null - ) -} - -async function runFlowStep( - label: string, - runner: () => Promise, - options: FlowStepOptions = {}, -) { - const rawRetries = Number(options.retries) - const rawRetryDelayMs = Number(options.retryDelayMs) - const retries = Number.isInteger(rawRetries) && rawRetries > 0 ? rawRetries : 0 - const retryDelayMs = - Number.isFinite(rawRetryDelayMs) && rawRetryDelayMs > 0 ? rawRetryDelayMs : 1000 - - for (let attempt = 0; attempt <= retries; attempt += 1) { - try { - return await runner() - } catch (error) { - if (attempt >= retries || !isHighFrequencyError(error)) { - throw wrapStepError(label, error) - } - - await sleep(retryDelayMs * (attempt + 1)) - } - } - - throw createHttpError(`cloudtentacles ${label}失败`, { - statusCode: 500, - errorCode: 'cloudtentacles_full_flow_step_failed', - }) -} - -function isHighFrequencyError(error: unknown) { - const message = error instanceof Error ? error.message : String(error || '') - return message.toLowerCase().includes('high-frequency request') -} - -function wrapStepError(label: string, error: unknown) { - const message = error instanceof Error ? error.message : String(error || '未知错误') - - if (isHighFrequencyError(error)) { - return createHttpError(`cloudtentacles ${label}失败:请求过于频繁,请稍后重试`, { - statusCode: 429, - errorCode: 'cloudtentacles_rate_limited', - }) - } - - if (error && typeof error === 'object' && 'statusCode' in error) { - const statusCode = Number(Reflect.get(error, 'statusCode') || 500) - const errorCode = String( - Reflect.get(error, 'errorCode') || 'cloudtentacles_full_flow_step_failed', - ) - - return createHttpError(`cloudtentacles ${label}失败:${message}`, { - statusCode, - errorCode, - }) - } - - return createHttpError(`cloudtentacles ${label}失败:${message}`, { - statusCode: 500, - errorCode: 'cloudtentacles_full_flow_step_failed', - }) -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/defaults.test.ts b/apps/backend/src/services/platforms/cloudtentacles/defaults.test.ts deleted file mode 100644 index 80b56534..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/defaults.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { - DEFAULT_CLOUDTENTACLES_DEVICE_ID, - DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from './defaults.js' - -test('normalizeCloudtentaclesDeviceId maps empty and legacy placeholder to default device', () => { - assert.equal(normalizeCloudtentaclesDeviceId(''), DEFAULT_CLOUDTENTACLES_DEVICE_ID) - assert.equal(normalizeCloudtentaclesDeviceId('-'), DEFAULT_CLOUDTENTACLES_DEVICE_ID) - assert.equal(normalizeCloudtentaclesDeviceId(' custom-device '), 'custom-device') -}) - -test('normalizeCloudtentaclesDeviceType maps empty and legacy zero to default type', () => { - assert.equal(normalizeCloudtentaclesDeviceType(undefined), DEFAULT_CLOUDTENTACLES_DEVICE_TYPE) - assert.equal(normalizeCloudtentaclesDeviceType(0), DEFAULT_CLOUDTENTACLES_DEVICE_TYPE) - assert.equal(normalizeCloudtentaclesDeviceType('2'), 2) -}) diff --git a/apps/backend/src/services/platforms/cloudtentacles/defaults.ts b/apps/backend/src/services/platforms/cloudtentacles/defaults.ts deleted file mode 100644 index 3c444f41..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/defaults.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const DEFAULT_CLOUDTENTACLES_DEVICE_ID = '08bc9d8c-fd15-48ea-bc00-8d754076cafc' -export const DEFAULT_CLOUDTENTACLES_DEVICE_TYPE = 1 - -export function normalizeCloudtentaclesDeviceId(value: unknown) { - const normalized = String(value || '').trim() - return normalized && normalized !== '-' ? normalized : DEFAULT_CLOUDTENTACLES_DEVICE_ID -} - -export function normalizeCloudtentaclesDeviceType(value: unknown) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_CLOUDTENTACLES_DEVICE_TYPE -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/helpers.ts b/apps/backend/src/services/platforms/cloudtentacles/helpers.ts deleted file mode 100644 index 5c90a8ca..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/helpers.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { runtimeConfig } from '../../../config/runtime.js' -import type { JsonObject } from '../../../types/json.js' - -import type { RuntimeConfig } from '../../../types/runtime-config.js' -import { - DEFAULT_CLOUDTENTACLES_DEVICE_ID, - DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from './defaults.js' - -type CloudtentaclesRuntimeConfig = RuntimeConfig['platforms']['cloudtentacles'] -export function resolveCloudtentaclesConfig(overrides: Partial = {}) { - const baseConfig = runtimeConfig.platforms?.cloudtentacles || { - baseUrl: '', - timeoutMs: 5000, - sendSmsPath: '/public/verif_code', - loginPath: '/public/login', - userInfoPath: '/user/info', - assetPath: '/user/get_asset', - permissionPath: '/user/get_permission', - categoriesPath: '/categories/get', - skuListPath: '/sku/list', - skuBuyPath: '/sku/buy', - skuUsePath: '/sku/use', - knapsackPath: '/user/get_knapsack', - vnListPath: '/vn/list', - vnAppointPath: '/vn/appoint', - vnGenerateLoginCodePath: '/vn/generate_login_code', - vnVerifCodePath: '/public/vn_verif_code', - vnVerifyLoginCodePath: '/vn/verify_login_code', - vnBindUrlPath: '/vn/bind_url', - vnBindInfoPath: '/vn/bind_info', - vnBackPath: '/vn/back', - bindUrlTtlSeconds: 600, - bindUrlProbeIntervalSeconds: 30, - bindUrlProbeTimeoutMs: 5000, - bindUrlProbeUserAgent: '', - bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', - bindUrlProbeChartId: '323794', - bindUrlProbeSubChartId: '323794', - bindUrlProbeIdeToken: 'z90Syo', - bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F', - bindUrlProbeReferer: 'https://gp.qq.com/', - bindUrlProbeExtraCookie: '', - publicKeyPem: '', - clientSource: 'ct-client', - deviceId: DEFAULT_CLOUDTENTACLES_DEVICE_ID, - deviceType: DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, - } - - return { - baseUrl: normalizeBaseUrl(overrides.baseUrl || baseConfig.baseUrl || 'https://123.207.217.176'), - timeoutMs: normalizePositiveInteger(overrides.timeoutMs || baseConfig.timeoutMs, 5000), - sendSmsPath: normalizePath( - overrides.sendSmsPath || baseConfig.sendSmsPath, - '/public/verif_code', - ), - loginPath: normalizePath(overrides.loginPath || baseConfig.loginPath, '/public/login'), - userInfoPath: normalizePath(overrides.userInfoPath || baseConfig.userInfoPath, '/user/info'), - assetPath: normalizePath(overrides.assetPath || baseConfig.assetPath, '/user/get_asset'), - permissionPath: normalizePath( - overrides.permissionPath || baseConfig.permissionPath, - '/user/get_permission', - ), - categoriesPath: normalizePath( - overrides.categoriesPath || baseConfig.categoriesPath, - '/categories/get', - ), - skuListPath: normalizePath(overrides.skuListPath || baseConfig.skuListPath, '/sku/list'), - skuBuyPath: normalizePath(overrides.skuBuyPath || baseConfig.skuBuyPath, '/sku/buy'), - skuUsePath: normalizePath(overrides.skuUsePath || baseConfig.skuUsePath, '/sku/use'), - knapsackPath: normalizePath( - overrides.knapsackPath || baseConfig.knapsackPath, - '/user/get_knapsack', - ), - vnListPath: normalizePath(overrides.vnListPath || baseConfig.vnListPath, '/vn/list'), - vnAppointPath: normalizePath( - overrides.vnAppointPath || baseConfig.vnAppointPath, - '/vn/appoint', - ), - vnGenerateLoginCodePath: normalizePath( - overrides.vnGenerateLoginCodePath || baseConfig.vnGenerateLoginCodePath, - '/vn/generate_login_code', - ), - vnVerifCodePath: normalizePath( - overrides.vnVerifCodePath || baseConfig.vnVerifCodePath, - '/public/vn_verif_code', - ), - vnVerifyLoginCodePath: normalizePath( - overrides.vnVerifyLoginCodePath || baseConfig.vnVerifyLoginCodePath, - '/vn/verify_login_code', - ), - vnBindUrlPath: normalizePath( - overrides.vnBindUrlPath || baseConfig.vnBindUrlPath, - '/vn/bind_url', - ), - vnBindInfoPath: normalizePath( - overrides.vnBindInfoPath || baseConfig.vnBindInfoPath, - '/vn/bind_info', - ), - vnBackPath: normalizePath(overrides.vnBackPath || baseConfig.vnBackPath, '/vn/back'), - bindUrlTtlSeconds: normalizePositiveInteger( - overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds, - 600, - ), - bindUrlProbeIntervalSeconds: normalizePositiveInteger( - overrides.bindUrlProbeIntervalSeconds || baseConfig.bindUrlProbeIntervalSeconds, - 30, - ), - bindUrlProbeTimeoutMs: normalizePositiveInteger( - overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs, - 5000, - ), - bindUrlProbeUserAgent: String( - overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '', - ).trim(), - bindUrlProbeEndpoint: String( - overrides.bindUrlProbeEndpoint || - baseConfig.bindUrlProbeEndpoint || - 'https://comm.ams.game.qq.com/ide/', - ).trim(), - bindUrlProbeChartId: String( - overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794', - ).trim(), - bindUrlProbeSubChartId: String( - overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794', - ).trim(), - bindUrlProbeIdeToken: String( - overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo', - ).trim(), - bindUrlProbeActivityUrl: String( - overrides.bindUrlProbeActivityUrl || - baseConfig.bindUrlProbeActivityUrl || - 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F', - ).trim(), - bindUrlProbeReferer: String( - overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/', - ).trim(), - bindUrlProbeExtraCookie: String( - overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '', - ).trim(), - publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem), - clientSource: - String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || - 'ct-client', - deviceId: normalizeCloudtentaclesDeviceId(overrides.deviceId ?? baseConfig.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(overrides.deviceType ?? baseConfig.deviceType), - } -} - -export function buildCloudtentaclesUrl( - baseUrl: unknown, - pathname: unknown, - searchParams: JsonObject | null = null, -) { - const url = new URL( - normalizePath(pathname, '/'), - normalizeBaseUrl(baseUrl) || 'https://123.207.217.176', - ) - - if (searchParams && typeof searchParams === 'object') { - for (const [key, value] of Object.entries(searchParams)) { - if (typeof value === 'undefined' || value === null || value === '') { - continue - } - url.searchParams.set(key, String(value)) - } - } - - return url -} - -export function buildCloudtentaclesHeaders({ - token = '', - contentType = 'application/json', - deviceId = DEFAULT_CLOUDTENTACLES_DEVICE_ID, - deviceType = DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, - extra = {}, -}: { - token?: unknown - contentType?: string - deviceId?: unknown - deviceType?: unknown - extra?: JsonObject -} = {}) { - const headers: JsonObject = { - accept: 'application/json, text/plain, */*', - ...(contentType ? { 'content-type': contentType } : {}), - deviceid: normalizeCloudtentaclesDeviceId(deviceId), - devicetype: String(normalizeCloudtentaclesDeviceType(deviceType)), - ...normalizeHeaderMap(extra), - } - - const normalizedToken = String(token || '').trim() - if (normalizedToken) { - headers.authorization = normalizedToken - } - - return headers -} - -function normalizeBaseUrl(value: unknown) { - return String(value || '') - .trim() - .replace(/\/+$/, '') -} - -function normalizePath(value: unknown, fallback: string) { - const normalized = String(value || '').trim() - if (!normalized) { - return fallback - } - - return normalized.startsWith('/') ? normalized : `/${normalized}` -} - -function normalizePositiveInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback -} - -function normalizeHeaderMap(extra: unknown) { - if (!extra || typeof extra !== 'object') { - return {} - } - - return Object.fromEntries( - Object.entries(extra) - .map(([key, value]) => [ - String(key || '') - .trim() - .toLowerCase(), - String(value || '').trim(), - ]) - .filter(([key, value]) => key && value), - ) -} - -function normalizePem(value: unknown) { - return String(value || '') - .replace(/\r/g, '') - .split('\n') - .map((line) => line.trim()) - .filter(Boolean) - .join('\n') -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/http-client.test.ts b/apps/backend/src/services/platforms/cloudtentacles/http-client.test.ts deleted file mode 100644 index 13589b85..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/http-client.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' -import http from 'node:http' - -import { cloudtentaclesRequest, isHighFrequencyMessage } from './http-client.js' - -type MockResponse = { - status?: number - body?: unknown -} - -test('isHighFrequencyMessage recognizes chinese 110001 and english high-frequency', () => { - assert.equal( - isHighFrequencyMessage('未知错误码: (110001) 抱歉,请求过于频繁,请稍后再试!谢谢!', -1), - true, - ) - assert.equal(isHighFrequencyMessage('high-frequency Request'), true) - assert.equal(isHighFrequencyMessage('ok', 0), false) - assert.equal(isHighFrequencyMessage('其他错误', 1), false) -}) - -test('cloudtentaclesRequest retries high-frequency business errors', async () => { - let callCount = 0 - const server = await createMockServer(() => { - callCount += 1 - return { - body: - callCount === 1 ? { code: 1, message: 'high-frequency Request' } : { code: 0, data: 'ok' }, - } - }) - - try { - const result = await cloudtentaclesRequest('/user/get_asset', { - baseUrl: server.baseUrl, - token: 'retry-token', - sourceKey: 'retry-source', - rateLimitIntervalMs: 0, - rateLimitRetries: 1, - rateLimitRetryDelayMs: 1, - }) - - assert.equal(callCount, 2) - assert.equal(result.payload?.data, 'ok') - } finally { - await server.close() - } -}) - -test('cloudtentaclesRequest retries chinese 110001 rate-limit messages', async () => { - let callCount = 0 - const server = await createMockServer(() => { - callCount += 1 - return { - body: - callCount === 1 - ? { - code: -1, - message: '未知错误码: (110001) 抱歉,请求过于频繁,请稍后再试!谢谢!', - } - : { code: 0, data: 'ok' }, - } - }) - - try { - const result = await cloudtentaclesRequest('/vn/bind_info', { - baseUrl: server.baseUrl, - token: 'cn-retry-token', - sourceKey: 'cn-retry-source', - rateLimitIntervalMs: 0, - rateLimitRetries: 1, - rateLimitRetryDelayMs: 1, - }) - - assert.equal(callCount, 2) - assert.equal(result.payload?.data, 'ok') - } finally { - await server.close() - } -}) - -test('cloudtentaclesRequest maps high-frequency exhaustion to 429', async () => { - const server = await createMockServer(() => ({ - body: { code: 1, message: 'high-frequency Request' }, - })) - - try { - await assert.rejects( - cloudtentaclesRequest('/user/get_asset', { - baseUrl: server.baseUrl, - token: 'limited-token', - sourceKey: 'limited-source', - rateLimitIntervalMs: 0, - rateLimitRetries: 0, - }), - (error) => { - assert.equal(error instanceof Error, true) - assert.equal(Reflect.get(error as object, 'statusCode'), 429) - assert.equal(Reflect.get(error as object, 'errorCode'), 'cloudtentacles_rate_limited') - return true - }, - ) - } finally { - await server.close() - } -}) - -test('cloudtentaclesRequest spaces concurrent requests in the same bucket', async () => { - const requestTimes: number[] = [] - const server = await createMockServer(() => { - requestTimes.push(Date.now()) - return { body: { code: 0, data: 'ok' } } - }) - - try { - await Promise.all([ - cloudtentaclesRequest('/sku/list', { - baseUrl: server.baseUrl, - token: 'throttle-token', - sourceKey: 'throttle-source', - rateLimitIntervalMs: 25, - rateLimitRetries: 0, - }), - cloudtentaclesRequest('/user/get_knapsack', { - baseUrl: server.baseUrl, - token: 'throttle-token', - sourceKey: 'throttle-source', - rateLimitIntervalMs: 25, - rateLimitRetries: 0, - }), - ]) - - assert.equal(requestTimes.length, 2) - assert.ok(Math.abs(Number(requestTimes[1]) - Number(requestTimes[0])) >= 20) - } finally { - await server.close() - } -}) - -async function createMockServer(handler: () => MockResponse) { - const server = http.createServer((_request, response) => { - const result = handler() - response.statusCode = result.status || 200 - response.setHeader('content-type', 'application/json') - response.end(JSON.stringify(result.body ?? { code: 0, data: null })) - }) - - await new Promise((resolve) => { - server.listen(0, '127.0.0.1', resolve) - }) - - const address = server.address() - assert.equal(typeof address, 'object') - assert.notEqual(address, null) - - return { - baseUrl: `http://127.0.0.1:${address?.port}`, - close: () => - new Promise((resolve, reject) => { - server.close((error) => { - if (error) { - reject(error) - return - } - - resolve() - }) - }), - } -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts b/apps/backend/src/services/platforms/cloudtentacles/http-client.ts deleted file mode 100644 index 58216aee..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/http-client.ts +++ /dev/null @@ -1,555 +0,0 @@ -import http from 'node:http' -import https from 'node:https' -import type { JsonObject } from '../../../types/json.js' - -import { createHttpError } from '../../../utils/http.js' -import { logInfo, logWarn } from '../../../utils/logger.js' -import { notifyCloudtentaclesAuthExpired } from '../../notification/domain-notifications.js' -import { - buildCloudtentaclesHeaders, - buildCloudtentaclesUrl, - resolveCloudtentaclesConfig, -} from './helpers.js' - -type HeaderAdapter = { - get(name: unknown): string | null -} -type NodeHttpResponse = { - ok: boolean - status: number - headers: HeaderAdapter - bodyText: string -} - -/** 通用接口默认最小间隔;bind_info 等敏感接口由调用方覆盖 */ -const DEFAULT_RATE_LIMIT_INTERVAL_MS = 500 -const DEFAULT_RATE_LIMIT_RETRIES = 2 -const DEFAULT_RATE_LIMIT_RETRY_DELAY_MS = 1_200 -const MAX_RETRY_JITTER_MS = 400 -/** 命中上游限频后,额外冷却桶时间 */ -const RATE_LIMIT_COOLDOWN_MS = 3_000 -const bucketNextAvailableAt = new Map() -let rateLimitQueue = Promise.resolve() - -export async function cloudtentaclesRequest(pathname: unknown, options: JsonObject = {}) { - const normalizedPathname = String(pathname || '').trim() - const config = resolveCloudtentaclesConfig(options) - const url = buildCloudtentaclesUrl(config.baseUrl, normalizedPathname, options.searchParams) - const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 5000) - const method = String(options.method || 'GET') - .trim() - .toUpperCase() - const headers = buildCloudtentaclesHeaders({ - token: options.token, - contentType: - options.contentType === null ? '' : options.contentType || inferContentType(options.body), - deviceId: options.deviceId ?? config.deviceId, - deviceType: options.deviceType ?? config.deviceType, - extra: options.headers, - }) - const rateLimitBucketKey = resolveRateLimitBucketKey({ - baseUrl: config.baseUrl, - token: options.token, - sourceKey: options.sourceKey, - deviceId: options.deviceId ?? config.deviceId, - pathname: normalizedPathname, - isolatePath: options.rateLimitIsolatePath === true, - }) - const rateLimitIntervalMs = normalizeNonNegativeInteger( - options.rateLimitIntervalMs, - resolveDefaultRateLimitIntervalMs(normalizedPathname), - ) - const maxRetries = normalizeNonNegativeInteger( - options.rateLimitRetries, - DEFAULT_RATE_LIMIT_RETRIES, - ) - const retryDelayMs = normalizeNonNegativeInteger( - options.rateLimitRetryDelayMs, - resolveDefaultRateLimitRetryDelayMs(normalizedPathname), - ) - - for (let attempt = 0; attempt <= maxRetries; attempt += 1) { - try { - await waitForCloudtentaclesSlot(rateLimitBucketKey, rateLimitIntervalMs) - const body = normalizeRequestBody(options.body, headers['content-type']) - const response = await executeCloudtentaclesRequest({ - url, - method, - headers, - timeoutMs, - ...(body !== undefined ? { body } : {}), - }) - const rawText = response.bodyText - const payload = tryParseJson(rawText) - - if (!response.ok) { - if (isCloudtentaclesHttpRateLimited(response.status) && attempt < maxRetries) { - await waitBeforeRateLimitRetry({ - attempt, - pathname: normalizedPathname, - method, - status: response.status, - retryDelayMs, - bucketKey: rateLimitBucketKey, - }) - continue - } - - logWarn('[cloudtentacles/http]', '请求 HTTP 失败', { - method, - pathname: normalizedPathname, - status: response.status, - attempt: attempt + 1, - sourceKey: options.sourceKey, - accountLabel: options.accountLabel, - context: options.context, - upstreamPayload: payload, - rawText: truncateText(rawText), - }) - - throw createHttpError(`cloudtentacles 请求失败,HTTP ${response.status}`, { - statusCode: isCloudtentaclesHttpRateLimited(response.status) ? 429 : 502, - errorCode: isCloudtentaclesHttpRateLimited(response.status) - ? 'cloudtentacles_rate_limited' - : 'cloudtentacles_http_error', - context: { - method, - pathname: normalizedPathname, - upstreamStatus: response.status, - upstreamPayload: payload, - requestContext: options.context, - }, - }) - } - - if (options.requireBusinessSuccess !== false && Number(payload?.code ?? 1) !== 0) { - const message = String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败') - const statusCode = Number(options.businessErrorStatusCode || 400) - const errorCode = String(options.businessErrorCode || 'cloudtentacles_business_error') - const upstreamCode = payload?.code - - if (isHighFrequencyMessage(message, upstreamCode)) { - if (attempt < maxRetries) { - await waitBeforeRateLimitRetry({ - attempt, - pathname: normalizedPathname, - method, - status: response.status, - retryDelayMs, - bucketKey: rateLimitBucketKey, - exponential: true, - }) - continue - } - - throw createHttpError('cloudtentacles 请求过于频繁,请稍后重试', { - statusCode: 429, - errorCode: 'cloudtentacles_rate_limited', - context: { - pathname: normalizedPathname, - upstreamMessage: message, - upstreamCode, - }, - }) - } - - if (isCloudtentaclesExpiredMessage(message)) { - await notifyCloudtentaclesAuthExpired({ - pathname: normalizedPathname, - errorCode, - message, - sourceKey: options.sourceKey, - accountLabel: options.accountLabel, - }) - } - - logWarn('[cloudtentacles/http]', '请求业务失败', { - method, - pathname: normalizedPathname, - status: response.status, - attempt: attempt + 1, - errorCode, - upstreamCode, - upstreamMessage: message, - sourceKey: options.sourceKey, - accountLabel: options.accountLabel, - context: options.context, - upstreamPayload: payload, - }) - - throw createHttpError(message, { - statusCode, - errorCode, - context: { - method, - pathname: normalizedPathname, - upstreamStatus: response.status, - upstreamCode, - upstreamMessage: message, - upstreamPayload: payload, - requestContext: options.context, - }, - }) - } - - logInfo('[cloudtentacles/http]', '请求完成', { - method, - pathname: normalizedPathname, - status: response.status, - attempt: attempt + 1, - sourceKey: options.sourceKey, - accountLabel: options.accountLabel, - context: options.context, - }) - - return { - url: url.toString(), - status: response.status, - headers: response.headers, - payload, - rawText, - } - } catch (error) { - if (error instanceof Error && error.name === 'AbortError') { - throw createHttpError(`cloudtentacles 请求超时(${timeoutMs}ms)`, { - statusCode: 504, - errorCode: 'cloudtentacles_request_timeout', - }) - } - - throw error - } - } - - throw createHttpError('cloudtentacles 请求过于频繁,请稍后重试', { - statusCode: 429, - errorCode: 'cloudtentacles_rate_limited', - }) -} - -/** 导出供单测:识别上游限频文案/错误码(含中文 110001) */ -export function isHighFrequencyMessage(message: unknown, code: unknown = null) { - const text = String(message || '') - .trim() - .toLowerCase() - const codeText = String(code ?? '').trim() - - if (codeText === '110001' || Number(code) === 110001) { - return true - } - - if (!text) { - return false - } - - return ( - text.includes('high-frequency request') || - text.includes('high frequency') || - text.includes('110001') || - text.includes('过于频繁') || - text.includes('请求过于频繁') || - text.includes('too frequent') || - text.includes('rate limit') || - text.includes('too many requests') - ) -} - -function isCloudtentaclesExpiredMessage(message: unknown) { - const normalized = String(message || '') - .trim() - .toLowerCase() - return ( - normalized.includes('expired') || normalized.includes('过期') || normalized.includes('失效') - ) -} - -function isCloudtentaclesHttpRateLimited(status: unknown) { - return Number(status) === 429 -} - -function resolveDefaultRateLimitIntervalMs(pathname: string) { - if (isBindInfoPath(pathname)) { - // bind_info 是 110001 高发接口,单独加长间隔 - return 1_500 - } - return DEFAULT_RATE_LIMIT_INTERVAL_MS -} - -function resolveDefaultRateLimitRetryDelayMs(pathname: string) { - if (isBindInfoPath(pathname)) { - return 2_500 - } - return DEFAULT_RATE_LIMIT_RETRY_DELAY_MS -} - -function isBindInfoPath(pathname: string) { - const normalized = String(pathname || '') - .trim() - .toLowerCase() - return normalized.includes('bind_info') || normalized.endsWith('/vn/bind_info') -} - -function truncateText(value: unknown, maxLength = 1000) { - const text = String(value || '') - if (text.length <= maxLength) { - return text - } - - return `${text.slice(0, maxLength)}...` -} - -async function executeCloudtentaclesRequest({ - url, - method, - headers, - body, - timeoutMs, -}: { - url: URL - method: string - headers: JsonObject - body?: string | URLSearchParams - timeoutMs: number -}) { - const controller = new AbortController() - const timer = setTimeout(() => controller.abort(), timeoutMs) - - try { - return await requestViaNodeHttp(url, { - method, - headers, - signal: controller.signal, - ...(body !== undefined ? { body } : {}), - }) - } finally { - clearTimeout(timer) - } -} - -async function waitForCloudtentaclesSlot(bucketKey: string, intervalMs: number) { - if (intervalMs <= 0) { - return - } - - const previousQueue = rateLimitQueue - let releaseQueue: () => void = () => {} - rateLimitQueue = new Promise((resolve) => { - releaseQueue = resolve - }) - - await previousQueue - - let waitMs - try { - const now = Date.now() - const nextAvailableAt = bucketNextAvailableAt.get(bucketKey) || 0 - waitMs = Math.max(0, nextAvailableAt - now) - bucketNextAvailableAt.set(bucketKey, Math.max(now, nextAvailableAt) + intervalMs) - } finally { - releaseQueue() - } - - if (waitMs > 0) { - await sleep(waitMs) - } -} - -async function waitBeforeRateLimitRetry(options: { - attempt: number - pathname: string - method: string - status: number - retryDelayMs: number - bucketKey: string - exponential?: boolean -}) { - const { attempt, pathname, method, status, retryDelayMs, bucketKey, exponential } = options - const factor = exponential ? 2 ** attempt : attempt + 1 - const delayMs = retryDelayMs * factor + Math.floor(Math.random() * MAX_RETRY_JITTER_MS) - - // 命中限频后额外冷却账号桶,避免紧接着再打 - const coolUntil = Date.now() + Math.max(delayMs, RATE_LIMIT_COOLDOWN_MS) - const current = bucketNextAvailableAt.get(bucketKey) || 0 - if (coolUntil > current) { - bucketNextAvailableAt.set(bucketKey, coolUntil) - } - - logWarn('[cloudtentacles/http]', '请求触发云触手限频,准备重试', { - method, - pathname, - status, - attempt: attempt + 1, - retryDelayMs: delayMs, - }) - await sleep(delayMs) -} - -function resolveRateLimitBucketKey({ - baseUrl, - token, - sourceKey, - deviceId, - pathname, - isolatePath, -}: { - baseUrl: unknown - token: unknown - sourceKey: unknown - deviceId: unknown - pathname?: unknown - isolatePath?: boolean -}) { - const parts = [ - String(baseUrl || '').trim(), - String(sourceKey || '').trim() || String(token || '').trim() || 'anonymous', - String(deviceId || '').trim(), - ] - if (isolatePath) { - parts.push(String(pathname || '').trim()) - } - return parts.join('|') -} - -function normalizeNonNegativeInteger(value: unknown, fallback: number) { - const numericValue = Number(value) - if (Number.isInteger(numericValue) && numericValue >= 0) { - return numericValue - } - - return fallback -} - -function sleep(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms) - }) -} - -function inferContentType(body: unknown) { - if (body == null) { - return '' - } - - if (typeof body === 'string' || body instanceof URLSearchParams) { - return 'application/json' - } - - return 'application/json' -} - -function normalizeRequestBody(body: unknown, contentType: unknown) { - if (body == null) { - return undefined - } - - if (typeof body === 'string' || body instanceof URLSearchParams) { - return body - } - - if (String(contentType || '').includes('application/json')) { - return JSON.stringify(body) - } - - return String(body) -} - -function tryParseJson(text: string) { - try { - return JSON.parse(text) - } catch { - return null - } -} - -async function requestViaNodeHttp( - url: URL, - { - method, - headers, - body, - signal, - }: { - method: string - headers: JsonObject - body?: string | URLSearchParams - signal?: AbortSignal - }, -): Promise { - const isHttps = url.protocol === 'https:' - const transport = isHttps ? https : http - - return new Promise((resolve, reject) => { - const request = transport.request( - url, - { - method, - headers, - rejectUnauthorized: false, - }, - (response) => { - const chunks: Buffer[] = [] - - response.on('data', (chunk) => { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) - }) - - response.on('end', () => { - const bodyText = Buffer.concat(chunks).toString('utf8') - resolve({ - ok: Number(response.statusCode || 0) >= 200 && Number(response.statusCode || 0) < 300, - status: Number(response.statusCode || 0), - headers: createHeaderAdapter(response.headers), - bodyText, - }) - }) - }, - ) - - request.on('error', reject) - - if (signal) { - if (signal.aborted) { - const error = new Error('Request aborted') - error.name = 'AbortError' - request.destroy(error) - } else { - signal.addEventListener( - 'abort', - () => { - const error = new Error('Request aborted') - error.name = 'AbortError' - request.destroy(error) - }, - { once: true }, - ) - } - } - - if (typeof body !== 'undefined') { - request.write(body) - } - - request.end() - }) -} - -function createHeaderAdapter(headers: http.IncomingHttpHeaders): HeaderAdapter { - const normalized = new Map() - - for (const [key, value] of Object.entries(headers || {})) { - if (Array.isArray(value)) { - normalized.set(String(key || '').toLowerCase(), value.join(', ')) - continue - } - - if (typeof value === 'string') { - normalized.set(String(key || '').toLowerCase(), value) - } - } - - return { - get(name: unknown) { - return normalized.get(String(name || '').toLowerCase()) || null - }, - } -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts b/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts deleted file mode 100644 index 3e382d17..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/knapsack-service.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './helpers.js' -import type { JsonObject } from '../../../types/json.js' - -export async function getCloudtentaclesKnapsack(payload: JsonObject = {}) { - const token = String(payload.token || '').trim() - if (!token) { - throw createHttpError('cloudtentacles 背包查询缺少 token', { - statusCode: 400, - errorCode: 'cloudtentacles_knapsack_missing_token', - }) - } - - const config = resolveCloudtentaclesConfig(payload) - const result = await cloudtentaclesRequest(config.knapsackPath, { - ...config, - method: 'GET', - token, - contentType: 'application/json', - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'knapsack_get', - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_knapsack_failed', - }) - - const items = Array.isArray(result.payload?.data) ? result.payload.data.map(mapKnapsackItem) : [] - - return { - baseUrl: config.baseUrl, - itemCount: items.length, - items, - rawItems: Array.isArray(result.payload?.data) ? result.payload.data : [], - } -} - -function mapKnapsackItem(item: unknown) { - const source = isPlainObject(item) ? item : {} - - return { - id: Number(source.id || 0), - name: String(source.name || '').trim(), - count: Number(source.count || 0), - image: String(source.image || '').trim(), - categoriesId: Number(source.categories_id || 0), - listingTime: String(source.listing_time || '').trim(), - delistingTime: String(source.delisting_time || '').trim(), - raw: source, - } -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/record-service.ts b/apps/backend/src/services/platforms/cloudtentacles/record-service.ts deleted file mode 100644 index 8df93465..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/record-service.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { logInfo } from '../../../utils/logger.js' -import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './helpers.js' -import type { JsonObject } from '../../../types/json.js' - -export type CloudtentaclesDeliveryRecordQuery = JsonObject & { - baseUrl?: string - timeoutMs?: number | string - token?: string - deviceId?: string - deviceType?: number | string - page?: number | string - size?: number | string - startDate?: string - endDate?: string - recordCode?: number | string -} - -export async function listCloudtentaclesDeliveryRecords( - payload: CloudtentaclesDeliveryRecordQuery = {}, -) { - const config = resolveCloudtentaclesConfig({ - ...pickDefined({ - baseUrl: payload.baseUrl, - deviceId: payload.deviceId, - deviceType: payload.deviceType, - }), - ...(typeof payload.timeoutMs === 'undefined' - ? {} - : { timeoutMs: normalizePositiveInteger(payload.timeoutMs, 5000) }), - }) - const page = normalizePositiveInteger(payload.page, 1) - const size = normalizePageSize(payload.size) - const startDate = normalizeDateInput(payload.startDate) - const endDate = normalizeDateInput(payload.endDate) - - if (!String(payload.token || '').trim()) { - throw createHttpError('cloudtentacles 发货记录查询缺少 token', { - statusCode: 409, - errorCode: 'cloudtentacles_record_missing_token', - }) - } - - if (!startDate || !endDate) { - throw createHttpError('cloudtentacles 发货记录查询需要选择开始和结束时间', { - statusCode: 400, - errorCode: 'cloudtentacles_record_date_required', - }) - } - - const result = await cloudtentaclesRequest('/record/paging', { - ...payload, - method: 'POST', - body: { - record_code: normalizePositiveInteger(payload.recordCode, 4), - page, - size, - start_date: startDate, - end_date: endDate, - }, - businessErrorCode: 'cloudtentacles_record_query_failed', - timeoutMs: payload.timeoutMs || config.timeoutMs, - }) - - const data = isPlainObject(result.payload?.data) ? result.payload.data : {} - const values = Array.isArray(data.values) ? data.values : [] - const total = normalizeNonNegativeInteger(data.total, values.length) - - logInfo('[cloudtentacles/records]', '发货记录查询完成', { - page, - size, - recordCode: normalizePositiveInteger(payload.recordCode, 4), - startDate, - endDate, - total, - returnedCount: values.length, - }) - - return { - baseUrl: config.baseUrl, - page, - size, - total, - items: values.map(mapCloudtentaclesDeliveryRecord), - raw: data, - } -} - -function mapCloudtentaclesDeliveryRecord(raw: JsonObject) { - return { - createdAt: String(raw.c_time || '').trim(), - recordId: String(raw.record_id || '').trim(), - virtualNumberId: normalizeNonNegativeInteger(raw.virtual_number_id, 0), - userId: normalizeNonNegativeInteger(raw.user_id, 0), - count: normalizeNonNegativeInteger(raw.count, 0), - cdk: String(raw.cdk || '').trim(), - exchangeUrl: String(raw.exchange_url || '').trim(), - name: String(raw.name || '').trim(), - phone: String(raw.phone || '').trim(), - status: normalizeNonNegativeInteger(raw.status, 0), - fulfillUser: String(raw.fulfill_user || '').trim(), - operatorName: String(raw.u_name || '').trim(), - raw, - } -} - -function normalizePositiveInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback -} - -function normalizePageSize(value: unknown) { - const parsed = normalizePositiveInteger(value, 100) - return Math.min(Math.max(parsed, 100), 1000) -} - -function normalizeNonNegativeInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback -} - -function normalizeDateInput(value: unknown) { - const raw = String(value || '').trim() - if (!raw) { - return '' - } - - const date = new Date(raw) - return Number.isNaN(date.getTime()) ? raw : date.toISOString() -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} - -function pickDefined(values: JsonObject = {}) { - return Object.fromEntries( - Object.entries(values).filter(([, value]) => typeof value !== 'undefined'), - ) -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-service.ts deleted file mode 100644 index cc0692bc..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/session-service.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { createHttpError } from '../../../utils/http.js' -import { logInfo } from '../../../utils/logger.js' -import { encryptCloudtentaclesPayload, md5CloudtentaclesPassword } from './crypto-service.js' -import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './helpers.js' -import type { JsonObject } from '../../../types/json.js' - -export async function sendCloudtentaclesSmsCode(payload: JsonObject = {}) { - const username = String(payload.username || '').trim() - const phone = String(payload.phone || '').trim() - - if (!username) { - throw createHttpError('cloudtentacles 缺少账号', { - statusCode: 400, - errorCode: 'cloudtentacles_missing_username', - }) - } - - if (!phone) { - throw createHttpError('cloudtentacles 缺少手机号', { - statusCode: 400, - errorCode: 'cloudtentacles_missing_phone', - }) - } - - const config = resolveCloudtentaclesConfig(payload) - const encrypted = encryptCloudtentaclesPayload( - { - account: username, - phone, - }, - config, - ) - - const result = await cloudtentaclesRequest(config.sendSmsPath, { - ...config, - method: 'POST', - body: { - covert: encrypted.covert, - t: encrypted.t, - r: encrypted.r, - }, - businessErrorStatusCode: 400, - businessErrorCode: 'cloudtentacles_send_sms_failed', - }) - - logInfo('[cloudtentacles/session]', '短信验证码发送成功', { - username, - phone: maskPhone(phone), - }) - - return { - baseUrl: config.baseUrl, - username, - phone, - sentAt: new Date().toISOString(), - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - } -} - -export async function loginCloudtentaclesSession(payload: JsonObject = {}) { - const username = String(payload.username || '').trim() - const password = String(payload.password || '').trim() - const phone = String(payload.phone || '').trim() - const code = String(payload.code || '').trim() - - if (!username) { - throw createHttpError('cloudtentacles 登录缺少账号', { - statusCode: 400, - errorCode: 'cloudtentacles_login_missing_username', - }) - } - - if (!password) { - throw createHttpError('cloudtentacles 登录缺少密码', { - statusCode: 400, - errorCode: 'cloudtentacles_login_missing_password', - }) - } - - if (!phone) { - throw createHttpError('cloudtentacles 登录缺少手机号', { - statusCode: 400, - errorCode: 'cloudtentacles_login_missing_phone', - }) - } - - if (!code) { - throw createHttpError('cloudtentacles 登录缺少短信验证码', { - statusCode: 400, - errorCode: 'cloudtentacles_login_missing_code', - }) - } - - const config = resolveCloudtentaclesConfig(payload) - const encrypted = encryptCloudtentaclesPayload( - { - account: username, - password: md5CloudtentaclesPassword(password), - phone, - code, - }, - config, - ) - - const loginResult = await cloudtentaclesRequest(config.loginPath, { - ...config, - method: 'POST', - body: { - covert: encrypted.covert, - t: encrypted.t, - r: encrypted.r, - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_login_failed', - }) - - const token = String(loginResult.payload?.data || '').trim() - if (!token) { - throw createHttpError('cloudtentacles 登录成功但未返回 token', { - statusCode: 502, - errorCode: 'cloudtentacles_missing_token', - }) - } - - const session = await validateCloudtentaclesSession({ - ...config, - token, - }) - - return { - ...session, - username, - phone, - responseMessage: String(loginResult.payload?.message || loginResult.payload?.msg || 'success'), - } -} - -export async function validateCloudtentaclesSession(payload: JsonObject = {}) { - const token = String(payload.token || '').trim() - - if (!token) { - throw createHttpError('cloudtentacles 会话校验缺少 token', { - statusCode: 400, - errorCode: 'cloudtentacles_validate_missing_token', - }) - } - - const config = resolveCloudtentaclesConfig(payload) - const [userInfoResult, assetResult, permissionResult] = await Promise.all([ - cloudtentaclesRequest(config.userInfoPath, { - ...config, - method: 'POST', - token, - body: {}, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_user_info_failed', - }), - cloudtentaclesRequest(config.assetPath, { - ...config, - method: 'GET', - token, - contentType: 'application/json', - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_asset_failed', - }), - cloudtentaclesRequest(config.permissionPath, { - ...config, - method: 'GET', - token, - contentType: 'application/json', - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_permission_failed', - }), - ]) - - return { - baseUrl: config.baseUrl, - token, - loggedInAt: new Date().toISOString(), - userInfo: isPlainObject(userInfoResult.payload?.data) ? userInfoResult.payload.data : {}, - asset: Number(assetResult.payload?.data || 0), - permissions: Array.isArray(permissionResult.payload?.data) - ? permissionResult.payload.data - .map((item: unknown) => String(item || '').trim()) - .filter(Boolean) - : [], - } -} - -function maskPhone(phone: unknown) { - const normalized = String(phone || '').trim() - if (normalized.length < 7) { - return normalized - } - - return `${normalized.slice(0, 3)}****${normalized.slice(-4)}` -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts b/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts deleted file mode 100644 index a65870a0..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/session-state-service.ts +++ /dev/null @@ -1,202 +0,0 @@ -import path from 'node:path' -import type { JsonObject } from '../../../types/json.js' - -import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' -import { PROJECT_ROOT } from '../../../config/runtime.js' -import { createHttpError } from '../../../utils/http.js' -import { readAppConfigEntry, saveAppConfigEntry } from '../../config/app-config-store.js' -import { - DEFAULT_CLOUDTENTACLES_DEVICE_ID, - DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from './defaults.js' - -const CLOUDTENTACLES_SESSION_FILE_PATH = path.join( - PROJECT_ROOT, - 'data', - 'cloudtentacles-session.json', -) - -type CloudtentaclesSessionState = ReturnType -type CloudtentaclesSessionStatesFile = { - sessions: Record -} - -export function getCloudtentaclesSessionFilePath() { - return CLOUDTENTACLES_SESSION_FILE_PATH -} - -// 兼容旧调用:返回 key='default' 的会话。 -export function getCloudtentaclesSessionState() { - const states = loadCloudtentaclesSessionStates() - return states.sessions['default'] || createDefaultCloudtentaclesSessionState() -} - -// 按 sourceKey 获取会话,未命中时返回 null。 -export function getCloudtentaclesSessionStateByKey(sourceKey: unknown) { - const states = loadCloudtentaclesSessionStates() - const key = String(sourceKey || '').trim() - if (!key) return null - return states.sessions[key] || null -} - -// 返回所有账号的会话状态。 -export function getAllCloudtentaclesSessionStates() { - return loadCloudtentaclesSessionStates() -} - -// 兼容旧保存格式:支持单会话对象和 sessions 映射。 -export function saveCloudtentaclesSessionState(rawValue: unknown) { - const normalized = normalizeSessionStatesFile(rawValue) - return saveAppConfigEntry({ - configKey: APP_CONFIG_KEYS.cloudtentaclesSession, - legacyFilePath: CLOUDTENTACLES_SESSION_FILE_PATH, - value: normalized, - normalize: normalizeSessionStatesFile, - }) -} - -// 保存指定 sourceKey 的会话。 -export async function saveCloudtentaclesSessionStateByKey(sourceKey: unknown, rawValue: unknown) { - const key = String(sourceKey || '').trim() - if (!key) { - throw createHttpError('cloudtentacles sourceKey 不能为空', { - statusCode: 400, - errorCode: 'cloudtentacles_source_key_required', - }) - } - - const states = loadCloudtentaclesSessionStates() - states.sessions[key] = normalizeCloudtentaclesSessionState(rawValue) - - await saveCloudtentaclesSessionState(states) - return states.sessions[key] -} - -// 删除指定 sourceKey 的会话。 -export async function deleteCloudtentaclesSessionStateByKey(sourceKey: unknown) { - const key = String(sourceKey || '').trim() - if (!key) { - throw createHttpError('cloudtentacles sourceKey 不能为空', { - statusCode: 400, - errorCode: 'cloudtentacles_source_key_required', - }) - } - - const states = loadCloudtentaclesSessionStates() - delete states.sessions[key] - - await saveCloudtentaclesSessionState(states) - return states -} - -// 兼容旧调用:只清理 default 会话。 -export function clearCloudtentaclesSessionState() { - return clearCloudtentaclesSessionStateByKey('default') -} - -// 清理指定 sourceKey 的会话,重置为空状态。 -export async function clearCloudtentaclesSessionStateByKey(sourceKey: unknown) { - const key = String(sourceKey || '').trim() - if (!key) { - throw createHttpError('cloudtentacles sourceKey 不能为空', { - statusCode: 400, - errorCode: 'cloudtentacles_source_key_required', - }) - } - - const cleared = createDefaultCloudtentaclesSessionState() - await saveCloudtentaclesSessionStateByKey(key, cleared) - return cleared -} - -// 只保留仍存在 source 配置的会话。 -export async function pruneCloudtentaclesSessionStates(sourceKeys: unknown[] = []) { - const allowedKeys = new Set( - Array.isArray(sourceKeys) - ? sourceKeys.map((value) => String(value || '').trim()).filter(Boolean) - : [], - ) - - const states = loadCloudtentaclesSessionStates() - states.sessions = Object.fromEntries( - Object.entries(states.sessions || {}).filter(([key]) => - allowedKeys.has(String(key || '').trim()), - ), - ) - - await saveCloudtentaclesSessionState(states) - return states -} - -function loadCloudtentaclesSessionStates(): CloudtentaclesSessionStatesFile { - return readAppConfigEntry({ - configKey: APP_CONFIG_KEYS.cloudtentaclesSession, - legacyFilePath: CLOUDTENTACLES_SESSION_FILE_PATH, - fallback: createDefaultCloudtentaclesSessionStates, - normalize: normalizeSessionStatesFile, - }) -} - -// 标准化单个会话状态。 -function normalizeCloudtentaclesSessionState(rawValue: unknown) { - const source = isPlainObject(rawValue) ? rawValue : {} - - return { - token: String(source.token || '').trim(), - baseUrl: String(source.baseUrl || '').trim(), - username: String(source.username || '').trim(), - phone: String(source.phone || '').trim(), - loggedInAt: String(source.loggedInAt || '').trim(), - deviceId: normalizeCloudtentaclesDeviceId(source.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(source.deviceType), - } -} - -// 标准化整体会话状态,同时兼容旧版单会话对象。 -export function normalizeSessionStatesFile(rawValue: unknown): CloudtentaclesSessionStatesFile { - if (isPlainObject(rawValue) && !rawValue.sessions) { - return { - sessions: { - default: normalizeCloudtentaclesSessionState(rawValue), - }, - } - } - - return { - sessions: - isPlainObject(rawValue) && isPlainObject(rawValue.sessions) - ? Object.fromEntries( - Object.entries(rawValue.sessions).map(([k, v]) => [ - k, - normalizeCloudtentaclesSessionState(v), - ]), - ) - : {}, - } -} - -function createDefaultCloudtentaclesSessionState() { - return { - token: '', - baseUrl: '', - username: '', - phone: '', - loggedInAt: '', - deviceId: DEFAULT_CLOUDTENTACLES_DEVICE_ID, - deviceType: DEFAULT_CLOUDTENTACLES_DEVICE_TYPE, - } -} - -function createDefaultCloudtentaclesSessionStates(): CloudtentaclesSessionStatesFile { - return { - sessions: { - default: createDefaultCloudtentaclesSessionState(), - }, - } -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts deleted file mode 100644 index 84c33ae9..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/source-config-service.ts +++ /dev/null @@ -1,163 +0,0 @@ -import path from 'node:path' -import type { JsonObject } from '../../../types/json.js' - -import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' -import { PROJECT_ROOT } from '../../../config/runtime.js' -import { createHttpError } from '../../../utils/http.js' -import { readAppConfigEntry, saveAppConfigEntry } from '../../config/app-config-store.js' -import { normalizeCloudtentaclesDeviceId, normalizeCloudtentaclesDeviceType } from './defaults.js' - -const CLOUDTENTACLES_SOURCES_FILE_PATH = path.join( - PROJECT_ROOT, - 'data', - 'cloudtentacles-sources.json', -) - -export function getCloudtentaclesSourcesFilePath() { - return CLOUDTENTACLES_SOURCES_FILE_PATH -} - -// 兼容旧调用:返回 key='default' 的单账号配置。 -export function getCloudtentaclesSourceConfig() { - const config = loadCloudtentaclesSourcesConfig() - const defaultSource = config.sources.find((s) => s.key === 'default') - return defaultSource || normalizeCloudtentaclesSourceItem({ key: 'default' }) -} - -// 按 sourceKey 获取账号配置,未命中时返回 null。 -export function getCloudtentaclesSourceByKey(sourceKey: unknown) { - const config = loadCloudtentaclesSourcesConfig() - const key = String(sourceKey || '').trim() - if (!key) return null - return config.sources.find((s) => s.key === key) || null -} - -// 返回完整的多账号配置。 -export function listCloudtentaclesSources() { - return loadCloudtentaclesSourcesConfig() -} - -// 兼容旧保存格式:支持单账号对象和 { enabled, sources } 多账号格式。 -export function saveCloudtentaclesSourceConfig(rawValue: unknown) { - const normalized = normalizeCloudtentaclesSourcesConfig(rawValue) - return saveAppConfigEntry({ - configKey: APP_CONFIG_KEYS.cloudtentaclesSources, - legacyFilePath: CLOUDTENTACLES_SOURCES_FILE_PATH, - value: normalized, - normalize: normalizeCloudtentaclesSourcesConfig, - }) -} - -// 保存完整多账号配置。 -export function saveCloudtentaclesSourcesList(rawValue: unknown) { - return saveCloudtentaclesSourceConfig(rawValue) -} - -// 按 sourceKey 新增或替换单个账号配置。 -export async function saveCloudtentaclesSourceByKey(sourceKey: unknown, data: JsonObject = {}) { - const key = String(sourceKey || '').trim() - if (!key) { - throw createHttpError('cloudtentacles sourceKey 不能为空', { - statusCode: 400, - errorCode: 'cloudtentacles_source_key_required', - }) - } - - const config = loadCloudtentaclesSourcesConfig() - const normalizedItem = normalizeCloudtentaclesSourceItem({ ...data, key }) - const existingIndex = config.sources.findIndex((s) => s.key === key) - - if (existingIndex >= 0) { - config.sources[existingIndex] = normalizedItem - } else { - config.sources.push(normalizedItem) - } - - await saveCloudtentaclesSourceConfig(config) - return normalizedItem -} - -// 删除单个账号配置。 -export async function deleteCloudtentaclesSourceByKey(sourceKey: unknown) { - const key = String(sourceKey || '').trim() - if (!key) { - throw createHttpError('cloudtentacles sourceKey 不能为空', { - statusCode: 400, - errorCode: 'cloudtentacles_source_key_required', - }) - } - - const config = loadCloudtentaclesSourcesConfig() - const existingIndex = config.sources.findIndex((s) => s.key === key) - - if (existingIndex < 0) { - return config - } - - config.sources.splice(existingIndex, 1) - - await saveCloudtentaclesSourceConfig(config) - return config -} - -function loadCloudtentaclesSourcesConfig() { - return readAppConfigEntry({ - configKey: APP_CONFIG_KEYS.cloudtentaclesSources, - legacyFilePath: CLOUDTENTACLES_SOURCES_FILE_PATH, - fallback: createDefaultCloudtentaclesSourcesConfig, - normalize: normalizeCloudtentaclesSourcesConfig, - }) -} - -// 标准化单个账号配置,补齐 key 和 label。 -function normalizeCloudtentaclesSourceItem(rawValue: unknown) { - const source = isPlainObject(rawValue) ? rawValue : {} - - return { - key: String(source.key || 'default').trim() || 'default', - label: String(source.label || '').trim(), - enabled: source.enabled !== false, - baseUrl: - String(source.baseUrl || 'https://123.207.217.176').trim() || 'https://123.207.217.176', - username: String(source.username || '').trim(), - password: String(source.password || '').trim(), - phone: String(source.phone || '').trim(), - deviceId: normalizeCloudtentaclesDeviceId(source.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(source.deviceType), - } -} - -// 标准化整体配置,同时兼容旧版单账号对象。 -export function normalizeCloudtentaclesSourcesConfig(rawValue: unknown) { - if (isPlainObject(rawValue) && !Array.isArray(rawValue.sources)) { - return { - enabled: rawValue.enabled !== false, - sources: [ - normalizeCloudtentaclesSourceItem({ - key: 'default', - label: '默认账号', - ...rawValue, // 旧字段自动映射为 default 账号。 - }), - ].filter(Boolean), - } - } - - return { - enabled: isPlainObject(rawValue) ? rawValue.enabled !== false : true, - sources: - isPlainObject(rawValue) && Array.isArray(rawValue.sources) - ? rawValue.sources.map((s) => normalizeCloudtentaclesSourceItem(s)).filter(Boolean) - : [], - } -} - -function createDefaultCloudtentaclesSourcesConfig() { - return { - enabled: true, - sources: [normalizeCloudtentaclesSourceItem({ key: 'default', label: '默认账号' })], - } -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts deleted file mode 100644 index c60a72e9..00000000 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.ts +++ /dev/null @@ -1,843 +0,0 @@ -import http from 'node:http' -import https from 'node:https' -import type { JsonObject } from '../../../types/json.js' - -import { createHttpError } from '../../../utils/http.js' -import { cloudtentaclesRequest } from './http-client.js' -import { resolveCloudtentaclesConfig } from './helpers.js' -import { maskPhone } from '../../../utils/masking.js' - -const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024 -const AMS_SIGNATURE_EXPIRED_CODE = '99998' -/** 同一 vn 短时复用 bind_info,降低领取轮询撞 110001 概率 */ -const BIND_INFO_CACHE_TTL_MS = 15_000 -const BIND_INFO_RATE_LIMIT_INTERVAL_MS = 1_500 -const BIND_INFO_RATE_LIMIT_RETRY_DELAY_MS = 2_500 - -type BindInfoResult = { - baseUrl: string - key: string - id: number - bindInfo: unknown - rawItem: JsonObject - raw: unknown -} - -const bindInfoCache = new Map() - -type HeaderAdapter = { - get(name: unknown): string | null -} -type AmsProbeResponse = { - status: number - headers: HeaderAdapter - bodyText: string -} -type AmsSignatureParams = { - userId: string - timestamp: string - nonce: string - sign: string -} - -export async function listCloudtentaclesVirtualNumbers(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 虚拟号列表缺少 token', - 'cloudtentacles_vn_list_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 虚拟号列表缺少 key', - 'cloudtentacles_vn_list_missing_key', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.vnListPath, { - ...config, - method: 'POST', - token, - body: { key }, - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_list', - vnKey: key, - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_vn_list_failed', - }) - - const items = Array.isArray(result.payload?.data) - ? result.payload.data.map(mapVirtualNumberItem) - : [] - const occupiedItemCount = items.filter( - (item: ReturnType) => item.status !== 0, - ).length - - return { - baseUrl: config.baseUrl, - key, - itemCount: items.length, - occupiedItemCount, - items, - rawItems: Array.isArray(result.payload?.data) ? result.payload.data : [], - } -} - -export async function appointCloudtentaclesVirtualNumber(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 申请虚拟号缺少 token', - 'cloudtentacles_vn_appoint_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 申请虚拟号缺少 key', - 'cloudtentacles_vn_appoint_missing_key', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.vnAppointPath, { - ...config, - method: 'POST', - token, - body: { key }, - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_appoint', - vnKey: key, - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_vn_appoint_failed', - }) - - return { - baseUrl: config.baseUrl, - key, - item: mapVirtualNumberItem(result.payload?.data), - rawItem: isPlainObject(result.payload?.data) ? result.payload.data : {}, - } -} - -export async function generateCloudtentaclesLoginCode(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 生成登录码缺少 token', - 'cloudtentacles_vn_generate_code_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 生成登录码缺少 key', - 'cloudtentacles_vn_generate_code_missing_key', - ) - const id = requireId( - payload.id, - 'cloudtentacles 生成登录码缺少 id', - 'cloudtentacles_vn_generate_code_missing_id', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.vnGenerateLoginCodePath, { - ...config, - method: 'POST', - token, - body: { key, id }, - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_generate_code', - vnKey: key, - vnId: id, - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_vn_generate_code_failed', - }) - - return { - baseUrl: config.baseUrl, - key, - id, - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - } -} - -export async function fetchCloudtentaclesVirtualNumberCode(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 获取验证码缺少 token', - 'cloudtentacles_vn_verif_code_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 获取验证码缺少 key', - 'cloudtentacles_vn_verif_code_missing_key', - ) - const phone = String(payload.phone || '').trim() - if (!phone) { - throw createHttpError('cloudtentacles 获取验证码缺少手机号', { - statusCode: 400, - errorCode: 'cloudtentacles_vn_verif_code_missing_phone', - }) - } - - const config = resolveCloudtentaclesConfig(payload) - const result = await cloudtentaclesRequest(config.vnVerifCodePath, { - ...config, - method: 'POST', - token, - body: { key, phone }, - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_fetch_code', - vnKey: key, - phoneMasked: maskPhone(phone), - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_vn_verif_code_failed', - }) - - return { - baseUrl: config.baseUrl, - key, - phone, - code: String(result.payload?.data || '').trim(), - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - } -} - -export async function verifyCloudtentaclesLoginCode(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 校验登录码缺少 token', - 'cloudtentacles_vn_verify_code_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 校验登录码缺少 key', - 'cloudtentacles_vn_verify_code_missing_key', - ) - const id = requireId( - payload.id, - 'cloudtentacles 校验登录码缺少 id', - 'cloudtentacles_vn_verify_code_missing_id', - ) - const code = String(payload.code || '').trim() - if (!code) { - throw createHttpError('cloudtentacles 校验登录码缺少验证码', { - statusCode: 400, - errorCode: 'cloudtentacles_vn_verify_code_missing_code', - }) - } - - const config = resolveCloudtentaclesConfig(payload) - const result = await cloudtentaclesRequest(config.vnVerifyLoginCodePath, { - ...config, - method: 'POST', - token, - body: { key, id, code }, - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_verify_code', - vnKey: key, - vnId: id, - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_vn_verify_code_failed', - }) - - return { - baseUrl: config.baseUrl, - key, - id, - code, - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - } -} - -export async function getCloudtentaclesBindUrl(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 获取兑换链接缺少 token', - 'cloudtentacles_vn_bind_url_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 获取兑换链接缺少 key', - 'cloudtentacles_vn_bind_url_missing_key', - ) - const id = requireId( - payload.id, - 'cloudtentacles 获取兑换链接缺少 id', - 'cloudtentacles_vn_bind_url_missing_id', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.vnBindUrlPath, { - ...config, - method: 'POST', - token, - body: { id, key }, - businessErrorStatusCode: 409, - businessErrorCode: 'cloudtentacles_vn_bind_url_failed', - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_bind_url', - vnKey: key, - vnId: id, - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - }) - - return { - baseUrl: config.baseUrl, - key, - id, - bindUrl: String(result.payload?.data || '').trim(), - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - } -} - -export async function probeCloudtentaclesBindUrl(payload: JsonObject = {}) { - const bindUrl = String(payload.bindUrl || '').trim() - if (!bindUrl) { - return { - valid: false, - status: 0, - finalUrl: '', - reason: 'empty_url', - } - } - - const config = resolveCloudtentaclesConfig(payload) - const signatureParams = extractBindUrlSignatureParams(bindUrl) - if (!signatureParams) { - return { - valid: false, - expired: false, - status: 0, - finalUrl: bindUrl, - reason: 'missing_signature_params', - roleInfo: normalizeAmsBindRoleInfo(null), - raw: null as null, - } - } - - const timeoutMs = Number(payload.timeoutMs || config.bindUrlProbeTimeoutMs || 5000) - const userAgent = String(payload.userAgent || config.bindUrlProbeUserAgent || '').trim() - const endpoint = String( - payload.endpoint || config.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/', - ).trim() - const startedAt = Date.now() - - try { - const response = await requestAmsIdeProbe(endpoint, { - timeoutMs, - body: buildAmsIdeProbeBody(signatureParams, config, payload), - headers: buildAmsIdeProbeHeaders(signatureParams, config, { - userAgent, - }), - }) - const raw = tryParseJson(response.bodyText) - const expired = isAmsSignatureExpired(raw) - const bindInfo = extractAmsBindInfo(raw) - const roleInfo = normalizeAmsBindRoleInfo(bindInfo) - const ok = response.status >= 200 && response.status < 300 && !expired && isAmsBusinessOk(raw) - - return { - valid: ok, - expired, - status: response.status, - finalUrl: endpoint, - ret: String(raw?.ret ?? raw?.iRet ?? ''), - iRet: String(raw?.iRet ?? raw?.jData?.iRet ?? ''), - message: String(raw?.sMsg || raw?.jData?.sMsg || ''), - reason: expired ? 'signature_expired' : ok ? 'ok' : 'ams_business_not_ok', - roleInfo, - raw, - durationMs: Date.now() - startedAt, - } - } catch (error) { - return { - valid: false, - expired: false, - status: 0, - finalUrl: bindUrl, - reason: error instanceof Error ? error.message : String(error || 'probe_failed'), - roleInfo: normalizeAmsBindRoleInfo(null), - raw: null as null, - durationMs: Date.now() - startedAt, - } - } -} - -export async function getCloudtentaclesBindInfo(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 获取绑定信息缺少 token', - 'cloudtentacles_vn_bind_info_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 获取绑定信息缺少 key', - 'cloudtentacles_vn_bind_info_missing_key', - ) - const id = requireId( - payload.id, - 'cloudtentacles 获取绑定信息缺少 id', - 'cloudtentacles_vn_bind_info_missing_id', - ) - const config = resolveCloudtentaclesConfig(payload) - const sourceKey = String(payload.sourceKey || '').trim() - const cacheKey = `${sourceKey || token}|${id}|${key}` - const skipCache = payload.skipCache === true - const now = Date.now() - - if (!skipCache) { - const cached = bindInfoCache.get(cacheKey) - if (cached && cached.expiresAt > now) { - return cached.value - } - } - - const result = await cloudtentaclesRequest(config.vnBindInfoPath, { - ...config, - method: 'POST', - token, - body: [{ id, key }], - businessErrorStatusCode: 401, - businessErrorCode: 'cloudtentacles_vn_bind_info_failed', - // bind_info 是 110001 高发接口:加长间隔、独立路径桶、更长退避 - rateLimitIntervalMs: BIND_INFO_RATE_LIMIT_INTERVAL_MS, - rateLimitRetryDelayMs: BIND_INFO_RATE_LIMIT_RETRY_DELAY_MS, - rateLimitRetries: 2, - rateLimitIsolatePath: true, - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_bind_info', - vnKey: key, - vnId: id, - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - }) - - const items = Array.isArray(result.payload?.data) ? result.payload.data : [] - const matchedItem = - items.find((item: JsonObject) => Number(item?.id || 0) === id) || items[0] || {} - const bindInfo = parseBindInfo(matchedItem?.bind_info) - - const value: BindInfoResult = { - baseUrl: config.baseUrl, - key, - id, - bindInfo, - rawItem: isPlainObject(matchedItem) ? matchedItem : {}, - raw: result.payload ?? null, - } - - bindInfoCache.set(cacheKey, { - expiresAt: now + BIND_INFO_CACHE_TTL_MS, - value, - }) - - // 简单淘汰过期项,避免 map 无限增长 - if (bindInfoCache.size > 500) { - for (const [entryKey, entry] of bindInfoCache) { - if (entry.expiresAt <= now) { - bindInfoCache.delete(entryKey) - } - } - } - - return value -} - -export async function backCloudtentaclesVirtualNumber(payload: JsonObject = {}) { - const token = requireToken( - payload.token, - 'cloudtentacles 退还号码缺少 token', - 'cloudtentacles_vn_back_missing_token', - ) - const key = requireKey( - payload.key, - 'cloudtentacles 退还号码缺少 key', - 'cloudtentacles_vn_back_missing_key', - ) - const id = requireId( - payload.id, - 'cloudtentacles 退还号码缺少 id', - 'cloudtentacles_vn_back_missing_id', - ) - const config = resolveCloudtentaclesConfig(payload) - - const result = await cloudtentaclesRequest(config.vnBackPath, { - ...config, - method: 'POST', - token, - body: { key, id }, - // 409:业务失败,避免前端/客服误判为后台登录或角色权限问题 - businessErrorStatusCode: 409, - businessErrorCode: 'cloudtentacles_vn_back_failed', - sourceKey: payload.sourceKey || payload.resolvedSourceKey, - accountLabel: payload.accountLabel, - context: { - operation: 'vn_back', - vnKey: key, - vnId: id, - sourceKey: payload.sourceKey || payload.resolvedSourceKey || '', - }, - }) - - return { - baseUrl: config.baseUrl, - key, - id, - responseMessage: String(result.payload?.message || result.payload?.msg || 'success'), - raw: result.payload ?? null, - } -} - -function mapVirtualNumberItem(item: unknown) { - const source = isPlainObject(item) ? item : {} - const bindInfo = parseBindInfo(source.bind_info) - - return { - id: Number(source.id || 0), - phone: String(source.phone || '').trim(), - status: Number(source.status || 0), - countTime: Number(source.count_time || 0), - bindInfo, - raw: source, - } -} - -function requireToken(value: unknown, message: string, errorCode: string) { - const token = String(value || '').trim() - if (!token) { - throw createHttpError(message, { - statusCode: 400, - errorCode, - }) - } - - return token -} - -function requireKey(value: unknown, message: string, errorCode: string) { - const key = String(value || '').trim() - if (!key) { - throw createHttpError(message, { - statusCode: 400, - errorCode, - }) - } - - return key -} - -function requireId(value: unknown, message: string, errorCode: string) { - const id = Number(value) - if (!Number.isInteger(id) || id <= 0) { - throw createHttpError(message, { - statusCode: 400, - errorCode, - }) - } - - return id -} - -function tryParseJson(text: string) { - try { - return JSON.parse(text) - } catch { - return null - } -} - -function parseBindInfo(value: unknown) { - const bindInfoText = String(value || '').trim() - return tryParseJson(bindInfoText) || bindInfoText || null -} - -async function requestAmsIdeProbe( - endpoint: string, - options: JsonObject = {}, -): Promise { - const url = new URL(endpoint) - if (!['http:', 'https:'].includes(url.protocol)) { - throw new Error('unsupported_ams_probe_protocol') - } - - const timeoutMs = Number(options.timeoutMs || 5000) - const isHttps = url.protocol === 'https:' - const transport = isHttps ? https : http - const body = - options.body instanceof URLSearchParams ? options.body.toString() : String(options.body || '') - - return new Promise((resolve, reject) => { - const request = transport.request( - url, - { - method: 'POST', - rejectUnauthorized: false, - headers: { - ...normalizeHeaderMap(options.headers), - 'content-length': Buffer.byteLength(body), - 'accept-encoding': 'identity', - }, - }, - (response) => { - const chunks: Buffer[] = [] - let receivedBytes = 0 - - response.on('data', (chunk) => { - if (receivedBytes >= BIND_URL_PROBE_MAX_BODY_BYTES) { - return - } - - const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - const remainingBytes = BIND_URL_PROBE_MAX_BODY_BYTES - receivedBytes - chunks.push(buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer) - receivedBytes += Math.min(buffer.length, remainingBytes) - }) - - response.on('end', () => { - clearTimeout(timer) - resolve({ - status: Number(response.statusCode || 0), - headers: createHeaderAdapter(response.headers), - bodyText: Buffer.concat(chunks).toString('utf8'), - }) - }) - }, - ) - - const timer = setTimeout(() => { - request.destroy(new Error('bind_url_probe_timeout')) - }, timeoutMs) - - request.on('error', (error) => { - clearTimeout(timer) - reject(error) - }) - - request.write(body) - request.end() - }) -} - -function createHeaderAdapter(headers: http.IncomingHttpHeaders): HeaderAdapter { - const normalized = new Map() - - for (const [key, value] of Object.entries(headers || {})) { - if (Array.isArray(value)) { - normalized.set(String(key || '').toLowerCase(), value.join(', ')) - continue - } - - if (typeof value === 'string') { - normalized.set(String(key || '').toLowerCase(), value) - } - } - - return { - get(name: unknown) { - return normalized.get(String(name || '').toLowerCase()) || null - }, - } -} - -function buildAmsIdeProbeBody( - signatureParams: AmsSignatureParams, - config: JsonObject, - payload: JsonObject = {}, -) { - const body = new URLSearchParams() - const chartId = String(payload.chartId || config.bindUrlProbeChartId || '323794').trim() - const subChartId = String(payload.subChartId || config.bindUrlProbeSubChartId || chartId).trim() - - body.set('iChartId', chartId) - body.set('iSubChartId', subChartId) - body.set('sIdeToken', String(payload.ideToken || config.bindUrlProbeIdeToken || 'z90Syo').trim()) - body.set('e_code', '0') - body.set('g_code', '0') - body.set( - 'eas_url', - String( - payload.easUrl || - config.bindUrlProbeActivityUrl || - 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F', - ).trim(), - ) - body.set( - 'eas_refer', - String( - payload.easRefer || `http%3A%2F%2Fnoreferrer%2F%3Freqid%3D${Date.now()}%26version%3D27`, - ).trim(), - ) - body.set('sMiloTag', String(payload.miloTag || `AMS-gp-${Date.now()}`).trim()) - body.set('userId', signatureParams.userId) - body.set('timestamp', signatureParams.timestamp) - body.set('nonce', signatureParams.nonce) - body.set('sign', signatureParams.sign) - - return body -} - -function buildAmsIdeProbeHeaders( - signatureParams: AmsSignatureParams, - config: JsonObject, - options: JsonObject = {}, -) { - const userAgent = String(options.userAgent || '').trim() - const referer = String(config.bindUrlProbeReferer || 'https://gp.qq.com/').trim() - - return { - accept: 'application/json, text/plain, */*', - 'content-type': 'application/x-www-form-urlencoded', - 'accept-language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6', - 'cache-control': 'no-cache', - origin: 'https://gp.qq.com', - pragma: 'no-cache', - referer, - 'sec-ch-ua': '""', - 'sec-ch-ua-mobile': '?1', - 'sec-ch-ua-platform': '""', - 'sec-fetch-dest': 'empty', - 'sec-fetch-mode': 'cors', - 'sec-fetch-site': 'same-site', - ...(userAgent ? { 'user-agent': userAgent } : {}), - cookie: buildAmsIdeProbeCookie(signatureParams, config), - } -} - -function buildAmsIdeProbeCookie(signatureParams: AmsSignatureParams, config: JsonObject) { - const tokenParams = `?userId=${signatureParams.userId}×tamp=${signatureParams.timestamp}&nonce=${signatureParams.nonce}&sign=${signatureParams.sign}` - const cookies = [ - `tokenParams=${encodeURIComponent(tokenParams)}`, - 'gpqqcomrouteLine=a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc', - String(config.bindUrlProbeExtraCookie || '').trim(), - ].filter(Boolean) - - return cookies.join('; ') -} - -function extractBindUrlSignatureParams(bindUrl: string): AmsSignatureParams | null { - try { - const url = new URL(bindUrl) - const params = new URLSearchParams(url.search) - const hashQueryIndex = String(url.hash || '').indexOf('?') - if (hashQueryIndex >= 0) { - const hashParams = new URLSearchParams(String(url.hash || '').slice(hashQueryIndex + 1)) - for (const [key, value] of hashParams.entries()) { - if (!params.has(key)) { - params.set(key, value) - } - } - } - const tokenParams = String(params.get('tokenParams') || '').trim() - if (tokenParams) { - const nested = new URLSearchParams( - tokenParams.startsWith('?') ? tokenParams.slice(1) : tokenParams, - ) - for (const [key, value] of nested.entries()) { - if (!params.has(key)) { - params.set(key, value) - } - } - } - - const userId = String(params.get('userId') || '').trim() - const timestamp = String(params.get('timestamp') || '').trim() - const nonce = String(params.get('nonce') || '').trim() - const sign = String(params.get('sign') || '').trim() - - if (!userId || !timestamp || !nonce || !sign) { - return null - } - - return { - userId, - timestamp, - nonce, - sign, - } - } catch { - return null - } -} - -function isAmsBusinessOk(raw: any) { - const ret = String(raw?.ret ?? '').trim() - const iRet = String(raw?.iRet ?? raw?.jData?.iRet ?? '').trim() - return ret === '0' || iRet === '0' -} - -function isAmsSignatureExpired(raw: any) { - const codes = [ - raw?.ret, - raw?.iRet, - raw?.jData?.ret, - raw?.jData?.iRet, - raw?.jData?.jData?.iRet, - raw?.arrErrNodeInfo?.errorCode, - raw?.jData?.arrErrNodeInfo?.errorCode, - ].map((value) => String(value ?? '').trim()) - - const messages = [raw?.sMsg, raw?.jData?.sMsg, raw?.jData?.jData?.sMsg].map((value) => - String(value ?? '').trim(), - ) - - return ( - codes.includes(AMS_SIGNATURE_EXPIRED_CODE) || - messages.some((message) => message.includes('签名已过期')) - ) -} - -function extractAmsBindInfo(raw: any) { - const candidates = [raw?.jData?.sBindInfo, raw?.jData?.jData?.sBindInfo, raw?.sBindInfo] - - return candidates.find((item) => isPlainObject(item)) || null -} - -function normalizeAmsBindRoleInfo(bindInfo: unknown) { - const source = isPlainObject(bindInfo) ? bindInfo : {} - - return { - name: String(source.sRoleName || source.roleName || source.name || '').trim(), - rid: String(source.sRoleId || source.roleId || source.rid || '').trim(), - rawInfo: isPlainObject(bindInfo) ? bindInfo : null, - } -} - -function normalizeHeaderMap(headers: unknown) { - if (!headers || typeof headers !== 'object') { - return {} - } - - return Object.fromEntries( - Object.entries(headers) - .map(([key, value]) => [ - String(key || '') - .trim() - .toLowerCase(), - String(value || '').trim(), - ]) - .filter(([key, value]) => key && value), - ) -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/config.ts b/apps/backend/src/services/platforms/kuaishou-feifei/config.ts deleted file mode 100644 index d7f02bd0..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/config.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { runtimeConfig } from '../../../config/runtime.js' -import { createHttpError } from '../../../utils/http.js' -import type { RuntimeConfig } from '../../../types/runtime-config.js' -import { - getKuaishouFeifeiSourceConfig, - hasKuaishouFeifeiConfigFile, -} from './source-config-service.js' - -export const KUAISHOU_FEIFEI_EXECUTOR_KEY = 'kuaishou_feifei' -export const KUAISHOU_FEIFEI_PROFILE_KEY = 'kuaishou_feifei' - -type KuaishouFeifeiRuntimeConfig = RuntimeConfig['platforms']['kuaishouFeifei'] - -export function getKuaishouFeifeiConfig(overrides: Partial = {}) { - const runtimeValue = runtimeConfig.platforms?.kuaishouFeifei || {} - const savedValue = hasKuaishouFeifeiConfigFile() ? getKuaishouFeifeiSourceConfig() : null - const config = mergeKuaishouFeifeiConfig(runtimeValue, savedValue, overrides) - - return { - enabled: config.enabled !== false, - baseUrl: String(config.baseUrl || 'http://skin-exchange.yiquyou.icu') - .trim() - .replace(/\/+$/, ''), - appKey: String(config.appKey || '').trim(), - appSecret: String(config.appSecret || '').trim(), - timeoutMs: Math.max(1, Number(config.timeoutMs || 10000) || 10000), - notifyUrl: String(config.notifyUrl || '').trim(), - productRules: Array.isArray(config.productRules) ? config.productRules : [], - } -} - -export function assertKuaishouFeifeiConfig() { - const config = getKuaishouFeifeiConfig() - - if (config.enabled === false) { - throw createHttpError('kuaishou-feifei 已停用', { - statusCode: 409, - errorCode: 'kuaishou_feifei_disabled', - }) - } - - if (!config.baseUrl) { - throw createHttpError('kuaishou-feifei baseUrl 未配置', { - statusCode: 500, - errorCode: 'kuaishou_feifei_missing_base_url', - }) - } - - if (!config.appKey || !config.appSecret) { - throw createHttpError('kuaishou-feifei App Key / App Secret 未配置', { - statusCode: 500, - errorCode: 'kuaishou_feifei_missing_credential', - }) - } - - return config -} - -function mergeKuaishouFeifeiConfig( - runtimeValue: Partial, - savedValue: Partial | null, - overrides: Partial, -): KuaishouFeifeiRuntimeConfig { - const base: KuaishouFeifeiRuntimeConfig = savedValue - ? { - enabled: savedValue.enabled !== false, - baseUrl: savedValue.baseUrl || runtimeValue.baseUrl || 'http://skin-exchange.yiquyou.icu', - appKey: savedValue.appKey || runtimeValue.appKey || '', - appSecret: savedValue.appSecret || runtimeValue.appSecret || '', - timeoutMs: savedValue.timeoutMs || runtimeValue.timeoutMs || 10000, - notifyUrl: savedValue.notifyUrl || runtimeValue.notifyUrl || '', - productRules: Array.isArray(savedValue.productRules) - ? savedValue.productRules - : runtimeValue.productRules || [], - } - : { - enabled: runtimeValue.enabled !== false, - baseUrl: runtimeValue.baseUrl || 'http://skin-exchange.yiquyou.icu', - appKey: runtimeValue.appKey || '', - appSecret: runtimeValue.appSecret || '', - timeoutMs: runtimeValue.timeoutMs || 10000, - notifyUrl: runtimeValue.notifyUrl || '', - productRules: runtimeValue.productRules || [], - } - - return { - enabled: overrides.enabled ?? base.enabled, - baseUrl: overrides.baseUrl ?? base.baseUrl, - appKey: overrides.appKey ?? base.appKey, - appSecret: overrides.appSecret ?? base.appSecret, - timeoutMs: overrides.timeoutMs ?? base.timeoutMs, - notifyUrl: overrides.notifyUrl ?? base.notifyUrl, - productRules: overrides.productRules ?? base.productRules, - } -} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts deleted file mode 100644 index 6b3397e4..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import crypto from 'node:crypto' -import test from 'node:test' -import assert from 'node:assert/strict' - -import { isKuaishouFeifeiSuccessResponse, signKuaishouFeifeiPayload } from './http-client.js' - -test('signKuaishouFeifeiPayload signs app key, timestamp and raw body with HMAC SHA256', () => { - const input = { - appKey: 'app-key-1', - appSecret: 'secret-1', - timestamp: '1783394218', - body: '{"platform_order_no":"DT-1","product_code":"10000001","platform_buy_num":1}', - } - const expected = crypto - .createHmac('sha256', input.appSecret) - .update(`${input.appKey}${input.timestamp}${input.body}`, 'utf8') - .digest('hex') - .toLowerCase() - - assert.equal(signKuaishouFeifeiPayload(input), expected) -}) - -test('isKuaishouFeifeiSuccessResponse accepts documented and actual success codes', () => { - assert.equal(isKuaishouFeifeiSuccessResponse({ code: 0, message: 'success' }), true) - assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: 'success' }), true) - assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '下单成功' }), true) - assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '查询成功' }), true) - assert.equal(isKuaishouFeifeiSuccessResponse({ code: 10000, message: '商品不存在' }), false) - assert.equal(isKuaishouFeifeiSuccessResponse({ code: 1, message: 'success' }), false) -}) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts b/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts deleted file mode 100644 index c42d0899..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/http-client.ts +++ /dev/null @@ -1,229 +0,0 @@ -import crypto from 'node:crypto' -import { asJsonObject, type JsonObject } from '../../../types/json.js' - -import { createHttpError } from '../../../utils/http.js' -import { createRequestId, logExternalHttpPacket } from '../../../utils/logger.js' -import { assertKuaishouFeifeiConfig } from './config.js' - -export async function kuaishouFeifeiRequest(pathname: string, payload: JsonObject = {}) { - const config = assertKuaishouFeifeiConfig() - const body = JSON.stringify(payload) - const timestamp = String(Math.floor(Date.now() / 1000)) - const packetId = createRequestId('ff') - const startedAt = Date.now() - let upstreamPacketLogged = false - const sign = signKuaishouFeifeiPayload({ - appKey: config.appKey, - appSecret: config.appSecret, - timestamp, - body, - }) - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), config.timeoutMs) - const url = `${config.baseUrl}${pathname}` - const requestHeaders = { - 'content-type': 'application/json', - 'x-app-key': config.appKey, - 'x-timestamp': timestamp, - 'x-sign': sign, - } - - logExternalHttpPacket('[kuaishou-feifei/http]', '发送请求', { - packetId, - method: 'POST', - pathname, - url, - timeoutMs: config.timeoutMs, - request: { - headers: requestHeaders, - body: payload, - rawBody: body, - }, - }) - - try { - const response = await fetch(url, { - method: 'POST', - headers: requestHeaders, - body, - signal: controller.signal, - }) - const text = await response.text() - const json = parseJsonObject(text) - const durationMs = Date.now() - startedAt - const responsePacket = { - packetId, - method: 'POST', - pathname, - url, - status: response.status, - durationMs, - request: { - headers: requestHeaders, - body: payload, - }, - response: { - ok: response.ok, - headers: normalizeFetchHeaders(response.headers), - summary: summarizeKuaishouFeifeiResponse(json), - body: json, - rawText: text, - }, - } - - if (!response.ok) { - upstreamPacketLogged = true - logExternalHttpPacket('[kuaishou-feifei/http]', 'HTTP 响应失败', responsePacket, { - level: 'warn', - }) - throw createHttpError(`kuaishou-feifei 请求失败: HTTP ${response.status}`, { - statusCode: 502, - errorCode: 'kuaishou_feifei_http_failed', - context: { - status: response.status, - body: text, - }, - }) - } - - if (!isKuaishouFeifeiSuccessResponse(json)) { - upstreamPacketLogged = true - logExternalHttpPacket('[kuaishou-feifei/http]', '业务响应失败', responsePacket, { - level: 'warn', - }) - throw createHttpError(String(json.message || 'kuaishou-feifei 业务失败'), { - statusCode: 502, - errorCode: 'kuaishou_feifei_business_failed', - context: json, - }) - } - - upstreamPacketLogged = true - logExternalHttpPacket('[kuaishou-feifei/http]', '请求完成', responsePacket) - - return json - } catch (error) { - if ((error as Error)?.name === 'AbortError') { - logExternalHttpPacket( - '[kuaishou-feifei/http]', - '请求超时', - { - packetId, - method: 'POST', - pathname, - url, - timeoutMs: config.timeoutMs, - durationMs: Date.now() - startedAt, - request: { - headers: requestHeaders, - body: payload, - }, - }, - { - level: 'warn', - }, - ) - throw createHttpError('kuaishou-feifei 请求超时', { - statusCode: 504, - errorCode: 'kuaishou_feifei_timeout', - }) - } - - if (!upstreamPacketLogged) { - logExternalHttpPacket( - '[kuaishou-feifei/http]', - '请求异常', - { - packetId, - method: 'POST', - pathname, - url, - timeoutMs: config.timeoutMs, - durationMs: Date.now() - startedAt, - request: { - headers: requestHeaders, - body: payload, - }, - error, - }, - { - level: 'error', - }, - ) - } - - throw error - } finally { - clearTimeout(timeout) - } -} - -export function signKuaishouFeifeiPayload({ - appKey, - appSecret, - timestamp, - body, -}: { - appKey: string - appSecret: string - timestamp: string - body: string -}) { - return crypto - .createHmac('sha256', appSecret) - .update(`${appKey}${timestamp}${body}`, 'utf8') - .digest('hex') - .toLowerCase() -} - -export function isKuaishouFeifeiSuccessResponse(json: JsonObject) { - const code = Number(json.code ?? 0) - const message = String(json.message || json.msg || '') - .trim() - .toLowerCase() - - return code === 0 || (code === 10000 && isKuaishouFeifeiSuccessMessage(message)) -} - -function parseJsonObject(text: string): JsonObject { - try { - const parsed = JSON.parse(text || '{}') - return asJsonObject(parsed) - } catch { - return {} - } -} - -function isKuaishouFeifeiSuccessMessage(message: string) { - return message === 'success' || message === 'ok' || message.endsWith('成功') -} - -function normalizeFetchHeaders(headers: Headers) { - const result: Record = {} - headers.forEach((value, key) => { - result[key] = value - }) - return result -} - -function summarizeKuaishouFeifeiResponse(json: JsonObject) { - const data = json.data && typeof json.data === 'object' ? (json.data as JsonObject) : {} - const order = data.order && typeof data.order === 'object' ? (data.order as JsonObject) : null - const h5 = order?.h5 && typeof order.h5 === 'object' ? (order.h5 as JsonObject) : {} - const list = Array.isArray(data.list) ? data.list : [] - - return { - code: json.code, - message: String(json.message || json.msg || '').trim(), - hasOrder: Boolean(order), - orderNo: String(order?.order_no || '').trim(), - platformOrderNo: String(order?.platform_order_no || '').trim(), - productCode: String(order?.product_code || '').trim(), - rechargeStatus: Number(order?.recharge_status ?? order?.status ?? 0) || 0, - rechargeStatusLabel: String(order?.recharge_status_label || order?.status_label || '').trim(), - hasH5EntryUrl: Boolean(String(h5.entry_url || '').trim()), - hasH5RechargeUrl: Boolean(String(h5.recharge_url || '').trim()), - listCount: list.length, - total: Number(data.total || list.length || 0) || 0, - } -} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.test.ts b/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.test.ts deleted file mode 100644 index 86cc7793..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { signKuaishouFeifeiPayload } from './http-client.js' -import { verifyKuaishouFeifeiNotifySignature } from './notify-service.js' - -test('verifyKuaishouFeifeiNotifySignature 使用原始 body 校验 feifei 通知签名', () => { - const input = { - appKey: 'mk_app_key', - appSecret: 'app-secret', - timestamp: '1783411548', - rawBody: '{"event":"order.status_changed","order":{"platform_order_no":"DT56bf8f1727a7"}}', - } - const sign = signKuaishouFeifeiPayload({ - appKey: input.appKey, - appSecret: input.appSecret, - timestamp: input.timestamp, - body: input.rawBody, - }) - const invalidSign = `${sign.slice(0, -1)}${sign.endsWith('0') ? '1' : '0'}` - - assert.equal(verifyKuaishouFeifeiNotifySignature({ ...input, sign }), true) - assert.equal(verifyKuaishouFeifeiNotifySignature({ ...input, sign: invalidSign }), false) -}) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.ts deleted file mode 100644 index d878c814..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/notify-service.ts +++ /dev/null @@ -1,159 +0,0 @@ -import crypto from 'node:crypto' -import type { JsonObject } from '../../../types/json.js' - -import { createTaskEvent } from '../../../repositories/task-event-repo.js' -import { findKuaishouFeifeiTaskByOrder } from '../../../repositories/task-repo.js' -import { syncKuaishouFeifeiTaskStatus } from '../../fulfillment/kuaishou-feifei/index.js' -import { createHttpError } from '../../../utils/http.js' -import { nowIso } from '../../../utils/time.js' -import { assertKuaishouFeifeiConfig } from './config.js' -import { signKuaishouFeifeiPayload } from './http-client.js' -import { mapKuaishouFeifeiOrder } from './order-service.js' - -type NotifyHeaders = Record - -export async function handleKuaishouFeifeiNotify(input: { - body: unknown - headers: NotifyHeaders - rawBody?: string | undefined -}) { - const config = assertKuaishouFeifeiConfig() - const rawBody = String(input.rawBody || '') - const appKey = normalizeHeader(input.headers['x-app-key']) - const timestamp = normalizeHeader(input.headers['x-timestamp']) - const sign = normalizeHeader(input.headers['x-sign']) - - if (!rawBody) { - throw createHttpError('kuaishou-feifei 通知原始请求体缺失', { - statusCode: 400, - errorCode: 'kuaishou_feifei_notify_raw_body_missing', - }) - } - - if (!appKey || appKey !== config.appKey) { - throw createHttpError('kuaishou-feifei 通知 App Key 无效', { - statusCode: 401, - errorCode: 'kuaishou_feifei_notify_app_key_invalid', - }) - } - - if (!timestamp || !sign) { - throw createHttpError('kuaishou-feifei 通知签名参数缺失', { - statusCode: 401, - errorCode: 'kuaishou_feifei_notify_sign_missing', - }) - } - - if ( - !verifyKuaishouFeifeiNotifySignature({ - appKey, - appSecret: config.appSecret, - timestamp, - rawBody, - sign, - }) - ) { - throw createHttpError('kuaishou-feifei 通知验签失败', { - statusCode: 401, - errorCode: 'kuaishou_feifei_notify_sign_invalid', - }) - } - - const source = isPlainObject(input.body) ? input.body : {} - const event = String(source.event || '').trim() - const order = mapKuaishouFeifeiOrder(source.order) - - if (!order.platformOrderNo && !order.orderNo) { - throw createHttpError('kuaishou-feifei 通知订单号缺失', { - statusCode: 400, - errorCode: 'kuaishou_feifei_notify_order_no_missing', - }) - } - - const task = await findKuaishouFeifeiTaskByOrder({ - platformOrderNo: order.platformOrderNo, - orderNo: order.orderNo, - }) - - if (!task) { - throw createHttpError('kuaishou-feifei 通知未匹配到本地任务', { - statusCode: 404, - errorCode: 'kuaishou_feifei_notify_task_not_found', - context: { - platformOrderNo: order.platformOrderNo, - orderNo: order.orderNo, - }, - }) - } - - const now = nowIso() - await createTaskEvent( - task.id, - 'kuaishou_feifei_notify_received', - { - event, - platformOrderNo: order.platformOrderNo, - orderNo: order.orderNo, - productCode: order.productCode, - rechargeStatus: order.rechargeStatus, - rechargeStatusLabel: order.rechargeStatusLabel, - raw: order.raw, - }, - now, - ) - - const updatedTask = await syncKuaishouFeifeiTaskStatus(task) - - return { - ok: true, - event, - taskId: task.id, - platformOrderNo: order.platformOrderNo, - orderNo: order.orderNo, - rechargeStatus: order.rechargeStatus, - rechargeStatusLabel: order.rechargeStatusLabel, - taskStatus: updatedTask.task_status, - deliveryStatus: updatedTask.delivery_status, - } -} - -export function verifyKuaishouFeifeiNotifySignature(input: { - appKey: string - appSecret: string - timestamp: string - rawBody: string - sign: string -}) { - const expected = signKuaishouFeifeiPayload({ - appKey: input.appKey, - appSecret: input.appSecret, - timestamp: input.timestamp, - body: input.rawBody, - }) - - return timingSafeEqualString( - expected, - String(input.sign || '') - .trim() - .toLowerCase(), - ) -} - -function normalizeHeader(value: string | string[] | undefined) { - return Array.isArray(value) ? String(value[0] || '').trim() : String(value || '').trim() -} - -function timingSafeEqualString(left: string, right: string) { - const leftBuffer = Buffer.from(left, 'utf8') - const rightBuffer = Buffer.from(right, 'utf8') - - if (leftBuffer.length !== rightBuffer.length) { - return false - } - - return crypto.timingSafeEqual(leftBuffer, rightBuffer) -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/order-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/order-service.ts deleted file mode 100644 index ed5dd8a4..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/order-service.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { getKuaishouFeifeiConfig } from './config.js' -import { kuaishouFeifeiRequest } from './http-client.js' -import type { JsonObject } from '../../../types/json.js' - -export type KuaishouFeifeiProductListResult = ReturnType - -export async function listKuaishouFeifeiProducts( - input: { - page?: number - perPage?: number - status?: string - supplyProductName?: string - } = {}, -) { - const page = Math.max(1, Number(input.page || 1) || 1) - const perPage = Math.min(100, Math.max(1, Number(input.perPage || 20) || 20)) - const filters: JsonObject = {} - const status = String(input.status || 'on_sale').trim() - const supplyProductName = String(input.supplyProductName || '').trim() - - if (status) filters.status = status - if (supplyProductName) filters.supply_product_name = supplyProductName - - const response = await kuaishouFeifeiRequest('/api/open/v1/products/index', { - page, - per_page: perPage, - filters, - }) - - return mapKuaishouFeifeiProductList(response.data) -} - -export async function createKuaishouFeifeiOrder(input: { - platformOrderNo: string - productCode: string - platformBuyNum?: number - platformAmount?: number - playerAccount?: string - playerGameRegion?: string - playerGameSrv?: string - playerGameRole?: string - submitPlayer?: boolean - notifyUrl?: string -}) { - const config = getKuaishouFeifeiConfig() - const payload: JsonObject = { - platform_order_no: input.platformOrderNo, - product_code: input.productCode, - platform_buy_num: Math.max(1, Number(input.platformBuyNum || 1) || 1), - } - - if (input.platformAmount != null) payload.platform_amount = input.platformAmount - if (input.playerAccount) payload.player_account = input.playerAccount - if (input.playerGameRegion) payload.player_game_region = input.playerGameRegion - if (input.playerGameSrv) payload.player_game_srv = input.playerGameSrv - if (input.playerGameRole) payload.player_game_role = input.playerGameRole - if (input.submitPlayer === true) payload.submit_player = true - if (input.notifyUrl || config.notifyUrl) payload.notify_url = input.notifyUrl || config.notifyUrl - - const response = await kuaishouFeifeiRequest('/api/open/v1/orders/store', payload) - return mapKuaishouFeifeiOrder(response.data?.order) -} - -export async function queryKuaishouFeifeiOrder(input: { - platformOrderNo?: string - orderNo?: string -}) { - const payload: JsonObject = {} - if (input.platformOrderNo) payload.platform_order_no = input.platformOrderNo - if (input.orderNo) payload.order_no = input.orderNo - - const response = await kuaishouFeifeiRequest('/api/open/v1/orders/show', payload) - return mapKuaishouFeifeiOrder(response.data?.order) -} - -export function mapKuaishouFeifeiOrder(value: unknown) { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - const h5 = source.h5 && typeof source.h5 === 'object' ? (source.h5 as JsonObject) : {} - - return { - orderNo: String(source.order_no || '').trim(), - platformOrderNo: String(source.platform_order_no || '').trim(), - productCode: String(source.product_code || '').trim(), - productName: String(source.product_name || '').trim(), - rechargeStatus: Number(source.recharge_status ?? source.status ?? 0) || 0, - rechargeStatusLabel: String(source.recharge_status_label || source.status_label || '').trim(), - pointsCharged: Number(source.points_charged || 0) || 0, - playerAccount: String(source.player_account || '').trim(), - platformBuyNum: Number(source.platform_buy_num || 1) || 1, - rechargeResultMessage: String(source.recharge_result_message || '').trim(), - createdAt: String(source.created_at || '').trim(), - updatedAt: String(source.updated_at || '').trim(), - rechargeFinishAt: String(source.recharge_finish_at || '').trim(), - h5: { - entryUrl: String(h5.entry_url || '').trim(), - rechargeUrl: String(h5.recharge_url || '').trim(), - }, - raw: source, - } -} - -export function mapKuaishouFeifeiProductList(value: unknown) { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - const list = Array.isArray(source.list) ? source.list : [] - - return { - items: list.map((item) => mapKuaishouFeifeiProduct(item)), - total: Number(source.total || list.length) || 0, - page: Number(source.page || 1) || 1, - perPage: Number(source.per_page || source.perPage || list.length || 20) || 20, - raw: source, - } -} - -export function mapKuaishouFeifeiProduct(value: unknown) { - const source = - value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} - - return { - productCode: String(source.product_code || '').trim(), - name: String(source.name || '').trim(), - channel: String(source.channel || '').trim(), - externalProductId: String(source.external_product_id || '').trim(), - imageUrl: String(source.image_url || '').trim(), - status: String(source.status || '').trim(), - supplyStatus: String(source.supply_status || '').trim(), - supplyPricePoints: Number(source.supply_price_points || 0) || 0, - feePoints: Number(source.fee_points || 0) || 0, - unitCostPoints: Number(source.unit_cost_points || 0) || 0, - maxOrderQuantity: Number(source.max_order_quantity || 0) || 0, - salePricePoints: - source.sale_price_points == null ? null : Number(source.sale_price_points || 0) || 0, - raw: source, - } -} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.test.ts b/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.test.ts deleted file mode 100644 index eb5050d1..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { resolveKuaishouFeifeiProductByName } from './product-rule-service.js' - -test('resolveKuaishouFeifeiProductByName matches generated name-code mapping by normalized name', () => { - const match = resolveKuaishouFeifeiProductByName('【自动发货】套装-Alan Walker', { - rules: [ - { - productName: '套装-Alan Walker', - normalizedProductName: '套装 alan walker', - productCode: '10000013', - skuName: '套装-Alan Walker', - }, - ], - }) - - assert.equal(match?.productCode, '10000013') - assert.equal(match?.matchMode, 'kuaishou_feifei_name') - assert.equal(match?.skuName, '套装-Alan Walker') - assert.equal(match?.normalizedProductName, '套装 alan walker') -}) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.ts deleted file mode 100644 index 6f1163da..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/product-rule-service.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js' -import { getKuaishouFeifeiConfig } from './config.js' -import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js' - -export type KuaishouFeifeiProductMatch = { - matchMode: 'kuaishou_feifei_name' - productName: string - normalizedProductName: string - productCode: string - skuName: string -} - -export function resolveKuaishouFeifeiProductByName( - productName: unknown, - options: { - rules?: KuaishouFeifeiProductRule[] - enabled?: boolean - } = {}, -): KuaishouFeifeiProductMatch | null { - const normalizedProductName = normalizeCloudtentaclesMatchName(productName) - if (!normalizedProductName) { - return null - } - - const rules = listKuaishouFeifeiProductRules(options) - const matched = rules.find( - (rule) => - normalizeCloudtentaclesMatchName(rule.productName || rule.normalizedProductName) === - normalizedProductName, - ) - - if (!matched) { - return null - } - - const productCode = String(matched.productCode || '').trim() - if (!productCode) { - return null - } - - return { - matchMode: 'kuaishou_feifei_name', - productName: String(matched.productName || productName || '').trim(), - normalizedProductName, - productCode, - skuName: String(matched.skuName || matched.productName || productName || productCode).trim(), - } -} - -function listKuaishouFeifeiProductRules(options: { - rules?: KuaishouFeifeiProductRule[] - enabled?: boolean -}): KuaishouFeifeiProductRule[] { - const config = options.rules ? null : getKuaishouFeifeiConfig() - if (options.enabled === false || config?.enabled === false) { - return [] - } - - const rules = options.rules || config?.productRules - return (Array.isArray(rules) ? rules : []) - .map((rule) => ({ - id: String(rule.id || '').trim(), - productName: String(rule.productName || '').trim(), - normalizedProductName: String(rule.normalizedProductName || '').trim(), - productCode: String(rule.productCode || '').trim(), - skuName: String(rule.skuName || '').trim(), - enabled: rule.enabled !== false, - notes: String(rule.notes || '').trim(), - })) - .filter((rule) => rule.enabled && rule.productName && rule.productCode) -} diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.test.ts b/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.test.ts deleted file mode 100644 index abcfcccf..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import test from 'node:test' -import assert from 'node:assert/strict' - -import { normalizeKuaishouFeifeiSourceConfig } from './source-config-service.js' - -test('normalizeKuaishouFeifeiSourceConfig keeps valid product rules and drops invalid ones', () => { - const config = normalizeKuaishouFeifeiSourceConfig({ - enabled: true, - baseUrl: ' https://feifei.example.com/ ', - appKey: ' app-key ', - appSecret: ' secret ', - timeoutMs: 0, - productRules: [ - { - productName: '套装-Alan Walker', - productCode: 'FF-1001', - skuName: 'Alan Walker 套装', - notes: '测试规则', - }, - { - productName: '套装-Alan Walker', - productCode: 'FF-1002', - }, - { - productName: '缺少编码', - productCode: '', - }, - ], - }) - - assert.equal(config.enabled, true) - assert.equal(config.baseUrl, 'https://feifei.example.com/') - assert.equal(config.appKey, 'app-key') - assert.equal(config.appSecret, 'secret') - assert.equal(config.timeoutMs, 10000) - assert.equal(config.productRules.length, 1) - assert.equal(config.productRules[0].productName, '套装-Alan Walker') - assert.equal(config.productRules[0].productCode, 'FF-1002') - assert.equal(config.productRules[0].normalizedProductName, '套装 alan walker') -}) diff --git a/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.ts b/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.ts deleted file mode 100644 index 99227903..00000000 --- a/apps/backend/src/services/platforms/kuaishou-feifei/source-config-service.ts +++ /dev/null @@ -1,136 +0,0 @@ -import path from 'node:path' -import type { JsonObject } from '../../../types/json.js' - -import { APP_CONFIG_KEYS } from '../../../config/app-config-keys.js' -import { PROJECT_ROOT } from '../../../config/runtime.js' -import { - hasAppConfigEntry, - readAppConfigEntry, - saveAppConfigEntry, -} from '../../config/app-config-store.js' -import { normalizeCloudtentaclesMatchName } from '../../order/cloudtentacles-match-utils.js' -import type { KuaishouFeifeiProductRule } from '../../../types/runtime-config.js' - -const KUAISHOU_FEIFEI_CONFIG_FILE_PATH = path.join( - PROJECT_ROOT, - 'data', - 'kuaishou-feifei-config.json', -) - -export type KuaishouFeifeiSourceConfig = { - enabled: boolean - baseUrl: string - appKey: string - appSecret: string - timeoutMs: number - notifyUrl: string - productRules: KuaishouFeifeiProductRule[] -} - -export function getKuaishouFeifeiConfigFilePath() { - return KUAISHOU_FEIFEI_CONFIG_FILE_PATH -} - -export function hasKuaishouFeifeiConfigFile() { - return hasAppConfigEntry(APP_CONFIG_KEYS.kuaishouFeifei, KUAISHOU_FEIFEI_CONFIG_FILE_PATH) -} - -export function getKuaishouFeifeiSourceConfig(): KuaishouFeifeiSourceConfig { - return readAppConfigEntry({ - configKey: APP_CONFIG_KEYS.kuaishouFeifei, - legacyFilePath: KUAISHOU_FEIFEI_CONFIG_FILE_PATH, - fallback: createDefaultKuaishouFeifeiSourceConfig, - normalize: normalizeKuaishouFeifeiSourceConfig, - }) -} - -export function saveKuaishouFeifeiSourceConfig( - rawValue: unknown, -): Promise { - return saveAppConfigEntry({ - configKey: APP_CONFIG_KEYS.kuaishouFeifei, - legacyFilePath: KUAISHOU_FEIFEI_CONFIG_FILE_PATH, - value: rawValue, - normalize: normalizeKuaishouFeifeiSourceConfig, - }) -} - -export function normalizeKuaishouFeifeiSourceConfig(rawValue: unknown): KuaishouFeifeiSourceConfig { - const source = isPlainObject(rawValue) ? rawValue : {} - - return { - enabled: source.enabled !== false, - baseUrl: String(source.baseUrl || 'http://skin-exchange.yiquyou.icu').trim(), - appKey: String(source.appKey || '').trim(), - appSecret: String(source.appSecret || '').trim(), - timeoutMs: normalizePositiveInteger(source.timeoutMs, 10000), - notifyUrl: String(source.notifyUrl || '').trim(), - productRules: normalizeKuaishouFeifeiProductRules(source.productRules), - } -} - -export function normalizeKuaishouFeifeiProductRules(value: unknown): KuaishouFeifeiProductRule[] { - const rules = Array.isArray(value) ? value : [] - const normalizedRules = rules - .map((item) => normalizeKuaishouFeifeiProductRule(item)) - .filter((item): item is KuaishouFeifeiProductRule => Boolean(item)) - - return dedupeProductRules(normalizedRules) -} - -function normalizeKuaishouFeifeiProductRule(rawValue: unknown): KuaishouFeifeiProductRule | null { - const source = isPlainObject(rawValue) ? rawValue : {} - const productName = String(source.productName || source.name || '').trim() - const normalizedProductName = normalizeCloudtentaclesMatchName(productName) - const productCode = String(source.productCode || source.code || '').trim() - - if (!productName || !normalizedProductName || !productCode) { - return null - } - - return { - id: String(source.id || normalizedProductName).trim() || normalizedProductName, - enabled: source.enabled !== false, - productName, - normalizedProductName, - productCode, - skuName: String(source.skuName || source.productName || productName).trim(), - notes: String(source.notes || '').trim(), - } -} - -function dedupeProductRules(rules: KuaishouFeifeiProductRule[]) { - const map = new Map() - - for (const rule of rules) { - const key = normalizeCloudtentaclesMatchName(rule.productName) - if (!key) { - continue - } - - map.set(key, rule) - } - - return Array.from(map.values()) -} - -function createDefaultKuaishouFeifeiSourceConfig(): KuaishouFeifeiSourceConfig { - return { - enabled: true, - baseUrl: 'http://skin-exchange.yiquyou.icu', - appKey: '', - appSecret: '', - timeoutMs: 10000, - notifyUrl: '', - productRules: [], - } -} - -function normalizePositiveInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback -} - -function isPlainObject(value: unknown): value is JsonObject { - return Object.prototype.toString.call(value) === '[object Object]' -} diff --git a/apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts b/apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts index 43db451e..83c91e22 100644 --- a/apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts +++ b/apps/backend/src/services/platforms/kuaishou-industry/voucher-binding-service.ts @@ -6,7 +6,6 @@ import { } from '../../../repositories/kuaishou-industry-voucher-repo.js' import { normalizeTimestampIso } from '../../../utils/time.js' import { parseTaskContext } from '../../../utils/task-json.js' -import { normalizeKuaishouCloudFlow } from '../../fulfillment/kuaishou-cloud/domain.js' import { isKuaishouIndustryVoucherSendCallbackSuccess, normalizeKuaishouIndustrySendCallbackStatus, @@ -71,49 +70,11 @@ export async function attachKuaishouIndustryVoucherToTask( context.kuaishouIndustryVoucher, now, ) - const sendCallbackConfirmed = isKuaishouIndustryVoucherSendCallbackSuccess(voucher) const nextContext: JsonObject = { ...context, kuaishouIndustryVoucher: nextVoucherContext, } - if ( - sendCallbackConfirmed && - (String(task.executor_key || '').trim() === 'kuaishou_ct_assisted' || - context.kuaishouCloudFulfillment) - ) { - const flow = normalizeKuaishouCloudFlow(context.kuaishouCloudFulfillment) - const consumedAt = voucher.consumed_at || nextVoucherContext.consumedAt || null - const status = String(voucher.status || 'UNUSED') - .trim() - .toUpperCase() - - nextContext.kuaishouCloudFulfillment = { - ...flow, - ticket: { - ...flow.ticket, - code: String(voucher.voucher_code || '').trim(), - status: status === 'DESTROYED' ? 'destroyed' : 'verified', - capturedAt: flow.ticket.capturedAt || now, - capturedBy: flow.ticket.capturedBy || { - source: options.source || 'kuaishou_industry_voucher', - }, - verifiedAt: flow.ticket.verifiedAt || now, - oid: String(voucher.oid || task.platform_order_id || '').trim(), - formToken: String(voucher.token || '').trim(), - leftCount: status === 'CONSUMED' || status === 'DESTROYED' ? 0 : 1, - goodsTitle: flow.ticket.goodsTitle || task.sku_name || task.sku_code || '', - }, - consume: { - ...flow.consume, - status: status === 'CONSUMED' ? 'success' : flow.consume.status, - autoConsumeEnabled: true, - consumedAt: status === 'CONSUMED' ? consumedAt : flow.consume.consumedAt, - errorMessage: '', - }, - } - } - if (JSON.stringify(context) === JSON.stringify(nextContext)) { return task } diff --git a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts b/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts deleted file mode 100644 index e5fc1e9d..00000000 --- a/apps/backend/src/services/scheduler/cloudtentacles-health-job.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { getCloudtentaclesAsset } from '../platforms/cloudtentacles/catalog-service.js' -import type { JsonObject } from '../../types/json.js' -import { - normalizeCloudtentaclesDeviceId, - normalizeCloudtentaclesDeviceType, -} from '../platforms/cloudtentacles/defaults.js' -import { - getCloudtentaclesSourceByKey, - listCloudtentaclesSources, -} from '../platforms/cloudtentacles/source-config-service.js' -import { getCloudtentaclesSessionStateByKey } from '../platforms/cloudtentacles/session-state-service.js' -import { - notifyCloudtentaclesAssetLow, - notifyCloudtentaclesAuthExpired, -} from '../notification/domain-notifications.js' -import { retryCloudtentaclesPendingReturns } from './cloudtentacles-number-recycle-service.js' - -type CloudtentaclesHealthAccountResult = { - sourceKey: string - label: string - enabled: boolean - hasToken: boolean - ok: boolean - skipped: boolean - status: string - message: string - asset: number | null - threshold: number - checkedAt: string -} - -export async function runCloudtentaclesHealthJob(job: JsonObject) { - const accounts = resolveCloudtentaclesHealthAccounts(job) - const cooldownSeconds = Number(job.cooldownSeconds || 1800) - const [results, returnRetryResult] = await Promise.all([ - Promise.all( - accounts.map((account) => runCloudtentaclesHealthAccount(account, cooldownSeconds)), - ), - retryCloudtentaclesPendingReturns(), - ]) - const checkedResults = results.filter((item) => !item.skipped) - const failedResults = checkedResults.filter((item) => item.ok === false) - const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low') - const okResults = checkedResults.filter((item) => item.status === 'ok') - const assetValues = checkedResults - .map((item) => item.asset) - .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)) - const summary = buildCloudtentaclesHealthSummary(results) - const status = resolveCloudtentaclesHealthStatus(results) - const orphanTaskCount = Number(returnRetryResult?.orphanCount || 0) - const recycleNote = - orphanTaskCount > 0 - ? `,另有 ${orphanTaskCount} 个孤儿号码待人工/上游清理` - : returnRetryResult?.returned - ? ',发货后退号重试正常' - : '' - const finalMessage = `${summary}${recycleNote}` - - return { - ok: failedResults.length === 0, - status, - message: finalMessage, - accountCount: results.length, - checkedCount: checkedResults.length, - okCount: okResults.length, - lowAssetCount: lowAssetResults.length, - failedCount: failedResults.length, - asset: assetValues.length > 0 ? Math.min(...assetValues) : null, - threshold: - checkedResults.length > 0 - ? Math.max(...checkedResults.map((item) => Number(item.threshold || 0))) - : normalizeNonNegativeInteger(job.config?.assetThreshold, 500), - accounts: results, - pendingReturnRetry: returnRetryResult, - } -} - -async function runCloudtentaclesHealthAccount( - account: JsonObject, - cooldownSeconds: number, -): Promise { - const sourceKey = String(account.sourceKey || '').trim() || 'default' - const source = getCloudtentaclesSourceByKey(sourceKey) - const session: JsonObject = getCloudtentaclesSessionStateByKey(sourceKey) || {} - const label = - String(account.label || source?.label || source?.username || sourceKey).trim() || sourceKey - const threshold = normalizeNonNegativeInteger(account.assetThreshold, 500) - const checkedAt = new Date().toISOString() - - if (!source) { - return { - sourceKey, - label, - enabled: account.enabled !== false, - hasToken: false, - ok: false, - skipped: false, - status: 'source_missing', - message: `账号 ${label} 不存在`, - asset: null, - threshold, - checkedAt, - } - } - - if (account.enabled === false || source.enabled === false) { - return { - sourceKey, - label, - enabled: false, - hasToken: Boolean(String(session.token || '').trim()), - ok: true, - skipped: true, - status: 'disabled', - message: `账号 ${label} 已停用`, - asset: null, - threshold, - checkedAt, - } - } - - const token = String(session.token || '').trim() - - if (!token) { - await notifyCloudtentaclesAuthExpired({ - pathname: '/user/get_asset', - errorCode: 'cloudtentacles_token_missing', - message: `账号 ${label} 没有可用 token,请到后台重新登录`, - cooldownSeconds, - sourceKey, - accountLabel: label, - }) - return { - ok: false, - status: 'auth_missing', - message: `账号 ${label} token 缺失`, - asset: null, - threshold, - sourceKey, - label, - enabled: true, - hasToken: false, - skipped: false, - checkedAt, - } - } - - try { - const assetResult = await getCloudtentaclesAsset({ - sourceKey, - accountLabel: label, - baseUrl: String(session.baseUrl || source.baseUrl || '').trim() || 'https://123.207.217.176', - token, - deviceId: normalizeCloudtentaclesDeviceId(session.deviceId || source.deviceId), - deviceType: normalizeCloudtentaclesDeviceType(session.deviceType ?? source.deviceType), - }) - const asset = Number(assetResult.asset || 0) - - if (asset < threshold) { - await notifyCloudtentaclesAssetLow({ - asset, - threshold, - cooldownSeconds, - sourceKey, - accountLabel: label, - }) - } - - return { - ok: true, - status: asset < threshold ? 'asset_low' : 'ok', - message: - asset < threshold - ? `账号 ${label} 余额 ${asset} 低于阈值 ${threshold}` - : `账号 ${label} 余额 ${asset} 正常`, - asset, - threshold, - sourceKey, - label, - enabled: true, - hasToken: true, - skipped: false, - checkedAt, - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '余额检查失败') - return { - ok: false, - status: 'failed', - message: `账号 ${label} 检查失败:${message}`, - asset: null, - threshold, - sourceKey, - label, - enabled: true, - hasToken: true, - skipped: false, - checkedAt, - } - } -} - -function resolveCloudtentaclesHealthAccounts(job: JsonObject) { - const config = isPlainObject(job.config) ? job.config : {} - const rawAccounts = Array.isArray(config.accounts) ? config.accounts : [] - const defaultThreshold = normalizeNonNegativeInteger(config.assetThreshold, 500) - const sourceConfig = listCloudtentaclesSources() - const sources = Array.isArray(sourceConfig.sources) ? sourceConfig.sources : [] - const sourceMap = new Map( - sources - .map((source) => [String(source.key || '').trim(), source] as const) - .filter(([sourceKey]) => Boolean(sourceKey)), - ) - const configuredMap = new Map( - rawAccounts - .map((item) => normalizeCloudtentaclesHealthAccount(item, defaultThreshold, sourceMap)) - .filter(Boolean) - .map((item) => [String(item!.sourceKey || '').trim(), item!] as const) - .filter(([sourceKey]) => Boolean(sourceKey)), - ) - - // 以平台配置中的全部账号为准,合并任务里已保存的阈值/开关;新增账号自动纳入监控。 - const mergedAccounts = sources - .map((source) => { - const sourceKey = String(source.key || '').trim() - if (!sourceKey) { - return null - } - - const configured = configuredMap.get(sourceKey) - return { - sourceKey, - label: - String(configured?.label || source.label || source.username || sourceKey).trim() || - sourceKey, - enabled: configured ? configured.enabled !== false : source.enabled !== false, - assetThreshold: normalizeNonNegativeInteger(configured?.assetThreshold, defaultThreshold), - } - }) - .filter(Boolean) as JsonObject[] - - // 保留已配置但源账号已删除的项,便于报 source_missing。 - for (const [sourceKey, configured] of configuredMap.entries()) { - if (sourceMap.has(sourceKey)) { - continue - } - mergedAccounts.push(configured) - } - - if (mergedAccounts.length > 0) { - return dedupeAccounts(mergedAccounts) - } - - // 兼容旧配置:没有任何平台账号时,仍检查历史 default。 - const fallback = configuredMap.get('default') - return [ - { - sourceKey: 'default', - label: String(fallback?.label || '默认账号').trim() || '默认账号', - enabled: fallback ? fallback.enabled !== false : true, - assetThreshold: normalizeNonNegativeInteger(fallback?.assetThreshold, defaultThreshold), - }, - ] -} - -function normalizeCloudtentaclesHealthAccount( - rawValue: unknown, - defaultThreshold: number, - sourceMap: Map, -) { - const source = isPlainObject(rawValue) ? rawValue : {} - const sourceKey = String(source.sourceKey || source.key || '').trim() - if (!sourceKey) { - return null - } - - const sourceConfig = sourceMap.get(sourceKey) || {} - - return { - sourceKey, - label: String(source.label || sourceConfig.label || sourceConfig.username || sourceKey).trim(), - enabled: source.enabled !== false, - assetThreshold: normalizeNonNegativeInteger( - source.assetThreshold ?? source.threshold, - defaultThreshold, - ), - } -} - -function dedupeAccounts(accounts: JsonObject[]) { - const seen = new Set() - return accounts.filter((item) => { - const sourceKey = String(item.sourceKey || '').trim() - if (!sourceKey || seen.has(sourceKey)) { - return false - } - seen.add(sourceKey) - return true - }) -} - -function buildCloudtentaclesHealthSummary(results: CloudtentaclesHealthAccountResult[]) { - if (results.length === 0) { - return '未配置 kuaishou-lewan 监控账号' - } - - const checkedResults = results.filter((item) => !item.skipped) - const lowAssetResults = checkedResults.filter((item) => item.status === 'asset_low') - const failedResults = checkedResults.filter((item) => item.ok === false) - const disabledCount = results.length - checkedResults.length - - if (checkedResults.length === 0) { - return `所有账号均已停用,共 ${disabledCount} 个` - } - - if (failedResults.length > 0 || lowAssetResults.length > 0) { - return [ - `检查 ${checkedResults.length} 个账号`, - lowAssetResults.length > 0 ? `余额预警 ${lowAssetResults.length} 个` : '', - failedResults.length > 0 ? `异常 ${failedResults.length} 个` : '', - disabledCount > 0 ? `停用 ${disabledCount} 个` : '', - ] - .filter(Boolean) - .join(',') - } - - return `检查 ${checkedResults.length} 个账号,余额均正常${disabledCount > 0 ? `,停用 ${disabledCount} 个` : ''}` -} - -function resolveCloudtentaclesHealthStatus(results: CloudtentaclesHealthAccountResult[]) { - const checkedResults = results.filter((item) => !item.skipped) - if (checkedResults.length === 0) { - return 'skipped' - } - - if ( - checkedResults.some((item) => - ['auth_missing', 'source_missing', 'failed'].includes(item.status), - ) - ) { - return 'failed' - } - - if (checkedResults.some((item) => item.status === 'asset_low')) { - return 'asset_low' - } - - return 'ok' -} - -function normalizeNonNegativeInteger(value: unknown, fallback: number) { - const parsed = Number(value) - return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback -} - -function isPlainObject(value: unknown): value is JsonObject { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} diff --git a/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts b/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts deleted file mode 100644 index d78cd8bc..00000000 --- a/apps/backend/src/services/scheduler/cloudtentacles-number-recycle-service.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { createTaskEvent } from '../../repositories/task-event-repo.js' -import { updateTask } from '../../repositories/task-repo.js' -import { query as dbQuery } from '../../db/client.js' -import { TASK_STATUS } from '../../domain/task-status.js' -import { logInfo, logWarn } from '../../utils/logger.js' -import { parseJsonObject } from '../../utils/task-json.js' -import { nowIso } from '../../utils/time.js' -import type { TaskRow } from '../../types/repository/rows.js' -import { - isKuaishouCloudBindingMutationFrozen, - isKuaishouCloudTask, - maskPhone, - normalizeKuaishouCloudFlow, -} from '../fulfillment/kuaishou-cloud/domain.js' -import { refreshKuaishouCloudTaskBindUrl } from '../fulfillment/kuaishou-cloud/prepare-fulfillment.js' -import { returnKuaishouCloudFulfillmentTask } from '../fulfillment/kuaishou-cloud/return-fulfillment.js' - -/** bindUrl 过期超过该时长才回收(避免刚过期就触发上游操作) */ -const STALE_BIND_URL_GRACE_MS = 15 * 60 * 1000 -/** 单轮最多处理的任务数(health 每 2.5 分钟跑一次,防止一次扫太多) */ -const RECYCLE_SCAN_LIMIT = 50 - -type RecycleNumberResult = { - scanned: number - skippedFrozen: number - skippedMissingContext: number - refreshed: number - failed: number - /** 上游判定无权操作(号码已被手动退回/回收/账号换 token 后的孤儿号码),任务绑定已清空 */ - orphanCount: number - orphanTaskIds: number[] -} - -/** 上游"权限不足/没有权限/号码不存在"类错误:号码已不属于当前会话,重试无法恢复,应清空任务绑定 */ -function isOrphanNumberError(error: unknown) { - const message = error instanceof Error ? error.message : String(error || '') - return ( - message.includes('权限不足') || - message.includes('没有权限') || - message.includes('不存在') || - message.includes('已释放') || - message.includes('已回收') - ) -} - -/** 清空任务侧已失效的旧绑定(号码已退/已回收),任务下次领取时重新取号 */ -async function clearStaleKuaishouCloudBinding(task: TaskRow, errorMessage: string) { - const now = nowIso() - const taskContext = parseJsonObject(task.context_json) - const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment) - const oldVnId = flow.binding.vnId - const oldVnPhone = flow.binding.vnPhone - - const nextContext = { - ...taskContext, - kuaishouCloudFulfillment: { - ...flow, - binding: { - ...flow.binding, - prepareStatus: 'pending', - vnId: 0, - vnPhone: '', - bindUrl: '', - bindPreparedAt: null, - bindExpiresAt: null, - bindProbeAt: now, - bindProbeStatus: 'stale_cleared', - bindProbeMessage: errorMessage, - roleName: '', - roleId: '', - }, - role: { - status: 'pending', - name: '', - rid: '', - refreshedAt: now, - errorMessage: '旧绑定号码已失效,已清空绑定,将重新取号', - rawInfo: null, - }, - }, - } - - await updateTask(task.id, { - task_status: TASK_STATUS.PENDING_BINDING_PREPARE, - role_id: '', - role_name: '', - last_error: `旧绑定号码已失效(${errorMessage}),已清空绑定`, - context_json: JSON.stringify(nextContext), - updated_at: now, - }) - - await createTaskEvent( - task.id, - 'kuaishou_cloud_stale_binding_cleared', - { - source: 'scheduler_stale_number_recycle', - oldVnId, - oldVnPhoneMasked: maskPhone(oldVnPhone), - errorMessage, - actor: { source: 'system' }, - }, - now, - ) -} - -export async function recycleCloudtentaclesStaleNumbers(): Promise { - const staleBeforeIso = new Date(Date.now() - STALE_BIND_URL_GRACE_MS).toISOString() - const result: RecycleNumberResult = { - scanned: 0, - skippedFrozen: 0, - skippedMissingContext: 0, - refreshed: 0, - failed: 0, - orphanCount: 0, - orphanTaskIds: [], - } - - const rows = await dbQuery( - ` - SELECT * - FROM fulfillment_tasks ft - WHERE ft.executor_key = 'kuaishou_ct_assisted' - AND ft.task_status NOT IN ('redeeming', 'dispatched_pending_return', 'completed', 'redeemed', 'expired', 'closed') - AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,dispatch,status}', '') <> 'success' - AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,prepareStatus}', '') = 'ready' - AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}', '') <> '' - AND (ft.context_json #>> '{kuaishouCloudFulfillment,binding,bindExpiresAt}')::timestamptz < $1 - ORDER BY ft.id DESC - LIMIT $2 - `, - [staleBeforeIso, RECYCLE_SCAN_LIMIT], - ) - - for (const task of rows.rows) { - result.scanned += 1 - const taskId = Number(task.id || 0) - - try { - if (!isKuaishouCloudTask(task)) { - result.skippedMissingContext += 1 - continue - } - - const flow = normalizeKuaishouCloudFlow( - parseJsonObject(task.context_json).kuaishouCloudFulfillment, - ) - - // 已发货/兑换中:禁止换号覆盖,跳过 - if (isKuaishouCloudBindingMutationFrozen(task, flow)) { - result.skippedFrozen += 1 - continue - } - - if (!flow.binding.vnId || !flow.binding.vnKey || !flow.binding.bindUrl) { - result.skippedMissingContext += 1 - continue - } - - await refreshKuaishouCloudTaskBindUrl(task, { - source: 'scheduler_stale_number_recycle', - actor: { source: 'system' }, - }) - result.refreshed += 1 - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '') - if (isOrphanNumberError(error)) { - result.orphanCount += 1 - result.orphanTaskIds.push(taskId) - try { - await clearStaleKuaishouCloudBinding(task, message) - logWarn( - '[scheduler/cloudtentacles-number-recycle]', - '旧绑定号码无操作权限(号码已退/已回收),已清空任务绑定,等待重新取号', - { taskId, error: message }, - ) - } catch (clearError) { - result.failed += 1 - logWarn('[scheduler/cloudtentacles-number-recycle]', '清空旧绑定失败', { - taskId, - error: clearError instanceof Error ? clearError.message : String(clearError || ''), - }) - } - continue - } - result.failed += 1 - logWarn('[scheduler/cloudtentacles-number-recycle]', '过期绑定资源回收失败', { - taskId, - error: message, - }) - } - } - - if (result.scanned > 0) { - logInfo('[scheduler/cloudtentacles-number-recycle]', '过期绑定资源回收完成', { - ...result, - taskIds: rows.rows.map((row) => Number(row.id || 0)), - }) - } - - return result -} - -/** 重试发货后未完成的退号(DISPATCHED_PENDING_RETURN 且未退号成功) */ -export async function retryCloudtentaclesPendingReturns(): Promise<{ - scanned: number - returned: number - failed: number - orphanCount: number - orphanTaskIds: number[] -}> { - const result = { - scanned: 0, - returned: 0, - failed: 0, - orphanCount: 0, - orphanTaskIds: [] as number[], - } - const rows = await dbQuery( - ` - SELECT * - FROM fulfillment_tasks ft - WHERE ft.executor_key = 'kuaishou_ct_assisted' - AND ft.task_status = $1 - AND COALESCE(ft.context_json #>> '{kuaishouCloudFulfillment,returnNumber,status}', '') <> 'success' - ORDER BY ft.id DESC - LIMIT $2 - `, - [TASK_STATUS.DISPATCHED_PENDING_RETURN, RECYCLE_SCAN_LIMIT], - ) - - for (const task of rows.rows) { - result.scanned += 1 - const taskId = Number(task.id || 0) - - try { - if (!isKuaishouCloudTask(task)) { - result.failed += 1 - continue - } - const flow = normalizeKuaishouCloudFlow( - parseJsonObject(task.context_json).kuaishouCloudFulfillment, - ) - if (!flow.binding.vnId || !flow.binding.vnKey) { - result.failed += 1 - continue - } - - await returnKuaishouCloudFulfillmentTask(task, { - source: 'scheduler_retry_return_number', - actor: { source: 'system' }, - }) - result.returned += 1 - } catch (error) { - const message = error instanceof Error ? error.message : String(error || '') - if (isOrphanNumberError(error)) { - result.orphanCount += 1 - result.orphanTaskIds.push(taskId) - logWarn( - '[scheduler/cloudtentacles-number-recycle]', - '发货收尾退号无操作权限(号码已退/已回收),请人工确认任务收尾', - { taskId, error: message }, - ) - continue - } - result.failed += 1 - logWarn('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试失败', { - taskId, - error: message, - }) - } - } - - if (result.scanned > 0) { - logInfo('[scheduler/cloudtentacles-number-recycle]', '发货后退号重试完成', { - ...result, - taskIds: rows.rows.map((row) => Number(row.id || 0)), - }) - } - - return result -} diff --git a/apps/backend/src/services/scheduler/config-service.test.ts b/apps/backend/src/services/scheduler/config-service.test.ts index a68f4b7a..f1b71ed8 100644 --- a/apps/backend/src/services/scheduler/config-service.test.ts +++ b/apps/backend/src/services/scheduler/config-service.test.ts @@ -1,9 +1,9 @@ -import test from 'node:test' import assert from 'node:assert/strict' +import test from 'node:test' import { normalizeScheduledJobsConfig } from './config-service.js' -test('normalizeScheduledJobsConfig 将旧版 cloudtentacles 阈值迁移为默认账号配置', () => { +test('normalizeScheduledJobsConfig drops retired cloudtentacles_health jobs', () => { const config = normalizeScheduledJobsConfig({ enabled: true, jobs: [ @@ -11,97 +11,34 @@ test('normalizeScheduledJobsConfig 将旧版 cloudtentacles 阈值迁移为默 id: 'cloudtentacles-health', type: 'cloudtentacles_health', enabled: true, - intervalSeconds: 18000, - cooldownSeconds: 1800, - config: { - assetThreshold: 100, - }, + intervalSeconds: 300, + config: { assetThreshold: 100 }, }, - ], - }) - - assert.deepEqual(config.jobs[0], { - id: 'cloudtentacles-health', - type: 'cloudtentacles_health', - enabled: true, - intervalSeconds: 18000, - cooldownSeconds: 1800, - config: { - assetThreshold: 100, - accounts: [ - { - sourceKey: 'default', - label: '', - enabled: true, - assetThreshold: 100, - }, - ], - }, - }) -}) - -test('normalizeScheduledJobsConfig 支持多账号去重并保留 0 阈值', () => { - const config = normalizeScheduledJobsConfig({ - jobs: [ { - id: 'cloudtentacles-health', - type: 'cloudtentacles_health', + id: 'work-order-timeout', + type: 'work_order_timeout', enabled: true, - intervalSeconds: 30, - cooldownSeconds: 10, - config: { - assetThreshold: 500, - accounts: [ - { - sourceKey: 'default', - label: '默认账号', - enabled: true, - assetThreshold: 0, - }, - { - sourceKey: 'default', - label: '重复账号', - enabled: false, - assetThreshold: 999, - }, - { - sourceKey: 'account2', - enabled: false, - threshold: 200, - }, - ], - }, + intervalSeconds: 60, + config: { scanLimit: 50 }, }, ], }) - assert.deepEqual(config.jobs[0].config.accounts, [ - { - sourceKey: 'default', - label: '默认账号', - enabled: true, - assetThreshold: 0, - }, - { - sourceKey: 'account2', - label: '', - enabled: false, - assetThreshold: 200, - }, - ]) - assert.equal(config.jobs[0].intervalSeconds, 60) - assert.equal(config.jobs[0].cooldownSeconds, 60) + assert.equal( + config.jobs.some((item) => item.type === 'cloudtentacles_health'), + false, + ) + assert.equal( + config.jobs.some((item) => item.id === 'work-order-timeout'), + true, + ) }) -test('normalizeScheduledJobsConfig 自动补齐数据保留清理任务', () => { +test('normalizeScheduledJobsConfig ensures default jobs exist', () => { const config = normalizeScheduledJobsConfig({ enabled: true, jobs: [] }) - const retentionJob = config.jobs.find((job) => job.type === 'data_retention') - - assert.deepEqual(retentionJob, { - id: 'data-retention', - type: 'data_retention', - enabled: true, - intervalSeconds: 86400, - config: {}, - }) + const ids = config.jobs.map((item) => item.id) + assert.ok(ids.includes('work-order-timeout')) + assert.ok(ids.includes('deposit-unfreeze')) + assert.ok(ids.includes('data-retention')) + assert.ok(ids.includes('pdd_order_sync')) }) diff --git a/apps/backend/src/services/scheduler/config-service.ts b/apps/backend/src/services/scheduler/config-service.ts index fbbc41c7..b097f1da 100644 --- a/apps/backend/src/services/scheduler/config-service.ts +++ b/apps/backend/src/services/scheduler/config-service.ts @@ -6,7 +6,6 @@ import { PROJECT_ROOT } from '../../config/runtime.js' import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-store.js' const SCHEDULED_JOBS_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'scheduled-jobs.json') -const CLOUDTENTACLES_HEALTH_JOB_ID = 'cloudtentacles-health' const WORK_ORDER_TIMEOUT_JOB_ID = 'work-order-timeout' const DEPOSIT_UNFREEZE_JOB_ID = 'deposit-unfreeze' const DATA_RETENTION_JOB_ID = 'data-retention' @@ -34,14 +33,6 @@ export function saveScheduledJobsConfig(rawValue: unknown) { }) } -export function getCloudtentaclesHealthJob(config: JsonObject = getScheduledJobsConfig()) { - return ( - (Array.isArray(config.jobs) ? config.jobs : []).find( - (item) => String(item.id || '').trim() === CLOUDTENTACLES_HEALTH_JOB_ID, - ) || createDefaultCloudtentaclesHealthJob() - ) -} - export function getWorkOrderTimeoutJob(config: JsonObject = getScheduledJobsConfig()) { return ( (Array.isArray(config.jobs) ? config.jobs : []).find( @@ -80,15 +71,11 @@ export function normalizeScheduledJobsConfig(rawValue: unknown) { const jobs = rawJobs .map((item) => normalizeScheduledJob(item)) .filter((item): item is NonNullable> => Boolean(item)) - const hasCloudtentaclesHealth = jobs.some((item) => item.id === CLOUDTENTACLES_HEALTH_JOB_ID) const hasWorkOrderTimeout = jobs.some((item) => item.id === WORK_ORDER_TIMEOUT_JOB_ID) const hasDepositUnfreeze = jobs.some((item) => item.id === DEPOSIT_UNFREEZE_JOB_ID) const hasDataRetention = jobs.some((item) => item.id === DATA_RETENTION_JOB_ID) const hasPddOrderSync = jobs.some((item) => item.id === PDD_ORDER_SYNC_JOB_ID) - if (!hasCloudtentaclesHealth) { - jobs.push(createDefaultCloudtentaclesHealthJob()) - } if (!hasWorkOrderTimeout) { jobs.push(createDefaultWorkOrderTimeoutJob()) } @@ -114,8 +101,9 @@ function normalizeScheduledJob(rawValue: unknown) { } const type = String(rawValue.type || '').trim() + // 已下线的 cloudtentacles_health 配置直接丢弃 if (type === 'cloudtentacles_health') { - return normalizeCloudtentaclesHealthJob(rawValue) + return null } if (type === 'work_order_timeout') { return normalizeWorkOrderTimeoutJob(rawValue) @@ -185,78 +173,10 @@ function normalizeWorkOrderTimeoutJob(rawValue: JsonObject) { } } -function normalizeCloudtentaclesHealthJob(rawValue: JsonObject) { - const config = isPlainObject(rawValue.config) ? rawValue.config : {} - const defaultAssetThreshold = normalizeRangeInteger(config.assetThreshold, 500, 0, 999999) - - return { - id: CLOUDTENTACLES_HEALTH_JOB_ID, - type: 'cloudtentacles_health', - enabled: rawValue.enabled === true, - intervalSeconds: normalizeRangeInteger(rawValue.intervalSeconds, 300, 60, 86400), - cooldownSeconds: normalizeRangeInteger(rawValue.cooldownSeconds, 1800, 60, 86400), - config: { - assetThreshold: defaultAssetThreshold, - accounts: normalizeCloudtentaclesHealthAccounts(config, defaultAssetThreshold), - }, - } -} - -function normalizeCloudtentaclesHealthAccounts(config: JsonObject, defaultAssetThreshold: number) { - const rawAccounts = Array.isArray(config.accounts) - ? config.accounts - : Array.isArray(config.sourceKeys) - ? config.sourceKeys.map((sourceKey) => ({ sourceKey })) - : [] - const accounts = rawAccounts - .map((item) => normalizeCloudtentaclesHealthAccount(item, defaultAssetThreshold)) - .filter(Boolean) as ReturnType[] - - if (accounts.length === 0) { - accounts.push( - normalizeCloudtentaclesHealthAccount( - { - sourceKey: config.sourceKey || 'default', - assetThreshold: defaultAssetThreshold, - }, - defaultAssetThreshold, - ), - ) - } - - const seen = new Set() - return accounts.filter((item) => { - const sourceKey = String(item?.sourceKey || '').trim() - if (!sourceKey || seen.has(sourceKey)) { - return false - } - seen.add(sourceKey) - return true - }) -} - -function normalizeCloudtentaclesHealthAccount(rawValue: unknown, defaultAssetThreshold: number) { - const source = isPlainObject(rawValue) ? rawValue : {} - const sourceKey = String(source.sourceKey || source.key || '').trim() || 'default' - - return { - sourceKey, - label: String(source.label || '').trim(), - enabled: source.enabled !== false, - assetThreshold: normalizeRangeInteger( - source.assetThreshold ?? source.threshold, - defaultAssetThreshold, - 0, - 999999, - ), - } -} - function createDefaultScheduledJobsConfig() { return { enabled: true, jobs: [ - createDefaultCloudtentaclesHealthJob(), createDefaultWorkOrderTimeoutJob(), createDefaultDepositUnfreezeJob(), createDefaultDataRetentionJob(), @@ -311,27 +231,6 @@ function createDefaultWorkOrderTimeoutJob() { } } -function createDefaultCloudtentaclesHealthJob() { - return { - id: CLOUDTENTACLES_HEALTH_JOB_ID, - type: 'cloudtentacles_health', - enabled: false, - intervalSeconds: 300, - cooldownSeconds: 1800, - config: { - assetThreshold: 500, - accounts: [ - { - sourceKey: 'default', - label: '默认账号', - enabled: true, - assetThreshold: 500, - }, - ], - }, - } -} - function normalizeRangeInteger(value: unknown, fallback: number, min: number, max: number) { const parsed = Number(value) if (!Number.isInteger(parsed)) { diff --git a/apps/backend/src/services/scheduler/scheduler-service.ts b/apps/backend/src/services/scheduler/scheduler-service.ts index d56fd485..8b2660ec 100644 --- a/apps/backend/src/services/scheduler/scheduler-service.ts +++ b/apps/backend/src/services/scheduler/scheduler-service.ts @@ -2,7 +2,6 @@ import { logError, logInfo, logWarn } from '../../utils/logger.js' import { createHttpError } from '../../utils/http.js' import type { JsonObject } from '../../types/json.js' import { getScheduledJobById, getScheduledJobsConfig } from './config-service.js' -import { runCloudtentaclesHealthJob } from './cloudtentacles-health-job.js' import { runDepositUnfreezeJob } from './deposit-unfreeze-job.js' import { runWorkOrderTimeoutJob } from './work-order-timeout-job.js' import { runDataRetentionJob } from './data-retention-job.js' @@ -186,9 +185,6 @@ async function runJob(job: JsonObject, { manual = false }: { manual?: boolean } } function dispatchJob(job: JsonObject) { - if (job.type === 'cloudtentacles_health') { - return runCloudtentaclesHealthJob(job) - } if (job.type === 'work_order_timeout') { return runWorkOrderTimeoutJob(job) } diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index 9f598e45..959e9acf 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -5,15 +5,6 @@ export type AdminDefaultUser = { status?: string } -export type KuaishouFeifeiProductRule = { - id?: string - productName: string - normalizedProductName?: string - productCode: string - skuName?: string - enabled?: boolean - notes?: string -} export type AffiliateDashSkuMapping = Record @@ -87,43 +78,6 @@ export type RuntimeConfig = { timestampToleranceSeconds: number cardsEncoding: string } - cloudtentacles: { - baseUrl: string - timeoutMs: number - sendSmsPath: string - loginPath: string - userInfoPath: string - assetPath: string - permissionPath: string - categoriesPath: string - skuListPath: string - skuBuyPath: string - skuUsePath: string - knapsackPath: string - vnListPath: string - vnAppointPath: string - vnGenerateLoginCodePath: string - vnVerifCodePath: string - vnVerifyLoginCodePath: string - vnBindUrlPath: string - vnBindInfoPath: string - vnBackPath: string - bindUrlTtlSeconds: number - bindUrlProbeIntervalSeconds: number - bindUrlProbeTimeoutMs: number - bindUrlProbeUserAgent: string - bindUrlProbeEndpoint: string - bindUrlProbeChartId: string - bindUrlProbeSubChartId: string - bindUrlProbeIdeToken: string - bindUrlProbeActivityUrl: string - bindUrlProbeReferer: string - bindUrlProbeExtraCookie: string - publicKeyPem: string - clientSource: string - deviceId: string - deviceType: number - } kuaishouIndustry: { appKey: string appSecret: string @@ -135,15 +89,6 @@ export type RuntimeConfig = { shopName: string version: string } - kuaishouFeifei: { - enabled: boolean - baseUrl: string - appKey: string - appSecret: string - timeoutMs: number - notifyUrl: string - productRules: KuaishouFeifeiProductRule[] - } affiliateDash: { enabled: boolean baseUrl: string diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 884ad9df..34577246 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -20,9 +20,6 @@ import WorkerLayout from '@/layouts/WorkerLayout' const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage')) const AdminFinancePage = lazy(() => import('@/pages/admin/AdminFinancePage')) const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage')) -const AdminCloudtentaclesRecordsPage = lazy( - () => import('@/pages/admin/AdminCloudtentaclesRecordsPage'), -) const AdminKuaishouIndustryPage = lazy(() => import('@/pages/admin/AdminKuaishouIndustryPage')) const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage')) const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage')) @@ -120,7 +117,6 @@ function AdminApplication() { } /> }> } /> - } /> } /> }> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index a03e4878..4a3fa4c0 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -132,11 +132,6 @@ export default function AdminLayout() { icon: , label: '电子凭证', }, - { - key: '/admin/cloudtentacles-records', - icon: , - label: '发货记录', - }, ] const items: MenuProps['items'] = [ diff --git a/apps/frontend/src/pages/admin/AdminCloudtentaclesRecordsPage.tsx b/apps/frontend/src/pages/admin/AdminCloudtentaclesRecordsPage.tsx deleted file mode 100644 index 9b925834..00000000 --- a/apps/frontend/src/pages/admin/AdminCloudtentaclesRecordsPage.tsx +++ /dev/null @@ -1,670 +0,0 @@ -import { - DownloadOutlined, - FileImageOutlined, - ReloadOutlined, - SearchOutlined, -} from '@ant-design/icons' -import { - Alert, - Button, - Card, - Image as AntdImage, - Input, - Modal, - Select, - Space, - Table, - Tag, - Typography, -} from 'antd' -import type { TableColumnsType } from 'antd' -import { useEffect, useMemo, useState } from 'react' - -import { AdminRangePicker } from '@/components/admin/AdminDatePicker' -import PageHeader from '@/components/admin/PageHeader' -import dayjs, { type Dayjs } from '@/lib/dayjs' -import { showError, showSuccess } from '@/lib/feedback' -import { - fetchAdminCloudtentaclesDeliveryRecords, - fetchAdminCloudtentaclesRecordSources, -} from '@/services/admin' -import type { AdminCloudtentaclesDeliveryRecordItem } from '@/types/admin' -import { getAdminToken } from '@/utils/admin-auth' -import { - buildCloudtentaclesTablePagination, - CLOUDTENTACLES_DEFAULT_PAGE_SIZE, -} from '@/utils/admin-pagination' -import { formatAdminDateTime } from '@/utils/admin-time' - -type RecordSourceOption = { - key: string - label: string - enabled: boolean - hasToken: boolean - loggedInAt: string -} - -type DateRangeValue = [Dayjs | null, Dayjs | null] | null - -export default function AdminCloudtentaclesRecordsPage() { - const [loading, setLoading] = useState(false) - const [sourceLoading, setSourceLoading] = useState(false) - const [errorMessage, setErrorMessage] = useState('') - const [sourceKey, setSourceKey] = useState('') - const [platformOrderId, setPlatformOrderId] = useState('') - const [dateRange, setDateRange] = useState(createDefaultDateRange()) - const [page, setPage] = useState(1) - const [pageSize, setPageSize] = useState(CLOUDTENTACLES_DEFAULT_PAGE_SIZE) - const [total, setTotal] = useState(0) - const [items, setItems] = useState([]) - const [sourceOptions, setSourceOptions] = useState([]) - const [previewVisible, setPreviewVisible] = useState(false) - const [previewImageUrl, setPreviewImageUrl] = useState('') - const [previewFileName, setPreviewFileName] = useState('') - - const currentSourceLabel = useMemo(() => { - const matched = sourceOptions.find((source) => source.key === sourceKey) - return matched?.label || sourceKey || '-' - }, [sourceKey, sourceOptions]) - - const columns: TableColumnsType = [ - { - title: '商品 / 角色', - minWidth: 260, - render: (_, row) => ( -
- - {row.name || '-'} - - 账号:{row.fulfillUser || '-'} - {row.roleName || row.roleId ? ( - - 角色:{row.roleName || '-'} - {row.roleId ? `(${row.roleId})` : ''} - - ) : null} -
- ), - }, - { - title: '订单 / 店铺', - minWidth: 260, - render: (_, row) => ( -
- - {row.platformOrderId || '-'} - - 订单店铺:{row.orderShopName || row.orderShopId || '-'} - 核销店铺:{row.consumeShopName || row.consumeShopId || '-'} -
- ), - }, - { - title: '虚拟号', - minWidth: 160, - render: (_, row) => ( -
- {row.phone || '-'} - VN {row.virtualNumberId || '-'} -
- ), - }, - { - title: '状态', - width: 140, - render: (_, row) => ( -
- {getStatusLabel(row.status)} - {row.localDispatchStatus ? ( - {getLocalDispatchStatusLabel(row.localDispatchStatus)} - ) : null} -
- ), - }, - { - title: '操作人', - minWidth: 120, - render: (_, row) => row.operatorName || '-', - }, - { - title: '发货时间', - minWidth: 170, - render: (_, row) => formatAdminDateTime(row.createdAt), - }, - { - title: '记录 ID', - minWidth: 230, - render: (_, row) => ( -
- - {row.recordId || '-'} - - {row.taskNo ? ( - - 任务:{row.taskNo} - {getMatchConfidenceLabel(row.matchConfidence) - ? ` · 匹配${getMatchConfidenceLabel(row.matchConfidence)}` - : ''} - - ) : null} -
- ), - }, - { - title: '操作', - fixed: 'right', - width: 136, - render: (_, row) => ( - - ), - }, - ] - - useEffect(() => { - void bootstrap() - }, []) - - async function bootstrap() { - const nextSourceKey = await loadSources() - if (nextSourceKey) { - await loadRecords(1, { sourceKey: nextSourceKey }) - } - } - - async function loadSources() { - setSourceLoading(true) - - try { - const response = await fetchAdminCloudtentaclesRecordSources() - const nextSources = response.data.sources || [] - const usableSources = nextSources.filter((source) => source.enabled && source.hasToken) - const nextSourceKey = sourceKey || usableSources[0]?.key || nextSources[0]?.key || '' - setSourceOptions(nextSources) - setSourceKey(nextSourceKey) - return nextSourceKey - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 账号失败') - return '' - } finally { - setSourceLoading(false) - } - } - - async function loadRecords( - nextPage = page, - options: { - sourceKey?: string - pageSize?: number - platformOrderId?: string - dateRange?: DateRangeValue - } = {}, - ) { - const nextSourceKey = options.sourceKey ?? sourceKey - const nextPageSize = options.pageSize ?? pageSize - const nextDateRange = options.dateRange ?? dateRange - const orderKeyword = (options.platformOrderId ?? platformOrderId).trim() - - if (!nextSourceKey && !orderKeyword) { - showError('请先选择 kuaishou-lewan 账号') - return - } - - const [startDate, endDate] = nextDateRange || [] - if (!startDate || !endDate) { - showError('请选择查询时间范围') - return - } - - setLoading(true) - setErrorMessage('') - - try { - const response = await fetchAdminCloudtentaclesDeliveryRecords({ - sourceKey: nextSourceKey || undefined, - page: nextPage, - size: nextPageSize, - startDate: formatQueryDateTime(startDate), - endDate: formatQueryDateTime(endDate), - platformOrderId: orderKeyword || undefined, - }) - - if (response.data.sourceKey && response.data.sourceKey !== sourceKey) { - setSourceKey(response.data.sourceKey) - } - - setPage(response.data.page) - setPageSize(response.data.size) - setTotal(response.data.total) - setItems(response.data.items || []) - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : '查询发货记录失败') - } finally { - setLoading(false) - } - } - - function resetFilters() { - const nextDateRange = createDefaultDateRange() - setPlatformOrderId('') - setDateRange(nextDateRange) - setPage(1) - setPageSize(CLOUDTENTACLES_DEFAULT_PAGE_SIZE) - void loadRecords(1, { - pageSize: CLOUDTENTACLES_DEFAULT_PAGE_SIZE, - platformOrderId: '', - dateRange: nextDateRange, - }) - } - - async function generateRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) { - try { - const dataUrl = await drawRecordImage(record) - setPreviewImageUrl(dataUrl) - setPreviewFileName(buildRecordImageFileName(record)) - setPreviewVisible(true) - } catch (error) { - showError(error instanceof Error ? error.message : '生成发货记录图片失败') - } - } - - function downloadPreviewImage() { - if (!previewImageUrl) { - return - } - - const link = document.createElement('a') - link.href = previewImageUrl - link.download = previewFileName || 'cloudtentacles-delivery-record.png' - link.click() - showSuccess('发货记录图片已生成') - } - - return ( -
- 共 {total} 条记录} - /> - - - 输入订单号时会先关联本地任务,再匹配 kuaishou-lewan 发货记录。 - - } - > -
- setPlatformOrderId(event.target.value)} - onPressEnter={() => loadRecords(1)} - /> - setDateRange(value)} - /> - - - - -
-
- - {errorMessage ? : null} - - -
- 发货记录 - - 账号 {currentSourceLabel} · 第 {page} 页,当前展示 {items.length} 条 - -
- - - rowKey={(row) => row.recordId || `${row.virtualNumberId}-${row.createdAt}`} - loading={loading} - columns={columns} - dataSource={items} - size="small" - scroll={{ x: 1450 }} - pagination={buildCloudtentaclesTablePagination({ - current: page, - pageSize, - total, - onChange: (nextPage, nextPageSize) => { - void loadRecords(nextPage, { pageSize: nextPageSize }) - }, - })} - locale={{ emptyText: loading ? '发货记录加载中' : '当前筛选条件下暂无发货记录' }} - /> -
- - setPreviewVisible(false)} - footer={[ - , - , - ]} - > -
- {previewImageUrl ? ( - - ) : null} -
-
-
- ) -} - -function getStatusLabel(status: number) { - if (status === 4) return '已完成' - if (status === 1) return '处理中' - if (status === 2) return '待处理' - if (status === 3) return '失败' - return `状态 ${status || '-'}` -} - -function getStatusColor(status: number) { - if (status === 4) return 'green' - if (status === 3) return 'red' - if (status === 1) return 'orange' - return 'blue' -} - -function getLocalDispatchStatusLabel(status: string) { - const normalized = String(status || '').trim() - if (normalized === 'success') return '本地已发货' - if (normalized === 'failed') return '本地发货失败' - if (normalized === 'pending') return '本地待发货' - return normalized || '-' -} - -function getMatchConfidenceLabel(value: string) { - if (value === 'high') return '高' - if (value === 'medium') return '中' - if (value === 'low') return '低' - if (value === 'local') return '本地' - return '' -} - -function createDefaultDateRange(): DateRangeValue { - const end = dayjs() - return [end.subtract(7, 'day').startOf('day'), end] -} - -function formatQueryDateTime(value: Dayjs) { - return value.format('YYYY-MM-DD HH:mm:ss') -} - -async function drawRecordImage(record: AdminCloudtentaclesDeliveryRecordItem) { - const productImage = await loadRecordProductImage(record.skuImage) - const canvas = document.createElement('canvas') - const scale = window.devicePixelRatio || 1 - const width = 720 - const height = 330 - canvas.width = width * scale - canvas.height = height * scale - canvas.style.width = `${width}px` - canvas.style.height = `${height}px` - - const ctx = canvas.getContext('2d') - if (!ctx) { - throw new Error('当前浏览器不支持图片生成') - } - - ctx.scale(scale, scale) - ctx.fillStyle = '#ffffff' - ctx.fillRect(0, 0, width, height) - ctx.fillStyle = '#1f2937' - ctx.font = '20px sans-serif' - ctx.fillText('订单详情', 28, 38) - ctx.fillStyle = '#9ca3af' - ctx.font = '24px sans-serif' - ctx.fillText('x', width - 40, 38) - - ctx.fillStyle = '#111827' - ctx.font = '13px sans-serif' - ctx.textAlign = 'center' - ctx.fillText(`购买时间:${formatAdminDateTime(record.createdAt)}`, width / 2, 88) - ctx.textAlign = 'left' - - drawTicketCard(ctx, record, productImage) - drawRecordMeta(ctx, record) - return canvas.toDataURL('image/png') -} - -function drawTicketCard( - ctx: CanvasRenderingContext2D, - record: AdminCloudtentaclesDeliveryRecordItem, - productImage: HTMLImageElement | null, -) { - const x = 170 - const y = 108 - const width = 392 - const height = 108 - const imageWidth = 106 - - ctx.save() - ctx.beginPath() - ctx.moveTo(x + 18, y) - ctx.lineTo(x + width, y) - ctx.lineTo(x + width, y + height - 22) - ctx.lineTo(x + width - 18, y + height) - ctx.lineTo(x, y + height) - ctx.lineTo(x, y + 18) - ctx.closePath() - ctx.fillStyle = '#f8fbff' - ctx.fill() - ctx.strokeStyle = '#2f80d1' - ctx.lineWidth = 1 - ctx.stroke() - ctx.restore() - - const gradient = ctx.createLinearGradient(x, y, x + imageWidth, y + height) - gradient.addColorStop(0, '#e7f1ff') - gradient.addColorStop(1, '#b8d7ff') - ctx.fillStyle = gradient - ctx.fillRect(x, y, imageWidth, height) - - if (productImage) { - drawCoverImage(ctx, productImage, x, y, imageWidth, height) - } else { - ctx.fillStyle = '#355d91' - ctx.font = '12px sans-serif' - ctx.textAlign = 'center' - ctx.fillText('套装发货记录', x + imageWidth / 2, y + 25) - ctx.fillStyle = '#ffffff' - ctx.fillRect(x + 34, y + 42, 38, 38) - ctx.strokeStyle = '#8ab6ef' - ctx.strokeRect(x + 34, y + 42, 38, 38) - ctx.fillStyle = '#2f80d1' - ctx.font = '20px sans-serif' - ctx.fillText('GP', x + imageWidth / 2, y + 69) - } - ctx.textAlign = 'left' - - const contentX = x + imageWidth + 16 - const orderText = `订单号:${record.platformOrderId || record.recordId.slice(-8) || '-'}` - ctx.fillStyle = '#1d5ea8' - ctx.font = '16px sans-serif' - drawEllipsisText(ctx, record.name || '未命名商品', contentX, y + 31, 170) - ctx.fillStyle = '#3478c6' - ctx.font = '13px sans-serif' - ctx.textAlign = 'right' - ctx.fillText(orderText, x + width - 12, y + 30) - ctx.textAlign = 'left' - - ctx.strokeStyle = '#c8d8ec' - ctx.beginPath() - ctx.moveTo(contentX, y + 42) - ctx.lineTo(x + width - 12, y + 42) - ctx.stroke() - - ctx.fillStyle = '#f59e0b' - ctx.font = '14px sans-serif' - ctx.fillText(`购买机会 x ${record.count || 1}`, contentX, y + 66) - ctx.fillStyle = '#16a34a' - ctx.textAlign = 'right' - ctx.fillText(getStatusLabel(record.status), x + width - 12, y + 66) - ctx.textAlign = 'left' - - ctx.fillStyle = '#3478c6' - ctx.font = '14px sans-serif' - drawEllipsisText(ctx, `账号:${record.fulfillUser || record.phone || '-'}`, contentX, y + 90, 250) -} - -function drawRecordMeta( - ctx: CanvasRenderingContext2D, - record: AdminCloudtentaclesDeliveryRecordItem, -) { - const left = 80 - const top = 250 - const lineHeight = 22 - const values = [ - `订单店铺:${record.orderShopName || record.orderShopId || '-'}`, - `核销店铺:${record.consumeShopName || record.consumeShopId || '-'}`, - `绑定角色:${record.roleName || record.fulfillUser || '-'}${record.roleId ? `(${record.roleId})` : ''}`, - ] - - ctx.fillStyle = '#374151' - ctx.font = '13px sans-serif' - values.forEach((value, index) => { - drawEllipsisText(ctx, value, left, top + index * lineHeight, 560) - }) -} - -async function loadRecordProductImage(imageUrl: string) { - const normalizedUrl = String(imageUrl || '').trim() - if (!normalizedUrl) { - return null - } - - const proxyUrl = `/api/v1/admin/cloudtentacles-records/image?url=${encodeURIComponent( - normalizedUrl, - )}` - return loadProtectedImage(proxyUrl).catch(() => null) -} - -async function loadProtectedImage(src: string) { - const token = getAdminToken() - const response = await fetch(src, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - }) - - if (!response.ok) { - throw new Error('商品图片加载失败') - } - - const objectUrl = URL.createObjectURL(await response.blob()) - try { - return await loadImage(objectUrl) - } finally { - URL.revokeObjectURL(objectUrl) - } -} - -function loadImage(src: string) { - return new Promise((resolve, reject) => { - const image = new Image() - image.onload = () => resolve(image) - image.onerror = () => reject(new Error('商品图片加载失败')) - image.src = src - }) -} - -function drawCoverImage( - ctx: CanvasRenderingContext2D, - image: HTMLImageElement, - x: number, - y: number, - width: number, - height: number, -) { - const ratio = Math.max(width / image.naturalWidth, height / image.naturalHeight) - const drawWidth = image.naturalWidth * ratio - const drawHeight = image.naturalHeight * ratio - const drawX = x + (width - drawWidth) / 2 - const drawY = y + (height - drawHeight) / 2 - - ctx.save() - ctx.beginPath() - ctx.rect(x, y, width, height) - ctx.clip() - ctx.drawImage(image, drawX, drawY, drawWidth, drawHeight) - ctx.restore() -} - -function drawEllipsisText( - ctx: CanvasRenderingContext2D, - text: string, - x: number, - y: number, - maxWidth: number, -) { - let output = text - while (output.length > 0 && ctx.measureText(output).width > maxWidth) { - output = `${output.slice(0, -2)}...` - } - ctx.fillText(output || '-', x, y) -} - -function buildRecordImageFileName(record: AdminCloudtentaclesDeliveryRecordItem) { - const name = sanitizeFileName(record.name || '发货记录') - const id = sanitizeFileName( - record.platformOrderId || - record.recordId.slice(0, 8) || - String(record.virtualNumberId || 'record'), - ) - return `${name}-${id}.png` -} - -function sanitizeFileName(value: string) { - return value.replace(/[\\/:*?"<>|]/g, '_').slice(0, 48) -} diff --git a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx index 1d5d7ba7..1403340c 100644 --- a/apps/frontend/src/pages/admin/AdminDevMockPage.tsx +++ b/apps/frontend/src/pages/admin/AdminDevMockPage.tsx @@ -22,10 +22,8 @@ import PageHeader from '@/components/admin/PageHeader' import { buildOpen91QueryDevMock, createAffiliateDashDevMock, - createFeifeiDevMock, - createKuaishouIndustryVoucherDevMock, - createLewanDevMock, - fetchDevMockStatus, + createKuaishouIndustryVoucherDevMock, + fetchDevMockStatus, type DevMockCreateResult, type DevMockKuaishouIndustryVoucherResult, type DevMockOpen91QueryResult, @@ -47,27 +45,7 @@ export default function AdminDevMockPage() { const status = statusQuery.data?.data const enabled = Boolean(status?.enabled) - const lewanMutation = useMutation({ - mutationFn: createLewanDevMock, - onSuccess: (response) => { - setLastCreate(response.data) - message.success('lewan mock 已生成') - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : '生成失败') - }, - }) - const feifeiMutation = useMutation({ - mutationFn: createFeifeiDevMock, - onSuccess: (response) => { - setLastCreate(response.data) - message.success('feifei mock 已生成') - }, - onError: (error) => { - message.error(error instanceof Error ? error.message : '生成失败') - }, - }) const affiliateDashMutation = useMutation({ mutationFn: createAffiliateDashDevMock, @@ -133,7 +111,7 @@ export default function AdminDevMockPage() {
NODE_ENV={status?.nodeEnv || '-'} } @@ -143,32 +121,14 @@ export default function AdminDevMockPage() { type="info" showIcon icon={} - message="Mock 会写入开发库真实订单/任务,但 lewan 兑换走 mock 成功路径,feifei 不调用真实平台下单。" - description="建议 UID 与默认角色一致:lewan 默认 10001,feifei 默认 166909256。" + message="Mock 会写入开发库真实订单/任务;当前仅保留 affiliate-dash / 电子凭证 / 91 查单。" + description="affiliate-dash 可用默认 UID 测试绑定与提交发货。" /> - - - lewanMutation.mutate(values)} - /> - - - - feifeiMutation.mutate(values)} - /> - - - - - + affiliateDashMutation.mutate(values)} @@ -177,7 +137,7 @@ export default function AdminDevMockPage() { - + voucherMutation.mutate(values)} @@ -186,7 +146,7 @@ export default function AdminDevMockPage() { - +
) => void -}) { - const [form] = Form.useForm() - const step = Form.useWatch('step', form) - const uidRequired = step !== 'uid' - - return ( - - - - - - - - - - - -
- ) -} - -function FeifeiMockForm({ - loading, - onSubmit, -}: { - loading: boolean - onSubmit: (values: Record) => void -}) { - const [form] = Form.useForm() - const step = Form.useWatch('step', form) - const uidRequired = step !== 'uid' - - return ( -
- - - - - - - - - - - - - -
- ) -} - function AffiliateDashMockForm({ loading, onSubmit, diff --git a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx index d2716e65..b4c998ae 100644 --- a/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTaskDetailPage.tsx @@ -4,10 +4,6 @@ import { CloseCircleOutlined, CopyOutlined, ReloadOutlined, - RollbackOutlined, - SendOutlined, - SwapOutlined, - ToolOutlined, UserSwitchOutlined, } from '@ant-design/icons' import { @@ -38,16 +34,11 @@ import { closeAdminTask, completeAdminTaskManualDispatch, consumeAdminTaskKuaishouIndustryVoucher, - dispatchAdminTaskKuaishouCloudFulfillment, fetchAdminTaskDetail, fetchAdminTaskScreenshot, markAdminTaskManualReview, - prepareAdminTaskKuaishouCloudFulfillment, - rebindAdminTaskKuaishouCloudRole, - refreshAdminTaskKuaishouCloudRoleInfo, resendAdminTaskKuaishouIndustryVoucherCode, retryAdminTask, - returnNumberAdminTaskKuaishouCloudFulfillment, } from '@/services/admin' import type { AdminTaskActionResponse, AdminTaskDetail } from '@/types/admin' import { hasAdminRole } from '@/utils/admin-auth' @@ -275,38 +266,6 @@ export default function AdminTaskDetailPage() { `确认重试任务 ${resolvedDetail.task.taskNo} 吗?`, ) } - onRebindRole={() => - runTaskAction( - 'rebind-role', - () => rebindAdminTaskKuaishouCloudRole(resolvedDetail.task.taskId), - '换绑完成:已生成新的绑定链接', - `确认为任务 ${resolvedDetail.task.taskNo} 换绑吗?会退还当前虚拟号并生成新绑链;若旧号已被系统回收会自动跳过退号并直接新取号。原领取链接可继续使用。`, - ) - } - onPrepareKuaishouCloud={() => - runTaskAction( - 'prepare-kuaishou-cloud', - () => prepareAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId), - '绑定资源已准备完成', - `确认开始为任务 ${resolvedDetail.task.taskNo} 准备绑定资源吗?系统会自动检查库存配置并申请虚拟号。`, - ) - } - onDispatchKuaishouCloud={() => - runTaskAction( - 'dispatch-kuaishou-cloud', - () => dispatchAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId), - '已完成绑定确认并发货', - `确认客户已经完成绑定,并立即为任务 ${resolvedDetail.task.taskNo} 执行发货吗?这个动作会把"确认绑定完成"和"发货"合并为一步。`, - ) - } - onReturnKuaishouCloud={() => - runTaskAction( - 'return-kuaishou-cloud', - () => returnNumberAdminTaskKuaishouCloudFulfillment(resolvedDetail.task.taskId), - '号码已退还', - `确认退还任务 ${resolvedDetail.task.taskNo} 当前使用的虚拟号吗?退号后该流程会正式收口。`, - ) - } onResendIndustryVoucher={() => runTaskAction( 'resend-industry-voucher', @@ -419,23 +378,11 @@ export default function AdminTaskDetailPage() { items={[ { key: 'cloud', - label: 'kuaishou-lewan', + label: '乐玩云(历史)', children: flow ? ( - - runTaskAction( - 'refresh-role', - () => refreshAdminTaskKuaishouCloudRoleInfo(resolvedDetail.task.taskId), - '角色信息已刷新', - `确认刷新任务 ${resolvedDetail.task.taskNo} 当前云绑定角色信息吗?系统会重新查询 kuaishou-lewan 的绑定结果。`, - ) - } - /> + ) : ( - + ), }, { @@ -496,10 +443,6 @@ function TaskActionPanel({ canCloseTasks, actionLoadingKey, onRetry, - onRebindRole, - onPrepareKuaishouCloud, - onDispatchKuaishouCloud, - onReturnKuaishouCloud, onResendIndustryVoucher, onConsumeIndustryVoucher, onMarkManualReview, @@ -511,10 +454,6 @@ function TaskActionPanel({ canCloseTasks: boolean actionLoadingKey: string onRetry: () => void - onRebindRole: () => void - onPrepareKuaishouCloud: () => void - onDispatchKuaishouCloud: () => void - onReturnKuaishouCloud: () => void onResendIndustryVoucher: () => void onConsumeIndustryVoucher: () => void onMarkManualReview: () => void @@ -522,11 +461,6 @@ function TaskActionPanel({ }) { const operations = detail.operations const loading = Boolean(actionLoadingKey) - const hasCloudActions = - operations.canPrepareKuaishouCloudFulfillment || - operations.canRebindKuaishouCloudRole || - operations.canDispatchKuaishouCloudFulfillment || - operations.canReturnKuaishouCloudFulfillment const hasIndustryVoucherActions = operations.canResendKuaishouIndustryVoucherCode || operations.canConsumeKuaishouIndustryVoucher || @@ -560,57 +494,6 @@ function TaskActionPanel({
) : null} - {hasCloudActions ? ( -
- kuaishou-lewan - - {canManageTaskLifecycle && operations.canPrepareKuaishouCloudFulfillment ? ( - - ) : null} - {operations.canRebindKuaishouCloudRole ? ( - - ) : null} - {canManageTaskLifecycle && operations.canDispatchKuaishouCloudFulfillment ? ( - - ) : null} - {canManageTaskLifecycle && operations.canReturnKuaishouCloudFulfillment ? ( - - ) : null} - -
- ) : null} - {canOperateIndustryVoucher && hasIndustryVoucherActions ? (
电子凭证 @@ -652,10 +535,7 @@ function TaskActionPanel({
) : null} - {!canManageTaskLifecycle && - !canOperateIndustryVoucher && - !canCloseTasks && - !hasCloudActions ? ( + {!canManageTaskLifecycle && !canOperateIndustryVoucher && !canCloseTasks ? ( 当前账号没有可执行的任务操作 ) : null} @@ -964,14 +844,17 @@ function resolveDeliveryStepSummary(detail: AdminTaskDetail) { function resolveTaskExecutorDisplay(value: unknown) { const executorKey = String(value || '').trim() + if (executorKey === 'affiliate_dash') { + return { label: 'Affiliate Dash', color: 'green' } + } if (executorKey === 'kuaishou_ct_assisted') { - return { label: 'kuaishou-lewan', color: 'blue' } + return { label: '乐玩云(已下线)', color: 'default' } } if (executorKey === 'kuaishou-industry') { return { label: '行业电子凭证', color: 'cyan' } } if (executorKey === 'kuaishou_feifei') { - return { label: 'kuaishou-feifei', color: 'purple' } + return { label: '飞飞(已下线)', color: 'default' } } if (executorKey === 'manual_dispatch') { return { label: '人工履约', color: 'orange' } @@ -1152,14 +1035,8 @@ function KuaishouIndustryVoucherPanel({ voucher }: { voucher: KuaishouIndustryVo function KuaishouCloudPanel({ flow, - canRefresh, - actionLoadingKey, - onRefreshRole, }: { flow: NonNullable - canRefresh: boolean - actionLoadingKey: string - onRefreshRole: () => void }) { const roleInfoEntries = flow.role.rawInfo ? Object.entries(flow.role.rawInfo).filter( @@ -1220,19 +1097,7 @@ function KuaishouCloudPanel({ return (
- } - disabled={Boolean(actionLoadingKey) || !canRefresh} - loading={actionLoadingKey === 'refresh-role'} - onClick={onRefreshRole} - > - 刷新角色 - - } - > + {flow.binding.resolvedSourceLabel || flow.binding.resolvedSourceKey || '-'} diff --git a/apps/frontend/src/pages/admin/AdminTasksPage.tsx b/apps/frontend/src/pages/admin/AdminTasksPage.tsx index d439e7d4..5b419eb8 100644 --- a/apps/frontend/src/pages/admin/AdminTasksPage.tsx +++ b/apps/frontend/src/pages/admin/AdminTasksPage.tsx @@ -2,7 +2,6 @@ import { CloseCircleOutlined, ReloadOutlined, SearchOutlined, - SwapOutlined, UserSwitchOutlined, } from '@ant-design/icons' import { @@ -32,7 +31,6 @@ import { closeAdminTask, fetchAdminTasks, markAdminTaskManualReview, - rebindAdminTaskKuaishouCloudRole, retryAdminTask, } from '@/services/admin' import type { AdminTaskActionResponse, AdminTaskListItem } from '@/types/admin' @@ -52,7 +50,7 @@ type TaskFilterForm = { dateRange?: unknown } -type TaskListAction = 'retry' | 'rebind_role' | 'manual_review' | 'close' +type TaskListAction = 'retry' | 'manual_review' | 'close' type TaskListBlocker = { label: string color: string @@ -72,8 +70,6 @@ export default function AdminTasksPage() { const page = Number(searchParams.get('page') || 1) || 1 const pageSize = resolveAdminPageSize(searchParams.get('pageSize'), ADMIN_DEFAULT_PAGE_SIZE) const canManageTaskLifecycle = hasAdminRole('operator') - // 与详情页一致:客服可换绑协助处理(canOperateAssistedTask) - const canRebindRole = hasAdminRole('support') const canCloseTasks = hasAdminRole('support') const filters = { keyword: @@ -235,12 +231,11 @@ export default function AdminTasksPage() { canManageTaskLifecycle && row.executorKey !== 'manual_dispatch' && ['retry_pending', 'manual_review'].includes(row.status) - const canRebind = canRebindListTask(row, canRebindRole) const canManualReview = canManageTaskLifecycle && !['redeemed', 'closed', 'completed', 'manual_review'].includes(row.status) const canClose = canCloseTasks && !['redeemed', 'closed'].includes(row.status) - const hasActions = canRetry || canRebind || canManualReview || canClose + const hasActions = canRetry || canManualReview || canClose return (
@@ -264,17 +259,6 @@ export default function AdminTasksPage() { 重试 ) : null} - {canRebind ? ( - - ) : null} {canManualReview ? ( - - - -
- - - {lastSyncText || 'feifei 商品名会按规范化名字匹配 91 商品名。'} - - } - > - - setMatchInput(event.target.value)} - onPressEnter={previewMatch} - /> - - - - {matchResult ? ( - ${matchResult.match.productCode}` - : matchResult.normalizedProductName || matchResult.productName - } - /> - ) : null} - - - rowKey={(row) => row.id || `${row.productName}-${row.productCode}`} - columns={columns} - dataSource={rules} - pagination={buildAdminLocalTablePagination()} - scroll={{ x: 920, y: 520 }} - locale={{ emptyText: '暂无 kuaishou-feifei 商品映射' }} - className="platform-section-gap" - /> - -
- ) -} - -function KuaishouCloudFulfillmentPanel() { - const [loading, setLoading] = useState(true) - const [skuLoading, setSkuLoading] = useState(false) - const [overrideSaving, setOverrideSaving] = useState(false) - const [errorMessage, setErrorMessage] = useState('') - const [skuErrorMessage, setSkuErrorMessage] = useState('') - const [overrideErrorMessage, setOverrideErrorMessage] = useState('') - const [selectedSourceKey, setSelectedSourceKey] = useState('') - const [sources, setSources] = useState([]) - const [sessions, setSessions] = useState({}) - const [skuItems, setSkuItems] = useState([]) - const [overrideEnabled, setOverrideEnabled] = useState(true) - const [overrideRules, setOverrideRules] = useState([]) - const [matchInput, setMatchInput] = useState('') - - const sourceRows = useMemo( - () => - sources.map((source) => { - const session = sessions[source.key] || null - return { - ...source, - session, - ready: source.enabled !== false && Boolean(session?.hasToken), - } - }), - [sessions, sources], - ) - const readySources = sourceRows.filter((source) => source.ready) - const selectedSource = sourceRows.find((source) => source.key === selectedSourceKey) || null - const matchedSku = useMemo(() => { - const normalizedInput = normalizeMatchName(matchInput) - if (!normalizedInput) { - return null - } - - return ( - skuItems.find((item) => String(item.name || '').trim() === matchInput.trim()) || - skuItems.find((item) => normalizeMatchName(item.name) === normalizedInput) || - null - ) - }, [matchInput, skuItems]) - const metrics = { - sourceCount: sources.length, - readySourceCount: readySources.length, - skuCount: skuItems.length, - inventoryTotal: skuItems.reduce( - (sum, item) => sum + Math.max(0, Number(item.inventory || 0)), - 0, - ), - overrideRuleCount: overrideRules.length, - } - - useEffect(() => { - void loadPage() - }, []) - - useEffect(() => { - if (selectedSourceKey) { - void loadSkuList(selectedSourceKey) - } else { - setSkuItems([]) - } - }, [selectedSourceKey]) - - async function loadPage() { - setLoading(true) - setErrorMessage('') - - try { - const [sourceResponse, overrideResponse] = await Promise.all([ - fetchAdminCloudtentaclesSourceConfig(), - fetchAdminCloudtentaclesOverrideRules(), - ]) - const nextSources = Array.isArray(sourceResponse.data.sources) - ? sourceResponse.data.sources - : [] - const nextSessions = sourceResponse.data.sessions || {} - const nextRows = nextSources.map((source) => { - const session = nextSessions[source.key] || null - return { - ...source, - session, - ready: source.enabled !== false && Boolean(session?.hasToken), - } - }) - - setSources(nextSources) - setSessions(nextSessions) - setOverrideEnabled(overrideResponse.data.enabled !== false) - setOverrideRules( - Array.isArray(overrideResponse.data.rules) - ? overrideResponse.data.rules.map((rule) => normalizeEditableRule(rule)) - : [], - ) - setSelectedSourceKey(resolveDefaultSourceKey(nextRows, selectedSourceKey)) - } catch (error) { - setErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 履约配置失败') - } finally { - setLoading(false) - } - } - - async function loadSkuList(sourceKey = selectedSourceKey) { - if (!sourceKey) { - setSkuItems([]) - return - } - - setSkuLoading(true) - setSkuErrorMessage('') - - try { - const response = await fetchAdminCloudtentaclesSkuList({ sourceKey }) - setSkuItems(Array.isArray(response.data.items) ? response.data.items : []) - } catch (error) { - setSkuItems([]) - setSkuErrorMessage(error instanceof Error ? error.message : '读取 kuaishou-lewan 商品失败') - } finally { - setSkuLoading(false) - } - } - - async function saveOverrideRules() { - setOverrideSaving(true) - setOverrideErrorMessage('') - - try { - const response = await saveAdminCloudtentaclesOverrideRules({ - enabled: overrideEnabled, - rules: overrideRules.map((rule) => ({ - id: rule.id, - enabled: rule.enabled, - productName: rule.productName, - sourceKey: rule.sourceKey, - deliveryItems: rule.deliveryItems, - notes: rule.notes, - })), - }) - setOverrideEnabled(response.data.enabled !== false) - setOverrideRules( - Array.isArray(response.data.rules) - ? response.data.rules.map((rule) => normalizeEditableRule(rule)) - : [], - ) - showSuccess(`商品覆盖规则已保存,共 ${response.data.rules.length} 条`) - } catch (error) { - const message = error instanceof Error ? error.message : '保存 kuaishou-lewan 覆盖规则失败' - setOverrideErrorMessage(message) - showError(message) - } finally { - setOverrideSaving(false) - } - } - - function addOverrideRule() { - setOverrideRules((current) => [ - { - id: createRuleId(), - enabled: true, - productName: matchInput.trim(), - normalizedProductName: normalizeMatchName(matchInput), - sourceKey: selectedSourceKey, - deliveryItems: [createDeliveryItem(skuItems)], - notes: '', - }, - ...current, - ]) - } - - function updateRule(index: number, patch: Partial) { - setOverrideRules((current) => - current.map((rule, ruleIndex) => (ruleIndex === index ? { ...rule, ...patch } : rule)), - ) - } - - function updateDeliveryItem( - ruleIndex: number, - itemIndex: number, - patch: Partial, - ) { - setOverrideRules((current) => - current.map((rule, currentRuleIndex) => { - if (currentRuleIndex !== ruleIndex) { - return rule - } - - return { - ...rule, - deliveryItems: rule.deliveryItems.map((item, currentItemIndex) => - currentItemIndex === itemIndex ? { ...item, ...patch } : item, - ), - } - }), - ) - } - - function removeOverrideRule(index: number) { - setOverrideRules((current) => current.filter((_, ruleIndex) => ruleIndex !== index)) - } - - function addDeliveryItem(ruleIndex: number) { - setOverrideRules((current) => - current.map((rule, currentRuleIndex) => - currentRuleIndex === ruleIndex - ? { ...rule, deliveryItems: [...rule.deliveryItems, createDeliveryItem(skuItems)] } - : rule, - ), - ) - } - - function removeDeliveryItem(ruleIndex: number, itemIndex: number) { - setOverrideRules((current) => - current.map((rule, currentRuleIndex) => { - if (currentRuleIndex !== ruleIndex) { - return rule - } - - const nextItems = rule.deliveryItems.filter( - (_, currentItemIndex) => currentItemIndex !== itemIndex, - ) - return { - ...rule, - deliveryItems: nextItems.length ? nextItems : [createDeliveryItem(skuItems)], - } - }), - ) - } - - function handleDeliverySkuChange(ruleIndex: number, itemIndex: number, skuId: number) { - const sku = skuItems.find((item) => Number(item.id || 0) === Number(skuId || 0)) - updateDeliveryItem(ruleIndex, itemIndex, { - cloudSkuId: Number(skuId || 0) || 0, - cloudSkuName: String(sku?.name || '').trim(), - }) - } - - if (loading) { - return - } - - return ( -
- {errorMessage ? : null} - -
- - - - - - - -
- -
- - {sourceRows.length === 0 ? ( - - ) : ( - - {sourceRows.map((source) => ( - - ))} - - )} - - -
- - - 当前账号:{selectedSource ? formatSourceLabel(selectedSource) : '-'} - - - - } - > - {skuErrorMessage ? ( - - ) : null} - - - - setMatchInput(event.target.value)} - /> - - - {matchedSku ? ( - - 命中 #{matchedSku.id} {matchedSku.name} - - ) : matchInput.trim() ? ( - 未命中 - ) : null} - - - - - - - - - } - > - {overrideErrorMessage ? ( - - ) : null} - - {overrideRules.length === 0 ? ( - - ) : ( - - {overrideRules.map((rule, ruleIndex) => ( - - updateRule(ruleIndex, { enabled })} - /> - {rule.productName || '未命名规则'} - - } - extra={ - - } - > -
- - updateRule(ruleIndex, { productName: event.target.value }) - } - /> - ({ - label: formatSkuOption(sku), - value: sku.id, - }))} - onChange={(skuId) => - handleDeliverySkuChange(ruleIndex, itemIndex, Number(skuId || 0)) - } - /> - - updateDeliveryItem(ruleIndex, itemIndex, { - cloudSkuName: event.target.value, - }) - } - /> - - updateDeliveryItem(ruleIndex, itemIndex, { - quantity: Math.max(1, Number(quantity || 1) || 1), - }) - } - /> - -
- ))} - - - - updateRule(ruleIndex, { notes: event.target.value })} - /> -
- ))} - - )} - - - - - rowKey="id" - loading={skuLoading} - dataSource={skuItems} - columns={[ - { - title: 'kuaishou-lewan 商品', - dataIndex: 'name', - minWidth: 260, - render: (_, row) => ( -
- - {row.name || '-'} - - - #{row.id} · {row.description || row.name || '-'} - -
- ), - }, - { - title: '91 自动匹配名', - dataIndex: 'name', - minWidth: 220, - render: (value) => {String(value || '-')}, - }, - { title: '价格', dataIndex: 'price', width: 120, render: formatCloudPrice }, - { - title: '库存', - dataIndex: 'inventory', - width: 120, - sorter: (a, b) => a.inventory - b.inventory, - }, - { - title: '发货限制', - width: 150, - render: (_, row) => `${row.buyLimitMin || 1} - ${row.buyLimitMax || 1}`, - }, - { - title: '状态', - width: 120, - render: (_, row) => ( - 0 ? 'green' : 'red'}> - {Number(row.inventory || 0) > 0 ? '可履约' : '无库存'} - - ), - }, - ]} - pagination={buildAdminLocalTablePagination()} - scroll={{ x: 1000, y: 420 }} - locale={{ emptyText: '当前账号暂无可展示商品' }} - /> -
-
-
-
- ) -} - function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) { return ( @@ -1263,7 +475,7 @@ function normalizeRoutingPriority(value: unknown) { .map((item) => String(item || '').trim()) .filter((item) => allowed.includes(item) && !seen.has(item) && (seen.add(item), true)) - return items.length > 0 ? items : ['kuaishou_ct_assisted', 'kuaishou_feifei'] + return items.length > 0 ? items : ['affiliate_dash'] } function normalizeRoutingExecutors(value: unknown): AdminFulfillmentRoutingConfig['executors'] { @@ -1293,7 +505,7 @@ function normalizeRoutingRule(rule: AdminFulfillmentRoutingRule): EditableRoutin productName, normalizedProductName: String(rule.normalizedProductName || '').trim(), matchType: rule.matchType === 'contains' ? 'contains' : 'exact', - executorKey: executorKey || 'kuaishou_feifei', + executorKey: executorKey || 'affiliate_dash', priority: Number(rule.priority || 0) || 0, notes: String(rule.notes || '').trim(), } @@ -1309,87 +521,6 @@ function resolveRoutingExecutorColor(executorKey: unknown) { return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.color || 'default' } -function normalizeFeifeiRule( - rule: Partial, -): AdminKuaishouFeifeiProductRule { - const productName = String(rule.productName || '').trim() - const productCode = String(rule.productCode || '').trim() - - return { - id: String(rule.id || productName || productCode).trim(), - enabled: rule.enabled !== false, - productName, - normalizedProductName: String(rule.normalizedProductName || '').trim(), - productCode, - skuName: String(rule.skuName || productName || productCode).trim(), - notes: String(rule.notes || '').trim(), - } -} - -function normalizeEditableRule(rule: AdminCloudtentaclesOverrideRule): EditableOverrideRule { - return { - id: String(rule.id || createRuleId()).trim(), - enabled: rule.enabled !== false, - productName: String(rule.productName || '').trim(), - normalizedProductName: String(rule.normalizedProductName || '').trim(), - sourceKey: String(rule.sourceKey || '').trim(), - deliveryItems: - Array.isArray(rule.deliveryItems) && rule.deliveryItems.length > 0 - ? rule.deliveryItems.map((item) => ({ - cloudSkuId: Number(item.cloudSkuId || 0) || 0, - cloudSkuName: String(item.cloudSkuName || '').trim(), - quantity: Math.max(1, Number(item.quantity || 1) || 1), - })) - : [createDeliveryItem([])], - notes: String(rule.notes || '').trim(), - } -} - -function createDeliveryItem( - skuItems: AdminCloudtentaclesSkuItem[], -): AdminCloudtentaclesOverrideDeliveryItem { - const firstSku = skuItems[0] - return { - cloudSkuId: Number(firstSku?.id || 0) || 0, - cloudSkuName: String(firstSku?.name || '').trim(), - quantity: 1, - } -} - -function resolveDefaultSourceKey(sourceRows: SourceRow[], selectedSourceKey: string) { - const current = sourceRows.find((source) => source.key === selectedSourceKey) - if (current?.ready) { - return current.key - } - - return sourceRows.find((source) => source.ready)?.key || sourceRows[0]?.key || '' -} - -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: Pick, -) { - return source.label || source.username || source.key -} - -function formatCloudPrice(value: unknown) { - const price = Number(value) - return Number.isFinite(price) ? String(price) : '-' -} - -function formatSkuOption(sku: AdminCloudtentaclesSkuItem) { - return `#${sku.id} ${sku.name}` -} - function createRuleId() { return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` } diff --git a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx index ab818535..deed2316 100644 --- a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx +++ b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx @@ -1,5 +1,4 @@ import { - CheckCircleOutlined, CopyOutlined, DeleteOutlined, EditOutlined, @@ -35,41 +34,23 @@ import PageHeader from '@/components/admin/PageHeader' import { PddPlatformPanel } from './PddPlatformPanel' import { showError, showSuccess } from '@/lib/feedback' import { - backAdminCloudtentaclesVn, - deleteAdminCloudtentaclesSource, exchangeAdminKuaishouIndustryAuthorizationCode, - fetchAdminCloudtentaclesAsset, - fetchAdminCloudtentaclesSkuList, - fetchAdminCloudtentaclesSourceConfig, - fetchAdminCloudtentaclesVnList, fetchAdminAffiliateDashConfig, fetchAdminAffiliateDashProducts, fetchAdminAffiliateDashWallet, - fetchAdminKuaishouFeifeiConfig, fetchAdminKuaishouIndustrySourceConfig, fetchAdminNotificationConfig, fetchAdminScheduledJobsConfig, matchAdminAffiliateDashSku, - matchAdminKuaishouFeifeiProduct, refreshAdminKuaishouIndustryAccessToken, runAdminScheduledJob, - saveAdminCloudtentaclesSourceConfig, saveAdminAffiliateDashConfig, - saveAdminKuaishouFeifeiConfig, saveAdminKuaishouIndustrySourceConfig, saveAdminNotificationConfig, saveAdminScheduledJobsConfig, - sendAdminCloudtentaclesSmsCode, - syncAdminKuaishouFeifeiProducts, - testAdminCloudtentaclesLogin, testAdminNotification, - validateAdminCloudtentaclesSession, } from '@/services/admin' import type { - AdminCloudtentaclesMonitorAccount, - AdminCloudtentaclesSourceConfigResponse, - AdminCloudtentaclesSourceItem, - AdminCloudtentaclesVnListItem, AdminAffiliateDashConfig, AdminAffiliateDashConfigResponse, AdminAffiliateDashMatchResult, @@ -78,14 +59,8 @@ import type { AdminKuaishouIndustryConfigResponse, AdminKuaishouIndustryShopConfig, AdminKuaishouIndustrySourceConfig, - AdminKuaishouFeifeiConfig, - AdminKuaishouFeifeiConfigResponse, - AdminKuaishouFeifeiMatchResult, - AdminKuaishouFeifeiProductRule, AdminNotificationConfig, AdminNotificationTestResult, - AdminScheduledJobAccountRuntime, - AdminScheduledJobCloudtentaclesAccount, AdminScheduledJobItem, AdminScheduledJobRuntimeState, AdminScheduledJobsConfig, @@ -97,8 +72,6 @@ import { formatAdminDateTime } from '@/utils/admin-time' type PlatformTab = | 'notifications' | 'kuaishouIndustry' - | 'kuaishouFeifei' - | 'cloudtentacles' | 'affiliateDash' | 'pdd' @@ -111,14 +84,11 @@ type ScheduledJobsState = { filePath: string source: AdminScheduledJobsConfig runtime: AdminScheduledJobRuntimeState[] - cloudtentaclesAccounts: AdminCloudtentaclesMonitorAccount[] } const platformTabs: PlatformTab[] = [ 'notifications', 'kuaishouIndustry', - 'kuaishouFeifei', - 'cloudtentacles', 'affiliateDash', 'pdd', ] @@ -135,9 +105,6 @@ export default function AdminPlatformShopsPage() { const [industryConfig, setIndustryConfig] = useState( null, ) - const [feifeiConfig, setFeifeiConfig] = useState(null) - const [cloudtentaclesConfig, setCloudtentaclesConfig] = - useState(null) const [_affiliateDashConfig, setAffiliateDashConfig] = useState(null) @@ -159,23 +126,17 @@ export default function AdminPlatformShopsPage() { notificationResponse, scheduledJobsResponse, industryResponse, - feifeiResponse, - cloudtentaclesResponse, affiliateDashResponse, ] = await Promise.all([ fetchAdminNotificationConfig(), fetchAdminScheduledJobsConfig(), fetchAdminKuaishouIndustrySourceConfig(), - fetchAdminKuaishouFeifeiConfig(), - fetchAdminCloudtentaclesSourceConfig(), fetchAdminAffiliateDashConfig(), ]) setNotificationConfig(notificationResponse.data) setScheduledJobs(scheduledJobsResponse.data) setIndustryConfig(industryResponse.data) - setFeifeiConfig(feifeiResponse.data) - setCloudtentaclesConfig(cloudtentaclesResponse.data) setAffiliateDashConfig(affiliateDashResponse.data) } catch (error) { setErrorMessage(error instanceof Error ? error.message : '读取平台配置失败') @@ -242,35 +203,11 @@ export default function AdminPlatformShopsPage() { ), }, - { - key: 'kuaishouFeifei', - label: 'kuaishou-feifei', - children: feifeiConfig ? ( - - ) : ( - - ), - }, { key: 'affiliateDash', label: 'affiliate-dash', children: , }, - { - key: 'cloudtentacles', - label: 'kuaishou-lewan', - children: cloudtentaclesConfig ? ( - - ) : ( - - ), - }, { key: 'pdd', label: '拼多多 (PDD)', @@ -303,23 +240,10 @@ function NotificationPanel({ const [runningJobId, setRunningJobId] = useState('') const [testResult, setTestResult] = useState(null) const source = notificationConfig.source - const monitorAccounts = scheduledJobs.cloudtentaclesAccounts || [] - const jobsWithMergedAccounts = scheduledJobs.source.jobs.map((job) => { - if (job.type !== 'cloudtentacles_health') { - return job - } - return { - ...job, - config: { - ...job.config, - accounts: mergeScheduledJobAccounts( - job.config?.accounts || [], - monitorAccounts, - Number(job.config?.assetThreshold ?? 500), - ), - }, - } - }) + // cloudtentacles_health(原 kuaishou-lewan)已下线,过滤残留配置 + const visibleJobs = scheduledJobs.source.jobs.filter( + (job) => job.type !== 'cloudtentacles_health', + ) async function saveNotification() { setSavingNotification(true) @@ -355,23 +279,7 @@ function NotificationPanel({ try { const payload: AdminScheduledJobsConfig = { ...scheduledJobs.source, - jobs: jobsWithMergedAccounts.map((job) => { - if (job.type !== 'cloudtentacles_health') { - return { ...job } - } - return { - ...job, - config: { - assetThreshold: Number(job.config?.assetThreshold ?? 500), - accounts: (job.config?.accounts || []).map((account) => ({ - sourceKey: account.sourceKey, - label: account.label, - enabled: account.enabled !== false, - assetThreshold: Number(account.assetThreshold ?? job.config?.assetThreshold ?? 500), - })), - }, - } - }), + jobs: visibleJobs.map((job) => ({ ...job })), } const response = await saveAdminScheduledJobsConfig(payload) onScheduledJobsChange(response.data) @@ -410,7 +318,7 @@ function NotificationPanel({ function updateJob(index: number, nextJob: AdminScheduledJobItem) { updateJobs({ ...scheduledJobs.source, - jobs: jobsWithMergedAccounts.map((item, jobIndex) => (jobIndex === index ? nextJob : item)), + jobs: visibleJobs.map((item, jobIndex) => (jobIndex === index ? nextJob : item)), }) } @@ -538,7 +446,7 @@ function NotificationPanel({ } > - 按 kuaishou-lewan 账号分别检查登录态和余额;新增账号会自动纳入监控,可单独设置阈值。 + 管理定时扫描类任务(如接单工单超时)。 - {jobsWithMergedAccounts.map((job, index) => ( + {visibleJobs.map((job, index) => ( item.id === job.id) || null} - monitorAccounts={monitorAccounts} running={runningJobId === job.id} onRun={() => runJob(job.id)} onChange={(nextJob) => updateJob(index, nextJob)} @@ -661,18 +568,21 @@ function RecipientList function ScheduledJobCard({ job, runtime, - monitorAccounts, running, onRun, onChange, }: { job: AdminScheduledJobItem runtime: AdminScheduledJobRuntimeState | null - monitorAccounts: AdminCloudtentaclesMonitorAccount[] running: boolean onRun: () => void onChange: (job: AdminScheduledJobItem) => void }) { + if (job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze') { + // 押金到期解冻配置已迁至 接单平台 → 杂项配置,这里不再渲染。 + return null + } + if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') { return ( , - ) { - onChange({ - ...job, - config: { - ...job.config, - accounts: accounts.map((account) => - account.sourceKey === sourceKey ? { ...account, ...patch } : account, - ), - }, - }) - } - - function updateDefaultThreshold(assetThreshold: number) { - onChange({ - ...job, - config: { - ...job.config, - assetThreshold, - accounts: accounts.map((account) => ({ - ...account, - // 仅同步仍等于旧默认阈值的账号,避免覆盖用户单独设置 - assetThreshold: - Number(account.assetThreshold) === defaultThreshold - ? assetThreshold - : account.assetThreshold, - })), - }, - }) - } - - const accountColumns: TableColumnsType = [ - { - title: '账号', - minWidth: 220, - render: (_, account) => { - const source = monitorAccounts.find((item) => item.sourceKey === account.sourceKey) - return ( - - {account.label || account.sourceKey} - {account.sourceKey} - - {formatMonitorAccountMeta(account, source)} - - - ) - }, - }, - { - title: '监控', - width: 90, - align: 'center', - render: (_, account) => ( - updateAccount(account.sourceKey, { enabled })} - /> - ), - }, - { - title: '余额阈值', - width: 140, - render: (_, account) => ( - - updateAccount(account.sourceKey, { - assetThreshold: Number(value ?? defaultThreshold), - }) - } - /> - ), - }, - { - title: '最近结果', - minWidth: 240, - render: (_, account) => { - const accountRuntime = getAccountRuntime(runtime, account.sourceKey) - if (!accountRuntime) { - return 尚未检查 - } - - return ( - - - - {formatHealthStatus(accountRuntime.status)} - - - 余额 {accountRuntime.asset ?? '-'} / 阈值{' '} - {accountRuntime.threshold ?? account.assetThreshold} - - - - {accountRuntime.message || formatHealthStatus(accountRuntime.status)} - - - {formatAdminDateTime(accountRuntime.checkedAt)} - - - ) - }, - }, - ] - - return ( - - onChange({ ...job, enabled })} - /> - {formatScheduledJobTitle(job)} - {job.enabled ? '已启用' : '已停用'} - - } - extra={ - - } - > -
- onChange({ ...job, intervalSeconds })} - /> - onChange({ ...job, cooldownSeconds })} - /> - updateDefaultThreshold(Number(assetThreshold ?? 500))} - /> -
- - {runtime ? ( - - ) : null} - - - className="platform-section-gap" - rowKey="sourceKey" - size="small" - pagination={false} - columns={accountColumns} - dataSource={accounts} - locale={{ emptyText: '暂无 kuaishou-lewan 账号,请先在平台配置登录。' }} - /> -
- ) -} - -function mergeScheduledJobAccounts( - configuredAccounts: AdminScheduledJobCloudtentaclesAccount[], - monitorAccounts: AdminCloudtentaclesMonitorAccount[], - defaultAssetThreshold: number, -) { - const configuredMap = new Map( - configuredAccounts - .map((item) => [String(item.sourceKey || '').trim(), item] as const) - .filter(([sourceKey]) => Boolean(sourceKey)), - ) - const merged = monitorAccounts - .filter((item) => String(item.sourceKey || '').trim()) - .map((item) => { - const sourceKey = String(item.sourceKey || '').trim() - const configured = configuredMap.get(sourceKey) - return { - sourceKey, - label: String(configured?.label || item.label || sourceKey).trim(), - enabled: configured ? configured.enabled !== false : item.enabled !== false, - assetThreshold: Number(configured?.assetThreshold ?? defaultAssetThreshold), - } - }) - const mergedKeys = new Set(merged.map((item) => item.sourceKey)) - - for (const configured of configuredAccounts) { - const sourceKey = String(configured.sourceKey || '').trim() - if (!sourceKey || mergedKeys.has(sourceKey)) { - continue - } - merged.push({ - sourceKey, - label: String(configured.label || sourceKey).trim(), - enabled: configured.enabled !== false, - assetThreshold: Number(configured.assetThreshold ?? defaultAssetThreshold), - }) - } - - return merged + // 其他未知/已下线任务类型不渲染(如 cloudtentacles_health) + return null } function formatScheduledJobTitle(job: AdminScheduledJobItem) { - if (job.type === 'cloudtentacles_health' || job.id === 'cloudtentacles-health') { - return 'kuaishou-lewan 健康检查' - } if (job.type === 'work_order_timeout' || job.id === 'work-order-timeout') { return '接单工单超时扫描' } return job.id || job.type || '定时任务' } -function formatMonitorAccountMeta( - account: AdminScheduledJobCloudtentaclesAccount, - source: AdminCloudtentaclesMonitorAccount | undefined, -) { - if (!source) { - return '账号配置不存在' - } - - return ( - [ - source.username ? `登录名 ${source.username}` : '', - source.phoneMasked ? `手机 ${source.phoneMasked}` : '', - source.hasToken ? 'Token 已保存' : '未登录', - ] - .filter(Boolean) - .join(' · ') || account.sourceKey - ) -} - -function getAccountRuntime( - runtime: AdminScheduledJobRuntimeState | null, - sourceKey: string, -): AdminScheduledJobAccountRuntime | null { - if (!runtime?.lastAccounts?.length) { - return null - } - return runtime.lastAccounts.find((item) => item.sourceKey === sourceKey) || null -} - -function formatHealthStatus(status: string) { - const normalized = String(status || '').trim() - if (normalized === 'ok' || normalized === 'success') return '正常' - if (normalized === 'asset_low') return '余额预警' - if (normalized === 'auth_missing') return '未登录' - if (normalized === 'source_missing') return '账号缺失' - if (normalized === 'disabled') return '已停用' - if (normalized === 'skipped') return '已跳过' - if (normalized === 'running') return '执行中' - if (normalized === 'failed') return '检查失败' - if (normalized === 'pending') return '待执行' - return normalized || '待执行' -} - -function resolveHealthStatusColor(status: string) { - const normalized = String(status || '').trim() - if (normalized === 'ok' || normalized === 'success') return 'green' - if (normalized === 'asset_low' || normalized === 'running') return 'orange' - if (['auth_missing', 'source_missing', 'failed'].includes(normalized)) return 'red' - return 'default' -} - function SecretFieldControl({ label, field, @@ -1727,797 +1355,6 @@ function KuaishouIndustryPanel({ ) } -function KuaishouFeifeiPlatformPanel({ - config, - onChange, -}: { - config: AdminKuaishouFeifeiConfigResponse - onChange: (config: AdminKuaishouFeifeiConfigResponse) => void -}) { - const [saving, setSaving] = useState(false) - const [syncing, setSyncing] = useState(false) - const [matching, setMatching] = useState(false) - const [matchInput, setMatchInput] = useState('') - const [matchResult, setMatchResult] = useState(null) - const source = config.source - const enabledRules = source.productRules.filter((rule) => rule.enabled !== false) - - async function saveConfig() { - setSaving(true) - try { - const response = await saveAdminKuaishouFeifeiConfig(source) - onChange(response.data) - showSuccess('kuaishou-feifei 平台配置已保存') - } catch (error) { - showError(error instanceof Error ? error.message : '保存 kuaishou-feifei 配置失败') - } finally { - setSaving(false) - } - } - - async function syncProducts() { - setSyncing(true) - try { - const response = await syncAdminKuaishouFeifeiProducts({ status: 'on_sale' }) - onChange(response.data) - showSuccess( - `商品映射已同步:商品 ${response.data.sync.productCount} 个,规则 ${response.data.sync.ruleCount} 条`, - ) - } catch (error) { - showError(error instanceof Error ? error.message : '同步 kuaishou-feifei 商品映射失败') - } finally { - setSyncing(false) - } - } - - async function previewMatch() { - if (!matchInput.trim()) { - showError('请输入 91 商品名') - return - } - - setMatching(true) - try { - const response = await matchAdminKuaishouFeifeiProduct(matchInput.trim()) - setMatchResult(response.data) - } catch (error) { - showError(error instanceof Error ? error.message : '预览映射失败') - } finally { - setMatching(false) - } - } - - function updateSource(patch: Partial) { - onChange({ ...config, source: { ...source, ...patch } }) - } - - function updateRule(index: number, patch: Partial) { - updateSource({ - productRules: source.productRules.map((rule, ruleIndex) => - ruleIndex === index ? { ...rule, ...patch } : rule, - ), - }) - } - - return ( -
-
- - - -
- - - - - - } - > -
- updateSource({ enabled })} - /> -
- 接口地址 - updateSource({ baseUrl: event.target.value })} - /> -
-
- App Key - updateSource({ appKey: event.target.value })} - /> -
- updateSource({ appSecret })} - /> - updateSource({ timeoutMs })} - /> -
- 通知地址 - updateSource({ notifyUrl: event.target.value })} - /> -
-
-
- - - - setMatchInput(event.target.value)} - onPressEnter={previewMatch} - /> - - - {matchResult ? ( - ${matchResult.match.productCode}` - : matchResult.normalizedProductName - } - /> - ) : null} - - - className="platform-section-gap" - rowKey={(row) => row.id || row.productCode || row.productName} - dataSource={source.productRules} - pagination={buildAdminLocalTablePagination()} - scroll={{ x: 980 }} - columns={[ - { - title: '启用', - width: 80, - render: (_, row, index) => ( - updateRule(index, { enabled })} - /> - ), - }, - { - title: '商品名', - minWidth: 260, - render: (_, row, index) => ( - updateRule(index, { productName: event.target.value })} - /> - ), - }, - { - title: 'product_code', - minWidth: 160, - render: (_, row, index) => ( - updateRule(index, { productCode: event.target.value })} - /> - ), - }, - { - title: '展示名', - minWidth: 220, - render: (_, row, index) => ( - updateRule(index, { skuName: event.target.value })} - /> - ), - }, - { - title: '备注', - minWidth: 180, - render: (_, row, index) => ( - updateRule(index, { notes: event.target.value })} - /> - ), - }, - ]} - /> - -
- ) -} - -function CloudtentaclesPlatformPanel({ - config, - onChange, -}: { - config: AdminCloudtentaclesSourceConfigResponse - onChange: (config: AdminCloudtentaclesSourceConfigResponse) => void -}) { - const [saving, setSaving] = useState(false) - const [debugLoading, setDebugLoading] = useState('') - const [selectedSourceKey, setSelectedSourceKey] = useState(config.sources[0]?.key || '') - const [smsCode, setSmsCode] = useState('') - const [debugResult, setDebugResult] = useState(null) - const [vnItems, setVnItems] = useState(null) - const [vnLoading, setVnLoading] = useState(false) - const [vnBackingId, setVnBackingId] = useState(null) - - useEffect(() => { - if (!config.sources.some((source) => source.key === selectedSourceKey)) { - setSelectedSourceKey(config.sources[0]?.key || '') - } - }, [config.sources, selectedSourceKey]) - - const selectedSource = config.sources.find((source) => source.key === selectedSourceKey) || null - const selectedSession = selectedSource ? config.sessions[selectedSource.key] : null - - async function saveConfig() { - setSaving(true) - try { - const response = await saveAdminCloudtentaclesSourceConfig({ - enabled: config.enabled, - sources: config.sources, - }) - onChange(response.data) - const nextKey = - selectedSourceKey && response.data.sources.some((item) => item.key === selectedSourceKey) - ? selectedSourceKey - : response.data.sources[0]?.key || '' - setSelectedSourceKey(nextKey) - showSuccess('kuaishou-lewan 账号配置已保存') - } catch (error) { - showError(error instanceof Error ? error.message : '保存 kuaishou-lewan 账号失败') - } finally { - setSaving(false) - } - } - - async function deleteSource(sourceKey: string) { - setDebugLoading(`delete-${sourceKey}`) - try { - await deleteAdminCloudtentaclesSource(sourceKey) - const response = await fetchAdminCloudtentaclesSourceConfig() - onChange(response.data) - const nextKey = - selectedSourceKey === sourceKey ? response.data.sources[0]?.key || '' : selectedSourceKey - setSelectedSourceKey( - response.data.sources.some((item) => item.key === nextKey) - ? nextKey - : response.data.sources[0]?.key || '', - ) - showSuccess('kuaishou-lewan 账号已删除') - } catch (error) { - showError(error instanceof Error ? error.message : '删除 kuaishou-lewan 账号失败') - } finally { - setDebugLoading('') - } - } - - async function runDebug(action: string, handler: () => Promise) { - if (!selectedSource) { - showError('请先选择一个账号') - return - } - setDebugLoading(action) - setDebugResult(null) - try { - const result = await handler() - setDebugResult(result) - showSuccess(`账号 ${formatCloudtentaclesSourceLabel(selectedSource)} 调试完成`) - } catch (error) { - showError(error instanceof Error ? error.message : 'kuaishou-lewan 调试失败') - } finally { - setDebugLoading('') - } - } - - function updateConfig(next: Partial) { - onChange({ ...config, ...next }) - } - - async function loadVnList() { - if (!selectedSource) { - showError('请先选择一个账号') - return - } - setVnLoading(true) - try { - const response = await fetchAdminCloudtentaclesVnList({ - sourceKey: selectedSource.key, - key: '1', - }) - setVnItems(response.data.items || []) - } catch (error) { - showError(error instanceof Error ? error.message : '查询虚拟号列表失败') - } finally { - setVnLoading(false) - } - } - - async function backVn(item: AdminCloudtentaclesVnListItem) { - if (!selectedSource) { - return - } - setVnBackingId(item.id) - try { - await backAdminCloudtentaclesVn({ - sourceKey: selectedSource.key, - key: '1', - id: item.id, - }) - showSuccess(`号码 ${item.phone} 已退回`) - await loadVnList() - } catch (error) { - showError(error instanceof Error ? error.message : '退回号码失败') - } finally { - setVnBackingId(null) - } - } - - function updateSource(sourceKey: string, patch: Partial) { - updateConfig({ - sources: config.sources.map((source) => - source.key === sourceKey ? { ...source, ...patch } : source, - ), - }) - if (patch.key && patch.key !== sourceKey && selectedSourceKey === sourceKey) { - setSelectedSourceKey(String(patch.key)) - } - } - - function addSource() { - const key = `account_${Date.now().toString(36)}` - updateConfig({ - sources: [ - ...config.sources, - { - key, - label: '新履约账号', - enabled: true, - baseUrl: 'https://123.207.217.176', - username: '', - password: '', - phone: '', - deviceId: '1', - deviceType: 2, - }, - ], - }) - setSelectedSourceKey(key) - } - - return ( -
- - updateConfig({ enabled })} - /> - - - - } - > - {config.sources.length === 0 ? ( - - ) : ( -
- - - {config.sources.map((source) => { - const session = config.sessions[source.key] - const active = source.key === selectedSourceKey - return ( - - ) - })} - - - - } - loading={debugLoading === `delete-${selectedSource.key}`} - onClick={() => deleteSource(selectedSource.key)} - > - 删除 - - ) : null - } - > - {!selectedSource ? ( - - ) : ( - <> -
- updateSource(selectedSource.key, { enabled })} - /> - updateSource(selectedSource.key, { key: value })} - /> - updateSource(selectedSource.key, { label: value })} - /> - updateSource(selectedSource.key, { baseUrl: value })} - /> - updateSource(selectedSource.key, { username: value })} - /> - updateSource(selectedSource.key, { password: value })} - /> - updateSource(selectedSource.key, { phone: value })} - /> - updateSource(selectedSource.key, { deviceId: value })} - /> - updateSource(selectedSource.key, { deviceType })} - /> -
- - - )} -
-
- )} -
- - - {!selectedSource ? ( - - ) : ( - <> - - - setSmsCode(event.target.value)} - /> - - - - - - - - {debugResult ? ( -
{JSON.stringify(debugResult, null, 2)}
- ) : null} - {vnItems !== null ? ( - - className="platform-section-gap" - size="small" - rowKey="id" - loading={vnLoading} - dataSource={vnItems} - pagination={{ pageSize: 10, showSizeChanger: false }} - columns={[ - { - title: '手机号', - dataIndex: 'phone', - render: (phone: string) => phone || '-', - }, - { - title: '绑定角色', - dataIndex: 'bindInfo', - render: (bindInfo: AdminCloudtentaclesVnListItem['bindInfo']) => { - const role = resolveVnBindRole(bindInfo) - return role ? ( - - {role.name || '-'} - - {role.rid || ''} - - - ) : ( - 未绑定 - ) - }, - }, - { - title: '回收倒计时', - dataIndex: 'countTime', - render: (countTime: number) => ( - - {formatCountdownSeconds(Number(countTime))} - - ), - }, - { - title: '状态', - dataIndex: 'status', - render: (status: number) => - Number(status) === 0 ? 空闲 : 占用中, - }, - { - title: '操作', - width: 120, - render: (_, item) => ( - void backVn(item)} - > - - - ), - }, - ]} - /> - ) : null} - - )} -
-
- ) -} - -function formatCloudtentaclesSourceLabel( - source: Pick, -) { - return String(source.label || source.username || source.key || '').trim() || source.key -} - -function resolveVnBindRole(bindInfo: AdminCloudtentaclesVnListItem['bindInfo']) { - if (typeof bindInfo === 'object' && bindInfo !== null) { - return { - name: String((bindInfo as Record).name || '').trim(), - rid: String((bindInfo as Record).rid || '').trim(), - } - } - if (typeof bindInfo === 'string' && bindInfo.trim()) { - try { - const parsed = JSON.parse(bindInfo) as Record - if (parsed && typeof parsed === 'object') { - return { - name: String(parsed.name || '').trim(), - rid: String(parsed.rid || '').trim(), - } - } - } catch { - return null - } - } - return null -} - function formatCountdownSeconds(totalSeconds: number) { const seconds = Math.max(0, Number(totalSeconds) || 0) const minutes = Math.floor(seconds / 60) diff --git a/apps/frontend/src/pages/claim/ClaimFeifeiSteps.tsx b/apps/frontend/src/pages/claim/ClaimFeifeiSteps.tsx deleted file mode 100644 index fbb6f722..00000000 --- a/apps/frontend/src/pages/claim/ClaimFeifeiSteps.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { CheckCircleOutlined, ExclamationCircleOutlined, LinkOutlined } from '@ant-design/icons' -import { Alert, Button, Card, Typography } from 'antd' -import type { ClaimKuaishouFeifeiFlowInfo, ClaimOrderInfo, ClaimProductInfo } from '@/types/claim' -import type { ClaimSnapshot } from './claim-snapshot' -import { InfoTile, UidEditRow } from './claim-shared' - -export function FeifeiClaimPanel({ - feifei, - expectedUid, - order, - product, - taskLastError, - onOpen, - uidInput, - submittingUid, - onUidChange, - onResubmitUid, -}: { - feifei: ClaimKuaishouFeifeiFlowInfo - expectedUid: string - order: ClaimOrderInfo | null - product: ClaimProductInfo | null - taskLastError?: string - onOpen: () => void - uidInput: string - submittingUid: boolean - onUidChange: (value: string) => void - onResubmitUid: () => void -}) { - const openReady = Boolean(feifei.h5UrlWithUid || feifei.h5.rechargeUrl || feifei.h5.entryUrl) - - return ( - -
- kuaishou-feifei - 第 2 步:打开领取链接 -

{feifei.rechargeStatusLabel || '待领取'}

-
- - - -
- - - - -
- - - - - - {taskLastError ? ( - - ) : null} -
- ) -} - -export function FeifeiResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { - return ( - -
- {snapshot.resultVariant === 'warning' ? ( - - ) : ( - - )} -
- {snapshot.resultTitle} -

{snapshot.resultDescription}

-
-
- -
- - - - -
-
- ) -} diff --git a/apps/frontend/src/pages/claim/ClaimLewanSteps.tsx b/apps/frontend/src/pages/claim/ClaimLewanSteps.tsx deleted file mode 100644 index 0374eaaa..00000000 --- a/apps/frontend/src/pages/claim/ClaimLewanSteps.tsx +++ /dev/null @@ -1,343 +0,0 @@ -import { - CheckCircleOutlined, - ExclamationCircleOutlined, - LinkOutlined, - LoadingOutlined, - ReloadOutlined, - SwapOutlined, -} from '@ant-design/icons' -import { Alert, Button, Card, Space, Tooltip, Typography } from 'antd' -import type { ClaimProductInfo } from '@/types/claim' -import { formatAdminDateTime } from '@/utils/admin-time' -import type { ClaimSnapshot } from './claim-snapshot' -import { ClaimProductItems, InfoRow, InfoTile, UidEditRow } from './claim-shared' - -export function KuaishouCloudClaimSteps({ - snapshot, - qrCodeDataUrl, - refreshingRole, - confirmingRole, - rebindingRole, - redeeming, - onOpenBindUrl, - onRefreshRole, - onRebindRole, - onConfirmRole, - onConfirmRedeem, - uidInput, - submittingUid, - onUidChange, - onResubmitUid, -}: { - snapshot: ClaimSnapshot - qrCodeDataUrl: string - refreshingRole: boolean - confirmingRole: boolean - rebindingRole: boolean - redeeming: boolean - onOpenBindUrl: (useCurrentPage?: boolean) => void - onRefreshRole: () => void - onRebindRole: () => void - onConfirmRole: () => void - onConfirmRedeem: () => void - uidInput: string - submittingUid: boolean - onUidChange: (value: string) => void - onResubmitUid: () => void -}) { - if (!snapshot.flow) { - return null - } - - if (snapshot.currentStep === 3) { - return ( - - ) - } - - return ( - - ) -} - -function ClaimBindingStep({ - snapshot, - qrCodeDataUrl, - refreshingRole, - confirmingRole, - rebindingRole, - redeeming, - onOpenBindUrl, - onRefreshRole, - onRebindRole, - onConfirmRole, - onConfirmRedeem, - uidInput, - submittingUid, - onUidChange, - onResubmitUid, -}: { - snapshot: ClaimSnapshot - qrCodeDataUrl: string - refreshingRole: boolean - confirmingRole: boolean - rebindingRole: boolean - redeeming: boolean - onOpenBindUrl: (useCurrentPage?: boolean) => void - onRefreshRole: () => void - onRebindRole: () => void - onConfirmRole: () => void - onConfirmRedeem: () => void - uidInput: string - submittingUid: boolean - onUidChange: (value: string) => void - onResubmitUid: () => void -}) { - const flow = snapshot.flow - if (!flow) { - return null - } - - return ( - - 第 2 步:绑定角色 -

- 请使用与 UID {snapshot.expectedUid || '-'} 对应的角色扫码绑定。 -

- - - - {snapshot.isBindingPrepared && qrCodeDataUrl ? ( -
-
-
Q区用Q扫
- 绑定二维码 -
V区用V扫
-

长按保存相册进行扫码绑定

-
- - -
- ) : ( - } - message="系统正在准备绑定资源,请稍后自动刷新。" - /> - )} - -
- - - - -
- - {!snapshot.isUidMatched && snapshot.roleId ? ( - - ) : null} - - - - - - - - - {snapshot.isUidMatched ? ( - - ) : ( - - - - - - )} - - -
- 最近刷新:{formatAdminDateTime(flow.role.refreshedAt)} - 链接有效期:{formatAdminDateTime(flow.binding.bindExpiresAt)} -
-
- ) -} - -function ClaimConfirmStep({ - roleName, - roleId, - expectedUid, - product, - redeeming, - rebindingRole, - onConfirmRedeem, - onRebindRole, -}: { - roleName: string - roleId: string - expectedUid: string - product: ClaimProductInfo | null - redeeming: boolean - rebindingRole: boolean - onConfirmRedeem: () => void - onRebindRole: () => void -}) { - return ( - - 第 3 步:确认兑换信息 -

请再次确认 UID 和商品信息,确认无误后再继续兑换。

- -
- - - - - -
- - - - - - - - - - - - - -
- ) -} - -export function ClaimResultStep({ snapshot }: { snapshot: ClaimSnapshot }) { - const flow = snapshot.flow - - return ( - -
- {snapshot.resultVariant === 'warning' ? ( - - ) : ( - - )} -
- {snapshot.resultTitle} -

{snapshot.resultDescription}

-
-
- -
- - - - - - -
- - - - -
- ) -} diff --git a/apps/frontend/src/pages/claim/ClaimPage.tsx b/apps/frontend/src/pages/claim/ClaimPage.tsx index 8bc6893d..1df74acd 100644 --- a/apps/frontend/src/pages/claim/ClaimPage.tsx +++ b/apps/frontend/src/pages/claim/ClaimPage.tsx @@ -4,21 +4,16 @@ import QRCode from 'qrcode' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useParams } from 'react-router' -import { isFeedbackDismissed, showConfirm, showError, showSuccess } from '@/lib/feedback' +import { showError, showSuccess } from '@/lib/feedback' import { - confirmKuaishouCloudClaimRole, fetchClaimDetail, - rebindKuaishouCloudClaimRole, - redeemKuaishouCloudClaim, submitAffiliateDashClaim, submitClaimUid, } from '@/services/claim' import type { ClaimDetailData } from '@/types/claim' import { AffiliateDashClaimPanel, AffiliateDashResultStep } from './ClaimAffiliateDashSteps' -import { FeifeiClaimPanel, FeifeiResultStep } from './ClaimFeifeiSteps' import { ClaimHeaderCard } from './ClaimHeaderCard' -import { ClaimResultStep, KuaishouCloudClaimSteps } from './ClaimLewanSteps' import { ClaimUidStep } from './ClaimUidStep' import { resolveNextPollDelayMs, resolveRolePollDelayMs } from './claim-poll' import { createClaimSnapshot, type ClaimSnapshot } from './claim-snapshot' @@ -30,10 +25,6 @@ export default function ClaimPage() { const [loading, setLoading] = useState(true) const [submittingUid, setSubmittingUid] = useState(false) const [uidInput, setUidInput] = useState('') - const [refreshingRole, setRefreshingRole] = useState(false) - const [confirmingRole, setConfirmingRole] = useState(false) - const [rebindingRole, setRebindingRole] = useState(false) - const [redeeming, setRedeeming] = useState(false) const [submittingSubmit, setSubmittingSubmit] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [detail, setDetail] = useState(null) @@ -42,10 +33,7 @@ export default function ClaimPage() { const pollTimerRef = useRef(0) const rolePollBaselineRef = useRef({ roleKey: '', at: 0 }) - const snapshot = useMemo( - () => createClaimSnapshot(detail, { rebindingRole }), - [detail, rebindingRole], - ) + const snapshot = useMemo(() => createClaimSnapshot(detail), [detail]) const stopPolling = useCallback(() => { if (pollTimerRef.current) { @@ -83,13 +71,7 @@ export default function ClaimPage() { if (expectedUid) { setUidInput(expectedUid) } - await generateQRCode( - String( - nextDetail.kuaishouCloudFulfillment?.binding.bindUrl || - nextDetail.affiliateDash?.bindUrl || - '', - ).trim(), - ) + await generateQRCode(String(nextDetail.affiliateDash?.bindUrl || '').trim()) }, [generateQRCode], ) @@ -145,6 +127,9 @@ export default function ClaimPage() { useEffect(() => { stopPolling() + if (snapshot.channelOffline) { + return undefined + } const delay = resolveNextPollDelay(snapshot) if (!delay) { return undefined @@ -161,7 +146,6 @@ export default function ClaimPage() { }, [loadDetail, resolveNextPollDelay, snapshot, stopPolling]) async function handleSubmitUid() { - // 防并发:提交中忽略重复触发(按钮 loading + 回车 Enter 都可能连续触发) if (submittingUid) { return } @@ -219,139 +203,6 @@ export default function ClaimPage() { } } - async function refreshRole() { - setRefreshingRole(true) - try { - const nextDetail = await loadDetail({ silent: true }) - const nextSnapshot = createClaimSnapshot(nextDetail, { rebindingRole }) - - if (nextSnapshot.isUidMatched) { - showSuccess('角色信息已匹配,可确认兑换') - } else if (nextSnapshot.roleId) { - showError( - `当前绑定角色 ID(${nextSnapshot.roleId})与填写 UID(${nextSnapshot.expectedUid || '-'})不一致`, - ) - } else if (nextDetail) { - showError( - nextSnapshot.flow?.role.errorMessage || - '暂时还没有识别到角色信息,请完成绑定后稍等片刻再试', - ) - } - } finally { - setRefreshingRole(false) - } - } - - async function confirmRole() { - setConfirmingRole(true) - try { - const response = await confirmKuaishouCloudClaimRole(token) - await applyDetail(response.data) - showSuccess('角色已确认,进入下一步') - } catch (error) { - showError(error instanceof Error ? error.message : '确认角色失败') - } finally { - setConfirmingRole(false) - } - } - - async function confirmRedeem() { - try { - await showConfirm('兑换后不可取消,也不可退货。请确认 UID 与商品信息完全正确。', '确认兑换', { - okText: '确认兑换', - okButtonProps: { danger: true }, - }) - } catch (error) { - if (!isFeedbackDismissed(error)) { - showError('确认弹窗已关闭') - } - return - } - - setRedeeming(true) - try { - const response = await redeemKuaishouCloudClaim(token) - await applyDetail(response.data) - showSuccess('兑换请求已提交') - } catch (error) { - showError(error instanceof Error ? error.message : '兑换失败') - } finally { - setRedeeming(false) - } - } - - async function rebindRole() { - try { - await showConfirm( - '换绑后当前绑定链接会失效,系统会退还当前虚拟号并生成新的绑定二维码。请使用你填写的 UID 对应角色重新绑定。', - '确认换绑角色', - { - okText: '确认换绑', - okButtonProps: { danger: true }, - }, - ) - } catch (error) { - if (!isFeedbackDismissed(error)) { - showError('确认弹窗已关闭') - } - return - } - - setRebindingRole(true) - try { - const response = await rebindKuaishouCloudClaimRole(token) - await applyDetail(response.data) - showSuccess('新的绑定二维码已生成,请重新绑定角色') - } catch (error) { - showError(error instanceof Error ? error.message : '换绑角色失败') - } finally { - setRebindingRole(false) - } - } - - function openBindUrl(useCurrentPage = false) { - const bindUrl = String( - snapshot.flow?.binding.bindUrl || snapshot.affiliateDash?.bindUrl || '', - ).trim() - if (!bindUrl) { - showError('绑定链接还没准备好,请稍后刷新') - return - } - - if (useCurrentPage) { - window.location.assign(bindUrl) - return - } - - window.open(bindUrl, '_blank', 'noopener,noreferrer') - } - - function openFeifeiUrl(useCurrentPage = false) { - const url = String( - snapshot.feifei?.h5UrlWithUid || - snapshot.feifei?.h5.rechargeUrl || - snapshot.feifei?.h5.entryUrl || - '', - ).trim() - - if (!snapshot.hasExpectedUid) { - showError('请先填写游戏 UID') - return - } - - if (!url) { - showError('领取链接还没准备好,请稍后刷新') - return - } - - if (useCurrentPage) { - window.location.assign(url) - return - } - - window.open(url, '_blank', 'noopener,noreferrer') - } - return (
+ ) : snapshot.channelOffline ? ( + + +

{snapshot.channelOfflineMessage || '该履约渠道已下线,无法继续领取'}

+
) : snapshot.currentStep === 1 ? ( void handleSubmitUid()} /> - ) : snapshot.hasRedeemResult ? ( - snapshot.isFeifeiFlow ? ( - - ) : snapshot.isAffiliateDashFlow ? ( - - ) : ( - - ) + ) : snapshot.hasRedeemResult && snapshot.isAffiliateDashFlow ? ( + ) : snapshot.isAffiliateDashFlow && snapshot.affiliateDash ? ( void handleSubmitUid()} onSubmit={() => void handleSubmitAffiliateDash()} /> - ) : snapshot.isFeifeiFlow && snapshot.feifei ? ( - openFeifeiUrl(false)} - uidInput={uidInput} - submittingUid={submittingUid} - onUidChange={setUidInput} - onResubmitUid={() => void handleSubmitUid()} - /> - ) : snapshot.flow ? ( - void handleSubmitUid()} - /> ) : ( - + )}
diff --git a/apps/frontend/src/pages/claim/claim-snapshot.ts b/apps/frontend/src/pages/claim/claim-snapshot.ts index 6840145e..b55fcb59 100644 --- a/apps/frontend/src/pages/claim/claim-snapshot.ts +++ b/apps/frontend/src/pages/claim/claim-snapshot.ts @@ -2,25 +2,14 @@ import { TASK_STATUS, hasKuaishouCloudRedeemResultStatus, isKuaishouCloudCompletedStatus, - isKuaishouCloudRoleConfirmedStatus, normalizeTaskStatus, } from '@/domain/task-status' -import type { - ClaimAffiliateDashFlowInfo, - ClaimDetailData, - ClaimKuaishouCloudFlowInfo, - ClaimKuaishouFeifeiFlowInfo, -} from '@/types/claim' +import type { ClaimAffiliateDashFlowInfo, ClaimDetailData } from '@/types/claim' export type ResultVariant = 'success' | 'warning' | 'info' export type ClaimSnapshot = ReturnType -export function createClaimSnapshot( - detail: ClaimDetailData | null, - options: { rebindingRole?: boolean } = {}, -) { - const flow = detail?.kuaishouCloudFulfillment || null - const feifei = detail?.kuaishouFeifei || null +export function createClaimSnapshot(detail: ClaimDetailData | null) { const affiliateDash = detail?.affiliateDash || null const order = detail?.order || null const orderItem = detail?.orderItem || null @@ -30,9 +19,8 @@ export function createClaimSnapshot( const expectedUid = String(claimIdentity?.expectedUid || '').trim() const hasExpectedUid = Boolean(expectedUid || claimIdentity?.ready) - const roleName = flow?.role.name || flow?.binding.roleName || '' - const roleId = flow?.role.rid || flow?.binding.roleId || '' - const isFeifeiFlow = detail?.flowType === 'kuaishou_feifei' + const channelOffline = Boolean(detail?.channelOffline) + const channelOfflineMessage = String(detail?.channelOfflineMessage || '').trim() const isAffiliateDashFlow = detail?.flowType === 'affiliate_dash' const affiliateDashExpectedUid = String( expectedUid || affiliateDash?.expectedGameAccount || affiliateDash?.gameAccount || '', @@ -55,78 +43,45 @@ export function createClaimSnapshot( const isAffiliateDashReadyToSubmit = Boolean( isAffiliateDashBound && isAffiliateDashUidMatched && !isAffiliateDashBindMismatch, ) - const isBindUrlExpired = isDateExpired(flow?.binding.bindExpiresAt || '') - const isBindingPrepared = - flow?.binding.prepareStatus === 'ready' && - Boolean(String(flow?.binding.bindUrl || '').trim()) && - !isBindUrlExpired - const isBindingPreparing = flow?.binding.prepareStatus === 'pending' - const isRoleReady = Boolean(roleName || roleId) - const isUidMatched = - Boolean(expectedUid) && Boolean(roleId) && normalizeUid(expectedUid) === normalizeUid(roleId) - const confirmRoleDisabledReason = resolveConfirmRoleDisabledReason({ - isBindingPrepared, - isRoleReady, - isUidMatched, - expectedUid, - roleId, - rebindingRole: Boolean(options.rebindingRole), - }) - const isRoleConfirmed = isKuaishouCloudRoleConfirmedStatus(task?.status) - const isDispatched = String(flow?.dispatch.status || '').trim() === 'success' const normalizedStatus = normalizeTaskStatus(task?.status) const isRedeemFailed = normalizedStatus === TASK_STATUS.MANUAL_REVIEW || normalizedStatus === TASK_STATUS.FAILED || normalizedStatus === TASK_STATUS.CLOSED || - String(flow?.dispatch.status || '').trim() === 'failed' || - (isFeifeiFlow && [40, 50].includes(Number(feifei?.rechargeStatus || 0))) || (isAffiliateDashFlow && ['ship_failed', 'cancelled'].includes(String(affiliateDash?.orderStatus || '').trim())) - const isCompleted = - isKuaishouCloudCompletedStatus(task?.status) || - (isFeifeiFlow && Number(feifei?.rechargeStatus || 0) === 30) + const isCompleted = isKuaishouCloudCompletedStatus(task?.status) const hasRedeemResult = - isDispatched || hasKuaishouCloudRedeemResultStatus(task?.status) || - (isFeifeiFlow && [30, 40, 50, 60].includes(Number(feifei?.rechargeStatus || 0))) || (isAffiliateDashFlow && ['delivered', 'ship_failed', 'cancelled'].includes( String(affiliateDash?.orderStatus || '').trim(), )) const currentStep = resolveCurrentStep({ + channelOffline, hasExpectedUid, hasRedeemResult, - isRoleConfirmed, - isFeifeiFlow, isAffiliateDashFlow, isAffiliateDashReadyToSubmit, }) const progressText = resolveProgressText({ - feifei, - isFeifeiFlow, - isAffiliateDashFlow, + channelOffline, + channelOfflineMessage, affiliateDash, + isAffiliateDashFlow, hasExpectedUid, - expectedUid, hasRedeemResult, - isRoleConfirmed, - isBindingPrepared, - isUidMatched, isAffiliateDashReadyToSubmit, isAffiliateDashBindMismatch, }) const resultTitle = resolveResultTitle({ isRedeemFailed, isCompleted, - isDispatched, isAffiliateDashFlow, affiliateDash, }) const resultDescription = resolveResultDescription({ detail, - flow, - feifei, affiliateDash, task, isRedeemFailed, @@ -134,13 +89,13 @@ export function createClaimSnapshot( }) const resultVariant: ResultVariant = isRedeemFailed ? 'warning' - : isCompleted || isDispatched + : isCompleted ? 'success' : 'info' return { - flow, - feifei, + flow: null as null, + feifei: null as null, affiliateDash, order, orderItem, @@ -149,9 +104,11 @@ export function createClaimSnapshot( claimIdentity, expectedUid, hasExpectedUid, - roleName, - roleId, - isFeifeiFlow, + channelOffline, + channelOfflineMessage, + roleName: '', + roleId: '', + isFeifeiFlow: false, isAffiliateDashFlow, affiliateDashExpectedUid, affiliateDashBoundAccount, @@ -159,14 +116,14 @@ export function createClaimSnapshot( isAffiliateDashUidMatched, isAffiliateDashBindMismatch, isAffiliateDashReadyToSubmit, - isBindUrlExpired, - isBindingPrepared, - isBindingPreparing, - isRoleReady, - isUidMatched, - confirmRoleDisabledReason, - isRoleConfirmed, - isDispatched, + isBindUrlExpired: false, + isBindingPrepared: false, + isBindingPreparing: false, + isRoleReady: false, + isUidMatched: false, + confirmRoleDisabledReason: '', + isRoleConfirmed: false, + isDispatched: false, isRedeemFailed, isCompleted, hasRedeemResult, @@ -184,37 +141,16 @@ export function normalizeUid(value: unknown) { .replace(/\s+/g, '') } -function resolveConfirmRoleDisabledReason(options: { - isBindingPrepared: boolean - isRoleReady: boolean - isUidMatched: boolean - expectedUid: string - roleId: string - rebindingRole: boolean -}) { - if (!options.isBindingPrepared) { - return '绑定二维码还在准备中,请稍后自动刷新' - } - if (!options.isRoleReady) { - return '请先扫码绑定自己的角色,并刷新角色信息' - } - if (!options.isUidMatched) { - return `当前绑定角色 ID(${options.roleId || '-'})与填写 UID(${options.expectedUid || '-'})不一致` - } - if (options.rebindingRole) { - return '正在换绑角色,请稍候' - } - return '' -} - function resolveCurrentStep(options: { + channelOffline: boolean hasExpectedUid: boolean hasRedeemResult: boolean - isRoleConfirmed: boolean - isFeifeiFlow: boolean isAffiliateDashFlow: boolean isAffiliateDashReadyToSubmit: boolean }) { + if (options.channelOffline) { + return 1 + } if (!options.hasExpectedUid) { return 1 } @@ -227,26 +163,22 @@ function resolveCurrentStep(options: { } return 3 } - if (!options.isFeifeiFlow && options.isRoleConfirmed) { - return 3 - } return 2 } function resolveProgressText(options: { - feifei: ClaimKuaishouFeifeiFlowInfo | null + channelOffline: boolean + channelOfflineMessage: string affiliateDash: ClaimAffiliateDashFlowInfo | null - isFeifeiFlow: boolean isAffiliateDashFlow: boolean hasExpectedUid: boolean - expectedUid: string hasRedeemResult: boolean - isRoleConfirmed: boolean - isBindingPrepared: boolean - isUidMatched: boolean isAffiliateDashReadyToSubmit: boolean isAffiliateDashBindMismatch: boolean }) { + if (options.channelOffline) { + return options.channelOfflineMessage || '该履约渠道已下线' + } if (!options.hasExpectedUid) { return '请填写游戏编号' } @@ -267,28 +199,12 @@ function resolveProgressText(options: { } return '已绑定,请提交发货' } - if (options.isFeifeiFlow) { - return options.feifei?.rechargeStatusLabel || `UID ${options.expectedUid},请打开领取链接` - } - if (options.hasRedeemResult) { - return '兑换结果已生成' - } - if (options.isRoleConfirmed) { - return '角色已确认,等待兑换' - } - if (!options.isBindingPrepared) { - return '绑定链接准备中,请稍候' - } - if (options.isUidMatched) { - return 'UID 已匹配,请确认兑换' - } - return '请完成扫码绑定并匹配 UID' + return '当前领取流程不可用' } function resolveResultTitle(options: { isRedeemFailed: boolean isCompleted: boolean - isDispatched: boolean isAffiliateDashFlow: boolean affiliateDash: ClaimAffiliateDashFlowInfo | null }) { @@ -298,9 +214,6 @@ function resolveResultTitle(options: { if (options.isCompleted) { return '兑换成功' } - if (options.isDispatched) { - return '兑换请求已提交' - } if (options.isAffiliateDashFlow && options.affiliateDash?.orderStatus === 'delivered') { return '发货已完成' } @@ -309,8 +222,6 @@ function resolveResultTitle(options: { function resolveResultDescription(options: { detail: ClaimDetailData | null - flow: ClaimKuaishouCloudFlowInfo | null - feifei: ClaimKuaishouFeifeiFlowInfo | null affiliateDash: ClaimAffiliateDashFlowInfo | null task: ClaimDetailData['task'] | null isRedeemFailed: boolean @@ -319,8 +230,6 @@ function resolveResultDescription(options: { if (options.isRedeemFailed) { const message = String( options.task?.lastError || - options.flow?.dispatch.errorMessage || - options.feifei?.rechargeResultMessage || options.affiliateDash?.failureReason || options.detail?.result?.resultMessage || '', @@ -338,13 +247,3 @@ function resolveResultDescription(options: { return '你的兑换请求已经提交。后续结果会由系统保存在后台任务界面。' } - -function isDateExpired(value: string | null) { - const normalized = String(value || '').trim() - if (!normalized) { - return false - } - - const expiresTime = Date.parse(normalized) - return Number.isFinite(expiresTime) && expiresTime <= Date.now() -} diff --git a/apps/frontend/src/services/admin/dev-mock.ts b/apps/frontend/src/services/admin/dev-mock.ts index cd2376c3..981e43f1 100644 --- a/apps/frontend/src/services/admin/dev-mock.ts +++ b/apps/frontend/src/services/admin/dev-mock.ts @@ -13,7 +13,7 @@ export type DevMockStatus = { } export type DevMockCreateResult = { - platform: 'lewan' | 'feifei' | 'affiliate_dash' + platform: 'affiliate_dash' orderId: number orderNo: string taskId: number @@ -49,26 +49,6 @@ export function fetchDevMockStatus() { return apiGet('/api/v1/admin/dev-mock/status') } -export function createLewanDevMock(payload: { - step?: string - uid?: string - orderNo?: string - productNo?: string -}) { - return apiPost('/api/v1/admin/dev-mock/lewan', payload) -} - -export function createFeifeiDevMock(payload: { - step?: string - uid?: string - orderNo?: string - productName?: string - productCode?: string - h5Url?: string -}) { - return apiPost('/api/v1/admin/dev-mock/feifei', payload) -} - export function createAffiliateDashDevMock(payload: { step?: string uid?: string diff --git a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts deleted file mode 100644 index 3cdd3d77..00000000 --- a/apps/frontend/src/services/admin/platform-config/cloudtentacles.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { apiDelete, apiGet, apiPost } from '@/lib/http' -import type { - AdminCloudtentaclesLoginTestResult, - AdminCloudtentaclesDeliveryRecordListResult, - AdminCloudtentaclesOverrideRuleConfig, - AdminCloudtentaclesSkuListResult, - AdminCloudtentaclesSendSmsResult, - AdminCloudtentaclesSourceConfigResponse, - AdminCloudtentaclesSourceItem, - AdminCloudtentaclesSourcesConfig, - AdminCloudtentaclesValidateSessionResult, - AdminCloudtentaclesVnListResult, -} from '@/types/admin' - -export function fetchAdminCloudtentaclesSourceConfig() { - return apiGet( - '/api/v1/admin/platform-config/cloudtentacles-source', - ) -} - -export function saveAdminCloudtentaclesSourceConfig( - payload: - | AdminCloudtentaclesSourcesConfig - | ({ sourceKey: string } & Partial), -) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles-source', - payload, - ) -} - -export function deleteAdminCloudtentaclesSource(sourceKey: string) { - return apiDelete<{ success: boolean }>( - `/api/v1/admin/platform-config/cloudtentacles-source/${sourceKey}`, - ) -} - -export function fetchAdminCloudtentaclesOverrideRules() { - return apiGet( - '/api/v1/admin/platform-config/cloudtentacles/override-rules', - ) -} - -export function saveAdminCloudtentaclesOverrideRules(payload: { - enabled: boolean - rules: Array<{ - id?: string - enabled?: boolean - productName?: string - sourceKey?: string - deliveryItems?: Array<{ - cloudSkuId?: number - cloudSkuName?: string - quantity?: number - }> - notes?: string - }> -}) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles/override-rules', - payload, - ) -} - -export function sendAdminCloudtentaclesSmsCode(payload: { - baseUrl?: string - username?: string - phone?: string - deviceId?: string - deviceType?: number - sourceKey?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles/send-sms-code', - payload, - ) -} - -export function testAdminCloudtentaclesLogin(payload: { - baseUrl?: string - username?: string - password?: string - phone?: string - code?: string - deviceId?: string - deviceType?: number - sourceKey?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles/test-login', - payload, - ) -} - -export function validateAdminCloudtentaclesSession(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - sourceKey?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles/validate-session', - payload, - ) -} - -export function fetchAdminCloudtentaclesAsset(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/asset', - payload, - ) -} - -export function fetchAdminCloudtentaclesCategories(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/categories', - payload, - ) -} - -export function fetchAdminCloudtentaclesSkuList(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - sourceKey?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles/sku/list', - payload, - ) -} - -export function buyAdminCloudtentaclesSku(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - id?: number - count?: number - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/sku/buy', - payload, - ) -} - -export function useAdminCloudtentaclesSku(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - id?: number - virtualNumberId?: number - phone?: string - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/sku/use', - payload, - ) -} - -export function fetchAdminCloudtentaclesKnapsack(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/knapsack', - payload, - ) -} - -export function fetchAdminCloudtentaclesDeliveryRecords(payload: { - sourceKey?: string - page?: number - size?: number - startDate?: string - endDate?: string - platformOrderId?: string -}) { - return apiPost( - '/api/v1/admin/cloudtentacles-records', - payload, - ) -} - -export function fetchAdminCloudtentaclesRecordSources() { - return apiGet<{ - sources: Array<{ - key: string - label: string - enabled: boolean - hasToken: boolean - loggedInAt: string - }> - }>('/api/v1/admin/cloudtentacles-records/sources') -} - -export function fetchAdminCloudtentaclesVnList(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string - sourceKey?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/cloudtentacles/vn/list', - payload, - ) -} - -export function appointAdminCloudtentaclesVn(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/vn/appoint', - payload, - ) -} - -export function generateAdminCloudtentaclesVnLoginCode(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string - id?: number - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/vn/generate-login-code', - payload, - ) -} - -export function fetchAdminCloudtentaclesVnCode(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string - phone?: string - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/vn/fetch-code', - payload, - ) -} - -export function verifyAdminCloudtentaclesVnCode(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string - id?: number - code?: string - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/vn/verify-code', - payload, - ) -} - -export function fetchAdminCloudtentaclesBindUrl(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string - id?: number - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/vn/bind-url', - payload, - ) -} - -export function backAdminCloudtentaclesVn(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - key?: string - id?: number - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/vn/back', - payload, - ) -} - -export function runAdminCloudtentaclesFullFlow(payload: { - baseUrl?: string - token?: string - deviceId?: string - deviceType?: number - skuId?: number - skuCount?: number - vnKey?: string - sourceKey?: string -}) { - return apiPost>( - '/api/v1/admin/platform-config/cloudtentacles/debug/full-flow', - payload, - ) -} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index 69bb21ee..9885667b 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -1,8 +1,6 @@ export * from './notifications' export * from './scheduled-jobs' export * from './kuaishou-industry' -export * from './kuaishou-feifei' export * from './affiliate-dash' -export * from './cloudtentacles' export * from './fulfillment-routing' export * from './pdd' diff --git a/apps/frontend/src/services/admin/platform-config/kuaishou-feifei.ts b/apps/frontend/src/services/admin/platform-config/kuaishou-feifei.ts deleted file mode 100644 index 3220fe29..00000000 --- a/apps/frontend/src/services/admin/platform-config/kuaishou-feifei.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminKuaishouFeifeiConfig, - AdminKuaishouFeifeiConfigResponse, - AdminKuaishouFeifeiMatchResult, - AdminKuaishouFeifeiOrderResult, - AdminKuaishouFeifeiProductListResult, - AdminKuaishouFeifeiProductSyncResult, -} from '@/types/admin' - -export function fetchAdminKuaishouFeifeiConfig() { - return apiGet('/api/v1/admin/platform-config/kuaishou-feifei') -} - -export function saveAdminKuaishouFeifeiConfig(payload: AdminKuaishouFeifeiConfig) { - return apiPost( - '/api/v1/admin/platform-config/kuaishou-feifei', - payload, - ) -} - -export function matchAdminKuaishouFeifeiProduct(productName: string) { - return apiPost( - '/api/v1/admin/platform-config/kuaishou-feifei/match', - { productName }, - ) -} - -export function fetchAdminKuaishouFeifeiProducts(payload: { - page?: number - perPage?: number - status?: string - supplyProductName?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/kuaishou-feifei/products', - payload, - ) -} - -export function syncAdminKuaishouFeifeiProducts( - payload: { - status?: string - supplyProductName?: string - } = {}, -) { - return apiPost( - '/api/v1/admin/platform-config/kuaishou-feifei/sync-products', - payload, - ) -} - -export function createAdminKuaishouFeifeiTestOrder(payload: { - platformOrderNo: string - productCode: string - platformBuyNum?: number - platformAmount?: number | null - playerAccount?: string - playerGameRegion?: string - playerGameSrv?: string - playerGameRole?: string - submitPlayer?: boolean - notifyUrl?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/kuaishou-feifei/test-order', - payload, - ) -} - -export function queryAdminKuaishouFeifeiOrder(payload: { - platformOrderNo?: string - orderNo?: string -}) { - return apiPost( - '/api/v1/admin/platform-config/kuaishou-feifei/query-order', - payload, - ) -} diff --git a/apps/frontend/src/services/admin/tasks.ts b/apps/frontend/src/services/admin/tasks.ts index c0eff2c9..4119bc84 100644 --- a/apps/frontend/src/services/admin/tasks.ts +++ b/apps/frontend/src/services/admin/tasks.ts @@ -52,41 +52,6 @@ export function completeAdminTaskManualDispatch( ) } -export function prepareAdminTaskKuaishouCloudFulfillment(taskId: number | string) { - return apiPost( - `/api/v1/admin/tasks/${taskId}/kuaishou-cloud/prepare`, - {}, - ) -} - -export function refreshAdminTaskKuaishouCloudRoleInfo(taskId: number | string) { - return apiPost( - `/api/v1/admin/tasks/${taskId}/kuaishou-cloud/refresh-role-info`, - {}, - ) -} - -export function rebindAdminTaskKuaishouCloudRole(taskId: number | string) { - return apiPost( - `/api/v1/admin/tasks/${taskId}/kuaishou-cloud/rebind-role`, - {}, - ) -} - -export function dispatchAdminTaskKuaishouCloudFulfillment(taskId: number | string) { - return apiPost( - `/api/v1/admin/tasks/${taskId}/kuaishou-cloud/dispatch`, - {}, - ) -} - -export function returnNumberAdminTaskKuaishouCloudFulfillment(taskId: number | string) { - return apiPost( - `/api/v1/admin/tasks/${taskId}/kuaishou-cloud/return-number`, - {}, - ) -} - export function resendAdminTaskKuaishouIndustryVoucherCode(taskId: number | string) { return apiPost( `/api/v1/admin/tasks/${taskId}/kuaishou-industry/resend-code`, diff --git a/apps/frontend/src/services/claim.ts b/apps/frontend/src/services/claim.ts index 231156dd..df2b11e5 100644 --- a/apps/frontend/src/services/claim.ts +++ b/apps/frontend/src/services/claim.ts @@ -15,15 +15,3 @@ export function submitAffiliateDashClaim( ) { return apiPost(`/api/v1/claim/${token}/affiliate-dash/submit`, payload) } - -export function confirmKuaishouCloudClaimRole(token: string) { - return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/confirm-role`, {}) -} - -export function rebindKuaishouCloudClaimRole(token: string) { - return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/rebind-role`, {}) -} - -export function redeemKuaishouCloudClaim(token: string) { - return apiPost(`/api/v1/claim/${token}/kuaishou-cloud/redeem`, {}) -} diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index c49079ee..15ebab34 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -59,7 +59,6 @@ export type { AdminNotificationConfig, AdminNotificationSendResult, AdminNotificationTestResult, - AdminCloudtentaclesMonitorAccount, AdminScheduledJobAccountRuntime, AdminScheduledJobCloudtentaclesAccount, AdminScheduledJobItem, @@ -73,36 +72,6 @@ export type { AdminKuaishouIndustryRefreshTokenResponse, AdminKuaishouIndustryAuthorizationCodePayload, AdminKuaishouIndustryExchangeCodeResponse, - AdminKuaishouFeifeiProductRule, - AdminKuaishouFeifeiConfig, - AdminKuaishouFeifeiEffectiveConfig, - AdminKuaishouFeifeiConfigResponse, - AdminKuaishouFeifeiProductSyncResult, - AdminKuaishouFeifeiProductMatch, - AdminKuaishouFeifeiMatchResult, - AdminKuaishouFeifeiProductItem, - AdminKuaishouFeifeiProductListResult, - AdminKuaishouFeifeiOrderResult, - AdminCloudtentaclesSourceItem, - AdminCloudtentaclesSourcesConfig, - AdminCloudtentaclesSourceConfigResponse, - AdminCloudtentaclesOverrideDeliveryItem, - AdminCloudtentaclesOverrideRule, - AdminCloudtentaclesOverrideRuleConfig, - AdminCloudtentaclesSessionsMap, - AdminCloudtentaclesSourceConfig, - AdminCloudtentaclesPersistedSession, - AdminCloudtentaclesSessionItem, - AdminCloudtentaclesSendSmsResult, - AdminCloudtentaclesSessionSummary, - AdminCloudtentaclesLoginTestResult, - AdminCloudtentaclesValidateSessionResult, - AdminCloudtentaclesSkuItem, - AdminCloudtentaclesSkuListResult, - AdminCloudtentaclesVnListItem, - AdminCloudtentaclesVnListResult, - AdminCloudtentaclesDeliveryRecordItem, - AdminCloudtentaclesDeliveryRecordListResult, AdminFulfillmentRoutingConfig, AdminFulfillmentRoutingExecutorConfig, AdminFulfillmentRoutingPreviewResult, diff --git a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts b/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts deleted file mode 100644 index 125e60b4..00000000 --- a/apps/frontend/src/types/admin/platform-config/cloudtentacles.ts +++ /dev/null @@ -1,199 +0,0 @@ -export interface AdminCloudtentaclesSourceItem { - key: string - label: string - enabled: boolean - baseUrl: string - username: string - password: string - phone: string - deviceId: string - deviceType: number -} - -export interface AdminCloudtentaclesSourcesConfig { - enabled: boolean - sources: AdminCloudtentaclesSourceItem[] -} - -export interface AdminCloudtentaclesPersistedSession { - token: string - tokenMasked: string - baseUrl: string - username: string - phoneMasked: string - loggedInAt: string - deviceId: string - deviceType: number - hasToken: boolean -} - -export type AdminCloudtentaclesSessionItem = AdminCloudtentaclesPersistedSession - -export interface AdminCloudtentaclesSessionsMap { - [sourceKey: string]: AdminCloudtentaclesPersistedSession -} - -export interface AdminCloudtentaclesSourceConfigResponse { - filePath: string - sessionFilePath: string - enabled: boolean - sources: AdminCloudtentaclesSourceItem[] - sessions: AdminCloudtentaclesSessionsMap -} - -export interface AdminCloudtentaclesOverrideDeliveryItem { - cloudSkuId: number - cloudSkuName: string - quantity: number -} - -export interface AdminCloudtentaclesOverrideRule { - id: string - enabled: boolean - productName: string - normalizedProductName: string - sourceKey: string - deliveryItems: AdminCloudtentaclesOverrideDeliveryItem[] - notes: string -} - -export interface AdminCloudtentaclesOverrideRuleConfig { - filePath: string - enabled: boolean - rules: AdminCloudtentaclesOverrideRule[] -} - -export interface AdminCloudtentaclesSendSmsResult { - baseUrl: string - username: string - phone: string - phoneMasked: string - sentAt: string - responseMessage: string -} - -export interface AdminCloudtentaclesSessionSummary { - tokenMasked: string - permissionCount: number - permissions: string[] - persisted?: boolean -} - -export interface AdminCloudtentaclesLoginTestResult { - baseUrl: string - username: string - phoneMasked: string - loggedInAt: string - responseMessage: string - token: string - session: AdminCloudtentaclesSessionSummary - userInfo: Record - asset: number -} - -export interface AdminCloudtentaclesValidateSessionResult { - baseUrl: string - loggedInAt: string - session: AdminCloudtentaclesSessionSummary - userInfo: Record - asset: number -} - -export interface AdminCloudtentaclesSkuItem { - id: number - categoriesId: number - name: string - description: string - image: string - inventory: number - price: number - listingTime: string - delistingTime: string - buyLimitMin: number - buyLimitMax: number - raw: Record -} - -export interface AdminCloudtentaclesSkuListResult { - baseUrl: string - itemCount: number - occupiedItemCount: number - items: AdminCloudtentaclesSkuItem[] - rawItems: Record[] -} - -export interface AdminCloudtentaclesDeliveryRecordItem { - createdAt: string - recordId: string - virtualNumberId: number - userId: number - count: number - cdk: string - exchangeUrl: string - name: string - skuId: number - skuName: string - skuImage: string - phone: string - status: number - fulfillUser: string - operatorName: string - platformOrderId: string - taskId: number - taskNo: string - orderShopId: string - orderShopName: string - consumeShopId: string - consumeShopName: string - roleName: string - roleId: string - localTaskStatus: string - localDeliveryStatus: string - localDispatchStatus: string - localDispatchAt: string - localResultCode: string - localResultMessage: string - matchConfidence: string - raw: Record -} - -export interface AdminCloudtentaclesDeliveryRecordListResult { - baseUrl: string - page: number - size: number - total: number - sourceKey?: string - queryStartDate?: string - queryEndDate?: string - localOrderMatched?: boolean - items: AdminCloudtentaclesDeliveryRecordItem[] - raw: Record -} - -/** @deprecated Use AdminCloudtentaclesSourceItem + AdminCloudtentaclesSourcesConfig instead */ -export interface AdminCloudtentaclesSourceConfig { - enabled: boolean - baseUrl: string - username: string - password: string - phone: string - deviceId: string - deviceType: number -} - -export interface AdminCloudtentaclesVnListItem { - id: number - phone: string - status: number - countTime: number - bindInfo: string | Record | null - raw: Record -} - -export interface AdminCloudtentaclesVnListResult { - baseUrl: string - key: string - itemCount: number - items: AdminCloudtentaclesVnListItem[] - rawItems: Array> -} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 9809b9aa..302eae48 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -26,19 +26,6 @@ export type { AdminKuaishouIndustryExchangeCodeResponse, } from './kuaishou-industry' -export type { - AdminKuaishouFeifeiProductRule, - AdminKuaishouFeifeiConfig, - AdminKuaishouFeifeiEffectiveConfig, - AdminKuaishouFeifeiConfigResponse, - AdminKuaishouFeifeiProductSyncResult, - AdminKuaishouFeifeiProductMatch, - AdminKuaishouFeifeiMatchResult, - AdminKuaishouFeifeiProductItem, - AdminKuaishouFeifeiProductListResult, - AdminKuaishouFeifeiOrderResult, -} from './kuaishou-feifei' - export type { AdminAffiliateDashConfig, AdminAffiliateDashEffectiveConfig, @@ -49,29 +36,6 @@ export type { AdminAffiliateDashWallet, } from './affiliate-dash' -export type { - AdminCloudtentaclesSourceItem, - AdminCloudtentaclesSourcesConfig, - AdminCloudtentaclesSourceConfigResponse, - AdminCloudtentaclesOverrideDeliveryItem, - AdminCloudtentaclesOverrideRule, - AdminCloudtentaclesOverrideRuleConfig, - AdminCloudtentaclesSessionsMap, - AdminCloudtentaclesSourceConfig, - AdminCloudtentaclesPersistedSession, - AdminCloudtentaclesSessionItem, - AdminCloudtentaclesSendSmsResult, - AdminCloudtentaclesSessionSummary, - AdminCloudtentaclesLoginTestResult, - AdminCloudtentaclesValidateSessionResult, - AdminCloudtentaclesSkuItem, - AdminCloudtentaclesSkuListResult, - AdminCloudtentaclesVnListItem, - AdminCloudtentaclesVnListResult, - AdminCloudtentaclesDeliveryRecordItem, - AdminCloudtentaclesDeliveryRecordListResult, -} from './cloudtentacles' - export type { AdminFulfillmentRoutingConfig, AdminFulfillmentRoutingExecutorConfig, diff --git a/apps/frontend/src/types/admin/platform-config/kuaishou-feifei.ts b/apps/frontend/src/types/admin/platform-config/kuaishou-feifei.ts deleted file mode 100644 index ecea64ef..00000000 --- a/apps/frontend/src/types/admin/platform-config/kuaishou-feifei.ts +++ /dev/null @@ -1,104 +0,0 @@ -export interface AdminKuaishouFeifeiProductRule { - id: string - enabled: boolean - productName: string - normalizedProductName: string - productCode: string - skuName: string - notes: string -} - -export interface AdminKuaishouFeifeiConfig { - enabled: boolean - baseUrl: string - appKey: string - appSecret: string - timeoutMs: number - notifyUrl: string - productRules: AdminKuaishouFeifeiProductRule[] -} - -export interface AdminKuaishouFeifeiEffectiveConfig { - enabled: boolean - baseUrl: string - timeoutMs: number - notifyUrl: string - hasAppKey: boolean - hasAppSecret: boolean - productRuleCount: number -} - -export interface AdminKuaishouFeifeiConfigResponse { - filePath: string - source: AdminKuaishouFeifeiConfig - effective: AdminKuaishouFeifeiEffectiveConfig -} - -export interface AdminKuaishouFeifeiProductSyncResult extends AdminKuaishouFeifeiConfigResponse { - sync: { - productCount: number - ruleCount: number - status: string - supplyProductName: string - } -} - -export interface AdminKuaishouFeifeiProductMatch { - matchMode: string - productName: string - normalizedProductName: string - productCode: string - skuName: string -} - -export interface AdminKuaishouFeifeiMatchResult { - productName: string - normalizedProductName: string - matched: boolean - match: AdminKuaishouFeifeiProductMatch | null -} - -export interface AdminKuaishouFeifeiProductItem { - productCode: string - name: string - channel: string - externalProductId: string - imageUrl: string - status: string - supplyStatus: string - supplyPricePoints: number - feePoints: number - unitCostPoints: number - maxOrderQuantity: number - salePricePoints: number | null - raw: Record -} - -export interface AdminKuaishouFeifeiProductListResult { - items: AdminKuaishouFeifeiProductItem[] - total: number - page: number - perPage: number - raw: Record -} - -export interface AdminKuaishouFeifeiOrderResult { - orderNo: string - platformOrderNo: string - productCode: string - productName: string - rechargeStatus: number - rechargeStatusLabel: string - pointsCharged: number - playerAccount: string - platformBuyNum: number - rechargeResultMessage: string - createdAt: string - updatedAt: string - rechargeFinishAt: string - h5: { - entryUrl: string - rechargeUrl: string - } - raw: Record -} diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index d4a57b70..04ce8296 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -198,6 +198,8 @@ export interface ClaimDetailData { | 'kuaishou_feifei' | 'affiliate_dash' | (string & {}) + channelOffline?: boolean + channelOfflineMessage?: string claimIdentity: ClaimIdentityInfo | null task: ClaimTaskInfo order: ClaimOrderInfo diff --git a/apps/frontend/src/utils/admin-options.ts b/apps/frontend/src/utils/admin-options.ts index 16b576c0..6381b31c 100644 --- a/apps/frontend/src/utils/admin-options.ts +++ b/apps/frontend/src/utils/admin-options.ts @@ -7,8 +7,6 @@ export const adminPayStatusOptions = [ ] export const adminOrderExecutorOptions = [ - { label: '乐玩云', value: 'kuaishou_ct_assisted' }, - { label: '飞飞', value: 'kuaishou_feifei' }, { label: 'Affiliate Dash', value: 'affiliate_dash' }, { label: '行业凭证', value: 'kuaishou-industry' }, { label: '人工履约', value: 'manual_dispatch' }, @@ -58,7 +56,6 @@ export const adminAuditActionOptions = [ { label: '修改用户角色', value: 'admin_user_role_updated' }, { label: '修改用户状态', value: 'admin_user_status_updated' }, { label: '重置用户密码', value: 'admin_user_password_reset' }, - { label: '换绑角色', value: 'task_kuaishou_cloud_rebind_role' }, { label: '关闭任务', value: 'task_closed' }, { label: '转人工处理', value: 'task_mark_manual_review' }, { label: '工单发布', value: 'work_order_published' },