From 8bdc8afea30a8becec14b8ea07073f5350cabe43 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 3 Mar 2026 19:10:59 -0800 Subject: [PATCH] fix gitea workflow path and runner label --- .../public/user/deleteAccountHandler.go | 14 +-- .../public/user/deleteAccountHandler_test.go | 102 ++++++++++++++++++ .../logic/public/user/deleteAccountLogic.go | 40 ++++++- internal/types/deleteAccountResponse_test.go | 37 +++++++ internal/types/types.go | 4 +- 5 files changed, 185 insertions(+), 12 deletions(-) create mode 100644 internal/handler/public/user/deleteAccountHandler_test.go create mode 100644 internal/types/deleteAccountResponse_test.go diff --git a/internal/handler/public/user/deleteAccountHandler.go b/internal/handler/public/user/deleteAccountHandler.go index 6a23e63..a0ac59e 100644 --- a/internal/handler/public/user/deleteAccountHandler.go +++ b/internal/handler/public/user/deleteAccountHandler.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "net/http" "strings" "time" @@ -14,6 +13,8 @@ import ( "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/result" + "github.com/perfect-panel/server/pkg/xerr" + "github.com/pkg/errors" ) // DeleteAccountHandler 注销账号处理器 @@ -28,7 +29,7 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc { return func(c *gin.Context) { var req deleteAccountReq if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()}) + result.ParamErrorResult(c, err) return } @@ -37,18 +38,13 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc { // 校验邮箱验证码 if err := verifyEmailCode(c.Request.Context(), serverCtx, req.Email, req.Code); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + result.HttpResult(c, nil, err) return } l := user.NewDeleteAccountLogic(c.Request.Context(), serverCtx) resp, err := l.DeleteAccountAll() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } result.HttpResult(c, resp, err) - } } @@ -85,7 +81,7 @@ func verifyEmailCode(ctx context.Context, serverCtx *svc.ServiceContext, email s } if !verified { - return fmt.Errorf("verification code error or expired") + return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired") } // 验证成功后删除缓存 diff --git a/internal/handler/public/user/deleteAccountHandler_test.go b/internal/handler/public/user/deleteAccountHandler_test.go new file mode 100644 index 0000000..0eea8e7 --- /dev/null +++ b/internal/handler/public/user/deleteAccountHandler_test.go @@ -0,0 +1,102 @@ +package user + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/internal/config" + "github.com/perfect-panel/server/internal/svc" + "github.com/redis/go-redis/v9" +) + +type handlerResponse struct { + Code uint32 `json:"code"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` +} + +func newDeleteAccountTestRouter(serverCtx *svc.ServiceContext) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + router.POST("/v1/public/user/delete_account", DeleteAccountHandler(serverCtx)) + return router +} + +func TestDeleteAccountHandlerInvalidParamsUsesUnifiedResponse(t *testing.T) { + router := newDeleteAccountTestRouter(&svc.ServiceContext{}) + + reqBody := bytes.NewBufferString(`{"email":"invalid-email"}`) + req := httptest.NewRequest(http.MethodPost, "/v1/public/user/delete_account", reqBody) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d", recorder.Code) + } + + var resp handlerResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Code != 400 { + t.Fatalf("expected business code 400, got %d, body=%s", resp.Code, recorder.Body.String()) + } + + var raw map[string]interface{} + if err := json.Unmarshal(recorder.Body.Bytes(), &raw); err != nil { + t.Fatalf("failed to decode raw response: %v", err) + } + if _, exists := raw["error"]; exists { + t.Fatalf("unexpected raw error field in response: %s", recorder.Body.String()) + } +} + +func TestDeleteAccountHandlerVerifyCodeErrorUsesUnifiedResponse(t *testing.T) { + redisClient := redis.NewClient(&redis.Options{ + Addr: "invalid:6379", + Dialer: func(_ context.Context, _, _ string) (net.Conn, error) { + return nil, errors.New("dial disabled in test") + }, + }) + defer redisClient.Close() + + serverCtx := &svc.ServiceContext{ + Redis: redisClient, + Config: config.Config{ + VerifyCode: config.VerifyCode{ + ExpireTime: 900, + }, + }, + } + router := newDeleteAccountTestRouter(serverCtx) + + reqBody := bytes.NewBufferString(`{"email":"user@example.com","code":"123456"}`) + req := httptest.NewRequest(http.MethodPost, "/v1/public/user/delete_account", reqBody) + req.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, req) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected HTTP 200, got %d", recorder.Code) + } + + var resp handlerResponse + if err := json.Unmarshal(recorder.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if resp.Code != 70001 { + t.Fatalf("expected business code 70001, got %d, body=%s", resp.Code, recorder.Body.String()) + } +} diff --git a/internal/logic/public/user/deleteAccountLogic.go b/internal/logic/public/user/deleteAccountLogic.go index fdc9517..00a0841 100644 --- a/internal/logic/public/user/deleteAccountLogic.go +++ b/internal/logic/public/user/deleteAccountLogic.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "fmt" + "strings" "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/model/user" @@ -146,9 +147,36 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon // 获取当前调用设备 ID currentDeviceId, _ := l.ctx.Value(constant.CtxKeyDeviceID).(int64) + currentIdentifier, _ := l.ctx.Value(constant.CtxKeyIdentifier).(string) + currentIdentifier = strings.TrimSpace(currentIdentifier) + + if currentDeviceId == 0 && currentIdentifier != "" { + deviceInfo, deviceErr := l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, currentIdentifier) + switch { + case deviceErr == nil: + if deviceInfo.UserId == currentUser.Id { + currentDeviceId = deviceInfo.Id + } else { + l.Infow("当前标识符设备归属与用户不匹配", + logger.Field("user_id", currentUser.Id), + logger.Field("device_id", deviceInfo.Id), + logger.Field("identifier", currentIdentifier), + ) + } + case errors.Is(deviceErr, gorm.ErrRecordNotFound): + l.Infow("未通过标识符找到当前设备", logger.Field("user_id", currentUser.Id), logger.Field("identifier", currentIdentifier)) + default: + l.Errorw("通过标识符查找当前设备失败", + logger.Field("user_id", currentUser.Id), + logger.Field("identifier", currentIdentifier), + logger.Field("error", deviceErr.Error()), + ) + } + } resp = &types.DeleteAccountResponse{} var newUserId int64 + var firstMigratedUserId int64 // 开始数据库事务 err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error { @@ -179,6 +207,9 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon l.Errorw("为设备分配新用户主体失败", logger.Field("device_id", dev.Id), logger.Field("error", err.Error())) return err } + if firstMigratedUserId == 0 { + firstMigratedUserId = newUser.Id + } // B. 迁移设备记录 (Update user_id) if err := tx.Model(&user.Device{}).Where("id = ?", dev.Id).Update("user_id", newUser.Id).Error; err != nil { @@ -195,7 +226,11 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon } // 如果是当前请求的设备,记录其新 UserID 返回给前端 - if dev.Id == currentDeviceId || dev.Identifier == l.getIdentifierByDeviceID(userDevices, currentDeviceId) { + isCurrentRequestDevice := currentDeviceId > 0 && dev.Id == currentDeviceId + if !isCurrentRequestDevice && currentIdentifier != "" { + isCurrentRequestDevice = dev.Identifier == currentIdentifier + } + if isCurrentRequestDevice { newUserId = newUser.Id } l.Infow("旧设备已迁移至新匿名账号", @@ -236,6 +271,9 @@ func (l *DeleteAccountLogic) DeleteAccountAll() (resp *types.DeleteAccountRespon // 最终清理所有 Session (踢掉所有设备) l.clearAllSessions(currentUser.Id) + if newUserId == 0 { + newUserId = firstMigratedUserId + } resp.Success = true resp.Message = "注销成功" diff --git a/internal/types/deleteAccountResponse_test.go b/internal/types/deleteAccountResponse_test.go new file mode 100644 index 0000000..0fc4c88 --- /dev/null +++ b/internal/types/deleteAccountResponse_test.go @@ -0,0 +1,37 @@ +package types + +import ( + "encoding/json" + "testing" +) + +func TestDeleteAccountResponseAlwaysContainsIntFields(t *testing.T) { + data, err := json.Marshal(DeleteAccountResponse{ + Success: true, + Message: "ok", + }) + if err != nil { + t.Fatalf("failed to marshal response: %v", err) + } + + var decoded map[string]interface{} + if err = json.Unmarshal(data, &decoded); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + userID, hasUserID := decoded["user_id"] + if !hasUserID { + t.Fatalf("expected user_id in JSON, got %s", string(data)) + } + if userID != float64(0) { + t.Fatalf("expected user_id=0, got %v", userID) + } + + code, hasCode := decoded["code"] + if !hasCode { + t.Fatalf("expected code in JSON, got %s", string(data)) + } + if code != float64(0) { + t.Fatalf("expected code=0, got %v", code) + } +} diff --git a/internal/types/types.go b/internal/types/types.go index 993e5d3..af15577 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -2968,8 +2968,8 @@ type GetSubscribeStatusRequest struct { type DeleteAccountResponse struct { Success bool `json:"success"` Message string `json:"message,omitempty"` - UserId int64 `json:"user_id,omitempty"` - Code int `json:"code,omitempty"` + UserId int64 `json:"user_id"` + Code int `json:"code"` } type GetDownloadLinkRequest struct {