feat(Sora): 完成Sora网关接入与媒体能力
新增 Sora 网关路由、账号调度与同步服务\n补充媒体代理与签名 URL、模型列表动态拉取\n完善计费配置、前端支持与相关测试
This commit is contained in:
@@ -27,7 +27,7 @@ func NewGroupHandler(adminService service.AdminService) *GroupHandler {
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -38,6 +38,10 @@ type CreateGroupRequest struct {
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
SoraImagePrice360 *float64 `json:"sora_image_price_360"`
|
||||
SoraImagePrice540 *float64 `json:"sora_image_price_540"`
|
||||
SoraVideoPricePerRequest *float64 `json:"sora_video_price_per_request"`
|
||||
SoraVideoPricePerRequestHD *float64 `json:"sora_video_price_per_request_hd"`
|
||||
ClaudeCodeOnly bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
@@ -49,7 +53,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity sora"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
@@ -61,6 +65,10 @@ type UpdateGroupRequest struct {
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
SoraImagePrice360 *float64 `json:"sora_image_price_360"`
|
||||
SoraImagePrice540 *float64 `json:"sora_image_price_540"`
|
||||
SoraVideoPricePerRequest *float64 `json:"sora_video_price_per_request"`
|
||||
SoraVideoPricePerRequestHD *float64 `json:"sora_video_price_per_request_hd"`
|
||||
ClaudeCodeOnly *bool `json:"claude_code_only"`
|
||||
FallbackGroupID *int64 `json:"fallback_group_id"`
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
@@ -167,6 +175,10 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
ImagePrice1K: req.ImagePrice1K,
|
||||
ImagePrice2K: req.ImagePrice2K,
|
||||
ImagePrice4K: req.ImagePrice4K,
|
||||
SoraImagePrice360: req.SoraImagePrice360,
|
||||
SoraImagePrice540: req.SoraImagePrice540,
|
||||
SoraVideoPricePerRequest: req.SoraVideoPricePerRequest,
|
||||
SoraVideoPricePerRequestHD: req.SoraVideoPricePerRequestHD,
|
||||
ClaudeCodeOnly: req.ClaudeCodeOnly,
|
||||
FallbackGroupID: req.FallbackGroupID,
|
||||
ModelRouting: req.ModelRouting,
|
||||
@@ -209,6 +221,10 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
ImagePrice1K: req.ImagePrice1K,
|
||||
ImagePrice2K: req.ImagePrice2K,
|
||||
ImagePrice4K: req.ImagePrice4K,
|
||||
SoraImagePrice360: req.SoraImagePrice360,
|
||||
SoraImagePrice540: req.SoraImagePrice540,
|
||||
SoraVideoPricePerRequest: req.SoraVideoPricePerRequest,
|
||||
SoraVideoPricePerRequestHD: req.SoraVideoPricePerRequestHD,
|
||||
ClaudeCodeOnly: req.ClaudeCodeOnly,
|
||||
FallbackGroupID: req.FallbackGroupID,
|
||||
ModelRouting: req.ModelRouting,
|
||||
|
||||
55
backend/internal/handler/admin/model_handler.go
Normal file
55
backend/internal/handler/admin/model_handler.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ModelHandler handles admin model listing requests.
|
||||
type ModelHandler struct {
|
||||
sora2apiService *service.Sora2APIService
|
||||
}
|
||||
|
||||
// NewModelHandler creates a new ModelHandler.
|
||||
func NewModelHandler(sora2apiService *service.Sora2APIService) *ModelHandler {
|
||||
return &ModelHandler{
|
||||
sora2apiService: sora2apiService,
|
||||
}
|
||||
}
|
||||
|
||||
// List handles listing models for a specific platform
|
||||
// GET /api/v1/admin/models?platform=sora
|
||||
func (h *ModelHandler) List(c *gin.Context) {
|
||||
platform := strings.TrimSpace(strings.ToLower(c.Query("platform")))
|
||||
if platform == "" {
|
||||
response.BadRequest(c, "platform is required")
|
||||
return
|
||||
}
|
||||
|
||||
switch platform {
|
||||
case service.PlatformSora:
|
||||
if h.sora2apiService == nil || !h.sora2apiService.Enabled() {
|
||||
response.Error(c, http.StatusServiceUnavailable, "sora2api not configured")
|
||||
return
|
||||
}
|
||||
models, err := h.sora2apiService.ListModels(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "failed to fetch sora models")
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(models))
|
||||
for _, m := range models {
|
||||
if strings.TrimSpace(m.ID) != "" {
|
||||
ids = append(ids, m.ID)
|
||||
}
|
||||
}
|
||||
response.Success(c, ids)
|
||||
default:
|
||||
response.BadRequest(c, "unsupported platform")
|
||||
}
|
||||
}
|
||||
87
backend/internal/handler/admin/model_handler_test.go
Normal file
87
backend/internal/handler/admin/model_handler_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestModelHandlerListSoraSuccess(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"object":"list","data":[{"id":"m1"},{"id":"m2"}]}`))
|
||||
}))
|
||||
t.Cleanup(upstream.Close)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.Sora2API.BaseURL = upstream.URL
|
||||
cfg.Sora2API.APIKey = "test-key"
|
||||
soraService := service.NewSora2APIService(cfg)
|
||||
|
||||
h := NewModelHandler(soraService)
|
||||
router := gin.New()
|
||||
router.GET("/admin/models", h.List)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/models?platform=sora", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var resp response.Response
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("解析响应失败: %v", err)
|
||||
}
|
||||
if resp.Code != 0 {
|
||||
t.Fatalf("响应 code=%d", resp.Code)
|
||||
}
|
||||
data, ok := resp.Data.([]any)
|
||||
if !ok {
|
||||
t.Fatalf("响应 data 类型错误")
|
||||
}
|
||||
if len(data) != 2 {
|
||||
t.Fatalf("模型数量不符: %d", len(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelHandlerListSoraNotConfigured(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewModelHandler(&service.Sora2APIService{})
|
||||
router := gin.New()
|
||||
router.GET("/admin/models", h.List)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/models?platform=sora", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelHandlerListInvalidPlatform(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := NewModelHandler(&service.Sora2APIService{})
|
||||
router := gin.New()
|
||||
router.GET("/admin/models", h.List)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/models?platform=unknown", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user