Compare commits

...
47 Commits
Author SHA1 Message Date
skycurtain 29019fc5d5 refactor(cli): naku 与 pack:release 合并为 pack CLI——release/build 子命令,--yes 非交互 2026-10-29 15:36:06 +08:00
skycurtain 8d4a5c4f94 docs: README 润色收尾——定位中性化、补适用场景与 AI 协作物料说明 2026-10-29 00:45:34 +08:00
skycurtain ddb1b56459 chore: MIT 许可证落地——LICENSE 全文、package.json license 字段与 README 边界声明 2026-10-28 23:14:41 +08:00
skycurtain fdc5942a72 feat(web): 管理端日志来源徽章支付弹窗与支付记录筛选——订阅 ID / 交易号可筛,弹窗可跳转核对补录 2026-10-18 12:22:02 +08:00
skycurtain 199972a2dc feat: 调用日志补模型筛选与汇总条——可按模型收窄查询,并在当前筛选下直接读出请求次数与消费用量 2026-10-14 08:01:58 +08:00
skycurtain b0e9ea750b feat: 账本域 billing_domain 与成员支付可见性收紧
三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
2026-10-13 20:42:26 +08:00
skycurtain 843ab4433a fix(web): 复制令牌兼容 HTTP 非 Secure Context 2026-10-13 12:42:11 +08:00
skycurtain 75a567611b fix(deploy): COPY --chmod=755 根治单文件二进制丢失可执行位
Windows 打包与跨机传输会使 naku-* 丢掉 +x,容器内 exec 以 126
(Permission denied)退出,migrate 失败后 server/engine 不会启动。
镜像内用 COPY --chmod=755 固定可执行位,并同步部署方案说明。
2026-10-13 11:03:35 +08:00
skycurtain c0051cff03 feat(deploy): Caddy 对外端口支持 HTTP_PORT / HTTPS_PORT 配置 2026-10-13 10:39:12 +08:00
skycurtain 2dbd625e87 docs: 对齐实现状态——进程拆分、部署与支付拆表相关稿定稿 2026-10-13 01:11:13 +08:00
skycurtain 905d4ce77e feat(deploy): 产物包部署链路与 naku 打包 CLI
Docker Compose 五服务 + Debian slim 单文件运行时;migrate 幂等门闩;
naku pack/build(citty + clack);部署方案文档入档。
2026-10-13 00:56:43 +08:00
skycurtain 86782c63ad chore(deploy): 移除 Docker 配置与文档中的部署方案,待从代码重议 2026-10-12 19:02:38 +08:00
skycurtain 9966f03347 feat(web): 时间选择器增加「今天」快捷项
北京日界左闭右开;相对窗口可跨日自动重算。
2026-10-12 15:00:29 +08:00
skycurtain d0bab545b1 chore: 服务启动不再自动迁移数据库
schema 变更改为显式 db:push / 手动应用后再启动。
2026-10-12 13:57:25 +08:00
skycurtain f4f5ed0b0f feat(member): 档案修改与空壳删除
可改 name/remark;key_count=0 时硬删,名下有 key 仅可停用。PATCH 扩档案字段并落审计。
2026-10-12 13:57:19 +08:00
skycurtain e293a8e5a9 docs: R16 成员档案修改与空壳删除裁决入档
成员操作面扩为可改 name/remark、key_count=0 时硬删空壳;软删除仍是账本实体默认语义。
2026-10-11 22:41:43 +08:00
skycurtain 34a7823e1a feat(web): 根路径与未知路径默认进入成员端
管理端入口形态待定,当前经地址栏 /admin 进入;交互设计访问拓扑同步。
2026-10-10 16:47:01 +08:00
skycurtain ea4efbe1d7 feat(deploy): 一条命令链部署——双单文件 + Caddy + DATABASE_URL 单源
多阶段镜像编出 server/engine 单文件与前端静态;compose 起 pg/server/engine/caddy;
启动时自动迁移;仅用 DATABASE_URL,postgres 建库参数由 entrypoint 拆解。
2026-10-10 16:46:54 +08:00
skycurtain 5f76bfc9d6 docs: 清除版本叙事并按开源文档体系整理
去掉初版/新版与分支指称,重写 README/AGENTS,删子包 README,对齐文档交叉引用与已废止测试脚本。
2026-10-10 07:56:07 +08:00
skycurtain 8816c32025 chore: vite 固定 5175,脚本统一 --cwd,清理未用导入 2026-10-10 01:36:57 +08:00
skycurtain 4be0853ade feat(web): 表格工具行统一刷新钮
纯图标 + tooltip,筛选下一行右对齐;覆盖管理端与成员端列表;清理 overview 未用 ref。
2026-10-09 23:42:24 +08:00
skycurtain 4a7a3e76ee fix(web): 成员侧边栏品牌与管理端对齐 2026-10-09 23:20:41 +08:00
skycurtain 502f985147 feat(web): 成员端仅总览轮询,相对时间窗定格 2026-10-09 23:20:05 +08:00
skycurtain e3e0978b95 fix(web): 订阅表付款列头改为「付款金额 / 时间」 2026-10-09 22:43:18 +08:00
skycurtain c32548e598 fix(web): 支付表列与表单语义校准
充值金额/实付金额文案与 dataIndex;期间两行;备注列收窄;
选用弹层 booting 不闪登录;订阅 paid_at 不默认当前时间。
2026-10-09 22:41:04 +08:00
skycurtain 00c08b37ff docs: payments 拆表设计定稿与契约/交互对齐
新增建模评估与 subscriptions/topups 表设计;删除 payments 单表篇;
契约、支付交互、需求清单与文档地图同步。
2026-10-09 22:03:43 +08:00
skycurtain c03fdc3d67 feat(web): 支付记录双 Tab 与平台选用弹层
订阅/充值分形态表单(秒级时间、金额非负、额度换算预览);
选用弹层已登记灰显、每次打开重拉、关弹窗清密码。
2026-10-09 22:02:36 +08:00
skycurtain 10d8f4c915 feat(server): 订阅/充值 CRUD 与平台选用分页
admin-subscriptions/topups 取代 payments;member overview 分两集;
platform/topups 单页代拉;上游 token 失效回 422,避免误踢管理端会话。
2026-10-09 22:02:20 +08:00
skycurtain c7cc6f6dfd feat(db): payments 拆为 subscriptions 与 topups
订阅权益与充值入账分表;topups 对齐上游充值单(无 quota_granted),
paid_at=complete_time;quota_derived 查询层派生。
2026-10-09 22:02:10 +08:00
skycurtain d4d740222e fix(web): 平台订阅选用弹窗居中并限高内滚 2026-10-09 20:50:15 +08:00
skycurtain 0269cf3386 feat(web): 成员端同构三页与日志详情,管理端轻量筛选
成员视图改管理端壳(总览/调用日志/支付参数),行内详情弹窗含原始数据;
成员日志含错误行;共享时间范围。管理端成员/支付筛选;支付仅留类型筛选。
2026-10-09 17:04:59 +08:00
skycurtain 665fa088f5 feat(web): 管理端菜单顺序与总览操作列查调用 2026-10-09 13:42:14 +08:00
skycurtain 31d963e956 fix(core): 明细用量美元保留 6 位小数 2026-10-09 13:42:13 +08:00
skycurtain 29f5f4f84c fix(web): 成员视图模型列与支付结构,范围 watch 勿误重置 2026-10-09 02:28:21 +08:00
skycurtain 431b27ea58 docs: 调用日志交互定稿,导航与契约界面落点对齐 2026-10-09 01:42:27 +08:00
skycurtain 008eb14adc feat(web): 管理面调用日志——筛选明细与 key/成员跳转链路 2026-10-09 01:41:45 +08:00
skycurtain 2e91c593ab feat(web): 成员视图——聚合、支付参数、key 列表与日志徽章展开 2026-10-08 23:57:54 +08:00
skycurtain b2f65a1d55 chore: 纳入 antdv 协作 skills,忽略本机 Agent 接线目录 2026-10-08 23:42:48 +08:00
skycurtain 932e74ba3b fix: 取消密码长度规则,仅保留必填 2026-10-08 23:27:43 +08:00
skycurtain 4d31c20d35 feat(web): 审计表体内滚动、时间范围筛选与分页档位 2026-10-08 23:27:42 +08:00
skycurtain 66a9a93914 feat(web): 审计日志表与账号设置改密 2026-10-08 23:02:16 +08:00
skycurtain 1b57b75be0 fix(web): 未登记订阅提示改为单条 Alert + Tag 编号换行 2026-10-08 22:49:37 +08:00
skycurtain ed2ff0f950 feat: 支付登记从平台选择订阅——登录拉取预填,密码不落库 2026-10-08 22:32:33 +08:00
skycurtain 29e0317155 fix(server): 数据截止仅统计 active key,停用 key 不拉慢全池水位 2026-10-08 15:32:07 +08:00
skycurtain b33fc64a28 feat(web): 请求时间改为 ISO8601+08:00 偏移,响应仍为 Unix 秒 2026-10-08 14:12:07 +08:00
skycurtain 8b54629159 feat(web): 支付记录——同构登记表单、额度双输入与核对提示 2026-10-08 00:47:05 +08:00
skycurtain 9aed3cbe8d fix(web): Dropdown trigger 传数组以消除 prop 警告 2026-10-08 00:47:04 +08:00
117 changed files with 11940 additions and 1127 deletions
@@ -0,0 +1,343 @@
---
name: antdv-next-cli-create-pr
description: Create pull requests for antdv-next/cli using the repository's PR conventions. Use this skill when the user asks to create/open a PR, draft PR title/body, summarize branch changes for a PR, or otherwise prepare PR content. Judge by intent rather than fixed phrases; short colloquial requests still count if they are about creating a PR rather than discussing PR concepts.
---
# Antdv Next CLI PR 创建规范
## 目标
一、基于当前分支相对基线分支的全部改动生成 PR,不只看最后一个 commit。
二、优先使用仓库自带 PR 模板;当前没有模板时,使用本 skill 约定的 `Summary` / `Testing` 结构。
三、PR 正文默认使用英文,用户明确要求中文时可使用中文;PR 标题始终使用英文,并遵循本文档约定的命名格式。
四、真正执行 `gh pr create` 之前,必须先把 `base`、`title`、`body` 给用户确认,确认后才能创建 PR。
## 基本规则
### 一、按意图触发,不按短语触发
只要能判断用户是在请求创建 PR,或为创建 PR 做准备,就应使用本 skill。
不要把触发限制成固定说法。即使用户表达很短、很口语,或要求不完整,只要不是在单纯讨论 PR 概念,也应进入本 skill 的工作流。
### 二、正文结构以仓库约定为准
当前仓库没有 `.github/PULL_REQUEST_TEMPLATE*`。默认使用:
- `## Summary`
- `## Testing`
如果仓库之后新增 PR 模板,必须先读取并使用模板,不要自行改 section 名称或删除主结构。
### 三、正文默认英文,但标题固定英文
现有 PR 正文以英文为主,因此默认使用英文正文。用户明确要求中文时可以使用中文正文。
无论正文使用哪种语言:
- `PR title` 都必须是英文
- `PR title` 必须符合 Conventional Commits 格式
- 标题 subject 必须以小写字符开头,以通过 `.github/workflows/validate-pr-title.yml`
### 四、先分析分支,再写 PR
创建 PR 前,必须先看:
- 当前分支名
- 基线分支
- 当前分支相对基线分支的 commit 列表
- `base...HEAD` 的完整 diff
不要只根据工作区未提交内容写 PR,也不要只根据最近一个 commit 写 PR。
### 五、先给草稿,后创建 PR
无论用户是否说“直接帮我创建 PR”,都要先完成以下步骤:
1. 生成 `base`、`title`、`body` 草稿
2. 明确告诉用户:这是准备提交的 PR 内容
3. 让用户确认是否继续创建,或先修改
4. 只有用户明确确认后,才能真正执行 `gh pr create`
若用户中途要求修改标题、类型、测试说明、目标分支等,应先更新草稿,再次确认。
### 六、标题和正文要分工明确
- PR 标题:用英文一句话概括本分支最主要的变动
- PR 正文:说明主要结果、关键方案、关联 issue 和实际验证结果
正文不是逐文件流水账。要归纳“为什么改”和“改完后对开发者/用户有什么影响”。
### 七、信息不足时不要硬写
若以下内容缺失且无法从分支改动中可靠推断:
- 基线分支
- 关联 issue
- 变动性质
- 测试或验证方式
可以先给出草稿,并把无法确认的地方保留为待补充项;若用户要求直接创建 PR,也必须先说明缺失项并等待确认。
## 执行步骤
### 1. 检查仓库和 PR 环境
建议先确认:
```bash
git status --short
git branch --show-current
git remote -v
gh auth status
```
若 `gh` 不可用、未登录、当前不在 git 仓库、或当前分支不适合提 PR,应先说明问题,不要继续伪造结果。
### 2. 确定基线分支
不要未经分析就直接使用 `main`。按以下顺序判断:
1. 用户明确指定了 `base branch` -> 直接使用
2. 若当前分支存在可用的“来源线索”,优先根据真实 Git 信息推断:
- `git branch -vv` 查看 tracking / upstream
- `git reflog show <current-branch>` 查看是否能看出“从哪条分支 checkout 出来”
- 必要时结合 `git merge-base HEAD <candidate-branch>` 比较分叉点
3. 若能较可靠判断“当前分支是从某条分支切出来的”,优先使用该分支作为 `base`
4. 若无法可靠推断,再退回远端默认分支或仓库默认分支
建议查看:
```bash
git branch --show-current
git branch -vv
git reflog show --date=local $(git branch --show-current)
git remote show origin
```
注意:
- tracking / upstream 只能作为线索,不等于绝对正确的“父分支”
- `reflog` 若已清理,可能无法得到结果
- 若推断结果不够确定,要在草稿中明确标注为“推断值”
#### 新功能分支说明
本仓库的新功能 PR 通常也提交到 `main`。只有用户明确要求 stacked PR,或 Git 历史能可靠证明当前分支基于另一功能分支时,才使用非 `main` 的 base;不要仅因类型为 `feat` 擅自改 base。
### 3. 收集本分支全部改动
至少查看:
```bash
git log --oneline <base>..HEAD
git diff --stat <base>...HEAD
git diff <base>...HEAD
```
必要时再看:
```bash
git diff --name-only <base>...HEAD
```
归纳时要覆盖该分支会进入 PR 的全部提交,而不是只写最后一次改动。
### 4. 确定正文结构和语言
先检查是否存在 `.github/PULL_REQUEST_TEMPLATE*`:
- 存在:读取并使用仓库模板
- 不存在:使用 `Summary` / `Testing` 结构
正文默认使用英文;用户明确要求中文时再使用中文。
### 5. 判断 PR 类型
必须根据整条分支的“主结果”判断,不要被单个文件或 commit 干扰。
优先判断:
1. 新增 CLI、Agent、MCP 或集成方可用能力 -> `feat`
2. 修复错误行为 -> `fix`
3. 只修改文档或 skill -> `docs`
4. 主要修改构建、打包配置 -> `build`
5. 主要修改 workflow / CI -> `ci`
6. 主要修改测试 -> `test`
7. 无外部行为变化的代码整理 -> `refactor`
8. 性能优化 -> `perf`
9. 依赖或内部维护 -> `chore`
10. 版本发布 -> `release`
例如:
- 新增 MCP tool -> `feat(mcp)`
- 修复 changelog 版本解析 -> `fix(changelog)`
- 更新 agent skill 说明 -> `docs(skills)`
- 调整 tsdown 打包配置 -> `build(config)`
- 修改 PR 校验 workflow -> `ci`
### 6. 归纳 PR 的核心信息
至少整理出:
- 主要结果:本分支解决什么问题或新增什么能力
- Related Issues:有可靠关联时填写 issue 链接或 `Closes #xxxx` / `Fixes #xxxx`
- Summary:归纳用户影响、关键方案和重要约束
- Testing:只列出本次实际执行并通过的命令
- 兼容性:说明是否有 CLI 参数、输出格式、配置、MCP API 或打包产物变化
### 7. 处理 Testing
根据改动范围选择验证:
- 所有 PR:`pnpm lint`
- 修改 `src/**` 或 `test/**`:`pnpm build` 和 `pnpm test`
- 涉及 CLI 执行、MCP、setup、打包或跨模块集成:考虑 `pnpm test:all`
- 通用补充检查:`git diff --check`
只写实际执行且通过的命令。未执行时写 `Not run (reason)`;失败时如实说明失败命令,不要写成通过。
### 8. 生成 PR 标题
标题要求:
- 按下方“写法要求 -> 标题”生成
- 覆盖整条分支的主要目标
- 不要照搬单个 commit message
- `type` 要与第 5 步判断一致
### 9. 按约定产出 PR 正文草稿
没有仓库模板时使用:
```markdown
## Summary
- <主要结果或用户影响>
- <关键方案或重要约束>
## Testing
- `<实际通过的命令>`
```
填写时遵守:
- Summary 通常使用 2 至 4 个要点
- Testing 只列实际结果
- 有可靠关联 issue 时加入 `Closes #xxxx`、`Fixes #xxxx` 或链接
- 涉及命令输出或交互变化时,提醒补充必要的终端输出、截图或示例
- 有破坏性变化、迁移要求或公开行为变化时明确写出
- 信息尚未确认时显式标注,不要假装确定
### 10. 先给用户确认
输出时至少包含:
- `Base branch`
- `PR title`
- `PR body`
- 实际执行的测试及结果
- 需要用户补充或确认的点
明确询问用户是否:
- 直接创建 PR
- 先修改后再创建
没有明确确认前,不得执行 `gh pr create`。
### 11. 创建 PR
只有在用户明确确认后,才执行。
执行前再次检查:
```bash
git status --short
git branch -vv
git remote -v
gh repo view --json nameWithOwner,defaultBranchRef
```
要求:
1. 确认当前分支的 tracking remote 和远端分支正确
2. 确认 PR 的目标仓库是 `antdv-next/cli`,不要依赖 `gh` 默认推断
3. 若 tracking remote 缺失、指向不明确、或不是预期 fork,先向用户确认,不要默认推送
4. 只有在推送目标 remote 明确无误时,才推送当前分支
5. 使用已确认过的标题和正文执行 `gh pr create`
6. 只有用户明确要求 Draft PR 时才使用 `--draft`
7. 相同 head/base 已存在 PR 时,返回现有 PR,不要重复创建
若需要推送,优先使用明确的远端与分支名:
```bash
git push -u <remote> HEAD
```
正文较长时写入安全的临时文件,并使用:
```bash
gh pr create \
--repo antdv-next/cli \
--base <base> \
--head <owner>:<branch> \
--title "<title>" \
--body-file <body-file>
```
同仓库分支可以只传 `<branch>`;fork 分支使用 `<owner>:<branch>`。创建成功后返回 PR 链接。
## 写法要求
### 标题
- 必须是英文
- 默认先判断 `type`,再决定是否需要 `scope`
- 优先使用 `type: subject` 或 `type(scope): subject`
- 优先写结果,不写过程
- 避免 `update`, `fix issues`, `misc changes` 这类空话
- 若分支包含多类小改动,提炼一个更高层概括
常用 `type` 参考:
- `feat`:新增能力
- `fix`:修复问题
- `docs`:文档或 skill
- `refactor`:重构
- `test`:测试改动
- `build`:构建或打包
- `ci`:CI 或 workflow
- `chore`:杂项维护
- `perf`:性能优化
- `release`:版本发布
`scope` 使用规则:
- 改动集中在单个功能边界时再加,如 `refactor(changelog): ...`
- 常见 scope 包括 `cli`、`mcp`、`changelog`、`version`、`bug`、`setup`、`skills`、`package`
- 若没有明显聚焦对象,就不要硬加 `scope`
- 不要把目录名机械塞进 `scope`
### Summary
- 先写本分支解决的问题或新增的能力
- 再写关键方案和重要约束
- 若涉及 CLI 参数、输出格式、配置、MCP API 或打包产物变化,点明外部可感知差异
- 不要写逐文件流水账
### Testing
- 只列本次实际执行并通过的命令
- 未运行时写 `Not run (reason)`
- 失败时如实说明,不要伪造通过结果
- 根据改动范围参考 `pnpm lint`、`pnpm build`、`pnpm test`、`pnpm test:all` 和 `git diff --check`
## 参考
更多类型判断、基线分支建议、确认话术与标题示例见 `references/template-notes-and-examples.md`。
@@ -0,0 +1,174 @@
## PR 类型建议
优先选择最贴近整条分支主结果的一个 type:
- 新增 CLI、Agent、MCP 或集成能力:`feat`
- 修复错误行为:`fix`
- 只修改文档或 skill:`docs`
- 主要修改测试:`test`
- 无外部行为变化的整理:`refactor`
- 性能优化:`perf`
- 构建或打包配置:`build`
- GitHub Actions / CI:`ci`
- 依赖或内部维护:`chore`
- 版本发布:`release`
## 类型判断补充说明
不要因为 diff 里包含逻辑代码就直接判成 `fix`,先看最终结果。
优先判断:
1. `feat` 只用于对外新增能力,不是“代码变多了”
2. `fix` 用于修复真实错误行为
3. 文档、skill、构建和 CI 改动优先使用各自 type
4. 以用户、CLI 调用方或维护者最终感知到的结果为准
示例:
- 新增 MCP 查询工具 -> `feat(mcp): ...`
- 修复版本 fallback 逻辑 -> `fix(version): ...`
- 更新 agent skill -> `docs(skills): ...`
- 调整 tsdown 配置 -> `build(config): ...`
- 修改 PR title workflow -> `ci: ...`
## Related Issues 写法
有明确 issue 时:
- `Closes #123`
- `Fixes #123`
- `Refs #123`
没有可靠关联时可以不写,或写 `None`。不要编造 issue 编号。
## Summary 写法
推荐使用 2 至 4 个要点,回答:
1. 原先有什么问题,或为什么需要新能力
2. 这次改变了什么
3. 是否影响 CLI 参数、输出格式、配置、MCP API 或包内容
4. 是否有破坏性变化或迁移要求
英文示例:
```markdown
## Summary
- keep changelog version resolution inside the diff utility
- simplify the command layer to pass resolved versions
- preserve existing CLI output with no public API changes
```
中文示例:
```markdown
## Summary
- 将 changelog 版本解析收敛到 diff 工具内部
- 简化命令层,只传递已解析版本
- 保持现有 CLI 输出,不涉及公开 API 变化
```
## Testing 写法
Testing 只记录实际执行并通过的命令,不要根据代码改动猜测结果。
常见验证:
- 所有 PR:`pnpm lint`
- 修改 `src/**` 或 `test/**`:`pnpm build`、`pnpm test`
- CLI、MCP、setup、打包或跨模块集成:考虑 `pnpm test:all`
- 通用检查:`git diff --check`
示例:
```markdown
## Testing
- `pnpm lint`
- `pnpm build`
- `pnpm test`
- `git diff --check`
```
未执行时:
```markdown
## Testing
- Not run (documentation-only change)
```
若测试失败,要写明失败命令和原因,不能写成通过。
## 基线分支判断建议
目标是尽量推断“当前分支实际从哪里切出来”,而不是拍脑袋默认 `main`。
建议顺序:
1. 用户明确指定了 `base branch` -> 直接使用
2. 查看当前分支是否能从 `reflog` 看出 checkout 来源
3. 查看 `git branch -vv` 的 tracking / upstream 作为辅助线索
4. 必要时结合 `merge-base` 比较候选分支
5. 若仍无法确定,再退回远端默认分支或仓库默认分支
建议命令:
```bash
git branch --show-current
git branch -vv
git reflog show --date=local $(git branch --show-current)
git remote show origin
git merge-base HEAD <candidate-branch>
```
注意:
- upstream 不是绝对父分支,只是候选线索
- `reflog` 最接近真实答案,但不一定一直存在
- 不确定时要明确告诉用户“这是推断值”
## 创建 PR 前确认话术建议
在真正执行 `git push` 或 `gh pr create` 前,先给用户确认版草稿:
```markdown
我先整理了一版待提交的 PR 草稿,请你确认:
- Target repository: `antdv-next/cli`
- Base branch: `main`
- Head branch: `refactor/changelog-version-loading`
- PR title: `refactor(changelog): move version loading into diff utility`
- Testing: `pnpm lint`, `pnpm test`
如果没问题,我再继续推送并创建 PR;如果你想改 title、base、正文或 Draft 状态,我先更新草稿。
```
## PR 标题示例
`antdv-next/cli` 的 PR 标题使用英文,并遵循:
- `<type>: <subject>`
- `<type>(<scope>): <subject>`
subject 必须以小写字符开头,以通过 `.github/workflows/validate-pr-title.yml`。
推荐示例:
- `feat(mcp): add component changelog query tool`
- `fix(version): resolve missing patch versions with fallback`
- `docs(skills): clarify CLI usage for agents`
- `refactor(changelog): move metadata loading into diff utility`
- `build(config): optimize tsdown bundle output`
- `ci: validate pull request title format`
- `chore(package): include skills in published files`
不要这样写:
- `修复版本解析问题`
- `Update version resolver`
- `fix issues`
- `some improvements`
+247
View File
@@ -0,0 +1,247 @@
---
name: antdv
description: >
Use when the user's task involves antdv-next — writing antdv-next components,
debugging antdv-next issues, querying antdv-next props/events/slot/tokens/demos, migrating between
antdv-next versions, or analyzing antdv-next usage in a project. Triggers on antdv-next-related
code, imports from 'antdv-next', or explicit antdv-next questions.
allowed-tools:
- Bash(antdv *)
- Bash(antdv bug*)
- Bash(antdv bug-cli*)
- Bash(npm install -g @antdv-next/cli*)
- Bash(which antdv)
---
# Ant Design CLI
You can visit `@antdv-next/cli` - a local cli tool that contains metadata about the current, latest version of antdv-next. Use it to query component knowledge, analyze projects, and guide migrations. All data is offline and does not require a network.
## Setup
Before first use, check if the CLI is installed. If not, install it automatically:
```bash
which antdv || npm install -g @antdv-next/cli
```
After running any command, if the output contains an "Update available" notice, run `antdv upgrade` to update before continuing.
**Always use `--format json` for structured output you can parse programmatically.**
## Scenarios
### 1. Writing antd component code
Before writing any antd component code, look up its API first — don't rely on memory.
```bash
# Check what props are available
antdv info Button --format json
# Get a working demo as starting point
antdv demo Button basic --format json
# Check semantic classNames/styles for custom styling
antdv semantic Button --format json
# Check component-level design tokens for theming
antdv token Button --format json
# Get the overall design language (design.md): colors, typography, spacing, radius + principles
antdv design.md --format json
```
**Workflow:** `antdv info` → understand props/event/slot → `antdv demo` → grab a working example → write code.
### 2. Looking up full documentation
When you need comprehensive component docs (not just props):
```bash
antdv doc Table --format json # full markdown docs for Table
antdv doc Table --lang zh # Chinese docs
```
### 3. Debugging antd issues
When code isn't working as expected or the user reports an antdv bug:
```bash
# Collect full environment snapshot (system, deps, browsers, build tools)
antdv env --format json
# Check if the prop exists for the user's antd version
antdv info Select --version 5.12.0 --format json
# Check if the prop is deprecated
antdv lint ./src/components/MyForm.tsx --format json
# Diagnose project-level configuration issues
antdv doctor --format json
```
**Workflow:** `antdv env` → capture full environment → `antdv doctor` → check configuration → `antdv info --ver X` → verify API against the user's exact version → `antdv lint` → find deprecated or incorrect usage.
### 4. Analyzing project antd usage
When the user wants to understand how antd is used in their project:
```bash
# Scan component usage statistics
antdv usage ./src --format json
# Filter to a specific component
antdv usage ./src --filter Form --format json
# Lint for best practice violations
antdv lint ./src --format json
# Check only specific rule categories
antdv lint ./src --only deprecated --format json
antdv lint ./src --only a11y --format json
antdv lint ./src --only performance --format json
```
### 5. Comparing component APIs between versions
When the user asks what component APIs changed between versions:
```bash
# Compare props, events, slots, and methods for all components
antdv changelog 1.2.2 1.5.2 --format json
# Limit the comparison to one component
antdv changelog 1.2.2 1.5.2 Select --format json
```
### 6. Exploring available components
When the user is choosing which component to use:
```bash
# List all components with categories
antdv list --format json
# List components for a specific antd version
antdv list --version 5.0.0 --format json
```
### 7. Collecting environment info
When you need to understand the project's antdv-next setup, or prepare info for a bug report:
```bash
# Full environment snapshot (text — paste into GitHub Issues)
antdv env
# Structured JSON for programmatic use
anvtd env --format json
# Scan a specific project directory
antdv env ./my-project --format json
```
Collects: OS, Node, package managers (npm/pnpm/yarn/bun/utoo), npm registry, browsers, core deps (antdv-next/vue/dayjs), all `@antdv-next/*` packages, and build tools (vite/typescript/etc.).
### 8. Reporting antd bugs
When the user asks you to report an antdv bug:
```bash
# Step 0: Collect environment info for reference (optional — antd bug already embeds basic env)
# Use the output to cross-check versions or attach extra details to the bug report
antdv env --format json
# Step 1: Preview for user review
antdv bug --title "DatePicker crashes when selecting date" \
--reproduction "https://codesandbox.io/s/xxx" \
--steps "1. Open DatePicker 2. Click a date" \
--expected "Date is selected" \
--actual "Component crashes with error" \
--format json
# Step 2: Show to user, ask for confirmation
# Step 3: Submit after user confirms
antdv bug --title "DatePicker crashes when selecting date" \
--reproduction "https://codesandbox.io/s/xxx" \
--steps "1. Open DatePicker 2. Click a date" \
--expected "Date is selected" \
--actual "Component crashes with error" \
--submit
```
### 9. Reporting CLI issues
When the user asks you to report a CLI bug, or explicitly requests help filing one:
```bash
# Preview the bug report for the user
antdv bug-cli --title "antdv info Button returns wrong props for v1.5.1" \
--description "When querying Button info for version 1.5.1, the output includes props that don't exist in that version" \
--steps "1. Run: antdv info Button --ver 1.5.1 --format json" \
--expected "Props matching antdv-next 1.5.0 Button API" \
--actual "Props include 'classes' which was added in 1.5.1" \
--format json
```
Show the report to the user and confirm before submitting:
```bash
antdv bug-cli --title "antdv info Button returns wrong props for v1.5.1" \
--description "..." \
--steps "..." \
--expected "..." \
--actual "..." \
--submit
```
**Opt-out:** If the environment variable `ANTDV_NO_AUTO_REPORT=1` is set, skip all bug-reporting suggestions entirely — do not suggest `antdv bug` or `antdv bug-cli` unless the user directly asks.
### 10. Upgrading the CLI
When the user wants to update `@antdv-next/cli` to the latest version, or when an "Update available" notice appears:
```bash
# Upgrade to the latest version (auto-detects package manager)
antdv upgrade
```
The command detects which package manager installed the CLI (npm, yarn, pnpm, bun) and runs the appropriate upgrade command. If detection fails, it suggests the manual command.
### 11. Using as MCP server
If working in an IDE that supports MCP (Claude Desktop, Cursor, etc.), the CLI can also run as an MCP server, exposing all knowledge-query tools directly:
```json
{
"mcpServers": {
"antdv": {
"command": "antdv",
"args": ["mcp", "--version", "1.5.0"]
}
}
}
```
This provides 8 tools (`antdv_list`, `antdv_info`, `antdv_doc`, `antdv_demo`, `antdv_token`, `antdv_design_md`, `antdv_semantic`) and 2 prompts (`antdv-expert`, `antdv-page-generator`) via MCP protocol.
## Global Flags
| Flag | Purpose |
|---------------------|----------------------------------------------------------------------------|
| `--format <format>` | Output format: `json`, `text`, or `markdown` (agents should prefer `json`) |
| `--ver` | Target a specific antd version (e.g. `1.5.0`) |
| `--detail` | Include extra fields (description, since, deprecated, FAQ) |
| `-V, --version` | Print CLI version and exit |
## Key Rules
1. **Always query before writing** — Don't guess antd APIs from memory. Run `antdv info` first.
2. **Match the user's version** — Knowledge queries (`list/info/doc/demo/token/semantic/changelog`) support antdv-next v1.x.
3. **Use `--format json`** — Every command supports it. Parse the JSON output rather than regex-matching text output.
4. **Check before suggesting migration** — Run `antdv changelog <v1> <v2>` and `antdv migrate` before advising on version upgrades.
5. **Lint after changes** — After writing or modifying antd code, run `antdv lint` on the changed files to catch deprecated or problematic usage.
6. **Report antdv-next bugs** — When the user asks to report an antd bug, use `antdv bug`. Always preview first, get user confirmation, then submit.
7. **Report CLI issues** — When the user asks about a CLI problem, use `antdv bug-cli` to help them file a report. Always preview first, get user confirmation, then submit.
+19
View File
@@ -0,0 +1,19 @@
# 构建上下文只保留 release/ 与 Caddyfile(Dockerfile 仅 COPY 这两处)
.git
.env
.env.*
!.env.example
node_modules
**/node_modules
apps
packages
scripts
docs
*.log
api*.json
session-archive
coverage
*.tsbuildinfo
.claude
.agents
agent
+17 -3
View File
@@ -1,10 +1,15 @@
# ── new-api-key-usage · 本地环境变量示例 ──────────────────────────
# ── new-api-key-usage · 环境变量示例 ──────────────────────────
# 复制为 .env 后填写(.env 已被 .gitignore,勿提交)
# 开发:任意可用的 PostgreSQL,DATABASE_URL 指向即可
# 部署:放在服务器约定目录(不进产物包);chmod 600;与库同等备份
# 本服务端口(避开 3000/3001 等常见占用)
# 本服务端口(避开 3000/3001 等常见占用;compose 内 server 固定 18456)
PORT=18456
# PostgreSQL 连接串(开发 compose 默认如下)
# PostgreSQL 连接串——数据库配置的唯一来源
# 开发:指向你本机/已有的库(主机 127.0.0.1 或 localhost)
# 部署:同一份即可;compose 会把主机改写为容器服务名 postgres
# 密码含 @ : / 等字符时请 URL 编码
DATABASE_URL=postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage
# 已登记 sk-key 的加密密钥:≥32 字节随机数,base64url。
@@ -19,11 +24,20 @@ NEWAPI_BASE=http://127.0.0.1:8080
# 会话 cookie 是否带 Secure(默认 true)。
# 本地 http://localhost 或 127.0.0.1 调试:现代浏览器可保留 true;
# 局域网 IP / 自定义域名 / 出现「登录后立刻 401」时改为 false。
# HTTPS + 域名部署务必 true。
COOKIE_SECURE=false
# Caddy 对外端口(宿主机被占用时改这里,例如 28080 / 28443)
# 仅影响宿主机映射;容器内仍是 80/443
HTTP_PORT=80
HTTPS_PORT=443
# 业务日志级别:debug | info | warning | error | fatal(访问日志另由 hono/logger 输出)
LOG_LEVEL=info
# 同步引擎周期(毫秒,≥1000;默认 300000 = 5 分钟)
# SYNC_INTERVAL_MS=300000
# 空库双通道:可选。用户名与密码同时存在时,首次 setup-state 静默落库;
# 只填一个视为未配置。此后改 env 不改密;改密走管理面表单。
# ADMIN_INIT_USERNAME=
+7
View File
@@ -17,9 +17,16 @@
# 依赖与构建产物
node_modules/
dist/
bin/
build/
.turbo/
release/
.bun-cache/
# 测试与工具
coverage/
*.tsbuildinfo
# Agent 本地接线(skills 本体只保留 .agents/skills)
.claude/
agent/
+14 -13
View File
@@ -5,18 +5,17 @@
## 协作约定(长期有效)
- **Git**:仅当项目作者明确说"提交 / commit"时才执行提交;`git push` 永远由项目作者手工执行,AI 绝不推送
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(本分支暂无此目录,需要时再建)
- **命名**:需求 / 设计 / 交互 / 技术 / 计划等**文档名**用中文(`README` / `AGENTS` / `LICENSE` 等约定俗成除外)、**目录**用英文;新文档按性质放入 `docs/design/`(我们定的方案与决策)或 `docs/references/`(平台可用接口参考——实证事实,不做设计决策),并同步更新 README 的「文档地图」;`docs/deprecated/` 只归档已废弃文档——**勿引用、勿作依据**(需要时再建)
- **代码文件命名**:所有**目录名与文件名**一律 kebab-case(如 `time-range-picker.vue` · `admin-session.ts`);**组件在模板中引用时用大驼峰**(`<TimeRangePicker />`)——文件名 kebab、模板标签 PascalCase
- **文档状态**:头部表格标「状态」(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);设计文档定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- **部署**:交付物必须保证「一条命令链」可部署;**双形态并行已裁决**——完整前后端(运营方服务器)先行,Cloudflare Workers 抽取版(作者自部署)后续(见 `docs/design/需求清单.md` R13)
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前分支之外、可能被删除的分支内容
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录,如 `v1-设计-*.md`)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关;`v1-设计-1.md` 为历史对话原档,允许保留旧术语,不作现行依据
- **不引入版本叙事**:不使用 v1/v2/v3 之类版本编号,不以「MVP」等标签或指称显式描述任何历史阶段;文档内容必须**自立**——不得引用、描述任何当前仓库之外、可能被删除的分支或目录内容
- **本地不入库也不 ignore 的物料**(AI 提交时务必避开):`api*.json`(上游实测快照)、`session-archive/`(作者主动备份的会话记录)——不加入 git、不写 `.gitignore`,靠提交时人工/执行者目视把关
## 项目速览(速记)
- **`v1`(本分支)= 设计文档中心 + 实现主线**:实现代码与文档同分支演进(代码落点已定案),monorepo 结构(`apps/server` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:四张基线表(`api_keys` · `usage_logs` · `payments` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)设计见本目录,**七篇已定稿(2026-10-01,经作者逐篇审阅)**;同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定,架构结论不变
- 需求总集与待裁决项(平台身份体系、费用分摊与账单、充值折算、多订阅、数据资产保护)见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- **本仓库 = 设计文档中心 + 实现主线**:实现代码与文档同仓演进,monorepo 结构(`apps/server` · `apps/engine` · `apps/web` · `packages/core` · `packages/db`)见 `docs/design/技术选型.md` §4
- 数据模型:基线表(`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history`)+ 三张访问主体表(`members` · `users` · `sessions`)——支付登记已拆为 `subscriptions` + `topups`(见 `docs/design/payments建模评估.md`);同步架构(轮询 + `request_id` 去重 + 攒库)的动因由上游物理约束决定
- 需求总集与裁决档案见 `docs/design/需求清单.md`;交付形态已裁决(双形态并行,完整形态先行)
- 上游约束速记:只有 sk-key、无时间过滤接口 → 轮询攒库是唯一路径(README「架构的根本动因」)
## 命令
@@ -26,15 +25,17 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
| 命令 | 用途 |
|------|------|
| `bun install` | 安装 / 链接 workspace 依赖(在仓库根) |
| `bun test` | 全部测试(core 契约 + server HTTP + **集成**);集成需 `DATABASE_URL`(compose 中的 PG),无库时自动跳过 |
| `bun --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
| `bun run db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run db:push` | 推送到本地库(开发期;**服务启动不自动迁**) |
| `bun run db:studio` | Drizzle Studio 看库 |
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
| `bun run dev:server` | 开发跑母体后端(hot reload) |
| `bun run dev:server` | 开发跑后端(hot reload) |
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
| `bun run dev:web` | 开发跑前端 Vite |
| `docker compose up -d` | 起开发用 PostgreSQL |
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
| `bun run --filter @new-api-key-usage/db db:push` | 推送到本地库(开发期) |
| `bun run build:web` | 前端类型检查 + 产物构建 |
| `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
| `bun run pack` | 打包/构建 CLI(citty + clack):`pack release` 交互打部署包 → `release/`;`pack build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only`;`pack release --yes` 非交互打部署包(目标默认 linux-x64) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。
+17
View File
@@ -0,0 +1,17 @@
# 站点地址按部署环境改:
# 本地/内网 HTTP → :80
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
:80 {
root * /srv
# 接口反代到 server 容器
handle /api/* {
reverse_proxy server:18456
}
# SPA:非 /api 的路径回退 index.html
handle {
try_files {path} /index.html
file_server
}
}
+21
View File
@@ -0,0 +1,21 @@
# 语法:docker/dockerfile:1
# 只装配 release/ 产物,构建期不编译业务代码
# ---------- 应用运行时 ----------
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
FROM debian:bookworm-slim AS app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# --chmod:Windows 打包 / 跨机传输常丢可执行位,缺 x 时容器内 exec 以 126 退出
COPY --chmod=755 release/naku-server release/naku-engine release/naku-migrate /app/
COPY release/drizzle /app/drizzle
ENV MIGRATIONS_FOLDER=/app/drizzle
CMD ["/app/naku-server"]
# ---------- 网关(Caddy + 前端静态) ----------
FROM caddy:2-alpine AS proxy
COPY Caddyfile /etc/caddy/Caddyfile
COPY release/dist /srv
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 skycurtain
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+139 -59
View File
@@ -1,92 +1,172 @@
# new-api-key-usage
> 针对 NewAPI 平台的 key 用量统计工具
> NewAPI 网关下的 key 用量统计工具(产品名 **NAKU**)
定时用每个 sk-key 轮询网关日志接口,按 `request_id` 去重,攒全量日志到数据库;任意时间范围的用量查询,即一条本地聚合查询。
| 项目 | 内容 |
|------|------|
| 当前状态 | **实现推进中**(`v1` 分支 = 文档中心 + 实现主线)——设计文档全部定稿;后端 + 同步引擎已落地;前端总览与 Key 管理已实现,其余页面进行中 |
| 技术栈 | 全栈 TypeScript(选型定稿见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):母体 Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版待做 |
| 技术栈 | 全栈 TypeScript(选型见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
## 项目沿革
## 这是什么
| 阶段 | 形态 | 结局 |
|------|------|------|
| 初版方案 | Cloudflare Workers | 弃——当时运营方愿意提供服务器资源 |
| 新版设计(当前) | **本分支,设计已收官、实现推进中** | 捡回 Cloudflare Workers(**serverless 后端**:Cron 定时轮询 + 云端数据库攒库,非初版原案复刻)作为后续衍生形态;母体完整前后端先行,CFW 延后至功能全集实现后 |
面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:订阅持有人创建多个 sk- API key 分发给多名使用者,平台把「谁用了多少」的事实记录清楚——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
**双形态并行的直接动因**:服务器部署形态下,每次迭代都需实例运营方在服务器上手工升级,测试阶段迭代频繁而运营方无暇跟随;若部署到作者自控的 Cloudflare 账号(Workers + Pages),迭代部署权回到作者手里。
**适用场景**:同一份订阅额度由多人共享的小团队 / 成本分担群体。
## 文档地图
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
- 仓库名 / 包名统一小写 kebab-case
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R15 全部定案(含裁撤与延后),R 编号体系为后续设计文档的引用锚点 | 定稿(2026-10-01) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 母体技术栈裁决(CFW 延后至功能全集实现后,仅零成本 R14 预留):全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退;antdv-next 核验通过(§7.1) | 定稿(2026-10-02) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | 数据模型第一张表:key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 数据模型第二张表:日志明细、来源提列、去重与写入策略 | 同上 |
| [`docs/design/payments表字段设计.md`](docs/design/payments表字段设计.md) | 数据模型第三张表:支付记录(订阅/充值同构,币种无关,只存事实) | 同上 |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 数据模型第四张表:换算参数时间线(自动采样、只读) | 同上 |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 数据模型新增表:成员实体——key 归属、成员视图身份与按成员统计的锚点 | 定稿(2026-10-01) |
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 数据模型新增表:管理员(单行)——登录体系的身份落点 | 同上 |
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 数据模型新增表:管理面会话——cookie 令牌的服务端对应物 | 同上 |
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互设计总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 管理面登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 同上 |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 订阅/充值同构表单、核对提示 | 同上 |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、令牌签发/重发(明文仅一次)、停用 | 同上 |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 同上 |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员单页世界——R12 携带形态定案(徽章 + 展开行) | 同上 |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 契约总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名(8 条拟定约定经审通过)——后两篇的引用锚点 | 定稿(2026-10-02) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 16 端点:引导与会话三态、引擎状态、总览聚合、key 登记四态、下钻明细、支付、成员(令牌明文仅一次)、审计;5 项裁定(经审通过)+ 4 项遗留(key 详情已裁决:实现期定) | 定稿(2026-10-02) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:令牌验证与页头、单页聚合(含支付参数卡判定)、日志明细(DTO 与管理面同构);4 项裁定(经审通过)+ 1 项遗留 | 定稿(2026-10-02) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine` 进程:手写 `setTimeout` 轮次、失败只记录、`engine_status` 快照供 `/api/admin/engine`;周期可配、**不为 CFW 预留** | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 计费平台在 NewAPI 中可用的接口参考:鉴权档位、请求与返回结构、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
## 背景与约束
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(我们定的方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:设计文档定稿后不搬家、只改状态(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);被取代时删除(git 历史即考古)
- 典型场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多名使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
- 平台持有方通常只是实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)——这是整个数据链路的设计前提
- 部署形态为**双形态并行**:完整前后端跑在自有或托管服务器上(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版部署在自己掌控的账号里(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
## 这是什么项目
**双形态并行的动因**:服务器部署形态下,每次迭代都要经服务器管理员手工升级;Workers 形态把迭代部署权收回自己手里。完整前后端先行,抽取版延后至功能全集实现后。
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage)
- 全场景统一小写 kebab-case:仓库名 / 包名 / 文档标题一致,无大写变体
## 核心能力
## 背景故事
- 本工具源于一个真实场景:[NewAPI](https://github.com/QuantumNous/new-api)(Go + GORM 的开源 LLM 网关)实例提供订阅服务,多个使用者通过分发 API key 共享同一份订阅额度
- 项目作者是该实例的**普通用户**:购买了订阅额度,创建多个 sk- API key 分发给多名使用者分摊使用成本——**非商业,纯成本分担**
- 关键身份约束:项目作者**只有 sk-key,没有实例的管理员账号**。因此:
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)
- 部署形态已裁决为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
## 核心需求(基线摘要;完整范围见需求清单)
| # | 需求 | 说明 |
| # | 能力 | 说明 |
|---|------|------|
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者的全员视角经管理面总览实现——「凭 sk-key 的开放查询」与「多 key 合并查询」机制已裁撤(见需求清单裁决记录) |
| 2 | key 登记 | 管理通道手动登记:登记后入库并加入轮询;查看面凭已登记的 key 查询(纯只读) |
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;管理端全员视角,成员端各自视角 |
| 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 |
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
基线刻意收缩的部分(费用分摊计算、计费周期管理、多组支持、登录页、admin 角色、会话记忆)与新增域(数据资产保护等)的完整需求盘点见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
完整需求范围(含刻意不做的部分)见 [`docs/design/需求清单.md`](docs/design/需求清单.md)。
## 架构的根本动因:为什么必须攒数据
这不是优化选项,是上游接口的物理约束决定的**唯一路径**(不随部署形态改变):
1. sk-key 可用的接口(`/api/usage/token`、`/api/log/token` 等)返回的都是**累计值**或**最近 1000 条**,没有任何接口支持时间范围过滤
2. 真正适合算“当月用量”的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是“按时间段统计用量”无法纯客户端实现的**核心障碍**
2. 真正适合算「当月用量」的接口(`/api/log/self/stat`,支持时间范围 + quota 汇总)需要账号登录态,sk-key 访问 401——这是「按时间段统计用量」无法纯客户端实现的**核心障碍**
3. `/api/log/token` 硬上限 1000 条、不支持任何参数(分页 / limit / 时间过滤全被忽略)
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变(运营方服务器 / Cloudflare Workers),攒库架构不变。**上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
→ 结论:轮询同步 + `request_id` 去重入库 + 数据库。**部署形态可以变,攒库架构不变。** 上游接口完整实证(含源码出处)见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md)。
## 技术栈与结构
全栈 TypeScript,monorepo(Bun workspaces):
```text
apps/
server/ # 后端:Hono + Drizzle/pg + Scalar 在线文档
engine/ # 同步引擎:独立进程,轮询去重入库
web/ # 前端:Vue 3 + antdv-next + Vite
packages/
core/ # 业务核心:去重、折算、加密、上游客户端、DTO
db/ # 数据访问层:Drizzle schema + 仓储
```
数据模型:`api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + `members` · `users` · `sessions`。
## 快速开始
前置:Bun、一个可用的 PostgreSQL(本机已有或别处均可)。
```bash
bun install
cp .env.example .env # DATABASE_URL 指向你已有的 PostgreSQL
bun run db:push # 开发期推送 schema(启动不自动迁)
bun run dev:server # 后端(默认 18456)
bun run dev:engine # 同步引擎(另开终端)
bun run dev:web # 前端 Vite(5175)
```
在线 API 文档:`http://127.0.0.1:18456/api/docs`。
更多命令与协作约定见 [`AGENTS.md`](AGENTS.md)。
## 部署
方案详文见 [`docs/design/部署方案.md`](docs/design/部署方案.md)。形态一句话:**本地打产物包 → 覆盖服务器 `release/` → 一条 compose 命令 → 看容器**。
### 本地打包
```bash
bun run pack release
# 交互选:目标平台 · 打包内容 · 是否 tar.gz
# 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/
# 非交互(CI / 一键):
bun run pack release --yes
# 或 bun run pack release --yes --target=bun-linux-arm64 --only=dist,naku-server
```
### 服务器
首次安装:把 `docker-compose.yml` · `Dockerfile` · `.dockerignore` · `Caddyfile` · `entrypoint-*.sh` 放到约定目录(如 `/opt/naku/`),再放 `release/`,填写 `.env`(`chmod 600`)。日常更新只覆盖 `release/`。
```bash
cd /opt/naku
docker compose up -d --build
docker compose ps # migrate 应为 exited(0),其余 running/healthy
```
- 页面:`http://<服务器>/`(若改了 `HTTP_PORT` 则加端口);接口文档:`http://<服务器>/api/docs`
- 迁移:由 `migrate` 一次性服务在启动时幂等执行(journal),重复执行不会多灌
- 端口:默认 80/443;宿主机被占用时在 `.env` 设 `HTTP_PORT` / `HTTPS_PORT`,再执行固定命令
- HTTPS:`Caddyfile` 站点行改成域名,`.env` 设 `COOKIE_SECURE=true`,再执行固定命令
### 注意
1. `.env` 不进产物包、不进 git;含 `SK_KEY_ENCRYPTION_SECRET`,须与数据库同等备份
2. `COOKIE_SECURE` 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」
3. `DATABASE_URL` 密码含 `@` `:` `/` 等字符时做 URL 编码
## 文档地图
| 文档 | 什么时候读 | 状态 |
|------|-----------|------|
| [`docs/design/需求清单.md`](docs/design/需求清单.md) | **需求权威索引与裁决档案**——R1–R16 定案;含支付拆表与成员档案/空壳删除裁决 | 定稿(2026-10-10) |
| [`docs/design/技术选型.md`](docs/design/技术选型.md) | 技术栈裁决:全栈 TS;Bun + Hono + Drizzle/pg;前端 Vue 3 + antdv-next;分层落地与回退 | 定稿(2026-10-02) |
| [`docs/design/部署方案.md`](docs/design/部署方案.md) | 母体交付与更新:产物包、服务器目录、容器编排、迁移时机、安全约定 | 已实现(定稿 2026-10-12) |
| [`docs/design/api_keys表字段设计.md`](docs/design/api_keys表字段设计.md) | key 登记、生命周期与成员归属 | 定稿(2026-10-01) |
| [`docs/design/usage_logs表字段设计.md`](docs/design/usage_logs表字段设计.md) | 日志明细、来源提列、去重与写入策略 | 同上 |
| [`docs/design/payments建模评估.md`](docs/design/payments建模评估.md) | 支付建模复盘:订阅权益 vs 充值入账两分;拆表已采纳 | 定稿(2026-10-09) |
| [`docs/design/subscriptions表字段设计.md`](docs/design/subscriptions表字段设计.md) | 订阅权益:期间 + 额度预算 + 可选付款事实;`subscription_id` 直连日志 | 定稿(2026-10-12) |
| [`docs/design/topups表字段设计.md`](docs/design/topups表字段设计.md) | 充值入账事件:`paid_at` 主轴;不挂订阅 | 定稿(2026-10-12) |
| [`docs/design/qpu_history表字段设计.md`](docs/design/qpu_history表字段设计.md) | 换算参数时间线(自动采样、只读) | 定稿(2026-10-01) |
| [`docs/design/members表字段设计.md`](docs/design/members表字段设计.md) | 成员实体——key 归属、身份与统计锚点;档案可改、空壳可删 | 定稿(2026-10-10) |
| [`docs/design/users表字段设计.md`](docs/design/users表字段设计.md) | 管理员(单行)——登录体系的身份落点 | 同上 |
| [`docs/design/sessions表字段设计.md`](docs/design/sessions表字段设计.md) | 管理面会话——cookie 令牌的服务端对应物 | 同上 |
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、汇总条、来源徽章支付弹窗、明细表与跳转链 | 定稿(2026-10-08;模型筛选与汇总条、徽章弹窗修订) |
| [`docs/design/实现方案-日志模型筛选与汇总条.md`](docs/design/实现方案-日志模型筛选与汇总条.md) | 日志页模型筛选 + 条数/消费用量汇总条:API、数据层、前端改造与验收 | 已实现(2026-10-13) |
| [`docs/design/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 定稿(2026-10-01) |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录(两 Tab):订阅权益 / 充值入账登记、核对提示、ID/交易号筛选 | 定稿(2026-10-12;补筛选) |
| [`docs/design/交互设计-成员管理.md`](docs/design/交互设计-成员管理.md) | 开户、档案修改、令牌签发/重发、启停、空壳删除 | 定稿(2026-10-10) |
| [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 定稿(2026-10-10) |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
| [`docs/design/API契约-公共约定.md`](docs/design/API契约-公共约定.md) | API 总纲:路由隔离与鉴权两档、错误信封、时间 / 数值 / 分页 / 命名 | 定稿(2026-10-10) |
| [`docs/design/API契约-管理面.md`](docs/design/API契约-管理面.md) | 管理面 18 端点 | 定稿(2026-10-12) |
| [`docs/design/API契约-成员视图.md`](docs/design/API契约-成员视图.md) | 成员视图 3 端点:`/me` · `/overview` · `/logs` | 定稿(2026-10-12) |
| [`docs/design/同步引擎设计.md`](docs/design/同步引擎设计.md) | 独立 `apps/engine`:轮次、失败只记录、`engine_status` 快照 | 已实现(定稿 2026-10-05) |
| [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md) | 上游可用接口参考:鉴权档位、字段语义、实测行为、源码出处(**唯一上游参考**) | 事实依据 |
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- AI 协作物料:[`AGENTS.md`](AGENTS.md)(协作约定)与 `.agents/skills/`(技能包)随仓库分发——参与开发的 Agent 从这里读取项目约定,人工贡献者亦可参考
## 关键事实清单(实测 / 源码确认,设计时不得违反)
> 出处均见 [`docs/references/NewAPI-接口范围.md`](docs/references/NewAPI-接口范围.md),此处仅索引。
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**:折算到实付人民币还需乘「购买汇率」(由运营方定价、可变,历史上调整过一次)
1. **`QuotaPerUnit = 500000`**:quota ÷ 500000 = 额度美元——**它不是实付金额**;折算到实付金额还需乘「购买汇率」(由运营方定价、可变)
2. **去重必须用 `request_id`**:`/api/log/token` 返回的 `id` 是每次从 1 重编的展示序号,绝不能用于去重
3. **`created_at` 是 UTC Unix 秒**(三字段交叉实测),北京时间输入需转换
4. **1000 条硬上限**:窗口只保留最近 1000 条——实测当前站点的日志速率下约等于 3 天;5 分钟级同步余量充分,但同步停摆超过约 3 天会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询(用完也能对账);**显式禁用会被 401 拒绝**
3. **`created_at` 是 UTC Unix 秒**,本地时间输入需转换
4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
## License
[MIT](LICENSE) © 2026 skycurtain
NAKU 是 NewAPI 的**独立客户端**,仅通过 HTTP API 交互,不包含其源码;与上游许可证相互独立。上游 NewAPI 为 AGPL-3.0,本项目不受其传染,亦不衍生自它。
+1
View File
@@ -8,6 +8,7 @@ setupLogging(env.LOG_LEVEL);
const log = appLogger("engine");
const db = createDb(env.DATABASE_URL);
const repos = createRepos(db);
const loop = createSyncLoop({
-13
View File
@@ -1,13 +0,0 @@
# apps/server
母体后端(Hono + Bun)。
```sh
bun install
bun run dev
```
默认端口 **18456**(可用 `PORT` 覆盖):
- 在线文档:http://127.0.0.1:18456/api/docs
- OpenAPI:http://127.0.0.1:18456/api/openapi.json
+16 -2
View File
@@ -3,6 +3,8 @@ import "./zod-openapi-setup.ts";
import {
changePasswordBody,
createMemberBody,
createSubscriptionBody,
createTopupBody,
engineStatus,
errorEnvelope,
keyItem,
@@ -14,7 +16,8 @@ import {
memberWithToken,
patchKeyBody,
patchMemberBody,
paymentItem,
subscriptionItem,
topupItem,
auditLogItem,
registerKeyBody,
setupBody,
@@ -50,12 +53,18 @@ export const memberOverviewSchema = nameSchema(
);
export const logItemSchema = nameSchema(logItem, "LogItem");
export const logPageSchema = nameSchema(logPage, "LogPage");
export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
export const subscriptionItemSchema = nameSchema(
subscriptionItem,
"SubscriptionItem",
);
export const topupItemSchema = nameSchema(topupItem, "TopupItem");
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
export {
changePasswordBody as changePasswordBodySchema,
createMemberBody as createMemberBodySchema,
createSubscriptionBody as createSubscriptionBodySchema,
createTopupBody as createTopupBodySchema,
loginBody as loginBodySchema,
patchKeyBody as patchKeyBodySchema,
patchMemberBody as patchMemberBodySchema,
@@ -84,3 +93,8 @@ export const err422 = {
description: "校验失败",
content: { "application/json": { schema: errorSchema } },
} as const;
export const err502 = {
description: "上游不可达",
content: { "application/json": { schema: errorSchema } },
} as const;
+7 -3
View File
@@ -4,11 +4,13 @@ import { ERR, appLogger } from "@new-api-key-usage/core";
import { createRepos, type Db } from "@new-api-key-usage/db";
import { jsonError, type AppVars } from "./middleware.ts";
import { adminSessionRoutes } from "./routes/admin-session.ts";
import { adminPlatformRoutes } from "./routes/admin-platform.ts";
import { adminEngineRoutes } from "./routes/admin-engine.ts";
import { adminKeyRoutes } from "./routes/admin-keys.ts";
import { adminMemberRoutes } from "./routes/admin-members.ts";
import { adminUsageRoutes } from "./routes/admin-usage.ts";
import { adminPaymentRoutes } from "./routes/admin-payments.ts";
import { adminSubscriptionRoutes } from "./routes/admin-subscriptions.ts";
import { adminTopupRoutes } from "./routes/admin-topups.ts";
import { adminAuditRoutes } from "./routes/admin-audit.ts";
import { memberViewRoutes } from "./routes/member-view.ts";
@@ -97,7 +99,7 @@ export function createApp(opts: CreateAppOptions) {
{ name: "引导与会话", description: "三态判定 / 登录 / 改密" },
{ name: "引擎", description: "同步引擎状态" },
{ name: "Key 管理", description: "登记 / 启停 / 归属" },
{ name: "成员管理", description: "开户 / 令牌 / 启停" },
{ name: "成员管理", description: "开户 / 档案 / 令牌 / 启停 / 空壳删除" },
{ name: "成员视图", description: "令牌自助查询" },
],
});
@@ -116,8 +118,10 @@ export function createApp(opts: CreateAppOptions) {
admin.route("/", adminKeyRoutes());
admin.route("/", adminMemberRoutes());
admin.route("/", adminUsageRoutes());
admin.route("/", adminPaymentRoutes());
admin.route("/", adminSubscriptionRoutes());
admin.route("/", adminTopupRoutes());
admin.route("/", adminAuditRoutes());
admin.route("/", adminPlatformRoutes());
app.route("/api/admin", admin);
app.route("/api/member", memberViewRoutes());
+7 -1
View File
@@ -21,7 +21,7 @@ export function adminAuditRoutes() {
method: "get",
path: "/audit-logs",
summary: "审计日志",
description: "分页、时间倒序;可按 domain 过滤(key/payment/member/account)",
description: "分页、时间倒序;可按 domain 与 [from,to) 过滤",
tags: ["审计"],
security: [{ adminSession: [] }],
request: {
@@ -31,6 +31,8 @@ export function adminAuditRoutes() {
domain: z
.enum(["key", "payment", "member", "account"])
.optional(),
from: z.string().optional(),
to: z.string().optional(),
}),
},
responses: {
@@ -49,8 +51,12 @@ export function adminAuditRoutes() {
200,
Math.max(1, Number(q.page_size ?? 50) || 50),
);
const from = q.from ? new Date(q.from) : undefined;
const to = q.to ? new Date(q.to) : undefined;
const { rows, total } = await c.get("repos").audit.listPage({
domain: q.domain,
from: from && !Number.isNaN(from.getTime()) ? from : undefined,
to: to && !Number.isNaN(to.getTime()) ? to : undefined,
page,
page_size,
});
+24 -1
View File
@@ -30,6 +30,7 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
status: row.status as "active" | "disabled",
member_id: row.member_id,
member_name: row.member_name ?? null,
billing_domain: row.billing_domain,
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
@@ -59,6 +60,7 @@ export function adminKeyRoutes() {
query: z.object({
member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
billing_domain: z.string().optional(),
recent_failed: z.enum(["true", "false"]).optional(),
}),
},
@@ -77,12 +79,15 @@ export function adminKeyRoutes() {
const filter: {
member_id?: number | "unassigned";
status?: string;
billing_domain?: string | "none";
} = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") {
filter.status = q.status;
}
if (q.billing_domain === "none") filter.billing_domain = "none";
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot();
@@ -144,6 +149,10 @@ export function adminKeyRoutes() {
}
member_id = n;
}
let billing_domain: string | null = null;
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
billing_domain = body.billing_domain.trim();
}
if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError(
c,
@@ -208,6 +217,7 @@ export function adminKeyRoutes() {
key_hash,
key_ciphertext,
member_id,
billing_domain,
token_name: usage.name ?? "",
});
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
@@ -241,7 +251,7 @@ export function adminKeyRoutes() {
createRoute({
method: "patch",
path: "/keys/{id}",
summary: "启停 / 归属变更",
summary: "启停 / 归属 / 账本域变更",
description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
@@ -301,6 +311,19 @@ export function adminKeyRoutes() {
});
}
}
if (body && "billing_domain" in body) {
let nextDomain: string | null = null;
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
nextDomain = body.billing_domain.trim();
}
if (nextDomain !== before.billing_domain) {
patch.billing_domain = nextDomain;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 账本域变更`,
});
}
}
if (Object.keys(patch).length === 0) {
return jsonError(
c,
+106 -9
View File
@@ -135,7 +135,8 @@ export function adminMemberRoutes() {
createRoute({
method: "patch",
path: "/members/{id}",
summary: "停用 / 启用",
summary: "档案修改 / 停用 / 启用",
description: "部分更新 name/remark/status;审计按实际变更字段各落一行",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
@@ -152,27 +153,123 @@ export function adminMemberRoutes() {
},
401: err401,
404: err404,
409: err409,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const status = body?.status;
if (status !== "active" && status !== "disabled") {
return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" }));
}
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const row = await repos.members.updateStatus(id, status);
const profile: { name?: string; remark?: string } = {};
const actions: Array<{ action: string; summary: string }> = [];
if (typeof body?.name === "string") {
const name = body.name.trim();
if (!name || name.length > 64) {
return jsonError(
c,
422,
ERR.validation_failed({ name: "名称无效" }),
);
}
if (name !== before.name) {
const taken = await repos.members.findByName(name);
if (taken && taken.id !== id) {
return jsonError(c, 409, ERR.member_name_taken());
}
profile.name = name;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 改名为 ${name}`,
});
}
}
if (typeof body?.remark === "string") {
if (body.remark !== before.remark) {
profile.remark = body.remark;
actions.push({
action: "member.update",
summary: `成员 ${before.name} · 修改备注`,
});
}
}
let nextStatus: "active" | "disabled" | null = null;
if (body?.status === "active" || body?.status === "disabled") {
if (body.status !== before.status) {
nextStatus = body.status;
actions.push({
action: body.status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${before.name} · ${body.status === "active" ? "启用" : "停用"}`,
});
}
}
if (Object.keys(profile).length === 0 && !nextStatus) {
return jsonError(
c,
422,
ERR.validation_failed({ body: "没有需要更新的字段" }),
);
}
let row = before;
if (Object.keys(profile).length > 0) {
row = await repos.members.updateProfile(id, profile);
}
if (nextStatus) {
row = await repos.members.updateStatus(id, nextStatus);
}
for (const a of actions) {
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: a.action,
summary: a.summary,
});
}
return c.json(toMemberItem(row, await repos.members.countKeys(id)), 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/members/{id}",
summary: "空壳删除",
description: "仅 key_count = 0 可硬删;名下有 key 返回 409 member_has_keys",
tags: ["成员管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "已删除" },
401: err401,
404: err404,
409: err409,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.members.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const keyCount = await repos.members.countKeys(id);
if (keyCount > 0) {
return jsonError(c, 409, ERR.member_has_keys(keyCount));
}
await repos.members.deleteById(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "member",
action: status === "active" ? "member.enable" : "member.disable",
summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`,
action: "member.delete",
summary: `删除成员 ${before.name}`,
});
return c.json(toMemberItem(row, 0), 200);
return c.body(null, 204);
},
);
-287
View File
@@ -1,287 +0,0 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type PaymentItem,
} from "@new-api-key-usage/core";
import type { PaymentRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import { err401, err404, err422, paymentItemSchema } from "../api-schemas.ts";
import { z } from "zod";
const createBody = z.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string(),
paid_amount: z.number(),
remark: z.string().optional().default(""),
period_start: z.string().nullable().optional(),
period_end: z.string().nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
});
function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
return {
id: row.id,
kind: row.kind as PaymentItem["kind"],
paid_at: toUnixSec(row.paid_at)!,
paid_amount: row.paid_amount,
remark: row.remark,
period_start: toUnixSec(row.period_start),
period_end: toUnixSec(row.period_end),
subscription_id: row.subscription_id,
quota_granted: row.quota_granted,
quota_usd,
};
}
function validatePaymentBody(
body: Record<string, unknown>,
):
| { fields: Record<string, string> }
| {
values: {
kind: string;
paid_at: Date;
paid_amount: number;
remark: string;
period_start: Date | null;
period_end: Date | null;
subscription_id: number | null;
quota_granted: number | null;
};
} {
const fields: Record<string, string> = {};
const kind = body?.kind;
if (kind !== "subscription" && kind !== "topup") {
fields.kind = "请选择订阅或充值";
return { fields };
}
if (!body?.paid_at) fields.paid_at = "请填写支付时间";
if (typeof body?.paid_amount !== "number") {
fields.paid_amount = "请填写支付金额";
}
if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度";
if (kind === "subscription") {
if (!body?.period_start) fields.period_start = "订阅必须填写期间起点";
if (body?.subscription_id == null) {
fields.subscription_id = "订阅必须填写订阅编号";
}
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
kind: kind as string,
paid_at: new Date(String(body.paid_at)),
paid_amount: Number(body.paid_amount),
remark: typeof body.remark === "string" ? body.remark : "",
period_start:
kind === "subscription" && body.period_start
? new Date(String(body.period_start))
: null,
period_end:
body.period_end != null && body.period_end !== ""
? new Date(String(body.period_end))
: null,
subscription_id:
kind === "subscription" && body.subscription_id != null
? Number(body.subscription_id)
: null,
quota_granted: Number(body.quota_granted),
},
};
}
export function adminPaymentRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/payments",
summary: "支付记录清单",
description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算",
tags: ["支付记录"],
security: [{ adminSession: [] }],
responses: {
200: {
description: "payment 数组",
content: {
"application/json": { schema: z.array(paymentItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const rows = await repos.payments.listAll();
const items = rows.map((row) => {
const qpu = qpuAt(row.paid_at, timeline);
return toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
);
});
return c.json(items, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/payments",
summary: "登记支付",
description: "订阅/充值同构;kind 决定期间与订阅编号是否必填",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
201: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.payments.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.create",
summary: `登记支付 #${row.id} ${row.kind}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/payments/{id}",
summary: "编辑支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: createBody } },
required: true,
},
},
responses: {
200: {
description: "payment",
content: { "application/json": { schema: paymentItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as Record<
string,
unknown
>;
const parsed = validatePaymentBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.payments.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.payments.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.update",
summary: `编辑支付 #${row.id}`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const qpu = qpuAt(row.paid_at, timeline);
return c.json(
toPaymentItem(
row,
row.quota_granted != null
? quotaToUsdStr(row.quota_granted, qpu)
: "0.00",
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/payments/{id}",
summary: "删除支付",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
},
responses: {
204: { description: "No Content" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.payments.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.payments.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "payment.delete",
summary: `删除支付 #${id}`,
});
return c.body(null, 204);
},
);
return r;
}
+212
View File
@@ -0,0 +1,212 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import { z } from "zod";
import {
ERR,
fetchPlatformSubscriptions,
fetchPlatformTopups,
platformLogin,
} from "@new-api-key-usage/core";
import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts";
import { err401, err422, err502 } from "../api-schemas.ts";
const platformSubsSchema = z.object({
subscriptions: z.array(
z.object({
id: z.number().int(),
plan_id: z.number().int(),
start_time: z.number().int(),
end_time: z.number().int(),
status: z.string(),
amount_total: z.number(),
amount_used: z.number(),
}),
),
});
const loginBody = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
const listBody = z.object({
access_token: z.string().min(1),
});
/**
* 登记辅助:控制台账号登录 + 拉订阅。密码不落库;access_token 由前端短暂持有。
*/
export function adminPlatformRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "post",
path: "/platform/login",
summary: "登录 NewAPI 控制台(登记辅助)",
tags: ["支付"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: loginBody } },
required: true,
},
},
responses: {
200: {
description: "access_token",
content: {
"application/json": {
schema: z.object({ access_token: z.string() }),
},
},
},
401: err401,
422: err422,
502: err502,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const username = String(body?.username ?? "");
const password = String(body?.password ?? "");
if (!username || !password) {
return jsonError(
c,
422,
ERR.validation_failed({
username: username ? "" : "请填写用户名",
password: password ? "" : "请填写密码",
}),
);
}
const login = await platformLogin(c.get("newapiBase"), username, password);
if (!login.ok) {
if (login.kind === "rejected") return jsonError(c, 422, ERR.invalid_credentials());
if (login.kind === "unreachable") return jsonError(c, 502, ERR.upstream_unreachable());
return jsonError(
c,
422,
ERR.validation_failed({
password: "登录未成功——可能需验证码/加密登录,或账号密码不正确",
}),
);
}
return c.json({ access_token: login.data.access_token }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/platform/subscriptions",
summary: "拉取订阅列表(登记辅助)",
tags: ["支付"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: listBody } },
required: true,
},
},
responses: {
200: {
description: "subscriptions",
content: { "application/json": { schema: platformSubsSchema } },
},
401: err401,
422: err422,
502: err502,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const token = String(body?.access_token ?? "");
if (!token) {
return jsonError(
c,
422,
ERR.validation_failed({ access_token: "缺少 access_token" }),
);
}
const subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token);
if (!subs.ok) {
if (subs.kind === "rejected") {
// 上游 token 失效 ≠ 管理端会话过期:绝不能回 401(前端会误登出)
return jsonError(c, 422, ERR.platform_token_invalid());
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
return c.json({ subscriptions: subs.data }, 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/platform/topups",
summary: "分页拉取充值单(登记辅助)",
tags: ["支付"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": {
schema: listBody.extend({
page: z.number().int().positive().optional(),
page_size: z.number().int().positive().max(50).optional(),
}),
},
},
required: true,
},
},
responses: {
200: {
description: "topups page",
content: {
"application/json": {
schema: z.object({
items: z.array(z.looseObject({ id: z.number().int() })),
total: z.number().int(),
page: z.number().int(),
page_size: z.number().int(),
}),
},
},
},
401: err401,
422: err422,
502: err502,
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const token = String(body?.access_token ?? "");
if (!token) {
return jsonError(
c,
422,
ERR.validation_failed({ access_token: "缺少 access_token" }),
);
}
const page = Number(body?.page ?? 1) || 1;
const pageSize = Math.min(50, Number(body?.page_size ?? 10) || 10);
const tops = await fetchPlatformTopups(
c.get("newapiBase"),
token,
page,
pageSize,
);
if (!tops.ok) {
if (tops.kind === "rejected") {
return jsonError(c, 422, ERR.platform_token_invalid());
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
return c.json(tops.data, 200);
},
);
return r;
}
+3 -3
View File
@@ -129,7 +129,7 @@ export function adminSessionRoutes() {
const password = typeof body?.password === "string" ? body.password : "";
const fields: Record<string, string> = {};
if (!username) fields.username = "请填写用户名";
if (password.length < 8) fields.password = "密码至少 8 位";
if (!password) fields.password = "请填写密码";
if (Object.keys(fields).length > 0) {
return jsonError(c, 422, ERR.validation_failed(fields));
}
@@ -274,11 +274,11 @@ export function adminSessionRoutes() {
const body = await c.req.json().catch(() => null);
const old_password = String(body?.old_password ?? "");
const new_password = String(body?.new_password ?? "");
if (new_password.length < 8) {
if (!new_password) {
return jsonError(
c,
422,
ERR.validation_failed({ new_password: "密码至少 8 位" }),
ERR.validation_failed({ new_password: "请填写新密码" }),
);
}
const repos = c.get("repos");
@@ -0,0 +1,300 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
errorBody,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type SubscriptionItem,
} from "@new-api-key-usage/core";
import type { SubscriptionRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createSubscriptionBodySchema,
err401,
err404,
err422,
errorSchema,
subscriptionItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toSubscriptionItem(
row: SubscriptionRow,
quota_usd: string,
): SubscriptionItem {
return {
id: row.id,
subscription_id: row.subscription_id,
period_start: toUnixSec(row.period_start)!,
period_end: toUnixSec(row.period_end),
quota_granted: row.quota_granted,
quota_usd,
paid_amount: row.paid_amount,
paid_at: toUnixSec(row.paid_at),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
}
function parseSubBody(body: z.infer<typeof createSubscriptionBodySchema>):
| { fields: Record<string, string> }
| {
values: {
subscription_id: number;
period_start: Date;
period_end: Date | null;
quota_granted: number;
paid_amount: number | null;
paid_at: Date | null;
billing_domain: string | null;
remark: string;
};
} {
const fields: Record<string, string> = {};
if (body.subscription_id == null || body.subscription_id <= 0) {
fields.subscription_id = "请填写订阅编号";
}
if (!body.period_start) fields.period_start = "请填写期间起点";
if (body.quota_granted == null || body.quota_granted <= 0) {
fields.quota_granted = "请填写套餐总额度";
}
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
subscription_id: body.subscription_id,
period_start: new Date(body.period_start),
period_end: body.period_end
? new Date(body.period_end)
: null,
quota_granted: body.quota_granted,
paid_amount: body.paid_amount ?? null,
paid_at: body.paid_at ? new Date(body.paid_at) : null,
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "",
},
};
}
export function adminSubscriptionRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/subscriptions",
summary: "订阅权益清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "subscription 数组",
content: {
"application/json": { schema: z.array(subscriptionItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.subscriptions.listAll(filter);
return c.json(
rows.map((row) =>
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
),
200,
);
},
);
r.openapi(
createRoute({
method: "post",
path: "/subscriptions",
summary: "登记订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": { schema: createSubscriptionBodySchema },
},
},
},
responses: {
201: {
description: "subscription",
content: {
"application/json": { schema: subscriptionItemSchema },
},
},
401: err401,
409: {
description: "重复登记",
content: { "application/json": { schema: errorSchema } },
},
422: err422,
},
}),
async (c) => {
const body = c.req.valid("json");
const parsed = parseSubBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const exists = await repos.subscriptions.findBySubscriptionId(
parsed.values.subscription_id,
);
if (exists) {
return jsonError(
c,
409,
errorBody(
"duplicate",
`订阅 #${parsed.values.subscription_id} 已有登记`,
),
);
}
const row = await repos.subscriptions.insert(parsed.values);
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.create",
summary: `订阅 #${row.subscription_id} · 登记`,
});
return c.json(
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
201,
);
},
);
r.openapi(
createRoute({
method: "put",
path: "/subscriptions/{id}",
summary: "编辑订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: {
"application/json": { schema: createSubscriptionBodySchema },
},
},
},
responses: {
200: {
description: "subscription",
content: {
"application/json": { schema: subscriptionItemSchema },
},
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = c.req.valid("json");
const parsed = parseSubBody(body);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.subscriptions.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.subscriptions.update(id, parsed.values);
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.update",
summary: `订阅 #${row.subscription_id} · 编辑`,
});
return c.json(
toSubscriptionItem(
row,
quotaToUsdStr(
row.quota_granted,
qpuAt(row.paid_at ?? row.created_at, timeline),
),
),
200,
);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/subscriptions/{id}",
summary: "删除订阅权益",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: "deleted" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.subscriptions.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.subscriptions.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "subscription.delete",
summary: `订阅 #${before.subscription_id} · 删除`,
});
return c.body(null, 204);
},
);
return r;
}
+256
View File
@@ -0,0 +1,256 @@
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
qpuAt,
quotaToUsdStr,
resolveQpuTimeline,
toUnixSec,
type TopupItem,
} from "@new-api-key-usage/core";
import type { TopupRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
createTopupBodySchema,
err401,
err404,
err422,
topupItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toTopupItem(row: TopupRow, qpu: number): TopupItem {
const quota_derived = Math.round(row.amount * qpu);
return {
id: row.id,
upstream_id: row.upstream_id,
amount: row.amount,
money: row.money,
trade_no: row.trade_no,
payment_method: row.payment_method,
payment_provider: row.payment_provider,
status: row.status,
paid_at: toUnixSec(row.paid_at)!,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
billing_domain: row.billing_domain,
remark: row.remark,
created_at: toUnixSec(row.created_at)!,
};
}
function parseTopupBody(body: z.infer<typeof createTopupBodySchema>):
| { fields: Record<string, string> }
| {
values: {
upstream_id: number | null;
amount: number;
money: number;
trade_no: string | null;
payment_method: string | null;
payment_provider: string | null;
status: string | null;
paid_at: Date;
billing_domain: string | null;
remark: string;
};
} {
const fields: Record<string, string> = {};
if (!body.paid_at) fields.paid_at = "请填写入账时间";
if (typeof body.amount !== "number") fields.amount = "请填写计费单位金额";
if (typeof body.money !== "number") fields.money = "请填写实付金额";
if (Object.keys(fields).length > 0) return { fields };
return {
values: {
upstream_id: body.upstream_id ?? null,
amount: body.amount,
money: body.money,
trade_no: body.trade_no ?? null,
payment_method: body.payment_method ?? null,
payment_provider: body.payment_provider ?? null,
status: body.status ?? "success",
paid_at: new Date(body.paid_at),
billing_domain:
typeof body.billing_domain === "string" && body.billing_domain.trim()
? body.billing_domain.trim()
: null,
remark: body.remark ?? "",
},
};
}
export function adminTopupRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/topups",
summary: "充值入账清单",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
query: z.object({
billing_domain: z.string().optional(),
}),
},
responses: {
200: {
description: "topup 数组",
content: {
"application/json": { schema: z.array(topupItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const q = c.req.query();
const filter =
q.billing_domain === "none"
? { billing_domain: "none" as const }
: q.billing_domain
? { billing_domain: q.billing_domain }
: undefined;
const rows = await repos.topups.listAll(filter);
return c.json(
rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))),
200,
);
},
);
r.openapi(
createRoute({
method: "post",
path: "/topups",
summary: "登记充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
body: {
content: {
"application/json": { schema: createTopupBodySchema },
},
required: true,
},
},
responses: {
201: {
description: "topup",
content: { "application/json": { schema: topupItemSchema } },
},
401: err401,
422: err422,
},
}),
async (c) => {
const body = (await c.req.json().catch(() => ({}))) as z.infer<
typeof createTopupBodySchema
>;
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
const row = await repos.topups.insert(parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.create",
summary: `充值 #${row.id} · 登记`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 201);
},
);
r.openapi(
createRoute({
method: "put",
path: "/topups/{id}",
summary: "编辑充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: {
"application/json": { schema: createTopupBodySchema },
},
required: true,
},
},
responses: {
200: {
description: "topup",
content: { "application/json": { schema: topupItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = (await c.req.json().catch(() => ({}))) as z.infer<
typeof createTopupBodySchema
>;
const parsed = parseTopupBody(body as z.infer<typeof createTopupBodySchema>);
if ("fields" in parsed) {
return jsonError(c, 422, ERR.validation_failed(parsed.fields));
}
const repos = c.get("repos");
if (!(await repos.topups.findById(id))) {
return jsonError(c, 404, ERR.not_found());
}
const row = await repos.topups.update(id, parsed.values);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.update",
summary: `充值 #${row.id} · 编辑`,
});
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 200);
},
);
r.openapi(
createRoute({
method: "delete",
path: "/topups/{id}",
summary: "删除充值",
tags: ["支付记录"],
security: [{ adminSession: [] }],
request: { params: z.object({ id: z.string() }) },
responses: {
204: { description: "deleted" },
401: err401,
404: err404,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const repos = c.get("repos");
const before = await repos.topups.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
await repos.topups.remove(id);
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "payment",
action: "topup.delete",
summary: `充值 #${before.id} · 删除`,
});
return c.body(null, 204);
},
);
return r;
}
+74 -11
View File
@@ -4,6 +4,7 @@ import {
resolveQpuTimeline,
sumUsage,
toUnixSec,
usageRowPrecise,
type LogItem,
} from "@new-api-key-usage/core";
import {
@@ -240,6 +241,7 @@ export function adminUsageRoutes() {
};
b.key_count += 1;
if (
v.status === "active" &&
v.log_synced_at &&
(b.cutoff === null || v.log_synced_at < b.cutoff)
) {
@@ -277,7 +279,7 @@ export function adminUsageRoutes() {
path: "/logs",
summary: "日志明细(下钻)",
description:
"分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名",
"分页;from/to 必填;默认全类型(可 type / model_name 筛);token_name 取当前名;附带 summary 与 models",
tags: ["用量"],
security: [{ adminSession: [] }],
request: {
@@ -288,12 +290,13 @@ export function adminUsageRoutes() {
member_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
@@ -310,23 +313,42 @@ export function adminUsageRoutes() {
const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50));
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listPage({
const filters = {
from: range.from,
to: range.to,
page,
page_size,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
member_id: q.member_id ? Number(q.member_id) : undefined,
billing_source: q.billing_source || undefined,
member_id:
q.member_id === "unassigned"
? ("unassigned" as const)
: q.member_id
? Number(q.member_id)
: undefined,
billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
type: q.type != null && q.type !== "" ? Number(q.type) : undefined,
model_name: q.model_name || undefined,
type:
q.type === "error"
? ("error" as const)
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
};
const { rows, total } = await repos.logs.listPage({
...filters,
page,
page_size,
});
const sum = await repos.logs.summarize(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listModels(filters);
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = sumUsage(
[{ quota: row.quota, created_at: row.created_at }],
const { quota_usd } = usageRowPrecise(
row.quota,
row.created_at,
timeline,
);
return {
@@ -345,10 +367,51 @@ export function adminUsageRoutes() {
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
raw: {
id: row.id,
api_key_id: row.api_key_id,
dedupe_key: row.dedupe_key,
billing_source: row.billing_source,
subscription_id: row.subscription_id,
user_id: row.user_id,
created_at: toUnixSec(row.created_at),
type: row.type,
content: row.content,
username: row.username,
token_name: row.token_name,
model_name: row.model_name,
quota: row.quota,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
channel: row.channel,
channel_name: row.channel_name,
token_id: row.token_id,
group_name: row.group_name,
ip: row.ip,
request_id: row.request_id,
upstream_request_id: row.upstream_request_id,
other: row.other,
},
};
});
return c.json({ items, total }, 200);
return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
},
);
+135 -50
View File
@@ -3,9 +3,9 @@ import {
ERR,
resolveQpuTimeline,
sumUsage,
usageRowPrecise,
toUnixSec,
type LogItem,
type PaymentItem,
} from "@new-api-key-usage/core";
import {
jsonError,
@@ -18,7 +18,6 @@ import {
logPageSchema,
memberMeSchema,
memberOverviewSchema,
paymentItemSchema,
} from "../api-schemas.ts";
import { z } from "zod";
@@ -72,7 +71,7 @@ export function memberViewRoutes() {
path: "/overview",
summary: "单页聚合",
description:
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)",
"名下 key 的 type=2 用量 + 来源分解 + 支付参数(同账本域内可见)",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -160,42 +159,66 @@ export function memberViewRoutes() {
usage: sumUsage(v.rows, timeline),
}));
// 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
const paymentRows = await repos.payments.listAll();
// 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 我域内进行中;
// 充值 = 我域内且 paid_at ∈ [from,to)。未标域默认藏(仅「涉及我日志」的订阅例外)。
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const myDomains = new Set<string>();
for (const k of allKeys) {
if (k.billing_domain) myDomains.add(k.billing_domain);
}
const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
);
const payments: PaymentItem[] = [];
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
for (const p of paymentRows) {
const inRange = p.paid_at >= range.from && p.paid_at < range.to;
const subscriptionsOut = [];
for (const s of subRows) {
const ongoing =
p.kind === "subscription" &&
(p.period_end == null ||
p.period_end > new Date() ||
(p.period_start != null &&
p.period_start <= range.to &&
(p.period_end ?? new Date()) >= range.from));
const involved =
p.kind === "topup"
? inRange
: usedSubIds.has(p.subscription_id ?? -1) || ongoing;
if (!involved) continue;
const qpu = qpuAt(p.paid_at, timeline);
payments.push({
id: p.id,
kind: p.kind as PaymentItem["kind"],
paid_at: toUnixSec(p.paid_at)!,
paid_amount: p.paid_amount,
remark: p.remark,
period_start: toUnixSec(p.period_start),
period_end: toUnixSec(p.period_end),
subscription_id: p.subscription_id,
quota_granted: p.quota_granted,
quota_usd:
p.quota_granted != null
? quotaToUsdStr(p.quota_granted, qpu)
: "0.00",
s.period_end == null ||
s.period_end > new Date() ||
(s.period_start <= range.to &&
(s.period_end ?? new Date()) >= range.from);
const involved = usedSubIds.has(s.subscription_id);
const inMyDomain =
s.billing_domain != null && myDomains.has(s.billing_domain);
// 涉及我日志的始终可见(事实直连);其余仅我域内进行中可见
if (!involved && !(ongoing && inMyDomain)) continue;
const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
subscriptionsOut.push({
id: s.id,
subscription_id: s.subscription_id,
period_start: toUnixSec(s.period_start)!,
period_end: toUnixSec(s.period_end),
quota_granted: s.quota_granted,
quota_usd: quotaToUsdStr(s.quota_granted, qpu),
paid_amount: s.paid_amount,
paid_at: toUnixSec(s.paid_at),
billing_domain: s.billing_domain,
remark: s.remark,
created_at: toUnixSec(s.created_at)!,
});
}
const topupRows = await repos.topups.listAll();
const topupsOut = [];
for (const t of topupRows) {
if (t.billing_domain == null || !myDomains.has(t.billing_domain)) continue;
if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue;
const qpu = qpuAt(t.paid_at, timeline);
const quota_derived = Math.round(t.amount * qpu);
topupsOut.push({
id: t.id,
upstream_id: t.upstream_id,
amount: t.amount,
money: t.money,
trade_no: t.trade_no,
payment_method: t.payment_method,
payment_provider: t.payment_provider,
status: t.status,
paid_at: toUnixSec(t.paid_at)!,
billing_domain: t.billing_domain,
quota_derived,
quota_usd: quotaToUsdStr(quota_derived, qpu),
remark: t.remark,
created_at: toUnixSec(t.created_at)!,
});
}
@@ -205,14 +228,8 @@ export function memberViewRoutes() {
usage,
by_source,
keys,
payments: payments.map((p) => ({
...p,
kind: p.kind as string,
period_start: p.period_start ?? null,
period_end: p.period_end ?? null,
subscription_id: p.subscription_id ?? null,
quota_granted: p.quota_granted ?? null,
})),
subscriptions: subscriptionsOut,
topups: topupsOut,
},
200,
);
@@ -224,7 +241,8 @@ export function memberViewRoutes() {
method: "get",
path: "/logs",
summary: "日志明细(名下 key)",
description: "分页;恒 type=2;DTO 与管理面同构",
description:
"分页;默认全类型(可 type / model_name 筛);DTO 与管理面同构;附带 summary 与 models",
tags: ["成员视图"],
security: [{ memberToken: [] }],
request: {
@@ -233,11 +251,16 @@ export function memberViewRoutes() {
to: z.string(),
page: z.string().optional(),
page_size: z.string().optional(),
api_key_id: z.string().optional(),
billing_source: z.string().optional(),
subscription_id: z.string().optional(),
model_name: z.string().optional(),
type: z.string().optional(),
}),
},
responses: {
200: {
description: "items/total",
description: "items/total/summary/models",
content: { "application/json": { schema: logPageSchema } },
},
401: err401,
@@ -257,16 +280,37 @@ export function memberViewRoutes() {
);
const repos = c.get("repos");
const timeline = resolveQpuTimeline(await repos.qpu.listTimeline());
const { rows, total } = await repos.logs.listMemberPage({
member_id: c.get("memberId")!,
const member_id = c.get("memberId")!;
const filters = {
member_id,
from: range.from,
to: range.to,
api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined,
billing_source:
q.billing_source === "none" ? "none" : q.billing_source || undefined,
subscription_id: q.subscription_id
? Number(q.subscription_id)
: undefined,
model_name: q.model_name || undefined,
type:
q.type === "error"
? ("error" as const)
: q.type != null && q.type !== ""
? Number(q.type)
: undefined,
};
const { rows, total } = await repos.logs.listMemberPage({
...filters,
page,
page_size,
});
const sum = await repos.logs.summarizeMember(filters);
const usage = sumUsage(sum.usage_rows, timeline);
const models = await repos.logs.listMemberModels(filters);
const items: LogItem[] = rows.map((row) => {
const { quota_usd } = sumUsage(
[{ quota: row.quota, created_at: row.created_at }],
const { quota_usd } = usageRowPrecise(
row.quota,
row.created_at,
timeline,
);
return {
@@ -285,9 +329,50 @@ export function memberViewRoutes() {
is_stream: row.is_stream,
billing_source: row.billing_source as LogItem["billing_source"],
subscription_id: row.subscription_id,
raw: {
id: row.id,
api_key_id: row.api_key_id,
dedupe_key: row.dedupe_key,
billing_source: row.billing_source,
subscription_id: row.subscription_id,
user_id: row.user_id,
created_at: toUnixSec(row.created_at),
type: row.type,
content: row.content,
username: row.username,
token_name: row.token_name,
model_name: row.model_name,
quota: row.quota,
prompt_tokens: row.prompt_tokens,
completion_tokens: row.completion_tokens,
use_time: row.use_time,
is_stream: row.is_stream,
channel: row.channel,
channel_name: row.channel_name,
token_id: row.token_id,
group_name: row.group_name,
ip: row.ip,
request_id: row.request_id,
upstream_request_id: row.upstream_request_id,
other: row.other,
},
};
});
return c.json({ items, total }, 200);
return c.json(
{
items,
total,
summary: {
hit_count: sum.hit_count,
usage_count: sum.usage_count,
error_count: sum.error_count,
quota: usage.quota,
quota_usd: usage.quota_usd,
},
models,
},
200,
);
},
);
-54
View File
@@ -1,54 +0,0 @@
# apps/web
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
bun install
```
### Compile and Hot-Reload for Development
```sh
bun dev
```
### Type-Check, Compile and Minify for Production
```sh
bun run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
bun test:unit
```
### Lint with [ESLint](https://eslint.org/)
```sh
bun lint
```
+2
View File
@@ -5,6 +5,8 @@ export async function fetchAuditLogs(params: {
page: number;
page_size: number;
domain?: string;
from?: string;
to?: string;
}): Promise<Paginated<AuditLogItem>> {
const { data } = await adminHttp.get<Paginated<AuditLogItem>>("/audit-logs", {
params,
+7 -1
View File
@@ -4,6 +4,7 @@ import { adminHttp } from "../http.ts";
export type KeyListFilters = {
member_id?: number | "unassigned";
status?: "active" | "disabled";
billing_domain?: string | "none";
recent_failed?: boolean;
};
@@ -15,6 +16,7 @@ export async function fetchKeys(filters: KeyListFilters = {}): Promise<KeyItem[]
export async function registerKey(body: {
sk_key: string;
member_id?: number | null;
billing_domain?: string | null;
}): Promise<KeyItem> {
const { data } = await adminHttp.post<KeyItem>("/keys", body);
return data;
@@ -22,7 +24,11 @@ export async function registerKey(body: {
export async function patchKey(
id: number,
body: { status?: "active" | "disabled"; member_id?: number | null },
body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
},
): Promise<KeyItem> {
const { data } = await adminHttp.patch<KeyItem>(`/keys/${id}`, body);
return data;
+17 -6
View File
@@ -1,11 +1,22 @@
import type { LogItem, Paginated } from "@new-api-key-usage/core";
import type { LogPage } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export async function fetchLogs(
range: { from: string; to: string; page: number; page_size: number },
): Promise<Paginated<LogItem>> {
const { data } = await adminHttp.get<Paginated<LogItem>>("/logs", {
params: range,
export type AdminLogFilters = {
from: string;
to: string;
page: number;
page_size: number;
api_key_id?: number;
member_id?: number | "unassigned";
billing_source?: string;
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
export async function fetchLogs(params: AdminLogFilters): Promise<LogPage> {
const { data } = await adminHttp.get<LogPage>("/logs", {
params,
});
return data;
}
+10 -1
View File
@@ -22,12 +22,21 @@ export async function createMember(body: {
export async function patchMember(
id: number,
body: { status: "active" | "disabled" },
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
},
): Promise<MemberItem> {
const { data } = await adminHttp.patch<MemberItem>(`/members/${id}`, body);
return data;
}
/** 空壳删除:仅 key_count = 0 */
export async function deleteMember(id: number): Promise<void> {
await adminHttp.delete(`/members/${id}`);
}
/** 重发:旧令牌即刻失效,明文仅此一次 */
export async function issueMemberToken(id: number): Promise<{ token: string }> {
const { data } = await adminHttp.post<{ token: string }>(`/members/${id}/token`);
-26
View File
@@ -1,26 +0,0 @@
import type { PaymentItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type PaymentBody = Record<string, unknown>;
export async function fetchPayments(): Promise<PaymentItem[]> {
const { data } = await adminHttp.get<PaymentItem[]>("/payments");
return data;
}
export async function createPayment(body: PaymentBody): Promise<PaymentItem> {
const { data } = await adminHttp.post<PaymentItem>("/payments", body);
return data;
}
export async function updatePayment(
id: number,
body: PaymentBody,
): Promise<PaymentItem> {
const { data } = await adminHttp.put<PaymentItem>(`/payments/${id}`, body);
return data;
}
export async function deletePayment(id: number): Promise<void> {
await adminHttp.delete(`/payments/${id}`);
}
+70
View File
@@ -0,0 +1,70 @@
import { adminHttp } from "../http.ts";
export type PlatformSubscription = {
id: number;
plan_id: number;
start_time: number;
end_time: number;
status: string;
amount_total: number;
amount_used: number;
};
export type PlatformTopup = {
id: number;
amount: number;
money: number;
trade_no?: string;
payment_method?: string;
payment_provider?: string;
create_time?: number;
complete_time?: number;
status?: string;
};
export type PlatformLoginResult = { access_token: string };
/** 登录控制台(密码不落库);token 由调用方短暂保存 */
export async function loginPlatform(body: {
username: string;
password: string;
}): Promise<PlatformLoginResult> {
const { data } = await adminHttp.post<PlatformLoginResult>(
"/platform/login",
body,
);
return data;
}
export async function listPlatformSubscriptions(
accessToken: string,
): Promise<PlatformSubscription[]> {
const { data } = await adminHttp.post<{ subscriptions: PlatformSubscription[] }>(
"/platform/subscriptions",
{ access_token: accessToken },
);
return data.subscriptions;
}
export async function listPlatformTopups(
accessToken: string,
page = 1,
pageSize = 10,
): Promise<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}> {
const { data } = await adminHttp.post<{
items: PlatformTopup[];
total: number;
page: number;
page_size: number;
}>("/platform/topups", {
access_token: accessToken,
page,
page_size: pageSize,
});
return data;
}
+44
View File
@@ -0,0 +1,44 @@
import type { SubscriptionItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type SubscriptionBody = {
subscription_id: number;
period_start: string;
period_end?: string | null;
quota_granted: number;
paid_amount?: number | null;
paid_at?: string | null;
billing_domain?: string | null;
remark?: string;
};
export async function fetchSubscriptions(filter?: {
billing_domain?: string | "none";
}): Promise<SubscriptionItem[]> {
const { data } = await adminHttp.get<SubscriptionItem[]>("/subscriptions", {
params: filter,
});
return data;
}
export async function createSubscription(
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.post<SubscriptionItem>("/subscriptions", body);
return data;
}
export async function updateSubscription(
id: number,
body: SubscriptionBody,
): Promise<SubscriptionItem> {
const { data } = await adminHttp.put<SubscriptionItem>(
`/subscriptions/${id}`,
body,
);
return data;
}
export async function deleteSubscription(id: number): Promise<void> {
await adminHttp.delete(`/subscriptions/${id}`);
}
+41
View File
@@ -0,0 +1,41 @@
import type { TopupItem } from "@new-api-key-usage/core";
import { adminHttp } from "../http.ts";
export type TopupBody = {
upstream_id?: number | null;
amount: number;
money: number;
trade_no?: string | null;
payment_method?: string | null;
payment_provider?: string | null;
status?: string | null;
paid_at: string;
billing_domain?: string | null;
remark?: string;
};
export async function fetchTopups(filter?: {
billing_domain?: string | "none";
}): Promise<TopupItem[]> {
const { data } = await adminHttp.get<TopupItem[]>("/topups", {
params: filter,
});
return data;
}
export async function createTopup(body: TopupBody): Promise<TopupItem> {
const { data } = await adminHttp.post<TopupItem>("/topups", body);
return data;
}
export async function updateTopup(
id: number,
body: TopupBody,
): Promise<TopupItem> {
const { data } = await adminHttp.put<TopupItem>(`/topups/${id}`, body);
return data;
}
export async function deleteTopup(id: number): Promise<void> {
await adminHttp.delete(`/topups/${id}`);
}
+5 -1
View File
@@ -77,7 +77,11 @@ function attachInterceptors(client: AxiosInstance, world: AuthWorld): void {
(response) => response,
(error: AxiosError) => {
const apiError = toApiError(error, world);
if (apiError.status === 401) {
// 401 仅表示「本世界凭证失效」;上游平台 token 失效是 422 platform_token_invalid
if (
apiError.status === 401 &&
apiError.code !== "platform_token_invalid"
) {
redirectAuth(world, "expired");
}
return Promise.reject(apiError);
+20 -9
View File
@@ -1,7 +1,7 @@
import type {
LogItem,
Paginated,
PaymentItem,
LogPage,
SubscriptionItem,
TopupItem,
UnixSec,
UsageTotals,
} from "@new-api-key-usage/core";
@@ -27,7 +27,8 @@ export type MemberOverview = {
log_synced_at: UnixSec | null;
usage: UsageTotals;
}>;
payments: PaymentItem[];
subscriptions: SubscriptionItem[];
topups: TopupItem[];
};
export async function fetchMe(): Promise<MemberMe> {
@@ -45,11 +46,21 @@ export async function fetchOverview(range: {
return data;
}
export async function fetchLogs(
range: { from: string; to: string; page: number; page_size: number },
): Promise<Paginated<LogItem>> {
const { data } = await memberHttp.get<Paginated<LogItem>>("/logs", {
params: range,
export type MemberLogQuery = {
from: string;
to: string;
page: number;
page_size: number;
api_key_id?: number;
billing_source?: string;
subscription_id?: number;
model_name?: string;
type?: number | "error";
};
export async function fetchLogs(query: MemberLogQuery): Promise<LogPage> {
const { data } = await memberHttp.get<LogPage>("/logs", {
params: query,
});
return data;
}
+33 -7
View File
@@ -26,19 +26,45 @@ export const qk = {
overview: (range: TimeRange) =>
["admin", "overview", range.from, range.to] as const,
keys: (filters: KeyFilters) => ["admin", "keys", filters] as const,
logs: (range: TimeRange & PageQuery) =>
["admin", "logs", range.from, range.to, range.page, range.page_size] as const,
payments: () => ["admin", "payments"] as const,
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
[
"admin",
"logs",
range.from,
range.to,
range.page,
range.page_size,
range.api_key_id ?? "",
range.member_id ?? "",
range.model_name ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
] as const,
subscriptions: () => ["admin", "subscriptions"] as const,
topups: () => ["admin", "topups"] as const,
qpuLatest: () => ["admin", "qpu-latest"] as const,
members: () => ["admin", "members"] as const,
auditLogs: (q: PageQuery & { domain?: string }) =>
["admin", "audit-logs", q.page, q.page_size, q.domain ?? ""] as const,
auditLogs: (q: PageQuery & { domain?: string; from?: string; to?: string }) =>
["admin", "audit-logs", q.page, q.page_size, q.domain ?? "", q.from ?? "", q.to ?? ""] as const,
},
member: {
me: () => ["member", "me"] as const,
overview: (range: TimeRange) =>
["member", "overview", range.from, range.to] as const,
logs: (range: TimeRange & PageQuery) =>
["member", "logs", range.from, range.to, range.page, range.page_size] as const,
logs: (range: TimeRange & PageQuery & Record<string, unknown>) =>
[
"member",
"logs",
range.from,
range.to,
range.page,
range.page_size,
range.api_key_id ?? "",
range.model_name ?? "",
range.billing_source ?? "",
range.subscription_id ?? "",
range.type ?? "",
] as const,
},
};
@@ -5,6 +5,7 @@
import { ref, watch } from "vue";
import { message } from "antdv-next";
import { CopyOutlined, WarningOutlined } from "@antdv-next/icons";
import { useClipboard } from "@vueuse/core";
const props = defineProps<{
open: boolean;
@@ -27,9 +28,12 @@ watch(
},
);
/** legacy: true —— HTTP 等非 Secure Context 无 Clipboard API 时退回 execCommand */
const { copy } = useClipboard({ legacy: true });
async function onCopy() {
try {
await navigator.clipboard.writeText(props.token);
await copy(props.token);
message.success("已复制");
} catch {
message.error("复制失败,请手动选中复制");
@@ -0,0 +1,486 @@
<script setup lang="ts">
/**
* 支付登记弹窗:订阅 / 充值分形态(拆表后)
* 时间控件统一精确到秒(show-time)
*/
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { computed, reactive, ref, watch } from "vue";
import { message } from "antdv-next";
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PlatformSubscriptionPicker from "@/components/admin/platform-subscription-picker.vue";
import PlatformTopupPicker from "@/components/admin/platform-topup-picker.vue";
import type {
PlatformSubscription,
PlatformTopup,
} from "@/api/admin/platform.ts";
import {
useBillingDomainOptions,
useCreateSubscription,
useCreateTopup,
useQpuLatest,
useSubscriptions,
useTopups,
useUpdateSubscription,
useUpdateTopup,
} from "@/queries/admin.ts";
import { unixToBeijingWall, wallAsBeijingIso } from "@/utils/time.ts";
const TIME_FMT = "YYYY-MM-DD HH:mm:ss";
const props = defineProps<{
open: boolean;
tab: "subs" | "topups";
editingSub?: SubscriptionItem | null;
editingTop?: TopupItem | null;
}>();
const emit = defineEmits<{ (e: "update:open", v: boolean): void }>();
const createSub = useCreateSubscription();
const updateSub = useUpdateSubscription();
const createTop = useCreateTopup();
const updateTop = useUpdateTopup();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const qpuQuery = useQpuLatest();
const qpu = computed(() => qpuQuery.data.value?.qpu ?? null);
/** 额度双输入旁的换算预览(不入库,仅展示) */
const quotaPreview = computed(() => {
if (isSub.value) {
if (subForm.quotaMode === "quota") {
const q = subForm.quota_granted;
if (q == null || !qpu.value) return null;
return `≈ $${(q / qpu.value).toFixed(2)}(按当前换算率)`;
}
const u = subForm.quota_usd;
if (u == null || !qpu.value) return null;
return `≈ ${Math.round(u * qpu.value)} quota`;
}
const a = topForm.amount;
if (a == null || !qpu.value) return null;
return `≈ ${Math.round(a * qpu.value)} quota(按当前换算率)`;
});
const pickerOpen = ref(false);
const topPickerOpen = ref(false);
const error = ref("");
const isSub = ref(props.tab === "subs");
const registeredSubIds = computed(
() => (subsQuery.data.value ?? []).map((s) => s.subscription_id),
);
const registeredTopIds = computed(
() =>
(topupsQuery.data.value ?? [])
.map((t) => t.upstream_id)
.filter((v): v is number => v != null),
);
const domainOptions = useBillingDomainOptions();
const subForm = reactive({
subscription_id: null as number | null,
period_start: null as Dayjs | null,
period_end: null as Dayjs | null,
quota_granted: null as number | null,
quotaMode: "quota" as "quota" | "usd",
quota_usd: null as number | null,
paid_amount: null as number | null,
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
const topForm = reactive({
upstream_id: null as number | null,
amount: null as number | null,
money: null as number | null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null as Dayjs | null,
billing_domain: "",
remark: "",
});
function resetSub() {
Object.assign(subForm, {
subscription_id: null,
period_start: null,
period_end: null,
quota_granted: null,
quotaMode: "quota",
quota_usd: null,
paid_amount: null,
paid_at: null,
billing_domain: "",
remark: "",
});
}
function resetTop() {
Object.assign(topForm, {
upstream_id: null,
amount: null,
money: null,
trade_no: "",
payment_method: "",
payment_provider: "",
paid_at: null,
billing_domain: "",
remark: "",
});
}
watch(
() => props.open,
(v) => {
if (!v) return;
error.value = "";
isSub.value = props.tab === "subs";
if (props.editingSub) {
isSub.value = true;
const s = props.editingSub;
subForm.subscription_id = s.subscription_id;
subForm.period_start = unixToBeijingWall(s.period_start);
subForm.period_end =
s.period_end != null ? unixToBeijingWall(s.period_end) : null;
subForm.quota_granted = s.quota_granted;
subForm.quotaMode = "quota";
subForm.quota_usd = null;
subForm.paid_amount = s.paid_amount;
subForm.paid_at =
s.paid_at != null ? unixToBeijingWall(s.paid_at) : null;
subForm.billing_domain = s.billing_domain ?? "";
subForm.remark = s.remark;
} else {
// 订阅的 paid_at 是「套餐付款时刻」,可空——不默认填今天
resetSub();
}
if (props.editingTop) {
isSub.value = false;
const t = props.editingTop;
topForm.upstream_id = t.upstream_id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
topForm.paid_at = unixToBeijingWall(t.paid_at);
topForm.billing_domain = t.billing_domain ?? "";
topForm.remark = t.remark;
} else {
resetTop();
topForm.paid_at = dayjs();
}
},
);
function onPickSub(s: PlatformSubscription) {
subForm.subscription_id = s.id;
subForm.period_start = unixToBeijingWall(s.start_time);
subForm.period_end =
s.end_time != null ? unixToBeijingWall(s.end_time) : null;
subForm.quota_granted = s.amount_total;
subForm.quotaMode = "quota";
}
function onPickTop(t: PlatformTopup & { paid_at_iso: string }) {
topForm.upstream_id = t.id;
topForm.amount = t.amount;
topForm.money = t.money;
topForm.trade_no = t.trade_no ?? "";
topForm.payment_method = t.payment_method ?? "";
topForm.payment_provider = t.payment_provider ?? "";
const sec = t.complete_time ?? t.create_time;
topForm.paid_at =
sec != null ? unixToBeijingWall(sec) : unixToBeijingWall(Date.now() / 1000);
}
function iso(v: Dayjs | null): string | null {
// 北京墙钟进控件 → wallAsBeijingIso 无损回写(与旧登记表单一致)
return v ? wallAsBeijingIso(v) : null;
}
async function onSubmit() {
error.value = "";
try {
if (isSub.value) {
if (!subForm.period_start) {
error.value = "请填写期间起点";
return;
}
if (subForm.subscription_id == null) {
error.value = "请填写订阅编号";
return;
}
const qpu = qpuQuery.data.value?.qpu ?? 500000;
const quota =
subForm.quotaMode === "quota"
? subForm.quota_granted
: subForm.quota_usd != null
? Math.round(subForm.quota_usd * qpu)
: null;
if (quota == null) {
error.value = "请填写套餐总额度";
return;
}
if (subForm.paid_amount != null && subForm.paid_amount < 0) {
error.value = "付款金额不能为负数";
return;
}
const dup = (subsQuery.data.value ?? []).find(
(p) =>
p.subscription_id === subForm.subscription_id &&
p.id !== props.editingSub?.id,
);
if (dup) {
error.value = `订阅 #${subForm.subscription_id} 已有登记——请编辑原记录`;
return;
}
const body = {
subscription_id: subForm.subscription_id,
period_start: iso(subForm.period_start)!,
period_end: iso(subForm.period_end),
quota_granted: quota,
paid_amount: subForm.paid_amount,
paid_at: iso(subForm.paid_at),
billing_domain: subForm.billing_domain.trim() || null,
remark: subForm.remark,
};
if (props.editingSub) {
await updateSub.mutateAsync({ id: props.editingSub.id, body });
} else {
await createSub.mutateAsync(body);
}
} else {
if (topForm.amount == null || topForm.amount <= 0) {
error.value = "请填写充值金额 amount(须为正)";
return;
}
if (topForm.money == null || topForm.money < 0) {
error.value = "实付金额不能为负数";
return;
}
if (!topForm.paid_at) {
error.value = "请填写入账时间";
return;
}
const body = {
upstream_id: topForm.upstream_id,
amount: topForm.amount,
money: topForm.money,
trade_no: topForm.trade_no || null,
payment_method: topForm.payment_method || null,
payment_provider: topForm.payment_provider || null,
status: props.editingTop?.status ?? "success",
paid_at: iso(topForm.paid_at)!,
billing_domain: topForm.billing_domain.trim() || null,
remark: topForm.remark,
};
if (props.editingTop) {
await updateTop.mutateAsync({ id: props.editingTop.id, body });
} else {
await createTop.mutateAsync(body);
}
}
message.success(props.editingSub || props.editingTop ? "已保存" : "已登记");
emit("update:open", false);
} catch (e) {
const err = e as ApiError;
if (err.fields) {
error.value = Object.values(err.fields)[0] || "请检查表单";
} else {
error.value = err.message || "保存失败";
}
}
}
</script>
<template>
<a-modal
:open="open"
:title="isSub ? (editingSub ? '编辑订阅' : '登记订阅') : editingTop ? '编辑充值' : '登记充值'"
:confirm-loading="createSub.isPending.value || createTop.isPending.value || updateSub.isPending.value || updateTop.isPending.value"
ok-text="保存"
cancel-text="取消"
width="560"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
@ok="onSubmit"
@update:open="emit('update:open', $event)"
>
<a-radio-group
v-if="!editingSub && !editingTop"
v-model:value="isSub"
style="margin-bottom: 16px"
>
<a-radio :value="true">订阅</a-radio>
<a-radio :value="false">充值</a-radio>
</a-radio-group>
<a-form v-if="isSub" layout="vertical">
<a-form-item label="订阅 ID" required>
<a-space>
<a-input-number
v-model:value="subForm.subscription_id"
:min="1"
:precision="0"
style="width: 160px"
/>
<a-button @click="pickerOpen = true">从平台选用</a-button>
</a-space>
</a-form-item>
<a-form-item label="期间起止" required>
<a-date-picker
v-model:value="subForm.period_start"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 200px"
/>
<span style="margin: 0 8px">~</span>
<a-date-picker
v-model:value="subForm.period_end"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="止(空=进行中)"
style="width: 200px"
/>
</a-form-item>
<a-form-item label="套餐总额度" required>
<a-radio-group v-model:value="subForm.quotaMode" style="margin-right: 8px">
<a-radio-button value="quota">quota</a-radio-button>
<a-radio-button value="usd">美元</a-radio-button>
</a-radio-group>
<a-input-number
v-if="subForm.quotaMode === 'quota'"
v-model:value="subForm.quota_granted"
:min="0"
:precision="0"
style="width: 180px"
/>
<a-input-number
v-else
v-model:value="subForm.quota_usd"
:min="0"
:precision="2"
style="width: 180px"
/>
<div v-if="quotaPreview" class="form-note">
{{ quotaPreview }} · 落库为原始 quota
</div>
</a-form-item>
<a-form-item label="付款金额 / 付款时间(可空)">
<a-input-number
v-model:value="subForm.paid_amount"
:min="0"
:precision="2"
style="width: 140px"
placeholder="金额"
/>
<a-date-picker
v-model:value="subForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
placeholder="钱出手时刻(可空)"
style="width: 200px; margin-left: 8px"
/>
<div class="form-note">
社交层付款事实:金额数值、币种由你们约定;未知可留空。登记时刻由系统另记
</div>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="subForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="subForm.remark" />
</a-form-item>
</a-form>
<a-form v-else layout="vertical">
<a-form-item label="充值金额 amount" required>
<a-space>
<a-input-number
v-model:value="topForm.amount"
:min="0"
:precision="6"
style="width: 180px"
/>
<a-button @click="topPickerOpen = true">从平台选用</a-button>
</a-space>
<div class="form-note">
平台计费侧充值额(不标币种);与「实付」之间汇率由管理员约定。换得 quota ≈ amount × QPU
<template v-if="quotaPreview"> · {{ quotaPreview }}</template>
</div>
</a-form-item>
<a-form-item label="实付金额 money" required>
<a-input-number
v-model:value="topForm.money"
:min="0"
:precision="2"
style="width: 180px"
/>
<div class="form-note">实际支付金额(不预设币种)</div>
</a-form-item>
<a-form-item label="入账时间 paid_at" required>
<a-date-picker
v-model:value="topForm.paid_at"
:format="TIME_FMT"
:show-time="{ defaultValue: undefined }"
style="width: 220px"
/>
</a-form-item>
<a-form-item label="交易号 / 渠道">
<a-input v-model:value="topForm.trade_no" placeholder="trade_no" style="width: 220px" />
<a-input
v-model:value="topForm.payment_method"
placeholder="method"
style="width: 120px; margin-left: 8px"
/>
<a-input
v-model:value="topForm.payment_provider"
placeholder="provider"
style="width: 120px; margin-left: 8px"
/>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="topForm.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="topForm.remark" />
</a-form-item>
</a-form>
<a-alert v-if="error" type="error" show-icon :message="error" />
<PlatformSubscriptionPicker
v-model:open="pickerOpen"
:registered-ids="registeredSubIds"
@select="onPickSub"
/>
<PlatformTopupPicker
v-model:open="topPickerOpen"
:registered-ids="registeredTopIds"
@select="onPickTop"
/>
</a-modal>
</template>
<style scoped>
.form-note {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
@@ -0,0 +1,264 @@
<script setup lang="ts">
/**
* 订阅选择弹层:登录(仅一次/标签页)+ 列表选用,与登记表单解耦
*/
import { ref, watch } from "vue";
import {
listPlatformSubscriptions,
loginPlatform,
type PlatformSubscription,
} from "@/api/admin/platform.ts";
import type { ApiError } from "@/api/http.ts";
import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{
open: boolean;
/** 已登记的 subscription_id——灰显不可选用 */
registeredIds?: number[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "select", sub: PlatformSubscription): void;
}>();
const TOKEN_KEY = "naku_platform_token";
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
/** 打开后检查登录并拉取中——避免闪现登录表单 */
const booting = ref(false);
const list = ref<PlatformSubscription[]>([]);
function readToken(): string {
try {
return sessionStorage.getItem(TOKEN_KEY) ?? "";
} catch {
return "";
}
}
function writeToken(token: string) {
try {
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
watch(
() => props.open,
(v) => {
if (v) {
// 有 token:先显示「检查中」,拉完再进列表/登录,不闪登录表单
if (readToken()) {
booting.value = true;
void load(true).finally(() => {
booting.value = false;
});
}
} else {
list.value = [];
error.value = "";
password.value = "";
booting.value = false;
}
},
);
async function load(skipLoginCheck = false) {
if (loading.value) return;
if (!skipLoginCheck && (!username.value.trim() || !password.value)) return;
error.value = "";
loading.value = true;
try {
let token = readToken();
if (!token) {
const login = await loginPlatform({
username: username.value.trim(),
password: password.value,
});
token = login.access_token;
writeToken(token);
}
password.value = "";
list.value = await listPlatformSubscriptions(token);
if (list.value.length === 0) {
error.value = "账号下没有订阅记录";
}
} catch (e) {
const err = e as ApiError;
if (err.status === 401 || err.code === "platform_token_invalid") {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
error.value = err.message || "拉取失败";
}
list.value = [];
password.value = "";
} finally {
loading.value = false;
}
}
function isRegistered(s: PlatformSubscription): boolean {
return (props.registeredIds ?? []).includes(s.id);
}
function onUse(s: PlatformSubscription) {
if (isRegistered(s)) return;
emit("select", s);
emit("update:open", false);
}
</script>
<template>
<a-modal
:open="open"
title="从平台选择订阅"
:footer="null"
:width="520"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<div v-if="booting || (loading && list.length === 0)" class="plat-pick__boot">
<a-spin />
<span class="plat-pick__boot-text">正在检查登录并拉取…</span>
</div>
<template v-else-if="list.length === 0">
<p class="plat-pick__hint">
使用 NewAPI 控制台账号登录后拉取订阅,仅用于预填登记;密码不保存。
</p>
<a-form layout="vertical" @finish="load">
<a-form-item label="账号">
<a-input
v-model:value="username"
autocomplete="username"
@press-enter="load"
/>
</a-form-item>
<a-form-item label="密码">
<a-input-password
v-model:value="password"
autocomplete="current-password"
@press-enter="load"
/>
</a-form-item>
<a-button
type="primary"
html-type="submit"
block
:loading="loading"
:disabled="!username.trim() || !password"
>
登录并拉取
</a-button>
</a-form>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 12px"
/>
</template>
<template v-else>
<div class="plat-pick__list">
<div
v-for="s in list"
:key="s.id"
class="plat-pick__item"
:class="{ 'is-registered': isRegistered(s) }"
@click="onUse(s)"
>
<div class="plat-pick__head">
<strong>订阅 #{{ s.id }}</strong>
<span class="plat-pick__status">
{{ isRegistered(s) ? "已登记" : s.status }}
</span>
</div>
<div class="plat-pick__meta">
{{ formatDateTime(s.start_time) }} ~
{{ s.end_time ? formatDateTime(s.end_time) : "进行中" }}
· 额度 {{ s.amount_total }}
</div>
</div>
</div>
</template>
</a-modal>
</template>
<style scoped>
.plat-pick__boot {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.plat-pick__boot-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__hint {
margin: 0 0 16px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__list {
max-height: 360px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.plat-pick__item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.plat-pick__item:hover {
background: #fafafa;
}
.plat-pick__item.is-registered {
opacity: 0.45;
cursor: not-allowed;
}
.plat-pick__item.is-registered:hover {
background: transparent;
}
.plat-pick__item:last-child {
border-bottom: none;
}
.plat-pick__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.plat-pick__status {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.plat-pick__meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,312 @@
<script setup lang="ts">
/**
* 充值选用弹层:登录 + 传统分页;已登记按 upstream_id 灰显
*/
import { computed, ref, watch } from "vue";
import {
listPlatformTopups,
loginPlatform,
type PlatformTopup,
} from "@/api/admin/platform.ts";
import type { ApiError } from "@/api/http.ts";
import { formatDateTime } from "@/utils/time.ts";
const props = defineProps<{
open: boolean;
registeredIds?: number[];
}>();
const emit = defineEmits<{
(e: "update:open", v: boolean): void;
(e: "select", topup: PlatformTopup & { paid_at_iso: string }): void;
}>();
const TOKEN_KEY = "naku_platform_token";
const PAGE_SIZE = 10;
const username = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
/** 打开后检查登录并拉取中——避免闪现登录表单 */
const booting = ref(false);
const items = ref<PlatformTopup[]>([]);
const total = ref(0);
const page = ref(1);
const totalPages = computed(() =>
Math.max(1, Math.ceil(total.value / PAGE_SIZE)),
);
function readToken(): string {
try {
return sessionStorage.getItem(TOKEN_KEY) ?? "";
} catch {
return "";
}
}
function writeToken(token: string) {
try {
if (token) sessionStorage.setItem(TOKEN_KEY, token);
else sessionStorage.removeItem(TOKEN_KEY);
} catch {
/* ignore */
}
}
async function ensureToken(): Promise<string> {
let token = readToken();
if (token) return token;
const login = await loginPlatform({
username: username.value.trim(),
password: password.value,
});
token = login.access_token;
writeToken(token);
password.value = "";
return token;
}
async function loadPage(p: number, opts?: { allowTokenOnly?: boolean }) {
if (loading.value) return;
if (!opts?.allowTokenOnly && !readToken() && (!username.value.trim() || !password.value)) {
return;
}
error.value = "";
loading.value = true;
try {
const token = await ensureToken();
password.value = "";
const data = await listPlatformTopups(token, p, PAGE_SIZE);
items.value = data.items;
total.value = data.total;
page.value = data.page || p;
if (items.value.length === 0 && p === 1) {
error.value = "账号下没有充值记录";
}
} catch (e) {
const err = e as ApiError;
if (err.status === 401 || err.code === "platform_token_invalid") {
writeToken("");
error.value = "平台登录已失效——请重新输入密码";
} else {
error.value = err.message || "拉取失败";
}
items.value = [];
password.value = "";
} finally {
loading.value = false;
}
}
async function onLogin() {
if (!username.value.trim() || !password.value || loading.value) return;
await loadPage(1);
}
function isRegistered(t: PlatformTopup): boolean {
return (props.registeredIds ?? []).includes(t.id);
}
function onUse(t: PlatformTopup) {
if (isRegistered(t)) return;
const sec = t.complete_time ?? t.create_time ?? Math.floor(Date.now() / 1000);
emit("select", {
...t,
paid_at_iso: new Date(sec * 1000).toISOString(),
});
emit("update:open", false);
}
watch(
() => props.open,
(v) => {
if (v) {
if (readToken()) {
booting.value = true;
void loadPage(1, { allowTokenOnly: true }).finally(() => {
booting.value = false;
});
}
} else {
items.value = [];
total.value = 0;
page.value = 1;
error.value = "";
password.value = "";
booting.value = false;
}
},
);
</script>
<template>
<a-modal
:open="open"
title="从平台选择充值"
:footer="null"
:width="560"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
destroy-on-hidden
@update:open="emit('update:open', $event)"
>
<div
v-if="booting || (loading && items.length === 0)"
class="top-pick__boot"
>
<a-spin />
<span class="top-pick__boot-text">正在检查登录并拉取…</span>
</div>
<template v-else-if="items.length === 0 && !loading">
<p class="top-pick__hint">
登录 NewAPI 控制台拉取充值单(传统分页);密码不保存。
</p>
<a-form layout="vertical" @finish="onLogin">
<a-form-item label="账号">
<a-input v-model:value="username" autocomplete="username" />
</a-form-item>
<a-form-item label="密码">
<a-input-password
v-model:value="password"
autocomplete="current-password"
/>
</a-form-item>
<a-button
type="primary"
html-type="submit"
block
:loading="loading"
:disabled="!username.trim() || !password"
>
登录并拉取
</a-button>
</a-form>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 12px"
/>
</template>
<template v-else>
<div class="top-pick__list">
<div
v-for="t in items"
:key="t.id"
class="top-pick__item"
:class="{ 'is-registered': isRegistered(t) }"
@click="onUse(t)"
>
<div class="top-pick__head">
<strong>
{{ formatDateTime(t.complete_time ?? t.create_time) }}
</strong>
<span class="top-pick__status">
{{ isRegistered(t) ? "已登记" : t.status || "" }}
</span>
</div>
<div class="top-pick__meta">
amount {{ t.amount }} · money {{ t.money }}
<template v-if="t.trade_no"> · {{ t.trade_no }}</template>
</div>
</div>
</div>
<div class="top-pick__pager">
<a-pagination
:current="page"
:page-size="PAGE_SIZE"
:total="total"
size="small"
:show-total="(t: number) => `共 ${t} 条`"
@change="(p: number) => loadPage(p)"
/>
</div>
<a-alert
v-if="error"
type="warning"
show-icon
:message="error"
style="margin-top: 8px"
/>
</template>
</a-modal>
</template>
<style scoped>
.top-pick__boot {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 0;
}
.top-pick__boot-text {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__hint {
margin: 0 0 16px;
font-size: 12px;
line-height: 20px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__list {
max-height: 360px;
overflow-y: auto;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.top-pick__item {
padding: 12px 16px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
}
.top-pick__item:hover {
background: #fafafa;
}
.top-pick__item.is-registered {
opacity: 0.45;
cursor: not-allowed;
}
.top-pick__item.is-registered:hover {
background: transparent;
}
.top-pick__item:last-child {
border-bottom: none;
}
.top-pick__head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 4px;
}
.top-pick__status {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.top-pick__meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
font-variant-numeric: tabular-nums;
}
.top-pick__pager {
display: flex;
justify-content: center;
margin-top: 12px;
}
</style>
@@ -5,7 +5,7 @@
import { computed, reactive, ref, watch } from "vue";
import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { useCreateMember, useRegisterKey } from "@/queries/admin.ts";
import { useCreateMember, useRegisterKey, useBillingDomainOptions } from "@/queries/admin.ts";
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
const props = defineProps<{
@@ -20,10 +20,12 @@ const emit = defineEmits<{
const register = useRegisterKey();
const createMember = useCreateMember();
const domainOptions = useBillingDomainOptions();
const form = reactive({
sk_key: "",
member_id: null as number | "unassigned" | null,
billing_domain: "",
});
const resultMsg = ref<{ type: "success" | "error" | "warning"; text: string } | null>(
null,
@@ -41,6 +43,7 @@ watch(
if (!v) {
form.sk_key = "";
form.member_id = null;
form.billing_domain = "";
resultMsg.value = null;
showNewMember.value = false;
newMemberName.value = "";
@@ -67,6 +70,7 @@ async function onSubmit() {
form.member_id === "unassigned" || form.member_id == null
? null
: form.member_id,
billing_domain: form.billing_domain.trim() || null,
});
resultMsg.value = {
type: "success",
@@ -170,6 +174,13 @@ async function onCreateMember() {
+ 新建成员
</a-button>
</a-form-item>
<a-form-item label="账本域">
<a-auto-complete
v-model:value="form.billing_domain"
:options="domainOptions"
allow-clear
/>
</a-form-item>
<a-form-item v-if="showNewMember" label="新成员名称">
<a-input
v-model:value="newMemberName"
@@ -0,0 +1,74 @@
<script setup lang="ts">
/**
* 调用日志汇总条(交互设计-调用日志 §2.5):
* 单行低高度——请求次数 + 消费用量;用量恒 type=2 口径由上层保证。
*/
import QuotaText from "./quota-text.vue";
defineProps<{
hitCount: number;
usageCount: number;
errorCount: number;
quota: number;
quotaUsd: string;
loading?: boolean;
}>();
</script>
<template>
<div class="logs-summary-bar" :class="{ 'is-loading': loading }">
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">请求次数</span>
<span class="logs-summary-bar__value">
<template v-if="loading">—</template>
<template v-else>
{{ hitCount }} 条<template v-if="errorCount > 0">
(消费 {{ usageCount }} / 错误 {{ errorCount }})</template>
</template>
</span>
</div>
<div class="logs-summary-bar__seg">
<span class="logs-summary-bar__label">消费用量</span>
<span class="logs-summary-bar__value">
<QuotaText v-if="!loading" :quota="quota" :quota-usd="quotaUsd" />
<template v-else>—</template>
</span>
</div>
</div>
</template>
<style scoped>
.logs-summary-bar {
display: flex;
align-items: center;
gap: 24px;
min-height: 36px;
padding: 0 12px;
margin-bottom: 12px;
border: 1px solid #e8ecf4;
border-radius: 6px;
background: #f7f9fc;
}
.logs-summary-bar.is-loading {
opacity: 0.55;
}
.logs-summary-bar__seg {
display: inline-flex;
align-items: baseline;
gap: 8px;
white-space: nowrap;
}
.logs-summary-bar__label {
font-size: 12px;
color: #8a8f99;
}
.logs-summary-bar__value {
font-size: 13px;
font-weight: 500;
color: #1f2329;
}
</style>
@@ -0,0 +1,33 @@
<script setup lang="ts">
/**
* 表格工具行刷新钮(纯图标 + tooltip)——放在筛选行下一行、右对齐
*/
import { ReloadOutlined } from "@antdv-next/icons";
defineProps<{ loading?: boolean }>();
const emit = defineEmits<{ (e: "refresh"): void }>();
</script>
<template>
<div class="table-refresh">
<a-tooltip title="刷新">
<a-button
:loading="loading"
aria-label="刷新"
@click="emit('refresh')"
>
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-tooltip>
</div>
</template>
<style scoped>
.table-refresh {
display: flex;
justify-content: flex-end;
margin-bottom: 8px;
}
</style>
@@ -2,7 +2,12 @@
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
import { computed } from "vue";
import { recentDaysRange, type RangeValue } from "@/utils/time.ts";
import {
recentDaysRange,
todayRange,
wallAsBeijingIso,
type RangeValue,
} from "@/utils/time.ts";
import type { RangeKind } from "@/composables/use-live-time-range.ts";
const props = defineProps<{
@@ -24,6 +29,10 @@ const pickerValue = computed<[Dayjs, Dayjs] | null>(() => {
const presets = computed(() => {
const builtIn = [
{
label: "今天",
value: rangeToDayjs(todayRange()),
},
{
label: "最近 7 天",
value: rangeToDayjs(recentDaysRange(7)),
@@ -47,6 +56,13 @@ function rangeToDayjs(range: RangeValue): [Dayjs, Dayjs] {
function detectKind(value: RangeValue): RangeKind {
const fromD = dayjs(value.from).format("YYYY-MM-DD");
const toD = dayjs(value.to).format("YYYY-MM-DD");
const today = todayRange();
if (
dayjs(today.from).format("YYYY-MM-DD") === fromD &&
dayjs(today.to).format("YYYY-MM-DD") === toD
) {
return "today";
}
for (const days of [7, 30] as const) {
const r = recentDaysRange(days);
if (
@@ -62,8 +78,8 @@ function detectKind(value: RangeValue): RangeKind {
function onChange(dates: Dayjs[] | null) {
if (!dates || dates.length !== 2 || !dates[0] || !dates[1]) return;
const value = {
from: dates[0].toISOString(),
to: dates[1].toISOString(),
from: wallAsBeijingIso(dates[0]),
to: wallAsBeijingIso(dates[1]),
};
emit("update:modelValue", value);
emit("change", value, detectKind(value));
@@ -74,8 +90,9 @@ function onChange(dates: Dayjs[] | null) {
<a-range-picker
:value="pickerValue"
:presets="presets"
format="YYYY-MM-DD"
:placeholder="['开始日期', '结束日期']"
format="YYYY-MM-DD HH:mm:ss"
:placeholder="['开始时间', '结束时间']"
show-time
@change="onChange"
/>
</template>
@@ -1,12 +1,12 @@
import { computed, onUnmounted, ref } from "vue";
import { recentDaysRange } from "@/utils/time.ts";
import { recentDaysRange, todayRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string };
export type RangeKind = "recent-7" | "recent-30" | "fixed";
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
/**
* 可跟随「今天」推进的时间范围:
* - 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
* - 今天 / 最近 7/30 天:定时重算窗口(页面挂过夜也不会陈旧)
* - fixed:自定义/订阅期间等绝对区间,窗口不挪
*/
export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-30") {
@@ -21,6 +21,7 @@ export function useLiveTimeRange(initial: Exclude<RangeKind, "fixed"> = "recent-
const range = computed<RangeValue>(() => {
void nowTick.value;
if (kind.value === "today") return todayRange();
if (kind.value === "recent-7") return recentDaysRange(7);
if (kind.value === "recent-30") return recentDaysRange(30);
return fixed.value;
@@ -0,0 +1,64 @@
import { computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
useMemberRangeStore,
type RangeKind,
type RangeValue,
} from "@/stores/member-range.ts";
/**
* 成员端共享时间范围 + 当前页 URL(from/to)双向同步。
* 切页时布局菜单带上 from/to,状态从 store 读——对账窗口不打断。
*/
export function useMemberRange() {
const store = useMemberRangeStore();
const route = useRoute();
const router = useRouter();
const range = computed(() => store.range);
function syncFromRoute() {
const from = route.query.from;
const to = route.query.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
store.setRange({ from, to }, "fixed");
}
}
/** 在当前路径上写入 from/to(可并入页内其它 query) */
function pushRangeQuery(extra: Record<string, string> = {}) {
void router.replace({
path: route.path,
query: {
...pickNonRangeQuery(route.query),
...extra,
from: store.range.from,
to: store.range.to,
},
});
}
function onRangeChange(v: RangeValue, k: RangeKind) {
store.setRange(v, k);
pushRangeQuery();
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
return {
range,
setRange: store.setRange,
onRangeChange,
pushRangeQuery,
};
}
function pickNonRangeQuery(q: Record<string, unknown>): Record<string, string> {
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(q)) {
if (k === "from" || k === "to") continue;
if (typeof v === "string" && v) out[k] = v;
}
return out;
}
+4 -2
View File
@@ -6,6 +6,7 @@ import {
AuditOutlined,
DashboardOutlined,
DownOutlined,
FileTextOutlined,
KeyOutlined,
LogoutOutlined,
MenuFoldOutlined,
@@ -42,9 +43,10 @@ const username = computed(
/** 收起时仅剩图标——每项必须带 icon(交互共性侧边栏六项) */
const items: MenuItemType[] = [
{ key: "/admin/overview", icon: DashboardOutlined, label: "总览" },
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
{ key: "/admin/keys", icon: KeyOutlined, label: "Key 管理" },
{ key: "/admin/payments", icon: AccountBookOutlined, label: "支付记录" },
{ key: "/admin/members", icon: TeamOutlined, label: "成员管理" },
{ key: "/admin/logs", icon: FileTextOutlined, label: "调用日志" },
{ key: "/admin/audit", icon: AuditOutlined, label: "审计日志" },
{ key: "/admin/settings", icon: SettingOutlined, label: "账号设置" },
];
@@ -119,7 +121,7 @@ async function onUserMenuClick({ key }: MenuInfo) {
<a-dropdown
:menu="{ items: userMenuItems }"
placement="bottomRight"
trigger="click"
:trigger="['click']"
@menu-click="onUserMenuClick"
>
<a class="admin-layout__account">
+246 -44
View File
@@ -1,71 +1,273 @@
<script setup lang="ts">
import { computed } from "vue";
import type { MenuInfo, MenuItemType } from "antdv-next";
import { theme } from "antdv-next";
import {
AccountBookOutlined,
DashboardOutlined,
DownOutlined,
FileTextOutlined,
LogoutOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
UserOutlined,
} from "@antdv-next/icons";
import { computed, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useMemberTokenStore } from "@/stores/member-token.ts";
import { useMemberRangeStore } from "@/stores/member-range.ts";
import { useMemberMe } from "@/queries/member.ts";
const { token } = theme.useToken();
const route = useRoute();
const router = useRouter();
const tokenStore = useMemberTokenStore();
const rangeStore = useMemberRangeStore();
const meQuery = useMemberMe();
const collapsed = ref(false);
const brandTitle = "NAKU";
const brandDescription = "NewAPI 用量统计";
const memberName = computed(
() => tokenStore.memberName || meQuery.data.value?.name || "",
);
const items: MenuItemType[] = [
{ key: "/member", icon: DashboardOutlined, label: "总览" },
{ key: "/member/logs", icon: FileTextOutlined, label: "调用日志" },
{ key: "/member/payments", icon: AccountBookOutlined, label: "支付参数" },
];
const selectedKeys = ref<string[]>([route.path === "/member" ? "/member" : route.path]);
watch(
() => route.path,
(path) => {
selectedKeys.value = [path === "/member" ? "/member" : path];
},
);
function onMenuClick({ key }: MenuInfo) {
// 切页保留对账窗口(from/to),不带页内筛选
void router.push({
path: String(key),
query: {
from: rangeStore.range.from,
to: rangeStore.range.to,
},
});
}
const userMenuItems: MenuItemType[] = [
{ key: "logout", icon: LogoutOutlined, label: "退出", danger: true },
];
function onUserMenuClick({ key }: MenuInfo) {
if (key === "logout") {
tokenStore.clearToken();
void router.replace({ name: "member-login" });
}
}
</script>
<template>
<div class="member-layout">
<header class="member-layout__header">
<div class="member-layout__brand">成员入口</div>
<div class="member-layout__name">{{ memberName }}</div>
</header>
<main class="member-layout__main">
<div class="member-layout__surface">
<router-view />
<a-layout class="member-layout">
<a-layout-sider
v-model:collapsed="collapsed"
collapsible
:width="220"
:collapsed-width="80"
>
<template #trigger>
<MenuUnfoldOutlined v-if="collapsed" />
<MenuFoldOutlined v-else />
</template>
<div
class="member-layout__brand"
:class="{ 'is-collapsed': collapsed }"
:title="collapsed ? `${brandTitle} · ${brandDescription}` : undefined"
>
<span class="member-layout__mark" aria-hidden="true">N</span>
<div v-if="!collapsed" class="member-layout__brand-text">
<div class="member-layout__brand-title">{{ brandTitle }}</div>
<div class="member-layout__brand-desc">{{ brandDescription }}</div>
</div>
</div>
</main>
</div>
<a-menu
theme="dark"
mode="inline"
:inline-collapsed="collapsed"
:selected-keys="selectedKeys"
:items="items"
@click="onMenuClick"
/>
</a-layout-sider>
<a-layout class="member-layout__main">
<a-layout-header class="member-layout__header">
<a-dropdown
:menu="{ items: userMenuItems }"
placement="bottomRight"
:trigger="['click']"
@menu-click="onUserMenuClick"
>
<a class="member-layout__account">
<UserOutlined class="member-layout__account-icon" />
<span v-if="memberName" class="member-layout__username">{{ memberName }}</span>
<span v-else class="member-layout__username is-muted">…</span>
<DownOutlined class="member-layout__account-caret" />
</a>
</a-dropdown>
</a-layout-header>
<a-layout-content class="member-layout__content">
<div
class="member-layout__surface"
:style="{
background: token.colorBgContainer,
borderRadius: `${token.borderRadiusLG}px`,
}"
>
<router-view />
</div>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<style scoped>
/* design.md:bg-layout 底 + bg-container 内容面;间距 24/16 对齐 spacing lg/md */
.member-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f5f5;
}
.member-layout__header {
height: 64px;
padding: 0 24px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.member-layout__brand {
font-weight: 600;
font-size: 16px;
line-height: 24px;
}
.member-layout__name {
color: rgba(0, 0, 0, 0.65);
font-size: 14px;
height: 100vh;
overflow: hidden;
}
.member-layout__main {
flex: 1;
width: 100%;
max-width: 960px;
margin: 0 auto;
padding: 24px 16px 48px;
height: 100vh;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.member-layout__brand {
height: 64px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 24px;
overflow: hidden;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-sizing: border-box;
}
.member-layout__brand.is-collapsed {
justify-content: center;
padding: 0;
gap: 0;
}
.member-layout__mark {
flex: 0 0 28px;
width: 28px;
height: 28px;
border-radius: 6px;
background: #1677ff;
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 28px;
text-align: center;
user-select: none;
}
.member-layout__brand-text {
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0;
}
.member-layout__brand-title {
color: rgba(255, 255, 255, 0.92);
font-size: 14px;
font-weight: 600;
line-height: 22px;
letter-spacing: 0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-layout__brand-desc {
color: rgba(255, 255, 255, 0.45);
font-size: 12px;
font-weight: 400;
line-height: 20px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.member-layout__header {
flex: 0 0 64px;
display: flex;
align-items: center;
justify-content: flex-end;
background: #fff;
padding: 0 24px;
height: 64px;
line-height: 64px;
border-bottom: 1px solid #f0f0f0;
}
.member-layout__account {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 0 8px;
height: 40px;
color: rgba(0, 0, 0, 0.88);
cursor: pointer;
border-radius: 6px;
transition: background-color 0.2s;
}
.member-layout__account:hover {
background: rgba(0, 0, 0, 0.06);
}
.member-layout__account-icon {
font-size: 14px;
color: rgba(0, 0, 0, 0.65);
}
.member-layout__account-caret {
font-size: 10px;
color: rgba(0, 0, 0, 0.45);
}
.member-layout__username {
color: rgba(0, 0, 0, 0.88);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.member-layout__username.is-muted {
color: rgba(0, 0, 0, 0.45);
}
.member-layout__content {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: #f5f5f5;
padding: 24px;
}
.member-layout__surface {
background: #fff;
border-radius: 8px;
padding: 24px;
min-height: 100%;
}
</style>
+124 -23
View File
@@ -1,3 +1,4 @@
import { computed, type ComputedRef } from "vue";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import {
fetchKeys,
@@ -6,15 +7,24 @@ import {
type KeyListFilters,
} from "@/api/admin/keys.ts";
import { fetchOverview } from "@/api/admin/overview.ts";
import { fetchEngine } from "@/api/admin/engine.ts";
import { fetchEngine, fetchQpuLatest } from "@/api/admin/engine.ts";
import {
createPayment,
deletePayment,
fetchPayments,
updatePayment,
} from "@/api/admin/payments.ts";
createSubscription,
deleteSubscription,
fetchSubscriptions,
updateSubscription,
type SubscriptionBody,
} from "@/api/admin/subscriptions.ts";
import {
createTopup,
deleteTopup,
fetchTopups,
updateTopup,
type TopupBody,
} from "@/api/admin/topups.ts";
import {
createMember,
deleteMember,
fetchMembers,
issueMemberToken,
patchMember,
@@ -40,6 +50,13 @@ export function useEngine() {
});
}
export function useQpuLatest() {
return useQuery({
queryKey: qk.admin.qpuLatest(),
queryFn: fetchQpuLatest,
});
}
export function useKeys(filters: () => KeyListFilters) {
return useQuery({
queryKey: () => qk.admin.keys(filters()),
@@ -68,7 +85,11 @@ export function usePatchKey() {
body,
}: {
id: number;
body: { status?: "active" | "disabled"; member_id?: number | null };
body: {
status?: "active" | "disabled";
member_id?: number | null;
billing_domain?: string | null;
};
}) => patchKey(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
@@ -78,47 +99,89 @@ export function usePatchKey() {
});
}
export function useAdminLogs(params: () => TimeRange & PageQuery) {
export function useAdminLogs(
params: () => Parameters<typeof fetchLogs>[0],
) {
return useQuery({
queryKey: () => qk.admin.logs(params()),
queryFn: () => fetchLogs(params()),
});
}
export function usePayments() {
export function useSubscriptions() {
return useQuery({
queryKey: qk.admin.payments(),
queryFn: fetchPayments,
queryKey: qk.admin.subscriptions(),
queryFn: () => fetchSubscriptions(),
staleTime: 0,
});
}
export function useCreatePayment() {
export function useCreateSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: createPayment,
mutationFn: (body: SubscriptionBody) => createSubscription(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useUpdatePayment() {
export function useUpdateSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: Record<string, unknown> }) =>
updatePayment(id, body),
mutationFn: ({ id, body }: { id: number; body: SubscriptionBody }) =>
updateSubscription(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useDeletePayment() {
export function useDeleteSubscription() {
const qc = useQueryClient();
return useMutation({
mutationFn: deletePayment,
mutationFn: deleteSubscription,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.payments() });
void qc.invalidateQueries({ queryKey: qk.admin.subscriptions() });
},
});
}
export function useTopups() {
return useQuery({
queryKey: qk.admin.topups(),
queryFn: () => fetchTopups(),
staleTime: 0,
});
}
export function useCreateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: TopupBody) => createTopup(body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useUpdateTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, body }: { id: number; body: TopupBody }) =>
updateTopup(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
export function useDeleteTopup() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteTopup,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.topups() });
},
});
}
@@ -150,10 +213,26 @@ export function usePatchMember() {
body,
}: {
id: number;
body: { status: "active" | "disabled" };
body: {
name?: string;
remark?: string;
status?: "active" | "disabled";
};
}) => patchMember(id, body),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
export function useDeleteMember() {
const qc = useQueryClient();
return useMutation({
mutationFn: deleteMember,
onSuccess: () => {
void qc.invalidateQueries({ queryKey: qk.admin.members() });
void qc.invalidateQueries({ queryKey: ["admin", "keys"] });
},
});
}
@@ -169,10 +248,32 @@ export function useIssueMemberToken() {
}
export function useAuditLogs(
params: () => PageQuery & { domain?: string },
params: () => PageQuery & { domain?: string; from?: string; to?: string },
) {
return useQuery({
queryKey: () => qk.admin.auditLogs(params()),
queryFn: () => fetchAuditLogs(params()),
});
}
/** 账本域联想:keys ∪ 订阅 ∪ 充值 已出现过的值(不强制) */
export function useBillingDomainOptions(): ComputedRef<
Array<{ value: string }>
> {
const keysQuery = useKeys(() => ({}));
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
return computed(() => {
const set = new Set<string>();
for (const k of keysQuery.data.value ?? []) {
if (k.billing_domain) set.add(k.billing_domain);
}
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((value) => ({ value }));
});
}
+13 -3
View File
@@ -3,8 +3,9 @@ import {
fetchLogs,
fetchMe,
fetchOverview,
type MemberLogQuery,
} from "@/api/member/me.ts";
import { qk, type PageQuery, type TimeRange } from "@/api/query-keys.ts";
import { qk, type TimeRange } from "@/api/query-keys.ts";
export function useMemberMe() {
return useQuery({
@@ -14,14 +15,23 @@ export function useMemberMe() {
});
}
export function useMemberOverview(range: () => TimeRange) {
/**
* 总览聚合。仅「总览」传 `refetchInterval`(看板心智);日志/支付不轮询。
*/
export function useMemberOverview(
range: () => TimeRange,
opts?: { refetchInterval?: number },
) {
return useQuery({
queryKey: () => qk.member.overview(range()),
queryFn: () => fetchOverview(range()),
...(opts?.refetchInterval != null
? { refetchInterval: opts.refetchInterval }
: {}),
});
}
export function useMemberLogs(params: () => TimeRange & PageQuery) {
export function useMemberLogs(params: () => MemberLogQuery) {
return useQuery({
queryKey: () => qk.member.logs(params()),
queryFn: () => fetchLogs(params()),
+5
View File
@@ -26,6 +26,11 @@ export const adminRoutes: RouteRecordRaw[] = [
name: "admin-overview",
component: () => import("@/views/admin/overview-view.vue"),
},
{
path: "logs",
name: "admin-logs",
component: () => import("@/views/admin/logs-view.vue"),
},
{
path: "keys",
name: "admin-keys",
+2 -2
View File
@@ -17,10 +17,10 @@ declare module "vue-router" {
export const router: Router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/admin/overview" },
{ path: "/", redirect: "/member" },
...adminRoutes,
...memberRoutes,
{ path: "/:pathMatch(.*)*", redirect: "/admin/overview" },
{ path: "/:pathMatch(.*)*", redirect: "/member" },
],
});
+13 -3
View File
@@ -1,7 +1,7 @@
import type { RouteRecordRaw } from "vue-router";
import MemberLayout from "@/layouts/member-layout.vue";
/** 成员视图 `/member/*`:登录壳 → 单页(交互共性 §1) */
/** 成员视图 `/member/*`:登录壳 → 侧边栏三页(与管理端同构壳) */
export const memberRoutes: RouteRecordRaw[] = [
{
path: "/member/login",
@@ -16,8 +16,18 @@ export const memberRoutes: RouteRecordRaw[] = [
children: [
{
path: "",
name: "member-home",
component: () => import("@/views/member/home-view.vue"),
name: "member-overview",
component: () => import("@/views/member/overview-view.vue"),
},
{
path: "logs",
name: "member-logs",
component: () => import("@/views/member/logs-view.vue"),
},
{
path: "payments",
name: "member-payments",
component: () => import("@/views/member/payments-view.vue"),
},
],
},
+29
View File
@@ -0,0 +1,29 @@
import { defineStore } from "pinia";
import { computed, ref } from "vue";
import { recentDaysRange } from "@/utils/time.ts";
export type RangeValue = { from: string; to: string };
export type RangeKind = "today" | "recent-7" | "recent-30" | "fixed";
/**
* 成员端全局时间范围——总览 / 调用日志 / 支付参数共用。
* 相对窗口在 setRange 时定格,避免每分钟改 key 打穿缓存;
* 「看板自动刷新」只属于总览页(refetchInterval),其余页按需请求。
*/
export const useMemberRangeStore = defineStore("member-range", () => {
const kind = ref<RangeKind>("recent-30");
const windowRange = ref<RangeValue>(recentDaysRange(30));
const fixed = ref<RangeValue>(recentDaysRange(30));
const range = computed<RangeValue>(() =>
kind.value === "fixed" ? fixed.value : windowRange.value,
);
function setRange(value: RangeValue, nextKind: RangeKind) {
kind.value = nextKind;
if (nextKind === "fixed") fixed.value = value;
else windowRange.value = value;
}
return { kind, range, setRange };
});
+46 -6
View File
@@ -10,6 +10,38 @@ dayjs.extend(timezone);
/** 北京时区展示(固定 +08:00,不随主机时区变) */
const DISPLAY_TZ = "Asia/Shanghai";
/** 请求时间序列化:ISO 8601 带偏移(契约 §4;墙钟按北京时间) */
const REQUEST_OFFSET = "+08:00";
/**
* 绝对时刻(Date / Unix / 带时区的 ISO)→ 北京墙钟 + `+08:00`。
* 先转 Asia/Shanghai,与主机时区无关。
*/
export function toRequestIso(value: Date | string | number | dayjs.Dayjs): string {
return shanghaiIso(dayjs(value as never));
}
/**
* 表单/日历墙钟(用户当作北京时间)→ `+08:00`。
* 不做时区换算,只给墙钟贴偏移。
*/
export function wallAsBeijingIso(value: Date | dayjs.Dayjs): string {
const wall = dayjs(value as never).format("YYYY-MM-DDTHH:mm:ss.SSS");
return `${wall}${REQUEST_OFFSET}`;
}
/** Unix 秒 → 可放进日历控件的「北京墙钟」dayjs */
export function unixToBeijingWall(unixSec: number): dayjs.Dayjs {
return dayjs(
dayjs.unix(unixSec).tz(DISPLAY_TZ).format("YYYY-MM-DD HH:mm:ss.SSS"),
);
}
/** 已在 Asia/Shanghai 上的时刻 → 带偏移 ISO */
function shanghaiIso(d: dayjs.Dayjs): string {
const wall = d.tz(DISPLAY_TZ).format("YYYY-MM-DDTHH:mm:ss.SSS");
return `${wall}${REQUEST_OFFSET}`;
}
/** Unix 秒 → 北京时间「YYYY-MM-DD HH:mm:ss」 */
export function formatDateTime(unixSec: number | null | undefined): string {
@@ -29,26 +61,34 @@ export function formatTimeOfDay(unixSec: number | null | undefined): string {
return dayjs.unix(unixSec).tz(DISPLAY_TZ).format("HH:mm:ss");
}
/** 北京时间日初 → 请求用 ISO 8601 UTC(契约 §4:from/to 收 ISO) */
/** 北京时间日初 → 请求用 ISO(带 +08:00 偏移) */
export function beijingDayStartToIso(date: string): string {
return dayjs.tz(date, DISPLAY_TZ).startOf("day").toISOString();
return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).startOf("day"));
}
/** 北京时间日界(次日零点,左闭右开的 to)→ ISO UTC */
/** 北京时间日界(次日零点,左闭右开的 to)→ ISO 带偏移 */
export function beijingDayEndToIso(date: string): string {
return dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day").toISOString();
return shanghaiIso(dayjs.tz(date, DISPLAY_TZ).add(1, "day").startOf("day"));
}
export type RangeValue = { from: string; to: string };
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO UTC */
/** 快捷项:最近 N 天(含今日),返回 [from, to) 的 ISO 带偏移 */
export function recentDaysRange(
days: number,
nowMs: number = Date.now(),
): { from: string; to: string } {
const to = dayjs(nowMs).tz(DISPLAY_TZ).add(1, "day").startOf("day");
const from = to.subtract(days, "day");
return { from: from.toISOString(), to: to.toISOString() };
return { from: shanghaiIso(from), to: shanghaiIso(to) };
}
/** 快捷项:今天(北京时间日界,左闭右开) */
export function todayRange(nowMs: number = Date.now()): {
from: string;
to: string;
} {
return recentDaysRange(1, nowMs);
}
/** 统计口径标注:「2026-09-02 ~ 10-01」(北京时间日) */
+161 -5
View File
@@ -1,13 +1,169 @@
<script setup lang="ts">
/**
* 审计日志(交互设计-审计日志):只读时间线 + 域筛选 + 分页
* 表体内部滚动:高度按容器实测,避免内容区被撑出视口
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import { useAuditLogs } from "@/queries/admin.ts";
import { formatDateTime, recentDaysRange } from "@/utils/time.ts";
const domain = ref<string | null>(null);
const page = ref(1);
const pageSize = ref(50);
const range = ref(recentDaysRange(30));
const boxRef = ref<HTMLElement | null>(null);
/** 表头 + 分页大约占掉的高度 */
const CHROME = 96;
const bodyY = ref(480);
const params = computed(() => ({
page: page.value,
page_size: pageSize.value,
domain: domain.value ?? undefined,
from: range.value.from,
to: range.value.to,
}));
const auditQuery = useAuditLogs(() => params.value);
const rows = computed(() => auditQuery.data.value?.items ?? []);
const total = computed(() => auditQuery.data.value?.total ?? 0);
const domainOptions = [
{ value: null, label: "全部域" },
{ value: "key", label: "Key" },
{ value: "payment", label: "支付" },
{ value: "member", label: "成员" },
{ value: "account", label: "账号" },
];
const columns = [
{ title: "时间", key: "created_at", width: 160 },
{ title: "操作者", key: "operator", width: 100 },
{ title: "域", key: "domain", width: 80 },
{ title: "操作", key: "action", width: 140 },
{ title: "摘要", key: "summary", ellipsis: true },
];
watch([domain, range], () => {
page.value = 1;
});
let ro: ResizeObserver | null = null;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
</script>
<template>
<section>
<PageHeader
title="审计日志"
subtitle="管理操作留痕(信息结构见交互设计-审计日志)"
<section class="audit">
<PageHeader title="审计日志" subtitle="管理操作留痕,只读">
<template #actions>
<span class="audit-range-label">时间范围</span>
<TimeRangePicker v-model="range" />
<a-select
v-model:value="domain"
:options="domainOptions"
style="width: 140px"
placeholder="域"
allow-clear
/>
</template>
</PageHeader>
<TableRefresh
:loading="auditQuery.isFetching.value"
@refresh="() => auditQuery.refetch()"
/>
<a-skeleton active :paragraph="{ rows: 4 }" />
<div ref="boxRef" class="audit__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: { id: number }) => r.id"
:loading="auditQuery.isPending.value || auditQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: (p: number, ps: number) => {
page = p;
pageSize = ps;
},
}"
size="middle"
:scroll="{ y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'operator'">
{{ record.operator || "系统" }}
</template>
<template v-else-if="column.key === 'domain'">
<a-tag>{{ record.domain }}</a-tag>
</template>
<template v-else-if="column.key === 'action'">
<span class="audit-action">{{ record.action }}</span>
</template>
<template v-else-if="column.key === 'summary'">
{{ record.summary }}
</template>
</template>
</a-table>
</div>
</section>
</template>
<style scoped>
/*
* 固定壳:视口高 − 顶栏 64 − 内容区 padding 24×2 − 内容面 padding 24×2
* 表体高度由 ResizeObserver 量出,内容区不随表格增高
*/
.audit {
height: calc(100vh - 64px - 48px - 48px);
display: flex;
flex-direction: column;
min-height: 0;
}
.audit__box {
flex: 1;
min-height: 0;
}
.audit-action {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier,
monospace;
font-size: 12px;
color: rgba(0, 0, 0, 0.65);
}
.audit-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
line-height: 32px;
}
</style>
+81 -2
View File
@@ -9,8 +9,9 @@ import { useRoute, useRouter } from "vue-router";
import type { KeyItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import RegisterKeyModal from "@/components/admin/register-key-modal.vue";
import { useEngine, useKeys, useMembers, usePatchKey } from "@/queries/admin.ts";
import { useEngine, useKeys, useMembers, usePatchKey, useBillingDomainOptions } from "@/queries/admin.ts";
import { formatInt } from "@/utils/format.ts";
import { formatMinute, hoursSince } from "@/utils/time.ts";
@@ -22,6 +23,8 @@ const highlightKeyId = ref<number | null>(null);
const filterMember = ref<number | "unassigned" | null>(null);
const filterStatus = ref<"active" | "disabled" | null>(null);
const filterDomain = ref<string | "none" | null>(null);
let domainSaveTimer: ReturnType<typeof setTimeout> | null = null;
const filterRecentFailed = ref(false);
/** URL → 筛选(总览跳入) */
@@ -34,6 +37,10 @@ function syncFromRoute() {
if (q.status === "active" || q.status === "disabled") filterStatus.value = q.status;
else filterStatus.value = null;
if (q.billing_domain === "none") filterDomain.value = "none";
else if (typeof q.billing_domain === "string" && q.billing_domain) {
filterDomain.value = q.billing_domain;
} else filterDomain.value = null;
filterRecentFailed.value = q.recent_failed === "1" || q.recent_failed === "true";
@@ -51,18 +58,21 @@ function pushQuery() {
if (filterMember.value === "unassigned") query.member_id = "unassigned";
else if (filterMember.value != null) query.member_id = String(filterMember.value);
if (filterStatus.value) query.status = filterStatus.value;
if (filterDomain.value === "none") query.billing_domain = "none";
else if (filterDomain.value) query.billing_domain = filterDomain.value;
if (filterRecentFailed.value) query.recent_failed = "1";
if (highlightKeyId.value != null) query.key_id = String(highlightKeyId.value);
void router.replace({ path: "/admin/keys", query });
}
watch([filterMember, filterStatus, filterRecentFailed], () => {
watch([filterMember, filterStatus, filterDomain, filterRecentFailed], () => {
pushQuery();
});
const filters = computed(() => ({
member_id: filterMember.value ?? undefined,
status: filterStatus.value ?? undefined,
billing_domain: filterDomain.value ?? undefined,
recent_failed: filterRecentFailed.value ? true : undefined,
}));
@@ -90,6 +100,7 @@ const hasActiveFilter = computed(
() =>
filterMember.value != null ||
filterStatus.value != null ||
filterDomain.value != null ||
filterRecentFailed.value,
);
@@ -98,6 +109,8 @@ const memberOptions = computed(() => [
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
const domainOptions = useBillingDomainOptions();
function isStalled(logSyncedAt: number | null | undefined): boolean {
const h = hoursSince(logSyncedAt);
return h != null && h > 2;
@@ -121,6 +134,7 @@ function tipTitle(label: string, tip: string) {
const columns = [
{ title: "名称", key: "token_name", width: 160 },
{ title: "归属", key: "member_name", width: 140 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "状态", key: "status", width: 100 },
{
title: tipTitle(
@@ -155,6 +169,36 @@ async function onMemberChange(
}
}
async function onDomainChange(record: KeyItem, value: string) {
const next = value.trim() || null;
if ((record.billing_domain ?? null) === next) return;
if (domainSaveTimer != null) clearTimeout(domainSaveTimer);
const id = record.id;
domainSaveTimer = setTimeout(() => {
domainSaveTimer = null;
void saveDomain(id, next);
}, 400);
}
async function onDomainBlur(record: KeyItem, value: string) {
const next = value.trim() || null;
if (domainSaveTimer != null) {
clearTimeout(domainSaveTimer);
domainSaveTimer = null;
}
if ((record.billing_domain ?? null) === next) return;
await saveDomain(record.id, next);
}
async function saveDomain(id: number, billing_domain: string | null) {
try {
await patchKey.mutateAsync({ id, body: { billing_domain } });
message.success("账本域已更新");
} catch (e) {
message.error((e as ApiError).message || "账本域更新失败");
}
}
async function onToggleStatus(record: KeyItem) {
const next = record.status === "active" ? "disabled" : "active";
try {
@@ -226,10 +270,26 @@ function onRowClass(record: KeyItem) {
placeholder="状态"
allow-clear
/>
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 140px"
placeholder="账本域"
allow-clear
/>
<a-checkbox v-model:checked="filterRecentFailed">最近轮次失败</a-checkbox>
</a-space>
</div>
<TableRefresh
:loading="keysQuery.isFetching.value"
@refresh="() => keysQuery.refetch()"
/>
<a-empty
v-if="!keysQuery.isPending.value && rows.length === 0"
:description="
@@ -267,6 +327,17 @@ function onRowClass(record: KeyItem) {
@change="(v: number | 'unassigned') => onMemberChange(record, v)"
/>
</template>
<template v-else-if="column.key === 'billing_domain'">
<a-auto-complete
:value="record.billing_domain ?? ''"
:options="domainOptions"
size="small"
style="width: 120px"
allow-clear
@change="(v: string) => onDomainChange(record, v)"
@blur="(e: FocusEvent) => onDomainBlur(record, (e.target as HTMLInputElement)?.value ?? '')"
/>
</template>
<template v-else-if="column.key === 'status'">
<a-badge
:status="record.status === 'active' ? 'success' : 'default'"
@@ -297,6 +368,13 @@ function onRowClass(record: KeyItem) {
{{ formatMinute(record.registered_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
size="small"
@click="() => router.push({ path: '/admin/logs', query: { api_key_id: String(record.id) } })"
>
查调用
</a-button>
<a-popconfirm
v-if="record.status === 'active'"
title="停用后移出轮询清单(数据保留)"
@@ -310,6 +388,7 @@ function onRowClass(record: KeyItem) {
<a-button v-else size="small" @click="() => onToggleStatus(record)">
启用
</a-button>
</a-space>
</template>
</template>
</a-table>
+617
View File
@@ -0,0 +1,617 @@
<script setup lang="ts">
/**
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
import {
useLiveTimeRange,
type RangeKind,
} from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers, useSubscriptions } from "@/queries/admin.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useLiveTimeRange("recent-30");
const memberId = ref<number | "unassigned" | null>(null);
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
const page = ref(1);
const pageSize = ref(50);
const membersQuery = useMembers();
const keysQuery = useKeys(() => ({}));
const keyById = computed(() => {
const m = new Map<number, { token_name: string; member_id: number | null; member_name: string | null }>();
for (const k of keysQuery.data.value ?? []) {
m.set(k.id, {
token_name: k.token_name,
member_id: k.member_id,
member_name: k.member_name,
});
}
return m;
});
const params = computed(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
member_id: memberId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
}));
const logsQuery = useAdminLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0);
const summary = computed(() => logsQuery.data.value?.summary);
const modelOptions = computed(() => [
{ value: null, label: "全部模型" },
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
]);
const memberOptions = computed(() => [
{ value: null, label: "全部成员" },
{ value: "unassigned", label: "未归属" },
...(membersQuery.data.value ?? []).map((m) => ({ value: m.id, label: m.name })),
]);
const keyOptions = computed(() => [
{ value: null, label: "全部 Key" },
...(keysQuery.data.value ?? []).map((k) => ({
value: k.id,
label: k.token_name,
})),
]);
const columns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", width: 120 },
{ title: "归属", key: "member_name", width: 110 },
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 110 },
{ title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 110 },
{ title: "用量", key: "usage", width: 140 },
{ title: "Tokens", key: "tokens", width: 110 },
{ title: "操作", key: "actions", width: 72 },
];
const detailOpen = ref(false);
const detailRecord = ref<LogItem | null>(null);
function openDetail(record: LogItem) {
detailRecord.value = record;
detailOpen.value = true;
}
function formatRaw(record: LogItem | null): string {
if (!record) return "";
return JSON.stringify(record.raw ?? record, null, 2);
}
function syncFromRoute() {
const q = route.query;
const m = q.member_id;
if (m === "unassigned") memberId.value = "unassigned";
else if (typeof m === "string" && m) memberId.value = Number(m);
const k = q.api_key_id ?? q.key_id;
if (typeof k === "string" && k) apiKeyId.value = Number(k);
modelFilter.value =
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
if (typeof q.billing_source === "string" && q.billing_source) {
billingSource.value = q.billing_source;
}
const s = q.subscription_id;
if (typeof s === "string" && s) subscriptionId.value = Number(s);
const t = q.type;
if (t === "2") typeFilter.value = 2;
else if (t === "error") typeFilter.value = "error";
else typeFilter.value = null;
const p = q.page;
if (typeof p === "string" && p) page.value = Number(p) || 1;
const from = q.from;
const to = q.to;
if (typeof from === "string" && from && typeof to === "string" && to) {
setRange({ from, to }, "fixed");
}
}
function pushQuery() {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (memberId.value === "unassigned") query.member_id = "unassigned";
else if (memberId.value != null) query.member_id = String(memberId.value);
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (modelFilter.value) query.model_name = modelFilter.value;
if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
}
if (typeFilter.value) query.type = String(typeFilter.value);
if (page.value > 1) query.page = String(page.value);
void router.replace({ path: "/admin/logs", query });
}
syncFromRoute();
watch(
() => route.query,
() => syncFromRoute(),
);
watch(
[memberId, apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter],
() => {
page.value = 1;
pushQuery();
},
);
watch(page, () => pushQuery());
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
color: "blue" as const,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", color: "gold" as const, kind: "wallet" as const, id: null };
}
return { text: "未关联", color: undefined, kind: "none" as const, id: null };
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const subsQuery = useSubscriptions();
const paymentBySub = computed(() => {
const m = new Map<number, SubscriptionItem>();
for (const p of subsQuery.data.value ?? []) {
m.set(p.subscription_id, p);
}
return m;
});
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
function onBadge(record: LogItem, e: MouseEvent) {
e.stopPropagation();
if (sourceBadge(record).kind !== "sub") return;
payModalSubId.value = sourceBadge(record).id;
payModalOpen.value = true;
}
function periodText(p: SubscriptionItem): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
function goPayments() {
if (payModalSubId.value == null) return;
payModalOpen.value = false;
void router.push({
path: "/admin/payments",
query: { tab: "subs", subscription_id: String(payModalSubId.value) },
});
}
function goKey(keyId: number) {
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
}
function goMember(memberId: number | null) {
if (memberId == null) return;
void router.push({
path: "/admin/members",
query: { member_id: String(memberId) },
});
}
let ro: ResizeObserver | null = null;
function onRangeChange(
v: { from: string; to: string },
k: RangeKind,
) {
setRange(v, k);
page.value = 1;
pushQuery();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
}
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
onMounted(() => {
measure();
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
</script>
<template>
<section class="call-logs">
<PageHeader title="调用日志" subtitle="全池模型请求查询与筛选">
<template #actions>
<span class="call-logs__label">时间范围</span>
<TimeRangePicker
:model-value="range"
@change="onRangeChange"
/>
</template>
</PageHeader>
<div class="call-logs__filters">
<a-select
v-model:value="memberId"
:options="memberOptions"
style="width: 140px"
allow-clear
placeholder="成员"
/>
<a-select
v-model:value="apiKeyId"
:options="keyOptions"
style="width: 160px"
allow-clear
placeholder="Key"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
{ value: null, label: '全部来源' },
{ value: 'subscription', label: '订阅' },
{ value: 'wallet', label: '充值' },
{ value: 'none', label: '未关联' },
]"
style="width: 120px"
allow-clear
placeholder="来源"
/>
<a-input-number
v-model:value="subscriptionId"
:min="1"
:precision="0"
style="width: 120px"
placeholder="订阅 ID"
/>
<a-select
v-model:value="typeFilter"
:options="[
{ value: null, label: '全部类型' },
{ value: 2, label: '消费' },
{ value: 'error', label: '错误' },
]"
style="width: 120px"
placeholder="类型"
/>
</div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
/>
<a-empty
v-if="!logsQuery.isPending.value && rows.length === 0"
description="该时段内无调用日志"
/>
<div v-else ref="boxRef" class="call-logs__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: onPageChange,
}"
size="middle"
:scroll="{ y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'token_name'">
<a @click="goKey(record.api_key_id)">{{ record.token_name }}</a>
</template>
<template v-else-if="column.key === 'member_name'">
<a
v-if="keyById.get(record.api_key_id)?.member_id != null"
@click="goMember(keyById.get(record.api_key_id)!.member_id)"
>
{{ keyById.get(record.api_key_id)?.member_name ?? "成员" }}
</a>
<span v-else>未归属</span>
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
{{ record.type === 2 ? "消费" : "错误" }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="call-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else :color="sourceBadge(record).color">
{{ sourceBadge(record).text }}
</a-tag>
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'tokens'">
{{ formatInt(record.prompt_tokens) }} / {{ formatInt(record.completion_tokens) }}
</template>
<template v-else-if="column.key === 'actions'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="detailOpen"
title="调用详情"
:footer="null"
width="720"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
>
<div v-if="detailRecord" class="call-logs__detail">
<div class="call-logs__kv">
<span class="call-logs__note">时间</span>
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">Key / 归属</span>
<span>
{{ detailRecord.token_name }}
·
{{ keyById.get(detailRecord.api_key_id)?.member_name ?? "未归属" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">模型 / 类型</span>
<span>
{{ detailRecord.model_name }}
·
{{ detailRecord.type === 2 ? "消费" : "错误" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">用量</span>
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
</div>
<div class="call-logs__kv">
<span class="call-logs__note">Tokens</span>
<span class="call-logs__num">
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">耗时</span>
<span class="call-logs__num">
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">来源</span>
<a-tag :color="sourceBadge(detailRecord).color">
{{ sourceBadge(detailRecord).text }}
</a-tag>
</div>
<div v-if="detailRecord.content" class="call-logs__kv">
<span class="call-logs__note">内容</span>
<span class="call-logs__content">{{ detailRecord.content }}</span>
</div>
<div class="call-logs__raw">
<div class="call-logs__note">原始数据</div>
<pre class="call-logs__json">{{ formatRaw(detailRecord) }}</pre>
</div>
</div>
</a-modal>
<a-modal
v-model:open="payModalOpen"
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
:footer="null"
width="480"
centered
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
>
<div v-if="payModalPayment" class="call-logs__pay">
<div class="call-logs__kv">
<span class="call-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="call-logs__kv">
<span class="call-logs__note">金额</span>
<span class="call-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="call-logs__kv">
<span class="call-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
</div>
<div v-else class="call-logs__note">该订阅无登记信息</div>
<div class="call-logs__foot">
<a @click="goPayments">在支付记录中查看</a>
</div>
</a-modal>
</section>
</template>
<style scoped>
.call-logs {
height: calc(100vh - 64px - 48px - 48px);
display: flex;
flex-direction: column;
min-height: 0;
}
.call-logs__label {
font-size: 13px;
line-height: 32px;
color: rgba(0, 0, 0, 0.65);
}
.call-logs__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.call-logs__box {
flex: 1;
min-height: 0;
}
.call-logs__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.call-logs__kv {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
gap: 12px;
}
.call-logs__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
min-width: 72px;
flex-shrink: 0;
}
.call-logs__num {
font-variant-numeric: tabular-nums;
}
.call-logs__content {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
}
.call-logs__raw {
margin-top: 4px;
}
.call-logs__json {
margin: 4px 0 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.call-logs__badge {
cursor: pointer;
}
.call-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.call-logs__foot {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style>
+179 -7
View File
@@ -1,16 +1,22 @@
<script setup lang="ts">
/**
* 成员管理(交互设计-成员管理):清单表 · 开户 · 令牌明文/重发 · 启停
* 成员管理(交互设计-成员管理):清单表 · 开户 · 档案修改 · 令牌明文/重发 · 启停 · 空壳删除
*/
import { computed, h, reactive, ref } from "vue";
import { computed, h, reactive, ref, watch } from "vue";
import { Tooltip, message } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
import type { MemberItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import MemberTokenModal from "@/components/admin/member-token-modal.vue";
import {
useCreateMember,
useDeleteMember,
useIssueMemberToken,
useMembers,
usePatchMember,
@@ -20,9 +26,11 @@ import { formatMinute } from "@/utils/time.ts";
const membersQuery = useMembers();
const createMember = useCreateMember();
const patchMember = usePatchMember();
const deleteMember = useDeleteMember();
const issueToken = useIssueMemberToken();
const createOpen = ref(false);
const editOpen = ref(false);
const tokenOpen = ref(false);
const plainToken = ref("");
const tokenMemberName = ref("");
@@ -30,6 +38,32 @@ const tokenMemberName = ref("");
const createForm = reactive({ name: "", remark: "" });
const createError = ref("");
const editForm = reactive({ id: 0, name: "", remark: "" });
const editError = ref("");
const filterName = ref("");
const filterStatus = ref<"active" | "disabled" | null>(null);
function syncFiltersFromRoute() {
const q = route.query;
filterName.value = typeof q.name === "string" ? q.name : "";
filterStatus.value =
q.status === "active" || q.status === "disabled" ? q.status : null;
}
function pushFilterQuery() {
const query: Record<string, string> = {};
const mid = route.query.member_id;
if (typeof mid === "string" && mid) query.member_id = mid;
if (filterName.value.trim()) query.name = filterName.value.trim();
if (filterStatus.value) query.status = filterStatus.value;
void router.replace({ path: "/admin/members", query });
}
syncFiltersFromRoute();
watch(() => route.query, () => syncFiltersFromRoute());
watch([filterName, filterStatus], () => pushFilterQuery());
function tipTitle(label: string, tip: string) {
return h("span", { class: "overview-col-title" }, [
label,
@@ -55,10 +89,37 @@ const columns = [
width: 170,
},
{ title: "名下 Key 数", key: "key_count", width: 110 },
{ title: "操作", key: "actions", width: 200 },
{ title: "操作", key: "actions", width: 280 },
];
const rows = computed(() => membersQuery.data.value ?? []);
const rows = computed(() => {
let list = membersQuery.data.value ?? [];
const mid = route.query.member_id;
if (typeof mid === "string" && mid) {
const n = Number(mid);
list = list.filter((m) => m.id === n);
}
const name = filterName.value.trim();
if (name) {
const lower = name.toLowerCase();
list = list.filter(
(m) =>
m.name.toLowerCase().includes(lower) ||
(m.remark ?? "").toLowerCase().includes(lower),
);
}
if (filterStatus.value) {
list = list.filter((m) => m.status === filterStatus.value);
}
return list;
});
const hasFilter = computed(
() =>
!!filterName.value.trim() ||
filterStatus.value != null ||
(typeof route.query.member_id === "string" && !!route.query.member_id),
);
async function onCreate() {
createError.value = "";
@@ -111,21 +172,83 @@ async function onToggle(record: MemberItem) {
message.error((e as ApiError).message || "状态更新失败");
}
}
function openEdit(record: MemberItem) {
editForm.id = record.id;
editForm.name = record.name;
editForm.remark = record.remark ?? "";
editError.value = "";
editOpen.value = true;
}
async function onEdit() {
editError.value = "";
const name = editForm.name.trim();
if (!name) {
editError.value = "请填写成员名称";
return;
}
try {
await patchMember.mutateAsync({
id: editForm.id,
body: { name, remark: editForm.remark },
});
editOpen.value = false;
message.success("已保存");
} catch (e) {
const err = e as ApiError;
editError.value = err.message || "保存失败";
}
}
async function onDelete(record: MemberItem) {
try {
await deleteMember.mutateAsync(record.id);
message.success("已删除——仅清理未挂 key 的空壳");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
</script>
<template>
<section>
<PageHeader title="成员管理" subtitle="开户、访问令牌与启停">
<PageHeader title="成员管理" subtitle="开户、档案修改、访问令牌与启停">
<template #actions>
<a-button type="primary" @click="createOpen = true">新建成员</a-button>
</template>
</PageHeader>
<div class="members-filters">
<a-input
v-model:value="filterName"
allow-clear
placeholder="名称 / 备注"
style="width: 180px"
/>
<a-select
v-model:value="filterStatus"
:options="[
{ value: null, label: '全部状态' },
{ value: 'active', label: '启用中' },
{ value: 'disabled', label: '已停用' },
]"
style="width: 120px"
allow-clear
placeholder="状态"
/>
</div>
<TableRefresh
:loading="membersQuery.isFetching.value"
@refresh="() => membersQuery.refetch()"
/>
<a-empty
v-if="!membersQuery.isPending.value && rows.length === 0"
description="还没有成员——为每位分摊朋友开户并发放访问令牌"
:description="hasFilter ? '没有符合筛选的成员' : '还没有成员——为每位分摊朋友开户并发放访问令牌'"
>
<a-button @click="createOpen = true">新建成员</a-button>
<a-button v-if="!hasFilter" @click="createOpen = true">新建成员</a-button>
</a-empty>
<a-table
@@ -156,6 +279,13 @@ async function onToggle(record: MemberItem) {
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button
size="small"
@click="() => router.push({ path: '/admin/logs', query: { member_id: String(record.id) } })"
>
查调用
</a-button>
<a-button size="small" @click="() => openEdit(record)">编辑</a-button>
<a-popconfirm
title="重发后旧令牌立即失效"
description="该成员正在用的访问将中断"
@@ -178,11 +308,46 @@ async function onToggle(record: MemberItem) {
<a-button v-else size="small" @click="() => onToggle(record)">
启用
</a-button>
<a-tooltip
v-if="record.key_count > 0"
title="名下仍有 key,只能停用"
>
<a-button size="small" danger disabled>删除</a-button>
</a-tooltip>
<a-popconfirm
v-else
title="删除后不可恢复(名称与令牌一并作废)"
description="仅清理未挂 key 的空壳"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDelete(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<a-modal
v-model:open="editOpen"
title="编辑成员"
:confirm-loading="patchMember.isPending.value"
ok-text="保存"
cancel-text="取消"
@ok="onEdit"
>
<a-form layout="vertical">
<a-form-item label="名称" required>
<a-input v-model:value="editForm.name" placeholder="成员名称" />
</a-form-item>
<a-form-item label="备注">
<a-input v-model:value="editForm.remark" placeholder="备注(可选)" />
</a-form-item>
<a-alert v-if="editError" type="error" show-icon :message="editError" />
</a-form>
</a-modal>
<a-modal
v-model:open="createOpen"
title="新建成员"
@@ -212,6 +377,13 @@ async function onToggle(record: MemberItem) {
</template>
<style scoped>
.members-filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.overview-col-title {
display: inline-flex;
align-items: center;
+40 -11
View File
@@ -2,11 +2,12 @@
/**
* 全员总览(交互设计-全员总览):健康带 · 时间范围 · 按成员 · 按 key
*/
import { computed, h, ref } from "vue";
import { computed, h } from "vue";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import SourceBar from "@/components/common/source-bar.vue";
@@ -16,8 +17,8 @@ import {
type RangeKind,
type RangeValue,
} from "@/composables/use-live-time-range.ts";
import { useAdminOverview, useEngine, usePayments } from "@/queries/admin.ts";
import { formatMinute } from "@/utils/time.ts";
import { useAdminOverview, useEngine, useSubscriptions } from "@/queries/admin.ts";
import { formatMinute, toRequestIso } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const router = useRouter();
@@ -25,7 +26,7 @@ const { range, setRange } = useLiveTimeRange("recent-30");
const overviewQuery = useAdminOverview(() => range.value);
const engineQuery = useEngine();
const paymentsQuery = usePayments();
const paymentsQuery = useSubscriptions();
const overview = computed(() => overviewQuery.data.value);
const engine = computed(() => engineQuery.data.value);
@@ -37,13 +38,13 @@ const loading = computed(
const extraPresets = computed(() => {
const list = paymentsQuery.data.value ?? [];
return list
.filter((p) => p.kind === "subscription" && p.period_start != null)
.filter((p) => p.period_start != null)
.map((p) => ({
label: `订阅 #${p.subscription_id ?? p.id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
label: `订阅 #${p.subscription_id} 期间`,
from: toRequestIso(new Date((p.period_start ?? 0) * 1000)),
to: toRequestIso(
new Date((p.period_end ?? Math.floor(Date.now() / 1000)) * 1000),
),
}))
.slice(0, 4);
});
@@ -77,7 +78,22 @@ function onMemberClick(memberId: number | null) {
}
function onKeyClick(keyId: number) {
void router.push({ path: "/admin/keys", query: { key_id: String(keyId) } });
void router.push({
path: "/admin/logs",
query: { api_key_id: String(keyId), ...range.value },
});
}
function onMemberLogs(memberId: number | null) {
void router.push({
path: "/admin/logs",
query: {
...(memberId == null
? { member_id: "unassigned" }
: { member_id: String(memberId) }),
...range.value,
},
});
}
function goFailedKeys() {
@@ -131,6 +147,7 @@ const memberColumns = [
),
key: "data_cutoff",
},
{ title: "操作", key: "actions", width: 100 },
];
const keyColumns = [
@@ -201,6 +218,10 @@ const keyColumns = [
<!-- 按成员 -->
<section class="overview-block">
<h2 class="overview-block__title">按成员汇总</h2>
<TableRefresh
:loading="overviewQuery.isFetching.value"
@refresh="() => overviewQuery.refetch()"
/>
<a-empty
v-if="!loading && byMember.length === 0"
description="本时段内没有任何消费调用,因此没有成员用量可汇总"
@@ -238,6 +259,14 @@ const keyColumns = [
<template v-else-if="column.key === 'data_cutoff'">
{{ formatMinute(record.data_cutoff) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-button
size="small"
@click="() => onMemberLogs(record.member_id)"
>
查调用
</a-button>
</template>
</template>
</a-table>
</section>
+455 -4
View File
@@ -1,13 +1,464 @@
<script setup lang="ts">
/**
* 支付记录(交互设计-支付记录):订阅 / 充值两 Tab · 核对提示
*/
import { computed, ref, watch } from "vue";
import { message } from "antdv-next";
import type { SubscriptionItem, TopupItem } from "@new-api-key-usage/core";
import type { ApiError } from "@/api/http.ts";
import { fetchLogs } from "@/api/admin/logs.ts";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import PaymentFormModal from "@/components/admin/payment-form-modal.vue";
import QuotaText from "@/components/common/quota-text.vue";
import {
useDeleteSubscription,
useDeleteTopup,
useSubscriptions,
useTopups,
} from "@/queries/admin.ts";
import { formatDateTime, toRequestIso } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const subsQuery = useSubscriptions();
const topupsQuery = useTopups();
const filterDomain = ref<string | "none" | null>(null);
const filterSubId = ref<number | null>(null);
const filterTradeNo = ref<string | null>(null);
const domainOptions = computed(() => {
const set = new Set<string>();
for (const s of subsQuery.data.value ?? []) {
if (s.billing_domain) set.add(s.billing_domain);
}
for (const t of topupsQuery.data.value ?? []) {
if (t.billing_domain) set.add(t.billing_domain);
}
return [...set].sort().map((v) => ({ value: v, label: v }));
});
const filteredSubs = computed(() => {
let list = subsQuery.data.value ?? [];
if (filterSubId.value != null) {
list = list.filter((r) => r.subscription_id === filterSubId.value);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const filteredTops = computed(() => {
let list = topupsQuery.data.value ?? [];
if (filterTradeNo.value) {
const q = filterTradeNo.value.trim().toLowerCase();
list = list.filter(
(r) =>
(r.trade_no ?? "").toLowerCase().includes(q) ||
(r.upstream_id != null && String(r.upstream_id).includes(q)),
);
}
if (!filterDomain.value) return list;
if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null);
}
return list.filter((r) => r.billing_domain === filterDomain.value);
});
const deleteSub = useDeleteSubscription();
const deleteTop = useDeleteTopup();
const tab = ref<"subs" | "topups">("subs");
const formOpen = ref(false);
const editingSub = ref<SubscriptionItem | null>(null);
const editingTop = ref<TopupItem | null>(null);
const missingSubs = ref<number[]>([]);
const checking = ref(false);
function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs";
const sid = route.query.subscription_id;
filterSubId.value = typeof sid === "string" && sid ? Number(sid) || null : null;
const tn = route.query.trade_no;
filterTradeNo.value = typeof tn === "string" && tn ? tn : null;
}
function pushQuery() {
const query: Record<string, string> = {};
if (tab.value === "topups") query.tab = "topups";
if (filterSubId.value != null) query.subscription_id = String(filterSubId.value);
if (filterTradeNo.value) query.trade_no = filterTradeNo.value;
void router.replace({ path: "/admin/payments", query });
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch([tab, filterSubId, filterTradeNo], () => pushQuery());
const subRows = computed(() => [...filteredSubs.value]);
const topRows = computed(() => {
const list = [...filteredTops.value];
list.sort((a, b) => (b.paid_at ?? 0) - (a.paid_at ?? 0));
return list;
});
const subColumns = [
{ title: "订阅 ID", dataIndex: "subscription_id", key: "subscription_id", width: 100 },
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "登记", key: "created_at", width: 150 },
{ title: "操作", key: "actions", width: 120 },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{
title: "充值金额",
dataIndex: "amount",
key: "amount",
width: 100,
},
{ title: "实付金额", dataIndex: "money", key: "money", width: 100 },
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", key: "billing_domain", width: 110 },
{ title: "备注", key: "remark" },
{ title: "操作", key: "actions", width: 120 },
];
function periodLines(s: SubscriptionItem): { start: string; end: string } {
return {
start: formatDateTime(s.period_start),
end: s.period_end == null ? "进行中" : formatDateTime(s.period_end),
};
}
function onCreateSub() {
editingSub.value = null;
editingTop.value = null;
formOpen.value = true;
}
function onCreateTop() {
editingSub.value = null;
editingTop.value = null;
tab.value = "topups";
formOpen.value = true;
}
function onEditSub(s: SubscriptionItem) {
editingSub.value = s;
editingTop.value = null;
formOpen.value = true;
}
function onEditTop(t: TopupItem) {
editingTop.value = t;
editingSub.value = null;
formOpen.value = true;
}
async function onDeleteSub(s: SubscriptionItem) {
try {
await deleteSub.mutateAsync(s.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
async function onDeleteTop(t: TopupItem) {
try {
await deleteTop.mutateAsync(t.id);
message.success("已删除");
} catch (e) {
message.error((e as ApiError).message || "删除失败");
}
}
/** 观测订阅号 ↔ 已登记:只提示不回填;双表皆空则静默 */
async function reconcile(subs: SubscriptionItem[]) {
if (subs.length === 0 && topupsQuery.data.value?.length === 0) {
missingSubs.value = [];
return;
}
if (subs.length === 0) {
missingSubs.value = [];
return;
}
checking.value = true;
try {
const known = new Set(subs.map((s) => s.subscription_id));
const earliest = Math.min(
...subs.map((s) => s.period_start ?? 0),
Date.now() / 1000,
);
const from = toRequestIso(new Date((earliest - 30 * 86400) * 1000));
const to = toRequestIso(new Date());
const page = await fetchLogs({
from,
to,
page: 1,
page_size: 200,
type: 2,
});
const observed = new Set<number>();
for (const it of page.items) {
if (it.type === 2 && it.subscription_id != null) {
observed.add(it.subscription_id);
}
}
missingSubs.value = [...observed].filter((id) => !known.has(id)).sort((a, b) => a - b);
} catch {
missingSubs.value = [];
} finally {
checking.value = false;
}
}
watch(
() => subsQuery.data.value,
(subs) => {
if (!subs) return;
void reconcile(subs);
},
{ immediate: true },
);
</script>
<template>
<section>
<PageHeader
title="支付记录"
subtitle="订阅 / 充值登记与核对(信息结构见交互设计-支付记录)"
<PageHeader title="支付记录" subtitle="订阅权益与充值入账登记">
<template #actions>
<a-button v-if="tab === 'subs'" type="primary" @click="onCreateSub">
登记订阅
</a-button>
<a-button v-else type="primary" @click="onCreateTop">登记充值</a-button>
</template>
</PageHeader>
<a-tabs v-model:active-key="tab">
<a-tab-pane key="subs" tab="订阅" />
<a-tab-pane key="topups" tab="充值" />
</a-tabs>
<div class="payments-toolbar">
<template v-if="tab === 'subs'">
<a-input-number
v-model:value="filterSubId"
:min="1"
:precision="0"
style="width: 140px"
placeholder="订阅 ID"
allow-clear
/>
</template>
<template v-else>
<a-input
v-model:value="filterTradeNo"
style="width: 180px"
placeholder="交易号 / 上游 ID"
allow-clear
/>
</template>
<a-select
v-model:value="filterDomain"
:options="[
{ value: null, label: '全部账本域' },
{ value: 'none', label: '未标注' },
...domainOptions,
]"
style="width: 160px"
placeholder="账本域"
allow-clear
/>
</div>
<a-alert
v-if="tab === 'subs' && missingSubs.length"
type="warning"
show-icon
style="margin-bottom: 16px"
:message="`观测到 ${missingSubs.length} 个订阅有消耗但尚无对应登记`"
>
<template #description>
<div class="pay-missing__tags">
<a-tag v-for="id in missingSubs" :key="id">#{{ id }}</a-tag>
</div>
<div class="pay-missing__note">
若本部署不登记支付,可忽略此提示;需要入账的请核对补录。
</div>
</template>
</a-alert>
<template v-if="tab === 'subs'">
<TableRefresh
:loading="subsQuery.isFetching.value"
@refresh="() => subsQuery.refetch()"
/>
<a-empty
v-if="!subsQuery.isPending.value && subRows.length === 0"
description="还没有订阅登记——从平台选用或手工登记"
>
<a-button @click="onCreateSub">登记订阅</a-button>
</a-empty>
<a-table
v-else
:columns="subColumns"
:data-source="subRows"
:row-key="(r: SubscriptionItem) => r.id"
:loading="subsQuery.isPending.value || subsQuery.isFetching.value || checking"
:pagination="false"
size="middle"
:scroll="{ x: 960 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'period'">
<div class="pay-period">
<span>{{ periodLines(record).start }}</span>
<span class="pay-period__end">~ {{ periodLines(record).end }}</span>
</div>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'paid'">
{{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEditSub(record)">编辑</a-button>
<a-popconfirm
title="删除后该条将消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDeleteSub(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</template>
<template v-else>
<TableRefresh
:loading="topupsQuery.isFetching.value"
@refresh="() => topupsQuery.refetch()"
/>
<a-empty
v-if="!topupsQuery.isPending.value && topRows.length === 0"
description="还没有充值登记"
>
<a-button @click="onCreateTop">登记充值</a-button>
</a-empty>
<a-table
v-else
:columns="topColumns"
:data-source="topRows"
:row-key="(r: TopupItem) => r.id"
:loading="topupsQuery.isPending.value || topupsQuery.isFetching.value"
:pagination="false"
size="middle"
:scroll="{ x: 900 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'amount'">
{{ formatPaidAmount(record.amount) }}
</template>
<template v-else-if="column.key === 'money'">
{{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="pay-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'billing_domain'">
{{ record.billing_domain || "—" }}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
<template v-else-if="column.key === 'actions'">
<a-space>
<a-button size="small" @click="() => onEditTop(record)">编辑</a-button>
<a-popconfirm
title="删除后该条将消失"
ok-text="删除"
cancel-text="取消"
@confirm="() => onDeleteTop(record)"
>
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</template>
<PaymentFormModal
v-model:open="formOpen"
:tab="tab"
:editing-sub="editingSub"
:editing-top="editingTop"
/>
<a-skeleton active :paragraph="{ rows: 4 }" />
</section>
</template>
<style scoped>
.payments-toolbar {
margin-bottom: 12px;
}
.pay-period {
display: flex;
flex-direction: column;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.pay-period__end {
color: rgba(0, 0, 0, 0.65);
}
.pay-missing__tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 4px;
}
.pay-missing__note,
.pay-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+124 -3
View File
@@ -1,10 +1,131 @@
<script setup lang="ts">
/**
* 账号设置——改密(交互设计-登录与会话 §3)
*/
import { computed, reactive, ref } from "vue";
import { message } from "antdv-next";
import type { ApiError } from "@/api/http.ts";
import PageHeader from "@/components/common/page-header.vue";
import { useChangePassword } from "@/queries/session.ts";
const changePassword = useChangePassword();
const form = reactive({
old_password: "",
new_password: "",
confirm_password: "",
});
const fieldError = ref<{ old_password?: string; new_password?: string }>({});
const formError = ref("");
const successMsg = ref("");
const canSubmit = computed(
() =>
form.old_password.length > 0 &&
form.new_password.length > 0 &&
form.confirm_password.length > 0,
);
async function onSubmit() {
if (!canSubmit.value || changePassword.isPending.value) return;
fieldError.value = {};
formError.value = "";
successMsg.value = "";
if (form.new_password !== form.confirm_password) {
formError.value = "两次输入的新密码不一致";
return;
}
try {
await changePassword.mutateAsync({
old_password: form.old_password,
new_password: form.new_password,
});
form.old_password = "";
form.new_password = "";
form.confirm_password = "";
successMsg.value = "密码已更新;其他设备的登录已失效";
message.success(successMsg.value);
} catch (e) {
const err = e as ApiError;
if (err.fields?.old_password) {
fieldError.value.old_password = err.fields.old_password;
} else if (err.fields?.new_password) {
fieldError.value.new_password = err.fields.new_password;
} else {
formError.value = err.message || "修改失败";
}
}
}
</script>
<template>
<section>
<PageHeader title="账号设置" subtitle="修改密码" />
<a-skeleton active :paragraph="{ rows: 3 }" />
<section class="settings">
<PageHeader title="账号设置" subtitle="修改管理面登录密码" />
<a-form layout="vertical" class="settings__form">
<a-form-item
label="旧密码"
required
:validate-status="fieldError.old_password ? 'error' : undefined"
:help="fieldError.old_password"
>
<a-input-password
v-model:value="form.old_password"
autocomplete="current-password"
@press-enter="onSubmit"
/>
</a-form-item>
<a-form-item
label="新密码"
required
:validate-status="fieldError.new_password ? 'error' : undefined"
:help="fieldError.new_password"
>
<a-input-password
v-model:value="form.new_password"
autocomplete="new-password"
@press-enter="onSubmit"
/>
</a-form-item>
<a-form-item label="确认新密码" required>
<a-input-password
v-model:value="form.confirm_password"
autocomplete="new-password"
@press-enter="onSubmit"
/>
</a-form-item>
<a-alert
v-if="formError"
type="error"
show-icon
:message="formError"
style="margin-bottom: 16px"
/>
<a-alert
v-else-if="successMsg"
type="success"
show-icon
:message="successMsg"
style="margin-bottom: 16px"
/>
<a-button
type="primary"
:loading="changePassword.isPending.value"
:disabled="!canSubmit"
@click="onSubmit"
>
修改密码
</a-button>
</a-form>
</section>
</template>
<style scoped>
.settings__form {
max-width: 420px;
}
</style>
-13
View File
@@ -1,13 +0,0 @@
<script setup lang="ts">
import PageHeader from "@/components/common/page-header.vue";
</script>
<template>
<section>
<PageHeader
title="我的用量"
subtitle="聚合 · 来源徽章 · 明细(信息结构见交互设计-成员视图)"
/>
<a-skeleton active :paragraph="{ rows: 4 }" />
</section>
</template>
+1 -1
View File
@@ -20,7 +20,7 @@ async function onFinish() {
tokenStore.setToken(model.token.trim());
const me = await fetchMe();
tokenStore.setMemberName(me.name);
void router.replace({ name: "member-home" });
void router.replace({ name: "member-overview" });
} catch {
tokenStore.clearToken();
error.value = "令牌无效或已停用";
+584
View File
@@ -0,0 +1,584 @@
<script setup lang="ts">
/**
* 成员调用日志(交互设计-成员视图 §4):
* 行展开 = 模型调用详情;来源徽章 = 订阅支付参数弹窗(R12)
*/
import { computed, h, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { Tooltip } from "antdv-next";
import { QuestionCircleOutlined } from "@antdv-next/icons";
import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import LogsSummaryBar from "@/components/common/logs-summary-bar.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import type { RangeKind } from "@/stores/member-range.ts";
import { useMemberLogs, useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute();
const router = useRouter();
const { range, setRange } = useMemberRange();
const apiKeyId = ref<number | null>(null);
const modelFilter = ref<string | null>(null);
const billingSource = ref<string | null>(null);
const subscriptionId = ref<number | null>(null);
const typeFilter = ref<number | "error" | null>(null);
const page = ref(1);
const pageSize = ref(50);
const overviewQuery = useMemberOverview(() => range.value);
const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const params = computed(() => ({
...range.value,
page: page.value,
page_size: pageSize.value,
api_key_id: apiKeyId.value ?? undefined,
model_name: modelFilter.value ?? undefined,
billing_source: billingSource.value ?? undefined,
subscription_id: subscriptionId.value ?? undefined,
type: typeFilter.value ?? undefined,
}));
const logsQuery = useMemberLogs(() => params.value);
const rows = computed(() => logsQuery.data.value?.items ?? []);
const total = computed(() => logsQuery.data.value?.total ?? 0);
const summary = computed(() => logsQuery.data.value?.summary);
const modelOptions = computed(() => [
{ value: null, label: "全部模型" },
...(logsQuery.data.value?.models ?? []).map((m) => ({ value: m, label: m })),
]);
const keyOptions = computed(() => [
{ value: null, label: "全部 Key" },
...keys.value.map((k) => ({ value: k.key_id, label: k.token_name })),
]);
const paymentBySub = computed(() => {
const m = new Map<number, SubscriptionItem>();
for (const p of payments.value) {
m.set(p.subscription_id, p);
}
return m;
});
function tipTitle(label: string, tip: string) {
return h("span", { class: "mv-col-title" }, [
label,
h(Tooltip, { title: tip }, () => h(QuestionCircleOutlined, { class: "mv-col-title__icon" })),
]);
}
const columns = [
{ title: "时间", key: "created_at", width: 150 },
{ title: "Key", key: "token_name", dataIndex: "token_name", width: 120 },
{ title: "模型", key: "model_name", dataIndex: "model_name", width: 120 },
{ title: "类型", key: "type", width: 80 },
{ title: "来源", key: "source", width: 120 },
{
title: tipTitle("用量", "quota 原值与按当时换算率折算的美元"),
key: "usage",
width: 150,
},
{ title: "操作", key: "actions", width: 72 },
];
const detailOpen = ref(false);
const detailRecord = ref<LogItem | null>(null);
/** 正在从 URL 灌入状态——避免筛选 watcher 误重置 page */
const applyingRoute = ref(false);
function openDetail(record: LogItem) {
detailRecord.value = record;
detailOpen.value = true;
}
function formatRaw(record: LogItem | null): string {
if (!record) return "";
return JSON.stringify(record.raw ?? record, null, 2);
}
function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) {
return {
text: `订阅 #${record.subscription_id}`,
kind: "sub" as const,
id: record.subscription_id,
};
}
if (record.billing_source === "wallet") {
return { text: "充值", kind: "wallet" as const, id: null };
}
return { text: "未关联", kind: "none" as const, id: null };
}
function onBadge(record: LogItem, e: MouseEvent) {
e.stopPropagation();
if (sourceBadge(record).kind !== "sub") return;
payModalSubId.value = sourceBadge(record).id;
payModalOpen.value = true;
}
function periodText(p: SubscriptionItem): string {
const end = p.period_end == null ? "进行中" : formatDateTime(p.period_end);
return `${formatDateTime(p.period_start)} ~ ${end}`;
}
const payModalOpen = ref(false);
const payModalSubId = ref<number | null>(null);
const payModalPayment = computed(() =>
payModalSubId.value == null
? undefined
: paymentBySub.value.get(payModalSubId.value),
);
function syncFromRoute() {
applyingRoute.value = true;
const q = route.query;
const k = q.api_key_id ?? q.key_id;
apiKeyId.value = typeof k === "string" && k ? Number(k) || null : null;
modelFilter.value =
typeof q.model_name === "string" && q.model_name ? q.model_name : null;
billingSource.value =
typeof q.billing_source === "string" && q.billing_source
? q.billing_source
: null;
const s = q.subscription_id;
subscriptionId.value = typeof s === "string" && s ? Number(s) || null : null;
const t = q.type;
if (t === "2") typeFilter.value = 2;
else if (t === "error") typeFilter.value = "error";
else typeFilter.value = null;
const p = q.page;
page.value = typeof p === "string" && p ? Number(p) || 1 : 1;
void nextTick(() => {
applyingRoute.value = false;
});
}
function pushQuery() {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (apiKeyId.value != null) query.api_key_id = String(apiKeyId.value);
if (modelFilter.value) query.model_name = modelFilter.value;
if (billingSource.value) query.billing_source = billingSource.value;
if (subscriptionId.value != null) {
query.subscription_id = String(subscriptionId.value);
}
if (typeFilter.value) query.type = String(typeFilter.value);
if (page.value > 1) query.page = String(page.value);
void router.replace({ path: "/member/logs", query });
}
syncFromRoute();
watch(() => route.query, () => syncFromRoute());
watch([apiKeyId, modelFilter, billingSource, subscriptionId, typeFilter], () => {
if (applyingRoute.value) return;
page.value = 1;
pushQuery();
});
watch(page, () => pushQuery());
function onRangeChange(v: { from: string; to: string }, k: RangeKind) {
setRange(v, k);
page.value = 1;
pushQuery();
}
function onPageChange(p: number, ps: number) {
page.value = p;
pageSize.value = ps;
}
const boxRef = ref<HTMLElement | null>(null);
const bodyY = ref(480);
const CHROME = 96;
let ro: ResizeObserver | null = null;
function measure() {
const h = boxRef.value?.clientHeight ?? 0;
if (h > 0) bodyY.value = Math.max(200, h - CHROME);
}
function bindObserve() {
ro?.disconnect();
ro = null;
if (typeof ResizeObserver !== "undefined" && boxRef.value) {
ro = new ResizeObserver(() => measure());
ro.observe(boxRef.value);
}
measure();
}
onMounted(() => {
bindObserve();
window.addEventListener("resize", measure);
});
onUnmounted(() => {
ro?.disconnect();
window.removeEventListener("resize", measure);
});
// 空态/有数据切换会重建 box 节点,需重新挂 RO
watch(
() => rows.value.length === 0 && !logsQuery.isPending.value,
async () => {
await nextTick();
bindObserve();
},
);
</script>
<template>
<section class="member-logs">
<PageHeader title="调用日志" subtitle="名下 key 的模型请求流水">
<template #actions>
<span class="member-logs__label">时间范围</span>
<TimeRangePicker :model-value="range" @change="onRangeChange" />
</template>
</PageHeader>
<div class="member-logs__filters">
<a-select
v-model:value="apiKeyId"
:options="keyOptions"
style="width: 160px"
allow-clear
placeholder="Key"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="modelFilter"
:options="modelOptions"
style="width: 140px"
allow-clear
placeholder="模型"
show-search
option-filter-prop="label"
/>
<a-select
v-model:value="billingSource"
:options="[
{ value: null, label: '全部来源' },
{ value: 'subscription', label: '订阅' },
{ value: 'wallet', label: '充值' },
{ value: 'none', label: '未关联' },
]"
style="width: 120px"
allow-clear
placeholder="来源"
/>
<a-input-number
v-model:value="subscriptionId"
:min="1"
:precision="0"
style="width: 120px"
placeholder="订阅 ID"
/>
<a-select
v-model:value="typeFilter"
:options="[
{ value: null, label: '全部类型' },
{ value: 2, label: '消费' },
{ value: 'error', label: '错误' },
]"
style="width: 120px"
placeholder="类型"
/>
</div>
<LogsSummaryBar
:hit-count="summary?.hit_count ?? 0"
:usage-count="summary?.usage_count ?? 0"
:error-count="summary?.error_count ?? 0"
:quota="summary?.quota ?? 0"
:quota-usd="summary?.quota_usd ?? '0.00'"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
/>
<TableRefresh
:loading="logsQuery.isFetching.value"
@refresh="() => logsQuery.refetch()"
/>
<a-alert
v-if="logsQuery.isError.value"
type="error"
show-icon
message="调用日志加载失败"
:description="logsQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => logsQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-empty
v-if="!logsQuery.isPending.value && !logsQuery.isError.value && rows.length === 0"
description="该时段内无调用日志"
/>
<div v-else ref="boxRef" class="member-logs__box">
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r: LogItem) => r.id"
:loading="logsQuery.isPending.value || logsQuery.isFetching.value"
:pagination="{
current: page,
pageSize,
total,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100', '200'],
showTotal: (t: number) => `共 ${t} 条`,
onChange: onPageChange,
}"
size="middle"
:scroll="{ x: 780, y: bodyY }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'created_at'">
{{ formatDateTime(record.created_at) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.quota" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'type'">
<a-tag :color="record.type === 2 ? 'blue' : 'red'">
{{ record.type === 2 ? "消费" : "错误" }}
</a-tag>
</template>
<template v-else-if="column.key === 'source'">
<a-tooltip
v-if="sourceBadge(record).kind === 'sub'"
title="点击查看该订阅支付参数"
>
<a-tag
color="blue"
class="member-logs__badge"
@click="(e: MouseEvent) => onBadge(record, e)"
>
{{ sourceBadge(record).text }}
</a-tag>
</a-tooltip>
<a-tag v-else-if="sourceBadge(record).kind === 'wallet'" color="gold">
充值
</a-tag>
<a-tag v-else>未关联</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a @click="openDetail(record)">详情</a>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="detailOpen"
title="调用详情"
:footer="null"
width="720"
centered
:styles="{ body: { maxHeight: '70vh', overflowY: 'auto' } }"
>
<div v-if="detailRecord" class="member-logs__detail">
<div class="member-logs__kv">
<span class="member-logs__note">时间</span>
<span>{{ formatDateTime(detailRecord.created_at) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Key / 模型</span>
<span>{{ detailRecord.token_name }} · {{ detailRecord.model_name }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">类型</span>
<a-tag :color="detailRecord.type === 2 ? 'blue' : 'red'">
{{ detailRecord.type === 2 ? "消费" : "错误" }}
</a-tag>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">用量</span>
<QuotaText :quota="detailRecord.quota" :quota-usd="detailRecord.quota_usd" />
</div>
<div class="member-logs__kv">
<span class="member-logs__note">Tokens</span>
<span class="member-logs__num">
提示 {{ formatInt(detailRecord.prompt_tokens) }} · 补全 {{ formatInt(detailRecord.completion_tokens) }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">耗时</span>
<span class="member-logs__num">
{{ detailRecord.use_time }}s · {{ detailRecord.is_stream ? "流式" : "非流式" }}
</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">来源</span>
<a-tag :color="sourceBadge(detailRecord).kind === 'sub' ? 'blue' : sourceBadge(detailRecord).kind === 'wallet' ? 'gold' : undefined">
{{ sourceBadge(detailRecord).text }}
</a-tag>
</div>
<div v-if="detailRecord.content" class="member-logs__kv">
<span class="member-logs__note">内容</span>
<span class="member-logs__content">{{ detailRecord.content }}</span>
</div>
<div class="member-logs__raw">
<div class="member-logs__note">原始数据</div>
<pre class="member-logs__json">{{ formatRaw(detailRecord) }}</pre>
</div>
</div>
</a-modal>
<a-modal
v-model:open="payModalOpen"
:title="payModalSubId != null ? `订阅 #${payModalSubId} 支付参数` : '支付参数'"
:footer="null"
width="480"
centered
:styles="{ body: { maxHeight: '56vh', overflowY: 'auto' } }"
>
<div v-if="payModalPayment" class="member-logs__pay">
<div class="member-logs__kv">
<span class="member-logs__note">期间</span>
<span>{{ periodText(payModalPayment) }}</span>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">额度</span>
<QuotaText
:quota="payModalPayment.quota_granted"
:quota-usd="payModalPayment.quota_usd"
/>
</div>
<div class="member-logs__kv">
<span class="member-logs__note">金额</span>
<span class="member-logs__num">{{ formatPaidAmount(payModalPayment.paid_amount) }}</span>
</div>
<div v-if="payModalPayment.remark" class="member-logs__kv">
<span class="member-logs__note">备注</span>
<span>{{ payModalPayment.remark }}</span>
</div>
<div class="member-logs__foot">
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</div>
<div v-else class="member-logs__note">该订阅无登记信息</div>
</a-modal>
</section>
</template>
<style scoped>
.member-logs {
display: flex;
flex-direction: column;
height: calc(100vh - 64px - 48px - 48px);
min-height: 0;
}
.member-logs__label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.member-logs__filters {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.member-logs__box {
flex: 1;
min-height: 0;
}
.member-logs__badge {
cursor: pointer;
}
.member-logs__detail {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__expand {
display: flex;
flex-direction: column;
gap: 8px;
padding: 4px 0;
}
.member-logs__pay {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-logs__foot {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.member-logs__kv {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.member-logs__note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
min-width: 72px;
}
.member-logs__num {
font-variant-numeric: tabular-nums;
}
.member-logs__content {
white-space: pre-wrap;
word-break: break-all;
flex: 1;
min-width: 0;
}
.member-logs__raw {
margin-top: 4px;
}
.member-logs__json {
margin: 4px 0 0;
padding: 12px;
max-height: 360px;
overflow: auto;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
font-size: 12px;
line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace;
}
.member-logs :deep(.mv-col-title) {
display: inline-flex;
align-items: center;
gap: 4px;
}
.member-logs :deep(.mv-col-title__icon) {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+245
View File
@@ -0,0 +1,245 @@
<script setup lang="ts">
/**
* 成员总览(交互设计-成员视图 §3):紧凑摘要 + 我的 Key 表 + 支付入口
*/
import { computed } from "vue";
import { useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import SourceBar from "@/components/common/source-bar.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatMinute, formatRangeCaption, hoursSince } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts";
const router = useRouter();
const { range, onRangeChange } = useMemberRange();
/** 看板心智:仅总览自动刷新;其它成员页不轮询 */
const overviewQuery = useMemberOverview(() => range.value, {
refetchInterval: 60_000,
});
const usage = computed(() => overviewQuery.data.value?.usage);
const bySource = computed(() => overviewQuery.data.value?.by_source ?? []);
const keys = computed(() => overviewQuery.data.value?.keys ?? []);
const payments = computed(
() => overviewQuery.data.value?.subscriptions ?? [],
);
const extraPresets = computed(() =>
payments.value
.map((p) => ({
label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
const rangeCaption = computed(() =>
formatRangeCaption(range.value.from, range.value.to),
);
function isStalled(at: number | null | undefined): boolean {
const h = hoursSince(at);
return h != null && h > 2;
}
function goLogs(keyId?: number) {
const query: Record<string, string> = {
from: range.value.from,
to: range.value.to,
};
if (keyId != null) query.api_key_id = String(keyId);
void router.push({ path: "/member/logs", query });
}
const keyColumns = [
{ title: "Key 名称", dataIndex: "token_name", key: "token_name" },
{
title: "数据截止",
key: "data_cutoff",
},
{ title: "调用次数", key: "count" },
{ title: "额度消耗", key: "usage" },
{ title: "", key: "actions", width: 80 },
];
</script>
<template>
<section>
<PageHeader title="总览" subtitle="你在本池的名下用量 · 仅统计消费行">
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-alert
v-if="overviewQuery.isError.value"
type="error"
show-icon
message="总览数据加载失败"
:description="overviewQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
style="margin-bottom: 24px"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && keys.length === 0"
description="作者尚未为你分配 key"
style="margin-bottom: 24px"
/>
<template v-else-if="keys.length > 0">
<section class="mv-block">
<h2 class="mv-block__title">
用量摘要
<span class="mv-caption">{{ rangeCaption }}</span>
</h2>
<div class="mv-summary">
<div class="mv-summary__main">
<QuotaText
v-if="usage"
:quota="usage.quota"
:quota-usd="usage.quota_usd"
/>
<div class="mv-summary__sub">
调用 {{ formatInt(usage?.count ?? 0) }} 次 · 按当时换算率
<span v-if="usage && usage.count === 0" class="mv-note">· 该时段内无调用</span>
</div>
</div>
<SourceBar :rows="bySource" />
</div>
</section>
<section class="mv-block">
<h2 class="mv-block__title">我的 Key</h2>
<TableRefresh
:loading="overviewQuery.isFetching.value"
@refresh="() => overviewQuery.refetch()"
/>
<a-table
:columns="keyColumns"
:data-source="keys"
:row-key="(r: { key_id: number }) => r.key_id"
size="middle"
:pagination="false"
:scroll="{ x: 720 }"
:loading="overviewQuery.isFetching.value"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'data_cutoff'">
<span
v-if="record.log_synced_at == null"
class="mv-syncing"
>同步中…</span>
<span
v-else
:class="{ 'is-stall': isStalled(record.log_synced_at) }"
>
{{ isStalled(record.log_synced_at) ? "同步停滞 " : "同步 " }}{{ formatMinute(record.log_synced_at) }}
</span>
</template>
<template v-else-if="column.key === 'count'">
{{ formatInt(record.usage.count) }}
</template>
<template v-else-if="column.key === 'usage'">
<QuotaText :quota="record.usage.quota" :quota-usd="record.usage.quota_usd" />
</template>
<template v-else-if="column.key === 'actions'">
<a-button size="small" @click="() => goLogs(record.key_id)">查调用</a-button>
</template>
</template>
</a-table>
</section>
<section class="mv-block">
<h2 class="mv-block__title">支付参数</h2>
<div class="mv-pay-entry">
<span v-if="payments.length === 0" class="mv-note">暂无支付记录信息</span>
<span v-else class="mv-note">
该时段涉及 {{ payments.length }} 笔登记参数(订阅 / 充值)
</span>
<a @click="() => router.push('/member/payments')">查看完整参数</a>
</div>
</section>
</template>
</section>
</template>
<style scoped>
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-block {
margin-bottom: 24px;
}
.mv-block__title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 600;
line-height: 22px;
}
.mv-caption {
margin-left: 8px;
font-size: 12px;
font-weight: 400;
color: rgba(0, 0, 0, 0.45);
}
.mv-syncing {
color: rgba(0, 0, 0, 0.45);
}
.mv-summary {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
border: 1px solid #f0f0f0;
border-radius: 8px;
}
.mv-summary__sub {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-pay-entry {
display: flex;
align-items: center;
gap: 16px;
}
.is-stall {
color: #faad14;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<script setup lang="ts">
/**
* 成员支付参数(交互设计-成员视图 §5):订阅 + 充值清单
*/
import { computed } from "vue";
import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue";
import TimeRangePicker from "@/components/common/time-range-picker.vue";
import QuotaText from "@/components/common/quota-text.vue";
import { useMemberRange } from "@/composables/use-member-range.ts";
import { useMemberOverview } from "@/queries/member.ts";
import { formatDateTime } from "@/utils/time.ts";
import { formatPaidAmount } from "@/utils/format.ts";
const { range, onRangeChange } = useMemberRange();
const overviewQuery = useMemberOverview(() => range.value);
const subs = computed(() => overviewQuery.data.value?.subscriptions ?? []);
const tops = computed(() => overviewQuery.data.value?.topups ?? []);
const extraPresets = computed(() =>
subs.value
.map((p) => ({
label: `订阅 #${p.subscription_id} 期间`,
from: new Date((p.period_start ?? 0) * 1000).toISOString(),
to: new Date(
(p.period_end ?? Math.floor(Date.now() / 1000)) * 1000,
).toISOString(),
}))
.slice(0, 4),
);
function periodLines(p: {
period_start: number;
period_end: number | null;
}): { start: string; end: string } {
return {
start: formatDateTime(p.period_start),
end: p.period_end == null ? "进行中" : formatDateTime(p.period_end),
};
}
const subColumns = [
{
title: "订阅 ID",
dataIndex: "subscription_id",
key: "subscription_id",
width: 100,
},
{ title: "期间", key: "period", width: 200 },
{ title: "额度", key: "quota", width: 160 },
{ title: "付款金额 / 时间", key: "paid", width: 170 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
const topColumns = [
{ title: "时间", key: "paid_at", width: 150 },
{
title: "充值金额",
dataIndex: "amount",
key: "amount",
width: 100,
},
{
title: "实付金额",
dataIndex: "money",
key: "money",
width: 100,
},
{ title: "交易号 / 渠道", key: "trade", width: 180 },
{ title: "派生额度", key: "quota", width: 150 },
{ title: "账本域", dataIndex: "billing_domain", key: "billing_domain", width: 110 },
{ title: "备注", dataIndex: "remark", key: "remark", width: 140, ellipsis: true },
];
</script>
<template>
<section>
<PageHeader
title="支付参数"
subtitle="订阅与充值的登记参数 · 同账本域内可见"
>
<template #actions>
<span class="mv-range-label">时间范围</span>
<TimeRangePicker
:model-value="range"
:extra-presets="extraPresets"
@change="onRangeChange"
/>
</template>
</PageHeader>
<a-alert
v-if="overviewQuery.isError.value"
type="error"
show-icon
message="支付参数加载失败"
:description="overviewQuery.error.value?.message"
style="margin-bottom: 16px"
>
<template #action>
<a-button size="small" @click="() => overviewQuery.refetch()">重试</a-button>
</template>
</a-alert>
<a-skeleton
v-if="overviewQuery.isPending.value"
active
:paragraph="{ rows: 4 }"
/>
<a-empty
v-else-if="!overviewQuery.isError.value && subs.length === 0 && tops.length === 0"
description="暂无支付记录信息"
/>
<template v-else>
<h3 class="mv-sec">订阅</h3>
<TableRefresh
:loading="overviewQuery.isFetching.value"
@refresh="() => overviewQuery.refetch()"
/>
<a-empty v-if="subs.length === 0" description="暂无订阅登记" />
<a-table
v-else
:columns="subColumns"
:data-source="subs"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 760 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'period'">
<div class="mv-period">
<span>{{ periodLines(record).start }}</span>
<span class="mv-period__end">~ {{ periodLines(record).end }}</span>
</div>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_granted" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'paid'">
{{
record.paid_amount != null
? `${formatPaidAmount(record.paid_amount)} · ${formatDateTime(record.paid_at)}`
: "—"
}}
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<h3 class="mv-sec">充值</h3>
<a-empty v-if="tops.length === 0" description="暂无充值登记" />
<a-table
v-else
:columns="topColumns"
:data-source="tops"
:row-key="(r: { id: number }) => r.id"
size="middle"
:pagination="false"
:scroll="{ x: 840 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'paid_at'">
{{ formatDateTime(record.paid_at) }}
</template>
<template v-else-if="column.key === 'amount'">
{{ formatPaidAmount(record.amount) }}
</template>
<template v-else-if="column.key === 'money'">
{{ formatPaidAmount(record.money) }}
</template>
<template v-else-if="column.key === 'trade'">
{{ record.trade_no || "—" }}
<span v-if="record.payment_method" class="mv-note">
· {{ record.payment_method }}
</span>
</template>
<template v-else-if="column.key === 'quota'">
<QuotaText :quota="record.quota_derived" :quota-usd="record.quota_usd" />
</template>
<template v-else-if="column.key === 'remark'">
{{ record.remark || "—" }}
</template>
</template>
</a-table>
<div class="mv-foot">
支付参数在你所属账本域内公开;币种由成员与作者约定
</div>
</template>
</section>
</template>
<style scoped>
.mv-period {
display: flex;
flex-direction: column;
line-height: 20px;
font-variant-numeric: tabular-nums;
}
.mv-period__end {
color: rgba(0, 0, 0, 0.65);
}
.mv-range-label {
font-size: 13px;
color: rgba(0, 0, 0, 0.65);
}
.mv-sec {
margin: 20px 0 12px;
font-size: 14px;
font-weight: 600;
}
.mv-note {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.mv-foot {
margin-top: 16px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
</style>
+3
View File
@@ -17,6 +17,9 @@ export default defineConfig({
},
},
server: {
// 固定开发口:避免在 5173/5174 被占时静默跳到 5175(strictPort 不自动改口)
port: 5175,
strictPort: true,
proxy: {
// 契约:/api/* 专属接口;SPA 路由不进 /api(公共约定 §2)
"/api": {
+16
View File
@@ -5,7 +5,9 @@
"": {
"name": "new-api-key-usage",
"devDependencies": {
"@clack/prompts": "^1.8.1",
"@types/bun": "^1.4.2",
"citty": "^0.2.2",
"typescript": "^7.0.2",
},
},
@@ -180,6 +182,10 @@
"@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="],
"@clack/core": ["@clack/core@1.5.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ=="],
"@clack/prompts": ["@clack/prompts@1.8.1", "", { "dependencies": { "@clack/core": "1.5.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ=="],
"@csstools/color-helpers": ["@csstools/color-helpers@6.1.2", "", {}, "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow=="],
"@csstools/css-calc": ["@csstools/css-calc@3.4.3", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.2", "@csstools/css-tokenizer": "^4.0.2" } }, "sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA=="],
@@ -714,6 +720,8 @@
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
"citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
@@ -822,6 +830,12 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-string-truncated-width": ["fast-string-truncated-width@3.0.3", "", {}, "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g=="],
"fast-string-width": ["fast-string-width@3.0.2", "", { "dependencies": { "fast-string-truncated-width": "^3.0.2" } }, "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg=="],
"fast-wrap-ansi": ["fast-wrap-ansi@0.2.2", "", { "dependencies": { "fast-string-width": "^3.0.2" } }, "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q=="],
"fastq": ["fastq@1.20.3", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -1120,6 +1134,8 @@
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
+86 -8
View File
@@ -1,20 +1,98 @@
# 一键部署:docker compose up -d --build
# 验收:docker compose ps(migrate 应为 exited 0)
# 环境变量写在 .env(不进产物包;生产务必改密钥与 COOKIE_SECURE)
# 注意:DATABASE_URL 密码含 @ : / 等字符时需 URL 编码
services:
postgres:
container_name: naku-postgres
image: postgres:17-alpine
container_name: new-api-key-usage-pg
entrypoint: ["/bin/sh", "/entrypoint-pg.sh"]
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: new_api_key_usage
ports:
- "5432:5432"
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL in .env}
volumes:
- ./entrypoint-pg.sh:/entrypoint-pg.sh:ro
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d new_api_key_usage"]
test: ["CMD-SHELL", "pg_isready -U $$(echo $$DATABASE_URL | sed -E 's#^[^:]+://([^:]+):.*#\\1#')"]
interval: 5s
timeout: 3s
retries: 10
retries: 12
restart: unless-stopped
migrate:
container_name: naku-migrate
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-migrate"]
environment:
DATABASE_URL: ${DATABASE_URL}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
postgres:
condition: service_healthy
restart: "no"
server:
container_name: naku-server
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-server"]
environment:
PORT: "18456"
DATABASE_URL: ${DATABASE_URL}
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET:?set SK_KEY_ENCRYPTION_SECRET in .env}
NEWAPI_BASE: ${NEWAPI_BASE:?set NEWAPI_BASE in .env}
COOKIE_SECURE: ${COOKIE_SECURE:-true}
LOG_LEVEL: ${LOG_LEVEL:-info}
ADMIN_INIT_USERNAME: ${ADMIN_INIT_USERNAME:-}
ADMIN_INIT_PASSWORD: ${ADMIN_INIT_PASSWORD:-}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
engine:
container_name: naku-engine
build:
context: .
target: app
entrypoint: ["/bin/sh", "/entrypoint-app.sh"]
command: ["/app/naku-engine"]
environment:
DATABASE_URL: ${DATABASE_URL}
SK_KEY_ENCRYPTION_SECRET: ${SK_KEY_ENCRYPTION_SECRET}
NEWAPI_BASE: ${NEWAPI_BASE}
SYNC_INTERVAL_MS: ${SYNC_INTERVAL_MS:-300000}
LOG_LEVEL: ${LOG_LEVEL:-info}
MIGRATIONS_FOLDER: /app/drizzle
volumes:
- ./entrypoint-app.sh:/entrypoint-app.sh:ro
depends_on:
migrate:
condition: service_completed_successfully
restart: unless-stopped
caddy:
container_name: naku-caddy
build:
context: .
target: proxy
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
depends_on:
- server
restart: unless-stopped
volumes:
pgdata:
+7 -7
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 七篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
| 状态 | 定稿(2026-10-02;R16 修订 2026-10-10) |
| 关联 | 《交互设计-总览与共性》(§1 访问拓扑 · §3 呈现约定)·《交互设计-登录与会话》(鉴权档位与 401 分流)· 各篇表字段设计(DTO 字段权威)·《技术选型》§4(DTO 类型属 `packages/core`) |
| 产出 | 路由前缀与隔离机制 · 鉴权两档 · 错误信封 · 时间 / 数值 / 分页 / 命名约定 |
---
@@ -15,7 +15,7 @@
- JSON over HTTP,UTF-8;请求与响应体均为 `application/json`
- 一套路由代码,两个世界靠**路径前缀与凭证中间件物理隔离**(共性 §1:令牌进不了管理面、会话进不了成员视图)
- DTO 类型定义于 `packages/core`,web 以纯类型依赖引用——前后端一份契约类型(技术选型 §4 依赖规则)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除)
- **端点面向交互设计,不与数据表对齐**——每个端点对应一个功能面(页面 / 流程需要什么);表只是存储模型,按其事实性质获得恰好够用的操作面(引擎专写表纯只读、人工抄录表全 CRUD、账本实体软删除为主——**空壳例外可硬删**:无 key 归属的成员行不承载账本事实,见 R16)
## 2. 路由前缀与隔离机制
@@ -27,7 +27,7 @@
- **会话 cookie**:`HttpOnly + Secure + SameSite=Lax`,`Path=/api/admin`(「路径限管理面」裁决的落地);随请求自动携带、登出即删行
- **成员令牌**:`Authorization: Bearer <token>`;明文令牌只存在于登录壳输入与 sessionStorage(关标签即失效——《交互设计-登录与会话》§4)
- 401 分流为前端职责:管理面任意 401 → 登录页「会话已过期」;成员视图任意 401 → 登录壳「令牌无效或已停用」——后端 401 响应不带面特化信息,前端按所在面分流(共性 §1)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架与部署设计)
- **命名空间约定:`/api/*` 专属接口**——前端页面路由永不进入 `/api` 前缀;未知 `/api/*` 路径 = 接口 `404` 错误信封,非 `/api` 请求归前端托管(SPA history 回退「非 `/api` → index.html」,细则归骨架设计)
## 3. 错误信封
@@ -57,7 +57,7 @@
## 4. 时间口径
- **响应时刻字段:Unix 秒整数**(UTC 时刻,如 `1780000000`)——无时区歧义;本地/北京时间由展示层转换
- **请求时间参数**(`from` / `to` 等):ISO 8601 UTC 字符串(便于试调);范围 **左闭右开 `[from, to)`**
- **请求时间参数**(`from` / `to`、`paid_at` / `period_*` 等):**ISO 8601 带时区偏移**(如 `2026-09-08T00:00:00.000+08:00`,便于人读;亦接受同绝对时刻的 `…Z`);范围 **左闭右开 `[from, to)`**
- 存储层维持 timestamptz / 上游 Unix 秒;DTO 输出统一 `toUnixSec`
- 快捷项(最近 7 / 30 天、本订阅期间、自定义起止)是前端预置逻辑:前端算出 `from/to` 再请求,后端无快捷参数
@@ -77,12 +77,12 @@
## 7. DTO 命名
- **snake_case、与表字段同名同构**——七篇表字段设计即 DTO 字段权威,无映射层
- **snake_case、与表字段同名同构**——各篇表字段设计即 DTO 字段权威,无映射层
- 前端不做 camelCase 转换:全栈 snake_case(组件 props 是 antd 的 API、与数据 DTO 互不干扰)
## 8. 本稿拟定约定(交互设计未覆盖,请作者重点审)
1. 时间传输格式 ISO 8601 UTC 字符串(§4);
1. 时间:**请求侧** ISO 8601 带偏移(§4);**响应侧** Unix 秒整数(§4);
2. 时间范围左闭右开 `[from, to)`(§4);
3. `quota_usd` 字符串两位小数 + 后端换算责任 + 微分取整规则(§5);
4. DTO 与表字段 snake_case 同构、无映射层(§7);
+32 -20
View File
@@ -1,11 +1,11 @@
# API契约-成员视图
> 成员视图(令牌档)全部端点:令牌验证与页头、页面聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§3)。
> 成员视图(令牌档)全部端点:令牌验证与页头、总览/支付参数聚合、日志明细。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/member`、Bearer 鉴权、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构;管理面草案 §10 的裁定 1(聚合仅 `type = 2`)与裁定 2(`token_name` 取当前名)在此沿用。成员世界**无**审计、无健康带、无改密(定位边界,《交互设计-成员视图》§2)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》(草案——裁定沿用)·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `payments` 表字段设计(DTO 字段权威) |
| 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | 《API契约-公共约定》(定稿——总纲)·《API契约-管理面》·《交互设计-成员视图》·《交互设计-登录与会话》§4(登录壳)· `members` / `usage_logs` / `subscriptions` / `topups` 表字段设计(DTO 字段权威) |
| 产出 | 端点清单(3 个)、请求 / 响应字段、裁定项(§5)、遗留项(§6) |
---
@@ -15,7 +15,7 @@
| # | 端点 | 方法 | 用途 |
|---|------|------|------|
| 1 | `/api/member/me` | GET | 令牌验证 + 页头(登录壳提交的落点) |
| 2 | `/api/member/overview` | GET | 单页聚合:总用量、按来源分解、我的 key、支付参数卡 |
| 2 | `/api/member/overview` | GET | 总览与支付参数聚合:总用量、按来源分解、我的 key、支付参数列表 |
| 3 | `/api/member/logs` | GET | 日志明细(名下 key,分页) |
## 2. 令牌验证与页头
@@ -27,7 +27,7 @@
- 登录壳提交 = 前端将令牌存 sessionStorage 后调本端点(`Authorization: Bearer`):验证通过(哈希命中 + `status = active`)→ 进入成员视图;`401` → 登录壳(前端固定文案「令牌无效或已停用」,不区分停用与无效——公共约定 §2)
- **不建会话**:令牌即长期凭证(`members` 表设计 3.2——无会话语义、无登出端点);旧令牌在作者重发后于下一请求自然 `401`
## 3. 单页聚合
## 3. 总览与支付参数聚合
### GET `/api/member/overview?from&to` → 200
@@ -45,24 +45,35 @@
{ "key_id": 5, "token_name": "小张-01", "log_synced_at": "2026-10-02T08:00:00Z",
"usage": { "count": 300, "quota": 1500000, "quota_usd": "3.00" } }
],
"payments": [
{ "id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00" }
"subscriptions": [
{ "id": 3, "subscription_id": 44, "period_start": "2026-09-01T00:00:00Z",
"period_end": "2026-10-01T00:00:00Z", "quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z", "billing_domain": "carpool", "remark": "九月拼车",
"created_at": "2026-09-02T12:00:00Z" }
],
"topups": [
{ "id": 1, "upstream_id": 17, "amount": 1, "money": 2.04,
"trade_no": "USR6…", "payment_method": "alipay", "payment_provider": "epay",
"status": "success", "paid_at": "…", "billing_domain": "carpool",
"quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "…" }
]
}
```
- `usage`:该时段总用量(聚合卡);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 key 窄卡(`log_synced_at` 支撑停滞标注)
- `payments`:**支付参数卡 + 徽章展开行 + 「本订阅期间」快捷项的数据源**,判定规则见 §5 裁定 2;对成员**全字段可见**(含 `remark`——完全公开透明裁决);`payments` 全空 = 纯用量模式中性空态(前端事)
- 徽章展开行仅限「订阅 #N」徽章:「充值」不展开(余额池扣款挂不到具体某笔,展开无答案——§5 裁定 4)、「未关联」(日志缺 `subscription_id`)不可展开;降级:徽章 `subscription_id` 不在 `payments` 列表 → 「该订阅无登记信息」——空值场景各自归位(《交互设计-成员视图》§1.6)
- `usage`:该时段总用量(总览摘要条);`by_source`:订阅 / 充值分解(分段条);`keys`:我的 Key 表(`log_synced_at` 支撑停滞标注)
- `subscriptions` / `topups`:**支付参数页 + 订阅徽章弹窗 + 「本订阅期间」快捷项**的数据源,判定见 §5 裁定 2;对成员**同账本域内全字段可见**(含 `remark`);两集皆空 = 纯用量模式中性空态
- 订阅徽章弹窗仅限「订阅 #N」(查 `subscriptions`):「充值」不弹窗(余额池无逐笔)、「未关联」不可点;降级「该订阅无登记信息」
## 4. 日志明细
### GET `/api/member/logs?from&to` → 200 `{items, total}`
### GET `/api/member/logs?from&to` → 200 `{items, total, summary, models}`
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key、恒 `type = 2`(§5 裁定 1——成员世界是「我的用量」视角,错误行排查属管理面)。
分页(公共约定 §6);过滤 `from/to`(必填);范围恒为名下 key;**默认全类型**(消费 + 错误,可 `type` 筛——2 / `error`)。
可选筛选(调用日志页,与管理面 `GET /api/admin/logs` 同名同义):`api_key_id` · `model_name` · `billing_source`(`subscription` / `wallet` / `none`)· `subscription_id` · `type`(2 / `error`)。
响应附带 `summary` / `models`(与管理面同构,见《API契约-管理面》§6):`summary` 为当前筛选下条数与 **type=2** 消费用量;`models` 为时间窗 + 主体筛选下去重的模型选项(不含 `model_name` / `type` 筛选)。
明细行 DTO 与管理面 `GET /api/admin/logs` **同构**(公共约定 §7——一份 DTO 类型两处复用):
@@ -73,18 +84,19 @@
"quota": 250000, "quota_usd": "0.50",
"prompt_tokens": 1200, "completion_tokens": 800,
"use_time": 3, "is_stream": true,
"billing_source": "subscription", "subscription_id": 3
"billing_source": "subscription", "subscription_id": 3,
"raw": { "id": 12345, "request_id": "…", "other": {}, "channel": 1 }
}
```
`token_name` 取当前名(管理面裁定 2 沿用)。
`token_name` 取当前名(管理面裁定 2 沿用)。`raw` 为**完整落库行**(上游字段原貌 + 系统字段,`created_at` 为 Unix 秒)——界面「原始数据」视图的数据源;管理面 `GET /api/admin/logs` 同构带出。
## 5. 本稿裁定项(请作者重点审)
1. **成员视图明细恒 `type = 2`**——对齐聚合口径;无 `type` 筛选参数(管理面明细的全类型排查能力不进成员世界);
2. **「该时段涉及」支付记录的判定**:订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应记录 **∪ 进行中的订阅**(`period_end` 为空或未来——支撑「本订阅期间」快捷项与「该订阅无登记信息」降级);充值 = `paid_at ∈ [from, to)`(充值消耗无逐笔关联可查,按支付时间锚定);
1. **成员视图明细默认全类型**(消费 + 错误)——对齐「事实可查」;聚合仍仅 `type = 2`(用量口径不变);`type` 可筛 2 / `error`;
2. **「该时段涉及」登记的判定**(账本域隔离):成员的**我的域** = 名下 key 的 `billing_domain` 非空并集。订阅 = 该时段日志(`type = 2`)出现过的 `subscription_id` 对应 `subscriptions` 行(**事实直连**,含未标域) **∪**(进行中 **∧** `billing_domain ∈ 我的域`)——未标域进行中订阅不放行;充值 = **`billing_domain ∈ 我的域`** ∧ `paid_at ∈ [from, to)`——未标域充值不放行。管理面不受此限;
3. **明细行 DTO 与管理面同构**:同一 `LogItem` 类型,成员端经归属与 `type` 恒定过滤;
4. **「充值」徽章不展开**——余额池扣款与具体充值记录无逐笔关联,展开回答不了「这笔花在哪」(《交互设计-成员视图》§1.6 未定义处的落定:徽章展开行仅限订阅徽章,「充值」为纯标识)。
4. **「充值」徽章不弹窗**——余额池扣款与具体充值记录无逐笔关联,弹窗回答不了「这笔花在哪」(《交互设计-成员视图》§4.2:订阅徽章才开支付参数弹窗,「充值」为纯标识;行展开只放模型调用详情)。
## 6. 遗留项
+100 -31
View File
@@ -1,11 +1,11 @@
# API契约-管理面
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、支付记录、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
> 管理面(作者,会话档)全部端点:引导与会话、引擎状态、全员总览、key 管理、日志明细(下钻)、订阅权益 / 充值入账、成员管理、审计日志。《API契约-公共约定》(定稿)全部条款在此适用不再重述——前缀 `/api/admin`、会话 cookie 鉴权、错误信封、ISO 8601 UTC 左闭右开、`quota_usd` 后端换算、snake_case 同构。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-02) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计(定稿)· `api_keys` / `usage_logs` / `payments` / `qpu_history` / `members` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 状态 | 定稿(2026-10-12,作者认可实现;含 10-09 支付拆表与 R16) |
| 关联 | 《API契约-公共约定》(定稿——总纲)· 八篇交互设计 · `api_keys` / `usage_logs` / `subscriptions` / `topups` / `qpu_history` / `members` / `users` / `sessions` 表字段设计(DTO 字段权威)· `需求清单.md` §9(访问主体模型) |
| 产出 | 端点清单、请求 / 响应字段、错误码、本稿裁定项(§10)与遗留项(§11) |
---
@@ -23,13 +23,15 @@
| 7 | `/api/admin/keys` | GET / POST | key 清单 / 登记 |
| 8 | `/api/admin/keys/:id` | PATCH | 启停 / 归属变更 |
| 9 | `/api/admin/logs` | GET | 日志明细(下钻,分页) |
| 10 | `/api/admin/payments` | GET / POST | 支付记录清单 / 登记 |
| 11 | `/api/admin/payments/:id` | PUT / DELETE | 编辑 / 删除 |
| 12 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 13 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 14 | `/api/admin/members/:id` | PATCH | 停用 / 启用 |
| 15 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 16 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
| 10 | `/api/admin/subscriptions` | GET / POST | 订阅权益清单 / 登记 |
| 11 | `/api/admin/subscriptions/:id` | PUT / DELETE | 编辑 / 删除 |
| 12 | `/api/admin/topups` | GET / POST | 充值入账清单 / 登记 |
| 13 | `/api/admin/topups/:id` | PUT / DELETE | 编辑 / 删除 |
| 14 | `/api/admin/qpu/latest` | GET | 当前换算率(表单美元双输入用) |
| 15 | `/api/admin/members` | GET / POST | 成员清单 / 开户(令牌明文仅一次) |
| 16 | `/api/admin/members/:id` | PATCH / DELETE | 档案修改(name/remark)与启停 / 空壳删除(仅 `key_count = 0`) |
| 17 | `/api/admin/members/:id/token` | POST | 令牌重发(明文仅一次) |
| 18 | `/api/admin/audit-logs` | GET | 审计日志(分页) |
## 2. 引导与会话
@@ -117,12 +119,13 @@
### GET `/api/admin/keys` → 200 `[key]`
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
全量直返(基数 10–25,不分页——§10 裁定 3)。筛选参数:`member_id`(id 或 `unassigned`)、`status`、`billing_domain`(值或 `none` = 未标注)、`recent_failed=true`(引擎最近轮次失败)。排序默认数据截止降序(前端事)。
```json
{
"id": 5, "token_name": "小张-01", "status": "active",
"member_id": 1, "member_name": "小张",
"billing_domain": "carpool",
"registered_at": "2026-09-20T10:00:00Z",
"log_synced_at": "2026-10-02T08:00:00Z", "usage_synced_at": "2026-10-02T08:00:00Z",
"total_used": 600000, "total_available": 940000, "total_granted": 1540000,
@@ -134,7 +137,7 @@
### POST `/api/admin/keys` → 201 `key`
`{ "sk_key": "sk-…", "member_id": null }`——登记四态:
`{ "sk_key": "sk-…", "member_id": null, "billing_domain": null }`——登记四态:
| 结果 | 响应 |
|------|------|
@@ -145,13 +148,34 @@
### PATCH `/api/admin/keys/:id` → 200 `key`
`{ "status": "disabled" }`(启用 / 停用)或 `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
`{ "status": "disabled" }`(启用 / 停用)· `{ "member_id": 3 }`(归属变更;`null` = 归还未归属)或 `{ "billing_domain": "office" }`(账本域变更;`null` = 未标域)——部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9 枚举)。非法值 `422`;key 不存在 `404`;`member_id` 引用不存在成员 `422`(`fields`)。
## 6. 日志明细(管理面下钻)
### GET `/api/admin/logs?from&to` → 200 `{items, total}`
### GET `/api/admin/logs?from&to` → 200 `{items, total, summary, models}`
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
分页(`page/page_size`,公共约定 §6)。过滤:`from/to`(必填)、`api_key_id`、`member_id`(经 `api_key_id → api_keys.member_id` 联查**当前归属**——行级归属语义)、`model_name`、`billing_source`、`subscription_id`、`type`。默认全类型(错误行排查入口——§10 裁定 1 的另一半)。
**响应附加块**(汇总条与模型下拉的数据源;成员端 `/api/member/logs` 同构):
```json
{
"summary": {
"hit_count": 131, "usage_count": 128, "error_count": 3,
"quota": 1250000, "quota_usd": "2.50"
},
"models": ["claude-sonnet-4", "gpt-5"]
}
```
| 字段 | 口径 |
|------|------|
| `hit_count` | 当前筛选命中总数(= `total`;界面文案「请求次数」) |
| `usage_count` / `error_count` | 同筛选下 `type=2` / `type≠2` 条数 |
| `quota` / `quota_usd` | 同筛选下 **type=2** 行合计;美元按行 QPU 微分(公共约定 §5) |
| `models` | 时间窗 + 主体/来源/订阅筛选下去重 `model_name`(**不含** `model_name` / `type`),升序,供筛选下拉 |
裁定:条数跟全部筛选(含 `type` / `model_name`);用量字段恒 `type = 2`(即使请求 `type=error`)——界面标签「消费用量」。
```json
{
@@ -166,30 +190,66 @@
`token_name` 取**当前名**(联查 `api_keys`,与「展示名以当前为准」对齐——§10 裁定 2);`username` / `channel` / `ip` / `token_id` / `request_id` / `other` 等其余表字段暂不出 API,界面需要时再扩。
> **界面落点待补**:总览「行点击 → Key 管理页该 key 详情」——交互设计无成篇的「key 详情」视图,本端点先行、界面归交互补篇或实现期定(§11 遗留 1)。
> **界面落点**:《交互设计-调用日志》——管理面独立查询页;总览/Key/成员筛选深链 + 行内回跳。
## 7. 支付记录
## 7. 订阅权益与充值入账
### GET `/api/admin/payments` → 200 `[payment]`
> 原 `payments` 单表已拆(`payments建模评估.md` 方案 B):**订阅 = 权益**(期间 + 额度预算 + 可选付款事实),**充值 = 入账事件**。UI 菜单仍叫「支付记录」,API 按资源分组。
全量直返(年十位数,不分页),时间倒序(后端排序)。
### GET `/api/admin/subscriptions` → 200 `[subscription]`
全量直返(年个位数,不分页),`period_start` 倒序。
```json
{
"id": 3, "kind": "subscription", "paid_at": "2026-09-01T00:00:00Z",
"paid_amount": 170, "remark": "九月拼车",
"id": 3, "subscription_id": 44,
"period_start": "2026-09-01T00:00:00Z", "period_end": "2026-10-01T00:00:00Z",
"subscription_id": 3, "quota_granted": 12500000, "quota_usd": "25.00"
"quota_granted": 12500000, "quota_usd": "25.00",
"paid_amount": 170, "paid_at": "2026-08-28T10:00:00Z",
"billing_domain": "carpool",
"remark": "九月拼车", "created_at": "2026-09-02T12:00:00Z"
}
```
`quota_usd` 按支付时刻(`paid_at`)QPU 换算(§10 裁定 4);`kind = topup` 行 `period_*` / `subscription_id` 为 `null`。
- `quota_usd` 换算锚点优先级:`paid_at` → 否则 `created_at`(§10 裁定 4 修订)
- `paid_amount` / `paid_at` 可空(付款事实未知)
### POST `/api/admin/payments` → 201 `payment`
### POST `/api/admin/subscriptions` → 201 `subscription`
`kind` 分形态必填校验(`422`):公共 `{kind, paid_at, paid_amount, remark?}`;`subscription` 追加必填 `period_start` / `subscription_id` / `quota_granted`(`period_end` 可空 = 进行中);`topup` 追加必填 `quota_granted`。表单美元双输入为前端换算(按 `qpu/latest` 预览),落库恒原始 quota(表设计 3.6)。
必填:`subscription_id` · `period_start` · `quota_granted`;可空:`period_end`(=进行中)· `paid_amount` · `paid_at` · `billing_domain` · `remark`。美元双输入为前端换算,落库恒原始 quota。列表筛选支持 `billing_domain`(值或 `none`)。
### PUT `/api/admin/payments/:id` → 200 `payment` / DELETE → 204
### PUT `/api/admin/subscriptions/:id` / DELETE → 204
### GET `/api/admin/topups` → 200 `[topup]`
全量直返,`paid_at` 倒序。对齐上游充值单;**事件时刻单列**(上游 `complete_time`),**无 `quota_granted` 入库列**:
```json
{
"id": 1, "upstream_id": 17,
"amount": 1, "money": 2.04,
"trade_no": "USR6NOLqTPGa1791534153",
"payment_method": "alipay", "payment_provider": "epay",
"status": "success",
"paid_at": "2026-10-09T12:22:56Z",
"billing_domain": "carpool",
"quota_derived": 500000, "quota_usd": "1.00",
"remark": "", "created_at": "2026-10-09T13:00:00Z"
}
```
`quota_derived` / `quota_usd` 为**派生**(`amount × qpu`,锚点 `paid_at`),不入库。
### POST `/api/admin/topups` → 201 `topup`
必填:`paid_at` · `amount` · `money`;可空:`upstream_id` · `trade_no` · `payment_method` · `payment_provider` · `status` · `billing_domain` · `remark`。列表筛选支持 `billing_domain`(值或 `none`)。
### PUT `/api/admin/topups/:id` / DELETE → 204
### 平台辅助(登录代拉,密码不入库)
已有:`POST /api/admin/platform/login` · `POST /api/admin/platform/subscriptions`。
**新增**:`POST /api/admin/platform/topups` body `{ access_token, page, page_size }` → `{ items, total, page, page_size }`——透传 `/api/user/topup/self` **单页**;前端传统分页、可翻至尽头、无时间参数、无页数硬上限(见交互篇 §4.1)。不做后台定时同步。
编辑全字段、删除有确认(前端);改错即修,均落审计。
@@ -223,7 +283,16 @@
### PATCH `/api/admin/members/:id` → 200 `member`
`{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)。
部分更新,单字段或并存均可,审计按实际变更字段各落一行(§9):
- `{ "status": "disabled" }` / `active`——停用即成员视图访问立即失效(令牌校验含状态判断)
- `{ "name": "小张" }` / `{ "remark": "同事" }`(R16 档案修改)——`name` 冲突 `409 member_name_taken`;不接受 `token_hash` / `created_at` / `id`
非法值 `422`;成员不存在 `404`。
### DELETE `/api/admin/members/:id` → 204
空壳删除(R16):**仅当该成员当前 `key_count = 0`**(无任何 `api_keys.member_id` 指向)才物理删除行;否则 `409 member_has_keys`(`fields` 可附 `key_count`)。删除后名称 UNIQUE 释放、令牌一并作废。名下有 key 的清理路径 = 停用(软删除语义不变)。均落审计。
### POST `/api/admin/members/:id/token` → 201
@@ -248,8 +317,8 @@
| 域 | action 枚举 |
|----|------------|
| key | `key.register` / `key.enable` / `key.disable` / `key.reassign`(归属变更) |
| payment | `payment.create` / `payment.update` / `payment.delete` |
| member | `member.create` / `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` |
| payment | `subscription.create` / `subscription.update` / `subscription.delete` / `topup.create` / `topup.update` / `topup.delete`(UI 菜单「支付记录」) |
| member | `member.create` / `member.update`(档案改 name/remark)/ `member.enable` / `member.disable` / `member.token_issue`(开户自动签发)/ `member.token_reissue` / `member.delete`(空壳硬删) |
| account | `account.password_change` / `account.init`(系统,operator null) |
每个管理面写操作各落一行;查询类与成员侧动作不留痕。**表结构归技术稿(members / api_keys 表设计既有标注),立稿时以本文 DTO 字段为对齐锚点**(§11 遗留 2)。
@@ -258,8 +327,8 @@
1. **用量聚合口径仅 `type = 2`(消费行)**——usage_logs §5 待定「type 过滤策略」落定:聚合与统计只计消费行(错误行入聚合即失真);明细查询默认全类型、可按 `type` 筛(错误排查入口);
2. **日志明细 `token_name` 取当前名**(联查 `api_keys`;快照名存库不出 API)——表设计 2.3 待定「展示口径」落定;
3. **有限基数清单不分页**:`keys` / `members` / `payments` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
4. **支付记录 `quota_usd` 按支付时刻(`paid_at`)QPU 换算**——对齐「按日志时刻取值」的历史正确性原则;
3. **有限基数清单不分页**:`keys` / `members` / `subscriptions` / `topups` 直返全量数组;公共约定 §6 的「列表类分页」指无界列表(`logs` / `audit-logs`);
4. **`quota_usd` 换算锚点**:充值恒 `paid_at`(= 上游 `complete_time`);订阅优先 `paid_at`、否则 `created_at`——对齐「按日志时刻取值」的历史正确性原则;
5. **公共约定随本稿与审阅增补四处**:状态表加 `429`(登录节流)、错误信封允许端点附机器可读附注字段(`409 key_already_registered` 附 `key_id`)、`/api` 命名空间专属接口(页面路由永不进入)、端点面向交互设计不与数据表对齐。
## 11. 遗留项(归后续立稿或作者裁决)
+3 -2
View File
@@ -32,6 +32,7 @@
| `key_ciphertext` | bytea | 登记时 AES-256-GCM(管理员粘贴输入),nonce + 密文 + tag 打包 | 调上游时解密原文(唯一用途) |
| `status` | text | 管理面启停(引擎只读) | `active` / `disabled`;轮询清单 = active;disabled 数据保留、仅移出轮询 |
| `member_id` | bigint FK NULL | 归属登记(管理通道) | → `members.id`;成员视图与按成员统计的锚点;空 = 未归属 |
| `billing_domain` | text NULL | 人工标注(登记 / 行内编辑) | **账本域标签**(如 `carpool` / `office`)——区分用途的自由文本,非身份、非权限;空 = 未标域;成员可见性与支付记录的 `billing_domain` 对齐 |
| `registered_at` | timestamptz | 登记时写入 | 空态文案「数据自该 key 登记起累积」 |
| `log_synced_at` | timestamptz NULL | 日志同步成功时写入 | **「数据截止 HH:mm」的唯一来源**;同步失败的显形信号(停滞) |
| `usage_synced_at` | timestamptz NULL | 额度采集成功时写入 | 额度信息的新鲜度(UI 若做)、排查 |
@@ -55,7 +56,7 @@
- `key_hash`(HMAC-SHA256,不可逆):只服务「命中判定」——没有它,每次查询都要把全部 key 送去上游验证
- `key_ciphertext`(AES-256-GCM,可解密):唯一用途是调上游
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**(记入部署文档)
- 密钥 `SK_KEY_ENCRYPTION_SECRET` 经 env 注入(HKDF 派生 hmac / aes 子密钥);**密钥丢失 = 全部 key 需重新登记**
> **注解:为什么是两个字段(给非密码学背景的读者)**
>
@@ -97,7 +98,7 @@
### 3.7 表名:`api_keys`
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `payments` / `qpu_history`)风格一致
- 理由:与页面 `/manage/api-keys` 同词对齐;`sk-` 只是 key 的实际格式前缀(不属表名职责),`api_keys` 为领域通用词,与其余各表(`usage_logs` / `subscriptions` / `topups` / `qpu_history`)风格一致
### 3.8 归属列 `member_id`:外键在本表,且可空
+22 -11
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9 |
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | `docs/design/api_keys表字段设计.md` 3.9(归属列指向本表)· `docs/design/需求清单.md` §9(访问主体模型)· R16(档案可改、空壳删除) |
| 范围 | 列结构 + 语义 + 写入时机;令牌生成细节归技术稿;方言映射归数据访问层设计 |
| 字段数 | 7 = 1 主键 + 6 业务字段 |
@@ -21,13 +21,13 @@
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `name` | text UNIQUE | 管理面录入 | 显示名——账目与视图中的署名 |
| `remark` | text | 管理面录入 | 备注(联系方式、是谁等;可空) |
| `status` | text | 管理面 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断) |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次) |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索) |
| `created_at` | timestamptz | 开户时 | |
| `id` | bigserial PK | 自增 | 不可改 |
| `name` | text UNIQUE | 管理面录入(可改) | 显示名——账目与视图中的署名;开户后可改(R16) |
| `remark` | text | 管理面录入(可改) | 备注(联系方式、是谁等;可空);开户后可改(R16) |
| `status` | text | 管理面启停 | `active` / `disabled`——停用即成员视图访问立即失效(令牌校验含状态判断);**不并入档案编辑** |
| `token_hash` | text | 管理面生成令牌时 | 成员视图的访问凭证(哈希存储,明文只在生成时展示一次);仅随重发更新 |
| `token_issued_at` | timestamptz | 同上 | 当前令牌的签发时间(审计线索);仅随重发更新 |
| `created_at` | timestamptz | 开户时 | 不可改 |
## 3. 决策记录(含理由)
@@ -45,17 +45,28 @@
账目署名不可歧义。
### 3.4 软删除(`status`)而非物理删除
### 3.4 软删除(`status`)为主,空壳例外硬删(R16)
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。
成员名下的 key 与历史用量是账本的一部分——「退拼」是停用(保留行、停访问),不是抹掉事实。**默认无物理删除**。
**空壳例外(R16)**:当前 `key_count = 0`(无任何 `api_keys.member_id` 指向本行)时允许硬删——该行不承载账本事实,删了只清掉误建/未使用的开户壳,并释放 UNIQUE 名称。名下有 key 一律拒绝删除(只能停用)。硬删与软删除不冲突:账本实体的默认语义仍是软删除,空壳删是「无账本可保」时的清理通道。
### 3.5 档案字段可改(`name` / `remark`)(R16)
- **动机**:开户后挂 key 前发现名称不对(误建、占位名)——已发出令牌时「改名」优于「删了重建」(重建会作废旧令牌)
- **可改字段收窄**:仅 `name` / `remark`;`status` / 令牌 / `id` / `created_at` 各走各的生命周期,不并入档案编辑
- **`name` 仍 UNIQUE**(3.3 不变);冲突 `409 member_name_taken`
- **展示口径 = 当前名**:日志 / 总览 / 成员视图联查实时取 `members.name`(与 `api_keys.token_name` 的「当前名为准」同则)——历史署名随改名漂移,可接受
## 4. 写入时机
| 时机 | 动作 |
|------|------|
| 开户(管理面) | INSERT(`name` / `remark`;生成令牌 → `token_hash` + `token_issued_at`) |
| 档案修改(R16) | UPDATE `name` 和/或 `remark` |
| 停用 / 启用 | UPDATE `status` |
| 重发令牌 | UPDATE `token_hash` + `token_issued_at`(旧令牌即失效) |
| 空壳删除(R16) | DELETE(仅 `key_count = 0`;名下有 key 拒绝) |
以上全部为管理面写操作,均落**审计日志**(审计为独立需求,表结构归技术稿)。
+141
View File
@@ -0,0 +1,141 @@
# payments 建模评估:充值与订阅要不要拆表
> 对「一张 `payments` 同构装下订阅 + 充值」的全面复盘。触发点:`paid_at` 语义在订阅行上更像 `created_at`,按付费时间筛会在**后补录**时丢行。**结论:方案 B 拆表已采纳**(2026-10-09)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-09,作者采纳方案 B) |
| 关联 | `subscriptions表字段设计.md` · `topups表字段设计.md`(替代原 `payments` 单表)· `NewAPI-接口范围.md` §2.⑨ §4.3 · `需求清单.md` R7/R8/R9 · `usage_logs表字段设计.md` 3.6 |
| 依据 | 上游订阅实体字段(钱包页 / `GET /api/subscription/self`)· 日志 `billing_source` / `subscription_id` · 现行登记与筛选交互 |
---
## 1. 领域里其实有两种东西
NewAPI 侧(接口参考 §4.3、日志 `other`):
| 上游概念 | 本质 | 时间轴 | 与消耗的关系 |
|----------|------|--------|--------------|
| **订阅**(`all_subscriptions[].id`) | **权益包**:`start_time`/`end_time` 覆盖期 + `amount_total`/`amount_used` 额度预算 + `created_at` | **期间** | 日志 `billing_source=subscription` + `subscription_id` 直连 |
| **充值**(`LogTypeTopup=1`) | **入账事件**:钱 → 钱包额度;账号级、无 token_id | **事件时刻** | 日志 `billing_source=wallet`,**无**逐笔 `subscription_id`(余额池) |
注意:上游**没有**「订阅付款单」——订阅的 `created_at` 是权益创建时刻,不是「作者何时把钱付给组织者」。后者只存在于社交层,正是 `payments` 要手工抄录的「支付事实」。
因此登记行混合了两类事实:
1. **权益事实**(订阅:编号、期间、总额度)——来自钱包页,客观
2. **金钱事实**(谁付了多少)——来自支付凭证/转账,社交层,**可与权益不同日**(预付、分期、事后补账)
## 2. 现行单表哪里拧着
`payments` 九列里约一半是「订阅专用可空」:
| 列 | 订阅 | 充值 | 问题 |
|----|------|------|------|
| `kind` | `subscription` | `topup` | 判别式列(合法但胖) |
| `paid_at` | 混用:真实付款日 **或** 登记日 | 真实充值日 | **语义分叉**(本次痛点) |
| `paid_amount` | 套餐价(可能与期间不同日) | 充值额 | 尚可 |
| `period_start/end` | 必填 | NULL | 可空列 |
| `subscription_id` | 必填 | NULL | 可空列 |
| `quota_granted` | 套餐总额度 | 充值换得额度 | 语义都是「换得的量」,可共用 |
| `remark` | ✓ | ✓ | |
| (无) | **登记时刻** | **登记时刻** | 补录/审计缺失 |
连带后果:
- **筛选**:按 `paid_at` 筛「这段时间的账」→ 后补录的订阅落在补录日,对不上用量窗口;按期间筛又对不上充值(无期间)。
- **QPU 换算锚点**:契约写「按 `paid_at` 换算 `quota_usd`」——若 `paid_at` 在订阅行上是登记日,换算率锚点也跟着漂。
- **校验**:kind 分支必填(契约已做),但类型系统层仍是「全可空 + 运行时 if」。
## 3. 三个方案
### 方案 A:单表保留,时间语义拆开(小改)
```
payments
kind, paid_amount, remark, quota_granted ← 共用
created_at timestamptz NOT NULL ← 新增:登记入库时刻
paid_at timestamptz? NULLABLE ← 仅「钱出手」时刻;订阅可空
period_start/end, subscription_id ← 仅订阅
```
| 筛选口径 | 规则 |
|----------|------|
| 登记时间 | `created_at`(后补录可查) |
| 充值 | `paid_at ∈ [from,to)` |
| 订阅 | 期间重叠:`period_start < to AND (period_end IS NULL OR period_end > from)` |
- **利**:迁移小(加一列 + 语义清洗存量 `paid_at`);API/DTO 形状基本不动。
- **弊**:仍是判别式胖行;「订阅的付款日」与「权益期间」继续挤在同一行心智里。
### 方案 B:拆两张表(对齐上游)
```
subscriptions topups
id PK id PK
subscription_id UNIQUE upstream_id? ← 上游充值单 id
period_start NOT NULL amount NOT NULL ← 平台计费金额(充值额)
period_end NULL money NOT NULL ← 实付
quota_granted NOT NULL trade_no / payment_* ← 渠道事实
paid_amount NULL paid_at NOT NULL ← = 上游 complete_time(唯一事件时刻)
paid_at NULL status
remark remark
created_at NOT NULL created_at NOT NULL
```
(2026-10-09 实测修正:上游充值单**无 `quota_granted`**——`amount × qpu` 派生;事件时刻**只留一条** `paid_at`=`complete_time`,`create_time` 不入库。)
- **利**:字段全非空(或意义明确);时间轴各归各;与 NewAPI「权益 vs 入账」同构;筛选规则按表定义,无 if-else。
- **弊**:登记 UI 两种表单(可仍一页两 Tab);「统一支付流水」要 UNION 视图;迁移把现表按 `kind` 拆走。
### 方案 C:三张表(金钱流水与权益彻底解耦)——**不建议**
`money_events` + `subscriptions` + 关联。年登记量个位数~十位数,过重。
## 4. 建议
**采用方案 B(拆表),展示层合并流水。**
理由摘要:
1. **领域本来就是两种东西**——上游也是。单表同构是登记方便,不是概念等价。
2. **时间轴不同**是刚性的:权益用期间、充值用事件、补录用登记时刻;塞进一列必然语义污染。
3. 规模极小(设计稿:年十行级)——拆表成本几乎只有代码路径,没有分片/联查负担。
4. `paid_at` 问题在 B 里消失:`topups.paid_at` 恒为事件;`subscriptions.paid_at` 明确为「套餐付款日,可空」;`created_at` 两表都有。
**统一入口**(交互不变感):
- 管理面「支付记录」页 = 两 Tab(订阅 / 充值)或一张 UNION 列表(按时间轴分别标注「期间 / 付费 / 登记」)。
- 成员「支付参数」页同源两集,R12 弹窗仍只挂订阅。
- 「该时段涉及」:订阅看期间重叠 ∪ 进行中;充值看 `paid_at`;补录审计看 `created_at`。
**字段语义钉死(两表通用原则)**:
| 时刻 | 含义 | 谁写 |
|------|------|------|
| `created_at` | 作者按下「登记」的时刻 | 系统 |
| `paid_at` | 钱真实出手时刻(凭证上的时间) | 人工;不知则空 |
| `period_*` | 用量覆盖期 `[start, end)` | 人工(抄钱包页) |
**迁移要点**(若采纳):
1. 存量 `kind=subscription` 行 → `subscriptions`;`kind=topup` → `topups`。
2. 存量订阅行的 `paid_at`:若当时填的是登记日 → 迁到 `created_at`,`paid_at` 置空(或作者按凭证补真付款日)。
3. `quota_usd` 换算锚点:优先 `paid_at`(有则),否则 `created_at` 或 `period_start`——契约里写死优先级。
4. 日志 `subscription_id` 直连改为指向 `subscriptions.subscription_id`(语义不变)。
5. 纯用量模式(零登记)不受影响。
**方案 A 的适用情形**:只想先止血筛选/补录、暂不动 CRUD 与 API 形状——则 A 是可接受的过渡;终态仍建议 B。
## 5. 与既有裁决的关系
| 裁决 | 拆表后 |
|------|--------|
| R7 充值与订阅同构(登记形态) | **展示/哲学同构**(只存事实、无分摊)不变;**物理模型分表** |
| R8 `subscription_id` 直连 | 不变,指向订阅表 |
| R9 币种无关、只存事实 | 不变 |
| 「payments」表名 | 订阅表可名 `subscriptions`;充值表 `topups`;若保留对外「支付记录」只作 UI 名 |
---
**已采纳方案 B(2026-10-09)**:表设计见 `subscriptions表字段设计.md` / `topups表字段设计.md`;契约与交互随拆表修订。
-98
View File
@@ -1,98 +0,0 @@
# payments 表字段设计
> 数据模型第三张表:支付记录——每笔支付事件(订阅购买 / 充值)一行。平台只存**事实**(支付金额、换得额度、来源参数),「支付金额与额度之间的比率」是查询时的派生视图,不存储、不做任何分摊计算。本文记录字段清单、语义与决策理由。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `docs/design/usage_logs表字段设计.md` 3.6(`subscription_id` 直连与本表)· `docs/design/qpu_history表字段设计.md`(quota → 额度美元的换算时间线)· `docs/references/NewAPI-接口范围.md` §2.⑨(`other.subscription_*` 字段依据) |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层设计 |
| 字段数 | 9 = 1 主键 + 8 人工录入(支付 4 · 期间 2 · 订阅 2) |
---
## 1. 表定位
- **一行 = 一笔支付事件**(订阅购买 / 充值)——同一模型兼容两种形态(`kind` 列区分)
- **本表是「只有作者知道」的信息的唯一落点**:支付金额、额度、订阅 ID——上游没有免 auth 接口可查,全靠作者手工抄录
- **币种无关**:`paid_amount` 是支付金额数值,**系统不预设币种**——币种是使用者之间的约定;「金额 ↔ 额度」的比率同样是派生视图(查询时相除即得)
- **不做分摊计算**:平台不把支付金额按用量比例分摊到日志或成员——分账是社交层问题;本表与日志的交汇只发生在**事实关联**(`subscription_id` 直连)与**参数展示**(订阅信息对使用者完全公开透明)
- 数据规模:年个位数到十位数行——无需索引设计
## 2. 字段清单
### 2.1 主键与支付字段(5 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | |
| `kind` | text | 人工选择 | `subscription`(订阅)/ `topup`(充值) |
| `paid_at` | timestamptz | 人工填写 | 支付时间 |
| `paid_amount` | numeric | **人工必填** | **支付金额**(币种无关——数值本身,币种由使用者约定) |
| `remark` | text | 人工填写 | 备注 |
### 2.2 期间与订阅字段(4 个)
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `period_start` | timestamptz? | **人工录入**(钱包页面) | 仅订阅:该笔覆盖的用量期间起点;左闭 |
| `period_end` | timestamptz? | **人工录入**(钱包页面) | 期间终点;左开;未结束可空 |
| `subscription_id` | bigint? | **人工录入**(钱包页面「订阅 #N」) | 上游订阅实例 ID;与日志 `subscription_id` 直连(事实关联,不推算) |
| `quota_granted` | bigint? | **人工录入**(钱包页面「总额度」/ 充值记录) | 本笔换得的额度——**单位钉死为原始 quota 整数**(如 $25 额度 = 12,500,000;见 3.6) |
## 3. 决策记录(含理由)
### 3.1 登记 = 手工抄录(来源只有两处)
支付记录全部由作者手工录入,来源仅两处:**支付凭证**(`kind` / `paid_at` / `paid_amount`)与**钱包页面**(订阅 ID / 期间 / 总额度)。订阅关键信息只有作者可见(没有免 auth 接口可查)——系统不做回填与推断,日志观测仅用于核对提示(见 §4);必填 / 可空的校验细节归技术稿。
### 3.2 只存事实,不存派生数
- `paid_amount` 与 `quota_granted` 是不可替代的事实;「金额 ↔ 额度」的比率是派生数——查询时相除即得,事后核对永远可用;只存比率会丢失核对依据
- **日志同样不加任何金额 / 比率列**(`usage_logs` 零分摊字段):把支付金额按用量分摊到日志行的任何计算都不做——分摊口径是社交层问题,账本上只有客观事实
- 关联维度(`billing_source` / `subscription_id`)已在日志侧提为独立列(见 `usage_logs表字段设计.md` 3.6),统计按事实关联
### 3.3 为什么额度也手工录入(不采用日志感知)
日志逐条自带 `subscription_total`(实测),但回填它需要「订阅 ID → 该 ID 日志 total」的关联逻辑;钱包页面已直接给出总额度——手工抄录一步到位。**系统对支付记录零回填、零推断**,观测仅用于核对(见 §4)。
### 3.4 币种无关(不预设人民币)
`paid_amount` 是纯数值。作者记账用什么货币是他的事,展示层的货币符号是界面约定——数据层与币种彻底解耦,更换支付渠道 / 币种时零 schema 变化。
### 3.5 命名与类型
- 表名 `payments`(支付记录):订阅与充值同构,`purchases`(购买)只覆盖前者,`payments` 是两者的上位词
- 金额列 `numeric`(金额不用浮点);时间列统一 `timestamptz`
- 期间左闭右开(`[period_start, period_end)`)——与全项目期间惯例一致
- 订阅实例 ID 与日志同名 `subscription_id`——同一体系、同一含义(直连即用此列)
### 3.6 `quota_granted` 的单位:原始 quota,不是美元
- **存真值,不存换算快照**:上游订阅套餐的额度本体是 quota 整数,「$25」只是界面显示形态(quota ÷ `quota_per_unit`,随 `quota_display_type` 而变)——本列存本体
- **域一致性**:日志的 `quota` 同为原始整数——`SUM(usage_logs.quota)` 与 `quota_granted` 同域直接可比,零换算;若存美元则每次比较都要过 ÷QPU,而 QPU 是时间线变量(`qpu_history`),凭空引入取值时刻问题
- **类型精确**:quota 恒为整数;美元额度可能有小数,`bigint` 装不下
- **录入体验归交互层**:钱包页面显示美元形态——管理面表单支持输入美元、按当时 QPU 换算落库(或展示两态),换算逻辑不进数据模型
- 美元形态随时可派生:`quota_granted ÷ QPU`(展示层)
- **与 qpu_history 无概念重复**:quota 是**量**,QPU 是**换算率**——记录量不等于记录单位定义(记「¥170」不涵盖「1 美元 = 6.8 人民币」)。真值域(quota)不携带任何换算,换算率只在一处(`qpu_history` 时间线),美元永远现算现用;反过来若直接存美元,每个值都是某次换算的快照、各自隐含一个换算时刻——那才是真正的冗余,且上游改 QPU 后与日志统计失去同域可比性
## 4. 写入时机与维护
| 时机 | 动作 |
|------|------|
| 人工登记(管理通道) | INSERT:录入支付信息(时间 / 金额)与订阅信息(ID / 期间 / 总额度,抄自钱包页面)→ 确认 |
| 系统观测(仅核对) | 日志观测到的订阅 ID / `subscription_total` 仅作核对提示(如「观测到 #N 未对应任何支付记录」——即「未关联」分组的健康信号),不参与回填 |
| 编辑 / 删除 | 数据为人工登记,改错即修 |
## 5. 展示层边界(界面设计的输入)
- **订阅与支付参数对使用者完全公开透明**(访问主体模型裁决):期间、额度、支付金额、订阅 ID——成员视图与管理面同源可见;不存在「管理面独占的金额信息」
- 「按订阅维度统计」(某笔订阅的消耗占比等)数据天然支持——按 `subscription_id` 直连聚合即可
- 成员视角的日志行携带所属订阅/充值信息的具体形态,交互设计期定(见 `docs/design/需求清单.md` R12)
## 6. 待定事项(关联后续环节)
| 事项 | 归属 |
|------|------|
| 核对提示(观测到的订阅 ID ↔ 已录入记录比对)的交互 | 交互设计(待立) |
| 充值(`topup`)的 `quota_granted` 抄录来源(充值记录页)核实 | 实现期真实数据核查 |
@@ -0,0 +1,65 @@
# subscriptions 表字段设计
> 数据模型:**订阅权益登记**——一行 = 一笔上游订阅实例(覆盖期 + 额度预算 + 可选的套餐付款事实)。与 `topups`(充值入账事件)分表,对齐 NewAPI「权益 vs 入账」两分(见 `payments建模评估.md`,方案 B 已采纳)。平台只存**事实**,不做分摊计算。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | `topups表字段设计.md`(充值事件)· `payments建模评估.md`(拆表裁决)· `usage_logs表字段设计.md` 3.6(`subscription_id` 直连)· `qpu_history表字段设计.md` · `docs/references/NewAPI-接口范围.md` §4.3 |
| 范围 | 列结构 + 语义 + 写入时机;方言映射归数据访问层 |
| 字段数 | 9 = 1 主键 + 1 上游编号 + 2 期间 + 1 额度 + 2 付款(可空) + 1 备注 + 1 登记时刻 |
---
## 1. 表定位
- **一行 = 一笔上游订阅实例**(权益),不是「一张付款单」
- 权益事实(`subscription_id` / 期间 / 总额度)抄自钱包页(`GET /api/subscription/self`);金钱事实(付了多少、何时付)来自支付凭证,**可空**——分期、赠送、未掌握凭证时允许不填
- `subscription_id` 与日志 `other.subscription_id` **同体系直连**(事实关联,不推算)
- 数据规模:年个位数行——无需独立索引设计(`subscription_id` UNIQUE 足够)
## 2. 字段清单
| 列 | 类型 | 值来源 | 说明 |
|----|------|--------|------|
| `id` | bigserial PK | 自增 | 本地主键 |
| `subscription_id` | bigint UNIQUE | 人工录入(钱包页「订阅 #N」) | 上游订阅实例 ID;日志直连键 |
| `period_start` | timestamptz | 人工录入 | 用量覆盖期起点;左闭 |
| `period_end` | timestamptz? | 人工录入 | 期间终点;左开;未结束可空 = 进行中 |
| `quota_granted` | bigint | 人工录入(钱包页 `amount_total`) | 套餐总额度——**原始 quota 整数**(与日志 `quota` 同域;充值侧无此列,额度由 `amount × qpu` 派生) |
| `paid_amount` | numeric? | 人工录入(支付凭证) | 该套餐实付金额;币种无关纯数值;未知可空 |
| `paid_at` | timestamptz? | 人工录入(支付凭证) | **钱出手时刻**(非登记时刻);未知可空 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(涉及该订阅 `subscription_id` 的日志事实直连除外) |
| `remark` | text | 人工填写 | 备注 |
| `created_at` | timestamptz | 系统 | **登记入库时刻**(补录审计) |
## 3. 时刻语义(三时刻钉死)
| 时刻 | 含义 | 谁写 |
|------|------|------|
| `created_at` | 作者按下「登记」 | 系统 |
| `paid_at` | 钱真实出手(凭证时间) | 人工;不知则空 |
| `period_start` / `period_end` | 用量覆盖期 | 人工(抄钱包页) |
筛选约定:**用量窗口看期间重叠**;**补录审计看 `created_at`**;**付款账看 `paid_at`(有则)**。
## 4. 决策摘要
- **不与充值同表**:时间轴不同(期间 vs 事件);见 `payments建模评估.md` 方案 B
- **只存事实、不存比率**:`paid_amount` 与 `quota_granted` 相除得比率;不落分摊
- **额度存原始 quota**(原 payments 3.6):与日志同域可比;美元展示现算
- **`paid_amount` / `paid_at` 可空**:社交层付款信息允许缺失;不阻塞权益登记
- **多订阅期间可重叠**(R8):不设互斥约束
## 5. 写入与维护
| 时机 | 动作 |
|------|------|
| 人工登记 | INSERT(管理通道);来源:钱包页 JSON 预填 + **辅助登录选用**(按 `subscription_id` 去重,已登记灰显)+ 支付凭证 |
| 编辑 / 删除 | 改错即修(人工数据) |
| 日志观测 | 仅核对提示(观测到 #N 无登记),不回填 |
## 6. 对外边界
- 参数对成员**同账本域内可见**(含 `remark` / 金额)——同域透明、跨域隔离;未标域默认对成员隐藏,例外:该订阅 `subscription_id` 出现在本人名下 key 的 `type = 2` 日志中(事实直连)
- 无「应付」、无分摊字段
+78
View File
@@ -0,0 +1,78 @@
# topups 表字段设计
> 数据模型:**充值入账事件**——一行 = 一次「钱 → 账号额度」的入账。与 `subscriptions`(订阅权益)分表(见 `payments建模评估.md`,方案 B)。字段对齐上游实测 `GET /api/user/topup/self`(2026-10-09 样本);**无 `quota_granted` 列**(派生),**事件时刻只存一条**(上游 `complete_time`,即平台列表展示的时间)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-12,作者认可实现) |
| 关联 | `subscriptions表字段设计.md` · `payments建模评估.md` · `NewAPI-接口范围.md` §4.5 · `qpu_history表字段设计.md` |
| 范围 | 列结构 + 语义 + 写入时机 |
| 字段数 | 10 = 1 主键 + 8 上游/业务事实 + 1 登记时刻 |
---
## 1. 表定位
- **一行 = 一次充值入账**(事件);时间主轴 = **`paid_at`**(上游 `complete_time`,平台展示时刻)
- 与消耗的关系:钱包/账号余额池,**无法**逐笔对应模型日志(`billing_source=wallet` 无 `subscription_id` 是正常态)
- 不存 `quota_granted` 真值列(上游没有;见 §3)
## 2. 字段清单
| 列 | 类型 | 上游字段 | 说明 |
|----|------|----------|------|
| `id` | bigserial PK | — | 本地主键 |
| `upstream_id` | bigint? | `id` | 上游充值单 ID(对账;手工补录可空) |
| `amount` | numeric | `amount` | **平台计费金额**(充值记账额,样本 `1`)——与实付 `money` 之间由**管理员约定汇率**换算;**不标币种/单位** |
| `money` | numeric | `money` | **实付金额**(样本 `2.04`);同样不预设币种 |
| `trade_no` | text? | `trade_no` | 交易号 |
| `payment_method` | text? | `payment_method` | 如 `alipay` |
| `payment_provider` | text? | `payment_provider` | 如 `epay` |
| `status` | text? | `status` | 如 `success`;失败单一般不入账本 |
| `paid_at` | timestamptz | **`complete_time`** | **入账时刻(唯一事件时间)**——平台列表展示的就是它;上游 `create_time` 不入库 |
| `billing_domain` | text NULL | 人工标注(登记 / 编辑) | **账本域标签**(如 `carpool` / `office`);空 = 未标域,成员不可见(充值无逐笔日志连线,无法用日志证明「与我相关」) |
| `remark` | text | — | 人工备注 |
| `created_at` | timestamptz | — | 本系统登记时刻(补录审计) |
> 样本(2026-10-09):`amount: 1` · `money: 2.04` · `trade_no: "USR6NOLqTPGa1791534153"` · `alipay`/`epay` · `create_time`/`complete_time` Unix 秒 · `status: "success"`。展示与筛选一律用 `complete_time` 落入的 `paid_at`。
## 3. 「换得额度」是派生
上游无 `quota_granted`:派生 `amount × quota_per_unit`(默认 QPU 500000)——`amount` 是平台计费侧充值额,与 quota 同域量纲。
- **只存事实**:`amount`(平台计费)与 `money`(实付)两真值;**汇率不入库**(管理员社交约定,查询/展示可现算 `money/amount`)
- 不在库中固化派生额度
## 4. 时刻语义
| 时刻 | 含义 |
|------|------|
| `paid_at` | 入账(= 上游 `complete_time`)——筛选、排序、QPU 锚点 |
| `created_at` | 登记进本表(补录审计) |
## 5. 写入与维护
| 时机 | 动作 |
|------|------|
| **辅助登录选用**(与订阅选择器同模式) | 控制台登录(密码不落库)→ 分页拉 `/api/user/topup/self` → 选用预填表单 → 确认入库 |
| 纯手工登记 | 抄充值页 / 账单;或粘贴 JSON 预填 |
| 编辑 / 删除 | 改错即修 |
| 后台定时同步 | **不做**(辅助登录仅代拉一次、token 会话内用) |
**分页请求策略**(接口仅 `p` / `page_size`,**无时间过滤**):
1. **传统分页 UI**:弹层底部「共 N 条 · 页码 / 上一页 / 下一页」;每次请求只取当前页(`page_size=10`)
2. **不设硬上限**——用户可一直翻到 `total` 尽头(找旧账必须靠翻页)
3. **不提供时间范围筛选**(上游收窄不了结果集;本地筛只会误导读不出的更早页)
4. **已登记去重**:按 `upstream_id` 灰显,不可重复选用
5. **401**:清会话 token → 重新登录;token 仅 sessionStorage
## 6. 对外边界
- 成员可见:`amount`、`money`、渠道、`paid_at`、备注、`billing_domain`;**仅本人名下 key 所属账本域内**;无逐笔与模型日志的连线;未标域默认不可见
- 纯用量模式可不登记
## 7. 迁移说明
- 原 `payments.kind=topup`:`paid_at` 语义本就落在入账日 → 直接迁入本表 `paid_at`
- 若曾手填 `quota_granted`:可反推 `amount = quota_granted / qpu` 填 `amount`,否则按实付与约定补记
+2 -2
View File
@@ -34,7 +34,7 @@
| 列 | 类型 | 上游来源 | 说明 |
|----|------|---------|------|
| `billing_source` | text | `other.billing_source` | 计费来源:`subscription`(订阅)/ `wallet`(充值)——按来源统计的维度 |
| `subscription_id` | bigint NULL | `other.subscription_id` | 该次消耗所属的上游订阅实例 ID——与 `payments.subscription_id` 直连;缺失时 NULL(归「未关联」分组,见 3.6) |
| `subscription_id` | bigint NULL | `other.subscription_id` | 该次消耗所属的上游订阅实例 ID——与 `subscriptions.subscription_id` 直连;缺失时 NULL(归「未关联」分组,见 3.6) |
### 2.3 上游字段(20 个,`/api/log/token` 响应照收)
@@ -109,7 +109,7 @@
### 3.6 来源提列:`billing_source` 与 `subscription_id` 升为独立列
- **为什么提列**:两者是按来源统计与日志-支付记录直连的核心维度——`other` jsonb 的键查询无法走索引(SQLite/D1 的 `json_extract` 同理),高频过滤维度应独立成列
- **`subscription_id` 直连语义**(事实关联,非推算):日志按 `subscription_id = payments.subscription_id` 关联支付记录;**不做期间推定**——订阅来源但该列缺失(NULL)的行归「未关联订阅」分组,统计中透明显示(正常应恒为零,非零即「缺登记」或「上游行为变化」的异常信号)
- **`subscription_id` 直连语义**(事实关联,非推算):日志按 `subscription_id = subscriptions.subscription_id` 关联订阅登记;**不做期间推定**——订阅来源但该列缺失(NULL)的行归「未关联订阅」分组,统计中透明显示(正常应恒为零,非零即「缺登记」或「上游行为变化」的异常信号)
- `billing_source = wallet`(充值)的日志不挂订阅(余额扣款与订阅实例无关),`subscription_id` 为 NULL 是**正常状态**而非异常——按来源统计时它们在「充值」分组,不进「未关联订阅」分组
- **其他键留在 `other`**:提列只针对这两个一等维度;`cache_tokens`、倍率等仍留在 jsonb,出现真实分析需求再评估(见 §5)
+6 -3
View File
@@ -16,6 +16,7 @@
|----|------|------|
| 名称 | `token_name`(上游同步) | 展示名以它为准(上游改名随轮询更新) |
| 归属 | 成员名徽章 / 「未归属」 | 行内下拉可直接改归属(见 §4) |
| 账本域 | `billing_domain` / 「—」 | 行内可编辑(联想输入,防抖落盘);列表可按域筛,含「未标注」 |
| 状态 | `active` / `disabled` 徽章 | disabled 灰态 |
| 数据截止 | `log_synced_at` | **停滞预警**:落后 > 2 小时 → 该单元格黄色标注「停滞」+ hover 提示「检查引擎日志;若持续 401 请停用」 |
| 累计已用 / 剩余 | `total_used` / `total_available` + `unlimited_quota` 分支 | 「剩余:不限」分支 |
@@ -30,6 +31,7 @@
1. **sk-key 粘贴框**(大号单行,paste 友好)——页脚安全注记:「粘贴后立即加密存储,界面不再回显明文」
2. 归属成员下拉(默认「未归属」,可后改;下拉旁附「+ 新建成员」快捷入口——缝合「开户 → 发令牌 → 开 key」的跨页流程断点,见《交互设计-成员管理》)
2b. 账本域输入(可空,已有值联想;与支付记录的 `billing_domain` 同语义)
3. 提交 → 服务端上游验证,四态反馈:
| 结果 | 反馈 |
@@ -45,10 +47,11 @@
- **启用**:行操作 → 「已启用——下一轮同步(≤5 分钟)将自动验证,结果见数据截止」(无人工预验证环节,引擎即验证器)
- **死亡处置推荐流**(对持续 401 的 key):数据截止停滞 → 你查看引擎日志确认 → 停用 → 找运营方 → 修复后启用。界面在停滞标注的 hover 中给出这条指引文案(把「引擎记录不决策」的操作闭环写进提示里)
## 4. 归属变更
## 4. 归属 / 账本域变更
- 清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义)
- 归属:清单行内下拉直接切换(含「未归属」选项)——即时生效,无确认弹窗(低风险、可逆)
- 账本域:行内联想输入,防抖保存(失焦落盘);可清空为未标域
- 变更后统计自动按新归属呈现(行级联查当前归属——表设计 3.8 语义);账本域影响成员支付参数可见性
## 5. 空态
+3 -3
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | `需求清单.md` §9(访问主体模型·审计日志条目)· `members/users表字段设计.md`(写入时机中「落审计」的动作全集) |
| 数据 | 审计日志表(表结构归技术稿——本文锁定信息结构与展示) |
@@ -26,8 +26,8 @@
| 域 | 类型 |
|----|------|
| Key | 登记 / 启用 / 停用 / 归属变更 |
| 支付记录 | 登记 / 编辑 / 删除 |
| 成员 | 开户 / 停用 / 启用 / 令牌签发 / 令牌重发 |
| 支付记录 | 订阅 / 充值登记、编辑、删除 |
| 成员 | 开户 / 档案修改(名称/备注)/ 停用 / 启用 / 令牌签发 / 令牌重发 / 空壳删除 |
| 账号 | 改密 /(空库重建为系统事件,记「初始化」) |
- 每类管理面写操作一行留痕——操作者列当前恒为作者(单管理员),但**审计是不可变历史,操作者归属不能事后补写**:保留该列是前向兼容(未来若扩展多管理员 / 权限引擎,历史记录自然区分),也是审计语义的自描述原则(who did what when 的 who 是本体字段)
+14 -11
View File
@@ -5,7 +5,7 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `需求清单.md`(R 编号与裁决记录——本文全部设计的依据)· 七篇表字段设计(数据来源) |
| 关联 | `需求清单.md`(R 编号与裁决记录——本文全部设计的依据)· 各篇表字段设计(数据来源) |
| 产出 | 访问拓扑、导航结构、跨页组件、状态模式、数值与时间呈现 |
---
@@ -16,9 +16,9 @@
(拓扑见下文描述,实现时以此为准)
```
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 单页;与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图)
- **没有第三种入口**:无开放查询页、无游客态;任何人第一眼看到的必然是两个登录入口之一
- **管理面**(作者专属):入口为登录页(账号密码)→ 侧边栏布局;空库时按三态分流(见《交互设计-登录与会话》);地址栏访问 `/admin`(根路径不指向此处)
- **成员视图**(成员专属):入口为登录壳(令牌)→ 侧边栏布局(与管理端同构壳,菜单三项:总览 / 调用日志 / 支付参数);与管理面物理隔离(两套路由、两套凭证中间件,令牌进不了管理面,会话进不了成员视图);**根路径 `/` 默认进入本世界**(未知路径同样回退到此)
- **没有第三种入口**:无开放查询页、无游客态、无分发页;根地址即成员入口。管理面的入口形态待定(当前经地址栏 `/admin` 进入)
- 401 分流统一约定:管理面任意请求 401 → 登录页(提示「会话已过期」);成员视图任意请求 401 → 登录壳(提示「令牌无效或已停用」)——各自回各自的家,不交叉
## 2. 管理面导航:侧边栏
@@ -28,21 +28,24 @@
| 菜单 | 页面 | 职责一句话 |
|------|------|-----------|
| 总览 | 全员总览 | 健康与用量全景(首页,登录后落地) |
| 成员管理 | 成员清单 | 开户、档案修改、令牌、启停、空壳删除 |
| Key 管理 | key 清单 | 登记、启停、归属 |
| 支付记录 | 记录清单 | 订阅/充值登记、核对 |
| 成员管理 | 成员清单 | 开户、令牌、停用 |
| 调用日志 | 请求明细 | 全池模型请求查询与筛选 |
| 审计日志 | 操作留痕 | 管理动作时间线 |
| 账号设置 | 改密 | 会话体系基本配套 |
顺序逻辑:先看全景 → 立对象(成员 → Key → 支付)→ 查事实(调用日志)→ 系统(审计 / 账号)。
- 侧边栏底部:当前用户名 + 登出(登出即删会话行 → 回登录页)
- 成员视图**无侧边栏**(单页,页头导航)
- 成员视图**同构侧边栏**(三项菜单,见《交互设计-成员视图》§2):布局壳与管理端一致,菜单与数据不同;顶栏下拉「退出」= 清 sessionStorage 令牌
## 3. 跨页组件与呈现规则
### 3.1 时间范围选择器(核心组件)
- 一切统计的公共维度:**任意时段** + 快捷项
- 快捷项:最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
- 快捷项:**今天** / 最近 7 天 / 最近 30 天(默认)/ **本订阅期间**(订阅期间作为预置选项——R10 裁撤后的「周期」形态;依赖支付记录,无登记的部署形态自动隐藏该项,见 §3.8)/ 自定义起止
- 所有统计数字标注其口径(「2026-09-02 ~ 10-01」),杜绝无口径数字
### 3.2 数值呈现三约定
@@ -82,7 +85,7 @@
八篇交互设计实际用到的组件能力——组件库与前端框架是实现期自由度,本清单是选型的验收标准而非锁定:
1. **布局**:可折叠侧边栏 / 抽屉、栅格系统(布局响应,见 §3.5)
2. **表格**:密集列、行内编辑(归属下拉)、**展开行**(来源徽章的展开层)、行级操作、窄屏横向滚动
2. **表格**:密集列、行内编辑(归属下拉)、行级操作、窄屏横向滚动;详情弹窗(调用详情 / 订阅参数)
3. **浮层**:对话框(含二次确认)、toast(复制反馈)、顶部提示条(可关闭)
4. **表单**:日期范围选择(时间选择器的快捷项 + 自定义起止)、大号粘贴输入框、双输入切换(支付记录的额度模式)
5. **展示**:徽章 / 标签(来源、状态)、分段条(来源分解)、骨架屏、分页
@@ -91,9 +94,9 @@
平台支持**不登记任何支付记录**的部署形态(如部门实例:只看用量、不掌握订阅套餐信息)——用量统计链路(登记 → 采集 → 聚合)独立于金额层,**零改造支持**:
- 成员视图支付参数卡:中性空态「暂无支付记录信息」——**不设行动引导**(不登记是合法使用形态,非遗漏)
- 日志来源徽章的展开行:无对应登记时显示「该订阅无登记信息」
- 核对提示条:`payments` 全空时**自动静默**(作者显然选择了不登记,此时提示是噪音而非健康信号)
- 成员视图支付参数页:中性空态「暂无支付记录信息」——**不设行动引导**(不登记是合法使用形态,非遗漏)
- 日志来源徽章的弹窗:无对应登记时显示「该订阅无登记信息」
- 核对提示条:`subscriptions` + `topups` 皆空时**自动静默**(作者显然选择了不登记,此时提示是噪音而非健康信号)
- 「本订阅期间」快捷项自动隐藏(无期间数据)
- 完全不受影响:健康带、按成员 / key 聚合、来源徽章与分解、全部管理操作、审计
+26 -8
View File
@@ -1,17 +1,19 @@
# 交互设计 · 成员管理
> 成员实体的操作面:开户、令牌签发与重发(明文仅展示一次)、停用。访问主体模型中「凭证事务经作者」的落点。
> 成员实体的操作面:开户、档案修改(名称/备注)、令牌签发与重发(明文仅展示一次)、停用/启用、空壳删除。访问主体模型中「凭证事务经作者」的落点。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》· `members表字段设计.md`(软删除、令牌不过期)· R12(成员视图的锚点) |
| 状态 | 定稿(2026-10-01;R16 修订 2026-10-10) |
| 关联 | 《交互设计-总览与共性》· `members表字段设计.md`(软删除、档案可改、空壳删除)· R12(成员视图的锚点)· R16 |
| 数据 | `members` 全表 · 名下 key 聚合 |
---
## 1. 成员清单(页面主体)
列表上方轻量筛选:名称/备注搜索 · 状态(全部/启用/停用);URL 可深链(`name` / `status` / `member_id`)。
卡片栅格(成员量级:个位数):
| 元素 | 内容 |
@@ -20,13 +22,21 @@
| 状态 | active / disabled 徽章 |
| 令牌签发时间 | `token_issued_at`(提示「明文已不可找回,如需新令牌请重发」) |
| 名下 key 数 | 聚合计数(hover 列出 key 名) |
| 操作 | 重发令牌 / 停用(或启用) |
| 操作 | 编辑 / 重发令牌 / 停用(或启用)/ 删除(见 §5 条件) |
## 2. 开户
表单:名称(必填、唯一性校验)、备注(可空)→ 提交 → **自动生成令牌** → 明文展示对话框(见 §3)
## 3. 令牌签发与重发(敏感操作的标准交互)
## 3. 档案修改(名称 / 备注)(R16)
- **入口**:卡片操作「编辑」→ 小对话框(或行内编辑,实现期定)
- **可改字段**:名称(必填、唯一性校验)、备注(可空)——**仅此两项**
- 不含启停、不含令牌(各自走 §4 / §5 / 既有重发);`created_at` 等只读展示
- 名称冲突提示与开户一致(「名称已被占用」)
- 典型场景:误建/占位名 → 挂 key 前改名;已发出令牌时改名优于删除重建(重建会作废旧令牌)
## 4. 令牌签发与重发(敏感操作的标准交互)
**明文展示对话框**(开户自动触发,或重发触发):
@@ -36,12 +46,20 @@
- **重发**的确认弹窗先行:「重发后旧令牌立即失效(该成员正在用的访问将中断)」→ 确认 → 新明文对话框
- 对话框关闭后,界面任何地方不再出现明文(库里只有哈希——members 3.1)
## 4. 停用 / 启用
## 5. 停用 / 启用 · 空壳删除
**停用 / 启用**:
- 停用:确认弹窗「停用后该成员的视图立即不可访问(令牌验证含状态检查);数据保留」——退拼 / 令牌疑似泄露的标准动作
- 启用:直接启用(下一请求即恢复)
- 软删除语义:**无删除按钮**——成员与其历史用量是账本事实,只有停用(members 3.4)
- 软删除语义仍是账本实体默认:名下有 key 的成员**只有停用**(members 3.4)
## 5. 空态
**空壳删除(R16)**:
- **条件**:仅 `key_count = 0` 时露出「删除」;名下有 key 则隐藏或禁用并提示「名下仍有 key,只能停用」
- 确认弹窗:「删除后不可恢复(名称与令牌一并作废);仅清理未挂 key 的空壳」→ 删除
- 典型场景:误建、从未使用的开户行——清壳,避免停用行越积越多
## 6. 空态
「还没有成员——为每位分摊朋友开户并发放访问令牌」
+131 -35
View File
@@ -1,76 +1,172 @@
# 交互设计 · 成员视图
> 成员的单页世界——名下 key 的调用日志、用量聚合、完全公开透明的支付参数。**R12 的交互落点**:含「日志行携带所属订阅/充值信息」的形态定案。页面无任何「应付」概念(分摊哲学裁决)。
> 成员世界——**布局与信息结构对齐管理端**(侧边栏壳 + 页内区块 + 表格优先),内容仍是「我的用量」:名下 key 的调用日志、用量聚合、同账本域内可见的支付参数。**R12 的交互落点**(日志行携带所属订阅/充值信息:徽章弹窗 + 行展开调用详情)。页面无任何「应付」概念(分摊哲学裁决)。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `payments表字段设计.md`(参数卡数据源) |
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `payments` 参数(完全可见) |
| 前置 | 经登录壳(令牌)进入;无侧边栏、单页 |
| 状态 | 定稿(2026-10-09,作者认可布局重写) |
| 关联 | 《交互设计-总览与共性》(数值呈现、时间选择器、响应式)·《交互设计-调用日志》(明细表列语义同构参照)· R12(裁决记录)· `members表字段设计.md`(令牌)· `usage_logs表字段设计.md`(来源提列)· `subscriptions` / `topups` 表字段设计(参数数据源)·《API契约-成员视图》 |
| 数据 | 名下 key 的 `usage_logs` 聚合与明细 · 相关 `subscriptions` / `topups`(同账本域内可见) |
| 前置 | 经登录壳(令牌)进入;**侧边栏布局**(与管理端同构,菜单与数据不同) |
---
## 1. 单页结构(自上而下,滚动即全部)
## 1. 布局壳(与管理端同构)
### 1.1 页头
成员视图**不再做单页长卷**,改为与管理端同一套页面壳:
成员名 + 一句话定位「你在本池的名下用量」(「你」= 该成员;页面文案以成员视角书写)
| 构件 | 形态 |
|------|------|
| 整体 | `a-layout`:左侧固定可折叠侧边栏 + 右侧顶栏 / 内容区;高度锁 `100vh`,**仅内容区滚动** |
| 品牌区 | 侧边栏顶部两行:`NAKU` +「成员用量」(收起只留 N 徽标)——与管理端同源,描述行区分世界 |
| 菜单 | 深色 `a-menu`,三项(见 §2);收起态只留图标 |
| 顶栏 | 右侧成员名 + 下拉「退出」(与管理端账号菜单位置一致) |
| 内容区 | `bg-layout` 上的白色内容面(8px 圆角);页内统一 `PageHeader` + 区块 |
| 退出 | 清 sessionStorage 令牌 → 登录壳(无服务端登出——令牌档语义) |
### 1.2 时间范围选择器
> 与管理端**物理隔离**不变:两套路由、两套凭证中间件;布局同构 ≠ 世界打通。
共性组件;默认最近 30 天,快捷项含「本订阅期间」
## 2. 导航结构
### 1.3 聚合卡(该时段总览)
| 菜单 | 页面 | 职责一句话 | 登录后落地 |
|------|------|-----------|-----------|
| 总览 | 成员总览 | 该时段用量与名下 key 全景 | **是** |
| 调用日志 | 调用明细 | 名下 key 的模型请求流水 | |
| 支付参数 | 支付参数 | 订阅 / 充值登记参数(同账本域内可见) | |
顺序逻辑:先看汇总 → 查事实(流水)→ 对参数(支付)——与管理端「总览 → 对象 → 事实」同型,体量收成三项。
**时间范围全局共享**:三页共用同一对账窗口——切页保留当前范围(菜单带 `from/to`),任一页改范围即全局生效;URL 可深链。支付参数页的「该时段涉及」:订阅 = 涉及我日志的 `subscription_id` ∪ 我域内进行中;充值 = 我域内且 `paid_at` 落在窗口内。
**数据刷新心智**:**仅总览**作看板自动刷新(约 60s);调用日志 / 支付参数为查询台,**不主动轮询**(改条件或手动重试才拉);相对时间窗在选定后定格,避免每分钟打穿缓存。
无账号设置、无审计、无健康带(成员世界定位边界,见《API契约-成员视图》导语)。
## 3. 总览页
### 3.1 页头
`PageHeader`:标题「总览」+ 副文案「你在本池的名下用量 · 仅统计消费行」;**右侧操作位放时间范围选择器**(共性 §3.1,默认最近 30 天,快捷项含「本订阅期间」)。
### 3.2 用量摘要条(页首,非海报)
一行紧凑摘要,**不做超大号 Hero 数字**(与管理端密度一致):
- 总用量:次数 / quota / 美元(标注「按当时换算率」)
- **按来源分解**:订阅消耗 / 充值消耗(分段条)——回答「我的用量花在哪个池子」
- 口径标注:「2026-09-02 ~ 10-01」(共性:杜绝无口径数字)
### 1.4 支付参数卡(完全公开透明的落点)
### 3.3 我的 Key(表格,非窄卡)
该时段涉及的订阅与充值,**参数原貌**逐笔列出:
管理端总览「按 key」区的成员版——**表格优先**:
- 订阅:期间、订阅 ID、额度(quota + $)、金额(纯数字)
- 充值:支付时间、额度、金额
- 脚注:「支付参数对成员完全公开;币种由成员与作者约定」——透明裁决的界面声明
| 列 | 内容 |
|----|------|
| Key 名称 | `token_name` |
| 数据截止 | 分钟级;落后 > 2h 停滞标注 |
| 调用次数 | 该时段消费日志条数 |
| 额度消耗 | quota + $ 双单位 |
> **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」——账本是事实,算账是社交层的事。
>
> 空态:无登记的部署形态(纯用量模式,《总览与共性》§3.8)下该卡显示中性文案「暂无支付记录信息」——不设行动引导(不登记是合法使用形态)。
- 行操作「查调用」→ 调用日志页并带上该 `api_key_id` + 当前时间范围
- 空态:名下无 key →「作者尚未为你分配 key」;有 key 无日志 → 表正常、用量 0 +「该时段内无调用」
### 1.5 我的 key 列表
### 3.4 支付参数(摘要入口)
每 key 一张窄卡:`token_name`、数据截止(停滞标注)、该时段用量(次数 / quota / $)。
表头或区块脚注链到**支付参数页**;总览可放 1–2 行摘要(进行中订阅一行)或仅入口链接——**完整参数不在此页堆叠**(对齐管理端「对象各归其页」)。
### 1.6 日志明细表
- 纯用量模式(无登记):不放行动引导;入口仍可进支付参数页看中性空态
## 4. 调用日志页
与管理端调用日志**列语义同构**(《交互设计-调用日志》为参照),范围恒为**名下 key**;明细**默认全类型**(消费 + 错误,可筛);聚合仍仅消费行。
### 4.1 页头与筛选
- `PageHeader`「调用日志」+ 副文案「名下 key 的模型请求流水」;右侧时间范围(同上)
- 筛选一行:Key(Select)· 模型(Select,可搜可清空)· 计费来源(全部 / 订阅 / 充值 / 未关联)· 订阅 ID(可空)· 类型(全部 / 消费 / 错误)
- 切换任一筛选 → 回第 1 页;URL 同步 `from/to` + 筛选(含 `model_name`)+ `page`(可深链)
- **汇总条**(筛选与表格之间,低高度单行):请求次数 + 消费用量(quota + $)——口径与《交互设计-调用日志》§2.5 同构;消费用量恒 `type = 2`
### 4.2 明细表(页面主体,表体滚动)
| 列 | 内容 |
|----|------|
| 时间 | 到秒(北京时间) |
| key | `token_name` |
| Key | `token_name` |
| 模型 | `model_name` |
| 来源 | **来源徽章**:`订阅 #N` / `充值` / `未关联`(灰)——携带形态的第一层(一眼识别) |
| 类型 | 消费 / 错误徽章 |
| 来源 | **来源徽章**:`订阅 #N` / `充值` / `未关联`(灰)——R12 携带形态第一层 |
| 用量 | quota + 美元双单位 |
| tokens | prompt + completion |
| 操作 | 「详情」按钮 → 详情弹窗 |
**携带形态定案(R12 悬置项的本设计结论)**:两层实现——
**携带形态定案(R12,2026-10-09 修订)** 两层分工——
1. **行内徽章**(常驻):来源类型 + 订阅编号,零噪声
2. **展开行**(点击徽章):该笔支付的完整参数(期间 / 额度 / 金额 / 备注)——完整透明的第二层,按需展开不打扰
1. **行内徽章**(常驻):来源类型 + 订阅编号,零噪声;**点击「订阅 #N」→ 弹窗**展示该笔支付完整参数(期间 / 额度 / 金额 / 备注)——与详情解耦,扫读不被打断
2. **「详情」弹窗**(操作列,居中定高内滚):调用事实——时间 / Key / 模型 / 类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚)
> 展开行降级:徽章对应的订阅**无支付记录**时,展开显示「该订阅无登记信息」(纯用量模式的常态,见《总览与共性》§3.8);徽章显示「未关联」的行(日志缺 `subscription_id`)不可展开——两种空值场景各自归位,见 `usage_logs表字段设计.md` 3.6。
> 弹窗降级:徽章对应订阅**无登记** →「该订阅无登记信息」;「充值 / 未关联」徽章不弹窗(余额池无逐笔关联 / 缺 `subscription_id`)。与《API契约-成员视图》§5 裁定 4 同步:「充值」为纯标识。
> 该形态随实现期真实数据核查(订阅编号同一性)后再终验;徽章文案预留「未关联」的正常位(健康指示语义)。
### 4.3 表体高度(对齐管理端)
## 2. 状态与边界
- 内容区固定壳内:**表体 `scroll.y` 按容器实测**(ResizeObserver),分页条贴底;页面不因明细变长而整页膨胀
- 分页:默认 50,档位 20 / 50 / 100 / 200
- 空态:「该时段内无调用日志」
## 5. 支付参数页
同账本域内可见的落点——**订阅权益 + 充值入账**两组清单(Tab 或上下两节),参数原貌成表。
### 5.1 页头
`PageHeader`「支付参数」+ 副文案「订阅与充值的登记参数 · 同账本域内可见」;右侧时间范围(全局共享窗口)。
### 5.2 订阅表
| 列 | 内容 |
|----|------|
| 订阅 ID | `subscription_id` |
| 期间 | `period_start` ~ `period_end`(进行中) |
| 额度 | quota + $(`quota_granted`) |
| 付款 | 金额 + `paid_at`(可空显示「—」) |
| 备注 | `remark` |
### 5.3 充值表
| 列 | 内容 |
|----|------|
| 时间 | `paid_at`(入账完成) |
| 充值金额 / 实付金额 | `amount` · `money`(汇率约定,不标币种) |
| 交易号 / 渠道 | `trade_no` · 支付方式 |
| 派生额度 | `amount × qpu` + $(展示) |
| 备注 | `remark` |
- 脚注:「支付参数在你所属账本域内公开;币种由成员与作者约定」——透明裁决的界面声明(同域透明、跨域隔离)
- **不出现**任何派生金额:无「折合人民币」、无「占比 × 金额」、无「应付」
- 空态(纯用量模式):中性文案「暂无支付记录信息」——**不设行动引导**
### 5.4 与日志订阅弹窗的关系
本页是**完整清单**(订阅 + 充值两组);日志页徽章弹窗是**单笔快捷透视**——同一数据源(`overview.subscriptions`),不两套口径。
## 6. 状态与边界
| 场景 | 表现 |
|------|------|
| 该时段无日志 | 聚合区「该时段内无调用」+ 明细空态 |
| 名下无 key(刚开户) | 全页空态「作者尚未为你分配 key」 |
| 该时段无日志 | 总览摘要为 0 +「该时段内无调用」;日志页空态 |
| 名下无 key(刚开户) | 总览空态「作者尚未为你分配 key」;其余页正常空态 |
| 令牌失效/停用 | 任意请求 401 → 登录壳(「令牌无效或已停用」) |
| 旧令牌(作者重发后) | 同上——旧令牌即刻作废 |
| 纯用量模式 | 支付参数页中性空态;订阅徽章弹窗「该订阅无登记信息」;「本订阅期间」快捷项隐藏 |
## 3. 窄屏说明
## 7. 窄屏说明
成员可能用手机查看本页——但按《总览与共性》§3.5 的裁决:**不做移动端 UI 设计**,窄屏下依赖布局响应(区块纵向堆叠、宽度弹性)保证可读不崩;明细表保持表格形态,横向滚动即可。
成员可能用手机查看——按《总览与共性》§3.5:**不做移动端 UI 设计**。侧边栏窄屏折叠为抽屉;区块纵向堆叠;明细与参数表保持表格形态,横向滚动即可。
## 8. 与前稿的差异(本次重写)
| 前稿(单页海报) | 本稿(管理端同构) |
|------------------|-------------------|
| 无侧边栏、单页五区滚动 | 侧边栏三页 + 固定壳、内容区滚动 |
| 超大 Hero 数字 + key 胶囊墙 | 紧凑摘要条 + 表格 |
| 支付参数卡堆在长卷里 | 独立「支付参数」页 |
| 明细表嵌在页尾 | 独立「调用日志」页,表体视口滚动 |
+74 -27
View File
@@ -1,47 +1,94 @@
# 交互设计 · 支付记录
> 订阅与充值的登记面(同构表单)+ 系统核对提示的呈现——「只有作者知道」的信息的唯一录入处(payments 定位)。
> 订阅权益与充值入账的登记面(管理入口名「支付记录」)+ 系统核对提示——「只有作者知道」的信息的唯一录入处。物理上分 `subscriptions` / `topups` 两表(`payments建模评估.md` 方案 B),界面仍作**一个菜单项**,两 Tab 各登记各的。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | 《交互设计-总览与共性》(数值呈现:金额无符号、额度双单位)· `payments表字段设计.md`(字段与「只存事实」)· R7(充值登记)· R8(`subscription_id` 直连) |
| 数据 | `payments` 全表 · 日志观测的订阅 ID(核对提示) |
| 状态 | 定稿(2026-10-12,作者认可实现;补订阅 ID / 交易号筛选) |
| 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 |
| 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) |
---
## 1. 记录清单(页面主体)
## 1. 页结构:两 Tab
| 列 | 内容 | 备注 |
|----|------|------|
| 类型 | `subscription` / `topup` 徽章(订阅 / 充值) | 同构表的两种形态 |
| 金额 | **纯数字**(如「170」) | 币种由你们约定——列头标注「金额(币种自定)」 |
| 时间 | 订阅:期间 `[start, end)`,未结束显示「进行中」;充值:`paid_at` | 期间左闭右开,展示为「09-01 ~ 10-01」 |
| 订阅 ID | `subscription_id`(充值留空) | 直连日志的分源键 |
| 额度 | 原始 quota + 美元换算双单位(如「12,500,000($25.00)」) | 美元按 QPU 换算,标注「按当时换算率」 |
| 备注 | `remark` | |
菜单仍叫「支付记录」;页内 Tab:
- 排序:时间倒序;编辑 / 删除(删除有确认弹窗——「改错即修,删除即消失」,人工登记数据)
| Tab | 清单 | 登记 |
|-----|------|------|
| **订阅** | 订阅权益表 | 登记订阅 |
| **充值** | 充值入账表 | 登记充值 |
## 2. 登记表单(订阅 / 充值同构,`kind` 切换)
筛选随 Tab(均为前端过滤——清单全量直返):
**公共字段**:金额(必填,纯数字输入;脚注「金额数值——币种由你们约定,系统不预设」)、备注(可空)
| Tab | 筛选 | URL |
|-----|------|-----|
| 订阅 | 订阅 ID(精确,可空)· 账本域(含「未标注」) | `tab=subs` · `subscription_id` |
| 充值 | 交易号(子串,可空)· 账本域 | `tab=topups` · `trade_no` |
**订阅形态追加**:
- 支付时间、期间起止(结束可空 = 进行中)
- 订阅 ID(抄自钱包页面「订阅 #N」)
- 额度——**双输入模式**:直接输原始 quota,或输美元由系统按当前 QPU 换算落库(表单内二选一切换,换算即时预览;录入体验归交互层、数据落原始 quota——payments 3.6)
- 切换 Tab 保留另一 Tab 已填筛选;URL 同步便于从调用日志来源徽章深链(`subscription_id`)
- 补录审计可看「登记」列(`created_at`)
**充值形态追加**:支付时间、额度(同上双输入)
## 2. 订阅清单列
## 3. 核对提示(系统观测 ↔ 人工登记的对照)
| 列 | 内容 |
|----|------|
| 订阅 ID | `subscription_id` |
| 期间 | `[period_start, period_end)`,未结束「进行中」 |
| 额度 | quota + $ 双单位 |
| 付款 | `paid_amount` / `paid_at`(可空显示「—」) |
| 备注 | `remark` |
| 登记 | `created_at` |
| 操作 | 编辑 / 删除 |
页面顶部提示区,由日志观测驱动:
## 3. 充值清单列(对齐上游充值单)
| 列 | 内容 |
|----|------|
| 时间 | `paid_at`(= 上游 `complete_time`,平台展示时刻;倒序) |
| 充值金额 | `amount`(平台计费侧充值额,不标币种) |
| 实付金额 | `money`(如 `2.04`,纯数字) |
| 交易号 / 渠道 | `trade_no` · `payment_method` / `payment_provider` |
| 派生额度 | `amount × qpu` + $(展示,不入库) |
| 备注 / 登记 | `remark` · `created_at` |
| 账本域 | `billing_domain`(可空;列表可按域筛,含「未标注」) |
| 操作 | 编辑 / 删除 |
## 4. 登记表单(分 Tab,不再 `kind` 切换)
**订阅**:订阅 ID · 期间起止(结束可空)· 额度(quota / 美元双输入)· 付款金额(可空)· 付款时间(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 订阅列表)
**充值**:时间 `paid_at`(必填)· `amount`(必填)· `money`(必填)· 交易号 / 渠道(可空)· 账本域(可空,已有值联想)· 备注 · **「从平台选用」**(辅助登录 → 充值单分页列表);**无「换得额度」输入框**(派生)
### 4.1 选用弹层(订阅 / 充值同规则)
与 `platform-subscription-picker` 同壳:账号密码 → 代拉 → 选用。**已登记灰显**两套一致:
| 实体 | 去重键 | 已登记表现 |
|------|--------|------------|
| 订阅 | `subscription_id` | 灰 +「已登记」,不可选用 |
| 充值 | `upstream_id` | 灰 +「已登记」,不可选用 |
**充值分页**(上游仅 `p` / `page_size`,无时间参数):
| 行为 | 策略 |
|------|------|
| 列表 | 当前页(`page_size=10`) |
| 翻页 | 传统分页:共 N 条 · 上一页 / 页码 / 下一页 |
| 范围 | 可翻至尽头;**不做时间筛选** |
| 401 | 重登;token 仅 sessionStorage |
订阅列表为全量一次拉取(`all_subscriptions`),无分页问题。
- 金额脚注:「金额数值——币种由你们约定,系统不预设」
- 订阅额度双输入:原始 quota 或美元(按 `qpu/latest` 预览),落库恒 quota
- 编辑 / 删除有确认;改错即修
## 5. 核对提示(只对订阅 Tab)
| 提示 | 触发 | 文案 |
|------|------|------|
| 未对应登记 | 观测到某 `subscription_id` 有消耗、但 `payments` 无此订阅 | 「观测到订阅 #N 有消耗,但尚无对应登记——请核对补录」 |
| 正常 | 全部观测到的订阅均有登记 | 不显示(健康默认无声) |
| 未对应登记 | 观测到某 `subscription_id` 有消耗、`subscriptions` 无此行 | 「观测到订阅 #N 有消耗,但尚无对应登记——请核对补录」 |
| 正常 | 均有登记 | 不显示 |
- 观测**只提示、不回填**(payments 定稿语义);提示区与全员总览的「未关联订阅」分组互为印证(一个说「有 ID 没账」,一个说「有账路没 ID」)
- **静默规则**:`payments` 全空时本提示区自动静默——纯用量模式(《总览与共性》§3.8,如部门实例)下「无登记」是作者的有意选择,此时提示是噪音而非健康信号
- 只提示不回填;`subscriptions` + `topups` 皆空时**自动静默**(纯用量模式)
+2 -2
View File
@@ -39,9 +39,9 @@ GET 管理面入口
## 4. 成员登录壳
- 成员视图的唯一入口:令牌输入框(单字段)+ 进入按钮
- **唯一入口**:令牌输入框(单字段)+ 进入按钮;**根路径 `/` 默认落到本页**(已持令牌则进成员总览);管理面入口形态(是否在界面露出)待定,当前经地址栏 `/admin` 进入
- 提交 → 服务端哈希比对 + `status` 检查:
- 通过 → 令牌存 sessionStorage → 进入成员视图(单页,见《交互设计-成员视图》)
- 通过 → 令牌存 sessionStorage → 进入成员视图(侧边栏布局,落地「总览」,见《交互设计-成员视图》)
- 失败/停用 → 「令牌无效或已停用」(同一提示,不泄露停用与错误的区别——成员侧无需区分,反正都是找作者)
- 页面氛围:与登录页同构但**视觉上明确这是「成员入口」**(页头「成员入口」标识),防止你自己走错门
- 无「找回令牌」链接:丢了找作者重发(裁决语义,界面不设自助入口)
+88
View File
@@ -0,0 +1,88 @@
# 交互设计 · 调用日志
> 管理面的模型请求日志查询页——`GET /api/admin/logs` 的界面落点。回答「这段时间谁、哪把 key、哪个模型、花了多少」;与 Key 管理、成员管理、全员总览构成筛选跳转闭环。**只读事实**,无改写。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条、来源徽章支付弹窗) |
| 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) |
---
## 1. 页面定位
- 侧边栏项「**调用日志**」(排在「支付记录」之后:对象链之后的事实查询)
- 纯查询页:无登记、无启停、无删除;排查与核对入口
- 与成员视图的日志表**列语义同构**,但范围是**全池**且可筛错误行
## 2. 筛选区(自上而下一行或两行)
| 筛选 | 形态 | 对应 API |
|------|------|----------|
| 时间范围 | 共性组件,默认最近 30 天 | `from` / `to`(必填) |
| 成员 | Select,含「未归属」 | `member_id` |
| Key | Select(依赖已选成员可收窄;或全量) | `api_key_id` |
| 模型 | Select,可搜可清空;选项 = 时间窗 + 主体筛选下去重 `model_name` | `model_name` |
| 计费来源 | 全部 / 订阅 / 充值 / 未关联 | `billing_source` |
| 订阅 ID | 数字(可空) | `subscription_id` |
| 类型 | 全部 / 消费 / 错误 | `type`(2 / 非 2) |
- 切换任一筛选 → 回第 1 页
- URL 查询同步:`from/to` 可选省略(用默认区间);`member_id`、`api_key_id`、`model_name`、`billing_source`、`subscription_id`、`type`、`page`——便于总览/管理页深链
## 2.5 汇总条(筛选条与表格之间)
低高度**单行**读数条(约 32–36px),只回答「这批条件加起来多少」:
| 段 | 内容 | 口径 |
|----|------|------|
| 请求次数 | 当前筛选条数;错误数 > 0 时附注「消费 X / 错误 Y」 | 跟全部筛选(含 `type`) |
| 消费用量 | quota + $(`QuotaText`) | 恒 `type = 2` + 其余筛选 |
- 类型筛「错误」时:请求次数 = 错误条数;消费用量仍为同期消费口径(标签保持「消费用量」,不叫「花费」)
- 查询中数字骨架/淡化,不闪上一轮;空结果汇总条保留、数字归零
- 成员端同构(见《交互设计-成员视图》§4)
## 3. 日志表(页面主体)
| 列 | 内容 | 备注 |
|----|------|------|
| 时间 | 到秒(北京时间) | 倒序由后端返回 |
| Key | `token_name` | **可点** → Key 管理定位该 key |
| 归属 | 成员名 / 未归属 | **可点** → 成员管理;未归属不跳 |
| 模型 | `model_name` | |
| 类型 | 消费 / 错误徽章 | 错误行 `type ≠ 2` |
| 来源 | 订阅 #N / 充值 / 未关联徽章 | 与成员视图同构 |
| 用量 | quota + $ 双单位 | 错误行可显示 `—` 或 0 |
| Tokens | prompt / completion | |
| 操作 | 「详情」按钮 | 开详情弹窗 |
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
- **来源徽章**:`订阅 #N`(蓝)**可点** → 支付参数弹窗(期间 / 额度 / 金额 / 备注;无登记显示「该订阅无登记信息」);底栏「在支付记录中查看」→ 支付记录页并带 `subscription_id` 深链。`充值` / `未关联` 为纯标识,不可点(与成员端同构)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(支付参数走来源徽章弹窗,核对/补录走支付记录页)
- 空态:「该时段内无调用日志」
- 窄屏:表格横向滚动(共性 §3.5)
## 4. 交互链路(与其它页)
| 从 | 到 | 携带 |
|----|-----|------|
| 全员总览 · 按 key 行 / 成员行 | 本页 | `api_key_id` 或 `member_id` + 当前时间范围 |
| Key 管理 · 行操作「查调用」 | 本页 | `api_key_id` |
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
| 本页 · 来源徽章弹窗「在支付记录中查看」 | 支付记录 | `tab=subs` + `subscription_id` |
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
## 5. 状态与边界
- 加载:表 loading;错误:顶部错误条 + 重试(共性 §3.4)
- 401 → 管理面登录(既有分流)
- 不展示 `request_id` / `ip` / `channel` 等表字段(契约 §6 暂不 API);将来需要时列可扩
## 6. 与既有文档的衔接
- 《API契约-管理面》§6「界面落点待补」→ **本篇即落点**;「跳 Key 管理详情」改为「跳本页带筛选 + Key 管理定位」并行
- 侧边栏六项 → **七项**(《交互设计-总览与共性》§2 同步修订)
+4 -4
View File
@@ -1,10 +1,10 @@
# 同步引擎设计
> 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(2026-10-05 作者裁决)**:① 本阶段**不考虑 Cloudflare Workers,也不为 CFW 做准备**;② 引擎**独立于 HTTP 服务进程**(`apps/engine`),避免后台 CPU 与 API 抢事件循环;③ 调度用**手写 `setTimeout` 循环**(不引入 cron 库),周期**可配置**。字段与去重的权威仍在七篇表设计;本文定「引擎怎么转」。
> 母体形态下同步引擎的进程形态、调度与轮次语义:拉什么、写什么、失败怎么办、状态如何暴露。**范围声明(2026-10-05 作者裁决)**:① 本阶段**不考虑 Cloudflare Workers,也不为 CFW 做准备**;② 引擎**独立于 HTTP 服务进程**(`apps/engine`),避免后台 CPU 与 API 抢事件循环;③ 调度用**手写 `setTimeout` 循环**(不引入 cron 库),周期**可配置**。字段与去重的权威仍在各篇表字段设计;本文定「引擎怎么转」。
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-05) |
| 状态 | 已实现(定稿 2026-10-05) |
| 关联 | 《api_keys表字段设计》§4 写入时机 · 《usage_logs表字段设计》§3.1 去重 · 《qpu_history表字段设计》§4 采样 · 《API契约-管理面》§3 `engine` 端点 · 《技术选型》§4 monorepo(本文增补 `apps/engine`)· `docs/references/NewAPI-接口范围.md` |
| 产出 | 进程形态 · 调度与轮次语义 · 失败与观测 · 与 `/api/admin/engine` 的状态衔接 · 配置与验收 |
@@ -26,7 +26,7 @@ apps/engine ──► 定时 syncRound() ──► packages/db
| 共享 | `packages/core`(上游客户端、dedupe、折算)· `packages/db`(仓储)——**业务能力仍一份** |
| 不共享 | 进程内存、事件循环、调度器;HTTP 与引擎**互不调用** |
| 不调自家 API | 引擎只打上游 NewAPI + 写库;不走 `/api/admin/*` |
| 部署 | 开发:两个命令(`dev:server` / `dev:engine`);生产 compose 两个 service(均连同一 PostgreSQL) |
| 运行形态 | 开发:两个命令(`dev:server` / `dev:engine`);生产两个进程(均连同一 PostgreSQL) |
**为何分进程(2026-10-05 复审)**:一轮可能解析上千条日志并做指纹/加密/批量写,若与 API 同进程会争事件循环,查询延迟与「引擎是否在跑」绑死;定时器漂移本身对本业务无害,**互扰才是拆分动因**。
@@ -174,7 +174,7 @@ apps/
## 9. 验收清单(实现后)
- [ ] 只启 `dev:engine`(或 compose 仅 engine+db):日志入库、`log_synced_at` 前进
- [ ] 只启 `dev:engine`(或只跑 engine + db):日志入库、`log_synced_at` 前进
- [ ] 只启 `dev:server`:HTTP 正常,无轮询日志
- [ ] 同一数据重复轮次不产生重复行
- [ ] 某 key 401:其余 key 正常;`engine_status.last_round.failed_api_key_ids` 含之
@@ -0,0 +1,260 @@
# 实现方案 · 日志模型筛选与汇总条
> 调用日志页(管理端 + 成员端)的两项增强:**按模型名筛选**、**当前筛选条件下的用量汇总条**。交互方向已在讨论中议定——汇总条只做「条数 + 花费」、高度压低;本篇落 API / 数据层 / 前端改造清单与验收口径。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(2026-10-13) |
| 关联 | 《交互设计-调用日志》·《交互设计-成员视图》§4 · 《API契约-管理面》§6 · 《API契约-成员视图》· 《API契约-公共约定》(分页、quota 双单位、聚合口径)· `usage_logs表字段设计.md` · 需求清单 R5 / R9(多维统计含模型) |
| 数据 | `usage_logs.model_name`(已入库)· 明细分页筛选与消费汇总(派生查询) |
---
## 1. 问题与目标
日志表已展示 `model_name`,筛选区却没有模型维度;总览有三维聚合,日志页却回答不了「这批条件加起来花了多少」。本方案补上:
1. **模型筛选**——筛选条第六格,与时间 / 成员 / Key / 来源 / 订阅 / 类型并列
2. **汇总条**——筛选条与表格之间一条**低高度**读数条,只展示**请求次数** + **消费用量(quota + $)**
范围仅两端调用日志页及其数据链路;**不**改总览 by_member / by_key / by_source,**不**加 by_model 分组卡(R5/R9 的「按模型」分组统计另议)。
---
## 2. 交互(已议定,实施以此为准)
### 2.1 页面骨架(两端同构)
```
PageHeader(标题 + 时间范围)
筛选条(成员/Key | 模型 | 计费来源 | 订阅 ID | 类型) ← 模型新增
汇总条(请求次数 · 消费用量) ← 新增,低高度单行
日志表(既有列不变)
分页
```
成员端无「成员」筛选,其余同构。
### 2.2 模型筛选
| 项 | 约定 |
|----|------|
| 位置 | 筛选条内,Key 与计费来源之间(谁 → 调了什么 → 记在哪) |
| 形态 | Select,`showSearch` + `allowClear`;空值 = 全部模型 |
| 选项 | 当前 `from/to` 与主体筛选(成员/Key)下、时间窗内去重的 `model_name`;**不含**模型自身与 type 筛选,避免「选了只剩自己」 |
| 变更 | 回第 1 页;URL 同步 `model`(与 `member_id` 等同级) |
| 宽度 | 与相邻筛选格一致(约 120–140px),可搜避免长模型名截断误选 |
### 2.3 汇总条
| 项 | 约定 |
|----|------|
| 位置 | 筛选条下方、表格上方——改条件 → 扫总量 → 下钻明细 |
| 高度 | **单行**,约 32–36px;浅底或细分隔,不做成大卡片 |
| 内容 | 两段:**请求次数**(当前筛选下总条数)· **消费用量**(quota + $,`QuotaText`) |
| 口径 | **请求次数**跟全部筛选(含 type);**消费用量**恒 `type = 2` + 其余筛选(既有裁决:聚合仅消费行) |
| 类型筛「错误」时 | 请求次数 = 错误条数;消费用量仍显示同期同条件的消费口径,标签保持「消费用量」不叫「花费」,避免误读为错误行开销 |
| 加载 | 查询中数字区骨架/淡化,不闪上一轮数字 |
| 空结果 | 汇总条保留,数字归零/`—`;表格空态不变 |
文案示例:
- 类型全部:`请求次数 131 条(消费 128 / 错误 3)` + `消费用量 1,250,000 quota ≈ $2.50`
- 类型=错误:`请求次数 3 条` + `消费用量 …`(消费口径不变)
- 零结果:`请求次数 0 条` + `消费用量 0 quota ≈ $0.00`
「消费 128 / 错误 3」在错误数为 0 时可只显示 `请求次数 128 条`,保持条更短。
---
## 3. API 契约变更
两端 `GET /api/admin/logs` / `GET /api/member/logs` **同名同义**扩展;DTO 继续一份复用。
### 3.1 请求
新增可选查询参数:
| 参数 | 类型 | 说明 |
|------|------|------|
| `model_name` | string | 精确匹配 `usage_logs.model_name`;空/缺省 = 不筛 |
其余参数不变。`from/to` 仍必填。
### 3.2 响应
在现有 `{ items, total }` 上增加 `summary` 与 `models`:
```json
{
"items": [ /* 不变 */ ],
"total": 131,
"summary": {
"hit_count": 131,
"usage_count": 128,
"error_count": 3,
"quota": 1250000,
"quota_usd": "2.50"
},
"models": ["claude-sonnet-4", "gpt-5"]
}
```
| 字段 | 语义 |
|------|------|
| `total` | 既有:当前筛选下命中总数(分页用) |
| `summary.hit_count` | 与 `total` 同值(汇总条「请求次数」;保留字段便于前端不依赖分页语义) |
| `summary.usage_count` | 当前筛选下 **type=2** 条数 |
| `summary.error_count` | 当前筛选下 **type≠2** 条数(`hit_count = usage_count + error_count`) |
| `summary.quota` / `quota_usd` | 当前筛选下 **type=2** 行的 quota 合计与美元(公共约定 §5:微分累加、一次取整) |
| `models` | 时间窗 + 主体筛选下去重的 `model_name` 升序列表(选项源;**不含** `model_name` / `type` 筛选) |
口径备注(写入契约裁定区):
1. `summary.usage_*` / `quota*` **不含** type 筛选与 `model_name` 筛选以外的 type 限制——即 **model_name 与主体/时间/来源/订阅参与,type 不参与用量字段**;`hit_count` / `error_count` / `usage_count` 的**条数**则跟全部筛选。
2. 更精确说:
- **条数字段**(`hit_count` / `usage_count` / `error_count`)→ 全部筛选(含 `type`、`model_name`)
- **用量字段**(`quota` / `quota_usd`)→ 同等筛选但 **固定 type=2**(即使请求 `type=error`)
3. `quota_usd` 换算沿「按日志时刻取 QPU」——汇总不得用单一当前汇率乘总 quota。
`models` 轻量查询:`DISTINCT model_name`,条件 = 时间窗 + 主体/来源/订阅(与列表一致),**刻意不套** `model_name` 与 `type`。办公室规模(模型十来个)直接全量返回,不分页。
### 3.3 契约文档落点
- 《API契约-管理面》§6:过滤表加 `model_name`;响应从 `{items,total}` 扩为含 `summary`/`models`
- 《API契约-成员视图》日志节:同名同义同步
- 《API契约-公共约定》:若需,补一句「列表响应可附带与筛选同源的汇总块」——或在各自契约内写死,避免总纲膨胀
---
## 4. 数据层(`packages/db`)
### 4.1 筛选
`repos.logs.listPage` / `listMemberPage` 参数增加 `model_name?: string`,条件 `eq(usageLogs.model_name, model_name)`(非空时)。
### 4.2 汇总
新增 `repos.logs.summarize` / `summarizeMember`(或 list 方法返回体扩展,推荐**独立方法**以免分页热路径多跑聚合):
- 条数:SQL `count()`,条件与列表筛选一致(含 type)
- 用量:`count() filter (type=2)` + `sum(quota) filter (type=2)`;`quota_usd` 需按行 QPU——**取 type=2 行的 `(quota, created_at)` 交给 `sumUsage`**(与总览聚合同一套微分规则)
- 选项:`select distinct model_name`,条件同列表但去掉 `model_name` 与 `type`
办公室查询频率低、命中行数量级可控;**不做**预计算表(需求清单「统计 = 派生视图」)。
时间范围仍走 `created_at` 索引;`model_name` 无索引,精确等值 + 时间窗过滤后残余行少,**暂不**加索引(表设计不为此改;若日后慢查询再评估)。
### 4.3 类型共享
`packages/core`:
- `logPage` schema:增加 `summary`(见下)与 `models: z.array(z.string())`
- 新导出 `LogPageSummary`(或复用/扩展 `UsageTotals`——字段更多,独立类型更清晰):
```ts
interface LogPageSummary {
hit_count: number;
usage_count: number;
error_count: number;
quota: Quota;
quota_usd: QuotaUsd;
}
```
---
## 5. 服务端路由
| 文件 | 改动 |
|------|------|
| `apps/server/src/routes/admin-usage.ts` | `GET /logs` query 增 `model_name`;调用 list + summarize + distinct models;组 `summary`(`sumUsage` → quota_usd);响应扩字段 |
| `apps/server/src/routes/member-view.ts` | 同上(`listMemberPage` / member 侧汇总与选项) |
| `apps/server/src/api-schemas.ts` | `logPageSchema` 随 core 更新 |
OpenAPI 描述字符串同步(summary 文案提到模型筛选与汇总块)。
---
## 6. 前端(`apps/web`)
### 6.1 API 层
- `api/admin/logs.ts`:`AdminLogFilters` 加 `model_name?: string`;返回类型改为带 `summary` / `models` 的分页结构
- `api/member/me.ts`:`MemberLogQuery` 同上
### 6.2 汇总条组件
新建共享组件(两端复用,避免双份样式漂移):
- 路径建议:`apps/web/src/components/common/logs-summary-bar.vue`
- Props:`hitCount` · `usageCount` · `errorCount` · `quota` · `quotaUsd` · `loading`
- 布局:单行 flex,左「请求次数…」右「消费用量…」;`loading` 时数字占位
- 花费数字复用 `QuotaText`
- 样式对齐共性:弱标签 + 主数字,高度 32–36px,间距遵循页面既有 `gap`
### 6.3 两端 logs-view
| 项 | 改动 |
|----|------|
| 状态 | `modelFilter: ref<string \| null>` |
| params | 带上 `model_name` |
| 模型选项 | 读响应 `models`(或独立 query 缓存按 `from/to+主体` 键) |
| 模板 | 筛选条插入模型 Select;筛选条与 `a-table` 之间插入 `LogsSummaryBar` |
| URL | `syncFromRoute` / 写回增加 `model` |
| 筛选变更 | 既有「回第 1 页」逻辑覆盖 model |
列结构、详情弹窗、跳转链路**不动**。
### 6.4 样式
筛选条若因第六格换行,允许两行(共性已允许一行或两行);汇总条始终单行不换行,过窄时隐藏次要括注(如「/ 错误 3」)。
---
## 7. 设计文档同步
实现时同步修订(不另开版本叙事):
| 文档 | 修订点 |
|------|--------|
| 《交互设计-调用日志》 | §2 筛选表加「模型」;§3 前插入汇总条小节(位置、两段、口径);URL 同步参数补 `model` |
| 《交互设计-成员视图》§4 | 同构引用或摘要 + 成员端差异 |
| 《API契约-管理面》§6 | 过滤 + 响应扩展 + 口径裁定 |
| 《API契约-成员视图》 | 同名同义 |
| README 文档地图 | 本篇条目 |
状态流转:本篇「待审阅」→ 实现完成后「已实现(日期)」。
---
## 8. 实现顺序
1. ✅ `packages/core`:`LogPageSummary` + `LogPage` + `logPage` schema 扩展
2. ✅ `packages/db`:`buildLogConds`、`model_name` 筛选、`summarize` / `summarizeMember`、`listModels` / `listMemberModels`
3. ✅ `apps/server`:两端 `/logs` 接线(summary + models)
4. ✅ `apps/web`:API 类型 → `logs-summary-bar` → admin / member logs-view
5. ✅ 设计文档修订 + README 文档地图
6. ✅ `bun run typecheck` + `bun run build:web` 通过
---
## 9. 验收口径
- [x] 管理端 / 成员端均可按模型筛选,选项来自时间窗内真实数据,可搜可清空
- [x] 筛选变更回第 1 页;URL 带 `model_name`,深链可还原
- [x] 汇总条位于筛选条与表格之间,**单行低高度**,仅「条数 + 消费用量」
- [x] 消费用量恒 type=2 + 其余筛选;类型筛错误时命中=错误数、用量仍为消费口径
- [x] `quota_usd` 按行 QPU 微分汇总(`sumUsage`)
- [x] 切换筛选时汇总加载态不闪旧数字(`is-loading` 淡化 + 占位)
- [x] typecheck / build 通过;无新依赖
---
## 10. 明确不做
- 总览页 by_model 分组卡(R5/R9 多维统计的另一落点,单议)
- 模型多选、模糊匹配(先精确单选;不够再扩)
- `model_name` 索引 / 预计算汇总表
- Tokens 段进汇总条(已议定砍掉,列内仍有)
- 明细列增减、详情弹窗改造
+14 -18
View File
@@ -22,7 +22,6 @@
| 调度 | **独立进程 `apps/engine`**:手写 `setTimeout` 循环(细则见《同步引擎设计》) |
| 加密 | WebCrypto(Bun 原生) |
| 前端 | **Vue 3 + antdv-next + Vite** |
| 部署 | **Docker**(倾向,§6) |
## 2. 语言:TypeScript 全栈
@@ -39,49 +38,47 @@
| 数据访问 | **Drizzle ORM** | TS 优先、查询保持 SQL 形态(不藏 SQL,利于学与审计);schema 即代码,drizzle-kit 迁移配套齐全 | Kysely / 裸驱动 + 手写仓储 |
| pg 驱动 | **postgres.js** | Drizzle 文档主推;查询构建器式 API;内置连接池;Bun 兼容好 | node-postgres |
| 前端框架 | **Vue 3** | 作者近年常用栈;实现期自由度(共性 §3.5「作者即前端」) | — |
| 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§7.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) |
| 组件库 | **antdv-next**(作者指定尝试项) | Vue 3 企业组件库、对齐 ant-design(React 上游)设计体系;**已核验通过**(§6.1):活跃周更、§3.7 清单逐项有对应组件、Agent 文档齐备 | Element Plus / ant-design-vue(§3.7 全覆盖) |
| 加密 | **WebCrypto** | HKDF / AES-256-GCM / HMAC-SHA256 / PBKDF2 与裁决一致(密钥体系见各表设计);Bun 原生 | HKDF 若 Bun 覆盖滞后:基于 HMAC 的纯 TS 垫片(同原语) |
| 构建 / 测试 | **Vite**(web)+ **bun test**(core/db/server) | Bun 自带 test runner;Vite 为 Vue 生态默认 | 前端组件测试可加 vitest(实现期定) |
| 构建 / 测试 | **Vite**(web);质量门 = `typecheck` + `build` + 人工验收 | 前端构建走 Vite 生态默认;自动化测试不在当前范围 | 需要时再引入测试框架 |
| API 文档与校验 | **zod** + **@hono/zod-openapi** + **Scalar**(作者确认) | 一源三用:TS 类型 / 请求校验(`422` 与 `fields` 的机器来源)/ OpenAPI 生成;Scalar 为 Hono 官方搭档,规范与渲染器解耦 | Swagger UI(规范不动、换皮即可) |
## 4. 分层落地:monorepo(Bun workspaces)
```text
(仓库根 · v1 分支)
(仓库根)
├─ apps/
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 进程内调度 · 环境变量与密钥注入
│ ├─ server/ # 母体后端:Hono serve 装配 · Drizzle/pg · 环境变量与密钥注入
│ ├─ engine/ # 同步引擎:独立进程(《同步引擎设计》)
│ └─ web/ # 前端:Vue 3 + antdv-next + Vite
├─ packages/
│ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储实现
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储 + 迁移 CLI
├─ scripts/ # 打包/构建 CLI(pack release / build)
```
**依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。
**代码落点**:实现代码与设计文档同在 v1 分支演进。
**代码落点**:实现代码与设计文档同仓演进。
## 5. R14 预留:现阶段零成本,只为后续抽取不挖坑
R13 的 CFW 抽取版仍是长期裁决,功能全集实现后启动,届时按 R14「从母体抽业务核心、换基础设施层」执行。本稿只保留两条现阶段零成本、届时免重写的预留:
1. **Bun 纪律**:`packages/core` 只使用 TypeScript 标准库与 Web 标准 API(`fetch` / WebCrypto / `URL` / `TextEncoder` / `setTimeout` 等);`Bun.*`、`bun:*`、`node:*` 与具体数据库驱动只出现在 `apps/server` 与 `packages/db`——届时 CFW 形态的绑定物(D1 等)同样只落在新的装配层,core 原样搬运;
1. **Bun 纪律**:`packages/core` 只使用 TypeScript 标准库与 Web 标准 API(`fetch` / WebCrypto / `URL` / `TextEncoder` / `setTimeout` 等);`Bun.*`、`bun:*`、`node:*` 与具体数据库驱动只出现在 `apps/*` 装配层、`packages/db` 与 `scripts/`——届时 CFW 形态的绑定物(D1 等)同样只落在新的装配层,core 原样搬运;
2. **方言不外泄**:pg 特有物(`jsonb` 等)只出现在 `packages/db` 的 schema 与仓储实现内,不进 core——届时补一份 SQLite 方言 schema 即可,core 与仓储接口不动。
Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手之得,不构成现阶段约束。
## 6. 部署通道(倾向,细则归后续部署设计)
母体以 **Docker 镜像**交付(server 容器 + PostgreSQL,compose 编排):满足仓库「一条命令链可部署」约定;运营方服务器零环境依赖(Bun 运行时随镜像内置),升级 = 换镜像重启;作者不接触服务器,镜像化把运行时选择与运营方环境解耦。
## 7. 风险与回退
## 6. 风险与回退
| 风险 | 评估 | 对策 |
|------|------|------|
| antdv-next 项目年轻、维护者 3 人 | 桌面核验通过(§7.1);未核验项:社区采用规模 | 实现期第一步做 **§3.7 能力 spike**(实测);锁版本;实测不满足即触发回退(Element Plus / ant-design-vue) |
| antdv-next 项目年轻、维护者 3 人 | 桌面核验通过(§6.1);未核验项:社区采用规模 | 实现期第一步做 **§3.7 能力 spike**(实测);锁版本;实测不满足即触发回退(Element Plus / ant-design-vue) |
| Bun 生态资料少于 Node、边角兼容性 | 本项目规模小、依赖面窄(Hono / Drizzle / postgres.js 均成熟) | **Bun → Node 回退不伤业务代码**:换 `@hono/node-server` 装配即可,packages/* 原样 |
| Bun 的 WebCrypto 原语覆盖(HKDF 等) | 未逐项实测 | 实现期首轮加密任务实测;缺失则 HMAC 纯 TS 垫片 |
### 7.1 antdv-next 核验记录(2026-10-02,一手源直查)
### 6.1 antdv-next 核验记录(2026-10-02,一手源直查)
口径:npm registry / GitHub API / 官网 llms.txt 直查(当轮无浏览器检索通道——社区口碑与采用规模未核验)。
@@ -96,7 +93,6 @@ Hono(双运行时可跑)与 WebCrypto 本身恰好与 CFW 兼容,属顺手
结论:**通过核验,维持选定**;§3.7 spike 与回退预案照旧——桌面核验不替代实测。
## 8. 待裁决项
## 7. 待裁决项
1. Docker 细则:基础镜像、compose 形态、密钥注入方式——部署设计时定;
2. 前端测试设施是否引入 vitest。
1. 前端测试设施是否引入 vitest。
+330
View File
@@ -0,0 +1,330 @@
# 部署方案
> 母体(完整前后端 + PostgreSQL)的交付与更新方案:产物包形态、服务器目录约定、容器编排、迁移时机、安全边界与固定操作流程。**范围**:只定「东西怎么装到运营方服务器、怎么更新」;不改业务语义,不覆盖 R13 的 CFW 抽取版部署面。设计对齐作者现场运维心智——**本地产物 → 传到服务器约定目录 → 一条固定命令 → 看容器状态**。
| 项目 | 内容 |
|------|------|
| 状态 | 已实现(定稿 2026-10-12) |
| 关联 | `技术选型.md`(Bun / Hono / Drizzle / Vue)· `同步引擎设计.md`(独立 `apps/engine` 进程)· `需求清单.md` R13/R14 · `users表字段设计.md`(部署期初始化)· `README.md` |
| 产出 | 产物包布局 · 服务器目录 · Dockerfile / Compose / Caddy 要点 · 打包与更新流程 · 迁移与安全约定 |
---
## 1. 设计约束(来自现场经验与项目事实)
1. **操作者心智**(作者工作流,优先对齐):
- 前端:本地打包 `dist/` → 放到服务器指定目录 → 执行固定命令 → `docker ps` 看容器全绿;
- 后端:拿到 `jar` 类产物 → 上传指定目录 → 同一固定命令 → 看容器。
2. **仓库事实**:`apps/server` 与 `apps/engine` 两个独立进程;前端为静态 `dist`(server 不托管静态);数据在 PostgreSQL;配置全走 env;`packages/db` 已有按 journal 的幂等 `runMigrations`。
3. **能力边界**:作者是前端,Docker 不娴熟;服务器侧只需 Docker,不要求 Bun / Node / 源码构建能力。
4. **协作约定**:交付物保证「一条命令链」可部署(AGENTS);密钥 `SK_KEY_ENCRYPTION_SECRET` 丢失 = 已登记 key 全部无法解密。
由此收敛出的形态一句话:**常驻 Docker 编排 + 每次更新只替换产物包 + 一条 compose 命令完成重建/重启/迁表。**
---
## 2. 总体结构
```text
本地(打包) 服务器(运营方)
────────── ────────────────
bun run pack release --yes /opt/naku/ ← 约定目录
├─ 前端 dist/ ├─ docker-compose.yml
├─ naku-server(linux 单文件) ├─ Dockerfile
├─ naku-engine(linux 单文件) ├─ .dockerignore
├─ naku-migrate(linux 单文件) ├─ Caddyfile
└─ drizzle/(迁移 SQL + journal) ├─ entrypoint-pg.sh
│ ├─ entrypoint-app.sh
▼ ├─ .env ← 密钥在此,不进包
release/ ──── 上传覆盖 ──────────► └─ release/ ← 每次更新整目录覆盖
固定命令: docker compose up -d --build
验收: docker compose ps
```
| 角色 | 职责 | 谁改 |
|------|------|------|
| 常驻编排(compose / Dockerfile / Caddyfile / entrypoint-*.sh / .env) | 描述系统怎么跑 | 装机时放好;改架构时才动 |
| 产物包 `release/` | 当前要跑的版本 | 每次发版整目录替换 |
首次安装与日常更新**同一套命令**;差别只是首次多一步「把常驻编排放到服务器」。
---
## 3. 产物包 `release/`
本地 `bun run pack release --yes` 产出,内容固定为:
```text
release/
dist/ # apps/web 构建产物(index.html · assets/…)
naku-server # Bun --compile,目标平台 linux-x64
naku-engine # 同上
naku-migrate # 同上;只跑迁移后退出
drizzle/ # packages/db/drizzle/ 的 SQL + meta/_journal.json
```
### 3.1 打包约定
| 项 | 约定 |
|----|------|
| 命令入口 | 优先 `bun run pack release`(交互:平台 / 内容 / tar.gz);一键等价 `bun run pack release --yes` |
| 脚本落点 | `scripts/cli.ts`(citty + clack)· `scripts/pack-core.ts`(纯打包逻辑) |
| 目标平台 | 默认 `bun-linux-x64`;`--target=` 可切 `bun-linux-arm64` / `bun-darwin-*` / `bun-windows-*`(含 arm64) |
| 构建顺序 | 清空输出目录 → `build:web` → 三次 `--compile` → 拷 `drizzle/` →(可选)tar.gz |
| 禁止入包 | `.env` · 源码 · `node_modules` · 任何密钥 |
| 校验 | 打包后检查五条目都在且非空(Windows 本机编译允许 `.exe` 后缀);缺一即失败退出 |
| 本地试跑 | `pack build` / `build:server` / `build:engine` → `bin/`(当前平台),与部署包 `release/` 分离 |
### 3.2 为何是单文件而不是「服务器上构建」或「上传源码」
- 对齐 `jar` 心智:产物即交付物,服务器零构建依赖;
- 更新体积小、失败面小(不跑 `bun install` / `vite build`);
- 运行时镜像只需 Debian slim + `ca-certificates` + `libstdc++6`,不需要 Bun。
代价:目标平台写在打包脚本里;换 CPU 架构需重新打包(在脚本参数上留出口即可)。
---
## 4. 服务器目录(`/opt/naku/`)
路径可改,下文以 `/opt/naku` 指代「约定目录」。
```text
/opt/naku/
docker-compose.yml
Dockerfile
.dockerignore
Caddyfile
entrypoint-pg.sh
entrypoint-app.sh
.env
release/ ← 上传覆盖,不入库
```
---
## 5. 配置文件设计
### 5.1 `Dockerfile`(两阶段,只吃 `release/`)
```dockerfile
# ---------- 应用运行时 ----------
# 用 Debian slim:与 Bun --compile(glibc)同源,兼容面更稳;
# CA 与 libstdc++ 显式安装,不依赖 slim 是否自带
FROM debian:bookworm-slim AS app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libstdc++6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# --chmod:Windows 打包 / 跨机传输常丢可执行位,缺 x 时容器内 exec 以 126 退出
COPY --chmod=755 release/naku-server release/naku-engine release/naku-migrate /app/
COPY release/drizzle /app/drizzle
ENV MIGRATIONS_FOLDER=/app/drizzle
CMD ["/app/naku-server"]
# ---------- 网关(Caddy + 前端静态) ----------
FROM caddy:2-alpine AS proxy
COPY Caddyfile /etc/caddy/Caddyfile
COPY release/dist /srv
```
要点:
- **构建期不编译业务代码**,只装配产物;`docker compose up -d --build` 的语义是「用当前 `release/` 重建镜像并重启」;
- 单文件二进制用 **`COPY --chmod=755`**:Windows 打包 / 跨机传输常丢可执行位,缺 `x` 时容器内 `exec` 以 **exit 126**(Permission denied)退出,迁移与应用都不会启动;
- `app` 阶段一个镜像供 `server` / `engine` / `migrate` 三用,靠 compose 的 `command` 区分;
- 应用运行时选 **`debian:bookworm-slim`**(glibc,对齐 Bun `--compile` 目标);`ca-certificates`(HTTPS 证书校验)与 `libstdc++6`(单文件 C++ 运行时)显式安装——两个都不指望镜像自带;
- 网关仍用官方 `caddy:2-alpine`(与应用基底无关,体积友好);
- PostgreSQL 使用官方镜像,不进本 Dockerfile。
### 5.2 `docker-compose.yml`(五服务)
| 服务 | 来源 | command | 说明 |
|------|------|---------|------|
| `postgres` | `postgres:17-alpine` | 官方入口 | 卷 `pgdata`;`healthcheck` 用 `pg_isready` |
| `migrate` | `target: app` | `/app/naku-migrate` | **一次性**:`restart: "no"`;迁完退出 |
| `server` | `target: app` | `/app/naku-server` | 管理面 / 成员视图 API |
| `engine` | `target: app` | `/app/naku-engine` | 轮询同步 |
| `caddy` | `target: proxy` | 官方入口 | **唯一对外**:`80` / `443` |
依赖关系:
```text
postgres (healthy) ──► migrate (completed successfully) ──► server ──► caddy
└──────► engine
```
要点:
1. **`migrate` 独立服务**,而不是塞进 server 启动:语义是「部署时迁表」,避免 server/engine 双进程抢迁;`depends_on: service_completed_successfully` 保证先迁后跑。
2. **环境变量**:`DATABASE_URL` · `SK_KEY_ENCRYPTION_SECRET` · `NEWAPI_BASE` · `COOKIE_SECURE` · `LOG_LEVEL` · `SYNC_INTERVAL_MS` · 可选 `ADMIN_INIT_*`——均从 `.env` 注入 `server` / `engine`(`migrate` 只需要 `DATABASE_URL`)。
3. **`DATABASE_URL` 单源**:用户 / 密码 / 库名都写在这一串里;`entrypoint-pg.sh` 解析出 `POSTGRES_USER/PASSWORD/DB` 喂给官方库镜像,`entrypoint-app.sh` 把主机改写为 compose 服务名 `postgres`。开发与部署共用同一份 `.env` 语义。
4. 端口:仅 `caddy` 映射到宿主机;默认 `80`/`443`,可用 `.env` 的 `HTTP_PORT` / `HTTPS_PORT` 覆盖(容器内仍为 80/443)。`server` / `engine` / `postgres` 均不对外。
5. **容器名固定**(`container_name`):`naku-postgres` / `naku-migrate` / `naku-server` / `naku-engine` / `naku-caddy`——单副本约定下使用;服务间 DNS 仍用服务名(如 `server:18456`)。
### 5.3 `Caddyfile`
```caddyfile
# 站点地址按部署环境改:
# 本地/内网 HTTP → :80
# 公网 HTTPS → your.domain.example(Caddy 自动签证书)
:80 {
root * /srv
handle /api/* {
reverse_proxy server:18456
}
handle {
try_files {path} /index.html
file_server
}
}
```
与 `API契约-公共约定` 的命名空间约定一致:`/api/*` 归接口,其余归 SPA(非 `/api` 回退 `index.html`)。
### 5.4 `entrypoint-pg.sh` / `entrypoint-app.sh`
| 脚本 | 职责 |
|------|------|
| `entrypoint-pg.sh` | 从 `DATABASE_URL` 拆出 `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB`,`exec` 官方 `docker-entrypoint.sh postgres` |
| `entrypoint-app.sh` | 将 `DATABASE_URL` 的主机段改写为 `postgres`(保留端口与路径),再 `exec "$@"` 启动传入的二进制 |
二者属**常驻编排**,打进镜像或只读挂载皆可(建议只读挂载,改脚本不用重建镜像)。
### 5.5 `packages/db/src/migrate-cli.ts`(新增入口)
```text
loadEnv → createDb(DATABASE_URL) → runMigrations(db, MIGRATIONS_FOLDER) → 退出码
```
- 复用已有 `runMigrations`(Drizzle migrator,**按 journal 幂等**:已应用的迁移号跳过);
- 失败打日志并以非 0 退出,使 `migrate` 服务失败 → `server`/`engine` 不会启动(`service_completed_successfully` 门闩)。
---
## 6. 操作流程
### 6.1 首次安装
```bash
# 本地
bun run pack release --yes
# 服务器
# 将 docker-compose.yml · Dockerfile · .dockerignore · Caddyfile · entrypoint-*.sh 放入 /opt/naku/
# 将 release/ 上传到 /opt/naku/release/
cp .env.example /opt/naku/.env # 再编辑填写
chmod 600 /opt/naku/.env
cd /opt/naku
docker compose up -d --build
docker compose ps
```
### 6.2 日常更新(与首次同一命令)
```bash
# 本地
bun run pack release --yes
# 上传,整目录覆盖 /opt/naku/release/
# 服务器(固定命令)
cd /opt/naku
docker compose up -d --build
docker compose ps
```
- 有表变更时,新 `release/drizzle/` 自动带新 SQL,`migrate` 启动时补跑——无需手工;
- 观察构建/迁移日志:`docker compose logs migrate`;同步:`docker compose logs -f engine`。
### 6.3 HTTPS(可选)
1. `Caddyfile` 站点行 `:80` → 域名;
2. `.env` 中 `COOKIE_SECURE=true`;
3. 重新执行固定命令。
### 6.4 验收标准
- `docker compose ps`:`postgres` healthy,`server` / `engine` / `caddy` running,`migrate` exited(0);
- 浏览器打开站点可见页面;`/api/docs` 可访问;
- `docker compose logs engine` 出现就绪/轮次日志(已配置 key 时)。
---
## 7. 迁移策略(裁决)
| 问题 | 裁决 |
|------|------|
| 会不会「多灌几次」? | **不会。** 使用 Drizzle `migrate()`(journal 表),重复执行只应用未跑过的迁移号 |
| 谁来跑? | compose 一次性服务 `migrate`(`naku-migrate` 单文件),不随 server/engine 长驻 |
| 何时跑? | 每次固定命令(含首次)启动/更新时 |
| 不用什么? | **不用 `db:push` 进部署链**——它是 schema diff 硬推,不适合生产更新命令 |
| 失败怎么办? | `migrate` 非 0 退出 → `server`/`engine` 不启动;修复 SQL 或数据后重跑固定命令 |
| `migrate` exit 126? | 二进制缺可执行位(Windows 打包 / 传输丢 `x`)。镜像内已 `COPY --chmod=755` 根治;宿主机侧应急 `chmod +x release/naku-*` 后 rebuild |
---
## 8. 安全约定
| 点 | 约定 |
|----|------|
| `.env` 位置 | 只在服务器约定目录;**不进产物包、不进 git** |
| 权限 | `chmod 600 .env`,仅部署用户可读 |
| `SK_KEY_ENCRYPTION_SECRET` | 生成一次、入库后不可更换;**与数据库同等备份**(`.env` 必须备份) |
| 对外暴露 | 仅 Caddy 的 80/443;库与应用进程不映射公网端口 |
| `COOKIE_SECURE` | 必须与访问方式匹配:HTTP + `true` 会「登录后立刻 401」;HTTPS/域名用 `true` |
| 日志 | 不打印密钥与完整 `DATABASE_URL`(含密码) |
| 范围说明 | 单运营方内部服务器场景;不上 vault/KMS。若未来有多租户或公有多环境,再升档 |
---
## 9. 与开发方式的关系
| | 开发 | 部署 |
|--|------|------|
| 进程 | `bun run dev:server` / `dev:engine` / `dev:web` | compose:`server` / `engine` / `caddy` |
| 库 | 任意 PostgreSQL,`DATABASE_URL` 指向即可 | compose 内 `postgres`,同一 `DATABASE_URL` 语义(主机由 entrypoint 改写) |
| 建表 | `bun run db:push`(开发期) | `migrate` 服务(生产幂等迁移) |
| 前端 | Vite dev server | `release/dist` 由 Caddy 托管 |
| 配置 | `.env` | 服务器 `.env` |
开发**不依赖**本方案的 Docker 配置;部署也**不依赖**本机 Bun。
---
## 10. 文件改动清单
| 文件 | 动作 |
|------|------|
| `scripts/cli.ts` · `scripts/pack-core.ts` | 打包/构建 CLI 与核心逻辑 |
| `packages/db/src/migrate-cli.ts` | 迁移 CLI 入口 |
| `package.json` | `pack` · `build:server` · `build:engine` |
| `Dockerfile` · `docker-compose.yml` · `.dockerignore` | 两阶段镜像 · 五服务(含 `container_name`) |
| `Caddyfile` · `entrypoint-pg.sh` · `entrypoint-app.sh` | 与 compose 同级平铺 |
| `README.md` | 「部署」章与文档地图 |
| `AGENTS.md` | 命令表(pack / build) |
| `.env.example` | 部署语义注释 |
---
## 11. 已知取舍(否决时改这里)
1. **`migrate` 独立单文件**:多一个产物,换来「先迁后跑、失败不启」的清晰门闩。若嫌多,可改为 server 启动时迁(仍幂等),但失去 migrate 失败阻断 engine 的不对称控制。
2. **目标平台写死 linux-x64**:匹配常见服务器;ARM 用打包参数切换,不为此引入交叉编译矩阵。
3. **`DATABASE_URL` 单源 + 两个 entrypoint**:少一处密码不一致;多两个常驻小 shell。若偏好 compose 原生 `POSTGRES_*`,需接受密码在 `.env` 写两处(或 `DATABASE_URL` 仅应用用)。
4. **产物包而非源码包**:服务器零构建;代价是发版必须在本地(或 CI)跑 `pack release --yes`。
---
## 12. 验收清单(实现后)
- [ ] `bun run pack release --yes` 在 Windows 产出完整 `release/`(五条目齐全)
- [ ] 首次安装按 §6.1 一次命令链可起,`docker compose ps` 形态符合 §6.4
- [ ] 仅替换 `release/` 后重跑固定命令,版本切换成功且数据保留(卷不重建)
- [ ] 连续两次固定命令:第二次 `migrate` 不重复应用已存在迁移(日志可证)
- [ ] 故意放一份坏 SQL:`migrate` 失败,`server`/`engine` 未启动
- [ ] `COOKIE_SECURE` 与 HTTP/HTTPS 交叉场景符合 §8
- [ ] 产物包内无 `.env`、无源码、无 `node_modules`
+32 -11
View File
@@ -4,8 +4,8 @@
| 项目 | 内容 |
|------|------|
| 状态 | 定稿(2026-10-01) |
| 关联 | `README.md`(项目沿革与动因)· 七篇表字段设计(定稿)· `docs/references/NewAPI-接口范围.md`(可用接口参考) |
| 状态 | 定稿(2026-10-01;R16 定稿 2026-10-10) |
| 关联 | `README.md`(背景、动因与架构约束)· 各篇表字段设计 · `docs/references/NewAPI-接口范围.md`(可用接口参考) |
| 取代 | 《设计议题》(2026-09-29 由本清单取代;其中双形态裁决与 CFW 调研结论已并入本文 §5/§7) |
---
@@ -19,7 +19,7 @@
**背景**(三类问题促成当前设计阶段):
1. **迭代部署成本**:服务器部署形态下每次迭代都需实例运营方手工升级,测试阶段迭代频繁而运营方无暇跟随 → 双形态交付(R13,已裁决)
2. **初版设计刻意收缩的部分**:管理面固定口令不妥(R11)、充值折算(R7)、多订阅处理(R8)
2. **刻意收缩后重新纳入的部分**:管理面固定口令不妥(R11)、充值折算(R7)、多订阅处理(R8)
3. **已知设计问题**:错误行 `request_id` 去重(R15 必修项)
## 1. 数据采集域
@@ -56,20 +56,21 @@
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R11 | 平台身份体系与鉴权 | 访问主体 = 作者 + 成员(运营方完全信任,不设身份)。**成员身份与管理面鉴权均已裁决**(2026-09-29,见裁决记录) | **已裁决**(2026-09-29) |
| R12 | 成员自助视图 | 令牌进成员视图:**名下 key 的模型调用日志**——每条记录额外携带所属订阅/充值的信息(具体携带形态待核查后设计)+ 用量聚合(quota / 美元,按来源分解);**完全公开透明**(订阅与支付参数对成员可见);无「应付」概念(分摊是社交层问题);登录壳交互(输令牌 → sessionStorage,关标签失效) | **已裁决**(2026-09-29) |
| R12 | 成员自助视图 | 令牌进成员视图:**名下 key 的模型调用日志**——每条记录额外携带所属订阅/充值的信息(具体携带形态待核查后设计)+ 用量聚合(quota / 美元,按来源分解);支付参数**同账本域内可见**(同域透明、跨域隔离;未标域默认藏,详见各表 `billing_domain` 与《API契约-成员视图》§5);无「应付」概念(分摊是社交层问题);登录壳交互(输令牌 → sessionStorage,关标签失效) | **已裁决**(2026-09-29;2026-10-13 补账本域可见性) |
| R16 | 成员档案维护与空壳删除 | **修改**:开户后可改 `name` / `remark`(误建、挂 key 前改名等真实场景);`status` 走启停、令牌走重发,不并入档案编辑。**删除**:仅当**当前无任何 key 归属**(`key_count = 0`)才允许硬删——清理空壳,不触碰账本事实;名下有 key 只能停用(软删除语义不变) | **已裁决**(2026-10-10) |
## 5. 交付域(已裁决)
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R13 | 双形态交付 | **完整前后端(先行,母体)**:服务器部署的前后端分离应用 + PostgreSQL(后端单进程承载 API 路由与同步引擎),功能全集在此形态上设计与实现;**CFW 抽取版(后续,衍生)**:Cloudflare Workers(API + Cron Triggers 轮询)+ D1 攒库 + 前端静态资产托管,从母体中抽取业务核心、替换基础设施层——承载「部署权归作者」的目标形态 | **已裁决**(2026-09-29) |
| R13 | 双形态交付 | **完整前后端(先行,母体)**:服务器部署的前后端分离应用 + PostgreSQL(`apps/server` 承载 API 路由,`apps/engine` 独立进程同步——见《同步引擎设计》2026-10-05),功能全集在此形态上设计与实现;**CFW 抽取版(后续,衍生)**:Cloudflare Workers(API + Cron Triggers 轮询)+ D1 攒库 + 前端静态资产托管,从母体中抽取业务核心、替换基础设施层——承载「部署权归作者」的目标形态 | **已裁决**(2026-09-29;进程拆分修订 2026-10-05) |
| R14 | 可移植性分层 | R13 的横切约束:业务核心(轮询逻辑 / `request_id` 去重 / 折算链计算 / 加密体系 / API 语义)与形态耦合面(HTTP 框架 / 调度器 / 数据库驱动 / 托管部署)分离;数据访问层隔离 PostgreSQL 与 SQLite/D1 方言(`jsonb` → TEXT + SQLite JSON 函数、类型与索引映射);轮次语义统一(幂等、错过即补——每轮全量拉最近 1000 条天然补偿) | **已裁决**(2026-09-29) |
## 6. 数据模型
| # | 需求 | 说明 | 状态 |
|---|------|------|------|
| R15 | 数据模型修订 | 七篇表设计(`api_keys` · `usage_logs` · `payments` · `qpu_history` · `members` · `users` · `sessions`)经作者逐篇审阅**定稿**:四张基线表复审落点 + `payments` 重定义(订阅/充值同构、币种无关、只存事实)+ 访问主体三新表 + `usage_logs` 来源提列(`billing_source` / `subscription_id`)与错误行去重修订(非消费行 `dedupe_key` 追加含 `content` 的指纹)+ `api_keys.member_id` 归属列 + 401 自动摘除状态机裁撤(引擎记录不决策);方言映射归数据访问层设计 | **定稿**(2026-10-01) |
| R15 | 数据模型修订 | 表字段设计经作者逐篇审阅**定稿**(2026-10-01);其后 `payments` 按 2026-10-09 裁决拆为 `subscriptions` + `topups`(见裁决记录)——基线域现为 `api_keys` · `usage_logs` · `subscriptions` · `topups` · `qpu_history` + 访问主体三表 | **已裁决**(2026-10-01;2026-10-09 拆表修订) |
## 7. R13 的支撑材料:CFW 可行性调研(2026-09-29,developers.cloudflare.com 官方文档核实,五项全部无阻断)
@@ -90,7 +91,9 @@
## 8. 推进方式
**需求阶段收官(2026-10-01)**:15 项全部有归宿——R1 / R2 / R5 / R6 / R7 / R8 / R9 / R11 / R12 / R13 / R14 / R15 已裁决关闭,R10 裁撤,R3 / R4 延后(提案存档见 §9)。七篇表设计、八篇交互设计均经作者逐篇审阅**定稿**。**剩余专门设计文档:API 契约 → 同步引擎设计**(双形态架构下,逐篇立稿、逐篇审阅)。
**需求阶段收官(2026-10-01)**:15 项全部有归宿——R1 / R2 / R5 / R6 / R7 / R8 / R9 / R11 / R12 / R13 / R14 / R15 已裁决关闭,R10 裁撤,R3 / R4 延后(提案存档见 §9)。表字段设计与交互设计均经作者逐篇审阅**定稿**;其后 API 契约与同步引擎设计亦已立稿。
**增补(2026-10-10)**:R16(成员档案维护与空壳删除)已裁决——成员操作面由「开户 / 启停 / 重发」扩为含档案修改与条件硬删;随 members / API / 交互三线落地。
## 9. 裁决记录
@@ -134,15 +137,15 @@
- **支付记录**(订阅与充值同构):支付金额 ↔ 额度的**比率关系**——**币种无关**(系统不预设人民币或其他币种,币种是使用者之间的约定);订阅与充值两类支付事件统一建模(表结构与命名随 R15)
- **计费来源标注为一等维度**:`billing_source`(订阅 / 充值)随日志解析存储;按来源 / 成员 / key / 模型的任意时段多维聚合
- **充值登记**(R7 重定义):与订阅同构——不做充值用量的金额分摊(原「池子加权平均」方案裁撤)
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、**完全公开透明**(订阅与支付参数对成员可见)、无「应付」概念
- **成员视角(R12 形态修正)**:名下 key 的模型调用日志,**每条记录额外携带所属订阅/充值的信息**(具体携带形态待核查后设计)、支付参数**同账本域内可见**、无「应付」概念
### 2026-10-01 · R15 审阅定稿
七篇表设计经作者逐篇审阅,全部**定稿**。审阅期实质修订:`key_id` → `api_key_id`、`note` → `remark`、**401 自动摘除状态机裁撤**(api_keys 3.6——引擎记录不决策,`status` 纯手动开关)、**管理员初始化与重置改为空库双通道**(users 3.1——env 静默重建 / 引导页兜底,env 凭证降级为可弃);另有多处语义辨析随文入档(错误行去重指纹、量与换算率、sessions 与 members 零关系、QPU 存在必要性裁决保留)。
表字段设计经作者逐篇审阅,全部**定稿**。审阅期实质修订:`key_id` → `api_key_id`、`note` → `remark`、**401 自动摘除状态机裁撤**(api_keys 3.6——引擎记录不决策,`status` 纯手动开关)、**管理员初始化与重置改为空库双通道**(users 3.1——env 静默重建 / 引导页兜底,env 凭证降级为可弃);另有多处语义辨析随文入档(错误行去重指纹、量与换算率、sessions 与 members 零关系、QPU 存在必要性裁决保留)。
### 2026-10-01 · 交互设计八篇定稿
### 2026-10-01 · 交互设计定稿
八篇交互设计(共性 / 登录 / 总览 / key / 支付 / 成员 / 审计 / 成员视图)经作者逐篇审阅,全部**定稿**。审阅期修订:
交互设计八篇(共性 / 登录 / 总览 / key / 支付 / 成员 / 审计 / 成员视图)经作者逐篇审阅,全部**定稿**。审阅期修订:
- **纯用量模式**(共性 §3.8,部门实例场景触发):无支付记录 = 完整可用子集——参数卡中性空态、展开行降级、核对提示全空静默、订阅期间快捷项隐藏
- **响应式范围收缩**(作者裁决):只做布局响应,不做组件响应与移动端 UI 重设计
@@ -157,6 +160,24 @@
- **未关联分组 = 查询层产物**(LEFT JOIN 未匹配集,无新字段),正常时恒为零,非零即异常信号;两种未关联(日志缺 ID / 有 ID 无登记)的展示区分留设计期
- **统计 = 派生视图**:数据库只存事实行,一切分组/分解/汇总为查询时聚合,无预计算存储
### 2026-10-09 · 支付建模拆表
- **裁决:`payments` 单表拆为 `subscriptions`(订阅权益)+ `topups`(充值入账事件)**——`paid_at` 在订阅行上语义污染(更像登记时刻),后补录无法按付款时间对齐用量窗口;对齐 NewAPI「权益 vs 入账」两分(评估见 `payments建模评估.md`)
- **三时刻钉死**:`created_at`(登记)· `paid_at`(钱出手,订阅可空)· `period_*`(用量覆盖期);筛选:订阅看期间重叠、充值看 `paid_at`、补录看 `created_at`
- **展示仍称「支付记录」**:管理面一菜单两 Tab;成员「支付参数」两组清单;R7/R8/R9 哲学不变(只存事实、`subscription_id` 直连、币种无关)
- **原 `payments表字段设计.md` 删除**,由两篇表设计替代;契约与交互随拆表修订
### 2026-10-10 · 成员档案维护与空壳删除(R16)
**动机**:开户后挂 key 前发现名称不对(误建、占位名)——改比重发/重建好(令牌已发出时重建会作废旧令牌);另有从未使用的开户空壳需要清理,停用行会越积越多。
**裁决**:
- **修改成员档案**:允许改 `name` / `remark` 两字段。`status` 与令牌不并入档案编辑(仍走启停 / 重发,生命周期语义不变);`id` / `created_at` / `token_hash` / `token_issued_at` 不可直接改。`name` 仍 UNIQUE(账目署名不可歧义,members 3.3);冲突 `409 member_name_taken`。**展示口径 = 当前名**(日志 / 总览 / 成员视图联查实时取 `members.name`,与 key 的 `token_name` 同则——历史账目署名随改名漂移可接受)
- **删除成员(条件硬删)**:**仅当 `key_count = 0`(当前无任何 `api_keys.member_id` 指向)** 才允许物理删除。理由:用量事实经 key 归属联查,空壳行不承载任何账本事实;硬删释放 UNIQUE 名称,便于「删了重建」。名下有 key → 拒绝删除(`409`),只允许停用——**软删除仍是账本实体的默认语义**,硬删只是空壳例外
- **与支付删除的分界**:支付记录是人工抄录、改错即修(全 CRUD);成员是账本锚点,默认软删除——空壳硬删不突破该哲学(无账本可保)
- **审计**:`member.update`(改名/改备注,按实际变更字段落痕)· `member.delete`(摘要含删除时名称);均属管理面写操作
### 2026-09-29 · 数据资产域延后(R3 / R4)
**裁决:不纳入现阶段**(锦上添花能力,核心功能实现后再议)。提案存档如下,供重启讨论时作起点:
+32 -3
View File
@@ -277,9 +277,18 @@ quota = ( (prompt_tokens − cache_tokens) ← 提示 tokens(未
### 4.3 订阅信息
- **没有免 auth 的接口可直接查询**(作者实测,2026-09-13);登录态的「钱包」页面可见——显示订阅编号(形如「订阅 #N」)、状态(生效中 / 已过期 + 过期时间)、总额度与已用 / 剩余
- 订阅编号**推测**与日志 `other.subscription_id` 为同一体系(实现期以真实数据核对)
- 对应设计:订阅信息由购买者手工录入(见 `docs/design/payments表字段设计.md`)
- **存在完整列表接口,但鉴权是 `UserAuth()`(账号登录态),不是 sk-key**(源码 `router/api-router.go` · `controller/subscription.go:GetSubscriptionSelf`,2026-10-08 核对):
| 路径 | 鉴权 | 能力 |
|------|------|------|
| `GET /api/subscription/self` | `UserAuth()` | 返回 `all_subscriptions`(含已过期):`id`、`start_time`/`end_time`(Unix 秒)、`status`、`amount_total`/`amount_used`、`created_at` 等;另有 `subscriptions`、`billing_preference` |
| `GET /api/subscription/plans` | `UserAuth()` | 套餐列表 |
| `/api/subscription/admin/*` | `AdminAuth()` | 管理端绑定/作废等,与本项目无关 |
- **sk-key(`TokenAuth` / `TokenAuthReadOnly`)挂不上这些路由**——钱包页在 F12 里看到的 JSON 即 `GET /api/subscription/self`,需网页登录 Cookie/会话,不是 `Authorization: Bearer sk-…`
- 「没有免 auth 的接口」结论仍成立;**并非漏查**,而是查询面从未对 sk-key 开放。UI 只显示过期时间,是前端展示裁剪,底层数据在上述接口里
- 订阅 `id` 与日志 `other.subscription_id` 同体系(作者 JSON 中 `id: 44/22/6` 可与日志对账)
- 对应设计:订阅信息由购买者手工录入或**辅助登录选用**(`subscriptions表字段设计`;密码不落库、token 会话内);登记时可粘贴 `all_subscriptions` JSON 预填;**不做后台账号自动同步**
### 4.4 参数被忽略的情况(实测)
@@ -290,6 +299,26 @@ quota = ( (prompt_tokens − cache_tokens) ← 提示 tokens(未
| `/api/log/token?start_timestamp=1751328000` | 参数被忽略,仍返回最近 1000 条 |
| `/v1/dashboard/billing/usage?start_date=2025-07-01` | 参数被忽略 |
### 4.5 充值记录(2026-10-09 实测)
- **`GET /api/user/topup/self?p=1&page_size=10`**——分页充值单;路径属 `/api/user/*`,**预期 `UserAuth()`**(账号登录态,非 sk-key)
- 响应 `{ data: { page, page_size, total, items }, success }`;单条字段与样本:
| 字段 | 样本 | 含义 |
|------|------|------|
| `id` | `17` | 上游充值单 ID |
| `user_id` | `6` | 账号 |
| `amount` | `1` | **平台计费金额**(充值记账额;与 `money` 之间为管理员约定汇率,不标币种) |
| `money` | `2.04` | **实付金额** |
| `trade_no` | `USR6…` | 交易号 |
| `payment_method` / `payment_provider` | `alipay` / `epay` | 渠道 |
| `create_time` / `complete_time` | Unix 秒 | 发起 / **入账完成** |
| `status` | `success` | 单据状态 |
- **无 `quota_granted` 字段**——换得额度为派生(`amount × quota_per_unit`);登记不固化该列(`topups表字段设计.md`)
- **展示时刻 = `complete_time`**(平台列表即显示它);`create_time` 仅发起时刻——设计只存一条事件时间 `paid_at` = `complete_time`
- **辅助登录代拉**(与订阅同模式):`UserAuth` access_token、密码不落库;**仅 `p` / `page_size` 分页、无时间参数**——前端传统分页、可翻至尽头(不设页数硬上限);不做后台定时同步
## 5. 行为事实:写权限与 key 生命周期
- **sk-key 没有任何写接口**:没有 POST/PUT/DELETE 能改 token 自身;无法通过 sk-key 重置 `used_quota`(没有"重置用量"接口)
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# 把 DATABASE_URL 的主机改写为 compose 服务名 postgres,再启动应用
set -e
if [ -z "$DATABASE_URL" ]; then
echo "DATABASE_URL is required" >&2
exit 1
fi
export DATABASE_URL="$(echo "$DATABASE_URL" | sed -E 's#(@)[^:/]+(:[0-9]+)?(/)#\1postgres\2\3#')"
exec "$@"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# 从 DATABASE_URL 拆出官方镜像建库所需的 POSTGRES_*,再交回原入口
set -e
if [ -z "$DATABASE_URL" ]; then
echo "DATABASE_URL is required" >&2
exit 1
fi
u="${DATABASE_URL#*://}"
auth="${u%%@*}"
rest="${u#*@}"
export POSTGRES_USER="${auth%%:*}"
export POSTGRES_PASSWORD="${auth#*:}"
export POSTGRES_DB="${rest#*/}"
export POSTGRES_DB="${POSTGRES_DB%%\?*}"
exec docker-entrypoint.sh postgres
+17 -4
View File
@@ -1,17 +1,30 @@
{
"name": "new-api-key-usage",
"private": true,
"license": "MIT",
"type": "module",
"workspaces": ["apps/*", "packages/*"],
"workspaces": [
"apps/*",
"packages/*"
],
"scripts": {
"test": "bun test",
"typecheck": "tsc -p tsconfig.json --noEmit",
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
"dev:engine": "bun --env-file=.env apps/engine/src/index.ts",
"dev:web": "bun --filter @new-api-key-usage/web dev"
"dev:web": "bun run --cwd apps/web dev",
"db:generate": "bun run --cwd packages/db db:generate",
"db:push": "bun run --cwd packages/db db:push",
"db:studio": "bun run --cwd packages/db db:studio",
"build:web": "bun run --cwd apps/web build",
"build:server": "bun build --compile --outfile=bin/naku-server apps/server/src/index.ts",
"build:engine": "bun build --compile --outfile=bin/naku-engine apps/engine/src/index.ts",
"pack": "bun run scripts/cli.ts"
},
"devDependencies": {
"@clack/prompts": "^1.8.1",
"@types/bun": "^1.4.2",
"citty": "^0.2.2",
"typescript": "^7.0.2"
}
},
"packageManager": "bun@1.4.2"
}
+7
View File
@@ -27,12 +27,19 @@ export const ERR = {
errorBody("invalid_credentials", "用户名或密码错误"),
session_expired: () =>
errorBody("session_expired", "会话已过期,请重新登录"),
/** 上游控制台 token 失效——**不是**管理端会话,禁止走 401 登出 */
platform_token_invalid: () =>
errorBody("platform_token_invalid", "平台登录已失效,请重新登录上游账号"),
already_initialized: () =>
errorBody("already_initialized", "系统已初始化,无法重复引导"),
key_already_registered: (key_id: number) =>
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
member_name_taken: () =>
errorBody("member_name_taken", "成员名称已被占用"),
member_has_keys: (key_count: number) =>
errorBody("member_has_keys", `名下仍有 ${key_count} 枚 key,只能停用`, {
fields: { key_count: String(key_count) },
}),
upstream_unreachable: () =>
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
not_found: () => errorBody("not_found", "请求的资源不存在"),
+9
View File
@@ -18,6 +18,15 @@ export function microUsdToQuotaUsdStr(micro: number): string {
return `${dollars}.${String(rem).padStart(2, "0")}`;
}
/** 明细行等小额场景:保留更多小数位(默认 4 位,仍是字符串两位小数契约的展示增强) */
export function microUsdToQuotaUsdStrPrecise(
micro: number,
fractionDigits = 4,
): string {
const usd = micro / 1_000_000;
return usd.toFixed(fractionDigits);
}
export function quotaToUsdStr(quota: number, qpu: number): string {
return microUsdToQuotaUsdStr(quotaToMicroUsd(quota, qpu));
}

Some files were not shown because too many files have changed in this diff Show More