Compare commits

...

5 Commits

Author SHA1 Message Date
wangjia 2ac1cbfb24 ci(devops): compile-windows 兼容裸 v 前缀版本号,修 pubspec 非法版本
Build Windows Only / build-windows (push) Successful in 2m21s
build-windows.yml 单独触发时默认 ver=v1.0.4,脚本仅剥 client-v 前缀,
导致 pubspec 写入 version: v1.0.4+1 被 flutter 拒(Invalid version number)。
追加一道 ${VER#v} 剥裸 v;真实发版路径 client-v* 不受影响(无裸 v,空操作)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 07:54:48 +08:00
wangjia f2f7ab77fa ci(devops): Windows runner bash 改由 Machine PATH 提供,workflow 回退干净 shell: bash
Build Windows Only / build-windows (push) Failing after 19s
runner 主机已把 C:\Program Files\Git\bin 加入 Machine PATH(forgejo-runner
以 LocalSystem 运行,已重启生效,where bash 可解析),故撤掉 cf468f5 的
绝对路径 hack,provision/compile 两步回到 shell: bash + sh scripts/ci/...。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 07:50:09 +08:00
wangjia cf468f51cf ci(devops): Windows runner 将 shell 钉到 Git for Windows 自带 bash 绝对路径
Build Windows Only / build-windows (push) Failing after 15s
Git 安装包默认只把 git.exe 加入 PATH(Git\cmd),不含 bash.exe(Git\bin),
导致 act_runner 解析 `shell: bash` 时报「Cannot find: bash in PATH」。
改用 GitHub Actions 官方等价写法,绕过 PATH 查找:
  C:\Program Files\Git\bin\bash.exe --noprofile --norc -eo pipefail {0}
(bin\bash.exe 包装器会注入 /usr/bin,使脚本内 sh/powershell 可解析)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:42:06 +08:00
wangjia 90f318e246 chore: release client-v1.0.57
Deploy Client / build-windows (push) Failing after 21s
Deploy Client / build-client-web (push) Successful in 42s
Deploy Client / build-macos (push) Successful in 2m8s
Deploy Client / build-android (push) Successful in 1m25s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Has been skipped
设备/状态管理屏(查看本店在线设备/会话,管理员可强制下线)、登录携带设备信息、
会话心跳(/auth/ping)、被踢下线/会话失效提示、顶栏精简 + 用户名移至左侧栏底部。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:13:05 +08:00
wangjia 53fa259284 chore: release server-v1.0.56
Deploy Server / release-deploy-server (push) Successful in 40s
会话/设备管理后端:user_sessions 会话表、JWT 加 sid 校验、按平台类限并发登录、
登录失败锁定、修复禁用账号仍可凭 refresh 续期漏洞、/auth/ping、/auth/logout、
GET /sessions、DELETE /sessions/:id(管理员强制下线)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 23:12:13 +08:00
29 changed files with 1268 additions and 64 deletions
+9
View File
@@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.57] - 2026-06-17
### 新功能
- 新增「设备 / 状态管理」页面:可查看本店当前在线的设备与登录会话(用户、平台、登录时间、最近活跃、在线状态);管理员/超级管理员可一键将指定设备强制下线
### 改进
- 顶栏精简,不再显示门店号;用户名移到左侧导航栏底部,点击可查看门店 / 账号 / 版本信息
- 账号在其他设备登录或会话失效时,自动退出并给出明确提示
## [1.0.56] - 2026-06-17
### 新功能
+10
View File
@@ -5,6 +5,16 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.56] - 2026-06-17
### 新功能
- 在线设备/会话管理:服务端记录每次登录会话,支持查看本店在线设备、按平台类(桌面端 / 移动端 / 网页端)分别限制并发登录数,管理员可强制将指定设备下线
- 登录失败锁定:同一账号连续多次密码错误后自动锁定一段时间,防止暴力破解
- 记录账号最近登录时间
### 修复
- 修复被禁用账号仍能凭刷新令牌继续续期登录的安全漏洞:续期时校验账号状态与会话是否有效
## [1.0.55] - 2026-06-17
### 新功能
+15
View File
@@ -13,6 +13,7 @@ type Config struct {
JWT JWTConfig
License LicenseConfig
Storage StorageConfig
Session SessionConfig
}
type ServerConfig struct {
@@ -39,6 +40,15 @@ type LicenseConfig struct {
Ed25519PrivateKey string `mapstructure:"ed25519_private_key"` // base64 Ed25519 private key for token signing (keep in Bitwarden)
}
// SessionConfig 登录会话与并发限制(全局默认,可被每店 session_policy 覆盖)。
type SessionConfig struct {
LimitDesktop int `mapstructure:"limit_desktop"` // 桌面端(win/mac/linux)最大并发会话,0=禁止
LimitMobile int `mapstructure:"limit_mobile"` // 移动端(android/ios)最大并发会话,0=禁止
LimitWeb int `mapstructure:"limit_web"` // web 端最大并发会话,0=禁止
MaxFailures int `mapstructure:"max_failures"` // 连续登录失败几次后锁定
LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟)
}
type StorageConfig struct {
UploadDir string `mapstructure:"upload_dir"`
BaseURL string `mapstructure:"base_url"`
@@ -75,6 +85,11 @@ func Load() {
viper.SetDefault("server.cors_origin", "*")
viper.SetDefault("jwt.access_expire_min", 60)
viper.SetDefault("jwt.refresh_expire_h", 168) // 7天
viper.SetDefault("session.limit_desktop", 2)
viper.SetDefault("session.limit_mobile", 2)
viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁
viper.SetDefault("session.max_failures", 5)
viper.SetDefault("session.lock_minutes", 15)
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("storage.upload_dir", "./uploads/images")
+42 -5
View File
@@ -1,9 +1,11 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
@@ -19,18 +21,34 @@ func NewAuthHandler(svc *service.AuthService) *AuthHandler {
// Login POST /api/v1/auth/login
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
ShopCode string `json:"shop_code" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
ShopCode string `json:"shop_code" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
DeviceID string `json:"device_id"`
DeviceName string `json:"device_name"`
Platform string `json:"platform"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password)
dev := service.DeviceInfo{
DeviceID: req.DeviceID,
DeviceName: req.DeviceName,
Platform: req.Platform,
IP: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
pair, user, err := h.svc.Login(req.ShopCode, req.Username, req.Password, dev)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
status := http.StatusUnauthorized
if errors.Is(err, service.ErrTooManyAttempts) {
status = http.StatusTooManyRequests
} else if errors.Is(err, service.ErrPlatformNotAllowed) {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
@@ -79,9 +97,28 @@ func (h *AuthHandler) Refresh(c *gin.Context) {
pair, err := h.svc.RefreshTokens(req.RefreshToken)
if err != nil {
// 被踢/登出/禁用 → 给前端明确 code,便于提示"账号已在其他设备登录/已失效"
if errors.Is(err, service.ErrSessionRevoked) || errors.Is(err, service.ErrUserInactive) {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error(), "code": "SESSION_REVOKED"})
return
}
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, pair)
}
// Logout POST /api/v1/auth/logout —— 撤销当前会话
func (h *AuthHandler) Logout(c *gin.Context) {
if err := h.svc.Logout(middleware.GetSID(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, gin.H{"ok": true})
}
// Ping POST /api/v1/auth/ping —— 心跳,仅触发中间件刷新 last_seen;会话已撤销则中间件直接 401
func (h *AuthHandler) Ping(c *gin.Context) {
util.RespondSuccess(c, gin.H{"ok": true})
}
+1 -1
View File
@@ -24,7 +24,7 @@ func TestFeedbackHandler_SubmitAndList(t *testing.T) {
r := gin.New()
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT())
api.Use(middleware.JWT(db))
api.POST("/feedback", fh.Submit)
adminG := api.Group("/admin")
adminG.Use(middleware.SuperAdminOnly())
+52
View File
@@ -0,0 +1,52 @@
package handler
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
)
type SessionHandler struct {
svc *service.AuthService
}
func NewSessionHandler(svc *service.AuthService) *SessionHandler {
return &SessionHandler{svc: svc}
}
// List GET /api/v1/sessions —— 列出本店在线会话(所有登录用户只读)
func (h *SessionHandler) List(c *gin.Context) {
shopID := middleware.GetShopID(c)
views, err := h.svc.ListSessions(shopID, middleware.GetSID(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, views)
}
// ForceLogout DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin,挂 AdminOnly
func (h *SessionHandler) ForceLogout(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid session id"})
return
}
shopID := middleware.GetShopID(c)
if err := h.svc.ForceLogout(shopID, id); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
util.RespondSuccess(c, gin.H{"ok": true})
}
+1 -1
View File
@@ -32,7 +32,7 @@ func setupProtectedRouter(db *gorm.DB) *gin.Engine {
r.Use(gin.Recovery())
api := r.Group("/api/v1")
api.Use(middleware.JWT())
api.Use(middleware.JWT(db))
// 商品路由
products := api.Group("/products")
+44 -5
View File
@@ -3,17 +3,22 @@ package middleware
import (
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
)
type Claims struct {
UserID uint64 `json:"user_id"`
ShopID uint64 `json:"shop_id"`
Role string `json:"role"`
LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual
UserID uint64 `json:"user_id"`
ShopID uint64 `json:"shop_id"`
Role string `json:"role"`
SID string `json:"sid,omitempty"` // 服务端会话标识(user_sessions.sid
LicenseExpiresAt *int64 `json:"lic_exp,omitempty"` // unix seconds; nil = perpetual
jwt.RegisteredClaims
}
@@ -21,10 +26,14 @@ const (
CtxUserID = "user_id"
CtxShopID = "shop_id"
CtxRole = "role"
CtxSID = "sid"
CtxLicenseExpiresAt = "lic_exp"
)
func JWT() gin.HandlerFunc {
// lastSeenThrottle 控制 last_seen_at 写频率,避免每请求一写。
const lastSeenThrottle = 30 * time.Second
func JWT(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") {
@@ -42,14 +51,44 @@ func JWT() gin.HandlerFunc {
return
}
// 会话校验:带 sid 的 token 必须对应一条未撤销会话(支持踢人/登出/禁用即时失效)。
// 存量无 sid 的 token 过渡放行(其 access ≤60min 过期后会换到带 sid 的会话)。
if claims.SID != "" {
var sess model.UserSession
if err := db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked", "code": "SESSION_REVOKED"})
return
}
// 节流刷新 last_seen,用于在线状态判定
if time.Since(sess.LastSeenAt) > lastSeenThrottle {
db.Model(&model.UserSession{}).Where("id = ?", sess.ID).
Update("last_seen_at", time.Now())
}
}
c.Set(CtxUserID, claims.UserID)
c.Set(CtxShopID, claims.ShopID)
c.Set(CtxRole, claims.Role)
c.Set(CtxSID, claims.SID)
c.Set(CtxLicenseExpiresAt, claims.LicenseExpiresAt)
c.Next()
}
}
// GetSID 从 context 中获取当前会话 sid(可能为空:存量无 sid token)。
func GetSID(c *gin.Context) string {
v, _ := c.Get(CtxSID)
s, _ := v.(string)
return s
}
// GetRole 从 context 中获取当前用户角色。
func GetRole(c *gin.Context) string {
v, _ := c.Get(CtxRole)
s, _ := v.(string)
return s
}
// AdminOnly 仅管理员可访问
func AdminOnly() gin.HandlerFunc {
return func(c *gin.Context) {
+11 -8
View File
@@ -1,13 +1,16 @@
package model
import "time"
type User struct {
Base
ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"`
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
PasswordHash string `gorm:"size:255" json:"-"`
RealName string `gorm:"size:50" json:"real_name"`
Phone string `gorm:"size:30" json:"phone"`
Role string `gorm:"type:enum('admin','operator','readonly','superadmin');default:'operator'" json:"role"`
IsActive bool `gorm:"default:true" json:"is_active"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
ShopID uint64 `gorm:"not null;index;uniqueIndex:uk_shop_username" json:"shop_id"`
Username string `gorm:"size:50;uniqueIndex:uk_shop_username" json:"username"`
PasswordHash string `gorm:"size:255" json:"-"`
RealName string `gorm:"size:50" json:"real_name"`
Phone string `gorm:"size:30" json:"phone"`
Role string `gorm:"type:enum('admin','operator','readonly','superadmin');default:'operator'" json:"role"`
IsActive bool `gorm:"default:true" json:"is_active"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
CustomFields JSON `gorm:"type:json" json:"custom_fields,omitempty"`
}
+39
View File
@@ -0,0 +1,39 @@
package model
import "time"
// UserSession 服务端登录会话。JWT 的 sid claim 指向此表一行,
// 用于支持登出/踢人/在线状态监控(JWT 本身无状态,无法撤销)。
type UserSession struct {
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
ShopID uint64 `gorm:"not null;index:idx_session_shop_user" json:"shop_id"`
UserID uint64 `gorm:"not null;index:idx_session_shop_user" json:"user_id"`
SID string `gorm:"column:sid;size:64;not null;uniqueIndex" json:"sid"` // 嵌入 JWT
DeviceID string `gorm:"size:255" json:"device_id"`
DeviceName string `gorm:"size:255" json:"device_name"`
Platform string `gorm:"size:50" json:"platform"` // windows|macos|linux|android|ios|web
PlatformClass string `gorm:"size:20;index" json:"platform_class"` // desktop|mobile|web
IP string `gorm:"size:64" json:"ip"`
UserAgent string `gorm:"size:512" json:"user_agent"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
LastSeenAt time.Time `gorm:"autoCreateTime" json:"last_seen_at"`
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled
RefreshExpAt time.Time `json:"refresh_exp_at"`
}
func (UserSession) TableName() string { return "user_sessions" }
// PlatformClass 把具体平台归类,用于按类限并发。
func PlatformClass(platform string) string {
switch platform {
case "android", "ios":
return "mobile"
case "web":
return "web"
case "windows", "macos", "linux":
return "desktop"
default:
return "desktop" // 未知平台按桌面端处理
}
}
+12 -1
View File
@@ -17,6 +17,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
// 处理器
authH := handler.NewAuthHandler(authSvc)
sessionH := handler.NewSessionHandler(authSvc)
licenseH := handler.NewLicenseHandler(licenseSvc)
productH := handler.NewProductHandler(db)
warehouseH := handler.NewWarehouseHandler(db)
@@ -69,7 +70,17 @@ func Setup(r *gin.Engine, db *gorm.DB) {
// 需要 JWT 的基础路由组
api := v1.Group("")
api.Use(middleware.JWT())
api.Use(middleware.JWT(db))
// 会话/在线状态(豁免 LicenseGuard:登出/心跳/在线列表在锁定期也要可用)
{
api.POST("/auth/logout", authH.Logout)
api.POST("/auth/ping", authH.Ping)
// 在线会话列表:所有登录用户只读
api.GET("/sessions", sessionH.List)
// 强制下线:仅 admin/superadmin
api.DELETE("/sessions/:id", middleware.AdminOnly(), sessionH.ForceLogout)
}
// 许可证路由:豁免 LicenseGuard(锁定时仍需查看状态和激活)
license := api.Group("/license")
+265 -5
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"sync"
"time"
"github.com/golang-jwt/jwt/v5"
@@ -19,8 +20,65 @@ var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserInactive = errors.New("user is disabled")
ErrLicenseLocked = errors.New("license locked, please renew or contact support")
ErrPlatformNotAllowed = errors.New("该平台不允许登录")
ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试")
ErrSessionRevoked = errors.New("session revoked")
)
// DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。
type DeviceInfo struct {
DeviceID string
DeviceName string
Platform string
IP string
UserAgent string
}
// loginLimiter 内存登录失败限流器(单实例,重启即清零)。
type loginLimiter struct {
mu sync.Mutex
entries map[string]*limiterEntry
}
type limiterEntry struct {
failures int
lockedTill time.Time
}
var loginLim = &loginLimiter{entries: map[string]*limiterEntry{}}
// locked 返回该 key 是否处于锁定中。
func (l *loginLimiter) locked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
return e != nil && time.Now().Before(e.lockedTill)
}
// recordFailure 记一次失败,达到阈值则锁定。
func (l *loginLimiter) recordFailure(key string) {
l.mu.Lock()
defer l.mu.Unlock()
e := l.entries[key]
if e == nil {
e = &limiterEntry{}
l.entries[key] = e
}
e.failures++
max := config.C.Session.MaxFailures
if max > 0 && e.failures >= max {
e.lockedTill = time.Now().Add(time.Duration(config.C.Session.LockMinutes) * time.Minute)
e.failures = 0
}
}
// reset 登录成功后清除失败计数。
func (l *loginLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.entries, key)
}
type AuthService struct {
db *gorm.DB
}
@@ -37,15 +95,22 @@ type TokenPair struct {
}
// Login 账号密码登录
func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *model.User, error) {
func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) {
limiterKey := shopCode + "|" + username
if loginLim.locked(limiterKey) {
return nil, nil, ErrTooManyAttempts
}
var shop model.Shop
if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil {
loginLim.recordFailure(limiterKey)
return nil, nil, ErrInvalidCredentials
}
var user model.User
if err := s.db.Where("shop_id = ? AND username = ? AND deleted_at IS NULL", shop.ID, username).
First(&user).Error; err != nil {
loginLim.recordFailure(limiterKey)
return nil, nil, ErrInvalidCredentials
}
@@ -54,6 +119,7 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
loginLim.recordFailure(limiterKey)
return nil, nil, ErrInvalidCredentials
}
@@ -61,13 +127,176 @@ func (s *AuthService) Login(shopCode, username, password string) (*TokenPair, *m
return nil, nil, err
}
pair, err := s.issueTokens(user.ID, shop.ID, user.Role)
// 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。
pclass := model.PlatformClass(dev.Platform)
quota := s.effectiveQuota(&shop, pclass)
if quota <= 0 {
return nil, nil, ErrPlatformNotAllowed
}
sid := uuid.New().String()
now := time.Now()
if err := s.db.Transaction(func(tx *gorm.DB) error {
// 统计该 user 在该 class 的活跃会话;超额踢最旧
var active []model.UserSession
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL",
shop.ID, user.ID, pclass).
Order("last_seen_at ASC").Find(&active).Error; err != nil {
return err
}
// 需要腾出 (active+1) - quota 个位置
for i := 0; i <= len(active)-quota; i++ {
if err := tx.Model(&model.UserSession{}).Where("id = ?", active[i].ID).
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil {
return err
}
}
sess := model.UserSession{
ShopID: shop.ID,
UserID: user.ID,
SID: sid,
DeviceID: dev.DeviceID,
DeviceName: dev.DeviceName,
Platform: dev.Platform,
PlatformClass: pclass,
IP: dev.IP,
UserAgent: dev.UserAgent,
LastSeenAt: now,
RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour),
}
if err := tx.Create(&sess).Error; err != nil {
return err
}
return tx.Model(&model.User{}).Where("id = ?", user.ID).Update("last_login_at", now).Error
}); err != nil {
return nil, nil, err
}
loginLim.reset(limiterKey)
user.LastLoginAt = &now
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid)
if err != nil {
return nil, nil, err
}
return pair, &user, nil
}
// effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。
func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int {
def := map[string]int{
"desktop": config.C.Session.LimitDesktop,
"mobile": config.C.Session.LimitMobile,
"web": config.C.Session.LimitWeb,
}[pclass]
if shop.CustomFields == nil {
return def
}
raw, ok := shop.CustomFields["session_policy"]
if !ok {
return def
}
policy, ok := raw.(map[string]interface{})
if !ok {
return def
}
v, ok := policy[pclass]
if !ok {
return def
}
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
default:
return def
}
}
// Logout 撤销指定会话(用户主动登出)。sid 为空(存量 token)时为 no-op。
func (s *AuthService) Logout(sid string) error {
if sid == "" {
return nil
}
return s.db.Model(&model.UserSession{}).
Where("sid = ? AND revoked_at IS NULL", sid).
Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "logout"}).Error
}
// SessionView 在线会话视图(含用户名,供管理端列表展示)。
type SessionView struct {
ID uint64 `json:"id"`
UserID uint64 `json:"user_id"`
Username string `json:"username"`
RealName string `json:"real_name"`
Platform string `json:"platform"`
PlatformClass string `json:"platform_class"`
DeviceName string `json:"device_name"`
IP string `json:"ip"`
CreatedAt time.Time `json:"created_at"`
LastSeenAt time.Time `json:"last_seen_at"`
Online bool `json:"online"`
IsCurrent bool `json:"is_current"`
}
// OnlineThreshold last_seen 在此时间内视为在线。
const OnlineThreshold = 90 * time.Second
// ListSessions 列出本店活跃(未撤销)会话,按最近活跃排序。currentSID 标记当前请求自身会话。
func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionView, error) {
var rows []struct {
model.UserSession
Username string
RealName string
}
err := s.db.Table("user_sessions AS s").
Select("s.*, u.username AS username, u.real_name AS real_name").
Joins("LEFT JOIN users u ON u.id = s.user_id").
Where("s.shop_id = ? AND s.revoked_at IS NULL", shopID).
Order("s.last_seen_at DESC").
Scan(&rows).Error
if err != nil {
return nil, err
}
now := time.Now()
views := make([]SessionView, 0, len(rows))
for _, r := range rows {
views = append(views, SessionView{
ID: r.ID,
UserID: r.UserID,
Username: r.Username,
RealName: r.RealName,
Platform: r.Platform,
PlatformClass: r.PlatformClass,
DeviceName: r.DeviceName,
IP: r.IP,
CreatedAt: r.CreatedAt,
LastSeenAt: r.LastSeenAt,
Online: now.Sub(r.LastSeenAt) <= OnlineThreshold,
IsCurrent: currentSID != "" && r.SID == currentSID,
})
}
return views, nil
}
// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。
func (s *AuthService) ForceLogout(shopID, sessionID uint64) error {
res := s.db.Model(&model.UserSession{}).
Where("id = ? AND shop_id = ? AND revoked_at IS NULL", sessionID, shopID).
Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin"})
if res.Error != nil {
return res.Error
}
if res.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// RegisterInput 注册新门店所需参数
type RegisterInput struct {
ShopName string `json:"shop_name" binding:"required"`
@@ -146,7 +375,8 @@ func HashPassword(plain string) (string, error) {
return string(b), err
}
// RefreshTokens 用 Refresh Token 换新 Token Pair
// RefreshTokens 用 Refresh Token 换新 Token Pair
// 修复历史漏洞:续期时重新查库校验用户/会话状态,被禁用或被踢的用户无法再续命。
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
claims := &middleware.Claims{}
token, err := jwt.ParseWithClaims(refreshToken, claims, func(t *jwt.Token) (interface{}, error) {
@@ -155,7 +385,35 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
if err != nil || !token.Valid {
return nil, errors.New("invalid refresh token")
}
return s.issueTokens(claims.UserID, claims.ShopID, claims.Role)
// 带 sid 的 token 必须对应未撤销会话(被踢/登出后无法续期)。
if claims.SID != "" {
var sess model.UserSession
if err := s.db.Where("sid = ?", claims.SID).First(&sess).Error; err != nil || sess.RevokedAt != nil {
return nil, ErrSessionRevoked
}
}
// 重新加载用户:不存在或已禁用 → 拒绝续期(修复漏洞)。
var user model.User
if err := s.db.Where("id = ? AND deleted_at IS NULL", claims.UserID).First(&user).Error; err != nil {
return nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, ErrUserInactive
}
if err := s.checkLicenseNotLocked(user.ShopID); err != nil {
return nil, err
}
// 刷新会话存活时间(同 sid 续期)。
if claims.SID != "" {
s.db.Model(&model.UserSession{}).Where("sid = ?", claims.SID).
Update("last_seen_at", time.Now())
}
return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID)
}
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
@@ -170,7 +428,7 @@ func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
return nil
}
func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPair, error) {
func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*TokenPair, error) {
cfg := config.C.JWT
now := time.Now()
@@ -188,6 +446,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai
UserID: userID,
ShopID: shopID,
Role: role,
SID: sid,
LicenseExpiresAt: licExpAt,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(accessExp),
@@ -204,6 +463,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role string) (*TokenPai
UserID: userID,
ShopID: shopID,
Role: role,
SID: sid,
LicenseExpiresAt: licExpAt,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(refreshExp),
+6 -6
View File
@@ -15,7 +15,7 @@ func TestAuthService_Login_Success(t *testing.T) {
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, user, err := svc.Login("HOTEL001", "admin", "password123")
pair, user, err := svc.Login("HOTEL001", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
require.NotNil(t, pair)
@@ -31,7 +31,7 @@ func TestAuthService_Login_WrongPassword(t *testing.T) {
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword")
pair, user, err := svc.Login("HOTEL002", "admin", "wrongpassword", DeviceInfo{Platform: "windows"})
assert.Error(t, err)
assert.Equal(t, ErrInvalidCredentials, err)
@@ -44,7 +44,7 @@ func TestAuthService_Login_WrongHotel(t *testing.T) {
testutil.CreateTestShop(db, "HOTEL003")
svc := NewAuthService(db)
pair, user, err := svc.Login("NONEXISTENT", "admin", "password123")
pair, user, err := svc.Login("NONEXISTENT", "admin", "password123", DeviceInfo{Platform: "windows"})
assert.Error(t, err)
assert.Equal(t, ErrInvalidCredentials, err)
@@ -60,7 +60,7 @@ func TestAuthService_Login_DisabledUser(t *testing.T) {
db.Model(user).Update("is_active", false)
svc := NewAuthService(db)
pair, u, err := svc.Login("HOTEL004", "disabled", "password123")
pair, u, err := svc.Login("HOTEL004", "disabled", "password123", DeviceInfo{Platform: "windows"})
assert.Error(t, err)
assert.Equal(t, ErrUserInactive, err)
@@ -74,7 +74,7 @@ func TestAuthService_Login_WrongUsername(t *testing.T) {
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123")
pair, user, err := svc.Login("HOTEL005", "nonexistent", "password123", DeviceInfo{Platform: "windows"})
assert.Error(t, err)
assert.Equal(t, ErrInvalidCredentials, err)
@@ -88,7 +88,7 @@ func TestAuthService_RefreshTokens(t *testing.T) {
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, _, err := svc.Login("HOTEL006", "admin", "password123")
pair, _, err := svc.Login("HOTEL006", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
require.NotNil(t, pair)
+169
View File
@@ -0,0 +1,169 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/testutil"
)
// 同平台类超配额时踢掉最旧会话。
func TestLogin_PerClassQuota_KicksOldest(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SESS01")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
dev := DeviceInfo{Platform: "windows"}
p1, _, err := svc.Login("SESS01", "admin", "password123", dev)
require.NoError(t, err)
p2, _, err := svc.Login("SESS01", "admin", "password123", dev)
require.NoError(t, err)
// 配额 2,第 3 次桌面端登录应踢掉最旧(p1)
_, _, err = svc.Login("SESS01", "admin", "password123", dev)
require.NoError(t, err)
// 共 2 个未撤销会话
var active int64
db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active)
assert.Equal(t, int64(2), active)
// p1 的 refresh 应失败(会话被踢)
_, err = svc.RefreshTokens(p1.RefreshToken)
assert.ErrorIs(t, err, ErrSessionRevoked)
// p2 仍可续期
_, err = svc.RefreshTokens(p2.RefreshToken)
assert.NoError(t, err)
}
// 移动端与桌面端配额互不影响。
func TestLogin_QuotaIsolatedByClass(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SESS02")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
_, _, err := svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
_, _, err = svc.Login("SESS02", "admin", "password123", DeviceInfo{Platform: "android"})
require.NoError(t, err)
var active int64
db.Model(&model.UserSession{}).Where("shop_id = ? AND revoked_at IS NULL", shop.ID).Count(&active)
assert.Equal(t, int64(2), active) // 桌面 1 + 移动 1,都没被踢
}
// 配额为 0 的平台拒绝登录(如禁 web)。
func TestLogin_PlatformDisabled(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SESS03")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
old := config.C.Session.LimitWeb
config.C.Session.LimitWeb = 0
defer func() { config.C.Session.LimitWeb = old }()
_, _, err := svc.Login("SESS03", "admin", "password123", DeviceInfo{Platform: "web"})
assert.ErrorIs(t, err, ErrPlatformNotAllowed)
}
// 每店 session_policy 覆盖全局默认。
func TestLogin_PerShopPolicyOverride(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SESS04")
// 该店限桌面 1
db.Model(&model.Shop{}).Where("id = ?", shop.ID).
Update("custom_fields", model.JSON{"session_policy": map[string]interface{}{"desktop": float64(1)}})
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
p1, _, err := svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
_, _, err = svc.Login("SESS04", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
// 第 2 次登录应把 p1 踢掉(配额 1)
_, err = svc.RefreshTokens(p1.RefreshToken)
assert.ErrorIs(t, err, ErrSessionRevoked)
}
// 被禁用用户无法用 refresh token 续命(修复历史漏洞)。
func TestRefresh_DisabledUserRejected(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SESS05")
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, _, err := svc.Login("SESS05", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
db.Model(user).Update("is_active", false)
_, err = svc.RefreshTokens(pair.RefreshToken)
assert.ErrorIs(t, err, ErrUserInactive)
}
// 管理员强制下线后,该会话 token 无法续期。
func TestForceLogout_RevokesSession(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SESS06")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
pair, _, err := svc.Login("SESS06", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
views, err := svc.ListSessions(shop.ID, "")
require.NoError(t, err)
require.Len(t, views, 1)
assert.True(t, views[0].Online)
require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID))
_, err = svc.RefreshTokens(pair.RefreshToken)
assert.ErrorIs(t, err, ErrSessionRevoked)
// 列表中不再出现
views, err = svc.ListSessions(shop.ID, "")
require.NoError(t, err)
assert.Len(t, views, 0)
}
// 强制下线跨店隔离:不能下线别店会话。
func TestForceLogout_TenantIsolation(t *testing.T) {
db := testutil.SetupTestDB()
shopA := testutil.CreateTestShop(db, "SESS07A")
shopB := testutil.CreateTestShop(db, "SESS07B")
testutil.CreateTestUser(db, shopA.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
_, _, err := svc.Login("SESS07A", "admin", "password123", DeviceInfo{Platform: "windows"})
require.NoError(t, err)
views, _ := svc.ListSessions(shopA.ID, "")
require.Len(t, views, 1)
// 用 shopB 的 shopID 尝试下线 shopA 的会话 → 找不到
err = svc.ForceLogout(shopB.ID, views[0].ID)
assert.Error(t, err)
}
// 连续登录失败达到阈值后锁定。
func TestLogin_LockoutAfterFailures(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SESS08")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
svc := NewAuthService(db)
for i := 0; i < config.C.Session.MaxFailures; i++ {
_, _, err := svc.Login("SESS08", "admin", "wrong", DeviceInfo{Platform: "windows"})
assert.ErrorIs(t, err, ErrInvalidCredentials)
}
// 锁定后即便密码正确也被拒
_, _, err := svc.Login("SESS08", "admin", "password123", DeviceInfo{Platform: "windows"})
assert.ErrorIs(t, err, ErrTooManyAttempts)
}
+1
View File
@@ -97,6 +97,7 @@ func autoMigrate(db *gorm.DB) {
&model.User{},
&model.License{},
&model.LicenseDevice{},
&model.UserSession{},
&model.ProductCategory{},
&model.Product{},
&model.Warehouse{},
+27
View File
@@ -42,6 +42,7 @@ CREATE TABLE IF NOT EXISTS `users` (
`phone` VARCHAR(30) DEFAULT NULL,
`role` ENUM('admin','operator','readonly','superadmin') NOT NULL DEFAULT 'operator',
`is_active` TINYINT(1) NOT NULL DEFAULT 1,
`last_login_at` DATETIME DEFAULT NULL COMMENT '最近登录时间',
`custom_fields` JSON DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -52,6 +53,32 @@ CREATE TABLE IF NOT EXISTS `users` (
KEY `idx_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户';
-- ------------------------------------------------------------
-- 登录会话(支持登出/踢人/在线状态监控;JWT 的 sid claim 指向此表)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `user_sessions` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`shop_id` BIGINT UNSIGNED NOT NULL,
`user_id` BIGINT UNSIGNED NOT NULL,
`sid` VARCHAR(64) NOT NULL COMMENT '嵌入 JWT 的会话标识',
`device_id` VARCHAR(255) DEFAULT NULL,
`device_name` VARCHAR(255) DEFAULT NULL,
`platform` VARCHAR(50) DEFAULT NULL COMMENT 'windows|macos|linux|android|ios|web',
`platform_class` VARCHAR(20) DEFAULT NULL COMMENT 'desktop|mobile|web',
`ip` VARCHAR(64) DEFAULT NULL,
`user_agent` VARCHAR(512) DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`last_seen_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`revoked_at` DATETIME DEFAULT NULL,
`revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled',
`refresh_exp_at` DATETIME DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_session_sid` (`sid`),
KEY `idx_session_shop_user` (`shop_id`, `user_id`),
KEY `idx_session_class` (`platform_class`),
KEY `idx_session_revoked` (`revoked_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录会话';
-- ------------------------------------------------------------
-- 许可证
-- ------------------------------------------------------------
+25
View File
@@ -30,6 +30,13 @@ func InitConfig() {
License: config.LicenseConfig{
HMACSecret: "test-license-hmac-secret",
},
Session: config.SessionConfig{
LimitDesktop: 2,
LimitMobile: 2,
LimitWeb: 2,
MaxFailures: 5,
LockMinutes: 15,
},
}
}
@@ -76,9 +83,27 @@ func SetupTestDB() *gorm.DB {
phone TEXT,
role TEXT DEFAULT 'operator',
is_active INTEGER DEFAULT 1,
last_login_at DATETIME,
custom_fields TEXT
)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uk_shop_username ON users(shop_id, username)`,
`CREATE TABLE IF NOT EXISTS user_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shop_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
sid TEXT NOT NULL UNIQUE,
device_id TEXT,
device_name TEXT,
platform TEXT,
platform_class TEXT,
ip TEXT,
user_agent TEXT,
created_at DATETIME,
last_seen_at DATETIME,
revoked_at DATETIME,
revoked_reason TEXT,
refresh_exp_at DATETIME
)`,
`CREATE TABLE IF NOT EXISTS licenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
+13 -3
View File
@@ -33,8 +33,11 @@ final apiClientProvider = Provider<ApiClient>((ref) {
onTokenRefreshed: (newToken) {
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
},
onAuthFailed: () {
onAuthFailed: (reason) {
if (ref.read(authStateProvider).isLoggedIn) {
if (reason != null) {
ref.read(sessionEndedMessageProvider.notifier).state = reason;
}
ref.read(authStateProvider.notifier).logout();
}
},
@@ -54,7 +57,7 @@ class ApiClient {
String? token,
String? refreshToken,
void Function(String newToken)? onTokenRefreshed,
void Function()? onAuthFailed,
void Function(String? reason)? onAuthFailed,
void Function()? onConnectionError,
}) {
_dio = Dio(BaseOptions(
@@ -110,7 +113,14 @@ class ApiClient {
return handler.resolve(retryResp);
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
if (!_disposed) onAuthFailed?.call();
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) onAuthFailed?.call(reason);
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
+19
View File
@@ -1,6 +1,8 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../config/app_config.dart';
const _kAccessToken = 'access_token';
const _kRefreshToken = 'refresh_token';
@@ -111,6 +113,20 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
final token = state.user?.accessToken;
if (token != null && token.isNotEmpty) {
try {
await Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 4),
receiveTimeout: const Duration(seconds: 4),
headers: {'Authorization': 'Bearer $token'},
)).post('/auth/logout');
} catch (_) {
// ignore: 后端不可达或会话已失效都无所谓
}
}
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
@@ -127,6 +143,9 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
(ref) => AuthNotifier(),
);
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
+5
View File
@@ -17,6 +17,7 @@ import '../../screens/public/public_product_screen.dart';
import '../../screens/public/public_shop_products_screen.dart';
import '../../screens/settings/settings_screen.dart';
import '../../screens/about/about_screen.dart';
import '../../screens/devices/device_management_screen.dart';
import '../auth/auth_state.dart';
Page<void> _noTransition(Widget child) =>
@@ -142,6 +143,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
GoRoute(
path: '/settings',
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
GoRoute(
path: '/devices',
pageBuilder: (_, __) =>
_noTransition(const DeviceManagementScreen())),
GoRoute(
path: '/about',
pageBuilder: (_, __) => _noTransition(const AboutScreen())),
+83
View File
@@ -0,0 +1,83 @@
/// 在线会话/设备(对应后端 service.SessionView)。
class DeviceSession {
final int id;
final int userId;
final String username;
final String realName;
final String platform;
final String platformClass;
final String deviceName;
final String ip;
final DateTime? createdAt;
final DateTime? lastSeenAt;
final bool online;
final bool isCurrent;
const DeviceSession({
required this.id,
required this.userId,
required this.username,
required this.realName,
required this.platform,
required this.platformClass,
required this.deviceName,
required this.ip,
required this.createdAt,
required this.lastSeenAt,
required this.online,
required this.isCurrent,
});
factory DeviceSession.fromJson(Map<String, dynamic> json) {
DateTime? parse(String? s) =>
(s == null || s.isEmpty) ? null : DateTime.tryParse(s)?.toLocal();
return DeviceSession(
id: (json['id'] as num).toInt(),
userId: (json['user_id'] as num?)?.toInt() ?? 0,
username: json['username'] as String? ?? '',
realName: json['real_name'] as String? ?? '',
platform: json['platform'] as String? ?? '',
platformClass: json['platform_class'] as String? ?? '',
deviceName: json['device_name'] as String? ?? '',
ip: json['ip'] as String? ?? '',
createdAt: parse(json['created_at'] as String?),
lastSeenAt: parse(json['last_seen_at'] as String?),
online: json['online'] as bool? ?? false,
isCurrent: json['is_current'] as bool? ?? false,
);
}
/// 平台中文显示名。
String get platformLabel {
switch (platform) {
case 'windows':
return 'Windows';
case 'macos':
return 'macOS';
case 'linux':
return 'Linux';
case 'android':
return 'Android';
case 'ios':
return 'iOS';
case 'web':
return '网页版';
default:
return platform.isEmpty ? '未知' : platform;
}
}
/// 平台类中文显示名。
String get platformClassLabel {
switch (platformClass) {
case 'desktop':
return '桌面端';
case 'mobile':
return '移动端';
case 'web':
return '网页端';
default:
return platformClass;
}
}
}
@@ -0,0 +1,40 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_client.dart';
import '../core/auth/auth_state.dart';
/// 登录态心跳:已登录时每 30s 打一次 POST /auth/ping。
/// 若会话已被撤销(被踢/管理员强制下线),后端返回 401,
/// ApiClient 的拦截器会触发 refresh→失败→onAuthFailed→logout
/// 因此空闲用户也能在 ~30s 内感知到被下线。
final sessionHeartbeatProvider = Provider<SessionHeartbeat>((ref) {
final hb = SessionHeartbeat(ref);
ref.onDispose(hb.dispose);
hb.start();
return hb;
});
class SessionHeartbeat {
final Ref _ref;
Timer? _timer;
SessionHeartbeat(this._ref);
void start() {
_timer?.cancel();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => _ping());
}
Future<void> _ping() async {
if (!_ref.read(authStateProvider).isLoggedIn) return;
try {
await _ref.read(apiClientProvider).post('/auth/ping');
} catch (e) {
// 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略
debugPrint('[Heartbeat] ping failed: $e');
}
}
void dispose() => _timer?.cancel();
}
@@ -0,0 +1,51 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_client.dart';
import '../core/auth/auth_state.dart';
import '../models/session.dart';
import '../repositories/session_repository.dart';
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
return SessionRepository(ref.watch(apiClientProvider));
});
final sessionListProvider =
AsyncNotifierProvider<SessionListNotifier, List<DeviceSession>>(
SessionListNotifier.new,
);
class SessionListNotifier extends AsyncNotifier<List<DeviceSession>> {
List<DeviceSession> _cache = [];
@override
Future<List<DeviceSession>> build() async {
ref.watch(authStateProvider.select((s) => s.user?.shopId));
try {
final result = await ref.read(sessionRepositoryProvider).list();
_cache = result;
return result;
} catch (_) {
if (_cache.isNotEmpty) return _cache;
rethrow;
}
}
Future<void> reload() async {
state = const AsyncValue.loading();
try {
final result = await ref.read(sessionRepositoryProvider).list();
_cache = result;
state = AsyncValue.data(result);
} catch (e, st) {
if (_cache.isNotEmpty) {
state = AsyncValue.data(_cache);
} else {
state = AsyncValue.error(e, st);
}
}
}
Future<void> forceLogout(int id) async {
await ref.read(sessionRepositoryProvider).forceLogout(id);
await reload();
}
}
@@ -2,6 +2,7 @@ import 'package:flutter/foundation.dart';
import 'package:dio/dio.dart';
import '../core/api/api_client.dart';
import '../core/auth/auth_state.dart';
import '../core/device/device_id.dart';
typedef AuthLoginFn = Future<AuthUser> Function({
required String shopCode,
@@ -37,10 +38,15 @@ class AuthRepository {
);
}
try {
final deviceId = await DeviceId.get();
final platform = DeviceId.platformName;
final resp = await PublicApiClient.post('/auth/login', data: {
'shop_code': shopCode,
'username': username,
'password': password,
'device_id': deviceId,
'device_name': platform,
'platform': platform,
});
final data = resp.data['data'] as Map<String, dynamic>;
@@ -0,0 +1,21 @@
import '../core/api/api_client.dart';
import '../models/session.dart';
class SessionRepository {
final ApiClient _client;
SessionRepository(this._client);
/// GET /api/v1/sessions —— 本店在线会话(所有登录用户只读)
Future<List<DeviceSession>> list() async {
final resp = await _client.get('/sessions');
final data = (resp.data['data'] as List?) ?? [];
return data
.map((e) => DeviceSession.fromJson(e as Map<String, dynamic>))
.toList();
}
/// DELETE /api/v1/sessions/:id —— 强制下线(仅 admin/superadmin
Future<void> forceLogout(int id) async {
await _client.delete('/sessions/$id');
}
}
+11
View File
@@ -244,6 +244,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
@override
Widget build(BuildContext context) {
// 被踢下线 / 会话失效:登出后跳回登录页,弹一次提示并清空
ref.listen<String?>(sessionEndedMessageProvider, (prev, next) {
if (next != null && next.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(next), backgroundColor: AppTheme.danger));
ref.read(sessionEndedMessageProvider.notifier).state = null;
});
}
});
return Scaffold(
backgroundColor: AppTheme.primaryDark,
body: Stack(
@@ -0,0 +1,237 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import '../../core/auth/auth_state.dart';
import '../../core/theme/app_theme.dart';
import '../../models/session.dart';
import '../../providers/session_provider.dart';
import '../../widgets/data_table_card.dart';
import '../../widgets/mobile_list_card.dart';
/// 设备 / 状态管理:本店在线设备/会话列表。
/// 所有登录用户只读;管理员/超级管理员可「强制下线」其他登录者。
class DeviceManagementScreen extends ConsumerWidget {
const DeviceManagementScreen({super.key});
static final _fmt = DateFormat('MM-dd HH:mm');
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncSessions = ref.watch(sessionListProvider);
final role = ref.watch(authStateProvider.select((s) => s.user?.role));
final canKick = role == 'admin' || role == 'superadmin';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 8),
child: Row(
children: [
const Text('设备 / 状态管理',
style:
TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(width: 12),
Text(
canKick ? '管理员可强制下线其他登录设备' : '仅查看,无操作权限',
style: const TextStyle(
fontSize: 12, color: AppTheme.textSecondary),
),
const Spacer(),
IconButton(
tooltip: '刷新',
icon: const Icon(Icons.refresh, size: 20),
onPressed: () =>
ref.read(sessionListProvider.notifier).reload(),
),
],
),
),
Expanded(
child: asyncSessions.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
size: 40, color: AppTheme.textSecondary),
const SizedBox(height: 12),
Text('加载失败:$e',
style:
const TextStyle(color: AppTheme.textSecondary)),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () =>
ref.read(sessionListProvider.notifier).reload(),
child: const Text('重试'),
),
],
),
),
data: (sessions) =>
_buildTable(context, ref, sessions, canKick),
),
),
],
);
}
Widget _buildTable(BuildContext context, WidgetRef ref,
List<DeviceSession> sessions, bool canKick) {
Widget onlineBadge(bool online) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.circle,
size: 9,
color: online ? AppTheme.success : AppTheme.textSecondary),
const SizedBox(width: 4),
Text(online ? '在线' : '离线',
style: TextStyle(
fontSize: 13,
color:
online ? AppTheme.success : AppTheme.textSecondary)),
],
);
String fmt(DateTime? t) => t == null ? '-' : _fmt.format(t);
Widget? kickButton(DeviceSession s, {bool dense = false}) {
if (!canKick) return null;
if (s.isCurrent) {
return const Text('当前设备',
style: TextStyle(fontSize: 12, color: AppTheme.textSecondary));
}
return TextButton(
key: Key('btn_kick_${s.id}'),
onPressed: () => _confirmKick(context, ref, s),
child: Text('强制下线',
style: TextStyle(
fontSize: dense ? 13 : 12, color: AppTheme.danger)),
);
}
Widget sessionCard(DeviceSession s) {
final action = kickButton(s, dense: true);
return MobileListCard(
title: Text(s.username.isEmpty ? '用户#${s.userId}' : s.username),
subtitle: Text('${s.platformLabel} · ${s.platformClassLabel}'),
trailing: onlineBadge(s.online),
fields: [
if (s.deviceName.isNotEmpty) MobileCardField('设备', s.deviceName),
if (s.ip.isNotEmpty) MobileCardField('IP', s.ip),
MobileCardField('登录时间', fmt(s.createdAt)),
MobileCardField('最近活跃', fmt(s.lastSeenAt)),
if (s.isCurrent) const MobileCardField('备注', '当前设备'),
],
actions: action == null ? null : [action],
);
}
return DataTableCard(
mobileCards: sessions.map(sessionCard).toList(),
columns: const [
DataColumn(label: Text('用户')),
DataColumn(label: Text('平台')),
DataColumn(label: Text('设备')),
DataColumn(label: Text('IP')),
DataColumn(label: Text('登录时间')),
DataColumn(label: Text('最近活跃')),
DataColumn(label: Text('状态')),
DataColumn(label: Text('操作')),
],
rows: sessions.isEmpty
? [
const DataRow(cells: [
DataCell(Text('暂无在线设备',
style: TextStyle(color: AppTheme.textSecondary))),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
DataCell(SizedBox()),
])
]
: sessions.map((s) {
return DataRow(cells: [
DataCell(Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(s.username.isEmpty ? '用户#${s.userId}' : s.username,
style:
const TextStyle(fontWeight: FontWeight.w500)),
if (s.isCurrent) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: AppTheme.primary.withAlpha(26),
borderRadius: BorderRadius.circular(4),
),
child: const Text('本机',
style: TextStyle(
fontSize: 11, color: AppTheme.primary)),
),
],
],
)),
DataCell(Text('${s.platformLabel} · ${s.platformClassLabel}')),
DataCell(Text(s.deviceName.isEmpty ? '-' : s.deviceName)),
DataCell(Text(s.ip.isEmpty ? '-' : s.ip,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: AppTheme.textSecondary))),
DataCell(Text(fmt(s.createdAt))),
DataCell(Text(fmt(s.lastSeenAt))),
DataCell(onlineBadge(s.online)),
DataCell(kickButton(s) ?? const SizedBox()),
]);
}).toList(),
);
}
Future<void> _confirmKick(
BuildContext context, WidgetRef ref, DeviceSession s) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('强制下线'),
content: Text(
'确认将「${s.username.isEmpty ? '用户#${s.userId}' : s.username}'
'${s.platformLabel}设备下线?该设备约 30 秒内退出登录。'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.danger,
foregroundColor: Colors.white),
child: const Text('强制下线'),
),
],
),
);
if (confirmed != true) return;
try {
await ref.read(sessionListProvider.notifier).forceLogout(s.id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已下线'), backgroundColor: AppTheme.success));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('操作失败:$e'), backgroundColor: AppTheme.danger));
}
}
}
}
+52 -29
View File
@@ -11,6 +11,7 @@ import '../../core/config/app_config.dart';
import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/session_heartbeat.dart';
import '../../providers/shop_provider.dart';
import '../../providers/update_provider.dart';
import '../../core/update/app_updater.dart';
@@ -72,6 +73,7 @@ class _AppShellState extends ConsumerState<AppShell> {
path: '/finance'),
_NavItem(icon: Icons.people, label: '往来单位', path: '/partners'),
_NavItem(icon: Icons.category, label: '基础数据', path: '/products'),
_NavItem(icon: Icons.devices, label: '设备管理', path: '/devices'),
_NavItem(icon: Icons.settings, label: '系统设置', path: '/settings'),
_NavItem(icon: Icons.info_outline, label: '关于我们', path: '/about'),
];
@@ -166,6 +168,8 @@ class _AppShellState extends ConsumerState<AppShell> {
@override
Widget build(BuildContext context) {
final user = ref.watch(authStateProvider).user;
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
ref.watch(sessionHeartbeatProvider);
final isOnline = ref.watch(connectivityProvider);
final location = GoRouterState.of(context).matchedLocation;
final isMobile = context.isMobile;
@@ -237,35 +241,7 @@ class _AppShellState extends ConsumerState<AppShell> {
],
const Spacer(),
if (user != null) ...[
// 窄屏隐藏门店号/用户名文字块,仅保留用户菜单,避免顶栏拥挤
if (!isMobile) ...[
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () =>
_showShopPanel(context, user, version: appVersion),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.business,
color: Colors.white70, size: 14),
const SizedBox(width: 4),
Text(user.shopNo,
style: const TextStyle(
color: Colors.white70, fontSize: 13)),
],
),
),
),
const SizedBox(width: 20),
const Icon(Icons.person_outline,
color: Colors.white70, size: 14),
const SizedBox(width: 4),
Text(user.username,
style: const TextStyle(
color: Colors.white70, fontSize: 13)),
const SizedBox(width: 8),
],
// 门店号已移除;用户名移到左侧栏底部。顶栏仅保留「个人设置」下拉。
PopupMenuButton<String>(
icon: const Icon(Icons.keyboard_arrow_down,
color: Colors.white70),
@@ -324,6 +300,53 @@ class _AppShellState extends ConsumerState<AppShell> {
}).toList(),
),
),
// 当前登录账号(顶栏移下来的用户名):点击弹门店/账号/版本面板
if (user != null) ...[
const Divider(height: 1, color: Colors.white24),
SizedBox(
height: 48,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => _showShopPanel(context, user,
version: appVersion),
hoverColor: Colors.white.withAlpha(13),
splashColor: Colors.white.withAlpha(26),
child: Row(
children: [
SizedBox(
width: _sidebarExpanded ? 16 : 3),
Expanded(
child: Row(
mainAxisAlignment: _sidebarExpanded
? MainAxisAlignment.start
: MainAxisAlignment.center,
children: [
const Icon(Icons.person_outline,
color: Colors.white60,
size: 20),
if (_sidebarExpanded) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
user.username,
style: const TextStyle(
color: Colors.white70,
fontSize: 14),
overflow:
TextOverflow.ellipsis,
),
),
],
],
),
),
],
),
),
),
),
],
// 退出登录(侧栏底部常驻;统一为左侧导航唯一退出入口)
const Divider(height: 1, color: Colors.white24),
SizedBox(
+1
View File
@@ -7,6 +7,7 @@ set -euo pipefail
TAG="$1"
VER="${TAG#client-v}"
VER="${VER#v}" # 兼容 build-windows.yml 传裸 v 前缀(如 v1.0.4);pubspec 版本不能带 v
echo "==> compile-windows: version=${VER}"