From 8c9552fbfc360031d450582ac1f55e627c41fda1 Mon Sep 17 00:00:00 2001 From: Milky0217 Date: Thu, 23 Apr 2026 10:48:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E6=94=AF=E4=BB=98?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E6=8E=A5=E5=8F=A3=EF=BC=8C=E4=B8=BA=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=E6=94=AF=E4=BB=98=E5=AE=9D=E5=81=9A=E5=87=86=E5=A4=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /payment: 套餐选择页(无需认证,外部浏览器打开) - GET /payment/page: 支付引导页(需 JWT,创建订单) - GET /payment/pay: 支付宝电脑网站支付占位页(需 JWT) - AppState 新增支付宝配置字段(可选) - afterbody.js: PDF下载增加状态提示和重试按钮 --- .env | 1 + AGENTS.md | 32 +++ src/handlers/mod.rs | 3 + src/handlers/payment.rs | 419 +++++++++++++++++++++++++++++++++++++++- src/main.rs | 12 +- src/models.rs | 10 + static/js/afterbody.js | 110 ++++++++++- 7 files changed, 575 insertions(+), 12 deletions(-) diff --git a/.env b/.env index 886c205..f53b2fc 100644 --- a/.env +++ b/.env @@ -8,3 +8,4 @@ SSL_CERT_PATH=/etc/ssl/certs/full_chain.pem RUST_LOG=info APP_VERSION="0.2.0" FREE_USER_DATA_LIMIT=20 +SSH_SERVER=root@1panel-server diff --git a/AGENTS.md b/AGENTS.md index 6ffc37b..6ffa2c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -275,6 +275,23 @@ created_at TIMESTAMPTZ(创建时间) |------|------| | `POST /api/payment/create-order` | 创建订单 | | `POST /api/payment/mock-confirm` | 模拟支付确认(测试用) | +| `GET /payment/page` | 支付引导页面(生成订单并引导打开支付链接) | +| `GET /payment/pay` | 支付宝电脑网站支付页面(占位,接入支付宝后替换) | + +### 支付宝配置(可选) + +不配置则使用模拟支付;配置后启用真实支付宝支付: + +```env +# .env 或 config/*.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 # 正式 +``` + +> 正式环境必须使用 HTTPS(服务已支持 TLS) --- @@ -401,6 +418,21 @@ ORDER BY "desc" ## 支付系统 +### 支付流程 + +小程序内无法直接接入支付宝支付,采用以下方案: + +``` +小程序 → 后端 /payment/page → 重定向到 /payment/pay → 支付宝 +``` + +1. 用户在小程序选择套餐 → 点击「立即开通」 +2. 小程序调用 `POST /api/payment/create-order` 创建订单 +3. 小程序跳转 `outter` 页面,URL 指向 `/payment/page?package=xxx` +4. 后端验证 JWT,创建订单,返回自动跳转 HTML(利用 outter 页面复制链接提示用户在浏览器打开) +5. 用户在浏览器打开 `/payment/pay?order_no=xxx&package_type=xxx` +6. 后端调用支付宝接口,返回支付表单或跳转链接 + ### 支付模式 | 模式 | 来源 | 处理方式 | diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 388322e..fe98268 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -31,6 +31,9 @@ pub use weather::get_weather_brief; pub use weather::get_weather_details; pub use weather::post_weather_data; +pub use payment::alipay_trade_page_pay; pub use payment::create_order; pub use payment::get_user_quota; pub use payment::mock_confirm; +pub use payment::payment_index; +pub use payment::payment_page; diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 925eeb3..30b0dab 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -1,12 +1,13 @@ -// handlers/payment.rs — 支付相关处理器 -use actix_web::{get, post, web, HttpResponse}; +// handlers/payment.rs — 支付相关处理器(接入支付宝) +use actix_web::{get, post, web, HttpRequest, HttpResponse}; use chrono::Utc; use sqlx::postgres::PgPool; use uuid::Uuid; use crate::db; use crate::error::AppError; -use crate::models::{Claims, CreateOrderRequest, MockConfirmRequest}; +use crate::models::{AppState, Claims, CreateOrderRequest, MockConfirmRequest}; + struct PackageInfo { amount: i32, @@ -39,6 +40,401 @@ fn get_package_info(package_type: &str) -> Option { } } +/// GET /payment — 套餐选择页面(无需认证,外部浏览器打开) +#[get("/payment")] +pub async fn payment_index() -> Result { + let html = r#" + + + + +开通会员 - 大气稳定度判定 + + + +
+

开通会员

+

解锁无限检测额度,畅享全部功能

+
+ +
+
+
推荐
+
包月会员
+
¥9.9/月
+
适合短期使用需求
+
+
每月 500 次检测额度
+
查看完整历史记录
+
专属客服支持
+
+
+ +
+
包年会员
+
¥59/年
+
相当于每月 ¥4.9,性价比最高
+
+
每年 5000 次检测额度
+
查看完整历史记录
+
专属客服支持
+
+
+ +
+
超值
+
永久会员
+
¥199/终身
+
一次购买,终身享用
+
+
无限次检测额度
+
查看完整历史记录
+
永久专属客服支持
+
优先体验新功能
+
+
+
+ + + +
+ 支付成功后额度将自动到账,如有疑问请联系客服 +
+ + + +"#; + + 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" # 正式 +// + +/// 提取 JWT token(供支付页面使用) +fn extract_token(req: &HttpRequest) -> Option { + req.headers() + .get("Authorization")? + .to_str() + .ok()? + .strip_prefix("Bearer ") + .map(|s| s.to_string()) +} + +/// GET /payment/page?package=xxx — 返回支付引导页面(用户扫码/浏览器打开) +#[get("/payment/page")] +pub async fn payment_page( + req: HttpRequest, + pool: web::Data, + app_state: web::Data, + query: web::Query, +) -> Result { + let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; + + // 验证 token + let claims = crate::auth::verify_token(&token, &app_state.jwt_secret) + .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; + + let pkg = get_package_info(&query.package_) + .ok_or_else(|| AppError::BadRequest("无效的套餐类型".to_string()))?; + + // 生成订单号 + let order_no = Uuid::new_v4().to_string(); + let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d)); + + // 创建待支付订单 + db::create_payment_order( + pool.get_ref(), + claims.user_id, + &order_no, + &query.package_, + pkg.amount, + expires_at, + ) + .await?; + + // 构造支付页面 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_ + ); + + let html = format!( + r#" + + + + +支付页面 + + + +
+
订单待支付
+
{display_amount}
+
{display_name}
+
即将跳转至支付宝支付页面
+ + 打开支付宝支付 + +
订单号: {order_no}
+
+ + +"#, + display_amount = pkg.display_amount, + display_name = pkg.display_name, + order_no = order_no, + package_type = query.package_, + ); + + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html)) +} + +/// GET /payment/pay — 支付宝电脑网站支付(返回支付表单或链接) +#[get("/payment/pay")] +pub async fn alipay_trade_page_pay( + req: HttpRequest, + app_state: web::Data, + query: web::Query, +) -> Result { + let token = extract_token(&req).ok_or_else(|| AppError::Unauthorized("未登录".to_string()))?; + let _claims = crate::auth::verify_token(&token, &app_state.jwt_secret) + .map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?; + + // TODO: 调用 alipay.trade.page.pay 接口 + // 当前为占位实现,正式接入时替换为支付宝 SDK 调用 + let html = format!( + r#" + + + + +支付宝支付 + + + +
+ +
支付宝收款页面
+
订单号:{order_no}
+
套餐:{package_type}
+
+ 支付宝支付表单将在这里渲染
+ (需配置 alipay_app_id 和私钥) +
+
⚠️ 请在 .env 或配置文件中设置支付宝相关配置
+
+ 支付完成后页面将自动跳转
+ 如未跳转,请手动关闭此页面 +
+
+ +"#, + order_no = query.order_no, + package_type = query.package_type, + ); + + Ok(HttpResponse::Ok() + .content_type("text/html; charset=utf-8") + .body(html)) +} + /// POST /api/payment/create-order #[post("/api/payment/create-order")] pub async fn create_order( @@ -125,4 +521,19 @@ pub async fn get_user_quota( "paid_expires_at": paid_expires_at, } }))) -} \ No newline at end of file +} + +// ===== 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, +} diff --git a/src/main.rs b/src/main.rs index 1c38d5c..98f93cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,11 +17,11 @@ use auth::jwt_middleware; use config::AppConfig; use db::create_pool; use handlers::{ - admin_get_user, admin_update_user_payment, add_favorite, create_order, - delete_weather, generate_temp_token_handler, get_current_user_profile, + admin_get_user, admin_update_user_payment, add_favorite, alipay_trade_page_pay, + create_order, delete_weather, generate_temp_token_handler, get_current_user_profile, get_favorites, get_user_quota, get_weather_brief, get_weather_details, - health_check, login, mock_confirm, post_weather_data, refresh_token, - remove_favorite, root, save_user_profile, serve_static_files, + health_check, login, mock_confirm, payment_index, payment_page, post_weather_data, + refresh_token, remove_favorite, root, save_user_profile, serve_static_files, }; use models::AppState; @@ -61,6 +61,10 @@ fn create_server_config( .app_data(web::Data::new(app_state)) // 根路径(无需认证) .service(root) // #[get("/")] - 返回服务信息 + // 支付页面(无需认证,外部浏览器访问) + .service(payment_index) // GET /payment — 套餐选择页 + .service(payment_page) // GET /payment/page(需 JWT) + .service(alipay_trade_page_pay) // GET /payment/pay(需 JWT) // 静态文件(无需认证) .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) // API 接口 diff --git a/src/models.rs b/src/models.rs index bc01c88..9c01440 100644 --- a/src/models.rs +++ b/src/models.rs @@ -337,6 +337,11 @@ pub struct AppState { pub wechat_appid: String, pub wechat_secret: String, pub free_user_data_limit: i32, + // ===== 支付宝配置 ===== + pub alipay_app_id: Option, + pub alipay_private_key: Option, + pub alipay_alipay_public_key: Option, + pub alipay_gateway: Option, } impl AppState { @@ -351,6 +356,11 @@ impl AppState { free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT") .map(|v| v.parse().unwrap_or(20)) .unwrap_or(20), + // 支付宝配置(可选,未配置时使用模拟支付) + alipay_app_id: std::env::var("ALIPAY_APP_ID").ok(), + alipay_private_key: std::env::var("ALIPAY_PRIVATE_KEY").ok(), + alipay_alipay_public_key: std::env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok(), + alipay_gateway: std::env::var("ALIPAY_GATEWAY").ok(), }) } } diff --git a/static/js/afterbody.js b/static/js/afterbody.js index 6d0ff24..9f65724 100644 --- a/static/js/afterbody.js +++ b/static/js/afterbody.js @@ -232,17 +232,95 @@ const WeatherReport = (function () { html2pdf().set(options).from(htmlContent).save(); } + // 下载状态管理(供重试使用) + let downloadState = { + ready: false, + html: null, + filename: null, + }; + + // 下载提示浮层 + function showDownloadPrompt(type, message) { + let overlay = document.getElementById("download-overlay"); + if (!overlay) { + overlay = document.createElement("div"); + overlay.id = "download-overlay"; + overlay.innerHTML = ` + + + + `; + document.body.appendChild(overlay); + } + const msgEl = document.getElementById("dl-msg"); + const retryEl = document.getElementById("dl-retry"); + if (msgEl) msgEl.textContent = message; + if (retryEl) { + if (type === "loading" || type === "success") { + retryEl.style.display = "none"; + } else { + retryEl.style.display = "inline-block"; + } + } + } + + function hideDownloadPrompt() { + const overlay = document.getElementById("download-overlay"); + if (overlay) { + overlay.style.opacity = "0"; + setTimeout(() => { if (overlay) overlay.remove(); }, 300); + } + } + + function retryDownload() { + if (!downloadState.ready || !downloadState.html || !downloadState.filename) { + showDownloadPrompt("error", "数据未准备好,请刷新页面重试"); + return; + } + downloadPDF(downloadState.html, downloadState.filename); + showDownloadPrompt("success", "正在重新下载,请稍候..."); + } + function init() { if (typeof window.weatherData === "undefined") { console.error("错误:window.weatherData 未定义"); + showDownloadPrompt("error", "数据加载失败,请刷新页面重试"); return; } const data = window.weatherData; console.log("【调试】从后端获取的 weatherData:", data); - console.log("【调试】inspectionType:", data.inspectionType); - console.log("【调试】assignmentNumber:", data.assignmentNumber); - console.log("【调试】title:", data.title); const versionEl = document.querySelector(".version"); if (versionEl) { @@ -251,7 +329,30 @@ const WeatherReport = (function () { const html = buildReportHTML(data); const filename = `${data.id}-${data.version || "unknown"}.pdf`; - downloadPDF(html, filename); + + // 保存下载状态,供重试使用 + downloadState.ready = true; + downloadState.html = html; + downloadState.filename = filename; + + // 显示下载中提示 + showDownloadPrompt("loading", "正在生成 PDF,请稍候..."); + + // 延迟执行下载,让用户看到提示 + setTimeout(() => { + downloadPDF(html, filename); + // 显示成功提示,3秒后消失 + showDownloadPrompt("success", "PDF 开始下载..."); + setTimeout(() => hideDownloadPrompt(), 3000); + // 额外等3秒,如果提示已消失(下载成功)则不再显示重试 + // 如果提示还在(下载失败),则替换为重试按钮 + setTimeout(() => { + const overlay = document.getElementById("download-overlay"); + if (overlay) { + showDownloadPrompt("retry", "下载未开始,请点击重试"); + } + }, 3300); + }, 300); } document.addEventListener("DOMContentLoaded", function () { @@ -293,5 +394,6 @@ const WeatherReport = (function () { init, toggleFormula, showNotification, + retryDownload, }; })();