feat(payment): 接入支付宝沙箱环境 (alipay.trade.page.pay)
This commit is contained in:
6
Cargo.lock
generated
6
Cargo.lock
generated
@@ -1952,6 +1952,7 @@ dependencies = [
|
|||||||
"pkcs1",
|
"pkcs1",
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
|
"sha2",
|
||||||
"signature",
|
"signature",
|
||||||
"spki",
|
"spki",
|
||||||
"subtle",
|
"subtle",
|
||||||
@@ -1966,15 +1967,20 @@ dependencies = [
|
|||||||
"actix-web",
|
"actix-web",
|
||||||
"base64",
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"digest",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"error",
|
"error",
|
||||||
|
"hex",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"log",
|
"log",
|
||||||
"openssl",
|
"openssl",
|
||||||
|
"pkcs8",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
toml = "0.8"
|
toml = "0.8"
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
|
rsa = { version = "0.9", features = ["pem", "sha2"] }
|
||||||
|
pkcs8 = "0.10"
|
||||||
|
sha2 = "0.10"
|
||||||
|
hex = "0.4"
|
||||||
|
digest = "0.10"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
43
src/db.rs
43
src/db.rs
@@ -392,6 +392,49 @@ pub async fn confirm_payment_order(
|
|||||||
Ok(expires_at)
|
Ok(expires_at)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 确认订单支付(支付宝异步回调用,通过 order_no 查找,不校验 user_id)
|
||||||
|
pub async fn confirm_payment_order_by_orderno(
|
||||||
|
pool: &PgPool,
|
||||||
|
order_no: &str,
|
||||||
|
) -> Result<(), AppError> {
|
||||||
|
let row = sqlx::query_as::<_, (i32, String, Option<chrono::DateTime<chrono::Utc>>)>(
|
||||||
|
r#"SELECT user_id, status, expires_at FROM payment_orders WHERE order_no = $1"#,
|
||||||
|
)
|
||||||
|
.bind(order_no)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?;
|
||||||
|
|
||||||
|
let (user_id, status, expires_at) = match row {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return Err(AppError::NotFound("订单不存在".to_string())),
|
||||||
|
};
|
||||||
|
|
||||||
|
if status != "pending" {
|
||||||
|
// 已支付直接返回成功(幂等)
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut tx = pool.begin().await.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
sqlx::query(r#"UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1"#)
|
||||||
|
.bind(order_no)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("更新订单状态失败: {}", e)))?;
|
||||||
|
|
||||||
|
sqlx::query(r#"UPDATE users SET is_paid = true, paid_expires_at = $1 WHERE id = $2"#)
|
||||||
|
.bind(expires_at)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Database(format!("更新用户付费状态失败: {}", e)))?;
|
||||||
|
|
||||||
|
tx.commit().await.map_err(|e| AppError::Database(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取用户配额信息
|
/// 获取用户配额信息
|
||||||
///
|
///
|
||||||
/// 返回 (已用条数, 是否付费活跃, 到期时间)
|
/// 返回 (已用条数, 是否付费活跃, 到期时间)
|
||||||
|
|||||||
@@ -31,9 +31,11 @@ pub use weather::get_weather_brief;
|
|||||||
pub use weather::get_weather_details;
|
pub use weather::get_weather_details;
|
||||||
pub use weather::post_weather_data;
|
pub use weather::post_weather_data;
|
||||||
|
|
||||||
pub use payment::alipay_trade_page_pay;
|
pub use payment::alipay_notify;
|
||||||
|
pub use payment::alipay_pay_page;
|
||||||
pub use payment::create_order;
|
pub use payment::create_order;
|
||||||
pub use payment::get_user_quota;
|
pub use payment::get_user_quota;
|
||||||
pub use payment::mock_confirm;
|
pub use payment::mock_confirm;
|
||||||
pub use payment::payment_index;
|
pub use payment::payment_index;
|
||||||
pub use payment::payment_page;
|
pub use payment::payment_page;
|
||||||
|
pub use payment::payment_success;
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
// handlers/payment.rs — 支付相关处理器(接入支付宝)
|
// handlers/payment.rs — 支付相关处理器(接入支付宝)
|
||||||
use actix_web::{get, post, web, HttpRequest, HttpResponse};
|
use actix_web::{get, post, web, HttpRequest, HttpResponse};
|
||||||
use chrono::Utc;
|
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 sqlx::postgres::PgPool;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::{AppState, Claims, CreateOrderRequest, MockConfirmRequest};
|
use crate::models::{Claims, CreateOrderRequest, MockConfirmRequest};
|
||||||
|
|
||||||
|
// ===== 套餐定义 =====
|
||||||
|
|
||||||
struct PackageInfo {
|
struct PackageInfo {
|
||||||
amount: i32,
|
amount: i32,
|
||||||
@@ -20,19 +27,19 @@ fn get_package_info(package_type: &str) -> Option<PackageInfo> {
|
|||||||
match package_type {
|
match package_type {
|
||||||
"monthly" => Some(PackageInfo {
|
"monthly" => Some(PackageInfo {
|
||||||
amount: 990,
|
amount: 990,
|
||||||
display_amount: "¥9.9",
|
display_amount: "9.9",
|
||||||
display_name: "包月会员",
|
display_name: "包月会员",
|
||||||
days: Some(30),
|
days: Some(30),
|
||||||
}),
|
}),
|
||||||
"yearly" => Some(PackageInfo {
|
"yearly" => Some(PackageInfo {
|
||||||
amount: 5900,
|
amount: 5900,
|
||||||
display_amount: "¥59",
|
display_amount: "59",
|
||||||
display_name: "包年会员",
|
display_name: "包年会员",
|
||||||
days: Some(365),
|
days: Some(365),
|
||||||
}),
|
}),
|
||||||
"permanent" => Some(PackageInfo {
|
"permanent" => Some(PackageInfo {
|
||||||
amount: 19900,
|
amount: 19900,
|
||||||
display_amount: "¥199",
|
display_amount: "199",
|
||||||
display_name: "永久会员",
|
display_name: "永久会员",
|
||||||
days: None,
|
days: None,
|
||||||
}),
|
}),
|
||||||
@@ -40,10 +47,201 @@ fn get_package_info(package_type: &str) -> Option<PackageInfo> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /payment — 套餐选择页面(无需认证,外部浏览器打开)
|
// ===== 支付宝配置 =====
|
||||||
|
|
||||||
|
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", ×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);
|
||||||
|
|
||||||
|
// 构造待签名串
|
||||||
|
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")]
|
#[get("/payment")]
|
||||||
pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||||
let html = r#"<!DOCTYPE html>
|
let html = r##"<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
@@ -51,137 +249,28 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
|||||||
<title>开通会员 - 大气稳定度判定</title>
|
<title>开通会员 - 大气稳定度判定</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif; background: #f0f2f5; min-height: 100vh; padding: 20px; }
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif;
|
.header { text-align: center; padding: 40px 0 30px; }
|
||||||
background: #f0f2f5;
|
.header h1 { font-size: 24px; color: #333; margin-bottom: 8px; }
|
||||||
min-height: 100vh;
|
.header p { font-size: 14px; color: #999; }
|
||||||
padding: 20px;
|
.packages { max-width: 480px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||||
}
|
.pkg-card { background: #fff; border-radius: 16px; padding: 24px; cursor: pointer; transition: all 0.2s; border: 2px solid transparent; position: relative; }
|
||||||
.header {
|
.pkg-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||||
text-align: center;
|
.pkg-card.selected { border-color: #1677ff; background: #f0f7ff; }
|
||||||
padding: 40px 0 30px;
|
.pkg-tag { position: absolute; top: -1px; right: 16px; background: #1677ff; color: #fff; font-size: 12px; padding: 4px 10px; border-radius: 0 0 8px 8px; }
|
||||||
}
|
.pkg-tag.orange { background: #ff6b00; }
|
||||||
.header h1 {
|
.pkg-name { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||||
font-size: 24px;
|
.pkg-price { font-size: 32px; font-weight: 700; color: #1677ff; margin-bottom: 4px; }
|
||||||
color: #333;
|
.pkg-price .unit { font-size: 14px; font-weight: 400; }
|
||||||
margin-bottom: 8px;
|
.pkg-desc { font-size: 13px; color: #999; }
|
||||||
}
|
.pkg-features { margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
|
||||||
.header p {
|
.pkg-feature { font-size: 13px; color: #666; margin-bottom: 6px; }
|
||||||
font-size: 14px;
|
.btn-pay { display: block; width: 100%; max-width: 480px; margin: 24px auto 0; background: #1677ff; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
|
||||||
color: #999;
|
.btn-pay:hover { background: #4096ff; }
|
||||||
}
|
.btn-pay:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||||
.packages {
|
.btn-pay.orange { background: #ff6b00; }
|
||||||
max-width: 480px;
|
.btn-pay.orange:hover { background: #ff8c33; }
|
||||||
margin: 0 auto;
|
.notice { text-align: center; font-size: 12px; color: #bbb; margin-top: 20px; }
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
}
|
|
||||||
.pkg-card {
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
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: #1677ff;
|
|
||||||
background: #f0f7ff;
|
|
||||||
}
|
|
||||||
.pkg-card.permanent {
|
|
||||||
border: 2px solid #ff6b00;
|
|
||||||
}
|
|
||||||
.pkg-card.permanent .pkg-tag {
|
|
||||||
background: #ff6b00;
|
|
||||||
}
|
|
||||||
.pkg-tag {
|
|
||||||
position: absolute;
|
|
||||||
top: -1px;
|
|
||||||
right: 16px;
|
|
||||||
background: #1677ff;
|
|
||||||
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: #1677ff;
|
|
||||||
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;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.pkg-feature::before {
|
|
||||||
content: '✓';
|
|
||||||
color: #52c41a;
|
|
||||||
margin-right: 6px;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.btn-pay {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
max-width: 480px;
|
|
||||||
margin: 24px auto 0;
|
|
||||||
background: #1677ff;
|
|
||||||
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: #4096ff;
|
|
||||||
}
|
|
||||||
.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;
|
|
||||||
margin-top: 20px;
|
|
||||||
padding: 0 20px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -189,7 +278,6 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
|||||||
<h1>开通会员</h1>
|
<h1>开通会员</h1>
|
||||||
<p>解锁无限检测额度,畅享全部功能</p>
|
<p>解锁无限检测额度,畅享全部功能</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="packages" id="packages">
|
<div class="packages" id="packages">
|
||||||
<div class="pkg-card" data-package="monthly" onclick="selectPackage('monthly')">
|
<div class="pkg-card" data-package="monthly" onclick="selectPackage('monthly')">
|
||||||
<div class="pkg-tag">推荐</div>
|
<div class="pkg-tag">推荐</div>
|
||||||
@@ -199,10 +287,8 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
|||||||
<div class="pkg-features">
|
<div class="pkg-features">
|
||||||
<div class="pkg-feature">每月 500 次检测额度</div>
|
<div class="pkg-feature">每月 500 次检测额度</div>
|
||||||
<div class="pkg-feature">查看完整历史记录</div>
|
<div class="pkg-feature">查看完整历史记录</div>
|
||||||
<div class="pkg-feature">专属客服支持</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="pkg-card" data-package="yearly" onclick="selectPackage('yearly')">
|
<div class="pkg-card" data-package="yearly" onclick="selectPackage('yearly')">
|
||||||
<div class="pkg-name">包年会员</div>
|
<div class="pkg-name">包年会员</div>
|
||||||
<div class="pkg-price">¥59<span class="unit">/年</span></div>
|
<div class="pkg-price">¥59<span class="unit">/年</span></div>
|
||||||
@@ -210,33 +296,24 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
|||||||
<div class="pkg-features">
|
<div class="pkg-features">
|
||||||
<div class="pkg-feature">每年 5000 次检测额度</div>
|
<div class="pkg-feature">每年 5000 次检测额度</div>
|
||||||
<div class="pkg-feature">查看完整历史记录</div>
|
<div class="pkg-feature">查看完整历史记录</div>
|
||||||
<div class="pkg-feature">专属客服支持</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pkg-card" data-package="permanent" onclick="selectPackage('permanent')">
|
||||||
<div class="pkg-card permanent" data-package="permanent" onclick="selectPackage('permanent')">
|
<div class="pkg-tag orange">超值</div>
|
||||||
<div class="pkg-tag">超值</div>
|
|
||||||
<div class="pkg-name">永久会员</div>
|
<div class="pkg-name">永久会员</div>
|
||||||
<div class="pkg-price">¥199<span class="unit">/终身</span></div>
|
<div class="pkg-price" style="color:#ff6b00;">¥199<span class="unit">/终身</span></div>
|
||||||
<div class="pkg-desc">一次购买,终身享用</div>
|
<div class="pkg-desc">一次购买,终身享用</div>
|
||||||
<div class="pkg-features">
|
<div class="pkg-features">
|
||||||
<div class="pkg-feature">无限次检测额度</div>
|
<div class="pkg-feature">无限次检测额度</div>
|
||||||
<div class="pkg-feature">查看完整历史记录</div>
|
<div class="pkg-feature">查看完整历史记录</div>
|
||||||
<div class="pkg-feature">永久专属客服支持</div>
|
|
||||||
<div class="pkg-feature">优先体验新功能</div>
|
<div class="pkg-feature">优先体验新功能</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="btn-pay" id="payBtn" onclick="goPay()" disabled>请先选择套餐</button>
|
<button class="btn-pay" id="payBtn" onclick="goPay()" disabled>请先选择套餐</button>
|
||||||
|
<div class="notice">支付成功后额度将自动到账,如有疑问请联系客服</div>
|
||||||
<div class="notice">
|
|
||||||
支付成功后额度将自动到账,如有疑问请联系客服
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let selected = null;
|
let selected = null;
|
||||||
|
|
||||||
function selectPackage(pkg) {
|
function selectPackage(pkg) {
|
||||||
selected = pkg;
|
selected = pkg;
|
||||||
document.querySelectorAll('.pkg-card').forEach(c => c.classList.remove('selected'));
|
document.querySelectorAll('.pkg-card').forEach(c => c.classList.remove('selected'));
|
||||||
@@ -247,63 +324,44 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
|||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.className = pkg === 'permanent' ? 'btn-pay orange' : 'btn-pay';
|
btn.className = pkg === 'permanent' ? 'btn-pay orange' : 'btn-pay';
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPay() {
|
function goPay() {
|
||||||
if (!selected) return;
|
if (!selected) return;
|
||||||
// 跳转到支付确认页(创建订单)
|
window.location.href = '/payment/page?package=' + selected;
|
||||||
window.location.href = '/payment/confirm?package=' + selected;
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>"#;
|
</html>"##;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok()
|
Ok(HttpResponse::Ok()
|
||||||
.content_type("text/html; charset=utf-8")
|
.content_type("text/html; charset=utf-8")
|
||||||
.body(html))
|
.body(html))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== 支付宝配置 =====
|
// ===== Handler: GET /payment/page — 创建订单并跳转支付宝 =====
|
||||||
//
|
|
||||||
// 请在 config/development.toml 或 config/production.toml 中配置以下字段:
|
|
||||||
// alipay_app_id = "your_alipay_app_id"
|
|
||||||
// alipay_private_key = "your_private_key_content"
|
|
||||||
// alipay_alipay_public_key = "alipay_public_key_content"
|
|
||||||
// alipay_gateway = "https://openapi-sandbox.dl.alipaydev.com/gateway.do" # 沙箱
|
|
||||||
// alipay_gateway = "https://openapi.alipay.com/gateway.do" # 正式
|
|
||||||
//
|
|
||||||
|
|
||||||
/// 提取 JWT token(供支付页面使用)
|
#[derive(Debug, Deserialize)]
|
||||||
fn extract_token(req: &HttpRequest) -> Option<String> {
|
pub struct PaymentPageQuery {
|
||||||
req.headers()
|
#[serde(rename = "package")]
|
||||||
.get("Authorization")?
|
pub package_: String,
|
||||||
.to_str()
|
|
||||||
.ok()?
|
|
||||||
.strip_prefix("Bearer ")
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /payment/page?package=xxx — 返回支付引导页面(用户扫码/浏览器打开)
|
|
||||||
#[get("/payment/page")]
|
#[get("/payment/page")]
|
||||||
pub async fn payment_page(
|
pub async fn payment_page(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
app_state: web::Data<AppState>,
|
|
||||||
query: web::Query<PaymentPageQuery>,
|
query: web::Query<PaymentPageQuery>,
|
||||||
) -> Result<HttpResponse, AppError> {
|
) -> Result<HttpResponse, AppError> {
|
||||||
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
||||||
|
|
||||||
// 验证 token
|
let claims = crate::auth::verify_token(&token, &get_jwt_secret())
|
||||||
let claims = crate::auth::verify_token(&token, &app_state.jwt_secret)
|
|
||||||
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
||||||
|
|
||||||
let pkg = get_package_info(&query.package_)
|
let pkg = get_package_info(&query.package_)
|
||||||
.ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?;
|
.ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?;
|
||||||
|
|
||||||
// 生成订单号
|
|
||||||
let order_no = Uuid::new_v4().to_string();
|
let order_no = Uuid::new_v4().to_string();
|
||||||
let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d));
|
let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d));
|
||||||
|
|
||||||
// 创建待支付订单
|
|
||||||
db::create_payment_order(
|
db::create_payment_order(
|
||||||
pool.get_ref(),
|
pool.get_ref(),
|
||||||
claims.user_id,
|
claims.user_id,
|
||||||
@@ -314,127 +372,212 @@ pub async fn payment_page(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// 构造支付页面 URL(用户打开后展示支付宝收款码)
|
|
||||||
// 实际跳转地址由前端 outter 页面处理
|
|
||||||
let base_url = std::env::var("APP_BASE_URL")
|
let base_url = std::env::var("APP_BASE_URL")
|
||||||
.unwrap_or_else(|_| "https://xmclassmate.top".to_string());
|
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
|
||||||
let _payment_url = format!(
|
let notify_url = format!("{}/payment/notify", base_url);
|
||||||
"{}/payment/pay?order_no={}&package_type={}",
|
let return_url = format!("{}/payment/success?order_no={}", base_url, order_no);
|
||||||
base_url, order_no, query.package_
|
|
||||||
);
|
|
||||||
|
|
||||||
let html = format!(
|
let Some(config) = AlipayConfig::from_env() else {
|
||||||
r#"<!DOCTYPE html>
|
return Ok(HttpResponse::Ok()
|
||||||
<html lang="zh-CN">
|
.content_type("text/html; charset=utf-8")
|
||||||
<head>
|
.body(format_error_html("支付配置不完整,请联系管理员", &order_no)));
|
||||||
<meta charset="utf-8">
|
};
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>支付页面</title>
|
|
||||||
<style>
|
|
||||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
background: #f5f5f5; margin: 0; padding: 20px; }}
|
|
||||||
.card {{ background: #fff; border-radius: 12px; padding: 32px; max-width: 400px;
|
|
||||||
margin: 40px auto; box-shadow: 0 2px 12px rgba(0,0,0,0.1); text-align: center; }}
|
|
||||||
.title {{ font-size: 20px; font-weight: 600; color: #333; margin-bottom: 24px; }}
|
|
||||||
.amount {{ font-size: 36px; color: #1677ff; font-weight: 700; margin-bottom: 8px; }}
|
|
||||||
.name {{ font-size: 14px; color: #666; margin-bottom: 24px; }}
|
|
||||||
.qr-info {{ font-size: 14px; color: #999; margin-bottom: 24px; }}
|
|
||||||
.btn {{ display: inline-block; background: #1677ff; color: #fff; text-decoration: none;
|
|
||||||
padding: 12px 32px; border-radius: 8px; font-size: 16px; }}
|
|
||||||
.btn:hover {{ background: #4096ff; }}
|
|
||||||
.order-no {{ font-size: 12px; color: #bbb; margin-top: 20px; }}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="card">
|
|
||||||
<div class="title">订单待支付</div>
|
|
||||||
<div class="amount">{display_amount}</div>
|
|
||||||
<div class="name">{display_name}</div>
|
|
||||||
<div class="qr-info">即将跳转至支付宝支付页面</div>
|
|
||||||
<a class="btn" href="/payment/pay?order_no={order_no}&package_type={package_type}">
|
|
||||||
打开支付宝支付
|
|
||||||
</a>
|
|
||||||
<div class="order-no">订单号: {order_no}</div>
|
|
||||||
</div>
|
|
||||||
<script>
|
|
||||||
// 自动跳转支付宝
|
|
||||||
window.location.href = '/payment/pay?order_no={order_no}&package_type={package_type}';
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>"#,
|
|
||||||
display_amount = pkg.display_amount,
|
|
||||||
display_name = pkg.display_name,
|
|
||||||
order_no = order_no,
|
|
||||||
package_type = query.package_,
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(HttpResponse::Ok()
|
let total_amount_str = format!("{:.2}", pkg.amount as f64 / 100.0);
|
||||||
.content_type("text/html; charset=utf-8")
|
|
||||||
.body(html))
|
match call_alipay_trade_page_pay(
|
||||||
|
&config,
|
||||||
|
&order_no,
|
||||||
|
&total_amount_str,
|
||||||
|
pkg.display_name,
|
||||||
|
¬ify_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 — 支付宝电脑网站支付(返回支付表单或链接)
|
|
||||||
#[get("/payment/pay")]
|
#[get("/payment/pay")]
|
||||||
pub async fn alipay_trade_page_pay(
|
pub async fn alipay_pay_page(
|
||||||
req: HttpRequest,
|
req: HttpRequest,
|
||||||
app_state: web::Data<AppState>,
|
|
||||||
query: web::Query<AlipayPayQuery>,
|
query: web::Query<AlipayPayQuery>,
|
||||||
) -> Result<HttpResponse, AppError> {
|
) -> Result<HttpResponse, AppError> {
|
||||||
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?;
|
||||||
let _claims = crate::auth::verify_token(&token, &app_state.jwt_secret)
|
crate::auth::verify_token(&token, &get_jwt_secret())
|
||||||
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
||||||
|
|
||||||
// TODO: 调用 alipay.trade.page.pay 接口
|
let pkg = get_package_info(&query.package_type)
|
||||||
// 当前为占位实现,正式接入时替换为支付宝 SDK 调用
|
.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,
|
||||||
|
¬ify_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!(
|
let html = format!(
|
||||||
r#"<!DOCTYPE html>
|
r##"<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>支付宝支付</title>
|
<title>支付成功</title>
|
||||||
<style>
|
<style>
|
||||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
body {{ font-family: -apple-system, BlinkMacSystemFont, sans-serif; background: #f0f2f5; margin: 0; padding: 40px; text-align: center; }}
|
||||||
background: #f5f5f5; margin: 0; padding: 20px; }}
|
.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); }}
|
||||||
.card {{ background: #fff; border-radius: 12px; padding: 32px; max-width: 480px;
|
.icon {{ width: 64px; height: 64px; margin-bottom: 16px; }}
|
||||||
margin: 40px auto; box-shadow: 0 2px 12px rgba(0,0,0,0.1); text-align: center; }}
|
h2 {{ color: {0}; font-size: 22px; margin-bottom: 8px; }}
|
||||||
.alipay-logo {{ width: 80px; margin-bottom: 20px; }}
|
p {{ color: #666; font-size: 14px; margin-bottom: 24px; }}
|
||||||
.title {{ font-size: 18px; font-weight: 600; color: #333; margin-bottom: 16px; }}
|
.tip {{ background: #f0f7ff; border-radius: 8px; padding: 16px; font-size: 13px; color: #1677ff; margin-top: 16px; }}
|
||||||
.info {{ font-size: 14px; color: #666; margin-bottom: 8px; text-align: left; }}
|
.order-no {{ font-size: 12px; color: #bbb; margin-top: 12px; }}
|
||||||
.info strong {{ color: #333; }}
|
|
||||||
.notice {{ font-size: 12px; color: #999; margin-top: 20px; }}
|
|
||||||
.placeholder {{ background: #fafafa; border: 2px dashed #ddd; border-radius: 8px;
|
|
||||||
padding: 40px; color: #999; margin: 20px 0; }}
|
|
||||||
.warn {{ color: #ff6b00; font-size: 13px; margin-top: 12px; }}
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img class="alipay-logo" src="https://cdn.antdv.com/logo.png" alt="支付宝" onerror="this.style.display='none'">
|
<svg class="icon" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<div class="title">支付宝收款页面</div>
|
<circle cx="32" cy="32" r="32" fill="{0}"/>
|
||||||
<div class="info"><strong>订单号:</strong>{order_no}</div>
|
<path d="M20 32l8 8 16-16" stroke="{1}" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<div class="info"><strong>套餐:</strong>{package_type}</div>
|
</svg>
|
||||||
<div class="placeholder">
|
<h2>支付成功!</h2>
|
||||||
支付宝支付表单将在这里渲染<br>
|
<p>恭喜您已成为会员,额度已自动到账</p>
|
||||||
(需配置 alipay_app_id 和私钥)
|
<div class="tip">请返回微信小程序查看您的会员状态</div>
|
||||||
</div>
|
<div class="order-no">订单号: {2}</div>
|
||||||
<div class="warn">⚠️ 请在 .env 或配置文件中设置支付宝相关配置</div>
|
|
||||||
<div class="notice">
|
|
||||||
支付完成后页面将自动跳转<br>
|
|
||||||
如未跳转,请手动关闭此页面
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>"#,
|
</html>"##,
|
||||||
order_no = query.order_no,
|
green, white, order_no
|
||||||
package_type = query.package_type,
|
|
||||||
);
|
);
|
||||||
|
html
|
||||||
Ok(HttpResponse::Ok()
|
|
||||||
.content_type("text/html; charset=utf-8")
|
|
||||||
.body(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
|
||||||
#[post("/api/payment/create-order")]
|
#[post("/api/payment/create-order")]
|
||||||
pub async fn create_order(
|
pub async fn create_order(
|
||||||
@@ -486,7 +629,8 @@ pub async fn mock_confirm(
|
|||||||
) -> Result<HttpResponse, AppError> {
|
) -> Result<HttpResponse, AppError> {
|
||||||
let user_id = claims.user_id;
|
let user_id = claims.user_id;
|
||||||
|
|
||||||
let expires_at = db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await?;
|
let expires_at =
|
||||||
|
db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await?;
|
||||||
|
|
||||||
Ok(HttpResponse::Ok().json(serde_json::json!({
|
Ok(HttpResponse::Ok().json(serde_json::json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
@@ -505,7 +649,8 @@ pub async fn get_user_quota(
|
|||||||
) -> Result<HttpResponse, AppError> {
|
) -> Result<HttpResponse, AppError> {
|
||||||
let user_id = claims.user_id;
|
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 (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")
|
let limit: i64 = std::env::var("FREE_USER_DATA_LIMIT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|v| v.parse().ok())
|
.and_then(|v| v.parse().ok())
|
||||||
@@ -522,18 +667,3 @@ pub async fn get_user_quota(
|
|||||||
}
|
}
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Query Structs =====
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct PaymentPageQuery {
|
|
||||||
#[serde(rename = "package")]
|
|
||||||
pub package_: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
|
||||||
pub struct AlipayPayQuery {
|
|
||||||
pub order_no: String,
|
|
||||||
#[serde(rename = "package_type")]
|
|
||||||
pub package_type: String,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ use auth::jwt_middleware;
|
|||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
use db::create_pool;
|
use db::create_pool;
|
||||||
use handlers::{
|
use handlers::{
|
||||||
admin_get_user, admin_update_user_payment, add_favorite, alipay_trade_page_pay,
|
admin_get_user, admin_update_user_payment, add_favorite, alipay_notify, alipay_pay_page,
|
||||||
create_order, delete_weather, generate_temp_token_handler, get_current_user_profile,
|
create_order, delete_weather, generate_temp_token_handler, get_current_user_profile,
|
||||||
get_favorites, get_user_quota, get_weather_brief, get_weather_details,
|
get_favorites, get_user_quota, get_weather_brief, get_weather_details,
|
||||||
health_check, login, mock_confirm, payment_index, payment_page, post_weather_data,
|
health_check, login, mock_confirm, payment_index, payment_page, payment_success,
|
||||||
|
post_weather_data,
|
||||||
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
|
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
|
||||||
};
|
};
|
||||||
use models::AppState;
|
use models::AppState;
|
||||||
@@ -64,7 +65,9 @@ fn create_server_config(
|
|||||||
// 支付页面(无需认证,外部浏览器访问)
|
// 支付页面(无需认证,外部浏览器访问)
|
||||||
.service(payment_index) // GET /payment — 套餐选择页
|
.service(payment_index) // GET /payment — 套餐选择页
|
||||||
.service(payment_page) // GET /payment/page(需 JWT)
|
.service(payment_page) // GET /payment/page(需 JWT)
|
||||||
.service(alipay_trade_page_pay) // GET /payment/pay(需 JWT)
|
.service(payment_success)
|
||||||
|
.service(alipay_pay_page) // GET /payment/pay(需 JWT)
|
||||||
|
.service(alipay_notify) // POST /payment/notify(支付宝异步回调)
|
||||||
// 静态文件(无需认证)
|
// 静态文件(无需认证)
|
||||||
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
|
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
|
||||||
// API 接口
|
// API 接口
|
||||||
|
|||||||
Reference in New Issue
Block a user