Compare commits

...

11 Commits

Author SHA1 Message Date
wangjia ec36b9679a chore: release server-v1.0.59
Deploy Server / release-deploy-server (push) Failing after 23m11s
2026-06-19 07:35:22 +08:00
wangjia 51c81fd943 docs: 会话安全方案文档 + 本地测试脚本
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:34:07 +08:00
wangjia 32bd64d676 feat(client): 实时授权状态与会话失效处理
- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info
- 账号被停用/删除(401 USER_DISABLED)即强制重新登录
- 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回
- 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:34:07 +08:00
wangjia e41085a878 feat(backend): 会话安全加固 + 授权实时 phase + 首次使用自动试用
会话安全(jti 轮换 / 重用检测 / 改密吊销 / 禁用即时下线 / 清理 / 失败登录落库):
- refresh token 轮换 jti + token-family 重用检测,旧 token 重放即吊销整条会话
- 改密码、停用用户即时吊销其全部活跃会话(revoked_by 审计)
- 中间件 session JOIN user 校验,禁用/删除用户带 token 请求返回 401 USER_DISABLED
- 新增 login_attempts 失败登录落库 + 会话保留期清理 goroutine

授权实时 phase + 心跳回带:
- LicenseGuard 改为按当前 DB 实时计算 phase(30s 每店缓存),续费/过期/被改 ~30s 内对写操作生效,无需重登
- /auth/ping 回带授权概况(ShopInfoView,与 /license/info 同构),客户端一次心跳即刷新横幅/门禁

首次使用自动试用 + code-review 修复:
- 门店首次登录/续期无有效授权时自动签发 30 天 trial(快路径无锁 Count,仅首用走 FOR UPDATE 事务)
- ShopInfo 区分「确无授权」与瞬时 DB 错误,避免误降级
- trial 签发后改为在事务提交后再失效 phase 缓存(修复早于提交的竞态)
- 存量无 sid token 续期纳入显式上限,legacy 会话不再游离于并发配额之外

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 07:34:04 +08:00
wangjia 2d84bda99a chore: release server-v1.0.58
Deploy Server / release-deploy-server (push) Successful in 48s
RefreshTokens 续登路径补发首次试用,修复自动续登老用户授权停留「未激活」。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 19:14:25 +08:00
wangjia e3c8350d03 chore(deploy): 接入 Ed25519 license 签发密钥到 production.env 渲染
render-env.sh 修正 Bitwarden 条目名为「jiu db password」,SECRET_KEYS
加入 LICENSE_ED25519_PRIVATE_KEY;模板补公钥(明文可提交)与私钥占位。
修复未激活门店因生产缺少签发私钥而无法自动发放试用授权的问题。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:57:30 +08:00
wangjia a8e29a641a feat(client): 退出登录归入用户名下拉菜单
侧栏底部用户名行改为可点击的 PopupMenuButton,点击弹出账号菜单,
退出登录移入其中;移除原用户名行直接打开门店信息面板的入口。
设备管理保持在侧栏导航不变。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:57:30 +08:00
wangjia e3e8cf7037 chore: release client-v1.0.58
Deploy Client / build-client-web (push) Successful in 40s
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-macos (push) Successful in 2m13s
Deploy Client / build-android (push) Successful in 1m35s
Deploy Client / build-ios (push) Successful in 3m0s
Deploy Client / release-deploy-client (push) Successful in 1m19s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:01:50 +08:00
wangjia 435e02278d chore: release server-v1.0.57
Deploy Server / release-deploy-server (push) Successful in 40s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 08:00:34 +08:00
wangjia 64a64e7c0a feat(backend): 首次登录无有效授权时自动发放 30 天试用
未激活门店(seed/手动建店/老数据,无任何 license 行)此前被当作永久授权,
既不拦截也无提示。现在登录时若门店无有效授权,自动签发 30 天 trial,
之后照常走 grace/readonly/locked 降级链。须在 issueTokens 之前发放,
使 JWT 的 lic_exp 带上新到期日。幂等(已有有效授权不重发),签发失败
仅记日志不阻断登录。复用注册路径的发放逻辑(抽出 issueTrialLicense)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 07:59:44 +08:00
wangjia 2ac1cbfb24 ci(devops): compile-windows 兼容裸 v 前缀版本号,修 pubspec 非法版本
Build Windows Only / build-windows (push) Successful in 2m21s
build-windows.yml 单独触发时默认 ver=v1.0.4,脚本仅剥 client-v 前缀,
导致 pubspec 写入 version: v1.0.4+1 被 flutter 拒(Invalid version number)。
追加一道 ${VER#v} 剥裸 v;真实发版路径 client-v* 不受影响(无裸 v,空操作)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y2Wdwo7SmgBJU37cBrkhPK
2026-06-18 07:54:48 +08:00
54 changed files with 2884 additions and 549 deletions
+5
View File
@@ -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
### 新功能
+23
View File
@@ -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
### 新功能
+4 -2
View File
@@ -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")
+19 -5
View File
@@ -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})
}
+53 -11
View File
@@ -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"])
}
+3 -19
View File
@@ -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 — 已绑定设备列表
+1 -1
View File
@@ -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)
}
+12
View File
@@ -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 应被拦截")
}
})
}
}
+22 -5
View File
@@ -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()
+72 -3
View File
@@ -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 快照仍是 normallic_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 说 normalPOST 也被 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)
}
}
+18
View File
@@ -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" }
+8 -1
View File
@@ -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"`
}
+2 -2
View File
@@ -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")
+213 -18
View File
@@ -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, // jtirefresh token 轮换与重用检测的依据
ExpiresAt: jwt.NewNumericDate(refreshExp),
IssuedAt: jwt.NewNumericDate(now),
},
+40
View File
@@ -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")
+68 -8
View File
@@ -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 的 jtiRegisteredClaims.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 来自 DeviceInforeason 正确)。
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)
}
+2 -2
View File
@@ -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)
}
+5
View File
@@ -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{},
+20 -1
View File
@@ -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='登录尝试审计';
-- ------------------------------------------------------------
-- 许可证
-- ------------------------------------------------------------
+12
View File
@@ -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,
+107 -20
View File
@@ -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;
+63 -23
View File
@@ -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。
+159
View File
@@ -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 天:系统锁定,无法登录,需续费或激活新授权码后恢复',
];
}
+13 -1
View File
@@ -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: (_, __) =>
+7
View File
@@ -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);
}
}
+18 -6
View File
@@ -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');
}
}
+18 -12
View File
@@ -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);
+95 -93
View File
@@ -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
/// phasenormal / 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),
);
}
///
+54
View File
@@ -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'))),
],
];
}
+64 -9
View File
@@ -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))),
);
},
),
),
],
);
}
}
+83
View File
@@ -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('授权过期 403phase=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);
});
});
}
+74
View File
@@ -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('只读'));
});
});
}
+17
View File
@@ -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'),
),
+126 -39
View File
@@ -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);
});
}
+16
View File
@@ -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),
+132
View File
@@ -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);
});
}
+2
View File
@@ -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
+2 -2
View File
@@ -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}"
+117
View File
@@ -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** 写入 jtiaccess 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` 均不经请求体传入,无越权面。
+1
View File
@@ -7,6 +7,7 @@ set -euo pipefail
TAG="$1"
VER="${TAG#client-v}"
VER="${VER#v}" # 兼容 build-windows.yml 传裸 v 前缀(如 v1.0.4);pubspec 版本不能带 v
echo "==> compile-windows: version=${VER}"
+44
View File
@@ -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