Version: 0.9.66.dev.260504
后端: 1. 阶段 2 user/auth 服务边界落地,新增 `cmd/userauth` go-zero zrpc 服务、`services/userauth` 核心实现、gateway user API/zrpc client 与 shared contracts/ports,迁移注册、登录、刷新 token、登出、JWT、黑名单和 token 额度治理 2. gateway 与启动装配切流,`cmd/all` 只保留边缘路由、鉴权和轻量组合,通过 userauth zrpc 访问核心用户能力;拆分 MySQL/Redis 初始化与 AutoMigrate 边界,`userauth` 自迁 `users` 和 token 记账幂等表,`all` 不再迁用户表 3. 清退 Gin 单体旧 user/auth DAO、model、service、router、middleware 和 JWT handler,并同步调整 agent/schedule/cache/outbox 相关调用依赖 4. 补齐 refresh token 防并发重放、MySQL 幂等 token 记账、额度 `>=` 拦截和 RPC 错误映射,避免重复记账与内部错误透出 文档: 1. 新增《学习计划论坛与Token商店PRD》
This commit is contained in:
29
backend/gateway/middleware/respond_error.go
Normal file
29
backend/gateway/middleware/respond_error.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// writeRespondError 负责把项目内 respond.Response 统一写回 HTTP。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理 respond.Response / 普通 error 到 HTTP JSON 的映射;
|
||||
// 2. 不关心调用方来自哪个中间件,也不关心上游业务属于鉴权还是额度控制;
|
||||
// 3. 方便多个 gateway 中间件复用同一套错误写回规则。
|
||||
func writeRespondError(c *gin.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var resp respond.Response
|
||||
if errors.As(err, &resp) {
|
||||
c.JSON(resp.HTTPStatus(), resp)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(err))
|
||||
}
|
||||
75
backend/gateway/middleware/token_handler.go
Normal file
75
backend/gateway/middleware/token_handler.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ExtractTokenFromAuthorization 从 Authorization 头中提取 token。
|
||||
// 职责边界:
|
||||
// 1. 兼容裸 token 与 Bearer token 两种传参方式;
|
||||
// 2. 不做签名校验,只做字符串提取;
|
||||
// 3. 返回空串表示缺少或格式非法。
|
||||
func ExtractTokenFromAuthorization(header string) string {
|
||||
trimmed := strings.TrimSpace(header)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// JWTTokenAuth 负责 access token 的 gateway 边缘鉴权。
|
||||
// 职责边界:
|
||||
// 1. 只验证 token,并把 user_id 写入 gin 上下文;
|
||||
// 2. 不直连 Redis、JWT 或 users 表,所有核心校验都交给 userauth 服务;
|
||||
// 3. 校验失败时直接中断请求,由 respond 风格统一写回前端。
|
||||
func JWTTokenAuth(validator ports.AccessTokenValidator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if validator == nil {
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token validator dependency not initialized")))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
|
||||
if tokenString == "" {
|
||||
c.JSON(http.StatusUnauthorized, respond.MissingToken)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := validator.ValidateAccessToken(ctx, tokenString)
|
||||
if err != nil {
|
||||
writeRespondError(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if resp == nil || !resp.Valid || resp.UserID <= 0 {
|
||||
c.JSON(http.StatusUnauthorized, respond.InvalidClaims)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", resp.UserID)
|
||||
c.Set("claims", resp)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
51
backend/gateway/middleware/token_quota_guard.go
Normal file
51
backend/gateway/middleware/token_quota_guard.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenQuotaGuard 在请求入口做 token 额度门禁。
|
||||
// 职责边界:
|
||||
// 1. 只负责调用 user/auth 服务判断当前用户是否还能继续消耗 token;
|
||||
// 2. 不再直连 users 表或 Redis 额度细节;
|
||||
// 3. 额度超限时直接拒绝,不进入业务 handler。
|
||||
func TokenQuotaGuard(checker ports.TokenQuotaChecker) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if checker == nil {
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token quota checker dependency not initialized")))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetInt("user_id")
|
||||
if userID <= 0 {
|
||||
c.JSON(http.StatusUnauthorized, respond.ErrUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := checker.CheckTokenQuota(ctx, userID)
|
||||
if err != nil {
|
||||
writeRespondError(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if resp == nil || !resp.Allowed {
|
||||
c.JSON(http.StatusBadRequest, respond.TokenUsageExceedsLimit)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
162
backend/gateway/router/router.go
Normal file
162
backend/gateway/router/router.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/api"
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/gateway/userapi"
|
||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// StartEngine 启动 HTTP 服务,并在上下文取消时尽量优雅退出。
|
||||
func StartEngine(ctx context.Context, r *gin.Engine) {
|
||||
// 1. 先解析端口,保持和历史行为一致。
|
||||
// 2. 再用 http.Server 托管 gin engine,便于收到取消信号时执行 Shutdown。
|
||||
port := viper.GetString("server.port")
|
||||
if port == "" {
|
||||
port = "8080"
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("Server starting on port %s...", port)
|
||||
errCh <- srv.ListenAndServe()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
log.Printf("Failed to shutdown server gracefully: %v", err)
|
||||
}
|
||||
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatalf("Failed to start server: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterRouters(handlers *api.ApiHandlers, authClient ports.UserAuthClient, cache *dao.CacheDAO, limiter *pkg.RateLimiter) *gin.Engine {
|
||||
r := gin.Default()
|
||||
apiGroup := r.Group("/api/v1")
|
||||
{
|
||||
apiGroup.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(200, gin.H{
|
||||
"status": "ok",
|
||||
"version": "0.4.0.dev",
|
||||
})
|
||||
})
|
||||
|
||||
userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter)
|
||||
|
||||
taskGroup := apiGroup.Group("/task")
|
||||
{
|
||||
taskGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
taskGroup.POST("/create", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskHandler.AddTask)
|
||||
taskGroup.PUT("/complete", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskHandler.CompleteTask)
|
||||
taskGroup.PUT("/undo-complete", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UndoCompleteTask)
|
||||
taskGroup.PUT("/update", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UpdateTask)
|
||||
taskGroup.DELETE("/delete", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskHandler.DeleteTask)
|
||||
taskGroup.GET("/get", handlers.TaskHandler.GetUserTasks)
|
||||
taskGroup.POST("/batch-status", handlers.TaskHandler.BatchTaskStatus)
|
||||
}
|
||||
|
||||
courseGroup := apiGroup.Group("/course")
|
||||
{
|
||||
courseGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
courseGroup.POST("/validate", handlers.CourseHandler.CheckUserCourse)
|
||||
courseGroup.POST("/parse-image", handlers.CourseHandler.ParseCourseTableImage)
|
||||
courseGroup.POST("/import", rootmiddleware.IdempotencyMiddleware(cache), handlers.CourseHandler.AddUserCourses)
|
||||
}
|
||||
|
||||
taskClassGroup := apiGroup.Group("/task-class")
|
||||
{
|
||||
taskClassGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
taskClassGroup.POST("/add", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserAddTaskClass)
|
||||
taskClassGroup.GET("/list", handlers.TaskClassHandler.UserGetTaskClassInfos)
|
||||
taskClassGroup.GET("/get", handlers.TaskClassHandler.UserGetCompleteTaskClass)
|
||||
taskClassGroup.PUT("/update", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserUpdateTaskClass)
|
||||
taskClassGroup.POST("/insert-into-schedule", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserAddTaskClassItemIntoSchedule)
|
||||
taskClassGroup.DELETE("/delete-item", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.DeleteTaskClassItem)
|
||||
taskClassGroup.DELETE("/delete-class", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.DeleteTaskClass)
|
||||
taskClassGroup.PUT("/apply-batch-into-schedule", rootmiddleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserInsertBatchTaskClassItemsIntoSchedule)
|
||||
}
|
||||
|
||||
scheduleGroup := apiGroup.Group("/schedule")
|
||||
{
|
||||
scheduleGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
scheduleGroup.GET("/today", handlers.ScheduleHandler.GetUserTodaySchedule)
|
||||
scheduleGroup.GET("/week", handlers.ScheduleHandler.GetUserWeeklySchedule)
|
||||
scheduleGroup.DELETE("/delete", rootmiddleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.DeleteScheduleEvent)
|
||||
scheduleGroup.GET("/recent-completed", handlers.ScheduleHandler.GetUserRecentCompletedSchedules)
|
||||
scheduleGroup.GET("/current", handlers.ScheduleHandler.GetUserOngoingSchedule)
|
||||
scheduleGroup.DELETE("/undo-task-item", rootmiddleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.UserRevocateTaskItemFromSchedule)
|
||||
scheduleGroup.GET("/smart-planning", handlers.ScheduleHandler.SmartPlanning)
|
||||
scheduleGroup.POST("/smart-planning-multi", handlers.ScheduleHandler.SmartPlanningMulti)
|
||||
}
|
||||
|
||||
agentGroup := apiGroup.Group("/agent")
|
||||
{
|
||||
agentGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
agentGroup.POST("/chat", gatewaymiddleware.TokenQuotaGuard(authClient), handlers.AgentHandler.ChatAgent)
|
||||
agentGroup.GET("/conversation-meta", handlers.AgentHandler.GetConversationMeta)
|
||||
agentGroup.GET("/conversation-list", handlers.AgentHandler.GetConversationList)
|
||||
agentGroup.GET("/conversation-timeline", handlers.AgentHandler.GetConversationTimeline)
|
||||
agentGroup.GET("/schedule-preview", handlers.AgentHandler.GetSchedulePlanPreview)
|
||||
agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats)
|
||||
agentGroup.POST("/schedule-state", handlers.AgentHandler.SaveScheduleState)
|
||||
}
|
||||
|
||||
memoryGroup := apiGroup.Group("/memory")
|
||||
{
|
||||
memoryGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
memoryGroup.GET("/items", handlers.MemoryHandler.ListItems)
|
||||
memoryGroup.GET("/items/:id", handlers.MemoryHandler.GetItem)
|
||||
memoryGroup.POST("/items", rootmiddleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.CreateItem)
|
||||
memoryGroup.PATCH("/items/:id", rootmiddleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.UpdateItem)
|
||||
memoryGroup.DELETE("/items/:id", rootmiddleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.DeleteItem)
|
||||
memoryGroup.POST("/items/:id/restore", rootmiddleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.RestoreItem)
|
||||
}
|
||||
|
||||
activeScheduleGroup := apiGroup.Group("/active-schedule")
|
||||
{
|
||||
activeScheduleGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
activeScheduleGroup.POST("/dry-run", handlers.ActiveSchedule.DryRun)
|
||||
activeScheduleGroup.POST("/trigger", handlers.ActiveSchedule.Trigger)
|
||||
activeScheduleGroup.POST("/preview", handlers.ActiveSchedule.CreatePreview)
|
||||
activeScheduleGroup.GET("/preview/:preview_id", handlers.ActiveSchedule.GetPreview)
|
||||
activeScheduleGroup.POST("/preview/:preview_id/confirm", handlers.ActiveSchedule.ConfirmPreview)
|
||||
}
|
||||
|
||||
notificationGroup := apiGroup.Group("/notification")
|
||||
{
|
||||
notificationGroup.Use(gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1))
|
||||
notificationGroup.GET("/channels/feishu", handlers.Notification.GetFeishuWebhook)
|
||||
notificationGroup.PUT("/channels/feishu", handlers.Notification.SaveFeishuWebhook)
|
||||
notificationGroup.DELETE("/channels/feishu", handlers.Notification.DeleteFeishuWebhook)
|
||||
notificationGroup.POST("/channels/feishu/test", handlers.Notification.TestFeishuWebhook)
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Routes setup completed")
|
||||
return r
|
||||
}
|
||||
98
backend/gateway/userapi/handler.go
Normal file
98
backend/gateway/userapi/handler.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
client ports.UserCommandClient
|
||||
}
|
||||
|
||||
// NewUserHandler 只接收 user/auth 客户端,不再直接依赖本地 user service。
|
||||
func NewUserHandler(client ports.UserCommandClient) *UserHandler {
|
||||
return &UserHandler{client: client}
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserRegister(c *gin.Context) {
|
||||
var req contracts.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
retUser, err := api.client.Register(ctx, req)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, retUser))
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserLogin(c *gin.Context) {
|
||||
var req contracts.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokens, err := api.client.Login(ctx, req)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
|
||||
}
|
||||
|
||||
func (api *UserHandler) RefreshTokenHandler(c *gin.Context) {
|
||||
var req contracts.RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RefreshToken) == "" {
|
||||
c.JSON(http.StatusBadRequest, respond.MissingParam)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokens, err := api.client.RefreshToken(ctx, req)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserLogout(c *gin.Context) {
|
||||
token := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, respond.MissingToken)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := api.client.Logout(ctx, token); err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.Ok)
|
||||
}
|
||||
28
backend/gateway/userapi/routes.go
Normal file
28
backend/gateway/userapi/routes.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes 把 user/auth HTTP 入口挂到 gateway 路由组。
|
||||
// 职责边界:
|
||||
// 1. 只注册 /user 下的边缘路由,不关心其它业务域路由;
|
||||
// 2. 登录、注册、刷新 token 只做请求转发;登出需要先经过 access token 边缘鉴权;
|
||||
// 3. 限流仍复用当前通用中间件,后续若 gateway 独立成包,可再整体下沉。
|
||||
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *UserHandler, authClient ports.AccessTokenValidator, limiter *pkg.RateLimiter) {
|
||||
if apiGroup == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
userGroup := apiGroup.Group("/user")
|
||||
{
|
||||
userGroup.POST("/register", handler.UserRegister)
|
||||
userGroup.POST("/login", handler.UserLogin)
|
||||
userGroup.POST("/refresh-token", handler.RefreshTokenHandler)
|
||||
userGroup.POST("/logout", gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1), handler.UserLogout)
|
||||
}
|
||||
}
|
||||
218
backend/gateway/userauth/client.go
Normal file
218
backend/gateway/userauth/client.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package userauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/userauth/rpc/pb"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEndpoint = "127.0.0.1:9081"
|
||||
defaultTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
Endpoints []string
|
||||
Target string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client 是 gateway 侧 user/auth zrpc 的最小适配层。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责跨进程 gRPC 调用和响应转译,不碰 DB / Redis / JWT 细节;
|
||||
// 2. 服务端业务错误先通过 gRPC status 传输,再在这里反解回 respond.Response 风格;
|
||||
// 3. 上层调用方仍然可以保持 `res, err :=` 的统一用法。
|
||||
type Client struct {
|
||||
rpc pb.UserAuthClient
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
endpoints := normalizeEndpoints(cfg.Endpoints)
|
||||
target := strings.TrimSpace(cfg.Target)
|
||||
if len(endpoints) == 0 && target == "" {
|
||||
endpoints = []string{defaultEndpoint}
|
||||
}
|
||||
|
||||
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
|
||||
Endpoints: endpoints,
|
||||
Target: target,
|
||||
NonBlock: true,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{rpc: pb.NewUserAuthClient(zclient.Conn())}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req contracts.RegisterRequest) (*contracts.RegisterResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.Register(ctx, &pb.RegisterRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty register response")
|
||||
}
|
||||
return &contracts.RegisterResponse{ID: uint(resp.Id)}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context, req contracts.LoginRequest) (*contracts.Tokens, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.Login(ctx, &pb.LoginRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
return tokensFromResponse(resp)
|
||||
}
|
||||
|
||||
func (c *Client) RefreshToken(ctx context.Context, req contracts.RefreshTokenRequest) (*contracts.Tokens, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.RefreshToken(ctx, &pb.RefreshTokenRequest{
|
||||
RefreshToken: req.RefreshToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
return tokensFromResponse(resp)
|
||||
}
|
||||
|
||||
func (c *Client) Logout(ctx context.Context, accessToken string) error {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.rpc.Logout(ctx, &pb.LogoutRequest{
|
||||
AccessToken: accessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return errors.New("userauth zrpc service returned empty logout response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ValidateAccessToken(ctx context.Context, accessToken string) (*contracts.ValidateAccessTokenResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ValidateAccessToken(ctx, &pb.ValidateAccessTokenRequest{
|
||||
AccessToken: accessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty validate response")
|
||||
}
|
||||
return &contracts.ValidateAccessTokenResponse{
|
||||
Valid: resp.Valid,
|
||||
UserID: int(resp.UserId),
|
||||
TokenType: resp.TokenType,
|
||||
JTI: resp.Jti,
|
||||
ExpiresAt: timeFromUnixNano(resp.ExpiresAtUnixNano),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CheckTokenQuota(ctx context.Context, userID int) (*contracts.CheckTokenQuotaResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.CheckTokenQuota(ctx, &pb.CheckTokenQuotaRequest{
|
||||
UserId: int64(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty quota response")
|
||||
}
|
||||
return &contracts.CheckTokenQuotaResponse{
|
||||
Allowed: resp.Allowed,
|
||||
TokenLimit: int(resp.TokenLimit),
|
||||
TokenUsage: int(resp.TokenUsage),
|
||||
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.AdjustTokenUsage(ctx, &pb.AdjustTokenUsageRequest{
|
||||
EventId: req.EventID,
|
||||
UserId: int64(req.UserID),
|
||||
TokenDelta: int64(req.TokenDelta),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty adjust response")
|
||||
}
|
||||
return &contracts.CheckTokenQuotaResponse{
|
||||
Allowed: resp.Allowed,
|
||||
TokenLimit: int(resp.TokenLimit),
|
||||
TokenUsage: int(resp.TokenUsage),
|
||||
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureReady() error {
|
||||
if c == nil || c.rpc == nil {
|
||||
return errors.New("userauth zrpc client is not initialized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tokensFromResponse(resp *pb.TokensResponse) (*contracts.Tokens, error) {
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty token response")
|
||||
}
|
||||
return &contracts.Tokens{
|
||||
AccessToken: resp.AccessToken,
|
||||
RefreshToken: resp.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeEndpoints(values []string) []string {
|
||||
endpoints := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
endpoints = append(endpoints, trimmed)
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func timeFromUnixNano(value int64) time.Time {
|
||||
if value <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(0, value)
|
||||
}
|
||||
198
backend/gateway/userauth/errors.go
Normal file
198
backend/gateway/userauth/errors.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package userauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// responseFromRPCError 负责把 user/auth 的 gRPC 错误反解回项目内的 respond.Response。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只在 gateway 边缘层使用,不下沉到服务实现里;
|
||||
// 2. 业务错误尽量恢复成 respond.Response,方便 API 层继续复用现有 DealWithError;
|
||||
// 3. 只要拿不到业务语义,就退化成普通 error,让上层按 500 处理。
|
||||
func responseFromRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return wrapRPCError(err)
|
||||
}
|
||||
|
||||
if resp, ok := responseFromStatus(st); ok {
|
||||
return resp
|
||||
}
|
||||
|
||||
switch st.Code() {
|
||||
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "userauth zrpc service internal error"
|
||||
}
|
||||
return wrapRPCError(errors.New(msg))
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "userauth zrpc service rejected request"
|
||||
}
|
||||
return respond.Response{
|
||||
Status: grpcCodeToRespondStatus(st.Code()),
|
||||
Info: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func responseFromStatus(st *status.Status) (respond.Response, bool) {
|
||||
if st == nil {
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
if resp, ok := responseFromStatusDetails(st); ok {
|
||||
return resp, true
|
||||
}
|
||||
if resp, ok := responseFromLegacyStatus(st.Code(), st.Message()); ok {
|
||||
return resp, true
|
||||
}
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func responseFromStatusDetails(st *status.Status) (respond.Response, bool) {
|
||||
for _, detail := range st.Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
statusValue := strings.TrimSpace(info.Reason)
|
||||
if statusValue == "" {
|
||||
statusValue = grpcCodeToRespondStatus(st.Code())
|
||||
}
|
||||
if statusValue == "" {
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(st.Message())
|
||||
if message == "" && info.Metadata != nil {
|
||||
message = strings.TrimSpace(info.Metadata["info"])
|
||||
}
|
||||
if message == "" {
|
||||
message = statusValue
|
||||
}
|
||||
return respond.Response{Status: statusValue, Info: message}, true
|
||||
}
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func responseFromLegacyStatus(code codes.Code, message string) (respond.Response, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if resp, ok := respondResponseByMessage(trimmed); ok {
|
||||
return resp, true
|
||||
}
|
||||
|
||||
switch code {
|
||||
case codes.Unauthenticated:
|
||||
if trimmed == "" {
|
||||
trimmed = "unauthorized"
|
||||
}
|
||||
return respond.Response{Status: respond.ErrUnauthorized.Status, Info: trimmed}, true
|
||||
case codes.AlreadyExists:
|
||||
if trimmed == "" {
|
||||
trimmed = "already exists"
|
||||
}
|
||||
return respond.Response{Status: respond.InvalidName.Status, Info: trimmed}, true
|
||||
case codes.NotFound:
|
||||
if trimmed == "" {
|
||||
trimmed = "not found"
|
||||
}
|
||||
return respond.Response{Status: respond.WrongName.Status, Info: trimmed}, true
|
||||
case codes.ResourceExhausted:
|
||||
if trimmed == "" {
|
||||
trimmed = respond.TokenUsageExceedsLimit.Info
|
||||
}
|
||||
return respond.Response{Status: respond.TokenUsageExceedsLimit.Status, Info: trimmed}, true
|
||||
case codes.InvalidArgument:
|
||||
if trimmed == "" {
|
||||
trimmed = "invalid argument"
|
||||
}
|
||||
return respond.Response{Status: respond.MissingParam.Status, Info: trimmed}, true
|
||||
case codes.Internal, codes.Unknown, codes.DataLoss:
|
||||
if trimmed == "" {
|
||||
trimmed = "userauth service internal error"
|
||||
}
|
||||
return respond.InternalError(errors.New(trimmed)), true
|
||||
}
|
||||
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func respondResponseByMessage(message string) (respond.Response, bool) {
|
||||
switch strings.TrimSpace(message) {
|
||||
case respond.MissingParam.Info:
|
||||
return respond.MissingParam, true
|
||||
case respond.WrongParamType.Info:
|
||||
return respond.WrongParamType, true
|
||||
case respond.ParamTooLong.Info:
|
||||
return respond.ParamTooLong, true
|
||||
case respond.InvalidName.Info:
|
||||
return respond.InvalidName, true
|
||||
case respond.WrongName.Info:
|
||||
return respond.WrongName, true
|
||||
case respond.WrongPwd.Info:
|
||||
return respond.WrongPwd, true
|
||||
case respond.WrongUsernameOrPwd.Info:
|
||||
return respond.WrongUsernameOrPwd, true
|
||||
case respond.MissingToken.Info:
|
||||
return respond.MissingToken, true
|
||||
case respond.InvalidTokenSingingMethod.Info:
|
||||
return respond.InvalidTokenSingingMethod, true
|
||||
case respond.InvalidToken.Info:
|
||||
return respond.InvalidToken, true
|
||||
case respond.InvalidClaims.Info:
|
||||
return respond.InvalidClaims, true
|
||||
case respond.ErrUnauthorized.Info:
|
||||
return respond.ErrUnauthorized, true
|
||||
case respond.InvalidRefreshToken.Info:
|
||||
return respond.InvalidRefreshToken, true
|
||||
case respond.WrongTokenType.Info:
|
||||
return respond.WrongTokenType, true
|
||||
case respond.UserLoggedOut.Info:
|
||||
return respond.UserLoggedOut, true
|
||||
case respond.WrongUserID.Info:
|
||||
return respond.WrongUserID, true
|
||||
case respond.TokenUsageExceedsLimit.Info:
|
||||
return respond.TokenUsageExceedsLimit, true
|
||||
}
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func grpcCodeToRespondStatus(code codes.Code) string {
|
||||
switch code {
|
||||
case codes.Unauthenticated:
|
||||
return respond.ErrUnauthorized.Status
|
||||
case codes.AlreadyExists:
|
||||
return respond.InvalidName.Status
|
||||
case codes.NotFound:
|
||||
return respond.WrongName.Status
|
||||
case codes.ResourceExhausted:
|
||||
return respond.TokenUsageExceedsLimit.Status
|
||||
case codes.Internal, codes.Unknown, codes.DataLoss:
|
||||
return "500"
|
||||
default:
|
||||
return "400"
|
||||
}
|
||||
}
|
||||
|
||||
func wrapRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("调用 userauth zrpc 服务失败: %w", err)
|
||||
}
|
||||
Reference in New Issue
Block a user