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:
Losita
2026-05-04 15:20:47 +08:00
parent 9902ca3563
commit b08ee17893
58 changed files with 3754 additions and 1510 deletions

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

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

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

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

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

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

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

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