Commit 618a614c authored by yangjianbo's avatar yangjianbo
Browse files

feat(Sora): 完成Sora网关接入与媒体能力

新增 Sora 网关路由、账号调度与同步服务\n补充媒体代理与签名 URL、模型列表动态拉取\n完善计费配置、前端支持与相关测试
parent 99dc3b59
......@@ -41,8 +41,8 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
if account == nil {
return "", errors.New("account is nil")
}
if account.Platform != PlatformOpenAI || account.Type != AccountTypeOAuth {
return "", errors.New("not an openai oauth account")
if (account.Platform != PlatformOpenAI && account.Platform != PlatformSora) || account.Type != AccountTypeOAuth {
return "", errors.New("not an openai/sora oauth account")
}
cacheKey := OpenAITokenCacheKey(account)
......@@ -157,7 +157,7 @@ func (p *OpenAITokenProvider) GetAccessToken(ctx context.Context, account *Accou
}
}
accessToken := account.GetOpenAIAccessToken()
accessToken := account.GetCredential("access_token")
if strings.TrimSpace(accessToken) == "" {
return "", errors.New("access_token not found in credentials")
}
......
......@@ -375,7 +375,7 @@ func TestOpenAITokenProvider_WrongPlatform(t *testing.T) {
token, err := provider.GetAccessToken(context.Background(), account)
require.Error(t, err)
require.Contains(t, err.Error(), "not an openai oauth account")
require.Contains(t, err.Error(), "not an openai/sora oauth account")
require.Empty(t, token)
}
......@@ -389,7 +389,7 @@ func TestOpenAITokenProvider_WrongAccountType(t *testing.T) {
token, err := provider.GetAccessToken(context.Background(), account)
require.Error(t, err)
require.Contains(t, err.Error(), "not an openai oauth account")
require.Contains(t, err.Error(), "not an openai/sora oauth account")
require.Empty(t, token)
}
......
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
)
// Sora2APIModel represents a model entry returned by sora2api.
type Sora2APIModel struct {
ID string `json:"id"`
Object string `json:"object"`
OwnedBy string `json:"owned_by,omitempty"`
Description string `json:"description,omitempty"`
}
// Sora2APIModelList represents /v1/models response.
type Sora2APIModelList struct {
Object string `json:"object"`
Data []Sora2APIModel `json:"data"`
}
// Sora2APIImportTokenItem mirrors sora2api ImportTokenItem.
type Sora2APIImportTokenItem struct {
Email string `json:"email"`
AccessToken string `json:"access_token,omitempty"`
SessionToken string `json:"session_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ClientID string `json:"client_id,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
Remark string `json:"remark,omitempty"`
IsActive bool `json:"is_active"`
ImageEnabled bool `json:"image_enabled"`
VideoEnabled bool `json:"video_enabled"`
ImageConcurrency int `json:"image_concurrency"`
VideoConcurrency int `json:"video_concurrency"`
}
// Sora2APIToken represents minimal fields for admin list.
type Sora2APIToken struct {
ID int64 `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Remark string `json:"remark"`
}
// Sora2APIService provides access to sora2api endpoints.
type Sora2APIService struct {
cfg *config.Config
baseURL string
apiKey string
adminUsername string
adminPassword string
adminTokenTTL time.Duration
adminTimeout time.Duration
tokenImportMode string
client *http.Client
adminClient *http.Client
adminToken string
adminTokenAt time.Time
adminMu sync.Mutex
modelCache []Sora2APIModel
modelCacheAt time.Time
modelMu sync.RWMutex
}
func NewSora2APIService(cfg *config.Config) *Sora2APIService {
if cfg == nil {
return &Sora2APIService{}
}
adminTTL := time.Duration(cfg.Sora2API.AdminTokenTTLSeconds) * time.Second
if adminTTL <= 0 {
adminTTL = 15 * time.Minute
}
adminTimeout := time.Duration(cfg.Sora2API.AdminTimeoutSeconds) * time.Second
if adminTimeout <= 0 {
adminTimeout = 10 * time.Second
}
return &Sora2APIService{
cfg: cfg,
baseURL: strings.TrimRight(strings.TrimSpace(cfg.Sora2API.BaseURL), "/"),
apiKey: strings.TrimSpace(cfg.Sora2API.APIKey),
adminUsername: strings.TrimSpace(cfg.Sora2API.AdminUsername),
adminPassword: strings.TrimSpace(cfg.Sora2API.AdminPassword),
adminTokenTTL: adminTTL,
adminTimeout: adminTimeout,
tokenImportMode: strings.ToLower(strings.TrimSpace(cfg.Sora2API.TokenImportMode)),
client: &http.Client{},
adminClient: &http.Client{Timeout: adminTimeout},
}
}
func (s *Sora2APIService) Enabled() bool {
return s != nil && s.baseURL != "" && s.apiKey != ""
}
func (s *Sora2APIService) AdminEnabled() bool {
return s != nil && s.baseURL != "" && s.adminUsername != "" && s.adminPassword != ""
}
func (s *Sora2APIService) buildURL(path string) string {
if s.baseURL == "" {
return path
}
if strings.HasPrefix(path, "/") {
return s.baseURL + path
}
return s.baseURL + "/" + path
}
// BuildURL 返回完整的 sora2api URL(用于代理媒体)
func (s *Sora2APIService) BuildURL(path string) string {
return s.buildURL(path)
}
func (s *Sora2APIService) NewAPIRequest(ctx context.Context, method string, path string, body []byte) (*http.Request, error) {
if !s.Enabled() {
return nil, errors.New("sora2api not configured")
}
req, err := http.NewRequestWithContext(ctx, method, s.buildURL(path), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
req.Header.Set("Content-Type", "application/json")
return req, nil
}
func (s *Sora2APIService) ListModels(ctx context.Context) ([]Sora2APIModel, error) {
if !s.Enabled() {
return nil, errors.New("sora2api not configured")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.buildURL("/v1/models"), nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+s.apiKey)
resp, err := s.client.Do(req)
if err != nil {
return s.cachedModelsOnError(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return s.cachedModelsOnError(fmt.Errorf("sora2api models status: %d", resp.StatusCode))
}
var payload Sora2APIModelList
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return s.cachedModelsOnError(err)
}
models := payload.Data
if s.cfg != nil && s.cfg.Gateway.SoraModelFilters.HidePromptEnhance {
filtered := make([]Sora2APIModel, 0, len(models))
for _, m := range models {
if strings.HasPrefix(strings.ToLower(m.ID), "prompt-enhance") {
continue
}
filtered = append(filtered, m)
}
models = filtered
}
s.modelMu.Lock()
s.modelCache = models
s.modelCacheAt = time.Now()
s.modelMu.Unlock()
return models, nil
}
func (s *Sora2APIService) cachedModelsOnError(err error) ([]Sora2APIModel, error) {
s.modelMu.RLock()
cached := append([]Sora2APIModel(nil), s.modelCache...)
s.modelMu.RUnlock()
if len(cached) > 0 {
log.Printf("[Sora2API] 模型列表拉取失败,回退缓存: %v", err)
return cached, nil
}
return nil, err
}
func (s *Sora2APIService) ImportTokens(ctx context.Context, items []Sora2APIImportTokenItem) error {
if !s.AdminEnabled() {
return errors.New("sora2api admin not configured")
}
mode := s.tokenImportMode
if mode == "" {
mode = "at"
}
payload := map[string]any{
"tokens": items,
"mode": mode,
}
_, err := s.doAdminRequest(ctx, http.MethodPost, "/api/tokens/import", payload, nil)
return err
}
func (s *Sora2APIService) ListTokens(ctx context.Context) ([]Sora2APIToken, error) {
if !s.AdminEnabled() {
return nil, errors.New("sora2api admin not configured")
}
var tokens []Sora2APIToken
_, err := s.doAdminRequest(ctx, http.MethodGet, "/api/tokens", nil, &tokens)
return tokens, err
}
func (s *Sora2APIService) DisableToken(ctx context.Context, tokenID int64) error {
if !s.AdminEnabled() {
return errors.New("sora2api admin not configured")
}
path := fmt.Sprintf("/api/tokens/%d/disable", tokenID)
_, err := s.doAdminRequest(ctx, http.MethodPost, path, nil, nil)
return err
}
func (s *Sora2APIService) DeleteToken(ctx context.Context, tokenID int64) error {
if !s.AdminEnabled() {
return errors.New("sora2api admin not configured")
}
path := fmt.Sprintf("/api/tokens/%d", tokenID)
_, err := s.doAdminRequest(ctx, http.MethodDelete, path, nil, nil)
return err
}
func (s *Sora2APIService) doAdminRequest(ctx context.Context, method string, path string, body any, out any) (*http.Response, error) {
if !s.AdminEnabled() {
return nil, errors.New("sora2api admin not configured")
}
token, err := s.getAdminToken(ctx)
if err != nil {
return nil, err
}
resp, err := s.doAdminRequestWithToken(ctx, method, path, token, body, out)
if err == nil && resp != nil && resp.StatusCode != http.StatusUnauthorized {
return resp, nil
}
if resp != nil && resp.StatusCode == http.StatusUnauthorized {
s.invalidateAdminToken()
token, err = s.getAdminToken(ctx)
if err != nil {
return resp, err
}
return s.doAdminRequestWithToken(ctx, method, path, token, body, out)
}
return resp, err
}
func (s *Sora2APIService) doAdminRequestWithToken(ctx context.Context, method string, path string, token string, body any, out any) (*http.Response, error) {
var reader *bytes.Reader
if body != nil {
buf, err := json.Marshal(body)
if err != nil {
return nil, err
}
reader = bytes.NewReader(buf)
} else {
reader = bytes.NewReader(nil)
}
req, err := http.NewRequestWithContext(ctx, method, s.buildURL(path), reader)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := s.adminClient.Do(req)
if err != nil {
return resp, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return resp, fmt.Errorf("sora2api admin status: %d", resp.StatusCode)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return resp, err
}
}
return resp, nil
}
func (s *Sora2APIService) getAdminToken(ctx context.Context) (string, error) {
s.adminMu.Lock()
defer s.adminMu.Unlock()
if s.adminToken != "" && time.Since(s.adminTokenAt) < s.adminTokenTTL {
return s.adminToken, nil
}
if !s.AdminEnabled() {
return "", errors.New("sora2api admin not configured")
}
payload := map[string]string{
"username": s.adminUsername,
"password": s.adminPassword,
}
buf, err := json.Marshal(payload)
if err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.buildURL("/api/login"), bytes.NewReader(buf))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.adminClient.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("sora2api login failed: %d", resp.StatusCode)
}
var result struct {
Success bool `json:"success"`
Token string `json:"token"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
if !result.Success || result.Token == "" {
if result.Message == "" {
result.Message = "sora2api login failed"
}
return "", errors.New(result.Message)
}
s.adminToken = result.Token
s.adminTokenAt = time.Now()
return result.Token, nil
}
func (s *Sora2APIService) invalidateAdminToken() {
s.adminMu.Lock()
defer s.adminMu.Unlock()
s.adminToken = ""
s.adminTokenAt = time.Time{}
}
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Sora2APISyncService 用于同步 Sora 账号到 sora2api token 池
type Sora2APISyncService struct {
sora2api *Sora2APIService
accountRepo AccountRepository
httpClient *http.Client
}
func NewSora2APISyncService(sora2api *Sora2APIService, accountRepo AccountRepository) *Sora2APISyncService {
return &Sora2APISyncService{
sora2api: sora2api,
accountRepo: accountRepo,
httpClient: &http.Client{Timeout: 10 * time.Second},
}
}
func (s *Sora2APISyncService) Enabled() bool {
return s != nil && s.sora2api != nil && s.sora2api.AdminEnabled()
}
// SyncAccount 将 Sora 账号同步到 sora2api(导入或更新)
func (s *Sora2APISyncService) SyncAccount(ctx context.Context, account *Account) error {
if !s.Enabled() {
return nil
}
if account == nil || account.Platform != PlatformSora {
return nil
}
accessToken := strings.TrimSpace(account.GetCredential("access_token"))
if accessToken == "" {
return errors.New("sora 账号缺少 access_token")
}
email, updated := s.resolveAccountEmail(ctx, account)
if email == "" {
return errors.New("无法解析 Sora 账号邮箱")
}
if updated && s.accountRepo != nil {
if err := s.accountRepo.Update(ctx, account); err != nil {
log.Printf("[SoraSync] 更新账号邮箱失败: account_id=%d err=%v", account.ID, err)
}
}
item := Sora2APIImportTokenItem{
Email: email,
AccessToken: accessToken,
SessionToken: strings.TrimSpace(account.GetCredential("session_token")),
RefreshToken: strings.TrimSpace(account.GetCredential("refresh_token")),
ClientID: strings.TrimSpace(account.GetCredential("client_id")),
Remark: account.Name,
IsActive: account.IsActive() && account.Schedulable,
ImageEnabled: true,
VideoEnabled: true,
ImageConcurrency: normalizeSoraConcurrency(account.Concurrency),
VideoConcurrency: normalizeSoraConcurrency(account.Concurrency),
}
if err := s.sora2api.ImportTokens(ctx, []Sora2APIImportTokenItem{item}); err != nil {
return err
}
return nil
}
// DisableAccount 禁用 sora2api 中的 token
func (s *Sora2APISyncService) DisableAccount(ctx context.Context, account *Account) error {
if !s.Enabled() {
return nil
}
if account == nil || account.Platform != PlatformSora {
return nil
}
tokenID, err := s.resolveTokenID(ctx, account)
if err != nil {
return err
}
return s.sora2api.DisableToken(ctx, tokenID)
}
// DeleteAccount 删除 sora2api 中的 token
func (s *Sora2APISyncService) DeleteAccount(ctx context.Context, account *Account) error {
if !s.Enabled() {
return nil
}
if account == nil || account.Platform != PlatformSora {
return nil
}
tokenID, err := s.resolveTokenID(ctx, account)
if err != nil {
return err
}
return s.sora2api.DeleteToken(ctx, tokenID)
}
func normalizeSoraConcurrency(value int) int {
if value <= 0 {
return -1
}
return value
}
func (s *Sora2APISyncService) resolveAccountEmail(ctx context.Context, account *Account) (string, bool) {
if account == nil {
return "", false
}
if email := strings.TrimSpace(account.GetCredential("email")); email != "" {
return email, false
}
if email := strings.TrimSpace(account.GetExtraString("email")); email != "" {
if account.Credentials == nil {
account.Credentials = map[string]any{}
}
account.Credentials["email"] = email
return email, true
}
if email := strings.TrimSpace(account.GetExtraString("sora_email")); email != "" {
if account.Credentials == nil {
account.Credentials = map[string]any{}
}
account.Credentials["email"] = email
return email, true
}
accessToken := strings.TrimSpace(account.GetCredential("access_token"))
if accessToken != "" {
if email := extractEmailFromAccessToken(accessToken); email != "" {
if account.Credentials == nil {
account.Credentials = map[string]any{}
}
account.Credentials["email"] = email
return email, true
}
if email := s.fetchEmailFromSora(ctx, accessToken); email != "" {
if account.Credentials == nil {
account.Credentials = map[string]any{}
}
account.Credentials["email"] = email
return email, true
}
}
return "", false
}
func (s *Sora2APISyncService) resolveTokenID(ctx context.Context, account *Account) (int64, error) {
if account == nil {
return 0, errors.New("account is nil")
}
if account.Extra != nil {
if v, ok := account.Extra["sora2api_token_id"]; ok {
if id, ok := v.(float64); ok && id > 0 {
return int64(id), nil
}
if id, ok := v.(int64); ok && id > 0 {
return id, nil
}
if id, ok := v.(int); ok && id > 0 {
return int64(id), nil
}
}
}
email := strings.TrimSpace(account.GetCredential("email"))
if email == "" {
email, _ = s.resolveAccountEmail(ctx, account)
}
if email == "" {
return 0, errors.New("sora2api token email missing")
}
tokenID, err := s.findTokenIDByEmail(ctx, email)
if err != nil {
return 0, err
}
return tokenID, nil
}
func (s *Sora2APISyncService) findTokenIDByEmail(ctx context.Context, email string) (int64, error) {
if !s.Enabled() {
return 0, errors.New("sora2api admin not configured")
}
tokens, err := s.sora2api.ListTokens(ctx)
if err != nil {
return 0, err
}
for _, token := range tokens {
if strings.EqualFold(strings.TrimSpace(token.Email), strings.TrimSpace(email)) {
return token.ID, nil
}
}
return 0, fmt.Errorf("sora2api token not found for email: %s", email)
}
func extractEmailFromAccessToken(accessToken string) string {
parser := jwt.NewParser(jwt.WithoutClaimsValidation())
claims := jwt.MapClaims{}
_, _, err := parser.ParseUnverified(accessToken, claims)
if err != nil {
return ""
}
if email, ok := claims["email"].(string); ok && strings.TrimSpace(email) != "" {
return email
}
if profile, ok := claims["https://api.openai.com/profile"].(map[string]any); ok {
if email, ok := profile["email"].(string); ok && strings.TrimSpace(email) != "" {
return email
}
}
return ""
}
func (s *Sora2APISyncService) fetchEmailFromSora(ctx context.Context, accessToken string) string {
if s.httpClient == nil {
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, soraMeAPIURL, nil)
if err != nil {
return ""
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("User-Agent", "Sora/1.2026.007 (Android 15; 24122RKC7C; build 2600700)")
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return ""
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return ""
}
var payload map[string]any
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return ""
}
if email, ok := payload["email"].(string); ok && strings.TrimSpace(email) != "" {
return email
}
return ""
}
This diff is collapsed.
package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
)
// SignSoraMediaURL 生成 Sora 媒体临时签名
func SignSoraMediaURL(path string, query string, expires int64, key string) string {
key = strings.TrimSpace(key)
if key == "" {
return ""
}
mac := hmac.New(sha256.New, []byte(key))
mac.Write([]byte(buildSoraMediaSignPayload(path, query)))
mac.Write([]byte("|"))
mac.Write([]byte(strconv.FormatInt(expires, 10)))
return hex.EncodeToString(mac.Sum(nil))
}
// VerifySoraMediaURL 校验 Sora 媒体签名
func VerifySoraMediaURL(path string, query string, expires int64, signature string, key string) bool {
signature = strings.TrimSpace(signature)
if signature == "" {
return false
}
expected := SignSoraMediaURL(path, query, expires, key)
if expected == "" {
return false
}
return hmac.Equal([]byte(signature), []byte(expected))
}
func buildSoraMediaSignPayload(path string, query string) string {
if strings.TrimSpace(query) == "" {
return path
}
return path + "?" + query
}
package service
import "testing"
func TestSoraMediaSignVerify(t *testing.T) {
key := "test-key"
path := "/tmp/abc.png"
query := "a=1&b=2"
expires := int64(1700000000)
signature := SignSoraMediaURL(path, query, expires, key)
if signature == "" {
t.Fatal("签名为空")
}
if !VerifySoraMediaURL(path, query, expires, signature, key) {
t.Fatal("签名校验失败")
}
if VerifySoraMediaURL(path, "a=1", expires, signature, key) {
t.Fatal("签名参数不同仍然通过")
}
if VerifySoraMediaURL(path, query, expires+1, signature, key) {
t.Fatal("签名过期校验未失败")
}
}
func TestSoraMediaSignWithEmptyKey(t *testing.T) {
signature := SignSoraMediaURL("/tmp/a.png", "a=1", 1, "")
if signature != "" {
t.Fatalf("空密钥不应生成签名")
}
if VerifySoraMediaURL("/tmp/a.png", "a=1", 1, "sig", "") {
t.Fatalf("空密钥不应通过校验")
}
}
......@@ -42,7 +42,7 @@ func (c *CompositeTokenCacheInvalidator) InvalidateToken(ctx context.Context, ac
// Antigravity 同样可能有两种缓存键
keysToDelete = append(keysToDelete, AntigravityTokenCacheKey(account))
keysToDelete = append(keysToDelete, "ag:"+accountIDKey)
case PlatformOpenAI:
case PlatformOpenAI, PlatformSora:
keysToDelete = append(keysToDelete, OpenAITokenCacheKey(account))
case PlatformAnthropic:
keysToDelete = append(keysToDelete, ClaudeTokenCacheKey(account))
......
......@@ -19,6 +19,7 @@ type TokenRefreshService struct {
refreshers []TokenRefresher
cfg *config.TokenRefreshConfig
cacheInvalidator TokenCacheInvalidator
soraSyncService *Sora2APISyncService
stopCh chan struct{}
wg sync.WaitGroup
......@@ -65,6 +66,17 @@ func (s *TokenRefreshService) SetSoraAccountRepo(repo SoraAccountRepository) {
}
}
// SetSoraSyncService 设置 Sora2API 同步服务
// 需要在 Start() 之前调用
func (s *TokenRefreshService) SetSoraSyncService(svc *Sora2APISyncService) {
s.soraSyncService = svc
for _, refresher := range s.refreshers {
if openaiRefresher, ok := refresher.(*OpenAITokenRefresher); ok {
openaiRefresher.SetSoraSyncService(svc)
}
}
}
// Start 启动后台刷新服务
func (s *TokenRefreshService) Start() {
if !s.cfg.Enabled {
......
......@@ -86,6 +86,7 @@ type OpenAITokenRefresher struct {
openaiOAuthService *OpenAIOAuthService
accountRepo AccountRepository
soraAccountRepo SoraAccountRepository // Sora 扩展表仓储,用于双表同步
soraSyncService *Sora2APISyncService // Sora2API 同步服务
}
// NewOpenAITokenRefresher 创建 OpenAI token刷新器
......@@ -103,17 +104,22 @@ func (r *OpenAITokenRefresher) SetSoraAccountRepo(repo SoraAccountRepository) {
r.soraAccountRepo = repo
}
// SetSoraSyncService 设置 Sora2API 同步服务
func (r *OpenAITokenRefresher) SetSoraSyncService(svc *Sora2APISyncService) {
r.soraSyncService = svc
}
// CanRefresh 检查是否能处理此账号
// 只处理 openai 平台的 oauth 类型账号
func (r *OpenAITokenRefresher) CanRefresh(account *Account) bool {
return account.Platform == PlatformOpenAI &&
return (account.Platform == PlatformOpenAI || account.Platform == PlatformSora) &&
account.Type == AccountTypeOAuth
}
// NeedsRefresh 检查token是否需要刷新
// 基于 expires_at 字段判断是否在刷新窗口内
func (r *OpenAITokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
expiresAt := account.GetOpenAITokenExpiresAt()
expiresAt := account.GetCredentialAsTime("expires_at")
if expiresAt == nil {
return false
}
......@@ -145,6 +151,17 @@ func (r *OpenAITokenRefresher) Refresh(ctx context.Context, account *Account) (m
go r.syncLinkedSoraAccounts(context.Background(), account.ID, newCredentials)
}
// 如果是 Sora 平台账号,同步到 sora2api(不阻塞主流程)
if account.Platform == PlatformSora && r.soraSyncService != nil {
syncAccount := *account
syncAccount.Credentials = newCredentials
go func() {
if err := r.soraSyncService.SyncAccount(context.Background(), &syncAccount); err != nil {
log.Printf("[TokenSync] 同步 Sora2API 失败: account_id=%d err=%v", syncAccount.ID, err)
}
}()
}
return newCredentials, nil
}
......@@ -201,6 +218,13 @@ func (r *OpenAITokenRefresher) syncLinkedSoraAccounts(ctx context.Context, opena
}
}
// 2.3 同步到 sora2api(如果配置)
if r.soraSyncService != nil {
if err := r.soraSyncService.SyncAccount(ctx, &soraAccount); err != nil {
log.Printf("[TokenSync] 同步 sora2api 失败: account_id=%d err=%v", soraAccount.ID, err)
}
}
log.Printf("[TokenSync] 成功同步 Sora 账号 token: sora_account_id=%d openai_account_id=%d dual_table=%v",
soraAccount.ID, openaiAccountID, r.soraAccountRepo != nil)
}
......
......@@ -46,6 +46,7 @@ type UsageLog struct {
// 图片生成字段
ImageCount int
ImageSize *string
MediaType *string
CreatedAt time.Time
......
......@@ -40,6 +40,7 @@ func ProvideEmailQueueService(emailService *EmailService) *EmailQueueService {
func ProvideTokenRefreshService(
accountRepo AccountRepository,
soraAccountRepo SoraAccountRepository, // Sora 扩展表仓储,用于双表同步
soraSyncService *Sora2APISyncService,
oauthService *OAuthService,
openaiOAuthService *OpenAIOAuthService,
geminiOAuthService *GeminiOAuthService,
......@@ -50,6 +51,9 @@ func ProvideTokenRefreshService(
svc := NewTokenRefreshService(accountRepo, oauthService, openaiOAuthService, geminiOAuthService, antigravityOAuthService, cacheInvalidator, cfg)
// 注入 Sora 账号扩展表仓储,用于 OpenAI Token 刷新时同步 sora_accounts 表
svc.SetSoraAccountRepo(soraAccountRepo)
if soraSyncService != nil {
svc.SetSoraSyncService(soraSyncService)
}
svc.Start()
return svc
}
......@@ -224,6 +228,7 @@ var ProviderSet = wire.NewSet(
NewBillingCacheService,
NewAdminService,
NewGatewayService,
NewSoraGatewayService,
NewOpenAIGatewayService,
NewOAuthService,
NewOpenAIOAuthService,
......@@ -237,6 +242,8 @@ var ProviderSet = wire.NewSet(
NewAntigravityTokenProvider,
NewOpenAITokenProvider,
NewClaudeTokenProvider,
NewSora2APIService,
NewSora2APISyncService,
NewAntigravityGatewayService,
ProvideRateLimitService,
NewAccountUsageService,
......
-- Migration: 047_add_sora_pricing_and_media_type
-- 新增 Sora 按次计费字段与 usage_logs.media_type
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS sora_image_price_360 decimal(20,8),
ADD COLUMN IF NOT EXISTS sora_image_price_540 decimal(20,8),
ADD COLUMN IF NOT EXISTS sora_video_price_per_request decimal(20,8),
ADD COLUMN IF NOT EXISTS sora_video_price_per_request_hd decimal(20,8);
ALTER TABLE usage_logs
ADD COLUMN IF NOT EXISTS media_type VARCHAR(16);
# =============================================================================
# Sub2API Caddy Reverse Proxy Configuration (宿主机部署)
# =============================================================================
# 使用方法:
# 1. 安装 Caddy: https://caddyserver.com/docs/install
# 2. 修改下方 example.com 为你的域名
# 3. 确保域名 DNS 已指向服务器
# 4. 复制配置: sudo cp Caddyfile /etc/caddy/Caddyfile
# 5. 重载配置: sudo systemctl reload caddy
#
# Caddy 会自动申请和续期 Let's Encrypt SSL 证书
# =============================================================================
# 全局配置
{
# Let's Encrypt 邮箱通知
email admin@example.com
# 服务器配置
servers {
# 启用 HTTP/2 和 HTTP/3
protocols h1 h2 h3
# 超时配置
timeouts {
read_body 30s
read_header 10s
write 300s
idle 300s
}
}
}
# 修改为你的域名
example.com {
api.sub2api.com {
# =========================================================================
# 静态资源长期缓存(高优先级,放在最前面)
# 带 hash 的文件可以永久缓存,浏览器和 CDN 都会缓存
......@@ -87,7 +54,6 @@ example.com {
# 连接池优化
transport http {
versions h2c h1
keepalive 120s
keepalive_idle_conns 256
read_buffer 16KB
......@@ -95,9 +61,6 @@ example.com {
compression off
}
# SSE/流式传输优化:禁用响应缓冲,立即刷新数据给客户端
flush_interval -1
# 故障转移
fail_duration 30s
max_fails 3
......@@ -112,10 +75,6 @@ example.com {
gzip 6
minimum_length 256
match {
# SSE 请求通常会带 Accept: text/event-stream,需排除压缩
not header Accept text/event-stream*
# 排除已知 SSE 路径(即便 Accept 缺失)
not path /v1/messages /v1/responses /responses /antigravity/v1/messages /v1beta/models/* /antigravity/v1beta/models/*
header Content-Type text/*
header Content-Type application/json*
header Content-Type application/javascript*
......@@ -199,7 +158,3 @@ example.com {
respond "{err.status_code} {err.status_text}"
}
}
# =============================================================================
# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明)
# =============================================================================
This diff is collapsed.
......@@ -18,6 +18,7 @@ import geminiAPI from './gemini'
import antigravityAPI from './antigravity'
import userAttributesAPI from './userAttributes'
import opsAPI from './ops'
import modelsAPI from './models'
/**
* Unified admin API object for convenient access
......@@ -37,7 +38,8 @@ export const adminAPI = {
gemini: geminiAPI,
antigravity: antigravityAPI,
userAttributes: userAttributesAPI,
ops: opsAPI
ops: opsAPI,
models: modelsAPI
}
export {
......@@ -55,7 +57,8 @@ export {
geminiAPI,
antigravityAPI,
userAttributesAPI,
opsAPI
opsAPI,
modelsAPI
}
export default adminAPI
import { apiClient } from '@/api/client'
export async function getPlatformModels(platform: string): Promise<string[]> {
const { data } = await apiClient.get<string[]>('/admin/models', {
params: { platform }
})
return data
}
export const modelsAPI = {
getPlatformModels
}
export default modelsAPI
......@@ -19,7 +19,7 @@ const props = defineProps(['searchQuery', 'filters']); const emit = defineEmits(
const updatePlatform = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, platform: value }) }
const updateType = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, type: value }) }
const updateStatus = (value: string | number | boolean | null) => { emit('update:filters', { ...props.filters, status: value }) }
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }])
const pOpts = computed(() => [{ value: '', label: t('admin.accounts.allPlatforms') }, { value: 'anthropic', label: 'Anthropic' }, { value: 'openai', label: 'OpenAI' }, { value: 'gemini', label: 'Gemini' }, { value: 'antigravity', label: 'Antigravity' }, { value: 'sora', label: 'Sora' }])
const tOpts = computed(() => [{ value: '', label: t('admin.accounts.allTypes') }, { value: 'oauth', label: t('admin.accounts.oauthType') }, { value: 'setup-token', label: t('admin.accounts.setupToken') }, { value: 'apikey', label: t('admin.accounts.apiKey') }])
const sOpts = computed(() => [{ value: '', label: t('admin.accounts.allStatus') }, { value: 'active', label: t('admin.accounts.status.active') }, { value: 'inactive', label: t('admin.accounts.status.inactive') }, { value: 'error', label: t('admin.accounts.status.error') }])
</script>
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment