diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index e0fb9a9a..682f82fb 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -76,6 +76,27 @@ module.exports = { captchaPath: '/verify_img.php', orderListPath: '/c/payOrder/get.php', }, + 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', + clientSource: 'ct-client', + deviceId: '-', + deviceType: 0, + 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-----`, + }, }, redeem: { diff --git a/apps/backend/data/cloudtentacles-sources.json b/apps/backend/data/cloudtentacles-sources.json new file mode 100644 index 00000000..1f1f9799 --- /dev/null +++ b/apps/backend/data/cloudtentacles-sources.json @@ -0,0 +1,9 @@ +{ + "enabled": true, + "baseUrl": "https://123.207.217.176", + "username": "17665234375", + "password": "yaochao11", + "phone": "17665234375", + "deviceId": "-", + "deviceType": 0 +} diff --git a/apps/backend/data/khhao-sync-state.json b/apps/backend/data/khhao-sync-state.json index f6dd28b5..ec226491 100644 --- a/apps/backend/data/khhao-sync-state.json +++ b/apps/backend/data/khhao-sync-state.json @@ -1,11 +1,11 @@ { "syncFromCreatedAt": "2026-05-02T10:53:28.550Z", - "lastRunStartedAt": "2026-05-02T12:36:55.519Z", - "lastRunFinishedAt": "2026-05-02T12:36:56.704Z", + "lastRunStartedAt": "2026-05-02T13:40:21.932Z", + "lastRunFinishedAt": "2026-05-02T13:40:23.162Z", "lastRunStatus": "success", "lastErrorMessage": "", "fetchedCount": 40, "syncedCount": 0, "ignoredCount": 40, - "lastOrderCreatedAt": "2026-05-02T12:36:45.000Z" + "lastOrderCreatedAt": "2026-05-02T13:39:40.000Z" } diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 2f076929..ae363315 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -260,6 +260,61 @@ function applyEnvOverrides(baseConfig) { nextConfig.platforms.khhao.orderListPath = khhaoOrderListPath } + const cloudtentaclesBaseUrl = String(process.env.CLOUDTENTACLES_BASE_URL || '').trim() + if (cloudtentaclesBaseUrl) { + nextConfig.platforms.cloudtentacles.baseUrl = cloudtentaclesBaseUrl + } + + const cloudtentaclesTimeoutMs = parseInteger(process.env.CLOUDTENTACLES_TIMEOUT_MS) + if (cloudtentaclesTimeoutMs !== null) { + nextConfig.platforms.cloudtentacles.timeoutMs = cloudtentaclesTimeoutMs + } + + const cloudtentaclesSendSmsPath = String(process.env.CLOUDTENTACLES_SEND_SMS_PATH || '').trim() + if (cloudtentaclesSendSmsPath) { + nextConfig.platforms.cloudtentacles.sendSmsPath = cloudtentaclesSendSmsPath + } + + const cloudtentaclesLoginPath = String(process.env.CLOUDTENTACLES_LOGIN_PATH || '').trim() + if (cloudtentaclesLoginPath) { + nextConfig.platforms.cloudtentacles.loginPath = cloudtentaclesLoginPath + } + + const cloudtentaclesUserInfoPath = String(process.env.CLOUDTENTACLES_USER_INFO_PATH || '').trim() + if (cloudtentaclesUserInfoPath) { + nextConfig.platforms.cloudtentacles.userInfoPath = cloudtentaclesUserInfoPath + } + + const cloudtentaclesAssetPath = String(process.env.CLOUDTENTACLES_ASSET_PATH || '').trim() + if (cloudtentaclesAssetPath) { + nextConfig.platforms.cloudtentacles.assetPath = cloudtentaclesAssetPath + } + + const cloudtentaclesPermissionPath = String(process.env.CLOUDTENTACLES_PERMISSION_PATH || '').trim() + if (cloudtentaclesPermissionPath) { + nextConfig.platforms.cloudtentacles.permissionPath = cloudtentaclesPermissionPath + } + + const cloudtentaclesPublicKeyPem = String(process.env.CLOUDTENTACLES_PUBLIC_KEY_PEM || '').trim() + if (cloudtentaclesPublicKeyPem) { + nextConfig.platforms.cloudtentacles.publicKeyPem = cloudtentaclesPublicKeyPem + } + + const cloudtentaclesClientSource = String(process.env.CLOUDTENTACLES_CLIENT_SOURCE || '').trim() + if (cloudtentaclesClientSource) { + nextConfig.platforms.cloudtentacles.clientSource = cloudtentaclesClientSource + } + + const cloudtentaclesDeviceId = String(process.env.CLOUDTENTACLES_DEVICE_ID || '').trim() + if (cloudtentaclesDeviceId) { + nextConfig.platforms.cloudtentacles.deviceId = cloudtentaclesDeviceId + } + + const cloudtentaclesDeviceType = parseInteger(process.env.CLOUDTENTACLES_DEVICE_TYPE) + if (cloudtentaclesDeviceType !== null) { + nextConfig.platforms.cloudtentacles.deviceType = cloudtentaclesDeviceType + } + const proofMode = String(process.env.TENCENT_REDEEM_PROOF_MODE || '').trim() if (proofMode) { nextConfig.redeem.proofMode = proofMode diff --git a/apps/backend/src/routes/admin/platform-config.js b/apps/backend/src/routes/admin/platform-config.js index 029b09c3..e9bc915f 100644 --- a/apps/backend/src/routes/admin/platform-config.js +++ b/apps/backend/src/routes/admin/platform-config.js @@ -3,16 +3,21 @@ import { Router } from 'express' import { + getAdminCloudtentaclesSourceConfig, getAdminAgisoShopConfigs, getAdminFulfillmentBindingConfigs, getAdminKhhaoSourceConfig, lookupAdminFulfillmentBindingOrder, queryAdminKhhaoOrders, + sendAdminCloudtentaclesSmsCode, syncAdminKhhaoOrders, + testAdminCloudtentaclesLogin, testAdminKhhaoLogin, + updateAdminCloudtentaclesSourceConfig, updateAdminAgisoShopConfigs, updateAdminKhhaoSourceConfig, updateAdminFulfillmentBindingConfigs, + validateAdminCloudtentaclesSession, } from '../../services/admin/admin-platform-config-service.js' import { createJsonHandler, requireAdminRoles } from './shared.js' @@ -22,6 +27,10 @@ import { createJsonHandler, requireAdminRoles } from './shared.js' /** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoOrderQueryRouteBody} AdminKhhaoOrderQueryRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoSourceConfigRouteBody} AdminKhhaoSourceConfigRouteBody */ /** @typedef {import('../../types/admin-route-inputs.js').AdminKhhaoTestLoginRouteBody} AdminKhhaoTestLoginRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesSourceConfigRouteBody} AdminCloudtentaclesSourceConfigRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesSendSmsCodeRouteBody} AdminCloudtentaclesSendSmsCodeRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesTestLoginRouteBody} AdminCloudtentaclesTestLoginRouteBody */ +/** @typedef {import('../../types/admin-route-inputs.js').AdminCloudtentaclesValidateSessionRouteBody} AdminCloudtentaclesValidateSessionRouteBody */ /** @typedef {import('../../types/admin-write-models.js').AdminAgisoShopConfigSaveResponse} AdminAgisoShopConfigSaveResponse */ const router = Router() @@ -163,6 +172,102 @@ router.post('/platform-config/khhao/sync-orders', createJsonHandler( }, )) +router.get('/platform-config/cloudtentacles-source', createJsonHandler( + () => getAdminCloudtentaclesSourceConfig(), + { + successMessage: 'ok', + errorMessage: '读取 cloudtentacles 履约平台配置失败', + scope: '[admin/platform-config/cloudtentacles-source]', + }, +)) + +router.post('/platform-config/cloudtentacles-source', createJsonHandler( + (req) => updateAdminCloudtentaclesSourceConfig(/** @type {AdminCloudtentaclesSourceConfigRouteBody} */ (req.body)), + { + successMessage: 'cloudtentacles 履约平台配置已保存', + errorMessage: '保存 cloudtentacles 履约平台配置失败', + scope: '[admin/platform-config/cloudtentacles-source]', + audit: (_req, data) => { + const result = /** @type {{ filePath?: string, source?: { username?: string, enabled?: boolean } }} */ (data) + 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.post('/platform-config/cloudtentacles/send-sms-code', createJsonHandler( + (req) => sendAdminCloudtentaclesSmsCode(/** @type {AdminCloudtentaclesSendSmsCodeRouteBody} */ (req.body)), + { + successMessage: 'cloudtentacles 短信验证码已发送', + errorMessage: 'cloudtentacles 发送短信验证码失败', + scope: '[admin/platform-config/cloudtentacles/send-sms-code]', + audit: (req, data) => { + const body = /** @type {AdminCloudtentaclesSendSmsCodeRouteBody} */ (req.body) + const result = /** @type {{ username?: string, phoneMasked?: string, baseUrl?: string }} */ (data) + 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('/platform-config/cloudtentacles/test-login', createJsonHandler( + (req) => testAdminCloudtentaclesLogin(/** @type {AdminCloudtentaclesTestLoginRouteBody} */ (req.body)), + { + successMessage: 'cloudtentacles 登录测试成功', + errorMessage: 'cloudtentacles 登录测试失败', + scope: '[admin/platform-config/cloudtentacles/test-login]', + audit: (req, data) => { + const body = /** @type {AdminCloudtentaclesTestLoginRouteBody} */ (req.body) + const result = /** @type {{ baseUrl?: string, username?: string, session?: { permissionCount?: number } }} */ (data) + 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('/platform-config/cloudtentacles/validate-session', createJsonHandler( + (req) => validateAdminCloudtentaclesSession(/** @type {AdminCloudtentaclesValidateSessionRouteBody} */ (req.body)), + { + successMessage: 'cloudtentacles 会话校验成功', + errorMessage: 'cloudtentacles 会话校验失败', + scope: '[admin/platform-config/cloudtentacles/validate-session]', + audit: (_req, data) => { + const result = /** @type {{ baseUrl?: string, session?: { permissionCount?: number } }} */ (data) + 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.get('/platform-config/fulfillment-bindings', createJsonHandler( () => getAdminFulfillmentBindingConfigs(), { 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 1053eabf..d72b0d03 100644 --- a/apps/backend/src/services/admin/admin-platform-config-service.js +++ b/apps/backend/src/services/admin/admin-platform-config-service.js @@ -15,6 +15,16 @@ import { syncKhhaoOrders } from '../platforms/khhao/order-sync-service.js' import { getKhhaoSourceConfig, getKhhaoSourcesFilePath, saveKhhaoSourceConfig } from '../platforms/khhao/source-config-service.js' import { ensureKhhaoSyncBaseline, getKhhaoSyncState, getKhhaoSyncStateFilePath } from '../platforms/khhao/sync-state-service.js' import { loginKhhaoSession } from '../platforms/khhao/session-service.js' +import { + getCloudtentaclesSourceConfig, + getCloudtentaclesSourcesFilePath, + saveCloudtentaclesSourceConfig, +} from '../platforms/cloudtentacles/source-config-service.js' +import { + loginCloudtentaclesSession, + sendCloudtentaclesSmsCode, + validateCloudtentaclesSession, +} from '../platforms/cloudtentacles/session-service.js' import { normalizeAgisoMessageTemplate } from '../platforms/agiso/xianyu/message-service.js' import { getOrderFulfillmentBindingConfigs, @@ -32,6 +42,10 @@ import { resolveDisplayShopName } from './admin-read-shared-helpers.js' /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoSourceConfigInput} AdminKhhaoSourceConfigInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminKhhaoTestLoginInput} AdminKhhaoTestLoginInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesSendSmsCodeInput} AdminCloudtentaclesSendSmsCodeInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesTestLoginInput} AdminCloudtentaclesTestLoginInput */ +/** @typedef {import('../../types/admin-write-inputs.js').AdminCloudtentaclesValidateSessionInput} AdminCloudtentaclesValidateSessionInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigSaveInput */ /** @typedef {import('../../types/admin-write-inputs.js').AdminFulfillmentBindingLookupInput} AdminFulfillmentBindingLookupInput */ @@ -336,6 +350,119 @@ export async function syncAdminKhhaoOrders(payload = /** @type {AdminKhhaoOrderQ }) } +export function getAdminCloudtentaclesSourceConfig() { + const config = getCloudtentaclesSourceConfig() + + return { + filePath: getCloudtentaclesSourcesFilePath(), + source: { + 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: String(config.deviceId || '-').trim() || '-', + deviceType: Number(config.deviceType || 0), + }, + } +} + +/** @param {AdminCloudtentaclesSourceConfigInput} [payload] */ +export function updateAdminCloudtentaclesSourceConfig(payload = /** @type {AdminCloudtentaclesSourceConfigInput} */ ({})) { + const saved = saveCloudtentaclesSourceConfig({ + 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: String(payload.deviceId || '-').trim() || '-', + deviceType: Number(payload.deviceType || 0), + }) + + return { + filePath: getCloudtentaclesSourcesFilePath(), + source: { + enabled: saved.enabled !== false, + baseUrl: String(saved.baseUrl || '').trim(), + username: String(saved.username || '').trim(), + password: String(saved.password || '').trim(), + phone: String(saved.phone || '').trim(), + deviceId: String(saved.deviceId || '-').trim() || '-', + deviceType: Number(saved.deviceType || 0), + }, + } +} + +/** @param {AdminCloudtentaclesSendSmsCodeInput} [payload] */ +export async function sendAdminCloudtentaclesSmsCode(payload = /** @type {AdminCloudtentaclesSendSmsCodeInput} */ ({})) { + const savedSource = getCloudtentaclesSourceConfig() + const result = await sendCloudtentaclesSmsCode({ + baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']), + username: pickFirstNonEmpty([payload.username, savedSource.username]), + phone: pickFirstNonEmpty([payload.phone, savedSource.phone]), + deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']), + deviceType: payload.deviceType ?? savedSource.deviceType, + }) + + return { + ...result, + phoneMasked: maskPhone(result.phone), + } +} + +/** @param {AdminCloudtentaclesTestLoginInput} [payload] */ +export async function testAdminCloudtentaclesLogin(payload = /** @type {AdminCloudtentaclesTestLoginInput} */ ({})) { + const savedSource = getCloudtentaclesSourceConfig() + const session = await loginCloudtentaclesSession({ + baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']), + username: pickFirstNonEmpty([payload.username, savedSource.username]), + password: pickFirstNonEmpty([payload.password, savedSource.password]), + phone: pickFirstNonEmpty([payload.phone, savedSource.phone]), + code: String(payload.code || '').trim(), + deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']), + deviceType: payload.deviceType ?? savedSource.deviceType, + }) + + return { + baseUrl: session.baseUrl, + username: session.username, + phoneMasked: maskPhone(session.phone), + loggedInAt: session.loggedInAt, + responseMessage: session.responseMessage, + token: session.token, + session: { + tokenMasked: maskSecret(session.token), + permissionCount: session.permissions.length, + permissions: session.permissions, + }, + userInfo: session.userInfo, + asset: session.asset, + } +} + +/** @param {AdminCloudtentaclesValidateSessionInput} [payload] */ +export async function validateAdminCloudtentaclesSession(payload = /** @type {AdminCloudtentaclesValidateSessionInput} */ ({})) { + const savedSource = getCloudtentaclesSourceConfig() + const session = await validateCloudtentaclesSession({ + baseUrl: pickFirstNonEmpty([payload.baseUrl, savedSource.baseUrl, 'https://123.207.217.176']), + token: String(payload.token || '').trim(), + deviceId: pickFirstNonEmpty([payload.deviceId, savedSource.deviceId, '-']), + deviceType: payload.deviceType ?? savedSource.deviceType, + }) + + return { + baseUrl: session.baseUrl, + loggedInAt: session.loggedInAt, + session: { + tokenMasked: maskSecret(session.token), + permissionCount: session.permissions.length, + permissions: session.permissions, + }, + userInfo: session.userInfo, + asset: session.asset, + } +} + export async function getAdminFulfillmentBindingConfigs() { const bindings = getOrderFulfillmentBindingConfigs() const rowsResult = await query( @@ -685,6 +812,19 @@ function maskSecret(value) { return `${normalized.slice(0, 6)}****${normalized.slice(-6)}` } +function maskPhone(value) { + const normalized = String(value || '').trim() + if (!normalized) { + return '' + } + + if (normalized.length < 7) { + return normalized + } + + return `${normalized.slice(0, 3)}****${normalized.slice(-4)}` +} + function isPlainObject(value) { return Boolean(value) && typeof value === 'object' && !Array.isArray(value) } diff --git a/apps/backend/src/services/platforms/cloudtentacles/crypto-service.js b/apps/backend/src/services/platforms/cloudtentacles/crypto-service.js new file mode 100644 index 00000000..9c5af2ca --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/crypto-service.js @@ -0,0 +1,65 @@ +// @ts-check + +import { createHash, constants, publicEncrypt } from 'node:crypto' + +import { createHttpError } from '../../../utils/http.js' +import { resolveCloudtentaclesConfig } from './shared.js' + +export function md5CloudtentaclesPassword(password) { + return createHash('md5').update(String(password || ''), 'utf8').digest('hex') +} + +export function encryptCloudtentaclesPayload(payload = {}, options = {}) { + 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 (error) { + throw createHttpError('cloudtentacles 加密失败', { + statusCode: 500, + errorCode: 'cloudtentacles_encrypt_failed', + }) + } +} + +function removeEmptyFields(payload) { + 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/http-client.js b/apps/backend/src/services/platforms/cloudtentacles/http-client.js new file mode 100644 index 00000000..443c26cd --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/http-client.js @@ -0,0 +1,107 @@ +// @ts-check + +import { createHttpError } from '../../../utils/http.js' +import { logInfo } from '../../../utils/logger.js' +import { buildCloudtentaclesHeaders, buildCloudtentaclesUrl, resolveCloudtentaclesConfig } from './shared.js' + +export async function cloudtentaclesRequest(pathname, options = {}) { + const config = resolveCloudtentaclesConfig(options) + const url = buildCloudtentaclesUrl(config.baseUrl, pathname, options.searchParams) + const timeoutMs = Number(options.timeoutMs || config.timeoutMs || 5000) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + 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, + }) + + try { + const response = await fetch(url, { + method, + headers, + body: normalizeRequestBody(options.body, headers['content-type']), + signal: controller.signal, + }) + + const rawText = await response.text() + const payload = tryParseJson(rawText) + + if (!response.ok) { + throw createHttpError(`cloudtentacles 请求失败,HTTP ${response.status}`, { + statusCode: 502, + errorCode: 'cloudtentacles_http_error', + }) + } + + if (options.requireBusinessSuccess !== false && Number(payload?.code ?? 1) !== 0) { + throw createHttpError(String(payload?.message || payload?.msg || 'cloudtentacles 接口返回失败'), { + statusCode: Number(options.businessErrorStatusCode || 400), + errorCode: String(options.businessErrorCode || 'cloudtentacles_business_error'), + }) + } + + logInfo('[cloudtentacles/http]', '请求完成', { + method, + pathname, + status: response.status, + }) + + 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 + } finally { + clearTimeout(timer) + } +} + +function inferContentType(body) { + if (body == null) { + return '' + } + + if (typeof body === 'string' || body instanceof URLSearchParams) { + return 'application/json' + } + + return 'application/json' +} + +function normalizeRequestBody(body, contentType) { + 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) { + try { + return JSON.parse(text) + } catch { + return null + } +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/session-service.js b/apps/backend/src/services/platforms/cloudtentacles/session-service.js new file mode 100644 index 00000000..a8b00ce2 --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/session-service.js @@ -0,0 +1,195 @@ +// @ts-check + +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 './shared.js' + +export async function sendCloudtentaclesSmsCode(payload = {}) { + 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 = {}) { + 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 = {}) { + 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) => String(item || '').trim()).filter(Boolean) + : [], + } +} + +function maskPhone(phone) { + const normalized = String(phone || '').trim() + if (normalized.length < 7) { + return normalized + } + + return `${normalized.slice(0, 3)}****${normalized.slice(-4)}` +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) +} diff --git a/apps/backend/src/services/platforms/cloudtentacles/shared.js b/apps/backend/src/services/platforms/cloudtentacles/shared.js new file mode 100644 index 00000000..b0d45e75 --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/shared.js @@ -0,0 +1,118 @@ +// @ts-check + +import { runtimeConfig } from '../../../config/runtime.js' + +/** @typedef {import('../../../types/runtime-config.js').RuntimeConfig} RuntimeConfig */ + +export function resolveCloudtentaclesConfig(overrides = {}) { + /** @type {RuntimeConfig['platforms']['cloudtentacles']} */ + 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', + publicKeyPem: '', + clientSource: 'ct-client', + deviceId: '-', + deviceType: 0, + } + + 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'), + publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem), + clientSource: String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || 'ct-client', + deviceId: String(overrides.deviceId ?? baseConfig.deviceId ?? '-').trim() || '-', + deviceType: normalizeInteger(overrides.deviceType ?? baseConfig.deviceType, 0), + } +} + +export function buildCloudtentaclesUrl(baseUrl, pathname, searchParams = 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 = '-', + deviceType = 0, + extra = {}, +} = {}) { + const headers = { + accept: 'application/json, text/plain, */*', + ...(contentType ? { 'content-type': contentType } : {}), + deviceid: String(deviceId || '-').trim() || '-', + devicetype: String(Number(deviceType || 0)), + ...normalizeHeaderMap(extra), + } + + const normalizedToken = String(token || '').trim() + if (normalizedToken) { + headers.authorization = normalizedToken + } + + return headers +} + +function normalizeBaseUrl(value) { + return String(value || '').trim().replace(/\/+$/, '') +} + +function normalizePath(value, fallback) { + const normalized = String(value || '').trim() + if (!normalized) { + return fallback + } + + return normalized.startsWith('/') ? normalized : `/${normalized}` +} + +function normalizePositiveInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback +} + +function normalizeInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +function normalizeHeaderMap(extra) { + 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) { + 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/source-config-service.js b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.js new file mode 100644 index 00000000..d0b48016 --- /dev/null +++ b/apps/backend/src/services/platforms/cloudtentacles/source-config-service.js @@ -0,0 +1,71 @@ +// @ts-check + +import fs from 'node:fs' +import path from 'node:path' + +import { PROJECT_ROOT } from '../../../config/runtime.js' + +const CLOUDTENTACLES_SOURCES_FILE_PATH = path.join(PROJECT_ROOT, 'data', 'cloudtentacles-sources.json') + +export function getCloudtentaclesSourcesFilePath() { + return CLOUDTENTACLES_SOURCES_FILE_PATH +} + +export function getCloudtentaclesSourceConfig() { + return loadCloudtentaclesSourceConfigFromFile() +} + +export function saveCloudtentaclesSourceConfig(rawValue) { + const normalized = normalizeCloudtentaclesSourceConfig(rawValue) + fs.mkdirSync(path.dirname(CLOUDTENTACLES_SOURCES_FILE_PATH), { recursive: true }) + fs.writeFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') + return normalized +} + +function loadCloudtentaclesSourceConfigFromFile() { + if (!fs.existsSync(CLOUDTENTACLES_SOURCES_FILE_PATH)) { + return createDefaultCloudtentaclesSourceConfig() + } + + try { + const rawText = fs.readFileSync(CLOUDTENTACLES_SOURCES_FILE_PATH, 'utf8') + return normalizeCloudtentaclesSourceConfig(JSON.parse(rawText)) + } catch { + return createDefaultCloudtentaclesSourceConfig() + } +} + +function normalizeCloudtentaclesSourceConfig(rawValue) { + const source = isPlainObject(rawValue) ? rawValue : {} + + return { + enabled: typeof source.enabled === 'boolean' ? source.enabled : true, + 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: String(source.deviceId || '-').trim() || '-', + deviceType: normalizeInteger(source.deviceType, 0), + } +} + +function createDefaultCloudtentaclesSourceConfig() { + return { + enabled: true, + baseUrl: 'https://123.207.217.176', + username: '', + password: '', + phone: '', + deviceId: '-', + deviceType: 0, + } +} + +function normalizeInteger(value, fallback) { + const parsed = Number(value) + return Number.isInteger(parsed) ? parsed : fallback +} + +function isPlainObject(value) { + return Object.prototype.toString.call(value) === '[object Object]' +} diff --git a/apps/backend/src/types/admin-route-inputs.js b/apps/backend/src/types/admin-route-inputs.js index 10233076..4aa31ab4 100644 --- a/apps/backend/src/types/admin-route-inputs.js +++ b/apps/backend/src/types/admin-route-inputs.js @@ -46,6 +46,22 @@ export {} * @typedef {import('./admin-write-inputs.js').AdminKhhaoOrderQueryInput} AdminKhhaoOrderQueryRouteBody */ +/** + * @typedef {import('./admin-write-inputs.js').AdminCloudtentaclesSourceConfigInput} AdminCloudtentaclesSourceConfigRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminCloudtentaclesSendSmsCodeInput} AdminCloudtentaclesSendSmsCodeRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminCloudtentaclesTestLoginInput} AdminCloudtentaclesTestLoginRouteBody + */ + +/** + * @typedef {import('./admin-write-inputs.js').AdminCloudtentaclesValidateSessionInput} AdminCloudtentaclesValidateSessionRouteBody + */ + /** * @typedef {import('./admin-write-inputs.js').AdminFulfillmentBindingConfigSaveInput} AdminFulfillmentBindingConfigRouteBody */ diff --git a/apps/backend/src/types/admin-write-inputs.js b/apps/backend/src/types/admin-write-inputs.js index e172b73a..729f0142 100644 --- a/apps/backend/src/types/admin-write-inputs.js +++ b/apps/backend/src/types/admin-write-inputs.js @@ -104,6 +104,49 @@ export {} * }} AdminKhhaoOrderQueryInput */ +/** + * @typedef {{ + * enabled?: boolean + * baseUrl?: string + * username?: string + * password?: string + * phone?: string + * deviceId?: string + * deviceType?: number | string + * }} AdminCloudtentaclesSourceConfigInput + */ + +/** + * @typedef {{ + * baseUrl?: string + * username?: string + * phone?: string + * deviceId?: string + * deviceType?: number | string + * }} AdminCloudtentaclesSendSmsCodeInput + */ + +/** + * @typedef {{ + * baseUrl?: string + * username?: string + * password?: string + * phone?: string + * code?: string + * deviceId?: string + * deviceType?: number | string + * }} AdminCloudtentaclesTestLoginInput + */ + +/** + * @typedef {{ + * baseUrl?: string + * token?: string + * deviceId?: string + * deviceType?: number | string + * }} AdminCloudtentaclesValidateSessionInput + */ + /** * @typedef {{ * provider?: string diff --git a/apps/backend/src/types/runtime-config.js b/apps/backend/src/types/runtime-config.js index 4d7c6586..424d99d8 100644 --- a/apps/backend/src/types/runtime-config.js +++ b/apps/backend/src/types/runtime-config.js @@ -96,6 +96,19 @@ export {} * captchaPath: string * orderListPath: string * } + * cloudtentacles: { + * baseUrl: string + * timeoutMs: number + * sendSmsPath: string + * loginPath: string + * userInfoPath: string + * assetPath: string + * permissionPath: string + * publicKeyPem: string + * clientSource: string + * deviceId: string + * deviceType: number + * } * } * redeem: { * proofMode: string diff --git a/apps/frontend/src/services/admin.ts b/apps/frontend/src/services/admin.ts index 162104b7..6693161e 100644 --- a/apps/frontend/src/services/admin.ts +++ b/apps/frontend/src/services/admin.ts @@ -4,6 +4,10 @@ import type { AdminAgisoObservedShopItem, AdminAgisoShopConfigItem, AdminAuditLogItem, + AdminCloudtentaclesLoginTestResult, + AdminCloudtentaclesSendSmsResult, + AdminCloudtentaclesSourceConfig, + AdminCloudtentaclesValidateSessionResult, AdminFulfillmentLookupResult, AdminFulfillmentBindingConfigItem, AdminKhhaoLoginTestResult, @@ -145,6 +149,51 @@ export function syncAdminKhhaoOrders(payload: { return apiPost('/api/v1/admin/platform-config/khhao/sync-orders', payload) } +export function fetchAdminCloudtentaclesSourceConfig() { + return apiGet<{ + filePath: string + source: AdminCloudtentaclesSourceConfig + }>('/api/v1/admin/platform-config/cloudtentacles-source') +} + +export function saveAdminCloudtentaclesSourceConfig(payload: AdminCloudtentaclesSourceConfig) { + return apiPost<{ + filePath: string + source: AdminCloudtentaclesSourceConfig + }>('/api/v1/admin/platform-config/cloudtentacles-source', payload as unknown as Record) +} + +export function sendAdminCloudtentaclesSmsCode(payload: { + baseUrl?: string + username?: string + phone?: string + deviceId?: string + deviceType?: number +}) { + 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 +}) { + return apiPost('/api/v1/admin/platform-config/cloudtentacles/test-login', payload) +} + +export function validateAdminCloudtentaclesSession(payload: { + baseUrl?: string + token?: string + deviceId?: string + deviceType?: number +}) { + return apiPost('/api/v1/admin/platform-config/cloudtentacles/validate-session', payload) +} + export function fetchAdminFulfillmentBindingConfigs() { return apiGet<{ filePath: string diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index 31693748..cb9a9d9d 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -175,6 +175,51 @@ export interface AdminKhhaoOrderSyncResult { results: AdminKhhaoOrderSyncItem[] } +export interface AdminCloudtentaclesSourceConfig { + enabled: boolean + baseUrl: string + username: string + password: string + phone: string + deviceId: string + deviceType: number +} + +export interface AdminCloudtentaclesSendSmsResult { + baseUrl: string + username: string + phone: string + phoneMasked: string + sentAt: string + responseMessage: string +} + +export interface AdminCloudtentaclesSessionSummary { + tokenMasked: string + permissionCount: number + permissions: string[] +} + +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 AdminFulfillmentBindingConfigItem { provider: string platform: string diff --git a/apps/frontend/src/views/admin/AdminPlatformShopsView.vue b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue index 9f8462d9..7dd55943 100644 --- a/apps/frontend/src/views/admin/AdminPlatformShopsView.vue +++ b/apps/frontend/src/views/admin/AdminPlatformShopsView.vue @@ -4,17 +4,26 @@ import { computed, onMounted, ref } from 'vue' import { showError, showSuccess } from '@/lib/feedback' import { fetchAdminAgisoShopConfigs, + fetchAdminCloudtentaclesSourceConfig, fetchAdminKhhaoSourceConfig, queryAdminKhhaoOrders, + saveAdminCloudtentaclesSourceConfig, saveAdminAgisoShopConfigs, saveAdminKhhaoSourceConfig, + sendAdminCloudtentaclesSmsCode, syncAdminKhhaoOrders, + testAdminCloudtentaclesLogin, testAdminKhhaoLogin, + validateAdminCloudtentaclesSession, } from '@/services/admin' import type { AdminAgisoMessagingDefaults, AdminAgisoObservedShopItem, AdminAgisoShopConfigItem, + AdminCloudtentaclesLoginTestResult, + AdminCloudtentaclesSendSmsResult, + AdminCloudtentaclesSourceConfig, + AdminCloudtentaclesValidateSessionResult, AdminKhhaoLoginTestResult, AdminKhhaoOrderQueryResult, AdminKhhaoSyncState, @@ -23,7 +32,7 @@ import type { import { hasAdminRole } from '@/utils/admin-auth' import { formatAdminDateTime } from '@/utils/admin-time' -type PlatformTab = 'agiso' | 'khhao' +type PlatformTab = 'agiso' | 'khhao' | 'cloudtentacles' type EditableDefaults = { messageTemplate: string @@ -43,12 +52,14 @@ type EditableShop = { const loading = ref(true) const saving = ref(false) const khhaoSaving = ref(false) +const cloudtentaclesSaving = ref(false) const errorMessage = ref('') const activePlatform = ref('agiso') const agisoFilePath = ref('') const khhaoFilePath = ref('') const khhaoStateFilePath = ref('') +const cloudtentaclesFilePath = ref('') const defaults = ref(createEmptyDefaults()) const shops = ref([]) const observedShops = ref([]) @@ -74,6 +85,23 @@ const khhaoLoginResult = ref(null) const khhaoQueryResult = ref(null) const khhaoSyncResult = ref(null) const khhaoAutoSyncState = ref(null) +const cloudtentaclesForm = ref({ + baseUrl: 'https://123.207.217.176', + username: '', + password: '', + phone: '', + code: '', + token: '', + deviceId: '-', + deviceType: 0, +}) +const cloudtentaclesSendingSms = ref(false) +const cloudtentaclesTesting = ref(false) +const cloudtentaclesValidating = ref(false) +const cloudtentaclesResultError = ref('') +const cloudtentaclesSmsResult = ref(null) +const cloudtentaclesLoginResult = ref(null) +const cloudtentaclesValidateResult = ref(null) const agisoStats = computed(() => ({ configuredShopCount: shops.value.length, @@ -88,6 +116,16 @@ const khhaoStats = computed(() => ({ ignoredCount: khhaoAutoSyncState.value?.ignoredCount || khhaoSyncResult.value?.ignoredCount || 0, })) +const cloudtentaclesStats = computed(() => ({ + hasCredential: Boolean( + cloudtentaclesForm.value.username.trim() + && cloudtentaclesForm.value.password.trim() + && cloudtentaclesForm.value.phone.trim(), + ), + smsSent: Boolean(cloudtentaclesSmsResult.value), + validated: Boolean(cloudtentaclesValidateResult.value || cloudtentaclesLoginResult.value), +})) + function createEmptyDefaults(): EditableDefaults { return { messageTemplate: '', @@ -136,9 +174,10 @@ async function loadConfigs() { errorMessage.value = '' try { - const [agisoResponse, khhaoResponse] = await Promise.all([ + const [agisoResponse, khhaoResponse, cloudtentaclesResponse] = await Promise.all([ fetchAdminAgisoShopConfigs(), fetchAdminKhhaoSourceConfig(), + fetchAdminCloudtentaclesSourceConfig(), ]) agisoFilePath.value = agisoResponse.data.filePath @@ -162,6 +201,18 @@ async function loadConfigs() { autoSyncPages: khhaoResponse.data.source.autoSync?.pages || 2, autoSyncPageSize: khhaoResponse.data.source.autoSync?.pageSize || 20, } + + cloudtentaclesFilePath.value = cloudtentaclesResponse.data.filePath + cloudtentaclesForm.value = { + baseUrl: cloudtentaclesResponse.data.source.baseUrl || 'https://123.207.217.176', + username: cloudtentaclesResponse.data.source.username || '', + password: cloudtentaclesResponse.data.source.password || '', + phone: cloudtentaclesResponse.data.source.phone || '', + code: '', + token: '', + deviceId: cloudtentaclesResponse.data.source.deviceId || '-', + deviceType: cloudtentaclesResponse.data.source.deviceType || 0, + } } catch (error) { errorMessage.value = error instanceof Error ? error.message : '读取平台配置失败' } finally { @@ -389,6 +440,135 @@ async function handleKhhaoSyncOrders() { } } +function buildCloudtentaclesConfigPayload(): AdminCloudtentaclesSourceConfig { + return { + enabled: true, + baseUrl: cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176', + username: cloudtentaclesForm.value.username.trim(), + password: cloudtentaclesForm.value.password.trim(), + phone: cloudtentaclesForm.value.phone.trim(), + deviceId: cloudtentaclesForm.value.deviceId.trim() || '-', + deviceType: Number(cloudtentaclesForm.value.deviceType || 0), + } +} + +function buildCloudtentaclesLoginPayload() { + return { + ...buildCloudtentaclesConfigPayload(), + code: cloudtentaclesForm.value.code.trim(), + } +} + +function ensureCloudtentaclesBaseFields() { + if (!cloudtentaclesForm.value.username.trim() || !cloudtentaclesForm.value.phone.trim()) { + cloudtentaclesResultError.value = '请先填写 cloudtentacles 账号和手机号' + return false + } + return true +} + +function ensureCloudtentaclesLoginFields() { + if ( + !cloudtentaclesForm.value.username.trim() + || !cloudtentaclesForm.value.password.trim() + || !cloudtentaclesForm.value.phone.trim() + ) { + cloudtentaclesResultError.value = '请先填写 cloudtentacles 账号、密码和手机号' + return false + } + + if (!cloudtentaclesForm.value.code.trim()) { + cloudtentaclesResultError.value = '请先填写短信验证码' + return false + } + + return true +} + +async function handleCloudtentaclesSaveSource() { + cloudtentaclesSaving.value = true + cloudtentaclesResultError.value = '' + + try { + const response = await saveAdminCloudtentaclesSourceConfig(buildCloudtentaclesConfigPayload()) + cloudtentaclesFilePath.value = response.data.filePath + showSuccess('cloudtentacles 履约平台配置已保存') + } catch (error) { + cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 配置保存失败' + showError(cloudtentaclesResultError.value) + } finally { + cloudtentaclesSaving.value = false + } +} + +async function handleCloudtentaclesSendSmsCode() { + if (!ensureCloudtentaclesBaseFields()) { + return + } + + cloudtentaclesSendingSms.value = true + cloudtentaclesResultError.value = '' + + try { + const response = await sendAdminCloudtentaclesSmsCode(buildCloudtentaclesConfigPayload()) + cloudtentaclesSmsResult.value = response.data + showSuccess('cloudtentacles 短信验证码已发送') + } catch (error) { + cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 发送短信验证码失败' + showError(cloudtentaclesResultError.value) + } finally { + cloudtentaclesSendingSms.value = false + } +} + +async function handleCloudtentaclesTestLogin() { + if (!ensureCloudtentaclesLoginFields()) { + return + } + + cloudtentaclesTesting.value = true + cloudtentaclesResultError.value = '' + + try { + const response = await testAdminCloudtentaclesLogin(buildCloudtentaclesLoginPayload()) + cloudtentaclesLoginResult.value = response.data + cloudtentaclesForm.value.token = response.data.token || cloudtentaclesForm.value.token + showSuccess('cloudtentacles 登录测试成功') + } catch (error) { + cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 登录测试失败' + showError(cloudtentaclesResultError.value) + } finally { + cloudtentaclesTesting.value = false + } +} + +async function handleCloudtentaclesValidateSession() { + const token = cloudtentaclesForm.value.token.trim() + if (!token) { + cloudtentaclesResultError.value = '请先填写 token' + return + } + + cloudtentaclesValidating.value = true + cloudtentaclesResultError.value = '' + + try { + const response = await validateAdminCloudtentaclesSession({ + baseUrl: cloudtentaclesForm.value.baseUrl.trim() || 'https://123.207.217.176', + token, + deviceId: cloudtentaclesForm.value.deviceId.trim() || '-', + deviceType: Number(cloudtentaclesForm.value.deviceType || 0), + }) + cloudtentaclesValidateResult.value = response.data + showSuccess('cloudtentacles 会话校验成功') + } catch (error) { + cloudtentaclesResultError.value = error instanceof Error ? error.message : 'cloudtentacles 会话校验失败' + showError(cloudtentaclesResultError.value) + } finally { + cloudtentaclesValidating.value = false + } +} + onMounted(loadConfigs) @@ -397,7 +577,7 @@ onMounted(loadConfigs)

平台配置

-

按平台分别管理来源接入、凭据与店铺能力。Agiso 负责 webhook / 店铺消息配置,khhao 负责账号登录拉单与同步。

+

按平台分别管理来源接入、凭据与店铺能力。Agiso 负责 webhook / 店铺消息配置,khhao 负责来源拉单,cloudtentacles 负责外部履约平台登录。

刷新全部配置 @@ -446,6 +626,23 @@ onMounted(loadConfigs) 最近同步成功 {{ khhaoStats.syncedCount }} 条
+ +
@@ -463,6 +660,13 @@ onMounted(loadConfigs) > khhao 来源与同步 +
- @@ -906,7 +1240,7 @@ onMounted(loadConfigs) .overview-grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; } diff --git a/docs/app信息.md b/docs/app信息.md index 71f67bc0..e0cf2c94 100644 --- a/docs/app信息.md +++ b/docs/app信息.md @@ -74,4 +74,11 @@ https://221329.cc.cd/api/v1/webhooks/agiso/trade ---- 服务器 https://order.khhao.com/api/v1/webhooks/agiso/trade -调试 https://926d-193-176-84-20.ngrok-free.app/api/v1/webhooks/agiso/trade \ No newline at end of file +调试 https://926d-193-176-84-20.ngrok-free.app/api/v1/webhooks/agiso/trade + + + +========== +https://123.207.217.176/#/home 发货 +账号 17665234375 密码 yaochao11 + diff --git a/docs/cloudtentacles发货平台接入设计.md b/docs/cloudtentacles发货平台接入设计.md new file mode 100644 index 00000000..223c9417 --- /dev/null +++ b/docs/cloudtentacles发货平台接入设计.md @@ -0,0 +1,733 @@ +# cloudtentacles 外部履约平台接入设计 + +## 1. 背景 + +当前项目里现有链路大致分两层: + +- **订单来源层** + - `agiso`:Webhook 推送型; + - `khhao`:账号密码登录 + 验证码识别 + Cookie 会话拉取型。 +- **履约执行层** + - 当前以内置人工发货、腾讯领取兑换等执行器为主。 + +现在新增一个发货相关的平台,当前已知入口为: + +- `https://123.207.217.176` +- 前端资源来自 `gp.playinjoy.com` +- 前端代码里根实例名显示为 `cloudtentacles` + +这个平台**不是订单数据源**,而是**订单创建后用于执行发货/兑换的外部履约平台**。 +它和 `khhao` 的相同点只是“都需要后台登录”,但系统角色完全不同: + +- `khhao`:订单来源 / 上游平台; +- `cloudtentacles`:履约执行 / 下游发货平台。 + +它的登录链路与 `khhao` 明显不同: + +- 不是图片验证码识别; +- 是“账号 + 密码 + 手机号 + 短信验证码”登录; +- 登录参数不是明文提交,而是前端先做 `MD5 + RSA-OAEP(SHA-256)` 加密; +- 登录成功后不是 Cookie 会话,而是返回 `token`,后续通过 `Authorization` 请求头访问接口。 + +因此它应当作为**履约执行器能力**接入,复用 `khhao` 的“HTTP 登录平台”经验,但不要按“来源平台”建模。 + +--- + +## 2. 当前已确认事实 + +### 2.1 证据来源 + +- HAR:`/Users/yml/Desktop/抓包/123.207.217.176.har` +- 前端代码:`tems/test1.js` +- 打包代码:`tems/index.93fe093c.js` + +### 2.2 已确认接口链路 + +1. 发送短信验证码 + `POST https://123.207.217.176/public/verif_code` + +2. 提交登录 + `POST https://123.207.217.176/public/login` + +3. 登录后探活接口 + - `POST /user/info` + - `GET /user/get_asset` + - `GET /user/get_permission` + +4. 当前 HAR 里还看到的业务接口 + - `GET /categories/get` + - `GET /sku/list` + +### 2.3 登录态机制 + +不是依赖 Cookie。 + +前端在登录成功后会把 `/public/login` 返回的 `data` 当成 token 保存,并在后续请求里自动带上: + +```http +Authorization: +deviceid: - +devicetype: 0 +``` + +### 2.4 登录参数的真实结构 + +根据 `tems/test1.js`,发验证码与登录都不是直接提交原始字段,而是先调用 `encryptWithPublicKey(...)`。 + +#### 发验证码原始参数 + +```js +{ + account: username, + phone: phone +} +``` + +#### 登录原始参数 + +```js +{ + account: username, + password: MD5(password), + phone: phone, + code: smsCode +} +``` + +#### 加密前最终明文 + +```js +{ + t: Date.now(), + r: Math.random().toString(16), + s: 'ct-client', + ...payload +} +``` + +然后: + +1. `JSON.stringify` +2. 使用 RSA-OAEP + SHA-256 公钥加密 +3. Base64 编码,作为 `covert` + +最终请求体: + +```json +{ + "covert": "", + "t": 1777726021956, + "r": "0.8601863c152d6" +} +``` + +### 2.5 密码处理 + +密码不是明文传输。 + +前端代码中: + +```js +password: cu(password) +``` + +而 `cu` 实际是: + +```js +MD5(password).toString(Hex) +``` + +即: + +- 先 `MD5` +- 再参与 RSA 加密 + +--- + +## 3. 设计目标 + +## 3.1 第一阶段目标 + +这一版只做“基础登录能力”,不直接做完整发货自动化: + +1. 能发送短信验证码; +2. 能使用账号、密码、手机号、短信验证码自动登录; +3. 能持久化保存该平台的履约配置; +4. 能在后台手动测试登录; +5. 能在登录后调用一个轻量探活接口确认登录成功; +6. 输出统一的会话对象,给后续“SKU 查询 / 背包读取 / 发货执行”复用。 + +## 3.2 第二阶段目标 + +后续逐步补: + +1. token 缓存与自动续登; +2. SKU/分类/背包读取; +3. 发货动作封装; +4. 发货结果记录与截图/证据保存; +5. 接成新的 `executor_key`,纳入现有履约任务执行链路; +6. 由“订单支付成功 / 任务就绪”触发自动发货。 + +--- + +## 4. 关键设计决策 + +## 4.1 领域定位 + +这里不应该新增 `provider = 'cloudtentacles'` 作为订单来源标识。 + +更合理的定位是: + +- 订单来源 `provider/platform/shopId` 仍然来自 `agiso` / `khhao` +- `cloudtentacles` 属于**履约执行器 / 外部发货平台** + +因此它更适合进入这条链路: + +- `fulfillment_profiles.profile_key` +- `fulfillment_profiles.executor_key` +- 任务执行服务 + +建议后续新增类似: + +- `profileKey = 'cloudtentacles_dispatch'` +- `executorKey = 'cloudtentacles_dispatch'` + +而不是把它混进订单来源层。 + +## 4.2 接入模式 + +这个平台本质上是 **外部履约平台 + token 会话** 模式。 + +因此结构上可以先放在 `platforms/` 下管理其登录与 API 封装,但业务接入点要落到**履约执行层**: + +- 配置服务 +- 加密服务 +- HTTP 客户端 +- 登录/会话服务 +- 发货业务服务 +- 履约执行适配层 + +## 4.3 不建议直接照抄 khhao 的原因 + +`khhao` 当前链路核心是: + +- 打开登录页 +- 拉验证码图片 +- OCR +- 表单提交 +- Cookie 维持会话 + +而当前平台核心是: + +- 组装明文 +- MD5 密码 +- RSA 公钥加密 +- 请求登录接口 +- token 维持会话 + +所以如果把所有逻辑都塞进一个 `session-service.js`,后面会越来越乱。 +更合理的方式是把“加密”、“请求头”、“token 管理”单独拆开。 + +## 4.4 第一版不引入浏览器自动化 + +当前证据显示不需要浏览器: + +- HAR 中登录接口是纯 XHR; +- 参数生成逻辑已经从 JS 中还原; +- token 机制清晰; +- 没看到依赖浏览器页面上下文的动态签名。 + +因此第一版应坚持 **纯 HTTP 实现**。 + +只有后续遇到这些问题时,才考虑浏览器方案: + +- 服务端增加不可还原的前端签名; +- 短信验证码流程强依赖页面态; +- 登录成功但服务端对业务接口做浏览器环境校验。 + +--- + +## 5. 推荐目录结构 + +建议新增目录: + +`apps/backend/src/services/platforms/cloudtentacles/` + +同时第二阶段补一个执行适配入口,例如: + +`apps/backend/src/services/fulfillment/cloudtentacles-dispatch-service.js` + +### 5.1 第一阶段实际落地文件 + +#### `shared.js` + +职责: + +- 读取运行时配置; +- 规范化 baseUrl / 路径 / timeout; +- 构建 URL; +- 构建公共请求头; +- 统一设备头默认值。 + +建议内容: + +- `resolveCloudtentaclesConfig(overrides)` +- `buildCloudtentaclesUrl(baseUrl, pathname, searchParams?)` +- `buildCloudtentaclesHeaders({ token?, contentType? })` + +#### `crypto-service.js` + +职责: + +- 密码 MD5; +- 公钥 PEM 转 ArrayBuffer / Buffer; +- 组装 `t/r/s` 明文; +- 执行 RSA-OAEP(SHA-256) 加密; +- 返回 `{ covert, t, r }` + +建议暴露: + +- `md5CloudtentaclesPassword(password)` +- `encryptCloudtentaclesPayload(payload, options?)` + +这样后续不仅登录能用,发验证码、改密等其它加密接口也都能复用。 + +#### `http-client.js` + +职责: + +- 对 `fetch` 做一层轻封装; +- 统一 timeout; +- 自动带 `Authorization / deviceid / devicetype`; +- 统一解析平台响应格式; +- 统一抛出带平台上下文的错误。 + +建议暴露: + +- `cloudtentaclesRequest(path, options)` + +注意:第一版不用做成全局通用 HTTP 框架,只服务这个平台即可。 + +#### `session-service.js` + +职责: + +- 发送短信验证码; +- 使用短信验证码登录; +- 登录后调用 `/user/info` 验证会话; +- 返回标准会话对象。 + +建议暴露: + +- `sendCloudtentaclesSmsCode(payload)` +- `loginCloudtentaclesSession(payload)` +- `validateCloudtentaclesSession(payload)` + +建议会话对象: + +```js +{ + baseUrl, + token, + loggedInAt, + username, + phone, + userInfo, + permissions +} +``` + +#### `source-config-service.js` + +职责: + +- 读写履约平台配置文件; +- 管理默认账号、密码、手机号; +- 管理后续自动化开关预留字段。 + +建议文件: + +`apps/backend/data/cloudtentacles-sources.json` + +建议结构: + +```json +{ + "enabled": true, + "baseUrl": "https://123.207.217.176", + "username": "", + "password": "", + "phone": "", + "deviceId": "-", + "deviceType": 0 +} +``` + +### 5.2 第二阶段预留文件 + +这些先不一定创建实现,但目录命名先按这个方向约束: + +#### `catalog-service.js` + +- 分类 +- SKU +- 背包 +- 库存类读取 + +#### `delivery-service.js` + +- 发货动作 +- CDK / 虚拟物品使用 +- 回滚 / 失败重试 + +#### `record-service.js` + +- 交易记录 +- 发货记录 +- 订单对账 + +#### `auto-sync-service.js` + +- 定时探活 +- 自动续登 +- 预热会话 + +#### `apps/backend/src/services/fulfillment/cloudtentacles-dispatch-service.js` + +职责: + +- 接收履约任务; +- 读取任务绑定的库存 / 凭据; +- 调用 `platforms/cloudtentacles/*` 完成实际发货; +- 回写任务状态、发货结果、证据。 + +--- + +## 6. 配置设计 + +## 6.1 运行时配置 + +建议在: + +- `apps/backend/config/default.cjs` +- `apps/backend/src/types/runtime-config.js` +- `apps/backend/src/config/runtime.js` + +增加: + +```js +platforms: { + 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', + publicKeyPem: '-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----', + clientSource: 'ct-client', + deviceId: '-', + deviceType: 0, + }, +} +``` + +### 设计说明 + +- `publicKeyPem` 放运行时配置,而不是硬编码到 service; +- `clientSource` 默认是 `ct-client`; +- `deviceId / deviceType` 先允许配置,避免后面服务端开始校验时需要改代码。 + +## 6.2 持久化来源配置 + +建议和 `khhao` 一样,先用数据文件落地,不急着进数据库。 + +建议文件: + +`apps/backend/data/cloudtentacles-sources.json` + +建议第一版结构: + +```json +{ + "enabled": true, + "baseUrl": "https://123.207.217.176", + "username": "", + "password": "", + "phone": "", + "deviceId": "-", + "deviceType": 0 +} +``` + +后续如果要支持多套账号,再演进为: + +```json +{ + "sources": [ + { + "sourceKey": "cloudtentacles-main", + "enabled": true, + "baseUrl": "https://123.207.217.176", + "username": "", + "password": "", + "phone": "", + "deviceId": "-", + "deviceType": 0 + } + ] +} +``` + +第一版先单来源,能明显降低 UI 和逻辑复杂度。 + +--- + +## 7. API 设计建议 + +建议先在管理后台补一组最小接口: + +### 7.1 配置读取 + +```http +GET /api/v1/admin/platform-config/cloudtentacles-source +``` + +返回: + +- 配置文件路径 +- 当前履约平台配置 + +### 7.2 配置保存 + +```http +POST /api/v1/admin/platform-config/cloudtentacles-source +``` + +用于保存: + +- 账号 +- 密码 +- 手机号 +- baseUrl +- 设备头默认值 + +### 7.3 发送短信验证码 + +```http +POST /api/v1/admin/platforms/cloudtentacles/send-sms-code +``` + +入参支持: + +- 使用已保存配置; +- 或临时覆盖账号/手机号/baseUrl。 + +### 7.4 手动登录测试 + +```http +POST /api/v1/admin/platforms/cloudtentacles/login +``` + +入参: + +- `username` +- `password` +- `phone` +- `code` + +返回: + +- `token` +- `userInfo` +- `permissions` +- `loggedInAt` + +### 7.5 会话探活测试 + +```http +POST /api/v1/admin/platforms/cloudtentacles/validate-session +``` + +入参: + +- `token` + +返回: + +- 是否有效 +- 用户信息 + +--- + +## 8. 后台 UI 设计建议 + +第一版 UI 不需要太复杂,但应与 `khhao` 分开,避免把“订单来源配置”和“履约平台配置”混在一起。 + +建议在“平台配置”页新增独立区块,归类到“履约平台”分组: + +### 8.1 履约平台配置区 + +字段: + +- 启用状态 +- baseUrl +- 账号 +- 密码 +- 手机号 +- deviceId +- deviceType + +操作: + +- 保存配置 + +### 8.2 登录测试区 + +字段: + +- 短信验证码输入框 + +操作: + +- 发送验证码 +- 执行登录 +- 验证当前 token + +### 8.3 调试结果区 + +显示: + +- token 是否返回 +- userInfo +- permissions +- 最近一次错误消息 + +注意: + +- 不要把这个平台和 `khhao` 放在同一张混合表单里; +- 页面上要明确区分: + - 订单来源平台 + - 履约执行平台 +- 后面平台越来越多时,这个结构才能撑住。 + +--- + +## 9. 错误处理与日志建议 + +建议统一给这个平台加明确日志前缀: + +- `[cloudtentacles/crypto]` +- `[cloudtentacles/session]` +- `[cloudtentacles/http]` + +第一版重点记录: + +1. 发送验证码是否成功; +2. 登录是否成功; +3. `/user/info` 探活是否成功; +4. 平台返回的 `code/message`; +5. 不记录明文密码、短信验证码、完整 token。 + +建议在日志和接口返回里都做脱敏: + +- 手机号只显示部分; +- token 只显示前后几位; +- password 永不回显。 + +--- + +## 10. 与现有履约架构的关系 + +当前项目里的履约主链路已经是: + +1. 订单进入系统; +2. 根据 `provider/platform/shopId/sku` 命中 `order-fulfillment-bindings.json`; +3. 解析到 `fulfillment_profile`; +4. 创建 `delivery task`; +5. 根据 `executor_key` 决定后续执行方式。 + +所以 `cloudtentacles` 后续正确的接入位置是: + +- 第一阶段:只补平台登录能力; +- 第二阶段:新增 `cloudtentacles_dispatch` 执行器; +- 第三阶段:把某些 SKU 的履约绑定切到这个执行器。 + +也就是说: + +- `agiso/khhao` 决定“订单从哪里来” +- `cloudtentacles` 决定“订单怎么发出去” + +--- + +## 11. 实施顺序 + +建议按这个顺序落地: + +### 第 1 步:后端基础模块 + +先实现: + +- `shared.js` +- `crypto-service.js` +- `http-client.js` +- `session-service.js` +- `source-config-service.js` + +目标: + +- 本地可完成发送验证码; +- 收到短信后可手动输入验证码登录; +- 登录后可拉 `/user/info`。 + +### 第 2 步:后台接口 + +增加: + +- 配置读取/保存 +- 发送验证码 +- 登录测试 +- token 探活 + +### 第 3 步:后台 UI + +增加: + +- 单独的平台配置区 +- 发送验证码与登录测试区 +- 调试反馈区 + +### 第 4 步:后续业务能力 + +登录能力稳定后,再补: + +- SKU/分类/背包读取 +- 发货动作 +- `cloudtentacles_dispatch` 执行器 +- 自动发货链路 + +--- + +## 12. 当前阶段结论 + +这个平台不是新的订单数据源,而是新的**外部履约能力**。 + +第一版接入重点不是立刻改主流程,而是先把 **可复用的登录能力** 做扎实。 + +最重要的不是把接口先堆进去,而是先把结构定对: + +- `khhao` 型平台:验证码图片 + Cookie +- `cloudtentacles` 型平台:短信码 + RSA 加密 + token + +二者都属于“后台登录型平台”,但在系统分层上不同: + +- `khhao`:来源层 +- `cloudtentacles`:履约层 + +因此第一版推荐结论是: + +1. 作为独立履约平台能力接入; +2. 先做“配置 + 发验证码 + 登录 + 探活”; +3. 目录结构按“配置 / 加密 / HTTP / 会话 / 业务”拆开; +4. 后续通过新的 `executor_key` 接入履约任务执行,而不是改订单来源模型。 diff --git a/tems/test1.js b/tems/test1.js new file mode 100644 index 00000000..fac0ecd8 --- /dev/null +++ b/tems/test1.js @@ -0,0 +1,563 @@ + $s = class { + constructor() { + gl(this, "root_url", "https://www.cloudtentacles.com"); + gl(this, "cache", new Map) + } + static getInstance() { + return $s.instance || ($s.instance = new $s) + } + PemToArrayBuffer(t) { + const r = t.split(` +`).filter(s => s.trim() && !s.includes("---")).join("") + , a = window.atob(r) + , o = new Uint8Array(a.length); + for (let s = 0; s < a.length; s++) + o[s] = a.charCodeAt(s); + return o.buffer + } + async encryptWithPublicKey(t) { + for (let s in t) + (t[s] === void 0 || t[s] === null || t[s] === "") && delete t[s]; + const n = Date.now() + , r = Math.random().toString(16) + , a = "ct-client" + , o = JSON.stringify(Object.assign({ + t: n, + r, + s: a + }, t)); + try { + if (window.crypto && window.crypto.subtle && window.crypto.subtle.importKey) { + const i = await window.crypto.subtle.importKey("spki", this.PemToArrayBuffer($s.PublicKey), { + name: "RSA-OAEP", + hash: { + name: "SHA-256" + } + }, !0, ["encrypt"]) + , u = new TextEncoder().encode(o) + , c = await window.crypto.subtle.encrypt({ + name: "RSA-OAEP" + }, i, u) + , f = new Uint8Array(c); + let p = ""; + for (let h = 0; h < f.byteLength; h++) + p += String.fromCharCode(f[h]); + return { + covert: window.btoa(p), + t: n, + r + } + } + console.warn("\u5F53\u524D\u5904\u4E8E\u975E\u5B89\u5168\u73AF\u5883\u6216\u6D4F\u89C8\u5668\u4E0D\u652F\u6301 WebCrypto\uFF0C\u5DF2\u56DE\u9000\u5230 forge \u52A0\u5BC6"); + const l = Td.pki.publicKeyFromPem($s.PublicKey).encrypt(o, "RSA-OAEP", { + md: Td.md.sha256.create(), + mgf1: { + md: Td.md.sha256.create() + } + }); + return { + covert: Td.util.encode64(l), + t: n, + r + } + } catch (s) { + throw console.error("\u52A0\u5BC6\u5931\u8D25:", s), + "\u52A0\u5BC6\u5904\u7406\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6D4F\u89C8\u5668\u73AF\u5883" + } + } + cacheCheck(t, n) { + if (n > 0) { + const r = this.cache.get(t); + if (r && Date.now() - r.time <= n) + return JSON.parse(JSON.stringify(r.value)) + } + return null + } + parametersToKey(t, n) { + let r = t; + for (let a in n) + a !== "token" && (r += `${a}:${n[a]}`); + return r + } + async httpProto(t, n, r) { + const a = this.parametersToKey(t, n) + , o = this.cacheCheck(a, r.interval || 0); + if (o) + return o; + const s = await B6({ + url: t, + data: n, + method: r.type + }) + , {data: l, code: i, message: u, token: c} = s.data; + switch (i) { + case bu.AlreadyExpired: + throw hs().setToken(""), + ei.replace("/login"), + "\u767B\u5F55\u5DF2\u8FC7\u671F"; + case bu.UpdateSuccess: + hs().setToken(c); + case bu.Success: + return r.interval && r.interval > 0 && this.cache.set(a, { + time: Date.now(), + value: JSON.parse(JSON.stringify(l)) + }), + l; + case bu.Fail: + throw u; + default: + throw "\u672A\u77E5\u9519\u8BEF" + } + } + async https(t, n={}, r={}) { + return await this.httpProto(t, n, Object.assign({}, $s.default_header, r)) + } + async getVerifCode(t, n) { + const r = await this.encryptWithPublicKey({ + account: t, + phone: n + }); + await this.https("/public/verif_code", r) + } + async logIn(t, n, r, a) { + const o = await this.encryptWithPublicKey({ + account: t, + password: cu(n), + phone: r, + code: a + }); + return await this.https("/public/login", o) + } + async getUserInfo() { + return await this.https("/user/info", {}, { + load: !1 + }) + } + async getAsset() { + return await this.https("/user/get_asset", {}, { + type: "GET", + load: !1 + }) + } + async getPermission() { + return await this.https("/user/get_permission", {}, { + type: "GET", + load: !1 + }) + } + async getRecordRange(t, n, r) { + return await this.https("/record/range", { + record_code: t, + start_date: n, + end_date: r + }, { + load: !1 + }) + } + async getSkuList() { + return await this.https("/sku/list", {}, { + type: "GET", + interval: 6e4 + }) + } + async selectSkuList(t) { + return await this.https("/sku/select_list", { + id: t + }) + } + async getCategoryList() { + return await this.https("/categories/get", {}, { + type: "GET", + interval: 6e4 + }) + } + async getSubList() { + return await this.https("/user/sub", {}, { + type: "GET" + }) + } + async updatePwd(t, n) { + const r = await this.encryptWithPublicKey({ + o_password: cu(t), + n_password: cu(n) + }); + return await this.https("/user/modify_pwd", r) + } + async giveAmount(t, n, r) { + return await this.https("/user/give", { + amount: t, + sub_id: n, + notes: r + }) + } + async giveSku(t, n, r, a) { + return await this.https("/user/give_sku", { + sku_id: t, + sub_id: n, + count: r, + notes: a + }) + } + async recoveryAmount(t, n, r) { + return await this.https("/user/recovery", { + amount: t, + sub_id: n, + notes: r + }) + } + async getUserPreferentialPolicies() { + return await this.https("/sku/user_pref_poli", {}, { + type: "GET", + interval: 6e4 + }) + } + async buyGoods(t, n) { + return await this.https("/sku/buy", { + id: t, + count: n + }) + } + async getKnapsack() { + const t = await this.https("/user/get_knapsack", {}, { + type: "GET" + }); + return R8e().setKnapsackInfo(t), + t + } + async use(t) { + return await this.https("/sku/use", t) + } + async getIdleVirtualNumber(t) { + return await this.https("/vn/list", { + key: t + }) + } + async getVirtualNumberBindUrl(t, n) { + return await this.https("/vn/bind_url", { + id: t, + key: n + }) + } + async getVirtualNumberGameUserName(t, n) { + return await this.https("/vn/bind_info", [{ + id: t, + key: n + }]) + } + async inquiryUID(t, n) { + return await this.https("/vn/inquiry", { + uid: t, + key: n + }) + } + async applyVirtualNumber(t, n) { + return await this.https("/vn/appoint", { + key: t, + phone: n + }) + } + async returnVirtualNumber(t, n) { + return await this.https("/vn/back", { + key: t, + id: n + }) + } + async generateLoginCode(t, n) { + return await this.https("/vn/generate_login_code", { + key: t, + id: n + }) + } + async verifyLoginCode(t, n, r) { + return await this.https("/vn/verify_login_code", { + key: t, + id: n, + code: r + }) + } + async vnVerifCode(t, n) { + return await this.https("/public/vn_verif_code", { + key: t, + phone: n + }) + } + async getRecordList(t, n, r, a, o) { + return await this.https("/record/paging", { + record_code: t, + page: n, + size: r, + start_date: a, + end_date: o + }) + } + async sendEMailCode(t, n, r) { + return await this.https("/vn/send_email_code", { + id: t, + key: n, + uid: r + }) + } + async verifyEMailBindGame(t, n, r, a) { + return await this.https("/vn/verify_email_code", { + id: t, + key: n, + uid: r, + code: a + }) + } + async direct_bind(t, n, r) { + return await this.https("/vn/direct_bind", { + id: t, + key: n, + uid: r + }) + } + async getUserAuthority(t) { + return await this.https("/admin/get_user_permissions", { + id: t + }) + } + async updateUserAuthority(t) { + return await this.https("/admin/modify_user_permissions", t) + } + async modifyUser(t) { + return await this.https("/admin/modify_user", t) + } + async getUserList() { + return await this.https("/user/list", {}, { + type: "GET" + }) + } + async getRelationshipList() { + return await this.https("/relationship/list", {}, { + type: "GET" + }) + } + async addRelationshipList(t, n) { + return await this.https("/relationship/add", { + user_id: t, + sub_id: n + }) + } + async delRelationshipList(t, n) { + return await this.https("/relationship/del", { + user_id: t, + sub_id: n + }) + } + async editRelationship(t, n) { + return await this.https("/relationship/modify", { + id: t, + open_repay: n + }) + } + async getCategoryAllList() { + return await this.https("/admin/get_categories_list", {}, { + type: "GET" + }) + } + async getSkuAllList() { + return await this.https("/admin/get_sku_list", {}, { + type: "GET" + }) + } + async getPreferentialPoliciesAll() { + return await this.https("/preferential/list", {}, { + type: "GET" + }) + } + async getUserPreferentialPoliciesToID(t) { + return await this.https("/preferential/get_relationship", { + id: t + }) + } + async createUser(t) { + const n = cu(t.password); + return await this.https("/admin/creator_user", Object.assign(t, { + password: n + })) + } + async updateUserPassword(t, n) { + const r = await this.encryptWithPublicKey({ + account: t, + n_password: cu(n) + }); + return await this.https("/admin/modify_pwd", r) + } + async updateUserStatus(t, n) { + return await this.https("/admin/ban_user", { + id: t, + expire: n + }) + } + async createSku(t) { + return await this.https("/admin/add_sku", t) + } + async createCategory(t) { + return await this.https("/admin/add_category", t) + } + async createPreferentialPolicies(t) { + return await this.https("/preferential/add", t) + } + async addUserPreferentialPolicies(t, n, r, a) { + return await this.https("/preferential/add_relationship", { + p_id: t, + u_id: n, + show_time: r, + hide_time: a + }) + } + async addRelationshipLow(t) { + return await this.https("/preferential/add_relationship_low", t) + } + async batchRelationshipLow(t) { + return await this.https("/preferential/batch_relationship_low", t) + } + async delUserPreferentialPolicies(t, n) { + return await this.https("/preferential/del_Relationship", { + p_id: t, + u_id: n + }) + } + async updateCategory(t) { + return await this.https("/admin/modify_category", t) + } + async updateSku(t) { + return await this.https("/admin/modify_sku", t) + } + async updatePreferentialPolicies(t) { + return await this.https("/preferential/modify", t) + } + async upsertCustom(t) { + return await this.https("/record/edit_custom", t) + } + async delCustom(t) { + return await this.https("/record/remove_custom", { + id: t + }) + } + async subRangeRecord(t) { + return await this.https("/record/sub_range", { + ids: t + }) + } + async transaction_record() { + return await this.https("/record/transaction_record") + } + async recipient_record() { + return await this.https("/record/recipient_record") + } + async observerSalesRecords(t, n, r) { + return await this.https("/observer/sales_records", { + ids: t, + start_date: n, + end_date: r + }) + } + async observerTransactionRecord(t) { + return await this.https("/observer/transaction_record", { + ids: t + }) + } + async observerHoldConsume(t) { + return await this.https("/observer/hold_consume", { + ids: t + }) + } + async observerPaging(t, n, r, a, o, s) { + return await this.https("/observer/paging", { + ids: t, + record_code: n, + page: r, + size: a, + start_date: o, + end_date: s + }) + } + async observerSub(t) { + return await this.https("/observer/sub", { + ids: t + }) + } + async getYeZiList() { + return await this.https("/utils/list") + } + async applyYeZiPhone(t) { + return await this.https("/utils/apply", { + id: t + }) + } + async releaseYeZiPhone(t) { + return await this.https("/utils/release", { + phone: t + }) + } + async messageYeZiPhone(t) { + return await this.https("/utils/message", { + phone: t + }) + } + async getProjectList() { + return await this.https("/utils/project_list") + } + async getHaoZhuList() { + return await this.https("/utils/list_hz") + } + async applyHaoZhuPhone(t) { + return await this.https("/utils/apply_hz", { + id: t + "" + }) + } + async releaseHaoZhuPhone(t) { + return await this.https("/utils/release_hz", { + phone: t + }) + } + async messageHaoZhuPhone(t) { + return await this.https("/utils/message_hz", { + phone: t + }) + } + async getHaoZhuProjectList() { + return await this.https("/utils/project_list_hz") + } + async returnOfGoods(t) { + return await this.https("/admin/return_of_goods", t) + } + async customRepay(t) { + return await this.https("/admin/custom_repay", t) + } + async upsertSKU(t) { + return await this.https("/sku/upsert", { + data: t + }) + } + async upsertCDK(t) { + await this.https("/admin/upsert_cdk", { + data: t + }) + } + async getCDKList(t, n) { + return await this.https("/admin/cdk", { + page: t, + size: n + }) + } +} +; +let Sr = $s; +gl(Sr, "instance"), +gl(Sr, "default_header", { + type: "POST", + interval: 0 +}), +gl(Sr, "PublicKey", `-----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-----`), +gl(Sr, "LocalCacheKey", "YCJ-LocalCacheKey"); \ No newline at end of file