feat(Sora): 完成Sora网关接入与媒体能力
新增 Sora 网关路由、账号调度与同步服务\n补充媒体代理与签名 URL、模型列表动态拉取\n完善计费配置、前端支持与相关测试
This commit is contained in:
355
backend/internal/service/sora2api_service.go
Normal file
355
backend/internal/service/sora2api_service.go
Normal file
@@ -0,0 +1,355 @@
|
||||
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{}
|
||||
}
|
||||
Reference in New Issue
Block a user