diff --git a/apps/backend/src/services/admin/admin-platform-config-context.js b/apps/backend/src/services/admin/admin-platform-config-context.js new file mode 100644 index 00000000..4e00cccc --- /dev/null +++ b/apps/backend/src/services/admin/admin-platform-config-context.js @@ -0,0 +1,68 @@ +// @ts-check + +export function pickFirstNonEmpty(values) { + for (const value of values) { + const normalized = String(value || '').trim() + if (normalized) { + return normalized + } + } + + return '' +} + +export function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} + +export function resolveKuaishouEticketAdminContext( + payload = {}, + options = {}, +) { + const savedSource = /** @type {Record} */ (options.savedSource || {}) + const findShopConfig = /** @type {(shopId: string, source: Record) => unknown} */ ( + options.findShopConfig || (() => null) + ) + const getFirstAvailableShop = /** @type {(source: Record) => unknown} */ ( + options.getFirstAvailableShop || (() => null) + ) + const defaultBaseUrl = String(options.defaultBaseUrl || 'https://s.kwaixiaodian.com').trim() + const requestedShopId = String(payload.shopId || '').trim() + const configuredShop = /** @type {Record | null} */ ( + requestedShopId + ? findShopConfig(requestedShopId, savedSource) + : getFirstAvailableShop(savedSource) + ) + + return { + baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]), + cookie: pickFirstNonEmpty([payload.cookie, configuredShop?.cookie]), + shopId: pickFirstNonEmpty([requestedShopId, configuredShop?.shopId]), + shop: configuredShop, + } +} + +export function resolveCloudtentaclesAdminContext( + payload = {}, + options = {}, +) { + const savedSource = /** @type {Record} */ (options.savedSource || {}) + const persistedSession = /** @type {Record} */ (options.persistedSession || {}) + const defaultBaseUrl = String(options.defaultBaseUrl || 'https://123.207.217.176').trim() + return { + baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, defaultBaseUrl]), + token: pickFirstNonEmpty([payload.token, persistedSession.token]), + deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, persistedSession.deviceId, '-']), + deviceType: payload.deviceType ?? savedSource.deviceType ?? persistedSession.deviceType, + } +} + +export function hasCloudtentaclesCredentialContextChanged(current = {}, next = {}) { + 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/admin-platform-config-context.test.js b/apps/backend/src/services/admin/admin-platform-config-context.test.js new file mode 100644 index 00000000..c454a69c --- /dev/null +++ b/apps/backend/src/services/admin/admin-platform-config-context.test.js @@ -0,0 +1,114 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + hasCloudtentaclesCredentialContextChanged, + isPlainObject, + pickFirstNonEmpty, + resolveCloudtentaclesAdminContext, + resolveKuaishouEticketAdminContext, +} from './admin-platform-config-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('resolveKuaishouEticketAdminContext prefers explicit payload values', () => { + const context = resolveKuaishouEticketAdminContext( + { + baseUrl: ' https://override.example.com ', + shopId: ' shop-2 ', + cookie: ' cookie-2 ', + }, + { + savedSource: { baseUrl: 'https://saved.example.com' }, + findShopConfig(requestedShopId) { + assert.equal(requestedShopId, 'shop-2') + return { shopId: 'shop-2', cookie: 'saved-cookie-2' } + }, + getFirstAvailableShop() { + throw new Error('should not use first available shop') + }, + }, + ) + + assert.deepEqual(context, { + baseUrl: 'https://override.example.com', + cookie: 'cookie-2', + shopId: 'shop-2', + shop: { shopId: 'shop-2', cookie: 'saved-cookie-2' }, + }) +}) + +test('resolveKuaishouEticketAdminContext falls back to first available shop', () => { + const context = resolveKuaishouEticketAdminContext( + {}, + { + savedSource: { baseUrl: ' https://saved.example.com ' }, + getFirstAvailableShop() { + return { shopId: 'shop-1', cookie: 'cookie-1' } + }, + }, + ) + + assert.deepEqual(context, { + baseUrl: 'https://saved.example.com', + cookie: 'cookie-1', + shopId: 'shop-1', + shop: { shopId: 'shop-1', cookie: 'cookie-1' }, + }) +}) + +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, { + 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/admin-platform-config-service.js b/apps/backend/src/services/admin/admin-platform-config-service.js index bd934285..a9f3a572 100644 --- a/apps/backend/src/services/admin/admin-platform-config-service.js +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -80,6 +80,13 @@ import { formatFenToAmount } from '../../utils/money.js' import { syncConfiguredFulfillmentBindings } from '../bootstrap/fulfillment-bootstrap-service.js' import { normalizeProductName } from '../order/product-match-service.js' import { resolveDisplayShopName } from './admin-read-shared-helpers.js' +import { + hasCloudtentaclesCredentialContextChanged, + isPlainObject, + pickFirstNonEmpty, + resolveCloudtentaclesAdminContext, + resolveKuaishouEticketAdminContext, +} from './admin-platform-config-context.js' import { mapAdminCloudtentaclesSession, mapAdminCloudtentaclesSourceConfig, @@ -396,7 +403,11 @@ export async function syncAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQ /** @param {AdminKuaishouEticketDetailQueryInput} [payload] */ export async function queryAdminKuaishouEticketDetail(payload = /** @type {AdminKuaishouEticketDetailQueryInput} */ ({})) { - const context = resolveKuaishouEticketAdminContext(payload) + const context = resolveKuaishouEticketAdminContext(payload, { + savedSource: getKuaishouEticketSourceConfig(), + findShopConfig: findKuaishouEticketShopConfig, + getFirstAvailableShop: getFirstAvailableKuaishouEticketShop, + }) return queryKuaishouEticketConsumeDetail({ baseUrl: context.baseUrl, @@ -407,7 +418,11 @@ export async function queryAdminKuaishouEticketDetail(payload = /** @type {Admin /** @param {AdminKuaishouEticketShopInfoInput} [payload] */ export async function queryAdminKuaishouEticketShopInfo(payload = /** @type {AdminKuaishouEticketShopInfoInput} */ ({})) { - const context = resolveKuaishouEticketAdminContext(payload) + const context = resolveKuaishouEticketAdminContext(payload, { + savedSource: getKuaishouEticketSourceConfig(), + findShopConfig: findKuaishouEticketShopConfig, + getFirstAvailableShop: getFirstAvailableKuaishouEticketShop, + }) return queryKuaishouEticketStableInfo({ baseUrl: context.baseUrl, cookie: context.cookie, @@ -416,7 +431,11 @@ export async function queryAdminKuaishouEticketShopInfo(payload = /** @type {Adm /** @param {AdminKuaishouEticketConsumeInput} [payload] */ export async function consumeAdminKuaishouEticket(payload = /** @type {AdminKuaishouEticketConsumeInput} */ ({})) { - const context = resolveKuaishouEticketAdminContext(payload) + const context = resolveKuaishouEticketAdminContext(payload, { + savedSource: getKuaishouEticketSourceConfig(), + findShopConfig: findKuaishouEticketShopConfig, + getFirstAvailableShop: getFirstAvailableKuaishouEticketShop, + }) return consumeKuaishouEticket({ baseUrl: context.baseUrl, @@ -557,25 +576,37 @@ export async function validateAdminCloudtentaclesSession(payload = /** @type {Ad /** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */ export async function getAdminCloudtentaclesAsset(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return getCloudtentaclesAsset(context) } /** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */ export async function getAdminCloudtentaclesCategories(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return getCloudtentaclesCategories(context) } /** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */ export async function getAdminCloudtentaclesSkuList(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return listCloudtentaclesSku(context) } /** @param {AdminCloudtentaclesSkuBuyInput} [payload] */ export async function buyAdminCloudtentaclesSku(payload = /** @type {AdminCloudtentaclesSkuBuyInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return buyCloudtentaclesSku({ ...context, id: payload.id, @@ -585,7 +616,10 @@ export async function buyAdminCloudtentaclesSku(payload = /** @type {AdminCloudt /** @param {AdminCloudtentaclesSkuUseInput} [payload] */ export async function useAdminCloudtentaclesSku(payload = /** @type {AdminCloudtentaclesSkuUseInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return useCloudtentaclesSku({ ...context, id: payload.id, @@ -596,13 +630,19 @@ export async function useAdminCloudtentaclesSku(payload = /** @type {AdminCloudt /** @param {AdminCloudtentaclesCatalogQueryInput} [payload] */ export async function getAdminCloudtentaclesKnapsack(payload = /** @type {AdminCloudtentaclesCatalogQueryInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return getCloudtentaclesKnapsack(context) } /** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */ export async function listAdminCloudtentaclesVirtualNumbers(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return listCloudtentaclesVirtualNumbers({ ...context, key: payload.key, @@ -611,7 +651,10 @@ export async function listAdminCloudtentaclesVirtualNumbers(payload = /** @type /** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */ export async function appointAdminCloudtentaclesVirtualNumber(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return appointCloudtentaclesVirtualNumber({ ...context, key: payload.key, @@ -620,7 +663,10 @@ export async function appointAdminCloudtentaclesVirtualNumber(payload = /** @typ /** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */ export async function generateAdminCloudtentaclesLoginCode(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return generateCloudtentaclesLoginCode({ ...context, key: payload.key, @@ -630,7 +676,10 @@ export async function generateAdminCloudtentaclesLoginCode(payload = /** @type { /** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */ export async function fetchAdminCloudtentaclesVirtualNumberCode(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return fetchCloudtentaclesVirtualNumberCode({ ...context, key: payload.key, @@ -640,7 +689,10 @@ export async function fetchAdminCloudtentaclesVirtualNumberCode(payload = /** @t /** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */ export async function verifyAdminCloudtentaclesLoginCode(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return verifyCloudtentaclesLoginCode({ ...context, key: payload.key, @@ -651,7 +703,10 @@ export async function verifyAdminCloudtentaclesLoginCode(payload = /** @type {Ad /** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */ export async function getAdminCloudtentaclesBindUrl(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return getCloudtentaclesBindUrl({ ...context, key: payload.key, @@ -661,7 +716,10 @@ export async function getAdminCloudtentaclesBindUrl(payload = /** @type {AdminCl /** @param {AdminCloudtentaclesVirtualNumberInput} [payload] */ export async function backAdminCloudtentaclesVirtualNumber(payload = /** @type {AdminCloudtentaclesVirtualNumberInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return backCloudtentaclesVirtualNumber({ ...context, key: payload.key, @@ -671,7 +729,10 @@ export async function backAdminCloudtentaclesVirtualNumber(payload = /** @type { /** @param {AdminCloudtentaclesFullFlowInput} [payload] */ export async function runAdminCloudtentaclesFullFlow(payload = /** @type {AdminCloudtentaclesFullFlowInput} */ ({})) { - const context = resolveCloudtentaclesAdminContext(payload) + const context = resolveCloudtentaclesAdminContext(payload, { + savedSource: getCloudtentaclesSourceConfig(), + persistedSession: getCloudtentaclesSessionState(), + }) return runCloudtentaclesFullDebugFlow({ ...context, skuId: payload.skuId, @@ -1125,55 +1186,3 @@ function mapAdminObservedProductItem(item, bindings = []) { function findMatchingObservedBinding(bindings, observed) { return (Array.isArray(bindings) ? bindings : []).find((binding) => matchesObservedProduct(binding, observed)) || null } - -function resolveKuaishouEticketAdminContext(payload = {}) { - const savedSource = getKuaishouEticketSourceConfig() - const requestedShopId = String(payload.shopId || '').trim() - const configuredShop = requestedShopId - ? findKuaishouEticketShopConfig(requestedShopId, savedSource) - : getFirstAvailableKuaishouEticketShop(savedSource) - - return { - baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://s.kwaixiaodian.com']), - cookie: pickFirstNonEmpty([payload.cookie, configuredShop?.cookie]), - shopId: pickFirstNonEmpty([requestedShopId, configuredShop?.shopId]), - shop: configuredShop, - } -} - -function resolveCloudtentaclesAdminContext(payload = {}) { - const savedSource = getCloudtentaclesSourceConfig() - const persistedSession = getCloudtentaclesSessionState() - - return { - baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']), - token: pickFirstNonEmpty([payload.token, persistedSession.token]), - deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, persistedSession.deviceId, '-']), - deviceType: payload.deviceType ?? savedSource.deviceType ?? persistedSession.deviceType, - } -} - -function hasCloudtentaclesCredentialContextChanged(current, next) { - 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) - ) -} - -function isPlainObject(value) { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value) -} - -function pickFirstNonEmpty(values) { - for (const value of values) { - const normalized = String(value || '').trim() - if (normalized) { - return normalized - } - } - - return '' -}