335 lines
11 KiB
Go
335 lines
11 KiB
Go
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
|
||
}
|