优化兑换流程, 优化浏览器分辨率

This commit is contained in:
yml
2026-04-14 13:13:36 +08:00
parent 006e68f7f6
commit 96eb32ad03
12 changed files with 470 additions and 44 deletions
@@ -3,7 +3,7 @@
"provider": "agiso",
"platform": "xianyu",
"shopId": "2209880145223",
"skuCode": "sjz_hdl_test01",
"skuCode": "三角洲海底捞动作",
"skuName": "海底捞第五人格皮肤",
"profileKey": "tencent_claim_redeem",
"enabled": true,
@@ -22,7 +22,7 @@
"provider": "agiso",
"platform": "xianyu",
"shopId": "2209880145223",
"skuCode": "sjz_hdl_test01",
"skuCode": "三角洲海底捞动作",
"skuName": "海底捞干员庆生动作1个",
"profileKey": "tencent_claim_redeem",
"enabled": true,
@@ -41,7 +41,7 @@
"provider": "agiso",
"platform": "xianyu",
"shopId": "693760716",
"skuCode": "sjz_hdl_test01",
"skuCode": "三角洲海底捞动作",
"skuName": "三角洲行动-自动领取",
"profileKey": "tencent_claim_assisted",
"enabled": true,
+34 -1
View File
@@ -15,10 +15,43 @@ is_true() {
start_browser_debug_services() {
display="${TENCENT_BROWSER_DISPLAY:-:99}"
resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1440x1280x24}"
resolution="${TENCENT_BROWSER_VNC_RESOLUTION:-1600x900x24}"
display_number="${display#:}"
display_number="${display_number%%.*}"
display_socket="/tmp/.X11-unix/X${display_number}"
display_lock="/tmp/.X${display_number}-lock"
if [ -f "$display_lock" ]; then
display_pid=$(cat "$display_lock" 2>/dev/null || true)
if [ -z "$display_pid" ] || ! kill -0 "$display_pid" 2>/dev/null; then
rm -f "$display_lock" "$display_socket"
fi
fi
export DISPLAY="$display"
Xvfb "$display" -screen 0 "$resolution" -nolisten tcp >/tmp/xvfb.log 2>&1 &
xvfb_pid=$!
ready=0
attempt=0
while [ "$attempt" -lt 50 ]; do
if [ -S "$display_socket" ]; then
ready=1
break
fi
if ! kill -0 "$xvfb_pid" 2>/dev/null; then
break
fi
attempt=$((attempt + 1))
sleep 0.1
done
if [ "$ready" -ne 1 ]; then
echo "[backend-dev] Xvfb display ${DISPLAY} not ready for x11vnc; skip noVNC startup" >&2
return
fi
if ! is_true "${TENCENT_BROWSER_NOVNC_ENABLED:-true}"; then
echo "[backend-dev] headed browser enabled on ${DISPLAY}; noVNC disabled"
@@ -696,15 +696,39 @@ function buildClaimDetailPayload({ claimToken, task, order, orderItem, session }
}
async function redeemClaimTaskWithInventoryFallback(context, initialInventoryItem) {
return redeemClaimTaskWithInventoryFallbackWithDeps(context, initialInventoryItem)
}
export async function redeemClaimTaskWithInventoryFallbackWithDeps(
context,
initialInventoryItem,
{
reloadTencentBrowserSession: reloadRedeemSession = reloadTencentBrowserSession,
redeemTencentBrowserSession: redeemSession = redeemTencentBrowserSession,
classifyTencentRedeemResult: classifyRedeemResult = classifyTencentRedeemResult,
markInventoryItemConsumed: markConsumedInventoryItem = markInventoryItemConsumed,
createTaskEvent: createRedeemTaskEvent = createTaskEvent,
invalidateReservedInventoryItem: invalidateReservedItem = invalidateReservedInventoryItem,
reserveInventoryForTask: reserveReplacementInventory = reserveInventoryForTask,
nowIso: getNowIso = nowIso,
redeemReplacementLimit = REDEEM_REPLACEMENT_LIMIT,
} = {},
) {
const attempts = []
let currentInventoryItem = initialInventoryItem
let shouldReloadBeforeNextAttempt = false
for (let attemptIndex = 1; attemptIndex <= REDEEM_REPLACEMENT_LIMIT; attemptIndex += 1) {
const session = await redeemTencentBrowserSession(context.task.browser_session_id, {
for (let attemptIndex = 1; attemptIndex <= redeemReplacementLimit; attemptIndex += 1) {
if (shouldReloadBeforeNextAttempt) {
await reloadRedeemSession(context.task.browser_session_id)
shouldReloadBeforeNextAttempt = false
}
const session = await redeemSession(context.task.browser_session_id, {
code: currentInventoryItem.display_value,
})
const finalRedeem = session.redeem?.final?.redeem || null
const classification = classifyTencentRedeemResult(finalRedeem)
const classification = classifyRedeemResult(finalRedeem)
const attemptSummary = {
attempt: attemptIndex,
inventoryItemId: currentInventoryItem.id,
@@ -727,18 +751,18 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte
}
if (classification.outcome === 'code_used') {
const updatedAt = nowIso()
await markInventoryItemConsumed(currentInventoryItem.id, classification.message, updatedAt)
await createTaskEvent(context.task.id, 'claim_redeem_code_used', {
const updatedAt = getNowIso()
await markConsumedInventoryItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_used', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
resultMessage: classification.message,
}, updatedAt)
} else if (classification.outcome === 'code_invalid') {
const updatedAt = nowIso()
await invalidateReservedInventoryItem(currentInventoryItem.id, classification.message, updatedAt)
await createTaskEvent(context.task.id, 'claim_redeem_code_invalid', {
const updatedAt = getNowIso()
await invalidateReservedItem(currentInventoryItem.id, classification.message, updatedAt)
await createRedeemTaskEvent(context.task.id, 'claim_redeem_code_invalid', {
inventoryItemId: currentInventoryItem.id,
codeMasked: attemptSummary.codeMasked,
resultCode: attemptSummary.resultCode,
@@ -755,7 +779,7 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte
})
}
if (attemptIndex >= REDEEM_REPLACEMENT_LIMIT) {
if (attemptIndex >= redeemReplacementLimit) {
throw createRedeemTaskStateError('连续更换兑换码后仍未成功,请联系人工处理', {
errorCode: 'claim_redeem_replacement_limit_reached',
taskStatus: 'retry_pending',
@@ -766,7 +790,7 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte
})
}
const replacement = await reserveInventoryForTask({
const replacement = await reserveReplacementInventory({
skuCode: context.orderItem.sku_code,
taskId: context.task.id,
credentialType: currentInventoryItem.credential_type || 'tencent_code',
@@ -788,8 +812,8 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte
})
}
const replacedAt = nowIso()
await createTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', {
const replacedAt = getNowIso()
await createRedeemTaskEvent(context.task.id, 'claim_redeem_inventory_replaced', {
previousInventoryItemId: currentInventoryItem.id,
previousCodeMasked: attemptSummary.codeMasked,
previousOutcome: classification.outcome,
@@ -798,6 +822,7 @@ async function redeemClaimTaskWithInventoryFallback(context, initialInventoryIte
credentialType: String(replacement.credential_type || currentInventoryItem.credential_type || ''),
}, replacedAt)
currentInventoryItem = replacement
shouldReloadBeforeNextAttempt = true
}
throw createRedeemTaskStateError('兑换失败,请稍后重试', {
@@ -0,0 +1,167 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { redeemClaimTaskWithInventoryFallbackWithDeps } from './claim-session-service.js'
test('redeemClaimTaskWithInventoryFallbackWithDeps invalidates missing CDKEY and retries with replacement inventory', async () => {
const events = []
const invalidated = []
const reserveCalls = []
const reloadCalls = []
const context = {
task: {
id: 42,
browser_session_id: 'txbs-test',
},
orderItem: {
sku_code: 'df-cdk',
},
}
const initialInventoryItem = {
id: 1,
display_value: 'DJQFf7HgONCL4XD4EH',
credential_type: 'tencent_code',
}
const replacementInventoryItem = {
id: 2,
display_value: 'ABCD1234EFGH5678',
credential_type: 'tencent_code',
}
let redeemCount = 0
const result = await redeemClaimTaskWithInventoryFallbackWithDeps(
context,
initialInventoryItem,
{
reloadTencentBrowserSession: async (sessionId) => {
reloadCalls.push(sessionId)
return { sessionId, status: 'ready_to_redeem' }
},
redeemTencentBrowserSession: async (sessionId, payload) => {
redeemCount += 1
if (redeemCount === 1) {
assert.equal(sessionId, 'txbs-test')
assert.equal(payload.code, 'DJQFf7HgONCL4XD4EH')
return {
redeem: {
final: {
redeem: {
iRet: -183,
sMsg: '该CDKEY不存在,请您确认后输入!',
},
},
},
}
}
assert.equal(payload.code, 'ABCD1234EFGH5678')
return {
redeem: {
final: {
redeem: {
iRet: 0,
sMsg: '领取成功',
},
},
},
}
},
invalidateReservedInventoryItem: async (inventoryItemId, reason, updatedAt) => {
invalidated.push({ inventoryItemId, reason, updatedAt })
return { id: inventoryItemId, status: 'invalid', invalid_reason: reason, updated_at: updatedAt }
},
reserveInventoryForTask: async (payload) => {
reserveCalls.push(payload)
return replacementInventoryItem
},
createTaskEvent: async (taskId, eventType, payload, createdAt) => {
events.push({ taskId, eventType, payload, createdAt })
},
nowIso: (() => {
const values = [
'2026-04-14T12:03:34.000Z',
'2026-04-14T12:03:35.000Z',
]
let index = 0
return () => values[index++] || values[values.length - 1]
})(),
},
)
assert.equal(result.inventoryItem.id, 2)
assert.equal(result.classification.outcome, 'success')
assert.equal(result.attempts.length, 2)
assert.deepEqual(
result.attempts.map((attempt) => ({
attempt: attempt.attempt,
inventoryItemId: attempt.inventoryItemId,
outcome: attempt.outcome,
resultCode: attempt.resultCode,
})),
[
{
attempt: 1,
inventoryItemId: 1,
outcome: 'code_invalid',
resultCode: '-183',
},
{
attempt: 2,
inventoryItemId: 2,
outcome: 'success',
resultCode: '0',
},
],
)
assert.deepEqual(invalidated, [
{
inventoryItemId: 1,
reason: '该CDKEY不存在,请您确认后输入!',
updatedAt: '2026-04-14T12:03:34.000Z',
},
])
assert.deepEqual(reserveCalls, [
{
skuCode: 'df-cdk',
taskId: 42,
credentialType: 'tencent_code',
roleKey: 'primary_code',
},
])
assert.deepEqual(reloadCalls, ['txbs-test'])
assert.deepEqual(
events.map((event) => ({
eventType: event.eventType,
taskId: event.taskId,
createdAt: event.createdAt,
payload: event.payload,
})),
[
{
eventType: 'claim_redeem_code_invalid',
taskId: 42,
createdAt: '2026-04-14T12:03:34.000Z',
payload: {
inventoryItemId: 1,
codeMasked: 'DJQF****D4EH',
resultCode: '-183',
resultMessage: '该CDKEY不存在,请您确认后输入!',
},
},
{
eventType: 'claim_redeem_inventory_replaced',
taskId: 42,
createdAt: '2026-04-14T12:03:35.000Z',
payload: {
previousInventoryItemId: 1,
previousCodeMasked: 'DJQF****D4EH',
previousOutcome: 'code_invalid',
nextInventoryItemId: 2,
nextCodeMasked: 'ABCD****5678',
credentialType: 'tencent_code',
},
},
],
)
})
@@ -299,7 +299,11 @@ export function classifyTencentRedeemResult(result) {
return buildTencentRedeemClassification('code_used', retCode, message || '兑换码已使用')
}
if (/兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确/.test(normalized)) {
if (
retCode === -165 ||
retCode === -183 ||
/兑换码错误|cdk错误|cdkey错误|请确认兑换码信息是否准确|兑换码信息是否准确|兑换码不存在|cdk不存在|cdkey不存在|不存在请您确认后输入/.test(normalized)
) {
return buildTencentRedeemClassification('code_invalid', retCode, message || '兑换码错误,请确认兑换码信息是否准确')
}
@@ -45,6 +45,17 @@ test('classifyTencentRedeemResult marks invalid codes as replacement retries', (
assert.equal(result.retryWithReplacementCode, true)
})
test('classifyTencentRedeemResult treats missing CDKEY responses as invalid replacement retries', () => {
const result = classifyTencentRedeemResult({
iRet: -183,
sMsg: '该CDKEY不存在,请您确认后输入!',
})
assert.equal(result.outcome, 'code_invalid')
assert.equal(result.success, false)
assert.equal(result.retryWithReplacementCode, true)
})
test('classifyTencentRedeemResult recognizes successful gift popup', () => {
const result = classifyTencentRedeemResult({
popup: {
+32 -1
View File
@@ -41,7 +41,7 @@ const ACTIVITY_URL = 'https://df.qq.com/cp/a20240812cdk/index.html'
const CHROME_PATH = String(runtimeConfig.browser.chromePath || '').trim()
const DEFAULT_LOGIN_TYPE = 'qq'
const SESSION_TTL_MS = 30 * 60 * 1000
const DEFAULT_VIEWPORT = { width: 1440, height: 1280 }
const DEFAULT_VIEWPORT = resolveDefaultViewport()
const SESSION_DEBUG_ENABLED = Boolean(runtimeConfig.session.debug)
const sessions = new Map()
@@ -429,6 +429,37 @@ function buildChromiumLaunchOptions(launchOptions) {
return options
}
function resolveDefaultViewport() {
const fromVncResolution = parseViewportSpec(process.env.TENCENT_BROWSER_VNC_RESOLUTION)
if (fromVncResolution) {
return fromVncResolution
}
return { width: 1600, height: 900 }
}
function parseViewportSpec(rawValue) {
const text = String(rawValue || '').trim()
if (!text) {
return null
}
const match = text.match(/^(\d+)x(\d+)(?:x\d+)?$/i)
if (!match) {
return null
}
const width = Number(match[1])
const height = Number(match[2])
if (!Number.isFinite(width) || !Number.isFinite(height) || width < 800 || height < 600) {
return null
}
return { width, height }
}
function createSessionId() {
let sessionId = ''