2026-04-28 14:53:04 +08:00
|
|
|
package service
|
2026-03-20 12:23:28 +08:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/google/uuid"
|
|
|
|
|
|
2026-04-22 16:01:59 +08:00
|
|
|
"with_you/internal/model"
|
|
|
|
|
"with_you/internal/pkg/redis"
|
|
|
|
|
"with_you/internal/pkg/ws"
|
2026-03-20 12:23:28 +08:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Lua 脚本:原子性地检查状态并更新为 scanned
|
|
|
|
|
const scanScript = `
|
|
|
|
|
local key = KEYS[1]
|
|
|
|
|
local status = redis.call('HGET', key, 'status')
|
|
|
|
|
if status == 'pending' then
|
|
|
|
|
redis.call('HMSET', key, 'status', 'scanned', 'user_id', ARGV[1])
|
|
|
|
|
return 1
|
|
|
|
|
elseif status == 'scanned' then
|
|
|
|
|
return 2
|
|
|
|
|
else
|
|
|
|
|
return 0
|
|
|
|
|
end
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
qrcodeSessionPrefix = "qrcode:session:"
|
|
|
|
|
qrcodeSessionTTL = 5 * time.Minute
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// QRCodeStatus 二维码状态
|
|
|
|
|
type QRCodeStatus string
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
QRCodeStatusPending QRCodeStatus = "pending"
|
|
|
|
|
QRCodeStatusScanned QRCodeStatus = "scanned"
|
|
|
|
|
QRCodeStatusConfirmed QRCodeStatus = "confirmed"
|
|
|
|
|
QRCodeStatusCancelled QRCodeStatus = "cancelled"
|
|
|
|
|
QRCodeStatusExpired QRCodeStatus = "expired"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// QRCodeSession 二维码会话
|
|
|
|
|
type QRCodeSession struct {
|
|
|
|
|
SessionID string `json:"session_id"`
|
|
|
|
|
Status QRCodeStatus `json:"status"`
|
|
|
|
|
UserID string `json:"user_id"`
|
|
|
|
|
CreatedAt int64 `json:"created_at"`
|
|
|
|
|
ExpiresAt int64 `json:"expires_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// QRCodeLoginService 二维码登录服务
|
|
|
|
|
type QRCodeLoginService struct {
|
|
|
|
|
redis *redis.Client
|
2026-03-30 04:49:35 +08:00
|
|
|
wsHub *ws.Hub
|
2026-03-20 12:23:28 +08:00
|
|
|
jwtService *JWTService
|
|
|
|
|
userService UserService
|
|
|
|
|
activityService UserActivityService
|
|
|
|
|
logService *LogService
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewQRCodeLoginService 创建二维码登录服务
|
2026-03-26 21:17:49 +08:00
|
|
|
func NewQRCodeLoginService(redis *redis.Client, wsHub *ws.Hub, jwtService *JWTService, userService UserService, activityService UserActivityService, logService *LogService) *QRCodeLoginService {
|
2026-03-20 12:23:28 +08:00
|
|
|
return &QRCodeLoginService{
|
|
|
|
|
redis: redis,
|
2026-03-30 04:49:35 +08:00
|
|
|
wsHub: wsHub,
|
2026-03-20 12:23:28 +08:00
|
|
|
jwtService: jwtService,
|
|
|
|
|
userService: userService,
|
|
|
|
|
activityService: activityService,
|
|
|
|
|
logService: logService,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CreateSession 创建二维码会话
|
|
|
|
|
func (s *QRCodeLoginService) CreateSession(ctx context.Context) (*QRCodeSession, error) {
|
|
|
|
|
sessionID := uuid.New().String()
|
|
|
|
|
now := time.Now()
|
|
|
|
|
expiresAt := now.Add(qrcodeSessionTTL)
|
|
|
|
|
|
|
|
|
|
session := &QRCodeSession{
|
|
|
|
|
SessionID: sessionID,
|
|
|
|
|
Status: QRCodeStatusPending,
|
|
|
|
|
CreatedAt: now.Unix(),
|
|
|
|
|
ExpiresAt: expiresAt.Unix(),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
key := qrcodeSessionPrefix + sessionID
|
2026-03-30 04:49:35 +08:00
|
|
|
data := map[string]any{
|
2026-03-20 12:23:28 +08:00
|
|
|
"status": string(session.Status),
|
|
|
|
|
"user_id": "",
|
|
|
|
|
"created_at": session.CreatedAt,
|
|
|
|
|
"expires_at": session.ExpiresAt,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := s.redis.HMSet(ctx, key, data); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create session: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := s.redis.Expire(ctx, key, qrcodeSessionTTL); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to set session ttl: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return session, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetSession 获取会话
|
|
|
|
|
func (s *QRCodeLoginService) GetSession(ctx context.Context, sessionID string) (*QRCodeSession, error) {
|
|
|
|
|
key := qrcodeSessionPrefix + sessionID
|
|
|
|
|
data, err := s.redis.HGetAll(ctx, key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to get session: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(data) == 0 {
|
|
|
|
|
return nil, fmt.Errorf("session not found or expired")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
session := &QRCodeSession{
|
|
|
|
|
SessionID: sessionID,
|
|
|
|
|
Status: QRCodeStatus(data["status"]),
|
|
|
|
|
UserID: data["user_id"],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 解析时间戳
|
|
|
|
|
if v := data["created_at"]; v != "" {
|
|
|
|
|
fmt.Sscanf(v, "%d", &session.CreatedAt)
|
|
|
|
|
}
|
|
|
|
|
if v := data["expires_at"]; v != "" {
|
|
|
|
|
fmt.Sscanf(v, "%d", &session.ExpiresAt)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 检查是否过期
|
|
|
|
|
if time.Now().Unix() > session.ExpiresAt {
|
|
|
|
|
session.Status = QRCodeStatusExpired
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return session, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Scan 扫描二维码
|
|
|
|
|
func (s *QRCodeLoginService) Scan(ctx context.Context, sessionID, userID string) error {
|
|
|
|
|
key := qrcodeSessionPrefix + sessionID
|
|
|
|
|
|
|
|
|
|
// 使用 Lua 脚本实现原子性检查和更新,避免竞态条件
|
|
|
|
|
result, err := s.redis.GetClient().Eval(ctx, scanScript, []string{key}, userID).Int()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to scan: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
switch result {
|
|
|
|
|
case 0:
|
|
|
|
|
return fmt.Errorf("session not found or expired")
|
|
|
|
|
case 2:
|
|
|
|
|
return fmt.Errorf("qrcode already scanned")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 获取用户信息用于推送
|
|
|
|
|
user, err := s.userService.GetUserByID(ctx, userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to get user info: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 推送扫码事件
|
|
|
|
|
payload := map[string]any{
|
|
|
|
|
"user": map[string]any{
|
|
|
|
|
"id": user.ID,
|
|
|
|
|
"nickname": user.Nickname,
|
|
|
|
|
"avatar": user.Avatar,
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-03-26 21:17:49 +08:00
|
|
|
s.wsHub.PublishToUser(sessionID, "scanned", payload)
|
2026-03-20 12:23:28 +08:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Confirm 确认登录
|
|
|
|
|
func (s *QRCodeLoginService) Confirm(ctx context.Context, sessionID, userID string, ip, userAgent string) (*LoginResponse, error) {
|
|
|
|
|
session, err := s.GetSession(ctx, sessionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if session.Status != QRCodeStatusScanned {
|
|
|
|
|
return nil, fmt.Errorf("invalid session status")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if session.UserID != userID {
|
|
|
|
|
return nil, fmt.Errorf("unauthorized")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 获取用户信息
|
|
|
|
|
user, err := s.userService.GetUserByID(ctx, userID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("user not found: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 复用JWT生成逻辑
|
|
|
|
|
accessToken, err := s.jwtService.GenerateAccessToken(user.ID, user.Username)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to generate access token: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
refreshToken, err := s.jwtService.GenerateRefreshToken(user.ID, user.Username)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to generate refresh token: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 更新状态
|
|
|
|
|
key := qrcodeSessionPrefix + sessionID
|
|
|
|
|
if err := s.redis.HSet(ctx, key, "status", string(QRCodeStatusConfirmed)); err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to update status: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 复用活跃记录
|
|
|
|
|
if s.activityService != nil {
|
|
|
|
|
go func() {
|
|
|
|
|
s.activityService.RecordUserActive(context.Background(), userID, model.LoginTypeLogin, ip, userAgent)
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 复用登录日志
|
|
|
|
|
if s.logService != nil {
|
|
|
|
|
go func() {
|
|
|
|
|
s.logService.LoginLog.RecordLogin(&model.LoginLog{
|
|
|
|
|
UserID: user.ID,
|
|
|
|
|
UserName: user.Username,
|
|
|
|
|
NickName: user.Nickname,
|
|
|
|
|
LoginType: string(model.LoginTypeQRCode),
|
|
|
|
|
Event: string(model.LoginEventLogin),
|
|
|
|
|
IP: ip,
|
|
|
|
|
UserAgent: userAgent,
|
|
|
|
|
Result: string(model.LoginResultSuccess),
|
|
|
|
|
})
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 推送确认事件
|
|
|
|
|
response := &LoginResponse{
|
|
|
|
|
Token: accessToken,
|
|
|
|
|
RefreshToken: refreshToken,
|
|
|
|
|
User: user,
|
|
|
|
|
}
|
2026-03-26 21:17:49 +08:00
|
|
|
s.wsHub.PublishToUser(sessionID, "confirmed", response)
|
2026-03-20 12:23:28 +08:00
|
|
|
|
|
|
|
|
return response, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// LoginResponse 登录响应
|
|
|
|
|
type LoginResponse struct {
|
|
|
|
|
Token string `json:"token"`
|
|
|
|
|
RefreshToken string `json:"refresh_token"`
|
|
|
|
|
User *model.User `json:"user"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancel 取消登录
|
|
|
|
|
func (s *QRCodeLoginService) Cancel(ctx context.Context, sessionID, userID string) error {
|
|
|
|
|
session, err := s.GetSession(ctx, sessionID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if session.Status != QRCodeStatusScanned {
|
|
|
|
|
return fmt.Errorf("invalid session status")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if session.UserID != userID {
|
|
|
|
|
return fmt.Errorf("unauthorized")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
key := qrcodeSessionPrefix + sessionID
|
|
|
|
|
if err := s.redis.HSet(ctx, key, "status", string(QRCodeStatusCancelled)); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to update status: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 推送取消事件
|
2026-03-30 04:49:35 +08:00
|
|
|
s.wsHub.PublishToUser(sessionID, "cancelled", map[string]any{})
|
2026-03-20 12:23:28 +08:00
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-26 21:17:49 +08:00
|
|
|
// GetWSHub 获取WS Hub
|
|
|
|
|
func (s *QRCodeLoginService) GetWSHub() *ws.Hub {
|
|
|
|
|
return s.wsHub
|
2026-03-20 12:23:28 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GetUserService 获取用户服务
|
|
|
|
|
func (s *QRCodeLoginService) GetUserService() UserService {
|
|
|
|
|
return s.userService
|
|
|
|
|
}
|