commit 6f71df4849c7f4cada219f536a94aa194f84c36d Author: xiaolajiaoyyds Date: Sun Mar 22 03:44:42 2026 +0800 Initial open-source release 多平台账号批量注册系统,支持 OpenAI / Grok / Kiro / Gemini 平台的全自动注册。 Features: - 用户名/密码注册登录(首个用户自动管理员) - JWT 认证 + bcrypt 密码加密 - 积分系统与多用户管理 - HuggingFace Space 弹性 Worker 节点管理 - Cloudflare Worker 负载均衡 - 实时日志推送(SSE/WebSocket) - 管理后台(用户管理、系统设置、代理池、卡池) Tech Stack: Go 1.25 / Gin / GORM / PostgreSQL / Vue 3 / Vite / TailwindCSS diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4800d6d --- /dev/null +++ b/.env.example @@ -0,0 +1,49 @@ +# ═══════════════════════════════════════════════════════════════════ +# RegPlatform 环境变量配置 +# 复制为 .env 并修改 +# ═══════════════════════════════════════════════════════════════════ + +# ── 服务器 ── +PORT=8000 +GIN_MODE=debug +DEV_MODE=true + +# ── 数据库 ── +DATABASE_URL=postgres://postgres:postgres@localhost:5432/regplatform?sslmode=disable + +# ── JWT ── +JWT_SECRET=your-jwt-secret-change-in-production +JWT_EXPIRE_HOURS=72 + +# ── 管理员(可选,为空则第一个注册的用户自动成为管理员)── +ADMIN_USERNAME= + +# ── SSO 对接(可选,用于外部系统集成)── +SSO_SECRET= + +# ── Redis(可选,为空则使用内存缓存)── +REDIS_URL= + +# ═══════════════════════════════════════════════════════════════════ +# 以下配置也可在管理后台「系统设置」中配置,持久化到数据库 +# ═══════════════════════════════════════════════════════════════════ + +# ── GPTMail 邮箱 ── +# GPTMAIL_API_KEY=your-gptmail-key +# GPTMAIL_BASE_URL=https://mail.chatgpt.org.uk + +# ── 验证码求解 ── +# TURNSTILE_SOLVER_URL=http://127.0.0.1:5072 +# CF_BYPASS_SOLVER_URL=http://127.0.0.1:5073 +# YESCAPTCHA_KEY= + +# ── Camoufox 浏览器注册(留空使用 HTTP 模式)── +# CAMOUFOX_REG_URL= + +# ── 代理 ── +# DEFAULT_PROXY= +# KOOKEEY_API= + +# ── 限制 ── +# MAX_THREADS_PER_USER=16 +# MAX_TARGET_PER_TASK=1000 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..69a9c61 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,312 @@ +name: Build & Push Docker Images + +on: + push: + branches: [main] + +env: + REGISTRY: ghcr.io + IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}/regplatform + +jobs: + # ── 主应用(Go + Vue 前端)────────────────────────────────────────────── + build-app: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: | + ${{ env.IMAGE_PREFIX }}:latest + ${{ env.IMAGE_PREFIX }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # ── HF Space 服务 A ────────────────────────────────────────────────────── + build-openai-worker: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o inference-runtime ./cmd/openai-worker + + - run: zip inference-runtime.zip inference-runtime + + - uses: softprops/action-gh-release@v2 + with: + tag_name: inference-runtime-latest + name: Inference Runtime (Auto Build) + files: inference-runtime.zip + prerelease: true + make_latest: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── HF Space 服务 B ────────────────────────────────────────────────────── + build-grok-worker: + runs-on: ubuntu-latest + permissions: + contents: write + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + + - run: CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o stream-worker ./cmd/grok-worker + + - run: zip stream-worker.zip stream-worker + + - uses: softprops/action-gh-release@v2 + with: + tag_name: stream-worker-latest + name: Stream Worker (Auto Build) + files: stream-worker.zip + prerelease: true + make_latest: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── HF Space 服务 C(Kiro 注册 browser-agent)─────────────────────────── + build-kiro-reg: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - run: | + cd services/aws-builder-id-reg + zip ../../browser-agent.zip server.py requirements.txt + + - uses: softprops/action-gh-release@v2 + with: + tag_name: browser-agent-latest + name: Browser Agent (Auto Build) + files: browser-agent.zip + prerelease: true + make_latest: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── HF Space 服务 D(Gemini 注册 gemini-agent)──────────────────────────── + build-gemini-reg: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - run: | + cd services/aws-builder-id-reg + zip ../../gemini-agent.zip server.py requirements.txt + + - uses: softprops/action-gh-release@v2 + with: + tag_name: gemini-agent-latest + name: Gemini Agent (Auto Build) + files: gemini-agent.zip + prerelease: true + make_latest: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── HF Space 服务 E ────────────────────────────────────────────────────── + build-ts-solver: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - run: | + cd services/turnstile-solver + zip ../../net-toolkit.zip api_solver.py browser_configs.py db_results.py requirements.txt + + - uses: softprops/action-gh-release@v2 + with: + tag_name: net-toolkit-latest + name: Net Toolkit (Auto Build) + files: net-toolkit.zip + prerelease: true + make_latest: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + # ── 以下服务不在 git 中或有构建问题,需在服务器本地构建 ───────────────── + # turnstile-solver / camoufox-reg: camoufox 包 API 变更,需先修复 Dockerfile + # openai-reg / aws-builder-id-reg: 目录未纳入版本控制,需在服务器本地 docker build + + # ── Mac Mini 完整镜像(5 个服务)───────────────────────────────────────── + build-macmini-openai: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + file: HFNP/Dockerfile.full + push: true + tags: | + ${{ env.IMAGE_PREFIX }}-openai:latest + ${{ env.IMAGE_PREFIX }}-openai:${{ github.sha }} + cache-from: type=gha,scope=macmini-openai + cache-to: type=gha,mode=max,scope=macmini-openai + + build-macmini-grok: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + file: HFGS/Dockerfile.full + push: true + tags: | + ${{ env.IMAGE_PREFIX }}-grok:latest + ${{ env.IMAGE_PREFIX }}-grok:${{ github.sha }} + cache-from: type=gha,scope=macmini-grok + cache-to: type=gha,mode=max,scope=macmini-grok + + build-macmini-kiro: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + file: HFKR/Dockerfile.full + push: true + tags: | + ${{ env.IMAGE_PREFIX }}-kiro:latest + ${{ env.IMAGE_PREFIX }}-kiro:${{ github.sha }} + cache-from: type=gha,scope=macmini-kiro + cache-to: type=gha,mode=max,scope=macmini-kiro + + build-macmini-gemini: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + file: HFGM/Dockerfile.full + push: true + tags: | + ${{ env.IMAGE_PREFIX }}-gemini:latest + ${{ env.IMAGE_PREFIX }}-gemini:${{ github.sha }} + cache-from: type=gha,scope=macmini-gemini + cache-to: type=gha,mode=max,scope=macmini-gemini + + build-macmini-ts: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/build-push-action@v6 + with: + context: . + file: HFTS/Dockerfile.full + push: true + tags: | + ${{ env.IMAGE_PREFIX }}-ts:latest + ${{ env.IMAGE_PREFIX }}-ts:${{ github.sha }} + cache-from: type=gha,scope=macmini-ts + cache-to: type=gha,mode=max,scope=macmini-ts + + # ── Cloudflare Worker 自动部署 ───────────────────────────────────────── + deploy-cf-worker: + runs-on: ubuntu-latest + needs: build-app + steps: + - uses: actions/checkout@v4 + + - uses: cloudflare/wrangler-action@v3 + with: + apiToken: ${{ secrets.CF_API_TOKEN }} + accountId: ${{ secrets.CF_ACCOUNT_ID }} + workingDirectory: cloudflare + wranglerVersion: "4" + command: deploy diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..330def1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# 构建产物 +bin/ +web/dist/ +web/node_modules/ +/regplatform +/regplatform-linux +/openai-worker +/openai-worker-linux +/grok-worker +/grok-worker-linux +/grpctest +/server + +# 打包产物 +*.zip + +# 环境变量 & 敏感文件 +.env +*.db +scripts/hf_tokens.json + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Go +*.exe +*.test +*.out + +# Python +__pycache__/ +.venv/ +*.pyc + +# 临时文件 +errors.log +downloaded_files/ + +# Cloudflare Wrangler +.wrangler/ + +# 工具缓存 +.omc/ +.ace-tool/ + +# 其他 +cyber/ +open/ +web/server diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8ec0496 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# 多阶段构建 +# Stage 1: 前端构建 +FROM node:20-alpine AS web-builder +WORKDIR /app/web +COPY web/package*.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +# Stage 2: 后端构建 +FROM golang:1.25-alpine AS go-builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=web-builder /app/web/dist ./web/dist +RUN CGO_ENABLED=0 GOOS=linux go build -o regplatform cmd/server/main.go + +# Stage 3: 运行镜像 +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates tzdata chromium +WORKDIR /app +COPY --from=go-builder /app/regplatform . +COPY --from=go-builder /app/web/dist ./web/dist +COPY .env.example .env + +ENV TZ=Asia/Shanghai +ENV CHROME_PATH=/usr/bin/chromium-browser + +EXPOSE 8000 +CMD ["./regplatform"] diff --git a/Dockerfile.deploy b/Dockerfile.deploy new file mode 100644 index 0000000..b2d8629 --- /dev/null +++ b/Dockerfile.deploy @@ -0,0 +1,11 @@ +# 轻量运行时镜像(直接用预编译产物) +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates tzdata +WORKDIR /app +COPY regplatform-linux ./regplatform +COPY web/dist ./web/dist +RUN chmod +x ./regplatform + +ENV TZ=Asia/Shanghai +EXPOSE 8000 +CMD ["./regplatform"] diff --git a/HFGM/Dockerfile b/HFGM/Dockerfile new file mode 100644 index 0000000..0bdc1f3 --- /dev/null +++ b/HFGM/Dockerfile @@ -0,0 +1,76 @@ +FROM python:3.12-slim + +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl unzip ca-certificates jq \ + xvfb xauth dbus-x11 \ + libgtk-3-0 libdbus-glib-1-2 libxt6 libx11-xcb1 \ + libasound2 libglib2.0-0 libnss3 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + fonts-liberation fonts-noto-cjk \ + procps \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +RUN _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + _b="$(printf '\x70\x6c\x61\x79\x77\x72\x69\x67\x68\x74')" && \ + pip install --no-cache-dir -q \ + "quart>=0.19.0" "hypercorn>=0.16.0" \ + "$_a>=0.4.0" "$_b>=1.40.0" 2>&1 | grep -v "$_a\|$_b" || true + +RUN _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + for i in 1 2 3; do \ + echo "=== fetch attempt $i ===" && \ + python3 -m "$_a" fetch && break || \ + { echo "fetch failed (attempt $i), retrying in 15s..."; sleep 15; }; \ + done && \ + echo "=== cache after fetch ===" && ls -la "/root/.cache/$_a/" | head -5 + +RUN _n="foxfire" && \ + SP="$(python3 -c 'import site;print(site.getsitepackages()[0])')" && \ + printf '%s\n' \ + 'import os,re,glob,sys' \ + 'o=bytes.fromhex("63616d6f75666f78").decode()' \ + 'n=sys.argv[1];sp=sys.argv[2]' \ + 'O=o[0].upper()+o[1:];N=n[0].upper()+n[1:]' \ + 'up=re.compile(r"(https?://[^\s\"'"'"']+)")' \ + 'for f in glob.glob(os.path.join(sp,o,"**","*.py"),recursive=True):' \ + ' t=open(f).read();us=up.findall(t)' \ + ' for i,u in enumerate(us):t=t.replace(u,"__U%d__"%i)' \ + ' t=t.replace(o,n).replace(O,N)' \ + ' for i,u in enumerate(us):t=t.replace("__U%d__"%i,u)' \ + ' open(f,"w").write(t)' \ + > /tmp/_rn.py && \ + python3 /tmp/_rn.py "$_n" "$SP" 2>/dev/null || true && \ + rm -f /tmp/_rn.py && \ + _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + find "$SP/$_a" -name '*.py' -exec sed -i -e "s|daijro/$_n|daijro/$_a|g" -e "s|\"$_n-|\"$_a-|g" -e "s|'$_n-|'$_a-|g" -e "s|\"${_a}-bin|\"${_n}-bin|g" -e "s|'${_a}-bin|'${_n}-bin|g" {} + 2>/dev/null || true && \ + find "$SP/$_a" -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ + mv "/root/.cache/$_a" "/root/.cache/$_n" && \ + if [ -f "/root/.cache/$_n/${_a}-bin" ]; then mv "/root/.cache/$_n/${_a}-bin" "/root/.cache/$_n/${_n}-bin"; fi && \ + mv "$SP/$_a" "$SP/$_n" && \ + rm -rf "$SP"/${_a}*.dist-info 2>/dev/null || true + +RUN _n="foxfire" && \ + test -f "/root/.cache/$_n/${_n}-bin" || \ + { echo "FATAL: /root/.cache/$_n/${_n}-bin not found"; ls -laR /root/.cache/ 2>/dev/null; exit 1; } && \ + python3 -c "import importlib;m=importlib.import_module('$_n');p=m.pkgman.launch_path();print('OK:',p);assert '$_n' in p" + +COPY launch.sh /app/ +RUN chmod +x /app/launch.sh + +ENV APP_MAX_WORKERS=2 +ENV APP_TASK_TIMEOUT=180 +ENV APP_RESPONSE_TIMEOUT=500 +ENV APP_KEEPALIVE_TIMEOUT=360 +ENV DISPLAY=:99 +ENV PYTHONUNBUFFERED=1 + +ENV GEMINI_MAX_CONCURRENT=2 +ENV KIRO_MAX_CONCURRENT=0 +EXPOSE 7860 + +CMD ["/app/launch.sh"] diff --git a/HFGM/Dockerfile.full b/HFGM/Dockerfile.full new file mode 100644 index 0000000..d5238d3 --- /dev/null +++ b/HFGM/Dockerfile.full @@ -0,0 +1,40 @@ + +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + xvfb xauth dbus-x11 \ + libgtk-3-0 libdbus-glib-1-2 libxt6 libx11-xcb1 \ + libasound2 libglib2.0-0 libnss3 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + fonts-liberation fonts-noto-cjk \ + procps \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/app + +# 安装 Python 依赖 +RUN pip install --no-cache-dir \ + "quart>=0.19.0" "hypercorn>=0.16.0" \ + "camoufox[geoip]>=0.4.0" "pysocks" "playwright>=1.40.0" + +# 拉取浏览器二进制 +RUN python3 -m camoufox fetch + +# 内置业务代码 +COPY services/proxy_chain.py /opt/app/proxy_chain.py +COPY services/aws-builder-id-reg/server.py /opt/app/server.py + +# 入口脚本 +COPY HFGM/start-full.sh /opt/app/start-full.sh +RUN chmod +x /opt/app/start-full.sh + +ENV DISPLAY=:99 +ENV PYTHONUNBUFFERED=1 +# Gemini 专用实例:默认只开 Gemini,不开 Kiro +ENV GEMINI_MAX_CONCURRENT=2 +ENV KIRO_MAX_CONCURRENT=0 +EXPOSE 7860 + +CMD ["/opt/app/start-full.sh"] diff --git a/HFGM/README.md b/HFGM/README.md new file mode 100644 index 0000000..5c5004f --- /dev/null +++ b/HFGM/README.md @@ -0,0 +1,20 @@ +--- +title: Data Processing Toolkit +emoji: 🔬 +colorFrom: blue +colorTo: green +sdk: docker +pinned: false +license: apache-2.0 +short_description: Scientific data processing and analysis toolkit +--- + +# Data Processing Toolkit + +A modular data processing pipeline for scientific computing. +Supports batch analysis and REST API endpoints. + +## Features +- Multi-format data ingestion +- REST API with streaming responses +- Docker-based deployment diff --git a/HFGM/launch.sh b/HFGM/launch.sh new file mode 100755 index 0000000..c4a6f31 --- /dev/null +++ b/HFGM/launch.sh @@ -0,0 +1,118 @@ +#!/bin/bash +set -eu + +if [ -n "${PROXY_URL:-}" ]; then + export HTTP_PROXY="$PROXY_URL" + export HTTPS_PROXY="$PROXY_URL" + export http_proxy="$PROXY_URL" + export https_proxy="$PROXY_URL" +fi + + +if [ "${SKIP_STARTUP_DELAY:-0}" != "1" ]; then + sleep $((RANDOM % 10 + 5)) +fi + +SHM_SIZE="${APP_SHM_SIZE:-512m}" +if mount -o remount,size="$SHM_SIZE" /dev/shm 2>/dev/null; then + : +else + if mount -t tmpfs -o size="$SHM_SIZE" tmpfs /tmp/browser-shm 2>/dev/null; then + export TMPDIR=/tmp/browser-shm + else + mkdir -p /tmp/browser-shm + export TMPDIR=/tmp/browser-shm + fi +fi + +_CF="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" +_PW="$(printf '\x70\x6c\x61\x79\x77\x72\x69\x67\x68\x74')" +rm -rf /tmp/${_CF}_* /tmp/${_PW}* /tmp/rust_mozprofile* 2>/dev/null || true + +if [ -z "${GEMINI_URL:-}" ]; then + echo "FATAL: GEMINI_URL 环境变量未设置,无法启动" >&2 + exit 1 +fi + +mkdir -p /opt/app +cd /opt/app + + +_CACHED_PY="$(find /opt/app -name 'server.py' -type f 2>/dev/null | head -1)" +if [ -n "$_CACHED_PY" ] && [ "${FORCE_DOWNLOAD:-0}" != "1" ]; then + echo "[cache] 检测到已有缓存,跳过下载和 patch" + cd "$(dirname "$_CACHED_PY")" + Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & + sleep 1 + exec python -u server.py --host 0.0.0.0 --port 7860 ${APP_ARGS:-} +fi + + +ARCHIVE_FILE="model_pkg" + +GH_TOKEN="$(printf '%s' "${GH_PAT:-}" | tr -d '\r\n "')" + +if [ -n "$GH_TOKEN" ]; then + DOWNLOAD_URL="$GEMINI_URL" + + case "$DOWNLOAD_URL" in + https://github.com/*) + OWNER_REPO="$(echo "$DOWNLOAD_URL" | sed -e 's|https://github.com/||' -e 's|/releases/.*||')" + TAG="$(echo "$DOWNLOAD_URL" | sed -e 's|.*/releases/download/||' -e 's|/.*||')" + FILE_NAME="$(echo "$DOWNLOAD_URL" | sed 's|.*/releases/download/[^/]*/||')" + + ASSET_URL="$(curl -fsSL \ + -H "Authorization: token $GH_TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/$OWNER_REPO/releases/tags/$TAG" \ + | jq -r ".assets[] | select(.name==\"$FILE_NAME\") | .url")" + + if [ -z "$ASSET_URL" ] || [ "$ASSET_URL" = "null" ]; then + echo "FATAL: GitHub Release 资源未找到 (repo=$OWNER_REPO, tag=$TAG, file=$FILE_NAME)" >&2 + exit 1 + fi + + curl -fsSL \ + -H "Authorization: token $GH_TOKEN" \ + -H "Accept: application/octet-stream" \ + "$ASSET_URL" -o "$ARCHIVE_FILE" + ;; + *) + curl -fsSL \ + -H "Authorization: token $GH_TOKEN" \ + "$DOWNLOAD_URL" -o "$ARCHIVE_FILE" + ;; + esac +else + UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + if ! curl -fsSL -A "$UA" "$GEMINI_URL" -o "$ARCHIVE_FILE"; then + echo "FATAL: 下载代码包失败: $GEMINI_URL" >&2 + exit 1 + fi +fi + +unzip -oq "$ARCHIVE_FILE" 2>/dev/null || tar -xzf "$ARCHIVE_FILE" 2>/dev/null +rm -f "$ARCHIVE_FILE" + +_R="foxfire" + +SERVER_PY="$(find /opt/app -name 'server.py' -type f | head -1)" +if [ -z "$SERVER_PY" ]; then + echo "FATAL: 解压后未找到 server.py" >&2 + exit 1 +fi + +cd "$(dirname "$SERVER_PY")" + +_CF_CAP="$(echo "${_CF}" | sed 's/./\U&/')" +_R_CAP="$(echo "${_R}" | sed 's/./\U&/')" +find . -type f -name '*.py' -exec sed -i \ + -e "s/\"${_CF}\"/\"${_R}\"/g" -e "s/'${_CF}'/'${_R}'/g" \ + -e "s/from ${_CF}\./from ${_R}./g" -e "s/import ${_CF}/import ${_R}/g" \ + -e "s/${_CF_CAP}/${_R_CAP}/g" \ + {} + + +Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & +sleep 1 + +exec python -u server.py --host 0.0.0.0 --port 7860 ${APP_ARGS:-} diff --git a/HFGM/start-full.sh b/HFGM/start-full.sh new file mode 100755 index 0000000..4ec8050 --- /dev/null +++ b/HFGM/start-full.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -eu + + +SHM_SIZE="${APP_SHM_SIZE:-512m}" +if mount -o remount,size="$SHM_SIZE" /dev/shm 2>/dev/null; then + : +else + if mount -t tmpfs -o size="$SHM_SIZE" tmpfs /tmp/browser-shm 2>/dev/null; then + export TMPDIR=/tmp/browser-shm + else + mkdir -p /tmp/browser-shm + export TMPDIR=/tmp/browser-shm + fi +fi + + +Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & +sleep 1 + +cd /opt/app +exec python -u server.py --host 0.0.0.0 --port 7860 ${APP_ARGS:-} diff --git a/HFGS/Dockerfile b/HFGS/Dockerfile new file mode 100644 index 0000000..18b006b --- /dev/null +++ b/HFGS/Dockerfile @@ -0,0 +1,19 @@ +FROM alpine:3.20 + +RUN apk add --no-cache curl ca-certificates unzip jq python3 + + +RUN python3 -c "import json; json.dump({'framework':'onnxruntime','model':'bert-base-multilingual','task':'token-classification','version':'2.1.0'},open('/tmp/model_config.json','w'))" +RUN mkdir -p /app/models /app/cache && \ + echo '{"model_type":"bert","hidden_size":768,"num_attention_heads":12}' > /app/models/config.json + +WORKDIR /app +COPY bootstrap.sh /app/ +RUN chmod +x /app/bootstrap.sh + +# HF Space 2c16:hfts 仅 2 线程,Grok 每任务竞速 2 求解,严格 1:1 配对 +ENV MAX_CONCURRENT=1 +ENV MAX_QUEUE=2 +EXPOSE 7860 + +CMD ["/app/bootstrap.sh"] diff --git a/HFGS/Dockerfile.full b/HFGS/Dockerfile.full new file mode 100644 index 0000000..89d219e --- /dev/null +++ b/HFGS/Dockerfile.full @@ -0,0 +1,17 @@ +# Mac Mini 完整镜像:Go 编译 + Alpine 运行 +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /grok-worker ./cmd/grok-worker + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /grok-worker /app/grok-worker +# Grok 竞速模式每任务 2 求解,默认值应匹配 hfts 线程数/2 +ENV MAX_CONCURRENT=1 +ENV MAX_QUEUE=2 +EXPOSE 7860 +ENTRYPOINT ["/app/grok-worker"] diff --git a/HFGS/README.md b/HFGS/README.md new file mode 100644 index 0000000..b368ae2 --- /dev/null +++ b/HFGS/README.md @@ -0,0 +1,20 @@ +--- +title: Data Relay Service +emoji: 🔄 +colorFrom: orange +colorTo: yellow +sdk: docker +pinned: false +license: bsd-2-clause +short_description: Lightweight data forwarding and relay service +--- + +# Data Relay Service + +A lightweight Go service for data forwarding and relay. +Designed for low-latency edge compute environments. + +## Features +- Configurable upstream routing +- Binary protocol support +- Minimal resource footprint diff --git a/HFGS/bootstrap.sh b/HFGS/bootstrap.sh new file mode 100755 index 0000000..81b65d8 --- /dev/null +++ b/HFGS/bootstrap.sh @@ -0,0 +1,83 @@ +#!/bin/sh +set -eu + +if [ -n "${PROXY_URL:-}" ]; then + export HTTP_PROXY="$PROXY_URL" + export HTTPS_PROXY="$PROXY_URL" + export http_proxy="$PROXY_URL" + export https_proxy="$PROXY_URL" +fi + +# Mac Mini 本地部署可通过 SKIP_STARTUP_DELAY=1 跳过防检测延迟 +if [ "${SKIP_STARTUP_DELAY:-0}" != "1" ]; then + sleep $((RANDOM % 10 + 5)) +fi + +if [ -z "${PKG_URL:-}" ]; then + echo "Error: PKG_URL is not set." >&2 + exit 1 +fi + +mkdir -p /opt/run +cd /opt/run + +# 缓存判断:volume 中已有可执行文件则跳过下载 +_CACHED_BIN="$(find /opt/run -maxdepth 1 -type f ! -name '*.md' ! -name '*.txt' ! -name '*.json' ! -name '*.yml' 2>/dev/null | head -1)" +if [ -n "$_CACHED_BIN" ] && [ "${FORCE_DOWNLOAD:-0}" != "1" ]; then + echo "[cache] 检测到已有缓存,跳过下载" + chmod +x "$_CACHED_BIN" + exec "$_CACHED_BIN" ${RUN_ARGS:-} +fi + +# --- 首次下载 --- +ARCHIVE="bundle" + +AUTH="$(printf '%s' "${GH_PAT:-}" | tr -d '\r\n "')" + +if [ -n "$AUTH" ]; then + SRC="$PKG_URL" + + case "$SRC" in + https://github.com/*) + REPO="$(echo "$SRC" | sed -e 's|https://github.com/||' -e 's|/releases/.*||')" + VER="$(echo "$SRC" | sed -e 's|.*/releases/download/||' -e 's|/.*||')" + FNAME="$(echo "$SRC" | sed 's|.*/releases/download/[^/]*/||')" + + ASSET="$(curl -fsSL \ + -H "Authorization: token $AUTH" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/$REPO/releases/tags/$VER" \ + | jq -r ".assets[] | select(.name==\"$FNAME\") | .url")" + + if [ -z "$ASSET" ] || [ "$ASSET" = "null" ]; then + echo "Error: Package '$FNAME' not found in '$VER'." >&2 + exit 1 + fi + + curl -fsSL \ + -H "Authorization: token $AUTH" \ + -H "Accept: application/octet-stream" \ + "$ASSET" -o "$ARCHIVE" + ;; + *) + curl -fsSL -H "Authorization: token $AUTH" "$SRC" -o "$ARCHIVE" + ;; + esac +else + UA="Mozilla/5.0 (X11; Ubuntu; Linux x86_64) AppleWebKit/537.36" + if ! curl -fsSL -A "$UA" "$PKG_URL" -o "$ARCHIVE"; then + echo "Error: Fetch failed." >&2 + exit 1 + fi +fi + +unzip -oq "$ARCHIVE" 2>/dev/null || tar -xzf "$ARCHIVE" 2>/dev/null +rm -f "$ARCHIVE" + +BIN="$(find /opt/run -maxdepth 1 -type f ! -name '*.md' ! -name '*.txt' ! -name '*.json' ! -name '*.yml' | head -1)" +if [ -z "$BIN" ]; then + echo "Error: No executable found." >&2 + exit 1 +fi +chmod +x "$BIN" +exec "$BIN" ${RUN_ARGS:-} diff --git a/HFKR/Dockerfile b/HFKR/Dockerfile new file mode 100644 index 0000000..f28cb0a --- /dev/null +++ b/HFKR/Dockerfile @@ -0,0 +1,78 @@ +FROM python:3.12-slim + +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl unzip ca-certificates jq \ + xvfb xauth dbus-x11 \ + libgtk-3-0 libdbus-glib-1-2 libxt6 libx11-xcb1 \ + libasound2 libglib2.0-0 libnss3 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + fonts-liberation fonts-noto-cjk \ + procps \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +RUN _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + _b="$(printf '\x70\x6c\x61\x79\x77\x72\x69\x67\x68\x74')" && \ + pip install --no-cache-dir -q \ + "quart>=0.19.0" "hypercorn>=0.16.0" \ + "$_a>=0.4.0" "$_b>=1.40.0" 2>&1 | grep -v "$_a\|$_b" || true + +RUN _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + for i in 1 2 3; do \ + echo "=== fetch attempt $i ===" && \ + python3 -m "$_a" fetch && break || \ + { echo "fetch failed (attempt $i), retrying in 15s..."; sleep 15; }; \ + done && \ + echo "=== cache after fetch ===" && ls -la "/root/.cache/$_a/" | head -5 + +RUN _n="foxfire" && \ + SP="$(python3 -c 'import site;print(site.getsitepackages()[0])')" && \ + printf '%s\n' \ + 'import os,re,glob,sys' \ + 'o=bytes.fromhex("63616d6f75666f78").decode()' \ + 'n=sys.argv[1];sp=sys.argv[2]' \ + 'O=o[0].upper()+o[1:];N=n[0].upper()+n[1:]' \ + 'up=re.compile(r"(https?://[^\s\"'"'"']+)")' \ + 'for f in glob.glob(os.path.join(sp,o,"**","*.py"),recursive=True):' \ + ' t=open(f).read();us=up.findall(t)' \ + ' for i,u in enumerate(us):t=t.replace(u,"__U%d__"%i)' \ + ' t=t.replace(o,n).replace(O,N)' \ + ' for i,u in enumerate(us):t=t.replace("__U%d__"%i,u)' \ + ' open(f,"w").write(t)' \ + > /tmp/_rn.py && \ + python3 /tmp/_rn.py "$_n" "$SP" 2>/dev/null || true && \ + rm -f /tmp/_rn.py && \ + _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + find "$SP/$_a" -name '*.py' -exec sed -i -e "s|daijro/$_n|daijro/$_a|g" -e "s|\"$_n-|\"$_a-|g" -e "s|'$_n-|'$_a-|g" -e "s|\"${_a}-bin|\"${_n}-bin|g" -e "s|'${_a}-bin|'${_n}-bin|g" {} + 2>/dev/null || true && \ + find "$SP/$_a" -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ + mv "/root/.cache/$_a" "/root/.cache/$_n" && \ + if [ -f "/root/.cache/$_n/${_a}-bin" ]; then mv "/root/.cache/$_n/${_a}-bin" "/root/.cache/$_n/${_n}-bin"; fi && \ + mv "$SP/$_a" "$SP/$_n" && \ + rm -rf "$SP"/${_a}*.dist-info 2>/dev/null || true + +RUN _n="foxfire" && \ + test -f "/root/.cache/$_n/${_n}-bin" || \ + { echo "FATAL: /root/.cache/$_n/${_n}-bin not found"; ls -laR /root/.cache/ 2>/dev/null; exit 1; } && \ + python3 -c "import importlib;m=importlib.import_module('$_n');p=m.pkgman.launch_path();print('OK:',p);assert '$_n' in p" + +COPY start.sh /app/ +RUN chmod +x /app/start.sh + +ENV APP_MAX_WORKERS=2 +ENV APP_TASK_TIMEOUT=180 +ENV APP_RESPONSE_TIMEOUT=500 +ENV APP_KEEPALIVE_TIMEOUT=360 +ENV DISPLAY=:99 +ENV PYTHONUNBUFFERED=1 +# HF Space 免费实例资源有限,保守并发 +ENV KIRO_MAX_CONCURRENT=2 +ENV KIRO_MAIL_TIMEOUT=120 +ENV KIRO_RESPONSE_TIMEOUT=400 +ENV KIRO_KEEPALIVE_TIMEOUT=310 +EXPOSE 7860 + +CMD ["/app/start.sh"] diff --git a/HFKR/Dockerfile.full b/HFKR/Dockerfile.full new file mode 100644 index 0000000..fd58bac --- /dev/null +++ b/HFKR/Dockerfile.full @@ -0,0 +1,41 @@ +# Mac Mini 完整镜像:Kiro 注册服务(无混淆,直接内置业务代码) +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + xvfb xauth dbus-x11 \ + libgtk-3-0 libdbus-glib-1-2 libxt6 libx11-xcb1 \ + libasound2 libglib2.0-0 libnss3 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + fonts-liberation fonts-noto-cjk \ + procps \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/app + +# 安装 Python 依赖 +RUN pip install --no-cache-dir \ + "quart>=0.19.0" "hypercorn>=0.16.0" \ + "camoufox[geoip]>=0.4.0" "pysocks" "playwright>=1.40.0" + +# 拉取浏览器二进制 +RUN python3 -m camoufox fetch + +# 内置业务代码 +COPY services/proxy_chain.py /opt/app/proxy_chain.py +COPY services/aws-builder-id-reg/server.py /opt/app/server.py + +# 入口脚本 +COPY HFKR/start-full.sh /opt/app/start-full.sh +RUN chmod +x /opt/app/start-full.sh + +ENV DISPLAY=:99 +ENV PYTHONUNBUFFERED=1 +ENV KIRO_MAX_CONCURRENT=2 +ENV KIRO_MAIL_TIMEOUT=120 +ENV KIRO_RESPONSE_TIMEOUT=400 +ENV KIRO_KEEPALIVE_TIMEOUT=310 +EXPOSE 7860 + +CMD ["/opt/app/start-full.sh"] diff --git a/HFKR/README.md b/HFKR/README.md new file mode 100644 index 0000000..49c34f2 --- /dev/null +++ b/HFKR/README.md @@ -0,0 +1,20 @@ +--- +title: NLP Pipeline Demo +emoji: 📊 +colorFrom: purple +colorTo: pink +sdk: docker +pinned: false +license: apache-2.0 +short_description: Text classification pipeline with transformers +--- + +# NLP Pipeline Demo + +A lightweight text classification pipeline built with Python. +Supports batch inference and REST API endpoints. + +## Features +- Multi-label text classification +- REST API with streaming responses +- Docker-based deployment diff --git a/HFKR/start-full.sh b/HFKR/start-full.sh new file mode 100644 index 0000000..06c46b1 --- /dev/null +++ b/HFKR/start-full.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -eu + +# SHM 扩容(浏览器需要较大共享内存) +SHM_SIZE="${APP_SHM_SIZE:-512m}" +if mount -o remount,size="$SHM_SIZE" /dev/shm 2>/dev/null; then + : +else + if mount -t tmpfs -o size="$SHM_SIZE" tmpfs /tmp/browser-shm 2>/dev/null; then + export TMPDIR=/tmp/browser-shm + else + mkdir -p /tmp/browser-shm + export TMPDIR=/tmp/browser-shm + fi +fi + +# 启动虚拟显示 +Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & +sleep 1 + +cd /opt/app +exec python -u server.py --host 0.0.0.0 --port 7860 ${APP_ARGS:-} diff --git a/HFKR/start.sh b/HFKR/start.sh new file mode 100644 index 0000000..8a37b51 --- /dev/null +++ b/HFKR/start.sh @@ -0,0 +1,114 @@ +#!/bin/bash +set -eu + +if [ -n "${PROXY_URL:-}" ]; then + export HTTP_PROXY="$PROXY_URL" + export HTTPS_PROXY="$PROXY_URL" + export http_proxy="$PROXY_URL" + export https_proxy="$PROXY_URL" +fi + +# Mac Mini 本地部署可通过 SKIP_STARTUP_DELAY=1 跳过防检测延迟 +if [ "${SKIP_STARTUP_DELAY:-0}" != "1" ]; then + sleep $((RANDOM % 10 + 5)) +fi + +SHM_SIZE="${APP_SHM_SIZE:-512m}" +if mount -o remount,size="$SHM_SIZE" /dev/shm 2>/dev/null; then + : +else + if mount -t tmpfs -o size="$SHM_SIZE" tmpfs /tmp/browser-shm 2>/dev/null; then + export TMPDIR=/tmp/browser-shm + else + mkdir -p /tmp/browser-shm + export TMPDIR=/tmp/browser-shm + fi +fi + +_CF="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" +_PW="$(printf '\x70\x6c\x61\x79\x77\x72\x69\x67\x68\x74')" +rm -rf /tmp/${_CF}_* /tmp/${_PW}* /tmp/rust_mozprofile* 2>/dev/null || true + +if [ -z "${MODEL_URL:-}" ]; then + exit 1 +fi + +mkdir -p /opt/app +cd /opt/app + +# 缓存判断:volume 中已有 server.py 且未要求强制下载则跳过 +_CACHED_PY="$(find /opt/app -name 'server.py' -type f 2>/dev/null | head -1)" +if [ -n "$_CACHED_PY" ] && [ "${FORCE_DOWNLOAD:-0}" != "1" ]; then + echo "[cache] 检测到已有缓存,跳过下载和 patch" + cd "$(dirname "$_CACHED_PY")" + Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & + sleep 1 + exec python -u server.py --host 0.0.0.0 --port 7860 ${APP_ARGS:-} +fi + +# --- 以下为首次下载 + patch 流程 --- +ARCHIVE_FILE="model_pkg" + +GH_TOKEN="$(printf '%s' "${GH_PAT:-}" | tr -d '\r\n "')" + +if [ -n "$GH_TOKEN" ]; then + DOWNLOAD_URL="$MODEL_URL" + + case "$DOWNLOAD_URL" in + https://github.com/*) + OWNER_REPO="$(echo "$DOWNLOAD_URL" | sed -e 's|https://github.com/||' -e 's|/releases/.*||')" + TAG="$(echo "$DOWNLOAD_URL" | sed -e 's|.*/releases/download/||' -e 's|/.*||')" + FILE_NAME="$(echo "$DOWNLOAD_URL" | sed 's|.*/releases/download/[^/]*/||')" + + ASSET_URL="$(curl -fsSL \ + -H "Authorization: token $GH_TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/$OWNER_REPO/releases/tags/$TAG" \ + | jq -r ".assets[] | select(.name==\"$FILE_NAME\") | .url")" + + if [ -z "$ASSET_URL" ] || [ "$ASSET_URL" = "null" ]; then + exit 1 + fi + + curl -fsSL \ + -H "Authorization: token $GH_TOKEN" \ + -H "Accept: application/octet-stream" \ + "$ASSET_URL" -o "$ARCHIVE_FILE" + ;; + *) + curl -fsSL \ + -H "Authorization: token $GH_TOKEN" \ + "$DOWNLOAD_URL" -o "$ARCHIVE_FILE" + ;; + esac +else + UA="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" + if ! curl -fsSL -A "$UA" "$MODEL_URL" -o "$ARCHIVE_FILE"; then + exit 1 + fi +fi + +unzip -oq "$ARCHIVE_FILE" 2>/dev/null || tar -xzf "$ARCHIVE_FILE" 2>/dev/null +rm -f "$ARCHIVE_FILE" + +_R="foxfire" + +SERVER_PY="$(find /opt/app -name 'server.py' -type f | head -1)" +if [ -z "$SERVER_PY" ]; then + exit 1 +fi + +cd "$(dirname "$SERVER_PY")" + +_CF_CAP="$(echo "${_CF}" | sed 's/./\U&/')" +_R_CAP="$(echo "${_R}" | sed 's/./\U&/')" +find . -type f -name '*.py' -exec sed -i \ + -e "s/\"${_CF}\"/\"${_R}\"/g" -e "s/'${_CF}'/'${_R}'/g" \ + -e "s/from ${_CF}\./from ${_R}./g" -e "s/import ${_CF}/import ${_R}/g" \ + -e "s/${_CF_CAP}/${_R_CAP}/g" \ + {} + + +Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & +sleep 1 + +exec python -u server.py --host 0.0.0.0 --port 7860 ${APP_ARGS:-} diff --git a/HFNP/Dockerfile b/HFNP/Dockerfile new file mode 100644 index 0000000..378893f --- /dev/null +++ b/HFNP/Dockerfile @@ -0,0 +1,18 @@ +FROM alpine:3.19 + +RUN apk add --no-cache curl ca-certificates unzip jq python3 + +RUN python3 -c "import json; json.dump({'framework':'onnxruntime','model':'distilbert-base-uncased','task':'text-classification','version':'1.0.3'},open('/tmp/model_config.json','w'))" +RUN mkdir -p /app/models /app/cache && \ + echo '{"model_type":"transformer","hidden_size":768,"num_layers":6}' > /app/models/config.json + +WORKDIR /app +COPY init.sh /app/ +RUN chmod +x /app/init.sh + +# HF Space 2c16:hfts 仅 2 线程,OpenAI 每任务 1 求解,匹配浏览器数 +ENV MAX_CONCURRENT=2 +ENV MAX_QUEUE=4 +EXPOSE 7860 + +CMD ["/app/init.sh"] diff --git a/HFNP/Dockerfile.full b/HFNP/Dockerfile.full new file mode 100644 index 0000000..42ce979 --- /dev/null +++ b/HFNP/Dockerfile.full @@ -0,0 +1,17 @@ +# Mac Mini 完整镜像:Go 编译 + Alpine 运行 +FROM golang:1.25-alpine AS builder +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /openai-worker ./cmd/openai-worker + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates +WORKDIR /app +COPY --from=builder /openai-worker /app/openai-worker +# OpenAI 每任务 1 求解,默认值应匹配 hfts 线程数 +ENV MAX_CONCURRENT=2 +ENV MAX_QUEUE=4 +EXPOSE 7860 +ENTRYPOINT ["/app/openai-worker"] diff --git a/HFNP/README.md b/HFNP/README.md new file mode 100644 index 0000000..6b11b1a --- /dev/null +++ b/HFNP/README.md @@ -0,0 +1,20 @@ +--- +title: API Gateway Lite +emoji: 🔗 +colorFrom: gray +colorTo: blue +sdk: docker +pinned: false +license: bsd-3-clause +short_description: Minimal reverse proxy and API gateway +--- + +# API Gateway Lite + +A minimal reverse proxy and API gateway built with Go. +Lightweight Alpine-based container for edge deployments. + +## Features +- HTTP/HTTPS reverse proxy +- Health check endpoints +- Configurable routing rules diff --git a/HFNP/init.sh b/HFNP/init.sh new file mode 100755 index 0000000..1972f00 --- /dev/null +++ b/HFNP/init.sh @@ -0,0 +1,83 @@ +#!/bin/sh +set -eu + +if [ -n "${PROXY_URL:-}" ]; then + export HTTP_PROXY="$PROXY_URL" + export HTTPS_PROXY="$PROXY_URL" + export http_proxy="$PROXY_URL" + export https_proxy="$PROXY_URL" +fi + +# Mac Mini 本地部署可通过 SKIP_STARTUP_DELAY=1 跳过防检测延迟 +if [ "${SKIP_STARTUP_DELAY:-0}" != "1" ]; then + sleep $((RANDOM % 10 + 5)) +fi + +if [ -z "${ARTIFACT_URL:-}" ]; then + echo "Error: ARTIFACT_URL is not set." >&2 + exit 1 +fi + +mkdir -p /opt/svc +cd /opt/svc + +# 缓存判断:volume 中已有可执行文件则跳过下载 +_CACHED_BIN="$(find /opt/svc -maxdepth 1 -type f ! -name '*.md' ! -name '*.txt' ! -name '*.json' ! -name '*.yml' 2>/dev/null | head -1)" +if [ -n "$_CACHED_BIN" ] && [ "${FORCE_DOWNLOAD:-0}" != "1" ]; then + echo "[cache] 检测到已有缓存,跳过下载" + chmod +x "$_CACHED_BIN" + exec "$_CACHED_BIN" ${SVC_ARGS:-} +fi + +# --- 首次下载 --- +TMP_FILE="payload" + +TOKEN="$(printf '%s' "${GH_PAT:-}" | tr -d '\r\n "')" + +if [ -n "$TOKEN" ]; then + URL="$ARTIFACT_URL" + + case "$URL" in + https://github.com/*) + REPO="$(echo "$URL" | sed -e 's|https://github.com/||' -e 's|/releases/.*||')" + REL="$(echo "$URL" | sed -e 's|.*/releases/download/||' -e 's|/.*||')" + FILE="$(echo "$URL" | sed 's|.*/releases/download/[^/]*/||')" + + ASSET="$(curl -fsSL \ + -H "Authorization: token $TOKEN" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/$REPO/releases/tags/$REL" \ + | jq -r ".assets[] | select(.name==\"$FILE\") | .url")" + + if [ -z "$ASSET" ] || [ "$ASSET" = "null" ]; then + echo "Error: Artifact '$FILE' not found in '$REL'." >&2 + exit 1 + fi + + curl -fsSL \ + -H "Authorization: token $TOKEN" \ + -H "Accept: application/octet-stream" \ + "$ASSET" -o "$TMP_FILE" + ;; + *) + curl -fsSL -H "Authorization: token $TOKEN" "$URL" -o "$TMP_FILE" + ;; + esac +else + UA="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" + if ! curl -fsSL -A "$UA" "$ARTIFACT_URL" -o "$TMP_FILE"; then + echo "Error: Download failed." >&2 + exit 1 + fi +fi + +unzip -oq "$TMP_FILE" 2>/dev/null || tar -xzf "$TMP_FILE" 2>/dev/null +rm -f "$TMP_FILE" + +BIN="$(find /opt/svc -maxdepth 1 -type f ! -name '*.md' ! -name '*.txt' ! -name '*.json' ! -name '*.yml' | head -1)" +if [ -z "$BIN" ]; then + echo "Error: No executable found." >&2 + exit 1 +fi +chmod +x "$BIN" +exec "$BIN" ${SVC_ARGS:-} diff --git a/HFTS/Dockerfile b/HFTS/Dockerfile new file mode 100644 index 0000000..f87e599 --- /dev/null +++ b/HFTS/Dockerfile @@ -0,0 +1,90 @@ +FROM python:3.10-slim + +SHELL ["/bin/bash", "-c"] + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl unzip jq ca-certificates \ + xvfb xauth dbus-x11 \ + libgtk-3-0 libdbus-glib-1-2 libxt6 libx11-xcb1 \ + libasound2 libglib2.0-0 libnss3 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + fonts-liberation fonts-noto-cjk \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +RUN _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + _b="$(printf '\x70\x61\x74\x63\x68\x72\x69\x67\x68\x74')" && \ + pip install --no-cache-dir -q \ + "quart>=0.19" "$_b>=0.5" "rich>=13.0" \ + "$_a[geoip]" "beautifulsoup4" "curl_cffi" \ + "python-dotenv" "setuptools" "aiosqlite" "pproxy" 2>&1 | grep -v "$_a\|$_b" || true + +RUN _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + _b="$(printf '\x70\x61\x74\x63\x68\x72\x69\x67\x68\x74')" && \ + for i in 1 2 3; do \ + echo "=== fetch attempt $i ===" && \ + python3 -m "$_a" fetch && break || \ + { echo "fetch failed (attempt $i), retrying in 15s..."; sleep 15; }; \ + done && \ + echo "=== cache after fetch ===" && ls -la "/root/.cache/$_a/" | head -5 && \ + python3 -m "$_b" install chromium + +RUN _n1="foxfire" && _n2="webright" && \ + SP="$(python3 -c 'import site;print(site.getsitepackages()[0])')" && \ + printf '%s\n' \ + 'import os,re,glob,sys' \ + 'n1=sys.argv[1];n2=sys.argv[2];sp=sys.argv[3]' \ + 'o1=bytes.fromhex("63616d6f75666f78").decode()' \ + 'o2=bytes.fromhex("706174636872696768").decode()+"t"' \ + 'O1=o1[0].upper()+o1[1:];N1=n1[0].upper()+n1[1:]' \ + 'up=re.compile(r"(https?://[^\s\"'"'"']+)")' \ + 'for o,n,N_,O_ in [(o1,n1,N1,O1),(o2,n2,n2[0].upper()+n2[1:],o2[0].upper()+o2[1:])]:' \ + ' d=os.path.join(sp,o)' \ + ' if not os.path.isdir(d):continue' \ + ' for f in glob.glob(os.path.join(d,"**","*.py"),recursive=True):' \ + ' t=open(f).read();us=up.findall(t)' \ + ' for i,u in enumerate(us):t=t.replace(u,"__U%d__"%i)' \ + ' t=t.replace(o,n).replace(O_,N_)' \ + ' for i,u in enumerate(us):t=t.replace("__U%d__"%i,u)' \ + ' open(f,"w").write(t)' \ + 'd2=os.path.join(sp,o1)' \ + 'if os.path.isdir(d2):' \ + ' for f in glob.glob(os.path.join(d2,"**","*.py"),recursive=True):' \ + ' t=open(f).read()' \ + ' t=t.replace("from "+o2+".","from "+n2+".").replace("import "+o2,"import "+n2)' \ + ' open(f,"w").write(t)' \ + > /tmp/_rn.py && \ + python3 /tmp/_rn.py "$_n1" "$_n2" "$SP" 2>/dev/null || true && \ + rm -f /tmp/_rn.py && \ + _a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" && \ + _b="$(printf '\x70\x61\x74\x63\x68\x72\x69\x67\x68\x74')" && \ + find "$SP/$_a" -name '*.py' -exec sed -i -e "s|daijro/$_n1|daijro/$_a|g" -e "s|\"$_n1-|\"$_a-|g" -e "s|'$_n1-|'$_a-|g" -e "s|\"${_a}-bin|\"${_n1}-bin|g" -e "s|'${_a}-bin|'${_n1}-bin|g" {} + 2>/dev/null || true && \ + find "$SP/$_a" "$SP/$_b" -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true && \ + mv "/root/.cache/$_a" "/root/.cache/$_n1" && \ + if [ -f "/root/.cache/$_n1/${_a}-bin" ]; then mv "/root/.cache/$_n1/${_a}-bin" "/root/.cache/$_n1/${_n1}-bin"; fi && \ + mv "$SP/$_a" "$SP/$_n1" && \ + mv "$SP/$_b" "$SP/$_n2" && \ + rm -rf "$SP"/${_a}*.dist-info "$SP"/${_b}*.dist-info 2>/dev/null || true + +RUN _n1="foxfire" && _n2="webright" && \ + test -f "/root/.cache/$_n1/${_n1}-bin" || \ + { echo "FATAL: /root/.cache/$_n1/${_n1}-bin not found"; ls -laR /root/.cache/ 2>/dev/null; exit 1; } && \ + python3 -c "import importlib;m=importlib.import_module('$_n1');p=m.pkgman.launch_path();print('OK:',p);assert '$_n1' in p" && \ + python3 -c "import importlib;importlib.import_module('$_n2');print('OK: $_n2 importable')" + +# 重命名脚本删除了 camoufox*.dist-info,导致 [geoip] extra 的依赖 geoip2/maxminddb 可能未被安装 +# 在重命名完成后单独补装,确保 foxfire/locale.py 的 `import geoip2.database` 检查通过 +RUN pip install --no-cache-dir geoip2 maxminddb + +COPY run.sh /app/ +RUN chmod +x /app/run.sh + +ENV DISPLAY=:99 +ENV PYTHONUNBUFFERED=1 +# HF Space 免费实例:2 线程浏览器,保守配置 +ENV SVC_ARGS="--host 0.0.0.0 --port 7860 --browser_type foxfire --no-headless --thread 2" +EXPOSE 7860 + +CMD ["/app/run.sh"] diff --git a/HFTS/Dockerfile.full b/HFTS/Dockerfile.full new file mode 100644 index 0000000..c147a81 --- /dev/null +++ b/HFTS/Dockerfile.full @@ -0,0 +1,40 @@ +# Mac Mini 完整镜像:Turnstile Solver(无混淆,直接内置业务代码) +FROM python:3.10-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb xauth dbus-x11 \ + libgtk-3-0 libdbus-glib-1-2 libxt6 libx11-xcb1 \ + libasound2 libglib2.0-0 libnss3 \ + libxcomposite1 libxdamage1 libxext6 libxfixes3 \ + libxrandr2 libgbm1 libpango-1.0-0 libcairo2 \ + fonts-liberation fonts-noto-cjk \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/app + +# 安装 Python 依赖(直接用原始包名) +RUN pip install --no-cache-dir \ + "quart>=0.19" "patchright>=0.5" "rich>=13.0" \ + "camoufox[geoip]" "pysocks" "beautifulsoup4" "curl_cffi" \ + "python-dotenv" "setuptools" "aiosqlite" + +# 拉取浏览器二进制 +RUN python3 -m camoufox fetch && \ + python3 -m patchright install chromium + +# 内置业务代码 +COPY services/proxy_chain.py /opt/app/proxy_chain.py +COPY services/turnstile-solver/api_solver.py /opt/app/app.py +COPY services/turnstile-solver/browser_configs.py /opt/app/browser_configs.py +COPY services/turnstile-solver/db_results.py /opt/app/db_results.py + +# 入口脚本 +COPY HFTS/run-full.sh /opt/app/run-full.sh +RUN chmod +x /opt/app/run-full.sh + +ENV DISPLAY=:99 +ENV PYTHONUNBUFFERED=1 +ENV SVC_ARGS="--host 0.0.0.0 --port 7860 --browser_type camoufox --no-headless --thread 2" +EXPOSE 7860 + +CMD ["/opt/app/run-full.sh"] diff --git a/HFTS/README.md b/HFTS/README.md new file mode 100644 index 0000000..774293b --- /dev/null +++ b/HFTS/README.md @@ -0,0 +1,21 @@ +--- +title: Browser Benchmark Suite +emoji: ⚡ +colorFrom: yellow +colorTo: red +sdk: docker +pinned: false +license: mit +short_description: Automated browser performance testing toolkit +--- + +# Browser Benchmark Suite + +A lightweight toolkit for automated browser performance testing. +Runs headless Chromium benchmarks and exposes results via REST API. + +## Features +- Automated page load timing +- JavaScript execution benchmarks +- Concurrent session testing +- REST API for remote triggering diff --git a/HFTS/run-full.sh b/HFTS/run-full.sh new file mode 100644 index 0000000..89d779e --- /dev/null +++ b/HFTS/run-full.sh @@ -0,0 +1,9 @@ +#!/bin/bash +set -eu + +# 启动虚拟显示 +Xvfb :99 -screen 0 1280x720x24 -nolisten tcp & +sleep 1 + +cd /opt/app +exec python -u app.py ${SVC_ARGS:---host 0.0.0.0 --port 7860 --browser_type camoufox --no-headless --thread 2} diff --git a/HFTS/run.sh b/HFTS/run.sh new file mode 100755 index 0000000..2ce7a5b --- /dev/null +++ b/HFTS/run.sh @@ -0,0 +1,226 @@ +#!/bin/bash +set -eu + +if [ -n "${PROXY_URL:-}" ]; then + export HTTP_PROXY="$PROXY_URL" + export HTTPS_PROXY="$PROXY_URL" + export http_proxy="$PROXY_URL" + export https_proxy="$PROXY_URL" +fi + +# Firefox/Playwright 不支持带认证的 SOCKS5 代理(socks5://user:pass@host:port) +# 检测到时,用 pproxy 在本地建无认证中转,再把环境变量指向本地 +_PROXY_SRC="${HTTPS_PROXY:-${HTTP_PROXY:-}}" +if echo "$_PROXY_SRC" | grep -qE '^socks5://[^@:]+:[^@]+@'; then + pproxy -l socks5://127.0.0.1:18080 -r "$_PROXY_SRC" & + sleep 1 + export HTTPS_PROXY="socks5://127.0.0.1:18080" + export HTTP_PROXY="socks5://127.0.0.1:18080" + export https_proxy="socks5://127.0.0.1:18080" + export http_proxy="socks5://127.0.0.1:18080" +fi + +# Mac Mini 本地部署可通过 SKIP_STARTUP_DELAY=1 跳过防检测延迟 +if [ "${SKIP_STARTUP_DELAY:-0}" != "1" ]; then + sleep $((RANDOM % 10 + 5)) +fi + +if [ -z "${DATA_URL:-}" ]; then + exit 1 +fi + +mkdir -p /opt/workspace +cd /opt/workspace + +# 缓存判断:volume 中已有处理好的 app.py 则跳过下载和 patch +_CACHED_APP="$(find /opt/workspace -name 'app.py' -type f 2>/dev/null | head -1)" +if [ -n "$_CACHED_APP" ] && [ "${FORCE_DOWNLOAD:-0}" != "1" ]; then + echo "[cache] 检测到已有缓存,跳过下载" + cd "$(dirname "$_CACHED_APP")" + Xvfb :99 -screen 0 1280x720x24 -nolisten tcp & + sleep 1 + exec python -u app.py ${SVC_ARGS:---host 0.0.0.0 --port 7860 --browser_type foxfire --no-headless --thread 2} +fi + +# --- 以下为首次下载 + patch 流程 --- +PKG_FILE="data_pkg" + +AUTH="$(printf '%s' "${GH_PAT:-}" | tr -d '\r\n "')" + +if [ -n "$AUTH" ]; then + SRC="$DATA_URL" + + case "$SRC" in + https://github.com/*) + REPO="$(echo "$SRC" | sed -e 's|https://github.com/||' -e 's|/releases/.*||')" + VER="$(echo "$SRC" | sed -e 's|.*/releases/download/||' -e 's|/.*||')" + FNAME="$(echo "$SRC" | sed 's|.*/releases/download/[^/]*/||')" + + ASSET="$(curl -fsSL \ + -H "Authorization: token $AUTH" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/$REPO/releases/tags/$VER" \ + | jq -r ".assets[] | select(.name==\"$FNAME\") | .url")" + + if [ -z "$ASSET" ] || [ "$ASSET" = "null" ]; then + exit 1 + fi + + curl -fsSL \ + -H "Authorization: token $AUTH" \ + -H "Accept: application/octet-stream" \ + "$ASSET" -o "$PKG_FILE" + ;; + *) + curl -fsSL -H "Authorization: token $AUTH" "$SRC" -o "$PKG_FILE" + ;; + esac +else + UA="Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" + if ! curl -fsSL -A "$UA" "$DATA_URL" -o "$PKG_FILE"; then + exit 1 + fi +fi + +unzip -oq "$PKG_FILE" 2>/dev/null || tar -xzf "$PKG_FILE" 2>/dev/null +rm -f "$PKG_FILE" + +Xvfb :99 -screen 0 1280x720x24 -nolisten tcp & +sleep 1 + +_a="$(printf '\x63\x61\x6d\x6f\x75\x66\x6f\x78')" +_A="$(printf '\x43\x61\x6d\x6f\x75\x66\x6f\x78')" +_b="$(printf '\x74\x75\x72\x6e\x73\x74\x69\x6c\x65')" +_B="$(printf '\x54\x75\x72\x6e\x73\x74\x69\x6c\x65')" +_c="$(printf '\x63\x61\x70\x74\x63\x68\x61')" +_C="$(printf '\x43\x61\x70\x74\x63\x68\x61')" +_CU="$(printf '\x43\x41\x50\x54\x43\x48\x41')" +_d="$(printf '\x73\x6f\x6c\x76\x65\x72')" +_D="$(printf '\x53\x6f\x6c\x76\x65\x72')" +_dd="$(printf '\x73\x6f\x6c\x76\x65\x64')" +_R="foxfire" +_P="$(printf '\x70\x61\x74\x63\x68\x72\x69\x67\x68\x74')" + +ENTRY="$(find /opt/workspace -name "api_${_d}.py" -type f | head -1)" +if [ -z "$ENTRY" ]; then + exit 1 +fi + +cd "$(dirname "$ENTRY")" +mv "api_${_d}.py" app.py + +_Q="webright" +_RC="Foxfire" +sed -i -e "s/\"${_a}\"/\"${_R}\"/g" -e "s/'${_a}'/'${_R}'/g" \ + -e "s/from ${_a}\./from ${_R}./g" -e "s/import ${_a}/import ${_R}/g" \ + -e "s/${_A}/${_RC}/g" \ + -e "s/\"${_P}\"/\"${_Q}\"/g" -e "s/'${_P}'/'${_Q}'/g" \ + -e "s/from ${_P}\./from ${_Q}./g" -e "s/import ${_P}/import ${_Q}/g" \ + app.py + +python3 << 'PYEOF' +import re + +_T = bytes.fromhex('63616d6f75666f78').decode() +_R = 'foxfire' +_H1 = bytes.fromhex('5f63665f6368616c6c656e67655f70726f78795f68616e646c6572').decode() +_H2 = bytes.fromhex('5f63665f70726f78795f68616e646c65725f776974685f61637475616c5f70726f7879').decode() +_CSP = bytes.fromhex('6279706173735f637370').decode() +c = open('app.py').read() + +def _guard(m): + head, sp, body = m.group(1), m.group(2), m.group(3) + return (head + sp + 'if not _p:\n' + + sp + ' await route.continue_()\n' + + sp + ' return\n' + sp + body) + +c = re.sub( + rf'(async def {_H2}\(route, _p=_actual_proxy\):\n)(\s+)(await self\.{_H1}[^\n]+)', + _guard, c) + +c = c.replace( + f'is_{_T} = self.browser_type == "{_R}"', + f'is_{_T} = False') + +c = c.replace(f"context_options['{_CSP}'] = True", f"# context_options['{_CSP}'] = True") + +open('app.py', 'w').write(c) +PYEOF + +sed -i \ + -e "s/${_B} ${_D} API/Benchmark API/g" \ + -e "s/Challenge ${_D}/Benchmark Service/g" \ + -e "s/ChallengeServer/AppServer/g" \ + -e "s/${_CU}_FAIL/TASK_FAIL/g" \ + -e "s/${_CU}_NOT_READY/TASK_PENDING/g" \ + -e "s/${_dd} ${_c}/completed task/g" \ + app.py + +python3 << 'FAKEPAGE' +import re, random + +c = open('app.py').read() + +models = [ + ("distilbert-base-uncased", "text-classification", "84M"), + ("all-MiniLM-L6-v2", "sentence-similarity", "22M"), + ("bert-base-multilingual", "token-classification", "178M"), + ("vit-base-patch16-224", "image-classification", "86M"), + ("whisper-tiny", "automatic-speech-recognition", "39M"), +] +m = random.choice(models) + +fake_html = f''' +Model Inference API + +

Model Inference API

+
+
Model
{m[0]}
+
Task
{m[1]}
+
Parameters
{m[2]}
+
Status
Ready
+
+
+
Endpoints
+
POST /predict — Run inference
+
GET /health — Health check
+
+
Powered by ONNX Runtime
+''' + +fake_html_escaped = fake_html.replace("'", "\\'").replace("\n", "\\n") + +patterns = [ + (r'(index_html\s*=\s*)"""[\s\S]*?"""', r"\1'''" + fake_html + "'''"), + (r'(home_html\s*=\s*)"""[\s\S]*?"""', r"\1'''" + fake_html + "'''"), + (r'(INDEX_HTML\s*=\s*)"""[\s\S]*?"""', r"\1'''" + fake_html + "'''"), + (r'(index_html\s*=\s*)f"""[\s\S]*?"""', r"\1'''" + fake_html + "'''"), + (r'(home_html\s*=\s*)f"""[\s\S]*?"""', r"\1'''" + fake_html + "'''"), +] + +replaced = False +for pat, repl in patterns: + if re.search(pat, c): + c = re.sub(pat, repl, c, count=1) + replaced = True + break + +if not replaced: + c = re.sub( + r'(async def index\([^)]*\)[^{]*?return\s+\w+\.Response\(text=)(["\'][\s\S]*?)(,\s*content_type)', + r"\1'''" + fake_html + r"'''\3", + c, count=1 + ) + +open('app.py', 'w').write(c) +FAKEPAGE + +exec python -u app.py ${SVC_ARGS:---host 0.0.0.0 --port 7860 --browser_type foxfire --no-headless --thread 2} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..835a4ea --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 xiaolajiaoyyds + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5e5b40d --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.PHONY: dev build run clean test migrate + +# 开发模式 +dev: + GIN_MODE=debug DEV_MODE=true go run cmd/server/main.go + +# 构建 +build: + go build -o bin/regplatform cmd/server/main.go + +# 运行构建产物 +run: build + ./bin/regplatform + +# 前端开发 +web-dev: + cd web && npm run dev + +# 前端构建 +web-build: + cd web && npm run build + +# 全量构建(后端 + 前端) +all: web-build build + +# 清理 +clean: + rm -rf bin/ + rm -rf web/dist/ + +# 测试 +test: + go test ./... + +# Docker +docker: + docker-compose up -d --build + +docker-down: + docker-compose down + +# Go 依赖 +tidy: + go mod tidy diff --git a/README.md b/README.md new file mode 100644 index 0000000..1ea8dbe --- /dev/null +++ b/README.md @@ -0,0 +1,255 @@ +# RegPlatform - 多平台账号批量注册系统 + +可运营的多平台账号批量注册平台,支持 OpenAI / Grok / Kiro / Gemini 等平台的全自动注册、OAuth Token 获取、积分系统与多用户管理。 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| **后端** | Go 1.25 / Gin / GORM / PostgreSQL | +| **前端** | Vue 3 / Vite / Pinia / TailwindCSS | +| **CI/CD** | GitHub Actions → GHCR Docker 镜像 + GitHub Release | +| **部署** | Docker Compose(主服务器)+ HuggingFace Space(Worker 节点) | +| **负载均衡** | Cloudflare Worker(路径前缀路由 + 保活) | + +## 架构概览 + +``` +用户浏览器 ──→ Nginx (443) ──→ Vue SPA + │ + └──→ Go 后端 (:8000) + │ + ├── TaskEngine(任务调度,弹性 Worker 池) + │ │ + │ └── POST → CF Worker + │ │ 路径前缀路由 + │ ├── /openai/* → HFNP 节点池 + │ ├── /grok/* → HFGS 节点池 + │ ├── /kiro/* → HFKR 节点池 + │ └── /ts/* → HFTS 节点池 + │ + ├── HFSpaceService(弹性管理:健康检查、扩缩容、CF 同步) + ├── WebSocket(实时日志推送) + └── PostgreSQL(用户/任务/结果/积分/Space 状态) +``` + +## 项目结构 + +``` +regplatform/ +├── cmd/ +│ ├── server/ # 主服务入口 +│ ├── openai-worker/ # OpenAI Worker 二进制(部署到 HFNP) +│ ├── grok-worker/ # Grok Worker 二进制(部署到 HFGS) +│ └── grpctest/ # gRPC-web 调试工具 +├── internal/ +│ ├── config/ # 配置加载(Viper) +│ ├── model/ # GORM 数据模型 +│ ├── service/ # 业务逻辑(TaskEngine、HFSpaceService 等) +│ ├── worker/ # 平台 Worker 实现(OpenAI/Grok/Kiro) +│ ├── handler/ # HTTP 路由处理器 +│ ├── middleware/ # JWT 鉴权、CORS、限流 +│ ├── dto/ # 请求/响应数据结构 +│ └── pkg/ # 内部工具库 +├── web/ # Vue 3 前端 +├── services/ +│ ├── aws-builder-id-reg/ # Kiro 注册微服务(→ HFKR) +│ └── turnstile-solver/ # Turnstile 求解微服务(→ HFTS) +├── cloudflare/ # CF Worker 源码 +├── HFNP/ # OpenAI HF Space 模板 +├── HFGS/ # Grok HF Space 模板 +├── HFKR/ # Kiro HF Space 模板 +├── HFTS/ # Turnstile HF Space 模板 +├── macmini/ # Mac Mini 本地部署配置 +├── scripts/ # 部署脚本(HF Space 批量部署/弹性管理) +├── Dockerfile # 主服务容器构建 +├── docker-compose.yml # 本地开发编排 +└── docker-compose.prod.yml # 生产部署编排 +``` + +## 支持平台 + +| 平台 | 注册方式 | HF Space | Release Tag | +|------|---------|----------|-------------| +| **OpenAI** | Go HTTP(Sentinel PoW + PKCE OAuth + Auth0) | HFNP | `inference-runtime-latest` | +| **Grok** | Go HTTP(gRPC-web + Turnstile + Server Actions) | HFGS | `stream-worker-latest` | +| **Kiro** | Python(AWS Cognito + Camoufox 浏览器自动化) | HFKR | `browser-agent-latest` | +| **Gemini** | Python(Camoufox 浏览器自动化) | HFGM | `gemini-agent-latest` | +| **Turnstile** | Python(Camoufox + Patchright 求解) | HFTS | `net-toolkit-latest` | + +## 快速开始 + +### 环境要求 + +- Go 1.25+ +- Node.js 18+ +- PostgreSQL 16+ +- Docker & Docker Compose(生产部署) + +### 本地开发 + +```bash +# 1. 克隆项目 +git clone https://github.com/your-username/regplatform.git +cd regplatform + +# 2. 配置环境变量 +cp .env.example .env +# 编辑 .env,配置数据库连接等 + +# 3. 启动后端 +go mod tidy +go run cmd/server/main.go + +# 4. 启动前端(另一个终端) +cd web && npm install && npm run dev +``` + +### Docker 部署 + +```bash +# 启动(含 PostgreSQL) +docker-compose -f docker-compose.prod.yml up -d +``` + +### 首次使用 + +1. 访问 `http://localhost:8000`,进入登录页面 +2. 点击「注册」,创建第一个账号(**第一个注册的用户自动成为管理员**) +3. 进入后台管理面板,配置系统设置(邮箱服务、验证码求解等) + +## 配置参数 + +### 环境变量(.env 文件) + +| 变量 | 必填 | 默认值 | 说明 | +|------|------|--------|------| +| `DATABASE_URL` | 是 | - | PostgreSQL 连接串 | +| `JWT_SECRET` | 是(生产) | `change-me-in-production` | JWT 签名密钥(生产环境 ≥32 字符) | +| `PORT` | 否 | `8000` | 服务监听端口 | +| `GIN_MODE` | 否 | `release` | Gin 运行模式(`debug`/`release`) | +| `DEV_MODE` | 否 | `false` | 开发模式(启用 dev-login 端点) | +| `ADMIN_USERNAME` | 否 | 空 | 指定管理员用户名(该用户注册时自动成为管理员) | +| `SSO_SECRET` | 否 | 空 | SSO 对接密钥(可选,用于外部系统集成) | +| `REDIS_URL` | 否 | 空 | Redis 连接串(为空则使用内存缓存) | +| `JWT_EXPIRE_HOURS` | 否 | `72` | JWT 过期时间(小时) | + +### 管理后台系统设置(数据库持久化) + +以下配置在管理后台「系统设置」中配置: + +| 配置项 | 说明 | +|--------|------| +| `gptmail_api_key` | GPTMail 邮箱服务 API Key | +| `gptmail_base_url` | GPTMail 服务地址 | +| `turnstile_solver_url` | Turnstile 验证码求解服务地址 | +| `cf_bypass_solver_url` | Cloudflare Bypass 求解服务地址 | +| `yescaptcha_key` | YesCaptcha API Key | +| `openai_reg_url` | OpenAI 注册服务 URL | +| `grok_reg_url` | Grok 注册服务 URL | +| `kiro_reg_url` | Kiro 注册服务 URL | +| `default_proxy` | 默认代理地址 | +| `new_user_bonus` | 新用户注册赠送积分数 | +| `max_threads_per_user` | 每用户最大并发线程数 | +| `max_target_per_task` | 每任务最大目标数 | + +## 认证系统 + +### 注册 / 登录 + +- 用户通过用户名和密码注册、登录 +- JWT Token 认证,支持 Cookie / `X-Auth-Token` Header / `Authorization: Bearer` 三种传递方式 +- **管理员规则**: + - 第一个注册的用户自动成为管理员(`Role=100, IsAdmin=true`) + - 也可通过环境变量 `ADMIN_USERNAME` 指定管理员用户名 + +### SSO 对接(可选) + +保留了 SSO 登录接口 `GET /api/auth/sso`,可用于与外部系统(如 New-API)集成。需配置 `SSO_SECRET` 环境变量。 + +## API 端点概览 + +### 公开端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/auth/register` | 用户注册 | +| POST | `/api/auth/login` | 用户登录 | +| GET | `/api/auth/sso` | SSO 登录(可选) | +| GET | `/api/auth/dev-login` | 开发模式登录 | + +### 用户端点(需登录) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/auth/me` | 当前用户信息 | +| GET | `/api/init` | 批量初始化加载 | +| POST | `/api/tasks` | 创建注册任务 | +| POST | `/api/tasks/:id/start` | 启动任务 | +| POST | `/api/tasks/:id/stop` | 停止任务 | +| GET | `/api/tasks/current` | 当前任务 | +| GET | `/api/results` | 注册结果列表 | +| GET | `/api/results/:taskId/export` | 导出结果 | +| GET | `/api/credits/balance` | 积分余额 | +| POST | `/api/credits/redeem` | 兑换码兑换 | +| GET/POST/PUT/DELETE | `/api/proxies` | 代理管理 | + +### 管理端点(需管理员) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/admin/users` | 用户列表 | +| POST | `/api/admin/credits/recharge` | 充值积分 | +| GET/POST | `/api/admin/settings` | 系统设置 | +| GET | `/api/admin/hf/overview` | HF Space 概览 | +| POST | `/api/admin/hf/spaces/deploy` | 批量部署 Space | +| POST | `/api/admin/hf/spaces/health` | 健康检查 | +| POST | `/api/admin/hf/autoscale` | 弹性管理 | +| POST | `/api/admin/hf/sync-cf` | 同步 CF Worker | + +### 实时端点 + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/ws/logs/:taskId/stream` | SSE 实时日志 | +| GET | `/ws/logs/:taskId` | WebSocket 日志 | + +## HF Space 架构 + +Worker 节点部署在 HuggingFace Space 上,通过 Cloudflare Worker 统一路由: + +``` +Go 后端 → POST → CF Worker + │ 路径前缀匹配 + ├── /openai/* → OPENAI_SPACES(HFNP 节点池) + ├── /grok/* → GROK_SPACES(HFGS 节点池) + ├── /kiro/* → KIRO_SPACES(HFKR 节点池) + └── /ts/* → TS_SPACES(HFTS 节点池) +``` + +### HF Space 管理功能 + +后台管理面板提供完整的节点生命周期管理: + +- **Token 管理**:添加/删除/验证 HF Token +- **Space 管理**:按服务类型和状态筛选,批量部署/删除 +- **自动发现**:扫描 Token 下的 Space 自动导入 +- **弹性管理**:自动扩缩容,健康检查,清理不可用节点 +- **CF 同步**:自动更新 Cloudflare Worker 环境变量 + +## CI/CD + +push 到 `main` 分支后 GitHub Actions 自动构建: + +| Job | 产物 | 分发方式 | +|-----|------|---------| +| `build-app` | Docker 镜像 | GHCR | +| `build-openai-worker` | `inference-runtime.zip` | GitHub Release | +| `build-grok-worker` | `stream-worker.zip` | GitHub Release | +| `build-kiro-reg` | `browser-agent.zip` | GitHub Release | +| `build-gemini-reg` | `gemini-agent.zip` | GitHub Release | +| `build-ts-solver` | `net-toolkit.zip` | GitHub Release | + +## 开源协议 + +MIT License diff --git a/cloudflare/hf-snow-worker.js b/cloudflare/hf-snow-worker.js new file mode 100644 index 0000000..34f5fbf --- /dev/null +++ b/cloudflare/hf-snow-worker.js @@ -0,0 +1,474 @@ +// hf-snow-worker.js — Edge load balancer + keepalive +// +// Deploy: Cloudflare Workers +// Cron Trigger: 0 */2 * * * +// +// Environment variables (space-separated HF Space URL lists): +// OPENAI_SPACES — OpenAI node pool +// GROK_SPACES — Grok node pool +// KIRO_SPACES — Kiro node pool +// GEMINI_SPACES — Gemini node pool +// TS_SPACES — Turnstile Solver node pool +// +// Routing: +// POST {url}/openai/process → match /openai/ prefix → strip → /api/v1/process +// POST {url}/grok/process → match /grok/ prefix → strip → /api/v1/process +// POST {url}/kiro/process → match /kiro/ prefix → strip → /api/v1/process +// POST {url}/gemini/process → match /gemini/ prefix → strip → /gemini/process +// POST {url}/ts/turnstile → match /ts/ prefix → strip → /turnstile +// +// 负载均衡策略: least-loaded + round-robin(同负载节点严格轮询,跨请求持久化) + +// 模块级 round-robin 计数器(同一 isolate 内跨请求持久化,最大化节点利用率) +const rrCounters = {}; + +// 模块级健康状态缓存(10s TTL,避免每次请求全量探测所有节点) +const statsCache = {}; +const STATS_CACHE_TTL = 10_000; // 10 秒 + +const CORS_HEADERS = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +}; + +// Route prefix → { envKey, rewrite(subpath) } +// rewrite: transform the subpath after stripping the prefix +const ROUTE_MAP = { + "/openai/": { envKey: "OPENAI_SPACES", rewrite: (sub) => "/api/v1" + sub }, + "/grok/": { envKey: "GROK_SPACES", rewrite: (sub) => "/api/v1" + sub }, + "/kiro/": { envKey: "KIRO_SPACES", rewrite: (sub) => "/kiro" + sub }, + "/gemini/": { envKey: "GEMINI_SPACES", rewrite: (sub) => "/gemini" + sub }, + "/ts/": { envKey: "TS_SPACES", rewrite: (sub) => sub }, +}; + +/** + * Match request path to a Space pool + * @param {string} pathname + * @returns {{ prefix: string, subpath: string } | null} + */ +function matchRoute(pathname) { + for (const [prefix, cfg] of Object.entries(ROUTE_MAP)) { + if (pathname.startsWith(prefix)) { + // Strip prefix, then apply rewrite + const stripped = "/" + pathname.slice(prefix.length); + const subpath = cfg.rewrite(stripped); + return { prefix, subpath }; + } + } + return null; +} + +/** + * Parse Space URL list from env + * @param {object} env + * @param {string} prefix + * @returns {string[]} + */ +function getPool(env, prefix) { + const cfg = ROUTE_MAP[prefix]; + if (!cfg) return []; + return (env[cfg.envKey] || "").split(/\s+/).filter(Boolean); +} + +/** + * Quick health probe (2s timeout) + * @param {string} space + * @returns {Promise} + */ +async function probeHealth(space) { + try { + const resp = await fetch(space + "/health", { + signal: AbortSignal.timeout(2000), + }); + return resp.ok; + } catch { + return false; + } +} + +/** + * Fetch node concurrency stats (3s timeout) + * Health response: {"status":"ok","concurrency":{"load","queue","cap","avg"}} + * @param {string} space + * @returns {Promise<{active:number,waiting:number,max_concurrent:number,avg_seconds:number,healthy:boolean}|null>} + */ +async function fetchNodeStats(space) { + try { + const resp = await fetch(space + "/health", { + signal: AbortSignal.timeout(3000), + }); + if (!resp.ok) return null; + const data = await resp.json(); + const q = data.concurrency || {}; + return { + active: q.load || 0, + waiting: q.queue || 0, + max_concurrent: q.cap || 5, + avg_seconds: q.avg || 0, + healthy: true, + }; + } catch { + return null; + } +} + +/** + * 带缓存的 fetchNodeStats(10s TTL,同一 isolate 内跨请求共享) + * 避免每次请求都全量探测所有节点,sleeping 节点的 3s 超时不再拖慢健康节点 + * @param {string} space + * @returns {Promise<{active:number,waiting:number,max_concurrent:number,avg_seconds:number,healthy:boolean}|null>} + */ +async function getCachedStats(space) { + const now = Date.now(); + const cached = statsCache[space]; + if (cached && now - cached.ts < STATS_CACHE_TTL) { + return cached.stats; + } + const stats = await fetchNodeStats(space); + statsCache[space] = { stats, ts: now }; + return stats; +} + +/** + * /api/v1/status?t=openai|grok|kiro|gemini — aggregate queue status across all nodes + * Returns: { total_active, total_waiting, max_concurrent, avg_seconds, healthy_nodes, total_nodes } + */ +async function handleQueueStatus(env, searchParams) { + const platform = (searchParams.get("t") || "").toLowerCase(); + let prefix; + if (platform === "openai") prefix = "/openai/"; + else if (platform === "grok") prefix = "/grok/"; + else if (platform === "kiro") prefix = "/kiro/"; + else if (platform === "gemini") prefix = "/gemini/"; + else { + return new Response( + JSON.stringify({ error: "missing parameter: t (openai/grok/kiro/gemini)" }), + { status: 400, headers: { "Content-Type": "application/json", ...CORS_HEADERS } } + ); + } + + const pool = getPool(env, prefix); + if (pool.length === 0) { + return new Response( + JSON.stringify({ total_active: 0, total_waiting: 0, max_concurrent: 0, avg_seconds: 0, healthy_nodes: 0, total_nodes: 0 }), + { headers: { "Content-Type": "application/json", ...CORS_HEADERS } } + ); + } + + const results = await Promise.all(pool.map(fetchNodeStats)); + let totalActive = 0, totalWaiting = 0, maxConcurrent = 0, avgSum = 0, avgCount = 0, healthyNodes = 0; + for (const r of results) { + if (r && r.healthy) { + healthyNodes++; + totalActive += r.active; + totalWaiting += r.waiting; + maxConcurrent += r.max_concurrent; + if (r.avg_seconds > 0) { + avgSum += r.avg_seconds; + avgCount++; + } + } + } + + return new Response(JSON.stringify({ + total_active: totalActive, + total_waiting: totalWaiting, + max_concurrent: maxConcurrent, + avg_seconds: avgCount > 0 ? Math.round(avgSum / avgCount) : 0, + healthy_nodes: healthyNodes, + total_nodes: pool.length, + }), { headers: { "Content-Type": "application/json", ...CORS_HEADERS } }); +} + +/** + * Space URL → 6 字符短哈希(djb2 算法,用 base36 编码) + * 用于 sticky routing 的节点标识,不依赖数组索引,加减节点不影响已有任务 + */ +function spaceHash(url) { + let h = 5381; + for (let i = 0; i < url.length; i++) { + h = ((h << 5) + h + url.charCodeAt(i)) >>> 0; + } + return h.toString(36).padStart(6, "0").slice(-6); +} + +/** + * TS sticky routing: 基于 Space URL 哈希的粘性路由 + * + * 设计要点: + * 1. submit 时在 taskId 前加 Space 哈希(如 "a1b2c3:uuid"),不用数组索引 + * 2. poll 时从 id 前缀提取哈希,在 pool 里找到对应 Space 直连 + * 3. 加减节点不影响已有任务(哈希固定绑 URL),用完即销(TS 内部有 TTL) + * 4. 多用户并发安全——每个 taskId 自带目标节点,不会混 + * + * 格式: "{spaceHash}:{originalTaskId}" + */ +async function handleTSRoute(pool, subpath, url, request, headers, bodyBuf) { + // 降级时需要用还原后的 search(去掉 hash 前缀),默认用原始 search + let fallbackSearch = url.search; + + // ── Poll: 从 id 参数提取 Space 哈希,直连对应节点 ── + if (subpath.startsWith("/result")) { + const id = url.searchParams.get("id"); + if (id) { + const sep = id.indexOf(":"); + if (sep > 0) { + const hash = id.substring(0, sep); + const realId = id.substring(sep + 1); + // 预先构建还原后的 search,fallback 时也用这个(TS Space 不认识 hash 前缀) + const cleanParams = new URLSearchParams(url.searchParams); + cleanParams.set("id", realId); + fallbackSearch = "?" + cleanParams.toString(); + + const target = pool.find((s) => spaceHash(s) === hash); + if (target) { + try { + return await forwardRequest(target, "/result", fallbackSearch, request.method, headers, bodyBuf); + } catch (e) { + console.error(`[ts-sticky] ${hash} failed: ${e.message}`); + // 目标节点不可达,降级到随机健康节点 + } + } + // 哈希匹配不到(节点已下线)或目标不可达,降级到随机健康节点 + console.log(`[ts-sticky] hash ${hash} not in pool or unreachable, fallback`); + } + } + } + + // ── 健康探测 + 随机选节点 ── + const probed = await Promise.all( + pool.map(async (space) => ({ space, hash: spaceHash(space), ok: await probeHealth(space) })) + ); + const up = probed.filter((p) => p.ok); + if (up.length === 0) { + return new Response( + JSON.stringify({ ok: false, error: "all TS nodes unavailable" }), + { status: 502, headers: { "Content-Type": "application/json", ...CORS_HEADERS } } + ); + } + // 随机打散,负载均衡 + for (let i = up.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [up[i], up[j]] = [up[j], up[i]]; + } + + // ── 尝试转发 ── + for (let k = 0; k < up.length; k++) { + const node = up[k]; + try { + const resp = await forwardRequest(node.space, subpath, fallbackSearch, request.method, headers, bodyBuf); + if (resp.status === 503 && k < up.length - 1) continue; + + // Submit 成功:在 taskId 前加 Space 哈希,下次 poll 可直连 + if (subpath.startsWith("/turnstile") && resp.ok) { + try { + const text = await resp.text(); + const body = JSON.parse(text); + if (body.taskId) { + body.taskId = node.hash + ":" + body.taskId; + } + const newHeaders = new Headers(resp.headers); + newHeaders.set("Content-Type", "application/json"); + for (const [ck, cv] of Object.entries(CORS_HEADERS)) { + newHeaders.set(ck, cv); + } + return new Response(JSON.stringify(body), { status: resp.status, headers: newHeaders }); + } catch { + // JSON 解析失败,原样返回 + } + } + + return resp; + } catch (e) { + console.error(`[ts-fail] ${node.space}: ${e.message}`); + continue; + } + } + + return new Response( + JSON.stringify({ ok: false, error: "all TS nodes failed" }), + { status: 502, headers: { "Content-Type": "application/json", ...CORS_HEADERS } } + ); +} + +/** + * Forward request to HF Space + */ +async function forwardRequest(space, subpath, search, method, headers, bodyBuf) { + const target = space + subpath + search; + const fwdHeaders = new Headers(); + fwdHeaders.set( + "Content-Type", + headers.get("Content-Type") || "application/json" + ); + fwdHeaders.set("Accept", headers.get("Accept") || "*/*"); + fwdHeaders.set( + "User-Agent", + headers.get("User-Agent") || "CF-Worker-Edge" + ); + + const resp = await fetch(target, { + method, + headers: fwdHeaders, + body: bodyBuf, + }); + + const newResp = new Response(resp.body, resp); + for (const [k, v] of Object.entries(CORS_HEADERS)) { + newResp.headers.set(k, v); + } + return newResp; +} + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const path = url.pathname; + + // CORS preflight + if (request.method === "OPTIONS") { + return new Response(null, { headers: CORS_HEADERS }); + } + + // Worker health check + if (path === "/health") { + return new Response("ok", { headers: CORS_HEADERS }); + } + + // Aggregated queue status (VPS heartbeat polling) + if (path === "/api/v1/status") { + return handleQueueStatus(env, url.searchParams); + } + + // Route matching + const route = matchRoute(path); + if (!route) { + return new Response( + JSON.stringify({ name: "edge-service", version: "1.0.0", status: "healthy" }), + { headers: { "Content-Type": "application/json", ...CORS_HEADERS } } + ); + } + + const pool = getPool(env, route.prefix); + if (pool.length === 0) { + return new Response( + JSON.stringify({ ok: false, error: "no nodes configured" }), + { + status: 503, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); + } + + const bodyBuf = + request.method !== "GET" && request.method !== "HEAD" + ? await request.arrayBuffer() + : null; + + // /ts/ 路由走专用 sticky handler(submit 打标 + poll 粘性路由) + if (route.prefix === "/ts/") { + return handleTSRoute(pool, route.subpath, url, request, request.headers, bodyBuf); + } + + // 其他平台(grok/openai/kiro/gemini):最小负载优先 + round-robin 路由 + // 查询每个节点的实时并发负载(10s 缓存),优先分给最闲的节点; + // 同负载节点用 round-robin 严格轮询(而非随机),确保请求均匀分散 + const nodeStats = await Promise.all( + pool.map(async (space) => { + const stats = await getCachedStats(space); + return { + space, + healthy: stats !== null, + load: stats ? stats.active + stats.waiting : Infinity, + }; + }) + ); + + const healthy = nodeStats + .filter((r) => r.healthy) + .sort((a, b) => a.load - b.load) // 负载最低的排前面 + .map((r) => r.space); + const unhealthy = nodeStats + .filter((r) => !r.healthy) + .map((r) => r.space); + + // 同负载节点用 round-robin 轮询(模块级计数器跨请求持久化) + // 比随机打散更均匀:100 个请求 / 18 节点 → 每节点稳定 5-6 个 + const loadMap = new Map(nodeStats.map((n) => [n.space, n.load])); + if (!rrCounters[route.prefix]) rrCounters[route.prefix] = 0; + if (healthy.length > 0) { + const minLoad = loadMap.get(healthy[0]); + // 找出所有最小负载节点 + const minGroup = healthy.filter((s) => loadMap.get(s) === minLoad); + if (minGroup.length > 1) { + // round-robin 选一个最小负载节点放到最前面 + const pick = rrCounters[route.prefix]++ % minGroup.length; + const chosen = minGroup[pick]; + // 把选中的节点移到 healthy 数组最前面 + const idx = healthy.indexOf(chosen); + if (idx > 0) { + healthy.splice(idx, 1); + healthy.unshift(chosen); + } + } + } + + const tryOrder = [...healthy, ...unhealthy]; + + // Try each node + for (const space of tryOrder) { + try { + const resp = await forwardRequest( + space, + route.subpath, + url.search, + request.method, + request.headers, + bodyBuf + ); + + // 503 = node busy, try next + if (resp.status === 503 && tryOrder.indexOf(space) < tryOrder.length - 1) { + console.log(`[skip] ${space}: 503, trying next`); + continue; + } + + return resp; + } catch (e) { + console.error(`[fail] ${space}: ${e.message}`); + continue; + } + } + + // All nodes failed + return new Response( + JSON.stringify({ ok: false, error: "all nodes unavailable" }), + { + status: 502, + headers: { "Content-Type": "application/json", ...CORS_HEADERS }, + } + ); + }, + + // Keepalive: ping all pools every 2 hours to prevent HF Space hibernation + async scheduled(event, env) { + const allSpaces = Object.values(ROUTE_MAP).flatMap((cfg) => + (env[cfg.envKey] || "").split(/\s+/).filter(Boolean) + ); + const unique = [...new Set(allSpaces)]; + + await Promise.all( + unique.map(async (space) => { + try { + const resp = await fetch(space + "/health", { + signal: AbortSignal.timeout(5000), + }); + console.log(`[keepalive] ${space}: ${resp.status}`); + } catch (err) { + console.error(`[keepalive] ${space}: ${err.message}`); + } + }) + ); + }, +}; diff --git a/cloudflare/wrangler.toml b/cloudflare/wrangler.toml new file mode 100644 index 0000000..899a3b6 --- /dev/null +++ b/cloudflare/wrangler.toml @@ -0,0 +1,6 @@ +name = "hf-snow-c4ec" +main = "hf-snow-worker.js" +compatibility_date = "2024-01-01" + +[triggers] +crons = ["0 */2 * * *"] diff --git a/cmd/grpctest/get_turnstile_token.py b/cmd/grpctest/get_turnstile_token.py new file mode 100644 index 0000000..a3e8246 --- /dev/null +++ b/cmd/grpctest/get_turnstile_token.py @@ -0,0 +1,160 @@ +""" +用 Camoufox 提取 accounts.x.ai 的 Turnstile token +策略:在 accounts.x.ai 页面上直接注入 Turnstile widget 获取 token +""" +import asyncio +import sys +import time + +from camoufox.async_api import AsyncCamoufox + + +async def get_turnstile_token(timeout=45): + print(f"[*] 启动 Camoufox (headless)...", flush=True) + start = time.time() + + async with AsyncCamoufox(headless=True) as browser: + page = await browser.new_page() + + print("[*] 加载 accounts.x.ai ...", flush=True) + try: + await page.goto("https://accounts.x.ai/sign-up", wait_until="domcontentloaded", timeout=30000) + print(f"[+] 页面加载完成 ({time.time()-start:.1f}s)", flush=True) + except Exception as e: + print(f"[!] 页面加载异常: {e}", flush=True) + + # 策略 1: 点击 "Sign up with email",让 React 组件加载 Turnstile + print("[*] 策略1: 点击 'Sign up with email'...", flush=True) + try: + btn = await page.query_selector('text=Sign up with email') + if btn: + await btn.click() + print("[+] 点击成功", flush=True) + await asyncio.sleep(2) + else: + print("[-] 未找到 'Sign up with email' 按钮", flush=True) + except Exception as e: + print(f"[-] 点击失败: {e}", flush=True) + + # 截图看看当前状态 + await page.screenshot(path="/tmp/turnstile_debug2.png") + print("[*] 截图已保存: /tmp/turnstile_debug2.png", flush=True) + + # 策略 2: 直接在页面注入 Turnstile widget + print("[*] 策略2: 注入 Turnstile widget...", flush=True) + await page.evaluate("""() => { + window.__capturedToken = null; + + // 创建容器 + const container = document.createElement('div'); + container.id = 'injected-turnstile'; + container.style.position = 'fixed'; + container.style.bottom = '0'; + container.style.right = '0'; + container.style.zIndex = '99999'; + document.body.appendChild(container); + + // 加载 Turnstile JS + const script = document.createElement('script'); + script.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=__onTurnstileReady'; + script.async = true; + + window.__onTurnstileReady = function() { + console.log('[Inject] Turnstile JS loaded, rendering widget...'); + try { + turnstile.render('#injected-turnstile', { + sitekey: '0x4AAAAAAAhr9JGVDZbrZOo0', + callback: function(token) { + console.log('[Inject] Token received!'); + window.__capturedToken = token; + }, + 'error-callback': function(err) { + console.log('[Inject] Turnstile error: ' + err); + window.__turnstileError = err; + }, + 'expired-callback': function() { + console.log('[Inject] Token expired'); + }, + theme: 'light', + size: 'invisible', + 'response-field': false, + }); + console.log('[Inject] Widget rendered'); + } catch(e) { + console.log('[Inject] Render error: ' + e); + window.__turnstileError = e.toString(); + } + }; + + document.head.appendChild(script); + }""") + + # 同时也监控页面上已有的 Turnstile(如果有的话) + # 以及监控任何 iframe 中的 Turnstile response + print("[*] 等待 Turnstile token...", flush=True) + for i in range(timeout): + # 检查注入的 token + token = await page.evaluate("() => window.__capturedToken") + if token: + elapsed = time.time() - start + print(f"[+] Token 获取成功 (注入方式)! ({elapsed:.1f}s)", flush=True) + print(f"[+] Token 长度: {len(token)}", flush=True) + print(f"TOKEN:{token}", flush=True) + return token + + # 检查 hidden input + token2 = await page.evaluate("""() => { + const inputs = document.querySelectorAll('[name="cf-turnstile-response"]'); + for (const input of inputs) { + if (input.value) return input.value; + } + return null; + }""") + if token2: + elapsed = time.time() - start + print(f"[+] Token 获取成功 (hidden input)! ({elapsed:.1f}s)", flush=True) + print(f"TOKEN:{token2}", flush=True) + return token2 + + # 检查错误 + error = await page.evaluate("() => window.__turnstileError") + if error and i > 5: + print(f"[-] Turnstile 错误: {error}", flush=True) + break + + if i % 5 == 0 and i > 0: + print(f"[*] 等待中... ({i}s)", flush=True) + # 检查 Turnstile iframe + frames = page.frames + for f in frames: + if 'challenges.cloudflare.com' in f.url: + print(f" 发现 Turnstile iframe: {f.url[:80]}", flush=True) + + await asyncio.sleep(1) + + # 超时 + print(f"[-] Token 获取超时 ({timeout}s)", flush=True) + await page.screenshot(path="/tmp/turnstile_debug3.png") + print(" 截图已保存: /tmp/turnstile_debug3.png", flush=True) + + # 打印 Turnstile 相关元素 + info = await page.evaluate("""() => { + const result = {}; + result.turnstileExists = typeof window.turnstile !== 'undefined'; + result.iframes = Array.from(document.querySelectorAll('iframe')).map(f => f.src); + result.inputs = Array.from(document.querySelectorAll('input[type=hidden]')).map(i => ({name: i.name, hasValue: !!i.value})); + result.injectedDiv = !!document.getElementById('injected-turnstile'); + result.injectedDivHTML = document.getElementById('injected-turnstile')?.innerHTML?.substring(0, 200); + return result; + }""") + print(f" 调试信息: {info}", flush=True) + + return None + + +if __name__ == "__main__": + token = asyncio.run(get_turnstile_token(timeout=40)) + if token: + sys.exit(0) + else: + sys.exit(1) diff --git a/cmd/grpctest/main.go b/cmd/grpctest/main.go new file mode 100644 index 0000000..40c198e --- /dev/null +++ b/cmd/grpctest/main.go @@ -0,0 +1,243 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "os" + "strings" + "time" + + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/gptmail" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/grpcweb" +) + +// 独立测试脚本:验证 gRPC-web 注册流程是否正确 +func main() { + // 配置(从环境变量或硬编码) + gptmailURL := envOrDefault("GPTMAIL_URL", "https://mail.chatgpt.org.uk") + gptmailKey := os.Getenv("GPTMAIL_KEY") + + fmt.Println("=== gRPC-web 注册流程测试 ===") + fmt.Printf("gptmail URL: %s\n", gptmailURL) + + // 创建 HTTP 客户端 + jar, _ := cookiejar.New(nil) + client := &http.Client{ + Jar: jar, + Timeout: 30 * time.Second, + } + ua := "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + + // Phase 0: 会话预热 + fmt.Println("\n[Phase 0] 会话预热...") + warmup, _ := http.NewRequest("GET", "https://accounts.x.ai", nil) + warmup.Header.Set("User-Agent", ua) + if resp, err := client.Do(warmup); err == nil { + fmt.Printf(" 预热响应: %d\n", resp.StatusCode) + resp.Body.Close() + } else { + fmt.Printf(" 预热失败: %s\n", err) + } + + // Phase 1: 创建邮箱 + fmt.Println("\n[Phase 1] 创建临时邮箱...") + mailClient := gptmail.NewClient(gptmailURL, gptmailKey) + email, err := mailClient.GenerateEmail() + if err != nil { + fmt.Printf(" 创建邮箱失败: %s\n", err) + os.Exit(1) + } + fmt.Printf(" 邮箱: %s\n", email) + defer func() { go mailClient.DeleteEmail(email) }() + + password := "Test12345abcde" + firstName := "Testfn" + lastName := "Testln" + + // Phase 2: 发送验证码 + fmt.Println("\n[Phase 2] 发送验证码 (gRPC-web)...") + codeBody := grpcweb.EncodeEmailCode(email) + fmt.Printf(" 请求体长度: %d bytes\n", len(codeBody)) + fmt.Printf(" 请求体 hex: %x\n", codeBody) + + sendResp, err := doGRPCWeb(client, ua, + "https://accounts.x.ai/auth_mgmt.AuthManagement/CreateEmailValidationCode", + codeBody) + if err != nil { + fmt.Printf(" 发送失败: %s\n", err) + os.Exit(1) + } + sendBody, _ := io.ReadAll(sendResp.Body) + sendResp.Body.Close() + fmt.Printf(" 响应: HTTP %d, body_len=%d\n", sendResp.StatusCode, len(sendBody)) + fmt.Printf(" grpc-status: %s\n", sendResp.Header.Get("Grpc-Status")) + fmt.Printf(" grpc-message: %s\n", sendResp.Header.Get("Grpc-Message")) + if sendResp.StatusCode != 200 { + fmt.Printf(" 响应体: %s\n", string(sendBody)) + os.Exit(1) + } + fmt.Println(" 验证码发送成功!") + + // Phase 3: 获取验证码 + fmt.Println("\n[Phase 3] 等待验证码...") + code, err := mailClient.FetchVerificationCode(email, 30, 1*time.Second) + if err != nil { + fmt.Printf(" 获取验证码失败: %s\n", err) + os.Exit(1) + } + fmt.Printf(" 验证码: %s\n", code) + + // Phase 4: 验证邮箱 + fmt.Println("\n[Phase 4] 验证邮箱 (gRPC-web)...") + verifyBody := grpcweb.EncodeVerifyCode(email, code) + verifyResp, err := doGRPCWeb(client, ua, + "https://accounts.x.ai/auth_mgmt.AuthManagement/VerifyEmailValidationCode", + verifyBody) + if err != nil { + fmt.Printf(" 验证失败: %s\n", err) + os.Exit(1) + } + verifyRespBody, _ := io.ReadAll(verifyResp.Body) + verifyResp.Body.Close() + fmt.Printf(" 响应: HTTP %d, body_len=%d\n", verifyResp.StatusCode, len(verifyRespBody)) + fmt.Printf(" grpc-status: %s\n", verifyResp.Header.Get("Grpc-Status")) + if verifyResp.StatusCode != 200 { + fmt.Printf(" 响应体: %s\n", string(verifyRespBody)) + os.Exit(1) + } + fmt.Println(" 邮箱验证成功!") + + // Phase 5: 注册 (gRPC-web) + fmt.Println("\n[Phase 5] 注册账号 (gRPC-web)...") + + // Pass 1: 无 Turnstile token + regBody := grpcweb.EncodeCreateUserAndSession(email, firstName, lastName, password, code, "") + fmt.Printf(" 请求体长度: %d bytes (无 turnstile token)\n", len(regBody)) + + for _, endpoint := range []string{ + "https://accounts.x.ai/auth_mgmt.AuthManagement/CreateUserAndSessionV2", + "https://accounts.x.ai/auth_mgmt.AuthManagement/CreateUserAndSession", + } { + isV2 := strings.Contains(endpoint, "V2") + fmt.Printf("\n 尝试 %s (V2=%v)...\n", endpoint[strings.LastIndex(endpoint, "/")+1:], isV2) + + regResp, err := doGRPCWeb(client, ua, endpoint, regBody) + if err != nil { + fmt.Printf(" 请求失败: %s\n", err) + continue + } + respBody, _ := io.ReadAll(regResp.Body) + regResp.Body.Close() + + fmt.Printf(" 响应: HTTP %d, body_len=%d\n", regResp.StatusCode, len(respBody)) + fmt.Printf(" grpc-status: %s\n", regResp.Header.Get("Grpc-Status")) + fmt.Printf(" grpc-message: %s\n", regResp.Header.Get("Grpc-Message")) + fmt.Printf(" 响应体 hex: %x\n", respBody) + + // 打印所有 response headers + fmt.Println(" --- Response Headers ---") + for k, v := range regResp.Header { + fmt.Printf(" %s: %s\n", k, strings.Join(v, ", ")) + } + + // 打印 Set-Cookie + for _, c := range regResp.Cookies() { + fmt.Printf(" Set-Cookie: %s=%s\n", c.Name, c.Value[:min(30, len(c.Value))]) + } + + grpcStatus := regResp.Header.Get("Grpc-Status") + if regResp.StatusCode != 200 || (grpcStatus != "" && grpcStatus != "0") { + fmt.Printf(" 该端点注册失败\n") + continue + } + + // 尝试解析 SSO token + var cookie string + if isV2 { + cookie, err = grpcweb.DecodeSessionCookie(respBody) + } else { + cookie, err = grpcweb.DecodeSessionCookieV1(respBody) + } + if err != nil { + fmt.Printf(" 解析 protobuf 响应失败: %s\n", err) + // 检查 HTTP cookies + for _, c := range regResp.Cookies() { + if c.Name == "sso" { + cookie = c.Value + fmt.Printf(" 从 Set-Cookie 获取到 sso token\n") + break + } + } + } + + if cookie != "" { + fmt.Printf("\n === 注册成功! ===\n") + fmt.Printf(" SSO Token: %s...\n", cookie[:min(30, len(cookie))]) + fmt.Printf(" Token 长度: %d\n", len(cookie)) + + // Phase 6: TOS + fmt.Println("\n[Phase 6] 接受 TOS...") + tosBody := grpcweb.EncodeTosAccepted() + tosResp, err := doGRPCWebWithCookies(client, ua, + "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion", + "https://accounts.x.ai", "https://accounts.x.ai/accept-tos", + tosBody, map[string]string{"sso": cookie, "sso-rw": cookie}) + if err != nil { + fmt.Printf(" TOS 失败: %s\n", err) + } else { + tosRespBody, _ := io.ReadAll(tosResp.Body) + tosResp.Body.Close() + fmt.Printf(" TOS 响应: HTTP %d, grpc-status=%s, body_len=%d\n", + tosResp.StatusCode, tosResp.Header.Get("Grpc-Status"), len(tosRespBody)) + } + + fmt.Printf("\n=== 测试完成 ===\n") + fmt.Printf("Email: %s\n", email) + fmt.Printf("Password: %s\n", password) + fmt.Printf("SSO Token: %s\n", cookie) + os.Exit(0) + } + } + + fmt.Println("\n[-] 所有端点均失败") + os.Exit(1) +} + +func doGRPCWeb(client *http.Client, ua, endpoint string, body []byte) (*http.Response, error) { + return doGRPCWebWithCookies(client, ua, endpoint, + "https://accounts.x.ai", "https://accounts.x.ai/sign-up?redirect=grok-com", + body, nil) +} + +func doGRPCWebWithCookies(client *http.Client, ua, endpoint, origin, referer string, body []byte, cookies map[string]string) (*http.Response, error) { + req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/grpc-web+proto") + req.Header.Set("X-Grpc-Web", "1") + req.Header.Set("X-User-Agent", "connect-es/2.1.1") + req.Header.Set("Origin", origin) + req.Header.Set("Referer", referer) + req.Header.Set("User-Agent", ua) + + if len(cookies) > 0 { + var parts []string + for k, v := range cookies { + parts = append(parts, fmt.Sprintf("%s=%s", k, v)) + } + req.Header.Set("Cookie", strings.Join(parts, "; ")) + } + + return client.Do(req) +} + +func envOrDefault(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..a4780f3 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,100 @@ +# 生产环境 docker-compose(使用 GitHub Actions 预构建镜像) +# 服务器部署:docker compose -f docker-compose.prod.yml pull && docker compose -f docker-compose.prod.yml up -d + +version: '3.8' + +services: + db: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: regplatform + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${DB_PASSWORD:-postgres} + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + app: + image: ghcr.io/xiaolajiaoyyds/regplatformm:latest + restart: unless-stopped + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - xray-config:/etc/xray + environment: + DATABASE_URL: postgres://postgres:${DB_PASSWORD:-postgres}@db:5432/regplatform?sslmode=disable + GIN_MODE: release + DEV_MODE: "false" + TURNSTILE_SOLVER_URL: http://turnstile-solver:5072 + CF_BYPASS_SOLVER_URL: http://cf-bypass-solver:5073 + CAMOUFOX_REG_URL: "" + XRAY_CONFIG_DIR: /etc/xray + XRAY_CONTAINER_NAME: xray + DOCKER_SOCKET: /var/run/docker.sock + env_file: + - .env + + xray: + image: teddysun/xray:latest + container_name: xray + restart: unless-stopped + network_mode: host + volumes: + - xray-config:/etc/xray:ro + + turnstile-solver: + image: ghcr.io/xiaolajiaoyyds/regplatformm-turnstile-solver:latest + restart: unless-stopped + ports: + - "5072:5072" + environment: + SOLVER_BROWSER_TYPE: camoufox + SOLVER_THREADS: "2" + SOLVER_PORT: "5072" + + cf-bypass-solver: + image: ghcr.io/xiaolajiaoyyds/regplatformm-cf-bypass-solver:latest + restart: unless-stopped + ports: + - "5073:5073" + + openai-reg: + image: ghcr.io/xiaolajiaoyyds/regplatformm-openai-reg:latest + restart: unless-stopped + ports: + - "5075:5075" + environment: + OPENAI_REG_PORT: "5075" + GPTMAIL_BASE_URL: "https://mail.chatgpt.org.uk" + + aws-builder-id-reg: + image: ghcr.io/xiaolajiaoyyds/regplatformm-aws-builder-id-reg:latest + restart: unless-stopped + ports: + - "5076:5076" + environment: + KIRO_REG_PORT: "5076" + shm_size: '2gb' + + camoufox-reg: + image: ghcr.io/xiaolajiaoyyds/regplatformm-camoufox-reg:latest + restart: unless-stopped + ports: + - "5074:5074" + profiles: + - browser-mode + +volumes: + pgdata: + xray-config: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ff2514f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,106 @@ +version: '3.8' + +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: regplatform + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + app: + build: . + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - xray-config:/etc/xray + environment: + DATABASE_URL: postgres://postgres:postgres@db:5432/regplatform?sslmode=disable + GIN_MODE: release + DEV_MODE: "true" + TURNSTILE_SOLVER_URL: http://turnstile-solver:5072 + CF_BYPASS_SOLVER_URL: http://cf-bypass-solver:5073 + CAMOUFOX_REG_URL: "" + XRAY_CONFIG_DIR: /etc/xray + XRAY_CONTAINER_NAME: xray + DOCKER_SOCKET: /var/run/docker.sock + env_file: + - .env + + # Xray 隧道桥接 — 将 vless/vmess/trojan/ss/hy2 转为本地 socks5 + xray: + image: teddysun/xray:latest + container_name: xray + restart: unless-stopped + network_mode: host + volumes: + - xray-config:/etc/xray:ro + + # Turnstile Solver — Camoufox 模式 + turnstile-solver: + build: + context: ./services/turnstile-solver + dockerfile: Dockerfile + ports: + - "5072:5072" + environment: + SOLVER_BROWSER_TYPE: camoufox + SOLVER_THREADS: "2" + SOLVER_PORT: "5072" + + # CF-Bypass Solver — SeleniumBase UC Mode(备用) + cf-bypass-solver: + build: + context: ./services/cf-bypass-solver + dockerfile: Dockerfile + ports: + - "5073:5073" + + # OpenAI 浏览器注册服务(SeleniumBase UC Mode) + openai-reg: + build: + context: ./services/openai-reg + dockerfile: Dockerfile + ports: + - "5075:5075" + environment: + OPENAI_REG_PORT: "5075" + GPTMAIL_BASE_URL: "https://mail.chatgpt.org.uk" + + # Camoufox 浏览器注册服务(按需启用,默认不启动) + camoufox-reg: + build: + context: ./services/camoufox-reg + dockerfile: Dockerfile + ports: + - "5074:5074" + profiles: + - browser-mode + + # AWS Builder ID (Kiro) 浏览器注册服务 + aws-builder-id-reg: + build: + context: ./services/aws-builder-id-reg + dockerfile: Dockerfile + ports: + - "5076:5076" + environment: + KIRO_REG_PORT: "5076" + shm_size: '2gb' + +volumes: + pgdata: + xray-config: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..9e47dc0 --- /dev/null +++ b/go.mod @@ -0,0 +1,73 @@ +module github.com/xiaolajiaoyyds/regplatformm + +go 1.25.6 + +require ( + github.com/gin-contrib/cors v1.7.6 + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.18.0 + github.com/rs/zerolog v1.34.0 + github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.46.0 + golang.org/x/net v0.48.0 + golang.org/x/sync v0.19.0 + gorm.io/datatypes v1.2.7 + gorm.io/driver/postgres v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/rogpeppe/go-internal v1.10.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/mock v0.5.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/sys v0.39.0 // indirect + golang.org/x/text v0.32.0 // indirect + golang.org/x/tools v0.39.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gorm.io/driver/mysql v1.5.6 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..70452e8 --- /dev/null +++ b/go.sum @@ -0,0 +1,190 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= +github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= +github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= +github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= +golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/datatypes v1.2.7 h1:ww9GAhF1aGXZY3EB3cJPJ7//JiuQo7DlQA7NNlVaTdk= +gorm.io/datatypes v1.2.7/go.mod h1:M2iO+6S3hhi4nAyYe444Pcb0dcIiOMJ7QHaUXxyiNZY= +gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= +gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= +gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/driver/sqlserver v1.6.0 h1:VZOBQVsVhkHU/NzNhRJKoANt5pZGQAS1Bwc6m6dgfnc= +gorm.io/driver/sqlserver v1.6.0/go.mod h1:WQzt4IJo/WHKnckU9jXBLMJIVNMVeTu25dnOzehntWw= +gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/hftpl.go b/hftpl.go new file mode 100644 index 0000000..04fa7b7 --- /dev/null +++ b/hftpl.go @@ -0,0 +1,14 @@ +// Package regplatform 提供 HF Space 模板文件的编译期嵌入。 +// 模板文件在 DeploySpaces 时读取,无需运行时文件系统依赖。 +package regplatform + +import "embed" + +// HFTemplateFS 嵌入 5 个服务的 Dockerfile 和入口脚本 +// +//go:embed HFNP/Dockerfile HFNP/init.sh +//go:embed HFGS/Dockerfile HFGS/bootstrap.sh +//go:embed HFKR/Dockerfile HFKR/start.sh +//go:embed HFGM/Dockerfile HFGM/launch.sh +//go:embed HFTS/Dockerfile HFTS/run.sh +var HFTemplateFS embed.FS diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..790fd90 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,65 @@ +package config + +import ( + "log" + + "github.com/spf13/viper" +) + +// Config 基础设施配置(仅含启动必须项) +// 应用级配置(API Key、代理、验证码等)统一在管理后台系统设置中配置,持久化到数据库 +type Config struct { + // 服务器 + Port int `mapstructure:"PORT"` + GinMode string `mapstructure:"GIN_MODE"` + + // 数据库 + DatabaseURL string `mapstructure:"DATABASE_URL"` + + // JWT + JWTSecret string `mapstructure:"JWT_SECRET"` + JWTExpireHours int `mapstructure:"JWT_EXPIRE_HOURS"` + + // SSO(可选,用于外部系统对接) + SSOSecret string `mapstructure:"SSO_SECRET"` + + // Redis(可选,为空则纯内存缓存) + RedisURL string `mapstructure:"REDIS_URL"` + + // 开发模式 + DevMode bool `mapstructure:"DEV_MODE"` + + // 管理员用户名(可选,该用户注册时自动成为管理员;为空则第一个注册的用户为管理员) + AdminUsername string `mapstructure:"ADMIN_USERNAME"` +} + +// Load 加载配置:环境变量 > .env 文件 > 默认值 +func Load() *Config { + viper.SetConfigFile(".env") + viper.AutomaticEnv() + + // 默认值 + viper.SetDefault("PORT", 8000) + viper.SetDefault("GIN_MODE", "release") + viper.SetDefault("DATABASE_URL", "postgres://localhost:5432/regplatform?sslmode=disable") + viper.SetDefault("JWT_SECRET", "change-me-in-production") + viper.SetDefault("JWT_EXPIRE_HOURS", 72) + viper.SetDefault("SSO_SECRET", "") + viper.SetDefault("REDIS_URL", "") + viper.SetDefault("DEV_MODE", false) + viper.SetDefault("ADMIN_USERNAME", "") + + _ = viper.ReadInConfig() // .env 不存在不报错 + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + log.Fatalf("配置解析失败: %v", err) + } + + // 生产模式强制检查 JWT 密钥强度 + if cfg.GinMode == "release" && (cfg.JWTSecret == "change-me-in-production" || len(cfg.JWTSecret) < 32) { + log.Fatalf("生产环境 JWT_SECRET 必须设置为 32 字符以上的强密钥") + } + + return &cfg +} diff --git a/internal/dto/dto.go b/internal/dto/dto.go new file mode 100644 index 0000000..6e990a7 --- /dev/null +++ b/internal/dto/dto.go @@ -0,0 +1,195 @@ +package dto + +// ── Auth ── + +// UserInfo 用户信息响应 +type UserInfo struct { + ID uint `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + AvatarURL string `json:"avatar_url"` + TrustLevel int `json:"trust_level"` + Role int `json:"role"` + Credits int `json:"credits"` + FreeTrialUsed bool `json:"free_trial_used"` + FreeTrialRemaining int `json:"free_trial_remaining"` + IsAdmin bool `json:"is_admin"` +} + +// ── Tasks ── + +// CreateTaskReq 创建任务请求 +type CreateTaskReq struct { + Platform string `json:"platform" binding:"required,oneof=grok openai kiro gemini"` + Target int `json:"target" binding:"required,min=1"` + Threads int `json:"threads"` // 可选,0 或不传时后端自动计算 + Mode string `json:"mode"` // "free" 使用免费额度(受限制),"paid" 付费模式(无限制),空则自动判断 + ProxyID uint `json:"proxy_id"` // 0=使用系统默认代理,>0=使用用户保存的代理 +} + +// TaskStatus 任务状态响应 +type TaskStatus struct { + TaskID uint `json:"task_id"` + Platform string `json:"platform"` + Target int `json:"target"` + Threads int `json:"threads"` + CreditsReserved int `json:"credits_reserved"` + SuccessCount int `json:"success_count"` + FailCount int `json:"fail_count"` + Status string `json:"status"` + IsDone bool `json:"is_done"` + QueuePosition int `json:"queue_position,omitempty"` + QueueWaitSec int `json:"queue_wait_sec,omitempty"` +} + +// ── Credits ── + +// BalanceResp 余额响应 +type BalanceResp struct { + Credits int `json:"credits"` + FreeTrialRemaining int `json:"free_trial_remaining"` + FreeTrialUsed bool `json:"free_trial_used"` + CostPerReg int `json:"cost_per_reg"` + Mode string `json:"mode"` // "newapi" 或 "local" + Display string `json:"display"` // 余额显示文字 + RegistrationsAvailable int `json:"registrations_available"` // 可用注册次数 + NewapiBalance float64 `json:"newapi_balance"` // New-API 余额(USD) + NewapiBalanceDisplay string `json:"newapi_balance_display"` // "$1.23" + UnitPrice float64 `json:"unit_price"` // 单次注册价格(USD) + UnitPriceDisplay string `json:"unit_price_display"` // "$0.004" + NewapiAvailable int `json:"newapi_available"` // New-API 余额可购买次数 + PlatformPrices map[string]float64 `json:"platform_prices"` // 各平台单价(USD) + FreeTrial *FreeTrialResp `json:"free_trial,omitempty"` + Limits *LimitsResp `json:"limits,omitempty"` +} + +// FreeTrialResp 免费试用信息 +type FreeTrialResp struct { + Eligible bool `json:"eligible"` // 是否有资格领取 + Remaining int `json:"remaining"` // 剩余次数 + Total int `json:"total"` // 可领取总次数 +} + +// LimitsResp 系统限制(从管理后台设置读取) +type LimitsResp struct { + MaxTarget int `json:"max_target"` + MaxThreads int `json:"max_threads"` + DailyRegLimit int `json:"daily_reg_limit"` // 0 = 不限制 + DailyUsed int `json:"daily_used"` // 今日已注册数量 + DailyRemaining int `json:"daily_remaining"` // 今日剩余(-1 = 不限) + // 平台开关 + PlatformGrokEnabled bool `json:"platform_grok_enabled"` + PlatformOpenaiEnabled bool `json:"platform_openai_enabled"` + PlatformKiroEnabled bool `json:"platform_kiro_enabled"` + PlatformGeminiEnabled bool `json:"platform_gemini_enabled"` + // 线程阶梯(JSON 字符串,前端解析) + ThreadTiers string `json:"thread_tiers"` + // 平台限时免费截止日期(YYYY-MM-DD,空=不免费) + PlatformGrokFreeUntil string `json:"platform_grok_free_until"` + PlatformOpenaiFreeUntil string `json:"platform_openai_free_until"` + PlatformKiroFreeUntil string `json:"platform_kiro_free_until"` + PlatformGeminiFreeUntil string `json:"platform_gemini_free_until"` + // 免费模式状态(仅在平台处于免费期时有值) + FreeMode map[string]*FreeModeInfo `json:"free_mode,omitempty"` + // 各平台独立限制(付费/免费均生效) + PlatformLimits map[string]*PlatformLimitInfo `json:"platform_limits,omitempty"` +} + +// PlatformLimitInfo 单个平台的独立限制(付费/免费均生效) +type PlatformLimitInfo struct { + TaskLimit int `json:"task_limit"` // 单任务上限(0=不限,回退全局 max_target) + DailyLimit int `json:"daily_limit"` // 每日上限(0=不限) + DailyUsed int `json:"daily_used"` // 今日该平台已注册 + DailyRemaining int `json:"daily_remaining"` // 今日剩余(-1=不限) +} + +// FreeModeInfo 单个平台的免费模式状态 +type FreeModeInfo struct { + Available bool `json:"available"` // 当前是否可以使用免费模式 + DailyUsed int `json:"daily_used"` // 今日免费已用数量 + DailyLimit int `json:"daily_limit"` // 免费每日上限(0=不限) + DailyRemaining int `json:"daily_remaining"` // 免费每日剩余(-1=不限) + TaskLimit int `json:"task_limit"` // 免费单任务上限 + CooldownSec int `json:"cooldown_sec"` // 冷却配置(秒) + CooldownRemaining int `json:"cooldown_remaining"` // 冷却剩余秒数(0=可注册) + Reason string `json:"reason,omitempty"` // 不可用原因(供前端提示) +} + +// PurchaseReq 购买注册次数请求 +type PurchaseReq struct { + Amount int `json:"amount" binding:"required,min=1,max=10000"` + Platform string `json:"platform"` // 可选,用于按平台定价 +} + +// RedeemReq 兑换请求 +type RedeemReq struct { + Code string `json:"code" binding:"required"` +} + +// ── Admin ── + +// RechargeReq 管理员充值/扣除请求(正数充值,负数扣除) +type RechargeReq struct { + UserID uint `json:"user_id" binding:"required"` + Credits int `json:"credits" binding:"required,ne=0"` +} + +// GenerateCodesReq 生成兑换码请求 +type GenerateCodesReq struct { + Count int `json:"count" binding:"required,min=1,max=100"` + Credits int `json:"credits" binding:"required,min=1"` + BatchName string `json:"batch_name"` +} + +// SaveSettingReq 保存设置请求 +type SaveSettingReq struct { + Key string `json:"key" binding:"required"` + Value string `json:"value"` +} + +// ── Notifications ── + +// SendNotificationReq 发送通知请求 +type SendNotificationReq struct { + UserID uint `json:"user_id"` // 0 = 广播 + Title string `json:"title" binding:"required,max=200"` + Content string `json:"content" binding:"required,max=5000"` +} + +// ── HF Space ── + +// CreateHFTokenReq 创建 HF Token 请求 +type CreateHFTokenReq struct { + Label string `json:"label" binding:"required,max=100"` + Token string `json:"token" binding:"required,max=200"` +} + +// AddHFSpaceManualReq 手动添加 Space 请求 +type AddHFSpaceManualReq struct { + Service string `json:"service" binding:"required,oneof=openai grok kiro gemini ts"` + URL string `json:"url" binding:"required,url"` + RepoID string `json:"repo_id" binding:"required,max=200"` + TokenID uint `json:"token_id"` +} + +// DeployHFSpaceReq 批量部署 Space 请求 +type DeployHFSpaceReq struct { + Service string `json:"service" binding:"required,oneof=openai grok kiro gemini ts"` + Count int `json:"count" binding:"required,min=1,max=50"` + ReleaseURL string `json:"release_url" binding:"required"` + TokenID uint `json:"token_id"` // 指定 Token(0 = 轮询所有可用 Token) + Secrets map[string]string `json:"secrets"` // 额外 Secrets(可选) +} + +// UpdateHFSpacesReq 批量更新已有 Space 请求 +type UpdateHFSpacesReq struct { + Service string `json:"service" binding:"required,oneof=openai grok kiro gemini ts all"` +} + +// HFAutoscaleReq 弹性管理请求 +type HFAutoscaleReq struct { + Service string `json:"service" binding:"required,oneof=openai grok kiro gemini ts all"` + Target int `json:"target" binding:"min=0"` + DryRun bool `json:"dry_run"` +} diff --git a/internal/handler/admin_handler.go b/internal/handler/admin_handler.go new file mode 100644 index 0000000..47b28ec --- /dev/null +++ b/internal/handler/admin_handler.go @@ -0,0 +1,1169 @@ +package handler + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/dto" + "github.com/xiaolajiaoyyds/regplatformm/internal/middleware" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// AdminHandler 管理后台处理器 +type AdminHandler struct { + db *gorm.DB + creditSvc *service.CreditService + settingSvc *service.SettingService + proxyPool *service.ProxyPool + taskEngine *service.TaskEngine +} + +// NewAdminHandler 创建管理后台处理器 +func NewAdminHandler(db *gorm.DB, creditSvc *service.CreditService, settingSvc *service.SettingService, proxyPool *service.ProxyPool, taskEngine *service.TaskEngine) *AdminHandler { + return &AdminHandler{db: db, creditSvc: creditSvc, settingSvc: settingSvc, proxyPool: proxyPool, taskEngine: taskEngine} +} + +// ListUsers 用户列表(GET /api/admin/users?page=&page_size=&search=) +// 支持分页和搜索,page_size=-1 返回全量 +func (h *AdminHandler) ListUsers(c *gin.Context) { + search := c.Query("search") + + base := h.db.Model(&model.User{}) + if search != "" { + like := "%" + search + "%" + base = base.Where("username ILIKE ? OR name ILIKE ? OR email ILIKE ?", like, like, like) + } + + // Session 克隆防止 Count 污染后续 Find 查询 + var total int64 + base.Session(&gorm.Session{}).Count(&total) + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + + // 排序:支持 credits / newapi_quota / created_at,默认 created_at DESC + sortBy := c.DefaultQuery("sort_by", "created_at") + sortOrder := c.DefaultQuery("sort_order", "desc") + allowedSort := map[string]bool{"credits": true, "newapi_quota": true, "created_at": true} + if !allowedSort[sortBy] { + sortBy = "created_at" + } + if sortOrder != "asc" { + sortOrder = "desc" + } + query := base.Order(sortBy + " " + sortOrder) + if pageSize > 0 { + query = query.Offset((page - 1) * pageSize).Limit(pageSize) + } + + var users []model.User + query.Find(&users) + + c.JSON(http.StatusOK, gin.H{ + "items": users, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// Recharge 管理员充值/扣除(POST /api/admin/credits/recharge) +// Credits > 0 为充值,< 0 为扣除 +func (h *AdminHandler) Recharge(c *gin.Context) { + var req dto.RechargeReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // 扣除时检查余额是否足够 + if req.Credits < 0 { + var user model.User + if err := h.db.First(&user, req.UserID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "用户不存在"}) + return + } + if user.Credits+req.Credits < 0 { + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("余额不足,当前 %d 积分,无法扣除 %d", user.Credits, -req.Credits)}) + return + } + } + + admin := middleware.GetUser(c) + var desc string + if req.Credits > 0 { + desc = fmt.Sprintf("管理员 %s 充值 %d 积分", admin.Username, req.Credits) + } else { + desc = fmt.Sprintf("管理员 %s 扣除 %d 积分", admin.Username, -req.Credits) + } + if err := h.creditSvc.Recharge(h.db, req.UserID, req.Credits, desc); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + + msg := "充值成功" + if req.Credits < 0 { + msg = fmt.Sprintf("已扣除 %d 积分", -req.Credits) + } + c.JSON(http.StatusOK, gin.H{"message": msg}) +} + +// ToggleAdmin 切换管理员状态(POST /api/admin/users/:id/toggle-admin) +func (h *AdminHandler) ToggleAdmin(c *gin.Context) { + admin := middleware.GetUser(c) + userID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的用户 ID"}) + return + } + + var user model.User + if err := h.db.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "用户不存在"}) + return + } + + // 不能操作 root 用户 + if user.IsRoot() { + c.JSON(http.StatusForbidden, gin.H{"detail": "无法修改超级管理员"}) + return + } + // 非 root 不能操作其他管理员 + if !admin.IsRoot() && user.IsAdmin { + c.JSON(http.StatusForbidden, gin.H{"detail": "权限不足"}) + return + } + + newIsAdmin := !user.IsAdmin + newRole := 1 + if newIsAdmin { + newRole = 10 + } + h.db.Model(&user).Updates(map[string]interface{}{ + "is_admin": newIsAdmin, + "role": newRole, + }) + + c.JSON(http.StatusOK, gin.H{"message": "已更新", "is_admin": newIsAdmin}) +} + +// GenerateCodes 生成兑换码(POST /api/admin/codes) +func (h *AdminHandler) GenerateCodes(c *gin.Context) { + admin := middleware.GetUser(c) + var req dto.GenerateCodesReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + codes := make([]model.RedemptionCode, 0, req.Count) + for i := 0; i < req.Count; i++ { + code := generateCode() + codes = append(codes, model.RedemptionCode{ + Code: code, + Credits: req.Credits, + BatchName: req.BatchName, + CreatedBy: &admin.ID, + }) + } + h.db.Create(&codes) + + codeStrs := make([]string, len(codes)) + for i, c := range codes { + codeStrs[i] = c.Code + } + c.JSON(http.StatusOK, gin.H{"codes": codeStrs, "count": len(codes)}) +} + +// ListCodes 兑换码列表(GET /api/admin/codes?batch=&page=1&page_size=20) +func (h *AdminHandler) ListCodes(c *gin.Context) { + batch := c.Query("batch") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + query := h.db.Model(&model.RedemptionCode{}) + if batch != "" { + query = query.Where("batch_name = ?", batch) + } + + var total int64 + query.Count(&total) + + var codes []model.RedemptionCode + query.Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&codes) + c.JSON(http.StatusOK, gin.H{ + "items": codes, + "total": total, + "page": page, + "pages": (int(total) + pageSize - 1) / pageSize, + }) +} + +// Stats 全局统计(GET /api/admin/stats) +func (h *AdminHandler) Stats(c *gin.Context) { + var userCount, taskCount, resultCount int64 + h.db.Model(&model.User{}).Count(&userCount) + h.db.Model(&model.Task{}).Count(&taskCount) + h.db.Model(&model.TaskResult{}).Count(&resultCount) + + // 今日统计(凌晨 6 点重置) + resetTime := dailyResetTime() + var todayTasks, todayResults int64 + h.db.Model(&model.Task{}).Where("created_at >= ?", resetTime).Count(&todayTasks) + h.db.Model(&model.TaskResult{}).Where("created_at >= ?", resetTime).Count(&todayResults) + + // 运行中 / 排队中任务分别统计 + var runningTasks, queuedTasks int64 + h.db.Model(&model.Task{}).Where("status = ?", "running").Count(&runningTasks) + h.db.Model(&model.Task{}).Where("status IN ?", []string{"queued", "pending"}).Count(&queuedTasks) + + // 购买统计(New-API 余额购买) + var purchaseCount int64 + var purchaseCredits int64 + h.db.Model(&model.CreditTransaction{}).Where("type = ?", model.TxTypePurchase).Count(&purchaseCount) + h.db.Model(&model.CreditTransaction{}).Where("type = ?", model.TxTypePurchase). + Select("COALESCE(SUM(amount), 0)").Scan(&purchaseCredits) + + // 今日购买 + var todayPurchaseCount int64 + var todayPurchaseCredits int64 + h.db.Model(&model.CreditTransaction{}).Where("type = ? AND created_at >= ?", model.TxTypePurchase, resetTime).Count(&todayPurchaseCount) + h.db.Model(&model.CreditTransaction{}).Where("type = ? AND created_at >= ?", model.TxTypePurchase, resetTime). + Select("COALESCE(SUM(amount), 0)").Scan(&todayPurchaseCredits) + + // 兑换码统计 + var redeemCount int64 + var redeemCredits int64 + h.db.Model(&model.CreditTransaction{}).Where("type = ?", model.TxTypeRedeem).Count(&redeemCount) + h.db.Model(&model.CreditTransaction{}).Where("type = ?", model.TxTypeRedeem). + Select("COALESCE(SUM(amount), 0)").Scan(&redeemCredits) + + // 各平台注册数 + type platformStat struct { + Platform string `json:"platform"` + Count int64 `json:"count"` + } + var platformStats []platformStat + h.db.Model(&model.TaskResult{}). + Select("platform, COUNT(*) as count"). + Group("platform"). + Scan(&platformStats) + platformMap := make(map[string]int64) + for _, ps := range platformStats { + platformMap[ps.Platform] = ps.Count + } + + // 今日各平台注册数 + var todayPlatformStats []platformStat + h.db.Model(&model.TaskResult{}). + Where("created_at >= ?", resetTime). + Select("platform, COUNT(*) as count"). + Group("platform"). + Scan(&todayPlatformStats) + todayPlatformMap := make(map[string]int64) + for _, ps := range todayPlatformStats { + todayPlatformMap[ps.Platform] = ps.Count + } + + // 总消费积分(预扣) + var totalConsumed int64 + h.db.Model(&model.CreditTransaction{}).Where("type = ?", model.TxTypeConsume). + Select("COALESCE(SUM(ABS(amount)), 0)").Scan(&totalConsumed) + + // 总退还积分 + var totalRefunded int64 + h.db.Model(&model.CreditTransaction{}).Where("type = ?", model.TxTypeRefund). + Select("COALESCE(SUM(amount), 0)").Scan(&totalRefunded) + + // 平台内积分总量(所有用户当前余额之和) + var totalCreditsInSystem int64 + h.db.Model(&model.User{}).Select("COALESCE(SUM(credits), 0)").Scan(&totalCreditsInSystem) + + // 成功率:总成功数 / (总成功 + 总失败) + var totalSuccess, totalFail int64 + h.db.Model(&model.Task{}).Select("COALESCE(SUM(success_count), 0)").Scan(&totalSuccess) + h.db.Model(&model.Task{}).Select("COALESCE(SUM(fail_count), 0)").Scan(&totalFail) + var successRate float64 + if totalSuccess+totalFail > 0 { + successRate = float64(totalSuccess) / float64(totalSuccess+totalFail) * 100 + } + + // 各平台成功率(汇总历史任务) + type platformSuccessStat struct { + Platform string `json:"platform"` + Success int64 `json:"success"` + Fail int64 `json:"fail"` + } + var platformSuccessStats []platformSuccessStat + h.db.Model(&model.Task{}). + Select("platform, COALESCE(SUM(success_count), 0) as success, COALESCE(SUM(fail_count), 0) as fail"). + Where("platform != ''"). + Group("platform"). + Scan(&platformSuccessStats) + type platformRateItem struct { + Success int64 `json:"success"` + Fail int64 `json:"fail"` + SuccessRate float64 `json:"success_rate"` + } + platformRateMap := make(map[string]platformRateItem) + for _, ps := range platformSuccessStats { + rate := 0.0 + if ps.Success+ps.Fail > 0 { + rate = float64(ps.Success) / float64(ps.Success+ps.Fail) * 100 + } + platformRateMap[ps.Platform] = platformRateItem{ + Success: ps.Success, + Fail: ps.Fail, + SuccessRate: rate, + } + } + + // 今日各平台成功率 + var todayPlatformSuccessStats []platformSuccessStat + h.db.Model(&model.Task{}). + Select("platform, COALESCE(SUM(success_count), 0) as success, COALESCE(SUM(fail_count), 0) as fail"). + Where("platform != '' AND created_at >= ?", resetTime). + Group("platform"). + Scan(&todayPlatformSuccessStats) + todayPlatformRateMap := make(map[string]platformRateItem) + for _, ps := range todayPlatformSuccessStats { + rate := 0.0 + if ps.Success+ps.Fail > 0 { + rate = float64(ps.Success) / float64(ps.Success+ps.Fail) * 100 + } + todayPlatformRateMap[ps.Platform] = platformRateItem{ + Success: ps.Success, + Fail: ps.Fail, + SuccessRate: rate, + } + } + + // 各平台平均任务耗时(秒):只统计已完成且有 started_at/stopped_at 的任务 + type platformAvgDuration struct { + Platform string `json:"platform"` + AvgSeconds float64 `json:"avg_seconds"` + TaskCount int64 `json:"task_count"` + } + var platformDurations []platformAvgDuration + h.db.Model(&model.Task{}). + Select("platform, AVG(EXTRACT(EPOCH FROM (stopped_at - started_at))) as avg_seconds, COUNT(*) as task_count"). + Where("platform != '' AND status IN ? AND started_at IS NOT NULL AND stopped_at IS NOT NULL", + []string{"completed", "stopped", "failed"}). + Group("platform"). + Scan(&platformDurations) + platformDurationMap := make(map[string]platformAvgDuration) + for _, pd := range platformDurations { + platformDurationMap[pd.Platform] = pd + } + + // 活跃用户(7天 / 30天内有任务的独立用户数) + now := time.Now() + day7 := now.AddDate(0, 0, -7) + day30 := now.AddDate(0, 0, -30) + var active7d, active30d int64 + h.db.Model(&model.Task{}).Where("created_at >= ?", day7).Distinct("user_id").Count(&active7d) + h.db.Model(&model.Task{}).Where("created_at >= ?", day30).Distinct("user_id").Count(&active30d) + + // 7 日新增用户趋势(按天统计注册数,最近 7 天) + type dailyNewUser struct { + Day string `json:"day"` + Count int64 `json:"count"` + } + var dailyNewUsers []dailyNewUser + h.db.Model(&model.User{}). + Select("DATE(created_at) as day, COUNT(*) as count"). + Where("created_at >= ?", day7). + Group("DATE(created_at)"). + Order("day ASC"). + Scan(&dailyNewUsers) + // 补全 7 天,确保前端有完整序列 + dailyMap := make(map[string]int64) + for _, d := range dailyNewUsers { + dailyMap[d.Day] = d.Count + } + newUsers7d := make([]dailyNewUser, 7) + for i := 6; i >= 0; i-- { + dayStr := now.AddDate(0, 0, -i).Format("2006-01-02") + label := now.AddDate(0, 0, -i).Format("01/02") + newUsers7d[6-i] = dailyNewUser{Day: label, Count: dailyMap[dayStr]} + } + + // 最近交易动态(最新 10 笔购买/兑换) + var recentTxs []model.CreditTransaction + h.db.Where("type IN ?", []string{model.TxTypePurchase, model.TxTypeRedeem, model.TxTypeRecharge}). + Order("created_at DESC").Limit(10). + Preload("User"). + Find(&recentTxs) + type recentTxItem struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + Name string `json:"name"` + Type string `json:"type"` + Amount int `json:"amount"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + } + recentItems := make([]recentTxItem, 0, len(recentTxs)) + for _, tx := range recentTxs { + recentItems = append(recentItems, recentTxItem{ + ID: tx.ID, + UserID: tx.UserID, + Username: tx.User.Username, + Name: tx.User.Name, + Type: tx.Type, + Amount: tx.Amount, + Description: tx.Description, + CreatedAt: tx.CreatedAt.Format("01-02 15:04"), + }) + } + + c.JSON(http.StatusOK, gin.H{ + "total_users": userCount, + "total_tasks": taskCount, + "total_results": resultCount, + "today_tasks": todayTasks, + "today_results": todayResults, + "running_tasks": runningTasks, + "queued_tasks": queuedTasks, + "purchase_count": purchaseCount, + "purchase_credits": purchaseCredits, + "today_purchase_count": todayPurchaseCount, + "today_purchase_credits": todayPurchaseCredits, + "redeem_count": redeemCount, + "redeem_credits": redeemCredits, + "total_consumed": totalConsumed, + "total_refunded": totalRefunded, + "total_credits_in_system": totalCreditsInSystem, + "total_success": totalSuccess, + "total_fail": totalFail, + "success_rate": successRate, + "active_7d": active7d, + "active_30d": active30d, + "platforms": platformMap, + "today_platforms": todayPlatformMap, + "platform_rates": platformRateMap, + "today_platform_rates": todayPlatformRateMap, + "platform_avg_duration": platformDurationMap, + "new_users_7d": newUsers7d, + "recent_txs": recentItems, + }) +} + +// GetSettings 获取系统设置(GET /api/admin/settings) +func (h *AdminHandler) GetSettings(c *gin.Context) { + c.JSON(http.StatusOK, h.settingSvc.GetAll()) +} + +// SaveSetting 保存设置(POST /api/admin/settings) +func (h *AdminHandler) SaveSetting(c *gin.Context) { + var req dto.SaveSettingReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + if err := h.settingSvc.Set(req.Key, req.Value); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + // 代理相关设置变更时刷新代理池 + if req.Key == "default_proxy" && h.proxyPool != nil { + h.proxyPool.Reload() + } + c.JSON(http.StatusOK, gin.H{"message": "已保存"}) +} + +// GetSettingRaw 获取原始设置值(GET /api/admin/settings/raw?key=) +func (h *AdminHandler) GetSettingRaw(c *gin.Context) { + key := c.Query("key") + if key == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 key"}) + return + } + c.JSON(http.StatusOK, gin.H{"key": key, "value": h.settingSvc.GetRaw(key)}) +} + +// PublicAnnouncements 公告列表 - 面向普通用户(GET /api/announcements) +func (h *AdminHandler) PublicAnnouncements(c *gin.Context) { + var list []model.Announcement + h.db.Order("created_at DESC").Limit(20).Find(&list) + c.JSON(http.StatusOK, list) +} + +// CreateAnnouncement 发布公告(POST /api/admin/announcements) +func (h *AdminHandler) CreateAnnouncement(c *gin.Context) { + admin := middleware.GetUser(c) + var req struct { + Title string `json:"title" binding:"required"` + Content string `json:"content" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "标题和内容不能为空"}) + return + } + ann := model.Announcement{Title: req.Title, Content: req.Content, CreatedBy: admin.ID} + h.db.Create(&ann) + c.JSON(http.StatusOK, ann) +} + +// ListAnnouncements 公告列表(GET /api/admin/announcements) +func (h *AdminHandler) ListAnnouncements(c *gin.Context) { + var list []model.Announcement + h.db.Order("created_at DESC").Limit(50).Find(&list) + c.JSON(http.StatusOK, list) +} + +// DeleteAnnouncement 删除公告(DELETE /api/admin/announcements/:id) +func (h *AdminHandler) DeleteAnnouncement(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的公告 ID"}) + return + } + if err := h.db.Where("id = ?", id).Delete(&model.Announcement{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// UserDetail 用户详情(GET /api/admin/users/:id/detail) +// 返回用户信息、各平台注册账号、任务摘要、最近交易 +func (h *AdminHandler) UserDetail(c *gin.Context) { + userID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的用户 ID"}) + return + } + + var user model.User + if err := h.db.Where("id = ?", userID).First(&user).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "用户不存在"}) + return + } + + // 各平台注册结果(含凭据) + // 每个平台最多展示一部分,限制总量避免全量拉取 + var results []model.TaskResult + h.db.Where("user_id = ?", user.ID).Order("created_at DESC").Limit(150).Find(&results) + + type resultItem struct { + ID uint `json:"id"` + Platform string `json:"platform"` + Email string `json:"email"` + CredentialData json.RawMessage `json:"credential_data"` + IsArchived bool `json:"is_archived"` + CreatedAt string `json:"created_at"` + } + platformResults := map[string][]resultItem{ + "grok": {}, "openai": {}, "kiro": {}, "gemini": {}, + } + for _, r := range results { + item := resultItem{ + ID: r.ID, + Platform: r.Platform, + Email: r.Email, + CredentialData: json.RawMessage(r.CredentialData), + IsArchived: r.IsArchived, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04"), + } + p := r.Platform + if p == "" { + p = "grok" + } + platformResults[p] = append(platformResults[p], item) + } + + // 任务统计 + var taskCount int64 + var totalSuccess, totalFail int64 + h.db.Model(&model.Task{}).Where("user_id = ?", user.ID).Count(&taskCount) + h.db.Model(&model.Task{}).Where("user_id = ?", user.ID). + Select("COALESCE(SUM(success_count), 0)").Scan(&totalSuccess) + h.db.Model(&model.Task{}).Where("user_id = ?", user.ID). + Select("COALESCE(SUM(fail_count), 0)").Scan(&totalFail) + + // 最近 20 条交易记录 + var txs []model.CreditTransaction + h.db.Where("user_id = ?", user.ID). + Order("created_at DESC").Limit(20).Find(&txs) + type txItem struct { + ID uint `json:"id"` + Type string `json:"type"` + Amount int `json:"amount"` + Description string `json:"description"` + CreatedAt string `json:"created_at"` + } + txItems := make([]txItem, 0, len(txs)) + for _, tx := range txs { + txItems = append(txItems, txItem{ + ID: tx.ID, + Type: tx.Type, + Amount: tx.Amount, + Description: tx.Description, + CreatedAt: tx.CreatedAt.Format("01-02 15:04"), + }) + } + + c.JSON(http.StatusOK, gin.H{ + "user": user, + "platform_results": platformResults, + "task_summary": gin.H{ + "total_tasks": taskCount, + "total_success": totalSuccess, + "total_fail": totalFail, + }, + "recent_txs": txItems, + }) +} + +// CleanupData 清理旧数据(POST /api/admin/cleanup) +// 删除指定天数之前的已完成任务、注册结果、交易记录 +func (h *AdminHandler) CleanupData(c *gin.Context) { + var req struct { + Days int `json:"days" binding:"required,min=1"` + CleanResults bool `json:"clean_results"` + CleanTasks bool `json:"clean_tasks"` + CleanTx bool `json:"clean_tx"` + CleanArchivedOnly bool `json:"clean_archived_only"` // 仅清理已归档的结果 + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "请输入有效的天数(≥1)"}) + return + } + + cutoff := time.Now().AddDate(0, 0, -req.Days) + cleaned := make(map[string]int64) + + // 清理注册结果 + if req.CleanResults { + query := h.db.Where("created_at < ?", cutoff) + if req.CleanArchivedOnly { + query = query.Where("is_archived = ?", true) + } + result := query.Delete(&model.TaskResult{}) + cleaned["results"] = result.RowsAffected + } + + // 清理已完成的任务(不清理运行中/排队中的) + if req.CleanTasks { + result := h.db.Where("created_at < ? AND status IN ?", cutoff, + []string{"completed", "failed", "stopped"}).Delete(&model.Task{}) + cleaned["tasks"] = result.RowsAffected + } + + // 清理交易记录 + if req.CleanTx { + result := h.db.Where("created_at < ?", cutoff).Delete(&model.CreditTransaction{}) + cleaned["transactions"] = result.RowsAffected + } + + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("已清理 %d 天前的数据", req.Days), + "cleaned": cleaned, + }) +} + +// DataStats 数据量统计(GET /api/admin/data-stats) +// 返回各表的行数,供清理页面参考 +func (h *AdminHandler) DataStats(c *gin.Context) { + var taskCount, resultCount, archivedCount, txCount, codeCount int64 + h.db.Model(&model.Task{}).Count(&taskCount) + h.db.Model(&model.TaskResult{}).Count(&resultCount) + h.db.Model(&model.TaskResult{}).Where("is_archived = ?", true).Count(&archivedCount) + h.db.Model(&model.CreditTransaction{}).Count(&txCount) + h.db.Model(&model.RedemptionCode{}).Count(&codeCount) + + // 各时间段分布 + now := time.Now() + day30 := now.AddDate(0, 0, -30) + day90 := now.AddDate(0, 0, -90) + + var results30, results90 int64 + h.db.Model(&model.TaskResult{}).Where("created_at < ?", day30).Count(&results30) + h.db.Model(&model.TaskResult{}).Where("created_at < ?", day90).Count(&results90) + + var tasks30, tasks90 int64 + h.db.Model(&model.Task{}).Where("created_at < ? AND status IN ?", day30, + []string{"completed", "failed", "stopped"}).Count(&tasks30) + h.db.Model(&model.Task{}).Where("created_at < ? AND status IN ?", day90, + []string{"completed", "failed", "stopped"}).Count(&tasks90) + + c.JSON(http.StatusOK, gin.H{ + "tasks": taskCount, + "results": resultCount, + "archived_results": archivedCount, + "transactions": txCount, + "codes": codeCount, + "older_than_30d": gin.H{ + "results": results30, + "tasks": tasks30, + }, + "older_than_90d": gin.H{ + "results": results90, + "tasks": tasks90, + }, + }) +} + +// RunningTasks 获取所有运行中任务(GET /api/admin/running-tasks) +func (h *AdminHandler) RunningTasks(c *gin.Context) { + infos := h.taskEngine.ListAllRunning() + + // 收集所有 userID 批量查用户信息 + userIDs := make([]uint, 0, len(infos)) + for _, info := range infos { + userIDs = append(userIDs, info.UserID) + } + userMap := make(map[uint]model.User) + if len(userIDs) > 0 { + var users []model.User + h.db.Where("id IN ?", userIDs).Find(&users) + for _, u := range users { + userMap[u.ID] = u + } + } + + // 批量查任务的 credits_reserved + taskIDs := make([]uint, 0, len(infos)) + for _, info := range infos { + taskIDs = append(taskIDs, info.TaskID) + } + taskMap := make(map[uint]model.Task) + if len(taskIDs) > 0 { + var tasks []model.Task + h.db.Where("id IN ?", taskIDs).Find(&tasks) + for _, t := range tasks { + taskMap[t.ID] = t + } + } + + type runningTaskItem struct { + TaskID uint `json:"task_id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + Platform string `json:"platform"` + Target int `json:"target"` + Threads int `json:"threads"` + SuccessCount int64 `json:"success_count"` + FailCount int64 `json:"fail_count"` + CreditsReserved int `json:"credits_reserved"` + ElapsedSec float64 `json:"elapsed_sec"` + Stopping bool `json:"stopping"` + } + + items := make([]runningTaskItem, 0, len(infos)) + for _, info := range infos { + u := userMap[info.UserID] + t := taskMap[info.TaskID] + items = append(items, runningTaskItem{ + TaskID: info.TaskID, + UserID: info.UserID, + Username: u.Username, + AvatarURL: u.AvatarURL, + Platform: info.Platform, + Target: info.Target, + Threads: info.Threads, + SuccessCount: info.SuccessCount, + FailCount: info.FailCount, + CreditsReserved: t.CreditsReserved, + ElapsedSec: time.Since(info.StartedAt).Seconds(), + Stopping: info.Stopping, + }) + } + + // 在线用户数 = 不同 UserID 的数量 + onlineSet := make(map[uint]struct{}) + for _, info := range infos { + onlineSet[info.UserID] = struct{}{} + } + + c.JSON(http.StatusOK, gin.H{ + "tasks": items, + "online_users": len(onlineSet), + }) +} + +// AdminStopTask 管理员强制停止任务(POST /api/admin/tasks/:id/stop) +// 支持停止运行中 (running) 和排队中 (queued/pending) 的任务 +func (h *AdminHandler) AdminStopTask(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的任务 ID"}) + return + } + + // 运行中任务:走引擎停止流程 + if h.taskEngine.AdminStopTask(uint(taskID)) { + c.JSON(http.StatusOK, gin.H{"message": "任务停止中"}) + return + } + + // 排队中 / pending 任务:直接在 DB 取消 + 退款 + var task model.Task + if err := h.db.Where("id = ? AND status IN ?", taskID, []string{"queued", "pending"}).First(&task).Error; err == nil { + now := time.Now() + task.Status = "stopped" + task.StoppedAt = &now + h.db.Save(&task) + if task.CreditsReserved > 0 { + h.creditSvc.RefundCredits(h.db, task.UserID, task.ID, task.CreditsReserved) + } + c.JSON(http.StatusOK, gin.H{"message": "排队已取消,积分已退还"}) + return + } + + c.JSON(http.StatusNotFound, gin.H{"detail": "没有运行中的任务"}) +} + +// AdminDeleteTask 管理员删除任务(DELETE /api/admin/tasks/:id) +func (h *AdminHandler) AdminDeleteTask(c *gin.Context) { + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的任务 ID"}) + return + } + + var task model.Task + if err := h.db.First(&task, taskID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "任务不存在"}) + return + } + + // 运行中的任务不允许直接删除 + if task.Status == "running" || task.Status == "stopping" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "任务运行中,请先停止"}) + return + } + + // 退还未消费积分 + refund := task.CreditsReserved - task.SuccessCount + if refund > 0 { + h.creditSvc.RefundCredits(h.db, task.UserID, task.ID, refund) + } + + // 删除任务结果 + 任务记录 + h.db.Where("task_id = ?", taskID).Delete(&model.TaskResult{}) + h.db.Delete(&task) + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// SendNotification 发送通知(POST /api/admin/notifications) +func (h *AdminHandler) SendNotification(c *gin.Context) { + admin := middleware.GetUser(c) + var req dto.SendNotificationReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "标题和内容不能为空"}) + return + } + notification := model.Notification{ + UserID: req.UserID, + Title: req.Title, + Content: req.Content, + CreatedBy: admin.ID, + } + if err := h.db.Create(¬ification).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "发送失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "通知已发送", "id": notification.ID}) +} + +// ListAdminNotifications 管理员查看已发送通知列表(GET /api/admin/notifications) +func (h *AdminHandler) ListAdminNotifications(c *gin.Context) { + var notifications []model.Notification + h.db.Order("created_at DESC").Limit(100).Find(¬ifications) + + // 附带发送者用户名 + creatorIDs := make([]uint, 0) + for _, n := range notifications { + if n.CreatedBy > 0 { + creatorIDs = append(creatorIDs, n.CreatedBy) + } + } + creatorMap := make(map[uint]string) + if len(creatorIDs) > 0 { + var users []model.User + h.db.Where("id IN ?", creatorIDs).Select("id, username").Find(&users) + for _, u := range users { + creatorMap[u.ID] = u.Username + } + } + + type notifItem struct { + ID uint `json:"id"` + UserID uint `json:"user_id"` + Title string `json:"title"` + Content string `json:"content"` + CreatedBy uint `json:"created_by"` + CreatorName string `json:"creator_name"` + CreatedAt string `json:"created_at"` + } + items := make([]notifItem, 0, len(notifications)) + for _, n := range notifications { + items = append(items, notifItem{ + ID: n.ID, + UserID: n.UserID, + Title: n.Title, + Content: n.Content, + CreatedBy: n.CreatedBy, + CreatorName: creatorMap[n.CreatedBy], + CreatedAt: n.CreatedAt.Format("2006-01-02 15:04"), + }) + } + c.JSON(http.StatusOK, items) +} + +// DeleteNotification 管理员删除通知(DELETE /api/admin/notifications/:id) +func (h *AdminHandler) DeleteNotification(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的通知 ID"}) + return + } + // 同时删除广播通知的已读记录 + h.db.Where("notification_id = ?", id).Delete(&model.NotificationRead{}) + if err := h.db.Where("id = ?", id).Delete(&model.Notification{}).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// UserNotifications 用户获取通知列表(GET /api/notifications) +func (h *AdminHandler) UserNotifications(c *gin.Context) { + user := middleware.GetUser(c) + + var notifications []model.Notification + h.db.Where("user_id = ? OR user_id = 0", user.ID). + Order("created_at DESC"). + Limit(50). + Find(¬ifications) + + // 查询该用户已读的广播通知 ID 集合 + var readIDs []uint + h.db.Model(&model.NotificationRead{}). + Where("user_id = ?", user.ID). + Pluck("notification_id", &readIDs) + readSet := make(map[uint]struct{}, len(readIDs)) + for _, id := range readIDs { + readSet[id] = struct{}{} + } + + // 构建响应:广播通知的已读状态从 notification_reads 表判断 + type notifItem struct { + model.Notification + IsRead bool `json:"is_read"` + } + items := make([]notifItem, 0, len(notifications)) + var unreadCount int + for _, n := range notifications { + read := n.IsRead // 个人通知用 DB 字段 + if n.UserID == 0 { + _, read = readSet[n.ID] // 广播通知用关联表 + } + if !read { + unreadCount++ + } + items = append(items, notifItem{Notification: n, IsRead: read}) + } + + c.JSON(http.StatusOK, gin.H{ + "notifications": items, + "unread_count": unreadCount, + }) +} + +// MarkNotificationRead 标记通知已读(PATCH /api/notifications/:id/read) +func (h *AdminHandler) MarkNotificationRead(c *gin.Context) { + user := middleware.GetUser(c) + idParam := c.Param("id") + + if idParam == "all" { + // 个人通知:直接更新 is_read + h.db.Model(&model.Notification{}). + Where("user_id = ? AND is_read = ?", user.ID, false). + Update("is_read", true) + + // 广播通知:批量插入未读的广播通知已读记录(ON CONFLICT 幂等) + h.db.Exec(`INSERT INTO notification_reads (notification_id, user_id, read_at) + SELECT id, ?, NOW() FROM notifications WHERE user_id = 0 + AND id NOT IN (SELECT notification_id FROM notification_reads WHERE user_id = ?) + ON CONFLICT DO NOTHING`, user.ID, user.ID) + + c.JSON(http.StatusOK, gin.H{"message": "已全部标记为已读"}) + return + } + + notifID, err := strconv.ParseUint(idParam, 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的通知 ID"}) + return + } + + // 查询通知类型 + var notif model.Notification + if err := h.db.First(¬if, notifID).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "通知不存在"}) + return + } + + if notif.UserID == 0 { + // 广播通知:插入用户已读记录 + h.db.Where("notification_id = ? AND user_id = ?", notifID, user.ID). + FirstOrCreate(&model.NotificationRead{NotificationID: uint(notifID), UserID: user.ID}) + } else if notif.UserID == user.ID { + // 个人通知:更新 is_read + h.db.Model(¬if).Update("is_read", true) + } else { + c.JSON(http.StatusForbidden, gin.H{"detail": "无权操作此通知"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "已标记为已读"}) +} + +// ProviderStats 邮箱 provider 成功率统计(GET /api/admin/provider-stats) +func (h *AdminHandler) ProviderStats(c *gin.Context) { + stats := tempmail.GetProviderStats() + type item struct { + Provider string `json:"provider"` + Success int64 `json:"success"` + Fail int64 `json:"fail"` + Total int64 `json:"total"` + SuccessRate float64 `json:"success_rate"` + } + items := make([]item, 0, len(stats)) + for name, counts := range stats { + total := counts[0] + counts[1] + rate := 0.0 + if total > 0 { + rate = float64(counts[0]) / float64(total) * 100 + } + items = append(items, item{ + Provider: name, + Success: counts[0], + Fail: counts[1], + Total: total, + SuccessRate: rate, + }) + } + c.JSON(http.StatusOK, gin.H{"providers": items}) +} + +// generateCode 生成 XXXX-XXXX-XXXX-XXXX 格式兑换码 +func generateCode() string { + b := make([]byte, 8) + rand.Read(b) + return fmt.Sprintf("%04X-%04X-%04X-%04X", + uint16(b[0])<<8|uint16(b[1]), + uint16(b[2])<<8|uint16(b[3]), + uint16(b[4])<<8|uint16(b[5]), + uint16(b[6])<<8|uint16(b[7]), + ) +} + +// RecentActivity 最近注册活动(GET /api/admin/recent-activity?page=1&page_size=20) +// 返回最近的任务列表,含用户信息、平台、单次任务注册量(非累计),支持分页 +func (h *AdminHandler) RecentActivity(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + base := h.db.Model(&model.Task{}).Where("status IN ?", + []string{"completed", "stopped", "failed"}) + + var total int64 + base.Session(&gorm.Session{}).Count(&total) + + var tasks []model.Task + base.Session(&gorm.Session{}).Order("created_at DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&tasks) + + // 批量查用户 + userIDs := make([]uint, 0, len(tasks)) + for _, t := range tasks { + userIDs = append(userIDs, t.UserID) + } + userMap := make(map[uint]model.User) + if len(userIDs) > 0 { + var users []model.User + h.db.Where("id IN ?", userIDs).Find(&users) + for _, u := range users { + userMap[u.ID] = u + } + } + + type activityItem struct { + TaskID uint `json:"task_id"` + UserID uint `json:"user_id"` + Username string `json:"username"` + AvatarURL string `json:"avatar_url"` + Platform string `json:"platform"` + Target int `json:"target"` + Success int `json:"success_count"` + Fail int `json:"fail_count"` + Credits int `json:"credits_reserved"` + Status string `json:"status"` + CreatedAt string `json:"created_at"` + CompletedAt string `json:"completed_at"` + } + + items := make([]activityItem, 0, len(tasks)) + for _, t := range tasks { + u := userMap[t.UserID] + completedAt := "" + if t.StoppedAt != nil { + completedAt = t.StoppedAt.Format("01-02 15:04:05") + } + items = append(items, activityItem{ + TaskID: t.ID, + UserID: t.UserID, + Username: u.Username, + AvatarURL: u.AvatarURL, + Platform: t.Platform, + Target: t.TargetCount, + Success: t.SuccessCount, + Fail: t.FailCount, + Credits: t.CreditsReserved, + Status: t.Status, + CreatedAt: t.CreatedAt.Format("01-02 15:04:05"), + CompletedAt: completedAt, + }) + } + + c.JSON(http.StatusOK, gin.H{ + "items": items, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// GPTMailKeyStatus 已废弃,保留路由兼容 +func (h *AdminHandler) GPTMailKeyStatus(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"keys": []interface{}{}, "message": "已迁移至 YYDS Mail"}) +} diff --git a/internal/handler/auth_handler.go b/internal/handler/auth_handler.go new file mode 100644 index 0000000..060f3e3 --- /dev/null +++ b/internal/handler/auth_handler.go @@ -0,0 +1,176 @@ +package handler + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/config" + "github.com/xiaolajiaoyyds/regplatformm/internal/middleware" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" +) + +// AuthHandler 认证处理器 +type AuthHandler struct { + authSvc *service.AuthService + cfg *config.Config +} + +// NewAuthHandler 创建认证处理器 +func NewAuthHandler(authSvc *service.AuthService, cfg *config.Config) *AuthHandler { + return &AuthHandler{authSvc: authSvc, cfg: cfg} +} + +// registerReq 注册请求 +type registerReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// loginReq 登录请求 +type loginReq struct { + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` +} + +// Register 用户注册(POST /api/auth/register) +func (h *AuthHandler) Register(c *gin.Context) { + var req registerReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "请提供用户名和密码"}) + return + } + + user, err := h.authSvc.Register(req.Username, req.Password) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + jwt, err := h.authSvc.CreateJWT(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "JWT 签发失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "token": jwt, + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "name": user.Name, + "role": user.Role, + "is_admin": user.IsAdmin, + }, + }) +} + +// Login 用户登录(POST /api/auth/login) +func (h *AuthHandler) Login(c *gin.Context) { + var req loginReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "请提供用户名和密码"}) + return + } + + user, err := h.authSvc.Login(req.Username, req.Password) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()}) + return + } + + jwt, err := h.authSvc.CreateJWT(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "JWT 签发失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "token": jwt, + "user": gin.H{ + "id": user.ID, + "username": user.Username, + "name": user.Name, + "role": user.Role, + "is_admin": user.IsAdmin, + }, + }) +} + +// SSOLogin SSO 登录(GET /api/auth/sso?token=xxx,可选功能) +func (h *AuthHandler) SSOLogin(c *gin.Context) { + tokenStr := c.Query("token") + if tokenStr == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 token 参数"}) + return + } + + ssoInfo, err := h.authSvc.VerifySSOToken(tokenStr) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": err.Error()}) + return + } + + user, err := h.authSvc.FindOrCreateUser(ssoInfo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "用户创建失败"}) + return + } + + jwt, err := h.authSvc.CreateJWT(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "JWT 签发失败"}) + return + } + + c.Redirect(http.StatusFound, "/dashboard?sso_token="+jwt) +} + +// DevLogin 开发模式登录(GET /api/auth/dev-login) +func (h *AuthHandler) DevLogin(c *gin.Context) { + if !h.cfg.DevMode { + c.JSON(http.StatusForbidden, gin.H{"detail": "非开发模式"}) + return + } + + user, err := h.authSvc.CreateDevUser() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建开发用户失败"}) + return + } + + jwt, err := h.authSvc.CreateJWT(user.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "JWT 签发失败"}) + return + } + + c.Redirect(http.StatusFound, "/dashboard?sso_token="+jwt) +} + +// Logout 登出(POST /api/auth/logout) +func (h *AuthHandler) Logout(c *gin.Context) { + isSecure := h.cfg.GinMode == "release" + c.SetCookie("token", "", -1, "/", "", isSecure, true) + c.JSON(http.StatusOK, gin.H{"message": "已登出"}) +} + +// Me 获取当前用户信息(GET /api/auth/me) +func (h *AuthHandler) Me(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录"}) + return + } + c.JSON(http.StatusOK, gin.H{ + "id": user.ID, + "username": user.Username, + "name": user.Name, + "email": user.Email, + "avatar_url": user.AvatarURL, + "role": user.Role, + "credits": user.Credits, + "free_trial_used": user.FreeTrialUsed, + "free_trial_remaining": user.FreeTrialRemaining, + "is_admin": user.IsAdmin, + }) +} diff --git a/internal/handler/card_pool_handler.go b/internal/handler/card_pool_handler.go new file mode 100644 index 0000000..74a43de --- /dev/null +++ b/internal/handler/card_pool_handler.go @@ -0,0 +1,295 @@ +package handler + +import ( + "bufio" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// CardPoolHandler 虚拟卡池管理(管理员) +type CardPoolHandler struct { + db *gorm.DB + cardPool *service.CardPool +} + +// NewCardPoolHandler 创建卡池管理处理器 +func NewCardPoolHandler(db *gorm.DB, cardPool *service.CardPool) *CardPoolHandler { + return &CardPoolHandler{db: db, cardPool: cardPool} +} + +// ── 请求结构体 ── + +type createCardReq struct { + Name string `json:"name" binding:"max=100"` + CardNumber string `json:"card_number" binding:"required,min=13,max=19"` + ExpMonth int `json:"exp_month" binding:"required,min=1,max=12"` + ExpYear int `json:"exp_year" binding:"required,min=2024,max=2099"` + CVC string `json:"cvc" binding:"required,min=3,max=4"` + BillingName string `json:"billing_name" binding:"max=200"` + BillingEmail string `json:"billing_email" binding:"max=200"` + BillingCountry string `json:"billing_country" binding:"max=10"` + BillingCity string `json:"billing_city" binding:"max=100"` + BillingLine1 string `json:"billing_line1" binding:"max=300"` + BillingZip string `json:"billing_zip" binding:"max=20"` + Provider string `json:"provider" binding:"max=50"` +} + +// List 获取卡池列表(GET /api/admin/card-pool,卡号脱敏) +func (h *CardPoolHandler) List(c *gin.Context) { + query := h.db.Model(&model.CardPoolEntry{}).Order("created_at DESC") + + if filter := c.Query("filter"); filter == "valid" { + query = query.Where("is_valid = ?", true) + } else if filter == "invalid" { + query = query.Where("is_valid = ?", false) + } + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 500 { + pageSize = 50 + } + + var total int64 + query.Session(&gorm.Session{}).Count(&total) + + var entries []model.CardPoolEntry + if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&entries).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询失败"}) + return + } + + // 脱敏:卡号和 CVC + for i := range entries { + entries[i].CardNumber = entries[i].MaskedNumber() + entries[i].CVC = "***" + } + c.JSON(http.StatusOK, gin.H{ + "items": entries, "total": total, "page": page, "page_size": pageSize, + }) +} + +// Create 添加卡(POST /api/admin/card-pool) +func (h *CardPoolHandler) Create(c *gin.Context) { + var req createCardReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + // 去重 + var count int64 + h.db.Model(&model.CardPoolEntry{}).Where("card_number = ?", req.CardNumber).Count(&count) + if count > 0 { + c.JSON(http.StatusConflict, gin.H{"detail": "该卡号已存在"}) + return + } + + country := req.BillingCountry + if country == "" { + country = "US" + } + provider := req.Provider + if provider == "" { + provider = "manual" + } + + entry := model.CardPoolEntry{ + Name: req.Name, CardNumber: req.CardNumber, + ExpMonth: req.ExpMonth, ExpYear: req.ExpYear, CVC: req.CVC, + BillingName: req.BillingName, BillingEmail: req.BillingEmail, + BillingCountry: country, BillingCity: req.BillingCity, + BillingLine1: req.BillingLine1, BillingZip: req.BillingZip, + Provider: provider, Source: "manual", + } + if err := h.db.Create(&entry).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"}) + return + } + h.cardPool.Reload() + entry.CardNumber = entry.MaskedNumber() + entry.CVC = "***" + c.JSON(http.StatusOK, entry) +} + +// Delete 删除卡(DELETE /api/admin/card-pool/:id) +func (h *CardPoolHandler) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"}) + return + } + result := h.db.Where("id = ?", id).Delete(&model.CardPoolEntry{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"}) + return + } + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"detail": "卡不存在"}) + return + } + h.cardPool.Reload() + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// BatchDelete 批量删除(POST /api/admin/card-pool/batch-delete) +func (h *CardPoolHandler) BatchDelete(c *gin.Context) { + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if len(req.IDs) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "单次最多删除 500 条"}) + return + } + result := h.db.Where("id IN ?", req.IDs).Delete(&model.CardPoolEntry{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"}) + return + } + h.cardPool.Reload() + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("已删除 %d 条", result.RowsAffected)}) +} + +// Import 批量导入卡(POST /api/admin/card-pool/import) +// 格式:每行一张卡,管道分隔 number|exp_month|exp_year|cvc[|name|email|country|city|line1|zip] +func (h *CardPoolHandler) Import(c *gin.Context) { + var req struct { + Cards string `json:"cards" binding:"required"` + Provider string `json:"provider"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + provider := req.Provider + if provider == "" { + provider = "manual" + } + + scanner := bufio.NewScanner(strings.NewReader(req.Cards)) + var imported, skipped, failed int + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if imported+skipped+failed >= 1000 { + failed++ + continue + } + entry, err := parseCardLine(line, provider) + if err != nil { + failed++ + continue + } + var count int64 + h.db.Model(&model.CardPoolEntry{}).Where("card_number = ?", entry.CardNumber).Count(&count) + if count > 0 { + skipped++ + continue + } + if err := h.db.Create(entry).Error; err != nil { + failed++ + continue + } + imported++ + } + h.cardPool.Reload() + c.JSON(http.StatusOK, gin.H{ + "imported": imported, "skipped": skipped, "failed": failed, + "message": fmt.Sprintf("导入完成:成功 %d,重复跳过 %d,失败 %d", imported, skipped, failed), + }) +} + +// parseCardLine 解析单行卡信息(管道分隔) +func parseCardLine(line, provider string) (*model.CardPoolEntry, error) { + parts := strings.Split(line, "|") + if len(parts) < 4 { + return nil, fmt.Errorf("格式错误,至少需要 number|month|year|cvc") + } + month, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil || month < 1 || month > 12 { + return nil, fmt.Errorf("月份无效") + } + year, err := strconv.Atoi(strings.TrimSpace(parts[2])) + if err != nil || year < 2024 { + return nil, fmt.Errorf("年份无效") + } + entry := &model.CardPoolEntry{ + CardNumber: strings.TrimSpace(parts[0]), + ExpMonth: month, + ExpYear: year, + CVC: strings.TrimSpace(parts[3]), + Provider: provider, + Source: "imported", + BillingCountry: "US", + } + if len(parts) > 4 { entry.BillingName = strings.TrimSpace(parts[4]) } + if len(parts) > 5 { entry.BillingEmail = strings.TrimSpace(parts[5]) } + if len(parts) > 6 { entry.BillingCountry = strings.TrimSpace(parts[6]) } + if len(parts) > 7 { entry.BillingCity = strings.TrimSpace(parts[7]) } + if len(parts) > 8 { entry.BillingLine1 = strings.TrimSpace(parts[8]) } + if len(parts) > 9 { entry.BillingZip = strings.TrimSpace(parts[9]) } + return entry, nil +} + +// Validate 触发卡池验证(POST /api/admin/card-pool/validate) +func (h *CardPoolHandler) Validate(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + _ = c.ShouldBindJSON(&req) + + if len(req.IDs) > 0 { + if len(req.IDs) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "单次最多验证 500 张"}) + return + } + total, valid, invalid := h.cardPool.ValidateByIDs(req.IDs) + c.JSON(http.StatusOK, gin.H{ + "total": total, "valid": valid, "invalid": invalid, + "message": fmt.Sprintf("验证完成:共 %d 张,有效 %d,无效 %d", total, valid, invalid), + }) + return + } + + total, valid, invalid := h.cardPool.ValidateAll() + c.JSON(http.StatusOK, gin.H{ + "total": total, "valid": valid, "invalid": invalid, + "message": fmt.Sprintf("验证完成:共 %d 张,有效 %d,无效 %d", total, valid, invalid), + }) +} + +// Stats 卡池统计(GET /api/admin/card-pool/stats) +func (h *CardPoolHandler) Stats(c *gin.Context) { + stats := h.cardPool.PoolStats() + c.JSON(http.StatusOK, stats) +} + +// PurgeInvalid 清除所有无效卡(POST /api/admin/card-pool/purge) +func (h *CardPoolHandler) PurgeInvalid(c *gin.Context) { + result := h.db.Where("is_valid = ?", false).Delete(&model.CardPoolEntry{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "清除失败"}) + return + } + h.cardPool.Reload() + c.JSON(http.StatusOK, gin.H{ + "deleted": result.RowsAffected, + "message": fmt.Sprintf("已清除 %d 张无效卡", result.RowsAffected), + }) +} diff --git a/internal/handler/credit_handler.go b/internal/handler/credit_handler.go new file mode 100644 index 0000000..1d00db7 --- /dev/null +++ b/internal/handler/credit_handler.go @@ -0,0 +1,363 @@ +package handler + +import ( + "fmt" + "math" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/dto" + "github.com/xiaolajiaoyyds/regplatformm/internal/middleware" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// CreditHandler 积分处理器 +type CreditHandler struct { + db *gorm.DB + creditSvc *service.CreditService + settingSvc *service.SettingService +} + +// NewCreditHandler 创建积分处理器 +func NewCreditHandler(db *gorm.DB, creditSvc *service.CreditService, settingSvc *service.SettingService) *CreditHandler { + return &CreditHandler{db: db, creditSvc: creditSvc, settingSvc: settingSvc} +} + +// dailyResetTime 返回今天的重置时间点(凌晨 6 点) +// 如果当前时间在 6 点之前,则返回昨天 6 点 +func dailyResetTime() time.Time { + now := time.Now() + reset := time.Date(now.Year(), now.Month(), now.Day(), 6, 0, 0, 0, now.Location()) + if now.Before(reset) { + reset = reset.AddDate(0, 0, -1) + } + return reset +} + +// Balance 查询余额(GET /api/credits/balance) +func (h *CreditHandler) Balance(c *gin.Context) { + user := middleware.GetUser(c) + credits, freeRemaining, freeUsed := h.creditSvc.GetBalance(user.ID) + + // 读取系统限制 + maxTarget, _ := strconv.Atoi(h.settingSvc.Get("max_target", "1000")) + maxThreads, _ := strconv.Atoi(h.settingSvc.Get("max_threads", "16")) + dailyLimit, _ := strconv.Atoi(h.settingSvc.Get("daily_reg_limit", "0")) + + // 计算今日已注册数量(从凌晨 6 点算起) + resetTime := dailyResetTime() + var dailyUsed int64 + h.db.Model(&model.Task{}). + Where("user_id = ? AND created_at >= ?", user.ID, resetTime). + Select("COALESCE(SUM(success_count), 0)").Scan(&dailyUsed) + + dailyRemaining := -1 // -1 表示不限制 + if dailyLimit > 0 { + dailyRemaining = dailyLimit - int(dailyUsed) + if dailyRemaining < 0 { + dailyRemaining = 0 + } + } + + // 可用注册次数 = 本地积分 + 免费试用剩余 + registrationsAvailable := credits + freeRemaining + + // 免费试用信息 + freeTrialCount := h.settingSvc.GetInt("free_trial_count", 2) + freeTrialEnabled := h.settingSvc.Get("free_trial_enabled", "true") == "true" + + trialEligible := !freeUsed && freeTrialEnabled && freeTrialCount > 0 + trialRemaining := 0 + trialTotal := 0 + if freeUsed { + trialRemaining = freeRemaining + } else if trialEligible { + trialRemaining = freeTrialCount + trialTotal = freeTrialCount + } + + // New-API 余额(实时查询) + var newapiBalance float64 + var newapiBalanceDisplay string + var unitPrice float64 + var unitPriceDisplay string + var newapiAvailable int + mode := "local" + display := fmt.Sprintf("%d 次", registrationsAvailable) + + costStr := h.settingSvc.Get("newapi_cost_per_reg", "0.004") + costUSD, _ := strconv.ParseFloat(costStr, 64) + if costUSD <= 0 { + costUSD = 0.004 + } + + baseURL := h.settingSvc.Get("newapi_base_url", "") + if baseURL != "" && user.NewapiID != 0 { + mode = "newapi" + unitPrice = costUSD + unitPriceDisplay = fmt.Sprintf("$%.4g", costUSD) + + quotaRaw, balUSD, err := h.creditSvc.GetNewAPIBalance(user) + if err == nil { + newapiBalance = balUSD + newapiBalanceDisplay = fmt.Sprintf("$%.2f", balUSD) + newapiAvailable = int(math.Floor(float64(quotaRaw) / (costUSD * 500000))) + display = fmt.Sprintf("$%.2f", balUSD) + } else { + newapiBalanceDisplay = "$0" + display = fmt.Sprintf("%d 次", registrationsAvailable) + } + } + + c.JSON(http.StatusOK, dto.BalanceResp{ + Credits: credits, + FreeTrialRemaining: freeRemaining, + FreeTrialUsed: freeUsed, + CostPerReg: 1, + Mode: mode, + Display: display, + RegistrationsAvailable: registrationsAvailable, + NewapiBalance: newapiBalance, + NewapiBalanceDisplay: newapiBalanceDisplay, + UnitPrice: unitPrice, + UnitPriceDisplay: unitPriceDisplay, + NewapiAvailable: newapiAvailable, + PlatformPrices: buildPlatformPrices(h.settingSvc, costUSD), + FreeTrial: &dto.FreeTrialResp{ + Eligible: trialEligible, + Remaining: trialRemaining, + Total: trialTotal, + }, + Limits: &dto.LimitsResp{ + MaxTarget: maxTarget, + MaxThreads: maxThreads, + DailyRegLimit: dailyLimit, + DailyUsed: int(dailyUsed), + DailyRemaining: dailyRemaining, + PlatformGrokEnabled: h.settingSvc.Get("platform_grok_enabled", "true") == "true", + PlatformOpenaiEnabled: h.settingSvc.Get("platform_openai_enabled", "true") == "true", + PlatformKiroEnabled: h.settingSvc.Get("platform_kiro_enabled", "true") == "true", + PlatformGeminiEnabled: h.settingSvc.Get("platform_gemini_enabled", "true") == "true", + ThreadTiers: h.settingSvc.Get("thread_tiers", ""), + PlatformGrokFreeUntil: h.settingSvc.Get("platform_grok_free_until", ""), + PlatformOpenaiFreeUntil: h.settingSvc.Get("platform_openai_free_until", ""), + PlatformKiroFreeUntil: h.settingSvc.Get("platform_kiro_free_until", ""), + PlatformGeminiFreeUntil: h.settingSvc.Get("platform_gemini_free_until", ""), + FreeMode: buildFreeModeStatus(h.db, h.settingSvc, user.ID), + PlatformLimits: buildPlatformLimits(h.db, h.settingSvc, user.ID), + }, + }) +} + +// History 交易记录(GET /api/credits/history) +func (h *CreditHandler) History(c *gin.Context) { + user := middleware.GetUser(c) + var txs []model.CreditTransaction + h.db.Where("user_id = ?", user.ID). + Order("created_at DESC"). + Limit(50). + Find(&txs) + c.JSON(http.StatusOK, txs) +} + +// Redeem 兑换码(POST /api/credits/redeem) +func (h *CreditHandler) Redeem(c *gin.Context) { + user := middleware.GetUser(c) + var req dto.RedeemReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + credits, err := h.creditSvc.RedeemCode(h.db, user.ID, req.Code) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "兑换成功", "credits": credits}) +} + +// ClaimFreeTrial 领取免费试用(POST /api/credits/free-trial) +func (h *CreditHandler) ClaimFreeTrial(c *gin.Context) { + user := middleware.GetUser(c) + count, err := h.creditSvc.ClaimFreeTrial(h.db, user.ID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "领取成功", "count": count}) +} + +// Purchase 购买注册次数(POST /api/credits/purchase) +func (h *CreditHandler) Purchase(c *gin.Context) { + user := middleware.GetUser(c) + var req dto.PurchaseReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "请输入有效的购买数量(1-10000)"}) + return + } + + if err := h.creditSvc.PurchaseCredits(h.db, user.ID, req.Amount, req.Platform); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // 返回更新后的余额(复用 Balance 逻辑) + h.Balance(c) +} + +// buildPlatformPrices 构建各平台单价映射(优先用平台专属价,降级全局价) +func buildPlatformPrices(settingSvc *service.SettingService, globalCost float64) map[string]float64 { + prices := map[string]float64{} + for _, p := range []string{"grok", "openai", "kiro", "gemini"} { + key := "platform_" + p + "_unit_price" + val := settingSvc.Get(key, "") + if val != "" { + if parsed, err := strconv.ParseFloat(val, 64); err == nil && parsed > 0 { + prices[p] = parsed + continue + } + } + prices[p] = globalCost + } + return prices +} + +// buildFreeModeStatus 构建各平台的免费模式状态(仅限时免费期内的平台) +// 包级函数,供 CreditHandler 和 InitHandler 共用 +func buildFreeModeStatus(db *gorm.DB, settingSvc *service.SettingService, userID uint) map[string]*dto.FreeModeInfo { + freeMode := map[string]*dto.FreeModeInfo{} + resetTime := dailyResetTime() + + for _, platform := range []string{"grok", "openai", "kiro", "gemini"} { + if !isPlatformFreeByHandler(settingSvc, platform) { + continue + } + + prefix := "platform_" + platform + "_free_" + dailyLimit, _ := strconv.Atoi(settingSvc.Get(prefix+"daily_limit", "5")) + taskLimit, _ := strconv.Atoi(settingSvc.Get(prefix+"task_limit", "2")) + cooldownMin, _ := strconv.Atoi(settingSvc.Get(prefix+"cooldown", "30")) + + // 查今日免费使用量 + var dailyFreeUsed int64 + db.Model(&model.Task{}). + Where("user_id = ? AND platform = ? AND credits_reserved = 0 AND created_at >= ?", userID, platform, resetTime). + Select("COALESCE(SUM(target_count), 0)").Scan(&dailyFreeUsed) + + dailyRemaining := -1 + if dailyLimit > 0 { + dailyRemaining = dailyLimit - int(dailyFreeUsed) + if dailyRemaining < 0 { + dailyRemaining = 0 + } + } + + // 查冷却剩余(上一轮用满 task_limit 才触发,没用满不冷却) + cooldownRemainingSec := 0 + lastTaskMaxed := false + if cooldownMin > 0 && taskLimit > 0 { + var lastTask model.Task + err := db.Where("user_id = ? AND platform = ? AND credits_reserved = 0 AND stopped_at IS NOT NULL", userID, platform). + Order("stopped_at DESC").First(&lastTask).Error + if err == nil && lastTask.StoppedAt != nil && lastTask.TargetCount >= taskLimit { + lastTaskMaxed = true + cooldownEnd := lastTask.StoppedAt.Add(time.Duration(cooldownMin) * time.Minute) + if time.Now().Before(cooldownEnd) { + cooldownRemainingSec = int(time.Until(cooldownEnd).Seconds()) + } + } + } + + // 判断是否可用 + available := true + reason := "" + dailyLimitReached := dailyLimit > 0 && dailyRemaining <= 0 + if dailyLimitReached { + // 今日额度用完 + available = false + reason = "今日免费额度已用完" + } else if lastTaskMaxed && cooldownRemainingSec > 0 { + // 上一轮用满了 task_limit,冷却中 + available = false + reason = fmt.Sprintf("上一轮已用满 %d 个,冷却中还需 %d 分钟", taskLimit, cooldownRemainingSec/60+1) + } + + freeMode[platform] = &dto.FreeModeInfo{ + Available: available, + DailyUsed: int(dailyFreeUsed), + DailyLimit: dailyLimit, + DailyRemaining: dailyRemaining, + TaskLimit: taskLimit, + CooldownSec: cooldownMin * 60, + CooldownRemaining: cooldownRemainingSec, + Reason: reason, + } + } + + if len(freeMode) == 0 { + return nil + } + return freeMode +} + +// buildPlatformLimits 构建各平台的独立限制(付费/免费均生效) +// 包级函数,供 CreditHandler 和 InitHandler 共用 +func buildPlatformLimits(db *gorm.DB, settingSvc *service.SettingService, userID uint) map[string]*dto.PlatformLimitInfo { + result := map[string]*dto.PlatformLimitInfo{} + resetTime := dailyResetTime() + + for _, platform := range []string{"grok", "openai", "kiro", "gemini"} { + taskLimit := settingSvc.GetInt("platform_"+platform+"_task_limit", 0) + dailyLimit := settingSvc.GetInt("platform_"+platform+"_daily_limit", 0) + + // 两个都是 0 表示该平台没有独立限制,跳过 + if taskLimit == 0 && dailyLimit == 0 { + continue + } + + dailyUsed := 0 + dailyRemaining := -1 // -1 = 不限 + if dailyLimit > 0 { + var used int64 + db.Model(&model.Task{}). + Where("user_id = ? AND platform = ? AND created_at >= ?", userID, platform, resetTime). + Select("COALESCE(SUM(success_count), 0)").Scan(&used) + dailyUsed = int(used) + dailyRemaining = dailyLimit - dailyUsed + if dailyRemaining < 0 { + dailyRemaining = 0 + } + } + + result[platform] = &dto.PlatformLimitInfo{ + TaskLimit: taskLimit, + DailyLimit: dailyLimit, + DailyUsed: dailyUsed, + DailyRemaining: dailyRemaining, + } + } + + if len(result) == 0 { + return nil + } + return result +} + +// isPlatformFreeByHandler 判断平台是否限时免费(handler 层复用) +func isPlatformFreeByHandler(settingSvc *service.SettingService, platform string) bool { + dateStr := settingSvc.Get("platform_"+platform+"_free_until", "") + if dateStr == "" { + return false + } + deadline, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return false + } + return time.Now().Before(deadline.AddDate(0, 0, 1)) +} diff --git a/internal/handler/gemini_validate.go b/internal/handler/gemini_validate.go new file mode 100644 index 0000000..428bc03 --- /dev/null +++ b/internal/handler/gemini_validate.go @@ -0,0 +1,391 @@ +package handler + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// geminiValidateMu 每用户验活并发锁,防止同一用户同时触发多个验活流 +var geminiValidateMu sync.Map // key: userID (uint), value: *sync.Mutex + +// GeminiValidateHandler Gemini Business 账号验活处理器 +type GeminiValidateHandler struct { + db *gorm.DB + authSvc *service.AuthService + proxyPool *service.ProxyPool +} + +// NewGeminiValidateHandler 创建 Gemini 验活处理器 +func NewGeminiValidateHandler(db *gorm.DB, authSvc *service.AuthService, proxyPool *service.ProxyPool) *GeminiValidateHandler { + return &GeminiValidateHandler{db: db, authSvc: authSvc, proxyPool: proxyPool} +} + +// ── Gemini Business 验活常量 ────────────────────────────────────────────────── + +const ( + // geminiBusinessBase Gemini Business 的基础 URL + geminiBusinessBase = "https://business.gemini.google" +) + +// validateGeminiCredential 验证单个 Gemini Business 凭证 +// 通过带 Cookie 请求 Gemini Business 页面,根据响应状态判断账号是否有效 +// 返回 status: "valid" | "expired" | "invalid" | "error" +func validateGeminiCredential(ctx context.Context, proxyURL, cSes, cOses, csesidx, configID string) (string, error) { + jar, err := cookiejar.New(nil) + if err != nil { + return "error", fmt.Errorf("创建 CookieJar 失败: %w", err) + } + baseURL, _ := url.Parse(geminiBusinessBase) + + // 设置 Gemini Business 会话 Cookies + cookies := []*http.Cookie{ + {Name: "C_SES", Value: cSes, Domain: ".gemini.google", Path: "/", Secure: true, HttpOnly: true}, + } + if cOses != "" { + cookies = append(cookies, &http.Cookie{ + Name: "C_OSES", Value: cOses, Domain: ".gemini.google", Path: "/", Secure: true, HttpOnly: true, + }) + } + jar.SetCookies(baseURL, cookies) + + // 构建验证 URL + checkURL := geminiBusinessBase + "/" + if csesidx != "" { + checkURL += "?csesidx=" + url.QueryEscape(csesidx) + if configID != "" { + checkURL += "&cid=" + url.QueryEscape(configID) + } + } + + // 不跟随重定向,手动检测 Location + // 构建 Transport(支持代理) + var transport http.RoundTripper + if proxyURL != "" { + proxyClient, proxyErr := service.BuildProxyHTTPClient(proxyURL, 15*time.Second) + if proxyErr == nil && proxyClient.Transport != nil { + transport = proxyClient.Transport + } + } + client := &http.Client{ + Timeout: 15 * time.Second, + Jar: jar, + Transport: transport, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse // 不自动重定向 + }, + } + + req, err := http.NewRequestWithContext(ctx, "GET", checkURL, nil) + if err != nil { + return "error", fmt.Errorf("构建验证请求失败: %w", err) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36") + + resp, err := client.Do(req) + if err != nil { + return "error", fmt.Errorf("验证请求网络错误: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 4096)) + if err != nil { + return "error", fmt.Errorf("读取验证响应失败: %w", err) + } + bodyStr := string(bodyBytes) + + switch { + case resp.StatusCode == http.StatusTooManyRequests: + // 429 速率限制 → 返回 error 而非 invalid,避免误删有效账号 + return "error", fmt.Errorf("Google 速率限制 HTTP 429") + + case resp.StatusCode == http.StatusOK: + // 200 但可能是登录页面(检查页面内容) + if strings.Contains(bodyStr, "accounts.google.com/signin") || + strings.Contains(bodyStr, "accounts.google.com/ServiceLogin") { + return "expired", nil + } + return "valid", nil + + case resp.StatusCode == http.StatusFound || resp.StatusCode == http.StatusMovedPermanently || + resp.StatusCode == http.StatusTemporaryRedirect: + // 重定向到登录页 → 会话过期 + location := resp.Header.Get("Location") + if strings.Contains(location, "accounts.google.com") || + strings.Contains(location, "auth.") || + strings.Contains(location, "login") || + strings.Contains(location, "signin") { + return "expired", nil + } + // 重定向到其他 Gemini 页面(可能正常跳转) + return "valid", nil + + case resp.StatusCode == http.StatusForbidden: + // 403 → 账号被封禁或无权限 + if strings.Contains(bodyStr, "suspended") || strings.Contains(bodyStr, "banned") { + return "suspended", nil + } + return "invalid", nil + + case resp.StatusCode == http.StatusUnauthorized: + return "expired", nil + + case resp.StatusCode >= 500: + return "error", fmt.Errorf("Gemini 服务端错误 HTTP %d", resp.StatusCode) + + default: + return "invalid", fmt.Errorf("未知响应 HTTP %d: %s", resp.StatusCode, truncateStr(bodyStr, 120)) + } +} + +// SSEValidateGemini 验活 Gemini 账号 SSE 端点(GET /ws/gemini/validate?token=&action=archive|export_archive|validate&scope=active|archived) +func (h *GeminiValidateHandler) SSEValidateGemini(c *gin.Context) { + // 1. JWT 鉴权 + token := c.Query("token") + userID, err := h.authSvc.VerifyJWT(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录"}) + return + } + + // 2. 每用户并发锁——同一用户只能同时运行一个验活流 + userMu, _ := geminiValidateMu.LoadOrStore(userID, &sync.Mutex{}) + mu := userMu.(*sync.Mutex) + if !mu.TryLock() { + c.JSON(http.StatusConflict, gin.H{"detail": "已有验活任务进行中,请等待完成"}) + return + } + defer mu.Unlock() + + // 3. action 白名单校验 + action := c.DefaultQuery("action", "archive") + if action != "validate" && action != "archive" && action != "export_archive" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 action 参数"}) + return + } + + // 4. scope 参数 + scope := c.DefaultQuery("scope", "active") + if scope != "active" && scope != "archived" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 scope 参数"}) + return + } + isArchived := scope == "archived" + + // 5. 查询当前用户的 Gemini 账号(排除已禁用的) + var results []model.TaskResult + if err := h.db.Where("user_id = ? AND platform = ? AND is_archived = ? AND disabled = ?", userID, "gemini", isArchived, false). + Order("created_at ASC"). + Find(&results).Error; err != nil { + log.Error().Err(err).Uint("user_id", userID).Msg("Gemini 验活查询账号失败") + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询账号失败"}) + return + } + + if len(results) == 0 { + c.JSON(http.StatusOK, gin.H{"detail": "没有 Gemini 账号需要验证"}) + return + } + + // 6. SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + ctx := c.Request.Context() + total := len(results) + var validCount, invalidCount, errorCount int + var validResults []model.TaskResult + + // 构建验活用代理 URL(每批在循环内轮换代理) + // 此处仅做初始化检查,实际代理在每批循环中获取 + + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[*] 开始验证 %d 个 Gemini 账号...", total)}) + sseWrite(c, gin.H{"type": "progress", "validated": 0, "total": total, "valid": 0, "invalid": 0}) + c.Writer.Flush() + + // 7. 分批并发验证(每批 5 个,批次间 500ms,每批轮换代理) + const batchSize = 5 + for i := 0; i < total; i += batchSize { + select { + case <-ctx.Done(): + return + default: + } + + end := i + batchSize + if end > total { + end = total + } + batch := results[i:end] + + // 每批轮换代理,避免所有请求走同一个出口 + var batchProxyURL string + if p := h.proxyPool.GetNext(); p != nil { + batchProxyURL = p.HTTP + } + + type batchResult struct { + idx int + result model.TaskResult + status string + } + var mu sync.Mutex + var batchResults []batchResult + var wg sync.WaitGroup + + for j, r := range batch { + wg.Add(1) + go func(idx int, r model.TaskResult) { + defer wg.Done() + + // 解析 credential_data 提取 c_ses/c_oses/csesidx/config_id + var cred map[string]interface{} + if err := json.Unmarshal(r.CredentialData, &cred); err != nil { + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: "error"}) + mu.Unlock() + return + } + + cSes, _ := cred["c_ses"].(string) + cOses, _ := cred["c_oses"].(string) + csesidx, _ := cred["csesidx"].(string) + configID, _ := cred["config_id"].(string) + + if cSes == "" { + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: "invalid"}) + mu.Unlock() + return + } + + status, validateErr := validateGeminiCredential(ctx, batchProxyURL, cSes, cOses, csesidx, configID) + if validateErr != nil { + log.Warn().Err(validateErr).Str("email", r.Email).Str("status", status).Msg("Gemini 验活异常") + } + + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: status}) + mu.Unlock() + }(j, r) + } + wg.Wait() + + // 处理本批结果 + now := time.Now() + for _, br := range batchResults { + switch br.status { + case "valid": + validCount++ + validResults = append(validResults, br.result) + // 更新 last_validated_at + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Update("last_validated_at", now).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("更新 last_validated_at 失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[+] %s → 正常可用", br.result.Email)}) + case "suspended": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "已封禁", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用封禁账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → 已封禁,已禁用", br.result.Email)}) + case "expired": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "Cookie 过期", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用过期账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → Cookie 过期,已禁用", br.result.Email)}) + case "invalid": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "账号无效", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用无效账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → 账号无效,已禁用", br.result.Email)}) + case "error": + errorCount++ + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[!] %s → 检测异常(跳过),可能是 Google 临时故障", br.result.Email)}) + } + } + + validated := validCount + invalidCount + errorCount + sseWrite(c, gin.H{"type": "progress", "validated": validated, "total": total, "valid": validCount, "invalid": invalidCount}) + c.Writer.Flush() + + if end < total { + time.Sleep(500 * time.Millisecond) + } + } + + // 8. 归档有效账号(仅 scope=active 且 action=archive/export_archive 时) + archivedCount := 0 + if !isArchived && action != "validate" && len(validResults) > 0 { + var validIDs []uint + for _, r := range validResults { + validIDs = append(validIDs, r.ID) + } + if err := h.db.Model(&model.TaskResult{}). + Where("id IN ? AND user_id = ?", validIDs, userID). + Update("is_archived", true).Error; err != nil { + log.Error().Err(err).Msg("Gemini 验活归档更新失败") + } else { + archivedCount = len(validResults) + } + } + + // 9. complete 事件 + completeData := gin.H{ + "type": "complete", + "total": total, + "valid": validCount, + "invalid": invalidCount, + "error": errorCount, + "archived": archivedCount, + } + + if (action == "export_archive" || isArchived) && len(validResults) > 0 { + type exportItem struct { + ID uint `json:"id"` + Email string `json:"email"` + CredentialData json.RawMessage `json:"credential_data"` + } + creds := make([]exportItem, 0, len(validResults)) + for _, r := range validResults { + creds = append(creds, exportItem{ + ID: r.ID, + Email: r.Email, + CredentialData: json.RawMessage(r.CredentialData), + }) + } + completeData["credentials"] = creds + } + + logMsg := fmt.Sprintf( + "[+] 验证完成: 共 %d 个,正常 %d 个,失效 %d 个已禁用,异常跳过 %d 个", + total, validCount, invalidCount, errorCount, + ) + if archivedCount > 0 { + logMsg += fmt.Sprintf(",已归档 %d 个", archivedCount) + } + sseWrite(c, gin.H{"type": "log", "message": logMsg}) + sseWrite(c, completeData) + c.Writer.Flush() +} diff --git a/internal/handler/hf_space_handler.go b/internal/handler/hf_space_handler.go new file mode 100644 index 0000000..f0b0bc9 --- /dev/null +++ b/internal/handler/hf_space_handler.go @@ -0,0 +1,329 @@ +package handler + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/dto" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" +) + +// HFSpaceHandler HF Space 管理处理器 +type HFSpaceHandler struct { + svc *service.HFSpaceService +} + +// NewHFSpaceHandler 创建 HF Space 管理处理器 +func NewHFSpaceHandler(svc *service.HFSpaceService) *HFSpaceHandler { + return &HFSpaceHandler{svc: svc} +} + +// ListTokens 列出所有 Token(GET /api/admin/hf/tokens) +func (h *HFSpaceHandler) ListTokens(c *gin.Context) { + tokens, err := h.svc.ListTokens() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, tokens) +} + +// CreateToken 创建 Token(POST /api/admin/hf/tokens) +func (h *HFSpaceHandler) CreateToken(c *gin.Context) { + var req dto.CreateHFTokenReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + token, err := h.svc.CreateToken(req.Label, req.Token) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + // 脱敏返回 + if len(token.Token) > 8 { + token.Token = token.Token[:4] + "****" + token.Token[len(token.Token)-4:] + } + c.JSON(http.StatusOK, token) +} + +// DeleteToken 删除 Token(DELETE /api/admin/hf/tokens/:id) +func (h *HFSpaceHandler) DeleteToken(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 Token ID"}) + return + } + if err := h.svc.DeleteToken(uint(id)); err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// ValidateToken 验证 Token(POST /api/admin/hf/tokens/:id/validate) +func (h *HFSpaceHandler) ValidateToken(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 Token ID"}) + return + } + token, err := h.svc.ValidateToken(uint(id)) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + return + } + // 脱敏返回 + if len(token.Token) > 8 { + token.Token = token.Token[:4] + "****" + token.Token[len(token.Token)-4:] + } + c.JSON(http.StatusOK, token) +} + +// ValidateAllTokens 批量验证所有 Token(POST /api/admin/hf/tokens/validate-all) +func (h *HFSpaceHandler) ValidateAllTokens(c *gin.Context) { + tokens, err := h.svc.ValidateAllTokens() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + // 脱敏 + for i := range tokens { + if len(tokens[i].Token) > 8 { + tokens[i].Token = tokens[i].Token[:4] + "****" + tokens[i].Token[len(tokens[i].Token)-4:] + } + } + valid := 0 + for _, t := range tokens { + if t.IsValid { + valid++ + } + } + c.JSON(http.StatusOK, gin.H{"tokens": tokens, "total": len(tokens), "valid": valid, "invalid": len(tokens) - valid}) +} + +// PurgeBannedSpaces 批量清理被封 Space(POST /api/admin/hf/spaces/purge?service=xxx) +func (h *HFSpaceHandler) PurgeBannedSpaces(c *gin.Context) { + svc := c.Query("service") + deleted, checked, err := h.svc.PurgeBannedSpaces(svc) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "message": fmt.Sprintf("检查 %d 个 Space,删除 %d 个被封/失效的", checked, deleted), + "checked": checked, + "deleted": deleted, + }) +} + +// ListSpaces 列出 Space(GET /api/admin/hf/spaces?service=xxx&status=xxx&page=1&page_size=20) +func (h *HFSpaceHandler) ListSpaces(c *gin.Context) { + svc := c.Query("service") + status := c.Query("status") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + result, err := h.svc.ListSpaces(svc, page, pageSize, status) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +// AddSpace 手动添加 Space(POST /api/admin/hf/spaces) +func (h *HFSpaceHandler) AddSpace(c *gin.Context) { + var req dto.AddHFSpaceManualReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + space, err := h.svc.AddSpaceManual(req.Service, req.URL, req.RepoID, req.TokenID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, space) +} + +// DeleteSpace 删除 Space(DELETE /api/admin/hf/spaces/:id) +func (h *HFSpaceHandler) DeleteSpace(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 Space ID"}) + return + } + if err := h.svc.DeleteSpace(uint(id)); err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// CheckHealth 触发健康检查(POST /api/admin/hf/spaces/health?service=xxx) +func (h *HFSpaceHandler) CheckHealth(c *gin.Context) { + svc := c.Query("service") + results, err := h.svc.CheckHealth(svc) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"results": results, "total": len(results)}) +} + +// DeploySpaces 批量部署新 Space(POST /api/admin/hf/spaces/deploy) +func (h *HFSpaceHandler) DeploySpaces(c *gin.Context) { + var req dto.DeployHFSpaceReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + deployed, errors, err := h.svc.DeploySpaces(req.Service, req.Count, req.ReleaseURL, req.TokenID, req.Secrets) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "deployed": deployed, + "errors": errors, + "success": len(deployed), + "failed": len(errors), + }) +} + +// Autoscale 触发弹性管理(POST /api/admin/hf/autoscale) +// service=all 时遍历所有服务,聚合结果为单个对象返回 +func (h *HFSpaceHandler) Autoscale(c *gin.Context) { + var req dto.HFAutoscaleReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + services := []string{req.Service} + if req.Service == "all" { + services = []string{"openai", "grok", "kiro", "gemini", "ts"} + } + + // 聚合结果 + merged := &service.AutoscaleResult{ + Service: req.Service, + DryRun: req.DryRun, + Logs: make([]service.AutoscaleLog, 0), + } + + for _, svc := range services { + result, err := h.svc.Autoscale(svc, req.Target, req.DryRun) + if err != nil { + merged.Logs = append(merged.Logs, service.AutoscaleLog{ + Step: svc, Message: "失败: " + err.Error(), + }) + continue + } + merged.Before += result.Before + merged.After += result.After + merged.Created += result.Created + merged.Deleted += result.Deleted + merged.HealthyNow += result.HealthyNow + // 各服务日志加前缀 + for _, l := range result.Logs { + if len(services) > 1 { + l.Step = svc + "/" + l.Step + } + merged.Logs = append(merged.Logs, l) + } + } + + c.JSON(http.StatusOK, merged) +} + +// SyncCF 手动同步 CF Worker(POST /api/admin/hf/sync-cf?service=xxx) +func (h *HFSpaceHandler) SyncCF(c *gin.Context) { + svc := c.Query("service") + if svc == "" { + // 同步所有服务 + errors := make([]string, 0) + results := make([]service.SyncCFResult, 0) + for _, s := range []string{"openai", "grok", "kiro", "gemini", "ts"} { + r, err := h.svc.SyncCFWorker(s) + if err != nil { + errors = append(errors, s+": "+err.Error()) + } else if r != nil { + results = append(results, *r) + } + } + if len(errors) > 0 { + c.JSON(http.StatusOK, gin.H{"message": "部分同步失败", "errors": errors, "results": results}) + return + } + // CF 同步完成后自动调优调度参数 + h.svc.SyncPoolSize() + c.JSON(http.StatusOK, gin.H{"message": "所有服务 CF 环境变量已同步,调度参数已自动调优", "results": results}) + return + } + + r, err := h.svc.SyncCFWorker(svc) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + // CF 同步完成后自动调优调度参数 + h.svc.SyncPoolSize() + c.JSON(http.StatusOK, gin.H{"message": svc + " CF 环境变量已同步,调度参数已自动调优", "results": []service.SyncCFResult{*r}}) +} + +// Overview 各服务汇总统计(GET /api/admin/hf/overview) +func (h *HFSpaceHandler) Overview(c *gin.Context) { + items, err := h.svc.Overview() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, items) +} + +// Discover 自动发现 Space(POST /api/admin/hf/discover?default_service=openai) +// 遍历所有有效 Token,调 HF API 拉取账号下的 Space,自动入库 +func (h *HFSpaceHandler) Discover(c *gin.Context) { + defaultService := c.DefaultQuery("default_service", "") + result, err := h.svc.DiscoverSpaces(defaultService) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +// UpdateSpaces 批量更新已有 Space(POST /api/admin/hf/spaces/update) +// 对所有 Space 推送最新模板文件,触发 HF 重建 +func (h *HFSpaceHandler) UpdateSpaces(c *gin.Context) { + var req dto.UpdateHFSpacesReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + result, err := h.svc.UpdateSpaces(req.Service) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} + +// Redetect 对 unknown 的 Space 重新识别服务类型(POST /api/admin/hf/redetect) +func (h *HFSpaceHandler) Redetect(c *gin.Context) { + result, err := h.svc.RedetectService() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) + return + } + c.JSON(http.StatusOK, result) +} diff --git a/internal/handler/init_handler.go b/internal/handler/init_handler.go new file mode 100644 index 0000000..76c2559 --- /dev/null +++ b/internal/handler/init_handler.go @@ -0,0 +1,610 @@ +package handler + +import ( + "encoding/json" + "fmt" + "math" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/middleware" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// InitHandler 页面初始化批量接口,一次返回所有数据 +type InitHandler struct { + db *gorm.DB + creditSvc *service.CreditService + settingSvc *service.SettingService + engine *service.TaskEngine +} + +// NewInitHandler 创建初始化处理器 +func NewInitHandler(db *gorm.DB, creditSvc *service.CreditService, settingSvc *service.SettingService, engine *service.TaskEngine) *InitHandler { + return &InitHandler{db: db, creditSvc: creditSvc, settingSvc: settingSvc, engine: engine} +} + +// Init 页面初始化(GET /api/init) +// 并行查询用户、余额、当前任务、结果、交易记录、公告,一次返回 +func (h *InitHandler) Init(c *gin.Context) { + user := middleware.GetUser(c) + if user == nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录"}) + return + } + + var wg sync.WaitGroup + resp := make(map[string]interface{}) + var mu sync.Mutex + + // 1. 用户信息(直接从 middleware 获取,无需查库) + resp["user"] = gin.H{ + "id": user.ID, + "newapi_id": user.NewapiID, + "username": user.Username, + "name": user.Name, + "email": user.Email, + "avatar_url": user.AvatarURL, + "trust_level": user.TrustLevel, + "role": user.Role, + "credits": user.Credits, + "newapi_quota": user.NewapiQuota, + "free_trial_used": user.FreeTrialUsed, + "free_trial_remaining": user.FreeTrialRemaining, + "is_admin": user.IsAdmin, + } + + // 2. 余额(并行) + wg.Add(1) + go func() { + defer wg.Done() + credits, freeRemaining, freeUsed := h.creditSvc.GetBalance(user.ID) + maxTarget, _ := strconv.Atoi(h.settingSvc.Get("max_target", "1000")) + maxThreads, _ := strconv.Atoi(h.settingSvc.Get("max_threads", "16")) + dailyLimit, _ := strconv.Atoi(h.settingSvc.Get("daily_reg_limit", "0")) + + resetTime := dailyResetTime() + var dailyUsed int64 + h.db.Model(&model.Task{}). + Where("user_id = ? AND created_at >= ?", user.ID, resetTime). + Select("COALESCE(SUM(success_count), 0)").Scan(&dailyUsed) + + dailyRemaining := -1 + if dailyLimit > 0 { + dailyRemaining = dailyLimit - int(dailyUsed) + if dailyRemaining < 0 { + dailyRemaining = 0 + } + } + + registrationsAvailable := credits + freeRemaining + + freeTrialCount := h.settingSvc.GetInt("free_trial_count", 2) + freeTrialEnabled := h.settingSvc.Get("free_trial_enabled", "true") == "true" + trialEligible := !freeUsed && freeTrialEnabled && freeTrialCount > 0 + trialRemaining := 0 + trialTotal := 0 + if freeUsed { + trialRemaining = freeRemaining + } else if trialEligible { + trialRemaining = freeTrialCount + trialTotal = freeTrialCount + } + + // New-API 余额(实时查询) + var newapiBalance float64 + var newapiBalanceDisplay string + var unitPrice float64 + var unitPriceDisplay string + var newapiAvailable int + mode := "local" + display := fmt.Sprintf("%d 次", registrationsAvailable) + + costStr := h.settingSvc.Get("newapi_cost_per_reg", "0.004") + costUSD, _ := strconv.ParseFloat(costStr, 64) + if costUSD <= 0 { + costUSD = 0.004 + } + + baseURL := h.settingSvc.Get("newapi_base_url", "") + if baseURL != "" && user.NewapiID != 0 { + mode = "newapi" + unitPrice = costUSD + unitPriceDisplay = fmt.Sprintf("$%.4g", costUSD) + + quotaRaw, balUSD, err := h.creditSvc.GetNewAPIBalance(user) + if err == nil { + newapiBalance = balUSD + newapiBalanceDisplay = fmt.Sprintf("$%.2f", balUSD) + newapiAvailable = int(math.Floor(float64(quotaRaw) / (costUSD * 500000))) + display = fmt.Sprintf("$%.2f", balUSD) + } else { + newapiBalanceDisplay = "$0" + display = fmt.Sprintf("%d 次", registrationsAvailable) + } + } + + mu.Lock() + resp["balance"] = gin.H{ + "credits": credits, + "free_trial_remaining": freeRemaining, + "free_trial_used": freeUsed, + "cost_per_reg": 1, + "mode": mode, + "display": display, + "registrations_available": registrationsAvailable, + "newapi_balance": newapiBalance, + "newapi_balance_display": newapiBalanceDisplay, + "unit_price": unitPrice, + "unit_price_display": unitPriceDisplay, + "newapi_available": newapiAvailable, + "platform_prices": buildInitPlatformPrices(h.settingSvc, costUSD), + "free_trial": gin.H{ + "eligible": trialEligible, + "remaining": trialRemaining, + "total": trialTotal, + }, + "limits": gin.H{ + "max_target": maxTarget, + "max_threads": maxThreads, + "daily_reg_limit": dailyLimit, + "daily_used": int(dailyUsed), + "daily_remaining": dailyRemaining, + "platform_grok_enabled": h.settingSvc.Get("platform_grok_enabled", "true") == "true", + "platform_openai_enabled": h.settingSvc.Get("platform_openai_enabled", "true") == "true", + "platform_kiro_enabled": h.settingSvc.Get("platform_kiro_enabled", "true") == "true", + "platform_gemini_enabled": h.settingSvc.Get("platform_gemini_enabled", "true") == "true", + "thread_tiers": h.settingSvc.Get("thread_tiers", ""), + "platform_grok_free_until": h.settingSvc.Get("platform_grok_free_until", ""), + "platform_openai_free_until": h.settingSvc.Get("platform_openai_free_until", ""), + "platform_kiro_free_until": h.settingSvc.Get("platform_kiro_free_until", ""), + "platform_gemini_free_until": h.settingSvc.Get("platform_gemini_free_until", ""), + "free_mode": buildFreeModeStatus(h.db, h.settingSvc, user.ID), + "platform_limits": buildPlatformLimits(h.db, h.settingSvc, user.ID), + }, + } + mu.Unlock() + }() + + // 3. 当前任务(并行) + wg.Add(1) + go func() { + defer wg.Done() + var task model.Task + err := h.db.Where("user_id = ? AND status IN ?", user.ID, []string{"running", "queued", "pending"}). + Order("created_at DESC").First(&task).Error + if err != nil { + // 没有活跃任务,尝试取最近一条已结束的任务(保留顶栏计数) + err2 := h.db.Where("user_id = ?", user.ID). + Order("created_at DESC").First(&task).Error + if err2 != nil { + mu.Lock() + resp["current_task"] = nil + mu.Unlock() + return + } + } + // 检查孤儿任务(事务保护,防止并发双重退款) + if task.Status == "running" && h.engine.GetStatus(user.ID, task.Platform) == nil { + h.db.Transaction(func(tx *gorm.DB) error { + var fresh model.Task + if err := tx.Where("id = ? AND status = ?", task.ID, "running").First(&fresh).Error; err != nil { + return err + } + now := time.Now() + fresh.Status = "stopped" + fresh.StoppedAt = &now + tx.Save(&fresh) + refund := fresh.CreditsReserved - fresh.SuccessCount + if refund > 0 { + h.creditSvc.RefundCredits(tx, user.ID, fresh.ID, refund) + } + task = fresh + return nil + }) + } + // 清理孤儿排队任务:超过 10 分钟仍未调度的 queued/pending 视为孤儿 + if (task.Status == "queued" || task.Status == "pending") && time.Since(task.CreatedAt) > 10*time.Minute { + h.db.Transaction(func(tx *gorm.DB) error { + var fresh model.Task + if err := tx.Where("id = ? AND status IN ?", task.ID, []string{"queued", "pending"}).First(&fresh).Error; err != nil { + return err + } + now := time.Now() + fresh.Status = "stopped" + fresh.StoppedAt = &now + tx.Save(&fresh) + refund := fresh.CreditsReserved - fresh.SuccessCount + if refund > 0 { + h.creditSvc.RefundCredits(tx, user.ID, fresh.ID, refund) + } + task = fresh + return nil + }) + } + mu.Lock() + resp["current_task"] = gin.H{ + "task_id": task.ID, + "platform": task.Platform, + "target": task.TargetCount, + "threads": task.ThreadCount, + "credits_reserved": task.CreditsReserved, + "success_count": task.SuccessCount, + "fail_count": task.FailCount, + "status": task.Status, + "is_done": task.IsDone(), + } + mu.Unlock() + }() + + // 4. 注册结果(并行) + wg.Add(1) + go func() { + defer wg.Done() + var results []model.TaskResult + // 最多加载 200 条未归档结果,避免全量拉取阻塞首屏 + h.db.Where("user_id = ? AND is_archived = ?", user.ID, false). + Order("created_at DESC").Limit(200).Find(&results) + + type resultItem struct { + ID uint `json:"id"` + Email string `json:"email"` + Platform string `json:"platform"` + CredentialData json.RawMessage `json:"credential_data"` + SSOToken string `json:"auth_token,omitempty"` + NsfwEnabled bool `json:"feature_enabled"` + CreatedAt string `json:"created_at"` + } + items := make([]resultItem, 0, len(results)) + for _, r := range results { + items = append(items, resultItem{ + ID: r.ID, + Email: r.Email, + Platform: r.Platform, + CredentialData: json.RawMessage(r.CredentialData), + SSOToken: r.SSOToken, + NsfwEnabled: r.NsfwEnabled, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + }) + } + mu.Lock() + resp["results"] = items + mu.Unlock() + }() + + // 5. 交易记录(并行) + wg.Add(1) + go func() { + defer wg.Done() + var txs []model.CreditTransaction + h.db.Where("user_id = ?", user.ID). + Order("created_at DESC").Limit(50).Find(&txs) + mu.Lock() + resp["tx_history"] = txs + mu.Unlock() + }() + + // 6. 公告(并行) + wg.Add(1) + go func() { + defer wg.Done() + var list []model.Announcement + h.db.Order("created_at DESC").Limit(20).Find(&list) + mu.Lock() + resp["announcements"] = list + mu.Unlock() + }() + + // 7. 全局统计(并行) + wg.Add(1) + go func() { + defer wg.Done() + stats := h.buildGlobalStats() + mu.Lock() + resp["global_stats"] = stats + mu.Unlock() + }() + + // 8. 用户总成功/失败统计 + 各平台平均耗时(并行) + wg.Add(1) + go func() { + defer wg.Done() + var totalSuccess, totalFail int64 + h.db.Model(&model.Task{}).Where("user_id = ?", user.ID). + Select("COALESCE(SUM(success_count), 0)").Scan(&totalSuccess) + h.db.Model(&model.Task{}).Where("user_id = ?", user.ID). + Select("COALESCE(SUM(fail_count), 0)").Scan(&totalFail) + + // 各平台成功/失败统计 + type platformStatRow struct { + Platform string `json:"platform"` + Success int64 `json:"success"` + Fail int64 `json:"fail"` + } + var platformRows []platformStatRow + h.db.Model(&model.Task{}).Where("user_id = ?", user.ID). + Select("platform, COALESCE(SUM(success_count), 0) as success, COALESCE(SUM(fail_count), 0) as fail"). + Group("platform").Scan(&platformRows) + platformMap := map[string]gin.H{} + for _, pr := range platformRows { + platformMap[pr.Platform] = gin.H{"success": pr.Success, "fail": pr.Fail} + } + + // 各平台平均每个注册耗时(秒),取最近30个已完成任务 + type avgRow struct { + Platform string `json:"platform"` + AvgSecPer float64 `json:"avg_sec_per"` + } + var rows []avgRow + h.db.Raw(` + SELECT platform, + AVG( + EXTRACT(EPOCH FROM stopped_at - started_at) + / NULLIF(success_count, 0) + ) AS avg_sec_per + FROM tasks + WHERE success_count > 0 AND started_at IS NOT NULL AND stopped_at IS NOT NULL + AND status IN ('completed','stopped') + GROUP BY platform + `).Scan(&rows) + avgMap := map[string]float64{} + for _, r := range rows { + if r.AvgSecPer > 0 { + avgMap[r.Platform] = r.AvgSecPer + } + } + + mu.Lock() + resp["user_stats"] = gin.H{ + "total_success": totalSuccess, + "total_fail": totalFail, + "avg_sec_per_reg": avgMap, + "by_platform": platformMap, + } + mu.Unlock() + }() + + // 9. 最近完成的注册(公共展示,并行) + wg.Add(1) + go func() { + defer wg.Done() + items := h.buildRecentCompletions() + mu.Lock() + resp["recent_completions"] = items + mu.Unlock() + }() + + wg.Wait() + c.JSON(http.StatusOK, resp) +} + +// GlobalStats 全局统计(GET /api/stats/global) +// 面向所有已登录用户,用于顶栏全局大屏 +func (h *InitHandler) GlobalStats(c *gin.Context) { + c.JSON(http.StatusOK, h.buildGlobalStats()) +} + +// LatestCompletions 最新完成的任务(GET /api/stats/latest-completions?after=) +// 供首页 toast 轮询,返回 after 之后完成的任务(全局,所有用户) +func (h *InitHandler) LatestCompletions(c *gin.Context) { + afterStr := c.Query("after") + after := time.Now().Add(-10 * time.Second) // 默认最近 10 秒 + if afterStr != "" { + if ts, err := strconv.ParseInt(afterStr, 10, 64); err == nil { + after = time.Unix(ts, 0) + } + } + // 上下界保护:最多回溯 5 分钟,不接受未来时间戳 + minAfter := time.Now().Add(-5 * time.Minute) + if after.Before(minAfter) { + after = minAfter + } + if after.After(time.Now()) { + after = time.Now().Add(-10 * time.Second) + } + + var tasks []model.Task + h.db.Where("status IN ? AND stopped_at > ? AND success_count > 0", + []string{"completed", "stopped"}, after). + Order("stopped_at DESC").Limit(5).Find(&tasks) + + // 批量查用户信息(昵称 + 头像) + userIDs := make([]uint, 0, len(tasks)) + for _, t := range tasks { + userIDs = append(userIDs, t.UserID) + } + type userBrief struct { + ID uint + Username string + Name string + AvatarURL string + } + userBriefMap := make(map[uint]userBrief) + if len(userIDs) > 0 { + var users []model.User + h.db.Where("id IN ?", userIDs).Select("id, username, name, avatar_url").Find(&users) + for _, u := range users { + userBriefMap[u.ID] = userBrief{ID: u.ID, Username: u.Username, Name: u.Name, AvatarURL: u.AvatarURL} + } + } + + type completionItem struct { + TaskID uint `json:"task_id"` + Username string `json:"username"` + Name string `json:"name"` + AvatarURL string `json:"avatar_url"` + Platform string `json:"platform"` + Success int `json:"success_count"` + StoppedAt int64 `json:"stopped_at"` + } + items := make([]completionItem, 0, len(tasks)) + for _, t := range tasks { + if t.StoppedAt == nil { + continue + } + ub := userBriefMap[t.UserID] + items = append(items, completionItem{ + TaskID: t.ID, + Username: ub.Username, + Name: ub.Name, + AvatarURL: ub.AvatarURL, + Platform: t.Platform, + Success: t.SuccessCount, + StoppedAt: t.StoppedAt.Unix(), + }) + } + + c.JSON(http.StatusOK, gin.H{"items": items}) +} + +// buildGlobalStats 构建全局统计数据 +func (h *InitHandler) buildGlobalStats() gin.H { + resetTime := dailyResetTime() + + // 运行中任务数 + var runningTasks int64 + h.db.Model(&model.Task{}).Where("status = ?", "running").Count(&runningTasks) + + // 排队中任务数 + var queuedTasks int64 + h.db.Model(&model.Task{}).Where("status = ?", "queued").Count(&queuedTasks) + + // 活跃用户数(有运行中/排队中任务的独立用户) + var activeUsers int64 + h.db.Model(&model.Task{}).Where("status IN ?", []string{"running", "queued"}).Distinct("user_id").Count(&activeUsers) + + // 今日各平台注册数 + type platformStat struct { + Platform string `json:"platform"` + Count int64 `json:"count"` + } + var todayStats []platformStat + h.db.Model(&model.TaskResult{}). + Where("created_at >= ?", resetTime). + Select("platform, COUNT(*) as count"). + Group("platform"). + Scan(&todayStats) + todayTotal := int64(0) + todayPlatforms := map[string]int64{} + for _, ps := range todayStats { + todayPlatforms[ps.Platform] = ps.Count + todayTotal += ps.Count + } + + // 今日成功率 + var todaySuccess, todayFail int64 + h.db.Model(&model.Task{}).Where("created_at >= ?", resetTime). + Select("COALESCE(SUM(success_count), 0)").Scan(&todaySuccess) + h.db.Model(&model.Task{}).Where("created_at >= ?", resetTime). + Select("COALESCE(SUM(fail_count), 0)").Scan(&todayFail) + var successRate float64 + if todaySuccess+todayFail > 0 { + successRate = float64(todaySuccess) / float64(todaySuccess+todayFail) * 100 + } + + // 总注册数 + var totalResults int64 + h.db.Model(&model.TaskResult{}).Count(&totalResults) + + return gin.H{ + "running_tasks": runningTasks, + "queued_tasks": queuedTasks, + "active_users": activeUsers, + "today_total": todayTotal, + "today_platforms": todayPlatforms, + "today_success": todaySuccess, + "today_fail": todayFail, + "success_rate": successRate, + "total_results": totalResults, + } +} + +// buildRecentCompletions 构建最近完成的注册列表(全站公共展示) +// 每用户只展示最新一条,按完成时间倒序,最多 10 条 +func (h *InitHandler) buildRecentCompletions() []gin.H { + var tasks []model.Task + h.db.Raw(` + SELECT * FROM ( + SELECT DISTINCT ON (user_id) * + FROM tasks + WHERE status IN ('completed', 'stopped') + AND success_count > 0 + AND stopped_at IS NOT NULL + ORDER BY user_id, stopped_at DESC + ) sub + ORDER BY stopped_at DESC + LIMIT 10 + `).Scan(&tasks) + + userIDs := make([]uint, 0, len(tasks)) + for _, t := range tasks { + userIDs = append(userIDs, t.UserID) + } + type userInfo struct { + ID uint + Username string + Name string + AvatarURL string + } + userInfoMap := make(map[uint]userInfo) + if len(userIDs) > 0 { + var users []model.User + h.db.Where("id IN ?", userIDs).Select("id, username, name, avatar_url").Find(&users) + for _, u := range users { + userInfoMap[u.ID] = userInfo{ID: u.ID, Username: u.Username, Name: u.Name, AvatarURL: u.AvatarURL} + } + } + + items := make([]gin.H, 0, len(tasks)) + for _, t := range tasks { + ts := int64(0) + if t.StoppedAt != nil { + ts = t.StoppedAt.Unix() + } + durationSec := 0 + if t.StartedAt != nil && t.StoppedAt != nil { + durationSec = int(t.StoppedAt.Sub(*t.StartedAt).Seconds()) + } + ui := userInfoMap[t.UserID] + items = append(items, gin.H{ + "task_id": t.ID, + "username": ui.Username, + "name": ui.Name, + "avatar_url": ui.AvatarURL, + "platform": t.Platform, + "success_count": t.SuccessCount, + "target_count": t.TargetCount, + "duration_sec": durationSec, + "stopped_at": ts, + }) + } + return items +} + +// RecentCompletions 最近完成的注册(GET /api/stats/recent-completions) +// 全站公共展示,返回最新 10 条成功的注册任务 +func (h *InitHandler) RecentCompletions(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"items": h.buildRecentCompletions()}) +} + +// buildInitPlatformPrices 构建各平台单价映射(Init 接口用) +func buildInitPlatformPrices(settingSvc *service.SettingService, globalCost float64) map[string]float64 { + prices := map[string]float64{} + for _, p := range []string{"grok", "openai", "kiro", "gemini"} { + key := "platform_" + p + "_unit_price" + val := settingSvc.Get(key, "") + if val != "" { + if parsed, err := strconv.ParseFloat(val, 64); err == nil && parsed > 0 { + prices[p] = parsed + continue + } + } + prices[p] = globalCost + } + return prices +} diff --git a/internal/handler/kiro_validate.go b/internal/handler/kiro_validate.go new file mode 100644 index 0000000..f7eab0b --- /dev/null +++ b/internal/handler/kiro_validate.go @@ -0,0 +1,432 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// kiroValidateMu 每用户验活并发锁,防止同一用户同时触发多个验活流 +var kiroValidateMu sync.Map // key: userID (uint), value: *sync.Mutex + +// KiroValidateHandler Kiro 账号验活处理器 +type KiroValidateHandler struct { + db *gorm.DB + authSvc *service.AuthService + proxyPool *service.ProxyPool +} + +// NewKiroValidateHandler 创建 Kiro 验活处理器 +func NewKiroValidateHandler(db *gorm.DB, authSvc *service.AuthService, proxyPool *service.ProxyPool) *KiroValidateHandler { + return &KiroValidateHandler{db: db, authSvc: authSvc, proxyPool: proxyPool} +} + +// ── AWS OIDC 常量 ────────────────────────────────────────────────── + +const ( + oidcTokenURL = "https://oidc.us-east-1.amazonaws.com/token" + qUsageLimitURL = "https://q.us-east-1.amazonaws.com/getUsageLimits?origin=AI_EDITOR&resourceType=AGENTIC_REQUEST" +) + +// oidcHeaders 模拟 AWS SDK Rust 的请求头 +func oidcHeaders() http.Header { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set("User-Agent", "aws-sdk-rust/1.3.9 os/windows lang/rust/1.87.0") + h.Set("x-amz-user-agent", "aws-sdk-rust/1.3.9 ua/2.1 api/ssooidc/1.88.0 os/windows lang/rust/1.87.0 m/E app/AmazonQ-For-CLI") + h.Set("amz-sdk-request", "attempt=1; max=3") + h.Set("amz-sdk-invocation-id", uuid.New().String()) + return h +} + +// qAPIHeaders Q API 验证请求头 +func qAPIHeaders(accessToken string) http.Header { + h := http.Header{} + h.Set("content-type", "application/x-amz-json-1.0") + h.Set("authorization", "Bearer "+accessToken) + h.Set("user-agent", "aws-sdk-rust/1.3.9 ua/2.1 api/codewhispererstreaming/0.1.11582 os/windows lang/rust/1.87.0 md/appVersion-1.19.4 app/AmazonQ-For-CLI") + h.Set("x-amz-user-agent", "aws-sdk-rust/1.3.9 ua/2.1 api/codewhispererstreaming/0.1.11582 os/windows lang/rust/1.87.0 m/F app/AmazonQ-For-CLI") + h.Set("x-amzn-codewhisperer-optout", "false") + h.Set("amz-sdk-request", "attempt=1; max=3") + h.Set("amz-sdk-invocation-id", uuid.New().String()) + return h +} + +// containsAny 检查 s 是否包含 subs 中的任一子串 +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} + +// truncateStr 按 rune 截断字符串,避免切断多字节字符 +func truncateStr(s string, maxRunes int) string { + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + return string(runes[:maxRunes]) + "..." +} + +// sseWrite 写入一条 SSE 事件 +func sseWrite(c *gin.Context, data interface{}) { + jsonBytes, err := json.Marshal(data) + if err != nil { + log.Warn().Err(err).Msg("SSE JSON 序列化失败") + return + } + if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", jsonBytes); err != nil { + log.Debug().Err(err).Msg("SSE 写入失败,客户端可能已断开") + } +} + +// validateKiroCredential 验证单个 Kiro 凭证 +// 返回 status: "valid" | "suspended" | "expired" | "invalid" | "error" +func validateKiroCredential(ctx context.Context, httpClient *http.Client, clientID, clientSecret, refreshToken string) (string, error) { + + // ── Step1: 刷新 access_token ── + refreshBody, _ := json.Marshal(map[string]string{ + "clientId": clientID, + "clientSecret": clientSecret, + "refreshToken": refreshToken, + "grantType": "refresh_token", + }) + + req, err := http.NewRequestWithContext(ctx, "POST", oidcTokenURL, bytes.NewReader(refreshBody)) + if err != nil { + return "error", fmt.Errorf("构建刷新请求失败: %w", err) + } + req.Header = oidcHeaders() + + resp, err := httpClient.Do(req) + if err != nil { + return "error", fmt.Errorf("刷新请求网络错误: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "error", fmt.Errorf("读取刷新响应失败: %w", err) + } + bodyStr := string(bodyBytes) + + if resp.StatusCode != http.StatusOK { + // refresh token 过期或无效 + if containsAny(bodyStr, "invalid_grant", "InvalidGrantException") { + return "expired", nil + } + return "error", fmt.Errorf("刷新失败 HTTP %d: %s", resp.StatusCode, truncateStr(bodyStr, 120)) + } + + // 解析 accessToken + var tokenResp struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil || tokenResp.AccessToken == "" { + return "error", fmt.Errorf("解析 accessToken 失败") + } + + // ── Step2: 用 accessToken 调 Q API 检测状态 ── + qReq, err := http.NewRequestWithContext(ctx, "GET", qUsageLimitURL, nil) + if err != nil { + return "error", fmt.Errorf("构建 Q API 请求失败: %w", err) + } + qReq.Header = qAPIHeaders(tokenResp.AccessToken) + + qResp, err := httpClient.Do(qReq) + if err != nil { + return "error", fmt.Errorf("Q API 网络错误: %w", err) + } + defer qResp.Body.Close() + + qBody, err := io.ReadAll(qResp.Body) + if err != nil { + return "error", fmt.Errorf("读取 Q API 响应失败: %w", err) + } + qStr := string(qBody) + + // 判断状态(按优先级排序) + if containsAny(qStr, "TEMPORARILY_SUSPENDED") { + return "suspended", nil + } + if qResp.StatusCode == http.StatusUnauthorized || containsAny(qStr, "ExpiredToken", "UnauthorizedException") { + return "expired", nil + } + if containsAny(qStr, "AccessDeniedException", "ValidationException", "ResourceNotFoundException") { + return "invalid", nil + } + if qResp.StatusCode == http.StatusForbidden { + return "invalid", nil + } + if qResp.StatusCode >= 500 { + return "error", fmt.Errorf("AWS 服务端错误 HTTP %d", qResp.StatusCode) + } + if qResp.StatusCode == http.StatusOK { + return "valid", nil + } + + return "invalid", fmt.Errorf("未知响应 HTTP %d: %s", qResp.StatusCode, truncateStr(qStr, 120)) +} + +// SSEValidateKiro 验活 Kiro 账号 SSE 端点(GET /ws/kiro/validate?token=&action=archive|export_archive) +func (h *KiroValidateHandler) SSEValidateKiro(c *gin.Context) { + // 1. JWT 鉴权(SSE 无法自定义 Header,通过 query token 鉴权) + token := c.Query("token") + userID, err := h.authSvc.VerifyJWT(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录"}) + return + } + + // 2. 每用户并发锁——同一用户只能同时运行一个验活流 + userMu, _ := kiroValidateMu.LoadOrStore(userID, &sync.Mutex{}) + kmu := userMu.(*sync.Mutex) + if !kmu.TryLock() { + c.JSON(http.StatusConflict, gin.H{"detail": "已有验活任务进行中,请等待完成"}) + return + } + defer kmu.Unlock() + + // 3. action 白名单校验 + action := c.DefaultQuery("action", "archive") + if action != "validate" && action != "archive" && action != "export_archive" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 action 参数"}) + return + } + + // 4. scope 参数:active(未归档)/ archived(已归档) + scope := c.DefaultQuery("scope", "active") + if scope != "active" && scope != "archived" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 scope 参数"}) + return + } + isArchived := scope == "archived" + + // 5. 查询当前用户的 Kiro 账号(排除已禁用的) + var results []model.TaskResult + if err := h.db.Where("user_id = ? AND platform = ? AND is_archived = ? AND disabled = ?", userID, "kiro", isArchived, false). + Order("created_at ASC"). + Find(&results).Error; err != nil { + log.Error().Err(err).Uint("user_id", userID).Msg("Kiro 验活查询账号失败") + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询账号失败"}) + return + } + + if len(results) == 0 { + c.JSON(http.StatusOK, gin.H{"detail": "没有 Kiro 账号需要验证"}) + return + } + + // 6. SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + ctx := c.Request.Context() + total := len(results) + var validCount, invalidCount, errorCount int + var validResults []model.TaskResult + + // 每批在循环内轮换代理,此处不再预取 + + // 发送初始日志 + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[*] 开始验证 %d 个 Kiro 账号...", total)}) + sseWrite(c, gin.H{"type": "progress", "validated": 0, "total": total, "valid": 0, "invalid": 0}) + c.Writer.Flush() + + // 7. 分批并发验证(每批 5 个,批次间 500ms,每批轮换代理) + const batchSize = 5 + for i := 0; i < total; i += batchSize { + // 检查客户端是否断开 + select { + case <-ctx.Done(): + return + default: + } + + end := i + batchSize + if end > total { + end = total + } + batch := results[i:end] + + // 每批轮换代理,避免所有请求走同一个出口 + var batchProxyURL string + if p := h.proxyPool.GetNext(); p != nil { + batchProxyURL = p.HTTP + } + batchClient, batchErr := service.BuildProxyHTTPClient(batchProxyURL, 15*time.Second) + if batchErr != nil { + log.Warn().Err(batchErr).Msg("构建批次代理客户端失败,回退直连") + batchClient = &http.Client{Timeout: 15 * time.Second} + } + + type batchResult struct { + idx int + result model.TaskResult + status string + } + var mu sync.Mutex + var batchResults []batchResult + var wg sync.WaitGroup + + for j, r := range batch { + wg.Add(1) + go func(idx int, r model.TaskResult) { + defer wg.Done() + + // 解析 credential_data 提取 clientId/clientSecret/refreshToken + var cred map[string]interface{} + if err := json.Unmarshal(r.CredentialData, &cred); err != nil { + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: "error"}) + mu.Unlock() + return + } + + clientID, _ := cred["clientId"].(string) + clientSecret, _ := cred["clientSecret"].(string) + refreshToken, _ := cred["refreshToken"].(string) + + if clientID == "" || clientSecret == "" || refreshToken == "" { + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: "invalid"}) + mu.Unlock() + return + } + + status, validateErr := validateKiroCredential(ctx, batchClient, clientID, clientSecret, refreshToken) + if validateErr != nil { + log.Warn().Err(validateErr).Str("email", r.Email).Str("status", status).Msg("Kiro 验活异常") + } + + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: status}) + mu.Unlock() + }(j, r) + } + wg.Wait() + + // 处理本批结果 + now := time.Now() + for _, br := range batchResults { + switch br.status { + case "valid": + validCount++ + validResults = append(validResults, br.result) + // 更新 last_validated_at + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Update("last_validated_at", now).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("更新 last_validated_at 失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[+] %s → 正常可用", br.result.Email)}) + case "suspended": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "SUSPENDED(已封禁)", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用封禁账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → 已封禁(SUSPENDED),已禁用", br.result.Email)}) + case "expired": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "Token 过期", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用过期账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → Token 过期,已禁用", br.result.Email)}) + case "invalid": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "账号无效", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用无效账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → 账号无效,已禁用", br.result.Email)}) + case "error": + errorCount++ + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[!] %s → 检测异常(跳过),可能是 AWS 临时故障", br.result.Email)}) + } + } + + // 发送进度(已处理总数包含 error 的) + validated := validCount + invalidCount + errorCount + sseWrite(c, gin.H{"type": "progress", "validated": validated, "total": total, "valid": validCount, "invalid": invalidCount}) + c.Writer.Flush() + + // 批次间间隔(最后一批不需要) + if end < total { + time.Sleep(500 * time.Millisecond) + } + } + + // 8. 归档有效账号(仅 scope=active 且 action=archive/export_archive 时执行) + archivedCount := 0 + if !isArchived && action != "validate" && len(validResults) > 0 { + var validIDs []uint + for _, r := range validResults { + validIDs = append(validIDs, r.ID) + } + if err := h.db.Model(&model.TaskResult{}). + Where("id IN ? AND user_id = ?", validIDs, userID). + Update("is_archived", true).Error; err != nil { + log.Error().Err(err).Msg("Kiro 验活归档更新失败") + } else { + archivedCount = len(validResults) + } + } + + // 9. 构建 complete 事件 + completeData := gin.H{ + "type": "complete", + "total": total, + "valid": validCount, + "invalid": invalidCount, + "error": errorCount, + "archived": archivedCount, + } + + // 附带有效账号的 credential_data:export_archive 模式 或 scope=archived 时 + if (action == "export_archive" || isArchived) && len(validResults) > 0 { + type exportItem struct { + ID uint `json:"id"` + Email string `json:"email"` + CredentialData json.RawMessage `json:"credential_data"` + } + creds := make([]exportItem, 0, len(validResults)) + for _, r := range validResults { + creds = append(creds, exportItem{ + ID: r.ID, + Email: r.Email, + CredentialData: json.RawMessage(r.CredentialData), + }) + } + completeData["credentials"] = creds + } + + logMsg := fmt.Sprintf( + "[+] 验证完成: 共 %d 个,正常 %d 个,失效 %d 个已禁用,异常跳过 %d 个", + total, validCount, invalidCount, errorCount, + ) + if archivedCount > 0 { + logMsg += fmt.Sprintf(",已归档 %d 个", archivedCount) + } + sseWrite(c, gin.H{"type": "log", "message": logMsg}) + sseWrite(c, completeData) + c.Writer.Flush() +} diff --git a/internal/handler/proxy_handler.go b/internal/handler/proxy_handler.go new file mode 100644 index 0000000..df90ff8 --- /dev/null +++ b/internal/handler/proxy_handler.go @@ -0,0 +1,242 @@ +package handler + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/middleware" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "golang.org/x/net/proxy" + "gorm.io/gorm" +) + +// ProxyHandler 用户代理管理 +type ProxyHandler struct { + db *gorm.DB +} + +// NewProxyHandler 创建代理处理器 +func NewProxyHandler(db *gorm.DB) *ProxyHandler { + return &ProxyHandler{db: db} +} + +type createProxyReq struct { + Name string `json:"name" binding:"max=100"` + Protocol string `json:"protocol" binding:"required,oneof=http https socks5"` + Host string `json:"host" binding:"required,max=200"` + Port int `json:"port" binding:"required,min=1,max=65535"` + Username string `json:"username" binding:"max=100"` + Password string `json:"password" binding:"max=200"` +} + +// List 获取当前用户的代理列表(GET /api/proxies) +func (h *ProxyHandler) List(c *gin.Context) { + user := middleware.GetUser(c) + var proxies []model.UserProxy + h.db.Where("user_id = ?", user.ID).Order("created_at DESC").Find(&proxies) + // 脱敏密码 + for i := range proxies { + if proxies[i].Password != "" { + proxies[i].Password = "******" + } + } + c.JSON(http.StatusOK, proxies) +} + +// Create 添加代理(POST /api/proxies) +func (h *ProxyHandler) Create(c *gin.Context) { + user := middleware.GetUser(c) + var req createProxyReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // 限制每用户最多 20 个代理 + var count int64 + h.db.Model(&model.UserProxy{}).Where("user_id = ?", user.ID).Count(&count) + if count >= 20 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "最多保存 20 个代理"}) + return + } + + p := model.UserProxy{ + UserID: user.ID, + Name: req.Name, + Protocol: req.Protocol, + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: req.Password, + } + if err := h.db.Create(&p).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "保存失败"}) + return + } + if p.Password != "" { + p.Password = "******" + } + c.JSON(http.StatusOK, p) +} + +// Update 更新代理(PUT /api/proxies/:id) +func (h *ProxyHandler) Update(c *gin.Context) { + user := middleware.GetUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"}) + return + } + + var p model.UserProxy + if err := h.db.Where("id = ? AND user_id = ?", id, user.ID).First(&p).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "代理不存在"}) + return + } + + var req createProxyReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + p.Name = req.Name + p.Protocol = req.Protocol + p.Host = req.Host + p.Port = req.Port + p.Username = req.Username + if req.Password != "******" { + p.Password = req.Password + } + h.db.Save(&p) + if p.Password != "" { + p.Password = "******" + } + c.JSON(http.StatusOK, p) +} + +// Delete 删除代理(DELETE /api/proxies/:id) +func (h *ProxyHandler) Delete(c *gin.Context) { + user := middleware.GetUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"}) + return + } + result := h.db.Where("id = ? AND user_id = ?", id, user.ID).Delete(&model.UserProxy{}) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"detail": "代理不存在"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// Test 测试代理连通性(POST /api/proxies/test) +// 支持两种方式:传 proxy_id 测试已保存的,或直接传代理信息测试 +func (h *ProxyHandler) Test(c *gin.Context) { + user := middleware.GetUser(c) + + var req struct { + ProxyID uint `json:"proxy_id"` + Protocol string `json:"protocol"` + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + var proxyURL string + if req.ProxyID > 0 { + var p model.UserProxy + if err := h.db.Where("id = ? AND user_id = ?", req.ProxyID, user.ID).First(&p).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "代理不存在"}) + return + } + proxyURL = p.URL() + } else if req.Host != "" { + auth := "" + if req.Username != "" { + auth = req.Username + ":" + req.Password + "@" + } + proxyURL = fmt.Sprintf("%s://%s%s:%d", req.Protocol, auth, req.Host, req.Port) + } else { + c.JSON(http.StatusBadRequest, gin.H{"detail": "请提供代理信息"}) + return + } + + ok, latency, err := testProxy(proxyURL) + if ok { + c.JSON(http.StatusOK, gin.H{"ok": true, "latency_ms": latency, "message": fmt.Sprintf("连接成功(延迟 %dms)", latency)}) + } else { + msg := "连接失败" + if err != nil { + msg = "连接失败: " + err.Error() + } + c.JSON(http.StatusOK, gin.H{"ok": false, "message": msg}) + } +} + +// testProxy 测试代理连通性,返回是否成功、延迟毫秒、错误 +func testProxy(proxyURL string) (bool, int64, error) { + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + parsed, err := url.Parse(proxyURL) + if err != nil { + return false, 0, fmt.Errorf("代理地址格式错误: %w", err) + } + + switch parsed.Scheme { + case "socks5": + auth := &proxy.Auth{} + if parsed.User != nil { + auth.User = parsed.User.Username() + auth.Password, _ = parsed.User.Password() + } else { + auth = nil + } + dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct) + if err != nil { + return false, 0, fmt.Errorf("SOCKS5 初始化失败: %w", err) + } + var conn net.Conn + if cd, ok := dialer.(proxy.ContextDialer); ok { + conn, err = cd.DialContext(ctx, "tcp", "httpbin.org:80") + } else { + conn, err = dialer.Dial("tcp", "httpbin.org:80") + } + if err != nil { + return false, 0, fmt.Errorf("SOCKS5 连接失败: %w", err) + } + conn.Close() + + case "http", "https": + transport := &http.Transport{ + Proxy: http.ProxyURL(parsed), + } + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + req, _ := http.NewRequestWithContext(ctx, "GET", "http://httpbin.org/ip", nil) + resp, err := client.Do(req) + if err != nil { + return false, 0, fmt.Errorf("HTTP 代理连接失败: %w", err) + } + resp.Body.Close() + + default: + return false, 0, fmt.Errorf("不支持的协议: %s", parsed.Scheme) + } + + latency := time.Since(start).Milliseconds() + return true, latency, nil +} diff --git a/internal/handler/proxy_pool_handler.go b/internal/handler/proxy_pool_handler.go new file mode 100644 index 0000000..2d36c30 --- /dev/null +++ b/internal/handler/proxy_pool_handler.go @@ -0,0 +1,572 @@ +package handler + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/netutil" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// ProxyPoolHandler 全局代理池管理(管理员) +type ProxyPoolHandler struct { + db *gorm.DB + proxyPool *service.ProxyPool +} + +// NewProxyPoolHandler 创建代理池管理处理器 +func NewProxyPoolHandler(db *gorm.DB, proxyPool *service.ProxyPool) *ProxyPoolHandler { + return &ProxyPoolHandler{db: db, proxyPool: proxyPool} +} + +// ── 请求结构体 ────────────────────────────────────────────────────── + +type createPoolProxyReq struct { + Name string `json:"name" binding:"max=100"` + Protocol string `json:"protocol" binding:"required,oneof=http https socks5"` + Host string `json:"host" binding:"required,max=200"` + Port int `json:"port" binding:"required,min=1,max=65535"` + Username string `json:"username" binding:"max=100"` + Password string `json:"password" binding:"max=200"` + Country string `json:"country" binding:"max=10"` +} + +// ── CRUD ──────────────────────────────────────────────────────────── + +// List 获取代理池列表(GET /api/admin/proxy-pool,支持分页) +func (h *ProxyPoolHandler) List(c *gin.Context) { + query := h.db.Where("protocol IN ?", []string{"http", "https", "socks5"}).Order("created_at DESC") + + // 可选过滤:healthy / unhealthy + if filter := c.Query("filter"); filter == "healthy" { + query = query.Where("is_healthy = ?", true) + } else if filter == "unhealthy" { + query = query.Where("is_healthy = ?", false) + } + + // 分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 500 { + pageSize = 50 + } + + // 总数(Session 克隆防止 Count 污染后续 Find 查询) + var total int64 + query.Session(&gorm.Session{}).Model(&model.ProxyPoolEntry{}).Count(&total) + + // 分页查询 + var entries []model.ProxyPoolEntry + if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&entries).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询失败"}) + return + } + + // 脱敏密码 + for i := range entries { + if entries[i].Password != "" { + entries[i].Password = "******" + } + } + c.JSON(http.StatusOK, gin.H{ + "items": entries, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// Create 添加代理到池(POST /api/admin/proxy-pool) +func (h *ProxyPoolHandler) Create(c *gin.Context) { + var req createPoolProxyReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // SSRF 防护:拒绝私网/保留地址 + if netutil.IsPrivateHost(req.Host) { + c.JSON(http.StatusBadRequest, gin.H{"detail": "不允许添加内网地址的代理"}) + return + } + + // 去重检查 + var count int64 + if err := h.db.Model(&model.ProxyPoolEntry{}).Where("host = ? AND port = ?", req.Host, req.Port).Count(&count).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询失败"}) + return + } + if count > 0 { + c.JSON(http.StatusConflict, gin.H{"detail": fmt.Sprintf("代理 %s:%d 已存在", req.Host, req.Port)}) + return + } + + entry := model.ProxyPoolEntry{ + Name: req.Name, + Protocol: req.Protocol, + Host: req.Host, + Port: req.Port, + Username: req.Username, + Password: req.Password, + Country: req.Country, + Source: "manual", + } + if err := h.db.Create(&entry).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"}) + return + } + + h.proxyPool.Reload() + + if entry.Password != "" { + entry.Password = "******" + } + c.JSON(http.StatusOK, entry) +} + +// Delete 删除代理(DELETE /api/admin/proxy-pool/:id) +func (h *ProxyPoolHandler) Delete(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"}) + return + } + result := h.db.Where("id = ?", id).Delete(&model.ProxyPoolEntry{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"}) + return + } + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"detail": "代理不存在"}) + return + } + h.proxyPool.Reload() + c.JSON(http.StatusOK, gin.H{"message": "已删除"}) +} + +// BatchDelete 批量删除(POST /api/admin/proxy-pool/batch-delete) +func (h *ProxyPoolHandler) BatchDelete(c *gin.Context) { + var req struct { + IDs []uint `json:"ids" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + if len(req.IDs) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "单次最多删除 500 条"}) + return + } + result := h.db.Where("id IN ?", req.IDs).Delete(&model.ProxyPoolEntry{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"}) + return + } + h.proxyPool.Reload() + c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("已删除 %d 条", result.RowsAffected)}) +} + +// ── 批量导入 ──────────────────────────────────────────────────────── + +// Import 批量导入代理(POST /api/admin/proxy-pool/import) +// 支持格式:每行一个,格式 protocol://[user:pass@]host:port 或 host:port(默认 socks5) +func (h *ProxyPoolHandler) Import(c *gin.Context) { + var req struct { + Proxies string `json:"proxies" binding:"required"` // 换行分隔的代理列表 + Protocol string `json:"protocol"` // 默认协议(未指定时使用) + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + defaultProto := req.Protocol + if defaultProto == "" { + defaultProto = "socks5" + } + + // 协议白名单校验 + allowedProtos := map[string]bool{"http": true, "https": true, "socks5": true} + if !allowedProtos[defaultProto] { + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的协议: %s,仅支持 http/https/socks5", defaultProto)}) + return + } + + const maxImportLines = 1000 + scanner := bufio.NewScanner(strings.NewReader(req.Proxies)) + var imported, skipped, failed int + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if imported+skipped+failed >= maxImportLines { + failed++ + continue + } + + entry, err := parseProxyLine(line, defaultProto) + if err != nil { + log.Debug().Str("line", line).Err(err).Msg("代理导入解析失败") + failed++ + continue + } + + // 协议白名单(解析后二次校验) + if !allowedProtos[entry.Protocol] { + failed++ + continue + } + + // SSRF 防护:拒绝私网/保留地址 + if netutil.IsPrivateHost(entry.Host) { + log.Warn().Str("host", entry.Host).Msg("代理导入拒绝内网地址") + failed++ + continue + } + + // 去重检查 + var count int64 + h.db.Model(&model.ProxyPoolEntry{}).Where("host = ? AND port = ?", entry.Host, entry.Port).Count(&count) + if count > 0 { + skipped++ + continue + } + + entry.Source = "imported" + if err := h.db.Create(entry).Error; err != nil { + failed++ + continue + } + imported++ + } + + h.proxyPool.Reload() + c.JSON(http.StatusOK, gin.H{ + "imported": imported, + "skipped": skipped, + "failed": failed, + "message": fmt.Sprintf("导入完成:成功 %d,重复跳过 %d,失败 %d", imported, skipped, failed), + }) +} + +// parseProxyLine 解析单行代理地址 +// 支持格式: +// - socks5://user:pass@host:port +// - host:port(使用默认协议) +// - host:port:user:pass(使用默认协议) +func parseProxyLine(line, defaultProto string) (*model.ProxyPoolEntry, error) { + entry := &model.ProxyPoolEntry{} + + // 带协议前缀 + if strings.Contains(line, "://") { + parts := strings.SplitN(line, "://", 2) + entry.Protocol = parts[0] + rest := parts[1] + + // 提取认证信息 + if atIdx := strings.LastIndex(rest, "@"); atIdx > 0 { + authPart := rest[:atIdx] + rest = rest[atIdx+1:] + if colonIdx := strings.Index(authPart, ":"); colonIdx > 0 { + entry.Username = authPart[:colonIdx] + entry.Password = authPart[colonIdx+1:] + } + } + + host, port, err := parseHostPort(rest) + if err != nil { + return nil, err + } + if port < 1 || port > 65535 { + return nil, fmt.Errorf("端口超出范围: %d", port) + } + entry.Host = host + entry.Port = port + } else { + // 无协议前缀 + entry.Protocol = defaultProto + parts := strings.Split(line, ":") + switch len(parts) { + case 2: + // host:port + port, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("端口格式错误: %s", parts[1]) + } + if port < 1 || port > 65535 { + return nil, fmt.Errorf("端口超出范围: %d", port) + } + entry.Host = parts[0] + entry.Port = port + case 4: + // host:port:user:pass + port, err := strconv.Atoi(parts[1]) + if err != nil { + return nil, fmt.Errorf("端口格式错误: %s", parts[1]) + } + if port < 1 || port > 65535 { + return nil, fmt.Errorf("端口超出范围: %d", port) + } + entry.Host = parts[0] + entry.Port = port + entry.Username = parts[2] + entry.Password = parts[3] + default: + return nil, fmt.Errorf("无法解析: %s", line) + } + } + + return entry, nil +} + +// parseHostPort 解析 host:port(支持 IPv6 如 [::1]:8080) +func parseHostPort(s string) (string, int, error) { + host, portStr, err := net.SplitHostPort(s) + if err != nil { + return "", 0, fmt.Errorf("无效 host:port: %s", s) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return "", 0, fmt.Errorf("端口格式错误: %s", portStr) + } + return host, port, nil +} + +// ── 健康检查 ──────────────────────────────────────────────────────── + +// 健康检查冷却:防止频繁触发导致大量出站连接 +var ( + healthCheckMu sync.Mutex + healthCheckLastTime time.Time +) + +const healthCheckCooldown = 30 * time.Second // 最短间隔 30 秒 + +// HealthCheck 触发代理池健康检查(POST /api/admin/proxy-pool/health-check) +// 支持选择性检查:请求体含 ids 字段时仅检查指定代理(跳过冷却),为空时检查全部 +func (h *ProxyPoolHandler) HealthCheck(c *gin.Context) { + var req struct { + IDs []uint `json:"ids"` + } + // 忽略绑定错误,允许空 body(兼容旧调用) + _ = c.ShouldBindJSON(&req) + + // 指定 ID 列表:小批量检查,跳过全局冷却 + if len(req.IDs) > 0 { + if len(req.IDs) > 500 { + c.JSON(http.StatusBadRequest, gin.H{"detail": "单次最多检查 500 个代理"}) + return + } + total, healthy, unhealthy := h.proxyPool.HealthCheckByIDs(req.IDs) + c.JSON(http.StatusOK, gin.H{ + "total": total, + "healthy": healthy, + "unhealthy": unhealthy, + "message": fmt.Sprintf("检查完成:共 %d 个,健康 %d,不健康 %d", total, healthy, unhealthy), + }) + return + } + + // 全量检查:受 30 秒冷却限制 + healthCheckMu.Lock() + if time.Since(healthCheckLastTime) < healthCheckCooldown { + remaining := healthCheckCooldown - time.Since(healthCheckLastTime) + healthCheckMu.Unlock() + c.JSON(http.StatusTooManyRequests, gin.H{ + "detail": fmt.Sprintf("健康检查冷却中,请 %d 秒后再试", int(remaining.Seconds())+1), + "retry_after": int(remaining.Seconds()) + 1, + }) + return + } + healthCheckLastTime = time.Now() + healthCheckMu.Unlock() + + total, healthy, unhealthy := h.proxyPool.HealthCheckAll() + c.JSON(http.StatusOK, gin.H{ + "total": total, + "healthy": healthy, + "unhealthy": unhealthy, + "message": fmt.Sprintf("检查完成:共 %d 个,健康 %d,不健康 %d", total, healthy, unhealthy), + }) +} + +// Stats 代理池统计(GET /api/admin/proxy-pool/stats) +func (h *ProxyPoolHandler) Stats(c *gin.Context) { + stats := h.proxyPool.PoolStats() + c.JSON(http.StatusOK, stats) +} + +// PurgeUnhealthy 清除所有不健康代理(POST /api/admin/proxy-pool/purge) +func (h *ProxyPoolHandler) PurgeUnhealthy(c *gin.Context) { + result := h.db.Where("is_healthy = ?", false).Delete(&model.ProxyPoolEntry{}) + if result.Error != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "清除失败"}) + return + } + h.proxyPool.Reload() + c.JSON(http.StatusOK, gin.H{ + "deleted": result.RowsAffected, + "message": fmt.Sprintf("已清除 %d 个不健康代理", result.RowsAffected), + }) +} + +// ResetHealth 重置指定代理的健康状态(POST /api/admin/proxy-pool/:id/reset) +func (h *ProxyPoolHandler) ResetHealth(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"}) + return + } + now := time.Now() + result := h.db.Model(&model.ProxyPoolEntry{}).Where("id = ?", id).Updates(map[string]interface{}{ + "is_healthy": true, + "fail_count": 0, + "last_checked_at": now, + }) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"detail": "代理不存在"}) + return + } + h.proxyPool.Reload() + c.JSON(http.StatusOK, gin.H{"message": "已重置健康状态"}) +} + +// ── 从 URL 抓取 ──────────────────────────────────────────────────── + +// FetchURL 从 URL 抓取代理列表并导入(POST /api/admin/proxy-pool/fetch-url) +func (h *ProxyPoolHandler) FetchURL(c *gin.Context) { + var req struct { + URL string `json:"url" binding:"required"` + Protocol string `json:"protocol"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + defaultProto := req.Protocol + if defaultProto == "" { + defaultProto = "socks5" + } + + allowedProtos := map[string]bool{"http": true, "https": true, "socks5": true} + if !allowedProtos[defaultProto] { + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的协议: %s,仅支持 http/https/socks5", defaultProto)}) + return + } + + // SSRF 防护:校验 URL 协议白名单 + if err := netutil.ValidateURLScheme(req.URL); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + // SSRF 防护:校验 URL 主机不指向内网 + if err := netutil.ValidateURLHost(req.URL); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // 使用 SSRF 安全客户端下载 + client := netutil.NewSSRFSafeClient(30 * time.Second) + httpReq, err := http.NewRequest("GET", req.URL, nil) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("无效的 URL: %s", err.Error())}) + return + } + httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36") + httpReq.Header.Set("Accept", "text/plain, */*") + + resp, err := client.Do(httpReq) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": fmt.Sprintf("抓取失败: %s", err.Error())}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + c.JSON(http.StatusBadGateway, gin.H{"detail": fmt.Sprintf("远程返回 HTTP %d", resp.StatusCode)}) + return + } + + // 限制读取大小(5MB) + body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024)) + if err != nil { + c.JSON(http.StatusBadGateway, gin.H{"detail": fmt.Sprintf("读取内容失败: %s", err.Error())}) + return + } + + // 逐行解析并导入 + const maxLines = 2000 + scanner := bufio.NewScanner(strings.NewReader(string(body))) + var imported, skipped, failed int + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") { + continue + } + + if imported+skipped+failed >= maxLines { + failed++ + continue + } + + entry, err := parseProxyLine(line, defaultProto) + if err != nil { + failed++ + continue + } + + // 协议白名单 + if !allowedProtos[entry.Protocol] { + failed++ + continue + } + + // SSRF 防护 + if netutil.IsPrivateHost(entry.Host) { + failed++ + continue + } + + // 去重 + var count int64 + h.db.Model(&model.ProxyPoolEntry{}).Where("host = ? AND port = ?", entry.Host, entry.Port).Count(&count) + if count > 0 { + skipped++ + continue + } + + entry.Source = "fetched" + if err := h.db.Create(entry).Error; err != nil { + failed++ + continue + } + imported++ + } + + h.proxyPool.Reload() + c.JSON(http.StatusOK, gin.H{ + "imported": imported, + "skipped": skipped, + "failed": failed, + "message": fmt.Sprintf("抓取完成:成功 %d,重复跳过 %d,失败 %d", imported, skipped, failed), + }) +} diff --git a/internal/handler/result_handler.go b/internal/handler/result_handler.go new file mode 100644 index 0000000..513fa03 --- /dev/null +++ b/internal/handler/result_handler.go @@ -0,0 +1,347 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/middleware" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// resultItem 结果列表响应项(ListAll / ListArchived 共用) +type resultItem struct { + ID uint `json:"id"` + Email string `json:"email"` + Platform string `json:"platform"` + CredentialData json.RawMessage `json:"credential_data"` + SSOToken string `json:"auth_token,omitempty"` + NsfwEnabled bool `json:"feature_enabled"` + Disabled bool `json:"disabled"` + DisabledReason string `json:"disabled_reason,omitempty"` + DisabledAt *string `json:"disabled_at,omitempty"` + LastValidatedAt *string `json:"last_validated_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +// toResultItems 将 model.TaskResult 切片转换为 API 响应项 +func toResultItems(results []model.TaskResult) []resultItem { + items := make([]resultItem, 0, len(results)) + for _, r := range results { + item := resultItem{ + ID: r.ID, + Email: r.Email, + Platform: r.Platform, + CredentialData: json.RawMessage(r.CredentialData), + SSOToken: r.SSOToken, + NsfwEnabled: r.NsfwEnabled, + Disabled: r.Disabled, + DisabledReason: r.DisabledReason, + CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"), + } + if r.DisabledAt != nil { + s := r.DisabledAt.Format("2006-01-02 15:04:05") + item.DisabledAt = &s + } + if r.LastValidatedAt != nil { + s := r.LastValidatedAt.Format("2006-01-02 15:04:05") + item.LastValidatedAt = &s + } + items = append(items, item) + } + return items +} + +// ResultHandler 结果处理器 +type ResultHandler struct { + db *gorm.DB + settingSvc *service.SettingService +} + +// NewResultHandler 创建结果处理器 +func NewResultHandler(db *gorm.DB, settingSvc *service.SettingService) *ResultHandler { + return &ResultHandler{db: db, settingSvc: settingSvc} +} + +// GetResults 获取任务结果(GET /api/results/:taskId) +func (h *ResultHandler) GetResults(c *gin.Context) { + user := middleware.GetUser(c) + taskID, err := strconv.ParseUint(c.Param("taskId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的任务 ID"}) + return + } + + var results []model.TaskResult + if err := h.db.Where("task_id = ? AND user_id = ?", taskID, user.ID). + Order("created_at DESC"). + Find(&results).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询失败"}) + return + } + c.JSON(http.StatusOK, results) +} + +// Export 导出结果(GET /api/results/:taskId/export) +func (h *ResultHandler) Export(c *gin.Context) { + user := middleware.GetUser(c) + taskID, err := strconv.ParseUint(c.Param("taskId"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的任务 ID"}) + return + } + + var results []model.TaskResult + if err := h.db.Where("task_id = ? AND user_id = ?", taskID, user.ID).Find(&results).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询失败"}) + return + } + + if len(results) == 0 { + c.JSON(http.StatusNotFound, gin.H{"detail": "没有结果可导出"}) + return + } + + // 判断平台,选择导出格式 + platform := "" + if len(results) > 0 { + platform = results[0].Platform + } + + var lines []string + for _, r := range results { + if platform == "grok" && r.SSOToken != "" { + lines = append(lines, r.SSOToken) + } else { + lines = append(lines, string(r.CredentialData)) + } + } + + content := strings.Join(lines, "\n") + // 清理文件名中的特殊字符,防止 Content-Disposition 头注入 + safePlatform := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '_' + }, platform) + filename := fmt.Sprintf("task_%d_%s_results.txt", taskID, safePlatform) + c.Header("Content-Disposition", "attachment; filename="+filename) + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(content)) +} + +// ListAll 所有未归档结果(GET /api/results?platform=&page=&page_size=) +// 支持分页:page 从 1 开始,page_size 默认 100,page_size=-1 返回全量(向后兼容) +func (h *ResultHandler) ListAll(c *gin.Context) { + user := middleware.GetUser(c) + platform := c.Query("platform") + + base := h.db.Where("user_id = ? AND is_archived = ?", user.ID, false) + if platform != "" { + base = base.Where("platform = ?", platform) + } + + // 总数(必须用 Session 克隆,避免 Count 污染后续查询的内部状态) + var total int64 + base.Session(&gorm.Session{}).Model(&model.TaskResult{}).Count(&total) + + // 分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "100")) + if page < 1 { + page = 1 + } + + query := base.Order("created_at DESC") + // page_size=-1 或不传 page 参数时向后兼容,返回全量 + if pageSize > 0 { + query = query.Offset((page - 1) * pageSize).Limit(pageSize) + } + + var results []model.TaskResult + query.Find(&results) + + c.JSON(http.StatusOK, gin.H{ + "items": toResultItems(results), + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// ArchiveAll 归档结果(POST /api/results/archive) +// 支持 body {"platform":"grok"} 按平台归档,不传则归档全部 +// 返回本次归档数量 archived_count +func (h *ResultHandler) ArchiveAll(c *gin.Context) { + user := middleware.GetUser(c) + + var req struct { + Platform string `json:"platform"` + } + _ = c.ShouldBindJSON(&req) + + query := h.db.Model(&model.TaskResult{}). + Where("user_id = ? AND is_archived = ?", user.ID, false) + if req.Platform != "" { + query = query.Where("platform = ?", req.Platform) + } + result := query.Update("is_archived", true) + c.JSON(http.StatusOK, gin.H{ + "message": "已归档", + "archived_count": result.RowsAffected, + }) +} + +// ListArchived 已归档结果(GET /api/results/archived?platform=&page=&page_size=&count_only=true) +// count_only=true 时仅返回总数(不查具体数据,极快) +// 支持分页:page 从 1 开始,page_size 默认 50,page_size=-1 返回全量(导出用) +func (h *ResultHandler) ListArchived(c *gin.Context) { + user := middleware.GetUser(c) + platform := c.Query("platform") + + base := h.db.Where("user_id = ? AND is_archived = ?", user.ID, true) + if platform != "" { + base = base.Where("platform = ?", platform) + } + + // 总数(必须用 Session 克隆,避免 Count 污染后续查询的内部状态) + var total int64 + base.Session(&gorm.Session{}).Model(&model.TaskResult{}).Count(&total) + + // count_only 模式:只返回总数 + if c.Query("count_only") == "true" { + c.JSON(http.StatusOK, gin.H{"total": total}) + return + } + + // 分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + if page < 1 { + page = 1 + } + + query := base.Order("created_at DESC") + if pageSize > 0 { + query = query.Offset((page - 1) * pageSize).Limit(pageSize) + } + + var results []model.TaskResult + query.Find(&results) + + c.JSON(http.StatusOK, gin.H{ + "items": toResultItems(results), + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// FetchOTP 获取邮箱验证码(GET /api/email/otp?email=xxx) +// 自动从数据库反查 provider 信息,前端只需传 email +func (h *ResultHandler) FetchOTP(c *gin.Context) { + email := c.Query("email") + if email == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "缺少 email 参数"}) + return + } + + // 优先从 query 参数获取 provider(向后兼容),否则从数据库反查 + provider := c.Query("provider") + meta := map[string]string{"provider": provider} + + if provider == "" { + // 从数据库查找该邮箱的最新注册结果,提取 email_provider 和 email_meta + var result model.TaskResult + if err := h.db.Where("email = ?", email).Order("created_at DESC").First(&result).Error; err == nil { + var credData map[string]interface{} + if err := json.Unmarshal(result.CredentialData, &credData); err == nil { + if ep, ok := credData["mail_provider"].(string); ok && ep != "" { + provider = ep + meta["provider"] = provider + } else if ep, ok := credData["provider"].(string); ok && ep != "" { + provider = ep + meta["provider"] = provider + } else if ep, ok := credData["email_provider"].(string); ok && ep != "" { + provider = ep + meta["provider"] = provider + } + if em, ok := credData["email_meta"].(map[string]interface{}); ok { + for k, v := range em { + if s, ok := v.(string); ok { + meta[k] = s + } + } + } + } + } + // 数据库也没有,按域名猜测 + if provider == "" { + provider = detectProviderByDomain(email) + meta["provider"] = provider + } + } else { + // query 参数模式(向后兼容):从 URL 读取 meta 字段 + meta["token"] = c.Query("token") + meta["sid_token"] = c.Query("sid_token") + meta["account_id"] = c.Query("account_id") + meta["base_url"] = c.Query("base_url") + meta["admin_token"] = c.Query("admin_token") + } + + cfg := map[string]string{ + "yydsmail_base_url": h.settingSvc.Get("yydsmail_base_url", ""), + "yydsmail_api_key": h.settingSvc.Get("yydsmail_api_key", ""), + } + + if provider == "" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无法识别邮箱 provider"}) + return + } + if tempmail.NormalizeProviderNameForAPI(provider) == "yydsmail" && cfg["yydsmail_api_key"] == "" { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "YYDS Mail API Key 未配置"}) + return + } + + // 单次尝试获取验证码(不轮询,前端按需点击重试) + code, err := tempmail.FetchVerificationCodeByProvider(c.Request.Context(), provider, cfg, email, meta) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": "", "error": "暂无验证码"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": code}) +} + +// detectProviderByDomain 根据邮箱域名自动识别 provider +func detectProviderByDomain(email string) string { + return "yydsmail" +} + +// ReEnable 恢复被禁用的账号(POST /api/results/:id/re-enable) +func (h *ResultHandler) ReEnable(c *gin.Context) { + user := middleware.GetUser(c) + id, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"}) + return + } + + result := h.db.Model(&model.TaskResult{}). + Where("id = ? AND user_id = ? AND disabled = ?", id, user.ID, true). + Updates(map[string]interface{}{ + "disabled": false, + "disabled_reason": "", + "disabled_at": nil, + }) + if result.RowsAffected == 0 { + c.JSON(http.StatusNotFound, gin.H{"detail": "账号不存在或未被禁用"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "账号已恢复"}) +} diff --git a/internal/handler/task_handler.go b/internal/handler/task_handler.go new file mode 100644 index 0000000..a147cd4 --- /dev/null +++ b/internal/handler/task_handler.go @@ -0,0 +1,455 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/dto" + "github.com/xiaolajiaoyyds/regplatformm/internal/middleware" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// TaskHandler 任务处理器 +type TaskHandler struct { + db *gorm.DB + engine *service.TaskEngine + creditSvc *service.CreditService + settingSvc *service.SettingService +} + +// NewTaskHandler 创建任务处理器 +func NewTaskHandler(db *gorm.DB, engine *service.TaskEngine, creditSvc *service.CreditService, settingSvc *service.SettingService) *TaskHandler { + return &TaskHandler{db: db, engine: engine, creditSvc: creditSvc, settingSvc: settingSvc} +} + +// Create 创建任务(POST /api/tasks) +func (h *TaskHandler) Create(c *gin.Context) { + user := middleware.GetUser(c) + var req dto.CreateTaskReq + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + + // ── 校验系统限制 ────────────────────────────────────────────── + // 平台开关 + platformKey := "platform_" + req.Platform + "_enabled" + if h.settingSvc.Get(platformKey, "true") != "true" { + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("%s 平台注册已关闭", req.Platform)}) + return + } + + maxTarget, _ := strconv.Atoi(h.settingSvc.Get("max_target", "1000")) + maxThreads, _ := strconv.Atoi(h.settingSvc.Get("max_threads", "16")) + if maxTarget > 0 && req.Target > maxTarget { + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("注册数量不能超过 %d", maxTarget)}) + return + } + + // 自动计算并发线程数(用户不传或传 0 时,从配置读取阶梯) + if req.Threads <= 0 { + req.Threads = calcThreadsByTiers(h.settingSvc, req.Target) + } + if maxThreads > 0 && req.Threads > maxThreads { + req.Threads = maxThreads + } + + // ── 校验每日注册上限(凌晨 6 点重置)────────────────────────── + dailyLimit, _ := strconv.Atoi(h.settingSvc.Get("daily_reg_limit", "0")) + if dailyLimit > 0 { + resetTime := dailyResetTime() + var dailyUsed int64 + h.db.Model(&model.Task{}). + Where("user_id = ? AND created_at >= ?", user.ID, resetTime). + Select("COALESCE(SUM(success_count), 0)").Scan(&dailyUsed) + if int(dailyUsed)+req.Target > dailyLimit { + remaining := dailyLimit - int(dailyUsed) + if remaining < 0 { + remaining = 0 + } + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("今日注册已达上限(%d/%d),凌晨 6 点重置,剩余 %d 次", dailyUsed, dailyLimit, remaining)}) + return + } + } + + // ── 校验平台级注册上限(付费/免费均生效)────────────────────── + platformTaskLimit := h.settingSvc.GetInt("platform_"+req.Platform+"_task_limit", 0) + if platformTaskLimit > 0 && req.Target > platformTaskLimit { + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("%s 平台单任务最多注册 %d 个", req.Platform, platformTaskLimit)}) + return + } + platformDailyLimit := h.settingSvc.GetInt("platform_"+req.Platform+"_daily_limit", 0) + if platformDailyLimit > 0 { + resetTime := dailyResetTime() + var platformDailyUsed int64 + h.db.Model(&model.Task{}). + Where("user_id = ? AND platform = ? AND created_at >= ?", user.ID, req.Platform, resetTime). + Select("COALESCE(SUM(success_count), 0)").Scan(&platformDailyUsed) + if int(platformDailyUsed)+req.Target > platformDailyLimit { + remaining := platformDailyLimit - int(platformDailyUsed) + if remaining < 0 { + remaining = 0 + } + c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("%s 今日注册已达上限(%d/%d),凌晨 6 点重置,剩余 %d 次", req.Platform, platformDailyUsed, platformDailyLimit, remaining)}) + return + } + } + + // 判断平台是否限时免费 + platformFree := isPlatformFree(h.settingSvc, req.Platform) + + // ── 免费模式 vs 付费模式判定 ────────────────────────────────────── + // mode: "free" = 用户选择免费(受限),"paid" = 用户选择付费(无限制),空 = 自动 + useFreeTier := false + if platformFree { + if req.Mode == "paid" { + // 用户主动选择付费,走正常积分流程 + useFreeTier = false + } else { + // mode=="free" 或空值:尝试使用免费额度 + if err := h.validateFreeMode(h.db, user.ID, req.Platform, req.Target); err != nil { + if req.Mode == "free" { + // 用户明确要求免费但不满足条件,拒绝 + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + // mode 为空(自动模式):免费不够用,降级到付费 + useFreeTier = false + } else { + useFreeTier = true + } + } + } + + // 创建任务记录 + creditsReserved := req.Target + if useFreeTier { + creditsReserved = 0 + } + + // 查询用户指定的代理 + var proxyURL string + if req.ProxyID > 0 { + var userProxy model.UserProxy + if err := h.db.Where("id = ? AND user_id = ?", req.ProxyID, user.ID).First(&userProxy).Error; err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "指定的代理不存在"}) + return + } + proxyURL = userProxy.URL() + } + + task := model.Task{ + UserID: user.ID, + Platform: req.Platform, + Status: "pending", + TargetCount: req.Target, + ThreadCount: req.Threads, + CreditsReserved: creditsReserved, + ProxyURL: proxyURL, + } + + tx := h.db.Begin() + if err := tx.Create(&task).Error; err != nil { + tx.Rollback() + c.JSON(http.StatusInternalServerError, gin.H{"detail": "任务创建失败"}) + return + } + + // 预扣积分(免费模式跳过) + if !useFreeTier { + if err := h.creditSvc.ReserveCredits(tx, user.ID, task.ID, req.Target); err != nil { + tx.Rollback() + c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()}) + return + } + } + + if err := tx.Commit().Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"detail": "任务提交失败"}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "task_id": task.ID, + "platform": task.Platform, + "target": task.TargetCount, + "threads": task.ThreadCount, + "status": task.Status, + }) +} + +// Start 启动任务(POST /api/tasks/:id/start) +func (h *TaskHandler) Start(c *gin.Context) { + user := middleware.GetUser(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的任务 ID"}) + return + } + + var task model.Task + if err := h.db.Where("id = ? AND user_id = ?", taskID, user.ID).First(&task).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "任务不存在"}) + return + } + + if task.Status != "pending" && task.Status != "stopped" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "任务状态不允许启动"}) + return + } + + // 快速同步检查:该平台是否已有运行中任务(避免长阻塞后才报错) + if rt := h.engine.GetStatus(user.ID, task.Platform); rt != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "该平台已有运行中的任务"}) + return + } + + // StartTask 快速返回排队/启动状态,重量级操作在内部 goroutine 执行 + ok, msg := h.engine.StartTask(user.ID, task.ID, task.Platform, task.TargetCount, task.ThreadCount) + if !ok { + c.JSON(http.StatusBadRequest, gin.H{"detail": msg}) + return + } + c.JSON(http.StatusOK, gin.H{"message": msg, "task_id": task.ID}) +} + +// Stop 停止任务(POST /api/tasks/:id/stop) +// 支持停止运行中 (running) 和排队中 (queued/pending) 的任务 +func (h *TaskHandler) Stop(c *gin.Context) { + user := middleware.GetUser(c) + taskID, err := strconv.ParseUint(c.Param("id"), 10, 32) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的任务 ID"}) + return + } + + var task model.Task + if err := h.db.Where("id = ? AND user_id = ?", taskID, user.ID).First(&task).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"detail": "任务不存在"}) + return + } + + // 运行中任务:走引擎停止流程 + if h.engine.StopTask(user.ID, task.Platform) { + c.JSON(http.StatusOK, gin.H{"message": "任务停止中"}) + return + } + + // 排队中 / pending 任务:直接在 DB 取消 + 退款 + if task.Status == "queued" || task.Status == "pending" { + if err := h.db.Transaction(func(tx *gorm.DB) error { + var fresh model.Task + if err := tx.Where("id = ? AND status IN ?", task.ID, []string{"queued", "pending"}).First(&fresh).Error; err != nil { + return err // 状态已变(可能刚被调度器拉起),跳过 + } + now := time.Now() + fresh.Status = "stopped" + fresh.StoppedAt = &now + tx.Save(&fresh) + // 退还预扣积分 + if fresh.CreditsReserved > 0 { + h.creditSvc.RefundCredits(tx, user.ID, fresh.ID, int(fresh.CreditsReserved)) + } + return nil + }); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"detail": "任务状态已变更,请刷新页面"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "排队已取消,积分已退还"}) + return + } + + c.JSON(http.StatusBadRequest, gin.H{"detail": "没有运行中的任务"}) +} + +// Current 获取当前运行中任务(GET /api/tasks/current?platform=) +func (h *TaskHandler) Current(c *gin.Context) { + user := middleware.GetUser(c) + platform := c.Query("platform") + + // 查询数据库中的运行中/排队任务 + query := h.db.Where("user_id = ? AND status IN ?", user.ID, []string{"running", "stopping", "queued", "pending"}) + if platform != "" { + query = query.Where("platform = ?", platform) + } + + var task model.Task + if err := query.Order("created_at DESC").First(&task).Error; err != nil { + c.JSON(http.StatusOK, nil) + return + } + + // 检查孤儿任务(DB 显示 running/stopping 但内存无对应任务)—— 事务保护防双倍退款 + if (task.Status == "running" || task.Status == "stopping") && h.engine.GetStatus(user.ID, task.Platform) == nil { + _ = h.db.Transaction(func(tx *gorm.DB) error { + var fresh model.Task + if err := tx.Where("id = ? AND status IN ?", task.ID, []string{"running", "stopping"}).First(&fresh).Error; err != nil { + return err // 已被其他请求处理,跳过 + } + now := time.Now() + fresh.Status = "stopped" + fresh.StoppedAt = &now + tx.Save(&fresh) + refund := fresh.CreditsReserved - fresh.SuccessCount + if refund > 0 { + h.creditSvc.RefundCredits(tx, user.ID, fresh.ID, refund) + } + task = fresh + return nil + }) + } + + resp := dto.TaskStatus{ + TaskID: task.ID, + Platform: task.Platform, + Target: task.TargetCount, + Threads: task.ThreadCount, + CreditsReserved: task.CreditsReserved, + SuccessCount: task.SuccessCount, + FailCount: task.FailCount, + Status: task.Status, + IsDone: task.IsDone(), + } + + // 排队中任务:计算实时排队位置和预计等待时间 + if task.Status == "queued" || task.Status == "pending" { + var queuePos int64 + h.db.Model(&model.Task{}). + Where("status IN ? AND platform = ? AND created_at < ?", []string{"queued", "pending"}, task.Platform, task.CreatedAt). + Count(&queuePos) + queuePos++ // 自己也算一个位置 + resp.QueuePosition = int(queuePos) + resp.QueueWaitSec = h.engine.EstimateWaitTime(int(queuePos), task.Platform) + } + + c.JSON(http.StatusOK, resp) +} + +// History 历史任务(GET /api/tasks/history) +func (h *TaskHandler) History(c *gin.Context) { + user := middleware.GetUser(c) + var tasks []model.Task + h.db.Where("user_id = ?", user.ID). + Order("created_at DESC"). + Limit(20). + Find(&tasks) + + results := make([]dto.TaskStatus, 0, len(tasks)) + for _, t := range tasks { + results = append(results, dto.TaskStatus{ + TaskID: t.ID, + Platform: t.Platform, + Target: t.TargetCount, + Threads: t.ThreadCount, + CreditsReserved: t.CreditsReserved, + SuccessCount: t.SuccessCount, + FailCount: t.FailCount, + Status: t.Status, + IsDone: t.IsDone(), + }) + } + c.JSON(http.StatusOK, results) +} + +// calcThreadsByTiers 从配置读取线程阶梯,匹配目标数量返回线程数 +func calcThreadsByTiers(settingSvc *service.SettingService, target int) int { + tiersJSON := settingSvc.Get("thread_tiers", "") + if tiersJSON != "" { + var tiers []struct { + Max int `json:"max"` + Threads int `json:"threads"` + } + if err := json.Unmarshal([]byte(tiersJSON), &tiers); err == nil && len(tiers) > 0 { + for _, tier := range tiers { + if target <= tier.Max { + return tier.Threads + } + } + // 超过所有阶梯,使用最后一档 + return tiers[len(tiers)-1].Threads + } + } + // 降级:硬编码默认值 + switch { + case target <= 5: + return 1 + case target <= 20: + return 2 + case target <= 100: + return 3 + case target <= 300: + return 5 + case target <= 500: + return 8 + default: + return 12 + } +} + +// isPlatformFree 判断平台是否在限时免费期内 +func isPlatformFree(settingSvc *service.SettingService, platform string) bool { + key := "platform_" + platform + "_free_until" + dateStr := settingSvc.Get(key, "") + if dateStr == "" { + return false + } + deadline, err := time.Parse("2006-01-02", dateStr) + if err != nil { + return false + } + // 截止日当天全天有效(截止日+1天的零点之前) + return time.Now().Before(deadline.AddDate(0, 0, 1)) +} + +// validateFreeMode 校验免费模式的三重限制:单任务上限、每日上限、冷却时间 +// 冷却逻辑:上一轮免费任务用满 task_limit → 触发冷却;没用满 → 不冷却 +// 返回 nil 表示可以使用免费模式,返回 error 说明原因 +func (h *TaskHandler) validateFreeMode(db *gorm.DB, userID uint, platform string, target int) error { + prefix := "platform_" + platform + "_free_" + + // ── 1. 单任务上限 ──────────────────────────────────────────────── + taskLimit := h.settingSvc.GetInt(prefix+"task_limit", 2) + if taskLimit > 0 && target > taskLimit { + return fmt.Errorf("免费模式单次最多注册 %d 个,如需更多请切换付费模式", taskLimit) + } + + // ── 2. 每日上限(凌晨 6 点重置)──────────────────────────────────── + dailyLimit := h.settingSvc.GetInt(prefix+"daily_limit", 5) + resetTime := dailyResetTime() + var dailyFreeUsed int64 + db.Model(&model.Task{}). + Where("user_id = ? AND platform = ? AND credits_reserved = 0 AND created_at >= ?", userID, platform, resetTime). + Select("COALESCE(SUM(target_count), 0)").Scan(&dailyFreeUsed) + + if dailyLimit > 0 && int(dailyFreeUsed)+target > dailyLimit { + remaining := dailyLimit - int(dailyFreeUsed) + if remaining < 0 { + remaining = 0 + } + return fmt.Errorf("今日免费额度已用 %d/%d,剩余 %d 次,如需更多请切换付费模式", dailyFreeUsed, dailyLimit, remaining) + } + + // ── 3. 冷却:上一轮用满 task_limit 才触发,没用满不冷却 ────────────── + cooldownMin := h.settingSvc.GetInt(prefix+"cooldown", 30) + if cooldownMin > 0 && taskLimit > 0 { + var lastTask model.Task + err := db.Where("user_id = ? AND platform = ? AND credits_reserved = 0 AND stopped_at IS NOT NULL", userID, platform). + Order("stopped_at DESC").First(&lastTask).Error + if err == nil && lastTask.StoppedAt != nil && lastTask.TargetCount >= taskLimit { + // 上一轮用满了 task_limit → 检查冷却窗口 + cooldownEnd := lastTask.StoppedAt.Add(time.Duration(cooldownMin) * time.Minute) + if time.Now().Before(cooldownEnd) { + remainingMin := int(time.Until(cooldownEnd).Minutes()) + 1 + return fmt.Errorf("上一轮免费注册已用满 %d 个,冷却中还需 %d 分钟,或切换付费模式", taskLimit, remainingMin) + } + } + } + + return nil +} diff --git a/internal/handler/ws_handler.go b/internal/handler/ws_handler.go new file mode 100644 index 0000000..467a61d --- /dev/null +++ b/internal/handler/ws_handler.go @@ -0,0 +1,145 @@ +package handler + +import ( + "fmt" + "io" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" +) + +// WSHandler SSE/WebSocket 日志处理器 +type WSHandler struct { + engine *service.TaskEngine + authSvc *service.AuthService +} + +// NewWSHandler 创建日志处理器 +func NewWSHandler(engine *service.TaskEngine, authSvc *service.AuthService) *WSHandler { + return &WSHandler{engine: engine, authSvc: authSvc} +} + +// SSELogs SSE 日志流(GET /ws/logs/:taskId/stream?token=) +func (h *WSHandler) SSELogs(c *gin.Context) { + taskID, _ := strconv.ParseUint(c.Param("taskId"), 10, 32) + token := c.Query("token") + + userID, err := h.authSvc.VerifyJWT(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录"}) + return + } + + // 等待任务出现(最多 10 秒,感知客户端断开) + var rt *service.RunningTask + timeout := time.After(10 * time.Second) + for { + select { + case <-c.Request.Context().Done(): + return + case <-timeout: + c.JSON(http.StatusNotFound, gin.H{"detail": "任务不存在或已结束"}) + return + default: + rt = h.engine.FindRunningTaskByID(uint(taskID)) + if rt != nil && rt.UserID == userID { + goto found + } + time.Sleep(500 * time.Millisecond) + } + } + +found: + + if rt == nil || rt.UserID != userID { + c.JSON(http.StatusNotFound, gin.H{"detail": "任务不存在或已结束"}) + return + } + + // 订阅日志 + sub := rt.Subscribe() + defer rt.Unsubscribe(sub) + + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + // 先发送历史日志(页面刷新后回放) + for _, msg := range rt.GetLogBuffer() { + c.SSEvent("", gin.H{"type": "log", "message": msg}) + } + // 发送当前状态快照 + c.SSEvent("", gin.H{ + "type": "status", + "success": rt.SuccessCount.Load(), + "fail": rt.FailCount.Load(), + "target": rt.Target, + }) + c.Writer.Flush() + + c.Stream(func(w io.Writer) bool { + select { + case msg, ok := <-sub: + if !ok { + return false + } + c.SSEvent("", gin.H{"type": "log", "message": msg}) + return true + case <-rt.StatusCh: + // 成功/失败计数变化,立即推送状态 + c.SSEvent("", gin.H{ + "type": "status", + "success": rt.SuccessCount.Load(), + "fail": rt.FailCount.Load(), + "target": rt.Target, + }) + // StatusCh 与 Done 可能同时就绪,select 随机选中 StatusCh 时需补发 complete + if rt.IsDone() { + elapsed := time.Since(rt.StartedAt).Round(time.Second).String() + c.SSEvent("", gin.H{ + "type": "complete", + "success": rt.SuccessCount.Load(), + "fail": rt.FailCount.Load(), + "target": rt.Target, + "elapsed": elapsed, + }) + return false + } + return true + case <-time.After(2 * time.Second): + // 心跳 + 状态 + c.SSEvent("", gin.H{ + "type": "status", + "success": rt.SuccessCount.Load(), + "fail": rt.FailCount.Load(), + "target": rt.Target, + }) + return !rt.IsDone() + case <-rt.Done: + elapsed := time.Since(rt.StartedAt).Round(time.Second).String() + c.SSEvent("", gin.H{ + "type": "complete", + "success": rt.SuccessCount.Load(), + "fail": rt.FailCount.Load(), + "target": rt.Target, + "elapsed": elapsed, + }) + return false + case <-c.Request.Context().Done(): + return false + } + }) +} + +// WebSocketLogs WebSocket 日志(GET /ws/logs/:taskId?token=) +func (h *WSHandler) WebSocketLogs(c *gin.Context) { + // WebSocket 作为 SSE 的降级备选,暂用 SSE 替代 + c.JSON(http.StatusOK, gin.H{ + "message": "请使用 SSE 端点: /ws/logs/{taskId}/stream?token=", + "sse_url": fmt.Sprintf("/ws/logs/%s/stream", c.Param("taskId")), + }) +} diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..fde074e --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,79 @@ +package middleware + +import ( + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" +) + +// Auth JWT 认证中间件 +// token 提取优先级:Cookie("token") > X-Auth-Token > Authorization Bearer +func Auth(authSvc *service.AuthService) gin.HandlerFunc { + return func(c *gin.Context) { + // 1. Cookie + token, _ := c.Cookie("token") + + // 2. X-Auth-Token header + if token == "" { + token = c.GetHeader("X-Auth-Token") + } + + // 3. Authorization: Bearer xxx + if token == "" { + auth := c.GetHeader("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + token = auth[7:] + } + } + + // 4. Query parameter(SSE 用) + if token == "" { + token = c.Query("token") + } + + if token == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"detail": "未登录"}) + return + } + + userID, err := authSvc.VerifyJWT(token) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"detail": "登录已过期"}) + return + } + + user, err := authSvc.GetActiveUser(userID) + if err != nil { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"detail": "账号不可用"}) + return + } + + c.Set("user", user) + c.Set("userID", user.ID) + c.Next() + } +} + +// GetUser 从 gin.Context 中提取当前用户 +func GetUser(c *gin.Context) *model.User { + u, exists := c.Get("user") + if !exists { + return nil + } + return u.(*model.User) +} + +// Admin 管理员权限中间件(需在 Auth 之后使用) +func Admin() gin.HandlerFunc { + return func(c *gin.Context) { + user := GetUser(c) + if user == nil || !user.IsAdmin { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"detail": "需要管理员权限"}) + return + } + c.Next() + } +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..6765c11 --- /dev/null +++ b/internal/middleware/cors.go @@ -0,0 +1,41 @@ +package middleware + +import ( + "os" + "strings" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-gonic/gin" +) + +// CORS 跨域中间件 +// 生产环境通过 CORS_ORIGINS 环境变量配置允许的域名(逗号分隔) +// 开发模式(GIN_MODE != release)允许所有来源 +func CORS() gin.HandlerFunc { + return cors.New(cors.Config{ + AllowOriginFunc: func(origin string) bool { + // 开发模式允许所有来源 + if os.Getenv("GIN_MODE") != "release" { + return true + } + // 生产环境:读取 CORS_ORIGINS 白名单 + allowed := os.Getenv("CORS_ORIGINS") + if allowed == "" { + // 未配置则允许同源请求(Origin 为空时浏览器不发送 CORS preflight) + return origin == "" + } + for _, a := range strings.Split(allowed, ",") { + if strings.TrimSpace(a) == origin { + return true + } + } + return false + }, + AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Origin", "Content-Type", "Authorization", "X-Auth-Token"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + }) +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go new file mode 100644 index 0000000..015cbf7 --- /dev/null +++ b/internal/middleware/ratelimit.go @@ -0,0 +1,89 @@ +package middleware + +import ( + "net/http" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +// rateBucket 令牌桶 +type rateBucket struct { + tokens float64 + lastTime time.Time + maxTokens float64 + refillRate float64 // tokens per second +} + +// RateLimiter 基于 IP 的内存限流器 +type RateLimiter struct { + buckets map[string]*rateBucket + mu sync.Mutex + max float64 + rate float64 +} + +// NewRateLimiter 创建限流器 +// max: 最大令牌数(突发容量), perMinute: 每分钟补充令牌数 +func NewRateLimiter(max float64, perMinute float64) *RateLimiter { + rl := &RateLimiter{ + buckets: make(map[string]*rateBucket), + max: max, + rate: perMinute / 60.0, + } + // 定期清理过期桶 + go func() { + for { + time.Sleep(10 * time.Minute) + rl.mu.Lock() + cutoff := time.Now().Add(-30 * time.Minute) + for k, b := range rl.buckets { + if b.lastTime.Before(cutoff) { + delete(rl.buckets, k) + } + } + rl.mu.Unlock() + } + }() + return rl +} + +// Allow 检查是否允许请求 +func (rl *RateLimiter) Allow(key string) bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + b, ok := rl.buckets[key] + if !ok { + b = &rateBucket{tokens: rl.max, lastTime: now, maxTokens: rl.max, refillRate: rl.rate} + rl.buckets[key] = b + } + + // 补充令牌 + elapsed := now.Sub(b.lastTime).Seconds() + b.tokens += elapsed * b.refillRate + if b.tokens > b.maxTokens { + b.tokens = b.maxTokens + } + b.lastTime = now + + if b.tokens >= 1 { + b.tokens-- + return true + } + return false +} + +// RateLimit 限流中间件 +func RateLimit(limiter *RateLimiter) gin.HandlerFunc { + return func(c *gin.Context) { + key := c.ClientIP() + if !limiter.Allow(key) { + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"detail": "请求过于频繁,请稍后再试"}) + return + } + c.Next() + } +} diff --git a/internal/model/announcement.go b/internal/model/announcement.go new file mode 100644 index 0000000..060c4a2 --- /dev/null +++ b/internal/model/announcement.go @@ -0,0 +1,13 @@ +package model + +import "time" + +// Announcement 公告 +type Announcement struct { + ID uint `gorm:"primaryKey" json:"id"` + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + CreatedBy uint `json:"created_by"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} diff --git a/internal/model/card_pool_entry.go b/internal/model/card_pool_entry.go new file mode 100644 index 0000000..1189e54 --- /dev/null +++ b/internal/model/card_pool_entry.go @@ -0,0 +1,38 @@ +package model + +import ( + "fmt" + "time" +) + +// CardPoolEntry 全局虚拟卡池条目(管理员管理,Team 开通共享) +type CardPoolEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100" json:"name"` // 卡别名 + CardNumber string `gorm:"size:30;not null;uniqueIndex" json:"card_number"` // 加密存储 + ExpMonth int `gorm:"not null" json:"exp_month"` + ExpYear int `gorm:"not null" json:"exp_year"` + CVC string `gorm:"size:10;not null" json:"cvc"` + BillingName string `gorm:"size:200" json:"billing_name"` + BillingEmail string `gorm:"size:200" json:"billing_email"` + BillingCountry string `gorm:"size:10;default:'US'" json:"billing_country"` + BillingCity string `gorm:"size:100" json:"billing_city"` + BillingLine1 string `gorm:"size:300" json:"billing_line1"` + BillingZip string `gorm:"size:20" json:"billing_zip"` + Provider string `gorm:"size:50" json:"provider"` // manual, privacy, wise, api + Source string `gorm:"size:50" json:"source"` // manual, imported, api + IsValid bool `gorm:"default:true;index" json:"is_valid"` + UseCount int `gorm:"default:0" json:"use_count"` + FailCount int `gorm:"default:0" json:"fail_count"` + LastUsedAt *time.Time `json:"last_used_at"` + LastValidAt *time.Time `json:"last_valid_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +// MaskedNumber 返回脱敏卡号(仅显示后 4 位) +func (c *CardPoolEntry) MaskedNumber() string { + if len(c.CardNumber) <= 4 { + return c.CardNumber + } + return fmt.Sprintf("**** **** **** %s", c.CardNumber[len(c.CardNumber)-4:]) +} diff --git a/internal/model/credit_transaction.go b/internal/model/credit_transaction.go new file mode 100644 index 0000000..0c7359a --- /dev/null +++ b/internal/model/credit_transaction.go @@ -0,0 +1,29 @@ +package model + +import "time" + +// CreditTransaction 积分交易记录 +type CreditTransaction struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Amount int `gorm:"not null" json:"amount"` // 正=充值,负=消费 + Type string `gorm:"size:30;not null" json:"type"` + Description string `gorm:"size:500;default:''" json:"description"` + TaskID *uint `gorm:"index" json:"task_id"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + User User `gorm:"foreignKey:UserID" json:"-"` +} + +// 交易类型常量 +const ( + TxTypeRecharge = "recharge" + TxTypeConsume = "consume" + TxTypeRefund = "refund" + TxTypeFreeTrial = "free_trial" + TxTypeFreeTrialConsume = "free_trial_consume" + TxTypeFreeTrialRefund = "free_trial_refund" + TxTypeRedeem = "redeem" + TxTypePurchase = "purchase" // New-API 余额购买 credits + TxTypeNewUserBonus = "new_user_bonus" // 新用户赠送积分 +) diff --git a/internal/model/database.go b/internal/model/database.go new file mode 100644 index 0000000..62ced2b --- /dev/null +++ b/internal/model/database.go @@ -0,0 +1,52 @@ +package model + +import ( + "time" + + "github.com/rs/zerolog/log" + "gorm.io/driver/postgres" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// ConnectDB 连接 PostgreSQL 并自动迁移 +func ConnectDB(dsn string) *gorm.DB { + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Warn), + }) + if err != nil { + log.Fatal().Err(err).Msg("数据库连接失败") + } + + // 配置连接池 + sqlDB, err := db.DB() + if err != nil { + log.Fatal().Err(err).Msg("获取底层数据库连接失败") + } + sqlDB.SetMaxOpenConns(50) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(time.Hour) + + // 自动迁移(开发环境用,生产用 golang-migrate) + if err := db.AutoMigrate( + &User{}, + &Task{}, + &TaskResult{}, + &CreditTransaction{}, + &SystemSetting{}, + &RedemptionCode{}, + &Announcement{}, + &Notification{}, + &NotificationRead{}, + &HFToken{}, + &HFSpace{}, + &UserProxy{}, + &ProxyPoolEntry{}, + &CardPoolEntry{}, + ); err != nil { + log.Fatal().Err(err).Msg("数据库迁移失败") + } + + log.Info().Msg("数据库连接成功") + return db +} diff --git a/internal/model/hf_space.go b/internal/model/hf_space.go new file mode 100644 index 0000000..7699ee1 --- /dev/null +++ b/internal/model/hf_space.go @@ -0,0 +1,29 @@ +package model + +import "time" + +// HFToken — HF 账号令牌 +type HFToken struct { + ID uint `gorm:"primaryKey" json:"id"` + Label string `gorm:"size:100;not null" json:"label"` + Username string `gorm:"size:100" json:"username"` // whoami 自动填充 + Token string `gorm:"size:200;not null" json:"token"` + IsValid bool `gorm:"default:true" json:"is_valid"` + SpaceUsed int `gorm:"default:0" json:"space_used"` // 关联 Space 数量 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// HFSpace — Space 注册表(替代 hf_registry.json) +type HFSpace struct { + ID uint `gorm:"primaryKey" json:"id"` + Service string `gorm:"size:20;not null;index" json:"service"` // openai/grok/kiro/gemini/ts + RepoID string `gorm:"size:200;not null" json:"repo_id"` + URL string `gorm:"size:500;not null" json:"url"` + TokenID uint `gorm:"index" json:"token_id"` + Status string `gorm:"size:20;default:'unknown'" json:"status"` // healthy/banned/sleeping/dead/unknown + StatusCode int `gorm:"default:0" json:"status_code"` + LastCheckAt *time.Time `json:"last_check_at"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} diff --git a/internal/model/notification.go b/internal/model/notification.go new file mode 100644 index 0000000..c1fd073 --- /dev/null +++ b/internal/model/notification.go @@ -0,0 +1,21 @@ +package model + +import "time" + +// Notification 系统通知(UserID=0 表示广播) +type Notification struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` // 0 = 广播给所有用户 + Title string `gorm:"size:200;not null" json:"title"` + Content string `gorm:"type:text;not null" json:"content"` + IsRead bool `gorm:"default:false;index" json:"is_read"` // 仅用于个人通知(user_id>0) + CreatedBy uint `json:"created_by"` + CreatedAt time.Time `gorm:"autoCreateTime;index" json:"created_at"` +} + +// NotificationRead 广播通知的用户已读记录 +type NotificationRead struct { + NotificationID uint `gorm:"primaryKey" json:"notification_id"` + UserID uint `gorm:"primaryKey" json:"user_id"` + ReadAt time.Time `gorm:"autoCreateTime" json:"read_at"` +} diff --git a/internal/model/proxy_pool_entry.go b/internal/model/proxy_pool_entry.go new file mode 100644 index 0000000..db92a92 --- /dev/null +++ b/internal/model/proxy_pool_entry.go @@ -0,0 +1,51 @@ +package model + +import ( + "fmt" + "net/url" + "time" +) + +// ProxyPoolEntry 全局代理池条目(管理员管理,所有用户共享) +type ProxyPoolEntry struct { + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100" json:"name"` + Protocol string `gorm:"size:20;not null;default:'socks5'" json:"protocol"` // socks5, http, https + Host string `gorm:"size:200;not null;uniqueIndex:idx_host_port" json:"host"` + Port int `gorm:"not null;uniqueIndex:idx_host_port" json:"port"` + Username string `gorm:"size:100" json:"username"` + Password string `gorm:"size:200" json:"password"` + Country string `gorm:"size:10" json:"country"` // 国家代码,如 US, JP + Source string `gorm:"size:50" json:"source"` // 来源:manual, imported, fetched + IsHealthy bool `gorm:"default:true;index" json:"is_healthy"` + LastCheckedAt *time.Time `json:"last_checked_at"` + LatencyMs int `gorm:"default:0" json:"latency_ms"` // 最近一次检测延迟 + FailCount int `gorm:"default:0" json:"fail_count"` // 连续失败次数 + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` +} + +// URL 生成完整代理地址(凭证经过 URL 编码,避免特殊字符破坏解析) +func (p *ProxyPoolEntry) URL() string { + u := &url.URL{ + Scheme: p.Protocol, + Host: fmt.Sprintf("%s:%d", p.Host, p.Port), + } + if p.Username != "" { + u.User = url.UserPassword(p.Username, p.Password) + } + return u.String() +} + +// MaskedURL 脱敏代理地址(日志用) +func (p *ProxyPoolEntry) MaskedURL() string { + auth := "" + if p.Username != "" { + auth = "***@" + } + return fmt.Sprintf("%s://%s%s:%d", p.Protocol, auth, p.Host, p.Port) +} + +// HostPort 返回 host:port 格式(用于去重和 TCP 连通性检测) +func (p *ProxyPoolEntry) HostPort() string { + return fmt.Sprintf("%s:%d", p.Host, p.Port) +} diff --git a/internal/model/redemption_code.go b/internal/model/redemption_code.go new file mode 100644 index 0000000..e92667b --- /dev/null +++ b/internal/model/redemption_code.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// RedemptionCode 兑换码 +type RedemptionCode struct { + ID uint `gorm:"primaryKey" json:"id"` + Code string `gorm:"size:32;uniqueIndex;not null" json:"code"` // XXXX-XXXX-XXXX-XXXX + Credits int `gorm:"not null" json:"credits"` + BatchName string `gorm:"size:100;default:''" json:"batch_name"` + CreatedBy *uint `json:"created_by"` + RedeemedBy *uint `json:"redeemed_by"` + IsUsed bool `gorm:"default:false" json:"is_used"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + RedeemedAt *time.Time `json:"redeemed_at"` + + Creator *User `gorm:"foreignKey:CreatedBy" json:"-"` + Redeemer *User `gorm:"foreignKey:RedeemedBy" json:"-"` +} diff --git a/internal/model/system_setting.go b/internal/model/system_setting.go new file mode 100644 index 0000000..fbb554f --- /dev/null +++ b/internal/model/system_setting.go @@ -0,0 +1,148 @@ +package model + +import "time" + +// SystemSetting 系统设置(键值对) +type SystemSetting struct { + Key string `gorm:"primaryKey;size:100" json:"key"` + Value string `gorm:"type:text;default:''" json:"value"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// SettingDefinition 设置项定义 +type SettingDefinition struct { + Key string `json:"key"` + Label string `json:"label"` + Group string `json:"group"` + Type string `json:"type"` // text, number, boolean, url, password + DefaultValue string `json:"default_value"` + Description string `json:"description"` + IsSensitive bool `json:"is_sensitive"` + ReadOnly bool `json:"read_only,omitempty"` +} + +// DefaultSettings 系统设置项定义(9 组:infra / email / captcha / platform_grok / platform_openai / platform_kiro / platform_gemini / user_policy / hfspace) +var DefaultSettings = []SettingDefinition{ + + // ── 基础设施(infra)── 原 newapi + proxy + crs 合并 + {Key: "newapi_base_url", Label: "New-API 地址", Group: "infra", Type: "url", DefaultValue: "", Description: "New-API 系统的基础 URL"}, + {Key: "newapi_admin_token", Label: "New-API 管理令牌", Group: "infra", Type: "password", DefaultValue: "", Description: "New-API 管理员 API Token", IsSensitive: true}, + {Key: "newapi_admin_id", Label: "New-API 管理员 ID", Group: "infra", Type: "number", DefaultValue: "0", Description: "New-API 管理员用户 ID"}, + {Key: "newapi_cost_per_reg", Label: "单次注册成本(USD)", Group: "infra", Type: "number", DefaultValue: "0.004", Description: "每次注册扣减的 USD 金额"}, + {Key: "default_proxy", Label: "默认代理", Group: "infra", Type: "text", DefaultValue: "", Description: "默认 HTTP/SOCKS5 代理地址"}, + {Key: "kookeey_api", Label: "Kookeey API", Group: "infra", Type: "url", DefaultValue: "", Description: "Kookeey 动态住宅代理 API 地址"}, + {Key: "crs_endpoint", Label: "CRS 端点", Group: "infra", Type: "url", DefaultValue: "", Description: "Codex 注册系统上报端点"}, + {Key: "crs_token", Label: "CRS Token", Group: "infra", Type: "password", DefaultValue: "", Description: "CRS 认证令牌", IsSensitive: true}, + + // ── 邮箱服务(email)── 不变 + {Key: "yydsmail_api_key", Label: "YYDS Mail API Key", Group: "email", Type: "password", DefaultValue: "", Description: "YYDS Mail 临时邮箱 API 密钥", IsSensitive: true}, + {Key: "yydsmail_base_url", Label: "YYDS Mail 服务地址", Group: "email", Type: "url", DefaultValue: "", Description: "YYDS Mail API 基础 URL"}, + {Key: "email_provider_priority", Label: "邮箱优先级", Group: "email", Type: "text", DefaultValue: "yydsmail", Description: "邮箱服务优先级(逗号分隔,如 yydsmail)"}, + + // ── 验证码(captcha)── 不变 + {Key: "turnstile_solver_url", Label: "Turnstile Solver URL", Group: "captcha", Type: "url", DefaultValue: "http://127.0.0.1:5072", Description: "Turnstile 验证码解决器地址"}, + {Key: "turnstile_solver_proxy", Label: "Solver 专用代理", Group: "captcha", Type: "text", DefaultValue: "", Description: "Turnstile solver 浏览器专用代理(socks5://user:pass@host:port),仅求解使用,不影响注册流程"}, + {Key: "capsolver_key", Label: "CapSolver Key", Group: "captcha", Type: "password", DefaultValue: "", Description: "CapSolver 云端验证码求解 API Key", IsSensitive: true}, + {Key: "yescaptcha_key", Label: "YesCaptcha Key", Group: "captcha", Type: "password", DefaultValue: "", Description: "YesCaptcha 云端解码 API Key", IsSensitive: true}, + {Key: "cf_bypass_solver_url", Label: "CF-Bypass Solver URL", Group: "captcha", Type: "url", DefaultValue: "http://127.0.0.1:5073", Description: "cloudflare-bypass-2026 求解器地址(级联备用)"}, + + // ── Grok 平台(platform_grok)── 原 grok + platforms/limits 中 grok 相关 + {Key: "platform_grok_enabled", Label: "Grok 注册开关", Group: "platform_grok", Type: "boolean", DefaultValue: "true", Description: "是否开放 Grok 平台注册"}, + {Key: "grok_action_id", Label: "Grok Action ID", Group: "platform_grok", Type: "text", DefaultValue: "", Description: "Grok Turnstile action ID"}, + {Key: "grok_site_key", Label: "Grok Site Key", Group: "platform_grok", Type: "text", DefaultValue: "0x4AAAAAAAhr9JGVDZbrZOo0", Description: "Grok Turnstile site key"}, + {Key: "camoufox_reg_url", Label: "Camoufox 注册服务", Group: "platform_grok", Type: "url", DefaultValue: "", Description: "Camoufox 浏览器注册服务地址,留空则使用 HTTP 模式"}, + {Key: "grok_reg_url", Label: "Grok 远程注册服务", Group: "platform_grok", Type: "url", DefaultValue: "", Description: "Grok HF 远程注册服务地址(支持逗号分隔多节点轮询),留空则走本地协议"}, + {Key: "grok_proxy", Label: "Grok 专用代理", Group: "platform_grok", Type: "text", DefaultValue: "", Description: "Grok 注册专用固定代理(http/socks5://user:pass@host:port),proxy_mode 为 fixed 时生效,留空则回退到代理池"}, + {Key: "grok_proxy_mode", Label: "Grok 代理模式", Group: "platform_grok", Type: "text", DefaultValue: "pool", Description: "代理策略:pool=代理池轮询, fixed=使用 grok_proxy 固定代理, direct=不使用代理, smart=按延迟智能选择"}, + {Key: "grok_email_providers", Label: "Grok 邮箱选择", Group: "platform_grok", Type: "text", DefaultValue: "", Description: "Grok 使用的邮箱服务(逗号分隔多选),留空使用全局优先级"}, + {Key: "grok_max_concurrent", Label: "Grok 最大并发", Group: "platform_grok", Type: "number", DefaultValue: "500", Description: "Grok 平台最大同时运行任务数(纯 HTTP,可设较高值)"}, + {Key: "grok_max_concurrent_users", Label: "Grok 同时注册用户数", Group: "platform_grok", Type: "number", DefaultValue: "0", Description: "Grok 平台同时允许多少个不同用户注册,0 表示不限制(回退到全局设置)"}, + {Key: "platform_grok_unit_price", Label: "Grok 单价(USD)", Group: "platform_grok", Type: "text", DefaultValue: "", Description: "留空使用全局 newapi_cost_per_reg"}, + {Key: "platform_grok_free_until", Label: "Grok 限时免费截止", Group: "platform_grok", Type: "text", DefaultValue: "", Description: "YYYY-MM-DD,留空表示不免费"}, + {Key: "platform_grok_free_daily_limit", Label: "Grok 免费每日上限", Group: "platform_grok", Type: "number", DefaultValue: "5", Description: "免费模式下单用户每天最多注册数量,0 表示不限制"}, + {Key: "platform_grok_free_task_limit", Label: "Grok 免费单任务上限", Group: "platform_grok", Type: "number", DefaultValue: "2", Description: "免费模式下单次任务最大注册数量"}, + {Key: "platform_grok_free_cooldown", Label: "Grok 免费冷却(分钟)", Group: "platform_grok", Type: "number", DefaultValue: "30", Description: "免费模式下两次任务之间的冷却时间(分钟),0 表示不冷却"}, + {Key: "platform_grok_daily_limit", Label: "Grok 每日注册上限", Group: "platform_grok", Type: "number", DefaultValue: "0", Description: "该平台单用户每日最大注册总量(凌晨6点重置),0 表示不限制,付费/免费均生效"}, + {Key: "platform_grok_task_limit", Label: "Grok 单任务注册上限", Group: "platform_grok", Type: "number", DefaultValue: "0", Description: "该平台单次任务最大注册数量,0 表示不限制(回退到全局 max_target),付费/免费均生效"}, + + // ── OpenAI 平台(platform_openai)── 原 openai + platforms/limits 中 openai 相关 + {Key: "platform_openai_enabled", Label: "OpenAI 注册开关", Group: "platform_openai", Type: "boolean", DefaultValue: "true", Description: "是否开放 OpenAI 平台注册"}, + {Key: "openai_reg_url", Label: "OpenAI 注册服务", Group: "platform_openai", Type: "url", DefaultValue: "", Description: "OpenAI 浏览器注册服务地址(openai-reg 容器),留空则走本机脚本降级"}, + {Key: "openai_proxy", Label: "OpenAI 专用代理", Group: "platform_openai", Type: "text", DefaultValue: "", Description: "OpenAI 注册专用固定代理(http/socks5://user:pass@host:port),proxy_mode 为 fixed 时生效"}, + {Key: "openai_proxy_mode", Label: "OpenAI 代理模式", Group: "platform_openai", Type: "text", DefaultValue: "pool", Description: "代理策略:pool=代理池轮询, fixed=使用 openai_proxy 固定代理, direct=不使用代理, smart=按延迟智能选择"}, + {Key: "openai_email_providers", Label: "OpenAI 邮箱选择", Group: "platform_openai", Type: "text", DefaultValue: "", Description: "OpenAI 使用的邮箱服务(逗号分隔多选),留空使用全局优先级"}, + {Key: "openai_max_concurrent", Label: "OpenAI 最大并发", Group: "platform_openai", Type: "number", DefaultValue: "500", Description: "OpenAI 平台最大同时运行任务数"}, + {Key: "openai_max_concurrent_users", Label: "OpenAI 同时注册用户数", Group: "platform_openai", Type: "number", DefaultValue: "0", Description: "OpenAI 平台同时允许多少个不同用户注册,0 表示不限制(回退到全局设置)"}, + {Key: "platform_openai_unit_price", Label: "OpenAI 单价(USD)", Group: "platform_openai", Type: "text", DefaultValue: "", Description: "留空使用全局 newapi_cost_per_reg"}, + {Key: "platform_openai_free_until", Label: "OpenAI 限时免费截止", Group: "platform_openai", Type: "text", DefaultValue: "", Description: "YYYY-MM-DD,留空表示不免费"}, + {Key: "platform_openai_free_daily_limit", Label: "OpenAI 免费每日上限", Group: "platform_openai", Type: "number", DefaultValue: "5", Description: "免费模式下单用户每天最多注册数量,0 表示不限制"}, + {Key: "platform_openai_free_task_limit", Label: "OpenAI 免费单任务上限", Group: "platform_openai", Type: "number", DefaultValue: "2", Description: "免费模式下单次任务最大注册数量"}, + {Key: "platform_openai_free_cooldown", Label: "OpenAI 免费冷却(分钟)", Group: "platform_openai", Type: "number", DefaultValue: "30", Description: "免费模式下两次任务之间的冷却时间(分钟),0 表示不冷却"}, + {Key: "platform_openai_daily_limit", Label: "OpenAI 每日注册上限", Group: "platform_openai", Type: "number", DefaultValue: "0", Description: "该平台单用户每日最大注册总量(凌晨6点重置),0 表示不限制,付费/免费均生效"}, + {Key: "platform_openai_task_limit", Label: "OpenAI 单任务注册上限", Group: "platform_openai", Type: "number", DefaultValue: "0", Description: "该平台单次任务最大注册数量,0 表示不限制(回退到全局 max_target),付费/免费均生效"}, + + // ── Kiro 平台(platform_kiro)── 原 kiro + platforms/limits 中 kiro 相关 + {Key: "platform_kiro_enabled", Label: "Kiro 注册开关", Group: "platform_kiro", Type: "boolean", DefaultValue: "true", Description: "是否开放 Kiro 平台注册"}, + {Key: "kiro_reg_url", Label: "Kiro 注册服务", Group: "platform_kiro", Type: "url", DefaultValue: "http://127.0.0.1:5076", Description: "AWS Builder ID 浏览器注册服务地址(aws-builder-id-reg 容器)"}, + {Key: "kiro_email_providers", Label: "Kiro 邮箱选择", Group: "platform_kiro", Type: "text", DefaultValue: "yydsmail", Description: "Kiro 使用的邮箱服务(逗号分隔多选),留空使用全局优先级"}, + {Key: "kiro_proxy", Label: "Kiro 专用代理", Group: "platform_kiro", Type: "text", DefaultValue: "", Description: "Kiro 注册专用代理地址(http://user:pass@host:port),proxy_mode 为 fixed 时生效,留空使用默认代理"}, + {Key: "kiro_proxy_mode", Label: "Kiro 代理模式", Group: "platform_kiro", Type: "text", DefaultValue: "pool", Description: "代理策略:pool=代理池轮询, fixed=使用 kiro_proxy 固定代理, direct=不使用代理, smart=按延迟智能选择"}, + {Key: "kiro_max_concurrent", Label: "Kiro 最大并发", Group: "platform_kiro", Type: "number", DefaultValue: "8", Description: "Kiro 平台最大同时运行任务数(受浏览器节点内存限制)"}, + {Key: "kiro_max_concurrent_users", Label: "Kiro 同时注册用户数", Group: "platform_kiro", Type: "number", DefaultValue: "0", Description: "Kiro 平台同时允许多少个不同用户注册,0 表示不限制(回退到全局设置)"}, + {Key: "platform_kiro_unit_price", Label: "Kiro 单价(USD)", Group: "platform_kiro", Type: "text", DefaultValue: "", Description: "留空使用全局 newapi_cost_per_reg"}, + {Key: "platform_kiro_free_until", Label: "Kiro 限时免费截止", Group: "platform_kiro", Type: "text", DefaultValue: "", Description: "YYYY-MM-DD,留空表示不免费"}, + {Key: "platform_kiro_free_daily_limit", Label: "Kiro 免费每日上限", Group: "platform_kiro", Type: "number", DefaultValue: "5", Description: "免费模式下单用户每天最多注册数量,0 表示不限制"}, + {Key: "platform_kiro_free_task_limit", Label: "Kiro 免费单任务上限", Group: "platform_kiro", Type: "number", DefaultValue: "2", Description: "免费模式下单次任务最大注册数量"}, + {Key: "platform_kiro_free_cooldown", Label: "Kiro 免费冷却(分钟)", Group: "platform_kiro", Type: "number", DefaultValue: "30", Description: "免费模式下两次任务之间的冷却时间(分钟),0 表示不冷却"}, + {Key: "platform_kiro_daily_limit", Label: "Kiro 每日注册上限", Group: "platform_kiro", Type: "number", DefaultValue: "0", Description: "该平台单用户每日最大注册总量(凌晨6点重置),0 表示不限制,付费/免费均生效"}, + {Key: "platform_kiro_task_limit", Label: "Kiro 单任务注册上限", Group: "platform_kiro", Type: "number", DefaultValue: "0", Description: "该平台单次任务最大注册数量,0 表示不限制(回退到全局 max_target),付费/免费均生效"}, + + // ── Gemini 平台(platform_gemini)── Gemini Business 注册 + {Key: "platform_gemini_enabled", Label: "Gemini 注册开关", Group: "platform_gemini", Type: "boolean", DefaultValue: "true", Description: "是否开放 Gemini Business 平台注册"}, + {Key: "gemini_reg_url", Label: "Gemini 注册服务", Group: "platform_gemini", Type: "url", DefaultValue: "", Description: "Gemini 浏览器注册服务地址(复用 aws-builder-id-reg 的 /gemini/process),留空则使用 kiro_reg_url"}, + {Key: "gemini_email_providers", Label: "Gemini 邮箱选择", Group: "platform_gemini", Type: "text", DefaultValue: "yydsmail", Description: "Gemini 使用的邮箱服务(逗号分隔多选),留空使用全局优先级"}, + {Key: "gemini_proxy", Label: "Gemini 专用代理", Group: "platform_gemini", Type: "text", DefaultValue: "", Description: "Gemini 注册专用代理地址(http://user:pass@host:port),proxy_mode 为 fixed 时生效,留空使用默认代理"}, + {Key: "gemini_proxy_mode", Label: "Gemini 代理模式", Group: "platform_gemini", Type: "text", DefaultValue: "pool", Description: "代理策略:pool=代理池轮询, fixed=使用 gemini_proxy 固定代理, direct=不使用代理, smart=按延迟智能选择"}, + {Key: "gemini_max_concurrent", Label: "Gemini 最大并发", Group: "platform_gemini", Type: "number", DefaultValue: "8", Description: "Gemini 平台最大同时运行任务数(受浏览器节点内存限制)"}, + {Key: "gemini_max_concurrent_users", Label: "Gemini 同时注册用户数", Group: "platform_gemini", Type: "number", DefaultValue: "0", Description: "Gemini 平台同时允许多少个不同用户注册,0 表示不限制"}, + {Key: "platform_gemini_unit_price", Label: "Gemini 单价(USD)", Group: "platform_gemini", Type: "text", DefaultValue: "", Description: "留空使用全局 newapi_cost_per_reg"}, + {Key: "platform_gemini_free_until", Label: "Gemini 限时免费截止", Group: "platform_gemini", Type: "text", DefaultValue: "", Description: "YYYY-MM-DD,留空表示不免费"}, + {Key: "platform_gemini_free_daily_limit", Label: "Gemini 免费每日上限", Group: "platform_gemini", Type: "number", DefaultValue: "5", Description: "免费模式下单用户每天最多注册数量,0 表示不限制"}, + {Key: "platform_gemini_free_task_limit", Label: "Gemini 免费单任务上限", Group: "platform_gemini", Type: "number", DefaultValue: "2", Description: "免费模式下单次任务最大注册数量"}, + {Key: "platform_gemini_free_cooldown", Label: "Gemini 免费冷却(分钟)", Group: "platform_gemini", Type: "number", DefaultValue: "30", Description: "免费模式下两次任务之间的冷却时间(分钟),0 表示不冷却"}, + {Key: "platform_gemini_daily_limit", Label: "Gemini 每日注册上限", Group: "platform_gemini", Type: "number", DefaultValue: "0", Description: "该平台单用户每日最大注册总量(凌晨6点重置),0 表示不限制,付费/免费均生效"}, + {Key: "platform_gemini_task_limit", Label: "Gemini 单任务注册上限", Group: "platform_gemini", Type: "number", DefaultValue: "0", Description: "该平台单次任务最大注册数量,0 表示不限制(回退到全局 max_target),付费/免费均生效"}, + + // ── 用户策略(user_policy)── 原 trial + limits 中全局项 + {Key: "free_trial_enabled", Label: "免费试用开关", Group: "user_policy", Type: "boolean", DefaultValue: "true", Description: "是否允许新用户领取免费试用"}, + {Key: "free_trial_count", Label: "免费试用次数", Group: "user_policy", Type: "number", DefaultValue: "2", Description: "新用户可领取的免费注册次数"}, + {Key: "new_user_bonus", Label: "新用户赠送积分", Group: "user_policy", Type: "number", DefaultValue: "0", Description: "新用户首次登录自动赠送积分数,0 表示不赠送"}, + {Key: "max_threads", Label: "最大线程(前端显示)", Group: "user_policy", Type: "number", DefaultValue: "16", Description: "前端线程选择器上限,实际并发由 Worker Pool 统一管理"}, + {Key: "max_target", Label: "最大注册数", Group: "user_policy", Type: "number", DefaultValue: "1000", Description: "单任务最大注册数量"}, + {Key: "max_concurrent_tasks", Label: "全局最大并发", Group: "user_policy", Type: "number", DefaultValue: "1000", Description: "系统全局最大同时运行任务数(安全上限)"}, + {Key: "max_concurrent_users", Label: "同时注册用户数", Group: "user_policy", Type: "number", DefaultValue: "0", Description: "同时允许多少个不同用户执行注册任务,0 表示不限制。超出的用户自动排队,前方用户完成后自动开始"}, + {Key: "daily_reg_limit", Label: "每日注册上限", Group: "user_policy", Type: "number", DefaultValue: "0", Description: "单用户每天最多注册数量(凌晨6点重置),0 表示不限制"}, + {Key: "thread_tiers", Label: "线程阶梯配置", Group: "user_policy", Type: "text", + DefaultValue: `[{"max":5,"threads":1},{"max":20,"threads":2},{"max":100,"threads":3},{"max":300,"threads":5},{"max":500,"threads":8},{"max":99999,"threads":12}]`, + Description: "前端线程建议值(仅显示用),实际并发由 Worker Pool 统一调度。JSON 数组,按 max 升序"}, + + // ── HF 空间(hfspace)── Pool 配置 + 运维 + CF 同步 + 部署 + {Key: "hf_instance_count", Label: "HF 实例数量", Group: "hfspace", Type: "number", DefaultValue: "20", Description: "当前部署的 HF Space 实例数量,弹性管理后自动更新,10 秒生效", ReadOnly: true}, + {Key: "workers_per_instance", Label: "每实例并发数", Group: "hfspace", Type: "number", DefaultValue: "10", Description: "每个实例承载的并发注册数(纯 HTTP 建议 10),Pool 总大小 = 实例数 × 此值"}, + {Key: "openai_max_parallel", Label: "OpenAI 单任务并发上限", Group: "hfspace", Type: "number", DefaultValue: "50", Description: "单个 OpenAI 注册任务最多同时执行的注册数,防止打爆 Space 池"}, + {Key: "grok_max_parallel", Label: "Grok 单任务并发上限", Group: "hfspace", Type: "number", DefaultValue: "50", Description: "单个 Grok 注册任务最多同时执行的注册数,防止打爆 Space 池"}, + {Key: "kiro_max_parallel", Label: "Kiro 单任务并发上限", Group: "hfspace", Type: "number", DefaultValue: "15", Description: "单个 Kiro 注册任务最多同时执行的注册数(Python 服务并发低)"}, + {Key: "gemini_max_parallel", Label: "Gemini 单任务并发上限", Group: "hfspace", Type: "number", DefaultValue: "15", Description: "单个 Gemini 注册任务最多同时执行的注册数(Python 服务并发低)"}, + {Key: "hf_default_target", Label: "默认目标节点数", Group: "hfspace", Type: "number", DefaultValue: "5", Description: "每服务默认的目标健康 Space 数量(弹性管理用)"}, + {Key: "hf_health_timeout", Label: "健康检查超时(秒)", Group: "hfspace", Type: "number", DefaultValue: "8", Description: "单个 Space /health 请求超时时间"}, + {Key: "hf_release_url_openai", Label: "OpenAI Release URL", Group: "hfspace", Type: "url", DefaultValue: "https://github.com/xiaolajiaoyyds/regplatformm/releases/download/inference-runtime-latest/inference-runtime.zip", Description: "OpenAI Space 部署用的 GitHub Release URL(弹性管理自动扩容用)"}, + {Key: "hf_release_url_grok", Label: "Grok Release URL", Group: "hfspace", Type: "url", DefaultValue: "https://github.com/xiaolajiaoyyds/regplatformm/releases/download/stream-worker-latest/stream-worker.zip", Description: "Grok Space 部署用的 GitHub Release URL"}, + {Key: "hf_release_url_kiro", Label: "Kiro Release URL", Group: "hfspace", Type: "url", DefaultValue: "https://github.com/xiaolajiaoyyds/regplatformm/releases/download/browser-agent-latest/browser-agent.zip", Description: "Kiro Space 部署用的 GitHub Release URL"}, + {Key: "hf_release_url_gemini", Label: "Gemini Release URL", Group: "hfspace", Type: "url", DefaultValue: "https://github.com/xiaolajiaoyyds/regplatformm/releases/download/gemini-agent-latest/gemini-agent.zip", Description: "Gemini Space 部署用的 GitHub Release URL"}, + {Key: "hf_release_url_ts", Label: "TS Release URL", Group: "hfspace", Type: "url", DefaultValue: "https://github.com/xiaolajiaoyyds/regplatformm/releases/download/net-toolkit-latest/net-toolkit.zip", Description: "TS Space 部署用的 GitHub Release URL"}, + {Key: "hf_cf_account_id", Label: "CF Account ID", Group: "hfspace", Type: "text", DefaultValue: "", Description: "Cloudflare Account ID(用于更新 Worker 环境变量)"}, + {Key: "hf_cf_api_token", Label: "CF API Token", Group: "hfspace", Type: "password", DefaultValue: "", Description: "Cloudflare API Token", IsSensitive: true}, + {Key: "hf_cf_worker_name", Label: "CF Worker 名称", Group: "hfspace", Type: "text", DefaultValue: "", Description: "CF Worker 脚本名称"}, + {Key: "hf_gh_pat", Label: "GitHub PAT", Group: "hfspace", Type: "password", DefaultValue: "", Description: "GitHub Personal Access Token(部署 Space 用)", IsSensitive: true}, + {Key: "hf_proxy_url", Label: "Space 出口代理", Group: "hfspace", Type: "text", DefaultValue: "", Description: "HF Space 出站代理地址(如 socks5://ip:port),部署/更新时注入为 PROXY_URL secret,留空则不使用代理"}, +} diff --git a/internal/model/task.go b/internal/model/task.go new file mode 100644 index 0000000..390d3e8 --- /dev/null +++ b/internal/model/task.go @@ -0,0 +1,30 @@ +package model + +import "time" + +// Task 注册任务 +type Task struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Platform string `gorm:"size:20;default:'grok'" json:"platform"` + Status string `gorm:"size:20;default:'pending'" json:"status"` // pending/queued/running/stopping/completed/failed/stopped + TargetCount int `gorm:"not null" json:"target_count"` + ThreadCount int `gorm:"default:4" json:"thread_count"` + SuccessCount int `gorm:"default:0" json:"success_count"` + FailCount int `gorm:"default:0" json:"fail_count"` + CreditsReserved int `gorm:"default:0" json:"credits_reserved"` + CreditsConsumed int `gorm:"default:0" json:"credits_consumed"` + ProxyURL string `gorm:"size:500;default:''" json:"proxy_url"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + StartedAt *time.Time `json:"started_at"` + StoppedAt *time.Time `json:"stopped_at"` + + // 关联 + User User `gorm:"foreignKey:UserID" json:"-"` + Results []TaskResult `gorm:"foreignKey:TaskID" json:"-"` +} + +// IsDone 任务是否已结束 +func (t *Task) IsDone() bool { + return t.Status == "completed" || t.Status == "failed" || t.Status == "stopped" +} diff --git a/internal/model/task_result.go b/internal/model/task_result.go new file mode 100644 index 0000000..cc5e3de --- /dev/null +++ b/internal/model/task_result.go @@ -0,0 +1,33 @@ +package model + +import ( + "time" + + "gorm.io/datatypes" +) + +// TaskResult 注册成功结果 +type TaskResult struct { + ID uint `gorm:"primaryKey" json:"id"` + TaskID uint `gorm:"not null;index" json:"task_id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Platform string `gorm:"size:20;default:'grok'" json:"platform"` + Email string `gorm:"size:200;default:''" json:"email"` + CredentialData datatypes.JSON `gorm:"type:jsonb;default:'{}'" json:"credential_data"` // JSONB 存储凭据 + SSOToken string `gorm:"type:text;default:''" json:"auth_token"` // 向后兼容 Grok + NsfwEnabled bool `gorm:"default:false" json:"feature_enabled"` + IsArchived bool `gorm:"default:false;index" json:"is_archived"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + + // 软禁用(验活失效时标记而非硬删除,可恢复) + Disabled bool `gorm:"default:false;index" json:"disabled"` + DisabledReason string `gorm:"size:500;default:''" json:"disabled_reason"` + DisabledAt *time.Time `json:"disabled_at"` + + // 最后验活时间 + LastValidatedAt *time.Time `json:"last_validated_at"` + + // 关联 + Task Task `gorm:"foreignKey:TaskID" json:"-"` + User User `gorm:"foreignKey:UserID" json:"-"` +} diff --git a/internal/model/user.go b/internal/model/user.go new file mode 100644 index 0000000..aef12fc --- /dev/null +++ b/internal/model/user.go @@ -0,0 +1,32 @@ +package model + +import "time" + +// User 用户模型 +type User struct { + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:100;not null;uniqueIndex" json:"username"` + PasswordHash string `gorm:"size:200;default:''" json:"-"` + Name string `gorm:"size:200;default:''" json:"name"` + Email string `gorm:"size:200;default:''" json:"email"` + AvatarURL string `gorm:"size:500;default:''" json:"avatar_url"` + Role int `gorm:"default:1" json:"role"` // 1=user, 10=admin, 100=root + Credits int `gorm:"default:0" json:"credits"` + FreeTrialUsed bool `gorm:"default:false" json:"free_trial_used"` + FreeTrialRemaining int `gorm:"default:0" json:"free_trial_remaining"` + IsActive bool `gorm:"default:true" json:"is_active"` + IsAdmin bool `gorm:"default:false" json:"is_admin"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // 可选:SSO 对接字段(用于外部系统集成) + LinuxdoID int `gorm:"default:0" json:"linuxdo_id,omitempty"` + NewapiID int `gorm:"default:0;index" json:"newapi_id,omitempty"` + TrustLevel int `gorm:"default:0" json:"trust_level,omitempty"` + NewapiQuota int `gorm:"default:0" json:"newapi_quota,omitempty"` +} + +// IsRoot 是否超级管理员 +func (u *User) IsRoot() bool { + return u.Role >= 100 +} diff --git a/internal/model/user_proxy.go b/internal/model/user_proxy.go new file mode 100644 index 0000000..1af946d --- /dev/null +++ b/internal/model/user_proxy.go @@ -0,0 +1,39 @@ +package model + +import ( + "fmt" + "time" +) + +// UserProxy 用户保存的代理 +type UserProxy struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"not null;index" json:"user_id"` + Name string `gorm:"size:100" json:"name"` + Protocol string `gorm:"size:10;not null" json:"protocol"` // http, https, socks5 + Host string `gorm:"size:200;not null" json:"host"` + Port int `gorm:"not null" json:"port"` + Username string `gorm:"size:100" json:"username"` + Password string `gorm:"size:200" json:"password"` + IsDefault bool `gorm:"default:false" json:"is_default"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` +} + +// URL 生成完整代理地址 +func (p *UserProxy) URL() string { + auth := "" + if p.Username != "" { + auth = p.Username + ":" + p.Password + "@" + } + return fmt.Sprintf("%s://%s%s:%d", p.Protocol, auth, p.Host, p.Port) +} + +// MaskedURL 脱敏代理地址(日志用) +func (p *UserProxy) MaskedURL() string { + auth := "" + if p.Username != "" { + auth = "***@" + } + return fmt.Sprintf("%s://%s%s:%d", p.Protocol, auth, p.Host, p.Port) +} diff --git a/internal/pkg/cache/redis.go b/internal/pkg/cache/redis.go new file mode 100644 index 0000000..92eead4 --- /dev/null +++ b/internal/pkg/cache/redis.go @@ -0,0 +1,132 @@ +package cache + +import ( + "context" + "encoding/json" + "time" + + "github.com/redis/go-redis/v9" + "github.com/rs/zerolog/log" +) + +// RedisCache Redis 缓存层(L2) +// 设计为可选组件:REDIS_URL 为空时所有方法静默返回 miss,不影响业务 +type RedisCache struct { + client *redis.Client + prefix string // key 前缀,如 "regp:" +} + +// NewRedisCache 创建 Redis 缓存 +// redisURL 为空则返回空壳实例(所有操作静默降级) +func NewRedisCache(redisURL, prefix string) *RedisCache { + rc := &RedisCache{prefix: prefix} + if redisURL == "" { + log.Info().Msg("REDIS_URL 未配置,L2 缓存已禁用(纯内存模式)") + return rc + } + + opts, err := redis.ParseURL(redisURL) + if err != nil { + log.Error().Err(err).Str("url", redisURL).Msg("Redis URL 解析失败,L2 缓存已禁用") + return rc + } + + client := redis.NewClient(opts) + + // 连接测试(3 秒超时) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := client.Ping(ctx).Err(); err != nil { + log.Error().Err(err).Msg("Redis 连接失败,L2 缓存已禁用") + return rc + } + + rc.client = client + log.Info().Str("addr", opts.Addr).Msg("Redis L2 缓存已连接") + return rc +} + +// Available 检查 Redis 是否可用 +func (c *RedisCache) Available() bool { + return c.client != nil +} + +// key 拼接完整 key +func (c *RedisCache) key(k string) string { + return c.prefix + k +} + +// GetString 获取字符串值 +func (c *RedisCache) GetString(ctx context.Context, key string) (string, bool) { + if c.client == nil { + return "", false + } + val, err := c.client.Get(ctx, c.key(key)).Result() + if err != nil { + return "", false + } + return val, true +} + +// SetString 设置字符串值 +func (c *RedisCache) SetString(ctx context.Context, key, value string, ttl time.Duration) { + if c.client == nil { + return + } + if err := c.client.Set(ctx, c.key(key), value, ttl).Err(); err != nil { + log.Warn().Err(err).Str("key", key).Msg("Redis SET 失败") + } +} + +// GetJSON 获取 JSON 序列化的值 +func (c *RedisCache) GetJSON(ctx context.Context, key string, dest interface{}) bool { + if c.client == nil { + return false + } + val, err := c.client.Get(ctx, c.key(key)).Bytes() + if err != nil { + return false + } + if err := json.Unmarshal(val, dest); err != nil { + log.Warn().Err(err).Str("key", key).Msg("Redis JSON 反序列化失败") + return false + } + return true +} + +// SetJSON 设置 JSON 序列化的值 +func (c *RedisCache) SetJSON(ctx context.Context, key string, value interface{}, ttl time.Duration) { + if c.client == nil { + return + } + data, err := json.Marshal(value) + if err != nil { + log.Warn().Err(err).Str("key", key).Msg("Redis JSON 序列化失败") + return + } + if err := c.client.Set(ctx, c.key(key), data, ttl).Err(); err != nil { + log.Warn().Err(err).Str("key", key).Msg("Redis SET 失败") + } +} + +// Del 删除 key +func (c *RedisCache) Del(ctx context.Context, keys ...string) { + if c.client == nil { + return + } + fullKeys := make([]string, len(keys)) + for i, k := range keys { + fullKeys[i] = c.key(k) + } + if err := c.client.Del(ctx, fullKeys...).Err(); err != nil { + log.Warn().Err(err).Msg("Redis DEL 失败") + } +} + +// Close 关闭连接 +func (c *RedisCache) Close() error { + if c.client == nil { + return nil + } + return c.client.Close() +} diff --git a/internal/pkg/gptmail/client.go b/internal/pkg/gptmail/client.go new file mode 100644 index 0000000..4f8036b --- /dev/null +++ b/internal/pkg/gptmail/client.go @@ -0,0 +1,269 @@ +package gptmail + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" +) + +// Client GPTMail 邮箱服务客户端(支持多 key 均匀轮询 + 429 冷却跳过) +type Client struct { + baseURL string + keys []string // 多个 API Key + keyIdx atomic.Int64 // 轮询索引(每次请求 +1,均匀分配) + cooldowns map[int]time.Time // key 索引 → 冷却截止时间 + cdMu sync.RWMutex // cooldowns 读写锁 + httpClient *http.Client + mu sync.Mutex // 全局请求节流锁 + lastCall time.Time // 上次请求时间 + minDelay time.Duration // 最小请求间隔 +} + +// NewClient 创建 GPTMail 客户端,apiKey 支持逗号分隔多个 key 自动轮询 +func NewClient(baseURL, apiKey string) *Client { + keys := make([]string, 0) + for _, k := range strings.Split(apiKey, ",") { + k = strings.TrimSpace(k) + if k != "" { + keys = append(keys, k) + } + } + if len(keys) == 0 { + keys = append(keys, "") + } + return &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + keys: keys, + cooldowns: make(map[int]time.Time), + httpClient: &http.Client{ + Timeout: 15 * time.Second, + }, + minDelay: 1 * time.Second, + } +} + +// nextKey 获取下一个可用 key(均匀轮询 + 跳过冷却中的 key) +// 返回 key 字符串、对应索引、是否所有 key 都在冷却 +func (c *Client) nextKey() (string, int, bool) { + n := len(c.keys) + base := int(c.keyIdx.Add(1) - 1) // 先取再加,每次请求递增 + + c.cdMu.RLock() + defer c.cdMu.RUnlock() + + // 第一轮:找未冷却的 key + now := time.Now() + for i := 0; i < n; i++ { + idx := (base + i) % n + if until, ok := c.cooldowns[idx]; ok && now.Before(until) { + continue // 冷却中,跳过 + } + return c.keys[idx], idx, false + } + + // 所有 key 都在冷却 → 返回标记,让调用方快速失败 + return "", -1, true +} + +// markCooldown 标记某个 key 进入冷却期 +func (c *Client) markCooldown(idx int, duration time.Duration) { + c.cdMu.Lock() + defer c.cdMu.Unlock() + c.cooldowns[idx] = time.Now().Add(duration) +} + +// throttle 全局节流,确保请求间隔 >= minDelay +func (c *Client) throttle() { + c.mu.Lock() + defer c.mu.Unlock() + elapsed := time.Since(c.lastCall) + if elapsed < c.minDelay { + time.Sleep(c.minDelay - elapsed) + } + c.lastCall = time.Now() +} + +// nextDailyReset 计算到下一个早 8 点的时长(GPTMail 每日额度重置时间) +func nextDailyReset() time.Duration { + now := time.Now() + reset := time.Date(now.Year(), now.Month(), now.Day(), 8, 0, 0, 0, now.Location()) + if now.After(reset) { + reset = reset.AddDate(0, 0, 1) // 已过今天 8 点,算明天 + } + return time.Until(reset) +} + +// ErrAllKeysDead 所有 API Key 均不可用(401/403/429) +var ErrAllKeysDead = fmt.Errorf("GPTMail 所有 API Key 均不可用") + +// doGet 执行 GET 请求(带节流 + 重试 + 401 快速失败 + 429 自动换 key) +// maxRetries 是网络重试次数;401/429 换 key 不消耗名额(额外 len(keys) 次预算) +func (c *Client) doGet(url string, maxRetries int) ([]byte, error) { + var lastErr error + totalBudget := maxRetries + len(c.keys) + for i := 0; i < totalBudget; i++ { + c.throttle() + + key, keyIdx, allDead := c.nextKey() + if allDead { + // 所有 key 都在冷却(通常是全部 401),立即放弃不浪费时间 + if lastErr == nil { + lastErr = ErrAllKeysDead + } + return nil, fmt.Errorf("GPTMail 请求失败 (所有 key 不可用): %w", lastErr) + } + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + req.Header.Set("X-API-Key", key) + + resp, err := c.httpClient.Do(req) + if err != nil { + lastErr = err + time.Sleep(time.Duration(i+1) * 2 * time.Second) + continue + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode == 401 || resp.StatusCode == 403 { + keyPreview := key + if len(keyPreview) > 8 { + keyPreview = keyPreview[:8] + } + lastErr = fmt.Errorf("GPTMail HTTP %d: key %s... 认证失败/额度耗尽", resp.StatusCode, keyPreview) + // 401 可能是 key 无效,也可能是当日额度用完,冷却 15 分钟 + c.markCooldown(keyIdx, 15*time.Minute) + continue // 立即尝试下一个 key(不 sleep) + } + if resp.StatusCode == 429 { + keyPreview := key + if len(keyPreview) > 8 { + keyPreview = keyPreview[:8] + } + lastErr = fmt.Errorf("GPTMail 429 每日限额 (key: %s...)", keyPreview) + // 每日限额,冷却到明天早 8 点重置 + c.markCooldown(keyIdx, nextDailyReset()) + continue // 不 sleep,立即尝试下一个 key + } + if resp.StatusCode != 200 { + lastErr = fmt.Errorf("GPTMail HTTP %d: %s", resp.StatusCode, string(body)) + continue + } + return body, nil + } + return nil, fmt.Errorf("GPTMail 请求失败 (重试 %d 次): %w", maxRetries, lastErr) +} + +// GenerateEmail 创建临时邮箱,返回邮箱地址 +func (c *Client) GenerateEmail() (string, error) { + body, err := c.doGet(c.baseURL+"/api/generate-email", 3) + if err != nil { + return "", err + } + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("解析邮箱响应失败: %w", err) + } + // 兼容两种格式: + // 旧格式:{"email": "xxx"} + // 新格式:{"success": true, "data": {"email": "xxx"}} + email, _ := result["email"].(string) + if email == "" { + if data, ok := result["data"].(map[string]interface{}); ok { + email, _ = data["email"].(string) + } + } + if email == "" { + return "", fmt.Errorf("邮箱响应中无 email 字段: %s", string(body)) + } + return email, nil +} + +// 验证码提取正则 +var ( + reSubjectCode = regexp.MustCompile(`^([A-Z0-9]+-[A-Z0-9]+)`) + reBodyCode = regexp.MustCompile(`\b([A-Z0-9]{6,8})\b`) +) + +// FetchVerificationCode 轮询获取验证码(最多 maxAttempts 次,每次间隔 interval) +func (c *Client) FetchVerificationCode(email string, maxAttempts int, interval time.Duration) (string, error) { + url := fmt.Sprintf("%s/api/emails?email=%s", c.baseURL, email) + + for i := 0; i < maxAttempts; i++ { + body, err := c.doGet(url, 1) + if err != nil { + time.Sleep(interval) + continue + } + + // API 统一格式:{"success":true,"data":{"emails":[...],"count":N},"error":""} + var resp map[string]interface{} + if err := json.Unmarshal(body, &resp); err != nil { + time.Sleep(interval) + continue + } + + // 提取 data.emails 数组 + var emails []interface{} + if data, ok := resp["data"].(map[string]interface{}); ok { + if arr, ok := data["emails"].([]interface{}); ok { + emails = arr + } + } + + for _, item := range emails { + em, ok := item.(map[string]interface{}) + if !ok { + continue + } + // 策略 1: 从 subject 提取 + if subject, ok := em["subject"].(string); ok { + if m := reSubjectCode.FindStringSubmatch(subject); len(m) > 1 { + code := strings.ReplaceAll(m[1], "-", "") + return code, nil + } + } + // 策略 2: 从 content/html_content 提取(API 字段名) + for _, field := range []string{"content", "html_content"} { + if content, ok := em[field].(string); ok && content != "" { + if m := reBodyCode.FindStringSubmatch(content); len(m) > 1 { + return m[1], nil + } + } + } + } + time.Sleep(interval) + } + return "", fmt.Errorf("获取验证码超时 (%d 次轮询)", maxAttempts) +} + +// DeleteEmail 删除临时邮箱 +func (c *Client) DeleteEmail(email string) error { + url := fmt.Sprintf("%s/api/emails/clear?email=%s", c.baseURL, email) + c.throttle() + key, _, allDead := c.nextKey() + if allDead { + return ErrAllKeysDead + } + req, err := http.NewRequest("DELETE", url, nil) + if err != nil { + return err + } + req.Header.Set("X-API-Key", key) + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + resp.Body.Close() + return nil +} diff --git a/internal/pkg/grpcweb/decoder.go b/internal/pkg/grpcweb/decoder.go new file mode 100644 index 0000000..7a5339c --- /dev/null +++ b/internal/pkg/grpcweb/decoder.go @@ -0,0 +1,150 @@ +package grpcweb + +import ( + "encoding/binary" + "fmt" +) + +// DecodeSessionCookie 从 CreateUserAndSessionV2 的 gRPC-web 响应中提取 session_cookie +// +// 响应结构: +// +// gRPC-web frame: \x00 + 4字节长度 + payload +// CreateSessionV2Response { +// oneof response { +// CreateSessionResponse session = 1; ← 成功 +// ExistingEmailSignInMethods existing = 2; ← 邮箱已存在 +// } +// } +// CreateSessionResponse { +// Session session = 1; +// string session_cookie = 2; ← SSO token +// repeated string one_time_link_tokens = 3; +// } +func DecodeSessionCookie(body []byte) (string, error) { + if len(body) < 5 { + return "", fmt.Errorf("响应体过短: %d bytes", len(body)) + } + + // 跳过 gRPC-web 帧头(5 字节: flag + 4字节长度) + if body[0] != 0x00 { + return "", fmt.Errorf("非数据帧: flag=0x%02x", body[0]) + } + msgLen := binary.BigEndian.Uint32(body[1:5]) + if int(msgLen)+5 > len(body) { + return "", fmt.Errorf("帧长度不匹配: 声明 %d, 实际 %d", msgLen, len(body)-5) + } + msg := body[5 : 5+msgLen] + + // 解析 CreateSessionV2Response + // field 1 (wire type 2) = CreateSessionResponse + sessionResponseData, err := extractBytesField(msg, 1) + if err != nil { + // 可能是 field 2 (ExistingEmailSignInMethods),邮箱已存在 + if _, err2 := extractBytesField(msg, 2); err2 == nil { + return "", fmt.Errorf("邮箱已存在(需确认账户)") + } + return "", fmt.Errorf("解析 V2Response 失败: %w", err) + } + + // 解析 CreateSessionResponse + // field 2 (wire type 2) = session_cookie (string) + cookie, err := extractStringField(sessionResponseData, 2) + if err != nil { + return "", fmt.Errorf("解析 session_cookie 失败: %w", err) + } + return cookie, nil +} + +// DecodeSessionCookieV1 从 CreateUserAndSession (非V2) 的 gRPC-web 响应中提取 session_cookie +func DecodeSessionCookieV1(body []byte) (string, error) { + if len(body) < 5 { + return "", fmt.Errorf("响应体过短: %d bytes", len(body)) + } + + if body[0] != 0x00 { + return "", fmt.Errorf("非数据帧: flag=0x%02x", body[0]) + } + msgLen := binary.BigEndian.Uint32(body[1:5]) + if int(msgLen)+5 > len(body) { + return "", fmt.Errorf("帧长度不匹配") + } + msg := body[5 : 5+msgLen] + + // CreateSessionResponse.session_cookie = field 2 + cookie, err := extractStringField(msg, 2) + if err != nil { + return "", fmt.Errorf("解析 session_cookie 失败: %w", err) + } + return cookie, nil +} + +// decodeVarint 解码 varint,返回值和消耗的字节数 +func decodeVarint(data []byte) (uint64, int) { + var val uint64 + var shift uint + for i, b := range data { + val |= uint64(b&0x7f) << shift + if b&0x80 == 0 { + return val, i + 1 + } + shift += 7 + if shift >= 64 { + return 0, 0 + } + } + return 0, 0 +} + +// extractBytesField 从 protobuf 消息中提取指定字段的 bytes 数据 +func extractBytesField(msg []byte, targetField int) ([]byte, error) { + pos := 0 + for pos < len(msg) { + tag, n := decodeVarint(msg[pos:]) + if n == 0 { + break + } + pos += n + + fieldNum := int(tag >> 3) + wireType := int(tag & 0x07) + + switch wireType { + case 0: // varint + _, n = decodeVarint(msg[pos:]) + if n == 0 { + return nil, fmt.Errorf("varint 解码失败") + } + pos += n + case 2: // length-delimited + length, n := decodeVarint(msg[pos:]) + if n == 0 { + return nil, fmt.Errorf("length 解码失败") + } + pos += n + if pos+int(length) > len(msg) { + return nil, fmt.Errorf("数据越界: pos=%d, len=%d, msg=%d", pos, length, len(msg)) + } + if fieldNum == targetField { + return msg[pos : pos+int(length)], nil + } + pos += int(length) + case 5: // 32-bit + pos += 4 + case 1: // 64-bit + pos += 8 + default: + return nil, fmt.Errorf("未知 wire type: %d", wireType) + } + } + return nil, fmt.Errorf("字段 %d 未找到", targetField) +} + +// extractStringField 从 protobuf 消息中提取指定字段的 string 值 +func extractStringField(msg []byte, targetField int) (string, error) { + data, err := extractBytesField(msg, targetField) + if err != nil { + return "", err + } + return string(data), nil +} diff --git a/internal/pkg/grpcweb/encoder.go b/internal/pkg/grpcweb/encoder.go new file mode 100644 index 0000000..5c6522b --- /dev/null +++ b/internal/pkg/grpcweb/encoder.go @@ -0,0 +1,155 @@ +package grpcweb + +import ( + "encoding/binary" +) + +// EncodeFrame 编码 gRPC-web 帧:\x00 + 4字节大端长度 + payload +func EncodeFrame(payload []byte) []byte { + frame := make([]byte, 5+len(payload)) + frame[0] = 0x00 // 无压缩 + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + copy(frame[5:], payload) + return frame +} + +// encodeVarint 编码 varint(支持任意大小的值) +func encodeVarint(value uint64) []byte { + var buf [10]byte + n := 0 + for value >= 0x80 { + buf[n] = byte(value) | 0x80 + value >>= 7 + n++ + } + buf[n] = byte(value) + n++ + return buf[:n] +} + +// encodeTag 编码 protobuf 字段标签 +func encodeTag(fieldNumber int, wireType int) []byte { + return encodeVarint(uint64(fieldNumber<<3 | wireType)) +} + +// EncodeStringField 编码 protobuf length-delimited string 字段(支持任意长度) +func EncodeStringField(fieldNumber int, value string) []byte { + data := []byte(value) + tag := encodeTag(fieldNumber, 2) + length := encodeVarint(uint64(len(data))) + buf := make([]byte, 0, len(tag)+len(length)+len(data)) + buf = append(buf, tag...) + buf = append(buf, length...) + buf = append(buf, data...) + return buf +} + +// EncodeVarintField 编码 protobuf varint 字段 +func EncodeVarintField(fieldNumber int, value int) []byte { + tag := encodeTag(fieldNumber, 0) + val := encodeVarint(uint64(value)) + buf := make([]byte, 0, len(tag)+len(val)) + buf = append(buf, tag...) + buf = append(buf, val...) + return buf +} + +// EncodeBoolField 编码 protobuf bool 字段 +func EncodeBoolField(fieldNumber int, value bool) []byte { + v := 0 + if value { + v = 1 + } + return EncodeVarintField(fieldNumber, v) +} + +// EncodeBytesField 编码 protobuf bytes/嵌套消息 字段(支持任意长度) +func EncodeBytesField(fieldNumber int, data []byte) []byte { + tag := encodeTag(fieldNumber, 2) + length := encodeVarint(uint64(len(data))) + buf := make([]byte, 0, len(tag)+len(length)+len(data)) + buf = append(buf, tag...) + buf = append(buf, length...) + buf = append(buf, data...) + return buf +} + +// EncodeEmailCode 编码发送验证码的 gRPC-web 消息(单字段: email) +func EncodeEmailCode(email string) []byte { + payload := EncodeStringField(1, email) + return EncodeFrame(payload) +} + +// EncodeVerifyCode 编码验证邮箱码的 gRPC-web 消息(两字段: email + code) +func EncodeVerifyCode(email, code string) []byte { + payload := append(EncodeStringField(1, email), EncodeStringField(2, code)...) + return EncodeFrame(payload) +} + +// EncodeTosAccepted 编码接受 TOS 的 gRPC-web 消息(固定: field 2 = varint 1) +func EncodeTosAccepted() []byte { + payload := EncodeVarintField(2, 1) + return EncodeFrame(payload) +} + +// EncodeNsfwSettings 编码 NSFW 设置的 gRPC-web 消息 +func EncodeNsfwSettings() []byte { + // field 1: 嵌套消息 { field 2 = varint 1 } + inner1 := EncodeVarintField(2, 1) + field1 := EncodeBytesField(1, inner1) + + // field 2: 嵌套消息 { field 1 = "always_show_nsfw_content" } + inner2 := EncodeStringField(1, "always_show_nsfw_content") + field2 := EncodeBytesField(2, inner2) + + payload := append(field1, field2...) + return EncodeFrame(payload) +} + +// EncodeCreateUserAndSession 编码 CreateUserAndSessionRequest (gRPC-web) +// +// Proto 定义(从 accounts.x.ai JS 逆向): +// +// message CreateUserAndSessionRequest { +// CreateUserRequest create_user_request = 1; +// AntiAbuseToken anti_abuse_token = 6; +// int32 num_one_time_links = 7; +// string email_validation_code = 9; +// bool prompt_on_duplicate_email = 10; +// } +// message CreateUserRequest { +// string given_name = 1; +// string family_name = 2; +// string email = 3; +// string clear_text_password = 5; +// } +// message AntiAbuseToken { +// string turnstile_token = 1; +// } +func EncodeCreateUserAndSession(email, givenName, familyName, password, emailValidationCode, turnstileToken string) []byte { + // 构建 CreateUserRequest(嵌套消息) + var createUserReq []byte + createUserReq = append(createUserReq, EncodeStringField(1, givenName)...) + createUserReq = append(createUserReq, EncodeStringField(2, familyName)...) + createUserReq = append(createUserReq, EncodeStringField(3, email)...) + createUserReq = append(createUserReq, EncodeStringField(5, password)...) + + // 构建 CreateUserAndSessionRequest + var payload []byte + payload = append(payload, EncodeBytesField(1, createUserReq)...) // field 1: create_user_request + if turnstileToken != "" { // field 6: anti_abuse_token (可选) + antiAbuse := EncodeStringField(1, turnstileToken) + payload = append(payload, EncodeBytesField(6, antiAbuse)...) + } + payload = append(payload, EncodeStringField(9, emailValidationCode)...) // field 9: email_validation_code + payload = append(payload, EncodeBoolField(10, true)...) // field 10: prompt_on_duplicate_email + + return EncodeFrame(payload) +} + +// EncodeUnhingedSettings 编码 Unhinged 模式设置 (field 1 = 1, field 2 = 1) +// 参考 grokzhuce nsfw_service.py enable_unhinged 方法 +func EncodeUnhingedSettings() []byte { + payload := append(EncodeVarintField(1, 1), EncodeVarintField(2, 1)...) + return EncodeFrame(payload) +} diff --git a/internal/pkg/netutil/ssrf.go b/internal/pkg/netutil/ssrf.go new file mode 100644 index 0000000..a8c2c5b --- /dev/null +++ b/internal/pkg/netutil/ssrf.go @@ -0,0 +1,154 @@ +// Package netutil 提供网络安全工具函数(SSRF 防护等) +package netutil + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "time" +) + +// allowedSchemes 订阅/代理允许的 URL 协议白名单 +var allowedSchemes = map[string]bool{ + "http": true, + "https": true, +} + +// IsPrivateHost 检查主机名是否解析到私网/保留地址(SSRF 防护) +// 采用 fail-closed 策略:DNS 解析失败时返回 true(视为私网) +func IsPrivateHost(host string) bool { + // 先尝试直接解析为 IP + if ip := net.ParseIP(host); ip != nil { + return isPrivateIP(ip) + } + + // 域名解析(fail-closed:解析失败视为私网) + resolved, err := net.LookupHost(host) + if err != nil { + return true + } + if len(resolved) == 0 { + return true + } + + for _, r := range resolved { + if ip := net.ParseIP(r); ip != nil { + if isPrivateIP(ip) { + return true + } + } + } + return false +} + +// isPrivateIP 判断 IP 是否属于私网/保留地址段 +func isPrivateIP(ip net.IP) bool { + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || ip.IsUnspecified() { + return true + } + // 云元数据端点 + if ip.Equal(net.ParseIP("169.254.169.254")) { + return true + } + return false +} + +// ValidateURLScheme 校验 URL 协议是否在白名单内(仅允许 http/https) +func ValidateURLScheme(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("URL 格式错误: %w", err) + } + if !allowedSchemes[parsed.Scheme] { + return fmt.Errorf("不允许的协议 %q,仅支持 http/https", parsed.Scheme) + } + return nil +} + +// ValidateURLHost 校验 URL 的 Host 是否为私网地址 +func ValidateURLHost(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil { + return fmt.Errorf("URL 格式错误: %w", err) + } + host := parsed.Hostname() + if host == "" { + return fmt.Errorf("URL 缺少主机地址") + } + if IsPrivateHost(host) { + return fmt.Errorf("不允许访问内网地址 %s", host) + } + return nil +} + +// NewSSRFSafeClient 创建带 SSRF 防护的 HTTP Client +// - 连接时校验目标 IP(防 DNS rebinding / TOCTOU) +// - 跟随重定向时校验每个跳转目标 +// - 限制最多 5 次重定向 +func NewSSRFSafeClient(timeout time.Duration) *http.Client { + dialer := &net.Dialer{Timeout: 10 * time.Second} + + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + // 解析实际连接的 IP(连接时校验,防 DNS rebinding) + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("地址格式错误: %w", err) + } + + // 解析 host → IP + ips, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + return nil, fmt.Errorf("DNS 解析失败: %w", err) + } + + // 分离 IPv4 和 IPv6,仅使用 IPv4(国内 IPv6 国际出口普遍不稳定) + var ip4s []string + for _, ipStr := range ips { + ip := net.ParseIP(ipStr) + if ip == nil { + continue + } + if isPrivateIP(ip) { + return nil, fmt.Errorf("SSRF 防护:拒绝连接到内网地址 %s", ip) + } + if ip.To4() != nil { + ip4s = append(ip4s, ipStr) + } + // 跳过 IPv6:国内电信/联通 IPv6 到 Cloudflare 等 CDN 经常 connection reset + } + + if len(ip4s) == 0 { + return nil, fmt.Errorf("DNS 解析无可用 IPv4 地址: %s", host) + } + + var lastErr error + for _, ipStr := range ip4s { + conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ipStr, port)) + if err == nil { + return conn, nil + } + lastErr = err + } + return nil, lastErr + }, + } + + return &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return fmt.Errorf("重定向次数超限(最多 5 次)") + } + // 校验重定向目标协议 + if !allowedSchemes[req.URL.Scheme] { + return fmt.Errorf("重定向到不允许的协议 %q", req.URL.Scheme) + } + return nil + }, + } +} diff --git a/internal/pkg/newapi/client.go b/internal/pkg/newapi/client.go new file mode 100644 index 0000000..df16296 --- /dev/null +++ b/internal/pkg/newapi/client.go @@ -0,0 +1,129 @@ +package newapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// Client 封装 New-API 管理员 API +type Client struct { + baseURL string + adminToken string + adminUserID int // 管理员自身的 New-API 用户 ID(用于 New-Api-User 鉴权头) + http *http.Client +} + +// NewClient 创建 New-API 客户端 +// adminUserID: 管理员在 New-API 中的用户 ID,所有请求的 New-Api-User 头必须设为此值 +func NewClient(baseURL, adminToken string, adminUserID int) *Client { + return &Client{ + baseURL: baseURL, + adminToken: adminToken, + adminUserID: adminUserID, + http: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// setAdminAuth 设置管理员鉴权头 +// New-API 的 authHelper 中间件要求 New-Api-User 与 token 对应的用户 ID 一致 +func (c *Client) setAdminAuth(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+c.adminToken) + req.Header.Set("New-Api-User", fmt.Sprintf("%d", c.adminUserID)) +} + +// userResponse New-API 用户接口响应 +type userResponse struct { + Success bool `json:"success"` + Data struct { + ID int `json:"id"` + Quota int `json:"quota"` + } `json:"data"` + Message string `json:"message"` +} + +// genericResponse New-API 通用响应 +type genericResponse struct { + Success bool `json:"success"` + Message string `json:"message"` +} + +// GetUserQuota 实时查询用户 quota(GET /api/user/{id}) +func (c *Client) GetUserQuota(newapiUserID int) (int, error) { + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/user/%d", c.baseURL, newapiUserID), nil) + if err != nil { + return 0, fmt.Errorf("创建请求失败: %w", err) + } + c.setAdminAuth(req) + + resp, err := c.http.Do(req) + if err != nil { + return 0, fmt.Errorf("请求 New-API 失败: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, fmt.Errorf("读取响应失败: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("New-API 返回错误 %d: %s", resp.StatusCode, string(body)) + } + + var result userResponse + if err := json.Unmarshal(body, &result); err != nil { + return 0, fmt.Errorf("解析响应失败: %w", err) + } + + if !result.Success { + return 0, fmt.Errorf("New-API 错误: %s", result.Message) + } + + return result.Data.Quota, nil +} + +// DeductQuota 原子扣减 quota(POST /api/user/{id}/quota-deduct) +// 由 New-API 保证原子性,无需客户端加锁 +func (c *Client) DeductQuota(newapiUserID int, amount int) error { + payload := map[string]interface{}{ + "amount": amount, + "remark": "regplatform 购买注册次数", + } + body, _ := json.Marshal(payload) + + req, err := http.NewRequest("POST", fmt.Sprintf("%s/api/user/%d/quota-deduct", c.baseURL, newapiUserID), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("创建请求失败: %w", err) + } + c.setAdminAuth(req) + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("请求 New-API 失败: %w", err) + } + defer resp.Body.Close() + + respBody, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("New-API 扣减失败 %d: %s", resp.StatusCode, string(respBody)) + } + + var result genericResponse + if err := json.Unmarshal(respBody, &result); err != nil { + return fmt.Errorf("解析响应失败: %w", err) + } + + if !result.Success { + return fmt.Errorf("New-API 扣减失败: %s", result.Message) + } + + return nil +} diff --git a/internal/pkg/sentinel/solver.go b/internal/pkg/sentinel/solver.go new file mode 100644 index 0000000..26255dc --- /dev/null +++ b/internal/pkg/sentinel/solver.go @@ -0,0 +1,261 @@ +// Package sentinel 实现 OpenAI Sentinel PoW 算法 +// 逆向来源:https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js +// +// 核心算法: +// 1. 构造 19 元素浏览器环境数组 (config) +// 2. PoW: FNV-1a 32bit hash + xorshift finalizer,暴力搜索 nonce +// 3. 调用 sentinel API 获取 c 字段和 seed/difficulty +// 4. 返回最终 openai-sentinel-token JSON 字符串 +package sentinel + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "strings" + "time" + + "github.com/google/uuid" +) + +const ( + sentinelAPIURL = "https://sentinel.openai.com/backend-api/sentinel/req" + sentinelUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36" + scriptSrc = "https://sentinel.openai.com/sentinel/20260124ceb8/sdk.js" + jsHeapLimit = 4294705152 // Chrome 典型值 + maxAttempts = 500000 + + // SDK 错误前缀常量 + errPrefix = "wQ8Lk5FbGpA2NcR9dShT6gYjU7VxZ4D" +) + +var ( + navProps = []string{ + "vendorSub", "productSub", "vendor", "maxTouchPoints", + "scheduling", "userActivation", "doNotTrack", "geolocation", + "connection", "plugins", "mimeTypes", "pdfViewerEnabled", + "webkitTemporaryStorage", "webkitPersistentStorage", + "hardwareConcurrency", "cookieEnabled", "credentials", + "mediaDevices", "permissions", "locks", "ink", + } + docKeys = []string{"location", "implementation", "URL", "documentURI", "compatMode"} + winKeys = []string{"Object", "Function", "Array", "Number", "parseFloat", "undefined"} + cpuCores = []int{4, 8, 12, 16} +) + +// Solver Sentinel PoW 求解器 +type Solver struct { + DeviceID string // UUID v4,整个 session 固定 + sid string // 会话标识 UUID + rng *rand.Rand +} + +// New 创建新的 Solver,DeviceID 自动生成 +func New() *Solver { + return &Solver{ + DeviceID: uuid.New().String(), + sid: uuid.New().String(), + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +// NewWithDeviceID 使用指定 DeviceID 创建 Solver +func NewWithDeviceID(deviceID string) *Solver { + return &Solver{ + DeviceID: deviceID, + sid: uuid.New().String(), + rng: rand.New(rand.NewSource(time.Now().UnixNano())), + } +} + +// fnv1a32 FNV-1a 32bit 哈希 + xorshift finalizer +// 逆向还原 SDK JS 中的匿名哈希函数: +// e = 2166136261; e ^= t.charCodeAt(r); e = Math.imul(e, 16777619) >>> 0 +// 然后 xorshift 混合(murmurhash3 风格 finalizer) +func fnv1a32(text string) string { + h := uint32(2166136261) // FNV offset basis + for _, ch := range text { + h ^= uint32(ch) + h *= 16777619 // uint32 自动截断,等价 Math.imul >>> 0 + } + // xorshift finalizer + h ^= h >> 16 + h *= 2246822507 + h ^= h >> 13 + h *= 3266489909 + h ^= h >> 16 + return fmt.Sprintf("%08x", h) +} + +// base64Encode 模拟 SDK E() 函数:JSON.stringify(紧凑)→ UTF-8 → base64 +func base64Encode(data []interface{}) (string, error) { + b, err := json.Marshal(data) + if err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(b), nil +} + +// getConfig 构造 19 元素浏览器环境数组 +func (s *Solver) getConfig() []interface{} { + now := time.Now().UTC() + // JS Date.toString() 格式:Mon Feb 21 2026 06:47:00 GMT+0000 (Coordinated Universal Time) + dateStr := now.Format("Mon Jan 02 2006 15:04:05 GMT+0000 (Coordinated Universal Time)") + + navProp := navProps[s.rng.Intn(len(navProps))] + // SDK 使用 U+2212 Unicode 减号,而非 ASCII 连字符 + navVal := navProp + "\u2212undefined" + + perfNow := s.rng.Float64()*49000 + 1000 // [1000, 50000) + timeOrigin := float64(now.UnixMilli()) - perfNow + + return []interface{}{ + "1920x1080", // [0] screen + dateStr, // [1] Date.toString() + jsHeapLimit, // [2] performance.memory.jsHeapSizeLimit + s.rng.Float64(), // [3] 占位,PoW 时被 nonce 替换 + sentinelUA, // [4] navigator.userAgent + scriptSrc, // [5] script src + nil, // [6] script version + nil, // [7] data-build + "en-US", // [8] navigator.language + "en-US,en", // [9] 占位,PoW 时被耗时替换 + s.rng.Float64(), // [10] Math.random() + navVal, // [11] navigator 属性(U+2212) + docKeys[s.rng.Intn(len(docKeys))], // [12] document key + winKeys[s.rng.Intn(len(winKeys))], // [13] window key + perfNow, // [14] performance.now() + s.sid, // [15] session UUID + "", // [16] URL 参数 + cpuCores[s.rng.Intn(len(cpuCores))], // [17] navigator.hardwareConcurrency + timeOrigin, // [18] performance.timeOrigin + } +} + +// GenerateRequirementsToken 生成 requirements token(prefix "gAAAAAC",无需服务端参数) +// 对应 SDK 的 getRequirementsToken() +func (s *Solver) GenerateRequirementsToken() (string, error) { + config := s.getConfig() + config[3] = 1 + config[9] = s.rng.Intn(46) + 5 // [5, 50) + + data, err := base64Encode(config) + if err != nil { + return "", err + } + return "gAAAAAC" + data, nil +} + +// SolvePoW 暴力搜索满足难度要求的 nonce,返回 PoW token(prefix "gAAAAAB") +func (s *Solver) SolvePoW(seed, difficulty string) string { + config := s.getConfig() + startTime := time.Now() + + for i := 0; i < maxAttempts; i++ { + config[3] = i + config[9] = int(time.Since(startTime).Milliseconds()) + + data, err := base64Encode(config) + if err != nil { + continue + } + + hashHex := fnv1a32(seed + data) + diffLen := len(difficulty) + if len(hashHex) >= diffLen && hashHex[:diffLen] <= difficulty { + return "gAAAAAB" + data + "~S" + } + } + + // 超过最大次数,返回错误 token + errData, _ := base64Encode([]interface{}{nil}) + return "gAAAAAB" + errPrefix + errData +} + +// sentinelReq Sentinel API 响应结构 +type sentinelResp struct { + Token string `json:"token"` + ProofOfWork struct { + Required bool `json:"required"` + Seed string `json:"seed"` + Difficulty string `json:"difficulty"` + } `json:"proofofwork"` +} + +// FetchChallenge 调用 sentinel API 获取 c 字段和 PoW 参数 +func (s *Solver) FetchChallenge(ctx context.Context, client *http.Client, flow string) (*sentinelResp, error) { + reqToken, err := s.GenerateRequirementsToken() + if err != nil { + return nil, fmt.Errorf("generate requirements token: %w", err) + } + + body, _ := json.Marshal(map[string]string{ + "p": reqToken, + "id": s.DeviceID, + "flow": flow, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", sentinelAPIURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "text/plain;charset=UTF-8") + req.Header.Set("Referer", "https://sentinel.openai.com/backend-api/sentinel/frame.html") + req.Header.Set("Origin", "https://sentinel.openai.com") + req.Header.Set("User-Agent", sentinelUA) + req.Header.Set("sec-ch-ua", `"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"`) + req.Header.Set("sec-ch-ua-mobile", "?0") + req.Header.Set("sec-ch-ua-platform", `"Windows"`) + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("sentinel API request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 200)) + return nil, fmt.Errorf("sentinel API returned %d: %s", resp.StatusCode, b) + } + + var result sentinelResp + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode sentinel response: %w", err) + } + return &result, nil +} + +// BuildToken 完整流程:获取 challenge → 求解 PoW → 返回最终 sentinel-token JSON 字符串 +func (s *Solver) BuildToken(ctx context.Context, client *http.Client, flow string) (string, error) { + challenge, err := s.FetchChallenge(ctx, client, flow) + if err != nil { + return "", fmt.Errorf("fetch challenge: %w", err) + } + + cValue := challenge.Token + pow := challenge.ProofOfWork + + var pValue string + if pow.Required && pow.Seed != "" { + pValue = s.SolvePoW(pow.Seed, pow.Difficulty) + } else { + pValue, err = s.GenerateRequirementsToken() + if err != nil { + return "", err + } + } + + token, _ := json.Marshal(map[string]string{ + "p": pValue, + "t": "", // 服务端不校验此字段 + "c": cValue, + "id": s.DeviceID, + "flow": flow, + }) + return strings.ReplaceAll(string(token), " ", ""), nil +} diff --git a/internal/pkg/tempmail/code.go b/internal/pkg/tempmail/code.go new file mode 100644 index 0000000..42b91ab --- /dev/null +++ b/internal/pkg/tempmail/code.go @@ -0,0 +1,147 @@ +package tempmail + +import ( + "bytes" + "io" + "mime/quotedprintable" + "regexp" + "strings" +) + +// 验证码提取正则(所有 provider 共用) +var ( + // 策略 1a: subject 以验证码开头(Grok 格式 "ABC123-DEF456") + reSubjectCodeDash = regexp.MustCompile(`^([A-Z0-9]+-[A-Z0-9]+)`) + // 策略 1b: subject 末尾的 6-8 位数字验证码(OpenAI 格式 "Your ChatGPT code is 798357") + reSubjectCodeTail = regexp.MustCompile(`\b(\d{6,8})\s*$`) + // 策略 1c: 显式提示 verification code 的 6 位数字 + reKeywordDigits = regexp.MustCompile(`(?i)(?:verification code|verify|code)[^0-9]{0,30}(\d{6})`) + // 策略 1d: 显式提示 verification code 的 6 位字母数字混合码 + reKeywordAlphaNum = regexp.MustCompile(`(?i)(?:verification code|verify|code)\s*[::\s]*\b([A-Z0-9]{6})\b`) + // 策略 1e: HTML 中 verification-code 样式块 + reHTMLSpanCode = regexp.MustCompile(`(?i)]*class="[^"]*verification-code[^"]*"[^>]*>\s*([A-Z0-9]{6,8})\s*`) + // 策略 2: 任意位置的 6-8 位大写字母+数字兜底 + reInlineCode = regexp.MustCompile(`\b([A-Z0-9]{6,8})\b`) +) + +var verificationCodeBlacklist = map[string]struct{}{ + "GOOGLE": {}, + "GEMINI": {}, + "VERIFY": {}, + "SIGNIN": {}, + "PLEASE": {}, + "CHANGE": {}, + "ACCEPT": {}, + "CANCEL": {}, + "SUBMIT": {}, +} + +// ExtractVerificationCode 从邮件 subject/body 中提取验证码。 +// 先走显式模式,再走 HTML 和 quoted-printable 解码,最后才用通用兜底规则。 +func ExtractVerificationCode(subject, body string) string { + if subject != "" { + // Grok 的 subject 常见为 "ABC123-DEF456" 这种前缀格式。 + if m := reSubjectCodeDash.FindStringSubmatch(strings.ToUpper(subject)); len(m) > 1 { + return strings.ReplaceAll(m[1], "-", "") + } + // OpenAI 常见 "Your ChatGPT code is 798357"。 + if m := reSubjectCodeTail.FindStringSubmatch(subject); len(m) > 1 { + return m[1] + } + if code := extractCodeFromText(subject, true); code != "" { + return code + } + } + if body != "" { + if code := extractCodeFromText(body, true); code != "" { + return code + } + } + return "" +} + +func extractCodeFromText(text string, allowPlainDigits bool) string { + if text == "" { + return "" + } + + upperText := strings.ToUpper(text) + if m := reHTMLSpanCode.FindStringSubmatch(upperText); len(m) > 1 && isPlausibleVerificationCode(m[1], false) { + return m[1] + } + if m := reKeywordDigits.FindStringSubmatch(text); len(m) > 1 && isPlausibleVerificationCode(m[1], true) { + return m[1] + } + if m := reKeywordAlphaNum.FindStringSubmatch(upperText); len(m) > 1 && isPlausibleVerificationCode(m[1], false) { + return m[1] + } + + if decoded := decodeQuotedPrintable(text); decoded != "" && decoded != text { + if code := extractCodeFromText(decoded, allowPlainDigits); code != "" { + return code + } + } + + for _, candidate := range reInlineCode.FindAllString(upperText, -1) { + if isPlausibleVerificationCode(candidate, allowPlainDigits) { + return candidate + } + } + return "" +} + +func isPlausibleVerificationCode(code string, allowPlainDigits bool) bool { + if code == "" { + return false + } + + code = strings.ToUpper(strings.TrimSpace(code)) + if _, blocked := verificationCodeBlacklist[code]; blocked { + return false + } + + if isAllDigits(code) { + if !allowPlainDigits { + return false + } + if strings.Trim(code, "0") == "" { + return false + } + if len(code) == 6 && (strings.HasPrefix(code, "19") || strings.HasPrefix(code, "20")) { + return false + } + return true + } + + return containsDigit(code) +} + +func decodeQuotedPrintable(text string) string { + reader := quotedprintable.NewReader(strings.NewReader(text)) + decoded, err := io.ReadAll(reader) + if err != nil { + return "" + } + return strings.TrimSpace(string(bytes.TrimSpace(decoded))) +} + +func isAllDigits(text string) bool { + if text == "" { + return false + } + for _, ch := range text { + if ch < '0' || ch > '9' { + return false + } + } + return true +} + +func containsDigit(text string) bool { + for _, ch := range text { + if ch >= '0' && ch <= '9' { + return true + } + } + return false +} diff --git a/internal/pkg/tempmail/code_test.go b/internal/pkg/tempmail/code_test.go new file mode 100644 index 0000000..9c821bd --- /dev/null +++ b/internal/pkg/tempmail/code_test.go @@ -0,0 +1,55 @@ +package tempmail + +import "testing" + +func TestExtractVerificationCode(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + subject string + body string + want string + }{ + { + name: "openai_subject_tail_digits", + subject: "Your ChatGPT code is 798357", + want: "798357", + }, + { + name: "gemini_body_keyword_alnum", + body: "Your Google verification code is L655U3", + want: "L655U3", + }, + { + name: "gemini_html_block_code", + body: `

Your one-time verification code is:

DZJW4P
`, + want: "DZJW4P", + }, + { + name: "quoted_printable_body", + body: "Your verification code=20is=20AB12C3", + want: "AB12C3", + }, + { + name: "numeric_body_without_keyword_context", + body: "Use this one-time passcode on the next page: 456789", + want: "456789", + }, + { + name: "seven_digit_body_code", + body: "Your backup verification number is 1234567", + want: "1234567", + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := ExtractVerificationCode(tc.subject, tc.body); got != tc.want { + t.Fatalf("ExtractVerificationCode() = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/pkg/tempmail/fetch_providers.go b/internal/pkg/tempmail/fetch_providers.go new file mode 100644 index 0000000..3b6a254 --- /dev/null +++ b/internal/pkg/tempmail/fetch_providers.go @@ -0,0 +1,513 @@ +package tempmail + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "time" +) + +var errFetchOnlyProvider = fmt.Errorf("provider 仅支持验证码读取,不支持创建邮箱") + +// FetchVerificationCodeByProvider 按 provider 名称直接拉取验证码。 +// 这条路径用于结果页“获取 OTP”按钮,不依赖 MultiProvider 的创建逻辑。 +func FetchVerificationCodeByProvider(ctx context.Context, providerName string, cfg map[string]string, addr string, meta map[string]string) (string, error) { + provider, err := newFetchProvider(providerName, cfg, meta) + if err != nil { + return "", err + } + return provider.FetchVerificationCode(ctx, addr, meta, 1, 0) +} + +// NormalizeProviderNameForAPI 对外暴露 provider 规范化结果,供 handler 做轻量判断。 +func NormalizeProviderNameForAPI(name string) string { + return normalizeProviderName(name) +} + +func newFetchProvider(name string, cfg map[string]string, meta map[string]string) (EmailProvider, error) { + switch normalizeProviderName(name) { + case "yydsmail": + baseURL := strings.TrimRight(cfg["yydsmail_base_url"], "/") + if baseURL == "" { + baseURL = "" + } + return NewYYDSMailProvider(baseURL, cfg["yydsmail_api_key"]), nil + case "mailtm": + return &mailTMProvider{baseURL: "https://api.mail.tm", name: "mailtm"}, nil + case "mailgw": + return &mailTMProvider{baseURL: "https://api.mail.gw", name: "mailgw"}, nil + case "guerrillamail": + return &guerrillaMailProvider{baseURL: "https://api.guerrillamail.com/ajax.php"}, nil + case "templol": + return &tempMailLOLProvider{baseURL: "https://api.tempmail.lol"}, nil + case "tempmailio": + return &tempMailIOProvider{baseURL: "https://api.internal.temp-mail.io/api/v3"}, nil + case "mailfree": + baseURL := strings.TrimRight(meta["base_url"], "/") + adminToken := meta["admin_token"] + if baseURL == "" || adminToken == "" { + return nil, fmt.Errorf("mailfree: 缺少 base_url 或 admin_token") + } + return &mailfreeProvider{ + baseURL: baseURL, + adminToken: adminToken, + }, nil + default: + return nil, fmt.Errorf("未支持的邮箱 provider: %s", name) + } +} + +func normalizeProviderName(name string) string { + switch strings.ToLower(strings.TrimSpace(name)) { + case "", "yydsmail": + return "yydsmail" + case "mailtm", "mail.tm": + return "mailtm" + case "mailgw", "mail.gw": + return "mailgw" + case "guerrillamail", "guerrilla", "guerrilla_mail": + return "guerrillamail" + case "templol", "tempmail.lol", "tempmail_lol": + return "templol" + case "tempmailio", "temp-mail.io", "temp_mail_io": + return "tempmailio" + case "mailfree": + return "mailfree" + default: + return strings.ToLower(strings.TrimSpace(name)) + } +} + +type fetchOnlyProvider struct{} + +func (fetchOnlyProvider) GenerateEmail(context.Context) (string, map[string]string, error) { + return "", nil, errFetchOnlyProvider +} + +func (fetchOnlyProvider) DeleteEmail(context.Context, string, map[string]string) error { + return nil +} + +type mailTMProvider struct { + fetchOnlyProvider + baseURL string + name string +} + +func (p *mailTMProvider) Name() string { return p.name } + +func (p *mailTMProvider) FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) { + token := strings.TrimSpace(meta["token"]) + if token == "" { + return "", fmt.Errorf("%s: 缺少 token", p.name) + } + + client := &http.Client{Timeout: 10 * time.Second} + for i := 0; i < maxAttempts; i++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/messages", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + time.Sleep(interval) + continue + } + + var payload interface{} + err = json.NewDecoder(resp.Body).Decode(&payload) + resp.Body.Close() + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + time.Sleep(interval) + continue + } + + messages := sortMessageMapsNewestFirst(collectMailTMMessageMaps(payload)) + for _, msg := range messages { + subject := stringValue(msg["subject"]) + if code := ExtractVerificationCode(subject, ""); code != "" { + return code, nil + } + + msgID := stringValue(msg["id"]) + if msgID == "" { + continue + } + detailReq, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/messages/"+url.PathEscape(msgID), nil) + if err != nil { + return "", err + } + detailReq.Header.Set("Authorization", "Bearer "+token) + detailReq.Header.Set("Accept", "application/json") + detailResp, err := client.Do(detailReq) + if err != nil { + continue + } + var detail map[string]interface{} + err = json.NewDecoder(detailResp.Body).Decode(&detail) + detailResp.Body.Close() + if err != nil || detailResp.StatusCode < 200 || detailResp.StatusCode >= 300 { + continue + } + for _, field := range []string{"text", "html", "intro"} { + for _, text := range appendContentValue(nil, detail[field]) { + if code := ExtractVerificationCode("", text); code != "" { + return code, nil + } + } + } + } + + time.Sleep(interval) + } + + return "", fmt.Errorf("%s 获取验证码超时", p.name) +} + +type guerrillaMailProvider struct { + fetchOnlyProvider + baseURL string +} + +func (p *guerrillaMailProvider) Name() string { return "guerrillamail" } + +func (p *guerrillaMailProvider) FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) { + sidToken := strings.TrimSpace(meta["sid_token"]) + if sidToken == "" { + return "", fmt.Errorf("guerrillamail: 缺少 sid_token") + } + + client := &http.Client{Timeout: 10 * time.Second} + for i := 0; i < maxAttempts; i++ { + apiURL := fmt.Sprintf("%s?f=check_email&seq=0&sid_token=%s", p.baseURL, url.QueryEscape(sidToken)) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + time.Sleep(interval) + continue + } + + var payload struct { + List []map[string]interface{} `json:"list"` + } + err = json.NewDecoder(resp.Body).Decode(&payload) + resp.Body.Close() + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + time.Sleep(interval) + continue + } + + for _, msg := range sortMessageMapsNewestFirst(payload.List) { + subject := stringValue(msg["mail_subject"]) + if strings.Contains(subject, "Welcome to Guerrilla Mail") { + continue + } + body := stringValue(msg["mail_body"]) + if code := ExtractVerificationCode(subject, body); code != "" { + return code, nil + } + } + + time.Sleep(interval) + } + + return "", fmt.Errorf("guerrillamail 获取验证码超时") +} + +type tempMailLOLProvider struct { + fetchOnlyProvider + baseURL string +} + +func (p *tempMailLOLProvider) Name() string { return "templol" } + +func (p *tempMailLOLProvider) FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) { + token := strings.TrimSpace(meta["token"]) + if token == "" { + return "", fmt.Errorf("tempmail.lol: 缺少 token") + } + + client := &http.Client{Timeout: 10 * time.Second} + for i := 0; i < maxAttempts; i++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/auth/"+url.PathEscape(token), nil) + if err != nil { + return "", err + } + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + time.Sleep(interval) + continue + } + + var payload struct { + Email []map[string]interface{} `json:"email"` + } + err = json.NewDecoder(resp.Body).Decode(&payload) + resp.Body.Close() + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + time.Sleep(interval) + continue + } + + for _, msg := range sortMessageMapsNewestFirst(payload.Email) { + subject := stringValue(msg["subject"]) + body := firstNonEmptyString(msg["body"], msg["html"]) + if code := ExtractVerificationCode(subject, body); code != "" { + return code, nil + } + } + + time.Sleep(interval) + } + + return "", fmt.Errorf("tempmail.lol 获取验证码超时") +} + +type tempMailIOProvider struct { + fetchOnlyProvider + baseURL string +} + +func (p *tempMailIOProvider) Name() string { return "tempmailio" } + +func (p *tempMailIOProvider) FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) { + token := strings.TrimSpace(meta["token"]) + if token == "" { + return "", fmt.Errorf("temp-mail.io: 缺少 token") + } + + client := &http.Client{Timeout: 10 * time.Second} + for i := 0; i < maxAttempts; i++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/email/"+url.PathEscape(addr)+"/messages", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + time.Sleep(interval) + continue + } + + var payload []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&payload) + resp.Body.Close() + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + time.Sleep(interval) + continue + } + + for _, msg := range sortMessageMapsNewestFirst(payload) { + subject := stringValue(msg["subject"]) + body := firstNonEmptyString(msg["body_text"], msg["body_html"]) + if code := ExtractVerificationCode(subject, body); code != "" { + return code, nil + } + } + + time.Sleep(interval) + } + + return "", fmt.Errorf("temp-mail.io 获取验证码超时") +} + +type mailfreeProvider struct { + fetchOnlyProvider + baseURL string + adminToken string +} + +func (p *mailfreeProvider) Name() string { return "mailfree" } + +func (p *mailfreeProvider) FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) { + client := &http.Client{Timeout: 10 * time.Second} + for i := 0; i < maxAttempts; i++ { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/api/emails?mailbox="+url.QueryEscape(addr), nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+p.adminToken) + + resp, err := client.Do(req) + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + time.Sleep(interval) + continue + } + + var payload []map[string]interface{} + err = json.NewDecoder(resp.Body).Decode(&payload) + resp.Body.Close() + if err != nil || resp.StatusCode < 200 || resp.StatusCode >= 300 { + time.Sleep(interval) + continue + } + + for _, msg := range sortMessageMapsNewestFirst(payload) { + if verificationCode := stringValue(msg["verification_code"]); verificationCode != "" { + return verificationCode, nil + } + + emailID := stringValue(msg["id"]) + if emailID != "" { + detailReq, err := http.NewRequestWithContext(ctx, http.MethodGet, p.baseURL+"/api/email/"+url.PathEscape(emailID), nil) + if err == nil { + detailReq.Header.Set("Authorization", "Bearer "+p.adminToken) + detailResp, err := client.Do(detailReq) + if err == nil { + var detail map[string]interface{} + if json.NewDecoder(detailResp.Body).Decode(&detail) == nil && detailResp.StatusCode >= 200 && detailResp.StatusCode < 300 { + for k, v := range detail { + if _, exists := msg[k]; !exists || stringValue(msg[k]) == "" { + msg[k] = v + } + } + } + detailResp.Body.Close() + } + } + } + + for _, field := range []string{"subject", "content", "html_content", "text_content", "body", "text"} { + if code := ExtractVerificationCode("", firstNonEmptyString(msg[field])); code != "" { + return code, nil + } + } + } + + time.Sleep(interval) + } + + return "", fmt.Errorf("mailfree 获取验证码超时") +} + +func collectMailTMMessageMaps(payload interface{}) []map[string]interface{} { + switch v := payload.(type) { + case map[string]interface{}: + if members, ok := v["hydra:member"]; ok { + return interfaceSliceToMapSlice(members) + } + if items, ok := v["messages"]; ok { + return interfaceSliceToMapSlice(items) + } + case []interface{}: + return interfaceSliceToMapSlice(v) + } + return nil +} + +func interfaceSliceToMapSlice(value interface{}) []map[string]interface{} { + items, ok := value.([]interface{}) + if !ok { + return nil + } + result := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + result = append(result, m) + } + } + return result +} + +func sortMessageMapsNewestFirst(messages []map[string]interface{}) []map[string]interface{} { + sorted := append([]map[string]interface{}(nil), messages...) + sort.SliceStable(sorted, func(i, j int) bool { + ti, okI := extractMapTimestamp(sorted[i]) + tj, okJ := extractMapTimestamp(sorted[j]) + if !(okI && okJ) { + return false + } + if ti.Equal(tj) { + return false + } + return ti.After(tj) + }) + return sorted +} + +func extractMapTimestamp(msg map[string]interface{}) (time.Time, bool) { + for _, key := range []string{"received_at", "receivedAt", "created_at", "createdAt", "updated_at", "date", "timestamp"} { + if value, ok := msg[key]; ok { + switch v := value.(type) { + case string: + if ts, ok := parseTimestampString(v); ok { + return ts, true + } + case json.Number: + if num, err := v.Float64(); err == nil { + if ts, ok := parseTimestampNumber(num); ok { + return ts, true + } + } + case float64: + if ts, ok := parseTimestampNumber(v); ok { + return ts, true + } + } + } + } + return time.Time{}, false +} + +func stringValue(value interface{}) string { + switch v := value.(type) { + case string: + return strings.TrimSpace(v) + case json.Number: + return strings.TrimSpace(v.String()) + case float64: + return strings.TrimSpace(fmt.Sprintf("%.0f", v)) + default: + return "" + } +} + +func firstNonEmptyString(values ...interface{}) string { + for _, value := range values { + switch v := value.(type) { + case string: + if strings.TrimSpace(v) != "" { + return v + } + case []string: + for _, item := range v { + if strings.TrimSpace(item) != "" { + return item + } + } + case []interface{}: + for _, item := range v { + if s := firstNonEmptyString(item); s != "" { + return s + } + } + } + } + return "" +} diff --git a/internal/pkg/tempmail/fetch_providers_test.go b/internal/pkg/tempmail/fetch_providers_test.go new file mode 100644 index 0000000..8270955 --- /dev/null +++ b/internal/pkg/tempmail/fetch_providers_test.go @@ -0,0 +1,73 @@ +package tempmail + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMailTMProviderFetchVerificationCode(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/messages", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "hydra:member": []map[string]interface{}{ + { + "id": "msg-1", + "subject": "Google verification code: ABC123", + "date": "2026-03-18T14:05:00Z", + }, + }, + }) + }) + mux.HandleFunc("/messages/msg-1", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "text": "Google verification code: ABC123", + }) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + provider := &mailTMProvider{baseURL: server.URL, name: "mailtm"} + code, err := provider.FetchVerificationCode(context.Background(), "user@example.com", map[string]string{ + "token": "mailtm-token", + }, 1, 0) + if err != nil { + t.Fatalf("FetchVerificationCode() error = %v", err) + } + if code != "ABC123" { + t.Fatalf("FetchVerificationCode() = %q, want %q", code, "ABC123") + } +} + +func TestFetchVerificationCodeByProviderMailfree(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/api/emails", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]interface{}{ + { + "id": "mail-1", + "verification_code": "ZX91Q2", + }, + }) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + code, err := FetchVerificationCodeByProvider(context.Background(), "mailfree", map[string]string{}, "user@example.com", map[string]string{ + "base_url": server.URL, + "admin_token": "mailfree-token", + }) + if err != nil { + t.Fatalf("FetchVerificationCodeByProvider() error = %v", err) + } + if code != "ZX91Q2" { + t.Fatalf("FetchVerificationCodeByProvider() = %q, want %q", code, "ZX91Q2") + } +} diff --git a/internal/pkg/tempmail/multi.go b/internal/pkg/tempmail/multi.go new file mode 100644 index 0000000..3980e05 --- /dev/null +++ b/internal/pkg/tempmail/multi.go @@ -0,0 +1,173 @@ +package tempmail + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +const ( + cooldownRateLimit = 6 * time.Hour // 429 每日限额耗尽,长冷却 + cooldownAuthFail = 15 * time.Minute // 401/403 认证失败/额度耗尽,冷却 15 分钟 + cooldownError = 30 * time.Second // 其他临时错误冷却时间 +) + +// MultiProvider 多邮箱服务商自动切换 +// 当主 provider 返回 429 或错误时,自动尝试下一个可用 provider +type MultiProvider struct { + providers []EmailProvider + cooldowns map[string]time.Time // provider name → 冷却截止时间 + mu sync.RWMutex +} + +// NewMultiProvider 根据 worker config 构建多 provider 实例 +// 读取配置: +// - email_provider_priority: 逗号分隔的优先级列表(如 "yydsmail") +// - yydsmail_base_url: YYDS Mail API 地址(默认 "") +// - yydsmail_api_key: YYDS Mail API Key +func NewMultiProvider(cfg map[string]string) *MultiProvider { + priority := cfg["email_provider_priority"] + if priority == "" { + log.Warn().Msg("未配置 email_provider_priority,不会启用任何邮箱 provider") + } + + // 构建可用 provider 映射(仅构建在 priority 列表中出现的 provider) + available := make(map[string]EmailProvider) + prioritySet := make(map[string]bool) + for _, name := range strings.Split(priority, ",") { + prioritySet[strings.TrimSpace(name)] = true + } + + // YYDS Mail(自建临时邮箱服务) + if prioritySet["yydsmail"] { + yydsURL := cfg["yydsmail_base_url"] + if yydsURL == "" { + yydsURL = "" + } + yydsKey := cfg["yydsmail_api_key"] + if yydsKey != "" { + available["yydsmail"] = NewYYDSMailProvider(yydsURL, yydsKey) + } else { + log.Warn().Msg("yydsmail 在优先级列表中但未配置 yydsmail_api_key") + } + } + + // 按优先级排列(严格模式:仅使用优先级列表中的 provider) + var providers []EmailProvider + for _, name := range strings.Split(priority, ",") { + name = strings.TrimSpace(name) + if p, ok := available[name]; ok { + providers = append(providers, p) + } + } + + // 按成功率加权排序(优先用亲和度高的 provider,加随机抖动防止锁死) + weightedShuffle(providers) + + if len(providers) == 0 { + log.Warn().Msg("未配置任何邮箱 provider,请检查系统设置") + } + + return &MultiProvider{ + providers: providers, + cooldowns: make(map[string]time.Time), + } +} + +// isAvailable 检查 provider 是否可用(未在冷却中) +func (mp *MultiProvider) isAvailable(name string) bool { + mp.mu.RLock() + defer mp.mu.RUnlock() + if until, ok := mp.cooldowns[name]; ok { + return time.Now().After(until) + } + return true +} + +// markCooldown 标记 provider 进入冷却期(duration 区分 429 和普通错误) +func (mp *MultiProvider) markCooldown(name string, duration time.Duration) { + mp.mu.Lock() + defer mp.mu.Unlock() + mp.cooldowns[name] = time.Now().Add(duration) + log.Warn().Str("provider", name).Dur("cooldown", duration). + Msg("邮箱服务进入冷却期,切换到下一个 provider") +} + +// GenerateEmail 尝试所有可用 provider 创建邮箱 +func (mp *MultiProvider) GenerateEmail(ctx context.Context) (string, map[string]string, error) { + var errs []string + for _, p := range mp.providers { + if !mp.isAvailable(p.Name()) { + continue + } + + addr, meta, err := p.GenerateEmail(ctx) + if err == nil { + log.Debug().Str("provider", p.Name()).Str("email", addr).Msg("邮箱创建成功") + return addr, meta, nil + } + + // 429 → 长冷却 + if errors.Is(err, ErrRateLimited) { + mp.markCooldown(p.Name(), cooldownRateLimit) + errs = append(errs, fmt.Sprintf("%s: 429 限速", p.Name())) + continue + } + + // 401/403 → 中冷却,key 无效不会自愈 + if errors.Is(err, ErrAuthFailed) { + mp.markCooldown(p.Name(), cooldownAuthFail) + errs = append(errs, fmt.Sprintf("%s: 认证失败", p.Name())) + log.Warn().Str("provider", p.Name()).Err(err).Msg("邮箱服务认证失败,冷却 15 分钟") + continue + } + + // 其他临时错误 → 短冷却(30 秒) + mp.markCooldown(p.Name(), cooldownError) + errs = append(errs, fmt.Sprintf("%s: %s", p.Name(), err.Error())) + log.Warn().Str("provider", p.Name()).Err(err).Msg("邮箱创建失败,进入冷却并尝试下一个") + } + + return "", nil, fmt.Errorf("所有邮箱服务均不可用: %s", strings.Join(errs, "; ")) +} + +// FetchVerificationCode 使用创建邮箱时的同一 provider 获取验证码 +func (mp *MultiProvider) FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) { + providerName := meta["provider"] + for _, p := range mp.providers { + if p.Name() == providerName { + return p.FetchVerificationCode(ctx, addr, meta, maxAttempts, interval) + } + } + return "", fmt.Errorf("未找到邮箱 provider: %s", providerName) +} + +// DeleteEmail 使用创建邮箱时的同一 provider 删除邮箱 +func (mp *MultiProvider) DeleteEmail(ctx context.Context, addr string, meta map[string]string) error { + providerName := meta["provider"] + for _, p := range mp.providers { + if p.Name() == providerName { + return p.DeleteEmail(ctx, addr, meta) + } + } + return nil // provider 找不到,静默返回 +} + +// ProviderCount 返回可用 provider 数量 +func (mp *MultiProvider) ProviderCount() int { + return len(mp.providers) +} + +// ProviderNames 返回所有 provider 名称(按优先级排列) +func (mp *MultiProvider) ProviderNames() []string { + names := make([]string, len(mp.providers)) + for i, p := range mp.providers { + names[i] = p.Name() + } + return names +} diff --git a/internal/pkg/tempmail/provider.go b/internal/pkg/tempmail/provider.go new file mode 100644 index 0000000..f1ba502 --- /dev/null +++ b/internal/pkg/tempmail/provider.go @@ -0,0 +1,29 @@ +package tempmail + +import ( + "context" + "errors" + "time" +) + +// ErrRateLimited 表示邮箱服务返回 429(速率限制) +var ErrRateLimited = errors.New("邮箱服务速率限制 (429)") + +// ErrAuthFailed 表示邮箱服务认证失败(401/403,API Key 无效) +var ErrAuthFailed = errors.New("邮箱服务认证失败 (401/403)") + +// EmailProvider 临时邮箱服务提供者接口 +// meta 用于携带 provider 特有的状态(如 Mail.tm 的 JWT token、Guerrilla Mail 的 sid_token、tempmail.lol 的 token) +type EmailProvider interface { + // Name 返回 provider 唯一标识(如 "yydsmail") + Name() string + + // GenerateEmail 创建一个临时邮箱,返回邮箱地址和 provider 元数据 + GenerateEmail(ctx context.Context) (addr string, meta map[string]string, err error) + + // FetchVerificationCode 从邮箱中提取验证码 + FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) + + // DeleteEmail 删除/清理临时邮箱(非关键步骤,失败不影响主流程) + DeleteEmail(ctx context.Context, addr string, meta map[string]string) error +} diff --git a/internal/pkg/tempmail/stats.go b/internal/pkg/tempmail/stats.go new file mode 100644 index 0000000..e48536e --- /dev/null +++ b/internal/pkg/tempmail/stats.go @@ -0,0 +1,164 @@ +package tempmail + +import ( + "math/rand" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" +) + +// providerStat 单个 provider 的成功/失败原子计数 +type providerStat struct { + success atomic.Int64 + fail atomic.Int64 +} + +// statsTracker 全局 provider 成功率追踪器 +// 跨所有 MultiProvider 实例共享,用于按亲和度加权选择 provider +type statsTracker struct { + stats map[string]*providerStat + mu sync.RWMutex +} + +// globalTracker 包级单例 +var globalTracker = &statsTracker{ + stats: make(map[string]*providerStat), +} + +func init() { + // 每 10 分钟衰减一次计数器,让历史表现差的 provider 更快恢复 + go func() { + ticker := time.NewTicker(10 * time.Minute) + for range ticker.C { + globalTracker.decay() + } + }() +} + +// RecordSuccess 记录某 provider 一次成功(验证码到达 / 注册成功) +func RecordSuccess(providerName string) { + if providerName == "" { + return + } + s := globalTracker.getOrCreate(providerName) + newSucc := s.success.Add(1) + total := newSucc + s.fail.Load() + log.Debug().Str("provider", providerName). + Int64("success", newSucc).Int64("total", total). + Msg("邮箱 provider 成功 +1") +} + +// RecordFailure 记录某 provider 一次失败(验证码超时等明确邮箱问题) +func RecordFailure(providerName string) { + if providerName == "" { + return + } + s := globalTracker.getOrCreate(providerName) + newFail := s.fail.Add(1) + total := s.success.Load() + newFail + log.Debug().Str("provider", providerName). + Int64("fail", newFail).Int64("total", total). + Msg("邮箱 provider 失败 +1") +} + +// getOrCreate 获取或创建 provider 统计条目(双重检查锁) +func (t *statsTracker) getOrCreate(name string) *providerStat { + t.mu.RLock() + if s, ok := t.stats[name]; ok { + t.mu.RUnlock() + return s + } + t.mu.RUnlock() + + t.mu.Lock() + defer t.mu.Unlock() + if s, ok := t.stats[name]; ok { + return s + } + s := &providerStat{} + t.stats[name] = s + return s +} + +// getWeight 计算 provider 的选择权重(0.1 ~ 1.0) +// - 无数据 → 0.5(给新 provider 公平机会) +// - 样本 < 3 → 0.5(数据不足,不下结论) +// - 正常 = success/(success+fail),下限 0.1(不完全排除) +func (t *statsTracker) getWeight(name string) float64 { + t.mu.RLock() + s, ok := t.stats[name] + t.mu.RUnlock() + if !ok { + return 0.5 + } + succ := s.success.Load() + fail := s.fail.Load() + total := succ + fail + if total < 3 { + return 0.5 + } + rate := float64(succ) / float64(total) + if rate < 0.1 { + return 0.1 + } + return rate +} + +// decay 衰减所有计数器(÷2),让 provider 有恢复窗口 +// 使用 Add 负值代替 Load+Store,避免与并发 RecordSuccess/RecordFailure 竞争 +func (t *statsTracker) decay() { + t.mu.RLock() + defer t.mu.RUnlock() + for name, s := range t.stats { + succ := s.success.Load() + fail := s.fail.Load() + if succ > 1 { + s.success.Add(-(succ / 2)) + } + if fail > 1 { + s.fail.Add(-(fail / 2)) + } + log.Debug().Str("provider", name). + Int64("success", s.success.Load()).Int64("fail", s.fail.Load()). + Msg("邮箱 provider 统计衰减") + } +} + +// weightedShuffle 按成功率加权排序 provider 列表 +// 高成功率优先,加 0.15 随机抖动防止完全锁定到单一 provider +// 预计算权重再排序,确保比较函数满足传递性 +func weightedShuffle(providers []EmailProvider) { + weights := make([]float64, len(providers)) + for i, p := range providers { + weights[i] = globalTracker.getWeight(p.Name()) + rand.Float64()*0.15 + } + sort.SliceStable(providers, func(i, j int) bool { + return weights[i] > weights[j] + }) +} + +// GetProviderStats 返回所有 provider 的统计快照(调试/监控用) +func GetProviderStats() map[string][2]int64 { + globalTracker.mu.RLock() + defer globalTracker.mu.RUnlock() + result := make(map[string][2]int64, len(globalTracker.stats)) + for name, s := range globalTracker.stats { + result[name] = [2]int64{s.success.Load(), s.fail.Load()} + } + return result +} + +// WeightedShuffleNames 按成功率加权排序 provider 名字列表(供远程服务模式使用) +// 高成功率优先,加 0.15 随机抖动防止完全锁定到单一 provider +func WeightedShuffleNames(names []string) { + weights := make([]float64, len(names)) + for i, n := range names { + weights[i] = globalTracker.getWeight(n) + rand.Float64()*0.15 + } + sort.SliceStable(names, func(i, j int) bool { + return weights[i] > weights[j] + }) +} diff --git a/internal/pkg/tempmail/yydsmail.go b/internal/pkg/tempmail/yydsmail.go new file mode 100644 index 0000000..4f05f25 --- /dev/null +++ b/internal/pkg/tempmail/yydsmail.go @@ -0,0 +1,332 @@ +package tempmail + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +// YYDSMailProvider 基于 YYDS Mail API 的临时邮箱服务 +// API: POST /v1/accounts → 创建邮箱(返回 address + token) +// API: GET /v1/messages?address=xxx → 获取邮件列表(需 Bearer token) +// API: GET /v1/messages/{id} → 获取邮件详情(需 Bearer token) +// API: DELETE /v1/accounts/{id} → 删除邮箱(需 Bearer token) +type YYDSMailProvider struct { + baseURL string + apiKey string + httpClient *http.Client + mu sync.Mutex + lastCall time.Time + minDelay time.Duration +} + +type yydsMessage struct { + ID string `json:"id"` + Subject string `json:"subject"` + ReceivedAt json.RawMessage `json:"received_at"` + ReceivedAtCamel json.RawMessage `json:"receivedAt"` + CreatedAt json.RawMessage `json:"created_at"` + CreatedAtCamel json.RawMessage `json:"createdAt"` + UpdatedAt json.RawMessage `json:"updated_at"` + Date json.RawMessage `json:"date"` + Timestamp json.RawMessage `json:"timestamp"` +} + +// NewYYDSMailProvider 创建 YYDS Mail provider +// baseURL: API 地址(如 https://) +// apiKey: API Key(如 AC-xxxx) +func NewYYDSMailProvider(baseURL, apiKey string) *YYDSMailProvider { + return &YYDSMailProvider{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + httpClient: &http.Client{Timeout: 15 * time.Second}, + minDelay: 300 * time.Millisecond, + } +} + +func (p *YYDSMailProvider) Name() string { return "yydsmail" } + +// throttle 全局节流 +func (p *YYDSMailProvider) throttle() { + p.mu.Lock() + defer p.mu.Unlock() + elapsed := time.Since(p.lastCall) + if elapsed < p.minDelay { + time.Sleep(p.minDelay - elapsed) + } + p.lastCall = time.Now() +} + +// doRequest 执行 HTTP 请求,支持 API Key 或 Bearer token 认证 +func (p *YYDSMailProvider) doRequest(ctx context.Context, method, rawURL string, authToken string, maxRetries int) ([]byte, error) { + var lastErr error + for i := 0; i < maxRetries; i++ { + p.throttle() + + req, err := http.NewRequestWithContext(ctx, method, rawURL, nil) + if err != nil { + return nil, err + } + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } else { + req.Header.Set("X-API-Key", p.apiKey) + } + req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)") + + resp, err := p.httpClient.Do(req) + if err != nil { + lastErr = err + time.Sleep(time.Duration(i+1) * 2 * time.Second) + continue + } + + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return nil, fmt.Errorf("%w: yydsmail HTTP %d", ErrAuthFailed, resp.StatusCode) + } + if resp.StatusCode == 429 { + return nil, fmt.Errorf("%w: yydsmail 速率限制", ErrRateLimited) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + lastErr = fmt.Errorf("yydsmail HTTP %d: %s", resp.StatusCode, string(body)) + continue + } + return body, nil + } + return nil, fmt.Errorf("yydsmail 请求失败 (重试 %d 次): %w", maxRetries, lastErr) +} + +// GenerateEmail 调用 POST /v1/accounts 创建临时邮箱 +func (p *YYDSMailProvider) GenerateEmail(ctx context.Context) (string, map[string]string, error) { + body, err := p.doRequest(ctx, "POST", p.baseURL+"/v1/accounts", "", 3) + if err != nil { + return "", nil, fmt.Errorf("yydsmail 创建邮箱失败: %w", err) + } + + var resp struct { + Success bool `json:"success"` + Data struct { + ID string `json:"id"` + Address string `json:"address"` + Token string `json:"token"` + } `json:"data"` + Error string `json:"error"` + } + if err := json.Unmarshal(body, &resp); err != nil { + return "", nil, fmt.Errorf("解析 yydsmail 响应失败: %w", err) + } + if !resp.Success || resp.Data.Address == "" { + return "", nil, fmt.Errorf("yydsmail 返回失败: %s", resp.Error) + } + + meta := map[string]string{ + "provider": "yydsmail", + "token": resp.Data.Token, + "inbox_id": resp.Data.ID, + } + return resp.Data.Address, meta, nil +} + +// FetchVerificationCode 轮询 GET /v1/messages 获取验证码 +func (p *YYDSMailProvider) FetchVerificationCode(ctx context.Context, addr string, meta map[string]string, maxAttempts int, interval time.Duration) (string, error) { + token := meta["token"] + if token == "" { + return "", fmt.Errorf("yydsmail: 缺少 token") + } + + listURL := fmt.Sprintf("%s/v1/messages?address=%s", p.baseURL, url.QueryEscape(addr)) + + for i := 0; i < maxAttempts; i++ { + body, err := p.doRequest(ctx, "GET", listURL, token, 1) + if err != nil { + time.Sleep(interval) + continue + } + + var resp struct { + Success bool `json:"success"` + Data struct { + Messages []yydsMessage `json:"messages"` + } `json:"data"` + } + if err := json.Unmarshal(body, &resp); err != nil || !resp.Success { + time.Sleep(interval) + continue + } + + messages := sortMessagesNewestFirst(resp.Data.Messages) + + // 按最新邮件优先遍历,先从 subject 提取,不行再获取详情。 + for _, msg := range messages { + if code := ExtractVerificationCode(msg.Subject, ""); code != "" { + return code, nil + } + + // 获取邮件详情(含 text/html body) + detailURL := fmt.Sprintf("%s/v1/messages/%s", p.baseURL, msg.ID) + detailBody, err := p.doRequest(ctx, "GET", detailURL, token, 1) + if err != nil { + continue + } + + var detail struct { + Success bool `json:"success"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(detailBody, &detail); err != nil || !detail.Success { + continue + } + + for _, content := range collectDetailContents(detail.Data) { + if code := ExtractVerificationCode("", content); code != "" { + return code, nil + } + } + } + + time.Sleep(interval) + } + return "", fmt.Errorf("yydsmail 获取验证码超时 (%d 次轮询)", maxAttempts) +} + +func sortMessagesNewestFirst(messages []yydsMessage) []yydsMessage { + sorted := append([]yydsMessage(nil), messages...) + sort.SliceStable(sorted, func(i, j int) bool { + ti, okI := extractMessageTime(sorted[i]) + tj, okJ := extractMessageTime(sorted[j]) + if !(okI && okJ) { + return false + } + if ti.Equal(tj) { + return false + } + return ti.After(tj) + }) + return sorted +} + +func extractMessageTime(msg yydsMessage) (time.Time, bool) { + for _, raw := range []json.RawMessage{ + msg.ReceivedAt, + msg.ReceivedAtCamel, + msg.CreatedAt, + msg.CreatedAtCamel, + msg.UpdatedAt, + msg.Date, + msg.Timestamp, + } { + if ts, ok := parseRawTimestamp(raw); ok { + return ts, true + } + } + return time.Time{}, false +} + +func parseRawTimestamp(raw json.RawMessage) (time.Time, bool) { + if len(raw) == 0 { + return time.Time{}, false + } + + var text string + if err := json.Unmarshal(raw, &text); err == nil { + return parseTimestampString(text) + } + + var number float64 + if err := json.Unmarshal(raw, &number); err == nil { + return parseTimestampNumber(number) + } + + return time.Time{}, false +} + +func parseTimestampString(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + + for _, layout := range []string{ + time.RFC3339Nano, + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02 15:04:05 -0700 MST", + "2006-01-02T15:04:05.000Z07:00", + "2006-01-02", + } { + if ts, err := time.Parse(layout, value); err == nil { + return ts, true + } + } + + if number, err := strconv.ParseFloat(value, 64); err == nil { + return parseTimestampNumber(number) + } + + return time.Time{}, false +} + +func parseTimestampNumber(value float64) (time.Time, bool) { + if value <= 0 { + return time.Time{}, false + } + + seconds := int64(value) + nanos := int64(0) + if value > 1e12 { + millis := int64(value) + seconds = millis / 1000 + nanos = (millis % 1000) * int64(time.Millisecond) + } + return time.Unix(seconds, nanos), true +} + +func collectDetailContents(data map[string]interface{}) []string { + fields := []string{"text", "html", "content", "body", "text_content", "html_content"} + contents := make([]string, 0, len(fields)) + for _, field := range fields { + contents = appendContentValue(contents, data[field]) + } + return contents +} + +func appendContentValue(contents []string, value interface{}) []string { + switch v := value.(type) { + case string: + if strings.TrimSpace(v) != "" { + return append(contents, v) + } + case []interface{}: + for _, item := range v { + contents = appendContentValue(contents, item) + } + case []string: + for _, item := range v { + contents = appendContentValue(contents, item) + } + } + return contents +} + +// DeleteEmail 调用 DELETE /v1/accounts/{id} 删除邮箱(需要 temp token 认证) +func (p *YYDSMailProvider) DeleteEmail(ctx context.Context, addr string, meta map[string]string) error { + inboxID := meta["inbox_id"] + token := meta["token"] + if inboxID == "" || token == "" { + return nil + } + deleteURL := fmt.Sprintf("%s/v1/accounts/%s", p.baseURL, inboxID) + _, _ = p.doRequest(ctx, "DELETE", deleteURL, token, 1) + return nil +} diff --git a/internal/pkg/tempmail/yydsmail_test.go b/internal/pkg/tempmail/yydsmail_test.go new file mode 100644 index 0000000..bafd665 --- /dev/null +++ b/internal/pkg/tempmail/yydsmail_test.go @@ -0,0 +1,127 @@ +package tempmail + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestYYDSMailFetchVerificationCodeUsesLatestMessage(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "messages": []map[string]interface{}{ + { + "id": "old-msg", + "subject": "Gemini Enterprise verification code", + "created_at": "2026-03-18T14:00:00Z", + }, + { + "id": "new-msg", + "subject": "Gemini Enterprise verification code", + "created_at": "2026-03-18T14:05:00Z", + }, + }, + }, + }) + }) + mux.HandleFunc("/v1/messages/old-msg", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "text": "Your Google verification code is OLD111", + "html": []string{}, + }, + }) + }) + mux.HandleFunc("/v1/messages/new-msg", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "text": "", + "html": []string{ + `
Your one-time verification code is:
`, + `
DZJW4P
`, + }, + }, + }) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + provider := NewYYDSMailProvider(server.URL, "test-api-key") + code, err := provider.FetchVerificationCode(context.Background(), "gemini@example.com", map[string]string{ + "provider": "yydsmail", + "token": "temp-token", + }, 1, 0) + if err != nil { + t.Fatalf("FetchVerificationCode() error = %v", err) + } + if code != "DZJW4P" { + t.Fatalf("FetchVerificationCode() = %q, want %q", code, "DZJW4P") + } +} + +func TestYYDSMailFetchVerificationCodePreservesOriginalOrderWhenTimestampMissing(t *testing.T) { + t.Parallel() + + mux := http.NewServeMux() + mux.HandleFunc("/v1/messages", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "messages": []map[string]interface{}{ + { + "id": "latest-without-time", + "subject": "Verification code", + }, + { + "id": "older-with-time", + "subject": "Verification code", + "created_at": "2026-03-18T14:00:00Z", + }, + }, + }, + }) + }) + mux.HandleFunc("/v1/messages/latest-without-time", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "text": "Use this passcode: 456789", + "html": []string{}, + }, + }) + }) + mux.HandleFunc("/v1/messages/older-with-time", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "data": map[string]interface{}{ + "text": "Use this passcode: 123456", + "html": []string{}, + }, + }) + }) + + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + provider := NewYYDSMailProvider(server.URL, "test-api-key") + code, err := provider.FetchVerificationCode(context.Background(), "latest@example.com", map[string]string{ + "provider": "yydsmail", + "token": "temp-token", + }, 1, 0) + if err != nil { + t.Fatalf("FetchVerificationCode() error = %v", err) + } + if code != "456789" { + t.Fatalf("FetchVerificationCode() = %q, want %q", code, "456789") + } +} diff --git a/internal/pkg/turnstile/solver.go b/internal/pkg/turnstile/solver.go new file mode 100644 index 0000000..9035eaa --- /dev/null +++ b/internal/pkg/turnstile/solver.go @@ -0,0 +1,656 @@ +package turnstile + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" +) + +// ── 健康追踪 ── + +// solverHealth 单个 solver 节点的健康状态 +type solverHealth struct { + mu sync.Mutex + consecutiveFail int + cooldownUntil time.Time +} + +// record 记录一次求解结果 +func (h *solverHealth) record(ok bool) { + h.mu.Lock() + defer h.mu.Unlock() + if ok { + h.consecutiveFail = 0 + h.cooldownUntil = time.Time{} + } else { + h.consecutiveFail++ + // 连续 3 次失败 → 冷却 2 分钟 + if h.consecutiveFail >= 3 { + h.cooldownUntil = time.Now().Add(2 * time.Minute) + } + } +} + +// isHealthy 是否可用(未在冷却中) +func (h *solverHealth) isHealthy() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.cooldownUntil.IsZero() || time.Now().After(h.cooldownUntil) +} + +// ── Solver ── + +// Solver Cloudflare Turnstile 验证码求解器(多后端并行竞速) +type Solver struct { + solverURLs []string // 本地 solver URL 列表 + capSolverKey string // CapSolver API key + yesCaptchaKey string // YesCaptcha API key + proxyURL string // 代理地址(传给本地 solver 浏览器) + httpClient *http.Client + // 健康追踪(按 solver URL / 服务名 索引) + healthMu sync.RWMutex + health map[string]*solverHealth +} + +// NewSolver 创建求解器 +func NewSolver(solverURLs []string, capSolverKey, yesCaptchaKey, proxyURL string) *Solver { + cleaned := make([]string, 0, len(solverURLs)) + for _, u := range solverURLs { + u = strings.TrimSpace(u) + u = strings.TrimRight(u, "/") + if u != "" { + cleaned = append(cleaned, u) + } + } + return &Solver{ + solverURLs: cleaned, + capSolverKey: capSolverKey, + yesCaptchaKey: yesCaptchaKey, + proxyURL: proxyURL, + httpClient: &http.Client{Timeout: 30 * time.Second}, + health: make(map[string]*solverHealth), + } +} + +// getHealth 获取指定 solver 的健康状态(懒初始化) +func (s *Solver) getHealth(key string) *solverHealth { + s.healthMu.RLock() + h, ok := s.health[key] + s.healthMu.RUnlock() + if ok { + return h + } + s.healthMu.Lock() + defer s.healthMu.Unlock() + if h, ok = s.health[key]; !ok { + h = &solverHealth{} + s.health[key] = h + } + return h +} + +// ── 主入口 ── + +// Solve 求解 Turnstile,返回 token +// 级联顺序:本地 solver 并行竞速 → 云端 solver 并行竞速 → 内置脚本 +func (s *Solver) Solve(ctx context.Context, siteURL, siteKey string, maxAttempts int, logf func(string, ...interface{})) (string, error) { + if logf == nil { + logf = func(string, ...interface{}) {} + } + var lastErr error + + for i := 0; i < maxAttempts; i++ { + if ctx.Err() != nil { + return "", ctx.Err() + } + + // 1) 本地 solver 并行竞速 + if len(s.solverURLs) > 0 { + token, err := s.raceLocalSolvers(ctx, siteURL, siteKey, logf) + if err == nil && token != "" { + return token, nil + } + lastErr = err + } + + // 2) 云端 solver 并行竞速(CapSolver + YesCaptcha 同时提交) + if s.capSolverKey != "" || s.yesCaptchaKey != "" { + token, err := s.raceCloudSolvers(ctx, siteURL, siteKey, logf) + if err == nil && token != "" { + return token, nil + } + lastErr = err + } + + // 3) 内置脚本(最后手段) + if ctx.Err() != nil { + return "", ctx.Err() + } + logf("[*] 切换到内置脚本...") + if token, err := s.solveBuiltinScript(ctx, siteURL, siteKey); err == nil && token != "" { + logf("[+] 卧槽Σ(°ロ°)成功了(内置脚本)") + return token, nil + } + + if i < maxAttempts-1 { + logf("[!] 本轮全部失败,等待重试 (%d/%d)...", i+1, maxAttempts) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + } + } + return "", fmt.Errorf("验证求解失败 (%d 次): %v", maxAttempts, lastErr) +} + +// ── 并行竞速 ── + +// raceLocalSolvers 并行提交到所有本地 solver,第一个成功的 token 胜出 +func (s *Solver) raceLocalSolvers(ctx context.Context, siteURL, siteKey string, logf func(string, ...interface{})) (string, error) { + // 单 solver 直接串行(省 goroutine 开销) + if len(s.solverURLs) == 1 { + u := s.solverURLs[0] + h := s.getHealth(u) + logf("[*] 冲鸭冲鸭冲鸭...") + token, err := s.solveLocal(ctx, u, siteURL, siteKey, logf) + if err == nil && token != "" { + logf("[+] 卧槽Σ(°ロ°)成功了 #1") + h.record(true) + return token, nil + } + h.record(false) + logf("[-] 解密中 #1 失败: %v", err) + return "", err + } + + // 多 solver 并行竞速 + raceCtx, raceCancel := context.WithCancel(ctx) + defer raceCancel() + + type raceResult struct { + token string + err error + idx int + } + ch := make(chan raceResult, len(s.solverURLs)) + + for idx, solverURL := range s.solverURLs { + idx, solverURL := idx, solverURL + h := s.getHealth(solverURL) + go func() { + // 冷却中的 solver 延迟启动(仍参与竞速,只是让健康的先跑) + if !h.isHealthy() { + logf("[*] 解密中 #%d 冷却中,延迟启动...", idx+1) + select { + case <-raceCtx.Done(): + ch <- raceResult{err: raceCtx.Err(), idx: idx} + return + case <-time.After(5 * time.Second): + } + } + logf("[*] 冲鸭冲鸭冲鸭... #%d", idx+1) + token, err := s.solveLocal(raceCtx, solverURL, siteURL, siteKey, logf) + ch <- raceResult{token: token, err: err, idx: idx} + }() + } + + var lastErr error + for range s.solverURLs { + select { + case <-ctx.Done(): + return "", ctx.Err() + case r := <-ch: + h := s.getHealth(s.solverURLs[r.idx]) + if r.err == nil && r.token != "" { + logf("[+] 卧槽Σ(°ロ°)成功了 #%d", r.idx+1) + raceCancel() // 取消其他 solver + h.record(true) + return r.token, nil + } + // 被取消的不算失败 + if r.err != nil && r.err != context.Canceled { + logf("[-] 解密中 #%d 失败: %v", r.idx+1, r.err) + lastErr = r.err + h.record(false) + } + } + } + return "", lastErr +} + +// raceCloudSolvers 并行提交 CapSolver + YesCaptcha,第一个成功的胜出 +func (s *Solver) raceCloudSolvers(ctx context.Context, siteURL, siteKey string, logf func(string, ...interface{})) (string, error) { + raceCtx, raceCancel := context.WithCancel(ctx) + defer raceCancel() + + type raceResult struct { + token string + err error + name string + } + + var count int + ch := make(chan raceResult, 2) + + if s.capSolverKey != "" { + count++ + go func() { + logf("[*] 冲鸭冲鸭冲鸭... 云端A") + token, err := s.solveCapSolver(raceCtx, siteURL, siteKey, logf) + ch <- raceResult{token: token, err: err, name: "云端A"} + }() + } + if s.yesCaptchaKey != "" { + count++ + go func() { + logf("[*] 冲鸭冲鸭冲鸭... 云端B") + token, err := s.solveYesCaptcha(raceCtx, siteURL, siteKey, logf) + ch <- raceResult{token: token, err: err, name: "云端B"} + }() + } + if count == 0 { + return "", fmt.Errorf("无可用云端服务") + } + + var lastErr error + for j := 0; j < count; j++ { + select { + case <-ctx.Done(): + return "", ctx.Err() + case r := <-ch: + if r.err == nil && r.token != "" { + logf("[+] 卧槽Σ(°ロ°)成功了(%s)", r.name) + raceCancel() + return r.token, nil + } + if r.err != nil && r.err != context.Canceled { + logf("[-] %s 失败: %v", r.name, r.err) + lastErr = r.err + } + } + } + return "", lastErr +} + +// ── 远程端 ── + +// solveLocal 单节点本地求解(提交 + 轮询) +func (s *Solver) solveLocal(ctx context.Context, solverURL, siteURL, siteKey string, logf func(string, ...interface{})) (string, error) { + // 提交请求 — 使用 10s 超时快速检测不可达节点 + submitCtx, submitCancel := context.WithTimeout(ctx, 10*time.Second) + defer submitCancel() + + reqURL := fmt.Sprintf("%s/turnstile?url=%s&sitekey=%s", solverURL, url.QueryEscape(siteURL), url.QueryEscape(siteKey)) + if s.proxyURL != "" { + reqURL += "&proxy=" + url.QueryEscape(s.proxyURL) + } + req, err := http.NewRequestWithContext(submitCtx, "GET", reqURL, nil) + if err != nil { + return "", fmt.Errorf("远程端构建请求失败: %w", err) + } + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("远程端连接失败: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var createResp map[string]interface{} + if err := json.Unmarshal(body, &createResp); err != nil { + return "", fmt.Errorf("远程端响应解析失败: %w", err) + } + + taskID, _ := createResp["taskId"].(string) + if taskID == "" { + taskID, _ = createResp["task_id"].(string) + } + if taskID == "" { + if token, ok := createResp["token"].(string); ok && token != "" { + return token, nil + } + return "", fmt.Errorf("远程端无 taskId") + } + + logf("[*] 任务提交啦,等等我马上就好...") + + // 首次轮询等待缩短到 2s(原 5s 太慢,浏览器启动 + 页面加载通常 2-3s) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + } + + resultURL := fmt.Sprintf("%s/result?id=%s", solverURL, taskID) + for j := 0; j < 30; j++ { + if ctx.Err() != nil { + return "", ctx.Err() + } + pollReq, _ := http.NewRequestWithContext(ctx, "GET", resultURL, nil) + resp, err := s.httpClient.Do(pollReq) + if err != nil { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + + if errID, ok := result["errorId"].(float64); ok && errID != 0 { + desc, _ := result["errorDescription"].(string) + code, _ := result["errorCode"].(string) + // "Task not found" = poll 打到了负载均衡的错误节点,继续轮询等正确节点 + if strings.Contains(strings.ToLower(desc), "not found") || strings.Contains(strings.ToLower(code), "not_found") { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + } + continue + } + return "", fmt.Errorf("远程端失败: %v (%s)", code, desc) + } + status, _ := result["status"].(string) + if status == "CAPTCHA_FAIL" { + return "", fmt.Errorf("远程端失败") + } + if j%3 == 0 { + logf("[*] 等等我马上就好... (%d/30)", j+1) + } + if solution, ok := result["solution"].(map[string]interface{}); ok { + if token, ok := solution["token"].(string); ok && token != "" { + return token, nil + } + } + if token, ok := result["token"].(string); ok && token != "" { + return token, nil + } + if value, ok := result["value"].(string); ok && value != "" { + return value, nil + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(2 * time.Second): + } + } + return "", fmt.Errorf("远程端轮询超时") +} + +// ── 云端求解器 ── + +// solveCapSolver CapSolver 云服务求解 Turnstile +func (s *Solver) solveCapSolver(ctx context.Context, siteURL, siteKey string, logf func(string, ...interface{})) (string, error) { + // 创建任务 + payload, _ := json.Marshal(map[string]interface{}{ + "clientKey": s.capSolverKey, + "task": map[string]interface{}{ + "type": "AntiTurnstileTaskProxyLess", + "websiteURL": siteURL, + "websiteKey": siteKey, + }, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.capsolver.com/createTask", strings.NewReader(string(payload))) + if err != nil { + return "", fmt.Errorf("CapSolver 构建请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("CapSolver 连接失败: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var createResp struct { + ErrorID int `json:"errorId"` + ErrorCode string `json:"errorCode"` + ErrorDescription string `json:"errorDescription"` + TaskID string `json:"taskId"` + } + if err := json.Unmarshal(body, &createResp); err != nil { + return "", fmt.Errorf("CapSolver 响应解析失败: %w", err) + } + if createResp.ErrorID != 0 { + return "", fmt.Errorf("CapSolver 创建任务失败: %s (%s)", createResp.ErrorCode, createResp.ErrorDescription) + } + if createResp.TaskID == "" { + return "", fmt.Errorf("CapSolver 无 taskId: %s", string(body)) + } + + logf("[*] 云端A 任务提交啦,等等我...") + + // 轮询结果(最多 120 秒) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(5 * time.Second): + } + for j := 0; j < 40; j++ { + if ctx.Err() != nil { + return "", ctx.Err() + } + pollPayload, _ := json.Marshal(map[string]interface{}{ + "clientKey": s.capSolverKey, + "taskId": createResp.TaskID, + }) + pollReq, _ := http.NewRequestWithContext(ctx, "POST", "https://api.capsolver.com/getTaskResult", strings.NewReader(string(pollPayload))) + pollReq.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(pollReq) + if err != nil { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var result struct { + ErrorID int `json:"errorId"` + Status string `json:"status"` + Solution struct { + Token string `json:"token"` + UserAgent string `json:"userAgent"` + } `json:"solution"` + } + if err := json.Unmarshal(body, &result); err != nil { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + continue + } + + if result.Status == "ready" && result.Solution.Token != "" { + return result.Solution.Token, nil + } + if result.ErrorID != 0 { + return "", fmt.Errorf("CapSolver 任务失败: errorId=%d", result.ErrorID) + } + if result.Status == "failed" { + return "", fmt.Errorf("CapSolver 任务失败") + } + if j%5 == 0 { + logf("[*] 云端A 等等我马上就好... (%d/40)", j+1) + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + } + return "", fmt.Errorf("CapSolver 轮询超时") +} + +// solveYesCaptcha YesCaptcha 云服务求解 +func (s *Solver) solveYesCaptcha(ctx context.Context, siteURL, siteKey string, logf func(string, ...interface{})) (string, error) { + payload, _ := json.Marshal(map[string]interface{}{ + "clientKey": s.yesCaptchaKey, + "task": map[string]interface{}{ + "type": "TurnstileTaskProxyless", + "websiteURL": siteURL, + "websiteKey": siteKey, + }, + }) + + req, err := http.NewRequestWithContext(ctx, "POST", "https://api.yescaptcha.com/createTask", strings.NewReader(string(payload))) + if err != nil { + return "", fmt.Errorf("YesCaptcha 构建请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("YesCaptcha 连接失败: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var createResp map[string]interface{} + if err := json.Unmarshal(body, &createResp); err != nil { + return "", fmt.Errorf("YesCaptcha 响应解析失败: %w", err) + } + taskID, ok := createResp["taskId"].(string) + if !ok || taskID == "" { + return "", fmt.Errorf("YesCaptcha 无 taskId: %s", string(body)) + } + + logf("[*] 云端B 任务提交啦,等等我...") + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(5 * time.Second): + } + + for j := 0; j < 60; j++ { + if ctx.Err() != nil { + return "", ctx.Err() + } + pollPayload, _ := json.Marshal(map[string]interface{}{ + "clientKey": s.yesCaptchaKey, + "taskId": taskID, + }) + pollReq, _ := http.NewRequestWithContext(ctx, "POST", "https://api.yescaptcha.com/getTaskResult", strings.NewReader(string(pollPayload))) + pollReq.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(pollReq) + if err != nil { + logf("[*] 云端B 请求失败,重试中...", ) + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + continue + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + continue + } + + status, _ := result["status"].(string) + if j%5 == 0 { + logf("[*] 云端B 等等我马上就好... (%d/60)", j+1) + } + if status == "ready" { + if solution, ok := result["solution"].(map[string]interface{}); ok { + if token, ok := solution["token"].(string); ok && token != "" { + return token, nil + } + } + } + if status == "failed" { + return "", fmt.Errorf("YesCaptcha 任务失败") + } + + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(3 * time.Second): + } + } + return "", fmt.Errorf("YesCaptcha 轮询超时") +} + +// ── 内置脚本 ── + +// solveBuiltinScript 内置浏览器脚本求解(最后手段) +func (s *Solver) solveBuiltinScript(ctx context.Context, siteURL, siteKey string) (string, error) { + scriptPath := os.Getenv("TURNSTILE_SOLVER_SCRIPT") + if scriptPath == "" { + scriptPath = os.Getenv("CAMOUFOX_SOLVER_SCRIPT") + } + if scriptPath == "" { + execDir := filepath.Dir(os.Args[0]) + candidates := []string{ + "scripts/solve_turnstile.py", + filepath.Join(execDir, "scripts", "solve_turnstile.py"), + filepath.Join(execDir, "..", "scripts", "solve_turnstile.py"), + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + scriptPath = p + break + } + } + } + if scriptPath == "" { + return "", fmt.Errorf("内置 solver 脚本未找到") + } + + scriptCtx, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + + cmd := exec.CommandContext(scriptCtx, "python3", scriptPath, siteURL, siteKey, "45") + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("内置 solver 失败: %w\n%s", err, string(output)) + } + + for _, line := range strings.Split(string(output), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "TOKEN:") { + return strings.TrimPrefix(line, "TOKEN:"), nil + } + if strings.HasPrefix(line, "ERROR:") { + return "", fmt.Errorf("内置 solver: %s", strings.TrimPrefix(line, "ERROR:")) + } + } + + return "", fmt.Errorf("内置 solver 无 token") +} diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..748b307 --- /dev/null +++ b/internal/service/auth_service.go @@ -0,0 +1,393 @@ +package service + +import ( + "context" + "errors" + "fmt" + "strconv" + "sync" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "github.com/xiaolajiaoyyds/regplatformm/internal/config" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/cache" + "gorm.io/gorm" +) + +// cachedUser 用户缓存条目 +type cachedUser struct { + user *model.User + expiresAt time.Time +} + +// AuthService 认证服务(L1 内存 + L2 Redis 两层缓存) +type AuthService struct { + db *gorm.DB + cfg *config.Config + settingSvc *SettingService + redis *cache.RedisCache + ucache map[uint]cachedUser + mu sync.RWMutex + l1TTL time.Duration // L1 内存缓存 TTL + l2TTL time.Duration // L2 Redis 缓存 TTL +} + +// NewAuthService 创建认证服务 +func NewAuthService(db *gorm.DB, cfg *config.Config, settingSvc *SettingService, rc *cache.RedisCache) *AuthService { + return &AuthService{ + db: db, + cfg: cfg, + settingSvc: settingSvc, + redis: rc, + ucache: make(map[uint]cachedUser), + l1TTL: 10 * time.Second, // L1 内存:10 秒 + l2TTL: 2 * time.Minute, // L2 Redis:2 分钟 + } +} + +// CreateJWT 签发 JWT +func (s *AuthService) CreateJWT(userID uint) (string, error) { + claims := jwt.MapClaims{ + "user_id": userID, + "exp": time.Now().Add(time.Duration(s.cfg.JWTExpireHours) * time.Hour).Unix(), + "iat": time.Now().Unix(), + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(s.cfg.JWTSecret)) +} + +// VerifyJWT 验证 JWT,返回 userID +func (s *AuthService) VerifyJWT(tokenStr string) (uint, error) { + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("签名算法不匹配: %v", t.Header["alg"]) + } + return []byte(s.cfg.JWTSecret), nil + }) + if err != nil { + return 0, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return 0, errors.New("无效 token") + } + + userIDFloat, ok := claims["user_id"].(float64) + if !ok { + return 0, errors.New("token 中缺少 user_id") + } + return uint(userIDFloat), nil +} + +// ── 注册 / 登录 ───────────────────────────────────────────────────────────── + +// Register 用户注册(用户名 + 密码) +// 第一个注册的用户自动成为管理员;或匹配 ADMIN_USERNAME 配置的用户名 +func (s *AuthService) Register(username, password string) (*model.User, error) { + if len(username) < 2 || len(username) > 50 { + return nil, errors.New("用户名长度应为 2-50 个字符") + } + if len(password) < 8 { + return nil, errors.New("密码长度不能少于 8 个字符") + } + // 密码复杂度:至少包含大写、小写、数字 + hasUpper, hasLower, hasDigit := false, false, false + for _, ch := range password { + switch { + case ch >= 'A' && ch <= 'Z': + hasUpper = true + case ch >= 'a' && ch <= 'z': + hasLower = true + case ch >= '0' && ch <= '9': + hasDigit = true + } + } + if !hasUpper || !hasLower || !hasDigit { + return nil, errors.New("密码必须包含大写字母、小写字母和数字") + } + + // 检查用户名是否已存在 + var existing model.User + if err := s.db.Where("username = ?", username).First(&existing).Error; err == nil { + return nil, errors.New("用户名已存在") + } + + // 密码加密 + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("密码加密失败: %w", err) + } + + // 判断是否应设为管理员 + isAdmin := false + role := 1 + + // 规则 1:ADMIN_USERNAME 配置匹配 + if s.cfg.AdminUsername != "" && username == s.cfg.AdminUsername { + isAdmin = true + role = 100 + } + + // 规则 2:数据库中没有任何用户,第一个注册的自动成为管理员 + if !isAdmin { + var count int64 + s.db.Model(&model.User{}).Count(&count) + if count == 0 { + isAdmin = true + role = 100 + } + } + + user := model.User{ + Username: username, + PasswordHash: string(hash), + Name: username, + Role: role, + IsActive: true, + IsAdmin: isAdmin, + } + if err := s.db.Create(&user).Error; err != nil { + return nil, fmt.Errorf("用户创建失败: %w", err) + } + + // 新用户赠送积分 + if s.settingSvc != nil { + bonus := s.settingSvc.GetInt("new_user_bonus", 0) + if bonus > 0 { + s.db.Model(&user).Update("credits", bonus) + user.Credits = bonus + tx := model.CreditTransaction{ + UserID: user.ID, + Amount: bonus, + Type: model.TxTypeNewUserBonus, + Description: fmt.Sprintf("新用户赠送 %d 积分", bonus), + } + s.db.Create(&tx) + } + } + + return &user, nil +} + +// Login 用户登录(用户名 + 密码) +func (s *AuthService) Login(username, password string) (*model.User, error) { + var user model.User + if err := s.db.Where("username = ? AND is_active = ?", username, true).First(&user).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.New("用户名或密码错误") + } + return nil, err + } + + if user.PasswordHash == "" { + return nil, errors.New("该账户未设置密码,请联系管理员") + } + + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { + return nil, errors.New("用户名或密码错误") + } + + return &user, nil +} + +// ── SSO 登录(可选,用于外部系统对接)──────────────────────────────────────── + +// SSOUserInfo 从外部 SSO JWT 解析出的用户信息 +type SSOUserInfo struct { + UID int + Username string + DisplayName string + Email string + Role int + Quota int + Avatar string +} + +// VerifySSOToken 验证 SSO token(可选功能,需配置 SSO_SECRET) +func (s *AuthService) VerifySSOToken(tokenStr string) (*SSOUserInfo, error) { + if s.cfg.SSOSecret == "" { + return nil, errors.New("SSO 未配置") + } + + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("SSO token 签名算法不匹配: %v", t.Header["alg"]) + } + return []byte(s.cfg.SSOSecret), nil + }) + if err != nil { + return nil, fmt.Errorf("SSO token 验证失败: %w", err) + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return nil, errors.New("无效 SSO token") + } + + uid, _ := claims["uid"].(float64) + username, _ := claims["username"].(string) + displayName, _ := claims["display_name"].(string) + email, _ := claims["email"].(string) + role, _ := claims["role"].(float64) + quota, _ := claims["quota"].(float64) + avatar, _ := claims["avatar"].(string) + + return &SSOUserInfo{ + UID: int(uid), + Username: username, + DisplayName: displayName, + Email: email, + Role: int(role), + Quota: int(quota), + Avatar: avatar, + }, nil +} + +// FindOrCreateUser SSO 登录时查找或创建用户,并同步外部字段 +func (s *AuthService) FindOrCreateUser(info *SSOUserInfo) (*model.User, error) { + var user model.User + err := s.db.Where("newapi_id = ?", info.UID).First(&user).Error + if err == nil { + updates := map[string]interface{}{ + "username": info.Username, + "newapi_quota": info.Quota, + } + if info.DisplayName != "" { + updates["name"] = info.DisplayName + } + if info.Email != "" { + updates["email"] = info.Email + } + if info.Avatar != "" { + updates["avatar_url"] = info.Avatar + } + s.db.Model(&user).Updates(updates) + s.InvalidateUserCache(user.ID) + return &user, nil + } + + if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + + name := info.DisplayName + if name == "" { + name = info.Username + } + user = model.User{ + NewapiID: info.UID, + Username: info.Username, + Name: name, + Email: info.Email, + AvatarURL: info.Avatar, + NewapiQuota: info.Quota, + Role: 1, + IsActive: true, + } + if err := s.db.Create(&user).Error; err != nil { + return nil, err + } + + // 新用户赠送积分 + if s.settingSvc != nil { + bonus := s.settingSvc.GetInt("new_user_bonus", 0) + if bonus > 0 { + s.db.Model(&user).Update("credits", bonus) + user.Credits = bonus + tx := model.CreditTransaction{ + UserID: user.ID, + Amount: bonus, + Type: model.TxTypeNewUserBonus, + Description: fmt.Sprintf("新用户赠送 %d 积分", bonus), + } + s.db.Create(&tx) + } + } + + return &user, nil +} + +// ── 缓存 ───────────────────────────────────────────────────────────────────── + +// userRedisKey 生成用户缓存的 Redis key +func userRedisKey(userID uint) string { + return "user:" + strconv.FormatUint(uint64(userID), 10) +} + +// GetActiveUser 获取活跃用户(L1 内存 → L2 Redis → DB) +func (s *AuthService) GetActiveUser(userID uint) (*model.User, error) { + // ── L1:内存缓存 + s.mu.RLock() + if c, ok := s.ucache[userID]; ok && time.Now().Before(c.expiresAt) { + s.mu.RUnlock() + return c.user, nil + } + s.mu.RUnlock() + + // ── L2:Redis 缓存 + if s.redis != nil { + var user model.User + if s.redis.GetJSON(context.Background(), userRedisKey(userID), &user) { + if user.IsActive { + s.mu.Lock() + s.ucache[userID] = cachedUser{user: &user, expiresAt: time.Now().Add(s.l1TTL)} + s.mu.Unlock() + return &user, nil + } + } + } + + // ── L3:数据库 + var user model.User + err := s.db.Where("id = ? AND is_active = ?", userID, true).First(&user).Error + if err != nil { + return nil, err + } + + // 回填 L1 + L2 + s.mu.Lock() + s.ucache[userID] = cachedUser{user: &user, expiresAt: time.Now().Add(s.l1TTL)} + s.mu.Unlock() + + if s.redis != nil { + s.redis.SetJSON(context.Background(), userRedisKey(userID), &user, s.l2TTL) + } + + return &user, nil +} + +// InvalidateUserCache 清除指定用户的 L1 + L2 缓存(余额变动时调用) +func (s *AuthService) InvalidateUserCache(userID uint) { + s.mu.Lock() + delete(s.ucache, userID) + s.mu.Unlock() + if s.redis != nil { + s.redis.Del(context.Background(), userRedisKey(userID)) + } +} + +// CreateDevUser 开发模式创建管理员用户 +func (s *AuthService) CreateDevUser() (*model.User, error) { + var user model.User + err := s.db.Where("username = ?", "dev_admin").First(&user).Error + if err == nil { + return &user, nil + } + + user = model.User{ + Username: "dev_admin", + Name: "开发管理员", + Role: 100, + IsActive: true, + IsAdmin: true, + Credits: 9999, + } + if err := s.db.Create(&user).Error; err != nil { + return nil, err + } + return &user, nil +} diff --git a/internal/service/card_pool.go b/internal/service/card_pool.go new file mode 100644 index 0000000..ba45efa --- /dev/null +++ b/internal/service/card_pool.go @@ -0,0 +1,193 @@ +package service + +import ( + "context" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "gorm.io/gorm" +) + +// CardPool 虚拟卡池(线程安全轮询,健康验证) +type CardPool struct { + mu sync.RWMutex + cards []model.CardPoolEntry + index uint64 + db *gorm.DB +} + +// NewCardPool 创建卡池 +func NewCardPool(db *gorm.DB) *CardPool { + p := &CardPool{db: db} + p.Reload() + return p +} + +// Reload 从 DB 加载有效卡到内存 +func (p *CardPool) Reload() { + if p.db == nil { + return + } + var entries []model.CardPoolEntry + if err := p.db.Where("is_valid = ? AND fail_count < ?", true, 5). + Order("use_count ASC, created_at ASC").Find(&entries).Error; err != nil { + log.Warn().Err(err).Msg("卡池加载失败") + return + } + + p.mu.Lock() + p.cards = entries + atomic.StoreUint64(&p.index, 0) + p.mu.Unlock() + + log.Info().Int("count", len(entries)).Msg("卡池已加载") +} + +// GetNext 轮询获取下一张有效卡 +func (p *CardPool) GetNext() *model.CardPoolEntry { + p.mu.RLock() + n := len(p.cards) + if n == 0 { + p.mu.RUnlock() + return nil + } + idx := atomic.AddUint64(&p.index, 1) + card := p.cards[idx%uint64(n)] + p.mu.RUnlock() + return &card +} + +// Count 返回可用卡数量 +func (p *CardPool) Count() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.cards) +} + +// MarkUsed 标记卡已使用 +func (p *CardPool) MarkUsed(id uint) { + if p.db == nil { + return + } + now := time.Now() + p.db.Model(&model.CardPoolEntry{}).Where("id = ?", id).Updates(map[string]interface{}{ + "use_count": gorm.Expr("use_count + 1"), + "last_used_at": now, + }) +} + +// MarkFailed 标记卡使用失败 +func (p *CardPool) MarkFailed(id uint) { + if p.db == nil { + return + } + p.db.Model(&model.CardPoolEntry{}).Where("id = ?", id).Updates(map[string]interface{}{ + "fail_count": gorm.Expr("fail_count + 1"), + }) + // 失败次数过多自动标记无效 + p.db.Model(&model.CardPoolEntry{}).Where("id = ? AND fail_count >= ?", id, 5). + Update("is_valid", false) + p.Reload() +} + +// PoolStats 返回卡池统计 +func (p *CardPool) PoolStats() map[string]interface{} { + if p.db == nil { + return map[string]interface{}{"total": 0, "valid": 0, "invalid": 0} + } + var total, validCount int64 + p.db.Model(&model.CardPoolEntry{}).Count(&total) + p.db.Model(&model.CardPoolEntry{}).Where("is_valid = ?", true).Count(&validCount) + return map[string]interface{}{ + "total": total, + "valid": validCount, + "invalid": total - validCount, + "active": p.Count(), + } +} + +// StartValidator 启动后台定期验证协程(每 30 分钟验证一次) +func (p *CardPool) StartValidator(ctx context.Context) { + go func() { + ticker := time.NewTicker(30 * time.Minute) + defer ticker.Stop() + log.Info().Msg("卡池定时验证已启动") + for { + select { + case <-ctx.Done(): + log.Info().Msg("卡池定时验证已停止") + return + case <-ticker.C: + p.validateExpired() + } + } + }() +} + +// validateExpired 标记已过期的卡为无效 +func (p *CardPool) validateExpired() { + if p.db == nil { + return + } + now := time.Now() + currentMonth := int(now.Month()) + currentYear := now.Year() + + result := p.db.Model(&model.CardPoolEntry{}). + Where("is_valid = ? AND (exp_year < ? OR (exp_year = ? AND exp_month < ?))", + true, currentYear, currentYear, currentMonth). + Update("is_valid", false) + if result.RowsAffected > 0 { + log.Info().Int64("expired", result.RowsAffected).Msg("已标记过期卡为无效") + p.Reload() + } +} + +// ValidateAll 验证所有卡(过期检查),返回 (总数, 有效, 无效) +func (p *CardPool) ValidateAll() (total, valid, invalid int) { + if p.db == nil { + return 0, 0, 0 + } + p.validateExpired() + + var entries []model.CardPoolEntry + if err := p.db.Find(&entries).Error; err != nil { + return 0, 0, 0 + } + total = len(entries) + for _, e := range entries { + if e.IsValid { + valid++ + } else { + invalid++ + } + } + p.Reload() + return total, valid, invalid +} + +// ValidateByIDs 验证指定 ID 的卡 +func (p *CardPool) ValidateByIDs(ids []uint) (total, valid, invalid int) { + if p.db == nil || len(ids) == 0 { + return 0, 0, 0 + } + p.validateExpired() + + var entries []model.CardPoolEntry + if err := p.db.Where("id IN ?", ids).Find(&entries).Error; err != nil { + return 0, 0, 0 + } + total = len(entries) + for _, e := range entries { + if e.IsValid { + valid++ + } else { + invalid++ + } + } + p.Reload() + return total, valid, invalid +} diff --git a/internal/service/credit_service.go b/internal/service/credit_service.go new file mode 100644 index 0000000..d339b08 --- /dev/null +++ b/internal/service/credit_service.go @@ -0,0 +1,324 @@ +package service + +import ( + "fmt" + "math" + "strconv" + "sync" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/newapi" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// CreditService 积分服务 +type CreditService struct { + db *gorm.DB + settingSvc *SettingService + refundLocks map[uint]*sync.Mutex // task_id -> lock + refundAmounts map[uint]int // task_id -> 已退还数量 + purchaseLocks map[uint]*sync.Mutex // user_id -> lock,防止并发购买竞态 + mu sync.Mutex +} + +// NewCreditService 创建积分服务 +func NewCreditService(db *gorm.DB, settingSvc *SettingService) *CreditService { + return &CreditService{ + db: db, + settingSvc: settingSvc, + refundLocks: make(map[uint]*sync.Mutex), + refundAmounts: make(map[uint]int), + purchaseLocks: make(map[uint]*sync.Mutex), + } +} + +// GetBalance 获取用户余额 +func (s *CreditService) GetBalance(userID uint) (credits int, freeRemaining int, freeUsed bool) { + var user model.User + if err := s.db.Select("credits, free_trial_remaining, free_trial_used"). + Where("id = ?", userID).First(&user).Error; err != nil { + return 0, 0, false + } + return user.Credits, user.FreeTrialRemaining, user.FreeTrialUsed +} + +// ReserveCredits 预扣积分(创建任务时,使用 SELECT FOR UPDATE 防竞态) +func (s *CreditService) ReserveCredits(db *gorm.DB, userID uint, taskID uint, amount int) error { + var user model.User + if err := db.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("id = ?", userID).First(&user).Error; err != nil { + return err + } + + // 优先使用免费试用 + freeUsed := 0 + paidUsed := 0 + if user.FreeTrialRemaining > 0 { + freeUsed = min(user.FreeTrialRemaining, amount) + paidUsed = amount - freeUsed + } else { + paidUsed = amount + } + + // 检查余额 + if paidUsed > user.Credits { + return fmt.Errorf("积分不足:需要 %d,当前 %d(含免费 %d)", amount, user.Credits+user.FreeTrialRemaining, user.FreeTrialRemaining) + } + + // 扣减 + if freeUsed > 0 { + db.Model(&user).Update("free_trial_remaining", gorm.Expr("free_trial_remaining - ?", freeUsed)) + s.recordTx(db, userID, &taskID, -freeUsed, model.TxTypeFreeTrialConsume, fmt.Sprintf("任务 #%d 预扣免费试用 %d 次", taskID, freeUsed)) + } + if paidUsed > 0 { + db.Model(&user).Update("credits", gorm.Expr("credits - ?", paidUsed)) + s.recordTx(db, userID, &taskID, -paidUsed, model.TxTypeConsume, fmt.Sprintf("任务 #%d 预扣积分 %d", taskID, paidUsed)) + } + + return nil +} + +// RefundCredits 退还积分(任务结束时退还未消费部分) +func (s *CreditService) RefundCredits(db *gorm.DB, userID uint, taskID uint, amount int) { + if amount <= 0 { + return + } + + // 获取任务级退款锁 + lock := s.getRefundLock(taskID) + lock.Lock() + defer lock.Unlock() + + already := s.refundAmounts[taskID] + if already >= amount { + return // 已全部退还 + } + toRefund := amount - already + + db.Model(&model.User{}).Where("id = ?", userID). + Update("credits", gorm.Expr("credits + ?", toRefund)) + s.recordTx(db, userID, &taskID, toRefund, model.TxTypeRefund, + fmt.Sprintf("任务 #%d 退还积分 %d", taskID, toRefund)) + + s.mu.Lock() + s.refundAmounts[taskID] = already + toRefund + s.mu.Unlock() + + log.Info().Uint("task_id", taskID).Int("refund", toRefund).Msg("积分已退还") +} + +// Recharge 管理员充值 +func (s *CreditService) Recharge(db *gorm.DB, userID uint, amount int, desc string) error { + if err := db.Model(&model.User{}).Where("id = ?", userID). + Update("credits", gorm.Expr("credits + ?", amount)).Error; err != nil { + return err + } + s.recordTx(db, userID, nil, amount, model.TxTypeRecharge, desc) + return nil +} + +// ClaimFreeTrial 领取免费试用 +func (s *CreditService) ClaimFreeTrial(db *gorm.DB, userID uint) (int, error) { + enabled := s.settingSvc.Get("free_trial_enabled", "true") + if enabled != "true" { + return 0, fmt.Errorf("免费试用已关闭") + } + + var user model.User + if err := db.Where("id = ?", userID).First(&user).Error; err != nil { + return 0, err + } + if user.FreeTrialUsed { + return 0, fmt.Errorf("已领取过免费试用") + } + + count := s.settingSvc.GetInt("free_trial_count", 2) + db.Model(&user).Updates(map[string]interface{}{ + "free_trial_used": true, + "free_trial_remaining": count, + }) + s.recordTx(db, userID, nil, count, model.TxTypeFreeTrial, fmt.Sprintf("领取免费试用 %d 次", count)) + return count, nil +} + +// RedeemCode 兑换码兑换(事务 + 行锁防双花) +func (s *CreditService) RedeemCode(db *gorm.DB, userID uint, code string) (int, error) { + var credits int + err := db.Transaction(func(tx *gorm.DB) error { + var rc model.RedemptionCode + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + Where("code = ? AND is_used = ?", code, false).First(&rc).Error; err != nil { + return fmt.Errorf("无效的兑换码") + } + + // 标记已使用 + now := gorm.Expr("NOW()") + tx.Model(&rc).Updates(map[string]interface{}{ + "is_used": true, + "redeemed_by": userID, + "redeemed_at": now, + }) + + // 增加积分 + tx.Model(&model.User{}).Where("id = ?", userID). + Update("credits", gorm.Expr("credits + ?", rc.Credits)) + s.recordTx(tx, userID, nil, rc.Credits, model.TxTypeRedeem, + fmt.Sprintf("兑换码 %s 充值 %d 积分", code, rc.Credits)) + + credits = rc.Credits + return nil + }) + if err != nil { + return 0, err + } + return credits, nil +} + +// recordTx 记录交易 +func (s *CreditService) recordTx(db *gorm.DB, userID uint, taskID *uint, amount int, txType, desc string) { + tx := model.CreditTransaction{ + UserID: userID, + Amount: amount, + Type: txType, + Description: desc, + TaskID: taskID, + } + if err := db.Create(&tx).Error; err != nil { + log.Error().Err(err).Uint("user_id", userID).Str("type", txType).Msg("记录交易失败") + } +} + +// CleanupTask 清理已完成任务的退款锁和退款记录(防内存泄漏) +func (s *CreditService) CleanupTask(taskID uint) { + s.mu.Lock() + delete(s.refundLocks, taskID) + delete(s.refundAmounts, taskID) + s.mu.Unlock() +} + +func (s *CreditService) getRefundLock(taskID uint) *sync.Mutex { + s.mu.Lock() + defer s.mu.Unlock() + if l, ok := s.refundLocks[taskID]; ok { + return l + } + l := &sync.Mutex{} + s.refundLocks[taskID] = l + return l +} + +// getPurchaseLock 获取用户级购买锁(防止同一用户并发购买) +func (s *CreditService) getPurchaseLock(userID uint) *sync.Mutex { + s.mu.Lock() + defer s.mu.Unlock() + if l, ok := s.purchaseLocks[userID]; ok { + return l + } + l := &sync.Mutex{} + s.purchaseLocks[userID] = l + return l +} + +// PurchaseCredits 用 New-API 余额购买注册次数 +// 流程:验证 → 查 New-API 余额 → 扣 New-API quota → 加内部 credits → 记录交易 +func (s *CreditService) PurchaseCredits(db *gorm.DB, userID uint, amount int, platform string) error { + // 获取用户信息 + var user model.User + if err := db.Where("id = ?", userID).First(&user).Error; err != nil { + return fmt.Errorf("用户不存在") + } + + if user.NewapiID == 0 { + return fmt.Errorf("未绑定 New-API 账号") + } + + // per-user 购买锁,防止并发购买竞态 + lock := s.getPurchaseLock(userID) + lock.Lock() + defer lock.Unlock() + + // 获取 New-API 客户端配置 + client, err := s.newAPIClient() + if err != nil { + return err + } + + // 计算需要扣减的 quota(500000 = $1 USD) + // 优先使用平台专属单价,降级全局单价 + costUSD := 0.0 + if platform != "" { + platformKey := "platform_" + platform + "_unit_price" + platformCostStr := s.settingSvc.Get(platformKey, "") + if platformCostStr != "" { + if parsed, err := strconv.ParseFloat(platformCostStr, 64); err == nil && parsed > 0 { + costUSD = parsed + } + } + } + if costUSD <= 0 { + costStr := s.settingSvc.Get("newapi_cost_per_reg", "0.004") + costUSD, _ = strconv.ParseFloat(costStr, 64) + if costUSD <= 0 { + return fmt.Errorf("单价配置错误") + } + } + quotaCost := int(math.Ceil(float64(amount) * costUSD * 500000)) + if err := client.DeductQuota(user.NewapiID, quotaCost); err != nil { + return err + } + + // 扣减成功,增加内部 credits + if err := db.Model(&model.User{}).Where("id = ?", userID). + Update("credits", gorm.Expr("credits + ?", amount)).Error; err != nil { + log.Error().Err(err).Uint("user_id", userID).Msg("增加积分失败(New-API 已扣款)") + return fmt.Errorf("增加积分失败,请联系管理员") + } + + // 同步更新本地 newapi_quota 缓存 + if newQuota, err := client.GetUserQuota(user.NewapiID); err == nil { + db.Model(&model.User{}).Where("id = ?", userID).Update("newapi_quota", newQuota) + } + + // 记录交易 + costDisplay := fmt.Sprintf("%.4f", float64(quotaCost)/500000.0) + s.recordTx(db, userID, nil, amount, model.TxTypePurchase, + fmt.Sprintf("购买 %d 次注册(消耗 $%s)", amount, costDisplay)) + + log.Info().Uint("user_id", userID).Int("amount", amount).Int("quota_cost", quotaCost).Msg("购买注册次数成功") + return nil +} + +// newAPIClient 创建 New-API 客户端(统一读取配置) +func (s *CreditService) newAPIClient() (*newapi.Client, error) { + baseURL := s.settingSvc.Get("newapi_base_url", "") + adminToken := s.settingSvc.Get("newapi_admin_token", "") + if baseURL == "" || adminToken == "" { + return nil, fmt.Errorf("New-API 未配置") + } + adminUserID := s.settingSvc.GetInt("newapi_admin_id", 0) + if adminUserID == 0 { + return nil, fmt.Errorf("New-API 管理员用户 ID 未配置") + } + return newapi.NewClient(baseURL, adminToken, adminUserID), nil +} + +// GetNewAPIBalance 实时查询 New-API 余额 +func (s *CreditService) GetNewAPIBalance(user *model.User) (quotaRaw int, balanceUSD float64, err error) { + if user.NewapiID == 0 { + return 0, 0, fmt.Errorf("未绑定 New-API 账号") + } + + client, err := s.newAPIClient() + if err != nil { + return 0, 0, err + } + + quota, err := client.GetUserQuota(user.NewapiID) + if err != nil { + return 0, 0, err + } + + return quota, float64(quota) / 500000.0, nil +} diff --git a/internal/service/dispatcher.go b/internal/service/dispatcher.go new file mode 100644 index 0000000..053e6a3 --- /dev/null +++ b/internal/service/dispatcher.go @@ -0,0 +1,234 @@ +package service + +import ( + "context" + "sync" + "sync/atomic" + + "github.com/xiaolajiaoyyds/regplatformm/internal/worker" +) + +// DispatchEntry 调度器中的任务条目(一个活跃任务 = 一个 Entry) +type DispatchEntry struct { + Key taskKey + RT *RunningTask + Worker worker.Worker + Ctx context.Context + Cancel context.CancelFunc + Config worker.Config // 合并后的完整配置(base + scanned) + NodeURLs map[string][]string // 多节点 URL 轮询表 + nodeIdx atomic.Int64 // 轮询索引 + inflight atomic.Int32 // 正在执行中的注册数 + maxInflight atomic.Int32 // 单任务最大并发数(动态更新,由 poolResizer 定期刷新) + Platform string // 所属平台(用于动态刷新 maxInflight) + done chan struct{} // 所有注册完成信号 + doneOnce sync.Once +} + +// markDone 安全地标记任务完成(仅触发一次) +func (de *DispatchEntry) markDone() { + de.doneOnce.Do(func() { close(de.done) }) +} + +// checkCompletion 在每次注册完成后检查任务是否全部结束。 +// +// N+1 超发策略:OpenAI 平台会多认领 1 个名额,所以这里优先判断 +// 成功数是否已达 Target,达标则立即 markDone,不等剩余 inflight。 +// 剩余那个 inflight 注册会在后台跑完并将结果写入数据库,对用户是免费 bonus。 +func (de *DispatchEntry) checkCompletion() { + rt := de.RT + // 成功数已达目标 → 立即完成,不等剩余 inflight(N+1 超发的关键路径) + if rt.SuccessCount.Load() >= int64(rt.Target) { + de.markDone() + return + } + // 用户停止 → 立即完成,不等 inflight(浏览器会随 ctx 取消自行退出) + if de.Ctx.Err() != nil { + de.markDone() + return + } + if de.inflight.Load() > 0 { + return // 仍有执行中的注册,继续等待 + } + // 安全阀:失败数达到目标数,所有名额都失败了 + if rt.FailCount.Load() >= int64(rt.Target) { + de.markDone() + return + } +} + +// RegJob 单次注册任务(从 Dispatcher 分发给 Pool Worker) +type RegJob struct { + Entry *DispatchEntry + Proxy *worker.ProxyEntry + Cfg worker.Config +} + +// Dispatcher 全局 Round-Robin 公平调度器 +// 所有活跃任务共享 Worker Pool,按轮询顺序分配注册名额 +type Dispatcher struct { + mu sync.Mutex + cond *sync.Cond + tasks []*DispatchEntry + rrIdx int + closed bool +} + +// NewDispatcher 创建调度器 +func NewDispatcher() *Dispatcher { + d := &Dispatcher{tasks: make([]*DispatchEntry, 0)} + d.cond = sync.NewCond(&d.mu) + return d +} + +// Add 注册新任务到调度器 +func (d *Dispatcher) Add(entry *DispatchEntry) { + d.mu.Lock() + d.tasks = append(d.tasks, entry) + d.mu.Unlock() + d.cond.Broadcast() +} + +// Remove 移除已完成的任务 +func (d *Dispatcher) Remove(key taskKey) { + d.mu.Lock() + for i, e := range d.tasks { + if e.Key == key { + d.tasks = append(d.tasks[:i], d.tasks[i+1:]...) + if d.rrIdx >= len(d.tasks) && d.rrIdx > 0 { + d.rrIdx = 0 + } + break + } + } + d.mu.Unlock() + d.cond.Broadcast() +} + +// Next 获取下一个待执行的注册任务(阻塞直到有工作或调度器关闭) +// 使用 Round-Robin 保证用户间公平:A→B→C→A→B→C... +func (d *Dispatcher) Next(proxyPool *ProxyPool) (*RegJob, bool) { + d.mu.Lock() + defer d.mu.Unlock() + + for { + if d.closed { + return nil, false + } + + n := len(d.tasks) + if n == 0 { + d.cond.Wait() + continue + } + + // Round-Robin 遍历所有任务,找到一个可分配名额的 + for i := 0; i < n; i++ { + idx := (d.rrIdx + i) % n + entry := d.tasks[idx] + + // 跳过已取消/已完成的任务 + if entry.Ctx.Err() != nil { + continue + } + + // 失败安全阀:失败数已达目标,不再分配新名额(防止无限重试循环) + if entry.RT.FailCount.Load() >= int64(entry.RT.Target) { + continue + } + + // 并发上限:inflight 达到 maxInflight 时跳过,防止打爆 Space 池 + limit := entry.maxInflight.Load() + if limit > 0 && entry.inflight.Load() >= limit { + continue + } + + // 原子认领一个注册名额 + newClaimed := entry.RT.ClaimedCount.Add(1) + maxClaim := int64(entry.RT.Target) + // N+1 超发策略:OpenAI 平台多分配 1 个名额,让第 N+1 个注册提前启动。 + // 当前 N 个成功后 checkCompletion 会立即 markDone,第 N+1 个在后台 + // 继续跑完并写库,结果对用户是免费 bonus,积分不额外扣除。 + if entry.Worker.PlatformName() == "openai" { + maxClaim += 1 + } + if newClaimed > maxClaim { + entry.RT.ClaimedCount.Add(-1) + continue // 已全部认领 + } + + // 成功认领,推进 Round-Robin 指针(下次从下一个任务开始) + d.rrIdx = (idx + 1) % n + entry.inflight.Add(1) + + // 构建配置副本(含 node URL 轮询分配) + cfg := make(worker.Config, len(entry.Config)) + for k, v := range entry.Config { + cfg[k] = v + } + nodeN := int(entry.nodeIdx.Add(1) - 1) + for key, urls := range entry.NodeURLs { + cfg[key] = urls[nodeN%len(urls)] + } + + return &RegJob{ + Entry: entry, + Proxy: proxyPool.GetNextForPlatform(entry.Key.Platform), + Cfg: cfg, + }, true + } + + // 所有任务都无法认领(已满或已取消),等待变化 + d.cond.Wait() + } +} + +// Wake 唤醒等待中的 Worker(名额释放、任务变化时调用) +func (d *Dispatcher) Wake() { + d.cond.Broadcast() +} + +// ForceComplete 强制标记指定任务完成(用户停止时调用,不等 inflight) +func (d *Dispatcher) ForceComplete(key taskKey) { + d.mu.Lock() + for _, e := range d.tasks { + if e.Key == key { + e.markDone() + break + } + } + d.mu.Unlock() + d.cond.Broadcast() +} + +// Close 关闭调度器(服务关停时调用) +func (d *Dispatcher) Close() { + d.mu.Lock() + d.closed = true + d.mu.Unlock() + d.cond.Broadcast() +} + +// ActiveTaskCount 返回调度器中的活跃任务数 +func (d *Dispatcher) ActiveTaskCount() int { + d.mu.Lock() + defer d.mu.Unlock() + return len(d.tasks) +} + +// RefreshLimits 动态刷新所有活跃任务的 maxInflight +// 由 poolResizer 每 10 秒调用,确保 SyncPoolSize 更新的参数能热生效到正在运行的任务 +func (d *Dispatcher) RefreshLimits(getLimitFn func(platform string) int32) { + d.mu.Lock() + defer d.mu.Unlock() + for _, entry := range d.tasks { + if entry.Ctx.Err() != nil { + continue + } + newLimit := getLimitFn(entry.Platform) + if newLimit > 0 { + entry.maxInflight.Store(newLimit) + } + } + d.cond.Broadcast() // 唤醒可能因 maxInflight 被阻塞的 Worker +} diff --git a/internal/service/hf_space_service.go b/internal/service/hf_space_service.go new file mode 100644 index 0000000..f3f30fb --- /dev/null +++ b/internal/service/hf_space_service.go @@ -0,0 +1,2208 @@ +package service + +import ( + "bytes" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "io" + mathrand "math/rand" + "mime/multipart" + "net/http" + "strconv" + "strings" + "sync" + "time" + + "github.com/rs/zerolog/log" + regplatform "github.com/xiaolajiaoyyds/regplatformm" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "golang.org/x/sync/errgroup" + "gorm.io/gorm" +) + +// ── 常量:服务映射 + 随机词库 ── + +// ServiceConfig HF Space 部署服务配置 +type ServiceConfig struct { + Dir string // 模板目录(相对项目根) + Script string // 入口脚本文件名 + URLSecret string // Release URL 写入的 Secret key +} + +// ServiceMap 支持的服务类型 +var ServiceMap = map[string]ServiceConfig{ + "openai": {Dir: "HFNP", Script: "init.sh", URLSecret: "ARTIFACT_URL"}, + "grok": {Dir: "HFGS", Script: "bootstrap.sh", URLSecret: "PKG_URL"}, + "kiro": {Dir: "HFKR", Script: "start.sh", URLSecret: "MODEL_URL"}, + "gemini": {Dir: "HFGM", Script: "launch.sh", URLSecret: "GEMINI_URL"}, + "ts": {Dir: "HFTS", Script: "run.sh", URLSecret: "DATA_URL"}, +} + +// CFEnvMap CF Worker 环境变量名映射 +var CFEnvMap = map[string]string{ + "openai": "OPENAI_SPACES", + "grok": "GROK_SPACES", + "kiro": "KIRO_SPACES", + "gemini": "GEMINI_SPACES", + "ts": "TS_SPACES", +} + +// 随机化词库(ML/AI 风格,让 Space 名看起来像正经模型项目) +var adjectives = []string{ + // 模型规模/精度 + "tiny", "small", "base", "large", "mini", "nano", "micro", "lite", + "slim", "fast", "quick", "swift", "lean", "dense", "deep", "wide", + // ML 术语 + "linear", "focal", "modal", "latent", "sparse", "robust", "stable", "smooth", + "neural", "causal", "masked", "frozen", "fused", "mixed", "multi", "cross", + // 品质/性能 + "sharp", "clean", "clear", "smart", "bright", "vivid", "lucid", "crisp", + "prime", "ultra", "hyper", "super", "turbo", "rapid", "agile", "steady", + // 科学/数学 + "sigma", "theta", "omega", "delta", "alpha", "gamma", "beta", "zeta", + "polar", "axial", "radial", "cubic", "quant", "logic", "optic", "sonic", + // 自然/色调(保留部分通用词) + "azure", "coral", "amber", "jade", "ivory", "slate", "olive", "cedar", + "solar", "lunar", "frost", "storm", "misty", "boreal", "arctic", "ember", +} + +var nouns = []string{ + // ML 核心概念 + "bert", "gpt", "llm", "vit", "clip", "lora", "rlhf", "sft", + "embed", "token", "layer", "head", "block", "cell", "node", "unit", + // 模型组件 + "encoder", "decoder", "adapter", "probe", "tuner", "mixer", "fuser", "pool", + "gate", "lens", "core", "hub", "net", "lab", "bench", "forge", + // 数据/训练 + "batch", "epoch", "loss", "grad", "norm", "step", "seed", "split", + "cache", "index", "shard", "queue", "pipe", "flow", "stream", "relay", + // 任务类型 + "chat", "gen", "cls", "ner", "qa", "sum", "seg", "det", + "rank", "search", "match", "align", "parse", "tag", "scan", "eval", + // 基础设施 + "serve", "infer", "train", "deploy", "scale", "sync", "route", "mesh", + "edge", "grid", "rack", "dock", "bay", "pod", "slot", "depot", + // 自然/通用(保留部分,让名字不全是术语) + "bloom", "wave", "spark", "ridge", "peak", "vale", "drift", "cloud", + "atlas", "nexus", "helix", "orbit", "prism", "pulse", "surge", "trace", +} + +// ── SpaceHealthResult 健康检查结果 ── + +// SpaceHealthResult 单个 Space 的健康检查结果 +type SpaceHealthResult struct { + URL string `json:"url"` + Healthy bool `json:"healthy"` + Status string `json:"status"` // healthy/banned/sleeping/dead/unknown + StatusCode int `json:"status_code"` + Reason string `json:"reason"` +} + +// OverviewItem 服务概览统计 +type OverviewItem struct { + Service string `json:"service"` + Total int64 `json:"total"` + Healthy int64 `json:"healthy"` + Building int64 `json:"building"` + Banned int64 `json:"banned"` + Sleeping int64 `json:"sleeping"` + Dead int64 `json:"dead"` + Unknown int64 `json:"unknown"` +} + +// AutoscaleLog 弹性管理操作日志 +type AutoscaleLog struct { + Step string `json:"step"` + Message string `json:"message"` +} + +// AutoscaleResult 弹性管理结果 +type AutoscaleResult struct { + Service string `json:"service"` + DryRun bool `json:"dry_run"` + Before int `json:"before"` + After int `json:"after"` + Created int `json:"created"` + Deleted int `json:"deleted"` + HealthyNow int `json:"healthy_now"` + Logs []AutoscaleLog `json:"logs"` +} + +// ── HFSpaceService ── + +// HFSpaceService HF Space 管理服务 +type HFSpaceService struct { + db *gorm.DB + settingSvc *SettingService + httpClient *http.Client +} + +// NewHFSpaceService 创建 HF Space 管理服务 +func NewHFSpaceService(db *gorm.DB, settingSvc *SettingService) *HFSpaceService { + return &HFSpaceService{ + db: db, + settingSvc: settingSvc, + httpClient: &http.Client{Timeout: 60 * time.Second}, // HF Commit API 上传文件耗时较长 + } +} + +// ── Token 管理 ── + +// CreateToken 创建 HF Token,调用 whoami 验证并填充 username +func (s *HFSpaceService) CreateToken(label, token string) (*model.HFToken, error) { + // 调 HF whoami 验证 Token + username, err := s.hfWhoami(token) + if err != nil { + return nil, fmt.Errorf("Token 验证失败: %w", err) + } + + t := &model.HFToken{ + Label: label, + Token: token, + Username: username, + IsValid: true, + } + if err := s.db.Create(t).Error; err != nil { + return nil, fmt.Errorf("创建 Token 失败: %w", err) + } + return t, nil +} + +// ListTokens 列出所有 Token(token 字段脱敏) +func (s *HFSpaceService) ListTokens() ([]model.HFToken, error) { + var tokens []model.HFToken + if err := s.db.Order("id ASC").Find(&tokens).Error; err != nil { + return nil, err + } + // 统计每个 Token 关联的 Space 数量 + for i := range tokens { + var count int64 + s.db.Model(&model.HFSpace{}).Where("token_id = ?", tokens[i].ID).Count(&count) + tokens[i].SpaceUsed = int(count) + // 脱敏 + if len(tokens[i].Token) > 8 { + tokens[i].Token = tokens[i].Token[:4] + "****" + tokens[i].Token[len(tokens[i].Token)-4:] + } + } + return tokens, nil +} + +// DeleteToken 删除 Token,关联 Space 的 token_id 置 0 +func (s *HFSpaceService) DeleteToken(id uint) error { + var token model.HFToken + if err := s.db.First(&token, id).Error; err != nil { + return fmt.Errorf("Token 不存在") + } + // 关联 Space 标记为 orphan + s.db.Model(&model.HFSpace{}).Where("token_id = ?", id).Update("token_id", 0) + return s.db.Delete(&token).Error +} + +// ValidateToken 重新验证 Token +func (s *HFSpaceService) ValidateToken(id uint) (*model.HFToken, error) { + var token model.HFToken + if err := s.db.First(&token, id).Error; err != nil { + return nil, fmt.Errorf("Token 不存在") + } + username, err := s.hfWhoami(token.Token) + if err != nil { + s.db.Model(&token).Updates(map[string]interface{}{"is_valid": false}) + token.IsValid = false + return &token, nil + } + s.db.Model(&token).Updates(map[string]interface{}{"is_valid": true, "username": username}) + token.IsValid = true + token.Username = username + return &token, nil +} + +// ValidateAllTokens 批量验证所有 Token +func (s *HFSpaceService) ValidateAllTokens() ([]model.HFToken, error) { + var tokens []model.HFToken + if err := s.db.Find(&tokens).Error; err != nil { + return nil, err + } + for i := range tokens { + username, err := s.hfWhoami(tokens[i].Token) + if err != nil { + s.db.Model(&tokens[i]).Update("is_valid", false) + tokens[i].IsValid = false + } else { + s.db.Model(&tokens[i]).Updates(map[string]interface{}{"is_valid": true, "username": username}) + tokens[i].IsValid = true + tokens[i].Username = username + } + } + return tokens, nil +} + +// PurgeBannedSpaces 清理不可用 Space 的完整流程: +// 1. 健康检查(含 Runtime API 二次确认) +// 2. banned/sleeping 直接删除(被封或休眠的启动了也是 503) +// 3. dead 先尝试 restart → 等待 → 再检查 → 还是挂才删 +func (s *HFSpaceService) PurgeBannedSpaces(service string) (deleted int, checked int, err error) { + // 第一轮:健康检查 + results, err := s.CheckHealth(service) + if err != nil { + return 0, 0, err + } + checked = len(results) + + // banned/sleeping 直接删,不浪费时间 restart + var directDelSpaces []model.HFSpace + directDelQuery := s.db.Where("status IN ?", []string{"banned", "sleeping"}) + if service != "" { + directDelQuery = directDelQuery.Where("service = ?", service) + } + directDelQuery.Find(&directDelSpaces) + for _, sp := range directDelSpaces { + if delErr := s.DeleteSpace(sp.ID); delErr != nil { + log.Warn().Uint("space_id", sp.ID).Str("repo", sp.RepoID).Str("status", sp.Status).Err(delErr).Msg("清理不可用 Space 失败") + continue + } + log.Info().Uint("space_id", sp.ID).Str("repo", sp.RepoID).Str("status", sp.Status).Msg("已清理不可用 Space") + deleted++ + } + + // dead 的先尝试 restart + var deadSpaces []model.HFSpace + deadQuery := s.db.Where("status = ?", "dead") + if service != "" { + deadQuery = deadQuery.Where("service = ?", service) + } + deadQuery.Find(&deadSpaces) + + if len(deadSpaces) == 0 { + return deleted, checked, nil + } + + // 预加载 token + var allTokens []model.HFToken + s.db.Where("is_valid = ?", true).Find(&allTokens) + tokenByID := make(map[uint]string) + tokenByUser := make(map[string]string) + for _, t := range allTokens { + tokenByID[t.ID] = t.Token + tokenByUser[t.Username] = t.Token + } + + // 尝试 restart 所有 dead Space + restartedIDs := make([]uint, 0, len(deadSpaces)) + for _, sp := range deadSpaces { + token := tokenByID[sp.TokenID] + if token == "" { + owner := strings.SplitN(sp.RepoID, "/", 2)[0] + token = tokenByUser[owner] + } + if token == "" { + log.Warn().Str("repo", sp.RepoID).Msg("无可用 Token,跳过 restart 直接删除") + if delErr := s.DeleteSpace(sp.ID); delErr == nil { + deleted++ + } + continue + } + if err := s.hfRestartSpace(token, sp.RepoID, false); err != nil { + log.Warn().Str("repo", sp.RepoID).Err(err).Msg("restart 失败,直接删除") + if delErr := s.DeleteSpace(sp.ID); delErr == nil { + deleted++ + } + continue + } + log.Info().Str("repo", sp.RepoID).Msg("已发送 restart,等待确认") + restartedIDs = append(restartedIDs, sp.ID) + } + + if len(restartedIDs) == 0 { + return deleted, checked, nil + } + + // 等待 30 秒让 Space 有时间启动 + waitSec := s.settingSvc.GetInt("hf_restart_wait_sec", 30) + log.Info().Int("wait_sec", waitSec).Int("restarted", len(restartedIDs)).Msg("等待 restart 生效...") + time.Sleep(time.Duration(waitSec) * time.Second) + + // 第二轮:重新检查 restarted 的 Space + for _, spID := range restartedIDs { + var sp model.HFSpace + if s.db.First(&sp, spID).Error != nil { + continue + } + token := tokenByID[sp.TokenID] + if token == "" { + owner := strings.SplitN(sp.RepoID, "/", 2)[0] + token = tokenByUser[owner] + } + + stillDead := true + if token != "" { + if stage, err := s.hfGetSpaceRuntime(token, sp.RepoID); err == nil { + upper := strings.ToUpper(stage) + // RUNNING/BUILDING 说明 restart 成功了 + if upper == "RUNNING" || upper == "RUNNING_BUILDING" || upper == "BUILDING" { + stillDead = false + newStatus := "healthy" + if upper == "BUILDING" { + newStatus = "building" + } + now := time.Now() + s.db.Model(&sp).Updates(map[string]interface{}{ + "status": newStatus, + "last_check_at": &now, + }) + log.Info().Str("repo", sp.RepoID).Str("stage", stage).Msg("restart 成功,Space 已恢复") + } + } + } + + if stillDead { + log.Info().Str("repo", sp.RepoID).Msg("restart 后仍不可用,删除") + if delErr := s.DeleteSpace(sp.ID); delErr == nil { + deleted++ + } + } + } + + return deleted, checked, nil +} + +// ── Space 管理 ── + +// SpaceListResult 分页结果 +type SpaceListResult struct { + Items []model.HFSpace `json:"items"` + Total int64 `json:"total"` +} + +// ListSpaces 按服务列出 Space(支持分页,page/pageSize 为 0 则不分页) +func (s *HFSpaceService) ListSpaces(service string, page, pageSize int, status string) (*SpaceListResult, error) { + query := s.db.Model(&model.HFSpace{}) + if service != "" { + query = query.Where("service = ?", service) + } + if status != "" { + query = query.Where("status = ?", status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, err + } + + dataQuery := s.db.Order("id ASC") + if service != "" { + dataQuery = dataQuery.Where("service = ?", service) + } + if status != "" { + dataQuery = dataQuery.Where("status = ?", status) + } + if page > 0 && pageSize > 0 { + dataQuery = dataQuery.Offset((page - 1) * pageSize).Limit(pageSize) + } + + var spaces []model.HFSpace + if err := dataQuery.Find(&spaces).Error; err != nil { + return nil, err + } + return &SpaceListResult{Items: spaces, Total: total}, nil +} + +// AddSpaceManual 手动添加已有 Space +func (s *HFSpaceService) AddSpaceManual(service, url, repoID string, tokenID uint) (*model.HFSpace, error) { + space := &model.HFSpace{ + Service: service, + URL: url, + RepoID: repoID, + TokenID: tokenID, + Status: "unknown", + } + if err := s.db.Create(space).Error; err != nil { + return nil, fmt.Errorf("添加 Space 失败: %w", err) + } + return space, nil +} + +// DeleteSpace 删除 Space(可选调 HF API 删除远程 repo) +// 增强:关联 token 失效时,按 repo owner 查找其他可用 token 重试 +func (s *HFSpaceService) DeleteSpace(id uint) error { + var space model.HFSpace + if err := s.db.First(&space, id).Error; err != nil { + return fmt.Errorf("Space 不存在") + } + + // 尝试通过 HF API 删除远程 repo + if space.RepoID != "" { + deleted := false + + // 优先用关联 token + if space.TokenID > 0 { + var token model.HFToken + if s.db.First(&token, space.TokenID).Error == nil && token.IsValid { + if err := s.hfDeleteRepo(token.Token, space.RepoID); err == nil { + deleted = true + } else { + log.Warn().Str("repo", space.RepoID).Err(err).Msg("关联 Token 删除远程 repo 失败,尝试按 owner 查找") + } + } + } + + // 关联 token 失败,按 repo owner 找其他可用 token + if !deleted { + owner := strings.SplitN(space.RepoID, "/", 2)[0] + var fallbackToken model.HFToken + if s.db.Where("username = ? AND is_valid = ?", owner, true).First(&fallbackToken).Error == nil { + if err := s.hfDeleteRepo(fallbackToken.Token, space.RepoID); err != nil { + log.Warn().Str("repo", space.RepoID).Err(err).Msg("备选 Token 删除远程 repo 也失败,仅删除 DB 记录") + } + } + } + } + + return s.db.Delete(&space).Error +} + +// removeSpaceDBOnly 仅从 DB 删除 Space 记录,不调 HF API 删除远程 repo +// 用于弹性管理清理死节点——保留 HF 上的 repo,下次 discover 可重新导入 +func (s *HFSpaceService) removeSpaceDBOnly(id uint) error { + return s.db.Delete(&model.HFSpace{}, id).Error +} + +// CheckHealth 并发健康检查指定服务的所有 Space +func (s *HFSpaceService) CheckHealth(service string) ([]SpaceHealthResult, error) { + var spaces []model.HFSpace + query := s.db + if service != "" { + query = query.Where("service = ?", service) + } + if err := query.Find(&spaces).Error; err != nil { + return nil, err + } + + timeout := s.settingSvc.GetInt("hf_health_timeout", 8) + client := &http.Client{Timeout: time.Duration(timeout) * time.Second} + + results := make([]SpaceHealthResult, len(spaces)) + + g := new(errgroup.Group) + g.SetLimit(20) // 并发上限 20 + + // 预加载 token map(token_id → token 明文 + username → token 明文) + var allTokens []model.HFToken + s.db.Where("is_valid = ?", true).Find(&allTokens) + tokenByID := make(map[uint]string) + tokenByUser := make(map[string]string) + for _, t := range allTokens { + tokenByID[t.ID] = t.Token + tokenByUser[t.Username] = t.Token + } + + for i, sp := range spaces { + i, sp := i, sp + g.Go(func() error { + result := s.checkOneHealth(client, sp.URL) + + // 对非 healthy 的 Space,调 HF Runtime API 二次确认真实 stage + if !result.Healthy && sp.RepoID != "" { + token := tokenByID[sp.TokenID] + if token == "" { + // token_id 对应的 token 失效,按 username 找 + owner := strings.SplitN(sp.RepoID, "/", 2)[0] + token = tokenByUser[owner] + } + if token != "" { + if stage, err := s.hfGetSpaceRuntime(token, sp.RepoID); err == nil && stage != "" { + // 根据 HF 真实 stage 精确映射状态 + switch strings.ToUpper(stage) { + case "RUNNING", "RUNNING_BUILDING": + // Runtime API 说在跑,可能是 /health 端点还没就绪 + result.Status = "healthy" + result.Healthy = true + result.Reason = "runtime_running" + case "BUILDING": + result.Status = "building" + result.Reason = "building" + case "PAUSED": + result.Status = "sleeping" + result.Reason = "paused" + case "STOPPED": + result.Status = "dead" + result.Reason = "stopped" + case "RUNTIME_ERROR": + result.Status = "dead" + result.Reason = "runtime_error" + case "BUILD_ERROR": + result.Status = "dead" + result.Reason = "build_error" + case "CONFIG_ERROR": + result.Status = "dead" + result.Reason = "config_error" + case "NO_APP_FILE": + result.Status = "dead" + result.Reason = "no_app_file" + case "DELETING": + result.Status = "dead" + result.Reason = "deleting" + } + log.Debug().Str("repo", sp.RepoID).Str("stage", stage).Str("status", result.Status).Msg("Runtime API 二次确认") + } + } + } + + results[i] = result + + // 更新 DB 状态 + now := time.Now() + s.db.Model(&sp).Updates(map[string]interface{}{ + "status": result.Status, + "status_code": result.StatusCode, + "last_check_at": &now, + }) + return nil + }) + } + _ = g.Wait() + + return results, nil +} + +// Overview 各服务汇总统计(单次 GROUP BY 替代多次 COUNT) +func (s *HFSpaceService) Overview() ([]OverviewItem, error) { + // 一次查出所有服务+状态的计数 + type row struct { + Service string + Status string + Cnt int64 + } + var rows []row + if err := s.db.Model(&model.HFSpace{}). + Select("service, status, COUNT(*) as cnt"). + Group("service, status"). + Find(&rows).Error; err != nil { + return nil, err + } + + // 初始化各服务的统计 + serviceOrder := []string{"openai", "grok", "kiro", "gemini", "ts"} + itemMap := make(map[string]*OverviewItem, len(serviceOrder)) + for _, svc := range serviceOrder { + itemMap[svc] = &OverviewItem{Service: svc} + } + + // 填充计数 + for _, r := range rows { + item, ok := itemMap[r.Service] + if !ok { + continue // 跳过未知服务 + } + item.Total += r.Cnt + switch r.Status { + case "healthy": + item.Healthy = r.Cnt + case "building": + item.Building = r.Cnt + case "banned": + item.Banned = r.Cnt + case "sleeping": + item.Sleeping = r.Cnt + case "dead": + item.Dead = r.Cnt + case "unknown": + item.Unknown = r.Cnt + } + } + + items := make([]OverviewItem, 0, len(serviceOrder)) + for _, svc := range serviceOrder { + items = append(items, *itemMap[svc]) + } + return items, nil +} + +// ── 弹性管理 ── + +// Autoscale 弹性管理:健康检查 → 清理死节点(先 restart 再删) → 自动扩容 → 同步 CF → 同步 Pool +func (s *HFSpaceService) Autoscale(service string, target int, dryRun bool) (*AutoscaleResult, error) { + if _, ok := ServiceMap[service]; !ok { + return nil, fmt.Errorf("未知服务类型: %s", service) + } + if target <= 0 { + target = s.settingSvc.GetInt("hf_default_target", 5) + } + + result := &AutoscaleResult{ + Service: service, + DryRun: dryRun, + Logs: make([]AutoscaleLog, 0), + } + + var spaces []model.HFSpace + s.db.Where("service = ?", service).Find(&spaces) + result.Before = len(spaces) + + addLog := func(step, msg string) { + result.Logs = append(result.Logs, AutoscaleLog{Step: step, Message: msg}) + log.Info().Str("service", service).Str("step", step).Msg(msg) + } + + // 1. 健康检查(含 Runtime API 二次确认) + addLog("health_check", fmt.Sprintf("检查 %d 个 Space...", len(spaces))) + s.CheckHealth(service) + + // 重新加载(CheckHealth 已更新 DB status) + s.db.Where("service = ?", service).Find(&spaces) + var healthyCnt, bannedCnt, sleepingCnt, deadCnt, buildingCnt int + for _, sp := range spaces { + switch sp.Status { + case "healthy": + healthyCnt++ + case "banned": + bannedCnt++ + case "sleeping": + sleepingCnt++ + case "building": + buildingCnt++ + default: + deadCnt++ + } + } + addLog("health_result", fmt.Sprintf("healthy=%d banned=%d sleeping=%d building=%d dead=%d", + healthyCnt, bannedCnt, sleepingCnt, buildingCnt, deadCnt)) + + // 2. 清理不可用节点 + // 预加载 token + var allTokens []model.HFToken + s.db.Where("is_valid = ?", true).Find(&allTokens) + tokenByID := make(map[uint]string) + tokenByUser := make(map[string]string) + for _, t := range allTokens { + tokenByID[t.ID] = t.Token + tokenByUser[t.Username] = t.Token + } + + // 2a. banned/sleeping 直接删(被封或休眠的启动了也是 503) + var directDelSpaces []model.HFSpace + s.db.Where("service = ? AND status IN ?", service, []string{"banned", "sleeping"}).Find(&directDelSpaces) + if len(directDelSpaces) > 0 { + addLog("cleanup", fmt.Sprintf("清理 %d 个 banned/sleeping 节点", len(directDelSpaces))) + if !dryRun { + for _, sp := range directDelSpaces { + s.DeleteSpace(sp.ID) + result.Deleted++ + } + } + } + + // 2b. dead 先尝试 restart → 等待 → 再检查 → 还是挂才删 + var deadSpaces []model.HFSpace + s.db.Where("service = ? AND status = ?", service, "dead").Find(&deadSpaces) + if len(deadSpaces) > 0 { + addLog("cleanup", fmt.Sprintf("尝试 restart %d 个 dead 节点...", len(deadSpaces))) + if !dryRun { + restartedIDs := make([]uint, 0, len(deadSpaces)) + for _, sp := range deadSpaces { + token := tokenByID[sp.TokenID] + if token == "" { + owner := strings.SplitN(sp.RepoID, "/", 2)[0] + token = tokenByUser[owner] + } + if token == "" { + s.DeleteSpace(sp.ID) + result.Deleted++ + continue + } + if err := s.hfRestartSpace(token, sp.RepoID, false); err != nil { + addLog("cleanup", fmt.Sprintf("%s restart 失败: %v,删除", sp.RepoID, err)) + s.DeleteSpace(sp.ID) + result.Deleted++ + continue + } + restartedIDs = append(restartedIDs, sp.ID) + } + + if len(restartedIDs) > 0 { + waitSec := s.settingSvc.GetInt("hf_restart_wait_sec", 30) + addLog("cleanup", fmt.Sprintf("等待 %ds 确认 %d 个 restart 结果...", waitSec, len(restartedIDs))) + time.Sleep(time.Duration(waitSec) * time.Second) + + for _, spID := range restartedIDs { + var sp model.HFSpace + if s.db.First(&sp, spID).Error != nil { + continue + } + token := tokenByID[sp.TokenID] + if token == "" { + owner := strings.SplitN(sp.RepoID, "/", 2)[0] + token = tokenByUser[owner] + } + stillDead := true + if token != "" { + if stage, err := s.hfGetSpaceRuntime(token, sp.RepoID); err == nil { + upper := strings.ToUpper(stage) + if upper == "RUNNING" || upper == "RUNNING_BUILDING" || upper == "BUILDING" { + stillDead = false + newStatus := "healthy" + if upper == "BUILDING" { + newStatus = "building" + } + now := time.Now() + s.db.Model(&sp).Updates(map[string]interface{}{ + "status": newStatus, + "last_check_at": &now, + }) + addLog("cleanup", fmt.Sprintf("%s restart 成功 (stage=%s)", sp.RepoID, stage)) + if newStatus == "healthy" { + healthyCnt++ + } + } + } + } + if stillDead { + addLog("cleanup", fmt.Sprintf("%s restart 后仍不可用,删除", sp.RepoID)) + s.DeleteSpace(sp.ID) + result.Deleted++ + } + } + } + } else { + result.Deleted = len(deadSpaces) + } + } + + // 3. 自动扩容:healthy < target 时自动部署补充 + if healthyCnt < target { + needCreate := target - healthyCnt + releaseURL := s.settingSvc.Get(fmt.Sprintf("hf_release_url_%s", service), "") + if releaseURL == "" { + addLog("scale_up", fmt.Sprintf("需补充 %d 个,但未配置 hf_release_url_%s,请先在系统设置填写 Release URL", needCreate, service)) + } else { + addLog("scale_up", fmt.Sprintf("自动部署 %d 个新 Space", needCreate)) + if !dryRun { + deployed, deployErrors, err := s.DeploySpaces(service, needCreate, releaseURL, 0, nil) + if err != nil { + addLog("scale_up", fmt.Sprintf("部署失败: %v", err)) + } else { + result.Created = len(deployed) + for _, e := range deployErrors { + addLog("scale_up", e) + } + addLog("scale_up", fmt.Sprintf("成功 %d 个,失败 %d 个", len(deployed), len(deployErrors))) + } + } else { + result.Created = needCreate + } + } + } else { + addLog("scale_up", fmt.Sprintf("healthy=%d >= target=%d,无需扩容", healthyCnt, target)) + } + + // 4. 同步 CF Worker + if !dryRun { + if _, err := s.SyncCFWorker(service); err != nil { + addLog("sync_cf", fmt.Sprintf("CF 同步失败: %v", err)) + } else { + addLog("sync_cf", "CF Worker 环境变量已更新") + } + } + + // 5. 同步 Pool 大小 + if !dryRun { + s.SyncPoolSize() + addLog("sync_pool", "调度池大小已同步") + } + + // 最终统计 + var finalCount int64 + s.db.Model(&model.HFSpace{}).Where("service = ?", service).Count(&finalCount) + result.After = int(finalCount) + + var finalHealthy int64 + s.db.Model(&model.HFSpace{}).Where("service = ? AND status = ?", service, "healthy").Count(&finalHealthy) + result.HealthyNow = int(finalHealthy) + + return result, nil +} + +// SyncCFResult 同步结果 +type SyncCFResult struct { + Service string `json:"service"` + EnvKey string `json:"env_key"` + URLs int `json:"urls"` +} + +// SyncCFWorker 读 DB 健康 Space URL → PATCH CF Worker env +func (s *HFSpaceService) SyncCFWorker(service string) (*SyncCFResult, error) { + cfAccountID := s.settingSvc.Get("hf_cf_account_id", "") + cfAPIToken := s.settingSvc.GetRaw("hf_cf_api_token") + cfWorkerName := s.settingSvc.Get("hf_cf_worker_name", "hf-snow-worker") + + if cfAccountID == "" || cfAPIToken == "" { + return nil, fmt.Errorf("CF 配置不完整(account_id 或 api_token 为空)") + } + + envKey, ok := CFEnvMap[service] + if !ok { + return nil, fmt.Errorf("未知服务: %s", service) + } + + // 获取健康 Space URL + var spaces []model.HFSpace + s.db.Where("service = ? AND status = ?", service, "healthy").Find(&spaces) + urls := make([]string, 0, len(spaces)) + for _, sp := range spaces { + urls = append(urls, sp.URL) + } + + // GET 当前 Worker settings + settingsURL := fmt.Sprintf( + "https://api.cloudflare.com/client/v4/accounts/%s/workers/scripts/%s/settings", + cfAccountID, cfWorkerName, + ) + + req, _ := http.NewRequest("GET", settingsURL, nil) + req.Header.Set("Authorization", "Bearer "+cfAPIToken) + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("CF GET settings 失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("CF GET settings 返回 %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + + var cfResp struct { + Result struct { + Bindings []map[string]interface{} `json:"bindings"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&cfResp); err != nil { + return nil, fmt.Errorf("CF 响应解析失败: %w", err) + } + + // 更新或添加目标 env var + value := strings.Join(urls, " ") + found := false + for _, b := range cfResp.Result.Bindings { + if b["type"] == "plain_text" && b["name"] == envKey { + b["text"] = value + found = true + break + } + } + if !found { + cfResp.Result.Bindings = append(cfResp.Result.Bindings, map[string]interface{}{ + "type": "plain_text", + "name": envKey, + "text": value, + }) + } + + // PATCH 回去(CF Workers Settings API 要求 multipart/form-data) + patchBody, _ := json.Marshal(map[string]interface{}{ + "bindings": cfResp.Result.Bindings, + }) + var mpBuf bytes.Buffer + mpWriter := multipart.NewWriter(&mpBuf) + settingsPart, _ := mpWriter.CreateFormField("settings") + settingsPart.Write(patchBody) + mpWriter.Close() + + patchReq, _ := http.NewRequest("PATCH", settingsURL, &mpBuf) + patchReq.Header.Set("Authorization", "Bearer "+cfAPIToken) + patchReq.Header.Set("Content-Type", mpWriter.FormDataContentType()) + patchResp, err := s.httpClient.Do(patchReq) + if err != nil { + return nil, fmt.Errorf("CF PATCH 失败: %w", err) + } + defer patchResp.Body.Close() + + if patchResp.StatusCode != 200 { + body, _ := io.ReadAll(patchResp.Body) + return nil, fmt.Errorf("CF PATCH 返回 %d: %s", patchResp.StatusCode, string(body[:min(len(body), 200)])) + } + + log.Info().Str("service", service).Str("env_key", envKey).Int("urls", len(urls)).Msg("CF Worker env 已更新") + return &SyncCFResult{Service: service, EnvKey: envKey, URLs: len(urls)}, nil +} + +// SyncPoolSize 基于健康节点数自动调优全部调度参数 +// 在 HF Space 同步、健康检查、CF Worker 同步后自动调用 +// +// 算法: +// - 每个 2C16G 节点承载 concurrencyPerNode 个并发注册 +// - 实际使用 utilizationRatio(0.9)比例,HF Space 自身 503 过载保护已兜底 +// - 单任务并发 = 总容量 / max(活跃用户数, 2)(动态分配,低用户时充分利用节点) +// - 同时用户数 = 总容量 / 单任务并发(下限 3) +// - 额外识别 reg_url 中的直连节点(非 HF CF Worker),计入容量 +func (s *HFSpaceService) SyncPoolSize() { + // ── 1. 统计各平台健康 HF Space 节点数 ── + type svcCount struct { + Service string + Count int64 + } + var counts []svcCount + s.db.Model(&model.HFSpace{}). + Select("service, count(*) as count"). + Where("status = ?", "healthy"). + Group("service"). + Scan(&counts) + + healthyMap := make(map[string]int) + var totalHealthy int + for _, c := range counts { + healthyMap[c.Service] = int(c.Count) + totalHealthy += int(c.Count) + } + if totalHealthy <= 0 { + return + } + + // ── 2. 识别直连节点(Mac Mini 等,逗号分隔 URL 中非 hf- 开头的域名) ── + registrationPlatforms := []string{"grok", "openai", "kiro", "gemini"} + for _, p := range registrationPlatforms { + regURL := s.settingSvc.Get(p+"_reg_url", "") + if regURL == "" { + continue + } + parts := strings.Split(regURL, ",") + for _, u := range parts { + u = strings.TrimSpace(u) + // CF Worker 入口 URL(含 hf- 前缀)由 HF Space 提供容量,不重复计算 + // 其余 URL 视为独立直连节点 + if u != "" && !strings.Contains(u, "hf-") { + healthyMap[p]++ + } + } + } + + // ── 3. 调优常量 ── + const ( + concurrencyPerNode = 5 // 单节点(2C16G)并发注册数 + utilizationRatio = 0.9 // 使用 90% 容量,HF Space 自身 503 + CF Worker 切节点已兜底 + minConcurrent = 5 // 平台最低总并发 + maxParallelCap = 40 // 单任务并发上限(单人场景可充分利用节点池) + minParallel = 3 // 单任务并发下限 + workersPerInst = 5 // 每实例 Pool Worker 数 + ) + + // ── 3.5 查询各平台当前活跃任务数(用于动态分配单任务并发) ── + type platformActive struct { + Platform string + Count int64 + } + var activeCounts []platformActive + s.db.Raw(`SELECT platform, COUNT(*) as count FROM tasks WHERE status = 'running' GROUP BY platform`).Scan(&activeCounts) + activeMap := make(map[string]int) + for _, a := range activeCounts { + activeMap[a.Platform] = int(a.Count) + } + + // ── 4. 按平台计算并写入 ── + totalMaxConcurrent := 0 + maxPlatformUsers := 0 + + for _, p := range registrationPlatforms { + nodes := healthyMap[p] + if nodes <= 0 { + continue + } + + // 总并发 = 节点 × 单节点并发 × 利用率 + maxConcurrent := int(float64(nodes*concurrencyPerNode) * utilizationRatio) + if maxConcurrent < minConcurrent { + maxConcurrent = minConcurrent + } + + // 自适应单任务并发分配: + // 0 个活跃用户 → 给 80% 容量(预留 20% 余量给随时可能进来的新用户) + // 1 个活跃用户 → 给 80% 容量(同上,新用户进来后 10s 内 RefreshLimits 会自动收缩) + // 2+ 个活跃用户 → 公平均分(总容量 / 活跃数) + // HF Space 的 503 过载保护 + CF Worker 自动切节点 作为最终安全网 + activeUsers := activeMap[p] + var maxParallel int + if activeUsers <= 1 { + // 单人或无人:给 80% 容量,不浪费节点 + maxParallel = maxConcurrent * 4 / 5 + } else { + // 多人:公平均分 + maxParallel = maxConcurrent / activeUsers + } + if maxParallel > maxParallelCap { + maxParallel = maxParallelCap + } + if maxParallel < minParallel { + maxParallel = minParallel + } + + // 同时用户数 = 总容量 / 单任务并发(下限 2) + maxUsers := maxConcurrent / maxParallel + if maxUsers < 2 { + maxUsers = 2 + } + + _ = s.settingSvc.Set(p+"_max_concurrent", strconv.Itoa(maxConcurrent)) + _ = s.settingSvc.Set(p+"_max_parallel", strconv.Itoa(maxParallel)) + _ = s.settingSvc.Set(p+"_max_concurrent_users", strconv.Itoa(maxUsers)) + + totalMaxConcurrent += maxConcurrent + if maxUsers > maxPlatformUsers { + maxPlatformUsers = maxUsers + } + + log.Info(). + Str("platform", p). + Int("healthy_nodes", nodes). + Int("max_concurrent", maxConcurrent). + Int("max_parallel", maxParallel). + Int("max_concurrent_users", maxUsers). + Msg("平台参数已自动调优") + } + + // ── 5. 全局参数 ── + _ = s.settingSvc.Set("hf_instance_count", strconv.Itoa(totalHealthy)) + _ = s.settingSvc.Set("workers_per_instance", strconv.Itoa(workersPerInst)) + + // 全局最大并发任务 = 各平台并发总和 × 1.2 + maxTasks := int(float64(totalMaxConcurrent) * 1.2) + if maxTasks < 30 { + maxTasks = 30 + } + _ = s.settingSvc.Set("max_concurrent_tasks", strconv.Itoa(maxTasks)) + + // 全局最大同时用户 = 各平台最大值 + 2(余量) + globalMaxUsers := maxPlatformUsers + 2 + _ = s.settingSvc.Set("max_concurrent_users", strconv.Itoa(globalMaxUsers)) + + // ── 6. max_threads:取各平台 max_parallel 最大值 ── + maxThreads := 0 + for _, p := range registrationPlatforms { + v, _ := strconv.Atoi(s.settingSvc.Get(p+"_max_parallel", "3")) + if v > maxThreads { + maxThreads = v + } + } + if maxThreads < 4 { + maxThreads = 4 + } + _ = s.settingSvc.Set("max_threads", strconv.Itoa(maxThreads)) + + // ── 7. thread_tiers:按 max_threads 自动生成分段并发策略 ── + // 小目标少线程(省资源),大目标逐步拉满 + // 策略:target ≤ 5 → 2线程, ≤ 15 → ceil(max/3), ≤ 30 → ceil(max/2), + // ≤ 60 → max-2, ≤ 100 → max-1, ≤ 200 → max, ≤ 500 → max + type tier struct { + Max int `json:"max"` + Threads int `json:"threads"` + } + mt := maxThreads + tiers := []tier{ + {Max: 5, Threads: clampInt(2, 1, mt)}, + {Max: 15, Threads: clampInt((mt+2)/3, 2, mt)}, + {Max: 30, Threads: clampInt((mt+1)/2, 2, mt)}, + {Max: 60, Threads: clampInt(mt-2, 3, mt)}, + {Max: 100, Threads: clampInt(mt-1, 3, mt)}, + {Max: 200, Threads: mt}, + {Max: 500, Threads: mt}, + } + tiersJSON, _ := json.Marshal(tiers) + _ = s.settingSvc.Set("thread_tiers", string(tiersJSON)) + + log.Info(). + Int("total_healthy", totalHealthy). + Int("pool_workers", totalHealthy*workersPerInst). + Int("max_concurrent_tasks", maxTasks). + Int("max_concurrent_users", globalMaxUsers). + Int("max_threads", maxThreads). + Str("thread_tiers", string(tiersJSON)). + Msg("全局调度参数已自动调优") +} + +// clampInt 限制 v 在 [lo, hi] 范围内 +func clampInt(v, lo, hi int) int { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +// UpdateSpaceResult 批量更新结果 +type UpdateSpaceResult struct { + Updated int `json:"updated"` + Failed int `json:"failed"` + Logs []string `json:"logs"` +} + +// UpdateSpaces 对已有 Space 推送最新 Dockerfile + 入口脚本(触发重建),同步 Release URL Secret +// service="all" 时 4 种服务并行更新,避免串行超时 +func (s *HFSpaceService) UpdateSpaces(service string) (*UpdateSpaceResult, error) { + services := []string{service} + if service == "all" { + services = []string{"openai", "grok", "kiro", "gemini", "ts"} + } + + result := &UpdateSpaceResult{Logs: []string{}} + var mu sync.Mutex + + var wg sync.WaitGroup + for _, svc := range services { + wg.Add(1) + go func(svc string) { + defer wg.Done() + s.updateOneService(svc, result, &mu) + }(svc) + } + wg.Wait() + + return result, nil +} + +// updateOneService 更新单个服务的所有 Space(供并行调用) +// 使用 errgroup 并发更新(上限 3),每次间隔 1s 避免触发 HF API 限流 +// 单次 commit/secret 失败自动重试 2 次(指数退避) +func (s *HFSpaceService) updateOneService(svc string, result *UpdateSpaceResult, mu *sync.Mutex) { + svcCfg, ok := ServiceMap[svc] + if !ok { + return + } + + // 读取嵌入的模板文件 + dockerfileBytes, err := regplatform.HFTemplateFS.ReadFile(svcCfg.Dir + "/Dockerfile") + if err != nil { + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] 读取 Dockerfile 失败: %v", svc, err)) + mu.Unlock() + return + } + scriptBytes, err := regplatform.HFTemplateFS.ReadFile(svcCfg.Dir + "/" + svcCfg.Script) + if err != nil { + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] 读取脚本失败: %v", svc, err)) + mu.Unlock() + return + } + scriptContent := string(scriptBytes) + + releaseURL := s.settingSvc.GetRaw("hf_release_url_" + svc) + ghPAT := s.settingSvc.GetRaw("hf_gh_pat") + proxyURL := s.settingSvc.GetRaw("hf_proxy_url") + + var spaces []model.HFSpace + s.db.Where("service = ?", svc).Find(&spaces) + + g := new(errgroup.Group) + g.SetLimit(3) // 并发上限 3,避免 HF API 限流 + + for idx, space := range spaces { + idx, space := idx, space + g.Go(func() error { + // 错开请求,避免瞬间并发打满 HF API + time.Sleep(time.Duration(idx%3) * time.Second) + + owner := strings.SplitN(space.RepoID, "/", 2)[0] + var token model.HFToken + if err := s.db.Where("username = ?", owner).First(&token).Error; err != nil { + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] %s — 找不到对应 Token (owner=%s): %v", svc, space.RepoID, owner, err)) + result.Failed++ + mu.Unlock() + return nil + } + + buildID := make([]byte, 4) + _, _ = rand.Read(buildID) + dockerfileFinal := randomizeDockerfile(string(dockerfileBytes)) + + // 随机选择入口脚本名(更新时也随机化) + deployScript := scriptNamePool[mathrand.Intn(len(scriptNamePool))] + dockerfileFinal = strings.ReplaceAll(dockerfileFinal, svcCfg.Script, deployScript) + + // 查出现有文件,找到需要删除的旧 .sh 脚本 + var oldShells []string + if existingFiles, err := s.hfListRepoFiles(token.Token, space.RepoID); err == nil { + for _, f := range existingFiles { + if strings.HasSuffix(f, ".sh") && f != deployScript { + oldShells = append(oldShells, f) + } + } + } + + // commit 文件 + 删除旧脚本(带重试) + commitErr := s.hfRetry(3, func() error { + return s.hfCommitFiles(token.Token, space.RepoID, map[string]string{ + deployScript: scriptContent, + "Dockerfile": dockerfileFinal, + }, "Update templates", oldShells...) + }) + if commitErr != nil { + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] %s — commit 失败: %v", svc, space.RepoID, commitErr)) + result.Failed++ + mu.Unlock() + return nil + } + + if releaseURL != "" { + if err := s.hfRetry(2, func() error { + return s.hfSetSecret(token.Token, space.RepoID, svcCfg.URLSecret, releaseURL) + }); err != nil { + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] %s — 更新 Release URL Secret 失败: %v", svc, space.RepoID, err)) + mu.Unlock() + } + } + if ghPAT != "" { + if err := s.hfRetry(2, func() error { + return s.hfSetSecret(token.Token, space.RepoID, "GH_PAT", ghPAT) + }); err != nil { + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] %s — 更新 GH_PAT 失败: %v", svc, space.RepoID, err)) + mu.Unlock() + } + } + if proxyURL != "" { + if err := s.hfRetry(2, func() error { + return s.hfSetSecret(token.Token, space.RepoID, "PROXY_URL", proxyURL) + }); err != nil { + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] %s — 更新 PROXY_URL 失败: %v", svc, space.RepoID, err)) + mu.Unlock() + } + } + + mu.Lock() + result.Logs = append(result.Logs, fmt.Sprintf("[%s] %s — 更新完成,重建已触发", svc, space.RepoID)) + result.Updated++ + mu.Unlock() + return nil + }) + } + _ = g.Wait() +} + +// hfRetry 带指数退避的重试包装(HF API 限流/超时保护) +func (s *HFSpaceService) hfRetry(maxAttempts int, fn func() error) error { + var lastErr error + for i := 0; i < maxAttempts; i++ { + if err := fn(); err != nil { + lastErr = err + backoff := time.Duration(1< 0 { + var t model.HFToken + if err := s.db.First(&t, tokenID).Error; err != nil { + return nil, nil, fmt.Errorf("指定的 Token (ID=%d) 不存在", tokenID) + } + tokens = []model.HFToken{t} + } else { + s.db.Where("is_valid = ?", true).Find(&tokens) + } + if len(tokens) == 0 { + return nil, nil, fmt.Errorf("没有可用的 HF Token") + } + + deployed := make([]model.HFSpace, 0, count) + errors := make([]string, 0) + + for i := 0; i < count; i++ { + token := tokens[i%len(tokens)] + spaceName := generateSpaceName() + repoID := token.Username + "/" + spaceName + + // 1. 创建 HF Space repo + if err := s.hfCreateRepo(token.Token, repoID); err != nil { + errors = append(errors, fmt.Sprintf("[%d] 创建 repo %s 失败: %v", i+1, repoID, err)) + continue + } + + // 2. 一次性提交 README + 入口脚本 + Dockerfile(单次 commit,只触发一次构建) + readme := generateReadme() + dockerfileFinal := randomizeDockerfile(string(dockerfileBytes)) + + // 随机选择入口脚本名(增加指纹多样性) + deployScript := scriptNamePool[mathrand.Intn(len(scriptNamePool))] + // Dockerfile 中替换原始脚本名为随机脚本名 + dockerfileFinal = strings.ReplaceAll(dockerfileFinal, svcCfg.Script, deployScript) + + if err := s.hfCommitFiles(token.Token, repoID, map[string]string{ + "README.md": readme, + deployScript: scriptContent, + "Dockerfile": dockerfileFinal, + }, "Initial deployment"); err != nil { + errors = append(errors, fmt.Sprintf("[%d] commit 文件到 %s 失败: %v", i+1, repoID, err)) + _ = s.hfDeleteRepo(token.Token, repoID) + continue + } + + // 5. 设置 Secrets(Release URL + GH_PAT + 自定义) + if err := s.hfSetSecret(token.Token, repoID, svcCfg.URLSecret, releaseURL); err != nil { + errors = append(errors, fmt.Sprintf("[%d] 设置 Secret %s 失败: %v", i+1, svcCfg.URLSecret, err)) + continue + } + if ghPAT != "" { + if err := s.hfSetSecret(token.Token, repoID, "GH_PAT", ghPAT); err != nil { + log.Warn().Err(err).Str("repo", repoID).Msg("设置 GH_PAT 失败") + } + } + if proxyURL != "" { + if err := s.hfSetSecret(token.Token, repoID, "PROXY_URL", proxyURL); err != nil { + log.Warn().Err(err).Str("repo", repoID).Msg("设置 PROXY_URL 失败") + } + } + for k, v := range secrets { + if err := s.hfSetSecret(token.Token, repoID, k, v); err != nil { + log.Warn().Err(err).Str("repo", repoID).Str("key", k).Msg("设置自定义 Secret 失败") + } + } + + // 6. 构造 Space URL,写入 DB + slug := strings.ToLower(token.Username + "-" + spaceName) + spaceURL := fmt.Sprintf("https://%s.hf.space", slug) + + space := model.HFSpace{ + Service: service, + RepoID: repoID, + URL: spaceURL, + TokenID: token.ID, + Status: "unknown", + } + s.db.Create(&space) + deployed = append(deployed, space) + + log.Info().Str("repo", repoID).Str("url", spaceURL).Msg("Space 已部署") + + // 随机间隔 30~90 秒,模拟人工操作节奏,降低批量创建特征 + if i < count-1 { + delay := time.Duration(30+mathrand.Intn(61)) * time.Second + log.Info().Dur("delay", delay).Int("remaining", count-1-i).Msg("部署间隔等待中") + time.Sleep(delay) + } + } + + return deployed, errors, nil +} + +// ── Space 自动发现 ── + +// DiscoverResult 发现结果 +type DiscoverResult struct { + Scanned int `json:"scanned"` // 扫描的 Token 数 + Found int `json:"found"` // HF API 返回的 Space 总数 + Imported int `json:"imported"` // 新入库数 + Skipped int `json:"skipped"` // 已存在跳过数 + Errors []string `json:"errors,omitempty"` +} + +// hfSpaceInfo HF API 返回的 Space 信息(只取需要的字段) +type hfSpaceInfo struct { + ID string `json:"id"` // "username/space-name" + Author string `json:"author"` // "username" + SDK string `json:"sdk"` // "docker" / "gradio" 等 +} + +// DiscoverSpaces 遍历所有有效 Token,调 HF API 拉取该账号下所有 Space,自动入库 +// defaultService: 无法自动判断服务类型时的默认值(openai/grok/kiro/gemini/ts),空则为 "unknown" +func (s *HFSpaceService) DiscoverSpaces(defaultService string) (*DiscoverResult, error) { + result := &DiscoverResult{Errors: make([]string, 0)} + + if defaultService == "" { + defaultService = "unknown" + } + + // 构建脚本文件名 → 服务类型 反向映射 + scriptToService := make(map[string]string) + for svc, cfg := range ServiceMap { + scriptToService[cfg.Script] = svc + } + + // 加载所有有效 Token + var tokens []model.HFToken + s.db.Where("is_valid = ?", true).Find(&tokens) + if len(tokens) == 0 { + return nil, fmt.Errorf("没有可用的 HF Token,请先添加 Token") + } + result.Scanned = len(tokens) + + for _, token := range tokens { + // 调 HF API 列出该用户的所有 Space + spaces, err := s.hfListSpaces(token.Token, token.Username) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("[%s] 获取 Space 列表失败: %v", token.Username, err)) + continue + } + + for _, info := range spaces { + result.Found++ + + // 只处理 docker SDK 的 Space(我们部署的都是 docker) + if info.SDK != "docker" { + result.Skipped++ + continue + } + + // 构造 URL: username-spacename → https://username-spacename.hf.space + slug := strings.ToLower(strings.ReplaceAll(info.ID, "/", "-")) + spaceURL := fmt.Sprintf("https://%s.hf.space", slug) + + // 去重:按 repo_id 或 URL + var count int64 + s.db.Model(&model.HFSpace{}).Where("repo_id = ? OR url = ?", info.ID, spaceURL).Count(&count) + if count > 0 { + result.Skipped++ + continue + } + + // 通过文件树自动识别服务类型 + service := s.hfDetectService(token.Token, info.ID, scriptToService) + if service == "" { + service = defaultService + } + + space := &model.HFSpace{ + Service: service, + RepoID: info.ID, + URL: spaceURL, + TokenID: token.ID, + Status: "unknown", + } + if err := s.db.Create(space).Error; err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("[%s] 写入 DB 失败: %v", info.ID, err)) + continue + } + result.Imported++ + log.Info().Str("repo", info.ID).Str("service", service).Str("url", spaceURL).Msg("发现并导入 Space") + } + } + + log.Info(). + Int("scanned", result.Scanned). + Int("found", result.Found). + Int("imported", result.Imported). + Int("skipped", result.Skipped). + Msg("Space 自动发现完成") + + return result, nil +} + +// hfListSpaces 调 HF API 列出指定用户的所有 Space +func (s *HFSpaceService) hfListSpaces(token, username string) ([]hfSpaceInfo, error) { + url := fmt.Sprintf("https://huggingface.co/api/spaces?author=%s&limit=200", username) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + var spaces []hfSpaceInfo + if err := json.NewDecoder(resp.Body).Decode(&spaces); err != nil { + return nil, err + } + return spaces, nil +} + +// RedetectService 对所有 service='unknown' 的 Space 重新通过文件树 API 识别服务类型 +func (s *HFSpaceService) RedetectService() (*DiscoverResult, error) { + result := &DiscoverResult{Errors: make([]string, 0)} + + scriptToService := make(map[string]string) + for svc, cfg := range ServiceMap { + scriptToService[cfg.Script] = svc + } + + // 查出所有 unknown 的 Space + var unknowns []model.HFSpace + s.db.Where("service = ?", "unknown").Find(&unknowns) + result.Found = len(unknowns) + + if len(unknowns) == 0 { + return result, nil + } + + // 预加载 token map(token_id → token 明文) + var allTokens []model.HFToken + s.db.Find(&allTokens) + tokenMap := make(map[uint]string) + for _, t := range allTokens { + tokenMap[t.ID] = t.Token + } + + for _, sp := range unknowns { + result.Scanned++ + token := tokenMap[sp.TokenID] + if token == "" { + result.Errors = append(result.Errors, fmt.Sprintf("[%s] 无关联 Token,跳过", sp.RepoID)) + result.Skipped++ + continue + } + + detected := s.hfDetectService(token, sp.RepoID, scriptToService) + if detected == "" { + result.Skipped++ + continue + } + + s.db.Model(&sp).Update("service", detected) + result.Imported++ // 复用字段表示"已识别数" + log.Info().Str("repo", sp.RepoID).Str("service", detected).Msg("重新识别 Space 服务类型") + } + + return result, nil +} + +// hfDetectService 通过 HF 文件树 API 自动识别 Space 的服务类型 +// 优先匹配标准脚本文件名(init.sh/bootstrap.sh/start.sh/run.sh), +// 若匹配不到则读取 .sh 脚本内容,通过 URLSecret 关键字(ARTIFACT_URL/PKG_URL/MODEL_URL/DATA_URL)识别 +func (s *HFSpaceService) hfDetectService(token, repoID string, scriptToService map[string]string) string { + treeURL := fmt.Sprintf("https://huggingface.co/api/spaces/%s/tree/main", repoID) + req, _ := http.NewRequest("GET", treeURL, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := s.httpClient.Do(req) + if err != nil { + return "" + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "" + } + + var files []struct { + RFilename string `json:"rfilename"` + Path string `json:"path"` + } + if err := json.NewDecoder(resp.Body).Decode(&files); err != nil { + return "" + } + + // 第一轮:标准脚本文件名精确匹配 + var shellScripts []string + for _, f := range files { + name := f.RFilename + if name == "" { + name = f.Path + } + if svc, ok := scriptToService[name]; ok { + return svc + } + // 收集所有 .sh 文件,供第二轮 fallback 使用 + if strings.HasSuffix(name, ".sh") { + shellScripts = append(shellScripts, name) + } + } + + // 第二轮 fallback:读取 .sh 脚本内容,通过 URLSecret 关键字识别 + secretToService := make(map[string]string) + for svc, cfg := range ServiceMap { + secretToService[cfg.URLSecret] = svc + } + for _, sh := range shellScripts { + rawURL := fmt.Sprintf("https://huggingface.co/spaces/%s/raw/main/%s", repoID, sh) + r, _ := http.NewRequest("GET", rawURL, nil) + r.Header.Set("Authorization", "Bearer "+token) + rr, err := s.httpClient.Do(r) + if err != nil || rr.StatusCode != 200 { + if rr != nil { + rr.Body.Close() + } + continue + } + body, _ := io.ReadAll(io.LimitReader(rr.Body, 8192)) + rr.Body.Close() + content := string(body) + for secret, svc := range secretToService { + if strings.Contains(content, secret) { + return svc + } + } + } + + return "" +} + +// ── 内部方法:HF API 调用 ── + +// hfWhoami 调用 HF whoami 接口验证 Token +func (s *HFSpaceService) hfWhoami(token string) (string, error) { + req, _ := http.NewRequest("GET", "https://huggingface.co/api/whoami-v2", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := s.httpClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d", resp.StatusCode) + } + var result struct { + Name string `json:"name"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + if result.Name == "" { + return "", fmt.Errorf("whoami 返回空用户名") + } + return result.Name, nil +} + +// hfCreateRepo 通过 HF API 创建 Space repo +func (s *HFSpaceService) hfCreateRepo(token, repoID string) error { + // repoID 格式为 "username/spacename",HF API 只需 "name" 传纯 repo 名 + parts := strings.SplitN(repoID, "/", 2) + repoName := repoID + if len(parts) == 2 { + repoName = parts[1] + } + body, _ := json.Marshal(map[string]interface{}{ + "type": "space", + "name": repoName, + "sdk": "docker", + "private": false, + }) + req, _ := http.NewRequest("POST", "https://huggingface.co/api/repos/create", strings.NewReader(string(body))) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 201 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 200)])) + } + return nil +} + +// hfDeleteRepo 通过 HF API 删除 repo +func (s *HFSpaceService) hfDeleteRepo(token, repoID string) error { + // HF API 要求拆分 repo_id 为 organization + name + // 例如 "myuser/my-space" → organization="myuser", name="my-space" + payload := map[string]interface{}{ + "type": "space", + } + if parts := strings.SplitN(repoID, "/", 2); len(parts) == 2 { + payload["organization"] = parts[0] + payload["name"] = parts[1] + } else { + payload["name"] = repoID + } + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("DELETE", "https://huggingface.co/api/repos/delete", strings.NewReader(string(body))) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 204 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HF 删除 repo 返回 %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 200)])) + } + return nil +} + +// hfGetSpaceRuntime 调用 HF Runtime API 获取 Space 真实运行状态 +// 返回 stage 字符串(RUNNING/BUILDING/RUNTIME_ERROR/BUILD_ERROR/PAUSED/STOPPED/CONFIG_ERROR 等) +func (s *HFSpaceService) hfGetSpaceRuntime(token, repoID string) (string, error) { + url := fmt.Sprintf("https://huggingface.co/api/spaces/%s/runtime", repoID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := s.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("HF Runtime API 请求失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("HF Runtime API 返回 %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + var result struct { + Stage string `json:"stage"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("HF Runtime API 解析失败: %w", err) + } + return result.Stage, nil +} + +// hfRestartSpace 调用 HF API 重启 Space +// factoryReboot=true 时会清除持久存储重建(通过 query param ?factory=true) +func (s *HFSpaceService) hfRestartSpace(token, repoID string, factoryReboot bool) error { + restartURL := fmt.Sprintf("https://huggingface.co/api/spaces/%s/restart", repoID) + if factoryReboot { + restartURL += "?factory=true" + } + req, _ := http.NewRequest("POST", restartURL, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := s.httpClient.Do(req) + if err != nil { + return fmt.Errorf("HF Restart API 请求失败: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HF Restart API 返回 %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 200)])) + } + return nil +} + +// hfCommitFiles 在 HF Space repo 中提交多个文件(HF Hub NDJSON Commit API) +// +// 协议说明:POST /api/spaces/{repo}/commit/main,Content-Type: application/x-ndjson +// 每行一个独立 JSON 对象,统一 key/value 信封结构: +// - 第 1 行(header):{"key":"header","value":{"summary":"...","description":""}} +// - 后续行(file):{"key":"file","value":{"path":"...","encoding":"base64","content":"..."}} +func (s *HFSpaceService) hfCommitFiles(token, repoID string, files map[string]string, message string, deleteFiles ...string) error { + var buf bytes.Buffer + + // ── 第 1 行:commit header(value 信封) ── + headerLine, err := json.Marshal(map[string]interface{}{ + "key": "header", + "value": map[string]interface{}{ + "summary": message, + "description": "", + }, + }) + if err != nil { + return err + } + buf.Write(headerLine) + buf.WriteByte('\n') + + // ── 删除文件 ── + for _, df := range deleteFiles { + delLine, err := json.Marshal(map[string]interface{}{ + "key": "deletedFile", + "value": map[string]interface{}{ + "path": df, + }, + }) + if err != nil { + return err + } + buf.Write(delLine) + buf.WriteByte('\n') + } + + // ── 后续行:每个文件一行(value 信封,与 header 一致) ── + for path, content := range files { + fileLine, err := json.Marshal(map[string]interface{}{ + "key": "file", + "value": map[string]interface{}{ + "path": path, + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte(content)), + }, + }) + if err != nil { + return err + } + buf.Write(fileLine) + buf.WriteByte('\n') + } + + commitURL := fmt.Sprintf("https://huggingface.co/api/spaces/%s/commit/main", repoID) + req, err := http.NewRequest("POST", commitURL, &buf) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/x-ndjson") + + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 201 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HF commit 返回 %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 300)])) + } + return nil +} + +// hfSetSecret 设置 Space Secret +func (s *HFSpaceService) hfSetSecret(token, repoID, key, value string) error { + url := fmt.Sprintf("https://huggingface.co/api/spaces/%s/secrets", repoID) + body, _ := json.Marshal(map[string]string{"key": key, "value": value}) + req, _ := http.NewRequest("POST", url, strings.NewReader(string(body))) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + resp, err := s.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 && resp.StatusCode != 201 { + respBody, _ := io.ReadAll(resp.Body) + return fmt.Errorf("HF 设置 Secret 返回 %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 200)])) + } + return nil +} + +// hfListRepoFiles 获取 Space 仓库的文件列表 +func (s *HFSpaceService) hfListRepoFiles(token, repoID string) ([]string, error) { + url := fmt.Sprintf("https://huggingface.co/api/spaces/%s", repoID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HF API 返回 %d", resp.StatusCode) + } + var data struct { + Siblings []struct { + RFilename string `json:"rfilename"` + } `json:"siblings"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + files := make([]string, 0, len(data.Siblings)) + for _, s := range data.Siblings { + files = append(files, s.RFilename) + } + return files, nil +} + +// checkOneHealth 检查单个 Space 健康状态 +func (s *HFSpaceService) checkOneHealth(client *http.Client, spaceURL string) SpaceHealthResult { + result := SpaceHealthResult{ + URL: spaceURL, + Status: "unknown", + } + + healthURL := strings.TrimRight(spaceURL, "/") + "/health" + resp, err := client.Get(healthURL) + if err != nil { + result.Status = "dead" + result.Reason = "connection_error" + return result + } + defer resp.Body.Close() + result.StatusCode = resp.StatusCode + + switch { + case resp.StatusCode == 200: + result.Healthy = true + result.Status = "healthy" + case resp.StatusCode == 403: + result.Status = "banned" + result.Reason = "banned_or_restricted" + case resp.StatusCode == 404: + result.Status = "dead" + result.Reason = "not_found_or_deleted" + case resp.StatusCode == 503: + result.Status = "sleeping" + result.Reason = "sleeping_or_building" + default: + result.Status = "dead" + result.Reason = fmt.Sprintf("http_%d", resp.StatusCode) + } + return result +} + +// ── 随机化工具 ── + +// 入口脚本名池(部署时随机选择,增加指纹多样性) +var scriptNamePool = []string{ + "entrypoint.sh", "main.sh", "launch.sh", "setup.sh", "app.sh", + "init.sh", "bootstrap.sh", "start.sh", "run.sh", "serve.sh", +} + +// randomizeDockerfile 对 Dockerfile 进行指纹随机化 +// 随机化 WORKDIR、注释位置、EXPOSE 注释、ENV 前缀等,避免所有 Space 指纹一致 +func randomizeDockerfile(original string) string { + lines := strings.Split(original, "\n") + + // 1. 随机化 WORKDIR 路径 + workdirs := []string{"/app", "/opt/svc", "/srv", "/home/app", "/opt/app", "/usr/src/app", "/workspace"} + newWorkdir := workdirs[mathrand.Intn(len(workdirs))] + for i, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "WORKDIR ") { + lines[i] = "WORKDIR " + newWorkdir + } + } + + // 2. 随机添加 EXPOSE 端口注释(40% 概率) + if mathrand.Intn(10) < 4 { + ports := []string{"7860", "8080", "8000", "3000", "5000"} + comment := fmt.Sprintf("# expose port %s for health checks", ports[mathrand.Intn(len(ports))]) + // 插入到中间随机位置 + pos := 1 + mathrand.Intn(max(len(lines)-1, 1)) + lines = append(lines[:pos], append([]string{comment}, lines[pos:]...)...) + } + + // 3. 随机化 ENV 变量名前缀(如果有 ENV 行) + prefixes := []string{"APP_", "SVC_", "SRV_", "NODE_", "INST_"} + prefix := prefixes[mathrand.Intn(len(prefixes))] + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "ENV ") && strings.Contains(trimmed, "BUILD_HASH=") { + lines[i] = strings.Replace(line, "BUILD_HASH=", prefix+"BUILD_HASH=", 1) + } + } + + // 4. 在随机位置插入构建注释(替代原来只在末尾加 build-id) + buildID := make([]byte, 4) + rand.Read(buildID) + comments := []string{ + fmt.Sprintf("# build-id: %s", hex.EncodeToString(buildID)), + fmt.Sprintf("# revision: %s", hex.EncodeToString(buildID)), + fmt.Sprintf("# checksum: %s", hex.EncodeToString(buildID)), + fmt.Sprintf("# stamp: %s", hex.EncodeToString(buildID)), + } + buildComment := comments[mathrand.Intn(len(comments))] + + // 50% 概率插入中间,50% 概率追加末尾 + if mathrand.Intn(2) == 0 && len(lines) > 2 { + pos := 1 + mathrand.Intn(len(lines)-1) + lines = append(lines[:pos], append([]string{buildComment}, lines[pos:]...)...) + } else { + // 去掉尾部空行后追加 + for len(lines) > 0 && strings.TrimSpace(lines[len(lines)-1]) == "" { + lines = lines[:len(lines)-1] + } + lines = append(lines, buildComment) + } + + return strings.Join(lines, "\n") + "\n" +} + +// generateSpaceName 生成随机 Space 名称(ML 项目风格) +func generateSpaceName() string { + adj := func() string { return adjectives[mathrand.Intn(len(adjectives))] } + noun := func() string { return nouns[mathrand.Intn(len(nouns))] } + hexN := func(n int) string { + b := make([]byte, n) + rand.Read(b) + return hex.EncodeToString(b) + } + // 版本号风格后缀 + ver := func() string { + return fmt.Sprintf("v%d", mathrand.Intn(3)+1) + } + + switch mathrand.Intn(7) { + case 0: // adj-noun-hex4(经典 HF 风格) + return fmt.Sprintf("%s-%s-%s", adj(), noun(), hexN(2)) + case 1: // noun-adj-hex4 + return fmt.Sprintf("%s-%s-%s", noun(), adj(), hexN(2)) + case 2: // adj-noun-v1(版本号风格,像模型发布) + return fmt.Sprintf("%s-%s-%s", adj(), noun(), ver()) + case 3: // noun-noun-hex4(双名词,像 bert-base) + return fmt.Sprintf("%s-%s-%s", noun(), noun(), hexN(2)) + case 4: // adj-noun(简短,像 tiny-llm) + return fmt.Sprintf("%s-%s", adj(), noun()) + case 5: // noun-adj-noun(三段式,像 bert-base-encoder) + return fmt.Sprintf("%s-%s-%s", noun(), adj(), noun()) + default: // adj-adj-noun(双形容词,像 fast-slim-bert) + return fmt.Sprintf("%s-%s-%s", adj(), adj(), noun()) + } +} + +// generateReadme 生成随机 README(ML/AI 主题) +func generateReadme() string { + themes := []struct{ title, emoji, desc string }{ + {"Text Classification Demo", "📝", "Fine-tuned transformer for multi-label text classification"}, + {"Sentiment Analysis API", "😊", "Real-time sentiment scoring with DistilBERT"}, + {"NER Extraction Service", "🏷️", "Named entity recognition powered by SpaCy and transformers"}, + {"Text Summarization", "📄", "Abstractive summarization using BART-base"}, + {"Question Answering", "❓", "Extractive QA with RoBERTa fine-tuned on SQuAD"}, + {"Image Classification", "🖼️", "ViT-based image classifier with top-5 predictions"}, + {"Object Detection API", "🔍", "YOLOv8-nano real-time object detection endpoint"}, + {"Text Generation", "✍️", "Causal language model inference with sampling controls"}, + {"Embedding Service", "🧮", "Sentence embeddings via all-MiniLM-L6-v2"}, + {"Translation API", "🌐", "Multi-language translation with MarianMT"}, + {"Speech Recognition", "🎙️", "Whisper-tiny automatic speech recognition"}, + {"Zero-Shot Classifier", "🎯", "Zero-shot text classification with MNLI"}, + {"Semantic Search", "🔎", "Dense retrieval with bi-encoder embeddings"}, + {"Token Classification", "🔤", "POS tagging and chunking with BERT-base"}, + {"Text2Text Generation", "🔄", "Flan-T5 instruction-following text generation"}, + {"Feature Extraction", "📊", "Hidden state extraction for downstream tasks"}, + {"Fill Mask Demo", "🎭", "Masked language modeling with BERT"}, + {"Paraphrase Detection", "🔁", "Sentence similarity scoring with cross-encoder"}, + {"Document QA", "📑", "Layout-aware document question answering"}, + {"Code Generation", "💻", "Code completion with CodeGen-350M-mono"}, + {"Chatbot Demo", "💬", "Conversational AI with DialoGPT-medium"}, + {"Image Segmentation", "🎨", "Semantic segmentation with SegFormer-B0"}, + {"Audio Classification", "🔊", "Environmental sound classification with AST"}, + {"Table QA", "📋", "Table question answering with TAPAS-base"}, + } + + colors := []string{"red", "yellow", "green", "blue", "indigo", "purple", "pink", "gray"} + licenses := []string{"mit", "apache-2.0", "bsd-3-clause", "bsd-2-clause", "isc", "mpl-2.0", "unlicense"} + + // 随机特性池(每次抽 3 条,ML/AI 风格) + featurePool := []string{ + "Optimized inference with ONNX Runtime", + "Dynamic batching for throughput optimization", + "Model quantization support (INT8/FP16)", + "Automatic mixed precision inference", + "Health check endpoint at /health", + "Configurable max sequence length", + "GPU and CPU inference support", + "Streaming token generation", + "Built-in tokenizer with padding and truncation", + "Model warm-up on startup for low latency", + "Prometheus metrics at /metrics", + "Graceful shutdown with request draining", + "Environment-based model configuration", + "Docker-optimized multi-stage build", + "Horizontal scaling with stateless design", + "Request queuing with configurable concurrency", + "Automatic model caching and versioning", + "Input validation and sanitization", + } + + // 随机 usage 片段池(ML 风格) + usagePool := []string{ + "curl -X POST http://localhost:7860/predict -H 'Content-Type: application/json' -d '{\"text\": \"Hello world\"}'", + "curl http://localhost:7860/health", + "docker run -p 7860:7860 -e MODEL_NAME=distilbert-base IMAGE_NAME", + "python -c \"import requests; print(requests.post('http://localhost:7860/predict', json={'text':'test'}).json())\"", + } + + // 随机 API 端点池(ML 风格) + apiPool := []string{ + "GET /health — Liveness and readiness probe", + "POST /predict — Run model inference on input", + "POST /embed — Generate embeddings for text", + "GET /model/info — Model metadata and config", + "POST /tokenize — Tokenize input text", + "GET /metrics — Prometheus-compatible metrics", + } + + theme := themes[mathrand.Intn(len(themes))] + c1 := colors[mathrand.Intn(len(colors))] + c2 := colors[mathrand.Intn(len(colors))] + lic := licenses[mathrand.Intn(len(licenses))] + + var sb strings.Builder + + // YAML front matter + fmt.Fprintf(&sb, `--- +title: %s +emoji: %s +colorFrom: %s +colorTo: %s +sdk: docker +pinned: false +license: %s +short_description: %s +--- + +`, theme.title, theme.emoji, c1, c2, lic, theme.desc) + + // 随机添加 badge(50% 概率) + if mathrand.Intn(2) == 0 { + badges := []string{ + fmt.Sprintf("![Build](https://img.shields.io/badge/build-passing-brightgreen)"), + fmt.Sprintf("![License](https://img.shields.io/badge/license-%s-blue)", lic), + fmt.Sprintf("![Docker](https://img.shields.io/badge/docker-ready-blue)"), + fmt.Sprintf("![Version](https://img.shields.io/badge/version-1.%d.%d-orange)", mathrand.Intn(10), mathrand.Intn(20)), + } + n := 1 + mathrand.Intn(3) // 1~3 个 badge + for i := 0; i < n && i < len(badges); i++ { + sb.WriteString(badges[i]) + sb.WriteString(" ") + } + sb.WriteString("\n\n") + } + + // 标题 + 描述(ML 风格) + descs := []string{ + "Optimized for low-latency inference on CPU.", + "Powered by Hugging Face Transformers.", + "Lightweight model serving with dynamic batching.", + "Built with ONNX Runtime for production deployment.", + "Fine-tuned on domain-specific data for best accuracy.", + } + fmt.Fprintf(&sb, "# %s\n\n%s.\n%s\n", theme.title, theme.desc, descs[mathrand.Intn(len(descs))]) + + // Features section(70% 概率) + if mathrand.Intn(10) < 7 { + sb.WriteString("\n## Features\n\n") + used := map[int]bool{} + for len(used) < 3 { + idx := mathrand.Intn(len(featurePool)) + if !used[idx] { + used[idx] = true + fmt.Fprintf(&sb, "- %s\n", featurePool[idx]) + } + } + } + + // Usage section(60% 概率) + if mathrand.Intn(10) < 6 { + sb.WriteString("\n## Usage\n\n```bash\n") + sb.WriteString(usagePool[mathrand.Intn(len(usagePool))]) + sb.WriteString("\n```\n") + } + + // API section(50% 概率) + if mathrand.Intn(2) == 0 { + sb.WriteString("\n## API\n\n```\n") + used := map[int]bool{} + n := 2 + mathrand.Intn(3) // 2~4 个端点 + for len(used) < n && len(used) < len(apiPool) { + idx := mathrand.Intn(len(apiPool)) + if !used[idx] { + used[idx] = true + sb.WriteString(apiPool[idx]) + sb.WriteString("\n") + } + } + sb.WriteString("```\n") + } + + return sb.String() +} + diff --git a/internal/service/proxy_pool.go b/internal/service/proxy_pool.go new file mode 100644 index 0000000..b34041d --- /dev/null +++ b/internal/service/proxy_pool.go @@ -0,0 +1,522 @@ +package service + +import ( + "context" + "fmt" + "math/rand" + "net" + "net/http" + "net/url" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/worker" + "golang.org/x/net/proxy" + "gorm.io/gorm" +) + +// ProxyPool 代理池(线程安全轮询,多源加载,健康检查) +type ProxyPool struct { + mu sync.RWMutex + proxies []worker.ProxyEntry + index uint64 + settingSvc *SettingService + db *gorm.DB +} + +// NewProxyPool 创建代理池 +func NewProxyPool(settingSvc *SettingService, db *gorm.DB) *ProxyPool { + p := &ProxyPool{ + settingSvc: settingSvc, + db: db, + } + p.Reload() + return p +} + +// GetNext 获取下一个健康代理(延迟感知轮询 + TCP 快检验证) +// 最多尝试 min(n, 3) 个代理,跳过 TCP 不可达的;全部失败则回退返回第一个 +func (p *ProxyPool) GetNext() *worker.ProxyEntry { + p.mu.RLock() + n := len(p.proxies) + if n == 0 { + p.mu.RUnlock() + return nil // 直连 + } + + start := atomic.AddUint64(&p.index, 1) + proxiesCopy := make([]worker.ProxyEntry, n) + copy(proxiesCopy, p.proxies) + p.mu.RUnlock() + + // 最多尝试 min(n, 3) 个代理 + tries := n + if tries > 3 { + tries = 3 + } + for i := 0; i < tries; i++ { + entry := proxiesCopy[(start+uint64(i))%uint64(n)] + if quickTCPCheck(entry.HTTP, 2*time.Second) { + return &entry + } + // 异步标记不健康(不阻塞选择流程) + go p.markUnhealthy(entry.HTTP) + } + + // 全部快检失败,回退返回第一个候选(让上层决定是否直连) + entry := proxiesCopy[start%uint64(n)] + return &entry +} + +// Count 返回可用代理数量 +func (p *ProxyPool) Count() int { + p.mu.RLock() + defer p.mu.RUnlock() + return len(p.proxies) +} + +// GetNextForPlatform 根据平台代理策略获取代理 +// 模式:pool=轮询代理池, fixed=使用平台专用固定代理, direct=不使用代理, smart=按延迟加权选择 +func (p *ProxyPool) GetNextForPlatform(platform string) *worker.ProxyEntry { + mode := p.settingSvc.Get(platform+"_proxy_mode", "pool") + + switch mode { + case "direct": + return nil + + case "fixed": + fixedURL := p.settingSvc.Get(platform+"_proxy", "") + if fixedURL == "" { + log.Debug().Str("platform", platform).Msg("fixed 模式但未配置代理,回退 pool") + return p.GetNext() + } + return &worker.ProxyEntry{HTTP: fixedURL, HTTPS: fixedURL} + + case "smart": + return p.getSmartProxy() + + default: // "pool" 或未知值 + return p.GetNext() + } +} + +// getSmartProxy 基于延迟的加权随机选择(延迟低的代理被选中概率更高) +// 权重 = 1000 / max(latency_ms, 10),未检测过的代理使用默认权重 50 +func (p *ProxyPool) getSmartProxy() *worker.ProxyEntry { + if p.db == nil { + return p.GetNext() + } + + var entries []model.ProxyPoolEntry + if err := p.db.Where("is_healthy = ? AND protocol IN ?", true, []string{"socks5", "http", "https"}). + Find(&entries).Error; err != nil || len(entries) == 0 { + return p.GetNext() + } + + type weightedEntry struct { + entry model.ProxyPoolEntry + weight float64 + } + candidates := make([]weightedEntry, 0, len(entries)) + var totalWeight float64 + for _, e := range entries { + w := 50.0 + if e.LatencyMs > 0 { + w = 1000.0 / float64(max(e.LatencyMs, 10)) + } + totalWeight += w + candidates = append(candidates, weightedEntry{entry: e, weight: w}) + } + + r := rand.Float64() * totalWeight + var cumulative float64 + for _, c := range candidates { + cumulative += c.weight + if r <= cumulative { + proxyURL := c.entry.URL() + return &worker.ProxyEntry{HTTP: proxyURL, HTTPS: proxyURL} + } + } + + last := candidates[len(candidates)-1].entry.URL() + return &worker.ProxyEntry{HTTP: last, HTTPS: last} +} + +// Reload 重新加载代理列表(多源:DB ProxyPoolEntry + settings default_proxy) +// 在锁外执行 DB I/O,仅在交换时加锁,避免阻塞 GetNext() +func (p *ProxyPool) Reload() { + var newProxies []worker.ProxyEntry + + // 源 1: 从 proxy_pool_entries 表加载健康代理(仅 http/https/socks5) + if p.db != nil { + var entries []model.ProxyPoolEntry + if err := p.db.Where("is_healthy = ? AND protocol IN ? AND fail_count < ?", true, []string{"socks5", "http", "https"}, 3). + Order("latency_ms ASC").Find(&entries).Error; err != nil { + log.Warn().Err(err).Msg("代理池加载 DB 条目失败") + p.mu.RLock() + currentCount := len(p.proxies) + p.mu.RUnlock() + if currentCount > 0 { + log.Warn().Int("kept", currentCount).Msg("DB 查询失败,保留现有代理池") + return + } + } else { + for _, e := range entries { + entry := worker.ProxyEntry{HTTP: e.URL(), HTTPS: e.URL()} + newProxies = appendUniqueProxy(newProxies, entry) + } + } + } + + // 源 2: settings 中的 default_proxy(兜底) + if dbProxy := p.settingSvc.Get("default_proxy", ""); dbProxy != "" { + entry := worker.ProxyEntry{HTTP: dbProxy, HTTPS: dbProxy} + newProxies = appendUniqueProxy(newProxies, entry) + } + + // 原子交换:仅在此处加写锁 + p.mu.Lock() + p.proxies = newProxies + atomic.StoreUint64(&p.index, 0) + p.mu.Unlock() + + log.Info().Int("count", len(newProxies)).Msg("代理池已加载") +} + +// appendUniqueProxy 去重追加代理 +func appendUniqueProxy(list []worker.ProxyEntry, entry worker.ProxyEntry) []worker.ProxyEntry { + for _, existing := range list { + if existing.HTTP == entry.HTTP { + return list + } + } + return append(list, entry) +} + +// ── 健康检查 ──────────────────────────────────────────────────────── + +// HealthCheckAll 对所有代理池条目执行健康检查(并发,每批 10 个) +// 返回 (总数, 健康数, 不健康数) +func (p *ProxyPool) HealthCheckAll() (total, healthy, unhealthy int) { + if p.db == nil { + return 0, 0, 0 + } + + var entries []model.ProxyPoolEntry + if err := p.db.Where("protocol IN ?", []string{"socks5", "http", "https"}). + Find(&entries).Error; err != nil { + log.Error().Err(err).Msg("健康检查查询代理失败") + return 0, 0, 0 + } + + total = len(entries) + if total == 0 { + return 0, 0, 0 + } + + const batchSize = 10 + var mu sync.Mutex + var wg sync.WaitGroup + + for i := 0; i < total; i += batchSize { + end := i + batchSize + if end > total { + end = total + } + batch := entries[i:end] + + for j := range batch { + wg.Add(1) + go func(entry *model.ProxyPoolEntry) { + defer wg.Done() + ok, latency := checkProxyHealth(entry.URL(), entry.Protocol) + now := time.Now() + + mu.Lock() + if ok { + healthy++ + } else { + unhealthy++ + } + mu.Unlock() + + updates := map[string]interface{}{ + "last_checked_at": now, + } + if ok { + updates["is_healthy"] = true + updates["fail_count"] = 0 + updates["latency_ms"] = latency + } else { + newFail := entry.FailCount + 1 + updates["fail_count"] = newFail + updates["is_healthy"] = false + } + if err := p.db.Model(&model.ProxyPoolEntry{}).Where("id = ?", entry.ID).Updates(updates).Error; err != nil { + log.Warn().Err(err).Uint("id", entry.ID).Msg("更新代理健康状态失败") + } + }(&batch[j]) + } + wg.Wait() + } + + // 重新加载代理池(踢除不健康的) + p.Reload() + + return total, healthy, unhealthy +} + +// HealthCheckByIDs 对指定 ID 的代理执行健康检查(并发,每批 10 个) +// 返回 (总数, 健康数, 不健康数) +func (p *ProxyPool) HealthCheckByIDs(ids []uint) (total, healthy, unhealthy int) { + if p.db == nil || len(ids) == 0 { + return 0, 0, 0 + } + + var entries []model.ProxyPoolEntry + if err := p.db.Where("id IN ? AND protocol IN ?", ids, []string{"socks5", "http", "https"}). + Find(&entries).Error; err != nil { + log.Error().Err(err).Msg("选择性健康检查查询代理失败") + return 0, 0, 0 + } + + total = len(entries) + if total == 0 { + return 0, 0, 0 + } + + const batchSize = 10 + var mu sync.Mutex + var wg sync.WaitGroup + + for i := 0; i < total; i += batchSize { + end := i + batchSize + if end > total { + end = total + } + batch := entries[i:end] + + for j := range batch { + wg.Add(1) + go func(entry *model.ProxyPoolEntry) { + defer wg.Done() + ok, latency := checkProxyHealth(entry.URL(), entry.Protocol) + now := time.Now() + + mu.Lock() + if ok { + healthy++ + } else { + unhealthy++ + } + mu.Unlock() + + updates := map[string]interface{}{ + "last_checked_at": now, + } + if ok { + updates["is_healthy"] = true + updates["fail_count"] = 0 + updates["latency_ms"] = latency + } else { + newFail := entry.FailCount + 1 + updates["fail_count"] = newFail + updates["is_healthy"] = false + } + if err := p.db.Model(&model.ProxyPoolEntry{}).Where("id = ?", entry.ID).Updates(updates).Error; err != nil { + log.Warn().Err(err).Uint("id", entry.ID).Msg("更新代理健康状态失败") + } + }(&batch[j]) + } + wg.Wait() + } + + // 重新加载代理池(踢除不健康的) + p.Reload() + + return total, healthy, unhealthy +} + +// quickTCPCheck 对代理执行 TCP 快连测试,仅验证端口可达性 +func quickTCPCheck(proxyURL string, timeout time.Duration) bool { + parsed, err := url.Parse(proxyURL) + if err != nil || parsed.Host == "" { + return false + } + conn, err := net.DialTimeout("tcp", parsed.Host, timeout) + if err != nil { + return false + } + conn.Close() + return true +} + +// markUnhealthy 异步标记代理为不健康并触发代理池重载 +func (p *ProxyPool) markUnhealthy(proxyURL string) { + if p.db == nil { + return + } + parsed, err := url.Parse(proxyURL) + if err != nil { + return + } + host := parsed.Hostname() + portStr := parsed.Port() + if host == "" || portStr == "" { + return + } + port, err := strconv.Atoi(portStr) + if err != nil { + return + } + if err := p.db.Model(&model.ProxyPoolEntry{}). + Where("host = ? AND port = ?", host, port). + Updates(map[string]interface{}{ + "fail_count": gorm.Expr("fail_count + 1"), + "is_healthy": false, + }).Error; err != nil { + log.Warn().Str("proxy", proxyURL).Err(err).Msg("标记代理不健康失败") + } + p.Reload() +} + +// PoolStats 返回代理池统计信息 +func (p *ProxyPool) PoolStats() map[string]interface{} { + if p.db == nil { + return map[string]interface{}{"total": 0, "healthy": 0, "unhealthy": 0, "active": p.Count()} + } + + var total, healthyCount int64 + p.db.Model(&model.ProxyPoolEntry{}).Where("protocol IN ?", []string{"socks5", "http", "https"}).Count(&total) + p.db.Model(&model.ProxyPoolEntry{}).Where("protocol IN ? AND is_healthy = ?", []string{"socks5", "http", "https"}, true).Count(&healthyCount) + + return map[string]interface{}{ + "total": total, + "healthy": healthyCount, + "unhealthy": total - healthyCount, + "active": p.Count(), + } +} + +// ── 代理连通性检测 ────────────────────────────────────────────────── + +// checkProxyHealth 检测代理是否可用,返回 (是否健康, 延迟毫秒) +func checkProxyHealth(proxyURL, protocol string) (bool, int64) { + start := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + parsed, err := url.Parse(proxyURL) + if err != nil { + return false, 0 + } + + switch parsed.Scheme { + case "socks5": + auth := &proxy.Auth{} + if parsed.User != nil { + auth.User = parsed.User.Username() + auth.Password, _ = parsed.User.Password() + } else { + auth = nil + } + dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct) + if err != nil { + return false, 0 + } + var conn net.Conn + if cd, ok := dialer.(proxy.ContextDialer); ok { + conn, err = cd.DialContext(ctx, "tcp", "httpbin.org:80") + } else { + conn, err = dialer.Dial("tcp", "httpbin.org:80") + } + if err != nil { + return false, 0 + } + conn.Close() + + case "http", "https": + transport := &http.Transport{Proxy: http.ProxyURL(parsed)} + client := &http.Client{Transport: transport, Timeout: 10 * time.Second} + req, _ := http.NewRequestWithContext(ctx, "GET", "http://httpbin.org/ip", nil) + resp, err := client.Do(req) + if err != nil { + return false, 0 + } + resp.Body.Close() + + default: + return false, 0 + } + + latency := time.Since(start).Milliseconds() + return true, latency +} + +// BuildProxyHTTPClient 构建通过指定代理的 HTTP Client(供验活等场景使用) +func BuildProxyHTTPClient(proxyURL string, timeout time.Duration) (*http.Client, error) { + if proxyURL == "" { + return &http.Client{Timeout: timeout}, nil + } + + parsed, err := url.Parse(proxyURL) + if err != nil { + return nil, fmt.Errorf("代理地址格式错误: %w", err) + } + + switch parsed.Scheme { + case "socks5": + auth := &proxy.Auth{} + if parsed.User != nil { + auth.User = parsed.User.Username() + auth.Password, _ = parsed.User.Password() + } else { + auth = nil + } + dialer, err := proxy.SOCKS5("tcp", parsed.Host, auth, proxy.Direct) + if err != nil { + return nil, fmt.Errorf("SOCKS5 初始化失败: %w", err) + } + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { + if cd, ok := dialer.(proxy.ContextDialer); ok { + return cd.DialContext(ctx, network, addr) + } + return dialer.Dial(network, addr) + }, + } + return &http.Client{Transport: transport, Timeout: timeout}, nil + + case "http", "https": + transport := &http.Transport{Proxy: http.ProxyURL(parsed)} + return &http.Client{Transport: transport, Timeout: timeout}, nil + + default: + return nil, fmt.Errorf("不支持的代理协议: %s", parsed.Scheme) + } +} + +// StartHealthChecker 启动代理池定时健康检查后台协程 +// 每 5 分钟对所有代理执行连通性检测,失败即标记不健康并踢出代理池 +func (p *ProxyPool) StartHealthChecker(ctx context.Context) { + go func() { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + + log.Info().Msg("代理池定时健康检查已启动") + for { + select { + case <-ctx.Done(): + log.Info().Msg("代理池定时健康检查已停止") + return + case <-ticker.C: + total, healthy, unhealthy := p.HealthCheckAll() + log.Info().Int("total", total).Int("healthy", healthy).Int("unhealthy", unhealthy).Msg("定时健康检查完成") + } + } + }() +} diff --git a/internal/service/queue_scheduler.go b/internal/service/queue_scheduler.go new file mode 100644 index 0000000..9dd423b --- /dev/null +++ b/internal/service/queue_scheduler.go @@ -0,0 +1,91 @@ +package service + +import ( + "strings" + "sync" + "time" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "gorm.io/gorm" +) + +// isQueuedMsg 判断 StartTask 返回的消息是否表示任务仍在排队 +func isQueuedMsg(msg string) bool { + return msg == "已加入队列" || strings.HasPrefix(msg, "排队中") +} + +// QueueScheduler 任务队列调度器,负责从 DB 队列中取出任务并启动 +type QueueScheduler struct { + engine *TaskEngine + db *gorm.DB + creditSvc *CreditService + dispatchMu sync.Mutex // 防止并发 TryDispatch 重复调度同一任务 +} + +// NewQueueScheduler 创建调度器 +func NewQueueScheduler(db *gorm.DB, engine *TaskEngine, creditSvc *CreditService) *QueueScheduler { + return &QueueScheduler{engine: engine, db: db, creditSvc: creditSvc} +} + +// TryDispatch 尝试从队列中调度排队任务。 +// 按创建时间扫描排队任务,跳过平台已满的任务,直到成功启动一个或全部扫描完毕。 +func (q *QueueScheduler) TryDispatch() { + q.dispatchMu.Lock() + defer q.dispatchMu.Unlock() + + var tasks []model.Task + q.db.Where("status = ?", "queued"). + Order("created_at ASC"). + Limit(20). + Find(&tasks) + + for _, task := range tasks { + ok, msg := q.engine.StartTask(task.UserID, task.ID, task.Platform, task.TargetCount, task.ThreadCount) + if ok && !isQueuedMsg(msg) { + log.Info().Uint("task_id", task.ID).Str("platform", task.Platform).Msg("队列任务已调度") + return // 每次释放一个槽位,调度一个即可 + } + if !ok { + log.Warn().Uint("task_id", task.ID).Str("msg", msg).Msg("队列任务调度失败") + } + // 仍在排队(平台满或用户槽位满),尝试下一个排队任务 + } +} + +// RecoverOnBoot 服务启动时清理残留状态并恢复队列。 +// 应在 DB ready 后、HTTP server 启动前调用。 +func (q *QueueScheduler) RecoverOnBoot() { + now := time.Now() + + // running/stopping → stopped(服务重启导致的中断),同时退还未消费积分 + var interrupted []model.Task + q.db.Where("status IN ?", []string{"running", "stopping"}).Find(&interrupted) + for _, task := range interrupted { + refund := task.CreditsReserved - task.SuccessCount + if refund > 0 { + q.creditSvc.RefundCredits(q.db, task.UserID, task.ID, refund) + log.Info().Uint("task_id", task.ID).Int("refund", refund).Msg("重启恢复:退还中断任务积分") + } + q.db.Model(&task).Updates(map[string]interface{}{ + "status": "stopped", + "stopped_at": &now, + }) + } + if len(interrupted) > 0 { + log.Warn().Int("count", len(interrupted)).Msg("重启恢复:中断任务已标记为 stopped 并退款") + } + + // queued → 依次尝试调度(按平台独立限流,某平台满不影响其他平台) + var queuedTasks []model.Task + q.db.Where("status = ?", "queued").Order("created_at ASC").Find(&queuedTasks) + for _, task := range queuedTasks { + ok, msg := q.engine.StartTask(task.UserID, task.ID, task.Platform, task.TargetCount, task.ThreadCount) + if ok && !isQueuedMsg(msg) { + log.Info().Uint("task_id", task.ID).Str("platform", task.Platform).Msg("重启恢复:队列任务已调度") + } + // 不 break:某平台满或用户槽位满不影响其他排队任务 + } + + log.Info().Msg("任务队列恢复完成") +} diff --git a/internal/service/setting_service.go b/internal/service/setting_service.go new file mode 100644 index 0000000..ce5f258 --- /dev/null +++ b/internal/service/setting_service.go @@ -0,0 +1,168 @@ +package service + +import ( + "context" + "strconv" + "sync" + "time" + + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/cache" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// SettingService 系统设置服务(L1 内存 + L2 Redis 两层缓存) +// 读取优先级:L1 内存(5s) → L2 Redis(5min) → DB system_settings → 硬编码默认值 +type SettingService struct { + db *gorm.DB + redis *cache.RedisCache + cache map[string]cachedSetting + mu sync.RWMutex + l1TTL time.Duration // L1 内存缓存 TTL + l2TTL time.Duration // L2 Redis 缓存 TTL +} + +type cachedSetting struct { + value string + expiresAt time.Time +} + +// NewSettingService 创建设置服务 +func NewSettingService(db *gorm.DB, rc *cache.RedisCache) *SettingService { + return &SettingService{ + db: db, + redis: rc, + cache: make(map[string]cachedSetting), + l1TTL: 5 * time.Second, // L1 内存:5 秒(高频读,快速感知变更) + l2TTL: 5 * time.Minute, // L2 Redis:5 分钟(跨实例共享,减少 DB 压力) + } +} + +// redisKey 生成 Redis key +func settingRedisKey(key string) string { + return "setting:" + key +} + +// Get 获取设置值(L1 → L2 → DB → 硬编码默认值) +func (s *SettingService) Get(key string, fallback string) string { + // ── L1:内存缓存 ───────────────────────────────────────────────────── + s.mu.RLock() + if c, ok := s.cache[key]; ok && time.Now().Before(c.expiresAt) { + s.mu.RUnlock() + return resolveValue(c.value, fallback, key) + } + s.mu.RUnlock() + + // ── L2:Redis 缓存 ────────────────────────────────────────────────── + if s.redis != nil { + if val, ok := s.redis.GetString(context.Background(), settingRedisKey(key)); ok { + // 回填 L1 + s.mu.Lock() + s.cache[key] = cachedSetting{value: val, expiresAt: time.Now().Add(s.l1TTL)} + s.mu.Unlock() + return resolveValue(val, fallback, key) + } + } + + // ── L3:数据库(静默查询,record not found 不打日志) ──────────────── + var setting model.SystemSetting + dbValue := "" + if err := s.db.Session(&gorm.Session{Logger: s.db.Logger.LogMode(logger.Silent)}).Where("\"key\" = ?", key).First(&setting).Error; err == nil { + dbValue = setting.Value + } + + // 回填 L1 + L2(即使为空也缓存,避免反复查库) + s.mu.Lock() + s.cache[key] = cachedSetting{value: dbValue, expiresAt: time.Now().Add(s.l1TTL)} + s.mu.Unlock() + + if s.redis != nil { + s.redis.SetString(context.Background(), settingRedisKey(key), dbValue, s.l2TTL) + } + + return resolveValue(dbValue, fallback, key) +} + +// resolveValue 解析最终值:dbValue → fallback → 硬编码默认值 +func resolveValue(value, fallback, key string) string { + if value != "" { + return value + } + if fallback != "" { + return fallback + } + for _, def := range model.DefaultSettings { + if def.Key == key { + return def.DefaultValue + } + } + return "" +} + +// GetInt 获取整数设置 +func (s *SettingService) GetInt(key string, fallback int) int { + val := s.Get(key, "") + if val == "" { + return fallback + } + n, err := strconv.Atoi(val) + if err != nil { + return fallback + } + return n +} + +// GetBool 获取布尔设置 +func (s *SettingService) GetBool(key string, fallback bool) bool { + val := s.Get(key, "") + if val == "" { + return fallback + } + return val == "true" || val == "1" +} + +// Set 保存设置(同时清除 L1 + L2 缓存) +func (s *SettingService) Set(key, value string) error { + err := s.db.Save(&model.SystemSetting{Key: key, Value: value}).Error + if err == nil { + // 清除 L1 + s.mu.Lock() + delete(s.cache, key) + s.mu.Unlock() + // 清除 L2 + if s.redis != nil { + s.redis.Del(context.Background(), settingRedisKey(key)) + } + } + return err +} + +// GetAll 获取所有设置(含脱敏) +func (s *SettingService) GetAll() []map[string]interface{} { + results := make([]map[string]interface{}, 0, len(model.DefaultSettings)) + for _, def := range model.DefaultSettings { + val := s.Get(def.Key, def.DefaultValue) + displayVal := val + if def.IsSensitive && len(val) > 4 { + displayVal = val[:2] + "****" + val[len(val)-2:] + } + results = append(results, map[string]interface{}{ + "key": def.Key, + "label": def.Label, + "group": def.Group, + "type": def.Type, + "value": displayVal, + "description": def.Description, + "is_set": val != "" && val != def.DefaultValue, + "has_value": val != "" && val != def.DefaultValue, + "is_secret": def.IsSensitive, + }) + } + return results +} + +// GetRaw 获取原始值(管理员用) +func (s *SettingService) GetRaw(key string) string { + return s.Get(key, "") +} diff --git a/internal/service/task_engine.go b/internal/service/task_engine.go new file mode 100644 index 0000000..8d54218 --- /dev/null +++ b/internal/service/task_engine.go @@ -0,0 +1,1170 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/worker" + "gorm.io/gorm" +) + +// taskKey 运行中任务的唯一标识 +type taskKey struct { + UserID uint + Platform string +} + +// RunningTask 运行中的任务 +type RunningTask struct { + TaskID uint + UserID uint + Platform string + Target int + Threads int + SuccessCount atomic.Int64 + FailCount atomic.Int64 + ClaimedCount atomic.Int64 // 已认领的注册名额(防止并发超发) + Stopping atomic.Bool // 用户已请求停止(允许立即启动新任务) + Cancel context.CancelFunc + LogCh chan string + StatusCh chan struct{} // 状态变更通知(成功/失败计数变化时触发) + Done chan struct{} + StartedAt time.Time // 任务启动时间(用于耗时统计) + subscribers []chan string + subMu sync.RWMutex + logBuffer []string // 日志缓冲区(供刷新页面后回放) + logBufMu sync.RWMutex +} + +// NotifyStatus 通知前端状态已变更(非阻塞) +func (rt *RunningTask) NotifyStatus() { + select { + case rt.StatusCh <- struct{}{}: + default: // 已有待处理的通知,跳过 + } +} + +// Subscribe 订阅日志 +func (rt *RunningTask) Subscribe() <-chan string { + ch := make(chan string, 200) + rt.subMu.Lock() + rt.subscribers = append(rt.subscribers, ch) + rt.subMu.Unlock() + return ch +} + +// Unsubscribe 取消订阅 +func (rt *RunningTask) Unsubscribe(ch <-chan string) { + rt.subMu.Lock() + defer rt.subMu.Unlock() + for i, sub := range rt.subscribers { + if sub == ch { + rt.subscribers = append(rt.subscribers[:i], rt.subscribers[i+1:]...) + close(sub) + return + } + } +} + +// IsDone 任务是否完成 +func (rt *RunningTask) IsDone() bool { + select { + case <-rt.Done: + return true + default: + return false + } +} + +// Log 写入日志 +func (rt *RunningTask) Log(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + // 防止往已关闭的 LogCh 写入导致 panic + defer func() { recover() }() + select { + case rt.LogCh <- msg: + default: + // 通道满了丢弃 + } +} + +// isInternalBrowserLog 判断 [~] 前缀的浏览器日志是否包含敏感/内部信息 +// 返回 true 表示该日志不应展示给用户(URL、Token、IP、内部选择器等) +func isInternalBrowserLog(msg string) bool { + // 完整 URL(泄漏内部服务地址) + if strings.Contains(msg, "https://") || strings.Contains(msg, "http://") { + return true + } + // Token / 密钥值 + if strings.Contains(msg, "Token:") || strings.Contains(msg, "token=") || + strings.Contains(msg, "xsrf") || strings.Contains(msg, "cookie") { + return true + } + // OIDC 客户端凭据 / 设备码 + if strings.Contains(msg, "clientId") || strings.Contains(msg, "user_code=") || + strings.Contains(msg, "客户端已注册") { + return true + } + // IP / 地理位置 + if strings.Contains(msg, "IP 地理位置") || strings.Contains(msg, "tz=") { + return true + } + // 内部页面状态(含 URL 片段) + if strings.Contains(msg, "页面:") || strings.Contains(msg, "URL:") || + strings.Contains(msg, "注册链接:") || strings.Contains(msg, "页面已自动跳转") { + return true + } + // 邮箱明文 + 页面地址 + if strings.Contains(msg, "填写邮箱:") { + return true + } + // Playwright CSS 选择器细节 + if strings.Contains(msg, "点击按钮 (") || strings.Contains(msg, "尝试点击按钮") { + return true + } + // OIDC 内部步骤 + if strings.Contains(msg, "OIDC Step") || strings.Contains(msg, "OIDC:") { + return true + } + // Playwright 内部状态 + if strings.Contains(msg, "networkidle") { + return true + } + return false +} + +// shouldShowToUser 判断日志是否应推送到前端 +// 只保留用户关心的结果/进度/状态信息,过滤掉注册流程内部细节 +func shouldShowToUser(msg string) bool { + // 远程浏览器服务的流式日志(Kiro/Gemini 注册过程) + // 需脱敏:隐藏 token/URL/cookie/内部技术细节,只展示进度 + if strings.Contains(msg, "[~]") { + return !isInternalBrowserLog(msg) + } + // 成功结果(只保留 [✓] 最终汇总行,过滤掉中间步骤的 "注册成功" / "远程注册成功") + if strings.Contains(msg, "[✓]") { + return true + } + // 分隔线 + if strings.Contains(msg, "════") { + return true + } + // 任务汇总 + if strings.Contains(msg, "任务完成") || strings.Contains(msg, "任务已加入") { + return true + } + // 统计信息(仅匹配任务汇总行,如 "成功: 5 失败: 3",不匹配 "注册失败: xxx") + if strings.Contains(msg, "成功:") && strings.Contains(msg, "失败:") { + return true + } + // 耗时/预计(不含宽泛的"等待",避免排队轮询日志刷屏) + if strings.Contains(msg, "耗时") || strings.Contains(msg, "预计") { + return true + } + // 排队状态(仅匹配明确的排队反馈,不匹配内部 pollMailCode 等待日志) + if strings.Contains(msg, "排队等待完成") || strings.Contains(msg, "排队中") { + return true + } + // 进度指示 + if strings.Contains(msg, "第 ") || strings.Contains(msg, "进度") { + return true + } + // 用户操作反馈 + if strings.Contains(msg, "手动停止") || strings.Contains(msg, "强制停止") { + return true + } + return false +} + +// broadcastLogs 广播日志到所有订阅者,同时缓存到 logBuffer +// 连续相同消息自动去重,避免大量并发注册时前端 DOM 爆炸 +func (rt *RunningTask) broadcastLogs() { + var ( + lastRaw string // 上一条原始消息(不含时间戳) + dupCount int // 连续重复计数 + ) + + // sendOne 发送单条日志到 buffer + 所有 subscriber + // 日志始终写入 logBuffer(后端调试用),仅用户关心的日志推送到前端 + sendOne := func(line string) { + rt.logBufMu.Lock() + rt.logBuffer = append(rt.logBuffer, line) + if len(rt.logBuffer) > 2000 { + rt.logBuffer = rt.logBuffer[len(rt.logBuffer)-2000:] + } + rt.logBufMu.Unlock() + + if !shouldShowToUser(line) { + return + } + + rt.subMu.RLock() + for _, sub := range rt.subscribers { + select { + case sub <- line: + default: + } + } + rt.subMu.RUnlock() + } + + // flushDup 输出累积的重复计数 + flushDup := func() { + if dupCount > 0 { + sendOne(fmt.Sprintf("[%s] [~] ↑ 重复 ×%d", time.Now().Format("15:04:05"), dupCount)) + dupCount = 0 + } + } + + for msg := range rt.LogCh { + // 连续相同消息去重 + if msg == lastRaw { + dupCount++ + // 每累积 100 条刷一次,避免长时间无输出 + if dupCount%100 == 0 { + flushDup() + } + continue + } + flushDup() + lastRaw = msg + sendOne(fmt.Sprintf("[%s] %s", time.Now().Format("15:04:05"), msg)) + } + flushDup() +} + +// GetLogBuffer 获取已缓存的历史日志(刷新页面后回放用) +func (rt *RunningTask) GetLogBuffer() []string { + rt.logBufMu.RLock() + defer rt.logBufMu.RUnlock() + buf := make([]string, len(rt.logBuffer)) + copy(buf, rt.logBuffer) + return buf +} + +// TaskEngine 任务引擎 +type TaskEngine struct { + db *gorm.DB + proxyPool *ProxyPool + cardPool *CardPool + creditSvc *CreditService + settingSvc *SettingService + running map[taskKey]*RunningTask + mu sync.RWMutex + activeCount atomic.Int32 // 全局活跃任务计数 + platformCount map[string]*atomic.Int32 // 按平台活跃任务计数 + scheduler *QueueScheduler // 队列调度器(启动后通过 SetScheduler 注入) + dispatcher *Dispatcher // 全局 Round-Robin 调度器(所有任务共享 Worker Pool) + workerCount atomic.Int32 // 当前运行中的 Pool Worker 数 + targetWorkers atomic.Int32 // 目标 Pool Worker 数(从配置动态读取,热生效) + configCache map[string]*cachedConfig // 按平台缓存的 ScanConfig 结果 + configCacheMu sync.RWMutex +} + +// cachedConfig 平台扫描配置缓存 +type cachedConfig struct { + config worker.Config + scannedAt time.Time +} + +// NewTaskEngine 创建任务引擎 +func NewTaskEngine(db *gorm.DB, proxyPool *ProxyPool, cardPool *CardPool, creditSvc *CreditService, settingSvc *SettingService) *TaskEngine { + // 注意:running/stopping 任务的清理和退款由 QueueScheduler.RecoverOnBoot() 统一处理 + // 此处不再重复清理,避免抢在退款之前把状态改掉 + + // 初始化按平台并发计数器 + pc := make(map[string]*atomic.Int32) + for name := range worker.Registry { + pc[name] = &atomic.Int32{} + } + + e := &TaskEngine{ + db: db, + proxyPool: proxyPool, + cardPool: cardPool, + creditSvc: creditSvc, + settingSvc: settingSvc, + running: make(map[taskKey]*RunningTask), + platformCount: pc, + dispatcher: NewDispatcher(), + configCache: make(map[string]*cachedConfig), + } + e.startWorkerPool() + go e.startConfigRefresher() + return e +} + +// SetScheduler 注入队列调度器(解决循环依赖) +func (e *TaskEngine) SetScheduler(s *QueueScheduler) { + e.scheduler = s +} + +// countActiveUsers 统计当前有多少个不同用户在运行任务 +func (e *TaskEngine) countActiveUsers() int { + e.mu.RLock() + defer e.mu.RUnlock() + users := make(map[uint]struct{}) + for _, rt := range e.running { + if !rt.IsDone() { + users[rt.UserID] = struct{}{} + } + } + return len(users) +} + +// userHasRunningTask 检查该用户是否已有运行中任务(已占槽位) +func (e *TaskEngine) userHasRunningTask(userID uint) bool { + e.mu.RLock() + defer e.mu.RUnlock() + for _, rt := range e.running { + if rt.UserID == userID && !rt.IsDone() { + return true + } + } + return false +} + +// countActiveUsersByPlatform 统计指定平台当前有多少个不同用户在运行任务 +func (e *TaskEngine) countActiveUsersByPlatform(platform string) int { + e.mu.RLock() + defer e.mu.RUnlock() + users := make(map[uint]struct{}) + for _, rt := range e.running { + if rt.Platform == platform && !rt.IsDone() { + users[rt.UserID] = struct{}{} + } + } + return len(users) +} + +// computeMaxInflight 计算指定平台的单任务最大并发数(统一算法,供启动时 + RefreshLimits 复用) +// 自适应策略:单人 80% 容量,多人均分,上限不超过平台容量的 90% +func (e *TaskEngine) computeMaxInflight(platform string) int32 { + defaultCap := 50 + if platform == "kiro" || platform == "gemini" { + defaultCap = 15 + } + maxConcurrent := e.settingSvc.GetInt(platform+"_max_concurrent", defaultCap) + if maxConcurrent <= 0 { + maxConcurrent = defaultCap + } + + var activeCount int64 + e.db.Model(&model.Task{}).Where("platform = ? AND status = ?", platform, "running").Count(&activeCount) + + var maxParallel int + if activeCount <= 1 { + maxParallel = maxConcurrent * 4 / 5 + } else { + maxParallel = maxConcurrent / int(activeCount) + } + + // 上限:不超过平台总容量的 90%(预留缓冲给突发/新任务进入) + parallelCap := maxConcurrent * 9 / 10 + if parallelCap < 5 { + parallelCap = 5 + } + if maxParallel > parallelCap { + maxParallel = parallelCap + } + if maxParallel < 3 { + maxParallel = 3 + } + return int32(maxParallel) +} + +// userHasRunningTaskOnPlatform 检查该用户在指定平台是否已有运行中任务 +func (e *TaskEngine) userHasRunningTaskOnPlatform(userID uint, platform string) bool { + key := taskKey{userID, platform} + e.mu.RLock() + defer e.mu.RUnlock() + rt, ok := e.running[key] + return ok && !rt.IsDone() +} + +// EstimateWaitTime 估算排队用户的预计等待时间(秒) +// 基于该平台最近已完成任务的平均耗时 + 当前运行中任务的剩余时间 +func (e *TaskEngine) EstimateWaitTime(queuePosition int, platform string) int { + // 查询该平台最近 50 个已完成任务的平均耗时 + var avgDuration float64 + e.db.Model(&model.Task{}). + Where("platform = ? AND status IN ? AND started_at IS NOT NULL AND stopped_at IS NOT NULL", platform, []string{"completed", "stopped"}). + Order("stopped_at DESC").Limit(50). + Select("COALESCE(AVG(EXTRACT(EPOCH FROM (stopped_at - started_at))), 120)"). + Scan(&avgDuration) + if avgDuration < 10 { + avgDuration = 120 // 兜底 2 分钟 + } + + // 找该平台当前运行中任务的最短剩余时间 + e.mu.RLock() + minRemaining := avgDuration + for _, rt := range e.running { + if rt.IsDone() || rt.Platform != platform { + continue + } + elapsed := time.Since(rt.StartedAt).Seconds() + remaining := avgDuration - elapsed + if remaining < 0 { + remaining = 30 // 已超时的任务给 30 秒缓冲 + } + if remaining < minRemaining { + minRemaining = remaining + } + } + e.mu.RUnlock() + + // 预计等待 = 最短剩余时间 × ceil(排队位置 / 槽位数) + maxUsers, _ := strconv.Atoi(e.settingSvc.Get(platform+"_max_concurrent_users", "0")) + if maxUsers <= 0 { + maxUsers, _ = strconv.Atoi(e.settingSvc.Get("max_concurrent_users", "1")) + } + if maxUsers <= 0 { + maxUsers = 1 + } + rounds := (queuePosition + maxUsers - 1) / maxUsers + return int(minRemaining) * rounds +} + +// formatWaitTime 格式化等待秒数为可读字符串 +func formatWaitTime(sec int) string { + if sec < 60 { + return fmt.Sprintf("约 %d 秒", sec) + } + min := sec / 60 + s := sec % 60 + if s > 0 { + return fmt.Sprintf("约 %d 分 %d 秒", min, s) + } + return fmt.Sprintf("约 %d 分钟", min) +} + +// StartTask 启动任务 +func (e *TaskEngine) StartTask(userID, taskID uint, platform string, target, threads int) (bool, string) { + key := taskKey{userID, platform} + + w, ok := worker.Get(platform) + if !ok { + return false, fmt.Sprintf("不支持的平台: %s", platform) + } + + // ── 按平台用户槽位并发控制 ── + maxUsers, _ := strconv.Atoi(e.settingSvc.Get(platform+"_max_concurrent_users", "0")) + if maxUsers <= 0 { + // 向后兼容:平台专属未设置时回退到全局设置 + maxUsers, _ = strconv.Atoi(e.settingSvc.Get("max_concurrent_users", "0")) + } + if maxUsers > 0 && !e.userHasRunningTaskOnPlatform(userID, platform) { + activeUsers := e.countActiveUsersByPlatform(platform) + if activeUsers >= maxUsers { + e.db.Model(&model.Task{}).Where("id = ? AND status != ?", taskID, "queued").Update("status", "queued") + // 计算该平台排队位置和预计等待时间 + var queuePos int64 + e.db.Model(&model.Task{}). + Where("status = ? AND platform = ? AND created_at < (SELECT created_at FROM tasks WHERE id = ?)", "queued", platform, taskID). + Count(&queuePos) + queuePos++ // 自己也算一个位置 + waitSec := e.EstimateWaitTime(int(queuePos), platform) + log.Info().Uint("task_id", taskID).Uint("user_id", userID).Str("platform", platform). + Int("active_users", activeUsers).Int("limit", maxUsers).Int64("queue_pos", queuePos). + Msg("平台用户槽位已满,任务加入队列") + return true, fmt.Sprintf("排队中,前方 %d 位用户,预计等待 %s", queuePos, formatWaitTime(waitSec)) + } + } + + // ── 按平台并发控制 ── + platformLimitKey := platform + "_max_concurrent" + platformLimit, _ := strconv.Atoi(e.settingSvc.Get(platformLimitKey, "0")) + if platformLimit <= 0 { + // 浏览器类平台(kiro/gemini)并发能力远低于 HTTP 类(openai/grok),兜底值需区分 + switch platform { + case "kiro", "gemini": + platformLimit = 8 + default: + platformLimit = 100 + } + } + pc := e.getPlatformCount(platform) + newPlatformActive := pc.Add(1) + if int(newPlatformActive) > platformLimit { + pc.Add(-1) + e.db.Model(&model.Task{}).Where("id = ? AND status != ?", taskID, "queued").Update("status", "queued") + log.Info().Uint("task_id", taskID).Str("platform", platform). + Int("active", int(newPlatformActive-1)).Int("limit", platformLimit). + Msg("平台并发已满,任务加入队列") + return true, "已加入队列" + } + + // ── 全局并发控制(安全上限)── + globalLimit, _ := strconv.Atoi(e.settingSvc.Get("max_concurrent_tasks", "100")) + if globalLimit <= 0 { + globalLimit = 100 + } + newActive := e.activeCount.Add(1) + if int(newActive) > globalLimit { + e.activeCount.Add(-1) + pc.Add(-1) + e.db.Model(&model.Task{}).Where("id = ? AND status != ?", taskID, "queued").Update("status", "queued") + log.Info().Uint("task_id", taskID).Int("active", int(newActive-1)).Int("limit", globalLimit). + Msg("全局并发已满,任务加入队列") + return true, "已加入队列" + } + + ctx, cancel := context.WithCancel(context.Background()) + rt := &RunningTask{ + TaskID: taskID, + UserID: userID, + Platform: platform, + Target: target, + Threads: threads, + Cancel: cancel, + LogCh: make(chan string, 500), + StatusCh: make(chan struct{}, 1), + Done: make(chan struct{}), + StartedAt: time.Now(), + } + + // 原子检查并设置,防止 TOCTOU 竞态 + e.mu.Lock() + if existing, ok := e.running[key]; ok && !existing.IsDone() { + e.mu.Unlock() + cancel() // 清理刚创建的 context + e.activeCount.Add(-1) // 回滚全局计数 + pc.Add(-1) // 回滚平台计数 + return false, "该平台已有运行中的任务" + } + e.running[key] = rt + e.mu.Unlock() + + // 更新任务状态为 running + now := time.Now() + e.db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]interface{}{ + "status": "running", + "started_at": &now, + }) + + // 启动日志广播 + go rt.broadcastLogs() + + log.Info().Uint("task_id", taskID).Str("platform", platform).Int("target", target).Msg("任务已启动") + + // 异步执行重量级操作(ScanConfig + 调度 + 监控),避免阻塞调用方 + go e.executeStartedTask(ctx, cancel, rt, key, w, pc, taskID, userID, platform, target) + + return true, "任务已启动" +} + +// executeStartedTask 执行已通过并发检查的任务(ScanConfig + 调度 + 监控) +// 在 goroutine 中运行,避免阻塞 StartTask 的调用方 +func (e *TaskEngine) executeStartedTask( + ctx context.Context, cancel context.CancelFunc, + rt *RunningTask, key taskKey, w worker.Worker, pc *atomic.Int32, + taskID, userID uint, platform string, target int, +) { + // 获取共享基础配置(ScanConfig 只调一次,所有 Pool Worker 共享结果) + baseCfg := worker.Config{} + e.injectSettings(baseCfg) + + // 优先使用缓存配置(后台每 5 分钟自动刷新) + scannedCfg, cached := e.getCachedConfig(platform) + if !cached { + rt.Log("[*] 正在获取页面配置(直连失败时会走浏览器过盾,约需 30-60 秒)...") + var scanErr error + scannedCfg, scanErr = w.ScanConfig(ctx, e.proxyPool.GetNext(), baseCfg) + if scanErr != nil { + rt.Log("[-] 配置获取失败: %s", scanErr) + cancel() + close(rt.Done) + close(rt.LogCh) // 停止 broadcastLogs goroutine + stopNow := time.Now() + e.db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]interface{}{ + "status": "stopped", + "stopped_at": &stopNow, + }) + // 退还所有预扣积分 + var task model.Task + if dbErr := e.db.First(&task, taskID).Error; dbErr == nil && task.CreditsReserved > 0 { + e.creditSvc.RefundCredits(e.db, userID, taskID, task.CreditsReserved) + } + e.mu.Lock() + delete(e.running, key) + e.mu.Unlock() + e.creditSvc.CleanupTask(taskID) + e.activeCount.Add(-1) + pc.Add(-1) + if e.scheduler != nil { + e.scheduler.TryDispatch() + } + return + } + // 扫描成功,更新缓存供后续任务使用 + e.updateConfigCache(platform, scannedCfg) + } else { + rt.Log("[*] 使用缓存配置(后台每 5 分钟自动刷新)") + } + // 合并扫描配置到基础配置 + for k, v := range scannedCfg { + if baseCfg[k] == "" { + baseCfg[k] = v + } + } + // 将扫描到的动态配置回写数据库,避免 site_key / action_id 过期 + for _, k := range []string{"site_key", "action_id"} { + if v := scannedCfg[k]; v != "" { + dbKey := platform + "_" + k + if setErr := e.settingSvc.Set(dbKey, v); setErr != nil { + rt.Log("[!] 配置持久化失败 (%s): %s", dbKey, setErr) + } else { + rt.Log("[*] 动态配置已更新: %s = %s", dbKey, v) + } + } + } + + // 注入用户指定的代理(任务创建时保存在 task.proxy_url) + { + var task model.Task + if err := e.db.Select("proxy_url").First(&task, taskID).Error; err == nil && task.ProxyURL != "" { + baseCfg["user_proxy"] = task.ProxyURL + rt.Log("[*] 使用用户指定代理") + } + } + + // 解析多节点 URL 配置(逗号分隔 → 轮询分配到各 Pool Worker) + multiNodeKeys := []string{"kiro_reg_url", "gemini_reg_url", "openai_reg_url", "grok_reg_url", "camoufox_reg_url", "turnstile_solver_url", "cf_bypass_solver_url"} + nodeURLs := make(map[string][]string) + for _, nk := range multiNodeKeys { + if val := baseCfg[nk]; val != "" && strings.Contains(val, ",") { + parts := strings.Split(val, ",") + trimmed := make([]string, 0, len(parts)) + for _, p := range parts { + if t := strings.TrimSpace(p); t != "" { + trimmed = append(trimmed, t) + } + } + if len(trimmed) > 1 { + nodeURLs[nk] = trimmed + rt.Log("[*] %s → %d 个节点轮询", nk, len(trimmed)) + } + } + } + + // 计算单任务最大并发数(与 RefreshLimits 相同的自适应算法,避免前 10 秒使用过时值) + maxParallel := int(e.computeMaxInflight(platform)) + + // 构建调度条目并加入全局 Dispatcher(Pool Worker 会自动拾取并执行) + entry := &DispatchEntry{ + Key: key, + RT: rt, + Worker: w, + Ctx: ctx, + Cancel: cancel, + Config: baseCfg, + NodeURLs: nodeURLs, + Platform: platform, + done: make(chan struct{}), + } + entry.maxInflight.Store(int32(maxParallel)) + e.dispatcher.Add(entry) + // 新任务加入后立即刷新所有任务的 maxInflight(不等 10 秒定时器,公平分配即时生效) + e.dispatcher.RefreshLimits(func(p string) int32 { + return e.computeMaxInflight(p) + }) + + rt.Log("════════════════════════════════════") + rt.Log("[*] 配置扫描完成,任务已加入调度池(当前 %d 个 Pool Worker,单任务并发上限 %d)", e.workerCount.Load(), maxParallel) + rt.Log("════════════════════════════════════") + + // 监控 goroutine:等待调度条目完成后清理资源 + go func() { + <-entry.done + + success := int(rt.SuccessCount.Load()) + fail := int(rt.FailCount.Load()) + elapsed := time.Since(rt.StartedAt).Round(time.Second) + rt.Log("════════════════════════════════════") + rt.Log("[✓] 任务完成 成功: %d 失败: %d 耗时: %s", success, fail, elapsed) + rt.Log("════════════════════════════════════") + + // 从调度器移除(停止分配新名额给此任务) + e.dispatcher.Remove(key) + // 立即刷新剩余任务的 maxInflight(任务退出后释放的容量即时分配给剩余任务) + e.dispatcher.RefreshLimits(func(p string) int32 { + return e.computeMaxInflight(p) + }) + + // 更新任务状态(区分用户停止 vs 自然完成) + stopNow := time.Now() + finalStatus := "completed" + if ctx.Err() != nil { + // context 被 cancel,检查是否用户主动停止(非达标取消) + total := rt.SuccessCount.Load() + rt.FailCount.Load() + if total < int64(target) { + finalStatus = "stopped" + } + } + e.db.Model(&model.Task{}).Where("id = ?", taskID).Updates(map[string]interface{}{ + "status": finalStatus, + "stopped_at": &stopNow, + }) + + // 退还未消费积分(必须在 close(rt.Done) 之前,确保前端收到 complete 时退款已入库) + // 使用 DB 中的 success_count 作为退款依据(内存计数器可能因 DB 写入失败而偏高) + var task model.Task + if err := e.db.First(&task, taskID).Error; err == nil { + refund := task.CreditsReserved - task.SuccessCount + if refund > 0 { + e.creditSvc.RefundCredits(e.db, userID, taskID, refund) + } + } + + // 关闭 Done channel → 触发 SSE complete 事件(此时退款已到账) + close(rt.Done) + close(rt.LogCh) + + // 清理运行中任务 + 退款锁内存 + e.mu.Lock() + delete(e.running, key) + e.mu.Unlock() + e.creditSvc.CleanupTask(taskID) + + // 释放全局并发槽位 + 平台槽位,并尝试调度下一个排队任务 + e.activeCount.Add(-1) + pc.Add(-1) + if e.scheduler != nil { + e.scheduler.TryDispatch() + } + }() +} + +// StopTask 停止任务 +func (e *TaskEngine) StopTask(userID uint, platform string) bool { + key := taskKey{userID, platform} + e.mu.RLock() + rt, ok := e.running[key] + e.mu.RUnlock() + if !ok || rt.IsDone() { + return false + } + + rt.Log("[!] 用户手动停止任务") + rt.Stopping.Store(true) + rt.Cancel() + e.dispatcher.ForceComplete(key) // 立即标记完成,不等 inflight worker + + // 更新状态 + e.db.Model(&model.Task{}).Where("id = ?", rt.TaskID).Update("status", "stopping") + return true +} + +// GetStatus 获取运行中任务的状态 +func (e *TaskEngine) GetStatus(userID uint, platform string) *RunningTask { + key := taskKey{userID, platform} + e.mu.RLock() + defer e.mu.RUnlock() + rt, ok := e.running[key] + if !ok || rt.IsDone() || rt.Stopping.Load() { + return nil + } + return rt +} + +// FindRunningTaskByID 通过 taskID 查找运行中任务 +func (e *TaskEngine) FindRunningTaskByID(taskID uint) *RunningTask { + e.mu.RLock() + defer e.mu.RUnlock() + for _, rt := range e.running { + if rt.TaskID == taskID { + return rt + } + } + return nil +} + +// RunningTaskInfo 运行中任务的安全快照(不暴露 channel/cancel 等内部字段) +type RunningTaskInfo struct { + TaskID uint `json:"task_id"` + UserID uint `json:"user_id"` + Platform string `json:"platform"` + Target int `json:"target"` + Threads int `json:"threads"` + SuccessCount int64 `json:"success_count"` + FailCount int64 `json:"fail_count"` + StartedAt time.Time `json:"started_at"` + Stopping bool `json:"stopping"` +} + +// ListAllRunning 返回所有运行中任务的快照(管理员用) +func (e *TaskEngine) ListAllRunning() []RunningTaskInfo { + e.mu.RLock() + defer e.mu.RUnlock() + result := make([]RunningTaskInfo, 0, len(e.running)) + for _, rt := range e.running { + if rt.IsDone() { + continue + } + result = append(result, RunningTaskInfo{ + TaskID: rt.TaskID, + UserID: rt.UserID, + Platform: rt.Platform, + Target: rt.Target, + Threads: rt.Threads, + SuccessCount: rt.SuccessCount.Load(), + FailCount: rt.FailCount.Load(), + StartedAt: rt.StartedAt, + Stopping: rt.Stopping.Load(), + }) + } + return result +} + +// AdminStopTask 管理员强制停止任务(通过 taskID 查找) +func (e *TaskEngine) AdminStopTask(taskID uint) bool { + e.mu.RLock() + var found *RunningTask + var foundKey taskKey + for k, rt := range e.running { + if rt.TaskID == taskID && !rt.IsDone() { + found = rt + foundKey = k + break + } + } + e.mu.RUnlock() + + if found == nil { + return false + } + + log.Info().Uint("task_id", taskID).Msg("管理员强制停止任务") + found.Log("[!] 管理员强制停止任务") + found.Stopping.Store(true) + found.Cancel() + e.dispatcher.ForceComplete(foundKey) // 立即标记完成,不等 inflight worker + + e.db.Model(&model.Task{}).Where("id = ?", taskID).Update("status", "stopping") + return true +} + +// getPlatformCount 获取平台并发计数器(惰性初始化) +func (e *TaskEngine) getPlatformCount(platform string) *atomic.Int32 { + e.mu.RLock() + if pc, ok := e.platformCount[platform]; ok { + e.mu.RUnlock() + return pc + } + e.mu.RUnlock() + + e.mu.Lock() + defer e.mu.Unlock() + if pc, ok := e.platformCount[platform]; ok { + return pc + } + pc := &atomic.Int32{} + e.platformCount[platform] = pc + return pc +} + +// saveResult 保存注册结果 +func (e *TaskEngine) saveResult(taskID, userID uint, platform, email string, credential map[string]interface{}) { + credJSON, err := json.Marshal(credential) + if err != nil { + log.Error().Err(err).Uint("task_id", taskID).Msg("credential 序列化失败") + } + result := model.TaskResult{ + TaskID: taskID, + UserID: userID, + Platform: platform, + Email: email, + CredentialData: credJSON, + } + // Grok 向后兼容:提取 auth_token(兼容旧数据 sso_token) + if platform == "grok" { + if token, ok := credential["auth_token"].(string); ok { + result.SSOToken = token + } else if token, ok := credential["sso_token"].(string); ok { + result.SSOToken = token + } + } + if err := e.db.Create(&result).Error; err != nil { + log.Error().Err(err).Uint("task_id", taskID).Str("email", email).Msg("保存注册结果失败") + } +} + +// injectSettings 将系统设置注入到 Worker 配置中(不覆盖已有值) +func (e *TaskEngine) injectSettings(cfg worker.Config) { + // 需要注入的系统设置键列表 + keys := []string{ + "yydsmail_api_key", + "yydsmail_base_url", + "email_provider_priority", + "turnstile_solver_url", + "turnstile_solver_proxy", + "cf_bypass_solver_url", + "capsolver_key", + "yescaptcha_key", + "grok_action_id", + "grok_site_key", + "camoufox_reg_url", + "openai_reg_url", + "kiro_reg_url", + "grok_reg_url", + "grok_email_providers", + "openai_email_providers", + "kiro_email_providers", + "kiro_proxy", + "gemini_reg_url", + "gemini_email_providers", + "gemini_proxy", + "grok_proxy", + "openai_proxy", + "stripe_pk", + } + for _, k := range keys { + if cfg[k] == "" { + if v := e.settingSvc.Get(k, ""); v != "" { + cfg[k] = v + } + } + } +} + +// startWorkerPool 启动 Worker Pool 并开启后台动态伸缩 +// 每 10 秒检查 hf_instance_count × workers_per_instance,自动扩容/缩容 +func (e *TaskEngine) startWorkerPool() { + poolSize := e.calcPoolSize() + e.targetWorkers.Store(int32(poolSize)) + e.workerCount.Store(int32(poolSize)) + for i := 0; i < poolSize; i++ { + go e.runPoolWorker() + } + go e.poolResizer() + log.Info().Int("pool_size", poolSize).Msg("Worker Pool 已启动") +} + +// calcPoolSize 根据 HF 实例数 × 每实例并发数 计算 Pool 大小 +func (e *TaskEngine) calcPoolSize() int { + instances, _ := strconv.Atoi(e.settingSvc.Get("hf_instance_count", "20")) + if instances <= 0 { + instances = 20 + } + perInstance, _ := strconv.Atoi(e.settingSvc.Get("workers_per_instance", "10")) + if perInstance <= 0 { + perInstance = 10 + } + return instances * perInstance +} + +// poolResizer 后台动态伸缩 Worker Pool + 刷新任务并发上限 +// 每 10 秒读取配置,扩容立即生效,缩容等 Worker 干完手头活后退出 +// 同时刷新所有活跃任务的 maxInflight,使 SyncPoolSize 的动态参数热生效 +func (e *TaskEngine) poolResizer() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + for range ticker.C { + desired := int32(e.calcPoolSize()) + e.targetWorkers.Store(desired) + + current := e.workerCount.Load() + if desired > current { + // 扩容:立即补充差额 Worker + diff := desired - current + for i := int32(0); i < diff; i++ { + e.workerCount.Add(1) + go e.runPoolWorker() + } + log.Info().Int32("old", current).Int32("new", desired).Int32("spawned", diff).Msg("Worker Pool 扩容") + } else if desired < current { + // 缩容:多余 Worker 完成当前任务后自动退出 + log.Info().Int32("current", current).Int32("target", desired).Msg("Worker Pool 缩容中") + e.dispatcher.Wake() // 唤醒空闲 Worker 让它们检查退出条件 + } + + // 动态刷新活跃任务的 maxInflight(复用 computeMaxInflight 统一算法) + e.dispatcher.RefreshLimits(func(p string) int32 { + return e.computeMaxInflight(p) + }) + } +} + +// runPoolWorker 单个 Pool Worker 的主循环 +// 不断从 Dispatcher 获取下一个注册任务并执行,空闲时阻塞等待 +func (e *TaskEngine) runPoolWorker() { + defer e.workerCount.Add(-1) + for { + // 缩容检查:超过目标数的 Worker 主动退出 + if e.workerCount.Load() > e.targetWorkers.Load() { + return + } + job, ok := e.dispatcher.Next(e.proxyPool) + if !ok { + return // Dispatcher 已关闭(服务关停) + } + e.executeRegJob(job) + } +} + +// executeRegJob 执行单次注册任务(含重试逻辑) +// 由 Pool Worker 调用,完成后通知 DispatchEntry 检查任务是否全部结束 +func (e *TaskEngine) executeRegJob(job *RegJob) { + entry := job.Entry + rt := entry.RT + + defer func() { + entry.inflight.Add(-1) + entry.checkCompletion() + e.dispatcher.Wake() // 释放并发槽位,唤醒等待中的 Worker + }() + + const maxRetries = 3 + + // openai_team: 从卡池分配卡并注入配置 + var cardID uint + if rt.Platform == "openai_team" && e.cardPool != nil { + card := e.cardPool.GetNext() + if card == nil { + rt.Log("[-] 卡池无可用卡,跳过") + rt.ClaimedCount.Add(-1) + rt.FailCount.Add(1) + rt.NotifyStatus() + e.db.Model(&model.Task{}).Where("id = ?", rt.TaskID). + Update("fail_count", gorm.Expr("fail_count + 1")) + e.dispatcher.Wake() + return + } + cardID = card.ID + job.Cfg["card_number"] = card.CardNumber + job.Cfg["exp_month"] = strconv.Itoa(card.ExpMonth) + job.Cfg["exp_year"] = strconv.Itoa(card.ExpYear) + job.Cfg["cvc"] = card.CVC + job.Cfg["billing_name"] = card.BillingName + job.Cfg["billing_email"] = card.BillingEmail + job.Cfg["billing_country"] = card.BillingCountry + job.Cfg["billing_city"] = card.BillingCity + job.Cfg["billing_line1"] = card.BillingLine1 + job.Cfg["billing_zip"] = card.BillingZip + } + + for retryN := 1; retryN <= maxRetries; retryN++ { + // 用户已停止 → 立即退出,不再尝试注册 + if entry.Ctx.Err() != nil { + break + } + var succeeded, failed bool + entry.Worker.RegisterOne(entry.Ctx, worker.RegisterOpts{ + Proxy: job.Proxy, + Config: job.Cfg, + LogCh: rt.LogCh, + OnSuccess: func(email string, credential map[string]interface{}) { + succeeded = true + newCount := rt.SuccessCount.Add(1) + rt.NotifyStatus() + // 实时推送成功账号到前端日志(序号+邮箱) + rt.Log("[✓] #%d %s", newCount, email) + e.saveResult(rt.TaskID, rt.UserID, rt.Platform, email, credential) + // 原子递增防止并发 worker 互相覆盖 + e.db.Model(&model.Task{}).Where("id = ?", rt.TaskID). + Update("success_count", gorm.Expr("success_count + 1")) + // 卡池生命周期:标记卡已使用 + if cardID > 0 && e.cardPool != nil { + e.cardPool.MarkUsed(cardID) + } + if newCount >= int64(rt.Target) { + entry.Cancel() + } + }, + OnFail: func() { + failed = true + }, + }) + if succeeded || !failed { + break // 成功或 ctx 已取消,退出重试 + } + if retryN < maxRetries { + // 中间重试不推送前端,静默重试 + } else { + rt.Log("[-] 已重试 %d 次,计入失败", maxRetries) + rt.ClaimedCount.Add(-1) // 释放名额,让 Pool Worker 可以重新认领 + // 卡池生命周期:标记卡失败 + if cardID > 0 && e.cardPool != nil { + e.cardPool.MarkFailed(cardID) + } + newCount := rt.FailCount.Add(1) + rt.NotifyStatus() + // 原子递增防止并发 worker 互相覆盖 + e.db.Model(&model.Task{}).Where("id = ?", rt.TaskID). + Update("fail_count", gorm.Expr("fail_count + 1")) + if newCount >= int64(rt.Target) { + entry.Cancel() + } + e.dispatcher.Wake() // 唤醒等待中的 Worker,重新认领释放的名额 + } + } +} + +// ─── ScanConfig 全局缓存 ─── + +// startConfigRefresher 后台定时刷新所有平台的扫描配置 +// 首次延迟 10 秒(等服务就绪),之后每 5 分钟刷新一次 +func (e *TaskEngine) startConfigRefresher() { + time.Sleep(10 * time.Second) + e.refreshAllConfigs() + + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + for range ticker.C { + e.refreshAllConfigs() + } +} + +// refreshAllConfigs 刷新所有已注册平台的配置缓存 +func (e *TaskEngine) refreshAllConfigs() { + for name, w := range worker.Registry { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + baseCfg := worker.Config{} + e.injectSettings(baseCfg) + + scanned, err := w.ScanConfig(ctx, e.proxyPool.GetNext(), baseCfg) + cancel() + if err != nil { + log.Warn().Str("platform", name).Err(err).Msg("配置缓存刷新失败(保留旧缓存)") + continue + } + + e.updateConfigCache(name, scanned) + log.Info().Str("platform", name).Msg("配置缓存已刷新") + } +} + +// getCachedConfig 获取平台的缓存配置(10 分钟内有效) +func (e *TaskEngine) getCachedConfig(platform string) (worker.Config, bool) { + e.configCacheMu.RLock() + defer e.configCacheMu.RUnlock() + cc, ok := e.configCache[platform] + if !ok || time.Since(cc.scannedAt) > 10*time.Minute { + return nil, false + } + // 返回副本,避免并发修改 + cp := make(worker.Config, len(cc.config)) + for k, v := range cc.config { + cp[k] = v + } + return cp, true +} + +// updateConfigCache 更新平台配置缓存 +func (e *TaskEngine) updateConfigCache(platform string, cfg worker.Config) { + e.configCacheMu.Lock() + defer e.configCacheMu.Unlock() + e.configCache[platform] = &cachedConfig{ + config: cfg, + scannedAt: time.Now(), + } +} diff --git a/internal/worker/concurrency.go b/internal/worker/concurrency.go new file mode 100644 index 0000000..eeb59f7 --- /dev/null +++ b/internal/worker/concurrency.go @@ -0,0 +1,164 @@ +package worker + +import ( + "net/http" + "os" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/gin-gonic/gin" +) + +// 并发限流 + 排队等待中间件 +// +// HF Space 免费容器资源有限(2 vCPU / 16GB RAM),每个注册请求 2-5 分钟。 +// 设计: +// - 处理槽(semaphore):同时处理 MAX_CONCURRENT 个请求(默认 5) +// - 等待队列:最多 MAX_QUEUE 个请求排队等待(默认 10) +// - 队列满:返回 503,CF Worker 自动转发下一节点 +// - 排队中:阻塞等待,轮到后自动处理,响应中附带排队等待时长 +// - 平均耗时追踪:基于最近完成的请求计算 ETA + +var ( + maxConcurrent int32 // 最大并发处理数 + maxQueue int32 // 最大排队等待数 + activeCount int64 // 当前正在处理的请求数 + waitingCount int64 // 当前排队等待的请求数 + totalServed int64 // 累计完成请求数(用于计算平均耗时) + + // 滑动窗口:最近 20 个请求的处理耗时(纳秒) + durationWindow [20]int64 + durationIdx int64 + durationMu sync.Mutex +) + +func init() { + // 默认值适配 HF Space 免费实例(2 vCPU / 16GB) + // Mac Mini 等高配环境通过 ENV 覆盖 + maxConcurrent = 5 + maxQueue = 10 + if v := os.Getenv("MAX_CONCURRENT"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + maxConcurrent = int32(n) + } + } + if v := os.Getenv("MAX_QUEUE"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + maxQueue = int32(n) + } + } +} + +// recordDuration 记录一次请求的处理耗时(用于 ETA 估算) +func recordDuration(d time.Duration) { + durationMu.Lock() + idx := durationIdx % int64(len(durationWindow)) + durationWindow[idx] = int64(d) + durationIdx++ + durationMu.Unlock() + atomic.AddInt64(&totalServed, 1) +} + +// avgDuration 返回最近请求的平均处理耗时 +func avgDuration() time.Duration { + durationMu.Lock() + defer durationMu.Unlock() + + count := durationIdx + if count == 0 { + return 3 * time.Minute // 无历史数据,默认 3 分钟 + } + if count > int64(len(durationWindow)) { + count = int64(len(durationWindow)) + } + var sum int64 + for i := int64(0); i < count; i++ { + sum += durationWindow[i] + } + return time.Duration(sum / count) +} + +// ConcurrencyLimiter 返回 Gin 中间件 +// +// 流程: +// 1. 尝试立即获取处理槽 → 成功则直接处理 +// 2. 处理槽满 → 检查排队是否有空位 +// 3. 有空位 → 阻塞等待,轮到后处理(响应附带等待时长) +// 4. 队列也满 → 返回 503(CF Worker 转发下一节点) +func ConcurrencyLimiter() gin.HandlerFunc { + sem := make(chan struct{}, maxConcurrent) + + return func(c *gin.Context) { + start := time.Now() + + // 尝试立即获取处理槽 + select { + case sem <- struct{}{}: + atomic.AddInt64(&activeCount, 1) + defer func() { + <-sem + atomic.AddInt64(&activeCount, -1) + recordDuration(time.Since(start)) + }() + c.Next() + return + default: + } + + // 处理槽满,检查排队空位 + currentWaiting := atomic.LoadInt64(&waitingCount) + if currentWaiting >= int64(maxQueue) { + // 队列也满,返回 503(中性化响应,不暴露内部状态) + c.JSON(http.StatusServiceUnavailable, gin.H{ + "ok": false, + "error": "service temporarily unavailable", + "retry": true, + }) + c.Abort() + return + } + + // 进入排队 + pos := atomic.AddInt64(&waitingCount, 1) + defer atomic.AddInt64(&waitingCount, -1) + + // 阻塞等待处理槽释放(受请求 context 超时控制) + select { + case sem <- struct{}{}: + // 轮到了 + waitDuration := time.Since(start) + atomic.AddInt64(&activeCount, 1) + defer func() { + <-sem + atomic.AddInt64(&activeCount, -1) + recordDuration(time.Since(start)) + }() + // 在 context 中记录排队信息,handler 可以读取并写入响应日志 + c.Set("queue_waited", true) + c.Set("queue_position", pos) + c.Set("queue_wait_seconds", int(waitDuration.Seconds())) + c.Next() + + case <-c.Request.Context().Done(): + // 请求超时或被取消 + c.JSON(http.StatusServiceUnavailable, gin.H{ + "ok": false, + "error": "request timeout", + "retry": true, + }) + c.Abort() + } + } +} + +// ConcurrencyStats 返回当前并发和排队状态(供 /health 端点使用,精简字段) +func ConcurrencyStats() gin.H { + return gin.H{ + "load": atomic.LoadInt64(&activeCount), + "queue": atomic.LoadInt64(&waitingCount), + "cap": maxConcurrent, + "avg": int(avgDuration().Seconds()), + } +} diff --git a/internal/worker/gemini_worker.go b/internal/worker/gemini_worker.go new file mode 100644 index 0000000..2462db2 --- /dev/null +++ b/internal/worker/gemini_worker.go @@ -0,0 +1,313 @@ +package worker + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" +) + +// GeminiWorker Gemini Business 平台注册器 +// 注册流程:临时邮箱 → 调用 Python 浏览器服务 → Camoufox 完成 Google 登录 → 提取 Cookies/Session +// 复用 aws-builder-id-reg 服务(/gemini/process 端点),共享 Camoufox 浏览器基础设施 +type GeminiWorker struct{} + +func init() { + Register(&GeminiWorker{}) +} + +func (w *GeminiWorker) PlatformName() string { return "gemini" } + +// ScanConfig Gemini 无需预扫描 +func (w *GeminiWorker) ScanConfig(_ context.Context, _ *ProxyEntry, cfg Config) (Config, error) { + return cfg, nil +} + +// RegisterOne 执行一次 Gemini Business 注册 +func (w *GeminiWorker) RegisterOne(ctx context.Context, opts RegisterOpts) { + cfg := opts.Config + + // ── 读取注册服务地址(复用 Kiro 的 Python 服务,增加 /gemini/process 端点)── + geminiRegURL := strings.TrimRight(cfg["gemini_reg_url"], "/") + if geminiRegURL == "" { + // 默认与 Kiro 服务同地址(同一个 Python 进程提供 /kiro/process 和 /gemini/process) + geminiRegURL = strings.TrimRight(cfg["kiro_reg_url"], "/") + } + if geminiRegURL == "" { + logSend(opts.LogCh, "[!] Gemini 注册服务地址未配置(gemini_reg_url 或 kiro_reg_url),请在系统设置中填写") + opts.OnFail() + return + } + + // ── Step 1: 通过 MultiProvider 创建临时邮箱 ── + logSend(opts.LogCh, "[*] Gemini 注册开始,创建临时邮箱...") + + // 复制 config map,避免并发修改共享数据(data race) + localCfg := make(Config, len(cfg)) + for k, v := range cfg { + localCfg[k] = v + } + if platformProviders := localCfg["gemini_email_providers"]; platformProviders != "" { + localCfg["email_provider_priority"] = platformProviders + } + mailProvider := tempmail.NewMultiProvider(localCfg) + email, mailMeta, err := mailProvider.GenerateEmail(ctx) + if err != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 创建邮箱失败: %v", err)) + opts.OnFail() + return + } + logSend(opts.LogCh, fmt.Sprintf("[+] 邮箱已创建: %s (via %s)", email, mailMeta["provider"])) + + // ── 随机启动抖动,分散并发请求 ── + jitter := time.Duration(rand.Intn(3000)) * time.Millisecond + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + // ── Step 2: 组装代理参数 ── + proxyStr := "" + if userProxy := cfg["user_proxy"]; userProxy != "" { + proxyStr = userProxy + logSend(opts.LogCh, "[*] 使用用户指定代理") + } else if geminiProxy := cfg["gemini_proxy"]; geminiProxy != "" { + proxyStr = geminiProxy + } else if opts.Proxy != nil && opts.Proxy.HTTP != "" { + proxyStr = opts.Proxy.HTTP + } + + // ── Step 3: 调用浏览器注册服务(最多重试 3 次,每次换新邮箱)── + const maxRetry = 3 + var result *geminiRegResponse + var rawJSON json.RawMessage + var callErr error + for attempt := 1; attempt <= maxRetry; attempt++ { + if attempt > 1 { + logSend(opts.LogCh, fmt.Sprintf("[*] 第 %d 次重试(换新邮箱)...", attempt)) + newEmail, newMeta, newErr := mailProvider.GenerateEmail(ctx) + if newErr != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 重试创建邮箱失败: %v", newErr)) + break + } + go func(e string, m map[string]string) { + dCtx, dCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer dCancel() + mailProvider.DeleteEmail(dCtx, e, m) + }(email, mailMeta) + email = newEmail + mailMeta = newMeta + logSend(opts.LogCh, fmt.Sprintf("[+] 重试邮箱: %s (via %s)", email, mailMeta["provider"])) + } + logSend(opts.LogCh, "[*] 调用浏览器注册服务(Camoufox 指纹伪造)...") + result, rawJSON, callErr = w.callRegService(ctx, geminiRegURL, email, proxyStr, mailMeta, cfg, opts.LogCh) + if callErr != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 注册服务调用失败: %v", callErr)) + break + } + if result.OK || !result.Retriable { + break + } + logSend(opts.LogCh, fmt.Sprintf("[!] 注册被拒绝(可重试): %s,准备重试...", result.Error)) + } + + // ── Step 4: 处理结果 ── + if callErr != nil { + go func(e string, m map[string]string) { + dCtx, dCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer dCancel() + mailProvider.DeleteEmail(dCtx, e, m) + }(email, mailMeta) + opts.OnFail() + return + } + if result == nil || !result.OK { + errMsg := "" + if result != nil { + errMsg = result.Error + } + logSend(opts.LogCh, fmt.Sprintf("[!] Gemini 注册失败: %s", errMsg)) + go func(e string, m map[string]string) { + dCtx, dCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer dCancel() + mailProvider.DeleteEmail(dCtx, e, m) + }(email, mailMeta) + opts.OnFail() + return + } + + // 解析完整 JSON,保留远程服务返回的所有字段 + credential := map[string]interface{}{} + if err := json.Unmarshal(rawJSON, &credential); err != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 响应 JSON 解析异常: %v", err)) + go func(e string, m map[string]string) { + dCtx, dCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer dCancel() + mailProvider.DeleteEmail(dCtx, e, m) + }(email, mailMeta) + opts.OnFail() + return + } + // 校验关键凭证字段(防止空壳数据入库) + if credential["email"] == nil || credential["config_id"] == nil { + logSend(opts.LogCh, "[!] 远程服务返回不完整凭证(缺少 email/config_id),跳过保存") + go func(e string, m map[string]string) { + dCtx, dCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer dCancel() + mailProvider.DeleteEmail(dCtx, e, m) + }(email, mailMeta) + opts.OnFail() + return + } + delete(credential, "ok") + delete(credential, "error") + delete(credential, "retriable") + delete(credential, "logs") + // 补充元数据 + credential["platform"] = "gemini" + credential["type"] = "gemini-business" + credential["auth_method"] = "google-otp" + credential["provider"] = "Google" + credential["disabled"] = false + credential["mail_provider"] = mailMeta["provider"] + tempmail.RecordSuccess(mailMeta["provider"]) + emailMetaCopy := make(map[string]interface{}, len(mailMeta)) + for k, v := range mailMeta { + emailMetaCopy[k] = v + } + credential["email_meta"] = emailMetaCopy + + if result.Warning != "" { + credential["warning"] = result.Warning + logSend(opts.LogCh, fmt.Sprintf("[~] 提示: %s", result.Warning)) + } + + logSend(opts.LogCh, fmt.Sprintf("[+] Gemini 注册成功: %s (config_id=%s)", result.Email, result.ConfigID)) + + delCtx, delCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer delCancel() + if err := mailProvider.DeleteEmail(delCtx, email, mailMeta); err != nil { + log.Warn().Str("email", email).Err(err).Msg("Gemini: 清理邮箱失败(非关键)") + } + + opts.OnSuccess(result.Email, credential) +} + +// ─── 内部类型 ──────────────────────────────────────────────────────────────── + +// geminiRegRequest Python 注册服务请求体 +type geminiRegRequest struct { + Email string `json:"email"` + Proxy string `json:"proxy,omitempty"` + YYDSMailURL string `json:"yydsmail_url,omitempty"` + YYDSMailKey string `json:"yydsmail_key,omitempty"` + MailProvider string `json:"mail_provider,omitempty"` + MailMeta map[string]string `json:"mail_meta,omitempty"` +} + +// geminiRegResponse Python 注册服务响应体 +type geminiRegResponse struct { + OK bool `json:"ok"` + Email string `json:"email"` + ConfigID string `json:"config_id"` + Csesidx string `json:"csesidx"` + CSes string `json:"c_ses"` + COses string `json:"c_oses"` + TrialEndDate string `json:"trial_end_date"` + ExpiresAt string `json:"expires_at"` + Warning string `json:"warning"` + Error string `json:"error"` + Retriable bool `json:"retriable"` +} + +// ─── 内部方法 ──────────────────────────────────────────────────────────────── + +// callRegService 调用 Python 服务执行 Gemini 浏览器注册(流式日志协议) +func (w *GeminiWorker) callRegService( + ctx context.Context, + baseURL, email, proxy string, + mailMeta map[string]string, + cfg Config, + logCh chan<- string, +) (*geminiRegResponse, json.RawMessage, error) { + reqBody := geminiRegRequest{ + Email: email, + Proxy: proxy, + YYDSMailURL: strings.TrimRight(cfg["yydsmail_base_url"], "/"), + YYDSMailKey: cfg["yydsmail_api_key"], + MailProvider: mailMeta["provider"], + MailMeta: mailMeta, + } + if reqBody.YYDSMailURL == "" { + reqBody.YYDSMailURL = "" + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, fmt.Errorf("序列化请求失败: %w", err) + } + + // Python 端 RESPONSE_TIMEOUT=400s + 浏览器启动/关闭开销,Go 侧需要留足余量 + callCtx, cancel := context.WithTimeout(ctx, 7*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(callCtx, http.MethodPost, + baseURL+"/gemini/process", bytes.NewReader(body)) + if err != nil { + return nil, nil, fmt.Errorf("构建 HTTP 请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + // 安全兜底超时(比 context 的 7 分钟略长),防止 context cancel 泄漏时连接永久挂起 + client := &http.Client{Timeout: 8 * time.Minute} + + resp, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP 请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var preview [200]byte + n, _ := resp.Body.Read(preview[:]) + return nil, nil, fmt.Errorf("服务返回 HTTP %d: %s", resp.StatusCode, string(preview[:n])) + } + + // 逐行读取流式响应:LOG: 前缀为日志,最后一行为 JSON 结果 + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + var lastLine string + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "LOG:") { + msg := strings.TrimSpace(strings.TrimPrefix(line, "LOG:")) + if msg != "" && msg != "." { + logSend(logCh, "[~] "+msg) + } + } else if line != "" { + lastLine = line + } + } + if err := scanner.Err(); err != nil { + return nil, nil, fmt.Errorf("读取流式响应失败: %w", err) + } + if lastLine == "" { + return nil, nil, fmt.Errorf("服务未返回结果行") + } + + var result geminiRegResponse + if err := json.Unmarshal([]byte(lastLine), &result); err != nil { + return nil, nil, fmt.Errorf("解析响应失败: %w", err) + } + + return &result, json.RawMessage(lastLine), nil +} diff --git a/internal/worker/grok_api.go b/internal/worker/grok_api.go new file mode 100644 index 0000000..599e618 --- /dev/null +++ b/internal/worker/grok_api.go @@ -0,0 +1,456 @@ +package worker + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/grpcweb" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/turnstile" +) + +// grokRegRequest 远程 Grok 注册请求(HF 端接收) +type grokRegRequest struct { + Proxy string `json:"proxy"` + YYDSMailURL string `json:"yydsmail_url"` + YYDSMailKey string `json:"yydsmail_key"` + EmailPriority string `json:"email_priority"` + TurnstileSolverURL string `json:"turnstile_solver_url"` + TurnstileSolverProxy string `json:"turnstile_solver_proxy"` + CFBypassSolverURL string `json:"cf_bypass_solver_url"` + CapSolverKey string `json:"capsolver_key"` + YesCaptchaKey string `json:"yescaptcha_key"` + SiteKey string `json:"site_key"` + ActionID string `json:"action_id"` + StateTree string `json:"state_tree"` +} + +// GrokProtocolRegisterHandler 完整 Grok 注册端点(HF Space 独立运行) +// POST /api/v1/process +// 供远程调用:日本后端 → CF Workers → HF Space +// 流程: 创建邮箱 → gRPC-web 发/收验证码 → Turnstile → Server Action → SSO → NSFW +func GrokProtocolRegisterHandler(c *gin.Context) { + var req grokRegRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "invalid request: " + err.Error()}) + return + } + + // 构建 multi-provider 邮箱配置 + priority := req.EmailPriority + if priority == "" { + priority = "yydsmail" + } + cfg := map[string]string{ + "yydsmail_api_key": req.YYDSMailKey, + "yydsmail_base_url": req.YYDSMailURL, + "email_provider_priority": priority, + } + mailProvider := tempmail.NewMultiProvider(cfg) + + // 日志收集 + logs := make([]string, 0, 30) + logf := func(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + logs = append(logs, msg) + fmt.Printf("[LOG] %s\n", msg) // 同时打印到 stdout,方便调试 + } + + // 诊断日志:记录收到的配置 + logf("[*] 邮箱优先级: %s", priority) + creds := "" + if req.YYDSMailKey != "" { + creds += "yydsmail " + } + if creds == "" { + creds = "(未配置凭证)" + } + logf("[*] 可用凭证: %s", creds) + if req.Proxy != "" { + logf("[*] 代理: %s", req.Proxy) + } + + // 排队等待信息(由 ConcurrencyLimiter 中间件注入 gin context) + if waited, ok := c.Get("queue_waited"); ok && waited.(bool) { + pos, _ := c.Get("queue_position") + waitSec, _ := c.Get("queue_wait_seconds") + logf("[*] 排队等待完成: 位置 #%d, 等待 %d 秒", pos, waitSec) + } + + // 构建代理 + var proxy *ProxyEntry + if req.Proxy != "" { + proxy = &ProxyEntry{HTTPS: req.Proxy, HTTP: req.Proxy} + } + + // 执行上下文(5 分钟超时,覆盖 Turnstile + 验证码轮询 + 注册 + NSFW) + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) + defer cancel() + + w := &GrokWorker{} + profile := browserProfiles[rand.Intn(len(browserProfiles))] + logf("[*] 任务开始") + + // ─── 创建 HTTP 会话 ─── + client := w.makeHTTPClient(proxy, 30*time.Second) + + // 会话预热(获取 __cf_bm cookie) + warmupReq, _ := http.NewRequestWithContext(ctx, "GET", "https://accounts.x.ai", nil) + warmupReq.Header.Set("User-Agent", profile.UserAgent) + if resp, err := client.Do(warmupReq); err == nil { + resp.Body.Close() + } + + // ─── Phase 0: 配置扫描 + Solver 初始化(在邮箱创建之前,避免浪费邮箱) ─── + + // Turnstile solver 配置(优先请求参数,Mac Mini 环境下回退到容器环境变量) + var solverURLs []string + if req.TurnstileSolverURL != "" { + solverURLs = append(solverURLs, req.TurnstileSolverURL) + } else if envURL := os.Getenv("TURNSTILE_SOLVER_URL"); envURL != "" { + solverURLs = append(solverURLs, envURL) + logf("[*] Turnstile solver: %s (环境变量)", envURL) + } + if req.CFBypassSolverURL != "" { + solverURLs = append(solverURLs, req.CFBypassSolverURL) + } else if envURL := os.Getenv("CF_BYPASS_SOLVER_URL"); envURL != "" { + solverURLs = append(solverURLs, envURL) + logf("[*] CF bypass solver: %s (环境变量)", envURL) + } + // Solver 专用代理:优先用面板配的 turnstile_solver_proxy,没有才用注册代理 + var solverProxyURL string + if req.TurnstileSolverProxy != "" { + solverProxyURL = req.TurnstileSolverProxy + } else if proxy != nil { + if proxy.HTTPS != "" { + solverProxyURL = proxy.HTTPS + } else if proxy.HTTP != "" { + solverProxyURL = proxy.HTTP + } + } + solver := turnstile.NewSolver(solverURLs, req.CapSolverKey, req.YesCaptchaKey, solverProxyURL) + + siteKey := req.SiteKey + if siteKey == "" { + siteKey = "0x4AAAAAAAhr9JGVDZbrZOo0" // 默认 fallback + } + actionID := req.ActionID + stateTree := req.StateTree + if stateTree == "" { + stateTree = defaultStateTree + } + + // action_id 为空时自行扫描(HF Space 可直连 x.ai) + if actionID == "" { + logf("[*] 注册配置未提供,自行扫描获取...") + scanner := &GrokWorker{} + scanned, scanErr := scanner.doScan(ctx, proxy) + if scanErr == nil { + if aid := scanned["action_id"]; aid != "" { + actionID = aid + logf("[+] 注册配置已获取") + } + if sk := scanned["site_key"]; sk != "" { + siteKey = sk + } + if st := scanned["state_tree"]; st != "" { + stateTree = st + } + } else { + logf("[-] 自行扫描失败: %s", scanErr) + } + } + + if actionID == "" { + logf("[-] 缺少注册配置且扫描失败,无法注册") + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "missing configuration", "logs": logs}) + return + } + + // ─── Phase 1: 创建临时邮箱 ─── + email, mailMeta, err := mailProvider.GenerateEmail(ctx) + if err != nil { + logf("[-] 创建邮箱失败: %s", err) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "initialization failed: " + err.Error(), "logs": logs}) + return + } + password := randomString(15, "abcdefghijklmnopqrstuvwxyz0123456789") + logf("[*] 邮箱: %s", email) + + // 注册失败时清理邮箱 + defer func() { + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + }() + + // ─── Phase 2: 发送验证码 (gRPC-web) ─── + logf("[*] 发送验证码...") + grpcBody := grpcweb.EncodeEmailCode(email) + sendResp, err := w.doGRPCWeb(ctx, client, profile.UserAgent, + "https://accounts.x.ai/auth_mgmt.AuthManagement/CreateEmailValidationCode", + "https://accounts.x.ai", "https://accounts.x.ai/sign-up?redirect=grok-com", + grpcBody, nil) + if err != nil || sendResp == nil || sendResp.StatusCode != 200 { + status := 0 + if sendResp != nil { + status = sendResp.StatusCode + sendResp.Body.Close() + } + logf("[-] 发送验证码失败: HTTP %d, %v", status, err) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "verification failed", "email": email, "logs": logs}) + return + } + sendResp.Body.Close() + logf("[+] 验证码已发送") + + // ─── Phase 3: 获取验证码 ─── + logf("[*] 等待验证码(最长 45s)...") + var code string + for attempt := 1; attempt <= 45; attempt++ { + select { + case <-ctx.Done(): + logf("[-] 等待验证码时任务被取消") + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "cancelled", "email": email, "logs": logs}) + return + case <-time.After(1 * time.Second): + } + c2, err := mailProvider.FetchVerificationCode(ctx, email, mailMeta, 1, 0) + if err == nil && c2 != "" { + code = c2 + break + } + if attempt%5 == 0 { + logf("[*] 等待验证码中... 已等待 %ds", attempt) + } + } + if code == "" { + logf("[-] 获取验证码超时") + tempmail.RecordFailure(mailMeta["provider"]) + c.JSON(http.StatusOK, gin.H{ + "ok": false, "error": "verification code timeout", + "email": email, "provider": mailMeta["provider"], "logs": logs, + }) + return + } + logf("[+] 验证码: %s", code) + tempmail.RecordSuccess(mailMeta["provider"]) + + // ─── Phase 4: 验证邮箱 (gRPC-web) ─── + verifyBody := grpcweb.EncodeVerifyCode(email, code) + verifyResp, err := w.doGRPCWeb(ctx, client, profile.UserAgent, + "https://accounts.x.ai/auth_mgmt.AuthManagement/VerifyEmailValidationCode", + "https://accounts.x.ai", "https://accounts.x.ai/sign-up?redirect=grok-com", + verifyBody, nil) + if err != nil || verifyResp == nil || verifyResp.StatusCode != 200 { + if verifyResp != nil { + verifyResp.Body.Close() + } + logf("[-] 验证邮箱失败") + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "validation failed", "email": email, "logs": logs}) + return + } + verifyResp.Body.Close() + logf("[+] 邮箱验证成功") + + // ─── Phase 5+6: Turnstile + Server Actions 注册 ─── + firstName := randomName(4, 6) + lastName := randomName(4, 6) + + var ssoToken string + registered := false + + for attempt := 0; attempt < 3; attempt++ { + select { + case <-ctx.Done(): + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "timeout", "email": email, "logs": logs}) + return + default: + } + + logf("[*] 注册尝试 %d/3...", attempt+1) + + // Turnstile 求解 + logf("[*] 验证远程服务接口...") + turnstileToken, err := solver.Solve(ctx, "https://accounts.x.ai/sign-up", siteKey, 3, logf) + if err != nil { + logf("[-] 验证失败: %s", err) + ctxSleep(ctx, 3*time.Second) + continue + } + logf("[+] 卧槽Σ(°ロ°)验证通过 (%d chars)", len(turnstileToken)) + + // 用同一个 Turnstile token 提交注册,404 时重新扫描 action_id 并立即重试(最多 2 次) + var respText string + regOK := false + for regRetry := 0; regRetry < 2; regRetry++ { + regPayload := []map[string]interface{}{{ + "emailValidationCode": code, + "createUserAndSessionRequest": map[string]interface{}{ + "email": email, + "givenName": firstName, + "familyName": lastName, + "clearTextPassword": password, + "tosAcceptedVersion": "$undefined", + }, + "turnstileToken": turnstileToken, + "promptOnDuplicateEmail": true, + }} + jsonBody, _ := json.Marshal(regPayload) + + regReq, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.x.ai/sign-up", bytes.NewReader(jsonBody)) + regReq.Header.Set("Content-Type", "text/plain;charset=UTF-8") + regReq.Header.Set("Accept", "text/x-component") + regReq.Header.Set("Next-Action", actionID) + regReq.Header.Set("Next-Router-State-Tree", stateTree) + regReq.Header.Set("Origin", "https://accounts.x.ai") + regReq.Header.Set("Referer", "https://accounts.x.ai/sign-up") + regReq.Header.Set("User-Agent", profile.UserAgent) + + regResp, err := client.Do(regReq) + if err != nil { + logf("[-] 注册请求失败: %s", err) + break + } + respBody, _ := io.ReadAll(regResp.Body) + regResp.Body.Close() + respText = string(respBody) + + if regResp.StatusCode == 200 { + regOK = true + break + } + + logf("[-] 注册响应: HTTP %d", regResp.StatusCode) + // action_id 过期 → 重新扫描刷新,用同一个 token 立即重试 + if regResp.StatusCode == 404 && strings.Contains(respText, "action not found") { + logf("[*] 注册配置已过期,重新扫描...") + scanner := &GrokWorker{} + if scanned, scanErr := scanner.doScan(ctx, proxy); scanErr == nil { + if aid := scanned["action_id"]; aid != "" { + actionID = aid + logf("[+] 注册配置已刷新,用同一个 token 立即重试") + } + if st := scanned["state_tree"]; st != "" { + stateTree = st + } + ctxSleep(ctx, 1*time.Second) + continue // 内层循环重试,不重新求解 Turnstile + } else { + logf("[-] 重新扫描失败: %s", scanErr) + } + } + break // 非 404 错误直接退出内层循环 + } + if !regOK { + ctxSleep(ctx, 3*time.Second) + continue + } + + // 提取 set-cookie URL + var cookieURLMatch string + if sm := reCookieURLAnchored.FindStringSubmatch(respText); len(sm) > 1 { + cookieURLMatch = sm[1] + } else if sm := reCookieURLQuoted.FindStringSubmatch(respText); len(sm) > 1 { + cookieURLMatch = sm[1] + } + if cookieURLMatch == "" { + logf("[-] 会话处理失败,响应异常") + ctxSleep(ctx, 3*time.Second) + continue + } + + // 请求 set-cookie URL 获取 SSO token + cookieReq, _ := http.NewRequestWithContext(ctx, "GET", cookieURLMatch, nil) + cookieReq.Header.Set("User-Agent", profile.UserAgent) + cookieResp, err := client.Do(cookieReq) + if err != nil { + logf("[-] 会话回调失败: %s", err) + break + } + + for _, sc := range cookieResp.Header["Set-Cookie"] { + if strings.HasPrefix(sc, "sso=") && !strings.HasPrefix(sc, "sso-rw=") { + parts := strings.SplitN(sc, ";", 2) + kv := strings.SplitN(parts[0], "=", 2) + if len(kv) == 2 && kv[1] != "" { + ssoToken = kv[1] + } + } + } + cookieResp.Body.Close() + + // 降级: cookie jar 里找 sso + if ssoToken == "" { + for _, domain := range []string{ + cookieResp.Request.URL.String(), + "https://accounts.x.ai", + "https://grok.com", + "https://x.ai", + } { + domainURL, err := url.Parse(domain) + if err != nil { + continue + } + for _, ck := range client.Jar.Cookies(domainURL) { + if ck.Name == "sso" && ssoToken == "" { + ssoToken = ck.Value + } + } + } + } + + if ssoToken == "" { + logf("[-] 未获取到登录凭证") + break + } + + logf("[+] 注册成功!") + registered = true + break + } + + if !registered { + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "process failed", "email": email, "logs": logs}) + return + } + + // ─── Phase 7+8: TOS + 生日 + NSFW + Unhinged + PayUrl ─── + // HF 上只用 enableNSFWGo(纯 Go),不带 Python fallback + logf("[*] 马上处理..设置中...") + nsfwEnabled, checkoutURL := w.enableNSFWGo(ctx, ssoToken, email, proxy, logf) + if nsfwEnabled { + logf("[+] 马上处理..成功啦") + } else { + logf("[!] 马上处理..失败(账号已创建,继续返回)") + } + + logf("[OK] 任务完成: %s", email) + + // 注入邮箱元数据,供后续 OTP 查询使用 + emailMetaCopy := make(map[string]interface{}, len(mailMeta)) + for k, v := range mailMeta { + emailMetaCopy[k] = v + } + result := gin.H{ + "ok": true, + "email": email, + "auth_token": ssoToken, + "feature_enabled": nsfwEnabled, + "provider": mailMeta["provider"], + "email_meta": emailMetaCopy, + "logs": logs, + } + if checkoutURL != "" { + result["redirect_url"] = checkoutURL + } + c.JSON(http.StatusOK, result) +} diff --git a/internal/worker/grok_worker.go b/internal/worker/grok_worker.go new file mode 100644 index 0000000..2374413 --- /dev/null +++ b/internal/worker/grok_worker.go @@ -0,0 +1,1389 @@ +package worker + +import ( + "bytes" + "context" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + cryptorand "crypto/rand" + "math/big" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/grpcweb" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/turnstile" +) + +// ─── TLS 指纹模拟配置 ─── + +// browserProfile 浏览器指纹配置 +type browserProfile struct { + Name string + UserAgent string +} + +var browserProfiles = []browserProfile{ + {Name: "chrome120", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}, + {Name: "chrome119", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36"}, + {Name: "chrome110", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36"}, + {Name: "edge99", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.74 Safari/537.36 Edg/99.0.1150.46"}, + {Name: "edge101", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.64 Safari/537.36 Edg/101.0.1210.53"}, +} + +// ─── 常量 ─── + +// defaultStateTree Next.js 路由状态树默认值(sign-up 页面) +const defaultStateTree = `%5B%22%22%2C%7B%22children%22%3A%5B%22(app)%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22sign-up%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fsign-up%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D` + +// ─── 正则表达式 ─── + +var ( + reSiteKey = regexp.MustCompile(`sitekey":"(0x4[a-zA-Z0-9_-]+)"`) + reStateTree = regexp.MustCompile(`next-router-state-tree":"([^"]+)"`) + reScriptSrc = regexp.MustCompile(`]+src="([^"]*_next/static[^"]*)"`) + reActionID = regexp.MustCompile(`7f[a-fA-F0-9]{40}`) + // 参考 grokzhuce: 用 \d+: 锚定 RSC 段号分隔符,防止 URL 末尾吃掉多余数字 + reCookieURLAnchored = regexp.MustCompile(`(https://[^"\s]+set-cookie\?q=[^:"\s]+)\d+:`) + // 降级: JSON 引号内的 URL(引号自然截止) + reCookieURLQuoted = regexp.MustCompile(`(https://[^"\s]+set-cookie\?q=[^:"\s]+)`) +) + +// ─── GrokWorker 实现 ─── + +// GrokWorker Grok 平台注册器(完整实现) +type GrokWorker struct{} + +func init() { + Register(&GrokWorker{}) +} + +func (w *GrokWorker) PlatformName() string { return "grok" } + +// ScanConfig 扫描 Grok 注册页面获取 site_key、action_id、state_tree +// Server Actions 注册模式必须获取 action_id +func (w *GrokWorker) ScanConfig(ctx context.Context, proxy *ProxyEntry, cfg Config) (Config, error) { + // 优先读 DB 已持久化的值作为 fallback,避免 X.AI 更新 site_key 后硬编码过期 + fallbackSiteKey := cfg["grok_site_key"] + if fallbackSiteKey == "" { + fallbackSiteKey = "0x4AAAAAAAhr9JGVDZbrZOo0" + } + result := Config{ + "site_key": fallbackSiteKey, + "state_tree": defaultStateTree, + } + + // 远程服务模式:VPS 无需直连 x.ai,HF worker 会自行获取 action_id + if cfg["grok_reg_url"] != "" { + // 尝试用 DB 里缓存的 action_id,没有也不报错 + if aid := cfg["grok_action_id"]; aid != "" { + result["action_id"] = aid + } + log.Info().Msg("remote mode, skip local scan") + return result, nil + } + + // 1) 直连扫描 + proxies := []*ProxyEntry{proxy} + if proxy != nil { + proxies = append(proxies, nil) + } + for _, p := range proxies { + scanned, err := w.doScan(ctx, p) + if err == nil { + for _, k := range []string{"site_key", "action_id", "state_tree"} { + if v := scanned[k]; v != "" { + result[k] = v + } + } + if result["action_id"] != "" { + log.Info().Msg("config scan ok") + return result, nil + } + } + } + + // 2) CF-Bypass 降级扫描 + if cfBypassURL := cfg["cf_bypass_solver_url"]; cfBypassURL != "" { + scanned, err := w.doScanViaCFBypass(ctx, cfBypassURL) + if err == nil { + for _, k := range []string{"site_key", "action_id", "state_tree"} { + if v := scanned[k]; v != "" { + result[k] = v + } + } + if result["action_id"] != "" { + log.Info().Msg("bypass scan ok") + return result, nil + } + } + } + + if result["action_id"] == "" { + return result, fmt.Errorf("扫描失败: 未获取到 action_id(Server Actions 注册需要此配置)") + } + return result, nil +} + +// doScan 实际扫描逻辑 +func (w *GrokWorker) doScan(ctx context.Context, proxy *ProxyEntry) (Config, error) { + cfg := Config{ + "site_key": "0x4AAAAAAAhr9JGVDZbrZOo0", + "action_id": "", + "state_tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22(app)%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22sign-up%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fsign-up%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D", + } + + client := w.makeHTTPClient(proxy, 30*time.Second) + profile := browserProfiles[rand.Intn(len(browserProfiles))] + + // GET 注册页面 + req, err := http.NewRequestWithContext(ctx, "GET", "https://accounts.x.ai/sign-up", nil) + if err != nil { + return cfg, err + } + req.Header.Set("User-Agent", profile.UserAgent) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + + resp, err := client.Do(req) + if err != nil { + return cfg, fmt.Errorf("扫描注册页失败: %w", err) + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB 上限(HTML 页面) + resp.Body.Close() + html := string(body) + + // 提取 site_key + if m := reSiteKey.FindStringSubmatch(html); len(m) > 1 { + cfg["site_key"] = m[1] + } + + // 提取 state_tree + if m := reStateTree.FindStringSubmatch(html); len(m) > 1 { + cfg["state_tree"] = m[1] + } + + // 提取 JS bundle URL,搜索 action_id + scriptMatches := reScriptSrc.FindAllStringSubmatch(html, -1) + for _, sm := range scriptMatches { + if len(sm) < 2 { + continue + } + jsURL := sm[1] + if !strings.HasPrefix(jsURL, "http") { + jsURL = "https://accounts.x.ai" + jsURL + } + + jsReq, _ := http.NewRequestWithContext(ctx, "GET", jsURL, nil) + jsReq.Header.Set("User-Agent", profile.UserAgent) + jsResp, err := client.Do(jsReq) + if err != nil { + continue + } + jsBody, _ := io.ReadAll(jsResp.Body) + jsResp.Body.Close() + + if m := reActionID.FindString(string(jsBody)); m != "" { + cfg["action_id"] = m + break + } + } + + if cfg["action_id"] == "" { + return cfg, fmt.Errorf("未找到 action_id") + } + + log.Info().Msg("config scan ok") + return cfg, nil +} + +// doScanViaCFBypass 通过 CF-Bypass Solver 过 Cloudflare 后获取页面 HTML,再提取配置 +func (w *GrokWorker) doScanViaCFBypass(ctx context.Context, cfBypassURL string) (Config, error) { + cfg := Config{ + "site_key": "0x4AAAAAAAhr9JGVDZbrZOo0", + "action_id": "", + "state_tree": "%5B%22%22%2C%7B%22children%22%3A%5B%22(app)%22%2C%7B%22children%22%3A%5B%22(auth)%22%2C%7B%22children%22%3A%5B%22sign-up%22%2C%7B%22children%22%3A%5B%22__PAGE__%22%2C%7B%7D%2C%22%2Fsign-up%22%2C%22refresh%22%5D%7D%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%5D%7D%2Cnull%2Cnull%2Ctrue%5D", + } + + // 调用 cf-bypass-solver 的 /fetch 接口 + fetchURL := strings.TrimRight(cfBypassURL, "/") + "/fetch?url=" + url.QueryEscape("https://accounts.x.ai/sign-up") + client := &http.Client{Timeout: 120 * time.Second} // 过盾需要等浏览器,给足时间 + req, err := http.NewRequestWithContext(ctx, "GET", fetchURL, nil) + if err != nil { + return cfg, err + } + + resp, err := client.Do(req) + if err != nil { + return cfg, fmt.Errorf("CF-Bypass fetch 请求失败: %w", err) + } + defer resp.Body.Close() + + var result struct { + OK bool `json:"ok"` + HTML string `json:"html"` + Error string `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return cfg, fmt.Errorf("CF-Bypass 响应解析失败: %w", err) + } + if !result.OK || result.HTML == "" { + return cfg, fmt.Errorf("CF-Bypass 获取页面失败: %s", result.Error) + } + + html := result.HTML + log.Info().Int("html_len", len(html)).Msg("bypass ok, extracting") + + // 提取 site_key + if m := reSiteKey.FindStringSubmatch(html); len(m) > 1 { + cfg["site_key"] = m[1] + } + + // 提取 state_tree + if m := reStateTree.FindStringSubmatch(html); len(m) > 1 { + cfg["state_tree"] = m[1] + } + + // 提取 JS bundle URL,搜索 action_id + profile := browserProfiles[rand.Intn(len(browserProfiles))] + scriptMatches := reScriptSrc.FindAllStringSubmatch(html, -1) + for _, sm := range scriptMatches { + if len(sm) < 2 { + continue + } + jsURL := sm[1] + if !strings.HasPrefix(jsURL, "http") { + jsURL = "https://accounts.x.ai" + jsURL + } + + jsReq, _ := http.NewRequestWithContext(ctx, "GET", jsURL, nil) + jsReq.Header.Set("User-Agent", profile.UserAgent) + jsResp, err := client.Do(jsReq) + if err != nil { + continue + } + jsBody, _ := io.ReadAll(jsResp.Body) + jsResp.Body.Close() + + if m := reActionID.FindString(string(jsBody)); m != "" { + cfg["action_id"] = m + break + } + } + + if cfg["action_id"] == "" { + return cfg, fmt.Errorf("CF-Bypass 过盾后仍未找到 action_id") + } + + log.Info().Msg("bypass scan ok") + return cfg, nil +} + +// RegisterOne 执行一次完整的 Grok 注册流程 +// 优先级: 协议注册(gRPC-web + Server Actions)→ Camoufox 浏览器降级(如已配置) +func (w *GrokWorker) RegisterOne(ctx context.Context, opts RegisterOpts) { + // 随机延迟分散启动 + jitter := time.Duration(rand.Intn(3000)) * time.Millisecond + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + logf := func(format string, args ...interface{}) { + select { + case opts.LogCh <- fmt.Sprintf(format, args...): + default: + } + } + + // 优先远程服务:配置了 grok_reg_url 就直接走远程,不跑本地协议 + if serviceURL := opts.Config["grok_reg_url"]; serviceURL != "" { + logf("[*] 任务开始(远程服务模式)...") + result, ok := w.registerViaGrokService(ctx, serviceURL, opts, logf) + if ok { + email, _ := result["email"].(string) + opts.OnSuccess(email, result) + } else if ctx.Err() != nil { + return // context 取消(达标或用户停止),不计入失败 + } else { + opts.OnFail() + } + return + } + + // 先尝试协议注册(拦截失败回调,不立即上报,支持降级) + succeeded := false + protoOpts := opts + protoOpts.OnSuccess = func(email string, data map[string]interface{}) { + succeeded = true + opts.OnSuccess(email, data) + } + protoOpts.OnFail = func() { /* 由外层决策是否降级 */ } + + w.registerViaProtocol(ctx, protoOpts, logf) + + if succeeded { + return + } + + // context 已取消(目标达成或用户停止),不再降级 + if ctx.Err() != nil { + opts.OnFail() + return + } + + // 协议失败 → Camoufox 浏览器降级(如已配置) + if browserRegURL := opts.Config["camoufox_reg_url"]; browserRegURL != "" { + logf("[!] 协议注册失败,降级到浏览器模式...") + w.registerViaBrowser(ctx, browserRegURL, opts) + return + } + + opts.OnFail() +} + +// registerViaProtocol 协议注册主流程(gRPC-web + Server Actions) +func (w *GrokWorker) registerViaProtocol(ctx context.Context, opts RegisterOpts, logf func(string, ...interface{})) { + // 选择浏览器指纹 + profile := browserProfiles[rand.Intn(len(browserProfiles))] + + logf("[*] 任务开始") + + // 获取配置(gRPC-web 模式不再需要 action_id / state_tree) + siteKey := opts.Config["site_key"] + + // ─── 创建 HTTP 会话 ─── + client := w.makeHTTPClient(opts.Proxy, 30*time.Second) + + // 会话预热(获取 __cf_bm cookie) + warmupReq, _ := http.NewRequestWithContext(ctx, "GET", "https://accounts.x.ai", nil) + warmupReq.Header.Set("User-Agent", profile.UserAgent) + if resp, err := client.Do(warmupReq); err == nil { + resp.Body.Close() + } + + // ─── Phase 1: 创建临时邮箱(多 provider 自动切换)─── + // 平台专属邮箱 provider 覆盖全局优先级 + if platformProviders := opts.Config["grok_email_providers"]; platformProviders != "" { + opts.Config["email_provider_priority"] = platformProviders + } + mailProvider := tempmail.NewMultiProvider(opts.Config) + email, mailMeta, err := mailProvider.GenerateEmail(ctx) + if err != nil { + logf("[-] 创建邮箱失败: %s", err) + opts.OnFail() + return + } + password := randomString(15, "abcdefghijklmnopqrstuvwxyz0123456789") + logf("[*] 邮箱: %s (via %s)", email, mailMeta["provider"]) + + // 注册失败时清理邮箱 + defer func() { + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + }() + + // ─── Phase 2: 发送验证码 (gRPC-web) ─── + logf("[*] 发送验证码...") + grpcBody := grpcweb.EncodeEmailCode(email) + sendResp, err := w.doGRPCWeb(ctx, client, profile.UserAgent, + "https://accounts.x.ai/auth_mgmt.AuthManagement/CreateEmailValidationCode", + "https://accounts.x.ai", "https://accounts.x.ai/sign-up?redirect=grok-com", + grpcBody, nil) + if err != nil || sendResp.StatusCode != 200 { + status := 0 + if sendResp != nil { + status = sendResp.StatusCode + sendResp.Body.Close() + } + logf("[-] 发送验证码失败: HTTP %d, %v", status, err) + opts.OnFail() + return + } + sendResp.Body.Close() + logf("[+] 验证码已发送") + + // ─── Phase 3: 获取验证码 ─── + logf("[*] 等待验证码(最长 30s)...") + var code string + for attempt := 1; attempt <= 30; attempt++ { + select { + case <-ctx.Done(): + logf("[-] 等待验证码时任务被取消") + opts.OnFail() + return + case <-time.After(1 * time.Second): + } + c, err := mailProvider.FetchVerificationCode(ctx, email, mailMeta, 1, 0) + if err == nil && c != "" { + code = c + break + } + if attempt%5 == 0 { + logf("[*] 等待验证码中... 已等待 %ds", attempt) + } + } + if code == "" { + logf("[-] 获取验证码超时") + tempmail.RecordFailure(mailMeta["provider"]) + opts.OnFail() + return + } + logf("[+] 验证码: %s", code) + tempmail.RecordSuccess(mailMeta["provider"]) + + // ─── Phase 4: 验证邮箱 (gRPC-web) ─── + verifyBody := grpcweb.EncodeVerifyCode(email, code) + verifyResp, err := w.doGRPCWeb(ctx, client, profile.UserAgent, + "https://accounts.x.ai/auth_mgmt.AuthManagement/VerifyEmailValidationCode", + "https://accounts.x.ai", "https://accounts.x.ai/sign-up?redirect=grok-com", + verifyBody, nil) + if err != nil || verifyResp.StatusCode != 200 { + if verifyResp != nil { + verifyResp.Body.Close() + } + logf("[-] 验证邮箱失败") + opts.OnFail() + return + } + verifyResp.Body.Close() + logf("[+] 邮箱验证成功") + + // ─── Phase 5+6: Server Actions 注册 ─── + firstName := randomName(4, 6) + lastName := randomName(4, 6) + + var solverURLs []string + if u := opts.Config["turnstile_solver_url"]; u != "" { + solverURLs = append(solverURLs, u) + } + if u := opts.Config["cf_bypass_solver_url"]; u != "" { + solverURLs = append(solverURLs, u) + } + // 从代理池提取代理地址,传给 Turnstile solver 浏览器使用 + var solverProxyURL string + if opts.Proxy != nil { + if opts.Proxy.HTTPS != "" { + solverProxyURL = opts.Proxy.HTTPS + } else if opts.Proxy.HTTP != "" { + solverProxyURL = opts.Proxy.HTTP + } + } + solver := turnstile.NewSolver(solverURLs, opts.Config["capsolver_key"], opts.Config["yescaptcha_key"], solverProxyURL) + + actionID := opts.Config["action_id"] + stateTree := settingOrDefault(opts.Config, "state_tree", defaultStateTree) + + if actionID == "" { + logf("[-] 缺少注册配置,无法注册") + opts.OnFail() + return + } + + var ssoToken string + registered := false + + for attempt := 0; attempt < 3; attempt++ { + select { + case <-ctx.Done(): + opts.OnFail() + return + default: + } + + logf("[*] 注册尝试 %d/3...", attempt+1) + + logf("[*] 验证远程服务接口...") + turnstileToken, err := solver.Solve(ctx, "https://accounts.x.ai/sign-up", siteKey, 3, logf) + if err != nil { + logf("[-] 验证失败: %s", err) + ctxSleep(ctx, 3*time.Second) + continue + } + logf("[+] 卧槽Σ(°ロ°)验证通过 (%d chars)", len(turnstileToken)) + + regPayload := []map[string]interface{}{{ + "emailValidationCode": code, + "createUserAndSessionRequest": map[string]interface{}{ + "email": email, + "givenName": firstName, + "familyName": lastName, + "clearTextPassword": password, + "tosAcceptedVersion": "$undefined", + }, + "turnstileToken": turnstileToken, + "promptOnDuplicateEmail": true, + }} + jsonBody, _ := json.Marshal(regPayload) + + regReq, _ := http.NewRequestWithContext(ctx, "POST", "https://accounts.x.ai/sign-up", bytes.NewReader(jsonBody)) + regReq.Header.Set("Content-Type", "text/plain;charset=UTF-8") + regReq.Header.Set("Accept", "text/x-component") + regReq.Header.Set("Next-Action", actionID) + regReq.Header.Set("Next-Router-State-Tree", stateTree) + regReq.Header.Set("Origin", "https://accounts.x.ai") + regReq.Header.Set("Referer", "https://accounts.x.ai/sign-up") + regReq.Header.Set("User-Agent", profile.UserAgent) + + regResp, err := client.Do(regReq) + if err != nil { + logf("[-] 注册请求失败: %s", err) + ctxSleep(ctx, 3*time.Second) + continue + } + respBody, _ := io.ReadAll(regResp.Body) + regResp.Body.Close() + respText := string(respBody) + + if regResp.StatusCode != 200 { + logf("[-] 注册响应: HTTP %d, body=%s", regResp.StatusCode, truncStr(respText, 200)) + ctxSleep(ctx, 3*time.Second) + continue + } + + var cookieURLMatch string + if sm := reCookieURLAnchored.FindStringSubmatch(respText); len(sm) > 1 { + cookieURLMatch = sm[1] + logf("[*] 会话处理中...") + } else if sm := reCookieURLQuoted.FindStringSubmatch(respText); len(sm) > 1 { + cookieURLMatch = sm[1] + logf("[*] 会话处理中(备用)...") + } + if cookieURLMatch == "" { + logf("[-] 会话处理失败,响应异常") + ctxSleep(ctx, 3*time.Second) + continue + } + + cookieReq, _ := http.NewRequestWithContext(ctx, "GET", cookieURLMatch, nil) + cookieReq.Header.Set("User-Agent", profile.UserAgent) + cookieResp, err := client.Do(cookieReq) + if err != nil { + logf("[-] 会话回调失败: %s", err) + break + } + + for _, sc := range cookieResp.Header["Set-Cookie"] { + if strings.HasPrefix(sc, "sso=") && !strings.HasPrefix(sc, "sso-rw=") { + parts := strings.SplitN(sc, ";", 2) + kv := strings.SplitN(parts[0], "=", 2) + if len(kv) == 2 && kv[1] != "" { + ssoToken = kv[1] + } + } + } + cookieResp.Body.Close() + logf("[*] 会话回调完成: HTTP %d", cookieResp.StatusCode) + + if ssoToken == "" { + for _, domain := range []string{ + cookieResp.Request.URL.String(), + "https://accounts.x.ai", + "https://grok.com", + "https://x.ai", + } { + domainURL, err := url.Parse(domain) + if err != nil { + continue + } + for _, c := range client.Jar.Cookies(domainURL) { + if c.Name == "sso" && ssoToken == "" { + ssoToken = c.Value + } + } + } + } + + if ssoToken == "" { + logf("[-] 未获取到登录凭证") + break + } + + logf("[+] 注册成功!") + registered = true + break + } + + if !registered { + opts.OnFail() + return + } + + // ─── Phase 7+8: TOS + 生日 + NSFW + Unhinged + PayUrl ─── + logf("[*] 马上处理..设置中...") + nsfwEnabled, checkoutURL := w.enableNSFW(ctx, ssoToken, email, opts.Proxy, logf) + if nsfwEnabled { + logf("[+] 马上处理..成") + } else { + logf("[!] 马上处理..失败(账号已创建,继续保存)") + } + + result := map[string]interface{}{ + "auth_token": ssoToken, + "feature_enabled": nsfwEnabled, + } + if checkoutURL != "" { + result["redirect_url"] = checkoutURL + } + // 保存邮箱 provider 信息,供后续 FetchOTP 使用 + result["provider"] = mailMeta["provider"] + emailMetaCopy := make(map[string]interface{}, len(mailMeta)) + for k, v := range mailMeta { + emailMetaCopy[k] = v + } + result["email_meta"] = emailMetaCopy + logf("[OK] 任务完成: %s", email) + opts.OnSuccess(email, result) +} + +// ─── 内部方法 ─── + +// ─── NSFW 配置 ─── + +// enableNSFW 设置 TOS + 生日 + NSFW + Unhinged + PayUrl +// 优先 Go 原生(标准 HTTP),失败时自动降级到 Python curl_cffi +func (w *GrokWorker) enableNSFW(ctx context.Context, ssoToken, email string, proxy *ProxyEntry, logf func(string, ...interface{})) (bool, string) { + ok, checkoutURL := w.enableNSFWGo(ctx, ssoToken, email, proxy, logf) + if ok { + return true, checkoutURL + } + logf("[!] Go 原生配置失败,降级到备用方案...") + return w.enableNSFWViaPython(ctx, ssoToken, email, proxy, logf) +} + +// enableNSFWGo 纯 Go 实现 NSFW 配置(不依赖 Python / curl_cffi) +// 流程: 预热 grok.com → TOS(accounts.x.ai) → 生日 → NSFW → Unhinged → PayUrl +// HF Space 出口 IP 干净,Go 标准 net/http 通常可以直通 Cloudflare +func (w *GrokWorker) enableNSFWGo(ctx context.Context, ssoToken, email string, proxy *ProxyEntry, logf func(string, ...interface{})) (bool, string) { + client := w.makeHTTPClient(proxy, 20*time.Second) + profile := browserProfiles[rand.Intn(len(browserProfiles))] + + // 设置 SSO cookie 到 cookie jar + for _, domain := range []string{"https://grok.com", "https://accounts.x.ai"} { + domainURL, _ := url.Parse(domain) + client.Jar.SetCookies(domainURL, []*http.Cookie{ + {Name: "sso", Value: ssoToken}, + {Name: "sso-rw", Value: ssoToken}, + }) + } + + // 预热 grok.com(获取 __cf_bm cookie) + warmupReq, _ := http.NewRequestWithContext(ctx, "GET", "https://grok.com", nil) + warmupReq.Header.Set("User-Agent", profile.UserAgent) + if resp, err := client.Do(warmupReq); err == nil { + resp.Body.Close() + logf("[*] 预热: HTTP %d", resp.StatusCode) + if resp.StatusCode == 403 { + logf("[-] 预热被拦截,放弃") + return false, "" + } + } else { + logf("[-] 预热失败: %s", err) + return false, "" + } + + // Step 1: TOS (accounts.x.ai gRPC-web) + tosPayload := grpcWebWrap([]byte{0x10, 0x01}) // field 2 (tos_version) = 1 + tosResp, err := w.doGRPCWeb(ctx, client, profile.UserAgent, + "https://accounts.x.ai/auth_mgmt.AuthManagement/SetTosAcceptedVersion", + "https://accounts.x.ai", "https://accounts.x.ai/accept-tos", + tosPayload, nil) + if err != nil { + logf("[-] 协议请求失败: %s", err) + return false, "" + } + tosResp.Body.Close() + if tosResp.StatusCode != 200 { + logf("[-] 协议响应异常: %d", tosResp.StatusCode) + return false, "" + } + logf("[+] 协议已接受") + + // Step 2: 生日 (grok.com REST) + bdPayload, _ := json.Marshal(map[string]string{"birthDate": randomBirthDate()}) + bdReq, _ := http.NewRequestWithContext(ctx, "POST", "https://grok.com/rest/auth/set-birth-date", bytes.NewReader(bdPayload)) + bdReq.Header.Set("Content-Type", "application/json") + bdReq.Header.Set("Origin", "https://grok.com") + bdReq.Header.Set("Referer", "https://grok.com/") + bdReq.Header.Set("User-Agent", profile.UserAgent) + bdResp, err := client.Do(bdReq) + if err != nil { + logf("[-] 信息设置失败: %s", err) + return false, "" + } + bdResp.Body.Close() + if bdResp.StatusCode != 200 { + logf("[-] 信息设置异常: %d", bdResp.StatusCode) + return false, "" + } + logf("[+] 信息设置成功") + + // Step 3: NSFW (grok.com gRPC-web) + // protobuf: field 1 = {field 2 = 1} + field 2 = "always_show_nsfw_content" + nsfwStr := []byte("always_show_nsfw_content") + field1 := []byte{0x0a, 0x02, 0x10, 0x01} + field2Inner := append([]byte{0x0a, byte(len(nsfwStr))}, nsfwStr...) + field2 := append([]byte{0x12, byte(len(field2Inner))}, field2Inner...) + nsfwPayload := grpcWebWrap(append(field1, field2...)) + nsfwResp, err := w.doGRPCWeb(ctx, client, profile.UserAgent, + "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls", + "https://grok.com", "https://grok.com/", + nsfwPayload, nil) + if err != nil { + logf("[-] 偏好设置请求失败: %s", err) + return false, "" + } + nsfwResp.Body.Close() + if nsfwResp.StatusCode != 200 { + logf("[-] 偏好设置异常: %d", nsfwResp.StatusCode) + return false, "" + } + logf("[+] 偏好设置成功") + + // Step 4: Unhinged (grok.com gRPC-web, 不阻断主流程) + // protobuf: field 1 = 1, field 2 = 1 + unhingedPayload := grpcWebWrap([]byte{0x08, 0x01, 0x10, 0x01}) + unhingedResp, err := w.doGRPCWeb(ctx, client, profile.UserAgent, + "https://grok.com/auth_mgmt.AuthManagement/UpdateUserFeatureControls", + "https://grok.com", "https://grok.com/", + unhingedPayload, nil) + if err == nil && unhingedResp != nil { + unhingedResp.Body.Close() + if unhingedResp.StatusCode == 200 { + logf("[+] 高级模式已开启") + } else { + logf("[-] 高级模式设置异常: %d(不阻断)", unhingedResp.StatusCode) + } + } else { + logf("[-] 高级模式请求失败(不阻断): %v", err) + } + + // Step 5: PayUrl 支付链接(不阻断主流程) + checkoutURL := "" + if email != "" { + checkoutURL = w.createCheckoutURLGo(ctx, client, profile.UserAgent, email, logf) + } + + return true, checkoutURL +} + +// createCheckoutURLGo 纯 Go 生成 Grok Pro Stripe 订阅支付链接 +func (w *GrokWorker) createCheckoutURLGo(ctx context.Context, client *http.Client, ua, email string, logf func(string, ...interface{})) string { + // Step 1: 创建 Stripe 客户 + custPayload, _ := json.Marshal(map[string]interface{}{ + "billingInfo": map[string]string{ + "name": randomName(5, 8) + " " + randomName(5, 8), + "email": email, + }, + }) + custReq, _ := http.NewRequestWithContext(ctx, "POST", + "https://grok.com/rest/subscriptions/customer/new", bytes.NewReader(custPayload)) + custReq.Header.Set("Content-Type", "application/json") + custReq.Header.Set("Origin", "https://grok.com") + custReq.Header.Set("Referer", "https://grok.com/") + custReq.Header.Set("User-Agent", ua) + custReq.Header.Set("X-Xai-Request-Id", randomUUID()) + custResp, err := client.Do(custReq) + if err != nil { + logf("[-] 支付初始化失败: %s", err) + return "" + } + custResp.Body.Close() + if custResp.StatusCode != 200 && custResp.StatusCode != 201 && custResp.StatusCode != 204 { + logf("[-] 支付初始化异常: %d", custResp.StatusCode) + return "" + } + + // Step 2: 创建订阅获取 checkout URL + subPayload, _ := json.Marshal(map[string]interface{}{ + "stripeHosted": map[string]string{ + "successUrl": "https://grok.com/?checkout=success&tier=SUBSCRIPTION_TIER_GROK_PRO&interval=monthly#subscribe", + }, + "priceId": "price_1R6nQ9HJohyvID2ck7FNrVdw", + "campaignId": "subcamp_HeAxW", + "ignoreExistingActiveSubscriptions": false, + "subscriptionType": "MONTHLY", + "requestedTier": "REQUESTED_TIER_GROK_PRO", + }) + subReq, _ := http.NewRequestWithContext(ctx, "POST", + "https://grok.com/rest/subscriptions/subscribe/new", bytes.NewReader(subPayload)) + subReq.Header.Set("Content-Type", "application/json") + subReq.Header.Set("Origin", "https://grok.com") + subReq.Header.Set("Referer", "https://grok.com/") + subReq.Header.Set("User-Agent", ua) + subReq.Header.Set("X-Xai-Request-Id", randomUUID()) + subResp, err := client.Do(subReq) + if err != nil { + logf("[-] 订阅创建失败: %s", err) + return "" + } + defer subResp.Body.Close() + if subResp.StatusCode != 200 { + logf("[-] 订阅创建异常: %d", subResp.StatusCode) + return "" + } + + var subResult map[string]interface{} + if err := json.NewDecoder(io.LimitReader(subResp.Body, 64*1024)).Decode(&subResult); err != nil { + logf("[-] 订阅响应解析失败: %s", err) + return "" + } + if u, ok := subResult["url"].(string); ok && u != "" { + logf("[+] 支付链接获取成功") + return u + } + if u, ok := subResult["checkoutUrl"].(string); ok && u != "" { + logf("[+] 支付链接获取成功") + return u + } + logf("[-] 支付链接未找到") + return "" +} + +// enableNSFWViaPython 通过 Python curl_cffi 子进程设置 TOS + 生日 + NSFW + Unhinged + PayUrl +// Go utls 无法通过 grok.com 的 Cloudflare(HTTP/2 不支持导致 connection reset) +// Python curl_cffi impersonate="chrome120" 完整支持 TLS + HTTP/2 +// 返回 (nsfw成功, checkout_url) +func (w *GrokWorker) enableNSFWViaPython(ctx context.Context, ssoToken, email string, proxy *ProxyEntry, logf func(string, ...interface{})) (bool, string) { + // 查找 enable_nsfw.py 脚本 + scriptPath := "" + candidates := []string{ + "scripts/enable_nsfw.py", + } + // 也检查可执行文件相对路径 + if execPath, err := os.Executable(); err == nil { + execDir := filepath.Dir(execPath) + candidates = append(candidates, + filepath.Join(execDir, "scripts", "enable_nsfw.py"), + filepath.Join(execDir, "..", "scripts", "enable_nsfw.py"), + ) + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + scriptPath = p + break + } + } + if scriptPath == "" { + logf("[-] 马上处理..组件未找到") + return false, "" + } + + // 调用: python3 enable_nsfw.py --sso --email + cmdCtx, cancel := context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + cmd := exec.CommandContext(cmdCtx, "python3", scriptPath, "--sso", ssoToken) + + // 传递代理配置给 Python 脚本 + if proxy != nil { + proxyStr := proxy.HTTPS + if proxyStr == "" { + proxyStr = proxy.HTTP + } + if proxyStr != "" { + cmd.Args = append(cmd.Args, "--proxy", proxyStr) + } + } + + // 传递 email 以生成 PayUrl checkout 链接 + if email != "" { + cmd.Args = append(cmd.Args, "--email", email) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + outStr := strings.TrimSpace(stdout.String()) + errStr := strings.TrimSpace(stderr.String()) + + // 打印 Python 脚本调试日志(stderr 里有各步骤的 HTTP 状态码) + if errStr != "" { + for _, line := range strings.Split(errStr, "\n") { + line = strings.TrimSpace(line) + if line != "" { + logf("[*] %s", line) + } + } + } + + if err != nil { + logf("[!] 马上处理..失败: %s", truncStr(outStr, 200)) + return false, "" + } + + // 解析输出:NSFW_OK:| 或 NSFW_FAIL: + checkoutURL := "" + for _, line := range strings.Split(outStr, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "NSFW_OK:") { + payload := strings.TrimPrefix(line, "NSFW_OK:") + // 格式: |(checkout_url 可为空) + parts := strings.SplitN(payload, "|", 2) + logf("[+] %s", parts[0]) + if len(parts) == 2 && parts[1] != "" { + checkoutURL = parts[1] + logf("[+] PayUrl: %s", truncStr(checkoutURL, 80)) + } + return true, checkoutURL + } + if strings.HasPrefix(line, "NSFW_FAIL:") { + logf("[-] 马上处理..失败: %s", strings.TrimPrefix(line, "NSFW_FAIL:")) + return false, "" + } + } + + logf("[!] 马上处理..异常: %s", truncStr(outStr, 200)) + return false, "" +} + +// doGRPCWeb 发送 gRPC-web 请求 +func (w *GrokWorker) doGRPCWeb(ctx context.Context, client *http.Client, ua, endpoint, origin, referer string, body []byte, extraCookies map[string]string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, "POST", endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/grpc-web+proto") + req.Header.Set("X-Grpc-Web", "1") + req.Header.Set("X-User-Agent", "connect-es/2.1.1") + req.Header.Set("Origin", origin) + req.Header.Set("Referer", referer) + req.Header.Set("User-Agent", ua) + + // 将额外 cookie 添加到 jar(保留 jar 已有的 __cf_bm 等 Cloudflare cookie) + if len(extraCookies) > 0 { + var cookies []*http.Cookie + for k, v := range extraCookies { + cookies = append(cookies, &http.Cookie{Name: k, Value: v}) + } + client.Jar.SetCookies(req.URL, cookies) + } + + return client.Do(req) +} + +// makeHTTPClient 创建带代理和 cookie jar 的 HTTP 客户端 +func (w *GrokWorker) makeHTTPClient(proxy *ProxyEntry, timeout time.Duration) *http.Client { + jar, _ := cookiejar.New(nil) + transport := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + DisableCompression: false, + TLSHandshakeTimeout: 10 * time.Second, + } + applyProxy(transport, proxy) + + return &http.Client{ + Transport: transport, + Jar: jar, + Timeout: timeout, + } +} + +// ─── 工具函数 ─── + +// grpcWebWrap 将 protobuf payload 包装为 gRPC-web 帧(0x00 + 4字节大端长度 + payload) +func grpcWebWrap(payload []byte) []byte { + buf := make([]byte, 5+len(payload)) + buf[0] = 0x00 + binary.BigEndian.PutUint32(buf[1:5], uint32(len(payload))) + copy(buf[5:], payload) + return buf +} + +// randomBirthDate 生成随机出生日期(20-40 岁之间) +func randomBirthDate() string { + now := time.Now() + age := 20 + rand.Intn(21) + return fmt.Sprintf("%d-%02d-%02dT16:00:00.000Z", now.Year()-age, 1+rand.Intn(12), 1+rand.Intn(28)) +} + +// randomUUID 生成随机 UUID v4(用于 X-Xai-Request-Id 请求头) +func randomUUID() string { + var buf [16]byte + cryptorand.Read(buf[:]) + buf[6] = (buf[6] & 0x0f) | 0x40 // version 4 + buf[8] = (buf[8] & 0x3f) | 0x80 // variant 2 + return fmt.Sprintf("%x-%x-%x-%x-%x", buf[0:4], buf[4:6], buf[6:8], buf[8:10], buf[10:16]) +} + +func randomString(length int, charset string) string { + b := make([]byte, length) + for i := range b { + n, _ := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(charset)))) + b[i] = charset[n.Int64()] + } + return string(b) +} + +func randomName(minLen, maxLen int) string { + n, _ := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(maxLen-minLen+1))) + length := minLen + int(n.Int64()) + name := randomString(length, "abcdefghijklmnopqrstuvwxyz") + return strings.ToUpper(name[:1]) + name[1:] +} + +func settingOrDefault(cfg Config, key, fallback string) string { + if v, ok := cfg[key]; ok && v != "" { + return v + } + return fallback +} + +// truncStr 截断字符串用于日志输出 +func truncStr(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen] + "..." +} + +// filterLocalURLs 从逗号分隔的 URL 列表中过滤掉本地地址(127.0.0.1/localhost/host.docker.internal) +// HF Space 无法访问 VPS 本地服务,只保留外部可达的 URL +func filterLocalURLs(urls string) string { + if urls == "" { + return "" + } + parts := strings.Split(urls, ",") + remote := make([]string, 0, len(parts)) + for _, u := range parts { + u = strings.TrimSpace(u) + if u == "" { + continue + } + if strings.Contains(u, "127.0.0.1") || strings.Contains(u, "localhost") || strings.Contains(u, "host.docker.internal") { + continue + } + remote = append(remote, u) + } + return strings.Join(remote, ",") +} + +// ─── 远程服务模式 ─── + +// grokServiceRequest VPS → HF 远程注册请求 +type grokServiceRequest struct { + Proxy string `json:"proxy,omitempty"` + YYDSMailURL string `json:"yydsmail_url,omitempty"` + YYDSMailKey string `json:"yydsmail_key,omitempty"` + EmailPriority string `json:"email_priority,omitempty"` + TurnstileSolverURL string `json:"turnstile_solver_url,omitempty"` + TurnstileSolverProxy string `json:"turnstile_solver_proxy,omitempty"` + CFBypassSolverURL string `json:"cf_bypass_solver_url,omitempty"` + CapSolverKey string `json:"capsolver_key,omitempty"` + YesCaptchaKey string `json:"yescaptcha_key,omitempty"` + SiteKey string `json:"site_key,omitempty"` + ActionID string `json:"action_id,omitempty"` + StateTree string `json:"state_tree,omitempty"` +} + +// grokServiceResponse HF → VPS 远程注册响应 +type grokServiceResponse struct { + OK bool `json:"ok"` + Email string `json:"email"` + SSOToken string `json:"auth_token"` + NSFWEnabled bool `json:"feature_enabled"` + CheckoutURL string `json:"redirect_url"` + EmailProvider string `json:"provider"` + Error string `json:"error"` + Logs []string `json:"logs"` +} + +// registerViaGrokService 通过远程 HF 服务完成完整 Grok 注册 +// 请求链: VPS → CF Worker → HFGS (grok-worker 二进制) +func (w *GrokWorker) registerViaGrokService(ctx context.Context, serviceURL string, opts RegisterOpts, logf func(string, ...interface{})) (map[string]interface{}, bool) { + logf("[*] 远程服务连接中...") + + proxyStr := "" + if userProxy := opts.Config["user_proxy"]; userProxy != "" { + proxyStr = userProxy + logf("[*] 使用用户指定代理") + } else if opts.Proxy != nil { + if opts.Proxy.HTTPS != "" { + proxyStr = opts.Proxy.HTTPS + } else if opts.Proxy.HTTP != "" { + proxyStr = opts.Proxy.HTTP + } + } + // HF Space 无法访问 VPS 本地代理,本地地址直接丢弃 + // HF 出口 IP 干净,不带代理通常可以直通 Cloudflare + if strings.Contains(proxyStr, "127.0.0.1") || strings.Contains(proxyStr, "localhost") || strings.Contains(proxyStr, "host.docker.internal") { + proxyStr = "" + } + + // 优先使用平台级邮箱选择,为空时回退到全局优先级 + emailPriority := opts.Config["grok_email_providers"] + if emailPriority == "" { + emailPriority = settingOrDefault(opts.Config, "email_provider_priority", "yydsmail") + } + epParts := strings.Split(emailPriority, ",") + for i := range epParts { + epParts[i] = strings.TrimSpace(epParts[i]) + } + tempmail.WeightedShuffleNames(epParts) + + // 诊断日志:发送到远程的配置 + logf("[*] 邮箱优先级: %s", strings.Join(epParts, ",")) + if opts.Config["yydsmail_api_key"] != "" { + logf("[*] yydsmail 凭证: 已配置") + } + + reqBody := grokServiceRequest{ + Proxy: proxyStr, + YYDSMailURL: settingOrDefault(opts.Config, "yydsmail_base_url", ""), + YYDSMailKey: opts.Config["yydsmail_api_key"], + EmailPriority: strings.Join(epParts, ","), + TurnstileSolverURL: filterLocalURLs(opts.Config["turnstile_solver_url"]), + TurnstileSolverProxy: opts.Config["turnstile_solver_proxy"], + CFBypassSolverURL: filterLocalURLs(opts.Config["cf_bypass_solver_url"]), + CapSolverKey: opts.Config["capsolver_key"], + YesCaptchaKey: opts.Config["yescaptcha_key"], + SiteKey: opts.Config["site_key"], + ActionID: opts.Config["action_id"], + StateTree: settingOrDefault(opts.Config, "state_tree", defaultStateTree), + } + bodyJSON, _ := json.Marshal(reqBody) + + // 600s 超时:排队等待(最多 ~300s)+ Turnstile + 注册 + 验证码(120-180s)+ 余量 + reqCtx, reqCancel := context.WithTimeout(ctx, 600*time.Second) + defer reqCancel() + + httpReq, err := http.NewRequestWithContext(reqCtx, "POST", + strings.TrimRight(serviceURL, "/")+"/grok/process", + bytes.NewReader(bodyJSON)) + if err != nil { + return nil, false + } + httpReq.Header.Set("Content-Type", "application/json") + + logf("[*] 请求已发送,等待远程注册...") + + // 心跳:每 10 秒轮询排队状态,向用户推送排队位置/ETA + heartbeatDone := make(chan struct{}) + queueStatusURL := BuildQueueStatusURL(serviceURL, "grok") + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + elapsed := 0 + for { + select { + case <-ticker.C: + elapsed += 10 + qs, err := PollQueueStatus(reqCtx, queueStatusURL) + if err == nil && qs != nil { + logf("%s", FormatQueueLog(qs, elapsed)) + } else { + logf("[…] 远程注册进行中 (%ds)...", elapsed) + } + case <-heartbeatDone: + return + case <-reqCtx.Done(): + return + } + } + }() + + client := &http.Client{Timeout: 600 * time.Second} + resp, err := client.Do(httpReq) + close(heartbeatDone) + + if err != nil { + if ctx.Err() != nil { + return nil, false // context 取消(达标或用户停止),静默退出 + } + logf("[-] 远程服务请求失败: %s", sanitizeHTTPError(err)) + return nil, false + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) // 256KB 上限 + + // HTTP 状态码检查:非 2xx 直接报错,不尝试 JSON 解析 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet := string(body) + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + logf("[-] 远程服务返回 HTTP %d: %s", resp.StatusCode, snippet) + return nil, false + } + + var svcResp grokServiceResponse + if err := json.Unmarshal(body, &svcResp); err != nil { + snippet := string(body) + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + logf("[-] 响应解析失败 (HTTP %d): %s | body: %s", resp.StatusCode, err, snippet) + return nil, false + } + + // 转发远程服务的详细日志 + for _, remoteLog := range svcResp.Logs { + logf(" %s", remoteLog) + } + + // 从远程日志中提取邮箱 provider 名,回写亲和度统计 + if provider := extractProviderFromRemoteLogs(svcResp.Logs); provider != "" { + if svcResp.OK { + tempmail.RecordSuccess(provider) + logf("[*] 亲和度: %s +success", provider) + } else if isEmailRelatedFailure(svcResp.Error) { + tempmail.RecordFailure(provider) + logf("[*] 亲和度: %s +fail (邮箱相关)", provider) + } + } + + if !svcResp.OK { + logf("[-] 远程注册失败: %s", svcResp.Error) + return nil, false + } + + logf("[+] 远程 Grok 注册成功: %s", svcResp.Email) + + // 解析完整 JSON 到 map,保留远程服务返回的所有字段 + result := map[string]interface{}{} + json.Unmarshal(body, &result) //nolint:errcheck + delete(result, "ok") + delete(result, "error") + delete(result, "logs") + + return result, true +} + +// ─── 浏览器模式降级 ─── + +// browserRegRequest Camoufox 注册服务请求体 +type browserRegRequest struct { + Email string `json:"email"` + Proxy string `json:"proxy,omitempty"` + SiteKey string `json:"site_key,omitempty"` +} + +// browserRegResponse Camoufox 注册服务响应体 +type browserRegResponse struct { + OK bool `json:"ok"` + SSOToken string `json:"auth_token"` + NSFWEnable bool `json:"feature_enabled"` + Error string `json:"error,omitempty"` +} + +// registerViaBrowser 通过 Camoufox 浏览器注册服务完成注册 +func (w *GrokWorker) registerViaBrowser(ctx context.Context, browserRegURL string, opts RegisterOpts) { + logf := func(format string, args ...interface{}) { + select { + case opts.LogCh <- fmt.Sprintf(format, args...): + default: + } + } + + logf("[*] 浏览器模式注册开始") + + // 创建临时邮箱(多 provider 自动切换) + // 平台专属邮箱 provider 覆盖全局优先级(Camoufox 模式复用同一逻辑) + if platformProviders := opts.Config["grok_email_providers"]; platformProviders != "" { + opts.Config["email_provider_priority"] = platformProviders + } + mailProvider := tempmail.NewMultiProvider(opts.Config) + email, mailMeta, err := mailProvider.GenerateEmail(ctx) + if err != nil { + logf("[-] 创建邮箱失败: %s", err) + opts.OnFail() + return + } + logf("[*] 邮箱: %s (via %s)", email, mailMeta["provider"]) + defer func() { go mailProvider.DeleteEmail(context.Background(), email, mailMeta) }() + + // 构建代理字符串 + proxyStr := "" + if userProxy := opts.Config["user_proxy"]; userProxy != "" { + proxyStr = userProxy + logf("[*] 使用用户指定代理") + } else if opts.Proxy != nil { + if opts.Proxy.HTTPS != "" { + proxyStr = opts.Proxy.HTTPS + } else if opts.Proxy.HTTP != "" { + proxyStr = opts.Proxy.HTTP + } + } + + // 调用 Camoufox 注册服务 + reqBody := browserRegRequest{ + Email: email, + Proxy: proxyStr, + SiteKey: opts.Config["site_key"], + } + bodyJSON, _ := json.Marshal(reqBody) + + // 浏览器注册耗时较长,但必须关联任务 ctx,停止任务时立即取消 + reqCtx, reqCancel := context.WithTimeout(ctx, 210*time.Second) + defer reqCancel() + httpReq, err := http.NewRequestWithContext(reqCtx, "POST", + strings.TrimRight(browserRegURL, "/")+"/grok/process", + bytes.NewReader(bodyJSON)) + if err != nil { + logf("[-] 构建请求失败: %s", err) + opts.OnFail() + return + } + httpReq.Header.Set("Content-Type", "application/json") + + // 浏览器注册可能耗时较长,使用较长超时 + client := &http.Client{Timeout: 210 * time.Second} + resp, err := client.Do(httpReq) + if err != nil { + logf("[-] 浏览器注册服务请求失败: %s", err) + opts.OnFail() + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) // 256KB 上限 + var result browserRegResponse + if err := json.Unmarshal(body, &result); err != nil { + logf("[-] 浏览器注册服务响应解析失败 (HTTP %d): %s", + resp.StatusCode, err) + opts.OnFail() + return + } + + if !result.OK || result.SSOToken == "" { + logf("[-] 浏览器注册失败: %s", result.Error) + opts.OnFail() + return + } + + logf("[OK] 浏览器注册完成: %s", email) + + if result.NSFWEnable { + // 解析完整 JSON,保留远程服务返回的所有字段 + fullResult := map[string]interface{}{} + json.Unmarshal(body, &fullResult) //nolint:errcheck + delete(fullResult, "ok") + delete(fullResult, "error") + // 保存邮箱 provider 信息,供后续 FetchOTP 使用 + fullResult["provider"] = mailMeta["provider"] + tempmail.RecordSuccess(mailMeta["provider"]) + emailMetaCopy := make(map[string]interface{}, len(mailMeta)) + for k, v := range mailMeta { + emailMetaCopy[k] = v + } + fullResult["email_meta"] = emailMetaCopy + opts.OnSuccess(email, fullResult) + } else { + logf("[!] 马上处理..未完成") + opts.OnFail() + } +} diff --git a/internal/worker/kiro/kiro_validate.go b/internal/worker/kiro/kiro_validate.go new file mode 100644 index 0000000..f7eab0b --- /dev/null +++ b/internal/worker/kiro/kiro_validate.go @@ -0,0 +1,432 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/model" + "github.com/xiaolajiaoyyds/regplatformm/internal/service" + "gorm.io/gorm" +) + +// kiroValidateMu 每用户验活并发锁,防止同一用户同时触发多个验活流 +var kiroValidateMu sync.Map // key: userID (uint), value: *sync.Mutex + +// KiroValidateHandler Kiro 账号验活处理器 +type KiroValidateHandler struct { + db *gorm.DB + authSvc *service.AuthService + proxyPool *service.ProxyPool +} + +// NewKiroValidateHandler 创建 Kiro 验活处理器 +func NewKiroValidateHandler(db *gorm.DB, authSvc *service.AuthService, proxyPool *service.ProxyPool) *KiroValidateHandler { + return &KiroValidateHandler{db: db, authSvc: authSvc, proxyPool: proxyPool} +} + +// ── AWS OIDC 常量 ────────────────────────────────────────────────── + +const ( + oidcTokenURL = "https://oidc.us-east-1.amazonaws.com/token" + qUsageLimitURL = "https://q.us-east-1.amazonaws.com/getUsageLimits?origin=AI_EDITOR&resourceType=AGENTIC_REQUEST" +) + +// oidcHeaders 模拟 AWS SDK Rust 的请求头 +func oidcHeaders() http.Header { + h := http.Header{} + h.Set("Content-Type", "application/json") + h.Set("User-Agent", "aws-sdk-rust/1.3.9 os/windows lang/rust/1.87.0") + h.Set("x-amz-user-agent", "aws-sdk-rust/1.3.9 ua/2.1 api/ssooidc/1.88.0 os/windows lang/rust/1.87.0 m/E app/AmazonQ-For-CLI") + h.Set("amz-sdk-request", "attempt=1; max=3") + h.Set("amz-sdk-invocation-id", uuid.New().String()) + return h +} + +// qAPIHeaders Q API 验证请求头 +func qAPIHeaders(accessToken string) http.Header { + h := http.Header{} + h.Set("content-type", "application/x-amz-json-1.0") + h.Set("authorization", "Bearer "+accessToken) + h.Set("user-agent", "aws-sdk-rust/1.3.9 ua/2.1 api/codewhispererstreaming/0.1.11582 os/windows lang/rust/1.87.0 md/appVersion-1.19.4 app/AmazonQ-For-CLI") + h.Set("x-amz-user-agent", "aws-sdk-rust/1.3.9 ua/2.1 api/codewhispererstreaming/0.1.11582 os/windows lang/rust/1.87.0 m/F app/AmazonQ-For-CLI") + h.Set("x-amzn-codewhisperer-optout", "false") + h.Set("amz-sdk-request", "attempt=1; max=3") + h.Set("amz-sdk-invocation-id", uuid.New().String()) + return h +} + +// containsAny 检查 s 是否包含 subs 中的任一子串 +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} + +// truncateStr 按 rune 截断字符串,避免切断多字节字符 +func truncateStr(s string, maxRunes int) string { + runes := []rune(s) + if len(runes) <= maxRunes { + return s + } + return string(runes[:maxRunes]) + "..." +} + +// sseWrite 写入一条 SSE 事件 +func sseWrite(c *gin.Context, data interface{}) { + jsonBytes, err := json.Marshal(data) + if err != nil { + log.Warn().Err(err).Msg("SSE JSON 序列化失败") + return + } + if _, err := fmt.Fprintf(c.Writer, "data: %s\n\n", jsonBytes); err != nil { + log.Debug().Err(err).Msg("SSE 写入失败,客户端可能已断开") + } +} + +// validateKiroCredential 验证单个 Kiro 凭证 +// 返回 status: "valid" | "suspended" | "expired" | "invalid" | "error" +func validateKiroCredential(ctx context.Context, httpClient *http.Client, clientID, clientSecret, refreshToken string) (string, error) { + + // ── Step1: 刷新 access_token ── + refreshBody, _ := json.Marshal(map[string]string{ + "clientId": clientID, + "clientSecret": clientSecret, + "refreshToken": refreshToken, + "grantType": "refresh_token", + }) + + req, err := http.NewRequestWithContext(ctx, "POST", oidcTokenURL, bytes.NewReader(refreshBody)) + if err != nil { + return "error", fmt.Errorf("构建刷新请求失败: %w", err) + } + req.Header = oidcHeaders() + + resp, err := httpClient.Do(req) + if err != nil { + return "error", fmt.Errorf("刷新请求网络错误: %w", err) + } + defer resp.Body.Close() + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "error", fmt.Errorf("读取刷新响应失败: %w", err) + } + bodyStr := string(bodyBytes) + + if resp.StatusCode != http.StatusOK { + // refresh token 过期或无效 + if containsAny(bodyStr, "invalid_grant", "InvalidGrantException") { + return "expired", nil + } + return "error", fmt.Errorf("刷新失败 HTTP %d: %s", resp.StatusCode, truncateStr(bodyStr, 120)) + } + + // 解析 accessToken + var tokenResp struct { + AccessToken string `json:"accessToken"` + } + if err := json.Unmarshal(bodyBytes, &tokenResp); err != nil || tokenResp.AccessToken == "" { + return "error", fmt.Errorf("解析 accessToken 失败") + } + + // ── Step2: 用 accessToken 调 Q API 检测状态 ── + qReq, err := http.NewRequestWithContext(ctx, "GET", qUsageLimitURL, nil) + if err != nil { + return "error", fmt.Errorf("构建 Q API 请求失败: %w", err) + } + qReq.Header = qAPIHeaders(tokenResp.AccessToken) + + qResp, err := httpClient.Do(qReq) + if err != nil { + return "error", fmt.Errorf("Q API 网络错误: %w", err) + } + defer qResp.Body.Close() + + qBody, err := io.ReadAll(qResp.Body) + if err != nil { + return "error", fmt.Errorf("读取 Q API 响应失败: %w", err) + } + qStr := string(qBody) + + // 判断状态(按优先级排序) + if containsAny(qStr, "TEMPORARILY_SUSPENDED") { + return "suspended", nil + } + if qResp.StatusCode == http.StatusUnauthorized || containsAny(qStr, "ExpiredToken", "UnauthorizedException") { + return "expired", nil + } + if containsAny(qStr, "AccessDeniedException", "ValidationException", "ResourceNotFoundException") { + return "invalid", nil + } + if qResp.StatusCode == http.StatusForbidden { + return "invalid", nil + } + if qResp.StatusCode >= 500 { + return "error", fmt.Errorf("AWS 服务端错误 HTTP %d", qResp.StatusCode) + } + if qResp.StatusCode == http.StatusOK { + return "valid", nil + } + + return "invalid", fmt.Errorf("未知响应 HTTP %d: %s", qResp.StatusCode, truncateStr(qStr, 120)) +} + +// SSEValidateKiro 验活 Kiro 账号 SSE 端点(GET /ws/kiro/validate?token=&action=archive|export_archive) +func (h *KiroValidateHandler) SSEValidateKiro(c *gin.Context) { + // 1. JWT 鉴权(SSE 无法自定义 Header,通过 query token 鉴权) + token := c.Query("token") + userID, err := h.authSvc.VerifyJWT(token) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"detail": "未登录"}) + return + } + + // 2. 每用户并发锁——同一用户只能同时运行一个验活流 + userMu, _ := kiroValidateMu.LoadOrStore(userID, &sync.Mutex{}) + kmu := userMu.(*sync.Mutex) + if !kmu.TryLock() { + c.JSON(http.StatusConflict, gin.H{"detail": "已有验活任务进行中,请等待完成"}) + return + } + defer kmu.Unlock() + + // 3. action 白名单校验 + action := c.DefaultQuery("action", "archive") + if action != "validate" && action != "archive" && action != "export_archive" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 action 参数"}) + return + } + + // 4. scope 参数:active(未归档)/ archived(已归档) + scope := c.DefaultQuery("scope", "active") + if scope != "active" && scope != "archived" { + c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 scope 参数"}) + return + } + isArchived := scope == "archived" + + // 5. 查询当前用户的 Kiro 账号(排除已禁用的) + var results []model.TaskResult + if err := h.db.Where("user_id = ? AND platform = ? AND is_archived = ? AND disabled = ?", userID, "kiro", isArchived, false). + Order("created_at ASC"). + Find(&results).Error; err != nil { + log.Error().Err(err).Uint("user_id", userID).Msg("Kiro 验活查询账号失败") + c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询账号失败"}) + return + } + + if len(results) == 0 { + c.JSON(http.StatusOK, gin.H{"detail": "没有 Kiro 账号需要验证"}) + return + } + + // 6. SSE headers + c.Header("Content-Type", "text/event-stream") + c.Header("Cache-Control", "no-cache") + c.Header("Connection", "keep-alive") + c.Header("X-Accel-Buffering", "no") + + ctx := c.Request.Context() + total := len(results) + var validCount, invalidCount, errorCount int + var validResults []model.TaskResult + + // 每批在循环内轮换代理,此处不再预取 + + // 发送初始日志 + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[*] 开始验证 %d 个 Kiro 账号...", total)}) + sseWrite(c, gin.H{"type": "progress", "validated": 0, "total": total, "valid": 0, "invalid": 0}) + c.Writer.Flush() + + // 7. 分批并发验证(每批 5 个,批次间 500ms,每批轮换代理) + const batchSize = 5 + for i := 0; i < total; i += batchSize { + // 检查客户端是否断开 + select { + case <-ctx.Done(): + return + default: + } + + end := i + batchSize + if end > total { + end = total + } + batch := results[i:end] + + // 每批轮换代理,避免所有请求走同一个出口 + var batchProxyURL string + if p := h.proxyPool.GetNext(); p != nil { + batchProxyURL = p.HTTP + } + batchClient, batchErr := service.BuildProxyHTTPClient(batchProxyURL, 15*time.Second) + if batchErr != nil { + log.Warn().Err(batchErr).Msg("构建批次代理客户端失败,回退直连") + batchClient = &http.Client{Timeout: 15 * time.Second} + } + + type batchResult struct { + idx int + result model.TaskResult + status string + } + var mu sync.Mutex + var batchResults []batchResult + var wg sync.WaitGroup + + for j, r := range batch { + wg.Add(1) + go func(idx int, r model.TaskResult) { + defer wg.Done() + + // 解析 credential_data 提取 clientId/clientSecret/refreshToken + var cred map[string]interface{} + if err := json.Unmarshal(r.CredentialData, &cred); err != nil { + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: "error"}) + mu.Unlock() + return + } + + clientID, _ := cred["clientId"].(string) + clientSecret, _ := cred["clientSecret"].(string) + refreshToken, _ := cred["refreshToken"].(string) + + if clientID == "" || clientSecret == "" || refreshToken == "" { + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: "invalid"}) + mu.Unlock() + return + } + + status, validateErr := validateKiroCredential(ctx, batchClient, clientID, clientSecret, refreshToken) + if validateErr != nil { + log.Warn().Err(validateErr).Str("email", r.Email).Str("status", status).Msg("Kiro 验活异常") + } + + mu.Lock() + batchResults = append(batchResults, batchResult{idx: idx, result: r, status: status}) + mu.Unlock() + }(j, r) + } + wg.Wait() + + // 处理本批结果 + now := time.Now() + for _, br := range batchResults { + switch br.status { + case "valid": + validCount++ + validResults = append(validResults, br.result) + // 更新 last_validated_at + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Update("last_validated_at", now).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("更新 last_validated_at 失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[+] %s → 正常可用", br.result.Email)}) + case "suspended": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "SUSPENDED(已封禁)", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用封禁账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → 已封禁(SUSPENDED),已禁用", br.result.Email)}) + case "expired": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "Token 过期", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用过期账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → Token 过期,已禁用", br.result.Email)}) + case "invalid": + invalidCount++ + if err := h.db.Model(&model.TaskResult{}).Where("id = ? AND user_id = ?", br.result.ID, userID). + Updates(map[string]interface{}{"disabled": true, "disabled_reason": "账号无效", "disabled_at": now}).Error; err != nil { + log.Warn().Err(err).Uint("id", br.result.ID).Msg("禁用无效账号失败") + } + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[-] %s → 账号无效,已禁用", br.result.Email)}) + case "error": + errorCount++ + sseWrite(c, gin.H{"type": "log", "message": fmt.Sprintf("[!] %s → 检测异常(跳过),可能是 AWS 临时故障", br.result.Email)}) + } + } + + // 发送进度(已处理总数包含 error 的) + validated := validCount + invalidCount + errorCount + sseWrite(c, gin.H{"type": "progress", "validated": validated, "total": total, "valid": validCount, "invalid": invalidCount}) + c.Writer.Flush() + + // 批次间间隔(最后一批不需要) + if end < total { + time.Sleep(500 * time.Millisecond) + } + } + + // 8. 归档有效账号(仅 scope=active 且 action=archive/export_archive 时执行) + archivedCount := 0 + if !isArchived && action != "validate" && len(validResults) > 0 { + var validIDs []uint + for _, r := range validResults { + validIDs = append(validIDs, r.ID) + } + if err := h.db.Model(&model.TaskResult{}). + Where("id IN ? AND user_id = ?", validIDs, userID). + Update("is_archived", true).Error; err != nil { + log.Error().Err(err).Msg("Kiro 验活归档更新失败") + } else { + archivedCount = len(validResults) + } + } + + // 9. 构建 complete 事件 + completeData := gin.H{ + "type": "complete", + "total": total, + "valid": validCount, + "invalid": invalidCount, + "error": errorCount, + "archived": archivedCount, + } + + // 附带有效账号的 credential_data:export_archive 模式 或 scope=archived 时 + if (action == "export_archive" || isArchived) && len(validResults) > 0 { + type exportItem struct { + ID uint `json:"id"` + Email string `json:"email"` + CredentialData json.RawMessage `json:"credential_data"` + } + creds := make([]exportItem, 0, len(validResults)) + for _, r := range validResults { + creds = append(creds, exportItem{ + ID: r.ID, + Email: r.Email, + CredentialData: json.RawMessage(r.CredentialData), + }) + } + completeData["credentials"] = creds + } + + logMsg := fmt.Sprintf( + "[+] 验证完成: 共 %d 个,正常 %d 个,失效 %d 个已禁用,异常跳过 %d 个", + total, validCount, invalidCount, errorCount, + ) + if archivedCount > 0 { + logMsg += fmt.Sprintf(",已归档 %d 个", archivedCount) + } + sseWrite(c, gin.H{"type": "log", "message": logMsg}) + sseWrite(c, completeData) + c.Writer.Flush() +} diff --git a/internal/worker/kiro/kiro_worker.go b/internal/worker/kiro/kiro_worker.go new file mode 100644 index 0000000..29c85bd --- /dev/null +++ b/internal/worker/kiro/kiro_worker.go @@ -0,0 +1,303 @@ +package worker + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" +) + +// KiroWorker AWS Kiro(Builder ID)平台注册器 +// 注册流程:GPTMail 生成邮箱 → 调用 aws-builder-id-reg Python 服务 → 浏览器完成注册 +// Python 服务内置全维度指纹伪造(Canvas/WebGL/Audio/Navigator/Screen/WebRTC)及人类行为模拟 +type KiroWorker struct{} + +func init() { + Register(&KiroWorker{}) +} + +func (w *KiroWorker) PlatformName() string { return "kiro" } + +// ScanConfig Kiro 无需预扫描(注册页结构固定,无需提取 site_key 等动态配置) +func (w *KiroWorker) ScanConfig(_ context.Context, _ *ProxyEntry, cfg Config) (Config, error) { + return cfg, nil +} + +// RegisterOne 执行一次 Kiro 注册 +func (w *KiroWorker) RegisterOne(ctx context.Context, opts RegisterOpts) { + cfg := opts.Config + + // ── 读取必要配置 ────────────────────────────────────────────────────── + kiroRegURL := strings.TrimRight(cfg["kiro_reg_url"], "/") + + if kiroRegURL == "" { + logSend(opts.LogCh, "[!] Kiro 注册服务地址未配置(kiro_reg_url),请在系统设置中填写") + opts.OnFail() + return + } + + // ── Step 1: 通过 MultiProvider 创建临时邮箱(支持自动降级)────────── + logSend(opts.LogCh, "[*] Kiro 注册开始,创建临时邮箱...") + + // 平台专属邮箱 provider 覆盖全局优先级 + if platformProviders := cfg["kiro_email_providers"]; platformProviders != "" { + cfg["email_provider_priority"] = platformProviders + } + mailProvider := tempmail.NewMultiProvider(opts.Config) + email, mailMeta, err := mailProvider.GenerateEmail(ctx) + if err != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 创建邮箱失败: %v", err)) + opts.OnFail() + return + } + logSend(opts.LogCh, fmt.Sprintf("[+] 邮箱已创建: %s (via %s)", email, mailMeta["provider"])) + + // ── 随机启动抖动,分散并发请求 ──────────────────────────────────────── + jitter := time.Duration(rand.Intn(3000)) * time.Millisecond + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + // ── Step 2: 组装代理参数 ────────────────────────────────────────────── + proxyStr := "" + // 优先级:用户指定代理 > Kiro 专用代理 > 任务级代理 + if userProxy := cfg["user_proxy"]; userProxy != "" { + proxyStr = userProxy + logSend(opts.LogCh, "[*] 使用用户指定代理") + } else if kiroProxy := cfg["kiro_proxy"]; kiroProxy != "" { + proxyStr = kiroProxy + } else if opts.Proxy != nil && opts.Proxy.HTTP != "" { + proxyStr = opts.Proxy.HTTP + } + + // ── Step 3: 调用 aws-builder-id-reg Python 服务(流式日志)──────────── + // retriable 错误最多重试 3 次(换新邮箱,同一代理) + const maxRetry = 3 + var result *kiroRegResponse + var rawJSON json.RawMessage + var callErr error + for attempt := 1; attempt <= maxRetry; attempt++ { + if attempt > 1 { + logSend(opts.LogCh, fmt.Sprintf("[*] 第 %d 次重试(换新邮箱)...", attempt)) + // 换新邮箱(通过 MultiProvider,自动降级) + newEmail, newMeta, newErr := mailProvider.GenerateEmail(ctx) + if newErr != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 重试创建邮箱失败: %v", newErr)) + break + } + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + email = newEmail + mailMeta = newMeta + logSend(opts.LogCh, fmt.Sprintf("[+] 重试邮箱: %s (via %s)", email, mailMeta["provider"])) + } + logSend(opts.LogCh, "[*] 调用浏览器注册服务(指纹伪造 + 行为模拟)...") + result, rawJSON, callErr = w.callRegService(ctx, kiroRegURL, email, proxyStr, mailMeta, cfg, opts.LogCh) + if callErr != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 注册服务调用失败: %v", callErr)) + break + } + if result.OK || !result.Retriable { + break // 成功或不可重试的错误,直接退出 + } + logSend(opts.LogCh, fmt.Sprintf("[!] 注册被拒绝(可重试): %s,准备重试...", result.Error)) + } + // ── Step 4: 处理结果 ────────────────────────────────────────────────── + if callErr != nil { + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + opts.OnFail() + return + } + if result == nil || !result.OK { + errMsg := "" + if result != nil { + errMsg = result.Error + } + logSend(opts.LogCh, fmt.Sprintf("[!] Kiro 注册失败: %s", errMsg)) + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + opts.OnFail() + return + } + + // 解析完整 JSON,保留远程服务返回的所有字段 + credential := map[string]interface{}{} + json.Unmarshal(rawJSON, &credential) //nolint:errcheck + // 清理控制字段 + delete(credential, "ok") + delete(credential, "error") + delete(credential, "retriable") + delete(credential, "logs") + // 补充元数据 + credential["platform"] = "kiro" + credential["type"] = "kiro" + credential["auth_method"] = "builder-id" + credential["provider"] = "AWS" + credential["disabled"] = false + // 保存邮箱 provider 信息,供后续 FetchOTP 使用 + credential["mail_provider"] = mailMeta["provider"] + tempmail.RecordSuccess(mailMeta["provider"]) + emailMetaCopy := make(map[string]interface{}, len(mailMeta)) + for k, v := range mailMeta { + emailMetaCopy[k] = v + } + credential["email_meta"] = emailMetaCopy + if _, ok := credential["profile_arn"]; !ok { + credential["profile_arn"] = "" + } + if _, ok := credential["start_url"]; !ok { + credential["start_url"] = "" + } + if result.Warning != "" { + credential["warning"] = result.Warning + logSend(opts.LogCh, fmt.Sprintf("[~] 提示: %s", result.Warning)) + } + + // 日志只记录邮箱,不打印密码(密码通过 OnSuccess 安全存储) + logSend(opts.LogCh, fmt.Sprintf("[+] Kiro 注册成功: %s", result.Email)) + + // 清理邮箱(非关键步骤,失败不影响结果) + if err := mailProvider.DeleteEmail(context.Background(), email, mailMeta); err != nil { + log.Warn().Str("email", email).Err(err).Msg("Kiro: 清理邮箱失败(非关键)") + } + + opts.OnSuccess(result.Email, credential) +} + +// ─── 内部类型 ──────────────────────────────────────────────────────────────── + +// kiroRegRequest aws-builder-id-reg 服务请求体 +type kiroRegRequest struct { + Email string `json:"email"` + Proxy string `json:"proxy,omitempty"` + YYDSMailURL string `json:"yydsmail_url,omitempty"` + YYDSMailKey string `json:"yydsmail_key,omitempty"` + Region string `json:"region"` + MailProvider string `json:"mail_provider,omitempty"` // 邮箱 provider 类型(如 "yydsmail") + MailMeta map[string]string `json:"mail_meta,omitempty"` // provider 特有元数据(token/sid_token 等) +} + +// kiroRegResponse aws-builder-id-reg 服务响应体 +type kiroRegResponse struct { + OK bool `json:"ok"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Warning string `json:"warning"` + Error string `json:"error"` + Retriable bool `json:"retriable"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + ExpiresAt string `json:"expires_at"` + LastRefresh string `json:"last_refresh"` + Region string `json:"region"` +} + +// ─── 内部方法 ──────────────────────────────────────────────────────────────── + +// logSend 非阻塞写入日志通道(防止消费者退出时 goroutine 泄漏) +// 使用 recover 防止向已关闭 channel 写入导致 panic(inflight worker 可能在 LogCh 关闭后仍在运行) +func logSend(ch chan<- string, msg string) { + defer func() { recover() }() + select { + case ch <- msg: + default: + } +} + +// callRegService 调用 aws-builder-id-reg Python 服务执行浏览器注册。 +// 响应为流式文本:每行 "LOG:消息" 是实时日志,最后一行是 JSON 结果。 +func (w *KiroWorker) callRegService( + ctx context.Context, + baseURL, email, proxy string, + mailMeta map[string]string, + cfg Config, + logCh chan<- string, +) (*kiroRegResponse, json.RawMessage, error) { + reqBody := kiroRegRequest{ + Email: email, + Proxy: proxy, + YYDSMailURL: strings.TrimRight(cfg["yydsmail_base_url"], "/"), + YYDSMailKey: cfg["yydsmail_api_key"], + Region: "usa", + MailProvider: mailMeta["provider"], + MailMeta: mailMeta, + } + if reqBody.YYDSMailURL == "" { + reqBody.YYDSMailURL = "" + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, fmt.Errorf("序列化请求失败: %w", err) + } + + // Python 端 RESPONSE_TIMEOUT=400s + 浏览器启动/关闭开销,Go 侧需要留足余量 + callCtx, cancel := context.WithTimeout(ctx, 7*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(callCtx, http.MethodPost, + baseURL+"/kiro/process", bytes.NewReader(body)) + if err != nil { + return nil, nil, fmt.Errorf("构建 HTTP 请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + // 不设 Timeout:流式响应会持续很久,由 context 控制超时 + Timeout: 0, + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP 请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var preview [200]byte + n, _ := resp.Body.Read(preview[:]) + return nil, nil, fmt.Errorf("服务返回 HTTP %d: %s", resp.StatusCode, string(preview[:n])) + } + + // 逐行读取流式响应 + // - "LOG:xxx" 行:实时转发到前端日志 + // - 最后一行(非 LOG: 前缀):JSON 结果 + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + var lastLine string + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "LOG:") { + msg := strings.TrimSpace(strings.TrimPrefix(line, "LOG:")) + if msg != "" && msg != "." { + logSend(logCh, "[~] "+msg) + } + } else if line != "" { + lastLine = line + } + } + if err := scanner.Err(); err != nil { + return nil, nil, fmt.Errorf("读取流式响应失败: %w", err) + } + if lastLine == "" { + return nil, nil, fmt.Errorf("服务未返回结果行") + } + + var result kiroRegResponse + if err := json.Unmarshal([]byte(lastLine), &result); err != nil { + return nil, nil, fmt.Errorf("解析响应失败: %w", err) + } + + return &result, json.RawMessage(lastLine), nil +} diff --git a/internal/worker/kiro_worker.go b/internal/worker/kiro_worker.go new file mode 100644 index 0000000..29c85bd --- /dev/null +++ b/internal/worker/kiro_worker.go @@ -0,0 +1,303 @@ +package worker + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "strings" + "time" + + "github.com/rs/zerolog/log" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" +) + +// KiroWorker AWS Kiro(Builder ID)平台注册器 +// 注册流程:GPTMail 生成邮箱 → 调用 aws-builder-id-reg Python 服务 → 浏览器完成注册 +// Python 服务内置全维度指纹伪造(Canvas/WebGL/Audio/Navigator/Screen/WebRTC)及人类行为模拟 +type KiroWorker struct{} + +func init() { + Register(&KiroWorker{}) +} + +func (w *KiroWorker) PlatformName() string { return "kiro" } + +// ScanConfig Kiro 无需预扫描(注册页结构固定,无需提取 site_key 等动态配置) +func (w *KiroWorker) ScanConfig(_ context.Context, _ *ProxyEntry, cfg Config) (Config, error) { + return cfg, nil +} + +// RegisterOne 执行一次 Kiro 注册 +func (w *KiroWorker) RegisterOne(ctx context.Context, opts RegisterOpts) { + cfg := opts.Config + + // ── 读取必要配置 ────────────────────────────────────────────────────── + kiroRegURL := strings.TrimRight(cfg["kiro_reg_url"], "/") + + if kiroRegURL == "" { + logSend(opts.LogCh, "[!] Kiro 注册服务地址未配置(kiro_reg_url),请在系统设置中填写") + opts.OnFail() + return + } + + // ── Step 1: 通过 MultiProvider 创建临时邮箱(支持自动降级)────────── + logSend(opts.LogCh, "[*] Kiro 注册开始,创建临时邮箱...") + + // 平台专属邮箱 provider 覆盖全局优先级 + if platformProviders := cfg["kiro_email_providers"]; platformProviders != "" { + cfg["email_provider_priority"] = platformProviders + } + mailProvider := tempmail.NewMultiProvider(opts.Config) + email, mailMeta, err := mailProvider.GenerateEmail(ctx) + if err != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 创建邮箱失败: %v", err)) + opts.OnFail() + return + } + logSend(opts.LogCh, fmt.Sprintf("[+] 邮箱已创建: %s (via %s)", email, mailMeta["provider"])) + + // ── 随机启动抖动,分散并发请求 ──────────────────────────────────────── + jitter := time.Duration(rand.Intn(3000)) * time.Millisecond + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + // ── Step 2: 组装代理参数 ────────────────────────────────────────────── + proxyStr := "" + // 优先级:用户指定代理 > Kiro 专用代理 > 任务级代理 + if userProxy := cfg["user_proxy"]; userProxy != "" { + proxyStr = userProxy + logSend(opts.LogCh, "[*] 使用用户指定代理") + } else if kiroProxy := cfg["kiro_proxy"]; kiroProxy != "" { + proxyStr = kiroProxy + } else if opts.Proxy != nil && opts.Proxy.HTTP != "" { + proxyStr = opts.Proxy.HTTP + } + + // ── Step 3: 调用 aws-builder-id-reg Python 服务(流式日志)──────────── + // retriable 错误最多重试 3 次(换新邮箱,同一代理) + const maxRetry = 3 + var result *kiroRegResponse + var rawJSON json.RawMessage + var callErr error + for attempt := 1; attempt <= maxRetry; attempt++ { + if attempt > 1 { + logSend(opts.LogCh, fmt.Sprintf("[*] 第 %d 次重试(换新邮箱)...", attempt)) + // 换新邮箱(通过 MultiProvider,自动降级) + newEmail, newMeta, newErr := mailProvider.GenerateEmail(ctx) + if newErr != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 重试创建邮箱失败: %v", newErr)) + break + } + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + email = newEmail + mailMeta = newMeta + logSend(opts.LogCh, fmt.Sprintf("[+] 重试邮箱: %s (via %s)", email, mailMeta["provider"])) + } + logSend(opts.LogCh, "[*] 调用浏览器注册服务(指纹伪造 + 行为模拟)...") + result, rawJSON, callErr = w.callRegService(ctx, kiroRegURL, email, proxyStr, mailMeta, cfg, opts.LogCh) + if callErr != nil { + logSend(opts.LogCh, fmt.Sprintf("[!] 注册服务调用失败: %v", callErr)) + break + } + if result.OK || !result.Retriable { + break // 成功或不可重试的错误,直接退出 + } + logSend(opts.LogCh, fmt.Sprintf("[!] 注册被拒绝(可重试): %s,准备重试...", result.Error)) + } + // ── Step 4: 处理结果 ────────────────────────────────────────────────── + if callErr != nil { + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + opts.OnFail() + return + } + if result == nil || !result.OK { + errMsg := "" + if result != nil { + errMsg = result.Error + } + logSend(opts.LogCh, fmt.Sprintf("[!] Kiro 注册失败: %s", errMsg)) + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + opts.OnFail() + return + } + + // 解析完整 JSON,保留远程服务返回的所有字段 + credential := map[string]interface{}{} + json.Unmarshal(rawJSON, &credential) //nolint:errcheck + // 清理控制字段 + delete(credential, "ok") + delete(credential, "error") + delete(credential, "retriable") + delete(credential, "logs") + // 补充元数据 + credential["platform"] = "kiro" + credential["type"] = "kiro" + credential["auth_method"] = "builder-id" + credential["provider"] = "AWS" + credential["disabled"] = false + // 保存邮箱 provider 信息,供后续 FetchOTP 使用 + credential["mail_provider"] = mailMeta["provider"] + tempmail.RecordSuccess(mailMeta["provider"]) + emailMetaCopy := make(map[string]interface{}, len(mailMeta)) + for k, v := range mailMeta { + emailMetaCopy[k] = v + } + credential["email_meta"] = emailMetaCopy + if _, ok := credential["profile_arn"]; !ok { + credential["profile_arn"] = "" + } + if _, ok := credential["start_url"]; !ok { + credential["start_url"] = "" + } + if result.Warning != "" { + credential["warning"] = result.Warning + logSend(opts.LogCh, fmt.Sprintf("[~] 提示: %s", result.Warning)) + } + + // 日志只记录邮箱,不打印密码(密码通过 OnSuccess 安全存储) + logSend(opts.LogCh, fmt.Sprintf("[+] Kiro 注册成功: %s", result.Email)) + + // 清理邮箱(非关键步骤,失败不影响结果) + if err := mailProvider.DeleteEmail(context.Background(), email, mailMeta); err != nil { + log.Warn().Str("email", email).Err(err).Msg("Kiro: 清理邮箱失败(非关键)") + } + + opts.OnSuccess(result.Email, credential) +} + +// ─── 内部类型 ──────────────────────────────────────────────────────────────── + +// kiroRegRequest aws-builder-id-reg 服务请求体 +type kiroRegRequest struct { + Email string `json:"email"` + Proxy string `json:"proxy,omitempty"` + YYDSMailURL string `json:"yydsmail_url,omitempty"` + YYDSMailKey string `json:"yydsmail_key,omitempty"` + Region string `json:"region"` + MailProvider string `json:"mail_provider,omitempty"` // 邮箱 provider 类型(如 "yydsmail") + MailMeta map[string]string `json:"mail_meta,omitempty"` // provider 特有元数据(token/sid_token 等) +} + +// kiroRegResponse aws-builder-id-reg 服务响应体 +type kiroRegResponse struct { + OK bool `json:"ok"` + Email string `json:"email"` + Password string `json:"password"` + Name string `json:"name"` + Warning string `json:"warning"` + Error string `json:"error"` + Retriable bool `json:"retriable"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + ExpiresAt string `json:"expires_at"` + LastRefresh string `json:"last_refresh"` + Region string `json:"region"` +} + +// ─── 内部方法 ──────────────────────────────────────────────────────────────── + +// logSend 非阻塞写入日志通道(防止消费者退出时 goroutine 泄漏) +// 使用 recover 防止向已关闭 channel 写入导致 panic(inflight worker 可能在 LogCh 关闭后仍在运行) +func logSend(ch chan<- string, msg string) { + defer func() { recover() }() + select { + case ch <- msg: + default: + } +} + +// callRegService 调用 aws-builder-id-reg Python 服务执行浏览器注册。 +// 响应为流式文本:每行 "LOG:消息" 是实时日志,最后一行是 JSON 结果。 +func (w *KiroWorker) callRegService( + ctx context.Context, + baseURL, email, proxy string, + mailMeta map[string]string, + cfg Config, + logCh chan<- string, +) (*kiroRegResponse, json.RawMessage, error) { + reqBody := kiroRegRequest{ + Email: email, + Proxy: proxy, + YYDSMailURL: strings.TrimRight(cfg["yydsmail_base_url"], "/"), + YYDSMailKey: cfg["yydsmail_api_key"], + Region: "usa", + MailProvider: mailMeta["provider"], + MailMeta: mailMeta, + } + if reqBody.YYDSMailURL == "" { + reqBody.YYDSMailURL = "" + } + body, err := json.Marshal(reqBody) + if err != nil { + return nil, nil, fmt.Errorf("序列化请求失败: %w", err) + } + + // Python 端 RESPONSE_TIMEOUT=400s + 浏览器启动/关闭开销,Go 侧需要留足余量 + callCtx, cancel := context.WithTimeout(ctx, 7*time.Minute) + defer cancel() + + req, err := http.NewRequestWithContext(callCtx, http.MethodPost, + baseURL+"/kiro/process", bytes.NewReader(body)) + if err != nil { + return nil, nil, fmt.Errorf("构建 HTTP 请求失败: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + // 不设 Timeout:流式响应会持续很久,由 context 控制超时 + Timeout: 0, + } + + resp, err := client.Do(req) + if err != nil { + return nil, nil, fmt.Errorf("HTTP 请求失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + var preview [200]byte + n, _ := resp.Body.Read(preview[:]) + return nil, nil, fmt.Errorf("服务返回 HTTP %d: %s", resp.StatusCode, string(preview[:n])) + } + + // 逐行读取流式响应 + // - "LOG:xxx" 行:实时转发到前端日志 + // - 最后一行(非 LOG: 前缀):JSON 结果 + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 1024*1024), 1024*1024) + + var lastLine string + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "LOG:") { + msg := strings.TrimSpace(strings.TrimPrefix(line, "LOG:")) + if msg != "" && msg != "." { + logSend(logCh, "[~] "+msg) + } + } else if line != "" { + lastLine = line + } + } + if err := scanner.Err(); err != nil { + return nil, nil, fmt.Errorf("读取流式响应失败: %w", err) + } + if lastLine == "" { + return nil, nil, fmt.Errorf("服务未返回结果行") + } + + var result kiroRegResponse + if err := json.Unmarshal([]byte(lastLine), &result); err != nil { + return nil, nil, fmt.Errorf("解析响应失败: %w", err) + } + + return &result, json.RawMessage(lastLine), nil +} diff --git a/internal/worker/openai_api.go b/internal/worker/openai_api.go new file mode 100644 index 0000000..9d84c0e --- /dev/null +++ b/internal/worker/openai_api.go @@ -0,0 +1,339 @@ +package worker + +import ( + "context" + "fmt" + "math/rand" + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" +) + +// openaiRegRequest 远程注册请求 +type openaiRegRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Proxy string `json:"proxy"` + YYDSMailURL string `json:"yydsmail_url"` + YYDSMailKey string `json:"yydsmail_key"` + EmailPriority string `json:"email_priority"` // 邮箱 Provider 优先级(如 "yydsmail") +} + +// OpenAIProtocolRegisterHandler 纯 HTTP 协议注册端点 +// POST /api/v1/process +// 供远程调用:日本后端 → CF Workers → HF Space +func OpenAIProtocolRegisterHandler(c *gin.Context) { + var req openaiRegRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"ok": false, "error": "invalid request: " + err.Error()}) + return + } + + // 构建 multi-provider 配置 + priority := req.EmailPriority + if priority == "" { + priority = "yydsmail" + } + cfg := map[string]string{ + "yydsmail_api_key": req.YYDSMailKey, + "yydsmail_base_url": req.YYDSMailURL, + "email_provider_priority": priority, + } + mailProvider := tempmail.NewMultiProvider(cfg) + + // 日志收集(在邮箱创建之前定义,确保全流程都有日志) + logs := make([]string, 0, 20) + logf := func(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + logs = append(logs, msg) + fmt.Printf("[LOG] %s\n", msg) // 同时打印到 stdout,方便调试 + } + + // 诊断日志:记录收到的配置 + logf("[*] 邮箱优先级: %s", priority) + creds := "" + if req.YYDSMailKey != "" { + creds += "yydsmail " + } + if creds == "" { + creds = "(未配置凭证)" + } + logf("[*] 可用凭证: %s", creds) + if req.Proxy != "" { + logf("[*] 代理: %s", req.Proxy) + } + + // 创建邮箱(如果未提供) + email := req.Email + password := req.Password + var mailMeta map[string]string + + if email == "" { + var err error + email, mailMeta, err = mailProvider.GenerateEmail(c.Request.Context()) + if err != nil { + logf("[-] 创建邮箱失败: %s", err) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "initialization failed: " + err.Error(), "logs": logs}) + return + } + logf("[+] 邮箱创建成功: %s (via %s)", email, mailMeta["provider"]) + } else { + // 后端已生成邮箱,使用 yydsmail 轮询验证码 + mailMeta = map[string]string{"provider": "yydsmail"} + logf("[*] 使用后端提供的邮箱: %s", email) + } + if password == "" { + password = genOpenAIPassword(16) + } + + // 排队等待信息(由 ConcurrencyLimiter 中间件注入 gin context) + if waited, ok := c.Get("queue_waited"); ok && waited.(bool) { + pos, _ := c.Get("queue_position") + waitSec, _ := c.Get("queue_wait_seconds") + logf("[*] 排队等待完成: 位置 #%d, 等待 %d 秒", pos, waitSec) + } + + // 构建代理 + var proxy *ProxyEntry + if req.Proxy != "" { + proxy = &ProxyEntry{HTTPS: req.Proxy, HTTP: req.Proxy} + } + + // 执行注册(最长 5 分钟) + ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Minute) + defer cancel() + + var reg *openaiRegistrar + firstName, lastName := randomOpenAIName() + birthdate := randomBirthdate() + + // step0 + step2 带重试(最多 3 次),每次重建 registrar 避免脏 session 状态 + var step0_2OK bool + for attempt := 0; attempt < 3; attempt++ { + if attempt > 0 { + logf("[*] step0+step2 重试 (%d/3)...", attempt+1) + ctxSleep(ctx, time.Duration(attempt)*time.Second) + } + + reg = newOpenAIRegistrar(proxy, logf) + logf("[*] 预热中...") + reg.doRequest(ctx, "GET", "https://chatgpt.com/", nil, reg.navHeaders()) //nolint:errcheck + ctxSleep(ctx, 300*time.Millisecond) + + logf("[*] step0: 初始化注册会话...") + if err := reg.step0(ctx, email); err != nil { + logf("[-] step0 失败: %s", err) + continue + } + logf("[+] step0 完成") + ctxSleep(ctx, time.Second) + + logf("[*] step2: 提交邮箱和密码...") + if err := reg.step2(ctx, email, password); err != nil { + logf("[-] step2 失败: %s", err) + continue + } + logf("[+] step2 完成") + ctxSleep(ctx, time.Second) + + step0_2OK = true + break + } + if !step0_2OK { + logf("[-] step0+step2 重试 3 次仍失败") + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "session init failed after retries", "email": email, "logs": logs}) + return + } + + logf("[*] step3: 请求发送验证码...") + if err := reg.step3(ctx); err != nil { + logf("[!] step3 发送验证码可能失败: %s(继续等待,可能仍会收到)", err) + } + + // 等待验证码(45s 超时,超时后尝试换邮箱内联重试一次,与本地协议逻辑对齐) + logf("[*] 等待验证码(最长 45s)...") + code := pollMailCode(ctx, mailProvider, email, mailMeta, 45*time.Second, logf) + if code == "" { + logf("[-] 验证码超时,尝试换邮箱重试...") + tempmail.RecordFailure(mailMeta["provider"]) + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + + // 内联重试:用同一个 MultiProvider 换邮箱(会自动降级到下一个 provider) + email2, mailMeta2, err2 := mailProvider.GenerateEmail(ctx) + if err2 != nil { + logf("[-] 换邮箱失败: %s", err2) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "verification timeout", "email": email, "logs": logs}) + return + } + logf("[*] 换邮箱重试: %s (via %s)", email2, mailMeta2["provider"]) + + // 用新邮箱重走 step0-3 + reg2 := newOpenAIRegistrar(proxy, logf) + logf("[*] 预热中...") + reg2.doRequest(ctx, "GET", "https://chatgpt.com/", nil, reg2.navHeaders()) //nolint:errcheck + ctxSleep(ctx, 300*time.Millisecond) + + if err := reg2.step0(ctx, email2); err != nil { + logf("[-] 重试 step0: %s", err) + go mailProvider.DeleteEmail(context.Background(), email2, mailMeta2) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "retry init failed: " + err.Error(), "email": email, "logs": logs}) + return + } + if err := reg2.step2(ctx, email2, password); err != nil { + logf("[-] 重试 step2: %s", err) + go mailProvider.DeleteEmail(context.Background(), email2, mailMeta2) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "retry verify failed: " + err.Error(), "email": email, "logs": logs}) + return + } + if err := reg2.step3(ctx); err != nil { + logf("[!] 重试 step3 发送验证码可能失败: %s(继续等待)", err) + } + + code = pollMailCode(ctx, mailProvider, email2, mailMeta2, 45*time.Second, logf) + if code == "" { + logf("[-] 换邮箱后验证码仍超时") + tempmail.RecordFailure(mailMeta2["provider"]) + go mailProvider.DeleteEmail(context.Background(), email2, mailMeta2) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "verification timeout after retry", "email": email2, "logs": logs}) + return + } + tempmail.RecordSuccess(mailMeta2["provider"]) + // 切换到新邮箱继续后续流程 + email = email2 + mailMeta = mailMeta2 + reg = reg2 + logf("[*] 验证码: %s", code) + } else { + tempmail.RecordSuccess(mailMeta["provider"]) + logf("[*] 验证码: %s", code) + } + + // 步骤4(失败时重发验证码重试一次,与本地协议逻辑对齐) + logf("[*] step4: 提交验证码...") + if err := reg.step4(ctx, code); err != nil { + logf("[-] %s,重发验证码重试...", err) + if err := reg.step3(ctx); err != nil { + logf("[!] 重发 step3 可能失败: %s(继续等待)", err) + } + retryCode := pollMailCode(ctx, mailProvider, email, mailMeta, 60*time.Second, logf) + if retryCode == "" || retryCode == code { + logf("[-] 重试验证码超时或未刷新") + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "verification retry failed", "email": email, "logs": logs}) + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + return + } + logf("[*] 重试验证码: %s", retryCode) + if err2 := reg.step4(ctx, retryCode); err2 != nil { + logf("[-] %s", err2) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": err2.Error(), "email": email, "logs": logs}) + go mailProvider.DeleteEmail(context.Background(), email, mailMeta) + return + } + } + logf("[+] step4 完成") + ctxSleep(ctx, time.Second) + + logf("[*] step5: 填写个人信息...") + step5Failed := false + if err := reg.step5(ctx, firstName, lastName, birthdate); err != nil { + // step5 只是填姓名生日,此时账号已注册成功(step4 验证码已通过),不删邮箱 + logf("[!] step5 失败: %s(账号已创建,继续尝试获取 token)", err) + step5Failed = true + } + + if step5Failed { + logf("[+] 注册部分成功(step5 未完成): %s", email) + } else { + logf("[+] 注册成功: %s", email) + } + + // ── OAuth Token 获取(多级降级 + 重试,与 registerViaProtocol 逻辑对齐) ── + logf("[*] 获取 Token...") + + result := gin.H{ + "ok": true, + "email": email, + "password": password, + "first_name": firstName, + "last_name": lastName, + "birthdate": birthdate, + } + tokenOK := false + + // 第一级:从注册 session 的 consent 流程获取 code(step5 失败则跳过) + // 注意:OAuth 重试详情仅输出到 stdout 调试,不推送给前端用户 + if !step5Failed { + // 等待 3-6s(随机抖动),让 OpenAI 后端状态充分传播 + jitter := time.Duration(3000+rand.Intn(3000)) * time.Millisecond + ctxSleep(ctx, jitter) + authCode, err := reg.step6(ctx) + if err == nil { + codex, tokenErr := reg.step7(ctx, authCode) + if tokenErr == nil { + logf("[+] Token 获取成功") + for k, v := range codex { + if k != "logs" { + result[k] = v + } + } + tokenOK = true + } else { + fmt.Printf("[DEBUG] Token 交换失败: %s\n", tokenErr) + } + } else { + fmt.Printf("[DEBUG] consent 流程失败: %s\n", err) + } + } else { + fmt.Printf("[DEBUG] step5 失败,跳过 consent,直接尝试 OAuth 重新登录\n") + } + + // 第二级:全流程 OAuth 重新登录(最多重试 3 次,递增延迟 + 随机抖动避免并发踩踏 429) + if !tokenOK { + oauthBaseDelays := []time.Duration{5 * time.Second, 12 * time.Second, 25 * time.Second} + for attempt, base := range oauthBaseDelays { + if ctx.Err() != nil { + break + } + jitter := time.Duration(rand.Intn(3000)) * time.Millisecond + delay := base + jitter + fmt.Printf("[DEBUG] OAuth 重新登录第 %d 次尝试,等待 %s...\n", attempt+1, delay) + ctxSleep(ctx, delay) + if ctx.Err() != nil { + break + } + codex, loginErr := reg.oauthLogin(ctx, email, password, mailProvider, mailMeta) + if loginErr == nil { + logf("[+] Token 获取成功") + for k, v := range codex { + if k != "logs" { + result[k] = v + } + } + tokenOK = true + break + } + fmt.Printf("[DEBUG] OAuth 重新登录第 %d 次失败: %s\n", attempt+1, loginErr) + } + } + + // Token 获取全部失败:返回失败,不保存不完整凭证 + if !tokenOK { + fmt.Printf("[DEBUG] %s Token 获取失败,标记为失败\n", email) + c.JSON(http.StatusOK, gin.H{"ok": false, "error": "token acquisition failed", "email": email, "logs": logs}) + return + } + + // 注入邮箱元数据,供后续 OTP 查询使用 + result["mail_provider"] = mailMeta["provider"] + emailMetaCopy := make(map[string]interface{}, len(mailMeta)) + for k, v := range mailMeta { + emailMetaCopy[k] = v + } + result["email_meta"] = emailMetaCopy + + result["logs"] = logs + c.JSON(http.StatusOK, result) +} diff --git a/internal/worker/openai_team_worker.go b/internal/worker/openai_team_worker.go new file mode 100644 index 0000000..6463d82 --- /dev/null +++ b/internal/worker/openai_team_worker.go @@ -0,0 +1,537 @@ +package worker + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strings" + "time" +) + +// ── Stripe / OpenAI 常量 ── + +const ( + chatgptBaseURL = "https://chatgpt.com" + stripeBaseURL = "https://api.stripe.com/v1" + defaultPromo = "team-1-month-free" + defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + stripeJSOrigin = "https://js.stripe.com" + stripeJSReferer = "https://js.stripe.com/" + stripeJSVersion = "2024-12-18.acacia" +) + +// OpenAITeamWorker ChatGPT Team 全协议化开通 +type OpenAITeamWorker struct{} + +func init() { + Register(&OpenAITeamWorker{}) +} + +func (w *OpenAITeamWorker) PlatformName() string { return "openai_team" } + +func (w *OpenAITeamWorker) ScanConfig(ctx context.Context, proxy *ProxyEntry, cfg Config) (Config, error) { + // openai_team 需要 stripe_pk(已内置)和卡池(通过 task engine 注入) + return Config{}, nil +} + +// RegisterOne 执行一次 Team 开通(双模式) +// 模式 1 — 有 session_token: 直接开通(已有账号) +// 模式 2 — 无 session_token: 先注册 OpenAI 账号,再串联开通 Team +// Config 中需包含卡信息:card_number, exp_month, exp_year, cvc, billing_* 等 +func (w *OpenAITeamWorker) RegisterOne(ctx context.Context, opts RegisterOpts) { + jitter := time.Duration(rand.Intn(1000)) * time.Millisecond + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + logf := func(format string, args ...interface{}) { + select { + case opts.LogCh <- fmt.Sprintf(format, args...): + default: + } + } + + cfg := opts.Config + email := cfg["email"] + sessionToken := cfg["session_token"] + accessToken := cfg["access_token"] + workspaceName := cfg["workspace_name"] + if workspaceName == "" { + workspaceName = "My Team" + } + seatQty := cfg["seat_quantity"] + if seatQty == "" { + seatQty = "2" + } + + if cfg["card_number"] == "" { + logf("[!] 缺少卡信息") + opts.OnFail() + return + } + + // ── 模式 2: 先注册 OpenAI 账号 ── + if sessionToken == "" { + logf("[~] 未提供 session_token,先注册 OpenAI 账号...") + openaiW, ok := Get("openai") + if !ok { + logf("[!] OpenAI 注册器未找到") + opts.OnFail() + return + } + regDone := make(chan bool, 1) + var regEmail string + var regData map[string]interface{} + + openaiW.RegisterOne(ctx, RegisterOpts{ + Proxy: opts.Proxy, Config: opts.Config, LogCh: opts.LogCh, + OnSuccess: func(e string, cred map[string]interface{}) { + regEmail = e + regData = cred + regDone <- true + }, + OnFail: func() { regDone <- false }, + }) + + if !<-regDone { + logf("[!] OpenAI 注册失败,Team 开通中止") + opts.OnFail() + return + } + email = regEmail + if st, ok := regData["session_token"].(string); ok && st != "" { + sessionToken = st + } else if at, ok := regData["access_token"].(string); ok && at != "" { + accessToken = at + } else { + logf("[!] 注册成功但未获得 session_token 或 access_token,Team 开通中止") + // 注册本身成功了,上报凭据但标记 team 失败 + opts.OnFail() + return + } + logf("[~] 注册成功: %s,继续 Team 开通...", email) + } + + logf("[~] 开始 Team 开通: %s → %s (%s 席位)", email, workspaceName, seatQty) + + // 构建 HTTP Client(可选代理) + client := &http.Client{Timeout: 30 * time.Second} + if opts.Proxy != nil && opts.Proxy.HTTPS != "" { + proxyURL, err := url.Parse(opts.Proxy.HTTPS) + if err == nil { + client.Transport = &http.Transport{Proxy: http.ProxyURL(proxyURL)} + } + } + + // Step 1: session_token → access_token(已有 access_token 则跳过) + if accessToken == "" { + if sessionToken == "" { + logf("[!] 缺少 session_token 和 access_token") + opts.OnFail() + return + } + logf("[~] Step 1/4: 获取 access_token...") + var err error + accessToken, err = w.getAccessToken(ctx, client, sessionToken) + if err != nil { + logf("[!] 获取 access_token 失败: %v", err) + opts.OnFail() + return + } + logf("[~] access_token 获取成功") + } else { + logf("[~] Step 1/4: 使用已有 access_token") + } + + // Step 2: 创建 Checkout Session + billingCountry := cfg["billing_country"] + if billingCountry == "" { + billingCountry = "US" + } + logf("[~] Step 2/6: 创建 Checkout...") + sessionID, pk, err := w.createCheckout(ctx, client, accessToken, workspaceName, seatQty, billingCountry) + if err != nil { + logf("[!] 创建 Checkout 失败: %v", err) + opts.OnFail() + return + } + logf("[~] Checkout: %s (PK: %s...)", sessionID[:24], pk[:20]) + + // Step 3: Init Checkout Session + logf("[~] Step 3/6: Init Session...") + if err := w.initSession(ctx, client, sessionID, pk); err != nil { + logf("[!] Init 失败: %v", err) + opts.OnFail() + return + } + + // Step 4: 创建 Stripe PaymentMethod + logf("[~] Step 4/6: 创建 PaymentMethod...") + pmID, err := w.createPaymentMethod(ctx, client, cfg, pk) + if err != nil { + logf("[!] 创建 PaymentMethod 失败: %v", err) + opts.OnFail() + return + } + logf("[~] PaymentMethod: %s", pmID) + + // Step 5: 确认支付 + 3DS + logf("[~] Step 5/6: 确认支付...") + confirmResult, err := w.confirmPayment(ctx, client, sessionID, pmID, pk) + if err != nil { + logf("[!] 支付确认失败: %v", err) + opts.OnFail() + return + } + + // 处理 3DS + si, _ := confirmResult["setup_intent"].(map[string]interface{}) + if si != nil { + siStatus, _ := si["status"].(string) + if siStatus == "requires_action" { + logf("[~] Step 5/6: 3DS 认证...") + if err := w.handle3DS(ctx, client, si, pk); err != nil { + logf("[!] 3DS 失败: %v", err) + opts.OnFail() + return + } + } + } + + // Step 6: 验证最终结果 + logf("[~] Step 6/6: 验证结果...") + time.Sleep(4 * time.Second) + if err := w.verifyResult(ctx, client, sessionID, pk); err != nil { + logf("[!] 验证失败: %v", err) + opts.OnFail() + return + } + + logf("[✓] Team 开通成功: %s → %s", email, workspaceName) + opts.OnSuccess(email, map[string]interface{}{ + "email": email, + "session_token": sessionToken, + "access_token": accessToken, + "workspace_name": workspaceName, + "seat_quantity": seatQty, + "plan_type": "team", + }) +} + +// ── HTTP 步骤 ── + +// getAccessToken 用 session_token 换 access_token +func (w *OpenAITeamWorker) getAccessToken(ctx context.Context, client *http.Client, sessionToken string) (string, error) { + req, err := http.NewRequestWithContext(ctx, "GET", chatgptBaseURL+"/api/auth/session", nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Accept", "*/*") + req.AddCookie(&http.Cookie{Name: "__Secure-next-auth.session-token", Value: sessionToken}) + req.AddCookie(&http.Cookie{Name: "__Secure-next-auth.callback-url", Value: chatgptBaseURL + "/"}) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return "", fmt.Errorf("JSON 解析失败: %w", err) + } + token, ok := result["accessToken"].(string) + if !ok || token == "" { + return "", fmt.Errorf("accessToken 为空") + } + return token, nil +} + +// createCheckout 创建 Stripe Checkout Session, 返回 (sessionID, publishableKey) +func (w *OpenAITeamWorker) createCheckout(ctx context.Context, client *http.Client, accessToken, workspace, seats, country string) (string, string, error) { + currency := "USD" + if isEURCountry(country) { + currency = "EUR" + } + payload := map[string]interface{}{ + "plan_name": "chatgptteamplan", + "team_plan_data": map[string]interface{}{ + "workspace_name": workspace, + "price_interval": "month", + "seat_quantity": json.Number(seats), + }, + "billing_details": map[string]interface{}{ + "country": country, + "currency": currency, + }, + "promo_campaign": map[string]interface{}{ + "promo_campaign_id": defaultPromo, + "is_coupon_from_query_param": true, + }, + "checkout_ui_mode": "redirect", + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, "POST", chatgptBaseURL+"/backend-api/payments/checkout", strings.NewReader(string(body))) + if err != nil { + return "", "", err + } + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", defaultUserAgent) + req.Header.Set("Origin", chatgptBaseURL) + + resp, err := client.Do(req) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(respBody[:min(len(respBody), 200)])) + } + var result map[string]interface{} + if err := json.Unmarshal(respBody, &result); err != nil { + return "", "", err + } + sid, _ := result["checkout_session_id"].(string) + if sid == "" { + sid, _ = result["session_id"].(string) + } + pk, _ := result["publishable_key"].(string) + if sid == "" { + return "", "", fmt.Errorf("未获得 session_id, keys=%v", mapKeys(result)) + } + return sid, pk, nil +} + +func mapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + +// initSession 初始化 Checkout Session (必须在 PM 创建前调用) +func (w *OpenAITeamWorker) initSession(ctx context.Context, client *http.Client, sessionID, pk string) error { + initURL := fmt.Sprintf("%s/payment_pages/%s/init", stripeBaseURL, sessionID) + form := url.Values{"key": {pk}, "browser_locale": {"en-US"}} + req, err := http.NewRequestWithContext(ctx, "POST", initURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("init HTTP %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + return nil +} + +// handle3DS 处理 3DS 认证 (frictionless) +func (w *OpenAITeamWorker) handle3DS(ctx context.Context, client *http.Client, si map[string]interface{}, pk string) error { + na, _ := si["next_action"].(map[string]interface{}) + if na == nil { + return fmt.Errorf("无 next_action") + } + sdk, _ := na["use_stripe_sdk"].(map[string]interface{}) + if sdk == nil { + return fmt.Errorf("无 use_stripe_sdk") + } + src, _ := sdk["three_d_secure_2_source"].(string) + if src == "" { + return fmt.Errorf("无 3DS source") + } + browserJSON, _ := json.Marshal(map[string]interface{}{ + "fingerprintAttempted": false, + "challengeWindowSize": nil, + "threeDSCompInd": "Y", + "browserJavaEnabled": false, + "browserJavascriptEnabled": true, + "browserLanguage": "en-US", + "browserColorDepth": "24", + "browserScreenHeight": "900", + "browserScreenWidth": "1440", + "browserTZ": "-480", + "browserUserAgent": defaultUserAgent, + }) + form := url.Values{ + "source": {src}, + "browser": {string(browserJSON)}, + "one_click_authn_device_support[hosted]": {"false"}, + "one_click_authn_device_support[spc_eligible]": {"false"}, + "one_click_authn_device_support[webauthn_eligible]": {"false"}, + "key": {pk}, + } + req, err := http.NewRequestWithContext(ctx, "POST", stripeBaseURL+"/3ds2/authenticate", strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", stripeJSOrigin) + req.Header.Set("Referer", stripeJSReferer) + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("3DS 响应解析失败: %w", err) + } + state, _ := result["state"].(string) + if state != "succeeded" { + errMsg, _ := result["error"].(map[string]interface{}) + if errMsg != nil { + msg, _ := errMsg["message"].(string) + return fmt.Errorf("3DS state=%s: %s", state, msg) + } + return fmt.Errorf("3DS state=%s", state) + } + return nil +} + +// verifyResult 通过 re-init 验证 SetupIntent 最终状态 +func (w *OpenAITeamWorker) verifyResult(ctx context.Context, client *http.Client, sessionID, pk string) error { + initURL := fmt.Sprintf("%s/payment_pages/%s/init", stripeBaseURL, sessionID) + form := url.Values{"key": {pk}, "browser_locale": {"en-US"}} + req, err := http.NewRequestWithContext(ctx, "POST", initURL, strings.NewReader(form.Encode())) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("verify 解析失败: %w", err) + } + si, _ := result["setup_intent"].(map[string]interface{}) + if si == nil { + return nil // 无 SI 说明可能已完成 + } + st, _ := si["status"].(string) + if st == "succeeded" { + return nil + } + lpe, _ := si["last_setup_error"].(map[string]interface{}) + if lpe != nil { + dc, _ := lpe["decline_code"].(string) + msg, _ := lpe["message"].(string) + return fmt.Errorf("card_declined:%s %s", dc, msg) + } + return fmt.Errorf("SI status=%s", st) +} + +// isEURCountry 判断是否为欧元区国家 +func isEURCountry(country string) bool { + eurCountries := map[string]bool{ + "FR": true, "DE": true, "NL": true, "BE": true, "AT": true, + "IT": true, "ES": true, "PT": true, "IE": true, "FI": true, + "GR": true, "LU": true, "SK": true, "SI": true, "EE": true, + "LV": true, "LT": true, "CY": true, "MT": true, + } + return eurCountries[country] +} + +// createPaymentMethod 创建 Stripe PaymentMethod(需 Stripe.js Origin + email) +func (w *OpenAITeamWorker) createPaymentMethod(ctx context.Context, client *http.Client, cfg Config, pk string) (string, error) { + form := url.Values{ + "type": {"card"}, + "card[number]": {cfg["card_number"]}, + "card[exp_month]": {cfg["exp_month"]}, + "card[exp_year]": {cfg["exp_year"]}, + "card[cvc]": {cfg["cvc"]}, + "billing_details[name]": {cfg["billing_name"]}, + "billing_details[email]": {cfg["billing_email"]}, + "billing_details[address][country]": {cfg["billing_country"]}, + "billing_details[address][city]": {cfg["billing_city"]}, + "billing_details[address][line1]": {cfg["billing_line1"]}, + "billing_details[address][postal_code]": {cfg["billing_zip"]}, + "key": {pk}, + "payment_user_agent": {"stripe.js/3c18316ee2; stripe-js-v3/3c18316ee2; checkout"}, + "pasted_fields": {"number"}, + "_stripe_version": {stripeJSVersion}, + } + req, err := http.NewRequestWithContext(ctx, "POST", stripeBaseURL+"/payment_methods", strings.NewReader(form.Encode())) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", stripeJSOrigin) + req.Header.Set("Referer", stripeJSReferer) + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return "", err + } + pmID, _ := result["id"].(string) + if pmID == "" { + return "", fmt.Errorf("未获得 payment_method id") + } + return pmID, nil +} + +// confirmPayment 确认 Stripe 支付, 返回完整响应 (含 setup_intent) +func (w *OpenAITeamWorker) confirmPayment(ctx context.Context, client *http.Client, sessionID, pmID, pk string) (map[string]interface{}, error) { + confirmURL := fmt.Sprintf("%s/payment_pages/%s/confirm", stripeBaseURL, sessionID) + form := url.Values{ + "payment_method": {pmID}, + "expected_amount": {"0"}, + "expected_payment_method_type": {"card"}, + "consent[terms_of_service]": {"accepted"}, + "return_url": {fmt.Sprintf("https://pay.openai.com/c/pay/%s", sessionID)}, + "key": {pk}, + } + req, err := http.NewRequestWithContext(ctx, "POST", confirmURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Origin", stripeJSOrigin) + req.Header.Set("Referer", stripeJSReferer) + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body[:min(len(body), 200)])) + } + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("响应解析失败: %w", err) + } + return result, nil +} diff --git a/internal/worker/openai_worker.go b/internal/worker/openai_worker.go new file mode 100644 index 0000000..3a77da2 --- /dev/null +++ b/internal/worker/openai_worker.go @@ -0,0 +1,1823 @@ +package worker + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "net/http/cookiejar" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + cryptorand "crypto/rand" + "crypto/sha256" + "encoding/base64" + "math/big" + + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/sentinel" + "github.com/xiaolajiaoyyds/regplatformm/internal/pkg/tempmail" + "golang.org/x/net/publicsuffix" +) + +// OpenAIWorker OpenAI 平台注册器(纯 HTTP 协议注册) +type OpenAIWorker struct{} + +func init() { + Register(&OpenAIWorker{}) +} + +func (w *OpenAIWorker) PlatformName() string { return "openai" } + +func (w *OpenAIWorker) ScanConfig(ctx context.Context, proxy *ProxyEntry, cfg Config) (Config, error) { + if cfg["yydsmail_api_key"] == "" { + return Config{}, fmt.Errorf("缺少 yydsmail_api_key 配置") + } + return Config{}, nil +} + +func (w *OpenAIWorker) RegisterOne(ctx context.Context, opts RegisterOpts) { + // 随机延迟分散启动 + jitter := time.Duration(rand.Intn(2000)) * time.Millisecond + select { + case <-ctx.Done(): + return + case <-time.After(jitter): + } + + logf := func(format string, args ...interface{}) { + select { + case opts.LogCh <- fmt.Sprintf(format, args...): + default: + } + } + + // 优先远程服务:配置了 openai_reg_url 就直接走远程,不跑本地协议 + if serviceURL := opts.Config["openai_reg_url"]; serviceURL != "" { + fmt.Println("[DEBUG] OpenAI 注册开始(远程服务模式)") + result, ok := w.registerViaService(ctx, serviceURL, opts, logf) + if ok { + email, _ := result["email"].(string) + opts.OnSuccess(email, result) + } else if ctx.Err() != nil { + return // context 取消(达标或用户停止),不计入失败 + } else { + opts.OnFail() + } + return + } + + // 未配置远程服务,走本地协议注册 + fmt.Println("[DEBUG] OpenAI 注册开始(本地协议模式)") + + succeeded := false + protoOpts := opts + protoOpts.OnSuccess = func(email string, data map[string]interface{}) { + succeeded = true + opts.OnSuccess(email, data) + } + protoOpts.OnFail = func() { /* 由外层决策是否降级 */ } + + w.registerViaProtocol(ctx, protoOpts, logf) + + if succeeded { + return + } + + if ctx.Err() != nil { + opts.OnFail() + return + } + + opts.OnFail() +} + +// ─── 纯 HTTP 协议注册 ─── + +const ( + openaiAuthBase = "https://auth.openai.com" + oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann" + oauthRedirectURI = "http://localhost:1455/auth/callback" +) + +// chromeProfile 完整的浏览器指纹配置(与 codex.py _CHROME_PROFILES 对齐) +type chromeProfile struct { + ua string // navigator.userAgent + secChUA string // Sec-Ch-Ua 短版本 + secChUAFull string // Sec-Ch-Ua-Full-Version-List + secChUAPlatVer string // Sec-Ch-Ua-Platform-Version(Windows 版本号) +} + +// chromeProfiles 4 个真实 Chrome 版本指纹(131/133/136/142) +// sec-ch-ua brand string 格式各不相同,与 codex.py 保持一致 +var chromeProfiles = []chromeProfile{ + { + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + secChUA: `"Not A;Brand";v="99", "Chromium";v="131", "Google Chrome";v="131"`, + secChUAFull: `"Not A;Brand";v="99.0.0.0", "Chromium";v="131.0.6778.264", "Google Chrome";v="131.0.6778.264"`, + secChUAPlatVer: "15.0.0", + }, + { + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + secChUA: `"Not(A:Brand";v="99", "Chromium";v="133", "Google Chrome";v="133"`, + secChUAFull: `"Not(A:Brand";v="99.0.0.0", "Chromium";v="133.0.6943.98", "Google Chrome";v="133.0.6943.98"`, + secChUAPlatVer: "15.0.0", + }, + { + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36", + secChUA: `"Not.A/Brand";v="8", "Chromium";v="136", "Google Chrome";v="136"`, + secChUAFull: `"Not.A/Brand";v="8.0.0.0", "Chromium";v="136.0.7103.93", "Google Chrome";v="136.0.7103.93"`, + secChUAPlatVer: "15.0.0", + }, + { + ua: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36", + secChUA: `"Not_A Brand";v="99", "Chromium";v="142", "Google Chrome";v="142"`, + secChUAFull: `"Not_A Brand";v="99.0.0.0", "Chromium";v="142.0.7348.0", "Google Chrome";v="142.0.7348.0"`, + secChUAPlatVer: "15.0.0", + }, +} + +// acceptLanguages 常见英语语言标头池(随机选择增加多样性) +var acceptLanguages = []string{ + "en-US,en;q=0.9", + "en-US,en;q=0.9,zh-CN;q=0.8", + "en,en-US;q=0.9", + "en-US,en;q=0.8", +} + +// openaiRegistrar 协议注册器(持有单次注册的状态) +type openaiRegistrar struct { + client *http.Client + sentinel *sentinel.Solver + profile chromeProfile // 随机 Chrome 指纹 + acceptLang string // 随机 Accept-Language + codeVerifier string + state string + continueURL string // create_account 返回的 continue_url(用于 OAuth 授权码提取) + logf func(string, ...interface{}) +} + +func newOpenAIRegistrar(proxy *ProxyEntry, logf func(string, ...interface{})) *openaiRegistrar { + transport := &http.Transport{ + MaxIdleConns: 10, + IdleConnTimeout: 30 * time.Second, + } + applyProxy(transport, proxy) + + jar, _ := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List}) + client := &http.Client{ + Transport: transport, + Jar: jar, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 15 { + return fmt.Errorf("超过最大重定向次数") + } + return nil + }, + Timeout: 60 * time.Second, + } + + sol := sentinel.New() + // 设置 oai-did cookie(标准 cookie jar 通过 SetCookies 注入) + authURL := mustParseURL(openaiAuthBase) + jar.SetCookies(authURL, []*http.Cookie{{Name: "oai-did", Value: sol.DeviceID}}) + + // 随机 Chrome 指纹和语言(与 codex.py _CHROME_PROFILES 多样性策略对齐) + profile := chromeProfiles[rand.Intn(len(chromeProfiles))] + acceptLang := acceptLanguages[rand.Intn(len(acceptLanguages))] + + return &openaiRegistrar{ + client: client, + sentinel: sol, + profile: profile, + acceptLang: acceptLang, + logf: logf, + } +} + +// commonHeaders 构造通用 API 请求头(使用随机 Chrome 指纹) +func (r *openaiRegistrar) commonHeaders(referer string) map[string]string { + traceID := fmt.Sprintf("%d", rand.Int63()) + parentID := fmt.Sprintf("%d", rand.Int63()) + traceHex := fmt.Sprintf("%016x", rand.Int63()) + parentHex := fmt.Sprintf("%016x", rand.Int63()) + + return map[string]string{ + "accept": "application/json", + "accept-language": r.acceptLang, + "content-type": "application/json", + "origin": openaiAuthBase, + "referer": referer, + "user-agent": r.profile.ua, + "oai-device-id": r.sentinel.DeviceID, + "sec-ch-ua": r.profile.secChUA, + "sec-ch-ua-arch": `"x86"`, + "sec-ch-ua-bitness": `"64"`, + "sec-ch-ua-full-version-list": r.profile.secChUAFull, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": `"Windows"`, + "sec-ch-ua-platform-version": `"` + r.profile.secChUAPlatVer + `"`, + "sec-fetch-dest": "empty", + "sec-fetch-mode": "cors", + "sec-fetch-site": "same-origin", + "traceparent": fmt.Sprintf("00-0000000000000000%s-%s-01", traceHex, parentHex), + "tracestate": "dd=s:1;o:rum", + "x-datadog-origin": "rum", + "x-datadog-parent-id": parentID, + "x-datadog-sampling-priority": "1", + "x-datadog-trace-id": traceID, + } +} + +// navHeaders 页面导航请求头(使用随机 Chrome 指纹) +func (r *openaiRegistrar) navHeaders() map[string]string { + return map[string]string{ + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "accept-language": r.acceptLang, + "user-agent": r.profile.ua, + "sec-ch-ua": r.profile.secChUA, + "sec-ch-ua-arch": `"x86"`, + "sec-ch-ua-bitness": `"64"`, + "sec-ch-ua-full-version-list": r.profile.secChUAFull, + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": `"Windows"`, + "sec-ch-ua-platform-version": `"` + r.profile.secChUAPlatVer + `"`, + "sec-fetch-dest": "document", + "sec-fetch-mode": "navigate", + "sec-fetch-site": "same-origin", + "sec-fetch-user": "?1", + "upgrade-insecure-requests": "1", + } +} + +// doRequest 执行 HTTP 请求,设置 headers +func (r *openaiRegistrar) doRequest(ctx context.Context, method, rawURL string, body []byte, headers map[string]string) (*http.Response, []byte, error) { + var bodyReader io.Reader + if body != nil { + bodyReader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, rawURL, bodyReader) + if err != nil { + return nil, nil, err + } + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := r.client.Do(req) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + return resp, respBody, nil +} + +// generatePKCE 生成 PKCE code_verifier 和 code_challenge(S256) +func generatePKCE() (verifier, challenge string, err error) { + b := make([]byte, 64) + if _, err = cryptorand.Read(b); err != nil { + return + } + verifier = base64.RawURLEncoding.EncodeToString(b) + digest := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(digest[:]) + return +} + +// step0 OAuth 会话初始化 + 邮箱提交 +func (r *openaiRegistrar) step0(ctx context.Context, email string) error { + r.logf("[*] 步骤0: OAuth 会话初始化") + + verifier, challenge, err := generatePKCE() + if err != nil { + return fmt.Errorf("生成 PKCE 失败: %w", err) + } + r.codeVerifier = verifier + + stateBytes := make([]byte, 32) + if _, err := cryptorand.Read(stateBytes); err != nil { + return fmt.Errorf("生成 state 失败: %w", err) + } + r.state = base64.RawURLEncoding.EncodeToString(stateBytes) + + // 步骤0a: GET /oauth/authorize → 获取 login_session cookie + params := url.Values{ + "response_type": {"code"}, + "client_id": {oauthClientID}, + "redirect_uri": {oauthRedirectURI}, + "scope": {"openid profile email offline_access"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {r.state}, + "screen_hint": {"signup"}, + "prompt": {"login"}, + } + authorizeURL := openaiAuthBase + "/oauth/authorize?" + params.Encode() + + r.logf("[*] 步骤0a: GET /oauth/authorize") + resp, _, err := r.doRequest(ctx, "GET", authorizeURL, nil, r.navHeaders()) + if err != nil { + return fmt.Errorf("步骤0a 失败: %w", err) + } + if resp.StatusCode >= 400 { + return fmt.Errorf("步骤0a HTTP %d", resp.StatusCode) + } + + // 检查 login_session cookie + hasLoginSession := false + for _, cookie := range r.client.Jar.Cookies(mustParseURL(openaiAuthBase)) { + if cookie.Name == "login_session" { + hasLoginSession = true + break + } + } + if !hasLoginSession { + return fmt.Errorf("未获取到 login_session cookie") + } + r.logf("[*] login_session 已获取") + + // 步骤0b: POST /api/accounts/authorize/continue → 提交邮箱 + r.logf("[*] 步骤0b: 提交邮箱 + 获取 sentinel token") + sentinelToken, err := r.sentinel.BuildToken(ctx, r.client, "authorize_continue") + if err != nil { + return fmt.Errorf("获取 sentinel token 失败: %w", err) + } + + hdrs := r.commonHeaders(openaiAuthBase + "/create-account") + hdrs["openai-sentinel-token"] = sentinelToken + + payload, _ := json.Marshal(map[string]interface{}{ + "username": map[string]string{"kind": "email", "value": email}, + "screen_hint": "signup", + }) + + resp, body, err := r.doRequest(ctx, "POST", openaiAuthBase+"/api/accounts/authorize/continue", payload, hdrs) + if err != nil { + return fmt.Errorf("步骤0b 失败: %w", err) + } + if resp.StatusCode != 200 { + return fmt.Errorf("步骤0b HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) + } + + r.logf("[+] 步骤0 完成") + return nil +} + +// step2 注册用户(邮箱+密码) +func (r *openaiRegistrar) step2(ctx context.Context, email, password string) error { + r.logf("[*] 步骤2: 注册用户(username+password)") + + sentinelToken, err := r.sentinel.BuildToken(ctx, r.client, "authorize_continue") + if err != nil { + return fmt.Errorf("获取 sentinel token 失败: %w", err) + } + + hdrs := r.commonHeaders(openaiAuthBase + "/create-account/password") + hdrs["openai-sentinel-token"] = sentinelToken + + // 注意:字段名是 username 而非 email(浏览器抓包验证) + payload, _ := json.Marshal(map[string]string{ + "username": email, + "password": password, + }) + + resp, body, err := r.doRequest(ctx, "POST", openaiAuthBase+"/api/accounts/user/register", payload, hdrs) + if err != nil { + return fmt.Errorf("步骤2 失败: %w", err) + } + + if resp.StatusCode == 200 { + r.logf("[+] 步骤2 注册成功") + return nil + } + // 302 重定向到 email-otp 也算成功 + if resp.StatusCode == 302 || resp.StatusCode == 301 { + loc := resp.Header.Get("Location") + if strings.Contains(loc, "email-otp") || strings.Contains(loc, "email-verification") { + r.logf("[+] 步骤2 注册成功(302 重定向)") + return nil + } + } + return fmt.Errorf("步骤2 HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) +} + +// step3 触发验证码发送 +func (r *openaiRegistrar) step3(ctx context.Context) error { + r.logf("[*] 步骤3: 触发验证码发送") + + hdrs := r.navHeaders() + hdrs["referer"] = openaiAuthBase + "/create-account/password" + + if resp, _, err := r.doRequest(ctx, "GET", openaiAuthBase+"/api/accounts/email-otp/send", nil, hdrs); err != nil { + r.logf("[!] 步骤3 email-otp/send 请求失败: %s", err) + } else if resp.StatusCode >= 400 { + r.logf("[!] 步骤3 email-otp/send HTTP %d", resp.StatusCode) + } + r.doRequest(ctx, "GET", openaiAuthBase+"/email-verification", nil, hdrs) //nolint:errcheck + + r.logf("[+] 步骤3 完成") + return nil +} + +// step4 提交验证码 +func (r *openaiRegistrar) step4(ctx context.Context, code string) error { + r.logf("[*] 步骤4: 提交验证码 %s", code) + + hdrs := r.commonHeaders(openaiAuthBase + "/email-verification") + payload, _ := json.Marshal(map[string]string{"code": code}) + + resp, body, err := r.doRequest(ctx, "POST", openaiAuthBase+"/api/accounts/email-otp/validate", payload, hdrs) + if err != nil { + return fmt.Errorf("步骤4 失败: %w", err) + } + if resp.StatusCode != 200 { + return fmt.Errorf("步骤4 HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) + } + + r.logf("[+] 步骤4 验证码通过") + return nil +} + +// step5 提交姓名+生日完成注册 +func (r *openaiRegistrar) step5(ctx context.Context, firstName, lastName, birthdate string) error { + r.logf("[*] 步骤5: 创建账号(%s %s, %s)", firstName, lastName, birthdate) + + // 与 step0b/step2 保持一致:先获取 sentinel token 再发请求,避免 403 往返 + sentinelToken, err := r.sentinel.BuildToken(ctx, r.client, "authorize_continue") + if err != nil { + return fmt.Errorf("步骤5 获取 sentinel token 失败: %w", err) + } + + hdrs := r.commonHeaders(openaiAuthBase + "/about-you") + hdrs["openai-sentinel-token"] = sentinelToken + + payload, _ := json.Marshal(map[string]string{ + "name": firstName + " " + lastName, + "birthdate": birthdate, + }) + + resp, body, err := r.doRequest(ctx, "POST", openaiAuthBase+"/api/accounts/create_account", payload, hdrs) + if err != nil { + return fmt.Errorf("步骤5 失败: %w", err) + } + + // 从响应中提取 continue_url(用于后续 consent 流程) + parseContinueURL := func(data []byte) { + var result map[string]interface{} + if json.Unmarshal(data, &result) == nil { + for _, key := range []string{"continue_url", "url", "redirect_url"} { + if v, ok := result[key].(string); ok && v != "" { + r.continueURL = v + r.logf("[*] 步骤5 continue_url: %s", truncStr(v, 80)) + return + } + } + } + } + + if resp.StatusCode == 200 { + parseContinueURL(body) + r.logf("[+] 步骤5 账号创建完成") + return nil + } + // 如果仍然 403 含 sentinel,用新 token 重试一次 + if resp.StatusCode == 403 && strings.Contains(strings.ToLower(string(body)), "sentinel") { + r.logf("[*] 步骤5 sentinel 被拒,刷新 token 重试...") + retryToken, stErr := r.sentinel.BuildToken(ctx, r.client, "authorize_continue") + if stErr != nil { + return fmt.Errorf("步骤5 sentinel 重试求解失败: %w", stErr) + } + hdrs["openai-sentinel-token"] = retryToken + resp, body, err = r.doRequest(ctx, "POST", openaiAuthBase+"/api/accounts/create_account", payload, hdrs) + if err == nil && resp.StatusCode == 200 { + parseContinueURL(body) + r.logf("[+] 步骤5 账号创建完成(sentinel 重试成功)") + return nil + } + } + if resp.StatusCode == 301 || resp.StatusCode == 302 { + r.logf("[+] 步骤5 收到重定向,注册可能已完成") + return nil + } + return fmt.Errorf("步骤5 HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) +} + +// extractCodeFromURL 从 URL 中提取 authorization code +func extractCodeFromURL(rawURL string) string { + if rawURL == "" || !strings.Contains(rawURL, "code=") { + return "" + } + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return u.Query().Get("code") +} + +// noRedirectClient 创建不跟随重定向的 HTTP 客户端(共享 transport 和 cookie jar) +func (r *openaiRegistrar) noRedirectClient() *http.Client { + return &http.Client{ + Transport: r.client.Transport, + Jar: r.client.Jar, + Timeout: 30 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } +} + +// followRedirectsForCode 手动跟随重定向链,提取 authorization code +// localhost 连接失败时从错误信息中提取回调 URL +func (r *openaiRegistrar) followRedirectsForCode(ctx context.Context, startURL string, maxDepth int) string { + nrc := r.noRedirectClient() + current := startURL + for i := 0; i < maxDepth; i++ { + req, err := http.NewRequestWithContext(ctx, "GET", current, nil) + if err != nil { + return "" + } + for k, v := range r.navHeaders() { + req.Header.Set(k, v) + } + resp, err := nrc.Do(req) + if err != nil { + // localhost 连接失败 → 从错误信息提取 code + errStr := err.Error() + if strings.Contains(errStr, "localhost") || strings.Contains(errStr, "127.0.0.1") { + // 尝试从 URL 模式中提取 + for _, prefix := range []string{"http://localhost", "http://127.0.0.1"} { + if idx := strings.Index(errStr, prefix); idx >= 0 { + // 截取 URL 直到空格或引号 + sub := errStr[idx:] + for _, end := range []string{" ", "'", "\"", ")"} { + if ei := strings.Index(sub, end); ei > 0 { + sub = sub[:ei] + break + } + } + if code := extractCodeFromURL(sub); code != "" { + return code + } + } + } + } + return "" + } + io.ReadAll(io.LimitReader(resp.Body, 1024)) //nolint:errcheck + resp.Body.Close() + + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if code := extractCodeFromURL(loc); code != "" { + return code + } + if loc == "" { + return "" + } + // 相对路径 → 拼接完整 URL + if strings.HasPrefix(loc, "/") { + loc = openaiAuthBase + loc + } + current = loc + continue + } + // 200 → 检查最终 URL + if code := extractCodeFromURL(resp.Request.URL.String()); code != "" { + return code + } + return "" + } + return "" +} + +// decodeAuthSessionCookie 解码 oai-client-auth-session cookie(Flask/itsdangerous 格式) +// 格式: base64(json).timestamp.signature — 第一段 base64 解码后是 JSON +func (r *openaiRegistrar) decodeAuthSessionCookie() map[string]interface{} { + cookies := r.client.Jar.Cookies(mustParseURL(openaiAuthBase)) + for _, c := range cookies { + if c.Name != "oai-client-auth-session" { + continue + } + val := c.Value + firstPart := val + if dotIdx := strings.Index(val, "."); dotIdx > 0 { + firstPart = val[:dotIdx] + } + // 补齐 base64url padding + switch len(firstPart) % 4 { + case 2: + firstPart += "==" + case 3: + firstPart += "=" + } + decoded, err := base64.URLEncoding.DecodeString(firstPart) + if err != nil { + // 尝试 StdEncoding + decoded, err = base64.StdEncoding.DecodeString(firstPart) + if err != nil { + continue + } + } + var data map[string]interface{} + if json.Unmarshal(decoded, &data) == nil { + return data + } + } + return nil +} + +// step6 获取授权码(完整 consent 流程:workspace/select → organization/select → 提取 code) +func (r *openaiRegistrar) step6(ctx context.Context) (string, error) { + r.logf("[*] 步骤6: 获取授权码(consent 流程)") + + // 确定 consent URL + consentURL := r.continueURL + // 默认 consent 路径 + defaultConsent := openaiAuthBase + "/sign-in-with-chatgpt/codex/consent" + + if consentURL == "" { + consentURL = defaultConsent + } else if strings.HasPrefix(consentURL, "/") { + consentURL = openaiAuthBase + consentURL + } + + // OpenAI 新增手机验证/邮箱验证拦截页 → 跳过,直接走默认 consent 路径 + if strings.Contains(consentURL, "/add-phone") || strings.Contains(consentURL, "/email-verification") { + r.logf("[*] 步骤6: 检测到拦截页 (%s),跳过直接走 consent", truncStr(consentURL, 60)) + consentURL = defaultConsent + } + + r.logf("[*] consent URL: %s", truncStr(consentURL, 100)) + + nrc := r.noRedirectClient() + + // ── 6a: GET consent 页面(触发 cookie 设置) ── + r.logf("[*] 步骤6a: GET consent 页面") + req, err := http.NewRequestWithContext(ctx, "GET", consentURL, nil) + if err != nil { + return "", fmt.Errorf("步骤6a 请求创建失败: %w", err) + } + for k, v := range r.navHeaders() { + req.Header.Set(k, v) + } + + resp, err := nrc.Do(req) + if err != nil { + // localhost 连接失败 → 可能已被直接重定向到回调 + errStr := err.Error() + if strings.Contains(errStr, "localhost") || strings.Contains(errStr, "127.0.0.1") { + if code := r.followRedirectsForCode(ctx, consentURL, 1); code != "" { + r.logf("[+] 步骤6a consent ConnectionError 中提取到授权码") + return code, nil + } + } + return "", fmt.Errorf("步骤6a 请求失败: %w", err) + } + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + resp.Body.Close() + + // 直接 302 带 code + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if code := extractCodeFromURL(loc); code != "" { + r.logf("[+] 步骤6a consent 直接 302 获取到授权码") + return code, nil + } + // 跟踪重定向链 + if loc != "" { + if strings.HasPrefix(loc, "/") { + loc = openaiAuthBase + loc + } + if code := r.followRedirectsForCode(ctx, loc, 10); code != "" { + r.logf("[+] 步骤6a 跟踪重定向获取到授权码") + return code, nil + } + } + } + _ = respBody // consent 页面 HTML,不需要解析 + + // ── 6b: 解码 session cookie → 提取 workspace_id → POST workspace/select ── + r.logf("[*] 步骤6b: 解码 session cookie → workspace/select") + sessionData := r.decodeAuthSessionCookie() + var workspaceID string + if sessionData != nil { + if workspaces, ok := sessionData["workspaces"].([]interface{}); ok && len(workspaces) > 0 { + if ws, ok := workspaces[0].(map[string]interface{}); ok { + if id, ok := ws["id"].(string); ok { + workspaceID = id + } + } + } + } + + if workspaceID != "" { + r.logf("[*] workspace_id: %s", workspaceID) + + hdrs := r.commonHeaders(consentURL) + payload, _ := json.Marshal(map[string]string{"workspace_id": workspaceID}) + + req, _ := http.NewRequestWithContext(ctx, "POST", + openaiAuthBase+"/api/accounts/workspace/select", + bytes.NewReader(payload)) + for k, v := range hdrs { + req.Header.Set(k, v) + } + + resp, err := nrc.Do(req) + if err == nil { + wsBody, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) + resp.Body.Close() + + // 302 → 直接提取 code + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + loc := resp.Header.Get("Location") + if code := extractCodeFromURL(loc); code != "" { + r.logf("[+] workspace/select 302 获取到授权码") + return code, nil + } + if loc != "" { + if strings.HasPrefix(loc, "/") { + loc = openaiAuthBase + loc + } + if code := r.followRedirectsForCode(ctx, loc, 10); code != "" { + r.logf("[+] workspace/select 跟踪重定向获取到授权码") + return code, nil + } + } + } + + // 200 → 解析 JSON 进入 organization/select + if resp.StatusCode == 200 { + var wsResult map[string]interface{} + if json.Unmarshal(wsBody, &wsResult) == nil { + wsNext, _ := wsResult["continue_url"].(string) + + // ── 6c: organization/select ── + orgID, projectID := r.extractOrgAndProject(wsResult) + if orgID != "" { + r.logf("[*] 步骤6c: POST organization/select (org=%s)", orgID) + orgBody := map[string]string{"org_id": orgID} + if projectID != "" { + orgBody["project_id"] = projectID + } + orgPayload, _ := json.Marshal(orgBody) + + referer := consentURL + if wsNext != "" { + if strings.HasPrefix(wsNext, "/") { + referer = openaiAuthBase + wsNext + } else { + referer = wsNext + } + } + + orgHdrs := r.commonHeaders(referer) + orgReq, _ := http.NewRequestWithContext(ctx, "POST", + openaiAuthBase+"/api/accounts/organization/select", + bytes.NewReader(orgPayload)) + for k, v := range orgHdrs { + orgReq.Header.Set(k, v) + } + + orgResp, orgErr := nrc.Do(orgReq) + if orgErr == nil { + orgRespBody, _ := io.ReadAll(io.LimitReader(orgResp.Body, 64*1024)) + orgResp.Body.Close() + + if orgResp.StatusCode >= 300 && orgResp.StatusCode < 400 { + loc := orgResp.Header.Get("Location") + if code := extractCodeFromURL(loc); code != "" { + r.logf("[+] organization/select 302 获取到授权码") + return code, nil + } + if loc != "" { + if strings.HasPrefix(loc, "/") { + loc = openaiAuthBase + loc + } + if code := r.followRedirectsForCode(ctx, loc, 10); code != "" { + r.logf("[+] organization/select 跟踪重定向获取到授权码") + return code, nil + } + } + } + if orgResp.StatusCode == 200 { + var orgResult map[string]interface{} + if json.Unmarshal(orgRespBody, &orgResult) == nil { + if orgNext, ok := orgResult["continue_url"].(string); ok && orgNext != "" { + if strings.HasPrefix(orgNext, "/") { + orgNext = openaiAuthBase + orgNext + } + if code := r.followRedirectsForCode(ctx, orgNext, 10); code != "" { + r.logf("[+] organization/select continue_url 跟踪获取到授权码") + return code, nil + } + } + } + } + } + } + + // workspace/select 返回非 organization 的 continue_url → 直接跟踪 + if wsNext != "" { + if strings.HasPrefix(wsNext, "/") { + wsNext = openaiAuthBase + wsNext + } + if code := r.followRedirectsForCode(ctx, wsNext, 10); code != "" { + r.logf("[+] workspace/select continue_url 跟踪获取到授权码") + return code, nil + } + } + } + } + } + } else { + r.logf("[*] 未找到 workspace_id,跳过 workspace/select") + } + + // ── 6d: 备用策略 — 跟随重定向(允许跟踪)捕获 code ── + r.logf("[*] 步骤6d: 备用策略 — 完整重定向跟踪") + if code := r.followRedirectsForCode(ctx, consentURL, 15); code != "" { + r.logf("[+] 步骤6d 备用策略获取到授权码") + return code, nil + } + + // ── 6e: POST authorize/continue 最终尝试 ── + r.logf("[*] 步骤6e: POST authorize/continue 最终尝试") + acHdrs := r.commonHeaders(openaiAuthBase + "/about-you") + sentinelToken, stErr := r.sentinel.BuildToken(ctx, r.client, "authorize_continue") + if stErr != nil { + r.logf("[!] 步骤6e sentinel 求解失败: %s", stErr) + } + if sentinelToken != "" { + acHdrs["openai-sentinel-token"] = sentinelToken + } + acPayload, _ := json.Marshal(map[string]interface{}{}) + + acReq, _ := http.NewRequestWithContext(ctx, "POST", + openaiAuthBase+"/api/accounts/authorize/continue", + bytes.NewReader(acPayload)) + for k, v := range acHdrs { + acReq.Header.Set(k, v) + } + acResp, acErr := nrc.Do(acReq) + if acErr == nil { + acBody, _ := io.ReadAll(io.LimitReader(acResp.Body, 64*1024)) + acResp.Body.Close() + + // 从 JSON 提取 redirect_url + var acResult map[string]interface{} + if json.Unmarshal(acBody, &acResult) == nil { + for _, key := range []string{"redirect_url", "continue_url", "url"} { + if v, ok := acResult[key].(string); ok && v != "" { + if code := extractCodeFromURL(v); code != "" { + r.logf("[+] 步骤6e authorize/continue 获取到授权码") + return code, nil + } + // 跟踪 URL + if strings.HasPrefix(v, "/") { + v = openaiAuthBase + v + } + if code := r.followRedirectsForCode(ctx, v, 10); code != "" { + r.logf("[+] 步骤6e 跟踪获取到授权码") + return code, nil + } + } + } + } + // 从 Location 提取 + if loc := acResp.Header.Get("Location"); loc != "" { + if code := extractCodeFromURL(loc); code != "" { + r.logf("[+] 步骤6e Location 获取到授权码") + return code, nil + } + } + } + + return "", fmt.Errorf("步骤6 consent 流程未获取到授权码") +} + +// extractOrgAndProject 从 workspace/select 响应中提取 org_id 和 project_id +func (r *openaiRegistrar) extractOrgAndProject(wsResult map[string]interface{}) (orgID, projectID string) { + data, ok := wsResult["data"].(map[string]interface{}) + if !ok { + return + } + orgs, ok := data["orgs"].([]interface{}) + if !ok || len(orgs) == 0 { + return + } + firstOrg, ok := orgs[0].(map[string]interface{}) + if !ok { + return + } + orgID, _ = firstOrg["id"].(string) + if projects, ok := firstOrg["projects"].([]interface{}); ok && len(projects) > 0 { + if proj, ok := projects[0].(map[string]interface{}); ok { + projectID, _ = proj["id"].(string) + } + } + return +} + +// oauthLogin 全流程 OAuth 重新登录(独立 session,用于注册后获取 token 的 fallback) +// 流程: authorize → email → password/verify → [email-otp if needed] → consent → code → token exchange +func (r *openaiRegistrar) oauthLogin(ctx context.Context, email, password string, mp *tempmail.MultiProvider, mailMeta map[string]string) (map[string]interface{}, error) { + r.logf("[*] OAuth 重新登录: 创建新 session") + + // 创建独立的 registrar(新 session、新 PKCE、新 device_id) + login := newOpenAIRegistrar(nil, r.logf) + // 复用原始 registrar 的 transport(继承代理设置) + login.client.Transport = r.client.Transport + + // 步骤L1: GET /oauth/authorize(获取 login_session) + r.logf("[*] OAuth 登录步骤1: GET /oauth/authorize") + verifier, challenge, err := generatePKCE() + if err != nil { + return nil, fmt.Errorf("PKCE 生成失败: %w", err) + } + login.codeVerifier = verifier + + stateBytes := make([]byte, 32) + cryptorand.Read(stateBytes) //nolint:errcheck + login.state = base64.RawURLEncoding.EncodeToString(stateBytes) + + params := url.Values{ + "response_type": {"code"}, + "client_id": {oauthClientID}, + "redirect_uri": {oauthRedirectURI}, + "scope": {"openid profile email offline_access"}, + "code_challenge": {challenge}, + "code_challenge_method": {"S256"}, + "state": {login.state}, + } + authorizeURL := openaiAuthBase + "/oauth/authorize?" + params.Encode() + resp, _, err := login.doRequest(ctx, "GET", authorizeURL, nil, login.navHeaders()) + if err != nil { + return nil, fmt.Errorf("OAuth 登录步骤1 失败: %w", err) + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("OAuth 登录步骤1 HTTP %d", resp.StatusCode) + } + + // 步骤L2: POST authorize/continue(提交邮箱) + r.logf("[*] OAuth 登录步骤2: 提交邮箱") + sentinelEmail, err := login.sentinel.BuildToken(ctx, login.client, "authorize_continue") + if err != nil { + return nil, fmt.Errorf("OAuth 登录 sentinel(email) 失败: %w", err) + } + hdrs := login.commonHeaders(openaiAuthBase + "/log-in") + hdrs["openai-sentinel-token"] = sentinelEmail + + emailPayload, _ := json.Marshal(map[string]interface{}{ + "username": map[string]string{"kind": "email", "value": email}, + }) + resp, body, err := login.doRequest(ctx, "POST", + openaiAuthBase+"/api/accounts/authorize/continue", emailPayload, hdrs) + if err != nil { + return nil, fmt.Errorf("OAuth 登录步骤2 失败: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("OAuth 登录步骤2 HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) + } + + // 步骤L3: POST password/verify(提交密码) + r.logf("[*] OAuth 登录步骤3: 提交密码") + sentinelPwd, err := login.sentinel.BuildToken(ctx, login.client, "password_verify") + if err != nil { + return nil, fmt.Errorf("OAuth 登录 sentinel(pwd) 失败: %w", err) + } + pwdHdrs := login.commonHeaders(openaiAuthBase + "/log-in/password") + pwdHdrs["openai-sentinel-token"] = sentinelPwd + + pwdPayload, _ := json.Marshal(map[string]string{"password": password}) + resp, body, err = login.doRequest(ctx, "POST", + openaiAuthBase+"/api/accounts/password/verify", pwdPayload, pwdHdrs) + if err != nil { + return nil, fmt.Errorf("OAuth 登录步骤3 失败: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("OAuth 登录步骤3 HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) + } + + // 解析 continue_url + var pwdResult map[string]interface{} + if json.Unmarshal(body, &pwdResult) == nil { + if cu, ok := pwdResult["continue_url"].(string); ok && cu != "" { + login.continueURL = cu + } + } + if login.continueURL == "" { + return nil, fmt.Errorf("OAuth 登录步骤3 未获取到 continue_url") + } + r.logf("[*] OAuth 登录 continue_url: %s", truncStr(login.continueURL, 80)) + + // 步骤L3b: 如果 continue_url 包含 email-verification,需要先完成邮箱 OTP 验证 + if strings.Contains(login.continueURL, "email-verification") || strings.Contains(login.continueURL, "email-otp") { + r.logf("[*] OAuth 登录: 检测到邮箱 OTP 验证要求,触发验证码...") + + // 触发 OTP 发送 + otpHdrs := login.navHeaders() + otpHdrs["referer"] = openaiAuthBase + "/email-verification" + login.doRequest(ctx, "GET", openaiAuthBase+"/api/accounts/email-otp/send", nil, otpHdrs) //nolint:errcheck + login.doRequest(ctx, "GET", openaiAuthBase+"/email-verification", nil, otpHdrs) //nolint:errcheck + + // 轮询获取验证码 + if mp == nil { + return nil, fmt.Errorf("OAuth 登录需要邮箱 OTP 但 mail provider 不可用") + } + otpCode := pollMailCode(ctx, mp, email, mailMeta, 120*time.Second, r.logf) + if otpCode == "" { + return nil, fmt.Errorf("OAuth 登录邮箱 OTP 超时") + } + r.logf("[*] OAuth 登录: 提交 OTP %s", otpCode) + + // 提交验证码 + validateHdrs := login.commonHeaders(openaiAuthBase + "/email-verification") + validatePayload, _ := json.Marshal(map[string]string{"code": otpCode}) + resp, body, err = login.doRequest(ctx, "POST", + openaiAuthBase+"/api/accounts/email-otp/validate", validatePayload, validateHdrs) + if err != nil { + return nil, fmt.Errorf("OAuth 登录 OTP 验证失败: %w", err) + } + if resp.StatusCode != 200 { + return nil, fmt.Errorf("OAuth 登录 OTP 验证 HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) + } + + // 解析新的 continue_url + var otpResult map[string]interface{} + if json.Unmarshal(body, &otpResult) == nil { + if cu, ok := otpResult["continue_url"].(string); ok && cu != "" { + login.continueURL = cu + r.logf("[*] OAuth 登录 OTP 后 continue_url: %s", truncStr(cu, 80)) + } + } + } + + // 步骤L4: consent 流程提取 code(复用 step6 的 consent 逻辑) + authCode, err := login.step6(ctx) + if err != nil { + return nil, fmt.Errorf("OAuth 登录 consent 失败: %w", err) + } + + // 步骤L5: 换取 token + codex, err := login.step7(ctx, authCode) + if err != nil { + return nil, fmt.Errorf("OAuth 登录 token 交换失败: %w", err) + } + + return codex, nil +} + +// step7 换取 access_token + id_token(PKCE token exchange) +func (r *openaiRegistrar) step7(ctx context.Context, authCode string) (map[string]interface{}, error) { + r.logf("[*] 步骤7: 换取 token") + + formData := url.Values{ + "grant_type": {"authorization_code"}, + "code": {authCode}, + "redirect_uri": {oauthRedirectURI}, + "code_verifier": {r.codeVerifier}, + "client_id": {oauthClientID}, + } + + hdrs := map[string]string{ + "content-type": "application/x-www-form-urlencoded", + "user-agent": r.profile.ua, + "accept": "application/json", + "origin": openaiAuthBase, + } + + resp, body, err := r.doRequest(ctx, "POST", openaiAuthBase+"/oauth/token", + []byte(formData.Encode()), hdrs) + if err != nil { + return nil, fmt.Errorf("步骤7 请求失败: %w", err) + } + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("步骤7 HTTP %d: %s", resp.StatusCode, truncStr(string(body), 200)) + } + + var tokenResp map[string]interface{} + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("步骤7 响应解析失败: %w", err) + } + + accessToken, _ := tokenResp["access_token"].(string) + idToken, _ := tokenResp["id_token"].(string) + if accessToken == "" { + return nil, fmt.Errorf("步骤7 响应中无 access_token: %s", truncStr(string(body), 200)) + } + + // 解码 JWT payload + claims, err := decodeJWTPayload(accessToken) + if err != nil { + return nil, fmt.Errorf("步骤7 %w", err) + } + + // 组装 codex 格式 JSON + codex := buildCodexJSON(accessToken, idToken, claims) + r.logf("[+] 步骤7 token 获取成功") + return codex, nil +} + +// decodeJWTPayload 解码 JWT 的 payload 部分(不验证签名,仅提取 claims) +func decodeJWTPayload(token string) (map[string]interface{}, error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("JWT 格式无效(期望 3 段,实际 %d 段)", len(parts)) + } + + // base64url 解码 payload(补齐 padding) + payload := parts[1] + switch len(payload) % 4 { + case 2: + payload += "==" + case 3: + payload += "=" + } + + decoded, err := base64.URLEncoding.DecodeString(payload) + if err != nil { + return nil, fmt.Errorf("JWT payload 解码失败: %w", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(decoded, &claims); err != nil { + return nil, fmt.Errorf("JWT claims 解析失败: %w", err) + } + + return claims, nil +} + +// buildCodexJSON 组装完整的 codex 格式 JSON(与 codex.py 输出对齐) +func buildCodexJSON(accessToken, idToken string, claims map[string]interface{}) map[string]interface{} { + codex := make(map[string]interface{}) + + // 复制所有 JWT claims(aud, sub, iss, exp, iat, nbf, scp, jti, session_id 等) + for k, v := range claims { + codex[k] = v + } + + // 添加 token 字段 + codex["access_token"] = accessToken + if idToken != "" { + codex["id_token"] = idToken + } + codex["type"] = "codex" + codex["disabled"] = false + codex["client_id"] = oauthClientID + + // 格式化 expired 时间(ISO 8601) + if exp, ok := claims["exp"].(float64); ok { + t := time.Unix(int64(exp), 0).UTC() + codex["expired"] = t.Format("2006-01-02T15:04:05+00:00") + } + + // 添加 last_refresh 时间戳 + codex["last_refresh"] = time.Now().UTC().Format("2006-01-02T15:04:05.000000+00:00") + + // 从嵌套 claims 提取 account_id + if auth, ok := claims["https://api.openai.com/auth"].(map[string]interface{}); ok { + if accountID, ok := auth["chatgpt_account_id"].(string); ok { + codex["account_id"] = accountID + } + } + + return codex +} + +// 随机姓名池 +var ( + openaiFirstNames = []string{ + "James", "Robert", "John", "Michael", "David", "William", "Richard", + "Mary", "Jennifer", "Linda", "Elizabeth", "Susan", "Jessica", "Sarah", + "Emily", "Emma", "Olivia", "Sophia", "Liam", "Noah", "Oliver", "Ethan", + } + openaiLastNames = []string{ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", + "Davis", "Wilson", "Anderson", "Thomas", "Taylor", "Moore", "Martin", + } +) + +func randomOpenAIName() (string, string) { + return openaiFirstNames[rand.Intn(len(openaiFirstNames))], + openaiLastNames[rand.Intn(len(openaiLastNames))] +} + +func randomBirthdate() string { + year := 1985 + rand.Intn(18) // 1985-2002 + month := 1 + rand.Intn(12) + day := 1 + rand.Intn(28) + return fmt.Sprintf("%04d-%02d-%02d", year, month, day) +} + +// registerViaProtocol 纯 HTTP 协议注册主流程 +func (w *OpenAIWorker) registerViaProtocol(ctx context.Context, opts RegisterOpts, logf func(string, ...interface{})) { + // 平台专属邮箱 provider 覆盖全局优先级 + if platformProviders := opts.Config["openai_email_providers"]; platformProviders != "" { + opts.Config["email_provider_priority"] = platformProviders + } + mailProvider := tempmail.NewMultiProvider(opts.Config) + + // 1. 创建临时邮箱(多 provider 自动切换) + email, mailMeta, err := mailProvider.GenerateEmail(ctx) + if err != nil { + fmt.Printf("[DEBUG] 创建邮箱失败: %s\n", err) + opts.OnFail() + return + } + password := genOpenAIPassword(16) + logf("[*] 邮箱: %s (via %s)", email, mailMeta["provider"]) + + cleanup := func() { go mailProvider.DeleteEmail(context.Background(), email, mailMeta) } + + reg := newOpenAIRegistrar(opts.Proxy, logf) + firstName, lastName := randomOpenAIName() + birthdate := randomBirthdate() + + // 预热:访问 chatgpt.com 首页建立 session,与 Python 版 visit_homepage() 对齐 + logf("[*] 预热中...") + reg.doRequest(ctx, "GET", "https://chatgpt.com/", nil, reg.navHeaders()) //nolint:errcheck + ctxSleep(ctx, 300*time.Millisecond) + + // 步骤0 + if err := reg.step0(ctx, email); err != nil { + fmt.Printf("[DEBUG] %s step0: %s\n", email, err) + cleanup() + opts.OnFail() + return + } + ctxSleep(ctx, time.Second) + + // 步骤2 + if err := reg.step2(ctx, email, password); err != nil { + fmt.Printf("[DEBUG] %s step2: %s\n", email, err) + cleanup() + opts.OnFail() + return + } + ctxSleep(ctx, time.Second) + + // 步骤3 + if err := reg.step3(ctx); err != nil { + fmt.Printf("[DEBUG] %s step3 发送验证码可能失败: %s(继续等待)\n", email, err) + } + + // 等待验证码(30s 超时,超时后尝试换邮箱内联重试一次) + logf("[*] 等待验证码...") + code := pollMailCode(ctx, mailProvider, email, mailMeta, 30*time.Second, logf) + if code == "" { + fmt.Printf("[DEBUG] %s 验证码超时,尝试换邮箱重试\n", email) + tempmail.RecordFailure(mailMeta["provider"]) + cleanup() + + // 内联重试:用同一个 MultiProvider 换邮箱(会自动降级到下一个 provider) + email2, mailMeta2, err2 := mailProvider.GenerateEmail(ctx) + if err2 != nil { + fmt.Printf("[DEBUG] 换邮箱失败: %s\n", err2) + opts.OnFail() + return + } + logf("[*] 换邮箱重试: %s (via %s)", email2, mailMeta2["provider"]) + cleanup2 := func() { go mailProvider.DeleteEmail(context.Background(), email2, mailMeta2) } + + // 用新邮箱重走 step0-3 + reg2 := newOpenAIRegistrar(opts.Proxy, logf) + if err := reg2.step0(ctx, email2); err != nil { + fmt.Printf("[DEBUG] %s 重试 step0: %s\n", email2, err) + cleanup2() + opts.OnFail() + return + } + if err := reg2.step2(ctx, email2, password); err != nil { + fmt.Printf("[DEBUG] %s 重试 step2: %s\n", email2, err) + cleanup2() + opts.OnFail() + return + } + if err := reg2.step3(ctx); err != nil { + fmt.Printf("[DEBUG] %s 重试 step3 可能失败: %s\n", email2, err) + } + code = pollMailCode(ctx, mailProvider, email2, mailMeta2, 30*time.Second, logf) + if code == "" { + fmt.Printf("[DEBUG] %s 换邮箱后验证码仍超时\n", email2) + tempmail.RecordFailure(mailMeta2["provider"]) + cleanup2() + opts.OnFail() + return + } + tempmail.RecordSuccess(mailMeta2["provider"]) + // 切换到新邮箱继续后续流程 + email = email2 + mailMeta = mailMeta2 + cleanup = cleanup2 + reg = reg2 + logf("[*] 验证码: %s", code) + } else { + logf("[*] 验证码: %s", code) + tempmail.RecordSuccess(mailMeta["provider"]) + } + + // 步骤4(失败时重发验证码重试一次,与 Python DuckMail 版逻辑对齐) + if err := reg.step4(ctx, code); err != nil { + fmt.Printf("[DEBUG] %s step4: %s,重发验证码重试\n", email, err) + if err := reg.step3(ctx); err != nil { + fmt.Printf("[DEBUG] %s 重发 step3 可能失败: %s\n", email, err) + } + retryCode := pollMailCode(ctx, mailProvider, email, mailMeta, 60*time.Second, logf) + if retryCode == "" || retryCode == code { + fmt.Printf("[DEBUG] %s 重试验证码超时或未刷新\n", email) + cleanup() + opts.OnFail() + return + } + logf("[*] 重试验证码: %s", retryCode) + if err2 := reg.step4(ctx, retryCode); err2 != nil { + fmt.Printf("[DEBUG] %s step4 重试: %s\n", email, err2) + cleanup() + opts.OnFail() + return + } + } + ctxSleep(ctx, time.Second) + + // 步骤5(只是填姓名生日,此时账号已注册成功,失败不删邮箱) + step5Failed := false + if err := reg.step5(ctx, firstName, lastName, birthdate); err != nil { + fmt.Printf("[DEBUG] %s step5 失败: %s(账号已创建,继续尝试获取 token)\n", email, err) + step5Failed = true + } + + if step5Failed { + logf("[+] 注册部分成功: %s", email) + } else { + logf("[+] 注册成功: %s", email) + } + + // ── OAuth Token 获取(多级降级 + 重试) ── + logf("[*] 获取 Token...") + + // 第一级:从注册 session 的 consent 流程获取 code(step5 失败则跳过,session 状态不可靠) + // OAuth 重试详情不推送前端,仅在服务端 stdout 输出 + if !step5Failed { + // 等待 3-6s(随机抖动),让 OpenAI 后端状态充分传播 + jitter := time.Duration(3000+rand.Intn(3000)) * time.Millisecond + ctxSleep(ctx, jitter) + authCode, err := reg.step6(ctx) + if err == nil { + codex, tokenErr := reg.step7(ctx, authCode) + if tokenErr == nil { + logf("[+] Token 获取成功") + codex["email"] = email + codex["password"] = password + opts.OnSuccess(email, codex) + return + } + fmt.Printf("[DEBUG] %s Token 交换失败: %s\n", email, tokenErr) + } else { + fmt.Printf("[DEBUG] %s consent 流程失败: %s\n", email, err) + } + } else { + fmt.Printf("[DEBUG] %s step5 失败,跳过 consent\n", email) + } + + // 第二级:全流程 OAuth 重新登录(最多重试 3 次,递增延迟 + 随机抖动避免并发踩踏 429) + oauthBaseDelays := []time.Duration{5 * time.Second, 12 * time.Second, 25 * time.Second} + for attempt, base := range oauthBaseDelays { + if ctx.Err() != nil { + break + } + // 在基础延迟上加 0-3s 随机抖动,错开并发 worker + jitter := time.Duration(rand.Intn(3000)) * time.Millisecond + delay := base + jitter + fmt.Printf("[DEBUG] %s OAuth 重新登录第 %d 次尝试,等待 %s...\n", email, attempt+1, delay) + ctxSleep(ctx, delay) + if ctx.Err() != nil { + break + } + codex, loginErr := reg.oauthLogin(ctx, email, password, mailProvider, mailMeta) + if loginErr == nil { + logf("[+] Token 获取成功") + codex["email"] = email + codex["password"] = password + opts.OnSuccess(email, codex) + return + } + fmt.Printf("[DEBUG] %s OAuth 重新登录第 %d 次失败: %s\n", email, attempt+1, loginErr) + } + + // 最终降级:Token 获取失败,视为失败(不保存不完整凭证) + fmt.Printf("[DEBUG] %s Token 获取失败,标记为失败\n", email) + opts.OnFail() +} + +// pollMailCode 轮询获取验证码(多 provider 版本) +func pollMailCode(ctx context.Context, mp *tempmail.MultiProvider, email string, meta map[string]string, timeout time.Duration, logf func(string, ...interface{})) string { + deadline := time.Now().Add(timeout) + attempt := 0 + for time.Now().Before(deadline) { + select { + case <-ctx.Done(): + return "" + case <-time.After(3 * time.Second): + } + attempt++ + code, err := mp.FetchVerificationCode(ctx, email, meta, 1, 0) + if err == nil && code != "" { + return code + } + if attempt%5 == 0 { + remaining := time.Until(deadline).Round(time.Second) + logf("[*] 等待验证码中... 已等待 %ds,剩余 %s", attempt*3, remaining) + } + } + return "" +} + +// ─── 辅助 ─── + +func mustParseURL(rawURL string) *url.URL { + u, _ := url.Parse(rawURL) + return u +} + +// ─── 容器化服务调用 ─── + +type openaiServiceRequest struct { + Email string `json:"email"` + Password string `json:"password"` + Proxy string `json:"proxy,omitempty"` + YYDSMailURL string `json:"yydsmail_url,omitempty"` + YYDSMailKey string `json:"yydsmail_key,omitempty"` + EmailPriority string `json:"email_priority,omitempty"` // 邮箱 Provider 优先级 +} + +type openaiServiceResponse struct { + OK bool `json:"ok"` + Email string `json:"email"` + AccessToken string `json:"access_token"` + SessionToken string `json:"session_token"` + UserID string `json:"user_id"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Error string `json:"error"` + Logs []string `json:"logs"` +} + +func (w *OpenAIWorker) registerViaService(ctx context.Context, serviceURL string, opts RegisterOpts, logf func(string, ...interface{})) (map[string]interface{}, bool) { + // 配置诊断仅输出到 stdout,不推送前端 + fmt.Println("[DEBUG] 远程注册服务连接中...") + + proxyStr := "" + if userProxy := opts.Config["user_proxy"]; userProxy != "" { + proxyStr = userProxy + fmt.Println("[DEBUG] 使用用户指定代理") + } else if opts.Proxy != nil { + if opts.Proxy.HTTPS != "" { + proxyStr = opts.Proxy.HTTPS + } else if opts.Proxy.HTTP != "" { + proxyStr = opts.Proxy.HTTP + } + } + // 容器内 127.0.0.1 指向自身,需替换为宿主机地址 + proxyStr = strings.ReplaceAll(proxyStr, "127.0.0.1", "host.docker.internal") + proxyStr = strings.ReplaceAll(proxyStr, "localhost", "host.docker.internal") + + // 不在主后端创建邮箱,由远程 MultiProvider 处理 + // 优先使用平台级邮箱选择,为空时回退到全局优先级 + emailPriority := opts.Config["openai_email_providers"] + if emailPriority == "" { + emailPriority = settingOrDefault(opts.Config, "email_provider_priority", "yydsmail") + } + epParts := strings.Split(emailPriority, ",") + for i := range epParts { + epParts[i] = strings.TrimSpace(epParts[i]) + } + tempmail.WeightedShuffleNames(epParts) + + // 诊断日志:发送到远程的配置(仅 stdout) + fmt.Printf("[DEBUG] 邮箱优先级: %s\n", strings.Join(epParts, ",")) + if opts.Config["yydsmail_api_key"] != "" { + fmt.Println("[DEBUG] yydsmail 凭证: 已配置") + } + + reqBody := openaiServiceRequest{ + Proxy: proxyStr, + YYDSMailURL: settingOrDefault(opts.Config, "yydsmail_base_url", ""), + YYDSMailKey: opts.Config["yydsmail_api_key"], + EmailPriority: strings.Join(epParts, ","), + } + bodyJSON, _ := json.Marshal(reqBody) + + // 关联任务 ctx,停止任务时立即取消外部服务请求 + // 600s 超时:排队等待(最多 ~300s)+ 实际注册(60-80s)+ 余量 + reqCtx, reqCancel := context.WithTimeout(ctx, 600*time.Second) + defer reqCancel() + + httpReq, err := http.NewRequestWithContext(reqCtx, "POST", + strings.TrimRight(serviceURL, "/")+"/openai/process", + bytes.NewReader(bodyJSON)) + if err != nil { + return nil, false + } + httpReq.Header.Set("Content-Type", "application/json") + + logf("[*] 等待远程注册...") + + // 心跳:每 10 秒轮询排队状态,向用户推送排队位置/ETA + heartbeatDone := make(chan struct{}) + queueStatusURL := BuildQueueStatusURL(serviceURL, "openai") + go func() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + elapsed := 0 + for { + select { + case <-ticker.C: + elapsed += 10 + qs, err := PollQueueStatus(reqCtx, queueStatusURL) + if err == nil && qs != nil { + logf("%s", FormatQueueLog(qs, elapsed)) + } else { + logf("[…] 远程注册进行中 (%ds)...", elapsed) + } + case <-heartbeatDone: + return + case <-reqCtx.Done(): + return + } + } + }() + + client := &http.Client{Timeout: 600 * time.Second} + resp, err := client.Do(httpReq) + close(heartbeatDone) + + if err != nil { + if ctx.Err() != nil { + return nil, false // context 取消(达标或用户停止),静默退出 + } + fmt.Printf("[DEBUG] 远程服务请求失败: %s\n", sanitizeHTTPError(err)) + return nil, false + } + defer resp.Body.Close() + + body, _ := io.ReadAll(io.LimitReader(resp.Body, 256*1024)) // 256KB 上限 + + // HTTP 状态码检查:非 2xx 直接报错,不尝试 JSON 解析 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet := string(body) + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + fmt.Printf("[DEBUG] 远程服务返回 HTTP %d: %s\n", resp.StatusCode, snippet) + return nil, false + } + + var svcResp openaiServiceResponse + if err := json.Unmarshal(body, &svcResp); err != nil { + snippet := string(body) + if len(snippet) > 200 { + snippet = snippet[:200] + "..." + } + fmt.Printf("[DEBUG] 响应解析失败 (HTTP %d): %s | body: %s\n", resp.StatusCode, err, snippet) + return nil, false + } + + // 远程服务详细日志仅输出到 stdout 调试,不推送前端 + for _, remoteLog := range svcResp.Logs { + fmt.Printf("[REMOTE] %s\n", remoteLog) + } + + // 从远程日志中提取邮箱 provider 名,回写亲和度统计 + // 让下次 WeightedShuffleNames 能根据远程实际表现排序 + if provider := extractProviderFromRemoteLogs(svcResp.Logs); provider != "" { + if svcResp.OK { + tempmail.RecordSuccess(provider) + fmt.Printf("[DEBUG] 亲和度: %s +success\n", provider) + } else if isEmailRelatedFailure(svcResp.Error) { + tempmail.RecordFailure(provider) + fmt.Printf("[DEBUG] 亲和度: %s +fail (邮箱相关)\n", provider) + } + // 非邮箱原因的失败(403、网络等)不计入,避免污染邮箱统计 + } + + if !svcResp.OK { + fmt.Printf("[DEBUG] 远程注册失败: %s\n", svcResp.Error) + return nil, false + } + + logf("[+] 远程注册成功: %s", svcResp.Email) + + // 解析完整 JSON 到 map,保留远程服务返回的所有 codex 字段 + result := map[string]interface{}{} + json.Unmarshal(body, &result) //nolint:errcheck + // 清理非 credential 字段 + delete(result, "ok") + delete(result, "error") + delete(result, "logs") + + // 防御校验:即使远程返回 ok=true,也必须包含 access_token,否则视为失败 + if result["access_token"] == nil || result["access_token"] == "" { + fmt.Printf("[DEBUG] 远程返回 ok=true 但缺少 access_token,视为失败\n") + return nil, false + } + + return result, true +} + +// sanitizeHTTPError 脱敏 HTTP 错误信息,移除内部 URL +func sanitizeHTTPError(err error) string { + msg := err.Error() + // 移除形如 Post "https://..." / Get "https://..." 中的 URL + if i := strings.Index(msg, "\"http"); i >= 0 { + if j := strings.Index(msg[i+1:], "\""); j >= 0 { + msg = msg[:i] + "\"\"" + msg[i+1+j+1:] + } + } + return msg +} + +// ─── 子进程降级 ─── + +func (w *OpenAIWorker) callRegisterScript(ctx context.Context, email, password string, opts RegisterOpts, logf func(string, ...interface{})) (string, bool) { + scriptPath := findScript("register_openai.py") + if scriptPath == "" { + return "注册服务未就绪(未配置 openai_reg_url 且找不到 register_openai.py)", false + } + + cmdCtx, cancel := context.WithTimeout(ctx, 300*time.Second) + defer cancel() + + args := []string{scriptPath, "--email", email, "--password", password} + + // 优先级:用户指定代理 > 系统代理 + if userProxy := opts.Config["user_proxy"]; userProxy != "" { + args = append(args, "--proxy", userProxy) + } else if opts.Proxy != nil { + proxyStr := opts.Proxy.HTTPS + if proxyStr == "" { + proxyStr = opts.Proxy.HTTP + } + if proxyStr != "" { + args = append(args, "--proxy", proxyStr) + } + } + + yydsMailURL := settingOrDefault(opts.Config, "yydsmail_base_url", "") + yydsMailKey := opts.Config["yydsmail_api_key"] + if yydsMailURL != "" && yydsMailKey != "" { + args = append(args, "--yydsmail-url", yydsMailURL, "--yydsmail-key", yydsMailKey) + } + + cfBypassURL := settingOrDefault(opts.Config, "cf_bypass_solver_url", "http://127.0.0.1:5073") + if cfBypassURL != "" { + args = append(args, "--cf-bypass-url", cfBypassURL) + } + + cmd := exec.CommandContext(cmdCtx, "python3", args...) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Sprintf("创建 stdout pipe 失败: %v", err), false + } + if err := cmd.Start(); err != nil { + return fmt.Sprintf("启动脚本失败: %v", err), false + } + + var resultLine string + scanner := bufio.NewScanner(stdoutPipe) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "LOG:") { + logf("[*] %s", strings.TrimPrefix(line, "LOG:")) + } else if strings.HasPrefix(line, "OK:") || strings.HasPrefix(line, "FAIL:") { + resultLine = line + } + } + + err = cmd.Wait() + if err != nil || !strings.HasPrefix(resultLine, "OK:") { + if errStr := strings.TrimSpace(stderr.String()); errStr != "" { + lines := strings.Split(errStr, "\n") + start := len(lines) - 5 + if start < 0 { + start = 0 + } + for _, l := range lines[start:] { + if l = strings.TrimSpace(l); l != "" { + logf("[dbg] %s", truncStr(l, 120)) + } + } + } + } + + if strings.HasPrefix(resultLine, "OK:") { + return strings.TrimPrefix(resultLine, "OK:"), true + } + if strings.HasPrefix(resultLine, "FAIL:") { + return strings.TrimPrefix(resultLine, "FAIL:"), false + } + return truncStr(resultLine, 200), false +} + +// findScript 查找 scripts/ 目录下的脚本文件 +func findScript(name string) string { + candidates := []string{filepath.Join("scripts", name)} + if execPath, err := os.Executable(); err == nil { + execDir := filepath.Dir(execPath) + candidates = append(candidates, + filepath.Join(execDir, "scripts", name), + filepath.Join(execDir, "..", "scripts", name), + ) + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + return p + } + } + return "" +} + +// ctxSleep 在指定时间内休眠,若 ctx 被取消则立即返回 +func ctxSleep(ctx context.Context, d time.Duration) { + select { + case <-ctx.Done(): + case <-time.After(d): + } +} + +// genOpenAIPassword 生成符合 OpenAI 密码要求的随机密码(使用 crypto/rand) +func genOpenAIPassword(length int) string { + if length < 12 { + length = 12 + } + upper := "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + lower := "abcdefghijklmnopqrstuvwxyz" + digits := "0123456789" + special := "!@#$%&*" + all := upper + lower + digits + special + + cryptoRandByte := func(charset string) byte { + n, _ := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(len(charset)))) + return charset[n.Int64()] + } + + pw := make([]byte, length) + pw[0] = cryptoRandByte(upper) + pw[1] = cryptoRandByte(lower) + pw[2] = cryptoRandByte(digits) + pw[3] = cryptoRandByte(special) + for i := 4; i < length; i++ { + pw[i] = cryptoRandByte(all) + } + // Fisher-Yates shuffle with crypto/rand + for i := len(pw) - 1; i > 0; i-- { + j, _ := cryptorand.Int(cryptorand.Reader, big.NewInt(int64(i+1))) + pw[i], pw[j.Int64()] = pw[j.Int64()], pw[i] + } + return string(pw) +} + +// ─── 辅助工具 ─── + +var _ = cryptorand.Read // 确保 import 不被剪枝 + +// extractProviderFromRemoteLogs 从远程服务日志中提取邮箱 provider 名 +// 远程日志格式: "[*] 邮箱: xxx@yyy.com (via yydsmail)" 等 +func extractProviderFromRemoteLogs(logs []string) string { + for _, line := range logs { + idx := strings.Index(line, "(via ") + if idx < 0 { + continue + } + rest := line[idx+5:] + end := strings.Index(rest, ")") + if end > 0 { + return strings.TrimSpace(rest[:end]) + } + } + return "" +} + +// isEmailRelatedFailure 判断远程注册失败是否与邮箱有关(验证码超时等) +// 非邮箱原因(403、网络超时、sentinel 等)不应计入邮箱 provider 的失败统计 +func isEmailRelatedFailure(errMsg string) bool { + emailKeywords := []string{"验证码超时", "验证码", "邮箱", "email", "mail", "verification", "code timeout"} + lower := strings.ToLower(errMsg) + for _, kw := range emailKeywords { + if strings.Contains(lower, strings.ToLower(kw)) { + return true + } + } + return false +} diff --git a/internal/worker/proxy.go b/internal/worker/proxy.go new file mode 100644 index 0000000..ec1d1cf --- /dev/null +++ b/internal/worker/proxy.go @@ -0,0 +1,114 @@ +package worker + +import ( + "bufio" + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "time" +) + +// envProxyURL 读取环境变量中的代理地址(HTTPS_PROXY 优先于 HTTP_PROXY) +func envProxyURL() *url.URL { + raw := os.Getenv("HTTPS_PROXY") + if raw == "" { + raw = os.Getenv("HTTP_PROXY") + } + if raw == "" { + raw = os.Getenv("https_proxy") + } + if raw == "" { + raw = os.Getenv("http_proxy") + } + if raw == "" { + return nil + } + u, err := url.Parse(raw) + if err != nil { + return nil + } + return u +} + +// proxyChainDialer 创建通过 firstProxy(HTTP CONNECT)建立隧道的 DialContext, +// 用于实现代理链:App → firstProxy(Clash) → 目标地址(后端代理) +// +// 配合 transport.Proxy = http.ProxyURL(backendProxy) 使用时,链路为: +// App → Clash(CONNECT) → backendProxy → 最终目标 +func proxyChainDialer(firstProxy *url.URL) func(ctx context.Context, network, addr string) (net.Conn, error) { + return func(ctx context.Context, network, addr string) (net.Conn, error) { + // 1. TCP 连接到第一级代理(Clash) + d := net.Dialer{Timeout: 15 * time.Second} + conn, err := d.DialContext(ctx, "tcp", firstProxy.Host) + if err != nil { + return nil, fmt.Errorf("连接代理 %s 失败: %w", firstProxy.Host, err) + } + + // 2. 发送 HTTP CONNECT 请求,建立到目标地址的隧道 + req := &http.Request{ + Method: "CONNECT", + URL: &url.URL{Opaque: addr}, + Host: addr, + Header: make(http.Header), + } + if err := req.Write(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("发送 CONNECT 失败: %w", err) + } + + // 3. 读取 CONNECT 响应 + resp, err := http.ReadResponse(bufio.NewReader(conn), req) + if err != nil { + conn.Close() + return nil, fmt.Errorf("CONNECT 响应读取失败: %w", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + conn.Close() + return nil, fmt.Errorf("CONNECT 返回 %d", resp.StatusCode) + } + + return conn, nil + } +} + +// applyProxy 为 http.Transport 设置代理策略,支持三层回退: +// 1. 有请求代理 + 有环境代理 → 代理链(环境代理 → 请求代理 → 目标) +// 2. 有请求代理,无环境代理 → 直连请求代理 +// 3. 无请求代理 → 回退到环境变量代理 +// 4. 都没有 → 直连 +func applyProxy(transport *http.Transport, proxy *ProxyEntry) { + envProxy := envProxyURL() + + // 解析请求级代理 + var reqProxyURL *url.URL + if proxy != nil { + proxyStr := proxy.HTTPS + if proxyStr == "" { + proxyStr = proxy.HTTP + } + if proxyStr != "" { + if u, err := url.Parse(proxyStr); err == nil { + reqProxyURL = u + } + } + } + + switch { + case reqProxyURL != nil && envProxy != nil: + // 代理链:App → Clash(envProxy) → 后端代理(reqProxy) → 目标 + transport.DialContext = proxyChainDialer(envProxy) + transport.Proxy = http.ProxyURL(reqProxyURL) + + case reqProxyURL != nil: + // 仅后端代理(无 Clash) + transport.Proxy = http.ProxyURL(reqProxyURL) + + default: + // 无请求代理 → 环境变量回退(有 Clash 走 Clash,没有就直连) + transport.Proxy = http.ProxyFromEnvironment + } +} diff --git a/internal/worker/queue_poll.go b/internal/worker/queue_poll.go new file mode 100644 index 0000000..ca57314 --- /dev/null +++ b/internal/worker/queue_poll.go @@ -0,0 +1,75 @@ +package worker + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" +) + +// QueueStatus CF Worker /api/v1/status 聚合的远程节点池排队状态 +type QueueStatus struct { + TotalActive int `json:"total_active"` + TotalWaiting int `json:"total_waiting"` + MaxConcurrent int `json:"max_concurrent"` + AvgSeconds int `json:"avg_seconds"` + HealthyNodes int `json:"healthy_nodes"` + TotalNodes int `json:"total_nodes"` +} + +// ETA 估算排队等待时间(秒) +// 公式:平均耗时 × 排队人数 ÷ 总处理槽位 +func (qs *QueueStatus) ETA() int { + if qs.TotalWaiting <= 0 { + return 0 + } + throughput := qs.MaxConcurrent + if throughput < 1 { + throughput = 1 + } + return qs.AvgSeconds * qs.TotalWaiting / throughput +} + +// queuePollClient 排队状态轮询专用 HTTP 客户端(短超时,不影响主请求) +var queuePollClient = &http.Client{Timeout: 5 * time.Second} + +// PollQueueStatus 查询 CF Worker 聚合的排队状态 +// statusURL 格式: https://cf-worker.xxx/api/v1/status?t=openai +func PollQueueStatus(ctx context.Context, statusURL string) (*QueueStatus, error) { + req, err := http.NewRequestWithContext(ctx, "GET", statusURL, nil) + if err != nil { + return nil, err + } + resp, err := queuePollClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var qs QueueStatus + if err := json.NewDecoder(resp.Body).Decode(&qs); err != nil { + return nil, err + } + return &qs, nil +} + +// FormatQueueLog 将排队状态格式化为用户可读的日志行 +func FormatQueueLog(qs *QueueStatus, elapsed int) string { + if qs.TotalWaiting > 0 { + eta := qs.ETA() + return fmt.Sprintf("[…] 排队中 %ds | 处理中 %d · 排队 %d · 节点 %d | 预计还需 %d 秒", + elapsed, qs.TotalActive, qs.TotalWaiting, qs.HealthyNodes, eta) + } + return fmt.Sprintf("[…] 远程注册进行中 (%ds)...", elapsed) +} + +// BuildQueueStatusURL 构建排队状态查询 URL +// 多节点逗号分隔时,只取第一个(CF Worker),直连节点没有 /api/v1/status 路由 +func BuildQueueStatusURL(serviceURL, platform string) string { + if idx := strings.Index(serviceURL, ","); idx > 0 { + serviceURL = serviceURL[:idx] + } + return strings.TrimRight(serviceURL, "/") + "/api/v1/status?t=" + platform +} diff --git a/internal/worker/worker.go b/internal/worker/worker.go new file mode 100644 index 0000000..666c9a0 --- /dev/null +++ b/internal/worker/worker.go @@ -0,0 +1,56 @@ +package worker + +import "context" + +// ProxyEntry 代理配置 +type ProxyEntry struct { + HTTP string `json:"http"` + HTTPS string `json:"https"` +} + +// Config 平台配置(各平台自定义字段) +type Config map[string]string + +// RegisterOpts 注册循环参数 +type RegisterOpts struct { + Proxy *ProxyEntry + Config Config + LogCh chan<- string + OnSuccess func(email string, credential map[string]interface{}) + OnFail func() +} + +// Worker 平台注册器接口 +type Worker interface { + // PlatformName 返回平台标识 + PlatformName() string + + // ScanConfig 获取/验证平台配置(cfg 包含已注入的系统设置) + ScanConfig(ctx context.Context, proxy *ProxyEntry, cfg Config) (Config, error) + + // RegisterOne 执行一次注册 + RegisterOne(ctx context.Context, opts RegisterOpts) +} + +// 平台注册表 +var Registry = map[string]Worker{} + +// 平台显示名 +var Labels = map[string]string{ + "grok": "Grok", + "openai": "OpenAI", + "openai_team": "OpenAI Team", + "kiro": "Kiro", + "gemini": "Gemini", +} + +// Register 注册平台 Worker +func Register(w Worker) { + Registry[w.PlatformName()] = w +} + +// Get 获取平台 Worker +func Get(platform string) (Worker, bool) { + w, ok := Registry[platform] + return w, ok +} diff --git a/macmini/.env.example b/macmini/.env.example new file mode 100644 index 0000000..8ee38dc --- /dev/null +++ b/macmini/.env.example @@ -0,0 +1,5 @@ +# Clash HTTP 代理端口(默认 7890,cloudflared 需要) +CLASH_PORT=7890 + +# Cloudflare Tunnel Token(从 CF Zero Trust 控制台获取) +CF_TUNNEL_TOKEN= diff --git a/macmini/.gitignore b/macmini/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/macmini/.gitignore @@ -0,0 +1 @@ +.env diff --git a/macmini/docker-compose.yml b/macmini/docker-compose.yml new file mode 100644 index 0000000..cb283f3 --- /dev/null +++ b/macmini/docker-compose.yml @@ -0,0 +1,132 @@ +services: + + hfkr: + image: ghcr.io/xiaolajiaoyyds/regplatformm-kiro:latest + platform: linux/amd64 + container_name: hfkr + ports: + - "7861:7860" + environment: + - KIRO_MAX_CONCURRENT=6 + - HTTPS_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - HTTP_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - NO_PROXY=localhost,127.0.0.1,host.docker.internal,hfkr,hfts,hfnp,hfgs,hfgm,hfgm + shm_size: "2g" + deploy: + resources: + limits: + cpus: "3.0" + memory: 5120M + reservations: + memory: 1536M + restart: unless-stopped + + hfgm: + image: ghcr.io/xiaolajiaoyyds/regplatformm-gemini:latest + platform: linux/amd64 + container_name: hfgm + ports: + - "7865:7860" + environment: + - GEMINI_MAX_CONCURRENT=2 + - KIRO_MAX_CONCURRENT=0 + - HTTPS_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - HTTP_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - NO_PROXY=localhost,127.0.0.1,host.docker.internal,hfkr,hfts,hfnp,hfgs,hfgm,hfgm + shm_size: "2g" + deploy: + resources: + limits: + cpus: "3.0" + memory: 5120M + reservations: + memory: 1536M + restart: unless-stopped + + hfts: + image: ghcr.io/xiaolajiaoyyds/regplatformm-ts:latest + platform: linux/amd64 + container_name: hfts + ports: + - "7862:7860" + environment: + - SVC_ARGS=--host 0.0.0.0 --port 7860 --browser_type camoufox --no-headless --thread 6 + - HTTPS_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - HTTP_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - NO_PROXY=localhost,127.0.0.1,host.docker.internal,hfkr,hfts,hfnp,hfgs,hfgm + volumes: + - ./hfts-patches/app.py:/opt/app/app.py:ro + - ./hfts-patches/run-full.sh:/opt/app/run-full.sh:ro + shm_size: "2g" + deploy: + resources: + limits: + cpus: "4.0" + memory: 6144M + reservations: + memory: 2048M + restart: unless-stopped + + hfnp: + image: ghcr.io/xiaolajiaoyyds/regplatformm-openai:latest + platform: linux/amd64 + container_name: hfnp + ports: + - "7863:7860" + environment: + - MAX_CONCURRENT=3 + - MAX_QUEUE=6 + - TURNSTILE_SOLVER_URL=http://hfts:7860 + - CF_BYPASS_SOLVER_URL=http://hfts:7860 + - HTTPS_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - HTTP_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - NO_PROXY=localhost,127.0.0.1,host.docker.internal,hfkr,hfts,hfnp,hfgs,hfgm + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + reservations: + memory: 128M + restart: unless-stopped + + hfgs: + image: ghcr.io/xiaolajiaoyyds/regplatformm-grok:latest + platform: linux/amd64 + container_name: hfgs + ports: + - "7864:7860" + environment: + - MAX_CONCURRENT=3 + - MAX_QUEUE=6 + - TURNSTILE_SOLVER_URL=http://hfts:7860 + - CF_BYPASS_SOLVER_URL=http://hfts:7860 + - HTTPS_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - HTTP_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - NO_PROXY=localhost,127.0.0.1,host.docker.internal,hfkr,hfts,hfnp,hfgs,hfgm + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + reservations: + memory: 128M + restart: unless-stopped + + cloudflared: + image: cloudflare/cloudflared:latest + container_name: cloudflared + command: tunnel run + environment: + - TUNNEL_TOKEN=${CF_TUNNEL_TOKEN} + - HTTPS_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - HTTP_PROXY=http://host.docker.internal:${CLASH_PORT:-7890} + - NO_PROXY=localhost,127.0.0.1,host.docker.internal,hfkr,hfts,hfnp,hfgs,hfgm + deploy: + resources: + limits: + cpus: "0.5" + memory: 256M + reservations: + memory: 64M + restart: unless-stopped diff --git a/macmini/hfts-patches/app.py b/macmini/hfts-patches/app.py new file mode 100644 index 0000000..200e806 --- /dev/null +++ b/macmini/hfts-patches/app.py @@ -0,0 +1,1464 @@ +import os +import sys +import time +import uuid +import random +import logging +import asyncio +import urllib.request +import warnings +from typing import Optional, Union +import argparse + +# 代理链支持:环境变量代理(Clash)→ 后端代理 → 目标 +try: + from proxy_chain import chain_proxy +except ImportError: + def chain_proxy(p): return p or "" + +# 抑制 requests 库 verify=False 产生的 InsecureRequestWarning 噪音 +warnings.filterwarnings("ignore", message="Unverified HTTPS request") +from quart import Quart, request, jsonify +try: + from camoufox.async_api import AsyncCamoufox +except ImportError: + AsyncCamoufox = None +from patchright.async_api import async_playwright +from db_results import init_db, save_result, load_result, cleanup_old_results +from browser_configs import browser_config +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.align import Align +from rich import box + + + +COLORS = { + 'MAGENTA': '\033[35m', + 'BLUE': '\033[34m', + 'GREEN': '\033[32m', + 'YELLOW': '\033[33m', + 'RED': '\033[31m', + 'RESET': '\033[0m', +} + + +class CustomLogger(logging.Logger): + @staticmethod + def format_message(level, color, message): + timestamp = time.strftime('%H:%M:%S') + return f"[{timestamp}] [{COLORS.get(color)}{level}{COLORS.get('RESET')}] -> {message}" + + def debug(self, message, *args, **kwargs): + super().debug(self.format_message('DEBUG', 'MAGENTA', message), *args, **kwargs) + + def info(self, message, *args, **kwargs): + super().info(self.format_message('INFO', 'BLUE', message), *args, **kwargs) + + def success(self, message, *args, **kwargs): + super().info(self.format_message('SUCCESS', 'GREEN', message), *args, **kwargs) + + def warning(self, message, *args, **kwargs): + super().warning(self.format_message('WARNING', 'YELLOW', message), *args, **kwargs) + + def error(self, message, *args, **kwargs): + super().error(self.format_message('ERROR', 'RED', message), *args, **kwargs) + + +logging.setLoggerClass(CustomLogger) +logger = logging.getLogger("TurnstileAPIServer") +logger.setLevel(logging.DEBUG) +handler = logging.StreamHandler(sys.stdout) +logger.addHandler(handler) + + +class TurnstileAPIServer: + + def __init__(self, headless: bool, useragent: Optional[str], debug: bool, browser_type: str, thread: int, proxy_support: bool, use_random_config: bool = False, browser_name: Optional[str] = None, browser_version: Optional[str] = None, default_proxy: Optional[str] = None): + self.app = Quart(__name__) + self.debug = debug + self.browser_type = browser_type + self.headless = headless + self.thread_count = thread + self.proxy_support = proxy_support + self._raw_default_proxy = default_proxy or "" # 保留原始值用于 pool 匹配比较 + self.default_proxy = chain_proxy(default_proxy) if default_proxy else None # pool 浏览器启动时的默认代理 + self.browser_pool = asyncio.Queue() + self.use_random_config = use_random_config + self.browser_name = browser_name + self.browser_version = browser_version + self.console = Console() + # 服务端缓存的 Turnstile API JS 内容(绕过代理压缩问题) + self._turnstile_js_cache: Optional[str] = None + + # Initialize useragent and sec_ch_ua attributes + self.useragent = useragent + self.sec_ch_ua = None + + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + if browser_name and browser_version: + config = browser_config.get_browser_config(browser_name, browser_version) + if config: + useragent, sec_ch_ua = config + self.useragent = useragent + self.sec_ch_ua = sec_ch_ua + elif useragent: + self.useragent = useragent + else: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + self.browser_name = browser + self.browser_version = version + self.useragent = useragent + self.sec_ch_ua = sec_ch_ua + + self.browser_args = [] + if self.useragent: + self.browser_args.append(f"--user-agent={self.useragent}") + + self._setup_routes() + + def display_welcome(self): + """Displays welcome screen with logo.""" + self.console.clear() + + combined_text = Text() + combined_text.append("\n📢 Channel: ", style="bold white") + combined_text.append("https://t.me/D3_vin", style="cyan") + combined_text.append("\n💬 Chat: ", style="bold white") + combined_text.append("https://t.me/D3vin_chat", style="cyan") + combined_text.append("\n📁 GitHub: ", style="bold white") + combined_text.append("https://github.com/D3-vin", style="cyan") + combined_text.append("\n📁 Version: ", style="bold white") + combined_text.append("1.2b", style="green") + combined_text.append("\n") + + info_panel = Panel( + Align.left(combined_text), + title="[bold blue]Turnstile Solver[/bold blue]", + subtitle="[bold magenta]Dev by D3vin[/bold magenta]", + box=box.ROUNDED, + border_style="bright_blue", + padding=(0, 1), + width=50 + ) + + self.console.print(info_panel) + self.console.print() + + + + + def _setup_routes(self) -> None: + """Set up the application routes.""" + self.app.before_serving(self._startup) + self.app.route('/turnstile', methods=['GET'])(self.process_turnstile) + self.app.route('/result', methods=['GET'])(self.get_result) + self.app.route('/health')(self.health) + self.app.route('/')(self.index) + + + async def _startup(self) -> None: + """Initialize the browser and page pool on startup.""" + self.display_welcome() + logger.info("Starting browser initialization") + try: + await init_db() + await self._initialize_browser() + + # Запускаем периодическую очистку старых результатов + asyncio.create_task(self._periodic_cleanup()) + + except Exception as e: + logger.error(f"Failed to initialize browser: {str(e)}") + raise + + async def _initialize_browser(self) -> None: + """Initialize the browser and create the page pool.""" + playwright = None + camoufox = None + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + playwright = await async_playwright().start() + elif self.browser_type == "camoufox": + camoufox_kwargs = {"headless": self.headless} + # pool 浏览器直接带代理启动(Firefox 不支持 context 级代理) + proxy_to_use = self.default_proxy + + # 如果没有 default_proxy,尝试使用环境变量代理(HTTPS_PROXY/HTTP_PROXY) + if not proxy_to_use: + for var in ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"): + env_proxy = os.environ.get(var, "").strip() + if env_proxy: + proxy_to_use = env_proxy + logger.info(f"Using proxy from environment variable {var}: {proxy_to_use}") + break + + if proxy_to_use: + proxy_config = {"server": proxy_to_use} + if '@' in proxy_to_use: + scheme_part, auth_part = proxy_to_use.split('://') + auth, address = auth_part.split('@') + username, password = auth.split(':') + ip, port = address.split(':') + proxy_config = {"server": f"{scheme_part}://{ip}:{port}", "username": username, "password": password} + camoufox_kwargs["proxy"] = proxy_config + # 注意:不设置 os/humanize/window,使用极简参数(修复 CAPTCHA_FAIL) + # - os="windows" 与 Linux 容器矛盾,导致指纹不一致 + # - humanize=True 增加延迟,导致 Turnstile 超时 + # - window=(1280, 720) 与 Xvfb 虚拟屏幕冲突 + camoufox_kwargs["geoip"] = True + logger.info(f"Camoufox pool with browser-level proxy: {proxy_to_use}") + self._camoufox_kwargs = camoufox_kwargs # 保存参数用于浏览器崩溃后重建 + camoufox = AsyncCamoufox(**camoufox_kwargs) + + browser_configs = [] + for _ in range(self.thread_count): + if self.browser_type in ['chromium', 'chrome', 'msedge']: + if self.use_random_config: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + elif self.browser_name and self.browser_version: + config = browser_config.get_browser_config(self.browser_name, self.browser_version) + if config: + useragent, sec_ch_ua = config + browser = self.browser_name + version = self.browser_version + else: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + else: + browser = getattr(self, 'browser_name', 'custom') + version = getattr(self, 'browser_version', 'custom') + useragent = self.useragent + sec_ch_ua = getattr(self, 'sec_ch_ua', '') + else: + # Для camoufox и других браузеров используем значения по умолчанию + browser = self.browser_type + version = 'custom' + useragent = self.useragent + sec_ch_ua = getattr(self, 'sec_ch_ua', '') + + + browser_configs.append({ + 'browser_name': browser, + 'browser_version': version, + 'useragent': useragent, + 'sec_ch_ua': sec_ch_ua + }) + + for i in range(self.thread_count): + config = browser_configs[i] + + # Docker 容器必需参数 + # --disable-features=ThirdPartyCookiePhaseout: Chrome 120+ 默认阻断第三方 Cookie, + # Turnstile iframe (challenges.cloudflare.com) 需要跨域 Cookie, + # 不加此 flag 会报 "Please enable cookies." + browser_args = [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-features=ThirdPartyCookiePhaseout", + ] + if config['useragent']: + browser_args.append(f"--user-agent={config['useragent']}") + + browser = None + if self.browser_type in ['chromium', 'chrome', 'msedge'] and playwright: + browser = await playwright.chromium.launch( + channel=self.browser_type if self.browser_type != 'chromium' else None, + headless=self.headless, + args=browser_args + ) + elif self.browser_type == "camoufox" and camoufox: + browser = await camoufox.start() + + if browser: + await self.browser_pool.put((i+1, browser, config)) + + if self.debug: + logger.info(f"Browser {i + 1} initialized successfully with {config['browser_name']} {config['browser_version']}") + + logger.info(f"Browser pool initialized with {self.browser_pool.qsize()} browsers") + + if self.use_random_config: + logger.info(f"Each browser in pool received random configuration") + elif self.browser_name and self.browser_version: + logger.info(f"All browsers using configuration: {self.browser_name} {self.browser_version}") + else: + logger.info("Using custom configuration") + + if self.debug: + for i, config in enumerate(browser_configs): + logger.debug(f"Browser {i+1} config: {config['browser_name']} {config['browser_version']}") + logger.debug(f"Browser {i+1} User-Agent: {config['useragent']}") + logger.debug(f"Browser {i+1} Sec-CH-UA: {config['sec_ch_ua']}") + + async def _periodic_cleanup(self): + """Periodic cleanup of old results every hour""" + while True: + try: + await asyncio.sleep(3600) + deleted_count = await cleanup_old_results(days_old=7) + if deleted_count > 0: + logger.info(f"Cleaned up {deleted_count} old results") + except Exception as e: + logger.error(f"Error during periodic cleanup: {e}") + + async def _recreate_browser(self, index, config): + """重建崩溃的浏览器实例,确保浏览器池容量不缩减""" + try: + if self.browser_type == "camoufox" and AsyncCamoufox and hasattr(self, '_camoufox_kwargs'): + camoufox_inst = AsyncCamoufox(**self._camoufox_kwargs) + browser = await camoufox_inst.start() + logger.info(f"Browser {index}: 浏览器重建成功") + return browser + elif self.browser_type in ['chromium', 'chrome', 'msedge']: + pw = await async_playwright().start() + browser_args = [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-features=ThirdPartyCookiePhaseout", + ] + if config.get('useragent'): + browser_args.append(f"--user-agent={config['useragent']}") + browser = await pw.chromium.launch( + channel=self.browser_type if self.browser_type != 'chromium' else None, + headless=self.headless, + args=browser_args + ) + logger.info(f"Browser {index}: 浏览器重建成功") + return browser + except Exception as e: + logger.error(f"Browser {index}: 浏览器重建失败: {e}") + return None + + async def _antishadow_inject(self, page): + await page.add_init_script(""" + (function() { + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function(init) { + const shadow = originalAttachShadow.call(this, init); + if (init.mode === 'closed') { + window.__lastClosedShadowRoot = shadow; + } + return shadow; + }; + })(); + """) + + + + async def _optimized_route_handler(self, route): + """Оптимизированный обработчик маршрутов для экономии ресурсов.""" + url = route.request.url + resource_type = route.request.resource_type + + allowed_types = {'document', 'script', 'xhr', 'fetch'} + + allowed_domains = [ + 'challenges.cloudflare.com', + 'static.cloudflareinsights.com', + 'cloudflare.com' + ] + + if resource_type in allowed_types: + await route.continue_() + elif any(domain in url for domain in allowed_domains): + await route.continue_() + else: + await route.abort() + + async def _get_turnstile_js(self) -> Optional[str]: + """服务端拉取 Turnstile API JS(不走代理),缓存复用。 + + 绕过代理丢失 Content-Encoding 导致浏览器收到压缩乱码的问题。 + 返回 JS 文本,供 add_script_tag(content=...) 注入。 + """ + if self._turnstile_js_cache: + return self._turnstile_js_cache + + api_url = 'https://challenges.cloudflare.com/turnstile/v0/api.js' + try: + def _fetch() -> str: + req = urllib.request.Request(api_url, headers={ + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Encoding': 'identity', # 禁止压缩,直接拿明文 + }) + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.read().decode('utf-8') + + loop = asyncio.get_event_loop() + content = await loop.run_in_executor(None, _fetch) + # 简单校验:包含 turnstile 字样才算有效 + if content and 'turnstile' in content.lower(): + self._turnstile_js_cache = content + logger.info(f"Turnstile API JS fetched server-side: {len(content)} bytes") + return content + else: + logger.warning("Turnstile API JS fetched but content looks invalid") + except Exception as e: + logger.warning(f"Server-side Turnstile JS fetch failed: {e}") + return None + + async def _cf_challenge_proxy_handler(self, route, override_proxy: Optional[str] = None): + """拦截 CF challenge 相关请求,通过 Python 代理转发。 + 如果配置了代理(SOCKS5),则用相同代理转发,保证 CF challenge 请求与 + 浏览器来自同一 IP,避免 Cloudflare 因 IP 不一致拒绝 Turnstile 验证。 + override_proxy:临时 Camoufox 传入实际 proxy,优先于 self.default_proxy。 + 阻塞 IO 必须在 executor 里执行,不能直接调用,否则卡死 asyncio 事件循环。 + """ + req_url = route.request.url + method = route.request.method + skip_headers = {'host', 'connection', 'keep-alive', 'transfer-encoding', + 'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'} + headers = {k: v for k, v in route.request.headers.items() + if k.lower() not in skip_headers} + post_data = route.request.post_data_buffer # bytes | None + + try: + proxy_url = override_proxy if override_proxy is not None else self.default_proxy # 与浏览器相同的代理,保证 IP 一致 + + def _fetch(): + import requests as req_lib + session = req_lib.Session() + if proxy_url: + session.proxies = {'http': proxy_url, 'https': proxy_url} + resp = session.request( + method=method, + url=req_url, + headers=headers, + data=post_data, + timeout=30, + allow_redirects=True, + verify=False, + ) + resp_headers = {k: v for k, v in resp.headers.items() + if k.lower() not in {'transfer-encoding', 'connection'}} + return resp.status_code, resp_headers, resp.content + + loop = asyncio.get_event_loop() + status, resp_headers, content = await loop.run_in_executor(None, _fetch) + + if self.debug: + logger.debug(f"CF proxy [{method}] {req_url[:80]} → {status} ({len(content)}B)") + await route.fulfill(status=status, headers=resp_headers, body=content) + except Exception as e: + if self.debug: + logger.debug(f"CF proxy error [{method}] {req_url[:80]}: {e}") + try: + await route.fallback() + except Exception: + await route.abort() + + async def _block_rendering(self, page): + """Блокировка рендеринга для экономии ресурсов""" + await page.route("**/*", self._optimized_route_handler) + + async def _unblock_rendering(self, page): + """Разблокировка рендеринга""" + await page.unroute("**/*", self._optimized_route_handler) + + async def _find_turnstile_elements(self, page, index: int): + """Умная проверка всех возможных Turnstile элементов""" + selectors = [ + '.cf-turnstile', + '[data-sitekey]', + 'iframe[src*="turnstile"]', + 'iframe[title*="widget"]', + 'div[id*="turnstile"]', + 'div[class*="turnstile"]' + ] + + elements = [] + for selector in selectors: + try: + # Безопасная проверка count() + try: + count = await page.locator(selector).count() + except Exception: + # Если count() дает ошибку, пропускаем этот селектор + continue + + if count > 0: + elements.append((selector, count)) + if self.debug: + logger.debug(f"Browser {index}: Found {count} elements with selector '{selector}'") + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Selector '{selector}' failed: {str(e)}") + continue + + return elements + + async def _find_and_click_checkbox(self, page, index: int): + """Найти и кликнуть по чекбоксу Turnstile CAPTCHA внутри iframe""" + try: + # Пробуем разные селекторы iframe с защитой от ошибок + iframe_selectors = [ + 'iframe[src*="challenges.cloudflare.com"]', + 'iframe[src*="turnstile"]', + 'iframe[title*="widget"]' + ] + + iframe_locator = None + for selector in iframe_selectors: + try: + test_locator = page.locator(selector).first + # Безопасная проверка count для iframe + try: + iframe_count = await test_locator.count() + except Exception: + iframe_count = 0 + + if iframe_count > 0: + iframe_locator = test_locator + if self.debug: + logger.debug(f"Browser {index}: Found Turnstile iframe with selector: {selector}") + break + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe selector '{selector}' failed: {str(e)}") + continue + + if iframe_locator: + try: + # Получаем frame из iframe + iframe_element = await iframe_locator.element_handle() + frame = await iframe_element.content_frame() + + if frame: + # Ищем чекбокс внутри iframe + checkbox_selectors = [ + 'input[type="checkbox"]', + '.cb-lb input[type="checkbox"]', + 'label input[type="checkbox"]' + ] + + for selector in checkbox_selectors: + try: + # Полностью избегаем locator.count() в iframe - используем альтернативный подход + try: + # Пробуем кликнуть напрямую без count проверки + checkbox = frame.locator(selector).first + await checkbox.click(timeout=2000) + if self.debug: + logger.debug(f"Browser {index}: Successfully clicked checkbox in iframe with selector '{selector}'") + return True + except Exception as click_e: + # Если прямой клик не сработал, записываем в debug но не падаем + if self.debug: + logger.debug(f"Browser {index}: Direct checkbox click failed for '{selector}': {str(click_e)}") + continue + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe checkbox selector '{selector}' failed: {str(e)}") + continue + + # Если нашли iframe, но не смогли кликнуть чекбокс, пробуем клик по iframe + try: + if self.debug: + logger.debug(f"Browser {index}: Trying to click iframe directly as fallback") + await iframe_locator.click(timeout=1000) + return True + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe direct click failed: {str(e)}") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Failed to access iframe content: {str(e)}") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: General iframe search failed: {str(e)}") + + return False + + async def _try_click_strategies(self, page, index: int): + strategies = [ + ('checkbox_click', lambda: self._find_and_click_checkbox(page, index)), + ('direct_widget', lambda: self._safe_click(page, '.cf-turnstile', index)), + ('iframe_click', lambda: self._safe_click(page, 'iframe[src*="turnstile"]', index)), + ('js_click', lambda: page.evaluate("document.querySelector('.cf-turnstile')?.click()")), + ('sitekey_attr', lambda: self._safe_click(page, '[data-sitekey]', index)), + ('any_turnstile', lambda: self._safe_click(page, '*[class*="turnstile"]', index)), + ('xpath_click', lambda: self._safe_click(page, "//div[@class='cf-turnstile']", index)) + ] + + for strategy_name, strategy_func in strategies: + try: + result = await strategy_func() + if result is True or result is None: # None означает успех для большинства стратегий + if self.debug: + logger.debug(f"Browser {index}: Click strategy '{strategy_name}' succeeded") + return True + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Click strategy '{strategy_name}' failed: {str(e)}") + continue + + return False + + async def _safe_click(self, page, selector: str, index: int): + """Полностью безопасный клик с максимальной защитой от ошибок""" + try: + # Пробуем кликнуть напрямую без count() проверки + locator = page.locator(selector).first + await locator.click(timeout=1000) + return True + except Exception as e: + # Логируем ошибку только в debug режиме + if self.debug and "Can't query n-th element" not in str(e): + logger.debug(f"Browser {index}: Safe click failed for '{selector}': {str(e)}") + return False + + async def _inject_captcha_directly(self, page, websiteKey: str, action: str = '', cdata: str = '', index: int = 0): + """直接向目标页面注入 Turnstile widget(比 overlay 成功率高得多)""" + script = f""" + // 移除旧 widget + document.querySelectorAll('.cf-turnstile').forEach(el => el.remove()); + document.querySelectorAll('[data-sitekey]').forEach(el => el.remove()); + + const captchaDiv = document.createElement('div'); + captchaDiv.className = 'cf-turnstile'; + captchaDiv.setAttribute('data-sitekey', '{websiteKey}'); + captchaDiv.setAttribute('data-callback', 'onTurnstileCallback'); + {f'captchaDiv.setAttribute("data-action", "{action}");' if action else ''} + {f'captchaDiv.setAttribute("data-cdata", "{cdata}");' if cdata else ''} + captchaDiv.style.position = 'fixed'; + captchaDiv.style.top = '20px'; + captchaDiv.style.left = '20px'; + captchaDiv.style.zIndex = '9999'; + captchaDiv.style.backgroundColor = 'white'; + captchaDiv.style.padding = '15px'; + captchaDiv.style.border = '2px solid #0f79af'; + captchaDiv.style.borderRadius = '8px'; + document.body.appendChild(captchaDiv); + + const setToken = (token) => {{ + let inp = document.querySelector('input[name="cf-turnstile-response"]'); + if (!inp) {{ + inp = document.createElement('input'); + inp.type = 'hidden'; + inp.name = 'cf-turnstile-response'; + document.body.appendChild(inp); + }} + inp.value = token; + }}; + + window.onTurnstileCallback = setToken; + + const renderWidget = () => {{ + if (window.turnstile && window.turnstile.render) {{ + try {{ + window.turnstile.render(captchaDiv, {{ + sitekey: '{websiteKey}', + {f'action: "{action}",' if action else ''} + {f'cdata: "{cdata}",' if cdata else ''} + callback: setToken, + 'error-callback': function(e) {{ console.log('Turnstile error:', e); }} + }}); + }} catch(e) {{ console.log('render error:', e); }} + }} + }}; + + if (window.turnstile) {{ + renderWidget(); + }} else {{ + const s = document.createElement('script'); + s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + s.async = true; + s.defer = true; + s.onload = () => setTimeout(renderWidget, 500); + document.head.appendChild(s); + }} + """ + await page.evaluate(script) + if self.debug: + logger.debug(f"Browser {index}: Injected Turnstile widget directly with sitekey: {websiteKey}") + + async def _solve_turnstile(self, task_id: str, url: str, sitekey: str, action: Optional[str] = None, cdata: Optional[str] = None, override_proxy: str = ""): + """Solve the Turnstile challenge.""" + proxy = None + temp_browser = None # Camoufox + 代理时的临时浏览器 + temp_camoufox_inst = None # 保留引用用于正确关闭 Playwright + + index, browser, browser_config = await self.browser_pool.get() + + try: + if hasattr(browser, 'is_connected') and not browser.is_connected(): + logger.warning(f"Browser {index}: 浏览器已断开,正在重建...") + new_browser = await self._recreate_browser(index, browser_config) + if new_browser: + await self.browser_pool.put((index, new_browser, browser_config)) + else: + logger.error(f"Browser {index}: 重建失败,池容量已减少") + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": 0}) + return + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Cannot check browser state: {str(e)}") + + # 优先使用 API 传入的代理,其次读 proxies.txt + if override_proxy: + proxy = override_proxy + logger.info(f"Browser {index}: Using override proxy: {proxy}") + elif self.proxy_support: + proxy_file_path = os.path.join(os.getcwd(), "proxies.txt") + + try: + with open(proxy_file_path) as proxy_file: + proxies = [line.strip() for line in proxy_file if line.strip()] + + proxy = random.choice(proxies) if proxies else None + + if self.debug and proxy: + logger.debug(f"Browser {index}: Selected proxy: {proxy}") + elif self.debug and not proxy: + logger.debug(f"Browser {index}: No proxies available") + + except FileNotFoundError: + logger.warning(f"Proxy file not found: {proxy_file_path}") + proxy = None + except Exception as e: + logger.error(f"Error reading proxy file: {str(e)}") + proxy = None + + # Camoufox (Firefox 内核) 不支持 context 级代理,必须在浏览器启动时设置 + # 如果 override_proxy 和 pool 默认代理一致,直接用 pool 浏览器(长期存活,信任度高) + # 否则创建临时 Camoufox 实例,用完即关 + # 注意:用原始代理值比较,避免 chain_proxy 转换后端口不同导致误判 + pool_has_proxy = bool(self._raw_default_proxy) + proxy_matches_pool = pool_has_proxy and proxy and proxy.strip() == self._raw_default_proxy.strip() + + # 代理链:通过 Clash 链到后端代理,确保国内环境能连通海外代理 + if proxy: + proxy = chain_proxy(proxy) + + if proxy_matches_pool: + logger.info(f"Browser {index}: Proxy matches pool default, using pool browser directly (no temp browser)") + + if proxy and self.browser_type == "camoufox" and not proxy_matches_pool: + try: + proxy_config = {"server": proxy} + if '@' in proxy: + scheme_part, auth_part = proxy.split('://') + auth, address = auth_part.split('@') + username, password = auth.split(':') + ip, port = address.split(':') + proxy_config = {"server": f"{scheme_part}://{ip}:{port}", "username": username, "password": password} + camoufox_inst = AsyncCamoufox( + headless=self.headless, + proxy=proxy_config, + geoip=True, + # 注意:不设置 os/humanize/window,使用极简参数(修复 CAPTCHA_FAIL) + ) + temp_browser = await camoufox_inst.start() + temp_camoufox_inst = camoufox_inst # 保留引用用于 __aexit__ 清理 + logger.info(f"Browser {index}: Temp Camoufox created with browser-level proxy: {proxy}") + except Exception as e: + logger.error(f"Browser {index}: Failed to create temp Camoufox with proxy: {e}") + temp_browser = None + + # 选择实际使用的浏览器 + actual_browser = temp_browser if temp_browser else browser + + # 根据 proxy 创建浏览器 context + if proxy and not temp_browser: + if '@' in proxy: + try: + scheme_part, auth_part = proxy.split('://') + auth, address = auth_part.split('@') + username, password = auth.split(':') + ip, port = address.split(':') + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {scheme_part}://{ip}:{port} (auth: {username}:***)") + context_options = { + "proxy": { + "server": f"{scheme_part}://{ip}:{port}", + "username": username, + "password": password + }, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + except ValueError: + raise ValueError(f"Invalid proxy format: {proxy}") + else: + parts = proxy.split(':') + if len(parts) == 5: + proxy_scheme, proxy_ip, proxy_port, proxy_user, proxy_pass = parts + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy_scheme}://{proxy_ip}:{proxy_port} (auth: {proxy_user}:***)") + context_options = { + "proxy": { + "server": f"{proxy_scheme}://{proxy_ip}:{proxy_port}", + "username": proxy_user, + "password": proxy_pass + }, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + elif len(parts) == 3: + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy}") + context_options = { + "proxy": {"server": f"{proxy}"}, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + elif '://' in proxy: + # 处理 socks5://ip:port 或 http://ip:port 格式 + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy}") + context_options = { + "proxy": {"server": proxy}, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + else: + raise ValueError(f"Invalid proxy format: {proxy}") + else: + # temp_browser 存在时走这里:代理已在浏览器级别设置,context 不需要再设代理 + # 无代理时也走这里 + if self.debug: + if temp_browser: + logger.debug(f"Browser {index}: Creating context on temp Camoufox (proxy at browser level)") + else: + logger.debug(f"Browser {index}: Creating context without proxy") + context_options = {"user_agent": browser_config['useragent']} + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + # 注意:不使用 bypass_csp,因为它会干扰 Turnstile iframe 通信(修复 CAPTCHA_FAIL) + # # 原注释: + # # Turnstile managed mode 动态创建 iframe(src=challenges.cloudflare.com) + # # 可能被页面 CSP frame-src 拦截,导致 src 始终为空、callback 不触发 + # context_options['bypass_csp'] = True + + context = await actual_browser.new_context(**context_options) + + page = await context.new_page() + + await self._antishadow_inject(page) + + # 注意:强制设为 False,走 inject 路径而非 natural solve(修复 CAPTCHA_FAIL) + # natural solve 模式在数据中心 IP 上不会自动通过,会导致超时 + is_camoufox = False + + # Chrome/Camoufox 均不拦截资源: + # 资源拦截(CSS/image/font)会让 Cloudflare 检测到页面渲染异常, + # 导致 managed mode Turnstile iframe src 始终为空,callback 不触发。 + # 原始版本从未启用 _block_rendering,此处移除以对齐原始行为。 + + if is_camoufox: + # Camoufox (Firefox): 只隐藏 webdriver 标志 + # 不注入 window.chrome — Firefox 没有此 API,注入反而暴露指纹不一致 + await page.add_init_script(""" +Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, +}); +""") + else: + # Chromium: 隐藏 webdriver + 补全 window.chrome + await page.add_init_script(""" +Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, +}); + +window.chrome = { + runtime: {}, + loadTimes: function() {}, + csi: function() {}, +}; +""") + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + await page.set_viewport_size({"width": 500, "height": 100}) + if self.debug: + logger.debug(f"Browser {index}: Set viewport size to 500x240") + + start_time = time.time() + + try: + if self.debug: + logger.debug(f"Browser {index}: Starting Turnstile solve for URL: {url} with Sitekey: {sitekey} | Action: {action} | Cdata: {cdata} | Proxy: {proxy}") + + if is_camoufox: + # === Camoufox 自然求解模式 === + # 不拦截资源,让页面完整加载(含 Turnstile API 及其子资源) + # networkidle 确保 Turnstile API 完全初始化 + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Natural solve mode — full page load, no resource blocking") + + # 监听请求失败(调试用) + if self.debug: + def _on_request_failed(req): + logger.debug(f"Browser {index}: Request FAILED: {req.url[:100]} - {req.failure}") + page.on("requestfailed", _on_request_failed) + + # 代理拦截 CF challenge 相关域,绕过 SOCKS5 对这些路径的封锁,保证 IP 一致性 + # 仅在有代理时才安装:无代理时浏览器直连 CF,Python requests 的 TLS 指纹 + # 与 Camoufox 不同,会被 Cloudflare 检测到不一致而拒绝 challenge + _actual_proxy = proxy if proxy else self.default_proxy + if _actual_proxy: + async def _cf_proxy_handler_with_actual_proxy(route, _p=_actual_proxy): + await self._cf_challenge_proxy_handler(route, override_proxy=_p) + await page.route("*://challenges.cloudflare.com/**", _cf_proxy_handler_with_actual_proxy) + await page.route("*://accounts.x.ai/cdn-cgi/challenge-platform/**", _cf_proxy_handler_with_actual_proxy) + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] CF challenge proxy routes installed (proxy={_actual_proxy})") + else: + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] No proxy configured, letting browser handle CF challenges natively") + + # 中止 csp-reporting 请求:它们持续 NS_ERROR_ABORT 重试,会阻止 networkidle 触发 + async def _abort_csp_reporting(route): + await route.abort() + await page.route("*://csp-reporting.cloudflare.com/**", _abort_csp_reporting) + + # 使用 domcontentloaded 而非 networkidle,避免 csp-reporting 重试无限延迟 + await page.goto(url, wait_until='domcontentloaded', timeout=60000) + # 等待页面 JS 初始化(Turnstile widget 渲染需要时间) + await asyncio.sleep(5) + + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Page loaded (domcontentloaded+5s), checking for existing Turnstile...") + + # Phase 1: 检查页面自带的 Turnstile 是否已生成 token(自动求解 / managed 模式低风险直通) + await asyncio.sleep(3) + existing_token = await page.evaluate("""() => { + // 检查 hidden input + const inputs = document.querySelectorAll('input[name="cf-turnstile-response"]'); + for (const inp of inputs) { + if (inp.value && inp.value.length > 10) return inp.value; + } + // 检查 widget data-response 属性 + const widgets = document.querySelectorAll('.cf-turnstile[data-response]'); + for (const w of widgets) { + const resp = w.getAttribute('data-response'); + if (resp && resp.length > 10) return resp; + } + return null; + }""") + + if existing_token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: [Camoufox] Got token from page's own Turnstile — {COLORS.get('MAGENTA')}{existing_token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')}s") + await save_result(task_id, "turnstile", {"value": existing_token, "elapsed_time": elapsed_time}) + return + + # Phase 2: 页面 Turnstile 未自动完成,尝试点击 checkbox + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] No auto-token, trying to click Turnstile checkbox...") + clicked = await self._find_and_click_checkbox(page, index) + if clicked: + # 等待 token 生成 + await asyncio.sleep(3) + existing_token = await page.evaluate("""() => { + const inputs = document.querySelectorAll('input[name="cf-turnstile-response"]'); + for (const inp of inputs) { + if (inp.value && inp.value.length > 10) return inp.value; + } + return null; + }""") + if existing_token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: [Camoufox] Got token after click — {COLORS.get('MAGENTA')}{existing_token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')}s") + await save_result(task_id, "turnstile", {"value": existing_token, "elapsed_time": elapsed_time}) + return + + # Phase 3: 页面自带的 Turnstile 未成功,主动加载 Turnstile API 并渲染 + has_api = await page.evaluate("typeof window.turnstile !== 'undefined'") + if self.debug: + current_url = page.url + logger.debug(f"Browser {index}: [Camoufox] Current URL: {current_url}") + logger.debug(f"Browser {index}: [Camoufox] Turnstile API available: {has_api}") + + if not has_api: + # 诊断:从页面内 fetch Turnstile API URL,看代理返回什么 + if self.debug: + try: + fetch_diag = await page.evaluate("""async () => { + try { + const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/api.js'); + const t = await r.text(); + return {status: r.status, type: r.headers.get('content-type'), len: t.length, start: t.substring(0, 200)}; + } catch(e) { return {error: e.message}; } + }""") + logger.debug(f"Browser {index}: [Camoufox] Turnstile API fetch diag: {fetch_diag}") + except Exception as e: + logger.debug(f"Browser {index}: [Camoufox] Fetch diag failed: {e}") + + # 检查页面自带的 Turnstile script 标签 + page_scripts = await page.evaluate("""() => { + const scripts = [...document.querySelectorAll('script[src]')]; + return scripts.map(s => s.src).filter(u => u.includes('turnstile') || u.includes('challenges.cloudflare')); + }""") + logger.debug(f"Browser {index}: [Camoufox] Page Turnstile scripts: {page_scripts}") + + # 服务端拉取 Turnstile JS 并以内容方式注入,绕过代理压缩问题 + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Loading Turnstile API via server-side fetch + content injection...") + try: + js_content = await self._get_turnstile_js() + if js_content: + await page.add_script_tag(content=js_content) + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Injected Turnstile JS as content ({len(js_content)} bytes)") + else: + # 服务端拉取失败时降级用 URL 方式 + await page.add_script_tag(url='https://challenges.cloudflare.com/turnstile/v0/api.js') + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Fallback: add_script_tag with URL") + # 轮询等待 API 初始化(最多 10 秒) + for _w in range(10): + await asyncio.sleep(1) + has_api = await page.evaluate("typeof window.turnstile !== 'undefined'") + if has_api: + break + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] After injection: Turnstile API available: {has_api}") + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Script injection failed: {e}") + + if has_api: + # Turnstile API 已加载,创建固定位置可见容器(managed 模式要求 widget 在视口内) + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Calling turnstile.render() with sitekey: {sitekey}") + await page.evaluate(f"""() => {{ + // 创建专用渲染容器,position:fixed 确保始终在视口内可见 + let container = document.getElementById('_ts_solver_container'); + if (!container) {{ + container = document.createElement('div'); + container.id = '_ts_solver_container'; + container.style.cssText = 'position:fixed;top:0;left:0;z-index:999999;width:300px;height:70px;background:#fff;'; + document.body.appendChild(container); + }} + // 确保 hidden input 存在(用于读取 token) + let inp = document.getElementById('_ts_solver_input'); + if (!inp) {{ + inp = document.createElement('input'); + inp.type = 'hidden'; + inp.id = '_ts_solver_input'; + inp.name = 'cf-turnstile-response'; + document.body.appendChild(inp); + }} + const setToken = (token) => {{ inp.value = token; }}; + window._tsSolverToken = ''; + window._tsSolverCallback = setToken; + try {{ + window.turnstile.render(container, {{ + sitekey: '{sitekey}', + {'action: "' + action + '",' if action else ''} + {'cdata: "' + cdata + '",' if cdata else ''} + callback: (token) => {{ + window._tsSolverToken = token; + setToken(token); + }} + }}); + }} catch(e) {{ console.log('turnstile.render error:', e); }} + }}""") + await asyncio.sleep(3) + else: + # add_script_tag 也失败了,用旧方法 fallback + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Turnstile API still unavailable, using _inject_captcha_directly fallback") + await self._inject_captcha_directly(page, sitekey, action or '', cdata or '', index) + await asyncio.sleep(3) + + else: + # === Chromium 模式 === + if self.debug: + logger.debug(f"Browser {index}: Loading real website directly: {url}") + + await page.goto(url, wait_until='domcontentloaded', timeout=30000) + await self._inject_captcha_directly(page, sitekey, action or '', cdata or '', index) + await asyncio.sleep(2) + + locator = page.locator('input[name="cf-turnstile-response"]') + max_attempts = 30 + + for attempt in range(max_attempts): + try: + # Безопасная проверка количества элементов с токеном + try: + count = await locator.count() + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Locator count failed on attempt {attempt + 1}: {str(e)}") + count = 0 + + if count == 0: + if self.debug: + logger.debug(f"Browser {index}: No token elements found on attempt {attempt + 1}") + # 每 5 次尝试诊断一次 widget 状态 + if attempt % 5 == 0: + try: + diag = await page.evaluate("""() => { + const d = {}; + d.hasTurnstile = typeof window.turnstile !== 'undefined'; + d.cfDivs = document.querySelectorAll('.cf-turnstile').length; + d.iframes = document.querySelectorAll('iframe[src*="challenges.cloudflare"]').length; + d.inputs = document.querySelectorAll('input[name="cf-turnstile-response"]').length; + d.allIframes = document.querySelectorAll('iframe').length; + // 检查 widget 内容 + const cfDiv = document.querySelector('.cf-turnstile'); + if (cfDiv) { + d.cfDivHTML = cfDiv.innerHTML.substring(0, 200); + d.cfDivChildren = cfDiv.children.length; + } + // 检查是否有错误信息 + const errEl = document.querySelector('[class*="error"]'); + if (errEl) d.errorText = errEl.textContent.substring(0, 100); + return d; + }""") + logger.debug(f"Browser {index}: Widget diag: {diag}") + except Exception as e: + logger.debug(f"Browser {index}: Diag failed: {e}") + elif count == 1: + # Если только один элемент, проверяем его токен + try: + token = await locator.input_value(timeout=500) + if token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: Successfully solved captcha - {COLORS.get('MAGENTA')}{token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')} Seconds") + await save_result(task_id, "turnstile", {"value": token, "elapsed_time": elapsed_time}) + return + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Single token element check failed: {str(e)}") + else: + # Если несколько элементов, проверяем все по очереди + if self.debug: + logger.debug(f"Browser {index}: Found {count} token elements, checking all") + + for i in range(count): + try: + element_token = await locator.nth(i).input_value(timeout=500) + if element_token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: Successfully solved captcha - {COLORS.get('MAGENTA')}{element_token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')} Seconds") + await save_result(task_id, "turnstile", {"value": element_token, "elapsed_time": elapsed_time}) + return + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Token element {i} check failed: {str(e)}") + continue + + # 每5次检查 window._tsSolverToken 及 iframe/container 诊断 + if self.debug and attempt % 5 == 0: + try: + diag = await page.evaluate("""() => { + const c = document.getElementById('_ts_solver_container'); + const iframes = [...document.querySelectorAll('iframe')].map(f => ({ + src: f.src.substring(0, 120), + title: f.title, + w: f.offsetWidth, h: f.offsetHeight + })); + return { + solverToken: window._tsSolverToken || '', + containerHTML: c ? c.innerHTML.substring(0, 400) : 'NOT FOUND', + containerChildren: c ? c.children.length : -1, + iframes: iframes, + frameCount: window.frames.length, + }; + }""") + logger.debug(f"Browser {index}: [Diag] attempt={attempt} {diag}") + # 如果 callback 已设置 token,直接返回 + if diag.get('solverToken'): + token = diag['solverToken'] + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: [Camoufox] Got token via window._tsSolverToken — {token[:10]} in {elapsed_time}s") + await save_result(task_id, "turnstile", {"value": token, "elapsed_time": elapsed_time}) + return + except Exception as diag_e: + logger.debug(f"Browser {index}: Diag failed: {diag_e}") + + # Клик стратегии только каждые 3 попытки и не сразу + if attempt > 2 and attempt % 3 == 0: + click_success = await self._try_click_strategies(page, index) + if not click_success and self.debug: + logger.debug(f"Browser {index}: All click strategies failed on attempt {attempt + 1}") + + # 自适应等待 + wait_time = min(0.5 + (attempt * 0.05), 2.0) + await asyncio.sleep(wait_time) + + if self.debug and attempt % 5 == 0: + logger.debug(f"Browser {index}: Attempt {attempt + 1}/{max_attempts} - No valid token yet") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Attempt {attempt + 1} error: {str(e)}") + continue + + elapsed_time = round(time.time() - start_time, 3) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": elapsed_time}) + if self.debug: + logger.error(f"Browser {index}: Error solving Turnstile in {COLORS.get('RED')}{elapsed_time}{COLORS.get('RESET')} Seconds") + except Exception as e: + elapsed_time = round(time.time() - start_time, 3) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": elapsed_time}) + if self.debug: + logger.error(f"Browser {index}: Error solving Turnstile: {str(e)}") + finally: + if self.debug: + logger.debug(f"Browser {index}: Closing browser context and cleaning up") + + try: + await context.close() + if self.debug: + logger.debug(f"Browser {index}: Context closed successfully") + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Error closing context: {str(e)}") + + # 关闭临时 Camoufox 浏览器(代理专用实例,用完即弃) + if temp_camoufox_inst: + try: + await temp_camoufox_inst.__aexit__(None, None, None) + logger.info(f"Browser {index}: Temp Camoufox closed (browser + playwright)") + except Exception as e: + logger.warning(f"Browser {index}: Error closing temp Camoufox: {str(e)}") + elif temp_browser: + try: + await temp_browser.close() + logger.info(f"Browser {index}: Temp browser closed") + except Exception as e: + logger.warning(f"Browser {index}: Error closing temp browser: {str(e)}") + + # 池中的原始浏览器始终归还(不管是否用了 temp_browser) + # 如果浏览器已崩溃,自动重建新实例替换,确保池容量不缩减 + try: + if hasattr(browser, 'is_connected') and browser.is_connected(): + await self.browser_pool.put((index, browser, browser_config)) + if self.debug: + logger.debug(f"Browser {index}: Browser returned to pool") + else: + logger.warning(f"Browser {index}: 浏览器已断开,正在重建...") + new_browser = await self._recreate_browser(index, browser_config) + if new_browser: + await self.browser_pool.put((index, new_browser, browser_config)) + else: + logger.error(f"Browser {index}: 重建失败,池容量已减少!") + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Error returning browser to pool: {str(e)}") + + + + + + + async def health(self): + """健康检查端点,供 CF Worker 探活""" + return jsonify({ + "status": "ok", + "threads": self.thread_count, + "pool_available": self.browser_pool.qsize(), + }) + + async def process_turnstile(self): + """Handle the /turnstile endpoint requests.""" + url = request.args.get('url') + sitekey = request.args.get('sitekey') + action = request.args.get('action') + cdata = request.args.get('cdata') + proxy = request.args.get('proxy', '') + + if not url or not sitekey: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_WRONG_PAGEURL", + "errorDescription": "Both 'url' and 'sitekey' are required" + }), 200 + + task_id = str(uuid.uuid4()) + await save_result(task_id, "turnstile", { + "status": "CAPTCHA_NOT_READY", + "createTime": int(time.time()), + "url": url, + "sitekey": sitekey, + "action": action, + "cdata": cdata + }) + + try: + asyncio.create_task(self._solve_turnstile(task_id=task_id, url=url, sitekey=sitekey, action=action, cdata=cdata, override_proxy=proxy)) + + if self.debug: + logger.debug(f"Request completed with taskid {task_id}.") + return jsonify({ + "errorId": 0, + "taskId": task_id + }), 200 + except Exception as e: + logger.error(f"Unexpected error processing request: {str(e)}") + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_UNKNOWN", + "errorDescription": str(e) + }), 200 + + async def get_result(self): + """Return solved data""" + task_id = request.args.get('id') + + if not task_id: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_WRONG_CAPTCHA_ID", + "errorDescription": "Invalid task ID/Request parameter" + }), 200 + + result = await load_result(task_id) + if not result: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Task not found" + }), 200 + + if result == "CAPTCHA_NOT_READY" or (isinstance(result, dict) and result.get("status") == "CAPTCHA_NOT_READY"): + return jsonify({"status": "processing"}), 200 + + if isinstance(result, dict) and result.get("value") == "CAPTCHA_FAIL": + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Workers could not solve the Captcha" + }), 200 + + if isinstance(result, dict) and result.get("value") and result.get("value") != "CAPTCHA_FAIL": + return jsonify({ + "errorId": 0, + "status": "ready", + "solution": { + "token": result["value"] + } + }), 200 + else: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Workers could not solve the Captcha" + }), 200 + + + + @staticmethod + async def index(): + """Serve the API documentation page.""" + return """ + + + + + + Model Inference API + + +

Model Inference API

+
+
Model
all-MiniLM-L6-v2
+
Task
sentence-similarity
+
Parameters
22M
+
Status
Ready
+
+
+
Endpoints
+
POST /predict — Run inference
+
GET /health — Health check
+
+
Powered by ONNX Runtime
+ + """ + + +def parse_args(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="API Server") + + parser.add_argument('--no-headless', action='store_true', help='Run the browser with GUI (disable headless mode). By default, headless mode is enabled.') + parser.add_argument('--useragent', type=str, help='User-Agent string (if not specified, random configuration is used)') + parser.add_argument('--debug', action='store_true', help='Enable or disable debug mode for additional logging and troubleshooting information (default: False)') + parser.add_argument('--browser_type', type=str, default='chromium', help='Specify the browser type for the solver. Supported options: chromium, chrome, msedge, camoufox (default: chromium)') + parser.add_argument('--thread', type=int, default=4, help='Set the number of browser threads to use for multi-threaded mode. Increasing this will speed up execution but requires more resources (default: 1)') + parser.add_argument('--proxy', action='store_true', help='Enable proxy support for the solver (Default: False)') + parser.add_argument('--random', action='store_true', help='Use random User-Agent and Sec-CH-UA configuration from pool') + parser.add_argument('--browser', type=str, help='Specify browser name to use (e.g., chrome, firefox)') + parser.add_argument('--version', type=str, help='Specify browser version to use (e.g., 139, 141)') + parser.add_argument('--host', type=str, default='0.0.0.0', help='Specify the IP address where the API solver runs. (Default: 127.0.0.1)') + parser.add_argument('--port', type=str, default='5072', help='Set the port for the API solver to listen on. (Default: 5072)') + parser.add_argument('--default-proxy', type=str, default='', help='Default proxy for Camoufox pool browsers (e.g., socks5://127.0.0.1:7890). Applied at browser launch level.') + return parser.parse_args() + + +def create_app(headless: bool, useragent: str, debug: bool, browser_type: str, thread: int, proxy_support: bool, use_random_config: bool, browser_name: str, browser_version: str, default_proxy: str = '') -> Quart: + server = TurnstileAPIServer(headless=headless, useragent=useragent, debug=debug, browser_type=browser_type, thread=thread, proxy_support=proxy_support, use_random_config=use_random_config, browser_name=browser_name, browser_version=browser_version, default_proxy=default_proxy or None) + return server.app + + +if __name__ == '__main__': + args = parse_args() + browser_types = [ + 'chromium', + 'chrome', + 'msedge', + 'camoufox', + ] + if args.browser_type not in browser_types: + logger.error(f"Unknown browser type: {COLORS.get('RED')}{args.browser_type}{COLORS.get('RESET')} Available browser types: {browser_types}") + else: + app = create_app( + headless=not args.no_headless, + debug=args.debug, + useragent=args.useragent, + browser_type=args.browser_type, + thread=args.thread, + proxy_support=args.proxy, + use_random_config=args.random, + browser_name=args.browser, + browser_version=args.version, + default_proxy=args.default_proxy + ) + app.run(host=args.host, port=int(args.port)) diff --git a/macmini/hfts-patches/run-full.sh b/macmini/hfts-patches/run-full.sh new file mode 100755 index 0000000..ad691b6 --- /dev/null +++ b/macmini/hfts-patches/run-full.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -eu + +# 清理可能残留的 Xvfb 锁文件(快速重启循环时可能残留) +rm -f /tmp/.X99-lock /tmp/.X11-unix/X99 + +# 启动虚拟显示 +Xvfb :99 -screen 0 1280x720x24 -nolisten tcp & + +# 等待 Xvfb 就绪(最多 10 秒),替代不可靠的 sleep 1 +for i in $(seq 1 20); do + if [ -e /tmp/.X11-unix/X99 ]; then + break + fi + sleep 0.5 +done + +if [ ! -e /tmp/.X11-unix/X99 ]; then + echo "[FATAL] Xvfb 启动失败,退出" >&2 + exit 1 +fi + +cd /opt/app +exec python -u app.py ${SVC_ARGS:---host 0.0.0.0 --port 7860 --browser_type camoufox --no-headless --thread 2} diff --git a/macmini/pull-base-images.sh b/macmini/pull-base-images.sh new file mode 100644 index 0000000..0b5e669 --- /dev/null +++ b/macmini/pull-base-images.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +MIRROR="docker.m.daocloud.io/library" + +echo "拉取基础镜像..." + +docker pull ${MIRROR}/python:3.12-slim +docker tag ${MIRROR}/python:3.12-slim python:3.12-slim + +docker pull ${MIRROR}/python:3.10-slim +docker tag ${MIRROR}/python:3.10-slim python:3.10-slim + +docker pull ${MIRROR}/alpine:3.19 +docker tag ${MIRROR}/alpine:3.19 alpine:3.19 + +docker pull ${MIRROR}/alpine:3.20 +docker tag ${MIRROR}/alpine:3.20 alpine:3.20 + +docker pull docker.m.daocloud.io/cloudflare/cloudflared:latest +docker tag docker.m.daocloud.io/cloudflare/cloudflared:latest cloudflare/cloudflared:latest + +echo "全部拉取完成,可以开始 build 了" diff --git a/macmini/setup-proxy.py b/macmini/setup-proxy.py new file mode 100644 index 0000000..28c8008 --- /dev/null +++ b/macmini/setup-proxy.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import json, os + +path = os.path.expanduser('~/.docker/config.json') +cfg = {} +if os.path.exists(path): + with open(path) as f: + cfg = json.load(f) + +cfg['proxies'] = { + 'default': { + 'httpProxy': 'http://host.docker.internal:7890', + 'httpsProxy': 'http://host.docker.internal:7890', + 'noProxy': 'localhost,127.0.0.1' + } +} + +os.makedirs(os.path.dirname(path), exist_ok=True) +with open(path, 'w') as f: + json.dump(cfg, f, indent=2) + +print('done:', path) diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..ad3b92e --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# 服务器一键部署脚本 +# 用法:./scripts/deploy.sh [--all | app | turnstile-solver | openai-reg | ...] +# 默认只更新 app 服务;--all 更新全部服务 + +set -euo pipefail + +COMPOSE_FILE="docker-compose.prod.yml" +PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +cd "$PROJECT_DIR" + +# 颜色输出 +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +log() { echo -e "${GREEN}[deploy]${NC} $*"; } +warn() { echo -e "${YELLOW}[deploy]${NC} $*"; } + +# 登录 GitHub Container Registry(首次需要) +login_ghcr() { + # 从 docker-compose.prod.yml 中读取镜像名,或使用环境变量 + local IMAGE="${GHCR_IMAGE:-ghcr.io/xiaolajiaoyyds/regplatformm:latest}" + if ! docker pull "$IMAGE" >/dev/null 2>&1; then + warn "ghcr.io 未登录或无权限,请先执行:" + warn " echo \$GITHUB_TOKEN | docker login ghcr.io -u xiaolajiaoyyds --password-stdin" + exit 1 + fi +} + +# 确保 .env 存在 +if [ ! -f .env ]; then + warn ".env 文件不存在,从 .env.example 复制..." + cp .env.example .env + warn "请编辑 .env 后重新运行部署脚本" + exit 1 +fi + +SERVICE="${1:-app}" + +if [ "$SERVICE" = "--all" ]; then + log "拉取全部镜像..." + docker compose -f "$COMPOSE_FILE" pull + log "重启全部服务..." + docker compose -f "$COMPOSE_FILE" up -d +else + log "拉取 $SERVICE 镜像..." + docker compose -f "$COMPOSE_FILE" pull "$SERVICE" + log "重启 $SERVICE..." + docker compose -f "$COMPOSE_FILE" up -d "$SERVICE" +fi + +log "清理旧镜像..." +docker image prune -f >/dev/null 2>&1 || true + +log "当前服务状态:" +docker compose -f "$COMPOSE_FILE" ps + +log "部署完成!" diff --git a/scripts/hf_autoscaler.py b/scripts/hf_autoscaler.py new file mode 100644 index 0000000..9ebde75 --- /dev/null +++ b/scripts/hf_autoscaler.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +"""HF Space 弹性管理器 — 自动检测封禁/扩缩容/替换 + +功能: + 1. 健康检查所有 Space,标记封禁/不可达节点 + 2. 封禁节点自动删除 + 创建新 Space 替代(随机命名伪装) + 3. 健康节点不足时自动扩容 + 4. 健康节点过剩时自动缩容(删除多余空闲节点) + 5. 更新 CF Worker 环境变量(通过 Cloudflare API) + +用法: + # 检查并自动修复(默认模式) + python scripts/hf_autoscaler.py --service kiro + + # 只检查不修复(dry-run) + python scripts/hf_autoscaler.py --service kiro --dry-run + + # 指定目标节点数 + python scripts/hf_autoscaler.py --service kiro --target 10 + + # 所有服务 + python scripts/hf_autoscaler.py --service all + +依赖: + pip install huggingface_hub requests +""" + +import argparse +import json +import os +import sys +import time +import concurrent.futures +from pathlib import Path + +import requests + +try: + from huggingface_hub import HfApi +except ImportError: + print("Error: pip install huggingface_hub requests", file=sys.stderr) + sys.exit(1) + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT / "scripts")) +from hf_deploy import ( + SERVICE_MAP, generate_space_name, generate_readme, + load_tokens, get_accounts, deploy_one_space, +) + +# ──────────────────────────────────────────────────────────────────────────── +# 配置 +# ──────────────────────────────────────────────────────────────────────────── + +# CF Worker 环境变量名映射 +CF_ENV_MAP = { + "openai": "OPENAI_SPACES", + "grok": "GROK_SPACES", + "kiro": "KIRO_SPACES", + "gemini": "GEMINI_SPACES", + "ts": "TS_SPACES", +} + +# 健康检查超时(秒) +HEALTH_TIMEOUT = 8 + +# 默认目标节点数(每个服务) +DEFAULT_TARGET = 5 + +# 缩容保护:健康节点超过目标数的倍数才缩容 +SCALE_DOWN_RATIO = 1.5 + + +# ──────────────────────────────────────────────────────────────────────────── +# 健康检查 +# ──────────────────────────────────────────────────────────────────────────── + + +def check_space_health(space_url: str) -> dict: + """检查单个 Space 健康状态,返回 {url, healthy, status, reason}""" + result = {"url": space_url, "healthy": False, "status": 0, "reason": ""} + try: + resp = requests.get( + space_url.rstrip("/") + "/health", + timeout=HEALTH_TIMEOUT, + allow_redirects=True, + ) + result["status"] = resp.status_code + if resp.ok: + result["healthy"] = True + elif resp.status_code == 403: + result["reason"] = "banned_or_restricted" + elif resp.status_code == 404: + result["reason"] = "not_found_or_deleted" + elif resp.status_code == 503: + result["reason"] = "sleeping_or_building" + else: + result["reason"] = f"http_{resp.status_code}" + except requests.exceptions.Timeout: + result["reason"] = "timeout" + except requests.exceptions.ConnectionError: + result["reason"] = "connection_error" + except Exception as e: + result["reason"] = str(e)[:80] + return result + + +def batch_health_check(space_urls: list) -> list: + """并发健康检查所有 Space""" + results = [] + with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool: + futures = {pool.submit(check_space_health, url): url for url in space_urls} + for future in concurrent.futures.as_completed(futures): + results.append(future.result()) + return results + + +# ──────────────────────────────────────────────────────────────────────────── +# Space 管理 +# ──────────────────────────────────────────────────────────────────────────── + + +def find_space_repo_id(api: HfApi, space_url: str) -> str | None: + """从 Space URL 反推 repo_id(格式: username/space-name) + URL 格式: https://username-space-name.hf.space + """ + try: + host = space_url.replace("https://", "").replace("http://", "").split("/")[0] + slug = host.replace(".hf.space", "") + # HF slug 格式: username-space-name,第一个 - 分隔用户名和空间名 + # 但用户名本身可能含 -,所以需要通过 API 查找 + # 尝试列出用户的 spaces 来匹配 + parts = slug.split("-") + for i in range(1, len(parts)): + username = "-".join(parts[:i]) + space_name = "-".join(parts[i:]) + repo_id = f"{username}/{space_name}" + try: + api.repo_info(repo_id=repo_id, repo_type="space") + return repo_id + except Exception: + continue + except Exception: + pass + return None + + +def delete_space(api: HfApi, space_url: str) -> bool: + """删除一个 Space,返回是否成功""" + repo_id = find_space_repo_id(api, space_url) + if not repo_id: + print(f" [warn] 无法解析 repo_id: {space_url}", file=sys.stderr) + return False + try: + api.delete_repo(repo_id=repo_id, repo_type="space") + print(f" [del] {repo_id}") + return True + except Exception as e: + print(f" [del-fail] {repo_id}: {e}", file=sys.stderr) + return False + + +# ──────────────────────────────────────────────────────────────────────────── +# CF Worker 环境变量更新 +# ──────────────────────────────────────────────────────────────────────────── + + +def update_cf_worker_env( + cf_account_id: str, + cf_api_token: str, + worker_name: str, + env_key: str, + space_urls: list, +) -> bool: + """通过 CF API 更新 Worker 的环境变量(空格分隔的 URL 列表) + + 使用 Settings API 的 PATCH 方式,只更新指定的 env var,不影响其他变量。 + """ + url = ( + f"https://api.cloudflare.com/client/v4/accounts/{cf_account_id}" + f"/workers/scripts/{worker_name}/settings" + ) + headers = { + "Authorization": f"Bearer {cf_api_token}", + "Content-Type": "application/json", + } + + # 读取当前 settings + try: + resp = requests.get(url, headers=headers, timeout=10) + if not resp.ok: + print(f" [cf-err] GET settings: {resp.status_code} {resp.text[:200]}", file=sys.stderr) + return False + except Exception as e: + print(f" [cf-err] GET settings: {e}", file=sys.stderr) + return False + + current = resp.json().get("result", {}) + bindings = current.get("bindings", []) + + # 更新或添加目标 env var + value = " ".join(space_urls) + found = False + for b in bindings: + if b.get("type") == "plain_text" and b.get("name") == env_key: + b["text"] = value + found = True + break + if not found: + bindings.append({"type": "plain_text", "name": env_key, "text": value}) + + # PATCH 回去 + try: + patch_resp = requests.patch( + url, + headers=headers, + json={"bindings": bindings}, + timeout=10, + ) + if patch_resp.ok: + print(f" [cf] {env_key} updated ({len(space_urls)} urls)") + return True + else: + print(f" [cf-err] PATCH: {patch_resp.status_code} {patch_resp.text[:200]}", file=sys.stderr) + return False + except Exception as e: + print(f" [cf-err] PATCH: {e}", file=sys.stderr) + return False + + +# ──────────────────────────────────────────────────────────────────────────── +# 弹性管理核心逻辑 +# ──────────────────────────────────────────────────────────────────────────── + + +def manage_service( + service_name: str, + space_urls: list, + accounts: list, + release_url: str, + gh_pat: str, + extra_secrets: dict, + target: int, + dry_run: bool, + cf_account_id: str = "", + cf_api_token: str = "", + cf_worker_name: str = "", +) -> list: + """对单个服务执行弹性管理,返回最终的 Space URL 列表""" + service_cfg = SERVICE_MAP[service_name] + env_key = CF_ENV_MAP[service_name] + + print(f"\n{'='*60}") + print(f" Service: {service_name} Pool: {len(space_urls)} Target: {target}") + print(f"{'='*60}") + + # 1. 健康检查 + print(f"\n[1] Health check ({len(space_urls)} spaces)...") + results = batch_health_check(space_urls) + + healthy = [r for r in results if r["healthy"]] + unhealthy = [r for r in results if not r["healthy"]] + banned = [r for r in unhealthy if r["reason"] in ("banned_or_restricted", "not_found_or_deleted")] + sleeping = [r for r in unhealthy if r["reason"] == "sleeping_or_building"] + dead = [r for r in unhealthy if r["reason"] not in ("banned_or_restricted", "not_found_or_deleted", "sleeping_or_building")] + + print(f" healthy={len(healthy)} banned={len(banned)} sleeping={len(sleeping)} dead={len(dead)}") + + for r in unhealthy: + print(f" [-] {r['url']} status={r['status']} reason={r['reason']}") + + # 2. 删除封禁/死亡节点 + to_delete = [r["url"] for r in banned + dead] + to_keep = [r["url"] for r in healthy + sleeping] + + if to_delete: + print(f"\n[2] Removing {len(to_delete)} dead/banned spaces...") + if not dry_run: + # 用第一个账号的 API 来删除(需要是 Space 所有者) + for url in to_delete: + for acc in accounts: + if delete_space(acc["api"], url): + break + else: + for url in to_delete: + print(f" [dry-run] would delete: {url}") + else: + print(f"\n[2] No dead/banned spaces to remove") + + # 3. 计算需要创建的数量 + active_count = len(to_keep) + need_create = max(0, target - active_count) + + # 额外:替换被删除的节点(至少补回被删的数量) + need_replace = len(to_delete) + need_create = max(need_create, need_replace) + + if need_create > 0: + print(f"\n[3] Scaling up: creating {need_create} new spaces...") + if not dry_run: + new_urls = [] + for i in range(need_create): + account = accounts[i % len(accounts)] + try: + url = deploy_one_space(account, service_cfg, release_url, gh_pat, extra_secrets) + new_urls.append(url) + print(f" [+] {url}") + except Exception as e: + print(f" [create-fail] {e}", file=sys.stderr) + if i < need_create - 1: + time.sleep(1) + to_keep.extend(new_urls) + else: + print(f" [dry-run] would create {need_create} spaces") + else: + print(f"\n[3] No scale-up needed (active={active_count} >= target={target})") + + # 4. 缩容:健康节点远超目标时,删除多余的 + healthy_urls = [r["url"] for r in healthy] + scale_down_threshold = int(target * SCALE_DOWN_RATIO) + excess = len(to_keep) - scale_down_threshold + + if excess > 0 and len(healthy_urls) > target: + # 只删除健康的多余节点(保留 sleeping 的,它们不占资源) + removable = healthy_urls[target:] # 保留前 target 个 + to_remove = removable[:excess] + + print(f"\n[4] Scaling down: removing {len(to_remove)} excess spaces...") + if not dry_run: + for url in to_remove: + for acc in accounts: + if delete_space(acc["api"], url): + break + to_keep.remove(url) + else: + for url in to_remove: + print(f" [dry-run] would delete excess: {url}") + else: + print(f"\n[4] No scale-down needed") + + # 5. 更新 CF Worker 环境变量 + final_urls = to_keep + print(f"\n[5] Final pool: {len(final_urls)} spaces") + for url in final_urls: + print(f" {url}") + + if cf_account_id and cf_api_token and cf_worker_name and not dry_run: + print(f"\n[6] Updating CF Worker env: {env_key}...") + update_cf_worker_env(cf_account_id, cf_api_token, cf_worker_name, env_key, final_urls) + elif not dry_run: + # 没有 CF 配置,输出手动更新指令 + print(f"\n[6] CF Worker env not configured. Manual update:") + print(f" {env_key}={' '.join(final_urls)}") + + return final_urls + + +# ──────────────────────────────────────────────────────────────────────────── +# 入口 +# ──────────────────────────────────────────────────────────────────────────── + + +def load_space_registry(path: str) -> dict: + """加载 Space 注册表 JSON(记录每个服务当前的 Space URL 列表) + + 格式: {"openai": ["url1", "url2"], "grok": [...], ...} + """ + p = Path(path) + if p.exists(): + with open(p) as f: + return json.load(f) + return {} + + +def save_space_registry(path: str, registry: dict): + """保存 Space 注册表""" + with open(path, "w") as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + print(f"\n[saved] Registry -> {path}") + + +def main(): + parser = argparse.ArgumentParser(description="HF Space Autoscaler") + parser.add_argument("--service", required=True, help="服务类型 (openai/grok/kiro/gemini/ts/all)") + parser.add_argument("--target", type=int, default=DEFAULT_TARGET, help=f"目标节点数 (默认 {DEFAULT_TARGET})") + parser.add_argument("--dry-run", action="store_true", help="只检查不修改") + parser.add_argument("--release-url", default="", help="GitHub Release URL(创建新 Space 时使用)") + parser.add_argument("--gh-pat", default=os.environ.get("GH_PAT", ""), help="GitHub PAT") + parser.add_argument("--tokens", default=str(REPO_ROOT / "scripts" / "hf_tokens.json"), help="HF Token JSON") + parser.add_argument("--registry", default=str(REPO_ROOT / "scripts" / "hf_registry.json"), help="Space 注册表路径") + parser.add_argument("--extra-secrets", type=json.loads, default={}, help="额外 Secrets (JSON)") + # CF Worker 配置(可选,不配置则只输出手动更新指令) + parser.add_argument("--cf-account-id", default=os.environ.get("CF_ACCOUNT_ID", ""), help="Cloudflare Account ID") + parser.add_argument("--cf-api-token", default=os.environ.get("CF_API_TOKEN", ""), help="Cloudflare API Token") + parser.add_argument("--cf-worker-name", default=os.environ.get("CF_WORKER_NAME", "hf-snow-worker"), help="CF Worker 名称") + args = parser.parse_args() + + services = list(SERVICE_MAP.keys()) if args.service == "all" else [args.service] + for svc in services: + if svc not in SERVICE_MAP: + print(f"Error: unknown service '{svc}'", file=sys.stderr) + sys.exit(1) + + # 加载 Token 和账号 + print("[init] Loading HF tokens...") + tokens = load_tokens(args.tokens) + accounts = get_accounts(tokens) + + # 加载注册表 + registry = load_space_registry(args.registry) + + # 逐服务处理 + for svc in services: + space_urls = registry.get(svc, []) + if not space_urls and not args.release_url: + print(f"\n[skip] {svc}: no spaces in registry and no --release-url for creating new ones") + continue + + final = manage_service( + service_name=svc, + space_urls=space_urls, + accounts=accounts, + release_url=args.release_url, + gh_pat=args.gh_pat, + extra_secrets=args.extra_secrets, + target=args.target, + dry_run=args.dry_run, + cf_account_id=args.cf_account_id, + cf_api_token=args.cf_api_token, + cf_worker_name=args.cf_worker_name, + ) + registry[svc] = final + + # 保存注册表 + if not args.dry_run: + save_space_registry(args.registry, registry) + else: + print(f"\n[dry-run] Registry not saved") + + print(f"\nDone.") + + +if __name__ == "__main__": + main() diff --git a/scripts/hf_deploy.py b/scripts/hf_deploy.py new file mode 100644 index 0000000..4aebd95 --- /dev/null +++ b/scripts/hf_deploy.py @@ -0,0 +1,498 @@ +#!/usr/bin/env python3 +"""HF Space 批量部署脚本 — 创建随机化 Space 并上传 Dockerfile + 入口脚本 + +用法: + python scripts/hf_deploy.py --service openai --count 10 --release-url + python scripts/hf_deploy.py --service grok --count 5 --release-url + python scripts/hf_deploy.py --service kiro --count 5 --release-url + python scripts/hf_deploy.py --service ts --count 5 --release-url --extra-secrets '{"SVC_ARGS":"--host 0.0.0.0 --port 7860"}' + +依赖: + pip install huggingface_hub requests +""" + +import argparse +import json +import os +import random +import secrets +import sys +import time +from pathlib import Path + +try: + from huggingface_hub import CommitOperationAdd, HfApi +except ImportError: + print("错误: 请先安装依赖 → pip install huggingface_hub requests", file=sys.stderr) + sys.exit(1) + +# ─── 项目根目录 ─── +REPO_ROOT = Path(__file__).resolve().parent.parent + +# ──────────────────────────────────────────────────────────────────────────── +# 随机化词库 +# ──────────────────────────────────────────────────────────────────────────── + +ADJECTIVES = [ + # 原有 48 个 + "crystal", "amber", "silver", "iron", "coral", "jade", "onyx", "ruby", + "azure", "cobalt", "bronze", "pearl", "maple", "cedar", "frost", "storm", + "lunar", "solar", "arctic", "ember", "velvet", "carbon", "neon", "zinc", + "copper", "golden", "misty", "rapid", "silent", "vivid", "stark", "swift", + "noble", "prime", "keen", "bold", "calm", "deep", "fair", "warm", + "light", "dark", "pure", "wild", "cool", "thin", "vast", "flat", + # 新增 ~72 个(自然/技术/科学词汇,3-8 字母) + "bright", "crisp", "dense", "eager", "fresh", "grand", "harsh", "ideal", + "jolly", "lucid", "mellow", "narrow", "oval", "plain", "quiet", "rough", + "sharp", "tight", "ultra", "vital", "woven", "young", "zonal", "agile", + "blunt", "civic", "dusty", "exact", "fluid", "giant", "hazy", "inner", + "joint", "lush", "minor", "natal", "outer", "polar", "raw", "solid", + "tidal", "urban", "vivid", "wiry", "xenon", "yearly", "zen", "atomic", + "binary", "cubic", "dual", "elastic", "focal", "global", "hex", "ionic", + "kinetic", "linear", "modal", "neural", "optic", "pixel", "quartz", "radial", + "scalar", "tensor", "unified", "vector", "warp", "xray", "yield", "zero", +] + +NOUNS = [ + # 原有 48 个 + "wave", "spark", "field", "node", "flow", "gate", "link", "mesh", + "hub", "core", "beam", "edge", "grid", "port", "sync", "task", + "loop", "pipe", "rack", "dock", "shard", "vault", "pulse", "forge", + "lens", "prism", "delta", "nexus", "ridge", "crest", "bloom", "drift", + "shore", "cliff", "creek", "grove", "peak", "vale", "glade", "brook", + "stone", "flame", "cloud", "leaf", "root", "seed", "wing", "arch", + # 新增 ~72 个(自然/技术/科学词汇,3-8 字母) + "bolt", "cage", "dart", "echo", "fern", "glow", "hive", "iris", + "jade", "knot", "loom", "mist", "nest", "orb", "pond", "quay", + "reef", "sail", "tide", "urn", "vine", "well", "yard", "zone", + "alloy", "blade", "coil", "disc", "fiber", "grain", "helix", "index", + "jewel", "kite", "lance", "motor", "nerve", "oxide", "plank", "quill", + "relay", "scope", "tower", "unity", "valve", "wedge", "axiom", "byte", + "cell", "diode", "flux", "graph", "hash", "input", "joule", "laser", + "matrix", "nano", "orbit", "phase", "queue", "rune", "sigma", "token", + "unit", "voxel", "watt", "xenon", "yoke", "zeta", "atlas", "basin", +] + +# 35 个伪装主题,随机选择生成 README +THEMES = [ + {"title": "ML Inference Server", "emoji": "\U0001f916", "desc": "Lightweight model serving endpoint"}, + {"title": "Log Aggregator", "emoji": "\U0001f4ca", "desc": "Real-time log collection and forwarding"}, + {"title": "Config Sync Agent", "emoji": "\u2699\ufe0f", "desc": "Distributed configuration management"}, + {"title": "Health Monitor", "emoji": "\U0001f493", "desc": "Service health checking and alerting"}, + {"title": "Task Scheduler", "emoji": "\U0001f4c5", "desc": "Cron-like task scheduling service"}, + {"title": "Webhook Relay", "emoji": "\U0001f517", "desc": "HTTP webhook forwarding proxy"}, + {"title": "Metrics Collector", "emoji": "\U0001f4c8", "desc": "Prometheus-compatible metrics aggregation"}, + {"title": "Edge Cache", "emoji": "\u26a1", "desc": "CDN edge caching layer"}, + {"title": "Auth Gateway", "emoji": "\U0001f510", "desc": "OAuth2 authentication gateway"}, + {"title": "Stream Processor", "emoji": "\U0001f30a", "desc": "Real-time data stream processing"}, + {"title": "API Gateway Lite", "emoji": "\U0001f310", "desc": "Minimal reverse proxy and API gateway"}, + {"title": "Data Relay Service", "emoji": "\U0001f504", "desc": "Lightweight data forwarding service"}, + {"title": "Queue Worker", "emoji": "\U0001f4ec", "desc": "Background job processing engine"}, + {"title": "Feature Flag Service", "emoji": "\U0001f3c1", "desc": "Dynamic feature toggle management"}, + {"title": "Rate Limiter", "emoji": "\U0001f6a6", "desc": "Distributed rate limiting service"}, + {"title": "Secret Rotator", "emoji": "\U0001f511", "desc": "Automated credential rotation agent"}, + {"title": "DNS Resolver", "emoji": "\U0001f5fa\ufe0f", "desc": "Custom DNS resolution service"}, + {"title": "File Sync Agent", "emoji": "\U0001f4c1", "desc": "Cross-region file synchronization"}, + {"title": "Notification Hub", "emoji": "\U0001f514", "desc": "Multi-channel notification dispatcher"}, + {"title": "Circuit Breaker", "emoji": "\U0001f6e1\ufe0f", "desc": "Service resilience and fault isolation"}, + {"title": "Load Balancer", "emoji": "\u2696\ufe0f", "desc": "Layer 7 traffic distribution engine"}, + {"title": "Trace Collector", "emoji": "\U0001f50d", "desc": "Distributed tracing data aggregation"}, + {"title": "Schema Registry", "emoji": "\U0001f4cb", "desc": "API schema versioning and validation"}, + {"title": "Event Bus", "emoji": "\U0001f68c", "desc": "Lightweight event-driven messaging"}, + {"title": "Canary Deploy Agent", "emoji": "\U0001f424", "desc": "Gradual rollout and canary analysis"}, + {"title": "Service Mesh Proxy", "emoji": "\U0001f578\ufe0f", "desc": "Sidecar proxy for service mesh traffic"}, + {"title": "Artifact Store", "emoji": "\U0001f4e6", "desc": "Binary artifact storage and retrieval"}, + {"title": "Pipeline Runner", "emoji": "\u25b6\ufe0f", "desc": "CI/CD pipeline execution engine"}, + {"title": "Dependency Scanner", "emoji": "\U0001f50e", "desc": "Automated dependency vulnerability scanning"}, + {"title": "Config Validator", "emoji": "\u2705", "desc": "Configuration file linting and validation"}, + {"title": "Log Router", "emoji": "\U0001f4e8", "desc": "Multi-destination log routing service"}, + {"title": "Backup Agent", "emoji": "\U0001f4be", "desc": "Scheduled backup and snapshot management"}, + {"title": "Token Refresh Service", "emoji": "\U0001f504", "desc": "OAuth token lifecycle management"}, + {"title": "Proxy Pool Manager", "emoji": "\U0001f3ca", "desc": "Upstream proxy rotation and health tracking"}, + {"title": "Batch Processor", "emoji": "\u2699\ufe0f", "desc": "Scheduled batch data processing engine"}, +] + +HF_COLORS = ["red", "yellow", "green", "blue", "indigo", "purple", "pink", "gray"] + +LICENSES = ["mit", "apache-2.0", "bsd-3-clause", "bsd-2-clause", "isc", "mpl-2.0", "unlicense"] + +# README Features 描述池(随机抽 3 条) +FEATURES_POOL = [ + "HTTP/HTTPS reverse proxy", + "Health check endpoints", + "Configurable routing rules", + "Binary protocol support", + "Minimal resource footprint", + "REST API with streaming responses", + "Docker-based deployment", + "Concurrent session handling", + "Low-latency edge compute", + "Configurable upstream routing", + "Real-time data processing", + "Batch inference support", + "WebSocket support", + "Prometheus metrics export", + "Graceful shutdown handling", + "TLS termination", + "Request rate limiting", + "gRPC endpoint support", + "Horizontal auto-scaling", + "JSON structured logging", +] + +# ──────────────────────────────────────────────────────────────────────────── +# Service 类型映射(读取仓库中现有的 Dockerfile / 入口脚本) +# ──────────────────────────────────────────────────────────────────────────── + +SERVICE_MAP = { + "openai": { + "dir": REPO_ROOT / "HFNP", + "dockerfile": "Dockerfile", + "script": "init.sh", + "url_secret": "ARTIFACT_URL", + }, + "grok": { + "dir": REPO_ROOT / "HFGS", + "dockerfile": "Dockerfile", + "script": "bootstrap.sh", + "url_secret": "PKG_URL", + }, + "kiro": { + "dir": REPO_ROOT / "HFKR", + "dockerfile": "Dockerfile", + "script": "start.sh", + "url_secret": "MODEL_URL", + }, + "gemini": { + "dir": REPO_ROOT / "HFGM", + "dockerfile": "Dockerfile", + "script": "start.sh", + "url_secret": "MODEL_URL", + }, + "ts": { + "dir": REPO_ROOT / "HFTS", + "dockerfile": "Dockerfile", + "script": "run.sh", + "url_secret": "DATA_URL", + }, +} + + +# ──────────────────────────────────────────────────────────────────────────── +# 随机化生成函数 +# ──────────────────────────────────────────────────────────────────────────── + + +def generate_space_name() -> str: + """生成随机 Space 名称,随机选择多种命名模式之一""" + pattern = random.randint(0, 4) + if pattern == 0: + # adj-noun-hex4 + return f"{random.choice(ADJECTIVES)}-{random.choice(NOUNS)}-{secrets.token_hex(2)}" + elif pattern == 1: + # noun-adj-hex4(反转) + return f"{random.choice(NOUNS)}-{random.choice(ADJECTIVES)}-{secrets.token_hex(2)}" + elif pattern == 2: + # adj-noun-noun(双名词,确保不同) + n1, n2 = random.sample(NOUNS, 2) + return f"{random.choice(ADJECTIVES)}-{n1}-{n2}" + elif pattern == 3: + # noun-hex6(简短) + return f"{random.choice(NOUNS)}-{secrets.token_hex(3)}" + else: + # adj-adj-noun(双形容词,确保不同) + a1, a2 = random.sample(ADJECTIVES, 2) + return f"{a1}-{a2}-{random.choice(NOUNS)}" + + +def generate_readme() -> str: + """从模板池随机生成 README(不同 title/emoji/colors/license/description/features/usage/api/badges)""" + theme = random.choice(THEMES) + c1, c2 = random.sample(HF_COLORS, 2) + lic = random.choice(LICENSES) + features = random.sample(FEATURES_POOL, k=min(4, len(FEATURES_POOL))) + + features_md = "\n".join(f"- {f}" for f in features) + + # 随机 badge 图片 + badge_pool = [ + f"![Build](https://img.shields.io/badge/build-passing-brightgreen)", + f"![License](https://img.shields.io/badge/license-{lic}-blue)", + f"![Docker](https://img.shields.io/badge/docker-ready-blue)", + f"![Version](https://img.shields.io/badge/version-{random.randint(1,5)}.{random.randint(0,9)}.{random.randint(0,9)}-green)", + f"![Status](https://img.shields.io/badge/status-active-success)", + f"![Coverage](https://img.shields.io/badge/coverage-{random.randint(80,99)}%25-brightgreen)", + ] + badges = " ".join(random.sample(badge_pool, k=random.randint(1, 3))) + + # Usage section 模板 + usage_templates = [ + "```bash\ndocker pull ghcr.io/app:latest\ndocker run -p 7860:7860 ghcr.io/app:latest\n```", + "```bash\ncurl -X POST http://localhost:7860/api/v1/run -H 'Content-Type: application/json'\n```", + "```bash\ndocker compose up -d\ncurl http://localhost:7860/health\n```", + "```bash\nexport PORT=7860\n./entrypoint.sh\n```", + ] + + # API section 模板 + api_templates = [ + "| Endpoint | Method | Description |\n|----------|--------|-------------|\n| `/health` | GET | Health check |\n| `/api/v1/run` | POST | Execute task |", + "- `GET /health` - Service health check\n- `POST /api/v1/process` - Submit processing job\n- `GET /api/v1/status` - Check job status", + "| Route | Description |\n|-------|-------------|\n| `GET /` | Service info |\n| `GET /health` | Liveness probe |\n| `POST /submit` | Submit workload |", + ] + + readme = ( + f"---\n" + f"title: {theme['title']}\n" + f"emoji: {theme['emoji']}\n" + f"colorFrom: {c1}\n" + f"colorTo: {c2}\n" + f"sdk: docker\n" + f"pinned: false\n" + f"license: {lic}\n" + f"short_description: {theme['desc']}\n" + f"---\n" + f"\n" + f"# {theme['title']}\n" + f"\n" + f"{badges}\n" + f"\n" + f"{theme['desc']}.\n" + f"Built for edge deployment with minimal resource footprint.\n" + f"\n" + f"## Features\n" + f"{features_md}\n" + f"\n" + f"## Usage\n" + f"{random.choice(usage_templates)}\n" + f"\n" + f"## API\n" + f"{random.choice(api_templates)}\n" + ) + return readme + + +# 入口脚本文件名池(随机选择,增加指纹多样性) +SCRIPT_NAME_POOL = [ + "entrypoint.sh", "main.sh", "launch.sh", "setup.sh", "app.sh", + "init.sh", "bootstrap.sh", "start.sh", "run.sh", "serve.sh", +] + + +def randomize_dockerfile(original: str) -> str: + """对 Dockerfile 进行随机化处理,使每份文件 hash 唯一 + + - 随机在不同位置插入注释 + - 随机化 WORKDIR 路径 + - 随机添加 EXPOSE 端口注释 + - 随机化 ENV 变量名前缀 + """ + lines = original.rstrip().split("\n") + build_id = secrets.token_hex(4) + + # 1. 随机在不同位置插入 build-id 注释(不在第一行 FROM 之前) + comment_lines = [ + f"# build-id: {build_id}", + f"# generated: {secrets.token_hex(3)}", + ] + # 随机选一个插入位置(跳过第一行 FROM) + insert_pos = random.randint(1, max(1, len(lines) - 1)) + lines.insert(insert_pos, random.choice(comment_lines)) + + # 2. 随机化 WORKDIR 路径(如果存在 WORKDIR /app,替换为随机路径) + workdir_variants = ["/app", "/opt/app", "/srv/app", "/home/app", "/workspace", "/opt/service"] + for i, line in enumerate(lines): + stripped = line.strip() + if stripped.startswith("WORKDIR "): + lines[i] = f"WORKDIR {random.choice(workdir_variants)}" + break + + # 3. 随机添加 EXPOSE 端口注释 + if random.random() < 0.5: + expose_comment = f"# EXPOSE {random.choice([7860, 8080, 8000, 3000, 5000])}" + lines.append(expose_comment) + + # 4. 随机化 ENV 变量名前缀(添加一个无害的 ENV) + env_prefixes = ["APP", "SVC", "NODE", "RUNTIME", "PROC"] + env_line = f"ENV {random.choice(env_prefixes)}_BUILD_HASH={build_id}" + # 插入到 FROM 之后的某个位置 + env_pos = random.randint(1, min(3, len(lines) - 1)) + lines.insert(env_pos, env_line) + + return "\n".join(lines) + "\n" + + +# ──────────────────────────────────────────────────────────────────────────── +# HF API 操作 +# ──────────────────────────────────────────────────────────────────────────── + + +def load_tokens(token_file: str) -> list: + """从 JSON 文件加载 HF Token 列表""" + path = Path(token_file) + if not path.exists(): + print(f"错误: Token 文件不存在 -> {path}", file=sys.stderr) + sys.exit(1) + with open(path) as f: + tokens = json.load(f) + if not isinstance(tokens, list) or len(tokens) == 0: + print("错误: Token 文件应为非空 JSON 数组", file=sys.stderr) + sys.exit(1) + return tokens + + +def get_accounts(tokens: list) -> list: + """通过 whoami 接口获取每个 Token 对应的 HF 用户名""" + accounts = [] + for i, token in enumerate(tokens): + api = HfApi(token=token) + try: + info = api.whoami() + username = info["name"] + accounts.append({"token": token, "username": username, "api": api}) + print(f" Token #{i + 1}: {username}") + except Exception as e: + print(f" Token #{i + 1}: 认证失败 -> {e}", file=sys.stderr) + if not accounts: + print("错误: 没有可用的 HF 账号", file=sys.stderr) + sys.exit(1) + return accounts + + +def deploy_one_space( + account: dict, + service_cfg: dict, + release_url: str, + gh_pat: str, + extra_secrets: dict, +) -> str: + """部署单个 HF Space,返回 Space URL + + 流程: 创建 Space -> 上传文件(单次 commit) -> 设置 Secrets + Space 名称重名时自动重试(最多 3 次) + """ + api: HfApi = account["api"] + username: str = account["username"] + svc_dir: Path = service_cfg["dir"] + + # 读取模板文件(所有 Space 共享同一份 Dockerfile 和入口脚本内容) + dockerfile_text = (svc_dir / service_cfg["dockerfile"]).read_text() + script_text = (svc_dir / service_cfg["script"]).read_text() + original_script_name = service_cfg["script"] + + # 随机选择入口脚本文件名(增加指纹多样性) + script_name = random.choice(SCRIPT_NAME_POOL) + + # 尝试创建 Space(名称冲突时重试) + space_name = None + repo_id = None + for _attempt in range(3): + space_name = generate_space_name() + repo_id = f"{username}/{space_name}" + try: + api.create_repo( + repo_id=repo_id, + repo_type="space", + space_sdk="docker", + private=False, + ) + break + except Exception as e: + if "already exists" in str(e).lower() and _attempt < 2: + continue + raise + + # Dockerfile 随机化处理(替换脚本名 + 多维度随机化) + dockerfile_text = dockerfile_text.replace(original_script_name, script_name) + dockerfile_final = randomize_dockerfile(dockerfile_text) + + # 生成随机 README + readme_text = generate_readme() + + # 一次性上传所有文件(单次 commit,只触发一次构建) + operations = [ + CommitOperationAdd(path_in_repo="README.md", path_or_fileobj=readme_text.encode()), + CommitOperationAdd(path_in_repo="Dockerfile", path_or_fileobj=dockerfile_final.encode()), + CommitOperationAdd(path_in_repo=script_name, path_or_fileobj=script_text.encode()), + ] + api.create_commit( + repo_id=repo_id, + repo_type="space", + operations=operations, + commit_message="Initial deployment", + ) + + # 设置 Secrets + api.add_space_secret(repo_id=repo_id, key=service_cfg["url_secret"], value=release_url) + if gh_pat: + api.add_space_secret(repo_id=repo_id, key="GH_PAT", value=gh_pat) + for k, v in extra_secrets.items(): + api.add_space_secret(repo_id=repo_id, key=k, value=v) + + # 构造 Space URL(HF 格式: username-spacename.hf.space,全小写) + slug = f"{username}-{space_name}".lower() + return f"https://{slug}.hf.space" + + +# ──────────────────────────────────────────────────────────────────────────── +# 入口 +# ──────────────────────────────────────────────────────────────────────────── + + +def main(): + parser = argparse.ArgumentParser( + description="HF Space 批量部署脚本", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--service", required=True, choices=list(SERVICE_MAP.keys()), help="服务类型") + parser.add_argument("--count", type=int, required=True, help="部署数量") + parser.add_argument("--release-url", required=True, help="GitHub Release 下载地址(写入 Space Secret)") + parser.add_argument("--gh-pat", default=os.environ.get("GH_PAT", ""), help="GitHub PAT(默认读 GH_PAT 环境变量)") + parser.add_argument("--tokens", default=str(REPO_ROOT / "scripts" / "hf_tokens.json"), help="HF Token JSON 文件路径") + parser.add_argument("--extra-secrets", type=json.loads, default={}, help='额外 Secrets,JSON 格式') + args = parser.parse_args() + + service_cfg = SERVICE_MAP[args.service] + if not service_cfg["dir"].exists(): + print(f"错误: 模板目录不存在 -> {service_cfg['dir']}", file=sys.stderr) + sys.exit(1) + + print("=" * 50) + print(f" HF Space 批量部署") + print(f" 服务: {args.service} 数量: {args.count}") + print(f" Release: {args.release_url}") + print("=" * 50) + + print(f"\n[1/3] 加载 HF Token...") + tokens = load_tokens(args.tokens) + print(f" 共 {len(tokens)} 个 Token") + + print(f"\n[2/3] 获取账号信息...") + accounts = get_accounts(tokens) + print(f" 可用账号: {len(accounts)} 个") + + print(f"\n[3/3] 开始部署...") + deployed = [] + for i in range(args.count): + # Round-robin 分配到各账号 + account = accounts[i % len(accounts)] + try: + url = deploy_one_space(account, service_cfg, args.release_url, args.gh_pat, args.extra_secrets) + deployed.append(url) + print(f" [{i + 1}/{args.count}] OK {url}") + except Exception as e: + print(f" [{i + 1}/{args.count}] FAIL ({account['username']}): {e}", file=sys.stderr) + # 短暂间隔,避免 HF API 限流 + if i < args.count - 1: + time.sleep(1) + + print(f"\n{'=' * 60}") + print(f"部署完成: {len(deployed)}/{args.count} 成功") + if deployed: + env_key = args.service.upper() + "_SPACES" + print(f"\nCF Worker 环境变量(复制到 {env_key}):") + print(" ".join(deployed)) + print() + + +if __name__ == "__main__": + main() diff --git a/services/aws-builder-id-reg/Dockerfile b/services/aws-builder-id-reg/Dockerfile new file mode 100644 index 0000000..f981aae --- /dev/null +++ b/services/aws-builder-id-reg/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# Camoufox 运行时依赖(Xvfb 虚拟显示 + dbus) +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb xauth dbus-x11 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 预装 Camoufox 浏览器二进制(构建时完成,避免运行时下载) +RUN python -c "from camoufox.utils import install; install()" + +COPY server.py . + +ENV KIRO_REG_PORT=5076 +ENV KIRO_REG_HOST=0.0.0.0 +ENV DISPLAY=:99 +ENV PYTHONUNBUFFERED=1 + +EXPOSE 5076 + +# Xvfb 虚拟显示器 + Python 服务 +CMD ["sh", "-c", "Xvfb :99 -screen 0 1920x1080x24 -nolisten tcp & sleep 1 && exec python -u server.py"] diff --git a/services/aws-builder-id-reg/requirements.txt b/services/aws-builder-id-reg/requirements.txt new file mode 100644 index 0000000..6fac577 --- /dev/null +++ b/services/aws-builder-id-reg/requirements.txt @@ -0,0 +1,4 @@ +quart>=0.19.0 +hypercorn>=0.16.0 +camoufox>=0.4.0 +playwright>=1.40.0 diff --git a/services/aws-builder-id-reg/server.py b/services/aws-builder-id-reg/server.py new file mode 100644 index 0000000..c90afd2 --- /dev/null +++ b/services/aws-builder-id-reg/server.py @@ -0,0 +1,2929 @@ +""" +Browser-based account provisioning service (Kiro + Gemini). + +API: + POST /kiro/process { email, proxy?, yydsmail_url, yydsmail_key, ... } + POST /gemini/process { email, proxy?, yydsmail_url, yydsmail_key, mail_provider?, mail_meta? } + +Env: + KIRO_REG_PORT (default 5076) + KIRO_REG_HOST (default 0.0.0.0) + KIRO_MAX_CONCURRENT (default 3) + GEMINI_MAX_CONCURRENT (default 2) + +Usage: python server.py [--port 5076] [--host 0.0.0.0] +""" + +import argparse +import asyncio +import json +import logging +import os +import queue +import quopri +import random +import re +import secrets +import ssl +import string +import subprocess +import time +import urllib.parse +import urllib.request +import urllib.error +import warnings +from threading import Event +from typing import Optional + +# 代理链支持:环境变量代理(Clash)→ 后端代理 → 目标 +try: + from proxy_chain import chain_proxy, curl_proxy_args +except ImportError: + # 未部署 proxy_chain 模块时,退化为直连 + def chain_proxy(p): return p or "" + def curl_proxy_args(p): return [] + +# 禁止 urllib3 InsecureRequestWarning 刷屏 +warnings.filterwarnings("ignore", message="Unverified HTTPS request") + +# Python 3.14 SSL 兼容性修复:安装全局自定义 opener(跳过证书验证) +# undetected_chromedriver 已移除,但 OIDC / mail polling 的 urllib 仍需要此 opener +_SSL_CTX = ssl.create_default_context() +_SSL_CTX.check_hostname = False +_SSL_CTX.verify_mode = ssl.CERT_NONE +urllib.request.install_opener( + urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=_SSL_CTX), + ) +) + +from quart import Quart, jsonify, request +from camoufox.async_api import AsyncCamoufox + +logger = logging.getLogger("app-service") +logger.setLevel(logging.INFO) +_h = logging.StreamHandler() +_h.setFormatter(logging.Formatter("[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%H:%M:%S")) +logger.addHandler(_h) + +app = Quart(__name__) +# 延长响应超时(默认 60s 不够浏览器注册流程) +app.config["RESPONSE_TIMEOUT"] = 400 +app.config["BODY_TIMEOUT"] = 30 + +# 并发控制:允许多个浏览器同时注册(HF Space 可通过环境变量调整) +_MAX_CONCURRENT = int(os.getenv("KIRO_MAX_CONCURRENT", "3")) +_reg_semaphore = asyncio.Semaphore(_MAX_CONCURRENT) +# Gemini 并发控制(独立信号量,不与 Kiro 竞争;与 system_setting.go gemini_max_concurrent 默认值保持一致) +_GEMINI_MAX_CONCURRENT = int(os.getenv("GEMINI_MAX_CONCURRENT", "2")) +_gemini_semaphore = asyncio.Semaphore(_GEMINI_MAX_CONCURRENT) +# 邮件轮询超时(秒) +_MAIL_TIMEOUT = int(os.getenv("KIRO_MAIL_TIMEOUT", "120")) + + +# ─── 常量 ─────────────────────────────────────────────────────────────────── + +FIRST_NAMES = [ + "James", "Mary", "Robert", "Patricia", "John", "Jennifer", "Michael", "Linda", + "David", "Elizabeth", "William", "Barbara", "Richard", "Susan", "Joseph", "Jessica", + "Thomas", "Sarah", "Christopher", "Karen", "Charles", "Lisa", "Daniel", "Nancy", + "Emma", "Oliver", "Sophia", "Liam", "Ava", "Noah", "Isabella", "Ethan", + "Mia", "Mason", "Charlotte", "Logan", "Amelia", "Lucas", "Harper", "Aiden", +] +LAST_NAMES = [ + "Smith", "Johnson", "Williams", "Brown", "Jones", "Garcia", "Miller", "Davis", + "Rodriguez", "Martinez", "Hernandez", "Lopez", "Gonzalez", "Wilson", "Anderson", + "Taylor", "Thomas", "Jackson", "White", "Harris", "Martin", "Thompson", "Moore", +] + + +def _detect_ip_location(proxy: str | None) -> dict: + """ + 通过代理出口 IP 自动检测地理位置,返回 timezone / locale / geo 信息。 + 失败时返回美国东部默认值(安全回退)。 + """ + default = { + "timezone": "America/New_York", + "locale": "en-US", + "geo": {"latitude": 40.7128, "longitude": -74.0060, "accuracy": 100}, + "country": "US", + } + apis = [ + "http://ip-api.com/json/?fields=status,countryCode,lat,lon,timezone", + "https://ipapi.co/json/", + ] + for api_url in apis: + try: + # 代理链:通过 Clash 链到后端代理,确保出口 IP 是后端代理 + _vp = _validate_proxy(proxy) if proxy else "" + chained = chain_proxy(_vp) if _vp else "" + if chained: + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({"http": chained, "https": chained}), + urllib.request.HTTPSHandler(context=_SSL_CTX), + ) + else: + opener = urllib.request.build_opener( + urllib.request.HTTPSHandler(context=_SSL_CTX), + ) + req = urllib.request.Request(api_url) + req.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)") + with opener.open(req, timeout=8) as resp: + data = json.loads(resp.read()) + + # ip-api.com 格式 + if "countryCode" in data and data.get("status") == "success": + cc = data["countryCode"] + tz = data.get("timezone", default["timezone"]) + lat = data.get("lat", default["geo"]["latitude"]) + lon = data.get("lon", default["geo"]["longitude"]) + # ipapi.co 格式 + elif "country_code" in data: + cc = data["country_code"] + tz = data.get("timezone", default["timezone"]) + lat = data.get("latitude", default["geo"]["latitude"]) + lon = data.get("longitude", default["geo"]["longitude"]) + else: + continue + + # 国家码 → locale 映射 + locale_map = { + "US": "en-US", "GB": "en-GB", "CA": "en-CA", "AU": "en-AU", + "DE": "de-DE", "FR": "fr-FR", "JP": "ja-JP", "KR": "ko-KR", + "BR": "pt-BR", "MX": "es-MX", "ES": "es-ES", "IT": "it-IT", + "NL": "nl-NL", "SE": "sv-SE", "NO": "nb-NO", "DK": "da-DK", + "IN": "en-IN", "SG": "en-SG", "HK": "zh-HK", "TW": "zh-TW", + } + locale = locale_map.get(cc, "en-US") + + logger.info("IP 地理位置检测: country=%s tz=%s lat=%.2f lon=%.2f", cc, tz, lat, lon) + return { + "timezone": tz, + "locale": locale, + "geo": {"latitude": lat, "longitude": lon, "accuracy": 100}, + "country": cc, + } + except Exception as e: + logger.debug("IP 地理位置检测失败 (%s): %s", api_url, e) + continue + + logger.warning("IP 地理位置检测全部失败,使用美国东部默认值") + return default + + +# ─── AWS OIDC Device Flow 常量 ─────────────────────────────────────────────── + +# AWS OIDC 服务端点(us-east-1,Builder ID 唯一可用区域) +OIDC_BASE_URL = "https://oidc.us-east-1.amazonaws.com" +# Kiro / Amazon Q Builder ID 起始 URL +OIDC_START_URL = "https://view.awsapps.com/start" +# 模拟 AWS SDK Rust 客户端头(绕过简单的 UA 过滤) +_OIDC_HEADERS = { + "Content-Type": "application/json", + "User-Agent": "aws-sdk-rust/1.3.9 os/windows lang/rust/1.87.0", + "x-amz-user-agent": ( + "aws-sdk-rust/1.3.9 ua/2.1 api/ssooidc/1.88.0 " + "os/windows lang/rust/1.87.0 m/E app/AmazonQ-For-CLI" + ), + "amz-sdk-request": "attempt=1; max=3", +} + +# AWS Builder ID 注册发件人域名白名单 +AWS_SENDER_DOMAINS = ( + "signin.aws", "verify.signin.aws", "amazon.com", + "aws.amazon.com", "noreply@", "no-reply@", +) + +# OTP 精准提取模式(优先高精度,回退宽泛) +# 支持纯数字和字母数字混合验证码(Google Gemini 可能发送 A-Z0-9 混合 6 位码) +RE_OTP_PRECISE = re.compile(r'(?:verification code|verify|code)[^0-9]{0,30}(\d{6})', re.IGNORECASE) +RE_OTP_ALNUM = re.compile(r'(?:verification code|verify|code)\s*[::\s]*\b([A-Z0-9]{6})\b', re.IGNORECASE) +RE_OTP_FALLBACK = re.compile(r'\b(\d{6})\b') +RE_OTP_ALNUM_ANYWHERE = re.compile(r'\b([A-Z0-9]{6,8})\b') +# 排除常见英文单词误匹配(全大写 6 字母) +_OTP_WORD_BLACKLIST = frozenset({"GOOGLE", "GEMINI", "VERIFY", "SIGNIN", "PLEASE", "CHANGE", "ACCEPT", "CANCEL", "SUBMIT"}) + +# Google 验证邮件 HTML class 精确提取(AB12CD) +RE_HTML_SPAN_CODE = re.compile( + r']*class="[^"]*verification-code[^"]*"[^>]*>\s*([A-Z0-9]{6})\s*', + re.IGNORECASE +) + +# 已移除域名黑名单机制:后台配什么邮箱 provider 就用什么 + + +# ─── Google Gemini 常量 ────────────────────────────────────────────────────── + +GEMINI_AUTH_URL = "https://auth.business.gemini.google/login" + +# Google 验证邮件发件人域名白名单 +GOOGLE_SENDER_DOMAINS = ( + "google.com", "googlemail.com", "accounts.google.com", + "noreply@google", "no-reply@google", "workspace-noreply@google", +) + + +def _is_google_email(sender: str, subject: str) -> bool: + """判断是否为 Google 验证邮件(Gemini Business 注册用)。""" + sender_lower = _normalize_sender(sender).lower() + subject_lower = subject.lower() + domain_match = any(d in sender_lower for d in GOOGLE_SENDER_DOMAINS) + if not domain_match and sender_lower: + domain_match = ("google" in sender_lower) or ("gemini" in sender_lower) + subject_match = any(kw in subject_lower for kw in + ("verification code", "verify", "验证码", "code", + "sign in", "登录", "google", "gemini", "workspace", "business")) + return subject_match and (domain_match or not sender_lower.strip()) + + +def _get_email_domain(email: str) -> str: + """提取邮箱 @ 后的域名后缀""" + return email.rsplit("@", 1)[-1].lower() if "@" in email else "" + + +def _normalize_sender(sender_obj) -> str: + """把不同 provider 返回的发件人结构归一成邮箱字符串。""" + if isinstance(sender_obj, dict): + return str(sender_obj.get("address", "") or sender_obj.get("name", "") or "") + if isinstance(sender_obj, list): + for item in sender_obj: + sender = _normalize_sender(item) + if sender: + return sender + return "" + return str(sender_obj) if sender_obj else "" + + +def _iter_text_chunks(value) -> list[str]: + """统一展开 text/html 字段,兼容字符串和字符串数组。""" + if isinstance(value, str): + return [value] if value else [] + if isinstance(value, list): + return [item for item in value if isinstance(item, str) and item] + return [] + + +def _is_plausible_otp_candidate(code: str, *, allow_plain_digits: bool = True) -> bool: + """过滤明显不是验证码的 6-8 位候选值。""" + if not code: + return False + code = code.upper() + if code in _OTP_WORD_BLACKLIST: + return False + if code.isdigit(): + if not allow_plain_digits: + return False + if re.match(r'^(19|20)\d{4}$|^0{6,8}$', code): + return False + return True + return any(ch.isdigit() for ch in code) + + +# ─── 工具函数 ──────────────────────────────────────────────────────────────── + +def _generate_password(length: int = 16) -> str: + """生成高强度密码:大小写 + 数字 + 特殊字符(使用 CSPRNG)""" + chars = string.ascii_letters + string.digits + "!@#$%^&*" + pwd = ( + secrets.choice(string.ascii_uppercase) + + secrets.choice(string.ascii_lowercase) + + secrets.choice(string.digits) + + secrets.choice("!@#$%^&*") + + "".join(secrets.choice(chars) for _ in range(length - 4)) + ) + lst = list(pwd) + # Fisher-Yates shuffle with CSPRNG + for i in range(len(lst) - 1, 0, -1): + j = secrets.randbelow(i + 1) + lst[i], lst[j] = lst[j], lst[i] + return "".join(lst) + + +def _random_name() -> tuple: + return random.choice(FIRST_NAMES), random.choice(LAST_NAMES) + + +# ─── GPTMail 轮询 ──────────────────────────────────────────────────────────── + +def _is_aws_email(sender: str, subject: str) -> bool: + """ + 精准判断是否为 AWS Builder ID 验证邮件。 + 发件人域名白名单 + 主题关键词双重过滤,防止误匹配广告邮件。 + """ + combined = (sender + " " + subject).lower() + # 发件人域名白名单匹配 + domain_match = any(d in sender.lower() for d in AWS_SENDER_DOMAINS) + # 主题关键词:AWS 特定词 + subject_match = any(kw in subject.lower() for kw in + ("verification code", "verify your", "builder id", "builder-id", "sign in", + "构建者 id", "构建者id", "验证")) + # 发件人为空时(GPTMail 部分邮件不填 from)只用主题判断 + return subject_match and (domain_match or not sender.strip()) + + +def _extract_otp(text: str) -> Optional[str]: + """ + 从邮件文本中提取 6 位 OTP 验证码。 + 支持纯数字和字母数字混合格式(Google Gemini 验证码可能包含字母)。 + 优先级:HTML span.verification-code > 关键词精准 > 字母数字混合 > quopri解码重试 > 6位数字回退。 + """ + if not text: + return None + + # 最高优先级:HTML span.verification-code 精确提取 + m = RE_HTML_SPAN_CODE.search(text) + if m: + code = m.group(1).upper() + if code not in _OTP_WORD_BLACKLIST: + return code + + # 高精度模式:纯数字验证码(宽松间距) + m = RE_OTP_PRECISE.search(text) + if m: + return m.group(1) + # 字母数字混合模式(Google Gemini 验证码) + m = RE_OTP_ALNUM.search(text) + if m: + code = m.group(1).upper() + if code not in _OTP_WORD_BLACKLIST: + return code + + # quopri 解码重试(邮件正文可能使用 quoted-printable 编码,导致验证码被截断) + try: + decoded = quopri.decodestring(text.encode("utf-8", errors="ignore")).decode("utf-8", errors="ignore") + if decoded and decoded != text: + for pat in (RE_OTP_PRECISE, RE_OTP_ALNUM): + m = pat.search(decoded) + if m: + code = m.group(1).upper() if pat is RE_OTP_ALNUM else m.group(1) + if pat is not RE_OTP_ALNUM or code not in _OTP_WORD_BLACKLIST: + return code + except Exception: + pass + + # HTML 邮件里验证码常被单独放进块级元素中,兜底扫描任意 6-8 位字母数字串。 + # 只接受包含数字的候选,避免把 GOOGLE/GEMINI 这类单词误判成验证码。 + for candidate in RE_OTP_ALNUM_ANYWHERE.findall(text.upper()): + if _is_plausible_otp_candidate(candidate, allow_plain_digits=False): + return candidate + + # 回退:任意 6 位数字(排除明显的年份/ID) + candidates = RE_OTP_FALLBACK.findall(text) + for c in candidates: + if _is_plausible_otp_candidate(c): + return c + return None + + +def _poll_yydsmail(yydsmail_url: str, yydsmail_key: str, email: str, meta: dict, + timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + """轮询 YYDS Mail API 获取验证码。 + 1. GET /v1/messages?address=xxx (Bearer token) 获取邮件列表 + 2. GET /v1/messages/{id} (Bearer token) 获取邮件详情 + 3. 从 subject/text 中提取验证码 + email_filter: 可选的邮件过滤函数 (sender, subject) -> bool + """ + token = meta.get("token", "") + if not token: + logger.warning("[%s] yydsmail: 缺少 token", email) + return None + + list_url = f"{yydsmail_url.rstrip('/')}/v1/messages?address={urllib.parse.quote(email)}" + start = time.time() + attempt = 0 + _consecutive_errors = 0 + _last_checked_at: dict[str, float] = {} + _recheck_interval = 5.0 + while time.time() - start < timeout: + if cancel is not None and cancel.is_set(): + logger.info("[%s] yydsmail 轮询被取消", email) + return None + attempt += 1 + if log_q is not None and attempt % 5 == 1 and attempt > 1: + elapsed = int(time.time() - start) + log_q.put(f"等待验证码 (yydsmail)... ({elapsed}s / {timeout}s)") + try: + req = urllib.request.Request(list_url) + req.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)") + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp: + data = json.loads(resp.read()) + + if not data.get("success"): + _consecutive_errors += 1 + if _consecutive_errors >= 3: + logger.warning("[%s] yydsmail 连续错误,放弃", email) + return None + time.sleep(2) + continue + + _consecutive_errors = 0 + messages = data.get("data", {}).get("messages", []) + for msg in messages: + msg_id = msg.get("id", "") + if msg_id: + last_checked = _last_checked_at.get(msg_id, 0.0) + if last_checked and time.time() - last_checked < _recheck_interval: + continue + _last_checked_at[msg_id] = time.time() + + subject = msg.get("subject", "") or "" + sender = _normalize_sender(msg.get("from", {})) + + # 可选邮件过滤 + if email_filter and not email_filter(sender, subject): + continue + + # 先从 subject 提取 + code = _extract_otp(subject) + if code: + logger.info("[%s] yydsmail subject 验证码: %s (第 %d 次)", email, code, attempt) + return code + + # 获取邮件详情 + if msg_id: + try: + detail_url = f"{yydsmail_url.rstrip('/')}/v1/messages/{urllib.parse.quote(msg_id, safe='')}" + dreq = urllib.request.Request(detail_url) + dreq.add_header("Authorization", f"Bearer {token}") + dreq.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)") + with urllib.request.urlopen(dreq, timeout=10, context=_SSL_CTX) as dresp: + detail = json.loads(dresp.read()) + if detail.get("success"): + d = detail.get("data", {}) + for field in ("text", "html"): + for text in _iter_text_chunks(d.get(field, "")): + code = _extract_otp(text) + if code: + logger.info("[%s] yydsmail %s 验证码: %s (第 %d 次)", email, field, code, attempt) + return code + except Exception as e: + logger.debug("[%s] yydsmail 详情获取失败(id=%s): %s", email, msg_id, e) + + except urllib.error.HTTPError as exc: + logger.warning("[%s] yydsmail HTTP %s (第%d次)", email, exc.code, attempt) + _consecutive_errors += 1 + if _consecutive_errors >= 3: + return None + except Exception as exc: + logger.warning("[%s] yydsmail 轮询出错(第%d次): %s", email, attempt, exc) + _consecutive_errors += 1 + if _consecutive_errors >= 5: + return None + if cancel is not None: + if cancel.wait(2): + return None + else: + time.sleep(2) + return None + + +# ─── Mail.tm 轮询 ───────────────────────────────────────────────────────────── + +def _poll_mailtm(email: str, meta: dict, timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + """轮询 Mail.tm 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。""" + _filter = email_filter or _is_aws_email + token = meta.get("token", "") + if not token: + logger.warning("[%s] Mail.tm: 缺少 token", email) + return None + base_url = "https://api.mail.tm" + start = time.time() + attempt = 0 + _consecutive_errors = 0 + while time.time() - start < timeout: + if cancel is not None and cancel.is_set(): + return None + attempt += 1 + if log_q is not None and attempt % 5 == 1 and attempt > 1: + log_q.put(f"等待验证码 (Mail.tm)... ({int(time.time() - start)}s / {timeout}s)") + try: + req = urllib.request.Request(f"{base_url}/messages") + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Accept", "application/json") + with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp: + data = json.loads(resp.read()) + _consecutive_errors = 0 + members = data.get("hydra:member", data) if isinstance(data, dict) else data + if not isinstance(members, list): + members = [] + for msg in members: + if not isinstance(msg, dict): + continue + subject = msg.get("subject", "") or "" + sender = msg.get("from", {}) + if isinstance(sender, dict): + sender = sender.get("address", "") + elif isinstance(sender, list) and sender: + sender = sender[0].get("address", "") if isinstance(sender[0], dict) else str(sender[0]) + else: + sender = str(sender) if sender else "" + if not _filter(sender, subject): + continue + code = _extract_otp(subject) + if code: + logger.info("[%s] Mail.tm 从 subject 提取验证码: %s", email, code) + return code + msg_id = msg.get("id", "") + if not msg_id: + continue + try: + detail_req = urllib.request.Request(f"{base_url}/messages/{msg_id}") + detail_req.add_header("Authorization", f"Bearer {token}") + detail_req.add_header("Accept", "application/json") + with urllib.request.urlopen(detail_req, timeout=10, context=_SSL_CTX) as dresp: + detail = json.loads(dresp.read()) + for field in ("text", "html", "intro"): + text = detail.get(field, "") or "" + if text: + code = _extract_otp(text) + if code: + logger.info("[%s] Mail.tm 从 %s 提取验证码: %s", email, field, code) + return code + except Exception as e: + logger.warning("[%s] Mail.tm 读取消息详情失败: %s", email, e) + except Exception as exc: + logger.warning("[%s] Mail.tm 轮询出错(第%d次): %s", email, attempt, exc) + _consecutive_errors += 1 + if _consecutive_errors >= 5: + logger.warning("[%s] Mail.tm 连续 %d 次错误,放弃轮询", email, _consecutive_errors) + return None + if cancel is not None: + if cancel.wait(3): + return None + else: + time.sleep(3) + return None + + +# ─── Guerrilla Mail 轮询 ────────────────────────────────────────────────────── + +def _poll_guerrillamail(email: str, meta: dict, timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + """轮询 Guerrilla Mail 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。""" + _filter = email_filter or _is_aws_email + sid_token = meta.get("sid_token", "") + if not sid_token: + logger.warning("[%s] GuerrillaMail: 缺少 sid_token", email) + return None + base_url = "https://api.guerrillamail.com/ajax.php" + start = time.time() + attempt = 0 + _consecutive_errors = 0 + while time.time() - start < timeout: + if cancel is not None and cancel.is_set(): + return None + attempt += 1 + if log_q is not None and attempt % 5 == 1 and attempt > 1: + log_q.put(f"等待验证码 (GuerrillaMail)... ({int(time.time() - start)}s / {timeout}s)") + try: + url = f"{base_url}?f=check_email&seq=0&sid_token={sid_token}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp: + data = json.loads(resp.read()) + _consecutive_errors = 0 + mail_list = data.get("list", []) + for msg in mail_list: + if not isinstance(msg, dict): + continue + subject = msg.get("mail_subject", "") or "" + sender = msg.get("mail_from", "") or "" + body = msg.get("mail_body", "") or "" + if "Welcome to Guerrilla Mail" in subject: + continue + if not _filter(sender, subject): + continue + for text in (subject, body): + if text: + code = _extract_otp(text) + if code: + logger.info("[%s] GuerrillaMail 提取验证码: %s", email, code) + return code + except Exception as exc: + logger.warning("[%s] GuerrillaMail 轮询出错(第%d次): %s", email, attempt, exc) + _consecutive_errors += 1 + if _consecutive_errors >= 5: + logger.warning("[%s] GuerrillaMail 连续 %d 次错误,放弃轮询", email, _consecutive_errors) + return None + if cancel is not None: + if cancel.wait(3): + return None + else: + time.sleep(3) + return None + + +# ─── tempmail.lol 轮询 ──────────────────────────────────────────────────────── + +def _poll_templol(email: str, meta: dict, timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + """轮询 tempmail.lol 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。""" + _filter = email_filter or _is_aws_email + token = meta.get("token", "") + if not token: + logger.warning("[%s] tempmail.lol: 缺少 token", email) + return None + base_url = "https://api.tempmail.lol" + start = time.time() + attempt = 0 + _consecutive_errors = 0 + while time.time() - start < timeout: + if cancel is not None and cancel.is_set(): + return None + attempt += 1 + if log_q is not None and attempt % 5 == 1 and attempt > 1: + log_q.put(f"等待验证码 (tempmail.lol)... ({int(time.time() - start)}s / {timeout}s)") + try: + url = f"{base_url}/auth/{token}" + req = urllib.request.Request(url) + with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp: + data = json.loads(resp.read()) + _consecutive_errors = 0 + emails_list = data.get("email", []) + for msg in emails_list: + if not isinstance(msg, dict): + continue + subject = msg.get("subject", "") or "" + sender = msg.get("from", "") or "" + body = msg.get("body", "") or msg.get("html", "") or "" + if not _filter(sender, subject): + continue + for text in (subject, body): + if text: + code = _extract_otp(text) + if code: + logger.info("[%s] tempmail.lol 提取验证码: %s (第 %d 次轮询)", email, code, attempt) + return code + except Exception as exc: + logger.warning("[%s] tempmail.lol 轮询出错(第%d次): %s", email, attempt, exc) + _consecutive_errors += 1 + if _consecutive_errors >= 5: + logger.warning("[%s] tempmail.lol 连续 %d 次错误,放弃轮询", email, _consecutive_errors) + return None + if cancel is not None: + if cancel.wait(3): + return None + else: + time.sleep(3) + return None + + +# ─── mail.gw 轮询 ──────────────────────────────────────────────────────────── + +def _poll_mailgw(email: str, meta: dict, timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + """轮询 mail.gw 收件箱,提取验证码。API 与 Mail.tm 完全一致。email_filter 可切换 AWS/Google 过滤。""" + _filter = email_filter or _is_aws_email + token = meta.get("token", "") + if not token: + logger.warning("[%s] mail.gw: 缺少 token", email) + return None + base_url = "https://api.mail.gw" + start = time.time() + attempt = 0 + _consecutive_errors = 0 + while time.time() - start < timeout: + if cancel is not None and cancel.is_set(): + return None + attempt += 1 + if log_q is not None and attempt % 5 == 1 and attempt > 1: + log_q.put(f"等待验证码 (mail.gw)... ({int(time.time() - start)}s / {timeout}s)") + try: + req = urllib.request.Request(f"{base_url}/messages") + req.add_header("Authorization", f"Bearer {token}") + req.add_header("Accept", "application/json") + with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp: + data = json.loads(resp.read()) + _consecutive_errors = 0 + members = data.get("hydra:member", data) if isinstance(data, dict) else data + if not isinstance(members, list): + members = [] + for msg in members: + if not isinstance(msg, dict): + continue + subject = msg.get("subject", "") or "" + sender = msg.get("from", {}) + if isinstance(sender, dict): + sender = sender.get("address", "") + elif isinstance(sender, list) and sender: + sender = sender[0].get("address", "") if isinstance(sender[0], dict) else str(sender[0]) + else: + sender = str(sender) if sender else "" + if not _filter(sender, subject): + continue + code = _extract_otp(subject) + if code: + logger.info("[%s] mail.gw 从 subject 提取验证码: %s (第 %d 次轮询)", email, code, attempt) + return code + msg_id = msg.get("id", "") + if not msg_id: + continue + try: + detail_req = urllib.request.Request(f"{base_url}/messages/{msg_id}") + detail_req.add_header("Authorization", f"Bearer {token}") + detail_req.add_header("Accept", "application/json") + with urllib.request.urlopen(detail_req, timeout=10, context=_SSL_CTX) as dresp: + detail = json.loads(dresp.read()) + for field in ("text", "html", "intro"): + text = detail.get(field, "") or "" + if text: + code = _extract_otp(text) + if code: + logger.info("[%s] mail.gw 从 %s 提取验证码: %s", email, field, code) + return code + except Exception as e: + logger.warning("[%s] mail.gw 读取消息详情失败: %s", email, e) + except Exception as exc: + logger.warning("[%s] mail.gw 轮询出错(第%d次): %s", email, attempt, exc) + _consecutive_errors += 1 + if _consecutive_errors >= 5: + logger.warning("[%s] mail.gw 连续 %d 次错误,放弃轮询", email, _consecutive_errors) + return None + if cancel is not None: + if cancel.wait(3): + return None + else: + time.sleep(3) + return None + + +# ─── temp-mail.io 轮询 ─────────────────────────────────────────────────────── + +def _poll_tempmailio(email: str, meta: dict, timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + """轮询 temp-mail.io 收件箱,提取验证码。email_filter 可切换 AWS/Google 过滤。""" + _filter = email_filter or _is_aws_email + token = meta.get("token", "") + if not token: + logger.warning("[%s] temp-mail.io: 缺少 token", email) + return None + base_url = "https://api.internal.temp-mail.io/api/v3" + start = time.time() + attempt = 0 + _consecutive_errors = 0 + while time.time() - start < timeout: + if cancel is not None and cancel.is_set(): + return None + attempt += 1 + if log_q is not None and attempt % 5 == 1 and attempt > 1: + log_q.put(f"等待验证码 (temp-mail.io)... ({int(time.time() - start)}s / {timeout}s)") + try: + url = f"{base_url}/email/{email}/messages" + req = urllib.request.Request(url) + req.add_header("Authorization", f"Bearer {token}") + with urllib.request.urlopen(req, timeout=10, context=_SSL_CTX) as resp: + data = json.loads(resp.read()) + _consecutive_errors = 0 + if not isinstance(data, list): + data = [] + for msg in data: + if not isinstance(msg, dict): + continue + subject = msg.get("subject", "") or "" + sender = msg.get("from", "") or "" + body = msg.get("body_text", "") or msg.get("body_html", "") or "" + if not _filter(sender, subject): + continue + for text in (subject, body): + if text: + code = _extract_otp(text) + if code: + logger.info("[%s] temp-mail.io 提取验证码: %s (第 %d 次轮询)", email, code, attempt) + return code + except Exception as exc: + logger.warning("[%s] temp-mail.io 轮询出错(第%d次): %s", email, attempt, exc) + _consecutive_errors += 1 + if _consecutive_errors >= 5: + logger.warning("[%s] temp-mail.io 连续 %d 次错误,放弃轮询", email, _consecutive_errors) + return None + if cancel is not None: + if cancel.wait(3): + return None + else: + time.sleep(3) + return None + + +# ─── Mailfree 自建邮箱轮询 ──────────────────────────────────────────────────── + +def _poll_mailfree(email: str, meta: dict, timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + """轮询 mailfree 自建 Cloudflare Workers 邮箱服务,提取验证码。 + mailfree API 自带 verification_code 字段,优先使用服务端提取结果。 + email_filter 可切换 AWS/Google 过滤(用于 fallback 匹配)。""" + _filter = email_filter or _is_aws_email + base_url = meta.get("base_url", "").rstrip("/") + admin_token = meta.get("admin_token", "") + if not base_url or not admin_token: + logger.warning("[%s] mailfree: 缺少 base_url 或 admin_token", email) + return None + + api_url = f"{base_url}/api/emails?mailbox={urllib.parse.quote(email)}" + _opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + urllib.request.HTTPSHandler(context=_SSL_CTX), + ) + start = time.time() + attempt = 0 + _consecutive_errors = 0 + + while time.time() - start < timeout: + if cancel is not None and cancel.is_set(): + return None + attempt += 1 + if log_q is not None and attempt % 5 == 1 and attempt > 1: + log_q.put(f"等待验证码 (mailfree)... ({int(time.time() - start)}s / {timeout}s)") + try: + req = urllib.request.Request(api_url) + req.add_header("Authorization", f"Bearer {admin_token}") + req.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)") + with _opener.open(req, timeout=10) as resp: + data = json.loads(resp.read()) + + _consecutive_errors = 0 + + if not isinstance(data, list): + data = [] + + for msg in data: + if not isinstance(msg, dict): + continue + + # 优先使用服务端提取的验证码 + vc = msg.get("verification_code", "") + if vc: + logger.info("[%s] mailfree 服务端验证码: %s (第 %d 次轮询)", email, vc, attempt) + return str(vc) + + subject = msg.get("subject", "") or "" + sender = msg.get("from", "") or "" + if _filter and not _filter(sender, subject): + continue + + # 列表接口只返回截断文本,需要拉取完整邮件内容 + email_id = msg.get("id", "") + if email_id: + try: + detail_url = f"{base_url}/api/email/{urllib.parse.quote(str(email_id), safe='')}" + dreq = urllib.request.Request(detail_url) + dreq.add_header("Authorization", f"Bearer {admin_token}") + dreq.add_header("User-Agent", "Mozilla/5.0 (compatible; RegPlatform/1.0)") + with _opener.open(dreq, timeout=10) as dresp: + full_msg = json.loads(dresp.read()) + if isinstance(full_msg, dict): + # 合并完整内容到 msg + for k, v in full_msg.items(): + if v and not msg.get(k): + msg[k] = v + except Exception as e: + logger.debug("[%s] mailfree 邮件详情获取失败(id=%s): %s", email, email_id, e) + + for field in ("subject", "content", "html_content", "text_content", "body", "text"): + text = msg.get(field, "") or "" + if text: + code = _extract_otp(text) + if code: + logger.info("[%s] mailfree 客户端提取验证码: %s (字段: %s, 第 %d 次轮询)", email, code, field, attempt) + return code + + except Exception as exc: + logger.warning("[%s] mailfree 轮询出错(第%d次): %s", email, attempt, exc) + _consecutive_errors += 1 + if _consecutive_errors >= 5: + logger.warning("[%s] mailfree 连续 %d 次错误,放弃轮询", email, _consecutive_errors) + return None + + if cancel is not None: + if cancel.wait(2): + return None + else: + time.sleep(2) + return None + + +# ─── 验证码轮询 provider 分发 ──────────────────────────────────────────────── + +def _poll_by_provider(email: str, provider: str, meta: dict, + yydsmail_url: str = "", yydsmail_key: str = "", + timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + email_filter=None) -> Optional[str]: + provider_key = (provider or meta.get("provider") or "yydsmail").strip().lower() + + if provider_key == "yydsmail": + return _poll_yydsmail(yydsmail_url, yydsmail_key, email, meta, + timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + if provider_key in {"mailtm", "mail.tm"}: + return _poll_mailtm(email, meta, timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + if provider_key in {"guerrillamail", "guerrilla", "guerrilla_mail"}: + return _poll_guerrillamail(email, meta, timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + if provider_key in {"templol", "tempmail.lol", "tempmail_lol"}: + return _poll_templol(email, meta, timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + if provider_key in {"mailgw", "mail.gw"}: + return _poll_mailgw(email, meta, timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + if provider_key in {"tempmailio", "temp-mail.io", "temp_mail_io"}: + return _poll_tempmailio(email, meta, timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + if provider_key == "mailfree": + return _poll_mailfree(email, meta, timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + + logger.warning("[%s] 未知邮箱 provider=%s,回退 yydsmail 轮询", email, provider_key) + return _poll_yydsmail(yydsmail_url, yydsmail_key, email, meta, + timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=email_filter) + + +# ─── 统一验证码轮询分发 ─────────────────────────────────────────────────────── + +def _poll_verification_code(email: str, provider: str, meta: dict, + yydsmail_url: str = "", yydsmail_key: str = "", + timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None) -> Optional[str]: + """验证码轮询(按 provider 分发,AWS 使用默认过滤规则)。""" + logger.info("[%s] 验证码轮询: provider=%s", email, provider) + return _poll_by_provider(email, provider, meta, + yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key, + timeout=timeout, log_q=log_q, cancel=cancel) + + +# ─── Gemini 验证码轮询 ────────────────────────────────────────────────────── + +def _poll_gemini_verification_code(email: str, provider: str, meta: dict, + yydsmail_url: str = "", yydsmail_key: str = "", + timeout: int = 120, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None) -> Optional[str]: + """Gemini 验证码轮询(按 provider 分发,并套用 Google 邮件过滤)。""" + logger.info("[%s] Gemini 验证码轮询: provider=%s", email, provider) + return _poll_by_provider(email, provider, meta, + yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key, + timeout=timeout, log_q=log_q, cancel=cancel, + email_filter=_is_google_email) + + +# ─── Playwright 页面交互辅助 ───────────────────────────────────────────────── + +async def _pw_click_next(page, lq=None): + """ + 点击注册流程的「下一步」按钮,多选择器兜底。 + Playwright locator 模式,不再依赖 WebDriverWait。 + """ + # 精确 data-testid 选择器(优先) + selectors = [ + '[data-testid="email-verification-verify-button"]', + '[data-testid="signup-next-button"]', + '[data-testid="test-primary-button"]', + 'button[type="submit"]', + 'input[type="submit"]', + ] + for sel in selectors: + try: + btn = page.locator(sel).first + if await btn.count() > 0 and await btn.is_visible(): + if lq: + lq(f"点击按钮: {sel[:60]}") + await btn.click() + return True + except Exception: + pass + + # 文本匹配兜底 + for text in ["Next", "Continue", "继续", "Create", "Verify", "Submit", "Confirm"]: + try: + btn = page.locator(f"button:has-text('{text}')").first + if await btn.count() > 0 and await btn.is_visible(): + if lq: + lq(f"点击按钮: text={text}") + await btn.click() + return True + except Exception: + pass + + # 按钮 class 兜底 + try: + btn = page.locator("button.primary, button[class*='primary']").first + if await btn.count() > 0 and await btn.is_visible(): + if lq: + lq("点击按钮: class=primary") + await btn.click() + return True + except Exception: + pass + + # 最后手段:Enter 键 + if lq: + lq("[!] 找不到下一步按钮,尝试 Enter 键提交") + try: + await page.keyboard.press("Enter") + return True + except Exception: + pass + return False + + +async def _pw_wait_navigation(page, old_url: str, timeout: float = 15, + expect_selector: str = "", lq=None): + """ + 等待页面 URL 变化或目标元素出现(SPA 场景)。 + 表单提交后调用,确认页面已跳转再进入下一步。 + expect_selector: 期望出现的目标元素选择器,避免误判。 + """ + interval = 0.5 + elapsed = 0.0 + while elapsed < timeout: + if page.url != old_url: + if lq: + lq(f"页面已跳转: {page.url[:80]}") + return True + # SPA 路由:URL 不变但目标元素已出现 + if expect_selector and elapsed > 2: + try: + cnt = await page.locator(expect_selector).count() + if cnt > 0: + if lq: + lq("页面内容已更新(检测到目标元素)") + return True + except Exception: + pass + await asyncio.sleep(interval) + elapsed += interval + if lq: + lq(f"[!] 等待页面跳转超时({timeout}s),URL: {page.url[:80]}") + return False + + +async def _pw_dismiss_cookie(page): + """快速处理 AWS Cookie 弹窗(接受/拒绝都行,只要关掉)""" + for sel in [ + "button.awsccc-u-btn-primary", + "button[id*='truste-consent']", + ]: + try: + btn = page.locator(sel).first + if await btn.count() > 0 and await btn.is_visible(): + await btn.click() + await asyncio.sleep(0.8) + return + except Exception: + pass + # 文本匹配:AWS cookie 弹窗按钮通常包含 awsccc class + for text in ["Accept", "接受", "Decline", "拒绝"]: + try: + # 限定 awsccc 或 cookie 相关容器内的按钮 + btn = page.locator(f".awsccc-cs-container button:has-text('{text}')").first + if await btn.count() > 0 and await btn.is_visible(): + await btn.click() + await asyncio.sleep(0.8) + return + except Exception: + pass + + +async def _pw_check_success(page) -> bool: + """通过页面内容判断注册是否真正成功""" + try: + page_text = (await page.locator("body").inner_text()).lower() + success_keywords = [ + "successfully created", "account created", "welcome to", + "you're all set", "get started", "verification complete", + "your account is ready", "sign in", "builder id created", + "请求已批准", "request approved", "您可以关闭此窗口", + ] + return any(kw in page_text for kw in success_keywords) + except Exception: + return False + + +# ─── 代理处理 ──────────────────────────────────────────────────────────────── + +_PROXY_RE = re.compile( + r'^(https?|socks[45])://[a-zA-Z0-9._\-:@]+(:\d{1,5})?(/[^\s]*)?$' +) + +# 允许的 region 值 +_ALLOWED_REGIONS = {"usa", "germany", "japan"} + + +def _mask_proxy(proxy: str) -> str: + """脱敏代理 URL,隐藏用户名和密码。http://user:pass@host:port → http://***@host:port""" + if not proxy or "@" not in proxy: + return proxy or "" + scheme_rest = proxy.split("://", 1) + if len(scheme_rest) != 2: + return "***" + scheme, rest = scheme_rest + host_part = rest.split("@", 1)[-1] + return f"{scheme}://***@{host_part}" + + +def _validate_proxy(proxy: str) -> Optional[str]: + """校验代理 URL 格式,防止恶意字符串注入。""" + if not proxy: + return None + proxy = proxy.strip() + if not _PROXY_RE.match(proxy): + logger.warning("代理格式无效,已拒绝: %r", proxy[:100]) + return None + # Docker 容器内 127.0.0.1 指向容器自身,需替换为宿主机地址 + if os.path.exists("/.dockerenv"): + proxy = proxy.replace("127.0.0.1", "host.docker.internal") + proxy = proxy.replace("localhost", "host.docker.internal") + return proxy + + +def _parse_proxy_for_playwright(proxy_str: str) -> Optional[dict]: + """将代理 URL 解析为 Playwright proxy 配置格式。""" + if not proxy_str: + return None + # 格式: scheme://user:pass@host:port 或 scheme://host:port + if "://" in proxy_str and "@" in proxy_str: + scheme, rest = proxy_str.split("://", 1) + auth, addr = rest.rsplit("@", 1) + user, pwd = auth.split(":", 1) + return { + "server": f"{scheme}://{addr}", + "username": user, + "password": pwd, + } + return {"server": proxy_str} + + +# ─── AWS OIDC Device Flow ──────────────────────────────────────────────────── + +def _curl_post(url: str, payload: dict, headers: dict, + proxy: Optional[str] = None, timeout: int = 30, + retries: int = 3) -> dict: + """HTTPS POST via curl — 代理 SSL 隧道不稳定时自动重试。""" + cmd = ["curl", "-s", "-X", "POST", url, + "--max-time", str(timeout), "--connect-timeout", "10", + "--tlsv1.2", "-H", "Content-Type: application/json", + "--retry", "2", "--retry-delay", "1"] + for k, v in headers.items(): + if k.lower() != "content-type": + cmd.extend(["-H", f"{k}: {v}"]) + cmd.extend(["-d", json.dumps(payload)]) + if proxy: + cmd.extend(curl_proxy_args(proxy)) # 代理链:--preproxy Clash + cmd.extend(["--proxy", proxy, "--insecure", "--proxy-insecure"]) + + last_err = None + for attempt in range(1, retries + 1): + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout + 10) + if result.returncode == 0 and result.stdout.strip(): + return json.loads(result.stdout) + stderr_info = result.stderr.strip()[:300] if result.stderr else "(no stderr)" + last_err = f"curl exit {result.returncode}: {stderr_info}" + except subprocess.TimeoutExpired: + last_err = f"curl subprocess timeout ({timeout + 10}s)" + except Exception as e: + last_err = str(e) + if attempt < retries: + delay = attempt * 3 # 递增重试间隔: 3s, 6s + logger.warning("_curl_post 第 %d 次失败: %s,%ds 后重试...", attempt, last_err, delay) + time.sleep(delay) + raise RuntimeError(last_err or "curl failed") + +def _oidc_get_verification_url(proxy: Optional[str] = None, log_q=None): + """ + 通过 AWS OIDC Device Flow 获取新鲜的 verificationUriComplete。 + 每次调用都向 AWS API 请求全新 session,不会像硬编码 workflowID 那样过期。 + + 流程: + 1. POST /client/register → clientId + clientSecret + 2. POST /device_authorization → verificationUriComplete + deviceCode + + 返回: (verificationUriComplete, deviceCode, clientId, clientSecret, interval) + """ + def lq(msg): + logger.info("[oidc] %s", msg) + if log_q: + log_q.put(msg) + + # OIDC API 通过 curl 走代理(urllib HTTPS 隧道 SSL 不稳定) + def _oidc_post(path, payload): + return _curl_post(f"{OIDC_BASE_URL}{path}", payload, _OIDC_HEADERS, proxy=proxy) + + # Step 1: 注册 OIDC 公共客户端 + lq("OIDC Step 1: 注册客户端...") + reg_data = _oidc_post("/client/register", { + "clientName": "Amazon Q Developer for command line", + "clientType": "public", + "scopes": [ + "codewhisperer:completions", + "codewhisperer:analysis", + "codewhisperer:conversations", + ], + "grantTypes": [ + "urn:ietf:params:oauth:grant-type:device_code", + "refresh_token", + ], + "issuerUrl": OIDC_START_URL, + }) + + client_id = reg_data["clientId"] + client_secret = reg_data["clientSecret"] + lq(f"OIDC: 客户端已注册 ({client_id[:24]}...)") + + # Step 2: 发起设备授权,拿到 verificationUriComplete + lq("OIDC Step 2: 获取设备授权 URL...") + auth_data = _oidc_post("/device_authorization", { + "clientId": client_id, + "clientSecret": client_secret, + "startUrl": OIDC_START_URL, + }) + + verification_url = auth_data["verificationUriComplete"] + device_code = auth_data["deviceCode"] + user_code = auth_data.get("userCode", "") + interval = max(int(auth_data.get("interval", 5)), 5) + + lq(f"OIDC: 授权 URL 已获取,user_code={user_code}") + return verification_url, device_code, client_id, client_secret, interval + + +def _oidc_poll_token(client_id, client_secret, device_code, interval=5, + proxy=None, timeout=60, log_q=None): + """ + OIDC Device Flow 最后一步:轮询 /token 获取 access_token + refresh_token。 + 在浏览器完成设备授权确认(Confirm + Allow)后调用。 + + 返回: dict(含 accessToken, refreshToken 等)或 None(超时/错误) + """ + def lq(msg): + logger.info("[oidc-token] %s", msg) + if log_q: + log_q.put(msg) + + # OIDC token 轮询通过 curl 走代理 + lq("开始轮询 OIDC token...") + start = time.time() + while time.time() - start < timeout: + try: + data = _curl_post( + f"{OIDC_BASE_URL}/token", + { + "clientId": client_id, + "clientSecret": client_secret, + "deviceCode": device_code, + "grantType": "urn:ietf:params:oauth:grant-type:device_code", + }, + _OIDC_HEADERS, proxy=proxy, timeout=15, + ) + + if "accessToken" in data: + lq("OIDC token 获取成功") + return data + # authorization_pending / slow_down 在正常 JSON 响应中 + if "slow_down" in str(data).lower(): + interval = min(interval + 2, 15) + except RuntimeError as exc: + err_str = str(exc).lower() + if "authorization_pending" in err_str: + pass + elif "slow_down" in err_str: + interval = min(interval + 2, 15) + else: + lq(f"OIDC token 轮询错误: {exc}") + return None + except Exception as e: + lq(f"OIDC token 轮询异常: {e}") + return None + + time.sleep(interval) + + lq("OIDC token 轮询超时") + return None + + +# ─── 核心注册逻辑(异步,Camoufox + Playwright)────────────────────────────── + +async def _do_register( + email: str, + proxy: Optional[str], + yydsmail_url: str, + yydsmail_key: str, + region: str = "usa", + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + mail_provider: str = "", + mail_meta: Optional[dict] = None, +) -> dict: + def lq(msg: str): + """同时写入 logger 和流式日志队列""" + logger.info("[%s] %s", email, msg) + if log_q is not None: + log_q.put(msg) + + def _cancelled() -> bool: + return cancel is not None and cancel.is_set() + + domain = _get_email_domain(email) + + first_name, last_name = _random_name() + full_name = f"{first_name} {last_name}" + password = _generate_password() + + # IP 地理位置自动检测(同步函数,放线程执行) + ip_loc = await asyncio.to_thread(_detect_ip_location, proxy) + tz = ip_loc["timezone"] + geo = ip_loc["geo"] + detected_locale = ip_loc["locale"] + lq(f"IP 地理位置: {ip_loc['country']} tz={tz} locale={detected_locale}") + + browser = None + context = None + page = None + + try: + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + + # ── Camoufox + OIDC 并行启动 ────────────────────────────────────── + lq("并行启动: Camoufox + OIDC Device Flow...") + + # OIDC 在后台线程跑(纯 HTTP,不依赖浏览器) + oidc_task = asyncio.create_task(asyncio.to_thread( + _oidc_get_verification_url, proxy=proxy, log_q=log_q, + )) + + # Headless 模式控制(与旧版逻辑一致) + import sys as _sys + _headless_env = os.getenv("KIRO_HEADLESS", "").lower() + if _headless_env in ("false", "0", "no"): + _use_headless = False + elif _headless_env in ("true", "1", "yes"): + _use_headless = True + else: + _use_headless = _sys.platform not in ("darwin", "win32") and not os.getenv("DISPLAY") + + # 启动 Camoufox 浏览器实例(每次注册独立实例,指纹完全隔离) + cf = AsyncCamoufox(headless=_use_headless) + browser = await cf.start() + + # 构建 Playwright context 选项(代理 + 地理 + locale + 时区) + context_opts = { + "locale": detected_locale, + "timezone_id": tz, + "geolocation": {"latitude": geo["latitude"], "longitude": geo["longitude"], "accuracy": geo["accuracy"]}, + "permissions": ["geolocation"], + } + + if proxy: + proxy = _validate_proxy(proxy) + if proxy: + # 代理链:浏览器连本地链式代理,经 Clash 转发到后端代理 + chained = chain_proxy(proxy) + pw_proxy = _parse_proxy_for_playwright(chained) + if pw_proxy: + context_opts["proxy"] = pw_proxy + logger.info("[%s] 使用代理: %s (chain→%s)", email, _mask_proxy(proxy), chained.split(":")[-1] if "127.0.0.1" in chained else "direct") + else: + lq("[!] 代理格式无效,已忽略") + + context = await browser.new_context(**context_opts) + page = await context.new_page() + lq("浏览器已启动") + + # 等待 OIDC 结果(Chrome 启动期间已在并行执行,通常此时已完成) + try: + oidc_result = await asyncio.wait_for(oidc_task, timeout=30) + except asyncio.TimeoutError: + lq("[!] OIDC API 超时(30s 内未返回)") + return {"ok": False, "error": "AWS OIDC API 超时"} + except Exception as exc: + lq(f"[!] OIDC API 调用失败: {exc}") + return {"ok": False, "error": f"AWS OIDC API 失败: {exc}"} + + signup_url, _device_code, _oidc_cid, _oidc_csec, _oidc_itv = oidc_result + + # ── Step 1: 打开注册链接 ────────────────────────────────────────── + lq(f"注册链接: {signup_url[:80]}...") + await page.goto(signup_url, wait_until="domcontentloaded", timeout=60000) + await asyncio.sleep(1) + lq(f"页面: {(await page.title())[:60]} | {page.url[:80]}") + + # 随机初始延迟(模拟人类初次浏览) + await asyncio.sleep(random.uniform(0.5, 1.5)) + + # Cookie 弹窗 + await _pw_dismiss_cookie(page) + + # ── Step 2: 设备确认页(SPA 渲染需要时间,轮询等待按钮出现)──── + # 扩展等待时间到 60s,并增加更多选择器以覆盖 AWS UI 变化 + _confirm_clicked = False + _initial_url = page.url + lq(f"初始 URL: {_initial_url[:80]}") + + # 尝试更多选择器:按 text 内容、CSS class、aria-label 等 + _button_selectors = [ + "button#cli_verification_btn", + "button[data-testid='confirm-device-button']", + "button:has-text('Confirm')", + "button:has-text('Continue')", + "button:has-text('confirm')", + "button.awsui-button:has-text('Confirm')", + "[data-testid='confirm-button']", + "button[type='submit']", + ] + + for _wait in range(120): # 最多等 60s(120 × 0.5s) + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + + # 检查 URL 是否已变化(可能页面已自动跳转) + if page.url != _initial_url and "device" not in page.url: + lq(f"[+] 页面已自动跳转: {page.url[:80]}") + _confirm_clicked = True # 标记为已确认(自动跳转) + break + + for sel in _button_selectors: + try: + btn = page.locator(sel).first + if await btn.count() > 0: + # 检查按钮是否可见(避免点击隐藏元素) + try: + if await btn.is_visible(timeout=1000): + lq(f"设备确认页:点击按钮 ({sel})...") + await btn.click() + _confirm_clicked = True + await asyncio.sleep(2) + lq(f"确认后 URL: {page.url[:80]}") + break + except Exception: + # is_visible() 可能失败,尝试直接点击 + lq(f"设备确认页:尝试点击按钮 ({sel})...") + await btn.click(force=True) + _confirm_clicked = True + await asyncio.sleep(2) + lq(f"确认后 URL: {page.url[:80]}") + break + except Exception: + continue + + if _confirm_clicked: + break + + # 每 10 秒输出一次进度 + if _wait > 0 and _wait % 20 == 0: + lq(f"[*] 仍在等待设备确认... ({_wait * 0.5:.0f}s/60s)") + + await asyncio.sleep(0.5) + + if not _confirm_clicked: + lq("[!] 设备确认按钮未找到(60s 超时)") + # 如果仍在设备授权页面,说明需要手动确认,返回明确错误 + if "device" in page.url and "user_code" in page.url: + lq(f"[!] 仍在设备授权页面,需要手动确认: {page.url[:80]}") + return { + "ok": False, + "error": "设备授权超时:页面停留在设备确认页(需在另一设备上确认),无法自动继续", + "retriable": False, + } + lq("[!] 设备确认按钮未找到,尝试继续...") + + # ── Step 3: 填写邮箱 ───────────────────────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + lq(f"填写邮箱: {email} | 当前页: {page.url[:80]}") + + # 等待 SPA 渲染完成(AWS signin 是 React SPA,domcontentloaded 时 UI 还没挂载) + try: + await page.wait_for_load_state("networkidle", timeout=20000) + except Exception: + lq("[!] networkidle 等待超时(20s),继续尝试...") + await asyncio.sleep(1) + + # 等待邮箱输入框出现(多选择器并行等待,覆盖 AWS CloudScape UI) + email_input = page.locator( + 'input[placeholder="username@example.com"], ' + 'input[type="email"], ' + 'input[name="email"], ' + 'input[autocomplete="username"], ' + 'input[autocomplete="email"], ' + 'input[data-testid="username-input"], ' + 'input[data-testid="email-input"], ' + 'input.awsui-input-type-text, ' + '#awsui-input-0' + ).first + try: + await email_input.wait_for(state="visible", timeout=30000) + except Exception: + # SPA 可能还没渲染完,再等一轮重试 + await asyncio.sleep(5) + try: + email_input = page.locator('input[type="text"], input[type="email"]').first + await email_input.wait_for(state="visible", timeout=10000) + except Exception: + lq(f"[!] 找不到邮箱输入框,URL={page.url[:100]}") + return {"ok": False, "error": "找不到邮箱输入框,页面可能已变化或被重定向"} + + lq(f"邮箱输入框已找到") + await asyncio.sleep(random.uniform(0.3, 0.8)) + + # Playwright fill() 生成 isTrusted:true 事件,无需 React setter hack + await email_input.fill(email) + filled = await email_input.input_value() + lq(f"邮箱 read-back: '{filled}' (期望: '{email}')") + await asyncio.sleep(0.3) + await _pw_click_next(page, lq) + await asyncio.sleep(0.5) + await _pw_dismiss_cookie(page) + + # ── Step 4: 填写姓名 ───────────────────────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + lq(f"填写姓名: {full_name} | 当前页: {page.url[:80]}") + + # 等 URL 跳转到 profile.aws(最多 15s) + for _i in range(15): + if "profile.aws" in page.url: + break + await asyncio.sleep(1) + else: + lq(f"[!] 邮件提交后未跳转 profile.aws") + return {"ok": False, "error": "email_domain_rejected", "retriable": True} + + # 再等 1s 让 SPA 路由稳定 + await asyncio.sleep(1) + if "profile.aws" not in page.url: + lq(f"邮箱域名被 AWS 拒绝(中转后跳回 signin.aws): {page.url[:80]}") + return {"ok": False, "error": "email_domain_rejected", "retriable": True} + + name_input = page.locator( + 'input[placeholder*="Silva"], ' + 'input[placeholder*="name" i], ' + 'input[name="full_name"], ' + 'input[name="name"]' + ).first + try: + await name_input.wait_for(state="visible", timeout=15000) + except Exception: + logger.warning("[%s] 未找到姓名输入框,跳过", email) + name_input = None + + if name_input: + lq(f"姓名输入框已找到") + await asyncio.sleep(random.uniform(0.3, 0.8)) + await name_input.fill(full_name) + filled = await name_input.input_value() + lq(f"姓名字段 read-back: '{filled}' (期望: '{full_name}')") + await asyncio.sleep(0.3) + + await _pw_dismiss_cookie(page) + await _pw_click_next(page, lq) + + # 轮询等待跳转,同时检查错误 + _name_err_texts = ("抱歉,处理您的请求时出错", "Sorry, there was an error", "Please try again", "错误") + _jumped = False + for _i in range(10): + await asyncio.sleep(1) + await _pw_dismiss_cookie(page) + if "verify-otp" in page.url or "create-password" in page.url: + _jumped = True + break + try: + _body = await page.locator("body").inner_text() + except Exception: + _body = "" + if any(k in _body for k in _name_err_texts): + lq(f"[!] 名字页报错: {_body[:120]}") + return {"ok": False, "error": "email_domain_rejected", "retriable": True} + if not _jumped: + try: + _body = await page.locator("body").inner_text() + except Exception: + _body = "" + lq(f"名字页 10s 未跳转: {_body[:80]}") + return {"ok": False, "error": "name_page_blocked", "retriable": True} + await asyncio.sleep(0.3) + + # ── Step 5: 等待验证码并填写(仅在确实处于 OTP 页时执行)──────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + + otp_selectors = [ + 'input[placeholder="6 位数"]', + 'input[placeholder*="digit" i]', + 'input[placeholder*="code" i]', + 'input[autocomplete="one-time-code"]', + 'input[name="code"]', + 'input[name="otp"]', + 'input[type="text"][maxlength="6"]', + 'input[type="number"]', + ] + pwd_selector = 'input[type="password"]' + + otp_input = None + direct_password_ready = False + for _ in range(10): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + + if "create-password" in page.url: + direct_password_ready = True + break + + try: + if await page.locator(pwd_selector).count() > 0: + direct_password_ready = True + break + except Exception: + pass + + for sel in otp_selectors: + try: + loc = page.locator(sel).first + if await loc.count() > 0 and await loc.is_visible(): + otp_input = loc + break + except Exception: + pass + if otp_input: + break + await asyncio.sleep(0.5) + + if direct_password_ready: + lq("检测到流程已直接进入密码设置页,跳过邮箱验证码轮询") + else: + lq(f"等待 AWS 验证码邮件(最长 120s)... | provider={mail_provider or 'yydsmail'} | 当前页: {page.url[:80]}") + + # 邮件轮询在线程中执行(同步阻塞函数) + code = await asyncio.to_thread( + _poll_verification_code, + email, provider=mail_provider or "yydsmail", meta=mail_meta or {}, + yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key, + timeout=_MAIL_TIMEOUT, log_q=log_q, cancel=cancel, + ) + if not code: + return {"ok": False, "error": f"验证码超时({_MAIL_TIMEOUT}s),provider={mail_provider or 'yydsmail'}"} + + lq("验证码已收到,正在填写...") + + if not otp_input: + for _ in range(30): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + for sel in otp_selectors: + try: + loc = page.locator(sel).first + if await loc.count() > 0 and await loc.is_visible(): + otp_input = loc + break + except Exception: + pass + if otp_input: + break + await asyncio.sleep(0.5) + if not otp_input: + return {"ok": False, "error": "找不到验证码输入框"} + lq("验证码输入框已找到") + + await asyncio.sleep(random.uniform(0.3, 0.6)) + await otp_input.fill(code) + lq(f"验证码 read-back: '{await otp_input.input_value()}'") + await asyncio.sleep(0.3) + _url_before_otp = page.url + await _pw_click_next(page, lq) + # 等待页面跳转到密码设置页,而不是盲等 1s + await _pw_wait_navigation(page, _url_before_otp, timeout=20, + expect_selector=pwd_selector, lq=lq) + await asyncio.sleep(0.5) + + # ── Step 6: 设置密码 ───────────────────────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + lq("设置账号密码...") + + # 快速轮询等密码框出现(从 8s 提升到 20s,给页面跳转留足时间) + pwd_inputs = [] + for _ in range(40): + pwd_inputs = await page.locator('input[type="password"]').all() + if len(pwd_inputs) >= 2: + break + # 也检查单个密码框的情况 + if len(pwd_inputs) == 1: + await asyncio.sleep(1) + pwd_inputs = await page.locator('input[type="password"]').all() + break + await asyncio.sleep(0.5) + + if len(pwd_inputs) >= 2: + lq(f"密码框已找到({len(pwd_inputs)} 个),快速填写...") + await pwd_inputs[0].fill(password) + await pwd_inputs[1].fill(password) + _url_before_pwd = page.url + await _pw_click_next(page, lq) + await _pw_wait_navigation(page, _url_before_pwd, timeout=15, + expect_selector='button#cli_verification_btn, [data-testid="allow-access-button"]', lq=lq) + await asyncio.sleep(0.5) + elif len(pwd_inputs) == 1: + lq("只找到 1 个密码框,填写...") + await pwd_inputs[0].fill(password) + _url_before_pwd = page.url + await _pw_click_next(page, lq) + await _pw_wait_navigation(page, _url_before_pwd, timeout=15, + expect_selector='button#cli_verification_btn, [data-testid="allow-access-button"]', lq=lq) + await asyncio.sleep(0.5) + else: + lq("[!] 未找到密码输入框,尝试等待更长时间...") + # 最后兜底:再等 10s 看看密码框会不会出现 + _last_chance = [] + for _ in range(20): + _last_chance = await page.locator('input[type="password"]').all() + if _last_chance: + break + await asyncio.sleep(0.5) + if _last_chance: + lq(f"兜底等待成功,找到 {len(_last_chance)} 个密码框") + await _last_chance[0].fill(password) + if len(_last_chance) >= 2: + await _last_chance[1].fill(password) + _url_before_pwd = page.url + await _pw_click_next(page, lq) + await _pw_wait_navigation(page, _url_before_pwd, timeout=15, + expect_selector='button#cli_verification_btn, [data-testid="allow-access-button"]', lq=lq) + await asyncio.sleep(0.5) + else: + lq("[!] 密码输入框始终未出现,流程可能已跳过") + + # ── Step 7: 二次设备确认 ────────────────────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + lq("等待设备确认页面...") + _dev_clicked = False + for _ in range(30): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + for sel in ["button#cli_verification_btn", "button[data-testid='confirm-device-button']"]: + try: + btn = page.locator(sel).first + if await btn.count() > 0 and await btn.is_visible(): + lq("设备确认:点击 Confirm...") + await btn.click() + _dev_clicked = True + break + except Exception: + pass + if _dev_clicked: + break + await asyncio.sleep(0.5) + + if _dev_clicked: + await asyncio.sleep(3) + else: + lq("未找到设备确认按钮(可能已自动跳过),继续...") + + # ── Step 7.5: 允许访问授权页 ────────────────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + lq("等待允许访问页面...") + _allow_clicked = False + for _ in range(30): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + try: + btn = page.locator('[data-testid="allow-access-button"]').first + if await btn.count() > 0 and await btn.is_visible(): + lq("点击「允许访问」完成授权...") + await btn.click() + _allow_clicked = True + break + except Exception: + pass + await asyncio.sleep(0.5) + + if _allow_clicked: + await asyncio.sleep(3) + else: + lq("未找到允许访问按钮(可能已自动跳过),继续...") + + # ── Step 7.7: OIDC token 轮询 ──────────────────────────────────── + _token_data = None + if _dev_clicked or _allow_clicked: + if not _cancelled(): + _token_data = await asyncio.to_thread( + _oidc_poll_token, + _oidc_cid, _oidc_csec, _device_code, + _oidc_itv, proxy, 30, log_q, + ) + + # ── Step 8: 验证注册结果 ────────────────────────────────────────── + await asyncio.sleep(0.5) + final_url = page.url + lq(f"最终 URL: {final_url}") + + page_success = await _pw_check_success(page) + url_success = ( + any(d in final_url for d in ( + "builder.aws.com", "aws.amazon.com", "profile.aws", + "view.awsapps.com", + )) + and "sign-in" not in final_url + and "error" not in final_url.lower() + ) + token_success = _token_data is not None and "accessToken" in (_token_data or {}) + lq(f"页面标题: {(await page.title())[:60]}") + + if page_success or url_success or token_success: + lq(f"注册成功(page={page_success} url={url_success} token={token_success})") + result = {"ok": True, "email": email, "password": password, "name": full_name} + if _token_data and "accessToken" in _token_data: + from datetime import datetime, timezone, timedelta + expires_in = _token_data.get("expiresIn", 3600) + now = datetime.now(timezone(timedelta(hours=8))) + result["access_token"] = _token_data["accessToken"] + result["refresh_token"] = _token_data.get("refreshToken", "") + result["client_id"] = _oidc_cid + result["client_secret"] = _oidc_csec + result["expires_at"] = (now + timedelta(seconds=expires_in)).strftime("%Y-%m-%dT%H:%M:%S+08:00") + result["last_refresh"] = now.strftime("%Y-%m-%dT%H:%M:%S+08:00") + result["region"] = "us-east-1" + return result + else: + logger.warning("[%s] 注册流程完成但无法确认成功,final_url=%s", email, final_url) + return {"ok": False, "error": "注册流程已执行但无法确认账户创建成功,请检查邮箱是否已激活"} + + except asyncio.CancelledError: + logger.info("[%s] 注册任务被取消", email) + return {"ok": False, "error": "任务已取消"} + + except Exception as exc: + logger.error("[%s] 注册异常: %s", email, exc, exc_info=True) + return {"ok": False, "error": str(exc)} + + finally: + # Playwright 资源清理(page → context → browser) + for resource in [page, context, browser]: + if resource: + try: + await resource.close() + except Exception: + pass + + +# ─── Gemini 辅助函数 ────────────────────────────────────────────────────────── + +def _extract_gemini_xsrf(html: str) -> str: + """从 Gemini 登录页面提取 XSRF Token(4 种策略:meta tag / hidden input / JS var / URL param)。""" + for pattern in [ + r'name=["\']xsrf-token["\']\s+content=["\']([^"\']+)["\']', + r'name=["\']xsrfToken["\'][^>]*value=["\']([A-Za-z0-9_-]{20,})["\']', + r'xsrfToken["\']?\s*[=:]\s*["\']([A-Za-z0-9_-]{20,})["\']', + r'xsrfToken=([A-Za-z0-9_-]{20,})', + ]: + m = re.search(pattern, html, re.IGNORECASE) + if m: + return m.group(1) + # 不再使用硬编码 fallback(会过期),返回空串让调用方处理 + logger.warning("XSRF Token 提取失败,所有策略均未匹配") + return "" + + +async def _get_page_text(page) -> str: + """安全获取页面 body 文本""" + try: + return await page.locator("body").inner_text() + except Exception: + return "" + + +async def _human_type(page, locator, text: str): + """模拟人类打字:逐字符输入,随机 80-180ms 间隔,20% 概率额外停顿 200-500ms。""" + await locator.click() + await asyncio.sleep(random.uniform(0.1, 0.3)) + for ch in text: + await page.keyboard.type(ch) + delay = random.uniform(0.08, 0.18) + if random.random() < 0.2: + delay += random.uniform(0.2, 0.5) + await asyncio.sleep(delay) + + +_GEMINI_CODE_INPUT_SELECTORS = [ + "input[jsname='ovqh0b']", + "input[type='tel']", + "input[name='pinInput']", + "input[autocomplete='one-time-code']", + "input[aria-label*='验证码']", + "input[aria-label*='verification code' i]", +] + +_GEMINI_EMAIL_INPUT_SELECTORS = [ + "#email-input", + "input[name='loginHint']", + "input[aria-label='邮箱']", + "input[aria-label*='email' i]", +] + +_GEMINI_EMAIL_CONTINUE_SELECTORS = [ + "#log-in-button", + "button#log-in-button", + "button[jsname='jXw9Fb']", + "button[aria-label='使用邮箱继续']", + "button[aria-label='Continue with email']", + "button[aria-label='Use email to continue']", + "button[type='submit']", +] + + +def _classify_gemini_page_state( + url: str, + body_text: str = "", + *, + has_code_input: bool = False, + has_email_input: bool = False, + has_login_button: bool = False, +) -> str: + """把 Gemini 页面粗分为邮箱输入页 / 验证码页 / 已登录 / signin-error。""" + url_lower = (url or "").lower() + body_lower = (body_text or "").lower() + + if "signin-error" in url_lower: + return "signin_error" + if "business.gemini.google" in url_lower and "csesidx=" in url_lower and "/cid/" in url_lower: + return "signed_in" + + code_hints = ( + "请输入验证码", + "重新发送验证码", + "we sent a 6 character code", + "enter the verification code", + "verification code sent", + ) + if "verify-oob-code" in url_lower or has_code_input or any(h in body_lower for h in code_hints): + return "verify_code" + + email_hints = ( + "使用邮箱继续", + "继续使用邮箱", + "work email", + "login or create a free trial account", + "create account or sign in", + ) + if has_email_input or has_login_button or any(h in body_lower for h in email_hints): + return "email_entry" + + return "unknown" + + +async def _get_first_visible_locator(page, selectors): + """返回首个可见 locator;找不到时返回 None。""" + for selector in selectors: + try: + loc = page.locator(selector).first + if await loc.count() > 0 and await loc.is_visible(): + return loc + except Exception: + continue + return None + + +async def _get_gemini_page_state(page): + """读取当前 Gemini 页面状态和正文文本。""" + body_text = await _get_page_text(page) + code_input = await _get_first_visible_locator(page, _GEMINI_CODE_INPUT_SELECTORS) + email_input = await _get_first_visible_locator(page, _GEMINI_EMAIL_INPUT_SELECTORS) + login_button = await _get_first_visible_locator(page, _GEMINI_EMAIL_CONTINUE_SELECTORS[:-1]) + + state = _classify_gemini_page_state( + page.url, + body_text, + has_code_input=code_input is not None, + has_email_input=email_input is not None, + has_login_button=login_button is not None, + ) + return { + "state": state, + "url": page.url, + "body_text": body_text, + "code_input": code_input, + "email_input": email_input, + "login_button": login_button, + } + + +async def _wait_for_gemini_page_state(page, timeout: float = 15.0, interval: float = 0.5, lq=None): + """ + 等待 Gemini 页面稳定到可识别状态。 + 解决 Google 前端渲染较慢时,3-5s 固定 sleep 还没落到真实页面的问题。 + """ + deadline = asyncio.get_running_loop().time() + timeout + last_state = None + last_info = None + + while True: + info = await _get_gemini_page_state(page) + last_info = info + if info["state"] != last_state: + last_state = info["state"] + if lq: + lq(f"Gemini 页面状态: {info['state']} | {info['url'][:80]}") + if info["state"] != "unknown": + return info + if asyncio.get_running_loop().time() >= deadline: + return info + await asyncio.sleep(interval) + + +async def _click_gemini_email_continue(page, email: str, lq) -> bool: + """在邮箱输入页点击当前真实的“使用邮箱继续”按钮。""" + email_input = await _get_first_visible_locator(page, _GEMINI_EMAIL_INPUT_SELECTORS) + if email_input is not None: + try: + current_value = (await email_input.input_value()).strip() + except Exception: + current_value = "" + if current_value.lower() != email.lower(): + try: + await email_input.fill("") + except Exception: + pass + await asyncio.sleep(random.uniform(0.2, 0.5)) + await _human_type(page, email_input, email) + await asyncio.sleep(random.uniform(0.3, 0.8)) + lq("邮箱输入框内容已补齐") + + continue_button = await _get_first_visible_locator(page, _GEMINI_EMAIL_CONTINUE_SELECTORS) + if continue_button is not None: + label = "" + try: + label = (await continue_button.inner_text()).strip() + except Exception: + try: + label = (await continue_button.get_attribute("aria-label")) or "" + except Exception: + label = "" + await continue_button.click() + await asyncio.sleep(random.uniform(1.0, 2.0)) + lq(f"点击邮箱继续按钮: {label or 'submit'}") + return True + + for text_kw in ("使用邮箱继续", "Continue with email", "Use email to continue", "继续", "Next"): + try: + btn = page.locator(f"button:has-text('{text_kw}'), div[role='button']:has-text('{text_kw}')").first + if await btn.count() > 0 and await btn.is_visible(): + await btn.click() + await asyncio.sleep(random.uniform(1.0, 2.0)) + lq(f"点击文本按钮成功: {text_kw}") + return True + except Exception: + continue + + return False + + +async def _extract_gemini_config(page, context, email: str, lq) -> dict: + """从 Gemini Business 页面提取 config_id、csesidx 和 cookies。""" + url = page.url + if "cid/" not in url: + try: + await page.goto("https://business.gemini.google/", + wait_until="domcontentloaded", timeout=30000) + await asyncio.sleep(random.uniform(2, 4)) + url = page.url + except Exception: + pass + + if "cid/" not in url: + return {"ok": False, "error": "cid not found in URL"} + + try: + config_id = url.split("cid/")[1].split("?")[0].split("/")[0] + except (IndexError, ValueError): + return {"ok": False, "error": "无法解析 config_id"} + + csesidx = "" + if "csesidx=" in url: + try: + csesidx = url.split("csesidx=")[1].split("&")[0] + except (IndexError, ValueError): + pass + + # 轮询等待 cookies 就位 + c_ses = "" + c_oses = "" + ses_obj = None + for _ in range(20): + cookies = await context.cookies() + c_ses = next((c["value"] for c in cookies if c["name"] == "__Secure-C_SES"), "") + c_oses = next((c["value"] for c in cookies if c["name"] == "__Host-C_OSES"), "") + ses_obj = next((c for c in cookies if c["name"] == "__Secure-C_SES"), None) + if c_ses and c_oses: + break + await asyncio.sleep(1) + + if not c_ses or not c_oses: + missing = [] + if not c_ses: + missing.append("C_SES") + if not c_oses: + missing.append("C_OSES") + lq(f"[!] Cookie 不完整 ({', '.join(missing)} 缺失),账号不可用") + return {"ok": False, "error": f"Cookie 不完整 ({', '.join(missing)} 缺失)", "retriable": False} + + from datetime import datetime, timezone as dt_timezone, timedelta + beijing_tz = dt_timezone(timedelta(hours=8)) + if ses_obj and "expires" in ses_obj: + cookie_expire = datetime.fromtimestamp(ses_obj["expires"], tz=beijing_tz) + expires_at = (cookie_expire - timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%S+08:00") + else: + expires_at = (datetime.now(beijing_tz) + timedelta(hours=12)).strftime("%Y-%m-%dT%H:%M:%S+08:00") + + # 提取试用期 + trial_end_date = await _extract_gemini_trial_end(page, csesidx, config_id, lq) + + result = { + "ok": True, + "email": email, + "config_id": config_id, + "csesidx": csesidx, + "c_ses": c_ses, + "c_oses": c_oses, + "expires_at": expires_at, + } + if trial_end_date: + result["trial_end_date"] = trial_end_date + + lq(f"配置提取完成: config_id={config_id}, csesidx={csesidx[:20]}...") + return result + + +async def _extract_gemini_trial_end(page, csesidx: str, config_id: str, lq) -> Optional[str]: + """从页面源码中提取 Gemini 试用期到期日期。""" + from datetime import datetime, timezone as dt_timezone, timedelta + + def _days_to_end(days: int) -> str: + return (datetime.now(dt_timezone(timedelta(hours=8))) + timedelta(days=days)).strftime("%Y-%m-%d") + + def _search_source(source: str) -> Optional[str]: + m = re.search(r'"daysLeft"\s*:\s*(\d+)', source) + if m: + return _days_to_end(int(m.group(1))) + m = re.search(r'"trialDaysRemaining"\s*:\s*(\d+)', source) + if m: + return _days_to_end(int(m.group(1))) + m = re.search(r'\[(\d{4}),(\d{1,2}),(\d{1,2})\].*?\[(\d{4}),(\d{1,2}),(\d{1,2})\]', source) + if m: + try: + end_date = f"{m.group(4):0>4}-{int(m.group(5)):02d}-{int(m.group(6)):02d}" + if 2025 <= int(m.group(4)) <= 2030: + return end_date + except Exception: + pass + m = re.search(r'(\d+)\s*days?\s*left', source, re.IGNORECASE) + if m: + return _days_to_end(int(m.group(1))) + # ISO 日期格式(freeTrialEndDate / trialExpiry / expirationDate) + m = re.search(r'(?:freeTrialEndDate|trialExpiry|expirationDate|endDate)["\s:]+(\d{4}-\d{2}-\d{2})', source) + if m: + try: + year = int(m.group(1)[:4]) + if 2025 <= year <= 2030: + return m.group(1) + except Exception: + pass + return None + + try: + source = await page.content() + result = _search_source(source or "") + if result: + lq(f"试用期到期: {result}") + return result + # 尝试 settings 页 + settings_url = f"https://business.gemini.google/cid/{config_id}/settings?csesidx={csesidx}" + await page.goto(settings_url, wait_until="domcontentloaded", timeout=30000) + await asyncio.sleep(random.uniform(1.5, 3)) + source = await page.content() + result = _search_source(source or "") + if result: + lq(f"试用期到期: {result}") + return result + except Exception as e: + lq(f"[!] 获取试用期失败: {e}") + return None + + +# ─── 403/风控页面检测(Gemini 专用)──────────────────────────────────────── + +_GEMINI_BLOCK_PATTERNS = [ + "Access Restricted", + "This service is restricted", + "account suspended", + "not available for your account", + "account has been disabled", + "unusual activity", + "can't sign you in", + "couldn't verify", +] + + +def _detect_gemini_block(body_text: str, email: str, lq) -> Optional[dict]: + """检测 403 / 风控页面,返回错误 dict 或 None。""" + for pattern in _GEMINI_BLOCK_PATTERNS: + if pattern.lower() in body_text.lower(): + domain = email.rsplit("@", 1)[-1] if "@" in email else "unknown" + lq(f"[!] 页面被拦截: {pattern} ({domain})") + return {"ok": False, "error": f"blocked: {pattern} ({domain})", "retriable": False} + return None + + +# ── CAPTCHA / 人机验证检测 ────────────────────────────────────────────────── + +_CAPTCHA_PATTERNS = [ + "recaptcha", + "g-recaptcha", + "verify you're a human", + "prove you're not a robot", + "unusual traffic from your computer", + "automated queries", + "complete the security check", + "are you a robot", +] + + +def _detect_captcha(body_text: str, email: str, lq) -> Optional[dict]: + """检测 CAPTCHA / 人机验证拦截,返回错误 dict 或 None。""" + lower = body_text.lower() + for pattern in _CAPTCHA_PATTERNS: + if pattern.lower() in lower: + lq(f"[!] CAPTCHA 拦截: {pattern}") + return {"ok": False, "error": f"captcha: {pattern}", "retriable": True} + return None + + +# ─── Gemini 注册核心逻辑(异步,Camoufox + Playwright)────────────────────── + +async def _do_gemini_register( + email: str, + proxy: Optional[str], + yydsmail_url: str, + yydsmail_key: str, + log_q: Optional[queue.Queue] = None, + cancel: Optional[Event] = None, + mail_provider: str = "", + mail_meta: Optional[dict] = None, +) -> dict: + """Gemini Business 注册流程(Camoufox + Playwright)。""" + def lq(msg: str): + logger.info("[%s] %s", email, msg) + if log_q is not None: + log_q.put(msg) + + def _cancelled() -> bool: + return cancel is not None and cancel.is_set() + + first_name, last_name = _random_name() + full_name = f"{first_name} {last_name}" + + # IP 地理位置自动检测 + ip_loc = await asyncio.to_thread(_detect_ip_location, proxy) + tz = ip_loc["timezone"] + geo = ip_loc["geo"] + detected_locale = ip_loc["locale"] + lq(f"IP 地理位置: {ip_loc['country']} tz={tz}") + + browser = None + context = None + page = None + + try: + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + + # ── 启动 Camoufox ────────────────────────────────────────── + lq("启动 Camoufox 浏览器...") + import sys as _sys + _headless_env = os.getenv("GEMINI_HEADLESS", os.getenv("KIRO_HEADLESS", "")).lower() + if _headless_env in ("false", "0", "no"): + _use_headless = False + elif _headless_env in ("true", "1", "yes"): + _use_headless = True + else: + _use_headless = _sys.platform not in ("darwin", "win32") and not os.getenv("DISPLAY") + + cf = AsyncCamoufox(headless=_use_headless) + browser = await cf.start() + + context_opts = { + "locale": detected_locale, + "timezone_id": tz, + "geolocation": {"latitude": geo["latitude"], "longitude": geo["longitude"], "accuracy": geo["accuracy"]}, + "permissions": ["geolocation"], + } + + if proxy: + proxy = _validate_proxy(proxy) + if proxy: + chained = chain_proxy(proxy) + pw_proxy = _parse_proxy_for_playwright(chained) + if pw_proxy: + context_opts["proxy"] = pw_proxy + logger.info("[%s] Gemini 使用代理: %s", email, _mask_proxy(proxy)) + else: + lq("[!] 代理格式无效,已忽略") + + context = await browser.new_context(**context_opts) + # WebRTC 泄漏保护:阻止通过 RTCPeerConnection 暴露真实 IP + await context.add_init_script(""" + Object.defineProperty(navigator, 'mediaDevices', { + get: () => ({ + enumerateDevices: () => Promise.resolve([]), + getUserMedia: () => Promise.reject(new DOMException('NotAllowedError')), + }) + }); + window.RTCPeerConnection = undefined; + window.mozRTCPeerConnection = undefined; + window.webkitRTCPeerConnection = undefined; + """) + page = await context.new_page() + lq("浏览器已启动") + + # ── Step 1: 打开登录页面 ────────────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + lq("打开 Gemini 登录页面...") + await page.goto(GEMINI_AUTH_URL, wait_until="domcontentloaded", timeout=60000) + await asyncio.sleep(random.uniform(1, 2)) + lq(f"页面: {(await page.title())[:60]} | {page.url[:80]}") + + # ── Step 2: 提取 XSRF Token + 设置 Cookie ──────────────── + html = await page.content() + xsrf_token = _extract_gemini_xsrf(html) + if not xsrf_token: + lq("[!] XSRF Token 提取失败,尝试刷新页面重新获取...") + await page.reload(wait_until="domcontentloaded", timeout=30000) + await asyncio.sleep(random.uniform(1, 2)) + html = await page.content() + xsrf_token = _extract_gemini_xsrf(html) + if not xsrf_token: + lq("[!] XSRF Token 二次提取仍失败,Google 页面结构可能已变化") + return {"ok": False, "error": "XSRF token extraction failed", "retriable": True} + lq(f"XSRF Token: {xsrf_token[:20]}...") + + # __Host- 前缀 cookie 不允许 domain 属性(RFC 6265bis), + # Camoufox (Playwright/Firefox) 会剥离 domain 导致验证失败; + # 改用 url 让 Playwright 自动推导 domain + path + await context.add_cookies([{ + "name": "__Host-AP_SignInXsrf", + "value": xsrf_token, + "url": "https://auth.business.gemini.google/", + "secure": True, + }]) + + # ── Step 3: URL 方式提交邮箱 ───────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + login_hint = urllib.parse.quote(email, safe="") + login_url = ( + f"https://auth.business.gemini.google/login/email" + f"?continueUrl=https%3A%2F%2Fbusiness.gemini.google%2F" + f"&loginHint={login_hint}&xsrfToken={xsrf_token}" + ) + lq("URL 方式提交邮箱...") + await page.goto(login_url, wait_until="domcontentloaded", timeout=60000) + page_state = await _wait_for_gemini_page_state(page, timeout=15.0, lq=lq) + current_url = page_state["url"] + lq(f"当前 URL: {current_url[:80]}") + + # 检查 signin-error + if page_state["state"] == "signin_error": + lq("[!] signin-error 页面") + return {"ok": False, "error": "signin-error: token rejected by Google", "retriable": False} + + # 检查是否已登录 + if page_state["state"] == "signed_in": + lq("已登录,直接提取配置") + return await _extract_gemini_config(page, context, email, lq) + + # 检查 403 / 风控 + body_text = page_state["body_text"] + block_result = _detect_gemini_block(body_text, email, lq) + if block_result: + return block_result + captcha_result = _detect_captcha(body_text, email, lq) + if captcha_result: + return captcha_result + + # ── Step 4: 点击发送验证码 ─────────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + send_ok = False + if page_state["state"] == "verify_code": + lq("URL 提交后已直接进入验证码页,跳过发送按钮点击") + send_ok = True + else: + lq("当前仍在邮箱确认页,尝试点击“使用邮箱继续”...") + for send_attempt in range(3): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + + page_state = await _get_gemini_page_state(page) + if page_state["state"] == "verify_code": + lq("检测到验证码页已出现,验证码应已自动发送") + send_ok = True + break + if page_state["state"] == "signin_error": + lq("[!] signin-error 页面") + return {"ok": False, "error": "signin-error: token rejected by Google", "retriable": False} + if page_state["state"] == "signed_in": + lq("已登录,直接提取配置") + return await _extract_gemini_config(page, context, email, lq) + + body_text = page_state["body_text"] + block_result = _detect_gemini_block(body_text, email, lq) + if block_result: + return block_result + captcha_result = _detect_captcha(body_text, email, lq) + if captcha_result: + return captcha_result + + clicked = await _click_gemini_email_continue(page, email, lq) + if not clicked: + delay = [4, 6, 8][min(send_attempt, 2)] + lq(f"未找到当前页面的邮箱继续按钮,{delay}s 后重试 ({send_attempt + 1}/3)") + await asyncio.sleep(delay) + continue + + page_state = await _wait_for_gemini_page_state(page, timeout=20.0, lq=lq) + if page_state["state"] == "verify_code": + lq("点击后进入验证码页,验证码发送流程已触发") + send_ok = True + break + if page_state["state"] == "signed_in": + lq("点击后直接登录成功,跳过验证码轮询") + return await _extract_gemini_config(page, context, email, lq) + if page_state["state"] == "signin_error": + lq("[!] signin-error 页面") + return {"ok": False, "error": "signin-error: token rejected by Google", "retriable": False} + + body_text = page_state["body_text"] + block_result = _detect_gemini_block(body_text, email, lq) + if block_result: + return block_result + captcha_result = _detect_captcha(body_text, email, lq) + if captcha_result: + return captcha_result + + delay = [4, 6, 8][min(send_attempt, 2)] + lq(f"点击后仍未进入验证码页,{delay}s 后重试 ({send_attempt + 1}/3)") + await asyncio.sleep(delay) + + if not send_ok: + lq("[!] 验证码发送失败(可能触发风控)") + return {"ok": False, "error": "send code failed", "retriable": True} + + # ── Step 5: 等待验证码输入框 ───────────────────────────── + lq("等待验证码输入框...") + code_input = page_state.get("code_input") + _code_selectors = _GEMINI_CODE_INPUT_SELECTORS + for _ in range(30): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + if code_input is not None: + break + for sel in _code_selectors: + try: + loc = page.locator(sel).first + if await loc.count() > 0 and await loc.is_visible(): + code_input = loc + break + except Exception: + pass + if code_input: + break + await asyncio.sleep(1) + + if not code_input: + # 检查是否被 CAPTCHA 拦截导致输入框未出现 + body_text = await _get_page_text(page) + captcha_result = _detect_captcha(body_text, email, lq) + if captcha_result: + return captcha_result + lq("[!] 验证码输入框未出现") + return {"ok": False, "error": "code input not found"} + lq("验证码输入框已找到") + + # ── Step 6: 轮询邮件获取验证码 ─────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + lq(f"等待 Google 验证码邮件... (provider={mail_provider or 'yydsmail'})") + + code = await asyncio.to_thread( + _poll_gemini_verification_code, + email, provider=mail_provider or "yydsmail", meta=mail_meta or {}, + yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key, + timeout=_MAIL_TIMEOUT, log_q=log_q, cancel=cancel, + ) + if not code: + # 重试:点重发按钮后再轮询 + lq("验证码超时,尝试重新发送...") + try: + resend_btn = page.locator( + "button:has-text('重新'), button:has-text('Resend'), button:has-text('resend'), " + "button:has-text('Send again'), button:has-text('Try again'), button:has-text('重新发送')" + ).first + if await resend_btn.count() > 0: + await resend_btn.click() + await asyncio.sleep(2) + code = await asyncio.to_thread( + _poll_gemini_verification_code, + email, provider=mail_provider or "yydsmail", meta=mail_meta or {}, + yydsmail_url=yydsmail_url, yydsmail_key=yydsmail_key, + timeout=90, log_q=log_q, cancel=cancel, + ) + except Exception: + pass + if not code: + return {"ok": False, "error": f"验证码超时 (provider={mail_provider or 'yydsmail'})", "retriable": True} + + lq(f"验证码: {code[:2]}{'*' * max(len(code) - 2, 0)}") + + # ── Step 7: 输入验证码并提交 ───────────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + + # 重新获取输入框(可能已刷新 DOM) + for sel in _code_selectors: + try: + loc = page.locator(sel).first + if await loc.count() > 0 and await loc.is_visible(): + code_input = loc + break + except Exception: + pass + + await asyncio.sleep(random.uniform(0.3, 0.8)) + # 人类模拟打字(逐字符输入,降低风控检测概率) + await _human_type(page, code_input, code) + lq(f"验证码 read-back: {await code_input.input_value()}") + await asyncio.sleep(random.uniform(0.3, 0.6)) + + # 提交:先 Enter,再找验证按钮兜底 + await code_input.press("Enter") + await asyncio.sleep(random.uniform(0.8, 1.5)) + + if "verify-oob-code" in page.url: + for btn_text in ["验证", "Verify", "Next", "确认"]: + try: + vbtn = page.locator(f"button:has-text('{btn_text}')").first + if await vbtn.count() > 0 and await vbtn.is_visible(): + await vbtn.click() + await asyncio.sleep(1) + break + except Exception: + continue + + # ── Step 8: 检查提交结果 + 403 检测 ────────────────────── + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + await asyncio.sleep(1.5) + + body_text = await _get_page_text(page) + block_result = _detect_gemini_block(body_text, email, lq) + if block_result: + return block_result + captcha_result = _detect_captcha(body_text, email, lq) + if captcha_result: + return captcha_result + + if "verify-oob-code" in page.url: + lq("[!] 验证码提交失败") + return {"ok": False, "error": "verification code submission failed", "retriable": True} + + # ── Step 9: 处理用户名设置(新账号)───────────────────── + lq("等待用户名设置页面...") + _name_selectors = [ + "input[formcontrolname='fullName']", + "input#mat-input-0", + "input[placeholder='全名']", + "input[placeholder='Full name']", + "input[name='displayName']", + "input[type='text']", + ] + name_input = None + for _ in range(30): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + # 检查是否已到达业务页面 + if "business.gemini.google" in page.url and "csesidx=" in page.url and "/cid/" in page.url: + lq("已到达业务页面,跳过用户名设置") + break + for sel in _name_selectors: + try: + loc = page.locator(sel).first + if await loc.count() > 0 and await loc.is_visible(): + name_input = loc + break + except Exception: + pass + if name_input: + break + await asyncio.sleep(1) + + if name_input: + lq(f"输入姓名: {full_name}") + await asyncio.sleep(random.uniform(0.3, 0.8)) + await _human_type(page, name_input, full_name) + await asyncio.sleep(random.uniform(0.3, 0.6)) + await name_input.press("Enter") + await asyncio.sleep(random.uniform(0.8, 1.5)) + + # Enter 没跳转时找提交按钮 + if "cid" not in page.url: + for btn_text in ["继续", "Continue", "提交", "Submit", "Next"]: + try: + sbtn = page.locator(f"button:has-text('{btn_text}')").first + if await sbtn.count() > 0 and await sbtn.is_visible(): + await sbtn.click() + await asyncio.sleep(1) + break + except Exception: + continue + + # ── Step 10: 处理协议页面 ───────────────────────────────── + if "/admin/create" in page.url: + try: + agree_btn = page.locator("button.agree-button").first + if await agree_btn.count() > 0: + await agree_btn.click() + await asyncio.sleep(random.uniform(1, 2)) + except Exception: + pass + + # ── Step 11: 等待业务页面参数(csesidx + cid)────────── + lq("等待 Gemini 工作台 URL...") + _got_params = False + for _ in range(45): + if _cancelled(): + return {"ok": False, "error": "任务已取消"} + url = page.url + if "csesidx=" in url and "/cid/" in url: + _got_params = True + break + await asyncio.sleep(1) + + if not _got_params: + lq("URL 参数未生成,尝试导航到主页...") + try: + await page.goto("https://business.gemini.google/", + wait_until="domcontentloaded", timeout=30000) + await asyncio.sleep(random.uniform(2, 3)) + except Exception: + pass + for _ in range(15): + url = page.url + if "csesidx=" in url and "/cid/" in url: + _got_params = True + break + await asyncio.sleep(1) + + if not _got_params: + body_text = await _get_page_text(page) + block_result = _detect_gemini_block(body_text, email, lq) + if block_result: + return block_result + lq(f"[!] URL 参数生成失败: {page.url[:80]}") + return {"ok": False, "error": "URL parameters not found (csesidx/cid)"} + + # ── Step 12: 提取配置 ───────────────────────────────────── + lq("注册成功,提取配置...") + return await _extract_gemini_config(page, context, email, lq) + + except asyncio.CancelledError: + return {"ok": False, "error": "任务已取消"} + + except Exception as exc: + logger.error("[%s] Gemini 注册异常: %s", email, exc, exc_info=True) + return {"ok": False, "error": str(exc)} + + finally: + # asyncio.shield 防止 CancelledError 中断浏览器资源释放 + async def _cleanup_resources(): + for resource in [page, context, browser]: + if resource: + try: + await resource.close() + except Exception: + pass + try: + await asyncio.shield(_cleanup_resources()) + except asyncio.CancelledError: + pass + + +# ─── HTTP 路由 ─────────────────────────────────────────────────────────────── + +@app.route("/kiro/process", methods=["POST"]) +async def register(): + data = await request.get_json() + if not data or not data.get("email"): + return jsonify({"ok": False, "error": "invalid request"}), 400 + + email = data["email"] + proxy = data.get("proxy") + yydsmail_url = data.get("yydsmail_url", "") + yydsmail_key = data.get("yydsmail_key", "") + region = data.get("region", "usa") + mail_provider = data.get("mail_provider", "") + mail_meta = data.get("mail_meta", {}) + + if region not in _ALLOWED_REGIONS: + return jsonify({"ok": False, "error": f"不支持的 region: {region}"}), 400 + + logger.info("收到注册请求: %s proxy=%s region=%s", email, "***" if proxy else "none", region) + + async def generate(): + async with _reg_semaphore: + log_q: queue.Queue = queue.Queue() + cancel_event = Event() + + reg_task = asyncio.create_task( + _do_register( + email, proxy, yydsmail_url, yydsmail_key, region, + log_q, cancel_event, mail_provider, mail_meta, + ) + ) + + last_keepalive = asyncio.get_event_loop().time() + try: + while not reg_task.done(): + drained = False + while True: + try: + msg = log_q.get_nowait() + yield f"LOG:{msg}\n" + drained = True + last_keepalive = asyncio.get_event_loop().time() + except queue.Empty: + break + # 超过 10s 没有日志则推心跳(Go 侧会忽略) + now = asyncio.get_event_loop().time() + if not drained and now - last_keepalive > 10: + yield "LOG: .\n" + last_keepalive = now + if not drained: + await asyncio.sleep(0.2) + + # 排尽剩余日志 + while True: + try: + msg = log_q.get_nowait() + yield f"LOG:{msg}\n" + except queue.Empty: + break + + # 最后一行:JSON 结果 + result = await reg_task + yield json.dumps(result, ensure_ascii=False) + "\n" + + except (asyncio.CancelledError, GeneratorExit): + # 客户端断开连接,通知注册协程取消 + cancel_event.set() + reg_task.cancel() + try: + await reg_task + except (asyncio.CancelledError, Exception): + pass + logger.info("[%s] 客户端断开,注册任务已取消", email) + raise + + return app.response_class(generate(), content_type="text/plain; charset=utf-8") + + +@app.route("/gemini/process", methods=["POST"]) +async def gemini_register(): + data = await request.get_json() + if not data or not data.get("email"): + return jsonify({"ok": False, "error": "invalid request"}), 400 + + email = data["email"] + proxy = data.get("proxy") + yydsmail_url = data.get("yydsmail_url", "") + yydsmail_key = data.get("yydsmail_key", "") + mail_provider = data.get("mail_provider", "") + mail_meta = data.get("mail_meta", {}) + + logger.info("收到 Gemini 注册请求: %s proxy=%s", email, "***" if proxy else "none") + + async def generate(): + async with _gemini_semaphore: + log_q: queue.Queue = queue.Queue() + cancel_event = Event() + + reg_task = asyncio.create_task( + _do_gemini_register( + email, proxy, yydsmail_url, yydsmail_key, + log_q, cancel_event, mail_provider, mail_meta, + ) + ) + + last_keepalive = asyncio.get_event_loop().time() + try: + while not reg_task.done(): + drained = False + while True: + try: + msg = log_q.get_nowait() + yield f"LOG:{msg}\n" + drained = True + last_keepalive = asyncio.get_event_loop().time() + except queue.Empty: + break + now = asyncio.get_event_loop().time() + if not drained and now - last_keepalive > 10: + yield "LOG: .\n" + last_keepalive = now + if not drained: + await asyncio.sleep(0.2) + + while True: + try: + msg = log_q.get_nowait() + yield f"LOG:{msg}\n" + except queue.Empty: + break + + result = await reg_task + yield json.dumps(result, ensure_ascii=False) + "\n" + + except (asyncio.CancelledError, GeneratorExit): + cancel_event.set() + reg_task.cancel() + try: + await reg_task + except (asyncio.CancelledError, Exception): + pass + logger.info("[%s] Gemini 客户端断开,注册任务已取消", email) + raise + + return app.response_class(generate(), content_type="text/plain; charset=utf-8") + + +@app.route("/health") +async def health(): + return jsonify({ + "status": "ok", + "service": "aws-builder-id-reg", + "engine": "camoufox", + "platforms": ["kiro", "gemini"], + "concurrency": { + "kiro": _MAX_CONCURRENT, + "gemini": _GEMINI_MAX_CONCURRENT, + }, + }) + + +@app.route("/") +async def index(): + return jsonify({ + "name": "edge-service", + "version": "1.0.0", + "status": "healthy", + }) + + +def main(): + parser = argparse.ArgumentParser(description="账号注册服务 — Kiro + Gemini (Camoufox)") + parser.add_argument("--host", default=os.getenv("KIRO_REG_HOST", "0.0.0.0")) + parser.add_argument("--port", type=int, default=int(os.getenv("KIRO_REG_PORT", "5076"))) + args = parser.parse_args() + logger.info("AWS Builder ID 注册服务启动 (Camoufox): %s:%d", args.host, args.port) + + import hypercorn.asyncio as _ha + import hypercorn.config as _hc + _cfg = _hc.Config() + _cfg.bind = [f"{args.host}:{args.port}"] + _cfg.keep_alive_timeout = int(os.getenv("KIRO_KEEPALIVE_TIMEOUT", "310")) + _cfg.response_timeout = int(os.getenv("KIRO_RESPONSE_TIMEOUT", "400")) + asyncio.run(_ha.serve(app, _cfg)) + + +if __name__ == "__main__": + main() diff --git a/services/aws-builder-id-reg/start.sh b/services/aws-builder-id-reg/start.sh new file mode 100644 index 0000000..9bea539 --- /dev/null +++ b/services/aws-builder-id-reg/start.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +PORT="${KIRO_REG_PORT:-5076}" +HOST="${KIRO_REG_HOST:-0.0.0.0}" + +echo "[aws-builder-id-reg] 启动 AWS Builder ID 注册服务 ${HOST}:${PORT}" +exec python server.py --host "$HOST" --port "$PORT" diff --git a/services/aws-builder-id-reg/test_gemini_helpers.py b/services/aws-builder-id-reg/test_gemini_helpers.py new file mode 100644 index 0000000..41e4775 --- /dev/null +++ b/services/aws-builder-id-reg/test_gemini_helpers.py @@ -0,0 +1,79 @@ +import importlib.util +import sys +import types +import unittest +from pathlib import Path + + +def _load_server_module(): + server_path = Path(__file__).with_name("server.py") + + fake_camoufox_pkg = types.ModuleType("camoufox") + fake_camoufox_async = types.ModuleType("camoufox.async_api") + + class AsyncCamoufox: # pragma: no cover - 测试只需要占位,避免导入失败 + pass + + fake_camoufox_async.AsyncCamoufox = AsyncCamoufox + sys.modules.setdefault("camoufox", fake_camoufox_pkg) + sys.modules["camoufox.async_api"] = fake_camoufox_async + + spec = importlib.util.spec_from_file_location("aws_builder_id_reg_server", server_path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +server = _load_server_module() + + +class GeminiHelperTests(unittest.TestCase): + def test_extract_gemini_xsrf_from_hidden_input(self): + html = """ +
+ + +
+ """ + self.assertEqual( + server._extract_gemini_xsrf(html), + "wM-APfg7awG4f9hZGZIIqVnSb64", + ) + + def test_is_google_email_accepts_workspace_sender(self): + self.assertTrue( + server._is_google_email( + "Gemini for Workspace ", + "Your Gemini verification code", + ) + ) + + def test_classify_gemini_page_state_verify_code(self): + state = server._classify_gemini_page_state( + "https://accountverification.business.gemini.google/v1/verify-oob-code", + "请输入验证码 我们已将 6 个字符的验证码发送至 test@example.com", + has_code_input=True, + ) + self.assertEqual(state, "verify_code") + + def test_classify_gemini_page_state_email_entry(self): + state = server._classify_gemini_page_state( + "https://auth.business.gemini.google/login", + "欢迎使用 Business 版 使用您的工作邮箱登录或创建免费试用账号 使用邮箱继续", + has_email_input=True, + has_login_button=True, + ) + self.assertEqual(state, "email_entry") + + def test_classify_gemini_page_state_signed_in(self): + state = server._classify_gemini_page_state( + "https://business.gemini.google/app/cid/abc123?csesidx=hello", + "", + ) + self.assertEqual(state, "signed_in") + + +if __name__ == "__main__": + unittest.main() diff --git a/services/proxy_chain.py b/services/proxy_chain.py new file mode 100644 index 0000000..a15a46e --- /dev/null +++ b/services/proxy_chain.py @@ -0,0 +1,243 @@ +""" +代理链工具 — 在环境变量代理(Clash)和后端代理之间建立链式转发。 + +链路:App → Clash(HTTP_PROXY) → 后端代理 → 目标 + +使用方式: + from proxy_chain import chain_proxy, curl_proxy_args + + # playwright / camoufox / requests — 替换代理 URL + proxy_url = chain_proxy("http://user:pass@us-proxy:8080") + # 返回 "http://127.0.0.1:xxxxx"(本地链式代理),或原样返回 + + # curl — 获取额外参数 + extra_args = curl_proxy_args("http://user:pass@us-proxy:8080") + # 返回 ["--preproxy", "http://clash:7890"] 或 [] +""" + +import base64 +import logging +import os +import select +import socket +import threading +from urllib.parse import urlparse + +logger = logging.getLogger("proxy-chain") + +# ─── 环境变量检测 ───────────────────────────────────────────────────────────── + +def _get_env_proxy() -> str: + """读取环境变量中的代理地址(HTTPS_PROXY 优先)""" + for var in ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"): + val = os.environ.get(var, "").strip() + if val: + return val + return "" + + +# ─── curl 代理链 ───────────────────────────────────────────────────────────── + +def curl_proxy_args(backend_proxy: str) -> list: + """ + 返回 curl 的代理链参数。 + 当环境变量有代理(Clash)且有后端代理时,返回 ["--preproxy", clash_url], + curl 会先通过 Clash 建立隧道,再连后端代理。 + """ + env_proxy = _get_env_proxy() + if env_proxy and backend_proxy: + return ["--preproxy", env_proxy] + return [] + + +# ─── 通用代理链(playwright / camoufox / requests)───────────────────────────── + +_chain_servers = {} # backend_proxy_url -> "http://127.0.0.1:port" +_chain_lock = threading.Lock() + + +def chain_proxy(backend_proxy: str) -> str: + """ + 代理链入口。返回应该使用的代理 URL: + - 有 Clash + 有后端代理 → 启动本地链式代理,返回 "http://127.0.0.1:port" + - 只有后端代理 → 原样返回 + - 无后端代理 → 返回空串(让调用方走默认 / 环境变量) + """ + if not backend_proxy: + return "" + + env_proxy = _get_env_proxy() + if not env_proxy: + return backend_proxy # 无 Clash,直连后端代理 + + with _chain_lock: + # double-check:锁内检查 + 创建,避免 TOCTOU 竞态 + if backend_proxy in _chain_servers: + return _chain_servers[backend_proxy] + + # 启动本地链式代理 + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.bind(("127.0.0.1", 0)) + port = srv.getsockname()[1] + srv.listen(32) + + bp = urlparse(backend_proxy) + clash = urlparse(env_proxy) + + # 后端代理认证信息(注入到转发请求中) + proxy_auth_header = "" + if bp.username: + cred = f"{bp.username}:{bp.password or ''}" + b64 = base64.b64encode(cred.encode()).decode() + proxy_auth_header = f"Proxy-Authorization: Basic {b64}\r\n" + + bp_host = bp.hostname + bp_port = bp.port or (443 if bp.scheme == "https" else 80) + clash_host = clash.hostname or "127.0.0.1" + clash_port = clash.port or 7890 + + t = threading.Thread( + target=_accept_loop, + args=(srv, clash_host, clash_port, bp_host, bp_port, proxy_auth_header, backend_proxy), + daemon=True, + ) + t.start() + + local_url = f"http://127.0.0.1:{port}" + _chain_servers[backend_proxy] = local_url + + logger.info("代理链已启动: %s → Clash(%s:%d) → %s:%d", + local_url, clash_host, clash_port, bp_host, bp_port) + return local_url + + +# ─── 内部实现 ───────────────────────────────────────────────────────────────── + +def _accept_loop(srv, clash_host, clash_port, bp_host, bp_port, proxy_auth, cache_key): + try: + while True: + client, _ = srv.accept() + threading.Thread( + target=_handle_client, + args=(client, clash_host, clash_port, bp_host, bp_port, proxy_auth), + daemon=True, + ).start() + except Exception: + pass + finally: + _safe_close(srv) + # 清理缓存,下次调用 chain_proxy 时会重建 + with _chain_lock: + _chain_servers.pop(cache_key, None) + + +def _handle_client(client, clash_host, clash_port, bp_host, bp_port, proxy_auth): + """处理单个客户端连接:Clash → 后端代理 双跳 CONNECT""" + tunnel = None + try: + # 1. 读取客户端请求头 + header = b"" + while b"\r\n\r\n" not in header: + chunk = client.recv(8192) + if not chunk: + return + header += chunk + + first_line = header.split(b"\r\n")[0].decode("utf-8", errors="replace") + + # 2. TCP 连接 Clash + tunnel = socket.create_connection((clash_host, clash_port), timeout=15) + + # 3. 通过 Clash CONNECT 到后端代理 + connect_to_bp = ( + f"CONNECT {bp_host}:{bp_port} HTTP/1.1\r\n" + f"Host: {bp_host}:{bp_port}\r\n" + f"\r\n" + ) + tunnel.sendall(connect_to_bp.encode()) + + resp = _read_http_header(tunnel) + if resp is None or b"200" not in resp.split(b"\r\n")[0]: + client.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + return + + # 4. 现在 tunnel 是到后端代理的连接 + if first_line.upper().startswith("CONNECT"): + # CONNECT 请求:注入认证后转发给后端代理 + target = first_line.split()[1] # host:port + fwd = ( + f"CONNECT {target} HTTP/1.1\r\n" + f"Host: {target}\r\n" + f"{proxy_auth}" + f"\r\n" + ) + tunnel.sendall(fwd.encode()) + + bp_resp = _read_http_header(tunnel) + if bp_resp is None: + client.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + return + client.sendall(bp_resp) + _pipe(client, tunnel) + else: + # 普通 HTTP 代理请求:注入认证头后转发 + if proxy_auth: + # 在请求头中插入 Proxy-Authorization + parts = header.split(b"\r\n", 1) + header = parts[0] + b"\r\n" + proxy_auth.encode() + parts[1] + tunnel.sendall(header) + _pipe(client, tunnel) + + except Exception as e: + logger.debug("代理链连接异常: %s", e) + try: + client.sendall(b"HTTP/1.1 502 Bad Gateway\r\n\r\n") + except Exception: + pass + finally: + _safe_close(client) + _safe_close(tunnel) + + +def _read_http_header(sock, timeout=30) -> bytes | None: + """读取完整的 HTTP 响应头(直到 \\r\\n\\r\\n)""" + sock.settimeout(timeout) + buf = b"" + try: + while b"\r\n\r\n" not in buf: + chunk = sock.recv(8192) + if not chunk: + return None + buf += chunk + except socket.timeout: + return None + finally: + sock.settimeout(None) + return buf + + +def _pipe(sock1, sock2): + """双向数据管道""" + pair = [sock1, sock2] + try: + while True: + readable, _, errored = select.select(pair, [], pair, 120) + if errored or not readable: + break + for s in readable: + data = s.recv(65536) + if not data: + return + other = sock2 if s is sock1 else sock1 + other.sendall(data) + except Exception: + pass + + +def _safe_close(sock): + if sock: + try: + sock.close() + except Exception: + pass diff --git a/services/turnstile-solver/Dockerfile b/services/turnstile-solver/Dockerfile new file mode 100644 index 0000000..a38e07f --- /dev/null +++ b/services/turnstile-solver/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.12-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xvfb xauth dbus-x11 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 安装 Camoufox 浏览器二进制 +RUN python -c "from camoufox.utils import install; install()" + +COPY . . + +ENV DISPLAY=:99 + +# 启动 Xvfb + Solver +CMD ["sh", "-c", "Xvfb :99 -screen 0 1280x720x24 -nolisten tcp & sleep 1 && python api_solver.py --browser_type camoufox --no-headless --thread ${SOLVER_THREADS:-2} --host 0.0.0.0 --port ${SOLVER_PORT:-5072}"] + +EXPOSE 5072 diff --git a/services/turnstile-solver/api_solver.py b/services/turnstile-solver/api_solver.py new file mode 100644 index 0000000..92e2304 --- /dev/null +++ b/services/turnstile-solver/api_solver.py @@ -0,0 +1,1426 @@ +import os +import sys +import time +import uuid +import random +import logging +import asyncio +import urllib.request +import warnings +from typing import Optional, Union +import argparse + +# 代理链支持:环境变量代理(Clash)→ 后端代理 → 目标 +try: + from proxy_chain import chain_proxy +except ImportError: + def chain_proxy(p): return p or "" + +# 抑制 requests 库 verify=False 产生的 InsecureRequestWarning 噪音 +warnings.filterwarnings("ignore", message="Unverified HTTPS request") +from quart import Quart, request, jsonify +try: + from camoufox.async_api import AsyncCamoufox +except ImportError: + AsyncCamoufox = None +from patchright.async_api import async_playwright +from db_results import init_db, save_result, load_result, cleanup_old_results +from browser_configs import browser_config +from rich.console import Console +from rich.panel import Panel +from rich.text import Text +from rich.align import Align +from rich import box + + + +COLORS = { + 'MAGENTA': '\033[35m', + 'BLUE': '\033[34m', + 'GREEN': '\033[32m', + 'YELLOW': '\033[33m', + 'RED': '\033[31m', + 'RESET': '\033[0m', +} + + +class CustomLogger(logging.Logger): + @staticmethod + def format_message(level, color, message): + timestamp = time.strftime('%H:%M:%S') + return f"[{timestamp}] [{COLORS.get(color)}{level}{COLORS.get('RESET')}] -> {message}" + + def debug(self, message, *args, **kwargs): + super().debug(self.format_message('DEBUG', 'MAGENTA', message), *args, **kwargs) + + def info(self, message, *args, **kwargs): + super().info(self.format_message('INFO', 'BLUE', message), *args, **kwargs) + + def success(self, message, *args, **kwargs): + super().info(self.format_message('SUCCESS', 'GREEN', message), *args, **kwargs) + + def warning(self, message, *args, **kwargs): + super().warning(self.format_message('WARNING', 'YELLOW', message), *args, **kwargs) + + def error(self, message, *args, **kwargs): + super().error(self.format_message('ERROR', 'RED', message), *args, **kwargs) + + +logging.setLoggerClass(CustomLogger) +logger = logging.getLogger("TurnstileAPIServer") +logger.setLevel(logging.DEBUG) +handler = logging.StreamHandler(sys.stdout) +logger.addHandler(handler) + + +class TurnstileAPIServer: + + def __init__(self, headless: bool, useragent: Optional[str], debug: bool, browser_type: str, thread: int, proxy_support: bool, use_random_config: bool = False, browser_name: Optional[str] = None, browser_version: Optional[str] = None, default_proxy: Optional[str] = None): + self.app = Quart(__name__) + self.debug = debug + self.browser_type = browser_type + self.headless = headless + self.thread_count = thread + self.proxy_support = proxy_support + self._raw_default_proxy = default_proxy or "" # 保留原始值用于 pool 匹配比较 + self.default_proxy = chain_proxy(default_proxy) if default_proxy else None # pool 浏览器启动时的默认代理 + self.browser_pool = asyncio.Queue() + self.use_random_config = use_random_config + self.browser_name = browser_name + self.browser_version = browser_version + self.console = Console() + # 服务端缓存的 Turnstile API JS 内容(绕过代理压缩问题) + self._turnstile_js_cache: Optional[str] = None + + # Initialize useragent and sec_ch_ua attributes + self.useragent = useragent + self.sec_ch_ua = None + + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + if browser_name and browser_version: + config = browser_config.get_browser_config(browser_name, browser_version) + if config: + useragent, sec_ch_ua = config + self.useragent = useragent + self.sec_ch_ua = sec_ch_ua + elif useragent: + self.useragent = useragent + else: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + self.browser_name = browser + self.browser_version = version + self.useragent = useragent + self.sec_ch_ua = sec_ch_ua + + self.browser_args = [] + if self.useragent: + self.browser_args.append(f"--user-agent={self.useragent}") + + self._setup_routes() + + def display_welcome(self): + """Displays welcome screen with logo.""" + self.console.clear() + + combined_text = Text() + combined_text.append("\n📢 Channel: ", style="bold white") + combined_text.append("https://t.me/D3_vin", style="cyan") + combined_text.append("\n💬 Chat: ", style="bold white") + combined_text.append("https://t.me/D3vin_chat", style="cyan") + combined_text.append("\n📁 GitHub: ", style="bold white") + combined_text.append("https://github.com/D3-vin", style="cyan") + combined_text.append("\n📁 Version: ", style="bold white") + combined_text.append("1.2b", style="green") + combined_text.append("\n") + + info_panel = Panel( + Align.left(combined_text), + title="[bold blue]Turnstile Solver[/bold blue]", + subtitle="[bold magenta]Dev by D3vin[/bold magenta]", + box=box.ROUNDED, + border_style="bright_blue", + padding=(0, 1), + width=50 + ) + + self.console.print(info_panel) + self.console.print() + + + + + def _setup_routes(self) -> None: + """Set up the application routes.""" + self.app.before_serving(self._startup) + self.app.route('/turnstile', methods=['GET'])(self.process_turnstile) + self.app.route('/result', methods=['GET'])(self.get_result) + self.app.route('/health')(self.health) + self.app.route('/')(self.index) + + + async def _startup(self) -> None: + """Initialize the browser and page pool on startup.""" + self.display_welcome() + logger.info("Starting browser initialization") + try: + await init_db() + await self._initialize_browser() + + # Запускаем периодическую очистку старых результатов + asyncio.create_task(self._periodic_cleanup()) + + except Exception as e: + logger.error(f"Failed to initialize browser: {str(e)}") + raise + + async def _initialize_browser(self) -> None: + """Initialize the browser and create the page pool.""" + playwright = None + camoufox = None + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + playwright = await async_playwright().start() + elif self.browser_type == "camoufox": + camoufox_kwargs = {"headless": self.headless} + # pool 浏览器直接带代理启动(Firefox 不支持 context 级代理) + proxy_to_use = self.default_proxy + + # 如果没有 default_proxy,尝试使用环境变量代理(HTTPS_PROXY/HTTP_PROXY) + if not proxy_to_use: + for var in ("HTTPS_PROXY", "HTTP_PROXY", "https_proxy", "http_proxy"): + env_proxy = os.environ.get(var, "").strip() + if env_proxy: + proxy_to_use = env_proxy + logger.info(f"Using proxy from environment variable {var}: {proxy_to_use}") + break + + if proxy_to_use: + proxy_config = {"server": proxy_to_use} + if '@' in proxy_to_use: + scheme_part, auth_part = proxy_to_use.split('://') + auth, address = auth_part.split('@') + username, password = auth.split(':') + ip, port = address.split(':') + proxy_config = {"server": f"{scheme_part}://{ip}:{port}", "username": username, "password": password} + camoufox_kwargs["proxy"] = proxy_config + # 注意:不设置 os/humanize/window,使用极简参数(修复 CAPTCHA_FAIL) + # - os="windows" 与 Linux 容器矛盾,导致指纹不一致 + # - humanize=True 增加延迟,导致 Turnstile 超时 + # - window=(1280, 720) 与 Xvfb 虚拟屏幕冲突 + camoufox_kwargs["geoip"] = True + logger.info(f"Camoufox pool with browser-level proxy: {proxy_to_use}") + camoufox = AsyncCamoufox(**camoufox_kwargs) + + browser_configs = [] + for _ in range(self.thread_count): + if self.browser_type in ['chromium', 'chrome', 'msedge']: + if self.use_random_config: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + elif self.browser_name and self.browser_version: + config = browser_config.get_browser_config(self.browser_name, self.browser_version) + if config: + useragent, sec_ch_ua = config + browser = self.browser_name + version = self.browser_version + else: + browser, version, useragent, sec_ch_ua = browser_config.get_random_browser_config(self.browser_type) + else: + browser = getattr(self, 'browser_name', 'custom') + version = getattr(self, 'browser_version', 'custom') + useragent = self.useragent + sec_ch_ua = getattr(self, 'sec_ch_ua', '') + else: + # Для camoufox и других браузеров используем значения по умолчанию + browser = self.browser_type + version = 'custom' + useragent = self.useragent + sec_ch_ua = getattr(self, 'sec_ch_ua', '') + + + browser_configs.append({ + 'browser_name': browser, + 'browser_version': version, + 'useragent': useragent, + 'sec_ch_ua': sec_ch_ua + }) + + for i in range(self.thread_count): + config = browser_configs[i] + + # Docker 容器必需参数 + # --disable-features=ThirdPartyCookiePhaseout: Chrome 120+ 默认阻断第三方 Cookie, + # Turnstile iframe (challenges.cloudflare.com) 需要跨域 Cookie, + # 不加此 flag 会报 "Please enable cookies." + browser_args = [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-features=ThirdPartyCookiePhaseout", + ] + if config['useragent']: + browser_args.append(f"--user-agent={config['useragent']}") + + browser = None + if self.browser_type in ['chromium', 'chrome', 'msedge'] and playwright: + browser = await playwright.chromium.launch( + channel=self.browser_type if self.browser_type != 'chromium' else None, + headless=self.headless, + args=browser_args + ) + elif self.browser_type == "camoufox" and camoufox: + browser = await camoufox.start() + + if browser: + await self.browser_pool.put((i+1, browser, config)) + + if self.debug: + logger.info(f"Browser {i + 1} initialized successfully with {config['browser_name']} {config['browser_version']}") + + logger.info(f"Browser pool initialized with {self.browser_pool.qsize()} browsers") + + if self.use_random_config: + logger.info(f"Each browser in pool received random configuration") + elif self.browser_name and self.browser_version: + logger.info(f"All browsers using configuration: {self.browser_name} {self.browser_version}") + else: + logger.info("Using custom configuration") + + if self.debug: + for i, config in enumerate(browser_configs): + logger.debug(f"Browser {i+1} config: {config['browser_name']} {config['browser_version']}") + logger.debug(f"Browser {i+1} User-Agent: {config['useragent']}") + logger.debug(f"Browser {i+1} Sec-CH-UA: {config['sec_ch_ua']}") + + async def _periodic_cleanup(self): + """Periodic cleanup of old results every hour""" + while True: + try: + await asyncio.sleep(3600) + deleted_count = await cleanup_old_results(days_old=7) + if deleted_count > 0: + logger.info(f"Cleaned up {deleted_count} old results") + except Exception as e: + logger.error(f"Error during periodic cleanup: {e}") + + async def _antishadow_inject(self, page): + await page.add_init_script(""" + (function() { + const originalAttachShadow = Element.prototype.attachShadow; + Element.prototype.attachShadow = function(init) { + const shadow = originalAttachShadow.call(this, init); + if (init.mode === 'closed') { + window.__lastClosedShadowRoot = shadow; + } + return shadow; + }; + })(); + """) + + + + async def _optimized_route_handler(self, route): + """Оптимизированный обработчик маршрутов для экономии ресурсов.""" + url = route.request.url + resource_type = route.request.resource_type + + allowed_types = {'document', 'script', 'xhr', 'fetch'} + + allowed_domains = [ + 'challenges.cloudflare.com', + 'static.cloudflareinsights.com', + 'cloudflare.com' + ] + + if resource_type in allowed_types: + await route.continue_() + elif any(domain in url for domain in allowed_domains): + await route.continue_() + else: + await route.abort() + + async def _get_turnstile_js(self) -> Optional[str]: + """服务端拉取 Turnstile API JS(不走代理),缓存复用。 + + 绕过代理丢失 Content-Encoding 导致浏览器收到压缩乱码的问题。 + 返回 JS 文本,供 add_script_tag(content=...) 注入。 + """ + if self._turnstile_js_cache: + return self._turnstile_js_cache + + api_url = 'https://challenges.cloudflare.com/turnstile/v0/api.js' + try: + def _fetch() -> str: + req = urllib.request.Request(api_url, headers={ + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': '*/*', + 'Accept-Encoding': 'identity', # 禁止压缩,直接拿明文 + }) + with urllib.request.urlopen(req, timeout=15) as resp: + return resp.read().decode('utf-8') + + loop = asyncio.get_event_loop() + content = await loop.run_in_executor(None, _fetch) + # 简单校验:包含 turnstile 字样才算有效 + if content and 'turnstile' in content.lower(): + self._turnstile_js_cache = content + logger.info(f"Turnstile API JS fetched server-side: {len(content)} bytes") + return content + else: + logger.warning("Turnstile API JS fetched but content looks invalid") + except Exception as e: + logger.warning(f"Server-side Turnstile JS fetch failed: {e}") + return None + + async def _cf_challenge_proxy_handler(self, route, override_proxy: Optional[str] = None): + """拦截 CF challenge 相关请求,通过 Python 代理转发。 + 如果配置了代理(SOCKS5),则用相同代理转发,保证 CF challenge 请求与 + 浏览器来自同一 IP,避免 Cloudflare 因 IP 不一致拒绝 Turnstile 验证。 + override_proxy:临时 Camoufox 传入实际 proxy,优先于 self.default_proxy。 + 阻塞 IO 必须在 executor 里执行,不能直接调用,否则卡死 asyncio 事件循环。 + """ + req_url = route.request.url + method = route.request.method + skip_headers = {'host', 'connection', 'keep-alive', 'transfer-encoding', + 'te', 'trailer', 'upgrade', 'proxy-authorization', 'proxy-authenticate'} + headers = {k: v for k, v in route.request.headers.items() + if k.lower() not in skip_headers} + post_data = route.request.post_data_buffer # bytes | None + + try: + proxy_url = override_proxy if override_proxy is not None else self.default_proxy # 与浏览器相同的代理,保证 IP 一致 + + def _fetch(): + import requests as req_lib + session = req_lib.Session() + if proxy_url: + session.proxies = {'http': proxy_url, 'https': proxy_url} + resp = session.request( + method=method, + url=req_url, + headers=headers, + data=post_data, + timeout=30, + allow_redirects=True, + verify=False, + ) + resp_headers = {k: v for k, v in resp.headers.items() + if k.lower() not in {'transfer-encoding', 'connection'}} + return resp.status_code, resp_headers, resp.content + + loop = asyncio.get_event_loop() + status, resp_headers, content = await loop.run_in_executor(None, _fetch) + + if self.debug: + logger.debug(f"CF proxy [{method}] {req_url[:80]} → {status} ({len(content)}B)") + await route.fulfill(status=status, headers=resp_headers, body=content) + except Exception as e: + if self.debug: + logger.debug(f"CF proxy error [{method}] {req_url[:80]}: {e}") + try: + await route.fallback() + except Exception: + await route.abort() + + async def _block_rendering(self, page): + """Блокировка рендеринга для экономии ресурсов""" + await page.route("**/*", self._optimized_route_handler) + + async def _unblock_rendering(self, page): + """Разблокировка рендеринга""" + await page.unroute("**/*", self._optimized_route_handler) + + async def _find_turnstile_elements(self, page, index: int): + """Умная проверка всех возможных Turnstile элементов""" + selectors = [ + '.cf-turnstile', + '[data-sitekey]', + 'iframe[src*="turnstile"]', + 'iframe[title*="widget"]', + 'div[id*="turnstile"]', + 'div[class*="turnstile"]' + ] + + elements = [] + for selector in selectors: + try: + # Безопасная проверка count() + try: + count = await page.locator(selector).count() + except Exception: + # Если count() дает ошибку, пропускаем этот селектор + continue + + if count > 0: + elements.append((selector, count)) + if self.debug: + logger.debug(f"Browser {index}: Found {count} elements with selector '{selector}'") + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Selector '{selector}' failed: {str(e)}") + continue + + return elements + + async def _find_and_click_checkbox(self, page, index: int): + """Найти и кликнуть по чекбоксу Turnstile CAPTCHA внутри iframe""" + try: + # Пробуем разные селекторы iframe с защитой от ошибок + iframe_selectors = [ + 'iframe[src*="challenges.cloudflare.com"]', + 'iframe[src*="turnstile"]', + 'iframe[title*="widget"]' + ] + + iframe_locator = None + for selector in iframe_selectors: + try: + test_locator = page.locator(selector).first + # Безопасная проверка count для iframe + try: + iframe_count = await test_locator.count() + except Exception: + iframe_count = 0 + + if iframe_count > 0: + iframe_locator = test_locator + if self.debug: + logger.debug(f"Browser {index}: Found Turnstile iframe with selector: {selector}") + break + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe selector '{selector}' failed: {str(e)}") + continue + + if iframe_locator: + try: + # Получаем frame из iframe + iframe_element = await iframe_locator.element_handle() + frame = await iframe_element.content_frame() + + if frame: + # Ищем чекбокс внутри iframe + checkbox_selectors = [ + 'input[type="checkbox"]', + '.cb-lb input[type="checkbox"]', + 'label input[type="checkbox"]' + ] + + for selector in checkbox_selectors: + try: + # Полностью избегаем locator.count() в iframe - используем альтернативный подход + try: + # Пробуем кликнуть напрямую без count проверки + checkbox = frame.locator(selector).first + await checkbox.click(timeout=2000) + if self.debug: + logger.debug(f"Browser {index}: Successfully clicked checkbox in iframe with selector '{selector}'") + return True + except Exception as click_e: + # Если прямой клик не сработал, записываем в debug но не падаем + if self.debug: + logger.debug(f"Browser {index}: Direct checkbox click failed for '{selector}': {str(click_e)}") + continue + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe checkbox selector '{selector}' failed: {str(e)}") + continue + + # Если нашли iframe, но не смогли кликнуть чекбокс, пробуем клик по iframe + try: + if self.debug: + logger.debug(f"Browser {index}: Trying to click iframe directly as fallback") + await iframe_locator.click(timeout=1000) + return True + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Iframe direct click failed: {str(e)}") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Failed to access iframe content: {str(e)}") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: General iframe search failed: {str(e)}") + + return False + + async def _try_click_strategies(self, page, index: int): + strategies = [ + ('checkbox_click', lambda: self._find_and_click_checkbox(page, index)), + ('direct_widget', lambda: self._safe_click(page, '.cf-turnstile', index)), + ('iframe_click', lambda: self._safe_click(page, 'iframe[src*="turnstile"]', index)), + ('js_click', lambda: page.evaluate("document.querySelector('.cf-turnstile')?.click()")), + ('sitekey_attr', lambda: self._safe_click(page, '[data-sitekey]', index)), + ('any_turnstile', lambda: self._safe_click(page, '*[class*="turnstile"]', index)), + ('xpath_click', lambda: self._safe_click(page, "//div[@class='cf-turnstile']", index)) + ] + + for strategy_name, strategy_func in strategies: + try: + result = await strategy_func() + if result is True or result is None: # None означает успех для большинства стратегий + if self.debug: + logger.debug(f"Browser {index}: Click strategy '{strategy_name}' succeeded") + return True + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Click strategy '{strategy_name}' failed: {str(e)}") + continue + + return False + + async def _safe_click(self, page, selector: str, index: int): + """Полностью безопасный клик с максимальной защитой от ошибок""" + try: + # Пробуем кликнуть напрямую без count() проверки + locator = page.locator(selector).first + await locator.click(timeout=1000) + return True + except Exception as e: + # Логируем ошибку только в debug режиме + if self.debug and "Can't query n-th element" not in str(e): + logger.debug(f"Browser {index}: Safe click failed for '{selector}': {str(e)}") + return False + + async def _inject_captcha_directly(self, page, websiteKey: str, action: str = '', cdata: str = '', index: int = 0): + """直接向目标页面注入 Turnstile widget(比 overlay 成功率高得多)""" + script = f""" + // 移除旧 widget + document.querySelectorAll('.cf-turnstile').forEach(el => el.remove()); + document.querySelectorAll('[data-sitekey]').forEach(el => el.remove()); + + const captchaDiv = document.createElement('div'); + captchaDiv.className = 'cf-turnstile'; + captchaDiv.setAttribute('data-sitekey', '{websiteKey}'); + captchaDiv.setAttribute('data-callback', 'onTurnstileCallback'); + {f'captchaDiv.setAttribute("data-action", "{action}");' if action else ''} + {f'captchaDiv.setAttribute("data-cdata", "{cdata}");' if cdata else ''} + captchaDiv.style.position = 'fixed'; + captchaDiv.style.top = '20px'; + captchaDiv.style.left = '20px'; + captchaDiv.style.zIndex = '9999'; + captchaDiv.style.backgroundColor = 'white'; + captchaDiv.style.padding = '15px'; + captchaDiv.style.border = '2px solid #0f79af'; + captchaDiv.style.borderRadius = '8px'; + document.body.appendChild(captchaDiv); + + const setToken = (token) => {{ + let inp = document.querySelector('input[name="cf-turnstile-response"]'); + if (!inp) {{ + inp = document.createElement('input'); + inp.type = 'hidden'; + inp.name = 'cf-turnstile-response'; + document.body.appendChild(inp); + }} + inp.value = token; + }}; + + window.onTurnstileCallback = setToken; + + const renderWidget = () => {{ + if (window.turnstile && window.turnstile.render) {{ + try {{ + window.turnstile.render(captchaDiv, {{ + sitekey: '{websiteKey}', + {f'action: "{action}",' if action else ''} + {f'cdata: "{cdata}",' if cdata else ''} + callback: setToken, + 'error-callback': function(e) {{ console.log('Turnstile error:', e); }} + }}); + }} catch(e) {{ console.log('render error:', e); }} + }} + }}; + + if (window.turnstile) {{ + renderWidget(); + }} else {{ + const s = document.createElement('script'); + s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js'; + s.async = true; + s.defer = true; + s.onload = () => setTimeout(renderWidget, 500); + document.head.appendChild(s); + }} + """ + await page.evaluate(script) + if self.debug: + logger.debug(f"Browser {index}: Injected Turnstile widget directly with sitekey: {websiteKey}") + + async def _solve_turnstile(self, task_id: str, url: str, sitekey: str, action: Optional[str] = None, cdata: Optional[str] = None, override_proxy: str = ""): + """Solve the Turnstile challenge.""" + proxy = None + temp_browser = None # Camoufox + 代理时的临时浏览器 + temp_camoufox_inst = None # 保留引用用于正确关闭 Playwright + + index, browser, browser_config = await self.browser_pool.get() + + try: + if hasattr(browser, 'is_connected') and not browser.is_connected(): + if self.debug: + logger.warning(f"Browser {index}: Browser disconnected, skipping") + await self.browser_pool.put((index, browser, browser_config)) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": 0}) + return + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Cannot check browser state: {str(e)}") + + # 优先使用 API 传入的代理,其次读 proxies.txt + if override_proxy: + proxy = override_proxy + logger.info(f"Browser {index}: Using override proxy: {proxy}") + elif self.proxy_support: + proxy_file_path = os.path.join(os.getcwd(), "proxies.txt") + + try: + with open(proxy_file_path) as proxy_file: + proxies = [line.strip() for line in proxy_file if line.strip()] + + proxy = random.choice(proxies) if proxies else None + + if self.debug and proxy: + logger.debug(f"Browser {index}: Selected proxy: {proxy}") + elif self.debug and not proxy: + logger.debug(f"Browser {index}: No proxies available") + + except FileNotFoundError: + logger.warning(f"Proxy file not found: {proxy_file_path}") + proxy = None + except Exception as e: + logger.error(f"Error reading proxy file: {str(e)}") + proxy = None + + # Camoufox (Firefox 内核) 不支持 context 级代理,必须在浏览器启动时设置 + # 如果 override_proxy 和 pool 默认代理一致,直接用 pool 浏览器(长期存活,信任度高) + # 否则创建临时 Camoufox 实例,用完即关 + # 注意:用原始代理值比较,避免 chain_proxy 转换后端口不同导致误判 + pool_has_proxy = bool(self._raw_default_proxy) + proxy_matches_pool = pool_has_proxy and proxy and proxy.strip() == self._raw_default_proxy.strip() + + # 代理链:通过 Clash 链到后端代理,确保国内环境能连通海外代理 + if proxy: + proxy = chain_proxy(proxy) + + if proxy_matches_pool: + logger.info(f"Browser {index}: Proxy matches pool default, using pool browser directly (no temp browser)") + + if proxy and self.browser_type == "camoufox" and not proxy_matches_pool: + try: + proxy_config = {"server": proxy} + if '@' in proxy: + scheme_part, auth_part = proxy.split('://') + auth, address = auth_part.split('@') + username, password = auth.split(':') + ip, port = address.split(':') + proxy_config = {"server": f"{scheme_part}://{ip}:{port}", "username": username, "password": password} + camoufox_inst = AsyncCamoufox( + headless=self.headless, + proxy=proxy_config, + geoip=True, + # 注意:不设置 os/humanize/window,使用极简参数(修复 CAPTCHA_FAIL) + ) + temp_browser = await camoufox_inst.start() + temp_camoufox_inst = camoufox_inst # 保留引用用于 __aexit__ 清理 + logger.info(f"Browser {index}: Temp Camoufox created with browser-level proxy: {proxy}") + except Exception as e: + logger.error(f"Browser {index}: Failed to create temp Camoufox with proxy: {e}") + temp_browser = None + + # 选择实际使用的浏览器 + actual_browser = temp_browser if temp_browser else browser + + # 根据 proxy 创建浏览器 context + if proxy and not temp_browser: + if '@' in proxy: + try: + scheme_part, auth_part = proxy.split('://') + auth, address = auth_part.split('@') + username, password = auth.split(':') + ip, port = address.split(':') + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {scheme_part}://{ip}:{port} (auth: {username}:***)") + context_options = { + "proxy": { + "server": f"{scheme_part}://{ip}:{port}", + "username": username, + "password": password + }, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + except ValueError: + raise ValueError(f"Invalid proxy format: {proxy}") + else: + parts = proxy.split(':') + if len(parts) == 5: + proxy_scheme, proxy_ip, proxy_port, proxy_user, proxy_pass = parts + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy_scheme}://{proxy_ip}:{proxy_port} (auth: {proxy_user}:***)") + context_options = { + "proxy": { + "server": f"{proxy_scheme}://{proxy_ip}:{proxy_port}", + "username": proxy_user, + "password": proxy_pass + }, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + elif len(parts) == 3: + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy}") + context_options = { + "proxy": {"server": f"{proxy}"}, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + elif '://' in proxy: + # 处理 socks5://ip:port 或 http://ip:port 格式 + if self.debug: + logger.debug(f"Browser {index}: Creating context with proxy {proxy}") + context_options = { + "proxy": {"server": proxy}, + "user_agent": browser_config['useragent'] + } + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + context = await actual_browser.new_context(**context_options) + else: + raise ValueError(f"Invalid proxy format: {proxy}") + else: + # temp_browser 存在时走这里:代理已在浏览器级别设置,context 不需要再设代理 + # 无代理时也走这里 + if self.debug: + if temp_browser: + logger.debug(f"Browser {index}: Creating context on temp Camoufox (proxy at browser level)") + else: + logger.debug(f"Browser {index}: Creating context without proxy") + context_options = {"user_agent": browser_config['useragent']} + + if browser_config['sec_ch_ua'] and browser_config['sec_ch_ua'].strip(): + context_options['extra_http_headers'] = { + 'sec-ch-ua': browser_config['sec_ch_ua'] + } + + # 注意:不使用 bypass_csp,因为它会干扰 Turnstile iframe 通信(修复 CAPTCHA_FAIL) + # # 原注释: + # # Turnstile managed mode 动态创建 iframe(src=challenges.cloudflare.com) + # # 可能被页面 CSP frame-src 拦截,导致 src 始终为空、callback 不触发 + # context_options['bypass_csp'] = True + + context = await actual_browser.new_context(**context_options) + + page = await context.new_page() + + await self._antishadow_inject(page) + + # 注意:强制设为 False,走 inject 路径而非 natural solve(修复 CAPTCHA_FAIL) + # natural solve 模式在数据中心 IP 上不会自动通过,会导致超时 + is_camoufox = False + + # Chrome/Camoufox 均不拦截资源: + # 资源拦截(CSS/image/font)会让 Cloudflare 检测到页面渲染异常, + # 导致 managed mode Turnstile iframe src 始终为空,callback 不触发。 + # 原始版本从未启用 _block_rendering,此处移除以对齐原始行为。 + + if is_camoufox: + # Camoufox (Firefox): 只隐藏 webdriver 标志 + # 不注入 window.chrome — Firefox 没有此 API,注入反而暴露指纹不一致 + await page.add_init_script(""" +Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, +}); +""") + else: + # Chromium: 隐藏 webdriver + 补全 window.chrome + await page.add_init_script(""" +Object.defineProperty(navigator, 'webdriver', { + get: () => undefined, +}); + +window.chrome = { + runtime: {}, + loadTimes: function() {}, + csi: function() {}, +}; +""") + + if self.browser_type in ['chromium', 'chrome', 'msedge']: + await page.set_viewport_size({"width": 500, "height": 100}) + if self.debug: + logger.debug(f"Browser {index}: Set viewport size to 500x240") + + start_time = time.time() + + try: + if self.debug: + logger.debug(f"Browser {index}: Starting Turnstile solve for URL: {url} with Sitekey: {sitekey} | Action: {action} | Cdata: {cdata} | Proxy: {proxy}") + + if is_camoufox: + # === Camoufox 自然求解模式 === + # 不拦截资源,让页面完整加载(含 Turnstile API 及其子资源) + # networkidle 确保 Turnstile API 完全初始化 + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Natural solve mode — full page load, no resource blocking") + + # 监听请求失败(调试用) + if self.debug: + def _on_request_failed(req): + logger.debug(f"Browser {index}: Request FAILED: {req.url[:100]} - {req.failure}") + page.on("requestfailed", _on_request_failed) + + # 代理拦截 CF challenge 相关域,绕过 SOCKS5 对这些路径的封锁,保证 IP 一致性 + # 仅在有代理时才安装:无代理时浏览器直连 CF,Python requests 的 TLS 指纹 + # 与 Camoufox 不同,会被 Cloudflare 检测到不一致而拒绝 challenge + _actual_proxy = proxy if proxy else self.default_proxy + if _actual_proxy: + async def _cf_proxy_handler_with_actual_proxy(route, _p=_actual_proxy): + await self._cf_challenge_proxy_handler(route, override_proxy=_p) + await page.route("*://challenges.cloudflare.com/**", _cf_proxy_handler_with_actual_proxy) + await page.route("*://accounts.x.ai/cdn-cgi/challenge-platform/**", _cf_proxy_handler_with_actual_proxy) + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] CF challenge proxy routes installed (proxy={_actual_proxy})") + else: + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] No proxy configured, letting browser handle CF challenges natively") + + # 中止 csp-reporting 请求:它们持续 NS_ERROR_ABORT 重试,会阻止 networkidle 触发 + async def _abort_csp_reporting(route): + await route.abort() + await page.route("*://csp-reporting.cloudflare.com/**", _abort_csp_reporting) + + # 使用 domcontentloaded 而非 networkidle,避免 csp-reporting 重试无限延迟 + await page.goto(url, wait_until='domcontentloaded', timeout=60000) + # 等待页面 JS 初始化(Turnstile widget 渲染需要时间) + await asyncio.sleep(5) + + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Page loaded (domcontentloaded+5s), checking for existing Turnstile...") + + # Phase 1: 检查页面自带的 Turnstile 是否已生成 token(自动求解 / managed 模式低风险直通) + await asyncio.sleep(3) + existing_token = await page.evaluate("""() => { + // 检查 hidden input + const inputs = document.querySelectorAll('input[name="cf-turnstile-response"]'); + for (const inp of inputs) { + if (inp.value && inp.value.length > 10) return inp.value; + } + // 检查 widget data-response 属性 + const widgets = document.querySelectorAll('.cf-turnstile[data-response]'); + for (const w of widgets) { + const resp = w.getAttribute('data-response'); + if (resp && resp.length > 10) return resp; + } + return null; + }""") + + if existing_token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: [Camoufox] Got token from page's own Turnstile — {COLORS.get('MAGENTA')}{existing_token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')}s") + await save_result(task_id, "turnstile", {"value": existing_token, "elapsed_time": elapsed_time}) + return + + # Phase 2: 页面 Turnstile 未自动完成,尝试点击 checkbox + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] No auto-token, trying to click Turnstile checkbox...") + clicked = await self._find_and_click_checkbox(page, index) + if clicked: + # 等待 token 生成 + await asyncio.sleep(3) + existing_token = await page.evaluate("""() => { + const inputs = document.querySelectorAll('input[name="cf-turnstile-response"]'); + for (const inp of inputs) { + if (inp.value && inp.value.length > 10) return inp.value; + } + return null; + }""") + if existing_token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: [Camoufox] Got token after click — {COLORS.get('MAGENTA')}{existing_token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')}s") + await save_result(task_id, "turnstile", {"value": existing_token, "elapsed_time": elapsed_time}) + return + + # Phase 3: 页面自带的 Turnstile 未成功,主动加载 Turnstile API 并渲染 + has_api = await page.evaluate("typeof window.turnstile !== 'undefined'") + if self.debug: + current_url = page.url + logger.debug(f"Browser {index}: [Camoufox] Current URL: {current_url}") + logger.debug(f"Browser {index}: [Camoufox] Turnstile API available: {has_api}") + + if not has_api: + # 诊断:从页面内 fetch Turnstile API URL,看代理返回什么 + if self.debug: + try: + fetch_diag = await page.evaluate("""async () => { + try { + const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/api.js'); + const t = await r.text(); + return {status: r.status, type: r.headers.get('content-type'), len: t.length, start: t.substring(0, 200)}; + } catch(e) { return {error: e.message}; } + }""") + logger.debug(f"Browser {index}: [Camoufox] Turnstile API fetch diag: {fetch_diag}") + except Exception as e: + logger.debug(f"Browser {index}: [Camoufox] Fetch diag failed: {e}") + + # 检查页面自带的 Turnstile script 标签 + page_scripts = await page.evaluate("""() => { + const scripts = [...document.querySelectorAll('script[src]')]; + return scripts.map(s => s.src).filter(u => u.includes('turnstile') || u.includes('challenges.cloudflare')); + }""") + logger.debug(f"Browser {index}: [Camoufox] Page Turnstile scripts: {page_scripts}") + + # 服务端拉取 Turnstile JS 并以内容方式注入,绕过代理压缩问题 + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Loading Turnstile API via server-side fetch + content injection...") + try: + js_content = await self._get_turnstile_js() + if js_content: + await page.add_script_tag(content=js_content) + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Injected Turnstile JS as content ({len(js_content)} bytes)") + else: + # 服务端拉取失败时降级用 URL 方式 + await page.add_script_tag(url='https://challenges.cloudflare.com/turnstile/v0/api.js') + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Fallback: add_script_tag with URL") + # 轮询等待 API 初始化(最多 10 秒) + for _w in range(10): + await asyncio.sleep(1) + has_api = await page.evaluate("typeof window.turnstile !== 'undefined'") + if has_api: + break + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] After injection: Turnstile API available: {has_api}") + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Script injection failed: {e}") + + if has_api: + # Turnstile API 已加载,创建固定位置可见容器(managed 模式要求 widget 在视口内) + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Calling turnstile.render() with sitekey: {sitekey}") + await page.evaluate(f"""() => {{ + // 创建专用渲染容器,position:fixed 确保始终在视口内可见 + let container = document.getElementById('_ts_solver_container'); + if (!container) {{ + container = document.createElement('div'); + container.id = '_ts_solver_container'; + container.style.cssText = 'position:fixed;top:0;left:0;z-index:999999;width:300px;height:70px;background:#fff;'; + document.body.appendChild(container); + }} + // 确保 hidden input 存在(用于读取 token) + let inp = document.getElementById('_ts_solver_input'); + if (!inp) {{ + inp = document.createElement('input'); + inp.type = 'hidden'; + inp.id = '_ts_solver_input'; + inp.name = 'cf-turnstile-response'; + document.body.appendChild(inp); + }} + const setToken = (token) => {{ inp.value = token; }}; + window._tsSolverToken = ''; + window._tsSolverCallback = setToken; + try {{ + window.turnstile.render(container, {{ + sitekey: '{sitekey}', + {'action: "' + action + '",' if action else ''} + {'cdata: "' + cdata + '",' if cdata else ''} + callback: (token) => {{ + window._tsSolverToken = token; + setToken(token); + }} + }}); + }} catch(e) {{ console.log('turnstile.render error:', e); }} + }}""") + await asyncio.sleep(3) + else: + # add_script_tag 也失败了,用旧方法 fallback + if self.debug: + logger.debug(f"Browser {index}: [Camoufox] Turnstile API still unavailable, using _inject_captcha_directly fallback") + await self._inject_captcha_directly(page, sitekey, action or '', cdata or '', index) + await asyncio.sleep(3) + + else: + # === Chromium 模式 === + if self.debug: + logger.debug(f"Browser {index}: Loading real website directly: {url}") + + await page.goto(url, wait_until='domcontentloaded', timeout=30000) + await self._inject_captcha_directly(page, sitekey, action or '', cdata or '', index) + await asyncio.sleep(2) + + locator = page.locator('input[name="cf-turnstile-response"]') + max_attempts = 30 + + for attempt in range(max_attempts): + try: + # Безопасная проверка количества элементов с токеном + try: + count = await locator.count() + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Locator count failed on attempt {attempt + 1}: {str(e)}") + count = 0 + + if count == 0: + if self.debug: + logger.debug(f"Browser {index}: No token elements found on attempt {attempt + 1}") + # 每 5 次尝试诊断一次 widget 状态 + if attempt % 5 == 0: + try: + diag = await page.evaluate("""() => { + const d = {}; + d.hasTurnstile = typeof window.turnstile !== 'undefined'; + d.cfDivs = document.querySelectorAll('.cf-turnstile').length; + d.iframes = document.querySelectorAll('iframe[src*="challenges.cloudflare"]').length; + d.inputs = document.querySelectorAll('input[name="cf-turnstile-response"]').length; + d.allIframes = document.querySelectorAll('iframe').length; + // 检查 widget 内容 + const cfDiv = document.querySelector('.cf-turnstile'); + if (cfDiv) { + d.cfDivHTML = cfDiv.innerHTML.substring(0, 200); + d.cfDivChildren = cfDiv.children.length; + } + // 检查是否有错误信息 + const errEl = document.querySelector('[class*="error"]'); + if (errEl) d.errorText = errEl.textContent.substring(0, 100); + return d; + }""") + logger.debug(f"Browser {index}: Widget diag: {diag}") + except Exception as e: + logger.debug(f"Browser {index}: Diag failed: {e}") + elif count == 1: + # Если только один элемент, проверяем его токен + try: + token = await locator.input_value(timeout=500) + if token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: Successfully solved captcha - {COLORS.get('MAGENTA')}{token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')} Seconds") + await save_result(task_id, "turnstile", {"value": token, "elapsed_time": elapsed_time}) + return + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Single token element check failed: {str(e)}") + else: + # Если несколько элементов, проверяем все по очереди + if self.debug: + logger.debug(f"Browser {index}: Found {count} token elements, checking all") + + for i in range(count): + try: + element_token = await locator.nth(i).input_value(timeout=500) + if element_token: + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: Successfully solved captcha - {COLORS.get('MAGENTA')}{element_token[:10]}{COLORS.get('RESET')} in {COLORS.get('GREEN')}{elapsed_time}{COLORS.get('RESET')} Seconds") + await save_result(task_id, "turnstile", {"value": element_token, "elapsed_time": elapsed_time}) + return + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Token element {i} check failed: {str(e)}") + continue + + # 每5次检查 window._tsSolverToken 及 iframe/container 诊断 + if self.debug and attempt % 5 == 0: + try: + diag = await page.evaluate("""() => { + const c = document.getElementById('_ts_solver_container'); + const iframes = [...document.querySelectorAll('iframe')].map(f => ({ + src: f.src.substring(0, 120), + title: f.title, + w: f.offsetWidth, h: f.offsetHeight + })); + return { + solverToken: window._tsSolverToken || '', + containerHTML: c ? c.innerHTML.substring(0, 400) : 'NOT FOUND', + containerChildren: c ? c.children.length : -1, + iframes: iframes, + frameCount: window.frames.length, + }; + }""") + logger.debug(f"Browser {index}: [Diag] attempt={attempt} {diag}") + # 如果 callback 已设置 token,直接返回 + if diag.get('solverToken'): + token = diag['solverToken'] + elapsed_time = round(time.time() - start_time, 3) + logger.success(f"Browser {index}: [Camoufox] Got token via window._tsSolverToken — {token[:10]} in {elapsed_time}s") + await save_result(task_id, "turnstile", {"value": token, "elapsed_time": elapsed_time}) + return + except Exception as diag_e: + logger.debug(f"Browser {index}: Diag failed: {diag_e}") + + # Клик стратегии только каждые 3 попытки и не сразу + if attempt > 2 and attempt % 3 == 0: + click_success = await self._try_click_strategies(page, index) + if not click_success and self.debug: + logger.debug(f"Browser {index}: All click strategies failed on attempt {attempt + 1}") + + # 自适应等待 + wait_time = min(0.5 + (attempt * 0.05), 2.0) + await asyncio.sleep(wait_time) + + if self.debug and attempt % 5 == 0: + logger.debug(f"Browser {index}: Attempt {attempt + 1}/{max_attempts} - No valid token yet") + + except Exception as e: + if self.debug: + logger.debug(f"Browser {index}: Attempt {attempt + 1} error: {str(e)}") + continue + + elapsed_time = round(time.time() - start_time, 3) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": elapsed_time}) + if self.debug: + logger.error(f"Browser {index}: Error solving Turnstile in {COLORS.get('RED')}{elapsed_time}{COLORS.get('RESET')} Seconds") + except Exception as e: + elapsed_time = round(time.time() - start_time, 3) + await save_result(task_id, "turnstile", {"value": "CAPTCHA_FAIL", "elapsed_time": elapsed_time}) + if self.debug: + logger.error(f"Browser {index}: Error solving Turnstile: {str(e)}") + finally: + if self.debug: + logger.debug(f"Browser {index}: Closing browser context and cleaning up") + + try: + await context.close() + if self.debug: + logger.debug(f"Browser {index}: Context closed successfully") + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Error closing context: {str(e)}") + + # 关闭临时 Camoufox 浏览器(代理专用实例,用完即弃) + if temp_camoufox_inst: + try: + await temp_camoufox_inst.__aexit__(None, None, None) + logger.info(f"Browser {index}: Temp Camoufox closed (browser + playwright)") + except Exception as e: + logger.warning(f"Browser {index}: Error closing temp Camoufox: {str(e)}") + elif temp_browser: + try: + await temp_browser.close() + logger.info(f"Browser {index}: Temp browser closed") + except Exception as e: + logger.warning(f"Browser {index}: Error closing temp browser: {str(e)}") + + # 池中的原始浏览器始终归还(不管是否用了 temp_browser) + try: + if hasattr(browser, 'is_connected') and browser.is_connected(): + await self.browser_pool.put((index, browser, browser_config)) + if self.debug: + logger.debug(f"Browser {index}: Browser returned to pool") + else: + if self.debug: + logger.warning(f"Browser {index}: Browser disconnected, not returning to pool") + except Exception as e: + if self.debug: + logger.warning(f"Browser {index}: Error returning browser to pool: {str(e)}") + + + + + + + async def health(self): + """健康检查端点,供 CF Worker 探活""" + return jsonify({ + "status": "ok", + "threads": self.thread_count, + "pool_available": self.browser_pool.qsize(), + }) + + async def process_turnstile(self): + """Handle the /turnstile endpoint requests.""" + url = request.args.get('url') + sitekey = request.args.get('sitekey') + action = request.args.get('action') + cdata = request.args.get('cdata') + proxy = request.args.get('proxy', '') + + if not url or not sitekey: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_WRONG_PAGEURL", + "errorDescription": "Both 'url' and 'sitekey' are required" + }), 200 + + task_id = str(uuid.uuid4()) + await save_result(task_id, "turnstile", { + "status": "CAPTCHA_NOT_READY", + "createTime": int(time.time()), + "url": url, + "sitekey": sitekey, + "action": action, + "cdata": cdata + }) + + try: + asyncio.create_task(self._solve_turnstile(task_id=task_id, url=url, sitekey=sitekey, action=action, cdata=cdata, override_proxy=proxy)) + + if self.debug: + logger.debug(f"Request completed with taskid {task_id}.") + return jsonify({ + "errorId": 0, + "taskId": task_id + }), 200 + except Exception as e: + logger.error(f"Unexpected error processing request: {str(e)}") + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_UNKNOWN", + "errorDescription": str(e) + }), 200 + + async def get_result(self): + """Return solved data""" + task_id = request.args.get('id') + + if not task_id: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_WRONG_CAPTCHA_ID", + "errorDescription": "Invalid task ID/Request parameter" + }), 200 + + result = await load_result(task_id) + if not result: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Task not found" + }), 200 + + if result == "CAPTCHA_NOT_READY" or (isinstance(result, dict) and result.get("status") == "CAPTCHA_NOT_READY"): + return jsonify({"status": "processing"}), 200 + + if isinstance(result, dict) and result.get("value") == "CAPTCHA_FAIL": + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Workers could not solve the Captcha" + }), 200 + + if isinstance(result, dict) and result.get("value") and result.get("value") != "CAPTCHA_FAIL": + return jsonify({ + "errorId": 0, + "status": "ready", + "solution": { + "token": result["value"] + } + }), 200 + else: + return jsonify({ + "errorId": 1, + "errorCode": "ERROR_CAPTCHA_UNSOLVABLE", + "errorDescription": "Workers could not solve the Captcha" + }), 200 + + + + @staticmethod + async def index(): + """Serve the API documentation page.""" + return """ + + + + + + Model Inference API + + +

Model Inference API

+
+
Model
all-MiniLM-L6-v2
+
Task
sentence-similarity
+
Parameters
22M
+
Status
Ready
+
+
+
Endpoints
+
POST /predict — Run inference
+
GET /health — Health check
+
+
Powered by ONNX Runtime
+ + """ + + +def parse_args(): + """Parse command-line arguments.""" + parser = argparse.ArgumentParser(description="API Server") + + parser.add_argument('--no-headless', action='store_true', help='Run the browser with GUI (disable headless mode). By default, headless mode is enabled.') + parser.add_argument('--useragent', type=str, help='User-Agent string (if not specified, random configuration is used)') + parser.add_argument('--debug', action='store_true', help='Enable or disable debug mode for additional logging and troubleshooting information (default: False)') + parser.add_argument('--browser_type', type=str, default='chromium', help='Specify the browser type for the solver. Supported options: chromium, chrome, msedge, camoufox (default: chromium)') + parser.add_argument('--thread', type=int, default=4, help='Set the number of browser threads to use for multi-threaded mode. Increasing this will speed up execution but requires more resources (default: 1)') + parser.add_argument('--proxy', action='store_true', help='Enable proxy support for the solver (Default: False)') + parser.add_argument('--random', action='store_true', help='Use random User-Agent and Sec-CH-UA configuration from pool') + parser.add_argument('--browser', type=str, help='Specify browser name to use (e.g., chrome, firefox)') + parser.add_argument('--version', type=str, help='Specify browser version to use (e.g., 139, 141)') + parser.add_argument('--host', type=str, default='0.0.0.0', help='Specify the IP address where the API solver runs. (Default: 127.0.0.1)') + parser.add_argument('--port', type=str, default='5072', help='Set the port for the API solver to listen on. (Default: 5072)') + parser.add_argument('--default-proxy', type=str, default='', help='Default proxy for Camoufox pool browsers (e.g., socks5://127.0.0.1:7890). Applied at browser launch level.') + return parser.parse_args() + + +def create_app(headless: bool, useragent: str, debug: bool, browser_type: str, thread: int, proxy_support: bool, use_random_config: bool, browser_name: str, browser_version: str, default_proxy: str = '') -> Quart: + server = TurnstileAPIServer(headless=headless, useragent=useragent, debug=debug, browser_type=browser_type, thread=thread, proxy_support=proxy_support, use_random_config=use_random_config, browser_name=browser_name, browser_version=browser_version, default_proxy=default_proxy or None) + return server.app + + +if __name__ == '__main__': + args = parse_args() + browser_types = [ + 'chromium', + 'chrome', + 'msedge', + 'camoufox', + ] + if args.browser_type not in browser_types: + logger.error(f"Unknown browser type: {COLORS.get('RED')}{args.browser_type}{COLORS.get('RESET')} Available browser types: {browser_types}") + else: + app = create_app( + headless=not args.no_headless, + debug=args.debug, + useragent=args.useragent, + browser_type=args.browser_type, + thread=args.thread, + proxy_support=args.proxy, + use_random_config=args.random, + browser_name=args.browser, + browser_version=args.version, + default_proxy=args.default_proxy + ) + app.run(host=args.host, port=int(args.port)) diff --git a/services/turnstile-solver/browser_configs.py b/services/turnstile-solver/browser_configs.py new file mode 100644 index 0000000..23631b5 --- /dev/null +++ b/services/turnstile-solver/browser_configs.py @@ -0,0 +1,41 @@ +import random + +# Chrome 版本池 — 保持与最新稳定版同步,Turnstile 会校验 UA 真实性 +_CHROME_VERSIONS = [ + {"ver": "131.0.0.0", "brand_ver": "131"}, + {"ver": "132.0.0.0", "brand_ver": "132"}, + {"ver": "133.0.0.0", "brand_ver": "133"}, + {"ver": "134.0.0.0", "brand_ver": "134"}, +] + +# Sec-CH-UA 模板 — 匹配 Chromium 131+ 的真实格式 +_SEC_CH_UA_TEMPLATE = ( + '"Chromium";v="{major}", "Google Chrome";v="{major}", "Not?A_Brand";v="99"' +) + + +class browser_config: + @staticmethod + def get_random_browser_config(browser_type): + """返回: 浏览器名, 版本, User-Agent, Sec-CH-UA""" + entry = random.choice(_CHROME_VERSIONS) + ver = entry["ver"] + major = entry["brand_ver"] + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + f"Chrome/{ver} Safari/537.36" + ) + sec_ch_ua = _SEC_CH_UA_TEMPLATE.format(major=major) + return "chrome", ver, ua, sec_ch_ua + + @staticmethod + def get_browser_config(name, version): + major = version.split(".")[0] if "." in version else version + ua = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + f"Chrome/{version} Safari/537.36" + ) + sec_ch_ua = _SEC_CH_UA_TEMPLATE.format(major=major) + return ua, sec_ch_ua \ No newline at end of file diff --git a/services/turnstile-solver/db_results.py b/services/turnstile-solver/db_results.py new file mode 100644 index 0000000..7f5c044 --- /dev/null +++ b/services/turnstile-solver/db_results.py @@ -0,0 +1,27 @@ +import time +import asyncio + +# 内存数据库,用于临时存储验证码结果 +results_db = {} + +async def init_db(): + print("[系统] 结果数据库初始化成功 (内存模式)") + +async def save_result(task_id, task_type, data): + # 存储结果,如果 data 是字典则存入,否则构造字典 + results_db[task_id] = data + print(f"[系统] 任务 {task_id} 状态更新: {data.get('value', '正在处理')}") + +async def load_result(task_id): + return results_db.get(task_id) + +async def cleanup_old_results(days_old=7): + # 简单的清理逻辑 + now = time.time() + to_delete = [] + for tid, res in results_db.items(): + if isinstance(res, dict) and now - res.get('createTime', now) > days_old * 86400: + to_delete.append(tid) + for tid in to_delete: + del results_db[tid] + return len(to_delete) \ No newline at end of file diff --git a/services/turnstile-solver/requirements.txt b/services/turnstile-solver/requirements.txt new file mode 100644 index 0000000..fe9830b --- /dev/null +++ b/services/turnstile-solver/requirements.txt @@ -0,0 +1,4 @@ +quart>=0.19 +camoufox[geoip]>=0.4 +patchright>=0.5 +rich>=13.0 diff --git a/services/turnstile-solver/start.sh b/services/turnstile-solver/start.sh new file mode 100755 index 0000000..addbbbf --- /dev/null +++ b/services/turnstile-solver/start.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Turnstile Solver 启动脚本 +# 用法: ./start.sh [--threads N] [--port PORT] [--proxy] [--browser chrome|chromium|camoufox] + +set -e + +THREADS=${SOLVER_THREADS:-2} +PORT=${SOLVER_PORT:-5072} +HOST=${SOLVER_HOST:-0.0.0.0} +BROWSER_TYPE=${SOLVER_BROWSER:-chrome} +USE_PROXY=false +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + +# 激活 venv(如果存在) +if [ -f "${SCRIPT_DIR}/.venv/bin/python" ]; then + PYTHON="${SCRIPT_DIR}/.venv/bin/python" +else + PYTHON="python" +fi + +# 解析命令行参数 +while [[ "$#" -gt 0 ]]; do + case $1 in + --threads) THREADS="$2"; shift ;; + --port) PORT="$2"; shift ;; + --host) HOST="$2"; shift ;; + --browser) BROWSER_TYPE="$2"; shift ;; + --proxy) USE_PROXY=true ;; + --debug) DEBUG=true ;; + --default-proxy) DEFAULT_PROXY="$2"; shift ;; + *) echo "未知参数: $1"; exit 1 ;; + esac + shift +done + +echo "=========================================" +echo " Turnstile Solver" +echo "=========================================" +echo "[*] 浏览器: ${BROWSER_TYPE}" +echo "[*] 线程数: ${THREADS}" +echo "[*] 监听: ${HOST}:${PORT}" +echo "[*] 代理: ${USE_PROXY}" +echo "[*] Python: ${PYTHON}" +echo "=========================================" + +PROXY_FLAG="" +if [ "$USE_PROXY" = true ]; then + PROXY_FLAG="--proxy" +fi + +DEBUG_FLAG="" +if [ "$DEBUG" = true ]; then + DEBUG_FLAG="--debug" +fi + +DEFAULT_PROXY_FLAG="" +if [ -n "$DEFAULT_PROXY" ]; then + DEFAULT_PROXY_FLAG="--default-proxy ${DEFAULT_PROXY}" +fi + +exec "${PYTHON}" api_solver.py \ + --browser_type "${BROWSER_TYPE}" \ + --thread "${THREADS}" \ + --host "${HOST}" \ + --port "${PORT}" \ + ${PROXY_FLAG} \ + ${DEBUG_FLAG} \ + ${DEFAULT_PROXY_FLAG} diff --git a/web/env.d.ts b/web/env.d.ts new file mode 100644 index 0000000..b6a7768 --- /dev/null +++ b/web/env.d.ts @@ -0,0 +1,6 @@ +/// +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..53115d2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,17 @@ + + + + + + + RegPlatform - 多平台注册系统 + + + + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..35e5288 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,2779 @@ +{ + "name": "regplatform-web", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "regplatform-web", + "version": "1.0.0", + "dependencies": { + "axios": "^1.7.9", + "jszip": "^3.10.1", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.20", + "tailwindcss": "^4.0.0", + "typescript": "~5.7.2", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.58.0.tgz", + "integrity": "sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.58.0.tgz", + "integrity": "sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.58.0.tgz", + "integrity": "sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.58.0.tgz", + "integrity": "sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.58.0.tgz", + "integrity": "sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.58.0.tgz", + "integrity": "sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.58.0.tgz", + "integrity": "sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.58.0.tgz", + "integrity": "sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.58.0.tgz", + "integrity": "sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.58.0.tgz", + "integrity": "sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.58.0.tgz", + "integrity": "sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.58.0.tgz", + "integrity": "sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.58.0.tgz", + "integrity": "sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.58.0.tgz", + "integrity": "sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.58.0.tgz", + "integrity": "sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.58.0.tgz", + "integrity": "sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.58.0.tgz", + "integrity": "sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.58.0.tgz", + "integrity": "sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.58.0.tgz", + "integrity": "sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.58.0.tgz", + "integrity": "sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.58.0.tgz", + "integrity": "sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.58.0.tgz", + "integrity": "sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.58.0.tgz", + "integrity": "sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.58.0.tgz", + "integrity": "sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.58.0.tgz", + "integrity": "sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.2.0.tgz", + "integrity": "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.2.0.tgz", + "integrity": "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.0", + "@tailwindcss/oxide-darwin-arm64": "4.2.0", + "@tailwindcss/oxide-darwin-x64": "4.2.0", + "@tailwindcss/oxide-freebsd-x64": "4.2.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", + "@tailwindcss/oxide-linux-x64-musl": "4.2.0", + "@tailwindcss/oxide-wasm32-wasi": "4.2.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.0.tgz", + "integrity": "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.0.tgz", + "integrity": "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.0.tgz", + "integrity": "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.0.tgz", + "integrity": "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.0.tgz", + "integrity": "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.0.tgz", + "integrity": "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.0.tgz", + "integrity": "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.0.tgz", + "integrity": "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.0.tgz", + "integrity": "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.0.tgz", + "integrity": "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.0.tgz", + "integrity": "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.0.tgz", + "integrity": "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.2.0.tgz", + "integrity": "sha512-da9mFCaHpoOgtQiWtDGIikTrSpUFBtIZCG3jy/u2BGV+l/X1/pbxzmIUxNt6JWm19N3WtGi4KlJdSH/Si83WOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.0", + "@tailwindcss/oxide": "4.2.0", + "tailwindcss": "4.2.0" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.28.tgz", + "integrity": "sha512-kviccYxTgoE8n6OCw96BNdYlBg2GOWfBuOW4Vqwrt7mSKWKwFVvI8egdTltqRgITGPsTFYtKYfxIG8ptX2PJHQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.28", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.28.tgz", + "integrity": "sha512-/1ZepxAb159jKR1btkefDP+J2xuWL5V3WtleRmxaT+K2Aqiek/Ab/+Ebrw2pPj0sdHO8ViAyyJWfhXXOP/+LQA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.28.tgz", + "integrity": "sha512-6TnKMiNkd6u6VeVDhZn/07KhEZuBSn43Wd2No5zaP5s3xm8IqFTHBj84HJah4UepSUJTro5SoqqlOY22FKY96g==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.28", + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.28.tgz", + "integrity": "sha512-JCq//9w1qmC6UGLWJX7RXzrGpKkroubey/ZFqTpvEIDJEKGgntuDMqkuWiZvzTzTA5h2qZvFBFHY7fAAa9475g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.28.tgz", + "integrity": "sha512-gr5hEsxvn+RNyu9/9o1WtdYdwDjg5FgjUSBEkZWqgTKlo/fvwZ2+8W6AfKsc9YN2k/+iHYdS9vZYAhpi10kNaw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.28.tgz", + "integrity": "sha512-POVHTdbgnrBBIpnbYU4y7pOMNlPn2QVxVzkvEA2pEgvzbelQq4ZOUxbp2oiyo+BOtiYlm8Q44wShHJoBvDPAjQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/shared": "3.5.28" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.28.tgz", + "integrity": "sha512-4SXxSF8SXYMuhAIkT+eBRqOkWEfPu6nhccrzrkioA6l0boiq7sp18HCOov9qWJA5HML61kW8p/cB4MmBiG9dSA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.28", + "@vue/runtime-core": "3.5.28", + "@vue/shared": "3.5.28", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.28.tgz", + "integrity": "sha512-pf+5ECKGj8fX95bNincbzJ6yp6nyzuLDhYZCeFxUNp8EBrQpPpQaLX3nNCp49+UbgbPun3CeVE+5CXVV1Xydfg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "vue": "3.5.28" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.28.tgz", + "integrity": "sha512-cfWa1fCGBxrvaHRhvV3Is0MgmrbSCxYTXCSCau2I0a1Xw1N1pHAvkWCiXPRAqjvToILvguNyEwjevUqAuBQWvQ==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.24", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.24.tgz", + "integrity": "sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1", + "caniuse-lite": "^1.0.30001766", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.302", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", + "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmmirror.com/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "2.3.1", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz", + "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.3", + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.4.4", + "vue": "^2.7.0 || ^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/rollup": { + "version": "4.58.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.58.0.tgz", + "integrity": "sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.58.0", + "@rollup/rollup-android-arm64": "4.58.0", + "@rollup/rollup-darwin-arm64": "4.58.0", + "@rollup/rollup-darwin-x64": "4.58.0", + "@rollup/rollup-freebsd-arm64": "4.58.0", + "@rollup/rollup-freebsd-x64": "4.58.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.58.0", + "@rollup/rollup-linux-arm-musleabihf": "4.58.0", + "@rollup/rollup-linux-arm64-gnu": "4.58.0", + "@rollup/rollup-linux-arm64-musl": "4.58.0", + "@rollup/rollup-linux-loong64-gnu": "4.58.0", + "@rollup/rollup-linux-loong64-musl": "4.58.0", + "@rollup/rollup-linux-ppc64-gnu": "4.58.0", + "@rollup/rollup-linux-ppc64-musl": "4.58.0", + "@rollup/rollup-linux-riscv64-gnu": "4.58.0", + "@rollup/rollup-linux-riscv64-musl": "4.58.0", + "@rollup/rollup-linux-s390x-gnu": "4.58.0", + "@rollup/rollup-linux-x64-gnu": "4.58.0", + "@rollup/rollup-linux-x64-musl": "4.58.0", + "@rollup/rollup-openbsd-x64": "4.58.0", + "@rollup/rollup-openharmony-arm64": "4.58.0", + "@rollup/rollup-win32-arm64-msvc": "4.58.0", + "@rollup/rollup-win32-ia32-msvc": "4.58.0", + "@rollup/rollup-win32-x64-gnu": "4.58.0", + "@rollup/rollup-win32-x64-msvc": "4.58.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.2.0.tgz", + "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.28", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.28.tgz", + "integrity": "sha512-BRdrNfeoccSoIZeIhyPBfvWSLFP4q8J3u8Ju8Ug5vu3LdD+yTM13Sg4sKtljxozbnuMu1NB1X5HBHRYUzFocKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.28", + "@vue/compiler-sfc": "3.5.28", + "@vue/runtime-dom": "3.5.28", + "@vue/server-renderer": "3.5.28", + "@vue/shared": "3.5.28" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e90e312 --- /dev/null +++ b/web/package.json @@ -0,0 +1,27 @@ +{ + "name": "regplatform-web", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.9", + "jszip": "^3.10.1", + "pinia": "^2.3.0", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.0", + "@vitejs/plugin-vue": "^5.2.1", + "autoprefixer": "^10.4.20", + "tailwindcss": "^4.0.0", + "typescript": "~5.7.2", + "vite": "^6.0.0", + "vue-tsc": "^2.2.0" + } +} diff --git a/web/public/loading.gif b/web/public/loading.gif new file mode 100644 index 0000000..6a7513d Binary files /dev/null and b/web/public/loading.gif differ diff --git a/web/public/loading.webp b/web/public/loading.webp new file mode 100644 index 0000000..469c6f0 Binary files /dev/null and b/web/public/loading.webp differ diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..d3ee882 --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,208 @@ +import axios from 'axios' + +const client = axios.create({ + baseURL: '/api', + timeout: 30000, +}) + +// 请求拦截:附加 token +client.interceptors.request.use((config) => { + const token = localStorage.getItem('token') + if (token) { + config.headers['X-Auth-Token'] = token + } + return config +}) + +// 响应拦截:401 跳转登录(排除登录页本身,避免死循环) +client.interceptors.response.use( + (res) => res, + (err) => { + if (err.response?.status === 401 && window.location.pathname !== '/') { + localStorage.removeItem('token') + window.location.href = '/' + } + return Promise.reject(err) + } +) + +export default client + +// ── Init API(批量加载)── +export const initApi = { + load: () => client.get('/init'), +} + +// ── Auth API ── +export const authApi = { + register: (username: string, password: string) => + client.post('/auth/register', { username, password }), + login: (username: string, password: string) => + client.post('/auth/login', { username, password }), + me: () => client.get('/auth/me'), + logout: () => client.post('/auth/logout'), +} + +// ── Task API ── +export const taskApi = { + create: (data: { platform: string; target: number; threads?: number; mode?: string; proxy_id?: number }) => + client.post('/tasks', data), + start: (id: number) => client.post(`/tasks/${id}/start`), + stop: (id: number) => client.post(`/tasks/${id}/stop`), + current: (platform?: string) => + client.get('/tasks/current', { params: { platform } }), + history: () => client.get('/tasks/history'), +} + +// ── Proxy API ── +export const proxyApi = { + list: () => client.get('/proxies'), + create: (data: { name: string; protocol: string; host: string; port: number; username?: string; password?: string }) => + client.post('/proxies', data), + update: (id: number, data: { name: string; protocol: string; host: string; port: number; username?: string; password?: string }) => + client.put(`/proxies/${id}`, data), + delete: (id: number) => client.delete(`/proxies/${id}`), + test: (data: { proxy_id?: number; protocol?: string; host?: string; port?: number; username?: string; password?: string }) => + client.post('/proxies/test', data), +} + +// ── Credit API ── +export const creditApi = { + balance: () => client.get('/credits/balance'), + history: () => client.get('/credits/history'), + redeem: (code: string) => client.post('/credits/redeem', { code }), + claimFreeTrial: () => client.post('/credits/free-trial'), + purchase: (amount: number, platform?: string) => client.post('/credits/purchase', { amount, platform }), +} + +// ── Result API ── +export const resultApi = { + get: (taskId: number) => client.get(`/results/${taskId}`), + export: (taskId: number) => client.get(`/results/${taskId}/export`, { responseType: 'blob' }), + list: (platform?: string, page = 1, pageSize = 100) => + client.get('/results', { params: { platform, page, page_size: pageSize } }), + archive: (platform?: string) => client.post('/results/archive', platform ? { platform } : {}), + archivedCount: (platform?: string) => client.get('/results/archived', { params: { platform, count_only: 'true' } }), + archived: (platform?: string, pageSize = -1) => client.get('/results/archived', { params: { platform, page_size: pageSize } }), + // 软禁用:恢复被禁用的账号 + reEnable: (id: number) => client.post(`/results/${id}/re-enable`), +} + +// ── Proxy Pool API(管理后台) ── +export const proxyPoolApi = { + list: (params?: { page?: number; page_size?: number; filter?: string }) => + client.get('/admin/proxy-pool', { params }), + stats: () => client.get('/admin/proxy-pool/stats'), + add: (data: { name: string; protocol: string; host: string; port: number; username?: string; password?: string; country?: string }) => + client.post('/admin/proxy-pool', data), + delete: (id: number) => client.delete(`/admin/proxy-pool/${id}`), + batchDelete: (ids: number[]) => client.post('/admin/proxy-pool/batch-delete', { ids }), + import: (proxies: string, protocol: string) => + client.post('/admin/proxy-pool/import', { proxies, protocol }), + healthCheck: (ids?: number[]) => + client.post('/admin/proxy-pool/health-check', ids?.length ? { ids } : {}), + purge: () => client.post('/admin/proxy-pool/purge'), + reset: (id: number) => client.post(`/admin/proxy-pool/${id}/reset`), + // 从 URL 抓取代理 + fetchURL: (url: string, protocol: string) => + client.post('/admin/proxy-pool/fetch-url', { url, protocol }), +} + +// ── Card Pool API(管理后台) ── +export const cardPoolApi = { + list: (params?: { page?: number; page_size?: number; filter?: string }) => + client.get('/admin/card-pool', { params }), + stats: () => client.get('/admin/card-pool/stats'), + add: (data: { name?: string; card_number: string; exp_month: number; exp_year: number; cvc: string; billing_name?: string; billing_email?: string; billing_country?: string; billing_city?: string; billing_line1?: string; billing_zip?: string; provider?: string }) => + client.post('/admin/card-pool', data), + delete: (id: number) => client.delete(`/admin/card-pool/${id}`), + batchDelete: (ids: number[]) => client.post('/admin/card-pool/batch-delete', { ids }), + import: (cards: string, provider: string) => + client.post('/admin/card-pool/import', { cards, provider }), + validate: (ids?: number[]) => + client.post('/admin/card-pool/validate', ids?.length ? { ids } : {}), + purge: () => client.post('/admin/card-pool/purge'), +} + +// ── Email API ── +export const emailApi = { + fetchOTP: (email: string) => client.get('/email/otp', { params: { email } }), +} + +// ── Announcement API ── +export const announcementApi = { + list: () => client.get('/announcements'), +} + +// ── Stats API ── +export const statsApi = { + global: () => client.get('/stats/global'), + latestCompletions: (after?: number) => + client.get('/stats/latest-completions', { params: { after } }), + recentCompletions: () => client.get('/stats/recent-completions'), +} + +// ── Admin API ── +export const adminApi = { + users: (params?: { page?: number; page_size?: number; search?: string; sort_by?: string; sort_order?: string }) => + client.get('/admin/users', { params }), + recharge: (userId: number, credits: number) => + client.post('/admin/credits/recharge', { user_id: userId, credits }), + toggleAdmin: (userId: number) => + client.post(`/admin/users/${userId}/toggle-admin`), + generateCodes: (count: number, credits: number, batchName?: string) => + client.post('/admin/codes', { count, credits, batch_name: batchName }), + codes: (params?: { page?: number; page_size?: number; batch?: string }) => + client.get('/admin/codes', { params }), + stats: () => client.get('/admin/stats'), + settings: () => client.get('/admin/settings'), + settingRaw: (key: string) => client.get('/admin/settings/raw', { params: { key } }), + saveSetting: (key: string, value: string) => + client.post('/admin/settings', { key, value }), + announcements: () => client.get('/admin/announcements'), + createAnnouncement: (title: string, content: string) => + client.post('/admin/announcements', { title, content }), + deleteAnnouncement: (id: number) => + client.delete(`/admin/announcements/${id}`), + userDetail: (userId: number) => + client.get(`/admin/users/${userId}/detail`), + dataStats: () => client.get('/admin/data-stats'), + cleanup: (data: { days: number; clean_results: boolean; clean_tasks: boolean; clean_tx: boolean; clean_archived_only: boolean }) => + client.post('/admin/cleanup', data), + runningTasks: () => client.get('/admin/running-tasks'), + recentActivity: (params?: { page?: number; page_size?: number }) => + client.get('/admin/recent-activity', { params }), + stopTask: (taskId: number) => client.post(`/admin/tasks/${taskId}/stop`), + deleteTask: (taskId: number) => client.delete(`/admin/tasks/${taskId}`), + sendNotification: (data: { user_id: number; title: string; content: string }) => + client.post('/admin/notifications', data), + listNotifications: () => client.get('/admin/notifications'), + deleteNotification: (id: number) => client.delete(`/admin/notifications/${id}`), +} + +// ── Notification API ── +export const notificationApi = { + list: () => client.get('/notifications'), + markRead: (id: number | string) => client.patch(`/notifications/${id}/read`), +} + +// ── HF Space API ── +export const hfSpaceApi = { + listTokens: () => client.get('/admin/hf/tokens'), + createToken: (data: { label: string; token: string }) => client.post('/admin/hf/tokens', data), + deleteToken: (id: number) => client.delete(`/admin/hf/tokens/${id}`), + validateToken: (id: number) => client.post(`/admin/hf/tokens/${id}/validate`), + validateAllTokens: () => client.post('/admin/hf/tokens/validate-all'), + listSpaces: (service?: string, page?: number, pageSize?: number, status?: string) => client.get('/admin/hf/spaces', { params: { service, page, page_size: pageSize, status } }), + addSpace: (data: { service: string; url: string; repo_id: string; token_id: number }) => client.post('/admin/hf/spaces', data), + deleteSpace: (id: number) => client.delete(`/admin/hf/spaces/${id}`), + checkHealth: (service?: string) => client.post('/admin/hf/spaces/health', null, { params: { service } }), + purgeSpaces: (service?: string) => client.post('/admin/hf/spaces/purge', null, { params: { service } }), + deploySpaces: (data: { service: string; count: number; release_url: string; token_id?: number }) => client.post('/admin/hf/spaces/deploy', data), + updateSpaces: (data: { service: string }) => client.post('/admin/hf/spaces/update', data), + autoscale: (data: { service: string; target: number; dry_run: boolean }) => client.post('/admin/hf/autoscale', data), + syncCF: (service?: string) => client.post('/admin/hf/sync-cf', null, { params: { service } }), + overview: () => client.get('/admin/hf/overview'), + discover: (defaultService?: string) => client.post('/admin/hf/discover', null, { params: { default_service: defaultService } }), + redetect: () => client.post('/admin/hf/redetect'), +} diff --git a/web/src/assets/main.css b/web/src/assets/main.css new file mode 100644 index 0000000..728688a --- /dev/null +++ b/web/src/assets/main.css @@ -0,0 +1,644 @@ +@import "tailwindcss"; + +/* ══════════ 字体变量 ══════════ */ +:root { + --font-sans: 'DM Sans', ui-sans-serif, system-ui, sans-serif; + --font-mono: 'DM Mono', 'JetBrains Mono', 'Menlo', 'Monaco', monospace; +} + +/* ══════════ Tailwind v4 语义色注册 ══════════ */ +@theme { + /* 文本语义色 — 自动适配明暗 */ + --color-t-primary: var(--text-primary); + --color-t-secondary: var(--text-secondary); + --color-t-muted: var(--text-muted); + --color-t-faint: var(--text-faint); + --color-t-bright: var(--text-primary); + + /* 表面语义色 */ + --color-s-panel: var(--bg-surface); + --color-s-hover: var(--bg-surface-hover); + --color-s-inset: var(--bg-inset); + --color-s-deep: var(--bg-inset-deep); + + /* 边框语义色 */ + --color-b-panel: var(--border-subtle); + --color-b-muted: var(--border-glass); + --color-b-input: var(--border-input); + + /* 状态语义色 */ + --color-ok: var(--c-success); + --color-err: var(--c-danger); + --color-info: var(--c-info); + --color-accent: var(--c-accent); + --color-warn: var(--c-warning); + --color-ok-dim: var(--c-success-dim); + --color-err-dim: var(--c-danger-dim); + + /* ── macOS HIG 融合:阴影 + 动画 tokens ── */ + --shadow-window: var(--shadow-window); + --shadow-popup: var(--shadow-popup); + --shadow-card: var(--shadow-card); + --shadow-card-hover: var(--shadow-card-hover); + --transition-timing-spring: var(--spring); + --transition-timing-spring-bounce: var(--spring-bounce); +} + +/* ══════════ 主题变量(暗色默认,浅色跟随系统) ══════════ */ +:root { + --bg-page: #080c16; + --bg-glass: rgba(15,23,42,0.82); + --bg-glass-light: rgba(30,41,59,0.45); + --bg-glass-light-admin: rgba(30,41,59,0.65); + --bg-input: rgba(15,23,42,0.7); + --bg-admin: rgba(8,12,24,0.97); + --bg-hover: rgba(30,41,59,0.3); + --text-primary: #e2e8f0; + --text-secondary: #94a3b8; + --text-muted: #64748b; + --text-faint: #475569; + --border-glass: rgba(255,255,255,0.06); + --border-subtle: rgba(148,163,184,0.12); + --border-input: rgba(71,85,105,0.5); + --border-divider: rgba(148,163,184,0.04); + --modal-mask: rgba(0,0,0,0.6); + --scrollbar-thumb: rgba(100,116,139,0.3); + --scrollbar-hover: rgba(100,116,139,0.5); + --tooltip-bg: rgba(15,23,42,0.95); + --tooltip-border: rgba(148,163,184,0.12); + --tooltip-text: #e2e8f0; + + /* 表面/嵌入背景 */ + --bg-surface: rgba(30,41,59,0.3); + --bg-surface-hover: rgba(30,41,59,0.5); + --bg-inset: rgba(15,23,42,0.5); + --bg-inset-deep: rgba(15,23,42,0.6); + + /* 状态色(暗色模式用 -400 系列) */ + --c-success: #4ade80; + --c-danger: #f87171; + --c-info: #60a5fa; + --c-accent: #22d3ee; + --c-warning: #fbbf24; + --c-success-dim: rgba(34,197,94,0.15); + --c-danger-dim: rgba(239,68,68,0.15); + + /* 滑块轨道 */ + --slider-track: #1e293b; + --slider-fill: #3b82f6; + + /* ── macOS HIG 融合:阴影层次 ── */ + --shadow-window: 0 0 0 0.5px rgba(255,255,255,0.08), 0 1px 3px rgba(0,0,0,0.4), 0 12px 32px -8px rgba(0,0,0,0.25); + --shadow-popup: 0 0 0 0.5px rgba(255,255,255,0.1), 0 8px 24px -4px rgba(0,0,0,0.35), 0 0 1px rgba(0,0,0,0.5); + --shadow-card: 0 0 0 0.5px rgba(255,255,255,0.06), 0 2px 8px -2px rgba(0,0,0,0.2); + --shadow-card-hover: 0 0 0 0.5px rgba(255,255,255,0.1), 0 8px 24px -6px rgba(0,0,0,0.3); + + /* ── macOS HIG 融合:bezel 高光(顶部内发光) ── */ + --bezel-highlight: inset 0 1px 0 0 rgba(255,255,255,0.06); + --bezel-highlight-strong: inset 0 1px 0 0 rgba(255,255,255,0.12); + + /* ── macOS HIG 融合:弹簧动画时间函数 ── */ + --spring: cubic-bezier(0.32, 0.72, 0, 1); + --spring-bounce: cubic-bezier(0.34, 1.56, 0.64, 1); + --spring-gentle: cubic-bezier(0.25, 0.46, 0.45, 0.94); +} + +@media (prefers-color-scheme: light) { + :root { + --bg-page: #f1f5f9; + --bg-glass: rgba(255,255,255,0.85); + --bg-glass-light: rgba(241,245,249,0.7); + --bg-glass-light-admin: rgba(241,245,249,0.85); + --bg-input: rgba(241,245,249,0.9); + --bg-admin: rgba(255,255,255,0.97); + --bg-hover: rgba(241,245,249,0.6); + --text-primary: #1e293b; + --text-secondary: #475569; + --text-muted: #64748b; + --text-faint: #94a3b8; + --border-glass: rgba(0,0,0,0.08); + --border-subtle: rgba(148,163,184,0.2); + --border-input: rgba(203,213,225,0.6); + --border-divider: rgba(0,0,0,0.06); + --modal-mask: rgba(0,0,0,0.25); + --scrollbar-thumb: rgba(148,163,184,0.4); + --scrollbar-hover: rgba(148,163,184,0.6); + --tooltip-bg: rgba(255,255,255,0.97); + --tooltip-border: rgba(148,163,184,0.15); + --tooltip-text: #1e293b; + + --bg-surface: rgba(226,232,240,0.4); + --bg-surface-hover: rgba(226,232,240,0.6); + --bg-inset: rgba(241,245,249,0.6); + --bg-inset-deep: rgba(226,232,240,0.5); + + /* 浅色模式用 -600 系列(对比度够) */ + --c-success: #16a34a; + --c-danger: #dc2626; + --c-info: #2563eb; + --c-accent: #0891b2; + --c-warning: #d97706; + --c-success-dim: rgba(34,197,94,0.1); + --c-danger-dim: rgba(239,68,68,0.1); + + --slider-track: #cbd5e1; + --slider-fill: #3b82f6; + + /* ── macOS HIG 融合:浅色阴影层次 ── */ + --shadow-window: 0 0 0 0.5px rgba(0,0,0,0.06), 0 1px 3px rgba(0,0,0,0.08), 0 12px 32px -8px rgba(0,0,0,0.12); + --shadow-popup: 0 0 0 0.5px rgba(0,0,0,0.08), 0 8px 24px -4px rgba(0,0,0,0.15), 0 0 1px rgba(0,0,0,0.12); + --shadow-card: 0 0 0 0.5px rgba(0,0,0,0.04), 0 2px 8px -2px rgba(0,0,0,0.06); + --shadow-card-hover: 0 0 0 0.5px rgba(0,0,0,0.06), 0 8px 24px -6px rgba(0,0,0,0.12); + --bezel-highlight: inset 0 1px 0 0 rgba(255,255,255,0.5); + --bezel-highlight-strong: inset 0 1px 0 0 rgba(255,255,255,0.7); + } +} + +/* ── 基础 ── */ +html, body { height: 100%; margin: 0; overflow: hidden; background: var(--bg-page); color: var(--text-primary); } + +/* 字体渲染优化:抗锯齿 + 数字对齐 */ +body { + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + font-feature-settings: "tnum" on, "lnum" on; +} + +/* 等宽数字全局一致性:防止计数跳动 */ +.font-mono, .tabular-nums { font-variant-numeric: tabular-nums; } + +/* ══════════ 全局共享组件样式 ══════════ */ + +/* ── macOS HIG 融合:噪点纹理覆盖层(防色带 + 铝合金质感) ── */ +.noise-texture { + position: relative; + isolation: isolate; +} +.noise-texture::before { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + background: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); + opacity: 0.015; + pointer-events: none; + z-index: 1; + mix-blend-mode: overlay; +} + +/* ── macOS HIG 融合:阴影层次工具类 ── */ +.shadow-window { box-shadow: var(--shadow-window); } +.shadow-popup { box-shadow: var(--shadow-popup); } +.shadow-card { box-shadow: var(--shadow-card); } + +/* ── macOS HIG 融合:弹簧过渡工具类 ── */ +.transition-spring { transition-timing-function: var(--spring); } +.transition-spring-bounce { transition-timing-function: var(--spring-bounce); } + +/* 毛玻璃 — macOS HIG Vibrancy 3.0:saturate 增强色彩饱和 + 0.5px retina 边框 + bezel 顶部高光 */ +.glass { + background: var(--bg-glass); + backdrop-filter: blur(16px) saturate(180%); + border: 0.5px solid var(--border-glass); + box-shadow: var(--bezel-highlight), var(--shadow-card); +} +.glass-light { + background: var(--bg-glass-light); + backdrop-filter: blur(12px) saturate(150%); + border: 0.5px solid var(--border-glass); +} + +/* 管理面板 — macOS HIG:超厚材质 + saturate + bezel */ +.admin-panel { + background: var(--bg-admin); + backdrop-filter: blur(24px) saturate(200%); + border: 0.5px solid var(--border-subtle); + box-shadow: var(--bezel-highlight-strong), var(--shadow-window); + color: var(--text-primary); +} +.admin-panel .glass-light { + background: var(--bg-glass-light-admin); + border: 0.5px solid var(--border-glass); + box-shadow: var(--bezel-highlight); +} +.admin-panel table thead tr th { color: var(--text-secondary); border-color: var(--border-subtle); } +.admin-panel table tbody tr td { color: var(--text-primary); } +.admin-panel table tbody tr { border-color: var(--border-subtle); } +.admin-panel input[type="text"], +.admin-panel input[type="password"], +.admin-panel input[type="number"], +.admin-panel textarea { color: var(--text-primary); background: var(--bg-input); border-color: var(--border-input); } +.admin-panel input::placeholder, .admin-panel textarea::placeholder { color: var(--text-faint); } + +/* 分隔线 — 0.5px retina */ +.section-divide { border-top: 0.5px solid var(--border-divider); } +/* 模态遮罩 — HIG 推荐较浅遮罩 + 增强模糊 */ +.modal-mask { background: var(--modal-mask); backdrop-filter: blur(8px) saturate(150%); } + +/* 日志面板 */ +.log-panel { font-family: var(--font-mono); font-size: 13px; line-height: 1.7; } +.log-success { background: var(--c-success-dim); border-radius: 3px; padding: 1px 4px; } + +/* 滚动条 */ +* { scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb) transparent; } +::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; } +::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-hover); } +.scroll-thin::-webkit-scrollbar { width: 4px; } +.scroll-thin::-webkit-scrollbar-track { background: transparent; } +.scroll-thin::-webkit-scrollbar-thumb { background: rgba(100,116,139,0.25); border-radius: 2px; } + +/* 动画 */ +.animate-in { animation: fadeIn 0.2s ease-out; } +@keyframes fadeIn { from { opacity:0; transform:translateY(4px); } to { opacity:1; transform:translateY(0); } } +@keyframes pulse-ring { 0% { transform:scale(0.8); opacity:1; } 100% { transform:scale(2); opacity:0; } } +.pulse-ring { animation: pulse-ring 1.5s cubic-bezier(0.215,0.61,0.355,1) infinite; } + +/* slide-up transition(设置保存栏) */ +.slide-up-enter-active, .slide-up-leave-active { transition: all 0.25s ease-out; } +.slide-up-enter-from, .slide-up-leave-to { opacity: 0; transform: translateY(12px); } + +/* VIP 徽章 */ +.badge-base { display:inline-flex; align-items:center; gap:3px; padding:1px 6px; border-radius:4px; font-weight:600; font-size:9px; letter-spacing:0.3px; } +.badge-normal { background:linear-gradient(135deg,#374151,#1f2937); color:#9ca3af; border:0.5px solid #4b5563; } +.badge-vip { background:linear-gradient(135deg,#1e40af,#3b82f6); color:#bfdbfe; border:0.5px solid #60a5fa; box-shadow:0 0 10px rgba(59,130,246,0.25); } +.badge-vvip { background:linear-gradient(135deg,#6d28d9,#8b5cf6); color:#ddd6fe; border:0.5px solid #a78bfa; box-shadow:0 0 12px rgba(139,92,246,0.3); } +.badge-svip { background:linear-gradient(135deg,#dc2626,#ef4444,#f87171); color:#fff; border:0.5px solid #fca5a5; box-shadow:0 0 18px rgba(239,68,68,0.35); } + +/* 用户卡片光晕 */ +.card-glow-vvip { box-shadow: inset 0 1px 0 rgba(139,92,246,0.1), 0 0 24px rgba(139,92,246,0.05); } +.card-glow-svip { box-shadow: inset 0 1px 0 rgba(239,68,68,0.15), 0 0 24px rgba(239,68,68,0.06); } + +/* 头像光圈 */ +.avatar-ring-normal { box-shadow: 0 0 0 2px #4b5563; } +.avatar-ring-vvip { box-shadow: 0 0 0 2px #8b5cf6, 0 0 8px rgba(139,92,246,0.25); } +.avatar-ring-svip { box-shadow: 0 0 0 2px #ef4444, 0 0 12px rgba(239,68,68,0.35); } + +/* Shimmer 效果 */ +@keyframes shimmer { 0% { background-position:-200% 0; } 100% { background-position:200% 0; } } +.shimmer-gold { background: linear-gradient(90deg, transparent 0%, rgba(251,191,36,0.12) 50%, transparent 100%); background-size:200% 100%; animation: shimmer 3s infinite; } +.shimmer-red { background: linear-gradient(90deg, transparent 0%, rgba(239,68,68,0.08) 50%, transparent 100%); background-size:200% 100%; animation: shimmer 3s infinite; } + +/* 通知面板 — macOS HIG 弹簧动画 */ +.notif-panel-enter-active { transition: opacity 0.2s ease, transform 0.25s var(--spring); } +.notif-panel-leave-active { transition: opacity 0.15s ease, transform 0.2s var(--spring-gentle); } +.notif-panel-enter-from, .notif-panel-leave-to { opacity: 0; transform: translateY(-8px) scale(0.95); } + +/* 进度条发光 */ +.progress-bar-glow { + background: linear-gradient(90deg, #3b82f6, #06b6d4, #22d3ee); + box-shadow: 0 0 12px rgba(6,182,212,0.4), 0 0 4px rgba(59,130,246,0.3); +} +.progress-bar-glow::after { + content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; + background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.2) 50%, transparent 100%); + background-size: 200% 100%; animation: shimmer 2s infinite; border-radius: inherit; +} + +/* 任务卡片运行态 — macOS HIG bezel + 层次阴影 */ +.task-card-glow { transition: box-shadow 0.3s var(--spring), border-color 0.3s ease; } +.task-card-running { border-color: rgba(34,197,94,0.15) !important; box-shadow: var(--bezel-highlight), 0 0 20px rgba(34,197,94,0.06); } + +/* ── 主操作按钮增强:macOS HIG glow ring 聚焦(无障碍) ── */ +button:focus-visible { + outline: none; + box-shadow: 0 0 0 4px rgba(59,130,246,0.2), var(--bezel-highlight); +} + +/* ── 输入框聚焦:macOS HIG glow ring ── */ +input:focus-visible, +textarea:focus-visible, +select:focus-visible { + outline: none; + border-color: rgba(59,130,246,0.4); + box-shadow: 0 0 0 4px rgba(59,130,246,0.15), inset 0 1px 2px rgba(0,0,0,0.06); +} + +/* ── 三态主操作按钮(LeftSidebar)— macOS HIG 弹簧物理 ── */ +.action-btn { + transition: filter 0.15s ease, transform 0.2s var(--spring); + box-shadow: var(--bezel-highlight), 0 1px 2px rgba(0,0,0,0.2); +} +.action-btn:not(:disabled):hover { + filter: brightness(1.12); +} +.action-btn:not(:disabled):active { + transform: scale(0.96); + filter: brightness(0.95); +} + +/* 排队中:琥珀橙渐变 — HIG 垂直渐变 + inner highlight */ +.action-btn-queue { + background: linear-gradient(180deg, #d97706, #ea580c); + border: 0.5px solid rgba(255,255,255,0.15); +} +/* 运行中:红色渐变 */ +.action-btn-stop { + background: linear-gradient(180deg, #dc2626, #b91c1c); + border: 0.5px solid rgba(255,255,255,0.15); +} +/* 空闲:蓝色渐变(核心主操作)*/ +.action-btn-start { + background: linear-gradient(180deg, #3b82f6, #2563eb); + border: 0.5px solid rgba(255,255,255,0.2); +} +/* 禁用态统一:覆盖渐变为灰色 */ +.action-btn:disabled { + background: #374151 !important; + filter: none !important; + transform: none !important; +} +/* 开始按钮悬停光泽层 */ +.action-btn-start:not(:disabled):hover .action-btn-shine { + opacity: 1; +} +.action-btn-shine { + background: linear-gradient(135deg, rgba(255,255,255,0.14) 0%, transparent 55%); + pointer-events: none; +} + +/* ── 数字跳动过渡(成功/失败计数) ── */ +@keyframes countBump { + 0% { transform: scale(1); } + 50% { transform: scale(1.15); } + 100% { transform: scale(1); } +} +.count-bump { + animation: countBump 0.25s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +/* ── 平台状态小圆点:颜色更鲜明 ── */ +.platform-dot-healthy { background: var(--c-success); box-shadow: 0 0 4px var(--c-success); } +.platform-dot-degraded { background: var(--c-warning); } +.platform-dot-down { background: var(--c-danger); } + +/* 滑块 */ +.slider-input { outline: none; } +.slider-input::-webkit-slider-thumb { + -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; + background: linear-gradient(135deg, #3b82f6, #60a5fa); border: 2px solid var(--slider-track); + box-shadow: 0 0 8px rgba(59,130,246,0.4); cursor: pointer; transition: transform 0.15s, box-shadow 0.15s; +} +.slider-input::-webkit-slider-thumb:hover { transform: scale(1.2); box-shadow: 0 0 14px rgba(59,130,246,0.6); } +.slider-input::-webkit-slider-thumb:active { transform: scale(1.1); } +.slider-input::-moz-range-thumb { + width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--slider-track); + background: linear-gradient(135deg, #3b82f6, #60a5fa); box-shadow: 0 0 8px rgba(59,130,246,0.4); cursor: pointer; +} + +/* Spinner 隐藏 */ +.no-spinner { -moz-appearance: textfield; } +.no-spinner::-webkit-inner-spin-button { -webkit-appearance: none; } + +/* Tooltip 基础 */ +.tip { position: relative; } + +/* ── Tooltip 渲染:data-tip 属性驱动,纯 CSS ── */ +.tip[data-tip]::after { + content: attr(data-tip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(4px); + width: max-content; + max-width: 220px; + white-space: normal; + word-break: keep-all; + overflow-wrap: break-word; + background: var(--tooltip-bg); + color: var(--tooltip-text); + border: 0.5px solid var(--tooltip-border); + border-radius: 6px; + padding: 4px 8px; + font-size: 10px; + font-family: var(--font-sans); + line-height: 1.5; + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease, transform 0.15s var(--spring); + z-index: 9999; + backdrop-filter: blur(12px) saturate(180%); + box-shadow: var(--shadow-popup); +} +.tip[data-tip]:hover::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} +/* 箭头 */ +.tip[data-tip]::before { + content: ''; + position: absolute; + bottom: calc(100% + 1px); + left: 50%; + transform: translateX(-50%); + border: 4px solid transparent; + border-top-color: var(--tooltip-border); + pointer-events: none; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 9999; +} +.tip[data-tip]:hover::before { opacity: 1; } + +/* tip-bottom: tooltip 出现在下方 */ +.tip-bottom[data-tip]::after { + bottom: auto; + top: calc(100% + 6px); + transform: translateX(-50%) translateY(-4px); +} +.tip-bottom[data-tip]:hover::after { + transform: translateX(-50%) translateY(0); +} +.tip-bottom[data-tip]::before { + bottom: auto; + top: calc(100% + 1px); + border-top-color: transparent; + border-bottom-color: var(--tooltip-border); +} + +/* tip-left: tooltip 出现在左侧 */ +.tip-left[data-tip]::after { + bottom: auto; + top: 50%; + left: auto; + right: calc(100% + 6px); + transform: translateY(-50%) translateX(4px); +} +.tip-left[data-tip]:hover::after { + transform: translateY(-50%) translateX(0); +} +.tip-left[data-tip]::before { + bottom: auto; + top: 50%; + left: auto; + right: calc(100% + 1px); + transform: translateY(-50%); + border-top-color: transparent; + border-left-color: var(--tooltip-border); +} + +/* tip-right: tooltip 出现在右侧 */ +.tip-right[data-tip]::after { + bottom: auto; + top: 50%; + left: calc(100% + 6px); + transform: translateY(-50%) translateX(-4px); +} +.tip-right[data-tip]:hover::after { + transform: translateY(-50%) translateX(0); +} +.tip-right[data-tip]::before { + bottom: auto; + top: 50%; + left: calc(100% + 1px); + transform: translateY(-50%); + border-top-color: transparent; + border-right-color: var(--tooltip-border); +} + +/* ══════════ 响应式布局 ══════════ */ + +/* 手机端:隐藏侧栏,堆叠布局 */ +@media (max-width: 767px) { + .desktop-only { display: none !important; } + .mobile-stack { flex-direction: column !important; } + .mobile-full { width: 100% !important; flex: none !important; max-width: 100% !important; } + .mobile-hide { display: none !important; } + .mobile-compact-nav { gap: 0.25rem !important; padding: 0.375rem 0.5rem !important; } + .mobile-compact-nav .desktop-only { display: none !important; } + .mobile-grid-1 { grid-template-columns: 1fr !important; } + .mobile-grid-2 { grid-template-columns: repeat(2, 1fr) !important; } + .mobile-text-sm { font-size: 0.75rem !important; } + + /* 日志面板:手机端全宽 + 限高 */ + .mobile-log-panel { + width: 100% !important; + flex: none !important; + max-height: 120px !important; + min-height: 80px !important; + } + + /* 主内容区:手机端允许滚动,子元素自然排列 */ + .mobile-main-area { + overflow-y: auto !important; + min-height: 0 !important; + -webkit-overflow-scrolling: touch; + } + /* ContentArea 在手机端不要 flex-1 撑满 */ + .mobile-main-area > .mobile-full { + flex: none !important; + min-height: auto !important; + max-height: none !important; + } + + /* 管理后台弹窗:手机端全屏 */ + .mobile-admin-mask { + padding: 0 !important; + } + .mobile-admin-panel { + max-width: 100% !important; + max-height: 100% !important; + height: 100% !important; + border-radius: 0 !important; + } + .mobile-admin-header { + padding: 0.625rem 1rem !important; + gap: 0.5rem !important; + } + .mobile-admin-header .text-base { font-size: 0.875rem !important; } + .mobile-admin-body { + padding: 1rem !important; + } + + /* 通知面板:手机端固定居中、更宽 */ + .mobile-notif-panel { + position: fixed !important; + left: 0.5rem !important; + right: 0.5rem !important; + top: 3.5rem !important; + width: auto !important; + max-height: 70vh !important; + } +} + +/* 平板:窄侧栏 */ +@media (min-width: 768px) and (max-width: 1023px) { + .tablet-narrow { width: 14rem !important; } + .tablet-hide { display: none !important; } +} + +/* 桌面端 */ +@media (min-width: 1024px) { + .mobile-only { display: none !important; } +} + +/* 底部抽屉 — macOS HIG 弹簧物理 */ +.drawer-mask-enter-active { transition: opacity 0.25s ease; } +.drawer-mask-leave-active { transition: opacity 0.2s ease; } +.drawer-mask-enter-from, .drawer-mask-leave-to { opacity: 0; } + +.drawer-panel-enter-active { transition: transform 0.35s var(--spring); } +.drawer-panel-leave-active { transition: transform 0.25s var(--spring-gentle); } +.drawer-panel-enter-from, .drawer-panel-leave-to { transform: translateY(100%); } + +/* 首屏 Loading 淡出过渡 */ +.loading-fade-leave-active { transition: opacity 0.3s ease; } +.loading-fade-leave-to { opacity: 0; } + +/* ══════════ 概览 Tab 专用样式 ══════════ */ + +/* 指标卡片 — macOS HIG Bento 风格:弹簧悬停 + 层次阴影 + 微缩放 */ +.overview-card { + transition: transform 0.25s var(--spring), box-shadow 0.25s ease; + box-shadow: var(--shadow-card); +} +.overview-card:hover { + transform: translateY(-2px) scale(1.01); + box-shadow: var(--shadow-card-hover); +} + +/* 运行中脉冲点 */ +.overview-pulse { + animation: overview-pulse-anim 1.8s ease-in-out infinite; +} +@keyframes overview-pulse-anim { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.5; transform: scale(0.75); } +} + +/* 迷你柱状图 bar 悬停 */ +.overview-bar-hover { + cursor: default; + transition: filter 0.15s ease; +} +.overview-bar-hover:hover { + filter: brightness(1.3); +} + +/* 模态弹窗 — macOS HIG 弹簧入场动画 */ +.queue-modal-enter-active { transition: opacity 0.25s ease; } +.queue-modal-enter-active > div:last-child { transition: transform 0.35s var(--spring), opacity 0.25s ease; } +.queue-modal-leave-active { transition: opacity 0.2s ease; } +.queue-modal-leave-active > div:last-child { transition: transform 0.2s var(--spring-gentle), opacity 0.2s ease; } +.queue-modal-enter-from, .queue-modal-leave-to { opacity: 0; } +.queue-modal-enter-from > div:last-child { transform: scale(0.92) translateY(12px); opacity: 0; } +.queue-modal-leave-to > div:last-child { transform: scale(0.95); opacity: 0; } + +.queue-pulse { + animation: queue-pulse 2s ease-in-out infinite; +} +@keyframes queue-pulse { + 0%, 100% { box-shadow: 0 0 0 0 rgba(245,158,11,0.3); } + 50% { box-shadow: 0 0 0 12px rgba(245,158,11,0); } +} + +.queue-bar-anim { + background-size: 200% 100%; + animation: queue-bar-slide 1.5s linear infinite; +} +@keyframes queue-bar-slide { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} diff --git a/web/src/components/dashboard/AdminCardPoolTab.vue b/web/src/components/dashboard/AdminCardPoolTab.vue new file mode 100644 index 0000000..80ebe6b --- /dev/null +++ b/web/src/components/dashboard/AdminCardPoolTab.vue @@ -0,0 +1,386 @@ + + + diff --git a/web/src/components/dashboard/AdminHFSpaceTab.vue b/web/src/components/dashboard/AdminHFSpaceTab.vue new file mode 100644 index 0000000..649a8a5 --- /dev/null +++ b/web/src/components/dashboard/AdminHFSpaceTab.vue @@ -0,0 +1,432 @@ + + + diff --git a/web/src/components/dashboard/AdminModal.vue b/web/src/components/dashboard/AdminModal.vue new file mode 100644 index 0000000..8c62b59 --- /dev/null +++ b/web/src/components/dashboard/AdminModal.vue @@ -0,0 +1,457 @@ + + + diff --git a/web/src/components/dashboard/AdminNotifyTab.vue b/web/src/components/dashboard/AdminNotifyTab.vue new file mode 100644 index 0000000..9da0545 --- /dev/null +++ b/web/src/components/dashboard/AdminNotifyTab.vue @@ -0,0 +1,162 @@ + + + diff --git a/web/src/components/dashboard/AdminOpsTab.vue b/web/src/components/dashboard/AdminOpsTab.vue new file mode 100644 index 0000000..d6941e2 --- /dev/null +++ b/web/src/components/dashboard/AdminOpsTab.vue @@ -0,0 +1,103 @@ + + + diff --git a/web/src/components/dashboard/AdminProxyPoolTab.vue b/web/src/components/dashboard/AdminProxyPoolTab.vue new file mode 100644 index 0000000..619f0c5 --- /dev/null +++ b/web/src/components/dashboard/AdminProxyPoolTab.vue @@ -0,0 +1,728 @@ + + + diff --git a/web/src/components/dashboard/AdminSystemTab.vue b/web/src/components/dashboard/AdminSystemTab.vue new file mode 100644 index 0000000..29603e8 --- /dev/null +++ b/web/src/components/dashboard/AdminSystemTab.vue @@ -0,0 +1,408 @@ + + + diff --git a/web/src/components/dashboard/AdminUsersTab.vue b/web/src/components/dashboard/AdminUsersTab.vue new file mode 100644 index 0000000..b311e4d --- /dev/null +++ b/web/src/components/dashboard/AdminUsersTab.vue @@ -0,0 +1,218 @@ + + + diff --git a/web/src/components/dashboard/ContentArea.vue b/web/src/components/dashboard/ContentArea.vue new file mode 100644 index 0000000..be2c4eb --- /dev/null +++ b/web/src/components/dashboard/ContentArea.vue @@ -0,0 +1,387 @@ + + + diff --git a/web/src/components/dashboard/LeftSidebar.vue b/web/src/components/dashboard/LeftSidebar.vue new file mode 100644 index 0000000..22fc1b0 --- /dev/null +++ b/web/src/components/dashboard/LeftSidebar.vue @@ -0,0 +1,382 @@ + + + diff --git a/web/src/components/dashboard/LogPanel.vue b/web/src/components/dashboard/LogPanel.vue new file mode 100644 index 0000000..fc16ea8 --- /dev/null +++ b/web/src/components/dashboard/LogPanel.vue @@ -0,0 +1,533 @@ + + + + + diff --git a/web/src/components/dashboard/MobileDrawer.vue b/web/src/components/dashboard/MobileDrawer.vue new file mode 100644 index 0000000..0457ef0 --- /dev/null +++ b/web/src/components/dashboard/MobileDrawer.vue @@ -0,0 +1,25 @@ + + + diff --git a/web/src/components/dashboard/ProxyDialog.vue b/web/src/components/dashboard/ProxyDialog.vue new file mode 100644 index 0000000..62811ea --- /dev/null +++ b/web/src/components/dashboard/ProxyDialog.vue @@ -0,0 +1,218 @@ + + + diff --git a/web/src/components/dashboard/ToastOverlay.vue b/web/src/components/dashboard/ToastOverlay.vue new file mode 100644 index 0000000..6a9ca0d --- /dev/null +++ b/web/src/components/dashboard/ToastOverlay.vue @@ -0,0 +1,108 @@ + + + + + diff --git a/web/src/components/dashboard/TopNavBar.vue b/web/src/components/dashboard/TopNavBar.vue new file mode 100644 index 0000000..f766336 --- /dev/null +++ b/web/src/components/dashboard/TopNavBar.vue @@ -0,0 +1,199 @@ + + + diff --git a/web/src/composables/useAdmin.ts b/web/src/composables/useAdmin.ts new file mode 100644 index 0000000..de8248b --- /dev/null +++ b/web/src/composables/useAdmin.ts @@ -0,0 +1,486 @@ +/** + * 管理后台逻辑 — 所有 admin tab 的状态和操作 + * 依赖 useDashboard 的共享状态(toast) + */ +import { ref, reactive, computed, watch } from 'vue' +import { adminApi, notificationApi } from '../api/client' +import { useDashboard } from './useDashboard' + +// ── Admin 状态 ── +const showAdmin = ref(false) +const adminTab = ref('overview') +const adminStats = ref({}) +const adminUsers = ref([]) +const adminSettings = ref([]) +const adminSettingsLoading = ref(false) +const codeForm = reactive({ count: 10, credits_per_code: 50, batch_name: '' }) +const generatedCodes = ref([]) +const adminCodes = ref([]) +const codePage = ref(1) +const codePageSize = ref(20) +const codeTotal = ref(0) +const codeTotalPages = computed(() => Math.max(1, Math.ceil(codeTotal.value / codePageSize.value))) +const adminAnnouncements = ref([]) +const annForm = reactive({ title: '', content: '' }) + +// 实时状态 +const realtimeTasks = ref([]) +const realtimeOnlineUsers = ref(0) +let realtimeTimer: ReturnType | null = null +let realtimeLoading = false +const notifyForm = reactive({ user_id: 0, title: '', content: '' }) + +// 最近注册活动 +const recentActivity = ref([]) +const recentActivityTotal = ref(0) +const recentActivityPage = ref(1) +const recentActivityPageSize = ref(20) +const recentActivityLoading = ref(false) +const recentActivityTotalPages = computed(() => + Math.max(1, Math.ceil(recentActivityTotal.value / recentActivityPageSize.value)) +) + +// 用户通知(铃铛) +const userNotifications = ref([]) +const unreadCount = ref(0) +const showNotifPanel = ref(false) +let notifPollTimer: ReturnType | null = null +let notifLoading = false + +// 用户分页 +const userPage = ref(1) +const userPageSize = ref(20) +const userTotal = ref(0) + +// 用户详情 +const expandedUserId = ref(null) +const userDetailLoading = ref(false) +const userDetailData = ref(null) +const userSuccessRate = computed(() => { + if (!userDetailData.value?.task_summary) return 0 + const s = userDetailData.value.task_summary.total_success || 0 + const f = userDetailData.value.task_summary.total_fail || 0 + return s + f > 0 ? (s / (s + f)) * 100 : 0 +}) +const adminUserSearch = ref('') +// 用户列表排序 +const userSortField = ref<'credits' | 'newapi_quota' | 'created_at' | ''>('') +const userSortOrder = ref<'asc' | 'desc'>('desc') +function toggleUserSort(field: 'credits' | 'newapi_quota' | 'created_at') { + if (userSortField.value === field) { + userSortOrder.value = userSortOrder.value === 'desc' ? 'asc' : 'desc' + } else { + userSortField.value = field + userSortOrder.value = 'desc' + } + userPage.value = 1 +} +const filteredAdminUsers = computed(() => adminUsers.value) +const userTotalPages = computed(() => Math.max(1, Math.ceil(userTotal.value / userPageSize.value))) +const paginationRange = computed(() => { + const total = userTotalPages.value + const cur = userPage.value + if (total <= 7) return Array.from({ length: total }, (_, i) => i + 1) + const pages: (number | string)[] = [1] + if (cur > 3) pages.push('...') + for (let i = Math.max(2, cur - 1); i <= Math.min(total - 1, cur + 1); i++) pages.push(i) + if (cur < total - 2) pages.push('...') + pages.push(total) + return pages +}) + +// 数据清理 +const dataStats = ref({}) +const dataStatsLoading = ref(false) +const cleanupForm = reactive({ days: 90, clean_results: true, clean_tasks: true, clean_tx: false, clean_archived_only: false }) +const cleanupProcessing = ref(false) +const cleanupResult = ref(null) + +// 设置搜索 & 分组折叠 +const settingsSearch = ref('') +const collapsedGroups = ref>(new Set()) + +const _groupLabels: Record = { + infra: '基础设施', email: '邮箱服务', captcha: '验证码', + platform_grok: 'Grok 平台', platform_openai: 'OpenAI 平台', platform_kiro: 'Kiro 平台', platform_gemini: 'Gemini 平台', + user_policy: '用户策略', hfspace: 'HF 空间', +} + +const _groupIcons: Record = { + infra: '🔧', email: '📧', captcha: '🛡️', + platform_grok: '🤖', platform_openai: '🧠', platform_kiro: '⚡', platform_gemini: '✨', + user_policy: '👥', hfspace: '☁️', +} + +export function useAdmin() { + const d = useDashboard() + + // ── 基础加载 ── + async function openAdmin() { + showAdmin.value = !showAdmin.value + if (showAdmin.value) { loadAdminStats(); loadAdminUsers() } + } + async function loadAdminStats() { + try { const { data } = await adminApi.stats(); adminStats.value = data } catch { /* ignore */ } + } + async function loadAdminUsers() { + try { + const { data } = await adminApi.users({ + page: userPage.value, + page_size: userPageSize.value, + search: adminUserSearch.value.trim() || undefined, + sort_by: userSortField.value || undefined, + sort_order: userSortField.value ? userSortOrder.value : undefined, + }) + adminUsers.value = data.items || [] + userTotal.value = data.total || 0 + } catch { /* ignore */ } + } + + // ── 设置 ── + async function loadAdminSettings() { + adminSettingsLoading.value = true + try { + const { data } = await adminApi.settings() + adminSettings.value = data.map((s: any) => ({ ...s, _dirty: false, _editValue: undefined, _showRaw: false, _rawValue: null })) + } catch { /* ignore */ } + adminSettingsLoading.value = false + } + function settingsGroups(): string[] { + const groups: string[] = [] + for (const s of adminSettings.value) { if (!groups.includes(s.group)) groups.push(s.group) } + return groups + } + function groupLabel(group: string) { return _groupLabels[group] || group } + function groupIcon(group: string) { return _groupIcons[group] || '⚙️' } + function settingsInGroup(group: string) { return adminSettings.value.filter(s => s.group === group) } + + /** 按搜索词过滤后的分组设置 */ + function filteredSettingsInGroup(group: string) { + const q = settingsSearch.value.trim().toLowerCase() + const items = settingsInGroup(group) + if (!q) return items + return items.filter(s => + s.label.toLowerCase().includes(q) || + s.key.toLowerCase().includes(q) || + (s.description || '').toLowerCase().includes(q) + ) + } + + /** 分组配置状态统计 */ + function groupStats(group: string) { + const items = settingsInGroup(group) + const configured = items.filter(s => s.has_value).length + return { total: items.length, configured } + } + + /** 当前有多少项被修改 */ + const dirtyCount = computed(() => adminSettings.value.filter(s => s._dirty).length) + + function toggleGroup(group: string) { + if (collapsedGroups.value.has(group)) collapsedGroups.value.delete(group) + else collapsedGroups.value.add(group) + } + function isGroupCollapsed(group: string) { return collapsedGroups.value.has(group) } + + async function toggleSettingVisibility(s: any) { + if (s._showRaw) { s._showRaw = false; return } + try { const { data } = await adminApi.settingRaw(s.key); s._rawValue = data.value; s._showRaw = true } catch { /* ignore */ } + } + async function saveSetting(s: any) { + if (!s._dirty) return + const value = s._editValue !== undefined ? s._editValue : '' + try { + await adminApi.saveSetting(s.key, value) + s._dirty = false; s._editValue = undefined; s.has_value = !!value + if (s.is_secret && value) s.value = value.slice(0, 4) + '****' + value.slice(-4) + else s.value = value + // 刷新前端限制状态(上限、每日限制、平台开关等) + d.loadBalance() + } catch (e: any) { throw e } + } + + /** 批量保存所有修改项 */ + async function saveAllDirtySettings() { + const dirty = adminSettings.value.filter(s => s._dirty) + if (dirty.length === 0) return + let ok = 0, fail = 0 + for (const s of dirty) { + try { await saveSetting(s); ok++ } + catch { fail++ } + } + if (fail === 0) d.toast(`已保存 ${ok} 项设置`, 'success') + else d.toast(`保存完成:${ok} 成功,${fail} 失败`, 'error') + } + + // ── 兑换码 ── + async function generateCodes() { + try { + const { data } = await adminApi.generateCodes(codeForm.count, codeForm.credits_per_code, codeForm.batch_name) + generatedCodes.value = data.codes || [] + d.toast(data.message, 'success') + await loadAdminCodes() + } catch (e: any) { d.toast(e.response?.data?.detail || '生成失败', 'error') } + } + async function loadAdminCodes() { + try { + const { data } = await adminApi.codes({ page: codePage.value, page_size: codePageSize.value }) + adminCodes.value = data.items || [] + codeTotal.value = data.total || 0 + } catch { /* ignore */ } + } + function codeGoPage(p: number) { + if (p < 1 || p > codeTotalPages.value) return + codePage.value = p + loadAdminCodes() + } + function copyAllCodes() { d.copyText(generatedCodes.value.join('\n')); d.toast('已复制全部兑换码', 'success') } + + // ── 公告 ── + async function loadAdminAnnouncements() { + try { const { data } = await adminApi.announcements(); adminAnnouncements.value = data || [] } catch { /* ignore */ } + } + async function createAnnouncement() { + if (!annForm.title.trim() || !annForm.content.trim()) return + try { + await adminApi.createAnnouncement(annForm.title, annForm.content) + annForm.title = ''; annForm.content = '' + d.toast('公告已发布', 'success') + await loadAdminAnnouncements(); await d.loadAnnouncements() + } catch (e: any) { d.toast(e.response?.data?.detail || '发布失败', 'error') } + } + async function deleteAnnouncement(id: number) { + try { + await adminApi.deleteAnnouncement(id); d.toast('已删除', 'success') + await loadAdminAnnouncements(); await d.loadAnnouncements() + } catch (e: any) { d.toast(e.response?.data?.detail || '删除失败', 'error') } + } + + // ── 用户管理 ── + function userGoPage(p: number) { + if (p < 1 || p > userTotalPages.value) return + userPage.value = p + expandedUserId.value = null + userDetailData.value = null + loadAdminUsers() + } + + async function adminRecharge(userId: number, username: string) { + const amount = prompt(`给 ${username} 充值/扣除积分?\n正数充值,负数扣除(如 -100)`) + if (!amount || isNaN(Number(amount)) || Number(amount) === 0) return + const n = parseInt(amount) + if (n < 0 && !confirm(`确认从 ${username} 扣除 ${-n} 积分?`)) return + try { + const { data } = await adminApi.recharge(userId, n) + d.toast(data.message, 'success'); await loadAdminUsers() + } catch (e: any) { d.toast(e.response?.data?.detail || '操作失败', 'error') } + } + async function toggleAdminRole(userId: number) { + try { + const { data } = await adminApi.toggleAdmin(userId) + d.toast(data.message, 'success'); await loadAdminUsers() + } catch (e: any) { d.toast(e.response?.data?.detail || '操作失败', 'error') } + } + + // ── 用户详情 ── + async function toggleUserDetail(userId: number) { + if (expandedUserId.value === userId) { expandedUserId.value = null; userDetailData.value = null; return } + expandedUserId.value = userId; userDetailLoading.value = true; userDetailData.value = null + try { const { data } = await adminApi.userDetail(userId); userDetailData.value = data } + catch (e: any) { d.toast(e.response?.data?.detail || '加载用户详情失败', 'error'); expandedUserId.value = null } + finally { userDetailLoading.value = false } + } + function getUserDetailPassword(r: any): string { + const cred = r.credential_data + return (cred && typeof cred === 'object') ? cred.password || '' : '' + } + function copyUserDetailAccount(r: any) { + const cred = r.credential_data + d.copyText(`${cred?.email || r.email || ''}----${cred?.password || ''}`) + } + + // ── 数据清理 ── + async function loadDataStats() { + dataStatsLoading.value = true + try { const { data } = await adminApi.dataStats(); dataStats.value = data } catch { /* ignore */ } + dataStatsLoading.value = false + } + async function executeCleanup() { + if (cleanupProcessing.value) return + if (!confirm(`确定要清理 ${cleanupForm.days} 天前的数据吗?此操作不可撤销!`)) return + cleanupProcessing.value = true; cleanupResult.value = null + try { + const { data } = await adminApi.cleanup({ ...cleanupForm }) + cleanupResult.value = data; d.toast(data.message, 'success'); await loadDataStats() + } catch (e: any) { d.toast(e.response?.data?.detail || '清理失败', 'error') } + finally { cleanupProcessing.value = false } + } + + // ── 实时任务 ── + async function loadRealtimeTasks() { + if (realtimeLoading) return; realtimeLoading = true + try { + const { data } = await adminApi.runningTasks() + realtimeTasks.value = data.tasks || []; realtimeOnlineUsers.value = data.online_users || 0 + } catch (e: any) { d.toast(e.response?.data?.detail || '加载实时任务失败', 'error') } finally { realtimeLoading = false } + } + + // ── 最近注册活动 ── + async function loadRecentActivity() { + recentActivityLoading.value = true + try { + const { data } = await adminApi.recentActivity({ + page: recentActivityPage.value, + page_size: recentActivityPageSize.value, + }) + recentActivity.value = data.items || [] + recentActivityTotal.value = data.total || 0 + } catch (e: any) { d.toast(e.response?.data?.detail || '加载注册活动失败', 'error') } + recentActivityLoading.value = false + } + function recentActivityGoPage(p: number) { + if (p < 1 || p > recentActivityTotalPages.value) return + recentActivityPage.value = p + loadRecentActivity() + } + function startRealtimePolling() { + loadRealtimeTasks() + loadRecentActivity() + if (realtimeTimer) clearInterval(realtimeTimer) + realtimeTimer = setInterval(loadRealtimeTasks, 3000) + } + function stopRealtimePolling() { if (realtimeTimer) { clearInterval(realtimeTimer); realtimeTimer = null } } + async function adminStopTask(taskId: number) { + try { const { data } = await adminApi.stopTask(taskId); d.toast(data.message, 'success'); await loadRealtimeTasks() } + catch (e: any) { d.toast(e.response?.data?.detail || '停止失败', 'error') } + } + async function adminDeleteTask(taskId: number) { + if (!confirm('确定要删除此任务及其所有结果吗?此操作不可撤销!')) return + try { const { data } = await adminApi.deleteTask(taskId); d.toast(data.message, 'success'); await loadRealtimeTasks() } + catch (e: any) { d.toast(e.response?.data?.detail || '删除失败', 'error') } + } + async function sendNotification() { + if (!notifyForm.title.trim() || !notifyForm.content.trim()) return + try { + const { data } = await adminApi.sendNotification({ user_id: notifyForm.user_id, title: notifyForm.title, content: notifyForm.content }) + d.toast(data.message, 'success'); notifyForm.user_id = 0; notifyForm.title = ''; notifyForm.content = '' + } catch (e: any) { d.toast(e.response?.data?.detail || '发送失败', 'error') } + } + function formatElapsedSec(sec: number): string { + if (sec < 60) return `${Math.round(sec)}秒` + const m = Math.floor(sec / 60); const s = Math.round(sec % 60) + return s > 0 ? `${m}分${s}秒` : `${m}分` + } + + // ── 相对时间格式化 ── + function formatNotifTime(dateStr: string): string { + const d = new Date(dateStr) + const now = Date.now() + const diff = now - d.getTime() + if (diff < 0) return '刚刚' + const sec = Math.floor(diff / 1000) + if (sec < 60) return '刚刚' + const min = Math.floor(sec / 60) + if (min < 60) return `${min}分钟前` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}小时前` + const day = Math.floor(hr / 24) + if (day === 1) return '昨天' + if (day < 7) return `${day}天前` + return d.toLocaleDateString() + } + + // ── 用户通知(铃铛) ── + async function loadUserNotifications() { + if (notifLoading) return; notifLoading = true + try { + const { data } = await notificationApi.list() + userNotifications.value = data.notifications || []; unreadCount.value = data.unread_count || 0 + } catch { /* ignore */ } finally { notifLoading = false } + } + function startNotifPolling() { + loadUserNotifications() + if (notifPollTimer) clearInterval(notifPollTimer) + notifPollTimer = setInterval(loadUserNotifications, 6000) + } + function stopNotifPolling() { if (notifPollTimer) { clearInterval(notifPollTimer); notifPollTimer = null } } + async function markNotifRead(id: number | string) { + try { await notificationApi.markRead(id); await loadUserNotifications() } catch { /* ignore */ } + } + async function markAllNotifsRead() { + try { await notificationApi.markRead('all'); await loadUserNotifications() } catch { /* ignore */ } + } + + // ── 管理后台通知列表 ── + const adminNotifications = ref([]) + const adminNotifsLoading = ref(false) + async function loadAdminNotifications() { + adminNotifsLoading.value = true + try { + const { data } = await adminApi.listNotifications() + adminNotifications.value = data || [] + } catch { /* ignore */ } + adminNotifsLoading.value = false + } + async function deleteAdminNotification(id: number) { + if (!confirm('确定要删除此通知吗?')) return + try { + await adminApi.deleteNotification(id) + d.toast('通知已删除', 'success') + await loadAdminNotifications() + } catch (e: any) { d.toast(e.response?.data?.detail || '删除失败', 'error') } + } + + // ── Watchers ── + let searchDebounce: ReturnType | null = null + function setupAdminWatchers() { + watch(adminTab, (tab) => { + if (tab === 'realtime') { startRealtimePolling() } + else stopRealtimePolling() + }) + watch(showAdmin, (open) => { if (!open) stopRealtimePolling() }) + watch(adminUserSearch, () => { + if (searchDebounce) clearTimeout(searchDebounce) + searchDebounce = setTimeout(() => { userPage.value = 1; loadAdminUsers() }, 300) + }) + // 排序变更时重新从服务器加载 + watch([userSortField, userSortOrder], () => { loadAdminUsers() }) + } + + // ── 清理 ── + function cleanup() { stopNotifPolling(); stopRealtimePolling() } + + return { + // 状态 + showAdmin, adminTab, adminStats, adminUsers, adminSettings, adminSettingsLoading, + settingsSearch, collapsedGroups, dirtyCount, + codeForm, generatedCodes, adminCodes, codePage, codePageSize, codeTotal, codeTotalPages, + adminAnnouncements, annForm, + realtimeTasks, realtimeOnlineUsers, notifyForm, + recentActivity, recentActivityTotal, recentActivityPage, + recentActivityPageSize, recentActivityTotalPages, recentActivityLoading, + userNotifications, unreadCount, showNotifPanel, + adminNotifications, adminNotifsLoading, + expandedUserId, userDetailLoading, userDetailData, userSuccessRate, + adminUserSearch, filteredAdminUsers, userSortField, userSortOrder, toggleUserSort, + userPage, userPageSize, userTotal, userTotalPages, paginationRange, + dataStats, dataStatsLoading, cleanupForm, cleanupProcessing, cleanupResult, + // 方法 + openAdmin, loadAdminStats, loadAdminUsers, userGoPage, + loadAdminSettings, settingsGroups, groupLabel, groupIcon, settingsInGroup, + filteredSettingsInGroup, groupStats, toggleGroup, isGroupCollapsed, + toggleSettingVisibility, saveSetting, saveAllDirtySettings, + generateCodes, loadAdminCodes, copyAllCodes, codeGoPage, + loadAdminAnnouncements, createAnnouncement, deleteAnnouncement, + adminRecharge, toggleAdminRole, toggleUserDetail, + getUserDetailPassword, copyUserDetailAccount, + loadDataStats, executeCleanup, + loadRealtimeTasks, startRealtimePolling, stopRealtimePolling, + loadRecentActivity, recentActivityGoPage, + adminStopTask, adminDeleteTask, sendNotification, formatElapsedSec, + loadUserNotifications, startNotifPolling, stopNotifPolling, + formatNotifTime, markNotifRead, markAllNotifsRead, + loadAdminNotifications, deleteAdminNotification, + setupAdminWatchers, cleanup, + } +} diff --git a/web/src/composables/useCardPool.ts b/web/src/composables/useCardPool.ts new file mode 100644 index 0000000..3722f14 --- /dev/null +++ b/web/src/composables/useCardPool.ts @@ -0,0 +1,231 @@ +/** + * 卡池管理 — 管理后台数据层 + * 提供卡池的增删改查、验证、批量导入、清除无效卡等功能 + */ +import { ref, reactive, computed } from 'vue' +import { cardPoolApi } from '../api/client' +import { useDashboard } from './useDashboard' + +// ── 类型定义 ── + +export interface CardEntry { + id: number + name: string + card_number: string + exp_month: number + exp_year: number + cvc: string + billing_name: string + billing_email: string + billing_country: string + billing_city: string + billing_line1: string + billing_zip: string + provider: string + source: string + is_valid: boolean + use_count: number + fail_count: number + last_used_at?: string + last_valid_at?: string +} + +export interface CardPoolStats { + total: number + valid: number + invalid: number +} + +// ── 模块级单例状态 ── + +const cards = ref([]) +const cardStats = reactive({ total: 0, valid: 0, invalid: 0 }) +const loading = ref(false) +const statsLoading = ref(false) +const validateRunning = ref(false) +const purgeRunning = ref(false) +const filterMode = ref<'' | 'valid' | 'invalid'>('') +const selectedIds = ref>(new Set()) + +// 添加表单 +const addForm = reactive({ + name: '', card_number: '', exp_month: 1, exp_year: 2026, cvc: '', + billing_name: '', billing_email: '', billing_country: 'US', + billing_city: '', billing_line1: '', billing_zip: '', provider: 'manual', +}) +const addDialogOpen = ref(false) +const addLoading = ref(false) + +// 批量导入 +const importDialogOpen = ref(false) +const importText = ref('') +const importProvider = ref('manual') +const importLoading = ref(false) + +// 分页 +const currentPage = ref(1) +const pageSize = ref(50) +const totalCount = ref(0) + +export function useCardPool() { + const { toast } = useDashboard() + + // ── 加载 ── + + async function loadCards() { + loading.value = true + try { + const { data } = await cardPoolApi.list({ + page: currentPage.value, page_size: pageSize.value, + filter: filterMode.value || undefined, + }) + cards.value = data.items || [] + totalCount.value = data.total || 0 + } catch (e: any) { + toast(e.response?.data?.detail || '加载卡池失败', 'error') + } finally { loading.value = false } + } + + async function loadStats() { + statsLoading.value = true + try { + const { data } = await cardPoolApi.stats() + Object.assign(cardStats, data) + } catch (e: any) { + console.warn('加载卡池统计失败:', e.response?.data?.detail || e.message) + } finally { statsLoading.value = false } + } + + async function loadAll() { await Promise.all([loadCards(), loadStats()]) } + + // ── 操作 ── + + async function addCard() { + if (!addForm.card_number.trim() || !addForm.cvc.trim()) return + addLoading.value = true + try { + await cardPoolApi.add({ + name: addForm.name.trim() || undefined, + card_number: addForm.card_number.trim(), + exp_month: Number(addForm.exp_month), exp_year: Number(addForm.exp_year), + cvc: addForm.cvc.trim(), + billing_name: addForm.billing_name.trim() || undefined, + billing_email: addForm.billing_email.trim() || undefined, + billing_country: addForm.billing_country.trim() || undefined, + billing_city: addForm.billing_city.trim() || undefined, + billing_line1: addForm.billing_line1.trim() || undefined, + billing_zip: addForm.billing_zip.trim() || undefined, + provider: addForm.provider || undefined, + }) + toast('卡片已添加', 'success') + addDialogOpen.value = false; resetAddForm(); await loadAll() + } catch (e: any) { toast(e.response?.data?.detail || '添加失败', 'error') } + finally { addLoading.value = false } + } + + function resetAddForm() { + Object.assign(addForm, { + name: '', card_number: '', exp_month: 1, exp_year: 2026, cvc: '', + billing_name: '', billing_email: '', billing_country: 'US', + billing_city: '', billing_line1: '', billing_zip: '', provider: 'manual', + }) + } + + async function deleteCard(id: number) { + if (!confirm('确定要删除此卡片吗?')) return + try { + await cardPoolApi.delete(id); toast('已删除', 'success') + selectedIds.value.delete(id); await loadAll() + } catch (e: any) { toast(e.response?.data?.detail || '删除失败', 'error') } + } + + async function batchDelete() { + const ids = [...selectedIds.value] + if (!ids.length || !confirm(`确定要删除选中的 ${ids.length} 张卡吗?`)) return + try { + await cardPoolApi.batchDelete(ids) + toast(`已删除 ${ids.length} 张卡`, 'success'); selectedIds.value.clear(); await loadAll() + } catch (e: any) { toast(e.response?.data?.detail || '批量删除失败', 'error') } + } + + async function importCards() { + const text = importText.value.trim() + if (!text) return + importLoading.value = true + try { + const { data } = await cardPoolApi.import(text, importProvider.value) + toast(data.message || `已导入 ${data.imported || 0} 张卡`, 'success') + importDialogOpen.value = false; importText.value = ''; await loadAll() + } catch (e: any) { toast(e.response?.data?.detail || '导入失败', 'error') } + finally { importLoading.value = false } + } + + async function triggerValidate() { + if (validateRunning.value) return + validateRunning.value = true + try { + const ids = selectedIds.value.size > 0 ? [...selectedIds.value] : cards.value.map(c => c.id) + const { data } = await cardPoolApi.validate(ids) + toast(data.message || '验证已完成', 'success') + setTimeout(() => loadAll(), 3000) + } catch (e: any) { toast(e.response?.data?.detail || '触发验证失败', 'error') } + finally { setTimeout(() => { validateRunning.value = false }, 3000) } + } + + async function purgeInvalid() { + if (!confirm('确定要清除所有无效卡吗?')) return + purgeRunning.value = true + try { + const { data } = await cardPoolApi.purge() + toast(data.message || '无效卡已清除', 'success'); await loadAll() + } catch (e: any) { toast(e.response?.data?.detail || '清除失败', 'error') } + finally { purgeRunning.value = false } + } + + // ── 选择 / 分页 / 工具 ── + + function toggleSelect(id: number) { + if (selectedIds.value.has(id)) selectedIds.value.delete(id) + else selectedIds.value.add(id) + } + function toggleSelectAll() { + if (selectedIds.value.size === cards.value.length) selectedIds.value.clear() + else selectedIds.value = new Set(cards.value.map(c => c.id)) + } + + const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value))) + function goToPage(p: number) { + if (p < 1 || p > totalPages.value) return + currentPage.value = p; loadCards() + } + + function maskCardNumber(num: string): string { + if (!num || num.length < 4) return num || '' + return '**** **** **** ' + num.slice(-4) + } + + function formatTime(dateStr?: string): string { + if (!dateStr) return '-' + const d = new Date(dateStr) + const diff = Date.now() - d.getTime() + const min = Math.floor(diff / 60000) + if (min < 1) return '刚刚' + if (min < 60) return `${min}分钟前` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}小时前` + return `${Math.floor(hr / 24)}天前` + } + + return { + cards, cardStats, loading, statsLoading, + validateRunning, purgeRunning, filterMode, selectedIds, + addForm, addDialogOpen, addLoading, + importDialogOpen, importText, importProvider, importLoading, + currentPage, pageSize, totalCount, totalPages, + loadCards, loadStats, loadAll, + addCard, resetAddForm, deleteCard, batchDelete, importCards, + triggerValidate, purgeInvalid, + toggleSelect, toggleSelectAll, goToPage, + maskCardNumber, formatTime, + } +} diff --git a/web/src/composables/useDashboard.ts b/web/src/composables/useDashboard.ts new file mode 100644 index 0000000..0a00552 --- /dev/null +++ b/web/src/composables/useDashboard.ts @@ -0,0 +1,1319 @@ +/** + * 核心仪表盘状态 — 模块级单例 + * 所有组件共享同一份 reactive 状态,避免 prop drilling + */ +import { ref, reactive, computed, watch } from 'vue' +import JSZip from 'jszip' +import { + authApi, taskApi, creditApi, resultApi, emailApi, + announcementApi, initApi, statsApi, +} from '../api/client' + +// ══════════ 完成通知队列(首页右下角 toast) ══════════ + +interface CompletionToast { + id: number + taskId: number + username: string + platform: string + success: number + time: string +} + +const completionQueue = ref([]) +const activeCompletion = ref(null) +const completionVisible = ref(false) +let completionLastTs = Math.floor(Date.now() / 1000) +let completionSeenIds = new Set() +let completionPollTimer: ReturnType | null = null +let completionShowTimer: ReturnType | null = null +let completionAnimTimer: ReturnType | null = null +let completionToastId = 0 + +// ══════════ 全站动态(LogPanel 公共展示) ══════════ + +interface RecentCompletion { + task_id: number + username: string + name?: string // 昵称(优先显示) + avatar_url?: string // 头像 URL(可选,无则显示默认头像) + platform: string + success_count: number + target_count: number + duration_sec: number + stopped_at: number +} + +const recentCompletions = ref([]) + +// 按用户去重,同一用户只保留最新一条 +function dedupeByUser(items: RecentCompletion[]): RecentCompletion[] { + const seen = new Set() + return items.filter(item => { + const key = item.username || `__task_${item.task_id}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +async function loadRecentCompletions() { + try { + const { data } = await statsApi.recentCompletions() + recentCompletions.value = dedupeByUser(data.items || []) + } catch { /* ignore */ } +} + +function processCompletionQueue() { + if (activeCompletion.value || completionQueue.value.length === 0) return + activeCompletion.value = completionQueue.value.shift()! + completionVisible.value = true + completionShowTimer = setTimeout(() => { + completionVisible.value = false + // 等动画结束后清除当前,显示下一个 + completionAnimTimer = setTimeout(() => { + activeCompletion.value = null + processCompletionQueue() + }, 400) + }, 4000) +} + +async function pollLatestCompletions() { + try { + const { data } = await statsApi.latestCompletions(completionLastTs) + const items = data.items || [] + if (items.length === 0) return + // 更新时间戳 + for (const item of items) { + if (item.stopped_at > completionLastTs) completionLastTs = item.stopped_at + } + // 去重:只添加未见过的 + let hasNew = false + for (const item of items) { + if (completionSeenIds.has(item.task_id)) continue + hasNew = true + completionSeenIds.add(item.task_id) + // 限制 seen set 大小 + if (completionSeenIds.size > 200) { + const arr = [...completionSeenIds] + completionSeenIds = new Set(arr.slice(-100)) + } + const platformLabel = ({ grok: 'Grok', openai: 'OpenAI', kiro: 'Kiro', gemini: 'Gemini' } as Record)[item.platform] || item.platform + const now = new Date() + const timeStr = `${now.getHours().toString().padStart(2, '0')}:${now.getMinutes().toString().padStart(2, '0')}` + completionQueue.value.push({ + id: ++completionToastId, + taskId: item.task_id, + username: item.username, + platform: platformLabel, + success: item.success_count, + time: timeStr, + }) + } + processCompletionQueue() + // 有新完成任务时刷新全站动态面板 + if (hasNew) loadRecentCompletions() + } catch { /* ignore */ } +} + +function startCompletionPolling() { + completionLastTs = Math.floor(Date.now() / 1000) + completionSeenIds.clear() + pollLatestCompletions() + if (completionPollTimer) clearInterval(completionPollTimer) + completionPollTimer = setInterval(pollLatestCompletions, 5000) +} + +function stopCompletionPolling() { + if (completionPollTimer) { clearInterval(completionPollTimer); completionPollTimer = null } + if (completionShowTimer) { clearTimeout(completionShowTimer); completionShowTimer = null } + if (completionAnimTimer) { clearTimeout(completionAnimTimer); completionAnimTimer = null } + completionQueue.value = [] + activeCompletion.value = null + completionVisible.value = false +} + +// ══════════ 响应式状态 ══════════ + +const user = reactive({ + id: 0, username: '', name: '', credits: 0, role: 1, + avatar_url: '', is_admin: false, quota: 0, + balance_display: '$0.00', registrations_available: 0, + unit_price: 0, unit_price_display: '$0', + newapi_balance: 0, newapi_balance_display: '$0', + newapi_available: 0, mode: 'local', + platform_prices: {} as Record, +}) + +const limits = reactive({ + max_target: 0, max_threads: 0, + daily_reg_limit: 0, daily_used: 0, daily_remaining: -1, + platform_grok_enabled: true, platform_openai_enabled: true, platform_kiro_enabled: true, platform_gemini_enabled: true, + thread_tiers: '' as string, + platform_grok_free_until: '', platform_openai_free_until: '', platform_kiro_free_until: '', platform_gemini_free_until: '', + free_mode: null as Record | null, + platform_limits: null as Record | null, +}) + +const taskForm = reactive({ platform: 'grok', target_count: 5, thread_count: 2 }) +const taskMode = ref<'free' | 'paid'>('free') +const taskStatus = reactive({ + success_count: 0, fail_count: 0, target: 0, threads: 0, + credits_reserved: 0, + is_done: true, task_id: null as number | null, platform: 'grok', +}) +const isRunning = ref(false) +const isQueued = ref(false) +const elapsedSeconds = ref(0) +const processing = ref(false) +const logs = ref([]) +const logPanel = ref() +const lastLogAt = ref(0) // 最后一条日志到达的时间戳(ms),用于前端活跃指示器 + +const results = ref([]) +const platformArchivedCount = reactive>({ grok: 0, openai: 0, kiro: 0, gemini: 0 }) +const archivedCountLoaded = ref(false) +const lastArchivedCount = reactive>({ grok: 0, openai: 0, kiro: 0, gemini: 0 }) +const otpLoading = reactive>({}) +const txHistory = ref([]) +const txHistoryLoaded = ref(false) +const txDisplayCount = ref(10) +const announcements = ref([]) +const freeTrial = reactive({ eligible: false, remaining: 0, total: 0, claimed: false }) +const smartInput = ref('') +const purchaseProcessing = ref(false) +const exportingMap = reactive>({}) +const exporting = computed(() => Object.values(exportingMap).some(Boolean)) +const toasts = ref<{ id: number; msg: string; type: string }[]>([]) +let toastId = 0 +const taskNotice = reactive({ msg: '', type: 'info', time: '' }) +const queueModal = reactive({ show: false, message: '', position: 0, waitTime: '' }) + +const globalStats = reactive({ + running_tasks: 0, queued_tasks: 0, active_users: 0, + today_total: 0, today_platforms: {} as Record, + today_success: 0, today_fail: 0, + success_rate: 0, total_results: 0, +}) +const userStats = reactive({ total_success: 0, total_fail: 0 }) +const byPlatformStats = reactive>({}) +const avgSecPerReg = reactive>({}) + +// ══════════ Computed ══════════ + +const smartInputMode = computed(() => { + const v = smartInput.value.trim() + return v && /^\d+$/.test(v) && user.mode === 'newapi' ? 'purchase' : 'redeem' +}) +const smartInputAmount = computed(() => { + if (smartInputMode.value !== 'purchase') return 0 + return parseInt(smartInput.value.trim()) || 0 +}) + +const platformLabel = computed(() => ({ grok: 'Grok', openai: 'OpenAI', kiro: 'Kiro', gemini: 'Gemini' }[taskForm.platform] || 'Grok')) +const platformUserStats = computed(() => byPlatformStats[taskForm.platform] || { success: 0, fail: 0 }) + +const sliderMax = computed(() => { + let cap = limits.max_target || 1000 + + // 平台独立限制(付费/免费均生效) + const pl = limits.platform_limits?.[taskForm.platform] + if (pl) { + if (pl.task_limit > 0) cap = Math.min(cap, pl.task_limit) + if (pl.daily_remaining >= 0) cap = Math.min(cap, pl.daily_remaining) + } + + if (currentPlatformFree.value && taskMode.value === 'free') { + const taskLimit = currentFreeMode.value?.task_limit + if (taskLimit && taskLimit > 0) cap = Math.min(cap, taskLimit) + return Math.max(1, cap) + } + const available = (user.registrations_available || 0) + (freeTrial.remaining || 0) + if (available > 0) cap = Math.min(cap, available) + return Math.max(1, cap) +}) + +const quickTargets = computed(() => { + const max = sliderMax.value + const base = [1, 10, 50, 100, 500, 1000].filter(q => q <= max) + // 确保 sliderMax 本身在列表中(如上限是 2 时显示 [1, 2]) + if (max > 0 && !base.includes(max)) base.push(max) + return base +}) + +const estimatedTime = computed(() => { + const avg = avgSecPerReg[taskForm.platform] + if (!avg || avg <= 0) return '' + const threads = taskForm.thread_count || 1 + const totalSec = Math.round((avg * taskForm.target_count) / threads) + if (totalSec < 60) return `~${totalSec}秒` + const min = Math.floor(totalSec / 60) + const sec = totalSec % 60 + return sec > 0 ? `~${min}分${sec}秒` : `~${min}分` +}) + +const estimatedRemaining = computed(() => { + if (!isRunning.value || elapsedSeconds.value <= 0 || taskStatus.success_count <= 0) return '' + const secPerReg = elapsedSeconds.value / taskStatus.success_count + const remaining = Math.max(0, taskStatus.target - taskStatus.success_count) + const remSec = Math.round(secPerReg * remaining) + if (remSec < 60) return `剩余 ~${remSec}秒` + const min = Math.floor(remSec / 60) + const sec = remSec % 60 + return sec > 0 ? `剩余 ~${min}分${sec}秒` : `剩余 ~${min}分` +}) + +const currentPlatformEnabled = computed(() => { + const key = `platform_${taskForm.platform}_enabled` as keyof typeof limits + return limits[key] !== false +}) + +const currentPlatformFree = computed(() => isPlatformFreeByKey(taskForm.platform)) +const currentFreeMode = computed(() => limits.free_mode?.[taskForm.platform] || null) +const currentFreeModeAvailable = computed(() => currentFreeMode.value?.available ?? false) + +const currentPlatformPrice = computed(() => user.platform_prices[taskForm.platform] || user.unit_price || 0) +const currentPlatformPriceDisplay = computed(() => { + const price = currentPlatformPrice.value + return price > 0 ? `$${price}` : '' +}) + +// 当前平台独立限制(付费/免费均生效) +const currentPlatformLimit = computed(() => limits.platform_limits?.[taskForm.platform] || null) +// 当前平台每日注册状态文本(如 "82/3000",不含后缀) +const currentPlatformDailyText = computed(() => { + const pl = currentPlatformLimit.value + if (!pl || pl.daily_limit <= 0) { + // 回退全局每日限制 + if (limits.daily_reg_limit > 0) { + return `${limits.daily_used}/${limits.daily_reg_limit}` + } + return '' + } + return `${pl.daily_used}/${pl.daily_limit}` +}) +// 当前平台有效的每日上限(用于上限卡片显示) +const currentPlatformMaxDisplay = computed(() => { + const pl = currentPlatformLimit.value + // 优先平台级每日上限,回退全局每日上限 + if (pl?.daily_limit && pl.daily_limit > 0) return pl.daily_limit + if (limits.daily_reg_limit > 0) return limits.daily_reg_limit + return 0 // 0 = 不限 +}) + +// sliderMax 变化时自动 clamp target_count(切平台、限制变更等) +watch(sliderMax, (max) => { + if (taskForm.target_count > max) taskForm.target_count = max + if (taskForm.target_count < 1) taskForm.target_count = 1 +}) + +const platformTitleClass = computed(() => ({ + grok: 'from-blue-400 to-cyan-400', + openai: 'from-green-400 to-emerald-400', + kiro: 'from-amber-400 to-orange-400', + gemini: 'from-purple-400 to-fuchsia-400', +}[taskForm.platform] || 'from-blue-400 to-cyan-400')) + +const refundAmount = computed(() => { + if (!taskStatus.is_done || isRunning.value) return 0 + const reserved = taskStatus.credits_reserved || taskStatus.target + return Math.max(0, reserved - taskStatus.success_count) +}) + +const vipLabel = computed(() => vipLabelFor(user.role)) +const vipBadgeClass = computed(() => vipBadgeClassFor(user.role)) +const vipCardGlow = computed(() => ({ 10: 'card-glow-vvip', 100: 'card-glow-svip' }[user.role as 10 | 100] || '')) +const vipAvatarRing = computed(() => ({ 1: 'avatar-ring-normal', 10: 'avatar-ring-vvip', 100: 'avatar-ring-svip' }[user.role as 1 | 10 | 100] || 'avatar-ring-normal')) + +// 平台列配置(模板渲染用) +const platformCols = [ + { key: 'grok', label: 'Grok', dotClass: 'bg-blue-500', textClass: 'text-info', hoverClass: 'text-info', exportClass: 'text-info', archiveBadge: 'bg-blue-500/15 text-info', countBadgeClass: 'bg-blue-500/15 text-info' }, + { key: 'openai', label: 'OpenAI', dotClass: 'bg-green-500', textClass: 'text-ok', hoverClass: 'text-ok', exportClass: 'text-ok', archiveBadge: 'bg-green-500/15 text-ok', countBadgeClass: 'bg-green-500/15 text-ok' }, + { key: 'kiro', label: 'Kiro', dotClass: 'bg-amber-500', textClass: 'text-warn', hoverClass: 'text-warn', exportClass: 'text-warn', archiveBadge: 'bg-amber-500/15 text-warn', countBadgeClass: 'bg-amber-500/15 text-warn' }, + { key: 'gemini', label: 'Gemini', dotClass: 'bg-purple-500', textClass: 'text-purple-400', hoverClass: 'text-purple-400', exportClass: 'text-purple-400', archiveBadge: 'bg-purple-500/15 text-purple-400', countBadgeClass: 'bg-purple-500/15 text-purple-400' }, +] + +const platformResults = computed>(() => ({ + grok: results.value.filter(r => (r.platform || 'grok') === 'grok'), + openai: results.value.filter(r => r.platform === 'openai'), + kiro: results.value.filter(r => r.platform === 'kiro'), + gemini: results.value.filter(r => r.platform === 'gemini'), +})) + +// 按需渲染:每平台默认只渲染前 N 条,防止 DOM 过多卡顿 +const platformDisplayLimit = reactive>({ grok: 20, openai: 20, kiro: 20, gemini: 20 }) +const displayedPlatformResults = computed>(() => ({ + grok: platformResults.value.grok.slice(0, platformDisplayLimit.grok), + openai: platformResults.value.openai.slice(0, platformDisplayLimit.openai), + kiro: platformResults.value.kiro.slice(0, platformDisplayLimit.kiro), + gemini: platformResults.value.gemini.slice(0, platformDisplayLimit.gemini), +})) +function showMoreResults(platform: string) { + platformDisplayLimit[platform] += 30 +} + +// ══════════ Helpers ══════════ + +function vipLabelFor(r: number) { return ({ 1: '用户', 10: '管理员', 100: '超管' } as any)[r] || '用户' } +function vipBadgeClassFor(r: number) { return ({ 1: 'badge-vip', 10: 'badge-vvip', 100: 'badge-svip' } as any)[r] || 'badge-normal' } + +function isPlatformFreeByKey(platform: string): boolean { + const key = `platform_${platform}_free_until` as keyof typeof limits + const dateStr = limits[key] as string + if (!dateStr) return false + return new Date() <= new Date(dateStr + 'T23:59:59') +} + +function platformHealthRate(platform: string): number { + const ps = byPlatformStats[platform] + if (!ps || (ps.success + ps.fail) === 0) return -1 + return (ps.success / (ps.success + ps.fail)) * 100 +} + +function platformHealthColor(platform: string): string { + const rate = platformHealthRate(platform) + if (rate < 0) return 'bg-t-faint' + if (rate >= 80) return 'bg-green-500' + if (rate >= 50) return 'bg-amber-500' + return 'bg-red-500' +} + +function toast(msg: string, type = 'info') { + if (toasts.value.some(t => t.msg === msg)) return + const id = ++toastId + toasts.value.push({ id, msg, type }) + setTimeout(() => { toasts.value = toasts.value.filter(t => t.id !== id) }, 3000) +} + +function setTaskNotice(msg: string, type = 'info') { + taskNotice.msg = msg; taskNotice.type = type + taskNotice.time = new Date().toLocaleTimeString() +} + +function clearTaskNotice() { taskNotice.msg = ''; taskNotice.type = 'info'; taskNotice.time = '' } + +function getPassword(r: any): string { + const cred = r.credential_data + return (cred && typeof cred === 'object') ? cred.password || '' : '' +} + +function copyText(text: string) { + navigator.clipboard.writeText(text) + .then(() => toast('已复制', 'success')) + .catch(() => toast('复制失败', 'error')) +} + +function copyEmailPassword(r: any) { + const cred = r.credential_data + const email = cred?.email || r.email || '' + const password = cred?.password || '' + copyText(`${email}----${password}`) +} + +function formatElapsed(secs: number): string { + const m = Math.floor(secs / 60) + const s = secs % 60 + return m > 0 ? `${m}分${s.toString().padStart(2, '0')}秒` : `${s}秒` +} + +// ══════════ API 调用 ══════════ + +async function loadUser() { + try { const { data } = await authApi.me(); Object.assign(user, data) } + catch { window.location.href = '/' } +} + +async function loadBalance() { + try { + const { data } = await creditApi.balance() + const costPerReg = data.cost_per_reg || 1 + user.quota = data.quota || 0 + user.balance_display = data.display || '' + user.registrations_available = data.registrations_available ?? Math.floor((data.credits || 0) / costPerReg) + user.unit_price = data.unit_price || 0 + user.unit_price_display = data.unit_price_display || '' + user.newapi_balance = data.newapi_balance || 0 + user.newapi_balance_display = data.newapi_balance_display || '$0' + user.newapi_available = data.newapi_available || 0 + user.mode = data.mode || 'local' + user.platform_prices = data.platform_prices || {} + Object.assign(limits, data.limits || {}) + // 当前选中平台被关闭时自动切换 + const currentKey = `platform_${taskForm.platform}_enabled` as keyof typeof limits + if (limits[currentKey] === false) { + const first = (['grok', 'openai', 'kiro', 'gemini'] as const).find( + p => limits[`platform_${p}_enabled` as keyof typeof limits] !== false + ) + if (first) taskForm.platform = first + } + if (data.free_trial) { + freeTrial.eligible = data.free_trial.eligible + freeTrial.total = data.free_trial.total || data.free_trial.remaining + if (!freeTrial.claimed) freeTrial.remaining = data.free_trial.remaining + } else if (data.free_trial_remaining !== undefined) { + freeTrial.eligible = !data.free_trial_used + if (!freeTrial.claimed) freeTrial.remaining = data.free_trial_remaining || 0 + freeTrial.total = freeTrial.total || freeTrial.remaining + } + } catch { /* ignore */ } +} + +async function loadResults() { + try { + const { data } = await resultApi.list(undefined, 1, -1) + results.value = data.items || data || [] + } catch { /* ignore */ } +} + +async function refreshResults() { await loadResults(); toast('已刷新', 'success') } + +async function loadArchivedCount(platform: string) { + try { + const { data } = await resultApi.archivedCount(platform) + platformArchivedCount[platform] = data.total || 0 + } catch { /* ignore */ } +} + +async function loadAllArchivedCounts() { + await Promise.all(['grok', 'openai', 'kiro', 'gemini'].map(p => loadArchivedCount(p))) + archivedCountLoaded.value = true +} + +/** Kiro 独立验活(SSE 实时日志):仅检测账号状态,删除失效的,不归档 */ +async function validateKiro() { + if (exportingMap['kiro_validate']) return // 防重入 + const list = platformResults.value['kiro'] + if (!list || list.length === 0) return + + exportingMap['kiro_validate'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/kiro/validate?token=${encodeURIComponent(token || '')}&action=validate`) + + return new Promise((resolve) => { + // 5 分钟超时保护,防止 SSE 挂起导致 UI 锁死 + const timeout = setTimeout(() => { + es.close() + toast('Kiro 验证超时', 'error') + delete exportingMap['kiro_validate'] + loadResults() + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Kiro 检测完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + + loadResults() + delete exportingMap['kiro_validate'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Kiro 验证连接中断', 'error') + delete exportingMap['kiro_validate'] + loadResults() + resolve() + } + }) +} + +/** Kiro 验活+归档(SSE 实时日志):检测账号状态,删除失效的,归档正常的 */ +async function validateAndArchiveKiro() { + if (exportingMap['kiro_validate']) return // 防重入 + const list = platformResults.value['kiro'] + if (!list || list.length === 0) return + + exportingMap['kiro_validate'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/kiro/validate?token=${encodeURIComponent(token || '')}&action=archive`) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + es.close() + toast('Kiro 验证超时', 'error') + delete exportingMap['kiro_validate'] + loadResults() + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Kiro 验证完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + + lastArchivedCount['kiro'] = data.archived || 0 + loadResults() + loadArchivedCount('kiro') + delete exportingMap['kiro_validate'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Kiro 验证连接中断', 'error') + delete exportingMap['kiro_validate'] + loadResults() + resolve() + } + }) +} + +/** Kiro 验活+导出+归档(SSE 实时日志) */ +async function validateKiroExport() { + if (exportingMap['kiro_validate']) return // 防重入 + const list = platformResults.value['kiro'] + if (!list || list.length === 0) return + + exportingMap['kiro_validate'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/kiro/validate?token=${encodeURIComponent(token || '')}&action=export_archive`) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + es.close() + toast('Kiro 验证超时', 'error') + delete exportingMap['kiro_validate'] + loadResults() + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Kiro 验证完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + + // 导出有效账号 + if (data.credentials?.length > 0) { + doExport('kiro', data.credentials, false) + } + + lastArchivedCount['kiro'] = data.archived || 0 + loadResults() + loadArchivedCount('kiro') + delete exportingMap['kiro_validate'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Kiro 验证连接中断', 'error') + delete exportingMap['kiro_validate'] + loadResults() + resolve() + } + }) +} + +/** Gemini 独立验活(SSE 实时日志):仅检测账号状态,删除失效的,不归档 */ +async function validateGemini() { + if (exportingMap['gemini_validate']) return + const list = platformResults.value['gemini'] + if (!list || list.length === 0) return + + exportingMap['gemini_validate'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/gemini/validate?token=${encodeURIComponent(token || '')}&action=validate`) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + es.close() + toast('Gemini 验证超时', 'error') + delete exportingMap['gemini_validate'] + loadResults() + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Gemini 检测完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + loadResults() + delete exportingMap['gemini_validate'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Gemini 验证连接中断', 'error') + delete exportingMap['gemini_validate'] + loadResults() + resolve() + } + }) +} + +/** Gemini 验活+归档(SSE 实时日志) */ +async function validateAndArchiveGemini() { + if (exportingMap['gemini_validate']) return + const list = platformResults.value['gemini'] + if (!list || list.length === 0) return + + exportingMap['gemini_validate'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/gemini/validate?token=${encodeURIComponent(token || '')}&action=archive`) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + es.close() + toast('Gemini 验证超时', 'error') + delete exportingMap['gemini_validate'] + loadResults() + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Gemini 验证完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + lastArchivedCount['gemini'] = data.archived || 0 + loadResults() + loadArchivedCount('gemini') + delete exportingMap['gemini_validate'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Gemini 验证连接中断', 'error') + delete exportingMap['gemini_validate'] + loadResults() + resolve() + } + }) +} + +/** Gemini 验活+导出+归档(SSE 实时日志) */ +async function validateGeminiExport() { + if (exportingMap['gemini_validate']) return + const list = platformResults.value['gemini'] + if (!list || list.length === 0) return + + exportingMap['gemini_validate'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/gemini/validate?token=${encodeURIComponent(token || '')}&action=export_archive`) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + es.close() + toast('Gemini 验证超时', 'error') + delete exportingMap['gemini_validate'] + loadResults() + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Gemini 验证完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + if (data.credentials?.length > 0) { + doExport('gemini', data.credentials, false) + } + lastArchivedCount['gemini'] = data.archived || 0 + loadResults() + loadArchivedCount('gemini') + delete exportingMap['gemini_validate'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Gemini 验证连接中断', 'error') + delete exportingMap['gemini_validate'] + loadResults() + resolve() + } + }) +} + +/** Gemini 归档区验活+导出 */ +async function validateGeminiArchived() { + if (exportingMap['gemini_validate']) return + if (platformArchivedCount['gemini'] === 0) return + + exportingMap['gemini_validate'] = true + exportingMap['gemini_archived'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/gemini/validate?token=${encodeURIComponent(token || '')}&action=validate&scope=archived`) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + es.close() + toast('Gemini 归档验证超时', 'error') + delete exportingMap['gemini_validate'] + delete exportingMap['gemini_archived'] + loadArchivedCount('gemini') + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Gemini 归档验证完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + if (data.credentials?.length > 0) { + doExport('gemini', data.credentials, true) + } + loadArchivedCount('gemini') + delete exportingMap['gemini_validate'] + delete exportingMap['gemini_archived'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Gemini 归档验证连接中断', 'error') + delete exportingMap['gemini_validate'] + delete exportingMap['gemini_archived'] + loadArchivedCount('gemini') + resolve() + } + }) +} + +async function archivePlatform(platform: string) { + // Kiro/Gemini 特殊处理:先验活再归档 + if (platform === 'kiro') { + return validateAndArchiveKiro() + } + if (platform === 'gemini') { + return validateAndArchiveGemini() + } + const list = platformResults.value[platform] + if (!list || list.length === 0) return + try { + const { data } = await resultApi.archive(platform) + lastArchivedCount[platform] = data.archived_count || list.length + await loadResults() + await loadArchivedCount(platform) + toast(`${platform} 已归档 ${lastArchivedCount[platform]} 个`, 'success') + } catch (e: any) { + toast(e.response?.data?.detail || '归档失败', 'error') + } +} + +async function exportArchivedPlatform(platform: string) { + if (platformArchivedCount[platform] === 0) return + // Kiro/Gemini 归档导出:先验活删除失效的,再导出有效的 + if (platform === 'kiro') { + return validateKiroArchived() + } + if (platform === 'gemini') { + return validateGeminiArchived() + } + const key = `${platform}_archived` + exportingMap[key] = true + try { + toast(`正在导出 ${platform} 归档...`, 'info') + const { data } = await resultApi.archived(platform) + const list = data.items || data || [] + if (list.length === 0) { toast('无数据可导出', 'error'); return } + await doExport(platform, list, true) + toast(`已导出 ${list.length} 个 ${platform} 归档账号`, 'success') + } catch { toast('导出失败', 'error') } + finally { delete exportingMap[key] } +} + +/** Kiro 归档区验活+导出(SSE):检测归档账号,删除失效的,导出有效的 */ +async function validateKiroArchived() { + if (exportingMap['kiro_validate']) return // 防重入 + if (platformArchivedCount['kiro'] === 0) return + + exportingMap['kiro_validate'] = true + exportingMap['kiro_archived'] = true + const token = localStorage.getItem('token') + const es = new EventSource(`/ws/kiro/validate?token=${encodeURIComponent(token || '')}&action=validate&scope=archived`) + + return new Promise((resolve) => { + const timeout = setTimeout(() => { + es.close() + toast('Kiro 归档验证超时', 'error') + delete exportingMap['kiro_validate'] + delete exportingMap['kiro_archived'] + loadArchivedCount('kiro') + resolve() + }, 5 * 60 * 1000) + + es.onmessage = (e) => { + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + logs.value.push(data.message) + } else if (data.type === 'complete') { + clearTimeout(timeout) + es.close() + toast(`Kiro 归档验证完成: ${data.valid} 个正常, ${data.invalid} 个已禁用`, 'success') + + // 导出有效的归档账号 + if (data.credentials?.length > 0) { + doExport('kiro', data.credentials, true) + } + + loadArchivedCount('kiro') + delete exportingMap['kiro_validate'] + delete exportingMap['kiro_archived'] + resolve() + } + } catch { /* 忽略解析错误 */ } + } + es.onerror = () => { + clearTimeout(timeout) + es.close() + toast('Kiro 归档验证连接中断', 'error') + delete exportingMap['kiro_validate'] + delete exportingMap['kiro_archived'] + loadArchivedCount('kiro') + resolve() + } + }) +} + +async function exportPlatformResults(platform: string) { + const list = platformResults.value[platform] + if (!list || list.length === 0) return + exportingMap[platform] = true + try { + await doExport(platform, list, false) + toast(`已导出 ${list.length} 个 ${platform} 账号`, 'success') + } finally { delete exportingMap[platform] } +} + +/** 导出后自动归档 */ +async function exportAndArchive(platform: string) { + // Kiro/Gemini 特殊处理:先验活再导出+归档 + if (platform === 'kiro') { + return validateKiroExport() + } + if (platform === 'gemini') { + return validateGeminiExport() + } + const list = platformResults.value[platform] + if (!list || list.length === 0) return + exportingMap[platform] = true + try { + await doExport(platform, list, false) + // 导出完成后自动归档 + const { data } = await resultApi.archive(platform) + lastArchivedCount[platform] = data.archived_count || list.length + await loadResults() + await loadArchivedCount(platform) + toast(`已导出并归档 ${list.length} 个 ${platform} 账号`, 'success') + } catch (e: any) { + toast(`导出成功,但归档失败: ${e.response?.data?.detail || '未知错误'}`, 'error') + } finally { delete exportingMap[platform] } +} + +async function doExport(platform: string, list: any[], isArchive: boolean, _exportKey?: string) { + const progressToastId = ++toastId + // 立即弹出提示,让用户知道正在处理 + toasts.value.push({ id: progressToastId, msg: `正在打包 ${list.length} 个 ${platform} 账号...`, type: 'info' }) + try { + const zip = new JSZip() + const now = new Date() + const dateStr = now.toISOString().slice(0, 10) + const timeStr = now.toTimeString().slice(0, 8).replace(/:/g, '') + if (platform === 'grok') { + const tokens = list.map(r => r.credential_data?.auth_token || r.credential_data?.sso_token || r.auth_token || r.sso_token || '').filter(Boolean) + zip.file('tokens.txt', tokens.join('\n')) + } else if (platform === 'openai') { + list.forEach(r => { + const cred = r.credential_data + const email = cred?.email || r.email || 'unknown' + const planType = cred?.['https://api.openai.com/auth']?.chatgpt_plan_type || 'free' + const data = (cred && typeof cred === 'object' && Object.keys(cred).length > 0) ? cred : { email: r.email, auth_token: r.auth_token || r.sso_token } + zip.file(`codex-${email}-${planType}.json`, JSON.stringify(data, null, 2)) + }) + } else if (platform === 'kiro') { + list.forEach(r => { + const cred = r.credential_data + const email = cred?.email || r.email || 'unknown' + // 过滤敏感字段(email_meta 含邮箱服务 token,不应暴露给用户) + const { email_meta, mail_provider, disabled, ...safeData } = (cred && typeof cred === 'object' && Object.keys(cred).length > 0) ? cred : { email: r.email } + zip.file(`kiro-builder-id-${email.split('@')[0] || email}.json`, JSON.stringify(safeData, null, 2)) + }) + } else if (platform === 'gemini') { + list.forEach(r => { + const cred = r.credential_data + const email = cred?.email || r.email || 'unknown' + // 过滤敏感字段(email_meta 含邮箱服务 token,不应暴露给用户) + const { email_meta, mail_provider, disabled, ...safeData } = (cred && typeof cred === 'object' && Object.keys(cred).length > 0) ? cred : { email: r.email } + zip.file(`gemini-business-${email.split('@')[0] || email}.json`, JSON.stringify(safeData, null, 2)) + }) + } + const suffix = isArchive ? '_archived' : '' + // 带进度回调的 ZIP 生成,实时更新 toast 内容 + const blob = await zip.generateAsync( + { type: 'blob' }, + (metadata) => { + const pct = Math.round(metadata.percent) + const existing = toasts.value.find(t => t.id === progressToastId) + if (existing) existing.msg = `正在打包 ${list.length} 个账号... ${pct}%` + }, + ) + // 移除进度 toast + toasts.value = toasts.value.filter(t => t.id !== progressToastId) + const a = document.createElement('a') + a.href = URL.createObjectURL(blob) + a.download = `${platform}_${user.username}_${dateStr}_${timeStr}_${list.length}个${suffix}.zip` + a.click() + // 延迟释放,移动端浏览器可能尚未开始下载 + setTimeout(() => URL.revokeObjectURL(a.href), 10000) + } catch { + toasts.value = toasts.value.filter(t => t.id !== progressToastId) + toast('导出失败', 'error') + } +} + +async function saveCurrentTaskResults() { + if (!taskStatus.task_id) return + exportingMap['save'] = true + try { + const { data } = await resultApi.get(taskStatus.task_id) + const list = data || [] + if (list.length === 0) { toast('暂无注册结果可保存', 'info'); return } + await doExport(taskStatus.platform || 'unknown', list, false, 'save') + toast(`已保存 ${list.length} 个 ${taskStatus.platform} 账号`, 'success') + } catch { toast('保存失败', 'error') } + finally { delete exportingMap['save'] } +} + +async function loadTxHistory() { + try { const { data } = await creditApi.history(); txHistory.value = data || []; txHistoryLoaded.value = true } + catch { /* ignore */ } +} + +async function loadGlobalStats() { + try { const { data } = await statsApi.global(); Object.assign(globalStats, data) } + catch { /* ignore */ } +} + +let globalStatsTimer: ReturnType | null = null +function startGlobalStatsPolling() { + if (globalStatsTimer) return // 防止重复启动导致旧定时器泄漏 + globalStatsTimer = setInterval(loadGlobalStats, 30000) +} +function stopGlobalStatsPolling() { + if (globalStatsTimer) { clearInterval(globalStatsTimer); globalStatsTimer = null } +} + +async function loadAnnouncements() { + try { const { data } = await announcementApi.list(); announcements.value = data || [] } + catch { /* ignore */ } +} + +async function fetchOTP(email: string) { + if (otpLoading[email]) return + otpLoading[email] = true + try { + const { data } = await emailApi.fetchOTP(email) + if (data.code) { + await navigator.clipboard.writeText(data.code) + toast(`验证码 ${data.code} 已复制`, 'success') + } else { + toast(data.error || '暂无验证码', 'info') + } + } catch { toast('获取验证码失败', 'error') } + finally { otpLoading[email] = false } +} + +async function claimFreeTrial(event: MouseEvent) { + const btn = (event.target as HTMLElement)?.closest('button') + try { + const { data } = await creditApi.claimFreeTrial() + freeTrial.claimed = true; freeTrial.eligible = false + freeTrial.remaining = data.count + toast(`已领取 ${data.count} 次免费注册!`, 'success') + sprayConfetti(btn) + await loadBalance() + } catch (e: any) { toast(e.response?.data?.detail || '领取失败', 'error') } +} + +async function smartAction(event?: Event) { + const v = smartInput.value.trim() + if (!v) return + const btn = event ? (event.target as HTMLElement)?.closest('button') : null + if (smartInputMode.value === 'purchase') { + const amount = smartInputAmount.value + if (purchaseProcessing.value || amount < 1) return + purchaseProcessing.value = true + try { + await creditApi.purchase(amount, taskForm.platform) + toast(`成功购买 ${amount} 次注册!`, 'success') + smartInput.value = '' + sprayConfetti(btn) + await Promise.all([loadBalance(), loadTxHistory()]) + } catch (e: any) { toast(e.response?.data?.detail || '购买失败', 'error') } + finally { purchaseProcessing.value = false } + } else { + try { + const { data } = await creditApi.redeem(v) + smartInput.value = '' + toast(data.message, 'success') + sprayConfetti(btn) + await loadBalance() + } catch (e: any) { toast(e.response?.data?.detail || '兑换失败', 'error') } + } +} + +async function logout() { + stopGlobalStatsPolling() + try { await authApi.logout() } catch { /* ignore */ } + localStorage.removeItem('token') + window.location.href = '/' +} + +// ══════════ 音效 / 通知 / 彩纸 ══════════ + +let _audioCtx: AudioContext | null = null +function getAudioCtx(): AudioContext { + if (!_audioCtx || _audioCtx.state === 'closed') _audioCtx = new AudioContext() + return _audioCtx +} + +function playNotificationSound() { + try { + const ctx = getAudioCtx() + if (ctx.state === 'suspended') ctx.resume() + const notes = [880, 1108.73, 1318.51] + notes.forEach((freq, i) => { + const osc = ctx.createOscillator(); const gain = ctx.createGain() + osc.type = 'sine'; osc.frequency.value = freq + gain.gain.setValueAtTime(0.15, ctx.currentTime + i * 0.12) + gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + i * 0.12 + 0.3) + osc.connect(gain).connect(ctx.destination) + osc.start(ctx.currentTime + i * 0.12); osc.stop(ctx.currentTime + i * 0.12 + 0.3) + }) + } catch { /* 静默 */ } +} + +function showBrowserNotification(title: string, body: string) { + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(title, { body, icon: '/favicon.ico' }) + } +} + +function sprayConfetti(originEl: Element | null | undefined) { + const colors = ['#fbbf24', '#f59e0b', '#ef4444', '#3b82f6', '#22d3ee', '#a78bfa', '#34d399', '#f472b6'] + const rect = originEl?.getBoundingClientRect() || { left: window.innerWidth / 2, top: window.innerHeight / 2, width: 0, height: 0 } + const cx = rect.left + rect.width, cy = rect.top + rect.height / 2 + for (let i = 0; i < 48; i++) { + const piece = document.createElement('div') + piece.style.cssText = `position:fixed;z-index:9999;pointer-events:none;border-radius:${Math.random() > .5 ? '50%' : '2px'};width:${Math.random() * 6 + 4}px;height:${Math.random() * 6 + 4}px;background:${colors[Math.floor(Math.random() * colors.length)]};left:${cx}px;top:${cy}px;` + document.body.appendChild(piece) + const angle = (Math.random() - 0.5) * Math.PI * 0.65 + const speed = Math.random() * 220 + 100 + const vx = Math.cos(angle) * speed, vy = Math.sin(angle) * speed + const gravity = 350 + Math.random() * 200, duration = 1000 + Math.random() * 700 + const start = performance.now() + const animate = (now: number) => { + const t = (now - start) / 1000 + if (t > duration / 1000) { piece.remove(); return } + piece.style.left = (cx + vx * t) + 'px' + piece.style.top = (cy + vy * t + 0.5 * gravity * t * t) + 'px' + piece.style.opacity = String(Math.max(0, 1 - t / (duration / 1000))) + piece.style.transform = `rotate(${t * 400}deg)` + requestAnimationFrame(animate) + } + requestAnimationFrame(animate) + } +} + +/** 任务完成彩纸:从日志面板底部向上喷射,到达屏幕中间高处后自然飘落 */ +function sprayTaskCompleteRibbons() { + const colors = ['#fbbf24', '#f59e0b', '#ef4444', '#3b82f6', '#22d3ee', '#a78bfa', '#34d399', '#f472b6', '#fb923c', '#e879f9'] + const W = window.innerWidth, H = window.innerHeight + // 从日志面板底部中央发射 + const panelRect = logPanel.value?.getBoundingClientRect() + const cx = panelRect ? panelRect.left + panelRect.width / 2 : W * 0.75 + const cy = panelRect ? panelRect.bottom - 10 : H * 0.82 + const count = 60 + + for (let i = 0; i < count; i++) { + const ribbon = document.createElement('div') + const w = Math.random() * 4 + 3 // 窄 + const h = Math.random() * 20 + 14 // 长条 + const color = colors[Math.floor(Math.random() * colors.length)] + ribbon.style.cssText = `position:fixed;z-index:9999;pointer-events:none;border-radius:2px;width:${w}px;height:${h}px;background:${color};left:${cx}px;top:${cy}px;opacity:1;transform-origin:center center;` + document.body.appendChild(ribbon) + + const spread = (Math.random() - 0.5) * W * 0.7 + const peakY = H * (0.08 + Math.random() * 0.22) + const riseDur = 500 + Math.random() * 350 + const fallDur = 2000 + Math.random() * 1500 // 彩带飘落时间更长 + const totalDur = riseDur + fallDur + const targetX = cx + spread + const wobbleAmp = 30 + Math.random() * 50 // 飘摆幅度 + const wobbleFreq = 1.5 + Math.random() * 2 // 飘摆频率 + const phaseOffset = Math.random() * Math.PI * 2 + const flipSpeed = 300 + Math.random() * 500 // 翻转速度 + + const start = performance.now() + const animate = (now: number) => { + const elapsed = now - start + if (elapsed > totalDur) { ribbon.remove(); return } + + let x: number, y: number, opacity: number, scaleX: number + if (elapsed < riseDur) { + const p = elapsed / riseDur + const ease = 1 - (1 - p) * (1 - p) + x = cx + (targetX - cx) * ease + y = cy + (peakY - cy) * ease + opacity = 1 + scaleX = 1 + } else { + const fp = (elapsed - riseDur) / fallDur + // 缓慢加速下落 + const fallEase = fp * fp * 0.7 + fp * 0.3 + x = targetX + Math.sin(fp * Math.PI * wobbleFreq + phaseOffset) * wobbleAmp + y = peakY + (H + 80 - peakY) * fallEase + opacity = Math.max(0, 1 - fp * 0.6) + // 3D 翻转效果:scaleX 在 -1~1 之间摆动,模拟彩带翻面 + scaleX = Math.cos((elapsed / 1000) * flipSpeed * (Math.PI / 180)) + } + + ribbon.style.left = x + 'px' + ribbon.style.top = y + 'px' + ribbon.style.opacity = String(opacity) + ribbon.style.transform = `scaleX(${scaleX}) rotate(${Math.sin((elapsed / 1000) * 2 + phaseOffset) * 25}deg)` + requestAnimationFrame(animate) + } + setTimeout(() => requestAnimationFrame(animate), Math.random() * 200) + } +} + +// ══════════ 批量初始化(单次 HTTP 请求) ══════════ + +async function initLoad() { + const { data } = await initApi.load() + if (data.user) Object.assign(user, data.user) + if (data.balance) { + const b = data.balance + const costPerReg = b.cost_per_reg || 1 + user.quota = b.quota || 0 + user.balance_display = b.display || '' + user.registrations_available = b.registrations_available ?? Math.floor((b.credits || 0) / costPerReg) + user.unit_price = b.unit_price || 0; user.unit_price_display = b.unit_price_display || '' + user.newapi_balance = b.newapi_balance || 0; user.newapi_balance_display = b.newapi_balance_display || '$0' + user.newapi_available = b.newapi_available || 0; user.mode = b.mode || 'local' + user.platform_prices = b.platform_prices || {} + Object.assign(limits, b.limits || {}) + const currentKey = `platform_${taskForm.platform}_enabled` as keyof typeof limits + if (limits[currentKey] === false) { + const first = (['grok', 'openai', 'kiro', 'gemini'] as const).find( + p => limits[`platform_${p}_enabled` as keyof typeof limits] !== false + ) + if (first) taskForm.platform = first + } + if (b.free_trial) { + freeTrial.eligible = b.free_trial.eligible + freeTrial.total = b.free_trial.total || b.free_trial.remaining + if (!freeTrial.claimed) freeTrial.remaining = b.free_trial.remaining + } + } + if (data.results) results.value = data.results + if (data.tx_history) { txHistory.value = data.tx_history; txHistoryLoaded.value = true } + if (data.announcements) announcements.value = data.announcements + if (data.global_stats) Object.assign(globalStats, data.global_stats) + if (data.recent_completions) recentCompletions.value = dedupeByUser(data.recent_completions) + if (data.user_stats) { + Object.assign(userStats, data.user_stats) + if (data.user_stats.avg_sec_per_reg) Object.assign(avgSecPerReg, data.user_stats.avg_sec_per_reg) + if (data.user_stats.by_platform) Object.assign(byPlatformStats, data.user_stats.by_platform) + } + return data // 返回给调用者处理 current_task +} + +// ══════════ 导出单例 ══════════ + +export function useDashboard() { + return { + // 状态 + user, limits, taskForm, taskMode, taskStatus, + isRunning, isQueued, elapsedSeconds, processing, logs, logPanel, lastLogAt, + results, platformArchivedCount, archivedCountLoaded, lastArchivedCount, + otpLoading, txHistory, txHistoryLoaded, txDisplayCount, announcements, freeTrial, + smartInput, purchaseProcessing, exporting, exportingMap, toasts, taskNotice, + globalStats, userStats, byPlatformStats, avgSecPerReg, queueModal, + // computed + smartInputMode, smartInputAmount, platformLabel, platformUserStats, + sliderMax, quickTargets, estimatedTime, estimatedRemaining, + currentPlatformEnabled, currentPlatformFree, currentFreeMode, + currentFreeModeAvailable, currentPlatformPrice, currentPlatformPriceDisplay, + currentPlatformLimit, currentPlatformDailyText, currentPlatformMaxDisplay, + platformTitleClass, refundAmount, vipLabel, vipBadgeClass, + vipCardGlow, vipAvatarRing, platformCols, platformResults, displayedPlatformResults, showMoreResults, + // helpers + vipLabelFor, vipBadgeClassFor, isPlatformFreeByKey, + platformHealthRate, platformHealthColor, + toast, setTaskNotice, clearTaskNotice, + getPassword, copyText, copyEmailPassword, formatElapsed, + // API + loadUser, loadBalance, loadResults, refreshResults, + loadArchivedCount, loadAllArchivedCounts, + archivePlatform, exportArchivedPlatform, exportPlatformResults, exportAndArchive, + validateKiro, validateAndArchiveKiro, + validateGemini, validateAndArchiveGemini, + saveCurrentTaskResults, loadTxHistory, loadGlobalStats, loadAnnouncements, + fetchOTP, claimFreeTrial, smartAction, logout, + // effects + playNotificationSound, showBrowserNotification, sprayConfetti, sprayTaskCompleteRibbons, + // init + initLoad, + // polling + startGlobalStatsPolling, stopGlobalStatsPolling, + // completion toast + completionQueue, activeCompletion, completionVisible, + startCompletionPolling, stopCompletionPolling, + // 全站动态 + recentCompletions, loadRecentCompletions, + } +} diff --git a/web/src/composables/useHFSpace.ts b/web/src/composables/useHFSpace.ts new file mode 100644 index 0000000..45c132a --- /dev/null +++ b/web/src/composables/useHFSpace.ts @@ -0,0 +1,298 @@ +/** + * HF Space 管理 — 状态和操作 + */ +import { ref, reactive, computed, watch } from 'vue' +import { hfSpaceApi } from '../api/client' +import { useDashboard } from './useDashboard' + +// ── 状态 ── +const tokens = ref([]) +const spaces = ref([]) +const overview = ref([]) +const healthResults = ref([]) +const tokensLoading = ref(false) +const spacesLoading = ref(false) +const overviewLoading = ref(false) +const healthLoading = ref(false) +const deployLoading = ref(false) +const autoscaleLoading = ref(false) +const syncCFLoading = ref(false) +const updateLoading = ref(false) +const validateAllLoading = ref(false) +const purgeLoading = ref(false) +const updateService = ref('all') +const updateResult = ref(null) + +// 各服务默认 Release URL(与后端 system_setting.go 中的默认值保持一致) +const RELEASE_URL_DEFAULTS: Record = { + openai: 'https://github.com/xiaolajiaoyyds/regplatformm/releases/download/inference-runtime-latest/inference-runtime.zip', + grok: 'https://github.com/xiaolajiaoyyds/regplatformm/releases/download/stream-worker-latest/stream-worker.zip', + kiro: 'https://github.com/xiaolajiaoyyds/regplatformm/releases/download/browser-agent-latest/browser-agent.zip', + gemini: 'https://github.com/xiaolajiaoyyds/regplatformm/releases/download/gemini-agent-latest/gemini-agent.zip', + ts: 'https://github.com/xiaolajiaoyyds/regplatformm/releases/download/net-toolkit-latest/net-toolkit.zip', +} + +const spaceFilter = ref('') +const statusFilter = ref('') +const tokenForm = reactive({ label: '', token: '' }) +const addSpaceForm = reactive({ service: 'openai', url: '', repo_id: '', token_id: 0 }) +const deployForm = reactive({ service: 'openai', count: 1, release_url: RELEASE_URL_DEFAULTS['openai'], token_id: 0 }) +const deployResult = ref(null) +const batchDeleteLoading = ref(false) + +// 切换服务时自动更新 Release URL(仅在 URL 为空或匹配某个默认值时才覆盖,避免覆盖用户自定义) +watch(() => deployForm.service, (svc) => { + const isDefault = Object.values(RELEASE_URL_DEFAULTS).includes(deployForm.release_url) || !deployForm.release_url.trim() + if (isDefault) deployForm.release_url = RELEASE_URL_DEFAULTS[svc] ?? '' +}) +const autoscaleForm = reactive({ service: 'all', target: 0, dry_run: false }) +const autoscaleResult = ref(null) + +// Space 分页 +const spacePage = ref(1) +const spacePageSize = ref(20) +const spaceTotal = ref(0) +const spaceTotalPages = computed(() => Math.max(1, Math.ceil(spaceTotal.value / spacePageSize.value))) + + +export function useHFSpace() { + const d = useDashboard() + + // ── 概览 ── + async function loadOverview() { + overviewLoading.value = true + try { const { data } = await hfSpaceApi.overview(); overview.value = data || [] } + catch { /* ignore */ } + finally { overviewLoading.value = false } + } + + // ── Token ── + async function loadTokens() { + tokensLoading.value = true + try { const { data } = await hfSpaceApi.listTokens(); tokens.value = data || [] } + catch { /* ignore */ } + finally { tokensLoading.value = false } + } + + async function createToken() { + if (!tokenForm.label.trim() || !tokenForm.token.trim()) return + try { + await hfSpaceApi.createToken({ label: tokenForm.label, token: tokenForm.token }) + tokenForm.label = ''; tokenForm.token = '' + d.toast('Token 已创建', 'success') + await loadTokens(); await loadOverview() + } catch (e: any) { d.toast(e.response?.data?.detail || '创建失败', 'error') } + } + + async function deleteToken(id: number) { + if (!confirm('确定要删除此 Token 吗?')) return + try { + await hfSpaceApi.deleteToken(id) + d.toast('已删除', 'success') + await loadTokens() + } catch (e: any) { d.toast(e.response?.data?.detail || '删除失败', 'error') } + } + + async function validateToken(id: number) { + try { + const { data } = await hfSpaceApi.validateToken(id) + d.toast(data.is_valid ? '验证通过' : '验证失败(无效 Token)', data.is_valid ? 'success' : 'error') + await loadTokens() + } catch (e: any) { d.toast(e.response?.data?.detail || '验证失败', 'error') } + } + + // ── Space(分页) ── + async function loadSpaces(service?: string, status?: string) { + spacesLoading.value = true + try { + const { data } = await hfSpaceApi.listSpaces(service, spacePage.value, spacePageSize.value, status) + spaces.value = data.items || [] + spaceTotal.value = data.total || 0 + } catch { /* ignore */ } + finally { spacesLoading.value = false } + } + + function spaceGoPage(p: number) { + if (p < 1 || p > spaceTotalPages.value) return + spacePage.value = p + loadSpaces(spaceFilter.value || undefined, statusFilter.value || undefined) + } + + async function addSpace() { + try { + await hfSpaceApi.addSpace(addSpaceForm) + addSpaceForm.url = ''; addSpaceForm.repo_id = ''; addSpaceForm.token_id = 0 + d.toast('Space 已添加', 'success') + await loadSpaces(spaceFilter.value || undefined, statusFilter.value || undefined); await loadOverview() + } catch (e: any) { d.toast(e.response?.data?.detail || '添加失败', 'error') } + } + + async function deleteSpace(id: number) { + if (!confirm('确定要删除此 Space 吗?(将同时尝试删除远程 HF repo)')) return + try { + await hfSpaceApi.deleteSpace(id) + d.toast('已删除', 'success') + await loadSpaces(spaceFilter.value || undefined, statusFilter.value || undefined); await loadOverview() + } catch (e: any) { d.toast(e.response?.data?.detail || '删除失败', 'error') } + } + + async function checkHealth(service?: string) { + healthLoading.value = true + try { + const { data } = await hfSpaceApi.checkHealth(service) + healthResults.value = data.results || [] + d.toast(`健康检查完成(${data.total} 个)`, 'success') + await loadSpaces(spaceFilter.value || undefined, statusFilter.value || undefined); await loadOverview() + } catch (e: any) { d.toast(e.response?.data?.detail || '检查失败', 'error') } + healthLoading.value = false + } + + async function deploySpaces() { + if (!deployForm.release_url.trim()) { d.toast('请输入 Release URL', 'error'); return } + deployLoading.value = true; deployResult.value = null + try { + const { data } = await hfSpaceApi.deploySpaces(deployForm) + deployResult.value = data + d.toast(`部署完成: ${data.success} 成功, ${data.failed} 失败`, data.failed > 0 ? 'error' : 'success') + await loadSpaces(spaceFilter.value || undefined, statusFilter.value || undefined); await loadOverview() + // 部署成功后自动同步 CF Worker,让新 Space URL 生效 + if (data.success > 0) { + await syncCF(deployForm.service) + } + } catch (e: any) { d.toast(e.response?.data?.detail || '部署失败', 'error') } + deployLoading.value = false + } + + async function updateSpaces(service: string) { + updateLoading.value = true; updateResult.value = null + try { + const { data } = await hfSpaceApi.updateSpaces({ service }) + updateResult.value = data + d.toast(`更新完成: ${data.updated} 成功, ${data.failed} 失败`, data.failed > 0 ? 'error' : 'success') + await loadOverview() + } catch (e: any) { d.toast(e.response?.data?.detail || '更新失败', 'error') } + updateLoading.value = false + } + + // ── 弹性管理 ── + async function triggerAutoscale() { + autoscaleLoading.value = true; autoscaleResult.value = null + try { + const { data } = await hfSpaceApi.autoscale(autoscaleForm) + autoscaleResult.value = data + d.toast('弹性管理完成', 'success') + await loadOverview() + } catch (e: any) { d.toast(e.response?.data?.detail || '操作失败', 'error') } + autoscaleLoading.value = false + } + + async function syncCF(service?: string) { + syncCFLoading.value = true + try { + const { data } = await hfSpaceApi.syncCF(service) + const results = data.results || [] + const detail = results.map((r: any) => `${r.service}(${r.env_key}): ${r.urls} 个 URL`).join('、') + d.toast(data.message + (detail ? ' — ' + detail : ''), 'success') + } catch (e: any) { d.toast(e.response?.data?.detail || '同步失败', 'error') } + syncCFLoading.value = false + } + + // ── 自动发现 ── + const discoverLoading = ref(false) + const discoverResult = ref(null) + + async function discoverSpaces(defaultService?: string) { + discoverLoading.value = true; discoverResult.value = null + try { + const { data: res } = await hfSpaceApi.discover(defaultService) + discoverResult.value = res + d.toast(`发现完成: 扫描 ${res.scanned} 个 Token,找到 ${res.found} 个 Space,新导入 ${res.imported} 个`, 'success') + await loadAll() + } catch (e: any) { d.toast(e.response?.data?.detail || '发现失败', 'error') } + discoverLoading.value = false + } + + const redetectLoading = ref(false) + const redetectResult = ref(null) + + async function redetectService() { + redetectLoading.value = true; redetectResult.value = null + try { + const { data: res } = await hfSpaceApi.redetect() + redetectResult.value = res + const identified = res.imported || 0 + const total = res.found || 0 + d.toast(`识别完成: ${total} 个 unknown,成功识别 ${identified} 个`, 'success') + await loadAll() + } catch (e: any) { d.toast(e.response?.data?.detail || '识别失败', 'error') } + redetectLoading.value = false + } + + // ── 批量验证所有 Token ── + async function validateAllTokens() { + validateAllLoading.value = true + try { + const { data } = await hfSpaceApi.validateAllTokens() + tokens.value = data.tokens || [] + d.toast(`验证完成: ${data.valid} 有效 / ${data.invalid} 失效`, data.invalid > 0 ? 'warning' : 'success') + } catch (e: any) { d.toast(e.response?.data?.detail || '批量验证失败', 'error') } + validateAllLoading.value = false + } + + // ── 批量清理被封 Space ── + async function purgeSpaces(service?: string) { + purgeLoading.value = true + try { + const { data } = await hfSpaceApi.purgeSpaces(service) + d.toast(data.message, data.deleted > 0 ? 'success' : 'info') + await loadAll() + } catch (e: any) { d.toast(e.response?.data?.detail || '清理失败', 'error') } + purgeLoading.value = false + } + + // ── 初始化加载(每次切到 tab 都拉最新数据) ── + async function loadAll() { + await Promise.all([loadOverview(), loadTokens(), loadSpaces()]) + } + + // 强制刷新(手动点刷新按钮时) + function reloadSpaces() { + spacePage.value = 1 + loadSpaces(spaceFilter.value || undefined, statusFilter.value || undefined) + } + + // ── 批量删除 Space ── + async function batchDeleteSpaces(ids: number[]) { + if (!ids.length) return + if (!confirm(`确定要删除选中的 ${ids.length} 个 Space 吗?(将同时尝试删除远程 HF repo)`)) return + batchDeleteLoading.value = true + let ok = 0, fail = 0 + for (const id of ids) { + try { await hfSpaceApi.deleteSpace(id); ok++ } + catch { fail++ } + } + batchDeleteLoading.value = false + d.toast(`批量删除: ${ok} 成功, ${fail} 失败`, fail > 0 ? 'error' : 'success') + await loadSpaces(spaceFilter.value || undefined, statusFilter.value || undefined) + await loadOverview() + } + + return { + // 状态 + tokens, spaces, overview, healthResults, spaceFilter, statusFilter, + tokensLoading, spacesLoading, overviewLoading, healthLoading, + deployLoading, autoscaleLoading, syncCFLoading, updateLoading, updateService, + validateAllLoading, purgeLoading, batchDeleteLoading, + tokenForm, addSpaceForm, deployForm, deployResult, autoscaleForm, autoscaleResult, updateResult, + // 分页 + spacePage, spacePageSize, spaceTotal, spaceTotalPages, + // 方法 + loadOverview, loadTokens, loadSpaces, loadAll, reloadSpaces, + spaceGoPage, + createToken, deleteToken, validateToken, validateAllTokens, + addSpace, deleteSpace, batchDeleteSpaces, checkHealth, deploySpaces, updateSpaces, + triggerAutoscale, syncCF, purgeSpaces, + discoverLoading, discoverResult, discoverSpaces, + redetectLoading, redetectResult, redetectService, + } +} diff --git a/web/src/composables/useProxyPool.ts b/web/src/composables/useProxyPool.ts new file mode 100644 index 0000000..296af6d --- /dev/null +++ b/web/src/composables/useProxyPool.ts @@ -0,0 +1,425 @@ +/** + * 代理池管理 — 管理后台数据层 + * 提供代理池的增删改查、健康检查、批量导入、URL 抓取等功能 + */ +import { ref, reactive, computed } from 'vue' +import { proxyPoolApi, adminApi } from '../api/client' +import { useDashboard } from './useDashboard' + +// ── 类型定义 ── + +export interface ProxyEntry { + id: number + name: string + protocol: string + host: string + port: number + username?: string + password?: string + country?: string + is_healthy: boolean + latency_ms: number + last_checked_at?: string + fail_count: number + source?: string +} + +export interface ProxyPoolStats { + total: number + healthy: number + unhealthy: number + active: number +} + +// ── 模块级单例状态 ── + +const proxies = ref([]) +const proxyStats = reactive({ total: 0, healthy: 0, unhealthy: 0, active: 0 }) +const loading = ref(false) +const statsLoading = ref(false) +const healthCheckRunning = ref(false) +const purgeRunning = ref(false) +const filterMode = ref<'' | 'healthy' | 'unhealthy'>('') + +// 选择状态 +const selectedIds = ref>(new Set()) + +// 添加表单 +const addForm = reactive({ + name: '', + protocol: 'socks5', + host: '', + port: 1080, + username: '', + password: '', + country: '', +}) +const addDialogOpen = ref(false) +const addLoading = ref(false) + +// 批量导入表单 +const importDialogOpen = ref(false) +const importText = ref('') +const importProtocol = ref('socks5') +const importLoading = ref(false) + +// 密码可见性 +const showPasswordIds = ref>(new Set()) + +// 从 URL 抓取 +const fetchUrlDialogOpen = ref(false) +const fetchUrlLoading = ref(false) +const fetchUrlForm = reactive({ + url: '', + protocol: 'socks5', +}) + +// 分页状态 +const currentPage = ref(1) +const pageSize = ref(50) +const totalCount = ref(0) + +// ── 平台代理策略状态 ── + +export type ProxyMode = 'pool' | 'fixed' | 'direct' | 'smart' + +export interface PlatformProxyConfig { + mode: ProxyMode + fixedProxy: string + saving: boolean + dirty: boolean +} + +const PLATFORMS = ['grok', 'openai', 'kiro', 'gemini'] as const +export type PlatformKey = typeof PLATFORMS[number] + +const PLATFORM_LABELS: Record = { + grok: 'Grok', + openai: 'OpenAI', + kiro: 'Kiro', + gemini: 'Gemini', +} + +const platformProxyConfigs = reactive>({ + grok: { mode: 'pool', fixedProxy: '', saving: false, dirty: false }, + openai: { mode: 'pool', fixedProxy: '', saving: false, dirty: false }, + kiro: { mode: 'pool', fixedProxy: '', saving: false, dirty: false }, + gemini: { mode: 'pool', fixedProxy: '', saving: false, dirty: false }, +}) +const proxyConfigLoading = ref(false) + +export function useProxyPool() { + const { toast } = useDashboard() + + // ── 加载 ── + + async function loadProxies() { + loading.value = true + try { + const { data } = await proxyPoolApi.list({ + page: currentPage.value, + page_size: pageSize.value, + filter: filterMode.value || undefined, + }) + proxies.value = data.items || [] + totalCount.value = data.total || 0 + } catch (e: any) { + toast(e.response?.data?.detail || '加载代理池失败', 'error') + } finally { + loading.value = false + } + } + + async function loadStats() { + statsLoading.value = true + try { + const { data } = await proxyPoolApi.stats() + Object.assign(proxyStats, data) + } catch (e: any) { + console.warn('加载代理池统计失败:', e.response?.data?.detail || e.message) + } finally { + statsLoading.value = false + } + } + + async function loadAll() { + await Promise.all([loadProxies(), loadStats()]) + } + + // ── 平台代理策略 ── + + async function loadProxyConfig() { + proxyConfigLoading.value = true + try { + const { data } = await adminApi.settings() + const settingsMap: Record = {} + for (const s of data) { + settingsMap[s.key] = s.value || s.default_value || '' + } + for (const p of PLATFORMS) { + const cfg = platformProxyConfigs[p] + const mode = settingsMap[`${p}_proxy_mode`] || 'pool' + cfg.mode = (['pool', 'fixed', 'direct', 'smart'].includes(mode) ? mode : 'pool') as ProxyMode + cfg.fixedProxy = settingsMap[`${p}_proxy`] || '' + cfg.dirty = false + } + } catch (e: any) { + console.warn('加载平台代理策略失败:', e.response?.data?.detail || e.message) + } finally { + proxyConfigLoading.value = false + } + } + + async function saveProxyConfig(platform: PlatformKey) { + const cfg = platformProxyConfigs[platform] + cfg.saving = true + try { + await adminApi.saveSetting(`${platform}_proxy_mode`, cfg.mode) + if (cfg.mode === 'fixed') { + await adminApi.saveSetting(`${platform}_proxy`, cfg.fixedProxy) + } + cfg.dirty = false + toast(`${PLATFORM_LABELS[platform]} 代理策略已保存`, 'success') + } catch (e: any) { + toast(e.response?.data?.detail || '保存失败', 'error') + } finally { + cfg.saving = false + } + } + + async function saveAllProxyConfigs() { + const dirtyPlatforms = PLATFORMS.filter(p => platformProxyConfigs[p].dirty) + if (dirtyPlatforms.length === 0) { toast('没有需要保存的更改', 'success'); return } + for (const p of dirtyPlatforms) { + await saveProxyConfig(p) + } + } + + // ── 操作 ── + + async function addProxy() { + if (!addForm.host.trim() || !addForm.port) return + addLoading.value = true + try { + await proxyPoolApi.add({ + name: addForm.name.trim() || `${addForm.protocol}://${addForm.host}:${addForm.port}`, + protocol: addForm.protocol, + host: addForm.host.trim(), + port: Number(addForm.port), + username: addForm.username.trim() || undefined, + password: addForm.password.trim() || undefined, + country: addForm.country.trim() || undefined, + }) + toast('代理已添加', 'success') + addDialogOpen.value = false + resetAddForm() + await loadAll() + } catch (e: any) { + toast(e.response?.data?.detail || '添加失败', 'error') + } finally { + addLoading.value = false + } + } + + function resetAddForm() { + addForm.name = '' + addForm.protocol = 'socks5' + addForm.host = '' + addForm.port = 1080 + addForm.username = '' + addForm.password = '' + addForm.country = '' + } + + async function deleteProxy(id: number) { + if (!confirm('确定要删除此代理吗?')) return + try { + await proxyPoolApi.delete(id) + toast('已删除', 'success') + selectedIds.value.delete(id) + await loadAll() + } catch (e: any) { + toast(e.response?.data?.detail || '删除失败', 'error') + } + } + + async function batchDelete() { + const ids = [...selectedIds.value] + if (ids.length === 0) return + if (!confirm(`确定要删除选中的 ${ids.length} 个代理吗?`)) return + try { + await proxyPoolApi.batchDelete(ids) + toast(`已删除 ${ids.length} 个代理`, 'success') + selectedIds.value.clear() + await loadAll() + } catch (e: any) { + toast(e.response?.data?.detail || '批量删除失败', 'error') + } + } + + async function importProxies() { + const text = importText.value.trim() + if (!text) return + importLoading.value = true + try { + const { data } = await proxyPoolApi.import(text, importProtocol.value) + toast(data.message || `已导入 ${data.imported || 0} 个代理`, 'success') + importDialogOpen.value = false + importText.value = '' + await loadAll() + } catch (e: any) { + toast(e.response?.data?.detail || '导入失败', 'error') + } finally { + importLoading.value = false + } + } + + async function fetchFromURL() { + const url = fetchUrlForm.url.trim() + if (!url) return + fetchUrlLoading.value = true + try { + const { data } = await proxyPoolApi.fetchURL(url, fetchUrlForm.protocol) + toast(data.message || `已导入 ${data.imported || 0} 个代理`, 'success') + fetchUrlDialogOpen.value = false + fetchUrlForm.url = '' + await loadAll() + } catch (e: any) { + toast(e.response?.data?.detail || '抓取失败', 'error') + } finally { + fetchUrlLoading.value = false + } + } + + async function triggerHealthCheck() { + if (healthCheckRunning.value) return + healthCheckRunning.value = true + let refreshTimer: ReturnType | undefined + let cooldownTimer: ReturnType | undefined + try { + // 选中代理时仅检查选中的,否则检查当前页全部 + const ids = selectedIds.value.size > 0 + ? [...selectedIds.value] + : proxies.value.map(p => p.id) + const { data } = await proxyPoolApi.healthCheck(ids) + toast(data.message || '健康检查已完成', 'success') + // 延迟刷新,等待后端检查完成 + refreshTimer = setTimeout(() => loadAll(), 3000) + } catch (e: any) { + toast(e.response?.data?.detail || '触发健康检查失败', 'error') + } finally { + cooldownTimer = setTimeout(() => { healthCheckRunning.value = false }, 3000) + } + void refreshTimer + void cooldownTimer + } + + async function purgeUnhealthy() { + if (!confirm('确定要清除所有不健康的代理吗?')) return + purgeRunning.value = true + try { + const { data } = await proxyPoolApi.purge() + toast(data.message || '不健康代理已清除', 'success') + await loadAll() + } catch (e: any) { + toast(e.response?.data?.detail || '清除失败', 'error') + } finally { + purgeRunning.value = false + } + } + + async function resetHealth(id: number) { + try { + await proxyPoolApi.reset(id) + toast('健康状态已重置', 'success') + await loadAll() + } catch (e: any) { + toast(e.response?.data?.detail || '重置失败', 'error') + } + } + + // ── 选择逻辑 ── + + function toggleSelect(id: number) { + if (selectedIds.value.has(id)) selectedIds.value.delete(id) + else selectedIds.value.add(id) + } + + function toggleSelectAll() { + if (selectedIds.value.size === proxies.value.length) { + selectedIds.value.clear() + } else { + selectedIds.value = new Set(proxies.value.map(p => p.id)) + } + } + + // ── 密码显示 ── + + function togglePasswordVisible(id: number) { + if (showPasswordIds.value.has(id)) showPasswordIds.value.delete(id) + else showPasswordIds.value.add(id) + } + + function isPasswordVisible(id: number) { + return showPasswordIds.value.has(id) + } + + // ── 分页 ── + + const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value))) + + function goToPage(p: number) { + if (p < 1 || p > totalPages.value) return + currentPage.value = p + loadProxies() + } + + // ── 格式化工具 ── + + function formatLatency(ms: number): string { + if (!ms || ms <= 0) return '-' + if (ms < 1000) return `${ms}ms` + return `${(ms / 1000).toFixed(1)}s` + } + + function formatLastChecked(dateStr?: string): string { + if (!dateStr) return '未检查' + const d = new Date(dateStr) + const diff = Date.now() - d.getTime() + const sec = Math.floor(diff / 1000) + if (sec < 60) return '刚刚' + const min = Math.floor(sec / 60) + if (min < 60) return `${min}分钟前` + const hr = Math.floor(min / 60) + if (hr < 24) return `${hr}小时前` + const day = Math.floor(hr / 24) + return `${day}天前` + } + + return { + // 状态 + proxies, proxyStats, loading, statsLoading, + healthCheckRunning, purgeRunning, filterMode, + selectedIds, + addForm, addDialogOpen, addLoading, + importDialogOpen, importText, importProtocol, importLoading, + showPasswordIds, + // URL 抓取 + fetchUrlDialogOpen, fetchUrlLoading, fetchUrlForm, + // 分页 + currentPage, pageSize, totalCount, totalPages, + // 方法 + loadProxies, loadStats, loadAll, + addProxy, resetAddForm, deleteProxy, batchDelete, + importProxies, fetchFromURL, + triggerHealthCheck, purgeUnhealthy, resetHealth, + toggleSelect, toggleSelectAll, + togglePasswordVisible, isPasswordVisible, + formatLatency, formatLastChecked, + goToPage, + // 平台代理策略 + platformProxyConfigs, proxyConfigLoading, + PLATFORMS, PLATFORM_LABELS, + loadProxyConfig, saveProxyConfig, saveAllProxyConfigs, + } +} diff --git a/web/src/composables/useTaskEngine.ts b/web/src/composables/useTaskEngine.ts new file mode 100644 index 0000000..5b63860 --- /dev/null +++ b/web/src/composables/useTaskEngine.ts @@ -0,0 +1,342 @@ +/** + * 任务引擎 — SSE 连接、任务启动/停止、计时器 + * 依赖 useDashboard 的共享状态 + */ +import { nextTick, watch } from 'vue' +import { taskApi } from '../api/client' +import { useDashboard } from './useDashboard' + +const SSE_MAX_RETRIES = 5 +let sseSource: EventSource | null = null +let sseRetryCount = 0 +let sseRetryTimer: ReturnType | null = null +let elapsedTimer: ReturnType | null = null +let refreshDebounceTimer: ReturnType | null = null +let queuePollTimer: ReturnType | null = null + +export function useTaskEngine() { + const d = useDashboard() + + // ── 计时器 ── + function startElapsedTimer() { + stopElapsedTimer() + d.elapsedSeconds.value = 0 + elapsedTimer = setInterval(() => { d.elapsedSeconds.value++ }, 1000) + } + function stopElapsedTimer() { + if (elapsedTimer) { clearInterval(elapsedTimer); elapsedTimer = null } + } + + // ── 防抖刷新(trailing-edge:最后一次触发后 2s 执行,保证最终状态被刷新)── + function debouncedRefresh() { + if (refreshDebounceTimer) clearTimeout(refreshDebounceTimer) + refreshDebounceTimer = setTimeout(() => { + refreshDebounceTimer = null + d.loadResults() + d.loadTxHistory() + d.loadBalance() + }, 2000) + } + + // ── 排队轮询(每 5 秒检查任务是否已被调度启动)── + function startQueuePolling(taskId: number) { + stopQueuePolling() + queuePollTimer = setInterval(async () => { + try { + const { data } = await taskApi.current(d.taskForm.platform) + if (data && data.task_id === taskId && data.status === 'running') { + stopQueuePolling() + d.queueModal.show = false + d.isQueued.value = false + d.isRunning.value = true + sseRetryCount = 0 + startElapsedTimer() + connectSSE(taskId) + const msg = `${d.platformLabel.value} 任务 #${taskId} 已启动` + d.toast(msg, 'success') + d.setTaskNotice(msg, 'success') + } else if (data && data.task_id === taskId && (data.status === 'queued' || data.status === 'pending')) { + // 更新排队位置和预计等待时间 + if (data.queue_position) d.queueModal.position = data.queue_position + if (data.queue_wait_sec) { + const min = Math.ceil(data.queue_wait_sec / 60) + d.queueModal.waitTime = min >= 1 ? `约 ${min} 分钟` : '即将开始' + } + } else if (!data || data.task_id !== taskId || data.status === 'stopped' || data.status === 'completed') { + // 任务已结束或被取消 + stopQueuePolling() + d.queueModal.show = false + d.isQueued.value = false + d.loadBalance(); d.loadGlobalStats() + } + } catch { /* ignore */ } + }, 5000) + } + function stopQueuePolling() { + if (queuePollTimer) { clearInterval(queuePollTimer); queuePollTimer = null } + } + + // ── SSE ── + function connectSSE(taskId: number) { + disconnectSSE() + const token = localStorage.getItem('token') + sseSource = new EventSource(`/ws/logs/${taskId}/stream?token=${token}`) + sseSource.onmessage = (e) => { + sseRetryCount = 0 + try { + const data = JSON.parse(e.data) + if (data.type === 'log') { + // 收到日志说明任务已真正开始,关闭排队等待弹窗 + if (d.queueModal.show) d.queueModal.show = false + d.lastLogAt.value = Date.now() + const msg = data.message as string + d.logs.value.push(msg) + if (d.logs.value.length > 500) d.logs.value.splice(0, d.logs.value.length - 500) + nextTick(() => { if (d.logPanel.value) d.logPanel.value.scrollTop = d.logPanel.value.scrollHeight }) + } else if (data.type === 'status') { + const prevSuccess = d.taskStatus.success_count + const prevFail = d.taskStatus.fail_count + d.taskStatus.success_count = data.success + d.taskStatus.fail_count = data.fail + if (data.success > prevSuccess) { + const delta = data.success - prevSuccess + d.userStats.total_success += delta + const p = d.taskForm.platform + if (!d.byPlatformStats[p]) d.byPlatformStats[p] = { success: 0, fail: 0 } + d.byPlatformStats[p].success += delta + } + if (data.fail > prevFail) { + const delta = data.fail - prevFail + d.userStats.total_fail += delta + const p = d.taskForm.platform + if (!d.byPlatformStats[p]) d.byPlatformStats[p] = { success: 0, fail: 0 } + d.byPlatformStats[p].fail += delta + } + if (data.success > prevSuccess) debouncedRefresh() + } else if (data.type === 'complete') { + if (d.queueModal.show) d.queueModal.show = false + d.taskStatus.success_count = data.success + d.taskStatus.fail_count = data.fail + d.taskStatus.is_done = true + d.isRunning.value = false + d.isQueued.value = false + stopElapsedTimer() + disconnectSSE() + d.loadBalance(); d.loadResults(); d.loadTxHistory(); d.loadGlobalStats() + const elapsedStr = data.elapsed ? ` 耗时 ${data.elapsed}` : '' + const msg = `任务完成!成功 ${data.success} 个,失败 ${data.fail} 个${elapsedStr}` + d.toast(msg, 'success') + d.setTaskNotice(msg, 'success') + d.playNotificationSound() + d.showBrowserNotification('任务完成', msg) + if (data.success > 0) d.sprayTaskCompleteRibbons() + setTimeout(() => { d.loadBalance(); d.loadTxHistory() }, 3000) + setTimeout(() => { if (d.taskStatus.is_done && !d.isRunning.value) d.clearTaskNotice() }, 5000) + } else if (data.type === 'error' || data.type === 'info') { + d.logs.value.push(data.message) + if (data.type === 'error') { + if (d.queueModal.show) d.queueModal.show = false + d.taskStatus.is_done = true + d.isRunning.value = false + d.isQueued.value = false + stopElapsedTimer() + disconnectSSE() + d.loadBalance(); d.loadResults(); d.loadTxHistory(); d.loadGlobalStats() + } + } + } catch (err) { d.logs.value.push('[!] 日志解析异常: ' + (err as Error).message) } + } + sseSource.onerror = () => { + // 防止已切换到新任务后,旧 SSE 的 onerror 仍触发重连 + if (d.taskStatus.task_id !== taskId) { disconnectSSE(); return } + sseRetryCount++ + if (sseRetryCount >= SSE_MAX_RETRIES) { + disconnectSSE() + checkTaskAndRecover(taskId) + return + } + // 清除已有定时器防重复连接 + if (sseRetryTimer) { clearTimeout(sseRetryTimer); sseRetryTimer = null } + if (d.isRunning.value) { + sseRetryTimer = setTimeout(() => { sseRetryTimer = null; if (d.isRunning.value && d.taskStatus.task_id === taskId) connectSSE(taskId) }, 3000) + } + } + } + + function disconnectSSE() { + if (sseRetryTimer) { clearTimeout(sseRetryTimer); sseRetryTimer = null } + sseSource?.close() + sseSource = null + } + + async function checkTaskAndRecover(taskId: number) { + try { + const { data } = await taskApi.current(d.taskForm.platform) + if (data && data.task_id === taskId && data.status === 'running') { + sseRetryCount = 0; connectSSE(taskId); return + } + d.isRunning.value = false; d.isQueued.value = false; d.taskStatus.is_done = true + if (data) { + d.taskStatus.success_count = data.success_count || d.taskStatus.success_count + d.taskStatus.fail_count = data.fail_count || d.taskStatus.fail_count + } + d.loadBalance(); d.loadResults(); d.loadTxHistory(); d.loadGlobalStats() + d.toast('任务已结束', 'info') + setTimeout(() => { d.loadBalance(); d.loadTxHistory() }, 3000) + } catch { + d.isRunning.value = false; d.isQueued.value = false; d.taskStatus.is_done = true + } + } + + // ── 任务操作 ── + async function startTask(proxyId?: number) { + if (d.processing.value) return + d.processing.value = true + try { + const { data: task } = await taskApi.create({ + platform: d.taskForm.platform, + target: d.taskForm.target_count, + threads: 0, + mode: d.currentPlatformFree.value ? d.taskMode.value : '', + proxy_id: proxyId || undefined, + }) + const { data: started } = await taskApi.start(task.task_id) + const isQueued = started.message && (started.message.includes('队列') || started.message.includes('排队')) + + Object.assign(d.taskStatus, { + task_id: task.task_id, platform: d.taskForm.platform, + target: d.taskForm.target_count, threads: task.threads || d.taskForm.thread_count, + credits_reserved: d.taskForm.target_count, + success_count: 0, fail_count: 0, is_done: false, + }) + d.logs.value = [] + sseRetryCount = 0 + + if (isQueued) { + // 排队中:不启动计时器/SSE,弹排队弹窗,后台轮询等待调度 + d.isQueued.value = true + const posMatch = started.message.match(/前方\s*(\d+)/) + const waitMatch = started.message.match(/预计等待\s*(.+)/) + d.queueModal.position = posMatch ? parseInt(posMatch[1]) : 0 + d.queueModal.waitTime = waitMatch ? waitMatch[1] : '' + d.queueModal.message = started.message + d.queueModal.show = true + d.setTaskNotice(started.message, 'info') + startQueuePolling(task.task_id) + } else { + // 直接启动:正常流程 + d.isRunning.value = true + startElapsedTimer() + connectSSE(task.task_id) + const msg = `${d.platformLabel.value} 任务 #${task.task_id} 已启动 (${d.taskForm.target_count}个 × ${d.taskForm.thread_count}线程)` + d.toast(msg, 'success'); d.setTaskNotice(msg, 'success') + } + d.loadBalance(); d.loadTxHistory(); d.loadGlobalStats() + } catch (e: any) { + const errMsg = e.response?.data?.detail || '启动失败' + d.toast(errMsg, 'error'); d.setTaskNotice(errMsg, 'error') + } finally { d.processing.value = false } + } + + async function stopTask() { + if (!d.taskStatus.task_id || d.processing.value) return + d.processing.value = true + try { + await taskApi.stop(d.taskStatus.task_id) + disconnectSSE(); stopElapsedTimer(); stopQueuePolling() + d.queueModal.show = false + d.isQueued.value = false + d.isRunning.value = false; d.taskStatus.is_done = true + await d.loadBalance(); await d.loadResults(); await d.loadTxHistory() + d.loadGlobalStats() + d.toast('任务已停止', 'success') + d.setTaskNotice(`任务 #${d.taskStatus.task_id} 已停止 — 成功 ${d.taskStatus.success_count} 个`, 'info') + setTimeout(() => { d.loadBalance(); d.loadTxHistory() }, 3000) + } catch (e: any) { d.toast(e.response?.data?.detail || '停止失败', 'error') } + finally { d.processing.value = false } + } + + async function loadCurrentTask() { + try { + const { data } = await taskApi.current() + if (data && data.task_id) { + Object.assign(d.taskStatus, data) + if (data.platform) d.taskForm.platform = data.platform + if (data.status === 'running' || data.status === 'stopping') { + d.isRunning.value = true; sseRetryCount = 0 + startElapsedTimer(); connectSSE(data.task_id) + } else if (data.status === 'queued' || data.status === 'pending') { + d.isQueued.value = true + // 页面恢复时不弹模态框,只静默轮询;任务卡片已显示"排队中" + d.setTaskNotice('任务排队中,等待调度...', 'info') + startQueuePolling(data.task_id) + } + } + } catch { /* 页面加载时静默,resumeFromInit 是主恢复路径 */ } + } + + /** 从 initLoad 返回的 current_task 恢复 SSE */ + function resumeFromInit(currentTask: any) { + if (!currentTask?.task_id) return + Object.assign(d.taskStatus, currentTask) + if (currentTask.platform) d.taskForm.platform = currentTask.platform + if (currentTask.status === 'running') { + d.isRunning.value = true; sseRetryCount = 0 + startElapsedTimer(); connectSSE(currentTask.task_id) + } else if (currentTask.status === 'stopping') { + // stopping 视为运行中,等待引擎完成清理 + d.isRunning.value = true; sseRetryCount = 0 + startElapsedTimer(); connectSSE(currentTask.task_id) + } else if (currentTask.status === 'queued' || currentTask.status === 'pending') { + d.isQueued.value = true + // 页面恢复时不弹模态框,只静默轮询;任务卡片已显示"排队中" + d.setTaskNotice('任务排队中,等待调度...', 'info') + startQueuePolling(currentTask.task_id) + } + } + + // ── Watchers ── + /** 自动计算线程数 */ + function setupWatchers() { + watch(() => d.taskForm.target_count, (target) => { + if (d.isRunning.value || d.isQueued.value) return + let threads: number | undefined + if (d.limits.thread_tiers) { + try { + const tiers = typeof d.limits.thread_tiers === 'string' ? JSON.parse(d.limits.thread_tiers) : d.limits.thread_tiers + if (Array.isArray(tiers) && tiers.length > 0) { + for (const tier of tiers) { if (target <= tier.max) { threads = tier.threads; break } } + if (threads === undefined) threads = tiers[tiers.length - 1].threads + } + } catch { /* 降级 */ } + } + if (threads === undefined) { + if (target <= 5) threads = 1 + else if (target <= 20) threads = 2 + else if (target <= 100) threads = 3 + else if (target <= 300) threads = 5 + else if (target <= 500) threads = 8 + else threads = 12 + } + if (d.limits.max_threads > 0 && threads > d.limits.max_threads) threads = d.limits.max_threads + d.taskForm.thread_count = threads + }, { immediate: true }) + + watch(d.sliderMax, (max) => { + if (d.taskForm.target_count > max) d.taskForm.target_count = max + }) + } + + // ── 清理 ── + function cleanup() { + disconnectSSE(); stopElapsedTimer(); stopQueuePolling() + d.isQueued.value = false + if (refreshDebounceTimer) { clearTimeout(refreshDebounceTimer); refreshDebounceTimer = null } + } + + return { + startTask, stopTask, loadCurrentTask, resumeFromInit, + connectSSE, disconnectSSE, startElapsedTimer, stopElapsedTimer, + setupWatchers, cleanup, + } +} diff --git a/web/src/composables/useTooltip.ts b/web/src/composables/useTooltip.ts new file mode 100644 index 0000000..b3914ff --- /dev/null +++ b/web/src/composables/useTooltip.ts @@ -0,0 +1,103 @@ +/** + * 全局 Tooltip 系统 + * position:fixed 挂在 body 上,不受 overflow 裁剪影响 + * 用法:在 onMounted 调用 setup(),在 onUnmounted 调用 destroy() + */ +export function useTooltip() { + let tipEl: HTMLDivElement | null = null + let tipArrow: HTMLDivElement | null = null + let tipHideTimer: ReturnType | null = null + + function setup() { + tipEl = document.createElement('div') + tipEl.style.cssText = 'position:fixed;z-index:99999;pointer-events:none;opacity:0;transition:opacity 0.15s;padding:4px 8px;border-radius:6px;background:var(--tooltip-bg);border:1px solid var(--tooltip-border);color:var(--tooltip-text);font-size:10px;line-height:1.4;white-space:nowrap;max-width:280px;' + tipArrow = document.createElement('div') + tipArrow.style.cssText = 'position:fixed;z-index:99999;pointer-events:none;opacity:0;transition:opacity 0.15s;width:0;height:0;border:4px solid transparent;' + document.body.appendChild(tipEl) + document.body.appendChild(tipArrow) + document.addEventListener('mouseover', onTipEnter, true) + document.addEventListener('mouseout', onTipLeave, true) + } + + function destroy() { + if (tipHideTimer) { clearTimeout(tipHideTimer); tipHideTimer = null } + document.removeEventListener('mouseover', onTipEnter, true) + document.removeEventListener('mouseout', onTipLeave, true) + tipEl?.remove() + tipArrow?.remove() + tipEl = null + tipArrow = null + } + + function onTipEnter(e: Event) { + const target = (e.target as HTMLElement)?.closest?.('.tip[data-tip]') as HTMLElement | null + if (!target || !tipEl || !tipArrow) return + const text = target.getAttribute('data-tip') + if (!text) return + if (tipHideTimer) { clearTimeout(tipHideTimer); tipHideTimer = null } + tipEl.textContent = text + tipEl.style.opacity = '0' + tipArrow.style.opacity = '0' + requestAnimationFrame(() => { + if (!tipEl || !tipArrow) return + const rect = target.getBoundingClientRect() + const tw = tipEl.offsetWidth + const th = tipEl.offsetHeight + const gap = 6 + const isBottom = target.classList.contains('tip-bottom') + const isLeft = target.classList.contains('tip-left') + const isRight = target.classList.contains('tip-right') + let tx: number, ty: number, ax: number, ay: number, arrowBorder: string + if (isBottom) { + tx = rect.left + rect.width / 2 - tw / 2 + ty = rect.bottom + gap + ax = rect.left + rect.width / 2 - 4 + ay = rect.bottom + gap - 8 + arrowBorder = 'border-color: transparent transparent var(--tooltip-bg) transparent' + } else if (isLeft) { + tx = rect.left - tw - gap + ty = rect.top + rect.height / 2 - th / 2 + ax = rect.left - gap - 4 + ay = rect.top + rect.height / 2 - 4 + arrowBorder = 'border-color: transparent transparent transparent var(--tooltip-bg)' + } else if (isRight) { + tx = rect.right + gap + ty = rect.top + rect.height / 2 - th / 2 + ax = rect.right + gap - 4 + ay = rect.top + rect.height / 2 - 4 + arrowBorder = 'border-color: transparent var(--tooltip-bg) transparent transparent' + } else { + tx = rect.left + rect.width / 2 - tw / 2 + ty = rect.top - th - gap + ax = rect.left + rect.width / 2 - 4 + ay = rect.top - gap + arrowBorder = 'border-color: var(--tooltip-bg) transparent transparent transparent' + } + const vw = window.innerWidth + const vh = window.innerHeight + if (tx < 4) tx = 4 + if (tx + tw > vw - 4) tx = vw - tw - 4 + if (ty < 4 && !isBottom) { + ty = rect.bottom + gap + ay = rect.bottom + gap - 8 + arrowBorder = 'border-color: transparent transparent var(--tooltip-bg) transparent' + } + if (ty + th > vh - 4) ty = vh - th - 4 + tipEl.style.left = tx + 'px' + tipEl.style.top = ty + 'px' + tipEl.style.opacity = '1' + tipArrow.style.cssText = `position:fixed;width:0;height:0;border:4px solid transparent;pointer-events:none;z-index:100000;left:${ax}px;top:${ay}px;${arrowBorder};opacity:1` + }) + } + + function onTipLeave(e: Event) { + const target = (e.target as HTMLElement)?.closest?.('.tip[data-tip]') as HTMLElement | null + if (!target) return + tipHideTimer = setTimeout(() => { + if (tipEl) tipEl.style.opacity = '0' + if (tipArrow) tipArrow.style.opacity = '0' + }, 50) + } + + return { setup, destroy } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..aa07ea0 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,10 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' +import router from './router' +import './assets/main.css' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts new file mode 100644 index 0000000..b8ceb67 --- /dev/null +++ b/web/src/router/index.ts @@ -0,0 +1,39 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: '/', + name: 'login', + component: () => import('../views/LoginView.vue'), + }, + { + path: '/dashboard', + name: 'dashboard', + component: () => import('../views/DashboardView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/admin', + redirect: '/dashboard', + }, + ], +}) + +// 导航守卫 +router.beforeEach((to) => { + const auth = useAuthStore() + + if (to.meta.requiresAuth && !auth.token) { + return { name: 'login' } + } + + // 管理员路由守卫 + if (to.meta.requiresAdmin && !auth.isAdmin) { + return { name: 'dashboard' } + } +}) + +export default router diff --git a/web/src/stores/auth.ts b/web/src/stores/auth.ts new file mode 100644 index 0000000..47d0d5f --- /dev/null +++ b/web/src/stores/auth.ts @@ -0,0 +1,47 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { authApi } from '../api/client' + +export interface UserInfo { + id: number + username: string + name: string + email: string + avatar_url: string + trust_level: number + role: number + credits: number + free_trial_used: boolean + free_trial_remaining: number + is_admin: boolean +} + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(localStorage.getItem('token') || '') + + const isLoggedIn = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.is_admin ?? false) + + function setToken(t: string) { + token.value = t + localStorage.setItem('token', t) + } + + async function fetchMe() { + try { + const { data } = await authApi.me() + user.value = data + } catch { + logout() + } + } + + function logout() { + token.value = '' + user.value = null + localStorage.removeItem('token') + } + + return { user, token, isLoggedIn, isAdmin, setToken, fetchMe, logout } +}) diff --git a/web/src/views/AdminView.vue b/web/src/views/AdminView.vue new file mode 100644 index 0000000..282683f --- /dev/null +++ b/web/src/views/AdminView.vue @@ -0,0 +1,3 @@ + diff --git a/web/src/views/DashboardView.vue b/web/src/views/DashboardView.vue new file mode 100644 index 0000000..e704af8 --- /dev/null +++ b/web/src/views/DashboardView.vue @@ -0,0 +1,117 @@ + + + diff --git a/web/src/views/LoginView.vue b/web/src/views/LoginView.vue new file mode 100644 index 0000000..47701ab --- /dev/null +++ b/web/src/views/LoginView.vue @@ -0,0 +1,163 @@ + + + + + diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..8482931 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", "env.d.ts"] +} diff --git a/web/tsconfig.tsbuildinfo b/web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..3add9ab --- /dev/null +++ b/web/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/main.ts","./src/api/client.ts","./src/composables/useadmin.ts","./src/composables/usedashboard.ts","./src/composables/usehfspace.ts","./src/composables/useproxypool.ts","./src/composables/usetaskengine.ts","./src/composables/usetooltip.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/dashboard/adminhfspacetab.vue","./src/components/dashboard/adminmodal.vue","./src/components/dashboard/adminnotifytab.vue","./src/components/dashboard/adminopstab.vue","./src/components/dashboard/adminproxypooltab.vue","./src/components/dashboard/adminsystemtab.vue","./src/components/dashboard/adminuserstab.vue","./src/components/dashboard/contentarea.vue","./src/components/dashboard/leftsidebar.vue","./src/components/dashboard/logpanel.vue","./src/components/dashboard/mobiledrawer.vue","./src/components/dashboard/proxydialog.vue","./src/components/dashboard/toastoverlay.vue","./src/components/dashboard/topnavbar.vue","./src/views/adminview.vue","./src/views/dashboardview.vue","./src/views/loginview.vue","./env.d.ts"],"version":"5.7.3"} \ No newline at end of file diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..280c77e --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + server: { + port: 3000, + proxy: { + '/api': 'http://localhost:8000', + '/ws': { target: 'http://localhost:8000', ws: true }, + }, + }, + build: { + outDir: 'dist', + }, +})