feat: Sprint 3 — benchmark + conversion funnel + invoice PDF
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
- 3.1: Add criterion benchmark for zclaw-growth TF-IDF retrieval
(indexing throughput, query scoring latency, top-K retrieval)
- 3.2: Extend admin-v2 Usage page with recharts funnel chart
(registration → trial → paid conversion) and daily trend bar chart
- 3.3: Add invoice PDF export via genpdf (Arial font, Windows)
with GET /api/v1/billing/invoices/{id}/pdf handler
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -39,3 +39,9 @@ zclaw-types = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio-test = "0.4"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
|
||||
[[bench]]
|
||||
name = "retrieval_bench"
|
||||
harness = false
|
||||
path = "benches/retrieval_bench.rs"
|
||||
|
||||
151
crates/zclaw-growth/benches/retrieval_bench.rs
Normal file
151
crates/zclaw-growth/benches/retrieval_bench.rs
Normal file
@@ -0,0 +1,151 @@
|
||||
//! Benchmark for TF-IDF retrieval performance in zclaw-growth
|
||||
//!
|
||||
//! Measures:
|
||||
//! - Indexing throughput (documents/sec)
|
||||
//! - Query latency at various corpus sizes (10/50/100/500 candidates)
|
||||
//! - Top-K retrieval latency
|
||||
|
||||
use criterion::{
|
||||
black_box, criterion_group, criterion_main, BenchmarkId, Criterion,
|
||||
};
|
||||
use zclaw_growth::retrieval::SemanticScorer;
|
||||
use zclaw_growth::types::{MemoryEntry, MemoryType};
|
||||
|
||||
/// Generate a synthetic memory entry
|
||||
fn make_entry(agent: &str, idx: usize, topic: &str, content: &str) -> MemoryEntry {
|
||||
MemoryEntry::new(
|
||||
agent,
|
||||
MemoryType::Knowledge,
|
||||
&format!("fact-{idx}"),
|
||||
content.to_string(),
|
||||
)
|
||||
.with_keywords(vec![topic.to_string(), format!("topic-{idx}")])
|
||||
}
|
||||
|
||||
/// Build a corpus of N entries with realistic content
|
||||
fn build_corpus(size: usize) -> (SemanticScorer, Vec<MemoryEntry>) {
|
||||
let mut scorer = SemanticScorer::new();
|
||||
let mut entries = Vec::with_capacity(size);
|
||||
|
||||
let topics = [
|
||||
("rust", "Rust is a systems programming language focused on safety and performance with zero-cost abstractions"),
|
||||
("python", "Python is a high-level general-purpose programming language emphasizing code readability"),
|
||||
("machine-learning", "Machine learning is a subset of artificial intelligence that enables systems to learn from data"),
|
||||
("web-development", "Web development involves building and maintaining websites using frontend and backend technologies"),
|
||||
("database", "Database management systems provide tools for storing retrieving and managing structured data efficiently"),
|
||||
("security", "Cybersecurity involves protecting computer systems and networks from information disclosure"),
|
||||
("devops", "DevOps combines software development and IT operations to shorten the systems development lifecycle"),
|
||||
("testing", "Software testing validates that applications meet their specified requirements and are free of defects"),
|
||||
("api", "API design involves creating interfaces that allow different software applications to communicate"),
|
||||
("cloud", "Cloud computing delivers computing services over the internet including servers storage databases networking"),
|
||||
];
|
||||
|
||||
for i in 0..size {
|
||||
let (topic, base_content) = &topics[i % topics.len()];
|
||||
let content = format!(
|
||||
"{base_content}. This fact #{i} discusses {topic} in depth with examples and use cases. \
|
||||
The key concepts include {topic} patterns, Implementation details cover performance optimization."
|
||||
);
|
||||
let entry = make_entry("bench-agent", i, topic, &content);
|
||||
scorer.index_entry(&entry);
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
(scorer, entries)
|
||||
}
|
||||
|
||||
/// Build a list of entries for indexing benchmarks
|
||||
fn build_entries(count: usize) -> Vec<MemoryEntry> {
|
||||
let topics = ["rust", "python", "ml", "web", "database"];
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let topic = topics[i % topics.len()];
|
||||
let content = format!(
|
||||
"Fact {} about {}: detailed technical content with multiple keywords and concepts \
|
||||
covering advanced patterns, best practices, and optimization strategies.",
|
||||
i, topic
|
||||
);
|
||||
make_entry("bench-agent", i, topic, &content)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ─── Indexing throughput ───
|
||||
|
||||
fn bench_indexing(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("index_entry");
|
||||
group.sample_size(50);
|
||||
|
||||
for &batch_size in &[10, 50, 100, 500] {
|
||||
let entries = build_entries(batch_size);
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("batch", batch_size),
|
||||
&entries,
|
||||
|b, entries| {
|
||||
b.iter(|| {
|
||||
let mut scorer = SemanticScorer::new();
|
||||
for entry in entries {
|
||||
scorer.index_entry(black_box(entry));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Query scoring latency ───
|
||||
|
||||
fn bench_query_scoring(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("score_similarity");
|
||||
|
||||
for &corpus_size in &[10, 50, 100, 500] {
|
||||
let (scorer, entries) = build_corpus(corpus_size);
|
||||
let query = "rust safety performance optimization";
|
||||
let entry = &entries[corpus_size / 2];
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("corpus", corpus_size),
|
||||
&scorer,
|
||||
|b, scorer| {
|
||||
b.iter(|| scorer.score_similarity(black_box(query), black_box(entry)));
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Top-K retrieval ───
|
||||
|
||||
fn bench_top_k_retrieval(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("top_k_retrieval");
|
||||
|
||||
for &corpus_size in &[10, 50, 100, 500] {
|
||||
let (scorer, entries) = build_corpus(corpus_size);
|
||||
let query = "machine learning model training optimization";
|
||||
|
||||
group.bench_with_input(
|
||||
BenchmarkId::new("top3", corpus_size),
|
||||
&entries,
|
||||
|b, entries| {
|
||||
b.iter(|| {
|
||||
let mut scored: Vec<(f32, usize)> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, entry)| (scorer.score_similarity(query, entry), idx))
|
||||
.collect();
|
||||
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let _top3 = scored.into_iter().take(3).collect::<Vec<_>>();
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_indexing,
|
||||
bench_query_scoring,
|
||||
bench_top_k_retrieval,
|
||||
);
|
||||
|
||||
criterion_main!(benches);
|
||||
@@ -51,6 +51,7 @@ aes-gcm = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
async-stream = { workspace = true }
|
||||
genpdf = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
|
||||
@@ -473,3 +473,77 @@ mod crypto {
|
||||
provided.len() >= 16 && expected.len() >= 16 && provided == expected
|
||||
}
|
||||
}
|
||||
|
||||
// === 发票 PDF ===
|
||||
|
||||
/// GET /api/v1/billing/invoices/:id/pdf — 下载发票 PDF
|
||||
pub async fn get_invoice_pdf(
|
||||
State(state): State<AppState>,
|
||||
Extension(ctx): Extension<AuthContext>,
|
||||
Path(invoice_id): Path<String>,
|
||||
) -> SaasResult<axum::response::Response> {
|
||||
// 查询发票(需属于当前账户)
|
||||
let invoice: Invoice = sqlx::query_as::<_, Invoice>(
|
||||
"SELECT * FROM billing_invoices WHERE id = $1 AND account_id = $2"
|
||||
)
|
||||
.bind(&invoice_id)
|
||||
.bind(&ctx.account_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or_else(|| SaasError::NotFound("发票不存在".into()))?;
|
||||
|
||||
// 仅已支付的发票可下载 PDF
|
||||
if invoice.status != "paid" {
|
||||
return Err(SaasError::InvalidInput("仅已支付的发票可导出 PDF".into()));
|
||||
}
|
||||
|
||||
// 查询关联支付记录
|
||||
let payments: Vec<Payment> = sqlx::query_as::<_, Payment>(
|
||||
"SELECT * FROM billing_payments WHERE invoice_id = $1"
|
||||
)
|
||||
.bind(&invoice_id)
|
||||
.fetch_all(&state.db)
|
||||
.await?;
|
||||
|
||||
// 构造发票信息(从 invoice metadata 中提取)
|
||||
let info = super::invoice_pdf::InvoiceInfo {
|
||||
title: invoice.metadata.get("invoice_title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
tax_id: invoice.metadata.get("tax_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
email: invoice.metadata.get("email")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
address: invoice.metadata.get("address")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
phone: invoice.metadata.get("phone")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string(),
|
||||
};
|
||||
|
||||
// 生成 PDF
|
||||
let bytes = super::invoice_pdf::generate_invoice_pdf(&invoice, &payments, &info)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Invoice PDF generation failed: {}", e);
|
||||
SaasError::Internal("PDF 生成失败".into())
|
||||
})?;
|
||||
|
||||
// 返回 PDF 响应
|
||||
Ok(axum::response::Response::builder()
|
||||
.status(200)
|
||||
.header("Content-Type", "application/pdf")
|
||||
.header(
|
||||
"Content-Disposition",
|
||||
format!("attachment; filename=\"invoice-{}.pdf\"", invoice.id),
|
||||
)
|
||||
.body(axum::body::Body::from(bytes))
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
107
crates/zclaw-saas/src/billing/invoice_pdf.rs
Normal file
107
crates/zclaw-saas/src/billing/invoice_pdf.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
//! 发票 PDF 生成模块
|
||||
//!
|
||||
//! 使用 genpdf 生成 PDF 发票。
|
||||
//! genpdf 需要 TTF 字体文件来测量文本宽度,我们使用 Windows 内置 Arial 字体。
|
||||
//! 注意: Arial 不支持中文字符,发票字段使用英文标签。
|
||||
|
||||
use genpdf::elements::Paragraph;
|
||||
use genpdf::fonts;
|
||||
use genpdf::{Document, Element, SimplePageDecorator};
|
||||
|
||||
use crate::billing::types::Invoice;
|
||||
use crate::billing::types::Payment;
|
||||
|
||||
/// 发票信息结构 — 用于客户填写
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct InvoiceInfo {
|
||||
pub title: String,
|
||||
pub tax_id: String,
|
||||
pub email: String,
|
||||
pub address: String,
|
||||
pub phone: String,
|
||||
}
|
||||
|
||||
/// 加载 Arial 字体族(Windows 系统字体)
|
||||
fn load_font_family() -> Result<fonts::FontFamily<fonts::FontData>, Box<dyn std::error::Error>> {
|
||||
let font_dir = "C:/Windows/Fonts";
|
||||
let family = fonts::from_files(font_dir, "arial", Some(fonts::Builtin::Helvetica))?;
|
||||
Ok(family)
|
||||
}
|
||||
|
||||
/// 生成发票 PDF 字节
|
||||
pub fn generate_invoice_pdf(
|
||||
invoice: &Invoice,
|
||||
payments: &[Payment],
|
||||
info: &InvoiceInfo,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let font_family = load_font_family()?;
|
||||
let mut doc = Document::new(font_family);
|
||||
doc.set_title(&format!("ZCLAW Invoice #{}", invoice.id));
|
||||
|
||||
let mut decorator = SimplePageDecorator::new();
|
||||
decorator.set_margins(10);
|
||||
doc.set_page_decorator(decorator);
|
||||
|
||||
// Header
|
||||
let header_style = genpdf::style::Style::new().with_font_size(14).bold();
|
||||
doc.push(Paragraph::new(format!("ZCLAW INVOICE #{}", invoice.id)).styled(header_style));
|
||||
doc.push(Paragraph::new(""));
|
||||
|
||||
// Customer info
|
||||
let info_style = genpdf::style::Style::new().with_font_size(10);
|
||||
doc.push(
|
||||
Paragraph::new(format!(
|
||||
"Title: {}\nTax ID: {}\nEmail: {}\nAddress: {}\nPhone: {}",
|
||||
info.title, info.tax_id, info.email, info.address, info.phone,
|
||||
))
|
||||
.styled(info_style),
|
||||
);
|
||||
doc.push(Paragraph::new(""));
|
||||
|
||||
// Invoice details
|
||||
let detail_style = genpdf::style::Style::new().with_font_size(10);
|
||||
let amount_yuan = format!("{:.2} CNY", invoice.amount_cents as f64 / 100.0);
|
||||
let created = invoice.created_at.format("%Y-%m-%d %H:%M");
|
||||
|
||||
doc.push(
|
||||
Paragraph::new(format!(
|
||||
"Plan: {}\nSubscription: {}\nAmount: {}\nDate: {}\nStatus: {}",
|
||||
invoice.plan_id.as_deref().unwrap_or("N/A"),
|
||||
invoice.subscription_id.as_deref().unwrap_or("N/A"),
|
||||
amount_yuan,
|
||||
created,
|
||||
invoice.status,
|
||||
))
|
||||
.styled(detail_style),
|
||||
);
|
||||
doc.push(Paragraph::new(""));
|
||||
|
||||
// Payment records
|
||||
if !payments.is_empty() {
|
||||
doc.push(Paragraph::new("Payments:").styled(detail_style));
|
||||
for p in payments {
|
||||
let paid_at = p
|
||||
.paid_at
|
||||
.map(|t| t.format("%Y-%m-%d %H:%M").to_string())
|
||||
.unwrap_or_else(|| "pending".to_string());
|
||||
let amount = format!("{:.2} CNY", p.amount_cents as f64 / 100.0);
|
||||
doc.push(
|
||||
Paragraph::new(format!(
|
||||
" {} via {} - {} ({})",
|
||||
amount, p.method, p.status, paid_at,
|
||||
))
|
||||
.styled(detail_style),
|
||||
);
|
||||
}
|
||||
doc.push(Paragraph::new(""));
|
||||
}
|
||||
|
||||
// Footer
|
||||
let footer_style = genpdf::style::Style::new().with_font_size(8);
|
||||
doc.push(Paragraph::new("Thank you for using ZCLAW.").styled(footer_style));
|
||||
|
||||
// Render to bytes
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
doc.render(&mut buf)?;
|
||||
Ok(buf)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ pub mod types;
|
||||
pub mod service;
|
||||
pub mod handlers;
|
||||
pub mod payment;
|
||||
pub mod invoice_pdf;
|
||||
|
||||
use axum::routing::{get, post};
|
||||
|
||||
@@ -17,6 +18,7 @@ pub fn routes() -> axum::Router<crate::state::AppState> {
|
||||
.route("/api/v1/billing/usage/increment", post(handlers::increment_usage_dimension))
|
||||
.route("/api/v1/billing/payments", post(handlers::create_payment))
|
||||
.route("/api/v1/billing/payments/{id}", get(handlers::get_payment_status))
|
||||
.route("/api/v1/billing/invoices/{id}/pdf", get(handlers::get_invoice_pdf))
|
||||
}
|
||||
|
||||
/// 支付回调路由(无需 auth — 支付宝/微信服务器回调)
|
||||
|
||||
Reference in New Issue
Block a user