Skip to content

Commit 707e624

Browse files
committed
feat(B3.3): 凭证列表展示 access token 到期与预警,顺带修 CB 预刷新永不触发
实测前提:CodeBuddy 的 OAuth 登录与刷新响应都不带 expires_at / created_at / expires_in,生产库三个 CB 凭证的 expires_at 恒为 0。由此 暴露出一个真实故障——CodeBuddyCredential.needs_refresh 首行要求 expires_at > 0,于是 CB token 从来不预刷新,过期后被上游 401 硬禁用, revive 也不自愈。两渠道 access token 都是标准 JWT,payload 的 exp 是 上游签发的权威到期时间,作为缺失时的回落来源。 - 新增渠道中立的 provider/token_expiry.py:jwt_times/credential_token_times /normalize_epoch;刻意不 import provider 子模块,否则 provider.codebuddy.events → engine.sse 会被拖进 db 层。TRAE 私有 _normalize_epoch 收敛为共享实现。 - credentials 增列 token_expires_at / token_issued_at(SCHEMA_VERSION 10→11,走 _MIGRATION_COLUMNS)。两个值都从凭证 JSON 派生:到期显式 expires_at 优先、回落 JWT exp;签发时间取 JWT iat。JWT 派生值不写回 凭证 JSON,否则刷新换到新 token 后旧派生值会残留成「权威」到期。 两边都取不到 = 0(未知),不猜本地 TTL。 - 老库不批量回填:列表读到时按需从密文派生(_token_times_from_blob), 写回后走列值;NULL = 老库未回填、0 = 已确认未知,避免重复解密。 - 进度条满量程取 token 自身寿命(exp - iat):CB 实测 55 天、TRAE 约 14 天,固定量程会把前者永远画成满格。拿不到 iat 就不画条、只给数字。 - 展示纪律:剩余时间与「最后续期」必须一起给——剩余天数会被刷新拉满, 单看会读反;未知到期整块不渲染,绝不当成已过期。 - 读路径只下发绝对 epoch + 阈值 TOKEN_EXPIRY_WARNING_SECONDS,剩余时间 与预警由前端同一时钟现算,避免与冷却时长两处口径漂移。 - Provider 协议补 credential_from(文档化 RefreshTask 依赖的鸭子类型)。 验证:ruff 通过;1270 passed,行+分支 100%;前端 tsc/vitest(137)/build 全绿。真库副本端到端:老库 user_version 9→11 补两列且零回填,5 条生产 凭证到期派生全部正确(TRAE 26%/89%、CB 92~97% 条宽);浏览器实测进度条、 标红预警(text-destructive)、「已过期」与「最后续期」均按预期渲染。
1 parent 15a8eee commit 707e624

22 files changed

Lines changed: 940 additions & 24 deletions

‎PROPOSAL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
| Q32 | Responses 出口 | v1 只做 `chat/completions` 子集:`POST /v1/responses` 与 chat 共用同一 executor,出口 translator 可注入;`include`/`store`/`previous_response_id` 按 Codex CLI 实测取舍(见 TECHNICAL §3.7) |
4141
| Q33 | 凭证暂停语义 | 复用现有 `enabled`(不新增 `manual_disabled` 列):`enabled=0` 实测已只摘对话流量,后台任务(签到/刷新/成长/探测)只认 `disabled`;UI 文案统一为「暂停/取消暂停」以区别于系统禁用后的「恢复」(见计划 B3.1 实测收窄) |
4242
| Q34 | 运行时配置热更 | **推翻 Q11 的「无设置页」**:新增 `runtime_settings` 表 + `RuntimeSettings` 覆盖层 + 管理台第 7 页「运行时配置」。白名单 13 项(默认模型 / 模型黑名单 / 到期两个窗口 / 会话粘性 TTL / 成长不可逆开关 / 成长与探测周期 / 两个节流窗口 / CB 聊天最小间隔 / 活跃上报开关与时点)改完立即生效,无需重启;**DB 覆盖值优先于 .env**,UI 与日志明示,可「恢复默认」清掉覆盖。启动期项(密钥 / 端口 / 数据目录 / 上游白名单)不进白名单——它们决定进程如何启动,运行期改只会让内存与磁盘静默分叉 |
43+
| Q35 | token 到期展示与预警 | `credentials` 增列 `token_expires_at` / `token_issued_at`(`SCHEMA_VERSION` 10→11)。到期时间优先取凭证显式 `expires_at`,缺失/非法时回落到 access token 的 **JWT `exp`**;签发时间取 JWT `iat`(新增渠道中立的 `provider/token_expiry.py`)——**实测 CodeBuddy 的 token 响应(OAuth 登录与刷新)不带任何到期字段**,只看 `expires_at` 会恒为 0,既让管理台看不到到期、也让 `needs_refresh` 永不触发(token 过期即被 401 硬禁用,且 revive 不自愈)。两边都取不到时为 0 = 未知,**不猜本地 TTL**。进度条满量程取 token 自身寿命(`exp - iat`),拿不到 `iat` 就不画条。老库不批量回填:列表读到时按需从密文派生,写回后走列值 |
4344

4445
## 2. 目标与非目标
4546

@@ -241,6 +242,7 @@ v1 只接 OpenAI 出口,但上游 SSE 解析到「中立事件」这一步独
241242
- **凭证加密列**:`data_enc` 走 Fernet,调度状态(`health` / `cooling_until` / `err_count` / `pinned` / `quota_expiry_ladder`)落库,进程重启不丢冷却状态与到期阶梯
242243
- **用量脱敏**:`usage_events`(明细 90 天)+ `usage_hourly`(小时汇总永久),`credit`/`cached_tokens` 可空仅辅助展示
243244
- **成长中心**:`growth_events` 只存汇总行(一轮一行人话汇报 + 积分/能量/连签 + trigger),不存活动内部数据结构;`credentials.growth_last_run_at/growth_last_result` 供列表直接显示;活跃上报(B1.7)复用该表记一行,不新增表
245+
- **token 到期**(Q35):`credentials.token_expires_at`(显式 `expires_at` 优先,缺失回落 access token 的 JWT `exp`;0 = 未知)与 `token_issued_at`(JWT `iat`,即最后续期;0 = 未知)。二者必须一起展示——剩余天数会被刷新拉满,单看会读反;进度条满量程也取二者之差。派生逻辑在渠道中立的 `provider/token_expiry.py`,**不猜本地 TTL**
244246
- 签到去重与模型列表缓存均进程内实现,不进库
245247

246248
DDL 以 [src/db/schema.sql](../src/db/schema.sql) 为准,补充实现细节见 [TECHNICAL.md §7](TECHNICAL.md)。

‎README.en.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ upstream channels, with a shared credential pool, unified scheduling, and per-us
2727
- **Model catalog hygiene**: `MODEL_BLOCKLIST` filters placeholder/legacy models; cached model list as fallback when upstreams fail; credit rates and token limits passed through to `/v1/models` and the Playground
2828
- **Per-credential pause**: the admin credential menu's "Pause" removes one credential from *chat traffic only* — quota probing, token refresh, daily check-in, growth-center and activity-report tasks keep running (they only honor the system hard-disable `disabled`). Unlike "Disabled", which means the upstream rejected the session and requires re-login plus "Restore"
2929
- **Runtime-configurable settings**: 13 settings (default model, model blocklist, both expiry windows, conversation-sticky TTL, irreversible growth actions, growth/probe intervals, both pacer bounds, CodeBuddy chat interval, activity-report toggle/hour) can be changed from the admin UI's *Runtime settings* page and take effect immediately — no restart. **DB overrides win over `.env`**; the page marks each row as "DB override" and offers "Reset to default" to fall back to `.env`. Startup-only knobs (`APP_SECRET`, `PORT`, `DATA_DIR`, allowlists) are deliberately excluded
30+
- **Token-expiry visibility**: each credential shows a remaining-lifetime bar plus its last-renewal time, red-flagged below `TOKEN_EXPIRY_WARNING_SECONDS`. The expiry comes from the credential's explicit `expires_at` and **falls back to the access token's JWT `exp`** — CodeBuddy's token responses carry no expiry at all (measured), so without the fallback the value is always 0 and CodeBuddy tokens would never pre-refresh (only hard-disabled on a 401). Last-renewal comes from the JWT `iat`, and the bar is scaled to that token's own lifetime (`exp - iat`) — CodeBuddy lives 50+ days and TRAE ~12, so a fixed scale would pin the former at 100%. Missing `iat` → no bar, numbers only. Both sources missing → shown as unknown, never guessed
3031

3132
## Quick start
3233

‎README.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,25 @@ curl http://127.0.0.1:8000/v1/user/balance -H "Authorization: Bearer sk-你的ke
124124
与状态列的「已禁用」不同——那是渠道判定会话失效后的系统禁用,需要重新登录后用
125125
「恢复」解除。同一个开关也存在于 `POST /api/credentials/{id}/toggle`。
126126

127+
### token 到期展示
128+
129+
凭证列表的额度列给每个账号画一条 access token 剩余时间进度条,并标注**最后续期
130+
时间**和到期预警。两者必须一起看:只剩 3 天看着像快挂了,但如果两分钟前刚续期,那只是
131+
刚拿到的新 token 里剩下的部分;反过来只剩 3 天且续期是十天前,才是真的没人管。
132+
只看剩余天数会把这两种情况读反。
133+
134+
到期时间优先取上游显式给的 `expires_at`,缺失时回落到 access token 的 JWT `exp`——
135+
**实测 CodeBuddy 的 token 响应(OAuth 登录与刷新)不带任何到期字段**,只看 `expires_at`
136+
会恒为 0,这里正是靠 JWT 回落补上的(否则 CodeBuddy 的 token 预刷新永远不会触发,
137+
只能等过期后被上游 401 硬禁用)。最后续期时间取 JWT 的 `iat`。
138+
139+
进度条的满量程是 **token 自己的寿命**(`exp - iat`),不是固定窗口:实测 CodeBuddy 的
140+
token 寿命 50+ 天、TRAE 约 12 天,用固定量程会把前者永远画成满格,看不出消耗。拿不到
141+
`iat` 时不画条、只给数字。
142+
143+
两边都拿不到时显示为未知并隐藏整块,**不猜本地 TTL**——否则管理台会显示一个凭空捏造的
144+
到期预警。剩余时间低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)时标红。
145+
127146
## 后台任务
128147

129148
额度探测、token 预刷新、每日签到、成长中心、明细清理由 `TaskRunner` 自动调度(失败互不影响),周期见 TECHNICAL.md §6.1。
@@ -180,7 +199,8 @@ CodeBuddy 成长中心的「连登天数 / 活跃地图」按日统计客户端
180199
| `CODEBUDDY_ALLOWED_ENDPOINTS` | 官方两站(见 compose) | 上游端点白名单,真实 Token 只发往白名单内地址 |
181200
| `CODEBUDDY_CHAT_MIN_INTERVAL` | `5` | CodeBuddy 聊天最小间隔(秒),与 TRAE 共享节流;`0` 关闭 |
182201
| `CODEBUDDY_SANITIZE_CHANNEL_MARKERS` | `true` | 出站 `system`/`assistant` 正文命中「伪装其他厂商官方客户端」指纹串时替换为占位符(上游 11128 内容风控:换号无效、会话带入即持续报错);只改出站副本,客户端历史不受影响;`false` 关闭(见 TECHNICAL.md §3.2) |
183-
| `REFRESH_SKEW_HOURS` | `24` | token 到期前该小时数窗口内预刷新 |
202+
| `REFRESH_SKEW_HOURS` | `24` | token 到期前该小时数窗口内预刷新。到期时间取凭证显式 `expires_at`,缺失时回落 access token 的 JWT `exp`(CodeBuddy 实测不带显式到期字段) |
203+
| `TOKEN_EXPIRY_WARNING_SECONDS` | `3600` | 管理台 token 到期预警阈值:剩余低于该值时标红;`≤0` 关闭预警(仍显示剩余时间)。纯展示,不参与调度 |
184204
| `PACER_MIN_SECONDS` / `PACER_MAX_SECONDS` | `5` / `20` | 全局节流器随机等待区间(秒) |
185205
| `LOG_LEVEL` | `INFO` | 日志级别;审计日志是 INFO 级,调到 `WARNING` 会一并关掉 |
186206
| `DUMP_REQUEST_BODIES` | `false` | 诊断:把 `/v1` 原始请求体落盘到 `data/dumps/`(**含对话内容**,仅排查用) |

‎TECHNICAL.md‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ coding2api/
6565
│ │ │ ├── events.py # 自定义 SSE → Event
6666
│ │ │ ├── credential.py # 凭证解析(嵌套/扁平)+ 原子写回 + 签到设备号生成
6767
│ │ │ └── callback.py # 登录 URL 构造 + 回调解析
68+
│ │ ├── token_expiry.py # access token 到期提取(显式 expires_at → JWT exp 回落,B3.3)
6869
│ │ └── fixtures/ # fixture 清单
6970
│ │ ├── codebuddy/*.sse
7071
│ │ └── trae/*.sse
@@ -443,6 +444,68 @@ UI 与日志都必须明示「DB 覆盖 .env」,否则用户改 `.env` 不生
443444

444445
---
445446

447+
### 3.9 token 到期展示与预警(B3.3)
448+
449+
**问题**:凭证池里 access token 什么时候过期,管理台此前完全看不到;只有 token 真的失效、
450+
上游回 401、凭证被硬禁用(`disabled=1`)才会在列表里暴露成一个红色状态,而那时已经
451+
无法接对话流量了。`revive` 只清禁用标记、不补刷新,凭证也无法自愈。
452+
453+
**到期时间的来源**(实测结论,决定了实现,不能只读凭证字段):
454+
455+
| 渠道 | 凭证 JSON 的 `expires_at` | 实际可靠来源 |
456+
|---|---|---|
457+
| TRAE | 有值(= access token 的 JWT `exp`) | 凭证字段即可 |
458+
| CodeBuddy | **恒为 0**:OAuth 登录与刷新响应都不带 `expires_at`/`created_at`/`expires_in` | 只能从 bearer token 的 JWT `exp` 解析 |
459+
460+
三个 CodeBuddy 凭证实测 `expires_at` 全为 0,而它们的 bearer token 都是 JWT、带权威 `exp`。
461+
若严格只读凭证字段,预警对**全部** CodeBuddy 凭证名存实亡;更糟的是
462+
`CodeBuddyCredential.needs_refresh` 首行要求 `expires_at > 0`,于是 CodeBuddy 的 token
463+
**从来不预刷新**——这正是本批顺带修掉的真实故障。
464+
465+
**实现**:`provider/token_expiry.py`(渠道中立,刻意不 import provider 子模块,否则
466+
`provider.codebuddy.events → engine.sse` 会被拖进 `db` 层):
467+
468+
- `credential_token_times(data)`:返回 `(签发, 到期)` 两个 epoch。到期优先显式
469+
`expires_at`/`expiresAt`,缺失/非法时遍历可能的 token 键(`bearer_token`/`accessToken`/…)
470+
解析 JWT `exp`;签发时间只来自 JWT `iat`(上游不会单独回传)。两者各自拿不到时返回
471+
**0 = 未知**。**不猜本地 TTL**——捏造的到期时间会让管理台显示假预警,比不显示更糟;
472+
拿回填时刻冒充 `iat` 同样不行(那是「我们什么时候写的」,不是「上游什么时候签发的」)。
473+
- `credential_expiry(data)`:上面的到期分量(预刷新判定与兼容入口)。
474+
- `jwt_times(token)` / `jwt_expiry(token)`:只 base64url 解码不验签(签名由上游校验,这里
475+
仅用于展示与预刷新判定);非 JWT / 结构异常 / claim 非法一律 0。
476+
- `normalize_epoch()`:毫秒时间戳归一(TRAE 原先的私有 `_normalize_epoch` 收敛到这里,
477+
两渠道共用一份实现)。
478+
479+
**落库**:`credentials` 新增两列(`SCHEMA_VERSION` 10 → 11,走 `_MIGRATION_COLUMNS`):
480+
481+
- `token_expires_at`:`add()` 与 `save_credential_data()` 都写回,值来自
482+
`credential_token_times()`。**JWT 派生值不写回凭证 JSON**——否则刷新换到新 token 后旧派生值
483+
会残留成「权威」到期时间。
484+
- `token_issued_at`:access token 的签发 epoch(JWT `iat`)。它既是「最后续期」的展示值,
485+
也是进度条的满量程(见下)。拿不到时写 0。
486+
487+
**老库升级不批量回填**:全池解密会拖慢启动,改在列表读到时按需从 `data_enc` 派生
488+
(`_token_times_from_blob()`,解密/解析失败返回 `(0, 0)` 而不是让整个列表崩掉);一旦写回就
489+
只读列值,不再每次都付一次解密开销(`NULL` = 老库未回填 → 派生;`0` = 已确认未知 → 不重复解密)。
490+
491+
**读路径**:`list_all()` 只下发绝对 epoch(`token_expires_at` / `token_issued_at`),
492+
**不代前端算剩余秒数**——服务端算好的「剩余」不会随页面 tick 更新,而且会与冷却时长各用
493+
一套口径。剩余时间、预警判定、进度条宽度全部由前端 `tokenExpiryView()` 用同一个时钟现算,
494+
阈值由 `TOKEN_EXPIRY_WARNING_SECONDS` 经列表接口下发。
495+
496+
**进度条量纲是 token 自己的寿命**(`exp - iat`),不是固定窗口:实测 CodeBuddy 的 token
497+
寿命 50+ 天、TRAE 约 12 天,用固定量程(比如 24h)会把前者永远画成满格,看不出消耗。
498+
拿不到 `iat` 时不画条、只给数字,而不是拿一个假量程充数。
499+
500+
**展示纪律**:剩余时间必须与「最后续期」一起给。只剩 3 天看着像快挂了,但若最后续期是
501+
两分钟前,那只是刚拿到的新 token 里剩下的部分;只剩 3 天且续期在十天前才是真的没人管。
502+
只看剩余天数会把两种情况读反。未知到期整块不渲染,**绝不当成已过期**。
503+
504+
**接口**:`GET /api/credentials` 响应新增 `token_expiry_warning_seconds`;每条凭证新增
505+
`token_expires_at` 与 `token_issued_at`(均为 0 表示未知)。
506+
507+
---
508+
446509
## 4. Provider 协议(Q16=A 细接口)
447510

448511
```python
@@ -576,7 +639,8 @@ class Scheduler:
576639
| 任务 | 周期 | 行为 |
577640
|---|---|---|
578641
| 额度探测(quota_probe.py) | 启动立即跑一轮(不节流) + 每 `QUOTA_PROBE_MINUTES`(默认 60)分钟 | 探测上游剩余额度 → `credentials.quota_*` / `quota_expiry_ladder` / `quota_packages` / `health` 写回 |
579-
| token 预刷新(refresh.py) | 每 60 分钟 | 到期前 `REFRESH_SKEW_HOURS`(默认 24h)窗口内轮换 refresh token |
642+
| token 到期(token_expiry.py) | —(读路径,非任务) | 从凭证显式 `expires_at` 或 access token 的 JWT `exp` 派生到期时间,写 `credentials.token_expires_at`;供管理台展示与预刷新判定(见 §3.9) |
643+
| token 预刷新(refresh.py) | 每 60 分钟 | 到期前 `REFRESH_SKEW_HOURS`(默认 24h)窗口内轮换 refresh token;到期时间取凭证显式 `expires_at`,缺失回落 access token 的 JWT `exp`(CodeBuddy 实测无显式字段,见 §3.9) |
580644
| 每日签到(checkin.py) | 每 10 分钟(全天) | 成功即封账该凭证当日(`日期:scope`,进程内内存态,重启重建);失败凭证持续重试,同账号多凭证共享一次 |
581645
| 成长中心(growth.py) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5 分钟) | 仅 CodeBuddy:领旅行礼物 / 派 Buddy / 领取新任务 / 领任务奖 / 断登补登 / 连登兑换 / 开盲盒 / Buddy 盲盒;结果落 `growth_events` + 回写 `credentials.growth_last_result` |
582646
| 活跃上报(activity.py,默认关闭) | 每 10 分钟醒一次,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点,北京时间)窗口内执行 | 仅 CodeBuddy:补发一条 `chat_request_send` 续连登;按「endpoint + userId」隔离,当日封账;成功落一行 `growth_events` |

‎docker-compose.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ services:
3030
QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS: ${QUOTA_EXPIRY_SECONDARY_WINDOW_SECONDS:-604800}
3131
# 会话粘性 TTL(秒):同一对话多轮请求固定用同一凭证;≤0 关闭
3232
CONVERSATION_STICKY_SECONDS: ${CONVERSATION_STICKY_SECONDS:-3600}
33+
# token 到期预警阈值(秒):剩余时间低于该值时管理台标红;≤0 关闭预警
34+
TOKEN_EXPIRY_WARNING_SECONDS: ${TOKEN_EXPIRY_WARNING_SECONDS:-3600}
3335
LOG_LEVEL: ${LOG_LEVEL:-INFO}
3436
# ---- 高级项:默认值已合适,需要时在 .env 里设即可生效 ----
3537
# 注意:compose 的 .env 只用于 ${VAR} 插值,只有在这里透传过的变量

‎src/api/admin_credentials.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,9 @@ async def list_credentials(principal=Depends(principal_from_request)):
6161
expiring_window=window, expiring_secondary_window=secondary),
6262
"expiry_window_seconds": window,
6363
"expiry_secondary_window_seconds": secondary,
64+
# 到期预警阈值(B3.3):前端进度条与 <1h 红字同源,避免两处各写一个数
65+
"token_expiry_warning_seconds":
66+
services.settings.token_expiry_warning_seconds,
6467
"viewer": principal.username,
6568
"is_admin": principal.is_admin}
6669

‎src/config.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ class Settings(BaseSettings):
5050
# 对话进行中不换号(避免上游风控与丢失上游提示词缓存);凭证出错仍会
5151
# 正常轮换,成功后重新粘定。≤0 关闭粘性
5252
conversation_sticky_seconds: int = 3600
53+
# token 到期预警阈值(秒):access token 剩余时间低于该值时管理台标红。
54+
# 展示阈值,不参与调度;与 REFRESH_SKEW_HOURS 分开——预刷新窗口是「何时
55+
# 续期」,这里是「何时该看」。
56+
token_expiry_warning_seconds: int = 3600
5357

5458
# 后台任务
5559
quota_probe_minutes: int = 60

‎src/db/migrate.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,9 @@
1818
# 当前 schema 版本。新增列/表、删表时 +1,并在下方对应元组里补增量。
1919
# 10:新增 runtime_settings 表(B3.2 运行时配置热更新)。新增表只需进
2020
# schema.sql(CREATE TABLE IF NOT EXISTS 对老库同样生效),无需迁移动作。
21-
SCHEMA_VERSION = 10
21+
# 11:credentials 新增 token_expires_at / token_issued_at(B3.3
22+
# token 到期展示与预警)。
23+
SCHEMA_VERSION = 11
2224

2325
# (表, 列定义):历史库升级时逐条补列
2426
_MIGRATION_COLUMNS: tuple[tuple[str, str], ...] = (
@@ -39,6 +41,10 @@
3941
# 额度包明细(展示用,含包名):与选号指标 quota_expiry_ladder 分开,
4042
# 避免为管理台展示改动调度排序行为
4143
("credentials", "quota_packages TEXT"),
44+
# access token 签发/到期时间(B3.3):上游显式 expires_at 缺失时回落
45+
# JWT 的 iat/exp;NULL=老库未回填(列表读到按需派生),0=确实未知
46+
("credentials", "token_expires_at INTEGER"),
47+
("credentials", "token_issued_at INTEGER"),
4248
)
4349

4450
# 已废弃的表:schema.sql 里已删定义,但老库里可能还留着,必须显式清理。

0 commit comments

Comments
 (0)