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
5fa45f3b
Commit
5fa45f3b
authored
Feb 23, 2026
by
yangjianbo
Browse files
feat(idempotency): 为关键写接口接入幂等并完善并发容错
parent
3b6584cc
Changes
40
Show whitespace changes
Inline
Side-by-side
backend/cmd/server/wire.go
View file @
5fa45f3b
...
...
@@ -74,6 +74,7 @@ func provideCleanup(
accountExpiry
*
service
.
AccountExpiryService
,
subscriptionExpiry
*
service
.
SubscriptionExpiryService
,
usageCleanup
*
service
.
UsageCleanupService
,
idempotencyCleanup
*
service
.
IdempotencyCleanupService
,
pricing
*
service
.
PricingService
,
emailQueue
*
service
.
EmailQueueService
,
billingCache
*
service
.
BillingCacheService
,
...
...
@@ -147,6 +148,12 @@ func provideCleanup(
}
return
nil
}},
{
"IdempotencyCleanupService"
,
func
()
error
{
if
idempotencyCleanup
!=
nil
{
idempotencyCleanup
.
Stop
()
}
return
nil
}},
{
"TokenRefreshService"
,
func
()
error
{
tokenRefresh
.
Stop
()
return
nil
...
...
backend/cmd/server/wire_gen.go
View file @
5fa45f3b
...
...
@@ -168,7 +168,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
gitHubReleaseClient
:=
repository
.
ProvideGitHubReleaseClient
(
configConfig
)
serviceBuildInfo
:=
provideServiceBuildInfo
(
buildInfo
)
updateService
:=
service
.
ProvideUpdateService
(
updateCache
,
gitHubReleaseClient
,
serviceBuildInfo
)
systemHandler
:=
handler
.
ProvideSystemHandler
(
updateService
)
idempotencyRepository
:=
repository
.
NewIdempotencyRepository
(
client
,
db
)
systemOperationLockService
:=
service
.
ProvideSystemOperationLockService
(
idempotencyRepository
,
configConfig
)
systemHandler
:=
handler
.
ProvideSystemHandler
(
updateService
,
systemOperationLockService
)
adminSubscriptionHandler
:=
admin
.
NewSubscriptionHandler
(
subscriptionService
)
usageCleanupRepository
:=
repository
.
NewUsageCleanupRepository
(
client
,
db
)
usageCleanupService
:=
service
.
ProvideUsageCleanupService
(
usageCleanupRepository
,
timingWheelService
,
dashboardAggregationService
,
configConfig
)
...
...
@@ -191,7 +193,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
soraGatewayHandler
:=
handler
.
NewSoraGatewayHandler
(
gatewayService
,
soraGatewayService
,
concurrencyService
,
billingCacheService
,
usageRecordWorkerPool
,
configConfig
)
handlerSettingHandler
:=
handler
.
ProvideSettingHandler
(
settingService
,
buildInfo
)
totpHandler
:=
handler
.
NewTotpHandler
(
totpService
)
handlers
:=
handler
.
ProvideHandlers
(
authHandler
,
userHandler
,
apiKeyHandler
,
usageHandler
,
redeemHandler
,
subscriptionHandler
,
announcementHandler
,
adminHandlers
,
gatewayHandler
,
openAIGatewayHandler
,
soraGatewayHandler
,
handlerSettingHandler
,
totpHandler
)
idempotencyCoordinator
:=
service
.
ProvideIdempotencyCoordinator
(
idempotencyRepository
,
configConfig
)
idempotencyCleanupService
:=
service
.
ProvideIdempotencyCleanupService
(
idempotencyRepository
,
configConfig
)
handlers
:=
handler
.
ProvideHandlers
(
authHandler
,
userHandler
,
apiKeyHandler
,
usageHandler
,
redeemHandler
,
subscriptionHandler
,
announcementHandler
,
adminHandlers
,
gatewayHandler
,
openAIGatewayHandler
,
soraGatewayHandler
,
handlerSettingHandler
,
totpHandler
,
idempotencyCoordinator
,
idempotencyCleanupService
)
jwtAuthMiddleware
:=
middleware
.
NewJWTAuthMiddleware
(
authService
,
userService
)
adminAuthMiddleware
:=
middleware
.
NewAdminAuthMiddleware
(
authService
,
userService
,
settingService
)
apiKeyAuthMiddleware
:=
middleware
.
NewAPIKeyAuthMiddleware
(
apiKeyService
,
subscriptionService
,
configConfig
)
...
...
@@ -206,7 +210,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tokenRefreshService
:=
service
.
ProvideTokenRefreshService
(
accountRepository
,
soraAccountRepository
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
compositeTokenCacheInvalidator
,
schedulerCache
,
configConfig
)
accountExpiryService
:=
service
.
ProvideAccountExpiryService
(
accountRepository
)
subscriptionExpiryService
:=
service
.
ProvideSubscriptionExpiryService
(
userSubscriptionRepository
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
opsSystemLogSink
,
soraMediaCleanupService
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
subscriptionExpiryService
,
usageCleanupService
,
pricingService
,
emailQueueService
,
billingCacheService
,
usageRecordWorkerPool
,
subscriptionService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
opsSystemLogSink
,
soraMediaCleanupService
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
subscriptionExpiryService
,
usageCleanupService
,
idempotencyCleanupService
,
pricingService
,
emailQueueService
,
billingCacheService
,
usageRecordWorkerPool
,
subscriptionService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
)
application
:=
&
Application
{
Server
:
httpServer
,
Cleanup
:
v
,
...
...
@@ -243,6 +247,7 @@ func provideCleanup(
accountExpiry
*
service
.
AccountExpiryService
,
subscriptionExpiry
*
service
.
SubscriptionExpiryService
,
usageCleanup
*
service
.
UsageCleanupService
,
idempotencyCleanup
*
service
.
IdempotencyCleanupService
,
pricing
*
service
.
PricingService
,
emailQueue
*
service
.
EmailQueueService
,
billingCache
*
service
.
BillingCacheService
,
...
...
@@ -315,6 +320,12 @@ func provideCleanup(
}
return
nil
}},
{
"IdempotencyCleanupService"
,
func
()
error
{
if
idempotencyCleanup
!=
nil
{
idempotencyCleanup
.
Stop
()
}
return
nil
}},
{
"TokenRefreshService"
,
func
()
error
{
tokenRefresh
.
Stop
()
return
nil
...
...
backend/ent/schema/idempotency_record.go
0 → 100644
View file @
5fa45f3b
package
schema
import
(
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
// IdempotencyRecord 幂等请求记录表。
type
IdempotencyRecord
struct
{
ent
.
Schema
}
func
(
IdempotencyRecord
)
Annotations
()
[]
schema
.
Annotation
{
return
[]
schema
.
Annotation
{
entsql
.
Annotation
{
Table
:
"idempotency_records"
},
}
}
func
(
IdempotencyRecord
)
Mixin
()
[]
ent
.
Mixin
{
return
[]
ent
.
Mixin
{
mixins
.
TimeMixin
{},
}
}
func
(
IdempotencyRecord
)
Fields
()
[]
ent
.
Field
{
return
[]
ent
.
Field
{
field
.
String
(
"scope"
)
.
MaxLen
(
128
),
field
.
String
(
"idempotency_key_hash"
)
.
MaxLen
(
64
),
field
.
String
(
"request_fingerprint"
)
.
MaxLen
(
64
),
field
.
String
(
"status"
)
.
MaxLen
(
32
),
field
.
Int
(
"response_status"
)
.
Optional
()
.
Nillable
(),
field
.
String
(
"response_body"
)
.
Optional
()
.
Nillable
(),
field
.
String
(
"error_reason"
)
.
MaxLen
(
128
)
.
Optional
()
.
Nillable
(),
field
.
Time
(
"locked_until"
)
.
Optional
()
.
Nillable
(),
field
.
Time
(
"expires_at"
),
}
}
func
(
IdempotencyRecord
)
Indexes
()
[]
ent
.
Index
{
return
[]
ent
.
Index
{
index
.
Fields
(
"scope"
,
"idempotency_key_hash"
)
.
Unique
(),
index
.
Fields
(
"expires_at"
),
index
.
Fields
(
"status"
,
"locked_until"
),
}
}
backend/internal/config/config.go
View file @
5fa45f3b
...
...
@@ -74,6 +74,7 @@ type Config struct {
Timezone
string
`mapstructure:"timezone"`
// e.g. "Asia/Shanghai", "UTC"
Gemini
GeminiConfig
`mapstructure:"gemini"`
Update
UpdateConfig
`mapstructure:"update"`
Idempotency
IdempotencyConfig
`mapstructure:"idempotency"`
}
type
LogConfig
struct
{
...
...
@@ -137,6 +138,25 @@ type UpdateConfig struct {
ProxyURL
string
`mapstructure:"proxy_url"`
}
type
IdempotencyConfig
struct
{
// ObserveOnly 为 true 时处于观察期:未携带 Idempotency-Key 的请求继续放行。
ObserveOnly
bool
`mapstructure:"observe_only"`
// DefaultTTLSeconds 关键写接口的幂等记录默认 TTL(秒)。
DefaultTTLSeconds
int
`mapstructure:"default_ttl_seconds"`
// SystemOperationTTLSeconds 系统操作接口的幂等记录 TTL(秒)。
SystemOperationTTLSeconds
int
`mapstructure:"system_operation_ttl_seconds"`
// ProcessingTimeoutSeconds processing 状态锁超时(秒)。
ProcessingTimeoutSeconds
int
`mapstructure:"processing_timeout_seconds"`
// FailedRetryBackoffSeconds 失败退避窗口(秒)。
FailedRetryBackoffSeconds
int
`mapstructure:"failed_retry_backoff_seconds"`
// MaxStoredResponseLen 持久化响应体最大长度(字节)。
MaxStoredResponseLen
int
`mapstructure:"max_stored_response_len"`
// CleanupIntervalSeconds 过期记录清理周期(秒)。
CleanupIntervalSeconds
int
`mapstructure:"cleanup_interval_seconds"`
// CleanupBatchSize 每次清理的最大记录数。
CleanupBatchSize
int
`mapstructure:"cleanup_batch_size"`
}
type
LinuxDoConnectConfig
struct
{
Enabled
bool
`mapstructure:"enabled"`
ClientID
string
`mapstructure:"client_id"`
...
...
@@ -1117,6 +1137,16 @@ func setDefaults() {
viper
.
SetDefault
(
"usage_cleanup.worker_interval_seconds"
,
10
)
viper
.
SetDefault
(
"usage_cleanup.task_timeout_seconds"
,
1800
)
// Idempotency
viper
.
SetDefault
(
"idempotency.observe_only"
,
true
)
viper
.
SetDefault
(
"idempotency.default_ttl_seconds"
,
86400
)
viper
.
SetDefault
(
"idempotency.system_operation_ttl_seconds"
,
3600
)
viper
.
SetDefault
(
"idempotency.processing_timeout_seconds"
,
30
)
viper
.
SetDefault
(
"idempotency.failed_retry_backoff_seconds"
,
5
)
viper
.
SetDefault
(
"idempotency.max_stored_response_len"
,
64
*
1024
)
viper
.
SetDefault
(
"idempotency.cleanup_interval_seconds"
,
60
)
viper
.
SetDefault
(
"idempotency.cleanup_batch_size"
,
500
)
// Gateway
viper
.
SetDefault
(
"gateway.response_header_timeout"
,
600
)
// 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
viper
.
SetDefault
(
"gateway.log_upstream_error_body"
,
true
)
...
...
@@ -1560,6 +1590,27 @@ func (c *Config) Validate() error {
return
fmt
.
Errorf
(
"usage_cleanup.task_timeout_seconds must be non-negative"
)
}
}
if
c
.
Idempotency
.
DefaultTTLSeconds
<=
0
{
return
fmt
.
Errorf
(
"idempotency.default_ttl_seconds must be positive"
)
}
if
c
.
Idempotency
.
SystemOperationTTLSeconds
<=
0
{
return
fmt
.
Errorf
(
"idempotency.system_operation_ttl_seconds must be positive"
)
}
if
c
.
Idempotency
.
ProcessingTimeoutSeconds
<=
0
{
return
fmt
.
Errorf
(
"idempotency.processing_timeout_seconds must be positive"
)
}
if
c
.
Idempotency
.
FailedRetryBackoffSeconds
<=
0
{
return
fmt
.
Errorf
(
"idempotency.failed_retry_backoff_seconds must be positive"
)
}
if
c
.
Idempotency
.
MaxStoredResponseLen
<=
0
{
return
fmt
.
Errorf
(
"idempotency.max_stored_response_len must be positive"
)
}
if
c
.
Idempotency
.
CleanupIntervalSeconds
<=
0
{
return
fmt
.
Errorf
(
"idempotency.cleanup_interval_seconds must be positive"
)
}
if
c
.
Idempotency
.
CleanupBatchSize
<=
0
{
return
fmt
.
Errorf
(
"idempotency.cleanup_batch_size must be positive"
)
}
if
c
.
Gateway
.
MaxBodySize
<=
0
{
return
fmt
.
Errorf
(
"gateway.max_body_size must be positive"
)
}
...
...
backend/internal/config/config_test.go
View file @
5fa45f3b
...
...
@@ -75,6 +75,42 @@ func TestLoadDefaultSchedulingConfig(t *testing.T) {
}
}
func
TestLoadDefaultIdempotencyConfig
(
t
*
testing
.
T
)
{
resetViperWithJWTSecret
(
t
)
cfg
,
err
:=
Load
()
if
err
!=
nil
{
t
.
Fatalf
(
"Load() error: %v"
,
err
)
}
if
!
cfg
.
Idempotency
.
ObserveOnly
{
t
.
Fatalf
(
"Idempotency.ObserveOnly = false, want true"
)
}
if
cfg
.
Idempotency
.
DefaultTTLSeconds
!=
86400
{
t
.
Fatalf
(
"Idempotency.DefaultTTLSeconds = %d, want 86400"
,
cfg
.
Idempotency
.
DefaultTTLSeconds
)
}
if
cfg
.
Idempotency
.
SystemOperationTTLSeconds
!=
3600
{
t
.
Fatalf
(
"Idempotency.SystemOperationTTLSeconds = %d, want 3600"
,
cfg
.
Idempotency
.
SystemOperationTTLSeconds
)
}
}
func
TestLoadIdempotencyConfigFromEnv
(
t
*
testing
.
T
)
{
resetViperWithJWTSecret
(
t
)
t
.
Setenv
(
"IDEMPOTENCY_OBSERVE_ONLY"
,
"false"
)
t
.
Setenv
(
"IDEMPOTENCY_DEFAULT_TTL_SECONDS"
,
"600"
)
cfg
,
err
:=
Load
()
if
err
!=
nil
{
t
.
Fatalf
(
"Load() error: %v"
,
err
)
}
if
cfg
.
Idempotency
.
ObserveOnly
{
t
.
Fatalf
(
"Idempotency.ObserveOnly = true, want false"
)
}
if
cfg
.
Idempotency
.
DefaultTTLSeconds
!=
600
{
t
.
Fatalf
(
"Idempotency.DefaultTTLSeconds = %d, want 600"
,
cfg
.
Idempotency
.
DefaultTTLSeconds
)
}
}
func
TestLoadSchedulingConfigFromEnv
(
t
*
testing
.
T
)
{
resetViperWithJWTSecret
(
t
)
t
.
Setenv
(
"GATEWAY_SCHEDULING_STICKY_SESSION_MAX_WAITING"
,
"5"
)
...
...
backend/internal/handler/admin/account_data.go
View file @
5fa45f3b
...
...
@@ -175,22 +175,28 @@ func (h *AccountHandler) ImportData(c *gin.Context) {
return
}
dataPayload
:=
req
.
Data
if
err
:=
validateDataHeader
(
dataPayload
);
err
!=
nil
{
if
err
:=
validateDataHeader
(
req
.
Data
);
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
executeAdminIdempotentJSON
(
c
,
"admin.accounts.import_data"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
return
h
.
importData
(
ctx
,
req
)
})
}
func
(
h
*
AccountHandler
)
importData
(
ctx
context
.
Context
,
req
DataImportRequest
)
(
DataImportResult
,
error
)
{
skipDefaultGroupBind
:=
true
if
req
.
SkipDefaultGroupBind
!=
nil
{
skipDefaultGroupBind
=
*
req
.
SkipDefaultGroupBind
}
dataPayload
:=
req
.
Data
result
:=
DataImportResult
{}
existingProxies
,
err
:=
h
.
listAllProxies
(
c
.
Request
.
Context
())
existingProxies
,
err
:=
h
.
listAllProxies
(
ctx
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
return
result
,
err
}
proxyKeyToID
:=
make
(
map
[
string
]
int64
,
len
(
existingProxies
))
...
...
@@ -221,8 +227,8 @@ func (h *AccountHandler) ImportData(c *gin.Context) {
proxyKeyToID
[
key
]
=
existingID
result
.
ProxyReused
++
if
normalizedStatus
!=
""
{
if
proxy
,
e
rr
:=
h
.
adminService
.
GetProxy
(
c
.
Request
.
Context
()
,
existingID
);
e
rr
==
nil
&&
proxy
!=
nil
&&
proxy
.
Status
!=
normalizedStatus
{
_
,
_
=
h
.
adminService
.
UpdateProxy
(
c
.
Request
.
Context
()
,
existingID
,
&
service
.
UpdateProxyInput
{
if
proxy
,
getE
rr
:=
h
.
adminService
.
GetProxy
(
c
tx
,
existingID
);
getE
rr
==
nil
&&
proxy
!=
nil
&&
proxy
.
Status
!=
normalizedStatus
{
_
,
_
=
h
.
adminService
.
UpdateProxy
(
c
tx
,
existingID
,
&
service
.
UpdateProxyInput
{
Status
:
normalizedStatus
,
})
}
...
...
@@ -230,7 +236,7 @@ func (h *AccountHandler) ImportData(c *gin.Context) {
continue
}
created
,
e
rr
:=
h
.
adminService
.
CreateProxy
(
c
.
Request
.
Context
()
,
&
service
.
CreateProxyInput
{
created
,
createE
rr
:=
h
.
adminService
.
CreateProxy
(
c
tx
,
&
service
.
CreateProxyInput
{
Name
:
defaultProxyName
(
item
.
Name
),
Protocol
:
item
.
Protocol
,
Host
:
item
.
Host
,
...
...
@@ -238,13 +244,13 @@ func (h *AccountHandler) ImportData(c *gin.Context) {
Username
:
item
.
Username
,
Password
:
item
.
Password
,
})
if
e
rr
!=
nil
{
if
createE
rr
!=
nil
{
result
.
ProxyFailed
++
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"proxy"
,
Name
:
item
.
Name
,
ProxyKey
:
key
,
Message
:
e
rr
.
Error
(),
Message
:
createE
rr
.
Error
(),
})
continue
}
...
...
@@ -252,7 +258,7 @@ func (h *AccountHandler) ImportData(c *gin.Context) {
result
.
ProxyCreated
++
if
normalizedStatus
!=
""
&&
normalizedStatus
!=
created
.
Status
{
_
,
_
=
h
.
adminService
.
UpdateProxy
(
c
.
Request
.
Context
()
,
created
.
ID
,
&
service
.
UpdateProxyInput
{
_
,
_
=
h
.
adminService
.
UpdateProxy
(
c
tx
,
created
.
ID
,
&
service
.
UpdateProxyInput
{
Status
:
normalizedStatus
,
})
}
...
...
@@ -303,7 +309,7 @@ func (h *AccountHandler) ImportData(c *gin.Context) {
SkipDefaultGroupBind
:
skipDefaultGroupBind
,
}
if
_
,
err
:=
h
.
adminService
.
CreateAccount
(
c
.
Request
.
Context
()
,
accountInput
);
err
!=
nil
{
if
_
,
err
:=
h
.
adminService
.
CreateAccount
(
c
tx
,
accountInput
);
err
!=
nil
{
result
.
AccountFailed
++
result
.
Errors
=
append
(
result
.
Errors
,
DataImportError
{
Kind
:
"account"
,
...
...
@@ -315,7 +321,7 @@ func (h *AccountHandler) ImportData(c *gin.Context) {
result
.
AccountCreated
++
}
re
sponse
.
Success
(
c
,
result
)
re
turn
result
,
nil
}
func
(
h
*
AccountHandler
)
listAllProxies
(
ctx
context
.
Context
)
([]
service
.
Proxy
,
error
)
{
...
...
backend/internal/handler/admin/account_handler.go
View file @
5fa45f3b
...
...
@@ -405,7 +405,8 @@ func (h *AccountHandler) Create(c *gin.Context) {
// 确定是否跳过混合渠道检查
skipCheck
:=
req
.
ConfirmMixedChannelRisk
!=
nil
&&
*
req
.
ConfirmMixedChannelRisk
account
,
err
:=
h
.
adminService
.
CreateAccount
(
c
.
Request
.
Context
(),
&
service
.
CreateAccountInput
{
result
,
err
:=
executeAdminIdempotent
(
c
,
"admin.accounts.create"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
account
,
execErr
:=
h
.
adminService
.
CreateAccount
(
ctx
,
&
service
.
CreateAccountInput
{
Name
:
req
.
Name
,
Notes
:
req
.
Notes
,
Platform
:
req
.
Platform
,
...
...
@@ -421,6 +422,11 @@ func (h *AccountHandler) Create(c *gin.Context) {
AutoPauseOnExpired
:
req
.
AutoPauseOnExpired
,
SkipMixedChannelCheck
:
skipCheck
,
})
if
execErr
!=
nil
{
return
nil
,
execErr
}
return
h
.
buildAccountResponseWithRuntime
(
ctx
,
account
),
nil
})
if
err
!=
nil
{
// 检查是否为混合渠道错误
var
mixedErr
*
service
.
MixedChannelError
...
...
@@ -440,11 +446,17 @@ func (h *AccountHandler) Create(c *gin.Context) {
return
}
if
retryAfter
:=
service
.
RetryAfterSecondsFromError
(
err
);
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
h
.
buildAccountResponseWithRuntime
(
c
.
Request
.
Context
(),
account
))
if
result
!=
nil
&&
result
.
Replayed
{
c
.
Header
(
"X-Idempotency-Replayed"
,
"true"
)
}
response
.
Success
(
c
,
result
.
Data
)
}
// Update handles updating an account
...
...
@@ -838,7 +850,7 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
return
}
ctx
:=
c
.
Request
.
Context
()
executeAdminIdempotentJSON
(
c
,
"admin.accounts.batch_create"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
success
:=
0
failed
:=
0
results
:=
make
([]
gin
.
H
,
0
,
len
(
req
.
Accounts
))
...
...
@@ -889,10 +901,11 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
})
}
re
sponse
.
Success
(
c
,
gin
.
H
{
re
turn
gin
.
H
{
"success"
:
success
,
"failed"
:
failed
,
"results"
:
results
,
},
nil
})
}
...
...
backend/internal/handler/admin/idempotency_helper.go
0 → 100644
View file @
5fa45f3b
package
admin
import
(
"context"
"strconv"
"time"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type
idempotencyStoreUnavailableMode
int
const
(
idempotencyStoreUnavailableFailClose
idempotencyStoreUnavailableMode
=
iota
idempotencyStoreUnavailableFailOpen
)
func
executeAdminIdempotent
(
c
*
gin
.
Context
,
scope
string
,
payload
any
,
ttl
time
.
Duration
,
execute
func
(
context
.
Context
)
(
any
,
error
),
)
(
*
service
.
IdempotencyExecuteResult
,
error
)
{
coordinator
:=
service
.
DefaultIdempotencyCoordinator
()
if
coordinator
==
nil
{
data
,
err
:=
execute
(
c
.
Request
.
Context
())
if
err
!=
nil
{
return
nil
,
err
}
return
&
service
.
IdempotencyExecuteResult
{
Data
:
data
},
nil
}
actorScope
:=
"admin:0"
if
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
);
ok
{
actorScope
=
"admin:"
+
strconv
.
FormatInt
(
subject
.
UserID
,
10
)
}
return
coordinator
.
Execute
(
c
.
Request
.
Context
(),
service
.
IdempotencyExecuteOptions
{
Scope
:
scope
,
ActorScope
:
actorScope
,
Method
:
c
.
Request
.
Method
,
Route
:
c
.
FullPath
(),
IdempotencyKey
:
c
.
GetHeader
(
"Idempotency-Key"
),
Payload
:
payload
,
RequireKey
:
true
,
TTL
:
ttl
,
},
execute
)
}
func
executeAdminIdempotentJSON
(
c
*
gin
.
Context
,
scope
string
,
payload
any
,
ttl
time
.
Duration
,
execute
func
(
context
.
Context
)
(
any
,
error
),
)
{
executeAdminIdempotentJSONWithMode
(
c
,
scope
,
payload
,
ttl
,
idempotencyStoreUnavailableFailClose
,
execute
)
}
func
executeAdminIdempotentJSONFailOpenOnStoreUnavailable
(
c
*
gin
.
Context
,
scope
string
,
payload
any
,
ttl
time
.
Duration
,
execute
func
(
context
.
Context
)
(
any
,
error
),
)
{
executeAdminIdempotentJSONWithMode
(
c
,
scope
,
payload
,
ttl
,
idempotencyStoreUnavailableFailOpen
,
execute
)
}
func
executeAdminIdempotentJSONWithMode
(
c
*
gin
.
Context
,
scope
string
,
payload
any
,
ttl
time
.
Duration
,
mode
idempotencyStoreUnavailableMode
,
execute
func
(
context
.
Context
)
(
any
,
error
),
)
{
result
,
err
:=
executeAdminIdempotent
(
c
,
scope
,
payload
,
ttl
,
execute
)
if
err
!=
nil
{
if
infraerrors
.
Code
(
err
)
==
infraerrors
.
Code
(
service
.
ErrIdempotencyStoreUnavail
)
{
strategy
:=
"fail_close"
if
mode
==
idempotencyStoreUnavailableFailOpen
{
strategy
=
"fail_open"
}
service
.
RecordIdempotencyStoreUnavailable
(
c
.
FullPath
(),
scope
,
"handler_"
+
strategy
)
logger
.
LegacyPrintf
(
"handler.idempotency"
,
"[Idempotency] store unavailable: method=%s route=%s scope=%s strategy=%s"
,
c
.
Request
.
Method
,
c
.
FullPath
(),
scope
,
strategy
)
if
mode
==
idempotencyStoreUnavailableFailOpen
{
data
,
fallbackErr
:=
execute
(
c
.
Request
.
Context
())
if
fallbackErr
!=
nil
{
response
.
ErrorFrom
(
c
,
fallbackErr
)
return
}
c
.
Header
(
"X-Idempotency-Degraded"
,
"store-unavailable"
)
response
.
Success
(
c
,
data
)
return
}
}
if
retryAfter
:=
service
.
RetryAfterSecondsFromError
(
err
);
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
response
.
ErrorFrom
(
c
,
err
)
return
}
if
result
!=
nil
&&
result
.
Replayed
{
c
.
Header
(
"X-Idempotency-Replayed"
,
"true"
)
}
response
.
Success
(
c
,
result
.
Data
)
}
backend/internal/handler/admin/idempotency_helper_test.go
0 → 100644
View file @
5fa45f3b
package
admin
import
(
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
storeUnavailableRepoStub
struct
{}
func
(
storeUnavailableRepoStub
)
CreateProcessing
(
context
.
Context
,
*
service
.
IdempotencyRecord
)
(
bool
,
error
)
{
return
false
,
errors
.
New
(
"store unavailable"
)
}
func
(
storeUnavailableRepoStub
)
GetByScopeAndKeyHash
(
context
.
Context
,
string
,
string
)
(
*
service
.
IdempotencyRecord
,
error
)
{
return
nil
,
errors
.
New
(
"store unavailable"
)
}
func
(
storeUnavailableRepoStub
)
TryReclaim
(
context
.
Context
,
int64
,
string
,
time
.
Time
,
time
.
Time
,
time
.
Time
)
(
bool
,
error
)
{
return
false
,
errors
.
New
(
"store unavailable"
)
}
func
(
storeUnavailableRepoStub
)
ExtendProcessingLock
(
context
.
Context
,
int64
,
string
,
time
.
Time
,
time
.
Time
)
(
bool
,
error
)
{
return
false
,
errors
.
New
(
"store unavailable"
)
}
func
(
storeUnavailableRepoStub
)
MarkSucceeded
(
context
.
Context
,
int64
,
int
,
string
,
time
.
Time
)
error
{
return
errors
.
New
(
"store unavailable"
)
}
func
(
storeUnavailableRepoStub
)
MarkFailedRetryable
(
context
.
Context
,
int64
,
string
,
time
.
Time
,
time
.
Time
)
error
{
return
errors
.
New
(
"store unavailable"
)
}
func
(
storeUnavailableRepoStub
)
DeleteExpired
(
context
.
Context
,
time
.
Time
,
int
)
(
int64
,
error
)
{
return
0
,
errors
.
New
(
"store unavailable"
)
}
func
TestExecuteAdminIdempotentJSONFailCloseOnStoreUnavailable
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
service
.
SetDefaultIdempotencyCoordinator
(
service
.
NewIdempotencyCoordinator
(
storeUnavailableRepoStub
{},
service
.
DefaultIdempotencyConfig
()))
t
.
Cleanup
(
func
()
{
service
.
SetDefaultIdempotencyCoordinator
(
nil
)
})
var
executed
int
router
:=
gin
.
New
()
router
.
POST
(
"/idempotent"
,
func
(
c
*
gin
.
Context
)
{
executeAdminIdempotentJSON
(
c
,
"admin.test.high"
,
map
[
string
]
any
{
"a"
:
1
},
time
.
Minute
,
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
executed
++
return
gin
.
H
{
"ok"
:
true
},
nil
})
})
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/idempotent"
,
bytes
.
NewBufferString
(
`{"a":1}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Idempotency-Key"
,
"test-key-1"
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
rec
.
Code
)
require
.
Equal
(
t
,
0
,
executed
,
"fail-close should block business execution when idempotency store is unavailable"
)
}
func
TestExecuteAdminIdempotentJSONFailOpenOnStoreUnavailable
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
service
.
SetDefaultIdempotencyCoordinator
(
service
.
NewIdempotencyCoordinator
(
storeUnavailableRepoStub
{},
service
.
DefaultIdempotencyConfig
()))
t
.
Cleanup
(
func
()
{
service
.
SetDefaultIdempotencyCoordinator
(
nil
)
})
var
executed
int
router
:=
gin
.
New
()
router
.
POST
(
"/idempotent"
,
func
(
c
*
gin
.
Context
)
{
executeAdminIdempotentJSONFailOpenOnStoreUnavailable
(
c
,
"admin.test.medium"
,
map
[
string
]
any
{
"a"
:
1
},
time
.
Minute
,
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
executed
++
return
gin
.
H
{
"ok"
:
true
},
nil
})
})
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/idempotent"
,
bytes
.
NewBufferString
(
`{"a":1}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Idempotency-Key"
,
"test-key-2"
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
"store-unavailable"
,
rec
.
Header
()
.
Get
(
"X-Idempotency-Degraded"
))
require
.
Equal
(
t
,
1
,
executed
,
"fail-open strategy should allow semantic idempotent path to continue"
)
}
type
memoryIdempotencyRepoStub
struct
{
mu
sync
.
Mutex
nextID
int64
data
map
[
string
]
*
service
.
IdempotencyRecord
}
func
newMemoryIdempotencyRepoStub
()
*
memoryIdempotencyRepoStub
{
return
&
memoryIdempotencyRepoStub
{
nextID
:
1
,
data
:
make
(
map
[
string
]
*
service
.
IdempotencyRecord
),
}
}
func
(
r
*
memoryIdempotencyRepoStub
)
key
(
scope
,
keyHash
string
)
string
{
return
scope
+
"|"
+
keyHash
}
func
(
r
*
memoryIdempotencyRepoStub
)
clone
(
in
*
service
.
IdempotencyRecord
)
*
service
.
IdempotencyRecord
{
if
in
==
nil
{
return
nil
}
out
:=
*
in
if
in
.
LockedUntil
!=
nil
{
v
:=
*
in
.
LockedUntil
out
.
LockedUntil
=
&
v
}
if
in
.
ResponseBody
!=
nil
{
v
:=
*
in
.
ResponseBody
out
.
ResponseBody
=
&
v
}
if
in
.
ResponseStatus
!=
nil
{
v
:=
*
in
.
ResponseStatus
out
.
ResponseStatus
=
&
v
}
if
in
.
ErrorReason
!=
nil
{
v
:=
*
in
.
ErrorReason
out
.
ErrorReason
=
&
v
}
return
&
out
}
func
(
r
*
memoryIdempotencyRepoStub
)
CreateProcessing
(
_
context
.
Context
,
record
*
service
.
IdempotencyRecord
)
(
bool
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
k
:=
r
.
key
(
record
.
Scope
,
record
.
IdempotencyKeyHash
)
if
_
,
ok
:=
r
.
data
[
k
];
ok
{
return
false
,
nil
}
cp
:=
r
.
clone
(
record
)
cp
.
ID
=
r
.
nextID
r
.
nextID
++
r
.
data
[
k
]
=
cp
record
.
ID
=
cp
.
ID
return
true
,
nil
}
func
(
r
*
memoryIdempotencyRepoStub
)
GetByScopeAndKeyHash
(
_
context
.
Context
,
scope
,
keyHash
string
)
(
*
service
.
IdempotencyRecord
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
return
r
.
clone
(
r
.
data
[
r
.
key
(
scope
,
keyHash
)]),
nil
}
func
(
r
*
memoryIdempotencyRepoStub
)
TryReclaim
(
_
context
.
Context
,
id
int64
,
fromStatus
string
,
now
,
newLockedUntil
,
newExpiresAt
time
.
Time
)
(
bool
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
if
rec
.
Status
!=
fromStatus
{
return
false
,
nil
}
if
rec
.
LockedUntil
!=
nil
&&
rec
.
LockedUntil
.
After
(
now
)
{
return
false
,
nil
}
rec
.
Status
=
service
.
IdempotencyStatusProcessing
rec
.
LockedUntil
=
&
newLockedUntil
rec
.
ExpiresAt
=
newExpiresAt
rec
.
ErrorReason
=
nil
return
true
,
nil
}
return
false
,
nil
}
func
(
r
*
memoryIdempotencyRepoStub
)
ExtendProcessingLock
(
_
context
.
Context
,
id
int64
,
requestFingerprint
string
,
newLockedUntil
,
newExpiresAt
time
.
Time
)
(
bool
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
if
rec
.
Status
!=
service
.
IdempotencyStatusProcessing
||
rec
.
RequestFingerprint
!=
requestFingerprint
{
return
false
,
nil
}
rec
.
LockedUntil
=
&
newLockedUntil
rec
.
ExpiresAt
=
newExpiresAt
return
true
,
nil
}
return
false
,
nil
}
func
(
r
*
memoryIdempotencyRepoStub
)
MarkSucceeded
(
_
context
.
Context
,
id
int64
,
responseStatus
int
,
responseBody
string
,
expiresAt
time
.
Time
)
error
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
rec
.
Status
=
service
.
IdempotencyStatusSucceeded
rec
.
LockedUntil
=
nil
rec
.
ExpiresAt
=
expiresAt
rec
.
ResponseStatus
=
&
responseStatus
rec
.
ResponseBody
=
&
responseBody
rec
.
ErrorReason
=
nil
return
nil
}
return
nil
}
func
(
r
*
memoryIdempotencyRepoStub
)
MarkFailedRetryable
(
_
context
.
Context
,
id
int64
,
errorReason
string
,
lockedUntil
,
expiresAt
time
.
Time
)
error
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
rec
.
Status
=
service
.
IdempotencyStatusFailedRetryable
rec
.
LockedUntil
=
&
lockedUntil
rec
.
ExpiresAt
=
expiresAt
rec
.
ErrorReason
=
&
errorReason
return
nil
}
return
nil
}
func
(
r
*
memoryIdempotencyRepoStub
)
DeleteExpired
(
_
context
.
Context
,
_
time
.
Time
,
_
int
)
(
int64
,
error
)
{
return
0
,
nil
}
func
TestExecuteAdminIdempotentJSONConcurrentRetryOnlyOneSideEffect
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
newMemoryIdempotencyRepoStub
()
cfg
:=
service
.
DefaultIdempotencyConfig
()
cfg
.
ProcessingTimeout
=
2
*
time
.
Second
service
.
SetDefaultIdempotencyCoordinator
(
service
.
NewIdempotencyCoordinator
(
repo
,
cfg
))
t
.
Cleanup
(
func
()
{
service
.
SetDefaultIdempotencyCoordinator
(
nil
)
})
var
executed
atomic
.
Int32
router
:=
gin
.
New
()
router
.
POST
(
"/idempotent"
,
func
(
c
*
gin
.
Context
)
{
executeAdminIdempotentJSON
(
c
,
"admin.test.concurrent"
,
map
[
string
]
any
{
"a"
:
1
},
time
.
Minute
,
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
executed
.
Add
(
1
)
time
.
Sleep
(
120
*
time
.
Millisecond
)
return
gin
.
H
{
"ok"
:
true
},
nil
})
})
call
:=
func
()
(
int
,
http
.
Header
)
{
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/idempotent"
,
bytes
.
NewBufferString
(
`{"a":1}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Idempotency-Key"
,
"same-key"
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
return
rec
.
Code
,
rec
.
Header
()
}
var
status1
,
status2
int
var
wg
sync
.
WaitGroup
wg
.
Add
(
2
)
go
func
()
{
defer
wg
.
Done
()
status1
,
_
=
call
()
}()
go
func
()
{
defer
wg
.
Done
()
status2
,
_
=
call
()
}()
wg
.
Wait
()
require
.
Contains
(
t
,
[]
int
{
http
.
StatusOK
,
http
.
StatusConflict
},
status1
)
require
.
Contains
(
t
,
[]
int
{
http
.
StatusOK
,
http
.
StatusConflict
},
status2
)
require
.
Equal
(
t
,
int32
(
1
),
executed
.
Load
(),
"same idempotency key should execute side-effect only once"
)
status3
,
headers3
:=
call
()
require
.
Equal
(
t
,
http
.
StatusOK
,
status3
)
require
.
Equal
(
t
,
"true"
,
headers3
.
Get
(
"X-Idempotency-Replayed"
))
require
.
Equal
(
t
,
int32
(
1
),
executed
.
Load
())
}
backend/internal/handler/admin/proxy_handler.go
View file @
5fa45f3b
package
admin
import
(
"context"
"strconv"
"strings"
...
...
@@ -130,7 +131,8 @@ func (h *ProxyHandler) Create(c *gin.Context) {
return
}
proxy
,
err
:=
h
.
adminService
.
CreateProxy
(
c
.
Request
.
Context
(),
&
service
.
CreateProxyInput
{
executeAdminIdempotentJSON
(
c
,
"admin.proxies.create"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
proxy
,
err
:=
h
.
adminService
.
CreateProxy
(
ctx
,
&
service
.
CreateProxyInput
{
Name
:
strings
.
TrimSpace
(
req
.
Name
),
Protocol
:
strings
.
TrimSpace
(
req
.
Protocol
),
Host
:
strings
.
TrimSpace
(
req
.
Host
),
...
...
@@ -139,11 +141,10 @@ func (h *ProxyHandler) Create(c *gin.Context) {
Password
:
strings
.
TrimSpace
(
req
.
Password
),
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
return
nil
,
err
}
response
.
Success
(
c
,
dto
.
ProxyFromService
(
proxy
)
)
return
dto
.
ProxyFromService
(
proxy
),
nil
}
)
}
// Update handles updating a proxy
...
...
backend/internal/handler/admin/redeem_handler.go
View file @
5fa45f3b
...
...
@@ -2,6 +2,7 @@ package admin
import
(
"bytes"
"context"
"encoding/csv"
"fmt"
"strconv"
...
...
@@ -88,23 +89,24 @@ func (h *RedeemHandler) Generate(c *gin.Context) {
return
}
codes
,
err
:=
h
.
adminService
.
GenerateRedeemCodes
(
c
.
Request
.
Context
(),
&
service
.
GenerateRedeemCodesInput
{
executeAdminIdempotentJSON
(
c
,
"admin.redeem_codes.generate"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
codes
,
execErr
:=
h
.
adminService
.
GenerateRedeemCodes
(
ctx
,
&
service
.
GenerateRedeemCodesInput
{
Count
:
req
.
Count
,
Type
:
req
.
Type
,
Value
:
req
.
Value
,
GroupID
:
req
.
GroupID
,
ValidityDays
:
req
.
ValidityDays
,
})
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
if
execErr
!=
nil
{
return
nil
,
execErr
}
out
:=
make
([]
dto
.
AdminRedeemCode
,
0
,
len
(
codes
))
for
i
:=
range
codes
{
out
=
append
(
out
,
*
dto
.
RedeemCodeFromServiceAdmin
(
&
codes
[
i
]))
}
response
.
Success
(
c
,
out
)
return
out
,
nil
})
}
// Delete handles deleting a redeem code
...
...
backend/internal/handler/admin/subscription_handler.go
View file @
5fa45f3b
package
admin
import
(
"context"
"strconv"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
...
...
@@ -199,13 +200,20 @@ func (h *SubscriptionHandler) Extend(c *gin.Context) {
return
}
subscription
,
err
:=
h
.
subscriptionService
.
ExtendSubscription
(
c
.
Request
.
Context
(),
subscriptionID
,
req
.
Days
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
idempotencyPayload
:=
struct
{
SubscriptionID
int64
`json:"subscription_id"`
Body
AdjustSubscriptionRequest
`json:"body"`
}{
SubscriptionID
:
subscriptionID
,
Body
:
req
,
}
response
.
Success
(
c
,
dto
.
UserSubscriptionFromServiceAdmin
(
subscription
))
executeAdminIdempotentJSON
(
c
,
"admin.subscriptions.extend"
,
idempotencyPayload
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
subscription
,
execErr
:=
h
.
subscriptionService
.
ExtendSubscription
(
ctx
,
subscriptionID
,
req
.
Days
)
if
execErr
!=
nil
{
return
nil
,
execErr
}
return
dto
.
UserSubscriptionFromServiceAdmin
(
subscription
),
nil
})
}
// Revoke handles revoking a subscription
...
...
backend/internal/handler/admin/system_handler.go
View file @
5fa45f3b
package
admin
import
(
"context"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/sysutil"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
...
...
@@ -14,12 +18,14 @@ import (
// SystemHandler handles system-related operations
type
SystemHandler
struct
{
updateSvc
*
service
.
UpdateService
lockSvc
*
service
.
SystemOperationLockService
}
// NewSystemHandler creates a new SystemHandler
func
NewSystemHandler
(
updateSvc
*
service
.
UpdateService
)
*
SystemHandler
{
func
NewSystemHandler
(
updateSvc
*
service
.
UpdateService
,
lockSvc
*
service
.
SystemOperationLockService
)
*
SystemHandler
{
return
&
SystemHandler
{
updateSvc
:
updateSvc
,
lockSvc
:
lockSvc
,
}
}
...
...
@@ -47,32 +53,78 @@ func (h *SystemHandler) CheckUpdates(c *gin.Context) {
// PerformUpdate downloads and applies the update
// POST /api/v1/admin/system/update
func
(
h
*
SystemHandler
)
PerformUpdate
(
c
*
gin
.
Context
)
{
if
err
:=
h
.
updateSvc
.
PerformUpdate
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
Error
(
c
,
http
.
StatusInternalServerError
,
err
.
Error
())
return
operationID
:=
buildSystemOperationID
(
c
,
"update"
)
payload
:=
gin
.
H
{
"operation_id"
:
operationID
}
executeAdminIdempotentJSON
(
c
,
"admin.system.update"
,
payload
,
service
.
DefaultSystemOperationIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
lock
,
release
,
err
:=
h
.
acquireSystemLock
(
ctx
,
operationID
)
if
err
!=
nil
{
return
nil
,
err
}
response
.
Success
(
c
,
gin
.
H
{
var
releaseReason
string
succeeded
:=
false
defer
func
()
{
release
(
releaseReason
,
succeeded
)
}()
if
err
:=
h
.
updateSvc
.
PerformUpdate
(
ctx
);
err
!=
nil
{
releaseReason
=
"SYSTEM_UPDATE_FAILED"
return
nil
,
err
}
succeeded
=
true
return
gin
.
H
{
"message"
:
"Update completed. Please restart the service."
,
"need_restart"
:
true
,
"operation_id"
:
lock
.
OperationID
(),
},
nil
})
}
// Rollback restores the previous version
// POST /api/v1/admin/system/rollback
func
(
h
*
SystemHandler
)
Rollback
(
c
*
gin
.
Context
)
{
operationID
:=
buildSystemOperationID
(
c
,
"rollback"
)
payload
:=
gin
.
H
{
"operation_id"
:
operationID
}
executeAdminIdempotentJSON
(
c
,
"admin.system.rollback"
,
payload
,
service
.
DefaultSystemOperationIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
lock
,
release
,
err
:=
h
.
acquireSystemLock
(
ctx
,
operationID
)
if
err
!=
nil
{
return
nil
,
err
}
var
releaseReason
string
succeeded
:=
false
defer
func
()
{
release
(
releaseReason
,
succeeded
)
}()
if
err
:=
h
.
updateSvc
.
Rollback
();
err
!=
nil
{
re
sponse
.
Error
(
c
,
http
.
StatusInternalServerError
,
err
.
Error
())
return
re
leaseReason
=
"SYSTEM_ROLLBACK_FAILED"
return
nil
,
err
}
response
.
Success
(
c
,
gin
.
H
{
succeeded
=
true
return
gin
.
H
{
"message"
:
"Rollback completed. Please restart the service."
,
"need_restart"
:
true
,
"operation_id"
:
lock
.
OperationID
(),
},
nil
})
}
// RestartService restarts the systemd service
// POST /api/v1/admin/system/restart
func
(
h
*
SystemHandler
)
RestartService
(
c
*
gin
.
Context
)
{
operationID
:=
buildSystemOperationID
(
c
,
"restart"
)
payload
:=
gin
.
H
{
"operation_id"
:
operationID
}
executeAdminIdempotentJSON
(
c
,
"admin.system.restart"
,
payload
,
service
.
DefaultSystemOperationIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
lock
,
release
,
err
:=
h
.
acquireSystemLock
(
ctx
,
operationID
)
if
err
!=
nil
{
return
nil
,
err
}
succeeded
:=
false
defer
func
()
{
release
(
""
,
succeeded
)
}()
// Schedule service restart in background after sending response
// This ensures the client receives the success response before the service restarts
go
func
()
{
...
...
@@ -80,8 +132,46 @@ func (h *SystemHandler) RestartService(c *gin.Context) {
time
.
Sleep
(
500
*
time
.
Millisecond
)
sysutil
.
RestartServiceAsync
()
}()
re
sponse
.
Success
(
c
,
gin
.
H
{
succeeded
=
true
re
turn
gin
.
H
{
"message"
:
"Service restart initiated"
,
"operation_id"
:
lock
.
OperationID
(),
},
nil
})
}
func
(
h
*
SystemHandler
)
acquireSystemLock
(
ctx
context
.
Context
,
operationID
string
,
)
(
*
service
.
SystemOperationLock
,
func
(
string
,
bool
),
error
)
{
if
h
.
lockSvc
==
nil
{
return
nil
,
nil
,
service
.
ErrIdempotencyStoreUnavail
}
lock
,
err
:=
h
.
lockSvc
.
Acquire
(
ctx
,
operationID
)
if
err
!=
nil
{
return
nil
,
nil
,
err
}
release
:=
func
(
reason
string
,
succeeded
bool
)
{
releaseCtx
,
cancel
:=
context
.
WithTimeout
(
context
.
Background
(),
2
*
time
.
Second
)
defer
cancel
()
_
=
h
.
lockSvc
.
Release
(
releaseCtx
,
lock
,
succeeded
,
reason
)
}
return
lock
,
release
,
nil
}
func
buildSystemOperationID
(
c
*
gin
.
Context
,
operation
string
)
string
{
key
:=
strings
.
TrimSpace
(
c
.
GetHeader
(
"Idempotency-Key"
))
if
key
==
""
{
return
"sysop-"
+
operation
+
"-"
+
strconv
.
FormatInt
(
time
.
Now
()
.
UnixNano
(),
36
)
}
actorScope
:=
"admin:0"
if
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
);
ok
{
actorScope
=
"admin:"
+
strconv
.
FormatInt
(
subject
.
UserID
,
10
)
}
seed
:=
operation
+
"|"
+
actorScope
+
"|"
+
c
.
FullPath
()
+
"|"
+
key
hash
:=
service
.
HashIdempotencyKey
(
seed
)
if
len
(
hash
)
>
24
{
hash
=
hash
[
:
24
]
}
return
"sysop-"
+
hash
}
backend/internal/handler/admin/usage_handler.go
View file @
5fa45f3b
package
admin
import
(
"context"
"net/http"
"strconv"
"strings"
...
...
@@ -472,6 +473,14 @@ func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
billingType
=
*
filters
.
BillingType
}
idempotencyPayload
:=
struct
{
OperatorID
int64
`json:"operator_id"`
Body
CreateUsageCleanupTaskRequest
`json:"body"`
}{
OperatorID
:
subject
.
UserID
,
Body
:
req
,
}
executeAdminIdempotentJSON
(
c
,
"admin.usage.cleanup_tasks.create"
,
idempotencyPayload
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
logger
.
LegacyPrintf
(
"handler.admin.usage"
,
"[UsageCleanup] 请求创建清理任务: operator=%d start=%s end=%s user_id=%v api_key_id=%v account_id=%v group_id=%v model=%v stream=%v billing_type=%v tz=%q"
,
subject
.
UserID
,
filters
.
StartTime
.
Format
(
time
.
RFC3339
),
...
...
@@ -486,15 +495,14 @@ func (h *UsageHandler) CreateCleanupTask(c *gin.Context) {
req
.
Timezone
,
)
task
,
err
:=
h
.
cleanupService
.
CreateTask
(
c
.
Request
.
Context
()
,
filters
,
subject
.
UserID
)
task
,
err
:=
h
.
cleanupService
.
CreateTask
(
c
tx
,
filters
,
subject
.
UserID
)
if
err
!=
nil
{
logger
.
LegacyPrintf
(
"handler.admin.usage"
,
"[UsageCleanup] 创建清理任务失败: operator=%d err=%v"
,
subject
.
UserID
,
err
)
response
.
ErrorFrom
(
c
,
err
)
return
return
nil
,
err
}
logger
.
LegacyPrintf
(
"handler.admin.usage"
,
"[UsageCleanup] 清理任务已创建: task=%d operator=%d status=%s"
,
task
.
ID
,
subject
.
UserID
,
task
.
Status
)
response
.
Success
(
c
,
dto
.
UsageCleanupTaskFromService
(
task
))
return
dto
.
UsageCleanupTaskFromService
(
task
),
nil
})
}
// CancelCleanupTask handles canceling a usage cleanup task
...
...
backend/internal/handler/admin/user_handler.go
View file @
5fa45f3b
package
admin
import
(
"context"
"strconv"
"strings"
...
...
@@ -257,13 +258,20 @@ func (h *UserHandler) UpdateBalance(c *gin.Context) {
return
}
user
,
err
:=
h
.
adminService
.
UpdateUserBalance
(
c
.
Request
.
Context
(),
userID
,
req
.
Balance
,
req
.
Operation
,
req
.
Notes
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
idempotencyPayload
:=
struct
{
UserID
int64
`json:"user_id"`
Body
UpdateBalanceRequest
`json:"body"`
}{
UserID
:
userID
,
Body
:
req
,
}
response
.
Success
(
c
,
dto
.
UserFromServiceAdmin
(
user
))
executeAdminIdempotentJSON
(
c
,
"admin.users.balance.update"
,
idempotencyPayload
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
user
,
execErr
:=
h
.
adminService
.
UpdateUserBalance
(
ctx
,
userID
,
req
.
Balance
,
req
.
Operation
,
req
.
Notes
)
if
execErr
!=
nil
{
return
nil
,
execErr
}
return
dto
.
UserFromServiceAdmin
(
user
),
nil
})
}
// GetUserAPIKeys handles getting user's API keys
...
...
backend/internal/handler/api_key_handler.go
View file @
5fa45f3b
...
...
@@ -2,6 +2,7 @@
package
handler
import
(
"context"
"strconv"
"time"
...
...
@@ -130,13 +131,14 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
if
req
.
Quota
!=
nil
{
svcReq
.
Quota
=
*
req
.
Quota
}
key
,
err
:=
h
.
apiKeyService
.
Create
(
c
.
Request
.
Context
(),
subject
.
UserID
,
svcReq
)
executeUserIdempotentJSON
(
c
,
"user.api_keys.create"
,
req
,
service
.
DefaultWriteIdempotencyTTL
(),
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
key
,
err
:=
h
.
apiKeyService
.
Create
(
ctx
,
subject
.
UserID
,
svcReq
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
return
nil
,
err
}
response
.
Success
(
c
,
dto
.
APIKeyFromService
(
key
)
)
return
dto
.
APIKeyFromService
(
key
),
nil
}
)
}
// Update handles updating an API key
...
...
backend/internal/handler/dto/mappers.go
View file @
5fa45f3b
...
...
@@ -2,6 +2,7 @@
package
dto
import
(
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
...
...
@@ -542,11 +543,18 @@ func BulkAssignResultFromService(r *service.BulkAssignResult) *BulkAssignResult
for
i
:=
range
r
.
Subscriptions
{
subs
=
append
(
subs
,
*
UserSubscriptionFromServiceAdmin
(
&
r
.
Subscriptions
[
i
]))
}
statuses
:=
make
(
map
[
string
]
string
,
len
(
r
.
Statuses
))
for
userID
,
status
:=
range
r
.
Statuses
{
statuses
[
strconv
.
FormatInt
(
userID
,
10
)]
=
status
}
return
&
BulkAssignResult
{
SuccessCount
:
r
.
SuccessCount
,
CreatedCount
:
r
.
CreatedCount
,
ReusedCount
:
r
.
ReusedCount
,
FailedCount
:
r
.
FailedCount
,
Subscriptions
:
subs
,
Errors
:
r
.
Errors
,
Statuses
:
statuses
,
}
}
...
...
backend/internal/handler/dto/types.go
View file @
5fa45f3b
...
...
@@ -395,9 +395,12 @@ type AdminUserSubscription struct {
type
BulkAssignResult
struct
{
SuccessCount
int
`json:"success_count"`
CreatedCount
int
`json:"created_count"`
ReusedCount
int
`json:"reused_count"`
FailedCount
int
`json:"failed_count"`
Subscriptions
[]
AdminUserSubscription
`json:"subscriptions"`
Errors
[]
string
`json:"errors"`
Statuses
map
[
string
]
string
`json:"statuses,omitempty"`
}
// PromoCode 注册优惠码
...
...
backend/internal/handler/idempotency_helper.go
0 → 100644
View file @
5fa45f3b
package
handler
import
(
"context"
"strconv"
"time"
infraerrors
"github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
func
executeUserIdempotentJSON
(
c
*
gin
.
Context
,
scope
string
,
payload
any
,
ttl
time
.
Duration
,
execute
func
(
context
.
Context
)
(
any
,
error
),
)
{
coordinator
:=
service
.
DefaultIdempotencyCoordinator
()
if
coordinator
==
nil
{
data
,
err
:=
execute
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
data
)
return
}
actorScope
:=
"user:0"
if
subject
,
ok
:=
middleware2
.
GetAuthSubjectFromContext
(
c
);
ok
{
actorScope
=
"user:"
+
strconv
.
FormatInt
(
subject
.
UserID
,
10
)
}
result
,
err
:=
coordinator
.
Execute
(
c
.
Request
.
Context
(),
service
.
IdempotencyExecuteOptions
{
Scope
:
scope
,
ActorScope
:
actorScope
,
Method
:
c
.
Request
.
Method
,
Route
:
c
.
FullPath
(),
IdempotencyKey
:
c
.
GetHeader
(
"Idempotency-Key"
),
Payload
:
payload
,
RequireKey
:
true
,
TTL
:
ttl
,
},
execute
)
if
err
!=
nil
{
if
infraerrors
.
Code
(
err
)
==
infraerrors
.
Code
(
service
.
ErrIdempotencyStoreUnavail
)
{
service
.
RecordIdempotencyStoreUnavailable
(
c
.
FullPath
(),
scope
,
"handler_fail_close"
)
logger
.
LegacyPrintf
(
"handler.idempotency"
,
"[Idempotency] store unavailable: method=%s route=%s scope=%s strategy=fail_close"
,
c
.
Request
.
Method
,
c
.
FullPath
(),
scope
)
}
if
retryAfter
:=
service
.
RetryAfterSecondsFromError
(
err
);
retryAfter
>
0
{
c
.
Header
(
"Retry-After"
,
strconv
.
Itoa
(
retryAfter
))
}
response
.
ErrorFrom
(
c
,
err
)
return
}
if
result
!=
nil
&&
result
.
Replayed
{
c
.
Header
(
"X-Idempotency-Replayed"
,
"true"
)
}
response
.
Success
(
c
,
result
.
Data
)
}
backend/internal/handler/idempotency_helper_test.go
0 → 100644
View file @
5fa45f3b
package
handler
import
(
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type
userStoreUnavailableRepoStub
struct
{}
func
(
userStoreUnavailableRepoStub
)
CreateProcessing
(
context
.
Context
,
*
service
.
IdempotencyRecord
)
(
bool
,
error
)
{
return
false
,
errors
.
New
(
"store unavailable"
)
}
func
(
userStoreUnavailableRepoStub
)
GetByScopeAndKeyHash
(
context
.
Context
,
string
,
string
)
(
*
service
.
IdempotencyRecord
,
error
)
{
return
nil
,
errors
.
New
(
"store unavailable"
)
}
func
(
userStoreUnavailableRepoStub
)
TryReclaim
(
context
.
Context
,
int64
,
string
,
time
.
Time
,
time
.
Time
,
time
.
Time
)
(
bool
,
error
)
{
return
false
,
errors
.
New
(
"store unavailable"
)
}
func
(
userStoreUnavailableRepoStub
)
ExtendProcessingLock
(
context
.
Context
,
int64
,
string
,
time
.
Time
,
time
.
Time
)
(
bool
,
error
)
{
return
false
,
errors
.
New
(
"store unavailable"
)
}
func
(
userStoreUnavailableRepoStub
)
MarkSucceeded
(
context
.
Context
,
int64
,
int
,
string
,
time
.
Time
)
error
{
return
errors
.
New
(
"store unavailable"
)
}
func
(
userStoreUnavailableRepoStub
)
MarkFailedRetryable
(
context
.
Context
,
int64
,
string
,
time
.
Time
,
time
.
Time
)
error
{
return
errors
.
New
(
"store unavailable"
)
}
func
(
userStoreUnavailableRepoStub
)
DeleteExpired
(
context
.
Context
,
time
.
Time
,
int
)
(
int64
,
error
)
{
return
0
,
errors
.
New
(
"store unavailable"
)
}
type
userMemoryIdempotencyRepoStub
struct
{
mu
sync
.
Mutex
nextID
int64
data
map
[
string
]
*
service
.
IdempotencyRecord
}
func
newUserMemoryIdempotencyRepoStub
()
*
userMemoryIdempotencyRepoStub
{
return
&
userMemoryIdempotencyRepoStub
{
nextID
:
1
,
data
:
make
(
map
[
string
]
*
service
.
IdempotencyRecord
),
}
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
key
(
scope
,
keyHash
string
)
string
{
return
scope
+
"|"
+
keyHash
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
clone
(
in
*
service
.
IdempotencyRecord
)
*
service
.
IdempotencyRecord
{
if
in
==
nil
{
return
nil
}
out
:=
*
in
if
in
.
LockedUntil
!=
nil
{
v
:=
*
in
.
LockedUntil
out
.
LockedUntil
=
&
v
}
if
in
.
ResponseBody
!=
nil
{
v
:=
*
in
.
ResponseBody
out
.
ResponseBody
=
&
v
}
if
in
.
ResponseStatus
!=
nil
{
v
:=
*
in
.
ResponseStatus
out
.
ResponseStatus
=
&
v
}
if
in
.
ErrorReason
!=
nil
{
v
:=
*
in
.
ErrorReason
out
.
ErrorReason
=
&
v
}
return
&
out
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
CreateProcessing
(
_
context
.
Context
,
record
*
service
.
IdempotencyRecord
)
(
bool
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
k
:=
r
.
key
(
record
.
Scope
,
record
.
IdempotencyKeyHash
)
if
_
,
ok
:=
r
.
data
[
k
];
ok
{
return
false
,
nil
}
cp
:=
r
.
clone
(
record
)
cp
.
ID
=
r
.
nextID
r
.
nextID
++
r
.
data
[
k
]
=
cp
record
.
ID
=
cp
.
ID
return
true
,
nil
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
GetByScopeAndKeyHash
(
_
context
.
Context
,
scope
,
keyHash
string
)
(
*
service
.
IdempotencyRecord
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
return
r
.
clone
(
r
.
data
[
r
.
key
(
scope
,
keyHash
)]),
nil
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
TryReclaim
(
_
context
.
Context
,
id
int64
,
fromStatus
string
,
now
,
newLockedUntil
,
newExpiresAt
time
.
Time
)
(
bool
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
if
rec
.
Status
!=
fromStatus
{
return
false
,
nil
}
if
rec
.
LockedUntil
!=
nil
&&
rec
.
LockedUntil
.
After
(
now
)
{
return
false
,
nil
}
rec
.
Status
=
service
.
IdempotencyStatusProcessing
rec
.
LockedUntil
=
&
newLockedUntil
rec
.
ExpiresAt
=
newExpiresAt
rec
.
ErrorReason
=
nil
return
true
,
nil
}
return
false
,
nil
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
ExtendProcessingLock
(
_
context
.
Context
,
id
int64
,
requestFingerprint
string
,
newLockedUntil
,
newExpiresAt
time
.
Time
)
(
bool
,
error
)
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
if
rec
.
Status
!=
service
.
IdempotencyStatusProcessing
||
rec
.
RequestFingerprint
!=
requestFingerprint
{
return
false
,
nil
}
rec
.
LockedUntil
=
&
newLockedUntil
rec
.
ExpiresAt
=
newExpiresAt
return
true
,
nil
}
return
false
,
nil
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
MarkSucceeded
(
_
context
.
Context
,
id
int64
,
responseStatus
int
,
responseBody
string
,
expiresAt
time
.
Time
)
error
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
rec
.
Status
=
service
.
IdempotencyStatusSucceeded
rec
.
LockedUntil
=
nil
rec
.
ExpiresAt
=
expiresAt
rec
.
ResponseStatus
=
&
responseStatus
rec
.
ResponseBody
=
&
responseBody
rec
.
ErrorReason
=
nil
return
nil
}
return
nil
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
MarkFailedRetryable
(
_
context
.
Context
,
id
int64
,
errorReason
string
,
lockedUntil
,
expiresAt
time
.
Time
)
error
{
r
.
mu
.
Lock
()
defer
r
.
mu
.
Unlock
()
for
_
,
rec
:=
range
r
.
data
{
if
rec
.
ID
!=
id
{
continue
}
rec
.
Status
=
service
.
IdempotencyStatusFailedRetryable
rec
.
LockedUntil
=
&
lockedUntil
rec
.
ExpiresAt
=
expiresAt
rec
.
ErrorReason
=
&
errorReason
return
nil
}
return
nil
}
func
(
r
*
userMemoryIdempotencyRepoStub
)
DeleteExpired
(
_
context
.
Context
,
_
time
.
Time
,
_
int
)
(
int64
,
error
)
{
return
0
,
nil
}
func
withUserSubject
(
userID
int64
)
gin
.
HandlerFunc
{
return
func
(
c
*
gin
.
Context
)
{
c
.
Set
(
string
(
middleware
.
ContextKeyUser
),
middleware
.
AuthSubject
{
UserID
:
userID
})
c
.
Next
()
}
}
func
TestExecuteUserIdempotentJSONFallbackWithoutCoordinator
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
service
.
SetDefaultIdempotencyCoordinator
(
nil
)
var
executed
int
router
:=
gin
.
New
()
router
.
Use
(
withUserSubject
(
1
))
router
.
POST
(
"/idempotent"
,
func
(
c
*
gin
.
Context
)
{
executeUserIdempotentJSON
(
c
,
"user.test.scope"
,
map
[
string
]
any
{
"a"
:
1
},
time
.
Minute
,
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
executed
++
return
gin
.
H
{
"ok"
:
true
},
nil
})
})
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/idempotent"
,
bytes
.
NewBufferString
(
`{"a":1}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusOK
,
rec
.
Code
)
require
.
Equal
(
t
,
1
,
executed
)
}
func
TestExecuteUserIdempotentJSONFailCloseOnStoreUnavailable
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
service
.
SetDefaultIdempotencyCoordinator
(
service
.
NewIdempotencyCoordinator
(
userStoreUnavailableRepoStub
{},
service
.
DefaultIdempotencyConfig
()))
t
.
Cleanup
(
func
()
{
service
.
SetDefaultIdempotencyCoordinator
(
nil
)
})
var
executed
int
router
:=
gin
.
New
()
router
.
Use
(
withUserSubject
(
2
))
router
.
POST
(
"/idempotent"
,
func
(
c
*
gin
.
Context
)
{
executeUserIdempotentJSON
(
c
,
"user.test.scope"
,
map
[
string
]
any
{
"a"
:
1
},
time
.
Minute
,
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
executed
++
return
gin
.
H
{
"ok"
:
true
},
nil
})
})
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/idempotent"
,
bytes
.
NewBufferString
(
`{"a":1}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Idempotency-Key"
,
"k1"
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
require
.
Equal
(
t
,
http
.
StatusServiceUnavailable
,
rec
.
Code
)
require
.
Equal
(
t
,
0
,
executed
)
}
func
TestExecuteUserIdempotentJSONConcurrentRetrySingleSideEffectAndReplay
(
t
*
testing
.
T
)
{
gin
.
SetMode
(
gin
.
TestMode
)
repo
:=
newUserMemoryIdempotencyRepoStub
()
cfg
:=
service
.
DefaultIdempotencyConfig
()
cfg
.
ProcessingTimeout
=
2
*
time
.
Second
service
.
SetDefaultIdempotencyCoordinator
(
service
.
NewIdempotencyCoordinator
(
repo
,
cfg
))
t
.
Cleanup
(
func
()
{
service
.
SetDefaultIdempotencyCoordinator
(
nil
)
})
var
executed
atomic
.
Int32
router
:=
gin
.
New
()
router
.
Use
(
withUserSubject
(
3
))
router
.
POST
(
"/idempotent"
,
func
(
c
*
gin
.
Context
)
{
executeUserIdempotentJSON
(
c
,
"user.test.scope"
,
map
[
string
]
any
{
"a"
:
1
},
time
.
Minute
,
func
(
ctx
context
.
Context
)
(
any
,
error
)
{
executed
.
Add
(
1
)
time
.
Sleep
(
80
*
time
.
Millisecond
)
return
gin
.
H
{
"ok"
:
true
},
nil
})
})
call
:=
func
()
(
int
,
http
.
Header
)
{
req
:=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/idempotent"
,
bytes
.
NewBufferString
(
`{"a":1}`
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
req
.
Header
.
Set
(
"Idempotency-Key"
,
"same-user-key"
)
rec
:=
httptest
.
NewRecorder
()
router
.
ServeHTTP
(
rec
,
req
)
return
rec
.
Code
,
rec
.
Header
()
}
var
status1
,
status2
int
var
wg
sync
.
WaitGroup
wg
.
Add
(
2
)
go
func
()
{
defer
wg
.
Done
();
status1
,
_
=
call
()
}()
go
func
()
{
defer
wg
.
Done
();
status2
,
_
=
call
()
}()
wg
.
Wait
()
require
.
Contains
(
t
,
[]
int
{
http
.
StatusOK
,
http
.
StatusConflict
},
status1
)
require
.
Contains
(
t
,
[]
int
{
http
.
StatusOK
,
http
.
StatusConflict
},
status2
)
require
.
Equal
(
t
,
int32
(
1
),
executed
.
Load
())
status3
,
headers3
:=
call
()
require
.
Equal
(
t
,
http
.
StatusOK
,
status3
)
require
.
Equal
(
t
,
"true"
,
headers3
.
Get
(
"X-Idempotency-Replayed"
))
require
.
Equal
(
t
,
int32
(
1
),
executed
.
Load
())
}
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