credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
277 lines
20 KiB
Markdown
277 lines
20 KiB
Markdown
# Goal Spec:凭据库(密码 + 2FA)与账号体系联动
|
||
|
||
> 供 goal 机制执行的规格说明。执行顺序:M0 → M1 → M2 → M3(M4 待用户圈选后启动)。
|
||
|
||
## 1. Goal Objective(可复制的目标摘要)
|
||
|
||
为 vps-manager 增加统一的「凭据库」:新建 `credentials` 表(站点 × 登录方式粒度,存用户名/密码/2FA secret/URL/备注),作为登录凭据的**唯一事实源**;平台账号(Account)保留「资产归属 + API 配置」语义,通过 `credential_id` 关联其登录凭据,账号密码的读写全部重定向到凭据层;提供独立的密码库前端视图(搜索/复制/授权登录标记)与 TOTP 动态验证码展示(录入需校验码验证);编写幂等迁移把历史 `accounts.login_password_encrypted` 迁入凭据表并清空原字段;全程不新增 Python 依赖(TOTP 按 RFC 6238 自实现)。
|
||
|
||
**同时建立 MASTER_KEY 密钥托管(M0,最先落地)**:系统生成随机 RESTORE_KEY(离线抄写保存 2~3 份),用其加密 MASTER_KEY 生成 `master_key.escrow` 随 data/ 备份流转;MASTER_KEY 遗失时运行一次性恢复脚本输入 RESTORE_KEY 找回并回写 .env,全量密文零迁移恢复,日常运行零接触 escrow、零新增网络接口。
|
||
|
||
本地 pytest + uvicorn 端到端验证截图后提交,再由 Gitea → cc1 timer 自动部署并做生产验证。
|
||
|
||
## 2. 领域模型决策(已与用户确认)
|
||
|
||
1. **凭据粒度 = 站点 × 登录方式**。同一 gmail 注册 A~F 六个网站 = 六条 credential,邮箱只是 `username` 的取值,重复出现是常态而非冗余。
|
||
2. **密码相同不共享**。A/B 站都是 `xxx` 也各存一份密文(改密逐站发生,禁止联动耦合)。
|
||
3. **授权登录(OAuth/SSO)是一条正常记录**:`login_type='oauth'` + `oauth_provider='google'` 等,`password` 为空,仍占一条便于检索回忆。
|
||
4. **2FA 挂在 credential 上**(登录凭据的一部分,非独立孤岛);账号体系通过关联的凭据使用 2FA。
|
||
5. **唯一事实源 = credentials 表**。`accounts.login_password_encrypted` 迁移后清空、停止写入;Account 的 `login_user` 字段保留(非机密,供展示);`api_config_encrypted`(SDK 用)留在 Account 不动。
|
||
6. **账号与凭据解耦生命周期**:删除账号不影响其凭据条目(凭据库独立留存);账号侧新建/改密自动同步到凭据。
|
||
7. **TOTP 不引第三方库**:`app/core/totp.py` 自实现 RFC 6238(HMAC-SHA1 + Base32,6 位 / 30s 步长),原因:零 SSH 部署链路不重装依赖,避免 `update.sh` 缺包导致自毁;用 RFC 6238 附录 B 官方向量做 pytest 锚定。
|
||
8. `site` 显示平台中文名(迁移时 join providers),无平台记录时回退 platform 原文 / "未分类"。
|
||
9. **MASTER_KEY 防遗失 = Key Escrow(托管),而非"后补万能钥匙"**:Fernet 单钥设计下 key 遗失后补的钥匙解不开旧密文,恢复能力必须预先建立(见 §3)。
|
||
|
||
## 3. MASTER_KEY 密钥托管(M0,最先落地)
|
||
|
||
### 3.1 设计决策
|
||
|
||
- **问题**:Fernet 单钥架构下 MASTER_KEY 遗失 = 全量密文不可解;其中 2FA secret 无法像密码一样逐站重置,损失不可逆。
|
||
- **方案(Key Escrow)**:
|
||
1. 系统生成随机 `RESTORE_KEY`(Fernet key 格式,44 字符 urlsafe base64);
|
||
2. 用 RESTORE_KEY 加密当前 MASTER_KEY,得 escrow token(`v1:` 前缀 + Fernet token),写入 `data/master_key.escrow`;
|
||
3. escrow 随 data/ 备份流转(其内容被 RESTORE_KEY 加密,库/备份泄露也解不开);RESTORE_KEY **只离线保存**:用户抄写 2~3 份(密码管理器 / 纸质 / 可信家人);
|
||
4. MASTER_KEY 遗失时:运行 `scripts/recover_master_key.py`,输入 RESTORE_KEY → 解密 escrow 找回 MASTER_KEY → 写回 .env → 重启服务,全量密文可解、零迁移。
|
||
- **恢复钥匙形态**:系统生成随机强钥匙(已确认),不引入口令派生(防弱口令拖库风险)。
|
||
- **恢复入口**:一次性 CLI 脚本(已确认),零新增网络接口/攻击面;日常运行不读取 escrow。
|
||
- **边界**:RESTORE_KEY 自身遗失 = escrow 失效 → 缓解手段是离线多副本抄写;不做 Shamir 秘密拆分(单用户规模过度设计)。
|
||
- **托管对象是 key 本身而非用户数据**:escrow 泄露最坏影响 = 需轮换 MASTER_KEY,不直接暴露任何业务密文(还要同时拿到离线 RESTORE_KEY 才有意义)。
|
||
|
||
### 3.2 实现清单
|
||
|
||
- `app/core/crypto.py` 增加(不动现有 encrypt/decrypt 与 lru 缓存):
|
||
- `_fernet_from_key(key: str) -> Fernet`:按给定 key 构造独立实例(不走 MASTER_KEY 缓存)。
|
||
- `build_escrow(restore_key: str) -> str`:读 `settings.MASTER_KEY`,返回 `'v1:' + Fernet(restore_key).encrypt(master_key_bytes)`。
|
||
- `recover_master_key(restore_key: str, escrow: str) -> str`:解析 `v1:` 前缀并解密,失败抛错(钥匙错误/文件损坏)。
|
||
- 新增 `scripts/setup_key_escrow.py`:
|
||
- 生成 RESTORE_KEY(`Fernet.generate_key()`);
|
||
- 立即用 build_escrow + recover_master_key 回验自检;
|
||
- 写入 `data/master_key.escrow`(权限 0600);幂等:文件已存在且回验通过则跳过并提示,`--force` 才重建;
|
||
- 终端**仅此一次**打印 RESTORE_KEY,附保存建议(3 个离线位置),随后清屏提示已保存到 .env 旁说明文件(`data/master_key.escrow.README`,含步骤简述、不含钥匙)。
|
||
- 新增 `scripts/recover_master_key.py`:
|
||
- `--key <RESTORE_KEY>` 必填(也支持环境变量 `RESTORE_KEY`,避免 shell 历史残留);
|
||
- 读 `data/master_key.escrow` → `recover_master_key`;失败提示"恢复钥匙错误或 escrow 损坏";
|
||
- 默认仅打印找回的 MASTER_KEY;`--write` 则备份 `.env` 为 `.env.bak-pre-recover` 后回写 MASTER_KEY 行,提示重启服务。
|
||
- `.gitignore`:确认 `data/` 已忽略(escrow 绝不进 git);M0 落地时手动把 escrow 文件复制一份到离线备份介质。
|
||
|
||
### 3.3 M0 验收门槛
|
||
|
||
1. 本地运行 setup 脚本生成 escrow,终端显示 RESTORE_KEY(留存截图一次后即离线保存)。
|
||
2. **丢失演练**:备份 `.env` → 用错误 MASTER_KEY 启动,验证真实数据解密失败 → 运行 recover(不带 --write)找回原 key → 回写 .env → 重启 → 既有真实账号密码可正常解密查看。
|
||
3. escrow 文件权限 0600、未纳入 git、已复制离线备份。
|
||
|
||
## 4. 数据模型
|
||
|
||
### 4.1 新表 `credentials`
|
||
|
||
`app/models/credential.py`,挂 assets.db:
|
||
|
||
| 列 | 类型 | 说明 |
|
||
|---|---|---|
|
||
| id | int PK | |
|
||
| site | str, index | 站点/服务名(如 "GitHub"、"阿里云"),迁移自平台名 |
|
||
| username | str, index, nullable | 登录用户名/邮箱 |
|
||
| login_type | str, default 'password' | password / oauth / other |
|
||
| oauth_provider | str, nullable | 授权来源:google / apple / github / wechat 等 |
|
||
| password_encrypted | str, nullable | Fernet 密文;oauth 为空 |
|
||
| otp_secret_encrypted | str, nullable | Fernet 密文,存 base32 secret(不存 otpauth URI,可随时重建) |
|
||
| url | str, nullable | 登录页地址(可选跳转) |
|
||
| note | str, nullable | 备注 |
|
||
| created_at / updated_at | datetime | 与 Asset 同款 utcnow 模式 |
|
||
|
||
- 不建 `(site, username)` 唯一约束(SQLite 对 NULL 不友好 + 同站多账号合法);服务层创建时提示重复但允许继续。
|
||
- 2FA 展示所需信息均从 secret 派生:`issuer=site`、`account=username`,导出时可重建 otpauth URI。
|
||
|
||
### 4.2 `accounts` 加列
|
||
|
||
`Account` 增加 `credential_id: Optional[int] = Field(default=None, foreign_key="credentials.id", index=True)`。
|
||
|
||
### 4.3 相关文件
|
||
|
||
- `app/database.py`:`init_db` 的 `asset_models` 加入 `Credential`;`_migrate_assets_db` 增加迁移函数(见 §5)。
|
||
- `app/main.py`:`include_router(credentials.router)`。
|
||
|
||
## 5. 幂等迁移(_migrate_assets_db 内)
|
||
|
||
```text
|
||
_migrate_credentials():
|
||
1. accounts 表存在时:若无 credential_id 列 → ALTER TABLE accounts ADD COLUMN credential_id INTEGER
|
||
2. 建表/加列后执行 backfill(幂等条件:credential_id 已非空则跳过):
|
||
遍历 accounts 中 login_password_encrypted 非空 且 credential_id IS NULL 的行:
|
||
site = providers.name(platform=slug 匹配)→ 否则 platform → 否则 '未分类'
|
||
username = COALESCE(login_user, name)
|
||
login_type = 'password'
|
||
password_encrypted = 原密文原样搬入(不解密再加密,避免中间态暴露)
|
||
credential_id = 新行 id
|
||
搬入成功后置空 accounts.login_password_encrypted(唯一事实源,防双份漂移)
|
||
3. 为 credentials 建索引 ix_credentials_site / ix_credentials_username(IF NOT EXISTS)
|
||
```
|
||
|
||
**执行前必须备份**:`cp data/assets.db data/assets.db.bak-pre-vault`(本地与生产各自执行一次,人工确认)。
|
||
|
||
**回滚**:git revert 后执行逆迁移(credential.password_encrypted 写回 account.login_password_encrypted 并解除关联)——单用户数据量小,直接在 sqlite3/脚本内完成,仅在需要时编写。
|
||
|
||
## 6. 后端改动清单
|
||
|
||
### 6.1 新增 `app/core/totp.py`(无依赖)
|
||
|
||
- `b32decode(secret)`:容错去空格/补 `=`。
|
||
- `totp_at(secret_b32, ts=None)` → `(code6, period_left_seconds)`:RFC 6238 标准实现。
|
||
- `verify(secret_b32, code, window=1)`:允许 ±1 步进(录入校验容时钟偏差)。
|
||
- `parse_otpauth_uri(uri)` → `{secret, issuer, account}`:支持用户直接粘贴 `otpauth://totp/...` 录入(仅取 secret 入库,issuer/account 仅回填建议)。
|
||
- `random_secret()`:`secrets` 生成 20 字节 → Base32(供"生成随机密钥"按钮,可选)。
|
||
|
||
### 6.2 新增 `app/schemas/credential.py`
|
||
|
||
```text
|
||
CredentialBase: site, username, login_type='password', oauth_provider, url, note
|
||
CredentialCreate: CredentialBase + password(明文, 可空) + otp_secret(可空) + otp_code(可空)
|
||
CredentialUpdate: 全字段可空;password: None=不改,''=清除,非空=重加密(沿用 Account 惯例)
|
||
CredentialRead: id, site, username, login_type, oauth_provider, url, note,
|
||
has_password, has_otp, account_name(可空,反向关联的账号名), created_at, updated_at
|
||
OtpBindRequest: secret(必填), code(必填, 6位) # 录入必须过校验
|
||
```
|
||
|
||
### 6.3 新增 `app/services/credential_service.py`
|
||
|
||
- `list_credentials(session, q, login_type, has_otp)`:搜索 site/username/note;反向查 account 填充 account_name。
|
||
- `create_credential`:`(site, username)` 重复时允许但返回提示(校验在路由层给 warning 字段或直接允许);password 用 `crypto.encrypt`;otp 见 §6.5。
|
||
- `update_credential / delete_credential`。
|
||
- `reveal_password(credential_id)` → `{username, password}`(同账号 reveal 语义)。
|
||
- `bind_otp / unbind_otp / current_otp`(见 §6.5)。
|
||
- 依赖注入、404、加密方式与 `account_service` 完全同构。
|
||
|
||
### 6.4 新增 `app/routers/credentials.py`(prefix `/api/credentials`)
|
||
|
||
| 方法 | 路径 | 鉴权 | 说明 |
|
||
|---|---|---|---|
|
||
| GET | `` ?q=&login_type=&has_otp= | 读 | 列表 |
|
||
| POST | `` | require_api_key | 创建(含可选 otp 绑定) |
|
||
| PUT | `/{id}` | require_api_key | 更新(含改密/清密) |
|
||
| DELETE | `/{id}` | require_api_key | 删除凭据(不动任何账号/资产) |
|
||
| GET | `/{id}/password` | require_api_key | 解密查看密码 |
|
||
| GET | `/{id}/otp` | require_api_key | `{code, expires_in}`;未绑定 404 |
|
||
| PUT | `/{id}/otp` | require_api_key | `OtpBindRequest`,verify 通过才存 |
|
||
| DELETE | `/{id}/otp` | require_api_key | 解绑 2FA |
|
||
|
||
- `GET /otp` 返回 `Cache-Control: no-store`(动态码防缓存)。
|
||
- 所有写操作与账号/资产路由一致使用 `require_api_key`(内网放行规则自动生效)。
|
||
|
||
### 6.5 2FA 绑定与生成规则
|
||
|
||
- **绑定**:`secret` + 用户当前 6 位码 `code` 一起提交 → 服务端 `verify(secret, code)` 通过才加密入库,失败 400「验证码不匹配」(防止 secret 手误录入成废条目,沿用 GitHub 添加 TOTP 模式)。
|
||
- **生成**:`current_otp` 用 `totp_at` 算出 `{code, expires_in}`;前端本地倒计时、到 0 重新请求(不轮询)。
|
||
|
||
### 6.6 改造 `app/services/account_service.py`
|
||
|
||
- `_to_read`:`has_login_password` 改判 `bool(account.credential_id)`;`AccountRead` 增 `credential_id`、`has_otp` 两字段(schema 同步加,均默认 False/None,兼容老前端)。 `has_otp` 需查关联 credential —— list 时批量取 `credential_id in (...)` 后填充。
|
||
- `create_account`:保存 `login_password` 时(解密前不落库):
|
||
- 同一事务内创建 credential(site=平台显示名规则同迁移,username=COALESCE(login_user,name)),记 `account.credential_id`。
|
||
- `update_account`:改名/换平台时同步 credential.site/username(保持引用一致);`login_password` 非空 → upsert 到关联 credential(没有则新建并回填 id);`''` → 清 credential.password;`None` → 不动。
|
||
- `reveal_password`:改从关联 credential 解密(未关联 → 404 同旧语义)。
|
||
- `delete_account`:**不删** credential(生命周期解耦,凭据库独立留存)。
|
||
|
||
### 6.7 `AccountRead` schema 扩展
|
||
|
||
```text
|
||
credential_id: Optional[int] = None
|
||
has_otp: bool = False
|
||
```
|
||
|
||
## 7. 前端改动清单
|
||
|
||
### 7.1 导航与路由(`app.js` + `static/js/views/vault.js` 新建)
|
||
|
||
- `NAVS` 增加 `{ key: 'vault', label: '凭据库', icon: '🔐' }`(放在「平台」之前)。
|
||
- `VIEW_MAP` 增加 `vault: 'vault-view'`。
|
||
- 新建 `static/js/views/vault.js`:`VaultView` 组件。
|
||
- `index.html` 在 modals.js 之前加入 `<script src="/static/js/views/vault.js?v={{ asset_version }}">`。
|
||
- `app.js` 注册 `app.component('vault-view', VaultView)`。
|
||
|
||
### 7.2 凭据库视图(vault.js)行为
|
||
|
||
- **头部**:搜索框(site/username/note,复用资产页搜索样式);「+ 新增」。
|
||
- **列表**:每行 `site` + `username`,徽章:登录方式(密码=无/隐藏值 `••••••`、`Google 授权` 等、`其他`);`2FA` 蓝色徽章;有密码行提供 `复制密码` 按钮(fetch password 后 clipboard);点击行 → 展开详情。
|
||
- **展开详情**:url(可点击跳转)、note、关联账号(若有,`account_name` 显示"来自平台账号")、操作:显示/复制密码、编辑、删除。
|
||
- **2FA 区块**(`has_otp` 才显示):大字 6 位码 + 倒计时秒(`expires_in` 驱动本地 1s tick,归零重新请求 `GET /otp`)+ 复制验证码 + 解绑。
|
||
- **空态**:提示"从平台账号迁移的凭据会自动出现在这里"。
|
||
|
||
### 7.3 凭据表单(扩展 modals.js 或新建 CredentialModal)
|
||
|
||
- 字段:site*、username、url、login_type 下拉(密码登录/授权登录/其他)、oauth_provider(login_type=oauth 时出现,含常用建议 google/apple/github/wechat + 自由输入)、password(type=password,编辑时留空=不改)、note。
|
||
- **2FA 区块**:secret 输入框(或粘贴 otpauth:// URI 自动解析填入)+「获取当前验证码」辅助说明 + 当前 code 输入(6 位,必须填,提交时服务端校验)→ 校验失败原地报错不落库。
|
||
- 保存后刷新列表 + `loadAccounts()`(账号关联展示需要)。
|
||
|
||
### 7.4 账号体系联动(modals.js / accounts-view 相关)
|
||
|
||
- 账号列表(accounts-view-modal)与账号弹窗的"查看密码"按钮逻辑不变(后端已重定向)。
|
||
- 若账号关联凭据且有 2FA:查看密码弹窗旁增加「获取验证码」按钮 → 调 `GET /api/credentials/{credential_id}/otp` 展示动态码(复用展开详情的 2FA 组件逻辑,独立小函数)。
|
||
- 平台页账号入口保持不动。
|
||
|
||
### 7.5 store.js / api.js
|
||
|
||
- `store.credentials` + `loadCredentials()`,`loadAll()` 并联加入。
|
||
- CRUD 函数与 credentialModal state(复制 saveAccount 的 saving 锁模式防双击)。
|
||
- `Fmt.LOGIN_TYPE_LABELS = { password: '密码', oauth: '授权登录', other: '其他' }` 与 oauth provider 徽章色。
|
||
|
||
## 8. 交互打磨(M4,占位待用户圈选后启动)
|
||
|
||
用户对交互尚有保留意见,此处列候选独立小改动,启动前由用户勾选范围:
|
||
|
||
1. 全局 toast「已复制」取代旧式 alert/瞬时无反馈(可做成 store.toast + 简单组件)。
|
||
2. OTP 环形/进度条倒计时视觉 + 复制即消失反馈。
|
||
3. `/` 快捷键聚焦当前页搜索框(移动端不启用)。
|
||
4. 账号查看密码弹窗合并进凭据详情(统一交互路径,减少两套弹窗)。
|
||
5. 快速录入:从平台账号弹窗一键「补全 2FA」跳凭据表单并预填 site/username。
|
||
6. 二维码录入(需要引入前端 QR 解码库/后端解码,成本高,默认不做,除非用户点名)。
|
||
7. 双击行快速复制密码、长按移动端复制。
|
||
|
||
> M4 独立成 goal/任务执行,不阻塞 M0–M3。
|
||
|
||
## 9. 测试与验收
|
||
|
||
### 9.1 pytest(tests/ 新增 test_totp.py、test_credential_migration.py)
|
||
|
||
- RFC 6238 附录 B 官方向量(secret = ASCII "12345678901234567890" 的 Base32,T=59 / 1111111109 / 1111111111 / 1234567890 / 2000000000 / 20000000000,8 位转 6 位 = 取模 1000000 补零)逐条断言 code 与 expires_in。
|
||
- `parse_otpauth_uri` 解析标准 URI 与缺 issuer 容错。
|
||
- 迁移幂等:造含密码账号 → 跑两次 backfill → 只产生一条 credential、account 密码字段已清空、第二次不重复建。
|
||
- 绑定校验:错 code 拒绝、对 code 落库、`current_otp` 用固定时间戳种子断言稳定性。
|
||
- escrow 回验:build_escrow 后 recover_master_key 能还原 MASTER_KEY;错误钥匙抛错。
|
||
|
||
### 9.2 本地端到端(必做,提交前)
|
||
|
||
1. `.venv/bin/uvicorn app.main:app --port 8000`(项目根 vps-manager/ 下,load_dotenv 自动读 .env)。
|
||
2. 先完成 M0 丢失演练(见 §3.3)。
|
||
3. 手动建测试凭据(含 oauth 条目 + 2FA 绑定真实 secret,用手机验证器/在线 TOTP 工具对码)。
|
||
4. 浏览器逐项验证:列表搜索、复制密码、OTP 倒计时刷新、账号弹窗查密码仍可用、新增账号自动生成凭据条目。
|
||
5. 迁移干跑:确认生产量级账号行全部迁移、无残留明文。
|
||
6. 截图留存(本地验证 + 产物截图惯例),再 git commit + push。
|
||
|
||
### 9.3 生产验证(cc1 自动部署后)
|
||
|
||
- git push origin main → cc1 timer 拉取更新(deploy.sh 若再出现 git push 卡住,按经验手动 `git push origin main` 干预)。
|
||
- 生产环境先备份 `data/assets.db`,确认 /health 新 commit。
|
||
- 生产也执行 M0 setup(生成新 escrow + RESTORE_KEY 离线保存)。
|
||
- 端到端抽查:老账号密码可见、2FA 录入→对码成功→删除测试数据。
|
||
- 确认 VERSION/commit 展示与静态资源版本(`?v=` mtime 机制自动生效,无需手动)。
|
||
|
||
## 10. 里程碑与验收门槛
|
||
|
||
| 里程碑 | 内容 | 完成门槛 |
|
||
|---|---|---|
|
||
| M0 | Key Escrow 托管(crypto 扩展 + setup/recover 脚本 + escrow 文件) | 丢失演练通过(§3.3);pytest escrow 回验绿 |
|
||
| M1 | 模型/schema/totp/迁移/credential service+router/account_service 改造 + pytest | 全绿;迁移干跑幂等 |
|
||
| M2 | vault 视图 + CredentialModal + 账号联动 + store/api | 本地端到端截图验证通过 |
|
||
| M3 | commit → push → cc1 生产验证(含生产 escrow 建立) | 生产抽查通过,无回归 |
|
||
| M4 | 交互打磨(圈选后另行启动) | 独立 |
|
||
|
||
## 11. 风险与注意
|
||
|
||
- 迁移前**必须**备份 assets.db(本地与生产各一次),迁移函数幂等可重跑。
|
||
- 迁移/回滚脚本只搬密文、不落明文,避免中间态暴露。
|
||
- RESTORE_KEY 一旦遗失 escrow 即失效 → 落地时强制抄写 3 个离线位置,并保存一份 escrow 到离线介质;`.env` 与 escrow 尽量分介质存放。
|
||
- 旧前端页面在部署后需刷新(HTML no-cache + asset_version 已保证)。
|
||
- credentials 是个人全量密码仓库:`reveal` / `otp` 接口均走 require_api_key,Tailscale 内网直连放行策略不变;前端不落任何明文到 localStorage(仅展示期内存持有)。
|
||
- 依赖零新增:requirements.txt / .env 均不改(MASTER_KEY 已存在;RESTORE_KEY 只作为脚本参数/环境变量出现,不常驻配置)。
|
||
- 本 spec 为凭据与 2FA 的完整闭环;账号的 `api_config_encrypted`(SDK 密钥)不在本次范围(后续可演进为"API 凭据"视图,不阻塞)。
|