Skip to content

Commit f4b62a0

Browse files
sxjeruCopilot
andcommitted
feat: 添加 USAGE_API_BASE_URL 环境变量,支持从 adapter 拉取使用数据
Co-authored-by: Copilot <copilot@github.com>
1 parent d49dd2f commit f4b62a0

6 files changed

Lines changed: 199 additions & 5 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# CLIProxyAPI management Center
22
CLIPROXY_SECRET_KEY=
33
CLIPROXY_API_BASE_URL=
4+
USAGE_API_BASE_URL=
45

56
# Database
67
DATABASE_URL=

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# CHANGELOG
22

3+
## 2026-05-02
4+
5+
- 新增独立 `usage` 来源环境变量切换:
6+
- 看板新增 `USAGE_API_BASE_URL``/api/sync` 拉取 usage 时优先走该地址;未设置时回退到 `CLIPROXY_API_BASE_URL`
7+
- 适配 CPA adapter 场景:可继续用 `CLIPROXY_API_BASE_URL` 访问原管理接口,同时仅将 usage 请求切到 `adapter.js` 暴露的 `/usage`
8+
- 同步更新 `.env.example``README.md` 的环境变量说明,便于部署时直接配置。
9+
310
## 2026-04-15
411

512
- 兼容 TypeScript 6.0.2 编译配置:

README.md

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
> [!CAUTION]
2-
> #### 注:CPA 上游已于 [v6.10.0](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v6.10.0) 正式去除 `/usage` 接口,若要使用本项目追踪使用数据,请使用 v6.9.49 或更早版本。
2+
> #### 注:CPA 上游已于 [v6.10.0](https://github.com/router-for-me/CLIProxyAPI/releases/tag/v6.10.0) 正式去除 `/usage` 接口,若要使用本项目追踪使用数据,请一并配置 [adapter.js](#cpa-近端适配器adapterjs)
3+
> 或者回退 v6.9.49 或更早版本。
34
45

56
# CLIProxyAPI 数据看板
@@ -20,6 +21,7 @@
2021
|---|---|---|
2122
| CLIPROXY_SECRET_KEY | 登录 CLIProxyAPI 后台管理界面的密钥 | 无 |
2223
| CLIPROXY_API_BASE_URL | 自部署的 CLIProxyAPI 根地址 | 如 `https://your-domain.com/` |
24+
| USAGE_API_BASE_URL | usage 数据源接口 | 可选;不填时沿用 `CLIPROXY_API_BASE_URL`,接 adapter 时可单独指向 |
2325
| DATABASE_URL | 数据库连接串(仅支持 Postgres) | 可直接使用 Neon |
2426
| DATABASE_DRIVER | `pg` 或 `neon` | 可选;默认自动检测 |
2527
| DATABASE_CA | DB 服务端 CA 证书 | 可选;PEM 原始内容或 Base64 编码均可 |
@@ -57,3 +59,65 @@
5759
3. 创建表结构:`pnpm run db:push`
5860
4. 同步数据:GET/POST `/api/sync`(可选)
5961
5. 启动开发:`pnpm dev`
62+
63+
---
64+
65+
## CPA 近端适配器(adapter.js)
66+
67+
由于 CPA 新版已移除 `/usage` 接口,可将 [adapter.js](adapter.js) 部署在 CPA 近端,实现从 CPA Redis 队列聚合 usage,还原接口功能,再自动提供给本项目的 `/api/sync` 拉取。
68+
69+
迁移时可在看板配置 `USAGE_API_BASE_URL=http://adapter-host:36871` 以正常使用看板的同步功能,同时保持对原管理接口的访问。
70+
71+
`adapter-host` 一般为 CPA 部署服务器 IP 。
72+
73+
```
74+
npm install ioredis
75+
node adapter.js
76+
77+
# 推荐使用 PM2
78+
npm install -g pm2
79+
pm2 start adapter.js --name cpa-adapter
80+
```
81+
82+
### 环境变量
83+
84+
| 环境变量 | 说明 | 默认值 |
85+
|---|---|---|
86+
| `CPA_REDIS_HOST` | CPA Redis 主机 | `127.0.0.1` |
87+
| `CPA_REDIS_PORT` | CPA Redis 端口 | `8317` |
88+
| `CPA_SECRET_KEY` | CPA Redis 访问密钥,即前述 `CLIPROXY_SECRET_KEY` ||
89+
| `CPA_REDIS_KEY` | usage 队列 key | `queue` |
90+
| `ADAPTER_PORT` | adapter 监听端口 | `36871` |
91+
| `POLL_INTERVAL` | 从 Redis 拉取间隔(毫秒) | `15000` |
92+
| `MAX_BUFFER_SIZE` | 内存缓冲上限 | `50000` |
93+
| `BATCH_SIZE` | 每次拉取条数 | `500` |
94+
| `CLEAR_BUFFER_ON_READ` | 读取 `/usage` 后是否清空缓冲 | `false` |
95+
96+
### 定时 sync 环境变量
97+
98+
| 环境变量 | 说明 | 默认值 |
99+
|---|---|---|
100+
| `ENABLE_PERIODIC_SYNC` | 是否启用内置定时 sync | `false` |
101+
| `DASHBOARD_URL` | 远端看板地址,如 `https://your-domain.com` ||
102+
| `SYNC_TOKEN` | 远端看板 `/api/sync` 的 Bearer token,建议填看板的 `CRON_SECRET``PASSWORD` ||
103+
| `SYNC_INTERVAL` | 定时触发 `/api/sync` 的间隔(毫秒) | `600000` |
104+
| `SYNC_TIMEOUT_MS` | 单次 sync 超时(毫秒) | `300000` |
105+
| `SYNC_ON_START` | 启动后是否立即触发一次 sync | `false` |
106+
107+
### 工作方式
108+
109+
1. `adapter.js` 定时从 CPA Redis 队列拉取 usage 记录
110+
2. 聚合后通过本地 `/usage``/v0/management/usage` 暴露给外部
111+
3. 若开启 `ENABLE_PERIODIC_SYNC=true`,adapter 会定时请求远端看板 `/api/sync`
112+
4. 看板 `/api/sync` 再回拉 adapter 的 `/usage`,并写入数据库
113+
114+
### 关于 `CLEAR_BUFFER_ON_READ`
115+
116+
- 设为 `true`:更适合“定时同步 + 节约内存”的增量模式
117+
- 设为 `false`:更适合保留快照,容忍重复读取,由数据库去重
118+
119+
建议:
120+
- 追求低内存时,使用 `CLEAR_BUFFER_ON_READ=true`
121+
- 更在意故障后可重复拉取时,使用 `CLEAR_BUFFER_ON_READ=false`
122+
123+
注意:`CLEAR_BUFFER_ON_READ=true` 时,远端一旦成功读取 `/usage`,adapter 就会清空内存缓冲;如果后续远端入库失败,该批数据无法由 adapter 重新提供。

adapter.js

Lines changed: 120 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,29 @@ const CONFIG = {
1919
key: process.env.CPA_REDIS_KEY || 'queue',
2020
},
2121
// 本适配器监听的端口
22-
port: parseInt(process.env.ADAPTER_PORT || '3001'),
22+
port: parseInt(process.env.ADAPTER_PORT || '36871'),
2323
// 轮询间隔 (毫秒)
2424
pollInterval: parseInt(process.env.POLL_INTERVAL || '15000'),
2525
// 内存中保留的最大记录数
26-
maxBufferSize: parseInt(process.env.MAX_BUFFER_SIZE || '5000'),
26+
maxBufferSize: parseInt(process.env.MAX_BUFFER_SIZE || '50000'),
2727
// 每次拉取的最大记录数
28-
batchSize: parseInt(process.env.BATCH_SIZE || '100'),
28+
batchSize: parseInt(process.env.BATCH_SIZE || '500'),
2929
// 访问 /usage 后是否清空内存缓冲区;true=增量导出,false=保留全量内存快照
3030
clearBufferOnRead: (process.env.CLEAR_BUFFER_ON_READ || 'false').toLowerCase() === 'true',
31+
// 远端 dashboard sync 配置
32+
sync: {
33+
enabled: (process.env.ENABLE_PERIODIC_SYNC || 'false').toLowerCase() === 'true',
34+
dashboardUrl: (process.env.DASHBOARD_URL || '').trim().replace(/\/$/, ''),
35+
token: (process.env.SYNC_TOKEN || process.env.CPA_SECRET_KEY || '').trim(),
36+
interval: parseInt(process.env.SYNC_INTERVAL || '6000000'), // 默认同步间隔 100 分钟
37+
timeoutMs: parseInt(process.env.SYNC_TIMEOUT_MS || '300000'),
38+
syncOnStart: (process.env.SYNC_ON_START || 'false').toLowerCase() === 'true',
39+
},
3140
};
3241

3342
// 内存缓冲区,用于存放最近拉取的记录
3443
let usageBuffer = [];
44+
let syncInProgress = false;
3545

3646
// 初始化 Redis 客户端
3747
const redis = new Redis({
@@ -80,6 +90,38 @@ function normalizeRecord(record) {
8090
};
8191
}
8292

93+
function toPositiveInt(value, fallback) {
94+
const parsed = Number.parseInt(String(value ?? ''), 10);
95+
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
96+
return parsed;
97+
}
98+
99+
function getSyncConfig() {
100+
const interval = toPositiveInt(CONFIG.sync.interval, 60000);
101+
const timeoutMs = toPositiveInt(CONFIG.sync.timeoutMs, 30000);
102+
103+
return {
104+
...CONFIG.sync,
105+
interval,
106+
timeoutMs,
107+
};
108+
}
109+
110+
function getSyncUrl() {
111+
const { dashboardUrl } = getSyncConfig();
112+
if (!dashboardUrl) return '';
113+
114+
try {
115+
const url = new URL('/api/sync', `${dashboardUrl}/`);
116+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
117+
return '';
118+
}
119+
return url.toString();
120+
} catch {
121+
return '';
122+
}
123+
}
124+
83125
/**
84126
* 从 Redis 拉取并聚合数据
85127
*/
@@ -124,10 +166,77 @@ async function drainQueue() {
124166
}
125167
}
126168

169+
async function triggerSync(reason = 'interval') {
170+
const syncConfig = getSyncConfig();
171+
172+
if (!syncConfig.enabled) return;
173+
174+
const syncUrl = getSyncUrl();
175+
if (!syncUrl) {
176+
console.error('[sync] Invalid or missing DASHBOARD_URL');
177+
return;
178+
}
179+
180+
if (!syncConfig.token) {
181+
console.error('[sync] Missing SYNC_TOKEN/CRON_SECRET/PASSWORD');
182+
return;
183+
}
184+
185+
if (syncInProgress) {
186+
console.warn('[sync] Previous sync still in progress, skipped');
187+
return;
188+
}
189+
190+
syncInProgress = true;
191+
const controller = new AbortController();
192+
const timeoutId = setTimeout(() => controller.abort(), syncConfig.timeoutMs);
193+
194+
try {
195+
const response = await fetch(syncUrl, {
196+
method: 'GET',
197+
headers: {
198+
Authorization: `Bearer ${syncConfig.token}`,
199+
},
200+
signal: controller.signal,
201+
});
202+
203+
if (!response.ok) {
204+
console.error(`[sync] Trigger failed (${reason}): ${response.status} ${response.statusText}`);
205+
return;
206+
}
207+
208+
let result = null;
209+
try {
210+
result = await response.json();
211+
} catch {
212+
result = null;
213+
}
214+
215+
console.log(`[sync] Triggered (${reason})`, result || { status: response.status });
216+
} catch (error) {
217+
const isTimeout = error instanceof Error && error.name === 'AbortError';
218+
console.error(`[sync] Trigger error (${reason}): ${isTimeout ? 'timeout' : error.message}`);
219+
} finally {
220+
clearTimeout(timeoutId);
221+
syncInProgress = false;
222+
}
223+
}
224+
127225
// 定时任务
128226
setInterval(drainQueue, CONFIG.pollInterval);
129227
drainQueue();
130228

229+
const syncConfig = getSyncConfig();
230+
if (syncConfig.enabled) {
231+
setInterval(() => {
232+
triggerSync('interval');
233+
}, syncConfig.interval);
234+
235+
if (syncConfig.syncOnStart) {
236+
triggerSync('startup');
237+
}
238+
}
239+
131240
/**
132241
* 将内存缓冲区的数据转换为旧版 /usage 聚合格式
133242
*/
@@ -215,4 +324,12 @@ server.listen(CONFIG.port, () => {
215324
console.log(`Polling CPA Redis at ${CONFIG.redis.host}:${CONFIG.redis.port}`);
216325
console.log(`Redis queue key: ${CONFIG.redis.key}`);
217326
console.log(`Clear buffer on read: ${CONFIG.clearBufferOnRead}`);
327+
328+
const syncUrl = getSyncUrl();
329+
console.log(`Periodic sync enabled: ${syncConfig.enabled}`);
330+
if (syncConfig.enabled) {
331+
console.log(`Periodic sync target: ${syncUrl || 'invalid DASHBOARD_URL'}`);
332+
console.log(`Periodic sync interval: ${syncConfig.interval}ms`);
333+
console.log(`Periodic sync on start: ${syncConfig.syncOnStart}`);
334+
}
218335
});

app/api/sync/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ async function performSync(request: Request) {
136136
return NextResponse.json({ error: (error as Error).message }, { status: 501 });
137137
}
138138

139-
const usageUrl = `${config.cliproxy.baseUrl.replace(/\/$/, "")}/usage`;
139+
const usageUrl = `${config.cliproxy.usageBaseUrl.replace(/\/$/, "")}/usage`;
140140
const pulledAt = new Date();
141141

142142
let response: Response;

lib/config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ function normalizeBaseUrl(raw: string | undefined) {
77
}
88

99
const baseUrl = normalizeBaseUrl(process.env.CLIPROXY_API_BASE_URL);
10+
const usageBaseUrl = normalizeBaseUrl(process.env.USAGE_API_BASE_URL || process.env.CLIPROXY_API_BASE_URL);
1011
const password = process.env.PASSWORD || process.env.CLIPROXY_SECRET_KEY || "";
1112
const cronSecret = process.env.CRON_SECRET || "";
1213

@@ -27,6 +28,7 @@ const timezone = normalizeTimezone(process.env.TIMEZONE);
2728
export const config = {
2829
cliproxy: {
2930
baseUrl,
31+
usageBaseUrl,
3032
apiKey: process.env.CLIPROXY_SECRET_KEY || ""
3133
},
3234
postgresUrl: process.env.DATABASE_URL || "",
@@ -42,6 +44,9 @@ export function assertEnv() {
4244
if (!config.cliproxy.baseUrl) {
4345
throw new Error("CLIPROXY_API_BASE_URL is missing");
4446
}
47+
if (!config.cliproxy.usageBaseUrl) {
48+
throw new Error("USAGE_API_BASE_URL is missing");
49+
}
4550
if (!config.postgresUrl) {
4651
throw new Error("DATABASE_URL is missing");
4752
}

0 commit comments

Comments
 (0)