fix: 7 项 E2E Bug 修复 — Dashboard 404 / 记忆去重 / 记忆注入 / invoice_id / Prompt 版本
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
P0: - BUG-H1: Dashboard 路由 /api/v1/stats/dashboard → /api/v1/admin/dashboard P1: - BUG-H2: viking_add 预检查 content_hash 去重,返回 "deduped" 状态;SqliteStorage 启动时回填已有条目 content_hash - BUG-M5: saas-relay-client 发送前调用 viking_inject_prompt 注入跨会话记忆 P2: - BUG-M1: PaymentResult 添加 invoice_id 字段,query_payment_status 返回 invoice_id - BUG-M2: UpdatePromptRequest 添加内容字段,更新时自动创建新版本并递增 current_version - BUG-M3: viking_find scope 参数文档化(设计行为,调用方需传 agent scope) - BUG-M4: Dashboard 路由缺失已修复,handler 层 require_admin 已正确返回 403 P3 (确认已修复/非代码问题): - BUG-L1: pain_seed_categories 已统一,无 pain_seeds 残留 - BUG-L2: pipeline_create 参数格式正确,E2E 测试方法问题
This commit is contained in:
@@ -179,6 +179,36 @@ impl SqliteStorage {
|
|||||||
.execute(&self.pool)
|
.execute(&self.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
// Backfill content_hash for existing entries that have NULL content_hash
|
||||||
|
{
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
|
let rows: Vec<(String, String)> = sqlx::query_as(
|
||||||
|
"SELECT uri, content FROM memories WHERE content_hash IS NULL"
|
||||||
|
)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
if !rows.is_empty() {
|
||||||
|
for (uri, content) in &rows {
|
||||||
|
let normalized = content.trim().to_lowercase();
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
normalized.hash(&mut hasher);
|
||||||
|
let hash = format!("{:016x}", hasher.finish());
|
||||||
|
let _ = sqlx::query("UPDATE memories SET content_hash = ? WHERE uri = ?")
|
||||||
|
.bind(&hash)
|
||||||
|
.bind(uri)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
tracing::info!(
|
||||||
|
"[SqliteStorage] Backfilled content_hash for {} existing entries",
|
||||||
|
rows.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create metadata table
|
// Create metadata table
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
|||||||
.route("/api/v1/tokens", post(handlers::create_token))
|
.route("/api/v1/tokens", post(handlers::create_token))
|
||||||
.route("/api/v1/tokens/:id", delete(handlers::revoke_token))
|
.route("/api/v1/tokens/:id", delete(handlers::revoke_token))
|
||||||
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
.route("/api/v1/logs/operations", get(handlers::list_operation_logs))
|
||||||
.route("/api/v1/stats/dashboard", get(handlers::dashboard_stats))
|
.route("/api/v1/admin/dashboard", get(handlers::dashboard_stats))
|
||||||
.route("/api/v1/devices", get(handlers::list_devices))
|
.route("/api/v1/devices", get(handlers::list_devices))
|
||||||
.route("/api/v1/devices/register", post(handlers::register_device))
|
.route("/api/v1/devices/register", post(handlers::register_device))
|
||||||
.route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat))
|
.route("/api/v1/devices/heartbeat", post(handlers::device_heartbeat))
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ pub async fn create_payment(
|
|||||||
|
|
||||||
Ok(PaymentResult {
|
Ok(PaymentResult {
|
||||||
payment_id,
|
payment_id,
|
||||||
|
invoice_id,
|
||||||
trade_no,
|
trade_no,
|
||||||
pay_url,
|
pay_url,
|
||||||
amount_cents: plan.price_cents,
|
amount_cents: plan.price_cents,
|
||||||
@@ -272,8 +273,8 @@ pub async fn query_payment_status(
|
|||||||
payment_id: &str,
|
payment_id: &str,
|
||||||
account_id: &str,
|
account_id: &str,
|
||||||
) -> SaasResult<serde_json::Value> {
|
) -> SaasResult<serde_json::Value> {
|
||||||
let payment: (String, String, i32, String, String) = sqlx::query_as::<_, (String, String, i32, String, String)>(
|
let payment: (String, String, String, i32, String, String) = sqlx::query_as::<_, (String, String, String, i32, String, String)>(
|
||||||
"SELECT id, method, amount_cents, currency, status \
|
"SELECT id, invoice_id, method, amount_cents, currency, status \
|
||||||
FROM billing_payments WHERE id = $1 AND account_id = $2"
|
FROM billing_payments WHERE id = $1 AND account_id = $2"
|
||||||
)
|
)
|
||||||
.bind(payment_id)
|
.bind(payment_id)
|
||||||
@@ -282,9 +283,10 @@ pub async fn query_payment_status(
|
|||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| SaasError::NotFound("支付记录不存在".into()))?;
|
.ok_or_else(|| SaasError::NotFound("支付记录不存在".into()))?;
|
||||||
|
|
||||||
let (id, method, amount, currency, status) = payment;
|
let (id, invoice_id, method, amount, currency, status) = payment;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"id": id,
|
"id": id,
|
||||||
|
"invoice_id": invoice_id,
|
||||||
"method": method,
|
"method": method,
|
||||||
"amount_cents": amount,
|
"amount_cents": amount,
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ pub struct CreatePaymentRequest {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct PaymentResult {
|
pub struct PaymentResult {
|
||||||
pub payment_id: String,
|
pub payment_id: String,
|
||||||
|
pub invoice_id: String,
|
||||||
pub trade_no: String,
|
pub trade_no: String,
|
||||||
pub pay_url: String,
|
pub pay_url: String,
|
||||||
pub amount_cents: i32,
|
pub amount_cents: i32,
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ pub async fn get_prompt(
|
|||||||
Ok(Json(service::get_template_by_name(&state.db, &name).await?))
|
Ok(Json(service::get_template_by_name(&state.db, &name).await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// PUT /api/v1/prompts/{name} — 更新模板元数据
|
/// PUT /api/v1/prompts/{name} — 更新模板元数据 + 可选自动创建新版本
|
||||||
pub async fn update_prompt(
|
pub async fn update_prompt(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Extension(ctx): Extension<AuthContext>,
|
Extension(ctx): Extension<AuthContext>,
|
||||||
@@ -82,6 +82,11 @@ pub async fn update_prompt(
|
|||||||
&state.db, &tmpl.id,
|
&state.db, &tmpl.id,
|
||||||
req.description.as_deref(),
|
req.description.as_deref(),
|
||||||
req.status.as_deref(),
|
req.status.as_deref(),
|
||||||
|
req.system_prompt.as_deref(),
|
||||||
|
req.user_prompt_template.as_deref(),
|
||||||
|
req.variables.clone(),
|
||||||
|
req.changelog.as_deref(),
|
||||||
|
req.min_app_version.as_deref(),
|
||||||
).await?;
|
).await?;
|
||||||
|
|
||||||
log_operation(&state.db, &ctx.account_id, "prompt.update", "prompt", &tmpl.id,
|
log_operation(&state.db, &ctx.account_id, "prompt.update", "prompt", &tmpl.id,
|
||||||
@@ -99,7 +104,7 @@ pub async fn archive_prompt(
|
|||||||
check_permission(&ctx, "prompt:admin")?;
|
check_permission(&ctx, "prompt:admin")?;
|
||||||
|
|
||||||
let tmpl = service::get_template_by_name(&state.db, &name).await?;
|
let tmpl = service::get_template_by_name(&state.db, &name).await?;
|
||||||
let result = service::update_template(&state.db, &tmpl.id, None, Some("archived")).await?;
|
let result = service::update_template(&state.db, &tmpl.id, None, Some("archived"), None, None, None, None, None).await?;
|
||||||
|
|
||||||
log_operation(&state.db, &ctx.account_id, "prompt.archive", "prompt", &tmpl.id, None, ctx.client_ip.as_deref()).await?;
|
log_operation(&state.db, &ctx.account_id, "prompt.archive", "prompt", &tmpl.id, None, ctx.client_ip.as_deref()).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -108,12 +108,20 @@ pub async fn list_templates(
|
|||||||
Ok(PaginatedResponse { items, total, page, page_size })
|
Ok(PaginatedResponse { items, total, page, page_size })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 更新模板元数据(不修改内容)
|
/// 更新模板元数据 + 可选自动创建新版本
|
||||||
|
///
|
||||||
|
/// 当传入 `system_prompt` 时,自动创建新版本并递增 `current_version`。
|
||||||
|
/// 仅更新 `description`/`status` 时不会递增版本号。
|
||||||
pub async fn update_template(
|
pub async fn update_template(
|
||||||
db: &PgPool,
|
db: &PgPool,
|
||||||
id: &str,
|
id: &str,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
status: Option<&str>,
|
status: Option<&str>,
|
||||||
|
system_prompt: Option<&str>,
|
||||||
|
user_prompt_template: Option<&str>,
|
||||||
|
variables: Option<serde_json::Value>,
|
||||||
|
changelog: Option<&str>,
|
||||||
|
min_app_version: Option<&str>,
|
||||||
) -> SaasResult<PromptTemplateInfo> {
|
) -> SaasResult<PromptTemplateInfo> {
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
@@ -130,6 +138,11 @@ pub async fn update_template(
|
|||||||
.bind(st).bind(&now).bind(id).execute(db).await?;
|
.bind(st).bind(&now).bind(id).execute(db).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-create version when content is provided
|
||||||
|
if let Some(sp) = system_prompt {
|
||||||
|
create_version(db, id, sp, user_prompt_template, variables, changelog, min_app_version).await?;
|
||||||
|
}
|
||||||
|
|
||||||
get_template(db, id).await
|
get_template(db, id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ pub struct CreatePromptRequest {
|
|||||||
pub struct UpdatePromptRequest {
|
pub struct UpdatePromptRequest {
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
|
/// If provided, auto-creates a new version with this content
|
||||||
|
pub system_prompt: Option<String>,
|
||||||
|
pub user_prompt_template: Option<String>,
|
||||||
|
pub variables: Option<serde_json::Value>,
|
||||||
|
pub changelog: Option<String>,
|
||||||
|
pub min_app_version: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Prompt Version ---
|
// --- Prompt Version ---
|
||||||
|
|||||||
@@ -189,6 +189,36 @@ pub async fn viking_add(uri: String, content: String) -> Result<VikingAddResult,
|
|||||||
// Expected format: agent://{agent_id}/{type}/{category}
|
// Expected format: agent://{agent_id}/{type}/{category}
|
||||||
let (agent_id, memory_type, category) = parse_uri(&uri)?;
|
let (agent_id, memory_type, category) = parse_uri(&uri)?;
|
||||||
|
|
||||||
|
// Pre-check for duplicates via content hash
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let normalized_content = content.trim().to_lowercase();
|
||||||
|
let content_hash = {
|
||||||
|
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
normalized_content.hash(&mut hasher);
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
};
|
||||||
|
|
||||||
|
let agent_scope = uri.split('/').nth(2).unwrap_or("");
|
||||||
|
let scope_prefix = format!("agent://{agent_scope}/");
|
||||||
|
|
||||||
|
// Check for existing entry with the same content hash in the same agent scope
|
||||||
|
let pool = storage.pool();
|
||||||
|
let existing: Option<(String,)> = sqlx::query_as(
|
||||||
|
"SELECT uri FROM memories WHERE content_hash = ? AND uri LIKE ? LIMIT 1"
|
||||||
|
)
|
||||||
|
.bind(&content_hash)
|
||||||
|
.bind(format!("{}%", scope_prefix))
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Dedup check failed: {}", e))?;
|
||||||
|
|
||||||
|
if existing.is_some() {
|
||||||
|
return Ok(VikingAddResult {
|
||||||
|
uri,
|
||||||
|
status: "deduped".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let entry = MemoryEntry::new(&agent_id, memory_type, &category, content);
|
let entry = MemoryEntry::new(&agent_id, memory_type, &category, content);
|
||||||
|
|
||||||
storage
|
storage
|
||||||
|
|||||||
@@ -16,6 +16,39 @@ import { createLogger } from './logger';
|
|||||||
|
|
||||||
const log = createLogger('SaaSRelayGateway');
|
const log = createLogger('SaaSRelayGateway');
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Memory injection helper — injects relevant memories into system prompt
|
||||||
|
// before sending to SaaS relay (mirrors MemoryMiddleware in Tauri kernel path)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempt to inject relevant memories into the system prompt via Tauri IPC.
|
||||||
|
* Falls back gracefully in non-Tauri contexts (browser mode).
|
||||||
|
*/
|
||||||
|
async function injectMemories(
|
||||||
|
agentId: string | undefined,
|
||||||
|
basePrompt: string,
|
||||||
|
userInput: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
// Dynamic import — only available in Tauri context
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const enhanced = await invoke<string>('viking_inject_prompt', {
|
||||||
|
agentId: agentId ?? 'default',
|
||||||
|
basePrompt,
|
||||||
|
userInput,
|
||||||
|
maxTokens: 500,
|
||||||
|
});
|
||||||
|
if (enhanced && enhanced !== basePrompt) {
|
||||||
|
log.debug('Memory injection succeeded for relay request');
|
||||||
|
return enhanced;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-Tauri context or viking not initialized — skip silently
|
||||||
|
}
|
||||||
|
return basePrompt;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Frontend DataMasking — mirrors Rust DataMasking middleware for SaaS Relay
|
// Frontend DataMasking — mirrors Rust DataMasking middleware for SaaS Relay
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -180,6 +213,17 @@ export function createSaaSRelayGatewayClient(
|
|||||||
? [...history, { role: 'user' as const, content: maskedMessage }]
|
? [...history, { role: 'user' as const, content: maskedMessage }]
|
||||||
: [{ role: 'user' as const, content: maskedMessage }];
|
: [{ role: 'user' as const, content: maskedMessage }];
|
||||||
|
|
||||||
|
// BUG-M5 fix: Inject relevant memories into system prompt via Tauri IPC.
|
||||||
|
// This mirrors the MemoryMiddleware that runs in the kernel path.
|
||||||
|
const enhancedSystemPrompt = await injectMemories(
|
||||||
|
opts?.agentId,
|
||||||
|
'',
|
||||||
|
message,
|
||||||
|
);
|
||||||
|
if (enhancedSystemPrompt) {
|
||||||
|
messages.unshift({ role: 'system', content: enhancedSystemPrompt });
|
||||||
|
}
|
||||||
|
|
||||||
const model = getModel();
|
const model = getModel();
|
||||||
if (!model) {
|
if (!model) {
|
||||||
callbacks.onError('No model available — please check SaaS relay configuration');
|
callbacks.onError('No model available — please check SaaS relay configuration');
|
||||||
|
|||||||
Reference in New Issue
Block a user