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)
|
||||
}
|
||||
|
||||
119
docs/reviews/2026-04-20-code-review-report.md
Normal file
119
docs/reviews/2026-04-20-code-review-report.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# 2026-04-20 Code Review Report
|
||||
|
||||
## Scope
|
||||
|
||||
本次 review 以代码实现和真实验证结果为主,不以停用业务说明或文档状态作为主判断依据。重点覆盖:
|
||||
|
||||
- 后端 Go 代码实现与测试执行结果
|
||||
- 前端 Vue/TypeScript 实现与质量门禁结果
|
||||
- 高风险管理操作的真实代码路径
|
||||
|
||||
## Validation Summary
|
||||
|
||||
已执行的验证:
|
||||
|
||||
- `backend`: `go test ./...`
|
||||
- `backend`: 定向复现 `internal/pkg/logger` 相关测试
|
||||
- `frontend`: `npm run test:run`
|
||||
- `frontend`: `npm run typecheck`
|
||||
- `frontend`: `npm run lint:check`
|
||||
|
||||
验证结论:
|
||||
|
||||
- 后端除 `internal/pkg/logger` 外,其余项目包测试通过
|
||||
- 前端 `vitest` 59 个测试文件、354 个测试全部通过
|
||||
- 前端 `typecheck` 失败
|
||||
- 前端 `lint:check` 失败
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. High: Windows 下 logger `Sync()` 在标准输出被 pipe 接管时会卡死
|
||||
|
||||
证据:
|
||||
|
||||
- [logger.go#L199](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger.go#L199)
|
||||
- [logger.go#L274](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger.go#L274)
|
||||
- [logger.go#L275](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger.go#L275)
|
||||
- [logger.go#L404](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger.go#L404)
|
||||
- [logger_test.go#L12](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger_test.go#L12)
|
||||
- [logger_test.go#L60](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger_test.go#L60)
|
||||
- [logger_test.go#L132](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger_test.go#L132)
|
||||
- [logger_test.go#L169](/D:/project/sub2api-merge/backend/internal/pkg/logger/logger_test.go#L169)
|
||||
|
||||
实际验证中,`go test ./...` 在 `internal/pkg/logger` 失败,`TestInit_DualOutput` 超时 10 分钟,阻塞栈停在 `FlushFileBuffers -> zap Sync -> logger.Sync()`。这说明当前实现对 Windows pipe 场景不安全,不只是测试代码问题。
|
||||
|
||||
### 2. High: “删除全部未使用兑换码”只删除前 1000 条
|
||||
|
||||
证据:
|
||||
|
||||
- [RedeemView.vue#L737](/D:/project/sub2api-merge/frontend/src/views/admin/RedeemView.vue#L737)
|
||||
- [RedeemView.vue#L740](/D:/project/sub2api-merge/frontend/src/views/admin/RedeemView.vue#L740)
|
||||
- [RedeemView.vue#L749](/D:/project/sub2api-merge/frontend/src/views/admin/RedeemView.vue#L749)
|
||||
- [RedeemView.vue#L750](/D:/project/sub2api-merge/frontend/src/views/admin/RedeemView.vue#L750)
|
||||
|
||||
当前实现固定只拉取第 1 页、每页 1000 条未使用兑换码,然后直接批量删除并提示成功。当未使用兑换码总数大于 1000 时,剩余数据会被静默遗漏。
|
||||
|
||||
### 3. Medium-High: 兑换码生成非事务化,叠加幂等重试后存在“部分成功再放大”风险
|
||||
|
||||
证据:
|
||||
|
||||
- [admin_service.go#L2056](/D:/project/sub2api-merge/backend/internal/service/admin_service.go#L2056)
|
||||
- [admin_service.go#L2072](/D:/project/sub2api-merge/backend/internal/service/admin_service.go#L2072)
|
||||
- [admin_service.go#L2092](/D:/project/sub2api-merge/backend/internal/service/admin_service.go#L2092)
|
||||
- [redeem_handler.go#L103](/D:/project/sub2api-merge/backend/internal/handler/admin/redeem_handler.go#L103)
|
||||
- [idempotency.go#L406](/D:/project/sub2api-merge/backend/internal/service/idempotency.go#L406)
|
||||
- [idempotency.go#L410](/D:/project/sub2api-merge/backend/internal/service/idempotency.go#L410)
|
||||
|
||||
服务层逐条 `Create`,中间任一点失败都会提前返回;幂等层则把执行错误记为 `failed_retryable`。如果请求已经成功插入部分兑换码,再按同一个 key 重试,存在超量生成的可能。
|
||||
|
||||
### 4. Medium: 兑换码批量删除会吞掉单条失败,并整体返回成功
|
||||
|
||||
证据:
|
||||
|
||||
- [admin_service.go#L2104](/D:/project/sub2api-merge/backend/internal/service/admin_service.go#L2104)
|
||||
- [admin_service.go#L2107](/D:/project/sub2api-merge/backend/internal/service/admin_service.go#L2107)
|
||||
- [admin_service.go#L2111](/D:/project/sub2api-merge/backend/internal/service/admin_service.go#L2111)
|
||||
- [redeem_handler.go#L243](/D:/project/sub2api-merge/backend/internal/handler/admin/redeem_handler.go#L243)
|
||||
- [redeem_handler.go#L252](/D:/project/sub2api-merge/backend/internal/handler/admin/redeem_handler.go#L252)
|
||||
- [redeem_handler.go#L253](/D:/project/sub2api-merge/backend/internal/handler/admin/redeem_handler.go#L253)
|
||||
- [admin_service_delete_test.go#L534](/D:/project/sub2api-merge/backend/internal/service/admin_service_delete_test.go#L534)
|
||||
- [admin_service_delete_test.go#L543](/D:/project/sub2api-merge/backend/internal/service/admin_service_delete_test.go#L543)
|
||||
|
||||
当前实现只累计成功删除数,失败项不会中断,也不会返回失败明细。更关键的是,测试已经把这种语义固化成预期行为,因此这是实现策略问题,不是单纯的漏测。
|
||||
|
||||
### 5. Medium: 前端测试全绿,但静态质量门禁仍然损坏
|
||||
|
||||
证据:
|
||||
|
||||
- [SoraAdminView.vue#L243](/D:/project/sub2api-merge/frontend/src/views/admin/SoraAdminView.vue#L243)
|
||||
- [SoraAdminView.vue#L259](/D:/project/sub2api-merge/frontend/src/views/admin/SoraAdminView.vue#L259)
|
||||
- [Icon.vue#L16](/D:/project/sub2api-merge/frontend/src/components/icons/Icon.vue#L16)
|
||||
- [Icon.vue#L17](/D:/project/sub2api-merge/frontend/src/components/icons/Icon.vue#L17)
|
||||
- [AppSidebar.spec.ts#L24](/D:/project/sub2api-merge/frontend/src/components/layout/__tests__/AppSidebar.spec.ts#L24)
|
||||
- [package.json#L8](/D:/project/sub2api-merge/frontend/package.json#L8)
|
||||
- [package.json#L11](/D:/project/sub2api-merge/frontend/package.json#L11)
|
||||
- [package.json#L12](/D:/project/sub2api-merge/frontend/package.json#L12)
|
||||
- [package.json#L14](/D:/project/sub2api-merge/frontend/package.json#L14)
|
||||
|
||||
`vitest` 全绿并不代表前端处于可交付状态。当前 `typecheck` 和 `lint:check` 都失败,说明测试集没有覆盖编译期约束与静态门禁。
|
||||
|
||||
### 6. Low: 仍有部分管理接口是占位实现
|
||||
|
||||
证据:
|
||||
|
||||
- [admin_service.go#L765](/D:/project/sub2api-merge/backend/internal/service/admin_service.go#L765)
|
||||
- [redeem_handler.go#L284](/D:/project/sub2api-merge/backend/internal/handler/admin/redeem_handler.go#L284)
|
||||
- [proxy_handler.go#L262](/D:/project/sub2api-merge/backend/internal/handler/admin/proxy_handler.go#L262)
|
||||
- [dashboard_handler.go#L180](/D:/project/sub2api-merge/backend/internal/handler/admin/dashboard_handler.go#L180)
|
||||
|
||||
这批接口目前主要是 mock/占位返回。它们不是这次最关键的问题,但如果继续对外暴露,建议明确收敛策略。
|
||||
|
||||
## Overall Assessment
|
||||
|
||||
本次代码库的主要问题集中在三类:
|
||||
|
||||
- Windows 环境下的日志稳定性
|
||||
- 管理端兑换码批量操作的语义正确性
|
||||
- 前端“测试通过但质量门禁失败”的交付断层
|
||||
|
||||
其中,日志 `Sync()` 卡死和兑换码批量操作缺陷建议优先处理。
|
||||
106
docs/reviews/2026-04-20-code-review-task-list.md
Normal file
106
docs/reviews/2026-04-20-code-review-task-list.md
Normal file
@@ -0,0 +1,106 @@
|
||||
# 2026-04-20 Code Review Task List
|
||||
|
||||
## P0
|
||||
|
||||
- 修复 Windows 下 `logger.Sync()` 对 `stdout/stderr` pipe 的阻塞问题
|
||||
- 为 `internal/pkg/logger` 补充 Windows 兼容回归测试,确保 `go test ./...` 可稳定通过
|
||||
- 重新执行后端全量测试,确认 `internal/pkg/logger` 修复后无新增失败
|
||||
|
||||
## P1
|
||||
|
||||
- 修复管理端“删除全部未使用兑换码”只处理前 1000 条的问题
|
||||
- 明确该功能应采用分页循环删除、后端专用批量接口,还是服务端单点清理命令
|
||||
- 为该流程补充前端测试,覆盖“超过 1000 条未使用兑换码”场景
|
||||
|
||||
- 修复 `GenerateRedeemCodes` 的非事务性问题
|
||||
- 明确幂等语义: 部分成功后应回滚、应返回已生成结果,还是应禁止重放生成
|
||||
- 为兑换码生成补充失败注入测试,覆盖“部分创建成功 + 重试”路径
|
||||
|
||||
- 调整 `BatchDeleteRedeemCodes` 返回语义
|
||||
- 明确接口是否应该失败即整体失败,或返回 `deleted` / `failed_ids` / `errors`
|
||||
- 同步更新 handler、前端提示文案和现有测试断言
|
||||
|
||||
## P2
|
||||
|
||||
- 修复前端 `typecheck` 失败
|
||||
- 处理 [SoraAdminView.vue](/D:/project/sub2api-merge/frontend/src/views/admin/SoraAdminView.vue) 中无效的 `Icon` 名称,或将停用页面排除出编译链
|
||||
|
||||
- 修复前端 `lint:check` 失败
|
||||
- 处理 [AppSidebar.spec.ts](/D:/project/sub2api-merge/frontend/src/components/layout/__tests__/AppSidebar.spec.ts) 中的 `no-regex-spaces` 问题
|
||||
- 清理当前 lint warning,避免门禁长期失真
|
||||
|
||||
## P3
|
||||
|
||||
- 盘点仍保留 mock 返回的管理接口
|
||||
- 对每个接口确定策略: 实现、废弃、隐藏入口,或从路由移除
|
||||
- 为仍需保留的接口补充契约测试,避免占位实现长期留在主分支
|
||||
|
||||
## Recommended Execution Order
|
||||
|
||||
1. 先修复 `internal/pkg/logger`,恢复后端全量测试可信度
|
||||
2. 再修复兑换码两个批量操作问题,避免管理端误操作
|
||||
3. 然后恢复前端 `typecheck` 和 `lint`
|
||||
4. 最后清理低优先级占位接口
|
||||
|
||||
## Assignable Work Packages
|
||||
|
||||
### Backend
|
||||
|
||||
- `BE-LOGGER-001`
|
||||
目标: 修复 Windows 下 `logger.Sync()` 对 pipe `stdout/stderr` 的阻塞
|
||||
交付物: `logger.go` 修复、回归测试、`go test ./internal/pkg/logger` 通过
|
||||
验收标准: `TestInit_DualOutput` 和 `TestInit_CallerShouldPointToCallsite` 不再超时
|
||||
|
||||
- `BE-REDEEM-001`
|
||||
目标: 将兑换码生成从“逐条创建 + 部分成功可重试”改为可证明安全的语义
|
||||
交付物: 服务层实现调整、失败注入测试、幂等路径验证
|
||||
验收标准: 不存在“部分成功后同 key 重试超量生成”的路径
|
||||
|
||||
- `BE-REDEEM-002`
|
||||
目标: 重构批量删除返回语义,明确成功、失败和跳过项
|
||||
交付物: 服务层返回结构、handler 响应调整、测试同步
|
||||
验收标准: 部分失败时 API 不再伪装为“完全成功”
|
||||
|
||||
### Frontend
|
||||
|
||||
- `FE-REDEEM-001`
|
||||
目标: 修复“删除全部未使用兑换码”只删前 1000 条的问题
|
||||
交付物: `RedeemView.vue` 实现调整、视图测试
|
||||
验收标准: 超过 1000 条未使用兑换码时仍能完整删除或明确失败
|
||||
|
||||
- `FE-REDEEM-002`
|
||||
目标: 对齐新的批量删除 API 语义和用户提示
|
||||
交付物: API 类型、页面提示、异常和部分成功文案
|
||||
验收标准: 用户能看到准确的删除结果
|
||||
|
||||
- `FE-QUALITY-001`
|
||||
目标: 恢复 `typecheck`
|
||||
交付物: 修复无效 `Icon` 名称或将停用页移出编译链
|
||||
验收标准: `npm run typecheck` 通过
|
||||
|
||||
- `FE-QUALITY-002`
|
||||
目标: 恢复 `lint:check`
|
||||
交付物: 修复 `AppSidebar.spec.ts` 正则问题并清理 warning
|
||||
验收标准: `npm run lint:check` 通过
|
||||
|
||||
### Testing
|
||||
|
||||
- `TEST-BE-001`
|
||||
目标: 为 logger Windows 行为建立稳定回归测试
|
||||
交付物: 针对 `Sync()`、stdout/stderr pipe、caller 输出的测试
|
||||
验收标准: 可在 Windows 环境稳定执行,不依赖 10 分钟超时
|
||||
|
||||
- `TEST-BE-002`
|
||||
目标: 为兑换码批量生成/删除建立失败路径覆盖
|
||||
交付物: 服务层和 handler 层测试
|
||||
验收标准: 覆盖部分成功、失败回滚、结果透出
|
||||
|
||||
- `TEST-FE-001`
|
||||
目标: 为 `RedeemView` 补足批量删除流程测试
|
||||
交付物: 视图级测试,覆盖分页循环和部分失败提示
|
||||
验收标准: 批量删除流程不再依赖人工回归
|
||||
|
||||
- `TEST-FULL-001`
|
||||
目标: 恢复全链路质量门禁
|
||||
交付物: 后端 `go test ./...`、前端 `npm run test:run`、`npm run typecheck`、`npm run lint:check`
|
||||
验收标准: 四项全部通过
|
||||
@@ -11,6 +11,16 @@ import type {
|
||||
PaginatedResponse
|
||||
} from '@/types'
|
||||
|
||||
export interface RedeemBatchDeleteSkipped {
|
||||
id: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface RedeemBatchDeleteResult {
|
||||
deleted_ids: number[]
|
||||
skipped: RedeemBatchDeleteSkipped[]
|
||||
}
|
||||
|
||||
/**
|
||||
* List all redeem codes with pagination
|
||||
* @param page - Page number (default: 1)
|
||||
@@ -102,14 +112,8 @@ export async function deleteCode(id: number): Promise<{ message: string }> {
|
||||
* @param ids - Array of redeem code IDs
|
||||
* @returns Success confirmation
|
||||
*/
|
||||
export async function batchDelete(ids: number[]): Promise<{
|
||||
deleted: number
|
||||
message: string
|
||||
}> {
|
||||
const { data } = await apiClient.post<{
|
||||
deleted: number
|
||||
message: string
|
||||
}>('/admin/redeem-codes/batch-delete', { ids })
|
||||
export async function batchDelete(ids: number[]): Promise<RedeemBatchDeleteResult> {
|
||||
const { data } = await apiClient.post<RedeemBatchDeleteResult>('/admin/redeem-codes/batch-delete', { ids })
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('UserEditModal', () => {
|
||||
// username 输入框是第二个 text input(第一个是密码)
|
||||
const textInputs = wrapper.findAll('input[type="text"]')
|
||||
// 找到 username 输入框(它有一个 label)
|
||||
const usernameInput = textInputs.find(input => {
|
||||
const _usernameInput = textInputs.find(input => {
|
||||
const parent = input.element.closest('div')
|
||||
return parent?.querySelector('label')?.textContent?.includes('admin.users.username')
|
||||
}) || textInputs[1]
|
||||
|
||||
@@ -21,7 +21,7 @@ describe('AppSidebar custom SVG styles', () => {
|
||||
|
||||
describe('AppSidebar header styles', () => {
|
||||
it('does not clip the version badge dropdown', () => {
|
||||
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n \}/)
|
||||
const sidebarHeaderBlockMatch = styleSource.match(/\.sidebar-header\s*\{[\s\S]*?\n {2}\}/)
|
||||
|
||||
expect(sidebarHeaderBlockMatch).not.toBeNull()
|
||||
expect(sidebarHeaderBlockMatch?.[0]).not.toContain('@apply overflow-hidden;')
|
||||
|
||||
@@ -3195,6 +3195,8 @@ export default {
|
||||
codesExported: 'Codes exported successfully',
|
||||
codeDeleted: 'Redeem code deleted successfully',
|
||||
codesDeleted: 'Successfully deleted {count} unused code(s)',
|
||||
codesDeletedPartial: 'Deleted {deleted} unused code(s); {failed} could not be deleted',
|
||||
codesDeleteSkipped: 'No codes were deleted; {failed} code(s) could not be deleted',
|
||||
noUnusedCodes: 'No unused codes to delete',
|
||||
failedToLoad: 'Failed to load redeem codes',
|
||||
failedToGenerate: 'Failed to generate codes',
|
||||
|
||||
@@ -3326,6 +3326,8 @@ export default {
|
||||
codesExported: '兑换码导出成功',
|
||||
codeDeleted: '兑换码删除成功',
|
||||
codesDeleted: '成功删除 {count} 个未使用的兑换码',
|
||||
codesDeletedPartial: '已删除 {deleted} 个未使用兑换码,还有 {failed} 个删除失败',
|
||||
codesDeleteSkipped: '未能删除任何兑换码,共 {failed} 个删除失败',
|
||||
noUnusedCodes: '没有未使用的兑换码可删除',
|
||||
userPrefix: '用户 #{id}',
|
||||
failedToExport: '导出兑换码失败',
|
||||
|
||||
@@ -408,6 +408,7 @@ import { adminAPI } from '@/api/admin'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { RedeemCode, RedeemCodeType, Group, GroupPlatform, SubscriptionType } from '@/types'
|
||||
import type { Column } from '@/components/common/types'
|
||||
import { deleteUnusedRedeemCodes } from './redeemDeleteUnused'
|
||||
import AppLayout from '@/components/layout/AppLayout.vue'
|
||||
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
|
||||
import DataTable from '@/components/common/DataTable.vue'
|
||||
@@ -736,18 +737,20 @@ const confirmDelete = async () => {
|
||||
|
||||
const confirmDeleteUnused = async () => {
|
||||
try {
|
||||
// Get all unused codes and delete them
|
||||
const unusedCodesResponse = await adminAPI.redeem.list(1, 1000, { status: 'unused' })
|
||||
const unusedCodeIds = unusedCodesResponse.items.map((code) => code.id)
|
||||
const result = await deleteUnusedRedeemCodes(adminAPI.redeem)
|
||||
const deleted = result.deletedIds.length
|
||||
const skipped = result.skipped.length
|
||||
|
||||
if (unusedCodeIds.length === 0) {
|
||||
if (deleted === 0 && skipped === 0) {
|
||||
appStore.showInfo(t('admin.redeem.noUnusedCodes'))
|
||||
showDeleteUnusedDialog.value = false
|
||||
return
|
||||
} else if (skipped === 0) {
|
||||
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: deleted }))
|
||||
} else if (deleted > 0) {
|
||||
appStore.showWarning(t('admin.redeem.codesDeletedPartial', { deleted, failed: skipped }))
|
||||
} else {
|
||||
appStore.showInfo(t('admin.redeem.codesDeleteSkipped', { failed: skipped }))
|
||||
}
|
||||
|
||||
const result = await adminAPI.redeem.batchDelete(unusedCodeIds)
|
||||
appStore.showSuccess(t('admin.redeem.codesDeleted', { count: result.deleted }))
|
||||
showDeleteUnusedDialog.value = false
|
||||
loadCodes()
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -240,7 +240,7 @@ onMounted(loadAll)
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-blue-100 p-2 dark:bg-blue-900/30">
|
||||
<Icon name="video" size="md" class="text-blue-600 dark:text-blue-400" />
|
||||
<Icon name="chartBar" size="md" class="text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
@@ -256,7 +256,7 @@ onMounted(loadAll)
|
||||
<div class="card p-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="rounded-lg bg-green-100 p-2 dark:bg-green-900/30">
|
||||
<Icon name="activity" size="md" class="text-green-600 dark:text-green-400" />
|
||||
<Icon name="refresh" size="md" class="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">
|
||||
|
||||
@@ -53,13 +53,6 @@ const IconStub = defineComponent({
|
||||
template: '<span class="icon" />'
|
||||
})
|
||||
|
||||
const BaseButtonStub = defineComponent({
|
||||
name: 'BaseButton',
|
||||
props: ['variant', 'size'],
|
||||
emits: ['click'],
|
||||
template: '<button @click="$emit(\'click\')"><slot /></button>'
|
||||
})
|
||||
|
||||
function createMockSystemStats(overrides: Partial<SoraSystemStats> = {}): SoraSystemStats {
|
||||
return {
|
||||
total_users: 10,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { PaginatedResponse, RedeemCode } from '@/types'
|
||||
import { deleteUnusedRedeemCodes } from '../redeemDeleteUnused'
|
||||
|
||||
function createCode(id: number): RedeemCode {
|
||||
return {
|
||||
id,
|
||||
code: `CODE-${id}`,
|
||||
type: 'balance',
|
||||
value: 10,
|
||||
status: 'unused',
|
||||
used_by: undefined,
|
||||
used_at: '',
|
||||
created_at: '2026-01-01T00:00:00Z'
|
||||
} as RedeemCode
|
||||
}
|
||||
|
||||
function paginated(items: RedeemCode[]): PaginatedResponse<RedeemCode> {
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
page_size: 1000,
|
||||
pages: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('deleteUnusedRedeemCodes', () => {
|
||||
it('deletes all unused redeem codes across multiple pages', async () => {
|
||||
const client = {
|
||||
list: vi.fn()
|
||||
.mockResolvedValueOnce(paginated([createCode(1), createCode(2)]))
|
||||
.mockResolvedValueOnce(paginated([createCode(3)]))
|
||||
.mockResolvedValueOnce(paginated([])),
|
||||
batchDelete: vi.fn()
|
||||
.mockResolvedValueOnce({ deleted_ids: [1, 2], skipped: [] })
|
||||
.mockResolvedValueOnce({ deleted_ids: [3], skipped: [] })
|
||||
}
|
||||
|
||||
const result = await deleteUnusedRedeemCodes(client)
|
||||
|
||||
expect(client.list).toHaveBeenCalledTimes(3)
|
||||
expect(client.batchDelete).toHaveBeenCalledTimes(2)
|
||||
expect(client.batchDelete).toHaveBeenNthCalledWith(1, [1, 2])
|
||||
expect(client.batchDelete).toHaveBeenNthCalledWith(2, [3])
|
||||
expect(result).toEqual({ deletedIds: [1, 2, 3], skipped: [] })
|
||||
})
|
||||
|
||||
it('stops and returns partial results when a batch delete has skipped items', async () => {
|
||||
const client = {
|
||||
list: vi.fn().mockResolvedValueOnce(paginated([createCode(1), createCode(2)])),
|
||||
batchDelete: vi.fn().mockResolvedValueOnce({
|
||||
deleted_ids: [1],
|
||||
skipped: [{ id: 2, reason: 'db error' }]
|
||||
})
|
||||
}
|
||||
|
||||
const result = await deleteUnusedRedeemCodes(client)
|
||||
|
||||
expect(client.list).toHaveBeenCalledTimes(1)
|
||||
expect(client.batchDelete).toHaveBeenCalledWith([1, 2])
|
||||
expect(result).toEqual({
|
||||
deletedIds: [1],
|
||||
skipped: [{ id: 2, reason: 'db error' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('returns immediately when there are no unused redeem codes', async () => {
|
||||
const client = {
|
||||
list: vi.fn().mockResolvedValueOnce(paginated([])),
|
||||
batchDelete: vi.fn()
|
||||
}
|
||||
|
||||
const result = await deleteUnusedRedeemCodes(client)
|
||||
|
||||
expect(client.batchDelete).not.toHaveBeenCalled()
|
||||
expect(result).toEqual({ deletedIds: [], skipped: [] })
|
||||
})
|
||||
})
|
||||
46
frontend/src/views/admin/redeemDeleteUnused.ts
Normal file
46
frontend/src/views/admin/redeemDeleteUnused.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { PaginatedResponse, RedeemCode } from '@/types'
|
||||
import type { RedeemBatchDeleteResult } from '@/api/admin/redeem'
|
||||
|
||||
type RedeemListClient = (
|
||||
page?: number,
|
||||
pageSize?: number,
|
||||
filters?: {
|
||||
status?: 'active' | 'used' | 'expired' | 'unused'
|
||||
}
|
||||
) => Promise<PaginatedResponse<RedeemCode>>
|
||||
|
||||
type RedeemBatchDeleteClient = (ids: number[]) => Promise<RedeemBatchDeleteResult>
|
||||
|
||||
export interface DeleteUnusedRedeemCodesClient {
|
||||
list: RedeemListClient
|
||||
batchDelete: RedeemBatchDeleteClient
|
||||
}
|
||||
|
||||
export interface DeleteUnusedRedeemCodesResult {
|
||||
deletedIds: number[]
|
||||
skipped: RedeemBatchDeleteResult['skipped']
|
||||
}
|
||||
|
||||
export async function deleteUnusedRedeemCodes(
|
||||
client: DeleteUnusedRedeemCodesClient,
|
||||
pageSize: number = 1000
|
||||
): Promise<DeleteUnusedRedeemCodesResult> {
|
||||
const deletedIds: number[] = []
|
||||
const skipped: RedeemBatchDeleteResult['skipped'] = []
|
||||
|
||||
for (;;) {
|
||||
const response = await client.list(1, pageSize, { status: 'unused' })
|
||||
const ids = response.items.map((code) => code.id)
|
||||
if (ids.length === 0) {
|
||||
return { deletedIds, skipped }
|
||||
}
|
||||
|
||||
const result = await client.batchDelete(ids)
|
||||
deletedIds.push(...result.deleted_ids)
|
||||
skipped.push(...result.skipped)
|
||||
|
||||
if (result.skipped.length > 0) {
|
||||
return { deletedIds, skipped }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user