package logic import ( "context" "encoding/json" "math" "sort" "strings" "time" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "easygoadmin/api/v1" "easygoadmin/internal/consts" "easygoadmin/internal/dao" "easygoadmin/internal/service" "easygoadmin/utility/cache" "easygoadmin/utility/ip2region" jwtutil "easygoadmin/utility/jwt" "easygoadmin/utility/ua" "easygoadmin/utility/upload" ) // ============================== 在线用户业务实现 ============================== // sOnlineUser 在线用户业务实现 // 数据源为在线令牌集合(登录登记、登出/强退移除),非数据库表 type sOnlineUser struct{} func init() { service.RegisterOnlineUser(&sOnlineUser{}) } // onlineInfo 在线令牌详情(JSON 缓存) type onlineInfo struct { Jti string `json:"jti"` UserId int `json:"userId"` Username string `json:"username"` Realname string `json:"realname"` Ip string `json:"ip"` Location string `json:"location"` Oss string `json:"oss"` // 操作系统 Browser string `json:"browser"` // 浏览器 LoginTime string `json:"loginTime"` IssuedAt int64 `json:"issuedAt"` // 访问令牌颁发时间戳(秒) ExpireAt int64 `json:"expireAt"` // 访问令牌过期时间戳(秒) RefreshJti string `json:"refreshJti"` // 刷新令牌 jti RefreshIssuedAt int64 `json:"refreshIssuedAt"` // 刷新令牌颁发时间戳(秒) RefreshExpireAt int64 `json:"refreshExpireAt"` // 刷新令牌过期时间戳(秒) AccessToken string `json:"accessToken"` // 访问令牌原文(详情查看) RefreshToken string `json:"refreshToken"` // 刷新令牌原文(详情查看) TokenType string `json:"tokenType"` // 令牌类型(Bearer) ExpiresIn int64 `json:"expiresIn"` // 访问令牌有效期(秒) } // MarkOnline 登录成功后登记在线令牌 // zset score=访问令牌过期时间戳用于区间过滤,详情键携带展示字段; // 刷新令牌仅登记标识与有效期(不入 zset,列表以访问令牌为准) func MarkOnline(ctx context.Context, access, refresh *jwtutil.Claims, accessToken, refreshToken string, userId int, username, realname, ip, userAgent string) { if access == nil || access.ID == "" { return } now := time.Now() expireAt := now.Add(jwtutil.ExpireDuration(ctx, jwtutil.TokenTypeAccess)) if access.ExpiresAt != nil { expireAt = access.ExpiresAt.Time } info := onlineInfo{ Jti: access.ID, UserId: userId, Username: username, Realname: realname, Ip: ip, Location: ip2region.Location(ip), Oss: ua.ParseOS(userAgent), Browser: ua.ParseBrowser(userAgent), LoginTime: now.Format("2006-01-02 15:04:05"), IssuedAt: now.Unix(), ExpireAt: expireAt.Unix(), RefreshJti: func() string { if refresh != nil { return refresh.ID } return "" }(), RefreshIssuedAt: now.Unix(), RefreshExpireAt: func() int64 { if refresh != nil && refresh.ExpiresAt != nil { return refresh.ExpiresAt.Time.Unix() } return 0 }(), AccessToken: accessToken, RefreshToken: refreshToken, TokenType: "Bearer", ExpiresIn: int64(jwtutil.ExpireDuration(ctx, jwtutil.TokenTypeAccess).Seconds()), } if b, err := json.Marshal(info); err == nil { ttl := int(expireAt.Sub(now).Seconds()) + 60 _ = cache.Set(ctx, consts.CacheKeyOnlineInfo(access.ID), string(b), ttl) } _ = cache.ZAdd(ctx, consts.CacheKeyOnlineJtiSet(), access.ID, float64(expireAt.Unix())) } // MarkOffline 登出或强退后移除在线登记 func MarkOffline(ctx context.Context, jti string) { if jti == "" { return } _ = cache.ZRem(ctx, consts.CacheKeyOnlineJtiSet(), jti) _ = cache.Del(ctx, consts.CacheKeyOnlineInfo(jti)) } // Page 在线用户分页 // 取未过期令牌后按用户名/IP 过滤,内存分页,按登录时间倒序 func (s *sOnlineUser) Page(ctx context.Context, req *v1.OnlineUserPageReq) (res *v1.OnlineUserPageRes, err error) { jtis, err := cache.ZRangeByScore(ctx, consts.CacheKeyOnlineJtiSet(), float64(time.Now().Unix()), math.Inf(1)) if err != nil { return nil, err } items := make([]v1.OnlineUserItem, 0, len(jtis)) for _, jti := range jtis { raw, ok := cache.Get(ctx, consts.CacheKeyOnlineInfo(jti)) if !ok { continue } var info onlineInfo if json.Unmarshal([]byte(raw), &info) != nil { continue } if req.Username != "" && !strings.Contains(info.Username, req.Username) { continue } if req.Ip != "" && !strings.Contains(info.Ip, req.Ip) { continue } items = append(items, v1.OnlineUserItem{ Id: info.Jti, UserId: info.UserId, Username: info.Username, Realname: info.Realname, Ip: info.Ip, Location: info.Location, Oss: info.Oss, Browser: info.Browser, LoginTime: info.LoginTime, IssuedTime: time.Unix(info.IssuedAt, 0).Format("2006-01-02 15:04:05"), ExpireTime: time.Unix(info.ExpireAt, 0).Format("2006-01-02 15:04:05"), RefreshIssuedTime: func() string { if info.RefreshIssuedAt <= 0 { return "" } return time.Unix(info.RefreshIssuedAt, 0).Format("2006-01-02 15:04:05") }(), RefreshExpireTime: func() string { if info.RefreshExpireAt <= 0 { return "" } return time.Unix(info.RefreshExpireAt, 0).Format("2006-01-02 15:04:05") }(), TokenType: info.TokenType, ExpiresIn: info.ExpiresIn, }) } sort.Slice(items, func(i, j int) bool { return items[i].LoginTime > items[j].LoginTime }) // 同用户多会话标注(序号/总数,按登录时间倒序内的序号) if len(items) > 1 { totalByUser := map[int]int{} seqByUser := map[int]int{} for _, it := range items { totalByUser[it.UserId]++ } for i := range items { seqByUser[items[i].UserId]++ items[i].SessionNo = seqByUser[items[i].UserId] items[i].Sessions = totalByUser[items[i].UserId] } } // 按用户ID批量补齐头像(与用户列表同源,头像变更即时生效) if len(items) > 0 { if err = fillOnlineAvatars(ctx, items); err != nil { return nil, err } } total := int64(len(items)) pageNo, pageSize := req.GetPageNo(), req.GetPageSize() start := int((pageNo - 1) * pageSize) if start > len(items) { start = len(items) } end := start + int(pageSize) if end > len(items) { end = len(items) } return v1.NewPageData(pageNo, pageSize, int(total), items[start:end]), nil } // fillOnlineAvatars 按用户ID批量查询并回填头像 // 查询用户表 avatar 相对路径,经 upload.GetFileUrl 补全访问域名; // 单个用户多会话只查一次 func fillOnlineAvatars(ctx context.Context, items []v1.OnlineUserItem) error { idMap := map[int]bool{} ids := make([]int, 0, len(items)) for _, it := range items { if it.UserId > 0 && !idMap[it.UserId] { idMap[it.UserId] = true ids = append(ids, it.UserId) } } if len(ids) == 0 { return nil } rows, err := dao.User.Ctx(ctx).WhereIn("id", ids).Fields("id, avatar").All() if err != nil { return err } avatarMap := make(map[int]string, len(rows)) for _, r := range rows { avatarMap[r["id"].Int()] = upload.GetFileUrl(ctx, r["avatar"].String()) } for i := range items { items[i].Avatar = avatarMap[items[i].UserId] } return nil } // Detail 在线会话详情 // 按访问令牌 jti 读取在线登记,返回含令牌原文的完整信息 func (s *sOnlineUser) Detail(ctx context.Context, req *v1.OnlineUserDetailReq) (res *v1.OnlineUserDetailRes, err error) { raw, ok := cache.Get(ctx, consts.CacheKeyOnlineInfo(req.Id)) if !ok { return nil, gerror.NewCode(consts.CodeRecordNotFound) } var info onlineInfo if err = json.Unmarshal([]byte(raw), &info); err != nil { return nil, gerror.NewCode(consts.CodeRecordNotFound) } item := v1.OnlineUserItem{ Id: info.Jti, UserId: info.UserId, Username: info.Username, Realname: info.Realname, Ip: info.Ip, Location: info.Location, Oss: info.Oss, Browser: info.Browser, LoginTime: info.LoginTime, IssuedTime: time.Unix(info.IssuedAt, 0).Format("2006-01-02 15:04:05"), ExpireTime: time.Unix(info.ExpireAt, 0).Format("2006-01-02 15:04:05"), RefreshIssuedTime: func() string { if info.RefreshIssuedAt <= 0 { return "" } return time.Unix(info.RefreshIssuedAt, 0).Format("2006-01-02 15:04:05") }(), RefreshExpireTime: func() string { if info.RefreshExpireAt <= 0 { return "" } return time.Unix(info.RefreshExpireAt, 0).Format("2006-01-02 15:04:05") }(), TokenType: info.TokenType, ExpiresIn: info.ExpiresIn, AccessToken: info.AccessToken, RefreshToken: info.RefreshToken, } // 切片字面量为副本,经列表回填后再取回 list := []v1.OnlineUserItem{item} if err = fillOnlineAvatars(ctx, list); err != nil { return nil, err } return &v1.OnlineUserDetailRes{OnlineUserItem: list[0]}, nil } // ForceLogout 强制下线 // 将访问令牌拉黑并移除在线登记 func (s *sOnlineUser) ForceLogout(ctx context.Context, req *v1.OnlineUserForceLogoutReq) (res *v1.OnlineUserForceLogoutRes, err error) { raw, ok := cache.Get(ctx, consts.CacheKeyOnlineInfo(req.Id)) if !ok { return nil, gerror.NewCode(consts.CodeRecordNotFound) } var info onlineInfo if err = json.Unmarshal([]byte(raw), &info); err != nil { return nil, gerror.NewCode(consts.CodeRecordNotFound) } ttl := int(time.Until(time.Unix(info.ExpireAt, 0)).Seconds()) + 60 if ttl > 0 { _ = cache.Set(ctx, consts.CacheKeyTokenBlacklist(info.Jti), "1", ttl) } // 刷新令牌一并拉黑,防止被踢会话经 refresh 换新令牌重新上线 if info.RefreshJti != "" && info.RefreshExpireAt > 0 { if rttl := int(time.Until(time.Unix(info.RefreshExpireAt, 0)).Seconds()) + 60; rttl > 0 { _ = cache.Set(ctx, consts.CacheKeyTokenBlacklist(info.RefreshJti), "1", rttl) } } MarkOffline(ctx, info.Jti) return &v1.OnlineUserForceLogoutRes{}, nil } // ForceLogoutUser 踢出该用户全部设备 // 遍历在线集合,将该用户所有会话的访问/刷新令牌拉黑并移除登记 func (s *sOnlineUser) ForceLogoutUser(ctx context.Context, req *v1.OnlineUserForceLogoutUserReq) (res *v1.OnlineUserForceLogoutUserRes, err error) { jtis, err := cache.ZRangeByScore(ctx, consts.CacheKeyOnlineJtiSet(), float64(time.Now().Unix()), math.Inf(1)) if err != nil { return nil, err } kicked := 0 for _, jti := range jtis { raw, ok := cache.Get(ctx, consts.CacheKeyOnlineInfo(jti)) if !ok { continue } var info onlineInfo if json.Unmarshal([]byte(raw), &info) != nil || info.UserId != req.UserId { continue } if ttl := int(time.Until(time.Unix(info.ExpireAt, 0)).Seconds()) + 60; ttl > 0 { _ = cache.Set(ctx, consts.CacheKeyTokenBlacklist(info.Jti), "1", ttl) } if info.RefreshJti != "" && info.RefreshExpireAt > 0 { if rttl := int(time.Until(time.Unix(info.RefreshExpireAt, 0)).Seconds()) + 60; rttl > 0 { _ = cache.Set(ctx, consts.CacheKeyTokenBlacklist(info.RefreshJti), "1", rttl) } } MarkOffline(ctx, info.Jti) kicked++ } g.Log().Infof(ctx, "踢出用户 %d 全部设备,共 %d 个会话", req.UserId, kicked) return &v1.OnlineUserForceLogoutUserRes{}, nil }