Files
EasyGoAdmin_GoFrame_EleVue/internal/logic/online_user.go
T
2026-10-20 11:37:25 +08:00

335 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}