Skip to content

Commit 6d61646

Browse files
committed
feat(stats): 明细钻取 + CSV 导出(公式注入防护)
分组表点行即钻取到「请求明细」,并支持把当前范围/过滤导出成 CSV: - StatsQuery._where 补 model / credential_id 过滤(provider 原有),events() 与新增 export_events() 共用;管理台点分组行按该维度钻取(渠道/模型/凭证同名 过滤、用户维度走作用域 username),条件可跨维度叠加,明细面板上方显示过滤 标签 + 一键清除。 - GET /api/stats/events?format=csv:仅 admin/operator(require_operator), 固定旧→新按 rowid ASC,多取一条判截断;上限 EXPORT_MAX_ROWS=10000,超限截断 并置响应头 X-Export-Truncated: 1(前端提示收窄条件)。 - CSV 生成收敛到 src/stats/csv.py 纯函数:公式注入防护给以 = + - @(及制表符/ 回车)开头的**文本**单元格加前导单引号,数值含负数原样(-1.5 在 Excel 里本就 是数字,加引号反毁可计算性);UTF-8 BOM 供 Excel 认编码;时间列 ISO 8601 UTC; CRLF 换行(RFC 4180)。 - 前端:钻取状态合并语义 + 导出按钮(operator 才显示),导出走 fetch→Blob 触发 下载,失败按统一错误体弹中文提示。
1 parent 2b300ff commit 6d61646

14 files changed

Lines changed: 773 additions & 27 deletions

File tree

‎PROPOSAL.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ DDL 以 [src/db/schema.sql](src/db/schema.sql) 为准,共 **12 张表**,`SCH
176176
- **会话**:Cookie `SameSite=Lax` + 写操作 CSRF 校验(含 logout);**无 Cookie 的写端点(登录 / `/authorize`)也做跨站防护**:登录按 Origin/Referer 同源校验(防 login CSRF),`/authorize` 按上游原样回显的 `loginTraceID` 绑定到**发起登录那一次**(TRAE 回调本就跨源,Origin/Referer 既拦不住真攻击也可能误杀真回调);会话与 API Key 除签名 / 摘要外校验用户仍存在、启用且 epoch 一致。
177177
- **错误响应不回流上游正文**:`/v1` 与外层的错误文案只带受控标识(HTTP 状态码 / 上游业务码 / 异常类名),上游错误体摘要只进服务端日志;未匹配的 `/api`、`/v1` 路径返回 JSON 404(不落到 SPA 的 200 + HTML)。**日志脱敏**:不打印 Token、完整请求体;审计写 INFO(含操作人),**绝不记密码/令牌明文**。
178178
- **请求 ID 是客户端可写输入**:入站 `X-Request-ID` 按白名单字符集与长度(`[A-Za-z0-9._:-]{1,128}`)校验后才采信与回显,不合法一律丢弃改用自生成——否则日志与统计列会被人塞进换行/超长串。响应头回显最终 ID(含自生成值),不伪装成上游追踪。
179-
- **租户隔离**:`/api/stats/events` 与 `/api/stats/by-credential` 的**凭证昵称**取自全局共享池(常含邮箱/手机),仅 admin/operator 可见;viewer 即便只看自己的记录也不下发该字段。
179+
- **租户隔离**:`/api/stats/events` 与 `/api/stats/by-credential` 的**凭证昵称**取自全局共享池(常含邮箱/手机),仅 admin/operator 可见;viewer 即便只看自己的记录也不下发该字段。`/api/stats/events?format=csv`(整份明细导出)同样仅 admin/operator。
180180
- **热更配置写入即校验**:float 拒 NaN/±inf;字符串类有长度上限;告警 webhook 只接受 `http(s)` 并做 **SSRF 校验**(写入期拒字面量内网 / 回环 / 链路本地 / 保留 / 多播 / 未指定地址,**投递前再按 DNS 解析结果复核一次**、解析失败即阻断),外发载荷对 `nickname` 打码,多地址并发投递并加总预算。告警出站 client `trust_env=False`,不吃宿主代理环境变量。
181181
- **上游流解析有界**:SSE 单行与单帧缓冲设上限,非 2xx 错误体有界读取;OAuth state store 的 owner/consume 有 TTL 判定。**入站解析把关**:三个出站协议在入口拒绝越界数值字段(`max_tokens` ≤0 或超上限、`temperature`/`top_p` 为 NaN/Inf)与 JSON 里的 `NaN`/`Infinity`。
182182
- **OAuth 登录弹窗**只允许 `http(s)` scheme,弹窗 `opener` 置空。**库版本守卫**:`user_version` 高于本程序时 `apply_schema` 拒绝执行。`scripts/hash_password` / `create_user` 支持 `--password-stdin`;拒绝含 `:` / 换行的用户名。

‎README.en.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,10 @@ The drawer under a credential's quota number lists the **net change between cons
301301

302302
The **Grouped stats** panel tabs four dimensions (channel / model / user / credential) over one table (requests / ok / success rate / input / output / cached / credit / cost), endpoints `GET /api/stats/by-provider|by-model|by-user|by-credential`. Default sort is requests descending; **user and credential tabs are admin/operator only**. **Scope differs**: channel / model / user read the **hourly rollup** (permanent; the last 5 minutes are not yet counted); the **credential** dimension reads request detail (the rollup key has no `credential_id`), covering the **last 90 days**.
303303

304+
**Click-to-drill**: clicking any group row drills the **Request detail** panel below into that dimension (channel / model / credential filter by the same-named column; the user dimension goes through the scope parameter). Conditions stack across dimensions, the active filters show as chips above the panel, and one click clears them. `GET /api/stats/events` therefore accepts `provider` / `model` / `credential_id` filters (AND semantics).
305+
306+
**CSV export**: the **Export CSV** button (admin/operator only) exports request detail for the **current time range and drill filters**, via `GET /api/stats/events?format=csv`. The file carries a UTF-8 BOM (so Excel detects the encoding), `\r\n` line endings, ISO 8601 (UTC) timestamps, and prefixes a leading apostrophe on **text** cells starting with `= + - @` (or tab/CR) to block spreadsheet formula injection (numbers, negatives included, are left as-is). Export is capped at 10000 rows; beyond that it truncates and flags `X-Export-Truncated: 1` in the response headers, and the page tells the user to narrow the conditions.
307+
304308
If history looks light (pre-upgrade detail not covered, or detail backfilled without re-rolling), run `python3 scripts/rollup_hourly.py --apply` (preview by default, backs up before writing, idempotent). It reports gaps and warns about intervals whose detail has passed the 90-day retention and cannot be restored.
305309

306310
### List sorting

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,10 @@ OpenRouter 收录数百条,其中多数本服务没接。每行带 `available`
355355

356356
**分组统计**面板用 tabs 切四个维度(按渠道 / 按模型 / 按用户 / 按凭证),共用同一张表(请求数 / 成功数 / 成功率 / 输入 / 输出 / 命中缓存 / Credit / 成本),对应端点 `GET /api/stats/by-provider|by-model|by-user|by-credential`。默认按请求数降序,点列头可切换排序。**按用户 / 按凭证**只对 admin 与 operator 展示。**口径差异**:渠道 / 模型 / 用户三维读**小时汇总**(永久保留,最近 5 分钟内的请求尚未计入);**凭证维度读明细表**(小时汇总主键里没有 `credential_id`),故只覆盖**最近 90 天**。
357357

358+
**点行钻取**:点分组表任意一行,下方「请求明细」即按该行钻取(渠道 / 模型 / 凭证按同名列过滤,用户维度走作用域参数),条件叠加、可多个维度层层收窄,面板上方显示当前过滤标签并可一键清除。`GET /api/stats/events` 因此接受 `provider` / `model` / `credential_id` 三个过滤参数(`AND` 语义)。
359+
360+
**导出 CSV**:明细面板右上「导出 CSV」(仅 admin / operator)按**当前时间范围 + 当前钻取过滤**导出逐请求明细,`GET /api/stats/events?format=csv`。文件带 UTF-8 BOM(Excel 认编码)、`\r\n` 换行、时间列 ISO 8601(UTC),并对以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号防公式注入(数值含负数原样)。单次导出上限 10000 行,超出截断并在响应头 `X-Export-Truncated: 1` 标出,页面提示收窄条件。
361+
358362
若历史数据偏小(老库升级前落的明细没被覆盖、或补过明细后没重算),用 `python3 scripts/rollup_hourly.py --apply` 一次性重算(默认预览、**写库前自动备份**、幂等)。脚本先报告缺口与两侧时间跨度,并提示**明细已过保留期无法还原**的区间。
359363

360364
### 列表排序

‎TECHNICAL.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ PROPOSAL.md 定方向,本文档定实现。描述**当前代码的现状**,
3737
| `src/engine/` | `scheduler.py` `executor.py` `limits.py` `affinity.py` `continuation.py` `compress.py` `model_resolver.py` `sse.py` | 选号 + 冷却状态机 + pin(纯逻辑,操作 Candidate)/执行 + 模型目录收窄 + 轮换重试 + fallback 链 + 统计埋点/凭证限额护栏判定 + 选号前快照缓存/会话粘性/截断续写/上下文压缩裁剪(纯函数)/`"glm-5.2"`·`"glm-5.2@trae"`·`auto` → 候选集 + fallback 组解析/SSE 帧解析 + 行长上限 |
3838
| `src/compat/` | `openai/{request,response,errors}.py` `responses/{request,response}.py` `anthropic/{request,response}.py` | ChatRequest 校验·chunk 聚合·OpenAI error shape/Responses 出口(Codex CLI 子集)/Anthropic Messages 出口(Claude Code 子集) |
3939
| `src/tasks/` | `pacer.py` `quota_probe.py` `checkin.py` `growth.py` `activity.py` `refresh.py` `retention.py` `backup.py` `alerting.py` `status.py` `runner.py` | 全局节流器(随机区间 + 按桶并发上限 + 滑动窗口)/启动立即一轮 + 每 `QUOTA_PROBE_MINUTES`/全天每 10 分钟签到、成功即封账(支持渠道回填 `seal_until`)/成长中心(仅 CB)落 `growth_events`/活跃上报(CB 与 CB 国际版,默认关闭)/每 `REFRESH_INTERVAL_MINUTES` 预刷新/每 5 分钟小时汇总重算 + 90 天前明细清理/每 `BACKUP_INTERVAL_MINUTES` 备份主库(`VACUUM INTO`,落线程池)/运维告警(四类规则 + webhook + 落库)/任务清单 `TASK_SPECS` + 进程内运行态/后台任务调度,接入应用生命周期 |
40-
| `src/stats/` | `backfill.py` `collector.py` `query.py` | 历史成本一次性回填与重算(`scripts/backfill_cost.py` 复用)/`usage_events` 写入(脱敏)+ 小时汇总双写·重算/`overview`·`by-*`·`timeline`·`model-timeline`·`events` |
40+
| `src/stats/` | `backfill.py` `collector.py` `query.py` `csv.py` | 历史成本一次性回填与重算(`scripts/backfill_cost.py` 复用)/`usage_events` 写入(脱敏)+ 小时汇总双写·重算/`overview`·`by-*`·`timeline`·`model-timeline`·`events`(含钻取过滤与导出取数)/CSV 导出纯函数(列定义 + 公式注入防护 + 行编码) |
4141
| `src/` 顶层 | `sorting.py` `benchmarks.py` `pricing.py` `model_match.py` `request_context.py` | 列表排序 `sort`/`order` 白名单解析(db 与 api 共用)/OpenRouter 单一源:刊例价 + 明细元数据 + 能力指数 + 落盘快照/单请求成本估算(USD→CNY)/本地模型名 ↔ OpenRouter 条目匹配(仅 benchmarks·pricing 用)/请求 ID 的 contextvar + 白名单校验 + 自生成(中间件设置、统计写入现读) |
4242
| `src/api/` | `deps.py` `endpoints.py` `chat.py` `responses.py` `messages.py` `context.py` `models.py` `model_merge.py` `model_catalog.py` `balance.py` `authorize.py` `admin_credentials.py` `admin_keys.py` `admin_settings.py` `admin_users.py` `admin_audit.py` `admin_alerts.py` `admin_model_catalog.py` `activate.py` `admin_stats.py` `admin_auth.py` `playground.py` `streaming.py` | Services 容器 + 鉴权·会话·CSRF 依赖/三个 /v1 出口共用的 `dispatch_compat` 骨架/`POST /v1/chat/completions`·`/v1/responses`·`/v1/messages`/请求入口的上下文压缩接线/`GET /v1/models`(按凭证加载 + SWR + 缓存·TTL + 落盘 + 黑名单 + 白名单)/模型归一·合并·命名·对外 id 消歧·别名表(纯函数)/模型目录落盘快照 + 别名预热/`GET /v1/user/balance`/`GET /authorize`(TRAE 回调落点)/凭证 CRUD·toggle·pin·probe·checkin·成长·账号切换/API Key CRUD(渠道绑定 + 双白名单 + 到期)/`GET`/`PUT /api/settings` + `GET /api/tasks`/用户管理/审计查询/告警回看/模型目录只读查看/一次性令牌激活流/统计查询/登录·登出·会话·自助改密 + 上游登录 start·poll·complete·cancel/会话调试端点(无需 API Key)/SSE 流包装(长空隙插心跳帧) |
4343
| `src/audit/actions.py` | — | 审计动作常量 + 中文标签 |
@@ -463,6 +463,7 @@ PRAGMA foreign_keys = ON; -- 仅 api_keys 之外无外键(users 与 api_k
463463
- **请求 ID(`request_id`)**:每个请求带一个 `X-Request-ID`(`src/webapp/request_id.py` 挂在最外层,任意响应含 413/400 都带头)。入站头合法(`[A-Za-z0-9._:-]{1,128}`)则沿用,否则 `uuid4().hex` 自生成;ID 经 `src/request_context.py` 的 contextvar 传递,`StatsCollector.record` 现读落进 `usage_events.request_id`(跨引擎层,不为一个可观测性字段给所有中间层加参数)。`events` 端点透出该列,统计页时间列悬停展示。**不透传上游**(各渠道出站头语义不同,见 `request_context.py` 模块注释)。
464464
- **延迟均值只算成功请求**(分子 `SUM(latency_ms WHERE ok=1)` 与分母 `ok_count` 配对)。
465465
- **回填脚本**:`scripts/backfill_trae_credit.py`(TRAE 推算积分)、`scripts/convert_codearts_credit_unit.py`(CodeArts token→积分,**只能跑一次**)、`scripts/backfill_cost.py`(成本,默认预览、写库前自动备份、幂等)、`scripts/rollup_hourly.py`(小时汇总重算)。均先报告缺口,再 `--apply` 写库。
466+
- **钻取与导出**:`events` 端点接受 `provider` / `model` / `credential_id` 三个明细过滤(`AND`),管理台分组表点行即按该维度钻取(用户维度走作用域参数 `username`),条件可叠加。`?format=csv` 走导出:仅 admin/operator(`require_operator`),固定旧→新按 `rowid ASC`,多取一条判截断(上限 `EXPORT_MAX_ROWS = 10000`,超限截断并置响应头 `X-Export-Truncated: 1`)。CSV 生成收敛在 `src/stats/csv.py` 纯函数:**公式注入防护**给以 `= + - @`(及制表符/回车)开头的**文本**单元格加前导单引号,数值(含负数)原样(`-1.5` 在 Excel 里本就是数字,加引号反而毁掉可计算性);带 UTF-8 BOM 供 Excel 认编码;时间列 ISO 8601 UTC;`\r\n` 换行(RFC 4180)。
466467
### 9.8 列表排序
467468
所有 `GET` 列表端点接受统一的 `sort`(白名单键)+ `order`(`asc`/`desc`)。**未知 `sort` 键或非法 `order` 值回落到默认排序,不报错**;`sort` 是白名单键而非 SQL 列名,SQL 片段只由受控常量拼出,用户输入永不进 SQL,注入面为零。契约在 `src/sorting.py`(db 与 api 共用,避免 db → api 反向依赖):`parse_sort_order`(唯一解析入口)、`sql_order`(`tiebreak` 兜底稳定次序)、`sort_rows`(组装后再排,`None` 值无论升降序都排**最后**)。逐请求明细默认(`time` 降序)走 rowid 游标分页返回 `next_before`;按其它列排序 / 时间升序 / 显式 `offset` 时改用 `LIMIT/OFFSET` 并返回 `total`。适用端点:`/api/credentials`、`/api/users`、`/api/api-keys`、`/api/audit`、`/api/alerts`、`/api/model-catalog`、`/api/credentials/{id}/credit-events`、`/api/credentials/{id}/accounts`、`/api/stats/by-*`、`/api/stats/events`。
468469

‎src/api/admin_stats.py‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22

33
from __future__ import annotations
44

5+
from collections.abc import Iterator
6+
from datetime import UTC, datetime
7+
58
from fastapi import APIRouter, Depends
9+
from fastapi.responses import StreamingResponse
610

7-
from ..auth.rbac import Principal
11+
from ..auth.rbac import Principal, require_operator
12+
from ..stats.csv import CSV_BOM, EXPORT_HEADERS, csv_line, event_cells
13+
from ..stats.query import EXPORT_MAX_ROWS
814
from .deps import Services, principal_from_request
915

1016

@@ -17,6 +23,18 @@ def _scope(principal: Principal, username: str | None) -> str | None:
1723
return username if principal.is_operator else principal.username
1824

1925

26+
def _export_filename(now: int) -> str:
27+
stamp = datetime.fromtimestamp(now, tz=UTC).strftime("%Y%m%d-%H%M%S")
28+
return f"coding2api-events-{stamp}Z.csv"
29+
30+
31+
def _csv_stream(rows: list[dict]) -> Iterator[str]:
32+
"""把明细行编成 CSV 文本块(含 BOM 与表头)。"""
33+
yield CSV_BOM + csv_line(list(EXPORT_HEADERS))
34+
for row in rows:
35+
yield csv_line(event_cells(row))
36+
37+
2038
def create_router(services: Services) -> APIRouter:
2139
router = APIRouter()
2240
stats_query = services.stats_query
@@ -76,12 +94,37 @@ async def stats_events(principal=Depends(principal_from_request),
7694
username: str | None = None, since: int | None = None,
7795
before: int | None = None, limit: int = 50,
7896
offset: int = 0, sort: str | None = None,
79-
order: str | None = None):
97+
order: str | None = None,
98+
provider: str | None = None, model: str | None = None,
99+
credential_id: str | None = None,
100+
format: str = "json"):
80101
# 明细保留 90 天;单页上限 200,防止一次拉爆
81102
# 凭证昵称来自共享池:仅 admin/operator 可见,viewer 只看自己的记录
82-
return stats_query.events(username=_scope(principal, username), since=since,
103+
scope = _scope(principal, username)
104+
names = principal.is_operator
105+
if format == "csv":
106+
# 导出仅 admin/operator:整份明细落盘比翻页浏览的暴露面大得多,
107+
# 且文件名/内容都带池级信息,viewer 不该有。
108+
require_operator(principal)
109+
rows = stats_query.export_events(
110+
username=scope, since=since, include_credential_name=names,
111+
provider=provider, model=model, credential_id=credential_id)
112+
truncated = len(rows) > EXPORT_MAX_ROWS
113+
rows = rows[:EXPORT_MAX_ROWS]
114+
now = int(datetime.now(tz=UTC).timestamp())
115+
headers = {
116+
"Content-Disposition": f'attachment; filename="{_export_filename(now)}"',
117+
"X-Export-Row-Count": str(len(rows)),
118+
"X-Export-Truncated": "1" if truncated else "0",
119+
"Cache-Control": "no-store",
120+
}
121+
return StreamingResponse(
122+
_csv_stream(rows), media_type="text/csv; charset=utf-8", headers=headers)
123+
return stats_query.events(username=scope, since=since,
83124
before=before, limit=max(1, min(limit, 200)),
84125
offset=max(0, offset), sort=sort, order=order,
85-
include_credential_name=principal.is_operator)
126+
provider=provider, model=model,
127+
credential_id=credential_id,
128+
include_credential_name=names)
86129

87130
return router

0 commit comments

Comments
 (0)