diff --git a/backend/internal/http/handler/user_generation.go b/backend/internal/http/handler/user_generation.go index 63d207b..4d4c70e 100644 --- a/backend/internal/http/handler/user_generation.go +++ b/backend/internal/http/handler/user_generation.go @@ -130,6 +130,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) { Resolution string `json:"resolution"` Duration string `json:"duration"` ReferenceImages []string `json:"reference_images"` + AccountID string `json:"account_id"` } if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"detail": "invalid request body"}) @@ -143,6 +144,7 @@ func (h *UserGenerationHandler) Test(c *gin.Context) { Resolution: body.Resolution, Duration: body.Duration, ReferenceImages: body.ReferenceImages, + AccountID: body.AccountID, }) if err != nil { switch { diff --git a/backend/internal/service/user_generation.go b/backend/internal/service/user_generation.go index ef65163..2ab24ae 100644 --- a/backend/internal/service/user_generation.go +++ b/backend/internal/service/user_generation.go @@ -33,6 +33,8 @@ type UserGenerateRequest struct { Resolution string Duration string ReferenceImages []string + // AccountID pins an admin test to one specific provider account (账号生图测试). + AccountID string } func (s *UserGenerationService) Generate(ctx context.Context, user *model.User, in UserGenerateRequest) (map[string]any, error) { @@ -102,6 +104,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User, AspectRatio: in.Ratio, Resolution: in.Resolution, ReferenceImages: in.ReferenceImages, + AccountID: in.AccountID, }) default: return s.v1.prepareAdminTestImage(ctx, principal, V1ImageRequest{ @@ -110,6 +113,7 @@ func (s *UserGenerationService) AdminTest(ctx context.Context, user *model.User, AspectRatio: in.Ratio, Resolution: in.Resolution, ReferenceImages: in.ReferenceImages, + AccountID: in.AccountID, }) } } diff --git a/backend/internal/service/v1.go b/backend/internal/service/v1.go index f8429f0..8bfba0a 100644 --- a/backend/internal/service/v1.go +++ b/backend/internal/service/v1.go @@ -203,6 +203,9 @@ type V1ImageRequest struct { // used to build absolute, directly-downloadable output URLs. Empty falls // back to a relative "/images/..." path. BaseURL string + // AccountID pins the generation to one specific provider account (admin + // account-test). Empty keeps the normal pool selection with failover. + AccountID string } type V1VideoRequest struct { @@ -214,6 +217,8 @@ type V1VideoRequest struct { ReferenceImages []string // BaseURL — see V1ImageRequest.BaseURL. BaseURL string + // AccountID — see V1ImageRequest.AccountID. + AccountID string } func NewV1Service(cfg *config.Config, models *repo.ModelRepository, users *repo.UserRepository, events *repo.EventRepository, tokens *repo.TokenRepository, settings *repo.SiteSettingRepository, cgroups *repo.ConcurrencyGroupRepository, conc *ConcurrencyService, adobeClient *adobe.Client, chatGPTClient *chatgpt.Client, runwayClient *runway.Client, leonardoClient *leonardo.Client, kreaClient *krea.Client, imagineClient *imagine.Client, grokClient *grok.Client, customClient *custom.Client, store *storage.Client) *V1Service { @@ -1547,6 +1552,7 @@ func (s *V1Service) generateAdobeImage(ctx context.Context, eventID string, mode active = append(active, item) } } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, ErrNoProviderAccount } @@ -1602,6 +1608,7 @@ func (s *V1Service) generateAdobeVideo(ctx context.Context, eventID string, mode active = append(active, item) } } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, "", ErrNoProviderAccount } @@ -1686,6 +1693,7 @@ func (s *V1Service) generateRunwayVideo(ctx context.Context, eventID string, mod } active = append(active, item) } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, "", ErrNoProviderAccount } @@ -1827,6 +1835,7 @@ func (s *V1Service) generateCustomImage(ctx context.Context, eventID string, mod if err != nil { return nil, err } + active = pinTestAccount(active, active, in.AccountID) if len(active) == 0 { return nil, ErrNoProviderAccount } @@ -1894,6 +1903,7 @@ func (s *V1Service) generateCustomVideo(ctx context.Context, eventID string, mod if err != nil { return nil, "", err } + active = pinTestAccount(active, active, in.AccountID) if len(active) == 0 { return nil, "", ErrNoProviderAccount } @@ -2058,6 +2068,7 @@ func (s *V1Service) generateGrokVideo(ctx context.Context, eventID string, model } active = append(active, item) } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, "", ErrNoProviderAccount } @@ -2161,6 +2172,7 @@ func (s *V1Service) generateRunwayImage(ctx context.Context, eventID string, mod } active = append(active, item) } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, ErrNoProviderAccount } @@ -2281,6 +2293,7 @@ func (s *V1Service) generateChatGPTImage(ctx context.Context, eventID string, mo active = append(active, item) } } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, ErrNoProviderAccount } @@ -2387,6 +2400,7 @@ func (s *V1Service) generateLeonardoImage(ctx context.Context, eventID string, m } active = append(active, item) } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, ErrNoProviderAccount } @@ -2538,6 +2552,7 @@ func (s *V1Service) generateKreaImage(ctx context.Context, eventID string, model active = append(active, item) } } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, ErrNoProviderAccount } @@ -2614,6 +2629,7 @@ func (s *V1Service) generateImagineImage(ctx context.Context, eventID string, mo active = append(active, item) } } + active = pinTestAccount(items, active, in.AccountID) if len(active) == 0 { return nil, ErrNoProviderAccount } @@ -3110,6 +3126,23 @@ func (s *V1Service) nextCursor(pool string) uint64 { // cycles in fixed order regardless of fails or last_used. The fall-through // retry chain is preserved — on failure the caller's loop simply continues to // the next account in rotation order. +// pinTestAccount narrows account selection to the single account requested by +// an admin 账号生图测试. The pinned account is taken from the pool's full list +// (bypassing active/dead/limited filters) so a limited or disabled account can +// still be probed. Returns nil when the account isn't in this pool. +func pinTestAccount(items, active []model.TokenAccount, accountID string) []model.TokenAccount { + id := strings.TrimSpace(accountID) + if id == "" { + return active + } + for _, item := range items { + if item.ID == id && strings.TrimSpace(item.Value) != "" { + return []model.TokenAccount{item} + } + } + return nil +} + func (s *V1Service) rotateRoundRobin(pool string, items []model.TokenAccount) { if len(items) <= 1 { return diff --git a/frontend/src/components/AccountTestModal.vue b/frontend/src/components/AccountTestModal.vue new file mode 100644 index 0000000..bc4b473 --- /dev/null +++ b/frontend/src/components/AccountTestModal.vue @@ -0,0 +1,168 @@ + + + + + diff --git a/frontend/src/views/AccountsView.vue b/frontend/src/views/AccountsView.vue index 6b0d344..daae2dc 100644 --- a/frontend/src/views/AccountsView.vue +++ b/frontend/src/views/AccountsView.vue @@ -5,6 +5,7 @@ import { fmtTs, fmtIso, fmtDate, fmtClock } from '../utils/format' import ImportModal from '../components/ImportModal.vue' import UpstreamModal from '../components/UpstreamModal.vue' import AccountEditModal from '../components/AccountEditModal.vue' +import AccountTestModal from '../components/AccountTestModal.vue' import Icon from '../components/Icon.vue' const rows = ref([]) @@ -16,6 +17,14 @@ const editingUpstream = ref(null) function editUpstream(a) { editingUpstream.value = a; showUpstream.value = true } const editingAccount = ref(null) function editAccount(a) { editingAccount.value = a } +const testingAccount = ref(null) +function testAccount(a) { testingAccount.value = a } +// 预加载模型列表,让「生图测试」弹窗即开即用(不显示加载中)。 +const allModels = ref([]) +async function loadModelList() { + const r = await api('/managed-models') + allModels.value = r.data?.data || [] +} // Reflect the saved values in the table without a full reload. function applyEdit(payload) { const row = editingAccount.value @@ -299,7 +308,7 @@ async function deleteSelected() { } } -onMounted(loadAccounts) +onMounted(() => { loadAccounts(); loadModelList() })