Skip to content
GitLab
Menu
Projects
Groups
Snippets
Loading...
Help
Help
Support
Community forum
Keyboard shortcuts
?
Submit feedback
Sign in / Register
Toggle navigation
Menu
Open sidebar
陈曦
sub2api
Commits
fff1d548
Commit
fff1d548
authored
Feb 12, 2026
by
yangjianbo
Browse files
feat(log): 落地统一日志底座与系统日志运维能力
parent
a5f29019
Changes
48
Hide whitespace changes
Inline
Side-by-side
backend/cmd/server/main.go
View file @
fff1d548
...
...
@@ -8,7 +8,6 @@ import (
"errors"
"flag"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
...
...
@@ -19,6 +18,7 @@ import (
_
"github.com/Wei-Shaw/sub2api/ent/runtime"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/handler"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/setup"
"github.com/Wei-Shaw/sub2api/internal/web"
...
...
@@ -49,22 +49,9 @@ func init() {
// initLogger configures the default slog handler based on gin.Mode().
// In non-release mode, Debug level logs are enabled.
func
initLogger
()
{
var
level
slog
.
Level
if
gin
.
Mode
()
==
gin
.
ReleaseMode
{
level
=
slog
.
LevelInfo
}
else
{
level
=
slog
.
LevelDebug
}
handler
:=
slog
.
NewTextHandler
(
os
.
Stderr
,
&
slog
.
HandlerOptions
{
Level
:
level
,
})
slog
.
SetDefault
(
slog
.
New
(
handler
))
}
func
main
()
{
// Initialize slog logger based on gin mode
initLogger
()
logger
.
InitBootstrap
()
defer
logger
.
Sync
()
// Parse command line flags
setupMode
:=
flag
.
Bool
(
"setup"
,
false
,
"Run setup wizard in CLI mode"
)
...
...
@@ -141,6 +128,9 @@ func runMainServer() {
if
err
!=
nil
{
log
.
Fatalf
(
"Failed to load config: %v"
,
err
)
}
if
err
:=
logger
.
Init
(
logger
.
OptionsFromConfig
(
cfg
.
Log
));
err
!=
nil
{
log
.
Fatalf
(
"Failed to initialize logger: %v"
,
err
)
}
if
cfg
.
RunMode
==
config
.
RunModeSimple
{
log
.
Println
(
"⚠️ WARNING: Running in SIMPLE mode - billing and quota checks are DISABLED"
)
}
...
...
backend/cmd/server/wire.go
View file @
fff1d548
...
...
@@ -67,6 +67,7 @@ func provideCleanup(
opsAlertEvaluator
*
service
.
OpsAlertEvaluatorService
,
opsCleanup
*
service
.
OpsCleanupService
,
opsScheduledReport
*
service
.
OpsScheduledReportService
,
opsSystemLogSink
*
service
.
OpsSystemLogSink
,
soraMediaCleanup
*
service
.
SoraMediaCleanupService
,
schedulerSnapshot
*
service
.
SchedulerSnapshotService
,
tokenRefresh
*
service
.
TokenRefreshService
,
...
...
@@ -103,6 +104,12 @@ func provideCleanup(
}
return
nil
}},
{
"OpsSystemLogSink"
,
func
()
error
{
if
opsSystemLogSink
!=
nil
{
opsSystemLogSink
.
Stop
()
}
return
nil
}},
{
"SoraMediaCleanupService"
,
func
()
error
{
if
soraMediaCleanup
!=
nil
{
soraMediaCleanup
.
Stop
()
...
...
backend/cmd/server/wire_gen.go
View file @
fff1d548
...
...
@@ -160,7 +160,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAITokenProvider
:=
service
.
NewOpenAITokenProvider
(
accountRepository
,
geminiTokenCache
,
openAIOAuthService
)
openAIGatewayService
:=
service
.
NewOpenAIGatewayService
(
accountRepository
,
usageLogRepository
,
userRepository
,
userSubscriptionRepository
,
gatewayCache
,
configConfig
,
schedulerSnapshotService
,
concurrencyService
,
billingService
,
rateLimitService
,
billingCacheService
,
httpUpstream
,
deferredService
,
openAITokenProvider
)
geminiMessagesCompatService
:=
service
.
NewGeminiMessagesCompatService
(
accountRepository
,
groupRepository
,
gatewayCache
,
schedulerSnapshotService
,
geminiTokenProvider
,
rateLimitService
,
httpUpstream
,
antigravityGatewayService
,
configConfig
)
opsService
:=
service
.
NewOpsService
(
opsRepository
,
settingRepository
,
configConfig
,
accountRepository
,
userRepository
,
concurrencyService
,
gatewayService
,
openAIGatewayService
,
geminiMessagesCompatService
,
antigravityGatewayService
)
opsSystemLogSink
:=
service
.
ProvideOpsSystemLogSink
(
opsRepository
)
opsService
:=
service
.
NewOpsService
(
opsRepository
,
settingRepository
,
configConfig
,
accountRepository
,
userRepository
,
concurrencyService
,
gatewayService
,
openAIGatewayService
,
geminiMessagesCompatService
,
antigravityGatewayService
,
opsSystemLogSink
)
settingHandler
:=
admin
.
NewSettingHandler
(
settingService
,
emailService
,
turnstileService
,
opsService
)
opsHandler
:=
admin
.
NewOpsHandler
(
opsService
)
updateCache
:=
repository
.
NewUpdateCache
(
redisClient
)
...
...
@@ -204,7 +205,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tokenRefreshService
:=
service
.
ProvideTokenRefreshService
(
accountRepository
,
soraAccountRepository
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
,
compositeTokenCacheInvalidator
,
schedulerCache
,
configConfig
)
accountExpiryService
:=
service
.
ProvideAccountExpiryService
(
accountRepository
)
subscriptionExpiryService
:=
service
.
ProvideSubscriptionExpiryService
(
userSubscriptionRepository
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
soraMediaCleanupService
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
subscriptionExpiryService
,
usageCleanupService
,
pricingService
,
emailQueueService
,
billingCacheService
,
subscriptionService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
)
v
:=
provideCleanup
(
client
,
redisClient
,
opsMetricsCollector
,
opsAggregationService
,
opsAlertEvaluatorService
,
opsCleanupService
,
opsScheduledReportService
,
opsSystemLogSink
,
soraMediaCleanupService
,
schedulerSnapshotService
,
tokenRefreshService
,
accountExpiryService
,
subscriptionExpiryService
,
usageCleanupService
,
pricingService
,
emailQueueService
,
billingCacheService
,
subscriptionService
,
oAuthService
,
openAIOAuthService
,
geminiOAuthService
,
antigravityOAuthService
)
application
:=
&
Application
{
Server
:
httpServer
,
Cleanup
:
v
,
...
...
@@ -234,6 +235,7 @@ func provideCleanup(
opsAlertEvaluator
*
service
.
OpsAlertEvaluatorService
,
opsCleanup
*
service
.
OpsCleanupService
,
opsScheduledReport
*
service
.
OpsScheduledReportService
,
opsSystemLogSink
*
service
.
OpsSystemLogSink
,
soraMediaCleanup
*
service
.
SoraMediaCleanupService
,
schedulerSnapshot
*
service
.
SchedulerSnapshotService
,
tokenRefresh
*
service
.
TokenRefreshService
,
...
...
@@ -269,6 +271,12 @@ func provideCleanup(
}
return
nil
}},
{
"OpsSystemLogSink"
,
func
()
error
{
if
opsSystemLogSink
!=
nil
{
opsSystemLogSink
.
Stop
()
}
return
nil
}},
{
"SoraMediaCleanupService"
,
func
()
error
{
if
soraMediaCleanup
!=
nil
{
soraMediaCleanup
.
Stop
()
...
...
backend/go.mod
View file @
fff1d548
...
...
@@ -5,6 +5,7 @@ go 1.25.7
require (
entgo.io/ent v0.14.5
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/cespare/xxhash/v2 v2.3.0
github.com/dgraph-io/ristretto v0.2.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.2
...
...
@@ -13,6 +14,7 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/imroc/req/v3 v3.57.0
github.com/lib/pq v1.10.9
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.17.2
github.com/refraction-networking/utls v1.8.1
...
...
@@ -25,10 +27,12 @@ require (
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.47.0
golang.org/x/net v0.49.0
golang.org/x/sync v0.19.0
golang.org/x/term v0.39.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
)
...
...
@@ -45,7 +49,6 @@ require (
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
...
...
@@ -104,7 +107,6 @@ require (
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
...
...
backend/go.sum
View file @
fff1d548
...
...
@@ -18,6 +18,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
...
...
@@ -137,8 +139,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=
...
...
@@ -174,8 +174,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=
...
...
@@ -209,8 +207,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=
...
...
@@ -240,8 +236,6 @@ github.com/refraction-networking/utls v1.8.1 h1:yNY1kapmQU8JeM1sSw2H2asfTIwWxIkr
github.com/refraction-networking/utls v1.8.1/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=
...
...
@@ -264,8 +258,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=
...
...
@@ -342,10 +334,14 @@ go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
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=
...
...
@@ -393,6 +389,8 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
...
...
backend/internal/config/config.go
View file @
fff1d548
...
...
@@ -39,6 +39,7 @@ const (
type
Config
struct
{
Server
ServerConfig
`mapstructure:"server"`
Log
LogConfig
`mapstructure:"log"`
CORS
CORSConfig
`mapstructure:"cors"`
Security
SecurityConfig
`mapstructure:"security"`
Billing
BillingConfig
`mapstructure:"billing"`
...
...
@@ -68,6 +69,38 @@ type Config struct {
Update
UpdateConfig
`mapstructure:"update"`
}
type
LogConfig
struct
{
Level
string
`mapstructure:"level"`
Format
string
`mapstructure:"format"`
ServiceName
string
`mapstructure:"service_name"`
Environment
string
`mapstructure:"env"`
Caller
bool
`mapstructure:"caller"`
StacktraceLevel
string
`mapstructure:"stacktrace_level"`
Output
LogOutputConfig
`mapstructure:"output"`
Rotation
LogRotationConfig
`mapstructure:"rotation"`
Sampling
LogSamplingConfig
`mapstructure:"sampling"`
}
type
LogOutputConfig
struct
{
ToStdout
bool
`mapstructure:"to_stdout"`
ToFile
bool
`mapstructure:"to_file"`
FilePath
string
`mapstructure:"file_path"`
}
type
LogRotationConfig
struct
{
MaxSizeMB
int
`mapstructure:"max_size_mb"`
MaxBackups
int
`mapstructure:"max_backups"`
MaxAgeDays
int
`mapstructure:"max_age_days"`
Compress
bool
`mapstructure:"compress"`
LocalTime
bool
`mapstructure:"local_time"`
}
type
LogSamplingConfig
struct
{
Enabled
bool
`mapstructure:"enabled"`
Initial
int
`mapstructure:"initial"`
Thereafter
int
`mapstructure:"thereafter"`
}
type
GeminiConfig
struct
{
OAuth
GeminiOAuthConfig
`mapstructure:"oauth"`
Quota
GeminiQuotaConfig
`mapstructure:"quota"`
...
...
@@ -756,6 +789,12 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg
.
Security
.
ResponseHeaders
.
AdditionalAllowed
=
normalizeStringSlice
(
cfg
.
Security
.
ResponseHeaders
.
AdditionalAllowed
)
cfg
.
Security
.
ResponseHeaders
.
ForceRemove
=
normalizeStringSlice
(
cfg
.
Security
.
ResponseHeaders
.
ForceRemove
)
cfg
.
Security
.
CSP
.
Policy
=
strings
.
TrimSpace
(
cfg
.
Security
.
CSP
.
Policy
)
cfg
.
Log
.
Level
=
strings
.
ToLower
(
strings
.
TrimSpace
(
cfg
.
Log
.
Level
))
cfg
.
Log
.
Format
=
strings
.
ToLower
(
strings
.
TrimSpace
(
cfg
.
Log
.
Format
))
cfg
.
Log
.
ServiceName
=
strings
.
TrimSpace
(
cfg
.
Log
.
ServiceName
)
cfg
.
Log
.
Environment
=
strings
.
TrimSpace
(
cfg
.
Log
.
Environment
)
cfg
.
Log
.
StacktraceLevel
=
strings
.
ToLower
(
strings
.
TrimSpace
(
cfg
.
Log
.
StacktraceLevel
))
cfg
.
Log
.
Output
.
FilePath
=
strings
.
TrimSpace
(
cfg
.
Log
.
Output
.
FilePath
)
// Auto-generate TOTP encryption key if not set (32 bytes = 64 hex chars for AES-256)
cfg
.
Totp
.
EncryptionKey
=
strings
.
TrimSpace
(
cfg
.
Totp
.
EncryptionKey
)
...
...
@@ -825,6 +864,25 @@ func setDefaults() {
viper
.
SetDefault
(
"server.h2c.max_upload_buffer_per_connection"
,
2
<<
20
)
// 2MB
viper
.
SetDefault
(
"server.h2c.max_upload_buffer_per_stream"
,
512
<<
10
)
// 512KB
// Log
viper
.
SetDefault
(
"log.level"
,
"info"
)
viper
.
SetDefault
(
"log.format"
,
"json"
)
viper
.
SetDefault
(
"log.service_name"
,
"sub2api"
)
viper
.
SetDefault
(
"log.env"
,
"production"
)
viper
.
SetDefault
(
"log.caller"
,
true
)
viper
.
SetDefault
(
"log.stacktrace_level"
,
"error"
)
viper
.
SetDefault
(
"log.output.to_stdout"
,
true
)
viper
.
SetDefault
(
"log.output.to_file"
,
true
)
viper
.
SetDefault
(
"log.output.file_path"
,
""
)
viper
.
SetDefault
(
"log.rotation.max_size_mb"
,
100
)
viper
.
SetDefault
(
"log.rotation.max_backups"
,
10
)
viper
.
SetDefault
(
"log.rotation.max_age_days"
,
7
)
viper
.
SetDefault
(
"log.rotation.compress"
,
true
)
viper
.
SetDefault
(
"log.rotation.local_time"
,
true
)
viper
.
SetDefault
(
"log.sampling.enabled"
,
false
)
viper
.
SetDefault
(
"log.sampling.initial"
,
100
)
viper
.
SetDefault
(
"log.sampling.thereafter"
,
100
)
// CORS
viper
.
SetDefault
(
"cors.allowed_origins"
,
[]
string
{})
viper
.
SetDefault
(
"cors.allow_credentials"
,
true
)
...
...
@@ -1098,6 +1156,54 @@ func (c *Config) Validate() error {
if
len
([]
byte
(
jwtSecret
))
<
32
{
return
fmt
.
Errorf
(
"jwt.secret must be at least 32 bytes"
)
}
switch
c
.
Log
.
Level
{
case
"debug"
,
"info"
,
"warn"
,
"error"
:
case
""
:
return
fmt
.
Errorf
(
"log.level is required"
)
default
:
return
fmt
.
Errorf
(
"log.level must be one of: debug/info/warn/error"
)
}
switch
c
.
Log
.
Format
{
case
"json"
,
"console"
:
case
""
:
return
fmt
.
Errorf
(
"log.format is required"
)
default
:
return
fmt
.
Errorf
(
"log.format must be one of: json/console"
)
}
switch
c
.
Log
.
StacktraceLevel
{
case
"none"
,
"error"
,
"fatal"
:
case
""
:
return
fmt
.
Errorf
(
"log.stacktrace_level is required"
)
default
:
return
fmt
.
Errorf
(
"log.stacktrace_level must be one of: none/error/fatal"
)
}
if
!
c
.
Log
.
Output
.
ToStdout
&&
!
c
.
Log
.
Output
.
ToFile
{
return
fmt
.
Errorf
(
"log.output.to_stdout and log.output.to_file cannot both be false"
)
}
if
c
.
Log
.
Rotation
.
MaxSizeMB
<=
0
{
return
fmt
.
Errorf
(
"log.rotation.max_size_mb must be positive"
)
}
if
c
.
Log
.
Rotation
.
MaxBackups
<
0
{
return
fmt
.
Errorf
(
"log.rotation.max_backups must be non-negative"
)
}
if
c
.
Log
.
Rotation
.
MaxAgeDays
<
0
{
return
fmt
.
Errorf
(
"log.rotation.max_age_days must be non-negative"
)
}
if
c
.
Log
.
Sampling
.
Enabled
{
if
c
.
Log
.
Sampling
.
Initial
<=
0
{
return
fmt
.
Errorf
(
"log.sampling.initial must be positive when sampling is enabled"
)
}
if
c
.
Log
.
Sampling
.
Thereafter
<=
0
{
return
fmt
.
Errorf
(
"log.sampling.thereafter must be positive when sampling is enabled"
)
}
}
else
{
if
c
.
Log
.
Sampling
.
Initial
<
0
{
return
fmt
.
Errorf
(
"log.sampling.initial must be non-negative"
)
}
if
c
.
Log
.
Sampling
.
Thereafter
<
0
{
return
fmt
.
Errorf
(
"log.sampling.thereafter must be non-negative"
)
}
}
if
c
.
SubscriptionMaintenance
.
WorkerCount
<
0
{
return
fmt
.
Errorf
(
"subscription_maintenance.worker_count must be non-negative"
)
...
...
backend/internal/config/config_test.go
View file @
fff1d548
...
...
@@ -965,6 +965,37 @@ func TestValidateConfigErrors(t *testing.T) {
},
wantErr
:
"gateway.scheduling.outbox_lag_rebuild_seconds"
,
},
{
name
:
"log level invalid"
,
mutate
:
func
(
c
*
Config
)
{
c
.
Log
.
Level
=
"trace"
},
wantErr
:
"log.level"
,
},
{
name
:
"log format invalid"
,
mutate
:
func
(
c
*
Config
)
{
c
.
Log
.
Format
=
"plain"
},
wantErr
:
"log.format"
,
},
{
name
:
"log output disabled"
,
mutate
:
func
(
c
*
Config
)
{
c
.
Log
.
Output
.
ToStdout
=
false
c
.
Log
.
Output
.
ToFile
=
false
},
wantErr
:
"log.output.to_stdout and log.output.to_file cannot both be false"
,
},
{
name
:
"log rotation size"
,
mutate
:
func
(
c
*
Config
)
{
c
.
Log
.
Rotation
.
MaxSizeMB
=
0
},
wantErr
:
"log.rotation.max_size_mb"
,
},
{
name
:
"log sampling enabled invalid"
,
mutate
:
func
(
c
*
Config
)
{
c
.
Log
.
Sampling
.
Enabled
=
true
c
.
Log
.
Sampling
.
Initial
=
0
},
wantErr
:
"log.sampling.initial"
,
},
{
name
:
"ops metrics collector ttl"
,
mutate
:
func
(
c
*
Config
)
{
c
.
Ops
.
MetricsCollectorCache
.
TTL
=
-
1
},
...
...
backend/internal/handler/admin/ops_runtime_logging_handler_test.go
0 → 100644
View file @
fff1d548
package
admin
import
(
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type
testSettingRepo
struct
{
values
map
[
string
]
string
}
func
newTestSettingRepo
()
*
testSettingRepo
{
return
&
testSettingRepo
{
values
:
map
[
string
]
string
{}}
}
func
(
s
*
testSettingRepo
)
Get
(
ctx
context
.
Context
,
key
string
)
(
*
service
.
Setting
,
error
)
{
v
,
err
:=
s
.
GetValue
(
ctx
,
key
)
if
err
!=
nil
{
return
nil
,
err
}
return
&
service
.
Setting
{
Key
:
key
,
Value
:
v
},
nil
}
func
(
s
*
testSettingRepo
)
GetValue
(
ctx
context
.
Context
,
key
string
)
(
string
,
error
)
{
v
,
ok
:=
s
.
values
[
key
]
if
!
ok
{
return
""
,
service
.
ErrSettingNotFound
}
return
v
,
nil
}
func
(
s
*
testSettingRepo
)
Set
(
ctx
context
.
Context
,
key
,
value
string
)
error
{
s
.
values
[
key
]
=
value
return
nil
}
func
(
s
*
testSettingRepo
)
GetMultiple
(
ctx
context
.
Context
,
keys
[]
string
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
keys
))
for
_
,
k
:=
range
keys
{
if
v
,
ok
:=
s
.
values
[
k
];
ok
{
out
[
k
]
=
v
}
}
return
out
,
nil
}
func
(
s
*
testSettingRepo
)
SetMultiple
(
ctx
context
.
Context
,
settings
map
[
string
]
string
)
error
{
for
k
,
v
:=
range
settings
{
s
.
values
[
k
]
=
v
}
return
nil
}
func
(
s
*
testSettingRepo
)
GetAll
(
ctx
context
.
Context
)
(
map
[
string
]
string
,
error
)
{
out
:=
make
(
map
[
string
]
string
,
len
(
s
.
values
))
for
k
,
v
:=
range
s
.
values
{
out
[
k
]
=
v
}
return
out
,
nil
}
func
(
s
*
testSettingRepo
)
Delete
(
ctx
context
.
Context
,
key
string
)
error
{
delete
(
s
.
values
,
key
)
return
nil
}
func
newOpsRuntimeRouter
(
handler
*
OpsHandler
,
withUser
bool
)
*
gin
.
Engine
{
gin
.
SetMode
(
gin
.
TestMode
)
r
:=
gin
.
New
()
if
withUser
{
r
.
Use
(
func
(
c
*
gin
.
Context
)
{
c
.
Set
(
string
(
middleware
.
ContextKeyUser
),
middleware
.
AuthSubject
{
UserID
:
7
})
c
.
Next
()
})
}
r
.
GET
(
"/runtime/logging"
,
handler
.
GetRuntimeLogConfig
)
r
.
PUT
(
"/runtime/logging"
,
handler
.
UpdateRuntimeLogConfig
)
r
.
POST
(
"/runtime/logging/reset"
,
handler
.
ResetRuntimeLogConfig
)
return
r
}
func
newRuntimeOpsService
(
t
*
testing
.
T
)
*
service
.
OpsService
{
t
.
Helper
()
if
err
:=
logger
.
Init
(
logger
.
InitOptions
{
Level
:
"info"
,
Format
:
"json"
,
ServiceName
:
"sub2api"
,
Environment
:
"test"
,
Output
:
logger
.
OutputOptions
{
ToStdout
:
false
,
ToFile
:
false
,
},
});
err
!=
nil
{
t
.
Fatalf
(
"init logger: %v"
,
err
)
}
settingRepo
:=
newTestSettingRepo
()
cfg
:=
&
config
.
Config
{
Ops
:
config
.
OpsConfig
{
Enabled
:
true
},
Log
:
config
.
LogConfig
{
Level
:
"info"
,
Caller
:
true
,
StacktraceLevel
:
"error"
,
Sampling
:
config
.
LogSamplingConfig
{
Enabled
:
false
,
Initial
:
100
,
Thereafter
:
100
,
},
},
}
return
service
.
NewOpsService
(
nil
,
settingRepo
,
cfg
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
,
nil
)
}
func
TestOpsRuntimeLoggingHandler_GetConfig
(
t
*
testing
.
T
)
{
h
:=
NewOpsHandler
(
newRuntimeOpsService
(
t
))
r
:=
newOpsRuntimeRouter
(
h
,
false
)
w
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodGet
,
"/runtime/logging"
,
nil
)
r
.
ServeHTTP
(
w
,
req
)
if
w
.
Code
!=
http
.
StatusOK
{
t
.
Fatalf
(
"status=%d, want 200"
,
w
.
Code
)
}
}
func
TestOpsRuntimeLoggingHandler_UpdateUnauthorized
(
t
*
testing
.
T
)
{
h
:=
NewOpsHandler
(
newRuntimeOpsService
(
t
))
r
:=
newOpsRuntimeRouter
(
h
,
false
)
body
:=
`{"level":"debug","enable_sampling":false,"sampling_initial":100,"sampling_thereafter":100,"caller":true,"stacktrace_level":"error","retention_days":30}`
w
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/runtime/logging"
,
bytes
.
NewBufferString
(
body
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
r
.
ServeHTTP
(
w
,
req
)
if
w
.
Code
!=
http
.
StatusUnauthorized
{
t
.
Fatalf
(
"status=%d, want 401"
,
w
.
Code
)
}
}
func
TestOpsRuntimeLoggingHandler_UpdateAndResetSuccess
(
t
*
testing
.
T
)
{
h
:=
NewOpsHandler
(
newRuntimeOpsService
(
t
))
r
:=
newOpsRuntimeRouter
(
h
,
true
)
payload
:=
map
[
string
]
any
{
"level"
:
"debug"
,
"enable_sampling"
:
false
,
"sampling_initial"
:
100
,
"sampling_thereafter"
:
100
,
"caller"
:
true
,
"stacktrace_level"
:
"error"
,
"retention_days"
:
30
,
}
raw
,
_
:=
json
.
Marshal
(
payload
)
w
:=
httptest
.
NewRecorder
()
req
:=
httptest
.
NewRequest
(
http
.
MethodPut
,
"/runtime/logging"
,
bytes
.
NewReader
(
raw
))
req
.
Header
.
Set
(
"Content-Type"
,
"application/json"
)
r
.
ServeHTTP
(
w
,
req
)
if
w
.
Code
!=
http
.
StatusOK
{
t
.
Fatalf
(
"update status=%d, want 200, body=%s"
,
w
.
Code
,
w
.
Body
.
String
())
}
w
=
httptest
.
NewRecorder
()
req
=
httptest
.
NewRequest
(
http
.
MethodPost
,
"/runtime/logging/reset"
,
nil
)
r
.
ServeHTTP
(
w
,
req
)
if
w
.
Code
!=
http
.
StatusOK
{
t
.
Fatalf
(
"reset status=%d, want 200, body=%s"
,
w
.
Code
,
w
.
Body
.
String
())
}
}
backend/internal/handler/admin/ops_settings_handler.go
View file @
fff1d548
...
...
@@ -4,6 +4,7 @@ import (
"net/http"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
...
...
@@ -101,6 +102,84 @@ func (h *OpsHandler) UpdateAlertRuntimeSettings(c *gin.Context) {
response
.
Success
(
c
,
updated
)
}
// GetRuntimeLogConfig returns runtime log config (DB-backed).
// GET /api/v1/admin/ops/runtime/logging
func
(
h
*
OpsHandler
)
GetRuntimeLogConfig
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
cfg
,
err
:=
h
.
opsService
.
GetRuntimeLogConfig
(
c
.
Request
.
Context
())
if
err
!=
nil
{
response
.
Error
(
c
,
http
.
StatusInternalServerError
,
"Failed to get runtime log config"
)
return
}
response
.
Success
(
c
,
cfg
)
}
// UpdateRuntimeLogConfig updates runtime log config and applies changes immediately.
// PUT /api/v1/admin/ops/runtime/logging
func
(
h
*
OpsHandler
)
UpdateRuntimeLogConfig
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
var
req
service
.
OpsRuntimeLogConfig
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request body"
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Error
(
c
,
http
.
StatusUnauthorized
,
"Unauthorized"
)
return
}
updated
,
err
:=
h
.
opsService
.
UpdateRuntimeLogConfig
(
c
.
Request
.
Context
(),
&
req
,
subject
.
UserID
)
if
err
!=
nil
{
response
.
Error
(
c
,
http
.
StatusBadRequest
,
err
.
Error
())
return
}
response
.
Success
(
c
,
updated
)
}
// ResetRuntimeLogConfig removes runtime override and falls back to env/yaml baseline.
// POST /api/v1/admin/ops/runtime/logging/reset
func
(
h
*
OpsHandler
)
ResetRuntimeLogConfig
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Error
(
c
,
http
.
StatusUnauthorized
,
"Unauthorized"
)
return
}
updated
,
err
:=
h
.
opsService
.
ResetRuntimeLogConfig
(
c
.
Request
.
Context
(),
subject
.
UserID
)
if
err
!=
nil
{
response
.
Error
(
c
,
http
.
StatusBadRequest
,
err
.
Error
())
return
}
response
.
Success
(
c
,
updated
)
}
// GetAdvancedSettings returns Ops advanced settings (DB-backed).
// GET /api/v1/admin/ops/advanced-settings
func
(
h
*
OpsHandler
)
GetAdvancedSettings
(
c
*
gin
.
Context
)
{
...
...
backend/internal/handler/admin/ops_system_log_handler.go
0 → 100644
View file @
fff1d548
package
admin
import
(
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type
opsSystemLogCleanupRequest
struct
{
StartTime
string
`json:"start_time"`
EndTime
string
`json:"end_time"`
Level
string
`json:"level"`
Component
string
`json:"component"`
RequestID
string
`json:"request_id"`
ClientRequestID
string
`json:"client_request_id"`
UserID
*
int64
`json:"user_id"`
AccountID
*
int64
`json:"account_id"`
Platform
string
`json:"platform"`
Model
string
`json:"model"`
Query
string
`json:"q"`
}
// ListSystemLogs returns indexed system logs.
// GET /api/v1/admin/ops/system-logs
func
(
h
*
OpsHandler
)
ListSystemLogs
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
page
,
pageSize
:=
response
.
ParsePagination
(
c
)
if
pageSize
>
200
{
pageSize
=
200
}
start
,
end
,
err
:=
parseOpsTimeRange
(
c
,
"1h"
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
err
.
Error
())
return
}
filter
:=
&
service
.
OpsSystemLogFilter
{
Page
:
page
,
PageSize
:
pageSize
,
StartTime
:
&
start
,
EndTime
:
&
end
,
Level
:
strings
.
TrimSpace
(
c
.
Query
(
"level"
)),
Component
:
strings
.
TrimSpace
(
c
.
Query
(
"component"
)),
RequestID
:
strings
.
TrimSpace
(
c
.
Query
(
"request_id"
)),
ClientRequestID
:
strings
.
TrimSpace
(
c
.
Query
(
"client_request_id"
)),
Platform
:
strings
.
TrimSpace
(
c
.
Query
(
"platform"
)),
Model
:
strings
.
TrimSpace
(
c
.
Query
(
"model"
)),
Query
:
strings
.
TrimSpace
(
c
.
Query
(
"q"
)),
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"user_id"
));
v
!=
""
{
id
,
parseErr
:=
strconv
.
ParseInt
(
v
,
10
,
64
)
if
parseErr
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid user_id"
)
return
}
filter
.
UserID
=
&
id
}
if
v
:=
strings
.
TrimSpace
(
c
.
Query
(
"account_id"
));
v
!=
""
{
id
,
parseErr
:=
strconv
.
ParseInt
(
v
,
10
,
64
)
if
parseErr
!=
nil
||
id
<=
0
{
response
.
BadRequest
(
c
,
"Invalid account_id"
)
return
}
filter
.
AccountID
=
&
id
}
result
,
err
:=
h
.
opsService
.
ListSystemLogs
(
c
.
Request
.
Context
(),
filter
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Paginated
(
c
,
result
.
Logs
,
int64
(
result
.
Total
),
result
.
Page
,
result
.
PageSize
)
}
// CleanupSystemLogs deletes indexed system logs by filter.
// POST /api/v1/admin/ops/system-logs/cleanup
func
(
h
*
OpsHandler
)
CleanupSystemLogs
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
subject
,
ok
:=
middleware
.
GetAuthSubjectFromContext
(
c
)
if
!
ok
||
subject
.
UserID
<=
0
{
response
.
Error
(
c
,
http
.
StatusUnauthorized
,
"Unauthorized"
)
return
}
var
req
opsSystemLogCleanupRequest
if
err
:=
c
.
ShouldBindJSON
(
&
req
);
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid request body"
)
return
}
parseTS
:=
func
(
raw
string
)
(
*
time
.
Time
,
error
)
{
raw
=
strings
.
TrimSpace
(
raw
)
if
raw
==
""
{
return
nil
,
nil
}
if
t
,
err
:=
time
.
Parse
(
time
.
RFC3339Nano
,
raw
);
err
==
nil
{
return
&
t
,
nil
}
t
,
err
:=
time
.
Parse
(
time
.
RFC3339
,
raw
)
if
err
!=
nil
{
return
nil
,
err
}
return
&
t
,
nil
}
start
,
err
:=
parseTS
(
req
.
StartTime
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid start_time"
)
return
}
end
,
err
:=
parseTS
(
req
.
EndTime
)
if
err
!=
nil
{
response
.
BadRequest
(
c
,
"Invalid end_time"
)
return
}
filter
:=
&
service
.
OpsSystemLogCleanupFilter
{
StartTime
:
start
,
EndTime
:
end
,
Level
:
strings
.
TrimSpace
(
req
.
Level
),
Component
:
strings
.
TrimSpace
(
req
.
Component
),
RequestID
:
strings
.
TrimSpace
(
req
.
RequestID
),
ClientRequestID
:
strings
.
TrimSpace
(
req
.
ClientRequestID
),
UserID
:
req
.
UserID
,
AccountID
:
req
.
AccountID
,
Platform
:
strings
.
TrimSpace
(
req
.
Platform
),
Model
:
strings
.
TrimSpace
(
req
.
Model
),
Query
:
strings
.
TrimSpace
(
req
.
Query
),
}
deleted
,
err
:=
h
.
opsService
.
CleanupSystemLogs
(
c
.
Request
.
Context
(),
filter
,
subject
.
UserID
)
if
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
gin
.
H
{
"deleted"
:
deleted
})
}
// GetSystemLogIngestionHealth returns sink health metrics.
// GET /api/v1/admin/ops/system-logs/health
func
(
h
*
OpsHandler
)
GetSystemLogIngestionHealth
(
c
*
gin
.
Context
)
{
if
h
.
opsService
==
nil
{
response
.
Error
(
c
,
http
.
StatusServiceUnavailable
,
"Ops service not available"
)
return
}
if
err
:=
h
.
opsService
.
RequireMonitoringEnabled
(
c
.
Request
.
Context
());
err
!=
nil
{
response
.
ErrorFrom
(
c
,
err
)
return
}
response
.
Success
(
c
,
h
.
opsService
.
GetSystemLogSinkHealth
())
}
backend/internal/handler/gateway_handler.go
View file @
fff1d548
...
...
@@ -276,7 +276,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
return
}
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
{
...
...
@@ -462,7 +462,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
return
}
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
// 检查请求拦截(预热请求、SUGGESTION MODE等)
if
account
.
IsInterceptWarmupEnabled
()
{
...
...
@@ -1087,7 +1087,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
h
.
errorResponse
(
c
,
http
.
StatusServiceUnavailable
,
"api_error"
,
"Service temporarily unavailable"
)
return
}
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
// 转发请求(不记录使用量)
if
err
:=
h
.
gatewayService
.
ForwardCountTokens
(
c
.
Request
.
Context
(),
c
,
account
,
parsedReq
);
err
!=
nil
{
...
...
backend/internal/handler/gemini_v1beta_handler.go
View file @
fff1d548
...
...
@@ -358,7 +358,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
return
}
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
// 检测账号切换:如果粘性会话绑定的账号与当前选择的账号不同,清除 thoughtSignature
// 注意:Gemini 原生 API 的 thoughtSignature 与具体上游账号强相关;跨账号透传会导致 400。
...
...
backend/internal/handler/openai_gateway_handler.go
View file @
fff1d548
...
...
@@ -240,7 +240,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
}
account
:=
selection
.
Account
log
.
Printf
(
"[OpenAI Handler] Selected account: id=%d name=%s"
,
account
.
ID
,
account
.
Name
)
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
// 3. Acquire account concurrency slot
accountReleaseFunc
:=
selection
.
ReleaseFunc
...
...
backend/internal/handler/ops_error_logger.go
View file @
fff1d548
...
...
@@ -255,18 +255,33 @@ func setOpsRequestContext(c *gin.Context, model string, stream bool, requestBody
if
c
==
nil
{
return
}
model
=
strings
.
TrimSpace
(
model
)
c
.
Set
(
opsModelKey
,
model
)
c
.
Set
(
opsStreamKey
,
stream
)
if
len
(
requestBody
)
>
0
{
c
.
Set
(
opsRequestBodyKey
,
requestBody
)
}
if
c
.
Request
!=
nil
&&
model
!=
""
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
Model
,
model
)
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
}
func
setOpsSelectedAccount
(
c
*
gin
.
Context
,
accountID
int64
)
{
func
setOpsSelectedAccount
(
c
*
gin
.
Context
,
accountID
int64
,
platform
...
string
)
{
if
c
==
nil
||
accountID
<=
0
{
return
}
c
.
Set
(
opsAccountIDKey
,
accountID
)
if
c
.
Request
!=
nil
{
ctx
:=
context
.
WithValue
(
c
.
Request
.
Context
(),
ctxkey
.
AccountID
,
accountID
)
if
len
(
platform
)
>
0
{
p
:=
strings
.
TrimSpace
(
platform
[
0
])
if
p
!=
""
{
ctx
=
context
.
WithValue
(
ctx
,
ctxkey
.
Platform
,
p
)
}
}
c
.
Request
=
c
.
Request
.
WithContext
(
ctx
)
}
}
type
opsCaptureWriter
struct
{
...
...
backend/internal/handler/sora_gateway_handler.go
View file @
fff1d548
...
...
@@ -215,7 +215,7 @@ func (h *SoraGatewayHandler) ChatCompletions(c *gin.Context) {
return
}
account
:=
selection
.
Account
setOpsSelectedAccount
(
c
,
account
.
ID
)
setOpsSelectedAccount
(
c
,
account
.
ID
,
account
.
Platform
)
accountReleaseFunc
:=
selection
.
ReleaseFunc
if
!
selection
.
Acquired
{
...
...
backend/internal/pkg/ctxkey/ctxkey.go
View file @
fff1d548
...
...
@@ -8,9 +8,21 @@ const (
// ForcePlatform 强制平台(用于 /antigravity 路由),由 middleware.ForcePlatform 设置
ForcePlatform
Key
=
"ctx_force_platform"
// RequestID 为服务端生成/透传的请求 ID。
RequestID
Key
=
"ctx_request_id"
// ClientRequestID 客户端请求的唯一标识,用于追踪请求全生命周期(用于 Ops 监控与排障)。
ClientRequestID
Key
=
"ctx_client_request_id"
// Model 请求模型标识(用于统一请求链路日志字段)。
Model
Key
=
"ctx_model"
// Platform 当前请求最终命中的平台(用于统一请求链路日志字段)。
Platform
Key
=
"ctx_platform"
// AccountID 当前请求最终命中的账号 ID(用于统一请求链路日志字段)。
AccountID
Key
=
"ctx_account_id"
// RetryCount 表示当前请求在网关层的重试次数(用于 Ops 记录与排障)。
RetryCount
Key
=
"ctx_retry_count"
...
...
backend/internal/pkg/logger/config_adapter.go
0 → 100644
View file @
fff1d548
package
logger
import
"github.com/Wei-Shaw/sub2api/internal/config"
func
OptionsFromConfig
(
cfg
config
.
LogConfig
)
InitOptions
{
return
InitOptions
{
Level
:
cfg
.
Level
,
Format
:
cfg
.
Format
,
ServiceName
:
cfg
.
ServiceName
,
Environment
:
cfg
.
Environment
,
Caller
:
cfg
.
Caller
,
StacktraceLevel
:
cfg
.
StacktraceLevel
,
Output
:
OutputOptions
{
ToStdout
:
cfg
.
Output
.
ToStdout
,
ToFile
:
cfg
.
Output
.
ToFile
,
FilePath
:
cfg
.
Output
.
FilePath
,
},
Rotation
:
RotationOptions
{
MaxSizeMB
:
cfg
.
Rotation
.
MaxSizeMB
,
MaxBackups
:
cfg
.
Rotation
.
MaxBackups
,
MaxAgeDays
:
cfg
.
Rotation
.
MaxAgeDays
,
Compress
:
cfg
.
Rotation
.
Compress
,
LocalTime
:
cfg
.
Rotation
.
LocalTime
,
},
Sampling
:
SamplingOptions
{
Enabled
:
cfg
.
Sampling
.
Enabled
,
Initial
:
cfg
.
Sampling
.
Initial
,
Thereafter
:
cfg
.
Sampling
.
Thereafter
,
},
}
}
backend/internal/pkg/logger/logger.go
0 → 100644
View file @
fff1d548
package
logger
import
(
"context"
"fmt"
"log"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"gopkg.in/natefinch/lumberjack.v2"
)
type
Level
=
zapcore
.
Level
const
(
LevelDebug
=
zapcore
.
DebugLevel
LevelInfo
=
zapcore
.
InfoLevel
LevelWarn
=
zapcore
.
WarnLevel
LevelError
=
zapcore
.
ErrorLevel
LevelFatal
=
zapcore
.
FatalLevel
)
type
Sink
interface
{
WriteLogEvent
(
event
*
LogEvent
)
}
type
LogEvent
struct
{
Time
time
.
Time
Level
string
Component
string
Message
string
LoggerName
string
Fields
map
[
string
]
any
}
var
(
mu
sync
.
RWMutex
global
*
zap
.
Logger
sugar
*
zap
.
SugaredLogger
atomicLevel
zap
.
AtomicLevel
initOptions
InitOptions
currentSink
Sink
stdLogUndo
func
()
bootstrapOnce
sync
.
Once
)
func
InitBootstrap
()
{
bootstrapOnce
.
Do
(
func
()
{
if
err
:=
Init
(
bootstrapOptions
());
err
!=
nil
{
_
,
_
=
fmt
.
Fprintf
(
os
.
Stderr
,
"logger bootstrap init failed: %v
\n
"
,
err
)
}
})
}
func
Init
(
options
InitOptions
)
error
{
mu
.
Lock
()
defer
mu
.
Unlock
()
return
initLocked
(
options
)
}
func
initLocked
(
options
InitOptions
)
error
{
normalized
:=
options
.
normalized
()
zl
,
al
,
err
:=
buildLogger
(
normalized
)
if
err
!=
nil
{
return
err
}
prev
:=
global
global
=
zl
sugar
=
zl
.
Sugar
()
atomicLevel
=
al
initOptions
=
normalized
bridgeStdLogLocked
()
bridgeSlogLocked
()
if
prev
!=
nil
{
_
=
prev
.
Sync
()
}
return
nil
}
func
Reconfigure
(
mutator
func
(
*
InitOptions
)
error
)
error
{
mu
.
Lock
()
defer
mu
.
Unlock
()
next
:=
initOptions
if
mutator
!=
nil
{
if
err
:=
mutator
(
&
next
);
err
!=
nil
{
return
err
}
}
return
initLocked
(
next
)
}
func
SetLevel
(
level
string
)
error
{
lv
,
ok
:=
parseLevel
(
level
)
if
!
ok
{
return
fmt
.
Errorf
(
"invalid log level: %s"
,
level
)
}
mu
.
Lock
()
defer
mu
.
Unlock
()
atomicLevel
.
SetLevel
(
lv
)
initOptions
.
Level
=
strings
.
ToLower
(
strings
.
TrimSpace
(
level
))
return
nil
}
func
CurrentLevel
()
string
{
mu
.
RLock
()
defer
mu
.
RUnlock
()
if
global
==
nil
{
return
"info"
}
return
atomicLevel
.
Level
()
.
String
()
}
func
SetSink
(
sink
Sink
)
{
mu
.
Lock
()
defer
mu
.
Unlock
()
currentSink
=
sink
}
func
L
()
*
zap
.
Logger
{
mu
.
RLock
()
defer
mu
.
RUnlock
()
if
global
!=
nil
{
return
global
}
return
zap
.
NewNop
()
}
func
S
()
*
zap
.
SugaredLogger
{
mu
.
RLock
()
defer
mu
.
RUnlock
()
if
sugar
!=
nil
{
return
sugar
}
return
zap
.
NewNop
()
.
Sugar
()
}
func
With
(
fields
...
zap
.
Field
)
*
zap
.
Logger
{
return
L
()
.
With
(
fields
...
)
}
func
Sync
()
{
mu
.
RLock
()
l
:=
global
mu
.
RUnlock
()
if
l
!=
nil
{
_
=
l
.
Sync
()
}
}
func
bridgeStdLogLocked
()
{
if
stdLogUndo
!=
nil
{
stdLogUndo
()
stdLogUndo
=
nil
}
log
.
SetFlags
(
0
)
log
.
SetPrefix
(
""
)
undo
,
err
:=
zap
.
RedirectStdLogAt
(
global
.
Named
(
"stdlog"
),
zap
.
InfoLevel
)
if
err
!=
nil
{
_
,
_
=
fmt
.
Fprintf
(
os
.
Stderr
,
"logger redirect stdlog failed: %v
\n
"
,
err
)
return
}
stdLogUndo
=
undo
}
func
bridgeSlogLocked
()
{
slog
.
SetDefault
(
slog
.
New
(
newSlogZapHandler
(
global
.
Named
(
"slog"
))))
}
func
buildLogger
(
options
InitOptions
)
(
*
zap
.
Logger
,
zap
.
AtomicLevel
,
error
)
{
level
,
_
:=
parseLevel
(
options
.
Level
)
atomic
:=
zap
.
NewAtomicLevelAt
(
level
)
encoderCfg
:=
zapcore
.
EncoderConfig
{
TimeKey
:
"time"
,
LevelKey
:
"level"
,
NameKey
:
"logger"
,
CallerKey
:
"caller"
,
MessageKey
:
"msg"
,
StacktraceKey
:
"stacktrace"
,
LineEnding
:
zapcore
.
DefaultLineEnding
,
EncodeLevel
:
zapcore
.
CapitalLevelEncoder
,
EncodeTime
:
zapcore
.
ISO8601TimeEncoder
,
EncodeDuration
:
zapcore
.
MillisDurationEncoder
,
EncodeCaller
:
zapcore
.
ShortCallerEncoder
,
}
var
enc
zapcore
.
Encoder
if
options
.
Format
==
"console"
{
enc
=
zapcore
.
NewConsoleEncoder
(
encoderCfg
)
}
else
{
enc
=
zapcore
.
NewJSONEncoder
(
encoderCfg
)
}
sinkCore
:=
newSinkCore
()
cores
:=
make
([]
zapcore
.
Core
,
0
,
3
)
if
options
.
Output
.
ToStdout
{
infoPriority
:=
zap
.
LevelEnablerFunc
(
func
(
lvl
zapcore
.
Level
)
bool
{
return
lvl
>=
atomic
.
Level
()
&&
lvl
<
zapcore
.
WarnLevel
})
errPriority
:=
zap
.
LevelEnablerFunc
(
func
(
lvl
zapcore
.
Level
)
bool
{
return
lvl
>=
atomic
.
Level
()
&&
lvl
>=
zapcore
.
WarnLevel
})
cores
=
append
(
cores
,
zapcore
.
NewCore
(
enc
,
zapcore
.
Lock
(
os
.
Stdout
),
infoPriority
))
cores
=
append
(
cores
,
zapcore
.
NewCore
(
enc
,
zapcore
.
Lock
(
os
.
Stderr
),
errPriority
))
}
if
options
.
Output
.
ToFile
{
fileCore
,
filePath
,
fileErr
:=
buildFileCore
(
enc
,
atomic
,
options
)
if
fileErr
!=
nil
{
_
,
_
=
fmt
.
Fprintf
(
os
.
Stderr
,
"time=%s level=WARN msg=
\"
日志文件输出初始化失败,降级为仅标准输出
\"
path=%s err=%v
\n
"
,
time
.
Now
()
.
Format
(
time
.
RFC3339Nano
),
filePath
,
fileErr
,
)
}
else
{
cores
=
append
(
cores
,
fileCore
)
}
}
if
len
(
cores
)
==
0
{
cores
=
append
(
cores
,
zapcore
.
NewCore
(
enc
,
zapcore
.
Lock
(
os
.
Stdout
),
atomic
))
}
core
:=
zapcore
.
NewTee
(
cores
...
)
if
options
.
Sampling
.
Enabled
{
core
=
zapcore
.
NewSamplerWithOptions
(
core
,
samplingTick
(),
options
.
Sampling
.
Initial
,
options
.
Sampling
.
Thereafter
)
}
core
=
sinkCore
.
Wrap
(
core
)
stacktraceLevel
,
_
:=
parseStacktraceLevel
(
options
.
StacktraceLevel
)
zapOpts
:=
make
([]
zap
.
Option
,
0
,
5
)
if
options
.
Caller
{
zapOpts
=
append
(
zapOpts
,
zap
.
AddCaller
())
}
if
stacktraceLevel
<=
zapcore
.
FatalLevel
{
zapOpts
=
append
(
zapOpts
,
zap
.
AddStacktrace
(
stacktraceLevel
))
}
zapOpts
=
append
(
zapOpts
,
zap
.
AddCallerSkip
(
1
))
logger
:=
zap
.
New
(
core
,
zapOpts
...
)
.
With
(
zap
.
String
(
"service"
,
options
.
ServiceName
),
zap
.
String
(
"env"
,
options
.
Environment
),
)
return
logger
,
atomic
,
nil
}
func
buildFileCore
(
enc
zapcore
.
Encoder
,
atomic
zap
.
AtomicLevel
,
options
InitOptions
)
(
zapcore
.
Core
,
string
,
error
)
{
filePath
:=
options
.
Output
.
FilePath
if
strings
.
TrimSpace
(
filePath
)
==
""
{
filePath
=
resolveLogFilePath
(
""
)
}
dir
:=
filepath
.
Dir
(
filePath
)
if
err
:=
os
.
MkdirAll
(
dir
,
0
o755
);
err
!=
nil
{
return
nil
,
filePath
,
err
}
lj
:=
&
lumberjack
.
Logger
{
Filename
:
filePath
,
MaxSize
:
options
.
Rotation
.
MaxSizeMB
,
MaxBackups
:
options
.
Rotation
.
MaxBackups
,
MaxAge
:
options
.
Rotation
.
MaxAgeDays
,
Compress
:
options
.
Rotation
.
Compress
,
LocalTime
:
options
.
Rotation
.
LocalTime
,
}
return
zapcore
.
NewCore
(
enc
,
zapcore
.
AddSync
(
lj
),
atomic
),
filePath
,
nil
}
type
sinkCore
struct
{
core
zapcore
.
Core
fields
[]
zapcore
.
Field
}
func
newSinkCore
()
*
sinkCore
{
return
&
sinkCore
{}
}
func
(
s
*
sinkCore
)
Wrap
(
core
zapcore
.
Core
)
zapcore
.
Core
{
cp
:=
*
s
cp
.
core
=
core
return
&
cp
}
func
(
s
*
sinkCore
)
Enabled
(
level
zapcore
.
Level
)
bool
{
return
s
.
core
.
Enabled
(
level
)
}
func
(
s
*
sinkCore
)
With
(
fields
[]
zapcore
.
Field
)
zapcore
.
Core
{
nextFields
:=
append
([]
zapcore
.
Field
{},
s
.
fields
...
)
nextFields
=
append
(
nextFields
,
fields
...
)
return
&
sinkCore
{
core
:
s
.
core
.
With
(
fields
),
fields
:
nextFields
,
}
}
func
(
s
*
sinkCore
)
Check
(
entry
zapcore
.
Entry
,
ce
*
zapcore
.
CheckedEntry
)
*
zapcore
.
CheckedEntry
{
if
s
.
Enabled
(
entry
.
Level
)
{
return
ce
.
AddCore
(
entry
,
s
)
}
return
ce
}
func
(
s
*
sinkCore
)
Write
(
entry
zapcore
.
Entry
,
fields
[]
zapcore
.
Field
)
error
{
if
err
:=
s
.
core
.
Write
(
entry
,
fields
);
err
!=
nil
{
return
err
}
mu
.
RLock
()
sink
:=
currentSink
mu
.
RUnlock
()
if
sink
==
nil
{
return
nil
}
enc
:=
zapcore
.
NewMapObjectEncoder
()
for
_
,
f
:=
range
s
.
fields
{
f
.
AddTo
(
enc
)
}
for
_
,
f
:=
range
fields
{
f
.
AddTo
(
enc
)
}
event
:=
&
LogEvent
{
Time
:
entry
.
Time
,
Level
:
strings
.
ToLower
(
entry
.
Level
.
String
()),
Component
:
entry
.
LoggerName
,
Message
:
entry
.
Message
,
LoggerName
:
entry
.
LoggerName
,
Fields
:
enc
.
Fields
,
}
sink
.
WriteLogEvent
(
event
)
return
nil
}
func
(
s
*
sinkCore
)
Sync
()
error
{
return
s
.
core
.
Sync
()
}
type
contextKey
string
const
loggerContextKey
contextKey
=
"ctx_logger"
func
IntoContext
(
ctx
context
.
Context
,
l
*
zap
.
Logger
)
context
.
Context
{
if
ctx
==
nil
{
ctx
=
context
.
Background
()
}
if
l
==
nil
{
l
=
L
()
}
return
context
.
WithValue
(
ctx
,
loggerContextKey
,
l
)
}
func
FromContext
(
ctx
context
.
Context
)
*
zap
.
Logger
{
if
ctx
==
nil
{
return
L
()
}
if
l
,
ok
:=
ctx
.
Value
(
loggerContextKey
)
.
(
*
zap
.
Logger
);
ok
&&
l
!=
nil
{
return
l
}
return
L
()
}
backend/internal/pkg/logger/logger_test.go
0 → 100644
View file @
fff1d548
package
logger
import
(
"io"
"os"
"path/filepath"
"strings"
"testing"
)
func
TestInit_DualOutput
(
t
*
testing
.
T
)
{
tmpDir
:=
t
.
TempDir
()
logPath
:=
filepath
.
Join
(
tmpDir
,
"logs"
,
"sub2api.log"
)
origStdout
:=
os
.
Stdout
origStderr
:=
os
.
Stderr
stdoutR
,
stdoutW
,
err
:=
os
.
Pipe
()
if
err
!=
nil
{
t
.
Fatalf
(
"create stdout pipe: %v"
,
err
)
}
stderrR
,
stderrW
,
err
:=
os
.
Pipe
()
if
err
!=
nil
{
t
.
Fatalf
(
"create stderr pipe: %v"
,
err
)
}
os
.
Stdout
=
stdoutW
os
.
Stderr
=
stderrW
t
.
Cleanup
(
func
()
{
os
.
Stdout
=
origStdout
os
.
Stderr
=
origStderr
_
=
stdoutR
.
Close
()
_
=
stderrR
.
Close
()
_
=
stdoutW
.
Close
()
_
=
stderrW
.
Close
()
})
err
=
Init
(
InitOptions
{
Level
:
"debug"
,
Format
:
"json"
,
ServiceName
:
"sub2api"
,
Environment
:
"test"
,
Output
:
OutputOptions
{
ToStdout
:
true
,
ToFile
:
true
,
FilePath
:
logPath
,
},
Rotation
:
RotationOptions
{
MaxSizeMB
:
10
,
MaxBackups
:
2
,
MaxAgeDays
:
1
,
},
Sampling
:
SamplingOptions
{
Enabled
:
false
},
})
if
err
!=
nil
{
t
.
Fatalf
(
"Init() error: %v"
,
err
)
}
L
()
.
Info
(
"dual-output-info"
)
L
()
.
Warn
(
"dual-output-warn"
)
Sync
()
_
=
stdoutW
.
Close
()
_
=
stderrW
.
Close
()
stdoutBytes
,
_
:=
io
.
ReadAll
(
stdoutR
)
stderrBytes
,
_
:=
io
.
ReadAll
(
stderrR
)
stdoutText
:=
string
(
stdoutBytes
)
stderrText
:=
string
(
stderrBytes
)
if
!
strings
.
Contains
(
stdoutText
,
"dual-output-info"
)
{
t
.
Fatalf
(
"stdout missing info log: %s"
,
stdoutText
)
}
if
!
strings
.
Contains
(
stderrText
,
"dual-output-warn"
)
{
t
.
Fatalf
(
"stderr missing warn log: %s"
,
stderrText
)
}
fileBytes
,
err
:=
os
.
ReadFile
(
logPath
)
if
err
!=
nil
{
t
.
Fatalf
(
"read log file: %v"
,
err
)
}
fileText
:=
string
(
fileBytes
)
if
!
strings
.
Contains
(
fileText
,
"dual-output-info"
)
||
!
strings
.
Contains
(
fileText
,
"dual-output-warn"
)
{
t
.
Fatalf
(
"file missing logs: %s"
,
fileText
)
}
}
func
TestInit_FileOutputFailureDowngrade
(
t
*
testing
.
T
)
{
origStdout
:=
os
.
Stdout
origStderr
:=
os
.
Stderr
_
,
stdoutW
,
err
:=
os
.
Pipe
()
if
err
!=
nil
{
t
.
Fatalf
(
"create stdout pipe: %v"
,
err
)
}
stderrR
,
stderrW
,
err
:=
os
.
Pipe
()
if
err
!=
nil
{
t
.
Fatalf
(
"create stderr pipe: %v"
,
err
)
}
os
.
Stdout
=
stdoutW
os
.
Stderr
=
stderrW
t
.
Cleanup
(
func
()
{
os
.
Stdout
=
origStdout
os
.
Stderr
=
origStderr
_
=
stdoutW
.
Close
()
_
=
stderrR
.
Close
()
_
=
stderrW
.
Close
()
})
err
=
Init
(
InitOptions
{
Level
:
"info"
,
Format
:
"json"
,
Output
:
OutputOptions
{
ToStdout
:
true
,
ToFile
:
true
,
FilePath
:
filepath
.
Join
(
os
.
DevNull
,
"logs"
,
"sub2api.log"
),
},
Rotation
:
RotationOptions
{
MaxSizeMB
:
10
,
MaxBackups
:
1
,
MaxAgeDays
:
1
,
},
})
if
err
!=
nil
{
t
.
Fatalf
(
"Init() should downgrade instead of failing, got: %v"
,
err
)
}
_
=
stderrW
.
Close
()
stderrBytes
,
_
:=
io
.
ReadAll
(
stderrR
)
if
!
strings
.
Contains
(
string
(
stderrBytes
),
"日志文件输出初始化失败"
)
{
t
.
Fatalf
(
"stderr should contain fallback warning, got: %s"
,
string
(
stderrBytes
))
}
}
backend/internal/pkg/logger/options.go
0 → 100644
View file @
fff1d548
package
logger
import
(
"os"
"path/filepath"
"strings"
"time"
)
const
(
// DefaultContainerLogPath 为容器内默认日志文件路径。
DefaultContainerLogPath
=
"/app/data/logs/sub2api.log"
defaultLogFilename
=
"sub2api.log"
)
type
InitOptions
struct
{
Level
string
Format
string
ServiceName
string
Environment
string
Caller
bool
StacktraceLevel
string
Output
OutputOptions
Rotation
RotationOptions
Sampling
SamplingOptions
}
type
OutputOptions
struct
{
ToStdout
bool
ToFile
bool
FilePath
string
}
type
RotationOptions
struct
{
MaxSizeMB
int
MaxBackups
int
MaxAgeDays
int
Compress
bool
LocalTime
bool
}
type
SamplingOptions
struct
{
Enabled
bool
Initial
int
Thereafter
int
}
func
(
o
InitOptions
)
normalized
()
InitOptions
{
out
:=
o
out
.
Level
=
strings
.
ToLower
(
strings
.
TrimSpace
(
out
.
Level
))
if
out
.
Level
==
""
{
out
.
Level
=
"info"
}
out
.
Format
=
strings
.
ToLower
(
strings
.
TrimSpace
(
out
.
Format
))
if
out
.
Format
==
""
{
out
.
Format
=
"json"
}
out
.
ServiceName
=
strings
.
TrimSpace
(
out
.
ServiceName
)
if
out
.
ServiceName
==
""
{
out
.
ServiceName
=
"sub2api"
}
out
.
Environment
=
strings
.
TrimSpace
(
out
.
Environment
)
if
out
.
Environment
==
""
{
out
.
Environment
=
"production"
}
out
.
StacktraceLevel
=
strings
.
ToLower
(
strings
.
TrimSpace
(
out
.
StacktraceLevel
))
if
out
.
StacktraceLevel
==
""
{
out
.
StacktraceLevel
=
"error"
}
if
!
out
.
Output
.
ToStdout
&&
!
out
.
Output
.
ToFile
{
out
.
Output
.
ToStdout
=
true
}
out
.
Output
.
FilePath
=
resolveLogFilePath
(
out
.
Output
.
FilePath
)
if
out
.
Rotation
.
MaxSizeMB
<=
0
{
out
.
Rotation
.
MaxSizeMB
=
100
}
if
out
.
Rotation
.
MaxBackups
<
0
{
out
.
Rotation
.
MaxBackups
=
10
}
if
out
.
Rotation
.
MaxAgeDays
<
0
{
out
.
Rotation
.
MaxAgeDays
=
7
}
if
out
.
Sampling
.
Enabled
{
if
out
.
Sampling
.
Initial
<=
0
{
out
.
Sampling
.
Initial
=
100
}
if
out
.
Sampling
.
Thereafter
<=
0
{
out
.
Sampling
.
Thereafter
=
100
}
}
return
out
}
func
resolveLogFilePath
(
explicit
string
)
string
{
explicit
=
strings
.
TrimSpace
(
explicit
)
if
explicit
!=
""
{
return
explicit
}
dataDir
:=
strings
.
TrimSpace
(
os
.
Getenv
(
"DATA_DIR"
))
if
dataDir
!=
""
{
return
filepath
.
Join
(
dataDir
,
"logs"
,
defaultLogFilename
)
}
return
DefaultContainerLogPath
}
func
bootstrapOptions
()
InitOptions
{
return
InitOptions
{
Level
:
"info"
,
Format
:
"console"
,
ServiceName
:
"sub2api"
,
Environment
:
"bootstrap"
,
Output
:
OutputOptions
{
ToStdout
:
true
,
ToFile
:
false
,
},
Rotation
:
RotationOptions
{
MaxSizeMB
:
100
,
MaxBackups
:
10
,
MaxAgeDays
:
7
,
Compress
:
true
,
LocalTime
:
true
,
},
Sampling
:
SamplingOptions
{
Enabled
:
false
,
Initial
:
100
,
Thereafter
:
100
,
},
}
}
func
parseLevel
(
level
string
)
(
Level
,
bool
)
{
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
level
))
{
case
"debug"
:
return
LevelDebug
,
true
case
"info"
:
return
LevelInfo
,
true
case
"warn"
:
return
LevelWarn
,
true
case
"error"
:
return
LevelError
,
true
default
:
return
LevelInfo
,
false
}
}
func
parseStacktraceLevel
(
level
string
)
(
Level
,
bool
)
{
switch
strings
.
ToLower
(
strings
.
TrimSpace
(
level
))
{
case
"none"
:
return
LevelFatal
+
1
,
true
case
"error"
:
return
LevelError
,
true
case
"fatal"
:
return
LevelFatal
,
true
default
:
return
LevelError
,
false
}
}
func
samplingTick
()
time
.
Duration
{
return
time
.
Second
}
Prev
1
2
3
Next
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
.
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment