Files
gpt-plus-gpt/internal/service/account_svc.go
2026-03-15 20:48:19 +08:00

419 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"strings"
"time"
"gpt-plus/config"
"gpt-plus/internal/db"
"gpt-plus/pkg/auth"
"gpt-plus/pkg/chatgpt"
"gpt-plus/pkg/httpclient"
"gpt-plus/pkg/proxy"
"gorm.io/gorm"
)
func getProxyClient(d *gorm.DB) (*httpclient.Client, error) {
var b2Enabled db.SystemConfig
if d.Where("key = ?", "proxy.b2proxy.enabled").First(&b2Enabled).Error == nil && b2Enabled.Value == "true" {
var apiBase, zone, proto db.SystemConfig
d.Where("key = ?", "proxy.b2proxy.api_base").First(&apiBase)
d.Where("key = ?", "proxy.b2proxy.zone").First(&zone)
d.Where("key = ?", "proxy.b2proxy.proto").First(&proto)
var country db.SystemConfig
d.Where("key = ?", "card.default_country").First(&country)
countryCode := country.Value
if countryCode == "" {
countryCode = "US"
}
sessTime := 5
var sessTimeCfg db.SystemConfig
if d.Where("key = ?", "proxy.b2proxy.sess_time").First(&sessTimeCfg).Error == nil {
fmt.Sscanf(sessTimeCfg.Value, "%d", &sessTime)
}
b2Cfg := config.B2ProxyConfig{
Enabled: true, APIBase: apiBase.Value,
Zone: zone.Value, Proto: proto.Value,
PType: 1, SessTime: sessTime,
}
proxyURL, err := proxy.FetchB2Proxy(b2Cfg, countryCode)
if err != nil {
return nil, fmt.Errorf("fetch B2Proxy: %w", err)
}
return httpclient.NewClient(proxyURL)
}
var proxyCfg db.SystemConfig
if d.Where("key = ?", "proxy.url").First(&proxyCfg).Error == nil && proxyCfg.Value != "" {
return httpclient.NewClient(proxyCfg.Value)
}
return httpclient.NewClient("")
}
// parseErrorCode extracts the "code" field from a JSON error response body.
func parseErrorCode(body []byte) string {
var errResp struct {
Detail struct {
Code string `json:"code"`
} `json:"detail"`
}
if json.Unmarshal(body, &errResp) == nil && errResp.Detail.Code != "" {
return errResp.Detail.Code
}
// Fallback: try top-level code
var simple struct {
Code string `json:"code"`
}
if json.Unmarshal(body, &simple) == nil && simple.Code != "" {
return simple.Code
}
return ""
}
// refreshAccountToken attempts to refresh the access token via Codex OAuth.
// Returns true if refresh succeeded and DB was updated.
func refreshAccountToken(d *gorm.DB, acct *db.Account) bool {
client, err := getProxyClient(d)
if err != nil {
log.Printf("[token-refresh] %s: proxy failed: %v", acct.Email, err)
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// team_member 不传 workspace_id用默认 personal workspace 刷新
workspaceID := ""
if acct.Plan == "team_owner" {
workspaceID = acct.TeamWorkspaceID
}
tokens, err := auth.ObtainCodexTokens(ctx, client, acct.DeviceID, workspaceID)
if err != nil {
log.Printf("[token-refresh] %s: ObtainCodexTokens failed: %v", acct.Email, err)
return false
}
acct.AccessToken = tokens.AccessToken
if tokens.RefreshToken != "" {
acct.RefreshToken = tokens.RefreshToken
}
if tokens.IDToken != "" {
acct.IDToken = tokens.IDToken
}
if tokens.ChatGPTAccountID != "" {
acct.AccountID = tokens.ChatGPTAccountID
}
d.Save(acct)
log.Printf("[token-refresh] %s: token refreshed successfully", acct.Email)
return true
}
type AccountCheckResult struct {
ID uint `json:"id"`
Email string `json:"email"`
Status string `json:"status"`
Plan string `json:"plan"`
Message string `json:"message"`
}
func CheckAccountStatuses(d *gorm.DB, ids []uint) []AccountCheckResult {
var results []AccountCheckResult
for _, id := range ids {
var acct db.Account
if d.First(&acct, id).Error != nil {
results = append(results, AccountCheckResult{ID: id, Status: "error", Message: "账号不存在"})
continue
}
r := checkSingleAccount(d, &acct)
results = append(results, r)
}
return results
}
func checkSingleAccount(d *gorm.DB, acct *db.Account) AccountCheckResult {
r := AccountCheckResult{ID: acct.ID, Email: acct.Email}
now := time.Now()
acct.StatusCheckedAt = &now
client, err := getProxyClient(d)
if err != nil {
r.Status = "error"
r.Message = "代理连接失败: " + err.Error()
d.Save(acct)
return r
}
accounts, err := chatgpt.CheckAccountFull(client, acct.AccessToken, acct.DeviceID)
if err != nil {
errMsg := err.Error()
// Try to detect specific error codes from response body
if strings.Contains(errMsg, "401") {
// Could be token_invalidated or account_deactivated — try refresh
log.Printf("[account-check] %s: got 401, attempting token refresh...", acct.Email)
if refreshAccountToken(d, acct) {
// Retry with new token
accounts2, err2 := chatgpt.CheckAccountFull(client, acct.AccessToken, acct.DeviceID)
if err2 == nil {
return buildCheckSuccess(d, acct, accounts2)
}
log.Printf("[account-check] %s: retry after refresh still failed: %v", acct.Email, err2)
}
acct.Status = "banned"
r.Status = "banned"
r.Message = "令牌无效且刷新失败,可能已封禁"
} else if strings.Contains(errMsg, "403") {
acct.Status = "banned"
r.Status = "banned"
r.Message = "账号已封禁 (403)"
} else {
acct.Status = "unknown"
r.Status = "unknown"
r.Message = errMsg
}
d.Save(acct)
return r
}
return buildCheckSuccess(d, acct, accounts)
}
func buildCheckSuccess(d *gorm.DB, acct *db.Account, accounts []*chatgpt.AccountInfo) AccountCheckResult {
r := AccountCheckResult{ID: acct.ID, Email: acct.Email}
var planParts []string
for _, info := range accounts {
planParts = append(planParts, fmt.Sprintf("%s(%s)", info.PlanType, info.Structure))
}
target := selectMembershipAccount(acct, accounts)
if target == nil {
acct.Status = "unknown"
r.Status = "unknown"
r.Message = "membership check returned no matching account"
d.Save(acct)
return r
}
resolvedStatus := normalizeMembershipStatus(target.PlanType)
if resolvedStatus == "" {
resolvedStatus = "unknown"
}
acct.Status = resolvedStatus
r.Status = resolvedStatus
r.Plan = resolvedStatus
r.Message = fmt.Sprintf("membership=%s (%d accounts: %s)", resolvedStatus, len(accounts), strings.Join(planParts, ", "))
if target.AccountID != "" && target.AccountID != acct.AccountID {
acct.AccountID = target.AccountID
}
if target.Structure == "workspace" && target.AccountID != "" {
acct.TeamWorkspaceID = target.AccountID
}
d.Save(acct)
log.Printf("[account-check] %s: %s", acct.Email, r.Message)
return r
}
func normalizeMembershipStatus(planType string) string {
switch planType {
case "free", "plus", "team":
return planType
default:
return ""
}
}
func selectMembershipAccount(acct *db.Account, accounts []*chatgpt.AccountInfo) *chatgpt.AccountInfo {
if len(accounts) == 0 {
return nil
}
if acct.Plan == "team_owner" || acct.Plan == "team_member" {
if acct.TeamWorkspaceID != "" {
for _, info := range accounts {
if info.AccountID == acct.TeamWorkspaceID {
return info
}
}
}
for _, info := range accounts {
if info.Structure == "workspace" && info.PlanType == "team" {
return info
}
}
for _, info := range accounts {
if info.Structure == "workspace" {
return info
}
}
}
if acct.AccountID != "" {
for _, info := range accounts {
if info.AccountID == acct.AccountID && info.Structure == "personal" {
return info
}
}
}
for _, info := range accounts {
if info.Structure == "personal" {
return info
}
}
if acct.AccountID != "" {
for _, info := range accounts {
if info.AccountID == acct.AccountID {
return info
}
}
}
return accounts[0]
}
// --- Model Test ---
type ModelTestResult struct {
ID uint `json:"id"`
Email string `json:"email"`
Model string `json:"model"`
Success bool `json:"success"`
Message string `json:"message"`
Output string `json:"output,omitempty"`
}
func TestModelAvailability(d *gorm.DB, accountID uint, modelID string) *ModelTestResult {
var acct db.Account
if d.First(&acct, accountID).Error != nil {
return &ModelTestResult{ID: accountID, Model: modelID, Message: "账号不存在"}
}
if modelID == "" {
modelID = "gpt-4o"
}
result := doModelTest(d, &acct, modelID)
// If token_invalidated, auto-refresh and retry once
if !result.Success && strings.Contains(result.Message, "令牌过期") {
log.Printf("[model-test] %s: token expired, refreshing...", acct.Email)
if refreshAccountToken(d, &acct) {
result = doModelTest(d, &acct, modelID)
if result.Success {
result.Message += " (令牌已自动刷新)"
}
}
}
return result
}
func doModelTest(d *gorm.DB, acct *db.Account, modelID string) *ModelTestResult {
client, err := getProxyClient(d)
if err != nil {
return &ModelTestResult{ID: acct.ID, Email: acct.Email, Model: modelID, Message: "代理连接失败: " + err.Error()}
}
result := &ModelTestResult{ID: acct.ID, Email: acct.Email, Model: modelID}
apiURL := "https://chatgpt.com/backend-api/codex/responses"
payload := map[string]interface{}{
"model": modelID,
"input": []map[string]interface{}{
{
"role": "user",
"content": []map[string]interface{}{
{"type": "input_text", "text": "hi"},
},
},
},
"stream": false,
"store": false,
}
headers := map[string]string{
"Authorization": "Bearer " + acct.AccessToken,
"Content-Type": "application/json",
"Accept": "*/*",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"Origin": "https://chatgpt.com",
"Referer": "https://chatgpt.com/",
"oai-language": "en-US",
}
if acct.AccountID != "" {
headers["chatgpt-account-id"] = acct.AccountID
}
if acct.DeviceID != "" {
headers["oai-device-id"] = acct.DeviceID
}
resp, err := client.PostJSON(apiURL, payload, headers)
if err != nil {
result.Message = fmt.Sprintf("请求失败: %v", err)
return result
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
result.Success = true
result.Message = fmt.Sprintf("模型 %s 可用", modelID)
var respData map[string]interface{}
if json.Unmarshal(body, &respData) == nil {
if output, ok := respData["output_text"]; ok {
result.Output = fmt.Sprintf("%v", output)
}
}
case http.StatusUnauthorized:
code := parseErrorCode(body)
switch code {
case "token_invalidated":
result.Message = "令牌过期 (token_invalidated)"
case "account_deactivated":
result.Message = "账号已封禁 (account_deactivated)"
acct.Status = "banned"
d.Save(acct)
default:
result.Message = fmt.Sprintf("认证失败 (401, code=%s)", code)
}
case http.StatusForbidden:
result.Message = "账号被封禁 (403)"
acct.Status = "banned"
d.Save(acct)
case http.StatusNotFound:
result.Message = fmt.Sprintf("模型 %s 不存在 (404)", modelID)
case http.StatusTooManyRequests:
result.Message = "请求限流 (429),请稍后再试"
default:
errMsg := string(body)
if len(errMsg) > 300 {
errMsg = errMsg[:300]
}
result.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, errMsg)
}
log.Printf("[model-test] %s model=%s status=%d success=%v msg=%s", acct.Email, modelID, resp.StatusCode, result.Success, result.Message)
return result
}