mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-04 15:32:13 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbdff4f34f | ||
|
|
0aa480283f | ||
|
|
cd9d31f5f2 | ||
|
|
cbfce49aa1 | ||
|
|
d3e73f1260 | ||
|
|
b5ca6a654c | ||
|
|
94749b12ac | ||
|
|
523fa9f71e | ||
|
|
54636781ea | ||
|
|
5187db5ee5 | ||
|
|
0b9c4ae69e | ||
|
|
0d5a8a95c8 | ||
|
|
9cd97c9e1d | ||
|
|
d521191e87 | ||
|
|
fd78993b91 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,6 +32,7 @@ frontend/node_modules/
|
||||
frontend/dist/
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
vite.config.d.ts
|
||||
|
||||
# 日志
|
||||
npm-debug.log*
|
||||
|
||||
@@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
billingService := service.NewBillingService(configConfig, pricingService)
|
||||
identityCache := repository.NewIdentityCache(client)
|
||||
identityService := service.NewIdentityService(identityCache)
|
||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream)
|
||||
timingWheelService := service.ProvideTimingWheelService()
|
||||
deferredService := service.ProvideDeferredService(accountRepository, timingWheelService)
|
||||
gatewayService := service.NewGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService)
|
||||
geminiMessagesCompatService := service.NewGeminiMessagesCompatService(accountRepository, gatewayCache, geminiTokenProvider, rateLimitService, httpUpstream)
|
||||
gatewayHandler := handler.NewGatewayHandler(gatewayService, geminiMessagesCompatService, userService, concurrencyService, billingCacheService)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream)
|
||||
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, userRepository, userSubscriptionRepository, gatewayCache, configConfig, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService)
|
||||
openAIGatewayHandler := handler.NewOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo)
|
||||
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler)
|
||||
|
||||
@@ -11,13 +11,14 @@ require (
|
||||
github.com/google/wire v0.7.0
|
||||
github.com/imroc/req/v3 v3.56.0
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/redis/go-redis/v9 v9.7.3
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/spf13/viper v1.18.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
|
||||
github.com/testcontainers/testcontainers-go/modules/redis v0.40.0
|
||||
github.com/tidwall/gjson v1.18.0
|
||||
github.com/tidwall/sjson v1.2.5
|
||||
github.com/zeromicro/go-zero v1.9.4
|
||||
golang.org/x/crypto v0.44.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/term v0.37.0
|
||||
@@ -49,6 +50,7 @@ require (
|
||||
github.com/docker/go-connections v0.6.0 // indirect
|
||||
github.com/docker/go-units v0.5.0 // indirect
|
||||
github.com/ebitengine/purego v0.8.4 // indirect
|
||||
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/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||
@@ -59,7 +61,7 @@ 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-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/subcommands v1.2.0 // indirect
|
||||
@@ -67,9 +69,9 @@ require (
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect
|
||||
github.com/jackc/pgx/v5 v5.5.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.4 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
@@ -78,7 +80,8 @@ require (
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mdelapenya/tlscert v0.2.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||
@@ -93,7 +96,7 @@ require (
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
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.1.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // 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
|
||||
@@ -105,6 +108,7 @@ require (
|
||||
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.6.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
@@ -123,7 +127,8 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
|
||||
@@ -52,6 +52,8 @@ github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
|
||||
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
@@ -80,8 +82,8 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
|
||||
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
|
||||
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo=
|
||||
github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw=
|
||||
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.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
@@ -113,12 +115,12 @@ github.com/imroc/req/v3 v3.56.0 h1:t6YdqqerYBXhZ9+VjqsQs5wlKxdUNEvsgBhxWc1AEEo=
|
||||
github.com/imroc/req/v3 v3.56.0/go.mod h1:cUZSooE8hhzFNOrAbdxuemXDQxFXLQTnu3066jr7ZGk=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw=
|
||||
github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A=
|
||||
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
|
||||
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
|
||||
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -142,8 +144,11 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
|
||||
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -179,8 +184,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
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/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=
|
||||
@@ -188,12 +193,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.56.0 h1:q/TW+OLismmXAehgFLczhCDTYB3bFmua4D9lsNBWxvY=
|
||||
github.com/quic-go/quic-go v0.56.0/go.mod h1:9gx5KsFQtw2oZ6GZTyh+7YEvOxWCL9WZAepnHxgAo6c=
|
||||
github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM=
|
||||
github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkrMJI0pRUOCAo=
|
||||
github.com/refraction-networking/utls v1.8.1/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
@@ -208,6 +215,8 @@ github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
@@ -228,6 +237,7 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
@@ -259,26 +269,30 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
|
||||
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
|
||||
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
|
||||
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
@@ -301,6 +315,7 @@ golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
||||
@@ -30,8 +30,8 @@ type GenerateRedeemCodesRequest struct {
|
||||
Count int `json:"count" binding:"required,min=1,max=100"`
|
||||
Type string `json:"type" binding:"required,oneof=balance concurrency subscription"`
|
||||
Value float64 `json:"value" binding:"min=0"`
|
||||
GroupID *int64 `json:"group_id"` // 订阅类型必填
|
||||
ValidityDays int `json:"validity_days"` // 订阅类型使用,默认30天
|
||||
GroupID *int64 `json:"group_id"` // 订阅类型必填
|
||||
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // 订阅类型使用,默认30天,最大100年
|
||||
}
|
||||
|
||||
// List handles listing all redeem codes with pagination
|
||||
|
||||
@@ -41,7 +41,7 @@ func NewSubscriptionHandler(subscriptionService *service.SubscriptionService) *S
|
||||
type AssignSubscriptionRequest struct {
|
||||
UserID int64 `json:"user_id" binding:"required"`
|
||||
GroupID int64 `json:"group_id" binding:"required"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
@@ -49,13 +49,13 @@ type AssignSubscriptionRequest struct {
|
||||
type BulkAssignSubscriptionRequest struct {
|
||||
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
|
||||
GroupID int64 `json:"group_id" binding:"required"`
|
||||
ValidityDays int `json:"validity_days"`
|
||||
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
// ExtendSubscriptionRequest represents extend subscription request
|
||||
type ExtendSubscriptionRequest struct {
|
||||
Days int `json:"days" binding:"required,min=1"`
|
||||
Days int `json:"days" binding:"required,min=1,max=36500"` // max 100 years
|
||||
}
|
||||
|
||||
// List handles listing all subscriptions with pagination and filters
|
||||
|
||||
@@ -171,6 +171,27 @@ func (r *accountRepository) UpdateLastUsed(ctx context.Context, id int64) error
|
||||
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).Update("last_used_at", now).Error
|
||||
}
|
||||
|
||||
func (r *accountRepository) BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var caseSql = "UPDATE accounts SET last_used_at = CASE id"
|
||||
var args []any
|
||||
var ids []int64
|
||||
|
||||
for id, ts := range updates {
|
||||
caseSql += " WHEN ? THEN CAST(? AS TIMESTAMP)"
|
||||
args = append(args, id, ts)
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
caseSql += " END WHERE id IN ?"
|
||||
args = append(args, ids)
|
||||
|
||||
return r.db.WithContext(ctx).Exec(caseSql, args...).Error
|
||||
}
|
||||
|
||||
func (r *accountRepository) SetError(ctx context.Context, id int64, errorMsg string) error {
|
||||
return r.db.WithContext(ctx).Model(&accountModel{}).Where("id = ?", id).
|
||||
Updates(map[string]any{
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package repository
|
||||
|
||||
import "gorm.io/gorm"
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MaxExpiresAt is the maximum allowed expiration date for subscriptions (year 2099)
|
||||
// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999)
|
||||
var maxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
// AutoMigrate runs schema migrations for all repository persistence models.
|
||||
// Persistence models are defined within individual `*_repo.go` files.
|
||||
func AutoMigrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
err := db.AutoMigrate(
|
||||
&userModel{},
|
||||
&apiKeyModel{},
|
||||
&groupModel{},
|
||||
@@ -17,4 +26,24 @@ func AutoMigrate(db *gorm.DB) error {
|
||||
&settingModel{},
|
||||
&userSubscriptionModel{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 修复无效的过期时间(年份超过 2099 会导致 JSON 序列化失败)
|
||||
return fixInvalidExpiresAt(db)
|
||||
}
|
||||
|
||||
// fixInvalidExpiresAt 修复 user_subscriptions 表中无效的过期时间
|
||||
func fixInvalidExpiresAt(db *gorm.DB) error {
|
||||
result := db.Model(&userSubscriptionModel{}).
|
||||
Where("expires_at > ?", maxExpiresAt).
|
||||
Update("expires_at", maxExpiresAt)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected > 0 {
|
||||
log.Printf("[AutoMigrate] Fixed %d subscriptions with invalid expires_at (year > 2099)", result.RowsAffected)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ func (s *claudeOAuthService) GetAuthorizationCode(ctx context.Context, sessionKe
|
||||
return fullCode, nil
|
||||
}
|
||||
|
||||
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error) {
|
||||
func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error) {
|
||||
client := s.clientFactory(proxyURL)
|
||||
|
||||
// Parse code which may contain state in format "authCode#state"
|
||||
@@ -168,6 +168,11 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
|
||||
reqBody["state"] = codeState
|
||||
}
|
||||
|
||||
// Setup token requires longer expiration (1 year)
|
||||
if isSetupToken {
|
||||
reqBody["expires_in"] = 31536000 // 365 * 24 * 60 * 60 seconds
|
||||
}
|
||||
|
||||
reqBodyJSON, _ := json.Marshal(reqBody)
|
||||
log.Printf("[OAuth] Step 3: Exchanging code for token at %s", s.tokenURL)
|
||||
log.Printf("[OAuth] Step 3 Request Body: %s", string(reqBodyJSON))
|
||||
|
||||
@@ -191,12 +191,13 @@ func (s *ClaudeOAuthServiceSuite) TestGetAuthorizationCode() {
|
||||
|
||||
func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
||||
tests := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
code string
|
||||
wantErr bool
|
||||
wantResp *oauth.TokenResponse
|
||||
validate func(captured requestCapture)
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
code string
|
||||
isSetupToken bool
|
||||
wantErr bool
|
||||
wantResp *oauth.TokenResponse
|
||||
validate func(captured requestCapture)
|
||||
}{
|
||||
{
|
||||
name: "sends_state_when_embedded",
|
||||
@@ -210,7 +211,8 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
||||
Scope: "s",
|
||||
})
|
||||
},
|
||||
code: "AUTH#STATE2",
|
||||
code: "AUTH#STATE2",
|
||||
isSetupToken: false,
|
||||
wantResp: &oauth.TokenResponse{
|
||||
AccessToken: "at",
|
||||
RefreshToken: "rt",
|
||||
@@ -223,6 +225,29 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
||||
require.Equal(s.T(), oauth.ClientID, captured.bodyJSON["client_id"])
|
||||
require.Equal(s.T(), oauth.RedirectURI, captured.bodyJSON["redirect_uri"])
|
||||
require.Equal(s.T(), "ver", captured.bodyJSON["code_verifier"])
|
||||
// Regular OAuth should not include expires_in
|
||||
require.Nil(s.T(), captured.bodyJSON["expires_in"], "regular OAuth should not include expires_in")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "setup_token_includes_expires_in",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(oauth.TokenResponse{
|
||||
AccessToken: "at",
|
||||
TokenType: "bearer",
|
||||
ExpiresIn: 31536000,
|
||||
})
|
||||
},
|
||||
code: "AUTH",
|
||||
isSetupToken: true,
|
||||
wantResp: &oauth.TokenResponse{
|
||||
AccessToken: "at",
|
||||
},
|
||||
validate: func(captured requestCapture) {
|
||||
// Setup token should include expires_in with 1 year value
|
||||
require.Equal(s.T(), float64(31536000), captured.bodyJSON["expires_in"],
|
||||
"setup token should include expires_in: 31536000")
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -231,8 +256,9 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("bad request"))
|
||||
},
|
||||
code: "AUTH",
|
||||
wantErr: true,
|
||||
code: "AUTH",
|
||||
isSetupToken: false,
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -254,7 +280,7 @@ func (s *ClaudeOAuthServiceSuite) TestExchangeCodeForToken() {
|
||||
s.client = client
|
||||
s.client.tokenURL = s.srv.URL
|
||||
|
||||
resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "")
|
||||
resp, err := s.client.ExchangeCodeForToken(context.Background(), tt.code, "ver", "", "", tt.isSetupToken)
|
||||
|
||||
if tt.wantErr {
|
||||
require.Error(s.T(), err)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
@@ -94,6 +95,9 @@ func (a *Account) GetCredential(key string) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case json.Number:
|
||||
// GORM datatypes.JSONMap 使用 UseNumber() 解析,数字类型为 json.Number
|
||||
return val.String()
|
||||
case float64:
|
||||
// JSON 解析后数字默认为 float64
|
||||
return strconv.FormatInt(int64(val), 10)
|
||||
|
||||
@@ -29,6 +29,7 @@ type AccountRepository interface {
|
||||
ListByPlatform(ctx context.Context, platform string) ([]Account, error)
|
||||
|
||||
UpdateLastUsed(ctx context.Context, id int64) error
|
||||
BatchUpdateLastUsed(ctx context.Context, updates map[int64]time.Time) error
|
||||
SetError(ctx context.Context, id int64, errorMsg string) error
|
||||
SetSchedulable(ctx context.Context, id int64, schedulable bool) error
|
||||
BindGroups(ctx context.Context, accountID int64, groupIDs []int64) error
|
||||
|
||||
76
backend/internal/service/deferred_service.go
Normal file
76
backend/internal/service/deferred_service.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DeferredService provides deferred batch update functionality
|
||||
type DeferredService struct {
|
||||
accountRepo AccountRepository
|
||||
timingWheel *TimingWheelService
|
||||
interval time.Duration
|
||||
|
||||
lastUsedUpdates sync.Map
|
||||
}
|
||||
|
||||
// NewDeferredService creates a new DeferredService instance
|
||||
func NewDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService, interval time.Duration) *DeferredService {
|
||||
return &DeferredService{
|
||||
accountRepo: accountRepo,
|
||||
timingWheel: timingWheel,
|
||||
interval: interval,
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the deferred service
|
||||
func (s *DeferredService) Start() {
|
||||
s.timingWheel.ScheduleRecurring("deferred:last_used", s.interval, s.flushLastUsed)
|
||||
log.Printf("[DeferredService] Started (interval: %v)", s.interval)
|
||||
}
|
||||
|
||||
// Stop stops the deferred service
|
||||
func (s *DeferredService) Stop() {
|
||||
s.timingWheel.Cancel("deferred:last_used")
|
||||
s.flushLastUsed()
|
||||
log.Printf("[DeferredService] Service stopped")
|
||||
}
|
||||
|
||||
func (s *DeferredService) ScheduleLastUsedUpdate(accountID int64) {
|
||||
s.lastUsedUpdates.Store(accountID, time.Now())
|
||||
}
|
||||
|
||||
func (s *DeferredService) flushLastUsed() {
|
||||
updates := make(map[int64]time.Time)
|
||||
s.lastUsedUpdates.Range(func(key, value any) bool {
|
||||
id, ok := key.(int64)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
ts, ok := value.(time.Time)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
updates[id] = ts
|
||||
s.lastUsedUpdates.Delete(key)
|
||||
return true
|
||||
})
|
||||
|
||||
if len(updates) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := s.accountRepo.BatchUpdateLastUsed(ctx, updates); err != nil {
|
||||
log.Printf("[DeferredService] BatchUpdateLastUsed failed (%d accounts): %v", len(updates), err)
|
||||
for id, ts := range updates {
|
||||
s.lastUsedUpdates.Store(id, ts)
|
||||
}
|
||||
} else {
|
||||
log.Printf("[DeferredService] BatchUpdateLastUsed flushed %d accounts", len(updates))
|
||||
}
|
||||
}
|
||||
@@ -103,6 +103,7 @@ type GatewayService struct {
|
||||
billingCacheService *BillingCacheService
|
||||
identityService *IdentityService
|
||||
httpUpstream HTTPUpstream
|
||||
deferredService *DeferredService
|
||||
}
|
||||
|
||||
// NewGatewayService creates a new GatewayService
|
||||
@@ -118,6 +119,7 @@ func NewGatewayService(
|
||||
billingCacheService *BillingCacheService,
|
||||
identityService *IdentityService,
|
||||
httpUpstream HTTPUpstream,
|
||||
deferredService *DeferredService,
|
||||
) *GatewayService {
|
||||
return &GatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@@ -131,6 +133,7 @@ func NewGatewayService(
|
||||
billingCacheService: billingCacheService,
|
||||
identityService: identityService,
|
||||
httpUpstream: httpUpstream,
|
||||
deferredService: deferredService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1095,10 +1098,8 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
|
||||
}
|
||||
}
|
||||
|
||||
// 更新账号最后使用时间
|
||||
if err := s.accountRepo.UpdateLastUsed(ctx, account.ID); err != nil {
|
||||
log.Printf("Update last used failed: %v", err)
|
||||
}
|
||||
// Schedule batch update for account last_used_at
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type OpenAIOAuthClient interface {
|
||||
type ClaudeOAuthClient interface {
|
||||
GetOrganizationUUID(ctx context.Context, sessionKey, proxyURL string) (string, error)
|
||||
GetAuthorizationCode(ctx context.Context, sessionKey, orgUUID, scope, codeChallenge, state, proxyURL string) (string, error)
|
||||
ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*oauth.TokenResponse, error)
|
||||
ExchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*oauth.TokenResponse, error)
|
||||
RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error)
|
||||
}
|
||||
|
||||
@@ -142,8 +142,11 @@ func (s *OAuthService) ExchangeCode(ctx context.Context, input *ExchangeCodeInpu
|
||||
}
|
||||
}
|
||||
|
||||
// Determine if this is a setup token (scope is inference only)
|
||||
isSetupToken := session.Scope == oauth.ScopeInference
|
||||
|
||||
// Exchange code for token
|
||||
tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL)
|
||||
tokenInfo, err := s.exchangeCodeForToken(ctx, input.Code, session.CodeVerifier, session.State, proxyURL, isSetupToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -172,10 +175,12 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
|
||||
}
|
||||
}
|
||||
|
||||
// Determine scope
|
||||
// Determine scope and if this is a setup token
|
||||
scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference)
|
||||
isSetupToken := false
|
||||
if input.Scope == "inference" {
|
||||
scope = oauth.ScopeInference
|
||||
isSetupToken = true
|
||||
}
|
||||
|
||||
// Step 1: Get organization info using sessionKey
|
||||
@@ -203,7 +208,7 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) (
|
||||
}
|
||||
|
||||
// Step 4: Exchange code for token
|
||||
tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL)
|
||||
tokenInfo, err := s.exchangeCodeForToken(ctx, authCode, codeVerifier, state, proxyURL, isSetupToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
@@ -228,8 +233,8 @@ func (s *OAuthService) getAuthorizationCode(ctx context.Context, sessionKey, org
|
||||
}
|
||||
|
||||
// exchangeCodeForToken exchanges authorization code for tokens
|
||||
func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string) (*TokenInfo, error) {
|
||||
tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL)
|
||||
func (s *OAuthService) exchangeCodeForToken(ctx context.Context, code, codeVerifier, state, proxyURL string, isSetupToken bool) (*TokenInfo, error) {
|
||||
tokenResp, err := s.oauthClient.ExchangeCodeForToken(ctx, code, codeVerifier, state, proxyURL, isSetupToken)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ type OpenAIGatewayService struct {
|
||||
rateLimitService *RateLimitService
|
||||
billingCacheService *BillingCacheService
|
||||
httpUpstream HTTPUpstream
|
||||
deferredService *DeferredService
|
||||
}
|
||||
|
||||
// NewOpenAIGatewayService creates a new OpenAIGatewayService
|
||||
@@ -97,6 +98,7 @@ func NewOpenAIGatewayService(
|
||||
rateLimitService *RateLimitService,
|
||||
billingCacheService *BillingCacheService,
|
||||
httpUpstream HTTPUpstream,
|
||||
deferredService *DeferredService,
|
||||
) *OpenAIGatewayService {
|
||||
return &OpenAIGatewayService{
|
||||
accountRepo: accountRepo,
|
||||
@@ -109,6 +111,7 @@ func NewOpenAIGatewayService(
|
||||
rateLimitService: rateLimitService,
|
||||
billingCacheService: billingCacheService,
|
||||
httpUpstream: httpUpstream,
|
||||
deferredService: deferredService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,8 +775,8 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
|
||||
}
|
||||
}
|
||||
|
||||
// Update account last used
|
||||
_ = s.accountRepo.UpdateLastUsed(ctx, account.ID)
|
||||
// Schedule batch update for account last_used_at
|
||||
s.deferredService.ScheduleLastUsedUpdate(account.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
)
|
||||
|
||||
// MaxExpiresAt is the maximum allowed expiration date (year 2099)
|
||||
// This prevents time.Time JSON serialization errors (RFC 3339 requires year <= 9999)
|
||||
var MaxExpiresAt = time.Date(2099, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
|
||||
// MaxValidityDays is the maximum allowed validity days for subscriptions (100 years)
|
||||
const MaxValidityDays = 36500
|
||||
|
||||
var (
|
||||
ErrSubscriptionNotFound = infraerrors.NotFound("SUBSCRIPTION_NOT_FOUND", "subscription not found")
|
||||
ErrSubscriptionExpired = infraerrors.Forbidden("SUBSCRIPTION_EXPIRED", "subscription has expired")
|
||||
@@ -111,6 +118,9 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
||||
if validityDays <= 0 {
|
||||
validityDays = 30
|
||||
}
|
||||
if validityDays > MaxValidityDays {
|
||||
validityDays = MaxValidityDays
|
||||
}
|
||||
|
||||
// 已有订阅,执行续期
|
||||
if existingSub != nil {
|
||||
@@ -125,6 +135,11 @@ func (s *SubscriptionService) AssignOrExtendSubscription(ctx context.Context, in
|
||||
newExpiresAt = now.AddDate(0, 0, validityDays)
|
||||
}
|
||||
|
||||
// 确保不超过最大过期时间
|
||||
if newExpiresAt.After(MaxExpiresAt) {
|
||||
newExpiresAt = MaxExpiresAt
|
||||
}
|
||||
|
||||
// 更新过期时间
|
||||
if err := s.userSubRepo.ExtendExpiry(ctx, existingSub.ID, newExpiresAt); err != nil {
|
||||
return nil, false, fmt.Errorf("extend subscription: %w", err)
|
||||
@@ -189,13 +204,21 @@ func (s *SubscriptionService) createSubscription(ctx context.Context, input *Ass
|
||||
if validityDays <= 0 {
|
||||
validityDays = 30
|
||||
}
|
||||
if validityDays > MaxValidityDays {
|
||||
validityDays = MaxValidityDays
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
expiresAt := now.AddDate(0, 0, validityDays)
|
||||
if expiresAt.After(MaxExpiresAt) {
|
||||
expiresAt = MaxExpiresAt
|
||||
}
|
||||
|
||||
sub := &UserSubscription{
|
||||
UserID: input.UserID,
|
||||
GroupID: input.GroupID,
|
||||
StartsAt: now,
|
||||
ExpiresAt: now.AddDate(0, 0, validityDays),
|
||||
ExpiresAt: expiresAt,
|
||||
Status: SubscriptionStatusActive,
|
||||
AssignedAt: now,
|
||||
Notes: input.Notes,
|
||||
@@ -291,8 +314,17 @@ func (s *SubscriptionService) ExtendSubscription(ctx context.Context, subscripti
|
||||
return nil, ErrSubscriptionNotFound
|
||||
}
|
||||
|
||||
// 限制延长天数
|
||||
if days > MaxValidityDays {
|
||||
days = MaxValidityDays
|
||||
}
|
||||
|
||||
// 计算新的过期时间
|
||||
newExpiresAt := sub.ExpiresAt.AddDate(0, 0, days)
|
||||
if newExpiresAt.After(MaxExpiresAt) {
|
||||
newExpiresAt = MaxExpiresAt
|
||||
}
|
||||
|
||||
if err := s.userSubRepo.ExtendExpiry(ctx, subscriptionID, newExpiresAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
63
backend/internal/service/timing_wheel_service.go
Normal file
63
backend/internal/service/timing_wheel_service.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/collection"
|
||||
)
|
||||
|
||||
// TimingWheelService wraps go-zero's TimingWheel for task scheduling
|
||||
type TimingWheelService struct {
|
||||
tw *collection.TimingWheel
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
// NewTimingWheelService creates a new TimingWheelService instance
|
||||
func NewTimingWheelService() *TimingWheelService {
|
||||
// 1 second tick, 3600 slots = supports up to 1 hour delay
|
||||
// execute function: runs func() type tasks
|
||||
tw, err := collection.NewTimingWheel(1*time.Second, 3600, func(key, value any) {
|
||||
if fn, ok := value.(func()); ok {
|
||||
fn()
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &TimingWheelService{tw: tw}
|
||||
}
|
||||
|
||||
// Start starts the timing wheel
|
||||
func (s *TimingWheelService) Start() {
|
||||
log.Println("[TimingWheel] Started (auto-start by go-zero)")
|
||||
}
|
||||
|
||||
// Stop stops the timing wheel
|
||||
func (s *TimingWheelService) Stop() {
|
||||
s.stopOnce.Do(func() {
|
||||
s.tw.Stop()
|
||||
log.Println("[TimingWheel] Stopped")
|
||||
})
|
||||
}
|
||||
|
||||
// Schedule schedules a one-time task
|
||||
func (s *TimingWheelService) Schedule(name string, delay time.Duration, fn func()) {
|
||||
_ = s.tw.SetTimer(name, fn, delay)
|
||||
}
|
||||
|
||||
// ScheduleRecurring schedules a recurring task
|
||||
func (s *TimingWheelService) ScheduleRecurring(name string, interval time.Duration, fn func()) {
|
||||
var schedule func()
|
||||
schedule = func() {
|
||||
fn()
|
||||
_ = s.tw.SetTimer(name, schedule, interval)
|
||||
}
|
||||
_ = s.tw.SetTimer(name, schedule, interval)
|
||||
}
|
||||
|
||||
// Cancel cancels a scheduled task
|
||||
func (s *TimingWheelService) Cancel(name string) {
|
||||
_ = s.tw.RemoveTimer(name)
|
||||
}
|
||||
@@ -106,6 +106,9 @@ func (s *TokenRefreshService) processRefresh() {
|
||||
return
|
||||
}
|
||||
|
||||
totalAccounts := len(accounts)
|
||||
oauthAccounts := 0 // 可刷新的OAuth账号数
|
||||
needsRefresh := 0 // 需要刷新的账号数
|
||||
refreshed, failed := 0, 0
|
||||
|
||||
for i := range accounts {
|
||||
@@ -117,11 +120,15 @@ func (s *TokenRefreshService) processRefresh() {
|
||||
continue
|
||||
}
|
||||
|
||||
oauthAccounts++
|
||||
|
||||
// 检查是否需要刷新
|
||||
if !refresher.NeedsRefresh(account, refreshWindow) {
|
||||
continue
|
||||
break // 不需要刷新,跳过
|
||||
}
|
||||
|
||||
needsRefresh++
|
||||
|
||||
// 执行刷新
|
||||
if err := s.refreshWithRetry(ctx, account, refresher); err != nil {
|
||||
log.Printf("[TokenRefresh] Account %d (%s) failed: %v", account.ID, account.Name, err)
|
||||
@@ -136,9 +143,9 @@ func (s *TokenRefreshService) processRefresh() {
|
||||
}
|
||||
}
|
||||
|
||||
if refreshed > 0 || failed > 0 {
|
||||
log.Printf("[TokenRefresh] Cycle complete: %d refreshed, %d failed", refreshed, failed)
|
||||
}
|
||||
// 始终打印周期日志,便于跟踪服务运行状态
|
||||
log.Printf("[TokenRefresh] Cycle complete: total=%d, oauth=%d, needs_refresh=%d, refreshed=%d, failed=%d",
|
||||
totalAccounts, oauthAccounts, needsRefresh, refreshed, failed)
|
||||
}
|
||||
|
||||
// listActiveAccounts 获取所有active状态的账号
|
||||
|
||||
@@ -43,18 +43,17 @@ func (r *ClaudeTokenRefresher) CanRefresh(account *Account) bool {
|
||||
// NeedsRefresh 检查token是否需要刷新
|
||||
// 基于 expires_at 字段判断是否在刷新窗口内
|
||||
func (r *ClaudeTokenRefresher) NeedsRefresh(account *Account, refreshWindow time.Duration) bool {
|
||||
expiresAtStr := account.GetCredential("expires_at")
|
||||
if expiresAtStr == "" {
|
||||
s := account.GetCredential("expires_at")
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
expiresAt, err := strconv.ParseInt(expiresAtStr, 10, 64)
|
||||
expiresAt, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
expiryTime := time.Unix(expiresAt, 0)
|
||||
return time.Until(expiryTime) < refreshWindow
|
||||
return time.Until(time.Unix(expiresAt, 0)) < refreshWindow
|
||||
}
|
||||
|
||||
// Refresh 执行token刷新
|
||||
|
||||
214
backend/internal/service/token_refresher_test.go
Normal file
214
backend/internal/service/token_refresher_test.go
Normal file
@@ -0,0 +1,214 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestClaudeTokenRefresher_NeedsRefresh(t *testing.T) {
|
||||
refresher := &ClaudeTokenRefresher{}
|
||||
refreshWindow := 30 * time.Minute
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
wantRefresh bool
|
||||
}{
|
||||
{
|
||||
name: "expires_at as string - expired",
|
||||
credentials: map[string]any{
|
||||
"expires_at": "1000", // 1970-01-01 00:16:40 UTC, 已过期
|
||||
},
|
||||
wantRefresh: true,
|
||||
},
|
||||
{
|
||||
name: "expires_at as float64 - expired",
|
||||
credentials: map[string]any{
|
||||
"expires_at": float64(1000), // 数字类型,已过期
|
||||
},
|
||||
wantRefresh: true,
|
||||
},
|
||||
{
|
||||
name: "expires_at as string - far future",
|
||||
credentials: map[string]any{
|
||||
"expires_at": "9999999999", // 远未来
|
||||
},
|
||||
wantRefresh: false,
|
||||
},
|
||||
{
|
||||
name: "expires_at as float64 - far future",
|
||||
credentials: map[string]any{
|
||||
"expires_at": float64(9999999999), // 远未来,数字类型
|
||||
},
|
||||
wantRefresh: false,
|
||||
},
|
||||
{
|
||||
name: "expires_at missing",
|
||||
credentials: map[string]any{},
|
||||
wantRefresh: false,
|
||||
},
|
||||
{
|
||||
name: "expires_at is nil",
|
||||
credentials: map[string]any{
|
||||
"expires_at": nil,
|
||||
},
|
||||
wantRefresh: false,
|
||||
},
|
||||
{
|
||||
name: "expires_at is invalid string",
|
||||
credentials: map[string]any{
|
||||
"expires_at": "invalid",
|
||||
},
|
||||
wantRefresh: false,
|
||||
},
|
||||
{
|
||||
name: "credentials is nil",
|
||||
credentials: nil,
|
||||
wantRefresh: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: tt.credentials,
|
||||
}
|
||||
|
||||
got := refresher.NeedsRefresh(account, refreshWindow)
|
||||
require.Equal(t, tt.wantRefresh, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeTokenRefresher_NeedsRefresh_WithinWindow(t *testing.T) {
|
||||
refresher := &ClaudeTokenRefresher{}
|
||||
refreshWindow := 30 * time.Minute
|
||||
|
||||
// 设置一个在刷新窗口内的时间(当前时间 + 15分钟)
|
||||
expiresAt := time.Now().Add(15 * time.Minute).Unix()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
}{
|
||||
{
|
||||
name: "string type - within refresh window",
|
||||
credentials: map[string]any{
|
||||
"expires_at": strconv.FormatInt(expiresAt, 10),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "float64 type - within refresh window",
|
||||
credentials: map[string]any{
|
||||
"expires_at": float64(expiresAt),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: tt.credentials,
|
||||
}
|
||||
|
||||
got := refresher.NeedsRefresh(account, refreshWindow)
|
||||
require.True(t, got, "should need refresh when within window")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeTokenRefresher_NeedsRefresh_OutsideWindow(t *testing.T) {
|
||||
refresher := &ClaudeTokenRefresher{}
|
||||
refreshWindow := 30 * time.Minute
|
||||
|
||||
// 设置一个在刷新窗口外的时间(当前时间 + 1小时)
|
||||
expiresAt := time.Now().Add(1 * time.Hour).Unix()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
credentials map[string]any
|
||||
}{
|
||||
{
|
||||
name: "string type - outside refresh window",
|
||||
credentials: map[string]any{
|
||||
"expires_at": strconv.FormatInt(expiresAt, 10),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "float64 type - outside refresh window",
|
||||
credentials: map[string]any{
|
||||
"expires_at": float64(expiresAt),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Credentials: tt.credentials,
|
||||
}
|
||||
|
||||
got := refresher.NeedsRefresh(account, refreshWindow)
|
||||
require.False(t, got, "should not need refresh when outside window")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeTokenRefresher_CanRefresh(t *testing.T) {
|
||||
refresher := &ClaudeTokenRefresher{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
platform string
|
||||
accType string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "anthropic oauth - can refresh",
|
||||
platform: PlatformAnthropic,
|
||||
accType: AccountTypeOAuth,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "anthropic api-key - cannot refresh",
|
||||
platform: PlatformAnthropic,
|
||||
accType: AccountTypeApiKey,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "openai oauth - cannot refresh",
|
||||
platform: PlatformOpenAI,
|
||||
accType: AccountTypeOAuth,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "gemini oauth - cannot refresh",
|
||||
platform: PlatformGemini,
|
||||
accType: AccountTypeOAuth,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
account := &Account{
|
||||
Platform: tt.platform,
|
||||
Type: tt.accType,
|
||||
}
|
||||
|
||||
got := refresher.CanRefresh(account)
|
||||
require.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/google/wire"
|
||||
)
|
||||
@@ -44,6 +46,20 @@ func ProvideTokenRefreshService(
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideTimingWheelService creates and starts TimingWheelService
|
||||
func ProvideTimingWheelService() *TimingWheelService {
|
||||
svc := NewTimingWheelService()
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideDeferredService creates and starts DeferredService
|
||||
func ProvideDeferredService(accountRepo AccountRepository, timingWheel *TimingWheelService) *DeferredService {
|
||||
svc := NewDeferredService(accountRepo, timingWheel, 10*time.Second)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProviderSet is the Wire provider set for all services
|
||||
var ProviderSet = wire.NewSet(
|
||||
// Core services
|
||||
@@ -80,4 +96,6 @@ var ProviderSet = wire.NewSet(
|
||||
NewCRSSyncService,
|
||||
ProvideUpdateService,
|
||||
ProvideTokenRefreshService,
|
||||
ProvideTimingWheelService,
|
||||
ProvideDeferredService,
|
||||
)
|
||||
|
||||
184
deploy/Caddyfile
Normal file
184
deploy/Caddyfile
Normal file
@@ -0,0 +1,184 @@
|
||||
# =============================================================================
|
||||
# Sub2API Caddy Reverse Proxy Configuration (宿主机部署)
|
||||
# =============================================================================
|
||||
# 使用方法:
|
||||
# 1. 安装 Caddy: https://caddyserver.com/docs/install
|
||||
# 2. 修改下方 example.com 为你的域名
|
||||
# 3. 确保域名 DNS 已指向服务器
|
||||
# 4. 复制配置: sudo cp Caddyfile /etc/caddy/Caddyfile
|
||||
# 5. 重载配置: sudo systemctl reload caddy
|
||||
#
|
||||
# Caddy 会自动申请和续期 Let's Encrypt SSL 证书
|
||||
# =============================================================================
|
||||
|
||||
# 全局配置
|
||||
{
|
||||
# Let's Encrypt 邮箱通知
|
||||
email admin@example.com
|
||||
|
||||
# 服务器配置
|
||||
servers {
|
||||
# 启用 HTTP/2 和 HTTP/3
|
||||
protocols h1 h2 h3
|
||||
|
||||
# 超时配置
|
||||
timeouts {
|
||||
read_body 30s
|
||||
read_header 10s
|
||||
write 60s
|
||||
idle 120s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 修改为你的域名
|
||||
example.com {
|
||||
# =========================================================================
|
||||
# TLS 安全配置
|
||||
# =========================================================================
|
||||
tls {
|
||||
# 仅使用 TLS 1.2 和 1.3
|
||||
protocols tls1.2 tls1.3
|
||||
|
||||
# 优先使用的加密套件
|
||||
ciphers TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256 TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 反向代理配置
|
||||
# =========================================================================
|
||||
reverse_proxy localhost:8080 {
|
||||
# 健康检查
|
||||
health_uri /health
|
||||
health_interval 30s
|
||||
health_timeout 10s
|
||||
health_status 200
|
||||
|
||||
# 负载均衡策略(单节点可忽略,多节点时有用)
|
||||
lb_policy round_robin
|
||||
lb_try_duration 5s
|
||||
lb_try_interval 250ms
|
||||
|
||||
# 传递真实客户端信息
|
||||
# 兼容 Cloudflare 和直连:后端应优先读取 CF-Connecting-IP,其次 X-Real-IP
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up X-Forwarded-Host {host}
|
||||
# 保留 Cloudflare 原始头(如果存在)
|
||||
# 后端获取 IP 的优先级建议: CF-Connecting-IP → X-Real-IP → X-Forwarded-For
|
||||
header_up CF-Connecting-IP {http.request.header.CF-Connecting-IP}
|
||||
|
||||
# 连接池优化
|
||||
transport http {
|
||||
keepalive 120s
|
||||
keepalive_idle_conns 256
|
||||
read_buffer 16KB
|
||||
write_buffer 16KB
|
||||
compression off
|
||||
}
|
||||
|
||||
# 故障转移
|
||||
fail_duration 30s
|
||||
max_fails 3
|
||||
unhealthy_status 500 502 503 504
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 压缩配置
|
||||
# =========================================================================
|
||||
encode {
|
||||
zstd
|
||||
gzip 6
|
||||
minimum_length 256
|
||||
match {
|
||||
header Content-Type text/*
|
||||
header Content-Type application/json*
|
||||
header Content-Type application/javascript*
|
||||
header Content-Type application/xml*
|
||||
header Content-Type application/rss+xml*
|
||||
header Content-Type image/svg+xml*
|
||||
}
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 速率限制 (需要 caddy-ratelimit 插件)
|
||||
# 如未安装插件,请注释掉此段
|
||||
# =========================================================================
|
||||
# rate_limit {
|
||||
# zone api {
|
||||
# key {remote_host}
|
||||
# events 100
|
||||
# window 1m
|
||||
# }
|
||||
# }
|
||||
|
||||
# =========================================================================
|
||||
# 安全响应头
|
||||
# =========================================================================
|
||||
header {
|
||||
# 防止点击劫持
|
||||
X-Frame-Options "SAMEORIGIN"
|
||||
|
||||
# XSS 保护
|
||||
X-XSS-Protection "1; mode=block"
|
||||
|
||||
# 防止 MIME 类型嗅探
|
||||
X-Content-Type-Options "nosniff"
|
||||
|
||||
# 引用策略
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
|
||||
# HSTS - 强制 HTTPS (max-age=1年)
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||
|
||||
# 内容安全策略 (根据需要调整)
|
||||
# Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https:;"
|
||||
|
||||
# 权限策略
|
||||
Permissions-Policy "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"
|
||||
|
||||
# 跨域资源策略
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Cross-Origin-Embedder-Policy "require-corp"
|
||||
Cross-Origin-Resource-Policy "same-origin"
|
||||
|
||||
# 移除敏感头
|
||||
-Server
|
||||
-X-Powered-By
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 请求大小限制 (防止大文件攻击)
|
||||
# =========================================================================
|
||||
request_body {
|
||||
max_size 100MB
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 日志配置
|
||||
# =========================================================================
|
||||
log {
|
||||
output file /var/log/caddy/sub2api.log {
|
||||
roll_size 50mb
|
||||
roll_keep 10
|
||||
roll_keep_for 720h
|
||||
}
|
||||
format json
|
||||
level INFO
|
||||
}
|
||||
|
||||
# =========================================================================
|
||||
# 错误处理
|
||||
# =========================================================================
|
||||
handle_errors {
|
||||
respond "{err.status_code} {err.status_text}"
|
||||
}
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
# HTTP 重定向到 HTTPS (Caddy 默认自动处理,此处显式声明)
|
||||
# =============================================================================
|
||||
; http://example.com {
|
||||
; redir https://{host}{uri} permanent
|
||||
; }
|
||||
@@ -281,6 +281,30 @@ To change after installation:
|
||||
sudo systemctl restart sub2api
|
||||
```
|
||||
|
||||
#### Gemini OAuth Configuration
|
||||
|
||||
If you need to use AI Studio OAuth for Gemini accounts, add the OAuth client credentials to the systemd service file:
|
||||
|
||||
1. Edit the service file:
|
||||
```bash
|
||||
sudo nano /etc/systemd/system/sub2api.service
|
||||
```
|
||||
|
||||
2. Add your OAuth credentials in the `[Service]` section (after the existing `Environment=` lines):
|
||||
```ini
|
||||
Environment=GEMINI_OAUTH_CLIENT_ID=your-client-id.apps.googleusercontent.com
|
||||
Environment=GEMINI_OAUTH_CLIENT_SECRET=GOCSPX-your-client-secret
|
||||
```
|
||||
|
||||
3. Reload and restart:
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart sub2api
|
||||
```
|
||||
|
||||
> **Note:** Code Assist OAuth does not require any configuration - it uses the built-in Gemini CLI client.
|
||||
> See the [Gemini OAuth Configuration](#gemini-oauth-configuration) section above for detailed setup instructions.
|
||||
|
||||
#### Application Configuration
|
||||
|
||||
The main config file is at `/etc/sub2api/config.yaml` (created by Setup Wizard).
|
||||
|
||||
@@ -121,8 +121,8 @@ services:
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
ports:
|
||||
- 5433:5432
|
||||
# 注意:不暴露端口到宿主机,应用通过内部网络连接
|
||||
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
|
||||
|
||||
# ===========================================================================
|
||||
# Redis Cache
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="lg" @close="handleClose">
|
||||
<Modal :show="show" :title="t('admin.accounts.editAccount')" size="xl" @close="handleClose">
|
||||
<form v-if="account" @submit.prevent="handleSubmit" class="space-y-5">
|
||||
<div>
|
||||
<label class="input-label">{{ t('common.name') }}</label>
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
:class="[
|
||||
'sticky-header-cell px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
|
||||
'sticky-header-cell py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-dark-400',
|
||||
getAdaptivePaddingClass(),
|
||||
{ 'cursor-pointer hover:bg-gray-100 dark:hover:bg-dark-700': column.sortable },
|
||||
getStickyColumnClass(column, index)
|
||||
]"
|
||||
@@ -81,7 +82,7 @@
|
||||
<tbody class="table-body divide-y divide-gray-200 bg-white dark:divide-dark-700 dark:bg-dark-900">
|
||||
<!-- Loading skeleton -->
|
||||
<tr v-if="loading" v-for="i in 5" :key="i">
|
||||
<td v-for="column in columns" :key="column.key" class="whitespace-nowrap px-6 py-4">
|
||||
<td v-for="column in columns" :key="column.key" :class="['whitespace-nowrap py-4', getAdaptivePaddingClass()]">
|
||||
<div class="animate-pulse">
|
||||
<div class="h-4 w-3/4 rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
@@ -92,7 +93,7 @@
|
||||
<tr v-else-if="!data || data.length === 0">
|
||||
<td
|
||||
:colspan="columns.length"
|
||||
class="px-6 py-12 text-center text-gray-500 dark:text-dark-400"
|
||||
:class="['py-12 text-center text-gray-500 dark:text-dark-400', getAdaptivePaddingClass()]"
|
||||
>
|
||||
<slot name="empty">
|
||||
<div class="flex flex-col items-center">
|
||||
@@ -128,7 +129,8 @@
|
||||
v-for="(column, colIndex) in columns"
|
||||
:key="column.key"
|
||||
:class="[
|
||||
'whitespace-nowrap px-6 py-4 text-sm text-gray-900 dark:text-gray-100',
|
||||
'whitespace-nowrap py-4 text-sm text-gray-900 dark:text-gray-100',
|
||||
getAdaptivePaddingClass(),
|
||||
getStickyColumnClass(column, colIndex)
|
||||
]"
|
||||
>
|
||||
@@ -165,24 +167,46 @@ const checkScrollable = () => {
|
||||
const checkActionsColumnWidth = () => {
|
||||
if (!tableWrapperRef.value) return
|
||||
|
||||
// 查找操作列的表头单元格
|
||||
const actionsHeader = tableWrapperRef.value.querySelector('th:has(button[title*="Expand"], button[title*="展开"])')
|
||||
if (!actionsHeader) return
|
||||
|
||||
// 查找第一行的操作列单元格
|
||||
const firstActionCell = tableWrapperRef.value.querySelector('tbody tr:first-child td:last-child')
|
||||
if (!firstActionCell) return
|
||||
|
||||
// 获取操作列内容的实际宽度
|
||||
const actionsContent = firstActionCell.querySelector('div')
|
||||
if (!actionsContent) return
|
||||
// 查找操作列内容的容器div
|
||||
const actionsContainer = firstActionCell.querySelector('div')
|
||||
if (!actionsContainer) return
|
||||
|
||||
// 比较内容宽度和单元格宽度
|
||||
const contentWidth = actionsContent.scrollWidth
|
||||
const cellWidth = (firstActionCell as HTMLElement).clientWidth
|
||||
// 临时展开以测量完整宽度
|
||||
const wasExpanded = actionsExpanded.value
|
||||
actionsExpanded.value = true
|
||||
|
||||
// 如果内容宽度超过单元格宽度,说明需要展开
|
||||
actionsColumnNeedsExpanding.value = contentWidth > cellWidth
|
||||
// 等待DOM更新
|
||||
nextTick(() => {
|
||||
// 测量所有按钮的总宽度
|
||||
const buttons = actionsContainer.querySelectorAll('button')
|
||||
if (buttons.length <= 2) {
|
||||
actionsColumnNeedsExpanding.value = false
|
||||
actionsExpanded.value = wasExpanded
|
||||
return
|
||||
}
|
||||
|
||||
// 计算所有按钮的总宽度(包括gap)
|
||||
let totalWidth = 0
|
||||
buttons.forEach((btn, index) => {
|
||||
totalWidth += (btn as HTMLElement).offsetWidth
|
||||
if (index < buttons.length - 1) {
|
||||
totalWidth += 4 // gap-1 = 4px
|
||||
}
|
||||
})
|
||||
|
||||
// 获取单元格可用宽度(减去padding)
|
||||
const cellWidth = (firstActionCell as HTMLElement).clientWidth - 32 // 减去左右padding
|
||||
|
||||
// 如果总宽度超过可用宽度,需要展开功能
|
||||
actionsColumnNeedsExpanding.value = totalWidth > cellWidth
|
||||
|
||||
// 恢复原来的展开状态
|
||||
actionsExpanded.value = wasExpanded
|
||||
})
|
||||
}
|
||||
|
||||
// 监听尺寸变化
|
||||
@@ -219,6 +243,7 @@ interface Props {
|
||||
stickyFirstColumn?: boolean
|
||||
stickyActionsColumn?: boolean
|
||||
expandableActions?: boolean
|
||||
actionsCount?: number // 操作按钮总数,用于判断是否需要展开功能
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -232,9 +257,10 @@ const sortKey = ref<string>('')
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
const actionsExpanded = ref(false)
|
||||
|
||||
// 数据/列/展开状态变化时重新检查滚动状态
|
||||
// 数据/列变化时重新检查滚动状态
|
||||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||
watch(
|
||||
[() => props.data.length, () => props.columns, actionsExpanded],
|
||||
[() => props.data.length, () => props.columns],
|
||||
async () => {
|
||||
await nextTick()
|
||||
checkScrollable()
|
||||
@@ -243,6 +269,12 @@ watch(
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
// 单独监听展开状态变化,只更新滚动状态
|
||||
watch(actionsExpanded, async () => {
|
||||
await nextTick()
|
||||
checkScrollable()
|
||||
})
|
||||
|
||||
const handleSort = (key: string) => {
|
||||
if (sortKey.value === key) {
|
||||
sortOrder.value = sortOrder.value === 'asc' ? 'desc' : 'asc'
|
||||
@@ -268,6 +300,12 @@ const sortedData = computed(() => {
|
||||
|
||||
// 检查是否有可展开的操作列
|
||||
const hasExpandableActions = computed(() => {
|
||||
// 如果明确指定了actionsCount,使用它来判断
|
||||
if (props.actionsCount !== undefined) {
|
||||
return props.expandableActions && props.columns.some((col) => col.key === 'actions') && props.actionsCount > 2
|
||||
}
|
||||
|
||||
// 否则使用原来的检测逻辑
|
||||
return (
|
||||
props.expandableActions &&
|
||||
props.columns.some((col) => col.key === 'actions') &&
|
||||
@@ -312,6 +350,22 @@ const getStickyColumnClass = (column: Column, index: number) => {
|
||||
|
||||
return classes.join(' ')
|
||||
}
|
||||
|
||||
// 根据列数自适应调整内边距
|
||||
const getAdaptivePaddingClass = () => {
|
||||
const columnCount = props.columns.length
|
||||
|
||||
// 列数越多,内边距越小
|
||||
if (columnCount >= 10) {
|
||||
return 'px-2' // 8px
|
||||
} else if (columnCount >= 7) {
|
||||
return 'px-3' // 12px
|
||||
} else if (columnCount >= 5) {
|
||||
return 'px-4' // 16px
|
||||
} else {
|
||||
return 'px-6' // 24px (原始值)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1172,9 +1172,9 @@ export default {
|
||||
batchAdd: 'Quick Add',
|
||||
batchInput: 'Proxy List',
|
||||
batchInputPlaceholder:
|
||||
"Enter one proxy per line in the following formats:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
||||
"Enter one proxy per line in the following formats:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
|
||||
batchInputHint:
|
||||
"Supports http, https, socks5 protocols. Format: protocol://[user:pass{'@'}]host:port",
|
||||
"Supports http, https, socks5 protocols. Format: protocol://[user:pass@]host:port",
|
||||
parsedCount: '{count} valid',
|
||||
invalidCount: '{count} invalid',
|
||||
duplicateCount: '{count} duplicate',
|
||||
@@ -1351,12 +1351,12 @@ export default {
|
||||
port: 'SMTP Port',
|
||||
portPlaceholder: '587',
|
||||
username: 'SMTP Username',
|
||||
usernamePlaceholder: 'your-email@gmail.com',
|
||||
usernamePlaceholder: "your-email{'@'}gmail.com",
|
||||
password: 'SMTP Password',
|
||||
passwordPlaceholder: '********',
|
||||
passwordHint: 'Leave empty to keep existing password',
|
||||
fromEmail: 'From Email',
|
||||
fromEmailPlaceholder: 'noreply@example.com',
|
||||
fromEmailPlaceholder: "noreply{'@'}example.com",
|
||||
fromName: 'From Name',
|
||||
fromNamePlaceholder: 'Sub2API',
|
||||
useTls: 'Use TLS',
|
||||
@@ -1366,7 +1366,7 @@ export default {
|
||||
title: 'Send Test Email',
|
||||
description: 'Send a test email to verify your SMTP configuration',
|
||||
recipientEmail: 'Recipient Email',
|
||||
recipientEmailPlaceholder: 'test@example.com',
|
||||
recipientEmailPlaceholder: "test{'@'}example.com",
|
||||
sendTestEmail: 'Send Test Email',
|
||||
sending: 'Sending...',
|
||||
enterRecipientHint: 'Please enter a recipient email address'
|
||||
|
||||
@@ -1321,8 +1321,8 @@ export default {
|
||||
batchAdd: '快捷添加',
|
||||
batchInput: '代理列表',
|
||||
batchInputPlaceholder:
|
||||
"每行输入一个代理,支持以下格式:\nsocks5://user:pass{'@'}192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass{'@'}proxy.example.com:443",
|
||||
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码{'@'}]主机:端口",
|
||||
"每行输入一个代理,支持以下格式:\nsocks5://user:pass@192.168.1.1:1080\nhttp://192.168.1.1:8080\nhttps://user:pass@proxy.example.com:443",
|
||||
batchInputHint: "支持 http、https、socks5 协议,格式:协议://[用户名:密码@]主机:端口",
|
||||
parsedCount: '有效 {count} 个',
|
||||
invalidCount: '无效 {count} 个',
|
||||
duplicateCount: '重复 {count} 个',
|
||||
@@ -1549,12 +1549,12 @@ export default {
|
||||
port: 'SMTP 端口',
|
||||
portPlaceholder: '587',
|
||||
username: 'SMTP 用户名',
|
||||
usernamePlaceholder: 'your-email@gmail.com',
|
||||
usernamePlaceholder: "your-email{'@'}gmail.com",
|
||||
password: 'SMTP 密码',
|
||||
passwordPlaceholder: '********',
|
||||
passwordHint: '留空以保留现有密码',
|
||||
fromEmail: '发件人邮箱',
|
||||
fromEmailPlaceholder: 'noreply@example.com',
|
||||
fromEmailPlaceholder: "noreply{'@'}example.com",
|
||||
fromName: '发件人名称',
|
||||
fromNamePlaceholder: 'Sub2API',
|
||||
useTls: '使用 TLS',
|
||||
@@ -1564,7 +1564,7 @@ export default {
|
||||
title: '发送测试邮件',
|
||||
description: '发送测试邮件以验证 SMTP 配置',
|
||||
recipientEmail: '收件人邮箱',
|
||||
recipientEmailPlaceholder: 'test@example.com',
|
||||
recipientEmailPlaceholder: "test{'@'}example.com",
|
||||
sendTestEmail: '发送测试邮件',
|
||||
sending: '发送中...',
|
||||
enterRecipientHint: '请输入收件人邮箱地址'
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading">
|
||||
<DataTable :columns="columns" :data="accounts" :loading="loading" :actions-count="6">
|
||||
<template #cell-select="{ row }">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
|
||||
<!-- Users Table -->
|
||||
<template #table>
|
||||
<DataTable :columns="columns" :data="users" :loading="loading">
|
||||
<DataTable :columns="columns" :data="users" :loading="loading" :actions-count="7">
|
||||
<template #cell-email="{ value }">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
|
||||
2
frontend/vite.config.d.ts
vendored
2
frontend/vite.config.d.ts
vendored
@@ -1,2 +0,0 @@
|
||||
declare const _default: import('vite').UserConfig
|
||||
export default _default
|
||||
Reference in New Issue
Block a user