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 个用例通过
## 下一步建议