签入脚本

This commit is contained in:
小蚂蚁云
2026-10-20 11:37:25 +08:00
commit 7f5e8470af
909 changed files with 4045080 additions and 0 deletions
+334
View File
@@ -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
}