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:
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package api
|
||||
|
||||
type ApiHandlers struct {
|
||||
UserHandler *UserHandler
|
||||
TaskHandler *TaskHandler
|
||||
CourseHandler *CourseHandler
|
||||
TaskClassHandler *TaskClassHandler
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/dao"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
const (
|
||||
accessSecretConfigKey = "jwt.accessSecret"
|
||||
refreshSecretConfigKey = "jwt.refreshSecret"
|
||||
accessExpireConfigKey = "jwt.accessTokenExpire"
|
||||
refreshExpireConfigKey = "jwt.refreshTokenExpire"
|
||||
|
||||
defaultAccessTokenExpire = 15 * time.Minute
|
||||
defaultRefreshTokenExpire = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type jwtRuntimeConfig struct {
|
||||
AccessKey []byte
|
||||
RefreshKey []byte
|
||||
AccessExpire time.Duration
|
||||
RefreshExpire time.Duration
|
||||
}
|
||||
|
||||
// AccessSigningKey 负责提供访问令牌签名/验签密钥。
|
||||
// 职责边界:
|
||||
// 1. 负责从运行时配置读取 accessSecret 并做空值校验。
|
||||
// 2. 不负责 token 解析、业务鉴权与错误码映射。
|
||||
// 3. 返回值语义:[]byte 为签名密钥;error 非空表示配置不可用。
|
||||
func AccessSigningKey() ([]byte, error) {
|
||||
cfg, err := loadJWTConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg.AccessKey, nil
|
||||
}
|
||||
|
||||
// generateJTI 生成唯一的 JWT ID。
|
||||
func generateJTI() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
// loadJWTConfig 负责聚合 JWT 运行时配置。
|
||||
// 职责边界:
|
||||
// 1. 负责读取密钥与过期时间配置,并转换为可直接使用的结构。
|
||||
// 2. 不负责持久化配置,也不负责降级到“不安全默认密钥”。
|
||||
// 3. 返回值语义:cfg 可直接用于签发/校验;error 非空表示配置不合法。
|
||||
func loadJWTConfig() (*jwtRuntimeConfig, error) {
|
||||
accessKey, err := readJWTSecret(accessSecretConfigKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refreshKey, err := readJWTSecret(refreshSecretConfigKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessExpire, err := readJWTExpireDuration(accessExpireConfigKey, defaultAccessTokenExpire)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
refreshExpire, err := readJWTExpireDuration(refreshExpireConfigKey, defaultRefreshTokenExpire)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &jwtRuntimeConfig{
|
||||
AccessKey: accessKey,
|
||||
RefreshKey: refreshKey,
|
||||
AccessExpire: accessExpire,
|
||||
RefreshExpire: refreshExpire,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// readJWTSecret 负责读取并校验 JWT 密钥配置。
|
||||
// 职责边界:
|
||||
// 1. 负责“读配置 + 去空白 + 空值校验”。
|
||||
// 2. 不负责任何默认值回退,避免静默使用弱配置。
|
||||
// 3. 返回值语义:[]byte 为密钥;error 非空表示该配置项不可用。
|
||||
func readJWTSecret(configKey string) ([]byte, error) {
|
||||
secret := strings.TrimSpace(viper.GetString(configKey))
|
||||
if secret == "" {
|
||||
return nil, fmt.Errorf("jwt 配置缺失: %s", configKey)
|
||||
}
|
||||
return []byte(secret), nil
|
||||
}
|
||||
|
||||
// readJWTExpireDuration 负责读取并解析 JWT 过期时间配置。
|
||||
// 职责边界:
|
||||
// 1. 负责把字符串配置解析成 time.Duration,并保证结果大于 0。
|
||||
// 2. 不负责签发 token;仅提供“可计算”的过期时长。
|
||||
// 3. 返回值语义:duration 为最终时长;error 非空表示格式非法。
|
||||
func readJWTExpireDuration(configKey string, fallback time.Duration) (time.Duration, error) {
|
||||
raw := strings.TrimSpace(viper.GetString(configKey))
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
d, err := parseFlexibleDuration(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("jwt 配置项 %s 非法: %w", configKey, err)
|
||||
}
|
||||
if d <= 0 {
|
||||
return 0, fmt.Errorf("jwt 配置项 %s 必须大于 0", configKey)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// parseFlexibleDuration 负责解析项目内常见时长格式。
|
||||
// 职责边界:
|
||||
// 1. 负责兼容 Go 标准格式(如 15m、168h)与项目常见格式(如 15min、7d)。
|
||||
// 2. 不负责读取配置键名;仅解析输入字符串。
|
||||
// 3. 输入输出语义:raw 为原始时长文本;返回解析后的正时长或错误。
|
||||
func parseFlexibleDuration(raw string) (time.Duration, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
if normalized == "" {
|
||||
return 0, errors.New("时长不能为空")
|
||||
}
|
||||
|
||||
// 1. 先走 Go 原生解析,优先兼容标准写法(如 15m/168h)。
|
||||
if d, err := time.ParseDuration(normalized); err == nil {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// 2. 原生解析失败后,兼容项目常见简写(如 15min、7d)。
|
||||
type unitDef struct {
|
||||
Suffix string
|
||||
Multiplier time.Duration
|
||||
}
|
||||
unitDefs := []unitDef{
|
||||
{Suffix: "minutes", Multiplier: time.Minute},
|
||||
{Suffix: "minute", Multiplier: time.Minute},
|
||||
{Suffix: "mins", Multiplier: time.Minute},
|
||||
{Suffix: "min", Multiplier: time.Minute},
|
||||
{Suffix: "days", Multiplier: 24 * time.Hour},
|
||||
{Suffix: "day", Multiplier: 24 * time.Hour},
|
||||
{Suffix: "d", Multiplier: 24 * time.Hour},
|
||||
{Suffix: "hours", Multiplier: time.Hour},
|
||||
{Suffix: "hour", Multiplier: time.Hour},
|
||||
{Suffix: "h", Multiplier: time.Hour},
|
||||
{Suffix: "seconds", Multiplier: time.Second},
|
||||
{Suffix: "second", Multiplier: time.Second},
|
||||
{Suffix: "secs", Multiplier: time.Second},
|
||||
{Suffix: "sec", Multiplier: time.Second},
|
||||
{Suffix: "m", Multiplier: time.Minute},
|
||||
{Suffix: "s", Multiplier: time.Second},
|
||||
}
|
||||
|
||||
for _, unit := range unitDefs {
|
||||
if !strings.HasSuffix(normalized, unit.Suffix) {
|
||||
continue
|
||||
}
|
||||
numberPart := strings.TrimSpace(strings.TrimSuffix(normalized, unit.Suffix))
|
||||
value, err := strconv.Atoi(numberPart)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("时长数值非法: %q", numberPart)
|
||||
}
|
||||
if value <= 0 {
|
||||
return 0, fmt.Errorf("时长数值必须大于 0: %d", value)
|
||||
}
|
||||
return time.Duration(value) * unit.Multiplier, nil
|
||||
}
|
||||
|
||||
return 0, fmt.Errorf("不支持的时长格式: %s", raw)
|
||||
}
|
||||
|
||||
// GenerateTokens 负责按配置签发访问令牌与刷新令牌。
|
||||
// 职责边界:
|
||||
// 1. 负责根据配置生成 exp,并签发 access/refresh 双 token。
|
||||
// 2. 不负责登录鉴权(用户名/密码验证在 service 层处理)。
|
||||
// 3. 返回值语义:第一个为 access token,第二个为 refresh token,error 非空表示签发失败。
|
||||
func GenerateTokens(userID int) (string, string, error) {
|
||||
cfg, err := loadJWTConfig()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
sid := generateJTI()
|
||||
|
||||
// 1. 先签 access token:短期有效,面向接口访问。
|
||||
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, model.MyCustomClaims{
|
||||
UserID: userID,
|
||||
TokenType: "access_token",
|
||||
Jti: sid,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(cfg.AccessExpire)),
|
||||
},
|
||||
})
|
||||
accessTokenString, err := accessToken.SignedString(cfg.AccessKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// 2. 再签 refresh token:长期有效,仅用于换发新 token。
|
||||
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, model.MyCustomClaims{
|
||||
UserID: userID,
|
||||
TokenType: "refresh_token",
|
||||
Jti: sid,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(cfg.RefreshExpire)),
|
||||
},
|
||||
})
|
||||
refreshTokenString, err := refreshToken.SignedString(cfg.RefreshKey)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return accessTokenString, refreshTokenString, nil
|
||||
}
|
||||
|
||||
// ValidateRefreshToken 验证刷新令牌的有效性,并增加 Redis 黑名单检查。
|
||||
func ValidateRefreshToken(tokenString string, cache *dao.CacheDAO) (*jwt.Token, error) {
|
||||
cfg, err := loadJWTConfig()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 1. 解析 refresh token,并强制校验签名算法与密钥来源。
|
||||
token, err := jwt.ParseWithClaims(tokenString, &model.MyCustomClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, respond.InvalidTokenSingingMethod
|
||||
}
|
||||
return cfg.RefreshKey, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, respond.InvalidRefreshToken
|
||||
}
|
||||
if !token.Valid {
|
||||
return nil, respond.InvalidRefreshToken
|
||||
}
|
||||
|
||||
// 2. 断言 claims 类型,后续业务字段都从结构体读取。
|
||||
claims, ok := token.Claims.(*model.MyCustomClaims)
|
||||
if !ok {
|
||||
return nil, respond.InvalidClaims
|
||||
}
|
||||
|
||||
// 3. 校验 token_type,防止把 access token 当 refresh token 用。
|
||||
if claims.TokenType != "refresh_token" {
|
||||
return nil, respond.WrongTokenType
|
||||
}
|
||||
|
||||
// 4. 黑名单校验:签名合法也要确认 jti 未被主动注销。
|
||||
isBlack, err := cache.IsBlacklisted(claims.Jti)
|
||||
if err != nil {
|
||||
return nil, errors.New("无法验证令牌状态")
|
||||
}
|
||||
if isBlack {
|
||||
return nil, respond.UserLoggedOut
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
25
backend/bootstrap/config.go
Normal file
25
backend/bootstrap/config.go
Normal 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
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
33
backend/cmd/userauth/main.go
Normal file
33
backend/cmd/userauth/main.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 写入“排程预览”缓存。
|
||||
//
|
||||
// 职责边界:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
29
backend/gateway/middleware/respond_error.go
Normal file
29
backend/gateway/middleware/respond_error.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// writeRespondError 负责把项目内 respond.Response 统一写回 HTTP。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只处理 respond.Response / 普通 error 到 HTTP JSON 的映射;
|
||||
// 2. 不关心调用方来自哪个中间件,也不关心上游业务属于鉴权还是额度控制;
|
||||
// 3. 方便多个 gateway 中间件复用同一套错误写回规则。
|
||||
func writeRespondError(c *gin.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
var resp respond.Response
|
||||
if errors.As(err, &resp) {
|
||||
c.JSON(resp.HTTPStatus(), resp)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(err))
|
||||
}
|
||||
75
backend/gateway/middleware/token_handler.go
Normal file
75
backend/gateway/middleware/token_handler.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ExtractTokenFromAuthorization 从 Authorization 头中提取 token。
|
||||
// 职责边界:
|
||||
// 1. 兼容裸 token 与 Bearer token 两种传参方式;
|
||||
// 2. 不做签名校验,只做字符串提取;
|
||||
// 3. 返回空串表示缺少或格式非法。
|
||||
func ExtractTokenFromAuthorization(header string) string {
|
||||
trimmed := strings.TrimSpace(header)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.Fields(trimmed)
|
||||
if len(parts) == 2 && strings.EqualFold(parts[0], "Bearer") {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
if len(parts) == 1 {
|
||||
return parts[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// JWTTokenAuth 负责 access token 的 gateway 边缘鉴权。
|
||||
// 职责边界:
|
||||
// 1. 只验证 token,并把 user_id 写入 gin 上下文;
|
||||
// 2. 不直连 Redis、JWT 或 users 表,所有核心校验都交给 userauth 服务;
|
||||
// 3. 校验失败时直接中断请求,由 respond 风格统一写回前端。
|
||||
func JWTTokenAuth(validator ports.AccessTokenValidator) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if validator == nil {
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token validator dependency not initialized")))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
tokenString := ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
|
||||
if tokenString == "" {
|
||||
c.JSON(http.StatusUnauthorized, respond.MissingToken)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := validator.ValidateAccessToken(ctx, tokenString)
|
||||
if err != nil {
|
||||
writeRespondError(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if resp == nil || !resp.Valid || resp.UserID <= 0 {
|
||||
c.JSON(http.StatusUnauthorized, respond.InvalidClaims)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", resp.UserID)
|
||||
c.Set("claims", resp)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
51
backend/gateway/middleware/token_quota_guard.go
Normal file
51
backend/gateway/middleware/token_quota_guard.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TokenQuotaGuard 在请求入口做 token 额度门禁。
|
||||
// 职责边界:
|
||||
// 1. 只负责调用 user/auth 服务判断当前用户是否还能继续消耗 token;
|
||||
// 2. 不再直连 users 表或 Redis 额度细节;
|
||||
// 3. 额度超限时直接拒绝,不进入业务 handler。
|
||||
func TokenQuotaGuard(checker ports.TokenQuotaChecker) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if checker == nil {
|
||||
c.JSON(http.StatusInternalServerError, respond.InternalError(errors.New("token quota checker dependency not initialized")))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
userID := c.GetInt("user_id")
|
||||
if userID <= 0 {
|
||||
c.JSON(http.StatusUnauthorized, respond.ErrUnauthorized)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := checker.CheckTokenQuota(ctx, userID)
|
||||
if err != nil {
|
||||
writeRespondError(c, err)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if resp == nil || !resp.Allowed {
|
||||
c.JSON(http.StatusBadRequest, respond.TokenUsageExceedsLimit)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
98
backend/gateway/userapi/handler.go
Normal file
98
backend/gateway/userapi/handler.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
client ports.UserCommandClient
|
||||
}
|
||||
|
||||
// NewUserHandler 只接收 user/auth 客户端,不再直接依赖本地 user service。
|
||||
func NewUserHandler(client ports.UserCommandClient) *UserHandler {
|
||||
return &UserHandler{client: client}
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserRegister(c *gin.Context) {
|
||||
var req contracts.RegisterRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
retUser, err := api.client.Register(ctx, req)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, retUser))
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserLogin(c *gin.Context) {
|
||||
var req contracts.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokens, err := api.client.Login(ctx, req)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
|
||||
}
|
||||
|
||||
func (api *UserHandler) RefreshTokenHandler(c *gin.Context) {
|
||||
var req contracts.RefreshTokenRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, respond.WrongParamType)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.RefreshToken) == "" {
|
||||
c.JSON(http.StatusBadRequest, respond.MissingParam)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
tokens, err := api.client.RefreshToken(ctx, req)
|
||||
if err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, tokens))
|
||||
}
|
||||
|
||||
func (api *UserHandler) UserLogout(c *gin.Context) {
|
||||
token := gatewaymiddleware.ExtractTokenFromAuthorization(c.GetHeader("Authorization"))
|
||||
if token == "" {
|
||||
c.JSON(http.StatusUnauthorized, respond.MissingToken)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := api.client.Logout(ctx, token); err != nil {
|
||||
respond.DealWithError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, respond.Ok)
|
||||
}
|
||||
28
backend/gateway/userapi/routes.go
Normal file
28
backend/gateway/userapi/routes.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package userapi
|
||||
|
||||
import (
|
||||
gatewaymiddleware "github.com/LoveLosita/smartflow/backend/gateway/middleware"
|
||||
rootmiddleware "github.com/LoveLosita/smartflow/backend/middleware"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/shared/ports"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterRoutes 把 user/auth HTTP 入口挂到 gateway 路由组。
|
||||
// 职责边界:
|
||||
// 1. 只注册 /user 下的边缘路由,不关心其它业务域路由;
|
||||
// 2. 登录、注册、刷新 token 只做请求转发;登出需要先经过 access token 边缘鉴权;
|
||||
// 3. 限流仍复用当前通用中间件,后续若 gateway 独立成包,可再整体下沉。
|
||||
func RegisterRoutes(apiGroup *gin.RouterGroup, handler *UserHandler, authClient ports.AccessTokenValidator, limiter *pkg.RateLimiter) {
|
||||
if apiGroup == nil || handler == nil {
|
||||
return
|
||||
}
|
||||
|
||||
userGroup := apiGroup.Group("/user")
|
||||
{
|
||||
userGroup.POST("/register", handler.UserRegister)
|
||||
userGroup.POST("/login", handler.UserLogin)
|
||||
userGroup.POST("/refresh-token", handler.RefreshTokenHandler)
|
||||
userGroup.POST("/logout", gatewaymiddleware.JWTTokenAuth(authClient), rootmiddleware.RateLimitMiddleware(limiter, 20, 1), handler.UserLogout)
|
||||
}
|
||||
}
|
||||
218
backend/gateway/userauth/client.go
Normal file
218
backend/gateway/userauth/client.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package userauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/services/userauth/rpc/pb"
|
||||
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
|
||||
"github.com/zeromicro/go-zero/zrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultEndpoint = "127.0.0.1:9081"
|
||||
defaultTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
Endpoints []string
|
||||
Target string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Client 是 gateway 侧 user/auth zrpc 的最小适配层。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责跨进程 gRPC 调用和响应转译,不碰 DB / Redis / JWT 细节;
|
||||
// 2. 服务端业务错误先通过 gRPC status 传输,再在这里反解回 respond.Response 风格;
|
||||
// 3. 上层调用方仍然可以保持 `res, err :=` 的统一用法。
|
||||
type Client struct {
|
||||
rpc pb.UserAuthClient
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) (*Client, error) {
|
||||
timeout := cfg.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
endpoints := normalizeEndpoints(cfg.Endpoints)
|
||||
target := strings.TrimSpace(cfg.Target)
|
||||
if len(endpoints) == 0 && target == "" {
|
||||
endpoints = []string{defaultEndpoint}
|
||||
}
|
||||
|
||||
zclient, err := zrpc.NewClient(zrpc.RpcClientConf{
|
||||
Endpoints: endpoints,
|
||||
Target: target,
|
||||
NonBlock: true,
|
||||
Timeout: int64(timeout / time.Millisecond),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Client{rpc: pb.NewUserAuthClient(zclient.Conn())}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Register(ctx context.Context, req contracts.RegisterRequest) (*contracts.RegisterResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.Register(ctx, &pb.RegisterRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
PhoneNumber: req.PhoneNumber,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty register response")
|
||||
}
|
||||
return &contracts.RegisterResponse{ID: uint(resp.Id)}, nil
|
||||
}
|
||||
|
||||
func (c *Client) Login(ctx context.Context, req contracts.LoginRequest) (*contracts.Tokens, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.Login(ctx, &pb.LoginRequest{
|
||||
Username: req.Username,
|
||||
Password: req.Password,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
return tokensFromResponse(resp)
|
||||
}
|
||||
|
||||
func (c *Client) RefreshToken(ctx context.Context, req contracts.RefreshTokenRequest) (*contracts.Tokens, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.RefreshToken(ctx, &pb.RefreshTokenRequest{
|
||||
RefreshToken: req.RefreshToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
return tokensFromResponse(resp)
|
||||
}
|
||||
|
||||
func (c *Client) Logout(ctx context.Context, accessToken string) error {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.rpc.Logout(ctx, &pb.LogoutRequest{
|
||||
AccessToken: accessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return errors.New("userauth zrpc service returned empty logout response")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) ValidateAccessToken(ctx context.Context, accessToken string) (*contracts.ValidateAccessTokenResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.ValidateAccessToken(ctx, &pb.ValidateAccessTokenRequest{
|
||||
AccessToken: accessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty validate response")
|
||||
}
|
||||
return &contracts.ValidateAccessTokenResponse{
|
||||
Valid: resp.Valid,
|
||||
UserID: int(resp.UserId),
|
||||
TokenType: resp.TokenType,
|
||||
JTI: resp.Jti,
|
||||
ExpiresAt: timeFromUnixNano(resp.ExpiresAtUnixNano),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) CheckTokenQuota(ctx context.Context, userID int) (*contracts.CheckTokenQuotaResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.CheckTokenQuota(ctx, &pb.CheckTokenQuotaRequest{
|
||||
UserId: int64(userID),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty quota response")
|
||||
}
|
||||
return &contracts.CheckTokenQuotaResponse{
|
||||
Allowed: resp.Allowed,
|
||||
TokenLimit: int(resp.TokenLimit),
|
||||
TokenUsage: int(resp.TokenUsage),
|
||||
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) {
|
||||
if err := c.ensureReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.rpc.AdjustTokenUsage(ctx, &pb.AdjustTokenUsageRequest{
|
||||
EventId: req.EventID,
|
||||
UserId: int64(req.UserID),
|
||||
TokenDelta: int64(req.TokenDelta),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, responseFromRPCError(err)
|
||||
}
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty adjust response")
|
||||
}
|
||||
return &contracts.CheckTokenQuotaResponse{
|
||||
Allowed: resp.Allowed,
|
||||
TokenLimit: int(resp.TokenLimit),
|
||||
TokenUsage: int(resp.TokenUsage),
|
||||
LastResetAt: timeFromUnixNano(resp.LastResetAtUnixNano),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) ensureReady() error {
|
||||
if c == nil || c.rpc == nil {
|
||||
return errors.New("userauth zrpc client is not initialized")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tokensFromResponse(resp *pb.TokensResponse) (*contracts.Tokens, error) {
|
||||
if resp == nil {
|
||||
return nil, errors.New("userauth zrpc service returned empty token response")
|
||||
}
|
||||
return &contracts.Tokens{
|
||||
AccessToken: resp.AccessToken,
|
||||
RefreshToken: resp.RefreshToken,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func normalizeEndpoints(values []string) []string {
|
||||
endpoints := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed != "" {
|
||||
endpoints = append(endpoints, trimmed)
|
||||
}
|
||||
}
|
||||
return endpoints
|
||||
}
|
||||
|
||||
func timeFromUnixNano(value int64) time.Time {
|
||||
if value <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return time.Unix(0, value)
|
||||
}
|
||||
198
backend/gateway/userauth/errors.go
Normal file
198
backend/gateway/userauth/errors.go
Normal file
@@ -0,0 +1,198 @@
|
||||
package userauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"google.golang.org/genproto/googleapis/rpc/errdetails"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// responseFromRPCError 负责把 user/auth 的 gRPC 错误反解回项目内的 respond.Response。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只在 gateway 边缘层使用,不下沉到服务实现里;
|
||||
// 2. 业务错误尽量恢复成 respond.Response,方便 API 层继续复用现有 DealWithError;
|
||||
// 3. 只要拿不到业务语义,就退化成普通 error,让上层按 500 处理。
|
||||
func responseFromRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
st, ok := status.FromError(err)
|
||||
if !ok {
|
||||
return wrapRPCError(err)
|
||||
}
|
||||
|
||||
if resp, ok := responseFromStatus(st); ok {
|
||||
return resp
|
||||
}
|
||||
|
||||
switch st.Code() {
|
||||
case codes.Internal, codes.Unknown, codes.Unavailable, codes.DeadlineExceeded, codes.DataLoss, codes.Unimplemented:
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "userauth zrpc service internal error"
|
||||
}
|
||||
return wrapRPCError(errors.New(msg))
|
||||
}
|
||||
|
||||
msg := strings.TrimSpace(st.Message())
|
||||
if msg == "" {
|
||||
msg = "userauth zrpc service rejected request"
|
||||
}
|
||||
return respond.Response{
|
||||
Status: grpcCodeToRespondStatus(st.Code()),
|
||||
Info: msg,
|
||||
}
|
||||
}
|
||||
|
||||
func responseFromStatus(st *status.Status) (respond.Response, bool) {
|
||||
if st == nil {
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
if resp, ok := responseFromStatusDetails(st); ok {
|
||||
return resp, true
|
||||
}
|
||||
if resp, ok := responseFromLegacyStatus(st.Code(), st.Message()); ok {
|
||||
return resp, true
|
||||
}
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func responseFromStatusDetails(st *status.Status) (respond.Response, bool) {
|
||||
for _, detail := range st.Details() {
|
||||
info, ok := detail.(*errdetails.ErrorInfo)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
statusValue := strings.TrimSpace(info.Reason)
|
||||
if statusValue == "" {
|
||||
statusValue = grpcCodeToRespondStatus(st.Code())
|
||||
}
|
||||
if statusValue == "" {
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
message := strings.TrimSpace(st.Message())
|
||||
if message == "" && info.Metadata != nil {
|
||||
message = strings.TrimSpace(info.Metadata["info"])
|
||||
}
|
||||
if message == "" {
|
||||
message = statusValue
|
||||
}
|
||||
return respond.Response{Status: statusValue, Info: message}, true
|
||||
}
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func responseFromLegacyStatus(code codes.Code, message string) (respond.Response, bool) {
|
||||
trimmed := strings.TrimSpace(message)
|
||||
if resp, ok := respondResponseByMessage(trimmed); ok {
|
||||
return resp, true
|
||||
}
|
||||
|
||||
switch code {
|
||||
case codes.Unauthenticated:
|
||||
if trimmed == "" {
|
||||
trimmed = "unauthorized"
|
||||
}
|
||||
return respond.Response{Status: respond.ErrUnauthorized.Status, Info: trimmed}, true
|
||||
case codes.AlreadyExists:
|
||||
if trimmed == "" {
|
||||
trimmed = "already exists"
|
||||
}
|
||||
return respond.Response{Status: respond.InvalidName.Status, Info: trimmed}, true
|
||||
case codes.NotFound:
|
||||
if trimmed == "" {
|
||||
trimmed = "not found"
|
||||
}
|
||||
return respond.Response{Status: respond.WrongName.Status, Info: trimmed}, true
|
||||
case codes.ResourceExhausted:
|
||||
if trimmed == "" {
|
||||
trimmed = respond.TokenUsageExceedsLimit.Info
|
||||
}
|
||||
return respond.Response{Status: respond.TokenUsageExceedsLimit.Status, Info: trimmed}, true
|
||||
case codes.InvalidArgument:
|
||||
if trimmed == "" {
|
||||
trimmed = "invalid argument"
|
||||
}
|
||||
return respond.Response{Status: respond.MissingParam.Status, Info: trimmed}, true
|
||||
case codes.Internal, codes.Unknown, codes.DataLoss:
|
||||
if trimmed == "" {
|
||||
trimmed = "userauth service internal error"
|
||||
}
|
||||
return respond.InternalError(errors.New(trimmed)), true
|
||||
}
|
||||
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func respondResponseByMessage(message string) (respond.Response, bool) {
|
||||
switch strings.TrimSpace(message) {
|
||||
case respond.MissingParam.Info:
|
||||
return respond.MissingParam, true
|
||||
case respond.WrongParamType.Info:
|
||||
return respond.WrongParamType, true
|
||||
case respond.ParamTooLong.Info:
|
||||
return respond.ParamTooLong, true
|
||||
case respond.InvalidName.Info:
|
||||
return respond.InvalidName, true
|
||||
case respond.WrongName.Info:
|
||||
return respond.WrongName, true
|
||||
case respond.WrongPwd.Info:
|
||||
return respond.WrongPwd, true
|
||||
case respond.WrongUsernameOrPwd.Info:
|
||||
return respond.WrongUsernameOrPwd, true
|
||||
case respond.MissingToken.Info:
|
||||
return respond.MissingToken, true
|
||||
case respond.InvalidTokenSingingMethod.Info:
|
||||
return respond.InvalidTokenSingingMethod, true
|
||||
case respond.InvalidToken.Info:
|
||||
return respond.InvalidToken, true
|
||||
case respond.InvalidClaims.Info:
|
||||
return respond.InvalidClaims, true
|
||||
case respond.ErrUnauthorized.Info:
|
||||
return respond.ErrUnauthorized, true
|
||||
case respond.InvalidRefreshToken.Info:
|
||||
return respond.InvalidRefreshToken, true
|
||||
case respond.WrongTokenType.Info:
|
||||
return respond.WrongTokenType, true
|
||||
case respond.UserLoggedOut.Info:
|
||||
return respond.UserLoggedOut, true
|
||||
case respond.WrongUserID.Info:
|
||||
return respond.WrongUserID, true
|
||||
case respond.TokenUsageExceedsLimit.Info:
|
||||
return respond.TokenUsageExceedsLimit, true
|
||||
}
|
||||
return respond.Response{}, false
|
||||
}
|
||||
|
||||
func grpcCodeToRespondStatus(code codes.Code) string {
|
||||
switch code {
|
||||
case codes.Unauthenticated:
|
||||
return respond.ErrUnauthorized.Status
|
||||
case codes.AlreadyExists:
|
||||
return respond.InvalidName.Status
|
||||
case codes.NotFound:
|
||||
return respond.WrongName.Status
|
||||
case codes.ResourceExhausted:
|
||||
return respond.TokenUsageExceedsLimit.Status
|
||||
case codes.Internal, codes.Unknown, codes.DataLoss:
|
||||
return "500"
|
||||
default:
|
||||
return "400"
|
||||
}
|
||||
}
|
||||
|
||||
func wrapRPCError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("调用 userauth zrpc 服务失败: %w", err)
|
||||
}
|
||||
104
backend/go.mod
104
backend/go.mod
@@ -1,6 +1,6 @@
|
||||
module github.com/LoveLosita/smartflow/backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/cloudwego/eino v0.7.13
|
||||
@@ -9,81 +9,155 @@ require (
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-redis/redis/v8 v8.11.5
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/golang/protobuf v1.5.4
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/segmentio/kafka-go v0.4.47
|
||||
github.com/spf13/viper v1.21.0
|
||||
github.com/volcengine/volcengine-go-sdk v1.2.9
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
gorm.io/driver/mysql v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/coreos/go-semver v0.3.1 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/google/gnostic-models v0.7.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.etcd.io/etcd/api/v3 v3.5.21 // indirect
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.21 // indirect
|
||||
go.etcd.io/etcd/client/v3 v3.5.21 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
go.uber.org/zap v1.24.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/term v0.40.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||
k8s.io/api v0.34.3 // indirect
|
||||
k8s.io/apimachinery v0.34.3 // indirect
|
||||
k8s.io/client-go v0.34.3 // indirect
|
||||
k8s.io/klog/v2 v2.130.1 // indirect
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
|
||||
sigs.k8s.io/randfill v1.0.0 // indirect
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect
|
||||
sigs.k8s.io/yaml v1.6.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.1.0 // indirect
|
||||
github.com/bahlo/generic-list-go v0.2.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.2.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/eino-contrib/jsonschema v1.0.3 // indirect
|
||||
github.com/evanphx/json-patch v0.5.2 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goph/emperror v0.17.2 // indirect
|
||||
github.com/grafana/pyroscope-go v1.2.8 // indirect
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jmespath/go-jmespath v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/compress v1.15.9 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.1 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nikolalohinski/gonja v1.5.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.15 // indirect
|
||||
github.com/openzipkin/zipkin-go v0.4.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/slongfield/pyfmt v0.0.0-20220222012616-ea85ff4c361f // indirect
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/titanous/json5 v1.0.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/volcengine/volc-sdk-golang v1.0.23 // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/yargevad/filepathx v1.0.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
github.com/zeromicro/go-zero v1.10.1
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 // indirect
|
||||
golang.org/x/mod v0.26.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.28.0 // indirect
|
||||
golang.org/x/tools v0.35.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
|
||||
google.golang.org/grpc v1.79.3
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
280
backend/go.sum
280
backend/go.sum
@@ -3,12 +3,21 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0 h1:RheObYW32G1aiJIj81XVt78ZHJpHonHLHW7OLIshq68=
|
||||
github.com/alicebob/miniredis/v2 v2.37.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
|
||||
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
|
||||
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bitly/go-simplejson v0.5.0/go.mod h1:cXHtHw4XUPsvGaxgjIAn8PhEWG9NfngEKAMDJEczWVA=
|
||||
github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
|
||||
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bugsnag/bugsnag-go v1.4.0/go.mod h1:2oa8nejYd4cQ/b0hMIopN0lCRxU0bueqREvZLWFrtK8=
|
||||
@@ -21,10 +30,12 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4=
|
||||
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
|
||||
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
@@ -34,6 +45,11 @@ github.com/cloudwego/eino-ext/components/model/ark v0.1.64 h1:ecsP4xWhOGi6NYxl2N
|
||||
github.com/cloudwego/eino-ext/components/model/ark v0.1.64/go.mod h1:aabMR15RTXBSi9Eu13CWavzE+no5BQO4FJUEEdqImbg=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13 h1:z0bI5TH3nE+uDQiRhxBQMvk2HswlDUM3xP38+VSgpSQ=
|
||||
github.com/cloudwego/eino-ext/libs/acl/openai v0.1.13/go.mod h1:1xMQZ8eE11pkEoTAEy8UlaAY817qGVMvjpDPGSIO3Ns=
|
||||
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
|
||||
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -43,16 +59,21 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0=
|
||||
github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU=
|
||||
github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/evanphx/json-patch v0.5.2 h1:xVCHIVMUu1wtM/VkR9jVZ45N3FhZfYMMYGorLCR8P3k=
|
||||
github.com/evanphx/json-patch v0.5.2/go.mod h1:ZWS5hhDbVDyob71nXKNL0+PWn6ToqBHMikGIFbs31qQ=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ=
|
||||
@@ -62,6 +83,19 @@ github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -72,15 +106,21 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
|
||||
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
|
||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -95,6 +135,10 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
|
||||
github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -104,6 +148,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
|
||||
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
@@ -111,6 +157,14 @@ github.com/goph/emperror v0.17.2 h1:yLapQcmEsO0ipe9p5TaN22djm3OFV/TfM/fcYP0/J18=
|
||||
github.com/goph/emperror v0.17.2/go.mod h1:+ZbQ+fUNO/6FNiUo0ujtMjhgad9Xa6fQL9KhH4LNHic=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/grafana/pyroscope-go v1.2.8 h1:UvCwIhlx9DeV7F6TW/z8q1Mi4PIm3vuUJ2ZlCEvmA4M=
|
||||
github.com/grafana/pyroscope-go v1.2.8/go.mod h1:SSi59eQ1/zmKoY/BKwa5rSFsJaq+242Bcrr4wPix1g8=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
|
||||
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542/go.mod h1:Ow0tF8D4Kplbc8s8sSb3V2oUCygFHVp8gC3Dn6U4MNI=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@@ -121,34 +175,41 @@ github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9Y
|
||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
|
||||
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/kardianos/osext v0.0.0-20190222173326-2bc1f35cddc0/go.mod h1:1NbS8ALrpOvjt0rHPNLyCIeMtbizbir8U//inJ+zuB8=
|
||||
github.com/klauspost/compress v1.15.9 h1:wKRjX6JRtDdrE9qwa4b/Cip7ACOshUI4smpCQanqjSY=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.1 h1:u/IMMgrj/d617Dh/8BKAwlcstD74ynOJzCtVl+y8xAs=
|
||||
github.com/meguminnnnnnnnn/go-openai v0.1.1/go.mod h1:qs96ysDmxhE4BZoU45I43zcyfnaYxU3X+aRzLko/htY=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
@@ -156,8 +217,11 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyex
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8=
|
||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c=
|
||||
github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTfXhkJv6YBtPa4=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
@@ -166,29 +230,44 @@ github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+W
|
||||
github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
|
||||
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
|
||||
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||
github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||
github.com/onsi/gomega v1.27.3 h1:5VwIwnBY3vbBDOJrNtA4rVdiTZCsq9B5F12pvy1Drmk=
|
||||
github.com/onsi/gomega v1.27.3/go.mod h1:5vG284IBtfDAmDyrK+eGyZmUgUlmi+Wngqo557cZ6Gw=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.15 h1:MO0/ucJhngq7299dKLwIMtgTfbkoSPF6AoMYDd8Q4q0=
|
||||
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
||||
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI=
|
||||
github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/robertkrimen/otto v0.2.1 h1:FVP0PJ0AHIjC+N4pKCG9yCDz6LHNPCwi/GKID5pGGF0=
|
||||
github.com/robertkrimen/otto v0.2.1/go.mod h1:UPwtJ1Xu7JrLcZjNWN8orJaM5n5YEtqL//farB5FlRY=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rollbar/rollbar-go v1.0.2/go.mod h1:AcFs5f0I+c71bpHlXNNDbOWJiKwjFDtISeXco0L5PKQ=
|
||||
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||
@@ -205,6 +284,8 @@ github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sS
|
||||
github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
@@ -217,6 +298,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
@@ -224,12 +306,15 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/titanous/json5 v1.0.0 h1:hJf8Su1d9NuI/ffpxgxQfxh/UiBFZX7bMPid0rIL/7s=
|
||||
github.com/titanous/json5 v1.0.0/go.mod h1:7JH1M8/LHKc6cyP5o5g3CSaRj+mBrIimTxzpvmckH8c=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
@@ -242,81 +327,147 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
|
||||
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY=
|
||||
github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4=
|
||||
github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs=
|
||||
github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8=
|
||||
github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8=
|
||||
github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM=
|
||||
github.com/yargevad/filepathx v1.0.0 h1:SYcT+N3tYGi+NvazubCNlvgIPbzAk7i7y2dwg3I5FYc=
|
||||
github.com/yargevad/filepathx v1.0.0/go.mod h1:BprfX/gpYNJHJfc35GjRRpVcwWXS89gGulUIU5tK3tA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
github.com/zeromicro/go-zero v1.10.1 h1:1nM3ilvYx97GUqyaNH2IQPtfNyK7tp5JvN63c7m6QKU=
|
||||
github.com/zeromicro/go-zero v1.10.1/go.mod h1:z41DXmO6gx/Se7Ow5UIwPxcUmpVj3ebhoNCcZ1gfp5k=
|
||||
go.etcd.io/etcd/api/v3 v3.5.21 h1:A6O2/JDb3tvHhiIz3xf9nJ7REHvtEFJJ3veW3FbCnS8=
|
||||
go.etcd.io/etcd/api/v3 v3.5.21/go.mod h1:c3aH5wcvXv/9dqIw2Y810LDXJfhSYdHQ0vxmP3CCHVY=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoBqUTc=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs=
|
||||
go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY=
|
||||
go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.40.0 h1:zu+I4j+FdO6xIxBVPeuncQVbjxUM4LiMgv6GwGe9REE=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.40.0/go.mod h1:zS6cC4nFBYXbu18e7aLfMzubBjOiN7ZcROu477qtMf8=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw=
|
||||
golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
|
||||
golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/telemetry v0.0.0-20250710130107-8d8967aff50b/go.mod h1:4ZwOYna0/zsOKwuR5X/m0QFOJpSZvAxFfkQT+Erd9D4=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
|
||||
golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
|
||||
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
@@ -324,28 +475,42 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
|
||||
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4 h1:yOzSCGPx+cp5VO7IxvZ9SBFF7j1tZVcNtlHR2iYKtVo=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:Q9HWtNeE7tM9npdIsEvqXj1QJIvVoeAV3rtXtS715Cw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -357,12 +522,21 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
|
||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4=
|
||||
gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
|
||||
gopkg.in/h2non/gock.v1 v1.1.2/go.mod h1:n7UGz/ckNChHiK05rDoiC4MYSunEC/lyaUm2WWaDva0=
|
||||
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
|
||||
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
@@ -375,9 +549,27 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
k8s.io/api v0.34.3 h1:D12sTP257/jSH2vHV2EDYrb16bS7ULlHpdNdNhEw2S4=
|
||||
k8s.io/api v0.34.3/go.mod h1:PyVQBF886Q5RSQZOim7DybQjAbVs8g7gwJNhGtY5MBk=
|
||||
k8s.io/apimachinery v0.34.3 h1:/TB+SFEiQvN9HPldtlWOTp0hWbJ+fjU+wkxysf/aQnE=
|
||||
k8s.io/apimachinery v0.34.3/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw=
|
||||
k8s.io/client-go v0.34.3 h1:wtYtpzy/OPNYf7WyNBTj3iUA0XaBHVqhv4Iv3tbrF5A=
|
||||
k8s.io/client-go v0.34.3/go.mod h1:OxxeYagaP9Kdf78UrKLa3YZixMCfP6bgPwPwNBQBzpM=
|
||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA=
|
||||
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts=
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM=
|
||||
k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE=
|
||||
sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg=
|
||||
sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU=
|
||||
sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco=
|
||||
sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE=
|
||||
sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs=
|
||||
sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4=
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 等标准字段
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package respond
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -24,6 +25,26 @@ func (r Response) Error() string { // 实现 error 接口
|
||||
return r.Info
|
||||
}
|
||||
|
||||
// HTTPStatus 负责把项目内部响应码映射到 HTTP 状态码。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只根据当前响应码判断该返回 200/400/401/500 里的哪一类;
|
||||
// 2. 不负责写响应体,也不负责区分具体业务来源;
|
||||
// 3. 业务侧可以继续透传 Status/Info,HTTP 层只需要调用这个方法。
|
||||
func (r Response) HTTPStatus() int {
|
||||
switch r.Status {
|
||||
case MissingToken.Status, InvalidToken.Status, InvalidClaims.Status, InvalidRefreshToken.Status,
|
||||
WrongTokenType.Status, UserLoggedOut.Status, ErrUnauthorized.Status:
|
||||
return http.StatusUnauthorized
|
||||
case UserTasksEmpty.Status, NoOngoingOrUpcomingSchedule.Status, TaskAlreadyDeleted.Status:
|
||||
return http.StatusOK
|
||||
}
|
||||
if strings.HasPrefix(strings.TrimSpace(r.Status), "5") {
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
func RespWithData(response Response, data interface{}) FinalResponse { //传入一个响应结构体和数据,返回一个最终响应结构体
|
||||
var finalResponse FinalResponse
|
||||
finalResponse.Status = response.Status
|
||||
@@ -42,7 +63,7 @@ func DealWithError(c *gin.Context, err error) { //处理错误,返回对应的
|
||||
return
|
||||
}
|
||||
if errors.As(err, &resp) {
|
||||
c.JSON(http.StatusBadRequest, resp)
|
||||
c.JSON(resp.HTTPStatus(), resp)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, InternalError(err))
|
||||
|
||||
@@ -150,6 +150,7 @@ func (s *AgentService) PersistChatHistory(ctx context.Context, payload model.Cha
|
||||
payload.ReasoningContent,
|
||||
payload.ReasoningDurationSeconds,
|
||||
payload.TokensConsumed,
|
||||
"",
|
||||
)
|
||||
}
|
||||
// 2. 已启用异步总线时,只发布“持久化请求事件”,不在请求路径阻塞 Kafka。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
130
backend/services/userauth/dao/cache.go
Normal file
130
backend/services/userauth/dao/cache.go
Normal 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()
|
||||
}
|
||||
55
backend/services/userauth/dao/connect.go
Normal file
55
backend/services/userauth/dao/connect.go
Normal 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
|
||||
}
|
||||
173
backend/services/userauth/dao/user.go
Normal file
173
backend/services/userauth/dao/user.go
Normal 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("a).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if duplicated {
|
||||
return nil, true, nil
|
||||
}
|
||||
return "a, false, nil
|
||||
}
|
||||
330
backend/services/userauth/internal/auth/tokens.go
Normal file
330
backend/services/userauth/internal/auth/tokens.go
Normal 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
|
||||
}
|
||||
20
backend/services/userauth/model/token_usage_adjustment.go
Normal file
20
backend/services/userauth/model/token_usage_adjustment.go
Normal 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"
|
||||
}
|
||||
19
backend/services/userauth/model/user.go
Normal file
19
backend/services/userauth/model/user.go
Normal 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"
|
||||
}
|
||||
86
backend/services/userauth/rpc/errors.go
Normal file
86
backend/services/userauth/rpc/errors.go
Normal 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
|
||||
}
|
||||
177
backend/services/userauth/rpc/handler.go
Normal file
177
backend/services/userauth/rpc/handler.go
Normal 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()
|
||||
}
|
||||
151
backend/services/userauth/rpc/pb/userauth.pb.go
Normal file
151
backend/services/userauth/rpc/pb/userauth.pb.go
Normal 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() {}
|
||||
307
backend/services/userauth/rpc/pb/userauth_grpc.pb.go
Normal file
307
backend/services/userauth/rpc/pb/userauth_grpc.pb.go
Normal 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",
|
||||
}
|
||||
72
backend/services/userauth/rpc/server.go
Normal file
72
backend/services/userauth/rpc/server.go
Normal 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
|
||||
}
|
||||
75
backend/services/userauth/rpc/userauth.proto
Normal file
75
backend/services/userauth/rpc/userauth.proto
Normal 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;
|
||||
}
|
||||
192
backend/services/userauth/sv/quota.go
Normal file
192
backend/services/userauth/sv/quota.go
Normal 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
|
||||
}
|
||||
176
backend/services/userauth/sv/service.go
Normal file
176
backend/services/userauth/sv/service.go
Normal 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
|
||||
}
|
||||
68
backend/shared/contracts/userauth/types.go
Normal file
68
backend/shared/contracts/userauth/types.go
Normal 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"`
|
||||
}
|
||||
46
backend/shared/ports/userauth.go
Normal file
46
backend/shared/ports/userauth.go
Normal 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
|
||||
}
|
||||
203
docs/backend/学习计划论坛与Token商店PRD.md
Normal file
203
docs/backend/学习计划论坛与Token商店PRD.md
Normal 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 是否需要前端先隐藏评论,只保留后端能力。
|
||||
Reference in New Issue
Block a user