签入脚本
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user