Compare commits

..
2 Commits
Author SHA1 Message Date
fenghp 0bcf5e011f fix: adjust douyin recharge flow 2026-06-18 18:10:07 +08:00
fenghp 5da2ea2330 chore: save current changes 2026-06-18 14:01:05 +08:00
7 changed files with 269 additions and 35 deletions
+13
View File
@@ -54,3 +54,16 @@ export function reportTaskResult(data) {
data
})
}
/**
* 按付款手机号为当前任务取短信验证码。
* 轮询期间静默请求,避免无可用验证码时反复弹出错误提示。
*/
export function fetchSmsCode(params) {
return request({
url: '/af/mobile/sms/code',
method: 'GET',
data: params,
silent: true
})
}
+38 -27
View File
@@ -37,6 +37,16 @@ export default [
retry: 2,
retryDelay: 800
},
{
code: 'CLICK_ADD_DOUYIN_ACCOUNT',
name: '点击添加抖音号',
action: 'click',
locator: { type: 'text', value: '添加抖音号' },
waitTimeout: 5000,
delay: 0,
retry: 2,
retryDelay: 800
},
{
code: 'INPUT_PHONE',
name: '输入手机号',
@@ -48,26 +58,27 @@ export default [
retry: 2,
retryDelay: 800
},
{
code: 'CLICK_CONFIRM',
name: '点击完成',
action: 'click',
locator: { type: 'css', value: 'button[class*="switch_account_simple_content_wrapper_confirm_button"]' },
waitTimeout: 3000,
delay: 0
},
// 新页面输入账号后通过状态节点提示用户不存在;未出现该提示时继续点击立即添加。
{
code: 'CHECK_ACCOUNT_NOT_FOUND',
name: '检查账号是否存在',
action: 'textCheck',
locator: { type: 'css', value: '.dux-toast-text' },
locator: { type: 'css', value: 'div[class*="account_check_status-"]' },
value: '用户不存在',
failMessage: '账号不存在',
passMessage: '未检测到账号不存在提示',
scriptTimeout: 600,
pollInterval: 150,
passMessage: '未检测到账号不存在提示,账号可添加',
scriptTimeout: 3000,
pollInterval: 200,
detectMode: 'instant'
},
{
code: 'CLICK_CONFIRM',
name: '点击立即添加',
action: 'click',
locator: { type: 'text', value: '立即添加' },
waitTimeout: 3000,
delay: 0
},
{
code: 'CLICK_DIALOG_CONFIRM',
name: '确认账号弹窗',
@@ -131,21 +142,6 @@ export default [
absentMessage: '未出现账号确认弹窗,继续执行',
notClickedMessage: '检测到账号确认弹窗,但未能点击确认按钮'
},
{
code: 'ASSERT_PAY_AMOUNT',
name: '校验支付页金额',
action: 'amountAssert',
locator: { type: 'css', value: '.common-header__price' },
waitTimeout: 5000,
scriptTimeout: 3000,
delay: 0,
retry: 1,
retryDelay: 300,
assertOptions: {
expectedFixed: 2,
actualFixed: 2
}
},
{
code: 'CLICK_BROWSER_PAY',
name: '点击继续浏览器付款',
@@ -214,6 +210,21 @@ export default [
retry: 1,
retryDelay: 500
},
{
code: 'HANDLE_SMS_VERIFY',
name: '处理短信验证码',
action: 'fetchSmsCodeIfPresent',
scriptTimeout: 12000,
delay: 400,
skipOnFail: false,
smsOptions: {
pagePresentLocator: { type: 'css', value: 'input[type="tel"][maxlength="6"]', requireInteractable: false },
inputLocator: { type: 'css', value: 'input[type="tel"][maxlength="6"]', requireInteractable: false },
pollInterval: 5000,
pollTimeout: 120000,
inputMode: 'nativeSetValue'
}
},
{
code: 'WAIT_PAY_SUCCESS_RESULT',
name: '等待支付成功结果',
+56 -5
View File
@@ -98,14 +98,25 @@
placeholder="请输入充值支付密码"
/>
</view>
<button class="button-secondary button-space" @tap="saveRechargePayPassword">保存充值密码</button>
<view class="field">
<view class="field-label">付款手机号</view>
<input
v-model="payerPhone"
class="input"
type="number"
maxlength="11"
placeholder="请输入接收验证码的付款手机号"
/>
<view class="field-value">需与收码真机转发模板中的 receiverPhone 保持一致。</view>
</view>
<button class="button-secondary button-space" @tap="saveRechargeConfig">保存配置</button>
<button class="button-primary button-space" @tap="openRechargePage">配置账号</button>
<button class="button-secondary button-space" @tap="clearAccountConfig">清除已有账号</button>
</view>
<view v-if="!hasTask" class="section">
<view class="section-title">说明</view>
<view class="field-value">充值密码会保存在本地缓存中,清除已有账号会同时清理 WebView 缓存和充值密码。</view>
<view class="field-value">充值密码与付款手机号会保存在本地缓存中,清除已有账号会同时清理 WebView 缓存、充值密码和付款手机号。</view>
</view>
</view>
</template>
@@ -119,7 +130,7 @@ import { updateDeviceWorkStatus } from '../../api/device'
// dryRun 由 appConfig.dryRun 控制,true 时跳过所有后端上报
import { runStepEngine } from '../../services/stepEngine'
import douyinSteps from '../../config/steps/douyin-recharge'
import { getRechargePayPasswordStorage, getUserInfo } from '../../utils/storage'
import { getPayerPhoneStorage, getRechargePayPasswordStorage, getUserInfo } from '../../utils/storage'
import { captureAndUploadErrorScreenshot } from '../../services/screenshot'
import { resolveFeishuEnvironment, sendFeishuErrorNotification } from '../../services/feishuNotifier'
@@ -132,6 +143,7 @@ export default {
taskId: null,
rechargeUrl: appConfig.rechargeUrl,
rechargePayPassword: '',
payerPhone: '',
failReason: '',
lastBlockedAppSchemeUrl: '',
lastBlockedAppSchemeAt: 0
@@ -165,6 +177,7 @@ export default {
},
onLoad(options) {
this.rechargePayPassword = getRechargePayPasswordStorage()
this.payerPhone = getPayerPhoneStorage()
if (options && options.taskId) {
this.taskId = Number(options.taskId) || options.taskId
this.$nextTick(() => {
@@ -260,6 +273,8 @@ export default {
const task = this.task
const url = task.rechargeUrl || this.rechargeUrl
const rechargePayPassword = this.$store.state.rechargePayPassword || getRechargePayPasswordStorage()
// 任务开始时读取一次付款手机号并锁进运行时上下文,执行过程中不再回读本地配置。
const payerPhone = this.$store.state.payerPhone || getPayerPhoneStorage()
if (!url) {
uni.showToast({ title: '充值页地址未配置', icon: 'none' })
return
@@ -351,6 +366,9 @@ export default {
amount: task.amount,
rechargeAccount: task.rechargeAccount || task.recharge_account,
rechargePayPassword,
payerPhone,
taskId: this.taskId,
orderNo: task.orderNo || task.order_no,
rechargeUrl: url,
runtimeSnapshots: {},
// 订单成功后直接结束,本地页面复位统一放到下一单开始前处理。
@@ -570,6 +588,9 @@ export default {
if (this.rechargePayPassword && !this.persistRechargePayPassword(false)) {
return
}
if (this.payerPhone && !this.persistPayerPhone(false)) {
return
}
const currentWebview = this.$scope && this.$scope.$getAppWebview ? this.$scope.$getAppWebview() : null
const webview = openRechargeWebview(this.rechargeUrl, currentWebview, {
onBlockedAppScheme: this.handleBlockedAppScheme
@@ -591,8 +612,36 @@ export default {
}
return true
},
saveRechargePayPassword() {
this.persistRechargePayPassword(true)
/**
* 校验并保存付款手机号。允许留空,非空时必须为 11 位手机号。
*
* @param {boolean} showToast 保存成功时是否提示
* @returns {boolean} 是否保存成功(格式不合法返回 false)
*/
persistPayerPhone(showToast) {
const phone = String(this.payerPhone || '').trim()
if (phone && !/^1\d{10}$/.test(phone)) {
uni.showToast({ title: '付款手机号需为11位手机号', icon: 'none' })
return false
}
this.payerPhone = phone
this.$store.commit('setPayerPhone', phone)
if (showToast) {
uni.showToast({ title: '付款手机号已保存', icon: 'none' })
}
return true
},
/**
* 一次性保存充值密码与付款手机号配置。
*/
saveRechargeConfig() {
if (!this.persistRechargePayPassword(false)) {
return
}
if (!this.persistPayerPhone(false)) {
return
}
uni.showToast({ title: '配置已保存', icon: 'none' })
},
goConfig() {
this.$store.commit('setExecutionState', 'IDLE')
@@ -611,7 +660,9 @@ export default {
this.$store.commit('setDouyinLoginStatus', 'unknown')
this.$store.commit('setAlipayLoginStatus', 'unknown')
this.$store.commit('clearRechargePayPassword')
this.$store.commit('clearPayerPhone')
this.rechargePayPassword = ''
this.payerPhone = ''
uni.showToast({ title: '已清除账号缓存,可重新配置', icon: 'none' })
}
}
+123
View File
@@ -16,11 +16,54 @@ import {
buildPasswordPageDiagnoseScript,
buildCallbackPageDiagnoseScript
} from './locator'
import { fetchSmsCode } from '../api/task'
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
/**
* 生成读取验证码页掩码手机号的 JS 代码。
* 在页面文本中查找形如 187******99 的掩码串。
*
* @returns {string} JS 代码,执行后返回 { masked }
*/
function buildReadMaskedPhoneScript() {
return `
var text = '';
var nodes = document.querySelectorAll('div[class*="mainHeaderSub"], [class*="phone"], [class*="mobile"]');
for (var i = 0; i < nodes.length; i++) {
text = String(nodes[i].innerText || nodes[i].textContent || '').replace(/\\s+/g, '');
if (/\\d{3}\\*+\\d{2}/.test(text)) break;
}
if (!/\\d{3}\\*+\\d{2}/.test(text)) {
text = (document.body && (document.body.innerText || document.body.textContent)) || '';
text = String(text).replace(/\\s+/g, '');
}
var m = text.match(/(\\d{3})\\*+(\\d{2})(?!\\d)/) || text.match(/(\\d{3})\\*+(\\d{2})/);
return { masked: m ? m[0] : '' };
`
}
/**
* 校验页面掩码手机号是否与付款手机号一致。
* 规则:前 3 位 + 后 N 位(N 取掩码尾部数字长度)一致。
*
* @param {string} payerPhone 本地配置付款手机号
* @param {string} maskedPhone 页面掩码手机号,如 187******99
* @returns {boolean} 是否一致
*/
function isMaskedPhoneMatch(payerPhone, maskedPhone) {
const matched = String(maskedPhone || '').match(/(\d{3})\*+(\d{2})/)
if (!matched) {
return false
}
const front = matched[1]
const tail = matched[2]
const phone = String(payerPhone || '')
return phone.slice(0, 3) === front && phone.slice(-tail.length) === tail
}
export async function waitForElement(locator, timeout = 10000, interval = 300, guards = []) {
await runGuardSweep(guards)
const script = buildExistScript(locator)
@@ -30,6 +73,23 @@ export async function waitForElement(locator, timeout = 10000, interval = 300, g
return found
}
async function waitForElementAcrossNavigation(locator, timeout = 10000, interval = 500, guards = []) {
const deadline = Date.now() + timeout
const script = buildExistScript(locator)
console.log('[actionExecutor] waitForElementAcrossNavigation: 开始检测, ', locator.value, ', ', timeout + 'ms')
while (Date.now() < deadline) {
await runGuardSweep(guards)
const found = await evalJSOnce(script, Math.min(interval, 1000))
if (found === true) {
console.log('[actionExecutor] waitForElementAcrossNavigation: 找到元素, ', locator.value)
return true
}
await sleep(interval)
}
console.log('[actionExecutor] waitForElementAcrossNavigation: 超时未找到, ', locator.value)
return false
}
async function runGuardSweep(guards = []) {
if (!Array.isArray(guards) || !guards.length) {
return { clicked: false, count: 0, names: [] }
@@ -338,6 +398,69 @@ export async function executeAction(step, context, options = {}) {
}
}
if (action === 'fetchSmsCodeIfPresent') {
const opts = step.smsOptions || {}
const pagePresentLocator = opts.pagePresentLocator || { type: 'text', value: '输入短信验证码' }
const inputLocator = opts.inputLocator || { type: 'css', value: 'input[type="tel"][maxlength="6"]', requireInteractable: false }
const pollInterval = Number(opts.pollInterval || 2000)
const pollTimeout = Number(opts.pollTimeout || 120000)
const inputMode = opts.inputMode || 'nativeSetValue'
// 1. 验证码页通常由支付密码页跳转而来,需跨页面重复注入检测脚本。
const present = await waitForElementAcrossNavigation(pagePresentLocator, step.scriptTimeout || 4000, 500, guards)
if (!present) {
return { success: true, message: '未出现验证码页,跳过取码' }
}
// 2. 校验付款手机号已配置。
const payerPhone = context && context.payerPhone ? String(context.payerPhone).trim() : ''
if (!payerPhone) {
return { success: false, message: '验证码页已出现,但未配置付款手机号' }
}
// 3. 读取页面掩码手机号并比对;读不到掩码时记录告警但不阻断(DOM 结构可能变动)。
const maskResult = await evalJSOnce(buildReadMaskedPhoneScript(), 2000)
const maskedPhone = maskResult && maskResult.masked ? String(maskResult.masked) : ''
if (maskedPhone) {
if (!isMaskedPhoneMatch(payerPhone, maskedPhone)) {
return { success: false, message: 'SMS_PHONE_MISMATCH 页面手机号(' + maskedPhone + ')与付款手机号不一致' }
}
} else {
console.warn('[actionExecutor] fetchSmsCodeIfPresent: 未读取到页面掩码手机号,跳过手机号比对')
}
// 4. 按付款手机号轮询后端取码,命中一条后立即停止,不再抢新码。
const taskId = context && context.taskId
const orderNo = (context && context.orderNo) || ''
const deadline = Date.now() + pollTimeout
let verifyCode = ''
while (Date.now() < deadline) {
try {
const res = await fetchSmsCode({ receiverPhone: payerPhone, taskId, orderNo })
if (res && res.data && res.data.verifyCode) {
verifyCode = String(res.data.verifyCode)
break
}
} catch (e) {
console.warn('[actionExecutor] fetchSmsCodeIfPresent: 取码请求异常,继续轮询', e && (e.msg || e.message || e))
}
await sleep(pollInterval)
}
if (!verifyCode) {
return { success: false, message: '取码超时,未获取到验证码' }
}
// 5. 填入验证码,满 6 位后页面通常自动提交,本期不额外点击确认。
const inputScript = buildInputScript(inputLocator, verifyCode, { mode: inputMode })
const inputOk = await evalJSOnce(inputScript, 3000)
if (inputOk !== true) {
return { success: false, message: '验证码已取到,但填入输入框失败' }
}
await sleep(step.delay || 400)
return { success: true, message: '验证码已自动填入: ' + verifyCode }
}
return { success: false, message: '未知动作类型: ' + action }
} catch (err) {
return { success: false, message: '执行异常: ' + (err.message || err) }
+3 -3
View File
@@ -20,10 +20,11 @@ const STEP_STAGE_LIST = [
successMessage: '账号步骤完成,已确认充值账号',
steps: [
'CLICK_SWITCH_ACCOUNT',
'CLICK_ADD_DOUYIN_ACCOUNT',
'CAPTURE_DOUYIN_ACCOUNT',
'INPUT_PHONE',
'CLICK_CONFIRM',
'CHECK_ACCOUNT_NOT_FOUND',
'CLICK_CONFIRM',
'CLICK_DIALOG_CONFIRM'
]
},
@@ -35,8 +36,7 @@ const STEP_STAGE_LIST = [
'CLICK_CUSTOM_AMOUNT',
'INPUT_CUSTOM_AMOUNT',
'CLICK_CUSTOM_AMOUNT_CONFIRM',
'HANDLE_ACCOUNT_CONFIRM_DIALOG',
'ASSERT_PAY_AMOUNT'
'HANDLE_ACCOUNT_CONFIRM_DIALOG'
]
},
{
+14
View File
@@ -3,15 +3,18 @@ import Vuex from 'vuex'
import {
getCurrentTaskStorage,
getExecutionStateStorage,
getPayerPhoneStorage,
getRechargePayPasswordStorage,
getTaskHistoryStorage,
getWorkStatusStorage,
getToken,
getUserInfo,
removeCurrentTaskStorage,
removePayerPhoneStorage,
removeRechargePayPasswordStorage,
setCurrentTaskStorage,
setExecutionStateStorage,
setPayerPhoneStorage,
setRechargePayPasswordStorage,
setTaskHistoryStorage,
setWorkStatusStorage
@@ -34,6 +37,7 @@ export default new Vuex.Store({
executionState: getExecutionStateStorage(),
workStatus: getWorkStatusStorage(),
rechargePayPassword: getRechargePayPasswordStorage(),
payerPhone: getPayerPhoneStorage(),
cooldownUntil: '',
currentStepIndex: -1,
currentStepCode: '',
@@ -121,6 +125,14 @@ export default new Vuex.Store({
state.rechargePayPassword = ''
removeRechargePayPasswordStorage()
},
setPayerPhone(state, value) {
state.payerPhone = value || ''
setPayerPhoneStorage(state.payerPhone)
},
clearPayerPhone(state) {
state.payerPhone = ''
removePayerPhoneStorage()
},
setCooldownUntil(state, value) {
state.cooldownUntil = value || ''
},
@@ -156,6 +168,7 @@ export default new Vuex.Store({
state.executionState = 'IDLE'
state.workStatus = 'NOT_READY'
state.rechargePayPassword = ''
state.payerPhone = ''
state.cooldownUntil = ''
state.currentStepIndex = -1
state.currentStepCode = ''
@@ -166,6 +179,7 @@ export default new Vuex.Store({
setExecutionStateStorage('IDLE')
setWorkStatusStorage('NOT_READY')
removeRechargePayPasswordStorage()
removePayerPhoneStorage()
}
}
})
+22
View File
@@ -7,6 +7,7 @@ const TASK_HISTORY_KEY = 'deposit_task_history'
const EXECUTION_STATE_KEY = 'deposit_execution_state'
const WORK_STATUS_KEY = 'deposit_work_status'
const RECHARGE_PAY_PASSWORD_KEY = 'deposit_recharge_pay_password'
const PAYER_PHONE_KEY = 'deposit_payer_phone'
/**
* 保存登录 token,供请求封装自动携带。
@@ -112,6 +113,27 @@ export function removeRechargePayPasswordStorage() {
uni.removeStorageSync(RECHARGE_PAY_PASSWORD_KEY)
}
/**
* 保存付款手机号,作为充值流程中按手机号取验证码的设备级固定配置。
*/
export function setPayerPhoneStorage(payerPhone) {
uni.setStorageSync(PAYER_PHONE_KEY, payerPhone || '')
}
/**
* 读取付款手机号。
*/
export function getPayerPhoneStorage() {
return uni.getStorageSync(PAYER_PHONE_KEY) || ''
}
/**
* 删除付款手机号。
*/
export function removePayerPhoneStorage() {
uni.removeStorageSync(PAYER_PHONE_KEY)
}
/**
* 保存记住账号时的用户名。
*/