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

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