Files
jiu/backend/internal/service/license.go
T
wangjia 23dff69c62 feat(backend): 授权改为时长兑换券体系 + 退役 ed25519/HMAC + 平台生码工具
- 新增 license_codes 码池表 + model.LicenseCode;licenses 加 tier 档位列
- LicenseService.Redeem:单事务 FOR UPDATE 校验码未用 → 时长叠加(可叠加,0=永久)
  → 写 type/tier/max_devices → 绑设备(超限整笔回滚) → 标记已用 → 即时失效 phase 缓存
  路由仍 POST /license/activate,客户端零破坏
- util.GenerateRedeemCode/NormalizeCode:JIUKU-XXXX-XXXX 短码(crypto/rand)
- cmd/gencode:平台批量生成兑换码并落库;删除 cmd/issue、cmd/genkey
- 退役 ed25519 + HMAC:删 util/license_key、GenerateKey、License 全部 config 字段
  及生产启动私钥校验;trial 改直接建行(无需私钥、去 Fatal)
- tier 档位钩子默认 standard,分档消费模式后续设计
- 测试:Redeem 全场景(叠加/过期重置/永久/一码一次/无效/设备上限回滚)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:14:27 +08:00

300 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"log"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
var (
ErrLicenseNotFound = errors.New("license not found")
ErrLicenseInactive = errors.New("license is inactive")
ErrLicenseExpired = errors.New("license has expired")
ErrDeviceLimitExceed = errors.New("设备数已达上限,请先在其它设备上解绑后再试")
// 兑换券(激活码)相关错误,消息直接面向用户。
ErrCodeNotFound = errors.New("无效激活码")
ErrCodeUsed = errors.New("该激活码已被使用")
ErrCodeVoid = errors.New("该激活码已失效")
)
type LicenseService struct {
db *gorm.DB
}
func NewLicenseService(db *gorm.DB) *LicenseService {
return &LicenseService{db: db}
}
// Redeem 兑换一张激活码(时长券):校验码有效且未使用 → 把时长叠加到门店当前授权的到期时间
// → 标记码已用 → 绑定本设备。整个过程在单事务内,check-then-act 用 FOR UPDATE 锁行,
// 保证一码只能被成功兑换一次(并发下恰一个成功)。
//
// 时长叠加规则:新到期 = max(今天, 当前到期) + duration_daysduration_days=0 视为永久(到期置 NULL)。
func (s *LicenseService) Redeem(shopID uint64, rawCode, deviceID, deviceName, platform string) (*model.License, error) {
code := util.NormalizeCode(rawCode)
if code == "" {
return nil, ErrCodeNotFound
}
var result model.License
err := s.db.Transaction(func(tx *gorm.DB) error {
// 1) 锁定并校验兑换码
var lc model.LicenseCode
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("code = ?", code).First(&lc).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrCodeNotFound
}
return err
}
switch lc.Status {
case "redeemed":
return ErrCodeUsed
case "void":
return ErrCodeVoid
}
now := time.Now()
// 2) 取本店最新有效授权行(锁行);无则新建一行
var lic model.License
err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND is_active = ?", shopID, true).
Order("id DESC").First(&lic).Error
creating := errors.Is(err, gorm.ErrRecordNotFound)
if err != nil && !creating {
return err
}
// 3) 计算叠加后的到期时间
var newExpires *time.Time
if lc.DurationDays > 0 {
base := now
if lic.ExpiresAt != nil && lic.ExpiresAt.After(now) {
base = *lic.ExpiresAt
}
t := base.Add(time.Duration(lc.DurationDays) * 24 * time.Hour)
newExpires = &t
} // duration_days==0 → 永久授权,newExpires 保持 nil
maxDevices := lic.MaxDevices
if lc.MaxDevices > maxDevices {
maxDevices = lc.MaxDevices
}
if maxDevices == 0 {
maxDevices = 1
}
if creating {
lic = model.License{
ShopID: shopID,
LicenseKey: "REDEEM-" + uuid.New().String(),
Type: lc.Type,
Tier: lc.Tier,
ExpiresAt: newExpires,
IsActive: true,
MaxDevices: maxDevices,
}
if err := tx.Create(&lic).Error; err != nil {
return err
}
} else {
// 不动 license_key(保留原值,licenses 行只表示"当前权益"
if err := tx.Model(&lic).Updates(map[string]any{
"type": lc.Type,
"tier": lc.Tier,
"expires_at": newExpires,
"is_active": true,
"max_devices": maxDevices,
}).Error; err != nil {
return err
}
lic.ExpiresAt = newExpires
lic.Type = lc.Type
lic.Tier = lc.Tier
lic.MaxDevices = maxDevices
}
// 4) 绑定本设备(幂等 + max_devices 上限校验)
if err := bindDevice(tx, &lic, deviceID, deviceName, platform); err != nil {
return err
}
// 5) 标记码已用
if err := tx.Model(&model.LicenseCode{}).Where("id = ?", lc.ID).Updates(map[string]any{
"status": "redeemed",
"redeemed_shop_id": shopID,
"redeemed_at": now,
"redeemed_device_id": deviceID,
}).Error; err != nil {
return err
}
result = lic
return nil
})
if err != nil {
return nil, err
}
// 事务提交后再失效 phase 缓存:此刻新到期对其它连接已可见,写权限即时恢复,不必等 30s TTL。
middleware.InvalidateLicensePhase(shopID)
return &result, nil
}
// bindDevice 把设备绑定到许可证(幂等:已绑则更新名称;新设备则校验 max_devices 上限)。
func bindDevice(tx *gorm.DB, lic *model.License, deviceID, deviceName, platform string) error {
if deviceID == "" {
return nil // 无设备信息(如服务端工具调用)则跳过绑定
}
var existing model.LicenseDevice
err := tx.Where("license_id = ? AND device_id = ?", lic.ID, deviceID).First(&existing).Error
if err == nil {
return tx.Model(&existing).Update("device_name", deviceName).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var count int64
if err := tx.Model(&model.LicenseDevice{}).Where("license_id = ?", lic.ID).Count(&count).Error; err != nil {
return err
}
if int(count) >= lic.MaxDevices {
return ErrDeviceLimitExceed
}
return tx.Create(&model.LicenseDevice{
LicenseID: lic.ID,
ShopID: lic.ShopID,
DeviceID: deviceID,
DeviceName: deviceName,
Platform: platform,
}).Error
}
// Verify 验证设备许可证(客户端启动时调用)。
// 通过 license_devices 表查找设备,再加载对应的许可证做有效性检查。
func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, error) {
var dev model.LicenseDevice
if err := s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).First(&dev).Error; err != nil {
return nil, ErrLicenseNotFound
}
var lic model.License
if err := s.db.Where("id = ? AND shop_id = ? AND is_active = 1", dev.LicenseID, shopID).First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
return nil, ErrLicenseExpired
}
return &lic, nil
}
// ShopInfo 返回门店当前授权信息(取最新一条有效许可证)
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 {
// 仅「确无记录」才算无授权;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
err := s.db.Model(&model.LicenseDevice{}).Where("license_id = ?", licenseID).Count(&count).Error
return count, err
}
// ListDevices 列出许可证下所有已绑定设备。
func (s *LicenseService) ListDevices(shopID uint64) ([]model.LicenseDevice, error) {
var devs []model.LicenseDevice
if err := s.db.Where("shop_id = ?", shopID).Order("activated_at DESC").Find(&devs).Error; err != nil {
return nil, err
}
return devs, nil
}
// Deactivate 解绑设备(从 license_devices 删除该条记录)。
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
return s.db.Where("shop_id = ? AND device_id = ?", shopID, deviceID).
Delete(&model.LicenseDevice{}).Error
}
// issueTrialLicense 为门店写入一条 30 天 trial license。直接建行(无需签名/私钥),
// license_key 用合成唯一值占位以满足 NOT NULL/UNIQUE。落库失败返回 error。
func issueTrialLicense(db *gorm.DB, shopID uint64) error {
expiresAt := time.Now().Add(30 * 24 * time.Hour)
lic := model.License{
ShopID: shopID,
LicenseKey: "TRIAL-" + uuid.New().String(),
Type: "trial",
Tier: "standard",
ExpiresAt: &expiresAt,
IsActive: true,
MaxDevices: 1,
}
if err := db.Create(&lic).Error; err != nil {
return err
}
// 注意:phase 缓存失效不在此处做——本函数运行在调用方事务内,提交前失效会留下
// 30s 窗口:并发请求可能在新 license 行可见前用旧 phase 重新填充缓存。
// 失效改由调用方在事务提交后执行(见 ensureTrialOnFirstUse)。
return nil
}
// createTrialLicense 在注册事务中为新门店写入 30 天 trial license。失败仅记日志、不阻断注册。
func createTrialLicense(tx *gorm.DB, shopID uint64) {
if err := issueTrialLicense(tx, shopID); err != nil {
log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err)
}
}