From 8925646eefbdf75fb17c9bc02cd9db6817b1ec09 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Tue, 9 Jun 2026 23:53:35 +0800 Subject: [PATCH] =?UTF-8?q?docs(architecture):=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=BF=A1=E6=81=AF=E5=8A=9F=E8=83=BD=E5=AE=8C=E6=95=B4=E6=96=B9?= =?UTF-8?q?=E6=A1=88=E8=AE=BE=E8=AE=A1=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- docs/architecture/license-design.md | 466 ++++++++++++++++++++++++++++ 1 file changed, 466 insertions(+) create mode 100644 docs/architecture/license-design.md diff --git a/docs/architecture/license-design.md b/docs/architecture/license-design.md new file mode 100644 index 0000000..291811b --- /dev/null +++ b/docs/architecture/license-design.md @@ -0,0 +1,466 @@ +# 授权信息功能 — 完整方案设计 + +> 本文档覆盖「付费授权」功能的产品决策、数据模型、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 非对称签名 + 私钥: 服务端持有(存 Bitwarden,rbw 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 +// header(base64url) +{ "alg": "EdDSA", "typ": "LIC" } + +// payload(base64url) +{ + "shop_id": 12345, + "lic_id": 67, + "type": "annual", + "issued_at": 1748000000, + "expires_at": 1779536000, // unix;0 = 永久 + "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` | 新增 | 采集设备 ID(device_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 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 = hostname,platform 自动推断) + +**当前授权信息**(已激活时展示): +- 类型标签 + 状态 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 +→ 生成 LicenseToken(Ed25519 签名) + LicenseKey(可读短码) +→ 写入 licenses 表(或导出给客户) + +[激活时] POST /license/activate + → 服务端读取内置公钥(config.C.License.Ed25519PublicKey,base64) + → 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*