Files
jiu/backend/internal/service/license.go
T
wangjia c402ea0070 feat(backend): license_devices 表 + 注册自动签发 trial (21B+21C)
21B — DB & model:
- licenses: 扩展 license_key 为 2048 字节(容纳 Ed25519 JWT),
  新增 max_devices INT DEFAULT 3,device_id/activated_at 标为 deprecated
- 新增 license_devices 表(license_id+device_id 唯一索引)
- model/license_device.go:LicenseDevice struct
- main.go AutoMigrate 加入 LicenseDevice
- testutil/setup.go 同步 SQLite DDL

21C — trial at register:
- config: 新增 Ed25519PrivateKey 配置项(LICENSE_ED25519_PRIVATE_KEY 环境变量)
- service/license.go: createTrialLicense(tx, shopID) — 签发 30d trial,
  私钥未配置时静默跳过(开发/测试不影响)
- service/auth.go: Register 事务末尾调用 createTrialLicense

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:42:56 +08:00

140 lines
4.1 KiB
Go

package service
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base32"
"errors"
"fmt"
"log"
"strings"
"time"
"gorm.io/gorm"
"github.com/wangjia/jiu/backend/config"
"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")
ErrDeviceMismatch = errors.New("license is bound to another device")
)
type LicenseService struct {
db *gorm.DB
}
func NewLicenseService(db *gorm.DB) *LicenseService {
return &LicenseService{db: db}
}
// GenerateKey 生成许可证激活码
// 格式:HMAC-SHA256(shopID+licenseType+expiry, secret) → base32, 每5字符加'-'
func GenerateKey(shopID uint64, licenseType string, expiresAt *time.Time) string {
payload := fmt.Sprintf("%d:%s", shopID, licenseType)
if expiresAt != nil {
payload += ":" + expiresAt.Format("20060102")
}
mac := hmac.New(sha256.New, []byte(config.C.License.HMACSecret))
mac.Write([]byte(payload))
raw := base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(mac.Sum(nil))
// 截取前20字符,分4段,每段5字符
raw = strings.ToUpper(raw)[:20]
return fmt.Sprintf("%s-%s-%s-%s", raw[0:5], raw[5:10], raw[10:15], raw[15:20])
}
// Activate 激活许可证(绑定设备)
func (s *LicenseService) Activate(licenseKey, deviceID string) (*model.License, error) {
var lic model.License
if err := s.db.Where("license_key = ?", licenseKey).First(&lic).Error; err != nil {
return nil, ErrLicenseNotFound
}
if !lic.IsActive {
return nil, ErrLicenseInactive
}
if lic.ExpiresAt != nil && time.Now().After(*lic.ExpiresAt) {
return nil, ErrLicenseExpired
}
// 若已绑定设备,校验是否一致
if lic.DeviceID != "" && lic.DeviceID != deviceID {
return nil, ErrDeviceMismatch
}
now := time.Now()
lic.DeviceID = deviceID
lic.ActivatedAt = &now
s.db.Save(&lic)
return &lic, nil
}
// Verify 验证(客户端启动时调用)
func (s *LicenseService) Verify(shopID uint64, deviceID string) (*model.License, error) {
var lic model.License
if err := s.db.Where("shop_id = ? AND device_id = ? AND is_active = 1", shopID, deviceID).
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 {
return nil, ErrLicenseNotFound
}
return &lic, nil
}
// Deactivate 解绑设备(换机时使用)
func (s *LicenseService) Deactivate(shopID uint64, deviceID string) error {
return s.db.Model(&model.License{}).
Where("shop_id = ? AND device_id = ?", shopID, deviceID).
Updates(map[string]interface{}{"device_id": "", "activated_at": nil}).Error
}
// createTrialLicense 在注册事务中为新门店签发 30 天 trial license。
// 若私钥未配置则静默跳过(开发/测试环境可不配置私钥)。
func createTrialLicense(tx *gorm.DB, shopID uint64) {
privKey := config.C.License.Ed25519PrivateKey
if privKey == "" {
log.Printf("[license] Ed25519 private key not configured, skipping trial for shop %d", shopID)
return
}
expiresAt := time.Now().Add(30 * 24 * time.Hour)
expiresUnix := expiresAt.Unix()
payload := util.LicensePayload{
ShopID: shopID,
Type: "trial",
IssuedAt: time.Now().Unix(),
ExpiresAt: &expiresUnix,
MaxDevices: 1,
}
token, err := util.IssueLicenseToken(payload, privKey)
if err != nil {
log.Printf("[license] failed to issue trial token for shop %d: %v", shopID, err)
return
}
lic := model.License{
ShopID: shopID,
LicenseKey: token,
Type: "trial",
ExpiresAt: &expiresAt,
IsActive: true,
MaxDevices: 1,
}
if err := tx.Create(&lic).Error; err != nil {
log.Printf("[license] failed to create trial license for shop %d: %v", shopID, err)
}
}