Version: 0.9.66.dev.260504

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

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

View File

@@ -0,0 +1,192 @@
package sv
import (
"context"
"errors"
"log"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/respond"
userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao"
userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
)
const (
userTokenResetInterval = 7 * 24 * time.Hour
userTokenQuotaSnapshotTTL = 60 * time.Second
minUserTokenBlockTTL = 30 * time.Second
)
// CheckTokenQuota 是 user/auth 服务内的 token 额度门禁。
//
// 职责边界:
// 1. 判断用户是否还能继续发起高消耗 agent/chat 请求;
// 2. 维护额度周期懒重置、Redis 快照和封禁键;
// 3. 不负责本轮对话完成后的 token 记账,记账由 AdjustTokenUsage 处理。
func (s *Service) CheckTokenQuota(ctx context.Context, req contracts.CheckTokenQuotaRequest) (*contracts.CheckTokenQuotaResponse, error) {
if s == nil || s.userRepo == nil || s.cacheRepo == nil {
return nil, errors.New("userauth quota dependencies not initialized")
}
if req.UserID <= 0 {
return nil, respond.ErrUnauthorized
}
now := time.Now()
// 1. 先查封禁键。封禁键的 TTL 按重置窗口计算,命中时可以避免每次回源 DB。
blocked, blockedErr := s.cacheRepo.IsUserTokenBlocked(ctx, req.UserID)
if blockedErr != nil {
log.Printf("userauth quota: 查询封禁键失败 user_id=%d err=%v回源 DB 校验", req.UserID, blockedErr)
} else if blocked {
return &contracts.CheckTokenQuotaResponse{Allowed: false}, nil
}
// 2. 快照未到重置窗口时直接判断;快照损坏或过期则回源 DB。
snapshot, hit, snapshotErr := s.cacheRepo.GetUserTokenQuotaSnapshot(ctx, req.UserID)
if snapshotErr != nil {
log.Printf("userauth quota: 读取额度快照失败 user_id=%d err=%v回源 DB 校验", req.UserID, snapshotErr)
}
if hit && snapshot != nil && !isResetDue(snapshot.LastResetAt, now) {
if isQuotaExceeded(snapshot.TokenLimit, snapshot.TokenUsage) {
ttl := calcBlockTTL(snapshot.LastResetAt, now)
if err := s.cacheRepo.SetUserTokenBlocked(ctx, req.UserID, ttl); err != nil {
log.Printf("userauth quota: 写入封禁键失败 user_id=%d err=%v", req.UserID, err)
}
return quotaResponse(false, snapshot.TokenLimit, snapshot.TokenUsage, snapshot.LastResetAt), nil
}
return quotaResponse(true, snapshot.TokenLimit, snapshot.TokenUsage, snapshot.LastResetAt), nil
}
// 3. 回源 DB 做权威判断;到 7 天窗口则先懒重置,再回读最新额度。
quota, err := s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
if isResetDue(quota.LastResetAt, now) {
if _, err = s.userRepo.ResetUserTokenUsageIfDue(ctx, req.UserID, now.Add(-userTokenResetInterval), now); err != nil {
return nil, err
}
quota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
if delErr := s.cacheRepo.DeleteUserTokenBlocked(ctx, req.UserID); delErr != nil {
log.Printf("userauth quota: 清理封禁键失败 user_id=%d err=%v", req.UserID, delErr)
}
}
return s.cacheQuotaAndBuildResponse(ctx, req.UserID, quota, now, "quota")
}
// AdjustTokenUsage 在 user/auth 服务内回写用户 token 账本。
//
// 职责边界:
// 1. 只负责 users.token_usage 的增量调整与 quota 缓存刷新;
// 2. 不负责 agent 会话 token_total调用方仍需在各自领域内维护会话统计
// 3. event_id 非空时通过 MySQL 幂等表和 users 更新同事务提交,避免 outbox 重试或并发重放重复记账。
func (s *Service) AdjustTokenUsage(ctx context.Context, req contracts.AdjustTokenUsageRequest) (*contracts.CheckTokenQuotaResponse, error) {
if s == nil || s.userRepo == nil || s.cacheRepo == nil {
return nil, errors.New("userauth adjust dependencies not initialized")
}
if req.UserID <= 0 || req.TokenDelta <= 0 {
return nil, respond.MissingParam
}
now := time.Now()
eventID := strings.TrimSpace(req.EventID)
var currentQuota *userauthmodel.User
var err error
if eventID != "" {
var duplicated bool
currentQuota, duplicated, err = s.userRepo.AdjustTokenUsageOnce(ctx, eventID, req.UserID, req.TokenDelta, now.Add(-userTokenResetInterval), now)
if err != nil {
return nil, err
}
if duplicated {
return s.CheckTokenQuota(ctx, contracts.CheckTokenQuotaRequest{UserID: req.UserID})
}
} else {
currentQuota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
if isResetDue(currentQuota.LastResetAt, now) {
if _, err = s.userRepo.ResetUserTokenUsageIfDue(ctx, req.UserID, now.Add(-userTokenResetInterval), now); err != nil {
return nil, err
}
}
if _, err = s.userRepo.AddTokenUsage(ctx, req.UserID, req.TokenDelta); err != nil {
return nil, err
}
currentQuota, err = s.userRepo.GetUserTokenQuotaByID(ctx, req.UserID)
if err != nil {
return nil, err
}
}
return s.cacheQuotaAndBuildResponse(ctx, req.UserID, currentQuota, now, "adjust")
}
func (s *Service) cacheQuotaAndBuildResponse(ctx context.Context, userID int, quota *userauthmodel.User, now time.Time, source string) (*contracts.CheckTokenQuotaResponse, error) {
if quota == nil {
return nil, errors.New("userauth quota is nil")
}
snapshot := userauthdao.TokenQuotaSnapshot{
TokenLimit: quota.TokenLimit,
TokenUsage: quota.TokenUsage,
LastResetAt: quota.LastResetAt,
}
if setErr := s.cacheRepo.SetUserTokenQuotaSnapshot(ctx, userID, snapshot, userTokenQuotaSnapshotTTL); setErr != nil {
log.Printf("userauth %s: 回填额度快照失败 user_id=%d err=%v", source, userID, setErr)
if delErr := s.cacheRepo.DeleteUserTokenQuotaSnapshot(ctx, userID); delErr != nil {
log.Printf("userauth %s: 清理失效额度快照失败 user_id=%d err=%v", source, userID, delErr)
}
}
if isQuotaExceeded(quota.TokenLimit, quota.TokenUsage) {
ttl := calcBlockTTL(quota.LastResetAt, now)
if err := s.cacheRepo.SetUserTokenBlocked(ctx, userID, ttl); err != nil {
log.Printf("userauth %s: 写入封禁标记失败 user_id=%d err=%v", source, userID, err)
}
return quotaResponse(false, quota.TokenLimit, quota.TokenUsage, quota.LastResetAt), nil
}
if delErr := s.cacheRepo.DeleteUserTokenBlocked(ctx, userID); delErr != nil {
log.Printf("userauth %s: 清理封禁标记失败 user_id=%d err=%v", source, userID, delErr)
}
return quotaResponse(true, quota.TokenLimit, quota.TokenUsage, quota.LastResetAt), nil
}
func quotaResponse(allowed bool, tokenLimit int, tokenUsage int, lastResetAt time.Time) *contracts.CheckTokenQuotaResponse {
return &contracts.CheckTokenQuotaResponse{
Allowed: allowed,
TokenLimit: tokenLimit,
TokenUsage: tokenUsage,
LastResetAt: lastResetAt,
}
}
func isQuotaExceeded(tokenLimit int, tokenUsage int) bool {
return tokenUsage >= tokenLimit
}
func isResetDue(lastResetAt time.Time, now time.Time) bool {
if lastResetAt.IsZero() {
return true
}
return !lastResetAt.Add(userTokenResetInterval).After(now)
}
func calcBlockTTL(lastResetAt time.Time, now time.Time) time.Duration {
if lastResetAt.IsZero() {
return minUserTokenBlockTTL
}
ttl := lastResetAt.Add(userTokenResetInterval).Sub(now)
if ttl <= 0 {
return minUserTokenBlockTTL
}
return ttl
}

View File

@@ -0,0 +1,176 @@
package sv
import (
"context"
"errors"
"strings"
"time"
"github.com/LoveLosita/smartflow/backend/respond"
userauthdao "github.com/LoveLosita/smartflow/backend/services/userauth/dao"
userauthauth "github.com/LoveLosita/smartflow/backend/services/userauth/internal/auth"
userauthmodel "github.com/LoveLosita/smartflow/backend/services/userauth/model"
contracts "github.com/LoveLosita/smartflow/backend/shared/contracts/userauth"
"github.com/LoveLosita/smartflow/backend/utils"
"gorm.io/gorm"
)
type UserRepo interface {
Create(ctx context.Context, username, phoneNumber, password string) (*userauthmodel.User, error)
IfUsernameExists(ctx context.Context, name string) (bool, error)
GetUserHashedPasswordByName(ctx context.Context, name string) (string, error)
GetUserIDByName(ctx context.Context, name string) (int, error)
GetUserTokenQuotaByID(ctx context.Context, id int) (*userauthmodel.User, error)
ResetUserTokenUsageIfDue(ctx context.Context, id int, dueBefore time.Time, resetAt time.Time) (bool, error)
AddTokenUsage(ctx context.Context, id int, delta int) (bool, error)
AdjustTokenUsageOnce(ctx context.Context, eventID string, id int, delta int, dueBefore time.Time, resetAt time.Time) (*userauthmodel.User, bool, error)
}
type CacheRepo interface {
IsBlacklisted(jti string) (bool, error)
SetBlacklist(jti string, expiration time.Duration) error
SetBlacklistIfAbsent(jti string, expiration time.Duration) (bool, error)
IsSessionBlacklisted(sessionID string) (bool, error)
SetSessionBlacklist(sessionID string, expiration time.Duration) error
IsUserTokenBlocked(ctx context.Context, userID int) (bool, error)
GetUserTokenQuotaSnapshot(ctx context.Context, userID int) (*userauthdao.TokenQuotaSnapshot, bool, error)
SetUserTokenQuotaSnapshot(ctx context.Context, userID int, snapshot userauthdao.TokenQuotaSnapshot, ttl time.Duration) error
DeleteUserTokenQuotaSnapshot(ctx context.Context, userID int) error
SetUserTokenBlocked(ctx context.Context, userID int, ttl time.Duration) error
DeleteUserTokenBlocked(ctx context.Context, userID int) error
}
// Service 承载 user/auth 服务内部业务规则。
//
// 职责边界:
// 1. 负责注册、登录、刷新、登出、JWT 签发/校验、黑名单和 token 额度门禁;
// 2. 不负责 Gin gateway 的响应适配、路由聚合和 SSE 等边缘职责;
// 3. 不负责 agent 会话 token 统计,迁移期该链路仍由 agent 持久化事件触发 userauth 账本调整。
type Service struct {
userRepo UserRepo
cacheRepo CacheRepo
}
func New(userRepo UserRepo, cacheRepo CacheRepo) *Service {
return &Service{
userRepo: userRepo,
cacheRepo: cacheRepo,
}
}
func (s *Service) Register(ctx context.Context, req contracts.RegisterRequest) (*contracts.RegisterResponse, error) {
if strings.TrimSpace(req.Username) == "" || strings.TrimSpace(req.Password) == "" || strings.TrimSpace(req.PhoneNumber) == "" {
return nil, respond.MissingParam
}
if len(req.Username) > 45 || len(req.Password) > 229 || len(req.PhoneNumber) > 18 {
return nil, respond.ParamTooLong
}
exists, err := s.userRepo.IfUsernameExists(ctx, req.Username)
if err != nil {
return nil, err
}
if exists {
return nil, respond.InvalidName
}
hashedPwd, err := utils.HashPassword(req.Password)
if err != nil {
return nil, err
}
newUser, err := s.userRepo.Create(ctx, req.Username, req.PhoneNumber, hashedPwd)
if err != nil {
return nil, err
}
return &contracts.RegisterResponse{ID: newUser.ID}, nil
}
func (s *Service) Login(ctx context.Context, req contracts.LoginRequest) (*contracts.Tokens, error) {
hashedPwd, err := s.userRepo.GetUserHashedPasswordByName(ctx, req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, respond.WrongName
}
return nil, err
}
matched, err := utils.CompareHashPwdAndPwd(hashedPwd, req.Password)
if err != nil {
return nil, err
}
if !matched {
return nil, respond.WrongPwd
}
userID, err := s.userRepo.GetUserIDByName(ctx, req.Username)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, respond.WrongName
}
return nil, err
}
return userauthauth.GenerateTokens(userID)
}
func (s *Service) RefreshToken(ctx context.Context, req contracts.RefreshTokenRequest) (*contracts.Tokens, error) {
if strings.TrimSpace(req.RefreshToken) == "" {
return nil, respond.MissingParam
}
claims, err := userauthauth.ValidateRefreshToken(req.RefreshToken, s.cacheRepo)
if err != nil {
return nil, err
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
return nil, respond.InvalidRefreshToken
}
// 1. 先用 SET NX 抢占旧 refresh 的 JTI确保并发刷新时只有一个请求能继续签发新 token。
// 2. 这里只黑掉旧 refresh不黑掉整个 session避免误伤同一会话下新签发的 access token。
consumed, err := s.cacheRepo.SetBlacklistIfAbsent(claims.JTI, ttl)
if err != nil {
return nil, err
}
if !consumed {
return nil, respond.InvalidRefreshToken
}
return userauthauth.GenerateTokensWithSession(claims.UserID, claims.SessionID)
}
func (s *Service) LogoutByAccessToken(ctx context.Context, accessToken string) error {
if strings.TrimSpace(accessToken) == "" {
return respond.MissingToken
}
claims, err := userauthauth.ValidateAccessToken(accessToken, s.cacheRepo)
if err != nil {
return err
}
// 1. logout 的目标是整段会话,而不是单个 access token。
// 2. 先按会话维度拉黑,再让 access / refresh 各自的 validate 流程拒绝后续请求。
if strings.TrimSpace(claims.SessionID) == "" {
return s.cacheRepo.SetBlacklist(claims.JTI, time.Until(claims.ExpiresAt.Time))
}
sessionTTL, err := userauthauth.SessionBlacklistTTL()
if err != nil {
return err
}
return s.cacheRepo.SetSessionBlacklist(claims.SessionID, sessionTTL)
}
func (s *Service) ValidateAccessToken(ctx context.Context, req contracts.ValidateAccessTokenRequest) (*contracts.ValidateAccessTokenResponse, error) {
if strings.TrimSpace(req.AccessToken) == "" {
return nil, respond.MissingToken
}
claims, err := userauthauth.ValidateAccessToken(req.AccessToken, s.cacheRepo)
if err != nil {
return nil, err
}
return &contracts.ValidateAccessTokenResponse{
Valid: true,
UserID: claims.UserID,
TokenType: claims.TokenType,
JTI: claims.JTI,
ExpiresAt: claims.ExpiresAt.Time,
}, nil
}