优化连接过期验证与戒色信息-1

This commit is contained in:
yml2213
2026-05-14 14:42:30 +08:00
parent c7a9787814
commit 486ba14dc4
18 changed files with 815 additions and 15 deletions
+10
View File
@@ -99,6 +99,16 @@ module.exports = {
vnBindUrlPath: '/vn/bind_url',
vnBindInfoPath: '/vn/bind_info',
vnBackPath: '/vn/back',
bindUrlTtlSeconds: 300,
bindUrlProbeTimeoutMs: 5000,
bindUrlProbeUserAgent: 'Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000',
bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/',
bindUrlProbeChartId: '323794',
bindUrlProbeSubChartId: '323794',
bindUrlProbeIdeToken: 'z90Syo',
bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
bindUrlProbeReferer: 'https://gp.qq.com/',
bindUrlProbeExtraCookie: '',
clientSource: 'ct-client',
deviceId: '-',
deviceType: 0,
+60
View File
@@ -355,6 +355,66 @@ function applyEnvOverrides(baseConfig) {
nextConfig.platforms.cloudtentacles.vnBindUrlPath = cloudtentaclesVnBindUrlPath
}
const cloudtentaclesVnBindInfoPath = String(process.env.CLOUDTENTACLES_VN_BIND_INFO_PATH || '').trim()
if (cloudtentaclesVnBindInfoPath) {
nextConfig.platforms.cloudtentacles.vnBindInfoPath = cloudtentaclesVnBindInfoPath
}
const cloudtentaclesVnBackPath = String(process.env.CLOUDTENTACLES_VN_BACK_PATH || '').trim()
if (cloudtentaclesVnBackPath) {
nextConfig.platforms.cloudtentacles.vnBackPath = cloudtentaclesVnBackPath
}
const cloudtentaclesBindUrlTtlSeconds = parseInteger(process.env.CLOUDTENTACLES_BIND_URL_TTL_SECONDS)
if (cloudtentaclesBindUrlTtlSeconds !== null) {
nextConfig.platforms.cloudtentacles.bindUrlTtlSeconds = cloudtentaclesBindUrlTtlSeconds
}
const cloudtentaclesBindUrlProbeTimeoutMs = parseInteger(process.env.CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS)
if (cloudtentaclesBindUrlProbeTimeoutMs !== null) {
nextConfig.platforms.cloudtentacles.bindUrlProbeTimeoutMs = cloudtentaclesBindUrlProbeTimeoutMs
}
const cloudtentaclesBindUrlProbeUserAgent = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT || '').trim()
if (cloudtentaclesBindUrlProbeUserAgent) {
nextConfig.platforms.cloudtentacles.bindUrlProbeUserAgent = cloudtentaclesBindUrlProbeUserAgent
}
const cloudtentaclesBindUrlProbeEndpoint = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT || '').trim()
if (cloudtentaclesBindUrlProbeEndpoint) {
nextConfig.platforms.cloudtentacles.bindUrlProbeEndpoint = cloudtentaclesBindUrlProbeEndpoint
}
const cloudtentaclesBindUrlProbeChartId = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID || '').trim()
if (cloudtentaclesBindUrlProbeChartId) {
nextConfig.platforms.cloudtentacles.bindUrlProbeChartId = cloudtentaclesBindUrlProbeChartId
}
const cloudtentaclesBindUrlProbeSubChartId = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID || '').trim()
if (cloudtentaclesBindUrlProbeSubChartId) {
nextConfig.platforms.cloudtentacles.bindUrlProbeSubChartId = cloudtentaclesBindUrlProbeSubChartId
}
const cloudtentaclesBindUrlProbeIdeToken = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN || '').trim()
if (cloudtentaclesBindUrlProbeIdeToken) {
nextConfig.platforms.cloudtentacles.bindUrlProbeIdeToken = cloudtentaclesBindUrlProbeIdeToken
}
const cloudtentaclesBindUrlProbeActivityUrl = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL || '').trim()
if (cloudtentaclesBindUrlProbeActivityUrl) {
nextConfig.platforms.cloudtentacles.bindUrlProbeActivityUrl = cloudtentaclesBindUrlProbeActivityUrl
}
const cloudtentaclesBindUrlProbeReferer = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_REFERER || '').trim()
if (cloudtentaclesBindUrlProbeReferer) {
nextConfig.platforms.cloudtentacles.bindUrlProbeReferer = cloudtentaclesBindUrlProbeReferer
}
const cloudtentaclesBindUrlProbeExtraCookie = String(process.env.CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE || '').trim()
if (cloudtentaclesBindUrlProbeExtraCookie) {
nextConfig.platforms.cloudtentacles.bindUrlProbeExtraCookie = cloudtentaclesBindUrlProbeExtraCookie
}
const cloudtentaclesPublicKeyPem = String(process.env.CLOUDTENTACLES_PUBLIC_KEY_PEM || '').trim()
if (cloudtentaclesPublicKeyPem) {
nextConfig.platforms.cloudtentacles.publicKeyPem = cloudtentaclesPublicKeyPem
@@ -61,6 +61,10 @@ export function mapKuaishouCloudFulfillmentContext(value) {
vnPhone: String(binding.vnPhone || '').trim(),
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt: binding.bindPreparedAt || null,
bindExpiresAt: binding.bindExpiresAt || null,
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
},
role: {
status: String(role.status || 'pending').trim() || 'pending',
@@ -40,6 +40,7 @@ import {
normalizeKuaishouCloudFlow,
normalizeKuaishouCloudRoleInfo,
prepareKuaishouCloudBindResourceWithFallback,
resolveKuaishouCloudBindUrlExpiresAt,
resolveKuaishouCloudBindingResources,
resolveKuaishouCloudVnKeyCandidates,
resolvePersistedCloudtentaclesContext,
@@ -169,6 +170,10 @@ export async function prepareAdminTaskKuaishouCloudFulfillment(taskId, session =
vnPhone,
bindUrl: preparedBinding.bindUrl,
bindPreparedAt: now,
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
bindProbeAt: null,
bindProbeStatus: 'pending',
bindProbeMessage: '',
roleName: '',
roleId: '',
},
@@ -3,6 +3,7 @@
import { normalizeProductName } from '../../order/product-match-service.js'
import { getCloudtentaclesSourceConfig } from '../../platforms/cloudtentacles/source-config-service.js'
import { getCloudtentaclesSessionState } from '../../platforms/cloudtentacles/session-state-service.js'
import { resolveCloudtentaclesConfig } from '../../platforms/cloudtentacles/shared.js'
import {
appointCloudtentaclesVirtualNumber,
backCloudtentaclesVirtualNumber,
@@ -48,6 +49,7 @@ export function normalizeKuaishouCloudFlow(value) {
const returnNumber = source.returnNumber && typeof source.returnNumber === 'object' ? source.returnNumber : {}
const consume = source.consume && typeof source.consume === 'object' ? source.consume : {}
const ticket = source.ticket && typeof source.ticket === 'object' ? source.ticket : {}
const bindPreparedAt = binding.bindPreparedAt || null
return {
...source,
@@ -74,7 +76,11 @@ export function normalizeKuaishouCloudFlow(value) {
vnId: Number(binding.vnId || 0) || 0,
vnPhone: String(binding.vnPhone || '').trim(),
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt: binding.bindPreparedAt || null,
bindPreparedAt,
bindExpiresAt: binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt),
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
roleName: String(binding.roleName || '').trim(),
roleId: String(binding.roleId || '').trim(),
},
@@ -121,14 +127,27 @@ export function normalizeKuaishouCloudFlow(value) {
export function normalizeKuaishouCloudRoleInfo(value) {
const rawInfo = value && typeof value === 'object' ? value : null
const nestedBindInfo = rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null
const source = nestedBindInfo || rawInfo
return {
name: String(rawInfo?.name || rawInfo?.roleName || rawInfo?.nickname || '').trim(),
rid: String(rawInfo?.rid || rawInfo?.roleId || rawInfo?.uid || '').trim(),
name: String(source?.name || source?.roleName || source?.nickname || source?.sRoleName || '').trim(),
rid: String(source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '').trim(),
rawInfo,
}
}
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) {
const preparedTime = Date.parse(String(preparedAt || ''))
if (!Number.isFinite(preparedTime)) {
return null
}
const config = resolveCloudtentaclesConfig()
const ttlSeconds = Number(config.bindUrlTtlSeconds || 300)
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString()
}
export function resolveKuaishouCloudBindingResources(flow, { skuItems = [], knapsackItems = [] } = {}) {
const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : []
const normalizedKnapsackItems = Array.isArray(knapsackItems) ? knapsackItems.filter(isCloudSkuLikeItem) : []
@@ -189,6 +189,7 @@ export async function confirmKuaishouCloudClaimRole(token) {
source: 'claim_page_role_confirm',
actor: { source: 'claim_page' },
recordEvent: false,
forceProbe: true,
})
const flow = normalizeKuaishouCloudFlow(parseTaskContext(refreshed.task).kuaishouCloudFulfillment)
@@ -1,5 +1,6 @@
import {
normalizeKuaishouCloudFlow,
probeKuaishouCloudTaskBindUrl,
refreshKuaishouCloudTaskRoleInfo,
} from '../fulfillment/kuaishou-cloud-task-service.js'
@@ -10,11 +11,13 @@ export async function syncKuaishouCloudRoleInfo(task) {
return task
}
if (flow.binding.roleName && flow.binding.roleId) {
return task
}
try {
const probed = await probeKuaishouCloudTaskBindUrl(task, {
source: 'claim_page_polling_bind_url_probe',
actor: { source: 'system' },
})
task = probed.task
const result = await refreshKuaishouCloudTaskRoleInfo(task, {
source: 'claim_page_polling',
actor: { source: 'system' },
@@ -105,6 +105,10 @@ export function mapClaimKuaishouCloudFulfillment(task, order) {
vnId: Number(binding.vnId || 0) || 0,
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt: binding.bindPreparedAt || null,
bindExpiresAt: binding.bindExpiresAt || null,
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
vnPhone: String(binding.vnPhone || '').trim(),
roleName: String(binding.roleName || '').trim(),
roleId: String(binding.roleId || '').trim(),
@@ -21,8 +21,10 @@ import {
generateCloudtentaclesLoginCode,
getCloudtentaclesBindInfo,
getCloudtentaclesBindUrl,
probeCloudtentaclesBindUrl,
verifyCloudtentaclesLoginCode,
} from '../platforms/cloudtentacles/virtual-number-service.js'
import { resolveCloudtentaclesConfig } from '../platforms/cloudtentacles/shared.js'
import { consumeKuaishouEticket } from '../platforms/kuaishou-eticket/consume-service.js'
import {
getKuaishouEticketSourceConfig,
@@ -49,6 +51,8 @@ export function normalizeKuaishouCloudFlow(value) {
const roleName = String(role.name || binding.roleName || '').trim()
const roleId = String(role.rid || binding.roleId || '').trim()
const bindPreparedAt = binding.bindPreparedAt || null
const bindExpiresAt = binding.bindExpiresAt || resolveKuaishouCloudBindUrlExpiresAt(bindPreparedAt)
return {
...source,
@@ -75,7 +79,11 @@ export function normalizeKuaishouCloudFlow(value) {
vnId: Number(binding.vnId || 0) || 0,
vnPhone: String(binding.vnPhone || '').trim(),
bindUrl: String(binding.bindUrl || '').trim(),
bindPreparedAt: binding.bindPreparedAt || null,
bindPreparedAt,
bindExpiresAt,
bindProbeAt: binding.bindProbeAt || null,
bindProbeStatus: String(binding.bindProbeStatus || '').trim(),
bindProbeMessage: String(binding.bindProbeMessage || '').trim(),
roleName,
roleId,
},
@@ -123,10 +131,12 @@ export function normalizeKuaishouCloudFlow(value) {
export function normalizeKuaishouCloudRoleInfo(value) {
const rawInfo = value && typeof value === 'object' ? value : null
const nestedBindInfo = rawInfo?.sBindInfo && typeof rawInfo.sBindInfo === 'object' ? rawInfo.sBindInfo : null
const source = nestedBindInfo || rawInfo
return {
name: String(rawInfo?.name || rawInfo?.roleName || rawInfo?.nickname || '').trim(),
rid: String(rawInfo?.rid || rawInfo?.roleId || rawInfo?.uid || '').trim(),
name: String(source?.name || source?.roleName || source?.nickname || source?.sRoleName || '').trim(),
rid: String(source?.rid || source?.roleId || source?.uid || source?.sRoleId || source?.sUserId || '').trim(),
rawInfo,
}
}
@@ -188,6 +198,14 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) {
const claimLinkState = await ensureTaskClaimLink(task)
if (!force && flow.binding.prepareStatus === 'ready' && flow.binding.vnId > 0 && flow.binding.vnPhone && flow.binding.bindUrl) {
if (!isKuaishouCloudBindUrlFresh(flow)) {
return refreshKuaishouCloudTaskBindUrl(task, {
source: options.source || 'system_refresh_expired_bind_url',
actor,
claimLinkState,
})
}
let readyTask = task
if (String(task.task_status || '').trim() !== 'waiting_binding') {
@@ -300,6 +318,10 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) {
vnPhone: preparedBinding.vnPhone,
bindUrl: preparedBinding.bindUrl,
bindPreparedAt: now,
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
bindProbeAt: null,
bindProbeStatus: 'pending',
bindProbeMessage: '',
roleName: '',
roleId: '',
},
@@ -356,6 +378,180 @@ export async function prepareKuaishouCloudFulfillmentTask(task, options = {}) {
}
}
export async function refreshKuaishouCloudTaskBindUrl(task, options = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
}
const now = nowIso()
const actor = normalizeActor(options.actor)
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
if (!flow.binding.vnId || !flow.binding.vnKey) {
throw createHttpError('当前任务缺少可刷新绑定链接的虚拟号信息', {
statusCode: 409,
errorCode: 'kuaishou_cloud_missing_bind_url_context',
})
}
const cloudContext = resolvePersistedCloudtentaclesContext()
const bindUrlResult = await getCloudtentaclesBindUrl({
...cloudContext,
key: flow.binding.vnKey,
id: flow.binding.vnId,
})
const bindUrl = String(bindUrlResult.bindUrl || '').trim()
if (!bindUrl) {
throw createHttpError('cloudtentacles 未返回新的绑定链接', {
statusCode: 502,
errorCode: 'kuaishou_cloud_empty_bind_url',
})
}
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
binding: {
...flow.binding,
prepareStatus: 'ready',
bindUrl,
bindPreparedAt: now,
bindExpiresAt: resolveKuaishouCloudBindUrlExpiresAt(now),
bindProbeAt: null,
bindProbeStatus: 'pending',
bindProbeMessage: '',
roleName: String(task.task_status || '').trim() === 'role_confirmed' ? flow.binding.roleName : '',
roleId: String(task.task_status || '').trim() === 'role_confirmed' ? flow.binding.roleId : '',
},
role: String(task.task_status || '').trim() === 'role_confirmed'
? flow.role
: {
status: 'pending',
name: '',
rid: '',
refreshedAt: null,
errorMessage: '',
rawInfo: null,
},
},
}
const claimLinkState = options.claimLinkState || await ensureTaskClaimLink(task)
const updatedTask = await updateTask(task.id, {
task_status: String(task.task_status || '').trim() === 'role_confirmed' ? task.task_status : 'waiting_binding',
claim_token: claimLinkState.token || task.claim_token || '',
claim_expires_at: claimLinkState.expiredAt || getTaskClaimExpiresAt(task),
role_id: String(task.task_status || '').trim() === 'role_confirmed' ? task.role_id : '',
role_name: String(task.task_status || '').trim() === 'role_confirmed' ? task.role_name : '',
last_error: '',
context_json: JSON.stringify(nextContext),
updated_at: now,
})
await createTaskEvent(task.id, 'kuaishou_cloud_bind_url_refreshed', {
source: String(options.source || 'system').trim() || 'system',
vnId: flow.binding.vnId,
vnPhoneMasked: maskPhone(flow.binding.vnPhone),
actor,
}, now)
return {
task: updatedTask,
claimUrl: claimLinkState.claimUrl,
token: claimLinkState.token,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
}
}
export async function probeKuaishouCloudTaskBindUrl(task, options = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
statusCode: 409,
errorCode: 'kuaishou_cloud_task_invalid',
})
}
const now = nowIso()
const taskContext = parseTaskContext(task)
const flow = normalizeKuaishouCloudFlow(taskContext.kuaishouCloudFulfillment)
if (!flow.binding.bindUrl) {
return {
task,
flow,
probe: null,
}
}
if (!options.force && flow.binding.bindProbeAt && Date.now() - Date.parse(flow.binding.bindProbeAt) < 15000) {
return {
task,
flow,
probe: null,
}
}
const probe = await probeCloudtentaclesBindUrl({
bindUrl: flow.binding.bindUrl,
})
if (probe.expired || !isKuaishouCloudBindUrlFresh(flow)) {
const refreshed = await refreshKuaishouCloudTaskBindUrl(task, {
source: options.source || 'claim_page_bind_url_expired',
actor: options.actor || { source: 'system' },
})
return {
...refreshed,
probe,
}
}
const probeRoleInfo = normalizeKuaishouCloudRoleInfo(probe.roleInfo)
const hasRoleInfo = Boolean(probeRoleInfo.name || probeRoleInfo.rid)
const nextContext = {
...taskContext,
kuaishouCloudFulfillment: {
...flow,
binding: {
...flow.binding,
bindProbeAt: now,
bindProbeStatus: probe.valid ? 'valid' : 'invalid',
bindProbeMessage: String(probe.message || probe.reason || '').trim(),
roleName: hasRoleInfo ? probeRoleInfo.name : flow.binding.roleName,
roleId: hasRoleInfo ? probeRoleInfo.rid : flow.binding.roleId,
},
role: {
...flow.role,
status: hasRoleInfo ? 'ready' : flow.role.status,
name: hasRoleInfo ? probeRoleInfo.name : flow.role.name,
rid: hasRoleInfo ? probeRoleInfo.rid : flow.role.rid,
refreshedAt: hasRoleInfo ? now : flow.role.refreshedAt,
errorMessage: hasRoleInfo ? '' : flow.role.errorMessage,
rawInfo: hasRoleInfo ? probeRoleInfo.rawInfo : flow.role.rawInfo,
},
},
}
const updatedTask = await updateTask(task.id, {
role_id: hasRoleInfo ? probeRoleInfo.rid : task.role_id,
role_name: hasRoleInfo ? probeRoleInfo.name : task.role_name,
context_json: JSON.stringify(nextContext),
updated_at: now,
})
return {
task: updatedTask,
flow: normalizeKuaishouCloudFlow(nextContext.kuaishouCloudFulfillment),
probe,
}
}
export async function refreshKuaishouCloudTaskRoleInfo(task, options = {}) {
if (!isKuaishouCloudTask(task)) {
throw createHttpError('当前任务不是快手 Cloud 履约任务', {
@@ -376,6 +572,34 @@ export async function refreshKuaishouCloudTaskRoleInfo(task, options = {}) {
})
}
if (flow.binding.bindUrl) {
const probed = await probeKuaishouCloudTaskBindUrl(task, {
source: options.source || 'system_role_refresh_bind_url_probe',
actor,
force: options.forceProbe === true,
})
const probedFlow = normalizeKuaishouCloudFlow(parseTaskContext(probed.task).kuaishouCloudFulfillment)
if (probed.probe?.expired) {
return {
task: probed.task,
roleInfo: normalizeKuaishouCloudRoleInfo(null),
flow: probedFlow,
}
}
if ((probed.probe === null || probed.probe?.valid) && (probedFlow.binding.roleName || probedFlow.binding.roleId)) {
return {
task: probed.task,
roleInfo: {
name: probedFlow.binding.roleName,
rid: probedFlow.binding.roleId,
rawInfo: probedFlow.role.rawInfo,
},
flow: probedFlow,
}
}
}
const cloudContext = resolvePersistedCloudtentaclesContext()
const bindInfoResult = await getCloudtentaclesBindInfo({
...cloudContext,
@@ -701,6 +925,36 @@ export function maskCode(value) {
return `${text.slice(0, 4)}****${text.slice(-4)}`
}
export function resolveKuaishouCloudBindUrlExpiresAt(preparedAt) {
const preparedTime = Date.parse(String(preparedAt || ''))
if (!Number.isFinite(preparedTime)) {
return null
}
const config = resolveCloudtentaclesConfig()
const ttlSeconds = Number(config.bindUrlTtlSeconds || 300)
return new Date(preparedTime + Math.max(1, ttlSeconds) * 1000).toISOString()
}
export function isKuaishouCloudBindUrlFresh(flow, now = new Date()) {
const normalizedFlow = normalizeKuaishouCloudFlow(flow)
if (!normalizedFlow.binding.bindUrl) {
return false
}
const expiresAt = normalizedFlow.binding.bindExpiresAt
if (!expiresAt) {
return false
}
const expiresTime = Date.parse(String(expiresAt || ''))
if (!Number.isFinite(expiresTime)) {
return false
}
return expiresTime > now.getTime()
}
function resolveKuaishouCloudBindingResources(flow, { skuItems = [], knapsackItems = [] } = {}) {
const normalizedSkuItems = Array.isArray(skuItems) ? skuItems.filter(isCloudSkuLikeItem) : []
const normalizedKnapsackItems = Array.isArray(knapsackItems) ? knapsackItems.filter(isCloudSkuLikeItem) : []
@@ -115,6 +115,10 @@ export async function syncDeliveryTasksForOrder(order, orderItems) {
vnPhone: '',
bindUrl: '',
bindPreparedAt: null,
bindExpiresAt: null,
bindProbeAt: null,
bindProbeStatus: '',
bindProbeMessage: '',
},
role: {
status: 'pending',
@@ -27,6 +27,16 @@ export function resolveCloudtentaclesConfig(overrides = {}) {
vnBindUrlPath: '/vn/bind_url',
vnBindInfoPath: '/vn/bind_info',
vnBackPath: '/vn/back',
bindUrlTtlSeconds: 300,
bindUrlProbeTimeoutMs: 5000,
bindUrlProbeUserAgent: '',
bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/',
bindUrlProbeChartId: '323794',
bindUrlProbeSubChartId: '323794',
bindUrlProbeIdeToken: 'z90Syo',
bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F',
bindUrlProbeReferer: 'https://gp.qq.com/',
bindUrlProbeExtraCookie: '',
publicKeyPem: '',
clientSource: 'ct-client',
deviceId: '-',
@@ -60,6 +70,16 @@ export function resolveCloudtentaclesConfig(overrides = {}) {
vnBindUrlPath: normalizePath(overrides.vnBindUrlPath || baseConfig.vnBindUrlPath, '/vn/bind_url'),
vnBindInfoPath: normalizePath(overrides.vnBindInfoPath || baseConfig.vnBindInfoPath, '/vn/bind_info'),
vnBackPath: normalizePath(overrides.vnBackPath || baseConfig.vnBackPath, '/vn/back'),
bindUrlTtlSeconds: normalizePositiveInteger(overrides.bindUrlTtlSeconds || baseConfig.bindUrlTtlSeconds, 300),
bindUrlProbeTimeoutMs: normalizePositiveInteger(overrides.bindUrlProbeTimeoutMs || baseConfig.bindUrlProbeTimeoutMs, 5000),
bindUrlProbeUserAgent: String(overrides.bindUrlProbeUserAgent || baseConfig.bindUrlProbeUserAgent || '').trim(),
bindUrlProbeEndpoint: String(overrides.bindUrlProbeEndpoint || baseConfig.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim(),
bindUrlProbeChartId: String(overrides.bindUrlProbeChartId || baseConfig.bindUrlProbeChartId || '323794').trim(),
bindUrlProbeSubChartId: String(overrides.bindUrlProbeSubChartId || baseConfig.bindUrlProbeSubChartId || '323794').trim(),
bindUrlProbeIdeToken: String(overrides.bindUrlProbeIdeToken || baseConfig.bindUrlProbeIdeToken || 'z90Syo').trim(),
bindUrlProbeActivityUrl: String(overrides.bindUrlProbeActivityUrl || baseConfig.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim(),
bindUrlProbeReferer: String(overrides.bindUrlProbeReferer || baseConfig.bindUrlProbeReferer || 'https://gp.qq.com/').trim(),
bindUrlProbeExtraCookie: String(overrides.bindUrlProbeExtraCookie || baseConfig.bindUrlProbeExtraCookie || '').trim(),
publicKeyPem: normalizePem(overrides.publicKeyPem || baseConfig.publicKeyPem),
clientSource: String(overrides.clientSource || baseConfig.clientSource || 'ct-client').trim() || 'ct-client',
deviceId: String(overrides.deviceId ?? baseConfig.deviceId ?? '-').trim() || '-',
@@ -1,9 +1,15 @@
// @ts-check
import http from 'node:http'
import https from 'node:https'
import { createHttpError } from '../../../utils/http.js'
import { cloudtentaclesRequest } from './http-client.js'
import { resolveCloudtentaclesConfig } from './shared.js'
const BIND_URL_PROBE_MAX_BODY_BYTES = 64 * 1024
const AMS_SIGNATURE_EXPIRED_CODE = '99998'
export async function listCloudtentaclesVirtualNumbers(payload = {}) {
const token = requireToken(payload.token, 'cloudtentacles 虚拟号列表缺少 token', 'cloudtentacles_vn_list_missing_token')
const key = requireKey(payload.key, 'cloudtentacles 虚拟号列表缺少 key', 'cloudtentacles_vn_list_missing_key')
@@ -159,6 +165,80 @@ export async function getCloudtentaclesBindUrl(payload = {}) {
}
}
export async function probeCloudtentaclesBindUrl(payload = {}) {
const bindUrl = String(payload.bindUrl || '').trim()
if (!bindUrl) {
return {
valid: false,
status: 0,
finalUrl: '',
reason: 'empty_url',
}
}
const config = resolveCloudtentaclesConfig(payload)
const signatureParams = extractBindUrlSignatureParams(bindUrl)
if (!signatureParams) {
return {
valid: false,
expired: false,
status: 0,
finalUrl: bindUrl,
reason: 'missing_signature_params',
roleInfo: normalizeAmsBindRoleInfo(null),
raw: null,
}
}
const timeoutMs = Number(payload.timeoutMs || config.bindUrlProbeTimeoutMs || 5000)
const userAgent = String(payload.userAgent || config.bindUrlProbeUserAgent || '').trim()
const endpoint = String(payload.endpoint || config.bindUrlProbeEndpoint || 'https://comm.ams.game.qq.com/ide/').trim()
const startedAt = Date.now()
try {
const response = await requestAmsIdeProbe(endpoint, {
timeoutMs,
body: buildAmsIdeProbeBody(signatureParams, config, payload),
headers: buildAmsIdeProbeHeaders(signatureParams, config, {
userAgent,
}),
})
const raw = tryParseJson(response.bodyText)
const expired = isAmsSignatureExpired(raw)
const bindInfo = extractAmsBindInfo(raw)
const roleInfo = normalizeAmsBindRoleInfo(bindInfo)
const ok = response.status >= 200
&& response.status < 300
&& !expired
&& isAmsBusinessOk(raw)
return {
valid: ok,
expired,
status: response.status,
finalUrl: endpoint,
ret: String(raw?.ret ?? raw?.iRet ?? ''),
iRet: String(raw?.iRet ?? raw?.jData?.iRet ?? ''),
message: String(raw?.sMsg || raw?.jData?.sMsg || ''),
reason: expired ? 'signature_expired' : (ok ? 'ok' : 'ams_business_not_ok'),
roleInfo,
raw,
durationMs: Date.now() - startedAt,
}
} catch (error) {
return {
valid: false,
expired: false,
status: 0,
finalUrl: bindUrl,
reason: error instanceof Error ? error.message : String(error || 'probe_failed'),
roleInfo: normalizeAmsBindRoleInfo(null),
raw: null,
durationMs: Date.now() - startedAt,
}
}
}
export async function getCloudtentaclesBindInfo(payload = {}) {
const token = requireToken(payload.token, 'cloudtentacles 获取绑定信息缺少 token', 'cloudtentacles_vn_bind_info_missing_token')
const key = requireKey(payload.key, 'cloudtentacles 获取绑定信息缺少 key', 'cloudtentacles_vn_bind_info_missing_key')
@@ -275,6 +355,242 @@ function parseBindInfo(value) {
return tryParseJson(bindInfoText) || bindInfoText || null
}
async function requestAmsIdeProbe(endpoint, options = {}) {
const url = new URL(endpoint)
if (!['http:', 'https:'].includes(url.protocol)) {
throw new Error('unsupported_ams_probe_protocol')
}
const timeoutMs = Number(options.timeoutMs || 5000)
const isHttps = url.protocol === 'https:'
const transport = isHttps ? https : http
const body = options.body instanceof URLSearchParams ? options.body.toString() : String(options.body || '')
return new Promise((resolve, reject) => {
const request = transport.request(url, {
method: 'POST',
rejectUnauthorized: false,
headers: {
...normalizeHeaderMap(options.headers),
'content-length': Buffer.byteLength(body),
'accept-encoding': 'identity',
},
}, (response) => {
const chunks = []
let receivedBytes = 0
response.on('data', (chunk) => {
if (receivedBytes >= BIND_URL_PROBE_MAX_BODY_BYTES) {
return
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
const remainingBytes = BIND_URL_PROBE_MAX_BODY_BYTES - receivedBytes
chunks.push(buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer)
receivedBytes += Math.min(buffer.length, remainingBytes)
})
response.on('end', () => {
clearTimeout(timer)
resolve({
status: Number(response.statusCode || 0),
headers: createHeaderAdapter(response.headers),
bodyText: Buffer.concat(chunks).toString('utf8'),
})
})
})
const timer = setTimeout(() => {
request.destroy(new Error('bind_url_probe_timeout'))
}, timeoutMs)
request.on('error', (error) => {
clearTimeout(timer)
reject(error)
})
request.write(body)
request.end()
})
}
function createHeaderAdapter(headers) {
const normalized = new Map()
for (const [key, value] of Object.entries(headers || {})) {
if (Array.isArray(value)) {
normalized.set(String(key || '').toLowerCase(), value.join(', '))
continue
}
if (typeof value === 'string') {
normalized.set(String(key || '').toLowerCase(), value)
}
}
return {
get(name) {
return normalized.get(String(name || '').toLowerCase()) || null
},
}
}
function buildAmsIdeProbeBody(signatureParams, config, payload = {}) {
const body = new URLSearchParams()
const chartId = String(payload.chartId || config.bindUrlProbeChartId || '323794').trim()
const subChartId = String(payload.subChartId || config.bindUrlProbeSubChartId || chartId).trim()
body.set('iChartId', chartId)
body.set('iSubChartId', subChartId)
body.set('sIdeToken', String(payload.ideToken || config.bindUrlProbeIdeToken || 'z90Syo').trim())
body.set('e_code', '0')
body.set('g_code', '0')
body.set('eas_url', String(payload.easUrl || config.bindUrlProbeActivityUrl || 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F').trim())
body.set('eas_refer', String(payload.easRefer || `http%3A%2F%2Fnoreferrer%2F%3Freqid%3D${Date.now()}%26version%3D27`).trim())
body.set('sMiloTag', String(payload.miloTag || `AMS-gp-${Date.now()}`).trim())
body.set('userId', signatureParams.userId)
body.set('timestamp', signatureParams.timestamp)
body.set('nonce', signatureParams.nonce)
body.set('sign', signatureParams.sign)
return body
}
function buildAmsIdeProbeHeaders(signatureParams, config, options = {}) {
const userAgent = String(options.userAgent || '').trim()
const referer = String(config.bindUrlProbeReferer || 'https://gp.qq.com/').trim()
return {
accept: 'application/json, text/plain, */*',
'content-type': 'application/x-www-form-urlencoded',
'accept-language': 'zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6',
'cache-control': 'no-cache',
origin: 'https://gp.qq.com',
pragma: 'no-cache',
referer,
'sec-ch-ua': '""',
'sec-ch-ua-mobile': '?1',
'sec-ch-ua-platform': '""',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
...(userAgent ? { 'user-agent': userAgent } : {}),
cookie: buildAmsIdeProbeCookie(signatureParams, config),
}
}
function buildAmsIdeProbeCookie(signatureParams, config) {
const tokenParams = `?userId=${signatureParams.userId}&timestamp=${signatureParams.timestamp}&nonce=${signatureParams.nonce}&sign=${signatureParams.sign}`
const cookies = [
`tokenParams=${encodeURIComponent(tokenParams)}`,
'gpqqcomrouteLine=a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc',
String(config.bindUrlProbeExtraCookie || '').trim(),
].filter(Boolean)
return cookies.join('; ')
}
function extractBindUrlSignatureParams(bindUrl) {
try {
const url = new URL(bindUrl)
const params = new URLSearchParams(url.search)
const hashQueryIndex = String(url.hash || '').indexOf('?')
if (hashQueryIndex >= 0) {
const hashParams = new URLSearchParams(String(url.hash || '').slice(hashQueryIndex + 1))
for (const [key, value] of hashParams.entries()) {
if (!params.has(key)) {
params.set(key, value)
}
}
}
const tokenParams = String(params.get('tokenParams') || '').trim()
if (tokenParams) {
const nested = new URLSearchParams(tokenParams.startsWith('?') ? tokenParams.slice(1) : tokenParams)
for (const [key, value] of nested.entries()) {
if (!params.has(key)) {
params.set(key, value)
}
}
}
const userId = String(params.get('userId') || '').trim()
const timestamp = String(params.get('timestamp') || '').trim()
const nonce = String(params.get('nonce') || '').trim()
const sign = String(params.get('sign') || '').trim()
if (!userId || !timestamp || !nonce || !sign) {
return null
}
return {
userId,
timestamp,
nonce,
sign,
}
} catch {
return null
}
}
function isAmsBusinessOk(raw) {
const ret = String(raw?.ret ?? '').trim()
const iRet = String(raw?.iRet ?? raw?.jData?.iRet ?? '').trim()
return ret === '0' || iRet === '0'
}
function isAmsSignatureExpired(raw) {
const codes = [
raw?.ret,
raw?.iRet,
raw?.jData?.ret,
raw?.jData?.iRet,
raw?.jData?.jData?.iRet,
raw?.arrErrNodeInfo?.errorCode,
raw?.jData?.arrErrNodeInfo?.errorCode,
].map((value) => String(value ?? '').trim())
const messages = [
raw?.sMsg,
raw?.jData?.sMsg,
raw?.jData?.jData?.sMsg,
].map((value) => String(value ?? '').trim())
return codes.includes(AMS_SIGNATURE_EXPIRED_CODE) || messages.some((message) => message.includes('签名已过期'))
}
function extractAmsBindInfo(raw) {
const candidates = [
raw?.jData?.sBindInfo,
raw?.jData?.jData?.sBindInfo,
raw?.sBindInfo,
]
return candidates.find((item) => isPlainObject(item)) || null
}
function normalizeAmsBindRoleInfo(bindInfo) {
const source = isPlainObject(bindInfo) ? bindInfo : {}
return {
name: String(source.sRoleName || source.roleName || source.name || '').trim(),
rid: String(source.sRoleId || source.roleId || source.rid || '').trim(),
rawInfo: isPlainObject(bindInfo) ? bindInfo : null,
}
}
function normalizeHeaderMap(headers) {
if (!headers || typeof headers !== 'object') {
return {}
}
return Object.fromEntries(
Object.entries(headers)
.map(([key, value]) => [String(key || '').trim().toLowerCase(), String(value || '').trim()])
.filter(([key, value]) => key && value),
)
}
function isPlainObject(value) {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
}
+10
View File
@@ -119,6 +119,16 @@ export {}
* vnBindUrlPath: string
* vnBindInfoPath: string
* vnBackPath: string
* bindUrlTtlSeconds: number
* bindUrlProbeTimeoutMs: number
* bindUrlProbeUserAgent: string
* bindUrlProbeEndpoint: string
* bindUrlProbeChartId: string
* bindUrlProbeSubChartId: string
* bindUrlProbeIdeToken: string
* bindUrlProbeActivityUrl: string
* bindUrlProbeReferer: string
* bindUrlProbeExtraCookie: string
* publicKeyPem: string
* clientSource: string
* deviceId: string