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