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