# Music_Server 单终端绑定 Token 与时效控制设计 日期:2026-04-20 状态:已确认设计,待实现计划 范围:`Music_Server`、`MusicFree` 插件、`MusicFree` 客户端插件列表页 ## 1. 背景 当前 `Music_Server` 的鉴权逻辑非常简单: - 所有受保护接口只校验 `Authorization: Bearer ` - 服务端把 Bearer 字符串与 `PUBLIC_MUSIC_ACCESS_TOKEN` 做一次直接比较 - 不区分不同终端 - 不支持过期、撤销、解绑、状态查询 这套逻辑足够支撑最初的自用联调,但在当前链路下已经不够用: - `MusicFree` 插件已经支持通过订阅地址自动更新,插件分发更方便后,访问控制也需要更精细 - 你希望一个 token 只能给一个终端使用,避免同一个 token 被多端直接共享 - 你希望 token 本身有失效时间,例如默认 90 天 - 你还希望在 `MusicFree` 插件管理页一眼看到 token 是否可用、剩余多久、是否已绑到别的终端,以及当前服务里已有多少首可播歌曲 本设计要解决的是“可控授权”,不是“强 DRM”。目标是把权限边界收紧到“一个 token 对应一个当前终端,并且 token 自身会过期”,同时保持部署和日常签发仍然足够简单。 ## 2. 目标与非目标 ### 2.1 目标 - 一个 token 在同一时刻只能绑定一个终端 - token 首次被合法终端使用时自动绑定 - token 本身有明确过期时间,默认 90 天 - 支持查看、签发、撤销、解绑 token - `Music_Server` 能返回 token 状态,供 `MusicFree` 展示剩余时间和绑定情况 - `Music_Server` 的状态接口在 token 有效时,同时返回当前全局可播歌曲数 - `MusicFree` 为每个安装实例持久化一个随机 `clientId` - 清缓存或重装后生成新的 `clientId`,服务端视为另一台终端 ### 2.2 非目标 - 不做硬件指纹、IMEI、MAC 地址等强设备识别 - 不做自动续期,token 过期后必须重新签发 - 不做“一个 token 多终端共享配额” - 不做歌单、搜索、播放接口语义调整 - 不修改 `catalog-sync` 的采集、下载、导出链路 ## 3. 设计结论 本轮采用以下方案: 1. `Music_Server` 把 token 状态落到本地可写库 `player.db` 2. token 不明文入库,只保存哈希 3. 业务接口使用“Bearer + clientId”联合校验 4. token 第一次被使用时自动首绑到当前 `clientId` 5. token 一旦绑定后,只允许同一 `clientId` 继续使用 6. token 过期或被撤销后立即失效,不做自动恢复 7. `MusicFree` 客户端为插件维护稳定的随机 `clientId`,插件只负责随请求发送 8. `MusicFree` 插件列表页通过新状态接口显示“剩余时间/绑定状态/可播歌曲数” 推荐原因: - 对现有部署侵入最小,不需要额外数据库服务 - 绑定语义清楚,用户容易理解 - 清缓存后的行为也一致:会变成“另一台终端”,而不是偷偷重置有效期 - 服务端掌握最终状态判断,插件和客户端只展示,不各自发明规则 ## 4. 系统边界 ### 4.1 Music_Server 负责: - token 的签发数据存储 - token 过期、撤销、绑定状态校验 - 首绑与续用时更新时间戳 - 对外提供 token 状态接口 - 从 `catalog_read.db` 聚合当前至少有一个 active 文件位置的歌曲总数 - 为运维提供签发、列出、撤销、解绑命令 不负责: - 识别物理设备真身 - 自动续期 - 为插件生成 UI 文案 ### 4.2 MusicFree 插件 负责: - 从运行环境拿到 `baseUrl`、`accessToken`、`clientId` - 给所有 `Music_Server` 请求附带鉴权头 - 调用 token 状态接口并把结果透传给客户端展示层,包括 `playableSongCount` 不负责: - 本地计算 token 是否过期 - 本地决定绑定是否合法 - 本地生成“剩余 89 天”这种最终展示文案 ### 4.3 MusicFree 客户端 负责: - 为 `Music_Server` 插件保存稳定的随机 `clientId` - 在插件管理列表页展示 token 状态与当前可播歌曲数 - 在用户修改 `accessToken` 后刷新一次状态 不负责: - 直接解析或验证 token - 代替服务端决定绑定、过期、撤销逻辑 ### 4.4 catalog-sync 本轮无改动。它继续只负责歌单采集、下载、上传、导出,不参与 Music_Server 的访问授权。 ## 5. 数据模型 ### 5.1 存储位置 token 元数据写入 `Music_Server` 的 `player.db`。原因: - `catalog_read.db` 是读模型快照,不适合存可写授权状态 - `player.db` 已经承担 `Music_Server` 本地状态数据职责,继续放 token 合理且部署简单 ### 5.2 access_tokens 表 新增表:`access_tokens` 建议字段: - `token_id TEXT PRIMARY KEY` - `token_hash TEXT NOT NULL UNIQUE` - `label TEXT` - `issued_at TEXT NOT NULL` - `expires_at TEXT NOT NULL` - `bound_client_id TEXT` - `bound_client_label TEXT` - `bound_at TEXT` - `last_seen_at TEXT` - `revoked_at TEXT` - `revoked_reason TEXT` 字段语义: - `token_id`:运维侧稳定主键,供 `list/revoke/unbind` 使用 - `token_hash`:token 明文的哈希值,服务端不保存明文 - `label`:签发时填的备注,例如 `iphone16` - `issued_at`:签发时间 - `expires_at`:失效时间 - `bound_client_id`:当前已绑定终端 ID - `bound_client_label`:终端备注,例如 `My iPhone` - `bound_at`:首次绑定时间 - `last_seen_at`:最近一次成功校验时间 - `revoked_at`:撤销时间,非空即永久失效 - `revoked_reason`:撤销备注,可空 建议索引: - `UNIQUE INDEX idx_access_tokens_token_hash(token_hash)` - `INDEX idx_access_tokens_expires_at(expires_at)` - `INDEX idx_access_tokens_bound_client_id(bound_client_id)` ### 5.3 token 明文格式 token 明文设计为高熵随机字符串,例如: ```text msv1_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ``` 约束: - 明文只在签发时输出一次 - 服务端落库时只保存哈希 - 后续所有管理动作以 `token_id` 为主,不依赖再次输入明文 ## 6. 鉴权与绑定流程 ### 6.1 请求头约定 所有受保护的 `Music_Server` 接口统一接受: - `Authorization: Bearer ` - `X-Music-Client-Id: ` - `X-Music-Client-Label: ` 可选 其中: - `clientId` 是 `MusicFree` 客户端为该插件实例持久化的随机 ID - `clientLabel` 是可选终端备注,不参与唯一性判断,只用于展示 ### 6.2 校验顺序 服务端按以下顺序处理: 1. 校验 `Authorization` 是否存在且格式正确 2. 校验 `X-Music-Client-Id` 是否存在 3. 根据 token 明文哈希查 `access_tokens` 4. 若 token 不存在,拒绝 5. 若 `revoked_at` 非空,拒绝 6. 若当前时间超过 `expires_at`,拒绝 7. 若 `bound_client_id` 为空,则原子地绑定到当前 `clientId` 8. 若 `bound_client_id` 等于当前 `clientId`,允许通过并更新 `last_seen_at` 9. 若 `bound_client_id` 不等于当前 `clientId`,拒绝 ### 6.3 首绑规则 首绑发生在“第一次成功访问受保护接口”时,而不是签发时。 这样做的原因: - 不需要在签发时就知道具体终端 - token 可以先发出去,再由用户首次在设备上配置 - 终端备注也能在首次请求时一并带上 这里的“受保护接口”包括业务接口,也包括 `GET /auth/v1/token-status`。也就是说,用户第一次在 `MusicFree` 里配置好插件后,插件列表页如果先请求状态接口,也可以完成首绑。 ### 6.4 清缓存/重装行为 `MusicFree` 清缓存或卸载重装后,会生成新的 `clientId`。在本设计里,这被视为另一台终端: - token 原有过期时间不变 - 不会重置 90 天倒计时 - 若 token 已绑到旧 `clientId`,新 `clientId` 再使用会被拒绝 - 用户需要执行 `unbind-token` 或重新签发一个新 token 这与用户已确认的预期一致。 ## 7. 状态接口设计 ### 7.1 路由 新增: - `GET /auth/v1/token-status` ### 7.2 输入 请求头与业务接口一致: - `Authorization` - `X-Music-Client-Id` - `X-Music-Client-Label` 可选 ### 7.3 输出 建议统一返回: ```json { "valid": true, "status": "active", "tokenId": "tok_01", "label": "iphone16", "issuedAt": "2026-04-20T08:00:00Z", "expiresAt": "2026-07-19T08:00:00Z", "remainingSeconds": 7776000, "remainingDays": 90, "playableSongCount": 12345, "bound": true, "isCurrentClientBound": true, "boundClientLabel": "My iPhone" } ``` 其中 `playableSongCount` 的语义固定为:当前 `Music_Server` 读模型中,至少存在一个 active 文件位置的歌曲总数。 返回策略: - 当 `status = active` 且统计成功时,返回实际整数值 - 当 token 不可用,或当前无法可靠统计时,返回 `null` - 插件列表页只在该字段为数值时展示“可播 N 首” 状态码约定: - `401`:`Authorization` 缺失或 Bearer 格式非法 - `200`:Bearer 格式合法且已进入状态判断,具体状态写在 body 中 `status` 枚举: - `active` - `expired` - `revoked` - `bound_to_other_client` - `token_not_found` - `client_id_missing` 设计原因: - 业务接口继续严格返回 `401` - 状态接口允许把“为什么不可用”讲清楚,方便插件列表页展示 - `X-Music-Client-Id` 缺失时,状态接口返回 `200 + status=client_id_missing`,而不是直接短路成 `401` ### 7.4 业务接口错误码 受保护业务接口继续使用 `401`,但建议响应 body 带明确 `detail` code: - `unauthorized` - `client_id_missing` - `token_not_found` - `token_expired` - `token_revoked` - `token_bound_to_other_client` 这能让插件或调试日志更容易定位问题。 ## 8. 管理命令设计 ### 8.1 目标命令 本轮提供四个命令: - `issue-token` - `list-tokens` - `revoke-token` - `unbind-token` ### 8.2 推荐命令行形式 签发: ```bash python -m music_server.tools.issue_token --days 90 --label iphone16 ``` 列出: ```bash python -m music_server.tools.list_tokens ``` 撤销: ```bash python -m music_server.tools.revoke_token --token-id tok_01 --reason replaced ``` 解绑: ```bash python -m music_server.tools.unbind_token --token-id tok_01 ``` ### 8.3 命令语义 `issue-token` - 默认 `--days 90` - 输出 `token_id`、明文 token、`expires_at` - 明文 token 只在这一步展示 `list-tokens` - 默认列出未撤销 token - 至少显示:`token_id`、`label`、`expires_at`、是否已绑定、绑定备注、最近使用时间、是否已撤销 `revoke-token` - 设置 `revoked_at` - 一旦撤销,不可恢复使用 `unbind-token` - 清空 `bound_client_id` - 清空 `bound_client_label` - 清空 `bound_at` - 不修改 `expires_at` - token 若未过期,下一次可重新绑定到新终端 ## 9. MusicFree 插件设计 ### 9.1 插件用户变量 插件用户变量保持最小集: - `baseUrl` - `accessToken` 不把 `clientId` 暴露为用户可编辑输入项,避免用户随手改掉后破坏绑定语义。 ### 9.2 clientId 存储位置 `clientId` 由 `MusicFree` 客户端生成并持久化到插件私有元数据,而不是存到插件的 `userVariables`: - `userVariables` 是面向用户可编辑的 - `clientId` 应该是客户端私有运行时状态 建议在插件元数据存储中新增类似键位: - `${pluginPlatform}.runtimeClientId` - `${pluginPlatform}.runtimeClientLabel` 插件运行环境通过注入的 `env` 读取它,而不是自己写文件。 ### 9.3 插件请求行为 插件对所有 `Music_Server` 请求统一附带: - `Authorization: Bearer ` - `X-Music-Client-Id: ` - `X-Music-Client-Label: ` 可选 插件新增一个轻量状态查询方法,供客户端列表页调用: - 调用 `GET /auth/v1/token-status` - 原样返回服务端状态结果,包括 `playableSongCount` ## 10. MusicFree 客户端展示设计 ### 10.1 展示位置 只在插件管理列表页展示,不扩散到更多页面。 目标位置: - `src/pages/setting/settingTypes/pluginSetting/components/pluginItem.tsx` ### 10.2 展示策略 只对 `Music_Server` 插件显示状态信息。推荐文案映射: - `active` 且剩余大于 1 天:主文案为 `Token 剩余 89 天` - `active` 且剩余不足 1 天:主文案为 `Token 今日到期` - `expired`:`Token 已过期` - `revoked`:`Token 已撤销` - `bound_to_other_client`:`Token 已绑定其他终端` - `token_not_found`:`Token 无效` - 请求失败:`Token 状态获取失败` 当满足以下条件时: - `status = active` - `playableSongCount` 为数值 则在主文案后追加: - `可播 12345 首` 组合后的推荐展示形态: - `Token 剩余 89 天 · 可播 12345 首` 若服务端返回: - `valid = true` - `isCurrentClientBound = true` 则可附加补充文案: - `已绑定当前终端` ### 10.3 刷新时机 不做高频轮询,采用轻量刷新: - 打开插件列表页时刷新一次 - 用户保存 `accessToken` 后刷新一次 - 手动更新插件后刷新一次 这样能拿到足够新的状态,同时避免插件列表页每秒请求服务端。 ## 11. 边界行为 ### 11.1 已绑定 token 再被别的终端使用 结果: - 业务接口返回 `401` - 状态接口返回 `200 + status=bound_to_other_client` ### 11.2 token 到期 结果: - 所有业务接口拒绝 - 状态页显示已过期 - 只能重新签发新 token,不能靠刷新 clientId 恢复 ### 11.3 token 被解绑 结果: - 旧终端下一次请求会因“未再次绑定”而进入重新首绑流程 - 哪个终端先成功请求,token 就重新绑给谁 - 解绑不延长 token 生命周期 ### 11.4 token 被撤销 结果: - 所有终端全部失效 - 即便原绑定终端也不能继续使用 ### 11.5 用户只改 baseUrl,不改 token 如果切换到另一台 `Music_Server`: - 新服务端把它当作独立 token 空间 - token 是否可用只由新服务端自己的 `access_tokens` 决定 ## 12. 测试与验收 ### 12.1 Music_Server 新增测试覆盖: - token 签发后可被首绑 - 已绑定同一 `clientId` 可继续访问 - 不同 `clientId` 被拒绝 - 过期 token 被拒绝 - 撤销 token 被拒绝 - `unbind-token` 后可重新首绑 - `GET /auth/v1/token-status` 返回正确状态,并在有效 token 下带上 `playableSongCount` ### 12.2 MusicFree 插件 新增测试覆盖: - 请求头正确携带 `Authorization` - 请求头正确携带 `X-Music-Client-Id` - 状态接口调用正确,并能透传 `playableSongCount` - 缺少 `baseUrl` 或 `accessToken` 时给出明确错误 ### 12.3 MusicFree 客户端 新增测试覆盖: - `Music_Server` 插件列表项显示 token 状态行 - 不同状态映射为正确文案 - `playableSongCount` 为数值时,列表项追加显示“可播 N 首” - 保存用户变量后会刷新状态 - 插件私有 `clientId` 可持久化复用 ### 12.4 验收标准 以下条件同时满足即视为完成: 1. 新签发 token 默认 90 天有效 2. 第一次在 `MusicFree` 配置后会自动绑定当前终端 3. 同一个 token 在第二台终端上会被拒绝 4. 清缓存后生成新 `clientId`,旧 token 不会自动恢复可用 5. `unbind-token` 后,未过期 token 能重新绑定到新终端 6. 插件管理列表页能看到剩余时间或失败原因;当 token 有效且统计成功时,还能看到可播歌曲数 7. 业务接口不会因为状态展示需求而放松鉴权 ## 13. 实施边界 本 spec 只覆盖: - `D:\source\musicdl-catalog-sync-worktrees\Music_Server` - `D:\source\MusicFree` 不回到旧仓库 `D:\source\musicdl` 实现这部分功能。 本轮完成后,下一步应进入实现计划阶段,再拆成: - `Music_Server` token 存储与认证 - `MusicFree` 插件请求头与状态查询 - `MusicFree` 客户端插件列表状态与可播歌曲数展示