Skip to content

Commit c6ce8af

Browse files
committed
feat(account): OpenAI Free 通用基础池 + 个人账号模型白名单严格化
- OpenAI「Free」共享号池改为通用基础池:接受同平台任意等级及未识别等级 的账号,分组查询保留平台谓词、去掉等级谓词;其他等级池仍精确匹配, Grok 的 Free/Heavy 严格匹配不变。 - 个人账号 model_mapping 收敛为「服务端当前定价目录内的非空精确集合」, 校验时机移到所有业务字段校验之后、重复/容量检查与写入之前。 - 凭证文件导入没有模型选择步骤,按当前活跃渠道定价并集生成同名白名单, 不使用平台预置模型全集兜底,也不覆盖已有账号的白名单。 - 用户端「测试连接」可选模型列表严格遵守号主白名单,空白名单返回空集合。 - OAuth 授权码兑换成功后立即清理输入与会话,避免创建失败时重复兑换同一个 一次性 code 导致上游 502;兑换前重新拉取服务端模型目录并校验,失效模型 明确报错而非静默裁剪。 - OAuth 失败提示统一走 extractApiErrorMessage,修正错误对象结构不符导致的 空白原因。
1 parent dc9f14c commit c6ce8af

18 files changed

Lines changed: 464 additions & 49 deletions

backend/internal/handler/admin/account_handler_available_models_test.go

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,16 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
3535
}
3636

3737
func TestAccountHandlerGetAvailableModels_OpenAIOAuthUsesExplicitModelMapping(t *testing.T) {
38+
ownerUserID := int64(7)
3839
svc := &availableModelsAdminService{
3940
stubAdminService: newStubAdminService(),
4041
account: service.Account{
41-
ID: 42,
42-
Name: "openai-oauth",
43-
Platform: service.PlatformOpenAI,
44-
Type: service.AccountTypeOAuth,
45-
Status: service.StatusActive,
42+
ID: 42,
43+
Name: "openai-oauth",
44+
Platform: service.PlatformOpenAI,
45+
Type: service.AccountTypeOAuth,
46+
Status: service.StatusActive,
47+
OwnerUserID: &ownerUserID,
4648
Credentials: map[string]any{
4749
"model_mapping": map[string]any{
4850
"gpt-5": "gpt-5.1",
@@ -196,6 +198,36 @@ func TestAccountHandlerGetAvailableModels_GeminiGoogleOneRespectsMappingPreceden
196198
}
197199
}
198200

201+
func TestAccountHandlerGetAvailableModels_OwnedOpenAIEmptyWhitelistReturnsNoModels(t *testing.T) {
202+
ownerUserID := int64(7)
203+
svc := &availableModelsAdminService{
204+
stubAdminService: newStubAdminService(),
205+
account: service.Account{
206+
ID: 46,
207+
Name: "openai-owned-legacy",
208+
Platform: service.PlatformOpenAI,
209+
Type: service.AccountTypeOAuth,
210+
Status: service.StatusActive,
211+
OwnerUserID: &ownerUserID,
212+
Credentials: map[string]any{"model_mapping": map[string]any{}},
213+
},
214+
}
215+
router := setupAvailableModelsRouter(svc)
216+
217+
rec := httptest.NewRecorder()
218+
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil)
219+
router.ServeHTTP(rec, req)
220+
221+
require.Equal(t, http.StatusOK, rec.Code)
222+
var resp struct {
223+
Data []struct {
224+
ID string `json:"id"`
225+
} `json:"data"`
226+
}
227+
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
228+
require.Empty(t, resp.Data)
229+
}
230+
199231
func TestAccountHandlerGetAvailableModels_RejectsUnsupportedPlatform(t *testing.T) {
200232
svc := &availableModelsAdminService{
201233
stubAdminService: newStubAdminService(),

backend/internal/handler/user_account_handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2274,7 +2274,7 @@ func (h *UserAccountHandler) Test(c *gin.Context) {
22742274

22752275
// GetAvailableModels handles getting available models for a user-owned account.
22762276
// GET /api/v1/accounts/:id/models
2277-
// 复用 service.AvailableTestModels,与管理员端「测试连接」模型列表保持同一口径
2277+
// 复用 service.AvailableTestModels,用户端严格遵守个人账号模型白名单
22782278
func (h *UserAccountHandler) GetAvailableModels(c *gin.Context) {
22792279
subject, ok := middleware2.GetAuthSubjectFromContext(c)
22802280
if !ok {

backend/internal/repository/account_repo.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4935,10 +4935,12 @@ func (r *accountRepository) queryAccountsByGroup(ctx context.Context, groupID in
49354935
requiredLevel := service.NormalizeRequiredAccountLevel(group.RequiredAccountLevel)
49364936
if group.Platform == service.PlatformOpenAI && requiredLevel != "" {
49374937
allowedLevels := service.OpenAISharedPoolAllowedAccountLevels(requiredLevel)
4938-
if len(allowedLevels) == 0 {
4939-
return []service.Account{}, nil
4938+
// The OpenAI Free pool is level-unrestricted but remains platform-bound.
4939+
// Strict pools add their exact allowed-level predicate below.
4940+
preds = append(preds, dbaccount.PlatformEQ(service.PlatformOpenAI))
4941+
if len(allowedLevels) > 0 {
4942+
preds = append(preds, dbaccount.AccountLevelIn(allowedLevels...))
49404943
}
4941-
preds = append(preds, dbaccount.PlatformEQ(service.PlatformOpenAI), dbaccount.AccountLevelIn(allowedLevels...))
49424944
} else if group.Platform == service.PlatformGrok && requiredLevel != "" {
49434945
if !service.IsUserSelectableGrokAccountLevel(requiredLevel) {
49444946
return []service.Account{}, nil

backend/internal/service/account.go

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,15 @@ func NormalizeOpenAISharedPoolRequiredLevel(level string) string {
344344
return NormalizeRequiredAccountLevel(level)
345345
}
346346

347+
// IsOpenAISharedPoolLevelUnrestricted reports whether an OpenAI shared pool
348+
// accepts every account level. The Free pool is the platform's common base
349+
// pool: accounts with a detected paid/custom level and accounts whose level is
350+
// unknown may all use it. Other non-empty level pools remain exact-match only.
351+
func IsOpenAISharedPoolLevelUnrestricted(requiredLevel string) bool {
352+
required := NormalizeOpenAISharedPoolRequiredLevel(requiredLevel)
353+
return required == "" || required == AccountLevelFree
354+
}
355+
347356
func OpenAISharedPoolLevelRank(level string) int {
348357
return OpenAISharedPoolLevelRankWithConfigs(level, DefaultOpenAIAccountLevelConfigs())
349358
}
@@ -364,7 +373,7 @@ func CanOpenAIAccountJoinSharedPool(accountLevel, requiredLevel string) bool {
364373

365374
func CanOpenAIAccountJoinSharedPoolWithConfigs(accountLevel, requiredLevel string, configs []OpenAIAccountLevelConfig) bool {
366375
required := NormalizeOpenAISharedPoolRequiredLevel(requiredLevel)
367-
if required == "" {
376+
if IsOpenAISharedPoolLevelUnrestricted(required) {
368377
return true
369378
}
370379
account := NormalizeOpenAISharedPoolAccountLevel(accountLevel)
@@ -383,7 +392,10 @@ func OpenAISharedPoolAllowedAccountLevels(requiredLevel string) []string {
383392

384393
func OpenAISharedPoolAllowedAccountLevelsWithConfigs(requiredLevel string, configs []OpenAIAccountLevelConfig) []string {
385394
required := NormalizeOpenAISharedPoolRequiredLevel(requiredLevel)
386-
if required == "" {
395+
// nil means that the pool has no account-level predicate. Callers must still
396+
// apply the OpenAI platform predicate so cross-platform stale bindings cannot
397+
// enter scheduling.
398+
if IsOpenAISharedPoolLevelUnrestricted(required) {
387399
return nil
388400
}
389401
normalizedConfigs := NormalizeOpenAIAccountLevelConfigs(configs)
@@ -392,9 +404,6 @@ func OpenAISharedPoolAllowedAccountLevelsWithConfigs(requiredLevel string, confi
392404
return []string{required}
393405
}
394406
levels := make([]string, 0, 6)
395-
if required == AccountLevelFree {
396-
levels = append(levels, AccountLevelUnknown)
397-
}
398407
for _, cfg := range normalizedConfigs {
399408
if cfg.Enabled && CanOpenAIAccountJoinSharedPoolWithConfigs(cfg.Key, required, normalizedConfigs) {
400409
levels = append(levels, cfg.Key)

backend/internal/service/account_available_models.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ func accountModelWhitelistKeys(account *Account) []string {
2323
return models
2424
}
2525

26-
// AvailableTestModels 返回账号「测试连接」流程可选的模型列表,管理员端与用户端共用。
26+
// AvailableTestModels 返回账号「测试连接」流程可选的模型列表。
27+
// 对个人账号严格使用号主白名单,空白名单必须返回空集合。
2728
//
2829
// 返回值是各平台各自的模型切片类型(openai.Model / geminicli.Model / xai.Model /
2930
// claude.Model / antigravity.ClaudeModel),直接 JSON 序列化给前端即可——前端只依赖

backend/internal/service/account_service.go

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1011,6 +1011,16 @@ func (s *AccountService) ImportOwnedWithResult(ctx context.Context, ownerUserID
10111011
return nil, err
10121012
}
10131013
if !IsOpenAIAgentIdentityCredentials(req.Credentials) {
1014+
// 凭证文件导入没有单独的模型选择步骤。新建账号时若调用方未携带
1015+
// model_mapping,使用同一份活跃渠道定价并集生成 identity 白名单;
1016+
// 这不是平台默认模型兜底,且不会覆盖已有账号的白名单。
1017+
if !IsOpenAIPersonalAccessTokenCredentials(req.Credentials) {
1018+
credentials, err := s.ensureOwnedImportModelMapping(ctx, req.Platform, req.Credentials)
1019+
if err != nil {
1020+
return nil, err
1021+
}
1022+
req.Credentials = credentials
1023+
}
10141024
account, err := s.createOwned(ctx, ownerUserID, req, false)
10151025
if err != nil {
10161026
return nil, err
@@ -1040,6 +1050,9 @@ func (s *AccountService) ImportOwnedWithResult(ctx context.Context, ownerUserID
10401050
return &OwnedAccountImportResult{Account: account, Updated: true}, nil
10411051
}
10421052

1053+
if req.Credentials, err = s.ensureOwnedImportModelMapping(ctx, req.Platform, req.Credentials); err != nil {
1054+
return nil, err
1055+
}
10431056
account, err := s.createOwned(ctx, ownerUserID, req, false)
10441057
if err == nil {
10451058
return &OwnedAccountImportResult{Account: account}, nil
@@ -1103,6 +1116,9 @@ func (s *AccountService) ImportOwnedValidatedPersonalAccessTokenWithResult(
11031116
return &OwnedAccountImportResult{Account: account, Updated: true}, nil
11041117
}
11051118

1119+
if req.Credentials, err = s.ensureOwnedImportModelMapping(ctx, req.Platform, req.Credentials); err != nil {
1120+
return nil, err
1121+
}
11061122
account, err := s.createOwned(ctx, ownerUserID, req, true)
11071123
if err == nil {
11081124
return &OwnedAccountImportResult{Account: account}, nil
@@ -1358,7 +1374,6 @@ func (s *AccountService) createOwned(ctx context.Context, ownerUserID int64, req
13581374
if isAgentIdentity {
13591375
req.Credentials = normalizeOwnedAgentIdentityCredentials(req.Credentials)
13601376
}
1361-
_, modelMappingProvided := req.Credentials["model_mapping"]
13621377
targetLevel := NormalizeAccountLevel(req.AccountLevel)
13631378
levelConfigs, err := s.openAIAccountLevelConfigs(ctx)
13641379
if err != nil {
@@ -1369,11 +1384,6 @@ func (s *AccountService) createOwned(ctx context.Context, ownerUserID int64, req
13691384
if err := applyOwnedPersonalAccountTemplateToCreate(&req); err != nil {
13701385
return nil, err
13711386
}
1372-
if modelMappingProvided {
1373-
if err := s.validateOwnedPersonalModelMapping(ctx, req.Platform, req.Credentials); err != nil {
1374-
return nil, err
1375-
}
1376-
}
13771387
if err := validateOwnedAccountSourceForPlatform(req.Platform, req.Type, req.Credentials, req.Extra); err != nil {
13781388
return nil, err
13791389
}
@@ -1438,6 +1448,12 @@ func (s *AccountService) createOwned(ctx context.Context, ownerUserID int64, req
14381448
if err != nil {
14391449
return nil, err
14401450
}
1451+
// 个人账号只能使用非空、精确的服务端定价白名单。放在所有业务字段
1452+
// 校验之后、任何重复/容量检查和写入之前,既保持错误优先级,也确保
1453+
// 无效模型不会触发后续副作用。
1454+
if err := s.validateOwnedPersonalModelMapping(ctx, req.Platform, req.Credentials); err != nil {
1455+
return nil, err
1456+
}
14411457

14421458
account := &Account{
14431459
Name: req.Name,
@@ -1899,6 +1915,40 @@ func (s *AccountService) listOwnedSelectableModelIDs(ctx context.Context, platfo
18991915
return result, nil
19001916
}
19011917

1918+
// ensureOwnedImportModelMapping supplies the compatibility mapping for a
1919+
// credential-file import that does not have a UI model-selection step. The
1920+
// generated mapping is always an identity whitelist from the current active
1921+
// channel pricing union; platform DefaultModels are intentionally never used.
1922+
func (s *AccountService) ensureOwnedImportModelMapping(
1923+
ctx context.Context,
1924+
platform string,
1925+
credentials map[string]any,
1926+
) (map[string]any, error) {
1927+
next := mergeAccountMap(credentials, nil)
1928+
if next == nil {
1929+
next = make(map[string]any)
1930+
}
1931+
if _, exists := next["model_mapping"]; exists {
1932+
return next, nil
1933+
}
1934+
models, err := s.listOwnedSelectableModelIDs(ctx, platform)
1935+
if err != nil {
1936+
return nil, err
1937+
}
1938+
if len(models) == 0 {
1939+
return nil, ErrOwnedAccountModelMappingInvalid.WithMetadata(map[string]string{
1940+
"field": "model_mapping",
1941+
"platform": strings.ToLower(strings.TrimSpace(platform)),
1942+
})
1943+
}
1944+
mapping := make(map[string]any, len(models))
1945+
for _, model := range models {
1946+
mapping[model] = model
1947+
}
1948+
next["model_mapping"] = mapping
1949+
return next, nil
1950+
}
1951+
19021952
// ListOwnedSelectableModelIDs 返回号主可用于个人账号白名单的精确模型集合。
19031953
// 它只暴露模型 ID,不泄露渠道、价格或分组配置。
19041954
func (s *AccountService) ListOwnedSelectableModelIDs(ctx context.Context, platform string) ([]string, error) {

backend/internal/service/account_service_owned_agent_identity_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,9 @@ func newOwnedAgentIdentityService(repo *ownedAgentIdentityRepoStub) (*AccountSer
306306
accountShareModeRepo: placementRepo,
307307
accountShareRoomRepo: placementRepo,
308308
agentIdentityWSInvalidator: invalidator,
309+
pricedModelCatalog: &ownedPricedModelCatalogStub{modelsByPlatform: map[string][]string{
310+
PlatformOpenAI: {"test-model"},
311+
}},
309312
}, invalidator
310313
}
311314

@@ -413,6 +416,7 @@ func TestAccountServiceImportOwnedAgentIdentityCreatesPrivateOwnedAccount(t *tes
413416
require.Nil(t, result.Account.ProxyID)
414417
require.Nil(t, result.Account.ExpiresAt)
415418
require.Equal(t, []int64{ownedAgentIdentityPrivateGroupID}, result.Account.GroupIDs)
419+
require.Equal(t, map[string]any{"test-model": "test-model"}, result.Account.Credentials["model_mapping"])
416420
require.Empty(t, invalidator.accountIDs)
417421
}
418422

0 commit comments

Comments
 (0)