Skip to content

Commit 65ea631

Browse files
committed
feat(B3.4): 积分变动流水 + 凭证行内「积分记录」抽屉
计划原文要求 credit_events.source 标注来源(签到/成长/对话)。实测 三类证据都拿不到真实归因:探测 diff 只能看到区间净变化,这段区间里 签到、成长领取与对话消耗可能同时发生;growth_events 无积分快照; 上游签到/成长接口本就不打日志。故 source 改为只表达**归因已知度** (observed=常规探测区间 / sync=首次建立基线),另加 window_start 记录变化覆盖时段。前端文案一律说「净变化」,不写「签到 +N」——把 净变化说成某个动作的成果就是拿猜测当事实。 - 新表 credit_events(id, credential_id, ts, window_start, before, after, delta, source)(SCHEMA_VERSION 11→12;新表只需进 schema.sql, 老库靠 CREATE TABLE IF NOT EXISTS 生效)。 - 写入放在 save_quota 的同一个事务里:分两次读改写会与并发探测交错, 记出「before 是别人写过的值」的错行。 - 只记有信息量的行:首次探测记 sync 基线(delta 为空,不是 +0); 余额未变不记(否则每轮落一行 0);任一端未知仍记但 delta 为空 (「余额变未知」是该追的异常,绝不量化成 0);两端都未知不记; 凭证被并发删除不记(避免悬挂行)。 - window_start 取**上次探测时刻**而非「上次有变动」:中间没变化但同样 观测过,区间必须从最近一次观测算起。 - 浮点护栏:上游 JSON 浮点运算会给出 100.00000000000001 这种值, 按 1e-9 判等并把 delta 收敛到 6 位小数,避免记出 delta≈0 的假变动 与 7.229999999999563 这类脏展示值。 - GET /api/credentials/{id}/credit-events?limit=20(管理员;limit 收敛 到 [1,200];未知凭证 400 而非空列表,避免拼错 id 时看起来「没有记录」)。 - 前端凭证行菜单「积分记录」→ 行内抽屉。抽屉必须是**独立的 <TableRow>**: 塞进凭证行内部的单元格会挤在「操作」列里(同行内 colSpan 不生效), 这个坑单测发现不了,是浏览器实测截图抓到的,已补结构断言防复发。 - RetentionTask 按 usage_events 同期限(90 天)回收,报告新增 purged_credit_events;未传 credit_events 时键为 0(兼容老调用方)。 验证:ruff 通过;后端 1279 passed,行+分支 100%;前端 tsc + vitest(144) + build 全绿。真库副本端到端:user_version 11→12 建表成功、 列结构正确、基线/净增/净减三类行按预期写入;浏览器实测抽屉布局与 文案(「净变化(非动作归因)」「基线 100」「由 X 变为未知」)均正确。
1 parent 707e624 commit 65ea631

19 files changed

Lines changed: 696 additions & 26 deletions

‎PROPOSAL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,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 与日志明示,可「恢复默认」清掉覆盖。启动期项(密钥 / 端口 / 数据目录 / 上游白名单)不进白名单——它们决定进程如何启动,运行期改只会让内存与磁盘静默分叉 |
4343
| 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` 就不画条。老库不批量回填:列表读到时按需从密文派生,写回后走列值 |
44+
| Q36 | 积分变动流水 | 新增 `credit_events` 表(`SCHEMA_VERSION` 11→12),在额度探测写回的**同一事务**里比对余额、只增记一条。**计划原文要求 `source` 标注来源(签到/成长/对话),但实测三类证据都拿不到真实归因**:diff 只能看到区间净变化,这段区间里签到、成长领取与对话消耗可能同时发生;`growth_events` 无积分快照;上游接口本就不打日志。故 `source` **改为只表达归因已知度**——`observed`(常规探测区间)/ `sync`(首次建立基线),另加 `window_start` 记录变化覆盖时段,前端文案一律说「净变化」而非「签到 +N」。余额未变不记(避免每轮 0 行淹没);任一端未知仍记但 `delta` 为空(「变未知」是该追的异常,绝不量化成 0)。保留期与 `usage_events` 一致(90 天) |
4445

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

@@ -243,6 +244,7 @@ v1 只接 OpenAI 出口,但上游 SSE 解析到「中立事件」这一步独
243244
- **用量脱敏**:`usage_events`(明细 90 天)+ `usage_hourly`(小时汇总永久),`credit`/`cached_tokens` 可空仅辅助展示
244245
- **成长中心**:`growth_events` 只存汇总行(一轮一行人话汇报 + 积分/能量/连签 + trigger),不存活动内部数据结构;`credentials.growth_last_run_at/growth_last_result` 供列表直接显示;活跃上报(B1.7)复用该表记一行,不新增表
245246
- **token 到期**(Q35):`credentials.token_expires_at`(显式 `expires_at` 优先,缺失回落 access token 的 JWT `exp`;0 = 未知)与 `token_issued_at`(JWT `iat`,即最后续期;0 = 未知)。二者必须一起展示——剩余天数会被刷新拉满,单看会读反;进度条满量程也取二者之差。派生逻辑在渠道中立的 `provider/token_expiry.py`,**不猜本地 TTL**
247+
- **积分流水**(Q36):`credit_events` 记两次额度探测之间的净变化(含 `window_start` 覆盖区间与归因已知度 `source`);**不是动作归因**——上游不打日志,diff 分不出分数是谁加的。保留期同 `usage_events`(90 天)
246248
- 签到去重与模型列表缓存均进程内实现,不进库
247249

248250
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
@@ -28,6 +28,7 @@ upstream channels, with a shared credential pool, unified scheduling, and per-us
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
3030
- **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
31+
- **Credit-change log**: the per-credential "credit record" drawer lists the **net change between consecutive quota probes** (`credit_events`, same 90-day retention as usage detail). It deliberately does **not** attribute changes to check-in / growth / chat: upstream logs nothing for those calls, so a diff cannot tell who added the points. The UI says "net change", never "check-in +5". First probe only records a baseline (`sync`); unchanged balances are skipped; a balance going *unknown* still records a row with no delta, because that is an anomaly worth chasing rather than "no change"
3132

3233
## Quick start
3334

‎README.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,17 @@ token 寿命 50+ 天、TRAE 约 12 天,用固定量程会把前者永远画成
143143
两边都拿不到时显示为未知并隐藏整块,**不猜本地 TTL**——否则管理台会显示一个凭空捏造的
144144
到期预警。剩余时间低于 `TOKEN_EXPIRY_WARNING_SECONDS`(默认 1 小时)时标红。
145145

146+
### 积分记录
147+
148+
凭证行内菜单的「积分记录」看每次余额变化的来龙去脉。它记录的是**两次额度探测之间的
149+
净变化**,不是动作归因:签到、成长中心、对话消耗都会改余额,而**上游这些接口不打日志**,
150+
探测只能看到区间净变化,分不出这几分是谁加的。所以界面一律写「净变化」,不写「签到 +5」
151+
——把净变化说成某个动作的成果就是拿猜测当事实。
152+
153+
首次探测只建立基线(`sync`,不算积分);余额没变不记(否则每轮探测落一行 0);
154+
余额变成「未知」(探测失败后)仍会记一行且不填变化量——「余额变未知」是该追的异常,
155+
不能当成「没有变化」。记录与请求明细同样保留 90 天。
156+
146157
## 后台任务
147158

148159
额度探测、token 预刷新、每日签到、成长中心、明细清理由 `TaskRunner` 自动调度(失败互不影响),周期见 TECHNICAL.md §6.1。

‎TECHNICAL.md‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ coding2api/
8686
│ │ ├── checkin.py # 全天每 10 分钟签到;成功即当日封账该凭证
8787
│ │ ├── growth.py # 成长中心(仅 CB):GROWTH_INTERVAL_MINUTES 一轮,落 growth_events
8888
│ │ ├── refresh.py # 每 60 分钟;REFRESH_SKEW_HOURS 窗口内预刷新
89-
│ │ ├── retention.py # 每 5 分钟:小时汇总重算(幂等)+ 90 天前明细清理
89+
│ │ ├── retention.py # 每 5 分钟:小时汇总重算(幂等)+ 90 天前明细 / 积分流水清理
9090
│ │ └── runner.py # 后台任务调度,接入应用生命周期
9191
│ ├── stats/
9292
│ │ ├── collector.py # usage_events 写入(脱敏)+ 小时汇总双写/重算
@@ -504,6 +504,44 @@ UI 与日志都必须明示「DB 覆盖 .env」,否则用户改 `.env` 不生
504504
**接口**:`GET /api/credentials` 响应新增 `token_expiry_warning_seconds`;每条凭证新增
505505
`token_expires_at` 与 `token_issued_at`(均为 0 表示未知)。
506506

507+
### 3.10 积分变动流水(B3.4)
508+
509+
**要解决的问题**:签到 / 成长中心 / 对话都会改余额,但上游这些接口**不打日志**,
510+
拿不到「这次动作加了多少分」。管理台此前只能看到「当前剩余」这一张快照。
511+
512+
**做法:在额度探测写回时比对余额,只增记一条**(`credit_events`)。写入刻意放在
513+
`save_quota` 的**同一个事务**里:分两次读改写会与并发探测交错,记出「before 是别人
514+
写过的值」的错行。
515+
516+
**归因纪律(本批最重要的取舍)**:diff 只能看到 `[上次探测, 本次探测]` 区间的**净变化**。
517+
这段区间里签到、成长领取与对话消耗可能同时发生,diff 无法区分谁贡献了多少。所以
518+
`source` 列**只表达归因已知度**,不写来源枚举:
519+
520+
- `observed` = 两次探测之间的净变化(delta 可为正、负,或为 NULL)
521+
- `sync` = 首次建立基线,没有对照(delta 为 NULL,不是「+0」)
522+
523+
前端配套文案一律说「净变化」,不写「签到 +5」——那等于把猜测当事实。
524+
525+
**只记有信息量的行**(否则表会被噪声淹没):
526+
527+
| 情形 | 是否记 | 理由 |
528+
|---|---|---|
529+
| 首次探测(无对照) | 记(`sync`),`delta` 为 NULL | 「从无到有」不是一次真实的积分变动 |
530+
| 余额未变 | **不记** | 否则每轮探测落一行 0 |
531+
| 余额变化 | 记(`observed`),`delta = after - before` | 正负都如实记 |
532+
| 任一端未知 | 记,`delta` 为 NULL | 「余额变未知」本身是该追的异常,绝不量化成 0 |
533+
| 两端都未知 | **不记** | 什么也没学到 |
534+
| 凭证被并发删除 | **不记** | 避免悬挂行 |
535+
536+
`window_start` 记的是**上次探测时刻**(不是「上次有变动」):中间那次没变化但同样
537+
观测过,区间必须从最近一次观测算起,否则会把一段无人观测的时间也算进去。
538+
539+
**接口**:`GET /api/credentials/{id}/credit-events?limit=20`(管理员;未知凭证 400
540+
而不是空列表,避免拼错 id 时看起来「没有记录」)。前端为凭证行内的「积分记录」抽屉。
541+
542+
**保留**:`RetentionTask` 按 `usage_events` 同一保留期(90 天)回收,报告里体现为
543+
`purged_credit_events`。
544+
507545
---
508546

509547
## 4. Provider 协议(Q16=A 细接口)
@@ -644,7 +682,7 @@ class Scheduler:
644682
| 每日签到(checkin.py) | 每 10 分钟(全天) | 成功即封账该凭证当日(`日期:scope`,进程内内存态,重启重建);失败凭证持续重试,同账号多凭证共享一次 |
645683
| 成长中心(growth.py) | 每 `GROWTH_INTERVAL_MINUTES`(默认 60,下限 5 分钟) | 仅 CodeBuddy:领旅行礼物 / 派 Buddy / 领取新任务 / 领任务奖 / 断登补登 / 连登兑换 / 开盲盒 / Buddy 盲盒;结果落 `growth_events` + 回写 `credentials.growth_last_result` |
646684
| 活跃上报(activity.py,默认关闭) | 每 10 分钟醒一次,仅 `ACTIVITY_REPORT_HOUR`(默认 10 点,北京时间)窗口内执行 | 仅 CodeBuddy:补发一条 `chat_request_send` 续连登;按「endpoint + userId」隔离,当日封账;成功落一行 `growth_events` |
647-
| 明细清理(retention.py) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert,与 record 的增量双写对账)+ 90 天前明细清理 |
685+
| 明细清理(retention.py) | 每 5 分钟 | `usage_events` 全量重算小时汇总(幂等 upsert,与 record 的增量双写对账)+ 90 天前明细清理;顺带按同期限回收 `credit_events`(见 §3.10) |
648686

649687
**签到 / 成长中心的「同账号」隔离键**:`checkin_scope(data) or f"credential|{credential_id}"`。
650688
provider 在身份未知时返回空串(CB 的 `checkin_scope_key` 在 `account_uid` 与

‎src/api/admin_credentials.py‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,20 @@ async def growth_history(credential_id: str, limit: int = 20,
190190
raise InvalidRequest("credential not found")
191191
return {"events": services.growth_events.recent(credential_id, limit=limit)}
192192

193+
@router.get("/api/credentials/{credential_id}/credit-events")
194+
async def credit_events(credential_id: str, limit: int = 20,
195+
principal=Depends(principal_from_request)):
196+
"""积分变动流水:两次额度探测之间的净变化(倒序)。
197+
198+
刻意不返回「来源」枚举:上游不打日志,diff 看到的只是区间净变化,
199+
写「签到 +5」就是把猜测当事实。source 只表达归因已知度
200+
(observed=常规区间 / sync=首次建立基线)。
201+
"""
202+
require_admin(principal)
203+
if services.credentials.provider_of(credential_id) is None:
204+
raise InvalidRequest("credential not found")
205+
return {"events": services.credit_events.recent(credential_id, limit=limit)}
206+
193207
@router.post("/api/credentials/{credential_id}/growth")
194208
async def run_growth(credential_id: str,
195209
_csrf: None = Depends(csrf_protected),

‎src/api/deps.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
from ..auth.throttle import LoginThrottle
2222
from ..compat.openai.request import InvalidRequest
2323
from ..config import Settings
24-
from ..db.repo import ApiKeyRepository, CredentialRepository, GrowthRepository
24+
from ..db.repo import (
25+
ApiKeyRepository,
26+
CredentialRepository,
27+
CreditEventRepository,
28+
GrowthRepository,
29+
)
2530
from ..engine.executor import Executor
2631
from ..runtime_settings import RuntimeSettings
2732
from ..stats.query import StatsQuery
@@ -41,6 +46,7 @@ class Services:
4146
settings: Settings | RuntimeSettings
4247
credentials: CredentialRepository
4348
growth_events: GrowthRepository
49+
credit_events: CreditEventRepository
4450
api_keys: ApiKeyRepository
4551
executor: Executor
4652
registry: dict[str, Any]

‎src/db/migrate.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@
2020
# schema.sql(CREATE TABLE IF NOT EXISTS 对老库同样生效),无需迁移动作。
2121
# 11:credentials 新增 token_expires_at / token_issued_at(B3.3
2222
# token 到期展示与预警)。
23-
SCHEMA_VERSION = 11
23+
# 12:新增 credit_events 表(B3.4 积分变动流水)。新增表只需进
24+
# schema.sql(CREATE TABLE IF NOT EXISTS 对老库同样生效),无需迁移动作。
25+
SCHEMA_VERSION = 12
2426

2527
# (表, 列定义):历史库升级时逐条补列
2628
_MIGRATION_COLUMNS: tuple[tuple[str, str], ...] = (

0 commit comments

Comments
 (0)