fix logger and redeem admin review findings
This commit is contained in:
@@ -56,7 +56,7 @@ func init() {
|
||||
// In non-release mode, Debug level logs are enabled.
|
||||
func main() {
|
||||
logger.InitBootstrap()
|
||||
defer logger.Sync()
|
||||
defer logger.Shutdown()
|
||||
|
||||
// Parse command line flags
|
||||
setupMode := flag.Bool("setup", false, "Run setup wizard in CLI mode")
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -226,7 +227,13 @@ func TestProxyHandlerEndpoints(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRedeemHandlerEndpoints(t *testing.T) {
|
||||
router, _ := setupAdminRouter()
|
||||
router, adminSvc := setupAdminRouter()
|
||||
adminSvc.batchDeleteRedeemResult = &service.RedeemBatchDeleteResult{
|
||||
DeletedIDs: []int64{1},
|
||||
Skipped: []service.RedeemBatchDeleteSkipped{
|
||||
{ID: 2, Reason: "db error"},
|
||||
},
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/redeem-codes", nil)
|
||||
@@ -255,6 +262,20 @@ func TestRedeemHandlerEndpoints(t *testing.T) {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
DeletedIDs []int64 `json:"deleted_ids"`
|
||||
Skipped []struct {
|
||||
ID int64 `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"skipped"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []int64{1}, resp.Data.DeletedIDs)
|
||||
require.Len(t, resp.Data.Skipped, 1)
|
||||
require.Equal(t, int64(2), resp.Data.Skipped[0].ID)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/redeem-codes/5/expire", nil)
|
||||
|
||||
@@ -58,7 +58,8 @@ type stubAdminService struct {
|
||||
sortOrder string
|
||||
calls int
|
||||
}
|
||||
mu sync.Mutex
|
||||
batchDeleteRedeemResult *service.RedeemBatchDeleteResult
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newStubAdminService() *stubAdminService {
|
||||
@@ -449,8 +450,11 @@ func (s *stubAdminService) DeleteRedeemCode(ctx context.Context, id int64) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error) {
|
||||
return int64(len(ids)), nil
|
||||
func (s *stubAdminService) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (*service.RedeemBatchDeleteResult, error) {
|
||||
if s.batchDeleteRedeemResult != nil {
|
||||
return s.batchDeleteRedeemResult, nil
|
||||
}
|
||||
return &service.RedeemBatchDeleteResult{DeletedIDs: ids, Skipped: []service.RedeemBatchDeleteSkipped{}}, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ExpireRedeemCode(ctx context.Context, id int64) (*service.RedeemCode, error) {
|
||||
|
||||
@@ -249,16 +249,13 @@ func (h *RedeemHandler) BatchDelete(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := h.adminService.BatchDeleteRedeemCodes(c.Request.Context(), req.IDs)
|
||||
result, err := h.adminService.BatchDeleteRedeemCodes(c.Request.Context(), req.IDs)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"deleted": deleted,
|
||||
"message": "Redeem codes deleted successfully",
|
||||
})
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
// Expire handles expiring a redeem code
|
||||
|
||||
@@ -48,6 +48,7 @@ var (
|
||||
atomicLevel zap.AtomicLevel
|
||||
initOptions InitOptions
|
||||
currentSink atomic.Value // sinkState
|
||||
currentClose func()
|
||||
stdLogUndo func()
|
||||
bootstrapOnce sync.Once
|
||||
)
|
||||
@@ -72,16 +73,18 @@ func Init(options InitOptions) error {
|
||||
|
||||
func initLocked(options InitOptions) error {
|
||||
normalized := options.normalized()
|
||||
zl, al, err := buildLogger(normalized)
|
||||
zl, al, closeFn, err := buildLogger(normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
prev := global.Load()
|
||||
prevClose := currentClose
|
||||
global.Store(zl)
|
||||
sugar.Store(zl.Sugar())
|
||||
atomicLevel = al
|
||||
initOptions = normalized
|
||||
currentClose = closeFn
|
||||
|
||||
bridgeSlogLocked()
|
||||
bridgeStdLogLocked()
|
||||
@@ -89,6 +92,9 @@ func initLocked(options InitOptions) error {
|
||||
if prev != nil {
|
||||
_ = prev.Sync()
|
||||
}
|
||||
if prevClose != nil {
|
||||
prevClose()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -205,6 +211,27 @@ func Sync() {
|
||||
}
|
||||
}
|
||||
|
||||
func Shutdown() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if stdLogUndo != nil {
|
||||
stdLogUndo()
|
||||
stdLogUndo = nil
|
||||
}
|
||||
|
||||
if l := global.Load(); l != nil {
|
||||
_ = l.Sync()
|
||||
}
|
||||
if currentClose != nil {
|
||||
currentClose()
|
||||
currentClose = nil
|
||||
}
|
||||
|
||||
global.Store(nil)
|
||||
sugar.Store(nil)
|
||||
}
|
||||
|
||||
func bridgeStdLogLocked() {
|
||||
if stdLogUndo != nil {
|
||||
stdLogUndo()
|
||||
@@ -238,7 +265,7 @@ func bridgeSlogLocked() {
|
||||
slog.SetDefault(slog.New(newSlogZapHandler(base.Named("slog"))))
|
||||
}
|
||||
|
||||
func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, error) {
|
||||
func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, func(), error) {
|
||||
level, _ := parseLevel(options.Level)
|
||||
atomic := zap.NewAtomicLevelAt(level)
|
||||
|
||||
@@ -265,6 +292,7 @@ func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, error) {
|
||||
|
||||
sinkCore := newSinkCore()
|
||||
cores := make([]zapcore.Core, 0, 3)
|
||||
var closers []io.Closer
|
||||
|
||||
if options.Output.ToStdout {
|
||||
infoPriority := zap.LevelEnablerFunc(func(lvl zapcore.Level) bool {
|
||||
@@ -273,12 +301,12 @@ func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, error) {
|
||||
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))
|
||||
cores = append(cores, zapcore.NewCore(enc, stdStreamWriteSyncer(os.Stdout), infoPriority))
|
||||
cores = append(cores, zapcore.NewCore(enc, stdStreamWriteSyncer(os.Stderr), errPriority))
|
||||
}
|
||||
|
||||
if options.Output.ToFile {
|
||||
fileCore, filePath, fileErr := buildFileCore(enc, atomic, options)
|
||||
fileCore, filePath, fileCloser, 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),
|
||||
@@ -287,11 +315,12 @@ func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, error) {
|
||||
)
|
||||
} else {
|
||||
cores = append(cores, fileCore)
|
||||
closers = append(closers, fileCloser)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cores) == 0 {
|
||||
cores = append(cores, zapcore.NewCore(enc, zapcore.Lock(os.Stdout), atomic))
|
||||
cores = append(cores, zapcore.NewCore(enc, stdStreamWriteSyncer(os.Stdout), atomic))
|
||||
}
|
||||
|
||||
core := zapcore.NewTee(cores...)
|
||||
@@ -313,10 +342,14 @@ func buildLogger(options InitOptions) (*zap.Logger, zap.AtomicLevel, error) {
|
||||
zap.String("service", options.ServiceName),
|
||||
zap.String("env", options.Environment),
|
||||
)
|
||||
return logger, atomic, nil
|
||||
return logger, atomic, func() {
|
||||
for _, closer := range closers {
|
||||
_ = closer.Close()
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildFileCore(enc zapcore.Encoder, atomic zap.AtomicLevel, options InitOptions) (zapcore.Core, string, error) {
|
||||
func buildFileCore(enc zapcore.Encoder, atomic zap.AtomicLevel, options InitOptions) (zapcore.Core, string, io.Closer, error) {
|
||||
filePath := options.Output.FilePath
|
||||
if strings.TrimSpace(filePath) == "" {
|
||||
filePath = resolveLogFilePath("")
|
||||
@@ -324,7 +357,7 @@ func buildFileCore(enc zapcore.Encoder, atomic zap.AtomicLevel, options InitOpti
|
||||
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, filePath, err
|
||||
return nil, filePath, nil, err
|
||||
}
|
||||
lj := &lumberjack.Logger{
|
||||
Filename: filePath,
|
||||
@@ -334,7 +367,25 @@ func buildFileCore(enc zapcore.Encoder, atomic zap.AtomicLevel, options InitOpti
|
||||
Compress: options.Rotation.Compress,
|
||||
LocalTime: options.Rotation.LocalTime,
|
||||
}
|
||||
return zapcore.NewCore(enc, zapcore.AddSync(lj), atomic), filePath, nil
|
||||
return zapcore.NewCore(enc, zapcore.AddSync(lj), atomic), filePath, lj, nil
|
||||
}
|
||||
|
||||
type stdStreamSyncer struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func stdStreamWriteSyncer(file *os.File) zapcore.WriteSyncer {
|
||||
return zapcore.Lock(&stdStreamSyncer{file: file})
|
||||
}
|
||||
|
||||
func (s *stdStreamSyncer) Write(p []byte) (int, error) {
|
||||
return s.file.Write(p)
|
||||
}
|
||||
|
||||
func (s *stdStreamSyncer) Sync() error {
|
||||
// Standard streams do not need fsync semantics, and on Windows a pipe-backed
|
||||
// stdout/stderr can block indefinitely in FlushFileBuffers.
|
||||
return nil
|
||||
}
|
||||
|
||||
type sinkCore struct {
|
||||
|
||||
@@ -33,6 +33,7 @@ func TestInit_DualOutput(t *testing.T) {
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
t.Cleanup(Shutdown)
|
||||
|
||||
err = Init(InitOptions{
|
||||
Level: "debug",
|
||||
@@ -103,6 +104,7 @@ func TestInit_FileOutputFailureDowngrade(t *testing.T) {
|
||||
_ = stderrR.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
t.Cleanup(Shutdown)
|
||||
|
||||
err = Init(InitOptions{
|
||||
Level: "info",
|
||||
@@ -149,6 +151,7 @@ func TestInit_CallerShouldPointToCallsite(t *testing.T) {
|
||||
_ = stdoutW.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
t.Cleanup(Shutdown)
|
||||
|
||||
if err := Init(InitOptions{
|
||||
Level: "info",
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestBuildFileCore_InvalidPathFallback(t *testing.T) {
|
||||
EncodeLevel: zapcore.CapitalLevelEncoder,
|
||||
}
|
||||
encoder := zapcore.NewJSONEncoder(encoderCfg)
|
||||
_, _, err := buildFileCore(encoder, zap.NewAtomicLevel(), opts)
|
||||
_, _, _, err := buildFileCore(encoder, zap.NewAtomicLevel(), opts)
|
||||
if err == nil {
|
||||
t.Fatalf("buildFileCore() expected error for invalid path")
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ func TestStdLogBridgeRoutesLevels(t *testing.T) {
|
||||
_ = stderrR.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
t.Cleanup(Shutdown)
|
||||
|
||||
if err := Init(InitOptions{
|
||||
Level: "debug",
|
||||
@@ -121,6 +122,7 @@ func TestLegacyPrintfRoutesLevels(t *testing.T) {
|
||||
_ = stderrR.Close()
|
||||
_ = stderrW.Close()
|
||||
})
|
||||
t.Cleanup(Shutdown)
|
||||
|
||||
if err := Init(InitOptions{
|
||||
Level: "debug",
|
||||
|
||||
@@ -97,7 +97,7 @@ type AdminService interface {
|
||||
GetRedeemCode(ctx context.Context, id int64) (*RedeemCode, error)
|
||||
GenerateRedeemCodes(ctx context.Context, input *GenerateRedeemCodesInput) ([]RedeemCode, error)
|
||||
DeleteRedeemCode(ctx context.Context, id int64) error
|
||||
BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error)
|
||||
BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (*RedeemBatchDeleteResult, error)
|
||||
ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error)
|
||||
ResetAccountQuota(ctx context.Context, id int64) error
|
||||
}
|
||||
@@ -321,6 +321,16 @@ type ProxyBatchDeleteSkipped struct {
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type RedeemBatchDeleteResult struct {
|
||||
DeletedIDs []int64 `json:"deleted_ids"`
|
||||
Skipped []RedeemBatchDeleteSkipped `json:"skipped"`
|
||||
}
|
||||
|
||||
type RedeemBatchDeleteSkipped struct {
|
||||
ID int64 `json:"id"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
// ProxyTestResult represents the result of testing a proxy
|
||||
type ProxyTestResult struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -2089,11 +2099,12 @@ func (s *adminServiceImpl) GenerateRedeemCodes(ctx context.Context, input *Gener
|
||||
code.ValidityDays = 30 // 默认30天
|
||||
}
|
||||
}
|
||||
if err := s.redeemCodeRepo.Create(ctx, &code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
|
||||
if err := s.redeemCodeRepo.CreateBatch(ctx, codes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
@@ -2101,14 +2112,22 @@ func (s *adminServiceImpl) DeleteRedeemCode(ctx context.Context, id int64) error
|
||||
return s.redeemCodeRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (int64, error) {
|
||||
var deleted int64
|
||||
func (s *adminServiceImpl) BatchDeleteRedeemCodes(ctx context.Context, ids []int64) (*RedeemBatchDeleteResult, error) {
|
||||
result := &RedeemBatchDeleteResult{
|
||||
DeletedIDs: make([]int64, 0, len(ids)),
|
||||
Skipped: make([]RedeemBatchDeleteSkipped, 0),
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := s.redeemCodeRepo.Delete(ctx, id); err == nil {
|
||||
deleted++
|
||||
result.DeletedIDs = append(result.DeletedIDs, id)
|
||||
} else {
|
||||
result.Skipped = append(result.Skipped, RedeemBatchDeleteSkipped{
|
||||
ID: id,
|
||||
Reason: err.Error(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return deleted, nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) ExpireRedeemCode(ctx context.Context, id int64) (*RedeemCode, error) {
|
||||
|
||||
@@ -248,8 +248,10 @@ func (s *proxyRepoStub) ListAccountSummariesByProxyID(ctx context.Context, proxy
|
||||
}
|
||||
|
||||
type redeemRepoStub struct {
|
||||
deleteErrByID map[int64]error
|
||||
deletedIDs []int64
|
||||
createBatchErr error
|
||||
createdBatches [][]RedeemCode
|
||||
deleteErrByID map[int64]error
|
||||
deletedIDs []int64
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
|
||||
@@ -257,7 +259,18 @@ func (s *redeemRepoStub) Create(ctx context.Context, code *RedeemCode) error {
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) CreateBatch(ctx context.Context, codes []RedeemCode) error {
|
||||
panic("unexpected CreateBatch call")
|
||||
if s.createBatchErr != nil {
|
||||
return s.createBatchErr
|
||||
}
|
||||
cloned := append([]RedeemCode(nil), codes...)
|
||||
s.createdBatches = append(s.createdBatches, cloned)
|
||||
for i := range codes {
|
||||
codes[i].ID = int64(i + 1)
|
||||
if codes[i].CreatedAt.IsZero() {
|
||||
codes[i].CreatedAt = time.Unix(int64(i+1), 0).UTC()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *redeemRepoStub) GetByID(ctx context.Context, id int64) (*RedeemCode, error) {
|
||||
@@ -521,13 +534,45 @@ func TestAdminService_DeleteRedeemCode_Error(t *testing.T) {
|
||||
require.Equal(t, []int64{1}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAdminService_GenerateRedeemCodes_UsesBatchCreate(t *testing.T) {
|
||||
repo := &redeemRepoStub{}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
codes, err := svc.GenerateRedeemCodes(context.Background(), &GenerateRedeemCodesInput{
|
||||
Count: 2,
|
||||
Type: RedeemTypeBalance,
|
||||
Value: 10,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.createdBatches, 1)
|
||||
require.Len(t, repo.createdBatches[0], 2)
|
||||
require.Len(t, codes, 2)
|
||||
require.NotZero(t, codes[0].ID)
|
||||
require.Equal(t, StatusUnused, codes[0].Status)
|
||||
}
|
||||
|
||||
func TestAdminService_GenerateRedeemCodes_BatchCreateError(t *testing.T) {
|
||||
repo := &redeemRepoStub{createBatchErr: errors.New("batch create failed")}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
codes, err := svc.GenerateRedeemCodes(context.Background(), &GenerateRedeemCodesInput{
|
||||
Count: 2,
|
||||
Type: RedeemTypeBalance,
|
||||
Value: 10,
|
||||
})
|
||||
require.Nil(t, codes)
|
||||
require.ErrorContains(t, err, "batch create failed")
|
||||
require.Len(t, repo.createdBatches, 0)
|
||||
}
|
||||
|
||||
func TestAdminService_BatchDeleteRedeemCodes_Success(t *testing.T) {
|
||||
repo := &redeemRepoStub{}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
deleted, err := svc.BatchDeleteRedeemCodes(context.Background(), []int64{1, 2, 3})
|
||||
result, err := svc.BatchDeleteRedeemCodes(context.Background(), []int64{1, 2, 3})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3), deleted)
|
||||
require.Equal(t, []int64{1, 2, 3}, result.DeletedIDs)
|
||||
require.Empty(t, result.Skipped)
|
||||
require.Equal(t, []int64{1, 2, 3}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
@@ -539,8 +584,11 @@ func TestAdminService_BatchDeleteRedeemCodes_PartialFailures(t *testing.T) {
|
||||
}
|
||||
svc := &adminServiceImpl{redeemCodeRepo: repo}
|
||||
|
||||
deleted, err := svc.BatchDeleteRedeemCodes(context.Background(), []int64{1, 2, 3})
|
||||
result, err := svc.BatchDeleteRedeemCodes(context.Background(), []int64{1, 2, 3})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), deleted)
|
||||
require.Equal(t, []int64{1, 3}, result.DeletedIDs)
|
||||
require.Len(t, result.Skipped, 1)
|
||||
require.Equal(t, int64(2), result.Skipped[0].ID)
|
||||
require.Equal(t, "db error", result.Skipped[0].Reason)
|
||||
require.Equal(t, []int64{1, 2, 3}, repo.deletedIDs)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user