Compare commits

..
4 Commits
12 changed files with 235 additions and 88 deletions
+1 -2
View File
@@ -35,8 +35,7 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
| `bun run build:web` | 前端类型检查 + 产物构建 | | `bun run build:web` | 前端类型检查 + 产物构建 |
| `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) | | `bun run build:server` | 编译后端单文件 → `bin/naku-server`(当前平台) |
| `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) | | `bun run build:engine` | 编译同步引擎单文件 → `bin/naku-engine`(当前平台) |
| `bun run naku` | 打包/构建 CLI(citty + clack):`naku pack` 交互打部署包 → `release/`;`naku build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only` | | `bun run pack` | 打包/构建 CLI(citty + clack):`pack release` 交互打部署包 → `release/`;`pack build` 本地构建 → `bin/`;支持 `--yes` / `--target` / `--only`;`pack release --yes` 非交互打部署包(目标默认 linux-x64) |
| `bun run pack:release` | 非交互打部署包(= `naku pack --yes`,目标默认 linux-x64) |
环境变量见 `.env.example`(复制为 `.env` 后填写)。 环境变量见 `.env.example`(复制为 `.env` 后填写)。
+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.
+23 -14
View File
@@ -6,30 +6,32 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));CFW 衍生形态待做 | | 当前状态 | **实现推进中**——设计文档主体定稿;后端 + 同步引擎 + 前端(管理面与成员视图)已落地;部署链已定(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版待做 |
| 技术栈 | 全栈 TypeScript(选型见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next | | 技术栈 | 全栈 TypeScript(选型见 [`docs/design/技术选型.md`](docs/design/技术选型.md)):Bun + Hono + Drizzle + PostgreSQL · 前端 Vue 3 + antdv-next |
## 这是什么 ## 这是什么
面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:作者购买订阅额度后,创建多个 sk- API key 分发给多名使用者分摊成本。平台只提供**事实服务**——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。 面向 [NewAPI](https://github.com/QuantumNous/new-api) 实例的**独立计费辅助平台**:订阅持有人创建多个 sk- API key 分发给多名使用者,平台把「谁用了多少」的事实记录清楚——用量、来源标注、支付参数、多维统计;**不算「谁该付谁多少钱」**(分摊是人和人之间的事)。
**适用场景**:同一份订阅额度由多人共享的小团队 / 成本分担群体。
- 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU - 名字直述用途:NewAPI 网关下按 key 查询用量(key usage);产品展示名 NAKU
- 全场景统一小写 kebab-case:仓库名 / 包名一致 - 仓库名 / 包名统一小写 kebab-case
## 背景与约束 ## 背景与约束
- 真实场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担** - 典型场景:NewAPI(Go + GORM 开源 LLM 网关)实例提供订阅服务,多名使用者通过分发 API key 共享订阅额度——**非商业,纯成本分担**
- 项目作者是该实例的**普通用户**:只有 sk-key,**没有实例管理员账号** - 平台持有方通常只是实例的**普通用户**:只有 sk-key,**没有实例管理员账号**
- 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401) - 一切数据获取只能走 key 级接口(账号级接口用 sk-key 访问会 401)——这是整个数据链路的设计前提
- 交付形态为**双形态并行**:完整前后端部署在实例运营方提供的服务器上(作者不接触服务器)+ Cloudflare Workers 抽取版部署在作者自控账号(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13) - 部署形态为**双形态并行**:完整前后端跑在自有或托管服务器上(见 [`docs/design/部署方案.md`](docs/design/部署方案.md));Cloudflare Workers 抽取版部署在自己掌控的账号里(见 [`docs/design/需求清单.md`](docs/design/需求清单.md) R13)
**双形态并行的动因**:服务器部署形态下,每次迭代都需实例运营方手工升级;部署到作者自控的 Cloudflare 账号后,迭代部署权回到作者手里。母体完整前后端先行,Workers 抽取版延后至功能全集实现后。 **双形态并行的动因**:服务器部署形态下,每次迭代都要经服务器管理员手工升级;Workers 形态把迭代部署权收回自己手里。完整前后端先行,抽取版延后至功能全集实现后。
## 核心能力 ## 核心能力
| # | 能力 | 说明 | | # | 能力 | 说明 |
|---|------|------| |---|------|------|
| 1 | key 用量查询 | 按时间范围查模型请求日志与用量;作者全员视角在管理面总览,成员视角在成员视图 | | 1 | key 用量查询 | 按时间范围查模型请求日志与用量;管理端全员视角,成员端各自视角 |
| 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) | | 2 | key 登记 | 管理通道手动登记:入库并加入轮询;生命周期为手动启停(引擎只读 `status`) |
| 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 | | 3 | 支付事实登记 | 订阅权益与充值入账分表登记;`billing_domain` 标账本域,成员同域可见 |
| 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 | | 4 | 多维统计 | 按成员 / key / 计费来源 / 模型的任意时段聚合 |
@@ -86,13 +88,13 @@ bun run dev:web # 前端 Vite(5175)
### 本地打包 ### 本地打包
```bash ```bash
bun run naku pack bun run pack release
# 交互选:目标平台 · 打包内容 · 是否 tar.gz # 交互选:目标平台 · 打包内容 · 是否 tar.gz
# 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/ # 产出 release/:dist · naku-server · naku-engine · naku-migrate · drizzle/
# 非交互(CI / 一键): # 非交互(CI / 一键):
bun run pack:release bun run pack release --yes
# 或 bun run naku pack --yes --target=bun-linux-arm64 --only=dist,naku-server # 或 bun run pack release --yes --target=bun-linux-arm64 --only=dist,naku-server
``` ```
### 服务器 ### 服务器
@@ -135,10 +137,10 @@ docker compose ps # migrate 应为 exited(0),其余 running/healthy
| [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) | | [`docs/design/交互设计-总览与共性.md`](docs/design/交互设计-总览与共性.md) | 交互总纲:访问拓扑、导航、跨页组件、状态与数值呈现约定 | 定稿(2026-10-01) |
| [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 | | [`docs/design/交互设计-登录与会话.md`](docs/design/交互设计-登录与会话.md) | 登录页、引导页、改密、成员登录壳 | 同上 |
| [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 | | [`docs/design/交互设计-全员总览.md`](docs/design/交互设计-全员总览.md) | 管理面首页:健康带 + 按成员 / key 汇总 | 同上 |
| [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、汇总条、明细表、与 key/成员/总览的跳转链 | 定稿(2026-10-08;模型筛选与汇总条修订) | | [`docs/design/交互设计-调用日志.md`](docs/design/交互设计-调用日志.md) | 调用日志:筛选、汇总条、来源徽章支付弹窗、明细表与跳转链 | 定稿(2026-10-08;模型筛选与汇总条、徽章弹窗修订) |
| [`docs/design/实现方案-日志模型筛选与汇总条.md`](docs/design/实现方案-日志模型筛选与汇总条.md) | 日志页模型筛选 + 条数/消费用量汇总条:API、数据层、前端改造与验收 | 已实现(2026-10-13) | | [`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/交互设计-key管理.md`](docs/design/交互设计-key管理.md) | 登记(sk-key 唯一界面露面)、启停、归属、死亡显形 | 定稿(2026-10-01) |
| [`docs/design/交互设计-支付记录.md`](docs/design/交互设计-支付记录.md) | 支付记录(两 Tab):订阅权益 / 充值入账登记、核对提示 | 定稿(2026-10-12) | | [`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-10) | | [`docs/design/交互设计-审计日志.md`](docs/design/交互设计-审计日志.md) | 管理操作留痕时间线 | 定稿(2026-10-10) |
| [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) | | [`docs/design/交互设计-成员视图.md`](docs/design/交互设计-成员视图.md) | 成员视图三页(总览 / 调用日志 / 支付参数);订阅徽章展开 + 调用详情 | 定稿(2026-10-09) |
@@ -150,6 +152,7 @@ docker compose ps # migrate 应为 exited(0),其余 running/healthy
- 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策) - 命名与分区:**目录用英文、文档名用中文**;`docs/design/`(方案与决策)· `docs/references/`(平台可用接口参考——实证事实,不做设计决策)
- 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古) - 生命周期:状态流转(草案 → 讨论中 → 待审阅 → 定稿(日期) → 已实现);定稿后不搬家、只改状态;被取代时删除(git 历史即考古)
- AI 协作物料:[`AGENTS.md`](AGENTS.md)(协作约定)与 `.agents/skills/`(技能包)随仓库分发——参与开发的 Agent 从这里读取项目约定,人工贡献者亦可参考
## 关键事实清单(实测 / 源码确认,设计时不得违反) ## 关键事实清单(实测 / 源码确认,设计时不得违反)
@@ -161,3 +164,9 @@ docker compose ps # migrate 应为 exited(0),其余 running/healthy
4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据 4. **1000 条硬上限**:窗口只保留最近 1000 条——当前日志速率下约等于 3 天;同步停摆超过窗口会永久丢数据
5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝** 5. **`TokenAuthReadOnly`**:用量/日志接口用宽松鉴权,key 过期 / 额度耗尽仍可查询;**显式禁用会被 401 拒绝**
6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度 6. **`DisplayTokenStatEnabled` 已开启**:billing 接口返回 token 级额度
## License
[MIT](LICENSE) © 2026 skycurtain
NAKU 是 NewAPI 的**独立客户端**,仅通过 HTTP API 交互,不包含其源码;与上游许可证相互独立。上游 NewAPI 为 AGPL-3.0,本项目不受其传染,亦不衍生自它。
+113 -7
View File
@@ -3,7 +3,7 @@
* 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转 * 管理面调用日志(交互设计-调用日志):筛选 + 明细表 + 与 key/成员跳转
*/ */
import { computed, onMounted, onUnmounted, ref, watch } from "vue"; import { computed, onMounted, onUnmounted, ref, watch } from "vue";
import type { LogItem } from "@new-api-key-usage/core"; import type { LogItem, SubscriptionItem } from "@new-api-key-usage/core";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import PageHeader from "@/components/common/page-header.vue"; import PageHeader from "@/components/common/page-header.vue";
import TableRefresh from "@/components/common/table-refresh.vue"; import TableRefresh from "@/components/common/table-refresh.vue";
@@ -14,9 +14,9 @@ import {
useLiveTimeRange, useLiveTimeRange,
type RangeKind, type RangeKind,
} from "@/composables/use-live-time-range.ts"; } from "@/composables/use-live-time-range.ts";
import { useAdminLogs, useKeys, useMembers } from "@/queries/admin.ts"; import { useAdminLogs, useKeys, useMembers, useSubscriptions } from "@/queries/admin.ts";
import { formatDateTime } from "@/utils/time.ts"; import { formatDateTime } from "@/utils/time.ts";
import { formatInt } from "@/utils/format.ts"; import { formatInt, formatPaidAmount } from "@/utils/format.ts";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -167,12 +167,54 @@ watch(page, () => pushQuery());
function sourceBadge(record: LogItem) { function sourceBadge(record: LogItem) {
if (record.billing_source === "subscription" && record.subscription_id != null) { if (record.billing_source === "subscription" && record.subscription_id != null) {
return { text: `订阅 #${record.subscription_id}`, color: "blue" as const }; return {
text: `订阅 #${record.subscription_id}`,
color: "blue" as const,
kind: "sub" as const,
id: record.subscription_id,
};
} }
if (record.billing_source === "wallet") { if (record.billing_source === "wallet") {
return { text: "充值", color: "gold" as const }; return { text: "充值", color: "gold" as const, kind: "wallet" as const, id: null };
} }
return { text: "未关联", color: undefined }; 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) { function goKey(keyId: number) {
@@ -354,7 +396,19 @@ onUnmounted(() => {
</a-tag> </a-tag>
</template> </template>
<template v-else-if="column.key === 'source'"> <template v-else-if="column.key === 'source'">
<a-tag :color="sourceBadge(record).color"> <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 }} {{ sourceBadge(record).text }}
</a-tag> </a-tag>
</template> </template>
@@ -432,6 +486,41 @@ onUnmounted(() => {
</div> </div>
</div> </div>
</a-modal> </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> </section>
</template> </template>
@@ -508,4 +597,21 @@ onUnmounted(() => {
line-height: 20px; line-height: 20px;
font-family: ui-monospace, "Cascadia Code", Consolas, monospace; 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> </style>
+44 -8
View File
@@ -27,6 +27,8 @@ const subsQuery = useSubscriptions();
const topupsQuery = useTopups(); const topupsQuery = useTopups();
const filterDomain = ref<string | "none" | null>(null); const filterDomain = ref<string | "none" | null>(null);
const filterSubId = ref<number | null>(null);
const filterTradeNo = ref<string | null>(null);
const domainOptions = computed(() => { const domainOptions = computed(() => {
const set = new Set<string>(); const set = new Set<string>();
for (const s of subsQuery.data.value ?? []) { for (const s of subsQuery.data.value ?? []) {
@@ -39,7 +41,10 @@ const domainOptions = computed(() => {
}); });
const filteredSubs = computed(() => { const filteredSubs = computed(() => {
const list = subsQuery.data.value ?? []; 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) return list;
if (filterDomain.value === "none") { if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null); return list.filter((r) => r.billing_domain == null);
@@ -48,7 +53,15 @@ const filteredSubs = computed(() => {
}); });
const filteredTops = computed(() => { const filteredTops = computed(() => {
const list = topupsQuery.data.value ?? []; 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) return list;
if (filterDomain.value === "none") { if (filterDomain.value === "none") {
return list.filter((r) => r.billing_domain == null); return list.filter((r) => r.billing_domain == null);
@@ -67,18 +80,23 @@ const checking = ref(false);
function syncFromRoute() { function syncFromRoute() {
tab.value = route.query.tab === "topups" ? "topups" : "subs"; 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 pushTab() { function pushQuery() {
void router.replace({ const query: Record<string, string> = {};
path: "/admin/payments", if (tab.value === "topups") query.tab = "topups";
query: tab.value === "topups" ? { 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(); syncFromRoute();
watch(() => route.query, () => syncFromRoute()); watch(() => route.query, () => syncFromRoute());
watch(tab, () => pushTab()); watch([tab, filterSubId, filterTradeNo], () => pushQuery());
const subRows = computed(() => [...filteredSubs.value]); const subRows = computed(() => [...filteredSubs.value]);
const topRows = computed(() => { const topRows = computed(() => {
@@ -231,6 +249,24 @@ watch(
</a-tabs> </a-tabs>
<div class="payments-toolbar"> <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 <a-select
v-model:value="filterDomain" v-model:value="filterDomain"
:options="[ :options="[
+6 -5
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-12,作者认可实现) | | 状态 | 定稿(2026-10-12,作者认可实现;补订阅 ID / 交易号筛选) |
| 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 | | 关联 | 《交互设计-总览与共性》· `subscriptions表字段设计.md` · `topups表字段设计.md` · R7/R8 |
| 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) | | 数据 | `subscriptions` + `topups` · 日志观测的订阅 ID(核对提示) |
@@ -19,14 +19,15 @@
| **订阅** | 订阅权益表 | 登记订阅 | | **订阅** | 订阅权益表 | 登记订阅 |
| **充值** | 充值入账表 | 登记充值 | | **充值** | 充值入账表 | 登记充值 |
筛选随 Tab: 筛选随 Tab(均为前端过滤——清单全量直返):
| Tab | 筛选 | URL | | Tab | 筛选 | URL |
|-----|------|-----| |-----|------|-----|
| 订阅 | 订阅 ID / 期间与所选窗口重叠(拆表后) | `tab=subs` | | 订阅 | 订阅 ID(精确,可空)· 账本域(含「未标注」) | `tab=subs` · `subscription_id` |
| 充值 | **仅类型**(付费时间筛选已撤——`paid_at` 语义待拆表钉死) | `tab=topups` · `kind` | | 充值 | 交易号(子串,可空)· 账本域 | `tab=topups` · `trade_no` |
当前实现(拆表前):仅「类型」筛选;补录审计可看「登记」列(`created_at`)。 - 切换 Tab 保留另一 Tab 已填筛选;URL 同步便于从调用日志来源徽章深链(`subscription_id`)
- 补录审计可看「登记」列(`created_at`)
## 2. 订阅清单列 ## 2. 订阅清单列
+4 -2
View File
@@ -4,7 +4,7 @@
| 项目 | 内容 | | 项目 | 内容 |
|------|------| |------|------|
| 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条) | | 状态 | 定稿(2026-10-08;2026-10-13 补模型筛选与汇总条、来源徽章支付弹窗) |
| 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` | | 关联 | 《实现方案-日志模型筛选与汇总条》·《API契约-管理面》§6(过滤与 DTO)·《交互设计-总览与共性》(时间范围、数值呈现、状态模式)·《交互设计-key管理》《交互设计-成员管理》《交互设计-全员总览》(双向跳转)· `usage_logs表字段设计.md` |
| 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) | | 数据 | `usage_logs` 明细(分页;默认全类型,可筛 `type` / `model_name`)+ 当前筛选汇总(条数 + 消费用量) |
@@ -59,7 +59,8 @@
| 操作 | 「详情」按钮 | 开详情弹窗 | | 操作 | 「详情」按钮 | 开详情弹窗 |
- 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表) - 分页:默认 50,档位 20 / 50 / 100 / 200(无界列表)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(管理面支付另页)——需要看订阅登记时从来源徽章旁的链接或工具提示指向支付记录筛选 - **来源徽章**:`订阅 #N`(蓝)**可点** → 支付参数弹窗(期间 / 额度 / 金额 / 备注;无登记显示「该订阅无登记信息」);底栏「在支付记录中查看」→ 支付记录页并带 `subscription_id` 深链。`充值` / `未关联` 为纯标识,不可点(与成员端同构)
- **「详情」弹窗**(操作列,居中定高内滚,宽约 720):时间 / Key·归属 / 模型·类型 / 用量 / Tokens / 耗时流式 / 来源 / 内容(错误详情),以及**原始数据**(落库行 JSON,内滚);**不承载支付参数**(支付参数走来源徽章弹窗,核对/补录走支付记录页)
- 空态:「该时段内无调用日志」 - 空态:「该时段内无调用日志」
- 窄屏:表格横向滚动(共性 §3.5) - 窄屏:表格横向滚动(共性 §3.5)
@@ -72,6 +73,7 @@
| 成员管理 · 行操作「查调用」 | 本页 | `member_id` | | 成员管理 · 行操作「查调用」 | 本页 | `member_id` |
| 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) | | 本页 · Key 列 | Key 管理 | `key_id`(定位/高亮) |
| 本页 · 归属列 | 成员管理 | `member_id`(筛选) | | 本页 · 归属列 | 成员管理 | `member_id`(筛选) |
| 本页 · 来源徽章弹窗「在支付记录中查看」 | 支付记录 | `tab=subs` + `subscription_id` |
| 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 | | 健康带失败数(总览) | 仍去 Key 管理失败筛选 | 不变 |
## 5. 状态与边界 ## 5. 状态与边界
+1 -1
View File
@@ -54,7 +54,7 @@
├─ packages/ ├─ packages/
│ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型 │ ├─ core/ # 业务核心:轮询与 request_id 去重 · 折算链 · 加密体系 · API 语义与 DTO 类型
│ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储 + 迁移 CLI │ └─ db/ # 数据访问层:Drizzle schema(pg 方言)+ 仓储 + 迁移 CLI
├─ scripts/ # 打包/构建 CLI(naku pack / build) ├─ scripts/ # 打包/构建 CLI(pack release / build)
``` ```
**依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。 **依赖规则(单向)**:`apps/*` → `packages/db` → `packages/core`;core 导出的 DTO 类型可被 web 以纯类型依赖引用(前后端共用一份契约类型)。
+12 -12
View File
@@ -28,7 +28,7 @@
```text ```text
本地(打包) 服务器(运营方) 本地(打包) 服务器(运营方)
────────── ──────────────── ────────── ────────────────
bun run pack:release /opt/naku/ ← 约定目录 bun run pack release --yes /opt/naku/ ← 约定目录
├─ 前端 dist/ ├─ docker-compose.yml ├─ 前端 dist/ ├─ docker-compose.yml
├─ naku-server(linux 单文件) ├─ Dockerfile ├─ naku-server(linux 单文件) ├─ Dockerfile
├─ naku-engine(linux 单文件) ├─ .dockerignore ├─ naku-engine(linux 单文件) ├─ .dockerignore
@@ -53,7 +53,7 @@ bun run pack:release /opt/naku/ ← 约定目录
## 3. 产物包 `release/` ## 3. 产物包 `release/`
本地 `bun run pack:release` 产出,内容固定为: 本地 `bun run pack release --yes` 产出,内容固定为:
```text ```text
release/ release/
@@ -68,13 +68,13 @@ release/
| 项 | 约定 | | 项 | 约定 |
|----|------| |----|------|
| 命令入口 | 优先 `bun run naku pack`(交互:平台 / 内容 / tar.gz);一键等价 `bun run pack:release` 或 `naku pack --yes` | | 命令入口 | 优先 `bun run pack release`(交互:平台 / 内容 / tar.gz);一键等价 `bun run pack release --yes` |
| 脚本落点 | `scripts/cli.ts`(citty + clack)· `scripts/pack-core.ts`(纯打包逻辑)· `scripts/pack-release.ts`(非交互兼容入口) | | 脚本落点 | `scripts/cli.ts`(citty + clack)· `scripts/pack-core.ts`(纯打包逻辑) |
| 目标平台 | 默认 `bun-linux-x64`;`--target=` 可切 `bun-linux-arm64` / `bun-darwin-*` / `bun-windows-*`(含 arm64) | | 目标平台 | 默认 `bun-linux-x64`;`--target=` 可切 `bun-linux-arm64` / `bun-darwin-*` / `bun-windows-*`(含 arm64) |
| 构建顺序 | 清空输出目录 → `build:web` → 三次 `--compile` → 拷 `drizzle/` →(可选)tar.gz | | 构建顺序 | 清空输出目录 → `build:web` → 三次 `--compile` → 拷 `drizzle/` →(可选)tar.gz |
| 禁止入包 | `.env` · 源码 · `node_modules` · 任何密钥 | | 禁止入包 | `.env` · 源码 · `node_modules` · 任何密钥 |
| 校验 | 打包后检查五条目都在且非空(Windows 本机编译允许 `.exe` 后缀);缺一即失败退出 | | 校验 | 打包后检查五条目都在且非空(Windows 本机编译允许 `.exe` 后缀);缺一即失败退出 |
| 本地试跑 | `naku build` / `build:server` / `build:engine` → `bin/`(当前平台),与部署包 `release/` 分离 | | 本地试跑 | `pack build` / `build:server` / `build:engine` → `bin/`(当前平台),与部署包 `release/` 分离 |
### 3.2 为何是单文件而不是「服务器上构建」或「上传源码」 ### 3.2 为何是单文件而不是「服务器上构建」或「上传源码」
@@ -211,7 +211,7 @@ loadEnv → createDb(DATABASE_URL) → runMigrations(db, MIGRATIONS_FOLDER) →
```bash ```bash
# 本地 # 本地
bun run pack:release bun run pack release --yes
# 服务器 # 服务器
# 将 docker-compose.yml · Dockerfile · .dockerignore · Caddyfile · entrypoint-*.sh 放入 /opt/naku/ # 将 docker-compose.yml · Dockerfile · .dockerignore · Caddyfile · entrypoint-*.sh 放入 /opt/naku/
@@ -228,7 +228,7 @@ docker compose ps
```bash ```bash
# 本地 # 本地
bun run pack:release bun run pack release --yes
# 上传,整目录覆盖 /opt/naku/release/ # 上传,整目录覆盖 /opt/naku/release/
# 服务器(固定命令) # 服务器(固定命令)
@@ -299,13 +299,13 @@ docker compose ps
| 文件 | 动作 | | 文件 | 动作 |
|------|------| |------|------|
| `scripts/cli.ts` · `scripts/pack-core.ts` · `scripts/pack-release.ts` | 打包/构建 CLI 与核心逻辑 | | `scripts/cli.ts` · `scripts/pack-core.ts` | 打包/构建 CLI 与核心逻辑 |
| `packages/db/src/migrate-cli.ts` | 迁移 CLI 入口 | | `packages/db/src/migrate-cli.ts` | 迁移 CLI 入口 |
| `package.json` | `naku` · `pack:release` · `build:server` · `build:engine` | | `package.json` | `pack` · `build:server` · `build:engine` |
| `Dockerfile` · `docker-compose.yml` · `.dockerignore` | 两阶段镜像 · 五服务(含 `container_name`) | | `Dockerfile` · `docker-compose.yml` · `.dockerignore` | 两阶段镜像 · 五服务(含 `container_name`) |
| `Caddyfile` · `entrypoint-pg.sh` · `entrypoint-app.sh` | 与 compose 同级平铺 | | `Caddyfile` · `entrypoint-pg.sh` · `entrypoint-app.sh` | 与 compose 同级平铺 |
| `README.md` | 「部署」章与文档地图 | | `README.md` | 「部署」章与文档地图 |
| `AGENTS.md` | 命令表(pack / build / naku) | | `AGENTS.md` | 命令表(pack / build) |
| `.env.example` | 部署语义注释 | | `.env.example` | 部署语义注释 |
--- ---
@@ -315,13 +315,13 @@ docker compose ps
1. **`migrate` 独立单文件**:多一个产物,换来「先迁后跑、失败不启」的清晰门闩。若嫌多,可改为 server 启动时迁(仍幂等),但失去 migrate 失败阻断 engine 的不对称控制。 1. **`migrate` 独立单文件**:多一个产物,换来「先迁后跑、失败不启」的清晰门闩。若嫌多,可改为 server 启动时迁(仍幂等),但失去 migrate 失败阻断 engine 的不对称控制。
2. **目标平台写死 linux-x64**:匹配常见服务器;ARM 用打包参数切换,不为此引入交叉编译矩阵。 2. **目标平台写死 linux-x64**:匹配常见服务器;ARM 用打包参数切换,不为此引入交叉编译矩阵。
3. **`DATABASE_URL` 单源 + 两个 entrypoint**:少一处密码不一致;多两个常驻小 shell。若偏好 compose 原生 `POSTGRES_*`,需接受密码在 `.env` 写两处(或 `DATABASE_URL` 仅应用用)。 3. **`DATABASE_URL` 单源 + 两个 entrypoint**:少一处密码不一致;多两个常驻小 shell。若偏好 compose 原生 `POSTGRES_*`,需接受密码在 `.env` 写两处(或 `DATABASE_URL` 仅应用用)。
4. **产物包而非源码包**:服务器零构建;代价是发版必须在本地(或 CI)跑 `pack:release`。 4. **产物包而非源码包**:服务器零构建;代价是发版必须在本地(或 CI)跑 `pack release --yes`。
--- ---
## 12. 验收清单(实现后) ## 12. 验收清单(实现后)
- [ ] `bun run naku pack --yes`(或 `pack:release`)在 Windows 产出完整 `release/`(五条目齐全) - [ ] `bun run pack release --yes` 在 Windows 产出完整 `release/`(五条目齐全)
- [ ] 首次安装按 §6.1 一次命令链可起,`docker compose ps` 形态符合 §6.4 - [ ] 首次安装按 §6.1 一次命令链可起,`docker compose ps` 形态符合 §6.4
- [ ] 仅替换 `release/` 后重跑固定命令,版本切换成功且数据保留(卷不重建) - [ ] 仅替换 `release/` 后重跑固定命令,版本切换成功且数据保留(卷不重建)
- [ ] 连续两次固定命令:第二次 `migrate` 不重复应用已存在迁移(日志可证) - [ ] 连续两次固定命令:第二次 `migrate` 不重复应用已存在迁移(日志可证)
+2 -2
View File
@@ -1,6 +1,7 @@
{ {
"name": "new-api-key-usage", "name": "new-api-key-usage",
"private": true, "private": true,
"license": "MIT",
"type": "module", "type": "module",
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -17,8 +18,7 @@
"build:web": "bun run --cwd apps/web build", "build:web": "bun run --cwd apps/web build",
"build:server": "bun build --compile --outfile=bin/naku-server apps/server/src/index.ts", "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", "build:engine": "bun build --compile --outfile=bin/naku-engine apps/engine/src/index.ts",
"pack:release": "bun run scripts/pack-release.ts", "pack": "bun run scripts/cli.ts"
"naku": "bun run scripts/cli.ts"
}, },
"devDependencies": { "devDependencies": {
"@clack/prompts": "^1.8.1", "@clack/prompts": "^1.8.1",
+8 -8
View File
@@ -1,5 +1,5 @@
/** /**
* naku 打包/构建 CLI:citty 定义命令,@clack/prompts 负责交互。 * pack 打包/构建 CLI:citty 定义命令,@clack/prompts 负责交互。
* 有 TTY 且未给全 flag 时进交互;--yes / 无 TTY 则纯 flag。 * 有 TTY 且未给全 flag 时进交互;--yes / 无 TTY 则纯 flag。
*/ */
import { defineCommand, runMain } from "citty"; import { defineCommand, runMain } from "citty";
@@ -34,9 +34,9 @@ const targetOption = {
}, },
} as const; } as const;
const pack = defineCommand({ const release = defineCommand({
meta: { meta: {
name: "pack", name: "release",
description: "打部署产物包(dist + 单文件 + drizzle)", description: "打部署产物包(dist + 单文件 + drizzle)",
}, },
args: { args: {
@@ -62,7 +62,7 @@ const pack = defineCommand({
}, },
async run({ args }) { async run({ args }) {
const interactive = isInteractive(args.yes as boolean | undefined); const interactive = isInteractive(args.yes as boolean | undefined);
p.intro("NAKU 打包"); p.intro("PACK 打包");
// --- target --- // --- target ---
let target = args.target as string | undefined; let target = args.target as string | undefined;
@@ -205,7 +205,7 @@ const build = defineCommand({
} }
only = [positional as PackItem]; only = [positional as PackItem];
} else if (interactive) { } else if (interactive) {
p.intro("NAKU 本地构建"); p.intro("PACK 本地构建");
only = assertNotCancel( only = assertNotCancel(
await p.multiselect({ await p.multiselect({
message: "构建什么", message: "构建什么",
@@ -245,11 +245,11 @@ const build = defineCommand({
const main = defineCommand({ const main = defineCommand({
meta: { meta: {
name: "naku", name: "pack",
version: "0.0.0", version: "0.0.0",
description: "NAKU 打包 / 本地构建", description: "PACK 打包 / 本地构建",
}, },
subCommands: { pack, build }, subCommands: { release, build },
}); });
runMain(main); runMain(main);
-27
View File
@@ -1,27 +0,0 @@
/**
* 兼容入口:等价于 `naku pack --yes --target=bun-linux-x64`。
* 交互请用 `bun run naku pack`。
*/
import { packRelease } from "./pack-core.ts";
function readTarget(): string {
const arg = process.argv.find((a) => a.startsWith("--target="));
return arg?.slice("--target=".length) || process.env.PACK_TARGET || "bun-linux-x64";
}
const target = readTarget();
console.log(`[pack-release] non-interactive pack (target=${target})`);
try {
const out = await packRelease({
target,
only: ["dist", "naku-server", "naku-engine", "naku-migrate", "drizzle"],
outDir: "release",
tarball: false,
onLog: (m) => console.log("[pack-release]", m),
});
console.log("[pack-release] done →", out);
} catch (err) {
console.error("[pack-release]", err instanceof Error ? err.message : err);
process.exit(1);
}