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
This commit is contained in:
wangjia
2026-06-18 07:59:44 +08:00
parent 2ac1cbfb24
commit 64a64e7c0a
3 changed files with 81 additions and 7 deletions
+25
View File
@@ -3,6 +3,7 @@ package service
import (
"errors"
"fmt"
"log"
"sync"
"time"
@@ -127,6 +128,11 @@ 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)
@@ -416,6 +422,25 @@ func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
return s.issueTokens(user.ID, user.ShopID, user.Role, claims.SID)
}
// ensureTrialOnFirstUse 门店首次使用(尚无任何 is_active 授权)时自动签发 30 天 trial,
// 使「未激活」门店在首次登录后即转为试用版;后续到期降级(grace/readonly/locked)链路照常生效。
// 已有有效授权(含已过期但未锁定的 trial/付费)则跳过,不重复发放。
// 签发失败仅记日志、不阻断登录(保持可用,门店维持未激活)。
func (s *AuthService) ensureTrialOnFirstUse(shopID uint64) {
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] count licenses for shop %d failed: %v", shopID, err)
return
}
if count > 0 {
return
}
if err := issueTrialLicense(s.db, shopID); err != nil {
log.Printf("[license] auto-trial for shop %d skipped: %v", shopID, err)
}
}
func (s *AuthService) checkLicenseNotLocked(shopID uint64) error {
var lic model.License
if err := s.db.Where("shop_id = ? AND is_active = 1", shopID).