Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec36b9679a | |||
| 51c81fd943 | |||
| 32bd64d676 | |||
| e41085a878 | |||
| 2d84bda99a | |||
| e3c8350d03 | |||
| a8e29a641a | |||
| e3e8cf7037 | |||
| 435e02278d | |||
| 64a64e7c0a | |||
| 2ac1cbfb24 |
@@ -5,6 +5,11 @@ 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.58] - 2026-06-18
|
||||
|
||||
### 修复
|
||||
- 恢复 Windows 桌面版的自动构建与发布:上一版因构建环境问题未能产出 Windows 安装包,本版已修复,Windows 用户可正常下载安装
|
||||
|
||||
## [1.0.57] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -5,6 +5,29 @@
|
||||
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.59] - 2026-06-19
|
||||
|
||||
### 新功能
|
||||
- 会话安全加固:刷新令牌每次续期自动轮换并具备盗用检测,旧令牌一旦被重放即吊销整条会话;新增失败登录记录便于审计风控
|
||||
|
||||
### 改进
|
||||
- 授权状态实时生效:续费、到期或授权被调整后约 30 秒内即对写操作生效,无需退出重新登录
|
||||
- 修改密码或停用账号后,该账号在所有设备上的登录会立即失效
|
||||
- 自动清理超过保留期的已撤销/过期会话与失败登录记录,避免数据无限增长
|
||||
|
||||
### 修复
|
||||
- 修复数据库瞬时异常时授权状态被误判为「无授权」、导致界面错误降级的问题
|
||||
|
||||
## [1.0.58] - 2026-06-18
|
||||
|
||||
### 修复
|
||||
- 修复已登录用户授权长期停留在「未激活」的问题:此前仅全新登录才会自动发放 30 天试用,靠本地令牌自动续登的老用户始终拿不到,现在续登时也会补发
|
||||
|
||||
## [1.0.57] - 2026-06-18
|
||||
|
||||
### 新功能
|
||||
- 未激活门店首次登录自动获得 30 天试用:此前手动建店或老数据无授权时既不提示也不限制,现在登录即开通试用,到期前会正常提醒并按阶段降级
|
||||
|
||||
## [1.0.56] - 2026-06-17
|
||||
|
||||
### 新功能
|
||||
|
||||
@@ -45,8 +45,9 @@ 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"` // 锁定时长(分钟)
|
||||
MaxFailures int `mapstructure:"max_failures"` // 连续登录失败几次后锁定
|
||||
LockMinutes int `mapstructure:"lock_minutes"` // 锁定时长(分钟)
|
||||
RetentionDays int `mapstructure:"retention_days"` // 已撤销/过期会话与失败登录记录的保留天数,过期后台清理
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
@@ -90,6 +91,7 @@ func Load() {
|
||||
viper.SetDefault("session.limit_web", 2) // 默认不禁 web(官网挂着 Web 版 app);设 0 可禁
|
||||
viper.SetDefault("session.max_failures", 5)
|
||||
viper.SetDefault("session.lock_minutes", 15)
|
||||
viper.SetDefault("session.retention_days", 90)
|
||||
viper.SetDefault("database.max_idle_conns", 10)
|
||||
viper.SetDefault("database.max_open_conns", 100)
|
||||
viper.SetDefault("storage.upload_dir", "./uploads/images")
|
||||
|
||||
@@ -11,11 +11,12 @@ import (
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
svc *service.AuthService
|
||||
svc *service.AuthService
|
||||
licSvc *service.LicenseService // 用于心跳 /auth/ping 回带授权概况;测试构造可传 nil
|
||||
}
|
||||
|
||||
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
|
||||
return &AuthHandler{svc: svc}
|
||||
func NewAuthHandler(svc *service.AuthService, licSvc *service.LicenseService) *AuthHandler {
|
||||
return &AuthHandler{svc: svc, licSvc: licSvc}
|
||||
}
|
||||
|
||||
// Login POST /api/v1/auth/login
|
||||
@@ -118,7 +119,20 @@ func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// Ping POST /api/v1/auth/ping —— 心跳,仅触发中间件刷新 last_seen;会话已撤销则中间件直接 401
|
||||
// Ping POST /api/v1/auth/ping —— 心跳:会话已撤销则中间件直接 401(触发登出),
|
||||
// 否则刷新 last_seen 并**回带当前授权概况**,使客户端无需再单独轮询 /license/info
|
||||
// (到期/续费/被改动等变化随心跳即时反映到横幅/只读门禁)。
|
||||
func (h *AuthHandler) Ping(c *gin.Context) {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
if h.licSvc == nil {
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
view, err := h.licSvc.ShopInfoView(middleware.GetShopID(c))
|
||||
if err != nil {
|
||||
// 授权查询出错不应让心跳失败(会话本身有效):省略 license 字段,客户端保留上次状态。
|
||||
util.RespondSuccess(c, gin.H{"ok": true})
|
||||
return
|
||||
}
|
||||
// view 可能为 nil(确无授权)→ license:null,与 /license/info 的 data:null 语义一致。
|
||||
util.RespondSuccess(c, gin.H{"ok": true, "license": view})
|
||||
}
|
||||
|
||||
@@ -11,7 +11,10 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
@@ -29,7 +32,7 @@ func newTestAuthRouter(t *testing.T) (*gin.Engine, *gin.Engine) {
|
||||
db.Exec("UPDATE users SET is_active = 0 WHERE username = 'disabled' AND shop_id = ?", shop.ID)
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
@@ -43,7 +46,7 @@ func TestAuthHandler_Login_Success(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -74,7 +77,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -96,7 +99,7 @@ func TestAuthHandler_Login_WrongPassword(t *testing.T) {
|
||||
func TestAuthHandler_Login_MissingFields(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -120,7 +123,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
@@ -160,7 +163,7 @@ func TestAuthHandler_Refresh_Success(t *testing.T) {
|
||||
func TestAuthHandler_Refresh_InvalidToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
|
||||
@@ -181,7 +184,7 @@ func TestAuthHandler_Login_DisabledUser(t *testing.T) {
|
||||
db.Model(user).Update("is_active", false)
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -205,7 +208,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
|
||||
testutil.CreateTestShop(db, "AH006")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -227,7 +230,7 @@ func TestAuthHandler_Login_WrongShopCode(t *testing.T) {
|
||||
func TestAuthHandler_Login_EmptyBody(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -242,7 +245,7 @@ func TestAuthHandler_Login_EmptyBody(t *testing.T) {
|
||||
func TestAuthHandler_Refresh_MissingToken(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/refresh", h.Refresh)
|
||||
|
||||
@@ -263,7 +266,7 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
|
||||
testutil.CreateTestUser(db, shop.ID, "manager", "password123", "admin")
|
||||
|
||||
svc := service.NewAuthService(db)
|
||||
h := NewAuthHandler(svc)
|
||||
h := NewAuthHandler(svc, nil)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/login", h.Login)
|
||||
|
||||
@@ -294,3 +297,42 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
|
||||
assert.Equal(t, "manager", userInfo["username"])
|
||||
}
|
||||
}
|
||||
|
||||
// #8 心跳 /auth/ping 回带授权概况,客户端据此免去单独轮询 /license/info。
|
||||
func TestAuthHandler_Ping_ReturnsLicense(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
priv, _, err := util.GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
config.C.License.Ed25519PrivateKey = priv
|
||||
|
||||
shop := testutil.CreateTestShop(db, "AHPING")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
authSvc := service.NewAuthService(db)
|
||||
// 登录触发首登自动 trial。
|
||||
_, _, err = authSvc.Login("AHPING", "admin", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
licSvc := service.NewLicenseService(db)
|
||||
h := NewAuthHandler(authSvc, licSvc)
|
||||
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/auth/ping", func(c *gin.Context) {
|
||||
c.Set(middleware.CtxShopID, shop.ID) // 模拟 JWT 中间件注入 shop_id
|
||||
h.Ping(c)
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", "/api/v1/auth/ping", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
data := resp["data"].(map[string]interface{})
|
||||
assert.Equal(t, true, data["ok"])
|
||||
lic, ok := data["license"].(map[string]interface{})
|
||||
require.True(t, ok, "心跳应回带 license 概况")
|
||||
assert.Equal(t, "trial", lic["type"])
|
||||
assert.NotEmpty(t, lic["phase"])
|
||||
}
|
||||
|
||||
@@ -58,29 +58,13 @@ func (h *LicenseHandler) Verify(c *gin.Context) {
|
||||
|
||||
// Info GET /api/v1/license/info — 当前门店授权概况
|
||||
func (h *LicenseHandler) Info(c *gin.Context) {
|
||||
shopID := middleware.GetShopID(c)
|
||||
lic, err := h.svc.ShopInfo(shopID)
|
||||
if err != nil {
|
||||
util.RespondSuccess(c, nil)
|
||||
return
|
||||
}
|
||||
|
||||
deviceCount, err := h.svc.CountDevices(lic.ID)
|
||||
view, err := h.svc.ShopInfoView(middleware.GetShopID(c))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
phase := middleware.CalcLicensePhase(lic.ExpiresAt)
|
||||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||||
"id": lic.ID,
|
||||
"type": lic.Type,
|
||||
"is_active": lic.IsActive,
|
||||
"max_devices": lic.MaxDevices,
|
||||
"device_count": deviceCount,
|
||||
"expires_at": lic.ExpiresAt,
|
||||
"phase": phase,
|
||||
}})
|
||||
// view 为 nil 表示无有效授权 → data:null(与原行为一致)。
|
||||
util.RespondSuccess(c, view)
|
||||
}
|
||||
|
||||
// Devices GET /api/v1/license/devices — 已绑定设备列表
|
||||
|
||||
@@ -40,7 +40,7 @@ func (h *SessionHandler) ForceLogout(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
shopID := middleware.GetShopID(c)
|
||||
if err := h.svc.ForceLogout(shopID, id); err != nil {
|
||||
if err := h.svc.ForceLogout(shopID, id, middleware.GetUserID(c)); err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "session not found"})
|
||||
return
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// setupSessionSecurityRouter 挂 JWT 中间件 + 用户管理路由 + 一个受保护 GET,
|
||||
// 用于验证「改密/禁用即时下线」与中间件对 is_active 的兜底校验。
|
||||
func setupSessionSecurityRouter(db *gorm.DB) *gin.Engine {
|
||||
userH := NewUserHandler(db)
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.JWT(db))
|
||||
api.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
|
||||
users := api.Group("/users")
|
||||
users.PUT("/:id", userH.Update)
|
||||
users.PUT("/:id/reset-password", userH.ResetPassword)
|
||||
return r
|
||||
}
|
||||
|
||||
// #2 管理员重置某用户密码 → 该用户全部活跃会话立即失效。
|
||||
func TestResetPassword_RevokesUserSessions(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SEC01")
|
||||
admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
|
||||
r := setupSessionSecurityRouter(db)
|
||||
svc := service.NewAuthService(db)
|
||||
|
||||
// 目标用户登录,拿到带 sid 的 access token(建出真实会话行)。
|
||||
pair, _, err := svc.Login("SEC01", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
targetToken := pair.AccessToken
|
||||
|
||||
// 改密前:目标 token 可用。
|
||||
require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code)
|
||||
|
||||
// 管理员重置目标用户密码。
|
||||
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
|
||||
w := makeRequest(r, "PUT", "/api/v1/users/2/reset-password", adminToken, jsonBody("password", "newpass123"))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
// 改密后:目标 token 立即 401 SESSION_REVOKED。
|
||||
w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"])
|
||||
|
||||
// 会话审计字段。
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
|
||||
assert.Equal(t, "pwd_reset", sess.RevokedReason)
|
||||
require.NotNil(t, sess.RevokedBy)
|
||||
assert.Equal(t, admin.ID, *sess.RevokedBy)
|
||||
}
|
||||
|
||||
// #3 管理员停用某用户 → 该用户活跃会话被吊销,原 token 立即 401。
|
||||
func TestDisableUser_RevokesSessions(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SEC02")
|
||||
admin := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
|
||||
r := setupSessionSecurityRouter(db)
|
||||
svc := service.NewAuthService(db)
|
||||
|
||||
pair, _, err := svc.Login("SEC02", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
targetToken := pair.AccessToken
|
||||
|
||||
adminToken := getAuthToken(admin.ID, shop.ID, "admin")
|
||||
w := makeRequest(r, "PUT", "/api/v1/users/2", adminToken, jsonBody("is_active", false))
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
|
||||
w = makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, "SESSION_REVOKED", parseResponse(w)["code"])
|
||||
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
|
||||
assert.Equal(t, "disabled", sess.RevokedReason)
|
||||
}
|
||||
|
||||
// #3 兜底:直接改库把 is_active=0(不动会话)→ 中间件 JOIN 校验返回 401 USER_DISABLED。
|
||||
func TestMiddleware_RejectsDisabledUserOnDirectDBFlip(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "SEC03")
|
||||
target := testutil.CreateTestUser(db, shop.ID, "clerk", "password123", "operator")
|
||||
r := setupSessionSecurityRouter(db)
|
||||
svc := service.NewAuthService(db)
|
||||
|
||||
pair, _, err := svc.Login("SEC03", "clerk", "password123", service.DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
targetToken := pair.AccessToken
|
||||
require.Equal(t, http.StatusOK, makeRequest(r, "GET", "/api/v1/ping", targetToken, nil).Code)
|
||||
|
||||
// 绕过 handler 直接改库:会话仍为活跃(revoked_at 为空),仅 is_active=0。
|
||||
require.NoError(t, db.Exec("UPDATE users SET is_active = 0 WHERE id = ?", target.ID).Error)
|
||||
|
||||
w := makeRequest(r, "GET", "/api/v1/ping", targetToken, nil)
|
||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
||||
assert.Equal(t, "USER_DISABLED", parseResponse(w)["code"])
|
||||
|
||||
// 会话本身未被吊销,证明拦截来自中间件对 is_active 的兜底校验。
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ?", target.ID).First(&sess).Error)
|
||||
assert.Nil(t, sess.RevokedAt)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
@@ -100,6 +101,13 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
if len(updates) > 0 {
|
||||
h.db.Model(&u).Where("shop_id = ?", shopID).Updates(updates)
|
||||
}
|
||||
// 禁用用户时立即吊销其全部活跃会话,使其在线设备下次请求即被拦下。
|
||||
if req.IsActive != nil && !*req.IsActive {
|
||||
if err := service.RevokeUserSessions(h.db, shopID, u.ID, middleware.GetUserID(c), "disabled"); err != nil {
|
||||
// 吊销失败不阻断本次更新(用户已停用,refresh 也已被 is_active 拦截)。
|
||||
c.Error(err) //nolint:errcheck
|
||||
}
|
||||
}
|
||||
h.db.Where("id = ? AND shop_id = ?", u.ID, shopID).First(&u)
|
||||
util.RespondSuccess(c, u)
|
||||
}
|
||||
@@ -126,5 +134,9 @@ func (h *UserHandler) ResetPassword(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
h.db.Model(&u).Update("password_hash", string(hash))
|
||||
// 改密后吊销该用户全部活跃会话,旧设备 token 立即失效(防止凭旧凭据继续访问)。
|
||||
if err := service.RevokeUserSessions(h.db, shopID, u.ID, middleware.GetUserID(c), "pwd_reset"); err != nil {
|
||||
c.Error(err) //nolint:errcheck
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"message": "密码已重置"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// setupGuardedRouter 复刻 router.go:98 的真实链路:
|
||||
// JWT → ReadOnly → LicenseGuard,后接真实 product handler。
|
||||
// 用于「写权限鉴权」的全链路集成测试(后端侧前后端契约)。
|
||||
func setupGuardedRouter(db *gorm.DB) *gin.Engine {
|
||||
productH := NewProductHandler(db)
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
api := r.Group("/api/v1")
|
||||
api.Use(middleware.JWT(db), middleware.ReadOnly(), middleware.LicenseGuard(db))
|
||||
|
||||
products := api.Group("/products")
|
||||
products.GET("", productH.List)
|
||||
products.POST("", productH.Create)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
// seedLicense 给店铺写入一条有效授权,daysAgo>0 表示已过期天数,<=0 表示未过期(剩余天数)。
|
||||
func seedLicense(t *testing.T, db *gorm.DB, shopID uint64, daysAgo int) {
|
||||
exp := time.Now().Add(-time.Duration(daysAgo) * 24 * time.Hour)
|
||||
assert.NoError(t, db.Create(&model.License{
|
||||
ShopID: shopID,
|
||||
IsActive: true,
|
||||
Type: "trial",
|
||||
LicenseKey: fmt.Sprintf("KEY-%d-%d", shopID, time.Now().UnixNano()),
|
||||
ExpiresAt: &exp,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
// TestWriteAccessMatrix 钉死「角色 × 授权阶段」对写/读操作的 403/200 契约。
|
||||
// 这是前端唯一依赖的契约,任何回归都会被这张表抓到。
|
||||
func TestWriteAccessMatrix(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db := testutil.SetupTestDB()
|
||||
r := setupGuardedRouter(db)
|
||||
|
||||
// 每档一个独立店铺,避免 LicenseGuard 的 30s 缓存按 shopID 串扰。
|
||||
type tc struct {
|
||||
name string
|
||||
role string
|
||||
daysExpired int // >0 已过期天数;-30 表示未过期
|
||||
wantPostFwd bool // POST 是否应放行(非 403)
|
||||
wantGetFwd bool // GET 是否应放行
|
||||
wantCode string // 期望 403 body 的 code(空则不校验)
|
||||
wantPhase string // 期望 403 body 的 phase(空则不校验)
|
||||
}
|
||||
cases := []tc{
|
||||
{"operator_normal", "operator", -30, true, true, "", ""},
|
||||
{"operator_grace", "operator", 3, true, true, "", ""},
|
||||
{"operator_readonly", "operator", 10, false, true, "", "readonly"},
|
||||
{"operator_locked", "operator", 20, false, false, "", "locked"},
|
||||
{"readonly_role", "readonly", -30, false, true, "READONLY_USER", ""},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
shop := testutil.CreateTestShop(db, c.name)
|
||||
shopID := shop.ID
|
||||
user := testutil.CreateTestUser(db, shopID, "u_"+c.name, "password123", c.role)
|
||||
seedLicense(t, db, shopID, c.daysExpired)
|
||||
token := getAuthToken(user.ID, shopID, c.role)
|
||||
|
||||
// POST /products
|
||||
wPost := makeRequest(r, http.MethodPost, "/api/v1/products", token, jsonBody("name", "集成测试酒"))
|
||||
if c.wantPostFwd {
|
||||
assert.NotEqual(t, http.StatusForbidden, wPost.Code, "POST 应放行")
|
||||
} else {
|
||||
assert.Equal(t, http.StatusForbidden, wPost.Code, "POST 应被拦截")
|
||||
body := parseResponse(wPost)
|
||||
if c.wantCode != "" {
|
||||
assert.Equal(t, c.wantCode, body["code"])
|
||||
}
|
||||
if c.wantPhase != "" {
|
||||
assert.Equal(t, c.wantPhase, body["phase"])
|
||||
}
|
||||
}
|
||||
|
||||
// GET /products
|
||||
wGet := makeRequest(r, http.MethodGet, "/api/v1/products", token, nil)
|
||||
if c.wantGetFwd {
|
||||
assert.NotEqual(t, http.StatusForbidden, wGet.Code, "GET 应放行")
|
||||
} else {
|
||||
assert.Equal(t, http.StatusForbidden, wGet.Code, "GET 应被拦截")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -54,14 +54,27 @@ func JWT(db *gorm.DB) gin.HandlerFunc {
|
||||
// 会话校验:带 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 {
|
||||
// 一次查询同时取会话 + 用户启用状态:兜底「直接改库 is_active=0」也能即时下线。
|
||||
var row struct {
|
||||
model.UserSession
|
||||
IsActive bool
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
err := db.Table("user_sessions AS s").
|
||||
Select("s.*, u.is_active AS is_active, u.deleted_at AS deleted_at").
|
||||
Joins("LEFT JOIN users u ON u.id = s.user_id").
|
||||
Where("s.sid = ?", claims.SID).First(&row).Error
|
||||
if err != nil || row.UserSession.RevokedAt != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session revoked", "code": "SESSION_REVOKED"})
|
||||
return
|
||||
}
|
||||
if !row.IsActive || row.DeletedAt != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "user disabled", "code": "USER_DISABLED"})
|
||||
return
|
||||
}
|
||||
// 节流刷新 last_seen,用于在线状态判定
|
||||
if time.Since(sess.LastSeenAt) > lastSeenThrottle {
|
||||
db.Model(&model.UserSession{}).Where("id = ?", sess.ID).
|
||||
if time.Since(row.UserSession.LastSeenAt) > lastSeenThrottle {
|
||||
db.Model(&model.UserSession{}).Where("id = ?", row.UserSession.ID).
|
||||
Update("last_seen_at", time.Now())
|
||||
}
|
||||
}
|
||||
@@ -106,7 +119,11 @@ func ReadOnly() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
role, _ := c.Get(CtxRole)
|
||||
if role == "readonly" && c.Request.Method != "GET" {
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "readonly user"})
|
||||
// code 供前端区分「角色只读」与「授权过期」(后者由 LicenseGuard 返回 phase)
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
"error": "readonly user",
|
||||
"code": "READONLY_USER",
|
||||
})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
|
||||
@@ -2,9 +2,13 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -38,7 +42,8 @@ func CalcLicensePhase(expiresAt *time.Time) string {
|
||||
return PhaseLocked
|
||||
}
|
||||
|
||||
// GetLicensePhase returns the current phase for the authenticated request.
|
||||
// GetLicensePhase returns the phase derived from the JWT claim (login-time snapshot).
|
||||
// Kept for display/diagnostic use; enforcement uses the live DB phase (see LicenseGuard).
|
||||
func GetLicensePhase(c *gin.Context) string {
|
||||
v, _ := c.Get(CtxLicenseExpiresAt)
|
||||
ptr, _ := v.(*int64)
|
||||
@@ -49,12 +54,76 @@ func GetLicensePhase(c *gin.Context) string {
|
||||
return CalcLicensePhase(&t)
|
||||
}
|
||||
|
||||
// licensePhaseCacheTTL 控制实时 phase 查库的缓存时长:
|
||||
// 既避免每个写请求都查库,又保证 DB 改动(续费/过期/被改)在 TTL 内生效。
|
||||
// 与前端心跳(30s)同量级,用户感知一致。
|
||||
const licensePhaseCacheTTL = 30 * time.Second
|
||||
|
||||
type licensePhaseEntry struct {
|
||||
expiresAt *time.Time // nil = 永久授权 / 无有效授权(按 normal 处理见下)
|
||||
hasActive bool // 是否存在有效授权记录
|
||||
revoked bool // 曾配置授权但当前全部被停用/吊销(is_active=0)→ 锁定写操作
|
||||
fetchedAt time.Time
|
||||
}
|
||||
|
||||
var licensePhaseCache sync.Map // shopID(uint64) -> licensePhaseEntry
|
||||
|
||||
// InvalidateLicensePhase 清除某店的 phase 缓存,使授权变更(激活/续费/停用)即时生效,
|
||||
// 不必等 30s TTL 自然过期。授权服务在激活/签发后调用。
|
||||
func InvalidateLicensePhase(shopID uint64) {
|
||||
licensePhaseCache.Delete(shopID)
|
||||
}
|
||||
|
||||
// liveLicensePhase 按当前 DB 的有效授权实时计算 phase(带 30s 每店缓存)。
|
||||
// - 有有效授权:按其 expires_at 计算 phase。
|
||||
// - 曾有授权但当前全部被停用(is_active=0):视为被吊销 → 锁定(管理员主动收回授权即时生效)。
|
||||
// - 从未配置任何授权(如未启用授权体系/试用签发失败):回退到 token 中的 lic_exp 快照,避免误锁。
|
||||
func liveLicensePhase(db *gorm.DB, c *gin.Context) string {
|
||||
shopID := GetShopID(c)
|
||||
|
||||
var entry licensePhaseEntry
|
||||
if v, ok := licensePhaseCache.Load(shopID); ok {
|
||||
entry = v.(licensePhaseEntry)
|
||||
}
|
||||
if entry.fetchedAt.IsZero() || time.Since(entry.fetchedAt) > licensePhaseCacheTTL {
|
||||
var lic model.License
|
||||
// 必须与 LicenseService.ShopInfo(/license/info 展示)一致:
|
||||
// 取最新创建(id DESC)的有效授权,否则展示与拦截可能选到不同记录,
|
||||
// 出现「展示已过期但仍可写」。
|
||||
err := db.Where("shop_id = ? AND is_active = ?", shopID, true).
|
||||
Order("id DESC").First(&lic).Error
|
||||
if err == nil {
|
||||
entry = licensePhaseEntry{expiresAt: lic.ExpiresAt, hasActive: true, fetchedAt: time.Now()}
|
||||
} else {
|
||||
// 无有效授权:区分「曾配置过但被停用/吊销」与「从未配置」。
|
||||
// 过期授权 is_active 仍为 1(走上面分支按 phase 降级),故此处无 active 记录
|
||||
// 只可能是管理员主动停用(is_active=0)或确实未配置。
|
||||
var anyCount int64
|
||||
db.Model(&model.License{}).Where("shop_id = ?", shopID).Count(&anyCount)
|
||||
entry = licensePhaseEntry{hasActive: false, revoked: anyCount > 0, fetchedAt: time.Now()}
|
||||
}
|
||||
licensePhaseCache.Store(shopID, entry)
|
||||
}
|
||||
|
||||
if entry.revoked {
|
||||
// 曾有授权但当前全部被停用 → 收回写权限,与「锁定」一致。
|
||||
return PhaseLocked
|
||||
}
|
||||
if !entry.hasActive {
|
||||
// 从未配置有效授权:回退到登录时 token 里的快照,避免误判
|
||||
return GetLicensePhase(c)
|
||||
}
|
||||
return CalcLicensePhase(entry.expiresAt)
|
||||
}
|
||||
|
||||
// LicenseGuard blocks write operations when the shop's license is expired (readonly/locked).
|
||||
// 以**当前 DB** 的授权状态实时判定 phase(带 30s 缓存),而非信任登录时嵌入 token 的快照,
|
||||
// 这样续费/过期/被改动后无需重登即可在 ~30s 内对写操作生效。
|
||||
// License routes (/license/*) must be mounted outside this middleware so users can
|
||||
// view status and activate a new key even when locked.
|
||||
func LicenseGuard() gin.HandlerFunc {
|
||||
func LicenseGuard(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
phase := GetLicensePhase(c)
|
||||
phase := liveLicensePhase(db, c)
|
||||
switch phase {
|
||||
case PhaseLocked:
|
||||
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCalcLicensePhase(t *testing.T) {
|
||||
@@ -37,3 +45,135 @@ func TestCalcLicensePhase(t *testing.T) {
|
||||
locked := now.Add(-16 * 24 * time.Hour)
|
||||
assert.Equal(t, PhaseLocked, CalcLicensePhase(&locked))
|
||||
}
|
||||
|
||||
// TestLicenseGuardUsesLiveDBPhase 验证 LicenseGuard 以「当前 DB 的授权状态」判 phase,
|
||||
// 而非信任登录时嵌入 token 的快照(lic_exp)。这是「只读模式还是能改数据」的根因修复:
|
||||
// 即便 token 快照仍是 normal(lic_exp 未来),DB 改成过期后写操作也应立即被拦截。
|
||||
func TestLicenseGuardUsesLiveDBPhase(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
// sqlite 不支持 enum/json 列类型,AutoMigrate 会失败,按 testutil 方式用原始 SQL 建表。
|
||||
assert.NoError(t, db.Exec(`CREATE TABLE licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME, updated_at DATETIME, deleted_at DATETIME,
|
||||
shop_id INTEGER NOT NULL,
|
||||
license_key TEXT, type TEXT, expires_at DATETIME,
|
||||
is_active INTEGER DEFAULT 1, max_devices INTEGER DEFAULT 3,
|
||||
features TEXT, device_id TEXT, activated_at DATETIME
|
||||
)`).Error)
|
||||
now := time.Now()
|
||||
tokenFuture := now.Add(30 * 24 * time.Hour).Unix() // token 快照恒为 normal
|
||||
|
||||
// 每个 shop 一条有效授权,DB 到期时间各异;用不同 shopID 规避缓存串扰。
|
||||
seed := func(shopID uint64, dbExpiresAt *time.Time) {
|
||||
licensePhaseCache.Delete(shopID)
|
||||
assert.NoError(t, db.Create(&model.License{
|
||||
ShopID: shopID, IsActive: true, ExpiresAt: dbExpiresAt,
|
||||
}).Error)
|
||||
}
|
||||
|
||||
// invoke 跑一次带 LicenseGuard 的请求,返回(是否被拦截, 状态码)。
|
||||
invoke := func(shopID uint64, method string) (bool, int) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, "/products", nil)
|
||||
c.Set(CtxShopID, shopID)
|
||||
c.Set(CtxLicenseExpiresAt, &tokenFuture)
|
||||
LicenseGuard(db)(c)
|
||||
return c.IsAborted(), w.Code
|
||||
}
|
||||
|
||||
// DB 未过期 → 写放行
|
||||
future := now.Add(10 * 24 * time.Hour)
|
||||
seed(1001, &future)
|
||||
aborted, _ := invoke(1001, http.MethodPost)
|
||||
assert.False(t, aborted, "DB 未过期应放行写操作")
|
||||
|
||||
// DB 过期 10 天(只读期)→ 即便 token 说 normal,POST 也被 403 拦截
|
||||
expired10d := now.Add(-10 * 24 * time.Hour)
|
||||
seed(1002, &expired10d)
|
||||
aborted, code := invoke(1002, http.MethodPost)
|
||||
assert.True(t, aborted, "DB 只读期应拦截写操作")
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
// 只读期 GET 放行
|
||||
aborted, _ = invoke(1002, http.MethodGet)
|
||||
assert.False(t, aborted, "只读期 GET 应放行")
|
||||
|
||||
// DB 过期 20 天(锁定期)→ 连 GET 也 403
|
||||
expired20d := now.Add(-20 * 24 * time.Hour)
|
||||
seed(1003, &expired20d)
|
||||
aborted, code = invoke(1003, http.MethodGet)
|
||||
assert.True(t, aborted, "锁定期应拦截所有请求")
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
}
|
||||
|
||||
// TestLicenseGuardRevokedAndInvalidation 覆盖 #5/#6:
|
||||
// - 主动停用(is_active=0,且未过期)应锁定写操作,而非回退到 token 快照继续放行;
|
||||
// - 从未配置授权的门店仍回退 token 快照,避免误锁;
|
||||
// - InvalidateLicensePhase 让授权变更绕过 30s 缓存即时生效。
|
||||
func TestLicenseGuardRevokedAndInvalidation(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Silent),
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
assert.NoError(t, db.Exec(`CREATE TABLE licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME, updated_at DATETIME, deleted_at DATETIME,
|
||||
shop_id INTEGER NOT NULL,
|
||||
license_key TEXT, type TEXT, expires_at DATETIME,
|
||||
is_active INTEGER DEFAULT 1, max_devices INTEGER DEFAULT 3,
|
||||
features TEXT, device_id TEXT, activated_at DATETIME
|
||||
)`).Error)
|
||||
|
||||
now := time.Now()
|
||||
tokenFuture := now.Add(30 * 24 * time.Hour).Unix() // token 快照恒为 normal
|
||||
|
||||
invoke := func(shopID uint64, method string) (bool, int) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, "/products", nil)
|
||||
c.Set(CtxShopID, shopID)
|
||||
c.Set(CtxLicenseExpiresAt, &tokenFuture)
|
||||
LicenseGuard(db)(c)
|
||||
return c.IsAborted(), w.Code
|
||||
}
|
||||
|
||||
// #6 主动停用:未过期但 is_active=0 → 即便 token 说 normal,写/读都应被锁定。
|
||||
// 注意 GORM bool 零值陷阱:Create 时 IsActive:false 会被列默认值 1 覆盖,
|
||||
// 生产中停用也总是经 Update 落地,故这里同样建后再 Update。
|
||||
future := now.Add(10 * 24 * time.Hour)
|
||||
InvalidateLicensePhase(2001)
|
||||
lic2001 := model.License{ShopID: 2001, IsActive: true, ExpiresAt: &future}
|
||||
assert.NoError(t, db.Create(&lic2001).Error)
|
||||
assert.NoError(t, db.Model(&model.License{}).Where("id = ?", lic2001.ID).Update("is_active", false).Error)
|
||||
aborted, code := invoke(2001, http.MethodGet)
|
||||
assert.True(t, aborted, "被停用授权应锁定(连 GET 也拦)")
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
|
||||
// #6 从未配置授权:无任何 license 行 → 回退 token 快照(normal)→ 放行,避免误锁。
|
||||
InvalidateLicensePhase(2002)
|
||||
aborted, _ = invoke(2002, http.MethodPost)
|
||||
assert.False(t, aborted, "未配置授权的门店应回退 token 快照放行")
|
||||
|
||||
// #5 缓存失效:先有有效授权(缓存为 normal 放行),再停用并 Invalidate → 立即锁定。
|
||||
InvalidateLicensePhase(2003)
|
||||
lic := model.License{ShopID: 2003, IsActive: true, ExpiresAt: &future}
|
||||
assert.NoError(t, db.Create(&lic).Error)
|
||||
aborted, _ = invoke(2003, http.MethodPost) // 写入缓存 normal
|
||||
assert.False(t, aborted, "有效授权应放行写操作")
|
||||
|
||||
assert.NoError(t, db.Model(&model.License{}).Where("id = ?", lic.ID).Update("is_active", false).Error)
|
||||
// 不失效缓存:30s 内仍按旧 normal 放行
|
||||
aborted, _ = invoke(2003, http.MethodPost)
|
||||
assert.False(t, aborted, "未失效缓存时停用应仍受 30s 缓存保护")
|
||||
// 失效缓存后:重新查库 → 锁定即时生效
|
||||
InvalidateLicensePhase(2003)
|
||||
aborted, code = invoke(2003, http.MethodPost)
|
||||
assert.True(t, aborted, "Invalidate 后停用应即时生效")
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestReadOnly 验证只读角色的写操作被拦截、读操作放行,且 403 带机器可读 code。
|
||||
func TestReadOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
// invoke 跑一次带 ReadOnly 的请求,返回(是否被拦截, 状态码, body)。
|
||||
invoke := func(role, method string) (bool, int, map[string]any) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(method, "/products", nil)
|
||||
c.Set(CtxRole, role)
|
||||
ReadOnly()(c)
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||||
return c.IsAborted(), w.Code, body
|
||||
}
|
||||
|
||||
// 只读角色 + 写方法 → 403 + code=READONLY_USER
|
||||
for _, m := range []string{http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodPatch} {
|
||||
aborted, code, body := invoke("readonly", m)
|
||||
assert.True(t, aborted, "readonly 用户 %s 应被拦截", m)
|
||||
assert.Equal(t, http.StatusForbidden, code)
|
||||
assert.Equal(t, "READONLY_USER", body["code"], "%s 应返回 code=READONLY_USER", m)
|
||||
}
|
||||
|
||||
// 只读角色 + GET → 放行
|
||||
aborted, _, _ := invoke("readonly", http.MethodGet)
|
||||
assert.False(t, aborted, "readonly 用户 GET 应放行")
|
||||
|
||||
// 非只读角色 + 写方法 → 放行
|
||||
for _, role := range []string{"operator", "admin", "superadmin"} {
|
||||
aborted, _, _ := invoke(role, http.MethodPost)
|
||||
assert.False(t, aborted, "%s 用户写操作应放行", role)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// LoginAttempt 登录尝试审计。当前只记录失败尝试(成功登录已由 user_sessions
|
||||
// + users.last_login_at 覆盖),用于风控/审计排查异常登录。由保留期清理任务定期删除旧行。
|
||||
type LoginAttempt struct {
|
||||
ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ShopCode string `gorm:"size:64;index:idx_attempt_user" json:"shop_code"`
|
||||
Username string `gorm:"size:50;index:idx_attempt_user" json:"username"`
|
||||
IP string `gorm:"size:64;index:idx_attempt_ip" json:"ip"`
|
||||
UserAgent string `gorm:"size:512" json:"user_agent"`
|
||||
Success bool `gorm:"default:false" json:"success"`
|
||||
Reason string `gorm:"size:40" json:"reason"` // invalid_shop|invalid_user|bad_password|locked|inactive|platform_not_allowed
|
||||
CreatedAt time.Time `gorm:"autoCreateTime;index:idx_attempt_user;index:idx_attempt_ip" json:"created_at"`
|
||||
}
|
||||
|
||||
func (LoginAttempt) TableName() string { return "login_attempts" }
|
||||
@@ -15,10 +15,17 @@ type UserSession struct {
|
||||
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"`
|
||||
// RefreshJTI 当前有效 refresh token 的 jti,用于「轮换 + 重用检测」:
|
||||
// 每次续期轮换此值,若 refresh 携带的 jti 与之不符即判定为旧 token 重放(盗用),吊销整条会话。
|
||||
RefreshJTI string `gorm:"column:refresh_jti;size:64" json:"-"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
// LastSeenAt 手工维护(Login 建行赋值、心跳/refresh 显式 Update)。
|
||||
// 故意用 autoCreateTime(建行给默认、之后不被 ORM 自动改);切勿改成 autoUpdateTime,
|
||||
// 否则 revoke/cleanup 等任意 Updates 都会把已撤销会话误刷成「刚活跃」。
|
||||
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
|
||||
RevokedReason string `gorm:"size:30" json:"revoked_reason,omitempty"` // kicked|logout|admin|disabled|reuse|pwd_reset
|
||||
RevokedBy *uint64 `gorm:"column:revoked_by" json:"revoked_by,omitempty"` // 吊销操作人 user_id;系统/自助吊销为 NULL
|
||||
RefreshExpAt time.Time `json:"refresh_exp_at"`
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
stockSvc := service.NewStockService(db)
|
||||
|
||||
// 处理器
|
||||
authH := handler.NewAuthHandler(authSvc)
|
||||
authH := handler.NewAuthHandler(authSvc, licenseSvc)
|
||||
sessionH := handler.NewSessionHandler(authSvc)
|
||||
licenseH := handler.NewLicenseHandler(licenseSvc)
|
||||
productH := handler.NewProductHandler(db)
|
||||
@@ -95,7 +95,7 @@ func Setup(r *gin.Engine, db *gorm.DB) {
|
||||
|
||||
// 业务路由:ReadOnly + LicenseGuard(过期只读/锁定拦截写操作)
|
||||
{
|
||||
api.Use(middleware.ReadOnly(), middleware.LicenseGuard())
|
||||
api.Use(middleware.ReadOnly(), middleware.LicenseGuard(db))
|
||||
|
||||
// 商品
|
||||
products := api.Group("/products")
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -23,6 +24,10 @@ var (
|
||||
ErrPlatformNotAllowed = errors.New("该平台不允许登录")
|
||||
ErrTooManyAttempts = errors.New("登录失败次数过多,账号已临时锁定,请稍后再试")
|
||||
ErrSessionRevoked = errors.New("session revoked")
|
||||
|
||||
// errRefreshReuse 内部哨兵:在续期事务内检测到 refresh token 重用,
|
||||
// 用于让调用方在事务回滚后于事务外提交「吊销整条会话」。
|
||||
errRefreshReuse = errors.New("refresh token reuse detected")
|
||||
)
|
||||
|
||||
// DeviceInfo 登录请求携带的设备信息,用于会话记录与按平台限并发。
|
||||
@@ -98,12 +103,14 @@ type TokenPair struct {
|
||||
func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo) (*TokenPair, *model.User, error) {
|
||||
limiterKey := shopCode + "|" + username
|
||||
if loginLim.locked(limiterKey) {
|
||||
s.recordLoginAttempt(shopCode, username, dev, false, "locked")
|
||||
return nil, nil, ErrTooManyAttempts
|
||||
}
|
||||
|
||||
var shop model.Shop
|
||||
if err := s.db.Where("code = ?", shopCode).First(&shop).Error; err != nil {
|
||||
loginLim.recordFailure(limiterKey)
|
||||
s.recordLoginAttempt(shopCode, username, dev, false, "invalid_shop")
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -111,15 +118,18 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
||||
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)
|
||||
s.recordLoginAttempt(shopCode, username, dev, false, "invalid_user")
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
if !user.IsActive {
|
||||
s.recordLoginAttempt(shopCode, username, dev, false, "inactive")
|
||||
return nil, nil, ErrUserInactive
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
|
||||
loginLim.recordFailure(limiterKey)
|
||||
s.recordLoginAttempt(shopCode, username, dev, false, "bad_password")
|
||||
return nil, nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
@@ -127,14 +137,21 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// 首次使用(门店尚无任何有效授权)自动发 30 天试用:seed/手动建店或老数据
|
||||
// 登录即转为 trial,「未激活」不再静默无限制。须在 issueTokens 之前,使 JWT
|
||||
// 的 lic_exp 带上新试用到期日。
|
||||
s.ensureTrialOnFirstUse(shop.ID)
|
||||
|
||||
// 按平台类限并发:取有效配额,0=禁止该平台,超额则踢最旧会话腾位。
|
||||
pclass := model.PlatformClass(dev.Platform)
|
||||
quota := s.effectiveQuota(&shop, pclass)
|
||||
if quota <= 0 {
|
||||
s.recordLoginAttempt(shopCode, username, dev, false, "platform_not_allowed")
|
||||
return nil, nil, ErrPlatformNotAllowed
|
||||
}
|
||||
|
||||
sid := uuid.New().String()
|
||||
jti := uuid.New().String() // 初始 refresh token jti,后续每次续期轮换
|
||||
now := time.Now()
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 统计该 user 在该 class 的活跃会话;超额踢最旧
|
||||
@@ -163,6 +180,7 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
||||
PlatformClass: pclass,
|
||||
IP: dev.IP,
|
||||
UserAgent: dev.UserAgent,
|
||||
RefreshJTI: jti,
|
||||
LastSeenAt: now,
|
||||
RefreshExpAt: now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour),
|
||||
}
|
||||
@@ -177,13 +195,28 @@ func (s *AuthService) Login(shopCode, username, password string, dev DeviceInfo)
|
||||
loginLim.reset(limiterKey)
|
||||
user.LastLoginAt = &now
|
||||
|
||||
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid)
|
||||
pair, err := s.issueTokens(user.ID, shop.ID, user.Role, sid, jti)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return pair, &user, nil
|
||||
}
|
||||
|
||||
// recordLoginAttempt 记录一次登录尝试(当前只用于失败审计),写库失败仅记日志不阻断登录。
|
||||
func (s *AuthService) recordLoginAttempt(shopCode, username string, dev DeviceInfo, success bool, reason string) {
|
||||
att := model.LoginAttempt{
|
||||
ShopCode: shopCode,
|
||||
Username: username,
|
||||
IP: dev.IP,
|
||||
UserAgent: dev.UserAgent,
|
||||
Success: success,
|
||||
Reason: reason,
|
||||
}
|
||||
if err := s.db.Create(&att).Error; err != nil {
|
||||
log.Printf("[auth] record login attempt failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// effectiveQuota 返回某店某平台类的有效并发配额:优先每店 session_policy 覆盖,否则全局默认。
|
||||
func (s *AuthService) effectiveQuota(shop *model.Shop, pclass string) int {
|
||||
def := map[string]int{
|
||||
@@ -283,11 +316,11 @@ func (s *AuthService) ListSessions(shopID uint64, currentSID string) ([]SessionV
|
||||
return views, nil
|
||||
}
|
||||
|
||||
// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。
|
||||
func (s *AuthService) ForceLogout(shopID, sessionID uint64) error {
|
||||
// ForceLogout 管理员强制下线本店某会话(按 id + shop_id 隔离)。byUserID 记入 revoked_by 供审计。
|
||||
func (s *AuthService) ForceLogout(shopID, sessionID, byUserID 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"})
|
||||
Updates(map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": "admin", "revoked_by": byUserID})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
@@ -297,6 +330,18 @@ func (s *AuthService) ForceLogout(shopID, sessionID uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevokeUserSessions 吊销某用户在本店的全部活跃会话(改密/禁用时调用)。byUserID 为操作人(记入 revoked_by)。
|
||||
// db 入参以便 handler 在不持有 AuthService 时也能复用同一语义。
|
||||
func RevokeUserSessions(db *gorm.DB, shopID, userID, byUserID uint64, reason string) error {
|
||||
updates := map[string]interface{}{"revoked_at": time.Now(), "revoked_reason": reason}
|
||||
if byUserID != 0 {
|
||||
updates["revoked_by"] = byUserID
|
||||
}
|
||||
return db.Model(&model.UserSession{}).
|
||||
Where("shop_id = ? AND user_id = ? AND revoked_at IS NULL", shopID, userID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// RegisterInput 注册新门店所需参数
|
||||
type RegisterInput struct {
|
||||
ShopName string `json:"shop_name" binding:"required"`
|
||||
@@ -386,14 +431,6 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
return nil, errors.New("invalid refresh token")
|
||||
}
|
||||
|
||||
// 带 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 {
|
||||
@@ -407,13 +444,170 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 刷新会话存活时间(同 sid 续期)。
|
||||
if claims.SID != "" {
|
||||
s.db.Model(&model.UserSession{}).Where("sid = ?", claims.SID).
|
||||
Update("last_seen_at", time.Now())
|
||||
// 带 sid 的会话:在事务内 FOR UPDATE 锁行做「校验 → 重用检测 → 轮换」(check-then-act)。
|
||||
// 无 sid 的存量 token:首刷即建立可吊销会话并签发带 sid 的新 token,使其转入受控状态
|
||||
//(自此可被强制下线/踢人/改密吊销),不再永久游离于会话治理之外。
|
||||
newJTI := uuid.New().String()
|
||||
now := time.Now()
|
||||
sid := claims.SID
|
||||
if sid == "" {
|
||||
// 存量无 sid token:复用「该用户唯一的 legacy 会话」,而非每次续期都新建一条。
|
||||
// 事务 + FOR UPDATE 锁住既有 legacy 行、串行化并发的存量续期;找不到才创建。
|
||||
// 这样重复/并发呈递同一存量 token 始终只对应一条可吊销会话(有界、纳入会话治理、
|
||||
// 可被强制下线/改密吊销),不再每刷一条无限膨胀。正常客户端首刷后即拿到带 sid 的新
|
||||
// token,自然转入受控分支,此 legacy 行随其过期由清理任务回收。
|
||||
exp := now.Add(time.Duration(config.C.JWT.RefreshExpireH) * time.Hour)
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 锁住该用户全部活跃 legacy 会话。legacy class 不在 effectiveQuota 的
|
||||
// desktop/mobile/web 映射内(会返回 0),若不在此显式约束,legacy 会话便游离于
|
||||
// 并发配额之外。这里把「每用户至多一条 legacy 会话」从 find-or-create 的隐式产物
|
||||
// 提升为显式上限:复用最早的一条(轮换 jti),其余多余 legacy 会话一并吊销。
|
||||
var sessions []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",
|
||||
user.ShopID, user.ID, "legacy").
|
||||
Order("id ASC").Find(&sessions).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sessions) > 0 {
|
||||
keep := sessions[0]
|
||||
sid = keep.SID
|
||||
if err := tx.Model(&model.UserSession{}).Where("id = ?", keep.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"refresh_jti": newJTI,
|
||||
"last_seen_at": now,
|
||||
"refresh_exp_at": exp,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sessions) > 1 {
|
||||
extraIDs := make([]uint64, 0, len(sessions)-1)
|
||||
for _, ex := range sessions[1:] {
|
||||
extraIDs = append(extraIDs, ex.ID)
|
||||
}
|
||||
if err := tx.Model(&model.UserSession{}).Where("id IN ?", extraIDs).
|
||||
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "kicked"}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
newSID := uuid.New().String()
|
||||
sess := model.UserSession{
|
||||
ShopID: user.ShopID,
|
||||
UserID: user.ID,
|
||||
SID: newSID,
|
||||
Platform: "legacy",
|
||||
PlatformClass: "legacy",
|
||||
RefreshJTI: newJTI,
|
||||
LastSeenAt: now,
|
||||
RefreshExpAt: exp,
|
||||
}
|
||||
if err := tx.Create(&sess).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
sid = newSID
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
var reuseSessID uint64
|
||||
txErr := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var sess model.UserSession
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("sid = ?", claims.SID).First(&sess).Error; err != nil {
|
||||
return ErrSessionRevoked
|
||||
}
|
||||
if sess.RevokedAt != nil {
|
||||
return ErrSessionRevoked
|
||||
}
|
||||
// 重用检测:会话已确立 jti,但呈递的 refresh token 携带的 jti 与当前值不符 →
|
||||
// 说明这是已被轮换取代的旧 token 重放(盗用信号),吊销整条会话。
|
||||
// 向后兼容:sess.RefreshJTI=="" 为存量会话,首刷直接采纳新 jti,不报重用。
|
||||
// 注意:吊销动作放到事务外执行——此处一旦 return 非 nil 错误,事务会回滚,
|
||||
// 在事务内写吊销会被一并回滚掉。
|
||||
if sess.RefreshJTI != "" && claims.ID != sess.RefreshJTI {
|
||||
reuseSessID = sess.ID
|
||||
return errRefreshReuse
|
||||
}
|
||||
// 轮换 jti + 刷新存活时间。
|
||||
return tx.Model(&model.UserSession{}).Where("id = ?", sess.ID).
|
||||
Updates(map[string]interface{}{"refresh_jti": newJTI, "last_seen_at": now}).Error
|
||||
})
|
||||
if errors.Is(txErr, errRefreshReuse) {
|
||||
// 事务外提交吊销(不随回滚丢失),吊销整条会话。写库失败必须记日志——
|
||||
// 否则盗用信号被静默吞掉,被取代的旧 token 仍可继续续期,功能形同虚设。
|
||||
if err := s.db.Model(&model.UserSession{}).Where("id = ?", reuseSessID).
|
||||
Updates(map[string]interface{}{"revoked_at": now, "revoked_reason": "reuse"}).Error; err != nil {
|
||||
log.Printf("[auth] revoke reused session %d failed: %v", reuseSessID, err)
|
||||
}
|
||||
return nil, ErrSessionRevoked
|
||||
}
|
||||
if txErr != nil {
|
||||
return nil, txErr
|
||||
}
|
||||
}
|
||||
|
||||
return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID)
|
||||
// 自动续登(refresh)路径同样补发首次试用:老用户用本地 refresh token 自动登录、
|
||||
// 从不走 Login(),否则永远停留在「未激活」。须在 issueTokens 之前,使新 JWT 的
|
||||
// lic_exp 带上试用到期日。
|
||||
s.ensureTrialOnFirstUse(user.ShopID)
|
||||
|
||||
return s.issueTokens(user.ID, user.ShopID, user.Role, sid, newJTI)
|
||||
}
|
||||
|
||||
// ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial,
|
||||
// 使「未激活」门店在首次登录后即转为试用版;后续到期降级(grace/readonly/locked)链路照常生效。
|
||||
// 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。
|
||||
// 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。
|
||||
func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) {
|
||||
// 快路径:绝大多数登录/续期门店已有有效授权。先做一次无锁 Count 直接返回,
|
||||
// 避免每次都开事务锁 shop 行——该函数在每次 Login/RefreshTokens 都被调用,
|
||||
// 高频路径上的行锁会无谓串行化同店并发请求。
|
||||
var count int64
|
||||
if err := s.db.Model(&model.License{}).
|
||||
Where("shop_id = ? AND is_active = 1", shopID).Count(&count).Error; err != nil {
|
||||
log.Printf("[license] auto-trial precheck for shop %d failed: %v", shopID, err)
|
||||
return
|
||||
}
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// 慢路径(首次使用):事务内锁住门店行后**重新** Count,串行化同店并发的「首次试用」
|
||||
// 判断:无既有 license 行可锁,故锁父级 shop 行,避免两个并发请求都读到 count==0
|
||||
// 各发一条 trial(重复授权)。
|
||||
issued := false
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var shop model.Shop
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("id = ?", shopID).First(&shop).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var c int64
|
||||
if err := tx.Model(&model.License{}).
|
||||
Where("shop_id = ? AND is_active = 1", shopID).Count(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if c > 0 {
|
||||
return nil
|
||||
}
|
||||
if err := issueTrialLicense(tx, shopID); err != nil {
|
||||
return err
|
||||
}
|
||||
issued = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err)
|
||||
return
|
||||
}
|
||||
// 事务提交后再失效 phase 缓存:此刻新签发的 trial 行对其它连接已可见,
|
||||
// 不会在提交前的窗口里被旧 phase 重新填充(修复缓存失效早于提交的竞态)。
|
||||
if issued {
|
||||
middleware.InvalidateLicensePhase(shopID)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
|
||||
@@ -428,7 +622,7 @@ func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*TokenPair, error) {
|
||||
func (s *AuthService) issueTokens(userID, shopID uint64, role, sid, refreshJTI string) (*TokenPair, error) {
|
||||
cfg := config.C.JWT
|
||||
now := time.Now()
|
||||
|
||||
@@ -466,6 +660,7 @@ func (s *AuthService) issueTokens(userID, shopID uint64, role, sid string) (*Tok
|
||||
SID: sid,
|
||||
LicenseExpiresAt: licExpAt,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: refreshJTI, // jti:refresh token 轮换与重用检测的依据
|
||||
ExpiresAt: jwt.NewNumericDate(refreshExp),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
|
||||
@@ -2,10 +2,14 @@ package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/internal/util"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
@@ -25,6 +29,42 @@ func TestAuthService_Login_Success(t *testing.T) {
|
||||
assert.Equal(t, "admin", user.Username)
|
||||
}
|
||||
|
||||
func TestAuthService_Login_AutoTrialOnFirstUse(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
priv, _, err := util.GenerateEd25519KeyPair()
|
||||
require.NoError(t, err)
|
||||
config.C.License.Ed25519PrivateKey = priv
|
||||
|
||||
shop := testutil.CreateTestShop(db, "TRIAL001")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
// 前置:门店「未激活」——无任何授权行
|
||||
var before int64
|
||||
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&before)
|
||||
require.EqualValues(t, 0, before)
|
||||
|
||||
svc := NewAuthService(db)
|
||||
|
||||
// 首次登录 → 自动签发 30 天 trial
|
||||
_, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
|
||||
var lic model.License
|
||||
require.NoError(t, db.Where("shop_id = ? AND is_active = 1", shop.ID).First(&lic).Error)
|
||||
assert.Equal(t, "trial", lic.Type)
|
||||
assert.True(t, lic.IsActive)
|
||||
require.NotNil(t, lic.ExpiresAt)
|
||||
days := time.Until(*lic.ExpiresAt).Hours() / 24
|
||||
assert.InDelta(t, 30, days, 1, "试用期应约为 30 天")
|
||||
|
||||
// 再次登录不重复发放
|
||||
_, _, err = svc.Login("TRIAL001", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
var count int64
|
||||
db.Model(&model.License{}).Where("shop_id = ?", shop.ID).Count(&count)
|
||||
assert.EqualValues(t, 1, count, "已有有效授权时不应再发放 trial")
|
||||
}
|
||||
|
||||
func TestAuthService_Login_WrongPassword(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HOTEL002")
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
@@ -62,6 +63,9 @@ func (s *LicenseService) Activate(shopID uint64, licenseKey, deviceID, deviceNam
|
||||
return nil, ErrLicenseExpired
|
||||
}
|
||||
|
||||
// 激活成功即清除该店 phase 缓存:续费/换新授权码后写权限即时恢复,不必等 30s TTL。
|
||||
defer middleware.InvalidateLicensePhase(shopID)
|
||||
|
||||
var existing model.LicenseDevice
|
||||
err := s.db.Where("license_id = ? AND device_id = ?", lic.ID, deviceID).First(&existing).Error
|
||||
if err == nil {
|
||||
@@ -116,11 +120,52 @@ func (s *LicenseService) ShopInfo(shopID uint64) (*model.License, error) {
|
||||
var lic model.License
|
||||
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).
|
||||
Order("id DESC").First(&lic).Error; err != nil {
|
||||
return nil, ErrLicenseNotFound
|
||||
// 仅「确无记录」才算无授权;DB 不可达等瞬时错误必须上抛,
|
||||
// 否则会被误判为「门店无授权」,把客户端横幅/门禁错误降级。
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrLicenseNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &lic, nil
|
||||
}
|
||||
|
||||
// LicenseInfoView 门店授权概况(含设备数与实时 phase),供 /license/info 与心跳 /auth/ping 复用,
|
||||
// 保证两条路径返回结构一致。
|
||||
type LicenseInfoView struct {
|
||||
ID uint64 `json:"id"`
|
||||
Type string `json:"type"`
|
||||
IsActive bool `json:"is_active"`
|
||||
MaxDevices int `json:"max_devices"`
|
||||
DeviceCount int64 `json:"device_count"`
|
||||
ExpiresAt *time.Time `json:"expires_at"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
|
||||
// ShopInfoView 返回门店授权概况;无有效授权时返回 (nil, nil),仅在统计设备数等查询出错时返回 error。
|
||||
func (s *LicenseService) ShopInfoView(shopID uint64) (*LicenseInfoView, error) {
|
||||
lic, err := s.ShopInfo(shopID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrLicenseNotFound) {
|
||||
return nil, nil // 确无有效授权
|
||||
}
|
||||
return nil, err // 瞬时错误上抛:Ping 据此省略 license 字段,客户端保留上次状态
|
||||
}
|
||||
count, err := s.CountDevices(lic.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LicenseInfoView{
|
||||
ID: lic.ID,
|
||||
Type: lic.Type,
|
||||
IsActive: lic.IsActive,
|
||||
MaxDevices: lic.MaxDevices,
|
||||
DeviceCount: count,
|
||||
ExpiresAt: lic.ExpiresAt,
|
||||
Phase: middleware.CalcLicensePhase(lic.ExpiresAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CountDevices 返回指定 license 下已绑定设备数。
|
||||
func (s *LicenseService) CountDevices(licenseID uint64) (int64, error) {
|
||||
var count int64
|
||||
@@ -143,12 +188,13 @@ func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
|
||||
Delete(&model.LicenseDevice{}).Error
|
||||
}
|
||||
|
||||
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
||||
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。
|
||||
func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
// issueTrialLicense 为门店签发并写入一条 30 天 trial license。
|
||||
// 私钥未配置或签发/落库失败时返回 error,由调用方决定如何处理(注册路径 Fatal、
|
||||
// 登录路径仅记日志)。
|
||||
func issueTrialLicense(db *gorm.DB, shopID uint64) error {
|
||||
privKey := config.C.License.Ed25519PrivateKey
|
||||
if privKey == "" {
|
||||
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
|
||||
return fmt.Errorf("ed25519 private key not configured")
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(30 * 24 * time.Hour)
|
||||
@@ -162,8 +208,7 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
}
|
||||
token, err := util.IssueLicenseToken(payload, privKey)
|
||||
if err != nil {
|
||||
log.Printf("[license] failed to issue trial token for shop %d: %v", shopID, err)
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
lic := model.License{
|
||||
@@ -174,7 +219,22 @@ func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
IsActive: true,
|
||||
MaxDevices: 1,
|
||||
}
|
||||
if err := tx.Create(&lic).Error; err != nil {
|
||||
if err := db.Create(&lic).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注意:phase 缓存失效不在此处做——本函数运行在调用方事务内,提交前失效会留下
|
||||
// 30s 窗口:并发请求可能在新 license 行可见前用旧 phase 重新填充缓存。
|
||||
// 失效改由调用方在事务提交后执行(见 ensureTrialOnFirstUse)。
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
|
||||
// 私钥未配置时 Fatal,防止生产环境静默跳过导致新注册门店无 license。
|
||||
func createTrialLicense(tx *gorm.DB, shopID uint64) {
|
||||
if config.C.License.Ed25519PrivateKey == "" {
|
||||
log.Fatalf("[license] Ed25519 private key not configured — cannot issue trial for shop %d; set License.Ed25519PrivateKey in config", shopID)
|
||||
}
|
||||
if err := issueTrialLicense(tx, shopID); err != nil {
|
||||
log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
)
|
||||
|
||||
// sessionCleanupInterval 清理任务执行周期。
|
||||
const sessionCleanupInterval = 24 * time.Hour
|
||||
|
||||
// StartSessionCleanup 启动后台清理 goroutine:启动即跑一次,之后每 24h 跑一次。
|
||||
// 删除已撤销/已过期的会话行与过旧的失败登录记录,防止表无限膨胀、IP/UA 长期滞留。
|
||||
// retentionDays<=0 时视为关闭清理(直接返回,不启动 goroutine)。
|
||||
func StartSessionCleanup(db *gorm.DB, retentionDays int) {
|
||||
if retentionDays <= 0 {
|
||||
log.Printf("[cleanup] session cleanup disabled (retention_days=%d)", retentionDays)
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
cleanupOnce(db, retentionDays)
|
||||
ticker := time.NewTicker(sessionCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
cleanupOnce(db, retentionDays)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// cleanupOnce 执行一轮清理,返回各表删除行数(供测试断言)。
|
||||
func cleanupOnce(db *gorm.DB, retentionDays int) (sessions, attempts int64) {
|
||||
cutoff := time.Now().AddDate(0, 0, -retentionDays)
|
||||
|
||||
r1 := db.Where(
|
||||
"(revoked_at IS NOT NULL AND revoked_at < ?) OR (refresh_exp_at IS NOT NULL AND refresh_exp_at < ?)",
|
||||
cutoff, cutoff,
|
||||
).Delete(&model.UserSession{})
|
||||
if r1.Error != nil {
|
||||
log.Printf("[cleanup] purge user_sessions failed: %v", r1.Error)
|
||||
}
|
||||
|
||||
r2 := db.Where("created_at < ?", cutoff).Delete(&model.LoginAttempt{})
|
||||
if r2.Error != nil {
|
||||
log.Printf("[cleanup] purge login_attempts failed: %v", r2.Error)
|
||||
}
|
||||
|
||||
if r1.RowsAffected > 0 || r2.RowsAffected > 0 {
|
||||
log.Printf("[cleanup] purged %d sessions, %d login_attempts (older than %dd)",
|
||||
r1.RowsAffected, r2.RowsAffected, retentionDays)
|
||||
}
|
||||
return r1.RowsAffected, r2.RowsAffected
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/middleware"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/testutil"
|
||||
)
|
||||
|
||||
// parseRefreshClaims 解析 refresh token 的 claims。
|
||||
func parseRefreshClaims(t *testing.T, token string) *middleware.Claims {
|
||||
t.Helper()
|
||||
claims := &middleware.Claims{}
|
||||
_, err := jwt.ParseWithClaims(token, claims, func(*jwt.Token) (interface{}, error) {
|
||||
return []byte(config.C.JWT.Secret), nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return claims
|
||||
}
|
||||
|
||||
// parseRefreshJTI 取出 refresh token 的 jti(RegisteredClaims.ID)。
|
||||
func parseRefreshJTI(t *testing.T, token string) string {
|
||||
return parseRefreshClaims(t, token).ID
|
||||
}
|
||||
|
||||
// signLegacyRefresh 签一个不带 jti 的 refresh token(模拟发版前的存量 token)。
|
||||
func signLegacyRefresh(t *testing.T, userID, shopID uint64, role, sid string) string {
|
||||
t.Helper()
|
||||
now := time.Now()
|
||||
claims := middleware.Claims{
|
||||
UserID: userID, ShopID: shopID, Role: role, SID: sid,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
}
|
||||
s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(config.C.JWT.Secret))
|
||||
require.NoError(t, err)
|
||||
return s
|
||||
}
|
||||
|
||||
// #1 续期轮换 jti;旧 refresh token 重放 → 判定盗用 → 吊销整条会话。
|
||||
func TestRefreshTokens_RotationAndReuseDetection(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD01")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
pair1, _, err := svc.Login("HARD01", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
jti0 := parseRefreshJTI(t, pair1.RefreshToken)
|
||||
require.NotEmpty(t, jti0)
|
||||
|
||||
// 首次续期成功,jti 轮换。
|
||||
pair2, err := svc.RefreshTokens(pair1.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
jti1 := parseRefreshJTI(t, pair2.RefreshToken)
|
||||
assert.NotEqual(t, jti0, jti1, "续期应轮换 jti")
|
||||
|
||||
// 重放已被取代的旧 refresh token → 盗用信号 → ErrSessionRevoked。
|
||||
_, err = svc.RefreshTokens(pair1.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrSessionRevoked)
|
||||
|
||||
// 整条会话被吊销,reason=reuse。
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ?", user.ID).First(&sess).Error)
|
||||
assert.NotNil(t, sess.RevokedAt)
|
||||
assert.Equal(t, "reuse", sess.RevokedReason)
|
||||
|
||||
// 即便是「最新」的 refresh token,此后也无法再续期(family 已撤销)。
|
||||
_, err = svc.RefreshTokens(pair2.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrSessionRevoked)
|
||||
}
|
||||
|
||||
// #1 向后兼容:存量会话(refresh_jti 为空)+ 不带 jti 的旧 refresh token,首刷应放行并采纳新 jti。
|
||||
func TestRefreshTokens_LegacyTokenBackwardCompat(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD02")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
sid := uuid.New().String()
|
||||
now := time.Now()
|
||||
require.NoError(t, db.Create(&model.UserSession{
|
||||
ShopID: shop.ID, UserID: user.ID, SID: sid,
|
||||
Platform: "windows", PlatformClass: "desktop",
|
||||
RefreshJTI: "", // 存量会话无 jti
|
||||
LastSeenAt: now,
|
||||
RefreshExpAt: now.Add(time.Hour),
|
||||
}).Error)
|
||||
|
||||
legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", sid)
|
||||
pair, err := svc.RefreshTokens(legacy)
|
||||
require.NoError(t, err, "存量 token 首刷应放行")
|
||||
|
||||
// 采纳新 jti 写回会话。
|
||||
newJTI := parseRefreshJTI(t, pair.RefreshToken)
|
||||
assert.NotEmpty(t, newJTI)
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("sid = ?", sid).First(&sess).Error)
|
||||
assert.Equal(t, newJTI, sess.RefreshJTI)
|
||||
assert.Nil(t, sess.RevokedAt)
|
||||
}
|
||||
|
||||
// #4 无 sid 的存量 token 首刷应自建可吊销会话,从此纳入会话治理(可被强制下线)。
|
||||
func TestRefreshTokens_LegacyNoSidAdoptsSession(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD08")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
// SID 为空的存量 refresh token(发版前签发,从无会话行)。
|
||||
legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", "")
|
||||
pair, err := svc.RefreshTokens(legacy)
|
||||
require.NoError(t, err, "无 sid 存量 token 首刷应放行")
|
||||
|
||||
// 新 token 带上自建的 sid,且已落库一条会话。
|
||||
newSID := parseRefreshClaims(t, pair.RefreshToken).SID
|
||||
require.NotEmpty(t, newSID, "首刷应签发带 sid 的新 token")
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("user_id = ? AND sid = ?", user.ID, newSID).First(&sess).Error)
|
||||
assert.Equal(t, "legacy", sess.PlatformClass)
|
||||
assert.Nil(t, sess.RevokedAt)
|
||||
|
||||
// 自此可被治理:管理员强制下线后,新 token 无法再续期。
|
||||
views, _ := svc.ListSessions(shop.ID, "")
|
||||
require.Len(t, views, 1)
|
||||
require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID, 0))
|
||||
_, err = svc.RefreshTokens(pair.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrSessionRevoked)
|
||||
}
|
||||
|
||||
// #1 同一无 sid 存量 token 重复续期,应复用唯一 legacy 会话而非每次新建(防无界膨胀 + 配额规避)。
|
||||
func TestRefreshTokens_LegacyNoSidReusesSingleSession(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD09")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", "")
|
||||
|
||||
// 反复呈递「同一」无 sid 存量 token(模拟未采纳新 token 的客户端/重放)。
|
||||
var firstSID string
|
||||
for i := 0; i < 5; i++ {
|
||||
pair, err := svc.RefreshTokens(legacy)
|
||||
require.NoError(t, err, "存量 token 续期应放行")
|
||||
sid := parseRefreshClaims(t, pair.RefreshToken).SID
|
||||
require.NotEmpty(t, sid)
|
||||
if i == 0 {
|
||||
firstSID = sid
|
||||
} else {
|
||||
assert.Equal(t, firstSID, sid, "重复续期应复用同一 legacy 会话的 sid")
|
||||
}
|
||||
}
|
||||
|
||||
// 始终只有一条 legacy 会话,而非 5 条。
|
||||
var count int64
|
||||
require.NoError(t, db.Model(&model.UserSession{}).
|
||||
Where("shop_id = ? AND user_id = ? AND platform_class = ?", shop.ID, user.ID, "legacy").
|
||||
Count(&count).Error)
|
||||
assert.EqualValues(t, 1, count, "重复存量续期不应无界新建会话")
|
||||
}
|
||||
|
||||
// #4 预存多条活跃 legacy 会话(legacy class 不在并发配额内)时,一次无 sid 续期应把它们
|
||||
// 收敛为一条:复用最早的一条、吊销其余,使「每用户至多一条 legacy 会话」成为显式强制的上限。
|
||||
func TestRefreshTokens_LegacyNoSidCollapsesExtraSessions(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD10")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
now := time.Now()
|
||||
sids := []string{uuid.New().String(), uuid.New().String(), uuid.New().String()}
|
||||
for _, sid := range sids {
|
||||
require.NoError(t, db.Create(&model.UserSession{
|
||||
ShopID: shop.ID, UserID: user.ID, SID: sid,
|
||||
Platform: "legacy", PlatformClass: "legacy",
|
||||
RefreshJTI: "", LastSeenAt: now, RefreshExpAt: now.Add(time.Hour),
|
||||
}).Error)
|
||||
}
|
||||
|
||||
legacy := signLegacyRefresh(t, user.ID, shop.ID, "admin", "")
|
||||
pair, err := svc.RefreshTokens(legacy)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 复用最早创建(id 最小)的那条会话。
|
||||
keptSID := parseRefreshClaims(t, pair.RefreshToken).SID
|
||||
assert.Equal(t, sids[0], keptSID, "应复用最早的一条 legacy 会话")
|
||||
|
||||
// 仅剩一条活跃 legacy 会话,其余被吊销(reason=kicked)。
|
||||
var active int64
|
||||
require.NoError(t, db.Model(&model.UserSession{}).
|
||||
Where("shop_id = ? AND user_id = ? AND platform_class = ? AND revoked_at IS NULL",
|
||||
shop.ID, user.ID, "legacy").Count(&active).Error)
|
||||
assert.EqualValues(t, 1, active, "多余 legacy 会话应被收敛为一条")
|
||||
|
||||
var revoked int64
|
||||
require.NoError(t, db.Model(&model.UserSession{}).
|
||||
Where("shop_id = ? AND user_id = ? AND revoked_reason = ?",
|
||||
shop.ID, user.ID, "kicked").Count(&revoked).Error)
|
||||
assert.EqualValues(t, 2, revoked, "其余两条应以 kicked 吊销")
|
||||
}
|
||||
|
||||
// #4 ForceLogout 写入 revoked_by。
|
||||
func TestForceLogout_RecordsRevokedBy(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD04")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
_, _, err := svc.Login("HARD04", "admin", "password123", DeviceInfo{Platform: "windows"})
|
||||
require.NoError(t, err)
|
||||
views, _ := svc.ListSessions(shop.ID, "")
|
||||
require.Len(t, views, 1)
|
||||
|
||||
const adminID = uint64(42)
|
||||
require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID, adminID))
|
||||
|
||||
var sess model.UserSession
|
||||
require.NoError(t, db.Where("id = ?", views[0].ID).First(&sess).Error)
|
||||
require.NotNil(t, sess.RevokedBy)
|
||||
assert.Equal(t, adminID, *sess.RevokedBy)
|
||||
assert.Equal(t, "admin", sess.RevokedReason)
|
||||
}
|
||||
|
||||
// #5 清理:删除已撤销/过期会话与过旧失败登录,保留新鲜行。
|
||||
func TestCleanupOnce_PurgesStaleRows(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD05")
|
||||
user := testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
|
||||
old := time.Now().AddDate(0, 0, -100) // 早于 90 天保留期
|
||||
fresh := time.Now()
|
||||
|
||||
revokedOld := old
|
||||
// 1) 久前撤销的会话 → 删
|
||||
require.NoError(t, db.Create(&model.UserSession{
|
||||
ShopID: shop.ID, UserID: user.ID, SID: "s-revoked-old",
|
||||
LastSeenAt: old, RefreshExpAt: fresh.Add(time.Hour), RevokedAt: &revokedOld,
|
||||
}).Error)
|
||||
// 2) refresh 久前过期的会话 → 删
|
||||
require.NoError(t, db.Create(&model.UserSession{
|
||||
ShopID: shop.ID, UserID: user.ID, SID: "s-expired-old",
|
||||
LastSeenAt: old, RefreshExpAt: old,
|
||||
}).Error)
|
||||
// 3) 新鲜活跃会话 → 保留
|
||||
require.NoError(t, db.Create(&model.UserSession{
|
||||
ShopID: shop.ID, UserID: user.ID, SID: "s-fresh",
|
||||
LastSeenAt: fresh, RefreshExpAt: fresh.Add(time.Hour),
|
||||
}).Error)
|
||||
// 失败登录:旧 → 删;新 → 留
|
||||
require.NoError(t, db.Create(&model.LoginAttempt{Username: "x", Reason: "bad_password", CreatedAt: old}).Error)
|
||||
require.NoError(t, db.Create(&model.LoginAttempt{Username: "y", Reason: "bad_password", CreatedAt: fresh}).Error)
|
||||
|
||||
sessions, attempts := cleanupOnce(db, 90)
|
||||
assert.Equal(t, int64(2), sessions)
|
||||
assert.Equal(t, int64(1), attempts)
|
||||
|
||||
var sessLeft, attLeft int64
|
||||
db.Model(&model.UserSession{}).Count(&sessLeft)
|
||||
db.Model(&model.LoginAttempt{}).Count(&attLeft)
|
||||
assert.Equal(t, int64(1), sessLeft)
|
||||
assert.Equal(t, int64(1), attLeft)
|
||||
}
|
||||
|
||||
// #7 失败登录落库(ip/ua 来自 DeviceInfo,reason 正确)。
|
||||
func TestLogin_RecordsFailedAttempt(t *testing.T) {
|
||||
db := testutil.SetupTestDB()
|
||||
shop := testutil.CreateTestShop(db, "HARD07")
|
||||
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
|
||||
svc := NewAuthService(db)
|
||||
|
||||
dev := DeviceInfo{Platform: "windows", IP: "1.2.3.4", UserAgent: "curl/8.1"}
|
||||
_, _, err := svc.Login("HARD07", "admin", "wrong-password", dev)
|
||||
require.Error(t, err)
|
||||
|
||||
var att model.LoginAttempt
|
||||
require.NoError(t, db.Where("username = ?", "admin").First(&att).Error)
|
||||
assert.False(t, att.Success)
|
||||
assert.Equal(t, "bad_password", att.Reason)
|
||||
assert.Equal(t, "1.2.3.4", att.IP)
|
||||
assert.Equal(t, "curl/8.1", att.UserAgent)
|
||||
assert.Equal(t, "HARD07", att.ShopCode)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func TestForceLogout_RevokesSession(t *testing.T) {
|
||||
require.Len(t, views, 1)
|
||||
assert.True(t, views[0].Online)
|
||||
|
||||
require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID))
|
||||
require.NoError(t, svc.ForceLogout(shop.ID, views[0].ID, 0))
|
||||
|
||||
_, err = svc.RefreshTokens(pair.RefreshToken)
|
||||
assert.ErrorIs(t, err, ErrSessionRevoked)
|
||||
@@ -148,7 +148,7 @@ func TestForceLogout_TenantIsolation(t *testing.T) {
|
||||
require.Len(t, views, 1)
|
||||
|
||||
// 用 shopB 的 shopID 尝试下线 shopA 的会话 → 找不到
|
||||
err = svc.ForceLogout(shopB.ID, views[0].ID)
|
||||
err = svc.ForceLogout(shopB.ID, views[0].ID, 0)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/wangjia/jiu/backend/config"
|
||||
"github.com/wangjia/jiu/backend/internal/model"
|
||||
"github.com/wangjia/jiu/backend/internal/router"
|
||||
"github.com/wangjia/jiu/backend/internal/service"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -37,6 +38,9 @@ func main() {
|
||||
// 自动迁移(GORM AutoMigrate 只增不删,生产安全)
|
||||
autoMigrate(db)
|
||||
|
||||
// 启动会话/失败登录保留期清理任务(后台 goroutine)
|
||||
service.StartSessionCleanup(db, config.C.Session.RetentionDays)
|
||||
|
||||
// 启动 Gin
|
||||
gin.SetMode(config.C.Server.Mode)
|
||||
r := gin.New()
|
||||
@@ -98,6 +102,7 @@ func autoMigrate(db *gorm.DB) {
|
||||
&model.License{},
|
||||
&model.LicenseDevice{},
|
||||
&model.UserSession{},
|
||||
&model.LoginAttempt{},
|
||||
&model.ProductCategory{},
|
||||
&model.Product{},
|
||||
&model.Warehouse{},
|
||||
|
||||
@@ -67,10 +67,12 @@ CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
`platform_class` VARCHAR(20) DEFAULT NULL COMMENT 'desktop|mobile|web',
|
||||
`ip` VARCHAR(64) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(512) DEFAULT NULL,
|
||||
`refresh_jti` VARCHAR(64) DEFAULT NULL COMMENT '当前有效 refresh token 的 jti,用于轮换+重用检测',
|
||||
`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',
|
||||
`revoked_reason` VARCHAR(30) DEFAULT NULL COMMENT 'kicked|logout|admin|disabled|reuse|pwd_reset',
|
||||
`revoked_by` BIGINT UNSIGNED DEFAULT NULL COMMENT '吊销操作人 user_id;系统/自助吊销为 NULL',
|
||||
`refresh_exp_at` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_session_sid` (`sid`),
|
||||
@@ -79,6 +81,23 @@ CREATE TABLE IF NOT EXISTS `user_sessions` (
|
||||
KEY `idx_session_revoked` (`revoked_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录会话';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 登录尝试审计(当前只记失败尝试,用于风控/排查;由保留期清理任务定期删旧)
|
||||
-- ------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS `login_attempts` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`shop_code` VARCHAR(64) DEFAULT NULL,
|
||||
`username` VARCHAR(50) DEFAULT NULL,
|
||||
`ip` VARCHAR(64) DEFAULT NULL,
|
||||
`user_agent` VARCHAR(512) DEFAULT NULL,
|
||||
`success` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`reason` VARCHAR(40) DEFAULT NULL COMMENT 'invalid_shop|invalid_user|bad_password|locked|inactive|platform_not_allowed',
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_attempt_user` (`username`, `created_at`),
|
||||
KEY `idx_attempt_ip` (`ip`, `created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='登录尝试审计';
|
||||
|
||||
-- ------------------------------------------------------------
|
||||
-- 许可证
|
||||
-- ------------------------------------------------------------
|
||||
|
||||
@@ -98,12 +98,24 @@ func SetupTestDB() *gorm.DB {
|
||||
platform_class TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
refresh_jti TEXT,
|
||||
created_at DATETIME,
|
||||
last_seen_at DATETIME,
|
||||
revoked_at DATETIME,
|
||||
revoked_reason TEXT,
|
||||
revoked_by INTEGER,
|
||||
refresh_exp_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS login_attempts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
shop_code TEXT,
|
||||
username TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
success INTEGER DEFAULT 0,
|
||||
reason TEXT,
|
||||
created_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS licenses (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME,
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../auth/auth_state.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../config/license_copy.dart';
|
||||
import '../errors/error_reporter.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import 'retry_interceptor.dart';
|
||||
|
||||
/// Public Dio instance for unauthenticated calls (login / refresh)
|
||||
@@ -30,8 +32,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
final client = ApiClient(
|
||||
token: user?.accessToken,
|
||||
refreshToken: user?.refreshToken,
|
||||
onTokenRefreshed: (newToken) {
|
||||
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
|
||||
onTokensRefreshed: (accessToken, refreshToken) {
|
||||
ref
|
||||
.read(authStateProvider.notifier)
|
||||
.updateAccessToken(accessToken, refreshToken);
|
||||
},
|
||||
onAuthFailed: (reason) {
|
||||
if (ref.read(authStateProvider).isLoggedIn) {
|
||||
@@ -44,6 +48,27 @@ final apiClientProvider = Provider<ApiClient>((ref) {
|
||||
onConnectionError: () {
|
||||
ref.read(connectivityProvider.notifier).forceCheck();
|
||||
},
|
||||
onForbidden: (body) {
|
||||
final map = body is Map ? body : const <String, dynamic>{};
|
||||
final code = map['code'];
|
||||
final phase = map['phase'];
|
||||
// 只读角色写被拒:直接提示(无需刷新授权)。
|
||||
if (code == 'READONLY_USER') {
|
||||
ref.read(apiMessageProvider.notifier).state =
|
||||
LicenseCopy.readonlyUserToast;
|
||||
return;
|
||||
}
|
||||
// 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。
|
||||
if (phase == 'readonly' || phase == 'locked') {
|
||||
ref.read(licenseProvider.notifier).refresh().then((_) {
|
||||
final lic = ref.read(licenseProvider).valueOrNull;
|
||||
ref.read(apiMessageProvider.notifier).state = lic != null
|
||||
? LicenseCopy.writeBlockedToast(lic)
|
||||
: '授权已过期,无法执行写操作';
|
||||
});
|
||||
}
|
||||
// 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。
|
||||
},
|
||||
);
|
||||
ref.onDispose(client.dispose);
|
||||
return client;
|
||||
@@ -53,13 +78,28 @@ class ApiClient {
|
||||
late final Dio _dio;
|
||||
bool _disposed = false;
|
||||
|
||||
/// 当前 refresh token。续期会轮换它,故必须可变并随响应更新——
|
||||
/// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。
|
||||
String? _refreshToken;
|
||||
|
||||
/// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh,
|
||||
/// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。
|
||||
Future<String?>? _refreshing;
|
||||
|
||||
void Function(String accessToken, String refreshToken)? _onTokensRefreshed;
|
||||
void Function(String? reason)? _onAuthFailed;
|
||||
|
||||
ApiClient({
|
||||
String? token,
|
||||
String? refreshToken,
|
||||
void Function(String newToken)? onTokenRefreshed,
|
||||
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
|
||||
void Function(String? reason)? onAuthFailed,
|
||||
void Function()? onConnectionError,
|
||||
void Function(dynamic body)? onForbidden,
|
||||
}) {
|
||||
_refreshToken = refreshToken;
|
||||
_onTokensRefreshed = onTokensRefreshed;
|
||||
_onAuthFailed = onAuthFailed;
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: AppConfig.apiBaseUrl,
|
||||
connectTimeout: const Duration(seconds: 8),
|
||||
@@ -98,29 +138,37 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
|
||||
// 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。
|
||||
// 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。
|
||||
if (e.response?.statusCode == 403) {
|
||||
if (!_disposed) onForbidden?.call(e.response?.data);
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
// 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态,
|
||||
// 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。
|
||||
if (e.response?.statusCode == 401 &&
|
||||
e.response?.data is Map &&
|
||||
e.response?.data['code'] == 'USER_DISABLED') {
|
||||
if (!_disposed) {
|
||||
_onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员');
|
||||
}
|
||||
return handler.next(e);
|
||||
}
|
||||
|
||||
if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
|
||||
final newToken = await _refreshAccessToken();
|
||||
if (newToken == null) {
|
||||
return handler.next(e);
|
||||
}
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': refreshToken,
|
||||
});
|
||||
final newToken = resp.data['data']['access_token'] as String;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newToken';
|
||||
if (!_disposed) onTokenRefreshed?.call(newToken);
|
||||
final opts = e.requestOptions;
|
||||
opts.headers['Authorization'] = 'Bearer $newToken';
|
||||
final retryResp = await _dio.fetch(opts);
|
||||
return handler.resolve(retryResp);
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) onAuthFailed?.call(reason);
|
||||
} catch (retryErr) {
|
||||
return handler.next(retryErr is DioException ? retryErr : e);
|
||||
}
|
||||
} else if (e.response?.statusCode == 401) {
|
||||
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
|
||||
@@ -131,6 +179,45 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
/// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。
|
||||
/// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。
|
||||
Future<String?> _refreshAccessToken() {
|
||||
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
|
||||
}
|
||||
|
||||
Future<String?> _doRefresh() async {
|
||||
try {
|
||||
final resp = await _publicDio.post('/auth/refresh', data: {
|
||||
'refresh_token': _refreshToken,
|
||||
});
|
||||
final data = resp.data['data'] as Map;
|
||||
final newAccess = data['access_token'] as String;
|
||||
// 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。
|
||||
final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken;
|
||||
_refreshToken = newRefresh;
|
||||
_dio.options.headers['Authorization'] = 'Bearer $newAccess';
|
||||
if (!_disposed && newRefresh != null) {
|
||||
_onTokensRefreshed?.call(newAccess, newRefresh);
|
||||
}
|
||||
return newAccess;
|
||||
} catch (refreshErr) {
|
||||
debugPrint('[ApiClient] refresh failed: $refreshErr');
|
||||
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
|
||||
String? reason;
|
||||
if (refreshErr is DioException &&
|
||||
refreshErr.response?.data is Map &&
|
||||
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
|
||||
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
|
||||
}
|
||||
if (!_disposed) _onAuthFailed?.call(reason);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。
|
||||
@visibleForTesting
|
||||
Dio get dioForTest => _dio;
|
||||
|
||||
/// 取消所有进行中的请求,标记实例为已废弃
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
|
||||
@@ -45,6 +45,22 @@ class AuthState {
|
||||
class AuthNotifier extends StateNotifier<AuthState> {
|
||||
AuthNotifier() : super(const AuthState());
|
||||
|
||||
/// 会话代号:每次 login/logout 自增,使「上一会话」遗留的异步持久化写入失效。
|
||||
/// 续期写回(updateAccessToken)是 fire-and-forget,若在并发登出清空 token 之后
|
||||
/// 才落盘,会把已失效的 token 重新写回;持有此代号即可在写入前丢弃过期操作。
|
||||
int _sessionGen = 0;
|
||||
|
||||
/// 串行化 SharedPreferences 写入:保证 login / updateAccessToken / logout 的持久化
|
||||
/// 按调用顺序落盘,杜绝并发交错(如续期写入与登出 remove 交叉)。
|
||||
Future<void> _storageQueue = Future.value();
|
||||
|
||||
Future<void> _runStorage(Future<void> Function() op) {
|
||||
final next = _storageQueue.then((_) => op());
|
||||
// 吞掉单次写入异常,避免队列被卡死。
|
||||
_storageQueue = next.catchError((_) {});
|
||||
return next;
|
||||
}
|
||||
|
||||
/// Called at app startup to restore persisted session.
|
||||
Future<void> restore() async {
|
||||
try {
|
||||
@@ -80,28 +96,43 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
Future<void> login(AuthUser user) async {
|
||||
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, user.accessToken);
|
||||
await prefs.setString(_kRefreshToken, user.refreshToken);
|
||||
await prefs.setString(_kUsername, user.username);
|
||||
await prefs.setString(_kRealName, user.realName);
|
||||
await prefs.setString(_kShopNo, user.shopNo);
|
||||
await prefs.setString(_kShopId, user.shopId.toString());
|
||||
await prefs.setString(_kRole, user.role);
|
||||
debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...');
|
||||
final gen = ++_sessionGen;
|
||||
await _runStorage(() async {
|
||||
if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, user.accessToken);
|
||||
await prefs.setString(_kRefreshToken, user.refreshToken);
|
||||
await prefs.setString(_kUsername, user.username);
|
||||
await prefs.setString(_kRealName, user.realName);
|
||||
await prefs.setString(_kShopNo, user.shopNo);
|
||||
await prefs.setString(_kShopId, user.shopId.toString());
|
||||
await prefs.setString(_kRole, user.role);
|
||||
});
|
||||
state = AuthState(initialized: true, user: user);
|
||||
debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}');
|
||||
}
|
||||
|
||||
void updateAccessToken(String newToken) {
|
||||
/// 续期后写回令牌。后端会**轮换 refresh token**(jti 轮换 + 盗用检测),
|
||||
/// 因此必须连同新的 refresh token 一起持久化;若仍沿用旧 refresh token,
|
||||
/// 下次续期会重放已被取代的 jti,触发盗用检测吊销整条会话,导致被强制登出。
|
||||
/// [newRefreshToken] 省略(如旧调用方)时保留原 refresh token。
|
||||
void updateAccessToken(String newToken, [String? newRefreshToken]) {
|
||||
if (state.user == null) return;
|
||||
SharedPreferences.getInstance()
|
||||
.then((prefs) => prefs.setString(_kAccessToken, newToken));
|
||||
final gen = _sessionGen;
|
||||
final refreshToken = newRefreshToken ?? state.user!.refreshToken;
|
||||
// fire-and-forget,但经队列串行 + 代号守卫:若落盘前发生过登出/重登,丢弃此次写入,
|
||||
// 避免把已失效的 token 写回 SharedPreferences。
|
||||
_runStorage(() async {
|
||||
if (gen != _sessionGen) return;
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_kAccessToken, newToken);
|
||||
await prefs.setString(_kRefreshToken, refreshToken);
|
||||
});
|
||||
state = AuthState(
|
||||
initialized: true,
|
||||
user: AuthUser(
|
||||
accessToken: newToken,
|
||||
refreshToken: state.user!.refreshToken,
|
||||
refreshToken: refreshToken,
|
||||
username: state.user!.username,
|
||||
realName: state.user!.realName,
|
||||
shopNo: state.user!.shopNo,
|
||||
@@ -113,8 +144,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
|
||||
Future<void> logout() async {
|
||||
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
|
||||
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
||||
final token = state.user?.accessToken;
|
||||
// 先作废在途的令牌持久化并立即本地登出,使任何并发的续期写入在落盘前被丢弃。
|
||||
_sessionGen++;
|
||||
state = const AuthState(initialized: true);
|
||||
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
|
||||
if (token != null && token.isNotEmpty) {
|
||||
try {
|
||||
await Dio(BaseOptions(
|
||||
@@ -127,15 +161,17 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
// ignore: 后端不可达或会话已失效都无所谓
|
||||
}
|
||||
}
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUsername);
|
||||
await prefs.remove(_kRealName);
|
||||
await prefs.remove(_kShopNo);
|
||||
await prefs.remove(_kShopId);
|
||||
await prefs.remove(_kRole);
|
||||
state = const AuthState(initialized: true);
|
||||
// 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。
|
||||
await _runStorage(() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_kAccessToken);
|
||||
await prefs.remove(_kRefreshToken);
|
||||
await prefs.remove(_kUsername);
|
||||
await prefs.remove(_kRealName);
|
||||
await prefs.remove(_kShopNo);
|
||||
await prefs.remove(_kShopId);
|
||||
await prefs.remove(_kRole);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +182,10 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
|
||||
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
|
||||
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。
|
||||
/// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。
|
||||
final apiMessageProvider = StateProvider<String?>((ref) => null);
|
||||
|
||||
/// 当前登录用户是否为只读角色(role == 'readonly')。
|
||||
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
|
||||
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/license.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
|
||||
/// 授权阶段文案 + 阈值的集中配置。
|
||||
///
|
||||
/// 所有授权相关提示(顶部横幅、到期弹窗、设置页状态、底部状态栏、过期说明)
|
||||
/// 统一在此维护,组件层不再硬编码文案与天数阈值。
|
||||
///
|
||||
/// 阈值须与后端 `backend/internal/middleware/license_guard.go` 保持一致:
|
||||
/// 宽限期 0–[graceDays] 天(仍可写)→ 只读 [graceDays]–[readonlyDays] 天 → 锁定 [readonlyDays] 天+。
|
||||
class LicenseCopy {
|
||||
LicenseCopy._();
|
||||
|
||||
/// 宽限期上限(天):过期 ≤ 此值仍可正常写入。
|
||||
static const int graceDays = 7;
|
||||
|
||||
/// 只读期上限(天):过期 ≤ 此值进入只读;超过则锁定登录。
|
||||
static const int readonlyDays = 15;
|
||||
|
||||
/// 顶部横幅文案(仅 grace/readonly/locked 显示;normal 返回空串)。
|
||||
static String banner(LicenseInfo lic) {
|
||||
final d = lic.daysExpired;
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
return '授权已过期 $d 天,请及时续费,否则将影响您的正常使用';
|
||||
case 'readonly':
|
||||
return '授权已过期 $d 天,已进入只读模式,暂时无法新增或修改数据;'
|
||||
'过期满 $readonlyDays 天后将彻底锁定,请尽快续费';
|
||||
case 'locked':
|
||||
return '授权已过期 $d 天并已锁定,所有功能已停用,请立即续费或激活新授权码';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页「授权状态」行文案。
|
||||
static String statusText(LicenseInfo lic) {
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
return '宽限期 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'readonly':
|
||||
return '只读模式 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'locked':
|
||||
return '已锁定 · 已过期 ${lic.daysExpired} 天';
|
||||
default:
|
||||
return lic.expiresAt == null
|
||||
? '正常(永久授权)'
|
||||
: '正常(剩余 ${lic.daysRemaining ?? 0} 天)';
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部状态栏紧凑文案。[expiryDate] 为已格式化的到期日(yyyy-MM-dd)。
|
||||
static String statusBar(LicenseInfo lic, String expiryDate) {
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
return '授权宽限期 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'readonly':
|
||||
return '只读模式 · 已过期 ${lic.daysExpired} 天';
|
||||
case 'locked':
|
||||
return '已锁定 · 已过期 ${lic.daysExpired} 天';
|
||||
default:
|
||||
return lic.expiresAt == null ? '永久授权' : '授权正常 · 到期 $expiryDate';
|
||||
}
|
||||
}
|
||||
|
||||
/// 到期提醒弹窗的(标题, 正文)。
|
||||
static (String title, String body) dialog(LicenseInfo lic) {
|
||||
final d = lic.daysExpired;
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
return (
|
||||
'授权已锁定',
|
||||
'您的授权已过期 $d 天(超过 $readonlyDays 天),所有功能已停用。\n'
|
||||
'请前往「设置 → 授权」激活新的授权码,或联系客服续费。',
|
||||
);
|
||||
case 'readonly':
|
||||
return (
|
||||
'授权已过期 · 只读模式',
|
||||
'您的授权已过期 $d 天,系统进入只读模式,无法执行任何写操作。\n'
|
||||
'过期满 $readonlyDays 天后将彻底锁定登录,请尽快续费。',
|
||||
);
|
||||
default: // grace
|
||||
return (
|
||||
'授权已过期',
|
||||
'您的授权已过期 $d 天,目前仍可正常使用(宽限期 $graceDays 天)。\n'
|
||||
'过期超过 $graceDays 天将进入只读模式,请尽快续费以免影响使用。',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读账号尝试写操作被后端拒绝(403 code=READONLY_USER)时的提示。
|
||||
static const String readonlyUserToast = '当前为只读账号,仅可查看数据,无法新增或修改';
|
||||
|
||||
/// 写按钮被授权过期禁用时,点击弹出的提示文案。
|
||||
static String writeBlockedToast(LicenseInfo lic) {
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
return '授权已过期 ${lic.daysExpired} 天并已锁定,无法操作,请前往「设置 → 授权」续费或激活';
|
||||
default: // readonly
|
||||
return '授权已过期 ${lic.daysExpired} 天,已进入只读模式,续费后可继续操作(设置 → 授权)';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 阶段视觉(颜色 / 图标)集中映射 ----
|
||||
// 顶部横幅、底部状态条、设置页状态三处此前各自硬编码 phase→色,readonly 取色还分叉
|
||||
// (横幅 0xFFB71C1C vs 状态条 0xFFEF5350)。这里按「角色」给出单一来源:
|
||||
// - [bannerColor]:顶部横幅**实底背景**(白字承其上),仅 grace/readonly/locked 显示。
|
||||
// - [phaseColor] :前景强调色(底部状态条图标+文字、设置页状态文字共用一套)。
|
||||
// - [phaseIcon] :状态图标(状态条用)。
|
||||
|
||||
/// 顶部横幅实底背景色(深色调,承载白字)。normal 不显示横幅,返回透明。
|
||||
static Color bannerColor(String phase) {
|
||||
switch (phase) {
|
||||
case 'locked':
|
||||
return AppTheme.danger;
|
||||
case 'readonly':
|
||||
return const Color(0xFFB71C1C); // 深红,强调即将彻底锁定
|
||||
case 'grace':
|
||||
return const Color(0xFFE65100); // 深橙
|
||||
default:
|
||||
return Colors.transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/// 前景强调色:normal=正常绿,grace=警告橙,readonly/locked=危险红。
|
||||
static Color phaseColor(String phase) {
|
||||
switch (phase) {
|
||||
case 'grace':
|
||||
return AppTheme.warning500;
|
||||
case 'readonly':
|
||||
case 'locked':
|
||||
return AppTheme.danger;
|
||||
default:
|
||||
return AppTheme.success;
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态图标:normal=已验证,grace=警告,readonly/locked=错误。
|
||||
static IconData phaseIcon(String phase) {
|
||||
switch (phase) {
|
||||
case 'grace':
|
||||
return Icons.warning_amber_rounded;
|
||||
case 'readonly':
|
||||
case 'locked':
|
||||
return Icons.error_outline;
|
||||
default:
|
||||
return Icons.verified_user_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
/// 过期降级说明(设置页固定展示),随阈值自动联动。
|
||||
static List<String> degradationNotes() => [
|
||||
'过期 $graceDays 天内(宽限期):仍可正常使用,请尽快续费',
|
||||
'过期 $graceDays–$readonlyDays 天(只读期):仅能查看数据,无法新增或修改',
|
||||
'过期满 $readonlyDays 天:系统锁定,无法登录,需续费或激活新授权码后恢复',
|
||||
];
|
||||
}
|
||||
@@ -142,7 +142,19 @@ final appRouterProvider = Provider<GoRouter>((ref) {
|
||||
productId: int.parse(state.pathParameters['id']!)))),
|
||||
GoRoute(
|
||||
path: '/settings',
|
||||
pageBuilder: (_, __) => _noTransition(const SettingsScreen())),
|
||||
pageBuilder: (_, state) {
|
||||
const tabIndex = {
|
||||
'shop': 0,
|
||||
'users': 1,
|
||||
'number': 2,
|
||||
'system': 3,
|
||||
'license': 4,
|
||||
'import': 5,
|
||||
};
|
||||
final tab =
|
||||
tabIndex[state.uri.queryParameters['tab']] ?? 0;
|
||||
return _noTransition(SettingsScreen(initialTab: tab));
|
||||
}),
|
||||
GoRoute(
|
||||
path: '/devices',
|
||||
pageBuilder: (_, __) =>
|
||||
|
||||
@@ -50,6 +50,13 @@ class LicenseInfo {
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
|
||||
/// 已过期天数(未过期或永久授权返回 0)。
|
||||
int get daysExpired {
|
||||
if (expiresAt == null) return 0;
|
||||
final diff = DateTime.now().difference(expiresAt!).inDays;
|
||||
return diff < 0 ? 0 : diff;
|
||||
}
|
||||
|
||||
bool get isReadOnlyPhase => phase == 'readonly' || phase == 'locked';
|
||||
bool get isLockedPhase => phase == 'locked';
|
||||
bool get needsAttention => phase == 'grace' || phase == 'readonly' || phase == 'locked';
|
||||
|
||||
@@ -32,4 +32,27 @@ class LicenseNotifier extends AsyncNotifier<LicenseInfo?> {
|
||||
state = const AsyncValue.loading();
|
||||
state = AsyncValue.data(await _fetch());
|
||||
}
|
||||
|
||||
/// 后台静默刷新(供心跳调用):不闪 loading;
|
||||
/// 仅在成功拿到结果时更新(含「确无授权」的 null),
|
||||
/// 网络/瞬时错误则保留上次状态,避免把已有授权误刷成「未激活」。
|
||||
/// 会话被撤销(401)由 ApiClient 拦截器统一处理(触发登出),与此无关。
|
||||
Future<void> refresh() async {
|
||||
try {
|
||||
final info = await ref.read(licenseRepositoryProvider).getInfo();
|
||||
state = AsyncValue.data(info);
|
||||
} catch (_) {
|
||||
// 保留上次状态
|
||||
}
|
||||
}
|
||||
|
||||
/// 直接采纳心跳 /auth/ping 回带的授权概况,免去单独再请求 /license/info。
|
||||
/// [raw] 为后端 LicenseInfoView 的 JSON(与 /license/info 同构);
|
||||
/// 为 null 表示「确无有效授权」,与 reload/refresh 拿到 null 语义一致。
|
||||
void applyServerInfo(dynamic raw) {
|
||||
final info = raw == null
|
||||
? null
|
||||
: LicenseInfo.fromJson(Map<String, dynamic>.from(raw as Map));
|
||||
state = AsyncValue.data(info);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,15 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api/api_client.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import 'license_provider.dart';
|
||||
|
||||
/// 登录态心跳:已登录时每 30s 打一次 POST /auth/ping。
|
||||
/// 若会话已被撤销(被踢/管理员强制下线),后端返回 401,
|
||||
/// ApiClient 的拦截器会触发 refresh→失败→onAuthFailed→logout,
|
||||
/// 因此空闲用户也能在 ~30s 内感知到被下线。
|
||||
/// 登录态心跳:已登录时每 30s 执行一次 **单个** POST /auth/ping:
|
||||
/// - 会话/在线检查:若会话已被撤销(被踢/管理员强制下线),后端返回 401,
|
||||
/// ApiClient 拦截器触发 refresh→失败→onAuthFailed→logout,因此空闲用户也能在
|
||||
/// ~30s 内感知到被下线;同时刷新 last_seen_at(在线状态)。
|
||||
/// - 授权状态检查:ping 响应**回带**当前授权概况(license 字段,与 /license/info 同构),
|
||||
/// 直接喂给 licenseProvider,使到期/续费/被改动等变化在 ~30s 内反映到横幅/状态栏/设置页。
|
||||
/// 如此一次心跳即覆盖两件事,**无需**再单独轮询 /license/info(每 30s 少一次请求)。
|
||||
final sessionHeartbeatProvider = Provider<SessionHeartbeat>((ref) {
|
||||
final hb = SessionHeartbeat(ref);
|
||||
ref.onDispose(hb.dispose);
|
||||
@@ -28,10 +32,18 @@ class SessionHeartbeat {
|
||||
|
||||
Future<void> _ping() async {
|
||||
if (!_ref.read(authStateProvider).isLoggedIn) return;
|
||||
// 一次 ping 同时完成会话/在线检查与授权概况刷新。
|
||||
try {
|
||||
await _ref.read(apiClientProvider).post('/auth/ping');
|
||||
final resp = await _ref.read(apiClientProvider).post('/auth/ping');
|
||||
// ping 可能已触发登出,故重新判断登录态后再采纳授权概况。
|
||||
final data = resp.data is Map ? resp.data['data'] : null;
|
||||
if (data is Map &&
|
||||
data.containsKey('license') &&
|
||||
_ref.read(authStateProvider).isLoggedIn) {
|
||||
_ref.read(licenseProvider.notifier).applyServerInfo(data['license']);
|
||||
}
|
||||
} catch (e) {
|
||||
// 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略
|
||||
// 401 已由 ApiClient 拦截器处理(触发登出);其余错误忽略,保留上次授权状态
|
||||
debugPrint('[Heartbeat] ping failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,10 +169,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
],
|
||||
actions: (canClose && !WriteGuard.isReadonly(ref))
|
||||
? [
|
||||
TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.success)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.success)),
|
||||
),
|
||||
),
|
||||
]
|
||||
: null,
|
||||
@@ -318,10 +320,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
if ((r.type == 'payable' || r.type == 'receivable') &&
|
||||
r.status == 'open' &&
|
||||
!WriteGuard.isReadonly(ref)) {
|
||||
return DataCell(TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||
return DataCell(WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () => _closeRecord(r),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||
),
|
||||
));
|
||||
}
|
||||
return const DataCell(SizedBox());
|
||||
@@ -419,10 +423,12 @@ class _FinanceTabState extends ConsumerState<_FinanceTab> {
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||
ElevatedButton.icon(
|
||||
onPressed: _showAddDialog,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(addLabel),
|
||||
WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _showAddDialog,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(addLabel),
|
||||
),
|
||||
),
|
||||
if (addLabel != null && !WriteGuard.isReadonly(ref))
|
||||
const SizedBox(width: 8),
|
||||
|
||||
@@ -79,6 +79,33 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
ref.read(inventoryListProvider.notifier).setKeyword(_searchCtrl.text.trim());
|
||||
}
|
||||
|
||||
/// 备注列展示:editable 时附带编辑图标(用于 WriteGuard 的可点子控件),
|
||||
/// 否则纯文本(只读角色占位)。
|
||||
Widget _remarkDisplay(Inventory item, {required bool editable}) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.remark.isEmpty
|
||||
? '—'
|
||||
: item.remark.length > 4
|
||||
? '${item.remark.substring(0, 4)}…'
|
||||
: item.remark,
|
||||
style: TextStyle(
|
||||
color: item.remark.isEmpty
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
if (editable) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.edit_outlined,
|
||||
size: 12, color: AppTheme.textSecondary),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editRemark(BuildContext context, Inventory item) async {
|
||||
final ctrl = TextEditingController(text: item.remark);
|
||||
final saved = await showDialog<String>(
|
||||
@@ -304,28 +331,13 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
'remark' => DataCell(Tooltip(
|
||||
message: item.remark.isEmpty ? '' : item.remark,
|
||||
waitDuration: const Duration(milliseconds: 300),
|
||||
child: GestureDetector(
|
||||
onTap: WriteGuard.isReadonly(ref)
|
||||
? null
|
||||
: () => _editRemark(context, item),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
item.remark.isEmpty
|
||||
? '—'
|
||||
: item.remark.length > 4
|
||||
? '${item.remark.substring(0, 4)}…'
|
||||
: item.remark,
|
||||
style: TextStyle(
|
||||
color: item.remark.isEmpty
|
||||
? AppTheme.textSecondary
|
||||
: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.edit_outlined, size: 12, color: AppTheme.textSecondary),
|
||||
],
|
||||
// 内联编辑入口统一交给 WriteGuard:只读角色显示纯文本(无编辑图标),
|
||||
// 授权过期则由 WriteGuard 自动置灰并在点击时弹提示——不再手搓三元 + toast。
|
||||
child: WriteGuard(
|
||||
placeholder: _remarkDisplay(item, editable: false),
|
||||
child: GestureDetector(
|
||||
onTap: () => _editRemark(context, item),
|
||||
child: _remarkDisplay(item, editable: true),
|
||||
),
|
||||
))),
|
||||
'status' => DataCell(_InventoryStatusBadge(item)),
|
||||
@@ -374,9 +386,11 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
],
|
||||
actions: [
|
||||
if (!WriteGuard.isReadonly(ref))
|
||||
TextButton(
|
||||
onPressed: () => _editRemark(context, item),
|
||||
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () => _editRemark(context, item),
|
||||
child: const Text('备注', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
if (item.productId != null)
|
||||
TextButton(
|
||||
@@ -622,16 +636,19 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
Row(
|
||||
children: [
|
||||
if (canCheck)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('盘点'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
WriteGuard(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('盘点'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize:
|
||||
MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -671,10 +688,12 @@ class _InventoryListScreenState extends ConsumerState<InventoryListScreen> {
|
||||
SizedBox(width: 220, child: searchField),
|
||||
const SizedBox(width: 12),
|
||||
if (canCheck) ...[
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
WriteGuard(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => context.go('/inventory/check'),
|
||||
icon: const Icon(Icons.fact_check, size: 16),
|
||||
label: const Text('发起盘点'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
|
||||
@@ -170,16 +170,20 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
actions: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -195,10 +199,12 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
toolbar: Row(
|
||||
children: [
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isSupplier ? '新建' : '新建'),
|
||||
WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isSupplier ? '新建' : '新建'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -288,19 +294,23 @@ class _PartnersScreenState extends ConsumerState<PartnersScreen> {
|
||||
children: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_edit_${p.id}'),
|
||||
onPressed: () => onEdit(p),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
key: Key('btn_delete_${p.id}'),
|
||||
onPressed: () => onDelete(p),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
|
||||
@@ -358,10 +358,12 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
return Row(
|
||||
children: [
|
||||
if (!WriteGuard.isReadonly(ref)) ...[
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建'),
|
||||
WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onAdd,
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: const Text('新建'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
@@ -422,14 +424,18 @@ class _ProductsScreenState extends ConsumerState<ProductsScreen> {
|
||||
actions: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: onEdit,
|
||||
child: const Text('编辑', style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: onDelete,
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.danger)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -12,19 +12,21 @@ import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/app_info.dart';
|
||||
import '../../core/config/license_copy.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../models/number_rule.dart';
|
||||
import '../../models/user.dart';
|
||||
import '../../providers/license_provider.dart';
|
||||
import '../../repositories/license_repository.dart';
|
||||
import '../../providers/number_rule_provider.dart';
|
||||
import '../../providers/user_provider.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
import '../../models/shop.dart';
|
||||
|
||||
class SettingsScreen extends ConsumerStatefulWidget {
|
||||
const SettingsScreen({super.key});
|
||||
/// 初始选中的 Tab(0=酒行信息 … 4=授权 … 5=数据管理)。
|
||||
final int initialTab;
|
||||
const SettingsScreen({super.key, this.initialTab = 0});
|
||||
|
||||
@override
|
||||
ConsumerState<SettingsScreen> createState() => _SettingsScreenState();
|
||||
@@ -44,6 +46,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
initialIndex: widget.initialTab,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
@@ -276,17 +279,21 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
children: WriteGuard.isReadonly(ref)
|
||||
? const []
|
||||
: [
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showEditUserDialog(context, u),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
_showEditUserDialog(context, u),
|
||||
child: const Text('编辑',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () =>
|
||||
_showResetPasswordDialog(context, u),
|
||||
child: const Text('重置密码',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
WriteGuard(
|
||||
child: TextButton(
|
||||
onPressed: () =>
|
||||
_showResetPasswordDialog(context, u),
|
||||
child: const Text('重置密码',
|
||||
style: TextStyle(fontSize: 12)),
|
||||
),
|
||||
),
|
||||
],
|
||||
)),
|
||||
@@ -326,6 +333,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 过期降级说明
|
||||
_buildExpiryNotes(),
|
||||
const SizedBox(height: 16),
|
||||
// 激活码输入区
|
||||
_buildActivationCard(),
|
||||
],
|
||||
@@ -349,24 +359,8 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Color phaseColor;
|
||||
String phaseText;
|
||||
switch (lic.phase) {
|
||||
case 'grace':
|
||||
phaseColor = Colors.orange;
|
||||
phaseText = '宽限期(剩余 ${lic.daysRemaining ?? 0} 天到期)';
|
||||
case 'readonly':
|
||||
phaseColor = AppTheme.danger;
|
||||
phaseText = '已过期 · 只读模式';
|
||||
case 'locked':
|
||||
phaseColor = AppTheme.danger;
|
||||
phaseText = '已锁定 · 请立即续费';
|
||||
default:
|
||||
phaseColor = AppTheme.success;
|
||||
phaseText = lic.expiresAt == null
|
||||
? '正常(永久授权)'
|
||||
: '正常(剩余 ${lic.daysRemaining ?? 0} 天)';
|
||||
}
|
||||
final Color phaseColor = LicenseCopy.phaseColor(lic.phase);
|
||||
final String phaseText = LicenseCopy.statusText(lic);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -407,6 +401,53 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpiryNotes() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: const [
|
||||
Icon(Icons.info_outline,
|
||||
size: 16, color: AppTheme.textSecondary),
|
||||
SizedBox(width: 6),
|
||||
Text('过期说明',
|
||||
style:
|
||||
TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text('授权到期后会分阶段降级,请在到期前及时续费',
|
||||
style:
|
||||
TextStyle(fontSize: 13, color: AppTheme.textSecondary)),
|
||||
const SizedBox(height: 12),
|
||||
for (final note in LicenseCopy.degradationNotes())
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6, right: 8),
|
||||
child: Icon(Icons.circle,
|
||||
size: 5, color: AppTheme.textSecondary),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(note,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, height: 1.5)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActivationCard() {
|
||||
return Card(
|
||||
child: Padding(
|
||||
@@ -789,6 +830,19 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
|
||||
style: TextStyle(color: AppTheme.textSecondary)),
|
||||
);
|
||||
}
|
||||
if (WriteGuard.licenseBlocked(ref)) {
|
||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
lic == null ? '授权已过期,暂时无法导入' : LicenseCopy.writeBlockedToast(lic),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final currentUser = ref.watch(authStateProvider).user;
|
||||
final isSuperAdmin = currentUser?.role == 'superadmin';
|
||||
return _BatchImportWidget(isSuperAdmin: isSuperAdmin);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../core/config/app_config.dart';
|
||||
import '../../core/config/license_copy.dart';
|
||||
import '../../core/responsive/responsive.dart';
|
||||
import '../../core/theme/app_theme.dart';
|
||||
import '../../providers/connectivity_provider.dart';
|
||||
@@ -170,6 +171,14 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
final user = ref.watch(authStateProvider).user;
|
||||
// 登录态心跳:随 shell 挂载存活,~30s 一次,感知被踢下线
|
||||
ref.watch(sessionHeartbeatProvider);
|
||||
// 全局轻提示(写请求被后端 403 拒绝等):统一在此弹出并清空。
|
||||
ref.listen<String?>(apiMessageProvider, (prev, next) {
|
||||
if (next == null || next.isEmpty) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
..clearSnackBars()
|
||||
..showSnackBar(SnackBar(content: Text(next)));
|
||||
ref.read(apiMessageProvider.notifier).state = null;
|
||||
});
|
||||
final isOnline = ref.watch(connectivityProvider);
|
||||
final location = GoRouterState.of(context).matchedLocation;
|
||||
final isMobile = context.isMobile;
|
||||
@@ -300,68 +309,37 @@ 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(
|
||||
height: 48,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
PopupMenuButton<String>(
|
||||
tooltip: '账号菜单',
|
||||
position: PopupMenuPosition.over,
|
||||
offset: const Offset(0, -8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(6)),
|
||||
color: Colors.white,
|
||||
elevation: 8,
|
||||
onSelected: (v) {
|
||||
if (v == 'logout') {
|
||||
ref
|
||||
.read(authStateProvider.notifier)
|
||||
.logout();
|
||||
context.go('/login');
|
||||
},
|
||||
hoverColor: Colors.white.withAlpha(13),
|
||||
splashColor: Colors.white.withAlpha(26),
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem<String>(
|
||||
value: 'logout',
|
||||
padding: EdgeInsets.zero,
|
||||
child: _HoverMenuItem(
|
||||
icon: Icons.logout,
|
||||
label: '退出登录',
|
||||
),
|
||||
),
|
||||
],
|
||||
child: SizedBox(
|
||||
height: 48,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(width: _sidebarExpanded ? 16 : 3),
|
||||
@@ -371,14 +349,24 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
? MainAxisAlignment.start
|
||||
: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.logout,
|
||||
const Icon(Icons.person_outline,
|
||||
color: Colors.white60, size: 20),
|
||||
if (_sidebarExpanded) ...[
|
||||
const SizedBox(width: 12),
|
||||
const Text('退出登录',
|
||||
style: TextStyle(
|
||||
Expanded(
|
||||
child: Text(
|
||||
user.username,
|
||||
style: const TextStyle(
|
||||
color: Colors.white70,
|
||||
fontSize: 14)),
|
||||
fontSize: 14),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_up,
|
||||
color: Colors.white38,
|
||||
size: 18),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -387,7 +375,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
@@ -559,6 +547,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
.valueOrNull ??
|
||||
'v1.0.0',
|
||||
iconOnly: iconOnly),
|
||||
_LicenseStatusItem(
|
||||
lic: licenseInfo, iconOnly: iconOnly),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -577,19 +567,8 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}
|
||||
|
||||
Widget _buildLicenseBanner(LicenseInfo lic) {
|
||||
final Color bg;
|
||||
final String msg;
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
bg = AppTheme.danger;
|
||||
msg = '授权已锁定,所有写操作已停用 — 请立即续费或激活新授权码';
|
||||
case 'readonly':
|
||||
bg = const Color(0xFFB71C1C);
|
||||
msg = '授权已过期,当前为只读模式(剩余宽限期 ${lic.daysRemaining ?? 0} 天后彻底锁定)';
|
||||
default: // grace
|
||||
bg = const Color(0xFFE65100);
|
||||
msg = '授权将于 ${lic.daysRemaining ?? 0} 天后到期,请及时续费';
|
||||
}
|
||||
final bg = LicenseCopy.bannerColor(lic.phase);
|
||||
final msg = LicenseCopy.banner(lic);
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: bg,
|
||||
@@ -605,7 +584,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/settings'),
|
||||
onPressed: () => context.go('/settings?tab=license'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.white70),
|
||||
child: const Text('去激活'),
|
||||
),
|
||||
@@ -615,23 +594,9 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
}
|
||||
|
||||
void _showLicenseExpiryDialog(BuildContext ctx, LicenseInfo lic) {
|
||||
final String title;
|
||||
final String body;
|
||||
final Color titleColor;
|
||||
switch (lic.phase) {
|
||||
case 'locked':
|
||||
title = '授权已锁定';
|
||||
body = '您的授权已到期超过 15 天,所有写操作已停用。\n请前往「设置 → 授权」激活新的授权码,或联系客服续费。';
|
||||
titleColor = AppTheme.danger;
|
||||
case 'readonly':
|
||||
title = '授权已过期 · 只读模式';
|
||||
body = '您的授权已过期,系统进入只读模式,无法执行任何写操作。\n到期 15 天后将彻底锁定登录,请尽快续费。';
|
||||
titleColor = AppTheme.danger;
|
||||
default: // grace
|
||||
title = '授权即将到期';
|
||||
body = '您的授权将在 ${lic.daysRemaining ?? 0} 天后到期,到期后系统进入只读模式。\n请提前联系客服续费,避免影响正常使用。';
|
||||
titleColor = Colors.orange[800]!;
|
||||
}
|
||||
final (title, body) = LicenseCopy.dialog(lic);
|
||||
final Color titleColor =
|
||||
lic.phase == 'grace' ? Colors.orange[800]! : AppTheme.danger;
|
||||
showDialog(
|
||||
context: ctx,
|
||||
barrierDismissible: true,
|
||||
@@ -651,7 +616,7 @@ class _AppShellState extends ConsumerState<AppShell> {
|
||||
style: ElevatedButton.styleFrom(backgroundColor: titleColor),
|
||||
onPressed: () {
|
||||
Navigator.pop(_);
|
||||
ctx.go('/settings');
|
||||
ctx.go('/settings?tab=license');
|
||||
},
|
||||
child: const Text('立即前往', style: TextStyle(color: Colors.white)),
|
||||
),
|
||||
@@ -764,6 +729,43 @@ class _StatusItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 状态栏「授权到期」项:按到期后时长分三级,用颜色 + 文案区分。
|
||||
/// 没到期 → 正常(绿);过期 ≤7 天(宽限期)→ 警告(橙);过期 >7 天 → error(红)。
|
||||
/// 对应后端 phase:normal / grace / readonly|locked。
|
||||
class _LicenseStatusItem extends StatelessWidget {
|
||||
final LicenseInfo? lic;
|
||||
final bool iconOnly;
|
||||
const _LicenseStatusItem({required this.lic, this.iconOnly = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lic = this.lic;
|
||||
if (lic == null) return const SizedBox.shrink();
|
||||
|
||||
final color = LicenseCopy.phaseColor(lic.phase);
|
||||
final icon = LicenseCopy.phaseIcon(lic.phase);
|
||||
|
||||
final String date = lic.expiresAt == null
|
||||
? ''
|
||||
: DateFormat('yyyy-MM-dd').format(lic.expiresAt!);
|
||||
final String text = LicenseCopy.statusBar(lic, date);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const _StatusDivider(),
|
||||
Icon(icon, size: 11, color: color),
|
||||
if (!iconOnly) ...[
|
||||
const SizedBox(width: 4),
|
||||
Text(text,
|
||||
style: TextStyle(
|
||||
color: color, fontSize: 11, fontWeight: FontWeight.w600)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusDivider extends StatelessWidget {
|
||||
const _StatusDivider();
|
||||
@override
|
||||
|
||||
@@ -24,6 +24,7 @@ import '../../repositories/product_repository.dart';
|
||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||
import '../../providers/shop_provider.dart' show shopInfoProvider;
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../widgets/order_row_actions.dart';
|
||||
|
||||
class StockInListScreen extends ConsumerStatefulWidget {
|
||||
const StockInListScreen({super.key});
|
||||
@@ -306,18 +307,20 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
);
|
||||
|
||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
? WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-in/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建入库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -417,93 +420,59 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 操作按钮列表,表格与移动端卡片共用。
|
||||
/// 操作按钮列表,表格与移动端卡片共用。结构见 [buildOrderRowActions],
|
||||
/// 入库特有的「打标签」通过 afterPrint 注入。
|
||||
List<Widget> _orderActions(BuildContext context, StockInOrder o) {
|
||||
final readonly = WriteGuard.isReadonly(ref);
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockInOrder(order));
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (!context.mounted) return;
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
final labels = order.items
|
||||
.map((item) => LabelData(
|
||||
productId: item.productId,
|
||||
name: item.productName ?? '',
|
||||
code: item.productCode ?? '',
|
||||
series: item.productSeries,
|
||||
spec: item.productSpec,
|
||||
batchNo: item.batchNo,
|
||||
productionDate: item.productionDate,
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
))
|
||||
.toList();
|
||||
showAppDialog(
|
||||
context: context,
|
||||
builder: (_) => LabelPreviewDialog(
|
||||
labels: labels,
|
||||
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (!readonly && o.status == 'approved')
|
||||
return buildOrderRowActions(
|
||||
readonly: WriteGuard.isReadonly(ref),
|
||||
status: o.status,
|
||||
orderId: o.id,
|
||||
onDetail: () => _showDetail(context, o.id),
|
||||
onPrint: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockInOrder(order));
|
||||
}
|
||||
},
|
||||
afterPrint: [
|
||||
TextButton(
|
||||
onPressed: () => _confirmSettle(context, o.id, 'stock_in'),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
||||
),
|
||||
if (!readonly && o.status == 'draft') ...[
|
||||
TextButton(
|
||||
onPressed: () => context.go('/stock-in/edit/${o.id}'),
|
||||
child: const Text('修改',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmDelete(context, o),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockInRepositoryProvider).get(o.id);
|
||||
if (!context.mounted) return;
|
||||
final shopInfo = ref.read(shopInfoProvider).valueOrNull;
|
||||
final labels = order.items
|
||||
.map((item) => LabelData(
|
||||
productId: item.productId,
|
||||
name: item.productName ?? '',
|
||||
code: item.productCode ?? '',
|
||||
series: item.productSeries,
|
||||
spec: item.productSpec,
|
||||
batchNo: item.batchNo,
|
||||
productionDate: item.productionDate,
|
||||
shopName: shopInfo?.name ?? '',
|
||||
shopAddress: shopInfo?.address ?? '',
|
||||
shopPhone: shopInfo?.phone ?? '',
|
||||
))
|
||||
.toList();
|
||||
showAppDialog(
|
||||
context: context,
|
||||
builder: (_) => LabelPreviewDialog(
|
||||
labels: labels,
|
||||
qrFetcher: ref.read(productRepositoryProvider).getQRCodeBytes,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('打标签',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
if (!readonly && o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () => _confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () => _confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
];
|
||||
onSettle: () => _confirmSettle(context, o.id, 'stock_in'),
|
||||
onEdit: () => context.go('/stock-in/edit/${o.id}'),
|
||||
onDelete: () => _confirmDelete(context, o),
|
||||
onSubmit: () => _confirmSubmit(context, o),
|
||||
onApprove: () => _confirmApprove(context, o),
|
||||
onReject: () => _confirmReject(context, o),
|
||||
);
|
||||
}
|
||||
|
||||
/// 入库单:窄屏卡片
|
||||
|
||||
@@ -22,6 +22,7 @@ import '../../providers/tab_state_provider.dart';
|
||||
import '../../providers/product_provider.dart';
|
||||
import '../../providers/finance_provider.dart' show financeRepositoryProvider;
|
||||
import '../../widgets/write_guard.dart';
|
||||
import '../../widgets/order_row_actions.dart';
|
||||
|
||||
class StockOutListScreen extends ConsumerStatefulWidget {
|
||||
const StockOutListScreen({super.key});
|
||||
@@ -312,18 +313,20 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
);
|
||||
|
||||
final newBtn = (showNewButton && !WriteGuard.isReadonly(ref))
|
||||
? ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
? WriteGuard(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => context.go('/stock-out/new'),
|
||||
icon: const Icon(Icons.add, size: 16),
|
||||
label: Text(isMobile ? '新建' : '新建出库审核单'),
|
||||
style: isMobile
|
||||
? ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -425,61 +428,24 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
|
||||
|
||||
/// 操作按钮列表,表格与移动端卡片共用。
|
||||
List<Widget> _orderActions(BuildContext context, StockOutOrder o) {
|
||||
final readonly = WriteGuard.isReadonly(ref);
|
||||
return [
|
||||
TextButton(
|
||||
onPressed: () => _showDetail(context, o.id),
|
||||
child: const Text('详情',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockOutOrder(order));
|
||||
}
|
||||
},
|
||||
child: const Text('打印',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
if (!readonly && o.status == 'approved')
|
||||
TextButton(
|
||||
onPressed: () => _confirmSettle(context, o.id, 'stock_out'),
|
||||
child: const Text('结清',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.accent)),
|
||||
),
|
||||
if (!readonly && o.status == 'draft') ...[
|
||||
TextButton(
|
||||
onPressed: () => context.go('/stock-out/edit/${o.id}'),
|
||||
child: const Text('修改',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmDelete(context, o),
|
||||
child: const Text('删除',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => _confirmSubmit(context, o),
|
||||
child: const Text('提交',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.primary)),
|
||||
),
|
||||
],
|
||||
if (!readonly && o.status == 'pending') ...[
|
||||
TextButton(
|
||||
key: Key('btn_approve_${o.id}'),
|
||||
onPressed: () => _confirmApprove(context, o),
|
||||
child: const Text('通过',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.success)),
|
||||
),
|
||||
TextButton(
|
||||
key: Key('btn_reject_${o.id}'),
|
||||
onPressed: () => _confirmReject(context, o),
|
||||
child: const Text('拒绝',
|
||||
style: TextStyle(fontSize: 12, color: AppTheme.danger)),
|
||||
),
|
||||
],
|
||||
];
|
||||
return buildOrderRowActions(
|
||||
readonly: WriteGuard.isReadonly(ref),
|
||||
status: o.status,
|
||||
orderId: o.id,
|
||||
onDetail: () => _showDetail(context, o.id),
|
||||
onPrint: () async {
|
||||
final order = await ref.read(stockOutRepositoryProvider).get(o.id);
|
||||
if (context.mounted) {
|
||||
await safePrint(context, () => printStockOutOrder(order));
|
||||
}
|
||||
},
|
||||
onSettle: () => _confirmSettle(context, o.id, 'stock_out'),
|
||||
onEdit: () => context.go('/stock-out/edit/${o.id}'),
|
||||
onDelete: () => _confirmDelete(context, o),
|
||||
onSubmit: () => _confirmSubmit(context, o),
|
||||
onApprove: () => _confirmApprove(context, o),
|
||||
onReject: () => _confirmReject(context, o),
|
||||
);
|
||||
}
|
||||
|
||||
/// 出库单:窄屏卡片
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/theme/app_theme.dart';
|
||||
import 'write_guard.dart';
|
||||
|
||||
/// 入/出库单行内操作按钮,列表表格与移动端卡片、入库与出库两屏共用。
|
||||
///
|
||||
/// 此前 stock_in / stock_out 各自逐字重复了「详情/打印/结清/修改/删除/提交/通过/拒绝」
|
||||
/// 的状态门控(draft/pending/approved)与 WriteGuard 包裹脚手架,仅单据类型、路由、
|
||||
/// 打印函数与回调不同。这里把**相同的结构**收敛到单一来源,差异通过回调注入:
|
||||
///
|
||||
/// - [afterPrint]:紧随「打印」之后的附加按钮(入库的「打标签」;出库传空)。
|
||||
/// - 各 `on*` 回调由调用方绑定到对应单据的确认/跳转逻辑。
|
||||
List<Widget> buildOrderRowActions({
|
||||
required bool readonly,
|
||||
required String status,
|
||||
required int orderId,
|
||||
required VoidCallback onDetail,
|
||||
required VoidCallback onPrint,
|
||||
required VoidCallback onSettle,
|
||||
required VoidCallback onEdit,
|
||||
required VoidCallback onDelete,
|
||||
required VoidCallback onSubmit,
|
||||
required VoidCallback onApprove,
|
||||
required VoidCallback onReject,
|
||||
List<Widget> afterPrint = const [],
|
||||
}) {
|
||||
TextButton btn(String text, Color color, VoidCallback onPressed, {Key? key}) =>
|
||||
TextButton(
|
||||
key: key,
|
||||
onPressed: onPressed,
|
||||
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
|
||||
);
|
||||
return [
|
||||
btn('详情', AppTheme.primary, onDetail),
|
||||
btn('打印', AppTheme.primary, onPrint),
|
||||
...afterPrint,
|
||||
if (!readonly && status == 'approved')
|
||||
WriteGuard(child: btn('结清', AppTheme.accent, onSettle)),
|
||||
if (!readonly && status == 'draft') ...[
|
||||
WriteGuard(child: btn('修改', AppTheme.primary, onEdit)),
|
||||
WriteGuard(child: btn('删除', AppTheme.danger, onDelete)),
|
||||
WriteGuard(child: btn('提交', AppTheme.primary, onSubmit)),
|
||||
],
|
||||
if (!readonly && status == 'pending') ...[
|
||||
WriteGuard(
|
||||
child: btn('通过', AppTheme.success, onApprove,
|
||||
key: Key('btn_approve_$orderId'))),
|
||||
WriteGuard(
|
||||
child: btn('拒绝', AppTheme.danger, onReject,
|
||||
key: Key('btn_reject_$orderId'))),
|
||||
],
|
||||
];
|
||||
}
|
||||
@@ -1,26 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/auth/auth_state.dart';
|
||||
import '../core/config/license_copy.dart';
|
||||
import '../providers/license_provider.dart';
|
||||
|
||||
/// 写操作守卫:当前登录用户为只读角色(role == 'readonly')时,
|
||||
/// 隐藏被包裹的写操作控件(新增/编辑/删除/审核/提交/结清/导入…)。
|
||||
/// 写操作守卫:按「角色只读」和「授权过期」两种受限场景**区分处理**被包裹的写控件
|
||||
/// (新增/编辑/删除/审核/提交/结清/导入…):
|
||||
///
|
||||
/// - **角色只读**(role == 'readonly')→ 隐藏(不显示)。
|
||||
/// - **授权过期 >7 天**(phase == 'readonly' | 'locked')→ 显示但置灰禁用,
|
||||
/// 点击弹提示说明原因并引导去激活。
|
||||
/// - **其它**(normal / grace 宽限期)→ 原样显示且可点(宽限期后端仍允许写)。
|
||||
///
|
||||
/// 统一入口,避免在各页面散落 `if (!ref.watch(isReadonlyProvider))` 判断。
|
||||
/// 后端 `middleware.ReadOnly()` 仍会对只读用户的写请求兜底返回 403,
|
||||
/// 本控件只负责「不让按钮出现」,二者配合:UI 不误导 + 后端不可绕过。
|
||||
/// 后端 `middleware.ReadOnly()` / `LicenseGuard()` 仍会对写请求兜底返回 403,
|
||||
/// 本控件只负责「不误导」:只读不显示、过期显示但不可点。
|
||||
///
|
||||
/// 用法:
|
||||
/// ```dart
|
||||
/// WriteGuard(child: ElevatedButton(onPressed: _add, child: const Text('新建')))
|
||||
/// ```
|
||||
/// 列表 children 里可用 [hidden] 配合 collection-if 直接剔除分隔符:
|
||||
/// 列表 children 里可用 collection-if 配合 [isReadonly] 直接剔除分隔符;
|
||||
/// 过期置灰仍由内层 [WriteGuard] 负责:
|
||||
/// ```dart
|
||||
/// if (!WriteGuard.isReadonly(ref)) ...[button, const SizedBox(width: 8)]
|
||||
/// if (!WriteGuard.isReadonly(ref)) ...[WriteGuard(child: button), const SizedBox(width: 8)]
|
||||
/// ```
|
||||
class WriteGuard extends ConsumerWidget {
|
||||
final Widget child;
|
||||
|
||||
/// 只读时显示的占位控件,默认完全隐藏(不占位)。
|
||||
/// 只读角色时显示的占位控件,默认完全隐藏(不占位)。
|
||||
final Widget placeholder;
|
||||
|
||||
const WriteGuard({
|
||||
@@ -29,11 +37,58 @@ class WriteGuard extends ConsumerWidget {
|
||||
this.placeholder = const SizedBox.shrink(),
|
||||
});
|
||||
|
||||
/// 供需要在 collection-if / 复合条件中判断的场景直接调用。
|
||||
/// 当前是否只读角色(写按钮应**隐藏**)。供 collection-if / 复合条件直接调用。
|
||||
static bool isReadonly(WidgetRef ref) => ref.watch(isReadonlyProvider);
|
||||
|
||||
/// 当前是否因授权过期 >7 天而应**禁用**写操作(phase readonly/locked)。
|
||||
static bool licenseBlocked(WidgetRef ref) {
|
||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||
return lic != null && lic.isReadOnlyPhase;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ref.watch(isReadonlyProvider) ? placeholder : child;
|
||||
// 1) 角色只读:隐藏
|
||||
if (ref.watch(isReadonlyProvider)) return placeholder;
|
||||
|
||||
// 2) 授权过期 >7 天:显示但置灰禁用 + 点击弹提示
|
||||
final lic = ref.watch(licenseProvider).valueOrNull;
|
||||
if (lic != null && lic.isReadOnlyPhase) {
|
||||
return _DisabledByLicense(lic: lic, child: child);
|
||||
}
|
||||
|
||||
// 3) normal / grace:原样可点
|
||||
return child;
|
||||
}
|
||||
}
|
||||
|
||||
/// 授权过期态的写控件包装:视觉置灰 + 拦截点击并提示。
|
||||
class _DisabledByLicense extends StatelessWidget {
|
||||
final LicenseInfo lic;
|
||||
final Widget child;
|
||||
const _DisabledByLicense({required this.lic, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 置灰且不响应子控件自身的点击
|
||||
IgnorePointer(child: Opacity(opacity: 0.45, child: child)),
|
||||
// 覆盖一层透明手势层:捕获点击弹出原因说明
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
messenger
|
||||
?..clearSnackBars()
|
||||
..showSnackBar(
|
||||
SnackBar(content: Text(LicenseCopy.writeBlockedToast(lic))),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
import 'package:jiu_client/core/api/api_client.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('ApiClient 403 拦截 → onForbidden 回调', () {
|
||||
test('写请求收到 403 时,onForbidden 收到响应体(code=READONLY_USER)', () async {
|
||||
dynamic captured;
|
||||
final client = ApiClient(
|
||||
token: 'test-token',
|
||||
onForbidden: (body) => captured = body,
|
||||
);
|
||||
final adapter = DioAdapter(
|
||||
dio: client.dioForTest,
|
||||
matcher: const FullHttpRequestMatcher(),
|
||||
);
|
||||
adapter.onPost(
|
||||
'/products',
|
||||
(server) => server.reply(403, {'error': 'readonly user', 'code': 'READONLY_USER'}),
|
||||
data: {'name': '测试'},
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.post('/products', data: {'name': '测试'}),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(captured, isA<Map>());
|
||||
expect(captured['code'], 'READONLY_USER');
|
||||
});
|
||||
|
||||
test('授权过期 403(phase=readonly)时,onForbidden 收到 phase 字段', () async {
|
||||
dynamic captured;
|
||||
final client = ApiClient(
|
||||
token: 'test-token',
|
||||
onForbidden: (body) => captured = body,
|
||||
);
|
||||
final adapter = DioAdapter(
|
||||
dio: client.dioForTest,
|
||||
matcher: const FullHttpRequestMatcher(),
|
||||
);
|
||||
adapter.onPost(
|
||||
'/stock-in',
|
||||
(server) => server.reply(403, {'error': 'license expired', 'phase': 'readonly'}),
|
||||
data: {'x': 1},
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.post('/stock-in', data: {'x': 1}),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(captured, isA<Map>());
|
||||
expect(captured['phase'], 'readonly');
|
||||
});
|
||||
|
||||
test('非 403 错误不触发 onForbidden', () async {
|
||||
var called = false;
|
||||
final client = ApiClient(
|
||||
token: 'test-token',
|
||||
onForbidden: (_) => called = true,
|
||||
);
|
||||
final adapter = DioAdapter(
|
||||
dio: client.dioForTest,
|
||||
matcher: const FullHttpRequestMatcher(),
|
||||
);
|
||||
adapter.onGet(
|
||||
'/products',
|
||||
(server) => server.reply(404, {'error': 'not found'}),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
client.get('/products'),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
expect(called, isFalse);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/config/license_copy.dart';
|
||||
import 'package:jiu_client/models/license.dart';
|
||||
|
||||
LicenseInfo _lic(String phase, {int expiredDays = 0, bool permanent = false}) =>
|
||||
LicenseInfo(
|
||||
id: 1,
|
||||
type: 'annual',
|
||||
isActive: true,
|
||||
maxDevices: 3,
|
||||
phase: phase,
|
||||
expiresAt:
|
||||
permanent ? null : DateTime.now().subtract(Duration(days: expiredDays)),
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('writeBlockedToast', () {
|
||||
test('readonly 阶段文案含过期天数并引导续费', () {
|
||||
final s = LicenseCopy.writeBlockedToast(_lic('readonly', expiredDays: 10));
|
||||
expect(s, contains('10'));
|
||||
expect(s, contains('只读'));
|
||||
expect(s, contains('设置 → 授权'));
|
||||
});
|
||||
|
||||
test('locked 阶段文案标注已锁定', () {
|
||||
final s = LicenseCopy.writeBlockedToast(_lic('locked', expiredDays: 20));
|
||||
expect(s, contains('20'));
|
||||
expect(s, contains('锁定'));
|
||||
});
|
||||
});
|
||||
|
||||
group('banner', () {
|
||||
test('normal 阶段返回空串', () {
|
||||
expect(LicenseCopy.banner(_lic('normal', permanent: true)), isEmpty);
|
||||
});
|
||||
|
||||
test('grace/readonly/locked 各阶段含过期天数', () {
|
||||
expect(LicenseCopy.banner(_lic('grace', expiredDays: 2)), contains('2'));
|
||||
expect(
|
||||
LicenseCopy.banner(_lic('readonly', expiredDays: 9)), contains('只读'));
|
||||
expect(
|
||||
LicenseCopy.banner(_lic('locked', expiredDays: 30)), contains('锁定'));
|
||||
});
|
||||
});
|
||||
|
||||
group('statusText / statusBar', () {
|
||||
test('normal 永久授权', () {
|
||||
final lic = _lic('normal', permanent: true);
|
||||
expect(LicenseCopy.statusText(lic), contains('永久'));
|
||||
expect(LicenseCopy.statusBar(lic, '2099-01-01'), contains('永久'));
|
||||
});
|
||||
|
||||
test('readonly 阶段标注只读', () {
|
||||
final lic = _lic('readonly', expiredDays: 9);
|
||||
expect(LicenseCopy.statusText(lic), contains('只读'));
|
||||
expect(LicenseCopy.statusBar(lic, '2024-01-01'), contains('只读'));
|
||||
});
|
||||
});
|
||||
|
||||
group('degradationNotes', () {
|
||||
test('三条说明随阈值联动(含 7 与 15)', () {
|
||||
final notes = LicenseCopy.degradationNotes();
|
||||
expect(notes.length, 3);
|
||||
expect(notes.join(), contains('${LicenseCopy.graceDays}'));
|
||||
expect(notes.join(), contains('${LicenseCopy.readonlyDays}'));
|
||||
});
|
||||
});
|
||||
|
||||
group('readonlyUserToast', () {
|
||||
test('只读账号提示固定文案', () {
|
||||
expect(LicenseCopy.readonlyUserToast, contains('只读'));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,10 +4,25 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/models/page_result.dart';
|
||||
import 'package:jiu_client/models/license.dart';
|
||||
import 'package:jiu_client/models/partner.dart';
|
||||
import 'package:jiu_client/providers/license_provider.dart';
|
||||
import 'package:jiu_client/providers/partner_provider.dart';
|
||||
import 'package:jiu_client/screens/partners/partners_screen.dart';
|
||||
|
||||
/// WriteGuard 内层会 watch licenseProvider;测试里覆写成同步返回 normal 授权,
|
||||
/// 避免触发真实网络请求(否则留下 pending timer 导致测试失败)。
|
||||
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||
@override
|
||||
Future<LicenseInfo?> build() async => const LicenseInfo(
|
||||
id: 1,
|
||||
type: 'annual',
|
||||
isActive: true,
|
||||
maxDevices: 3,
|
||||
phase: 'normal',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake notifier
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -80,6 +95,7 @@ Widget _buildApp({
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||
supplierListProvider.overrideWith(
|
||||
() => _FakePartnerNotifier('supplier', supplierState),
|
||||
),
|
||||
@@ -94,6 +110,7 @@ Widget _buildApp({
|
||||
Widget _buildLoadingApp() {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||
supplierListProvider.overrideWith(
|
||||
() => _FakeLoadingPartnerNotifier('supplier'),
|
||||
),
|
||||
|
||||
@@ -1,69 +1,156 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/exceptions.dart';
|
||||
import 'package:http_mock_adapter/http_mock_adapter.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:jiu_client/core/api/api_client.dart';
|
||||
import 'package:jiu_client/core/auth/auth_state.dart';
|
||||
import 'package:jiu_client/core/config/license_copy.dart';
|
||||
import 'package:jiu_client/core/models/page_result.dart';
|
||||
import 'package:jiu_client/models/product.dart';
|
||||
import 'package:jiu_client/providers/product_provider.dart';
|
||||
import 'package:jiu_client/screens/products/products_screen.dart';
|
||||
import 'package:jiu_client/models/license.dart';
|
||||
import 'package:jiu_client/models/partner.dart';
|
||||
import 'package:jiu_client/providers/license_provider.dart';
|
||||
import 'package:jiu_client/providers/partner_provider.dart';
|
||||
import 'package:jiu_client/screens/partners/partners_screen.dart';
|
||||
|
||||
// 只读角色:写按钮(新建/编辑/删除)应被 WriteGuard 隐藏。
|
||||
// 替换原先因 ProductsScreen 重构而 skip 的旧用例,改测稳定的 PartnersScreen。
|
||||
|
||||
class _FakePartnerNotifier extends PartnerListNotifier {
|
||||
final AsyncValue<PageResult<Partner>> _fixed;
|
||||
_FakePartnerNotifier(String type, this._fixed) : super(type: type);
|
||||
|
||||
class _ReadonlyProductNotifier extends ProductListNotifier {
|
||||
@override
|
||||
Future<PageResult<Product>> build() async {
|
||||
return const PageResult(data: [], total: 0, page: 1, pageSize: 20);
|
||||
Future<PageResult<Partner>> build() async {
|
||||
state = _fixed;
|
||||
return state.value ??
|
||||
const PageResult(data: [], total: 0, page: 1, pageSize: 20);
|
||||
}
|
||||
|
||||
@override
|
||||
void reload() {}
|
||||
|
||||
@override
|
||||
void setKeyword(String keyword) {}
|
||||
|
||||
@override
|
||||
void setPage(int page) {}
|
||||
|
||||
@override
|
||||
Future<void> createProduct(Map<String, dynamic> data) async {
|
||||
throw const AppException('readonly user', statusCode: 403);
|
||||
}
|
||||
|
||||
Future<void> createPartner(Map<String, dynamic> data) async {}
|
||||
@override
|
||||
Future<void> updateProduct(int id, Map<String, dynamic> data) async {}
|
||||
|
||||
Future<void> updatePartner(int id, Map<String, dynamic> data) async {}
|
||||
@override
|
||||
Future<void> deleteProduct(int id) async {}
|
||||
Future<void> deletePartner(int id) async {}
|
||||
}
|
||||
|
||||
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||
@override
|
||||
Future<LicenseInfo?> build() async => const LicenseInfo(
|
||||
id: 1,
|
||||
type: 'annual',
|
||||
isActive: true,
|
||||
maxDevices: 3,
|
||||
phase: 'normal',
|
||||
);
|
||||
}
|
||||
|
||||
const _suppliers = PageResult<Partner>(
|
||||
data: [Partner(id: 1, code: 'S001', name: '茅台供应商', type: 'supplier')],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
);
|
||||
|
||||
Widget _buildApp({required bool readonly}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
isReadonlyProvider.overrideWithValue(readonly),
|
||||
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||
supplierListProvider.overrideWith(
|
||||
() => _FakePartnerNotifier('supplier', const AsyncValue.data(_suppliers)),
|
||||
),
|
||||
customerListProvider.overrideWith(
|
||||
() => _FakePartnerNotifier(
|
||||
'customer',
|
||||
const AsyncValue.data(
|
||||
PageResult(data: [], total: 0, page: 1, pageSize: 20)),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const MaterialApp(home: Scaffold(body: PartnersScreen())),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// TODO: 新建按钮位置已变更,测试待更新
|
||||
testWidgets('write operation shows forbidden error for readonly user',
|
||||
skip: true, (tester) async {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 仅隐藏按钮不够:只读账号若绕过 UI(或后端授权过期)直接提交写操作,
|
||||
// 必须验证 403 真的被 apiClientProvider 的 onForbidden 捕获并浮出只读提示。
|
||||
test('只读账号提交写操作:后端 403(READONLY_USER) 经 apiClientProvider 浮出只读提示',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
// 登录一个只读账号,使 apiClientProvider 携带 token 并构建真实客户端。
|
||||
await container.read(authStateProvider.notifier).login(const AuthUser(
|
||||
accessToken: 'ro-token',
|
||||
refreshToken: 'ro-refresh',
|
||||
username: 'viewer',
|
||||
realName: '只读用户',
|
||||
shopNo: 'S001',
|
||||
shopId: 1,
|
||||
role: 'readonly',
|
||||
));
|
||||
|
||||
final client = container.read(apiClientProvider);
|
||||
final adapter = DioAdapter(
|
||||
dio: client.dioForTest,
|
||||
matcher: const FullHttpRequestMatcher(),
|
||||
);
|
||||
adapter.onPost(
|
||||
'/partners',
|
||||
(server) =>
|
||||
server.reply(403, {'error': 'readonly user', 'code': 'READONLY_USER'}),
|
||||
data: {'name': '新供应商'},
|
||||
);
|
||||
|
||||
// 写请求被后端拒绝。
|
||||
await expectLater(
|
||||
client.post('/partners', data: {'name': '新供应商'}),
|
||||
throwsA(isA<DioException>()),
|
||||
);
|
||||
|
||||
// provider 的 onForbidden 把只读提示浮到全局消息通道(用户可见的「403 浮出」)。
|
||||
expect(container.read(apiMessageProvider), LicenseCopy.readonlyUserToast);
|
||||
});
|
||||
|
||||
testWidgets('只读角色:新建/编辑/删除按钮全部隐藏', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
productListProvider.overrideWith(() => _ReadonlyProductNotifier()),
|
||||
],
|
||||
child: const MaterialApp(
|
||||
home: Scaffold(body: ProductsScreen()),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpWidget(_buildApp(readonly: true));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('新建'));
|
||||
// 供应商已渲染,证明列表确实加载。
|
||||
expect(find.text('茅台供应商'), findsOneWidget);
|
||||
// 写按钮被 WriteGuard 隐藏。
|
||||
expect(find.text('新建'), findsNothing);
|
||||
expect(find.byKey(const Key('btn_edit_1')), findsNothing);
|
||||
expect(find.byKey(const Key('btn_delete_1')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('普通角色(normal 授权):写按钮可见', (tester) async {
|
||||
tester.view.physicalSize = const Size(1280, 800);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
|
||||
await tester.pumpWidget(_buildApp(readonly: false));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextFormField).at(0), '测试商品');
|
||||
await tester.enterText(find.byType(TextFormField).at(1), 'P999');
|
||||
|
||||
await tester.tap(find.text('保存'));
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('保存失败:readonly user'), findsOneWidget);
|
||||
expect(find.text('茅台供应商'), findsOneWidget);
|
||||
expect(find.text('新建'), findsWidgets);
|
||||
expect(find.byKey(const Key('btn_edit_1')), findsOneWidget);
|
||||
expect(find.byKey(const Key('btn_delete_1')), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,10 +5,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:jiu_client/core/models/page_result.dart';
|
||||
import 'package:jiu_client/models/license.dart';
|
||||
import 'package:jiu_client/models/stock_in.dart';
|
||||
import 'package:jiu_client/providers/license_provider.dart';
|
||||
import 'package:jiu_client/providers/stock_in_provider.dart';
|
||||
import 'package:jiu_client/screens/stock_in/stock_in_list_screen.dart';
|
||||
|
||||
/// WriteGuard 内层会 watch licenseProvider;覆写成同步 normal 授权,避免真实网络请求。
|
||||
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||
@override
|
||||
Future<LicenseInfo?> build() async => const LicenseInfo(
|
||||
id: 1,
|
||||
type: 'annual',
|
||||
isActive: true,
|
||||
maxDevices: 3,
|
||||
phase: 'normal',
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake notifier
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -102,6 +116,7 @@ Widget _buildApp(AsyncValue<PageResult<StockInOrder>> state) {
|
||||
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||
stockInListProvider.overrideWith(() => _FakeStockInNotifier(state)),
|
||||
],
|
||||
child: MaterialApp.router(routerConfig: router),
|
||||
@@ -125,6 +140,7 @@ Widget _buildLoadingApp() {
|
||||
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
licenseProvider.overrideWith(() => _FakeLicenseNotifier()),
|
||||
stockInListProvider.overrideWith(() => _FakeLoadingStockInNotifier()),
|
||||
],
|
||||
child: MaterialApp.router(routerConfig: router),
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/auth/auth_state.dart';
|
||||
import 'package:jiu_client/core/config/license_copy.dart';
|
||||
import 'package:jiu_client/models/license.dart';
|
||||
import 'package:jiu_client/providers/license_provider.dart';
|
||||
import 'package:jiu_client/widgets/write_guard.dart';
|
||||
|
||||
/// 用固定 LicenseInfo(含指定 phase)填充 licenseProvider 的 Fake Notifier。
|
||||
class _FakeLicenseNotifier extends LicenseNotifier {
|
||||
final LicenseInfo? _value;
|
||||
_FakeLicenseNotifier(this._value);
|
||||
|
||||
@override
|
||||
Future<LicenseInfo?> build() async => _value;
|
||||
}
|
||||
|
||||
LicenseInfo _lic(String phase, {int expiredDays = 0}) => LicenseInfo(
|
||||
id: 1,
|
||||
type: 'annual',
|
||||
isActive: true,
|
||||
maxDevices: 3,
|
||||
phase: phase,
|
||||
expiresAt: DateTime.now().subtract(Duration(days: expiredDays)),
|
||||
);
|
||||
|
||||
/// 把一个带 onPressed 的按钮包进 WriteGuard,按 role + license phase 覆写 provider。
|
||||
Widget _harness({
|
||||
required bool readonly,
|
||||
LicenseInfo? lic,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
isReadonlyProvider.overrideWithValue(readonly),
|
||||
licenseProvider.overrideWith(() => _FakeLicenseNotifier(lic)),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: Scaffold(
|
||||
body: WriteGuard(
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
child: const Text('新建'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('只读角色:按钮隐藏(findsNothing)', (tester) async {
|
||||
var tapped = false;
|
||||
await tester.pumpWidget(
|
||||
_harness(readonly: true, lic: _lic('normal'), onPressed: () => tapped = true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('新建'), findsNothing);
|
||||
expect(tapped, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('operator + normal:按钮可见且可点', (tester) async {
|
||||
var tapped = false;
|
||||
await tester.pumpWidget(
|
||||
_harness(readonly: false, lic: _lic('normal'), onPressed: () => tapped = true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('新建'), findsOneWidget);
|
||||
await tester.tap(find.text('新建'));
|
||||
await tester.pump();
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('operator + grace(宽限期):按钮可见且可点', (tester) async {
|
||||
var tapped = false;
|
||||
await tester.pumpWidget(
|
||||
_harness(
|
||||
readonly: false,
|
||||
lic: _lic('grace', expiredDays: 3),
|
||||
onPressed: () => tapped = true,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('新建'), findsOneWidget);
|
||||
await tester.tap(find.text('新建'));
|
||||
await tester.pump();
|
||||
expect(tapped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('operator + readonly(过期>7天):按钮可见但禁用,点击弹提示且不触发 onPressed',
|
||||
(tester) async {
|
||||
var tapped = false;
|
||||
final lic = _lic('readonly', expiredDays: 10);
|
||||
await tester.pumpWidget(
|
||||
_harness(readonly: false, lic: lic, onPressed: () => tapped = true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
// 按钮仍渲染(不隐藏),但被 IgnorePointer + Opacity 包裹禁用。
|
||||
expect(find.text('新建'), findsOneWidget);
|
||||
expect(find.byType(IgnorePointer), findsWidgets);
|
||||
expect(find.byType(Opacity), findsWidgets);
|
||||
|
||||
// 点击覆盖层弹出原因提示,且 onPressed 未被调用。
|
||||
// 覆盖层(GestureDetector)盖在按钮上,命中的是覆盖层而非按钮文字,故 warnIfMissed:false。
|
||||
await tester.tap(find.text('新建'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapped, isFalse);
|
||||
expect(find.text(LicenseCopy.writeBlockedToast(lic)), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('operator + locked(过期>15天):按钮可见但禁用,弹锁定提示', (tester) async {
|
||||
var tapped = false;
|
||||
final lic = _lic('locked', expiredDays: 20);
|
||||
await tester.pumpWidget(
|
||||
_harness(readonly: false, lic: lic, onPressed: () => tapped = true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('新建'), findsOneWidget);
|
||||
await tester.tap(find.text('新建'), warnIfMissed: false);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(tapped, isFalse);
|
||||
expect(find.text(LicenseCopy.writeBlockedToast(lic)), findsOneWidget);
|
||||
});
|
||||
}
|
||||
@@ -6,6 +6,8 @@ JWT_SECRET=CHANGE_ME_RANDOM_32CHARS
|
||||
JWT_ACCESS_EXPIRE_MIN=60
|
||||
JWT_REFRESH_EXPIRE_H=168
|
||||
LICENSE_HMAC_SECRET=CHANGE_ME_RANDOM_32CHARS
|
||||
LICENSE_ED25519_PUBLIC_KEY=zFkRV0E+oMjHbixiBamf2ZAq+n7En2V7EJRIurgtVxo=
|
||||
LICENSE_ED25519_PRIVATE_KEY=CHANGE_ME_ED25519_PRIVATE_FROM_BITWARDEN
|
||||
STORAGE_UPLOAD_DIR=/opt/jiu/images
|
||||
STORAGE_BASE_URL=https://jiu.51yanmei.com/images
|
||||
STORAGE_PUBLIC_URL=https://jiu.51yanmei.com
|
||||
|
||||
@@ -15,8 +15,8 @@ set -euo pipefail
|
||||
|
||||
# ---- 可配置项 ----
|
||||
RBW="${RBW_BIN:-/opt/homebrew/bin/rbw}"
|
||||
BW_ITEM="${JIU_BW_ITEM:-DB_PASSWORD}" # 存放 jiu 密钥字段的 Bitwarden 条目
|
||||
SECRET_KEYS="DATABASE_DSN JWT_SECRET LICENSE_HMAC_SECRET DB_PASSWORD"
|
||||
BW_ITEM="${JIU_BW_ITEM:-jiu db password}" # 存放 jiu 密钥字段的 Bitwarden 条目
|
||||
SECRET_KEYS="DATABASE_DSN JWT_SECRET LICENSE_HMAC_SECRET LICENSE_ED25519_PRIVATE_KEY DB_PASSWORD"
|
||||
|
||||
EC2_HOST="${EC2_HOST:-18.136.60.128}"
|
||||
EC2_USER="${EC2_USER:-ec2-user}"
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# 会话安全加固 — 方案设计
|
||||
|
||||
> 本文档覆盖登录会话(`user_sessions`)的安全与运维要素:refresh token 轮换/盗用检测、改密与禁用即时下线、吊销审计、保留清理、失败登录落库。纯后端实现,老 token 平滑过渡(发版不强制全员重登)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
登录会话已支持登出/踢人/在线监控,但对照成熟 session 实现仍缺一批安全与运维要素。本次补齐 7 项:
|
||||
|
||||
1. **Refresh token 不轮换、无盗用检测** — 续期复用同一 `sid`,库里不存 token 凭据,泄露后到期前一直可用且无感知。
|
||||
2. **改密码不吊销会话** — 旧设备 token 仍有效。
|
||||
3. **禁用用户不即时下线** — 中间件每请求只查 `session.revoked_at`,不查 `user.is_active`。
|
||||
4. **`revoked_by` 缺失** — 无法审计谁吊销了会话。
|
||||
5. **无清理/保留策略** — 撤销/过期会话行永不删,表无限膨胀、IP/UA 永久留存。
|
||||
6. **`last_seen_at` 的 gorm tag 语义不清** — 易被后人误改成 `autoUpdateTime` 引入回归。
|
||||
7. **失败登录不落库** — 仅内存限流计数,重启清零,无审计/风控留痕。
|
||||
|
||||
**目标**:不强制现网用户重登的前提下堵上缺口,并补齐后端自动化测试。
|
||||
|
||||
---
|
||||
|
||||
## 2. 数据模型变更
|
||||
|
||||
GORM `AutoMigrate` 只增不删,新增列/表生产安全;`schema.sql` 与 sqlite 测试建表手工同步。
|
||||
|
||||
`user_sessions` 新增两列:
|
||||
|
||||
| 列 | 类型 | 含义 |
|
||||
|----|------|------|
|
||||
| `refresh_jti` | `VARCHAR(64)` | 当前有效 refresh token 的 jti(轮换 + 重用检测) |
|
||||
| `revoked_by` | `BIGINT UNSIGNED` | 吊销操作人 user_id(系统/自助吊销为 NULL) |
|
||||
|
||||
`revoked_reason` 新增取值:`reuse`(盗用检测)、`pwd_reset`(改密)、`disabled`(禁用)。
|
||||
|
||||
新增表 `login_attempts`(失败登录落库):
|
||||
|
||||
```sql
|
||||
id, shop_code VARCHAR(64), username VARCHAR(50), ip VARCHAR(64),
|
||||
user_agent VARCHAR(512), success TINYINT(1), reason VARCHAR(40), created_at
|
||||
-- 索引: (username, created_at), (ip, created_at)
|
||||
```
|
||||
|
||||
`model.LoginAttempt` 已加入 `main.go` `autoMigrate(...)`。
|
||||
|
||||
---
|
||||
|
||||
## 3. Refresh token 轮换 + 盗用检测(#1)
|
||||
|
||||
采用 **jti 轮换 + token family 重用检测**(存 jti 而非整 token hash —— JWT 签名已防伪造,jti 足以判定「是否已被取代」,更轻量):
|
||||
|
||||
- `Claims` 内嵌 `jwt.RegisteredClaims`,其 `ID` 字段即 jti。仅 **refresh token** 写入 jti,access token 不需要。
|
||||
- **Login**:生成初始 `jti=uuid`,建会话写入 `refresh_jti`,下发 refresh token 带该 jti。
|
||||
- **RefreshTokens**(事务 + `FOR UPDATE` 锁会话行):
|
||||
1. 锁定会话,未找到/已撤销 → `ErrSessionRevoked`。
|
||||
2. **重用检测**:`sess.RefreshJTI != "" && claims.ID != sess.RefreshJTI` → 旧/被取代的 refresh token 重放 = 盗用信号 → 吊销整条会话(`reason=reuse`)并返回 `ErrSessionRevoked`。
|
||||
3. 校验用户存在/启用/授权未锁。
|
||||
4. **轮换**:`newJTI=uuid`,更新 `refresh_jti` + `last_seen_at`,下发新 pair。
|
||||
- **向后兼容**:老 token `claims.ID==""` 且老会话 `refresh_jti==""` → 合法首刷,直接采纳新 jti 不报重用(避免发版即把所有人踢下线)。
|
||||
|
||||
> 实现细节:重用检测的「吊销」必须在事务**外**执行 —— 事务内返回非 nil error 会回滚包括吊销在内的全部写入,故用内部 sentinel `errRefreshReuse` 从事务返回(无写入、安全回滚),再在事务外 commit 吊销。
|
||||
|
||||
---
|
||||
|
||||
## 4. 改密 / 禁用即时下线(#2 #3)
|
||||
|
||||
- **改密**(`user.go` `ResetPassword`):更新 `password_hash` 后吊销该用户全部活跃会话(`reason=pwd_reset`, `revoked_by=操作人`)。
|
||||
- **禁用**(`user.go` `Update`,`is_active=false`):吊销该用户全部活跃会话(`reason=disabled`, `revoked_by=操作人`)—— 立即生效、零每请求开销。
|
||||
- 上述吊销复用 service 层包级函数 `RevokeUserSessions(db, shopID, userID, byUserID, reason)`。
|
||||
- **兜底(防直接改库)**:中间件 `JWT` 会话校验由「单查 session」改为「session JOIN users」,顺带取 `is_active`/`deleted_at`;禁用或删除即返回 `401 {code:"USER_DISABLED"}`。仍是每请求一次查询(`sid` 唯一键覆盖),无额外往返。
|
||||
- `RefreshTokens` 已校验 `is_active`,与中间件构成双保险。
|
||||
|
||||
---
|
||||
|
||||
## 5. 吊销审计(#4)
|
||||
|
||||
- `ForceLogout(shopID, sessionID, byUserID)` 加操作人参数,写入 `revoked_by`;handler 传 `middleware.GetUserID(c)`。
|
||||
- 并发踢人(`kicked`)、自助登出(`logout`)、盗用(`reuse`)无明确他方操作人 → `revoked_by` 留 NULL(系统)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 保留清理(#5)
|
||||
|
||||
- `config.Session.RetentionDays`(默认 90,`retention_days <= 0` 关闭清理)。
|
||||
- `service.StartSessionCleanup(db, retentionDays)`:goroutine 启动即跑一次 + 24h ticker 周期跑。每次 `cutoff = now - RetentionDays`:
|
||||
- 删 `user_sessions`:`revoked_at < cutoff` 或 `refresh_exp_at < cutoff`。
|
||||
- 删 `login_attempts`:`created_at < cutoff`。
|
||||
|
||||
---
|
||||
|
||||
## 7. last_seen_at tag 加固(#6)
|
||||
|
||||
该字段全程**手工维护**(Login 建行赋值、心跳/refresh 显式 Update)。`autoCreateTime` 正好满足「建行给默认、之后不被 ORM 自动改」;若改 `autoUpdateTime` 反而会在 revoke/cleanup 等 `Updates` 时**错误地把已撤销会话刷成「刚活跃」**。故 #6 的正确处理是**防回归而非改行为**:保留 `autoCreateTime` + 补警示注释。
|
||||
|
||||
---
|
||||
|
||||
## 8. 失败登录落库(#7)
|
||||
|
||||
- helper `recordLoginAttempt(shopCode, username, dev, success, reason)` 写一行 `login_attempts`。
|
||||
- `Login` 各失败分支各记一条:`invalid_shop` / `invalid_user` / `inactive` / `bad_password` / `locked` / `platform_not_allowed`。成功路径不记(已由 `user_sessions` + `last_login_at` 覆盖)。
|
||||
- 写库失败仅 `log.Printf` 不阻断登录。内存限流器(`loginLim`)保留,锁定后写入自然受限,旧行由 #5 清理。
|
||||
|
||||
---
|
||||
|
||||
## 9. 测试覆盖
|
||||
|
||||
- `internal/service/session_hardening_test.go`:#1 轮换/重用/向后兼容、#4 `revoked_by`、#5 清理、#7 失败落库。
|
||||
- `internal/handler/session_security_test.go`:#2 改密吊销(原 token 401 `SESSION_REVOKED`、审计字段)、#3 禁用吊销 + 中间件对直接改库的 `USER_DISABLED` 兜底。
|
||||
|
||||
---
|
||||
|
||||
## 10. 影响边界 / 风险
|
||||
|
||||
- **平滑发版**:老 access/refresh token(无 jti)走兼容分支,不会因部署被批量登出。
|
||||
- **每请求成本**:中间件由「单查 session」变「session JOIN users」,仍为一次查询。
|
||||
- **写放大**:失败登录落库受内存限流封顶 + 保留期清理,生产可控。
|
||||
- **多租户/权限不变**;`revoked_by`/`refresh_jti`/`login_attempts` 均不经请求体传入,无越权面。
|
||||
@@ -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}"
|
||||
|
||||
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# local_test.sh — 本地编译一份「指向线上后端」的 macOS app 并启动,用于发版前自测。
|
||||
#
|
||||
# 用法:
|
||||
# sh scripts/local_test.sh # 编译 + 启动
|
||||
# sh scripts/local_test.sh --no-open # 只编译不启动
|
||||
#
|
||||
# 后端固定指向生产 https://jiu.51yanmei.com(与 CI compile-macos.sh 一致)。
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="/opt/homebrew/bin:$PATH"
|
||||
|
||||
# 版本号:取最新 client-v* tag 的 patch+1,纯展示用,取不到则回退 0.0.0-dev。
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
LATEST_TAG="$(git describe --tags --match 'client-v*' --abbrev=0 2>/dev/null || true)"
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
BASE_VER="${LATEST_TAG#client-v}"
|
||||
MAJOR_MINOR="${BASE_VER%.*}"
|
||||
PATCH="${BASE_VER##*.}"
|
||||
VER="${MAJOR_MINOR}.$((PATCH + 1))"
|
||||
else
|
||||
VER="0.0.0-dev"
|
||||
fi
|
||||
|
||||
APP="client/build/macos/Build/Products/Release/jiu_client.app"
|
||||
|
||||
echo "==> 编译 macOS app(线上后端,版本 v${VER})"
|
||||
cd client
|
||||
flutter build macos --release \
|
||||
"--dart-define=BASE_URL=https://jiu.51yanmei.com" \
|
||||
"--dart-define=PUBLIC_URL=https://jiu.51yanmei.com" \
|
||||
"--dart-define=APP_VERSION=v${VER}"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "==> 产物:$APP"
|
||||
|
||||
if [ "${1:-}" = "--no-open" ]; then
|
||||
echo "==> 跳过启动(--no-open)"
|
||||
else
|
||||
echo "==> 前台启动 app(终端附着日志,退出 app 才返回;Ctrl-C 可终止)"
|
||||
exec "$APP/Contents/MacOS/jiu_client"
|
||||
fi
|
||||
Reference in New Issue
Block a user