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>
This commit is contained in:
wangjia
2026-06-19 12:14:27 +08:00
parent 914e2fb533
commit 23dff69c62
20 changed files with 541 additions and 642 deletions
+1 -6
View File
@@ -11,10 +11,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/middleware"
"github.com/wangjia/jiu/backend/internal/service"
"github.com/wangjia/jiu/backend/internal/util"
"github.com/wangjia/jiu/backend/testutil"
)
@@ -301,16 +299,13 @@ func TestAuthHandler_Login_ResponseContainsUserInfo(t *testing.T) {
// #8 心跳 /auth/ping 回带授权概况,客户端据此免去单独轮询 /license/info。
func TestAuthHandler_Ping_ReturnsLicense(t *testing.T) {
db := testutil.SetupTestDB()
priv, _, err := util.GenerateEd25519KeyPair()
require.NoError(t, err)
config.C.License.Ed25519PrivateKey = priv
shop := testutil.CreateTestShop(db, "AHPING")
testutil.CreateTestUser(db, shop.ID, "admin", "password123", "admin")
authSvc := service.NewAuthService(db)
// 登录触发首登自动 trial。
_, _, err = authSvc.Login("AHPING", "admin", "password123", service.DeviceInfo{Platform: "windows"})
_, _, err := authSvc.Login("AHPING", "admin", "password123", service.DeviceInfo{Platform: "windows"})
require.NoError(t, err)
licSvc := service.NewLicenseService(db)
+4 -3
View File
@@ -17,11 +17,12 @@ func NewLicenseHandler(svc *service.LicenseService) *LicenseHandler {
return &LicenseHandler{svc: svc}
}
// Activate POST /api/v1/license/activate
// Activate POST /api/v1/license/activate — 兑换激活码(时长券),把时长叠加到门店授权。
// 路由名保留 activate 以兼容客户端;内部走 Redeem 逻辑。
func (h *LicenseHandler) Activate(c *gin.Context) {
shopID := middleware.GetShopID(c)
var req struct {
LicenseKey string `json:"license_key" binding:"required"`
LicenseKey string `json:"license_key" binding:"required"` // 承载激活码(短码)
DeviceID string `json:"device_id" binding:"required"`
DeviceName string `json:"device_name"`
Platform string `json:"platform"`
@@ -31,7 +32,7 @@ func (h *LicenseHandler) Activate(c *gin.Context) {
return
}
lic, err := h.svc.Activate(shopID, req.LicenseKey, req.DeviceID, req.DeviceName, req.Platform)
lic, err := h.svc.Redeem(shopID, req.LicenseKey, req.DeviceID, req.DeviceName, req.Platform)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
+28 -33
View File
@@ -19,28 +19,32 @@ func TestLicenseHandler_Activate_Success(t *testing.T) {
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
expiry := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID,
LicenseKey: "LHACT-BBBBB-CCCCC-DDDDD",
IsActive: true,
ExpiresAt: &expiry,
MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
// 码池里放一张未用兑换码(30 天)
require.NoError(t, db.Create(&model.LicenseCode{
Code: "JIUKUTEST0001", Type: "annual", Tier: "standard",
DurationDays: 30, MaxDevices: 3, Status: "unused",
}).Error)
// 请求带连字符/会被归一化为 JIUKUTEST0001
w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{
"license_key": "LHACT-BBBBB-CCCCC-DDDDD",
"license_key": "JIUKU-TEST-0001",
"device_id": "device-123",
"device_name": "Test Machine",
"platform": "windows",
})
assert.Equal(t, http.StatusOK, w.Code)
// Verify device was recorded in license_devices
// 兑换后门店生成授权行 + 设备绑定
var lic model.License
require.NoError(t, db.Where("shop_id = ?", shop.ID).First(&lic).Error)
var dev model.LicenseDevice
require.NoError(t, db.Where("license_id = ? AND device_id = ?", lic.ID, "device-123").First(&dev).Error)
assert.Equal(t, "Test Machine", dev.DeviceName)
// 码被标记已用
var lc model.LicenseCode
require.NoError(t, db.Where("code = ?", "JIUKUTEST0001").First(&lc).Error)
assert.Equal(t, "redeemed", lc.Status)
}
func TestLicenseHandler_Activate_MissingFields(t *testing.T) {
@@ -84,40 +88,31 @@ func TestLicenseHandler_Activate_DeviceLimitExceeded(t *testing.T) {
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 既有授权:1 个设备名额已占满
future := time.Now().Add(30 * 24 * time.Hour)
lic := &model.License{
ShopID: shop.ID, LicenseKey: "LHBND-BBBBB-CCCCC-DDDDD", IsActive: true, MaxDevices: 1,
ShopID: shop.ID, LicenseKey: "TRIAL-LH004", IsActive: true, MaxDevices: 1, ExpiresAt: &future,
}
require.NoError(t, db.Create(lic).Error)
// Fill the single allowed slot
require.NoError(t, db.Create(&model.LicenseDevice{
LicenseID: lic.ID, ShopID: shop.ID, DeviceID: "existing-device",
}).Error)
// 码不提升设备上限(max_devices=0
require.NoError(t, db.Create(&model.LicenseCode{
Code: "JIUKUDEVLIMIT1", Type: "annual", Tier: "standard",
DurationDays: 365, MaxDevices: 0, Status: "unused",
}).Error)
w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{
"license_key": "LHBND-BBBBB-CCCCC-DDDDD",
"license_key": "JIUKU-DEVL-IMIT1",
"device_id": "different-device",
})
assert.Equal(t, http.StatusBadRequest, w.Code)
}
func TestLicenseHandler_Activate_Expired(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "LH005")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
expiry := time.Now().Add(-24 * time.Hour)
lic := &model.License{
ShopID: shop.ID, LicenseKey: "LHEXP-BBBBB-CCCCC-DDDDD", IsActive: true, ExpiresAt: &expiry, MaxDevices: 3,
}
require.NoError(t, db.Create(lic).Error)
w := makeRequest(r, "POST", "/api/v1/license/activate", token, map[string]interface{}{
"license_key": "LHEXP-BBBBB-CCCCC-DDDDD",
"device_id": "device-123",
})
assert.Equal(t, http.StatusBadRequest, w.Code)
// 设备超限 → 整笔回滚:码仍未使用
var lc model.LicenseCode
require.NoError(t, db.Where("code = ?", "JIUKUDEVLIMIT1").First(&lc).Error)
assert.Equal(t, "unused", lc.Status)
}
func TestLicenseHandler_Activate_NoAuth(t *testing.T) {