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>
This commit is contained in:
wangjia
2026-06-19 07:34:04 +08:00
parent 2d84bda99a
commit e41085a878
23 changed files with 1248 additions and 74 deletions
+53 -2
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
@@ -174,7 +219,13 @@ func issueTrialLicense(db *gorm.DB, shopID uint64) error {
IsActive: true,
MaxDevices: 1,
}
return db.Create(&lic).Error
if err := db.Create(&lic).Error; err != nil {
return err
}
// 注意:phase 缓存失效不在此处做——本函数运行在调用方事务内,提交前失效会留下
// 30s 窗口:并发请求可能在新 license 行可见前用旧 phase 重新填充缓存。
// 失效改由调用方在事务提交后执行(见 ensureTrialOnFirstUse)。
return nil
}
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。