From b08ee1789332fda195222f3f4ca662c14fe0ea47 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Mon, 4 May 2026 15:20:47 +0800 Subject: [PATCH] Version: 0.9.66.dev.260504 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: 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》 --- .../service/session_bridge.go | 2 +- backend/api/container.go | 1 - backend/api/user.go | 103 ------ backend/auth/jwt_handler.go | 265 -------------- backend/auth/jwt_handler_test.go | 128 ------- backend/bootstrap/config.go | 25 ++ backend/cmd/start.go | 57 +-- backend/cmd/userauth/main.go | 33 ++ backend/config.example.yaml | 8 + backend/dao/agent.go | 103 +++--- backend/dao/base.go | 3 - backend/dao/cache.go | 103 ------ backend/dao/user.go | 132 ------- backend/gateway/middleware/respond_error.go | 29 ++ backend/gateway/middleware/token_handler.go | 75 ++++ .../gateway/middleware/token_quota_guard.go | 51 +++ .../routers.go => gateway/router/router.go} | 88 ++--- backend/gateway/userapi/handler.go | 98 ++++++ backend/gateway/userapi/routes.go | 28 ++ backend/gateway/userauth/client.go | 218 ++++++++++++ backend/gateway/userauth/errors.go | 198 +++++++++++ backend/go.mod | 104 +++++- backend/go.sum | 280 ++++++++++++--- backend/infra/outbox/repository.go | 90 +++-- backend/inits/mysql.go | 51 ++- backend/inits/redis.go | 23 +- backend/middleware/cache_deleter.go | 1 - backend/middleware/token_handler.go | 103 ------ backend/middleware/token_quota_guard.go | 184 ---------- backend/model/agent.go | 35 +- backend/model/auth.go | 15 - backend/model/user.go | 50 --- backend/respond/respond.go | 23 +- backend/service/agentsvc/agent.go | 1 + backend/service/agentsvc/agent_meta.go | 2 +- backend/service/agentsvc/agent_newagent.go | 2 +- .../service/events/chat_history_persist.go | 74 ++-- .../service/events/chat_token_usage_adjust.go | 49 ++- .../service/events/core_outbox_handlers.go | 14 +- backend/service/schedule.go | 12 +- backend/service/user.go | 123 ------- backend/services/userauth/dao/cache.go | 130 +++++++ backend/services/userauth/dao/connect.go | 55 +++ backend/services/userauth/dao/user.go | 173 +++++++++ .../services/userauth/internal/auth/tokens.go | 330 ++++++++++++++++++ .../userauth/model/token_usage_adjustment.go | 20 ++ backend/services/userauth/model/user.go | 19 + backend/services/userauth/rpc/errors.go | 86 +++++ backend/services/userauth/rpc/handler.go | 177 ++++++++++ .../services/userauth/rpc/pb/userauth.pb.go | 151 ++++++++ .../userauth/rpc/pb/userauth_grpc.pb.go | 307 ++++++++++++++++ backend/services/userauth/rpc/server.go | 72 ++++ backend/services/userauth/rpc/userauth.proto | 75 ++++ backend/services/userauth/sv/quota.go | 192 ++++++++++ backend/services/userauth/sv/service.go | 176 ++++++++++ backend/shared/contracts/userauth/types.go | 68 ++++ backend/shared/ports/userauth.go | 46 +++ docs/backend/学习计划论坛与Token商店PRD.md | 203 +++++++++++ 58 files changed, 3754 insertions(+), 1510 deletions(-) delete mode 100644 backend/api/user.go delete mode 100644 backend/auth/jwt_handler.go delete mode 100644 backend/auth/jwt_handler_test.go create mode 100644 backend/bootstrap/config.go create mode 100644 backend/cmd/userauth/main.go delete mode 100644 backend/dao/user.go create mode 100644 backend/gateway/middleware/respond_error.go create mode 100644 backend/gateway/middleware/token_handler.go create mode 100644 backend/gateway/middleware/token_quota_guard.go rename backend/{routers/routers.go => gateway/router/router.go} (52%) create mode 100644 backend/gateway/userapi/handler.go create mode 100644 backend/gateway/userapi/routes.go create mode 100644 backend/gateway/userauth/client.go create mode 100644 backend/gateway/userauth/errors.go delete mode 100644 backend/middleware/token_handler.go delete mode 100644 backend/middleware/token_quota_guard.go delete mode 100644 backend/model/auth.go delete mode 100644 backend/model/user.go delete mode 100644 backend/service/user.go create mode 100644 backend/services/userauth/dao/cache.go create mode 100644 backend/services/userauth/dao/connect.go create mode 100644 backend/services/userauth/dao/user.go create mode 100644 backend/services/userauth/internal/auth/tokens.go create mode 100644 backend/services/userauth/model/token_usage_adjustment.go create mode 100644 backend/services/userauth/model/user.go create mode 100644 backend/services/userauth/rpc/errors.go create mode 100644 backend/services/userauth/rpc/handler.go create mode 100644 backend/services/userauth/rpc/pb/userauth.pb.go create mode 100644 backend/services/userauth/rpc/pb/userauth_grpc.pb.go create mode 100644 backend/services/userauth/rpc/server.go create mode 100644 backend/services/userauth/rpc/userauth.proto create mode 100644 backend/services/userauth/sv/quota.go create mode 100644 backend/services/userauth/sv/service.go create mode 100644 backend/shared/contracts/userauth/types.go create mode 100644 backend/shared/ports/userauth.go create mode 100644 docs/backend/学习计划论坛与Token商店PRD.md diff --git a/backend/active_scheduler/service/session_bridge.go b/backend/active_scheduler/service/session_bridge.go index 409f8cd..cd098bd 100644 --- a/backend/active_scheduler/service/session_bridge.go +++ b/backend/active_scheduler/service/session_bridge.go @@ -84,7 +84,7 @@ func (s *TriggerWorkflowService) bootstrapActiveScheduleConversationInTx( if baseSeq == 0 { assistantText := resolveInitialActiveScheduleAssistantText(selectionResult, previewDetail) if assistantText != "" { - if err := txAgentDAO.SaveChatHistoryInTx(ctx, triggerRow.UserID, conversationID, "assistant", assistantText, "", 0, 0); err != nil { + if err := txAgentDAO.SaveChatHistoryInTx(ctx, triggerRow.UserID, conversationID, "assistant", assistantText, "", 0, 0, ""); err != nil { return err } if err := saveActiveScheduleTimelineEvent(ctx, txAgentDAO, triggerRow.UserID, conversationID, baseSeq+1, model.AgentTimelineKindAssistantText, "assistant", assistantText, nil); err != nil { diff --git a/backend/api/container.go b/backend/api/container.go index 1154a4f..634a3e5 100644 --- a/backend/api/container.go +++ b/backend/api/container.go @@ -1,7 +1,6 @@ package api type ApiHandlers struct { - UserHandler *UserHandler TaskHandler *TaskHandler CourseHandler *CourseHandler TaskClassHandler *TaskClassHandler diff --git a/backend/api/user.go b/backend/api/user.go deleted file mode 100644 index 2880a04..0000000 --- a/backend/api/user.go +++ /dev/null @@ -1,103 +0,0 @@ -// Package api 定义API接口层 -// 包含所有对外暴露的HTTP接口定义 -package api - -import ( - "context" - "net/http" - "time" - - "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/respond" - "github.com/LoveLosita/smartflow/backend/service" - "github.com/gin-gonic/gin" -) - -type UserHandler struct { - // 伸出手:准备接住 Service - svc *service.UserService -} - -// NewUserHandler:组装 Handler 的“工厂” -func NewUserHandler(svc *service.UserService) *UserHandler { - return &UserHandler{ - svc: svc, // 把传进来的 Service 揣进口袋里 - } -} - -// UserRegister 用户注册API -// 处理用户注册请求 -func (api *UserHandler) UserRegister(c *gin.Context) { - var user model.UserRegisterRequest - err := c.ShouldBindJSON(&user) - if err != nil { - c.JSON(http.StatusBadRequest, respond.WrongParamType) - return - } - // 创建一个带 1 秒超时的上下文 - ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) - defer cancel() // 记得释放资源 - retUser, err := api.svc.UserRegister(ctx, user) - 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 model.UserLoginRequest - err := c.ShouldBindJSON(&req) - if err != nil { - c.JSON(http.StatusOK, respond.WrongParamType) - return - } - // 创建一个带 1 秒超时的上下文 - ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) - defer cancel() // 记得释放资源 - tokens, err := api.svc.UserLogin(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 requestBody struct { - RefreshToken string `json:"old_refresh_token"` - } - if err := c.ShouldBindJSON(&requestBody); err != nil { - c.JSON(http.StatusBadRequest, respond.WrongParamType) - return - } - if requestBody.RefreshToken == "" { - c.JSON(http.StatusBadRequest, respond.MissingParam) - } - // 创建一个带 1 秒超时的上下文 - ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) - defer cancel() // 记得释放资源 - tokens, err := api.svc.RefreshTokenHandler(ctx, requestBody.RefreshToken) - if err != nil { - respond.DealWithError(c, err) - return - } - c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens)) -} - -func (api *UserHandler) UserLogout(c *gin.Context) { - //1.从上下文中获取 jti 和 expireTime - claims, _ := c.Get("claims") - cl := claims.(*model.MyCustomClaims) - //2.调用 Service 层的 UserLogout 方法 - // 创建一个带 1 秒超时的上下文 - ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) - defer cancel() // 记得释放资源 - err := api.svc.UserLogout(ctx, cl.Jti, cl.ExpiresAt.Time) - if err != nil { - respond.DealWithError(c, err) - return - } - c.JSON(http.StatusOK, respond.Ok) -} diff --git a/backend/auth/jwt_handler.go b/backend/auth/jwt_handler.go deleted file mode 100644 index 04278c6..0000000 --- a/backend/auth/jwt_handler.go +++ /dev/null @@ -1,265 +0,0 @@ -package auth - -import ( - "errors" - "fmt" - "strconv" - "strings" - "time" - - "github.com/LoveLosita/smartflow/backend/dao" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/respond" - "github.com/golang-jwt/jwt/v4" - "github.com/google/uuid" - "github.com/spf13/viper" -) - -const ( - accessSecretConfigKey = "jwt.accessSecret" - refreshSecretConfigKey = "jwt.refreshSecret" - accessExpireConfigKey = "jwt.accessTokenExpire" - refreshExpireConfigKey = "jwt.refreshTokenExpire" - - defaultAccessTokenExpire = 15 * time.Minute - defaultRefreshTokenExpire = 7 * 24 * time.Hour -) - -type jwtRuntimeConfig struct { - AccessKey []byte - RefreshKey []byte - AccessExpire time.Duration - RefreshExpire time.Duration -} - -// AccessSigningKey 负责提供访问令牌签名/验签密钥。 -// 职责边界: -// 1. 负责从运行时配置读取 accessSecret 并做空值校验。 -// 2. 不负责 token 解析、业务鉴权与错误码映射。 -// 3. 返回值语义:[]byte 为签名密钥;error 非空表示配置不可用。 -func AccessSigningKey() ([]byte, error) { - cfg, err := loadJWTConfig() - if err != nil { - return nil, err - } - return cfg.AccessKey, nil -} - -// generateJTI 生成唯一的 JWT ID。 -func generateJTI() string { - return uuid.New().String() -} - -// loadJWTConfig 负责聚合 JWT 运行时配置。 -// 职责边界: -// 1. 负责读取密钥与过期时间配置,并转换为可直接使用的结构。 -// 2. 不负责持久化配置,也不负责降级到“不安全默认密钥”。 -// 3. 返回值语义:cfg 可直接用于签发/校验;error 非空表示配置不合法。 -func loadJWTConfig() (*jwtRuntimeConfig, error) { - accessKey, err := readJWTSecret(accessSecretConfigKey) - if err != nil { - return nil, err - } - refreshKey, err := readJWTSecret(refreshSecretConfigKey) - if err != nil { - return nil, err - } - - accessExpire, err := readJWTExpireDuration(accessExpireConfigKey, defaultAccessTokenExpire) - if err != nil { - return nil, err - } - refreshExpire, err := readJWTExpireDuration(refreshExpireConfigKey, defaultRefreshTokenExpire) - if err != nil { - return nil, err - } - - return &jwtRuntimeConfig{ - AccessKey: accessKey, - RefreshKey: refreshKey, - AccessExpire: accessExpire, - RefreshExpire: refreshExpire, - }, nil -} - -// readJWTSecret 负责读取并校验 JWT 密钥配置。 -// 职责边界: -// 1. 负责“读配置 + 去空白 + 空值校验”。 -// 2. 不负责任何默认值回退,避免静默使用弱配置。 -// 3. 返回值语义:[]byte 为密钥;error 非空表示该配置项不可用。 -func readJWTSecret(configKey string) ([]byte, error) { - secret := strings.TrimSpace(viper.GetString(configKey)) - if secret == "" { - return nil, fmt.Errorf("jwt 配置缺失: %s", configKey) - } - return []byte(secret), nil -} - -// readJWTExpireDuration 负责读取并解析 JWT 过期时间配置。 -// 职责边界: -// 1. 负责把字符串配置解析成 time.Duration,并保证结果大于 0。 -// 2. 不负责签发 token;仅提供“可计算”的过期时长。 -// 3. 返回值语义:duration 为最终时长;error 非空表示格式非法。 -func readJWTExpireDuration(configKey string, fallback time.Duration) (time.Duration, error) { - raw := strings.TrimSpace(viper.GetString(configKey)) - if raw == "" { - return fallback, nil - } - d, err := parseFlexibleDuration(raw) - if err != nil { - return 0, fmt.Errorf("jwt 配置项 %s 非法: %w", configKey, err) - } - if d <= 0 { - return 0, fmt.Errorf("jwt 配置项 %s 必须大于 0", configKey) - } - return d, nil -} - -// parseFlexibleDuration 负责解析项目内常见时长格式。 -// 职责边界: -// 1. 负责兼容 Go 标准格式(如 15m、168h)与项目常见格式(如 15min、7d)。 -// 2. 不负责读取配置键名;仅解析输入字符串。 -// 3. 输入输出语义:raw 为原始时长文本;返回解析后的正时长或错误。 -func parseFlexibleDuration(raw string) (time.Duration, error) { - normalized := strings.ToLower(strings.TrimSpace(raw)) - if normalized == "" { - return 0, errors.New("时长不能为空") - } - - // 1. 先走 Go 原生解析,优先兼容标准写法(如 15m/168h)。 - if d, err := time.ParseDuration(normalized); err == nil { - return d, nil - } - - // 2. 原生解析失败后,兼容项目常见简写(如 15min、7d)。 - type unitDef struct { - Suffix string - Multiplier time.Duration - } - unitDefs := []unitDef{ - {Suffix: "minutes", Multiplier: time.Minute}, - {Suffix: "minute", Multiplier: time.Minute}, - {Suffix: "mins", Multiplier: time.Minute}, - {Suffix: "min", Multiplier: time.Minute}, - {Suffix: "days", Multiplier: 24 * time.Hour}, - {Suffix: "day", Multiplier: 24 * time.Hour}, - {Suffix: "d", Multiplier: 24 * time.Hour}, - {Suffix: "hours", Multiplier: time.Hour}, - {Suffix: "hour", Multiplier: time.Hour}, - {Suffix: "h", Multiplier: time.Hour}, - {Suffix: "seconds", Multiplier: time.Second}, - {Suffix: "second", Multiplier: time.Second}, - {Suffix: "secs", Multiplier: time.Second}, - {Suffix: "sec", Multiplier: time.Second}, - {Suffix: "m", Multiplier: time.Minute}, - {Suffix: "s", Multiplier: time.Second}, - } - - for _, unit := range unitDefs { - if !strings.HasSuffix(normalized, unit.Suffix) { - continue - } - numberPart := strings.TrimSpace(strings.TrimSuffix(normalized, unit.Suffix)) - value, err := strconv.Atoi(numberPart) - if err != nil { - return 0, fmt.Errorf("时长数值非法: %q", numberPart) - } - if value <= 0 { - return 0, fmt.Errorf("时长数值必须大于 0: %d", value) - } - return time.Duration(value) * unit.Multiplier, nil - } - - return 0, fmt.Errorf("不支持的时长格式: %s", raw) -} - -// GenerateTokens 负责按配置签发访问令牌与刷新令牌。 -// 职责边界: -// 1. 负责根据配置生成 exp,并签发 access/refresh 双 token。 -// 2. 不负责登录鉴权(用户名/密码验证在 service 层处理)。 -// 3. 返回值语义:第一个为 access token,第二个为 refresh token,error 非空表示签发失败。 -func GenerateTokens(userID int) (string, string, error) { - cfg, err := loadJWTConfig() - if err != nil { - return "", "", err - } - - now := time.Now() - sid := generateJTI() - - // 1. 先签 access token:短期有效,面向接口访问。 - accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, model.MyCustomClaims{ - UserID: userID, - TokenType: "access_token", - Jti: sid, - RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(now.Add(cfg.AccessExpire)), - }, - }) - accessTokenString, err := accessToken.SignedString(cfg.AccessKey) - if err != nil { - return "", "", err - } - - // 2. 再签 refresh token:长期有效,仅用于换发新 token。 - refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, model.MyCustomClaims{ - UserID: userID, - TokenType: "refresh_token", - Jti: sid, - RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(now), - ExpiresAt: jwt.NewNumericDate(now.Add(cfg.RefreshExpire)), - }, - }) - refreshTokenString, err := refreshToken.SignedString(cfg.RefreshKey) - if err != nil { - return "", "", err - } - - return accessTokenString, refreshTokenString, nil -} - -// ValidateRefreshToken 验证刷新令牌的有效性,并增加 Redis 黑名单检查。 -func ValidateRefreshToken(tokenString string, cache *dao.CacheDAO) (*jwt.Token, error) { - cfg, err := loadJWTConfig() - if err != nil { - return nil, err - } - - // 1. 解析 refresh token,并强制校验签名算法与密钥来源。 - token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, respond.InvalidTokenSingingMethod - } - return cfg.RefreshKey, nil - }) - if err != nil { - return nil, respond.InvalidRefreshToken - } - if !token.Valid { - return nil, respond.InvalidRefreshToken - } - - // 2. 断言 claims 类型,后续业务字段都从结构体读取。 - claims, ok := token.Claims.(*model.MyCustomClaims) - if !ok { - return nil, respond.InvalidClaims - } - - // 3. 校验 token_type,防止把 access token 当 refresh token 用。 - if claims.TokenType != "refresh_token" { - return nil, respond.WrongTokenType - } - - // 4. 黑名单校验:签名合法也要确认 jti 未被主动注销。 - isBlack, err := cache.IsBlacklisted(claims.Jti) - if err != nil { - return nil, errors.New("无法验证令牌状态") - } - if isBlack { - return nil, respond.UserLoggedOut - } - - return token, nil -} diff --git a/backend/auth/jwt_handler_test.go b/backend/auth/jwt_handler_test.go deleted file mode 100644 index b78a160..0000000 --- a/backend/auth/jwt_handler_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package auth - -import ( - "testing" - "time" - - "github.com/LoveLosita/smartflow/backend/model" - "github.com/golang-jwt/jwt/v4" - "github.com/spf13/viper" -) - -func TestParseFlexibleDuration(t *testing.T) { - t.Parallel() - - testCases := []struct { - name string - raw string - want time.Duration - wantFail bool - }{ - {name: "标准格式", raw: "15m", want: 15 * time.Minute}, - {name: "项目分钟简写", raw: "15min", want: 15 * time.Minute}, - {name: "项目天简写", raw: "7d", want: 7 * 24 * time.Hour}, - {name: "非法格式", raw: "abc", wantFail: true}, - } - - for _, tc := range testCases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, err := parseFlexibleDuration(tc.raw) - if tc.wantFail { - if err == nil { - t.Fatalf("期望解析失败,但得到成功: %s", tc.raw) - } - return - } - if err != nil { - t.Fatalf("解析失败: %v", err) - } - if got != tc.want { - t.Fatalf("解析结果不符合预期,got=%v want=%v", got, tc.want) - } - }) - } -} - -func TestGenerateTokens_UseConfigExpire(t *testing.T) { - const ( - accessSecret = "unit-test-access-secret" - refreshSecret = "unit-test-refresh-secret" - accessExpire = "2h" - refreshExpire = "3d" - ) - - originAccessSecret := viper.GetString(accessSecretConfigKey) - originRefreshSecret := viper.GetString(refreshSecretConfigKey) - originAccessExpire := viper.GetString(accessExpireConfigKey) - originRefreshExpire := viper.GetString(refreshExpireConfigKey) - - viper.Set(accessSecretConfigKey, accessSecret) - viper.Set(refreshSecretConfigKey, refreshSecret) - viper.Set(accessExpireConfigKey, accessExpire) - viper.Set(refreshExpireConfigKey, refreshExpire) - - t.Cleanup(func() { - viper.Set(accessSecretConfigKey, originAccessSecret) - viper.Set(refreshSecretConfigKey, originRefreshSecret) - viper.Set(accessExpireConfigKey, originAccessExpire) - viper.Set(refreshExpireConfigKey, originRefreshExpire) - }) - - start := time.Now() - accessTokenString, refreshTokenString, err := GenerateTokens(9527) - if err != nil { - t.Fatalf("签发 token 失败: %v", err) - } - - accessClaims := parseTokenClaimsForTest(t, accessTokenString, []byte(accessSecret)) - refreshClaims := parseTokenClaimsForTest(t, refreshTokenString, []byte(refreshSecret)) - - if accessClaims.TokenType != "access_token" { - t.Fatalf("access token_type 不符合预期: %s", accessClaims.TokenType) - } - if refreshClaims.TokenType != "refresh_token" { - t.Fatalf("refresh token_type 不符合预期: %s", refreshClaims.TokenType) - } - if accessClaims.Jti == "" || refreshClaims.Jti == "" { - t.Fatalf("jti 不能为空") - } - if accessClaims.Jti != refreshClaims.Jti { - t.Fatalf("access/refresh 应共享同一个 jti") - } - - assertExpireNear(t, accessClaims.ExpiresAt.Time, start.Add(2*time.Hour), 3*time.Second) - assertExpireNear(t, refreshClaims.ExpiresAt.Time, start.Add(3*24*time.Hour), 3*time.Second) -} - -func parseTokenClaimsForTest(t *testing.T, tokenString string, key []byte) *model.MyCustomClaims { - t.Helper() - - token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { - return key, nil - }) - if err != nil { - t.Fatalf("解析 token 失败: %v", err) - } - if !token.Valid { - t.Fatalf("token 无效") - } - - claims, ok := token.Claims.(*model.MyCustomClaims) - if !ok { - t.Fatalf("claims 类型断言失败") - } - return claims -} - -func assertExpireNear(t *testing.T, got time.Time, want time.Time, tolerance time.Duration) { - t.Helper() - delta := got.Sub(want) - if delta < 0 { - delta = -delta - } - if delta > tolerance { - t.Fatalf("exp 偏差超出容忍范围,got=%s want=%s delta=%s tolerance=%s", got.Format(time.RFC3339), want.Format(time.RFC3339), delta, tolerance) - } -} diff --git a/backend/bootstrap/config.go b/backend/bootstrap/config.go new file mode 100644 index 0000000..6d40391 --- /dev/null +++ b/backend/bootstrap/config.go @@ -0,0 +1,25 @@ +package bootstrap + +import ( + "fmt" + "log" + + "github.com/spf13/viper" +) + +// LoadConfig 统一加载后端进程配置。 +// 职责边界: +// 1. 只负责把 config.yaml 读入 viper,不解释具体业务配置语义; +// 2. 同时兼容从仓库根目录和 backend 目录启动的两种路径; +// 3. 失败时返回 error,由各进程入口决定是否退出。 +func LoadConfig() error { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("backend") + if err := viper.ReadInConfig(); err != nil { + return fmt.Errorf("failed to read config file: %w", err) + } + log.Println("Config loaded successfully") + return nil +} diff --git a/backend/cmd/start.go b/backend/cmd/start.go index b8399fb..ae98449 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -21,7 +21,10 @@ import ( activesvc "github.com/LoveLosita/smartflow/backend/active_scheduler/service" activeTrigger "github.com/LoveLosita/smartflow/backend/active_scheduler/trigger" "github.com/LoveLosita/smartflow/backend/api" + "github.com/LoveLosita/smartflow/backend/bootstrap" "github.com/LoveLosita/smartflow/backend/dao" + gatewayrouter "github.com/LoveLosita/smartflow/backend/gateway/router" + gatewayuserauth "github.com/LoveLosita/smartflow/backend/gateway/userauth" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" "github.com/LoveLosita/smartflow/backend/inits" @@ -37,7 +40,6 @@ import ( "github.com/LoveLosita/smartflow/backend/newAgent/tools/web" "github.com/LoveLosita/smartflow/backend/notification" "github.com/LoveLosita/smartflow/backend/pkg" - "github.com/LoveLosita/smartflow/backend/routers" "github.com/LoveLosita/smartflow/backend/service" agentsvcsvc "github.com/LoveLosita/smartflow/backend/service/agentsvc" eventsvc "github.com/LoveLosita/smartflow/backend/service/events" @@ -59,7 +61,6 @@ type appRuntime struct { db *gorm.DB redisClient *redis.Client cacheRepo *dao.CacheDAO - userRepo *dao.UserDAO agentRepo *dao.AgentDAO agentCache *dao.AgentCache manager *dao.RepoManager @@ -71,21 +72,12 @@ type appRuntime struct { notificationService *notification.NotificationService limiter *pkg.RateLimiter handlers *api.ApiHandlers + userAuthClient *gatewayuserauth.Client } -// loadConfig 加载应用配置。 +// loadConfig 锻炼? func loadConfig() error { - viper.SetConfigName("config") - viper.SetConfigType("yaml") - viper.AddConfigPath(".") - // 1. 兼容从仓库根目录执行 `go run ./backend/cmd/api` 的场景; - // 2. 从 backend 目录执行时仍优先命中当前目录,不改变现有默认行为。 - viper.AddConfigPath("backend") - if err := viper.ReadInConfig(); err != nil { - return fmt.Errorf("failed to read config file: %w", err) - } - log.Println("Config loaded successfully") - return nil + return bootstrap.LoadConfig() } // Start 保留历史兼容入口,当前默认等价于 StartAll。 @@ -154,12 +146,15 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { return nil, err } - db, err := inits.ConnectDB() + db, err := inits.ConnectCoreDB() if err != nil { return nil, fmt.Errorf("failed to connect to database: %w", err) } - rdb := inits.InitRedis() + rdb, err := inits.InitCoreRedis() + if err != nil { + return nil, fmt.Errorf("failed to connect to redis: %w", err) + } limiter := pkg.NewRateLimiter(rdb) aiHub, err := inits.InitEino() @@ -198,7 +193,6 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { cacheRepo := dao.NewCacheDAO(rdb) agentCacheRepo := dao.NewAgentCache(rdb) _ = db.Use(middleware.NewGormCachePlugin(cacheRepo)) - userRepo := dao.NewUserDAO(db) taskRepo := dao.NewTaskDAO(db) courseRepo := dao.NewCourseDAO(db) taskClassRepo := dao.NewTaskClassDAO(db) @@ -213,12 +207,19 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { } // Service 层初始化。 - userService := service.NewUserService(userRepo, cacheRepo) + userAuthClient, err := gatewayuserauth.NewClient(gatewayuserauth.ClientConfig{ + Endpoints: viper.GetStringSlice("userauth.rpc.endpoints"), + Target: viper.GetString("userauth.rpc.target"), + Timeout: viper.GetDuration("userauth.rpc.timeout"), + }) + if err != nil { + return nil, fmt.Errorf("failed to initialize userauth zrpc client: %w", err) + } taskSv := service.NewTaskService(taskRepo, cacheRepo, eventBus) taskSv.SetActiveScheduleDAO(manager.ActiveSchedule) courseService := buildCourseService(llmService, courseRepo, scheduleRepo) taskClassService := service.NewTaskClassService(taskClassRepo, cacheRepo, scheduleRepo, manager) - scheduleService := service.NewScheduleService(scheduleRepo, userRepo, taskClassRepo, manager, cacheRepo) + scheduleService := service.NewScheduleService(scheduleRepo, taskClassRepo, manager, cacheRepo) agentService := service.NewAgentServiceWithSchedule( llmService, agentRepo, @@ -304,13 +305,13 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { return nil, err } } - handlers := buildAPIHandlers(userService, taskSv, taskClassService, courseService, scheduleService, agentService, memoryModule, activeScheduleDryRun, activeSchedulePreviewConfirm, activeScheduleTrigger, notificationChannelService) + handlers := buildAPIHandlers(taskSv, taskClassService, courseService, scheduleService, agentService, memoryModule, activeScheduleDryRun, activeSchedulePreviewConfirm, activeScheduleTrigger, notificationChannelService) runtime := &appRuntime{ - db: db, - redisClient: rdb, - cacheRepo: cacheRepo, - userRepo: userRepo, + db: db, + redisClient: rdb, + cacheRepo: cacheRepo, + agentRepo: agentRepo, agentCache: agentCacheRepo, manager: manager, @@ -322,6 +323,7 @@ func buildRuntime(ctx context.Context) (*appRuntime, error) { notificationService: notificationService, limiter: limiter, handlers: handlers, + userAuthClient: userAuthClient, } if runtime.eventBus != nil { if err := runtime.registerEventHandlers(); err != nil { @@ -835,7 +837,6 @@ func buildQuickTaskQueryFunc(agentService *service.AgentService) func(ctx contex } func buildAPIHandlers( - userService *service.UserService, taskService *service.TaskService, taskClassService *service.TaskClassService, courseService *service.CourseService, @@ -848,7 +849,6 @@ func buildAPIHandlers( notificationChannelService *notification.ChannelService, ) *api.ApiHandlers { return &api.ApiHandlers{ - UserHandler: api.NewUserHandler(userService), TaskHandler: api.NewTaskHandler(taskService), TaskClassHandler: api.NewTaskClassHandler(taskClassService), CourseHandler: api.NewCourseHandler(courseService), @@ -896,6 +896,7 @@ func (r *appRuntime) registerEventHandlers() error { r.memoryModule, r.activeTriggerWorkflow, r.notificationService, + r.userAuthClient, ); err != nil { return err } @@ -903,8 +904,8 @@ func (r *appRuntime) registerEventHandlers() error { } func (r *appRuntime) startHTTP(ctx context.Context) { - router := routers.RegisterRouters(r.handlers, r.cacheRepo, r.userRepo, r.limiter) - routers.StartEngine(ctx, router) + router := gatewayrouter.RegisterRouters(r.handlers, r.userAuthClient, r.cacheRepo, r.limiter) + gatewayrouter.StartEngine(ctx, router) } func (r *appRuntime) close() { diff --git a/backend/cmd/userauth/main.go b/backend/cmd/userauth/main.go new file mode 100644 index 0000000..cbf46d9 --- /dev/null +++ b/backend/cmd/userauth/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "log" + + "github.com/LoveLosita/smartflow/backend/bootstrap" + userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao" + userauthrpc "github.com/LoveLosita/smartflow/backend/services/userauth/rpc" + userauthsv "github.com/LoveLosita/smartflow/backend/services/userauth/sv" + "github.com/spf13/viper" +) + +func main() { + if err := bootstrap.LoadConfig(); err != nil { + log.Fatalf("failed to load config: %v", err) + } + + db, err := userauthdao.OpenDBFromConfig() + if err != nil { + log.Fatalf("failed to connect userauth database: %v", err) + } + rdb, err := userauthdao.OpenRedisFromConfig() + if err != nil { + log.Fatalf("failed to connect userauth redis: %v", err) + } + + svc := userauthsv.New(userauthdao.NewUserDAO(db), userauthdao.NewCacheDAO(rdb)) + userauthrpc.Start(userauthrpc.ServerOptions{ + ListenOn: viper.GetString("userauth.rpc.listenOn"), + Timeout: viper.GetDuration("userauth.rpc.timeout"), + Service: svc, + }) +} diff --git a/backend/config.example.yaml b/backend/config.example.yaml index 9af75fa..e6ea27a 100644 --- a/backend/config.example.yaml +++ b/backend/config.example.yaml @@ -29,6 +29,14 @@ redis: port: 6379 password: "" +# user/auth zrpc 独立服务与网关客户端配置。 +userauth: + rpc: + listenOn: "0.0.0.0:9081" + endpoints: + - "127.0.0.1:9081" + timeout: 2s + # Kafka outbox 事件总线配置。 kafka: enabled: true diff --git a/backend/dao/agent.go b/backend/dao/agent.go index 19db071..62dd26f 100644 --- a/backend/dao/agent.go +++ b/backend/dao/agent.go @@ -10,6 +10,7 @@ import ( "github.com/LoveLosita/smartflow/backend/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type AgentDAO struct { @@ -39,7 +40,7 @@ func (r *AgentDAO) WithTx(tx *gorm.DB) *AgentDAO { // 1. retry 机制已整体下线,本函数不再写入 retry_group_id / retry_index / retry_from_* 四列; // 2. 这些列在 GORM ChatHistory 模型上暂时保留,列本身可空,历史数据不受影响; // 3. Step B 会做 DROP COLUMN 的 migration。 -func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int) error { +func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int, sourceEventID string) error { // 0. token 入库前兜底:负数统一归零,避免异常值污染累计统计。 if tokensConsumed < 0 { tokensConsumed = 0 @@ -48,6 +49,23 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa if reasoningDurationSeconds < 0 { reasoningDurationSeconds = 0 } + normalizedEventID := strings.TrimSpace(sourceEventID) + var normalizedEventIDPtr *string + if normalizedEventID != "" { + normalizedEventIDPtr = &normalizedEventID + var chat model.AgentChat + err := a.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Select("last_history_event_id"). + Where("user_id = ? AND chat_id = ?", userID, conversationID). + First(&chat).Error + if err != nil { + return err + } + if chat.LastHistoryEventID != nil && strings.TrimSpace(*chat.LastHistoryEventID) == normalizedEventID { + return nil + } + } // 1. 先写 chat_histories 原始消息。 var reasoningContentPtr *string @@ -55,6 +73,7 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa reasoningContentPtr = &reasoningContent } userChat := model.ChatHistory{ + SourceEventID: normalizedEventIDPtr, UserID: userID, MessageContent: &message, ReasoningContent: reasoningContentPtr, @@ -67,16 +86,16 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa return err } - // 2. 再更新会话统计: - // 2.1 message_count +1,保持和 chat_histories 行数口径一致; - // 2.2 tokens_total 累加本条消息 token; - // 2.3 last_message_at 刷新为当前时间,供会话排序使用。 + // 2. 再更新会话统计,保证 message_count / tokens_total / last_message_at 同步推进。 now := time.Now() updates := map[string]interface{}{ "message_count": gorm.Expr("message_count + ?", 1), "tokens_total": gorm.Expr("tokens_total + ?", tokensConsumed), "last_message_at": &now, } + if normalizedEventIDPtr != nil { + updates["last_history_event_id"] = normalizedEventIDPtr + } result := a.db.WithContext(ctx).Model(&model.AgentChat{}). Where("user_id = ? AND chat_id = ?", userID, conversationID). Updates(updates) @@ -84,26 +103,9 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa return result.Error } if result.RowsAffected == 0 { - // 会话不存在时直接失败,避免出现"孤儿历史消息"。 return fmt.Errorf("conversation not found when updating stats: user_id=%d chat_id=%s", userID, conversationID) } - // 3. 最后更新 users.token_usage(同一事务内): - // 3.1 只在 tokensConsumed>0 时执行,避免无意义写入; - // 3.2 和 chat_histories/agent_chats 放在同一事务里,保证统计口径原子一致; - // 3.3 若用户行不存在则返回错误,触发事务回滚,防止出现"会话统计成功但用户统计丢失"。 - if tokensConsumed > 0 { - userUpdate := a.db.WithContext(ctx). - Model(&model.User{}). - Where("id = ?", userID). - Update("token_usage", gorm.Expr("token_usage + ?", tokensConsumed)) - if userUpdate.Error != nil { - return userUpdate.Error - } - if userUpdate.RowsAffected == 0 { - return fmt.Errorf("user not found when updating token usage: user_id=%d", userID) - } - } return nil } @@ -112,8 +114,8 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa // 设计目的: // 1. 给服务层组合多个 DAO 操作时复用,避免嵌套事务; // 2. 让 outbox 消费处理器可以和业务写入共享同一个 tx。 -func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int) error { - return a.saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed) +func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int, sourceEventID string) error { + return a.saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed, sourceEventID) } // SaveChatHistory 在同步直写路径下写入聊天历史。 @@ -121,28 +123,47 @@ func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversa // 说明: // 1. 该方法会自行开启事务; // 2. 内部复用 saveChatHistoryCore,确保和 SaveChatHistoryInTx 的业务口径完全一致。 -func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int) error { +func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, tokensConsumed int, sourceEventID string) error { return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - return a.WithTx(tx).saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed) + return a.WithTx(tx).saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, tokensConsumed, sourceEventID) }) } -// adjustTokenUsageCore 在同一事务语义下做"会话/用户"token 账本增量调整。 +// adjustTokenUsageCore 在同一事务语义下做"会话"token 账本增量调整。 // // 职责边界: -// 1. 只更新 agent_chats.tokens_total 与 users.token_usage; +// 1. 只更新 agent_chats.tokens_total; // 2. 不写 chat_histories(消息落库由 SaveChatHistory* 路径负责); // 3. deltaTokens<=0 时视为无操作,直接返回。 -func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, conversationID string, deltaTokens int) error { +func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, conversationID string, deltaTokens int, eventID string) error { if deltaTokens <= 0 { return nil } + normalizedEventID := strings.TrimSpace(eventID) + var normalizedEventIDPtr *string + if normalizedEventID != "" { + normalizedEventIDPtr = &normalizedEventID + var chat model.AgentChat + err := a.db.WithContext(ctx). + Clauses(clause.Locking{Strength: "UPDATE"}). + Select("last_token_adjust_event_id"). + Where("user_id = ? AND chat_id = ?", userID, conversationID). + First(&chat).Error + if err != nil { + return err + } + if chat.LastTokenAdjustEventID != nil && strings.TrimSpace(*chat.LastTokenAdjustEventID) == normalizedEventID { + return nil + } + } - // 1. 先更新会话累计 token。 chatUpdate := a.db.WithContext(ctx). Model(&model.AgentChat{}). Where("user_id = ? AND chat_id = ?", userID, conversationID). - Update("tokens_total", gorm.Expr("tokens_total + ?", deltaTokens)) + Updates(map[string]interface{}{ + "tokens_total": gorm.Expr("tokens_total + ?", deltaTokens), + "last_token_adjust_event_id": normalizedEventIDPtr, + }) if chatUpdate.Error != nil { return chatUpdate.Error } @@ -150,32 +171,20 @@ func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, convers return fmt.Errorf("conversation not found when adjusting tokens: user_id=%d chat_id=%s", userID, conversationID) } - // 2. 再更新用户累计 token。 - userUpdate := a.db.WithContext(ctx). - Model(&model.User{}). - Where("id = ?", userID). - Update("token_usage", gorm.Expr("token_usage + ?", deltaTokens)) - if userUpdate.Error != nil { - return userUpdate.Error - } - if userUpdate.RowsAffected == 0 { - return fmt.Errorf("user not found when adjusting token usage: user_id=%d", userID) - } return nil } // AdjustTokenUsageInTx 在调用方已开启事务时执行 token 账本增量调整。 -func (a *AgentDAO) AdjustTokenUsageInTx(ctx context.Context, userID int, conversationID string, deltaTokens int) error { - return a.adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens) +func (a *AgentDAO) AdjustTokenUsageInTx(ctx context.Context, userID int, conversationID string, deltaTokens int, eventID string) error { + return a.adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens, eventID) } // AdjustTokenUsage 在同步路径下执行 token 账本增量调整(内部自带事务)。 -func (a *AgentDAO) AdjustTokenUsage(ctx context.Context, userID int, conversationID string, deltaTokens int) error { +func (a *AgentDAO) AdjustTokenUsage(ctx context.Context, userID int, conversationID string, deltaTokens int, eventID string) error { return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - return a.WithTx(tx).adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens) + return a.WithTx(tx).adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens, eventID) }) } - func (a *AgentDAO) CreateNewChat(userID int, chatID string) (int64, error) { chat := model.AgentChat{ ChatID: chatID, diff --git a/backend/dao/base.go b/backend/dao/base.go index d5f6439..e031782 100644 --- a/backend/dao/base.go +++ b/backend/dao/base.go @@ -13,7 +13,6 @@ type RepoManager struct { Task *TaskDAO Course *CourseDAO TaskClass *TaskClassDAO - User *UserDAO Agent *AgentDAO ActiveSchedule *ActiveScheduleDAO ActiveScheduleSession *ActiveScheduleSessionDAO @@ -27,7 +26,6 @@ func NewManager(db *gorm.DB) *RepoManager { Task: NewTaskDAO(db), Course: NewCourseDAO(db), TaskClass: NewTaskClassDAO(db), - User: NewUserDAO(db), Agent: NewAgentDAO(db), ActiveSchedule: NewActiveScheduleDAO(db), ActiveScheduleSession: NewActiveScheduleSessionDAO(db), @@ -48,7 +46,6 @@ func (m *RepoManager) WithTx(tx *gorm.DB) *RepoManager { Task: m.Task.WithTx(tx), TaskClass: m.TaskClass.WithTx(tx), Course: m.Course.WithTx(tx), - User: m.User.WithTx(tx), Agent: m.Agent.WithTx(tx), ActiveSchedule: m.ActiveSchedule.WithTx(tx), ActiveScheduleSession: m.ActiveScheduleSession.WithTx(tx), diff --git a/backend/dao/cache.go b/backend/dao/cache.go index 1e726ff..ab1dac3 100644 --- a/backend/dao/cache.go +++ b/backend/dao/cache.go @@ -18,17 +18,6 @@ type CacheDAO struct { client *redis.Client } -// UserTokenQuotaSnapshot 是“用户额度判断”的 Redis 快照结构。 -// -// 设计说明: -// 1. 只保留额度判断必要字段,避免把 users 全字段塞进缓存; -// 2. 该结构仅用于“快速门禁判断”,权威账本仍以 MySQL 为准。 -type UserTokenQuotaSnapshot struct { - TokenLimit int `json:"token_limit"` - TokenUsage int `json:"token_usage"` - LastResetAt time.Time `json:"last_reset_at"` -} - func NewCacheDAO(client *redis.Client) *CacheDAO { return &CacheDAO{client: client} } @@ -45,22 +34,6 @@ func (d *CacheDAO) conversationTimelineSeqKey(userID int, conversationID string) return fmt.Sprintf("smartflow:conversation_timeline_seq:u:%d:c:%s", userID, conversationID) } -// SetBlacklist 把 Token 写入黑名单。 -func (d *CacheDAO) SetBlacklist(jti string, expiration time.Duration) error { - return d.client.Set(context.Background(), "blacklist:"+jti, "1", expiration).Err() -} - -// IsBlacklisted 检查 Token 是否在黑名单中。 -func (d *CacheDAO) IsBlacklisted(jti string) (bool, error) { - result, err := d.client.Get(context.Background(), "blacklist:"+jti).Result() - if errors.Is(err, redis.Nil) { - return false, nil // 不在黑名单中 - } else if err != nil { - return false, err // 其他错误 - } - return result == "1", nil // 在黑名单中 -} - func (d *CacheDAO) AddTaskClassList(ctx context.Context, userID int, list *model.UserGetTaskClassesResponse) error { // 1. 定义 Key,使用 userID 隔离不同用户的数据。 key := fmt.Sprintf("smartflow:task_classes:%d", userID) @@ -293,82 +266,6 @@ func (d *CacheDAO) DeleteUserOngoingScheduleFromCache(ctx context.Context, userI return d.client.Del(ctx, key).Err() } -func userTokenQuotaSnapshotKey(userID int) string { - return fmt.Sprintf("smartflow:user_token_quota_snapshot:%d", userID) -} - -func userTokenBlockedKey(userID int) string { - return fmt.Sprintf("smartflow:user_token_blocked:%d", userID) -} - -// GetUserTokenQuotaSnapshot 读取用户 token 配额快照。 -// -// 输入输出语义: -// 1. 命中返回 (*UserTokenQuotaSnapshot, true, nil); -// 2. 未命中返回 (nil, false, nil); -// 3. Redis/反序列化错误返回 (nil, false, err)。 -func (d *CacheDAO) GetUserTokenQuotaSnapshot(ctx context.Context, userID int) (*UserTokenQuotaSnapshot, bool, error) { - key := userTokenQuotaSnapshotKey(userID) - val, err := d.client.Get(ctx, key).Result() - if errors.Is(err, redis.Nil) { - return nil, false, nil - } - if err != nil { - return nil, false, err - } - - var snapshot UserTokenQuotaSnapshot - if err = json.Unmarshal([]byte(val), &snapshot); err != nil { - return nil, false, err - } - return &snapshot, true, nil -} - -// SetUserTokenQuotaSnapshot 写入用户 token 配额快照。 -// -// 职责边界: -// 1. 只做缓存写入,不做额度判断; -// 2. ttl 由上层策略控制,便于按场景调优“性能 vs 一致性”。 -func (d *CacheDAO) SetUserTokenQuotaSnapshot(ctx context.Context, userID int, snapshot UserTokenQuotaSnapshot, ttl time.Duration) error { - key := userTokenQuotaSnapshotKey(userID) - data, err := json.Marshal(snapshot) - if err != nil { - return err - } - return d.client.Set(ctx, key, data, ttl).Err() -} - -// DeleteUserTokenQuotaSnapshot 删除用户 token 快照缓存。 -func (d *CacheDAO) DeleteUserTokenQuotaSnapshot(ctx context.Context, userID int) error { - return d.client.Del(ctx, userTokenQuotaSnapshotKey(userID)).Err() -} - -// IsUserTokenBlocked 检查用户是否被“额度封禁键”命中。 -func (d *CacheDAO) IsUserTokenBlocked(ctx context.Context, userID int) (bool, error) { - result, err := d.client.Get(ctx, userTokenBlockedKey(userID)).Result() - if errors.Is(err, redis.Nil) { - return false, nil - } - if err != nil { - return false, err - } - return result == "1", nil -} - -// SetUserTokenBlocked 设置用户“额度封禁键”。 -// -// 说明: -// 1. 该键是快速拦截层,不是权威账本; -// 2. ttl 建议设置到“下一次重置时间”,到期自动解封。 -func (d *CacheDAO) SetUserTokenBlocked(ctx context.Context, userID int, ttl time.Duration) error { - return d.client.Set(ctx, userTokenBlockedKey(userID), "1", ttl).Err() -} - -// DeleteUserTokenBlocked 清理用户“额度封禁键”。 -func (d *CacheDAO) DeleteUserTokenBlocked(ctx context.Context, userID int) error { - return d.client.Del(ctx, userTokenBlockedKey(userID)).Err() -} - // SetSchedulePlanPreviewToCache 写入“排程预览”缓存。 // // 职责边界: diff --git a/backend/dao/user.go b/backend/dao/user.go deleted file mode 100644 index 9e9b88d..0000000 --- a/backend/dao/user.go +++ /dev/null @@ -1,132 +0,0 @@ -package dao - -import ( - "context" - "errors" - "time" - - "github.com/LoveLosita/smartflow/backend/model" - "gorm.io/gorm" -) - -// UserDAO 用户数据访问对象 -// 负责用户相关的数据库操作 -type UserDAO struct { - // 这是一个口袋,用来装数据库连接实例 - db *gorm.DB -} - -// NewUserDAO 创建UserDAO实例 -// NewUserDAO 接收一个 *gorm.DB,并把它塞进结构体的口袋里 -func NewUserDAO(db *gorm.DB) *UserDAO { - return &UserDAO{ - db: db, - } -} - -func (r *UserDAO) WithTx(tx *gorm.DB) *UserDAO { - return &UserDAO{db: tx} -} - -// Create 创建新用户 -// 插入新用户信息到数据库 -func (r *UserDAO) Create(username, phoneNumber, password string) (*model.User, error) { - // 创建User实例 - user := &model.User{ - Username: username, - PhoneNumber: phoneNumber, - Password: password, // 注意:实际项目中应该对密码进行加密处理 - TokenLimit: 100000, // 默认值 - TokenUsage: 0, // 初始使用量为0 - LastResetAt: time.Now(), // 设置为当前时间 - } - - // 插入数据 - if err := r.db.Create(user).Error; err != nil { - return nil, err - } - - return user, nil -} - -func (r *UserDAO) IfUsernameExists(name string) (bool, error) { - err := r.db.Where("username = ?", name).First(&model.User{}).Error - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return false, nil - } - return true, err - } - return true, nil -} - -func (r *UserDAO) GetUserHashedPasswordByName(name string) (string, error) { - var user model.User - err := r.db.Where("username = ?", name).First(&user).Error - if err != nil { - return "", err - } - return user.Password, nil -} - -func (r *UserDAO) GetUserIDByName(name string) (int, error) { - var user model.User - err := r.db.Where("username = ?", name).First(&user).Error - if err != nil { - return -1, err - } - return int(user.ID), nil -} - -func (r *UserDAO) GetUserByID(id int) (*model.User, error) { - var user model.User - err := r.db.Where("id = ?", id).First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -// GetUserTokenQuotaByID 查询用户 token 配额快照(仅查询配额相关字段)。 -// -// 职责边界: -// 1. 只返回 token_limit / token_usage / last_reset_at 等“额度判断必需字段”; -// 2. 不负责做超额判断与重置判断(由中间件统一决策); -// 3. 不返回密码等敏感字段,避免把无关信息带入鉴权链路。 -func (r *UserDAO) GetUserTokenQuotaByID(ctx context.Context, id int) (*model.User, error) { - var user model.User - err := r.db.WithContext(ctx). - Select("id", "token_limit", "token_usage", "last_reset_at"). - Where("id = ?", id). - First(&user).Error - if err != nil { - return nil, err - } - return &user, nil -} - -// ResetUserTokenUsageIfDue 在“已到重置窗口”时执行懒重置。 -// -// 输入输出语义: -// 1. dueBefore:判定“到期可重置”的截止时间(通常是 now-7d); -// 2. resetAt:本次重置写入的时间戳; -// 3. 返回值 bool: -// - true 表示本次调用实际执行了重置; -// - false 表示条件未命中(尚未到期或记录不存在)。 -// -// 并发与幂等说明: -// 1. 使用条件更新(WHERE last_reset_at <= dueBefore)保证并发下最多一次成功重置; -// 2. 重复调用是安全的,未命中条件时不会破坏现有统计。 -func (r *UserDAO) ResetUserTokenUsageIfDue(ctx context.Context, id int, dueBefore time.Time, resetAt time.Time) (bool, error) { - result := r.db.WithContext(ctx). - Model(&model.User{}). - Where("id = ? AND (last_reset_at IS NULL OR last_reset_at <= ?)", id, dueBefore). - Updates(map[string]interface{}{ - "token_usage": 0, - "last_reset_at": resetAt, - }) - if result.Error != nil { - return false, result.Error - } - return result.RowsAffected > 0, nil -} diff --git a/backend/gateway/middleware/respond_error.go b/backend/gateway/middleware/respond_error.go new file mode 100644 index 0000000..3d8c76c --- /dev/null +++ b/backend/gateway/middleware/respond_error.go @@ -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)) +} diff --git a/backend/gateway/middleware/token_handler.go b/backend/gateway/middleware/token_handler.go new file mode 100644 index 0000000..218f1bc --- /dev/null +++ b/backend/gateway/middleware/token_handler.go @@ -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() + } +} diff --git a/backend/gateway/middleware/token_quota_guard.go b/backend/gateway/middleware/token_quota_guard.go new file mode 100644 index 0000000..8293579 --- /dev/null +++ b/backend/gateway/middleware/token_quota_guard.go @@ -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() + } +} diff --git a/backend/routers/routers.go b/backend/gateway/router/router.go similarity index 52% rename from backend/routers/routers.go rename to backend/gateway/router/router.go index 43b71db..0185881 100644 --- a/backend/routers/routers.go +++ b/backend/gateway/router/router.go @@ -1,6 +1,4 @@ -// Package routers 路由配置 -// 定义所有 HTTP 路由和路由组 -package routers +package router import ( "context" @@ -11,8 +9,11 @@ import ( "github.com/LoveLosita/smartflow/backend/api" "github.com/LoveLosita/smartflow/backend/dao" - "github.com/LoveLosita/smartflow/backend/middleware" + 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" ) @@ -20,7 +21,7 @@ import ( // StartEngine 启动 HTTP 服务,并在上下文取消时尽量优雅退出。 func StartEngine(ctx context.Context, r *gin.Engine) { // 1. 先解析端口,保持和历史行为一致。 - // 2. 再用 http.Server 托管 gin engine,方便在取消信号到来时执行 Shutdown。 + // 2. 再用 http.Server 托管 gin engine,便于收到取消信号时执行 Shutdown。 port := viper.GetString("server.port") if port == "" { port = "8080" @@ -54,13 +55,10 @@ func StartEngine(ctx context.Context, r *gin.Engine) { } } -func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *dao.UserDAO, limiter *pkg.RateLimiter) *gin.Engine { - // 初始化 Gin 引擎 +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", @@ -68,59 +66,58 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d }) }) - userGroup := apiGroup.Group("/user") - { - userGroup.POST("/register", handlers.UserHandler.UserRegister) - userGroup.POST("/login", handlers.UserHandler.UserLogin) - userGroup.POST("/refresh-token", handlers.UserHandler.RefreshTokenHandler) - userGroup.POST("/logout", middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1), handlers.UserHandler.UserLogout) - } + userapi.RegisterRoutes(apiGroup, userapi.NewUserHandler(authClient), authClient, limiter) + taskGroup := apiGroup.Group("/task") { - taskGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) - taskGroup.POST("/create", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.AddTask) - taskGroup.PUT("/complete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.CompleteTask) - taskGroup.PUT("/undo-complete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UndoCompleteTask) - taskGroup.PUT("/update", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UpdateTask) - taskGroup.DELETE("/delete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.DeleteTask) + 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(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) + 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", middleware.IdempotencyMiddleware(cache), handlers.CourseHandler.AddUserCourses) + courseGroup.POST("/import", rootmiddleware.IdempotencyMiddleware(cache), handlers.CourseHandler.AddUserCourses) } + taskClassGroup := apiGroup.Group("/task-class") { - taskClassGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) - taskClassGroup.POST("/add", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserAddTaskClass) + 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", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserUpdateTaskClass) - taskClassGroup.POST("/insert-into-schedule", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserAddTaskClassItemIntoSchedule) - taskClassGroup.DELETE("/delete-item", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.DeleteTaskClassItem) - taskClassGroup.DELETE("/delete-class", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.DeleteTaskClass) - taskClassGroup.PUT("/apply-batch-into-schedule", middleware.IdempotencyMiddleware(cache), handlers.TaskClassHandler.UserInsertBatchTaskClassItemsIntoSchedule) + 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(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) + 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", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.DeleteScheduleEvent) + 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", middleware.IdempotencyMiddleware(cache), handlers.ScheduleHandler.UserRevocateTaskItemFromSchedule) + 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(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) - agentGroup.POST("/chat", middleware.TokenQuotaGuard(cache, userRepo), handlers.AgentHandler.ChatAgent) + 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) @@ -128,35 +125,38 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d agentGroup.GET("/context-stats", handlers.AgentHandler.GetContextStats) agentGroup.POST("/schedule-state", handlers.AgentHandler.SaveScheduleState) } + memoryGroup := apiGroup.Group("/memory") { - memoryGroup.Use(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) + 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", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.CreateItem) - memoryGroup.PATCH("/items/:id", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.UpdateItem) - memoryGroup.DELETE("/items/:id", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.DeleteItem) - memoryGroup.POST("/items/:id/restore", middleware.IdempotencyMiddleware(cache), handlers.MemoryHandler.RestoreItem) + 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(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) + 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(middleware.JWTTokenAuth(cache), middleware.RateLimitMiddleware(limiter, 20, 1)) + 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) } } - // 初始化 Gin 引擎 + log.Println("Routes setup completed") return r } diff --git a/backend/gateway/userapi/handler.go b/backend/gateway/userapi/handler.go new file mode 100644 index 0000000..790278d --- /dev/null +++ b/backend/gateway/userapi/handler.go @@ -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) +} diff --git a/backend/gateway/userapi/routes.go b/backend/gateway/userapi/routes.go new file mode 100644 index 0000000..3411394 --- /dev/null +++ b/backend/gateway/userapi/routes.go @@ -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) + } +} diff --git a/backend/gateway/userauth/client.go b/backend/gateway/userauth/client.go new file mode 100644 index 0000000..19c39fc --- /dev/null +++ b/backend/gateway/userauth/client.go @@ -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) +} diff --git a/backend/gateway/userauth/errors.go b/backend/gateway/userauth/errors.go new file mode 100644 index 0000000..da2aecf --- /dev/null +++ b/backend/gateway/userauth/errors.go @@ -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) +} diff --git a/backend/go.mod b/backend/go.mod index f0fc632..3e9bc0c 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/LoveLosita/smartflow/backend -go 1.24.0 +go 1.25.0 require ( github.com/cloudwego/eino v0.7.13 @@ -9,81 +9,155 @@ require ( github.com/gin-gonic/gin v1.11.0 github.com/go-redis/redis/v8 v8.11.5 github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/golang/protobuf v1.5.4 github.com/google/uuid v1.6.0 github.com/segmentio/kafka-go v0.4.47 github.com/spf13/viper v1.21.0 github.com/volcengine/volcengine-go-sdk v1.2.9 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.48.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.1 ) +require ( + github.com/coreos/go-semver v0.3.1 // indirect + github.com/coreos/go-systemd/v22 v22.5.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/redis/go-redis/v9 v9.18.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.etcd.io/etcd/api/v3 v3.5.21 // indirect + go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect + go.etcd.io/etcd/client/v3 v3.5.21 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + go.uber.org/zap v1.24.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/time v0.14.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.34.3 // indirect + k8s.io/apimachinery v0.34.3 // indirect + k8s.io/client-go v0.34.3 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/eino-contrib/jsonschema v1.0.3 // indirect github.com/evanphx/json-patch v0.5.2 // indirect + github.com/fatih/color v1.18.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect - github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-sql-driver/mysql v1.9.3 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-yaml v1.18.0 // indirect github.com/goph/emperror v0.17.2 // indirect + github.com/grafana/pyroscope-go v1.2.8 // indirect + github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.15.9 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/meguminnnnnnnnn/go-openai v0.1.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pierrec/lz4/v4 v4.1.15 // indirect + github.com/openzipkin/zipkin-go v0.4.3 // indirect + github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/quic-go v0.54.0 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/subosito/gotenv v1.6.0 // indirect + github.com/titanous/json5 v1.0.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/volcengine/volc-sdk-golang v1.0.23 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/yargevad/filepathx v1.0.0 // indirect - go.uber.org/mock v0.5.0 // indirect + github.com/zeromicro/go-zero v1.10.1 + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect + go.uber.org/mock v0.6.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.28.0 // indirect - golang.org/x/tools v0.35.0 // indirect - google.golang.org/protobuf v1.36.9 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/tools v0.41.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect + google.golang.org/grpc v1.79.3 + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index e694a81..c24c2ed 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -3,12 +3,21 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= +github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68= +github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA= github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8= @@ -21,10 +30,12 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= @@ -34,6 +45,11 @@ github.com/cloudwego/eino-ext/components/model/ark v0.1.64 h1:ecsP4xWhOGi6NYxl2N github.com/cloudwego/eino-ext/components/model/ark v0.1.64/go.mod h1:aabMR15RTXBSi9Eu13CWavzE+no5BQO4FJUEEdqImbg= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ= github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -43,16 +59,21 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k= github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ= -github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= @@ -62,6 +83,19 @@ github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -72,15 +106,21 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= -github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= @@ -95,6 +135,10 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -104,6 +148,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -111,6 +157,14 @@ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18= github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= +github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M= +github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og= +github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= +github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= @@ -121,34 +175,41 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8= -github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= -github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs= github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= @@ -156,8 +217,11 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyex github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -166,29 +230,44 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= +github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= -github.com/onsi/gomega v1.27.3 h1:5VwIwnBY3vbBDOJrNtA4rVdiTZCsq9B5F12pvy1Drmk= -github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw= -github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= -github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0= +github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= +github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= +github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg= +github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c= +github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= +github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= +github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= +github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= -github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= -github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0= +github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= @@ -205,6 +284,8 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -217,6 +298,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= @@ -224,12 +306,15 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s= +github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= @@ -242,81 +327,147 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/ github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg= github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= -github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc= github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= +github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= +github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= +github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU= +github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k= +go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8= +go.etcd.io/etcd/api/v3 v3.5.21/go.mod h1:c3aH5wcvXv/9dqIw2Y810LDXJfhSYdHQ0vxmP3CCHVY= +go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoBqUTc= +go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs= +go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY= +go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE= +go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= -golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= -golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= -golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= -golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= -golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -324,28 +475,42 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= -golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo= +google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= @@ -357,12 +522,21 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY= +gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI= +gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -375,9 +549,27 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= +k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4= +k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk= +k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE= +k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A= +k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= +k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/backend/infra/outbox/repository.go b/backend/infra/outbox/repository.go index e9bb641..f935859 100644 --- a/backend/infra/outbox/repository.go +++ b/backend/infra/outbox/repository.go @@ -13,12 +13,11 @@ import ( "gorm.io/gorm/clause" ) -// Repository 是 outbox 状态机仓储。 -// +// Repository 是 outbox 状态仓储。 // 职责边界: // 1. 只负责 outbox 状态流转与通用事务编排; // 2. 不负责聊天、任务、通知等具体业务语义; -// 3. 同一仓储实例只面向一个服务级 outbox 目录,避免把共享表当成终态。 +// 3. 同一仓储实例只面向一个服务级 outbox 目录。 type Repository struct { db *gorm.DB route ServiceRoute @@ -28,7 +27,7 @@ func NewRepository(db *gorm.DB) *Repository { return &Repository{db: db} } -// WithTx 用外部事务句柄构造同事务仓储实例。 +// WithTx 使用外部事务句柄构造同事务仓储实例。 func (d *Repository) WithTx(tx *gorm.DB) *Repository { if d == nil { return &Repository{db: tx} @@ -36,11 +35,9 @@ func (d *Repository) WithTx(tx *gorm.DB) *Repository { return &Repository{db: tx, route: d.route} } -// WithRoute 用指定服务目录构造服务级仓储。 -// -// 职责边界: -// 1. 只切换 outbox 物理目录,不改变事务句柄; -// 2. 适合多个 service engine 共享同一 DB 连接; +// WithRoute 使用指定服务路由构造仓储实例。 +// 1. 只切换 outbox 物理目录,不改变业务事务语义; +// 2. 适合多个 service engine 共用同一 DB 连接时分别绑定各自 route; // 3. 保留 route 的 table/topic/group,避免回落到共享 topic。 func (d *Repository) WithRoute(route ServiceRoute) *Repository { route = normalizeServiceRoute(route) @@ -50,11 +47,9 @@ func (d *Repository) WithRoute(route ServiceRoute) *Repository { return &Repository{db: d.db, route: route} } -// CreateMessage 把事件写入 outbox。 -// -// 职责边界: -// 1. 只接受 eventType、messageKey、payload 和 maxRetry,不再允许业务侧显式传 topic; -// 2. table/topic/group 统一由 eventType -> service -> route 解析,确保服务级路由是唯一入口; +// CreateMessage 将事件写入 outbox。 +// 1. 只接收 eventType、messageKey、payload 和 maxRetry,不再允许业务侧显式传 topic; +// 2. table/topic/group 统一由 eventType -> service -> route 解析,保证服务级路由是唯一入口; // 3. eventType 未注册时直接返回 error,避免消息静默落到默认表或默认 topic。 func (d *Repository) CreateMessage(ctx context.Context, eventType string, messageKey string, payload any, maxRetry int) (int64, error) { if d == nil || d.db == nil { @@ -109,8 +104,6 @@ func (d *Repository) GetByID(ctx context.Context, id int64) (*model.AgentOutboxM } // ListDueMessages 拉取到期可投递消息。 -// -// 说明: // 1. serviceName 为空时保持当前仓储目录内的扫描语义; // 2. serviceName 非空时只扫描对应服务的消息; // 3. 这样既能支持单服务 relay,也能支持后续多服务 relay。 @@ -134,7 +127,7 @@ func (d *Repository) ListDueMessages(ctx context.Context, serviceName string, li return messages, nil } -// MarkPublished 标记消息已成功投递到 Kafka。 +// MarkPublished 标记消息已经成功投递到 Kafka。 func (d *Repository) MarkPublished(ctx context.Context, id int64) error { now := time.Now() updates := map[string]interface{}{ @@ -150,7 +143,20 @@ func (d *Repository) MarkPublished(ctx context.Context, id int64) error { return result.Error } -// MarkDead 把消息标记为死信。 +// MarkConsumed 标记消息已经在处理侧成功完成。 +func (d *Repository) MarkConsumed(ctx context.Context, id int64) error { + now := time.Now() + updates := map[string]interface{}{ + "status": model.OutboxStatusConsumed, + "consumed_at": &now, + "last_error": nil, + "next_retry_at": nil, + "updated_at": now, + } + return d.scopedDB(ctx).Model(&model.AgentOutboxMessage{}).Where("id = ?", id).Updates(updates).Error +} + +// MarkDead 将消息标记为死信。 func (d *Repository) MarkDead(ctx context.Context, id int64, reason string) error { now := time.Now() lastErr := truncateError(reason) @@ -164,12 +170,10 @@ func (d *Repository) MarkDead(ctx context.Context, id int64, reason string) erro } // MarkFailedForRetry 记录一次可重试失败并推进重试窗口。 -// -// 步骤: // 1. 行级锁读取当前消息状态; -// 2. 已进入 consumed/dead 时幂等短路; -// 3. retry_count+1,并根据最大次数决定继续 pending 还是转 dead; -// 4. 写回 last_error 和 next_retry_at,交给下一轮扫描继续投递。 +// 2. consumed/dead 状态直接短路; +// 3. retry_count + 1,并根据最大次数决定继续 pending 还是转 dead; +// 4. 写回 last_error 与 next_retry_at,交给下一轮扫描继续投递。 func (d *Repository) MarkFailedForRetry(ctx context.Context, id int64, reason string) error { return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var msg model.AgentOutboxMessage @@ -207,11 +211,9 @@ func (d *Repository) MarkFailedForRetry(ctx context.Context, id int64, reason st } // ConsumeAndMarkConsumed 是通用“消费成功事务入口”。 -// -// 步骤: -// 1. 事务内锁定 outbox 记录; -// 2. consumed/dead 状态幂等返回; -// 3. 执行业务回调 fn(tx),让业务落库和 outbox 状态共用同一事务; +// 1. 在事务内锁定 outbox 记录; +// 2. consumed/dead 状态直接返回; +// 3. 执行业务回调 fn(tx),让业务落库和 outbox 状态共享同一事务; // 4. 业务成功后统一标记 consumed。 func (d *Repository) ConsumeAndMarkConsumed(ctx context.Context, outboxID int64, fn func(tx *gorm.DB) error) error { return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -245,6 +247,32 @@ func (d *Repository) ConsumeAndMarkConsumed(ctx context.Context, outboxID int64, }) } +// ConsumeInTx 执行 outbox 业务事务,但不负责标记 consumed。 +// 1. 先锁定当前 outbox 记录,避免并发消费者同时处理同一条消息; +// 2. 只要业务函数返回错误,就保持消息为 pending,交给上层 retry; +// 3. 业务成功后再由上层单独标记 consumed,这样可以把远端 RPC 移到事务外。 +func (d *Repository) ConsumeInTx(ctx context.Context, outboxID int64, fn func(tx *gorm.DB) error) error { + return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var outboxMsg model.AgentOutboxMessage + err := tx.Table(d.tableName()).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", outboxID).First(&outboxMsg).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil + } + return err + } + if outboxMsg.Status == model.OutboxStatusConsumed || outboxMsg.Status == model.OutboxStatusDead { + return nil + } + if fn != nil { + if err = fn(tx); err != nil { + return err + } + } + return nil + }) +} + func (d *Repository) scopedDB(ctx context.Context) *gorm.DB { return d.db.WithContext(ctx).Table(d.tableName()) } @@ -285,10 +313,10 @@ func calcRetryBackoff(retryCount int) time.Duration { if retryCount <= 0 { return time.Second } - if retryCount > 6 { - retryCount = 6 + if retryCount > 10 { + retryCount = 10 } - return time.Second * time.Duration(1<<(retryCount-1)) + return time.Duration(retryCount*retryCount) * time.Second } func truncateError(reason string) string { diff --git a/backend/inits/mysql.go b/backend/inits/mysql.go index fa2cb1d..b3cd5e2 100644 --- a/backend/inits/mysql.go +++ b/backend/inits/mysql.go @@ -11,9 +11,14 @@ import ( "gorm.io/gorm" ) -func autoMigrateModels(db *gorm.DB) error { +// autoMigrateCoreModels 只迁移仍留在当前单体进程内的业务表。 +// +// 职责边界: +// 1. 负责 agent / task / schedule / memory / notification 等尚未独立拆出的表; +// 2. 不负责 users、JWT、黑名单、token 额度等 user/auth 领域表; +// 3. user/auth 表由 cmd/userauth 进程在自己的 DAO 初始化阶段迁移,避免 all 启动时跨服务碰核心用户表。 +func autoMigrateCoreModels(db *gorm.DB) error { models := []any{ - &model.User{}, &model.AgentChat{}, &model.ChatHistory{}, &model.AgentTimelineEvent{}, @@ -92,7 +97,13 @@ func backfillAutoMigrateData(db *gorm.DB) error { return nil } -func ConnectDB() (*gorm.DB, error) { +// OpenDBFromConfig 只按配置创建 MySQL 连接,不执行任何自动迁移。 +// +// 职责边界: +// 1. 负责把 viper 中的 database 配置转换成 *gorm.DB; +// 2. 不负责选择要迁移哪些模型,迁移入口必须由具体服务显式调用; +// 3. 调用方负责决定这是单体残留域、user/auth 还是后续新服务的连接。 +func OpenDBFromConfig() (*gorm.DB, error) { host := viper.GetString("database.host") port := viper.GetString("database.port") user := viper.GetString("database.user") @@ -108,8 +119,35 @@ func ConnectDB() (*gorm.DB, error) { if err != nil { return nil, err } + return db, nil +} - if err = autoMigrateModels(db); err != nil { +// AutoMigrateCoreStorage 执行当前单体残留域拥有的 schema 初始化。 +// +// 职责边界: +// 1. 只迁移当前 all/api/worker 仍直接拥有的表和这些域的 outbox 表; +// 2. 不迁移 userauth.User,避免 gateway/all 在阶段 2 之后继续直接管理用户核心表; +// 3. 回填逻辑仍保留在当前域内,因为 schedule_events 仍属于单体残留域。 +func AutoMigrateCoreStorage(db *gorm.DB) error { + if err := autoMigrateCoreModels(db); err != nil { + return err + } + return nil +} + +// ConnectCoreDB 创建当前单体残留域的 MySQL 连接,并执行该域自己的迁移。 +// +// 迁移期约束: +// 1. all/api/worker 仍需要这条入口来承载尚未拆出的业务域; +// 2. 已拆出的 user/auth 不再通过这里迁移; +// 3. 后续每拆出一个服务,就从 autoMigrateCoreModels 中移走对应模型。 +func ConnectCoreDB() (*gorm.DB, error) { + db, err := OpenDBFromConfig() + if err != nil { + return nil, err + } + + if err = AutoMigrateCoreStorage(db); err != nil { return nil, err } @@ -117,3 +155,8 @@ func ConnectDB() (*gorm.DB, error) { log.Println("Database auto migration completed") return db, nil } + +// ConnectDB 保留历史兼容入口,新的装配代码应优先调用 ConnectCoreDB。 +func ConnectDB() (*gorm.DB, error) { + return ConnectCoreDB() +} diff --git a/backend/inits/redis.go b/backend/inits/redis.go index 46a4c99..89abfc5 100644 --- a/backend/inits/redis.go +++ b/backend/inits/redis.go @@ -8,14 +8,33 @@ import ( "github.com/spf13/viper" ) -func InitRedis() *redis.Client { +// OpenRedisFromConfig 只创建 Redis client 并做连通性校验。 +// +// 职责边界: +// 1. 负责当前调用方声明需要 Redis 时的连接初始化; +// 2. 不承载 user/auth 黑名单、token 额度等业务语义,那些语义已经收进 userauth 服务; +// 3. 返回 error 给服务入口统一处理,避免基础设施包直接 log.Fatal 终止进程。 +func OpenRedisFromConfig() (*redis.Client, error) { rdb := redis.NewClient(&redis.Options{ Addr: viper.GetString("redis.host") + ":" + viper.GetString("redis.port"), Password: viper.GetString("redis.password"), DB: 0, }) - // 检查连接是否通畅 if _, err := rdb.Ping(context.Background()).Result(); err != nil { + return nil, err + } + return rdb, nil +} + +// InitCoreRedis 初始化当前单体残留域使用的 Redis 连接。 +func InitCoreRedis() (*redis.Client, error) { + return OpenRedisFromConfig() +} + +// InitRedis 保留历史兼容入口,新的装配代码应优先使用 InitCoreRedis。 +func InitRedis() *redis.Client { + rdb, err := InitCoreRedis() + if err != nil { log.Fatalf("Redis 连接失败: %v", err) } return rdb diff --git a/backend/middleware/cache_deleter.go b/backend/middleware/cache_deleter.go index 4a81a8d..3c07ae3 100644 --- a/backend/middleware/cache_deleter.go +++ b/backend/middleware/cache_deleter.go @@ -81,7 +81,6 @@ func (p *GormCachePlugin) dispatchCacheLogic(modelObj interface{}) { // 3. 若 UserID 为 0(无 userID 参数的 repo 方法),invalidMemoryPrefetchCache 内部守卫会直接跳过。 p.invalidMemoryPrefetchCache(m.UserID) case model.AgentOutboxMessage, - model.User, model.ChatHistory, model.AgentChat, model.AgentTimelineEvent, diff --git a/backend/middleware/token_handler.go b/backend/middleware/token_handler.go deleted file mode 100644 index f0f2040..0000000 --- a/backend/middleware/token_handler.go +++ /dev/null @@ -1,103 +0,0 @@ -package middleware - -import ( - "errors" - "net/http" - "strings" - - "github.com/LoveLosita/smartflow/backend/auth" - "github.com/LoveLosita/smartflow/backend/dao" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/respond" - "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v4" -) - -// extractTokenFromAuthorization 负责解析 Authorization 头中的 token。 -// 职责边界: -// 1. 兼容“裸 token”和“Bearer ”两种传参方式。 -// 2. 不负责 token 合法性校验,只做字符串提取。 -// 3. 输入输出语义:header 为空或格式非法时返回空字符串。 -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 的鉴权拦截。 -// 职责边界: -// 1. 负责解析 token、验签、校验 token_type 与黑名单状态。 -// 2. 不负责签发 token,也不负责用户登录逻辑。 -// 3. 输出语义:校验通过时写入 user_id/claims 到上下文并放行;失败则中断请求。 -func JWTTokenAuth(cache *dao.CacheDAO) gin.HandlerFunc { - return func(c *gin.Context) { - tokenString := extractTokenFromAuthorization(c.GetHeader("Authorization")) - if tokenString == "" { - c.JSON(http.StatusUnauthorized, respond.MissingToken) - c.Abort() - return - } - - accessKey, err := auth.AccessSigningKey() - if err != nil { - c.JSON(http.StatusInternalServerError, respond.InternalError(err)) - c.Abort() - return - } - - // 1. 先验签并由 jwt 库统一校验 exp 等标准声明。 - token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) { - if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { - return nil, respond.InvalidTokenSingingMethod - } - return accessKey, nil - }) - if err != nil || !token.Valid { - c.JSON(http.StatusUnauthorized, respond.InvalidToken) - c.Abort() - return - } - - // 2. 再做业务声明校验,防止 refresh token 越权访问业务接口。 - claims, ok := token.Claims.(*model.MyCustomClaims) - if !ok { - c.JSON(http.StatusUnauthorized, respond.InvalidClaims) - c.Abort() - return - } - if claims.TokenType != "access_token" { - c.JSON(http.StatusUnauthorized, respond.WrongTokenType) - c.Abort() - return - } - - // 3. 最后查黑名单,兜住“用户已登出但 token 仍未到期”的场景。 - isBlack, err := cache.IsBlacklisted(claims.Jti) - if err != nil { - c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("无法验证令牌状态"))) - c.Abort() - return - } - if isBlack { - c.JSON(http.StatusUnauthorized, respond.UserLoggedOut) - c.Abort() - return - } - - c.Set("user_id", claims.UserID) - c.Set("claims", claims) - c.Next() - } -} diff --git a/backend/middleware/token_quota_guard.go b/backend/middleware/token_quota_guard.go deleted file mode 100644 index d920766..0000000 --- a/backend/middleware/token_quota_guard.go +++ /dev/null @@ -1,184 +0,0 @@ -package middleware - -import ( - "errors" - "log" - "net/http" - "time" - - "github.com/LoveLosita/smartflow/backend/dao" - "github.com/LoveLosita/smartflow/backend/respond" - "github.com/gin-gonic/gin" -) - -const ( - // userTokenResetInterval 是“用户 token 周期重置”的窗口长度。 - // 当前按需求设置为 7 天。 - userTokenResetInterval = 7 * 24 * time.Hour - - // userTokenQuotaSnapshotTTL 是额度快照缓存时长。 - // 说明:该值越大,读 DB 越少;但“超额生效”的最坏延迟会变长。 - userTokenQuotaSnapshotTTL = 60 * time.Second - - // minUserTokenBlockTTL 是封禁键的最小 TTL,避免出现 0/负数导致“刚封就失效”。 - minUserTokenBlockTTL = 30 * time.Second -) - -// TokenQuotaGuard 在请求入口做“token 额度门禁 + 懒重置”。 -// -// 职责边界: -// 1. 负责在进入业务 Handler 前判断“该用户是否还能继续消费 token”; -// 2. 负责按 7 天窗口执行懒重置(只有访问时才判断是否重置); -// 3. 负责维护 Redis 快照与封禁键,降低每次请求都查库的成本; -// 4. 不负责 token 累加记账(记账由聊天持久化链路负责)。 -func TokenQuotaGuard(cache *dao.CacheDAO, userRepo *dao.UserDAO) gin.HandlerFunc { - return func(c *gin.Context) { - // 1. 基础依赖判空: - // 1.1 若中间件依赖未初始化,直接返回 500,避免出现“无门禁放行”的安全漏洞; - // 1.2 这里选择 fail-close(拒绝),因为该中间件是额度治理主入口。 - if cache == nil || userRepo == nil { - c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token quota guard dependencies not initialized"))) - c.Abort() - return - } - - // 2. 从 JWT 中间件上下文获取 user_id: - // 2.1 若 user_id 非法,说明鉴权链路异常,直接按未授权拦截; - // 2.2 这里不尝试兜底查 token,避免重复实现鉴权逻辑。 - userID := c.GetInt("user_id") - if userID <= 0 { - c.JSON(http.StatusUnauthorized, respond.ErrUnauthorized) - c.Abort() - return - } - - ctx := c.Request.Context() - now := time.Now() - - // 3. 快速封禁检查(Redis): - // 3.1 命中封禁键直接拒绝,避免每次都查 DB; - // 3.2 Redis 查询失败时不立即放行,而是继续走 DB 严格校验,保证安全性。 - blocked, blockedErr := cache.IsUserTokenBlocked(ctx, userID) - if blockedErr != nil { - log.Printf("TokenQuotaGuard: 查询封禁键失败 user_id=%d err=%v,回退 DB 校验", userID, blockedErr) - } else if blocked { - c.JSON(http.StatusBadRequest, respond.TokenUsageExceedsLimit) - c.Abort() - return - } - - // 4. 优先尝试走快照快速路径: - // 4.1 命中快照且未到重置窗口时,直接用快照判断; - // 4.2 快照未命中/已到重置窗口/读取失败,则回源 DB 做权威判断。 - snapshot, hit, snapshotErr := cache.GetUserTokenQuotaSnapshot(ctx, userID) - if snapshotErr != nil { - log.Printf("TokenQuotaGuard: 读取额度快照失败 user_id=%d err=%v,回退 DB 校验", userID, snapshotErr) - } - if hit && snapshot != nil && !isResetDue(snapshot.LastResetAt, now) { - if snapshot.TokenUsage > snapshot.TokenLimit { - // 4.3 快照判断超额时,顺手写入封禁键,后续请求可 O(1) 拦截。 - ttl := calcBlockTTL(snapshot.LastResetAt, now) - if err := cache.SetUserTokenBlocked(ctx, userID, ttl); err != nil { - log.Printf("TokenQuotaGuard: 写入封禁键失败 user_id=%d err=%v", userID, err) - } - c.JSON(http.StatusBadRequest, respond.TokenUsageExceedsLimit) - c.Abort() - return - } - - // 4.4 快照命中且未超额,直接放行,避免本次请求访问 DB。 - c.Next() - return - } - - // 5. 回源 DB(权威判断路径): - // 5.1 先读取用户额度字段; - // 5.2 若已到重置窗口,执行条件更新懒重置,再回读最新值; - // 5.3 最后依据“token_usage > token_limit”判断是否拦截。 - quota, err := userRepo.GetUserTokenQuotaByID(ctx, userID) - if err != nil { - log.Printf("TokenQuotaGuard: 查询用户额度失败 user_id=%d err=%v", userID, err) - c.JSON(http.StatusInternalServerError, respond.InternalError(err)) - c.Abort() - return - } - - if isResetDue(quota.LastResetAt, now) { - _, resetErr := userRepo.ResetUserTokenUsageIfDue(ctx, userID, now.Add(-userTokenResetInterval), now) - if resetErr != nil { - log.Printf("TokenQuotaGuard: 懒重置失败 user_id=%d err=%v", userID, resetErr) - c.JSON(http.StatusInternalServerError, respond.InternalError(resetErr)) - c.Abort() - return - } - - // 5.2.1 重置后回读一次最新额度,避免使用旧值继续判断; - // 5.2.2 同时主动清理封禁键,防止“已重置仍被封”的残留状态。 - quota, err = userRepo.GetUserTokenQuotaByID(ctx, userID) - if err != nil { - log.Printf("TokenQuotaGuard: 重置后回读失败 user_id=%d err=%v", userID, err) - c.JSON(http.StatusInternalServerError, respond.InternalError(err)) - c.Abort() - return - } - if delErr := cache.DeleteUserTokenBlocked(ctx, userID); delErr != nil { - log.Printf("TokenQuotaGuard: 清理封禁键失败 user_id=%d err=%v", userID, delErr) - } - } - - // 6. 把最新权威值回填快照: - // 6.1 回填失败不影响主流程(仅影响性能,不影响正确性); - // 6.2 这样后续同用户短时间内请求可直接走快照快速路径。 - if setErr := cache.SetUserTokenQuotaSnapshot(ctx, userID, dao.UserTokenQuotaSnapshot{ - TokenLimit: quota.TokenLimit, - TokenUsage: quota.TokenUsage, - LastResetAt: quota.LastResetAt, - }, userTokenQuotaSnapshotTTL); setErr != nil { - log.Printf("TokenQuotaGuard: 回填额度快照失败 user_id=%d err=%v", userID, setErr) - } - - // 7. 最终判定: - // 7.1 按你的规则使用“>”判断超额(等于不拦截); - // 7.2 超额时写封禁键并拒绝;未超额则继续放行。 - if quota.TokenUsage > quota.TokenLimit { - ttl := calcBlockTTL(quota.LastResetAt, now) - if err = cache.SetUserTokenBlocked(ctx, userID, ttl); err != nil { - log.Printf("TokenQuotaGuard: 写入封禁键失败 user_id=%d err=%v", userID, err) - } - c.JSON(http.StatusBadRequest, respond.TokenUsageExceedsLimit) - c.Abort() - return - } - - c.Next() - } -} - -// isResetDue 判断“是否到达 7 天懒重置窗口”。 -// -// 说明: -// 1. lastResetAt 为零值时,视为到期(首次迁移数据时兜底); -// 2. now - lastResetAt >= 7 天 时返回 true。 -func isResetDue(lastResetAt time.Time, now time.Time) bool { - if lastResetAt.IsZero() { - return true - } - return !lastResetAt.Add(userTokenResetInterval).After(now) -} - -// calcBlockTTL 计算封禁键 TTL。 -// -// 规则: -// 1. 目标是封到“下一次重置时间”; -// 2. 若计算结果非正数,回退到最小 TTL,避免封禁键瞬时失效。 -func calcBlockTTL(lastResetAt time.Time, now time.Time) time.Duration { - if lastResetAt.IsZero() { - return minUserTokenBlockTTL - } - nextResetAt := lastResetAt.Add(userTokenResetInterval) - ttl := nextResetAt.Sub(now) - if ttl <= 0 { - return minUserTokenBlockTTL - } - return ttl -} diff --git a/backend/model/agent.go b/backend/model/agent.go index 75d6e91..1de0e26 100644 --- a/backend/model/agent.go +++ b/backend/model/agent.go @@ -255,22 +255,24 @@ type SSEMessageData struct { } type AgentChat struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:自增ID"` - ChatID string `gorm:"column:chat_id;type:varchar(36);not null;uniqueIndex:uk_chat_id;comment:会话UUID"` - UserID int `gorm:"column:user_id;not null;index:idx_user_last,priority:1;index:idx_user_status,priority:1;comment:所属用户ID"` - Title *string `gorm:"column:title;type:varchar(255);comment:会话标题"` - SystemPrompt *string `gorm:"column:system_prompt;type:text;comment:系统提示词"` - Model *string `gorm:"column:model;type:varchar(100);comment:模型标识"` - MessageCount int `gorm:"column:message_count;not null;default:0;comment:消息总数"` - TokensTotal int `gorm:"column:tokens_total;not null;default:0;comment:累计Token"` - LastMessageAt *time.Time `gorm:"column:last_message_at;comment:最后消息时间"` - Status string `gorm:"column:status;type:varchar(32);not null;default:active;index:idx_user_status,priority:2;comment:会话状态"` - CompactionSummary *string `gorm:"column:compaction_summary;type:text;comment:历史上下文压缩摘要"` - CompactionWatermark int `gorm:"column:compaction_watermark;not null;default:0;comment:压缩水位线(最后被压缩的消息ID)"` - ContextTokenStats *string `gorm:"column:context_token_stats;type:json;comment:上下文窗口实时token分布"` - CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"` - UpdatedAt *time.Time `gorm:"column:updated_at;autoUpdateTime"` - DeletedAt *time.Time `gorm:"column:deleted_at;comment:软删除时间"` + ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:自增ID"` + ChatID string `gorm:"column:chat_id;type:varchar(36);not null;uniqueIndex:uk_chat_id;comment:会话UUID"` + UserID int `gorm:"column:user_id;not null;index:idx_user_last,priority:1;index:idx_user_status,priority:1;comment:所属用户ID"` + Title *string `gorm:"column:title;type:varchar(255);comment:会话标题"` + SystemPrompt *string `gorm:"column:system_prompt;type:text;comment:系统提示词"` + Model *string `gorm:"column:model;type:varchar(100);comment:模型标识"` + MessageCount int `gorm:"column:message_count;not null;default:0;comment:消息总数"` + TokensTotal int `gorm:"column:tokens_total;not null;default:0;comment:累计Token"` + LastHistoryEventID *string `gorm:"column:last_history_event_id;type:varchar(64);comment:最后一次聊天历史持久化事件ID"` + LastTokenAdjustEventID *string `gorm:"column:last_token_adjust_event_id;type:varchar(64);comment:最后一次会话token调整事件ID"` + LastMessageAt *time.Time `gorm:"column:last_message_at;comment:最后消息时间"` + Status string `gorm:"column:status;type:varchar(32);not null;default:active;index:idx_user_status,priority:2;comment:会话状态"` + CompactionSummary *string `gorm:"column:compaction_summary;type:text;comment:历史上下文压缩摘要"` + CompactionWatermark int `gorm:"column:compaction_watermark;not null;default:0;comment:压缩水位线(最后被压缩的消息ID)"` + ContextTokenStats *string `gorm:"column:context_token_stats;type:json;comment:上下文窗口实时token分布"` + CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"` + UpdatedAt *time.Time `gorm:"column:updated_at;autoUpdateTime"` + DeletedAt *time.Time `gorm:"column:deleted_at;comment:软删除时间"` } func (AgentChat) TableName() string { return "agent_chats" } @@ -279,6 +281,7 @@ type ChatHistory struct { ID int `gorm:"column:id;primaryKey;autoIncrement"` ChatID string `gorm:"column:chat_id;type:varchar(36);not null;index:idx_user_chat,priority:2;index:idx_chat_id;comment:会话UUID"` UserID int `gorm:"column:user_id;not null;index:idx_user_chat,priority:1"` + SourceEventID *string `gorm:"column:source_event_id;type:varchar(64);uniqueIndex:uk_chat_history_source_event;comment:来源事件ID"` MessageContent *string `gorm:"column:message_content;type:text;comment:消息内容"` ReasoningContent *string `gorm:"column:reasoning_content;type:text;comment:deep reasoning text"` ReasoningDurationSeconds int `gorm:"column:reasoning_duration_seconds;not null;default:0;comment:deep reasoning duration seconds"` diff --git a/backend/model/auth.go b/backend/model/auth.go deleted file mode 100644 index 5ada3bf..0000000 --- a/backend/model/auth.go +++ /dev/null @@ -1,15 +0,0 @@ -package model - -import "github.com/golang-jwt/jwt/v4" - -type Tokens struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` -} - -type MyCustomClaims struct { - UserID int `json:"user_id"` - TokenType string `json:"token_type"` - Jti string `json:"jti"` - jwt.RegisteredClaims // 包含 ExpiresAt, IssuedAt 等标准字段 -} diff --git a/backend/model/user.go b/backend/model/user.go deleted file mode 100644 index 8fb22a8..0000000 --- a/backend/model/user.go +++ /dev/null @@ -1,50 +0,0 @@ -// Package model 数据模型层 -// 定义所有数据结构和模型 -package model - -import ( - "time" -) - -// TableName 指定表名 -// 确保与数据库表名一致 -func (User) TableName() string { - return "users" -} - -// User 用户模型 -// 对应数据库中的users表 -type User struct { - // 增加 autoIncrement 标签,对应 SQL 的 AUTO_INCREMENT - ID uint `gorm:"primaryKey;autoIncrement" json:"id"` - // 增加 unique 和 not null,确保与数据库约束一致 - Username string `gorm:"type:varchar(255);not null;unique" json:"username"` - // Password 保持 json:"-" 是非常专业的做法,防止接口无意间泄露哈希值 - Password string `gorm:"type:varchar(255);not null" json:"password"` - PhoneNumber string `gorm:"type:varchar(255)" json:"phone_number"` - // 设定默认值,确保 GORM 在插入时能正确处理初始配额 - TokenLimit int `gorm:"default:100000" json:"token_limit"` - // 增加 default:0,防止出现 null 导致的解析问题 - TokenUsage int `gorm:"default:0" json:"token_usage"` - // LastResetAt 映射 timestamp - LastResetAt time.Time `gorm:"comment:上次周用量重置时间" json:"last_reset_at"` -} - -type UserRegisterRequest struct { - Username string `json:"username"` - Password string `json:"password"` - PhoneNumber string `json:"phone_number"` -} - -type UserRegisterResponse struct { - ID uint `json:"id"` -} - -type UserLoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` -} - -type UserLoginResponse struct { - Tokens -} diff --git a/backend/respond/respond.go b/backend/respond/respond.go index 145f6e3..cd6de2b 100644 --- a/backend/respond/respond.go +++ b/backend/respond/respond.go @@ -5,6 +5,7 @@ package respond import ( "errors" "net/http" + "strings" "github.com/gin-gonic/gin" ) @@ -24,6 +25,26 @@ func (r Response) Error() string { // 实现 error 接口 return r.Info } +// HTTPStatus 负责把项目内部响应码映射到 HTTP 状态码。 +// +// 职责边界: +// 1. 只根据当前响应码判断该返回 200/400/401/500 里的哪一类; +// 2. 不负责写响应体,也不负责区分具体业务来源; +// 3. 业务侧可以继续透传 Status/Info,HTTP 层只需要调用这个方法。 +func (r Response) HTTPStatus() int { + switch r.Status { + case MissingToken.Status, InvalidToken.Status, InvalidClaims.Status, InvalidRefreshToken.Status, + WrongTokenType.Status, UserLoggedOut.Status, ErrUnauthorized.Status: + return http.StatusUnauthorized + case UserTasksEmpty.Status, NoOngoingOrUpcomingSchedule.Status, TaskAlreadyDeleted.Status: + return http.StatusOK + } + if strings.HasPrefix(strings.TrimSpace(r.Status), "5") { + return http.StatusInternalServerError + } + return http.StatusBadRequest +} + func RespWithData(response Response, data interface{}) FinalResponse { //传入一个响应结构体和数据,返回一个最终响应结构体 var finalResponse FinalResponse finalResponse.Status = response.Status @@ -42,7 +63,7 @@ func DealWithError(c *gin.Context, err error) { //处理错误,返回对应的 return } if errors.As(err, &resp) { - c.JSON(http.StatusBadRequest, resp) + c.JSON(resp.HTTPStatus(), resp) return } c.JSON(http.StatusInternalServerError, InternalError(err)) diff --git a/backend/service/agentsvc/agent.go b/backend/service/agentsvc/agent.go index f50c2d4..9ac95fb 100644 --- a/backend/service/agentsvc/agent.go +++ b/backend/service/agentsvc/agent.go @@ -150,6 +150,7 @@ func (s *AgentService) PersistChatHistory(ctx context.Context, payload model.Cha payload.ReasoningContent, payload.ReasoningDurationSeconds, payload.TokensConsumed, + "", ) } // 2. 已启用异步总线时,只发布“持久化请求事件”,不在请求路径阻塞 Kafka。 diff --git a/backend/service/agentsvc/agent_meta.go b/backend/service/agentsvc/agent_meta.go index 4122308..b9a4dfd 100644 --- a/backend/service/agentsvc/agent_meta.go +++ b/backend/service/agentsvc/agent_meta.go @@ -218,7 +218,7 @@ func (s *AgentService) ensureConversationTitleAsync(userID int, chatID string) { log.Printf("异步标题 token 记账事件发布失败 chat=%s tokens=%d err=%v", chatID, titleTokens, publishErr) } } else { - if adjustErr := s.repo.AdjustTokenUsage(ctx, userID, chatID, titleTokens); adjustErr != nil { + if adjustErr := s.repo.AdjustTokenUsage(ctx, userID, chatID, titleTokens, ""); adjustErr != nil { log.Printf("异步标题 token 同步记账失败 chat=%s tokens=%d err=%v", chatID, titleTokens, adjustErr) } } diff --git a/backend/service/agentsvc/agent_newagent.go b/backend/service/agentsvc/agent_newagent.go index 1a3bdb3..c9ec166 100644 --- a/backend/service/agentsvc/agent_newagent.go +++ b/backend/service/agentsvc/agent_newagent.go @@ -570,7 +570,7 @@ func (s *AgentService) adjustNewAgentRequestTokenUsage(ctx context.Context, user return } - if err := s.repo.AdjustTokenUsage(ctx, userID, chatID, deltaTokens); err != nil { + if err := s.repo.AdjustTokenUsage(ctx, userID, chatID, deltaTokens, ""); err != nil { log.Printf("同步写入 newAgent 请求级 token 调整失败 chat=%s tokens=%d err=%v", chatID, deltaTokens, err) } } diff --git a/backend/service/events/chat_history_persist.go b/backend/service/events/chat_history_persist.go index 5d0300f..b87e317 100644 --- a/backend/service/events/chat_history_persist.go +++ b/backend/service/events/chat_history_persist.go @@ -4,37 +4,35 @@ import ( "context" "encoding/json" "errors" + "strconv" + "strings" "github.com/LoveLosita/smartflow/backend/dao" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" "github.com/LoveLosita/smartflow/backend/model" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" + "github.com/LoveLosita/smartflow/backend/shared/ports" "gorm.io/gorm" ) const ( - // EventTypeChatHistoryPersistRequested 是"聊天消息持久化请求"的业务事件类型。 - // - // 命名策略: - // 1. 只描述业务语义,不包含 outbox/kafka 等实现词; - // 2. 作为新路由键长期保留,后续协议变化优先走 event_version; - // 3. 旧路由键仅作兼容,不再作为新发布默认值。 + // EventTypeChatHistoryPersistRequested 是聊天消息持久化请求的业务事件类型。 EventTypeChatHistoryPersistRequested = "chat.history.persist.requested" ) -// RegisterChatHistoryPersistHandler 注册"聊天消息持久化"消费者处理器。 -// +// RegisterChatHistoryPersistHandler 注册“聊天消息持久化”消费者。 // 职责边界: -// 1. 只负责聊天事件,不处理其他业务事件; -// 2. 只负责注册,不负责总线启停; -// 3. 通过 outbox 通用事务入口把"业务写入 + consumed 推进"合并为一个事务; -// 4. 当前版本仅注册新路由键(chat.history.persist.requested),不再注册旧兼容键。 +// 1. 只处理聊天历史事件,不处理其它业务事件; +// 2. 只负责注册,不负责总线启动; +// 3. 先写本地 chat 相关表,再调用 userauth 调整 token 额度; +// 4. 当前版本仅注册新路由键,不再注册旧兼容键。 func RegisterChatHistoryPersistHandler( bus OutboxBus, outboxRepo *outboxinfra.Repository, repoManager *dao.RepoManager, + adjuster ports.TokenUsageAdjuster, ) error { - // 1. 依赖校验:任何一个关键依赖为空都无法安全处理消息。 if bus == nil { return errors.New("event bus is nil") } @@ -44,28 +42,26 @@ func RegisterChatHistoryPersistHandler( if repoManager == nil { return errors.New("repo manager is nil") } + eventOutboxRepo, err := scopedOutboxRepoForEvent(outboxRepo, EventTypeChatHistoryPersistRequested) if err != nil { return err } - // 2. 定义统一处理器: - // 2.1 解析 payload; - // 2.2 调用 outbox 通用消费事务; - // 2.3 在事务回调中复用 RepoManager.WithTx 执行业务 DAO 写入。 handler := func(ctx context.Context, envelope kafkabus.Envelope) error { var payload model.ChatHistoryPersistPayload if unmarshalErr := json.Unmarshal(envelope.Payload, &payload); unmarshalErr != nil { - // 2.1 payload 非法属于不可恢复错误,直接标 dead,避免无意义重试。 _ = eventOutboxRepo.MarkDead(ctx, envelope.OutboxID, "解析聊天持久化载荷失败: "+unmarshalErr.Error()) return nil } - // 2.2 使用 outbox 通用消费事务,保证"业务写入 + consumed 状态推进"原子一致。 - return eventOutboxRepo.ConsumeAndMarkConsumed(ctx, envelope.OutboxID, func(tx *gorm.DB) error { - // 2.2.1 基于同一个 tx 构造 RepoManager,复用你现有跨包事务模型。 + eventID := strings.TrimSpace(envelope.EventID) + if eventID == "" { + eventID = strconv.FormatInt(envelope.OutboxID, 10) + } + + if err := eventOutboxRepo.ConsumeInTx(ctx, envelope.OutboxID, func(tx *gorm.DB) error { txM := repoManager.WithTx(tx) - // 2.2.2 在同事务内写入聊天历史与会话计数。 return txM.Agent.SaveChatHistoryInTx( ctx, payload.UserID, @@ -75,24 +71,32 @@ func RegisterChatHistoryPersistHandler( payload.ReasoningContent, payload.ReasoningDurationSeconds, payload.TokensConsumed, + eventID, ) - }) + }); err != nil { + return err + } + + if payload.TokensConsumed > 0 { + if adjuster == nil { + return errors.New("userauth token adjuster is nil") + } + if _, err := adjuster.AdjustTokenUsage(ctx, contracts.AdjustTokenUsageRequest{ + EventID: eventID, + UserID: payload.UserID, + TokenDelta: payload.TokensConsumed, + }); err != nil { + return err + } + } + + return eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID) } - // 3. 注册新路由键(主路由)。 - if err := bus.RegisterEventHandler(EventTypeChatHistoryPersistRequested, handler); err != nil { - return err - } - - return nil + return bus.RegisterEventHandler(EventTypeChatHistoryPersistRequested, handler) } -// PublishChatHistoryPersistRequested 发布"聊天消息持久化请求"事件。 -// -// 设计目的: -// 1. 让业务层只传 DTO,不重复拼事件元数据; -// 2. 统一消息键策略(conversation_id 作为 MessageKey/AggregateID); -// 3. 发布失败时显式返回 error,由调用方决定是否降级到同步写库。 +// PublishChatHistoryPersistRequested 发布“聊天消息持久化请求”事件。 func PublishChatHistoryPersistRequested( ctx context.Context, publisher outboxinfra.EventPublisher, diff --git a/backend/service/events/chat_token_usage_adjust.go b/backend/service/events/chat_token_usage_adjust.go index 3e1fb0c..cc291d2 100644 --- a/backend/service/events/chat_token_usage_adjust.go +++ b/backend/service/events/chat_token_usage_adjust.go @@ -5,34 +5,36 @@ import ( "encoding/json" "errors" "strconv" + "strings" "time" "github.com/LoveLosita/smartflow/backend/dao" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox" "github.com/LoveLosita/smartflow/backend/model" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" + "github.com/LoveLosita/smartflow/backend/shared/ports" "gorm.io/gorm" ) const ( - // EventTypeChatTokenUsageAdjustRequested 是“会话 token 账本增量调整”事件类型。 - // + // EventTypeChatTokenUsageAdjustRequested 是“会话 token 额度调整”事件类型。 // 命名约束: - // 1. 仅表达业务语义,不泄露 outbox/kafka 实现细节; + // 1. 只表达业务语义,不泄露 outbox/kafka 实现细节; // 2. 作为稳定路由键长期保留,后续演进优先通过 event_version。 EventTypeChatTokenUsageAdjustRequested = "chat.token.usage.adjust.requested" ) -// RegisterChatTokenUsageAdjustHandler 注册“会话 token 账本增量调整”消费者。 -// +// RegisterChatTokenUsageAdjustHandler 注册“会话 token 额度调整”消费者。 // 职责边界: // 1. 只处理 token 调整事件,不处理聊天正文落库; -// 2. 通过 outbox 统一消费事务入口,保证“业务成功 + consumed 推进”原子一致; +// 2. 先写本地账本,再调用 userauth 侧做额度同步; // 3. 非法载荷直接标记 dead,避免无意义重试。 func RegisterChatTokenUsageAdjustHandler( bus OutboxBus, outboxRepo *outboxinfra.Repository, repoManager *dao.RepoManager, + adjuster ports.TokenUsageAdjuster, ) error { if bus == nil { return errors.New("event bus is nil") @@ -43,6 +45,7 @@ func RegisterChatTokenUsageAdjustHandler( if repoManager == nil { return errors.New("repo manager is nil") } + eventOutboxRepo, err := scopedOutboxRepoForEvent(outboxRepo, EventTypeChatTokenUsageAdjustRequested) if err != nil { return err @@ -60,20 +63,38 @@ func RegisterChatTokenUsageAdjustHandler( return nil } - return eventOutboxRepo.ConsumeAndMarkConsumed(ctx, envelope.OutboxID, func(tx *gorm.DB) error { + eventID := strings.TrimSpace(envelope.EventID) + if eventID == "" { + eventID = strconv.FormatInt(envelope.OutboxID, 10) + } + + if err := eventOutboxRepo.ConsumeInTx(ctx, envelope.OutboxID, func(tx *gorm.DB) error { txM := repoManager.WithTx(tx) - return txM.Agent.AdjustTokenUsageInTx(ctx, payload.UserID, payload.ConversationID, payload.TokensDelta) - }) + return txM.Agent.AdjustTokenUsageInTx(ctx, payload.UserID, payload.ConversationID, payload.TokensDelta, eventID) + }); err != nil { + return err + } + + if adjuster == nil { + return errors.New("userauth token adjuster is nil") + } + if _, err := adjuster.AdjustTokenUsage(ctx, contracts.AdjustTokenUsageRequest{ + EventID: eventID, + UserID: payload.UserID, + TokenDelta: payload.TokensDelta, + }); err != nil { + return err + } + + return eventOutboxRepo.MarkConsumed(ctx, envelope.OutboxID) } return bus.RegisterEventHandler(EventTypeChatTokenUsageAdjustRequested, handler) } -// PublishChatTokenUsageAdjustRequested 发布“会话 token 账本增量调整”事件。 -// -// 说明: -// 1. 只保证“写入 outbox 成功”,不等待消费完成; -// 2. 业务层只传 DTO,不关心 outbox/kafka 协议细节。 +// PublishChatTokenUsageAdjustRequested 发布“会话 token 额度调整”事件。 +// 1. 这里只保证 outbox 写入成功,不等待消费结果; +// 2. 业务层只关心 DTO,不关心 outbox/Kafka 细节。 func PublishChatTokenUsageAdjustRequested( ctx context.Context, publisher outboxinfra.EventPublisher, diff --git a/backend/service/events/core_outbox_handlers.go b/backend/service/events/core_outbox_handlers.go index bd89922..90647dd 100644 --- a/backend/service/events/core_outbox_handlers.go +++ b/backend/service/events/core_outbox_handlers.go @@ -8,6 +8,7 @@ import ( "github.com/LoveLosita/smartflow/backend/memory" "github.com/LoveLosita/smartflow/backend/notification" sharedevents "github.com/LoveLosita/smartflow/backend/shared/events" + "github.com/LoveLosita/smartflow/backend/shared/ports" ) // RegisterCoreOutboxHandlers 注册核心业务 outbox handler。 @@ -24,12 +25,13 @@ func RegisterCoreOutboxHandlers( agentRepo *dao.AgentDAO, cacheRepo *dao.CacheDAO, memoryModule *memory.Module, + adjuster ports.TokenUsageAdjuster, ) error { if err := validateCoreOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule); err != nil { return err } - return registerOutboxHandlerRoutes(coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule)) + return registerOutboxHandlerRoutes(coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, adjuster)) } // RegisterAllOutboxHandlers 注册当前阶段所有 outbox handler。 @@ -47,6 +49,7 @@ func RegisterAllOutboxHandlers( memoryModule *memory.Module, activeTriggerWorkflow ActiveScheduleTriggeredProcessor, notificationService *notification.NotificationService, + adjuster ports.TokenUsageAdjuster, ) error { if err := validateAllOutboxHandlerDeps(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, activeTriggerWorkflow, notificationService); err != nil { return err @@ -61,6 +64,7 @@ func RegisterAllOutboxHandlers( memoryModule, activeTriggerWorkflow, notificationService, + adjuster, )) } @@ -129,13 +133,14 @@ func coreOutboxHandlerRoutes( agentRepo *dao.AgentDAO, cacheRepo *dao.CacheDAO, memoryModule *memory.Module, + adjuster ports.TokenUsageAdjuster, ) []outboxHandlerRoute { return []outboxHandlerRoute{ { EventType: EventTypeChatHistoryPersistRequested, Service: outboxHandlerServiceAgent, Register: func() error { - return RegisterChatHistoryPersistHandler(eventBus, outboxRepo, repoManager) + return RegisterChatHistoryPersistHandler(eventBus, outboxRepo, repoManager, adjuster) }, }, { @@ -149,7 +154,7 @@ func coreOutboxHandlerRoutes( EventType: EventTypeChatTokenUsageAdjustRequested, Service: outboxHandlerServiceAgent, Register: func() error { - return RegisterChatTokenUsageAdjustHandler(eventBus, outboxRepo, repoManager) + return RegisterChatTokenUsageAdjustHandler(eventBus, outboxRepo, repoManager, adjuster) }, }, { @@ -186,8 +191,9 @@ func allOutboxHandlerRoutes( memoryModule *memory.Module, activeTriggerWorkflow ActiveScheduleTriggeredProcessor, notificationService *notification.NotificationService, + adjuster ports.TokenUsageAdjuster, ) []outboxHandlerRoute { - routes := coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule) + routes := coreOutboxHandlerRoutes(eventBus, outboxRepo, repoManager, agentRepo, cacheRepo, memoryModule, adjuster) routes = append(routes, outboxHandlerRoute{ EventType: sharedevents.ActiveScheduleTriggeredEventType, diff --git a/backend/service/schedule.go b/backend/service/schedule.go index 6d15036..7f30cd7 100644 --- a/backend/service/schedule.go +++ b/backend/service/schedule.go @@ -18,16 +18,14 @@ import ( type ScheduleService struct { scheduleDAO *dao.ScheduleDAO - userDAO *dao.UserDAO taskClassDAO *dao.TaskClassDAO repoManager *dao.RepoManager // 统一管理多个 DAO 的事务 cacheDAO *dao.CacheDAO // 需要在 ScheduleService 中使用缓存 } -func NewScheduleService(scheduleDAO *dao.ScheduleDAO, userDAO *dao.UserDAO, taskClassDAO *dao.TaskClassDAO, repoManager *dao.RepoManager, cacheDAO *dao.CacheDAO) *ScheduleService { +func NewScheduleService(scheduleDAO *dao.ScheduleDAO, taskClassDAO *dao.TaskClassDAO, repoManager *dao.RepoManager, cacheDAO *dao.CacheDAO) *ScheduleService { return &ScheduleService{ scheduleDAO: scheduleDAO, - userDAO: userDAO, taskClassDAO: taskClassDAO, repoManager: repoManager, cacheDAO: cacheDAO, @@ -35,14 +33,6 @@ func NewScheduleService(scheduleDAO *dao.ScheduleDAO, userDAO *dao.UserDAO, task } func (ss *ScheduleService) GetUserTodaySchedule(ctx context.Context, userID int) ([]model.UserTodaySchedule, error) { - //1.先检查用户id是否存在(考虑移除) - /*_, err := ss.userDAO.GetUserByID(userID) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, respond.WrongUserID - } - return nil, err - }*/ //1.先尝试从缓存获取数据 cachedResp, err := ss.cacheDAO.GetUserTodayScheduleFromCache(ctx, userID) if err == nil { diff --git a/backend/service/user.go b/backend/service/user.go deleted file mode 100644 index f1a9fd1..0000000 --- a/backend/service/user.go +++ /dev/null @@ -1,123 +0,0 @@ -// Package service 业务逻辑层 -// 包含所有核心业务逻辑 -package service - -import ( - "errors" - "time" - - "context" - - "github.com/LoveLosita/smartflow/backend/auth" - "github.com/LoveLosita/smartflow/backend/dao" - "github.com/LoveLosita/smartflow/backend/model" - "github.com/LoveLosita/smartflow/backend/respond" - "github.com/LoveLosita/smartflow/backend/utils" - "gorm.io/gorm" -) - -type UserService struct { - userRepo *dao.UserDAO - cacheRepo *dao.CacheDAO -} - -func NewUserService(userRepo *dao.UserDAO, cacheRepo *dao.CacheDAO) *UserService { - return &UserService{ - userRepo: userRepo, // 把传进来的 DAO 揣进口袋里 - cacheRepo: cacheRepo, - } -} - -func (sv *UserService) UserRegister(ctx context.Context, user model.UserRegisterRequest) (*model.UserRegisterResponse, error) { - //检查是否有空字段 - if user.Username == "" || user.Password == "" || - user.PhoneNumber == "" { - return nil, respond.MissingParam - } - // 检查字段长度是否超过90% - if len(user.Username) > 45 || len(user.Password) > 229 || len(user.PhoneNumber) > 18 { - return nil, respond.ParamTooLong - } - //检查用户名是否已存在 - result, err := sv.userRepo.IfUsernameExists(user.Username) - if err != nil { - return nil, err - } - if result { - return nil, respond.InvalidName - } - hashedPwd, err := utils.HashPassword(user.Password) //调用utils层的方法 - if err != nil { - return nil, err - } - user.Password = hashedPwd //将user的密码字段改为加密后的密码 - newUser, err := sv.userRepo.Create(user.Username, user.PhoneNumber, user.Password) - if err != nil { - return nil, err - } - //返回注册成功的用户ID - return &model.UserRegisterResponse{ID: newUser.ID}, nil -} - -func (sv *UserService) UserLogin(ctx context.Context, req *model.UserLoginRequest) (*model.Tokens, error) { - var tokens model.Tokens - hashedPwd, err := sv.userRepo.GetUserHashedPasswordByName(req.Username) //调用dao层的方法 - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, respond.WrongName - } - return nil, err - } - result, err := utils.CompareHashPwdAndPwd(hashedPwd, req.Password) //比较密码是否匹配 - if err != nil { //其他错误 - return &tokens, err - } else if !result { //密码不匹配 - return nil, respond.WrongPwd - } - id, err := sv.userRepo.GetUserIDByName(req.Username) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, respond.WrongName - } - return nil, err - } - tokens.AccessToken, tokens.RefreshToken, err = auth.GenerateTokens(id) //生成jwt key - if err != nil { //其他错误 - return nil, err - } - return &tokens, nil -} - -func (sv *UserService) RefreshTokenHandler(ctx context.Context, refreshToken string) (*model.Tokens, error) { - // 1. 验证刷新令牌 (这里已经包含了 Redis 黑名单检查) - token, err := auth.ValidateRefreshToken(refreshToken, sv.cacheRepo) - if err != nil { - return nil, err - } - - // 2. 改动点:直接断言为你定义的结构体 model.MyCustomClaims - if claims, ok := token.Claims.(*model.MyCustomClaims); ok { - // 3. 这里的 userID 已经是 int 了,不再需要 (float64) 转换 - newAccessToken, newRefreshToken, err := auth.GenerateTokens(claims.UserID) - if err != nil { - return nil, err - } - // 返回新的双 Token - return &model.Tokens{ - AccessToken: newAccessToken, - RefreshToken: newRefreshToken, - }, nil - } - - return nil, respond.InvalidClaims -} - -func (sv *UserService) UserLogout(ctx context.Context, jti string, expireTime time.Time) error { - //1.直接把 jti 扔进黑名单 - expiration := time.Until(expireTime) - err := sv.cacheRepo.SetBlacklist(jti, expiration) - if err != nil { - return err - } - return nil -} diff --git a/backend/services/userauth/dao/cache.go b/backend/services/userauth/dao/cache.go new file mode 100644 index 0000000..f255593 --- /dev/null +++ b/backend/services/userauth/dao/cache.go @@ -0,0 +1,130 @@ +package dao + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "github.com/go-redis/redis/v8" +) + +// TokenQuotaSnapshot 是 user/auth 服务内部的额度快照缓存结构。 +type TokenQuotaSnapshot struct { + TokenLimit int `json:"token_limit"` + TokenUsage int `json:"token_usage"` + LastResetAt time.Time `json:"last_reset_at"` +} + +// CacheDAO 只承载 user/auth 领域需要的 Redis 能力。 +type CacheDAO struct { + client *redis.Client +} + +func NewCacheDAO(client *redis.Client) *CacheDAO { + return &CacheDAO{client: client} +} + +func blacklistKey(jti string) string { + return "blacklist:" + jti +} + +func sessionBlacklistKey(sessionID string) string { + return "session_blacklist:" + sessionID +} + +func userTokenQuotaSnapshotKey(userID int) string { + return fmt.Sprintf("smartflow:user_token_quota_snapshot:%d", userID) +} + +func userTokenBlockedKey(userID int) string { + return fmt.Sprintf("smartflow:user_token_blocked:%d", userID) +} + +func (d *CacheDAO) SetBlacklist(jti string, expiration time.Duration) error { + return d.client.Set(context.Background(), blacklistKey(jti), "1", expiration).Err() +} + +// SetBlacklistIfAbsent 使用 Redis SET NX 原子抢占某个 JTI。 +// +// 职责边界: +// 1. 用于 refresh token 轮转时保证旧 refresh 只能被消费一次; +// 2. 返回 ok=false 表示该 JTI 已经被其它请求消费过; +// 3. 不负责解析 JWT,也不负责判断 token 类型。 +func (d *CacheDAO) SetBlacklistIfAbsent(jti string, expiration time.Duration) (bool, error) { + return d.client.SetNX(context.Background(), blacklistKey(jti), "1", expiration).Result() +} + +func (d *CacheDAO) IsBlacklisted(jti string) (bool, error) { + result, err := d.client.Get(context.Background(), blacklistKey(jti)).Result() + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, err + } + return result == "1", nil +} + +func (d *CacheDAO) SetSessionBlacklist(sessionID string, expiration time.Duration) error { + return d.client.Set(context.Background(), sessionBlacklistKey(sessionID), "1", expiration).Err() +} + +func (d *CacheDAO) IsSessionBlacklisted(sessionID string) (bool, error) { + result, err := d.client.Get(context.Background(), sessionBlacklistKey(sessionID)).Result() + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, err + } + return result == "1", nil +} + +func (d *CacheDAO) GetUserTokenQuotaSnapshot(ctx context.Context, userID int) (*TokenQuotaSnapshot, bool, error) { + val, err := d.client.Get(ctx, userTokenQuotaSnapshotKey(userID)).Result() + if errors.Is(err, redis.Nil) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + + var snapshot TokenQuotaSnapshot + if err = json.Unmarshal([]byte(val), &snapshot); err != nil { + return nil, false, err + } + return &snapshot, true, nil +} + +func (d *CacheDAO) SetUserTokenQuotaSnapshot(ctx context.Context, userID int, snapshot TokenQuotaSnapshot, ttl time.Duration) error { + data, err := json.Marshal(snapshot) + if err != nil { + return err + } + return d.client.Set(ctx, userTokenQuotaSnapshotKey(userID), data, ttl).Err() +} + +func (d *CacheDAO) DeleteUserTokenQuotaSnapshot(ctx context.Context, userID int) error { + return d.client.Del(ctx, userTokenQuotaSnapshotKey(userID)).Err() +} + +func (d *CacheDAO) IsUserTokenBlocked(ctx context.Context, userID int) (bool, error) { + result, err := d.client.Get(ctx, userTokenBlockedKey(userID)).Result() + if errors.Is(err, redis.Nil) { + return false, nil + } + if err != nil { + return false, err + } + return result == "1", nil +} + +func (d *CacheDAO) SetUserTokenBlocked(ctx context.Context, userID int, ttl time.Duration) error { + return d.client.Set(ctx, userTokenBlockedKey(userID), "1", ttl).Err() +} + +func (d *CacheDAO) DeleteUserTokenBlocked(ctx context.Context, userID int) error { + return d.client.Del(ctx, userTokenBlockedKey(userID)).Err() +} diff --git a/backend/services/userauth/dao/connect.go b/backend/services/userauth/dao/connect.go new file mode 100644 index 0000000..e15c5de --- /dev/null +++ b/backend/services/userauth/dao/connect.go @@ -0,0 +1,55 @@ +package dao + +import ( + "context" + "fmt" + + userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model" + "github.com/go-redis/redis/v8" + "github.com/spf13/viper" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +// OpenDBFromConfig 创建 user/auth 服务自己的数据库句柄。 +// +// 职责边界: +// 1. 只迁移 users 以及 user/auth 自己拥有的辅助表,避免独立 userauth 进程顺手迁移其它服务表; +// 2. 不负责读取业务配置之外的外部依赖,配置来源仍由 bootstrap.LoadConfig 统一注入; +// 3. 返回 *gorm.DB 供服务内 DAO 复用,调用方负责进程生命周期。 +func OpenDBFromConfig() (*gorm.DB, error) { + host := viper.GetString("database.host") + port := viper.GetString("database.port") + user := viper.GetString("database.user") + password := viper.GetString("database.password") + dbname := viper.GetString("database.dbname") + + dsn := fmt.Sprintf( + "%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", + user, password, host, port, dbname, + ) + + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, err + } + if err = db.AutoMigrate(&userauthmodel.User{}, &userauthmodel.TokenUsageAdjustment{}); err != nil { + return nil, fmt.Errorf("auto migrate userauth tables failed: %w", err) + } + return db, nil +} + +// OpenRedisFromConfig 创建 user/auth 服务自己的 Redis 句柄。 +// +// 失败时返回 error,让独立进程入口 fail-fast,避免黑名单和额度门禁静默失效。 +func OpenRedisFromConfig() (*redis.Client, error) { + client := redis.NewClient(&redis.Options{ + Addr: viper.GetString("redis.host") + ":" + viper.GetString("redis.port"), + Password: viper.GetString("redis.password"), + DB: 0, + }) + if _, err := client.Ping(context.Background()).Result(); err != nil { + return nil, err + } + return client, nil +} diff --git a/backend/services/userauth/dao/user.go b/backend/services/userauth/dao/user.go new file mode 100644 index 0000000..ba02ab0 --- /dev/null +++ b/backend/services/userauth/dao/user.go @@ -0,0 +1,173 @@ +package dao + +import ( + "context" + "errors" + "strings" + "time" + + userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +// UserDAO 是 user/auth 服务内部的 users 表访问层。 +// 职责边界:只提供注册、登录和额度治理需要的最小读写能力,不暴露整张 users 表给 gateway。 +type UserDAO struct { + db *gorm.DB +} + +func NewUserDAO(db *gorm.DB) *UserDAO { + return &UserDAO{db: db} +} + +// Create 创建新用户并初始化 token 额度字段。 +func (r *UserDAO) Create(ctx context.Context, username, phoneNumber, password string) (*userauthmodel.User, error) { + user := &userauthmodel.User{ + Username: username, + PhoneNumber: phoneNumber, + Password: password, + TokenLimit: 100000, + TokenUsage: 0, + LastResetAt: time.Now(), + } + if err := r.db.WithContext(ctx).Create(user).Error; err != nil { + return nil, err + } + return user, nil +} + +func (r *UserDAO) IfUsernameExists(ctx context.Context, name string) (bool, error) { + err := r.db.WithContext(ctx).Where("username = ?", name).First(&userauthmodel.User{}).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return false, nil + } + return true, err + } + return true, nil +} + +func (r *UserDAO) GetUserHashedPasswordByName(ctx context.Context, name string) (string, error) { + var user userauthmodel.User + if err := r.db.WithContext(ctx).Where("username = ?", name).First(&user).Error; err != nil { + return "", err + } + return user.Password, nil +} + +func (r *UserDAO) GetUserIDByName(ctx context.Context, name string) (int, error) { + var user userauthmodel.User + if err := r.db.WithContext(ctx).Where("username = ?", name).First(&user).Error; err != nil { + return -1, err + } + return int(user.ID), nil +} + +// GetUserTokenQuotaByID 只读取额度判断需要的字段。 +func (r *UserDAO) GetUserTokenQuotaByID(ctx context.Context, id int) (*userauthmodel.User, error) { + var user userauthmodel.User + err := r.db.WithContext(ctx). + Select("id", "token_limit", "token_usage", "last_reset_at"). + Where("id = ?", id). + First(&user).Error + if err != nil { + return nil, err + } + return &user, nil +} + +// ResetUserTokenUsageIfDue 使用条件更新实现幂等懒重置。 +func (r *UserDAO) ResetUserTokenUsageIfDue(ctx context.Context, id int, dueBefore time.Time, resetAt time.Time) (bool, error) { + result := r.db.WithContext(ctx). + Model(&userauthmodel.User{}). + Where("id = ? AND (last_reset_at IS NULL OR last_reset_at <= ?)", id, dueBefore). + Updates(map[string]interface{}{ + "token_usage": 0, + "last_reset_at": resetAt, + }) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// AddTokenUsage 为用户 token 账本做增量累加。 +// 职责边界: +// 1. 只做数据库累加,不负责额度判断与缓存刷新; +// 2. delta<=0 视为无操作,直接返回成功; +// 3. 由 service 层决定是否需要先做懒重置和后续 cache 回填。 +func (r *UserDAO) AddTokenUsage(ctx context.Context, id int, delta int) (bool, error) { + if delta <= 0 { + return true, nil + } + result := r.db.WithContext(ctx). + Model(&userauthmodel.User{}). + Where("id = ?", id). + Update("token_usage", gorm.Expr("token_usage + ?", delta)) + if result.Error != nil { + return false, result.Error + } + return result.RowsAffected > 0, nil +} + +// AdjustTokenUsageOnce 在同一个 MySQL 事务里完成“幂等占位 + token 用量增量”。 +// +// 职责边界: +// 1. eventID 非空时先写入 user_token_usage_adjustments,依赖主键冲突判断是否重复事件; +// 2. 只有幂等占位写入成功后才更新 users.token_usage,保证并发重放不会重复记账; +// 3. 不负责 Redis 快照和封禁键维护,这些缓存语义仍由 service 层在事务成功后刷新。 +func (r *UserDAO) AdjustTokenUsageOnce(ctx context.Context, eventID string, id int, delta int, dueBefore time.Time, resetAt time.Time) (*userauthmodel.User, bool, error) { + var quota userauthmodel.User + duplicated := false + trimmedEventID := strings.TrimSpace(eventID) + + err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if trimmedEventID != "" { + marker := userauthmodel.TokenUsageAdjustment{ + EventID: trimmedEventID, + UserID: id, + TokenDelta: delta, + } + result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&marker) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + duplicated = true + return nil + } + } + + resetResult := tx.Model(&userauthmodel.User{}). + Where("id = ? AND (last_reset_at IS NULL OR last_reset_at <= ?)", id, dueBefore). + Updates(map[string]interface{}{ + "token_usage": 0, + "last_reset_at": resetAt, + }) + if resetResult.Error != nil { + return resetResult.Error + } + + updateResult := tx.Model(&userauthmodel.User{}). + Where("id = ?", id). + Update("token_usage", gorm.Expr("token_usage + ?", delta)) + if updateResult.Error != nil { + return updateResult.Error + } + if updateResult.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + + return tx.Select("id", "token_limit", "token_usage", "last_reset_at"). + Where("id = ?", id). + First("a).Error + }) + if err != nil { + return nil, false, err + } + if duplicated { + return nil, true, nil + } + return "a, false, nil +} diff --git a/backend/services/userauth/internal/auth/tokens.go b/backend/services/userauth/internal/auth/tokens.go new file mode 100644 index 0000000..2cebc15 --- /dev/null +++ b/backend/services/userauth/internal/auth/tokens.go @@ -0,0 +1,330 @@ +package auth + +import ( + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/spf13/viper" +) + +const ( + accessSecretConfigKey = "jwt.accessSecret" + refreshSecretConfigKey = "jwt.refreshSecret" + accessExpireConfigKey = "jwt.accessTokenExpire" + refreshExpireConfigKey = "jwt.refreshTokenExpire" + + defaultAccessTokenExpire = 15 * time.Minute + defaultRefreshTokenExpire = 7 * 24 * time.Hour +) + +type BlacklistReader interface { + IsBlacklisted(jti string) (bool, error) + IsSessionBlacklisted(sessionID string) (bool, error) +} + +type runtimeConfig struct { + AccessKey []byte + RefreshKey []byte + AccessExpire time.Duration + RefreshExpire time.Duration +} + +// Claims 是 user/auth 服务内部使用的 JWT 声明结构。 +type Claims struct { + UserID int `json:"user_id"` + SessionID string `json:"sid"` + TokenType string `json:"token_type"` + JTI string `json:"jti"` + jwt.RegisteredClaims // 标准字段包含 exp/iat 等时间声明。 +} + +func generateJTI() string { + return uuid.New().String() +} + +func loadConfig() (*runtimeConfig, error) { + accessKey, err := readSecret(accessSecretConfigKey) + if err != nil { + return nil, err + } + refreshKey, err := readSecret(refreshSecretConfigKey) + if err != nil { + return nil, err + } + accessExpire, err := readExpireDuration(accessExpireConfigKey, defaultAccessTokenExpire) + if err != nil { + return nil, err + } + refreshExpire, err := readExpireDuration(refreshExpireConfigKey, defaultRefreshTokenExpire) + if err != nil { + return nil, err + } + return &runtimeConfig{ + AccessKey: accessKey, + RefreshKey: refreshKey, + AccessExpire: accessExpire, + RefreshExpire: refreshExpire, + }, nil +} + +func readSecret(configKey string) ([]byte, error) { + secret := strings.TrimSpace(viper.GetString(configKey)) + if secret == "" { + return nil, fmt.Errorf("jwt 配置缺失: %s", configKey) + } + return []byte(secret), nil +} + +func readExpireDuration(configKey string, fallback time.Duration) (time.Duration, error) { + raw := strings.TrimSpace(viper.GetString(configKey)) + if raw == "" { + return fallback, nil + } + d, err := parseFlexibleDuration(raw) + if err != nil { + return 0, fmt.Errorf("jwt 配置项 %s 非法: %w", configKey, err) + } + if d <= 0 { + return 0, fmt.Errorf("jwt 配置项 %s 必须大于 0", configKey) + } + return d, nil +} + +// SessionBlacklistTTL 返回 logout 后会话黑名单需要保留的时长。 +// +// 职责边界: +// 1. 只负责从配置推导“会话级黑名单”保留多久; +// 2. 不负责写 Redis,也不负责判断具体 token 是否过期; +// 3. 取 access / refresh 中更长的有效期,避免旧 access 在 refresh 轮转后把整段会话放掉。 +func SessionBlacklistTTL() (time.Duration, error) { + cfg, err := loadConfig() + if err != nil { + return 0, err + } + if cfg.RefreshExpire >= cfg.AccessExpire { + return cfg.RefreshExpire, nil + } + return cfg.AccessExpire, nil +} + +// parseFlexibleDuration 兼容 Go 原生时长和项目历史配置中的 7d / 15min。 +func parseFlexibleDuration(raw string) (time.Duration, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if normalized == "" { + return 0, errors.New("时长不能为空") + } + if d, err := time.ParseDuration(normalized); err == nil { + return d, nil + } + + type unitDef struct { + Suffix string + Multiplier time.Duration + } + unitDefs := []unitDef{ + {Suffix: "minutes", Multiplier: time.Minute}, + {Suffix: "minute", Multiplier: time.Minute}, + {Suffix: "mins", Multiplier: time.Minute}, + {Suffix: "min", Multiplier: time.Minute}, + {Suffix: "days", Multiplier: 24 * time.Hour}, + {Suffix: "day", Multiplier: 24 * time.Hour}, + {Suffix: "d", Multiplier: 24 * time.Hour}, + {Suffix: "hours", Multiplier: time.Hour}, + {Suffix: "hour", Multiplier: time.Hour}, + {Suffix: "h", Multiplier: time.Hour}, + {Suffix: "seconds", Multiplier: time.Second}, + {Suffix: "second", Multiplier: time.Second}, + {Suffix: "secs", Multiplier: time.Second}, + {Suffix: "sec", Multiplier: time.Second}, + {Suffix: "m", Multiplier: time.Minute}, + {Suffix: "s", Multiplier: time.Second}, + } + + for _, unit := range unitDefs { + if !strings.HasSuffix(normalized, unit.Suffix) { + continue + } + numberPart := strings.TrimSpace(strings.TrimSuffix(normalized, unit.Suffix)) + value, err := strconv.Atoi(numberPart) + if err != nil { + return 0, fmt.Errorf("时长数值非法: %q", numberPart) + } + if value <= 0 { + return 0, fmt.Errorf("时长数值必须大于 0: %d", value) + } + return time.Duration(value) * unit.Multiplier, nil + } + return 0, fmt.Errorf("不支持的时长格式: %s", raw) +} + +// GenerateTokens 签发访问令牌与刷新令牌。 +func GenerateTokens(userID int) (*contracts.Tokens, error) { + return GenerateTokensWithSession(userID, "") +} + +// GenerateTokensWithSession 为同一个登录会话签发一对 access / refresh token。 +// +// 职责边界: +// 1. 负责生成新的会话标识,或复用传入的会话标识; +// 2. access / refresh 各自使用独立 JTI,避免 refresh 轮转时误伤新 access; +// 3. 不负责黑名单写入,黑名单由 logout / refresh 重放防护链路处理。 +func GenerateTokensWithSession(userID int, sessionID string) (*contracts.Tokens, error) { + cfg, err := loadConfig() + if err != nil { + return nil, err + } + + now := time.Now() + sessionID = strings.TrimSpace(sessionID) + if sessionID == "" { + sessionID = generateJTI() + } + accessJTI := generateJTI() + refreshJTI := generateJTI() + + accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UserID: userID, + SessionID: sessionID, + TokenType: "access_token", + JTI: accessJTI, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(cfg.AccessExpire)), + }, + }) + accessTokenString, err := accessToken.SignedString(cfg.AccessKey) + if err != nil { + return nil, err + } + + refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{ + UserID: userID, + SessionID: sessionID, + TokenType: "refresh_token", + JTI: refreshJTI, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(cfg.RefreshExpire)), + }, + }) + refreshTokenString, err := refreshToken.SignedString(cfg.RefreshKey) + if err != nil { + return nil, err + } + + return &contracts.Tokens{ + AccessToken: accessTokenString, + RefreshToken: refreshTokenString, + }, nil +} + +func parseToken(tokenString string, signingKey []byte) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, respond.InvalidTokenSingingMethod + } + return signingKey, nil + }) + if err != nil || !token.Valid { + return nil, respond.InvalidToken + } + claims, ok := token.Claims.(*Claims) + if !ok || claims.ExpiresAt == nil || claims.UserID <= 0 || strings.TrimSpace(claims.JTI) == "" { + return nil, respond.InvalidClaims + } + return claims, nil +} + +func ensureAccessActive(cache BlacklistReader, sessionID, jti string) error { + if cache == nil { + return errors.New("token blacklist dependency is not initialized") + } + sessionID = strings.TrimSpace(sessionID) + if sessionID != "" { + isBlack, err := cache.IsSessionBlacklisted(sessionID) + if err != nil { + return errors.New("无法验证令牌状态") + } + if isBlack { + return respond.UserLoggedOut + } + return nil + } + isBlack, err := cache.IsBlacklisted(jti) + if err != nil { + return errors.New("无法验证令牌状态") + } + if isBlack { + return respond.UserLoggedOut + } + return nil +} + +func ensureRefreshActive(cache BlacklistReader, sessionID, jti string) error { + if cache == nil { + return errors.New("token blacklist dependency is not initialized") + } + sessionID = strings.TrimSpace(sessionID) + if sessionID != "" { + isBlack, err := cache.IsSessionBlacklisted(sessionID) + if err != nil { + return errors.New("无法验证令牌状态") + } + if isBlack { + return respond.UserLoggedOut + } + } + isBlack, err := cache.IsBlacklisted(jti) + if err != nil { + return errors.New("无法验证令牌状态") + } + if isBlack { + return respond.InvalidRefreshToken + } + return nil +} + +// ValidateAccessToken 校验 access token,并统一检查黑名单。 +func ValidateAccessToken(tokenString string, cache BlacklistReader) (*Claims, error) { + cfg, err := loadConfig() + if err != nil { + return nil, err + } + claims, err := parseToken(tokenString, cfg.AccessKey) + if err != nil { + return nil, err + } + if claims.TokenType != "access_token" { + return nil, respond.WrongTokenType + } + if err = ensureAccessActive(cache, claims.SessionID, claims.JTI); err != nil { + return nil, err + } + return claims, nil +} + +// ValidateRefreshToken 校验 refresh token,并统一检查黑名单。 +func ValidateRefreshToken(tokenString string, cache BlacklistReader) (*Claims, error) { + cfg, err := loadConfig() + if err != nil { + return nil, err + } + claims, err := parseToken(tokenString, cfg.RefreshKey) + if err != nil { + return nil, respond.InvalidRefreshToken + } + if claims.TokenType != "refresh_token" { + return nil, respond.WrongTokenType + } + if err = ensureRefreshActive(cache, claims.SessionID, claims.JTI); err != nil { + return nil, err + } + return claims, nil +} diff --git a/backend/services/userauth/model/token_usage_adjustment.go b/backend/services/userauth/model/token_usage_adjustment.go new file mode 100644 index 0000000..f71a28b --- /dev/null +++ b/backend/services/userauth/model/token_usage_adjustment.go @@ -0,0 +1,20 @@ +package model + +import "time" + +// TokenUsageAdjustment 是 user/auth 服务内的 token 账本幂等表。 +// +// 职责边界: +// 1. 只记录“某个 outbox/event_id 是否已经调整过 users.token_usage”; +// 2. 不保存 agent 会话 token_total,那个统计仍属于 agent 领域; +// 3. event_id 作为主键,配合 users.token_usage 更新放在同一个 MySQL 事务里,避免并发重放重复记账。 +type TokenUsageAdjustment struct { + EventID string `gorm:"column:event_id;type:varchar(64);primaryKey;comment:来源事件ID"` + UserID int `gorm:"column:user_id;not null;index:idx_userauth_token_adjust_user;comment:用户ID"` + TokenDelta int `gorm:"column:token_delta;not null;comment:本次增加的 token 用量"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间"` +} + +func (TokenUsageAdjustment) TableName() string { + return "user_token_usage_adjustments" +} diff --git a/backend/services/userauth/model/user.go b/backend/services/userauth/model/user.go new file mode 100644 index 0000000..b01d86f --- /dev/null +++ b/backend/services/userauth/model/user.go @@ -0,0 +1,19 @@ +package model + +import "time" + +// User 是 user/auth 服务内部拥有的 users 表模型。 +// 职责边界:只覆盖 user/auth 需要维护的字段,不承载 gateway 或其他领域规则。 +type User struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + Username string `gorm:"type:varchar(255);not null;unique" json:"username"` + Password string `gorm:"type:varchar(255);not null" json:"-"` + PhoneNumber string `gorm:"type:varchar(255)" json:"phone_number"` + TokenLimit int `gorm:"default:100000" json:"token_limit"` + TokenUsage int `gorm:"default:0" json:"token_usage"` + LastResetAt time.Time `json:"last_reset_at"` +} + +func (User) TableName() string { + return "users" +} diff --git a/backend/services/userauth/rpc/errors.go b/backend/services/userauth/rpc/errors.go new file mode 100644 index 0000000..da4a934 --- /dev/null +++ b/backend/services/userauth/rpc/errors.go @@ -0,0 +1,86 @@ +package rpc + +import ( + "errors" + "log" + "strings" + + "github.com/LoveLosita/smartflow/backend/respond" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const userAuthErrorDomain = "smartflow.userauth" + +// grpcErrorFromServiceError 负责把 user/auth 内部错误收口成 gRPC status。 +// +// 职责边界: +// 1. 只负责把本服务内部的 respond.Response / 普通 error 转成 gRPC 可传输错误; +// 2. 不负责决定 HTTP 语义,也不负责写回前端响应体; +// 3. 上层 handler 只要直接 return 这个结果,就能让 client 侧按 `res, err :=` 的方式接收。 +func grpcErrorFromServiceError(err error) error { + if err == nil { + return nil + } + + var resp respond.Response + if errors.As(err, &resp) { + return grpcErrorFromResponse(resp) + } + log.Printf("userauth rpc internal error: %v", err) + return status.Error(codes.Internal, "userauth service internal error") +} + +// grpcErrorFromResponse 负责把项目内业务响应映射成 gRPC status。 +// +// 职责边界: +// 1. 只处理 user/auth 这组响应码到 gRPC code 的映射; +// 2. 业务码和业务文案通过 ErrorInfo 附带,方便 gateway 再反解回 respond.Response; +// 3. 失败时退化为普通 gRPC status,不阻断请求链路。 +func grpcErrorFromResponse(resp respond.Response) error { + code := grpcCodeFromRespondStatus(resp.Status) + message := strings.TrimSpace(resp.Info) + if message == "" { + message = strings.TrimSpace(resp.Status) + } + + st := status.New(code, message) + detail := &errdetails.ErrorInfo{ + Domain: userAuthErrorDomain, + Reason: resp.Status, + Metadata: map[string]string{ + "info": resp.Info, + }, + } + withDetails, err := st.WithDetails(detail) + if err != nil { + return st.Err() + } + return withDetails.Err() +} + +func grpcCodeFromRespondStatus(statusValue string) codes.Code { + switch strings.TrimSpace(statusValue) { + case respond.InvalidName.Status: + return codes.AlreadyExists + case respond.WrongName.Status: + return codes.NotFound + case respond.WrongPwd.Status, respond.WrongUsernameOrPwd.Status: + return codes.Unauthenticated + case respond.MissingToken.Status, respond.InvalidTokenSingingMethod.Status, respond.InvalidToken.Status, + respond.InvalidClaims.Status, respond.ErrUnauthorized.Status, respond.InvalidRefreshToken.Status, + respond.WrongTokenType.Status, respond.UserLoggedOut.Status: + return codes.Unauthenticated + case respond.TokenUsageExceedsLimit.Status: + return codes.ResourceExhausted + case respond.MissingParam.Status, respond.WrongParamType.Status, respond.ParamTooLong.Status, + respond.WrongGender.Status, respond.WrongUserID.Status: + return codes.InvalidArgument + } + + if strings.HasPrefix(strings.TrimSpace(statusValue), "5") { + return codes.Internal + } + return codes.InvalidArgument +} diff --git a/backend/services/userauth/rpc/handler.go b/backend/services/userauth/rpc/handler.go new file mode 100644 index 0000000..1793fb3 --- /dev/null +++ b/backend/services/userauth/rpc/handler.go @@ -0,0 +1,177 @@ +package rpc + +import ( + "context" + "errors" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + "github.com/LoveLosita/smartflow/backend/services/userauth/rpc/pb" + userauthsv "github.com/LoveLosita/smartflow/backend/services/userauth/sv" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" +) + +type Handler struct { + pb.UnimplementedUserAuthServer + svc *userauthsv.Service +} + +func NewHandler(svc *userauthsv.Service) *Handler { + return &Handler{svc: svc} +} + +// Register 负责把 user/auth 的注册请求从 gRPC 协议转成内部服务调用。 +// +// 职责边界: +// 1. 只做 transport -> service 的参数搬运,不碰 DAO/Redis/JWT 细节; +// 2. 业务错误统一转成 gRPC status,让 client 侧继续使用 `res, err :=`; +// 3. 成功时只回传业务数据,不再在 payload 里塞 status/info。 +func (h *Handler) Register(ctx context.Context, req *pb.RegisterRequest) (*pb.RegisterResponse, error) { + if h == nil || h.svc == nil { + return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.MissingParam) + } + + resp, err := h.svc.Register(ctx, contracts.RegisterRequest{ + Username: req.Username, + Password: req.Password, + PhoneNumber: req.PhoneNumber, + }) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.RegisterResponse{Id: uint64(resp.ID)}, nil +} + +func (h *Handler) Login(ctx context.Context, req *pb.LoginRequest) (*pb.TokensResponse, error) { + if h == nil || h.svc == nil { + return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.MissingParam) + } + + resp, err := h.svc.Login(ctx, contracts.LoginRequest{ + Username: req.Username, + Password: req.Password, + }) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.TokensResponse{ + AccessToken: resp.AccessToken, + RefreshToken: resp.RefreshToken, + }, nil +} + +func (h *Handler) RefreshToken(ctx context.Context, req *pb.RefreshTokenRequest) (*pb.TokensResponse, error) { + if h == nil || h.svc == nil { + return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.MissingParam) + } + + resp, err := h.svc.RefreshToken(ctx, contracts.RefreshTokenRequest{ + RefreshToken: req.RefreshToken, + }) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.TokensResponse{ + AccessToken: resp.AccessToken, + RefreshToken: resp.RefreshToken, + }, nil +} + +func (h *Handler) Logout(ctx context.Context, req *pb.LogoutRequest) (*pb.StatusResponse, error) { + if h == nil || h.svc == nil { + return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.MissingToken) + } + + if err := h.svc.LogoutByAccessToken(ctx, req.AccessToken); err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.StatusResponse{}, nil +} + +func (h *Handler) ValidateAccessToken(ctx context.Context, req *pb.ValidateAccessTokenRequest) (*pb.ValidateAccessTokenResponse, error) { + if h == nil || h.svc == nil { + return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.MissingToken) + } + + resp, err := h.svc.ValidateAccessToken(ctx, contracts.ValidateAccessTokenRequest{ + AccessToken: req.AccessToken, + }) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.ValidateAccessTokenResponse{ + Valid: resp.Valid, + UserId: int64(resp.UserID), + TokenType: resp.TokenType, + Jti: resp.JTI, + ExpiresAtUnixNano: timeToUnixNano(resp.ExpiresAt), + }, nil +} + +func (h *Handler) CheckTokenQuota(ctx context.Context, req *pb.CheckTokenQuotaRequest) (*pb.CheckTokenQuotaResponse, error) { + if h == nil || h.svc == nil { + return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.ErrUnauthorized) + } + + resp, err := h.svc.CheckTokenQuota(ctx, contracts.CheckTokenQuotaRequest{ + UserID: int(req.UserId), + }) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.CheckTokenQuotaResponse{ + Allowed: resp.Allowed, + TokenLimit: int64(resp.TokenLimit), + TokenUsage: int64(resp.TokenUsage), + LastResetAtUnixNano: timeToUnixNano(resp.LastResetAt), + }, nil +} + +func (h *Handler) AdjustTokenUsage(ctx context.Context, req *pb.AdjustTokenUsageRequest) (*pb.CheckTokenQuotaResponse, error) { + if h == nil || h.svc == nil { + return nil, grpcErrorFromServiceError(errors.New("userauth service dependency not initialized")) + } + if req == nil { + return nil, grpcErrorFromServiceError(respond.MissingParam) + } + + resp, err := h.svc.AdjustTokenUsage(ctx, contracts.AdjustTokenUsageRequest{ + EventID: req.EventId, + UserID: int(req.UserId), + TokenDelta: int(req.TokenDelta), + }) + if err != nil { + return nil, grpcErrorFromServiceError(err) + } + return &pb.CheckTokenQuotaResponse{ + Allowed: resp.Allowed, + TokenLimit: int64(resp.TokenLimit), + TokenUsage: int64(resp.TokenUsage), + LastResetAtUnixNano: timeToUnixNano(resp.LastResetAt), + }, nil +} + +func timeToUnixNano(value time.Time) int64 { + if value.IsZero() { + return 0 + } + return value.UnixNano() +} diff --git a/backend/services/userauth/rpc/pb/userauth.pb.go b/backend/services/userauth/rpc/pb/userauth.pb.go new file mode 100644 index 0000000..6942978 --- /dev/null +++ b/backend/services/userauth/rpc/pb/userauth.pb.go @@ -0,0 +1,151 @@ +package pb + +import proto "github.com/golang/protobuf/proto" + +var _ = proto.Marshal + +const _ = proto.ProtoPackageIsVersion3 + +type RegisterRequest struct { + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + PhoneNumber string `protobuf:"bytes,3,opt,name=phone_number,json=phoneNumber,proto3" json:"phone_number,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *RegisterRequest) Reset() { *m = RegisterRequest{} } +func (m *RegisterRequest) String() string { return proto.CompactTextString(m) } +func (*RegisterRequest) ProtoMessage() {} + +type RegisterResponse struct { + Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *RegisterResponse) Reset() { *m = RegisterResponse{} } +func (m *RegisterResponse) String() string { return proto.CompactTextString(m) } +func (*RegisterResponse) ProtoMessage() {} + +type LoginRequest struct { + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *LoginRequest) Reset() { *m = LoginRequest{} } +func (m *LoginRequest) String() string { return proto.CompactTextString(m) } +func (*LoginRequest) ProtoMessage() {} + +type TokensResponse struct { + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *TokensResponse) Reset() { *m = TokensResponse{} } +func (m *TokensResponse) String() string { return proto.CompactTextString(m) } +func (*TokensResponse) ProtoMessage() {} + +type RefreshTokenRequest struct { + RefreshToken string `protobuf:"bytes,1,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *RefreshTokenRequest) Reset() { *m = RefreshTokenRequest{} } +func (m *RefreshTokenRequest) String() string { return proto.CompactTextString(m) } +func (*RefreshTokenRequest) ProtoMessage() {} + +type LogoutRequest struct { + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *LogoutRequest) Reset() { *m = LogoutRequest{} } +func (m *LogoutRequest) String() string { return proto.CompactTextString(m) } +func (*LogoutRequest) ProtoMessage() {} + +type StatusResponse struct { + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *StatusResponse) Reset() { *m = StatusResponse{} } +func (m *StatusResponse) String() string { return proto.CompactTextString(m) } +func (*StatusResponse) ProtoMessage() {} + +type ValidateAccessTokenRequest struct { + AccessToken string `protobuf:"bytes,1,opt,name=access_token,json=accessToken,proto3" json:"access_token,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ValidateAccessTokenRequest) Reset() { *m = ValidateAccessTokenRequest{} } +func (m *ValidateAccessTokenRequest) String() string { return proto.CompactTextString(m) } +func (*ValidateAccessTokenRequest) ProtoMessage() {} + +type ValidateAccessTokenResponse struct { + Valid bool `protobuf:"varint,1,opt,name=valid,proto3" json:"valid,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + TokenType string `protobuf:"bytes,3,opt,name=token_type,json=tokenType,proto3" json:"token_type,omitempty"` + Jti string `protobuf:"bytes,4,opt,name=jti,proto3" json:"jti,omitempty"` + ExpiresAtUnixNano int64 `protobuf:"varint,5,opt,name=expires_at_unix_nano,json=expiresAtUnixNano,proto3" json:"expires_at_unix_nano,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *ValidateAccessTokenResponse) Reset() { *m = ValidateAccessTokenResponse{} } +func (m *ValidateAccessTokenResponse) String() string { return proto.CompactTextString(m) } +func (*ValidateAccessTokenResponse) ProtoMessage() {} + +type CheckTokenQuotaRequest struct { + UserId int64 `protobuf:"varint,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CheckTokenQuotaRequest) Reset() { *m = CheckTokenQuotaRequest{} } +func (m *CheckTokenQuotaRequest) String() string { return proto.CompactTextString(m) } +func (*CheckTokenQuotaRequest) ProtoMessage() {} + +type AdjustTokenUsageRequest struct { + EventId string `protobuf:"bytes,1,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + TokenDelta int64 `protobuf:"varint,3,opt,name=token_delta,json=tokenDelta,proto3" json:"token_delta,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *AdjustTokenUsageRequest) Reset() { *m = AdjustTokenUsageRequest{} } +func (m *AdjustTokenUsageRequest) String() string { return proto.CompactTextString(m) } +func (*AdjustTokenUsageRequest) ProtoMessage() {} + +type CheckTokenQuotaResponse struct { + Allowed bool `protobuf:"varint,1,opt,name=allowed,proto3" json:"allowed,omitempty"` + TokenLimit int64 `protobuf:"varint,2,opt,name=token_limit,json=tokenLimit,proto3" json:"token_limit,omitempty"` + TokenUsage int64 `protobuf:"varint,3,opt,name=token_usage,json=tokenUsage,proto3" json:"token_usage,omitempty"` + LastResetAtUnixNano int64 `protobuf:"varint,4,opt,name=last_reset_at_unix_nano,json=lastResetAtUnixNano,proto3" json:"last_reset_at_unix_nano,omitempty"` + XXX_NoUnkeyedLiteral struct{} `json:"-"` + XXX_unrecognized []byte `json:"-"` + XXX_sizecache int32 `json:"-"` +} + +func (m *CheckTokenQuotaResponse) Reset() { *m = CheckTokenQuotaResponse{} } +func (m *CheckTokenQuotaResponse) String() string { return proto.CompactTextString(m) } +func (*CheckTokenQuotaResponse) ProtoMessage() {} diff --git a/backend/services/userauth/rpc/pb/userauth_grpc.pb.go b/backend/services/userauth/rpc/pb/userauth_grpc.pb.go new file mode 100644 index 0000000..25c2b8b --- /dev/null +++ b/backend/services/userauth/rpc/pb/userauth_grpc.pb.go @@ -0,0 +1,307 @@ +package pb + +import ( + context "context" + + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +const ( + UserAuth_Register_FullMethodName = "/smartflow.userauth.UserAuth/Register" + UserAuth_Login_FullMethodName = "/smartflow.userauth.UserAuth/Login" + UserAuth_RefreshToken_FullMethodName = "/smartflow.userauth.UserAuth/RefreshToken" + UserAuth_Logout_FullMethodName = "/smartflow.userauth.UserAuth/Logout" + UserAuth_ValidateAccessToken_FullMethodName = "/smartflow.userauth.UserAuth/ValidateAccessToken" + UserAuth_CheckTokenQuota_FullMethodName = "/smartflow.userauth.UserAuth/CheckTokenQuota" + UserAuth_AdjustTokenUsage_FullMethodName = "/smartflow.userauth.UserAuth/AdjustTokenUsage" +) + +type UserAuthClient interface { + Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) + Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*TokensResponse, error) + RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*TokensResponse, error) + Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*StatusResponse, error) + ValidateAccessToken(ctx context.Context, in *ValidateAccessTokenRequest, opts ...grpc.CallOption) (*ValidateAccessTokenResponse, error) + CheckTokenQuota(ctx context.Context, in *CheckTokenQuotaRequest, opts ...grpc.CallOption) (*CheckTokenQuotaResponse, error) + AdjustTokenUsage(ctx context.Context, in *AdjustTokenUsageRequest, opts ...grpc.CallOption) (*CheckTokenQuotaResponse, error) +} + +type userAuthClient struct { + cc grpc.ClientConnInterface +} + +func NewUserAuthClient(cc grpc.ClientConnInterface) UserAuthClient { + return &userAuthClient{cc} +} + +func (c *userAuthClient) Register(ctx context.Context, in *RegisterRequest, opts ...grpc.CallOption) (*RegisterResponse, error) { + out := new(RegisterResponse) + err := c.cc.Invoke(ctx, UserAuth_Register_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userAuthClient) Login(ctx context.Context, in *LoginRequest, opts ...grpc.CallOption) (*TokensResponse, error) { + out := new(TokensResponse) + err := c.cc.Invoke(ctx, UserAuth_Login_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userAuthClient) RefreshToken(ctx context.Context, in *RefreshTokenRequest, opts ...grpc.CallOption) (*TokensResponse, error) { + out := new(TokensResponse) + err := c.cc.Invoke(ctx, UserAuth_RefreshToken_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userAuthClient) Logout(ctx context.Context, in *LogoutRequest, opts ...grpc.CallOption) (*StatusResponse, error) { + out := new(StatusResponse) + err := c.cc.Invoke(ctx, UserAuth_Logout_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userAuthClient) ValidateAccessToken(ctx context.Context, in *ValidateAccessTokenRequest, opts ...grpc.CallOption) (*ValidateAccessTokenResponse, error) { + out := new(ValidateAccessTokenResponse) + err := c.cc.Invoke(ctx, UserAuth_ValidateAccessToken_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userAuthClient) CheckTokenQuota(ctx context.Context, in *CheckTokenQuotaRequest, opts ...grpc.CallOption) (*CheckTokenQuotaResponse, error) { + out := new(CheckTokenQuotaResponse) + err := c.cc.Invoke(ctx, UserAuth_CheckTokenQuota_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userAuthClient) AdjustTokenUsage(ctx context.Context, in *AdjustTokenUsageRequest, opts ...grpc.CallOption) (*CheckTokenQuotaResponse, error) { + out := new(CheckTokenQuotaResponse) + err := c.cc.Invoke(ctx, UserAuth_AdjustTokenUsage_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +type UserAuthServer interface { + Register(context.Context, *RegisterRequest) (*RegisterResponse, error) + Login(context.Context, *LoginRequest) (*TokensResponse, error) + RefreshToken(context.Context, *RefreshTokenRequest) (*TokensResponse, error) + Logout(context.Context, *LogoutRequest) (*StatusResponse, error) + ValidateAccessToken(context.Context, *ValidateAccessTokenRequest) (*ValidateAccessTokenResponse, error) + CheckTokenQuota(context.Context, *CheckTokenQuotaRequest) (*CheckTokenQuotaResponse, error) + AdjustTokenUsage(context.Context, *AdjustTokenUsageRequest) (*CheckTokenQuotaResponse, error) +} + +type UnimplementedUserAuthServer struct{} + +func (UnimplementedUserAuthServer) Register(context.Context, *RegisterRequest) (*RegisterResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Register not implemented") +} + +func (UnimplementedUserAuthServer) Login(context.Context, *LoginRequest) (*TokensResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") +} + +func (UnimplementedUserAuthServer) RefreshToken(context.Context, *RefreshTokenRequest) (*TokensResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RefreshToken not implemented") +} + +func (UnimplementedUserAuthServer) Logout(context.Context, *LogoutRequest) (*StatusResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented") +} + +func (UnimplementedUserAuthServer) ValidateAccessToken(context.Context, *ValidateAccessTokenRequest) (*ValidateAccessTokenResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ValidateAccessToken not implemented") +} + +func (UnimplementedUserAuthServer) CheckTokenQuota(context.Context, *CheckTokenQuotaRequest) (*CheckTokenQuotaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckTokenQuota not implemented") +} + +func (UnimplementedUserAuthServer) AdjustTokenUsage(context.Context, *AdjustTokenUsageRequest) (*CheckTokenQuotaResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method AdjustTokenUsage not implemented") +} + +func RegisterUserAuthServer(s grpc.ServiceRegistrar, srv UserAuthServer) { + s.RegisterService(&UserAuth_ServiceDesc, srv) +} + +func _UserAuth_Register_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RegisterRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserAuthServer).Register(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserAuth_Register_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserAuthServer).Register(ctx, req.(*RegisterRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserAuth_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LoginRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserAuthServer).Login(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserAuth_Login_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserAuthServer).Login(ctx, req.(*LoginRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserAuth_RefreshToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RefreshTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserAuthServer).RefreshToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserAuth_RefreshToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserAuthServer).RefreshToken(ctx, req.(*RefreshTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserAuth_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LogoutRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserAuthServer).Logout(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserAuth_Logout_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserAuthServer).Logout(ctx, req.(*LogoutRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserAuth_ValidateAccessToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ValidateAccessTokenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserAuthServer).ValidateAccessToken(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserAuth_ValidateAccessToken_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserAuthServer).ValidateAccessToken(ctx, req.(*ValidateAccessTokenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserAuth_CheckTokenQuota_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CheckTokenQuotaRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserAuthServer).CheckTokenQuota(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserAuth_CheckTokenQuota_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserAuthServer).CheckTokenQuota(ctx, req.(*CheckTokenQuotaRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserAuth_AdjustTokenUsage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(AdjustTokenUsageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserAuthServer).AdjustTokenUsage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserAuth_AdjustTokenUsage_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserAuthServer).AdjustTokenUsage(ctx, req.(*AdjustTokenUsageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +var UserAuth_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "smartflow.userauth.UserAuth", + HandlerType: (*UserAuthServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "Register", + Handler: _UserAuth_Register_Handler, + }, + { + MethodName: "Login", + Handler: _UserAuth_Login_Handler, + }, + { + MethodName: "RefreshToken", + Handler: _UserAuth_RefreshToken_Handler, + }, + { + MethodName: "Logout", + Handler: _UserAuth_Logout_Handler, + }, + { + MethodName: "ValidateAccessToken", + Handler: _UserAuth_ValidateAccessToken_Handler, + }, + { + MethodName: "CheckTokenQuota", + Handler: _UserAuth_CheckTokenQuota_Handler, + }, + { + MethodName: "AdjustTokenUsage", + Handler: _UserAuth_AdjustTokenUsage_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "services/userauth/rpc/userauth.proto", +} diff --git a/backend/services/userauth/rpc/server.go b/backend/services/userauth/rpc/server.go new file mode 100644 index 0000000..9523f30 --- /dev/null +++ b/backend/services/userauth/rpc/server.go @@ -0,0 +1,72 @@ +package rpc + +import ( + "errors" + "log" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/services/userauth/rpc/pb" + userauthsv "github.com/LoveLosita/smartflow/backend/services/userauth/sv" + "github.com/zeromicro/go-zero/core/service" + "github.com/zeromicro/go-zero/zrpc" + "google.golang.org/grpc" +) + +const ( + defaultListenOn = "0.0.0.0:9081" + defaultTimeout = 2 * time.Second +) + +type ServerOptions struct { + ListenOn string + Timeout time.Duration + Service *userauthsv.Service +} + +// Start 启动 user/auth zrpc 服务。 +// +// 职责边界: +// 1. 只负责装配 gozero zrpc server 和注册 protobuf service; +// 2. 不创建 DB/Redis 连接,这些依赖由 cmd/userauth 入口注入; +// 3. 阻塞直到进程收到退出信号,保持一个服务一个独立进程的迁移方向。 +func Start(opts ServerOptions) { + server, listenOn, err := NewServer(opts) + if err != nil { + log.Fatalf("failed to build userauth zrpc server: %v", err) + } + defer server.Stop() + + log.Printf("userauth zrpc service starting on %s", listenOn) + server.Start() +} + +func NewServer(opts ServerOptions) (*zrpc.RpcServer, string, error) { + if opts.Service == nil { + return nil, "", errors.New("userauth service dependency not initialized") + } + + listenOn := strings.TrimSpace(opts.ListenOn) + if listenOn == "" { + listenOn = defaultListenOn + } + timeout := opts.Timeout + if timeout <= 0 { + timeout = defaultTimeout + } + + server, err := zrpc.NewServer(zrpc.RpcServerConf{ + ServiceConf: service.ServiceConf{ + Name: "userauth.rpc", + Mode: service.DevMode, + }, + ListenOn: listenOn, + Timeout: int64(timeout / time.Millisecond), + }, func(grpcServer *grpc.Server) { + pb.RegisterUserAuthServer(grpcServer, NewHandler(opts.Service)) + }) + if err != nil { + return nil, "", err + } + return server, listenOn, nil +} diff --git a/backend/services/userauth/rpc/userauth.proto b/backend/services/userauth/rpc/userauth.proto new file mode 100644 index 0000000..4d935e3 --- /dev/null +++ b/backend/services/userauth/rpc/userauth.proto @@ -0,0 +1,75 @@ +syntax = "proto3"; + +package smartflow.userauth; + +option go_package = "github.com/LoveLosita/smartflow/backend/services/userauth/rpc/pb"; + +service UserAuth { + rpc Register(RegisterRequest) returns (RegisterResponse); + rpc Login(LoginRequest) returns (TokensResponse); + rpc RefreshToken(RefreshTokenRequest) returns (TokensResponse); + rpc Logout(LogoutRequest) returns (StatusResponse); + rpc ValidateAccessToken(ValidateAccessTokenRequest) returns (ValidateAccessTokenResponse); + rpc CheckTokenQuota(CheckTokenQuotaRequest) returns (CheckTokenQuotaResponse); + rpc AdjustTokenUsage(AdjustTokenUsageRequest) returns (CheckTokenQuotaResponse); +} + +message RegisterRequest { + string username = 1; + string password = 2; + string phone_number = 3; +} + +message RegisterResponse { + uint64 id = 1; +} + +message LoginRequest { + string username = 1; + string password = 2; +} + +message TokensResponse { + string access_token = 1; + string refresh_token = 2; +} + +message RefreshTokenRequest { + string refresh_token = 1; +} + +message LogoutRequest { + string access_token = 1; +} + +message StatusResponse { +} + +message ValidateAccessTokenRequest { + string access_token = 1; +} + +message ValidateAccessTokenResponse { + bool valid = 1; + int64 user_id = 2; + string token_type = 3; + string jti = 4; + int64 expires_at_unix_nano = 5; +} + +message CheckTokenQuotaRequest { + int64 user_id = 1; +} + +message AdjustTokenUsageRequest { + string event_id = 1; + int64 user_id = 2; + int64 token_delta = 3; +} + +message CheckTokenQuotaResponse { + bool allowed = 1; + int64 token_limit = 2; + int64 token_usage = 3; + int64 last_reset_at_unix_nano = 4; +} diff --git a/backend/services/userauth/sv/quota.go b/backend/services/userauth/sv/quota.go new file mode 100644 index 0000000..1bedfbc --- /dev/null +++ b/backend/services/userauth/sv/quota.go @@ -0,0 +1,192 @@ +package sv + +import ( + "context" + "errors" + "log" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao" + userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" +) + +const ( + userTokenResetInterval = 7 * 24 * time.Hour + userTokenQuotaSnapshotTTL = 60 * time.Second + minUserTokenBlockTTL = 30 * time.Second +) + +// CheckTokenQuota 是 user/auth 服务内的 token 额度门禁。 +// +// 职责边界: +// 1. 判断用户是否还能继续发起高消耗 agent/chat 请求; +// 2. 维护额度周期懒重置、Redis 快照和封禁键; +// 3. 不负责本轮对话完成后的 token 记账,记账由 AdjustTokenUsage 处理。 +func (s *Service) CheckTokenQuota(ctx context.Context, req contracts.CheckTokenQuotaRequest) (*contracts.CheckTokenQuotaResponse, error) { + if s == nil || s.userRepo == nil || s.cacheRepo == nil { + return nil, errors.New("userauth quota dependencies not initialized") + } + if req.UserID <= 0 { + return nil, respond.ErrUnauthorized + } + + now := time.Now() + + // 1. 先查封禁键。封禁键的 TTL 按重置窗口计算,命中时可以避免每次回源 DB。 + blocked, blockedErr := s.cacheRepo.IsUserTokenBlocked(ctx, req.UserID) + if blockedErr != nil { + log.Printf("userauth quota: 查询封禁键失败 user_id=%d err=%v,回源 DB 校验", req.UserID, blockedErr) + } else if blocked { + return &contracts.CheckTokenQuotaResponse{Allowed: false}, nil + } + + // 2. 快照未到重置窗口时直接判断;快照损坏或过期则回源 DB。 + snapshot, hit, snapshotErr := s.cacheRepo.GetUserTokenQuotaSnapshot(ctx, req.UserID) + if snapshotErr != nil { + log.Printf("userauth quota: 读取额度快照失败 user_id=%d err=%v,回源 DB 校验", req.UserID, snapshotErr) + } + if hit && snapshot != nil && !isResetDue(snapshot.LastResetAt, now) { + if isQuotaExceeded(snapshot.TokenLimit, snapshot.TokenUsage) { + ttl := calcBlockTTL(snapshot.LastResetAt, now) + if err := s.cacheRepo.SetUserTokenBlocked(ctx, req.UserID, ttl); err != nil { + log.Printf("userauth quota: 写入封禁键失败 user_id=%d err=%v", req.UserID, err) + } + return quotaResponse(false, snapshot.TokenLimit, snapshot.TokenUsage, snapshot.LastResetAt), nil + } + return quotaResponse(true, snapshot.TokenLimit, snapshot.TokenUsage, snapshot.LastResetAt), nil + } + + // 3. 回源 DB 做权威判断;到 7 天窗口则先懒重置,再回读最新额度。 + quota, err := s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID) + if err != nil { + return nil, err + } + if isResetDue(quota.LastResetAt, now) { + if _, err = s.userRepo.ResetUserTokenUsageIfDue(ctx, req.UserID, now.Add(-userTokenResetInterval), now); err != nil { + return nil, err + } + quota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID) + if err != nil { + return nil, err + } + if delErr := s.cacheRepo.DeleteUserTokenBlocked(ctx, req.UserID); delErr != nil { + log.Printf("userauth quota: 清理封禁键失败 user_id=%d err=%v", req.UserID, delErr) + } + } + return s.cacheQuotaAndBuildResponse(ctx, req.UserID, quota, now, "quota") +} + +// AdjustTokenUsage 在 user/auth 服务内回写用户 token 账本。 +// +// 职责边界: +// 1. 只负责 users.token_usage 的增量调整与 quota 缓存刷新; +// 2. 不负责 agent 会话 token_total,调用方仍需在各自领域内维护会话统计; +// 3. event_id 非空时通过 MySQL 幂等表和 users 更新同事务提交,避免 outbox 重试或并发重放重复记账。 +func (s *Service) AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) { + if s == nil || s.userRepo == nil || s.cacheRepo == nil { + return nil, errors.New("userauth adjust dependencies not initialized") + } + if req.UserID <= 0 || req.TokenDelta <= 0 { + return nil, respond.MissingParam + } + + now := time.Now() + eventID := strings.TrimSpace(req.EventID) + + var currentQuota *userauthmodel.User + var err error + if eventID != "" { + var duplicated bool + currentQuota, duplicated, err = s.userRepo.AdjustTokenUsageOnce(ctx, eventID, req.UserID, req.TokenDelta, now.Add(-userTokenResetInterval), now) + if err != nil { + return nil, err + } + if duplicated { + return s.CheckTokenQuota(ctx, contracts.CheckTokenQuotaRequest{UserID: req.UserID}) + } + } else { + currentQuota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID) + if err != nil { + return nil, err + } + if isResetDue(currentQuota.LastResetAt, now) { + if _, err = s.userRepo.ResetUserTokenUsageIfDue(ctx, req.UserID, now.Add(-userTokenResetInterval), now); err != nil { + return nil, err + } + } + if _, err = s.userRepo.AddTokenUsage(ctx, req.UserID, req.TokenDelta); err != nil { + return nil, err + } + currentQuota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID) + if err != nil { + return nil, err + } + } + + return s.cacheQuotaAndBuildResponse(ctx, req.UserID, currentQuota, now, "adjust") +} + +func (s *Service) cacheQuotaAndBuildResponse(ctx context.Context, userID int, quota *userauthmodel.User, now time.Time, source string) (*contracts.CheckTokenQuotaResponse, error) { + if quota == nil { + return nil, errors.New("userauth quota is nil") + } + + snapshot := userauthdao.TokenQuotaSnapshot{ + TokenLimit: quota.TokenLimit, + TokenUsage: quota.TokenUsage, + LastResetAt: quota.LastResetAt, + } + if setErr := s.cacheRepo.SetUserTokenQuotaSnapshot(ctx, userID, snapshot, userTokenQuotaSnapshotTTL); setErr != nil { + log.Printf("userauth %s: 回填额度快照失败 user_id=%d err=%v", source, userID, setErr) + if delErr := s.cacheRepo.DeleteUserTokenQuotaSnapshot(ctx, userID); delErr != nil { + log.Printf("userauth %s: 清理失效额度快照失败 user_id=%d err=%v", source, userID, delErr) + } + } + + if isQuotaExceeded(quota.TokenLimit, quota.TokenUsage) { + ttl := calcBlockTTL(quota.LastResetAt, now) + if err := s.cacheRepo.SetUserTokenBlocked(ctx, userID, ttl); err != nil { + log.Printf("userauth %s: 写入封禁标记失败 user_id=%d err=%v", source, userID, err) + } + return quotaResponse(false, quota.TokenLimit, quota.TokenUsage, quota.LastResetAt), nil + } + + if delErr := s.cacheRepo.DeleteUserTokenBlocked(ctx, userID); delErr != nil { + log.Printf("userauth %s: 清理封禁标记失败 user_id=%d err=%v", source, userID, delErr) + } + return quotaResponse(true, quota.TokenLimit, quota.TokenUsage, quota.LastResetAt), nil +} + +func quotaResponse(allowed bool, tokenLimit int, tokenUsage int, lastResetAt time.Time) *contracts.CheckTokenQuotaResponse { + return &contracts.CheckTokenQuotaResponse{ + Allowed: allowed, + TokenLimit: tokenLimit, + TokenUsage: tokenUsage, + LastResetAt: lastResetAt, + } +} + +func isQuotaExceeded(tokenLimit int, tokenUsage int) bool { + return tokenUsage >= tokenLimit +} + +func isResetDue(lastResetAt time.Time, now time.Time) bool { + if lastResetAt.IsZero() { + return true + } + return !lastResetAt.Add(userTokenResetInterval).After(now) +} + +func calcBlockTTL(lastResetAt time.Time, now time.Time) time.Duration { + if lastResetAt.IsZero() { + return minUserTokenBlockTTL + } + ttl := lastResetAt.Add(userTokenResetInterval).Sub(now) + if ttl <= 0 { + return minUserTokenBlockTTL + } + return ttl +} diff --git a/backend/services/userauth/sv/service.go b/backend/services/userauth/sv/service.go new file mode 100644 index 0000000..2bc4275 --- /dev/null +++ b/backend/services/userauth/sv/service.go @@ -0,0 +1,176 @@ +package sv + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/respond" + userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao" + userauthauth "github.com/LoveLosita/smartflow/backend/services/userauth/internal/auth" + userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model" + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" + "github.com/LoveLosita/smartflow/backend/utils" + "gorm.io/gorm" +) + +type UserRepo interface { + Create(ctx context.Context, username, phoneNumber, password string) (*userauthmodel.User, error) + IfUsernameExists(ctx context.Context, name string) (bool, error) + GetUserHashedPasswordByName(ctx context.Context, name string) (string, error) + GetUserIDByName(ctx context.Context, name string) (int, error) + GetUserTokenQuotaByID(ctx context.Context, id int) (*userauthmodel.User, error) + ResetUserTokenUsageIfDue(ctx context.Context, id int, dueBefore time.Time, resetAt time.Time) (bool, error) + AddTokenUsage(ctx context.Context, id int, delta int) (bool, error) + AdjustTokenUsageOnce(ctx context.Context, eventID string, id int, delta int, dueBefore time.Time, resetAt time.Time) (*userauthmodel.User, bool, error) +} + +type CacheRepo interface { + IsBlacklisted(jti string) (bool, error) + SetBlacklist(jti string, expiration time.Duration) error + SetBlacklistIfAbsent(jti string, expiration time.Duration) (bool, error) + IsSessionBlacklisted(sessionID string) (bool, error) + SetSessionBlacklist(sessionID string, expiration time.Duration) error + IsUserTokenBlocked(ctx context.Context, userID int) (bool, error) + GetUserTokenQuotaSnapshot(ctx context.Context, userID int) (*userauthdao.TokenQuotaSnapshot, bool, error) + SetUserTokenQuotaSnapshot(ctx context.Context, userID int, snapshot userauthdao.TokenQuotaSnapshot, ttl time.Duration) error + DeleteUserTokenQuotaSnapshot(ctx context.Context, userID int) error + SetUserTokenBlocked(ctx context.Context, userID int, ttl time.Duration) error + DeleteUserTokenBlocked(ctx context.Context, userID int) error +} + +// Service 承载 user/auth 服务内部业务规则。 +// +// 职责边界: +// 1. 负责注册、登录、刷新、登出、JWT 签发/校验、黑名单和 token 额度门禁; +// 2. 不负责 Gin gateway 的响应适配、路由聚合和 SSE 等边缘职责; +// 3. 不负责 agent 会话 token 统计,迁移期该链路仍由 agent 持久化事件触发 userauth 账本调整。 +type Service struct { + userRepo UserRepo + cacheRepo CacheRepo +} + +func New(userRepo UserRepo, cacheRepo CacheRepo) *Service { + return &Service{ + userRepo: userRepo, + cacheRepo: cacheRepo, + } +} + +func (s *Service) Register(ctx context.Context, req contracts.RegisterRequest) (*contracts.RegisterResponse, error) { + if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" || strings.TrimSpace(req.PhoneNumber) == "" { + return nil, respond.MissingParam + } + if len(req.Username) > 45 || len(req.Password) > 229 || len(req.PhoneNumber) > 18 { + return nil, respond.ParamTooLong + } + + exists, err := s.userRepo.IfUsernameExists(ctx, req.Username) + if err != nil { + return nil, err + } + if exists { + return nil, respond.InvalidName + } + + hashedPwd, err := utils.HashPassword(req.Password) + if err != nil { + return nil, err + } + newUser, err := s.userRepo.Create(ctx, req.Username, req.PhoneNumber, hashedPwd) + if err != nil { + return nil, err + } + return &contracts.RegisterResponse{ID: newUser.ID}, nil +} + +func (s *Service) Login(ctx context.Context, req contracts.LoginRequest) (*contracts.Tokens, error) { + hashedPwd, err := s.userRepo.GetUserHashedPasswordByName(ctx, req.Username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, respond.WrongName + } + return nil, err + } + + matched, err := utils.CompareHashPwdAndPwd(hashedPwd, req.Password) + if err != nil { + return nil, err + } + if !matched { + return nil, respond.WrongPwd + } + + userID, err := s.userRepo.GetUserIDByName(ctx, req.Username) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, respond.WrongName + } + return nil, err + } + return userauthauth.GenerateTokens(userID) +} + +func (s *Service) RefreshToken(ctx context.Context, req contracts.RefreshTokenRequest) (*contracts.Tokens, error) { + if strings.TrimSpace(req.RefreshToken) == "" { + return nil, respond.MissingParam + } + claims, err := userauthauth.ValidateRefreshToken(req.RefreshToken, s.cacheRepo) + if err != nil { + return nil, err + } + + ttl := time.Until(claims.ExpiresAt.Time) + if ttl <= 0 { + return nil, respond.InvalidRefreshToken + } + // 1. 先用 SET NX 抢占旧 refresh 的 JTI,确保并发刷新时只有一个请求能继续签发新 token。 + // 2. 这里只黑掉旧 refresh,不黑掉整个 session,避免误伤同一会话下新签发的 access token。 + consumed, err := s.cacheRepo.SetBlacklistIfAbsent(claims.JTI, ttl) + if err != nil { + return nil, err + } + if !consumed { + return nil, respond.InvalidRefreshToken + } + + return userauthauth.GenerateTokensWithSession(claims.UserID, claims.SessionID) +} + +func (s *Service) LogoutByAccessToken(ctx context.Context, accessToken string) error { + if strings.TrimSpace(accessToken) == "" { + return respond.MissingToken + } + claims, err := userauthauth.ValidateAccessToken(accessToken, s.cacheRepo) + if err != nil { + return err + } + // 1. logout 的目标是整段会话,而不是单个 access token。 + // 2. 先按会话维度拉黑,再让 access / refresh 各自的 validate 流程拒绝后续请求。 + if strings.TrimSpace(claims.SessionID) == "" { + return s.cacheRepo.SetBlacklist(claims.JTI, time.Until(claims.ExpiresAt.Time)) + } + sessionTTL, err := userauthauth.SessionBlacklistTTL() + if err != nil { + return err + } + return s.cacheRepo.SetSessionBlacklist(claims.SessionID, sessionTTL) +} + +func (s *Service) ValidateAccessToken(ctx context.Context, req contracts.ValidateAccessTokenRequest) (*contracts.ValidateAccessTokenResponse, error) { + if strings.TrimSpace(req.AccessToken) == "" { + return nil, respond.MissingToken + } + claims, err := userauthauth.ValidateAccessToken(req.AccessToken, s.cacheRepo) + if err != nil { + return nil, err + } + return &contracts.ValidateAccessTokenResponse{ + Valid: true, + UserID: claims.UserID, + TokenType: claims.TokenType, + JTI: claims.JTI, + ExpiresAt: claims.ExpiresAt.Time, + }, nil +} diff --git a/backend/shared/contracts/userauth/types.go b/backend/shared/contracts/userauth/types.go new file mode 100644 index 0000000..bb323ed --- /dev/null +++ b/backend/shared/contracts/userauth/types.go @@ -0,0 +1,68 @@ +package userauth + +import "time" + +// RegisterRequest 是 user/auth 服务对外暴露的注册契约。 +// 职责边界:只描述跨进程请求字段,不承载校验、加密或持久化逻辑。 +type RegisterRequest struct { + Username string `json:"username"` + Password string `json:"password"` + PhoneNumber string `json:"phone_number"` +} + +// RegisterResponse 是注册成功后的稳定响应契约。 +type RegisterResponse struct { + ID uint `json:"id"` +} + +// LoginRequest 是登录请求契约。 +type LoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +// Tokens 保持历史接口 access_token / refresh_token 字段名不变。 +type Tokens struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` +} + +// RefreshTokenRequest 保持历史 old_refresh_token 字段名不变。 +type RefreshTokenRequest struct { + RefreshToken string `json:"old_refresh_token"` +} + +// ValidateAccessTokenRequest 是 gateway 调用 user/auth 做边缘鉴权时使用的内部契约。 +type ValidateAccessTokenRequest struct { + AccessToken string `json:"access_token"` +} + +// ValidateAccessTokenResponse 返回 gateway 后续轻量组合所需的最小身份信息。 +// Valid=false 预留给后续“软失败”兼容;当前实现遇到非法 token 会直接返回错误。 +type ValidateAccessTokenResponse struct { + Valid bool `json:"valid"` + UserID int `json:"user_id"` + TokenType string `json:"token_type"` + JTI string `json:"jti"` + ExpiresAt time.Time `json:"expires_at"` +} + +// CheckTokenQuotaRequest 是 agent/chat 进入业务前的额度门禁请求。 +type CheckTokenQuotaRequest struct { + UserID int `json:"user_id"` +} + +// AdjustTokenUsageRequest 是业务链路回写用户 token 账本的请求。 +type AdjustTokenUsageRequest struct { + EventID string `json:"event_id"` + UserID int `json:"user_id"` + TokenDelta int `json:"token_delta"` +} + +// CheckTokenQuotaResponse 返回额度门禁判断结果。 +type CheckTokenQuotaResponse struct { + Allowed bool `json:"allowed"` + TokenLimit int `json:"token_limit"` + TokenUsage int `json:"token_usage"` + LastResetAt time.Time `json:"last_reset_at"` +} diff --git a/backend/shared/ports/userauth.go b/backend/shared/ports/userauth.go new file mode 100644 index 0000000..ed42ffb --- /dev/null +++ b/backend/shared/ports/userauth.go @@ -0,0 +1,46 @@ +package ports + +import ( + "context" + + contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth" +) + +// UserCommandClient 是用户入口依赖的 user/auth 命令能力集合。 +// 职责边界: +// 1. 只描述注册、登录、刷新、登出这些入口能力; +// 2. 不暴露 DAO、Redis、JWT 签发细节; +// 3. 具体通信协议由 adapter 实现,调用方只按 res, err 语义处理。 +type UserCommandClient interface { + Register(ctx context.Context, req contracts.RegisterRequest) (*contracts.RegisterResponse, error) + Login(ctx context.Context, req contracts.LoginRequest) (*contracts.Tokens, error) + RefreshToken(ctx context.Context, req contracts.RefreshTokenRequest) (*contracts.Tokens, error) + Logout(ctx context.Context, accessToken string) error +} + +// AccessTokenValidator 是边缘鉴权依赖的最小接口。 +// 职责边界:只校验 access token 并返回后续业务所需的最小身份信息。 +type AccessTokenValidator interface { + ValidateAccessToken(ctx context.Context, accessToken string) (*contracts.ValidateAccessTokenResponse, error) +} + +// TokenQuotaChecker 是 agent/chat 入口做额度门禁时依赖的最小接口。 +// 职责边界:只判断当前用户是否允许继续消费 token,不负责 token 入账。 +type TokenQuotaChecker interface { + CheckTokenQuota(ctx context.Context, userID int) (*contracts.CheckTokenQuotaResponse, error) +} + +// TokenUsageAdjuster 是业务链路回写 token 账本时依赖的最小接口。 +// 职责边界:只做 token 账本增量调整,不承载鉴权与登录逻辑。 +type TokenUsageAdjuster interface { + AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) +} + +// UserAuthClient 组合当前阶段需要的 user/auth 能力。 +// 职责边界:作为统一装配口径,避免 gateway 和 core service 各自维护一份接口。 +type UserAuthClient interface { + UserCommandClient + AccessTokenValidator + TokenQuotaChecker + TokenUsageAdjuster +} diff --git a/docs/backend/学习计划论坛与Token商店PRD.md b/docs/backend/学习计划论坛与Token商店PRD.md new file mode 100644 index 0000000..a8ecc1e --- /dev/null +++ b/docs/backend/学习计划论坛与Token商店PRD.md @@ -0,0 +1,203 @@ +# 学习计划论坛与 Token 商店 PRD + +## 1. 文档定位 + +本文只记录当前需要讨论和快速推进的核心产品口径,不展开完整交互稿、运营后台和支付细节。 + +本轮目标是新增两个终态服务模块: + +1. `taskclass-forum`:支持用户分享 TaskClass 学习计划,并让其他用户一键导入。 +2. `token-store`:支持 Token 商品购买、活动奖励和发放账本。 + +两个模块后续都放在 `backend/services` 下,以独立服务为目标设计;当前仓库工作区未干净前只讨论 PRD,不进入代码实现。 + +## 2. 背景与目标 + +当前产品已经具备用户自建 TaskClass、智能排程和 Token 额度门禁能力。下一阶段希望补上社区化与商业化闭环: + +1. 用户可以把自己的复习计划分享出去,形成可浏览、可点赞、可评论、可复用的学习计划论坛。 +2. 其他用户可以一键导入计划模板,快速生成自己的 TaskClass。 +3. 被点赞、被导入等社区行为可以转化为 Token 激励。 +4. 用户可以通过 Token 商店购买或领取 Token,为后续高频 Agent 使用建立基础商业闭环。 + +## 3. 模块一:学习计划论坛 + +### 3.1 产品定位 + +学习计划论坛不是普通帖子论坛,而是“帖子 + TaskClass 模板快照”的社区。 + +用户发布时,系统从用户自己的 TaskClass 复制一份模板快照。其他用户导入时,再从快照生成自己的 TaskClass 副本。 + +快照原则: + +1. 发布后不直接引用原作者的 `task_classes` / `task_items`。 +2. 原作者后续修改自己的计划,不影响已发布模板。 +3. 导入用户拿到的是自己的 TaskClass 副本,后续可自由编辑。 +4. 不分享 `embedded_time`、schedule 绑定、用户私有排程状态。 + +### 3.2 P0 功能 + +1. 发布学习计划:用户选择一个 TaskClass,填写标题、简介、标签后发布。 +2. 浏览列表:支持分页查看公开计划,按最新、点赞数、导入数排序。 +3. 查看详情:展示计划说明、TaskClass 配置摘要和任务条目预览。 +4. 点赞:同一用户对同一帖子只能点赞一次,可取消点赞。 +5. 评论:支持基础评论列表和发表评论,P0 不做楼中楼。 +6. 一键导入:从论坛模板复制出当前用户自己的 TaskClass。 +7. 基础激励:模板获得点赞或导入后,可触发 Token 奖励事件。 + +### 3.3 P0 不做 + +1. 不做复杂推荐算法。 +2. 不做关注、私信、用户主页。 +3. 不做富文本编辑器,先用纯文本简介。 +4. 不做审核后台,先预留状态字段。 +5. 不直接把模板应用进 schedule;导入后由用户走现有 TaskClass / 排程链路。 + +### 3.4 核心实体 + +1. `forum_posts`:帖子主体,记录作者、标题、简介、状态、点赞数、评论数、导入数。 +2. `forum_post_templates`:TaskClass 快照,记录模式、日期范围、策略、约束配置等。 +3. `forum_post_template_items`:TaskClassItem 快照,只记录 order/content 等模板信息。 +4. `forum_likes`:点赞幂等记录。 +5. `forum_comments`:评论记录。 +6. `forum_imports`:导入记录,记录从哪个帖子导入到哪个用户和新 TaskClass ID。 + +### 3.5 关键流程 + +发布流程: + +1. 用户选择自己的 TaskClass。 +2. `taskclass-forum` 通过 TaskClass 读取端口拿到完整模板。 +3. 服务过滤私有字段,生成论坛快照。 +4. 写入帖子和模板快照。 + +导入流程: + +1. 用户点击一键导入。 +2. `taskclass-forum` 读取帖子模板快照。 +3. 通过 TaskClass 写入端口为当前用户创建 TaskClass 副本。 +4. 写入导入记录并增加导入计数。 +5. 可异步发布 Token 奖励事件。 + +## 4. 模块二:Token 商店 + +### 4.1 产品定位 + +Token 商店负责 Token 的购买、奖励、发放和账本,不负责登录鉴权,也不直接承载 Agent 消耗统计。 + +`user/auth` 继续负责用户 Token quota 的权威判断;`token-store` 只负责产生“发放 Token”的业务事实,并通过跨服务契约通知 `user/auth` 增加用户额度。 + +### 4.2 P0 功能 + +1. 商品列表:展示可购买 Token 包。 +2. 创建订单:用户选择商品生成订单。 +3. 支付确认:P0 先支持 mock paid 或管理端确认 paid,不接真实支付网关。 +4. Token 发放:订单支付成功后发放 Token。 +5. 奖励发放:支持论坛点赞、导入等事件触发奖励。 +6. 发放账本:所有发放必须有幂等 event_id,避免重复加额度。 + +### 4.3 P0 不做 + +1. 不接真实微信 / 支付宝 / Stripe。 +2. 不做退款、发票、优惠券。 +3. 不做复杂会员体系。 +4. 不直接改 `users.token_usage`,避免和消费统计混淆。 + +### 4.4 核心实体 + +1. `token_products`:Token 商品。 +2. `token_orders`:订单。 +3. `token_grants`:Token 发放账本,记录购买、奖励、补偿等来源。 +4. `token_reward_rules`:奖励规则,P0 可先用配置或简单表。 + +### 4.5 关键流程 + +购买流程: + +1. 用户选择商品并创建订单。 +2. 订单进入 `pending`。 +3. P0 通过 mock paid 或管理端确认,把订单置为 `paid`。 +4. `token-store` 写入 token grant 账本。 +5. `token-store` 调用 `user/auth` 的额度发放能力。 +6. 发放成功后订单进入 `granted`。 + +奖励流程: + +1. 论坛产生点赞或导入事件。 +2. `token-store` 按奖励规则判断是否发放。 +3. 写入 token grant 账本。 +4. 调用 `user/auth` 增加额度。 + +## 5. 服务边界 + +### 5.1 `taskclass-forum` + +负责: + +1. 论坛帖子、点赞、评论、导入记录。 +2. TaskClass 模板快照。 +3. 导入时的模板复制编排。 +4. 发布社区行为事件,供 Token 激励消费。 + +不负责: + +1. TaskClass 原始表所有权。 +2. schedule 写入和排程应用。 +3. Token 额度发放。 +4. 用户登录鉴权。 + +### 5.2 `token-store` + +负责: + +1. 商品、订单、发放账本。 +2. 社区奖励规则。 +3. 幂等发放。 +4. 调用 `user/auth` 增加 Token 额度。 + +不负责: + +1. JWT、登录、注册。 +2. Agent 消耗统计。 +3. TaskClass 论坛内容。 +4. 真实第三方支付回调,P0 只预留状态机。 + +### 5.3 与现有服务关系 + +1. 论坛读取和导入 TaskClass 时,先通过端口适配旧 `TaskClassService/DAO`。 +2. 后续 `task-class` 独立成服务后,只替换端口适配器。 +3. 论坛 P0 不直接写 schedule,避免被 `schedule` 未拆服务影响。 +4. Token 商店不直接改 users 表,通过 `user/auth` 契约发放额度。 + +## 6. 事件与激励 + +P0 建议事件: + +1. `forum.post.liked`:帖子被点赞。 +2. `forum.post.imported`:帖子被导入。 +3. `token.grant.requested`:请求发放 Token。 +4. `token.grant.completed`:Token 发放完成。 + +奖励口径先从简单规则开始: + +1. 每个帖子每个用户首次点赞只奖励一次。 +2. 每个帖子每个用户首次导入只奖励一次。 +3. 同一 event_id 的 Token 发放必须幂等。 +4. 奖励额度先走配置,不在 PRD 阶段定死。 + +## 7. 当前推进策略 + +1. 当前工作区存在其它拆服务改动,本阶段只提交 PRD。 +2. 等工作区干净后,从集成分支新开功能分支或单独 git worktree。 +3. 实现时两个服务主体可以并行推进。 +4. `gateway/router`、`shared/contracts`、`shared/ports`、`outbox route`、`config` 由主代理统一收口。 +5. 先做 P0 闭环,再扩展审核、真实支付和推荐排序。 + +## 8. 待讨论问题 + +1. 论坛展示名使用“学习计划论坛”“计划广场”还是“模板市场”。 +2. 点赞奖励是否给作者、点赞者,还是双方都给。 +3. 导入奖励是否需要上限,避免刷导入。 +4. 评论是否需要删除、举报、审核状态。 +5. Token 发放应增加 `user/auth` 的 `GrantTokenQuota`,还是命名为 `AdjustTokenLimit`。 +6. P0 是否需要前端先隐藏评论,只保留后端能力。