376 lines
14 KiB
Go
376 lines
14 KiB
Go
// +======================================================================
|
||
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
|
||
// +----------------------------------------------------------------------
|
||
// | 开源协议: Apache-2.0
|
||
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
|
||
// +----------------------------------------------------------------------
|
||
// | 官方网站: https://www.easygoadmin.vip
|
||
// +----------------------------------------------------------------------
|
||
// | 研发团队: 小蚂蚁云团队 荣誉出品
|
||
// +======================================================================
|
||
// | 版权和免责声明:
|
||
// | 一、知识产权声明
|
||
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
|
||
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
|
||
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
|
||
// | 二、使用限制与禁止条款
|
||
// | 您理解并同意,禁止将本产品用于以下任何行为:
|
||
// | 1. 任何违反中华人民共和国法律法规的用途;
|
||
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
|
||
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
|
||
// | 三、责任豁免
|
||
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
|
||
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
|
||
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
|
||
// | 四、违约与法律责任
|
||
// | 如发现任何违反本声明的行为,本团队有权:
|
||
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
|
||
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
|
||
// | 3. 保留进一步采取刑事控告等法律措施的权利。
|
||
// | 五、许可范围说明
|
||
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
|
||
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
|
||
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
|
||
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
|
||
// | 成的一切经济损失及商誉损害。
|
||
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
|
||
// | 许可协议》附件为准。
|
||
// +======================================================================
|
||
|
||
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
|
||
}
|