Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "aitokenpool"
version = "0.6.2"
version = "0.6.3"
edition = "2021"
description = "AI Token 共享池 — 企业 key 池 + 公共共享市场"
license = "MIT"
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ AITokenPool 是一个开源的 **AI Token 共享平台**:企业版(内部 ke
- ✅ **v0.6.0**:**移除所有 demo 种子数据(rant 2026-08-19T10:41:03)**——首次部署 = 干净空库(只建表),不再预置 demo/admin/ops 假账号、假余额、占位 key;测试改用 `#[cfg(test)]` 专用 `seed_test_users` 辅助(生产构建不含);UI 登录页/设置页移除演示账号预填与提示
- ✅ **v0.6.1**:**首次启动自动创建初始管理员(rant 2026-08-19T14:35:05)**——空库启动时创建 `admin@aitokenpool.local` + 随机 16 位密码(打印到启动日志,仅首次)+ quotas 账户(balance=0),幂等不重复;新增 `POST /api/auth/change-password` 改密端点(旧密码校验 + argon2 更新);不再需要手工插库
- ✅ **v0.6.2**:**用户自助注册 + 邮箱验证(rant 2026-08-19T14:36:19 方案 B)**——`POST /api/auth/register` + `verify` + `resend-code`;6 位数字验证码(10 分钟有效、5 次错误失效、60 秒重发限频);未验证邮箱不可登录(403);登录页注册表单 + 验证码页(中英 i18n);SMTP 发信(`[mail]` 配置,未配置时 dev 模式验证码打日志/响应)
- ✅ **v0.6.3**:**接入方式 URL 配置化(rant 2026-08-19T20:37:37)**——设置页「接入方式」端点不再硬编码域名:新增 `[server].public_url` 配置(缺省 `http://localhost:8080`)+ `GET /api/config` 下发;前端从配置拼接 `{public_url}/v1`、`{public_url}/anthropic`,取不到配置时回退同源 origin

`ui/` 已由纯静态原型升级为**对接真实 API**(登录、钱包、市场、共享、交易、设置、管理、运营全部真实数据),由后端 `ServeDir` 静态托管,无需单独部署前端。

Expand All @@ -50,6 +51,11 @@ cargo run # http://localhost:8080/
> (或取消 config/config.toml `[server].master_key` 注释)。未配置时使用随机 dev 密钥,
> **重启后已上架的 key 密文无法解密 → 全部 503**(rant 2026-08-18T16:14:21 Bug 3)。

> **对外网关地址(`[server].public_url`)**(rant 2026-08-19T20:37:37):设置页「接入方式」展示的
> OpenAI/Anthropic 兼容端点由它拼接(`{public_url}/v1`、`{public_url}/anthropic`)。
> 缺省 `http://localhost:8080`(本地 dev 正确);**生产必须设为真实域名**(如 `https://pool.example.com`),
> 否则用户拿到的 Base URL 是错的。改配置重启后设置页 URL 自动更新。

### ② Docker 部署

```bash
Expand Down Expand Up @@ -103,8 +109,8 @@ open http://localhost:8080/ # 浏览器访问

## API 端点(Bearer 认证)

- `GET /healthz` → `{"status":"ok","version":"0.6.2"}`
- `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`POST /api/auth/register|verify|resend-code`(注册+邮箱验证);`GET /api/me` → `{id,email,name,role}`
- `GET /healthz` → `{"status":"ok","version":"0.6.3"}`
- `POST /api/auth/login` → `{api_key}`;`POST /api/auth/change-password`(改密);`POST /api/auth/register|verify|resend-code`(注册+邮箱验证);`GET /api/me` → `{id,email,name,role}`;`GET /api/config` → `{public_url}`(接入端点 base,rant 2026-08-19T20:37:37)
- `POST|GET /api/api-keys`(key 脱敏 `atk_live_****xxxx`);`DELETE /api/api-keys/:id`(撤销)
- `POST /v1/chat/completions` / `POST /anthropic/v1/messages` / `POST /v1/responses`(网关,三协议互转,非流式 + 流式 SSE 跨协议转换);`GET /v1/models`(OpenAI 兼容模型列表,认证可选)
- `GET /api/models`(模型市场)
Expand Down
4 changes: 4 additions & 0 deletions config/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
[server]
addr = "0.0.0.0:8080"
db_path = "data/aitokenpool.db"
# 平台对外网关地址(不含 /v1 等路径):设置页「接入方式」端点由此拼接({public_url}/v1、{public_url}/anthropic)。
# 缺省 http://localhost:8080(dev 默认,与 addr 解耦——addr 是监听地址,public_url 是对外可达地址)。
# ⚠️ 生产必须设为真实域名(如 https://pool.example.com),否则前端展示的接入端点是错的。
public_url = "https://gateway.example.com"
# 上游 key 主密钥(P0-C 起):hex 32 字节;env ATP_MASTER_KEY 优先级更高。
# ⚠️ 生产必须显式配置(env 或此处),否则使用随机 dev 密钥 —— 重启后旧密文不可解,
# 所有已上架 key 将解密失败(503)。生成:openssl rand -hex 32
Expand Down
19 changes: 18 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,12 @@ fn default_addr() -> String {
fn default_db_path() -> String {
"data/aitokenpool.db".to_string()
}
/// 对外可达地址缺省(dev 默认;与 addr 解耦——addr 是监听地址,public_url 是对外地址)
fn default_public_url() -> String {
"http://localhost:8080".to_string()
}

/// 服务(监听 / 数据库路径 / 主密钥)——config.example.toml 可缺省,走默认值
/// 服务(监听 / 数据库路径 / 主密钥 / 对外地址)——config.example.toml 可缺省,走默认值
#[derive(Debug, Clone, Deserialize)]
pub struct Server {
#[serde(default = "default_addr")]
Expand All @@ -25,6 +29,10 @@ pub struct Server {
/// 上游 key 主密钥(hex 32 字节;P0-C 起生效;env ATP_MASTER_KEY 优先级更高)
#[serde(default)]
pub master_key: String,
/// 平台对外网关地址(不含 /v1 等路径),供前端「接入方式」端点展示;
/// 生产设置真实域名(如 https://gateway.example.com);缺省 http://localhost:8080
#[serde(default = "default_public_url")]
pub public_url: String,
}

impl Default for Server {
Expand All @@ -33,6 +41,7 @@ impl Default for Server {
addr: default_addr(),
db_path: default_db_path(),
master_key: String::new(),
public_url: default_public_url(),
}
}
}
Expand Down Expand Up @@ -256,6 +265,14 @@ mod tests {
// server 默认值
assert_eq!(cfg.server.addr, "0.0.0.0:8080");
assert_eq!(cfg.server.db_path, "data/aitokenpool.db");
// public_url(rant 2026-08-19T20:37:37:接入方式 URL 配置化)
assert_eq!(cfg.server.public_url, "https://gateway.example.com");
}

#[test]
fn server_public_url_defaults_to_localhost() {
// 未配置 public_url 时缺省 http://localhost:8080(dev 默认,与 addr 解耦)
assert_eq!(Server::default().public_url, "http://localhost:8080");
}

#[test]
Expand Down
24 changes: 24 additions & 0 deletions src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,15 @@ pub async fn me(
})))
}

/// GET /api/config:前端需要的服务端配置(rant 2026-08-19T20:37:37:接入方式 URL 配置化)。
/// 返回 public_url(平台对外网关地址,不含 /v1 等路径),前端据此拼接入端点;
/// 未认证也可访问(public_url 非敏感信息),后续其它前端配置项可复用本端点。
pub async fn config(State(st): State<AppState>) -> Result<Json<serde_json::Value>, ApiErr> {
Ok(Json(serde_json::json!({
"public_url": st.cfg.server.public_url,
})))
}

#[derive(Deserialize)]
pub struct ChangePasswordReq {
pub old_password: String,
Expand Down Expand Up @@ -381,6 +390,7 @@ pub fn router() -> Router<AppState> {
.route("/api/auth/verify", post(verify))
.route("/api/auth/resend-code", post(resend_code))
.route("/api/me", get(me))
.route("/api/config", get(config))
.route("/api/api-keys", post(api_keys::create).get(api_keys::list))
.route("/api/api-keys/:id", axum::routing::delete(api_keys::remove))
.route("/v1/chat/completions", post(gateway::chat_completions))
Expand Down Expand Up @@ -820,6 +830,20 @@ mod tests {
assert_eq!(v["role"], "admin");
}

#[tokio::test]
async fn config_returns_public_url() {
// rant 2026-08-19T20:37:37:接入方式 URL 配置化——GET /api/config 返回 public_url
// (config.example.toml 配了示例真实值 → 返回该值;未认证也可访问)
let st = test_state("cfg");
let (s, body) = get(st.clone(), "/api/config", None).await;
assert_eq!(s, StatusCode::OK, "config 应 200: {body}");
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
assert_eq!(
v["public_url"], "https://gateway.example.com",
"返回 example 配置值: {body}"
);
}

#[tokio::test]
async fn change_password_rotates_and_old_stops_working() {
// rant 2026-08-19T14:35:05:初始管理员改密端点——旧密码校验 + argon2 更新
Expand Down
4 changes: 2 additions & 2 deletions ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -338,13 +338,13 @@ <h3 data-i18n="settings.endpoints">接入方式</h3>
<p class="muted" data-i18n="settings.endpoints.sub">生成了 API Key 后,把下面端点填进 Cursor / Cline / Roo Code / Claude Code 等工具的 Base URL。</p>
<div class="endpoint-row">
<span class="ep-tag" data-i18n="settings.ep.openai">OpenAI 兼容</span>
<code class="ep-url" data-endpoint="https://gateway.aitokenpool.local/v1">https://gateway.aitokenpool.local/v1</code>
<code class="ep-url" data-ep-url="0">https://gateway.aitokenpool.local/v1</code>
<button type="button" class="btn btn-ghost ep-copy" data-ep-copy="0" data-i18n="settings.ak.copy">复制</button>
<span class="ep-desc">Chat Completions · Cursor / Cline / Roo Code / OpenCode / OpenAI SDK</span>
</div>
<div class="endpoint-row">
<span class="ep-tag" data-i18n="settings.ep.anthropic">Anthropic 兼容</span>
<code class="ep-url" data-endpoint="https://gateway.aitokenpool.local/anthropic">https://gateway.aitokenpool.local/anthropic</code>
<code class="ep-url" data-ep-url="1">https://gateway.aitokenpool.local/anthropic</code>
<button type="button" class="btn btn-ghost ep-copy" data-ep-copy="1" data-i18n="settings.ak.copy">复制</button>
<span class="ep-desc">Messages API · Claude Code / Goose / OpenClaw</span>
</div>
Expand Down
39 changes: 33 additions & 6 deletions ui/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1440,6 +1440,8 @@
/* --- 设置 --- */

function renderSettings() {
// 接入端点卡片:实时从配置/同源 fallback 读取(rant 2026-08-19T20:37:37)
applyEndpointUrls();
const rawQ = $("#ak-search").value || "";
const q = rawQ.toLowerCase();
// 零 mock(rant 2026-08-19T15:54:06):登录态绝不 fallback D.API_KEYS;
Expand Down Expand Up @@ -1527,15 +1529,34 @@
}

/* --- 接入端点(rant 2026-08-17T20:44:18:设置页展示 OpenAI/Anthropic 兼容 base URL) --- */
// 原型静态常量:真实值来自部署配置(后端实现时接入 config 的 server.base_url 之类)
const API_ENDPOINTS = [
{ tag: () => T("settings.ep.openai"), url: "https://gateway.aitokenpool.local/v1", desc: "Chat Completions · Cursor / Cline / Roo Code / OpenCode / OpenAI SDK" },
{ tag: () => T("settings.ep.anthropic"), url: "https://gateway.aitokenpool.local/anthropic", desc: "Messages API · Claude Code / Goose / OpenClaw" },
];
// rant 2026-08-19T20:37:37:URL 不再硬编码域名——由配置 public_url 拼接(GET /api/config);
// 取不到配置 → fallback 同源 origin(同源部署天然正确);渲染时实时读取,不依赖全局常量
function endpointBase() {
const u = Live.publicUrl ? String(Live.publicUrl).trim() : "";
return u ? u.replace(/\/+$/, "") : location.origin;
}
function apiEndpoints() {
const base = endpointBase();
return [
{ tag: () => T("settings.ep.openai"), url: base + "/v1", desc: "Chat Completions · Cursor / Cline / Roo Code / OpenCode / OpenAI SDK" },
{ tag: () => T("settings.ep.anthropic"), url: base + "/anthropic", desc: "Messages API · Claude Code / Goose / OpenClaw" },
];
}
// 把动态端点写回设置页「接入方式」卡片(index.html 的 <code data-ep-url="i">)
function applyEndpointUrls() {
const eps = apiEndpoints();
document.querySelectorAll("[data-ep-url]").forEach((el) => {
const i = Number(el.getAttribute("data-ep-url"));
const ep = eps[i];
if (!ep) return;
el.textContent = ep.url;
el.setAttribute("data-endpoint", ep.url);
});
}

// 复制端点 URL(复用 copyKey 的降级逻辑:clipboard API → execCommand → 提示 Ctrl+C)
function copyEndpoint(i) {
const ep = API_ENDPOINTS[i];
const ep = apiEndpoints()[i];
if (!ep) return;
const btn = document.querySelector('[data-ep-copy="' + i + '"]');
const flash = (ok) => {
Expand Down Expand Up @@ -2103,6 +2124,11 @@
D.USER.name = (me && me.name) || (me && me.email ? me.email.split("@")[0] : T("common.user"));
D.USER.email = (me && me.email) || D.USER.email;
D.USER.role = (me && me.role) || "user";
// 服务端配置(rant 2026-08-19T20:37:37):public_url → 接入端点 base;失败 → 同源 fallback
try {
const cfg = await api.get("/api/config");
if (cfg && cfg.public_url) Live.publicUrl = String(cfg.public_url).trim();
} catch (e) { Live.publicUrl = null; }
try {
const w = await api.get("/api/wallet");
D.USER.balance = (w && typeof w.available === "number") ? w.available : (w ? w.balance : 0);
Expand Down Expand Up @@ -2137,6 +2163,7 @@

// 各视图真实数据缓存:登录且加载成功后使用;游客 / 失败降级 mock
const Live = {
publicUrl: null, // GET /api/config → public_url(接入端点 base,rant 2026-08-19T20:37:37)
models: null, // GET /api/models 原始数组
plans: null, // GET /api/plans 原始数组(上架表单数据源;rant 16:14:21 Bug 1)
sharings: null, // GET /api/sharings 原始数组
Expand Down
4 changes: 2 additions & 2 deletions ui/js/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@
"settings.endpoints.sub": "生成了 API Key 后,把下面端点填进 Cursor / Cline / Roo Code / Claude Code 等工具的 Base URL。",
"settings.ep.openai": "OpenAI 兼容",
"settings.ep.anthropic": "Anthropic 兼容",
"settings.ep.note": "部署后替换为你的网关域名(原型占位说明)",
"settings.ep.note": "端点来自服务端配置 public_url;生产部署请在 config 中设为你的网关域名",
"settings.ep.step1": "① 在下方生成 API Key",
"settings.ep.step2": "② 工具 Base URL 填上面对应端点",
"settings.ep.step3": "③ API Key 填刚生成的 key",
Expand Down Expand Up @@ -958,7 +958,7 @@
"settings.endpoints.sub": "After generating an API Key, put these endpoints into the Base URL of Cursor / Cline / Roo Code / Claude Code etc.",
"settings.ep.openai": "OpenAI compatible",
"settings.ep.anthropic": "Anthropic compatible",
"settings.ep.note": "Replace with your gateway domain after deployment (prototype placeholder)",
"settings.ep.note": "Endpoints come from server config public_url; set your gateway domain in config for production",
"settings.ep.step1": "① Generate an API Key below",
"settings.ep.step2": "② Fill the tool's Base URL with the matching endpoint above",
"settings.ep.step3": "③ Fill the API Key you just generated",
Expand Down
Loading