第53批:网页绑邮箱三接口+前端向导——不拦截改游戏内60秒批准,密码复核+IP连错5次封5分钟

This commit is contained in:
ypsdf1
2026-10-23 23:01:59 +08:00
parent 3a4252e861
commit f2859fe6e5
3 changed files with 449 additions and 4 deletions
+198 -2
View File
@@ -62,6 +62,9 @@ try {
case 'totp_unbind': acctTotpUnbind(); break;
case 'recovery_list': acctRecoveryList(); break;
case 'recovery_regenerate': acctRecoveryRegen(); break;
case 'bind_email_start': acctBindEmailStart(); break;
case 'bind_email_poll': acctBindEmailPoll(); break;
case 'bind_email_confirm': acctBindEmailConfirm(); break;
default:
error('未知操作: ' . $action);
}
@@ -271,6 +274,19 @@ function acctEnsureTables() {
)");
// 顺手清理过期超过 1 天的会话行
$db->exec("DELETE FROM web_account_verify WHERE expire_at < " . (time() - 86400));
// ★第53批:网页绑定邮箱请求表(schema 必须与 sync.php checkPendingWebEmailBindRequests 完全一致)
$db->exec("CREATE TABLE IF NOT EXISTS web_email_bind_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
email TEXT NOT NULL,
ip_address TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
created_at INTEGER NOT NULL,
expire_at INTEGER NOT NULL,
approved_at INTEGER DEFAULT 0,
processed_at INTEGER DEFAULT 0,
fail_reason TEXT DEFAULT ''
)");
}
// ===================== 公共辅助 =====================
@@ -457,7 +473,7 @@ function acctStatus() {
$hint = '';
if (!$methods) {
$hint = '账号未绑定邮箱,也未绑定二次验证器 —— 无法完成第二层验证。请先在游戏中使用 /bindemail 绑定邮箱,否则无法修改密码等账号信息。';
$hint = '账号未绑定邮箱,也未绑定二次验证器 —— 无法完成第二层验证。可在下方点击「网页绑定邮箱」,游戏内批准后即可完成绑定。';
}
success([
@@ -495,7 +511,8 @@ function acctVerifyStart() {
$email = acctEmailOf($player);
$methods = acctMethods($player, $email);
if (!$methods) {
error('无法完成第二层验证:账号未绑定邮箱且未绑定二次验证器,请先在游戏中使用 /bindemail 绑定邮箱');
// ★第53批:不再硬拦截——密码第一层已过,返回导航标记让前端直接进入「网页绑定邮箱」定向导
jsonResponse(['success' => false, 'message' => '账号未绑定邮箱且未绑定二次验证器,请先完成邮箱绑定', 'need_bind_email' => true], 409);
}
$challenge = acctIssueToken($player, 'challenge', 300, json_encode($methods, JSON_UNESCAPED_UNICODE), acctClientIp());
@@ -841,3 +858,182 @@ function acctRecoveryRegen() {
debugLog('[account.php] 恢复代码已重新生成', ['player' => $player, 'ip' => acctClientIp()]);
success(['recovery_codes' => $codes], '恢复代码已重新生成,旧代码全部作废');
}
// ===================== 第53批:网页端绑定邮箱(游戏内批准流) =====================
//
// 流程:bind_email_start 建 60 秒请求(要求玩家在线)→ Java 定时器A 拉取并游戏内发
// clickable 通知(/webbind approve|reject <id>)→ sync.php web_email_bind_result 回写
// approved/rejected/offline/expired → bind_email_poll 前端轮询 → approved 后
// bind_email_confirm 复核登录密码才写 users.email。
// 防盗绑三重:①游戏内本人批准 ②批准后重验登录密码 ③IP 连错 5 次封 5 分钟。
/** 玩家当前是否在线:online_players 有行 且 Java 心跳(online_player_hb)<=120秒(Java 死了通知发不出,等同不在线) */
function acctPlayerOnlineNow($player) {
$db = getDB();
try {
$db->exec("CREATE TABLE IF NOT EXISTS online_players (player_name TEXT PRIMARY KEY, login_time INTEGER DEFAULT 0)");
$db->exec("CREATE TABLE IF NOT EXISTS online_player_hb (id INTEGER PRIMARY KEY DEFAULT 1, last_seen INTEGER DEFAULT 0)");
$hb = $db->query("SELECT last_seen FROM online_player_hb WHERE id = 1")->fetchArray(SQLITE3_ASSOC);
if (!$hb || time() - (int)$hb['last_seen'] > 120) return false;
$stmt = $db->prepare("SELECT 1 FROM online_players WHERE player_name = :p");
$stmt->bindValue(':p', $player, SQLITE3_TEXT);
return ($stmt->execute()->fetchArray() !== false);
} catch (\Throwable $e) {
@error_log("[acctPlayerOnlineNow] error: " . $e->getMessage());
return false;
}
}
/** 发起绑定:校验邮箱格式 + 在线 → 建 60 秒请求(同玩家旧未终结请求全部作废) */
function acctBindEmailStart() {
$player = acctAuthPlayer();
$email = strtolower(trim((string)getParam('email', '')));
if ($email === '') error('请输入邮箱地址');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) error('邮箱格式不正确');
$cur = acctEmailOf($player);
if ($cur !== '') error('账号已绑定邮箱,无需重复绑定');
if (!acctPlayerOnlineNow($player)) {
error('你当前未在线或未登录游戏,请先登录游戏后再发起绑定(需在游戏内点击批准)');
}
$db = getDB();
$now = time();
// 同一玩家任何时刻只允许一条活跃请求(含已批准未输密码的,重新发起即作废旧的)
$void = $db->prepare("UPDATE web_email_bind_requests SET status = 'expired', processed_at = :t WHERE player_name = :p AND status IN ('pending','notified','approved')");
$void->bindValue(':t', $now, SQLITE3_INTEGER);
$void->bindValue(':p', $player, SQLITE3_TEXT);
$void->execute();
$ins = $db->prepare("INSERT INTO web_email_bind_requests (player_name, email, ip_address, status, created_at, expire_at, processed_at)
VALUES (:p, :e, :ip, 'pending', :c, :x, 0)");
$ins->bindValue(':p', $player, SQLITE3_TEXT);
$ins->bindValue(':e', $email, SQLITE3_TEXT);
$ins->bindValue(':ip', acctClientIp(), SQLITE3_TEXT);
$ins->bindValue(':c', $now, SQLITE3_INTEGER);
$ins->bindValue(':x', $now + 60, SQLITE3_INTEGER); // CLI通知1分钟时效
$ins->execute();
$id = (int)$db->lastInsertRowID();
debugLog('[account.php] 发起网页绑邮箱', ['player' => $player, 'id' => $id, 'ip' => acctClientIp()]);
success(['id' => $id, 'expire_in' => 60], '请求已发送,等待游戏内批准');
}
/** 前端轮询请求状态(1秒一次):pending/notified/approved/rejected/offline/expired/completed/failed */
function acctBindEmailPoll() {
$player = acctAuthPlayer();
$id = (int)getParam('id', 0);
if ($id <= 0) error('缺少请求id');
$db = getDB();
$stmt = $db->prepare("SELECT id, player_name, status, created_at, expire_at, approved_at, fail_reason FROM web_email_bind_requests WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('请求不存在');
if ((string)$row['player_name'] !== $player) error('无权查看该请求');
$now = time();
$status = (string)$row['status'];
$left = 0;
// 惰性超时:不等 Java 拉,前端轮询到就地终结(批准时效60秒)
if (in_array($status, array('pending', 'notified'), true) && $now > (int)$row['expire_at']) {
$status = 'expired';
}
// 批准后密码窗口 180 秒,超窗即折返终结
if ($status === 'approved') {
$left = max(0, ((int)$row['approved_at'] + 180) - $now);
if ($left <= 0) $status = 'expired';
} elseif (in_array($status, array('pending', 'notified'), true)) {
$left = max(0, (int)$row['expire_at'] - $now);
}
if ($status === 'expired' && (string)$row['status'] !== 'expired') {
$upd = $db->prepare("UPDATE web_email_bind_requests SET status = 'expired', processed_at = :t WHERE id = :id AND status IN ('pending','notified','approved')");
$upd->bindValue(':t', $now, SQLITE3_INTEGER);
$upd->bindValue(':id', $id, SQLITE3_INTEGER);
$upd->execute();
}
success([
'id' => $id,
'status' => $status,
'left' => $left,
'fail_reason' => (string)$row['fail_reason'],
], 'ok');
}
/** 终态确认:批准窗口内复核登录密码 → 写 users.email(IP 连错5次封5分钟) */
function acctBindEmailConfirm() {
$player = acctAuthPlayer();
$id = (int)getParam('id', 0);
$pwd = (string)getParam('password', '');
if ($id <= 0) error('缺少请求id');
if ($pwd === '') error('请输入登录密码');
$ip = acctClientIp();
// ★第53批风控:绑定确认按 IP 计数,连错 5 次封锁 5 分钟(防密码爆破盗绑)
$locked = acctThrottleLocked('bindip', $ip, 5, 300);
if ($locked > 0) error('密码错误次数过多,IP 已被限制,请 ' . $locked . ' 秒后再试', 429);
$db = getDB();
$stmt = $db->prepare("SELECT id, player_name, email, status, approved_at FROM web_email_bind_requests WHERE id = :id");
$stmt->bindValue(':id', $id, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('请求不存在');
if ((string)$row['player_name'] !== $player) error('无权操作该请求');
$status = (string)$row['status'];
if ($status === 'completed') success(['id' => $id, 'status' => 'completed'], '邮箱已绑定');
if ($status !== 'approved') error('该请求未获游戏内批准或已失效,请重新发起绑定');
$now = time();
if ($now > ((int)$row['approved_at'] + 180)) {
$exp = $db->prepare("UPDATE web_email_bind_requests SET status = 'expired', processed_at = :t WHERE id = :id");
$exp->bindValue(':t', $now, SQLITE3_INTEGER);
$exp->bindValue(':id', $id, SQLITE3_INTEGER);
$exp->execute();
error('批准已超时,未做任何处置,请重新发起绑定');
}
$email = (string)$row['email'];
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
error('请求数据异常,请重新发起绑定');
}
if (acctEmailOf($player) !== '') {
// 绑定期间已被别处绑定 → 幂等终结本请求
$dup = $db->prepare("UPDATE web_email_bind_requests SET status = 'completed', processed_at = :t WHERE id = :id");
$dup->bindValue(':t', $now, SQLITE3_INTEGER);
$dup->bindValue(':id', $id, SQLITE3_INTEGER);
$dup->execute();
success(['id' => $id, 'status' => 'completed'], '邮箱已绑定');
}
// 密码复核(只认主密码,同第一层)——错:保持 approved 可在窗口内重试,IP 计数
$cred = acctCredentials($player);
if (!acctPwdCheck($cred, $pwd)) {
$remain = acctThrottleFail('bindip', $ip, 5, 300);
debugLog('[account.php] 网页绑邮箱密码错误', ['player' => $player, 'ip' => $ip, 'id' => $id]);
if ($remain > 0) error('密码错误(连续错误 5 次将封锁 IP 5 分钟),IP 即将被限制', 403);
error('密码错误', 403);
}
acctThrottleReset('bindip', $ip);
// 写库:users.email —— users 类别对账 pull 会在几十秒内同步到 Java login.db
$upd = $db->prepare("UPDATE users SET email = :e WHERE player_name = :p");
$upd->bindValue(':e', $email, SQLITE3_TEXT);
$upd->bindValue(':p', $player, SQLITE3_TEXT);
$upd->execute();
if ($db->changes() <= 0) {
error('绑定失败:玩家数据不存在,请重新发起');
}
$done = $db->prepare("UPDATE web_email_bind_requests SET status = 'completed', processed_at = :t WHERE id = :id");
$done->bindValue(':t', time(), SQLITE3_INTEGER);
$done->bindValue(':id', $id, SQLITE3_INTEGER);
$done->execute();
debugLog('[account.php] 网页绑邮箱成功', ['player' => $player, 'id' => $id, 'ip' => $ip]);
success(['id' => $id, 'status' => 'completed', 'email_masked' => acctMaskEmail($email)], '邮箱绑定成功');
}
+120
View File
@@ -303,6 +303,12 @@ switch ($action) {
case 'complete_web_register_request':
completeWebRegisterRequest();
break;
case 'check_pending_web_email_bind_requests':
checkPendingWebEmailBindRequests();
break;
case 'web_email_bind_result':
webEmailBindResult();
break;
case 'delete_user':
deleteUser();
break;
@@ -5025,6 +5031,120 @@ function completeWebRegisterRequest() {
success(['request_id' => $requestId, 'status' => $status]);
}
// ===== 第53批:插件轮询待处理的网页邮箱绑定请求(SECRET_KEY认证)=====
function checkPendingWebEmailBindRequests() {
$secret = getParam('secret');
if (!$secret || $secret !== SECRET_KEY) {
error('密钥验证失败', 403);
}
$db = getDB();
try {
$db->exec("SELECT id FROM web_email_bind_requests LIMIT 0");
} catch (\Throwable $e) {
$db->exec("CREATE TABLE IF NOT EXISTS web_email_bind_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
player_name TEXT NOT NULL,
email TEXT NOT NULL,
ip_address TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
created_at INTEGER NOT NULL,
expire_at INTEGER NOT NULL,
approved_at INTEGER DEFAULT 0,
processed_at INTEGER DEFAULT 0,
fail_reason TEXT DEFAULT ''
)");
}
$now = time();
// 惰性终结超时未处理的请求(CLI通知1分钟时效)
try {
$db->exec("UPDATE web_email_bind_requests SET status = 'expired', processed_at = " . $now . " WHERE status IN ('pending','notified') AND expire_at < " . $now);
} catch (\Throwable $e) {
@error_log("[checkPendingWebEmailBindRequests] expire error: " . $e->getMessage());
}
// ★ 只读查询不需要BEGIN IMMEDIATE(避免与其它并发请求抢锁导致500)
$stmt = $db->prepare("SELECT id, player_name, email, created_at, expire_at FROM web_email_bind_requests WHERE status = 'pending' ORDER BY created_at ASC");
$result = $stmt->execute();
$requests = array();
$ids = array();
while ($row = $result->fetchArray(SQLITE3_ASSOC)) {
$requests[] = $row;
$ids[] = (int)$row['id'];
}
// 拉取即标记notified(防重复通知;允许失败,下次还会拉到)
if (!empty($ids)) {
try {
$idList = implode(',', $ids);
$db->exec("UPDATE web_email_bind_requests SET status = 'notified' WHERE id IN ($idList) AND status = 'pending'");
} catch (\Throwable $e) {
@error_log("[checkPendingWebEmailBindRequests] mark notified error: " . $e->getMessage());
}
}
success(['requests' => $requests, 'count' => count($requests)]);
}
// ===== 第53批:插件回传游戏内玩家对邮箱绑定请求的处理结果(SECRET_KEY认证)=====
function webEmailBindResult() {
$secret = getParam('secret');
if (!$secret || $secret !== SECRET_KEY) {
error('密钥验证失败', 403);
}
$requestId = getParam('request_id');
$action = getParam('action', ''); // approve / reject / offline / failed
$player = getParam('player', '');
$failReason = getParam('fail_reason', '');
if (!$requestId) error('缺少request_id');
if (!in_array($action, array('approve', 'reject', 'offline', 'failed'), true)) error('非法action');
if (!$player) error('缺少player');
$db = getDB();
$stmt = $db->prepare("SELECT id, player_name, status, expire_at FROM web_email_bind_requests WHERE id = :id");
$stmt->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
$row = $stmt->execute()->fetchArray(SQLITE3_ASSOC);
if (!$row) error('请求不存在');
if ((string)$row['player_name'] !== (string)$player) error('玩家名不匹配');
$now = time();
$current = (string)$row['status'];
$open = in_array($current, array('pending', 'notified'), true);
if (!$open) {
// 已终结:幂等返回当前状态(重复点击/迟到回调直接忽略,不做任何处置)
success(['request_id' => (int)$requestId, 'status' => $current], '请求已终结,忽略');
return;
}
if ($action === 'approve') {
$newStatus = ($now > (int)$row['expire_at']) ? 'expired' : 'approved';
} elseif ($action === 'reject') {
$newStatus = 'rejected';
} elseif ($action === 'offline') {
$newStatus = 'offline';
} else {
$newStatus = 'failed';
}
$approvedAt = ($newStatus === 'approved') ? $now : 0;
$reason = ($newStatus === 'failed') ? (string)$failReason : '';
$upd = $db->prepare("UPDATE web_email_bind_requests SET status = :status, approved_at = :approved, processed_at = :time, fail_reason = :reason WHERE id = :id");
$upd->bindValue(':status', $newStatus, SQLITE3_TEXT);
$upd->bindValue(':approved', $approvedAt, SQLITE3_INTEGER);
$upd->bindValue(':time', $now, SQLITE3_INTEGER);
$upd->bindValue(':reason', $reason, SQLITE3_TEXT);
$upd->bindValue(':id', (int)$requestId, SQLITE3_INTEGER);
$upd->execute();
success(['request_id' => (int)$requestId, 'status' => $newStatus]);
}
// ===== CDK验证(供sdf1插件调用)=====
// sdf1插件本地CDK未找到时,请求Web后端验证
function validateCdk() {
+131 -2
View File
@@ -2552,7 +2552,7 @@ if ($currentVersion !== $BUILD_VERSION) {
`<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('change_password','修改登录密码')">修改</button>`);
html += acctRow('绑定邮箱', d.has_email ? escapeHtml(d.email_masked) : '<span style="color:var(--red)">未绑定</span>',
d.has_email ? '<span style="font-size:12px;color:var(--dim)">第二层验证可用</span>'
: '<span style="font-size:12px;color:var(--dim)">请先游戏中 /bindemail</span>');
: '<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="acctBindEmailWizard()">网页绑定</button>');
const twofaVal = d.twofa_bound
? '✅ 已绑定认证器(TOTP)'
: (d.pending_setup ? '⏳ 密钥已生成但未确认' : '<span style="color:var(--dim)">未绑定</span>');
@@ -2578,6 +2578,131 @@ if ($currentVersion !== $BUILD_VERSION) {
return html;
}
// ===== 第53批:网页绑定邮箱向导(游戏内批准流,60秒时效) =====
let _bindWizTimer = null;
let _bindWizId = 0;
function acctBindEmailWizard() {
if (IS_PREVIEW || !TOKEN || !currentPlayer) { showToast('请先登录游戏后再操作', 'error'); return; }
if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; }
acctModalOpen('📧 网页绑定邮箱');
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px;line-height:1.7">输入邮箱后,你的游戏内会收到一条批准通知(需在线)。<b>1分钟内</b>点击「✔ 批准」,再回到这里输入登录密码即可完成绑定;拒绝或超时则直接折返,不做任何处置。</div>
<input type="email" id="bindEmailInput" placeholder="you@example.com" autocomplete="email" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'发送批准请求 →', acctBindEmailStart);
setTimeout(() => { const el = document.getElementById('bindEmailInput'); if (el) el.focus(); }, 120);
}
async function acctBindEmailStart() {
const el = document.getElementById('bindEmailInput');
const email = el ? el.value.trim() : '';
if (!email) { acctErr('请输入邮箱地址'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('发送中...');
try {
const res = await apiPost('account.php', {action: 'bind_email_start', email: email});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '发起失败'); return; }
_bindWizId = res.data.id;
acctBindEmailWait(res.data.expire_in || 60);
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
function acctBindEmailWait(expireIn) {
acctModalSet(`
<div style="font-size:13.5px;line-height:1.8;text-align:center">
<div style="font-size:26px">🎮</div>
<div style="margin-top:4px">等待游戏内批准…</div>
<div style="color:var(--dim);font-size:12.5px;margin-top:4px">请到游戏中点击聊天栏的 <b style="color:var(--green)">✔ 批准</b>,再回来输入登录密码</div>
<div style="margin-top:10px;font-size:22px;font-family:monospace" id="bindLeft">${expireIn}s</div>
<div id="bindWizStatus" style="margin-top:6px;font-size:12.5px;color:var(--dim)">已向游戏发送通知…</div>
</div>
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'取消', () => { if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; } closeModal(); });
if (_bindWizTimer) clearInterval(_bindWizTimer);
_bindWizTimer = setInterval(acctBindEmailPollTick, 1000);
acctBindEmailPollTick();
}
async function acctBindEmailPollTick() {
const leftEl = document.getElementById('bindLeft');
if (leftEl) {
const v = parseInt(leftEl.textContent) || 0;
if (v > 0) leftEl.textContent = (v - 1) + 's';
}
try {
const res = await apiPost('account.php', {action: 'bind_email_poll', id: _bindWizId});
if (!res.success) return;
const d = res.data || {};
const stEl = document.getElementById('bindWizStatus');
if (d.status === 'pending' || d.status === 'notified') {
if (stEl) stEl.textContent = d.status === 'pending' ? '正在通知游戏…' : '已发送游戏内通知,等待点击批准…';
if (!d.left) acctBindEmailFinish('expired');
return;
}
if (d.status === 'approved') {
if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; }
acctBindEmailPwdStep();
return;
}
acctBindEmailFinish(d.status, d.fail_reason);
} catch (e) { /* 网络抖动忽略,下一轮重试 */ }
}
function acctBindEmailPwdStep() {
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px;line-height:1.7">✅ 游戏内已批准。输入登录密码完成绑定(<b>3分钟内有效</b>;密码错误不生效、可重试,连错 5 次将封锁 IP 5 分钟)</div>
<input type="password" id="bindPwdInput" placeholder="当前登录密码" autocomplete="current-password" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'确认绑定', acctBindEmailConfirm);
setTimeout(() => { const p = document.getElementById('bindPwdInput'); if (p) p.focus(); }, 120);
}
async function acctBindEmailConfirm() {
const el = document.getElementById('bindPwdInput');
const pwd = el ? el.value : '';
if (!pwd) { acctErr('请输入密码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('验证中...');
try {
const res = await apiPost('account.php', {action: 'bind_email_confirm', id: _bindWizId, password: pwd});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '密码错误'); return; }
if (res.data && res.data.status === 'completed') {
acctModalSet(`
<div style="text-align:center;padding:10px 0;line-height:1.8">
<div style="font-size:34px">🎉</div>
<div style="font-size:15px;margin-top:4px">邮箱绑定成功</div>
<div style="font-size:13px;color:var(--dim)">${escapeHtml(res.data.email_masked || '')}</div>
<div style="font-size:12px;color:var(--dim);margin-top:6px">现在可以完成第二层验证了,请重新发起刚才的操作</div>
</div>`,
'完成', () => { closeModal(); acctRefreshStatus(); });
}
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
function acctBindEmailFinish(status, reason) {
if (_bindWizTimer) { clearInterval(_bindWizTimer); _bindWizTimer = null; }
const msgs = {
rejected: '❌ 你在游戏内拒绝了本次绑定,已折返、不做任何处置',
offline: '📴 你当前不在线,无法批准。请先登录游戏后重新发起',
expired: '⌛ 1分钟内未处理,请求已超时作废(未做任何处置)',
failed: '⚠️ 绑定失败:' + (reason || '未知原因'),
completed: '✅ 邮箱已绑定'
};
const msg = msgs[status] || ('状态: ' + status);
acctModalSet(`
<div style="text-align:center;padding:12px 0;font-size:14px;line-height:1.8">${escapeHtml(msg)}</div>`,
'知道了', () => { closeModal(); acctRefreshStatus(); });
}
// ===== 通用 =====
function toast(msg, type = 'success') {
const t = document.createElement('div');
@@ -3059,7 +3184,11 @@ if ($currentVersion !== $BUILD_VERSION) {
try {
const res = await apiPost('account.php', {action: 'verify_start', password: pwd});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '密码错误'); return; }
if (!res.success) {
// ★第53批:第一层密码已过但未绑邮箱 → 不拦截,直接进入网页绑定定向导
if (res.need_bind_email) { acctBindEmailWizard(); return; }
acctErr(res.message || '密码错误'); return;
}
st.challenge = res.data.challenge;
st.methods = res.data.methods || [];
st.emailMasked = res.data.email_masked || '';