Files
jiu/docs/architecture/license-design.md
T
wangjia f955391830 docs: license-design 增补 v2 时长兑换券模型说明
顶部新增 v2 节,标注 v1 的 Ed25519 token 方案已废弃,记录兑换券
模型/数据表/Redeem 流程/与 v1 差异;运行时降级 phase 两版共用。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:14:35 +08:00

21 KiB
Raw Blame History

授权信息功能 — 完整方案设计

⚠️ 本文档第 111 节为 v1Ed25519 自包含签名 token 方案),已被 v2「时长兑换券」模型取代。 实际线上实现以下方「## v2」为准;下方 v1 各节仅作历史保留,其中「激活时验签」「license_token 列」「AdminOnly 激活」等已不再适用。


v2 实际实现(时长兑换券模型,2026-06 起)

为什么改

v1 把许可证做成「一个 Ed25519 签名 token = 一张完整授权」,但运行时从不验签(签名形同虚设),且 token 内 expires_at绝对日期、不可叠加,不符合「买一张码续 1 年、可买多张累加」的消费直觉。经产品方确认改为时长兑换券

模型要点

  • 时长券:每张码代表一段时长duration_days),兑换 = 新到期 = max(今天, 当前到期) + 时长可叠加duration_days=0 表示永久(到期置 NULL)。
  • 短码 + 服务端码表:码形如 JIUKU-7F3A-9K2Dutil.GenerateRedeemCodecrypto/rand + 无歧义字母表),归一化(util.NormalizeCode)后存 license_codes 表。一码一次由服务端码表 status 强制(签名无法防重放,故 v2 退役 ed25519)。
  • 档位钩子license_codes.tier / licenses.tier(默认 standard),为将来 pro/max-like 分档预留;当前不据此做能力差异,分档消费模式后续设计。
  • 签发归平台方:码由 CLI go run ./cmd/gencode -type annual -days 365 -count N 批量生成落库,用户购买/活动获得后在 App「设置 → 授权」兑换。不开放 App 内自助签发(防店铺管理员自签终身授权)。

数据模型

  • 新表 license_codes(码池):code(uniq) / type / tier / duration_days / max_devices / status(unused/redeemed/void) / redeemed_shop_id / redeemed_at / redeemed_device_id / batch / note。见 model.LicenseCode + schema.sql + testutil
  • licenses 表新增 tier 列(additive)。license_key 列降级为「当前权益占位」:trial 用 TRIAL-<uuid>、无既有行兜底用 REDEEM-<uuid>,兑换扩展时不改原值。v1 的 license_token 列未实现,已废弃。

兑换流程(LicenseService.Redeem,路由仍 POST /license/activate 以兼容客户端)

单事务 + FOR UPDATEcheck-then-act 并发安全):① 锁码校验 unused(否则 无效激活码/已被使用/已失效)→ ② 锁本店最新 is_active 授权行(无则新建)→ ③ 叠加到期、写 type/tier/max_devices=max(现值,码值) → ④ 绑定本设备(幂等 + max_devices 上限,超限整笔回滚)→ ⑤ 标记码 redeemed → ⑥ 提交后 InvalidateLicensePhase 即时生效。

与 v1 的差异(务必注意)

维度 v1(已废弃) v2(现状)
码形态 Ed25519 246 字符 token 短码 JIUKU-XXXX-XXXX + 服务端码表
到期语义 token 内绝对日期 时长券叠加,DB expires_at 为准
验签 计划激活时验签(实际从未生效) 退役 ed25519,改服务端码表查验
签发 cmd/issue 打印 token(需手动 INSERT cmd/gencode 批量生成并落库
激活权限 计划加 AdminOnly 维持 ReadOnly 豁免、不加 AdminOnly(兑换是店主自助行为)
trial Ed25519 签发 直接建行(无需私钥),createTrialLicense 去除 Fatal
运行时降级 同 v2LicenseGuard 读 DB expires_at 实时算 phase30s 缓存) 不变,沿用

v1 第 7 节「授权状态机与拦截层」(CalcLicensePhase / LicenseGuard / grace/readonly/locked已落地且 v2 沿用,是两版共同的运行时基础。


1. 背景与目标

酒库管理系统是按门店付费授权的 SaaS:许可证绑定设备、支持试用/月付/年付/买断。但当前实现是空壳:

  • 虽有 licenses 表和 4 个接口(activate/verify/deactivate/info),但激活时完全不验签,许可证密钥仅做 DB 存在性查询,防伪能力为零。
  • GenerateKey 把 HMAC 截断到 20 字符后无法反向验签,且无任何地方调用它,激活码只能靠人工写入 DB。
  • 注册时不自动发试用许可,新门店开箱即「无授权」。
  • license 路由未加 AdminOnly,普通员工可激活/解绑。
  • 前端授权 Tab 仅只读展示;「续费」只弹邮箱;无过期拦截、无横幅、对应用行为零影响。
  • 客户端无任何设备 ID 采集能力(未引入 device_info_plus)。

目标:建立完整的「签发 → 激活 → 校验 → 过期降级」闭环,兼顾安全(防伪造、防多机盗用)与用户体验(自助换机、清晰过期提示)。


2. 产品决策(已与产品方确认)

试用期:
  触发: 注册成功自动签发
  时长: 30 
  设备上限: 2 

过期降级(宽限窗口):
  day 0~+6:   宽限期(grace)  — 仍可读写,全局橙色横幅 + 启动弹窗提醒
  day +7~+14: 只读期(readonly)— 禁所有写操作,横幅+弹窗变红
  day +15~:   锁定期(locked) — 禁止登录,跳到激活/续费页
  配置项: LICENSE_GRACE_DAYS=7, LICENSE_LOCK_DAYS=15(在 config.go 中定义)

设备绑定:
  策略: 一证多设备(按套餐设上限)
  默认上限: trial=2, monthly/annual/lifetime=3(存 features.max_devices
  换机: 用户可在授权 Tab 自助解绑旧设备后在新设备激活

防伪机制:
  算法: Ed25519 非对称签名
  私钥: 服务端持有(存 Bitwardenrbw get license-ed25519-private
  公钥: 客户端内置(编译期嵌入,无法通过 DB 泄露伪造新 key)

套餐类型(licenses.type 枚举不变):trial / monthly / annual / lifetime


3. 数据模型

3.1 licenses 表改造

-- 新增字段(AutoMigrate 自动补)
ALTER TABLE licenses
  ADD COLUMN max_devices INT NOT NULL DEFAULT 3 AFTER is_active,
  ADD COLUMN license_token TEXT NOT NULL DEFAULT '' AFTER license_key;
  -- license_token: Ed25519 自包含 JWT 格式 token,激活时验签
  -- 原 device_id 列废弃:设备记录迁到 license_devices 表
  -- device_id / activated_at 列保留(向后兼容),新激活通过 license_devices

3.2 新表 license_devices

CREATE TABLE IF NOT EXISTS `license_devices` (
  `id`           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  `license_id`   BIGINT UNSIGNED NOT NULL,
  `shop_id`      BIGINT UNSIGNED NOT NULL,
  `device_id`    VARCHAR(128)    NOT NULL COMMENT '设备唯一标识(客户端稳定 ID)',
  `device_name`  VARCHAR(200)    NOT NULL DEFAULT '' COMMENT '设备名称(主机名)',
  `platform`     VARCHAR(20)     NOT NULL DEFAULT '' COMMENT 'windows/macos/android/ios/web',
  `activated_at` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `last_seen_at` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_license_device` (`license_id`, `device_id`),
  KEY `idx_shop_id` (`shop_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证设备绑定';

3.3 GORM Model 草案

// backend/internal/model/license.go(改造)
type License struct {
    Base
    ShopID       uint64     `gorm:"not null;index"                        json:"shop_id"`
    LicenseKey   string     `gorm:"size:50;uniqueIndex"                   json:"license_key"`
    LicenseToken string     `gorm:"type:text"                             json:"-"` // Ed25519 token,不对外暴露
    Type         string     `gorm:"type:enum('trial','monthly','annual','lifetime');default:'trial'" json:"type"`
    ExpiresAt    *time.Time `                                              json:"expires_at"`
    IsActive     bool       `gorm:"default:true"                          json:"is_active"`
    MaxDevices   int        `gorm:"default:3"                             json:"max_devices"`
    Features     JSON       `gorm:"type:json"                             json:"features,omitempty"`
    ActivatedAt  *time.Time `                                              json:"activated_at"`
    // 废弃字段(保留列兼容旧数据)
    DeviceID     string     `gorm:"size:255"                              json:"-"`
    Devices      []LicenseDevice `gorm:"foreignKey:LicenseID"            json:"devices,omitempty"`
}

// backend/internal/model/license_device.go(新增)
type LicenseDevice struct {
    Base
    LicenseID   uint64    `gorm:"not null;index" json:"license_id"`
    ShopID      uint64    `gorm:"not null;index" json:"shop_id"`
    DeviceID    string    `gorm:"size:128;not null" json:"device_id"`
    DeviceName  string    `gorm:"size:200" json:"device_name"`
    Platform    string    `gorm:"size:20"  json:"platform"`
    ActivatedAt time.Time `                json:"activated_at"`
    LastSeenAt  time.Time `                json:"last_seen_at"`
}

4. License Token 格式(Ed25519 自包含)

License Token 采用 JWT 格式,算法字段 "alg":"EdDSA"

// headerbase64url
{ "alg": "EdDSA", "typ": "LIC" }

// payloadbase64url
{
  "shop_id":    12345,
  "lic_id":     67,
  "type":       "annual",
  "issued_at":  1748000000,
  "expires_at": 1779536000,   // unix0 = 永久
  "max_devices": 3,
  "features":   { "finance": true }
}

// signature: Ed25519(header.payload, private_key)

激活时服务端用内置公钥验签;验签通过且 shop_id 与 JWT 一致后,将 license 记录与设备绑定。客户端可离线验证 token 格式与签名,防止任意字符串被当作有效 key。


5. 后端 API 设计

5.1 权限矩阵

接口 方法 路由 JWT AdminOnly ReadOnly 拦截
查询授权信息 GET /license/info
激活许可证 POST /license/activate ✗(豁免)
设备列表 GET /license/devices
解绑设备 POST /license/devices/unbind ✗(豁免)
解绑当前设备 POST /license/deactivate ✗(豁免)

activate/deactivate/unbind 加 AdminOnly,防普通员工操作许可证。

5.2 GET /license/info

新增返回字段:

{
  "data": {
    "type": "annual",
    "is_active": true,
    "expires_at": "2027-06-09T00:00:00Z",
    "activated_at": "2026-06-09T00:00:00Z",
    "max_devices": 3,
    "bound_devices": 2,
    "phase": "normal",          // normal | grace | readonly | locked
    "days_remaining": 365,       // null = 永久;负数 = 已过期 N 天
    "current_device_bound": true // 当前请求设备是否已绑定(query: device_id
  }
}

5.3 POST /license/activate

// 请求体
{
  "license_key":  "XXXXX-XXXXX-XXXXX-XXXXX",
  "device_id":    "win-{machine-guid}",
  "device_name":  "JiuPC-01",
  "platform":     "windows"
}

// 成功响应
{ "data": { ...License, "devices": [...] } }

// 错误码
// 400: missing fields
// 403: license 不属于当前门店 / is_active=false
// 409: 设备数已达上限(提示先解绑)
// 422: token 验签失败(伪造 key
// 410: license 已过期

处理流程

  1. 验证 Ed25519 签名,失败→422
  2. license_key 查记录,校验 shop_id 匹配 JWT 中 shopIDis_active=true
  3. 检查过期;expires_at 已过→410
  4. license_devices WHERE license_id=? 计数,>= max_devices 且设备不在列表中→409
  5. Upsert license_devices(同设备重复激活更新 last_seen_at),更新 licenses.activated_at

5.4 GET /license/devices

返回当前许可证绑定的设备列表(devices[]),每条含 device_id, device_name, platform, activated_at, last_seen_at

5.5 POST /license/devices/unbind

{ "device_id": "win-{machine-guid}" }

删除 license_devices 对应记录。仅管理员可操作。


6. 注册自动发 trial

AuthService.Register 事务内(auth.go:92-132)注册末尾注入:

// 注入点:创建 user 之后,return nil 之前
trial := model.License{
    ShopID:       shop.ID,
    LicenseKey:   generateTrialKey(shop.ID),  // 简单格式,仅内部标识
    LicenseToken: licSvc.GenerateTrial(shop.ID, 30),  // 签发 30 天 Ed25519 token
    Type:         "trial",
    ExpiresAt:    ptr(time.Now().AddDate(0, 0, 30)),
    IsActive:     true,
    MaxDevices:   2,
}
tx.Create(&trial)

LicenseService.GenerateTrial 用 Ed25519 私钥签发一个 trial token,写入 licenses 表。激活该 trial 无需用户输入 key(Web 和 trial 设备自动激活,桌面端首次启动提示激活)。


7. 授权状态机与拦截层

7.1 Phase 推算

// service/license.go
type Phase string
const (
    PhaseNormal   Phase = "normal"
    PhaseGrace    Phase = "grace"
    PhaseReadOnly Phase = "readonly"
    PhaseLocked   Phase = "locked"
)

func CalcPhase(expiresAt *time.Time) Phase {
    if expiresAt == nil { return PhaseNormal } // 永久
    diff := time.Since(*expiresAt)
    if diff < 0 { return PhaseNormal }
    if diff < 7*24*time.Hour  { return PhaseGrace }
    if diff < 15*24*time.Hour { return PhaseReadOnly }
    return PhaseLocked
}

7.2 后端 LicenseGuard 中间件

复用 ReadOnly()auth.go:63)模式,挂到全局 api 路由组(紧跟 JWT + ReadOnly 之后):

// middleware/license.go
func LicenseGuard(licSvc *service.LicenseService) gin.HandlerFunc {
    return func(c *gin.Context) {
        shopID := GetShopID(c)
        phase := licSvc.GetPhase(shopID) // 查库或读 JWT claim(见下方)

        if phase == PhaseLocked {
            c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
                "error": "license_locked",
                "phase": phase,
            })
            return
        }
        if phase == PhaseReadOnly && c.Request.Method != "GET" {
            // 豁免:activate/deactivate/unbind(让用户能续费)
            if !isLicenseManagementRoute(c.FullPath()) {
                c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
                    "error": "license_readonly",
                    "phase": phase,
                })
                return
            }
        }
        c.Set("license_phase", string(phase))
        c.Next()
    }
}

性能:将 expires_at 写入 JWT Claim(扩展 middleware.Claims),避免每请求查库——续费后 refresh token 自动刷新 claim。

type Claims struct {
    UserID    uint64     `json:"user_id"`
    ShopID    uint64     `json:"shop_id"`
    Role      string     `json:"role"`
    LicExpiry *time.Time `json:"lic_exp,omitempty"` // 新增
    jwt.RegisteredClaims
}

7.3 登录拦截 locked

AuthHandler.Login 成功发 token 之前,判断 phase;若 locked 返回 403 license_locked,前端跳续费页。


8. 前端方案

8.1 文件改动清单

文件 类型 说明
client/lib/models/license.dart 新增 LicenseInfo + LicenseDevice 模型(从 provider 抽出并扩展)
client/lib/repositories/license_repository.dart 新增 封装所有 license API 调用
client/lib/providers/license_provider.dart 修改 改接 repository;新增 phase/deviceId/devices 状态
client/lib/core/platform/device_id.dart 新增 采集设备 IDdevice_info_plus + shared_preferences 持久化)
client/pubspec.yaml 修改 添加 device_info_plus 依赖
client/lib/screens/settings/settings_screen.dart 修改 授权 Tab 重构(激活码 + 设备列表 + 解绑)
client/lib/screens/shell/app_shell.dart 修改 注入 license 横幅 + 到期弹窗

8.2 设备 ID 采集策略

// client/lib/core/platform/device_id.dart
// kIsWeb 先于 dart:io,遵守项目规则
Future<String> getDeviceId() async {
  // Web:无稳定 ID,用 localStorage 存 UUID(随清 cookies 失效,可接受)
  if (kIsWeb) return _webId();
  return switch (defaultTargetPlatform) {
    TargetPlatform.windows => _windowsMachineGuid(),   // reg HKLM\...\MachineGuid
    TargetPlatform.macOS   => _macosSystemGuid(),      // IOPlatformUUID
    TargetPlatform.android => DeviceInfoPlugin().androidInfo.then((i) => i.id),
    TargetPlatform.iOS     => DeviceInfoPlugin().iosInfo.then((i) => i.identifierForVendor ?? _uuid()),
    _                      => _uuid(),                  // fallback
  };
}
// 首次生成后持久化到 shared_preferences,避免二次采集差异

8.3 授权 Tab 重构

激活区is_active=false 或设备未绑定时展示):

  • 激活码输入框(格式提示 XXXXX-XXXXX-XXXXX-XXXXX
  • 「激活」按钮 → POST /license/activate
  • 设备自动填入(device_name = hostnameplatform 自动推断)

当前授权信息(已激活时展示):

  • 类型标签 + 状态 Chip(正常/宽限/只读/已锁定)+ 到期时间 / 「永久有效」
  • 已绑设备数 n / max_devices
  • 设备列表卡片:设备名、平台图标、激活时间、「解绑」按钮(当前设备标记为「此设备」)
  • 「续费 / 升级授权」入口(保留现有联系邮箱逻辑,后续可升为在线续费)

8.4 全局横幅与弹窗(app_shell.dart

复用更新横幅模式app_shell.dart:300-352):

// grace 期:橙色横幅(可关闭)
if (licPhase == 'grace')
  Container(color: Color(0xFFFFF3E0), child: Row([
    Icon(Icons.warning_amber, color: Color(0xFFE65100)),
    Text('授权将在 $daysOverdue 天后进入只读模式,请及时续费'),
    TextButton('立即续费', onTap: _showRenewDialog),
    TextButton('稍后', onTap: _dismiss),
  ]))

// readonly 期:红色横幅(不可关闭)
if (licPhase == 'readonly')
  Container(color: Color(0xFFFFEBEE), child: Row([
    Icon(Icons.lock_outline, color: Colors.red),
    Text('授权已过期,当前为只读模式,无法写入数据'),
    TextButton('立即续费', onTap: _showRenewDialog),
  ]))

启动弹窗addPostFrameCallback,类似强制更新弹窗):

  • grace/readonly 首次进入 shell 弹一次,当天不再重弹(shared_preferences 记录最后弹窗日期)

9. 安全与威胁模型

9.1 Ed25519 签发/验签流程

[运营工具] rbw get license-ed25519-private → go run tools/license-gen/main.go
    --shop-id 12345 --type annual --days 365 --max-devices 3
→ 生成 LicenseTokenEd25519 签名) + LicenseKey(可读短码)
→ 写入 licenses 表(或导出给客户)

[激活时] POST /license/activate
    → 服务端读取内置公钥(config.C.License.Ed25519PublicKeybase64
    → ed25519.Verify(publicKey, header.payload, signature)
    → 通过 → 绑设备

私钥存 Bitwardenrbw get license-ed25519-private;服务端只存公钥(可公开)。即便服务端被拖库,攻击者也无法签发新许可证。

9.2 威胁模型与对策

威胁 对策
伪造 license key Ed25519 验签,无私钥无法生成有效签名 → 激活返回 422
篡改 token 内容(改 expires_at 签名覆盖 payload,任何篡改导致验签失败
多机盗用(key 泄露) max_devices 上限 + 按设备计数,超限拒绝
拖库后批量伪造 私钥不落库,公钥验签流程无私钥无用
修改本地系统时钟绕过过期 phase服务端推算后写入 JWT,客户端无法单方面伪造
普通员工自行激活/解绑 activate/deactivate/unbind 加 AdminOnly
已吊销 license 继续使用 is_active=falseLicenseGuard 拦截 + 联网校验时生效

10. 分期实施计划

以下每项作为独立 todo,按依赖顺序排列:

# 标题 tier 依赖 模块
A 后端:Ed25519 签发工具 + 替换 GenerateKey + 激活验签 1 后端/安全
B 后端:新增 license_devices 表 + 设备绑定/列表/解绑 API 1 A 后端/数据库
C 后端:注册自动发 30 天 trial(事务内注入) 2 A B 后端
D 后端:LicenseGuard 中间件 + JWT claim 扩展 + 登录 locked 拦截 1 B C 后端/安全
E 前端:device_info_plus + device_id 采集模块 2 无(可与 A 并行) 前端
F 前端:License model/repository 重构 + provider 对接 2 E 前端
G 前端:授权 Tab 重构(激活码输入 + 设备列表 + 解绑) 2 D F 前端
H 前端:全局横幅 + 到期弹窗(app_shell gating 2 D F 前端

并行窗口:A 与 E 可同时开始;D 依赖 B/C;G/H 依赖 D/F,但可并行。


11. 验证方式

# 后端
export PATH="/opt/homebrew/bin:$PATH"
cd backend && go build ./... && go test ./... && go vet ./...

# 前端
cd client && flutter analyze --no-fatal-infos --no-fatal-warnings

# 端到端场景
# 1. 注册 → 自动拥有 trial license/license/info 返回 type=trial, expires_at=+30d
# 2. 修改 expires_at = now()-1d → phase=grace → 横幅显示 / 操作可写
# 3. 修改 expires_at = now()-8d → phase=readonly → 写操作返回 403 / 横幅红色
# 4. 修改 expires_at = now()-16d → phase=locked → 登录返回 403 license_locked
# 5. POST /license/activate with 伪造 key → 422 验签失败
# 6. 绑定超 max_devices → 409 提示先解绑
# 7. 管理员解绑设备 → 设备列表减少 → 可重新激活

文档版本:v1.0 | 2026-06-09