Version: 0.9.66.dev.260504

后端:
1. 阶段 2 user/auth 服务边界落地,新增 `cmd/userauth` go-zero zrpc 服务、`services/userauth` 核心实现、gateway user API/zrpc client 与 shared contracts/ports,迁移注册、登录、刷新 token、登出、JWT、黑名单和 token 额度治理
2. gateway 与启动装配切流,`cmd/all` 只保留边缘路由、鉴权和轻量组合,通过 userauth zrpc 访问核心用户能力;拆分 MySQL/Redis 初始化与 AutoMigrate 边界,`userauth` 自迁 `users` 和 token 记账幂等表,`all` 不再迁用户表
3. 清退 Gin 单体旧 user/auth DAO、model、service、router、middleware 和 JWT handler,并同步调整 agent/schedule/cache/outbox 相关调用依赖
4. 补齐 refresh token 防并发重放、MySQL 幂等 token 记账、额度 `>=` 拦截和 RPC 错误映射,避免重复记账与内部错误透出

文档:
1. 新增《学习计划论坛与Token商店PRD》
This commit is contained in:
Losita
2026-05-04 15:20:47 +08:00
parent 9902ca3563
commit b08ee17893
58 changed files with 3754 additions and 1510 deletions

View File

@@ -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 {

View File

@@ -1,7 +1,6 @@
package api
type ApiHandlers struct {
UserHandler *UserHandler
TaskHandler *TaskHandler
CourseHandler *CourseHandler
TaskClassHandler *TaskClassHandler

View File

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

View File

@@ -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 tokenerror 非空表示签发失败。
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
}

View File

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

View File

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

View File

@@ -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() {

View File

@@ -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,
})
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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),

View File

@@ -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 写入“排程预览”缓存。
//
// 职责边界:

View File

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

View File

@@ -0,0 +1,29 @@
package middleware
import (
"errors"
"net/http"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/gin-gonic/gin"
)
// writeRespondError 负责把项目内 respond.Response 统一写回 HTTP。
//
// 职责边界:
// 1. 只处理 respond.Response / 普通 error 到 HTTP JSON 的映射;
// 2. 不关心调用方来自哪个中间件,也不关心上游业务属于鉴权还是额度控制;
// 3. 方便多个 gateway 中间件复用同一套错误写回规则。
func writeRespondError(c *gin.Context, err error) {
if err == nil {
return
}
var resp respond.Response
if errors.As(err, &resp) {
c.JSON(resp.HTTPStatus(), resp)
return
}
c.JSON(http.StatusInternalServerError, respond.InternalError(err))
}

View File

@@ -0,0 +1,75 @@
package middleware
import (
"context"
"errors"
"net/http"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
// ExtractTokenFromAuthorization 从 Authorization 头中提取 token。
// 职责边界:
// 1. 兼容裸 token 与 Bearer token 两种传参方式;
// 2. 不做签名校验,只做字符串提取;
// 3. 返回空串表示缺少或格式非法。
func ExtractTokenFromAuthorization(header string) string {
trimmed := strings.TrimSpace(header)
if trimmed == "" {
return ""
}
parts := strings.Fields(trimmed)
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
return strings.TrimSpace(parts[1])
}
if len(parts) == 1 {
return parts[0]
}
return ""
}
// JWTTokenAuth 负责 access token 的 gateway 边缘鉴权。
// 职责边界:
// 1. 只验证 token并把 user_id 写入 gin 上下文;
// 2. 不直连 Redis、JWT 或 users 表,所有核心校验都交给 userauth 服务;
// 3. 校验失败时直接中断请求,由 respond 风格统一写回前端。
func JWTTokenAuth(validator ports.AccessTokenValidator) gin.HandlerFunc {
return func(c *gin.Context) {
if validator == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token validator dependency not initialized")))
c.Abort()
return
}
tokenString := ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
if tokenString == "" {
c.JSON(http.StatusUnauthorized, respond.MissingToken)
c.Abort()
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
resp, err := validator.ValidateAccessToken(ctx, tokenString)
if err != nil {
writeRespondError(c, err)
c.Abort()
return
}
if resp == nil || !resp.Valid || resp.UserID <= 0 {
c.JSON(http.StatusUnauthorized, respond.InvalidClaims)
c.Abort()
return
}
c.Set("user_id", resp.UserID)
c.Set("claims", resp)
c.Next()
}
}

View File

@@ -0,0 +1,51 @@
package middleware
import (
"context"
"errors"
"net/http"
"time"
"github.com/LoveLosita/smartflow/backend/respond"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
// TokenQuotaGuard 在请求入口做 token 额度门禁。
// 职责边界:
// 1. 只负责调用 user/auth 服务判断当前用户是否还能继续消耗 token
// 2. 不再直连 users 表或 Redis 额度细节;
// 3. 额度超限时直接拒绝,不进入业务 handler。
func TokenQuotaGuard(checker ports.TokenQuotaChecker) gin.HandlerFunc {
return func(c *gin.Context) {
if checker == nil {
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token quota checker dependency not initialized")))
c.Abort()
return
}
userID := c.GetInt("user_id")
if userID <= 0 {
c.JSON(http.StatusUnauthorized, respond.ErrUnauthorized)
c.Abort()
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
resp, err := checker.CheckTokenQuota(ctx, userID)
if err != nil {
writeRespondError(c, err)
c.Abort()
return
}
if resp == nil || !resp.Allowed {
c.JSON(http.StatusBadRequest, respond.TokenUsageExceedsLimit)
c.Abort()
return
}
c.Next()
}
}

View File

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

View File

@@ -0,0 +1,98 @@
package userapi
import (
"context"
"net/http"
"strings"
"time"
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
"github.com/LoveLosita/smartflow/backend/respond"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
type UserHandler struct {
client ports.UserCommandClient
}
// NewUserHandler 只接收 user/auth 客户端,不再直接依赖本地 user service。
func NewUserHandler(client ports.UserCommandClient) *UserHandler {
return &UserHandler{client: client}
}
func (api *UserHandler) UserRegister(c *gin.Context) {
var req contracts.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
retUser, err := api.client.Register(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, retUser))
}
func (api *UserHandler) UserLogin(c *gin.Context) {
var req contracts.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
tokens, err := api.client.Login(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
}
func (api *UserHandler) RefreshTokenHandler(c *gin.Context) {
var req contracts.RefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, respond.WrongParamType)
return
}
if strings.TrimSpace(req.RefreshToken) == "" {
c.JSON(http.StatusBadRequest, respond.MissingParam)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
tokens, err := api.client.RefreshToken(ctx, req)
if err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
}
func (api *UserHandler) UserLogout(c *gin.Context) {
token := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
if token == "" {
c.JSON(http.StatusUnauthorized, respond.MissingToken)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
if err := api.client.Logout(ctx, token); err != nil {
respond.DealWithError(c, err)
return
}
c.JSON(http.StatusOK, respond.Ok)
}

View File

@@ -0,0 +1,28 @@
package userapi
import (
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/LoveLosita/smartflow/backend/shared/ports"
"github.com/gin-gonic/gin"
)
// RegisterRoutes 把 user/auth HTTP 入口挂到 gateway 路由组。
// 职责边界:
// 1. 只注册 /user 下的边缘路由,不关心其它业务域路由;
// 2. 登录、注册、刷新 token 只做请求转发;登出需要先经过 access token 边缘鉴权;
// 3. 限流仍复用当前通用中间件,后续若 gateway 独立成包,可再整体下沉。
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *UserHandler, authClient ports.AccessTokenValidator, limiter *pkg.RateLimiter) {
if apiGroup == nil || handler == nil {
return
}
userGroup := apiGroup.Group("/user")
{
userGroup.POST("/register", handler.UserRegister)
userGroup.POST("/login", handler.UserLogin)
userGroup.POST("/refresh-token", handler.RefreshTokenHandler)
userGroup.POST("/logout", gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1), handler.UserLogout)
}
}

View File

@@ -0,0 +1,218 @@
package userauth
import (
"context"
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/services/userauth/rpc/pb"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
"github.com/zeromicro/go-zero/zrpc"
)
const (
defaultEndpoint = "127.0.0.1:9081"
defaultTimeout = 2 * time.Second
)
type ClientConfig struct {
Endpoints []string
Target string
Timeout time.Duration
}
// Client 是 gateway 侧 user/auth zrpc 的最小适配层。
//
// 职责边界:
// 1. 只负责跨进程 gRPC 调用和响应转译,不碰 DB / Redis / JWT 细节;
// 2. 服务端业务错误先通过 gRPC status 传输,再在这里反解回 respond.Response 风格;
// 3. 上层调用方仍然可以保持 `res, err :=` 的统一用法。
type Client struct {
rpc pb.UserAuthClient
}
func NewClient(cfg ClientConfig) (*Client, error) {
timeout := cfg.Timeout
if timeout <= 0 {
timeout = defaultTimeout
}
endpoints := normalizeEndpoints(cfg.Endpoints)
target := strings.TrimSpace(cfg.Target)
if len(endpoints) == 0 && target == "" {
endpoints = []string{defaultEndpoint}
}
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
Endpoints: endpoints,
Target: target,
NonBlock: true,
Timeout: int64(timeout / time.Millisecond),
})
if err != nil {
return nil, err
}
return &Client{rpc: pb.NewUserAuthClient(zclient.Conn())}, nil
}
func (c *Client) Register(ctx context.Context, req contracts.RegisterRequest) (*contracts.RegisterResponse, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.Register(ctx, &pb.RegisterRequest{
Username: req.Username,
Password: req.Password,
PhoneNumber: req.PhoneNumber,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("userauth zrpc service returned empty register response")
}
return &contracts.RegisterResponse{ID: uint(resp.Id)}, nil
}
func (c *Client) Login(ctx context.Context, req contracts.LoginRequest) (*contracts.Tokens, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.Login(ctx, &pb.LoginRequest{
Username: req.Username,
Password: req.Password,
})
if err != nil {
return nil, responseFromRPCError(err)
}
return tokensFromResponse(resp)
}
func (c *Client) RefreshToken(ctx context.Context, req contracts.RefreshTokenRequest) (*contracts.Tokens, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.RefreshToken(ctx, &pb.RefreshTokenRequest{
RefreshToken: req.RefreshToken,
})
if err != nil {
return nil, responseFromRPCError(err)
}
return tokensFromResponse(resp)
}
func (c *Client) Logout(ctx context.Context, accessToken string) error {
if err := c.ensureReady(); err != nil {
return err
}
resp, err := c.rpc.Logout(ctx, &pb.LogoutRequest{
AccessToken: accessToken,
})
if err != nil {
return responseFromRPCError(err)
}
if resp == nil {
return errors.New("userauth zrpc service returned empty logout response")
}
return nil
}
func (c *Client) ValidateAccessToken(ctx context.Context, accessToken string) (*contracts.ValidateAccessTokenResponse, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.ValidateAccessToken(ctx, &pb.ValidateAccessTokenRequest{
AccessToken: accessToken,
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("userauth zrpc service returned empty validate response")
}
return &contracts.ValidateAccessTokenResponse{
Valid: resp.Valid,
UserID: int(resp.UserId),
TokenType: resp.TokenType,
JTI: resp.Jti,
ExpiresAt: timeFromUnixNano(resp.ExpiresAtUnixNano),
}, nil
}
func (c *Client) CheckTokenQuota(ctx context.Context, userID int) (*contracts.CheckTokenQuotaResponse, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.CheckTokenQuota(ctx, &pb.CheckTokenQuotaRequest{
UserId: int64(userID),
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("userauth zrpc service returned empty quota response")
}
return &contracts.CheckTokenQuotaResponse{
Allowed: resp.Allowed,
TokenLimit: int(resp.TokenLimit),
TokenUsage: int(resp.TokenUsage),
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
}, nil
}
func (c *Client) AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) {
if err := c.ensureReady(); err != nil {
return nil, err
}
resp, err := c.rpc.AdjustTokenUsage(ctx, &pb.AdjustTokenUsageRequest{
EventId: req.EventID,
UserId: int64(req.UserID),
TokenDelta: int64(req.TokenDelta),
})
if err != nil {
return nil, responseFromRPCError(err)
}
if resp == nil {
return nil, errors.New("userauth zrpc service returned empty adjust response")
}
return &contracts.CheckTokenQuotaResponse{
Allowed: resp.Allowed,
TokenLimit: int(resp.TokenLimit),
TokenUsage: int(resp.TokenUsage),
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
}, nil
}
func (c *Client) ensureReady() error {
if c == nil || c.rpc == nil {
return errors.New("userauth zrpc client is not initialized")
}
return nil
}
func tokensFromResponse(resp *pb.TokensResponse) (*contracts.Tokens, error) {
if resp == nil {
return nil, errors.New("userauth zrpc service returned empty token response")
}
return &contracts.Tokens{
AccessToken: resp.AccessToken,
RefreshToken: resp.RefreshToken,
}, nil
}
func normalizeEndpoints(values []string) []string {
endpoints := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
endpoints = append(endpoints, trimmed)
}
}
return endpoints
}
func timeFromUnixNano(value int64) time.Time {
if value <= 0 {
return time.Time{}
}
return time.Unix(0, value)
}

View File

@@ -0,0 +1,198 @@
package userauth
import (
"errors"
"fmt"
"strings"
"github.com/LoveLosita/smartflow/backend/respond"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// responseFromRPCError 负责把 user/auth 的 gRPC 错误反解回项目内的 respond.Response。
//
// 职责边界:
// 1. 只在 gateway 边缘层使用,不下沉到服务实现里;
// 2. 业务错误尽量恢复成 respond.Response方便 API 层继续复用现有 DealWithError
// 3. 只要拿不到业务语义,就退化成普通 error让上层按 500 处理。
func responseFromRPCError(err error) error {
if err == nil {
return nil
}
st, ok := status.FromError(err)
if !ok {
return wrapRPCError(err)
}
if resp, ok := responseFromStatus(st); ok {
return resp
}
switch st.Code() {
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
msg := strings.TrimSpace(st.Message())
if msg == "" {
msg = "userauth zrpc service internal error"
}
return wrapRPCError(errors.New(msg))
}
msg := strings.TrimSpace(st.Message())
if msg == "" {
msg = "userauth zrpc service rejected request"
}
return respond.Response{
Status: grpcCodeToRespondStatus(st.Code()),
Info: msg,
}
}
func responseFromStatus(st *status.Status) (respond.Response, bool) {
if st == nil {
return respond.Response{}, false
}
if resp, ok := responseFromStatusDetails(st); ok {
return resp, true
}
if resp, ok := responseFromLegacyStatus(st.Code(), st.Message()); ok {
return resp, true
}
return respond.Response{}, false
}
func responseFromStatusDetails(st *status.Status) (respond.Response, bool) {
for _, detail := range st.Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok {
continue
}
statusValue := strings.TrimSpace(info.Reason)
if statusValue == "" {
statusValue = grpcCodeToRespondStatus(st.Code())
}
if statusValue == "" {
return respond.Response{}, false
}
message := strings.TrimSpace(st.Message())
if message == "" && info.Metadata != nil {
message = strings.TrimSpace(info.Metadata["info"])
}
if message == "" {
message = statusValue
}
return respond.Response{Status: statusValue, Info: message}, true
}
return respond.Response{}, false
}
func responseFromLegacyStatus(code codes.Code, message string) (respond.Response, bool) {
trimmed := strings.TrimSpace(message)
if resp, ok := respondResponseByMessage(trimmed); ok {
return resp, true
}
switch code {
case codes.Unauthenticated:
if trimmed == "" {
trimmed = "unauthorized"
}
return respond.Response{Status: respond.ErrUnauthorized.Status, Info: trimmed}, true
case codes.AlreadyExists:
if trimmed == "" {
trimmed = "already exists"
}
return respond.Response{Status: respond.InvalidName.Status, Info: trimmed}, true
case codes.NotFound:
if trimmed == "" {
trimmed = "not found"
}
return respond.Response{Status: respond.WrongName.Status, Info: trimmed}, true
case codes.ResourceExhausted:
if trimmed == "" {
trimmed = respond.TokenUsageExceedsLimit.Info
}
return respond.Response{Status: respond.TokenUsageExceedsLimit.Status, Info: trimmed}, true
case codes.InvalidArgument:
if trimmed == "" {
trimmed = "invalid argument"
}
return respond.Response{Status: respond.MissingParam.Status, Info: trimmed}, true
case codes.Internal, codes.Unknown, codes.DataLoss:
if trimmed == "" {
trimmed = "userauth service internal error"
}
return respond.InternalError(errors.New(trimmed)), true
}
return respond.Response{}, false
}
func respondResponseByMessage(message string) (respond.Response, bool) {
switch strings.TrimSpace(message) {
case respond.MissingParam.Info:
return respond.MissingParam, true
case respond.WrongParamType.Info:
return respond.WrongParamType, true
case respond.ParamTooLong.Info:
return respond.ParamTooLong, true
case respond.InvalidName.Info:
return respond.InvalidName, true
case respond.WrongName.Info:
return respond.WrongName, true
case respond.WrongPwd.Info:
return respond.WrongPwd, true
case respond.WrongUsernameOrPwd.Info:
return respond.WrongUsernameOrPwd, true
case respond.MissingToken.Info:
return respond.MissingToken, true
case respond.InvalidTokenSingingMethod.Info:
return respond.InvalidTokenSingingMethod, true
case respond.InvalidToken.Info:
return respond.InvalidToken, true
case respond.InvalidClaims.Info:
return respond.InvalidClaims, true
case respond.ErrUnauthorized.Info:
return respond.ErrUnauthorized, true
case respond.InvalidRefreshToken.Info:
return respond.InvalidRefreshToken, true
case respond.WrongTokenType.Info:
return respond.WrongTokenType, true
case respond.UserLoggedOut.Info:
return respond.UserLoggedOut, true
case respond.WrongUserID.Info:
return respond.WrongUserID, true
case respond.TokenUsageExceedsLimit.Info:
return respond.TokenUsageExceedsLimit, true
}
return respond.Response{}, false
}
func grpcCodeToRespondStatus(code codes.Code) string {
switch code {
case codes.Unauthenticated:
return respond.ErrUnauthorized.Status
case codes.AlreadyExists:
return respond.InvalidName.Status
case codes.NotFound:
return respond.WrongName.Status
case codes.ResourceExhausted:
return respond.TokenUsageExceedsLimit.Status
case codes.Internal, codes.Unknown, codes.DataLoss:
return "500"
default:
return "400"
}
}
func wrapRPCError(err error) error {
if err == nil {
return nil
}
return fmt.Errorf("调用 userauth zrpc 服务失败: %w", err)
}

View File

@@ -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
)

View File

@@ -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=

View File

@@ -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 {

View File

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

View File

@@ -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

View File

@@ -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,

View File

@@ -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 <token>”两种传参方式。
// 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()
}
}

View File

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

View File

@@ -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"`

View File

@@ -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 等标准字段
}

View File

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

View File

@@ -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/InfoHTTP 层只需要调用这个方法。
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))

View File

@@ -150,6 +150,7 @@ func (s *AgentService) PersistChatHistory(ctx context.Context, payload model.Cha
payload.ReasoningContent,
payload.ReasoningDurationSeconds,
payload.TokensConsumed,
"",
)
}
// 2. 已启用异步总线时,只发布“持久化请求事件”,不在请求路径阻塞 Kafka。

View File

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

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,

View File

@@ -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 {

View File

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

View File

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

View File

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

View File

@@ -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(&quota).Error
})
if err != nil {
return nil, false, err
}
if duplicated {
return nil, true, nil
}
return &quota, false, nil
}

View File

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

View File

@@ -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"
}

View File

@@ -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"
}

View File

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

View File

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

View File

@@ -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() {}

View File

@@ -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",
}

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

@@ -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"`
}

View File

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

View File

@@ -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 是否需要前端先隐藏评论,只保留后端能力。