Browse Source

first commit

husj 1 month ago
commit
9b0f7c3494

+ 77 - 0
README.md

@@ -0,0 +1,77 @@
+# husj-openai
+
+OpenAI 自动注册服务 — Spring Boot 版(完整复刻 `openai_register.py` 功能)
+
+## 功能亮点
+
+- 支持两种临时邮箱:**TempMail.lol** 与 **GPTMail**,可手动指定,也可自动回退。
+- 完整复刻 OpenAI 10 步注册流程(PKCE OAuth + Sentinel Token + OTP 验证 + Workspace 选择 + Token 换取)。
+- 支持 **CPA 池管理**:token 上传、失效探测(401 / 用量超阈值)、自动清理。
+- 本地存储:`tokens/accounts.txt` + `tokens/token_<email>_<timestamp>.json`。
+- REST API 对外暴露全部功能,无需命令行。
+
+## 快速启动
+
+```bash
+cd husj-openai
+mvn clean package -DskipTests
+java -jar target/husj-openai-0.0.1-SNAPSHOT.jar
+```
+
+或指定代理与邮箱提供商:
+
+```bash
+java -jar target/husj-openai-0.0.1-SNAPSHOT.jar \
+  --husj.openai.proxy=http://127.0.0.1:7890 \
+  --husj.openai.mail-provider=tempmail
+```
+
+## 配置项(application.yml)
+
+| 配置项 | 默认值 | 说明 |
+|--------|--------|------|
+| `husj.openai.proxy` | `""` | HTTP 代理,例 `http://127.0.0.1:7890` |
+| `husj.openai.mail-provider` | `auto` | `auto` / `gptmail` / `tempmail` |
+| `husj.openai.output-dir` | `""` | Token 输出目录(默认运行目录下 `tokens/`) |
+| `husj.openai.cpa.base-url` | `""` | CPA 管理端地址(填到 `协议+IP+端口`) |
+| `husj.openai.cpa.token` | `""` | CPA 登录密码(也可用环境变量 `CPA_TOKEN`) |
+| `husj.openai.cpa.workers` | `20` | 并发探测数 |
+| `husj.openai.cpa.timeout` | `12` | 请求超时(秒) |
+| `husj.openai.cpa.retries` | `1` | 清理探测重试次数 |
+| `husj.openai.cpa.used-threshold` | `95` | 用量超此值(%)判定为失效 |
+| `husj.openai.cpa.target-count` | `300` | CPA 目标有效 token 数 |
+
+## REST API
+
+### 触发注册
+```
+POST /api/register
+
+Query 参数(可选):
+  ?cpaUpload=true        注册后上传 CPA
+  ?cpaClean=true         注册前/后清理 CPA 失效账号
+  ?cpaPruneLocal=true    上传成功后删除本地文件 + accounts.txt 行
+```
+
+### CPA 管理
+```
+GET  /api/cpa/status     统计有效 token 数
+GET  /api/cpa/files      获取 CPA auth-files 列表
+POST /api/cpa/clean      手动触发探测 + 清理
+```
+
+### 服务状态
+```
+GET /api/status          返回服务配置概览
+```
+
+## 输出位置
+
+- 账号密码:`tokens/accounts.txt`(`email----password` 格式)
+- Token JSON:`tokens/token_<email>_<timestamp>.json`
+
+## 注意事项
+
+- 需能访问 `https://auth.openai.com`;代理地区尽量避开 CN/HK。
+- CPA 地址只填写到 `协议 + IP/域名 + 端口`,不要带后台页面路径。
+- 如使用 `auto` 邮箱模式,优先尝试 TempMail.lol,失败后自动回退到 GPTMail。

+ 95 - 0
pom.xml

@@ -0,0 +1,95 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>org.springframework.boot</groupId>
+        <artifactId>spring-boot-starter-parent</artifactId>
+        <version>3.2.5</version>
+        <relativePath/>
+    </parent>
+
+    <groupId>com.husj</groupId>
+    <artifactId>husj-openai</artifactId>
+    <version>0.0.1-SNAPSHOT</version>
+    <name>husj-openai</name>
+    <description>OpenAI 自动注册 Spring Boot 服务</description>
+    <packaging>jar</packaging>
+
+    <properties>
+        <java.version>17</java.version>
+        <okhttp.version>4.12.0</okhttp.version>
+    </properties>
+
+    <dependencies>
+        <!-- Spring Boot Web -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+
+        <!-- OkHttp3 - HTTP client with cookie management -->
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp</artifactId>
+            <version>${okhttp.version}</version>
+        </dependency>
+
+        <!-- OkHttp3 URLConnection bridge — provides JavaNetCookieJar -->
+        <dependency>
+            <groupId>com.squareup.okhttp3</groupId>
+            <artifactId>okhttp-urlconnection</artifactId>
+            <version>${okhttp.version}</version>
+        </dependency>
+
+        <!-- Jackson -->
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-databind</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.fasterxml.jackson.core</groupId>
+            <artifactId>jackson-annotations</artifactId>
+        </dependency>
+
+        <!-- Lombok -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- Configuration processor -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+
+        <!-- Test -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <configuration>
+                    <excludes>
+                        <exclude>
+                            <groupId>org.projectlombok</groupId>
+                            <artifactId>lombok</artifactId>
+                        </exclude>
+                    </excludes>
+                </configuration>
+            </plugin>
+        </plugins>
+    </build>
+</project>

+ 15 - 0
src/main/java/com/husj/openai/HusjOpenaiApplication.java

@@ -0,0 +1,15 @@
+package com.husj.openai;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import com.husj.openai.config.AppProperties;
+
+@SpringBootApplication
+@EnableConfigurationProperties(AppProperties.class)
+public class HusjOpenaiApplication {
+
+    public static void main(String[] args) {
+        SpringApplication.run(HusjOpenaiApplication.class, args);
+    }
+}

+ 45 - 0
src/main/java/com/husj/openai/config/AppProperties.java

@@ -0,0 +1,45 @@
+package com.husj.openai.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "husj.openai")
+public class AppProperties {
+
+    /** 代理地址, 例: http://127.0.0.1:7890 */
+    private String proxy = "";
+
+    /** 临时邮箱提供商: auto / gptmail / tempmail */
+    private String mailProvider = "auto";
+
+    /** 循环模式最小等待秒数 */
+    private int sleepMin = 5;
+
+    /** 循环模式最大等待秒数 */
+    private int sleepMax = 30;
+
+    /** 输出目录 */
+    private String outputDir = "";
+
+    /** CPA 配置 */
+    private Cpa cpa = new Cpa();
+
+    @Data
+    public static class Cpa {
+        /** CPA 管理端地址 */
+        private String baseUrl = "";
+        /** CPA 管理密码 (Bearer token) */
+        private String token = "";
+        /** 并发探测数 */
+        private int workers = 20;
+        /** 请求超时(秒) */
+        private int timeout = 12;
+        /** 清理重试次数 */
+        private int retries = 1;
+        /** 用量超此值判定为失效(%) */
+        private int usedThreshold = 95;
+        /** 目标有效 token 数 */
+        private int targetCount = 300;
+    }
+}

+ 44 - 0
src/main/java/com/husj/openai/config/OkHttpConfig.java

@@ -0,0 +1,44 @@
+package com.husj.openai.config;
+
+import okhttp3.CookieJar;
+import okhttp3.JavaNetCookieJar;
+import okhttp3.OkHttpClient;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.net.CookieManager;
+import java.net.CookiePolicy;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.util.concurrent.TimeUnit;
+
+@Configuration
+public class OkHttpConfig {
+
+    @Bean
+    public OkHttpClient okHttpClient(AppProperties props) {
+        CookieManager cookieManager = new CookieManager();
+        cookieManager.setCookiePolicy(CookiePolicy.ACCEPT_ALL);
+        CookieJar cookieJar = new JavaNetCookieJar(cookieManager);
+
+        OkHttpClient.Builder builder = new OkHttpClient.Builder()
+                .cookieJar(cookieJar)
+                .connectTimeout(15, TimeUnit.SECONDS)
+                .readTimeout(30, TimeUnit.SECONDS)
+                .writeTimeout(15, TimeUnit.SECONDS)
+                .followRedirects(true)
+                .followSslRedirects(true);
+
+        String proxyStr = props.getProxy();
+        if (proxyStr != null && !proxyStr.isBlank()) {
+            String noProto = proxyStr.replaceFirst("^https?://", "");
+            String[] parts = noProto.split(":");
+            if (parts.length == 2) {
+                builder.proxy(new Proxy(Proxy.Type.HTTP,
+                        new InetSocketAddress(parts[0], Integer.parseInt(parts[1]))));
+            }
+        }
+
+        return builder.build();
+    }
+}

+ 48 - 0
src/main/java/com/husj/openai/controller/CpaController.java

@@ -0,0 +1,48 @@
+package com.husj.openai.controller;
+
+import com.husj.openai.model.ApiResponse;
+import com.husj.openai.service.CpaPoolService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * CPA 池管理接口
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api/cpa")
+@RequiredArgsConstructor
+public class CpaController {
+
+    private final CpaPoolService cpaPoolService;
+
+    /** 获取 CPA auth-files 列表 */
+    @GetMapping("/files")
+    public ApiResponse<List<Map<String, Object>>> files() {
+        try {
+            return ApiResponse.ok(cpaPoolService.fetchAuthFiles());
+        } catch (Exception e) {
+            return ApiResponse.fail("获取 CPA 文件列表失败: " + e.getMessage());
+        }
+    }
+
+    /** 统计有效 token 数 */
+    @GetMapping("/status")
+    public ApiResponse<Map<String, Object>> status() {
+        int count = cpaPoolService.countValidTokens();
+        return ApiResponse.ok(Map.of("valid_tokens", count));
+    }
+
+    /** 探测并清理失效账号 */
+    @PostMapping("/clean")
+    public ApiResponse<Map<String, Object>> clean() {
+        log.info("[CPA] 手动触发清理");
+        Map<String, Object> result = cpaPoolService.probeAndClean();
+        log.info("[CPA] 清理完成: {}", result);
+        return ApiResponse.ok("清理完成", result);
+    }
+}

+ 107 - 0
src/main/java/com/husj/openai/controller/RegisterController.java

@@ -0,0 +1,107 @@
+package com.husj.openai.controller;
+
+import com.husj.openai.config.AppProperties;
+import com.husj.openai.model.ApiResponse;
+import com.husj.openai.model.RegisterResult;
+import com.husj.openai.service.CpaPoolService;
+import com.husj.openai.service.OpenAiRegisterService;
+import com.husj.openai.service.TokenStorageService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.*;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 注册接口 — POST /api/register
+ */
+@Slf4j
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class RegisterController {
+
+    private final OpenAiRegisterService registerService;
+    private final CpaPoolService cpaPoolService;
+    private final TokenStorageService storageService;
+    private final AppProperties props;
+
+    /**
+     * 触发一次注册
+     * 可选查询参数:
+     *   - cpaUpload=true      注册后上传 CPA
+     *   - cpaClean=true       注册后清理 CPA 失效账号
+     *   - cpaPruneLocal=true  上传成功后删除本地文件
+     */
+    @PostMapping("/register")
+    public ApiResponse<Map<String, Object>> register(
+            @RequestParam(defaultValue = "false") boolean cpaUpload,
+            @RequestParam(defaultValue = "false") boolean cpaClean,
+            @RequestParam(defaultValue = "false") boolean cpaPruneLocal
+    ) {
+        log.info("[API] 收到注册请求 cpaUpload={} cpaClean={} cpaPruneLocal={}", cpaUpload, cpaClean, cpaPruneLocal);
+        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
+        log.info("[{}] >>> 流程开始 <<<", timestamp);
+
+        // 注册前清理
+        if (cpaClean) {
+            Map<String, Object> cleanResult = cpaPoolService.probeAndClean();
+            log.info("[CPA] 清理完成: {}", cleanResult);
+        }
+
+        Optional<RegisterResult> resultOpt = registerService.run();
+        if (resultOpt.isEmpty()) {
+            return ApiResponse.fail("注册流程未能完成");
+        }
+
+        RegisterResult result = resultOpt.get();
+        log.info("[🎉] 成功! {} ---- {}", result.getEmail(), result.getPassword());
+
+        // 保存账号密码
+        storageService.saveAccount(result.getEmail(), result.getPassword());
+
+        // 保存 Token JSON
+        String tokenFile = storageService.saveTokenJson(result.getEmail(), result.getTokenJson());
+        result.setTokenFile(tokenFile);
+
+        // 可选上传 CPA
+        boolean uploadOk = false;
+        if (cpaUpload) {
+            uploadOk = cpaPoolService.uploadToken(tokenFile, result.getTokenJson());
+            result.setUploadedToCpa(uploadOk);
+            if (uploadOk) log.info("[CPA] 已上传 {} 到 CPA", tokenFile);
+            else log.warn("[CPA] 上传失败");
+        }
+
+        // 可选本地清理
+        if (uploadOk && cpaPruneLocal) {
+            storageService.deleteTokenFile(tokenFile);
+            storageService.removeAccountEntry(result.getEmail(), result.getPassword());
+        }
+
+        // 注册后再清理
+        if (cpaClean) {
+            Map<String, Object> cleanResult = cpaPoolService.probeAndClean();
+            log.info("[CPA] 注册后清理完成: {}", cleanResult);
+        }
+
+        return ApiResponse.ok("注册成功", Map.of(
+                "email", result.getEmail(),
+                "password", result.getPassword(),
+                "tokenFile", tokenFile,
+                "uploadedToCpa", uploadOk
+        ));
+    }
+
+    /**
+     * 查询当前 CPA 有效 token 数
+     */
+    @GetMapping("/cpa/count")
+    public ApiResponse<Integer> cpaCount() {
+        int count = cpaPoolService.countValidTokens();
+        return ApiResponse.ok("当前有效 token 数: " + count, count);
+    }
+}

+ 35 - 0
src/main/java/com/husj/openai/controller/StatusController.java

@@ -0,0 +1,35 @@
+package com.husj.openai.controller;
+
+import com.husj.openai.config.AppProperties;
+import com.husj.openai.model.ApiResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.time.LocalDateTime;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * 服务状态接口
+ */
+@RestController
+@RequestMapping("/api")
+@RequiredArgsConstructor
+public class StatusController {
+
+    private final AppProperties props;
+
+    @GetMapping("/status")
+    public ApiResponse<Map<String, Object>> status() {
+        Map<String, Object> info = new LinkedHashMap<>();
+        info.put("service", "husj-openai");
+        info.put("time", LocalDateTime.now().toString());
+        info.put("mailProvider", props.getMailProvider());
+        info.put("proxy", props.getProxy().isBlank() ? "(none)" : props.getProxy());
+        info.put("cpaConfigured", !props.getCpa().getBaseUrl().isBlank() && !props.getCpa().getToken().isBlank());
+        info.put("cpaTargetCount", props.getCpa().getTargetCount());
+        return ApiResponse.ok(info);
+    }
+}

+ 112 - 0
src/main/java/com/husj/openai/mail/EmailBundle.java

@@ -0,0 +1,112 @@
+package com.husj.openai.mail;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * 邮件包装 + OTP 轮询逻辑 — 对应 Python fetch_code() 和 get_email_and_code_fetcher()
+ */
+@Slf4j
+public class EmailBundle {
+
+    private static final Pattern OTP_PATTERN = Pattern.compile("(?<!\\d)(\\d{6})(?!\\d)");
+
+    public enum Provider { TEMPMAIL, GPTMAIL }
+
+    private final Provider provider;
+    private final String email;
+    private final String password;
+
+    // TempMail specific
+    private TempMailClient tempMailClient;
+    // GPTMail specific
+    private GptMailClient gptMailClient;
+
+    /** Build TempMail bundle */
+    public EmailBundle(TempMailClient client, String password) {
+        this.provider = Provider.TEMPMAIL;
+        this.tempMailClient = client;
+        this.email = client.getAddress();
+        this.password = password;
+    }
+
+    /** Build GPTMail bundle */
+    public EmailBundle(GptMailClient client, String email, String password) {
+        this.provider = Provider.GPTMAIL;
+        this.gptMailClient = client;
+        this.email = email;
+        this.password = password;
+    }
+
+    public String getEmail() { return email; }
+    public String getPassword() { return password; }
+    public Provider getProvider() { return provider; }
+
+    /** 从所有已到达邮件中提取所有 6 位验证码 */
+    public List<String> extractAllCodes() {
+        List<String> results = new ArrayList<>();
+        try {
+            List<Map<String, Object>> msgs = fetchRawMessages();
+            for (Map<String, Object> m : msgs) {
+                String combined = buildSearchText(m);
+                Matcher mt = OTP_PATTERN.matcher(combined);
+                while (mt.find()) results.add(mt.group(1));
+            }
+        } catch (Exception ignored) {}
+        return results;
+    }
+
+    /**
+     * 轮询直到收到新的验证码
+     * @param timeoutSec    超时秒数 (默认180)
+     * @param pollInterval  每次轮询间隔毫秒
+     * @param excludeCodes  已知旧验证码, 不重复返回
+     */
+    public String fetchCode(int timeoutSec, long pollIntervalMs, Set<String> excludeCodes) {
+        long start = System.currentTimeMillis();
+        int attempt = 0;
+        while ((System.currentTimeMillis() - start) < timeoutSec * 1000L) {
+            attempt++;
+            try {
+                List<Map<String, Object>> msgs = fetchRawMessages();
+                log.info("[otp][{}] 轮询 #{}, 收到 {} 封邮件, 目标: {}",
+                        provider.name().toLowerCase(), attempt, msgs.size(), email);
+                for (Map<String, Object> m : msgs) {
+                    String combined = buildSearchText(m);
+                    Matcher mt = OTP_PATTERN.matcher(combined);
+                    while (mt.find()) {
+                        String code = mt.group(1);
+                        if (!excludeCodes.contains(code)) return code;
+                    }
+                }
+            } catch (Exception ignored) {}
+            try { Thread.sleep(pollIntervalMs); } catch (InterruptedException ie) {
+                Thread.currentThread().interrupt();
+                return null;
+            }
+        }
+        return null;
+    }
+
+    private List<Map<String, Object>> fetchRawMessages() {
+        return switch (provider) {
+            case TEMPMAIL -> tempMailClient.getMessages();
+            case GPTMAIL -> gptMailClient.listEmails(email);
+        };
+    }
+
+    private String buildSearchText(Map<String, Object> m) {
+        StringBuilder sb = new StringBuilder();
+        for (String key : new String[]{"subject", "body", "text", "html", "from"}) {
+            Object v = m.get(key);
+            if (v != null) sb.append(v).append(" ");
+        }
+        return sb.toString();
+    }
+}

+ 51 - 0
src/main/java/com/husj/openai/mail/EmailProviderFactory.java

@@ -0,0 +1,51 @@
+package com.husj.openai.mail;
+
+import com.husj.openai.util.PkceUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 邮箱提供商工厂 — 对应 Python get_email_and_code_fetcher()
+ */
+@Slf4j
+@Component
+public class EmailProviderFactory {
+
+    /**
+     * 根据 provider 字符串构建 EmailBundle
+     * @param proxy     代理地址 (可为空)
+     * @param provider  "auto" / "gptmail" / "tempmail"
+     */
+    public EmailBundle build(String proxy, String provider) throws Exception {
+        String p = (provider == null ? "auto" : provider).strip().toLowerCase();
+        return switch (p) {
+            case "tempmail" -> buildTempMail(proxy);
+            case "gptmail" -> buildGptMail(proxy);
+            default -> buildAuto(proxy);
+        };
+    }
+
+    private EmailBundle buildTempMail(String proxy) throws Exception {
+        TempMailClient client = new TempMailClient(proxy);
+        return new EmailBundle(client, PkceUtil.genPassword());
+    }
+
+    private EmailBundle buildGptMail(String proxy) throws Exception {
+        GptMailClient client = new GptMailClient(proxy);
+        String email = client.generateEmail();
+        return new EmailBundle(client, email, PkceUtil.genPassword());
+    }
+
+    private EmailBundle buildAuto(String proxy) {
+        try {
+            return buildTempMail(proxy);
+        } catch (Exception e) {
+            log.warn("[邮箱] TempMail.lol 初始化失败,回退 GPTMail: {}", e.getMessage());
+            try {
+                return buildGptMail(proxy);
+            } catch (Exception e2) {
+                throw new RuntimeException("所有邮箱提供商均失败: " + e2.getMessage(), e2);
+            }
+        }
+    }
+}

+ 116 - 0
src/main/java/com/husj/openai/mail/GptMailClient.java

@@ -0,0 +1,116 @@
+package com.husj.openai.mail;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * GPTMail 临时邮箱客户端 — 对应 Python GPTMailClient
+ */
+@Slf4j
+public class GptMailClient {
+
+    private static final String BASE_URL = "https://mail.chatgpt.org.uk";
+    private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
+
+    private final OkHttpClient client;
+    private final ObjectMapper mapper = new ObjectMapper();
+    private String inboxToken;
+
+    public GptMailClient(String proxyStr) {
+        CookieJar cookieJar = new JavaNetCookieJar(new java.net.CookieManager(null, java.net.CookiePolicy.ACCEPT_ALL));
+        OkHttpClient.Builder builder = new OkHttpClient.Builder()
+                .cookieJar(cookieJar)
+                .connectTimeout(15, TimeUnit.SECONDS)
+                .readTimeout(15, TimeUnit.SECONDS);
+        if (proxyStr != null && !proxyStr.isBlank()) {
+            String noProto = proxyStr.replaceFirst("^https?://", "");
+            String[] parts = noProto.split(":");
+            if (parts.length == 2) {
+                builder.proxy(new java.net.Proxy(java.net.Proxy.Type.HTTP,
+                        new java.net.InetSocketAddress(parts[0], Integer.parseInt(parts[1]))));
+            }
+        }
+        this.client = builder.build();
+    }
+
+    private void initBrowserSession() {
+        try {
+            Request req = new Request.Builder()
+                    .url(BASE_URL)
+                    .header("User-Agent", UA)
+                    .header("Accept", "application/json, text/plain, */*")
+                    .header("Accept-Language", "zh-CN,zh;q=0.9")
+                    .header("Referer", "https://mail.chatgpt.org.uk/")
+                    .build();
+            try (Response resp = client.newCall(req).execute()) {
+                String body = resp.body() != null ? resp.body().string() : "";
+                // extract JWT token from page
+                Pattern p = Pattern.compile("(eyJ[a-zA-Z0-9_-]+\\.[a-zA-Z0-9_-]+)");
+                Matcher m = p.matcher(body);
+                if (m.find()) {
+                    this.inboxToken = m.group(1);
+                }
+            }
+        } catch (Exception e) {
+            log.debug("GPTMail initBrowserSession failed: {}", e.getMessage());
+        }
+    }
+
+    public String generateEmail() throws Exception {
+        initBrowserSession();
+        Request.Builder rb = new Request.Builder()
+                .url(BASE_URL + "/api/generate-email")
+                .header("User-Agent", UA)
+                .header("Accept", "application/json, text/plain, */*")
+                .header("Referer", "https://mail.chatgpt.org.uk/");
+        if (inboxToken != null) rb.header("x-inbox-token", inboxToken);
+        Request req = rb.get().build();
+        try (Response resp = client.newCall(req).execute()) {
+            if (resp.code() != 200) throw new RuntimeException("GPTMail generate failed: " + resp.code());
+            String body = resp.body() != null ? resp.body().string() : "{}";
+            JsonNode root = mapper.readTree(body);
+            String email = root.path("data").path("email").asText();
+            String token = root.path("auth").path("token").asText();
+            if (!token.isBlank()) this.inboxToken = token;
+            log.info("[+] 生成邮箱: {} (GPTMail)", email);
+            log.info("[*] 自动轮询已启动(GPTMail 会话已准备)");
+            return email;
+        }
+    }
+
+    public List<Map<String, Object>> listEmails(String email) {
+        List<Map<String, Object>> results = new ArrayList<>();
+        try {
+            String encodedEmail = java.net.URLEncoder.encode(email, java.nio.charset.StandardCharsets.UTF_8);
+            Request.Builder rb = new Request.Builder()
+                    .url(BASE_URL + "/api/emails?email=" + encodedEmail)
+                    .header("User-Agent", UA)
+                    .header("Accept", "application/json");
+            if (inboxToken != null) rb.header("x-inbox-token", inboxToken);
+            rb.header("Referer", "https://mail.chatgpt.org.uk/");
+            try (Response resp = client.newCall(rb.get().build()).execute()) {
+                if (resp.code() == 200 && resp.body() != null) {
+                    JsonNode root = mapper.readTree(resp.body().string());
+                    JsonNode emails = root.path("data").path("emails");
+                    if (emails.isArray()) {
+                        for (JsonNode e : emails) {
+                            results.add(mapper.convertValue(e, Map.class));
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.debug("GPTMail listEmails error: {}", e.getMessage());
+        }
+        return results;
+    }
+}

+ 92 - 0
src/main/java/com/husj/openai/mail/TempMailClient.java

@@ -0,0 +1,92 @@
+package com.husj.openai.mail;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * TempMail.lol 临时邮箱客户端 — 对应 Python EMail class
+ */
+@Slf4j
+public class TempMailClient {
+
+    private static final String API_BASE = "https://api.tempmail.lol/v2";
+    private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36";
+
+    private final OkHttpClient client;
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    private String address;
+    private String token;
+
+    public TempMailClient(String proxyStr) throws Exception {
+        CookieJar cookieJar = new JavaNetCookieJar(new java.net.CookieManager(null, java.net.CookiePolicy.ACCEPT_ALL));
+        OkHttpClient.Builder builder = new OkHttpClient.Builder()
+                .cookieJar(cookieJar)
+                .connectTimeout(15, TimeUnit.SECONDS)
+                .readTimeout(15, TimeUnit.SECONDS);
+        if (proxyStr != null && !proxyStr.isBlank()) {
+            String noProto = proxyStr.replaceFirst("^https?://", "");
+            String[] parts = noProto.split(":");
+            if (parts.length == 2) {
+                builder.proxy(new java.net.Proxy(java.net.Proxy.Type.HTTP,
+                        new java.net.InetSocketAddress(parts[0], Integer.parseInt(parts[1]))));
+            }
+        }
+        this.client = builder.build();
+
+        // Create inbox on construction — mirrors Python __init__
+        RequestBody body = RequestBody.create("{}", MediaType.parse("application/json"));
+        Request req = new Request.Builder()
+                .url(API_BASE + "/inbox/create")
+                .header("User-Agent", UA)
+                .header("Accept", "application/json")
+                .header("Content-Type", "application/json")
+                .post(body)
+                .build();
+        try (Response resp = client.newCall(req).execute()) {
+            if (!resp.isSuccessful() || resp.body() == null) {
+                throw new RuntimeException("TempMail.lol create inbox failed: " + resp.code());
+            }
+            JsonNode root = mapper.readTree(resp.body().string());
+            this.address = root.path("address").asText();
+            this.token = root.path("token").asText();
+            log.info("[+] 生成邮箱: {} (TempMail.lol)", address);
+            log.info("[*] 自动轮询已启动(token 已保存)");
+        }
+    }
+
+    public String getAddress() { return address; }
+
+    public List<Map<String, Object>> getMessages() {
+        List<Map<String, Object>> results = new ArrayList<>();
+        try {
+            Request req = new Request.Builder()
+                    .url(API_BASE + "/inbox?token=" + token)
+                    .header("User-Agent", UA)
+                    .header("Accept", "application/json")
+                    .get()
+                    .build();
+            try (Response resp = client.newCall(req).execute()) {
+                if (resp.isSuccessful() && resp.body() != null) {
+                    JsonNode root = mapper.readTree(resp.body().string());
+                    JsonNode emails = root.path("emails");
+                    if (emails.isArray()) {
+                        for (JsonNode e : emails) {
+                            results.add(mapper.convertValue(e, Map.class));
+                        }
+                    }
+                }
+            }
+        } catch (Exception e) {
+            log.debug("TempMail getMessages error: {}", e.getMessage());
+        }
+        return results;
+    }
+}

+ 36 - 0
src/main/java/com/husj/openai/model/ApiResponse.java

@@ -0,0 +1,36 @@
+package com.husj.openai.model;
+
+import lombok.Data;
+
+/**
+ * 统一 REST 响应包装
+ */
+@Data
+public class ApiResponse<T> {
+    private boolean success;
+    private String message;
+    private T data;
+
+    public static <T> ApiResponse<T> ok(T data) {
+        ApiResponse<T> r = new ApiResponse<>();
+        r.success = true;
+        r.message = "ok";
+        r.data = data;
+        return r;
+    }
+
+    public static <T> ApiResponse<T> ok(String message, T data) {
+        ApiResponse<T> r = new ApiResponse<>();
+        r.success = true;
+        r.message = message;
+        r.data = data;
+        return r;
+    }
+
+    public static <T> ApiResponse<T> fail(String message) {
+        ApiResponse<T> r = new ApiResponse<>();
+        r.success = false;
+        r.message = message;
+        return r;
+    }
+}

+ 14 - 0
src/main/java/com/husj/openai/model/OAuthStart.java

@@ -0,0 +1,14 @@
+package com.husj.openai.model;
+
+import lombok.Data;
+
+/**
+ * OAuth 流程起始参数 (对应 Python OAuthStart dataclass)
+ */
+@Data
+public class OAuthStart {
+    private final String authUrl;
+    private final String state;
+    private final String codeVerifier;
+    private final String redirectUri;
+}

+ 19 - 0
src/main/java/com/husj/openai/model/RegisterResult.java

@@ -0,0 +1,19 @@
+package com.husj.openai.model;
+
+import lombok.Data;
+import lombok.AllArgsConstructor;
+import lombok.NoArgsConstructor;
+
+/**
+ * 单次注册成功结果
+ */
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class RegisterResult {
+    private String email;
+    private String password;
+    private String tokenJson;
+    private String tokenFile;
+    private boolean uploadedToCpa;
+}

+ 294 - 0
src/main/java/com/husj/openai/service/CpaPoolService.java

@@ -0,0 +1,294 @@
+package com.husj.openai.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.husj.openai.config.AppProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.stereotype.Service;
+
+import java.util.*;
+import java.util.concurrent.*;
+
+/**
+ * CPA 池管理服务 — 对应 Python MiniPoolMaintainer +
+ * _upload_token_to_cpa / _clean_invalid_in_cpa / _count_valid_cpa_tokens
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class CpaPoolService {
+
+    private static final String MGMT_UA = "codex_cli_rs/0.76.0 (Debian 13.0.0; x86_64) WindowsTerminal";
+    private final AppProperties props;
+    private final ObjectMapper mapper = new ObjectMapper();
+    private final OkHttpClient httpClient;
+
+    private boolean isConfigured() {
+        AppProperties.Cpa cpa = props.getCpa();
+        return cpa.getBaseUrl() != null && !cpa.getBaseUrl().isBlank()
+                && cpa.getToken() != null && !cpa.getToken().isBlank();
+    }
+
+    private String joinMgmtUrl(String path) {
+        String base = props.getCpa().getBaseUrl().replaceAll("/+$", "");
+        String suffix = path.startsWith("/") ? path : "/" + path;
+        if (base.endsWith("/v0")) return base + suffix;
+        return base + "/v0" + suffix;
+    }
+
+    private Headers mgmtHeaders() {
+        String token = props.getCpa().getToken();
+        if (!token.toLowerCase().startsWith("bearer ")) token = "Bearer " + token;
+        return new Headers.Builder()
+                .add("Authorization", token)
+                .add("Accept", "application/json")
+                .add("User-Agent", MGMT_UA)
+                .build();
+    }
+
+    /**
+     * 上传 token 文件到 CPA (对应 Python upload_token)
+     */
+    public boolean uploadToken(String filename, String tokenJson) {
+        if (!isConfigured()) {
+            log.info("[CPA] 未配置 CPA,跳过上传");
+            return false;
+        }
+        AppProperties.Cpa cpa = props.getCpa();
+        RequestBody fileBody = RequestBody.create(tokenJson.getBytes(java.nio.charset.StandardCharsets.UTF_8),
+                MediaType.parse("application/json"));
+        RequestBody multipart = new MultipartBody.Builder()
+                .setType(MultipartBody.FORM)
+                .addFormDataPart("file", filename, fileBody)
+                .build();
+
+        for (int attempt = 0; attempt < 3; attempt++) {
+            try {
+                Request req = new Request.Builder()
+                        .url(joinMgmtUrl("/management/auth-files"))
+                        .header("Authorization", "Bearer " + cpa.getToken())
+                        .post(multipart)
+                        .build();
+                try (Response resp = httpClient.newCall(req).execute()) {
+                    if (resp.code() == 200 || resp.code() == 201 || resp.code() == 204) return true;
+                }
+            } catch (Exception e) {
+                log.debug("[CPA] 上传尝试 {}/3 失败: {}", attempt + 1, e.getMessage());
+            }
+            if (attempt < 2) {
+                try { Thread.sleep(1000L * (1 << attempt)); } catch (InterruptedException ie) {
+                    Thread.currentThread().interrupt();
+                    return false;
+                }
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 获取 CPA auth-files 列表
+     */
+    public List<Map<String, Object>> fetchAuthFiles() throws Exception {
+        if (!isConfigured()) return Collections.emptyList();
+        Request req = new Request.Builder()
+                .url(joinMgmtUrl("/management/auth-files"))
+                .headers(mgmtHeaders())
+                .get()
+                .build();
+        try (Response resp = httpClient.newCall(req).execute()) {
+            resp.close();
+            if (!resp.isSuccessful() || resp.body() == null) return Collections.emptyList();
+            // re-read after close would fail, need to read before close
+        }
+        // redo with proper body read
+        try (Response resp = httpClient.newCall(new Request.Builder()
+                .url(joinMgmtUrl("/management/auth-files"))
+                .headers(mgmtHeaders()).get().build()).execute()) {
+            String body = resp.body() != null ? resp.body().string() : "{}";
+            JsonNode root = mapper.readTree(body);
+            List<Map<String, Object>> files = new ArrayList<>();
+            JsonNode arr = root.isArray() ? root : root.path("files");
+            if (arr.isArray()) {
+                for (JsonNode item : arr) {
+                    files.add(mapper.convertValue(item, Map.class));
+                }
+            }
+            return files;
+        }
+    }
+
+    /**
+     * 计算有效 codex 类型 token 数量
+     */
+    public int countValidTokens() {
+        if (!isConfigured()) return 0;
+        try {
+            List<Map<String, Object>> files = fetchAuthFiles();
+            long count = files.stream()
+                    .filter(f -> "codex".equalsIgnoreCase(getItemType(f)))
+                    .count();
+            return (int) count;
+        } catch (Exception e) {
+            log.warn("[CPA] 统计 token 失败: {}", e.getMessage());
+            return 0;
+        }
+    }
+
+    /**
+     * 探测并清理失效 token (对应 Python probe_and_clean_sync)
+     * 使用线程池模拟 Python asyncio 并发
+     */
+    public Map<String, Object> probeAndClean() {
+        AppProperties.Cpa cpa = props.getCpa();
+        int workers = Math.max(1, cpa.getWorkers());
+        int timeout = Math.max(5, cpa.getTimeout());
+        int retries = Math.max(0, cpa.getRetries());
+
+        if (!isConfigured()) {
+            return Map.of("total", 0, "candidates", 0, "invalid_count", 0, "deleted_ok", 0, "deleted_fail", 0);
+        }
+
+        List<Map<String, Object>> files;
+        try { files = fetchAuthFiles(); } catch (Exception e) {
+            log.warn("[CPA] 获取 auth-files 失败: {}", e.getMessage());
+            return Map.of("total", 0, "candidates", 0, "invalid_count", 0, "deleted_ok", 0, "deleted_fail", 0);
+        }
+
+        List<Map<String, Object>> candidates = files.stream()
+                .filter(f -> "codex".equalsIgnoreCase(getItemType(f)))
+                .toList();
+
+        if (candidates.isEmpty()) {
+            return Map.of("total", files.size(), "candidates", 0, "invalid_count", 0, "deleted_ok", 0, "deleted_fail", 0);
+        }
+
+        ExecutorService pool = Executors.newFixedThreadPool(workers);
+        List<Future<Map<String, Object>>> futures = new ArrayList<>();
+        for (Map<String, Object> item : candidates) {
+            futures.add(pool.submit(() -> probeOne(item, timeout, retries)));
+        }
+
+        List<Map<String, Object>> invalidList = new ArrayList<>();
+        for (Future<Map<String, Object>> f : futures) {
+            try {
+                Map<String, Object> r = f.get(timeout * 2L, TimeUnit.SECONDS);
+                if (Boolean.TRUE.equals(r.get("invalid_401")) || Boolean.TRUE.equals(r.get("invalid_used_percent"))) {
+                    invalidList.add(r);
+                }
+            } catch (Exception ignored) {}
+        }
+        pool.shutdown();
+
+        int deletedOk = 0, deletedFail = 0;
+        for (Map<String, Object> inv : invalidList) {
+            String name = String.valueOf(inv.getOrDefault("name", ""));
+            if (!name.isBlank()) {
+                if (deleteOne(name, timeout)) deletedOk++;
+                else deletedFail++;
+            }
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", files.size());
+        result.put("candidates", candidates.size());
+        result.put("invalid_count", invalidList.size());
+        result.put("deleted_ok", deletedOk);
+        result.put("deleted_fail", deletedFail);
+        return result;
+    }
+
+    private Map<String, Object> probeOne(Map<String, Object> item, int timeout, int retries) {
+        Map<String, Object> res = new LinkedHashMap<>();
+        String name = String.valueOf(item.getOrDefault("name", item.getOrDefault("id", "")));
+        Object authIndex = item.get("auth_index");
+        res.put("name", name);
+        res.put("auth_index", authIndex);
+        res.put("invalid_401", false);
+        res.put("invalid_used_percent", false);
+        res.put("used_percent", null);
+
+        if (authIndex == null || String.valueOf(authIndex).isBlank()) return res;
+
+        String accountId = extractAccountId(item);
+        Map<String, String> header = new LinkedHashMap<>();
+        header.put("Authorization", "Bearer $TOKEN$");
+        header.put("Content-Type", "application/json");
+        header.put("User-Agent", MGMT_UA);
+        if (accountId != null) header.put("Chatgpt-Account-Id", accountId);
+
+        Map<String, Object> payload = new LinkedHashMap<>();
+        payload.put("authIndex", authIndex);
+        payload.put("method", "GET");
+        payload.put("url", "https://chatgpt.com/backend-api/wham/usage");
+        payload.put("header", header);
+
+        for (int attempt = 0; attempt <= retries; attempt++) {
+            try {
+                String payloadJson = mapper.writeValueAsString(payload);
+                Request req = new Request.Builder()
+                        .url(joinMgmtUrl("/management/api-call"))
+                        .headers(mgmtHeaders())
+                        .header("Content-Type", "application/json")
+                        .post(RequestBody.create(payloadJson, MediaType.parse("application/json")))
+                        .build();
+                try (Response resp = httpClient.newCall(req).execute()) {
+                    String bodyText = resp.body() != null ? resp.body().string() : "{}";
+                    if (!resp.isSuccessful()) {
+                        throw new RuntimeException("HTTP " + resp.code() + ": " + bodyText);
+                    }
+                    JsonNode data = mapper.readTree(bodyText);
+                    int sc = data.path("status_code").asInt(0);
+                    res.put("invalid_401", sc == 401);
+                    if (sc == 200) {
+                        JsonNode usagePct = data.path("body");
+                        JsonNode body = mapper.readTree(usagePct.asText("{}"));
+                        JsonNode used = body.path("rate_limit").path("primary_window").path("used_percent");
+                        if (!used.isMissingNode()) {
+                            double pct = used.asDouble();
+                            res.put("used_percent", pct);
+                            res.put("invalid_used_percent", pct >= props.getCpa().getUsedThreshold());
+                        }
+                    }
+                    return res;
+                }
+            } catch (Exception e) {
+                if (attempt >= retries) res.put("error", e.getMessage());
+            }
+        }
+        return res;
+    }
+
+    private boolean deleteOne(String name, int timeout) {
+        try {
+            String encoded = java.net.URLEncoder.encode(name, java.nio.charset.StandardCharsets.UTF_8);
+            Request req = new Request.Builder()
+                    .url(joinMgmtUrl("/management/auth-files") + "?name=" + encoded)
+                    .headers(mgmtHeaders())
+                    .delete()
+                    .build();
+            try (Response resp = httpClient.newCall(req).execute()) {
+                String body = resp.body() != null ? resp.body().string() : "{}";
+                JsonNode data = mapper.readTree(body);
+                return resp.code() == 200 && "ok".equals(data.path("status").asText());
+            }
+        } catch (Exception e) {
+            return false;
+        }
+    }
+
+    private String getItemType(Map<String, Object> item) {
+        Object t = item.get("type");
+        if (t == null) t = item.get("typo");
+        return t == null ? "" : String.valueOf(t);
+    }
+
+    private String extractAccountId(Map<String, Object> item) {
+        for (String key : new String[]{"chatgpt_account_id", "chatgptAccountId", "account_id", "accountId"}) {
+            Object v = item.get(key);
+            if (v != null && !String.valueOf(v).isBlank()) return String.valueOf(v);
+        }
+        return null;
+    }
+}

+ 135 - 0
src/main/java/com/husj/openai/service/OAuthService.java

@@ -0,0 +1,135 @@
+package com.husj.openai.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.husj.openai.model.OAuthStart;
+import com.husj.openai.util.PkceUtil;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.stereotype.Service;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.*;
+
+/**
+ * OAuth URL 生成 与 Token 换取 — 对应 Python generate_oauth_url() + submit_callback_url()
+ */
+@Slf4j
+@Service
+public class OAuthService {
+
+    public static final String AUTH_URL = "https://auth.openai.com/oauth/authorize";
+    public static final String TOKEN_URL = "https://auth.openai.com/oauth/token";
+    public static final String CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
+    public static final String DEFAULT_REDIRECT_URI = "http://localhost:1455/auth/callback";
+    public static final String DEFAULT_SCOPE = "openid email profile offline_access";
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * 生成 OAuth 授权 URL (含 PKCE)
+     */
+    public OAuthStart generateOAuthUrl(String redirectUri) {
+        if (redirectUri == null || redirectUri.isBlank()) redirectUri = DEFAULT_REDIRECT_URI;
+        String state = PkceUtil.randomState();
+        String verifier = PkceUtil.pkceVerifier();
+        String challenge = PkceUtil.sha256B64urlNoPad(verifier);
+
+        Map<String, String> params = new LinkedHashMap<>();
+        params.put("client_id", CLIENT_ID);
+        params.put("response_type", "code");
+        params.put("redirect_uri", redirectUri);
+        params.put("scope", DEFAULT_SCOPE);
+        params.put("state", state);
+        params.put("code_challenge", challenge);
+        params.put("code_challenge_method", "S256");
+        params.put("prompt", "login");
+        params.put("id_token_add_organizations", "true");
+        params.put("codex_cli_simplified_flow", "true");
+
+        StringBuilder query = new StringBuilder();
+        for (Map.Entry<String, String> e : params.entrySet()) {
+            if (!query.isEmpty()) query.append("&");
+            query.append(URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
+                    .append("=")
+                    .append(URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
+        }
+        String authUrl = AUTH_URL + "?" + query;
+        return new OAuthStart(authUrl, state, verifier, redirectUri);
+    }
+
+    /**
+     * 提交 callback URL 换取 token (对应 Python submit_callback_url)
+     */
+    public String submitCallbackUrl(OkHttpClient client, String callbackUrl, String expectedState,
+                                     String codeVerifier, String redirectUri) throws Exception {
+        Map<String, String> cb = PkceUtil.parseCallbackUrl(callbackUrl);
+        String error = cb.get("error");
+        if (error != null && !error.isBlank()) {
+            throw new RuntimeException("OAuth 错误: " + error + ": " + cb.get("error_description"));
+        }
+        String code = cb.get("code");
+        if (code == null || code.isBlank()) throw new IllegalArgumentException("Callback URL 缺少 ?code=");
+        if (cb.get("state") == null || cb.get("state").isBlank()) throw new IllegalArgumentException("Callback URL 缺少 ?state=");
+        if (!cb.get("state").equals(expectedState)) throw new IllegalArgumentException("State 校验不匹配");
+
+        // Exchange code for token
+        FormBody.Builder fb = new FormBody.Builder()
+                .add("grant_type", "authorization_code")
+                .add("client_id", CLIENT_ID)
+                .add("code", code)
+                .add("redirect_uri", redirectUri)
+                .add("code_verifier", codeVerifier);
+
+        Request req = new Request.Builder()
+                .url(TOKEN_URL)
+                .header("Content-Type", "application/x-www-form-urlencoded")
+                .header("Accept", "application/json")
+                .post(fb.build())
+                .build();
+
+        try (Response resp = client.newCall(req).execute()) {
+            if (resp.code() != 200) {
+                String bodyText = resp.body() != null ? resp.body().string() : "";
+                throw new RuntimeException("Token 交换失败: " + resp.code() + ": " + bodyText);
+            }
+            String bodyText = resp.body() != null ? resp.body().string() : "{}";
+            @SuppressWarnings("unchecked")
+            Map<String, Object> tokenResp = mapper.readValue(bodyText, Map.class);
+
+            String accessToken = String.valueOf(tokenResp.getOrDefault("access_token", "")).strip();
+            String refreshToken = String.valueOf(tokenResp.getOrDefault("refresh_token", "")).strip();
+            String idToken = String.valueOf(tokenResp.getOrDefault("id_token", "")).strip();
+            int expiresIn = 0;
+            Object expObj = tokenResp.get("expires_in");
+            if (expObj != null) {
+                try { expiresIn = Integer.parseInt(expObj.toString()); } catch (Exception ignored) {}
+            }
+
+            Map<String, Object> claims = PkceUtil.jwtClaimsNoVerify(idToken);
+            @SuppressWarnings("unchecked")
+            Map<String, Object> authClaims = (Map<String, Object>) claims.getOrDefault("https://api.openai.com/auth", Map.of());
+
+            long now = Instant.now().getEpochSecond();
+            Map<String, Object> config = new LinkedHashMap<>();
+            config.put("id_token", idToken);
+            config.put("access_token", accessToken);
+            config.put("refresh_token", refreshToken);
+            config.put("account_id", String.valueOf(authClaims.getOrDefault("chatgpt_account_id", "")).strip());
+            config.put("last_refresh", formatUtc(now));
+            config.put("email", String.valueOf(claims.getOrDefault("email", "")).strip());
+            config.put("type", "codex");
+            config.put("expired", formatUtc(now + Math.max(expiresIn, 0)));
+
+            return mapper.writerWithDefaultPrettyPrinter().writeValueAsString(config);
+        }
+    }
+
+    private String formatUtc(long epochSec) {
+        return java.time.Instant.ofEpochSecond(epochSec)
+                .toString()
+                .replace(".000Z", "Z")
+                .replaceFirst("\\.\\d+Z$", "Z");
+    }
+}

+ 476 - 0
src/main/java/com/husj/openai/service/OpenAiRegisterService.java

@@ -0,0 +1,476 @@
+package com.husj.openai.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.husj.openai.config.AppProperties;
+import com.husj.openai.mail.EmailBundle;
+import com.husj.openai.mail.EmailProviderFactory;
+import com.husj.openai.model.OAuthStart;
+import com.husj.openai.model.RegisterResult;
+import com.husj.openai.util.PkceUtil;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.stereotype.Service;
+
+import java.net.CookieManager;
+import java.net.CookiePolicy;
+import java.net.InetSocketAddress;
+import java.net.Proxy;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * OpenAI 主注册流程 — 完整复刻 Python run() 函数的 10 步注册 + 登录补全
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class OpenAiRegisterService {
+
+    private static final String UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36";
+
+    private final AppProperties props;
+    private final EmailProviderFactory emailFactory;
+    private final SentinelService sentinelService;
+    private final OAuthService oauthService;
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * 执行一次完整的注册流程
+     */
+    public Optional<RegisterResult> run() {
+        String proxy = props.getProxy();
+        String mailProvider = props.getMailProvider();
+
+        log.info("\n{} 开启注册流程 {}", "=".repeat(20), "=".repeat(20));
+
+        OkHttpClient s = buildClient(proxy);
+        try {
+            // ===== 步骤1: 获取临时邮箱 =====
+            log.info("[步骤1] 正在初始化临时邮箱(provider={})...", mailProvider);
+            EmailBundle mailBundle = emailFactory.build(proxy, mailProvider);
+            String email = mailBundle.getEmail();
+            String password = mailBundle.getPassword();
+            log.info("[*] 当前邮箱提供商: {}", mailBundle.getProvider().name().toLowerCase());
+            log.info("[成功] 邮箱: {} | 临时密码: {}", email, password);
+
+            // ===== 步骤2: 访问 OpenAI 授权页获取 Device ID =====
+            log.info("[步骤2] 访问 OpenAI 授权页获取 Device ID...");
+            OAuthStart oauth = oauthService.generateOAuthUrl(OAuthService.DEFAULT_REDIRECT_URI);
+            Response authPageResp = s.newCall(new Request.Builder().url(oauth.getAuthUrl())
+                    .header("user-agent", UA).header("accept", "application/json, text/plain, */*").build()).execute();
+            authPageResp.close();
+
+            String did = getCookieValue(s, "oai-did");
+            if (did == null || did.isBlank()) {
+                log.warn("[失败] 未能从 Cookie 获取 oai-did");
+                return Optional.empty();
+            }
+            log.info("[成功] Device ID: {}", did);
+
+            // ===== 步骤3: Sentinel + 提交注册邮箱 =====
+            log.info("[步骤3] 获取 Sentinel 载荷并提交注册邮箱...");
+            String authorizeContinueSentinel;
+            try {
+                authorizeContinueSentinel = sentinelService.buildSentinelPayload(s, did, "authorize_continue");
+            } catch (Exception e) {
+                log.warn("[失败] 获取 authorize_continue Sentinel 失败: {}", e.getMessage());
+                return Optional.empty();
+            }
+
+            Map<String, Object> usernamePayload = new LinkedHashMap<>();
+            usernamePayload.put("username", Map.of("value", email, "kind", "email"));
+            usernamePayload.put("screen_hint", "signup");
+
+            Response signupRes = s.newCall(new Request.Builder()
+                    .url("https://auth.openai.com/api/accounts/authorize/continue")
+                    .header("referer", "https://auth.openai.com/create-account")
+                    .header("accept", "application/json")
+                    .header("content-type", "application/json")
+                    .header("openai-sentinel-token", authorizeContinueSentinel)
+                    .post(RequestBody.create(mapper.writeValueAsString(usernamePayload), MediaType.parse("application/json")))
+                    .build()).execute();
+            log.info("[日志] 邮箱提交状态: {}", signupRes.code());
+            if (signupRes.code() != 200) {
+                String body = signupRes.body() != null ? signupRes.body().string() : "";
+                log.warn("[失败] 邮箱提交失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                signupRes.close();
+                return Optional.empty();
+            }
+            signupRes.close();
+
+            // ===== 步骤4: 设置账户密码 =====
+            log.info("[步骤4] 设置账户密码...");
+            Map<String, String> pwdPayload = Map.of("password", password, "username", email);
+            Response pwdRes = s.newCall(new Request.Builder()
+                    .url("https://auth.openai.com/api/accounts/user/register")
+                    .header("referer", "https://auth.openai.com/create-account/password")
+                    .header("accept", "application/json")
+                    .header("content-type", "application/json")
+                    .post(RequestBody.create(mapper.writeValueAsString(pwdPayload), MediaType.parse("application/json")))
+                    .build()).execute();
+            log.info("[日志] 密码设置状态: {}", pwdRes.code());
+            if (pwdRes.code() != 200) {
+                String body = pwdRes.body() != null ? pwdRes.body().string() : "";
+                log.warn("[失败] 密码设置失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                pwdRes.close();
+                return Optional.empty();
+            }
+            pwdRes.close();
+
+            // ===== 步骤5: 触发发送验证邮件 =====
+            log.info("[步骤5] 触发 OpenAI 发送验证邮件...");
+            s.newCall(new Request.Builder().url("https://auth.openai.com/create-account/password")
+                    .header("user-agent", UA).get().build()).execute().close();
+            Response otpSendRes = s.newCall(new Request.Builder()
+                    .url("https://auth.openai.com/api/accounts/email-otp/send")
+                    .header("referer", "https://auth.openai.com/create-account/password")
+                    .header("accept", "application/json")
+                    .get().build()).execute();
+            log.info("[日志] 发送指令状态: {}", otpSendRes.code());
+            if (otpSendRes.code() != 200) {
+                String body = otpSendRes.body() != null ? otpSendRes.body().string() : "";
+                log.warn("[失败] 发送验证码失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                otpSendRes.close();
+                return Optional.empty();
+            }
+            otpSendRes.close();
+
+            // ===== 步骤6: 等待邮箱接收 6 位验证码 =====
+            log.info("[步骤6] 等待邮箱接收 6 位验证码...");
+            String code = mailBundle.fetchCode(180, 6000, Collections.emptySet());
+            if (code == null) {
+                log.warn("[失败] 邮箱长时间未收到验证码");
+                return Optional.empty();
+            }
+            log.info("[成功] 捕获验证码: {}", code);
+
+            // ===== 步骤7: 提交验证码 =====
+            log.info("[步骤7] 提交验证码至 OpenAI...");
+            Response valRes = s.newCall(new Request.Builder()
+                    .url("https://auth.openai.com/api/accounts/email-otp/validate")
+                    .header("referer", "https://auth.openai.com/email-verification")
+                    .header("accept", "application/json")
+                    .header("content-type", "application/json")
+                    .post(RequestBody.create(mapper.writeValueAsString(Map.of("code", code)), MediaType.parse("application/json")))
+                    .build()).execute();
+            log.info("[日志] 验证码校验状态: {}", valRes.code());
+            if (valRes.code() != 200) {
+                String body = valRes.body() != null ? valRes.body().string() : "";
+                log.warn("[失败] 验证码校验失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                valRes.close();
+                return Optional.empty();
+            }
+            valRes.close();
+
+            // ===== 步骤8: 完善账户信息 =====
+            log.info("[步骤8] 完善账户基本信息...");
+            String createAccountSentinel;
+            try {
+                createAccountSentinel = sentinelService.buildSentinelPayload(s, did, "authorize_continue");
+            } catch (Exception e) {
+                log.warn("[失败] 获取 create_account Sentinel 失败: {}", e.getMessage());
+                return Optional.empty();
+            }
+
+            Map<String, String> accPayload = Map.of("name", PkceUtil.randomName(), "birthdate", PkceUtil.randomBirthdate());
+            Response accRes = s.newCall(new Request.Builder()
+                    .url("https://auth.openai.com/api/accounts/create_account")
+                    .header("referer", "https://auth.openai.com/about-you")
+                    .header("accept", "application/json")
+                    .header("content-type", "application/json")
+                    .header("openai-sentinel-token", createAccountSentinel)
+                    .post(RequestBody.create(mapper.writeValueAsString(accPayload), MediaType.parse("application/json")))
+                    .build()).execute();
+            log.info("[日志] 账户创建状态: {}", accRes.code());
+            if (accRes.code() != 200) {
+                String body = accRes.body() != null ? accRes.body().string() : "";
+                log.warn("[失败] 账户创建失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                accRes.close();
+                return Optional.empty();
+            }
+            accRes.close();
+
+            // ===== 步骤9: 重新走登录流程获取 Token =====
+            log.info("[步骤9] 注册完成,重新走登录流程获取 Workspace / Token...");
+            String firstCode = code;
+            for (int loginAttempt = 0; loginAttempt < 3; loginAttempt++) {
+                try {
+                    log.info("[*] 正在通过登录流程获取 Token...{}", loginAttempt > 0 ? " (重试 " + loginAttempt + "/3)" : "");
+                    OkHttpClient s2 = buildClient(proxy);
+                    OAuthStart oauth2 = oauthService.generateOAuthUrl(OAuthService.DEFAULT_REDIRECT_URI);
+                    s2.newCall(new Request.Builder().url(oauth2.getAuthUrl()).header("user-agent", UA).build()).execute().close();
+
+                    String did2 = getCookieValue(s2, "oai-did");
+                    if (did2 == null || did2.isBlank()) {
+                        log.warn("[失败] 登录会话未能获取 oai-did");
+                        continue;
+                    }
+
+                    // 提交登录邮箱
+                    Map<String, Object> loginUsernamePayload = new LinkedHashMap<>();
+                    loginUsernamePayload.put("username", Map.of("value", email, "kind", "email"));
+                    loginUsernamePayload.put("screen_hint", "login");
+
+                    Response lc = s2.newCall(new Request.Builder()
+                            .url("https://auth.openai.com/api/accounts/authorize/continue")
+                            .header("referer", "https://auth.openai.com/log-in")
+                            .header("accept", "application/json")
+                            .header("content-type", "application/json")
+                            .header("openai-sentinel-token", sentinelService.buildSentinelPayload(s2, did2, "authorize_continue"))
+                            .post(RequestBody.create(mapper.writeValueAsString(loginUsernamePayload), MediaType.parse("application/json")))
+                            .build()).execute();
+                    log.info("[日志] 登录邮箱提交状态: {}", lc.code());
+                    if (lc.code() != 200) {
+                        String body = lc.body() != null ? lc.body().string() : "";
+                        log.warn("[失败] 登录邮箱提交失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                        lc.close();
+                        continue;
+                    }
+                    String lcBody = lc.body() != null ? lc.body().string() : "{}";
+                    lc.close();
+                    @SuppressWarnings("unchecked")
+                    Map<String, Object> lcData = mapper.readValue(lcBody, Map.class);
+                    String continueUrl = String.valueOf(lcData.getOrDefault("continue_url", "")).strip();
+                    if (!continueUrl.isBlank()) {
+                        s2.newCall(new Request.Builder().url(continueUrl).header("user-agent", UA).get().build()).execute().close();
+                    }
+
+                    // 提交密码
+                    Response pw = s2.newCall(new Request.Builder()
+                            .url("https://auth.openai.com/api/accounts/password/verify")
+                            .header("referer", "https://auth.openai.com/log-in/password")
+                            .header("accept", "application/json")
+                            .header("content-type", "application/json")
+                            .header("openai-sentinel-token", sentinelService.buildSentinelPayload(s2, did2, "authorize_continue"))
+                            .post(RequestBody.create(mapper.writeValueAsString(Map.of("password", password)), MediaType.parse("application/json")))
+                            .build()).execute();
+                    log.info("[日志] 登录密码验证状态: {}", pw.code());
+                    if (pw.code() != 200) {
+                        String body = pw.body() != null ? pw.body().string() : "";
+                        log.warn("[失败] 登录密码验证失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                        pw.close();
+                        continue;
+                    }
+                    pw.close();
+
+                    // 等待登录 OTP
+                    List<String> existingCodes = mailBundle.extractAllCodes();
+                    s2.newCall(new Request.Builder()
+                            .url("https://auth.openai.com/email-verification")
+                            .header("referer", "https://auth.openai.com/log-in/password")
+                            .get().build()).execute().close();
+                    log.info("[*] 正在等待登录 OTP...");
+                    Thread.sleep(2000);
+
+                    Set<String> baselineCodes = new HashSet<>(existingCodes);
+                    baselineCodes.add(firstCode);
+                    String otp2 = mailBundle.fetchCode(180, 2000, baselineCodes);
+                    if (otp2 == null) {
+                        log.warn("[失败] 未收到登录 OTP");
+                        continue;
+                    }
+                    log.info("[成功] 捕获登录 OTP: {}", otp2);
+
+                    // 提交登录 OTP
+                    Response val2 = s2.newCall(new Request.Builder()
+                            .url("https://auth.openai.com/api/accounts/email-otp/validate")
+                            .header("referer", "https://auth.openai.com/email-verification")
+                            .header("accept", "application/json")
+                            .header("content-type", "application/json")
+                            .post(RequestBody.create(mapper.writeValueAsString(Map.of("code", otp2)), MediaType.parse("application/json")))
+                            .build()).execute();
+                    log.info("[日志] 登录 OTP 校验状态: {}", val2.code());
+                    if (val2.code() != 200) {
+                        String body = val2.body() != null ? val2.body().string() : "";
+                        log.warn("[失败] 登录 OTP 校验失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                        val2.close();
+                        continue;
+                    }
+                    String val2Body = val2.body() != null ? val2.body().string() : "{}";
+                    val2.close();
+                    @SuppressWarnings("unchecked")
+                    Map<String, Object> val2Data = mapper.readValue(val2Body, Map.class);
+                    log.info("[成功] 登录 OTP 验证成功");
+
+                    String consentUrl = String.valueOf(val2Data.getOrDefault("continue_url", "")).strip();
+                    if (!consentUrl.isBlank()) {
+                        s2.newCall(new Request.Builder().url(consentUrl).header("user-agent", UA).get().build()).execute().close();
+                    }
+
+                    // 获取 auth cookie 中的 workspace
+                    String authCookie = getCookieValue(s2, "oai-client-auth-session");
+                    if (authCookie == null || authCookie.isBlank()) {
+                        log.warn("[失败] 登录后未能获取 oai-client-auth-session");
+                        continue;
+                    }
+                    Map<String, Object> authJson = PkceUtil.jwtHeaderNoVerify(authCookie);
+                    if (authJson.isEmpty()) {
+                        // try claims
+                        authJson = PkceUtil.jwtClaimsNoVerify(authCookie);
+                    }
+
+                    Object workspacesObj = authJson.get("workspaces");
+                    if (!(workspacesObj instanceof List<?> workspaces) || workspaces.isEmpty()) {
+                        log.warn("[失败] Cookie 中无 workspaces: {}", authJson.keySet());
+                        continue;
+                    }
+                    Map<?, ?> ws0 = (Map<?, ?>) workspaces.get(0);
+                    String workspaceId = String.valueOf(ws0.get("id"));
+                    log.info("[成功] Workspace ID: {}", workspaceId);
+
+                    // 选择 Workspace
+                    Response selResp = s2.newCall(new Request.Builder()
+                            .url("https://auth.openai.com/api/accounts/workspace/select")
+                            .header("referer", consentUrl)
+                            .header("accept", "application/json")
+                            .header("content-type", "application/json")
+                            .post(RequestBody.create(mapper.writeValueAsString(Map.of("workspace_id", workspaceId)), MediaType.parse("application/json")))
+                            .build()).execute();
+                    log.info("[日志] Workspace 选择状态: {}", selResp.code());
+                    if (selResp.code() != 200) {
+                        String body = selResp.body() != null ? selResp.body().string() : "";
+                        log.warn("[失败] Workspace 选择失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                        selResp.close();
+                        continue;
+                    }
+                    String selBody = selResp.body() != null ? selResp.body().string() : "{}";
+                    selResp.close();
+                    @SuppressWarnings("unchecked")
+                    Map<String, Object> selData = mapper.readValue(selBody, Map.class);
+
+                    // 处理 organization_select 分支
+                    if ("organization_select".equals(getNestedStr(selData, "page", "type"))) {
+                        List<?> orgs = getNestedList(selData, "page", "payload", "data", "orgs");
+                        if (orgs != null && !orgs.isEmpty()) {
+                            @SuppressWarnings("unchecked")
+                            Map<String, Object> org0 = (Map<String, Object>) orgs.get(0);
+                            Map<String, String> orgPayload = new LinkedHashMap<>();
+                            orgPayload.put("org_id", String.valueOf(org0.getOrDefault("id", "")));
+                            orgPayload.put("project_id", String.valueOf(org0.getOrDefault("default_project_id", "")));
+                            Response orgSel = s2.newCall(new Request.Builder()
+                                    .url("https://auth.openai.com/api/accounts/organization/select")
+                                    .header("accept", "application/json")
+                                    .header("content-type", "application/json")
+                                    .post(RequestBody.create(mapper.writeValueAsString(orgPayload), MediaType.parse("application/json")))
+                                    .build()).execute();
+                            log.info("[日志] Organization 选择状态: {}", orgSel.code());
+                            if (orgSel.code() != 200) {
+                                String body = orgSel.body() != null ? orgSel.body().string() : "";
+                                log.warn("[失败] Organization 选择失败: {}", body.length() > 200 ? body.substring(0, 200) : body);
+                                orgSel.close();
+                                continue;
+                            }
+                            @SuppressWarnings("unchecked")
+                            Map<String, Object> orgData = mapper.readValue(orgSel.body().string(), Map.class);
+                            selData = orgData;
+                            orgSel.close();
+                        }
+                    }
+
+                    if (!selData.containsKey("continue_url")) {
+                        log.warn("[失败] 未能获取 continue_url: {}", mapper.writeValueAsString(selData));
+                        continue;
+                    }
+
+                    // ===== 步骤10: 跟踪重定向并换取 Token =====
+                    log.info("[步骤10] 跟踪重定向并换取 Token...");
+                    // Operate with followRedirects=false for manual tracking
+                    OkHttpClient s2NoRedir = s2.newBuilder().followRedirects(false).followSslRedirects(false).build();
+                    Response r = s2NoRedir.newCall(new Request.Builder()
+                            .url(String.valueOf(selData.get("continue_url")))
+                            .header("user-agent", UA).get().build()).execute();
+                    String cbk = null;
+                    for (int i = 0; i < 20; i++) {
+                        String loc = r.header("Location", "");
+                        log.info("  -> 重定向 #{} 状态: {} | 下一跳: {}", i + 1, r.code(),
+                                loc != null && loc.length() > 80 ? loc.substring(0, 80) : loc);
+                        r.close();
+                        if (loc != null && loc.startsWith("http://localhost")) {
+                            cbk = loc;
+                            break;
+                        }
+                        if ((r.code() < 301 || r.code() > 303) || loc == null || loc.isBlank()) break;
+                        r = s2NoRedir.newCall(new Request.Builder().url(loc).header("user-agent", UA).get().build()).execute();
+                    }
+
+                    if (cbk == null) {
+                        log.warn("[失败] 未能获取到 Callback URL");
+                        continue;
+                    }
+
+                    String tokenJson = oauthService.submitCallbackUrl(s2, cbk, oauth2.getState(), oauth2.getCodeVerifier(), oauth2.getRedirectUri());
+                    log.info("[大功告成] 账号注册完毕!");
+
+                    RegisterResult result = new RegisterResult();
+                    result.setEmail(email);
+                    result.setPassword(password);
+                    result.setTokenJson(tokenJson);
+                    return Optional.of(result);
+
+                } catch (Exception e) {
+                    log.warn("[失败] 登录补全流程异常: {}", e.getMessage());
+                    Thread.sleep(2000);
+                }
+            }
+            log.warn("[失败] 登录补全流程 3 次均未完成。");
+            return Optional.empty();
+
+        } catch (Exception e) {
+            log.error("[致命错误] 流程崩溃: {}", e.getMessage(), e);
+            return Optional.empty();
+        }
+    }
+
+    // ===== helper methods =====
+
+    private OkHttpClient buildClient(String proxy) {
+        CookieManager cm = new CookieManager(null, CookiePolicy.ACCEPT_ALL);
+        OkHttpClient.Builder b = new OkHttpClient.Builder()
+                .cookieJar(new JavaNetCookieJar(cm))
+                .connectTimeout(15, TimeUnit.SECONDS)
+                .readTimeout(30, TimeUnit.SECONDS)
+                .followRedirects(true)
+                .followSslRedirects(true);
+        if (proxy != null && !proxy.isBlank()) {
+            String noProto = proxy.replaceFirst("^https?://", "");
+            String[] parts = noProto.split(":");
+            if (parts.length == 2) {
+                b.proxy(new Proxy(Proxy.Type.HTTP, new InetSocketAddress(parts[0], Integer.parseInt(parts[1]))));
+            }
+        }
+        return b.build();
+    }
+
+    private String getCookieValue(OkHttpClient client, String name) {
+        // OkHttp's CookieJar is backed by JavaNetCookieJar/CookieManager — inspect via cookie jar
+        // We iterate all URLs we know cookies might be on
+        for (String domain : List.of("https://auth.openai.com", "https://openai.com")) {
+            List<Cookie> cookies = client.cookieJar().loadForRequest(HttpUrl.parse(domain));
+            for (Cookie c : cookies) {
+                if (c.name().equals(name)) return c.value();
+            }
+        }
+        return null;
+    }
+
+    @SuppressWarnings("unchecked")
+    private String getNestedStr(Map<String, Object> map, String... keys) {
+        Object current = map;
+        for (String key : keys) {
+            if (!(current instanceof Map)) return "";
+            current = ((Map<String, Object>) current).get(key);
+        }
+        return current == null ? "" : String.valueOf(current);
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<?> getNestedList(Map<String, Object> map, String... keys) {
+        Object current = map;
+        for (String key : keys) {
+            if (!(current instanceof Map)) return null;
+            current = ((Map<String, Object>) current).get(key);
+        }
+        return current instanceof List ? (List<?>) current : null;
+    }
+}

+ 56 - 0
src/main/java/com/husj/openai/service/SentinelService.java

@@ -0,0 +1,56 @@
+package com.husj.openai.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import okhttp3.*;
+import org.springframework.stereotype.Service;
+
+/**
+ * Sentinel token 获取服务 — 对应 Python _build_sentinel_payload() / fetch_sentinel_token()
+ */
+@Slf4j
+@Service
+public class SentinelService {
+
+    private static final String SENTINEL_URL = "https://sentinel.openai.com/backend-api/sentinel/req";
+    private static final String SENTINEL_ORIGIN = "https://sentinel.openai.com";
+    private static final String SENTINEL_REFERER = "https://sentinel.openai.com/backend-api/sentinel/frame.html?sv=20260219f9f6";
+
+    private final ObjectMapper mapper = new ObjectMapper();
+
+    /**
+     * 构建 Sentinel JSON payload, 返回完整 payload 字符串
+     * @param client   已有会话的 OkHttpClient
+     * @param did      device id (oai-did cookie 值)
+     * @param flow     流程名 ("authorize_continue" 等)
+     */
+    public String buildSentinelPayload(OkHttpClient client, String did, String flow) throws Exception {
+        String reqBody = mapper.writeValueAsString(java.util.Map.of("p", "", "id", did, "flow", flow));
+        RequestBody body = RequestBody.create(reqBody, MediaType.parse("text/plain;charset=UTF-8"));
+        Request req = new Request.Builder()
+                .url(SENTINEL_URL)
+                .header("origin", SENTINEL_ORIGIN)
+                .header("referer", SENTINEL_REFERER)
+                .header("content-type", "text/plain;charset=UTF-8")
+                .post(body)
+                .build();
+        try (Response resp = client.newCall(req).execute()) {
+            if (resp.code() != 200) {
+                String bodyText = resp.body() != null ? resp.body().string() : "";
+                throw new RuntimeException("Sentinel 验证失败: " + resp.code() + ": " + bodyText);
+            }
+            String responseText = resp.body() != null ? resp.body().string() : "{}";
+            JsonNode root = mapper.readTree(responseText);
+            String token = root.path("token").asText("");
+            // return the full payload JSON matching Python output
+            java.util.Map<String, Object> payload = new java.util.LinkedHashMap<>();
+            payload.put("p", "");
+            payload.put("t", "");
+            payload.put("c", token);
+            payload.put("id", did);
+            payload.put("flow", flow);
+            return mapper.writeValueAsString(payload);
+        }
+    }
+}

+ 102 - 0
src/main/java/com/husj/openai/service/TokenStorageService.java

@@ -0,0 +1,102 @@
+package com.husj.openai.service;
+
+import com.husj.openai.config.AppProperties;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.time.Instant;
+
+/**
+ * 本地 Token 文件存储 — 对应 Python 的 tokens/ 目录写入 + _remove_account_entry
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class TokenStorageService {
+
+    private final AppProperties props;
+
+    public Path getTokensDir() {
+        String outputDir = props.getOutputDir();
+        Path dir = (outputDir != null && !outputDir.isBlank())
+                ? Paths.get(outputDir)
+                : Paths.get(System.getProperty("user.dir"), "tokens");
+        try { Files.createDirectories(dir); } catch (IOException ignored) {}
+        return dir;
+    }
+
+    /**
+     * 追加账号密码到 accounts.txt (格式: email----password)
+     */
+    public void saveAccount(String email, String password) {
+        Path file = getTokensDir().resolve("accounts.txt");
+        try {
+            Files.writeString(file, email + "----" + password + System.lineSeparator(),
+                    StandardOpenOption.CREATE, StandardOpenOption.APPEND);
+        } catch (IOException e) {
+            log.error("[存储] 写入 accounts.txt 失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 保存 token JSON 文件, 返回文件名
+     */
+    public String saveTokenJson(String email, String tokenJson) {
+        String fnEmail = email.replace("@", "_");
+        String filename = "token_" + fnEmail + "_" + Instant.now().getEpochSecond() + ".json";
+        Path file = getTokensDir().resolve(filename);
+        try {
+            Files.writeString(file, tokenJson, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+            log.info("[*] Token 文件已保存: {}", filename);
+        } catch (IOException e) {
+            log.error("[存储] 写入 token 文件失败: {}", e.getMessage());
+        }
+        return filename;
+    }
+
+    /**
+     * 删除 token 文件
+     */
+    public void deleteTokenFile(String filename) {
+        try {
+            Path file = getTokensDir().resolve(filename);
+            if (Files.exists(file)) {
+                Files.delete(file);
+                log.info("[本地清理] 已删除 token 文件: {}", filename);
+            }
+        } catch (IOException e) {
+            log.warn("[本地清理] 删除 token 文件失败: {}", e.getMessage());
+        }
+    }
+
+    /**
+     * 从 accounts.txt 移除指定账号行 — 对应 Python _remove_account_entry
+     */
+    public void removeAccountEntry(String email, String password) {
+        Path file = getTokensDir().resolve("accounts.txt");
+        if (!Files.exists(file)) return;
+        try {
+            String target = email + "----" + password;
+            String content = Files.readString(file);
+            String[] lines = content.split("\\r?\\n");
+            StringBuilder sb = new StringBuilder();
+            for (String line : lines) {
+                if (!line.strip().equals(target)) {
+                    if (!sb.isEmpty()) sb.append(System.lineSeparator());
+                    sb.append(line);
+                }
+            }
+            Files.writeString(file, sb + (sb.isEmpty() ? "" : System.lineSeparator()),
+                    StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
+            log.info("[本地清理] 已从 accounts.txt 移除: {}", email);
+        } catch (IOException e) {
+            log.warn("[本地清理] 移除账号行失败: {}", e.getMessage());
+        }
+    }
+}

+ 203 - 0
src/main/java/com/husj/openai/util/PkceUtil.java

@@ -0,0 +1,203 @@
+package com.husj.openai.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.net.URI;
+import java.net.URLDecoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.SecureRandom;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * PKCE / OAuth / 密码生成工具类 — 对应 Python openai_register.py 中同名函数
+ */
+public class PkceUtil {
+
+    private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+    private static final Random RANDOM = ThreadLocalRandom.current();
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private static final String LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
+    private static final String UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+    private static final String DIGITS = "0123456789";
+    private static final String SPECIAL = "!@#$%^&*.-";
+    private static final String ALL_ALPHA = LOWERCASE + UPPERCASE + DIGITS;
+    private static final String ALL_WITH_SPECIAL = ALL_ALPHA + SPECIAL;
+
+    /** 生成随机密码 (至少含大小写字母/数字/特殊字符各1位, 共16位) */
+    public static String genPassword() {
+        List<Character> pwd = new ArrayList<>();
+        pwd.add(LOWERCASE.charAt(RANDOM.nextInt(LOWERCASE.length())));
+        pwd.add(UPPERCASE.charAt(RANDOM.nextInt(UPPERCASE.length())));
+        pwd.add(DIGITS.charAt(RANDOM.nextInt(DIGITS.length())));
+        pwd.add(SPECIAL.charAt(RANDOM.nextInt(SPECIAL.length())));
+        for (int i = 0; i < 12; i++) {
+            pwd.add(ALL_WITH_SPECIAL.charAt(RANDOM.nextInt(ALL_WITH_SPECIAL.length())));
+        }
+        Collections.shuffle(pwd);
+        StringBuilder sb = new StringBuilder();
+        for (char c : pwd) sb.append(c);
+        return sb.toString();
+    }
+
+    /** 生成随机用户名 (首字母大写7位) */
+    public static String randomName() {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < 7; i++) {
+            sb.append(LOWERCASE.charAt(RANDOM.nextInt(LOWERCASE.length())));
+        }
+        String s = sb.toString();
+        return Character.toUpperCase(s.charAt(0)) + s.substring(1);
+    }
+
+    /** 生成随机生日 (1975-01-01 ~ 1999-12-31) */
+    public static String randomBirthdate() {
+        LocalDate start = LocalDate.of(1975, 1, 1);
+        LocalDate end = LocalDate.of(1999, 12, 31);
+        long days = end.toEpochDay() - start.toEpochDay();
+        LocalDate d = start.plusDays(RANDOM.nextLong(days + 1));
+        return d.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
+    }
+
+    /** Base64 URL Safe 无填充编码 */
+    public static String b64urlNoPad(byte[] raw) {
+        return Base64.getUrlEncoder().withoutPadding().encodeToString(raw);
+    }
+
+    /** SHA-256 + Base64URL 无填充 */
+    public static String sha256B64urlNoPad(String s) {
+        try {
+            MessageDigest md = MessageDigest.getInstance("SHA-256");
+            return b64urlNoPad(md.digest(s.getBytes(StandardCharsets.US_ASCII)));
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /** 生成 PKCE code_verifier (64字节 URL-safe random) */
+    public static String pkceVerifier() {
+        byte[] bytes = new byte[64];
+        SECURE_RANDOM.nextBytes(bytes);
+        return b64urlNoPad(bytes);
+    }
+
+    /** 生成随机 state (16字节) */
+    public static String randomState() {
+        byte[] bytes = new byte[16];
+        SECURE_RANDOM.nextBytes(bytes);
+        return b64urlNoPad(bytes);
+    }
+
+    /**
+     * 解析 callback URL, 返回 code/state/error/error_description
+     * 对应 Python _parse_callback_url()
+     */
+    public static Map<String, String> parseCallbackUrl(String callbackUrl) {
+        Map<String, String> result = new LinkedHashMap<>();
+        result.put("code", "");
+        result.put("state", "");
+        result.put("error", "");
+        result.put("error_description", "");
+
+        String candidate = (callbackUrl == null ? "" : callbackUrl).strip();
+        if (candidate.isEmpty()) return result;
+
+        try {
+            if (!candidate.contains("://")) {
+                if (candidate.startsWith("?")) {
+                    candidate = "http://localhost" + candidate;
+                } else if (candidate.contains("/") || candidate.contains("?") || candidate.contains("#") || candidate.contains(":")) {
+                    candidate = "http://" + candidate;
+                } else if (candidate.contains("=")) {
+                    candidate = "http://localhost/?" + candidate;
+                }
+            }
+
+            URI uri = new URI(candidate);
+            Map<String, String> queryMap = parseQueryString(uri.getRawQuery());
+            Map<String, String> fragMap = parseQueryString(uri.getRawFragment());
+
+            // Fragment 补充 query
+            for (Map.Entry<String, String> e : fragMap.entrySet()) {
+                if (!queryMap.containsKey(e.getKey()) || queryMap.get(e.getKey()).isBlank()) {
+                    queryMap.put(e.getKey(), e.getValue());
+                }
+            }
+
+            String code = queryMap.getOrDefault("code", "").strip();
+            String state = queryMap.getOrDefault("state", "").strip();
+            String error = queryMap.getOrDefault("error", "").strip();
+            String errorDesc = queryMap.getOrDefault("error_description", "").strip();
+
+            if (!code.isEmpty() && state.isEmpty() && code.contains("#")) {
+                String[] parts = code.split("#", 2);
+                code = parts[0];
+                state = parts[1];
+            }
+            if (error.isEmpty() && !errorDesc.isEmpty()) {
+                error = errorDesc;
+                errorDesc = "";
+            }
+
+            result.put("code", code);
+            result.put("state", state);
+            result.put("error", error);
+            result.put("error_description", errorDesc);
+        } catch (Exception ignored) {}
+
+        return result;
+    }
+
+    private static Map<String, String> parseQueryString(String query) {
+        Map<String, String> map = new LinkedHashMap<>();
+        if (query == null || query.isBlank()) return map;
+        for (String pair : query.split("&")) {
+            String[] kv = pair.split("=", 2);
+            String key = URLDecoder.decode(kv[0], StandardCharsets.UTF_8);
+            String val = kv.length > 1 ? URLDecoder.decode(kv[1], StandardCharsets.UTF_8) : "";
+            map.put(key, val);
+        }
+        return map;
+    }
+
+    /**
+     * 解码 JWT segment (无验证), 对应 Python _decode_jwt_segment + _jwt_claims_no_verify
+     */
+    public static Map<String, Object> jwtClaimsNoVerify(String token) {
+        if (token == null || token.isBlank() || token.chars().filter(c -> c == '.').count() < 2) {
+            return Collections.emptyMap();
+        }
+        try {
+            String payload = token.split("\\.")[1];
+            int pad = (4 - payload.length() % 4) % 4;
+            payload = payload + "=".repeat(pad);
+            byte[] decoded = Base64.getUrlDecoder().decode(payload);
+            JsonNode node = MAPPER.readTree(decoded);
+            return MAPPER.convertValue(node, Map.class);
+        } catch (Exception e) {
+            return Collections.emptyMap();
+        }
+    }
+
+    /** 解码 JWT 第一段 (header segment) */
+    public static Map<String, Object> jwtHeaderNoVerify(String token) {
+        if (token == null || token.isBlank() || !token.contains(".")) {
+            return Collections.emptyMap();
+        }
+        try {
+            String header = token.split("\\.")[0];
+            int pad = (4 - header.length() % 4) % 4;
+            header = header + "=".repeat(pad);
+            byte[] decoded = Base64.getUrlDecoder().decode(header);
+            JsonNode node = MAPPER.readTree(decoded);
+            return MAPPER.convertValue(node, Map.class);
+        } catch (Exception e) {
+            return Collections.emptyMap();
+        }
+    }
+}

+ 32 - 0
src/main/resources/application.yml

@@ -0,0 +1,32 @@
+server:
+  port: 8080
+
+spring:
+  application:
+    name: husj-openai
+
+husj:
+  openai:
+    # 代理配置 (可选, 例: http://127.0.0.1:7890)
+    proxy: ""
+    # 临时邮箱提供商: auto / gptmail / tempmail
+    mail-provider: auto
+    # 循环模式随机等待时间范围(秒)
+    sleep-min: 5
+    sleep-max: 30
+    # 输出目录 (为空则使用程序运行目录下的 tokens/)
+    output-dir: ""
+
+    # CPA 池管理配置
+    cpa:
+      base-url: ${CPA_BASE_URL:}
+      token: ${CPA_TOKEN:}
+      workers: 20
+      timeout: 12
+      retries: 1
+      used-threshold: 95
+      target-count: 300
+
+logging:
+  level:
+    com.husj: DEBUG