docs: Pixelle 连接凭证管理实现计划(5 任务)

serde_yaml+config路径 → service(settings读写+yaml同步+restart) → admin端点(掩码)
→ 前端连接管理卡片 → 部署联调。写文件+重启容器同步机制。

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
boos
2026-06-18 11:33:05 +08:00
parent 973396aac4
commit 284cf84a47
@@ -0,0 +1,503 @@
# Pixelle 连接凭证纳入 BoosAPI 管理 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在 BoosAPI 连接管理页加「Pixelle 连接」卡片,管 runninghub_api_key / comfyui_url / comfyui_api_key 三个凭证,保存即写入 settings 表 + 同步 Pixelle config.yaml + 重启 pixelle 容器生效。
**Architecture:** 新增 service `pixelle_connection.rs`(读写 settings 表 + 改 config.yaml YAML 三键 + docker restart)+ admin 路由(GET/PUT,密钥掩码)+ 前端 Accounts 页卡片。settings 表为真相源,config.yaml 是同步目标。
**Tech Stack:** Rust(axum, sqlx, serde_yaml<新增依赖>, tokio::process)、React/TS。
> **构建/测试:** `cargo build -j 2`(-j2 防 OOM)、`cargo test -p boosapi --lib pixelle_connection`、`npm run typecheck:web`。分支 `feat/comfy-cloud-adapter`。Pixelle compose 目录 `/opt/Pixelle-Video`,config `/opt/Pixelle-Video/config.yaml`。
---
## 文件结构
- Modify `Cargo.toml` — 加 `serde_yaml` 依赖。
- Modify `src/config.rs` — `PixelleVideoConfig` 加 `config_path` / `compose_dir` 两字段(默认 `/opt/Pixelle-Video/config.yaml`、`/opt/Pixelle-Video`)。
- Create `src/services/pixelle_connection.rs` — read/save settings + sync_to_pixelle(改 yaml)+ restart。
- Modify `src/services/mod.rs` — `pub mod pixelle_connection;`。
- Create `src/routes/admin/pixelle_connection.rs` — GET/PUT handler(掩码)。
- Modify `src/routes/admin/mod.rs` — 声明子模块 + 挂 2 条路由。
- Modify `src/web/api.ts` — adminApi 加 getPixelleConnection / savePixelleConnection。
- Modify `src/web/pages/Accounts.tsx` — 加「Pixelle 连接」卡片。
---
<!-- TASKS -->
## Task 1: 加 serde_yaml 依赖 + config 路径字段
**Files:**
- Modify: `Cargo.toml`
- Modify: `src/config.rs`
- [ ] **Step 1: Cargo.toml 加 serde_yaml**
在 `[dependencies]` 段加(版本对齐项目 serde 生态,用 0.9):
```toml
serde_yaml = "0.9"
```
- [ ] **Step 2: PixelleVideoConfig 加两字段(src/config.rs,struct 内 timeout_secs 后)**
在 `pub timeout_secs: u64,` 之后加:
```rust
#[serde(default = "default_pixelle_config_path")]
pub config_path: String,
#[serde(default = "default_pixelle_compose_dir")]
pub compose_dir: String,
```
- [ ] **Step 3: 加默认值函数(src/config.rs,default_pixelle_backend_provider 附近)**
```rust
fn default_pixelle_config_path() -> String {
"/opt/Pixelle-Video/config.yaml".to_string()
}
fn default_pixelle_compose_dir() -> String {
"/opt/Pixelle-Video".to_string()
}
```
- [ ] **Step 4: PixelleVideoConfig::default() 补两字段**
找到 `impl Default for ...` 或 `PixelleVideoConfig::default()`(约 line 207 区域,有 `api_base_url: Some(...)` 那个),在其字段列表末尾加:
```rust
config_path: default_pixelle_config_path(),
compose_dir: default_pixelle_compose_dir(),
```
- [ ] **Step 5: 构建确认**
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
Expected: `Finished`。
- [ ] **Step 6: Commit**
```bash
cd /root/metapi && git add Cargo.toml Cargo.lock src/config.rs
git commit -m "feat(pixelle): 加 serde_yaml + Pixelle config 路径配置项
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T2 -->
## Task 2: pixelle_connection service(settings + yaml 同步)
**Files:**
- Create: `src/services/pixelle_connection.rs`
- Modify: `src/services/mod.rs`
- [ ] **Step 1: src/services/mod.rs 加模块声明**
```rust
pub mod pixelle_connection;
```
- [ ] **Step 2: 写失败测试 `src/services/pixelle_connection.rs`(含结构 + sync 测试)**
```rust
use crate::error::{AppError, AppResult};
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PixelleConnection {
pub runninghub_api_key: Option<String>,
pub comfyui_url: Option<String>,
pub comfyui_api_key: Option<String>,
}
const KEY_RH: &str = "pixelle.runninghub_api_key";
const KEY_URL: &str = "pixelle.comfyui_url";
const KEY_CK: &str = "pixelle.comfyui_api_key";
pub async fn read_connection(db: &SqlitePool) -> AppResult<PixelleConnection> {
async fn get(db: &SqlitePool, k: &str) -> AppResult<Option<String>> {
Ok(sqlx::query_scalar("SELECT value FROM settings WHERE key = ?")
.bind(k).fetch_optional(db).await?)
}
Ok(PixelleConnection {
runninghub_api_key: get(db, KEY_RH).await?,
comfyui_url: get(db, KEY_URL).await?,
comfyui_api_key: get(db, KEY_CK).await?,
})
}
pub async fn save_connection(db: &SqlitePool, conn: &PixelleConnection) -> AppResult<()> {
async fn put(db: &SqlitePool, k: &str, v: &Option<String>) -> AppResult<()> {
if let Some(val) = v {
sqlx::query("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)")
.bind(k).bind(val).execute(db).await?;
}
Ok(())
}
put(db, KEY_RH, &conn.runninghub_api_key).await?;
put(db, KEY_URL, &conn.comfyui_url).await?;
put(db, KEY_CK, &conn.comfyui_api_key).await?;
Ok(())
}
/// 改 config.yaml 的 comfyui.{runninghub_api_key,comfyui_url,comfyui_api_key} 三键,其余保留。
/// 改前备份到 <path>.bak-<ts>。返回备份路径。
pub fn sync_to_pixelle(config_path: &str, conn: &PixelleConnection) -> AppResult<String> {
let content = std::fs::read_to_string(config_path)
.map_err(|e| AppError::ServiceUnavailable(format!("读取 Pixelle config 失败: {e}")))?;
let mut doc: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| AppError::ServiceUnavailable(format!("解析 config.yaml 失败: {e}")))?;
let comfyui = doc.get_mut("comfyui")
.ok_or_else(|| AppError::ServiceUnavailable("config.yaml 缺少 comfyui 段".into()))?;
let map = comfyui.as_mapping_mut()
.ok_or_else(|| AppError::ServiceUnavailable("comfyui 段不是映射".into()))?;
if let Some(v) = &conn.runninghub_api_key {
map.insert("runninghub_api_key".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_url {
map.insert("comfyui_url".into(), serde_yaml::Value::String(v.clone()));
}
if let Some(v) = &conn.comfyui_api_key {
map.insert("comfyui_api_key".into(), serde_yaml::Value::String(v.clone()));
}
let ts = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string();
let backup = format!("{config_path}.bak-{ts}");
std::fs::copy(config_path, &backup)
.map_err(|e| AppError::ServiceUnavailable(format!("备份失败: {e}")))?;
let out = serde_yaml::to_string(&doc)
.map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
std::fs::write(config_path, out)
.map_err(|e| AppError::ServiceUnavailable(format!("写入 config 失败: {e}")))?;
Ok(backup)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sync_updates_three_keys_preserves_rest() {
let dir = std::env::temp_dir().join(format!("pxtest_{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("config.yaml");
std::fs::write(&path,
"project_name: Pixelle-Video\ncomfyui:\n comfyui_url: https://old\n runninghub_api_key: oldkey\n comfyui_api_key: oldck\n runninghub_concurrent_limit: 1\n").unwrap();
let conn = PixelleConnection {
runninghub_api_key: Some("newkey".into()),
comfyui_url: Some("http://new".into()),
comfyui_api_key: Some("newck".into()),
};
let backup = sync_to_pixelle(path.to_str().unwrap(), &conn).unwrap();
assert!(std::path::Path::new(&backup).exists());
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("newkey"));
assert!(after.contains("http://new"));
assert!(after.contains("newck"));
// 保留其余键
assert!(after.contains("runninghub_concurrent_limit"));
assert!(after.contains("project_name"));
std::fs::remove_dir_all(&dir).ok();
}
}
```
- [ ] **Step 3: 运行测试,确认失败**
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -15`
Expected: FAIL —— 模块/函数未定义(首次编译错误)。
- [ ] **Step 4: 实现**
Step 2 的代码即实现(测试与实现同文件)。确保 mod.rs 已声明。
- [ ] **Step 5: 运行测试,确认通过**
Run: `cd /root/metapi && cargo test -p boosapi --lib services::pixelle_connection 2>&1 | tail -10`
Expected: PASS(1 个测试)。
- [ ] **Step 6: Commit**
```bash
cd /root/metapi && git add src/services/mod.rs src/services/pixelle_connection.rs
git commit -m "feat(pixelle): 连接凭证 service(settings 读写 + config.yaml 同步)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T3 -->
## Task 3: restart helper + admin 路由(掩码)
**Files:**
- Modify: `src/services/pixelle_connection.rs`(加 restart)
- Create: `src/routes/admin/pixelle_connection.rs`
- Modify: `src/routes/admin/mod.rs`
- [ ] **Step 1: 加 restart_pixelle(pixelle_connection.rs,sync_to_pixelle 之后)**
```rust
pub async fn restart_pixelle(compose_dir: &str) -> AppResult<()> {
let output = tokio::process::Command::new("docker")
.args(["compose", "restart", "api"])
.current_dir(compose_dir)
.output()
.await
.map_err(|e| AppError::ServiceUnavailable(format!("docker compose restart 启动失败: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(AppError::ServiceUnavailable(format!(
"Pixelle 重启失败: {}", stderr.trim()
)));
}
Ok(())
}
```
- [ ] **Step 2: 写 admin 路由 `src/routes/admin/pixelle_connection.rs`**
```rust
use crate::error::AppResult;
use crate::services::pixelle_connection::{self, PixelleConnection};
use crate::AppState;
use axum::{extract::State, Json};
use serde::Deserialize;
use serde_json::{json, Value};
const MASK_SENTINEL: &str = "••••";
fn mask(v: &Option<String>) -> Value {
match v {
Some(s) if s.len() > 4 => json!(format!("{}{}", MASK_SENTINEL, &s[s.len()-4..])),
Some(s) if !s.is_empty() => json!(MASK_SENTINEL),
_ => Value::Null,
}
}
pub async fn get_connection(State(state): State<AppState>) -> AppResult<Json<Value>> {
let c = pixelle_connection::read_connection(&state.db).await?;
Ok(Json(json!({
"success": true,
"connection": {
// url 不算敏感,明文回;两个 key 掩码
"comfyuiUrl": c.comfyui_url,
"runninghubApiKey": mask(&c.runninghub_api_key),
"comfyuiApiKey": mask(&c.comfyui_api_key),
}
})))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdatePayload {
runninghub_api_key: Option<String>,
comfyui_url: Option<String>,
comfyui_api_key: Option<String>,
}
fn keep_if_not_mask(incoming: Option<String>) -> Option<String> {
// 掩码值(以 •••• 开头)表示"未修改",跳过
match incoming {
Some(s) if s.starts_with(MASK_SENTINEL) => None,
other => other,
}
}
pub async fn put_connection(
State(state): State<AppState>,
Json(p): Json<UpdatePayload>,
) -> AppResult<Json<Value>> {
let conn = PixelleConnection {
runninghub_api_key: keep_if_not_mask(p.runninghub_api_key),
comfyui_url: p.comfyui_url, // url 明文,不掩码
comfyui_api_key: keep_if_not_mask(p.comfyui_api_key),
};
pixelle_connection::save_connection(&state.db, &conn).await?;
let backup = pixelle_connection::sync_to_pixelle(&state.config.pixelle_video.config_path, &conn)?;
let restart = pixelle_connection::restart_pixelle(&state.config.pixelle_video.compose_dir).await;
match restart {
Ok(()) => Ok(Json(json!({ "success": true, "backup": backup, "restarted": true }))),
Err(e) => Ok(Json(json!({
"success": true, "backup": backup, "restarted": false,
"warning": format!("配置已写入,但 Pixelle 重启失败,请手动重启: {e}")
}))),
}
}
```
- [ ] **Step 3: src/routes/admin/mod.rs 声明子模块 + 挂路由**
模块声明区(`mod users;` 附近)加:
```rust
mod pixelle_connection;
```
在 `routes()` 的路由链里(`/api/admin/user-balances` 那一带)加:
```rust
.route(
"/api/admin/pixelle/connection",
get(pixelle_connection::get_connection).put(pixelle_connection::put_connection),
)
```
确认 `get`/`put` 已从 axum::routing 引入(文件顶部应已 `use axum::routing::{get, post, ...}`;若缺 put 则补)。
- [ ] **Step 4: 构建确认**
Run: `cd /root/metapi && cargo build -j 2 2>&1 | tail -3`
Expected: `Finished`。
- [ ] **Step 5: Commit**
```bash
cd /root/metapi && git add src/services/pixelle_connection.rs src/routes/admin/pixelle_connection.rs src/routes/admin/mod.rs
git commit -m "feat(pixelle): 连接凭证 admin 端点(GET/PUT 掩码 + 重启)
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T4 -->
## Task 4: 前端连接管理卡片
**Files:**
- Modify: `src/web/api.ts`
- Modify: `src/web/pages/Accounts.tsx`
- [ ] **Step 1: api.ts adminApi 加方法**
在 adminApi 对象里(找 `request(` 范式的 admin 方法群)加:
```typescript
getPixelleConnection: () =>
request<{ success: boolean; connection: { comfyuiUrl?: string | null; runninghubApiKey?: string | null; comfyuiApiKey?: string | null } }>("/api/admin/pixelle/connection"),
savePixelleConnection: (data: { runninghubApiKey?: string; comfyuiUrl?: string; comfyuiApiKey?: string }) =>
request<{ success: boolean; backup?: string; restarted?: boolean; warning?: string }>("/api/admin/pixelle/connection", {
method: "PUT",
body: JSON.stringify(data),
}),
```
- [ ] **Step 2: Accounts.tsx 加「Pixelle 连接」卡片**
先读 `src/web/pages/Accounts.tsx` 顶部,确认 `api`/`adminApi` 的导入名和 toast 用法。在页面主体某处(如页尾)加一个卡片组件块。最小实现(state + 加载 + 保存):
```tsx
// —— Pixelle 连接(加在组件内 state 区)——
const [pxConn, setPxConn] = useState({ comfyuiUrl: '', runninghubApiKey: '', comfyuiApiKey: '' });
const [pxSaving, setPxSaving] = useState(false);
useEffect(() => {
adminApi.getPixelleConnection()
.then((r) => setPxConn({
comfyuiUrl: r.connection?.comfyuiUrl || '',
runninghubApiKey: r.connection?.runninghubApiKey || '',
comfyuiApiKey: r.connection?.comfyuiApiKey || '',
}))
.catch(() => {});
}, []);
const savePxConn = async () => {
setPxSaving(true);
try {
const res = await adminApi.savePixelleConnection({
runninghubApiKey: pxConn.runninghubApiKey || undefined,
comfyuiUrl: pxConn.comfyuiUrl || undefined,
comfyuiApiKey: pxConn.comfyuiApiKey || undefined,
});
if (res.warning) toast.error(res.warning);
else toast.success('已保存并重启 Pixelle,约数秒生效');
} catch (e: any) {
toast.error(e?.message || '保存失败');
} finally {
setPxSaving(false);
}
};
```
卡片 JSX(加到 return 的合适位置,样式对齐页面现有 card):
```tsx
<div className="card" style={{ marginTop: 16, padding: 16 }}>
<h3>Pixelle 连接</h3>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>ComfyUI URL</span>
<input value={pxConn.comfyuiUrl} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiUrl: e.target.value }))} placeholder="http://172.17.0.1:4000/comfy-cloud" style={{ width: '100%' }} />
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>RunningHub API Key</span>
<input value={pxConn.runninghubApiKey} onChange={(e) => setPxConn((s) => ({ ...s, runninghubApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
</label>
<label style={{ display: 'block', marginBottom: 8 }}>
<span>Comfy Cloud API Key</span>
<input value={pxConn.comfyuiApiKey} onChange={(e) => setPxConn((s) => ({ ...s, comfyuiApiKey: e.target.value }))} placeholder="留空••••表示不改" style={{ width: '100%' }} />
</label>
<button type="button" className="btn btn-primary" disabled={pxSaving} onClick={savePxConn}>
{pxSaving ? '保存中...' : '保存并重启 Pixelle'}
</button>
</div>
```
> 注:`useState`/`useEffect` 若未导入需补;`adminApi`/`api` 用 Accounts.tsx 实际的导入名(读文件确认是 `api` 还是 `adminApi`)。若提交掩码值(`••••...`)后端会跳过不改,符合预期。
- [ ] **Step 3: typecheck**
Run: `cd /root/metapi && npm run typecheck:web 2>&1 | tail -5`
Expected: 无 TS 错误。
- [ ] **Step 4: Commit**
```bash
cd /root/metapi && git add src/web/api.ts src/web/pages/Accounts.tsx
git commit -m "feat(pixelle): 连接管理页加 Pixelle 连接卡片
Co-Authored-By: Claude <noreply@anthropic.com>"
```
<!-- T5 -->
## Task 5: 部署 + 端到端验证
**Files:** 无(部署 + 运行时)
- [ ] **Step 1: 重建并重启 BoosAPI(含前后端改动)**
```bash
cd /root/metapi && ./build-and-run.sh --fast-release 2>&1 | tail -15
```
Expected: 构建成功,BoosAPI 起在 :4000。动线上服务,确认时机。
- [ ] **Step 2: 连接管理页确认「Pixelle 连接」卡片**
硬刷新管理控制台连接管理页(`/accounts`),确认出现「Pixelle 连接」卡片,
ComfyUI URL 显示当前值,两个 key 显示掩码(`••••xxxx`)。
- [ ] **Step 3: 改一个值测试同步**
把 RunningHub API Key 改成一个新值,保存。确认:
```bash
grep runninghub_api_key /opt/Pixelle-Video/config.yaml | sed 's/[a-f0-9]\{20\}/.../'
ls -t /opt/Pixelle-Video/config.yaml.bak-* | head -1
rtk proxy docker ps --filter name=pixelle --format '{{.Names}} {{.Status}}'
```
Expected: config.yaml 的 key 变成新值;有新备份文件;容器重启后 healthy。
- [ ] **Step 4: 掩码不改测试**
只改 ComfyUI URL、两个 key 保持掩码原值,保存。确认 config.yaml 里两个 key **没变**
(掩码值被后端跳过),URL 变了。
- [ ] **Step 5: 成功标准**
UI 改 Pixelle 凭证 → settings 表存值 + config.yaml 同步 + 容器重启,不再需要手动编辑文件。
---
## 完成标准
- 后端:pixelle_connection service(读写 settings + sync_to_pixelle + restart)+ admin GET/PUT 端点,`cargo build -j 2` 通过,service 单测通过。
- 前端:连接管理页有「Pixelle 连接」卡片,typecheck 干净。
- 端到端:UI 改凭证 → config.yaml 同步 + 容器重启生效;掩码值不误改。
- 向后兼容:不改 Pixelle 源码;config.yaml 其余键保留;每次改前备份。
## 已知风险 / 备注
- serde_yaml 重序列化会丢 config.yaml 注释(已接受;每次有时间戳备份兜底)。
- BoosAPI 进程需有 docker compose 执行权限 + config.yaml 读写权限(同宿主 root 运行,满足)。
- Task 5 重启 BoosAPI 动线上服务,确认时机。
- 备份文件会累积(config.yaml.bak-*),长期可加清理,本期不做。