diff --git a/Cargo.lock b/Cargo.lock index 50d30da..d60524a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -60,7 +60,7 @@ dependencies = [ [[package]] name = "aitokenpool" -version = "0.6.2" +version = "0.6.3" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 3a12152..cf0b3b9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aitokenpool" -version = "0.6.2" +version = "0.6.3" edition = "2021" description = "AI Token 共享池 — 企业 key 池 + 公共共享市场" license = "MIT" diff --git a/README.md b/README.md index fdd1da9..8b76b1b 100644 --- a/README.md +++ b/README.md @@ -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` 静态托管,无需单独部署前端。 @@ -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 @@ -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`(模型市场) diff --git a/config/config.example.toml b/config/config.example.toml index 54dce5f..943ef4d 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -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 diff --git a/src/config.rs b/src/config.rs index 9424d6c..20352bd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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")] @@ -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 { @@ -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(), } } } @@ -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] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 9a703fc..693b5b2 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -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) -> Result, ApiErr> { + Ok(Json(serde_json::json!({ + "public_url": st.cfg.server.public_url, + }))) +} + #[derive(Deserialize)] pub struct ChangePasswordReq { pub old_password: String, @@ -381,6 +390,7 @@ pub fn router() -> Router { .route("/api/auth/verify", post(verify)) .route("/api/auth/resend-code", post(resend_code)) .route("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/api/me", get(me)) + .route("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/api/config", get(config)) .route("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/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)) @@ -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(), "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/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 更新 diff --git a/ui/index.html b/ui/index.html index ef8d11e..795c1a7 100644 --- a/ui/index.html +++ b/ui/index.html @@ -338,13 +338,13 @@

接入方式

生成了 API Key 后,把下面端点填进 Cursor / Cline / Roo Code / Claude Code 等工具的 Base URL。

OpenAI 兼容 - https://gateway.aitokenpool.local/v1 + https://gateway.aitokenpool.local/v1 Chat Completions · Cursor / Cline / Roo Code / OpenCode / OpenAI SDK
Anthropic 兼容 - https://gateway.aitokenpool.local/anthropic + https://gateway.aitokenpool.local/anthropic Messages API · Claude Code / Goose / OpenClaw
diff --git a/ui/js/app.js b/ui/js/app.js index 549c616..c565b13 100644 --- a/ui/js/app.js +++ b/ui/js/app.js @@ -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; @@ -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 的 ) + 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) => { @@ -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("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/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("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/api/wallet"); D.USER.balance = (w && typeof w.available === "number") ? w.available : (w ? w.balance : 0); @@ -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 原始数组 diff --git a/ui/js/i18n.js b/ui/js/i18n.js index eb36815..5dd15e9 100644 --- a/ui/js/i18n.js +++ b/ui/js/i18n.js @@ -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", @@ -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",