fix logger and redeem admin review findings
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

This commit is contained in:
2026-04-20 11:24:36 +08:00
parent 3a0ca7f57f
commit ed642e8769
22 changed files with 563 additions and 63 deletions

View File

@@ -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")

View File

@@ -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)

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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",

View File

@@ -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")
}

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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)
}

View 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()` 卡死和兑换码批量操作缺陷建议优先处理。

View 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`
验收标准: 四项全部通过

View File

@@ -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
}

View File

@@ -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]

View File

@@ -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;')

View File

@@ -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',

View File

@@ -3326,6 +3326,8 @@ export default {
codesExported: '兑换码导出成功',
codeDeleted: '兑换码删除成功',
codesDeleted: '成功删除 {count} 个未使用的兑换码',
codesDeletedPartial: '已删除 {deleted} 个未使用兑换码,还有 {failed} 个删除失败',
codesDeleteSkipped: '未能删除任何兑换码,共 {failed} 个删除失败',
noUnusedCodes: '没有未使用的兑换码可删除',
userPrefix: '用户 #{id}',
failedToExport: '导出兑换码失败',

View File

@@ -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) {

View File

@@ -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">

View File

@@ -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,

View File

@@ -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: [] })
})
})

View 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 }
}
}
}