Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
4da681f5
Commit
4da681f5
authored
Jan 12, 2026
by
shaw
Browse files
Merge branch 'mt21625457/main'
parents
68ba866c
9622347f
Changes
22
Expand all
Hide whitespace changes
Inline
Side-by-side
backend/cmd/server/wire.go
View file @
4da681f5
...
@@ -67,6 +67,7 @@ func provideCleanup(
...
@@ -67,6 +67,7 @@ func provideCleanup(
opsAlertEvaluator
*
service
.
OpsAlertEvaluatorService
,
opsAlertEvaluator
*
service
.
OpsAlertEvaluatorService
,
opsCleanup
*
service
.
OpsCleanupService
,
opsCleanup
*
service
.
OpsCleanupService
,
opsScheduledReport
*
service
.
OpsScheduledReportService
,
opsScheduledReport
*
service
.
OpsScheduledReportService
,
schedulerSnapshot
*
service
.
SchedulerSnapshotService
,
tokenRefresh
*
service
.
TokenRefreshService
,
tokenRefresh
*
service
.
TokenRefreshService
,
accountExpiry
*
service
.
AccountExpiryService
,
accountExpiry
*
service
.
AccountExpiryService
,
pricing
*
service
.
PricingService
,
pricing
*
service
.
PricingService
,
...
@@ -116,6 +117,12 @@ func provideCleanup(
...
@@ -116,6 +117,12 @@ func provideCleanup(
}
}
return
nil
return
nil
}},
}},
{
"SchedulerSnapshotService"
,
func
()
error
{
if
schedulerSnapshot
!=
nil
{
schedulerSnapshot
.
Stop
()
}
return
nil
}},
{
"TokenRefreshService"
,
func
()
error
{
{
"TokenRefreshService"
,
func
()
error
{
tokenRefresh
.
Stop
()
tokenRefresh
.
Stop
()
return
nil
return
nil
...
...
backend/cmd/server/wire_gen.go
View file @
4da681f5
...
@@ -112,6 +112,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
...
@@ -112,6 +112,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountTestService
:=
service
.
NewAccountTestService
(
accountRepository
,
geminiTokenProvider
,
antigravityGatewayService
,
httpUpstream
,
configConfig
)
accountTestService
:=
service
.
NewAccountTestService
(
accountRepository
,
geminiTokenProvider
,
antigravityGatewayService
,
httpUpstream
,
configConfig
)
concurrencyCache
:=
repository
.
ProvideConcurrencyCache
(
redisClient
,
configConfig
)
concurrencyCache
:=
repository
.
ProvideConcurrencyCache
(
redisClient
,
configConfig
)
concurrencyService
:=
service
.
ProvideConcurrencyService
(
concurrencyCache
,
accountRepository
,
configConfig
)
concurrencyService
:=
service
.
ProvideConcurrencyService
(
concurrencyCache
,
accountRepository
,
configConfig
)
schedulerCache
:=
repository
.
NewSchedulerCache
(
redisClient
)
schedulerOutboxRepository
:=
repository
.
NewSchedulerOutboxRepository
(
db
)
schedulerSnapshotService
:=
service
.
ProvideSchedulerSnapshotService
(
schedulerCache
,
schedulerOutboxRepository
,
accountRepository
,
groupRepository
,
configConfig
)
crsSyncService
:=
service
.
NewCRSSyncService
(
accountRepository
,
proxyRepository
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
configConfig
)
crsSyncService
:=
service
.
NewCRSSyncService
(
accountRepository
,
proxyRepository
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
configConfig
)
accountHandler
:=
admin
.
NewAccountHandler
(
adminService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
rateLimitService
,
accountUsageService
,
accountTestService
,
concurrencyService
,
crsSyncService
)
accountHandler
:=
admin
.
NewAccountHandler
(
adminService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
rateLimitService
,
accountUsageService
,
accountTestService
,
concurrencyService
,
crsSyncService
)
oAuthHandler
:=
admin
.
NewOAuthHandler
(
oAuthService
)
oAuthHandler
:=
admin
.
NewOAuthHandler
(
oAuthService
)
...
@@ -131,9 +134,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
...
@@ -131,9 +134,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
identityCache
:=
repository
.
NewIdentityCache
(
redisClient
)
identityCache
:=
repository
.
NewIdentityCache
(
redisClient
)
identityService
:=
service
.
NewIdentityService
(
identityCache
)
identityService
:=
service
.
NewIdentityService
(
identityCache
)
deferredService
:=
service
.
ProvideDeferredService
(
accountRepository
,
timingWheelService
)
deferredService
:=
service
.
ProvideDeferredService
(
accountRepository
,
timingWheelService
)
gatewayService
:=
service
.
NewGatewayService
(
accountRepository
,
groupRepository
,
usageLogRepository
,
userRepository
,
userSubscriptionRepository
,
gatewayCache
,
configConfig
,
concurrencyService
,
billingService
,
rateLimitService
,
billingCacheService
,
identityService
,
httpUpstream
,
deferredService
)
gatewayService
:=
service
.
NewGatewayService
(
accountRepository
,
groupRepository
,
usageLogRepository
,
userRepository
,
userSubscriptionRepository
,
gatewayCache
,
configConfig
,
schedulerSnapshotService
,
concurrencyService
,
billingService
,
rateLimitService
,
billingCacheService
,
identityService
,
httpUpstream
,
deferredService
)
openAIGatewayService
:=
service
.
NewOpenAIGatewayService
(
accountRepository
,
usageLogRepository
,
userRepository
,
userSubscriptionRepository
,
gatewayCache
,
configConfig
,
concurrencyService
,
billingService
,
rateLimitService
,
billingCacheService
,
httpUpstream
,
deferredService
)
openAIGatewayService
:=
service
.
NewOpenAIGatewayService
(
accountRepository
,
usageLogRepository
,
userRepository
,
userSubscriptionRepository
,
gatewayCache
,
configConfig
,
schedulerSnapshotService
,
concurrencyService
,
billingService
,
rateLimitService
,
billingCacheService
,
httpUpstream
,
deferredService
)
geminiMessagesCompatService
:=
service
.
NewGeminiMessagesCompatService
(
accountRepository
,
groupRepository
,
gatewayCache
,
geminiTokenProvider
,
rateLimitService
,
httpUpstream
,
antigravityGatewayService
,
configConfig
)
geminiMessagesCompatService
:=
service
.
NewGeminiMessagesCompatService
(
accountRepository
,
groupRepository
,
gatewayCache
,
schedulerSnapshotService
,
geminiTokenProvider
,
rateLimitService
,
httpUpstream
,
antigravityGatewayService
,
configConfig
)
opsService
:=
service
.
NewOpsService
(
opsRepository
,
settingRepository
,
configConfig
,
accountRepository
,
concurrencyService
,
gatewayService
,
openAIGatewayService
,
geminiMessagesCompatService
,
antigravityGatewayService
)
opsService
:=
service
.
NewOpsService
(
opsRepository
,
settingRepository
,
configConfig
,
accountRepository
,
concurrencyService
,
gatewayService
,
openAIGatewayService
,
geminiMessagesCompatService
,
antigravityGatewayService
)
settingHandler
:=
admin
.
NewSettingHandler
(
settingService
,
emailService
,
turnstileService
,
opsService
)
settingHandler
:=
admin
.
NewSettingHandler
(
settingService
,
emailService
,
turnstileService
,
opsService
)
opsHandler
:=
admin
.
NewOpsHandler
(
opsService
)
opsHandler
:=
admin
.
NewOpsHandler
(
opsService
)
...
@@ -165,7 +168,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
...
@@ -165,7 +168,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsScheduledReportService
:=
service
.
ProvideOpsScheduledReportService
(
opsService
,
userService
,
emailService
,
redisClient
,
configConfig
)
opsScheduledReportService
:=
service
.
ProvideOpsScheduledReportService
(
opsService
,
userService
,
emailService
,
redisClient
,
configConfig
)
tokenRefreshService
:=
service
.
ProvideTokenRefreshService
(
accountRepository
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
configConfig
)
tokenRefreshService
:=
service
.
ProvideTokenRefreshService
(
accountRepository
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
configConfig
)
accountExpiryService
:=
service
.
ProvideAccountExpiryService
(
accountRepository
)
accountExpiryService
:=
service
.
ProvideAccountExpiryService
(
accountRepository
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
tokenRefreshService
,
accountExpiryService
,
pricingService
,
emailQueueService
,
billingCacheService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
pricingService
,
emailQueueService
,
billingCacheService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
)
application
:=
&
Application
{
application
:=
&
Application
{
Server
:
httpServer
,
Server
:
httpServer
,
Cleanup
:
v
,
Cleanup
:
v
,
...
@@ -195,6 +198,7 @@ func provideCleanup(
...
@@ -195,6 +198,7 @@ func provideCleanup(
opsAlertEvaluator
*
service
.
OpsAlertEvaluatorService
,
opsAlertEvaluator
*
service
.
OpsAlertEvaluatorService
,
opsCleanup
*
service
.
OpsCleanupService
,
opsCleanup
*
service
.
OpsCleanupService
,
opsScheduledReport
*
service
.
OpsScheduledReportService
,
opsScheduledReport
*
service
.
OpsScheduledReportService
,
schedulerSnapshot
*
service
.
SchedulerSnapshotService
,
tokenRefresh
*
service
.
TokenRefreshService
,
tokenRefresh
*
service
.
TokenRefreshService
,
accountExpiry
*
service
.
AccountExpiryService
,
accountExpiry
*
service
.
AccountExpiryService
,
pricing
*
service
.
PricingService
,
pricing
*
service
.
PricingService
,
...
@@ -243,6 +247,12 @@ func provideCleanup(
...
@@ -243,6 +247,12 @@ func provideCleanup(
}
}
return
nil
return
nil
}},
}},
{
"SchedulerSnapshotService"
,
func
()
error
{
if
schedulerSnapshot
!=
nil
{
schedulerSnapshot
.
Stop
()
}
return
nil
}},
{
"TokenRefreshService"
,
func
()
error
{
{
"TokenRefreshService"
,
func
()
error
{
tokenRefresh
.
Stop
()
tokenRefresh
.
Stop
()
return
nil
return
nil
...
...
backend/internal/config/config.go
View file @
4da681f5
...
@@ -270,6 +270,29 @@ type GatewaySchedulingConfig struct {
...
@@ -270,6 +270,29 @@ type GatewaySchedulingConfig struct {
// 过期槽位清理周期(0 表示禁用)
// 过期槽位清理周期(0 表示禁用)
SlotCleanupInterval
time
.
Duration
`mapstructure:"slot_cleanup_interval"`
SlotCleanupInterval
time
.
Duration
`mapstructure:"slot_cleanup_interval"`
// 受控回源配置
DbFallbackEnabled
bool
`mapstructure:"db_fallback_enabled"`
// 受控回源超时(秒),0 表示不额外收紧超时
DbFallbackTimeoutSeconds
int
`mapstructure:"db_fallback_timeout_seconds"`
// 受控回源限流(实例级 QPS),0 表示不限制
DbFallbackMaxQPS
int
`mapstructure:"db_fallback_max_qps"`
// Outbox 轮询与滞后阈值配置
// Outbox 轮询周期(秒)
OutboxPollIntervalSeconds
int
`mapstructure:"outbox_poll_interval_seconds"`
// Outbox 滞后告警阈值(秒)
OutboxLagWarnSeconds
int
`mapstructure:"outbox_lag_warn_seconds"`
// Outbox 触发强制重建阈值(秒)
OutboxLagRebuildSeconds
int
`mapstructure:"outbox_lag_rebuild_seconds"`
// Outbox 连续滞后触发次数
OutboxLagRebuildFailures
int
`mapstructure:"outbox_lag_rebuild_failures"`
// Outbox 积压触发重建阈值(行数)
OutboxBacklogRebuildRows
int
`mapstructure:"outbox_backlog_rebuild_rows"`
// 全量重建周期配置
// 全量重建周期(秒),0 表示禁用
FullRebuildIntervalSeconds
int
`mapstructure:"full_rebuild_interval_seconds"`
}
}
func
(
s
*
ServerConfig
)
Address
()
string
{
func
(
s
*
ServerConfig
)
Address
()
string
{
...
@@ -744,11 +767,20 @@ func setDefaults() {
...
@@ -744,11 +767,20 @@ func setDefaults() {
viper
.
SetDefault
(
"gateway.stream_keepalive_interval"
,
10
)
viper
.
SetDefault
(
"gateway.stream_keepalive_interval"
,
10
)
viper
.
SetDefault
(
"gateway.max_line_size"
,
10
*
1024
*
1024
)
viper
.
SetDefault
(
"gateway.max_line_size"
,
10
*
1024
*
1024
)
viper
.
SetDefault
(
"gateway.scheduling.sticky_session_max_waiting"
,
3
)
viper
.
SetDefault
(
"gateway.scheduling.sticky_session_max_waiting"
,
3
)
viper
.
SetDefault
(
"gateway.scheduling.sticky_session_wait_timeout"
,
45
*
time
.
Second
)
viper
.
SetDefault
(
"gateway.scheduling.sticky_session_wait_timeout"
,
120
*
time
.
Second
)
viper
.
SetDefault
(
"gateway.scheduling.fallback_wait_timeout"
,
30
*
time
.
Second
)
viper
.
SetDefault
(
"gateway.scheduling.fallback_wait_timeout"
,
30
*
time
.
Second
)
viper
.
SetDefault
(
"gateway.scheduling.fallback_max_waiting"
,
100
)
viper
.
SetDefault
(
"gateway.scheduling.fallback_max_waiting"
,
100
)
viper
.
SetDefault
(
"gateway.scheduling.load_batch_enabled"
,
true
)
viper
.
SetDefault
(
"gateway.scheduling.load_batch_enabled"
,
true
)
viper
.
SetDefault
(
"gateway.scheduling.slot_cleanup_interval"
,
30
*
time
.
Second
)
viper
.
SetDefault
(
"gateway.scheduling.slot_cleanup_interval"
,
30
*
time
.
Second
)
viper
.
SetDefault
(
"gateway.scheduling.db_fallback_enabled"
,
true
)
viper
.
SetDefault
(
"gateway.scheduling.db_fallback_timeout_seconds"
,
0
)
viper
.
SetDefault
(
"gateway.scheduling.db_fallback_max_qps"
,
0
)
viper
.
SetDefault
(
"gateway.scheduling.outbox_poll_interval_seconds"
,
1
)
viper
.
SetDefault
(
"gateway.scheduling.outbox_lag_warn_seconds"
,
5
)
viper
.
SetDefault
(
"gateway.scheduling.outbox_lag_rebuild_seconds"
,
10
)
viper
.
SetDefault
(
"gateway.scheduling.outbox_lag_rebuild_failures"
,
3
)
viper
.
SetDefault
(
"gateway.scheduling.outbox_backlog_rebuild_rows"
,
10000
)
viper
.
SetDefault
(
"gateway.scheduling.full_rebuild_interval_seconds"
,
300
)
viper
.
SetDefault
(
"concurrency.ping_interval"
,
10
)
viper
.
SetDefault
(
"concurrency.ping_interval"
,
10
)
// TokenRefresh
// TokenRefresh
...
@@ -1021,6 +1053,35 @@ func (c *Config) Validate() error {
...
@@ -1021,6 +1053,35 @@ func (c *Config) Validate() error {
if
c
.
Gateway
.
Scheduling
.
SlotCleanupInterval
<
0
{
if
c
.
Gateway
.
Scheduling
.
SlotCleanupInterval
<
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.slot_cleanup_interval must be non-negative"
)
return
fmt
.
Errorf
(
"gateway.scheduling.slot_cleanup_interval must be non-negative"
)
}
}
if
c
.
Gateway
.
Scheduling
.
DbFallbackTimeoutSeconds
<
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.db_fallback_timeout_seconds must be non-negative"
)
}
if
c
.
Gateway
.
Scheduling
.
DbFallbackMaxQPS
<
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.db_fallback_max_qps must be non-negative"
)
}
if
c
.
Gateway
.
Scheduling
.
OutboxPollIntervalSeconds
<=
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.outbox_poll_interval_seconds must be positive"
)
}
if
c
.
Gateway
.
Scheduling
.
OutboxLagWarnSeconds
<
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.outbox_lag_warn_seconds must be non-negative"
)
}
if
c
.
Gateway
.
Scheduling
.
OutboxLagRebuildSeconds
<
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.outbox_lag_rebuild_seconds must be non-negative"
)
}
if
c
.
Gateway
.
Scheduling
.
OutboxLagRebuildFailures
<=
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.outbox_lag_rebuild_failures must be positive"
)
}
if
c
.
Gateway
.
Scheduling
.
OutboxBacklogRebuildRows
<
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.outbox_backlog_rebuild_rows must be non-negative"
)
}
if
c
.
Gateway
.
Scheduling
.
FullRebuildIntervalSeconds
<
0
{
return
fmt
.
Errorf
(
"gateway.scheduling.full_rebuild_interval_seconds must be non-negative"
)
}
if
c
.
Gateway
.
Scheduling
.
OutboxLagWarnSeconds
>
0
&&
c
.
Gateway
.
Scheduling
.
OutboxLagRebuildSeconds
>
0
&&
c
.
Gateway
.
Scheduling
.
OutboxLagRebuildSeconds
<
c
.
Gateway
.
Scheduling
.
OutboxLagWarnSeconds
{
return
fmt
.
Errorf
(
"gateway.scheduling.outbox_lag_rebuild_seconds must be >= outbox_lag_warn_seconds"
)
}
if
c
.
Ops
.
MetricsCollectorCache
.
TTL
<
0
{
if
c
.
Ops
.
MetricsCollectorCache
.
TTL
<
0
{
return
fmt
.
Errorf
(
"ops.metrics_collector_cache.ttl must be non-negative"
)
return
fmt
.
Errorf
(
"ops.metrics_collector_cache.ttl must be non-negative"
)
}
}
...
...
backend/internal/config/config_test.go
View file @
4da681f5
...
@@ -39,8 +39,8 @@ func TestLoadDefaultSchedulingConfig(t *testing.T) {
...
@@ -39,8 +39,8 @@ func TestLoadDefaultSchedulingConfig(t *testing.T) {
if
cfg
.
Gateway
.
Scheduling
.
StickySessionMaxWaiting
!=
3
{
if
cfg
.
Gateway
.
Scheduling
.
StickySessionMaxWaiting
!=
3
{
t
.
Fatalf
(
"StickySessionMaxWaiting = %d, want 3"
,
cfg
.
Gateway
.
Scheduling
.
StickySessionMaxWaiting
)
t
.
Fatalf
(
"StickySessionMaxWaiting = %d, want 3"
,
cfg
.
Gateway
.
Scheduling
.
StickySessionMaxWaiting
)
}
}
if
cfg
.
Gateway
.
Scheduling
.
StickySessionWaitTimeout
!=
45
*
time
.
Second
{
if
cfg
.
Gateway
.
Scheduling
.
StickySessionWaitTimeout
!=
120
*
time
.
Second
{
t
.
Fatalf
(
"StickySessionWaitTimeout = %v, want
45
s"
,
cfg
.
Gateway
.
Scheduling
.
StickySessionWaitTimeout
)
t
.
Fatalf
(
"StickySessionWaitTimeout = %v, want
120
s"
,
cfg
.
Gateway
.
Scheduling
.
StickySessionWaitTimeout
)
}
}
if
cfg
.
Gateway
.
Scheduling
.
FallbackWaitTimeout
!=
30
*
time
.
Second
{
if
cfg
.
Gateway
.
Scheduling
.
FallbackWaitTimeout
!=
30
*
time
.
Second
{
t
.
Fatalf
(
"FallbackWaitTimeout = %v, want 30s"
,
cfg
.
Gateway
.
Scheduling
.
FallbackWaitTimeout
)
t
.
Fatalf
(
"FallbackWaitTimeout = %v, want 30s"
,
cfg
.
Gateway
.
Scheduling
.
FallbackWaitTimeout
)
...
...
backend/internal/repository/account_repo.go
View file @
4da681f5
...
@@ -15,6 +15,7 @@ import (
...
@@ -15,6 +15,7 @@ import (
"database/sql"
"database/sql"
"encoding/json"
"encoding/json"
"errors"
"errors"
"log"
"strconv"
"strconv"
"time"
"time"
...
@@ -115,6 +116,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
...
@@ -115,6 +116,9 @@ func (r *accountRepository) Create(ctx context.Context, account *service.Account
account
.
ID
=
created
.
ID
account
.
ID
=
created
.
ID
account
.
CreatedAt
=
created
.
CreatedAt
account
.
CreatedAt
=
created
.
CreatedAt
account
.
UpdatedAt
=
created
.
UpdatedAt
account
.
UpdatedAt
=
created
.
UpdatedAt
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
account
.
ID
,
nil
,
buildSchedulerGroupPayload
(
account
.
GroupIDs
));
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue account create failed: account=%d err=%v"
,
account
.
ID
,
err
)
}
return
nil
return
nil
}
}
...
@@ -341,10 +345,17 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
...
@@ -341,10 +345,17 @@ func (r *accountRepository) Update(ctx context.Context, account *service.Account
return
translatePersistenceError
(
err
,
service
.
ErrAccountNotFound
,
nil
)
return
translatePersistenceError
(
err
,
service
.
ErrAccountNotFound
,
nil
)
}
}
account
.
UpdatedAt
=
updated
.
UpdatedAt
account
.
UpdatedAt
=
updated
.
UpdatedAt
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
account
.
ID
,
nil
,
buildSchedulerGroupPayload
(
account
.
GroupIDs
));
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue account update failed: account=%d err=%v"
,
account
.
ID
,
err
)
}
return
nil
return
nil
}
}
func
(
r
*
accountRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
func
(
r
*
accountRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
groupIDs
,
err
:=
r
.
loadAccountGroupIDs
(
ctx
,
id
)
if
err
!=
nil
{
return
err
}
// 使用事务保证账号与关联分组的删除原子性
// 使用事务保证账号与关联分组的删除原子性
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
if
err
!=
nil
&&
!
errors
.
Is
(
err
,
dbent
.
ErrTxStarted
)
{
if
err
!=
nil
&&
!
errors
.
Is
(
err
,
dbent
.
ErrTxStarted
)
{
...
@@ -368,7 +379,12 @@ func (r *accountRepository) Delete(ctx context.Context, id int64) error {
...
@@ -368,7 +379,12 @@ func (r *accountRepository) Delete(ctx context.Context, id int64) error {
}
}
if
tx
!=
nil
{
if
tx
!=
nil
{
return
tx
.
Commit
()
if
err
:=
tx
.
Commit
();
err
!=
nil
{
return
err
}
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
buildSchedulerGroupPayload
(
groupIDs
));
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue account delete failed: account=%d err=%v"
,
id
,
err
)
}
}
return
nil
return
nil
}
}
...
@@ -455,7 +471,18 @@ func (r *accountRepository) UpdateLastUsed(ctx context.Context, id int64) error
...
@@ -455,7 +471,18 @@ func (r *accountRepository) UpdateLastUsed(ctx context.Context, id int64) error
Where
(
dbaccount
.
IDEQ
(
id
))
.
Where
(
dbaccount
.
IDEQ
(
id
))
.
SetLastUsedAt
(
now
)
.
SetLastUsedAt
(
now
)
.
Save
(
ctx
)
Save
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
payload
:=
map
[
string
]
any
{
"last_used"
:
map
[
string
]
int64
{
strconv
.
FormatInt
(
id
,
10
)
:
now
.
Unix
(),
},
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountLastUsed
,
&
id
,
nil
,
payload
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue last used failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
BatchUpdateLastUsed
(
ctx
context
.
Context
,
updates
map
[
int64
]
time
.
Time
)
error
{
func
(
r
*
accountRepository
)
BatchUpdateLastUsed
(
ctx
context
.
Context
,
updates
map
[
int64
]
time
.
Time
)
error
{
...
@@ -479,7 +506,18 @@ func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map
...
@@ -479,7 +506,18 @@ func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map
args
=
append
(
args
,
pq
.
Array
(
ids
))
args
=
append
(
args
,
pq
.
Array
(
ids
))
_
,
err
:=
r
.
sql
.
ExecContext
(
ctx
,
caseSQL
,
args
...
)
_
,
err
:=
r
.
sql
.
ExecContext
(
ctx
,
caseSQL
,
args
...
)
return
err
if
err
!=
nil
{
return
err
}
lastUsedPayload
:=
make
(
map
[
string
]
int64
,
len
(
updates
))
for
id
,
ts
:=
range
updates
{
lastUsedPayload
[
strconv
.
FormatInt
(
id
,
10
)]
=
ts
.
Unix
()
}
payload
:=
map
[
string
]
any
{
"last_used"
:
lastUsedPayload
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountLastUsed
,
nil
,
nil
,
payload
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue batch last used failed: err=%v"
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
SetError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
{
func
(
r
*
accountRepository
)
SetError
(
ctx
context
.
Context
,
id
int64
,
errorMsg
string
)
error
{
...
@@ -488,7 +526,13 @@ func (r *accountRepository) SetError(ctx context.Context, id int64, errorMsg str
...
@@ -488,7 +526,13 @@ func (r *accountRepository) SetError(ctx context.Context, id int64, errorMsg str
SetStatus
(
service
.
StatusError
)
.
SetStatus
(
service
.
StatusError
)
.
SetErrorMessage
(
errorMsg
)
.
SetErrorMessage
(
errorMsg
)
.
Save
(
ctx
)
Save
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue set error failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
AddToGroup
(
ctx
context
.
Context
,
accountID
,
groupID
int64
,
priority
int
)
error
{
func
(
r
*
accountRepository
)
AddToGroup
(
ctx
context
.
Context
,
accountID
,
groupID
int64
,
priority
int
)
error
{
...
@@ -497,7 +541,14 @@ func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID i
...
@@ -497,7 +541,14 @@ func (r *accountRepository) AddToGroup(ctx context.Context, accountID, groupID i
SetGroupID
(
groupID
)
.
SetGroupID
(
groupID
)
.
SetPriority
(
priority
)
.
SetPriority
(
priority
)
.
Save
(
ctx
)
Save
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
payload
:=
buildSchedulerGroupPayload
([]
int64
{
groupID
})
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountGroupsChanged
,
&
accountID
,
nil
,
payload
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue add to group failed: account=%d group=%d err=%v"
,
accountID
,
groupID
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
RemoveFromGroup
(
ctx
context
.
Context
,
accountID
,
groupID
int64
)
error
{
func
(
r
*
accountRepository
)
RemoveFromGroup
(
ctx
context
.
Context
,
accountID
,
groupID
int64
)
error
{
...
@@ -507,7 +558,14 @@ func (r *accountRepository) RemoveFromGroup(ctx context.Context, accountID, grou
...
@@ -507,7 +558,14 @@ func (r *accountRepository) RemoveFromGroup(ctx context.Context, accountID, grou
dbaccountgroup
.
GroupIDEQ
(
groupID
),
dbaccountgroup
.
GroupIDEQ
(
groupID
),
)
.
)
.
Exec
(
ctx
)
Exec
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
payload
:=
buildSchedulerGroupPayload
([]
int64
{
groupID
})
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountGroupsChanged
,
&
accountID
,
nil
,
payload
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue remove from group failed: account=%d group=%d err=%v"
,
accountID
,
groupID
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
GetGroups
(
ctx
context
.
Context
,
accountID
int64
)
([]
service
.
Group
,
error
)
{
func
(
r
*
accountRepository
)
GetGroups
(
ctx
context
.
Context
,
accountID
int64
)
([]
service
.
Group
,
error
)
{
...
@@ -528,6 +586,10 @@ func (r *accountRepository) GetGroups(ctx context.Context, accountID int64) ([]s
...
@@ -528,6 +586,10 @@ func (r *accountRepository) GetGroups(ctx context.Context, accountID int64) ([]s
}
}
func
(
r
*
accountRepository
)
BindGroups
(
ctx
context
.
Context
,
accountID
int64
,
groupIDs
[]
int64
)
error
{
func
(
r
*
accountRepository
)
BindGroups
(
ctx
context
.
Context
,
accountID
int64
,
groupIDs
[]
int64
)
error
{
existingGroupIDs
,
err
:=
r
.
loadAccountGroupIDs
(
ctx
,
accountID
)
if
err
!=
nil
{
return
err
}
// 使用事务保证删除旧绑定与创建新绑定的原子性
// 使用事务保证删除旧绑定与创建新绑定的原子性
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
tx
,
err
:=
r
.
client
.
Tx
(
ctx
)
if
err
!=
nil
&&
!
errors
.
Is
(
err
,
dbent
.
ErrTxStarted
)
{
if
err
!=
nil
&&
!
errors
.
Is
(
err
,
dbent
.
ErrTxStarted
)
{
...
@@ -568,7 +630,13 @@ func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, gro
...
@@ -568,7 +630,13 @@ func (r *accountRepository) BindGroups(ctx context.Context, accountID int64, gro
}
}
if
tx
!=
nil
{
if
tx
!=
nil
{
return
tx
.
Commit
()
if
err
:=
tx
.
Commit
();
err
!=
nil
{
return
err
}
}
payload
:=
buildSchedulerGroupPayload
(
mergeGroupIDs
(
existingGroupIDs
,
groupIDs
))
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountGroupsChanged
,
&
accountID
,
nil
,
payload
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue bind groups failed: account=%d err=%v"
,
accountID
,
err
)
}
}
return
nil
return
nil
}
}
...
@@ -672,7 +740,13 @@ func (r *accountRepository) SetRateLimited(ctx context.Context, id int64, resetA
...
@@ -672,7 +740,13 @@ func (r *accountRepository) SetRateLimited(ctx context.Context, id int64, resetA
SetRateLimitedAt
(
now
)
.
SetRateLimitedAt
(
now
)
.
SetRateLimitResetAt
(
resetAt
)
.
SetRateLimitResetAt
(
resetAt
)
.
Save
(
ctx
)
Save
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue rate limit failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
SetAntigravityQuotaScopeLimit
(
ctx
context
.
Context
,
id
int64
,
scope
service
.
AntigravityQuotaScope
,
resetAt
time
.
Time
)
error
{
func
(
r
*
accountRepository
)
SetAntigravityQuotaScopeLimit
(
ctx
context
.
Context
,
id
int64
,
scope
service
.
AntigravityQuotaScope
,
resetAt
time
.
Time
)
error
{
...
@@ -706,6 +780,9 @@ func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, i
...
@@ -706,6 +780,9 @@ func (r *accountRepository) SetAntigravityQuotaScopeLimit(ctx context.Context, i
if
affected
==
0
{
if
affected
==
0
{
return
service
.
ErrAccountNotFound
return
service
.
ErrAccountNotFound
}
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue quota scope failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
return
nil
}
}
...
@@ -714,7 +791,13 @@ func (r *accountRepository) SetOverloaded(ctx context.Context, id int64, until t
...
@@ -714,7 +791,13 @@ func (r *accountRepository) SetOverloaded(ctx context.Context, id int64, until t
Where
(
dbaccount
.
IDEQ
(
id
))
.
Where
(
dbaccount
.
IDEQ
(
id
))
.
SetOverloadUntil
(
until
)
.
SetOverloadUntil
(
until
)
.
Save
(
ctx
)
Save
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue overload failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
SetTempUnschedulable
(
ctx
context
.
Context
,
id
int64
,
until
time
.
Time
,
reason
string
)
error
{
func
(
r
*
accountRepository
)
SetTempUnschedulable
(
ctx
context
.
Context
,
id
int64
,
until
time
.
Time
,
reason
string
)
error
{
...
@@ -727,7 +810,13 @@ func (r *accountRepository) SetTempUnschedulable(ctx context.Context, id int64,
...
@@ -727,7 +810,13 @@ func (r *accountRepository) SetTempUnschedulable(ctx context.Context, id int64,
AND deleted_at IS NULL
AND deleted_at IS NULL
AND (temp_unschedulable_until IS NULL OR temp_unschedulable_until < $1)
AND (temp_unschedulable_until IS NULL OR temp_unschedulable_until < $1)
`
,
until
,
reason
,
id
)
`
,
until
,
reason
,
id
)
return
err
if
err
!=
nil
{
return
err
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue temp unschedulable failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
ClearTempUnschedulable
(
ctx
context
.
Context
,
id
int64
)
error
{
func
(
r
*
accountRepository
)
ClearTempUnschedulable
(
ctx
context
.
Context
,
id
int64
)
error
{
...
@@ -739,7 +828,13 @@ func (r *accountRepository) ClearTempUnschedulable(ctx context.Context, id int64
...
@@ -739,7 +828,13 @@ func (r *accountRepository) ClearTempUnschedulable(ctx context.Context, id int64
WHERE id = $1
WHERE id = $1
AND deleted_at IS NULL
AND deleted_at IS NULL
`
,
id
)
`
,
id
)
return
err
if
err
!=
nil
{
return
err
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue clear temp unschedulable failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
ClearRateLimit
(
ctx
context
.
Context
,
id
int64
)
error
{
func
(
r
*
accountRepository
)
ClearRateLimit
(
ctx
context
.
Context
,
id
int64
)
error
{
...
@@ -749,7 +844,13 @@ func (r *accountRepository) ClearRateLimit(ctx context.Context, id int64) error
...
@@ -749,7 +844,13 @@ func (r *accountRepository) ClearRateLimit(ctx context.Context, id int64) error
ClearRateLimitResetAt
()
.
ClearRateLimitResetAt
()
.
ClearOverloadUntil
()
.
ClearOverloadUntil
()
.
Save
(
ctx
)
Save
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue clear rate limit failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
ClearAntigravityQuotaScopes
(
ctx
context
.
Context
,
id
int64
)
error
{
func
(
r
*
accountRepository
)
ClearAntigravityQuotaScopes
(
ctx
context
.
Context
,
id
int64
)
error
{
...
@@ -770,6 +871,9 @@ func (r *accountRepository) ClearAntigravityQuotaScopes(ctx context.Context, id
...
@@ -770,6 +871,9 @@ func (r *accountRepository) ClearAntigravityQuotaScopes(ctx context.Context, id
if
affected
==
0
{
if
affected
==
0
{
return
service
.
ErrAccountNotFound
return
service
.
ErrAccountNotFound
}
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue clear quota scopes failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
return
nil
}
}
...
@@ -792,7 +896,13 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu
...
@@ -792,7 +896,13 @@ func (r *accountRepository) SetSchedulable(ctx context.Context, id int64, schedu
Where
(
dbaccount
.
IDEQ
(
id
))
.
Where
(
dbaccount
.
IDEQ
(
id
))
.
SetSchedulable
(
schedulable
)
.
SetSchedulable
(
schedulable
)
.
Save
(
ctx
)
Save
(
ctx
)
return
err
if
err
!=
nil
{
return
err
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue schedulable change failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
accountRepository
)
AutoPauseExpiredAccounts
(
ctx
context
.
Context
,
now
time
.
Time
)
(
int64
,
error
)
{
func
(
r
*
accountRepository
)
AutoPauseExpiredAccounts
(
ctx
context
.
Context
,
now
time
.
Time
)
(
int64
,
error
)
{
...
@@ -813,6 +923,11 @@ func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now ti
...
@@ -813,6 +923,11 @@ func (r *accountRepository) AutoPauseExpiredAccounts(ctx context.Context, now ti
if
err
!=
nil
{
if
err
!=
nil
{
return
0
,
err
return
0
,
err
}
}
if
rows
>
0
{
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventFullRebuild
,
nil
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue auto pause rebuild failed: err=%v"
,
err
)
}
}
return
rows
,
nil
return
rows
,
nil
}
}
...
@@ -844,6 +959,9 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m
...
@@ -844,6 +959,9 @@ func (r *accountRepository) UpdateExtra(ctx context.Context, id int64, updates m
if
affected
==
0
{
if
affected
==
0
{
return
service
.
ErrAccountNotFound
return
service
.
ErrAccountNotFound
}
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountChanged
,
&
id
,
nil
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue extra update failed: account=%d err=%v"
,
id
,
err
)
}
return
nil
return
nil
}
}
...
@@ -928,6 +1046,12 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
...
@@ -928,6 +1046,12 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
if
err
!=
nil
{
if
err
!=
nil
{
return
0
,
err
return
0
,
err
}
}
if
rows
>
0
{
payload
:=
map
[
string
]
any
{
"account_ids"
:
ids
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventAccountBulkChanged
,
nil
,
nil
,
payload
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue bulk update failed: err=%v"
,
err
)
}
}
return
rows
,
nil
return
rows
,
nil
}
}
...
@@ -1170,6 +1294,54 @@ func (r *accountRepository) loadAccountGroups(ctx context.Context, accountIDs []
...
@@ -1170,6 +1294,54 @@ func (r *accountRepository) loadAccountGroups(ctx context.Context, accountIDs []
return
groupsByAccount
,
groupIDsByAccount
,
accountGroupsByAccount
,
nil
return
groupsByAccount
,
groupIDsByAccount
,
accountGroupsByAccount
,
nil
}
}
func
(
r
*
accountRepository
)
loadAccountGroupIDs
(
ctx
context
.
Context
,
accountID
int64
)
([]
int64
,
error
)
{
entries
,
err
:=
r
.
client
.
AccountGroup
.
Query
()
.
Where
(
dbaccountgroup
.
AccountIDEQ
(
accountID
))
.
All
(
ctx
)
if
err
!=
nil
{
return
nil
,
err
}
ids
:=
make
([]
int64
,
0
,
len
(
entries
))
for
_
,
entry
:=
range
entries
{
ids
=
append
(
ids
,
entry
.
GroupID
)
}
return
ids
,
nil
}
func
mergeGroupIDs
(
a
[]
int64
,
b
[]
int64
)
[]
int64
{
seen
:=
make
(
map
[
int64
]
struct
{},
len
(
a
)
+
len
(
b
))
out
:=
make
([]
int64
,
0
,
len
(
a
)
+
len
(
b
))
for
_
,
id
:=
range
a
{
if
id
<=
0
{
continue
}
if
_
,
ok
:=
seen
[
id
];
ok
{
continue
}
seen
[
id
]
=
struct
{}{}
out
=
append
(
out
,
id
)
}
for
_
,
id
:=
range
b
{
if
id
<=
0
{
continue
}
if
_
,
ok
:=
seen
[
id
];
ok
{
continue
}
seen
[
id
]
=
struct
{}{}
out
=
append
(
out
,
id
)
}
return
out
}
func
buildSchedulerGroupPayload
(
groupIDs
[]
int64
)
map
[
string
]
any
{
if
len
(
groupIDs
)
==
0
{
return
nil
}
return
map
[
string
]
any
{
"group_ids"
:
groupIDs
}
}
func
accountEntityToService
(
m
*
dbent
.
Account
)
*
service
.
Account
{
func
accountEntityToService
(
m
*
dbent
.
Account
)
*
service
.
Account
{
if
m
==
nil
{
if
m
==
nil
{
return
nil
return
nil
...
...
backend/internal/repository/group_repo.go
View file @
4da681f5
...
@@ -4,6 +4,7 @@ import (
...
@@ -4,6 +4,7 @@ import (
"context"
"context"
"database/sql"
"database/sql"
"errors"
"errors"
"log"
dbent
"github.com/Wei-Shaw/sub2api/ent"
dbent
"github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/ent/apikey"
"github.com/Wei-Shaw/sub2api/ent/apikey"
...
@@ -55,6 +56,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
...
@@ -55,6 +56,9 @@ func (r *groupRepository) Create(ctx context.Context, groupIn *service.Group) er
groupIn
.
ID
=
created
.
ID
groupIn
.
ID
=
created
.
ID
groupIn
.
CreatedAt
=
created
.
CreatedAt
groupIn
.
CreatedAt
=
created
.
CreatedAt
groupIn
.
UpdatedAt
=
created
.
UpdatedAt
groupIn
.
UpdatedAt
=
created
.
UpdatedAt
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventGroupChanged
,
nil
,
&
groupIn
.
ID
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue group create failed: group=%d err=%v"
,
groupIn
.
ID
,
err
)
}
}
}
return
translatePersistenceError
(
err
,
nil
,
service
.
ErrGroupExists
)
return
translatePersistenceError
(
err
,
nil
,
service
.
ErrGroupExists
)
}
}
...
@@ -111,12 +115,21 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
...
@@ -111,12 +115,21 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
return
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
service
.
ErrGroupExists
)
return
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
service
.
ErrGroupExists
)
}
}
groupIn
.
UpdatedAt
=
updated
.
UpdatedAt
groupIn
.
UpdatedAt
=
updated
.
UpdatedAt
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventGroupChanged
,
nil
,
&
groupIn
.
ID
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue group update failed: group=%d err=%v"
,
groupIn
.
ID
,
err
)
}
return
nil
return
nil
}
}
func
(
r
*
groupRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
func
(
r
*
groupRepository
)
Delete
(
ctx
context
.
Context
,
id
int64
)
error
{
_
,
err
:=
r
.
client
.
Group
.
Delete
()
.
Where
(
group
.
IDEQ
(
id
))
.
Exec
(
ctx
)
_
,
err
:=
r
.
client
.
Group
.
Delete
()
.
Where
(
group
.
IDEQ
(
id
))
.
Exec
(
ctx
)
return
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
nil
)
if
err
!=
nil
{
return
translatePersistenceError
(
err
,
service
.
ErrGroupNotFound
,
nil
)
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventGroupChanged
,
nil
,
&
id
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue group delete failed: group=%d err=%v"
,
id
,
err
)
}
return
nil
}
}
func
(
r
*
groupRepository
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
Group
,
*
pagination
.
PaginationResult
,
error
)
{
func
(
r
*
groupRepository
)
List
(
ctx
context
.
Context
,
params
pagination
.
PaginationParams
)
([]
service
.
Group
,
*
pagination
.
PaginationResult
,
error
)
{
...
@@ -246,6 +259,9 @@ func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, grou
...
@@ -246,6 +259,9 @@ func (r *groupRepository) DeleteAccountGroupsByGroupID(ctx context.Context, grou
return
0
,
err
return
0
,
err
}
}
affected
,
_
:=
res
.
RowsAffected
()
affected
,
_
:=
res
.
RowsAffected
()
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventGroupChanged
,
nil
,
&
groupID
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue group account clear failed: group=%d err=%v"
,
groupID
,
err
)
}
return
affected
,
nil
return
affected
,
nil
}
}
...
@@ -353,6 +369,9 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64,
...
@@ -353,6 +369,9 @@ func (r *groupRepository) DeleteCascade(ctx context.Context, id int64) ([]int64,
return
nil
,
err
return
nil
,
err
}
}
}
}
if
err
:=
enqueueSchedulerOutbox
(
ctx
,
r
.
sql
,
service
.
SchedulerOutboxEventGroupChanged
,
nil
,
&
id
,
nil
);
err
!=
nil
{
log
.
Printf
(
"[SchedulerOutbox] enqueue group cascade delete failed: group=%d err=%v"
,
id
,
err
)
}
return
affectedUserIDs
,
nil
return
affectedUserIDs
,
nil
}
}
...
...
backend/internal/repository/migrations_runner.go
View file @
4da681f5
...
@@ -28,6 +28,23 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
...
@@ -28,6 +28,23 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
);
);
`
`
const
atlasSchemaRevisionsTableDDL
=
`
CREATE TABLE IF NOT EXISTS atlas_schema_revisions (
version TEXT PRIMARY KEY,
description TEXT NOT NULL,
type INTEGER NOT NULL,
applied INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
executed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
execution_time BIGINT NOT NULL DEFAULT 0,
error TEXT NULL,
error_stmt TEXT NULL,
hash TEXT NOT NULL DEFAULT '',
partial_hashes TEXT[] NULL,
operator_version TEXT NULL
);
`
// migrationsAdvisoryLockID 是用于序列化迁移操作的 PostgreSQL Advisory Lock ID。
// migrationsAdvisoryLockID 是用于序列化迁移操作的 PostgreSQL Advisory Lock ID。
// 在多实例部署场景下,该锁确保同一时间只有一个实例执行迁移。
// 在多实例部署场景下,该锁确保同一时间只有一个实例执行迁移。
// 任何稳定的 int64 值都可以,只要不与同一数据库中的其他锁冲突即可。
// 任何稳定的 int64 值都可以,只要不与同一数据库中的其他锁冲突即可。
...
@@ -94,6 +111,11 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
...
@@ -94,6 +111,11 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
return
fmt
.
Errorf
(
"create schema_migrations: %w"
,
err
)
return
fmt
.
Errorf
(
"create schema_migrations: %w"
,
err
)
}
}
// 自动对齐 Atlas 基线(如果检测到 legacy schema_migrations 且缺失 atlas_schema_revisions)。
if
err
:=
ensureAtlasBaselineAligned
(
ctx
,
db
,
fsys
);
err
!=
nil
{
return
err
}
// 获取所有 .sql 迁移文件并按文件名排序。
// 获取所有 .sql 迁移文件并按文件名排序。
// 命名规范:使用零填充数字前缀(如 001_init.sql, 002_add_users.sql)。
// 命名规范:使用零填充数字前缀(如 001_init.sql, 002_add_users.sql)。
files
,
err
:=
fs
.
Glob
(
fsys
,
"*.sql"
)
files
,
err
:=
fs
.
Glob
(
fsys
,
"*.sql"
)
...
@@ -172,6 +194,80 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
...
@@ -172,6 +194,80 @@ func applyMigrationsFS(ctx context.Context, db *sql.DB, fsys fs.FS) error {
return
nil
return
nil
}
}
func
ensureAtlasBaselineAligned
(
ctx
context
.
Context
,
db
*
sql
.
DB
,
fsys
fs
.
FS
)
error
{
hasLegacy
,
err
:=
tableExists
(
ctx
,
db
,
"schema_migrations"
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"check schema_migrations: %w"
,
err
)
}
if
!
hasLegacy
{
return
nil
}
hasAtlas
,
err
:=
tableExists
(
ctx
,
db
,
"atlas_schema_revisions"
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"check atlas_schema_revisions: %w"
,
err
)
}
if
!
hasAtlas
{
if
_
,
err
:=
db
.
ExecContext
(
ctx
,
atlasSchemaRevisionsTableDDL
);
err
!=
nil
{
return
fmt
.
Errorf
(
"create atlas_schema_revisions: %w"
,
err
)
}
}
var
count
int
if
err
:=
db
.
QueryRowContext
(
ctx
,
"SELECT COUNT(*) FROM atlas_schema_revisions"
)
.
Scan
(
&
count
);
err
!=
nil
{
return
fmt
.
Errorf
(
"count atlas_schema_revisions: %w"
,
err
)
}
if
count
>
0
{
return
nil
}
version
,
description
,
hash
,
err
:=
latestMigrationBaseline
(
fsys
)
if
err
!=
nil
{
return
fmt
.
Errorf
(
"atlas baseline version: %w"
,
err
)
}
if
_
,
err
:=
db
.
ExecContext
(
ctx
,
`
INSERT INTO atlas_schema_revisions (version, description, type, applied, total, executed_at, execution_time, hash)
VALUES ($1, $2, $3, 0, 0, NOW(), 0, $4)
`
,
version
,
description
,
1
,
hash
);
err
!=
nil
{
return
fmt
.
Errorf
(
"insert atlas baseline: %w"
,
err
)
}
return
nil
}
func
tableExists
(
ctx
context
.
Context
,
db
*
sql
.
DB
,
tableName
string
)
(
bool
,
error
)
{
var
exists
bool
err
:=
db
.
QueryRowContext
(
ctx
,
`
SELECT EXISTS (
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = $1
)
`
,
tableName
)
.
Scan
(
&
exists
)
return
exists
,
err
}
func
latestMigrationBaseline
(
fsys
fs
.
FS
)
(
string
,
string
,
string
,
error
)
{
files
,
err
:=
fs
.
Glob
(
fsys
,
"*.sql"
)
if
err
!=
nil
{
return
""
,
""
,
""
,
err
}
if
len
(
files
)
==
0
{
return
"baseline"
,
"baseline"
,
""
,
nil
}
sort
.
Strings
(
files
)
name
:=
files
[
len
(
files
)
-
1
]
contentBytes
,
err
:=
fs
.
ReadFile
(
fsys
,
name
)
if
err
!=
nil
{
return
""
,
""
,
""
,
err
}
content
:=
strings
.
TrimSpace
(
string
(
contentBytes
))
sum
:=
sha256
.
Sum256
([]
byte
(
content
))
hash
:=
hex
.
EncodeToString
(
sum
[
:
])
version
:=
strings
.
TrimSuffix
(
name
,
".sql"
)
return
version
,
version
,
hash
,
nil
}
// pgAdvisoryLock 获取 PostgreSQL Advisory Lock。
// pgAdvisoryLock 获取 PostgreSQL Advisory Lock。
// Advisory Lock 是一种轻量级的锁机制,不与任何特定的数据库对象关联。
// Advisory Lock 是一种轻量级的锁机制,不与任何特定的数据库对象关联。
// 它非常适合用于应用层面的分布式锁场景,如迁移序列化。
// 它非常适合用于应用层面的分布式锁场景,如迁移序列化。
...
...
backend/internal/repository/scheduler_cache.go
0 → 100644
View file @
4da681f5
package
repository
import
(
"context"
"encoding/json"
"fmt"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
const
(
schedulerBucketSetKey
=
"sched:buckets"
schedulerOutboxWatermarkKey
=
"sched:outbox:watermark"
schedulerAccountPrefix
=
"sched:acc:"
schedulerActivePrefix
=
"sched:active:"
schedulerReadyPrefix
=
"sched:ready:"
schedulerVersionPrefix
=
"sched:ver:"
schedulerSnapshotPrefix
=
"sched:"
schedulerLockPrefix
=
"sched:lock:"
)
type
schedulerCache
struct
{
rdb
*
redis
.
Client
}
func
NewSchedulerCache
(
rdb
*
redis
.
Client
)
service
.
SchedulerCache
{
return
&
schedulerCache
{
rdb
:
rdb
}
}
func
(
c
*
schedulerCache
)
GetSnapshot
(
ctx
context
.
Context
,
bucket
service
.
SchedulerBucket
)
([]
*
service
.
Account
,
bool
,
error
)
{
readyKey
:=
schedulerBucketKey
(
schedulerReadyPrefix
,
bucket
)
readyVal
,
err
:=
c
.
rdb
.
Get
(
ctx
,
readyKey
)
.
Result
()
if
err
==
redis
.
Nil
{
return
nil
,
false
,
nil
}
if
err
!=
nil
{
return
nil
,
false
,
err
}
if
readyVal
!=
"1"
{
return
nil
,
false
,
nil
}
activeKey
:=
schedulerBucketKey
(
schedulerActivePrefix
,
bucket
)
activeVal
,
err
:=
c
.
rdb
.
Get
(
ctx
,
activeKey
)
.
Result
()
if
err
==
redis
.
Nil
{
return
nil
,
false
,
nil
}
if
err
!=
nil
{
return
nil
,
false
,
err
}
snapshotKey
:=
schedulerSnapshotKey
(
bucket
,
activeVal
)
ids
,
err
:=
c
.
rdb
.
ZRange
(
ctx
,
snapshotKey
,
0
,
-
1
)
.
Result
()
if
err
!=
nil
{
return
nil
,
false
,
err
}
if
len
(
ids
)
==
0
{
return
[]
*
service
.
Account
{},
true
,
nil
}
keys
:=
make
([]
string
,
0
,
len
(
ids
))
for
_
,
id
:=
range
ids
{
keys
=
append
(
keys
,
schedulerAccountKey
(
id
))
}
values
,
err
:=
c
.
rdb
.
MGet
(
ctx
,
keys
...
)
.
Result
()
if
err
!=
nil
{
return
nil
,
false
,
err
}
accounts
:=
make
([]
*
service
.
Account
,
0
,
len
(
values
))
for
_
,
val
:=
range
values
{
if
val
==
nil
{
return
nil
,
false
,
nil
}
account
,
err
:=
decodeCachedAccount
(
val
)
if
err
!=
nil
{
return
nil
,
false
,
err
}
accounts
=
append
(
accounts
,
account
)
}
return
accounts
,
true
,
nil
}
func
(
c
*
schedulerCache
)
SetSnapshot
(
ctx
context
.
Context
,
bucket
service
.
SchedulerBucket
,
accounts
[]
service
.
Account
)
error
{
activeKey
:=
schedulerBucketKey
(
schedulerActivePrefix
,
bucket
)
oldActive
,
_
:=
c
.
rdb
.
Get
(
ctx
,
activeKey
)
.
Result
()
versionKey
:=
schedulerBucketKey
(
schedulerVersionPrefix
,
bucket
)
version
,
err
:=
c
.
rdb
.
Incr
(
ctx
,
versionKey
)
.
Result
()
if
err
!=
nil
{
return
err
}
versionStr
:=
strconv
.
FormatInt
(
version
,
10
)
snapshotKey
:=
schedulerSnapshotKey
(
bucket
,
versionStr
)
pipe
:=
c
.
rdb
.
Pipeline
()
for
_
,
account
:=
range
accounts
{
payload
,
err
:=
json
.
Marshal
(
account
)
if
err
!=
nil
{
return
err
}
pipe
.
Set
(
ctx
,
schedulerAccountKey
(
strconv
.
FormatInt
(
account
.
ID
,
10
)),
payload
,
0
)
}
if
len
(
accounts
)
>
0
{
// 使用序号作为 score,保持数据库返回的排序语义。
members
:=
make
([]
redis
.
Z
,
0
,
len
(
accounts
))
for
idx
,
account
:=
range
accounts
{
members
=
append
(
members
,
redis
.
Z
{
Score
:
float64
(
idx
),
Member
:
strconv
.
FormatInt
(
account
.
ID
,
10
),
})
}
pipe
.
ZAdd
(
ctx
,
snapshotKey
,
members
...
)
}
else
{
pipe
.
Del
(
ctx
,
snapshotKey
)
}
pipe
.
Set
(
ctx
,
activeKey
,
versionStr
,
0
)
pipe
.
Set
(
ctx
,
schedulerBucketKey
(
schedulerReadyPrefix
,
bucket
),
"1"
,
0
)
pipe
.
SAdd
(
ctx
,
schedulerBucketSetKey
,
bucket
.
String
())
if
_
,
err
:=
pipe
.
Exec
(
ctx
);
err
!=
nil
{
return
err
}
if
oldActive
!=
""
&&
oldActive
!=
versionStr
{
_
=
c
.
rdb
.
Del
(
ctx
,
schedulerSnapshotKey
(
bucket
,
oldActive
))
.
Err
()
}
return
nil
}
func
(
c
*
schedulerCache
)
GetAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
service
.
Account
,
error
)
{
key
:=
schedulerAccountKey
(
strconv
.
FormatInt
(
accountID
,
10
))
val
,
err
:=
c
.
rdb
.
Get
(
ctx
,
key
)
.
Result
()
if
err
==
redis
.
Nil
{
return
nil
,
nil
}
if
err
!=
nil
{
return
nil
,
err
}
return
decodeCachedAccount
(
val
)
}
func
(
c
*
schedulerCache
)
SetAccount
(
ctx
context
.
Context
,
account
*
service
.
Account
)
error
{
if
account
==
nil
||
account
.
ID
<=
0
{
return
nil
}
payload
,
err
:=
json
.
Marshal
(
account
)
if
err
!=
nil
{
return
err
}
key
:=
schedulerAccountKey
(
strconv
.
FormatInt
(
account
.
ID
,
10
))
return
c
.
rdb
.
Set
(
ctx
,
key
,
payload
,
0
)
.
Err
()
}
func
(
c
*
schedulerCache
)
DeleteAccount
(
ctx
context
.
Context
,
accountID
int64
)
error
{
if
accountID
<=
0
{
return
nil
}
key
:=
schedulerAccountKey
(
strconv
.
FormatInt
(
accountID
,
10
))
return
c
.
rdb
.
Del
(
ctx
,
key
)
.
Err
()
}
func
(
c
*
schedulerCache
)
UpdateLastUsed
(
ctx
context
.
Context
,
updates
map
[
int64
]
time
.
Time
)
error
{
if
len
(
updates
)
==
0
{
return
nil
}
keys
:=
make
([]
string
,
0
,
len
(
updates
))
ids
:=
make
([]
int64
,
0
,
len
(
updates
))
for
id
:=
range
updates
{
keys
=
append
(
keys
,
schedulerAccountKey
(
strconv
.
FormatInt
(
id
,
10
)))
ids
=
append
(
ids
,
id
)
}
values
,
err
:=
c
.
rdb
.
MGet
(
ctx
,
keys
...
)
.
Result
()
if
err
!=
nil
{
return
err
}
pipe
:=
c
.
rdb
.
Pipeline
()
for
i
,
val
:=
range
values
{
if
val
==
nil
{
continue
}
account
,
err
:=
decodeCachedAccount
(
val
)
if
err
!=
nil
{
return
err
}
account
.
LastUsedAt
=
ptrTime
(
updates
[
ids
[
i
]])
updated
,
err
:=
json
.
Marshal
(
account
)
if
err
!=
nil
{
return
err
}
pipe
.
Set
(
ctx
,
keys
[
i
],
updated
,
0
)
}
_
,
err
=
pipe
.
Exec
(
ctx
)
return
err
}
func
(
c
*
schedulerCache
)
TryLockBucket
(
ctx
context
.
Context
,
bucket
service
.
SchedulerBucket
,
ttl
time
.
Duration
)
(
bool
,
error
)
{
key
:=
schedulerBucketKey
(
schedulerLockPrefix
,
bucket
)
return
c
.
rdb
.
SetNX
(
ctx
,
key
,
time
.
Now
()
.
UnixNano
(),
ttl
)
.
Result
()
}
func
(
c
*
schedulerCache
)
ListBuckets
(
ctx
context
.
Context
)
([]
service
.
SchedulerBucket
,
error
)
{
raw
,
err
:=
c
.
rdb
.
SMembers
(
ctx
,
schedulerBucketSetKey
)
.
Result
()
if
err
!=
nil
{
return
nil
,
err
}
out
:=
make
([]
service
.
SchedulerBucket
,
0
,
len
(
raw
))
for
_
,
entry
:=
range
raw
{
bucket
,
ok
:=
service
.
ParseSchedulerBucket
(
entry
)
if
!
ok
{
continue
}
out
=
append
(
out
,
bucket
)
}
return
out
,
nil
}
func
(
c
*
schedulerCache
)
GetOutboxWatermark
(
ctx
context
.
Context
)
(
int64
,
error
)
{
val
,
err
:=
c
.
rdb
.
Get
(
ctx
,
schedulerOutboxWatermarkKey
)
.
Result
()
if
err
==
redis
.
Nil
{
return
0
,
nil
}
if
err
!=
nil
{
return
0
,
err
}
id
,
err
:=
strconv
.
ParseInt
(
val
,
10
,
64
)
if
err
!=
nil
{
return
0
,
err
}
return
id
,
nil
}
func
(
c
*
schedulerCache
)
SetOutboxWatermark
(
ctx
context
.
Context
,
id
int64
)
error
{
return
c
.
rdb
.
Set
(
ctx
,
schedulerOutboxWatermarkKey
,
strconv
.
FormatInt
(
id
,
10
),
0
)
.
Err
()
}
func
schedulerBucketKey
(
prefix
string
,
bucket
service
.
SchedulerBucket
)
string
{
return
fmt
.
Sprintf
(
"%s%d:%s:%s"
,
prefix
,
bucket
.
GroupID
,
bucket
.
Platform
,
bucket
.
Mode
)
}
func
schedulerSnapshotKey
(
bucket
service
.
SchedulerBucket
,
version
string
)
string
{
return
fmt
.
Sprintf
(
"%s%d:%s:%s:v%s"
,
schedulerSnapshotPrefix
,
bucket
.
GroupID
,
bucket
.
Platform
,
bucket
.
Mode
,
version
)
}
func
schedulerAccountKey
(
id
string
)
string
{
return
schedulerAccountPrefix
+
id
}
func
ptrTime
(
t
time
.
Time
)
*
time
.
Time
{
return
&
t
}
func
decodeCachedAccount
(
val
any
)
(
*
service
.
Account
,
error
)
{
var
payload
[]
byte
switch
raw
:=
val
.
(
type
)
{
case
string
:
payload
=
[]
byte
(
raw
)
case
[]
byte
:
payload
=
raw
default
:
return
nil
,
fmt
.
Errorf
(
"unexpected account cache type: %T"
,
val
)
}
var
account
service
.
Account
if
err
:=
json
.
Unmarshal
(
payload
,
&
account
);
err
!=
nil
{
return
nil
,
err
}
return
&
account
,
nil
}
backend/internal/repository/scheduler_outbox_repo.go
0 → 100644
View file @
4da681f5
package
repository
import
(
"context"
"database/sql"
"encoding/json"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type
schedulerOutboxRepository
struct
{
db
*
sql
.
DB
}
func
NewSchedulerOutboxRepository
(
db
*
sql
.
DB
)
service
.
SchedulerOutboxRepository
{
return
&
schedulerOutboxRepository
{
db
:
db
}
}
func
(
r
*
schedulerOutboxRepository
)
ListAfter
(
ctx
context
.
Context
,
afterID
int64
,
limit
int
)
([]
service
.
SchedulerOutboxEvent
,
error
)
{
if
limit
<=
0
{
limit
=
100
}
rows
,
err
:=
r
.
db
.
QueryContext
(
ctx
,
`
SELECT id, event_type, account_id, group_id, payload, created_at
FROM scheduler_outbox
WHERE id > $1
ORDER BY id ASC
LIMIT $2
`
,
afterID
,
limit
)
if
err
!=
nil
{
return
nil
,
err
}
defer
func
()
{
_
=
rows
.
Close
()
}()
events
:=
make
([]
service
.
SchedulerOutboxEvent
,
0
,
limit
)
for
rows
.
Next
()
{
var
(
payloadRaw
[]
byte
accountID
sql
.
NullInt64
groupID
sql
.
NullInt64
event
service
.
SchedulerOutboxEvent
)
if
err
:=
rows
.
Scan
(
&
event
.
ID
,
&
event
.
EventType
,
&
accountID
,
&
groupID
,
&
payloadRaw
,
&
event
.
CreatedAt
);
err
!=
nil
{
return
nil
,
err
}
if
accountID
.
Valid
{
v
:=
accountID
.
Int64
event
.
AccountID
=
&
v
}
if
groupID
.
Valid
{
v
:=
groupID
.
Int64
event
.
GroupID
=
&
v
}
if
len
(
payloadRaw
)
>
0
{
var
payload
map
[
string
]
any
if
err
:=
json
.
Unmarshal
(
payloadRaw
,
&
payload
);
err
!=
nil
{
return
nil
,
err
}
event
.
Payload
=
payload
}
events
=
append
(
events
,
event
)
}
if
err
:=
rows
.
Err
();
err
!=
nil
{
return
nil
,
err
}
return
events
,
nil
}
func
(
r
*
schedulerOutboxRepository
)
MaxID
(
ctx
context
.
Context
)
(
int64
,
error
)
{
var
maxID
int64
if
err
:=
r
.
db
.
QueryRowContext
(
ctx
,
"SELECT COALESCE(MAX(id), 0) FROM scheduler_outbox"
)
.
Scan
(
&
maxID
);
err
!=
nil
{
return
0
,
err
}
return
maxID
,
nil
}
func
enqueueSchedulerOutbox
(
ctx
context
.
Context
,
exec
sqlExecutor
,
eventType
string
,
accountID
*
int64
,
groupID
*
int64
,
payload
any
)
error
{
if
exec
==
nil
{
return
nil
}
var
payloadArg
any
if
payload
!=
nil
{
encoded
,
err
:=
json
.
Marshal
(
payload
)
if
err
!=
nil
{
return
err
}
payloadArg
=
encoded
}
_
,
err
:=
exec
.
ExecContext
(
ctx
,
`
INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)
VALUES ($1, $2, $3, $4)
`
,
eventType
,
accountID
,
groupID
,
payloadArg
)
return
err
}
backend/internal/repository/scheduler_snapshot_outbox_integration_test.go
0 → 100644
View file @
4da681f5
//go:build integration
package
repository
import
(
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func
TestSchedulerSnapshotOutboxReplay
(
t
*
testing
.
T
)
{
ctx
:=
context
.
Background
()
rdb
:=
testRedis
(
t
)
client
:=
testEntClient
(
t
)
_
,
_
=
integrationDB
.
ExecContext
(
ctx
,
"TRUNCATE scheduler_outbox"
)
accountRepo
:=
newAccountRepositoryWithSQL
(
client
,
integrationDB
)
outboxRepo
:=
NewSchedulerOutboxRepository
(
integrationDB
)
cache
:=
NewSchedulerCache
(
rdb
)
cfg
:=
&
config
.
Config
{
RunMode
:
config
.
RunModeStandard
,
Gateway
:
config
.
GatewayConfig
{
Scheduling
:
config
.
GatewaySchedulingConfig
{
OutboxPollIntervalSeconds
:
1
,
FullRebuildIntervalSeconds
:
0
,
DbFallbackEnabled
:
true
,
},
},
}
account
:=
&
service
.
Account
{
Name
:
"outbox-replay-"
+
time
.
Now
()
.
Format
(
"150405.000000"
),
Platform
:
service
.
PlatformOpenAI
,
Type
:
service
.
AccountTypeAPIKey
,
Status
:
service
.
StatusActive
,
Schedulable
:
true
,
Concurrency
:
3
,
Priority
:
1
,
Credentials
:
map
[
string
]
any
{},
Extra
:
map
[
string
]
any
{},
}
require
.
NoError
(
t
,
accountRepo
.
Create
(
ctx
,
account
))
require
.
NoError
(
t
,
cache
.
SetAccount
(
ctx
,
account
))
svc
:=
service
.
NewSchedulerSnapshotService
(
cache
,
outboxRepo
,
accountRepo
,
nil
,
cfg
)
svc
.
Start
()
t
.
Cleanup
(
svc
.
Stop
)
require
.
NoError
(
t
,
accountRepo
.
UpdateLastUsed
(
ctx
,
account
.
ID
))
updated
,
err
:=
accountRepo
.
GetByID
(
ctx
,
account
.
ID
)
require
.
NoError
(
t
,
err
)
require
.
NotNil
(
t
,
updated
.
LastUsedAt
)
expectedUnix
:=
updated
.
LastUsedAt
.
Unix
()
require
.
Eventually
(
t
,
func
()
bool
{
cached
,
err
:=
cache
.
GetAccount
(
ctx
,
account
.
ID
)
if
err
!=
nil
||
cached
==
nil
||
cached
.
LastUsedAt
==
nil
{
return
false
}
return
cached
.
LastUsedAt
.
Unix
()
==
expectedUnix
},
5
*
time
.
Second
,
100
*
time
.
Millisecond
)
}
backend/internal/repository/wire.go
View file @
4da681f5
...
@@ -67,6 +67,8 @@ var ProviderSet = wire.NewSet(
...
@@ -67,6 +67,8 @@ var ProviderSet = wire.NewSet(
NewRedeemCache
,
NewRedeemCache
,
NewUpdateCache
,
NewUpdateCache
,
NewGeminiTokenCache
,
NewGeminiTokenCache
,
NewSchedulerCache
,
NewSchedulerOutboxRepository
,
// HTTP service ports (DI Strategy A: return interface directly)
// HTTP service ports (DI Strategy A: return interface directly)
NewTurnstileVerifier
,
NewTurnstileVerifier
,
...
...
backend/internal/service/gateway_service.go
View file @
4da681f5
...
@@ -151,6 +151,7 @@ type GatewayService struct {
...
@@ -151,6 +151,7 @@ type GatewayService struct {
userSubRepo
UserSubscriptionRepository
userSubRepo
UserSubscriptionRepository
cache
GatewayCache
cache
GatewayCache
cfg
*
config
.
Config
cfg
*
config
.
Config
schedulerSnapshot
*
SchedulerSnapshotService
billingService
*
BillingService
billingService
*
BillingService
rateLimitService
*
RateLimitService
rateLimitService
*
RateLimitService
billingCacheService
*
BillingCacheService
billingCacheService
*
BillingCacheService
...
@@ -169,6 +170,7 @@ func NewGatewayService(
...
@@ -169,6 +170,7 @@ func NewGatewayService(
userSubRepo
UserSubscriptionRepository
,
userSubRepo
UserSubscriptionRepository
,
cache
GatewayCache
,
cache
GatewayCache
,
cfg
*
config
.
Config
,
cfg
*
config
.
Config
,
schedulerSnapshot
*
SchedulerSnapshotService
,
concurrencyService
*
ConcurrencyService
,
concurrencyService
*
ConcurrencyService
,
billingService
*
BillingService
,
billingService
*
BillingService
,
rateLimitService
*
RateLimitService
,
rateLimitService
*
RateLimitService
,
...
@@ -185,6 +187,7 @@ func NewGatewayService(
...
@@ -185,6 +187,7 @@ func NewGatewayService(
userSubRepo
:
userSubRepo
,
userSubRepo
:
userSubRepo
,
cache
:
cache
,
cache
:
cache
,
cfg
:
cfg
,
cfg
:
cfg
,
schedulerSnapshot
:
schedulerSnapshot
,
concurrencyService
:
concurrencyService
,
concurrencyService
:
concurrencyService
,
billingService
:
billingService
,
billingService
:
billingService
,
rateLimitService
:
rateLimitService
,
rateLimitService
:
rateLimitService
,
...
@@ -745,6 +748,9 @@ func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, gr
...
@@ -745,6 +748,9 @@ func (s *GatewayService) resolvePlatform(ctx context.Context, groupID *int64, gr
}
}
func
(
s
*
GatewayService
)
listSchedulableAccounts
(
ctx
context
.
Context
,
groupID
*
int64
,
platform
string
,
hasForcePlatform
bool
)
([]
Account
,
bool
,
error
)
{
func
(
s
*
GatewayService
)
listSchedulableAccounts
(
ctx
context
.
Context
,
groupID
*
int64
,
platform
string
,
hasForcePlatform
bool
)
([]
Account
,
bool
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
return
s
.
schedulerSnapshot
.
ListSchedulableAccounts
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
}
useMixed
:=
(
platform
==
PlatformAnthropic
||
platform
==
PlatformGemini
)
&&
!
hasForcePlatform
useMixed
:=
(
platform
==
PlatformAnthropic
||
platform
==
PlatformGemini
)
&&
!
hasForcePlatform
if
useMixed
{
if
useMixed
{
platforms
:=
[]
string
{
platform
,
PlatformAntigravity
}
platforms
:=
[]
string
{
platform
,
PlatformAntigravity
}
...
@@ -821,6 +827,13 @@ func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID in
...
@@ -821,6 +827,13 @@ func (s *GatewayService) tryAcquireAccountSlot(ctx context.Context, accountID in
return
s
.
concurrencyService
.
AcquireAccountSlot
(
ctx
,
accountID
,
maxConcurrency
)
return
s
.
concurrencyService
.
AcquireAccountSlot
(
ctx
,
accountID
,
maxConcurrency
)
}
}
func
(
s
*
GatewayService
)
getSchedulableAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
Account
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
return
s
.
schedulerSnapshot
.
GetAccount
(
ctx
,
accountID
)
}
return
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
}
func
sortAccountsByPriorityAndLastUsed
(
accounts
[]
*
Account
,
preferOAuth
bool
)
{
func
sortAccountsByPriorityAndLastUsed
(
accounts
[]
*
Account
,
preferOAuth
bool
)
{
sort
.
SliceStable
(
accounts
,
func
(
i
,
j
int
)
bool
{
sort
.
SliceStable
(
accounts
,
func
(
i
,
j
int
)
bool
{
a
,
b
:=
accounts
[
i
],
accounts
[
j
]
a
,
b
:=
accounts
[
i
],
accounts
[
j
]
...
@@ -851,7 +864,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
...
@@ -851,7 +864,7 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
)
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
)
if
err
==
nil
&&
accountID
>
0
{
if
err
==
nil
&&
accountID
>
0
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
account
,
err
:=
s
.
getSchedulableAccount
(
ctx
,
accountID
)
// 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台)
// 检查账号分组归属和平台匹配(确保粘性会话不会跨分组或跨平台)
if
err
==
nil
&&
s
.
isAccountInGroup
(
account
,
groupID
)
&&
account
.
Platform
==
platform
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
{
if
err
==
nil
&&
s
.
isAccountInGroup
(
account
,
groupID
)
&&
account
.
Platform
==
platform
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
{
if
err
:=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
stickySessionTTL
);
err
!=
nil
{
if
err
:=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
,
stickySessionTTL
);
err
!=
nil
{
...
@@ -864,16 +877,11 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
...
@@ -864,16 +877,11 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
}
}
// 2. 获取可调度账号列表(单平台)
// 2. 获取可调度账号列表(单平台)
var
accounts
[]
Account
forcePlatform
,
hasForcePlatform
:=
ctx
.
Value
(
ctxkey
.
ForcePlatform
)
.
(
string
)
var
err
error
if
hasForcePlatform
&&
forcePlatform
==
""
{
if
s
.
cfg
.
RunMode
==
config
.
RunModeSimple
{
hasForcePlatform
=
false
// 简易模式:忽略 groupID,查询所有可用账号
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
platform
)
}
else
if
groupID
!=
nil
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatform
(
ctx
,
*
groupID
,
platform
)
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
platform
)
}
}
accounts
,
_
,
err
:=
s
.
listSchedulableAccounts
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
}
}
...
@@ -935,7 +943,6 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
...
@@ -935,7 +943,6 @@ func (s *GatewayService) selectAccountForModelWithPlatform(ctx context.Context,
// selectAccountWithMixedScheduling 选择账户(支持混合调度)
// selectAccountWithMixedScheduling 选择账户(支持混合调度)
// 查询原生平台账户 + 启用 mixed_scheduling 的 antigravity 账户
// 查询原生平台账户 + 启用 mixed_scheduling 的 antigravity 账户
func
(
s
*
GatewayService
)
selectAccountWithMixedScheduling
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
nativePlatform
string
)
(
*
Account
,
error
)
{
func
(
s
*
GatewayService
)
selectAccountWithMixedScheduling
(
ctx
context
.
Context
,
groupID
*
int64
,
sessionHash
string
,
requestedModel
string
,
excludedIDs
map
[
int64
]
struct
{},
nativePlatform
string
)
(
*
Account
,
error
)
{
platforms
:=
[]
string
{
nativePlatform
,
PlatformAntigravity
}
preferOAuth
:=
nativePlatform
==
PlatformGemini
preferOAuth
:=
nativePlatform
==
PlatformGemini
// 1. 查询粘性会话
// 1. 查询粘性会话
...
@@ -943,7 +950,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
...
@@ -943,7 +950,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
)
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
sessionHash
)
if
err
==
nil
&&
accountID
>
0
{
if
err
==
nil
&&
accountID
>
0
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
account
,
err
:=
s
.
getSchedulableAccount
(
ctx
,
accountID
)
// 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
// 检查账号分组归属和有效性:原生平台直接匹配,antigravity 需要启用混合调度
if
err
==
nil
&&
s
.
isAccountInGroup
(
account
,
groupID
)
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
{
if
err
==
nil
&&
s
.
isAccountInGroup
(
account
,
groupID
)
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
{
if
account
.
Platform
==
nativePlatform
||
(
account
.
Platform
==
PlatformAntigravity
&&
account
.
IsMixedSchedulingEnabled
())
{
if
account
.
Platform
==
nativePlatform
||
(
account
.
Platform
==
PlatformAntigravity
&&
account
.
IsMixedSchedulingEnabled
())
{
...
@@ -958,13 +965,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
...
@@ -958,13 +965,7 @@ func (s *GatewayService) selectAccountWithMixedScheduling(ctx context.Context, g
}
}
// 2. 获取可调度账号列表
// 2. 获取可调度账号列表
var
accounts
[]
Account
accounts
,
_
,
err
:=
s
.
listSchedulableAccounts
(
ctx
,
groupID
,
nativePlatform
,
false
)
var
err
error
if
groupID
!=
nil
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatforms
(
ctx
,
*
groupID
,
platforms
)
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatforms
(
ctx
,
platforms
)
}
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
}
}
...
...
backend/internal/service/gemini_messages_compat_service.go
View file @
4da681f5
...
@@ -40,6 +40,7 @@ type GeminiMessagesCompatService struct {
...
@@ -40,6 +40,7 @@ type GeminiMessagesCompatService struct {
accountRepo
AccountRepository
accountRepo
AccountRepository
groupRepo
GroupRepository
groupRepo
GroupRepository
cache
GatewayCache
cache
GatewayCache
schedulerSnapshot
*
SchedulerSnapshotService
tokenProvider
*
GeminiTokenProvider
tokenProvider
*
GeminiTokenProvider
rateLimitService
*
RateLimitService
rateLimitService
*
RateLimitService
httpUpstream
HTTPUpstream
httpUpstream
HTTPUpstream
...
@@ -51,6 +52,7 @@ func NewGeminiMessagesCompatService(
...
@@ -51,6 +52,7 @@ func NewGeminiMessagesCompatService(
accountRepo
AccountRepository
,
accountRepo
AccountRepository
,
groupRepo
GroupRepository
,
groupRepo
GroupRepository
,
cache
GatewayCache
,
cache
GatewayCache
,
schedulerSnapshot
*
SchedulerSnapshotService
,
tokenProvider
*
GeminiTokenProvider
,
tokenProvider
*
GeminiTokenProvider
,
rateLimitService
*
RateLimitService
,
rateLimitService
*
RateLimitService
,
httpUpstream
HTTPUpstream
,
httpUpstream
HTTPUpstream
,
...
@@ -61,6 +63,7 @@ func NewGeminiMessagesCompatService(
...
@@ -61,6 +63,7 @@ func NewGeminiMessagesCompatService(
accountRepo
:
accountRepo
,
accountRepo
:
accountRepo
,
groupRepo
:
groupRepo
,
groupRepo
:
groupRepo
,
cache
:
cache
,
cache
:
cache
,
schedulerSnapshot
:
schedulerSnapshot
,
tokenProvider
:
tokenProvider
,
tokenProvider
:
tokenProvider
,
rateLimitService
:
rateLimitService
,
rateLimitService
:
rateLimitService
,
httpUpstream
:
httpUpstream
,
httpUpstream
:
httpUpstream
,
...
@@ -105,12 +108,6 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
...
@@ -105,12 +108,6 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
// gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
// gemini 分组支持混合调度(包含启用了 mixed_scheduling 的 antigravity 账户)
// 注意:强制平台模式不走混合调度
// 注意:强制平台模式不走混合调度
useMixedScheduling
:=
platform
==
PlatformGemini
&&
!
hasForcePlatform
useMixedScheduling
:=
platform
==
PlatformGemini
&&
!
hasForcePlatform
var
queryPlatforms
[]
string
if
useMixedScheduling
{
queryPlatforms
=
[]
string
{
PlatformGemini
,
PlatformAntigravity
}
}
else
{
queryPlatforms
=
[]
string
{
platform
}
}
cacheKey
:=
"gemini:"
+
sessionHash
cacheKey
:=
"gemini:"
+
sessionHash
...
@@ -118,7 +115,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
...
@@ -118,7 +115,7 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
cacheKey
)
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
cacheKey
)
if
err
==
nil
&&
accountID
>
0
{
if
err
==
nil
&&
accountID
>
0
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
account
,
err
:=
s
.
getSchedulableAccount
(
ctx
,
accountID
)
// 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度
// 检查账号是否有效:原生平台直接匹配,antigravity 需要启用混合调度
if
err
==
nil
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
{
if
err
==
nil
&&
account
.
IsSchedulableForModel
(
requestedModel
)
&&
(
requestedModel
==
""
||
s
.
isModelSupportedByAccount
(
account
,
requestedModel
))
{
valid
:=
false
valid
:=
false
...
@@ -149,22 +146,16 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
...
@@ -149,22 +146,16 @@ func (s *GeminiMessagesCompatService) SelectAccountForModelWithExclusions(ctx co
}
}
// 查询可调度账户(强制平台模式:优先按分组查找,找不到再查全部)
// 查询可调度账户(强制平台模式:优先按分组查找,找不到再查全部)
var
accounts
[]
Account
accounts
,
err
:=
s
.
listSchedulableAccountsOnce
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
var
err
error
if
err
!=
nil
{
if
groupID
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatforms
(
ctx
,
*
groupID
,
queryPlatforms
)
}
// 强制平台模式下,分组中找不到账户时回退查询全部
if
len
(
accounts
)
==
0
&&
groupID
!=
nil
&&
hasForcePlatform
{
accounts
,
err
=
s
.
listSchedulableAccountsOnce
(
ctx
,
nil
,
platform
,
hasForcePlatform
)
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
}
}
// 强制平台模式下,分组中找不到账户时回退查询全部
if
len
(
accounts
)
==
0
&&
hasForcePlatform
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatforms
(
ctx
,
queryPlatforms
)
}
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatforms
(
ctx
,
queryPlatforms
)
}
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
}
}
var
selected
*
Account
var
selected
*
Account
...
@@ -245,6 +236,31 @@ func (s *GeminiMessagesCompatService) GetAntigravityGatewayService() *Antigravit
...
@@ -245,6 +236,31 @@ func (s *GeminiMessagesCompatService) GetAntigravityGatewayService() *Antigravit
return
s
.
antigravityGatewayService
return
s
.
antigravityGatewayService
}
}
func
(
s
*
GeminiMessagesCompatService
)
getSchedulableAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
Account
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
return
s
.
schedulerSnapshot
.
GetAccount
(
ctx
,
accountID
)
}
return
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
}
func
(
s
*
GeminiMessagesCompatService
)
listSchedulableAccountsOnce
(
ctx
context
.
Context
,
groupID
*
int64
,
platform
string
,
hasForcePlatform
bool
)
([]
Account
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
accounts
,
_
,
err
:=
s
.
schedulerSnapshot
.
ListSchedulableAccounts
(
ctx
,
groupID
,
platform
,
hasForcePlatform
)
return
accounts
,
err
}
useMixedScheduling
:=
platform
==
PlatformGemini
&&
!
hasForcePlatform
queryPlatforms
:=
[]
string
{
platform
}
if
useMixedScheduling
{
queryPlatforms
=
[]
string
{
platform
,
PlatformAntigravity
}
}
if
groupID
!=
nil
{
return
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatforms
(
ctx
,
*
groupID
,
queryPlatforms
)
}
return
s
.
accountRepo
.
ListSchedulableByPlatforms
(
ctx
,
queryPlatforms
)
}
func
(
s
*
GeminiMessagesCompatService
)
validateUpstreamBaseURL
(
raw
string
)
(
string
,
error
)
{
func
(
s
*
GeminiMessagesCompatService
)
validateUpstreamBaseURL
(
raw
string
)
(
string
,
error
)
{
if
s
.
cfg
!=
nil
&&
!
s
.
cfg
.
Security
.
URLAllowlist
.
Enabled
{
if
s
.
cfg
!=
nil
&&
!
s
.
cfg
.
Security
.
URLAllowlist
.
Enabled
{
normalized
,
err
:=
urlvalidator
.
ValidateURLFormat
(
raw
,
s
.
cfg
.
Security
.
URLAllowlist
.
AllowInsecureHTTP
)
normalized
,
err
:=
urlvalidator
.
ValidateURLFormat
(
raw
,
s
.
cfg
.
Security
.
URLAllowlist
.
AllowInsecureHTTP
)
...
@@ -266,13 +282,7 @@ func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (strin
...
@@ -266,13 +282,7 @@ func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (strin
// HasAntigravityAccounts 检查是否有可用的 antigravity 账户
// HasAntigravityAccounts 检查是否有可用的 antigravity 账户
func
(
s
*
GeminiMessagesCompatService
)
HasAntigravityAccounts
(
ctx
context
.
Context
,
groupID
*
int64
)
(
bool
,
error
)
{
func
(
s
*
GeminiMessagesCompatService
)
HasAntigravityAccounts
(
ctx
context
.
Context
,
groupID
*
int64
)
(
bool
,
error
)
{
var
accounts
[]
Account
accounts
,
err
:=
s
.
listSchedulableAccountsOnce
(
ctx
,
groupID
,
PlatformAntigravity
,
false
)
var
err
error
if
groupID
!=
nil
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatform
(
ctx
,
*
groupID
,
PlatformAntigravity
)
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
PlatformAntigravity
)
}
if
err
!=
nil
{
if
err
!=
nil
{
return
false
,
err
return
false
,
err
}
}
...
@@ -288,13 +298,7 @@ func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context
...
@@ -288,13 +298,7 @@ func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context
// 3) OAuth accounts explicitly marked as ai_studio
// 3) OAuth accounts explicitly marked as ai_studio
// 4) Any remaining Gemini accounts (fallback)
// 4) Any remaining Gemini accounts (fallback)
func
(
s
*
GeminiMessagesCompatService
)
SelectAccountForAIStudioEndpoints
(
ctx
context
.
Context
,
groupID
*
int64
)
(
*
Account
,
error
)
{
func
(
s
*
GeminiMessagesCompatService
)
SelectAccountForAIStudioEndpoints
(
ctx
context
.
Context
,
groupID
*
int64
)
(
*
Account
,
error
)
{
var
accounts
[]
Account
accounts
,
err
:=
s
.
listSchedulableAccountsOnce
(
ctx
,
groupID
,
PlatformGemini
,
true
)
var
err
error
if
groupID
!=
nil
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatform
(
ctx
,
*
groupID
,
PlatformGemini
)
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
PlatformGemini
)
}
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
}
}
...
...
backend/internal/service/openai_gateway_service.go
View file @
4da681f5
...
@@ -85,6 +85,7 @@ type OpenAIGatewayService struct {
...
@@ -85,6 +85,7 @@ type OpenAIGatewayService struct {
userSubRepo
UserSubscriptionRepository
userSubRepo
UserSubscriptionRepository
cache
GatewayCache
cache
GatewayCache
cfg
*
config
.
Config
cfg
*
config
.
Config
schedulerSnapshot
*
SchedulerSnapshotService
concurrencyService
*
ConcurrencyService
concurrencyService
*
ConcurrencyService
billingService
*
BillingService
billingService
*
BillingService
rateLimitService
*
RateLimitService
rateLimitService
*
RateLimitService
...
@@ -101,6 +102,7 @@ func NewOpenAIGatewayService(
...
@@ -101,6 +102,7 @@ func NewOpenAIGatewayService(
userSubRepo
UserSubscriptionRepository
,
userSubRepo
UserSubscriptionRepository
,
cache
GatewayCache
,
cache
GatewayCache
,
cfg
*
config
.
Config
,
cfg
*
config
.
Config
,
schedulerSnapshot
*
SchedulerSnapshotService
,
concurrencyService
*
ConcurrencyService
,
concurrencyService
*
ConcurrencyService
,
billingService
*
BillingService
,
billingService
*
BillingService
,
rateLimitService
*
RateLimitService
,
rateLimitService
*
RateLimitService
,
...
@@ -115,6 +117,7 @@ func NewOpenAIGatewayService(
...
@@ -115,6 +117,7 @@ func NewOpenAIGatewayService(
userSubRepo
:
userSubRepo
,
userSubRepo
:
userSubRepo
,
cache
:
cache
,
cache
:
cache
,
cfg
:
cfg
,
cfg
:
cfg
,
schedulerSnapshot
:
schedulerSnapshot
,
concurrencyService
:
concurrencyService
,
concurrencyService
:
concurrencyService
,
billingService
:
billingService
,
billingService
:
billingService
,
rateLimitService
:
rateLimitService
,
rateLimitService
:
rateLimitService
,
...
@@ -159,7 +162,7 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
...
@@ -159,7 +162,7 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
"openai:"
+
sessionHash
)
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
"openai:"
+
sessionHash
)
if
err
==
nil
&&
accountID
>
0
{
if
err
==
nil
&&
accountID
>
0
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
if
_
,
excluded
:=
excludedIDs
[
accountID
];
!
excluded
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
account
,
err
:=
s
.
getSchedulableAccount
(
ctx
,
accountID
)
if
err
==
nil
&&
account
.
IsSchedulable
()
&&
account
.
IsOpenAI
()
&&
(
requestedModel
==
""
||
account
.
IsModelSupported
(
requestedModel
))
{
if
err
==
nil
&&
account
.
IsSchedulable
()
&&
account
.
IsOpenAI
()
&&
(
requestedModel
==
""
||
account
.
IsModelSupported
(
requestedModel
))
{
// Refresh sticky session TTL
// Refresh sticky session TTL
_
=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
"openai:"
+
sessionHash
,
openaiStickySessionTTL
)
_
=
s
.
cache
.
RefreshSessionTTL
(
ctx
,
derefGroupID
(
groupID
),
"openai:"
+
sessionHash
,
openaiStickySessionTTL
)
...
@@ -170,16 +173,7 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
...
@@ -170,16 +173,7 @@ func (s *OpenAIGatewayService) SelectAccountForModelWithExclusions(ctx context.C
}
}
// 2. Get schedulable OpenAI accounts
// 2. Get schedulable OpenAI accounts
var
accounts
[]
Account
accounts
,
err
:=
s
.
listSchedulableAccounts
(
ctx
,
groupID
)
var
err
error
// 简易模式:忽略分组限制,查询所有可用账号
if
s
.
cfg
.
RunMode
==
config
.
RunModeSimple
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
PlatformOpenAI
)
}
else
if
groupID
!=
nil
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByGroupIDAndPlatform
(
ctx
,
*
groupID
,
PlatformOpenAI
)
}
else
{
accounts
,
err
=
s
.
accountRepo
.
ListSchedulableByPlatform
(
ctx
,
PlatformOpenAI
)
}
if
err
!=
nil
{
if
err
!=
nil
{
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
return
nil
,
fmt
.
Errorf
(
"query accounts failed: %w"
,
err
)
}
}
...
@@ -301,7 +295,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
...
@@ -301,7 +295,7 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
if
sessionHash
!=
""
{
if
sessionHash
!=
""
{
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
"openai:"
+
sessionHash
)
accountID
,
err
:=
s
.
cache
.
GetSessionAccountID
(
ctx
,
derefGroupID
(
groupID
),
"openai:"
+
sessionHash
)
if
err
==
nil
&&
accountID
>
0
&&
!
isExcluded
(
accountID
)
{
if
err
==
nil
&&
accountID
>
0
&&
!
isExcluded
(
accountID
)
{
account
,
err
:=
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
account
,
err
:=
s
.
getSchedulableAccount
(
ctx
,
accountID
)
if
err
==
nil
&&
account
.
IsSchedulable
()
&&
account
.
IsOpenAI
()
&&
if
err
==
nil
&&
account
.
IsSchedulable
()
&&
account
.
IsOpenAI
()
&&
(
requestedModel
==
""
||
account
.
IsModelSupported
(
requestedModel
))
{
(
requestedModel
==
""
||
account
.
IsModelSupported
(
requestedModel
))
{
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
accountID
,
account
.
Concurrency
)
result
,
err
:=
s
.
tryAcquireAccountSlot
(
ctx
,
accountID
,
account
.
Concurrency
)
...
@@ -446,6 +440,10 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
...
@@ -446,6 +440,10 @@ func (s *OpenAIGatewayService) SelectAccountWithLoadAwareness(ctx context.Contex
}
}
func
(
s
*
OpenAIGatewayService
)
listSchedulableAccounts
(
ctx
context
.
Context
,
groupID
*
int64
)
([]
Account
,
error
)
{
func
(
s
*
OpenAIGatewayService
)
listSchedulableAccounts
(
ctx
context
.
Context
,
groupID
*
int64
)
([]
Account
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
accounts
,
_
,
err
:=
s
.
schedulerSnapshot
.
ListSchedulableAccounts
(
ctx
,
groupID
,
PlatformOpenAI
,
false
)
return
accounts
,
err
}
var
accounts
[]
Account
var
accounts
[]
Account
var
err
error
var
err
error
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
RunMode
==
config
.
RunModeSimple
{
if
s
.
cfg
!=
nil
&&
s
.
cfg
.
RunMode
==
config
.
RunModeSimple
{
...
@@ -468,6 +466,13 @@ func (s *OpenAIGatewayService) tryAcquireAccountSlot(ctx context.Context, accoun
...
@@ -468,6 +466,13 @@ func (s *OpenAIGatewayService) tryAcquireAccountSlot(ctx context.Context, accoun
return
s
.
concurrencyService
.
AcquireAccountSlot
(
ctx
,
accountID
,
maxConcurrency
)
return
s
.
concurrencyService
.
AcquireAccountSlot
(
ctx
,
accountID
,
maxConcurrency
)
}
}
func
(
s
*
OpenAIGatewayService
)
getSchedulableAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
Account
,
error
)
{
if
s
.
schedulerSnapshot
!=
nil
{
return
s
.
schedulerSnapshot
.
GetAccount
(
ctx
,
accountID
)
}
return
s
.
accountRepo
.
GetByID
(
ctx
,
accountID
)
}
func
(
s
*
OpenAIGatewayService
)
schedulingConfig
()
config
.
GatewaySchedulingConfig
{
func
(
s
*
OpenAIGatewayService
)
schedulingConfig
()
config
.
GatewaySchedulingConfig
{
if
s
.
cfg
!=
nil
{
if
s
.
cfg
!=
nil
{
return
s
.
cfg
.
Gateway
.
Scheduling
return
s
.
cfg
.
Gateway
.
Scheduling
...
...
backend/internal/service/scheduler_cache.go
0 → 100644
View file @
4da681f5
package
service
import
(
"context"
"fmt"
"strconv"
"strings"
"time"
)
const
(
SchedulerModeSingle
=
"single"
SchedulerModeMixed
=
"mixed"
SchedulerModeForced
=
"forced"
)
type
SchedulerBucket
struct
{
GroupID
int64
Platform
string
Mode
string
}
func
(
b
SchedulerBucket
)
String
()
string
{
return
fmt
.
Sprintf
(
"%d:%s:%s"
,
b
.
GroupID
,
b
.
Platform
,
b
.
Mode
)
}
func
ParseSchedulerBucket
(
raw
string
)
(
SchedulerBucket
,
bool
)
{
parts
:=
strings
.
Split
(
raw
,
":"
)
if
len
(
parts
)
!=
3
{
return
SchedulerBucket
{},
false
}
groupID
,
err
:=
strconv
.
ParseInt
(
parts
[
0
],
10
,
64
)
if
err
!=
nil
{
return
SchedulerBucket
{},
false
}
if
parts
[
1
]
==
""
||
parts
[
2
]
==
""
{
return
SchedulerBucket
{},
false
}
return
SchedulerBucket
{
GroupID
:
groupID
,
Platform
:
parts
[
1
],
Mode
:
parts
[
2
],
},
true
}
// SchedulerCache 负责调度快照与账号快照的缓存读写。
type
SchedulerCache
interface
{
// GetSnapshot 读取快照并返回命中与否(ready + active + 数据完整)。
GetSnapshot
(
ctx
context
.
Context
,
bucket
SchedulerBucket
)
([]
*
Account
,
bool
,
error
)
// SetSnapshot 写入快照并切换激活版本。
SetSnapshot
(
ctx
context
.
Context
,
bucket
SchedulerBucket
,
accounts
[]
Account
)
error
// GetAccount 获取单账号快照。
GetAccount
(
ctx
context
.
Context
,
accountID
int64
)
(
*
Account
,
error
)
// SetAccount 写入单账号快照(包含不可调度状态)。
SetAccount
(
ctx
context
.
Context
,
account
*
Account
)
error
// DeleteAccount 删除单账号快照。
DeleteAccount
(
ctx
context
.
Context
,
accountID
int64
)
error
// UpdateLastUsed 批量更新账号的最后使用时间。
UpdateLastUsed
(
ctx
context
.
Context
,
updates
map
[
int64
]
time
.
Time
)
error
// TryLockBucket 尝试获取分桶重建锁。
TryLockBucket
(
ctx
context
.
Context
,
bucket
SchedulerBucket
,
ttl
time
.
Duration
)
(
bool
,
error
)
// ListBuckets 返回已注册的分桶集合。
ListBuckets
(
ctx
context
.
Context
)
([]
SchedulerBucket
,
error
)
// GetOutboxWatermark 读取 outbox 水位。
GetOutboxWatermark
(
ctx
context
.
Context
)
(
int64
,
error
)
// SetOutboxWatermark 保存 outbox 水位。
SetOutboxWatermark
(
ctx
context
.
Context
,
id
int64
)
error
}
backend/internal/service/scheduler_events.go
0 → 100644
View file @
4da681f5
package
service
const
(
SchedulerOutboxEventAccountChanged
=
"account_changed"
SchedulerOutboxEventAccountGroupsChanged
=
"account_groups_changed"
SchedulerOutboxEventAccountBulkChanged
=
"account_bulk_changed"
SchedulerOutboxEventAccountLastUsed
=
"account_last_used"
SchedulerOutboxEventGroupChanged
=
"group_changed"
SchedulerOutboxEventFullRebuild
=
"full_rebuild"
)
backend/internal/service/scheduler_outbox.go
0 → 100644
View file @
4da681f5
package
service
import
(
"context"
"time"
)
type
SchedulerOutboxEvent
struct
{
ID
int64
EventType
string
AccountID
*
int64
GroupID
*
int64
Payload
map
[
string
]
any
CreatedAt
time
.
Time
}
// SchedulerOutboxRepository 提供调度 outbox 的读取接口。
type
SchedulerOutboxRepository
interface
{
ListAfter
(
ctx
context
.
Context
,
afterID
int64
,
limit
int
)
([]
SchedulerOutboxEvent
,
error
)
MaxID
(
ctx
context
.
Context
)
(
int64
,
error
)
}
backend/internal/service/scheduler_snapshot_service.go
0 → 100644
View file @
4da681f5
This diff is collapsed.
Click to expand it.
backend/internal/service/wire.go
View file @
4da681f5
...
@@ -86,6 +86,19 @@ func ProvideConcurrencyService(cache ConcurrencyCache, accountRepo AccountReposi
...
@@ -86,6 +86,19 @@ func ProvideConcurrencyService(cache ConcurrencyCache, accountRepo AccountReposi
return
svc
return
svc
}
}
// ProvideSchedulerSnapshotService creates and starts SchedulerSnapshotService.
func
ProvideSchedulerSnapshotService
(
cache
SchedulerCache
,
outboxRepo
SchedulerOutboxRepository
,
accountRepo
AccountRepository
,
groupRepo
GroupRepository
,
cfg
*
config
.
Config
,
)
*
SchedulerSnapshotService
{
svc
:=
NewSchedulerSnapshotService
(
cache
,
outboxRepo
,
accountRepo
,
groupRepo
,
cfg
)
svc
.
Start
()
return
svc
}
// ProvideRateLimitService creates RateLimitService with optional dependencies.
// ProvideRateLimitService creates RateLimitService with optional dependencies.
func
ProvideRateLimitService
(
func
ProvideRateLimitService
(
accountRepo
AccountRepository
,
accountRepo
AccountRepository
,
...
@@ -217,6 +230,7 @@ var ProviderSet = wire.NewSet(
...
@@ -217,6 +230,7 @@ var ProviderSet = wire.NewSet(
NewTurnstileService
,
NewTurnstileService
,
NewSubscriptionService
,
NewSubscriptionService
,
ProvideConcurrencyService
,
ProvideConcurrencyService
,
ProvideSchedulerSnapshotService
,
NewIdentityService
,
NewIdentityService
,
NewCRSSyncService
,
NewCRSSyncService
,
ProvideUpdateService
,
ProvideUpdateService
,
...
...
backend/migrations/036_scheduler_outbox.sql
0 → 100644
View file @
4da681f5
CREATE
TABLE
IF
NOT
EXISTS
scheduler_outbox
(
id
BIGSERIAL
PRIMARY
KEY
,
event_type
TEXT
NOT
NULL
,
account_id
BIGINT
NULL
,
group_id
BIGINT
NULL
,
payload
JSONB
NULL
,
created_at
TIMESTAMPTZ
NOT
NULL
DEFAULT
NOW
()
);
CREATE
INDEX
IF
NOT
EXISTS
idx_scheduler_outbox_created_at
ON
scheduler_outbox
(
created_at
);
Prev
1
2
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment