From 2360323581a914eb55c016d3fa4ffe1c515b1d4b Mon Sep 17 00:00:00 2001 From: yml Date: Thu, 21 May 2026 15:13:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=90=8E=E7=AB=AF=E8=BF=81=E7=A7=BB=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=85=91=E6=8D=A2=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{session-redeem.js => session-redeem.ts} | 195 +++++++++++++++--- apps/backend/tsconfig.json | 2 +- docs/backend-typescript-migration-plan.md | 8 + 3 files changed, 181 insertions(+), 24 deletions(-) rename apps/backend/src/services/session/{session-redeem.js => session-redeem.ts} (72%) diff --git a/apps/backend/src/services/session/session-redeem.js b/apps/backend/src/services/session/session-redeem.ts similarity index 72% rename from apps/backend/src/services/session/session-redeem.js rename to apps/backend/src/services/session/session-redeem.ts index cbc764ba..af6be4bc 100644 --- a/apps/backend/src/services/session/session-redeem.js +++ b/apps/backend/src/services/session/session-redeem.ts @@ -1,7 +1,137 @@ -// @ts-nocheck +/// import fs from 'node:fs/promises' +type JsonObject = Record + +type BrowserResponseLike = { + url: () => string + request: () => { method: () => string } + text: () => Promise + status: () => number +} + +type BrowserLocatorLike = { + screenshot: (options: { path: string }) => Promise + getAttribute: (name: string) => Promise + click: (options?: JsonObject) => Promise + evaluate: (callback: (element: Element) => T | Promise) => Promise +} + +type BrowserPageLike = { + waitForFunction: (callback: any, argOrOptions?: any, options?: JsonObject) => Promise + waitForResponse: ( + predicate: (response: BrowserResponseLike) => boolean, + options?: JsonObject, + ) => Promise + waitForTimeout: (milliseconds: number) => Promise + locator: (selector: string) => BrowserLocatorLike + evaluate: (callback: any, arg?: any) => Promise +} + +type ActivityInfo = { + role?: JsonObject & { + ready?: boolean + area?: string + } + form?: { + cdkeyInputId?: string + verifyInputId?: string + verifyImgId?: string + submitId?: string + } +} + +type TencentRedeemPopup = { + visible?: boolean + text?: string + detail?: string +} + +type TencentRedeemResult = JsonObject & { + iRet?: number | string + sMsg?: string + msg?: string + popup?: TencentRedeemPopup | null +} + +type TencentRedeemOutcome = 'success' | 'captcha_rejected' | 'code_used' | 'code_invalid' | 'failed' + +type TencentRedeemClassification = { + outcome: TencentRedeemOutcome + success: boolean + retryWithSameCode: boolean + retryWithReplacementCode: boolean + retCode: number | null + message: string +} + +type RedeemAttemptRecord = { + attempt: number + verifyCode: string + verifysession: string + ocrSample: unknown + redeem: TencentRedeemResult + classification: TencentRedeemClassification + role: ActivityInfo['role'] +} + +type TencentBrowserRedeemSession = JsonObject & { + status: string + notice: string + updatedAt: string + lastError?: string + lastRedeem?: JsonObject + page: BrowserPageLike + browserContext: unknown + sessionDir: string + sessionId: string +} + +type RunTencentBrowserRedeemOptions = { + session: TencentBrowserRedeemSession + code: string + maxAttempts: number + ensureLoggedInPresentation: (session: TencentBrowserRedeemSession, options?: JsonObject) => Promise + ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise + fillRedeemCodeInBrowser: (page: BrowserPageLike, code: string, options?: JsonObject) => Promise + capturePageCaptchaForOcr: ( + page: BrowserPageLike, + options: { sessionDir: string; attempt: number }, + ) => Promise<{ + imageBuffer: Buffer + imagePath: string + imageExtension: string + contentType: string + verifyImgId: string + verifysession: string + }> + recognizeTencentCaptcha: (payload: JsonObject) => Promise + submitRedeemInBrowser: ( + page: BrowserPageLike, + payload: { code: string; verifyCode: string }, + ) => Promise + isCaptchaRejectedResult: (result: TencentRedeemResult) => boolean + refreshPageCaptcha: (page: BrowserPageLike, verifyImgId?: string) => Promise + persistSessionState: (session: TencentBrowserRedeemSession) => Promise + buildSessionPayload: (session: TencentBrowserRedeemSession) => JsonObject + saveRedeemArtifacts: (payload: JsonObject) => Promise + activityUrl: string +} + +type ActivityInfoProvider = { + ensureActivityInfoReady: (page: BrowserPageLike, options?: JsonObject) => Promise +} + +declare global { + interface Window { + Milo?: { + get?: (key: string) => unknown + } + closeDialog?: () => void + } +} + export async function runTencentBrowserRedeem({ session, code, @@ -18,14 +148,14 @@ export async function runTencentBrowserRedeem({ buildSessionPayload, saveRedeemArtifacts, activityUrl, -}) { +}: RunTencentBrowserRedeemOptions): Promise { session.status = 'redeeming' session.notice = '正在识别验证码并提交兑换' session.updatedAt = new Date().toISOString() await persistSessionState(session) - const attempts = [] - let finalResult = null + const attempts: RedeemAttemptRecord[] = [] + let finalResult: RedeemAttemptRecord | null = null try { await ensureLoggedInPresentation(session, { credentialReady: true }) @@ -133,7 +263,10 @@ export async function runTencentBrowserRedeem({ } } -export async function capturePageCaptchaForOcr(page, { sessionDir, attempt, ensureActivityInfoReady }) { +export async function capturePageCaptchaForOcr( + page: BrowserPageLike, + { sessionDir, attempt, ensureActivityInfoReady }: { sessionDir: string; attempt: number } & ActivityInfoProvider, +) { const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 5_000 }) if (!activityInfo?.form?.verifyImgId) { @@ -187,7 +320,11 @@ export async function capturePageCaptchaForOcr(page, { sessionDir, attempt, ensu } } -export async function submitRedeemInBrowser(page, payload, { ensureActivityInfoReady }) { +export async function submitRedeemInBrowser( + page: BrowserPageLike, + payload: { code: string; verifyCode: string }, + { ensureActivityInfoReady }: ActivityInfoProvider, +): Promise { const activityInfo = await ensureActivityInfoReady(page, { timeoutMs: 2_000 }) if (!activityInfo?.form?.verifyInputId || !activityInfo?.form?.submitId) { @@ -237,7 +374,11 @@ export async function submitRedeemInBrowser(page, payload, { ensureActivityInfoR } } -export function fillRedeemCodeInBrowser(page, code, { activityInfo }) { +export function fillRedeemCodeInBrowser( + page: BrowserPageLike, + code: string, + { activityInfo }: { activityInfo?: ActivityInfo } = {}, +) { const cdkeySelector = activityInfo?.form?.cdkeyInputId ? `#${activityInfo.form.cdkeyInputId}` : '[id^="milo_cdkeyInfo_"]' @@ -252,12 +393,16 @@ export function fillRedeemCodeInBrowser(page, code, { activityInfo }) { ]) } -export async function prepareRedeemCodeFill(page, code, { ensureActivityInfoReady }) { +export async function prepareRedeemCodeFill( + page: BrowserPageLike, + code: string, + { ensureActivityInfoReady }: ActivityInfoProvider, +) { const activityInfo = await ensureActivityInfoReady(page, { triggerRender: true, timeoutMs: 2_000 }) await fillRedeemCodeInBrowser(page, code, { activityInfo }) } -export async function refreshPageCaptcha(page, verifyImgId) { +export async function refreshPageCaptcha(page: BrowserPageLike, verifyImgId?: string) { if (!verifyImgId) { return } @@ -280,7 +425,7 @@ export async function refreshPageCaptcha(page, verifyImgId) { ).catch(() => null) } -export function isCaptchaRejectedResult(result) { +export function isCaptchaRejectedResult(result: TencentRedeemResult): boolean { if (classifyTencentRedeemResult(result).outcome === 'captcha_rejected') { return true } @@ -288,7 +433,7 @@ export function isCaptchaRejectedResult(result) { return false } -export function classifyTencentRedeemResult(result) { +export function classifyTencentRedeemResult(result: TencentRedeemResult): TencentRedeemClassification { const retCode = Number(result?.iRet) const message = resolveTencentRedeemMessage(result) const normalized = normalizeTencentRedeemText(message) @@ -323,7 +468,7 @@ export function classifyTencentRedeemResult(result) { return buildTencentRedeemClassification('failed', retCode, message || '兑换失败') } -function resetRedeemPopup(page) { +function resetRedeemPopup(page: BrowserPageLike) { return page.evaluate(() => { const popup = document.querySelector('#pop2') const popupText = document.querySelector('#PopText') @@ -344,7 +489,7 @@ function resetRedeemPopup(page) { }) } -async function dismissRedeemRetryPopup(page) { +async function dismissRedeemRetryPopup(page: BrowserPageLike) { await page.evaluate(() => { try { if (typeof window.closeDialog === 'function') { @@ -372,7 +517,7 @@ async function dismissRedeemRetryPopup(page) { await page.waitForTimeout(150) } -async function normalizeRedeemOverlay(page) { +async function normalizeRedeemOverlay(page: BrowserPageLike) { await page.evaluate(() => { const overlayIds = ['_overlay_', 'overlay_mask', 'overlay'] @@ -390,7 +535,7 @@ async function normalizeRedeemOverlay(page) { }) } -async function clickRedeemSubmit(page, submitSelector) { +async function clickRedeemSubmit(page: BrowserPageLike, submitSelector: string) { const locator = page.locator(submitSelector) try { @@ -412,12 +557,12 @@ async function clickRedeemSubmit(page, submitSelector) { } } -async function setFormControlValue(page, selector, value) { +async function setFormControlValue(page: BrowserPageLike, selector: string, value: unknown) { const nextValue = String(value ?? '') const appliedValue = await page.evaluate( ({ targetSelector, targetValue }) => { - const isVisible = (element) => { + const isVisible = (element: Element) => { if (!(element instanceof HTMLElement)) { return false } @@ -491,7 +636,7 @@ async function setFormControlValue(page, selector, value) { } } -async function waitForRedeemPopup(page) { +async function waitForRedeemPopup(page: BrowserPageLike): Promise { await page.waitForFunction(() => { const popup = document.querySelector('#pop2') const popupText = document.querySelector('#PopText') @@ -511,7 +656,7 @@ async function waitForRedeemPopup(page) { })) } -function tryParseJson(text) { +function tryParseJson(text: string): any { try { return JSON.parse(text) } catch { @@ -519,7 +664,7 @@ function tryParseJson(text) { } } -function inferRedeemCodeFromPopup(popup) { +function inferRedeemCodeFromPopup(popup: TencentRedeemPopup) { const text = `${String(popup?.text || '')} ${String(popup?.detail || '')}`.trim() if (!text) { @@ -537,7 +682,7 @@ function inferRedeemCodeFromPopup(popup) { return 1 } -export function resolveTencentRedeemMessage(result) { +export function resolveTencentRedeemMessage(result: TencentRedeemResult): string { const candidates = [ result?.popup?.detail, result?.popup?.text, @@ -550,7 +695,11 @@ export function resolveTencentRedeemMessage(result) { return candidates[0] || '兑换完成' } -function buildTencentRedeemClassification(outcome, retCode, message) { +function buildTencentRedeemClassification( + outcome: TencentRedeemOutcome, + retCode: number, + message: string, +): TencentRedeemClassification { return { outcome, success: outcome === 'success', @@ -561,7 +710,7 @@ function buildTencentRedeemClassification(outcome, retCode, message) { } } -function normalizeTencentRedeemText(value) { +function normalizeTencentRedeemText(value: unknown): string { return String(value || '') .replace(/\s+/g, '') .trim() diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 4111c803..e31204e4 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -19,7 +19,7 @@ "node_modules", "data", "src/**/*.test.js", - "src/services/session/**", + "src/services/session/**/*.js", "src/services/platforms/agiso/**/*.js" ] } diff --git a/docs/backend-typescript-migration-plan.md b/docs/backend-typescript-migration-plan.md index 9fc3d4fc..88d109b2 100644 --- a/docs/backend-typescript-migration-plan.md +++ b/docs/backend-typescript-migration-plan.md @@ -389,6 +389,14 @@ - `npm run typecheck` - `npm run build` - `npm test` 共 139 个用例通过 +71. 腾讯浏览器会话兑换服务迁移到 `.ts`: + - `src/services/session/session-redeem.ts` +72. 会话兑换编排、验证码截图 / OCR payload、页面提交、弹窗结果解析、兑换结果分类已补轻量结构类型;`tsconfig.json` 已将 session 新增 `.ts` 文件纳入 `typecheck`,旧 session `.js` 文件继续暂时排除 +73. Docker 内验证通过: + - `src/services/session/session-redeem.test.js` 共 6 个用例通过 + - `npm run typecheck` + - `npm run build` + - `npm test` 共 139 个用例通过 ## 下一步建议