1381 lines
50 KiB
Rust
1381 lines
50 KiB
Rust
// 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,
|
||
original_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: 590,
|
||
display_amount: "5.9",
|
||
original_amount: "9.9",
|
||
display_name: "包月会员",
|
||
days: Some(30),
|
||
}),
|
||
"quarterly" => Some(PackageInfo {
|
||
amount: 1680,
|
||
display_amount: "16.8",
|
||
original_amount: "29",
|
||
display_name: "季卡会员",
|
||
days: Some(90),
|
||
}),
|
||
"half_year" => Some(PackageInfo {
|
||
amount: 3190,
|
||
display_amount: "31.9",
|
||
original_amount: "49",
|
||
display_name: "半年会员",
|
||
days: Some(182),
|
||
}),
|
||
"yearly" => Some(PackageInfo {
|
||
amount: 6020,
|
||
display_amount: "60.2",
|
||
original_amount: "99",
|
||
display_name: "包年会员",
|
||
days: Some(365),
|
||
}),
|
||
_ => 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))?;
|
||
// 支付宝密钥工具生成的 Base64 解码后是 PKCS#8 DER,不是 PEM 文本
|
||
RsaPrivateKey::from_pkcs8_der(&decoded)
|
||
.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", ×tamp);
|
||
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)?;
|
||
|
||
// 生成自动提交的表单 HTML(HTML 转义防止特殊字符破坏属性)
|
||
fn h(s: &str) -> String {
|
||
s.replace('&', "&")
|
||
.replace('"', """)
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
}
|
||
let mut form_fields = String::new();
|
||
for (k, v) in ¶ms {
|
||
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: 32px 20px 28px; text-align: center; }
|
||
.paid-banner h2 { font-size: 20px; margin-bottom: 4px; }
|
||
.paid-banner p { font-size: 13px; opacity: 0.85; }
|
||
.paid-info { background: #fff; margin: -16px 12px 0; border-radius: 14px; padding: 20px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); position: relative; z-index: 1; }
|
||
.paid-info-row { display: flex; justify-content: space-between; align-items: center; padding: 10px 0; border-bottom: 1px solid #f5f5f5; font-size: 14px; }
|
||
.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 12px 60px; }
|
||
.packages-title { text-align: center; padding: 28px 0 20px; font-size: 18px; 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-original { font-size: 14px; color: #bbb; text-decoration: line-through; margin-right: 4px; }
|
||
.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; }
|
||
|
||
@media (max-width: 480px) {
|
||
.paid-banner { padding: 24px 16px 20px; }
|
||
.paid-banner h2 { font-size: 18px; }
|
||
.paid-info { margin: -14px 8px 0; padding: 16px; border-radius: 12px; }
|
||
.paid-info-row { font-size: 13px; padding: 8px 0; }
|
||
.packages-title { font-size: 16px; padding: 20px 0 16px; }
|
||
.pkg-card { padding: 16px; margin-bottom: 12px; }
|
||
.pkg-name { font-size: 16px; }
|
||
.pkg-price { font-size: 26px; }
|
||
.pkg-desc { font-size: 12px; }
|
||
.pkg-original { font-size: 13px; }
|
||
.pkg-feature { font-size: 12px; }
|
||
.btn-pay { padding: 14px; font-size: 16px; }
|
||
}
|
||
</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"><span class="pkg-original">¥9.9</span> ¥5.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="quarterly" onclick="selectPackage('quarterly')">
|
||
<div class="pkg-name">季卡会员</div>
|
||
<div class="pkg-price"><span class="pkg-original">¥29</span> ¥16.8<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="half_year" onclick="selectPackage('half_year')">
|
||
<div class="pkg-name">半年会员</div>
|
||
<div class="pkg-price"><span class="pkg-original">¥49</span> ¥31.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"><span class="pkg-original">¥99</span> ¥60.2<span class="unit">/年</span></div>
|
||
<div class="pkg-desc">相当于每月 ¥5.0,性价比最高</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_active_member || false;
|
||
paidExpiresAt = data.data.membership_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_active_member || false;
|
||
paidExpiresAt = data.membership_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';
|
||
return;
|
||
}
|
||
}
|
||
// 显示套餐(未付费用户购买 / 已付费用户续费)
|
||
document.getElementById('paidSection').style.display = 'block';
|
||
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: '立即开通 - ¥5.9/月', quarterly: '立即开通 - ¥16.8/季', half_year: '立即开通 - ¥31.9/半年', yearly: '立即开通 - ¥60.2/年' };
|
||
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>,
|
||
/// 复用已有待支付订单的 order_no(不创建新订单)
|
||
#[serde(default)]
|
||
pub resume: 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()))?;
|
||
|
||
// 复用已有待支付订单(如果提供了 resume order_no)
|
||
let order_no = if let Some(ref resume_no) = query.resume {
|
||
let existing: Option<(i32, String)> = sqlx::query_as(
|
||
r#"SELECT user_id, status FROM payment_orders WHERE order_no = $1"#,
|
||
)
|
||
.bind(resume_no)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
|
||
|
||
match existing {
|
||
Some((uid, status)) if uid == claims.user_id && status == "pending" => {
|
||
resume_no.clone()
|
||
}
|
||
_ => {
|
||
// 订单不存在/不属于该用户/已支付 → 创建新订单
|
||
let new_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, &new_no, &query.package_, pkg.amount, expires_at,
|
||
).await?;
|
||
new_no
|
||
}
|
||
}
|
||
} else {
|
||
// 查重:用户是否已有同套餐的待支付订单,有则复用
|
||
let existing: Option<String> = sqlx::query_scalar(
|
||
r#"SELECT order_no FROM payment_orders
|
||
WHERE user_id = $1 AND package_type = $2 AND status = 'pending'
|
||
ORDER BY created_at DESC LIMIT 1"#,
|
||
)
|
||
.bind(claims.user_id)
|
||
.bind(&query.package_)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
.map_err(|e| AppError::Database(format!("查询已有订单失败: {}", e)))?;
|
||
|
||
if let Some(no) = existing {
|
||
no
|
||
} else {
|
||
let new_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, &new_no, &query.package_, pkg.amount, expires_at,
|
||
).await?;
|
||
new_no
|
||
}
|
||
};
|
||
|
||
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,
|
||
¬ify_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,
|
||
¬ify_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("");
|
||
|
||
// 不再在此处确认订单(安全原因: 此端点无认证, 任何人知道 order_no 即可激活会员)。
|
||
// Mock 支付由 mock_confirm 在跳转前确认, 真实支付宝由 notify 异步回调确认。
|
||
|
||
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,
|
||
}
|
||
})))
|
||
}
|
||
|
||
/// 检查 Mock 支付是否允许
|
||
/// 规则(依次):
|
||
/// 1. 已配置支付宝 → 禁用(真实支付优先)
|
||
/// 2. MOCK_PAY_ENABLED != true → 禁用
|
||
/// 3. MOCK_PAY_KEY 已设置 → 验证 X-Mock-Key 请求头
|
||
fn check_mock_payment_allowed(req: &HttpRequest) -> Result<(), AppError> {
|
||
// 规则 1:有支付宝时永不走 Mock
|
||
if AlipayConfig::from_env().is_some() {
|
||
return Err(AppError::BadRequest("真实支付已启用,Mock 支付不可用".to_string()));
|
||
}
|
||
|
||
// 规则 2:必须显式启用 Mock 支付
|
||
if std::env::var("MOCK_PAY_ENABLED").ok() != Some("true".to_string()) {
|
||
return Err(AppError::Forbidden("Mock 支付未启用".to_string()));
|
||
}
|
||
|
||
// 规则 3:如果设了 MOCK_PAY_KEY,验证请求头
|
||
if let Ok(key) = std::env::var("MOCK_PAY_KEY") {
|
||
if !key.is_empty() {
|
||
let header_key = req
|
||
.headers()
|
||
.get("X-Mock-Key")
|
||
.and_then(|v| v.to_str().ok())
|
||
.unwrap_or("");
|
||
if header_key != key {
|
||
return Err(AppError::Forbidden("Mock 支付密钥错误".to_string()));
|
||
}
|
||
}
|
||
}
|
||
|
||
Ok(())
|
||
}
|
||
|
||
/// POST /api/payment/mock-confirm
|
||
/// 所有 Mock 操作均需密钥授权(通过 MOCK_PAY_ENABLED + MOCK_PAY_KEY 控制)
|
||
#[post("/api/payment/mock-confirm")]
|
||
pub async fn mock_confirm(
|
||
req: HttpRequest,
|
||
pool: web::Data<PgPool>,
|
||
claims: web::ReqData<Claims>,
|
||
body: web::Json<MockConfirmRequest>,
|
||
) -> Result<HttpResponse, AppError> {
|
||
check_payment_maintenance()?;
|
||
check_mock_payment_allowed(&req)?;
|
||
|
||
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_active_member": true,
|
||
"membership_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;
|
||
|
||
// 查询订单当前状态(不执行确认,防止误确认未支付订单)
|
||
let order_status: Option<String> = sqlx::query_scalar(
|
||
r#"SELECT status FROM payment_orders WHERE order_no = $1 AND user_id = $2"#,
|
||
)
|
||
.bind(&body.order_id)
|
||
.bind(user_id)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?
|
||
.unwrap_or_default();
|
||
|
||
// 查询会员最新状态
|
||
let user = sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
||
r#"SELECT is_member, membership_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_member, membership_expires_at) = user;
|
||
let is_active_member = is_member && membership_expires_at.map_or(true, |expires| expires > Utc::now());
|
||
|
||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||
"success": true,
|
||
"data": {
|
||
"order_status": order_status,
|
||
"is_member": is_member,
|
||
"is_active_member": is_active_member,
|
||
"membership_expires_at": membership_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_active_member, membership_expires_at, was_member) =
|
||
db::get_user_quota(pool.get_ref(), user_id).await?;
|
||
|
||
// 维护模式使用更高的临时限额,但非会员仍然有限制防止滥用
|
||
let (limit, unlimited) = if is_maintenance && !is_active_member {
|
||
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_active_member {
|
||
(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_active_member };
|
||
|
||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||
"success": true,
|
||
"data": {
|
||
"used": used,
|
||
"limit": limit,
|
||
"unlimited": unlimited,
|
||
"is_active_member": active,
|
||
"membership_expires_at": membership_expires_at,
|
||
"was_member": was_member,
|
||
"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_active_member: bool,
|
||
pub membership_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_active_member: false,
|
||
membership_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_active_member: false,
|
||
membership_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_active_member: false,
|
||
membership_expires_at: None,
|
||
}));
|
||
}
|
||
|
||
// 已确认 → 使用已生成的 token
|
||
let token = token.unwrap();
|
||
let user_id = user_id.unwrap();
|
||
|
||
let (is_active_member, membership_expires_at): (bool, Option<String>) =
|
||
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
|
||
"SELECT is_member, membership_expires_at FROM users WHERE id = $1"
|
||
)
|
||
.bind(user_id)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
{
|
||
Ok(Some((is_member, expires))) => {
|
||
let active = is_member && 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_active_member,
|
||
membership_expires_at,
|
||
}))
|
||
}
|