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

- 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:
iven
2026-04-04 14:42:29 +08:00
parent a6902c28f5
commit e90eb5df60
10 changed files with 1408 additions and 51 deletions

View File

@@ -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"

View 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);

View File

@@ -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 }

View File

@@ -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())
}

View 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)
}

View File

@@ -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 — 支付宝/微信服务器回调)