mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26d894ef4f | ||
|
|
017f6bbd5e | ||
|
|
f9d2791693 | ||
|
|
105e5c5da3 | ||
|
|
bf0fc03ab7 | ||
|
|
2be08f3f39 | ||
|
|
493955f7bd | ||
|
|
53aa5cd247 | ||
|
|
0a45be17d8 | ||
|
|
60f6dc91cf | ||
|
|
1702ee1362 | ||
|
|
c4b461c68c | ||
|
|
3c387a164d | ||
|
|
e854132a57 | ||
|
|
a8cd33eead | ||
|
|
276d9cbd9b | ||
|
|
1f8b3a9c60 | ||
|
|
c9156c1e7f | ||
|
|
91850d36e6 | ||
|
|
6fa784fdd0 | ||
|
|
da49ce3f29 | ||
|
|
313121f3f7 | ||
|
|
488d3b09ec | ||
|
|
b2d895fb8d | ||
|
|
fb40211305 | ||
|
|
352b21f4e2 | ||
|
|
0ee9ea5765 | ||
|
|
711056f5b7 | ||
|
|
ac90355a87 | ||
|
|
54b1f8f6b3 | ||
|
|
0010894f99 | ||
|
|
0353cdadfe | ||
|
|
92dc61d401 | ||
|
|
5a6143097d | ||
|
|
b9c7cb8e24 | ||
|
|
99c8e4bf75 | ||
|
|
f2d824836f | ||
|
|
6e1cbed423 | ||
|
|
acad7f1a09 | ||
|
|
8fd01c2814 | ||
|
|
39903f006e | ||
|
|
32618e71e4 | ||
|
|
86fb4781f4 | ||
|
|
720c405e35 | ||
|
|
2e432173f7 | ||
|
|
38ef8dc069 | ||
|
|
edac19e87a | ||
|
|
97f44b21bb | ||
|
|
0ef2228ce9 | ||
|
|
1cf6972e73 | ||
|
|
20893dc4fd | ||
|
|
a12d88ed4b | ||
|
|
6c04e41952 | ||
|
|
f71332ff85 | ||
|
|
c342a885b5 | ||
|
|
1b966dbea1 | ||
|
|
7e65eafbe4 | ||
|
|
1aeacf4d41 | ||
|
|
d95f6b98ce | ||
|
|
bfbe113f5e | ||
|
|
d8ae153ae9 | ||
|
|
248236ce6d | ||
|
|
1c26dc7ad8 | ||
|
|
1631b19f84 | ||
|
|
3c62b5ca85 | ||
|
|
fc495e087d | ||
|
|
a6fc2d10b3 | ||
|
|
59ce11c780 | ||
|
|
46dba19397 | ||
|
|
83b368553d | ||
|
|
357c5b917b | ||
|
|
4158e73b3f | ||
|
|
cc62979aa7 | ||
|
|
efa5a2240d |
@@ -16,6 +16,8 @@ jobs:
|
||||
run: |
|
||||
/bin/bash -n deploy/apple-container.sh
|
||||
/bin/bash deploy/tests/apple-container-test.sh
|
||||
/bin/sh deploy/tests/docker-compose-security-test.sh
|
||||
/bin/sh deploy/tests/docker-runtime-resources-test.sh
|
||||
/bin/sh deploy/test-caddyfile-cache.sh
|
||||
|
||||
test:
|
||||
|
||||
@@ -49,6 +49,7 @@ dockers:
|
||||
use: buildx
|
||||
extra_files:
|
||||
- deploy/docker-entrypoint.sh
|
||||
- backend/resources
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||
|
||||
@@ -65,6 +65,7 @@ dockers:
|
||||
use: buildx
|
||||
extra_files:
|
||||
- deploy/docker-entrypoint.sh
|
||||
- backend/resources
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||
@@ -80,6 +81,7 @@ dockers:
|
||||
use: buildx
|
||||
extra_files:
|
||||
- deploy/docker-entrypoint.sh
|
||||
- backend/resources
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||
@@ -95,6 +97,7 @@ dockers:
|
||||
use: buildx
|
||||
extra_files:
|
||||
- deploy/docker-entrypoint.sh
|
||||
- backend/resources
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||
@@ -110,6 +113,7 @@ dockers:
|
||||
use: buildx
|
||||
extra_files:
|
||||
- deploy/docker-entrypoint.sh
|
||||
- backend/resources
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.version={{ .Version }}"
|
||||
|
||||
@@ -42,8 +42,9 @@ RUN addgroup -g 1000 sub2api && \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy pre-built binary from GoReleaser
|
||||
# Copy the pre-built binary and runtime fallback resources from GoReleaser
|
||||
COPY sub2api /app/sub2api
|
||||
COPY --chown=sub2api:sub2api backend/resources /app/resources
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && chown -R sub2api:sub2api /app
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.1.165
|
||||
0.1.168
|
||||
|
||||
@@ -46,8 +46,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
return nil, err
|
||||
}
|
||||
userRepository := repository.NewUserRepository(client, db)
|
||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||
redisClient := repository.ProvideRedis(configConfig)
|
||||
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
|
||||
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
|
||||
settingRepository := repository.NewSettingRepository(client)
|
||||
groupRepository := repository.NewGroupRepository(client, db)
|
||||
@@ -79,6 +81,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
||||
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
|
||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
|
||||
@@ -92,6 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
|
||||
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
|
||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService)
|
||||
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
|
||||
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
@@ -255,7 +262,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
|
||||
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
|
||||
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
|
||||
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor)
|
||||
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor, configConfig)
|
||||
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
|
||||
redisPayloadStore := securityaudit.NewRedisPayloadStore(redisClient)
|
||||
openAICompatibleScanner := securityaudit.NewOpenAICompatibleScanner()
|
||||
@@ -283,6 +290,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
|
||||
imageTaskStore := repository.NewImageTaskStore(redisClient)
|
||||
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
|
||||
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
|
||||
@@ -296,13 +304,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
|
||||
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
|
||||
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
|
||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
|
||||
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, optionalJWTAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
|
||||
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
|
||||
|
||||
+9
-1
@@ -16,7 +16,8 @@ require (
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/dgraph-io/ristretto v0.2.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
@@ -97,6 +98,7 @@ require (
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
@@ -106,9 +108,12 @@ require (
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.14.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
|
||||
@@ -137,6 +142,7 @@ require (
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
@@ -158,10 +164,12 @@ require (
|
||||
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zclconf/go-cty v1.14.4 // indirect
|
||||
|
||||
@@ -128,6 +128,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
@@ -153,16 +155,26 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
|
||||
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
@@ -261,6 +273,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -356,6 +370,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru4+3Q7Y=
|
||||
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||
@@ -366,6 +382,8 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
|
||||
@@ -73,6 +73,7 @@ type Config struct {
|
||||
Ops OpsConfig `mapstructure:"ops"`
|
||||
JWT JWTConfig `mapstructure:"jwt"`
|
||||
Totp TotpConfig `mapstructure:"totp"`
|
||||
WebAuthn WebAuthnConfig `mapstructure:"webauthn"`
|
||||
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
|
||||
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
|
||||
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
|
||||
@@ -686,6 +687,16 @@ type CORSConfig struct {
|
||||
AllowCredentials bool `mapstructure:"allow_credentials"`
|
||||
}
|
||||
|
||||
// WebAuthnConfig configures this deployment as a WebAuthn relying party.
|
||||
// RPID and RPOrigins are security boundaries and must never be inferred from
|
||||
// untrusted request Host or Origin headers.
|
||||
type WebAuthnConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
RPDisplayName string `mapstructure:"rp_display_name"`
|
||||
RPID string `mapstructure:"rp_id"`
|
||||
RPOrigins []string `mapstructure:"rp_origins"`
|
||||
}
|
||||
|
||||
const MaxForwardedClientIPHeaders = 16
|
||||
|
||||
type ForwardedClientIPSettings struct {
|
||||
@@ -1024,6 +1035,8 @@ type GatewayOpenAIHTTP2Config struct {
|
||||
// GatewayOpenAIProxyStreamCircuitConfig controls the bounded, in-process
|
||||
// proxy-ID circuit used for incomplete OpenAI Responses SSE streams.
|
||||
type GatewayOpenAIProxyStreamCircuitConfig struct {
|
||||
// Disabled: 完全关闭代理断流熔断(默认开启)。
|
||||
Disabled bool `mapstructure:"disabled"`
|
||||
// FailureThreshold: 统计窗口内多少次断流后隔离代理。
|
||||
FailureThreshold int `mapstructure:"failure_threshold"`
|
||||
// WindowSeconds: 断流统计窗口(秒)。
|
||||
@@ -1875,12 +1888,21 @@ func setDefaults() {
|
||||
viper.SetDefault("cors.allowed_origins", []string{})
|
||||
viper.SetDefault("cors.allow_credentials", true)
|
||||
|
||||
// WebAuthn / Passkeys are opt-in because every deployment must explicitly
|
||||
// declare its relying-party domain and trusted browser origins.
|
||||
viper.SetDefault("webauthn.enabled", false)
|
||||
viper.SetDefault("webauthn.rp_display_name", "Sub2API")
|
||||
viper.SetDefault("webauthn.rp_id", "")
|
||||
viper.SetDefault("webauthn.rp_origins", []string{})
|
||||
|
||||
// Security
|
||||
viper.SetDefault("security.url_allowlist.enabled", false)
|
||||
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
|
||||
"api.openai.com",
|
||||
"api.anthropic.com",
|
||||
"api.kimi.com",
|
||||
"api.moonshot.ai",
|
||||
"api.moonshot.cn",
|
||||
"open.bigmodel.cn",
|
||||
"api.minimaxi.com",
|
||||
"generativelanguage.googleapis.com",
|
||||
@@ -2262,6 +2284,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.openai_http2.fallback_error_threshold", 2)
|
||||
viper.SetDefault("gateway.openai_http2.fallback_window_seconds", 60)
|
||||
viper.SetDefault("gateway.openai_http2.fallback_ttl_seconds", 600)
|
||||
viper.SetDefault("gateway.openai_proxy_stream_circuit.disabled", false)
|
||||
viper.SetDefault("gateway.openai_proxy_stream_circuit.failure_threshold", 2)
|
||||
viper.SetDefault("gateway.openai_proxy_stream_circuit.window_seconds", 60)
|
||||
viper.SetDefault("gateway.openai_proxy_stream_circuit.ttl_seconds", 600)
|
||||
@@ -2591,6 +2614,43 @@ func (c *Config) Validate() error {
|
||||
}
|
||||
warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL)
|
||||
}
|
||||
if c.WebAuthn.Enabled {
|
||||
c.WebAuthn.RPDisplayName = strings.TrimSpace(c.WebAuthn.RPDisplayName)
|
||||
c.WebAuthn.RPID = strings.ToLower(strings.TrimSpace(c.WebAuthn.RPID))
|
||||
c.WebAuthn.RPOrigins = normalizeStringSlice(c.WebAuthn.RPOrigins)
|
||||
if c.WebAuthn.RPDisplayName == "" {
|
||||
return fmt.Errorf("webauthn.rp_display_name is required when passkeys are enabled")
|
||||
}
|
||||
if c.WebAuthn.RPID == "" {
|
||||
return fmt.Errorf("webauthn.rp_id is required when passkeys are enabled")
|
||||
}
|
||||
if strings.Contains(c.WebAuthn.RPID, "://") || strings.ContainsAny(c.WebAuthn.RPID, "/:") {
|
||||
return fmt.Errorf("webauthn.rp_id must be a domain without scheme, port, or path")
|
||||
}
|
||||
if len(c.WebAuthn.RPOrigins) == 0 {
|
||||
return fmt.Errorf("webauthn.rp_origins must contain at least one origin when passkeys are enabled")
|
||||
}
|
||||
for i, origin := range c.WebAuthn.RPOrigins {
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("webauthn.rp_origins contains invalid origin %q", origin)
|
||||
}
|
||||
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return fmt.Errorf("webauthn.rp_origins entry %q must not include userinfo, path, query, or fragment", origin)
|
||||
}
|
||||
u.Scheme = strings.ToLower(u.Scheme)
|
||||
u.Host = strings.ToLower(u.Host)
|
||||
host := strings.ToLower(u.Hostname())
|
||||
localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1"
|
||||
if u.Scheme != "https" && (u.Scheme != "http" || !localDevelopment) {
|
||||
return fmt.Errorf("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)", origin)
|
||||
}
|
||||
if host != c.WebAuthn.RPID && !strings.HasSuffix(host, "."+c.WebAuthn.RPID) {
|
||||
return fmt.Errorf("webauthn.rp_origins entry %q is not within relying party ID %q", origin, c.WebAuthn.RPID)
|
||||
}
|
||||
c.WebAuthn.RPOrigins[i] = u.Scheme + "://" + u.Host
|
||||
}
|
||||
}
|
||||
if c.JWT.ExpireHour <= 0 {
|
||||
return fmt.Errorf("jwt.expire_hour must be positive")
|
||||
}
|
||||
|
||||
@@ -545,6 +545,7 @@ func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.True(t, cfg.Gateway.OpenAIHTTP2.Enabled)
|
||||
require.True(t, cfg.Gateway.OpenAIHTTP2.AllowProxyFallbackToHTTP1)
|
||||
require.False(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled)
|
||||
require.Equal(t, 2, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold)
|
||||
require.Equal(t, 60, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds)
|
||||
require.Equal(t, 600, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds)
|
||||
@@ -552,12 +553,14 @@ func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
|
||||
|
||||
func TestLoadOpenAIProxyStreamCircuitFromEnv(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_DISABLED", "true")
|
||||
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD", "3")
|
||||
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS", "90")
|
||||
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS", "420")
|
||||
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
require.True(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled)
|
||||
require.Equal(t, 3, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold)
|
||||
require.Equal(t, 90, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds)
|
||||
require.Equal(t, 420, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds)
|
||||
@@ -755,6 +758,21 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
|
||||
if !cfg.Security.ResponseHeaders.Enabled {
|
||||
t.Fatalf("ResponseHeaders.Enabled = false, want true")
|
||||
}
|
||||
|
||||
wantHosts := []string{
|
||||
"api.kimi.com",
|
||||
"api.moonshot.ai",
|
||||
"api.moonshot.cn",
|
||||
}
|
||||
hostSet := make(map[string]struct{}, len(cfg.Security.URLAllowlist.UpstreamHosts))
|
||||
for _, h := range cfg.Security.URLAllowlist.UpstreamHosts {
|
||||
hostSet[h] = struct{}{}
|
||||
}
|
||||
for _, want := range wantHosts {
|
||||
if _, ok := hostSet[want]; !ok {
|
||||
t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultServerMode(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateWebAuthnConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
configure func(*Config)
|
||||
wantError string
|
||||
}{
|
||||
{
|
||||
name: "valid production origin",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "sub2api.example.com",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid localhost development origin",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API Dev",
|
||||
RPID: "localhost",
|
||||
RPOrigins: []string{"http://localhost:5173"},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing relying party id",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
wantError: "webauthn.rp_id",
|
||||
},
|
||||
{
|
||||
name: "relying party id contains scheme",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "https://sub2api.example.com",
|
||||
RPOrigins: []string{"https://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
wantError: "domain without scheme",
|
||||
},
|
||||
{
|
||||
name: "non-local insecure origin",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "sub2api.example.com",
|
||||
RPOrigins: []string{"http://sub2api.example.com"},
|
||||
}
|
||||
},
|
||||
wantError: "must use HTTPS",
|
||||
},
|
||||
{
|
||||
name: "origin outside relying party id",
|
||||
configure: func(cfg *Config) {
|
||||
cfg.WebAuthn = WebAuthnConfig{
|
||||
Enabled: true,
|
||||
RPDisplayName: "Sub2API",
|
||||
RPID: "example.com",
|
||||
RPOrigins: []string{"https://example.net"},
|
||||
}
|
||||
},
|
||||
wantError: "not within relying party ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
|
||||
cfg, err := Load()
|
||||
require.NoError(t, err)
|
||||
tt.configure(cfg)
|
||||
|
||||
err = cfg.Validate()
|
||||
if tt.wantError == "" {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, tt.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -123,6 +123,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
if paymentCfg == nil {
|
||||
paymentCfg = &service.PaymentConfig{}
|
||||
}
|
||||
passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration()
|
||||
|
||||
payload := dto.SystemSettings{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
@@ -134,6 +135,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||
PasskeyEnabled: settings.PasskeyEnabled,
|
||||
PasskeyConfigured: passkeyConfigured,
|
||||
PasskeyRPID: passkeyRPID,
|
||||
PasskeyRPOrigins: passkeyRPOrigins,
|
||||
SessionBindingEnabled: settings.SessionBindingEnabled,
|
||||
StepUpEnabled: settings.StepUpEnabled,
|
||||
AuditLogRetentionDays: settings.AuditLogRetentionDays,
|
||||
@@ -346,6 +351,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: settings.ModelPlazaDescription,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
|
||||
@@ -56,6 +56,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.TotpEnabled != after.TotpEnabled {
|
||||
changed = append(changed, "totp_enabled")
|
||||
}
|
||||
if before.PasskeyEnabled != after.PasskeyEnabled {
|
||||
changed = append(changed, "passkey_enabled")
|
||||
}
|
||||
if before.SessionBindingEnabled != after.SessionBindingEnabled {
|
||||
changed = append(changed, "session_binding_enabled")
|
||||
}
|
||||
@@ -525,6 +528,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
|
||||
changed = append(changed, "available_channels_enabled")
|
||||
}
|
||||
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
|
||||
changed = append(changed, "model_plaza_enabled")
|
||||
}
|
||||
if before.ModelPlazaRequireAuth != after.ModelPlazaRequireAuth {
|
||||
changed = append(changed, "model_plaza_require_auth")
|
||||
}
|
||||
if before.ModelPlazaDescription != after.ModelPlazaDescription {
|
||||
changed = append(changed, "model_plaza_description")
|
||||
}
|
||||
if before.AffiliateEnabled != after.AffiliateEnabled {
|
||||
changed = append(changed, "affiliate_enabled")
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ type UpdateSettingsRequest struct {
|
||||
FrontendURL string `json:"frontend_url"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值)
|
||||
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
|
||||
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
|
||||
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
|
||||
@@ -312,6 +313,11 @@ type UpdateSettingsRequest struct {
|
||||
// Available Channels feature switch (user-facing)
|
||||
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature switches + description
|
||||
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription *string `json:"model_plaza_description"`
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||||
|
||||
@@ -463,6 +469,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
if req.StepUpEnabled != nil {
|
||||
stepUpEnabled = *req.StepUpEnabled
|
||||
}
|
||||
passkeyEnabled := previousSettings.PasskeyEnabled
|
||||
if req.PasskeyEnabled != nil {
|
||||
passkeyEnabled = *req.PasskeyEnabled
|
||||
}
|
||||
if passkeyEnabled {
|
||||
configured, _, _ := h.settingService.PasskeyConfiguration()
|
||||
if !configured {
|
||||
response.BadRequest(c, "Passkey sign-in requires a valid WebAuthn RP ID and allowed HTTPS origins in the deployment configuration")
|
||||
return
|
||||
}
|
||||
}
|
||||
forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...)
|
||||
if req.ForwardedClientIPHeaders != nil {
|
||||
forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...)
|
||||
@@ -1313,6 +1330,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
FrontendURL: req.FrontendURL,
|
||||
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||||
TotpEnabled: req.TotpEnabled,
|
||||
PasskeyEnabled: passkeyEnabled,
|
||||
SessionBindingEnabled: sessionBindingEnabled,
|
||||
StepUpEnabled: stepUpEnabled,
|
||||
AuditLogRetentionDays: req.AuditLogRetentionDays,
|
||||
@@ -1665,6 +1683,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.AvailableChannelsEnabled
|
||||
}(),
|
||||
ModelPlazaEnabled: func() bool {
|
||||
if req.ModelPlazaEnabled != nil {
|
||||
return *req.ModelPlazaEnabled
|
||||
}
|
||||
return previousSettings.ModelPlazaEnabled
|
||||
}(),
|
||||
ModelPlazaRequireAuth: func() bool {
|
||||
if req.ModelPlazaRequireAuth != nil {
|
||||
return *req.ModelPlazaRequireAuth
|
||||
}
|
||||
return previousSettings.ModelPlazaRequireAuth
|
||||
}(),
|
||||
ModelPlazaDescription: func() string {
|
||||
if req.ModelPlazaDescription != nil {
|
||||
return *req.ModelPlazaDescription
|
||||
}
|
||||
return previousSettings.ModelPlazaDescription
|
||||
}(),
|
||||
AffiliateEnabled: func() bool {
|
||||
if req.AffiliateEnabled != nil {
|
||||
return *req.AffiliateEnabled
|
||||
@@ -1836,6 +1872,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
if updatedPaymentCfg == nil {
|
||||
updatedPaymentCfg = &service.PaymentConfig{}
|
||||
}
|
||||
passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration()
|
||||
|
||||
payload := dto.SystemSettings{
|
||||
RegistrationEnabled: updatedSettings.RegistrationEnabled,
|
||||
@@ -1847,6 +1884,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
||||
TotpEnabled: updatedSettings.TotpEnabled,
|
||||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||||
PasskeyEnabled: updatedSettings.PasskeyEnabled,
|
||||
PasskeyConfigured: passkeyConfigured,
|
||||
PasskeyRPID: passkeyRPID,
|
||||
PasskeyRPOrigins: passkeyRPOrigins,
|
||||
SessionBindingEnabled: updatedSettings.SessionBindingEnabled,
|
||||
StepUpEnabled: updatedSettings.StepUpEnabled,
|
||||
AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays,
|
||||
@@ -2055,6 +2096,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||||
|
||||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||||
|
||||
RiskControlEnabled: updatedSettings.RiskControlEnabled,
|
||||
|
||||
@@ -98,16 +98,20 @@ func ensureLoginUserActive(user *service.User) error {
|
||||
// respondWithTokenPair 生成 Token 对并返回认证响应
|
||||
// 如果 Token 对生成失败,回退到只返回 Access Token(向后兼容)
|
||||
func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
|
||||
respondWithTokenPair(c, h.authService, user)
|
||||
}
|
||||
|
||||
func respondWithTokenPair(c *gin.Context, authService *service.AuthService, user *service.User) {
|
||||
if err := ensureLoginUserActive(user); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "")
|
||||
tokenPair, err := authService.GenerateTokenPair(c.Request.Context(), user, "")
|
||||
if err != nil {
|
||||
slog.Error("failed to generate token pair", "error", err, "user_id", user.ID)
|
||||
// 回退到只返回Access Token
|
||||
token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user)
|
||||
token, tokenErr := authService.GenerateToken(c.Request.Context(), user)
|
||||
if tokenErr != nil {
|
||||
response.InternalError(c, "Failed to generate token")
|
||||
return
|
||||
|
||||
@@ -3015,7 +3015,7 @@ func (r *oauthPendingFlowUserRepo) GetFirstAdmin(context.Context) (*service.User
|
||||
panic("unexpected GetFirstAdmin call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User) error {
|
||||
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User, fields service.UserUpdateFields) error {
|
||||
entity, err := r.client.User.UpdateOneID(user.ID).
|
||||
SetEmail(user.Email).
|
||||
SetUsername(user.Username).
|
||||
@@ -3168,6 +3168,14 @@ func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64
|
||||
panic("unexpected DeductBalance call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
|
||||
panic("unexpected AdjustBalance call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
|
||||
panic("unexpected SetBalance call")
|
||||
}
|
||||
|
||||
func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int) error {
|
||||
panic("unexpected UpdateConcurrency call")
|
||||
}
|
||||
|
||||
@@ -47,7 +47,11 @@ func TestAuthHandlerRevokeAllSessionsInvalidatesAccessTokens(t *testing.T) {
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, []int64{29}, refreshTokenCache.revokedUserIDs)
|
||||
require.Equal(t, int64(8), repo.user.TokenVersion)
|
||||
// users 表没有 token_version 列(见 resolvedTokenVersion:JWT 里的值由
|
||||
// email+password_hash 指纹推导),所以自增 TokenVersion 只停留在内存里。
|
||||
// 此前紧跟其后的整行 Update 不写任何有效数据,却会用旧快照覆盖并发写入的列,
|
||||
// 已移除。会话撤销由上面的 refresh session 清理承担。
|
||||
require.Equal(t, int64(7), repo.user.TokenVersion)
|
||||
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
|
||||
@@ -12,12 +12,11 @@ import (
|
||||
|
||||
// AvailableChannelHandler 处理用户侧「可用渠道」查询。
|
||||
//
|
||||
// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做三层过滤:
|
||||
// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做四层过滤:
|
||||
// 1. 行过滤:只保留状态为 Active 且与当前用户可访问分组有交集的渠道;
|
||||
// 2. 分组过滤:渠道的 Groups 只保留用户可访问的那些;
|
||||
// 3. 平台过滤:渠道的 SupportedModels 只保留平台在用户可见 Groups 中出现过的模型,
|
||||
// 防止"渠道同时挂在 antigravity / anthropic 两个平台的分组上,用户只访问
|
||||
// antigravity,却看到 anthropic 模型"这类跨平台信息泄漏;
|
||||
// 3. 平台过滤:普通分组只保留自身平台模型;Composite 分组按渠道已配置的具体模型平台
|
||||
// 展开。这样既防止普通分组跨平台泄漏,也让 Composite 正确展示其多平台能力;
|
||||
// 4. 字段白名单:仅返回用户需要的字段(省略 BillingModelSource / RestrictModels
|
||||
// / 内部 ID / Status 等管理字段)。
|
||||
type AvailableChannelHandler struct {
|
||||
@@ -172,19 +171,47 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
|
||||
}
|
||||
|
||||
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
|
||||
// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。
|
||||
// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。
|
||||
//
|
||||
// Composite 分组可访问渠道中所有已配置的具体平台,因此会被展开到每个有支持模型的
|
||||
// 平台 section。普通分组仍严格留在自身平台,避免跨平台模型信息泄漏。Composite 渠道
|
||||
// 尚未配置任何模型时保留 composite section,以便前端继续展示该分组和“未配置模型”状态。
|
||||
// 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
|
||||
func buildPlatformSections(
|
||||
ch service.AvailableChannel,
|
||||
visibleGroups []userAvailableGroup,
|
||||
) []userChannelPlatformSection {
|
||||
groupsByPlatform := make(map[string][]userAvailableGroup, 4)
|
||||
compositeGroups := make([]userAvailableGroup, 0, 1)
|
||||
for _, g := range visibleGroups {
|
||||
if g.Platform == "" {
|
||||
continue
|
||||
}
|
||||
if g.Platform == service.PlatformComposite {
|
||||
compositeGroups = append(compositeGroups, g)
|
||||
continue
|
||||
}
|
||||
groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], g)
|
||||
}
|
||||
|
||||
if len(compositeGroups) > 0 {
|
||||
modelPlatforms := make(map[string]struct{}, len(ch.SupportedModels))
|
||||
for i := range ch.SupportedModels {
|
||||
if platform := ch.SupportedModels[i].Platform; platform != "" {
|
||||
modelPlatforms[platform] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(modelPlatforms) == 0 {
|
||||
groupsByPlatform[service.PlatformComposite] = append(
|
||||
groupsByPlatform[service.PlatformComposite],
|
||||
compositeGroups...,
|
||||
)
|
||||
} else {
|
||||
for platform := range modelPlatforms {
|
||||
groupsByPlatform[platform] = append(groupsByPlatform[platform], compositeGroups...)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(groupsByPlatform) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -155,3 +155,101 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
|
||||
require.Len(t, sections[0].SupportedModels, 1)
|
||||
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
|
||||
}
|
||||
|
||||
func TestBuildPlatformSections_CompositeGroupExpandsAcrossConfiguredModelPlatforms(t *testing.T) {
|
||||
anthropicPrice := 3e-6
|
||||
openAIPrice := 2.5e-6
|
||||
ch := service.AvailableChannel{
|
||||
Name: "composite-channel",
|
||||
SupportedModels: []service.SupportedModel{
|
||||
{
|
||||
Name: "claude-sonnet-4-6",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Pricing: &service.ChannelModelPricing{InputPrice: &anthropicPrice},
|
||||
},
|
||||
{
|
||||
Name: "gpt-5",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Pricing: &service.ChannelModelPricing{InputPrice: &openAIPrice},
|
||||
},
|
||||
},
|
||||
}
|
||||
visible := []userAvailableGroup{
|
||||
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
|
||||
}
|
||||
|
||||
sections := buildPlatformSections(ch, visible)
|
||||
|
||||
require.Len(t, sections, 2)
|
||||
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
|
||||
require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
|
||||
for _, section := range sections {
|
||||
require.Len(t, section.Groups, 1)
|
||||
require.Equal(t, int64(9), section.Groups[0].ID)
|
||||
require.Equal(t, service.PlatformComposite, section.Groups[0].Platform)
|
||||
require.Len(t, section.SupportedModels, 1)
|
||||
require.Equal(t, section.Platform, section.SupportedModels[0].Platform)
|
||||
require.NotNil(t, section.SupportedModels[0].Pricing)
|
||||
}
|
||||
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
|
||||
require.Equal(t, "gpt-5", sections[1].SupportedModels[0].Name)
|
||||
}
|
||||
|
||||
func TestBuildPlatformSections_OrdinaryGroupRemainsPlatformIsolated(t *testing.T) {
|
||||
ch := service.AvailableChannel{
|
||||
SupportedModels: []service.SupportedModel{
|
||||
{Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
|
||||
{Name: "gpt-5", Platform: service.PlatformOpenAI},
|
||||
},
|
||||
}
|
||||
visible := []userAvailableGroup{
|
||||
{ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
|
||||
}
|
||||
|
||||
sections := buildPlatformSections(ch, visible)
|
||||
|
||||
require.Len(t, sections, 1)
|
||||
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
|
||||
require.Len(t, sections[0].SupportedModels, 1)
|
||||
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
|
||||
}
|
||||
|
||||
func TestBuildPlatformSections_CompositeAndOrdinaryGroupsShareConcreteSection(t *testing.T) {
|
||||
ch := service.AvailableChannel{
|
||||
SupportedModels: []service.SupportedModel{
|
||||
{Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
|
||||
{Name: "gpt-5", Platform: service.PlatformOpenAI},
|
||||
},
|
||||
}
|
||||
visible := []userAvailableGroup{
|
||||
{ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
|
||||
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
|
||||
}
|
||||
|
||||
sections := buildPlatformSections(ch, visible)
|
||||
|
||||
require.Len(t, sections, 2)
|
||||
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
|
||||
require.Equal(t, []int64{1, 9}, []int64{
|
||||
sections[0].Groups[0].ID,
|
||||
sections[0].Groups[1].ID,
|
||||
})
|
||||
require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
|
||||
require.Len(t, sections[1].Groups, 1)
|
||||
require.Equal(t, int64(9), sections[1].Groups[0].ID)
|
||||
}
|
||||
|
||||
func TestBuildPlatformSections_CompositeWithoutModelsKeepsEmptyCompositeSection(t *testing.T) {
|
||||
visible := []userAvailableGroup{
|
||||
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
|
||||
}
|
||||
|
||||
sections := buildPlatformSections(service.AvailableChannel{
|
||||
SupportedModels: []service.SupportedModel{{Name: "invalid-without-platform"}},
|
||||
}, visible)
|
||||
|
||||
require.Len(t, sections, 1)
|
||||
require.Equal(t, service.PlatformComposite, sections[0].Platform)
|
||||
require.Len(t, sections[0].Groups, 1)
|
||||
require.Empty(t, sections[0].SupportedModels)
|
||||
}
|
||||
|
||||
@@ -36,9 +36,13 @@ type SystemSettings struct {
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
|
||||
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
|
||||
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
|
||||
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
|
||||
PasskeyEnabled bool `json:"passkey_enabled"`
|
||||
PasskeyConfigured bool `json:"passkey_configured"`
|
||||
PasskeyRPID string `json:"passkey_rp_id"`
|
||||
PasskeyRPOrigins []string `json:"passkey_rp_origins"`
|
||||
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
|
||||
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
|
||||
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
@@ -286,6 +290,11 @@ type SystemSettings struct {
|
||||
// Available Channels feature switch (user-facing aggregate view)
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
// Model Plaza feature (public group/model pricing showcase)
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
ModelPlazaDescription string `json:"model_plaza_description"`
|
||||
|
||||
// 风控中心功能开关
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
|
||||
@@ -320,6 +329,7 @@ type PublicSettings struct {
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||||
PasskeyEnabled bool `json:"passkey_enabled"`
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
@@ -369,6 +379,9 @@ type PublicSettings struct {
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
|
||||
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
|
||||
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
|
||||
@@ -2126,22 +2126,22 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
|
||||
|
||||
switch interceptType {
|
||||
case InterceptTypeSuggestionMode:
|
||||
msgID = "msg_mock_suggestion"
|
||||
msgID = generateRealisticMsgID()
|
||||
outputTokens = 1
|
||||
textDeltas = []string{""} // 空内容
|
||||
default: // InterceptTypeWarmup
|
||||
msgID = "msg_mock_warmup"
|
||||
msgID = generateRealisticMsgID()
|
||||
outputTokens = 2
|
||||
textDeltas = []string{"New", " Conversation"}
|
||||
}
|
||||
|
||||
// Build message_start event with fixed schema.
|
||||
messageStartJSON := `{"type":"message_start","message":{"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","model":` + strconv.Quote(model) + `,"content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}`
|
||||
// Build message_start event — field order matches real Anthropic API response.
|
||||
messageStartJSON := `{"type":"message_start","message":{"model":` + strconv.Quote(model) + `,"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}`
|
||||
|
||||
// Build events
|
||||
events := []string{
|
||||
`event: message_start` + "\n" + `data: ` + string(messageStartJSON),
|
||||
`event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`,
|
||||
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
|
||||
}
|
||||
|
||||
// Add text deltas
|
||||
@@ -2151,7 +2151,7 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
|
||||
}
|
||||
|
||||
// Add final events
|
||||
messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":` + strconv.Itoa(outputTokens) + `}}`
|
||||
messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":` + strconv.Itoa(outputTokens) + `}}`
|
||||
|
||||
events = append(events,
|
||||
`event: content_block_stop`+"\n"+`data: {"index":0,"type":"content_block_stop"}`,
|
||||
@@ -2166,20 +2166,20 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
|
||||
}
|
||||
}
|
||||
|
||||
// generateRealisticMsgID 生成仿真的消息 ID(msg_bdrk_XXXXXXX 格式)
|
||||
// 格式与 Claude API 真实响应一致,24 位随机字母数字
|
||||
// generateRealisticMsgID 生成仿真的消息 ID(msg_01XXXXXXX 格式)
|
||||
// 格式与 Anthropic API 官方响应一致:msg_01 + 22 位 Base62 随机字符
|
||||
func generateRealisticMsgID() string {
|
||||
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const idLen = 24
|
||||
const idLen = 22
|
||||
randomBytes := make([]byte, idLen)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return fmt.Sprintf("msg_bdrk_%d", time.Now().UnixNano())
|
||||
return fmt.Sprintf("msg_01%d", time.Now().UnixNano())
|
||||
}
|
||||
b := make([]byte, idLen)
|
||||
for i := range b {
|
||||
b[i] = charset[int(randomBytes[i])%len(charset)]
|
||||
}
|
||||
return "msg_bdrk_" + string(b)
|
||||
return "msg_01" + string(b)
|
||||
}
|
||||
|
||||
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
|
||||
@@ -2189,7 +2189,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
|
||||
|
||||
switch interceptType {
|
||||
case InterceptTypeSuggestionMode:
|
||||
msgID = "msg_mock_suggestion"
|
||||
msgID = generateRealisticMsgID()
|
||||
text = ""
|
||||
outputTokens = 1
|
||||
stopReason = "end_turn"
|
||||
@@ -2199,13 +2199,13 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
|
||||
outputTokens = 1
|
||||
stopReason = "max_tokens" // max_tokens=1 探测请求的 stop_reason 应为 max_tokens
|
||||
default: // InterceptTypeWarmup
|
||||
msgID = "msg_mock_warmup"
|
||||
msgID = generateRealisticMsgID()
|
||||
text = "New Conversation"
|
||||
outputTokens = 2
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
|
||||
// 构建完整的响应格式(与 Claude API 响应格式一致)
|
||||
// 构建完整的响应格式(与 Anthropic API 官方响应格式一致)
|
||||
response := gin.H{
|
||||
"model": model,
|
||||
"id": msgID,
|
||||
@@ -2214,6 +2214,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
|
||||
"content": []gin.H{{"type": "text", "text": text}},
|
||||
"stop_reason": stopReason,
|
||||
"stop_sequence": nil,
|
||||
"stop_details": nil,
|
||||
"usage": gin.H{
|
||||
"input_tokens": 10,
|
||||
"cache_creation_input_tokens": 0,
|
||||
@@ -2223,7 +2224,6 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
|
||||
"ephemeral_1h_input_tokens": 0,
|
||||
},
|
||||
"output_tokens": outputTokens,
|
||||
"total_tokens": 10 + outputTokens,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestSendMockInterceptResponse_MaxTokensOneHaiku(t *testing.T) {
|
||||
|
||||
id, ok := response["id"].(string)
|
||||
require.True(t, ok)
|
||||
require.True(t, strings.HasPrefix(id, "msg_bdrk_"))
|
||||
require.True(t, strings.HasPrefix(id, "msg_01"))
|
||||
|
||||
content, ok := response["content"].([]any)
|
||||
require.True(t, ok)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -289,7 +290,7 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_MixedScheduli
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, "msg_mock_warmup", resp["id"])
|
||||
require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
|
||||
require.Equal(t, "claude-sonnet-4-5", resp["model"])
|
||||
|
||||
content, ok := resp["content"].([]any)
|
||||
@@ -378,6 +379,6 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_ForcePlatform
|
||||
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, "msg_mock_warmup", resp["id"])
|
||||
require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
|
||||
require.Equal(t, "claude-sonnet-4-5", resp["model"])
|
||||
}
|
||||
|
||||
@@ -98,6 +98,12 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
googleError(c, http.StatusBadRequest, "Missing model in URL")
|
||||
return
|
||||
}
|
||||
// 模型名会被拼进上游 URL 的 path,先在入口校验片段合规性,
|
||||
// 见 service/upstream_path_guard.go。
|
||||
if !service.IsSafeGeminiModelPathSegment(modelName) {
|
||||
googleError(c, http.StatusBadRequest, "Invalid model in URL")
|
||||
return
|
||||
}
|
||||
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
|
||||
modelName = strings.TrimSpace(resolvedModel)
|
||||
}
|
||||
@@ -169,6 +175,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
googleError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
// URL 里的模型名最终会被拼进上游 /v1beta/models/{model}:{action},
|
||||
// 先在入口校验片段合规性,见 service/upstream_path_guard.go。
|
||||
if !service.IsSafeGeminiModelPathSegment(modelName) {
|
||||
googleError(c, http.StatusBadRequest, "Invalid model in URL")
|
||||
return
|
||||
}
|
||||
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
|
||||
modelName = strings.TrimSpace(resolvedModel)
|
||||
}
|
||||
|
||||
@@ -58,9 +58,11 @@ type Handlers struct {
|
||||
OpenAIGateway *OpenAIGatewayHandler
|
||||
Setting *SettingHandler
|
||||
Totp *TotpHandler
|
||||
Passkey *PasskeyHandler
|
||||
Payment *PaymentHandler
|
||||
PaymentWebhook *PaymentWebhookHandler
|
||||
AvailableChannel *AvailableChannelHandler
|
||||
ModelPlaza *ModelPlazaHandler
|
||||
AsyncImage *AsyncImageHandler
|
||||
BatchImage *BatchImageHandler
|
||||
}
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ModelPlazaHandler 处理「模型广场」查询。
|
||||
//
|
||||
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
|
||||
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
|
||||
// - 匿名:仅非专属分组(订阅型照常展示);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
|
||||
type ModelPlazaHandler struct {
|
||||
channelService *service.ChannelService
|
||||
apiKeyService *service.APIKeyService
|
||||
settingService *service.SettingService
|
||||
}
|
||||
|
||||
// NewModelPlazaHandler 创建模型广场 handler。
|
||||
func NewModelPlazaHandler(
|
||||
channelService *service.ChannelService,
|
||||
apiKeyService *service.APIKeyService,
|
||||
settingService *service.SettingService,
|
||||
) *ModelPlazaHandler {
|
||||
return &ModelPlazaHandler{
|
||||
channelService: channelService,
|
||||
apiKeyService: apiKeyService,
|
||||
settingService: settingService,
|
||||
}
|
||||
}
|
||||
|
||||
// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
|
||||
type modelPlazaOfficialPricing struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
}
|
||||
|
||||
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
|
||||
type modelPlazaModel struct {
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Pricing *userSupportedModelPricing `json:"pricing"`
|
||||
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
|
||||
}
|
||||
|
||||
// modelPlazaGroup 广场分组条目(白名单字段)。
|
||||
type modelPlazaGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
Models []modelPlazaModel `json:"models"`
|
||||
}
|
||||
|
||||
// modelPlazaResponse 广场页响应。
|
||||
type modelPlazaResponse struct {
|
||||
Description string `json:"description"`
|
||||
Groups []modelPlazaGroup `json:"groups"`
|
||||
}
|
||||
|
||||
// Get 返回模型广场数据。
|
||||
// GET /api/v1/model-plaza
|
||||
func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
if h.settingService == nil {
|
||||
response.NotFound(c, "Model plaza is not enabled")
|
||||
return
|
||||
}
|
||||
rt := h.settingService.GetModelPlazaRuntime(c.Request.Context())
|
||||
if !rt.Enabled {
|
||||
response.NotFound(c, "Model plaza is not enabled")
|
||||
return
|
||||
}
|
||||
|
||||
subject, authed := middleware.GetAuthSubjectFromContext(c)
|
||||
if rt.RequireAuth && !authed {
|
||||
response.Unauthorized(c, "Authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
|
||||
var allowedExclusive map[int64]struct{}
|
||||
var userRates map[int64]float64
|
||||
if authed {
|
||||
allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
// 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
userRates, err = h.apiKeyService.GetUserGroupRates(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
// 专属倍率仅是展示增强,失败降级为分组默认倍率。
|
||||
slog.Warn("model_plaza_user_rates_failed", "error", err, "user_id", subject.UserID)
|
||||
userRates = nil
|
||||
}
|
||||
}
|
||||
|
||||
visible := filterPlazaVisibleGroups(groups, allowedExclusive)
|
||||
|
||||
out := make([]modelPlazaGroup, 0, len(visible))
|
||||
for i := range visible {
|
||||
out = append(out, toModelPlazaGroupDTO(&visible[i], userRates))
|
||||
}
|
||||
response.Success(c, modelPlazaResponse{
|
||||
Description: rt.Description,
|
||||
Groups: out,
|
||||
})
|
||||
}
|
||||
|
||||
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
|
||||
// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
|
||||
func filterPlazaVisibleGroups(
|
||||
groups []service.PlazaGroup,
|
||||
allowedExclusive map[int64]struct{},
|
||||
) []service.PlazaGroup {
|
||||
visible := make([]service.PlazaGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
if g.IsExclusive {
|
||||
if allowedExclusive == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowedExclusive[g.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
visible = append(visible, g)
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
// toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。
|
||||
func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup {
|
||||
models := make([]modelPlazaModel, 0, len(g.Models))
|
||||
for i := range g.Models {
|
||||
m := &g.Models[i]
|
||||
models = append(models, modelPlazaModel{
|
||||
Name: m.Name,
|
||||
Platform: m.Platform,
|
||||
Pricing: toUserPricing(m.Pricing),
|
||||
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
|
||||
})
|
||||
}
|
||||
dto := modelPlazaGroup{
|
||||
ID: g.ID,
|
||||
Name: g.Name,
|
||||
Description: g.Description,
|
||||
Platform: g.Platform,
|
||||
SubscriptionType: g.SubscriptionType,
|
||||
RateMultiplier: g.RateMultiplier,
|
||||
PeakRateEnabled: g.PeakRateEnabled,
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
IsExclusive: g.IsExclusive,
|
||||
Models: models,
|
||||
}
|
||||
if rate, ok := userRates[g.ID]; ok {
|
||||
dto.UserRateMultiplier = &rate
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
|
||||
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
return &modelPlazaOfficialPricing{
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func plazaGroups() []service.PlazaGroup {
|
||||
return []service.PlazaGroup{
|
||||
{ID: 1, Name: "public-standard", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1},
|
||||
{ID: 2, Name: "exclusive-a", Platform: "anthropic", IsExclusive: true, RateMultiplier: 0.5},
|
||||
{ID: 3, Name: "public-subscription", Platform: "openai", SubscriptionType: "subscription", RateMultiplier: 1},
|
||||
{ID: 4, Name: "exclusive-b", Platform: "openai", IsExclusive: true, RateMultiplier: 0.8},
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
|
||||
// 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), nil)
|
||||
require.Len(t, visible, 2)
|
||||
ids := []int64{visible[0].ID, visible[1].ID}
|
||||
require.ElementsMatch(t, []int64{1, 3}, ids)
|
||||
}
|
||||
|
||||
func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
|
||||
// 登录:非专属 + 授权的专属;未授权的专属仍不可见。
|
||||
allowed := map[int64]struct{}{2: {}}
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), allowed)
|
||||
require.Len(t, visible, 3)
|
||||
ids := make([]int64, 0, len(visible))
|
||||
for _, g := range visible {
|
||||
ids = append(ids, g.ID)
|
||||
}
|
||||
require.ElementsMatch(t, []int64{1, 2, 3}, ids)
|
||||
}
|
||||
|
||||
func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
|
||||
// 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
|
||||
// 但语义区分要保持——空集合不能被当作 nil 匿名分支。
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{})
|
||||
require.Len(t, visible, 2)
|
||||
}
|
||||
|
||||
func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := &ModelPlazaHandler{} // settingService == nil → fail-closed
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
|
||||
|
||||
h.Get(c)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, w.Code)
|
||||
}
|
||||
|
||||
func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
|
||||
g := service.PlazaGroup{
|
||||
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
|
||||
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
|
||||
Models: []service.PlazaModel{{
|
||||
Name: "claude-sonnet",
|
||||
Platform: "anthropic",
|
||||
Pricing: &service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
InputPrice: testPtr(3e-6),
|
||||
},
|
||||
OfficialPricing: &service.PlazaOfficialPricing{
|
||||
InputPrice: testPtr(3e-6),
|
||||
CacheReadPrice: testPtr(3e-7),
|
||||
},
|
||||
}},
|
||||
}
|
||||
|
||||
// 有专属倍率:user_rate_multiplier 序列化输出
|
||||
dto := toModelPlazaGroupDTO(&g, map[int64]float64{2: 0.5})
|
||||
raw, err := json.Marshal(dto)
|
||||
require.NoError(t, err)
|
||||
var decoded map[string]any
|
||||
require.NoError(t, json.Unmarshal(raw, &decoded))
|
||||
|
||||
for _, key := range []string{
|
||||
"id", "name", "description", "platform", "subscription_type",
|
||||
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
|
||||
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
|
||||
} {
|
||||
_, exists := decoded[key]
|
||||
require.Truef(t, exists, "plaza group DTO must expose %q", key)
|
||||
}
|
||||
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
|
||||
|
||||
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
|
||||
models := decoded["models"].([]any)
|
||||
require.Len(t, models, 1)
|
||||
model := models[0].(map[string]any)
|
||||
require.Contains(t, model, "pricing")
|
||||
require.Contains(t, model, "official_pricing")
|
||||
official := model["official_pricing"].(map[string]any)
|
||||
require.Contains(t, official, "input_price")
|
||||
require.Contains(t, official, "cache_read_price")
|
||||
_, has1h := official["cache_write_1h_price"]
|
||||
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
|
||||
|
||||
// 无专属倍率:user_rate_multiplier 整个字段省略
|
||||
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
|
||||
rawNoRate, err := json.Marshal(dtoNoRate)
|
||||
require.NoError(t, err)
|
||||
var decodedNoRate map[string]any
|
||||
require.NoError(t, json.Unmarshal(rawNoRate, &decodedNoRate))
|
||||
_, hasRate := decodedNoRate["user_rate_multiplier"]
|
||||
require.False(t, hasRate, "无专属倍率时 user_rate_multiplier 应 omitempty")
|
||||
}
|
||||
|
||||
func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
|
||||
require.Nil(t, toModelPlazaOfficialPricing(nil))
|
||||
}
|
||||
|
||||
func testPtr(v float64) *float64 { return &v }
|
||||
@@ -15,9 +15,9 @@ import (
|
||||
// Codex CLI and the Codex desktop app refresh their model picker from
|
||||
// GET {base_url}/models?client_version=... (custom provider mode) or
|
||||
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
|
||||
// here. The manifest is proxied verbatim from the selected account's ChatGPT
|
||||
// backend or custom API key upstream. API key manifests use a short-lived,
|
||||
// asynchronously revalidated cache to tolerate canceled client requests.
|
||||
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
|
||||
// provider-compatibility normalization and use a short-lived, asynchronously
|
||||
// revalidated cache to tolerate canceled client requests.
|
||||
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
|
||||
@@ -1407,7 +1407,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
|
||||
require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel)
|
||||
require.NotNil(t, got.logs[1].ModelMappingChain)
|
||||
require.Equal(t, "terra→terra-channel→gpt-5.6-terra", *got.logs[1].ModelMappingChain)
|
||||
require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12,
|
||||
require.InDelta(t, got.logs[1].TotalCost*2.5, got.logs[0].TotalCost, 1e-12,
|
||||
"each turn must be billed with its own channel-mapped model")
|
||||
}
|
||||
|
||||
@@ -1477,7 +1477,7 @@ func TestOpenAIResponsesWebSocket_PassthroughKeepsTurnMappingSnapshot(t *testing
|
||||
require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel)
|
||||
require.NotNil(t, got.logs[1].ModelMappingChain)
|
||||
require.Equal(t, "sol→gpt-5.6-terra", *got.logs[1].ModelMappingChain)
|
||||
require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12,
|
||||
require.InDelta(t, got.logs[1].TotalCost*2.5, got.logs[0].TotalCost, 1e-12,
|
||||
"the next turn must use the updated channel mapping")
|
||||
}
|
||||
|
||||
@@ -1506,7 +1506,7 @@ func TestOpenAIResponsesWebSocket_CtxPoolAppliesPerTurnMappingAndPreservesReques
|
||||
require.Equal(t, "gpt-5.6-terra", got.logs[1].RequestedModel)
|
||||
require.NotNil(t, got.logs[1].ModelMappingChain)
|
||||
require.Equal(t, "gpt-5.6-terra→gpt-5.6-sol", *got.logs[1].ModelMappingChain)
|
||||
require.InDelta(t, 20e-6, got.logs[1].TotalCost, 1e-12,
|
||||
require.InDelta(t, 16e-6, got.logs[1].TotalCost, 1e-12,
|
||||
"BillingModelSourceRequested must use the client model before channel mapping")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"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 PasskeyHandler struct {
|
||||
passkeys *service.PasskeyService
|
||||
authService *service.AuthService
|
||||
settingSvc *service.SettingService
|
||||
}
|
||||
|
||||
func NewPasskeyHandler(
|
||||
passkeys *service.PasskeyService,
|
||||
authService *service.AuthService,
|
||||
settingService *service.SettingService,
|
||||
) *PasskeyHandler {
|
||||
return &PasskeyHandler{
|
||||
passkeys: passkeys,
|
||||
authService: authService,
|
||||
settingSvc: settingService,
|
||||
}
|
||||
}
|
||||
|
||||
type passkeyOptionsResponse struct {
|
||||
SessionToken string `json:"session_token"`
|
||||
Options any `json:"options"`
|
||||
}
|
||||
|
||||
type passkeyFinishRequest struct {
|
||||
SessionToken string `json:"session_token" binding:"required"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Credential json.RawMessage `json:"credential" binding:"required"`
|
||||
}
|
||||
|
||||
type passkeyRenameRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
}
|
||||
|
||||
// passkeyPasswordRequest carries the account password that gates passkey
|
||||
// enrollment and revocation. Binding errors are tolerated: a missing or
|
||||
// malformed body yields an empty password, which the service rejects with a
|
||||
// precise PASSWORD_REQUIRED error.
|
||||
type passkeyPasswordRequest struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func bindPasskeyPassword(c *gin.Context) string {
|
||||
var req passkeyPasswordRequest
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
return req.Password
|
||||
}
|
||||
|
||||
const passkeyFinishBodyMaxBytes = 64 * 1024
|
||||
|
||||
// BeginLogin starts a usernameless, discoverable-credential login ceremony.
|
||||
func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: assertion})
|
||||
}
|
||||
|
||||
// FinishLogin validates a passkey assertion and creates a normal Sub2API token
|
||||
// session. User verification is mandatory, so a successful passkey assertion
|
||||
// already supplies phishing-resistant multi-factor authentication and does not
|
||||
// enter the separate TOTP challenge flow.
|
||||
func (h *PasskeyHandler) FinishLogin(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
req, ok := bindPasskeyFinishRequest(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
|
||||
user, err := h.passkeys.FinishLogin(c.Request.Context(), req.SessionToken, credentialRequest)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err = h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
middleware2.SetAuditActor(c, user.ID, user.Email)
|
||||
c.Set("auth_method", service.AuditAuthMethodPasskey)
|
||||
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
|
||||
respondWithTokenPair(c, h.authService, user)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) BeginRegistration(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID, bindPasskeyPassword(c))
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: creation})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) FinishRegistration(c *gin.Context) {
|
||||
if !h.requirePasskeysEnabled(c) {
|
||||
return
|
||||
}
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
req, valid := bindPasskeyFinishRequest(c)
|
||||
if !valid {
|
||||
return
|
||||
}
|
||||
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
|
||||
credential, err := h.passkeys.FinishRegistration(
|
||||
c.Request.Context(),
|
||||
subject.UserID,
|
||||
req.SessionToken,
|
||||
req.Name,
|
||||
credentialRequest,
|
||||
)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, credential)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) List(c *gin.Context) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return
|
||||
}
|
||||
credentials, err := h.passkeys.List(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, credentials)
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) Rename(c *gin.Context) {
|
||||
subject, credentialID, ok := passkeyMutationTarget(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req passkeyRenameRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
|
||||
response.BadRequest(c, "Passkey name is required")
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.Rename(c.Request.Context(), subject.UserID, credentialID, req.Name); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) Delete(c *gin.Context) {
|
||||
subject, credentialID, ok := passkeyMutationTarget(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID, bindPasskeyPassword(c)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) requirePasskeysEnabled(c *gin.Context) bool {
|
||||
if h.settingSvc == nil {
|
||||
response.ErrorFrom(c, service.ErrPasskeysDisabled)
|
||||
return false
|
||||
}
|
||||
enabled, err := h.settingSvc.PasskeyEnabled(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return false
|
||||
}
|
||||
if !enabled {
|
||||
response.ErrorFrom(c, service.ErrPasskeysDisabled)
|
||||
}
|
||||
return enabled
|
||||
}
|
||||
|
||||
func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error {
|
||||
if err := ensureLoginUserActive(user); err != nil {
|
||||
return err
|
||||
}
|
||||
if h.settingSvc == nil || !h.settingSvc.IsBackendModeEnabled(ctx) || user.IsAdmin() {
|
||||
return nil
|
||||
}
|
||||
return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.")
|
||||
}
|
||||
|
||||
func bindPasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, bool) {
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, passkeyFinishBodyMaxBytes)
|
||||
var req passkeyFinishRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
|
||||
response.BadRequest(c, "Invalid passkey response")
|
||||
return nil, false
|
||||
}
|
||||
return &req, true
|
||||
}
|
||||
|
||||
func cloneRequestWithJSON(original *http.Request, payload []byte) *http.Request {
|
||||
request := original.Clone(original.Context())
|
||||
request.Body = io.NopCloser(bytes.NewReader(payload))
|
||||
request.ContentLength = int64(len(payload))
|
||||
request.Header = original.Header.Clone()
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
return request
|
||||
}
|
||||
|
||||
func passkeyMutationTarget(c *gin.Context) (middleware2.AuthSubject, int64, bool) {
|
||||
subject, ok := middleware2.GetAuthSubjectFromContext(c)
|
||||
if !ok {
|
||||
response.Unauthorized(c, "User not authenticated")
|
||||
return middleware2.AuthSubject{}, 0, false
|
||||
}
|
||||
credentialID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || credentialID <= 0 {
|
||||
response.BadRequest(c, "Invalid passkey ID")
|
||||
return middleware2.AuthSubject{}, 0, false
|
||||
}
|
||||
return subject, credentialID, true
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type passkeySwitchSettingRepo struct {
|
||||
value string
|
||||
err error
|
||||
}
|
||||
|
||||
func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) {
|
||||
return nil, service.ErrSettingNotFound
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) {
|
||||
return r.value, r.err
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil }
|
||||
func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error {
|
||||
return nil
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) {
|
||||
return map[string]string{}, nil
|
||||
}
|
||||
func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil }
|
||||
|
||||
func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
context, _ := gin.CreateTestContext(recorder)
|
||||
context.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/passkey/login/finish",
|
||||
strings.NewReader(`{"credential":"`+strings.Repeat("x", passkeyFinishBodyMaxBytes)+`"}`),
|
||||
)
|
||||
context.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
_, ok := bindPasskeyFinishRequest(context)
|
||||
require.False(t, ok)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &passkeySwitchSettingRepo{value: "false"}
|
||||
settings := service.NewSettingService(repo, &config.Config{
|
||||
WebAuthn: config.WebAuthnConfig{Enabled: true},
|
||||
})
|
||||
handler := NewPasskeyHandler(nil, nil, settings)
|
||||
recorder := httptest.NewRecorder()
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
|
||||
|
||||
handler.BeginLogin(ginContext)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED")
|
||||
}
|
||||
|
||||
func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
settings := service.NewSettingService(
|
||||
&passkeySwitchSettingRepo{err: errors.New("database unavailable")},
|
||||
&config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}},
|
||||
)
|
||||
handler := NewPasskeyHandler(nil, nil, settings)
|
||||
recorder := httptest.NewRecorder()
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
|
||||
|
||||
handler.BeginLogin(ginContext)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, recorder.Code)
|
||||
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
|
||||
}
|
||||
|
||||
func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
handler := NewPasskeyHandler(nil, nil, nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
ginContext, _ := gin.CreateTestContext(recorder)
|
||||
ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil)
|
||||
|
||||
handler.List(ginContext)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, recorder.Code)
|
||||
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
|
||||
}
|
||||
@@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
PasskeyEnabled: settings.PasskeyEnabled,
|
||||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: settings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||
@@ -98,6 +99,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
|
||||
ModelPlazaEnabled: settings.ModelPlazaEnabled,
|
||||
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
|
||||
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
|
||||
RiskControlEnabled: settings.RiskControlEnabled,
|
||||
|
||||
@@ -41,7 +41,7 @@ func (s *userHandlerRepoStub) GetFirstAdmin(context.Context) (*service.User, err
|
||||
cloned := *s.user
|
||||
return &cloned, nil
|
||||
}
|
||||
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User) error {
|
||||
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User, _ service.UserUpdateFields) error {
|
||||
cloned := *user
|
||||
s.user = &cloned
|
||||
return nil
|
||||
@@ -93,6 +93,14 @@ func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) err
|
||||
func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (s *userHandlerRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
|
||||
panic("unexpected AdjustBalance call")
|
||||
}
|
||||
|
||||
func (s *userHandlerRepoStub) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
|
||||
panic("unexpected SetBalance call")
|
||||
}
|
||||
func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -656,7 +664,11 @@ func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigure
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs)
|
||||
require.Equal(t, int64(5), repo.user.TokenVersion)
|
||||
// 撤销依赖的是 refresh session 清理,而不是 token_version:users 表没有这一列
|
||||
// (见 resolvedTokenVersion,实际值由 email+password_hash 指纹推导),
|
||||
// 所以此前"自增 TokenVersion 再整行写回"不持久化任何东西,
|
||||
// 却会用旧快照覆盖并发写入的列。这里断言用户行未被改写。
|
||||
require.Equal(t, int64(4), repo.user.TokenVersion)
|
||||
}
|
||||
|
||||
func TestUserHandlerUnbindIdentityDoesNotRevokeSessionsWhenNothingWasUnbound(t *testing.T) {
|
||||
|
||||
@@ -179,9 +179,11 @@ func ProvideHandlers(
|
||||
openaiGatewayHandler *OpenAIGatewayHandler,
|
||||
settingHandler *SettingHandler,
|
||||
totpHandler *TotpHandler,
|
||||
passkeyHandler *PasskeyHandler,
|
||||
paymentHandler *PaymentHandler,
|
||||
paymentWebhookHandler *PaymentWebhookHandler,
|
||||
availableChannelHandler *AvailableChannelHandler,
|
||||
modelPlazaHandler *ModelPlazaHandler,
|
||||
asyncImageHandler *AsyncImageHandler,
|
||||
batchImageHandler *BatchImageHandler,
|
||||
_ *service.IdempotencyCoordinator,
|
||||
@@ -201,9 +203,11 @@ func ProvideHandlers(
|
||||
OpenAIGateway: openaiGatewayHandler,
|
||||
Setting: settingHandler,
|
||||
Totp: totpHandler,
|
||||
Passkey: passkeyHandler,
|
||||
Payment: paymentHandler,
|
||||
PaymentWebhook: paymentWebhookHandler,
|
||||
AvailableChannel: availableChannelHandler,
|
||||
ModelPlaza: modelPlazaHandler,
|
||||
AsyncImage: asyncImageHandler,
|
||||
BatchImage: batchImageHandler,
|
||||
}
|
||||
@@ -223,10 +227,12 @@ var ProviderSet = wire.NewSet(
|
||||
ProvideGatewayHandler,
|
||||
ProvideOpenAIGatewayHandler,
|
||||
NewTotpHandler,
|
||||
NewPasskeyHandler,
|
||||
ProvideSettingHandler,
|
||||
NewPaymentHandler,
|
||||
NewPaymentWebhookHandler,
|
||||
NewAvailableChannelHandler,
|
||||
NewModelPlazaHandler,
|
||||
NewAsyncImageHandler,
|
||||
ProvideBatchImageHandler,
|
||||
|
||||
|
||||
@@ -293,7 +293,7 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
|
||||
respID = geminiResp.ResponseID
|
||||
}
|
||||
if respID == "" {
|
||||
respID = "msg_" + generateRandomID()
|
||||
respID = generateAnthropicMsgID()
|
||||
}
|
||||
|
||||
return &ClaudeResponse{
|
||||
@@ -372,3 +372,18 @@ func generateRandomID() string {
|
||||
}
|
||||
return string(id)
|
||||
}
|
||||
|
||||
// generateAnthropicMsgID 生成 Anthropic 官方格式的 message ID:msg_01 + 22 位 Base62
|
||||
func generateAnthropicMsgID() string {
|
||||
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
||||
const idLen = 22
|
||||
randomBytes := make([]byte, idLen)
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
return "msg_01" + generateRandomID() + generateRandomID()[:10]
|
||||
}
|
||||
b := make([]byte, idLen)
|
||||
for i := range b {
|
||||
b[i] = charset[int(randomBytes[i])%len(charset)]
|
||||
}
|
||||
return "msg_01" + string(b)
|
||||
}
|
||||
|
||||
@@ -195,7 +195,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
|
||||
responseID = v1Resp.Response.ResponseID
|
||||
}
|
||||
if responseID == "" {
|
||||
responseID = "msg_" + generateRandomID()
|
||||
responseID = generateAnthropicMsgID()
|
||||
}
|
||||
|
||||
var usageValue any = usage
|
||||
|
||||
@@ -611,6 +611,11 @@ func BuildVideoURLWithValidator(baseURL, requestID string, validator BaseURLVali
|
||||
if requestID == "" {
|
||||
return "", fmt.Errorf("request id is required")
|
||||
}
|
||||
// requestID 由客户端提供并拼进上游 URL 的 path。PathEscape 之外再要求它不是
|
||||
// 纯点片段、不含控制字符,保证它只能是一个普通的路径片段。
|
||||
if requestID == "." || requestID == ".." || strings.ContainsAny(requestID, "\x00\r\n") {
|
||||
return "", fmt.Errorf("invalid request id")
|
||||
}
|
||||
return validatedBaseURL + "/videos/" + url.PathEscape(requestID), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1107,6 +1107,7 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o
|
||||
SELECT id
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
AND schedulable = TRUE
|
||||
AND platform = ANY($1)
|
||||
AND id > $2`
|
||||
if options.ActiveOnly {
|
||||
|
||||
@@ -327,6 +327,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
|
||||
"expires_at": now.Add(30 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
})
|
||||
unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{
|
||||
Name: "grok-oauth-unschedulable-excluded",
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{"refresh_token": "refresh-unschedulable"},
|
||||
})
|
||||
s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx))
|
||||
mustCreateAccount(s.T(), s.client, &service.Account{
|
||||
Name: "grok-api-key-excluded",
|
||||
Platform: service.PlatformGrok,
|
||||
@@ -386,6 +394,7 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
|
||||
first := firstPage.Accounts
|
||||
s.Require().Len(first, 2)
|
||||
s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID})
|
||||
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID)
|
||||
|
||||
options.AfterID = first[len(first)-1].ID
|
||||
secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options)
|
||||
|
||||
@@ -276,6 +276,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T)
|
||||
|
||||
normalized := normalizeSQLWhitespace(capturedSQL)
|
||||
require.Contains(t, normalized, "deleted_at IS NULL")
|
||||
require.Contains(t, normalized, "schedulable = TRUE",
|
||||
"permanently unschedulable accounts must not remain OAuth refresh candidates")
|
||||
require.Contains(t, normalized, "status = 'active'")
|
||||
// setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
|
||||
require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
|
||||
|
||||
@@ -222,7 +222,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
||||
return apiKeyEntityToService(m), nil
|
||||
}
|
||||
|
||||
func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) error {
|
||||
func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey, fields service.APIKeyUpdateFields) error {
|
||||
// 空掩码代表调用方不改任何列,直接返回,避免产生一次无意义的整行写。
|
||||
if fields.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用原子操作:将软删除检查与更新合并到同一语句,避免竞态条件。
|
||||
// 之前的实现先检查 Exist 再 UpdateOneID,若在两步之间发生软删除,
|
||||
// 则会更新已删除的记录。
|
||||
@@ -232,57 +237,77 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
|
||||
now := time.Now()
|
||||
builder := client.APIKey.Update().
|
||||
Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()).
|
||||
SetName(key.Name).
|
||||
SetStatus(key.Status).
|
||||
SetQuota(key.Quota).
|
||||
SetQuotaUsed(key.QuotaUsed).
|
||||
SetRateLimit5h(key.RateLimit5h).
|
||||
SetRateLimit1d(key.RateLimit1d).
|
||||
SetRateLimit7d(key.RateLimit7d).
|
||||
SetUsage5h(key.Usage5h).
|
||||
SetUsage1d(key.Usage1d).
|
||||
SetUsage7d(key.Usage7d).
|
||||
SetUpdatedAt(now)
|
||||
if key.GroupID != nil {
|
||||
builder.SetGroupID(*key.GroupID)
|
||||
} else {
|
||||
builder.ClearGroupID()
|
||||
if fields.Name {
|
||||
builder.SetName(key.Name)
|
||||
}
|
||||
if fields.Status {
|
||||
builder.SetStatus(key.Status)
|
||||
}
|
||||
if fields.Quota {
|
||||
builder.SetQuota(key.Quota)
|
||||
}
|
||||
if fields.QuotaUsed {
|
||||
builder.SetQuotaUsed(key.QuotaUsed)
|
||||
}
|
||||
if fields.RateLimits {
|
||||
builder.
|
||||
SetRateLimit5h(key.RateLimit5h).
|
||||
SetRateLimit1d(key.RateLimit1d).
|
||||
SetRateLimit7d(key.RateLimit7d)
|
||||
}
|
||||
if fields.RateLimitUsage {
|
||||
builder.
|
||||
SetUsage5h(key.Usage5h).
|
||||
SetUsage1d(key.Usage1d).
|
||||
SetUsage7d(key.Usage7d)
|
||||
|
||||
// Rate limit window start times
|
||||
if key.Window5hStart != nil {
|
||||
builder.SetWindow5hStart(*key.Window5hStart)
|
||||
} else {
|
||||
builder.ClearWindow5hStart()
|
||||
}
|
||||
if key.Window1dStart != nil {
|
||||
builder.SetWindow1dStart(*key.Window1dStart)
|
||||
} else {
|
||||
builder.ClearWindow1dStart()
|
||||
}
|
||||
if key.Window7dStart != nil {
|
||||
builder.SetWindow7dStart(*key.Window7dStart)
|
||||
} else {
|
||||
builder.ClearWindow7dStart()
|
||||
}
|
||||
}
|
||||
if fields.GroupID {
|
||||
if key.GroupID != nil {
|
||||
builder.SetGroupID(*key.GroupID)
|
||||
} else {
|
||||
builder.ClearGroupID()
|
||||
}
|
||||
}
|
||||
|
||||
// Expiration time
|
||||
if key.ExpiresAt != nil {
|
||||
builder.SetExpiresAt(*key.ExpiresAt)
|
||||
} else {
|
||||
builder.ClearExpiresAt()
|
||||
}
|
||||
|
||||
// Rate limit window start times
|
||||
if key.Window5hStart != nil {
|
||||
builder.SetWindow5hStart(*key.Window5hStart)
|
||||
} else {
|
||||
builder.ClearWindow5hStart()
|
||||
}
|
||||
if key.Window1dStart != nil {
|
||||
builder.SetWindow1dStart(*key.Window1dStart)
|
||||
} else {
|
||||
builder.ClearWindow1dStart()
|
||||
}
|
||||
if key.Window7dStart != nil {
|
||||
builder.SetWindow7dStart(*key.Window7dStart)
|
||||
} else {
|
||||
builder.ClearWindow7dStart()
|
||||
if fields.ExpiresAt {
|
||||
if key.ExpiresAt != nil {
|
||||
builder.SetExpiresAt(*key.ExpiresAt)
|
||||
} else {
|
||||
builder.ClearExpiresAt()
|
||||
}
|
||||
}
|
||||
|
||||
// IP 限制字段
|
||||
if len(key.IPWhitelist) > 0 {
|
||||
builder.SetIPWhitelist(key.IPWhitelist)
|
||||
} else {
|
||||
builder.ClearIPWhitelist()
|
||||
}
|
||||
if len(key.IPBlacklist) > 0 {
|
||||
builder.SetIPBlacklist(key.IPBlacklist)
|
||||
} else {
|
||||
builder.ClearIPBlacklist()
|
||||
if fields.IPRules {
|
||||
if len(key.IPWhitelist) > 0 {
|
||||
builder.SetIPWhitelist(key.IPWhitelist)
|
||||
} else {
|
||||
builder.ClearIPWhitelist()
|
||||
}
|
||||
if len(key.IPBlacklist) > 0 {
|
||||
builder.SetIPBlacklist(key.IPBlacklist)
|
||||
} else {
|
||||
builder.ClearIPBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
affected, err := builder.Save(ctx)
|
||||
|
||||
@@ -139,7 +139,7 @@ func (s *APIKeyRepoSuite) TestUpdate() {
|
||||
|
||||
key.Name = "Renamed"
|
||||
key.Status = service.StatusDisabled
|
||||
err := s.repo.Update(s.ctx, key)
|
||||
err := s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Name: true, Status: true})
|
||||
s.Require().NoError(err, "Update")
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
@@ -163,7 +163,7 @@ func (s *APIKeyRepoSuite) TestUpdate_ClearGroupID() {
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key))
|
||||
|
||||
key.GroupID = nil
|
||||
err := s.repo.Update(s.ctx, key)
|
||||
err := s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{GroupID: true})
|
||||
s.Require().NoError(err, "Update")
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
@@ -368,7 +368,7 @@ func (s *APIKeyRepoSuite) TestCRUD_Search_ClearGroupID() {
|
||||
key.Name = "Renamed"
|
||||
key.Status = service.StatusDisabled
|
||||
key.GroupID = nil
|
||||
s.Require().NoError(s.repo.Update(s.ctx, key), "Update")
|
||||
s.Require().NoError(s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Name: true, Status: true, GroupID: true}), "Update")
|
||||
|
||||
got2, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
@@ -486,7 +486,7 @@ func (s *APIKeyRepoSuite) TestIncrementQuotaUsedAndGetState() {
|
||||
key := s.mustCreateApiKey(user.ID, "sk-quota-state", "QuotaState", nil)
|
||||
key.Quota = 3
|
||||
key.QuotaUsed = 1
|
||||
s.Require().NoError(s.repo.Update(s.ctx, key), "Update quota")
|
||||
s.Require().NoError(s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Quota: true, QuotaUsed: true}), "Update quota")
|
||||
|
||||
state, err := s.repo.IncrementQuotaUsedAndGetState(s.ctx, key.ID, 2.5)
|
||||
s.Require().NoError(err, "IncrementQuotaUsedAndGetState")
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// api_keys 上的用量列由计费热路径原子递增(IncrementQuotaUsed /
|
||||
// IncrementRateLimitUsage)。编辑 Key 时若整行回写,
|
||||
// 并发累计的配额与限流计数就会被旧快照覆盖。
|
||||
|
||||
func (s *APIKeyRepoSuite) TestUpdate_DoesNotRevertConcurrentQuotaUsage() {
|
||||
user := s.mustCreateUser("apikey-lost-update-quota@example.com")
|
||||
key := &service.APIKey{
|
||||
UserID: user.ID,
|
||||
Key: "sk-lost-update-quota",
|
||||
Name: "before",
|
||||
Status: service.StatusActive,
|
||||
Quota: 100,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
|
||||
|
||||
stale, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().Zero(stale.QuotaUsed)
|
||||
|
||||
newUsed, err := s.repo.IncrementQuotaUsed(s.ctx, key.ID, 30)
|
||||
s.Require().NoError(err, "IncrementQuotaUsed")
|
||||
s.Require().InDelta(30, newUsed, 1e-9)
|
||||
|
||||
stale.Name = "after"
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, stale, service.APIKeyUpdateFields{Name: true}),
|
||||
"Update",
|
||||
)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
s.Require().Equal("after", got.Name, "declared column must still be written")
|
||||
s.Require().InDelta(30, got.QuotaUsed, 1e-9, "quota_used must not be reverted by a stale key edit")
|
||||
}
|
||||
|
||||
func (s *APIKeyRepoSuite) TestUpdate_DoesNotRevertConcurrentRateLimitUsage() {
|
||||
user := s.mustCreateUser("apikey-lost-update-ratelimit@example.com")
|
||||
key := &service.APIKey{
|
||||
UserID: user.ID,
|
||||
Key: "sk-lost-update-ratelimit",
|
||||
Name: "before",
|
||||
Status: service.StatusActive,
|
||||
RateLimit5h: 100,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
|
||||
|
||||
stale, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().Zero(stale.Usage5h)
|
||||
|
||||
s.Require().NoError(s.repo.IncrementRateLimitUsage(s.ctx, key.ID, 42), "IncrementRateLimitUsage")
|
||||
|
||||
stale.Name = "after"
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, stale, service.APIKeyUpdateFields{Name: true}),
|
||||
"Update",
|
||||
)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
s.Require().InDelta(42, got.Usage5h, 1e-9, "usage_5h must not be reverted by a stale key edit")
|
||||
s.Require().InDelta(42, got.Usage1d, 1e-9, "usage_1d must not be reverted by a stale key edit")
|
||||
s.Require().InDelta(42, got.Usage7d, 1e-9, "usage_7d must not be reverted by a stale key edit")
|
||||
}
|
||||
|
||||
// 显式重置仍然必须生效,避免收窄写入列时把功能改坏。
|
||||
func (s *APIKeyRepoSuite) TestUpdate_StillResetsUsageWhenDeclared() {
|
||||
user := s.mustCreateUser("apikey-reset-usage@example.com")
|
||||
key := &service.APIKey{
|
||||
UserID: user.ID,
|
||||
Key: "sk-reset-usage",
|
||||
Name: "reset",
|
||||
Status: service.StatusActive,
|
||||
Quota: 100,
|
||||
}
|
||||
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
|
||||
|
||||
_, err := s.repo.IncrementQuotaUsed(s.ctx, key.ID, 30)
|
||||
s.Require().NoError(err, "IncrementQuotaUsed")
|
||||
s.Require().NoError(s.repo.IncrementRateLimitUsage(s.ctx, key.ID, 42), "IncrementRateLimitUsage")
|
||||
|
||||
current, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
current.QuotaUsed = 0
|
||||
current.Usage5h = 0
|
||||
current.Usage1d = 0
|
||||
current.Usage7d = 0
|
||||
current.Window5hStart = nil
|
||||
current.Window1dStart = nil
|
||||
current.Window7dStart = nil
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, current, service.APIKeyUpdateFields{QuotaUsed: true, RateLimitUsage: true}),
|
||||
"Update",
|
||||
)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, key.ID)
|
||||
s.Require().NoError(err, "GetByID after reset")
|
||||
s.Require().Zero(got.QuotaUsed, "explicit quota reset must still apply")
|
||||
s.Require().Zero(got.Usage5h, "explicit rate limit reset must still apply")
|
||||
s.Require().Zero(got.Usage1d)
|
||||
s.Require().Zero(got.Usage7d)
|
||||
s.Require().Nil(got.Window5hStart)
|
||||
}
|
||||
@@ -77,8 +77,8 @@ func TestAuthCacheInvalidationTriggers_CoverSecurityMutationsOnly(t *testing.T)
|
||||
userRepo := NewUserRepository(integrationEntClient, integrationDB)
|
||||
loadedUser, err := userRepo.GetByID(ctx, user.ID)
|
||||
require.NoError(t, err)
|
||||
loadedUser.Balance += 10
|
||||
require.NoError(t, userRepo.Update(ctx, loadedUser))
|
||||
_, err = userRepo.AdjustBalance(ctx, loadedUser.ID, 10)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count(), "balance update with unchanged allowed groups must not enqueue")
|
||||
|
||||
_, err = integrationDB.ExecContext(ctx, "UPDATE users SET status = 'disabled' WHERE id = $1", user.ID)
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
type passkeyRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPasskeyRepository(db *sql.DB) service.PasskeyRepository {
|
||||
return &passkeyRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) EnsureUserHandle(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
candidate []byte,
|
||||
) ([]byte, error) {
|
||||
if len(candidate) < 16 || len(candidate) > 64 {
|
||||
return nil, fmt.Errorf("passkey user handle must contain 16-64 bytes")
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO passkey_user_handles (user_id, user_handle)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
`, userID, candidate); err != nil {
|
||||
return nil, fmt.Errorf("ensure passkey user handle: %w", err)
|
||||
}
|
||||
return r.GetUserHandle(ctx, userID)
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) GetUserHandle(ctx context.Context, userID int64) ([]byte, error) {
|
||||
var handle []byte
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT user_handle
|
||||
FROM passkey_user_handles
|
||||
WHERE user_id = $1
|
||||
`, userID).Scan(&handle)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, service.ErrPasskeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get passkey user handle: %w", err)
|
||||
}
|
||||
return handle, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) GetByCredentialID(
|
||||
ctx context.Context,
|
||||
credentialID []byte,
|
||||
) (*service.PasskeyCredentialRecord, error) {
|
||||
row := r.db.QueryRowContext(ctx, `
|
||||
SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
|
||||
c.last_used_at, c.created_at, c.updated_at
|
||||
FROM passkey_credentials c
|
||||
JOIN passkey_user_handles h ON h.user_id = c.user_id
|
||||
WHERE c.credential_id = $1
|
||||
`, credentialID)
|
||||
record, err := scanPasskeyCredential(row)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, service.ErrPasskeyNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get passkey credential: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) ListByUserID(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
) ([]service.PasskeyCredentialRecord, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT c.id, c.user_id, h.user_handle, c.name, c.credential_data,
|
||||
c.last_used_at, c.created_at, c.updated_at
|
||||
FROM passkey_credentials c
|
||||
JOIN passkey_user_handles h ON h.user_id = c.user_id
|
||||
WHERE c.user_id = $1
|
||||
ORDER BY c.created_at DESC, c.id DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list passkey credentials: %w", err)
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
records := make([]service.PasskeyCredentialRecord, 0)
|
||||
for rows.Next() {
|
||||
record, scanErr := scanPasskeyCredential(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scan passkey credential: %w", scanErr)
|
||||
}
|
||||
records = append(records, *record)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list passkey credentials: %w", err)
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) Create(
|
||||
ctx context.Context,
|
||||
record *service.PasskeyCredentialRecord,
|
||||
) (*service.PasskeyCredentialRecord, error) {
|
||||
if record == nil || len(record.Credential.ID) == 0 {
|
||||
return nil, fmt.Errorf("passkey credential is required")
|
||||
}
|
||||
credentialJSON, err := json.Marshal(record.Credential)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode passkey credential: %w", err)
|
||||
}
|
||||
name := strings.TrimSpace(record.Name)
|
||||
if name == "" {
|
||||
name = "Passkey"
|
||||
}
|
||||
created := &service.PasskeyCredentialRecord{
|
||||
UserID: record.UserID,
|
||||
UserHandle: append([]byte(nil), record.UserHandle...),
|
||||
Name: name,
|
||||
Credential: record.Credential,
|
||||
}
|
||||
err = r.db.QueryRowContext(ctx, `
|
||||
INSERT INTO passkey_credentials
|
||||
(user_id, credential_id, name, credential_data)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
RETURNING id, name, created_at, updated_at
|
||||
`, record.UserID, record.Credential.ID, name, string(credentialJSON)).
|
||||
Scan(&created.ID, &created.Name, &created.CreatedAt, &created.UpdatedAt)
|
||||
if err != nil {
|
||||
var pqErr *pq.Error
|
||||
if errors.As(err, &pqErr) && pqErr.Code == "23505" {
|
||||
return nil, service.ErrPasskeyExists
|
||||
}
|
||||
return nil, fmt.Errorf("create passkey credential: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) UpdateCredential(
|
||||
ctx context.Context,
|
||||
userID int64,
|
||||
credential *webauthn.Credential,
|
||||
usedAt time.Time,
|
||||
) error {
|
||||
if credential == nil || len(credential.ID) == 0 {
|
||||
return fmt.Errorf("passkey credential is required")
|
||||
}
|
||||
credentialJSON, err := json.Marshal(credential)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode passkey credential: %w", err)
|
||||
}
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE passkey_credentials
|
||||
SET credential_data = $3::jsonb, last_used_at = $4, updated_at = NOW()
|
||||
WHERE user_id = $1 AND credential_id = $2
|
||||
`, userID, credential.ID, string(credentialJSON), usedAt.UTC())
|
||||
if err != nil {
|
||||
return fmt.Errorf("update passkey credential: %w", err)
|
||||
}
|
||||
return requirePasskeyAffected(result)
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) Rename(
|
||||
ctx context.Context,
|
||||
userID, credentialID int64,
|
||||
name string,
|
||||
) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
UPDATE passkey_credentials
|
||||
SET name = $3, updated_at = NOW()
|
||||
WHERE user_id = $1 AND id = $2
|
||||
`, userID, credentialID, name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename passkey credential: %w", err)
|
||||
}
|
||||
return requirePasskeyAffected(result)
|
||||
}
|
||||
|
||||
func (r *passkeyRepository) Delete(
|
||||
ctx context.Context,
|
||||
userID, credentialID int64,
|
||||
) error {
|
||||
result, err := r.db.ExecContext(ctx, `
|
||||
DELETE FROM passkey_credentials
|
||||
WHERE user_id = $1 AND id = $2
|
||||
`, userID, credentialID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete passkey credential: %w", err)
|
||||
}
|
||||
return requirePasskeyAffected(result)
|
||||
}
|
||||
|
||||
type passkeyScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPasskeyCredential(scanner passkeyScanner) (*service.PasskeyCredentialRecord, error) {
|
||||
var (
|
||||
record service.PasskeyCredentialRecord
|
||||
credentialJSON []byte
|
||||
)
|
||||
if err := scanner.Scan(
|
||||
&record.ID,
|
||||
&record.UserID,
|
||||
&record.UserHandle,
|
||||
&record.Name,
|
||||
&credentialJSON,
|
||||
&record.LastUsedAt,
|
||||
&record.CreatedAt,
|
||||
&record.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := json.Unmarshal(credentialJSON, &record.Credential); err != nil {
|
||||
return nil, fmt.Errorf("decode passkey credential: %w", err)
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
func requirePasskeyAffected(result sql.Result) error {
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected == 0 {
|
||||
return service.ErrPasskeyNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
const passkeySessionPrefix = "passkey:session:"
|
||||
|
||||
type passkeySessionStore struct {
|
||||
redis *redis.Client
|
||||
}
|
||||
|
||||
func NewPasskeySessionStore(redisClient *redis.Client) service.PasskeySessionStore {
|
||||
return &passkeySessionStore{redis: redisClient}
|
||||
}
|
||||
|
||||
func (s *passkeySessionStore) Store(
|
||||
ctx context.Context,
|
||||
session *service.PasskeySession,
|
||||
ttl time.Duration,
|
||||
) (string, error) {
|
||||
if session == nil || ttl <= 0 {
|
||||
return "", fmt.Errorf("invalid passkey session")
|
||||
}
|
||||
random := make([]byte, 32)
|
||||
if _, err := rand.Read(random); err != nil {
|
||||
return "", fmt.Errorf("generate passkey session token: %w", err)
|
||||
}
|
||||
token := base64.RawURLEncoding.EncodeToString(random)
|
||||
payload, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("encode passkey session: %w", err)
|
||||
}
|
||||
if err = s.redis.Set(ctx, passkeySessionPrefix+token, payload, ttl).Err(); err != nil {
|
||||
return "", fmt.Errorf("store passkey session: %w", err)
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *passkeySessionStore) Consume(
|
||||
ctx context.Context,
|
||||
token string,
|
||||
) (*service.PasskeySession, error) {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" || len(token) > 128 {
|
||||
return nil, service.ErrPasskeySession
|
||||
}
|
||||
payload, err := s.redis.GetDel(ctx, passkeySessionPrefix+token).Bytes()
|
||||
if err == redis.Nil {
|
||||
return nil, service.ErrPasskeySession
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("consume passkey session: %w", err)
|
||||
}
|
||||
var session service.PasskeySession
|
||||
if err = json.Unmarshal(payload, &session); err != nil {
|
||||
return nil, service.ErrPasskeySession
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
@@ -87,13 +87,18 @@ func (r *promoCodeRepository) GetByCodeForUpdate(ctx context.Context, code strin
|
||||
return promoCodeEntityToService(m), nil
|
||||
}
|
||||
|
||||
// Update 写入管理员可编辑的字段。
|
||||
//
|
||||
// 这里刻意不写 used_count:它由兑换路径的 IncrementUsedCount 原子递增,
|
||||
// 而 used_count >= max_uses 正是"优惠码用完了"的判定依据。若管理员编辑
|
||||
// (改有效期、改额度……)时按快照把 used_count 回写,并发的兑换计数就会被抹掉,
|
||||
// 兑换次数统计随之失真。PromoService.Update 也从不修改该字段。
|
||||
func (r *promoCodeRepository) Update(ctx context.Context, code *service.PromoCode) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
builder := client.PromoCode.UpdateOneID(code.ID).
|
||||
SetCode(code.Code).
|
||||
SetBonusAmount(code.BonusAmount).
|
||||
SetMaxUses(code.MaxUses).
|
||||
SetUsedCount(code.UsedCount).
|
||||
SetStatus(code.Status).
|
||||
SetNotes(code.Notes)
|
||||
|
||||
|
||||
@@ -482,7 +482,8 @@ func (s *UserProfileIdentityRepoSuite) TestWithUserProfileIdentityTx_AllowsAvata
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.Update(txCtx, model)
|
||||
// 只改头像时用户行没有任何列需要写,掩码为空——与 UserService.updateProfile 一致。
|
||||
return s.repo.Update(txCtx, model, service.UserUpdateFields{})
|
||||
})
|
||||
s.Require().NoError(err)
|
||||
|
||||
|
||||
@@ -205,10 +205,14 @@ func (r *userRepository) GetByEmail(ctx context.Context, email string) (*service
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, userIn *service.User) error {
|
||||
func (r *userRepository) Update(ctx context.Context, userIn *service.User, fields service.UserUpdateFields) error {
|
||||
if userIn == nil {
|
||||
return nil
|
||||
}
|
||||
// 空掩码代表调用方不改任何列,直接返回,避免产生一次无意义的整行写。
|
||||
if fields.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用 ent 事务包裹用户更新与 allowed_groups 同步,避免跨层事务不一致。
|
||||
tx, err := r.client.Tx(ctx)
|
||||
@@ -231,19 +235,23 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
||||
}
|
||||
}
|
||||
|
||||
releaseEmailLock, err := lockRepositoryScopedKeys(
|
||||
txCtx,
|
||||
txClient,
|
||||
txAwareSQLExecutor(txCtx, r.sql, r.client),
|
||||
normalizedEmailUniquenessLockKey(userIn.Email),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseEmailLock()
|
||||
// 邮箱唯一性锁与查重只在本次确实要改邮箱时才做:不改邮箱的更新既不需要
|
||||
// 串行化,也不该因为快照里的旧邮箱已被他人占用而报 ErrEmailExists。
|
||||
if fields.Email {
|
||||
releaseEmailLock, err := lockRepositoryScopedKeys(
|
||||
txCtx,
|
||||
txClient,
|
||||
txAwareSQLExecutor(txCtx, r.sql, r.client),
|
||||
normalizedEmailUniquenessLockKey(userIn.Email),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseEmailLock()
|
||||
|
||||
if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, userIn.ID, userIn.Email); err != nil {
|
||||
return err
|
||||
if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, userIn.ID, userIn.Email); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
existing, err := clientFromContext(txCtx, txClient).User.Get(txCtx, userIn.ID)
|
||||
@@ -252,41 +260,64 @@ func (r *userRepository) Update(ctx context.Context, userIn *service.User) error
|
||||
}
|
||||
oldEmail := existing.Email
|
||||
|
||||
updateOp := txClient.User.UpdateOneID(userIn.ID).
|
||||
SetEmail(userIn.Email).
|
||||
SetUsername(userIn.Username).
|
||||
SetNotes(userIn.Notes).
|
||||
SetPasswordHash(userIn.PasswordHash).
|
||||
SetRole(userIn.Role).
|
||||
SetBalance(userIn.Balance).
|
||||
SetConcurrency(userIn.Concurrency).
|
||||
SetStatus(userIn.Status).
|
||||
SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
|
||||
SetBalanceNotifyThresholdType(userIn.BalanceNotifyThresholdType).
|
||||
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold).
|
||||
SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails)).
|
||||
SetTotalRecharged(userIn.TotalRecharged).
|
||||
SetRpmLimit(userIn.RPMLimit)
|
||||
if userIn.SignupSource != "" {
|
||||
updateOp := txClient.User.UpdateOneID(userIn.ID)
|
||||
if fields.Email {
|
||||
updateOp = updateOp.SetEmail(userIn.Email)
|
||||
}
|
||||
if fields.Username {
|
||||
updateOp = updateOp.SetUsername(userIn.Username)
|
||||
}
|
||||
if fields.Notes {
|
||||
updateOp = updateOp.SetNotes(userIn.Notes)
|
||||
}
|
||||
if fields.PasswordHash {
|
||||
updateOp = updateOp.SetPasswordHash(userIn.PasswordHash)
|
||||
}
|
||||
if fields.Role {
|
||||
updateOp = updateOp.SetRole(userIn.Role)
|
||||
}
|
||||
if fields.Concurrency {
|
||||
updateOp = updateOp.SetConcurrency(userIn.Concurrency)
|
||||
}
|
||||
if fields.RPMLimit {
|
||||
updateOp = updateOp.SetRpmLimit(userIn.RPMLimit)
|
||||
}
|
||||
if fields.Status {
|
||||
updateOp = updateOp.SetStatus(userIn.Status)
|
||||
}
|
||||
if fields.BalanceNotifySettings {
|
||||
updateOp = updateOp.
|
||||
SetBalanceNotifyEnabled(userIn.BalanceNotifyEnabled).
|
||||
SetBalanceNotifyThresholdType(userIn.BalanceNotifyThresholdType).
|
||||
SetNillableBalanceNotifyThreshold(userIn.BalanceNotifyThreshold)
|
||||
if userIn.BalanceNotifyThreshold == nil {
|
||||
updateOp = updateOp.ClearBalanceNotifyThreshold()
|
||||
}
|
||||
}
|
||||
if fields.BalanceNotifyExtraEmails {
|
||||
updateOp = updateOp.SetBalanceNotifyExtraEmails(marshalExtraEmails(userIn.BalanceNotifyExtraEmails))
|
||||
}
|
||||
if fields.SignupSource && userIn.SignupSource != "" {
|
||||
updateOp = updateOp.SetSignupSource(userIn.SignupSource)
|
||||
}
|
||||
if userIn.LastLoginAt != nil {
|
||||
if fields.LastLoginAt && userIn.LastLoginAt != nil {
|
||||
updateOp = updateOp.SetLastLoginAt(*userIn.LastLoginAt)
|
||||
}
|
||||
if userIn.LastActiveAt != nil {
|
||||
if fields.LastActiveAt && userIn.LastActiveAt != nil {
|
||||
updateOp = updateOp.SetLastActiveAt(*userIn.LastActiveAt)
|
||||
}
|
||||
if userIn.BalanceNotifyThreshold == nil {
|
||||
updateOp = updateOp.ClearBalanceNotifyThreshold()
|
||||
}
|
||||
updated, err := updateOp.Save(txCtx)
|
||||
if err != nil {
|
||||
return translatePersistenceError(err, service.ErrUserNotFound, service.ErrEmailExists)
|
||||
}
|
||||
|
||||
if err := r.syncUserAllowedGroupsWithClient(txCtx, txClient, updated.ID, userIn.AllowedGroups); err != nil {
|
||||
return err
|
||||
if fields.AllowedGroups {
|
||||
if err := r.syncUserAllowedGroupsWithClient(txCtx, txClient, updated.ID, userIn.AllowedGroups); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// 始终以库中的邮箱为准补齐 email 身份:未改邮箱时 updated.Email == oldEmail,
|
||||
// 这里退化为幂等的身份补写,与改邮箱前的行为一致。
|
||||
if err := replaceEmailAuthIdentityWithClient(txCtx, txClient, updated.ID, oldEmail, updated.Email, "user_repo_update"); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -828,6 +859,106 @@ func (r *userRepository) DeductBalance(ctx context.Context, id int64, amount flo
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdjustBalance 原子地把 delta 累加到余额上,结果为负时整条语句不生效。
|
||||
// 相比"读余额 → 算新值 → 整行写回",这里把读与写压进同一条 UPDATE,
|
||||
// 并发的计费扣款不会被旧快照覆盖。
|
||||
func (r *userRepository) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
|
||||
const updateSQL = `
|
||||
UPDATE users
|
||||
SET balance = balance + $1, updated_at = NOW()
|
||||
WHERE id = $2 AND deleted_at IS NULL AND balance + $1 >= 0
|
||||
RETURNING balance - $1, balance
|
||||
`
|
||||
change, ok, err := scanBalanceChange(ctx, clientFromContext(ctx, r.client), updateSQL, delta, id)
|
||||
if err != nil {
|
||||
return service.BalanceChange{}, err
|
||||
}
|
||||
if ok {
|
||||
return change, nil
|
||||
}
|
||||
|
||||
// 0 行既可能是用户不存在,也可能是余额不足以承受这次扣减,需要区分。
|
||||
current, err := r.currentBalance(ctx, id)
|
||||
if err != nil {
|
||||
return service.BalanceChange{}, err
|
||||
}
|
||||
return service.BalanceChange{Old: current, New: current + delta}, service.ErrBalanceNegative
|
||||
}
|
||||
|
||||
// SetBalance 原子地把余额置为 value,并返回变更前后的值。
|
||||
func (r *userRepository) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
|
||||
if value < 0 {
|
||||
// 连同当前余额一起返回,便于上层给出可读的错误信息。
|
||||
current, err := r.currentBalance(ctx, id)
|
||||
if err != nil {
|
||||
return service.BalanceChange{}, err
|
||||
}
|
||||
return service.BalanceChange{Old: current, New: value}, service.ErrBalanceNegative
|
||||
}
|
||||
const updateSQL = `
|
||||
UPDATE users AS u
|
||||
SET balance = $1, updated_at = NOW()
|
||||
FROM (SELECT id, balance FROM users WHERE id = $2 AND deleted_at IS NULL) AS prev
|
||||
WHERE u.id = prev.id AND u.deleted_at IS NULL
|
||||
RETURNING prev.balance, u.balance
|
||||
`
|
||||
change, ok, err := scanBalanceChange(ctx, clientFromContext(ctx, r.client), updateSQL, value, id)
|
||||
if err != nil {
|
||||
return service.BalanceChange{}, err
|
||||
}
|
||||
if !ok {
|
||||
return service.BalanceChange{}, service.ErrUserNotFound
|
||||
}
|
||||
return change, nil
|
||||
}
|
||||
|
||||
// currentBalance 读取用户当前余额,用户不存在时返回 ErrUserNotFound。
|
||||
func (r *userRepository) currentBalance(ctx context.Context, id int64) (balance float64, err error) {
|
||||
rows, err := clientFromContext(ctx, r.client).QueryContext(ctx,
|
||||
`SELECT balance FROM users WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}()
|
||||
if !rows.Next() {
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
return 0, rowsErr
|
||||
}
|
||||
return 0, service.ErrUserNotFound
|
||||
}
|
||||
if err := rows.Scan(&balance); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return balance, rows.Err()
|
||||
}
|
||||
|
||||
// scanBalanceChange 执行一条 RETURNING 旧余额、新余额的语句。ok 为 false 表示语句未命中任何行。
|
||||
func scanBalanceChange(ctx context.Context, client *dbent.Client, query string, args ...any) (change service.BalanceChange, ok bool, err error) {
|
||||
rows, err := client.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return service.BalanceChange{}, false, err
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := rows.Close(); closeErr != nil && err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
}()
|
||||
if !rows.Next() {
|
||||
if rowsErr := rows.Err(); rowsErr != nil {
|
||||
return service.BalanceChange{}, false, rowsErr
|
||||
}
|
||||
return service.BalanceChange{}, false, nil
|
||||
}
|
||||
if err := rows.Scan(&change.Old, &change.New); err != nil {
|
||||
return service.BalanceChange{}, false, err
|
||||
}
|
||||
return change, true, rows.Err()
|
||||
}
|
||||
|
||||
func (r *userRepository) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
n, err := client.User.Update().Where(dbuser.IDEQ(id)).AddConcurrency(amount).Save(ctx)
|
||||
|
||||
@@ -60,7 +60,7 @@ func (s *UserRepoSuite) TestUpdate_ReplacesEmailAuthIdentityWhenEmailChanges() {
|
||||
})
|
||||
|
||||
user.Email = "after-update@example.com"
|
||||
s.Require().NoError(s.repo.Update(s.ctx, user))
|
||||
s.Require().NoError(s.repo.Update(s.ctx, user, service.UserUpdateFields{Email: true}))
|
||||
|
||||
newIdentity, err := s.client.AuthIdentity.Query().
|
||||
Where(
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestUserRepositoryUpdateRejectsNormalizedEmailDuplicate(t *testing.T) {
|
||||
require.NoError(t, repo.Create(ctx, second))
|
||||
|
||||
second.Email = " existing@example.com "
|
||||
err := repo.Update(ctx, second)
|
||||
err := repo.Update(ctx, second, service.UserUpdateFields{Email: true})
|
||||
require.ErrorIs(t, err, service.ErrEmailExists)
|
||||
}
|
||||
|
||||
|
||||
@@ -154,7 +154,7 @@ func (s *UserRepoSuite) TestUpdate() {
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
got.Username = "updated"
|
||||
s.Require().NoError(s.repo.Update(s.ctx, got), "Update")
|
||||
s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update")
|
||||
|
||||
updated, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
@@ -232,7 +232,7 @@ func (s *UserRepoSuite) TestUpdateIgnoresNoRowsFromConflictingEmailIdentityUpser
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
got.Username = "updated"
|
||||
s.Require().NoError(s.repo.Update(s.ctx, got), "Update should tolerate ON CONFLICT DO NOTHING returning no rows")
|
||||
s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update should tolerate ON CONFLICT DO NOTHING returning no rows")
|
||||
|
||||
updated, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err)
|
||||
@@ -658,7 +658,7 @@ func (s *UserRepoSuite) TestCRUD_And_Filters_And_AtomicUpdates() {
|
||||
s.Require().Equal(user2.ID, gotByEmail.ID, "GetByEmail ID mismatch")
|
||||
|
||||
got.Username = "Alice2"
|
||||
s.Require().NoError(s.repo.Update(s.ctx, got), "Update")
|
||||
s.Require().NoError(s.repo.Update(s.ctx, got, service.UserUpdateFields{Username: true}), "Update")
|
||||
got2, err := s.repo.GetByID(s.ctx, user1.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
s.Require().Equal("Alice2", got2.Username, "Update did not persist")
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// 这一组用例覆盖用户行上的 lost update:调用方手里的快照可能早于并发发生的
|
||||
// 原子写入(扣费、状态变更、限额调整、分组授予)。Update 只写显式声明的列,
|
||||
// 未声明的列一律保持库中当前值,因此陈旧快照不会回滚这些并发结果。
|
||||
|
||||
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBalanceDeduction() {
|
||||
user := s.mustCreateUser(&service.User{
|
||||
Email: "lost-update-balance@example.com",
|
||||
Username: "before",
|
||||
Balance: 0.30,
|
||||
})
|
||||
|
||||
// 调用方在扣费之前读到的旧快照(余额还是 0.30)。
|
||||
stale, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().InDelta(0.30, stale.Balance, 1e-9)
|
||||
|
||||
// 与之并发:计费按原子方式扣费。
|
||||
s.Require().NoError(s.repo.DeductBalance(s.ctx, user.ID, 0.25), "DeductBalance")
|
||||
|
||||
// 基于旧快照的资料更新这时才落库。
|
||||
stale.Username = "after"
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
|
||||
"Update",
|
||||
)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
s.Require().Equal("after", got.Username, "declared column must still be written")
|
||||
s.Require().InDelta(0.05, got.Balance, 1e-9, "balance must not be reverted by a stale profile save")
|
||||
}
|
||||
|
||||
// 同理,风控自动封禁把 status 置为 disabled 后,
|
||||
// 基于旧快照的资料更新不得把 status 刷回 active。
|
||||
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentBan() {
|
||||
user := s.mustCreateUser(&service.User{
|
||||
Email: "lost-update-ban@example.com",
|
||||
Username: "before",
|
||||
Status: service.StatusActive,
|
||||
})
|
||||
|
||||
stale, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().Equal(service.StatusActive, stale.Status)
|
||||
|
||||
banned, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID for ban")
|
||||
banned.Status = service.StatusDisabled
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, banned, service.UserUpdateFields{Status: true}),
|
||||
"ban",
|
||||
)
|
||||
|
||||
stale.Username = "after"
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
|
||||
"stale profile save",
|
||||
)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
s.Require().Equal("after", got.Username)
|
||||
s.Require().Equal(service.StatusDisabled, got.Status, "ban must survive a stale profile save")
|
||||
}
|
||||
|
||||
// 未声明的列不写,也意味着并发的限额调整不会被资料保存回滚。
|
||||
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentLimitChanges() {
|
||||
user := s.mustCreateUser(&service.User{
|
||||
Email: "lost-update-limits@example.com",
|
||||
Username: "before",
|
||||
Concurrency: 3,
|
||||
RPMLimit: 30,
|
||||
})
|
||||
|
||||
stale, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
|
||||
concurrency, rpmLimit := 9, 90
|
||||
affected, err := s.repo.BatchUpdateLimits(s.ctx, []int64{user.ID}, &concurrency, &rpmLimit)
|
||||
s.Require().NoError(err, "BatchUpdateLimits")
|
||||
s.Require().Equal(1, affected)
|
||||
|
||||
stale.Username = "after"
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
|
||||
"stale profile save",
|
||||
)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
s.Require().Equal(9, got.Concurrency, "concurrency must not be reverted")
|
||||
s.Require().Equal(90, got.RPMLimit, "rpm limit must not be reverted")
|
||||
}
|
||||
|
||||
// AllowedGroups 只在显式声明时才同步,否则并发授予的分组权限会被旧快照删掉。
|
||||
func (s *UserRepoSuite) TestUpdate_DoesNotRevertConcurrentAllowedGroupGrant() {
|
||||
group := s.mustCreateGroup("lost-update-group")
|
||||
user := s.mustCreateUser(&service.User{
|
||||
Email: "lost-update-groups@example.com",
|
||||
Username: "before",
|
||||
})
|
||||
|
||||
stale, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().Empty(stale.AllowedGroups)
|
||||
|
||||
s.Require().NoError(s.repo.AddGroupToAllowedGroups(s.ctx, user.ID, group.ID), "AddGroupToAllowedGroups")
|
||||
|
||||
stale.Username = "after"
|
||||
s.Require().NoError(
|
||||
s.repo.Update(s.ctx, stale, service.UserUpdateFields{Username: true}),
|
||||
"stale profile save",
|
||||
)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID after update")
|
||||
s.Require().Equal([]int64{group.ID}, got.AllowedGroups, "granted group must not be reverted")
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestAdjustBalance_AppliesDeltaAndReportsChange() {
|
||||
user := s.mustCreateUser(&service.User{Email: "adjust-balance@example.com", Balance: 10})
|
||||
|
||||
change, err := s.repo.AdjustBalance(s.ctx, user.ID, 5)
|
||||
s.Require().NoError(err, "AdjustBalance add")
|
||||
s.Require().InDelta(10, change.Old, 1e-9)
|
||||
s.Require().InDelta(15, change.New, 1e-9)
|
||||
|
||||
change, err = s.repo.AdjustBalance(s.ctx, user.ID, -5)
|
||||
s.Require().NoError(err, "AdjustBalance subtract")
|
||||
s.Require().InDelta(15, change.Old, 1e-9)
|
||||
s.Require().InDelta(10, change.New, 1e-9)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().InDelta(10, got.Balance, 1e-9)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestAdjustBalance_RefusesNegativeResult() {
|
||||
user := s.mustCreateUser(&service.User{Email: "adjust-balance-negative@example.com", Balance: 3})
|
||||
|
||||
change, err := s.repo.AdjustBalance(s.ctx, user.ID, -4)
|
||||
s.Require().ErrorIs(err, service.ErrBalanceNegative)
|
||||
s.Require().InDelta(3, change.Old, 1e-9, "error must report the real current balance")
|
||||
s.Require().InDelta(-1, change.New, 1e-9)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().InDelta(3, got.Balance, 1e-9, "refused adjustment must not write")
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestAdjustBalance_UserNotFound() {
|
||||
_, err := s.repo.AdjustBalance(s.ctx, 99999999, 1)
|
||||
s.Require().ErrorIs(err, service.ErrUserNotFound)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestSetBalance_ReplacesValueAndReportsPrevious() {
|
||||
user := s.mustCreateUser(&service.User{Email: "set-balance@example.com", Balance: 7})
|
||||
|
||||
change, err := s.repo.SetBalance(s.ctx, user.ID, 2)
|
||||
s.Require().NoError(err, "SetBalance")
|
||||
s.Require().InDelta(7, change.Old, 1e-9)
|
||||
s.Require().InDelta(2, change.New, 1e-9)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().InDelta(2, got.Balance, 1e-9)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestSetBalance_RejectsNegativeValue() {
|
||||
user := s.mustCreateUser(&service.User{Email: "set-balance-negative@example.com", Balance: 7})
|
||||
|
||||
_, err := s.repo.SetBalance(s.ctx, user.ID, -1)
|
||||
s.Require().ErrorIs(err, service.ErrBalanceNegative)
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, user.ID)
|
||||
s.Require().NoError(err, "GetByID")
|
||||
s.Require().InDelta(7, got.Balance, 1e-9)
|
||||
}
|
||||
|
||||
func (s *UserRepoSuite) TestSetBalance_UserNotFound() {
|
||||
_, err := s.repo.SetBalance(s.ctx, 99999999, 1)
|
||||
s.Require().ErrorIs(err, service.ErrUserNotFound)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (s *UserRepoSuite) TestUpdate_PersistsSignupSourceAndActivityTimestamps() {
|
||||
created.LastLoginAt = &lastLoginAt
|
||||
created.LastActiveAt = &lastActiveAt
|
||||
|
||||
s.Require().NoError(s.repo.Update(s.ctx, created))
|
||||
s.Require().NoError(s.repo.Update(s.ctx, created, service.UserUpdateFields{SignupSource: true, LastLoginAt: true, LastActiveAt: true}))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, created.ID)
|
||||
s.Require().NoError(err)
|
||||
|
||||
@@ -88,6 +88,8 @@ var ProviderSet = wire.NewSet(
|
||||
NewSettingRepository,
|
||||
NewOpsRepository,
|
||||
NewAuditLogRepository,
|
||||
NewPasskeyRepository,
|
||||
NewPasskeySessionStore,
|
||||
NewUserSubscriptionRepository,
|
||||
NewUserAttributeDefinitionRepository,
|
||||
NewUserAttributeValueRepository,
|
||||
|
||||
@@ -90,6 +90,11 @@ type ActiveEndpoint struct {
|
||||
TimeoutMS int
|
||||
InputLimit int
|
||||
Enabled bool
|
||||
// TokenInvalid marks an endpoint whose persisted token ciphertext cannot be
|
||||
// decrypted with the current encryption key (key changed or auto-generated
|
||||
// on restart). The endpoint is kept visible for admins but excluded from
|
||||
// runtime use until the token is re-entered or cleared (issue #4887).
|
||||
TokenInvalid bool
|
||||
}
|
||||
|
||||
type ActiveConfig struct {
|
||||
@@ -365,7 +370,23 @@ func (cfg ActiveConfig) EnabledEndpoints() []ActiveEndpoint {
|
||||
return result
|
||||
}
|
||||
|
||||
func PublicFromStorage(cfg storageConfig, riskControlEnabled bool) PublicConfig {
|
||||
// InvalidTokenEndpointIDs lists endpoints whose stored token could not be
|
||||
// decrypted with the current encryption key.
|
||||
func (cfg ActiveConfig) InvalidTokenEndpointIDs() []string {
|
||||
ids := make([]string, 0)
|
||||
for _, ep := range cfg.Endpoints {
|
||||
if ep.TokenInvalid {
|
||||
ids = append(ids, ep.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenEndpointIDs []string) PublicConfig {
|
||||
invalid := make(map[string]struct{}, len(invalidTokenEndpointIDs))
|
||||
for _, id := range invalidTokenEndpointIDs {
|
||||
invalid[id] = struct{}{}
|
||||
}
|
||||
scanners := append([]string{}, cfg.Scanners...)
|
||||
groupIDs := append([]int64{}, cfg.GroupIDs...)
|
||||
endpoints := make([]PublicEndpoint, 0, len(cfg.Endpoints))
|
||||
@@ -374,6 +395,9 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool) PublicConfig
|
||||
status := "missing"
|
||||
if hasToken {
|
||||
status = "configured"
|
||||
if _, ok := invalid[ep.ID]; ok {
|
||||
status = "invalid"
|
||||
}
|
||||
}
|
||||
endpoints = append(endpoints, PublicEndpoint{
|
||||
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL,
|
||||
@@ -402,19 +426,27 @@ func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor Sec
|
||||
}
|
||||
for _, ep := range cfg.Endpoints {
|
||||
token := ""
|
||||
tokenInvalid := false
|
||||
if ep.TokenCiphertext != "" {
|
||||
if encryptor == nil {
|
||||
return ActiveConfig{}, fmt.Errorf("prompt audit secret encryptor unavailable")
|
||||
}
|
||||
plain, err := encryptor.Decrypt(ep.TokenCiphertext)
|
||||
if err != nil {
|
||||
return ActiveConfig{}, fmt.Errorf("decrypt prompt audit endpoint token %q: %w", ep.ID, err)
|
||||
// An undecryptable token (encryption key changed or regenerated)
|
||||
// must not take the whole config down: admins would otherwise be
|
||||
// locked out of the real config version and unable to recover
|
||||
// (issue #4887). Keep the ciphertext persisted, but exclude the
|
||||
// endpoint from runtime use until the token is re-entered.
|
||||
tokenInvalid = true
|
||||
} else {
|
||||
token = plain
|
||||
}
|
||||
token = plain
|
||||
}
|
||||
active.Endpoints = append(active.Endpoints, ActiveEndpoint{
|
||||
ID: ep.ID, Name: ep.Name, Protocol: ep.Protocol, BaseURL: ep.BaseURL, Model: ep.Model,
|
||||
Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit, Enabled: ep.Enabled,
|
||||
Token: token, TimeoutMS: ep.TimeoutMS, InputLimit: ep.InputLimit,
|
||||
Enabled: ep.Enabled && !tokenInvalid, TokenInvalid: tokenInvalid,
|
||||
})
|
||||
}
|
||||
return active, nil
|
||||
|
||||
@@ -148,8 +148,8 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
|
||||
t.Cleanup(func() { require.NoError(t, redisClient.Close()) })
|
||||
require.NoError(t, redisClient.Ping(context.Background()).Err())
|
||||
|
||||
managerOne := NewConfigManager(db, settingRepo, redisClient, encryptor)
|
||||
managerTwo := NewConfigManager(db, settingRepo, redisClient, encryptor)
|
||||
managerOne := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
|
||||
managerTwo := NewConfigManager(db, settingRepo, redisClient, encryptor, testTotpKeyConfig())
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
require.NoError(t, managerOne.Start(ctx))
|
||||
@@ -220,7 +220,7 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
|
||||
|
||||
// A manager without Redis subscriptions must still converge through the
|
||||
// bounded five-second refresh loop.
|
||||
ttlManager := NewConfigManager(db, settingRepo, nil, encryptor)
|
||||
ttlManager := NewConfigManager(db, settingRepo, nil, encryptor, testTotpKeyConfig())
|
||||
require.NoError(t, ttlManager.Start(ctx))
|
||||
t.Cleanup(func() { require.NoError(t, ttlManager.Shutdown(context.Background())) })
|
||||
waitForConfigVersion(t, ttlManager, 3, time.Second)
|
||||
@@ -233,7 +233,7 @@ func TestPromptAuditConfigCASSecretRoundTripInvalidationAndTTL(t *testing.T) {
|
||||
// successfully committed PostgreSQL config.
|
||||
deadRedis := redis.NewClient(&redis.Options{Addr: "127.0.0.1:1", MaxRetries: 0, DialTimeout: 30 * time.Millisecond, ReadTimeout: 30 * time.Millisecond, WriteTimeout: 30 * time.Millisecond})
|
||||
t.Cleanup(func() { _ = deadRedis.Close() })
|
||||
degraded := NewConfigManager(db, settingRepo, deadRedis, encryptor)
|
||||
degraded := NewConfigManager(db, settingRepo, deadRedis, encryptor, testTotpKeyConfig())
|
||||
require.NoError(t, degraded.Reload(context.Background()))
|
||||
degradedSaved, err := degraded.Save(context.Background(), promptAuditUpdateRequest(4, 6, ""), 401)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -29,6 +30,10 @@ type ConfigManager struct {
|
||||
redis *redis.Client
|
||||
encryptor SecretEncryptor
|
||||
clock Clock
|
||||
// encryptionKeyConfigured mirrors cfg.Totp.EncryptionKeyConfigured. With an
|
||||
// auto-generated (per-boot) key, newly saved endpoint tokens would become
|
||||
// undecryptable after the next restart, so Save rejects them (issue #4887).
|
||||
encryptionKeyConfigured bool
|
||||
|
||||
snapshot atomic.Pointer[activeConfigSnapshot]
|
||||
expected atomic.Int64
|
||||
@@ -53,8 +58,11 @@ type ConfigManager struct {
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor) *ConfigManager {
|
||||
return &ConfigManager{db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{}}
|
||||
func NewConfigManager(db *sql.DB, settings service.SettingRepository, redisClient *redis.Client, encryptor service.SecretEncryptor, cfg *config.Config) *ConfigManager {
|
||||
return &ConfigManager{
|
||||
db: db, settings: settings, redis: redisClient, encryptor: encryptor, clock: realClock{},
|
||||
encryptionKeyConfigured: cfg != nil && cfg.Totp.EncryptionKeyConfigured,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ConfigManager) Start(ctx context.Context) error {
|
||||
@@ -125,15 +133,45 @@ func (m *ConfigManager) Reload(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
now := m.clock.Now()
|
||||
previous := m.snapshot.Load()
|
||||
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(storage), active: cloneActiveConfig(active), loadedAt: now})
|
||||
m.configUntrusted.Store(false)
|
||||
m.clearLoadError()
|
||||
m.logInvalidTokenEndpoints(previous, active)
|
||||
LogInfo(EventConfigLoaded, map[string]any{
|
||||
"config_version": storage.ConfigVersion, "status": "loaded",
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// logInvalidTokenEndpoints warns once per change (not on every 5s refresh)
|
||||
// when stored endpoint tokens cannot be decrypted with the current key.
|
||||
func (m *ConfigManager) logInvalidTokenEndpoints(previous *activeConfigSnapshot, active ActiveConfig) {
|
||||
invalid := active.InvalidTokenEndpointIDs()
|
||||
if len(invalid) == 0 {
|
||||
return
|
||||
}
|
||||
if previous != nil {
|
||||
prior := previous.active.InvalidTokenEndpointIDs()
|
||||
if len(prior) == len(invalid) {
|
||||
same := true
|
||||
for i := range invalid {
|
||||
if prior[i] != invalid[i] {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if same && previous.active.ConfigVersion == active.ConfigVersion {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
LogWarn(EventConfigTokenInvalid, map[string]any{
|
||||
"config_version": active.ConfigVersion, "status": "degraded",
|
||||
"error_code": "endpoint_token_undecryptable", "guard_endpoint_id": strings.Join(invalid, ","),
|
||||
})
|
||||
}
|
||||
|
||||
func (m *ConfigManager) Active() (ActiveConfig, bool) {
|
||||
if m == nil {
|
||||
return ActiveConfig{}, false
|
||||
@@ -202,7 +240,7 @@ func (m *ConfigManager) Public() (PublicConfig, error) {
|
||||
if snapshot == nil {
|
||||
return PublicConfig{}, infraerrors.ServiceUnavailable(ErrorCodeConfigUnavailable, "提示词审计配置暂不可用")
|
||||
}
|
||||
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled), nil
|
||||
return PublicFromStorage(cloneStorageConfig(snapshot.storage), snapshot.active.RiskControlEnabled, snapshot.active.InvalidTokenEndpointIDs()), nil
|
||||
}
|
||||
|
||||
func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actorID int64) (PublicConfig, error) {
|
||||
@@ -268,11 +306,13 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor
|
||||
}
|
||||
m.expected.Store(next.ConfigVersion)
|
||||
m.expectedBlocking.Store(active.RiskControlEnabled && next.Enabled && next.BlockingEnabled)
|
||||
previous := m.snapshot.Load()
|
||||
m.snapshot.Store(&activeConfigSnapshot{storage: cloneStorageConfig(next), active: cloneActiveConfig(active), loadedAt: m.clock.Now()})
|
||||
// A successful admin save installs a trustworthy snapshot; clear any prior
|
||||
// fail-closed degradation so disabling audit actually takes effect.
|
||||
m.configUntrusted.Store(false)
|
||||
m.clearLoadError()
|
||||
m.logInvalidTokenEndpoints(previous, active)
|
||||
LogInfo(EventConfigUpdated, map[string]any{
|
||||
"config_version": next.ConfigVersion, "status": "updated",
|
||||
})
|
||||
@@ -283,7 +323,7 @@ func (m *ConfigManager) Save(ctx context.Context, req UpdateConfigRequest, actor
|
||||
})
|
||||
}
|
||||
}
|
||||
return PublicFromStorage(next, active.RiskControlEnabled), nil
|
||||
return PublicFromStorage(next, active.RiskControlEnabled, active.InvalidTokenEndpointIDs()), nil
|
||||
}
|
||||
|
||||
func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfigRequest, actorID int64) (storageConfig, error) {
|
||||
@@ -317,6 +357,10 @@ func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfig
|
||||
case endpoint.ClearToken:
|
||||
stored.TokenCiphertext = ""
|
||||
case strings.TrimSpace(endpoint.Token) != "":
|
||||
if !m.encryptionKeyConfigured {
|
||||
return storageConfig{}, infraerrors.BadRequest(ErrorCodeEncryptionKeyRequired,
|
||||
"未配置固定加密密钥,审计节点 Token 将在服务重启后失效。请先设置 TOTP_ENCRYPTION_KEY 环境变量(64 位十六进制)并重启服务")
|
||||
}
|
||||
ciphertext, err := m.encryptor.Encrypt(strings.TrimSpace(endpoint.Token))
|
||||
if err != nil {
|
||||
return storageConfig{}, fmt.Errorf("encrypt prompt audit endpoint token: %w", err)
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -13,7 +15,18 @@ import (
|
||||
type prefixEncryptor struct{}
|
||||
|
||||
func (prefixEncryptor) Encrypt(value string) (string, error) { return "enc:" + value, nil }
|
||||
func (prefixEncryptor) Decrypt(value string) (string, error) { return value[4:], nil }
|
||||
func (prefixEncryptor) Decrypt(value string) (string, error) {
|
||||
if !strings.HasPrefix(value, "enc:") {
|
||||
return "", errors.New("cipher: message authentication failed")
|
||||
}
|
||||
return value[4:], nil
|
||||
}
|
||||
|
||||
// testTotpKeyConfig mirrors a deployment with a fixed TOTP_ENCRYPTION_KEY so
|
||||
// unit tests may persist endpoint tokens.
|
||||
func testTotpKeyConfig() *config.Config {
|
||||
return &config.Config{Totp: config.TotpConfig{EncryptionKeyConfigured: true}}
|
||||
}
|
||||
|
||||
func TestDefaultConfigIsOff(t *testing.T) {
|
||||
storage, err := ParseStorageConfig("")
|
||||
@@ -23,7 +36,7 @@ func TestDefaultConfigIsOff(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ModeOff, active.EffectiveMode())
|
||||
require.Equal(t, AllScannerIDs, storage.Scanners)
|
||||
publicJSON, err := json.Marshal(PublicFromStorage(storage, true))
|
||||
publicJSON, err := json.Marshal(PublicFromStorage(storage, true, nil))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(publicJSON), `"group_ids":[]`)
|
||||
require.Contains(t, string(publicJSON), `"endpoints":[]`)
|
||||
@@ -38,7 +51,7 @@ func TestConfigRejectsBlockingWithoutAudit(t *testing.T) {
|
||||
func TestPublicConfigNeverMarshalsToken(t *testing.T) {
|
||||
storage := DefaultStorageConfig()
|
||||
storage.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "GUARD_TOKEN_CANARY_SECRET", TimeoutMS: 1000, InputLimit: 1000, Enabled: true}}
|
||||
public := PublicFromStorage(storage, true)
|
||||
public := PublicFromStorage(storage, true, nil)
|
||||
raw, err := json.Marshal(public)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "GUARD_TOKEN_CANARY_SECRET")
|
||||
@@ -61,7 +74,7 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
|
||||
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
|
||||
SettingKeyPromptAuditConfig: "",
|
||||
SettingKeyRiskControl: "false",
|
||||
}}, nil, prefixEncryptor{})
|
||||
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
|
||||
public, err := manager.Public()
|
||||
@@ -70,12 +83,14 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
|
||||
require.False(t, public.Enabled)
|
||||
})
|
||||
|
||||
t.Run("persisted config activation failure is unavailable", func(t *testing.T) {
|
||||
t.Run("unparseable persisted config is unavailable", func(t *testing.T) {
|
||||
const canary = "persisted-token-canary"
|
||||
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
|
||||
// Endpoint without id/name fails validation, so no trustworthy
|
||||
// snapshot can be installed from this raw value.
|
||||
SettingKeyPromptAuditConfig: `{"enabled":true,"config_version":9,"endpoints":[{"token_ciphertext":"` + canary + `"}]}`,
|
||||
SettingKeyRiskControl: "true",
|
||||
}}, nil, prefixEncryptor{})
|
||||
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
require.Error(t, manager.Reload(context.Background()))
|
||||
|
||||
public, err := manager.Public()
|
||||
@@ -95,7 +110,7 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
|
||||
SettingKeyPromptAuditConfig: string(raw),
|
||||
SettingKeyRiskControl: "false",
|
||||
}}}
|
||||
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
|
||||
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
repository.loadErr = errors.New("settings unavailable")
|
||||
require.Error(t, manager.Reload(context.Background()))
|
||||
@@ -107,8 +122,66 @@ func TestConfigManagerPublicRequiresSuccessfullyLoadedSnapshot(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Regression coverage for issue #4887: a persisted config whose endpoint token
|
||||
// can no longer be decrypted (encryption key changed or auto-generated per
|
||||
// boot) must stay visible and editable for admins instead of falling back to a
|
||||
// default v1 config that makes every save fail the CAS version check.
|
||||
func TestConfigManagerUndecryptableTokenKeepsConfigVisibleAndRecoverable(t *testing.T) {
|
||||
const canary = "persisted-token-canary"
|
||||
persisted := `{"enabled":true,"blocking_enabled":false,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"` + canary + `","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
|
||||
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
|
||||
SettingKeyPromptAuditConfig: persisted,
|
||||
SettingKeyRiskControl: "true",
|
||||
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
require.NoError(t, manager.Reload(context.Background()), "an undecryptable token must not fail the whole config load")
|
||||
|
||||
public, err := manager.Public()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(9), public.ConfigVersion, "admins must see the real persisted version so CAS saves can succeed")
|
||||
require.Len(t, public.Endpoints, 1)
|
||||
require.True(t, public.Endpoints[0].HasToken)
|
||||
require.Equal(t, "invalid", public.Endpoints[0].TokenStatus)
|
||||
raw, err := json.Marshal(public)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), canary)
|
||||
|
||||
active, ok := manager.Active()
|
||||
require.True(t, ok)
|
||||
require.Len(t, active.Endpoints, 1)
|
||||
require.False(t, active.Endpoints[0].Enabled, "an endpoint with an undecryptable token must not be used at runtime")
|
||||
require.True(t, active.Endpoints[0].TokenInvalid)
|
||||
require.Empty(t, active.Endpoints[0].Token)
|
||||
require.Empty(t, active.EnabledEndpoints())
|
||||
require.Equal(t, []string{"g1"}, active.InvalidTokenEndpointIDs())
|
||||
|
||||
expected, activeVersion, _, _ := manager.RuntimeState()
|
||||
require.Equal(t, int64(9), expected)
|
||||
require.Equal(t, int64(9), activeVersion)
|
||||
}
|
||||
|
||||
func TestConfigManagerUndecryptableTokenStillFailsClosedForBlockingIntent(t *testing.T) {
|
||||
persisted := `{"enabled":true,"blocking_enabled":true,"config_version":9,"endpoints":[{"id":"g1","name":"Guard","protocol":"openai_compatible","base_url":"http://127.0.0.1:8080","model":"m","token_ciphertext":"undecryptable","timeout_ms":1000,"input_limit":1000,"enabled":true}]}`
|
||||
manager := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
|
||||
SettingKeyPromptAuditConfig: persisted,
|
||||
SettingKeyRiskControl: "true",
|
||||
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
require.NoError(t, manager.Reload(context.Background()))
|
||||
require.Equal(t, ModeBlocking, manager.EffectiveMode())
|
||||
|
||||
service := &PromptService{config: manager, evaluator: NewGuardEvaluator(NewOpenAICompatibleScanner(), nil, nil)}
|
||||
decision, err := service.Evaluate(context.Background(), Request{
|
||||
Protocol: "openai_chat_completions",
|
||||
Body: []byte(`{"messages":[{"role":"user","content":"hi"}]}`),
|
||||
})
|
||||
require.Error(t, err, "blocking intent with no usable endpoint must not let requests pass unaudited")
|
||||
require.Nil(t, decision)
|
||||
var guardErr *GuardError
|
||||
require.ErrorAs(t, err, &guardErr)
|
||||
require.Equal(t, ErrorCodeUnavailable, guardErr.Code)
|
||||
}
|
||||
|
||||
func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
|
||||
manager := &ConfigManager{encryptor: prefixEncryptor{}}
|
||||
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
|
||||
current := DefaultStorageConfig()
|
||||
current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
|
||||
base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
|
||||
@@ -130,6 +203,37 @@ func TestBuildNextStoragePreserveReplaceAndClearToken(t *testing.T) {
|
||||
require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
|
||||
}
|
||||
|
||||
// Without a fixed encryption key the per-boot auto-generated key would make a
|
||||
// freshly saved token undecryptable after the next restart (issue #4887), so
|
||||
// saving a new token must be rejected with an actionable error. Preserving or
|
||||
// clearing an existing ciphertext stays allowed so admins can still edit or
|
||||
// disable the feature.
|
||||
func TestBuildNextStorageRejectsNewTokenWithoutConfiguredEncryptionKey(t *testing.T) {
|
||||
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: false}
|
||||
current := DefaultStorageConfig()
|
||||
current.Endpoints = []StorageEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", Model: DefaultGuardModel, TokenCiphertext: "enc:old", TimeoutMS: 1000, InputLimit: 1000}}
|
||||
base := UpdateConfigRequest{ExpectedConfigVersion: 1, Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"PII"}, AllGroups: true,
|
||||
Endpoints: []UpdateEndpoint{{ID: "one", Name: "One", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080", TimeoutMS: 1000, InputLimit: 1000}}}
|
||||
|
||||
newTokenReq := base
|
||||
newTokenReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
|
||||
newTokenReq.Endpoints[0].Token = "fresh-token"
|
||||
_, err := manager.buildNextStorage(current, newTokenReq, 9)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, ErrorCodeEncryptionKeyRequired, infraerrors.Reason(err))
|
||||
|
||||
preserved, err := manager.buildNextStorage(current, base, 9)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "enc:old", preserved.Endpoints[0].TokenCiphertext)
|
||||
|
||||
clearedReq := base
|
||||
clearedReq.Endpoints = append([]UpdateEndpoint(nil), base.Endpoints...)
|
||||
clearedReq.Endpoints[0].ClearToken = true
|
||||
cleared, err := manager.buildNextStorage(current, clearedReq, 9)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, cleared.Endpoints[0].TokenCiphertext)
|
||||
}
|
||||
|
||||
func TestEffectiveModeTruthTable(t *testing.T) {
|
||||
tests := []struct {
|
||||
risk, enabled, blocking bool
|
||||
@@ -203,7 +307,7 @@ func (r *switchableSettingRepository) GetMultiple(ctx context.Context, keys []st
|
||||
func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *testing.T) {
|
||||
// Settings unavailable and no prior blocking intent: stay ModeOff so the
|
||||
// gateway remains usable and admins can still disable/configure Prompt Audit.
|
||||
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{})
|
||||
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
err := manager.Start(context.Background())
|
||||
require.Error(t, err)
|
||||
require.True(t, manager.configUntrusted.Load())
|
||||
@@ -222,7 +326,7 @@ func TestConfigManagerStartupLoadFailureDoesNotBlockWhenBlockingNotIntended(t *t
|
||||
}
|
||||
|
||||
func TestConfigManagerStartupLoadFailureFailsClosedWhenBlockingIntended(t *testing.T) {
|
||||
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{})
|
||||
manager := NewConfigManager(nil, errorSettingRepository{}, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
// Simulate intent observed before a later load failure (e.g. decrypt error).
|
||||
manager.observeExpectedState(`{"enabled":true,"blocking_enabled":true,"config_version":3}`, true)
|
||||
manager.markConfigUntrusted()
|
||||
|
||||
@@ -161,7 +161,7 @@ func TestPromptAdminConfigRequiresVersionMapsConflictAndNeverEchoesToken(t *test
|
||||
func TestPromptAdminGetConfigReturnsSecretFreeUnavailableError(t *testing.T) {
|
||||
const canary = "persisted-config-secret-canary"
|
||||
repository := &switchableSettingRepository{loadErr: errors.New("failed to load token " + canary)}
|
||||
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{})
|
||||
manager := NewConfigManager(nil, repository, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
require.Error(t, manager.Reload(context.Background()))
|
||||
service := &PromptService{config: manager}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const (
|
||||
EventConfigUpdated = "prompt_audit.config_updated"
|
||||
EventConfigLoaded = "prompt_guard.config_loaded"
|
||||
EventConfigReloadDegraded = "prompt_guard.config_reload_degraded"
|
||||
EventConfigTokenInvalid = "prompt_guard.config_token_invalid"
|
||||
EventProbeStarted = "prompt_audit.endpoint_probe_started"
|
||||
EventProbeFinished = "prompt_audit.endpoint_probe_finished"
|
||||
EventProbeFailed = "prompt_audit.endpoint_probe_failed"
|
||||
@@ -37,7 +38,7 @@ const (
|
||||
)
|
||||
|
||||
var knownLogEvents = map[string]struct{}{
|
||||
EventConfigUpdated: {}, EventConfigLoaded: {}, EventConfigReloadDegraded: {},
|
||||
EventConfigUpdated: {}, EventConfigLoaded: {}, EventConfigReloadDegraded: {}, EventConfigTokenInvalid: {},
|
||||
EventProbeStarted: {}, EventProbeFinished: {}, EventProbeFailed: {},
|
||||
EventJobEnqueued: {}, EventEnqueueSkipped: {}, EventEnqueueDropped: {},
|
||||
EventAuditStarted: {}, EventProcessingReclaimed: {}, EventProcessed: {}, EventProcessFailed: {}, EventFindingRecorded: {},
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestPromptAuditLogAllowlistAndErrorsDoNotLeakCanarySecrets(t *testing.T) {
|
||||
beforeUnknown := output.Len()
|
||||
LogWarn("prompt_audit.typo_event", map[string]any{"status": "failed"})
|
||||
require.Equal(t, beforeUnknown, output.Len(), "events outside the stable dictionary must not be emitted")
|
||||
require.Len(t, knownLogEvents, 27)
|
||||
require.Len(t, knownLogEvents, 28)
|
||||
|
||||
_, err := NormalizeBaseURL("https://guard.example.test/path?token=" + canary)
|
||||
require.Error(t, err)
|
||||
|
||||
@@ -86,19 +86,13 @@ func NormalizeCategory(value string) string {
|
||||
}
|
||||
|
||||
func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResult, error) {
|
||||
lines := make([]string, 0, 2)
|
||||
for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
if len(lines) != 2 {
|
||||
return nil, &GuardError{Code: ErrorCodeInvalidResponse, Retryable: false}
|
||||
}
|
||||
var safety string
|
||||
var categoryLine string
|
||||
for _, line := range lines {
|
||||
for _, line := range strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.HasPrefix(lower, "safety:"):
|
||||
@@ -112,7 +106,7 @@ func ParseQwen3Guard(content string, enabledScanners []string) (*NormalizedResul
|
||||
}
|
||||
categoryLine = strings.TrimSpace(line[len("categories:"):])
|
||||
default:
|
||||
return nil, &GuardError{Code: ErrorCodeInvalidResponse}
|
||||
// Auxiliary Guard fields, such as Refusal, do not affect audit decisions.
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(safety) {
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) {
|
||||
{"unsafe", "Safety: Unsafe\nCategories: Jailbreak", AllScannerIDs, EventCritical, ActionBlock, false},
|
||||
{"unknown unsafe", "Safety: Unsafe\nCategories: Future Risk", AllScannerIDs, EventCritical, ActionBlock, false},
|
||||
{"disabled unsafe warns", "Safety: Unsafe\nCategories: Violent", []string{"PII"}, EventFlag, ActionWarn, false},
|
||||
{"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, "", "", true},
|
||||
{"extra explanation", "Safety: Safe\nCategories: None\nThis is safe", AllScannerIDs, EventPass, ActionAllow, false},
|
||||
{"duplicate", "Safety: Safe\nSafety: Safe", AllScannerIDs, "", "", true},
|
||||
{"duplicate categories", "Safety: Safe\nCategories: None\nCategories: PII", AllScannerIDs, "", "", true},
|
||||
{"missing categories", "Safety: Safe\n", AllScannerIDs, "", "", true},
|
||||
@@ -42,6 +42,18 @@ func TestParseQwen3GuardStrictAndPolicy(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQwen3GuardIgnoresAuxiliaryResponseFields(t *testing.T) {
|
||||
result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: Jailbreak\nRefusal: No", AllScannerIDs)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Unsafe", result.Safety)
|
||||
require.Equal(t, []string{"jailbreak"}, result.Categories)
|
||||
|
||||
serialized, err := json.Marshal(result)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(serialized), "Refusal")
|
||||
require.NotContains(t, string(serialized), "No")
|
||||
}
|
||||
|
||||
func TestQwen3GuardOfficialCategoriesAliasesAndUnknownAreStable(t *testing.T) {
|
||||
official := "Violent, Non-violent Illegal Acts, Sexual Content or Sexual Acts, PII, Suicide & Self-Harm, Unethical Acts, Politically Sensitive Topics, Copyright Violation, Jailbreak"
|
||||
result, err := ParseQwen3Guard("Safety: Unsafe\nCategories: "+official, AllScannerIDs)
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestPromptServiceHasExplicitIdempotentLifecycle(t *testing.T) {
|
||||
config := NewConfigManager(nil, staticSettingRepository{values: map[string]string{
|
||||
SettingKeyPromptAuditConfig: "",
|
||||
SettingKeyRiskControl: "false",
|
||||
}}, nil, prefixEncryptor{})
|
||||
}}, nil, prefixEncryptor{}, testTotpKeyConfig())
|
||||
service := NewPromptService(
|
||||
config,
|
||||
NewPostgreSQLRepository(nil),
|
||||
|
||||
@@ -12,12 +12,13 @@ const (
|
||||
ConfigInvalidationChannel = "sub2api:prompt_guard:config:invalidate"
|
||||
PayloadKeyPrefix = "sub2api:prompt_audit:payload:"
|
||||
|
||||
ErrorCodeBlocked = "prompt_guard_blocked"
|
||||
ErrorCodeUnavailable = "prompt_guard_unavailable"
|
||||
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
|
||||
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
|
||||
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
|
||||
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
|
||||
ErrorCodeBlocked = "prompt_guard_blocked"
|
||||
ErrorCodeUnavailable = "prompt_guard_unavailable"
|
||||
ErrorCodeInvalidResponse = "prompt_guard_invalid_response"
|
||||
ErrorCodeConfigConflict = "prompt_audit_config_conflict"
|
||||
ErrorCodeConfigUnavailable = "prompt_audit_config_unavailable"
|
||||
ErrorCodeEncryptionKeyRequired = "prompt_audit_encryption_key_required"
|
||||
ErrorCodeRequiresEnabled = "prompt_guard_requires_audit_enabled"
|
||||
|
||||
DefaultGuardModel = "sileader/qwen3guard:0.6b"
|
||||
)
|
||||
|
||||
@@ -711,6 +711,10 @@ func TestAPIContracts(t *testing.T) {
|
||||
"frontend_url": "",
|
||||
"totp_enabled": false,
|
||||
"totp_encryption_key_configured": false,
|
||||
"passkey_enabled": false,
|
||||
"passkey_configured": false,
|
||||
"passkey_rp_id": "",
|
||||
"passkey_rp_origins": [],
|
||||
"session_binding_enabled": false,
|
||||
"step_up_enabled": false,
|
||||
"audit_log_retention_days": 180,
|
||||
@@ -954,6 +958,9 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_enabled": true,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"model_plaza_enabled": false,
|
||||
"model_plaza_require_auth": false,
|
||||
"model_plaza_description": "",
|
||||
"risk_control_enabled": false,
|
||||
"cyber_session_block_enabled": false,
|
||||
"cyber_session_block_ttl_seconds": 3600,
|
||||
@@ -1028,6 +1035,10 @@ func TestAPIContracts(t *testing.T) {
|
||||
"invitation_code_enabled": false,
|
||||
"totp_enabled": false,
|
||||
"totp_encryption_key_configured": false,
|
||||
"passkey_enabled": false,
|
||||
"passkey_configured": false,
|
||||
"passkey_rp_id": "",
|
||||
"passkey_rp_origins": [],
|
||||
"session_binding_enabled": false,
|
||||
"step_up_enabled": false,
|
||||
"audit_log_retention_days": 180,
|
||||
@@ -1234,6 +1245,9 @@ func TestAPIContracts(t *testing.T) {
|
||||
"channel_monitor_enabled": true,
|
||||
"channel_monitor_default_interval_seconds": 60,
|
||||
"available_channels_enabled": false,
|
||||
"model_plaza_enabled": false,
|
||||
"model_plaza_require_auth": false,
|
||||
"model_plaza_description": "",
|
||||
"risk_control_enabled": false,
|
||||
"cyber_session_block_enabled": false,
|
||||
"cyber_session_block_ttl_seconds": 3600,
|
||||
@@ -1528,7 +1542,7 @@ func (r *stubUserRepo) GetFirstAdmin(ctx context.Context) (*service.User, error)
|
||||
return nil, service.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) Update(ctx context.Context, user *service.User) error {
|
||||
func (r *stubUserRepo) Update(ctx context.Context, user *service.User, fields service.UserUpdateFields) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
@@ -1564,6 +1578,14 @@ func (r *stubUserRepo) DeductBalance(ctx context.Context, id int64, amount float
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
|
||||
return service.BalanceChange{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
|
||||
return service.BalanceChange{}, errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (r *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
@@ -2274,7 +2296,7 @@ func (r *stubApiKeyRepo) GetByKeyForAuth(ctx context.Context, key string) (*serv
|
||||
return r.GetByKey(ctx, key)
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
|
||||
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey, _ service.APIKeyUpdateFields) error {
|
||||
if key == nil {
|
||||
return errors.New("nil key")
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ func ProvideRouter(
|
||||
cfg *config.Config,
|
||||
handlers *handler.Handlers,
|
||||
jwtAuth middleware2.JWTAuthMiddleware,
|
||||
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
|
||||
adminAuth middleware2.AdminAuthMiddleware,
|
||||
apiKeyAuth middleware2.APIKeyAuthMiddleware,
|
||||
auditLog middleware2.AuditLogMiddleware,
|
||||
@@ -86,7 +87,7 @@ func ProvideRouter(
|
||||
service.SetWebSearchManager(websearch.NewManager(configs, redisClient))
|
||||
})
|
||||
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
|
||||
return SetupRouter(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
|
||||
}
|
||||
|
||||
func configureTrustedProxies(r *gin.Engine, cfg config.ServerConfig) {
|
||||
|
||||
@@ -150,7 +150,7 @@ func (s *stubUserRepo) GetFirstAdmin(ctx context.Context) (*service.User, error)
|
||||
panic("unexpected GetFirstAdmin call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) Update(ctx context.Context, user *service.User) error {
|
||||
func (s *stubUserRepo) Update(ctx context.Context, user *service.User, fields service.UserUpdateFields) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
|
||||
@@ -198,6 +198,14 @@ func (s *stubUserRepo) DeductBalance(ctx context.Context, id int64, amount float
|
||||
panic("unexpected DeductBalance call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
|
||||
panic("unexpected AdjustBalance call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
|
||||
panic("unexpected SetBalance call")
|
||||
}
|
||||
|
||||
func (s *stubUserRepo) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
|
||||
panic("unexpected UpdateConcurrency call")
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ func (f fakeAPIKeyRepo) GetByKey(ctx context.Context, key string) (*service.APIK
|
||||
func (f fakeAPIKeyRepo) GetByKeyForAuth(ctx context.Context, key string) (*service.APIKey, error) {
|
||||
return f.GetByKey(ctx, key)
|
||||
}
|
||||
func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
|
||||
func (f fakeAPIKeyRepo) Update(ctx context.Context, key *service.APIKey, _ service.APIKeyUpdateFields) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
func (f fakeAPIKeyRepo) Delete(ctx context.Context, id int64) error {
|
||||
|
||||
@@ -1550,7 +1550,7 @@ func (r *stubApiKeyRepo) GetByKeyForAuth(ctx context.Context, key string) (*serv
|
||||
return r.GetByKey(ctx, key)
|
||||
}
|
||||
|
||||
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey) error {
|
||||
func (r *stubApiKeyRepo) Update(ctx context.Context, key *service.APIKey, _ service.APIKeyUpdateFields) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ var auditSensitiveReads = map[string]string{
|
||||
var auditActionOverrides = map[string]string{
|
||||
"POST /api/v1/auth/login": service.AuditActionLogin,
|
||||
"POST /api/v1/auth/login/2fa": service.AuditActionLogin2FA,
|
||||
"POST /api/v1/auth/passkey/login/finish": service.AuditActionLogin,
|
||||
"POST /api/v1/auth/register": service.AuditActionRegister,
|
||||
"POST /api/v1/auth/refresh": service.AuditActionTokenRefresh,
|
||||
"POST /api/v1/user/totp/step-up": service.AuditActionStepUpVerify,
|
||||
@@ -146,6 +147,8 @@ var auditActionOverrides = map[string]string{
|
||||
// auditBodyOmittedRoutes 请求体几乎整体由凭证构成的路由(如整块粘贴 auth JSON 的导入接口)。
|
||||
// 这类 body 的凭证内嵌在普通字符串值里,键级脱敏无法覆盖,整体不入库。
|
||||
var auditBodyOmittedRoutes = map[string]struct{}{
|
||||
"POST /api/v1/auth/passkey/login/finish": {},
|
||||
"POST /api/v1/user/passkeys/register/finish": {},
|
||||
"POST /api/v1/admin/accounts/import/codex-session": {},
|
||||
"PUT /api/v1/admin/accounts/:id/ollama-cloud-usage/session": {},
|
||||
"PUT /api/v1/admin/prompt-audit/config": {},
|
||||
|
||||
@@ -146,6 +146,12 @@ func TestPromptAuditMutationAuditRoutesHaveStableActionsAndOmitBodies(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasskeyLoginAuditUsesCanonicalLoginActionAndOmitsCredentialBody(t *testing.T) {
|
||||
route := "POST /api/v1/auth/passkey/login/finish"
|
||||
require.Equal(t, service.AuditActionLogin, auditActionOverrides[route])
|
||||
require.Contains(t, auditBodyOmittedRoutes, route)
|
||||
}
|
||||
|
||||
// Ollama 会话保存的请求体整体就是浏览器 Cookie 明文,键级脱敏清单曾漏掉裸键
|
||||
// "session",必须走整体不入库路径,防止会话凭证长期留存在 audit_logs。
|
||||
func TestOllamaCloudUsageSessionRouteOmitsAuditBody(t *testing.T) {
|
||||
|
||||
@@ -29,7 +29,14 @@ func BackendModeUserGuard(settingService *service.SettingService) gin.HandlerFun
|
||||
|
||||
func backendModeAllowsAuthPath(path string) bool {
|
||||
path = strings.ToLower(strings.TrimSpace(path))
|
||||
for _, suffix := range []string{"/auth/login", "/auth/login/2fa", "/auth/logout", "/auth/refresh"} {
|
||||
for _, suffix := range []string{
|
||||
"/auth/login",
|
||||
"/auth/login/2fa",
|
||||
"/auth/passkey/login/begin",
|
||||
"/auth/passkey/login/finish",
|
||||
"/auth/logout",
|
||||
"/auth/refresh",
|
||||
} {
|
||||
if strings.HasSuffix(path, suffix) {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewOptionalJWTAuthMiddleware 创建可选 JWT 认证中间件。
|
||||
//
|
||||
// 无 Authorization header 时直接放行(匿名,context 中不设置 AuthSubject);
|
||||
// 带 header 则委托严格 JWT 校验(token 版本 / 用户状态 / 会话绑定),失败返回 401——
|
||||
// 前端 API client 对 401 会自动走 refresh-token 重试,因此不做静默降级。
|
||||
func NewOptionalJWTAuthMiddleware(
|
||||
authService *service.AuthService,
|
||||
userService *service.UserService,
|
||||
settingService *service.SettingService,
|
||||
auditService *service.AuditLogService,
|
||||
) OptionalJWTAuthMiddleware {
|
||||
strict := jwtAuth(authService, userService, userService, settingService, auditService)
|
||||
return OptionalJWTAuthMiddleware(func(c *gin.Context) {
|
||||
if strings.TrimSpace(c.GetHeader("Authorization")) == "" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
strict(c)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build unit
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newOptionalJWTTestEnv 创建 OptionalJWT 中间件测试环境。
|
||||
// handler 回写「是否携带 AuthSubject」,便于断言匿名 vs 登录两种路径。
|
||||
func newOptionalJWTTestEnv(users map[int64]*service.User) (*gin.Engine, *service.AuthService) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
cfg := &config.Config{}
|
||||
cfg.JWT.Secret = "test-jwt-secret-32bytes-long!!!"
|
||||
cfg.JWT.AccessTokenExpireMinutes = 60
|
||||
|
||||
userRepo := &stubJWTUserRepo{users: users}
|
||||
authSvc := service.NewAuthService(nil, userRepo, nil, nil, cfg, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
userSvc := service.NewUserService(userRepo, nil, nil, nil)
|
||||
mw := NewOptionalJWTAuthMiddleware(authSvc, userSvc, nil, nil)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.HandlerFunc(mw))
|
||||
r.GET("/plaza", func(c *gin.Context) {
|
||||
subject, authed := GetAuthSubjectFromContext(c)
|
||||
c.JSON(http.StatusOK, gin.H{"authed": authed, "user_id": subject.UserID})
|
||||
})
|
||||
return r, authSvc
|
||||
}
|
||||
|
||||
func TestOptionalJWTAuth_NoHeaderPassesAnonymously(t *testing.T) {
|
||||
router, _ := newOptionalJWTTestEnv(nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"authed":false`)
|
||||
}
|
||||
|
||||
func TestOptionalJWTAuth_ValidTokenSetsSubject(t *testing.T) {
|
||||
user := &service.User{
|
||||
ID: 7,
|
||||
Email: "plaza@example.com",
|
||||
Role: "user",
|
||||
Status: service.StatusActive,
|
||||
Concurrency: 5,
|
||||
TokenVersion: 1,
|
||||
}
|
||||
router, authSvc := newOptionalJWTTestEnv(map[int64]*service.User{7: user})
|
||||
|
||||
token, err := authSvc.GenerateToken(context.Background(), user)
|
||||
require.NoError(t, err)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"authed":true`)
|
||||
require.Contains(t, w.Body.String(), `"user_id":7`)
|
||||
}
|
||||
|
||||
func TestOptionalJWTAuth_InvalidTokenRejected401(t *testing.T) {
|
||||
// 带了 header 就必须通过严格校验:坏 token 返回 401 而非静默降级为匿名,
|
||||
// 前端 401 拦截器会走 refresh-token 重试。
|
||||
router, _ := newOptionalJWTTestEnv(nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
|
||||
req.Header.Set("Authorization", "Bearer not-a-real-token")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
}
|
||||
|
||||
func TestOptionalJWTAuth_BlankHeaderTreatedAsAnonymous(t *testing.T) {
|
||||
// 空白 header(如 "Authorization: ")按匿名处理,不进入严格校验。
|
||||
router, _ := newOptionalJWTTestEnv(nil)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/plaza", nil)
|
||||
req.Header.Set("Authorization", " ")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Contains(t, w.Body.String(), `"authed":false`)
|
||||
}
|
||||
@@ -8,6 +8,9 @@ import (
|
||||
// JWTAuthMiddleware JWT 认证中间件类型
|
||||
type JWTAuthMiddleware gin.HandlerFunc
|
||||
|
||||
// OptionalJWTAuthMiddleware 可选 JWT 认证中间件类型:匿名放行,带 token 严格校验
|
||||
type OptionalJWTAuthMiddleware gin.HandlerFunc
|
||||
|
||||
// AdminAuthMiddleware 管理员认证中间件类型
|
||||
type AdminAuthMiddleware gin.HandlerFunc
|
||||
|
||||
@@ -17,6 +20,7 @@ type APIKeyAuthMiddleware gin.HandlerFunc
|
||||
// ProviderSet 中间件层的依赖注入
|
||||
var ProviderSet = wire.NewSet(
|
||||
NewJWTAuthMiddleware,
|
||||
NewOptionalJWTAuthMiddleware,
|
||||
NewAdminAuthMiddleware,
|
||||
NewAPIKeyAuthMiddleware,
|
||||
NewAuditLogMiddleware,
|
||||
|
||||
@@ -24,6 +24,7 @@ func SetupRouter(
|
||||
r *gin.Engine,
|
||||
handlers *handler.Handlers,
|
||||
jwtAuth middleware2.JWTAuthMiddleware,
|
||||
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
|
||||
adminAuth middleware2.AdminAuthMiddleware,
|
||||
apiKeyAuth middleware2.APIKeyAuthMiddleware,
|
||||
auditLog middleware2.AuditLogMiddleware,
|
||||
@@ -71,8 +72,8 @@ func SetupRouter(
|
||||
|
||||
// Serve embedded frontend with settings injection if available
|
||||
if web.HasEmbeddedFrontend() {
|
||||
frontendServer, err := web.NewFrontendServer(settingService)
|
||||
if err != nil {
|
||||
frontendServer, err := web.NewFrontendServer(settingService) //nolint:staticcheck // SA4023: the !embed stub always errors; embed builds can return nil
|
||||
if err != nil { //nolint:staticcheck // SA4023: see above
|
||||
log.Printf("Warning: Failed to create frontend server with settings injection: %v, using legacy mode", err)
|
||||
r.Use(web.ServeEmbeddedFrontend())
|
||||
settingService.SetOnUpdateCallback(refreshFrameOrigins)
|
||||
@@ -89,7 +90,7 @@ func SetupRouter(
|
||||
}
|
||||
|
||||
// 注册路由
|
||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
|
||||
registerRoutes(r, handlers, jwtAuth, optionalJWTAuth, adminAuth, apiKeyAuth, auditLog, stepUpAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg, redisClient)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -99,6 +100,7 @@ func registerRoutes(
|
||||
r *gin.Engine,
|
||||
h *handler.Handlers,
|
||||
jwtAuth middleware2.JWTAuthMiddleware,
|
||||
optionalJWTAuth middleware2.OptionalJWTAuthMiddleware,
|
||||
adminAuth middleware2.AdminAuthMiddleware,
|
||||
apiKeyAuth middleware2.APIKeyAuthMiddleware,
|
||||
auditLog middleware2.AuditLogMiddleware,
|
||||
@@ -124,6 +126,7 @@ func registerRoutes(
|
||||
// 注册各模块路由
|
||||
routes.RegisterAuthRoutes(v1, h, jwtAuth, auditLog, redisClient, settingService, panelRateLimiter)
|
||||
routes.RegisterUserRoutes(v1, h, jwtAuth, auditLog, settingService, panelRateLimiter)
|
||||
routes.RegisterModelPlazaRoutes(v1, h, optionalJWTAuth, settingService, panelRateLimiter)
|
||||
routes.RegisterAdminRoutes(v1, h, adminAuth, auditLog, stepUpAuth, settingService, panelRateLimiter)
|
||||
routes.RegisterGatewayRoutes(r, h, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, compositeResolver, cfg)
|
||||
routes.RegisterPaymentRoutes(v1, h.Payment, h.PaymentWebhook, h.Admin.Payment, jwtAuth, adminAuth, auditLog, settingService, panelRateLimiter)
|
||||
|
||||
@@ -41,6 +41,12 @@ func RegisterAuthRoutes(
|
||||
auth.POST("/login/2fa", rateLimiter.LimitWithOptions("auth-login-2fa", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.Login2FA)
|
||||
auth.POST("/passkey/login/begin", rateLimiter.LimitWithOptions("passkey-login-begin", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Passkey.BeginLogin)
|
||||
auth.POST("/passkey/login/finish", rateLimiter.LimitWithOptions("passkey-login-finish", 20, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Passkey.FinishLogin)
|
||||
auth.POST("/send-verify-code", rateLimiter.LimitWithOptions("auth-send-verify-code", 5, time.Minute, middleware.RateLimitOptions{
|
||||
FailureMode: middleware.RateLimitFailClose,
|
||||
}), h.Auth.SendVerifyCode)
|
||||
|
||||
@@ -153,6 +153,25 @@ func RegisterGatewayRoutes(
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalFeatureGate)
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{"type": "not_found_error", "message": "Videos API is not supported for this platform"}})
|
||||
}
|
||||
// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此在入口就拒掉
|
||||
// 不可转发的子路径,不让它进入调度与转发流程。可转发的判定见
|
||||
// service.IsForwardableOpenAIResponsesRequestPath 及 upstream_path_guard.go。
|
||||
guardResponsesSubpath := func(next gin.HandlerFunc) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !service.IsForwardableOpenAIResponsesRequestPath(c) {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{
|
||||
"error": gin.H{
|
||||
"type": "not_found_error",
|
||||
"message": "Unsupported responses subpath",
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
next(c)
|
||||
}
|
||||
}
|
||||
|
||||
// API网关(Claude API兼容)
|
||||
gateway := r.Group("/v1")
|
||||
gateway.Use(bodyLimit)
|
||||
@@ -190,13 +209,13 @@ func RegisterGatewayRoutes(
|
||||
}
|
||||
h.Gateway.Responses(c)
|
||||
})
|
||||
gateway.POST("/responses/*subpath", func(c *gin.Context) {
|
||||
gateway.POST("/responses/*subpath", guardResponsesSubpath(func(c *gin.Context) {
|
||||
if isOpenAIResponsesCompatibleGatewayPlatform(c) {
|
||||
h.OpenAIGateway.Responses(c)
|
||||
return
|
||||
}
|
||||
h.Gateway.Responses(c)
|
||||
})
|
||||
}))
|
||||
gateway.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
|
||||
gateway.GET("/responses", func(c *gin.Context) {
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
@@ -269,7 +288,7 @@ func RegisterGatewayRoutes(
|
||||
h.Gateway.Responses(c)
|
||||
}
|
||||
r.POST("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
|
||||
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, responsesHandler)
|
||||
r.POST("/responses/*subpath", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, guardResponsesSubpath(responsesHandler))
|
||||
r.POST("/alpha/search", textBodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, h.OpenAIGateway.AlphaSearch)
|
||||
r.GET("/responses", bodyLimit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), compositeTarget, requireGroupAnthropic, func(c *gin.Context) {
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
@@ -282,7 +301,7 @@ func RegisterGatewayRoutes(
|
||||
codexDirect.POST("/realtime/calls", h.OpenAIGateway.Live)
|
||||
codexDirect.GET("/:call_id", h.OpenAIGateway.LiveSideband)
|
||||
codexDirect.POST("/responses", responsesHandler)
|
||||
codexDirect.POST("/responses/*subpath", responsesHandler)
|
||||
codexDirect.POST("/responses/*subpath", guardResponsesSubpath(responsesHandler))
|
||||
codexDirect.POST("/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
|
||||
codexDirect.GET("/responses", func(c *gin.Context) {
|
||||
h.OpenAIGateway.ResponsesWebSocket(c)
|
||||
|
||||
@@ -333,6 +333,33 @@ func TestGatewayRoutesGrokAllowsCLICompatibilityEntrypoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths 端到端锁定不变式:
|
||||
// /responses/*subpath 的子路径会被转发到上游同名端点之后,因此不合规的子路径必须
|
||||
// 在入口就被拒绝,不得进入调度与转发流程。
|
||||
func TestGatewayRoutesResponsesSubpathRejectsNonConformingSubpaths(t *testing.T) {
|
||||
router := newGatewayRoutesTestRouter()
|
||||
|
||||
for _, path := range []string{
|
||||
"/v1/responses/../../x/y",
|
||||
"/v1/responses/..%2f..%2fx/y",
|
||||
"/v1/responses/%2e%2e/%2e%2e/x",
|
||||
"/responses/%2e%2e%2fx",
|
||||
"/backend-api/codex/responses/..%2f..%2fx",
|
||||
`/v1/responses/..\..\x`,
|
||||
"/v1/responses/%3fa=b",
|
||||
"/v1/responses/x%23frag",
|
||||
"/v1/responses/compact%2f..",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{"model":"gpt-5"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusNotFound, w.Code, "path=%s must be rejected at the edge", path)
|
||||
require.Contains(t, w.Body.String(), "Unsupported responses subpath", "path=%s", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayRoutesOpenAICountTokensPathIsRegistered(t *testing.T) {
|
||||
router := newGatewayRoutesTestRouter(service.PlatformOpenAI)
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterModelPlazaRoutes 注册模型广场路由。
|
||||
//
|
||||
// 挂 OptionalJWT:匿名可访问(开关与 require_auth 由 handler fail-closed 判定),
|
||||
// 带 token 则识别用户以展示专属分组与个人倍率。
|
||||
// BackendModeUserGuard 保证 backend 模式下广场不对非管理员开放(匿名无 role → 403)。
|
||||
func RegisterModelPlazaRoutes(
|
||||
v1 *gin.RouterGroup,
|
||||
h *handler.Handlers,
|
||||
optionalJWT middleware.OptionalJWTAuthMiddleware,
|
||||
settingService *service.SettingService,
|
||||
panelRateLimiter *middleware.PanelRateLimiter,
|
||||
) {
|
||||
plaza := v1.Group("/model-plaza")
|
||||
plaza.Use(panelRateLimiter.PublicIP())
|
||||
plaza.Use(gin.HandlerFunc(optionalJWT))
|
||||
plaza.Use(middleware.BackendModeUserGuard(settingService))
|
||||
{
|
||||
plaza.GET("", h.ModelPlaza.Get)
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,15 @@ func RegisterUserRoutes(
|
||||
// 敏感操作二次验证:授予当前会话一段时间的 step-up 权限
|
||||
totp.POST("/step-up", h.Totp.StepUp)
|
||||
}
|
||||
|
||||
passkeys := user.Group("/passkeys")
|
||||
{
|
||||
passkeys.GET("", h.Passkey.List)
|
||||
passkeys.POST("/register/begin", h.Passkey.BeginRegistration)
|
||||
passkeys.POST("/register/finish", h.Passkey.FinishRegistration)
|
||||
passkeys.PATCH("/:id", h.Passkey.Rename)
|
||||
passkeys.DELETE("/:id", h.Passkey.Delete)
|
||||
}
|
||||
}
|
||||
|
||||
// API Key管理
|
||||
|
||||
@@ -136,6 +136,18 @@ func (a *Account) IsActive() bool {
|
||||
return a.Status == StatusActive
|
||||
}
|
||||
|
||||
// IsSyntheticUITest reports whether the account belongs to an isolated UI load-test
|
||||
// dataset. Production accounts never receive this marker. It lets the dedicated
|
||||
// test instance exercise interactive quota and connection-test controls without
|
||||
// sending fake credentials to an upstream provider.
|
||||
func (a *Account) IsSyntheticUITest() bool {
|
||||
if a == nil || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["synthetic_ui_test"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// BillingRateMultiplier 返回账号计费倍率。
|
||||
// - nil 表示未配置/旧缓存缺字段,按 1.0 处理
|
||||
// - 允许 0,表示该账号计费为 0
|
||||
@@ -779,9 +791,16 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
|
||||
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
|
||||
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
|
||||
func (a *Account) IsModelSupported(requestedModel string) bool {
|
||||
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
|
||||
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
|
||||
// credentials 里常残留旧的非空 model_mapping,若不在此放行,透传账号会被
|
||||
// model_mapping 白名单错误排除出候选集,导致 no available accounts / 404(issue #4936)。
|
||||
if a.IsOpenAIPassthroughEnabled() {
|
||||
return true
|
||||
}
|
||||
mapping := a.GetModelMapping()
|
||||
if len(mapping) == 0 {
|
||||
if a.IsOpenAIOAuth() && !a.IsOpenAIPassthroughEnabled() {
|
||||
if a.IsOpenAIOAuth() {
|
||||
return isOpenAIOAuthServableModel(requestedModel)
|
||||
}
|
||||
return true // 无映射 = 允许所有
|
||||
|
||||
@@ -186,6 +186,20 @@ func (s *AccountTestService) TestAccountConnection(c *gin.Context, accountID int
|
||||
return s.sendErrorAndEnd(c, "Account not found")
|
||||
}
|
||||
|
||||
// Synthetic UI load-test accounts exercise the real SSE parsing and modal
|
||||
// interactions, but intentionally do not send their placeholder credentials
|
||||
// to an upstream provider.
|
||||
if account.IsSyntheticUITest() {
|
||||
testModelID := modelID
|
||||
if testModelID == "" {
|
||||
testModelID = claude.DefaultTestModel
|
||||
}
|
||||
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
|
||||
s.sendEvent(c, TestEvent{Type: "content", Text: "Synthetic Anthropic OAuth account is healthy and interactive."})
|
||||
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Route to platform-specific test method
|
||||
if account.IsOpenAI() {
|
||||
return s.testOpenAIAccountConnection(c, account, modelID, prompt, normalizeAccountTestMode(mode))
|
||||
@@ -1195,8 +1209,10 @@ func (s *AccountTestService) buildGeminiAPIKeyRequest(ctx context.Context, accou
|
||||
}
|
||||
|
||||
// Use streamGenerateContent for real-time feedback
|
||||
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse",
|
||||
strings.TrimRight(normalizedBaseURL, "/"), modelID)
|
||||
fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", fullURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
@@ -1232,7 +1248,10 @@ func (s *AccountTestService) buildGeminiOAuthRequest(ctx context.Context, accoun
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fullURL := fmt.Sprintf("%s/v1beta/models/%s:streamGenerateContent?alt=sse", strings.TrimRight(normalizedBaseURL, "/"), modelID)
|
||||
fullURL, err := buildGeminiAIStudioModelActionURL(normalizedBaseURL, modelID, "streamGenerateContent", true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, fullURL, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
|
||||
@@ -345,6 +345,13 @@ func (s *AccountUsageService) GetUsage(ctx context.Context, accountID int64, for
|
||||
return nil, fmt.Errorf("get account failed: %w", err)
|
||||
}
|
||||
|
||||
// Dedicated UI load-test accounts must remain fully interactive without ever
|
||||
// contacting Anthropic with synthetic credentials. Reuse the same persisted
|
||||
// passive snapshot that the account table loads on mount.
|
||||
if account.IsSyntheticUITest() && account.IsAnthropicOAuthOrSetupToken() {
|
||||
return s.GetPassiveUsage(ctx, accountID)
|
||||
}
|
||||
|
||||
if account.Platform == PlatformOpenAI && account.Type == AccountTypeOAuth {
|
||||
usage, err := s.getOpenAIUsage(ctx, account, forceProbe)
|
||||
if err == nil {
|
||||
@@ -508,10 +515,30 @@ func (s *AccountUsageService) GetPassiveUsage(ctx context.Context, accountID int
|
||||
|
||||
// 添加窗口统计
|
||||
s.addWindowStats(ctx, account, info)
|
||||
if account.IsSyntheticUITest() {
|
||||
applySyntheticWindowStats(info, account.Extra)
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func applySyntheticWindowStats(info *UsageInfo, extra map[string]any) {
|
||||
if info == nil || info.FiveHour == nil || len(extra) == 0 {
|
||||
return
|
||||
}
|
||||
raw, ok := extra["synthetic_window_stats"].(map[string]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
info.FiveHour.WindowStats = &WindowStats{
|
||||
Requests: int64(parseExtraInt(raw["requests"])),
|
||||
Tokens: int64(parseExtraInt(raw["tokens"])),
|
||||
Cost: parseExtraFloat64(raw["cost"]),
|
||||
StandardCost: parseExtraFloat64(raw["standard_cost"]),
|
||||
UserCost: parseExtraFloat64(raw["user_cost"]),
|
||||
}
|
||||
}
|
||||
|
||||
// buildPassiveUsageWindow 从 Extra 中的被动采样数据(utilization 为 0-1 小数、reset 为 Unix 秒)
|
||||
// 构建用量窗口,无数据时返回 nil。
|
||||
func buildPassiveUsageWindow(extra map[string]any, utilKey, resetKey string) *UsageProgress {
|
||||
|
||||
@@ -1078,7 +1078,7 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
|
||||
if addErr := s.userRepo.AddGroupToAllowedGroups(opCtx, apiKey.UserID, gid); addErr != nil {
|
||||
return nil, fmt.Errorf("add group to user allowed groups: %w", addErr)
|
||||
}
|
||||
if err := s.apiKeyRepo.Update(opCtx, apiKey); err != nil {
|
||||
if err := s.apiKeyRepo.Update(opCtx, apiKey, APIKeyUpdateFields{GroupID: true}); err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
if tx != nil {
|
||||
@@ -1102,7 +1102,7 @@ func (s *adminServiceImpl) AdminUpdateAPIKeyGroupID(ctx context.Context, keyID i
|
||||
}
|
||||
|
||||
// 非专属分组 / 解绑:无需事务,单步更新即可
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{GroupID: true}); err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
|
||||
@@ -1127,7 +1127,7 @@ func (s *adminServiceImpl) AdminResetAPIKeyRateLimitUsage(ctx context.Context, k
|
||||
apiKey.Window5hStart = nil
|
||||
apiKey.Window1dStart = nil
|
||||
apiKey.Window7dStart = nil
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{RateLimitUsage: true}); err != nil {
|
||||
return nil, fmt.Errorf("reset api key rate limit usage: %w", err)
|
||||
}
|
||||
if s.authCacheInvalidator != nil {
|
||||
|
||||
@@ -45,7 +45,9 @@ func (s *userRepoStubForGroupUpdate) GetByEmail(context.Context, string) (*User,
|
||||
func (s *userRepoStubForGroupUpdate) GetFirstAdmin(context.Context) (*User, error) {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) Update(context.Context, *User, UserUpdateFields) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) Delete(context.Context, int64) error { panic("unexpected") }
|
||||
func (s *userRepoStubForGroupUpdate) GetUserAvatar(context.Context, int64) (*UserAvatar, error) {
|
||||
panic("unexpected")
|
||||
@@ -68,6 +70,14 @@ func (s *userRepoStubForGroupUpdate) UpdateBalance(context.Context, int64, float
|
||||
func (s *userRepoStubForGroupUpdate) DeductBalance(context.Context, int64, float64) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
|
||||
func (s *userRepoStubForGroupUpdate) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
|
||||
panic("unexpected AdjustBalance call")
|
||||
}
|
||||
|
||||
func (s *userRepoStubForGroupUpdate) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
|
||||
panic("unexpected SetBalance call")
|
||||
}
|
||||
func (s *userRepoStubForGroupUpdate) UpdateConcurrency(context.Context, int64, int) error {
|
||||
panic("unexpected")
|
||||
}
|
||||
@@ -134,7 +144,7 @@ func (s *apiKeyRepoStubForGroupUpdate) GetByID(_ context.Context, _ int64) (*API
|
||||
clone := *s.key
|
||||
return &clone, nil
|
||||
}
|
||||
func (s *apiKeyRepoStubForGroupUpdate) Update(_ context.Context, key *APIKey) error {
|
||||
func (s *apiKeyRepoStubForGroupUpdate) Update(_ context.Context, key *APIKey, _ APIKeyUpdateFields) error {
|
||||
if s.updateErr != nil {
|
||||
return s.updateErr
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ func (s *userRepoStub) GetFirstAdmin(ctx context.Context) (*User, error) {
|
||||
panic("unexpected GetFirstAdmin call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) Update(ctx context.Context, user *User) error {
|
||||
func (s *userRepoStub) Update(ctx context.Context, user *User, fields UserUpdateFields) error {
|
||||
s.updated = append(s.updated, user)
|
||||
if s.usersByEmail == nil {
|
||||
s.usersByEmail = make(map[string]*User)
|
||||
@@ -141,6 +141,14 @@ func (s *userRepoStub) DeductBalance(ctx context.Context, id int64, amount float
|
||||
panic("unexpected DeductBalance call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
|
||||
panic("unexpected AdjustBalance call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
|
||||
panic("unexpected SetBalance call")
|
||||
}
|
||||
|
||||
func (s *userRepoStub) UpdateConcurrency(ctx context.Context, id int64, amount int) error {
|
||||
panic("unexpected UpdateConcurrency call")
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ func (s *emailSyncRepoStub) GetFirstAdmin(context.Context) (*User, error) {
|
||||
return nil, fmt.Errorf("unexpected GetFirstAdmin call")
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) Update(_ context.Context, user *User) error {
|
||||
func (s *emailSyncRepoStub) Update(_ context.Context, user *User, _ UserUpdateFields) error {
|
||||
s.updateCalls++
|
||||
s.updated = append(s.updated, user)
|
||||
s.user = user
|
||||
@@ -117,6 +117,14 @@ func (s *emailSyncRepoStub) ExistsByEmailAlias(context.Context, string) (bool, e
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
|
||||
panic("unexpected AdjustBalance call")
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
|
||||
panic("unexpected SetBalance call")
|
||||
}
|
||||
|
||||
func (s *emailSyncRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -12,23 +12,34 @@ import (
|
||||
|
||||
type balanceUserRepoStub struct {
|
||||
*userRepoStub
|
||||
updateErr error
|
||||
updated []*User
|
||||
adjustErr error
|
||||
// changes 记录每次原子余额变更,顺序与调用顺序一致。
|
||||
changes []BalanceChange
|
||||
}
|
||||
|
||||
func (s *balanceUserRepoStub) Update(ctx context.Context, user *User) error {
|
||||
if s.updateErr != nil {
|
||||
return s.updateErr
|
||||
func (s *balanceUserRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (BalanceChange, error) {
|
||||
return s.apply(func(current float64) float64 { return current + delta })
|
||||
}
|
||||
|
||||
func (s *balanceUserRepoStub) SetBalance(ctx context.Context, id int64, value float64) (BalanceChange, error) {
|
||||
return s.apply(func(float64) float64 { return value })
|
||||
}
|
||||
|
||||
func (s *balanceUserRepoStub) apply(next func(current float64) float64) (BalanceChange, error) {
|
||||
if s.adjustErr != nil {
|
||||
return BalanceChange{}, s.adjustErr
|
||||
}
|
||||
if user == nil {
|
||||
return nil
|
||||
if s.userRepoStub == nil || s.userRepoStub.user == nil {
|
||||
return BalanceChange{}, ErrUserNotFound
|
||||
}
|
||||
clone := *user
|
||||
s.updated = append(s.updated, &clone)
|
||||
if s.userRepoStub != nil {
|
||||
s.userRepoStub.user = &clone
|
||||
change := BalanceChange{Old: s.userRepoStub.user.Balance}
|
||||
change.New = next(change.Old)
|
||||
if change.New < 0 {
|
||||
return change, ErrBalanceNegative
|
||||
}
|
||||
return nil
|
||||
s.userRepoStub.user.Balance = change.New
|
||||
s.changes = append(s.changes, change)
|
||||
return change, nil
|
||||
}
|
||||
|
||||
type balanceRedeemRepoStub struct {
|
||||
@@ -87,6 +98,63 @@ func (s *authCacheInvalidatorStub) InvalidateAuthCacheByGroupID(ctx context.Cont
|
||||
s.groupIDs = append(s.groupIDs, groupID)
|
||||
}
|
||||
|
||||
// 管理员调账必须走原子的 AdjustBalance/SetBalance,而不是"读余额→算新值→整行写回",
|
||||
// 后者会把并发的计费扣款覆盖掉。userRepoStub.Update 对未预期的调用会 panic,
|
||||
// 因此这里同时证明它没被走到。
|
||||
func TestAdminService_UpdateUserBalance_UsesAtomicPrimitives(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
amount float64
|
||||
want BalanceChange
|
||||
}{
|
||||
{name: "add", operation: "add", amount: 5, want: BalanceChange{Old: 10, New: 15}},
|
||||
{name: "subtract", operation: "subtract", amount: 4, want: BalanceChange{Old: 10, New: 6}},
|
||||
{name: "set", operation: "set", amount: 2, want: BalanceChange{Old: 10, New: 2}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
||||
}
|
||||
|
||||
user, err := svc.UpdateUserBalance(context.Background(), 7, tt.amount, tt.operation, "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []BalanceChange{tt.want}, repo.changes)
|
||||
require.Equal(t, tt.want.New, user.Balance)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUserBalance_RejectsNegativeResult(t *testing.T) {
|
||||
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 3}}}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
||||
}
|
||||
|
||||
_, err := svc.UpdateUserBalance(context.Background(), 7, 4, "subtract", "")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "balance cannot be negative")
|
||||
require.Empty(t, repo.changes, "refused adjustment must not be applied")
|
||||
require.Equal(t, 3.0, repo.userRepoStub.user.Balance)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUserBalance_RejectsUnknownOperation(t *testing.T) {
|
||||
repo := &balanceUserRepoStub{userRepoStub: &userRepoStub{user: &User{ID: 7, Balance: 10}}}
|
||||
svc := &adminServiceImpl{
|
||||
userRepo: repo,
|
||||
redeemCodeRepo: &balanceRedeemRepoStub{redeemRepoStub: &redeemRepoStub{}},
|
||||
}
|
||||
|
||||
_, err := svc.UpdateUserBalance(context.Background(), 7, 1, "multiply", "")
|
||||
require.Error(t, err)
|
||||
require.Empty(t, repo.changes)
|
||||
}
|
||||
|
||||
func TestAdminService_UpdateUserBalance_InvalidatesAuthCache(t *testing.T) {
|
||||
baseRepo := &userRepoStub{user: &User{ID: 7, Balance: 10}}
|
||||
repo := &balanceUserRepoStub{userRepoStub: baseRepo}
|
||||
|
||||
@@ -16,7 +16,7 @@ type rpmUserRepoStub struct {
|
||||
lastUpdated *User
|
||||
}
|
||||
|
||||
func (s *rpmUserRepoStub) Update(_ context.Context, user *User) error {
|
||||
func (s *rpmUserRepoStub) Update(_ context.Context, user *User, _ UserUpdateFields) error {
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -218,24 +218,33 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
oldRPMLimit := user.RPMLimit
|
||||
oldAllowedGroups := append([]int64(nil), user.AllowedGroups...)
|
||||
|
||||
// fields 与下面的 input.X 判空条件一一对应:管理员没提交的列不写回,
|
||||
// 避免这份快照回滚并发的扣费、状态变更或批量限额调整。
|
||||
var fields UserUpdateFields
|
||||
|
||||
if input.Email != "" {
|
||||
user.Email = input.Email
|
||||
fields.Email = true
|
||||
}
|
||||
if input.Password != "" {
|
||||
if err := user.SetPassword(input.Password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fields.PasswordHash = true
|
||||
}
|
||||
|
||||
if input.Username != nil {
|
||||
user.Username = *input.Username
|
||||
fields.Username = true
|
||||
}
|
||||
if input.Notes != nil {
|
||||
user.Notes = *input.Notes
|
||||
fields.Notes = true
|
||||
}
|
||||
|
||||
if input.Status != "" {
|
||||
user.Status = input.Status
|
||||
fields.Status = true
|
||||
}
|
||||
|
||||
// 角色变更(admin/user);空字符串表示不修改。
|
||||
@@ -252,21 +261,25 @@ func (s *adminServiceImpl) UpdateUser(ctx context.Context, id int64, input *Upda
|
||||
}
|
||||
}
|
||||
user.Role = role
|
||||
fields.Role = true
|
||||
}
|
||||
|
||||
if input.Concurrency != nil {
|
||||
user.Concurrency = *input.Concurrency
|
||||
fields.Concurrency = true
|
||||
}
|
||||
|
||||
if input.RPMLimit != nil {
|
||||
user.RPMLimit = *input.RPMLimit
|
||||
fields.RPMLimit = true
|
||||
}
|
||||
|
||||
if input.AllowedGroups != nil {
|
||||
user.AllowedGroups = *input.AllowedGroups
|
||||
fields.AllowedGroups = true
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
if err := s.userRepo.Update(ctx, user, fields); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -493,30 +506,34 @@ func (s *adminServiceImpl) BatchUpdateLimits(ctx context.Context, userIDs []int6
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateUserBalance(ctx context.Context, userID int64, balance float64, operation string, notes string) (*User, error) {
|
||||
// 余额调整必须走原子接口:先读后整行写回会把并发的计费扣款覆盖掉。
|
||||
var (
|
||||
change BalanceChange
|
||||
err error
|
||||
)
|
||||
switch operation {
|
||||
case "set":
|
||||
change, err = s.userRepo.SetBalance(ctx, userID, balance)
|
||||
case "add":
|
||||
change, err = s.userRepo.AdjustBalance(ctx, userID, balance)
|
||||
case "subtract":
|
||||
change, err = s.userRepo.AdjustBalance(ctx, userID, -balance)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported balance operation: %q", operation)
|
||||
}
|
||||
if errors.Is(err, ErrBalanceNegative) {
|
||||
return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", change.Old, change.New)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldBalance := user.Balance
|
||||
|
||||
switch operation {
|
||||
case "set":
|
||||
user.Balance = balance
|
||||
case "add":
|
||||
user.Balance += balance
|
||||
case "subtract":
|
||||
user.Balance -= balance
|
||||
}
|
||||
|
||||
if user.Balance < 0 {
|
||||
return nil, fmt.Errorf("balance cannot be negative, current balance: %.2f, requested operation would result in: %.2f", oldBalance, user.Balance)
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
balanceDiff := user.Balance - oldBalance
|
||||
balanceDiff := change.New - change.Old
|
||||
if s.authCacheInvalidator != nil && balanceDiff != 0 {
|
||||
s.authCacheInvalidator.InvalidateAuthCacheByUserID(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -53,6 +53,34 @@ const (
|
||||
apiKeyLastUsedFailBackoff = 5 * time.Second
|
||||
)
|
||||
|
||||
// APIKeyUpdateFields 声明 APIKeyRepository.Update 允许写回的列。
|
||||
//
|
||||
// 与 UserUpdateFields 同理:api_keys 的用量列由计费热路径原子递增
|
||||
// (IncrementQuotaUsed / IncrementRateLimitUsage 的 quota_used、usage_5h/1d/7d),
|
||||
// 若编辑 Key 时无条件整行回写,并发累计的配额与限流计数就会被旧快照覆盖。
|
||||
// 因此调用方必须显式声明要改的列。
|
||||
type APIKeyUpdateFields struct {
|
||||
Name bool
|
||||
Status bool
|
||||
Quota bool
|
||||
GroupID bool
|
||||
ExpiresAt bool
|
||||
// QuotaUsed 仅供"重置配额用量"路径声明;常规计费走 IncrementQuotaUsed。
|
||||
QuotaUsed bool
|
||||
// RateLimits 覆盖 rate_limit_5h / _1d / _7d 三个阈值。
|
||||
RateLimits bool
|
||||
// RateLimitUsage 覆盖 usage_5h/_1d/_7d 与三个窗口起点,
|
||||
// 仅供"重置限流用量"路径声明;常规计费走 IncrementRateLimitUsage。
|
||||
RateLimitUsage bool
|
||||
// IPRules 覆盖 ip_whitelist 与 ip_blacklist。
|
||||
IPRules bool
|
||||
}
|
||||
|
||||
// IsEmpty 报告该次 Update 是否不写任何列。
|
||||
func (f APIKeyUpdateFields) IsEmpty() bool {
|
||||
return f == APIKeyUpdateFields{}
|
||||
}
|
||||
|
||||
type APIKeyRepository interface {
|
||||
Create(ctx context.Context, key *APIKey) error
|
||||
GetByID(ctx context.Context, id int64) (*APIKey, error)
|
||||
@@ -61,7 +89,8 @@ type APIKeyRepository interface {
|
||||
GetByKey(ctx context.Context, key string) (*APIKey, error)
|
||||
// GetByKeyForAuth 认证专用查询,返回最小字段集
|
||||
GetByKeyForAuth(ctx context.Context, key string) (*APIKey, error)
|
||||
Update(ctx context.Context, key *APIKey) error
|
||||
// Update 只写 fields 中显式声明的列,其余列保持库中当前值。
|
||||
Update(ctx context.Context, key *APIKey, fields APIKeyUpdateFields) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
// DeleteWithAudit keeps the legacy interface name for rolling-upgrade compatibility.
|
||||
// Implementations must tombstone the key and soft-delete it atomically without
|
||||
@@ -718,9 +747,17 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
}
|
||||
}
|
||||
|
||||
// fields 只登记本次请求真正要改的列。quota_used 与 usage_5h/1d/7d 由计费热路径
|
||||
// 原子递增,除非用户显式点了"重置",否则这里不用快照把它们写回去。
|
||||
var fields APIKeyUpdateFields
|
||||
// 下面若干分支会顺带把 Status 改回 active(配额扩容、清除过期等),
|
||||
// 所以用原始值比对来决定是否写 status,而不是只看 req.Status。
|
||||
originalStatus := apiKey.Status
|
||||
|
||||
// 更新字段
|
||||
if req.Name != nil {
|
||||
apiKey.Name = html.EscapeString(*req.Name)
|
||||
fields.Name = true
|
||||
}
|
||||
|
||||
if req.GroupID != nil {
|
||||
@@ -740,10 +777,12 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
}
|
||||
|
||||
apiKey.GroupID = req.GroupID
|
||||
fields.GroupID = true
|
||||
}
|
||||
|
||||
if req.Status != nil {
|
||||
apiKey.Status = *req.Status
|
||||
fields.Status = true
|
||||
// 如果状态改变,清除Redis缓存
|
||||
if s.cache != nil {
|
||||
_ = s.cache.DeleteCreateAttemptCount(ctx, apiKey.UserID)
|
||||
@@ -753,6 +792,7 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
// Update quota fields
|
||||
if req.Quota != nil {
|
||||
apiKey.Quota = *req.Quota
|
||||
fields.Quota = true
|
||||
// If quota now has room, or is changed to unlimited, reactivate exhausted keys.
|
||||
if apiKey.Status == StatusAPIKeyQuotaExhausted && (*req.Quota <= 0 || *req.Quota > apiKey.QuotaUsed) {
|
||||
apiKey.Status = StatusActive
|
||||
@@ -760,6 +800,7 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
}
|
||||
if req.ResetQuota != nil && *req.ResetQuota {
|
||||
apiKey.QuotaUsed = 0
|
||||
fields.QuotaUsed = true
|
||||
// If resetting quota and status was quota_exhausted, reactivate
|
||||
if apiKey.Status == StatusAPIKeyQuotaExhausted {
|
||||
apiKey.Status = StatusActive
|
||||
@@ -767,12 +808,14 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
}
|
||||
if req.ClearExpiration {
|
||||
apiKey.ExpiresAt = nil
|
||||
fields.ExpiresAt = true
|
||||
// If clearing expiry and status was expired, reactivate
|
||||
if apiKey.Status == StatusAPIKeyExpired {
|
||||
apiKey.Status = StatusActive
|
||||
}
|
||||
} else if req.ExpiresAt != nil {
|
||||
apiKey.ExpiresAt = req.ExpiresAt
|
||||
fields.ExpiresAt = true
|
||||
// If extending expiry and status was expired, reactivate
|
||||
if apiKey.Status == StatusAPIKeyExpired && time.Now().Before(*req.ExpiresAt) {
|
||||
apiKey.Status = StatusActive
|
||||
@@ -782,20 +825,25 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
// 更新 IP 限制(nil 不修改,空数组清空设置)
|
||||
if req.IPWhitelist != nil {
|
||||
apiKey.IPWhitelist = *req.IPWhitelist
|
||||
fields.IPRules = true
|
||||
}
|
||||
if req.IPBlacklist != nil {
|
||||
apiKey.IPBlacklist = *req.IPBlacklist
|
||||
fields.IPRules = true
|
||||
}
|
||||
|
||||
// Update rate limit configuration
|
||||
if req.RateLimit5h != nil {
|
||||
apiKey.RateLimit5h = *req.RateLimit5h
|
||||
fields.RateLimits = true
|
||||
}
|
||||
if req.RateLimit1d != nil {
|
||||
apiKey.RateLimit1d = *req.RateLimit1d
|
||||
fields.RateLimits = true
|
||||
}
|
||||
if req.RateLimit7d != nil {
|
||||
apiKey.RateLimit7d = *req.RateLimit7d
|
||||
fields.RateLimits = true
|
||||
}
|
||||
resetRateLimit := req.ResetRateLimitUsage != nil && *req.ResetRateLimitUsage
|
||||
if resetRateLimit {
|
||||
@@ -805,9 +853,15 @@ func (s *APIKeyService) Update(ctx context.Context, id int64, userID int64, req
|
||||
apiKey.Window5hStart = nil
|
||||
apiKey.Window1dStart = nil
|
||||
apiKey.Window7dStart = nil
|
||||
fields.RateLimitUsage = true
|
||||
}
|
||||
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
|
||||
// 上面的自动复活分支可能改了 status,这里统一登记。
|
||||
if apiKey.Status != originalStatus {
|
||||
fields.Status = true
|
||||
}
|
||||
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey, fields); err != nil {
|
||||
return nil, fmt.Errorf("update api key: %w", err)
|
||||
}
|
||||
|
||||
@@ -980,6 +1034,22 @@ func (s *APIKeyService) SearchAPIKeys(ctx context.Context, userID int64, keyword
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
// GetUserAllowedGroupIDSet 返回 user_allowed_groups 授权给该用户的专属分组 ID 集合。
|
||||
//
|
||||
// 与 GetAvailableGroups 的区别:这里是「橱窗」语义(模型广场用),不检查订阅有效性,
|
||||
// 也不关心分组是否活跃——仅回答"哪些专属分组对该用户可见"。返回值恒非 nil。
|
||||
func (s *APIKeyService) GetUserAllowedGroupIDSet(ctx context.Context, userID int64) (map[int64]struct{}, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get user: %w", err)
|
||||
}
|
||||
allowed := make(map[int64]struct{}, len(user.AllowedGroups))
|
||||
for _, id := range user.AllowedGroups {
|
||||
allowed[id] = struct{}{}
|
||||
}
|
||||
return allowed, nil
|
||||
}
|
||||
|
||||
// GetUserGroupRates 获取用户的专属分组倍率配置
|
||||
// 返回 map[groupID]rateMultiplier
|
||||
func (s *APIKeyService) GetUserGroupRates(ctx context.Context, userID int64) (map[int64]float64, error) {
|
||||
@@ -1046,7 +1116,9 @@ func (s *APIKeyService) UpdateQuotaUsed(ctx context.Context, apiKeyID int64, cos
|
||||
// If quota is set and now exhausted, update status
|
||||
if apiKey.Quota > 0 && newQuotaUsed >= apiKey.Quota {
|
||||
apiKey.Status = StatusAPIKeyQuotaExhausted
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey); err != nil {
|
||||
// 只写 status:这条位于计费热路径,若整行回写会把刚刚原子递增的
|
||||
// quota_used 与限流用量按快照覆盖掉。
|
||||
if err := s.apiKeyRepo.Update(ctx, apiKey, APIKeyUpdateFields{Status: true}); err != nil {
|
||||
return nil // Don't fail the request
|
||||
}
|
||||
// Invalidate cache so next request sees the new status
|
||||
|
||||
@@ -46,7 +46,7 @@ func (s *authRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*APIKey
|
||||
return s.getByKeyForAuth(ctx, key)
|
||||
}
|
||||
|
||||
func (s *authRepoStub) Update(ctx context.Context, key *APIKey) error {
|
||||
func (s *authRepoStub) Update(ctx context.Context, key *APIKey, _ APIKeyUpdateFields) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ func (s *apiKeyRepoStub) GetByKeyForAuth(ctx context.Context, key string) (*APIK
|
||||
panic("unexpected GetByKeyForAuth call")
|
||||
}
|
||||
|
||||
func (s *apiKeyRepoStub) Update(ctx context.Context, key *APIKey) error {
|
||||
func (s *apiKeyRepoStub) Update(ctx context.Context, key *APIKey, _ APIKeyUpdateFields) error {
|
||||
if key != nil {
|
||||
s.updatedKeys = append(s.updatedKeys, *key)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func (s *quotaBaseAPIKeyRepoStub) GetByKey(context.Context, string) (*APIKey, er
|
||||
func (s *quotaBaseAPIKeyRepoStub) GetByKeyForAuth(context.Context, string) (*APIKey, error) {
|
||||
panic("unexpected GetByKeyForAuth call")
|
||||
}
|
||||
func (s *quotaBaseAPIKeyRepoStub) Update(context.Context, *APIKey) error {
|
||||
func (s *quotaBaseAPIKeyRepoStub) Update(context.Context, *APIKey, APIKeyUpdateFields) error {
|
||||
panic("unexpected Update call")
|
||||
}
|
||||
func (s *quotaBaseAPIKeyRepoStub) Delete(context.Context, int64) error {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// api_keys 的 quota_used / usage_5h|1d|7d 由计费热路径原子递增。
|
||||
// 编辑 Key(改名、换分组……)若整行回写,并发累计的用量就会被旧快照覆盖。
|
||||
// 这些用例锁死"只声明请求真正要改的列"。
|
||||
|
||||
type updateFieldsAPIKeyRepoStub struct {
|
||||
quotaBaseAPIKeyRepoStub
|
||||
key *APIKey
|
||||
updateFields []APIKeyUpdateFields
|
||||
}
|
||||
|
||||
// IncrementQuotaUsed 模拟计费热路径上的原子递增:只动 quota_used。
|
||||
func (s *updateFieldsAPIKeyRepoStub) IncrementQuotaUsed(_ context.Context, _ int64, amount float64) (float64, error) {
|
||||
s.key.QuotaUsed += amount
|
||||
return s.key.QuotaUsed, nil
|
||||
}
|
||||
|
||||
func (s *updateFieldsAPIKeyRepoStub) GetByID(context.Context, int64) (*APIKey, error) {
|
||||
clone := *s.key
|
||||
return &clone, nil
|
||||
}
|
||||
|
||||
func (s *updateFieldsAPIKeyRepoStub) Update(_ context.Context, _ *APIKey, fields APIKeyUpdateFields) error {
|
||||
s.updateFields = append(s.updateFields, fields)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newUpdateFieldsAPIKeyService(key *APIKey) (*APIKeyService, *updateFieldsAPIKeyRepoStub) {
|
||||
repo := &updateFieldsAPIKeyRepoStub{key: key}
|
||||
return &APIKeyService{apiKeyRepo: repo}, repo
|
||||
}
|
||||
|
||||
func TestAPIKeyUpdate_OnlyDeclaresRequestedColumns(t *testing.T) {
|
||||
name := "renamed"
|
||||
quota := 500.0
|
||||
rateLimit := 42.0
|
||||
whitelist := []string{"10.0.0.1"}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req UpdateAPIKeyRequest
|
||||
want APIKeyUpdateFields
|
||||
}{
|
||||
{
|
||||
name: "name only",
|
||||
req: UpdateAPIKeyRequest{Name: &name},
|
||||
want: APIKeyUpdateFields{Name: true},
|
||||
},
|
||||
{
|
||||
name: "quota only",
|
||||
req: UpdateAPIKeyRequest{Quota: "a},
|
||||
want: APIKeyUpdateFields{Quota: true},
|
||||
},
|
||||
{
|
||||
name: "rate limit threshold only",
|
||||
req: UpdateAPIKeyRequest{RateLimit5h: &rateLimit},
|
||||
want: APIKeyUpdateFields{RateLimits: true},
|
||||
},
|
||||
{
|
||||
name: "ip whitelist only",
|
||||
req: UpdateAPIKeyRequest{IPWhitelist: &whitelist},
|
||||
want: APIKeyUpdateFields{IPRules: true},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
|
||||
ID: 1,
|
||||
UserID: 7,
|
||||
Key: "sk-test",
|
||||
Name: "before",
|
||||
Status: StatusActive,
|
||||
Quota: 100,
|
||||
QuotaUsed: 30,
|
||||
Usage5h: 12,
|
||||
})
|
||||
|
||||
_, err := svc.Update(context.Background(), 1, 7, tt.req)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []APIKeyUpdateFields{tt.want}, repo.updateFields)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 显式重置仍需声明对应的列,避免收窄写入列时把功能改坏。
|
||||
func TestAPIKeyUpdate_DeclaresUsageColumnsOnExplicitReset(t *testing.T) {
|
||||
reset := true
|
||||
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
|
||||
ID: 1, UserID: 7, Key: "sk-test", Status: StatusActive, Quota: 100, QuotaUsed: 30, Usage5h: 12,
|
||||
})
|
||||
|
||||
_, err := svc.Update(context.Background(), 1, 7, UpdateAPIKeyRequest{
|
||||
ResetQuota: &reset,
|
||||
ResetRateLimitUsage: &reset,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []APIKeyUpdateFields{{QuotaUsed: true, RateLimitUsage: true}}, repo.updateFields)
|
||||
}
|
||||
|
||||
// 配额扩容会顺带把 quota_exhausted 复活为 active,此时必须声明 status。
|
||||
func TestAPIKeyUpdate_DeclaresStatusWhenReactivated(t *testing.T) {
|
||||
quota := 500.0
|
||||
svc, repo := newUpdateFieldsAPIKeyService(&APIKey{
|
||||
ID: 1, UserID: 7, Key: "sk-test", Status: StatusAPIKeyQuotaExhausted, Quota: 100, QuotaUsed: 100,
|
||||
})
|
||||
|
||||
_, err := svc.Update(context.Background(), 1, 7, UpdateAPIKeyRequest{Quota: "a})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []APIKeyUpdateFields{{Quota: true, Status: true}}, repo.updateFields)
|
||||
}
|
||||
|
||||
// 计费热路径把 Key 标记为配额耗尽时只写 status,
|
||||
// 否则会把刚原子递增的 quota_used 按快照覆盖掉。
|
||||
func TestUpdateQuotaUsed_ExhaustedMarkOnlyDeclaresStatus(t *testing.T) {
|
||||
repo := &updateFieldsAPIKeyRepoStub{key: &APIKey{
|
||||
ID: 1, UserID: 7, Key: "sk-test", Status: StatusActive, Quota: 10, QuotaUsed: 10,
|
||||
}}
|
||||
svc := &APIKeyService{apiKeyRepo: repo}
|
||||
|
||||
require.NoError(t, svc.UpdateQuotaUsed(context.Background(), 1, 5))
|
||||
require.Equal(t, []APIKeyUpdateFields{{Status: true}}, repo.updateFields)
|
||||
}
|
||||
@@ -19,6 +19,7 @@ const (
|
||||
// AuditAuthMethodJWT / AuditAuthMethodAdminAPIKey 与 auth 中间件写入的 auth_method 对齐。
|
||||
AuditAuthMethodJWT = "jwt"
|
||||
AuditAuthMethodAdminAPIKey = "admin_api_key"
|
||||
AuditAuthMethodPasskey = "passkey"
|
||||
|
||||
// auditRequestBodyMaxBytes 请求体脱敏后入库的最大长度(字节),超出截断。
|
||||
auditRequestBodyMaxBytes = 16 * 1024
|
||||
|
||||
@@ -79,7 +79,7 @@ func (s *AuthService) BindEmailIdentity(
|
||||
|
||||
currentUser.Email = normalizedEmail
|
||||
currentUser.PasswordHash = hashedPassword
|
||||
if err := s.userRepo.Update(ctx, currentUser); err != nil {
|
||||
if err := s.userRepo.Update(ctx, currentUser, UserUpdateFields{Email: true, PasswordHash: true}); err != nil {
|
||||
if errors.Is(err, ErrEmailExists) {
|
||||
return nil, ErrEmailExists
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user