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:
@@ -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()
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// genkey generates an Ed25519 keypair for license signing.
|
||||
// Run once; store the private key in Bitwarden and set the public key in config.
|
||||
//
|
||||
// Usage: go run ./cmd/genkey
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
priv, pub, err := util.GenerateEd25519KeyPair()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to generate keypair: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("=== Ed25519 License Keypair ===")
|
||||
fmt.Println()
|
||||
fmt.Println("[Bitwarden] Private key (keep secret, never commit):")
|
||||
fmt.Println(priv)
|
||||
fmt.Println()
|
||||
fmt.Println("[Config / LICENSE_ED25519_PUBLIC_KEY] Public key:")
|
||||
fmt.Println(pub)
|
||||
fmt.Println()
|
||||
|
||||
// Demo: issue and verify a sample token to confirm the keypair works
|
||||
now := time.Now()
|
||||
exp := now.Add(30 * 24 * time.Hour).Unix()
|
||||
sample := util.LicensePayload{
|
||||
ShopID: 1,
|
||||
LicenseID: 1,
|
||||
Type: "trial",
|
||||
IssuedAt: now.Unix(),
|
||||
ExpiresAt: &exp,
|
||||
MaxDevices: 3,
|
||||
}
|
||||
token, err := util.IssueLicenseToken(sample, priv)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "demo sign failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
verified, err := util.VerifyLicenseToken(token, pub)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "demo verify failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
out, _ := json.MarshalIndent(verified, "", " ")
|
||||
fmt.Println("[Demo] Sample token (30-day trial, shop_id=1):")
|
||||
fmt.Println(token)
|
||||
fmt.Println()
|
||||
fmt.Println("[Demo] Verified payload:")
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// issue signs a license token for a specific shop.
|
||||
// Usage: go run ./cmd/issue -shop 1 -days 365 -type annual -key <base64-private-key>
|
||||
// Or use env var: LICENSE_ED25519_PRIVATE_KEY=<key> go run ./cmd/issue -shop 1 -days 365
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/jiu/backend/internal/util"
|
||||
)
|
||||
|
||||
func main() {
|
||||
shopID := flag.Uint64("shop", 0, "shop ID (required)")
|
||||
licenseID := flag.Uint64("license", 0, "license record ID (optional, 0 = omit)")
|
||||
days := flag.Int("days", 365, "validity days; 0 = perpetual (no expiry)")
|
||||
licType := flag.String("type", "annual", "license type: trial | annual | lifetime")
|
||||
maxDevices := flag.Int("devices", 3, "max devices")
|
||||
privKey := flag.String("key", "", "Ed25519 private key (base64); falls back to LICENSE_ED25519_PRIVATE_KEY env")
|
||||
flag.Parse()
|
||||
|
||||
if *shopID == 0 {
|
||||
fmt.Fprintln(os.Stderr, "error: -shop is required")
|
||||
flag.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
key := *privKey
|
||||
if key == "" {
|
||||
key = os.Getenv("LICENSE_ED25519_PRIVATE_KEY")
|
||||
}
|
||||
if key == "" {
|
||||
fmt.Fprintln(os.Stderr, "error: provide -key or set LICENSE_ED25519_PRIVATE_KEY")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
payload := util.LicensePayload{
|
||||
ShopID: *shopID,
|
||||
Type: *licType,
|
||||
IssuedAt: now.Unix(),
|
||||
MaxDevices: *maxDevices,
|
||||
}
|
||||
if *licenseID > 0 {
|
||||
payload.LicenseID = *licenseID
|
||||
}
|
||||
if *days > 0 {
|
||||
exp := now.Add(time.Duration(*days) * 24 * time.Hour).Unix()
|
||||
payload.ExpiresAt = &exp
|
||||
}
|
||||
|
||||
token, err := util.IssueLicenseToken(payload, key)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "sign failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
out, _ := json.MarshalIndent(payload, "", " ")
|
||||
fmt.Println("=== License Token ===")
|
||||
fmt.Println(token)
|
||||
fmt.Println()
|
||||
fmt.Println("=== Payload ===")
|
||||
fmt.Println(string(out))
|
||||
if payload.ExpiresAt != nil {
|
||||
fmt.Printf("\nExpires: %s\n", time.Unix(*payload.ExpiresAt, 0).Format("2006-01-02 15:04:05"))
|
||||
} else {
|
||||
fmt.Println("\nExpires: never (perpetual)")
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ var allModels = []any{
|
||||
&model.Shop{},
|
||||
&model.User{},
|
||||
&model.License{},
|
||||
&model.LicenseCode{},
|
||||
&model.ProductCategory{},
|
||||
&model.Product{},
|
||||
&model.ProductNameOption{},
|
||||
|
||||
Reference in New Issue
Block a user