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

1027 lines
36 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::pkcs8::DecodePrivateKey;
use rsa::signature::Signer;
use rsa::{Pkcs1v15Sign, 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 =
RsaPrivateKey::from_pkcs8_pem(private_key_pem).map_err(|e| format!("私钥解析失败: {}", e))?;
let signature = private_key.sign(Pkcs1v15Sign::new::<Sha256>(), content.as_bytes())
.map_err(|e| format!("签名失败: {}", e))?;
Ok(base64::Engine::encode(
&base64::engine::general_purpose::STANDARD,
&signature,
))
}
/// 验证 RSA2 签名
fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result<bool, String> {
use rsa::pkcs8::DecodePublicKey;
let public_key =
rsa::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))?;
Ok(public_key
.verify(Pkcs1v15Sign::new::<Sha256>(), content.as_bytes(), &sig_bytes)
.is_ok())
}
// ===== Helper: 调用 alipay.trade.page.pay =====
async fn call_alipay_trade_page_pay(
config: &AlipayConfig,
out_trade_no: &str,
total_amount: &str,
subject: &str,
notify_url: &str,
return_url: &str,
) -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| format!("HTTP 客户端创建失败: {}", e))?;
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);
// 构造待签名串
let sign_source: String = params
.iter()
.map(|(k, v)| format!("{}={}", k, urlencoding(v)))
.collect::<Vec<_>>()
.join("&");
let sign = rsa2_sign(&sign_source, &config.private_key)?;
// 构建 POST body
let query: String = params
.iter()
.map(|(k, v)| format!("{}={}", urlencoding(k), urlencoding(v)))
.chain(std::iter::once(format!("sign={}", urlencoding(&sign))))
.collect::<Vec<_>>()
.join("&");
let resp = client
.post(&config.gateway)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(query)
.send()
.await
.map_err(|e| format!("请求支付宝失败: {}", e))?;
let body = resp.text().await.map_err(|e| format!("读取响应失败: {}", e))?;
// 支付宝返回格式: alipay_trade_page_pay_response={...}&sign=xxx
let parts: Vec<&str> = body.splitn(2, "&sign=").collect();
if parts.len() != 2 {
// 如果直接返回 HTML 表单(沙箱环境可能直接返回表单),直接返回
if body.contains("<form") {
return Ok(body);
}
return Err(format!(
"支付宝响应格式异常: {}",
&body[..body.len().min(300)]
));
}
let json_str = parts[0]
.strip_prefix("alipay_trade_page_pay_response=")
.unwrap_or(parts[0]);
let sign_from_alipay = parts[1];
if !rsa2_verify(json_str, sign_from_alipay, &config.alipay_public_key)? {
return Err("支付宝响应验签失败".to_string());
}
let json: serde_json::Value =
serde_json::from_str(json_str).map_err(|e| format!("JSON 解析失败: {}", e))?;
if json.get("code").and_then(|v| v.as_str()) != Some("10000") {
let sub_msg = json
.get("sub_msg")
.and_then(|v| v.as_str())
.unwrap_or("");
return Err(format!(
"支付宝接口错误: {} - {}",
json.get("msg")
.and_then(|v| v.as_str())
.unwrap_or("未知"),
sub_msg
));
}
let form_html = json
.get("form_html")
.and_then(|v| v.as_str())
.ok_or_else(|| "响应中缺少 form_html".to_string())?;
Ok(form_html.to_string())
}
// ===== 提取 JWT token =====
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").unwrap_or_else(|_| "default_secret".to_string())
}
// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) =====
#[get("/payment")]
pub async fn payment_index() -> Result<HttpResponse, AppError> {
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-tag.orange { background: #ff6b00; }
.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; }
.btn-pay.orange { background: #ff6b00; }
.btn-pay.orange:hover { background: #ff8c33; }
.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-tag">推荐</div>
<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-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>
<div class="pkg-card" data-package="permanent" onclick="selectPackage('permanent')">
<div class="pkg-tag orange">超值</div>
<div class="pkg-name">永久会员</div>
<div class="pkg-price" style="color:#ff6b00;">¥199<span class="unit">/终身</span></div>
<div class="pkg-desc">一次购买,终身享用</div>
<div class="pkg-features">
<div class="pkg-feature">无限次检测额度</div>
<div class="pkg-feature">查看完整历史记录</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 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;
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/年', permanent: '立即开通 - ¥199/终身' };
btn.textContent = labels[pkg];
btn.disabled = false;
btn.className = 'btn-pay' + (pkg === 'permanent' ? ' orange' : '');
}
function goPay() {
if (!selected || !jwt) {
showError('请先登录');
return;
}
window.location.href = '/payment/page?package=' + selected + '&jwt=' + encodeURIComponent(jwt);
}
</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> {
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 {
return Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(format_error_html("支付配置不完整,请联系管理员", &order_no)));
};
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
match call_alipay_trade_page_pay(
&config,
&order_no,
&total_amount_str,
pkg.display_name,
&notify_url,
&return_url,
)
.await
{
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> {
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 {
return Ok(HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(format_error_html("支付配置不完整,请联系管理员", &query.order_no)));
};
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
match call_alipay_trade_page_pay(
&config,
&query.order_no,
&total_amount_str,
pkg.display_name,
&notify_url,
&return_url,
)
.await
{
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 — 支付宝异步回调 =====
#[derive(Debug, Deserialize)]
pub struct AlipayNotify {
pub out_trade_no: String,
pub trade_no: String,
pub trade_status: String,
pub total_amount: Option<String>,
pub app_id: Option<String>,
pub sign: Option<String>,
}
#[post("/payment/notify")]
pub async fn alipay_notify(
pool: web::Data<PgPool>,
body: web::Form<AlipayNotify>,
) -> HttpResponse {
let body = body.into_inner();
tracing::info!(
"收到支付宝回调: out_trade_no={}, trade_status={}",
body.out_trade_no,
body.trade_status
);
// 1. 检查交易状态
if body.trade_status != "TRADE_SUCCESS" && body.trade_status != "TRADE_FINISHED" {
return HttpResponse::Ok().body("success");
}
// 2. 确认订单(通过 order_no不校验 user_id
match db::confirm_payment_order_by_orderno(pool.get_ref(), &body.out_trade_no).await {
Ok(_) => {
tracing::info!("订单 {} 支付确认成功", body.out_trade_no);
HttpResponse::Ok().body("success")
}
Err(e) => {
tracing::error!("订单 {} 确认失败: {}", body.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(query: web::Query<AlipaySuccessQuery>) -> HttpResponse {
let order_no = query.order_no.as_deref().unwrap_or("");
let html = build_success_html(order_no);
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(html)
}
// ===== 旧的 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> {
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> {
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,
}
})))
}
/// 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 (used, is_paid_active, paid_expires_at) =
db::get_user_quota(pool.get_ref(), user_id).await?;
let limit: i64 = std::env::var("FREE_USER_DATA_LIMIT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
Ok(HttpResponse::Ok().json(serde_json::json!({
"success": true,
"data": {
"used": used,
"limit": limit,
"unlimited": is_paid_active,
"is_paid_active": is_paid_active,
"paid_expires_at": paid_expires_at,
}
})))
}
/// 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 {
let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1")
.bind(&db_code)
.execute(pool.get_ref())
.await;
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_active($1)")
.bind(user_id)
.fetch_optional(pool.get_ref())
.await
{
Ok(Some((active, expires))) => (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,
}))
}