feat: 支持用户专属分组倍率配置
This commit is contained in:
@@ -59,8 +59,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig)
|
billingCacheService := service.NewBillingCacheService(billingCache, userRepository, userSubscriptionRepository, configConfig)
|
||||||
apiKeyRepository := repository.NewAPIKeyRepository(client)
|
apiKeyRepository := repository.NewAPIKeyRepository(client)
|
||||||
groupRepository := repository.NewGroupRepository(client, db)
|
groupRepository := repository.NewGroupRepository(client, db)
|
||||||
|
userGroupRateRepository := repository.NewUserGroupRateRepository(db)
|
||||||
apiKeyCache := repository.NewAPIKeyCache(redisClient)
|
apiKeyCache := repository.NewAPIKeyCache(redisClient)
|
||||||
apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, apiKeyCache, configConfig)
|
apiKeyService := service.NewAPIKeyService(apiKeyRepository, userRepository, groupRepository, userSubscriptionRepository, userGroupRateRepository, apiKeyCache, configConfig)
|
||||||
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
|
apiKeyAuthCacheInvalidator := service.ProvideAPIKeyAuthCacheInvalidator(apiKeyService)
|
||||||
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
promoService := service.NewPromoService(promoCodeRepository, userRepository, billingCacheService, client, apiKeyAuthCacheInvalidator)
|
||||||
authService := service.NewAuthService(userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService)
|
authService := service.NewAuthService(userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService)
|
||||||
@@ -100,7 +101,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
proxyRepository := repository.NewProxyRepository(client, db)
|
proxyRepository := repository.NewProxyRepository(client, db)
|
||||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||||
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator)
|
adminService := service.NewAdminService(userRepository, groupRepository, accountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator)
|
||||||
adminUserHandler := admin.NewUserHandler(adminService)
|
adminUserHandler := admin.NewUserHandler(adminService)
|
||||||
groupHandler := admin.NewGroupHandler(adminService)
|
groupHandler := admin.NewGroupHandler(adminService)
|
||||||
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
claudeOAuthClient := repository.NewClaudeOAuthClient()
|
||||||
@@ -153,7 +154,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
|||||||
identityService := service.NewIdentityService(identityCache)
|
identityService := service.NewIdentityService(identityCache)
|
||||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||||
claudeTokenProvider := service.NewClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService)
|
claudeTokenProvider := service.NewClaudeTokenProvider(accountRepository, geminiTokenCache, oAuthService)
|
||||||
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache)
|
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache)
|
||||||
openAITokenProvider := service.NewOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService)
|
openAITokenProvider := service.NewOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService)
|
||||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider)
|
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider)
|
||||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, groupRepository, gatewayCache, schedulerSnapshotService, geminiTokenProvider, rateLimitService, httpUpstream, antigravityGatewayService, configConfig)
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
|
|||||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||||
|
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||||
@@ -203,6 +205,8 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
|||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||||
|
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||||
@@ -230,6 +234,8 @@ github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkr
|
|||||||
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
@@ -252,6 +258,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
|||||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||||
|
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ type UpdateUserRequest struct {
|
|||||||
Concurrency *int `json:"concurrency"`
|
Concurrency *int `json:"concurrency"`
|
||||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||||
AllowedGroups *[]int64 `json:"allowed_groups"`
|
AllowedGroups *[]int64 `json:"allowed_groups"`
|
||||||
|
// GroupRates 用户专属分组倍率配置
|
||||||
|
// map[groupID]*rate,nil 表示删除该分组的专属倍率
|
||||||
|
GroupRates map[int64]*float64 `json:"group_rates"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateBalanceRequest represents balance update request
|
// UpdateBalanceRequest represents balance update request
|
||||||
@@ -183,6 +186,7 @@ func (h *UserHandler) Update(c *gin.Context) {
|
|||||||
Concurrency: req.Concurrency,
|
Concurrency: req.Concurrency,
|
||||||
Status: req.Status,
|
Status: req.Status,
|
||||||
AllowedGroups: req.AllowedGroups,
|
AllowedGroups: req.AllowedGroups,
|
||||||
|
GroupRates: req.GroupRates,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ErrorFrom(c, err)
|
response.ErrorFrom(c, err)
|
||||||
|
|||||||
@@ -243,3 +243,21 @@ func (h *APIKeyHandler) GetAvailableGroups(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
response.Success(c, out)
|
response.Success(c, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserGroupRates 获取当前用户的专属分组倍率配置
|
||||||
|
// GET /api/v1/groups/rates
|
||||||
|
func (h *APIKeyHandler) GetUserGroupRates(c *gin.Context) {
|
||||||
|
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||||
|
if !ok {
|
||||||
|
response.Unauthorized(c, "User not authenticated")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rates, err := h.apiKeyService.GetUserGroupRates(c.Request.Context(), subject.UserID)
|
||||||
|
if err != nil {
|
||||||
|
response.ErrorFrom(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Success(c, rates)
|
||||||
|
}
|
||||||
|
|||||||
@@ -58,8 +58,9 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &AdminUser{
|
return &AdminUser{
|
||||||
User: *base,
|
User: *base,
|
||||||
Notes: u.Notes,
|
Notes: u.Notes,
|
||||||
|
GroupRates: u.GroupRates,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ type AdminUser struct {
|
|||||||
User
|
User
|
||||||
|
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
|
// GroupRates 用户专属分组倍率配置
|
||||||
|
// map[groupID]rateMultiplier
|
||||||
|
GroupRates map[int64]float64 `json:"group_rates,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type APIKey struct {
|
type APIKey struct {
|
||||||
|
|||||||
113
backend/internal/repository/user_group_rate_repo.go
Normal file
113
backend/internal/repository/user_group_rate_repo.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type userGroupRateRepository struct {
|
||||||
|
sql sqlExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserGroupRateRepository 创建用户专属分组倍率仓储
|
||||||
|
func NewUserGroupRateRepository(sqlDB *sql.DB) service.UserGroupRateRepository {
|
||||||
|
return &userGroupRateRepository{sql: sqlDB}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUserID 获取用户的所有专属分组倍率
|
||||||
|
func (r *userGroupRateRepository) GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error) {
|
||||||
|
query := `SELECT group_id, rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1`
|
||||||
|
rows, err := r.sql.QueryContext(ctx, query, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
result := make(map[int64]float64)
|
||||||
|
for rows.Next() {
|
||||||
|
var groupID int64
|
||||||
|
var rate float64
|
||||||
|
if err := rows.Scan(&groupID, &rate); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[groupID] = rate
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByUserAndGroup 获取用户在特定分组的专属倍率
|
||||||
|
func (r *userGroupRateRepository) GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error) {
|
||||||
|
query := `SELECT rate_multiplier FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2`
|
||||||
|
var rate float64
|
||||||
|
err := scanSingleRow(ctx, r.sql, query, []any{userID, groupID}, &rate)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rate, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SyncUserGroupRates 同步用户的分组专属倍率
|
||||||
|
func (r *userGroupRateRepository) SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error {
|
||||||
|
if len(rates) == 0 {
|
||||||
|
// 如果传入空 map,删除该用户的所有专属倍率
|
||||||
|
_, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE user_id = $1`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分离需要删除和需要 upsert 的记录
|
||||||
|
var toDelete []int64
|
||||||
|
toUpsert := make(map[int64]float64)
|
||||||
|
for groupID, rate := range rates {
|
||||||
|
if rate == nil {
|
||||||
|
toDelete = append(toDelete, groupID)
|
||||||
|
} else {
|
||||||
|
toUpsert[groupID] = *rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除指定的记录
|
||||||
|
for _, groupID := range toDelete {
|
||||||
|
_, err := r.sql.ExecContext(ctx,
|
||||||
|
`DELETE FROM user_group_rate_multipliers WHERE user_id = $1 AND group_id = $2`,
|
||||||
|
userID, groupID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert 记录
|
||||||
|
now := time.Now()
|
||||||
|
for groupID, rate := range toUpsert {
|
||||||
|
_, err := r.sql.ExecContext(ctx, `
|
||||||
|
INSERT INTO user_group_rate_multipliers (user_id, group_id, rate_multiplier, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $4)
|
||||||
|
ON CONFLICT (user_id, group_id) DO UPDATE SET rate_multiplier = $3, updated_at = $4
|
||||||
|
`, userID, groupID, rate, now)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByGroupID 删除指定分组的所有用户专属倍率
|
||||||
|
func (r *userGroupRateRepository) DeleteByGroupID(ctx context.Context, groupID int64) error {
|
||||||
|
_, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE group_id = $1`, groupID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteByUserID 删除指定用户的所有专属倍率
|
||||||
|
func (r *userGroupRateRepository) DeleteByUserID(ctx context.Context, userID int64) error {
|
||||||
|
_, err := r.sql.ExecContext(ctx, `DELETE FROM user_group_rate_multipliers WHERE user_id = $1`, userID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ var ProviderSet = wire.NewSet(
|
|||||||
NewUserSubscriptionRepository,
|
NewUserSubscriptionRepository,
|
||||||
NewUserAttributeDefinitionRepository,
|
NewUserAttributeDefinitionRepository,
|
||||||
NewUserAttributeValueRepository,
|
NewUserAttributeValueRepository,
|
||||||
|
NewUserGroupRateRepository,
|
||||||
|
|
||||||
// Cache implementations
|
// Cache implementations
|
||||||
NewGatewayCache,
|
NewGatewayCache,
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
userService := service.NewUserService(userRepo, nil)
|
userService := service.NewUserService(userRepo, nil)
|
||||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, apiKeyCache, cfg)
|
apiKeyService := service.NewAPIKeyService(apiKeyRepo, userRepo, groupRepo, userSubRepo, nil, apiKeyCache, cfg)
|
||||||
|
|
||||||
usageRepo := newStubUsageLogRepo()
|
usageRepo := newStubUsageLogRepo()
|
||||||
usageService := service.NewUsageService(usageRepo, userRepo, nil, nil)
|
usageService := service.NewUsageService(usageRepo, userRepo, nil, nil)
|
||||||
@@ -607,7 +607,7 @@ func newContractDeps(t *testing.T) *contractDeps {
|
|||||||
settingRepo := newStubSettingRepo()
|
settingRepo := newStubSettingRepo()
|
||||||
settingService := service.NewSettingService(settingRepo, cfg)
|
settingService := service.NewSettingService(settingRepo, cfg)
|
||||||
|
|
||||||
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil)
|
adminService := service.NewAdminService(userRepo, groupRepo, &accountRepo, proxyRepo, apiKeyRepo, redeemRepo, nil, nil, nil, nil, nil)
|
||||||
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
|
authHandler := handler.NewAuthHandler(cfg, nil, userService, settingService, nil, redeemService, nil)
|
||||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||||
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
usageHandler := handler.NewUsageHandler(usageService, apiKeyService)
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func newTestAPIKeyService(repo service.APIKeyRepository) *service.APIKeyService
|
|||||||
nil, // userRepo (unused in GetByKey)
|
nil, // userRepo (unused in GetByKey)
|
||||||
nil, // groupRepo
|
nil, // groupRepo
|
||||||
nil, // userSubRepo
|
nil, // userSubRepo
|
||||||
|
nil, // userGroupRateRepo
|
||||||
nil, // cache
|
nil, // cache
|
||||||
&config.Config{},
|
&config.Config{},
|
||||||
)
|
)
|
||||||
@@ -187,6 +188,7 @@ func TestApiKeyAuthWithSubscriptionGoogleSetsGroupContext(t *testing.T) {
|
|||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
nil,
|
nil,
|
||||||
|
nil,
|
||||||
&config.Config{RunMode: config.RunModeSimple},
|
&config.Config{RunMode: config.RunModeSimple},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("simple_mode_bypasses_quota_check", func(t *testing.T) {
|
t.Run("simple_mode_bypasses_quota_check", func(t *testing.T) {
|
||||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
|
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||||
subscriptionService := service.NewSubscriptionService(nil, &stubUserSubscriptionRepo{}, nil)
|
subscriptionService := service.NewSubscriptionService(nil, &stubUserSubscriptionRepo{}, nil)
|
||||||
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
|
router := newAuthTestRouter(apiKeyService, subscriptionService, cfg)
|
||||||
|
|
||||||
@@ -73,7 +73,7 @@ func TestSimpleModeBypassesQuotaCheck(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("standard_mode_enforces_quota_check", func(t *testing.T) {
|
t.Run("standard_mode_enforces_quota_check", func(t *testing.T) {
|
||||||
cfg := &config.Config{RunMode: config.RunModeStandard}
|
cfg := &config.Config{RunMode: config.RunModeStandard}
|
||||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
|
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
sub := &service.UserSubscription{
|
sub := &service.UserSubscription{
|
||||||
@@ -150,7 +150,7 @@ func TestAPIKeyAuthSetsGroupContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
|
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg)))
|
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg)))
|
||||||
router.GET("/t", func(c *gin.Context) {
|
router.GET("/t", func(c *gin.Context) {
|
||||||
@@ -208,7 +208,7 @@ func TestAPIKeyAuthOverwritesInvalidContextGroup(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||||
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, cfg)
|
apiKeyService := service.NewAPIKeyService(apiKeyRepo, nil, nil, nil, nil, nil, cfg)
|
||||||
router := gin.New()
|
router := gin.New()
|
||||||
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg)))
|
router.Use(gin.HandlerFunc(NewAPIKeyAuthMiddleware(apiKeyService, nil, cfg)))
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func RegisterUserRoutes(
|
|||||||
groups := authenticated.Group("/groups")
|
groups := authenticated.Group("/groups")
|
||||||
{
|
{
|
||||||
groups.GET("/available", h.APIKey.GetAvailableGroups)
|
groups.GET("/available", h.APIKey.GetAvailableGroups)
|
||||||
|
groups.GET("/rates", h.APIKey.GetUserGroupRates)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用记录
|
// 使用记录
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ type UpdateUserInput struct {
|
|||||||
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
Concurrency *int // 使用指针区分"未提供"和"设置为0"
|
||||||
Status string
|
Status string
|
||||||
AllowedGroups *[]int64 // 使用指针区分"未提供"和"设置为空数组"
|
AllowedGroups *[]int64 // 使用指针区分"未提供"和"设置为空数组"
|
||||||
|
// GroupRates 用户专属分组倍率配置
|
||||||
|
// map[groupID]*rate,nil 表示删除该分组的专属倍率
|
||||||
|
GroupRates map[int64]*float64
|
||||||
}
|
}
|
||||||
|
|
||||||
type CreateGroupInput struct {
|
type CreateGroupInput struct {
|
||||||
@@ -293,6 +296,7 @@ type adminServiceImpl struct {
|
|||||||
proxyRepo ProxyRepository
|
proxyRepo ProxyRepository
|
||||||
apiKeyRepo APIKeyRepository
|
apiKeyRepo APIKeyRepository
|
||||||
redeemCodeRepo RedeemCodeRepository
|
redeemCodeRepo RedeemCodeRepository
|
||||||
|
userGroupRateRepo UserGroupRateRepository
|
||||||
billingCacheService *BillingCacheService
|
billingCacheService *BillingCacheService
|
||||||
proxyProber ProxyExitInfoProber
|
proxyProber ProxyExitInfoProber
|
||||||
proxyLatencyCache ProxyLatencyCache
|
proxyLatencyCache ProxyLatencyCache
|
||||||
@@ -307,6 +311,7 @@ func NewAdminService(
|
|||||||
proxyRepo ProxyRepository,
|
proxyRepo ProxyRepository,
|
||||||
apiKeyRepo APIKeyRepository,
|
apiKeyRepo APIKeyRepository,
|
||||||
redeemCodeRepo RedeemCodeRepository,
|
redeemCodeRepo RedeemCodeRepository,
|
||||||
|
userGroupRateRepo UserGroupRateRepository,
|
||||||
billingCacheService *BillingCacheService,
|
billingCacheService *BillingCacheService,
|
||||||
proxyProber ProxyExitInfoProber,
|
proxyProber ProxyExitInfoProber,
|
||||||
proxyLatencyCache ProxyLatencyCache,
|
proxyLatencyCache ProxyLatencyCache,
|
||||||
@@ -319,6 +324,7 @@ func NewAdminService(
|
|||||||
proxyRepo: proxyRepo,
|
proxyRepo: proxyRepo,
|
||||||
apiKeyRepo: apiKeyRepo,
|
apiKeyRepo: apiKeyRepo,
|
||||||
redeemCodeRepo: redeemCodeRepo,
|
redeemCodeRepo: redeemCodeRepo,
|
||||||
|
userGroupRateRepo: userGroupRateRepo,
|
||||||
billingCacheService: billingCacheService,
|
billingCacheService: billingCacheService,
|
||||||
proxyProber: proxyProber,
|
proxyProber: proxyProber,
|
||||||
proxyLatencyCache: proxyLatencyCache,
|
proxyLatencyCache: proxyLatencyCache,
|
||||||
@@ -333,11 +339,35 @@ func (s *adminServiceImpl) ListUsers(ctx context.Context, page, pageSize int, fi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
// 批量加载用户专属分组倍率
|
||||||
|
if s.userGroupRateRepo != nil && len(users) > 0 {
|
||||||
|
for i := range users {
|
||||||
|
rates, err := s.userGroupRateRepo.GetByUserID(ctx, users[i].ID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to load user group rates: user_id=%d err=%v", users[i].ID, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users[i].GroupRates = rates
|
||||||
|
}
|
||||||
|
}
|
||||||
return users, result.Total, nil
|
return users, result.Total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) {
|
func (s *adminServiceImpl) GetUser(ctx context.Context, id int64) (*User, error) {
|
||||||
return s.userRepo.GetByID(ctx, id)
|
user, err := s.userRepo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// 加载用户专属分组倍率
|
||||||
|
if s.userGroupRateRepo != nil {
|
||||||
|
rates, err := s.userGroupRateRepo.GetByUserID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("failed to load user group rates: user_id=%d err=%v", id, err)
|
||||||
|
} else {
|
||||||
|
user.GroupRates = rates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) {
|
func (s *adminServiceImpl) CreateUser(ctx context.Context, input *CreateUserInput) (*User, error) {
|
||||||
@@ -406,6 +436,14 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
|||||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 同步用户专属分组倍率
|
||||||
|
if input.GroupRates != nil && s.userGroupRateRepo != nil {
|
||||||
|
if err := s.userGroupRateRepo.SyncUserGroupRates(ctx, user.ID, input.GroupRates); err != nil {
|
||||||
|
log.Printf("failed to sync user group rates: user_id=%d err=%v", user.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if s.authCacheInvalidator != nil {
|
if s.authCacheInvalidator != nil {
|
||||||
if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole {
|
if user.Concurrency != oldConcurrency || user.Status != oldStatus || user.Role != oldRole {
|
||||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID)
|
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, user.ID)
|
||||||
@@ -941,6 +979,7 @@ func (s *adminServiceImpl) DeleteGroup(ctx context.Context, id int64) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 注意:user_group_rate_multipliers 表通过外键 ON DELETE CASCADE 自动清理
|
||||||
|
|
||||||
// 事务成功后,异步失效受影响用户的订阅缓存
|
// 事务成功后,异步失效受影响用户的订阅缓存
|
||||||
if len(affectedUserIDs) > 0 && s.billingCacheService != nil {
|
if len(affectedUserIDs) > 0 && s.billingCacheService != nil {
|
||||||
|
|||||||
@@ -115,15 +115,16 @@ type UpdateAPIKeyRequest struct {
|
|||||||
|
|
||||||
// APIKeyService API Key服务
|
// APIKeyService API Key服务
|
||||||
type APIKeyService struct {
|
type APIKeyService struct {
|
||||||
apiKeyRepo APIKeyRepository
|
apiKeyRepo APIKeyRepository
|
||||||
userRepo UserRepository
|
userRepo UserRepository
|
||||||
groupRepo GroupRepository
|
groupRepo GroupRepository
|
||||||
userSubRepo UserSubscriptionRepository
|
userSubRepo UserSubscriptionRepository
|
||||||
cache APIKeyCache
|
userGroupRateRepo UserGroupRateRepository
|
||||||
cfg *config.Config
|
cache APIKeyCache
|
||||||
authCacheL1 *ristretto.Cache
|
cfg *config.Config
|
||||||
authCfg apiKeyAuthCacheConfig
|
authCacheL1 *ristretto.Cache
|
||||||
authGroup singleflight.Group
|
authCfg apiKeyAuthCacheConfig
|
||||||
|
authGroup singleflight.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIKeyService 创建API Key服务实例
|
// NewAPIKeyService 创建API Key服务实例
|
||||||
@@ -132,16 +133,18 @@ func NewAPIKeyService(
|
|||||||
userRepo UserRepository,
|
userRepo UserRepository,
|
||||||
groupRepo GroupRepository,
|
groupRepo GroupRepository,
|
||||||
userSubRepo UserSubscriptionRepository,
|
userSubRepo UserSubscriptionRepository,
|
||||||
|
userGroupRateRepo UserGroupRateRepository,
|
||||||
cache APIKeyCache,
|
cache APIKeyCache,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
) *APIKeyService {
|
) *APIKeyService {
|
||||||
svc := &APIKeyService{
|
svc := &APIKeyService{
|
||||||
apiKeyRepo: apiKeyRepo,
|
apiKeyRepo: apiKeyRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
groupRepo: groupRepo,
|
groupRepo: groupRepo,
|
||||||
userSubRepo: userSubRepo,
|
userSubRepo: userSubRepo,
|
||||||
cache: cache,
|
userGroupRateRepo: userGroupRateRepo,
|
||||||
cfg: cfg,
|
cache: cache,
|
||||||
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
svc.initAuthCache(cfg)
|
svc.initAuthCache(cfg)
|
||||||
return svc
|
return svc
|
||||||
@@ -627,6 +630,19 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
|
|||||||
return keys, nil
|
return keys, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetUserGroupRates 获取用户的专属分组倍率配置
|
||||||
|
// 返回 map[groupID]rateMultiplier
|
||||||
|
func (s *APIKeyService) GetUserGroupRates(ctx context.Context, userID int64) (map[int64]float64, error) {
|
||||||
|
if s.userGroupRateRepo == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rates, err := s.userGroupRateRepo.GetByUserID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user group rates: %w", err)
|
||||||
|
}
|
||||||
|
return rates, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CheckAPIKeyQuotaAndExpiry checks if the API key is valid for use (not expired, quota not exhausted)
|
// CheckAPIKeyQuotaAndExpiry checks if the API key is valid for use (not expired, quota not exhausted)
|
||||||
// Returns nil if valid, error if invalid
|
// Returns nil if valid, error if invalid
|
||||||
func (s *APIKeyService) CheckAPIKeyQuotaAndExpiry(apiKey *APIKey) error {
|
func (s *APIKeyService) CheckAPIKeyQuotaAndExpiry(apiKey *APIKey) error {
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ func TestAPIKeyService_GetByKey_UsesL2Cache(t *testing.T) {
|
|||||||
NegativeTTLSeconds: 30,
|
NegativeTTLSeconds: 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
|
|
||||||
groupID := int64(9)
|
groupID := int64(9)
|
||||||
cacheEntry := &APIKeyAuthCacheEntry{
|
cacheEntry := &APIKeyAuthCacheEntry{
|
||||||
@@ -223,7 +223,7 @@ func TestAPIKeyService_GetByKey_NegativeCache(t *testing.T) {
|
|||||||
NegativeTTLSeconds: 30,
|
NegativeTTLSeconds: 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) {
|
cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) {
|
||||||
return &APIKeyAuthCacheEntry{NotFound: true}, nil
|
return &APIKeyAuthCacheEntry{NotFound: true}, nil
|
||||||
}
|
}
|
||||||
@@ -256,7 +256,7 @@ func TestAPIKeyService_GetByKey_CacheMissStoresL2(t *testing.T) {
|
|||||||
NegativeTTLSeconds: 30,
|
NegativeTTLSeconds: 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) {
|
cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) {
|
||||||
return nil, redis.Nil
|
return nil, redis.Nil
|
||||||
}
|
}
|
||||||
@@ -293,7 +293,7 @@ func TestAPIKeyService_GetByKey_UsesL1Cache(t *testing.T) {
|
|||||||
L1TTLSeconds: 60,
|
L1TTLSeconds: 60,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
require.NotNil(t, svc.authCacheL1)
|
require.NotNil(t, svc.authCacheL1)
|
||||||
|
|
||||||
_, err := svc.GetByKey(context.Background(), "k-l1")
|
_, err := svc.GetByKey(context.Background(), "k-l1")
|
||||||
@@ -320,7 +320,7 @@ func TestAPIKeyService_InvalidateAuthCacheByUserID(t *testing.T) {
|
|||||||
NegativeTTLSeconds: 30,
|
NegativeTTLSeconds: 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
|
|
||||||
svc.InvalidateAuthCacheByUserID(context.Background(), 7)
|
svc.InvalidateAuthCacheByUserID(context.Background(), 7)
|
||||||
require.Len(t, cache.deleteAuthKeys, 2)
|
require.Len(t, cache.deleteAuthKeys, 2)
|
||||||
@@ -338,7 +338,7 @@ func TestAPIKeyService_InvalidateAuthCacheByGroupID(t *testing.T) {
|
|||||||
L2TTLSeconds: 60,
|
L2TTLSeconds: 60,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
|
|
||||||
svc.InvalidateAuthCacheByGroupID(context.Background(), 9)
|
svc.InvalidateAuthCacheByGroupID(context.Background(), 9)
|
||||||
require.Len(t, cache.deleteAuthKeys, 2)
|
require.Len(t, cache.deleteAuthKeys, 2)
|
||||||
@@ -356,7 +356,7 @@ func TestAPIKeyService_InvalidateAuthCacheByKey(t *testing.T) {
|
|||||||
L2TTLSeconds: 60,
|
L2TTLSeconds: 60,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
|
|
||||||
svc.InvalidateAuthCacheByKey(context.Background(), "k1")
|
svc.InvalidateAuthCacheByKey(context.Background(), "k1")
|
||||||
require.Len(t, cache.deleteAuthKeys, 1)
|
require.Len(t, cache.deleteAuthKeys, 1)
|
||||||
@@ -375,7 +375,7 @@ func TestAPIKeyService_GetByKey_CachesNegativeOnRepoMiss(t *testing.T) {
|
|||||||
NegativeTTLSeconds: 30,
|
NegativeTTLSeconds: 30,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) {
|
cache.getAuthCache = func(ctx context.Context, key string) (*APIKeyAuthCacheEntry, error) {
|
||||||
return nil, redis.Nil
|
return nil, redis.Nil
|
||||||
}
|
}
|
||||||
@@ -411,7 +411,7 @@ func TestAPIKeyService_GetByKey_SingleflightCollapses(t *testing.T) {
|
|||||||
Singleflight: true,
|
Singleflight: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
svc := NewAPIKeyService(repo, nil, nil, nil, cache, cfg)
|
svc := NewAPIKeyService(repo, nil, nil, nil, nil, cache, cfg)
|
||||||
|
|
||||||
start := make(chan struct{})
|
start := make(chan struct{})
|
||||||
wg := sync.WaitGroup{}
|
wg := sync.WaitGroup{}
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ type GatewayService struct {
|
|||||||
usageLogRepo UsageLogRepository
|
usageLogRepo UsageLogRepository
|
||||||
userRepo UserRepository
|
userRepo UserRepository
|
||||||
userSubRepo UserSubscriptionRepository
|
userSubRepo UserSubscriptionRepository
|
||||||
|
userGroupRateRepo UserGroupRateRepository
|
||||||
cache GatewayCache
|
cache GatewayCache
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
schedulerSnapshot *SchedulerSnapshotService
|
schedulerSnapshot *SchedulerSnapshotService
|
||||||
@@ -405,6 +406,7 @@ func NewGatewayService(
|
|||||||
usageLogRepo UsageLogRepository,
|
usageLogRepo UsageLogRepository,
|
||||||
userRepo UserRepository,
|
userRepo UserRepository,
|
||||||
userSubRepo UserSubscriptionRepository,
|
userSubRepo UserSubscriptionRepository,
|
||||||
|
userGroupRateRepo UserGroupRateRepository,
|
||||||
cache GatewayCache,
|
cache GatewayCache,
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
schedulerSnapshot *SchedulerSnapshotService,
|
schedulerSnapshot *SchedulerSnapshotService,
|
||||||
@@ -424,6 +426,7 @@ func NewGatewayService(
|
|||||||
usageLogRepo: usageLogRepo,
|
usageLogRepo: usageLogRepo,
|
||||||
userRepo: userRepo,
|
userRepo: userRepo,
|
||||||
userSubRepo: userSubRepo,
|
userSubRepo: userSubRepo,
|
||||||
|
userGroupRateRepo: userGroupRateRepo,
|
||||||
cache: cache,
|
cache: cache,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
schedulerSnapshot: schedulerSnapshot,
|
schedulerSnapshot: schedulerSnapshot,
|
||||||
@@ -4609,10 +4612,17 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
|||||||
account := input.Account
|
account := input.Account
|
||||||
subscription := input.Subscription
|
subscription := input.Subscription
|
||||||
|
|
||||||
// 获取费率倍数
|
// 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
|
||||||
multiplier := s.cfg.Default.RateMultiplier
|
multiplier := s.cfg.Default.RateMultiplier
|
||||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||||
multiplier = apiKey.Group.RateMultiplier
|
multiplier = apiKey.Group.RateMultiplier
|
||||||
|
|
||||||
|
// 检查用户专属倍率
|
||||||
|
if s.userGroupRateRepo != nil {
|
||||||
|
if userRate, err := s.userGroupRateRepo.GetByUserAndGroup(ctx, user.ID, *apiKey.GroupID); err == nil && userRate != nil {
|
||||||
|
multiplier = *userRate
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cost *CostBreakdown
|
var cost *CostBreakdown
|
||||||
@@ -4773,10 +4783,17 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
|
|||||||
account := input.Account
|
account := input.Account
|
||||||
subscription := input.Subscription
|
subscription := input.Subscription
|
||||||
|
|
||||||
// 获取费率倍数
|
// 获取费率倍数(优先级:用户专属 > 分组默认 > 系统默认)
|
||||||
multiplier := s.cfg.Default.RateMultiplier
|
multiplier := s.cfg.Default.RateMultiplier
|
||||||
if apiKey.GroupID != nil && apiKey.Group != nil {
|
if apiKey.GroupID != nil && apiKey.Group != nil {
|
||||||
multiplier = apiKey.Group.RateMultiplier
|
multiplier = apiKey.Group.RateMultiplier
|
||||||
|
|
||||||
|
// 检查用户专属倍率
|
||||||
|
if s.userGroupRateRepo != nil {
|
||||||
|
if userRate, err := s.userGroupRateRepo.GetByUserAndGroup(ctx, user.ID, *apiKey.GroupID); err == nil && userRate != nil {
|
||||||
|
multiplier = *userRate
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cost *CostBreakdown
|
var cost *CostBreakdown
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ type User struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
UpdatedAt time.Time
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
// GroupRates 用户专属分组倍率配置
|
||||||
|
// map[groupID]rateMultiplier
|
||||||
|
GroupRates map[int64]float64
|
||||||
|
|
||||||
// TOTP 双因素认证字段
|
// TOTP 双因素认证字段
|
||||||
TotpSecretEncrypted *string // AES-256-GCM 加密的 TOTP 密钥
|
TotpSecretEncrypted *string // AES-256-GCM 加密的 TOTP 密钥
|
||||||
TotpEnabled bool // 是否启用 TOTP
|
TotpEnabled bool // 是否启用 TOTP
|
||||||
@@ -40,18 +44,20 @@ func (u *User) IsActive() bool {
|
|||||||
|
|
||||||
// CanBindGroup checks whether a user can bind to a given group.
|
// CanBindGroup checks whether a user can bind to a given group.
|
||||||
// For standard groups:
|
// For standard groups:
|
||||||
// - If AllowedGroups is non-empty, only allow binding to IDs in that list.
|
// - Public groups (non-exclusive): all users can bind
|
||||||
// - If AllowedGroups is empty (nil or length 0), allow binding to any non-exclusive group.
|
// - Exclusive groups: only users with the group in AllowedGroups can bind
|
||||||
func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool {
|
func (u *User) CanBindGroup(groupID int64, isExclusive bool) bool {
|
||||||
if len(u.AllowedGroups) > 0 {
|
// 公开分组(非专属):所有用户都可以绑定
|
||||||
for _, id := range u.AllowedGroups {
|
if !isExclusive {
|
||||||
if id == groupID {
|
return true
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
return !isExclusive
|
// 专属分组:需要在 AllowedGroups 中
|
||||||
|
for _, id := range u.AllowedGroups {
|
||||||
|
if id == groupID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *User) SetPassword(password string) error {
|
func (u *User) SetPassword(password string) error {
|
||||||
|
|||||||
25
backend/internal/service/user_group_rate.go
Normal file
25
backend/internal/service/user_group_rate.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// UserGroupRateRepository 用户专属分组倍率仓储接口
|
||||||
|
// 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
|
||||||
|
type UserGroupRateRepository interface {
|
||||||
|
// GetByUserID 获取用户的所有专属分组倍率
|
||||||
|
// 返回 map[groupID]rateMultiplier
|
||||||
|
GetByUserID(ctx context.Context, userID int64) (map[int64]float64, error)
|
||||||
|
|
||||||
|
// GetByUserAndGroup 获取用户在特定分组的专属倍率
|
||||||
|
// 如果未设置专属倍率,返回 nil
|
||||||
|
GetByUserAndGroup(ctx context.Context, userID, groupID int64) (*float64, error)
|
||||||
|
|
||||||
|
// SyncUserGroupRates 同步用户的分组专属倍率
|
||||||
|
// rates: map[groupID]*rateMultiplier,nil 表示删除该分组的专属倍率
|
||||||
|
SyncUserGroupRates(ctx context.Context, userID int64, rates map[int64]*float64) error
|
||||||
|
|
||||||
|
// DeleteByGroupID 删除指定分组的所有用户专属倍率(分组删除时调用)
|
||||||
|
DeleteByGroupID(ctx context.Context, groupID int64) error
|
||||||
|
|
||||||
|
// DeleteByUserID 删除指定用户的所有专属倍率(用户删除时调用)
|
||||||
|
DeleteByUserID(ctx context.Context, userID int64) error
|
||||||
|
}
|
||||||
19
backend/migrations/047_add_user_group_rate_multipliers.sql
Normal file
19
backend/migrations/047_add_user_group_rate_multipliers.sql
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
-- 用户专属分组倍率表
|
||||||
|
-- 允许管理员为特定用户设置分组的专属计费倍率,覆盖分组默认倍率
|
||||||
|
CREATE TABLE IF NOT EXISTS user_group_rate_multipliers (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
group_id BIGINT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||||
|
rate_multiplier DECIMAL(10,4) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY (user_id, group_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 按 group_id 查询索引(删除分组时清理关联记录)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_group_rate_multipliers_group_id
|
||||||
|
ON user_group_rate_multipliers(group_id);
|
||||||
|
|
||||||
|
COMMENT ON TABLE user_group_rate_multipliers IS '用户专属分组倍率配置';
|
||||||
|
COMMENT ON COLUMN user_group_rate_multipliers.user_id IS '用户ID';
|
||||||
|
COMMENT ON COLUMN user_group_rate_multipliers.group_id IS '分组ID';
|
||||||
|
COMMENT ON COLUMN user_group_rate_multipliers.rate_multiplier IS '专属计费倍率(覆盖分组默认倍率)';
|
||||||
@@ -18,8 +18,18 @@ export async function getAvailable(): Promise<Group[]> {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current user's custom group rate multipliers
|
||||||
|
* @returns Map of group_id to custom rate_multiplier
|
||||||
|
*/
|
||||||
|
export async function getUserGroupRates(): Promise<Record<number, number>> {
|
||||||
|
const { data } = await apiClient.get<Record<number, number> | null>('/groups/rates')
|
||||||
|
return data || {}
|
||||||
|
}
|
||||||
|
|
||||||
export const userGroupsAPI = {
|
export const userGroupsAPI = {
|
||||||
getAvailable
|
getAvailable,
|
||||||
|
getUserGroupRates
|
||||||
}
|
}
|
||||||
|
|
||||||
export default userGroupsAPI
|
export default userGroupsAPI
|
||||||
|
|||||||
@@ -1,59 +1,328 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseDialog :show="show" :title="t('admin.users.setAllowedGroups')" width="normal" @close="$emit('close')">
|
<BaseDialog :show="show" :title="t('admin.users.groupConfig')" width="wide" @close="$emit('close')">
|
||||||
<div v-if="user" class="space-y-4">
|
<div v-if="user" class="space-y-6">
|
||||||
<div class="flex items-center gap-3 rounded-xl bg-gray-50 p-4 dark:bg-dark-700">
|
<!-- 用户信息头部 -->
|
||||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-primary-100">
|
<div class="flex items-center gap-4 rounded-2xl bg-gradient-to-r from-primary-50 to-primary-100 p-5 dark:from-primary-900/30 dark:to-primary-800/20">
|
||||||
<span class="text-lg font-medium text-primary-700">{{ user.email.charAt(0).toUpperCase() }}</span>
|
<div class="flex h-14 w-14 items-center justify-center rounded-full bg-white shadow-sm dark:bg-dark-700">
|
||||||
|
<span class="text-2xl font-semibold text-primary-600 dark:text-primary-400">{{ user.email.charAt(0).toUpperCase() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ user.email }}</p>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 dark:text-gray-400">{{ t('admin.users.groupConfigHint', { email: user.email }) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ user.email }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="loading" class="flex justify-center py-8"><svg class="h-8 w-8 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg></div>
|
|
||||||
<div v-else>
|
<!-- 加载状态 -->
|
||||||
<p class="mb-3 text-sm text-gray-600">{{ t('admin.users.allowedGroupsHint') }}</p>
|
<div v-if="loading" class="flex justify-center py-12">
|
||||||
<div class="max-h-64 space-y-2 overflow-y-auto">
|
<svg class="h-10 w-10 animate-spin text-primary-500" fill="none" viewBox="0 0 24 24">
|
||||||
<label v-for="group in groups" :key="group.id" class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-primary-300 bg-primary-50': selectedIds.includes(group.id)}">
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
<input type="checkbox" :value="group.id" v-model="selectedIds" class="h-4 w-4 rounded border-gray-300 text-primary-600" />
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ group.name }}</p><p v-if="group.description" class="truncate text-sm text-gray-500">{{ group.description }}</p></div>
|
</svg>
|
||||||
<div class="flex items-center gap-2"><span class="badge badge-gray text-xs">{{ group.platform }}</span><span v-if="group.is_exclusive" class="badge badge-purple text-xs">{{ t('admin.groups.exclusive') }}</span></div>
|
</div>
|
||||||
</label>
|
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
<!-- 专属分组区域 -->
|
||||||
|
<div v-if="exclusiveGroups.length > 0">
|
||||||
|
<div class="mb-3 flex items-center gap-2">
|
||||||
|
<div class="h-1.5 w-1.5 rounded-full bg-purple-500"></div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.exclusiveGroups') }}</h4>
|
||||||
|
<span class="text-xs text-gray-400">({{ exclusiveGroupConfigs.filter(c => c.isSelected).length }}/{{ exclusiveGroupConfigs.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div
|
||||||
|
v-for="config in exclusiveGroupConfigs"
|
||||||
|
:key="config.groupId"
|
||||||
|
class="group relative overflow-hidden rounded-xl border-2 p-4 transition-all duration-200"
|
||||||
|
:class="config.isSelected
|
||||||
|
? 'border-primary-400 bg-primary-50/50 shadow-sm dark:border-primary-500 dark:bg-primary-900/20'
|
||||||
|
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-dark-600 dark:bg-dark-800 dark:hover:border-dark-500'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- 复选框 -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<label class="relative flex h-6 w-6 cursor-pointer items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="config.isSelected"
|
||||||
|
@change="toggleExclusiveGroup(config.groupId)"
|
||||||
|
class="peer sr-only"
|
||||||
|
/>
|
||||||
|
<div class="h-5 w-5 rounded-md border-2 border-gray-300 transition-all peer-checked:border-primary-500 peer-checked:bg-primary-500 dark:border-dark-500 peer-checked:dark:border-primary-500">
|
||||||
|
<svg v-if="config.isSelected" class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分组信息 -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span>
|
||||||
|
<span class="inline-flex items-center rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-300">
|
||||||
|
{{ t('admin.groups.exclusive') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 flex items-center gap-3 text-sm">
|
||||||
|
<span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||||
|
<PlatformIcon :platform="config.platform" size="xs" />
|
||||||
|
<span>{{ config.platform }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-300 dark:text-dark-500">•</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 专属倍率输入 -->
|
||||||
|
<div class="flex flex-shrink-0 items-center gap-3">
|
||||||
|
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
:value="config.customRate ?? ''"
|
||||||
|
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
|
||||||
|
:placeholder="String(config.defaultRate)"
|
||||||
|
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 border-t border-gray-200 pt-4">
|
|
||||||
<label class="flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 hover:bg-gray-50" :class="{'border-green-300 bg-green-50': selectedIds.length === 0}">
|
<!-- 公开分组区域 -->
|
||||||
<input type="radio" :checked="selectedIds.length === 0" @change="selectedIds = []" class="h-4 w-4 border-gray-300 text-green-600" />
|
<div v-if="publicGroups.length > 0">
|
||||||
<div class="flex-1"><p class="font-medium text-gray-900">{{ t('admin.users.allowAllGroups') }}</p><p class="text-sm text-gray-500">{{ t('admin.users.allowAllGroupsHint') }}</p></div>
|
<div class="mb-3 flex items-center gap-2">
|
||||||
</label>
|
<div class="h-1.5 w-1.5 rounded-full bg-green-500"></div>
|
||||||
|
<h4 class="text-sm font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.users.publicGroups') }}</h4>
|
||||||
|
<span class="text-xs text-gray-400">({{ publicGroupConfigs.length }})</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<div
|
||||||
|
v-for="config in publicGroupConfigs"
|
||||||
|
:key="config.groupId"
|
||||||
|
class="relative overflow-hidden rounded-xl border-2 border-green-200 bg-green-50/50 p-4 dark:border-green-800/50 dark:bg-green-900/10"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- 复选框(禁用状态) -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<div class="flex h-5 w-5 items-center justify-center rounded-md border-2 border-green-400 bg-green-500 dark:border-green-600 dark:bg-green-600">
|
||||||
|
<svg class="h-full w-full text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分组信息 -->
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-base font-semibold text-gray-900 dark:text-white">{{ config.groupName }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 flex items-center gap-3 text-sm">
|
||||||
|
<span class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400">
|
||||||
|
<PlatformIcon :platform="config.platform" size="xs" />
|
||||||
|
<span>{{ config.platform }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-300 dark:text-dark-500">•</span>
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">
|
||||||
|
{{ t('admin.users.defaultRate') }}: <span class="font-medium text-gray-700 dark:text-gray-300">{{ config.defaultRate }}x</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 专属倍率输入 -->
|
||||||
|
<div class="flex flex-shrink-0 items-center gap-3">
|
||||||
|
<label class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ t('admin.users.customRate') }}</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.001"
|
||||||
|
min="0"
|
||||||
|
:value="config.customRate ?? ''"
|
||||||
|
@input="updateCustomRate(config.groupId, ($event.target as HTMLInputElement).value)"
|
||||||
|
:placeholder="String(config.defaultRate)"
|
||||||
|
class="hide-spinner w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium transition-colors focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500/20 dark:border-dark-500 dark:bg-dark-700 dark:focus:border-primary-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 无分组提示 -->
|
||||||
|
<div v-if="groups.length === 0" class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-dark-700">
|
||||||
|
<svg class="h-8 w-8 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">{{ t('common.noGroupsAvailable') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-3">
|
<div class="flex justify-end gap-3">
|
||||||
<button @click="$emit('close')" class="btn btn-secondary">{{ t('common.cancel') }}</button>
|
<button @click="$emit('close')" class="btn btn-secondary px-5">{{ t('common.cancel') }}</button>
|
||||||
<button @click="handleSave" :disabled="submitting" class="btn btn-primary">{{ submitting ? t('common.saving') : t('common.save') }}</button>
|
<button @click="handleSave" :disabled="submitting" class="btn btn-primary px-6">
|
||||||
|
<svg v-if="submitting" class="-ml-1 mr-2 h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ submitting ? t('common.saving') : t('common.save') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BaseDialog>
|
</BaseDialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from '@/stores/app'
|
||||||
import { adminAPI } from '@/api/admin'
|
import { adminAPI } from '@/api/admin'
|
||||||
import type { AdminUser, Group } from '@/types'
|
import type { AdminUser, Group, GroupPlatform } from '@/types'
|
||||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||||
|
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||||
|
|
||||||
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
|
interface GroupRateConfig {
|
||||||
const emit = defineEmits(['close', 'success']); const { t } = useI18n(); const appStore = useAppStore()
|
groupId: number
|
||||||
|
groupName: string
|
||||||
|
platform: GroupPlatform
|
||||||
|
isExclusive: boolean
|
||||||
|
defaultRate: number
|
||||||
|
customRate: number | null
|
||||||
|
isSelected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const groups = ref<Group[]>([]); const selectedIds = ref<number[]>([]); const loading = ref(false); const submitting = ref(false)
|
const props = defineProps<{ show: boolean; user: AdminUser | null }>()
|
||||||
|
const emit = defineEmits(['close', 'success'])
|
||||||
|
const { t } = useI18n()
|
||||||
|
const appStore = useAppStore()
|
||||||
|
|
||||||
watch(() => props.show, (v) => { if(v && props.user) { selectedIds.value = props.user.allowed_groups || []; load() } })
|
const groups = ref<Group[]>([])
|
||||||
const load = async () => { loading.value = true; try { const res = await adminAPI.groups.list(1, 1000); groups.value = res.items.filter(g => g.subscription_type === 'standard' && g.status === 'active') } catch (error) { console.error('Failed to load groups:', error) } finally { loading.value = false } }
|
const groupConfigs = ref<GroupRateConfig[]>([])
|
||||||
const handleSave = async () => {
|
const originalGroupRates = ref<Record<number, number>>({}) // 记录原始专属倍率,用于检测删除
|
||||||
if (!props.user) return; submitting.value = true
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
|
||||||
|
// 分离专属分组和公开分组
|
||||||
|
const exclusiveGroups = computed(() => groups.value.filter((g) => g.is_exclusive))
|
||||||
|
const publicGroups = computed(() => groups.value.filter((g) => !g.is_exclusive))
|
||||||
|
|
||||||
|
const exclusiveGroupConfigs = computed(() => groupConfigs.value.filter((c) => c.isExclusive))
|
||||||
|
const publicGroupConfigs = computed(() => groupConfigs.value.filter((c) => !c.isExclusive))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.show,
|
||||||
|
(v) => {
|
||||||
|
if (v && props.user) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await adminAPI.users.update(props.user.id, { allowed_groups: selectedIds.value })
|
const res = await adminAPI.groups.list(1, 1000)
|
||||||
appStore.showSuccess(t('admin.users.allowedGroupsUpdated')); emit('success'); emit('close')
|
// 只显示标准类型且活跃的分组
|
||||||
} catch (error) { console.error('Failed to update allowed groups:', error) } finally { submitting.value = false }
|
groups.value = res.items.filter((g) => g.subscription_type === 'standard' && g.status === 'active')
|
||||||
|
|
||||||
|
// 初始化配置
|
||||||
|
const userAllowedGroups = props.user?.allowed_groups || []
|
||||||
|
const userGroupRates = props.user?.group_rates || {}
|
||||||
|
|
||||||
|
// 保存原始专属倍率,用于检测删除操作
|
||||||
|
originalGroupRates.value = { ...userGroupRates }
|
||||||
|
|
||||||
|
groupConfigs.value = groups.value.map((g) => ({
|
||||||
|
groupId: g.id,
|
||||||
|
groupName: g.name,
|
||||||
|
platform: g.platform,
|
||||||
|
isExclusive: g.is_exclusive,
|
||||||
|
defaultRate: g.rate_multiplier,
|
||||||
|
customRate: userGroupRates[g.id] ?? null,
|
||||||
|
// 专属分组:检查是否在 allowed_groups 中
|
||||||
|
// 公开分组:始终选中
|
||||||
|
isSelected: g.is_exclusive ? userAllowedGroups.includes(g.id) : true,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load groups:', error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleExclusiveGroup = (groupId: number) => {
|
||||||
|
const config = groupConfigs.value.find((c) => c.groupId === groupId)
|
||||||
|
if (config && config.isExclusive) {
|
||||||
|
config.isSelected = !config.isSelected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCustomRate = (groupId: number, value: string) => {
|
||||||
|
const config = groupConfigs.value.find((c) => c.groupId === groupId)
|
||||||
|
if (config) {
|
||||||
|
if (value === '' || value === null || value === undefined) {
|
||||||
|
config.customRate = null
|
||||||
|
} else {
|
||||||
|
const numValue = parseFloat(value)
|
||||||
|
config.customRate = isNaN(numValue) ? null : numValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!props.user) return
|
||||||
|
submitting.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 构建 allowed_groups(仅包含专属分组中被勾选的)
|
||||||
|
const allowedGroups = groupConfigs.value.filter((c) => c.isExclusive && c.isSelected).map((c) => c.groupId)
|
||||||
|
|
||||||
|
// 构建 group_rates
|
||||||
|
// - 有新专属倍率: 设置为该值
|
||||||
|
// - 原本有专属倍率但现在被清空: 设置为 null(表示删除)
|
||||||
|
const groupRates: Record<number, number | null> = {}
|
||||||
|
for (const c of groupConfigs.value) {
|
||||||
|
const hadOriginalRate = originalGroupRates.value[c.groupId] !== undefined
|
||||||
|
|
||||||
|
if (c.customRate !== null) {
|
||||||
|
// 有专属倍率
|
||||||
|
groupRates[c.groupId] = c.customRate
|
||||||
|
} else if (hadOriginalRate) {
|
||||||
|
// 原本有专属倍率,现在被清空,需要显式删除
|
||||||
|
groupRates[c.groupId] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await adminAPI.users.update(props.user.id, {
|
||||||
|
allowed_groups: allowedGroups,
|
||||||
|
group_rates: Object.keys(groupRates).length > 0 ? groupRates : undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
appStore.showSuccess(t('admin.users.groupConfigUpdated'))
|
||||||
|
emit('success')
|
||||||
|
emit('close')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update user group config:', error)
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 隐藏数字输入框的箭头按钮 */
|
||||||
|
.hide-spinner::-webkit-outer-spin-button,
|
||||||
|
.hide-spinner::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.hide-spinner {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -11,7 +11,14 @@
|
|||||||
<span class="truncate">{{ name }}</span>
|
<span class="truncate">{{ name }}</span>
|
||||||
<!-- Right side label -->
|
<!-- Right side label -->
|
||||||
<span v-if="showLabel" :class="labelClass">
|
<span v-if="showLabel" :class="labelClass">
|
||||||
{{ labelText }}
|
<template v-if="hasCustomRate">
|
||||||
|
<!-- 原倍率删除线 + 专属倍率高亮 -->
|
||||||
|
<span class="line-through opacity-50 mr-0.5">{{ rateMultiplier }}x</span>
|
||||||
|
<span class="font-bold">{{ userRateMultiplier }}x</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ labelText }}
|
||||||
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -27,6 +34,7 @@ interface Props {
|
|||||||
platform?: GroupPlatform
|
platform?: GroupPlatform
|
||||||
subscriptionType?: SubscriptionType
|
subscriptionType?: SubscriptionType
|
||||||
rateMultiplier?: number
|
rateMultiplier?: number
|
||||||
|
userRateMultiplier?: number | null // 用户专属倍率
|
||||||
showRate?: boolean
|
showRate?: boolean
|
||||||
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
daysRemaining?: number | null // 剩余天数(订阅类型时使用)
|
||||||
}
|
}
|
||||||
@@ -34,20 +42,31 @@ interface Props {
|
|||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
subscriptionType: 'standard',
|
subscriptionType: 'standard',
|
||||||
showRate: true,
|
showRate: true,
|
||||||
daysRemaining: null
|
daysRemaining: null,
|
||||||
|
userRateMultiplier: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const isSubscription = computed(() => props.subscriptionType === 'subscription')
|
const isSubscription = computed(() => props.subscriptionType === 'subscription')
|
||||||
|
|
||||||
|
// 是否有专属倍率(且与默认倍率不同)
|
||||||
|
const hasCustomRate = computed(() => {
|
||||||
|
return (
|
||||||
|
props.userRateMultiplier !== null &&
|
||||||
|
props.userRateMultiplier !== undefined &&
|
||||||
|
props.rateMultiplier !== undefined &&
|
||||||
|
props.userRateMultiplier !== props.rateMultiplier
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
// 是否显示右侧标签
|
// 是否显示右侧标签
|
||||||
const showLabel = computed(() => {
|
const showLabel = computed(() => {
|
||||||
if (!props.showRate) return false
|
if (!props.showRate) return false
|
||||||
// 订阅类型:显示天数或"订阅"
|
// 订阅类型:显示天数或"订阅"
|
||||||
if (isSubscription.value) return true
|
if (isSubscription.value) return true
|
||||||
// 标准类型:显示倍率
|
// 标准类型:显示倍率(包括专属倍率)
|
||||||
return props.rateMultiplier !== undefined
|
return props.rateMultiplier !== undefined || hasCustomRate.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// Label text
|
// Label text
|
||||||
@@ -71,7 +90,7 @@ const labelClass = computed(() => {
|
|||||||
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
|
const base = 'px-1.5 py-0.5 rounded text-[10px] font-semibold'
|
||||||
|
|
||||||
if (!isSubscription.value) {
|
if (!isSubscription.value) {
|
||||||
// Standard: subtle background
|
// Standard: subtle background (不再为专属倍率使用不同的背景色)
|
||||||
return `${base} bg-black/10 dark:bg-white/10`
|
return `${base} bg-black/10 dark:bg-white/10`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
:platform="platform"
|
:platform="platform"
|
||||||
:subscription-type="subscriptionType"
|
:subscription-type="subscriptionType"
|
||||||
:rate-multiplier="rateMultiplier"
|
:rate-multiplier="rateMultiplier"
|
||||||
|
:user-rate-multiplier="userRateMultiplier"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="description"
|
v-if="description"
|
||||||
@@ -39,6 +40,7 @@ interface Props {
|
|||||||
platform: GroupPlatform
|
platform: GroupPlatform
|
||||||
subscriptionType?: SubscriptionType
|
subscriptionType?: SubscriptionType
|
||||||
rateMultiplier?: number
|
rateMultiplier?: number
|
||||||
|
userRateMultiplier?: number | null
|
||||||
description?: string | null
|
description?: string | null
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
showCheckmark?: boolean
|
showCheckmark?: boolean
|
||||||
@@ -47,6 +49,7 @@ interface Props {
|
|||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
subscriptionType: 'standard',
|
subscriptionType: 'standard',
|
||||||
selected: false,
|
selected: false,
|
||||||
showCheckmark: true
|
showCheckmark: true,
|
||||||
|
userRateMultiplier: null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -849,6 +849,16 @@ export default {
|
|||||||
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
allowedGroupsUpdated: 'Allowed groups updated successfully',
|
||||||
failedToLoadGroups: 'Failed to load groups',
|
failedToLoadGroups: 'Failed to load groups',
|
||||||
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
failedToUpdateAllowedGroups: 'Failed to update allowed groups',
|
||||||
|
// User Group Configuration
|
||||||
|
groupConfig: 'User Group Configuration',
|
||||||
|
groupConfigHint: 'Configure custom rate multipliers for user {email} (overrides group defaults)',
|
||||||
|
exclusiveGroups: 'Exclusive Groups',
|
||||||
|
publicGroups: 'Public Groups (Default Available)',
|
||||||
|
defaultRate: 'Default Rate',
|
||||||
|
customRate: 'Custom Rate',
|
||||||
|
useDefaultRate: 'Use Default',
|
||||||
|
customRatePlaceholder: 'Leave empty for default',
|
||||||
|
groupConfigUpdated: 'Group configuration updated successfully',
|
||||||
deposit: 'Deposit',
|
deposit: 'Deposit',
|
||||||
withdraw: 'Withdraw',
|
withdraw: 'Withdraw',
|
||||||
depositAmount: 'Deposit Amount',
|
depositAmount: 'Deposit Amount',
|
||||||
|
|||||||
@@ -910,6 +910,16 @@ export default {
|
|||||||
allowedGroupsUpdated: '允许分组更新成功',
|
allowedGroupsUpdated: '允许分组更新成功',
|
||||||
failedToLoadGroups: '加载分组列表失败',
|
failedToLoadGroups: '加载分组列表失败',
|
||||||
failedToUpdateAllowedGroups: '更新允许分组失败',
|
failedToUpdateAllowedGroups: '更新允许分组失败',
|
||||||
|
// 用户分组配置
|
||||||
|
groupConfig: '用户分组配置',
|
||||||
|
groupConfigHint: '为用户 {email} 配置专属分组倍率(覆盖分组默认倍率)',
|
||||||
|
exclusiveGroups: '专属分组',
|
||||||
|
publicGroups: '公开分组(默认可用)',
|
||||||
|
defaultRate: '默认倍率',
|
||||||
|
customRate: '专属倍率',
|
||||||
|
useDefaultRate: '使用默认',
|
||||||
|
customRatePlaceholder: '留空使用默认',
|
||||||
|
groupConfigUpdated: '分组配置更新成功',
|
||||||
deposit: '充值',
|
deposit: '充值',
|
||||||
withdraw: '退款',
|
withdraw: '退款',
|
||||||
depositAmount: '充值金额',
|
depositAmount: '充值金额',
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface User {
|
|||||||
export interface AdminUser extends User {
|
export interface AdminUser extends User {
|
||||||
// 管理员备注(普通用户接口不返回)
|
// 管理员备注(普通用户接口不返回)
|
||||||
notes: string
|
notes: string
|
||||||
|
// 用户专属分组倍率配置 (group_id -> rate_multiplier)
|
||||||
|
group_rates?: Record<number, number>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
@@ -966,6 +968,9 @@ export interface UpdateUserRequest {
|
|||||||
concurrency?: number
|
concurrency?: number
|
||||||
status?: 'active' | 'disabled'
|
status?: 'active' | 'disabled'
|
||||||
allowed_groups?: number[] | null
|
allowed_groups?: number[] | null
|
||||||
|
// 用户专属分组倍率配置 (group_id -> rate_multiplier | null)
|
||||||
|
// null 表示删除该分组的专属倍率
|
||||||
|
group_rates?: Record<number, number | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChangePasswordRequest {
|
export interface ChangePasswordRequest {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
:platform="row.group.platform"
|
:platform="row.group.platform"
|
||||||
:subscription-type="row.group.subscription_type"
|
:subscription-type="row.group.subscription_type"
|
||||||
:rate-multiplier="row.group.rate_multiplier"
|
:rate-multiplier="row.group.rate_multiplier"
|
||||||
|
:user-rate-multiplier="userGroupRates[row.group.id]"
|
||||||
/>
|
/>
|
||||||
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{
|
<span v-else class="text-sm text-gray-400 dark:text-dark-500">{{
|
||||||
t('keys.noGroup')
|
t('keys.noGroup')
|
||||||
@@ -272,6 +273,7 @@
|
|||||||
:platform="(option as unknown as GroupOption).platform"
|
:platform="(option as unknown as GroupOption).platform"
|
||||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||||
|
:user-rate-multiplier="(option as unknown as GroupOption).userRate"
|
||||||
/>
|
/>
|
||||||
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
<span v-else class="text-gray-400">{{ t('keys.selectGroup') }}</span>
|
||||||
</template>
|
</template>
|
||||||
@@ -281,6 +283,7 @@
|
|||||||
:platform="(option as unknown as GroupOption).platform"
|
:platform="(option as unknown as GroupOption).platform"
|
||||||
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
:subscription-type="(option as unknown as GroupOption).subscriptionType"
|
||||||
:rate-multiplier="(option as unknown as GroupOption).rate"
|
:rate-multiplier="(option as unknown as GroupOption).rate"
|
||||||
|
:user-rate-multiplier="(option as unknown as GroupOption).userRate"
|
||||||
:description="(option as unknown as GroupOption).description"
|
:description="(option as unknown as GroupOption).description"
|
||||||
:selected="selected"
|
:selected="selected"
|
||||||
/>
|
/>
|
||||||
@@ -667,6 +670,7 @@
|
|||||||
:platform="option.platform"
|
:platform="option.platform"
|
||||||
:subscription-type="option.subscriptionType"
|
:subscription-type="option.subscriptionType"
|
||||||
:rate-multiplier="option.rate"
|
:rate-multiplier="option.rate"
|
||||||
|
:user-rate-multiplier="option.userRate"
|
||||||
:description="option.description"
|
:description="option.description"
|
||||||
:selected="
|
:selected="
|
||||||
selectedKeyForGroup?.group_id === option.value ||
|
selectedKeyForGroup?.group_id === option.value ||
|
||||||
@@ -718,6 +722,7 @@ interface GroupOption {
|
|||||||
label: string
|
label: string
|
||||||
description: string | null
|
description: string | null
|
||||||
rate: number
|
rate: number
|
||||||
|
userRate: number | null
|
||||||
subscriptionType: SubscriptionType
|
subscriptionType: SubscriptionType
|
||||||
platform: GroupPlatform
|
platform: GroupPlatform
|
||||||
}
|
}
|
||||||
@@ -742,6 +747,7 @@ const groups = ref<Group[]>([])
|
|||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
const usageStats = ref<Record<string, BatchApiKeyUsageStats>>({})
|
const usageStats = ref<Record<string, BatchApiKeyUsageStats>>({})
|
||||||
|
const userGroupRates = ref<Record<number, number>>({})
|
||||||
|
|
||||||
const pagination = ref({
|
const pagination = ref({
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -825,6 +831,7 @@ const groupOptions = computed(() =>
|
|||||||
label: group.name,
|
label: group.name,
|
||||||
description: group.description,
|
description: group.description,
|
||||||
rate: group.rate_multiplier,
|
rate: group.rate_multiplier,
|
||||||
|
userRate: userGroupRates.value[group.id] ?? null,
|
||||||
subscriptionType: group.subscription_type,
|
subscriptionType: group.subscription_type,
|
||||||
platform: group.platform
|
platform: group.platform
|
||||||
}))
|
}))
|
||||||
@@ -899,6 +906,14 @@ const loadGroups = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadUserGroupRates = async () => {
|
||||||
|
try {
|
||||||
|
userGroupRates.value = await userGroupsAPI.getUserGroupRates()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user group rates:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const loadPublicSettings = async () => {
|
const loadPublicSettings = async () => {
|
||||||
try {
|
try {
|
||||||
publicSettings.value = await authAPI.getPublicSettings()
|
publicSettings.value = await authAPI.getPublicSettings()
|
||||||
@@ -1268,6 +1283,7 @@ const closeCcsClientSelect = () => {
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
loadGroups()
|
loadGroups()
|
||||||
|
loadUserGroupRates()
|
||||||
loadPublicSettings()
|
loadPublicSettings()
|
||||||
document.addEventListener('click', closeGroupSelector)
|
document.addEventListener('click', closeGroupSelector)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user