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:
@@ -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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user