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_days;duration_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) } }