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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user