feat: 设置页新增登录管理 TAB

- Session 结构体新增 IP、UserAgent、Remark 字段,登录/注册时记录设备信息
- Store 接口新增 ListByUID、DeleteByUIDExclude 方法,MemoryStore 完整实现
- SessionManager 新增 ListByUID、DestroyOtherByUID、UpdateRemark 方法
- 新增 UA 轻量解析器(session/ua.go),提取浏览器/OS/设备类型
- 新增 settings_api_sessions.go,提供列表/踢出/一键踢出/备注 4 个 API
- 控制器层声明 sessionManager 最小接口(ISP),SettingsController 注入依赖
- Auth 中间件注入 sid 到 gin context,支持识别当前会话
- 设置页左侧导航增加登录管理入口,tab 白名单新增 sessions
- 前端实现设备列表展示(浏览器/OS/设备图标/IP/时间)、当前设备标识、备注输入、踢出按钮、一键踢出
- settings.css 新增会话管理全套样式及响应式适配
This commit is contained in:
2026-05-30 23:58:37 +08:00
parent e3853071d6
commit d203447eb3
15 changed files with 884 additions and 13 deletions

View File

@ -0,0 +1,161 @@
package controller
import (
"net/http"
"metazone.cc/metalab/internal/common"
"github.com/gin-gonic/gin"
)
// ListSessions 列出当前用户的所有活跃会话
func (sc *SettingsController) ListSessions(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
sessions, err := sc.sessionMgr.ListByUID(uid.(uint))
if err != nil {
common.Error(c, http.StatusInternalServerError, "查询失败")
return
}
currentSID, _ := c.Cookie(common.SessionCookieName)
type sessionItem struct {
ID string `json:"id"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
Remark string `json:"remark"`
RememberMe bool `json:"remember_me"`
CreatedAt string `json:"created_at"`
LastAccess string `json:"last_access"`
IsCurrent bool `json:"is_current"`
}
var items []sessionItem
for _, s := range sessions {
items = append(items, sessionItem{
ID: s.ID,
IP: s.IP,
UserAgent: s.UserAgent,
Remark: s.Remark,
RememberMe: s.RememberMe,
CreatedAt: s.CreatedAt.Format("2006-01-02 15:04:05"),
LastAccess: s.LastAccess.Format("2006-01-02 15:04:05"),
IsCurrent: s.ID == currentSID,
})
}
if items == nil {
items = []sessionItem{}
}
common.Ok(c, gin.H{"sessions": items})
}
// DestroySession 踢出指定会话(需验证归属当前用户)
func (sc *SettingsController) DestroySession(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
sid := c.Param("sid")
if sid == "" {
common.Error(c, http.StatusBadRequest, "缺少会话 ID")
return
}
// 验证目标会话属于当前用户
sessions, err := sc.sessionMgr.ListByUID(uid.(uint))
if err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
found := false
for _, s := range sessions {
if s.ID == sid {
found = true
break
}
}
if !found {
common.Error(c, http.StatusNotFound, "会话不存在")
return
}
// 不能踢出当前会话
currentSID, _ := c.Cookie(common.SessionCookieName)
if sid == currentSID {
common.Error(c, http.StatusBadRequest, "不能踢出当前设备,请使用退出登录")
return
}
if err := sc.sessionMgr.Destroy(sid); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "已踢出该设备")
}
// DestroyOtherSessions 一键踢出非当前设备的所有会话
func (sc *SettingsController) DestroyOtherSessions(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
currentSID, _ := c.Cookie(common.SessionCookieName)
if currentSID == "" {
common.Error(c, http.StatusBadRequest, "无法识别当前会话")
return
}
if err := sc.sessionMgr.DestroyOtherByUID(uid.(uint), currentSID); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "已踢出所有其他设备")
}
// UpdateSessionRemark 更新指定会话的备注
func (sc *SettingsController) UpdateSessionRemark(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
sid := c.Param("sid")
if sid == "" {
common.Error(c, http.StatusBadRequest, "缺少会话 ID")
return
}
var req struct {
Remark string `json:"remark"`
}
if err := c.ShouldBindJSON(&req); err != nil {
common.Error(c, http.StatusBadRequest, "请检查输入")
return
}
// 备注长度限制
if len(req.Remark) > 32 {
common.Error(c, http.StatusBadRequest, "备注不能超过 32 个字符")
return
}
if err := sc.sessionMgr.UpdateRemark(sid, uid.(uint), req.Remark); err != nil {
common.Error(c, http.StatusInternalServerError, "操作失败")
return
}
common.OkMessage(c, "备注已更新")
}