diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index f8b22ee70..15ff97fe0 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -20,7 +20,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: Unit tests working-directory: backend run: make test-unit @@ -60,7 +60,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: golangci-lint uses: golangci/golangci-lint-action@v9 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 26ed85241..80bc9850d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -115,7 +115,7 @@ jobs: - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' # Docker setup for GoReleaser - name: Set up QEMU diff --git a/.github/workflows/security-scan.yml b/.github/workflows/security-scan.yml index 600fd2fae..ef8e59e54 100644 --- a/.github/workflows/security-scan.yml +++ b/.github/workflows/security-scan.yml @@ -23,7 +23,7 @@ jobs: cache-dependency-path: backend/go.sum - name: Verify Go version run: | - go version | grep -q 'go1.26.2' + go version | grep -q 'go1.26.3' - name: Run govulncheck working-directory: backend run: | diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 92ba39169..8f97c9454 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -9,7 +9,6 @@ linters: - govet - ineffassign - staticcheck - - unused settings: depguard: diff --git a/backend/Dockerfile b/backend/Dockerfile index 13f16df42..f153d6866 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.26.2-alpine +FROM golang:1.26.3-alpine WORKDIR /app diff --git a/backend/cmd/server/VERSION b/backend/cmd/server/VERSION index 8955a0173..adb7b04cb 100644 --- a/backend/cmd/server/VERSION +++ b/backend/cmd/server/VERSION @@ -1 +1 @@ -1.0.26 +1.0.27 diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 22c76d472..6eb85e3b1 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -7,11 +7,14 @@ import ( _ "embed" "errors" "flag" + "fmt" "log" + "net" "net/http" - _ "net/http/pprof" + _ "net/http/pprof" //nolint:gosec // Admin/debug profiling is intentionally exposed only when the server is started with that route mounted. "os" "os/signal" + "path/filepath" "strconv" "strings" "syscall" @@ -27,7 +30,7 @@ import ( "github.com/gin-gonic/gin" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. ) //go:embed VERSION @@ -120,12 +123,15 @@ func runSetupServer() { server := &http.Server{ Addr: addr, - Handler: h2c.NewHandler(r, &http2.Server{}), + Handler: h2c.NewHandler(r, &http2.Server{}), //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. ReadHeaderTimeout: 30 * time.Second, IdleTimeout: 120 * time.Second, } - if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := serveServer(server, config.ServerListenSpec{ + Network: config.ServerListenNetworkTCP, + Address: addr, + }); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Failed to start setup server: %v", err) } } @@ -154,15 +160,19 @@ func runMainServer() { defer app.Cleanup() pprofServer := startPprofServer() + listenSpec, err := cfg.Server.ListenSpec() + if err != nil { + log.Fatalf("Invalid server listen configuration: %v", err) + } // 启动服务器 go func() { - if err := app.Server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + if err := serveServer(app.Server, listenSpec); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("Failed to start server: %v", err) } }() - log.Printf("Server started on %s", app.Server.Addr) + log.Printf("Server started on %s", listenSpec.DisplayAddress()) // 等待中断信号 quit := make(chan os.Signal, 1) @@ -187,6 +197,35 @@ func runMainServer() { log.Println("Server exited") } +func serveServer(server *http.Server, spec config.ServerListenSpec) error { + switch spec.Network { + case config.ServerListenNetworkUnix: + if err := os.MkdirAll(filepath.Dir(spec.Address), 0o755); err != nil { + return fmt.Errorf("create unix socket directory: %w", err) + } + if err := config.RemoveUnixSocketIfExists(spec.Address); err != nil { + return err + } + listener, err := net.Listen(string(spec.Network), spec.Address) + if err != nil { + return fmt.Errorf("listen on %s: %w", spec.DisplayAddress(), err) + } + if err := os.Chmod(spec.Address, spec.Mode); err != nil { + _ = listener.Close() + _ = os.Remove(spec.Address) + return fmt.Errorf("chmod unix socket %s: %w", spec.Address, err) + } + defer func() { + _ = listener.Close() + _ = os.Remove(spec.Address) + }() + return server.Serve(listener) + default: + server.Addr = spec.Address + return server.ListenAndServe() + } +} + func startPprofServer() *http.Server { enabledValue := strings.TrimSpace(os.Getenv("PPROF_ENABLED")) if enabledValue == "" { diff --git a/backend/go.mod b/backend/go.mod index 982bf91b9..caf35ea4a 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/Wei-Shaw/sub2api -go 1.26.2 +go 1.26.3 require ( entgo.io/ent v0.14.5 @@ -20,6 +20,7 @@ require ( github.com/google/wire v0.7.0 github.com/gorilla/websocket v1.5.3 github.com/imroc/req/v3 v3.57.0 + github.com/klauspost/compress v1.18.2 github.com/lib/pq v1.10.9 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pquerna/otp v1.5.0 @@ -39,11 +40,11 @@ require ( github.com/wechatpay-apiv3/wechatpay-go v0.2.21 github.com/zeromicro/go-zero v1.9.4 go.uber.org/zap v1.24.0 - golang.org/x/crypto v0.49.0 + golang.org/x/crypto v0.51.0 golang.org/x/image v0.39.0 - golang.org/x/net v0.52.0 + golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 - golang.org/x/term v0.41.0 + golang.org/x/term v0.43.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.44.3 @@ -104,13 +105,11 @@ require ( 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/subcommands v1.2.0 // 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 github.com/icholy/digest v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.18.2 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect @@ -173,10 +172,10 @@ require ( go.uber.org/multierr v1.9.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect - golang.org/x/mod v0.34.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect - golang.org/x/tools v0.43.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect google.golang.org/grpc v1.75.1 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0f366ee10..29fab3969 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -162,8 +162,6 @@ github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17 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= -github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= -github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= @@ -183,8 +181,6 @@ github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4= github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y= github.com/imroc/req/v3 v3.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI= github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00= -github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= -github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -220,8 +216,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM= github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= @@ -255,8 +249,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= -github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= -github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= @@ -286,8 +278,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= @@ -320,8 +310,6 @@ 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= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= -github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= -github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= @@ -413,16 +401,16 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= -golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= -golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -434,16 +422,16 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc 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= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= -golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= -golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU= diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 02b3a9132..9ed78dd3e 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -4,10 +4,13 @@ package config import ( "crypto/rand" "encoding/hex" + "errors" "fmt" "log/slog" "net/url" "os" + pathpkg "path" + "strconv" "strings" "time" @@ -530,6 +533,139 @@ type ServerConfig struct { H2C H2CConfig `mapstructure:"h2c"` // HTTP/2 Cleartext 配置 } +type ServerListenNetwork string + +const ( + ServerListenNetworkTCP ServerListenNetwork = "tcp" + ServerListenNetworkUnix ServerListenNetwork = "unix" +) + +const ( + defaultUnixSocketFileMode os.FileMode = 0o660 +) + +type ServerListenSpec struct { + Network ServerListenNetwork + Address string + Mode os.FileMode +} + +func (s ServerListenSpec) DisplayAddress() string { + switch s.Network { + case ServerListenNetworkUnix: + return fmt.Sprintf("unix://%s", s.Address) + default: + return s.Address + } +} + +func ParseServerListenSpec(host string, port int) (ServerListenSpec, error) { + rawHost := strings.TrimSpace(host) + if rawHost == "" { + rawHost = "0.0.0.0" + } + + socketMode := defaultUnixSocketFileMode + switch { + case strings.HasPrefix(rawHost, "unix:"): + return parseUnixSocketListenSpec(strings.TrimSpace(strings.TrimPrefix(rawHost, "unix:")), socketMode) + case strings.HasPrefix(rawHost, "/"): + return parseUnixSocketListenSpec(rawHost, socketMode) + default: + if port <= 0 || port > 65535 { + return ServerListenSpec{}, fmt.Errorf("tcp listen port must be between 1-65535") + } + return ServerListenSpec{ + Network: ServerListenNetworkTCP, + Address: fmt.Sprintf("%s:%d", rawHost, port), + }, nil + } +} + +func parseUnixSocketListenSpec(raw string, defaultMode os.FileMode) (ServerListenSpec, error) { + socketPath := strings.TrimSpace(raw) + if socketPath == "" { + return ServerListenSpec{}, fmt.Errorf("unix socket path is required") + } + + mode := defaultMode + if idx := strings.LastIndex(socketPath, ","); idx >= 0 { + maybeMode := strings.TrimSpace(socketPath[idx+1:]) + if maybeMode != "" { + parsedMode, err := parseUnixSocketFileMode(maybeMode) + if err != nil { + return ServerListenSpec{}, err + } + mode = parsedMode + socketPath = strings.TrimSpace(socketPath[:idx]) + } + } + + if socketPath == "" { + return ServerListenSpec{}, fmt.Errorf("unix socket path is required") + } + if !strings.HasPrefix(socketPath, "/") { + return ServerListenSpec{}, fmt.Errorf("unix socket path must be absolute") + } + cleanPath := pathpkg.Clean(socketPath) + if cleanPath == "/" { + return ServerListenSpec{}, fmt.Errorf("unix socket path cannot be root directory") + } + + return ServerListenSpec{ + Network: ServerListenNetworkUnix, + Address: cleanPath, + Mode: mode, + }, nil +} + +func parseUnixSocketFileMode(raw string) (os.FileMode, error) { + value := strings.TrimSpace(raw) + if value == "" { + return 0, fmt.Errorf("unix socket file mode cannot be empty") + } + if strings.HasPrefix(value, "0o") || strings.HasPrefix(value, "0O") { + value = value[2:] + } + if strings.HasPrefix(value, "0") && len(value) > 1 { + value = value[1:] + } + if len(value) < 3 || len(value) > 4 { + return 0, fmt.Errorf("unix socket file mode must be 3-4 octal digits") + } + for _, r := range value { + if r < '0' || r > '7' { + return 0, fmt.Errorf("unix socket file mode must be octal") + } + } + parsed, err := strconv.ParseUint(value, 8, 32) + if err != nil { + return 0, fmt.Errorf("parse unix socket file mode: %w", err) + } + mode := os.FileMode(parsed) + if mode&os.ModeType != 0 { + return 0, fmt.Errorf("unix socket file mode must not include file type bits") + } + return mode, nil +} + +func RemoveUnixSocketIfExists(path string) error { + info, err := os.Lstat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat unix socket %q: %w", path, err) + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("refusing to remove non-socket file at %q", path) + } + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove stale unix socket %q: %w", path, err) + } + return nil +} + // H2CConfig HTTP/2 Cleartext 配置 type H2CConfig struct { Enabled bool `mapstructure:"enabled"` // 是否启用 H2C @@ -967,6 +1103,21 @@ func (s *ServerConfig) Address() string { return fmt.Sprintf("%s:%d", s.Host, s.Port) } +func (s *ServerConfig) ListenSpec() (ServerListenSpec, error) { + if s == nil { + return ParseServerListenSpec("", 8080) + } + return ParseServerListenSpec(s.Host, s.Port) +} + +func (s *ServerConfig) DisplayAddress() string { + spec, err := s.ListenSpec() + if err != nil { + return s.Address() + } + return spec.DisplayAddress() +} + // DatabaseConfig 数据库连接配置 // 性能优化:新增连接池参数,避免频繁创建/销毁连接 type DatabaseConfig struct { @@ -1041,10 +1192,76 @@ type RedisConfig struct { EnableTLS bool `mapstructure:"enable_tls"` } +type RedisConnectionNetwork string + +const ( + RedisConnectionNetworkTCP RedisConnectionNetwork = "tcp" + RedisConnectionNetworkUnix RedisConnectionNetwork = "unix" +) + +type RedisConnectionSpec struct { + Network RedisConnectionNetwork + Address string +} + +func ParseRedisConnectionSpec(host string, port int) (RedisConnectionSpec, error) { + rawHost := strings.TrimSpace(host) + if rawHost == "" { + rawHost = "localhost" + } + + switch { + case strings.HasPrefix(rawHost, "unix:"): + return parseRedisUnixSocketSpec(strings.TrimSpace(strings.TrimPrefix(rawHost, "unix:"))) + case strings.HasPrefix(rawHost, "/"): + return parseRedisUnixSocketSpec(rawHost) + default: + if port <= 0 || port > 65535 { + return RedisConnectionSpec{}, fmt.Errorf("tcp redis port must be between 1-65535") + } + return RedisConnectionSpec{ + Network: RedisConnectionNetworkTCP, + Address: fmt.Sprintf("%s:%d", rawHost, port), + }, nil + } +} + +func parseRedisUnixSocketSpec(raw string) (RedisConnectionSpec, error) { + socketPath := strings.TrimSpace(raw) + if socketPath == "" { + return RedisConnectionSpec{}, fmt.Errorf("redis unix socket path is required") + } + if !strings.HasPrefix(socketPath, "/") { + return RedisConnectionSpec{}, fmt.Errorf("redis unix socket path must be absolute") + } + cleanPath := pathpkg.Clean(socketPath) + if cleanPath == "/" { + return RedisConnectionSpec{}, fmt.Errorf("redis unix socket path cannot be root directory") + } + return RedisConnectionSpec{ + Network: RedisConnectionNetworkUnix, + Address: cleanPath, + }, nil +} + func (r *RedisConfig) Address() string { return fmt.Sprintf("%s:%d", r.Host, r.Port) } +func (r *RedisConfig) ConnectionSpec() (RedisConnectionSpec, error) { + if r == nil { + return ParseRedisConnectionSpec("", 6379) + } + spec, err := ParseRedisConnectionSpec(r.Host, r.Port) + if err != nil { + return RedisConnectionSpec{}, err + } + if spec.Network == RedisConnectionNetworkUnix && r.EnableTLS { + return RedisConnectionSpec{}, fmt.Errorf("redis.enable_tls is not supported with unix socket connections") + } + return spec, nil +} + type OpsConfig struct { // Enabled controls whether ops features should run. // @@ -1949,6 +2166,9 @@ func (c *Config) Validate() error { } warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL) } + if _, err := c.Server.ListenSpec(); err != nil { + return fmt.Errorf("server listen config invalid: %w", err) + } if c.JWT.ExpireHour <= 0 { return fmt.Errorf("jwt.expire_hour must be positive") } @@ -2190,6 +2410,9 @@ func (c *Config) Validate() error { if c.Redis.MinIdleConns > c.Redis.PoolSize { return fmt.Errorf("redis.min_idle_conns cannot exceed redis.pool_size") } + if _, err := c.Redis.ConnectionSpec(); err != nil { + return fmt.Errorf("redis connection config invalid: %w", err) + } if c.Dashboard.Enabled { if c.Dashboard.StatsFreshTTLSeconds <= 0 { return fmt.Errorf("dashboard_cache.stats_fresh_ttl_seconds must be positive") diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 40d73605c..ba19aa23f 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -1,8 +1,11 @@ package config import ( + "net" "os" + pathpkg "path" "path/filepath" + "runtime" "strings" "testing" "time" @@ -210,6 +213,144 @@ func TestLoadDefaultIdempotencyConfig(t *testing.T) { } } +func TestServerListenSpecTCP(t *testing.T) { + server := &ServerConfig{Host: "127.0.0.1", Port: 9000} + + spec, err := server.ListenSpec() + if err != nil { + t.Fatalf("ListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkTCP { + t.Fatalf("ListenSpec().Network = %q, want %q", spec.Network, ServerListenNetworkTCP) + } + if spec.Address != "127.0.0.1:9000" { + t.Fatalf("ListenSpec().Address = %q", spec.Address) + } + if spec.DisplayAddress() != "127.0.0.1:9000" { + t.Fatalf("ListenSpec().DisplayAddress() = %q", spec.DisplayAddress()) + } +} + +func TestServerListenSpecUnixCompat(t *testing.T) { + spec, err := ParseServerListenSpec("/var/run/pixel.sock,0660", 0) + if err != nil { + t.Fatalf("ParseServerListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, ServerListenNetworkUnix) + } + if spec.Address != pathpkg.Clean("/var/run/pixel.sock") { + t.Fatalf("Address = %q", spec.Address) + } + if spec.Mode != 0o660 { + t.Fatalf("Mode = %#o, want 0660", spec.Mode) + } + if spec.DisplayAddress() != "unix:///var/run/pixel.sock" { + t.Fatalf("DisplayAddress() = %q", spec.DisplayAddress()) + } +} + +func TestServerListenSpecUnixExplicitPrefix(t *testing.T) { + spec, err := ParseServerListenSpec("unix:/run/pixel/pixel.sock,0600", 8080) + if err != nil { + t.Fatalf("ParseServerListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, ServerListenNetworkUnix) + } + if spec.Address != pathpkg.Clean("/run/pixel/pixel.sock") { + t.Fatalf("Address = %q", spec.Address) + } + if spec.Mode != 0o600 { + t.Fatalf("Mode = %#o, want 0600", spec.Mode) + } +} + +func TestServerListenSpecUnixDefaultMode(t *testing.T) { + spec, err := ParseServerListenSpec("unix:/run/pixel/pixel.sock", 8080) + if err != nil { + t.Fatalf("ParseServerListenSpec() error: %v", err) + } + if spec.Mode != defaultUnixSocketFileMode { + t.Fatalf("Mode = %#o, want %#o", spec.Mode, defaultUnixSocketFileMode) + } +} + +func TestServerListenSpecRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + host string + port int + }{ + {name: "tcp_missing_port", host: "127.0.0.1", port: 0}, + {name: "unix_relative", host: "unix:pixel.sock", port: 8080}, + {name: "unix_bad_mode", host: "unix:/run/pixel.sock,09", port: 8080}, + {name: "unix_empty_path", host: "unix:", port: 8080}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseServerListenSpec(tt.host, tt.port); err == nil { + t.Fatalf("ParseServerListenSpec(%q, %d) expected error", tt.host, tt.port) + } + }) + } +} + +func TestRemoveUnixSocketIfExists(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("unix domain sockets are not available in this Windows test environment") + } + + dir := t.TempDir() + socketPath := filepath.Join(dir, "pixel.sock") + + ln, err := net.Listen("unix", socketPath) + if err != nil { + t.Fatalf("net.Listen(unix) error: %v", err) + } + if err := ln.Close(); err != nil { + t.Fatalf("close unix listener: %v", err) + } + + if err := RemoveUnixSocketIfExists(socketPath); err != nil { + t.Fatalf("RemoveUnixSocketIfExists() error: %v", err) + } + if _, err := os.Stat(socketPath); !os.IsNotExist(err) { + t.Fatalf("socket path still exists after removal, stat err=%v", err) + } +} + +func TestRemoveUnixSocketIfExistsRejectsNonSocket(t *testing.T) { + dir := t.TempDir() + plainFile := filepath.Join(dir, "pixel.sock") + if err := os.WriteFile(plainFile, []byte("not-a-socket"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + if err := RemoveUnixSocketIfExists(plainFile); err == nil { + t.Fatal("RemoveUnixSocketIfExists() expected error for non-socket file") + } +} + +func TestValidateAllowsUnixSocketServerHost(t *testing.T) { + resetViperWithJWTSecret(t) + t.Setenv("SERVER_HOST", "unix:/run/pixel/pixel.sock,0660") + t.Setenv("SERVER_PORT", "0") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error: %v", err) + } + spec, err := cfg.Server.ListenSpec() + if err != nil { + t.Fatalf("ListenSpec() error: %v", err) + } + if spec.Network != ServerListenNetworkUnix { + t.Fatalf("ListenSpec().Network = %q, want unix", spec.Network) + } +} + func TestLoadIdempotencyConfigFromEnv(t *testing.T) { resetViperWithJWTSecret(t) t.Setenv("IDEMPOTENCY_OBSERVE_ONLY", "false") @@ -762,6 +903,74 @@ func TestConfigAddressHelpers(t *testing.T) { } } +func TestRedisConnectionSpecTCP(t *testing.T) { + redis := RedisConfig{Host: "redis", Port: 6379} + + spec, err := redis.ConnectionSpec() + if err != nil { + t.Fatalf("ConnectionSpec() error: %v", err) + } + if spec.Network != RedisConnectionNetworkTCP { + t.Fatalf("ConnectionSpec().Network = %q, want %q", spec.Network, RedisConnectionNetworkTCP) + } + if spec.Address != "redis:6379" { + t.Fatalf("ConnectionSpec().Address = %q", spec.Address) + } +} + +func TestRedisConnectionSpecUnix(t *testing.T) { + spec, err := ParseRedisConnectionSpec("unix:/run/redis/redis.sock", 0) + if err != nil { + t.Fatalf("ParseRedisConnectionSpec() error: %v", err) + } + if spec.Network != RedisConnectionNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, RedisConnectionNetworkUnix) + } + if spec.Address != pathpkg.Clean("/run/redis/redis.sock") { + t.Fatalf("Address = %q", spec.Address) + } +} + +func TestRedisConnectionSpecUnixCompat(t *testing.T) { + spec, err := ParseRedisConnectionSpec("/var/run/redis.sock", 6379) + if err != nil { + t.Fatalf("ParseRedisConnectionSpec() error: %v", err) + } + if spec.Network != RedisConnectionNetworkUnix { + t.Fatalf("Network = %q, want %q", spec.Network, RedisConnectionNetworkUnix) + } + if spec.Address != pathpkg.Clean("/var/run/redis.sock") { + t.Fatalf("Address = %q", spec.Address) + } +} + +func TestRedisConnectionSpecRejectsInvalidValues(t *testing.T) { + tests := []struct { + name string + host string + port int + }{ + {name: "tcp_missing_port", host: "redis", port: 0}, + {name: "unix_relative", host: "unix:redis.sock", port: 0}, + {name: "unix_empty_path", host: "unix:", port: 0}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if _, err := ParseRedisConnectionSpec(tt.host, tt.port); err == nil { + t.Fatalf("ParseRedisConnectionSpec(%q, %d) expected error", tt.host, tt.port) + } + }) + } +} + +func TestRedisConnectionSpecRejectsTLSWithUnixSocket(t *testing.T) { + redis := RedisConfig{Host: "unix:/run/redis/redis.sock", EnableTLS: true} + if _, err := redis.ConnectionSpec(); err == nil { + t.Fatal("ConnectionSpec() expected error for TLS over unix socket") + } +} + func TestNormalizeStringSlice(t *testing.T) { values := normalizeStringSlice([]string{" a ", "", "b", " ", "c"}) if len(values) != 3 || values[0] != "a" || values[1] != "b" || values[2] != "c" { diff --git a/backend/internal/domain/constants.go b/backend/internal/domain/constants.go index 7ee3353dc..27431f2fb 100644 --- a/backend/internal/domain/constants.go +++ b/backend/internal/domain/constants.go @@ -103,12 +103,10 @@ var DefaultAntigravityModelMapping = map[string]string{ "claude-haiku-4-5": "claude-sonnet-4-6", "claude-haiku-4-5-20251001": "claude-sonnet-4-6", // Gemini 2.5 白名单 - "gemini-2.5-flash": "gemini-2.5-flash", - "gemini-2.5-flash-image": "gemini-2.5-flash-image", - "gemini-2.5-flash-image-preview": "gemini-2.5-flash-image", - "gemini-2.5-flash-lite": "gemini-2.5-flash-lite", - "gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking", - "gemini-2.5-pro": "gemini-2.5-pro", + "gemini-2.5-flash": "gemini-2.5-flash", + "gemini-2.5-flash-lite": "gemini-2.5-flash-lite", + "gemini-2.5-flash-thinking": "gemini-2.5-flash-thinking", + "gemini-2.5-pro": "gemini-2.5-pro", // Gemini 3 白名单 "gemini-3-flash": "gemini-3-flash", "gemini-3-pro-high": "gemini-3-pro-high", @@ -121,13 +119,6 @@ var DefaultAntigravityModelMapping = map[string]string{ "gemini-3.1-pro-low": "gemini-3.1-pro-low", // Gemini 3.1 preview 映射 "gemini-3.1-pro-preview": "gemini-3.1-pro-high", - // Gemini 3.1 image 白名单 - "gemini-3.1-flash-image": "gemini-3.1-flash-image", - // Gemini 3.1 image preview 映射 - "gemini-3.1-flash-image-preview": "gemini-3.1-flash-image", - // Gemini 3 image 兼容映射(向 3.1 image 迁移) - "gemini-3-pro-image": "gemini-3.1-flash-image", - "gemini-3-pro-image-preview": "gemini-3.1-flash-image", // 其他官方模型 "gpt-oss-120b-medium": "gpt-oss-120b-medium", "tab_flash_lite_preview": "tab_flash_lite_preview", diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index 2d2af2403..a629bb723 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -289,6 +289,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { PaymentProductNameSuffix: paymentCfg.ProductNameSuffix, PaymentAnnouncementText: paymentCfg.AnnouncementText, PaymentRechargeCenterItems: rechargeCenterItemsToDTO(paymentCfg.RechargeCenterItems), + PaymentRechargeCenterTabEnabled: paymentCfg.RechargeCenterTabEnabled, + PaymentRechargeTabEnabled: paymentCfg.RechargeTabEnabled, + PaymentSubscriptionTabEnabled: paymentCfg.SubscriptionTabEnabled, PaymentHelpImageURL: paymentCfg.HelpImageURL, PaymentHelpText: paymentCfg.HelpText, PaymentReceiptCodeOSSEnabled: paymentCfg.ReceiptCodeOSS.Enabled, @@ -608,6 +611,9 @@ type UpdateSettingsRequest struct { PaymentProductNameSuffix *string `json:"payment_product_name_suffix"` PaymentAnnouncementText *string `json:"payment_announcement_text"` PaymentRechargeCenterItems []dto.PaymentRechargeCenterItem `json:"payment_recharge_center_items"` + PaymentRechargeCenterTabEnabled *bool `json:"payment_recharge_center_tab_enabled"` + PaymentRechargeTabEnabled *bool `json:"payment_recharge_tab_enabled"` + PaymentSubscriptionTabEnabled *bool `json:"payment_subscription_tab_enabled"` PaymentHelpImageURL *string `json:"payment_help_image_url"` PaymentHelpText *string `json:"payment_help_text"` @@ -1722,6 +1728,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { ProductNameSuffix: req.PaymentProductNameSuffix, AnnouncementText: req.PaymentAnnouncementText, RechargeCenterItems: rechargeCenterItemsFromDTO(req.PaymentRechargeCenterItems), + RechargeCenterTabEnabled: req.PaymentRechargeCenterTabEnabled, + RechargeTabEnabled: req.PaymentRechargeTabEnabled, + SubscriptionTabEnabled: req.PaymentSubscriptionTabEnabled, HelpImageURL: req.PaymentHelpImageURL, HelpText: req.PaymentHelpText, ReceiptCodeOSSEnabled: req.PaymentReceiptCodeOSSEnabled, @@ -1934,6 +1943,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix, PaymentAnnouncementText: updatedPaymentCfg.AnnouncementText, PaymentRechargeCenterItems: rechargeCenterItemsToDTO(updatedPaymentCfg.RechargeCenterItems), + PaymentRechargeCenterTabEnabled: updatedPaymentCfg.RechargeCenterTabEnabled, + PaymentRechargeTabEnabled: updatedPaymentCfg.RechargeTabEnabled, + PaymentSubscriptionTabEnabled: updatedPaymentCfg.SubscriptionTabEnabled, PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL, PaymentHelpText: updatedPaymentCfg.HelpText, PaymentReceiptCodeOSSEnabled: updatedPaymentCfg.ReceiptCodeOSS.Enabled, @@ -1978,6 +1990,8 @@ func hasPaymentFields(req UpdateSettingsRequest) bool { req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil || req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil || req.PaymentAnnouncementText != nil || req.PaymentRechargeCenterItems != nil || + req.PaymentRechargeCenterTabEnabled != nil || req.PaymentRechargeTabEnabled != nil || + req.PaymentSubscriptionTabEnabled != nil || req.PaymentHelpText != nil || req.PaymentReceiptCodeOSSEnabled != nil || req.PaymentReceiptCodeOSSEndpoint != nil || req.PaymentReceiptCodeOSSRegion != nil || req.PaymentReceiptCodeOSSBucket != nil || req.PaymentReceiptCodeOSSAccessKeyID != nil || diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index ccb2980c6..fb3034851 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -210,6 +210,9 @@ type SystemSettings struct { PaymentProductNameSuffix string `json:"payment_product_name_suffix"` PaymentAnnouncementText string `json:"payment_announcement_text"` PaymentRechargeCenterItems []PaymentRechargeCenterItem `json:"payment_recharge_center_items"` + PaymentRechargeCenterTabEnabled bool `json:"payment_recharge_center_tab_enabled"` + PaymentRechargeTabEnabled bool `json:"payment_recharge_tab_enabled"` + PaymentSubscriptionTabEnabled bool `json:"payment_subscription_tab_enabled"` PaymentHelpImageURL string `json:"payment_help_image_url"` PaymentHelpText string `json:"payment_help_text"` diff --git a/backend/internal/handler/payment_handler.go b/backend/internal/handler/payment_handler.go index 7a702ac63..0ec5af806 100644 --- a/backend/internal/handler/payment_handler.go +++ b/backend/internal/handler/payment_handler.go @@ -142,6 +142,9 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) { RechargeFeeRate: cfg.RechargeFeeRate, AnnouncementText: cfg.AnnouncementText, RechargeCenterItems: cfg.RechargeCenterItems, + RechargeCenterTabEnabled: cfg.RechargeCenterTabEnabled, + RechargeTabEnabled: cfg.RechargeTabEnabled, + SubscriptionTabEnabled: cfg.SubscriptionTabEnabled, HelpText: cfg.HelpText, HelpImageURL: cfg.HelpImageURL, StripePublishableKey: cfg.StripePublishableKey, @@ -160,6 +163,9 @@ type checkoutInfoResponse struct { RechargeFeeRate float64 `json:"recharge_fee_rate"` AnnouncementText string `json:"announcement_text"` RechargeCenterItems []service.RechargeCenterItem `json:"recharge_center_items"` + RechargeCenterTabEnabled bool `json:"recharge_center_tab_enabled"` + RechargeTabEnabled bool `json:"recharge_tab_enabled"` + SubscriptionTabEnabled bool `json:"subscription_tab_enabled"` HelpText string `json:"help_text"` HelpImageURL string `json:"help_image_url"` StripePublishableKey string `json:"stripe_publishable_key"` diff --git a/backend/internal/pkg/apicompat/anthropic_responses_test.go b/backend/internal/pkg/apicompat/anthropic_responses_test.go index e8b25c2b2..e3e3b4467 100644 --- a/backend/internal/pkg/apicompat/anthropic_responses_test.go +++ b/backend/internal/pkg/apicompat/anthropic_responses_test.go @@ -464,6 +464,109 @@ func TestStreamingCachedTokensUseAnthropicInputSemantics(t *testing.T) { assert.Equal(t, "message_stop", events[1].Type) } +func TestAnthropicToResponsesResponse_CacheTokensUseResponsesInputSemantics(t *testing.T) { + resp := &AnthropicResponse{ + Usage: AnthropicUsage{ + InputTokens: 3318, + OutputTokens: 123, + CacheReadInputTokens: 50688, + CacheCreationInputTokens: 200, + }, + } + + out := AnthropicToResponsesResponse(resp) + require.NotNil(t, out.Usage) + assert.Equal(t, 54206, out.Usage.InputTokens) + assert.Equal(t, 123, out.Usage.OutputTokens) + assert.Equal(t, 54329, out.Usage.TotalTokens) + require.NotNil(t, out.Usage.InputTokensDetails) + assert.Equal(t, 50688, out.Usage.InputTokensDetails.CachedTokens) +} + +func TestAnthropicEventToResponses_CacheTokensFromMessageStart(t *testing.T) { + state := NewAnthropicEventToResponsesState() + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: "msg_cached_start", + Model: "claude-sonnet-4-5-20250929", + Usage: AnthropicUsage{ + InputTokens: 12, + CacheReadInputTokens: 9, + CacheCreationInputTokens: 3, + }, + }, + }, state) + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_delta", + Usage: &AnthropicUsage{ + OutputTokens: 7, + }, + }, state) + + events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state) + var completed *ResponsesStreamEvent + for i := range events { + if events[i].Type == "response.completed" { + completed = &events[i] + break + } + } + + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + assert.Equal(t, 24, completed.Response.Usage.InputTokens) + assert.Equal(t, 7, completed.Response.Usage.OutputTokens) + assert.Equal(t, 31, completed.Response.Usage.TotalTokens) + require.NotNil(t, completed.Response.Usage.InputTokensDetails) + assert.Equal(t, 9, completed.Response.Usage.InputTokensDetails.CachedTokens) +} + +func TestAnthropicEventToResponses_CacheTokensFromMessageDelta(t *testing.T) { + state := NewAnthropicEventToResponsesState() + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_start", + Message: &AnthropicResponse{ + ID: "msg_cached_delta", + Model: "claude-sonnet-4-5-20250929", + Usage: AnthropicUsage{ + InputTokens: 20, + }, + }, + }, state) + + AnthropicEventToResponsesEvents(&AnthropicStreamEvent{ + Type: "message_delta", + Usage: &AnthropicUsage{ + OutputTokens: 8, + CacheReadInputTokens: 11, + CacheCreationInputTokens: 4, + }, + }, state) + + events := AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state) + var completed *ResponsesStreamEvent + for i := range events { + if events[i].Type == "response.completed" { + completed = &events[i] + break + } + } + + require.NotNil(t, completed) + require.NotNil(t, completed.Response) + require.NotNil(t, completed.Response.Usage) + assert.Equal(t, 35, completed.Response.Usage.InputTokens) + assert.Equal(t, 8, completed.Response.Usage.OutputTokens) + assert.Equal(t, 43, completed.Response.Usage.TotalTokens) + require.NotNil(t, completed.Response.Usage.InputTokensDetails) + assert.Equal(t, 11, completed.Response.Usage.InputTokensDetails.CachedTokens) +} + func TestStreamingToolCall(t *testing.T) { state := NewResponsesEventToAnthropicState() diff --git a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go index 9290e3995..821ad426f 100644 --- a/backend/internal/pkg/apicompat/anthropic_to_responses_response.go +++ b/backend/internal/pkg/apicompat/anthropic_to_responses_response.go @@ -94,11 +94,14 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse { out.IncompleteDetails = &ResponsesIncompleteDetails{Reason: "max_output_tokens"} } - // Usage + // Anthropic input_tokens excludes cache read/write tokens; Responses input_tokens includes them. + totalInputTokens := resp.Usage.InputTokens + + resp.Usage.CacheReadInputTokens + + resp.Usage.CacheCreationInputTokens out.Usage = &ResponsesUsage{ - InputTokens: resp.Usage.InputTokens, + InputTokens: totalInputTokens, OutputTokens: resp.Usage.OutputTokens, - TotalTokens: resp.Usage.InputTokens + resp.Usage.OutputTokens, + TotalTokens: totalInputTokens + resp.Usage.OutputTokens, } if resp.Usage.CacheReadInputTokens > 0 { out.Usage.InputTokensDetails = &ResponsesInputTokensDetails{ @@ -150,10 +153,11 @@ type AnthropicEventToResponsesState struct { CurrentCallID string CurrentName string - // Usage from message_delta - InputTokens int - OutputTokens int - CacheReadInputTokens int + // Usage from Anthropic stream events. InputTokens uses Anthropic semantics and excludes cache tokens. + InputTokens int + OutputTokens int + CacheReadInputTokens int + CacheCreationInputTokens int } // NewAnthropicEventToResponsesState returns an initialised stream state. @@ -225,6 +229,12 @@ func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEven if evt.Message.Usage.InputTokens > 0 { state.InputTokens = evt.Message.Usage.InputTokens } + if evt.Message.Usage.CacheReadInputTokens > 0 { + state.CacheReadInputTokens = evt.Message.Usage.CacheReadInputTokens + } + if evt.Message.Usage.CacheCreationInputTokens > 0 { + state.CacheCreationInputTokens = evt.Message.Usage.CacheCreationInputTokens + } } if state.CreatedSent { @@ -392,9 +402,15 @@ func anthToResHandleMessageDelta(evt *AnthropicStreamEvent, state *AnthropicEven // Update usage if evt.Usage != nil { state.OutputTokens = evt.Usage.OutputTokens + if evt.Usage.InputTokens > 0 { + state.InputTokens = evt.Usage.InputTokens + } if evt.Usage.CacheReadInputTokens > 0 { state.CacheReadInputTokens = evt.Usage.CacheReadInputTokens } + if evt.Usage.CacheCreationInputTokens > 0 { + state.CacheCreationInputTokens = evt.Usage.CacheCreationInputTokens + } } return nil @@ -472,10 +488,11 @@ func makeResponsesCompletedEvent( seq := state.SequenceNumber state.SequenceNumber++ + totalInputTokens := state.InputTokens + state.CacheReadInputTokens + state.CacheCreationInputTokens usage := &ResponsesUsage{ - InputTokens: state.InputTokens, + InputTokens: totalInputTokens, OutputTokens: state.OutputTokens, - TotalTokens: state.InputTokens + state.OutputTokens, + TotalTokens: totalInputTokens + state.OutputTokens, } if state.CacheReadInputTokens > 0 { usage.InputTokensDetails = &ResponsesInputTokensDetails{ diff --git a/backend/internal/pkg/gemini/models.go b/backend/internal/pkg/gemini/models.go index fac79d187..f2c464166 100644 --- a/backend/internal/pkg/gemini/models.go +++ b/backend/internal/pkg/gemini/models.go @@ -20,13 +20,11 @@ func DefaultModels() []Model { return []Model{ {Name: "models/gemini-2.0-flash", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.5-flash", SupportedGenerationMethods: methods}, - {Name: "models/gemini-2.5-flash-image", SupportedGenerationMethods: methods}, {Name: "models/gemini-2.5-pro", SupportedGenerationMethods: methods}, {Name: "models/gemini-3-flash-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3-pro-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3.1-pro-preview", SupportedGenerationMethods: methods}, {Name: "models/gemini-3.1-pro-preview-customtools", SupportedGenerationMethods: methods}, - {Name: "models/gemini-3.1-flash-image", SupportedGenerationMethods: methods}, } } diff --git a/backend/internal/pkg/geminicli/models.go b/backend/internal/pkg/geminicli/models.go index 195fb06f8..1fc4d983c 100644 --- a/backend/internal/pkg/geminicli/models.go +++ b/backend/internal/pkg/geminicli/models.go @@ -13,12 +13,10 @@ type Model struct { var DefaultModels = []Model{ {ID: "gemini-2.0-flash", Type: "model", DisplayName: "Gemini 2.0 Flash", CreatedAt: ""}, {ID: "gemini-2.5-flash", Type: "model", DisplayName: "Gemini 2.5 Flash", CreatedAt: ""}, - {ID: "gemini-2.5-flash-image", Type: "model", DisplayName: "Gemini 2.5 Flash Image", CreatedAt: ""}, {ID: "gemini-2.5-pro", Type: "model", DisplayName: "Gemini 2.5 Pro", CreatedAt: ""}, {ID: "gemini-3-flash-preview", Type: "model", DisplayName: "Gemini 3 Flash Preview", CreatedAt: ""}, {ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""}, {ID: "gemini-3.1-pro-preview", Type: "model", DisplayName: "Gemini 3.1 Pro Preview", CreatedAt: ""}, - {ID: "gemini-3.1-flash-image", Type: "model", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: ""}, } // DefaultTestModel is the default model to preselect in test flows. diff --git a/backend/internal/repository/account_share_policy_repo.go b/backend/internal/repository/account_share_policy_repo.go index fa264da8f..10a01efbe 100644 --- a/backend/internal/repository/account_share_policy_repo.go +++ b/backend/internal/repository/account_share_policy_repo.go @@ -218,8 +218,8 @@ func accountSharePolicyWhere(filters service.AccountSharePolicyFilters) (string, args := make([]any, 0, 3) add := func(condition string, arg any) { args = append(args, arg) - where.WriteString(" AND ") - where.WriteString(fmt.Sprintf(condition, len(args))) + _, _ = where.WriteString(" AND ") + _, _ = where.WriteString(fmt.Sprintf(condition, len(args))) } if scopeType := strings.TrimSpace(filters.ScopeType); scopeType != "" { add("scope_type = $%d", scopeType) diff --git a/backend/internal/repository/affiliate_repo.go b/backend/internal/repository/affiliate_repo.go index 54deca9d4..51c2a201a 100644 --- a/backend/internal/repository/affiliate_repo.go +++ b/backend/internal/repository/affiliate_repo.go @@ -582,6 +582,9 @@ func (r *affiliateRepository) withTx(ctx context.Context, fn func(txCtx context. tx, err := r.client.Tx(ctx) if err != nil { + if errors.Is(err, dbent.ErrTxStarted) { + return fn(ctx, r.client) + } return fmt.Errorf("begin affiliate transaction: %w", err) } defer func() { _ = tx.Rollback() }() @@ -984,7 +987,7 @@ func (r *affiliateRepository) ListUsersWithCustomSettings(ctx context.Context, f const baseFrom = ` FROM user_affiliates ua JOIN users u ON u.id = ua.user_id -WHERE ua.aff_code_custom = true +WHERE (ua.aff_code_custom = true OR ua.aff_rebate_rate_percent IS NOT NULL) AND (u.email ILIKE $1 OR u.username ILIKE $1)` client := clientFromContext(ctx, r.client) diff --git a/backend/internal/repository/api_key_repo.go b/backend/internal/repository/api_key_repo.go index ef758892d..74f9ac291 100644 --- a/backend/internal/repository/api_key_repo.go +++ b/backend/internal/repository/api_key_repo.go @@ -476,7 +476,7 @@ func (r *apiKeyRepository) ClearGroupIDByGroupID(ctx context.Context, groupID in if err != nil && !errors.Is(err, dbent.ErrTxStarted) { return 0, err } - client := r.client + var client *dbent.Client txCtx := ctx if err == nil { defer func() { _ = tx.Rollback() }() @@ -913,6 +913,9 @@ func (r *apiKeyRepository) withTx(ctx context.Context, fn func(txCtx context.Con } tx, err := r.client.Tx(ctx) if err != nil { + if errors.Is(err, dbent.ErrTxStarted) { + return fn(ctx, r.client) + } return fmt.Errorf("begin api key transaction: %w", err) } defer func() { _ = tx.Rollback() }() diff --git a/backend/internal/repository/integration_harness_test.go b/backend/internal/repository/integration_harness_test.go index 5857fbcb2..a904d628e 100644 --- a/backend/internal/repository/integration_harness_test.go +++ b/backend/internal/repository/integration_harness_test.go @@ -330,6 +330,7 @@ func (h prefixHook) prefixCmd(cmd redisclient.Cmder) { switch strings.ToLower(cmd.Name()) { case "get", "set", "setnx", "setex", "psetex", "incr", "decr", "incrby", "expire", "pexpire", "ttl", "pttl", "hgetall", "hget", "hset", "hdel", "hincrbyfloat", "exists", + "sadd", "scard", "smembers", "sismember", "srem", "zadd", "zcard", "zrange", "zrangebyscore", "zrem", "zremrangebyscore", "zrevrange", "zrevrangebyscore", "zscore": prefixOne(1) case "mget": diff --git a/backend/internal/repository/receipt_code_oss_store.go b/backend/internal/repository/receipt_code_oss_store.go index 03aac37ab..f263eed9d 100644 --- a/backend/internal/repository/receipt_code_oss_store.go +++ b/backend/internal/repository/receipt_code_oss_store.go @@ -104,4 +104,3 @@ func (s *receiptCodeOSSStore) PublicURL(key string) string { } return s.publicBaseURL + "/" + strings.TrimLeft(key, "/") } - diff --git a/backend/internal/repository/redis.go b/backend/internal/repository/redis.go index 2b4ee4e63..ffb17424b 100644 --- a/backend/internal/repository/redis.go +++ b/backend/internal/repository/redis.go @@ -27,8 +27,19 @@ func InitRedis(cfg *config.Config) *redis.Client { // buildRedisOptions 构建 Redis 连接选项 // 从配置文件读取连接池和超时参数,支持生产环境调优 func buildRedisOptions(cfg *config.Config) *redis.Options { + spec, err := cfg.Redis.ConnectionSpec() + if err != nil { + // Config validation should catch this before repository wiring; keep a + // defensive fallback so tests and partial configs do not panic. + spec = config.RedisConnectionSpec{ + Network: config.RedisConnectionNetworkTCP, + Address: cfg.Redis.Address(), + } + } + opts := &redis.Options{ - Addr: cfg.Redis.Address(), + Network: string(spec.Network), + Addr: spec.Address, Password: cfg.Redis.Password, DB: cfg.Redis.DB, DialTimeout: time.Duration(cfg.Redis.DialTimeoutSeconds) * time.Second, // 建连超时 diff --git a/backend/internal/repository/redis_test.go b/backend/internal/repository/redis_test.go index 7cb31002b..a8fd79a6d 100644 --- a/backend/internal/repository/redis_test.go +++ b/backend/internal/repository/redis_test.go @@ -24,6 +24,7 @@ func TestBuildRedisOptions(t *testing.T) { } opts := buildRedisOptions(cfg) + require.Equal(t, "tcp", opts.Network) require.Equal(t, "localhost:6379", opts.Addr) require.Equal(t, "secret", opts.Password) require.Equal(t, 2, opts.DB) @@ -45,3 +46,25 @@ func TestBuildRedisOptions(t *testing.T) { require.NotNil(t, optsTLS.TLSConfig) require.Equal(t, "localhost", optsTLS.TLSConfig.ServerName) } + +func TestBuildRedisOptionsUnixSocket(t *testing.T) { + cfg := &config.Config{ + Redis: config.RedisConfig{ + Host: "unix:/run/redis/redis.sock", + Password: "secret", + DB: 1, + DialTimeoutSeconds: 5, + ReadTimeoutSeconds: 3, + WriteTimeoutSeconds: 4, + PoolSize: 100, + MinIdleConns: 10, + }, + } + + opts := buildRedisOptions(cfg) + require.Equal(t, "unix", opts.Network) + require.Equal(t, "/run/redis/redis.sock", opts.Addr) + require.Equal(t, "secret", opts.Password) + require.Equal(t, 1, opts.DB) + require.Nil(t, opts.TLSConfig) +} diff --git a/backend/internal/repository/subsite_repo.go b/backend/internal/repository/subsite_repo.go index 782fff070..e24427dca 100644 --- a/backend/internal/repository/subsite_repo.go +++ b/backend/internal/repository/subsite_repo.go @@ -124,7 +124,7 @@ func (r *subsiteRepository) List(ctx context.Context, params pagination.Paginati if err != nil { return nil, nil, fmt.Errorf("list subsites: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() items := make([]service.Subsite, 0) for rows.Next() { subsite, err := scanSubsite(rows) @@ -459,7 +459,7 @@ func (r *accountLeaseRepository) ListActiveAccountIDsBySubsite(ctx context.Conte if err != nil { return nil, fmt.Errorf("list active lease account ids: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() accountIDs := make([]int64, 0) for rows.Next() { @@ -644,7 +644,7 @@ func (r *accountLeaseRepository) list(ctx context.Context, query string, args .. if err != nil { return nil, fmt.Errorf("list account leases: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() leases := make([]service.AccountLease, 0) for rows.Next() { lease, err := scanAccountLease(rows) diff --git a/backend/internal/repository/subsite_repo_integration_test.go b/backend/internal/repository/subsite_repo_integration_test.go index fe6ac09b7..d234d739c 100644 --- a/backend/internal/repository/subsite_repo_integration_test.go +++ b/backend/internal/repository/subsite_repo_integration_test.go @@ -132,6 +132,7 @@ func TestQuotaReservationRepositoryCreateEnforcesLeaseCapacity(t *testing.T) { AccountID: accountID, APIKeyID: apiKeyID, UserID: userID, + GroupID: &groupID, Platform: service.PlatformOpenAI, RequestedModel: "gpt-5.4", MappedModel: "gpt-5.4", diff --git a/backend/internal/repository/usage_log_repo.go b/backend/internal/repository/usage_log_repo.go index ff389ff42..83d051f71 100644 --- a/backend/internal/repository/usage_log_repo.go +++ b/backend/internal/repository/usage_log_repo.go @@ -1325,35 +1325,34 @@ func (r *usageLogRepository) bestEffortRecentKey(requestID string, apiKeyID int6 return usageLogBatchKey(requestID, apiKeyID), true } -func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) { +func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (*service.UsageLog, error) { query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1" rows, err := r.sql.QueryContext(ctx, query, id) if err != nil { return nil, err } - defer func() { - // 保持主错误优先;仅在无错误时回传 Close 失败。 - // 同时清空返回值,避免误用不完整结果。 - if closeErr := rows.Close(); closeErr != nil && err == nil { - err = closeErr - log = nil - } - }() if !rows.Next() { - if err = rows.Err(); err != nil { + if err := rows.Err(); err != nil { + _ = rows.Close() return nil, err } + _ = rows.Close() return nil, service.ErrUsageLogNotFound } - log, err = scanUsageLog(rows) + log, err := scanUsageLog(rows) if err != nil { + _ = rows.Close() return nil, err } - if err = rows.Err(); err != nil { + if err := rows.Err(); err != nil { + _ = rows.Close() + return nil, err + } + if err := rows.Close(); err != nil { return nil, err } logs := []service.UsageLog{*log} - if err = r.hydrateUsageLogWalletDeductions(ctx, logs); err != nil { + if err := r.hydrateUsageLogWalletDeductions(ctx, logs); err != nil { return nil, err } *log = logs[0] @@ -4502,7 +4501,7 @@ func (r *usageLogRepository) hydrateUsageLogPointsDeductions(ctx context.Context if err != nil { return err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id int64 var amount float64 @@ -4529,7 +4528,7 @@ func (r *usageLogRepository) hydrateUsageLogBalanceDeductions(ctx context.Contex if err != nil { return err } - defer rows.Close() + defer func() { _ = rows.Close() }() for rows.Next() { var id int64 var amount float64 diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index 0d5aa6259..ba657851d 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -829,6 +829,9 @@ func TestAPIContracts(t *testing.T) { "payment_product_name_suffix": "", "payment_announcement_text": "", "payment_recharge_center_items": [], + "payment_recharge_center_tab_enabled": false, + "payment_recharge_tab_enabled": false, + "payment_subscription_tab_enabled": false, "payment_receipt_code_oss_enabled": false, "payment_receipt_code_oss_endpoint": "", "payment_receipt_code_oss_region": "", @@ -1058,6 +1061,9 @@ func TestAPIContracts(t *testing.T) { "payment_product_name_suffix": "", "payment_announcement_text": "", "payment_recharge_center_items": [], + "payment_recharge_center_tab_enabled": false, + "payment_recharge_tab_enabled": false, + "payment_subscription_tab_enabled": false, "payment_receipt_code_oss_enabled": false, "payment_receipt_code_oss_endpoint": "", "payment_receipt_code_oss_region": "", diff --git a/backend/internal/server/http.go b/backend/internal/server/http.go index 023e40bb2..9883b176b 100644 --- a/backend/internal/server/http.go +++ b/backend/internal/server/http.go @@ -18,7 +18,7 @@ import ( "github.com/google/wire" "github.com/redis/go-redis/v9" "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" + "golang.org/x/net/http2/h2c" //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. ) // ProviderSet 提供服务器层的依赖 @@ -114,7 +114,7 @@ func ProvideHTTPServer(cfg *config.Config, router *gin.Engine) *http.Server { // 根据配置决定是否启用 H2C if cfg.Server.H2C.Enabled { h2cConfig := cfg.Server.H2C - httpHandler = h2c.NewHandler(router, &http2.Server{ + httpHandler = h2c.NewHandler(router, &http2.Server{ //nolint:staticcheck // Keep existing h2c behavior until the server moves fully to Go's Protocols API. MaxConcurrentStreams: h2cConfig.MaxConcurrentStreams, IdleTimeout: time.Duration(h2cConfig.IdleTimeout) * time.Second, MaxReadFrameSize: uint32(h2cConfig.MaxReadFrameSize), diff --git a/backend/internal/service/account_service_delete_test.go b/backend/internal/service/account_service_delete_test.go index 2892bdff2..6d9c17617 100644 --- a/backend/internal/service/account_service_delete_test.go +++ b/backend/internal/service/account_service_delete_test.go @@ -19,14 +19,20 @@ import ( // 用于隔离测试 AccountService.Delete 方法,避免依赖真实数据库。 // // 设计说明: +// - account/getErr: 模拟 GetByID 返回的账号和错误 // - exists: 模拟 ExistsByID 返回的存在性结果 // - existsErr: 模拟 ExistsByID 返回的错误 // - deleteErr: 模拟 Delete 返回的错误 +// - getIDs/existsIDs: 记录查询调用的账号 ID,用于断言验证 // - deletedIDs: 记录被调用删除的账号 ID,用于断言验证 type accountRepoStub struct { + account *Account + getErr error exists bool // ExistsByID 的返回值 existsErr error // ExistsByID 的错误返回值 deleteErr error // Delete 的错误返回值 + getIDs []int64 // 记录已查询的账号 ID 列表 + existsIDs []int64 // 记录已检查存在性的账号 ID 列表 deletedIDs []int64 // 记录已删除的账号 ID 列表 } @@ -37,7 +43,8 @@ func (s *accountRepoStub) Create(ctx context.Context, account *Account) error { } func (s *accountRepoStub) GetByID(ctx context.Context, id int64) (*Account, error) { - panic("unexpected GetByID call") + s.getIDs = append(s.getIDs, id) + return s.account, s.getErr } func (s *accountRepoStub) GetByIDs(ctx context.Context, ids []int64) ([]*Account, error) { @@ -45,8 +52,9 @@ func (s *accountRepoStub) GetByIDs(ctx context.Context, ids []int64) ([]*Account } // ExistsByID 返回预设的存在性检查结果。 -// 这是 Delete 方法调用的第一个仓储方法,用于验证账号是否存在。 +// Delete 方法会在 GetByID 失败时使用它作为兼容性兜底。 func (s *accountRepoStub) ExistsByID(ctx context.Context, id int64) (bool, error) { + s.existsIDs = append(s.existsIDs, id) return s.exists, s.existsErr } @@ -209,7 +217,7 @@ func (s *accountRepoStub) ResetQuotaUsed(ctx context.Context, id int64) error { // TestAccountService_Delete_NotFound 测试删除不存在的账号时返回正确的错误。 // 预期行为: -// - ExistsByID 返回 false(账号不存在) +// - GetByID 返回 nil(账号不存在) // - 返回 ErrAccountNotFound 错误 // - Delete 方法不被调用(deletedIDs 为空) func TestAccountService_Delete_NotFound(t *testing.T) { @@ -218,21 +226,29 @@ func TestAccountService_Delete_NotFound(t *testing.T) { err := svc.Delete(context.Background(), 55) require.ErrorIs(t, err, ErrAccountNotFound) + require.Equal(t, []int64{55}, repo.getIDs) + require.Empty(t, repo.existsIDs) require.Empty(t, repo.deletedIDs) // 验证删除操作未被调用 } // TestAccountService_Delete_CheckError 测试存在性检查失败时的错误处理。 // 预期行为: +// - GetByID 返回错误 // - ExistsByID 返回数据库错误 // - 返回包含 "check account" 的错误信息 // - Delete 方法不被调用 func TestAccountService_Delete_CheckError(t *testing.T) { - repo := &accountRepoStub{existsErr: errors.New("db down")} + repo := &accountRepoStub{ + getErr: errors.New("get failed"), + existsErr: errors.New("db down"), + } svc := &AccountService{accountRepo: repo} err := svc.Delete(context.Background(), 55) require.Error(t, err) require.ErrorContains(t, err, "check account") // 验证错误信息包含上下文 + require.Equal(t, []int64{55}, repo.getIDs) + require.Equal(t, []int64{55}, repo.existsIDs) require.Empty(t, repo.deletedIDs) } @@ -244,6 +260,7 @@ func TestAccountService_Delete_CheckError(t *testing.T) { // - deletedIDs 记录了尝试删除的 ID func TestAccountService_Delete_DeleteError(t *testing.T) { repo := &accountRepoStub{ + account: &Account{ID: 55}, exists: true, deleteErr: errors.New("delete failed"), } @@ -252,6 +269,8 @@ func TestAccountService_Delete_DeleteError(t *testing.T) { err := svc.Delete(context.Background(), 55) require.Error(t, err) require.ErrorContains(t, err, "delete account") + require.Equal(t, []int64{55}, repo.getIDs) + require.Empty(t, repo.existsIDs) require.Equal(t, []int64{55}, repo.deletedIDs) // 验证删除操作被调用 } @@ -262,10 +281,12 @@ func TestAccountService_Delete_DeleteError(t *testing.T) { // - 返回 nil 错误 // - deletedIDs 记录了被删除的 ID func TestAccountService_Delete_Success(t *testing.T) { - repo := &accountRepoStub{exists: true} + repo := &accountRepoStub{account: &Account{ID: 55}} svc := &AccountService{accountRepo: repo} err := svc.Delete(context.Background(), 55) require.NoError(t, err) + require.Equal(t, []int64{55}, repo.getIDs) + require.Empty(t, repo.existsIDs) require.Equal(t, []int64{55}, repo.deletedIDs) // 验证正确的 ID 被删除 } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index d459c25f8..509b3ceb2 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -51,7 +51,6 @@ type TestEvent struct { const ( defaultGeminiTextTestPrompt = "hi" - defaultGeminiImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." defaultOpenAIImageTestPrompt = "Generate a cute orange cat astronaut sticker on a clean pastel background." openAITestMaxOutputTokens = 16 ) @@ -1030,34 +1029,9 @@ func (s *AccountTestService) buildCodeAssistRequest(ctx context.Context, accessT return req, nil } -// createGeminiTestPayload creates a minimal test payload for Gemini API. -// Image models use the image-generation path so the frontend can preview the returned image. +// createGeminiTestPayload creates a minimal text-only test payload for Gemini API. func createGeminiTestPayload(modelID string, prompt string) []byte { - if isImageGenerationModel(modelID) { - imagePrompt := strings.TrimSpace(prompt) - if imagePrompt == "" { - imagePrompt = defaultGeminiImageTestPrompt - } - - payload := map[string]any{ - "contents": []map[string]any{ - { - "role": "user", - "parts": []map[string]any{ - {"text": imagePrompt}, - }, - }, - }, - "generationConfig": map[string]any{ - "responseModalities": []string{"TEXT", "IMAGE"}, - "imageConfig": map[string]any{ - "aspectRatio": "1:1", - }, - }, - } - bytes, _ := json.Marshal(payload) - return bytes - } + _ = modelID textPrompt := strings.TrimSpace(prompt) if textPrompt == "" { diff --git a/backend/internal/service/antigravity_gateway_service.go b/backend/internal/service/antigravity_gateway_service.go index 4e0356ab8..65f69e78a 100644 --- a/backend/internal/service/antigravity_gateway_service.go +++ b/backend/internal/service/antigravity_gateway_service.go @@ -1741,7 +1741,6 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context, if resp != nil { resp.Request = nil } - body = nil // 客户端要求流式,直接透传转换 streamRes, err := s.handleClaudeStreamingResponse(c, resp, startTime, originalModel) if err != nil { @@ -2428,7 +2427,6 @@ handleSuccess: if resp != nil { resp.Request = nil } - body = nil // 客户端要求流式,直接透传 streamRes, err := s.handleGeminiStreamingResponse(c, resp, startTime) if err != nil { @@ -4297,7 +4295,6 @@ func (s *AntigravityGatewayService) ForwardUpstream(ctx context.Context, c *gin. if resp != nil { resp.Request = nil } - body = nil // 流式响应:透传 c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") diff --git a/backend/internal/service/auth_oauth_email_flow.go b/backend/internal/service/auth_oauth_email_flow.go index e3c8298c2..33371c821 100644 --- a/backend/internal/service/auth_oauth_email_flow.go +++ b/backend/internal/service/auth_oauth_email_flow.go @@ -18,7 +18,7 @@ func normalizeOAuthSignupSource(signupSource string) string { switch signupSource { case "", "email": return "email" - case "linuxdo", "wechat", "oidc", "github", "google": + case "linuxdo", "wechat", "oidc": return signupSource default: return "email" diff --git a/backend/internal/service/billing_service.go b/backend/internal/service/billing_service.go index fbd4b861f..65f4a1d1f 100644 --- a/backend/internal/service/billing_service.go +++ b/backend/internal/service/billing_service.go @@ -247,6 +247,12 @@ func (s *BillingService) initFallbackPricing() { CacheReadPricePerToken: 7.5e-8, SupportsCacheBreakdown: false, } + s.fallbackPrices["gpt-5.4-nano"] = &ModelPricing{ + InputPricePerToken: 2e-7, + OutputPricePerToken: 1.25e-6, + CacheReadPricePerToken: 2e-8, + SupportsCacheBreakdown: false, + } // OpenAI GPT-5.2(本地兜底) s.fallbackPrices["gpt-5.2"] = &ModelPricing{ InputPricePerToken: 1.75e-6, @@ -314,6 +320,8 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing { switch normalized { case "gpt-5.5": return s.fallbackPrices["gpt-5.5"] + case "gpt-5.4-nano": + return s.fallbackPrices["gpt-5.4-nano"] case "gpt-5.4-mini": return s.fallbackPrices["gpt-5.4-mini"] case "gpt-5.4": @@ -492,6 +500,7 @@ func (s *BillingService) computeTokenBreakdown( inputPrice := pricing.InputPricePerToken outputPrice := pricing.OutputPricePerToken cacheReadPrice := pricing.CacheReadPricePerToken + cacheCreationLongContextMultiplier := 1.0 inputTierMultiplier := 1.0 outputTierMultiplier := 1.0 cacheCreationTierMultiplier := 1.0 @@ -530,6 +539,8 @@ func (s *BillingService) computeTokenBreakdown( if applyLongCtx && s.shouldApplySessionLongContextPricing(tokens, pricing) { inputPrice *= pricing.LongContextInputMultiplier outputPrice *= pricing.LongContextOutputMultiplier + cacheReadPrice *= pricing.LongContextInputMultiplier + cacheCreationLongContextMultiplier = pricing.LongContextInputMultiplier } bd := &CostBreakdown{} @@ -552,7 +563,8 @@ func (s *BillingService) computeTokenBreakdown( } // 缓存创建费用 - bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens) * cacheCreationTierMultiplier + bd.CacheCreationCost = s.computeCacheCreationCost(pricing, tokens) * + cacheCreationLongContextMultiplier * cacheCreationTierMultiplier bd.CacheReadCost = float64(tokens.CacheReadTokens) * cacheReadPrice * cacheReadTierMultiplier diff --git a/backend/internal/service/billing_service_test.go b/backend/internal/service/billing_service_test.go index 19a83193c..74aa4af2b 100644 --- a/backend/internal/service/billing_service_test.go +++ b/backend/internal/service/billing_service_test.go @@ -187,6 +187,40 @@ func TestCalculateCost_OpenAIGPT54LongContextAppliesWholeSessionMultipliers(t *t require.InDelta(t, expectedInput+expectedOutput, cost.ActualCost, 1e-10) } +func TestCalculateCost_OpenAIGPT54LongContextScalesCacheReadAndCreation(t *testing.T) { + svc := newTestBillingService() + + tokens := UsageTokens{ + InputTokens: 1000, + OutputTokens: 1000, + CacheCreationTokens: 10000, + CacheReadTokens: 300000, + } + + cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0) + require.NoError(t, err) + + require.InDelta(t, float64(tokens.CacheReadTokens)*6.25e-6*2.0, cost.CacheReadCost, 1e-10) + require.InDelta(t, float64(tokens.CacheCreationTokens)*62.5e-6*2.0, cost.CacheCreationCost, 1e-10) +} + +func TestCalculateCost_NoLongContextKeepsCacheReadAndCreationAtBasePrice(t *testing.T) { + svc := newTestBillingService() + + tokens := UsageTokens{ + InputTokens: 1000, + OutputTokens: 1000, + CacheCreationTokens: 10000, + CacheReadTokens: 100000, + } + + cost, err := svc.CalculateCost("gpt-5.4-2026-03-05", tokens, 1.0) + require.NoError(t, err) + + require.InDelta(t, float64(tokens.CacheReadTokens)*6.25e-6, cost.CacheReadCost, 1e-10) + require.InDelta(t, float64(tokens.CacheCreationTokens)*62.5e-6, cost.CacheCreationCost, 1e-10) +} + func TestGetFallbackPricing_FamilyMatching(t *testing.T) { svc := newTestBillingService() @@ -453,6 +487,40 @@ func TestCalculateCost_SupportsCacheBreakdown(t *testing.T) { require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10) } +func TestCalculateCost_LongContextScalesCacheCreation5mAnd1h(t *testing.T) { + svc := &BillingService{ + cfg: &config.Config{}, + fallbackPrices: map[string]*ModelPricing{ + "claude-sonnet-4": { + InputPricePerToken: 3e-6, + OutputPricePerToken: 15e-6, + CacheReadPricePerToken: 0.3e-6, + SupportsCacheBreakdown: true, + CacheCreation5mPrice: 4e-6, + CacheCreation1hPrice: 5e-6, + LongContextInputThreshold: 200000, + LongContextInputMultiplier: 2.0, + LongContextOutputMultiplier: 1.5, + }, + }, + } + + tokens := UsageTokens{ + InputTokens: 1000, + OutputTokens: 1000, + CacheReadTokens: 300000, + CacheCreation5mTokens: 8000, + CacheCreation1hTokens: 4000, + } + + cost, err := svc.CalculateCost("claude-sonnet-4", tokens, 1.0) + require.NoError(t, err) + + expected5m := float64(tokens.CacheCreation5mTokens) * 4e-6 * 2.0 + expected1h := float64(tokens.CacheCreation1hTokens) * 5e-6 * 2.0 + require.InDelta(t, expected5m+expected1h, cost.CacheCreationCost, 1e-10) +} + func TestCalculateCost_LargeTokenCount(t *testing.T) { svc := newTestBillingService() diff --git a/backend/internal/service/gateway_service.go b/backend/internal/service/gateway_service.go index 36f30ad81..44aa2dd9e 100644 --- a/backend/internal/service/gateway_service.go +++ b/backend/internal/service/gateway_service.go @@ -5205,7 +5205,6 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A if resp != nil { resp.Request = nil } - body = nil streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, reqModel, shouldMimicClaudeCode) if err != nil { if err.Error() == "have error in stream" { diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go index d0a231a40..8d32a17f6 100644 --- a/backend/internal/service/gemini_messages_compat_service.go +++ b/backend/internal/service/gemini_messages_compat_service.go @@ -1082,7 +1082,6 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex if resp != nil { resp.Request = nil } - body = nil streamRes, err := s.handleStreamingResponse(c, resp, startTime, originalModel) if err != nil { return nil, err diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 129e5b6af..ec80324bb 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -2554,9 +2554,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco } } openAIReqBody := reqBody - if !reqStream && wsDecision.Transport != OpenAIUpstreamTransportResponsesWebsocketV2 { - reqBody = nil - } // Get access token token, _, err := s.GetAccessToken(ctx, account) @@ -2780,9 +2777,6 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco s.writeOpenAIWSFallbackErrorResponse(c, account, wsErr) return nil, wsErr } - if reqStream { - reqBody = nil - } httpInvalidEncryptedContentRetryTried := false for { @@ -2884,15 +2878,13 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco if resp != nil { resp.Request = nil } - body = nil - openAIReqBody = nil streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel) if err != nil { return nil, err } - usage = streamResult.usage - firstTokenMs = streamResult.firstTokenMs if streamResult != nil { + usage = streamResult.usage + firstTokenMs = streamResult.firstTokenMs if responseServiceTier := extractOpenAIServiceTierFromResponses(streamResult.responseServiceTier); responseServiceTier != nil { serviceTier = responseServiceTier } @@ -3114,14 +3106,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough( if resp != nil { resp.Request = nil } - body = nil result, err := s.handleStreamingResponsePassthrough(ctx, resp, c, account, startTime, reqModel, upstreamPassthroughModel) if err != nil { return nil, err } - usage = result.usage - firstTokenMs = result.firstTokenMs if result != nil { + usage = result.usage + firstTokenMs = result.firstTokenMs if responseServiceTier := extractOpenAIServiceTierFromResponses(result.responseServiceTier); responseServiceTier != nil { serviceTier = responseServiceTier } diff --git a/backend/internal/service/payment_config_service.go b/backend/internal/service/payment_config_service.go index c59428496..98559c4a1 100644 --- a/backend/internal/service/payment_config_service.go +++ b/backend/internal/service/payment_config_service.go @@ -32,6 +32,9 @@ const ( SettingProductNameSuffix = "PRODUCT_NAME_SUFFIX" SettingPaymentAnnouncement = "PAYMENT_ANNOUNCEMENT_TEXT" SettingRechargeCenterItems = "PAYMENT_RECHARGE_CENTER_ITEMS" + SettingRechargeCenterTabOn = "PAYMENT_RECHARGE_CENTER_TAB_ENABLED" + SettingRechargeTabOn = "PAYMENT_RECHARGE_TAB_ENABLED" + SettingSubscriptionTabOn = "PAYMENT_SUBSCRIPTION_TAB_ENABLED" SettingHelpImageURL = "PAYMENT_HELP_IMAGE_URL" SettingHelpText = "PAYMENT_HELP_TEXT" SettingCancelRateLimitOn = "CANCEL_RATE_LIMIT_ENABLED" @@ -83,6 +86,9 @@ type PaymentConfig struct { ProductNameSuffix string `json:"product_name_suffix"` AnnouncementText string `json:"announcement_text"` RechargeCenterItems []RechargeCenterItem `json:"recharge_center_items"` + RechargeCenterTabEnabled bool `json:"recharge_center_tab_enabled"` + RechargeTabEnabled bool `json:"recharge_tab_enabled"` + SubscriptionTabEnabled bool `json:"subscription_tab_enabled"` HelpImageURL string `json:"help_image_url"` HelpText string `json:"help_text"` StripePublishableKey string `json:"stripe_publishable_key,omitempty"` @@ -135,6 +141,9 @@ type UpdatePaymentConfigRequest struct { ProductNameSuffix *string `json:"product_name_suffix"` AnnouncementText *string `json:"announcement_text"` RechargeCenterItems []RechargeCenterItem `json:"recharge_center_items"` + RechargeCenterTabEnabled *bool `json:"recharge_center_tab_enabled"` + RechargeTabEnabled *bool `json:"recharge_tab_enabled"` + SubscriptionTabEnabled *bool `json:"subscription_tab_enabled"` HelpImageURL *string `json:"help_image_url"` HelpText *string `json:"help_text"` @@ -263,7 +272,9 @@ func (s *PaymentConfigService) GetPaymentConfig(ctx context.Context) (*PaymentCo SettingDailyRechargeLimit, SettingOrderTimeoutMinutes, SettingMaxPendingOrders, SettingEnabledPaymentTypes, SettingBalancePayDisabled, SettingBalanceRechargeMult, SettingRechargeFeeRate, SettingLoadBalanceStrategy, SettingProductNamePrefix, SettingProductNameSuffix, - SettingPaymentAnnouncement, SettingRechargeCenterItems, SettingHelpImageURL, SettingHelpText, + SettingPaymentAnnouncement, SettingRechargeCenterItems, + SettingRechargeCenterTabOn, SettingRechargeTabOn, SettingSubscriptionTabOn, + SettingHelpImageURL, SettingHelpText, SettingCancelRateLimitOn, SettingCancelRateLimitMax, SettingCancelWindowSize, SettingCancelWindowUnit, SettingCancelWindowMode, SettingPaymentVisibleMethodAlipayEnabled, SettingPaymentVisibleMethodAlipaySource, @@ -301,6 +312,9 @@ func (s *PaymentConfigService) parsePaymentConfig(vals map[string]string) *Payme ProductNameSuffix: vals[SettingProductNameSuffix], AnnouncementText: vals[SettingPaymentAnnouncement], RechargeCenterItems: parseRechargeCenterItems(vals[SettingRechargeCenterItems]), + RechargeCenterTabEnabled: parseBoolWithDefault(vals[SettingRechargeCenterTabOn], true), + RechargeTabEnabled: parseBoolWithDefault(vals[SettingRechargeTabOn], true), + SubscriptionTabEnabled: parseBoolWithDefault(vals[SettingSubscriptionTabOn], true), HelpImageURL: vals[SettingHelpImageURL], HelpText: vals[SettingHelpText], @@ -435,6 +449,9 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda SettingProductNamePrefix: derefStr(req.ProductNamePrefix), SettingProductNameSuffix: derefStr(req.ProductNameSuffix), SettingPaymentAnnouncement: derefStr(req.AnnouncementText), + SettingRechargeCenterTabOn: formatBoolOrEmpty(req.RechargeCenterTabEnabled), + SettingRechargeTabOn: formatBoolOrEmpty(req.RechargeTabEnabled), + SettingSubscriptionTabOn: formatBoolOrEmpty(req.SubscriptionTabEnabled), SettingHelpImageURL: derefStr(req.HelpImageURL), SettingHelpText: derefStr(req.HelpText), SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled), diff --git a/backend/internal/service/payment_config_service_test.go b/backend/internal/service/payment_config_service_test.go index bb4a102bb..7969f1b35 100644 --- a/backend/internal/service/payment_config_service_test.go +++ b/backend/internal/service/payment_config_service_test.go @@ -102,6 +102,9 @@ func TestParsePaymentConfig(t *testing.T) { if len(cfg.EnabledTypes) != 0 { t.Fatalf("expected empty EnabledTypes, got %v", cfg.EnabledTypes) } + if !cfg.RechargeCenterTabEnabled || !cfg.RechargeTabEnabled || !cfg.SubscriptionTabEnabled { + t.Fatalf("expected payment page tabs enabled by default, got center=%v recharge=%v subscription=%v", cfg.RechargeCenterTabEnabled, cfg.RechargeTabEnabled, cfg.SubscriptionTabEnabled) + } }) t.Run("all values populated", func(t *testing.T) { @@ -120,6 +123,9 @@ func TestParsePaymentConfig(t *testing.T) { SettingProductNameSuffix: "SUF", SettingPaymentAnnouncement: "Please read before paying", SettingRechargeCenterItems: `[{"name":"FastPay","description":"Instant balance top-up","url":"https://pay.example.com/fast"}]`, + SettingRechargeCenterTabOn: "false", + SettingRechargeTabOn: "false", + SettingSubscriptionTabOn: "false", SettingHelpText: "Contact support after payment", } cfg := svc.parsePaymentConfig(vals) @@ -172,6 +178,9 @@ func TestParsePaymentConfig(t *testing.T) { if cfg.HelpText != "Contact support after payment" { t.Fatalf("HelpText = %q, want %q", cfg.HelpText, "Contact support after payment") } + if cfg.RechargeCenterTabEnabled || cfg.RechargeTabEnabled || cfg.SubscriptionTabEnabled { + t.Fatalf("expected payment page tabs disabled, got center=%v recharge=%v subscription=%v", cfg.RechargeCenterTabEnabled, cfg.RechargeTabEnabled, cfg.SubscriptionTabEnabled) + } }) t.Run("enabled types with spaces are trimmed", func(t *testing.T) { @@ -491,6 +500,31 @@ func TestUpdatePaymentConfig_PersistsAnnouncementText(t *testing.T) { } } +func TestUpdatePaymentConfig_PersistsPaymentPageTabVisibility(t *testing.T) { + repo := &paymentConfigSettingRepoStub{values: map[string]string{}} + svc := &PaymentConfigService{settingRepo: repo} + + disabled := false + err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{ + RechargeCenterTabEnabled: &disabled, + RechargeTabEnabled: &disabled, + SubscriptionTabEnabled: &disabled, + }) + if err != nil { + t.Fatalf("UpdatePaymentConfig returned error: %v", err) + } + + if repo.values[SettingRechargeCenterTabOn] != "false" { + t.Fatalf("recharge center tab = %q, want false", repo.values[SettingRechargeCenterTabOn]) + } + if repo.values[SettingRechargeTabOn] != "false" { + t.Fatalf("recharge tab = %q, want false", repo.values[SettingRechargeTabOn]) + } + if repo.values[SettingSubscriptionTabOn] != "false" { + t.Fatalf("subscription tab = %q, want false", repo.values[SettingSubscriptionTabOn]) + } +} + func TestUpdatePaymentConfig_PersistsRechargeCenterItems(t *testing.T) { repo := &paymentConfigSettingRepoStub{values: map[string]string{}} svc := &PaymentConfigService{settingRepo: repo} diff --git a/backend/internal/service/shop.go b/backend/internal/service/shop.go index 5acd588f3..d7c4a84b5 100644 --- a/backend/internal/service/shop.go +++ b/backend/internal/service/shop.go @@ -590,7 +590,7 @@ func (s *ShopService) createBalanceOrder(ctx context.Context, req ShopCreateOrde if err != nil { return nil, err } - delivered := []string{} + var delivered []string if drawReward != nil { order, err = tx.ShopOrder.UpdateOneID(order.ID). SetDrawRewardAmount(drawReward.Amount). @@ -697,7 +697,7 @@ func (s *ShopService) createPointsOrder(ctx context.Context, req ShopCreateOrder if err != nil { return nil, err } - delivered := []string{} + var delivered []string if drawReward != nil { order, err = tx.ShopOrder.UpdateOneID(order.ID). SetDrawRewardAmount(drawReward.Amount). diff --git a/backend/internal/service/shop_file_card.go b/backend/internal/service/shop_file_card.go index acfc1fc80..58d7608da 100644 --- a/backend/internal/service/shop_file_card.go +++ b/backend/internal/service/shop_file_card.go @@ -379,10 +379,10 @@ func (s *ShopService) writeOrderFileCardArchive(ctx context.Context, orderID int } name := uniqueArchiveFilename(sanitizeShopFilename(file.Filename), usedNames) header := &zip.FileHeader{ - Name: name, - Method: zip.Deflate, + Name: name, + Method: zip.Deflate, + Modified: time.Now(), } - header.SetModTime(time.Now()) writer, err := zw.CreateHeader(header) if err != nil { _ = body.Close() diff --git a/backend/internal/setup/cli.go b/backend/internal/setup/cli.go index 2b323acf4..87290550d 100644 --- a/backend/internal/setup/cli.go +++ b/backend/internal/setup/cli.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" + "github.com/Wei-Shaw/sub2api/internal/config" + "golang.org/x/term" ) @@ -125,18 +127,26 @@ func RunCLI() error { for { cfg.Redis.Host = promptString(reader, "Redis Host", "localhost") - if cliValidateHostname(cfg.Redis.Host) { + if isRedisUnixSocketHost(cfg.Redis.Host) { + if _, err := config.ParseRedisConnectionSpec(cfg.Redis.Host, 0); err == nil { + break + } + } else if cliValidateHostname(cfg.Redis.Host) { break } - fmt.Println(" Invalid hostname format. Use alphanumeric, dots, hyphens only.") + fmt.Println(" Invalid Redis host. Use a hostname/IP or unix:/absolute/path.sock.") } - for { - cfg.Redis.Port = promptInt(reader, "Redis Port", 6379) - if cliValidatePort(cfg.Redis.Port) { - break + if isRedisUnixSocketHost(cfg.Redis.Host) { + cfg.Redis.Port = 0 + } else { + for { + cfg.Redis.Port = promptInt(reader, "Redis Port", 6379) + if cliValidatePort(cfg.Redis.Port) { + break + } + fmt.Println(" Invalid port. Must be between 1 and 65535.") } - fmt.Println(" Invalid port. Must be between 1 and 65535.") } cfg.Redis.Password = promptPassword("Redis Password (optional)") @@ -149,7 +159,11 @@ func RunCLI() error { fmt.Println(" Invalid Redis DB. Must be between 0 and 15.") } - cfg.Redis.EnableTLS = promptConfirm(reader, "Enable Redis TLS?") + if isRedisUnixSocketHost(cfg.Redis.Host) { + cfg.Redis.EnableTLS = false + } else { + cfg.Redis.EnableTLS = promptConfirm(reader, "Enable Redis TLS?") + } fmt.Println() fmt.Print("Testing Redis connection... ") @@ -206,7 +220,11 @@ func RunCLI() error { fmt.Println() fmt.Println("── Configuration Summary ──") fmt.Printf("Database: %s@%s:%d/%s\n", cfg.Database.User, cfg.Database.Host, cfg.Database.Port, cfg.Database.DBName) - fmt.Printf("Redis: %s:%d\n", cfg.Redis.Host, cfg.Redis.Port) + if spec, err := config.ParseRedisConnectionSpec(cfg.Redis.Host, cfg.Redis.Port); err == nil && spec.Network == config.RedisConnectionNetworkUnix { + fmt.Printf("Redis: unix://%s\n", spec.Address) + } else { + fmt.Printf("Redis: %s:%d\n", cfg.Redis.Host, cfg.Redis.Port) + } fmt.Printf("Redis TLS: %s\n", map[bool]string{true: "enabled", false: "disabled"}[cfg.Redis.EnableTLS]) fmt.Printf("Admin: %s\n", cfg.Admin.Email) fmt.Printf("Server: :%d\n", cfg.Server.Port) @@ -239,6 +257,11 @@ func RunCLI() error { return nil } +func isRedisUnixSocketHost(host string) bool { + trimmed := strings.TrimSpace(host) + return strings.HasPrefix(trimmed, "unix:") || strings.HasPrefix(trimmed, "/") +} + func promptString(reader *bufio.Reader, prompt, defaultVal string) string { if defaultVal != "" { fmt.Printf(" %s [%s]: ", prompt, defaultVal) diff --git a/backend/internal/setup/handler.go b/backend/internal/setup/handler.go index c2944cedf..fbf010c0c 100644 --- a/backend/internal/setup/handler.go +++ b/backend/internal/setup/handler.go @@ -9,6 +9,7 @@ import ( "sync" "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/Wei-Shaw/sub2api/internal/pkg/response" "github.com/Wei-Shaw/sub2api/internal/pkg/sysutil" @@ -177,7 +178,7 @@ func testDatabase(c *gin.Context) { // TestRedisRequest represents Redis test request type TestRedisRequest struct { Host string `json:"host" binding:"required"` - Port int `json:"port" binding:"required"` + Port int `json:"port"` Password string `json:"password"` DB int `json:"db"` EnableTLS bool `json:"enable_tls"` @@ -191,19 +192,23 @@ func testRedis(c *gin.Context) { return } - // Security: Validate inputs - if !validateHostname(req.Host) { - response.Error(c, http.StatusBadRequest, "Invalid hostname format") - return - } - if !validatePort(req.Port) { - response.Error(c, http.StatusBadRequest, "Invalid port number") + spec, err := config.ParseRedisConnectionSpec(req.Host, req.Port) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid Redis connection config: "+err.Error()) return } if req.DB < 0 || req.DB > 15 { response.Error(c, http.StatusBadRequest, "Invalid Redis database number (0-15)") return } + if spec.Network == config.RedisConnectionNetworkTCP && !validateHostname(req.Host) { + response.Error(c, http.StatusBadRequest, "Invalid hostname format") + return + } + if spec.Network == config.RedisConnectionNetworkUnix && req.EnableTLS { + response.Error(c, http.StatusBadRequest, "Redis TLS is not supported with unix socket connections") + return + } cfg := &RedisConfig{ Host: req.Host, @@ -273,18 +278,23 @@ func install(c *gin.Context) { } // Redis validation - if !validateHostname(req.Redis.Host) { - response.Error(c, http.StatusBadRequest, "Invalid Redis hostname") - return - } - if !validatePort(req.Redis.Port) { - response.Error(c, http.StatusBadRequest, "Invalid Redis port") + redisSpec, err := config.ParseRedisConnectionSpec(req.Redis.Host, req.Redis.Port) + if err != nil { + response.Error(c, http.StatusBadRequest, "Invalid Redis connection config: "+err.Error()) return } if req.Redis.DB < 0 || req.Redis.DB > 15 { response.Error(c, http.StatusBadRequest, "Invalid Redis database number") return } + if redisSpec.Network == config.RedisConnectionNetworkTCP && !validateHostname(req.Redis.Host) { + response.Error(c, http.StatusBadRequest, "Invalid Redis hostname") + return + } + if redisSpec.Network == config.RedisConnectionNetworkUnix && req.Redis.EnableTLS { + response.Error(c, http.StatusBadRequest, "Redis TLS is not supported with unix socket connections") + return + } // Admin validation if !validateEmail(req.Admin.Email) { @@ -296,12 +306,6 @@ func install(c *gin.Context) { return } - // Server validation - if req.Server.Port != 0 && !validatePort(req.Server.Port) { - response.Error(c, http.StatusBadRequest, "Invalid server port") - return - } - // ========== SET DEFAULTS ========== if req.Database.SSLMode == "" { req.Database.SSLMode = "disable" @@ -324,6 +328,10 @@ func install(c *gin.Context) { response.Error(c, http.StatusBadRequest, "Invalid server mode (must be 'release' or 'debug')") return } + if _, err := config.ParseServerListenSpec(req.Server.Host, req.Server.Port); err != nil { + response.Error(c, http.StatusBadRequest, "Invalid server listen config: "+err.Error()) + return + } cfg := &SetupConfig{ Database: req.Database, diff --git a/backend/internal/setup/setup.go b/backend/internal/setup/setup.go index 2d8239c79..6cec48c6e 100644 --- a/backend/internal/setup/setup.go +++ b/backend/internal/setup/setup.go @@ -247,8 +247,17 @@ func TestDatabaseConnection(cfg *DatabaseConfig) error { // TestRedisConnection tests the Redis connection func TestRedisConnection(cfg *RedisConfig) error { + spec, err := config.ParseRedisConnectionSpec(cfg.Host, cfg.Port) + if err != nil { + return err + } + if spec.Network == config.RedisConnectionNetworkUnix && cfg.EnableTLS { + return fmt.Errorf("redis TLS is not supported with unix socket connections") + } + opts := &redis.Options{ - Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port), + Network: string(spec.Network), + Addr: spec.Address, Password: cfg.Password, DB: cfg.DB, } diff --git a/backend/internal/subsite/agent/proxy.go b/backend/internal/subsite/agent/proxy.go index aba49dab1..42c9dbe7d 100644 --- a/backend/internal/subsite/agent/proxy.go +++ b/backend/internal/subsite/agent/proxy.go @@ -76,7 +76,7 @@ func (s *Server) proxyAuthorizedRequest(c *gin.Context, authorization *service.A if err != nil { return nil, fmt.Errorf("call upstream: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() responseBody, wroteResponse, metrics, err := streamOrBufferResponse(c, resp) if err != nil { @@ -148,7 +148,7 @@ func (s *Server) proxyOpenAIChatCompletionsViaResponses(c *gin.Context, authoriz if err != nil { return nil, fmt.Errorf("call upstream: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() responseBody, wroteResponse, metrics, err := streamOrBufferResponse(c, resp) if err != nil { @@ -1240,7 +1240,7 @@ func convertResponsesSSEToChatCompletionsSSE(body []byte, originalModel string) if err != nil { return nil, fmt.Errorf("marshal chat chunk: %w", err) } - out.WriteString(sse) + _, _ = out.WriteString(sse) } } if err := scanner.Err(); err != nil { @@ -1251,8 +1251,8 @@ func convertResponsesSSEToChatCompletionsSSE(body []byte, originalModel string) if err != nil { return nil, fmt.Errorf("finalize chat chunk: %w", err) } - out.WriteString(sse) + _, _ = out.WriteString(sse) } - out.WriteString("data: [DONE]\n\n") + _, _ = out.WriteString("data: [DONE]\n\n") return out.Bytes(), nil } diff --git a/backend/internal/subsite/agent/proxy_test.go b/backend/internal/subsite/agent/proxy_test.go index de044c586..f3725079e 100644 --- a/backend/internal/subsite/agent/proxy_test.go +++ b/backend/internal/subsite/agent/proxy_test.go @@ -80,7 +80,7 @@ func TestSubsiteAgentProxy_AuthorizesForwardsAndQueuesUsage(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -137,7 +137,7 @@ func TestSubsiteAgentProxy_WebSocketQueuesEachCompletedTurn(t *testing.T) { upstreamAuth = r.Header.Get("Authorization") conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{InsecureSkipVerify: true}) require.NoError(t, err) - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() for i := 1; i <= 2; i++ { readCtx, cancel := context.WithTimeout(r.Context(), time.Second) _, payload, err := conn.Read(readCtx) @@ -202,7 +202,7 @@ func TestSubsiteAgentProxy_WebSocketQueuesEachCompletedTurn(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -223,7 +223,7 @@ func TestSubsiteAgentProxy_WebSocketQueuesEachCompletedTurn(t *testing.T) { }, }) require.NoError(t, err) - defer client.CloseNow() + defer func() { _ = client.CloseNow() }() writeCtx, cancel := context.WithTimeout(context.Background(), time.Second) require.NoError(t, client.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.4","input":"one"}`))) @@ -306,7 +306,7 @@ func TestSubsiteAgentProxy_OpenAIOAuthResponsesUsesCodexUpstream(t *testing.T) { originalTransport := http.DefaultTransport http.DefaultTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // Test transport connects only to httptest.NewTLSServer. DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { if addr == "chatgpt.com:443" { return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "https://")) @@ -353,7 +353,7 @@ func TestSubsiteAgentProxy_OpenAIOAuthResponsesUsesCodexUpstream(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -437,7 +437,7 @@ func TestSubsiteAgentProxy_OpenAIChatCompletionsViaResponses(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -521,7 +521,7 @@ func TestSubsiteAgentProxy_OpenAIChatCompletionsViaResponsesCapturesLatencyAndRe usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -583,7 +583,7 @@ func TestSubsiteAgentProxy_OpenAIOAuthChatCompletionsUseCodexResponsesUpstream(t originalTransport := http.DefaultTransport http.DefaultTransport = &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // Test transport connects only to httptest.NewTLSServer. DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) { if addr == "chatgpt.com:443" { return (&net.Dialer{}).DialContext(ctx, network, strings.TrimPrefix(upstream.URL, "https://")) @@ -630,7 +630,7 @@ func TestSubsiteAgentProxy_OpenAIOAuthChatCompletionsUseCodexResponsesUpstream(t usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -755,7 +755,7 @@ func TestSubsiteAgentProxy_FailoverOnOpenAIResponsesScopeError(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -869,7 +869,7 @@ func TestSubsiteAgentProxy_FailoverOnUpstreamServerError(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -947,7 +947,7 @@ func TestSubsiteAgentProxy_ReturnsRetryAuthorizeError(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -979,7 +979,7 @@ func TestSubsiteAgentProxy_WebSocketFailoverOnInitialDialError(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{InsecureSkipVerify: true}) require.NoError(t, err) - defer conn.CloseNow() + defer func() { _ = conn.CloseNow() }() readCtx, cancel := context.WithTimeout(r.Context(), time.Second) _, payload, err := conn.Read(readCtx) cancel() @@ -1057,7 +1057,7 @@ func TestSubsiteAgentProxy_WebSocketFailoverOnInitialDialError(t *testing.T) { usageQueue, err := queue.Open(filepath.Join(t.TempDir(), "usage.db")) require.NoError(t, err) - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(&Config{ ListenAddr: ":0", @@ -1075,7 +1075,7 @@ func TestSubsiteAgentProxy_WebSocketFailoverOnInitialDialError(t *testing.T) { }, }) require.NoError(t, err) - defer client.CloseNow() + defer func() { _ = client.CloseNow() }() writeCtx, cancel := context.WithTimeout(context.Background(), time.Second) require.NoError(t, client.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.4","input":"one"}`))) diff --git a/backend/internal/subsite/agent/server.go b/backend/internal/subsite/agent/server.go index fe0842bf5..ecd5c1993 100644 --- a/backend/internal/subsite/agent/server.go +++ b/backend/internal/subsite/agent/server.go @@ -571,7 +571,7 @@ func Run(ctx context.Context, cfg *Config) error { if err != nil { return err } - defer usageQueue.Close() + defer func() { _ = usageQueue.Close() }() server := NewServer(cfg, master, usageQueue) return server.Run(ctx) } diff --git a/backend/internal/subsite/client/master_client.go b/backend/internal/subsite/client/master_client.go index 75e63cf5b..f02824ef2 100644 --- a/backend/internal/subsite/client/master_client.go +++ b/backend/internal/subsite/client/master_client.go @@ -81,7 +81,7 @@ func (c *MasterClient) do(ctx context.Context, method, path string, body []byte, if err != nil { return fmt.Errorf("call master: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() payload, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("read master response: %w", err) diff --git a/backend/internal/subsite/queue/sqlite_queue.go b/backend/internal/subsite/queue/sqlite_queue.go index d78355a0d..eb15f408e 100644 --- a/backend/internal/subsite/queue/sqlite_queue.go +++ b/backend/internal/subsite/queue/sqlite_queue.go @@ -72,7 +72,7 @@ func (q *UsageQueue) DequeueBatch(ctx context.Context, limit int) ([]UsageQueueI if err != nil { return nil, fmt.Errorf("select usage queue: %w", err) } - defer rows.Close() + defer func() { _ = rows.Close() }() items := make([]UsageQueueItem, 0, limit) for rows.Next() { var item UsageQueueItem @@ -101,7 +101,7 @@ func (q *UsageQueue) Ack(ctx context.Context, ids []int64) error { if err != nil { return fmt.Errorf("prepare usage queue ack: %w", err) } - defer stmt.Close() + defer func() { _ = stmt.Close() }() for _, id := range ids { if _, err := stmt.ExecContext(ctx, id); err != nil { return fmt.Errorf("ack usage queue item: %w", err) diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 440032be8..12256f9d7 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -778,9 +778,11 @@ database: redis: # Redis host address # Redis 主机地址 + # For unix socket, use "unix:/run/redis/redis.sock" or "/run/redis/redis.sock" + # 如需通过 Unix Socket 连接,可填写 "unix:/run/redis/redis.sock" 或 "/run/redis/redis.sock" host: "localhost" # Redis port - # Redis 端口 + # Redis 端口(Unix Socket 模式下忽略) port: 6379 # Redis password (leave empty if no password is set) # Redis 密码(如果未设置密码则留空) @@ -795,7 +797,7 @@ redis: # 最小空闲连接数 min_idle_conns: 128 # Enable TLS/SSL connection - # 是否启用 TLS/SSL 连接 + # 是否启用 TLS/SSL 连接(Unix Socket 模式不支持 TLS) enable_tls: false # ============================================================================= diff --git a/deploy/install.sh b/deploy/install.sh index 6dcf41238..e159194cd 100644 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -677,6 +677,8 @@ ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/opt/sub2api +RuntimeDirectory=sub2api +RuntimeDirectoryMode=0755 # Environment - Server configuration Environment=GIN_MODE=release diff --git a/deploy/sub2api.service b/deploy/sub2api.service index 1a59ad032..5564d4a27 100644 --- a/deploy/sub2api.service +++ b/deploy/sub2api.service @@ -22,6 +22,8 @@ ProtectSystem=strict ProtectHome=true PrivateTmp=true ReadWritePaths=/opt/sub2api +RuntimeDirectory=sub2api +RuntimeDirectoryMode=0755 # Environment - Server configuration # Modify these values to change listen address and port diff --git a/frontend/package.json b/frontend/package.json index 61d58c10f..507d20cbe 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,5 +57,10 @@ "vite-plugin-checker": "^0.9.1", "vitest": "^2.1.9", "vue-tsc": "^2.2.0" + }, + "pnpm": { + "overrides": { + "js-cookie": "^3.0.8" + } } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 12fd84b55..06cd44bb2 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + js-cookie: ^3.0.8 + importers: .: @@ -2879,9 +2882,8 @@ packages: engines: {node: '>=14'} hasBin: true - js-cookie@3.0.5: - resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} - engines: {node: '>=14'} + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -6362,7 +6364,7 @@ snapshots: '@types/js-cookie': 3.0.6 dayjs: 1.11.20 intersection-observer: 0.12.2 - js-cookie: 3.0.5 + js-cookie: 3.0.8 lodash: 4.18.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -7649,10 +7651,10 @@ snapshots: config-chain: 1.1.13 editorconfig: 1.0.4 glob: 10.5.0 - js-cookie: 3.0.5 + js-cookie: 3.0.8 nopt: 7.2.1 - js-cookie@3.0.5: {} + js-cookie@3.0.8: {} js-tokens@4.0.0: {} diff --git a/frontend/src/api/admin/payment.ts b/frontend/src/api/admin/payment.ts index ae74258f4..89a03333e 100644 --- a/frontend/src/api/admin/payment.ts +++ b/frontend/src/api/admin/payment.ts @@ -33,6 +33,9 @@ export interface AdminPaymentConfig { product_name_suffix: string announcement_text: string recharge_center_items: RechargeCenterItem[] + recharge_center_tab_enabled: boolean + recharge_tab_enabled: boolean + subscription_tab_enabled: boolean help_image_url: string help_text: string } @@ -54,6 +57,9 @@ export interface UpdatePaymentConfigRequest { product_name_suffix?: string announcement_text?: string recharge_center_items?: RechargeCenterItem[] + recharge_center_tab_enabled?: boolean + recharge_tab_enabled?: boolean + subscription_tab_enabled?: boolean help_image_url?: string help_text?: string } diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index 58ac70307..db65a3260 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -498,6 +498,9 @@ export interface SystemSettings { payment_product_name_suffix: string; payment_announcement_text: string; payment_recharge_center_items: PaymentRechargeCenterItem[]; + payment_recharge_center_tab_enabled: boolean; + payment_recharge_tab_enabled: boolean; + payment_subscription_tab_enabled: boolean; payment_help_image_url: string; payment_help_text: string; payment_receipt_code_oss_enabled: boolean; @@ -718,6 +721,9 @@ export interface UpdateSettingsRequest { payment_product_name_suffix?: string; payment_announcement_text?: string; payment_recharge_center_items?: PaymentRechargeCenterItem[]; + payment_recharge_center_tab_enabled?: boolean; + payment_recharge_tab_enabled?: boolean; + payment_subscription_tab_enabled?: boolean; payment_help_image_url?: string; payment_help_text?: string; payment_receipt_code_oss_enabled?: boolean; diff --git a/frontend/src/components/store/DeliveredFilesList.vue b/frontend/src/components/store/DeliveredFilesList.vue index b6ad51617..df8b3eb50 100644 --- a/frontend/src/components/store/DeliveredFilesList.vue +++ b/frontend/src/components/store/DeliveredFilesList.vue @@ -6,7 +6,7 @@ v-if="normalizedFiles.length > 1" type="button" class="btn btn-secondary btn-sm min-h-[2.5rem]" - @click="downloadAllFiles" + @click="handleDownloadAllFiles" > {{ t('store.downloadAllFiles') }} @@ -25,7 +25,7 @@ {{ t('store.downloadFile') }} @@ -52,7 +52,7 @@ const props = defineProps<{ const { t } = useI18n() const normalizedFiles = computed(() => props.files || []) -async function downloadFile(cardId: number, filename: string): Promise { +async function handleDownloadFile(cardId: number, filename: string): Promise { if (props.downloadFile) { await props.downloadFile(props.orderId, cardId, filename) return @@ -60,7 +60,7 @@ async function downloadFile(cardId: number, filename: string): Promise { await storeAPI.downloadOrderFile(props.orderId, cardId, filename) } -async function downloadAllFiles(): Promise { +async function handleDownloadAllFiles(): Promise { if (props.downloadAllFiles) { await props.downloadAllFiles(props.orderId) return diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 2695793d7..cf927eaf5 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -6043,6 +6043,8 @@ export default { configGuide: 'Configuration Guide', enabled: 'Enable Payment', enabledHint: 'Enable or disable the payment system', + tabVisibilityTitle: 'Recharge / Subscription Page', + tabVisibilityHint: 'When disabled, the corresponding tab and content are hidden from the user payment page.', enabledPaymentTypes: 'Enabled Providers', enabledPaymentTypesHint: 'Disabling a provider will also disable its instances.', findProvider: 'Looking for a suitable EasyPay provider?', @@ -7161,6 +7163,7 @@ export default { noActiveSubscription: 'No active subscription', tabTopUp: 'Top Up', tabSubscribe: 'Subscribe', + noVisibleTabs: 'Recharge / subscription entries are currently unavailable', noPlans: 'No subscription plans available', notAvailable: 'Top-up is currently unavailable', confirmSubscription: 'Confirm Subscription', diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index 43fad6817..cf6e652b7 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -6203,6 +6203,8 @@ export default { configGuide: '支付配置指南', enabled: '启用支付', enabledHint: '启用或禁用支付系统', + tabVisibilityTitle: '充值/订阅页显示', + tabVisibilityHint: '关闭后,用户侧充值/订阅页将不显示对应的顶部 Tab 和内容区域。', enabledPaymentTypes: '启用的服务商', enabledPaymentTypesHint: '禁用服务商将同时禁用对应的实例。', findProvider: '正在寻找合适的易支付服务商?', @@ -7344,6 +7346,7 @@ export default { noActiveSubscription: '暂无有效订阅', tabTopUp: '充值', tabSubscribe: '订阅', + noVisibleTabs: '充值/订阅入口暂未开放', noPlans: '暂无可用订阅套餐', notAvailable: '充值功能暂未开放', confirmSubscription: '确认订阅', diff --git a/frontend/src/types/payment.ts b/frontend/src/types/payment.ts index 7bbdf8caa..32983badb 100644 --- a/frontend/src/types/payment.ts +++ b/frontend/src/types/payment.ts @@ -44,6 +44,9 @@ export interface PaymentConfig { enabled_payment_types: PaymentType[] announcement_text: string recharge_center_items: RechargeCenterItem[] + recharge_center_tab_enabled: boolean + recharge_tab_enabled: boolean + subscription_tab_enabled: boolean help_image_url: string help_text: string stripe_publishable_key: string @@ -80,6 +83,9 @@ export interface CheckoutInfoResponse { recharge_fee_rate: number announcement_text: string recharge_center_items: RechargeCenterItem[] + recharge_center_tab_enabled: boolean + recharge_tab_enabled: boolean + subscription_tab_enabled: boolean help_text: string help_image_url: string stripe_publishable_key: string diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 06cd02999..719fbb56d 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -5373,6 +5373,44 @@ + + + + {{ t("admin.settings.payment.tabVisibilityTitle") }} + + + {{ t("admin.settings.payment.tabVisibilityHint") }} + + + + + + {{ t("payment.tabRechargeCenter") }} + + + + + + {{ t("payment.tabTopUp") }} + + + + + + {{ t("payment.tabSubscribe") }} + + + + + @@ -6828,6 +6866,9 @@ const form = reactive({ master_data_plane_enabled: true, hide_ccs_import_button: false, payment_enabled: false, + payment_recharge_center_tab_enabled: true, + payment_recharge_tab_enabled: true, + payment_subscription_tab_enabled: true, payment_min_amount: 1, payment_max_amount: 10000, payment_daily_limit: 50000, @@ -8174,6 +8215,11 @@ async function saveSettings() { form.enable_anthropic_cache_ttl_1h_injection, // Payment configuration payment_enabled: form.payment_enabled, + payment_recharge_center_tab_enabled: + form.payment_recharge_center_tab_enabled, + payment_recharge_tab_enabled: form.payment_recharge_tab_enabled, + payment_subscription_tab_enabled: + form.payment_subscription_tab_enabled, risk_control_enabled: form.risk_control_enabled, payment_min_amount: Number(form.payment_min_amount) || 0, payment_max_amount: Number(form.payment_max_amount) || 0, diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts index db70e3f76..e83191f44 100644 --- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts +++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts @@ -378,6 +378,9 @@ const baseSettingsResponse = { payment_product_name_suffix: "", payment_announcement_text: "", payment_recharge_center_items: [], + payment_recharge_center_tab_enabled: true, + payment_recharge_tab_enabled: true, + payment_subscription_tab_enabled: true, payment_help_image_url: "", payment_help_text: "", payment_cancel_rate_limit_enabled: false, diff --git a/frontend/src/views/user/PaymentView.vue b/frontend/src/views/user/PaymentView.vue index 35c295e12..943495d10 100644 --- a/frontend/src/views/user/PaymentView.vue +++ b/frontend/src/views/user/PaymentView.vue @@ -48,8 +48,11 @@ + + {{ t('payment.noVisibleTabs') }} + - + {{ t('payment.rechargeCenter.empty') }} @@ -512,7 +515,9 @@ function onPaymentSettled() { // All checkout data from single API call const checkout = ref({ methods: {}, global_min: 0, global_max: 0, min_amount: 0, max_amount: 0, - plans: [], balance_disabled: false, balance_recharge_multiplier: 1, recharge_fee_rate: 0, announcement_text: '', recharge_center_items: [], help_text: '', help_image_url: '', stripe_publishable_key: '', + plans: [], balance_disabled: false, balance_recharge_multiplier: 1, recharge_fee_rate: 0, announcement_text: '', recharge_center_items: [], + recharge_center_tab_enabled: true, recharge_tab_enabled: true, subscription_tab_enabled: true, + help_text: '', help_image_url: '', stripe_publishable_key: '', }) const rechargeCenterItems = computed(() => @@ -560,14 +565,27 @@ function parseAnnouncementParts(text: string): AnnouncementPart[] { const announcementParts = computed(() => parseAnnouncementParts(checkout.value.announcement_text || '')) const tabs = computed(() => { - const result: { key: PaymentTabKey; label: string }[] = [ - { key: 'center', label: t('payment.tabRechargeCenter') }, - ] - if (!checkout.value.balance_disabled) result.push({ key: 'recharge', label: t('payment.tabTopUp') }) - result.push({ key: 'subscription', label: t('payment.tabSubscribe') }) + const result: { key: PaymentTabKey; label: string }[] = [] + if (checkout.value.recharge_center_tab_enabled) { + result.push({ key: 'center', label: t('payment.tabRechargeCenter') }) + } + if (checkout.value.recharge_tab_enabled && !checkout.value.balance_disabled) { + result.push({ key: 'recharge', label: t('payment.tabTopUp') }) + } + if (checkout.value.subscription_tab_enabled) { + result.push({ key: 'subscription', label: t('payment.tabSubscribe') }) + } return result }) +function isTabVisible(tab: PaymentTabKey): boolean { + return tabs.value.some(item => item.key === tab) +} + +function selectFirstVisibleTab() { + activeTab.value = tabs.value[0]?.key ?? 'center' +} + const visibleMethods = computed(() => getVisibleMethods(checkout.value.methods)) const enabledMethods = computed(() => Object.keys(visibleMethods.value)) const validAmount = computed(() => amount.value ?? 0) @@ -1143,13 +1161,14 @@ onMounted(async () => { } } await resumeWechatPaymentFromQuery() - if (route.query.tab === 'recharge' && !checkout.value.balance_disabled) { + selectFirstVisibleTab() + if (route.query.tab === 'recharge' && isTabVisible('recharge')) { activeTab.value = 'recharge' - } else if (route.query.tab === 'center') { + } else if (route.query.tab === 'center' && isTabVisible('center')) { activeTab.value = 'center' } // Handle renewal navigation: ?tab=subscription&group=123 - if (route.query.tab === 'subscription') { + if (route.query.tab === 'subscription' && isTabVisible('subscription')) { activeTab.value = 'subscription' if (route.query.group) { const groupId = Number(route.query.group) diff --git a/frontend/src/views/user/__tests__/PaymentView.spec.ts b/frontend/src/views/user/__tests__/PaymentView.spec.ts index 0eeac919d..4a4c3dcd0 100644 --- a/frontend/src/views/user/__tests__/PaymentView.spec.ts +++ b/frontend/src/views/user/__tests__/PaymentView.spec.ts @@ -108,6 +108,9 @@ function checkoutInfoFixture() { recharge_fee_rate: 0, announcement_text: '', recharge_center_items: [], + recharge_center_tab_enabled: true, + recharge_tab_enabled: true, + subscription_tab_enabled: true, help_text: '', help_image_url: '', stripe_publishable_key: '',
+ {{ t("admin.settings.payment.tabVisibilityHint") }} +
{{ t('payment.noVisibleTabs') }}
{{ t('payment.rechargeCenter.empty') }}