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

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