refactor(platform): 平台白名单由数据库约束改为应用层校验

新增迁移 242 删除 user_platform_quotas_platform_check 与
composite_model_routes_target_platform_check(DROP ... IF EXISTS,可重入)。
此后新增平台只需在平台清单中登记,不再需要改约束的迁移;此前 Grok、国产
厂商、MiniMax、OpenCode 共写过 5 次。

平台合法性改由应用层保证,与原约束的拒绝范围相同:
- user_platform_quotas 用原生 SQL 写入,不经过 ent Validate,repository 在
  BulkInsertInitial / UpsertForUser 写入前校验平台,非法值在发出任何 SQL 前
  返回错误(此前由数据库拒绝并回滚);
- ent schema 的 platform / target_platform 校验改查平台清单,组合路由新增
  target_platform 校验(service 层与 admin 接口的原有校验保留)。

渠道监控的 provider 约束表示已实现的探测能力,不属于平台清单,保留不动。

集成测试(真实 PostgreSQL):在还原旧约束的库上重复执行迁移、约束被移除、
渠道监控约束保留、数据库层可写入未来的新平台值;repository 拒绝未登记平台
且不产生副作用(去掉校验后该测试失败)。全部仓储集成测试通过。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SavitarC
2026-10-09 10:10:33 +08:00
co-authored by Claude Opus 5.5
parent 3b89cc08dd
commit eefa7dd9dc
9 changed files with 234 additions and 12 deletions
+15 -1
View File
@@ -906,7 +906,21 @@ func init() {
// compositemodelroute.DefaultTargetPlatform holds the default value on creation for the target_platform field. // compositemodelroute.DefaultTargetPlatform holds the default value on creation for the target_platform field.
compositemodelroute.DefaultTargetPlatform = compositemodelrouteDescTargetPlatform.Default.(string) compositemodelroute.DefaultTargetPlatform = compositemodelrouteDescTargetPlatform.Default.(string)
// compositemodelroute.TargetPlatformValidator is a validator for the "target_platform" field. It is called by the builders before save. // compositemodelroute.TargetPlatformValidator is a validator for the "target_platform" field. It is called by the builders before save.
compositemodelroute.TargetPlatformValidator = compositemodelrouteDescTargetPlatform.Validators[0].(func(string) error) compositemodelroute.TargetPlatformValidator = func() func(string) error {
validators := compositemodelrouteDescTargetPlatform.Validators
fns := [...]func(string) error{
validators[0].(func(string) error),
validators[1].(func(string) error),
}
return func(target_platform string) error {
for _, fn := range fns {
if err := fn(target_platform); err != nil {
return err
}
}
return nil
}
}()
// compositemodelrouteDescUpstreamModel is the schema descriptor for upstream_model field. // compositemodelrouteDescUpstreamModel is the schema descriptor for upstream_model field.
compositemodelrouteDescUpstreamModel := compositemodelrouteFields[4].Descriptor() compositemodelrouteDescUpstreamModel := compositemodelrouteFields[4].Descriptor()
// compositemodelroute.DefaultUpstreamModel holds the default value on creation for the upstream_model field. // compositemodelroute.DefaultUpstreamModel holds the default value on creation for the upstream_model field.
@@ -1,6 +1,8 @@
package schema package schema
import ( import (
"fmt"
"github.com/Wei-Shaw/sub2api/ent/schema/mixins" "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/internal/domain" "github.com/Wei-Shaw/sub2api/internal/domain"
@@ -45,6 +47,13 @@ func (CompositeModelRoute) Fields() []ent.Field {
field.String("target_platform"). field.String("target_platform").
MaxLen(50). MaxLen(50).
Default(domain.PlatformOpenAI). Default(domain.PlatformOpenAI).
Validate(func(s string) error {
// 目标平台须为平台清单中的具体平台;数据库不再维护 CHECK 约束。
if !domain.IsConcretePlatform(s) {
return fmt.Errorf("target_platform %q is not a concrete platform", s)
}
return nil
}).
Comment("Concrete provider platform."), Comment("Concrete provider platform."),
field.String("upstream_model"). field.String("upstream_model").
MaxLen(200). MaxLen(200).
+5 -7
View File
@@ -11,6 +11,7 @@ import (
"entgo.io/ent/schema/field" "entgo.io/ent/schema/field"
"entgo.io/ent/schema/index" "entgo.io/ent/schema/index"
"github.com/Wei-Shaw/sub2api/ent/schema/mixins" "github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/internal/domain"
) )
// UserPlatformQuota holds the schema definition for per-user per-platform quota. // UserPlatformQuota holds the schema definition for per-user per-platform quota.
@@ -38,15 +39,12 @@ func (UserPlatformQuota) Fields() []ent.Field {
MaxLen(32). MaxLen(32).
NotEmpty(). NotEmpty().
Validate(func(s string) error { Validate(func(s string) error {
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms; // 平台白名单为平台清单(domain/platforms.go)中的全部具体平台,
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。 // 与 service.AllowedQuotaPlatforms 同源;数据库不再维护 CHECK 约束。
switch s { if !domain.IsConcretePlatform(s) {
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "typesafe":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s) return fmt.Errorf("platform %q is not allowed", s)
} }
return nil
}), }),
// 日 / 周 / 月 USD 上限: // 日 / 周 / 月 USD 上限:
+1 -1
View File
@@ -30,7 +30,7 @@ const (
PlatformMiniMax = "minimax" // MiniMax (M 系列) PlatformMiniMax = "minimax" // MiniMax (M 系列)
PlatformTypeSafe = "typesafe" // TypeSafe AI System One (Jev) PlatformTypeSafe = "typesafe" // TypeSafe AI System One (Jev)
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。 // PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。 // 值保持 opencode_go 以兼容已落库的账号、分组、配额与 Composite 路由。
PlatformOpenCodeGo = "opencode_go" PlatformOpenCodeGo = "opencode_go"
PlatformComposite = "composite" PlatformComposite = "composite"
) )
@@ -0,0 +1,143 @@
//go:build integration
package repository
import (
"context"
"testing"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/service"
dbmigrations "github.com/Wei-Shaw/sub2api/migrations"
"github.com/stretchr/testify/require"
)
func platformConstraintExists(t *testing.T, ctx context.Context, client *dbent.Client, table, constraint string) bool {
t.Helper()
rows, err := client.QueryContext(ctx, `
SELECT 1
FROM pg_constraint c
JOIN pg_class tbl ON tbl.oid = c.conrelid
JOIN pg_namespace ns ON ns.oid = tbl.relnamespace
WHERE ns.nspname = 'public' AND tbl.relname = $1 AND c.conname = $2`, table, constraint)
require.NoError(t, err)
defer func() { _ = rows.Close() }()
exists := rows.Next()
require.NoError(t, rows.Err())
return exists
}
// 迁移 242 删除平台白名单 CHECK 约束:旧库(约束仍在)上可重复执行,执行后
// 新登记的平台无需再做数据库迁移即可写入。
func TestMigration242DropsPlatformCheckConstraints(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
client := tx.Client()
// 还原迁移 241_add_typesafe_platform 之后、242 之前的约束状态。
for _, stmt := range []string{
`ALTER TABLE user_platform_quotas DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check`,
`ALTER TABLE user_platform_quotas ADD CONSTRAINT user_platform_quotas_platform_check
CHECK (platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'))`,
`ALTER TABLE composite_model_routes DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check`,
`ALTER TABLE composite_model_routes ADD CONSTRAINT composite_model_routes_target_platform_check
CHECK (target_platform IN ('anthropic', 'openai', 'gemini', 'antigravity', 'grok',
'kimi', 'zhipu', 'deepseek', 'minimax', 'opencode_go', 'typesafe'))`,
} {
_, err := client.ExecContext(ctx, stmt)
require.NoError(t, err)
}
require.True(t, platformConstraintExists(t, ctx, client, "user_platform_quotas", "user_platform_quotas_platform_check"))
require.True(t, platformConstraintExists(t, ctx, client, "composite_model_routes", "composite_model_routes_target_platform_check"))
migrationSQL, err := dbmigrations.FS.ReadFile("242_drop_platform_check_constraints.sql")
require.NoError(t, err)
for range 2 {
_, err = client.ExecContext(ctx, string(migrationSQL))
require.NoError(t, err)
}
require.False(t, platformConstraintExists(t, ctx, client, "user_platform_quotas", "user_platform_quotas_platform_check"))
require.False(t, platformConstraintExists(t, ctx, client, "composite_model_routes", "composite_model_routes_target_platform_check"))
// 渠道监控的 provider 约束表示探测能力,保留。
require.True(t, platformConstraintExists(t, ctx, client, "channel_monitors", "channel_monitors_provider_check"))
require.True(t, platformConstraintExists(t, ctx, client, "channel_monitor_request_templates", "channel_monitor_request_templates_provider_check"))
// 数据库层不再限制平台取值:模拟未来新登记的平台。
userID := mustCreateUserForQuota(t, client)
_, err = client.ExecContext(ctx, `
INSERT INTO user_platform_quotas (user_id, platform, daily_limit_usd, daily_usage_usd, weekly_usage_usd, monthly_usage_usd, created_at, updated_at)
VALUES ($1, 'future_platform', 1, 0, 0, 0, NOW(), NOW())`, userID)
require.NoError(t, err)
group := mustCreateGroup(t, client, &service.Group{Name: "platform-list-composite", Platform: service.PlatformComposite})
_, err = client.ExecContext(ctx, `
INSERT INTO composite_model_routes (group_id, public_model, target_platform)
VALUES ($1, 'future-model', 'future_platform')`, group.ID)
require.NoError(t, err)
}
// 删除 CHECK 约束后,平台合法性由应用层保证:原生 SQL 写入前的 repository 校验。
func TestUserPlatformQuotaRepository_RejectsUnregisteredPlatform(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
userID := mustCreateUserForQuota(t, client)
repo := NewUserPlatformQuotaRepository(client)
daily := 5.0
require.NoError(t, repo.UpsertForUser(txCtx, userID, []UserPlatformQuotaRecord{
{UserID: userID, Platform: service.PlatformOpenCodeGo, DailyLimitUSD: &daily},
}))
for _, platform := range []string{"bogus", "moonshot", "Kimi", service.PlatformComposite} {
err := repo.BulkInsertInitial(txCtx, []UserPlatformQuotaRecord{
{UserID: userID, Platform: service.PlatformAnthropic, DailyLimitUSD: &daily},
{UserID: userID, Platform: platform, DailyLimitUSD: &daily},
})
require.ErrorContains(t, err, "is not allowed", platform)
err = repo.UpsertForUser(txCtx, userID, []UserPlatformQuotaRecord{
{UserID: userID, Platform: platform, DailyLimitUSD: &daily},
})
require.ErrorContains(t, err, "is not allowed", platform)
}
// 被拒绝的写入不产生任何副作用:既有记录保留、未新增任何行。
got, err := repo.ListByUser(txCtx, userID)
require.NoError(t, err)
require.Len(t, got, 1)
require.Equal(t, service.PlatformOpenCodeGo, got[0].Platform)
// 三档全空的记录不写入,也不参与校验(与既有 configuredRecords 语义一致)。
require.NoError(t, repo.BulkInsertInitial(txCtx, []UserPlatformQuotaRecord{{UserID: userID, Platform: "bogus"}}))
}
func TestCompositeModelRouteRepository_RejectsUnregisteredTargetPlatform(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
txCtx := dbent.NewTxContext(ctx, tx)
client := tx.Client()
group := mustCreateGroup(t, client, &service.Group{Name: "platform-list-routes", Platform: service.PlatformComposite})
repo := NewCompositeModelRouteRepository(client)
route := &service.CompositeModelRoute{
GroupID: group.ID, PublicModel: "glm-5", MatchType: "exact", TargetPlatform: service.PlatformZhipu,
UpstreamModel: "glm-5", Endpoint: "any", Priority: 100, Enabled: true,
}
require.NoError(t, repo.Create(txCtx, route))
bad := &service.CompositeModelRoute{
GroupID: group.ID, PublicModel: "bogus-model", MatchType: "exact", TargetPlatform: "bogus",
UpstreamModel: "bogus-model", Endpoint: "any", Priority: 100, Enabled: true,
}
require.Error(t, repo.Create(txCtx, bad))
route.TargetPlatform = service.PlatformComposite
require.Error(t, repo.Update(txCtx, route))
routes, err := repo.ListByGroup(txCtx, group.ID, true)
require.NoError(t, err)
require.Len(t, routes, 1)
require.Equal(t, service.PlatformZhipu, routes[0].TargetPlatform)
}
@@ -9,6 +9,7 @@ import (
dbent "github.com/Wei-Shaw/sub2api/ent" dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/userplatformquota" "github.com/Wei-Shaw/sub2api/ent/userplatformquota"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone" "github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
) )
@@ -95,6 +96,9 @@ func (r *userPlatformQuotaRepository) BulkInsertInitial(ctx context.Context, rec
if len(records) == 0 { if len(records) == 0 {
return nil return nil
} }
if err := validateQuotaPlatforms(records); err != nil {
return err
}
client := clientFromContext(ctx, r.client) client := clientFromContext(ctx, r.client)
@@ -318,6 +322,9 @@ func monthlyMaybeReset(prevUsage float64, prevStart *time.Time, cost float64, no
// 清空某平台的限额即软删该行并放弃其累计用量;之后再次配置限额会新建行、从新窗口起算。 // 清空某平台的限额即软删该行并放弃其累计用量;之后再次配置限额会新建行、从新窗口起算。
func (r *userPlatformQuotaRepository) UpsertForUser(ctx context.Context, userID int64, records []UserPlatformQuotaRecord) error { func (r *userPlatformQuotaRepository) UpsertForUser(ctx context.Context, userID int64, records []UserPlatformQuotaRecord) error {
records = configuredRecords(records) records = configuredRecords(records)
if err := validateQuotaPlatforms(records); err != nil {
return err
}
return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error { return r.withTx(ctx, func(txCtx context.Context, txClient *dbent.Client) error {
platforms := make([]string, 0, len(records)) platforms := make([]string, 0, len(records))
for _, rec := range records { for _, rec := range records {
@@ -484,6 +491,17 @@ func (r *userPlatformQuotaRepository) BatchSnapshotUsage(ctx context.Context, sn
return nil return nil
} }
// validateQuotaPlatforms 在原生 SQL 写入前校验平台须为平台清单中的具体平台,
// 取代迁移 242 删除的 user_platform_quotas_platform_check 约束(原生 SQL 不经过 ent Validate)。
func validateQuotaPlatforms(records []UserPlatformQuotaRecord) error {
for _, rec := range records {
if !domain.IsConcretePlatform(rec.Platform) {
return fmt.Errorf("user_platform_quotas: platform %q is not allowed", rec.Platform)
}
}
return nil
}
// configuredRecords 过滤出至少配置了一档限额的记录;三档全空的记录视为未配置。 // configuredRecords 过滤出至少配置了一档限额的记录;三档全空的记录视为未配置。
func configuredRecords(records []UserPlatformQuotaRecord) []UserPlatformQuotaRecord { func configuredRecords(records []UserPlatformQuotaRecord) []UserPlatformQuotaRecord {
out := make([]UserPlatformQuotaRecord, 0, len(records)) out := make([]UserPlatformQuotaRecord, 0, len(records))
+3 -3
View File
@@ -1937,9 +1937,9 @@ func (s *AuthService) snapshotPlatformQuotaDefaults(ctx context.Context, userID
return nil return nil
} }
// 平台配额快照是 best-effort(fail-open):必须脱离调用方事务执行。 // 平台配额快照是 best-effort(fail-open):必须脱离调用方事务执行。
// 否则某平台违反 user_platform_quotas 的 CHECK 约束(如尚未进约束的新平台)会让 // 否则写入时的任何数据库错误(历史上是新平台尚未加入 user_platform_quotas 的
// 整个调用方事务被 Postgres 标记 aborted,把"无关紧要的默认配额快照"放大成 // CHECK 约束)都会让整个调用方事务被 Postgres 标记 aborted,把"无关紧要的默认
// "整笔注册失败"(OAuth pending 路径曾因此 500 → 清 cookie → 404)。 // 配额快照"放大成"整笔注册失败"(OAuth pending 路径曾因此 500 → 清 cookie → 404)。
ctx = dbent.WithoutTx(ctx) ctx = dbent.WithoutTx(ctx)
// 仅为至少配置了一档限额的平台建行:user_platform_quotas 中不存在的行等价于不限额, // 仅为至少配置了一档限额的平台建行:user_platform_quotas 中不存在的行等价于不限额,
// 三档全空的记录不携带任何可执行的限额。 // 三档全空的记录不携带任何可执行的限额。
@@ -0,0 +1,18 @@
-- 平台白名单改由应用层的平台清单(internal/domain/platforms.go)统一校验,
-- 新增平台不再需要修改数据库约束的迁移。
--
-- 1. user_platform_quotas.platform:admin 接口 / 设置校验 service.AllowedQuotaPlatforms,
-- repository 写入前校验 domain.IsConcretePlatform,ent schema Validate 同源。
-- 2. composite_model_routes.target_platform:admin 接口 binding、service
-- compositeRouteFromInput 与 ent schema Validate 均校验为已登记的具体平台。
--
-- channel_monitors / channel_monitor_request_templates 的 provider CHECK 表示
-- 渠道监控已实现的探测能力,不属于平台清单,保留不动。
--
-- DROP ... IF EXISTS 保证可重入。
ALTER TABLE user_platform_quotas
DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check;
ALTER TABLE composite_model_routes
DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check;
@@ -0,0 +1,22 @@
package migrations
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestDropPlatformCheckConstraintsMigration(t *testing.T) {
content, err := FS.ReadFile("242_drop_platform_check_constraints.sql")
require.NoError(t, err)
sql := strings.Join(strings.Fields(string(content)), " ")
require.Contains(t, sql,
"ALTER TABLE user_platform_quotas DROP CONSTRAINT IF EXISTS user_platform_quotas_platform_check;")
require.Contains(t, sql,
"ALTER TABLE composite_model_routes DROP CONSTRAINT IF EXISTS composite_model_routes_target_platform_check;")
require.NotContains(t, sql, "ADD CONSTRAINT")
require.NotContains(t, sql, "DROP CONSTRAINT IF EXISTS channel_monitors_provider_check")
require.NotContains(t, sql, "DROP CONSTRAINT IF EXISTS channel_monitor_request_templates_provider_check")
}