Files
asd-backend/src/handlers/payment.rs

1256 lines
44 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// handlers/payment.rs — 支付相关处理器(接入支付宝)
use actix_web::{get, post, web, HttpRequest, HttpResponse};
use chrono::Utc;
use rsa::pkcs1v15::SigningKey;
use rsa::pkcs8::DecodePrivateKey;
use rsa::signature::{SignatureEncoding, Signer};
use rsa::RsaPrivateKey;
use serde::Deserialize;
use sha2::Sha256;
use sqlx::postgres::PgPool;
use std::collections::BTreeMap;
use tracing::info;
use uuid::Uuid;
use crate::db;
use crate::error::AppError;
use crate::models::{Claims, CreateOrderRequest, MockConfirmRequest};
// ===== 套餐定义 =====
struct PackageInfo {
amount: i32,
display_amount: &'static str,
display_name: &'static str,
days: Option<i64>,
}
fn get_package_info(package_type: &str) -> Option<PackageInfo> {
match package_type {
"monthly" => Some(PackageInfo {
amount: 990,
display_amount: "9.9",
display_name: "包月会员",
days: Some(30),
}),
"yearly" => Some(PackageInfo {
amount: 5900,
display_amount: "59",
display_name: "包年会员",
days: Some(365),
}),
"permanent" => Some(PackageInfo {
amount: 19900,
display_amount: "199",
display_name: "永久会员",
days: None,
}),
_ => None,
}
}
// ===== 支付宝配置 =====
struct AlipayConfig {
app_id: String,
private_key: String,
alipay_public_key: String,
gateway: String,
}
impl AlipayConfig {
fn from_env() -> Option<Self> {
Some(Self {
app_id: std::env::var("ALIPAY_APP_ID").ok()?,
private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok()?,
alipay_public_key: std::env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok()?,
gateway: std::env::var("ALIPAY_GATEWAY")
.unwrap_or_else(|_| "https://openapi.alipay.com/gateway.do".to_string()),
})
}
}
/// URL 编码RFC 3986
fn urlencoding(s: &str) -> String {
let mut result = String::new();
for c in s.chars() {
match c {
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c),
_ => {
for b in c.to_string().as_bytes() {
result.push_str(&format!("%{:02X}", b));
}
}
}
}
result
}
/// RSA2 (SHA256) 签名
fn rsa2_sign(content: &str, private_key_pem: &str) -> Result<String, String> {
let private_key = match RsaPrivateKey::from_pkcs8_pem(private_key_pem) {
Ok(key) => key,
Err(_) => {
let decoded = base64::Engine::decode(
&base64::engine::general_purpose::STANDARD,
private_key_pem,
)
.map_err(|e| format!("Base64 解码失败: {}", e))?;
let pem_str = String::from_utf8(decoded)
.map_err(|e| format!("PEM 字符串格式错误: {}", e))?;
RsaPrivateKey::from_pkcs8_pem(&pem_str)
.map_err(|e| format!("私钥解析失败: {}", e))?
}
};
let signing_key = SigningKey::<Sha256>::new(private_key);
let signature = signing_key.sign(content.as_bytes());
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
signature.to_bytes().as_ref(),
))
}
/// 合并 mock 支付 HTML在 payment_page 和 alipay_pay_page 中复用)
fn build_mock_pay_html(order_no: &str, display_name: &str, jwt: &str) -> String {
format!(r#"<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>模拟支付</title></head>
<body style="font-family:-apple-system;padding:40px;text-align:center">
<h2 style="color:#52c41a">模拟支付环境</h2>
<p style="color:#666">当前为沙箱模拟环境,无需真实支付</p>
<p style="color:#999;font-size:14px">订单号: {}</p>
<p style="color:#999;font-size:14px">套餐: {}</p>
<button id="confirmBtn" onclick="confirmMockPay()" style="padding:12px 32px;font-size:16px;background:#1677ff;color:#fff;border:none;border-radius:4px;cursor:pointer">确认模拟支付</button>
<p id="result"></p>
<p><a href="/payment" style="color:#1677ff">返回重试</a></p>
<script>
function confirmMockPay() {{
var btn = document.getElementById('confirmBtn');
btn.disabled = true;
btn.textContent = '处理中...';
fetch('/api/payment/mock-confirm', {{
method: 'POST',
headers: {{
'Content-Type': 'application/json',
'Authorization': 'Bearer {}'
}},
body: JSON.stringify({{order_id: '{}'}})
}})
.then(r => r.json())
.then(data => {{
if (data.success) {{
document.getElementById('result').innerHTML = '<span style="color:#52c41a">支付成功!</span>';
setTimeout(() => window.location.href = '/payment?mock=1', 1500);
}} else {{
document.getElementById('result').innerHTML = '<span style="color:#f5222d">失败: ' + (data.error || '未知错误') + '</span>';
btn.disabled = false;
btn.textContent = '重试';
}}
}})
.catch(e => {{
document.getElementById('result').innerHTML = '<span style="color:#f5222d">网络错误</span>';
btn.disabled = false;
btn.textContent = '重试';
}});
}}
</script>
</body>
</html>"#, order_no, display_name, jwt, order_no)
}
/// 验证 RSA2 签名
fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result<bool, String> {
use rsa::pkcs8::DecodePublicKey;
use rsa::RsaPublicKey;
use sha2::Digest;
let public_key =
RsaPublicKey::from_public_key_pem(public_key_pem)
.map_err(|e| format!("支付宝公钥解析失败: {}", e))?;
let sig_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, sign)
.map_err(|e| format!("签名 Base64 解码失败: {}", e))?;
let hashed = Sha256::digest(content.as_bytes());
public_key
.verify(rsa::Pkcs1v15Sign::new::<Sha256>(), &hashed, &sig_bytes)
.map_err(|e| format!("签名验证失败: {}", e))?;
Ok(true)
}
// ===== Helper: 生成支付宝支付表单 HTML =====
fn build_alipay_form_html(
config: &AlipayConfig,
out_trade_no: &str,
total_amount: &str,
subject: &str,
notify_url: &str,
return_url: &str,
) -> Result<String, String> {
let biz_content = serde_json::json!({
"out_trade_no": out_trade_no,
"total_amount": total_amount,
"subject": subject,
"product_code": "FAST_INSTANT_TRADE_PAY",
});
let biz_content_str =
serde_json::to_string(&biz_content).map_err(|e| format!("biz_content 序列化失败: {}", e))?;
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let mut params: BTreeMap<&str, &str> = BTreeMap::new();
params.insert("app_id", &config.app_id);
params.insert("method", "alipay.trade.page.pay");
params.insert("format", "JSON");
params.insert("charset", "utf-8");
params.insert("sign_type", "RSA2");
params.insert("timestamp", &timestamp);
params.insert("version", "1.0");
params.insert("biz_content", &biz_content_str);
params.insert("notify_url", notify_url);
params.insert("return_url", return_url);
// 构造待签名串(注意:签名源使用原始值,不做 URL 编码)
let sign_source: String = params
.iter()
.map(|(k, v)| format!("{}={}", k, *v))
.collect::<Vec<_>>()
.join("&");
let sign = rsa2_sign(&sign_source, &config.private_key)?;
// 生成自动提交的表单 HTMLHTML 转义防止特殊字符破坏属性)
fn h(s: &str) -> String {
s.replace('&', "&amp;")
.replace('"', "&quot;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
let mut form_fields = String::new();
for (k, v) in &params {
form_fields.push_str(&format!(
r#"<input type="hidden" name="{}" value="{}" />"#,
h(k), h(v)
));
}
form_fields.push_str(&format!(
r#"<input type="hidden" name="sign" value="{}" />"#,
h(&sign)
));
let html = format!(
r##"<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>正在跳转支付宝...</title></head>
<body>
<form id="alipay" action="{}" method="get">
{}
</form>
<script>document.getElementById('alipay').submit();</script>
</body>
</html>"##,
config.gateway, form_fields
);
Ok(html)
}
// ===== 提取 JWT token =====
/// 维护模式守卫PAYMENT_MAINTENANCE_MODE=true 时所有支付接口返回 503
fn check_payment_maintenance() -> Result<(), AppError> {
if std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string()) {
return Err(AppError::ServiceUnavailable(
"支付系统维护中,请稍后再试".to_string()
));
}
Ok(())
}
fn extract_token(req: &HttpRequest) -> Option<String> {
req.headers()
.get("Authorization")?
.to_str()
.ok()?
.strip_prefix("Bearer ")
.map(|s| s.to_string())
}
fn get_jwt_secret() -> String {
std::env::var("JWT_SECRET").expect("JWT_SECRET must be set")
}
// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) =====
#[get("/payment")]
pub async fn payment_index() -> Result<HttpResponse, AppError> {
check_payment_maintenance()?;
let html = r##"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>开通会员 - 大气稳定度判定</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif; background: #f0f2f5; min-height: 100vh; }
.header { text-align: center; padding: 60px 0 40px; }
.header h1 { font-size: 28px; color: #333; margin-bottom: 8px; }
.header p { font-size: 14px; color: #999; }
/* ===== 登录区域 ===== */
.login-section { max-width: 400px; margin: 0 auto; padding: 0 24px; }
.login-card { background: #fff; border-radius: 20px; padding: 48px 32px; text-align: center; box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
.login-icon { width: 80px; height: 80px; margin: 0 auto 24px; background: #07c160; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.login-icon svg { width: 48px; height: 48px; }
.login-title { font-size: 20px; font-weight: 600; color: #333; margin-bottom: 8px; }
.login-desc { font-size: 14px; color: #999; margin-bottom: 32px; line-height: 1.6; }
/* 登录码展示 */
.code-display { background: #f7f8fa; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
.code-label { font-size: 13px; color: #999; margin-bottom: 12px; }
.code-value { font-size: 32px; font-weight: bold; color: #07c160; letter-spacing: 4px; font-family: 'SF Mono', monospace; }
.code-hint { font-size: 12px; color: #bbb; margin-top: 8px; }
/* 扫码状态 */
.scan-status { padding: 16px; border-radius: 12px; margin-bottom: 24px; font-size: 14px; }
.scan-status.waiting { background: #fff7e6; color: #ad6800; }
.scan-status.confirmed { background: #f6ffed; color: #52c41a; }
.btn-login { display: block; width: 100%; background: #07c160; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; margin-bottom: 16px; }
.btn-login:disabled { background: #d9d9d9; cursor: not-allowed; }
.btn-refresh { background: #fff; color: #666; border: 1px solid #d9d9d9; }
.login-note { font-size: 12px; color: #bbb; margin-top: 12px; }
/* ===== 会员区域 ===== */
.paid-section { display: none; }
.paid-banner { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; padding: 48px 24px; text-align: center; }
.paid-banner h2 { font-size: 24px; margin-bottom: 8px; }
.paid-banner p { font-size: 14px; opacity: 0.9; }
.paid-info { background: #fff; margin: -20px 16px 16px; border-radius: 16px; padding: 24px; box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.paid-info-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f0f0f0; font-size: 15px; }
.paid-info-row:last-child { border-bottom: none; }
.paid-info-label { color: #999; }
.paid-info-value { color: #333; font-weight: 500; }
.paid-badge { display: inline-block; background: #07c160; color: #fff; font-size: 12px; padding: 2px 8px; border-radius: 4px; }
/* ===== 套餐区域 ===== */
.packages { display: none; max-width: 480px; margin: 0 auto; padding: 0 16px 80px; }
.packages-title { text-align: center; padding: 40px 0 24px; font-size: 20px; color: #333; }
.pkg-card { background: #fff; border-radius: 16px; padding: 24px; margin-bottom: 16px; cursor: pointer; transition: all 0.2s; border: 2px solid transparent; position: relative; }
.pkg-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
.pkg-card.selected { border-color: #07c160; background: #f0f7ff; }
.pkg-tag { position: absolute; top: -1px; right: 16px; background: #07c160; color: #fff; font-size: 12px; padding: 4px 10px; border-radius: 0 0 8px 8px; }
.pkg-name { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; }
.pkg-price { font-size: 32px; font-weight: 700; color: #07c160; margin-bottom: 4px; }
.pkg-price .unit { font-size: 14px; font-weight: 400; }
.pkg-desc { font-size: 13px; color: #999; }
.pkg-features { margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
.pkg-feature { font-size: 13px; color: #666; margin-bottom: 6px; }
.btn-pay { display: block; width: 100%; max-width: 480px; margin: 0 auto 16px; background: #07c160; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
.btn-pay:hover { background: #06ad56; }
.btn-pay:disabled { background: #d9d9d9; cursor: not-allowed; }
.notice { text-align: center; font-size: 12px; color: #bbb; }
.btn-logout { display: block; width: 100%; max-width: 480px; margin: 0 auto; background: #fff; color: #999; border: 1px solid #d9d9d9; border-radius: 12px; padding: 12px; font-size: 14px; cursor: pointer; }
.btn-logout:hover { color: #666; border-color: #999; }
/* 错误提示 */
.error-msg { background: #fff2f0; color: #cf1322; border: 1px solid #ffccc7; border-radius: 8px; padding: 12px; margin-bottom: 16px; font-size: 14px; display: none; }
</style>
</head>
<body>
<!-- 登录区(等待授权) -->
<div class="login-section" id="loginSection">
<div class="login-card">
<div class="login-icon">
<svg viewBox="0 0 24 24" fill="#fff">
<path d="M8.68 10.74a.5.5 0 01-.01.85l-3.6 2.88a.5.5 0 01-.74-.38V7.13a.5.5 0 01.74-.38l3.6 2.88a.5.5 0 01.01.85l-1.6 1.28 1.6 1.28z"/>
<path d="M12.02 5.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zm0 10.5a4 4 0 110-8 4 4 0 010 8z"/>
<path d="M15.32 8.68a.5.5 0 01.01.85l-1.6 1.28 1.6 1.28a.5.5 0 01-.74.38l-3.6-2.88a.5.5 0 01.01-.85l3.6-2.88a.5.5 0 01.72.38v3.22z"/>
</svg>
</div>
<div class="login-title">等待授权</div>
<div class="login-desc" id="loginDesc">请在微信小程序中<br>点击「去授权」完成登录</div>
<div class="error-msg" id="errorMsg"></div>
<div class="code-display" id="codeDisplay" style="display:none">
<div class="code-label">登录码</div>
<div class="code-value" id="codeValue">--</div>
</div>
<div class="scan-status waiting" id="scanStatus" style="display:none">
等待小程序授权确认...
</div>
</div>
</div>
<!-- 会员信息区(已登录) -->
<div class="paid-section" id="paidSection">
<div class="paid-banner">
<h2 id="paidTitle">会员专享福利</h2>
<p id="paidSubtitle">解锁无限检测额度</p>
</div>
<div class="paid-info">
<div class="paid-info-row">
<span class="paid-info-label">会员状态</span>
<span class="paid-info-value"><span class="paid-badge" id="paidBadge">付费会员</span></span>
</div>
<div class="paid-info-row" id="expiresRow">
<span class="paid-info-label">到期时间</span>
<span class="paid-info-value" id="paidExpires">--</span>
</div>
<div class="paid-info-row">
<span class="paid-info-label">检测额度</span>
<span class="paid-info-value">无限次</span>
</div>
</div>
</div>
<!-- 套餐区(登录后显示) -->
<div class="packages" id="packages">
<div class="packages-title">选择会员套餐</div>
<div class="pkg-card" data-package="monthly" onclick="selectPackage('monthly')">
<div class="pkg-name">包月会员</div>
<div class="pkg-price">¥9.9<span class="unit">/月</span></div>
<div class="pkg-desc">适合短期使用需求</div>
<div class="pkg-features">
<div class="pkg-feature">每月 500 次检测额度</div>
<div class="pkg-feature">查看完整历史记录</div>
</div>
</div>
<div class="pkg-card" data-package="yearly" onclick="selectPackage('yearly')">
<div class="pkg-tag">推荐</div>
<div class="pkg-name">包年会员</div>
<div class="pkg-price">¥59<span class="unit">/年</span></div>
<div class="pkg-desc">相当于每月 ¥4.9,性价比最高</div>
<div class="pkg-features">
<div class="pkg-feature">每年 5000 次检测额度</div>
<div class="pkg-feature">查看完整历史记录</div>
</div>
</div>
<button class="btn-pay" id="payBtn" onclick="goPay()" disabled>请先选择套餐</button>
<button class="btn-logout" onclick="logout()">退出登录</button>
<div class="notice" style="margin-top:16px">支付成功后额度将自动到账</div>
</div>
<script>
const API_BASE = ''; // 同源
let currentShortCode = '';
let pollTimer = null;
let pollRetryCount = 0;
const POLL_MAX_RETRIES = 150; // 2s * 150 = 5min 超时
let jwt = '';
let isPaidActive = false;
let paidExpiresAt = null;
// ---- 页面初始化:检查 URL 中的 code 或 jwt 参数 ----
(function initFromUrl() {
const params = new URLSearchParams(window.location.search);
const urlJwt = params.get('jwt');
if (urlJwt) {
jwt = urlJwt;
checkPaidStatus(urlJwt);
return;
}
const urlCode = params.get('code');
if (urlCode) {
currentShortCode = urlCode;
document.getElementById('codeValue').textContent = currentShortCode;
document.getElementById('codeDisplay').style.display = 'block';
document.getElementById('scanStatus').style.display = 'block';
document.getElementById('loginDesc').style.display = 'none';
// 启动轮询
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollLoginStatus, 2000);
}
})();
async function checkPaidStatus(token) {
try {
const resp = await fetch(API_BASE + '/api/user/profile', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const data = await resp.json();
if (data.success) {
isPaidActive = data.data.is_paid_active || false;
paidExpiresAt = data.data.paid_expires_at || null;
}
}
} catch(e) {}
showLoggedIn();
}
async function pollLoginStatus() {
if (!currentShortCode) return;
pollRetryCount++;
if (pollRetryCount > POLL_MAX_RETRIES) {
clearInterval(pollTimer);
document.getElementById('scanStatus').textContent = '登录码已过期,请重新获取';
document.getElementById('scanStatus').className = 'scan-status waiting';
return;
}
try {
const resp = await fetch(API_BASE + '/payment/login-status?code=' + encodeURIComponent(currentShortCode));
const data = await resp.json();
if (data.success && data.token) {
jwt = data.token;
isPaidActive = data.is_paid_active || false;
paidExpiresAt = data.paid_expires_at;
clearInterval(pollTimer);
showLoggedIn();
}
} catch(e) {
// 继续轮询
}
}
function showLoggedIn() {
document.getElementById('loginSection').style.display = 'none';
if (isPaidActive) {
// 已付费用户,显示会员信息
document.getElementById('paidSection').style.display = 'block';
document.getElementById('paidBadge').textContent = '付费会员';
if (paidExpiresAt) {
document.getElementById('paidExpires').textContent = new Date(paidExpiresAt).toLocaleString('zh-CN');
document.getElementById('expiresRow').style.display = 'flex';
} else {
document.getElementById('paidExpires').textContent = '永久有效';
document.getElementById('paidBadge').textContent = '永久会员';
}
document.getElementById('packages').style.display = 'none';
} else {
// 未付费用户,显示套餐
document.getElementById('paidSection').style.display = 'none';
document.getElementById('packages').style.display = 'block';
}
}
function logout() {
jwt = '';
currentShortCode = '';
isPaidActive = false;
paidExpiresAt = null;
document.getElementById('loginSection').style.display = 'block';
document.getElementById('paidSection').style.display = 'none';
document.getElementById('packages').style.display = 'none';
document.getElementById('codeDisplay').style.display = 'none';
document.getElementById('scanStatus').style.display = 'none';
document.getElementById('loginDesc').style.display = 'block';
}
function showError(msg) {
const el = document.getElementById('errorMsg');
el.textContent = msg;
el.style.display = 'block';
}
function hideError() {
document.getElementById('errorMsg').style.display = 'none';
}
// ---- 套餐选择 ----
let selected = null;
function selectPackage(pkg) {
selected = pkg;
document.querySelectorAll('.pkg-card').forEach(c => c.classList.remove('selected'));
document.querySelector('[data-package="' + pkg + '"]').classList.add('selected');
var btn = document.getElementById('payBtn');
var labels = { monthly: '立即开通 - ¥9.9/月', yearly: '立即开通 - ¥59/年' };
btn.textContent = labels[pkg];
btn.disabled = false;
btn.className = 'btn-pay';
}
function goPay() {
if (!selected || !jwt) {
showError('请先登录');
return;
}
var btn = document.getElementById('payBtn');
btn.disabled = true;
btn.textContent = '正在跳转...';
setTimeout(function() {
window.location.href = '/payment/page?package=' + selected + '&jwt=' + encodeURIComponent(jwt);
}, 300);
}
</script>
</body>
</html>"##;
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html))
}
// ===== Handler: GET /payment/page — 创建订单并跳转支付宝 =====
#[derive(Debug, Deserialize)]
pub struct PaymentPageQuery {
#[serde(rename = "package")]
pub package_: String,
/// JWT: 从 URL 参数传入(外部浏览器无 Cookie 时使用)
#[serde(default)]
pub jwt: Option<String>,
}
#[get("/payment/page")]
pub async fn payment_page(
req: HttpRequest,
pool: web::Data<PgPool>,
query: web::Query<PaymentPageQuery>,
) -> Result<HttpResponse, AppError> {
check_payment_maintenance()?;
let token = extract_token(&req)
.or_else(|| query.jwt.clone())
.ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
let claims = crate::auth::verify_token(&token, &get_jwt_secret())
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
let pkg = get_package_info(&query.package_)
.ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?;
let order_no = Uuid::new_v4().to_string();
let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d));
db::create_payment_order(
pool.get_ref(),
claims.user_id,
&order_no,
&query.package_,
pkg.amount,
expires_at,
)
.await?;
let base_url = std::env::var("APP_BASE_URL")
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
let notify_url = format!("{}/payment/notify", base_url);
let return_url = format!("{}/payment/success?order_no={}", base_url, order_no);
let Some(config) = AlipayConfig::from_env() else {
let jwt_for_mock = token.clone();
let mock_html = build_mock_pay_html(&order_no, pkg.display_name, &jwt_for_mock);
return Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(mock_html));
};
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
match build_alipay_form_html(
&config,
&order_no,
&total_amount_str,
pkg.display_name,
&notify_url,
&return_url,
) {
Ok(form_html) => Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(form_html)),
Err(e) => {
tracing::error!("支付宝下单失败: {}", e);
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(format_error_html(&e, &order_no)))
}
}
}
// ===== Handler: GET /payment/pay — 直接支付接口 =====
#[derive(Debug, Deserialize)]
pub struct AlipayPayQuery {
pub order_no: String,
pub package_type: String,
}
#[get("/payment/pay")]
pub async fn alipay_pay_page(
req: HttpRequest,
query: web::Query<AlipayPayQuery>,
) -> Result<HttpResponse, AppError> {
check_payment_maintenance()?;
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
crate::auth::verify_token(&token, &get_jwt_secret())
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
let pkg = get_package_info(&query.package_type)
.ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?;
let base_url = std::env::var("APP_BASE_URL")
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
let notify_url = format!("{}/payment/notify", base_url);
let return_url = format!("{}/payment/success?order_no={}", base_url, query.order_no);
let Some(config) = AlipayConfig::from_env() else {
let jwt_for_mock = token.clone();
let mock_html = build_mock_pay_html(&query.order_no, pkg.display_name, &jwt_for_mock);
return Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(mock_html));
};
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
match build_alipay_form_html(
&config,
&query.order_no,
&total_amount_str,
pkg.display_name,
&notify_url,
&return_url,
) {
Ok(form_html) => Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(form_html)),
Err(e) => {
tracing::error!("支付宝下单失败: {}", e);
Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(format_error_html(&e, &query.order_no)))
}
}
}
// ===== Handler: POST /payment/notify — 支付宝异步回调 =====
#[post("/payment/notify")]
pub async fn alipay_notify(
pool: web::Data<PgPool>,
body: web::Form<BTreeMap<String, String>>,
) -> HttpResponse {
let body = body.into_inner();
let out_trade_no = body.get("out_trade_no").cloned().unwrap_or_default();
let trade_status = body.get("trade_status").cloned().unwrap_or_default();
tracing::info!(
"收到支付宝回调: out_trade_no={}, trade_status={}",
out_trade_no,
trade_status
);
// 1. 检查交易状态
if trade_status != "TRADE_SUCCESS" && trade_status != "TRADE_FINISHED" {
return HttpResponse::Ok().body("success");
}
// 2. 验证 RSA2 签名(防止伪造回调)
let Some(config) = AlipayConfig::from_env() else {
tracing::warn!("支付宝配置不存在,无法验证签名");
return HttpResponse::Ok().body("fail");
};
let sign = body.get("sign").cloned().unwrap_or_default();
// BTreeMap 已按 key 排序,直接拼接除 sign 和 sign_type 外的所有参数
let sign_source: String = body
.iter()
.filter(|(k, _)| *k != "sign" && *k != "sign_type")
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) {
tracing::warn!("支付宝签名验证失败: {}", e);
return HttpResponse::Ok().body("fail");
}
// 3. 确认订单(通过 order_no不校验 user_id
match db::confirm_payment_order_by_orderno(pool.get_ref(), &out_trade_no).await {
Ok(_) => {
tracing::info!("订单 {} 支付确认成功", out_trade_no);
HttpResponse::Ok().body("success")
}
Err(e) => {
tracing::error!("订单 {} 确认失败: {}", out_trade_no, e);
HttpResponse::Ok().body("fail")
}
}
}
// ===== Handler: GET /payment/success — 支付成功页面 =====
#[derive(Debug, Deserialize)]
pub struct AlipaySuccessQuery {
pub order_no: Option<String>,
}
fn build_success_html(order_no: &str) -> String {
let green = "#52c41a";
let white = "white";
let html = format!(
r##"<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>支付成功</title>
<style>
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f0f2f5; margin: 0; padding: 40px; text-align: center; }}
.card {{ background: #fff; border-radius: 16px; padding: 48px 32px; max-width: 400px; margin: 0 auto; box-shadow: 0 2px 12px rgba(0,0,0,0.1); }}
.icon {{ width: 64px; height: 64px; margin-bottom: 16px; }}
h2 {{ color: {0}; font-size: 22px; margin-bottom: 8px; }}
p {{ color: #666; font-size: 14px; margin-bottom: 24px; }}
.tip {{ background: #f0f7ff; border-radius: 8px; padding: 16px; font-size: 13px; color: #1677ff; margin-top: 16px; }}
.order-no {{ font-size: 12px; color: #bbb; margin-top: 12px; }}
</style>
</head>
<body>
<div class="card">
<svg class="icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="32" r="32" fill="{0}"/>
<path d="M20 32l8 8 16-16" stroke="{1}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<h2>支付成功!</h2>
<p>恭喜您已成为会员,额度已自动到账</p>
<div class="tip">请返回微信小程序查看您的会员状态</div>
<div class="order-no">订单号: {2}</div>
</div>
</body>
</html>"##,
green, white, order_no
);
html
}
fn format_error_html(msg: &str, order_no: &str) -> String {
format!(
r##"<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>支付失败</title></head>
<body style="font-family:-apple-system;padding:40px;text-align:center">
<h2 style="color:#ff4d4f">支付页面生成失败</h2>
<p style="color:#666">{}</p>
<p style="color:#999;font-size:13px">订单号: {}</p>
<p><a href="/payment" style="color:#1677ff">返回重试</a></p>
</body>
</html>"##,
msg, order_no
)
}
#[get("/payment/success")]
pub async fn payment_success(
pool: web::Data<PgPool>,
query: web::Query<AlipaySuccessQuery>,
) -> HttpResponse {
let order_no = query.order_no.as_deref().unwrap_or("");
// 同步确认订单(幂等:已确认的订单会跳过)
if !order_no.is_empty()
&& let Err(e) = db::confirm_payment_order_by_orderno(pool.get_ref(), order_no).await {
tracing::warn!("支付成功页同步确认失败(可能是异步回调已处理): {}", e);
}
let html = build_success_html(order_no);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html)
}
// ===== Handler: POST /payment/refund-notify — 支付宝退款异步回调 =====
#[post("/payment/refund-notify")]
pub async fn alipay_refund_notify(
pool: web::Data<PgPool>,
body: web::Form<BTreeMap<String, String>>,
) -> HttpResponse {
let body = body.into_inner();
let out_trade_no = body.get("out_trade_no").cloned().unwrap_or_default();
let refund_status = body.get("refund_status").cloned().unwrap_or_default();
tracing::info!(
"收到支付宝退款回调: out_trade_no={}, refund_status={}",
out_trade_no,
refund_status
);
// 只处理成功的退款
if refund_status != "REFUND_SUCCESS" {
return HttpResponse::Ok().body("success");
}
// 验证 RSA2 签名
let Some(config) = AlipayConfig::from_env() else {
tracing::warn!("支付宝配置不存在,无法验证退款签名");
return HttpResponse::Ok().body("fail");
};
let sign = body.get("sign").cloned().unwrap_or_default();
let sign_source: String = body
.iter()
.filter(|(k, _)| *k != "sign" && *k != "sign_type")
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) {
tracing::warn!("支付宝退款签名验证失败: {}", e);
return HttpResponse::Ok().body("fail");
}
// 处理退款:标记订单 + 撤销会员
match db::refund_payment_order(pool.get_ref(), &out_trade_no).await {
Ok(_) => {
tracing::info!("订单 {} 退款处理成功", out_trade_no);
HttpResponse::Ok().body("success")
}
Err(e) => {
tracing::error!("订单 {} 退款处理失败: {}", out_trade_no, e);
HttpResponse::Ok().body("fail")
}
}
}
// ===== 旧的 API Handler保持兼容=====
/// POST /api/payment/create-order
#[post("/api/payment/create-order")]
pub async fn create_order(
pool: web::Data<PgPool>,
claims: web::ReqData<Claims>,
body: web::Json<CreateOrderRequest>,
) -> Result<HttpResponse, AppError> {
check_payment_maintenance()?;
let user_id = claims.user_id;
let pkg = match get_package_info(&body.package_type) {
Some(p) => p,
None => {
return Err(AppError::BadRequest("无效的套餐类型".to_string()));
}
};
let order_no = Uuid::new_v4().to_string();
let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d));
db::create_payment_order(
pool.get_ref(),
user_id,
&order_no,
&body.package_type,
pkg.amount,
expires_at,
)
.await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": {
"order_id": order_no,
"package_type": body.package_type,
"amount": pkg.amount,
"display_amount": pkg.display_amount,
"display_name": pkg.display_name,
"expires_at": expires_at,
}
})))
}
/// POST /api/payment/mock-confirm
#[post("/api/payment/mock-confirm")]
pub async fn mock_confirm(
pool: web::Data<PgPool>,
claims: web::ReqData<Claims>,
body: web::Json<MockConfirmRequest>,
) -> Result<HttpResponse, AppError> {
check_payment_maintenance()?;
let user_id = claims.user_id;
let expires_at =
db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": {
"is_paid_active": true,
"paid_expires_at": expires_at,
}
})))
}
/// POST /api/payment/sync-order — 根据订单号强制同步会员状态
///
/// 用户支付完成后,若前端会员状态未刷新,可调用此接口同步确认订单并获取最新状态。
#[post("/api/payment/sync-order")]
pub async fn sync_order(
pool: web::Data<PgPool>,
claims: web::ReqData<Claims>,
body: web::Json<MockConfirmRequest>,
) -> Result<HttpResponse, AppError> {
check_payment_maintenance()?;
let user_id = claims.user_id;
// 尝试确认订单(幂等),失败时记录日志
if let Err(e) = db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await {
tracing::warn!("确认订单失败(可能已被异步回调处理): {}", e);
}
// 查询最新状态
let user = sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
r#"SELECT is_paid, paid_expires_at FROM users WHERE id = $1"#,
)
.bind(user_id)
.fetch_optional(pool.get_ref())
.await
.map_err(|e| AppError::Database(format!("查询用户失败: {}", e)))?
.ok_or_else(|| AppError::NotFound("用户不存在".to_string()))?;
let (is_paid, paid_expires_at) = user;
let is_paid_active = is_paid && paid_expires_at.map_or(true, |expires| expires > Utc::now());
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": {
"is_paid": is_paid,
"is_paid_active": is_paid_active,
"paid_expires_at": paid_expires_at,
}
})))
}
/// POST /api/payment/cancel-order — 用户主动取消待支付订单
#[derive(Debug, Deserialize)]
pub struct CancelOrderRequest {
pub order_id: String,
}
#[post("/api/payment/cancel-order")]
pub async fn cancel_order(
pool: web::Data<PgPool>,
claims: web::ReqData<Claims>,
body: web::Json<CancelOrderRequest>,
) -> Result<HttpResponse, AppError> {
check_payment_maintenance()?;
db::cancel_payment_order(pool.get_ref(), &body.order_id, claims.user_id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"message": "订单已取消"
})))
}
/// GET /api/payment/orders — 获取当前用户的订单记录
#[get("/api/payment/orders")]
pub async fn get_user_orders(
pool: web::Data<PgPool>,
claims: web::ReqData<Claims>,
) -> Result<HttpResponse, AppError> {
let orders = db::get_user_orders(pool.get_ref(), claims.user_id).await?;
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": orders.iter().map(|o| serde_json::json!({
"order_no": o.order_no,
"package_type": o.package_type,
"amount": o.amount,
"status": o.status,
"paid_at": o.paid_at,
"expires_at": o.expires_at,
"created_at": o.created_at,
})).collect::<Vec<_>>()
})))
}
/// GET /api/user/quota
#[get("/api/user/quota")]
pub async fn get_user_quota(
pool: web::Data<PgPool>,
claims: web::ReqData<Claims>,
) -> Result<HttpResponse, AppError> {
let user_id = claims.user_id;
let is_maintenance = std::env::var("PAYMENT_MAINTENANCE_MODE").ok() == Some("true".to_string());
let (used, is_paid_active, paid_expires_at, was_paid) =
db::get_user_quota(pool.get_ref(), user_id).await?;
// 维护模式使用更高的临时限额,但非会员仍然有限制防止滥用
let (limit, unlimited) = if is_maintenance && !is_paid_active {
let ml: i64 = std::env::var("MAINTENANCE_MODE_DATA_LIMIT")
.ok().and_then(|v| v.parse().ok()).unwrap_or(500);
(ml, false)
} else if is_paid_active {
(0, true)
} else {
let fl: i64 = std::env::var("FREE_USER_DATA_LIMIT")
.ok().and_then(|v| v.parse().ok()).unwrap_or(20);
(fl, false)
};
let active = if is_maintenance { true } else { is_paid_active };
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": {
"used": used,
"limit": limit,
"unlimited": unlimited,
"is_paid_active": active,
"paid_expires_at": paid_expires_at,
"was_paid": was_paid,
"maintenance_mode": is_maintenance,
}
})))
}
/// GET /payment/login-status?code=ASD-XXXXX
/// 网页端轮询:查询登录码是否已被小程序确认
#[derive(Debug, Deserialize)]
pub struct LoginStatusQuery {
pub code: String,
}
#[derive(Debug, serde::Serialize)]
pub struct LoginStatusResponse {
pub success: bool,
pub confirmed: bool,
pub token: Option<String>,
pub is_paid_active: bool,
pub paid_expires_at: Option<String>,
}
// ===== Handler: GET /payment/generate-code — 网页端生成登录码 =====
#[derive(Debug, serde::Serialize)]
pub struct GenerateCodeResponse {
pub code: String,
pub expires_in: i64, // 秒
}
/// 生成随机登录码(网页端专用,无需认证)
#[get("/payment/generate-code")]
pub async fn generate_code(
pool: web::Data<PgPool>,
) -> Result<HttpResponse, AppError> {
use rand::Rng;
let mut rng = rand::thread_rng();
let suffix: String = (0..6)
.map(|_| {
let idx = rng.gen_range(0..36);
if idx < 10 { (b'0' + idx) as char } else { (b'A' + idx - 10) as char }
})
.collect();
let code = format!("ASD-{}", suffix);
let expires_at = Utc::now() + chrono::Duration::minutes(10);
sqlx::query("INSERT INTO web_login_codes (code, expires_at) VALUES ($1, $2)")
.bind(&code)
.bind(expires_at)
.execute(pool.get_ref())
.await
.map_err(|e| AppError::Internal(format!("建码失败: {}", e)))?;
Ok(HttpResponse::Ok().json(GenerateCodeResponse {
code,
expires_in: 600,
}))
}
#[get("/payment/login-status")]
pub async fn payment_login_status(
pool: web::Data<PgPool>,
query: web::Query<LoginStatusQuery>,
_app_state: web::Data<crate::models::AppState>,
) -> Result<HttpResponse, AppError> {
let code = query.code.trim();
// 查询登录码记录(包含 token 字段用于判断是否已确认)
let record: Option<(String, chrono::DateTime<chrono::Utc>, Option<String>, Option<i32>)> =
sqlx::query_as(
"SELECT code, expires_at, token, user_id FROM web_login_codes WHERE code = $1",
)
.bind(code)
.fetch_optional(pool.get_ref())
.await
.map_err(|e| AppError::Internal(format!("数据库查询失败: {}", e)))?;
let (db_code, expires_at, token, user_id) = match record {
Some(r) => r,
None => {
return Ok(HttpResponse::Ok().json(LoginStatusResponse {
success: false,
confirmed: false,
token: None,
is_paid_active: false,
paid_expires_at: None,
}));
}
};
// 检查是否过期
if Utc::now() > expires_at {
if let Err(e) = sqlx::query("DELETE FROM web_login_codes WHERE code = $1")
.bind(&db_code)
.execute(pool.get_ref())
.await
{
tracing::warn!("清理过期登录码失败: {}", e);
}
return Ok(HttpResponse::Ok().json(LoginStatusResponse {
success: false,
confirmed: false,
token: None,
is_paid_active: false,
paid_expires_at: None,
}));
}
// 登录码存在但还没被小程序确认token 为空 = 刚生成,还没点确认)
// 使用 token 字段判断是否已确认(而非 user_id因为 web_generate_login_code 会设置 user_id
if token.is_none() {
return Ok(HttpResponse::Ok().json(LoginStatusResponse {
success: true,
confirmed: false,
token: None,
is_paid_active: false,
paid_expires_at: None,
}));
}
// 已确认 → 使用已生成的 token
let token = token.unwrap();
let user_id = user_id.unwrap();
let (is_paid_active, paid_expires_at): (bool, Option<String>) =
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
"SELECT is_paid, paid_expires_at FROM users WHERE id = $1"
)
.bind(user_id)
.fetch_optional(pool.get_ref())
.await
{
Ok(Some((is_paid, expires))) => {
let active = is_paid && expires.map_or(true, |e| e > Utc::now());
(active, expires.map(|e| e.to_rfc3339()))
}
_ => (false, None),
};
// 清理已使用的登录码
let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1")
.bind(&db_code)
.execute(pool.get_ref())
.await;
info!("[LOGIN STATUS] user_id={} confirmed=true", user_id);
Ok(HttpResponse::Ok().json(LoginStatusResponse {
success: true,
confirmed: true,
token: Some(token),
is_paid_active,
paid_expires_at,
}))
}