diff --git a/doc/api-version-switch-zh.md b/doc/api-version-switch-zh.md new file mode 100644 index 0000000..2f181c2 --- /dev/null +++ b/doc/api-version-switch-zh.md @@ -0,0 +1,74 @@ +# API 版本分流接入指南(`api-header`) + +## 目标 +- 通过请求头 `api-header` 动态选择不同 Handler(如 `001Handler` / `002Handler`)。 +- 让旧 App 无需升级仍可走旧逻辑,新 App 通过版本头走新逻辑。 + +## 当前规则 +- 仅识别请求头:`api-header` +- 严格版本格式:`x.y.z` 或 `vx.y.z` +- 仅当 `api-header > 1.0.0` 时走新逻辑(V2) +- 其余情况(缺失/非法/`<=1.0.0`)走旧逻辑(V1) + +相关代码: +- 版本解析:`pkg/apiversion/version.go` +- 版本注入中间件:`internal/middleware/apiVersionMiddleware.go` +- 通用分流器:`internal/middleware/apiVersionSwitchHandler.go` + +## 新接口接入步骤(推荐) + +### 1) 实现两个 Handler +```go +func FooV1Handler(svcCtx *svc.ServiceContext) gin.HandlerFunc { + return func(c *gin.Context) { + // 旧逻辑(001) + } +} + +func FooV2Handler(svcCtx *svc.ServiceContext) gin.HandlerFunc { + return func(c *gin.Context) { + // 新逻辑(002) + } +} +``` + +### 2) 在路由中挂分流器 +```go +group.POST("/foo", middleware.ApiVersionSwitchHandler( + foo.FooV1Handler(serverCtx), + foo.FooV2Handler(serverCtx), +)) +``` + +完成后,无需在业务代码里手写 `api-header` 判断。 + +## 客户端调用示例 + +### 旧逻辑(V1) +```bash +curl -X POST 'https://example.com/v1/common/foo' \ + -H 'Content-Type: application/json' \ + -d '{"x":1}' +``` + +### 新逻辑(V2) +```bash +curl -X POST 'https://example.com/v1/common/foo' \ + -H 'Content-Type: application/json' \ + -H 'api-header: 1.0.1' \ + -d '{"x":1}' +``` + +## 测试建议(最小集) +- 无 `api-header`:命中 V1 +- `api-header: 1.0.0`:命中 V1 +- `api-header: 1.0.1`:命中 V2 +- `api-header: abc`:命中 V1 + +可参考测试: +- `internal/middleware/apiVersionSwitchHandler_test.go` + +## 适用建议 +- 差异较小:优先在 V2 中复用现有逻辑,减少重复代码。 +- 差异较大:拆分 V1/V2 各自逻辑,避免分支污染。 +- 上线顺序:先发后端分流能力,再逐步让客户端加 `api-header`。 diff --git a/internal/handler/auth/checkCodeLegacyHandler.go b/internal/handler/auth/checkCodeLegacyHandler.go index 87db15c..88679ea 100644 --- a/internal/handler/auth/checkCodeLegacyHandler.go +++ b/internal/handler/auth/checkCodeLegacyHandler.go @@ -11,6 +11,18 @@ import ( // Check legacy verification code func CheckCodeLegacyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return CheckCodeLegacyV1Handler(svcCtx) +} + +func CheckCodeLegacyV1Handler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return checkCodeLegacyHandler(svcCtx, false) +} + +func CheckCodeLegacyV2Handler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return checkCodeLegacyHandler(svcCtx, true) +} + +func checkCodeLegacyHandler(svcCtx *svc.ServiceContext, consume bool) func(c *gin.Context) { return func(c *gin.Context) { var req types.LegacyCheckVerificationCodeRequest _ = c.ShouldBind(&req) @@ -27,14 +39,9 @@ func CheckCodeLegacyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { } l := commonLogic.NewCheckVerificationCodeLogic(c.Request.Context(), svcCtx) - useLatest := false - if value, ok := c.Request.Context().Value(constant.CtxKeyAPIVersionUseLatest).(bool); ok { - useLatest = value - } - resp, err := l.CheckVerificationCodeWithBehavior(normalizedReq, commonLogic.VerifyCodeCheckBehavior{ Source: "legacy", - Consume: useLatest, + Consume: consume, LegacyType3Mapped: legacyType3Mapped, AllowSceneFallback: constant.ParseVerifyType(normalizedReq.Type) != constant.DeleteAccount, }) diff --git a/internal/handler/auth/checkCodeLegacyHandler_test.go b/internal/handler/auth/checkCodeLegacyHandler_test.go index c6c0b0c..cde2a35 100644 --- a/internal/handler/auth/checkCodeLegacyHandler_test.go +++ b/internal/handler/auth/checkCodeLegacyHandler_test.go @@ -33,7 +33,10 @@ func newLegacyCheckCodeTestRouter(svcCtx *svc.ServiceContext) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() router.Use(middleware.ApiVersionMiddleware(svcCtx)) - router.POST("/v1/auth/check-code", CheckCodeLegacyHandler(svcCtx)) + router.POST("/v1/auth/check-code", middleware.ApiVersionSwitchHandler( + CheckCodeLegacyV1Handler(svcCtx), + CheckCodeLegacyV2Handler(svcCtx), + )) return router } diff --git a/internal/handler/common/checkverificationcodehandler.go b/internal/handler/common/checkverificationcodehandler.go index 1d2ffee..542defd 100644 --- a/internal/handler/common/checkverificationcodehandler.go +++ b/internal/handler/common/checkverificationcodehandler.go @@ -5,12 +5,23 @@ import ( "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/svc" "github.com/perfect-panel/server/internal/types" - "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/result" ) // Check verification code func CheckVerificationCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return CheckVerificationCodeV1Handler(svcCtx) +} + +func CheckVerificationCodeV1Handler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return checkVerificationCodeHandler(svcCtx, false) +} + +func CheckVerificationCodeV2Handler(svcCtx *svc.ServiceContext) func(c *gin.Context) { + return checkVerificationCodeHandler(svcCtx, true) +} + +func checkVerificationCodeHandler(svcCtx *svc.ServiceContext, consume bool) func(c *gin.Context) { return func(c *gin.Context) { var req types.CheckVerificationCodeRequest _ = c.ShouldBind(&req) @@ -21,14 +32,9 @@ func CheckVerificationCodeHandler(svcCtx *svc.ServiceContext) func(c *gin.Contex } l := common.NewCheckVerificationCodeLogic(c.Request.Context(), svcCtx) - useLatest := false - if value, ok := c.Request.Context().Value(constant.CtxKeyAPIVersionUseLatest).(bool); ok { - useLatest = value - } - resp, err := l.CheckVerificationCodeWithBehavior(&req, common.VerifyCodeCheckBehavior{ Source: "canonical", - Consume: useLatest, + Consume: consume, }) result.HttpResult(c, resp, err) } diff --git a/internal/handler/common/checkverificationcodehandler_test.go b/internal/handler/common/checkverificationcodehandler_test.go index 721e0b5..511bc79 100644 --- a/internal/handler/common/checkverificationcodehandler_test.go +++ b/internal/handler/common/checkverificationcodehandler_test.go @@ -55,7 +55,10 @@ func newCanonicalCheckCodeTestRouter(svcCtx *svc.ServiceContext) *gin.Engine { gin.SetMode(gin.TestMode) router := gin.New() router.Use(middleware.ApiVersionMiddleware(svcCtx)) - router.POST("/v1/common/check_verification_code", CheckVerificationCodeHandler(svcCtx)) + router.POST("/v1/common/check_verification_code", middleware.ApiVersionSwitchHandler( + CheckVerificationCodeV1Handler(svcCtx), + CheckVerificationCodeV2Handler(svcCtx), + )) return router } diff --git a/internal/handler/public/user/deleteAccountHandler.go b/internal/handler/public/user/deleteAccountHandler.go index 1bb6383..c33514b 100644 --- a/internal/handler/public/user/deleteAccountHandler.go +++ b/internal/handler/public/user/deleteAccountHandler.go @@ -2,15 +2,14 @@ package user import ( "context" - "encoding/json" - "fmt" "strings" - "time" "github.com/gin-gonic/gin" - "github.com/perfect-panel/server/internal/config" + commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/logic/public/user" "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/internal/types" + "github.com/perfect-panel/server/pkg/authmethod" "github.com/perfect-panel/server/pkg/constant" "github.com/perfect-panel/server/pkg/result" "github.com/perfect-panel/server/pkg/xerr" @@ -36,7 +35,7 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc { // 统一处理邮箱格式:转小写并去空格,与发送验证码逻辑保持一致 req.Email = strings.ToLower(strings.TrimSpace(req.Email)) - // 校验邮箱验证码 + // 校验邮箱验证码(统一走公共验证码校验器,支持 delete_account/security 场景兼容) if err := verifyEmailCode(c.Request.Context(), serverCtx, req.Email, req.Code); err != nil { result.HttpResult(c, nil, err) return @@ -48,43 +47,29 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc { } } -// CacheKeyPayload 验证码缓存结构 -type CacheKeyPayload struct { - Code string `json:"code"` - LastAt int64 `json:"lastAt"` -} - // verifyEmailCode 校验邮箱验证码 -// 支持 DeleteAccount 和 Security 两种场景的验证码 +// 1. 统一复用 common checker +// 2. 强制 consume=true(最终业务操作做一次性消费) +// 3. 兼容历史 security 场景验证码(allow fallback) func verifyEmailCode(ctx context.Context, serverCtx *svc.ServiceContext, email string, code string) error { - // 尝试多种场景的验证码 - scenes := []string{constant.DeleteAccount.String(), constant.Security.String()} - var verified bool - var cacheKeyUsed string - var payload CacheKeyPayload - - for _, scene := range scenes { - cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email) - value, err := serverCtx.Redis.Get(ctx, cacheKey).Result() - if err != nil || value == "" { - continue - } - if err := json.Unmarshal([]byte(value), &payload); err != nil { - continue - } - // 检查验证码是否匹配且未过期 - if payload.Code == code && time.Now().Unix()-payload.LastAt <= serverCtx.Config.VerifyCode.VerifyCodeExpireTime { - verified = true - cacheKeyUsed = cacheKey - break - } + l := commonLogic.NewCheckVerificationCodeLogic(ctx, serverCtx) + resp, err := l.CheckVerificationCodeWithBehavior(&types.CheckVerificationCodeRequest{ + Method: authmethod.Email, + Account: email, + Code: strings.TrimSpace(code), + Type: uint8(constant.DeleteAccount), + }, commonLogic.VerifyCodeCheckBehavior{ + Source: "delete_account", + Consume: true, + AllowSceneFallback: true, + }) + if err != nil { + return err } - if !verified { + if resp == nil || !resp.Status { return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "verification code error or expired") } - // 验证成功后删除缓存 - serverCtx.Redis.Del(ctx, cacheKeyUsed) return nil } diff --git a/internal/handler/public/user/deleteAccountHandler_test.go b/internal/handler/public/user/deleteAccountHandler_test.go index d66fe41..345227d 100644 --- a/internal/handler/public/user/deleteAccountHandler_test.go +++ b/internal/handler/public/user/deleteAccountHandler_test.go @@ -5,14 +5,18 @@ import ( "context" "encoding/json" "errors" + "fmt" "net" "net/http" "net/http/httptest" "testing" + "time" + "github.com/alicebob/miniredis/v2" "github.com/gin-gonic/gin" "github.com/perfect-panel/server/internal/config" "github.com/perfect-panel/server/internal/svc" + "github.com/perfect-panel/server/pkg/constant" "github.com/redis/go-redis/v9" ) @@ -100,3 +104,89 @@ func TestDeleteAccountHandlerVerifyCodeErrorUsesUnifiedResponse(t *testing.T) { t.Fatalf("expected business code 70001, got %d, body=%s", resp.Code, recorder.Body.String()) } } + +func TestVerifyEmailCode_DeleteAccountSceneConsume(t *testing.T) { + miniRedis := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()}) + t.Cleanup(func() { + redisClient.Close() + miniRedis.Close() + }) + + serverCtx := &svc.ServiceContext{ + Redis: redisClient, + Config: config.Config{ + VerifyCode: config.VerifyCode{VerifyCodeExpireTime: 900}, + }, + } + + email := "delete-account@example.com" + code := "112233" + cacheKey := seedDeleteSceneCode(t, redisClient, constant.DeleteAccount.String(), email, code) + + err := verifyEmailCode(context.Background(), serverCtx, email, code) + if err != nil { + t.Fatalf("verifyEmailCode returned unexpected error: %v", err) + } + + exists, err := redisClient.Exists(context.Background(), cacheKey).Result() + if err != nil { + t.Fatalf("failed to check redis key: %v", err) + } + if exists != 0 { + t.Fatalf("expected verification code to be consumed, key still exists") + } +} + +func TestVerifyEmailCode_SecurityFallbackConsume(t *testing.T) { + miniRedis := miniredis.RunT(t) + redisClient := redis.NewClient(&redis.Options{Addr: miniRedis.Addr()}) + t.Cleanup(func() { + redisClient.Close() + miniRedis.Close() + }) + + serverCtx := &svc.ServiceContext{ + Redis: redisClient, + Config: config.Config{ + VerifyCode: config.VerifyCode{VerifyCodeExpireTime: 900}, + }, + } + + email := "security-fallback@example.com" + code := "445566" + cacheKey := seedDeleteSceneCode(t, redisClient, constant.Security.String(), email, code) + + err := verifyEmailCode(context.Background(), serverCtx, email, code) + if err != nil { + t.Fatalf("verifyEmailCode fallback returned unexpected error: %v", err) + } + + exists, err := redisClient.Exists(context.Background(), cacheKey).Result() + if err != nil { + t.Fatalf("failed to check redis key: %v", err) + } + if exists != 0 { + t.Fatalf("expected fallback verification code to be consumed, key still exists") + } +} + +func seedDeleteSceneCode(t *testing.T, redisClient *redis.Client, scene string, email string, code string) string { + t.Helper() + + cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email) + payload := map[string]interface{}{ + "code": code, + "lastAt": time.Now().Unix(), + } + payloadRaw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + err = redisClient.Set(context.Background(), cacheKey, payloadRaw, time.Minute*15).Err() + if err != nil { + t.Fatalf("failed to seed redis payload: %v", err) + } + + return cacheKey +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 3d18628..16c9250 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -636,7 +636,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { authGroupRouter.GET("/check/telephone", auth.CheckUserTelephoneHandler(serverCtx)) // Check legacy verification code - authGroupRouter.POST("/check-code", auth.CheckCodeLegacyHandler(serverCtx)) + authGroupRouter.POST("/check-code", middleware.ApiVersionSwitchHandler( + auth.CheckCodeLegacyV1Handler(serverCtx), + auth.CheckCodeLegacyV2Handler(serverCtx), + )) // User login authGroupRouter.POST("/login", auth.UserLoginHandler(serverCtx)) @@ -684,7 +687,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { commonGroupRouter.GET("/ads", common.GetAdsHandler(serverCtx)) // Check verification code - commonGroupRouter.POST("/check_verification_code", common.CheckVerificationCodeHandler(serverCtx)) + commonGroupRouter.POST("/check_verification_code", middleware.ApiVersionSwitchHandler( + common.CheckVerificationCodeV1Handler(serverCtx), + common.CheckVerificationCodeV2Handler(serverCtx), + )) // Get Client commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx)) diff --git a/internal/logic/common/verifyCodeChecker.go b/internal/logic/common/verifyCodeChecker.go index 4cfaee9..577e371 100644 --- a/internal/logic/common/verifyCodeChecker.go +++ b/internal/logic/common/verifyCodeChecker.go @@ -205,6 +205,9 @@ func resolveVerifyScenes(verifyType constant.VerifyType, allowFallback bool) []s } return []string{constant.Security.String()} case constant.DeleteAccount: + if allowFallback { + return []string{constant.DeleteAccount.String(), constant.Security.String()} + } return []string{constant.DeleteAccount.String()} default: return nil diff --git a/internal/middleware/apiVersionSwitchHandler.go b/internal/middleware/apiVersionSwitchHandler.go new file mode 100644 index 0000000..ec1633e --- /dev/null +++ b/internal/middleware/apiVersionSwitchHandler.go @@ -0,0 +1,23 @@ +package middleware + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/pkg/constant" +) + +func ApiVersionSwitchHandler(legacyHandler gin.HandlerFunc, latestHandler gin.HandlerFunc) gin.HandlerFunc { + return func(c *gin.Context) { + useLatest, _ := c.Request.Context().Value(constant.CtxKeyAPIVersionUseLatest).(bool) + if useLatest && latestHandler != nil { + latestHandler(c) + return + } + if legacyHandler != nil { + legacyHandler(c) + return + } + c.AbortWithStatus(http.StatusNotFound) + } +} diff --git a/internal/middleware/apiVersionSwitchHandler_test.go b/internal/middleware/apiVersionSwitchHandler_test.go new file mode 100644 index 0000000..dd48dc6 --- /dev/null +++ b/internal/middleware/apiVersionSwitchHandler_test.go @@ -0,0 +1,50 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/perfect-panel/server/pkg/constant" +) + +func TestApiVersionSwitchHandlerUsesLegacyByDefault(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/test", ApiVersionSwitchHandler( + func(c *gin.Context) { c.String(http.StatusOK, "legacy") }, + func(c *gin.Context) { c.String(http.StatusOK, "latest") }, + )) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != "legacy" { + t.Fatalf("expected legacy handler, code=%d body=%s", resp.Code, resp.Body.String()) + } +} + +func TestApiVersionSwitchHandlerUsesLatestWhenFlagSet(t *testing.T) { + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), constant.CtxKeyAPIVersionUseLatest, true) + c.Request = c.Request.WithContext(ctx) + c.Next() + }) + r.GET("/test", ApiVersionSwitchHandler( + func(c *gin.Context) { c.String(http.StatusOK, "legacy") }, + func(c *gin.Context) { c.String(http.StatusOK, "latest") }, + )) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + resp := httptest.NewRecorder() + r.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || resp.Body.String() != "latest" { + t.Fatalf("expected latest handler, code=%d body=%s", resp.Code, resp.Body.String()) + } +}