@@ -1,13 +1,20 @@
// handlers/payment.rs — 支付相关处理器(接入支付宝)
use actix_web ::{ get , post , web , HttpRequest , HttpResponse } ;
use chrono ::Utc ;
use rsa ::pkcs8 ::DecodePrivateKey ;
use rsa ::signature ::Signer ;
use rsa ::{ Pkcs1v15Sign , RsaPrivateKey } ;
use serde ::Deserialize ;
use sha2 ::Sha256 ;
use sqlx ::postgres ::PgPool ;
use std ::collections ::BTreeMap ;
use uuid ::Uuid ;
use crate ::db ;
use crate ::error ::AppError ;
use crate ::models ::{ AppState , Claims, CreateOrderRequest , MockConfirmRequest } ;
use crate ::models ::{ Claims , CreateOrderRequest , MockConfirmRequest } ;
// ===== 套餐定义 =====
struct PackageInfo {
amount : i32 ,
@@ -20,19 +27,19 @@ fn get_package_info(package_type: &str) -> Option<PackageInfo> {
match package_type {
" monthly " = > Some ( PackageInfo {
amount : 990 ,
display_amount : " ¥ 9.9" ,
display_amount : " 9.9 " ,
display_name : " 包月会员 " ,
days : Some ( 30 ) ,
} ) ,
" yearly " = > Some ( PackageInfo {
amount : 5900 ,
display_amount : " ¥ 59" ,
display_amount : " 59 " ,
display_name : " 包年会员 " ,
days : Some ( 365 ) ,
} ) ,
" permanent " = > Some ( PackageInfo {
amount : 19900 ,
display_amount : " ¥ 199" ,
display_amount : " 199 " ,
display_name : " 永久会员 " ,
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 " , & timestamp ) ;
params . insert ( " version " , " 1.0 " ) ;
params . insert ( " biz_content " , & biz_content_str ) ;
params . insert ( " notify_url " , notify_url ) ;
params . insert ( " return_url " , return_url ) ;
// 构造待签名串
let sign_source : String = params
. iter ( )
. map ( | ( k , v ) | format! ( " {} = {} " , k , urlencoding ( v ) ) )
. collect ::< Vec < _ > > ( )
. join ( " & " ) ;
let sign = rsa2_sign ( & sign_source , & config . private_key ) ? ;
// 构建 POST body
let query : String = params
. iter ( )
. map ( | ( k , v ) | format! ( " {} = {} " , urlencoding ( k ) , urlencoding ( v ) ) )
. chain ( std ::iter ::once ( format! ( " sign= {} " , urlencoding ( & sign ) ) ) )
. collect ::< Vec < _ > > ( )
. join ( " & " ) ;
let resp = client
. post ( & config . gateway )
. header ( " Content-Type " , " application/x-www-form-urlencoded " )
. body ( query )
. send ( )
. await
. map_err ( | e | format! ( " 请求支付宝失败: {} " , e ) ) ? ;
let body = resp . text ( ) . await . map_err ( | e | format! ( " 读取响应失败: {} " , e ) ) ? ;
// 支付宝返回格式: alipay_trade_page_pay_response={...}&sign=xxx
let parts : Vec < & str > = body . splitn ( 2 , " &sign= " ) . collect ( ) ;
if parts . len ( ) ! = 2 {
// 如果直接返回 HTML 表单(沙箱环境可能直接返回表单),直接返回
if body . contains ( " <form " ) {
return Ok ( body ) ;
}
return Err ( format! (
" 支付宝响应格式异常: {} " ,
& body [ .. body . len ( ) . min ( 300 ) ]
) ) ;
}
let json_str = parts [ 0 ]
. strip_prefix ( " alipay_trade_page_pay_response= " )
. unwrap_or ( parts [ 0 ] ) ;
let sign_from_alipay = parts [ 1 ] ;
if ! rsa2_verify ( json_str , sign_from_alipay , & config . alipay_public_key ) ? {
return Err ( " 支付宝响应验签失败 " . to_string ( ) ) ;
}
let json : serde_json ::Value =
serde_json ::from_str ( json_str ) . map_err ( | e | format! ( " JSON 解析失败: {} " , e ) ) ? ;
if json . get ( " code " ) . and_then ( | v | v . as_str ( ) ) ! = Some ( " 10000 " ) {
let sub_msg = json
. get ( " sub_msg " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " " ) ;
return Err ( format! (
" 支付宝接口错误: {} - {} " ,
json . get ( " msg " )
. and_then ( | v | v . as_str ( ) )
. unwrap_or ( " 未知 " ) ,
sub_msg
) ) ;
}
let form_html = json
. get ( " form_html " )
. and_then ( | v | v . as_str ( ) )
. ok_or_else ( | | " 响应中缺少 form_html " . to_string ( ) ) ? ;
Ok ( form_html . to_string ( ) )
}
// ===== 提取 JWT token =====
fn extract_token ( req : & HttpRequest ) -> Option < String > {
req . headers ( )
. get ( " Authorization " ) ?
. to_str ( )
. ok ( ) ?
. strip_prefix ( " Bearer " )
. map ( | s | s . to_string ( ) )
}
fn get_jwt_secret ( ) -> String {
std ::env ::var ( " JWT_SECRET " ) . unwrap_or_else ( | _ | " default_secret " . to_string ( ) )
}
// ===== Handler: GET /payment — 套餐选择页 =====
#[ get( " /payment " ) ]
pub async fn payment_index ( ) -> Result < HttpResponse , AppError > {
let html = r # "<!DOCTYPE html>
let html = r ## "<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
@@ -51,137 +249,28 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
<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;
padding: 20 px;
}
.header {
text-align: center;
padding: 40px 0 30 px;
}
.header h1 {
font-size: 2 4px;
color: #333;
margin-bottom: 8px;
}
.header p {
font-size: 14 px;
color: #999;
}
.packa ges {
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;
}
.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;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif; background: #f0f2f5; min-height: 100vh; padding: 20px; }
.header { text-align: center; padding: 40px 0 30px; }
.header h1 { font-size: 24px; color: #333; margin-bottom: 8px; }
.header p { font-size: 14px; color: #999; }
.packages { max-width: 480px; margin: 0 auto; display: flex; flex-direction: column; gap: 16 px; }
.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-tag { position: absolute; top: -1px; right: 16px; background: #1677ff; color: #fff; font-size: 12px; padding: 4px 10px; border-radius: 0 0 8px 8 px; }
.pkg-tag.orange { background: #ff6b00; }
.pkg-name { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; }
.pkg-price { font-size: 32px; font-weight: 700; color: #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; }
.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: 17 px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
.btn-pay:hover { background: #4096ff; }
.btn-pay:disabled { background: #d9d9d9; cursor: not-allowed; }
.btn-pay.oran ge { background: #ff6b00; }
.btn-pay.orange:hover { background: #ff8c33; }
.notice { text-align: center; font-size: 12px; color: #bbb; margin-top: 20px; }
</style>
</head>
<body>
@@ -189,7 +278,6 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
<h1>开通会员</h1>
<p>解锁无限检测额度,畅享全部功能</p>
</div>
<div class="packages" id="packages">
<div class="pkg-card" data-package="monthly" onclick="selectPackage('monthly')">
<div class="pkg-tag">推荐</div>
@@ -199,10 +287,8 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
<div class="pkg-features">
<div class="pkg-feature">每月 500 次检测额度</div>
<div class="pkg-feature">查看完整历史记录</div>
<div class="pkg-feature">专属客服支持</div>
</div>
</div>
<div class="pkg-card" data-package="yearly" onclick="selectPackage('yearly')">
<div class="pkg-name">包年会员</div>
<div class="pkg-price">¥59<span class="unit">/年</span></div>
@@ -210,33 +296,24 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
<div class="pkg-features">
<div class="pkg-feature">每年 5000 次检测额度</div>
<div class="pkg-feature">查看完整历史记录</div>
<div class="pkg-feature">专属客服支持</div>
</div>
</div>
<div class="pkg-card permanent" data-package="permanent" onclick="selectPackage('permanent')" >
<div class="pkg-tag">超值</div>
<div class="pkg-card" data-package="permanent" onclick="selectPackage('permanent')">
<div class="pkg-tag orange">超值</div >
<div class="pkg-name">永久会员</div>
<div class="pkg-price">¥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-features">
<div class="pkg-feature">无限次检测额度</div>
<div class="pkg-feature">查看完整历史记录</div>
<div class="pkg-feature">永久专属客服支持</div>
<div class="pkg-feature">优先体验新功能</div>
</div>
</div>
</div>
<button class="btn-pay" id="payBtn" onclick="goPay()" disabled>请先选择套餐</button>
<div class="notice">
支付成功后额度将自动到账,如有疑问请联系客服
</div>
<div class="notice">支付成功后额度将自动到账,如有疑问请联系客服</div>
<script>
let selected = null;
function selectPackage(pkg) {
selected = pkg;
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.className = pkg === 'permanent' ? 'btn-pay orange' : 'btn-pay';
}
function goPay() {
if (!selected) return;
// 跳转到支付确认页(创建订单)
window.location.href = '/payment/confirm?package=' + selected;
window.location.href = '/payment/page?package=' + selected;
}
</script>
</body>
</html>"# ;
</html>"## ;
Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( html ) )
}
// ===== 支付宝配置 =====
//
// 请在 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" # 正式
//
// ===== Handler: GET /payment/page — 创建订单并跳转 支付宝 =====
/// 提取 JWT token( 供支付页面使用)
fn ex tra ct_token ( req : & HttpRequest ) -> Option < String > {
req . headers ( )
. get ( " Authorization " ) ?
. to_str ( )
. ok ( ) ?
. strip_prefix ( " Bearer " )
. map ( | s | s . to_string ( ) )
#[ derive(Debug, Deserialize) ]
pub s tru ct PaymentPageQuery {
#[ serde(rename = " package " ) ]
pub package_ : String ,
}
/// GET /payment/page?package=xxx — 返回支付引导页面(用户扫码/浏览器打开)
#[ get( " /payment/page " ) ]
pub async fn payment_page (
req : HttpRequest ,
pool : web ::Data < PgPool > ,
app_state : web ::Data < AppState > ,
query : web ::Query < PaymentPageQuery > ,
) -> Result < HttpResponse , AppError > {
let token = extract_token ( & req ) . ok_or_else ( | | AppError ::Unauthorized ( " 未登录 " . to_string ( ) ) ) ? ;
// 验证 token
let claims = crate ::auth ::verify_token ( & token , & app_state . jwt_secret )
let claims = crate ::auth ::verify_token ( & token , & get_jwt_secret ( ) )
. map_err ( | _ | AppError ::Unauthorized ( " Token 无效 " . to_string ( ) ) ) ? ;
let pkg = get_package_info ( & query . package_ )
. ok_or_else ( | | AppError ::BadRequest ( " 无效的套餐类型 " . to_string ( ) ) ) ? ;
// 生成订单号
let order_no = Uuid ::new_v4 ( ) . to_string ( ) ;
let expires_at = pkg . days . map ( | d | Utc ::now ( ) + chrono ::Duration ::days ( d ) ) ;
// 创建待支付订单
db ::create_payment_order (
pool . get_ref ( ) ,
claims . user_id ,
@@ -314,127 +372,212 @@ pub async fn payment_page(
)
. await ? ;
// 构造支付页面 URL( 用户打开后展示支付宝收款码)
// 实际跳转地址由前端 outter 页面处理
let base_url = std ::env ::var ( " APP_BASE_URL " )
. unwrap_or_else ( | _ | " https://xmclassmate.top " . to_string ( ) ) ;
let _payment _url = format! (
" {} /payment/pay ?order_no= {} &package_type= {} ",
base_url , order_no , query . package_
) ;
. 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 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, '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_ ,
) ;
let Some ( config ) = AlipayConfig ::from_env ( ) else {
return Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( format_error_html ( " 支付配置不完整,请联系管理员 " , & order_no ) ) ) ;
} ;
Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( html ) )
let total_amount_str = format! ( " {:.2} " , pkg . amount as f64 / 100.0 ) ;
match call_alipay_trade_page_pay (
& config ,
& order_no ,
& total_amount_str ,
pkg . display_name ,
& notify_url ,
& return_url ,
)
. await
{
Ok ( form_html ) = > Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( form_html ) ) ,
Err ( e ) = > {
tracing ::error! ( " 支付宝下单失败: {} " , e ) ;
Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( format_error_html ( & e , & order_no ) ) )
}
}
}
// ===== Handler: GET /payment/pay — 直接支付接口 =====
#[ derive(Debug, Deserialize) ]
pub struct AlipayPayQuery {
pub order_no : String ,
pub package_type : String ,
}
/// GET /payment/pay — 支付宝电脑网站支付(返回支付表单或链接)
#[ get( " /payment/pay " ) ]
pub async fn alipay_trade _page_pay (
pub async fn alipay_pay _page (
req : HttpRequest ,
app_state : web ::Data < AppState > ,
query : web ::Query < AlipayPayQuery > ,
) -> Result < HttpResponse , AppError > {
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 ( ) ) ) ? ;
// TODO: 调用 alipay.trade.page.pay 接口
// 当前为占位实现,正式接入时替换为支付宝 SDK 调用
let pkg = get_package_info ( & query . package_type )
. ok_or_else ( | | AppError ::BadRequest ( " 无效的套餐类型 " . to_string ( ) ) ) ? ;
let base_url = std ::env ::var ( " APP_BASE_URL " )
. unwrap_or_else ( | _ | " https://dev.xmclassmate.top " . to_string ( ) ) ;
let notify_url = format! ( " {} /payment/notify " , base_url ) ;
let return_url = format! ( " {} /payment/success?order_no= {} " , base_url , query . order_no ) ;
let Some ( config ) = AlipayConfig ::from_env ( ) else {
return Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( format_error_html ( " 支付配置不完整,请联系管理员 " , & query . order_no ) ) ) ;
} ;
let total_amount_str = format! ( " {:.2} " , pkg . amount as f64 / 100.0 ) ;
match call_alipay_trade_page_pay (
& config ,
& query . order_no ,
& total_amount_str ,
pkg . display_name ,
& notify_url ,
& return_url ,
)
. await
{
Ok ( form_html ) = > Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( form_html ) ) ,
Err ( e ) = > {
tracing ::error! ( " 支付宝下单失败: {} " , e ) ;
Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( format_error_html ( & e , & query . order_no ) ) )
}
}
}
// ===== Handler: POST /payment/notify — 支付宝异步回调 =====
#[ derive(Debug, Deserialize) ]
pub struct AlipayNotify {
pub out_trade_no : String ,
pub trade_no : String ,
pub trade_status : String ,
pub total_amount : Option < String > ,
pub app_id : Option < String > ,
pub sign : Option < String > ,
}
#[ post( " /payment/notify " ) ]
pub async fn alipay_notify (
pool : web ::Data < PgPool > ,
body : web ::Form < AlipayNotify > ,
) -> HttpResponse {
let body = body . into_inner ( ) ;
tracing ::info! (
" 收到支付宝回调: out_trade_no={}, trade_status={} " ,
body . out_trade_no ,
body . trade_status
) ;
// 1. 检查交易状态
if body . trade_status ! = " TRADE_SUCCESS " & & body . trade_status ! = " TRADE_FINISHED " {
return HttpResponse ::Ok ( ) . body ( " success " ) ;
}
// 2. 确认订单(通过 order_no, 不校验 user_id)
match db ::confirm_payment_order_by_orderno ( pool . get_ref ( ) , & body . out_trade_no ) . await {
Ok ( _ ) = > {
tracing ::info! ( " 订单 {} 支付确认成功 " , body . out_trade_no ) ;
HttpResponse ::Ok ( ) . body ( " success " )
}
Err ( e ) = > {
tracing ::error! ( " 订单 {} 确认失败: {} " , body . out_trade_no , e ) ;
HttpResponse ::Ok ( ) . body ( " fail " )
}
}
}
// ===== Handler: GET /payment/success — 支付成功页面 =====
#[ derive(Debug, Deserialize) ]
pub struct AlipaySuccessQuery {
pub order_no : Option < String > ,
}
fn build_success_html ( order_no : & str ) -> String {
let green = " #52c41a " ;
let white = " white " ;
let html = format! (
r # "<!DOCTYPE html>
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>
<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: 480px;
margin: 40px auto; box-shadow: 0 2px 12px rgba(0,0,0,0.1); text-align: center ; }}
.alipay-logo {{ width: 80 px; margin-bottom: 20 px; }}
.title {{ font-size : 18 px; font-weight: 600 ; color: #333 ; margin-bot tom : 16px; }}
.inf o {{ font-size: 14 px; color: #666 ; margin-bot tom : 8px; text-align: left ; }}
.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; }}
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: 14 px; margin-bottom: 24 px; }}
.tip {{ background: #f0f7ff; border-radius: 8px; padding : 16 px; font-size: 13px ; color: #1677ff ; margin-top : 16px; }}
.order-n o {{ font-size: 12 px; color: #bbb ; margin-top : 12px ; }}
</style>
</head>
<body>
<div class="card">
<im g class="alipay-logo" src ="https ://cdn.antdv.com/logo.png" alt="支付宝" onerror="this.style.display='none' ">
<div class="title">支付宝收款页面</div >
<div class="info"><strong>订单号:</strong>{order_no}</div >
<div class="info"><strong>套餐:</strong>{package_type}</div >
<div class="placeholder" >
支付宝支付表单将在这里渲染<br >
(需配置 alipay_app_id 和私钥)
</div>
<div class="warn">⚠️ 请在 .env 或配置文件中设置支付宝相关配置</div>
<div class="notice">
支付完成后页面将自动跳转<br>
如未跳转,请手动关闭此页面
</div>
<sv g 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>"# ,
order_no = query . order_no ,
package_type = query . package_type ,
</html>"## ,
green , white , order_no
) ;
Ok ( HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( html ) )
html
}
fn format_error_html ( msg : & str , order_no : & str ) -> String {
format! (
r ## "<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>支付失败</title></head>
<body style="font-family:-apple-system;padding:40px;text-align:center">
<h2 style="color:#ff4d4f">支付页面生成失败</h2>
<p style="color:#666">{}</p>
<p style="color:#999;font-size:13px">订单号: {}</p>
<p><a href="/payment" style="color:#1677ff">返回重试</a></p>
</body>
</html>"## ,
msg , order_no
)
}
#[ get( " /payment/success " ) ]
pub async fn payment_success ( query : web ::Query < AlipaySuccessQuery > ) -> HttpResponse {
let order_no = query . order_no . as_deref ( ) . unwrap_or ( " " ) ;
let html = build_success_html ( order_no ) ;
HttpResponse ::Ok ( )
. content_type ( " text/html; charset=utf-8 " )
. body ( html )
}
// ===== 旧的 API Handler( 保持兼容) =====
/// POST /api/payment/create-order
#[ post( " /api/payment/create-order " ) ]
pub async fn create_order (
@@ -486,7 +629,8 @@ pub async fn mock_confirm(
) -> Result < HttpResponse , AppError > {
let user_id = claims . user_id ;
let expires_at = db ::confirm_payment_order ( pool . get_ref ( ) , & body . order_id , user_id ) . await ? ;
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 ,
@@ -505,7 +649,8 @@ pub async fn get_user_quota(
) -> Result < HttpResponse , AppError > {
let user_id = claims . user_id ;
let ( used , is_paid_active , paid_expires_at ) = db ::get_user_quota ( pool . get_ref ( ) , user_id ) . await ? ;
let ( used , is_paid_active , paid_expires_at ) =
db ::get_user_quota ( pool . get_ref ( ) , user_id ) . await ? ;
let limit : i64 = std ::env ::var ( " FREE_USER_DATA_LIMIT " )
. ok ( )
. and_then ( | v | v . parse ( ) . ok ( ) )
@@ -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 ,
}