From 243ded46e9ee251ffb071350a18bb66dedfe60b6 Mon Sep 17 00:00:00 2001 From: songkx Date: Thu, 24 Sep 2026 16:01:34 +0800 Subject: [PATCH] =?UTF-8?q?fix=20=E4=B8=80=E9=94=AE=E7=BB=84=E5=90=88?= =?UTF-8?q?=E5=8F=AA=E4=B8=8A=E4=BC=A0=E9=82=AE=E7=AE=B1=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E6=9C=8D=E5=8A=A1=E7=AB=AF=E5=86=8D=E4=BB=8E=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=BA=93=E5=8A=A0=E8=BD=BD=E5=AE=8C=E6=95=B4=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/app.js | 19 ++- scripts/Build-BitBrowserCleanPackage.ps1 | 140 +++++++++++++++++ .../Install-Clean.cmd | 12 ++ .../Install-Clean.ps1 | 136 +++++++++++++++++ scripts/bitbrowser-clean-package/README.txt | 35 +++++ .../bitbrowser-clean-package/make_clean_db.py | 142 ++++++++++++++++++ .../一键安装_保留YesCaptcha人机助手.cmd | 12 ++ src/core/database.js | 23 ++- src/routes/operations.js | 31 +++- tests/aistudio_large_batch_request.test.js | 43 ++++++ 10 files changed, 582 insertions(+), 11 deletions(-) create mode 100644 scripts/Build-BitBrowserCleanPackage.ps1 create mode 100644 scripts/bitbrowser-clean-package/Install-Clean.cmd create mode 100644 scripts/bitbrowser-clean-package/Install-Clean.ps1 create mode 100644 scripts/bitbrowser-clean-package/README.txt create mode 100644 scripts/bitbrowser-clean-package/make_clean_db.py create mode 100644 scripts/bitbrowser-clean-package/一键安装_保留YesCaptcha人机助手.cmd create mode 100644 tests/aistudio_large_batch_request.test.js diff --git a/public/app.js b/public/app.js index 033a3c3..99ba210 100644 --- a/public/app.js +++ b/public/app.js @@ -3025,14 +3025,23 @@ function geminiApp() { this.addLog(`🚀 启动任务: ${taskType}, 账号数: ${requestedCount}, 并发: ${taskThreadCount}${monitorText}`, 'info'); try { + // AI Studio 一键组合可能一次选择上万个账号。这里只提交邮箱引用, + // 完整账号由服务端从数据库重新加载,避免请求体超过 Express 的 10MB 限制。 + const taskRequestBody = taskType === 'aistudio_combo' + ? { + taskType, + accountEmails: taskAccounts.map(account => account.email), + params: taskParams, + } + : { + taskType, + accounts: taskAccounts, + params: taskParams, + }; const res = await fetch('/api/operations/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - taskType, - accounts: taskAccounts, - params: taskParams, - }), + body: JSON.stringify(taskRequestBody), }); const data = await res.json(); if (!data.success) { diff --git a/scripts/Build-BitBrowserCleanPackage.ps1 b/scripts/Build-BitBrowserCleanPackage.ps1 new file mode 100644 index 0000000..b9e6eae --- /dev/null +++ b/scripts/Build-BitBrowserCleanPackage.ps1 @@ -0,0 +1,140 @@ +[CmdletBinding()] +param( + [string]$SourceApp = 'C:\Program Files\bitbrowser', + [string]$OutputDirectory = '', + [switch]$SkipArchive +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Copy-Tree { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination, + [string[]]$ExcludeFiles = @() + ) + + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + $arguments = @($Source, $Destination, '/E', '/COPY:DAT', '/DCOPY:DAT', '/R:2', '/W:1', '/XJ', '/NFL', '/NDL', '/NP') + if ($ExcludeFiles.Count -gt 0) { + $arguments += '/XF' + $arguments += $ExcludeFiles + } + & robocopy.exe @arguments + $code = $LASTEXITCODE + if ($code -gt 7) { + throw "Copy failed with robocopy exit code $code ($Source)" + } +} + +$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$templateRoot = Join-Path $scriptRoot 'bitbrowser-clean-package' +$workspaceRoot = Split-Path -Parent $scriptRoot +$sourceExe = Join-Path $SourceApp ([string][char]0x6BD4 + [char]0x7279 + [char]0x6D4F + [char]0x89C8 + [char]0x5668 + '.exe') +if (-not (Test-Path -LiteralPath $sourceExe -PathType Leaf)) { + throw "BitBrowser executable was not found: $sourceExe" +} + +if (-not $OutputDirectory) { + $OutputDirectory = Split-Path -Parent $workspaceRoot +} +$outputBase = [System.IO.Path]::GetFullPath($OutputDirectory) +New-Item -ItemType Directory -Path $outputBase -Force | Out-Null + +$version = (Get-Item -LiteralPath $sourceExe).VersionInfo.FileVersion +if (-not $version) { $version = 'unknown' } +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$packageName = "BitBrowser-Clean-Share-$version-$stamp" +$packageRoot = Join-Path $outputBase $packageName +if (Test-Path -LiteralPath $packageRoot) { + throw "Refusing to overwrite an existing package: $packageRoot" +} + +$appDestination = Join-Path $packageRoot 'App' +$seedRoaming = Join-Path $packageRoot 'Seed\Roaming\BitBrowser' +$seedLocal = Join-Path $packageRoot 'Seed\Local\BitBrowser' +New-Item -ItemType Directory -Path $seedRoaming -Force | Out-Null +New-Item -ItemType Directory -Path $seedLocal -Force | Out-Null + +Write-Host "Copying application files from $SourceApp" +Copy-Tree -Source $SourceApp -Destination $appDestination -ExcludeFiles @('Uninstall BitBrowser.exe', 'Uninstall *.exe', '*.lnk') + +$sourceRoaming = Join-Path $env:APPDATA 'BitBrowser' +$sourceDb = Join-Path $sourceRoaming 'db.sqlite' +if (-not (Test-Path -LiteralPath $sourceDb -PathType Leaf)) { + throw "BitBrowser database was not found: $sourceDb" +} + +foreach ($directory in @('BitExtensions', 'Chrome-bin', 'chromedriver')) { + $source = Join-Path $sourceRoaming $directory + if (Test-Path -LiteralPath $source -PathType Container) { + Write-Host "Copying $directory" + Copy-Tree -Source $source -Destination (Join-Path $seedRoaming $directory) + } +} + +$cleanDb = Join-Path $seedRoaming 'db.sqlite' +$dbTool = Join-Path $templateRoot 'make_clean_db.py' +$python = Get-Command py.exe -ErrorAction SilentlyContinue +if (-not $python) { $python = Get-Command python.exe -ErrorAction SilentlyContinue } +if (-not $python) { throw 'Python 3 is required to build the sanitized SQLite database.' } + +Write-Host 'Creating sanitized database...' +if ($python.Name -ieq 'py.exe') { + & $python.Source -3 $dbTool --source $sourceDb --output $cleanDb +} else { + & $python.Source $dbTool --source $sourceDb --output $cleanDb +} +if ($LASTEXITCODE -ne 0) { throw 'Sanitized database creation failed.' } + +Copy-Item -LiteralPath (Join-Path $templateRoot 'Install-Clean.ps1') -Destination $packageRoot +Copy-Item -LiteralPath (Join-Path $templateRoot 'Install-Clean.cmd') -Destination $packageRoot +Copy-Item -LiteralPath (Join-Path $templateRoot 'README.txt') -Destination $packageRoot + +$manifestPath = Join-Path $packageRoot 'SHA256SUMS.txt' +Write-Host 'Computing SHA-256 manifest...' +$manifestLines = Get-ChildItem -LiteralPath $packageRoot -File -Recurse -Force | + Where-Object { $_.FullName -ne $manifestPath } | + Sort-Object FullName | + ForEach-Object { + $relative = $_.FullName.Substring($packageRoot.Length + 1).Replace('\', '/') + $hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + "$hash $relative" + } +[System.IO.File]::WriteAllLines($manifestPath, $manifestLines, [System.Text.UTF8Encoding]::new($false)) + +$allFiles = Get-ChildItem -LiteralPath $packageRoot -File -Recurse -Force +$totalBytes = ($allFiles | Measure-Object Length -Sum).Sum +$result = [ordered]@{ + PackageDirectory = $packageRoot + Version = $version + FileCount = $allFiles.Count + UncompressedBytes = $totalBytes + Archive = $null + ArchiveSha256 = $null +} + +if (-not $SkipArchive) { + $sevenZip = Join-Path $SourceApp 'resources\7zip-bin\7za.exe' + if (-not (Test-Path -LiteralPath $sevenZip -PathType Leaf)) { + throw "7-Zip command was not found: $sevenZip" + } + $archivePath = Join-Path $outputBase ($packageName + '.zip') + Write-Host "Creating archive $archivePath" + Push-Location $outputBase + try { + & $sevenZip a -tzip -mx=5 -mmt=on $archivePath $packageName + if ($LASTEXITCODE -ne 0) { throw "7-Zip failed with exit code $LASTEXITCODE" } + & $sevenZip t $archivePath + if ($LASTEXITCODE -ne 0) { throw "Archive verification failed with exit code $LASTEXITCODE" } + } finally { + Pop-Location + } + $result.Archive = $archivePath + $result.ArchiveSha256 = (Get-FileHash -LiteralPath $archivePath -Algorithm SHA256).Hash.ToLowerInvariant() +} + +Write-Host '' +Write-Host 'Build complete:' +$result | ConvertTo-Json -Depth 3 diff --git a/scripts/bitbrowser-clean-package/Install-Clean.cmd b/scripts/bitbrowser-clean-package/Install-Clean.cmd new file mode 100644 index 0000000..340cd98 --- /dev/null +++ b/scripts/bitbrowser-clean-package/Install-Clean.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install-Clean.ps1" +if errorlevel 1 ( + echo. + echo Installation failed. Read the message above, then try again. + pause + exit /b 1 +) +echo. +echo Installation completed successfully. +pause diff --git a/scripts/bitbrowser-clean-package/Install-Clean.ps1 b/scripts/bitbrowser-clean-package/Install-Clean.ps1 new file mode 100644 index 0000000..35f2b9e --- /dev/null +++ b/scripts/bitbrowser-clean-package/Install-Clean.ps1 @@ -0,0 +1,136 @@ +[CmdletBinding()] +param( + [switch]$NoLaunch, + [switch]$SkipShortcuts, + [switch]$SkipProcessCheck, + [string]$InstallRoot = '', + [string]$RoamingRoot = '', + [string]$LocalRoot = '' +) + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +function Copy-Tree { + param( + [Parameter(Mandatory = $true)][string]$Source, + [Parameter(Mandatory = $true)][string]$Destination + ) + + if (-not (Test-Path -LiteralPath $Source -PathType Container)) { + throw "Required source directory is missing: $Source" + } + New-Item -ItemType Directory -Path $Destination -Force | Out-Null + & robocopy.exe $Source $Destination /E /COPY:DAT /DCOPY:DAT /R:2 /W:1 /XJ /NFL /NDL /NP + $code = $LASTEXITCODE + if ($code -gt 7) { + throw "Copy failed with robocopy exit code $code ($Source)" + } +} + +function Move-ToBackup { + param([Parameter(Mandatory = $true)][string]$Path) + + if (-not (Test-Path -LiteralPath $Path)) { + return $null + } + $parent = Split-Path -Parent $Path + $leaf = Split-Path -Leaf $Path + $stamp = Get-Date -Format 'yyyyMMdd-HHmmss' + $suffix = [Guid]::NewGuid().ToString('N').Substring(0, 6) + $backup = Join-Path $parent ($leaf + '.backup-' + $stamp + '-' + $suffix) + Move-Item -LiteralPath $Path -Destination $backup + return $backup +} + +$packageRoot = Split-Path -Parent $MyInvocation.MyCommand.Path +$sourceApp = Join-Path $packageRoot 'App' +$sourceRoaming = Join-Path $packageRoot 'Seed\Roaming\BitBrowser' +$sourceLocal = Join-Path $packageRoot 'Seed\Local\BitBrowser' +$targetApp = if ($InstallRoot) { [System.IO.Path]::GetFullPath($InstallRoot) } else { Join-Path $env:LOCALAPPDATA 'Programs\BitBrowser' } +$targetRoaming = if ($RoamingRoot) { [System.IO.Path]::GetFullPath($RoamingRoot) } else { Join-Path $env:APPDATA 'BitBrowser' } +$targetLocal = if ($LocalRoot) { [System.IO.Path]::GetFullPath($LocalRoot) } else { Join-Path $env:LOCALAPPDATA 'BitBrowser' } +$targetExe = Join-Path $targetApp ([string][char]0x6BD4 + [char]0x7279 + [char]0x6D4F + [char]0x89C8 + [char]0x5668 + '.exe') +$expectedExtensionId = 'jiofmdifioeejeilfkpegipdjiopiekl' +$sourceDb = Join-Path $sourceRoaming 'db.sqlite' +$sourceExtensionManifest = Join-Path $sourceRoaming "BitExtensions\$expectedExtensionId\manifest.json" + +if (-not $SkipProcessCheck) { + $running = @(Get-CimInstance Win32_Process -ErrorAction SilentlyContinue | Where-Object { + $_.Name -eq ([string][char]0x6BD4 + [char]0x7279 + [char]0x6D4F + [char]0x89C8 + [char]0x5668 + '.exe') -or + ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($targetApp, [System.StringComparison]::OrdinalIgnoreCase)) + }) + if ($running.Count -gt 0) { + throw 'BitBrowser is running. Close it and run this installer again.' + } +} + +if (-not (Test-Path -LiteralPath $sourceDb -PathType Leaf)) { + throw 'The clean seed database is missing from this package.' +} +if (-not (Test-Path -LiteralPath $sourceExtensionManifest -PathType Leaf)) { + throw 'The YesCaptcha extension is missing from this package.' +} + +Write-Host 'Installing BitBrowser application files...' +Copy-Tree -Source $sourceApp -Destination $targetApp + +$backups = @() +$backup = Move-ToBackup -Path $targetRoaming +if ($backup) { $backups += $backup } +$backup = Move-ToBackup -Path $targetLocal +if ($backup) { $backups += $backup } + +Write-Host 'Installing clean user data and preserved extensions...' +Copy-Tree -Source $sourceRoaming -Destination $targetRoaming +if (Test-Path -LiteralPath $sourceLocal -PathType Container) { + Copy-Tree -Source $sourceLocal -Destination $targetLocal +} + +if (-not (Test-Path -LiteralPath $targetExe -PathType Leaf)) { + throw "Installed executable was not found: $targetExe" +} +$targetDb = Join-Path $targetRoaming 'db.sqlite' +$targetExtensionManifest = Join-Path $targetRoaming "BitExtensions\$expectedExtensionId\manifest.json" +if (-not (Test-Path -LiteralPath $targetExtensionManifest -PathType Leaf)) { + throw "YesCaptcha was not installed: $targetExtensionManifest" +} +if ((Get-FileHash -LiteralPath $sourceDb -Algorithm SHA256).Hash -ne (Get-FileHash -LiteralPath $targetDb -Algorithm SHA256).Hash) { + throw 'The clean database copy failed verification.' +} +if ((Get-FileHash -LiteralPath $sourceExtensionManifest -Algorithm SHA256).Hash -ne (Get-FileHash -LiteralPath $targetExtensionManifest -Algorithm SHA256).Hash) { + throw 'The YesCaptcha extension copy failed verification.' +} + +if (-not $SkipShortcuts) { + $shell = New-Object -ComObject WScript.Shell + $desktop = [Environment]::GetFolderPath('Desktop') + $shortcutPath = Join-Path $desktop 'BitBrowser.lnk' + $shortcut = $shell.CreateShortcut($shortcutPath) + $shortcut.TargetPath = $targetExe + $shortcut.WorkingDirectory = $targetApp + $shortcut.IconLocation = $targetExe + ',0' + $shortcut.Save() + + $startMenu = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' + $startShortcutPath = Join-Path $startMenu 'BitBrowser.lnk' + $startShortcut = $shell.CreateShortcut($startShortcutPath) + $startShortcut.TargetPath = $targetExe + $startShortcut.WorkingDirectory = $targetApp + $startShortcut.IconLocation = $targetExe + ',0' + $startShortcut.Save() +} + +Write-Host '' +Write-Host "Installed application: $targetApp" +Write-Host "Installed clean data: $targetRoaming" +Write-Host "Verified extension: YesCaptcha 1.4.7 ($expectedExtensionId)" +if ($backups.Count -gt 0) { + Write-Host 'Previous data was preserved at:' + foreach ($item in $backups) { Write-Host " $item" } +} +Write-Host 'No browser profiles, cookies, history, cache, proxy settings, or login token were installed.' + +if (-not $NoLaunch) { + Start-Process -FilePath $targetExe -WorkingDirectory $targetApp +} diff --git a/scripts/bitbrowser-clean-package/README.txt b/scripts/bitbrowser-clean-package/README.txt new file mode 100644 index 0000000..49e2702 --- /dev/null +++ b/scripts/bitbrowser-clean-package/README.txt @@ -0,0 +1,35 @@ +比特浏览器干净初始化分享包 +========================== + +使用方法 +-------- +1. 解压整个 ZIP,不要只在压缩包里直接运行。 +2. 先关闭比特浏览器。 +3. 双击“一键安装_保留YesCaptcha人机助手.cmd”(不要直接运行 App 目录里的 EXE)。 +4. 首次打开后,使用新机器自己的比特账号登录。 + +包内保留 +-------- +- 比特浏览器程序本体 +- Chromium 浏览器内核和 chromedriver +- YesCaptcha 验证码助手 1.4.7 +- 比特的“服务助手(勿关闭)” 2.0.6 +- 插件和内核必需的本地登记 + +已清空/未包含 +------------ +- 比特账号登录令牌 +- 浏览器环境和窗口列表 +- Cookie、历史记录、缓存、会话和站点存储 +- 代理设置、日志和任务记录 +- 插件的登录状态和使用数据 + +安全说明 +-------- +- 如果目标机器已有比特用户数据,安装脚本会将其改名为 BitBrowser.backup-时间戳-随机码,不会直接删除。 +- 安装脚本不会复制原机器的 BrowserCache,因此不会携带原有环境资料。 +- 程序默认安装到 %LOCALAPPDATA%\Programs\BitBrowser。 +- 干净用户数据安装到 %APPDATA%\BitBrowser。 +- 已有的外置缓存目录(例如 D:\bitbrowser-cache)不会被删除,新安装也不会引用它。 + +首次登录后建议新建一个浏览器环境,确认 YesCaptcha 插件已加载。插件本身的账号需要在新机器重新登录。 diff --git a/scripts/bitbrowser-clean-package/make_clean_db.py b/scripts/bitbrowser-clean-package/make_clean_db.py new file mode 100644 index 0000000..ed9e707 --- /dev/null +++ b/scripts/bitbrowser-clean-package/make_clean_db.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""Create a sanitized BitBrowser database while preserving extensions and kernels.""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +from pathlib import Path + + +def quote_identifier(value: str) -> str: + return '"' + value.replace('"', '""') + '"' + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--source", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + + source_path = Path(args.source).resolve() + output_path = Path(args.output).resolve() + if source_path == output_path: + raise RuntimeError("The output database must differ from the source database") + if not source_path.is_file(): + raise FileNotFoundError(source_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + if output_path.exists(): + output_path.unlink() + + source = sqlite3.connect(f"file:{source_path.as_posix()}?mode=ro", uri=True) + target = sqlite3.connect(str(output_path)) + try: + source.backup(target) + target.execute("PRAGMA foreign_keys = OFF") + + tables = [ + row[0] + for row in target.execute( + "SELECT name FROM sqlite_master " + "WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ) + ] + + extension_rows = [] + if "local_extensions" in tables: + extension_rows = target.execute( + "SELECT name, desc, extension_id, icon, version, is_use " + "FROM local_extensions ORDER BY id" + ).fetchall() + + chrome_rows = [] + if "chrome_versions" in tables: + chrome_rows = target.execute( + "SELECT chrome, version FROM chrome_versions ORDER BY id" + ).fetchall() + + firefox_rows = [] + if "firefox_versions" in tables: + firefox_rows = target.execute( + "SELECT firefox, version FROM firefox_versions ORDER BY id" + ).fetchall() + + for table in tables: + target.execute(f"DELETE FROM {quote_identifier(table)}") + target.execute("DELETE FROM sqlite_sequence") + + if extension_rows: + target.executemany( + "INSERT INTO local_extensions " + "(name, desc, extension_id, icon, version, is_use, cloud_id, main_user_id) " + "VALUES (?, ?, ?, ?, ?, ?, NULL, NULL)", + extension_rows, + ) + if chrome_rows: + target.executemany( + "INSERT INTO chrome_versions (chrome, version) VALUES (?, ?)", + chrome_rows, + ) + if firefox_rows: + target.executemany( + "INSERT INTO firefox_versions (firefox, version) VALUES (?, ?)", + firefox_rows, + ) + + target.commit() + target.execute("VACUUM") + target.execute("PRAGMA optimize") + + counts = { + table: target.execute( + f"SELECT COUNT(*) FROM {quote_identifier(table)}" + ).fetchone()[0] + for table in tables + } + allowed_nonempty = {"local_extensions", "chrome_versions", "firefox_versions"} + unexpected = { + table: count + for table, count in counts.items() + if count and table not in allowed_nonempty + } + if unexpected: + raise RuntimeError(f"Sanitization failed; unexpected rows remain: {unexpected}") + + integrity = target.execute("PRAGMA integrity_check").fetchone()[0] + if integrity != "ok": + raise RuntimeError(f"SQLite integrity check failed: {integrity}") + + report = { + "source": str(source_path), + "output": str(output_path), + "extensions": [ + { + "name": row[0], + "extension_id": row[2], + "version": row[4], + "enabled": bool(row[5]), + } + for row in extension_rows + ], + "chrome_versions": [ + {"chrome": row[0], "version": row[1]} for row in chrome_rows + ], + "firefox_versions": [ + {"firefox": row[0], "version": row[1]} for row in firefox_rows + ], + "table_counts": counts, + "integrity_check": integrity, + "size_bytes": os.path.getsize(output_path), + } + print(json.dumps(report, ensure_ascii=False, indent=2)) + finally: + target.close() + source.close() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bitbrowser-clean-package/一键安装_保留YesCaptcha人机助手.cmd b/scripts/bitbrowser-clean-package/一键安装_保留YesCaptcha人机助手.cmd new file mode 100644 index 0000000..d161a6c --- /dev/null +++ b/scripts/bitbrowser-clean-package/一键安装_保留YesCaptcha人机助手.cmd @@ -0,0 +1,12 @@ +@echo off +setlocal +powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0Install-Clean.ps1" +if errorlevel 1 ( + echo. + echo Installation failed. Close BitBrowser and run this file again. + pause + exit /b 1 +) +echo. +echo BitBrowser and YesCaptcha were installed and verified. +pause diff --git a/src/core/database.js b/src/core/database.js index 5aa3f90..4f0f6be 100644 --- a/src/core/database.js +++ b/src/core/database.js @@ -954,10 +954,27 @@ function getAccount(email) { * @returns {object[]} 账号记录数组 */ function getAccountsByEmails(emails) { - const normalizedEmails = Array.from(new Set((Array.isArray(emails) ? emails : []).filter(Boolean))); + const normalizedEmails = Array.from(new Set((Array.isArray(emails) ? emails : []) + .map(email => String(email || '').trim()) + .filter(Boolean))); if (normalizedEmails.length === 0) return []; - const placeholders = normalizedEmails.map(() => '?').join(','); - return getDb().prepare(`SELECT * FROM accounts WHERE email IN (${placeholders})`).all(...normalizedEmails); + const database = getDb(); + const foundAccounts = []; + // SQLite 的绑定参数数量有编译时上限;分块查询以支持上万个账号。 + for (let i = 0; i < normalizedEmails.length; i += 200) { + const chunk = normalizedEmails.slice(i, i + 200); + const placeholders = chunk.map(() => '?').join(','); + foundAccounts.push(...database + .prepare(`SELECT * FROM accounts WHERE email IN (${placeholders})`) + .all(...chunk)); + } + const accountByEmail = new Map(foundAccounts.map(account => [ + String(account.email || '').trim().toLowerCase(), + account, + ])); + return normalizedEmails + .map(email => accountByEmail.get(email.toLowerCase())) + .filter(Boolean); } /** diff --git a/src/routes/operations.js b/src/routes/operations.js index 31a6573..37378a0 100644 --- a/src/routes/operations.js +++ b/src/routes/operations.js @@ -148,11 +148,12 @@ router.get('/events', sseMiddleware); /** * POST /api/operations/run * 启动后台任务,创建 Worker 线程执行指定任务类型 - * @param {import('express').Request} req - Express 请求对象,body 包含 taskType、accounts、params + * @param {import('express').Request} req - Express 请求对象,body 包含 taskType、accounts/accountEmails、params * @param {import('express').Response} res - Express 响应对象,返回 taskId 供客户端订阅 SSE */ router.post('/run', (req, res) => { - const { taskType, accounts, params } = req.body; + const { taskType, params } = req.body; + let { accounts } = req.body; if (!taskType) return res.status(400).json({ success: false, msg: '缺少 taskType' }); // 充值和退款会使用服务端保存的敏感凭据,不能由局域网中的其他设备触发。 @@ -181,7 +182,31 @@ router.post('/run', (req, res) => { && !String(req.get('content-type') || '').toLowerCase().startsWith('application/json')) { return res.status(415).json({ success: false, msg: '该任务仅接受 JSON 请求' }); } - if (!accounts || !Array.isArray(accounts)) { + if (taskType === 'aistudio_combo' && Array.isArray(req.body.accountEmails)) { + const requestedEmails = Array.from(new Set(req.body.accountEmails + .map(email => String(email || '').trim()) + .filter(Boolean))); + if (requestedEmails.length === 0) { + return res.status(400).json({ success: false, msg: 'AI Studio 一键组合缺少有效账号' }); + } + + // 前端只传邮箱以支持上万账号一次启动;敏感字段和长文本由服务端重新读取。 + const storedAccounts = db.getAccountsByEmails(requestedEmails); + const accountByEmail = new Map(storedAccounts.map(account => [ + String(account.email || '').trim().toLowerCase(), + account, + ])); + const missingCount = requestedEmails.reduce((count, email) => ( + accountByEmail.has(email.toLowerCase()) ? count : count + 1 + ), 0); + if (missingCount > 0) { + return res.status(400).json({ + success: false, + msg: `有 ${missingCount} 个账号已不存在,请刷新账号列表后重试`, + }); + } + accounts = requestedEmails.map(email => accountByEmail.get(email.toLowerCase())); + } else if (!accounts || !Array.isArray(accounts)) { return res.status(400).json({ success: false, msg: '缺少 accounts 数组' }); } const normalizedParams = params && typeof params === 'object' && !Array.isArray(params) diff --git a/tests/aistudio_large_batch_request.test.js b/tests/aistudio_large_batch_request.test.js new file mode 100644 index 0000000..8928c06 --- /dev/null +++ b/tests/aistudio_large_batch_request.test.js @@ -0,0 +1,43 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const appSource = fs.readFileSync(path.join(root, 'public', 'app.js'), 'utf8'); +const operationsSource = fs.readFileSync(path.join(root, 'src', 'routes', 'operations.js'), 'utf8'); +const databaseSource = fs.readFileSync(path.join(root, 'src', 'core', 'database.js'), 'utf8'); + +assert.match( + appSource, + /taskType === 'aistudio_combo'[\s\S]{0,300}accountEmails:\s*taskAccounts\.map/, + 'AI Studio 一键组合必须只提交邮箱引用,不能上传完整账号对象', +); +assert.match( + operationsSource, + /taskType === 'aistudio_combo'[\s\S]{0,120}Array\.isArray\(req\.body\.accountEmails\)/, + '服务端必须接受 AI Studio 邮箱引用列表', +); +assert.match( + operationsSource, + /db\.getAccountsByEmails\(requestedEmails\)/, + '服务端必须从数据库重新加载完整账号', +); +assert.match( + databaseSource, + /for \(let i = 0; i < normalizedEmails\.length; i \+= 200\)/, + '批量账号查询必须分块,避免 SQLite 绑定参数上限', +); + +const emailOnlyPayload = { + taskType: 'aistudio_combo', + accountEmails: Array.from({ length: 10513 }, (_, index) => `account-${index}@example.com`), + params: { threadCount: 1 }, +}; +assert( + Buffer.byteLength(JSON.stringify(emailOnlyPayload), 'utf8') < 10 * 1024 * 1024, + '10513 个邮箱的 AI Studio 请求必须低于 10MB', +); + +console.log('aistudio large batch request tests passed');