package service import ( "errors" "fmt" "sync" "time" "github.com/golang-jwt/jwt/v5" "github.com/google/uuid" "golang.org/x/crypto/bcrypt" "gorm.io/gorm" "github.com/wangjia/jiu/backend/config" "github.com/wangjia/jiu/backend/internal/middleware" "github.com/wangjia/jiu/backend/internal/model" ) var ( ErrInvalidCredentials = errors.New("invalid username or password") ErrUserInactive = errors.New("user is disabled") ErrLicenseLocked = errors.New("license locked, please renew or contact support") ErrPlatformNotAllowed = errors.New("该平台不允许登录") ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试") ErrSessionRevoked = errors.New("session revoked") ) // DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。 type DeviceInfo struct { DeviceID string DeviceName string Platform string IP string UserAgent string } // loginLimiter 内存登录失败限流器(单实例,重启即清零)。 type loginLimiter struct { mu sync.Mutex entries map[string]*limiterEntry } type limiterEntry struct { failures int lockedTill time.Time } var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}} // locked 返回该 key 是否处于锁定中。 func (l *loginLimiter) locked(key string) bool { l.mu.Lock() defer l.mu.Unlock() e := l.entries[key] return e != nil && time.Now().Before(e.lockedTill) } // recordFailure 记一次失败,达到阈值则锁定。 func (l *loginLimiter) recordFailure(key string) { l.mu.Lock() defer l.mu.Unlock() e := l.entries[key] if e == nil { e = &limiterEntry{} l.entries[key] = e } e.failures++ max := config.C.Session.MaxFailures if max > 0 && e.failures >= max { e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute) e.failures = 0 } } // reset 登录成功后清除失败计数。 func (l *loginLimiter) reset(key string) { l.mu.Lock() defer l.mu.Unlock() delete(l.entries, key) } type AuthService struct { db *gorm.DB } func NewAuthService(db *gorm.DB) *AuthService { return &AuthService{db: db} } type TokenPair struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` ExpiresIn int `json:"expires_in"` // 秒 ShopID uint64 `json:"shop_id"` } // Login 账号密码登录 func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) { limiterKey := shopCode + "|" + username if loginLim.locked(limiterKey) { return nil, nil, ErrTooManyAttempts } var shop model.Shop if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil { loginLim.recordFailure(limiterKey) return nil, nil, ErrInvalidCredentials } var user model.User if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username). First(&user).Error; err != nil { loginLim.recordFailure(limiterKey) return nil, nil, ErrInvalidCredentials } if !user.IsActive { return nil, nil, ErrUserInactive } if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { loginLim.recordFailure(limiterKey) return nil, nil, ErrInvalidCredentials } if err := s.checkLicenseNotLocked(shop.ID); err != nil { return nil, nil, err } // 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。 pclass := model.PlatformClass(dev.Platform) quota := s.effectiveQuota(&shop, pclass) if quota <= 0 { return nil, nil, ErrPlatformNotAllowed } sid := uuid.New().String() now := time.Now() if err := s.db.Transaction(func(tx *gorm.DB) error { // 统计该 user 在该 class 的活跃会话;超额踢最旧 var active []model.UserSession if err := tx.Set("gorm:query_option", "FOR UPDATE"). Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL", shop.ID, user.ID, pclass). Order("last_seen_at ASC").Find(&active).Error; err != nil { return err } // 需要腾出 (active+1) - quota 个位置 for i := 0; i <= len(active)-quota; i++ { if err := tx.Model(&model.UserSession{}).Where("id = ?", active[i].ID). Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil { return err } } sess := model.UserSession{ ShopID: shop.ID, UserID: user.ID, SID: sid, DeviceID: dev.DeviceID, DeviceName: dev.DeviceName, Platform: dev.Platform, PlatformClass: pclass, IP: dev.IP, UserAgent: dev.UserAgent, LastSeenAt: now, RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour), } if err := tx.Create(&sess).Error; err != nil { return err } return tx.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error }); err != nil { return nil, nil, err } loginLim.reset(limiterKey) user.LastLoginAt = &now pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid) if err != nil { return nil, nil, err } return pair, &user, nil } // effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。 func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int { def := map[string]int{ "desktop": config.C.Session.LimitDesktop, "mobile": config.C.Session.LimitMobile, "web": config.C.Session.LimitWeb, }[pclass] if shop.CustomFields == nil { return def } raw, ok := shop.CustomFields["session_policy"] if !ok { return def } policy, ok := raw.(map[string]interface{}) if !ok { return def } v, ok := policy[pclass] if !ok { return def } switch n := v.(type) { case float64: return int(n) case int: return n default: return def } } // Logout 撤销指定会话(用户主动登出)。sid 为空(存量 token)时为 no-op。 func (s *AuthService) Logout(sid string) error { if sid == "" { return nil } return s.db.Model(&model.UserSession{}). Where("sid = ? AND revoked_at IS NULL", sid). Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "logout"}).Error } // SessionView 在线会话视图(含用户名,供管理端列表展示)。 type SessionView struct { ID uint64 `json:"id"` UserID uint64 `json:"user_id"` Username string `json:"username"` RealName string `json:"real_name"` Platform string `json:"platform"` PlatformClass string `json:"platform_class"` DeviceName string `json:"device_name"` IP string `json:"ip"` CreatedAt time.Time `json:"created_at"` LastSeenAt time.Time `json:"last_seen_at"` Online bool `json:"online"` IsCurrent bool `json:"is_current"` } // OnlineThreshold last_seen 在此时间内视为在线。 const OnlineThreshold = 90 * time.Second // ListSessions 列出本店活跃(未撤销)会话,按最近活跃排序。currentSID 标记当前请求自身会话。 func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionView, error) { var rows []struct { model.UserSession Username string RealName string } err := s.db.Table("user_sessions AS s"). Select("s.*, u.username AS username, u.real_name AS real_name"). Joins("LEFT JOIN users u ON u.id = s.user_id"). Where("s.shop_id = ? AND s.revoked_at IS NULL", shopID). Order("s.last_seen_at DESC"). Scan(&rows).Error if err != nil { return nil, err } now := time.Now() views := make([]SessionView, 0, len(rows)) for _, r := range rows { views = append(views, SessionView{ ID: r.ID, UserID: r.UserID, Username: r.Username, RealName: r.RealName, Platform: r.Platform, PlatformClass: r.PlatformClass, DeviceName: r.DeviceName, IP: r.IP, CreatedAt: r.CreatedAt, LastSeenAt: r.LastSeenAt, Online: now.Sub(r.LastSeenAt) <= OnlineThreshold, IsCurrent: currentSID != "" && r.SID == currentSID, }) } return views, nil } // ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。 func (s *AuthService) ForceLogout(shopID, sessionID uint64) error { res := s.db.Model(&model.UserSession{}). Where("id = ? AND shop_id = ? AND revoked_at IS NULL", sessionID, shopID). Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin"}) if res.Error != nil { return res.Error } if res.RowsAffected == 0 { return gorm.ErrRecordNotFound } return nil } // RegisterInput 注册新门店所需参数 type RegisterInput struct { ShopName string `json:"shop_name" binding:"required"` Address string `json:"address" binding:"required"` ManagerName string `json:"manager_name" binding:"required"` Phone string `json:"phone" binding:"required"` Description string `json:"description"` Username string `json:"username" binding:"required"` Password string `json:"password" binding:"required,min=6"` } // RegisterResult 注册成功后返回的数据 type RegisterResult struct { ShopCode string `json:"shop_code"` ShopName string `json:"shop_name"` Username string `json:"username"` } // Register 自助注册新门店(公开接口,无需认证) func (s *AuthService) Register(in RegisterInput) (*RegisterResult, error) { hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { return nil, err } var result RegisterResult err = s.db.Transaction(func(tx *gorm.DB) error { // 先用 UUID 占位创建门店,得到真实 ID shop := model.Shop{ Name: in.ShopName, Code: uuid.New().String(), Address: in.Address, Phone: in.Phone, ManagerName: in.ManagerName, Description: in.Description, } if err := tx.Create(&shop).Error; err != nil { return err } // 用自增 ID 生成正式门店编码 shop.Code = fmt.Sprintf("S%06d", shop.ID) if err := tx.Model(&shop).Update("code", shop.Code).Error; err != nil { return err } // 创建管理员用户 user := model.User{ ShopID: shop.ID, Username: in.Username, PasswordHash: string(hash), RealName: in.ManagerName, Phone: in.Phone, Role: "superadmin", IsActive: true, } if err := tx.Create(&user).Error; err != nil { return err } createTrialLicense(tx, shop.ID) result = RegisterResult{ ShopCode: shop.Code, ShopName: shop.Name, Username: user.Username, } return nil }) return &result, err } // HashPassword 生成 bcrypt 哈希 func HashPassword(plain string) (string, error) { b, err := bcrypt.GenerateFromPassword([]byte(plain), bcrypt.DefaultCost) return string(b), err } // RefreshTokens 用 Refresh Token 换新 Token Pair。 // 修复历史漏洞:续期时重新查库校验用户/会话状态,被禁用或被踢的用户无法再续命。 func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) { claims := &middleware.Claims{} token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) { return []byte(config.C.JWT.Secret), nil }) if err != nil || !token.Valid { return nil, errors.New("invalid refresh token") } // 带 sid 的 token 必须对应未撤销会话(被踢/登出后无法续期)。 if claims.SID != "" { var sess model.UserSession if err := s.db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil { return nil, ErrSessionRevoked } } // 重新加载用户:不存在或已禁用 → 拒绝续期(修复漏洞)。 var user model.User if err := s.db.Where("id = ? AND deleted_at IS NULL", claims.UserID).First(&user).Error; err != nil { return nil, ErrInvalidCredentials } if !user.IsActive { return nil, ErrUserInactive } if err := s.checkLicenseNotLocked(user.ShopID); err != nil { return nil, err } // 刷新会话存活时间(同 sid 续期)。 if claims.SID != "" { s.db.Model(&model.UserSession{}).Where("sid = ?", claims.SID). Update("last_seen_at", time.Now()) } return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID) } func (s *AuthService) checkLicenseNotLocked(shopID uint64) error { var lic model.License if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). Order("id DESC").First(&lic).Error; err != nil { return nil // no license record → allow login } if middleware.CalcLicensePhase(lic.ExpiresAt) == middleware.PhaseLocked { return ErrLicenseLocked } return nil } func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*TokenPair, error) { cfg := config.C.JWT now := time.Now() // Embed license expires_at in JWT so LicenseGuard can check phase without DB. var licExpAt *int64 var lic model.License if err := s.db.Where("shop_id = ? AND is_active = 1", shopID). Order("id DESC").First(&lic).Error; err == nil && lic.ExpiresAt != nil { ts := lic.ExpiresAt.Unix() licExpAt = &ts } accessExp := now.Add(time.Duration(cfg.AccessExpireMin) * time.Minute) accessClaims := middleware.Claims{ UserID: userID, ShopID: shopID, Role: role, SID: sid, LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(accessExp), IssuedAt: jwt.NewNumericDate(now), }, } accessToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(cfg.Secret)) if err != nil { return nil, err } refreshExp := now.Add(time.Duration(cfg.RefreshExpireH) * time.Hour) refreshClaims := middleware.Claims{ UserID: userID, ShopID: shopID, Role: role, SID: sid, LicenseExpiresAt: licExpAt, RegisteredClaims: jwt.RegisteredClaims{ ExpiresAt: jwt.NewNumericDate(refreshExp), IssuedAt: jwt.NewNumericDate(now), }, } refreshToken, err := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(cfg.Secret)) if err != nil { return nil, err } return &TokenPair{ AccessToken: accessToken, RefreshToken: refreshToken, ExpiresIn: cfg.AccessExpireMin * 60, ShopID: shopID, }, nil }