多平台账号批量注册系统,支持 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
1824 lines
59 KiB
Go
1824 lines
59 KiB
Go
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] + "\"<remote>\"" + 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
|
||
}
|