Files
jiu/docs/architecture/license-design.md
T
2026-06-09 23:53:35 +08:00

467 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 授权信息功能 — 完整方案设计
> 本文档覆盖「付费授权」功能的产品决策、数据模型、API 设计、安全方案、前端方案及分期实施计划。所有编码工作在本文档评审通过后按计划分批推进。
---
## 1. 背景与目标
酒库管理系统是按门店付费授权的 SaaS:许可证绑定设备、支持试用/月付/年付/买断。但当前实现是空壳:
- 虽有 `licenses` 表和 4 个接口(activate/verify/deactivate/info),但**激活时完全不验签**,许可证密钥仅做 DB 存在性查询,防伪能力为零。
- `GenerateKey` 把 HMAC 截断到 20 字符后无法反向验签,且无任何地方调用它,激活码只能靠人工写入 DB。
- 注册时不自动发试用许可,新门店开箱即「无授权」。
- license 路由未加 `AdminOnly`,普通员工可激活/解绑。
- 前端授权 Tab 仅只读展示;「续费」只弹邮箱;无过期拦截、无横幅、对应用行为零影响。
- 客户端无任何设备 ID 采集能力(未引入 device_info_plus)。
**目标**:建立完整的「签发 → 激活 → 校验 → 过期降级」闭环,兼顾安全(防伪造、防多机盗用)与用户体验(自助换机、清晰过期提示)。
---
## 2. 产品决策(已与产品方确认)
```yaml
试用期:
触发: 注册成功自动签发
时长: 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` 表改造
```sql
-- 新增字段(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`
```sql
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 草案
```go
// 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"`
```json
// 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
新增返回字段:
```json
{
"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
```json
// 请求体
{
"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 中 shopID`is_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
```json
{ "device_id": "win-{machine-guid}" }
```
删除 `license_devices` 对应记录。仅管理员可操作。
---
## 6. 注册自动发 trial
`AuthService.Register` 事务内(`auth.go:92-132`)注册末尾注入:
```go
// 注入点:创建 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 推算
```go
// 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 之后):
```go
// 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。
```go
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 采集策略
```dart
// 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):
```dart
// 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)
→ 通过 → 绑设备
```
私钥**仅**存 Bitwarden`rbw 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=false``LicenseGuard` 拦截 + 联网校验时生效 |
---
## 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. 验证方式
```bash
# 后端
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*