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

1381 lines
50 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,
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", &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: 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,
&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("");
// 不再在此处确认订单(安全原因: 此端点无认证, 任何人知道 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,
}))
}