From 486ba14dc4b6d919fadf35ffac4d09818bca0a3d Mon Sep 17 00:00:00 2001 From: yml2213 Date: Thu, 14 May 2026 14:42:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BF=9E=E6=8E=A5=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E9=AA=8C=E8=AF=81=E4=B8=8E=E6=88=92=E8=89=B2=E4=BF=A1?= =?UTF-8?q?=E6=81=AF-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.server.example | 8 + apps/backend/config/default.cjs | 10 + apps/backend/src/config/runtime.js | 60 ++++ .../admin/admin-read-shared-helpers.js | 4 + .../admin/write/kuaishou-cloud-actions.js | 5 + .../admin/write/kuaishou-cloud-helpers.js | 25 +- .../claim/kuaishou-cloud-claim-service.js | 1 + .../claim/kuaishou-cloud-sync-service.js | 11 +- .../src/services/claim/session/shared.js | 4 + .../kuaishou-cloud-task-service.js | 260 +++++++++++++- .../services/order/delivery-task-service.js | 4 + .../platforms/cloudtentacles/shared.js | 20 ++ .../cloudtentacles/virtual-number-service.js | 316 ++++++++++++++++++ apps/backend/src/types/runtime-config.js | 10 + apps/frontend/src/types/admin.ts | 4 + apps/frontend/src/types/claim.ts | 4 + .../views/claim/KuaishouCloudClaimView.vue | 26 +- docs/是否过期判断.md | 58 ++++ 18 files changed, 815 insertions(+), 15 deletions(-) create mode 100644 docs/是否过期判断.md diff --git a/.env.server.example b/.env.server.example index a893b1e3..2c8d43ae 100644 --- a/.env.server.example +++ b/.env.server.example @@ -25,6 +25,14 @@ TENCENT_BROWSER_PREWARM=true TENCENT_BROWSER_KEEP_ALIVE=true TENCENT_BROWSER_SLOW_MO=0 TENCENT_SESSION_DEBUG=false +CLOUDTENTACLES_BIND_URL_TTL_SECONDS=300 +CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS=5000 +CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT=https://comm.ams.game.qq.com/ide/ +CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID=323794 +CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID=323794 +CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN=z90Syo +CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL=http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F +CLOUDTENTACLES_BIND_URL_PROBE_REFERER=https://gp.qq.com/ ADMIN_SESSION_SECRET=xm4BDpbdHJvU4RIlpGqD0nvQTZtIidvQMk6LSP1d ADMIN_SESSION_TTL_HOURS=12 diff --git a/apps/backend/config/default.cjs b/apps/backend/config/default.cjs index 6093efe8..3374e758 100644 --- a/apps/backend/config/default.cjs +++ b/apps/backend/config/default.cjs @@ -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, diff --git a/apps/backend/src/config/runtime.js b/apps/backend/src/config/runtime.js index 65db233d..0dc0e288 100644 --- a/apps/backend/src/config/runtime.js +++ b/apps/backend/src/config/runtime.js @@ -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 diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.js b/apps/backend/src/services/admin/admin-read-shared-helpers.js index 8a7b27d8..e4604ec9 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.js +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.js @@ -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', diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.js b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.js index 74c44e50..e9289cfe 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-actions.js +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-actions.js @@ -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: '', }, diff --git a/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.js b/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.js index d3b3e50c..1d8edfa5 100644 --- a/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.js +++ b/apps/backend/src/services/admin/write/kuaishou-cloud-helpers.js @@ -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) : [] diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.js b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.js index 031a6830..acb414b8 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-service.js +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-service.js @@ -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) diff --git a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.js b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.js index b02715d1..4e1c0992 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-sync-service.js +++ b/apps/backend/src/services/claim/kuaishou-cloud-sync-service.js @@ -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' }, diff --git a/apps/backend/src/services/claim/session/shared.js b/apps/backend/src/services/claim/session/shared.js index 57c9591f..de1304a4 100644 --- a/apps/backend/src/services/claim/session/shared.js +++ b/apps/backend/src/services/claim/session/shared.js @@ -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(), diff --git a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js index 946f8063..33dad741 100644 --- a/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js +++ b/apps/backend/src/services/fulfillment/kuaishou-cloud-task-service.js @@ -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) : [] diff --git a/apps/backend/src/services/order/delivery-task-service.js b/apps/backend/src/services/order/delivery-task-service.js index 31e85b47..6b5c63f7 100644 --- a/apps/backend/src/services/order/delivery-task-service.js +++ b/apps/backend/src/services/order/delivery-task-service.js @@ -115,6 +115,10 @@ export async function syncDeliveryTasksForOrder(order, orderItems) { vnPhone: '', bindUrl: '', bindPreparedAt: null, + bindExpiresAt: null, + bindProbeAt: null, + bindProbeStatus: '', + bindProbeMessage: '', }, role: { status: 'pending', diff --git a/apps/backend/src/services/platforms/cloudtentacles/shared.js b/apps/backend/src/services/platforms/cloudtentacles/shared.js index a584c319..24ed4ee1 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/shared.js +++ b/apps/backend/src/services/platforms/cloudtentacles/shared.js @@ -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() || '-', diff --git a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.js b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.js index b398a88c..70c4d353 100644 --- a/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.js +++ b/apps/backend/src/services/platforms/cloudtentacles/virtual-number-service.js @@ -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}×tamp=${signatureParams.timestamp}&nonce=${signatureParams.nonce}&sign=${signatureParams.sign}` + const cookies = [ + `tokenParams=${encodeURIComponent(tokenParams)}`, + 'gpqqcomrouteLine=a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc', + String(config.bindUrlProbeExtraCookie || '').trim(), + ].filter(Boolean) + + return cookies.join('; ') +} + +function extractBindUrlSignatureParams(bindUrl) { + 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) } diff --git a/apps/backend/src/types/runtime-config.js b/apps/backend/src/types/runtime-config.js index 03d1f7e0..1a9f5a3c 100644 --- a/apps/backend/src/types/runtime-config.js +++ b/apps/backend/src/types/runtime-config.js @@ -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 diff --git a/apps/frontend/src/types/admin.ts b/apps/frontend/src/types/admin.ts index ef4c1c65..0bb91865 100644 --- a/apps/frontend/src/types/admin.ts +++ b/apps/frontend/src/types/admin.ts @@ -671,6 +671,10 @@ export interface AdminTaskDetail { vnPhone: string bindUrl: string bindPreparedAt: string | null + bindExpiresAt: string | null + bindProbeAt: string | null + bindProbeStatus: string + bindProbeMessage: string } role: { status: string diff --git a/apps/frontend/src/types/claim.ts b/apps/frontend/src/types/claim.ts index 8a9f7473..59b7b087 100644 --- a/apps/frontend/src/types/claim.ts +++ b/apps/frontend/src/types/claim.ts @@ -85,6 +85,10 @@ export interface ClaimKuaishouCloudFlowInfo { vnId: number bindUrl: string bindPreparedAt: string | null + bindExpiresAt: string | null + bindProbeAt: string | null + bindProbeStatus: string + bindProbeMessage: string vnPhone: string roleName: string roleId: string diff --git a/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue b/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue index 3878f997..f4cd0916 100644 --- a/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue +++ b/apps/frontend/src/views/claim/KuaishouCloudClaimView.vue @@ -33,10 +33,23 @@ const order = computed(() => detail.value?.order || null) const orderItem = computed(() => detail.value?.orderItem || null) const task = computed(() => detail.value?.task || null) -const roleName = computed(() => flow.value?.binding.roleName || flow.value?.role.name || '') -const roleId = computed(() => flow.value?.binding.roleId || flow.value?.role.rid || '') +const roleName = computed(() => flow.value?.role.name || flow.value?.binding.roleName || '') +const roleId = computed(() => flow.value?.role.rid || flow.value?.binding.roleId || '') const isTicketVerified = computed(() => flow.value?.ticket.status === 'verified') -const isBindingPrepared = computed(() => flow.value?.binding.prepareStatus === 'ready' && Boolean(String(flow.value?.binding.bindUrl || '').trim())) +const isBindUrlExpired = computed(() => { + const expiresAt = String(flow.value?.binding.bindExpiresAt || '').trim() + if (!expiresAt) { + return false + } + + const expiresTime = Date.parse(expiresAt) + return Number.isFinite(expiresTime) && expiresTime <= Date.now() +}) +const isBindingPrepared = computed(() => ( + flow.value?.binding.prepareStatus === 'ready' + && Boolean(String(flow.value?.binding.bindUrl || '').trim()) + && !isBindUrlExpired.value +)) const isBindingPreparing = computed(() => flow.value?.binding.prepareStatus === 'pending') const canEnterBindingStep = computed(() => isTicketVerified.value && isBindingPrepared.value) const isRoleReady = computed(() => Boolean(roleName.value || roleId.value)) @@ -408,7 +421,10 @@ onBeforeUnmount(() => { 如果当前角色不正确,请重新打开绑定页完成重新绑定,然后刷新角色信息。

- 最近刷新:{{ formatDateTime(flow.role.refreshedAt) }} + 最近刷新:{{ formatDateTime(flow.role.refreshedAt) }};链接有效期:{{ formatDateTime(flow.binding.bindExpiresAt) }} +

+

+ 链接检测:{{ flow.binding.bindProbeStatus || '-' }} / {{ flow.binding.bindProbeMessage }}

@@ -425,7 +441,7 @@ onBeforeUnmount(() => { type="primary" size="large" :loading="confirmingRole" - :disabled="!isBindingPrepared" + :disabled="!isBindingPrepared || !isRoleReady" @click="confirmRole" > 我已完成绑定,下一步 diff --git a/docs/是否过期判断.md b/docs/是否过期判断.md new file mode 100644 index 00000000..c9d896ae --- /dev/null +++ b/docs/是否过期判断.md @@ -0,0 +1,58 @@ +curl -X POST 'https://comm.ams.game.qq.com/ide/' -H 'User-Agent: 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' -H 'Accept: application/json, text/plain, */*' -H 'Content-Type: application/x-www-form-urlencoded' -H 'accept-language: zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6' -H 'cache-control: no-cache' -H 'origin: https://gp.qq.com' -H 'pragma: no-cache' -H 'priority: u=1, i' -H 'referer: https://gp.qq.com/' -H 'sec-ch-ua: ""' -H 'sec-ch-ua-mobile: ?1' -H 'sec-ch-ua-platform: ""' -H 'sec-fetch-dest: empty' -H 'sec-fetch-mode: cors' -H 'sec-fetch-site: same-site' -H 'Cookie: RK=uMT6S1KV0W; ptcz=c5241ee891d01510c81130023879f9d68088e87c75cb94e6dcc439a0d0078bab; pgv_pvid=5513916356; eas_sid=a1t7G7I644L7p7O3w0S3Y7W1f0; pgv_info=ssid=s2668768060; tokenParams=%3FuserId%3D14987713343%26timestamp%3D1778739347%26nonce%3Dd593e045%26sign%3D43ed7de057afb340fc766de4404817f8396ec727; gpqqcomrouteLine=a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc_a20240828cmcc' --data-urlencode 'iChartId=323794' --data-urlencode 'iSubChartId=323794' --data-urlencode 'sIdeToken=z90Syo' --data-urlencode 'e_code=0' --data-urlencode 'g_code=0' --data-urlencode 'eas_url=http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F' --data-urlencode 'eas_refer=http%3A%2F%2Fnoreferrer%2F%3Freqid%3Da29ad787-5500-414c-8773-7723877f52c5%26version%3D27' --data-urlencode 'sMiloTag=AMS-gp-0514141601-VuK8ts-666311-1067699' --data-urlencode 'userId=14987713343' --data-urlencode 'timestamp=1778739347' --data-urlencode 'nonce=d593e045' --data-urlencode 'sign=43ed7de057afb340fc766de4404817f8396ec727' + +正常响应 +{ + "ret": 0, + "iRet": 0, + "sMsg": "ok", + "jData": { + "iRet": "0", + "sMsg": "ok", + "sBindInfo": { + "id": "213252", + "sUserId": "14987713343", + "sOpenId": "osewR0kiZgLUM7CTh2bIFsds9Yyo", + "iAreaId": "1", + "iPlatId": "1", + "sRoleId": "3177794957", + "sRoleName": "是海也是牛", + "iStatus": "1", + "iDate": "20260321", + "dtCreateTime": "2026-02-20 12:13:01", + "dtUpdateTime": "2026-03-21 10:47:03", + "hasBind": 1 + }, + "iStatus": "0" + }, + "sAmsSerial": "AMS-GP-0514141709-1UrrnM-666311-323794" +} + +签名过期 响应 +{ + "ret": 99998, + "iRet": 99998, + "sMsg": "签名已过期", + "jData": { + "ret": "99998", + "iRet": "99998", + "sMsg": "签名已过期", + "jData": { + "iRet": "99998", + "sMsg": "签名已过期" + }, + "arrErrNodeInfo": { + "nodeId": "nodeEnd", + "nodeType": "nodeEnd", + "errorCode": "99998" + }, + "sAmsSerial": "AMS-GP-0514141817-eN55LW-666311-323794", + "subChartAmsSerial": "AMS-GP-0514141817-eN55LW-666311-323794", + "timecost": 17 + }, + "arrErrNodeInfo": { + "nodeId": "4", + "nodeType": "nodeSubChart", + "errorCode": "99998" + }, + "sAmsSerial": "AMS-GP-0514141817-cZJfjw-666311-323794" +} \ No newline at end of file