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
+90
View File
@@ -0,0 +1,90 @@
// gencode — 平台方批量生成兑换券(激活码)并写入码池 license_codes。
//
// 用法(在 backend/ 目录下执行,读 config/env 取数据库 DSN):
//
// go run ./cmd/gencode -type annual -days 365 -count 100 -batch 2026-summer
// go run ./cmd/gencode -days 30 -count 10 -note "试用补偿"
// go run ./cmd/gencode -type lifetime -days 0 -count 1 # 永久授权码
//
// 生成的码以 JIUKU-XXXX-XXXX 格式打印(status=unused),分发给用户在 App 内兑换。
package main
import (
"flag"
"fmt"
stdlog "log"
"os"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/wangjia/jiu/backend/config"
"github.com/wangjia/jiu/backend/internal/model"
"github.com/wangjia/jiu/backend/internal/util"
)
func main() {
licType := flag.String("type", "annual", "计费/时长标签:trial|monthly|annual|lifetime")
tier := flag.String("tier", "standard", "档位(当前仅 standard")
days := flag.Int("days", 365, "授予时长(天);0 = 永久")
devices := flag.Int("devices", 0, "授予设备上限;0 = 兑换时不改变门店现值")
count := flag.Int("count", 1, "生成数量")
batch := flag.String("batch", "", "发放批次/活动名(便于追踪)")
note := flag.String("note", "", "备注")
flag.Parse()
if *count < 1 {
fmt.Fprintln(os.Stderr, "error: -count 必须 >= 1")
os.Exit(1)
}
config.Load()
db, err := gorm.Open(mysql.Open(config.C.Database.DSN), &gorm.Config{
Logger: logger.New(
stdlog.New(os.Stdout, "\r\n", stdlog.LstdFlags),
logger.Config{LogLevel: logger.Warn, IgnoreRecordNotFoundError: true},
),
})
if err != nil {
stdlog.Fatalf("连接数据库失败: %v", err)
}
if err := db.AutoMigrate(&model.LicenseCode{}); err != nil {
stdlog.Fatalf("迁移 license_codes 失败: %v", err)
}
display := make([]string, 0, *count)
for i := 0; i < *count; i++ {
// 重试避免极小概率的唯一冲突
var shown string
for attempt := 0; attempt < 5; attempt++ {
shown = util.GenerateRedeemCode()
lc := model.LicenseCode{
Code: util.NormalizeCode(shown),
Type: *licType,
Tier: *tier,
DurationDays: *days,
MaxDevices: *devices,
Status: "unused",
Batch: *batch,
Note: *note,
}
err := db.Create(&lc).Error
if err == nil {
break
}
if attempt == 4 {
stdlog.Fatalf("写入兑换码失败(连续冲突): %v", err)
}
}
display = append(display, shown)
}
fmt.Printf("\n✅ 已生成 %d 个兑换码(type=%s tier=%s 时长=%d天 设备=%d batch=%q)于 %s\n\n",
*count, *licType, *tier, *days, *devices, *batch, time.Now().Format("2006-01-02 15:04:05"))
for _, c := range display {
fmt.Println(" " + c)
}
fmt.Println()
}