feat: 新增支付页面接口,为接入支付宝做准备
- GET /payment: 套餐选择页(无需认证,外部浏览器打开) - GET /payment/page: 支付引导页(需 JWT,创建订单) - GET /payment/pay: 支付宝电脑网站支付占位页(需 JWT) - AppState 新增支付宝配置字段(可选) - afterbody.js: PDF下载增加状态提示和重试按钮
This commit is contained in:
1
.env
1
.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
|
||||
|
||||
32
AGENTS.md
32
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. 后端调用支付宝接口,返回支付表单或跳转链接
|
||||
|
||||
### 支付模式
|
||||
|
||||
| 模式 | 来源 | 处理方式 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PackageInfo> {
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /payment — 套餐选择页面(无需认证,外部浏览器打开)
|
||||
#[get("/payment")]
|
||||
pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
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;
|
||||
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: 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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<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>
|
||||
<div class="pkg-name">包月会员</div>
|
||||
<div class="pkg-price">¥9.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 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>
|
||||
<div class="pkg-desc">相当于每月 ¥4.9,性价比最高</div>
|
||||
<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-name">永久会员</div>
|
||||
<div class="pkg-price">¥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>
|
||||
|
||||
<script>
|
||||
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: '立即开通 - ¥9.9/月', yearly: '立即开通 - ¥59/年', permanent: '立即开通 - ¥199/终身' };
|
||||
btn.textContent = labels[pkg];
|
||||
btn.disabled = false;
|
||||
btn.className = pkg === 'permanent' ? 'btn-pay orange' : 'btn-pay';
|
||||
}
|
||||
|
||||
function goPay() {
|
||||
if (!selected) return;
|
||||
// 跳转到支付确认页(创建订单)
|
||||
window.location.href = '/payment/confirm?package=' + selected;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</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" # 正式
|
||||
//
|
||||
|
||||
/// 提取 JWT token(供支付页面使用)
|
||||
fn extract_token(req: &HttpRequest) -> Option<String> {
|
||||
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<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)
|
||||
.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#"<!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_,
|
||||
);
|
||||
|
||||
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<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)
|
||||
.map_err(|_| AppError::Unauthorized("Token 无效".to_string()))?;
|
||||
|
||||
// TODO: 调用 alipay.trade.page.pay 接口
|
||||
// 当前为占位实现,正式接入时替换为支付宝 SDK 调用
|
||||
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: 480px;
|
||||
margin: 40px auto; box-shadow: 0 2px 12px rgba(0,0,0,0.1); text-align: center; }}
|
||||
.alipay-logo {{ width: 80px; margin-bottom: 20px; }}
|
||||
.title {{ font-size: 18px; font-weight: 600; color: #333; margin-bottom: 16px; }}
|
||||
.info {{ font-size: 14px; color: #666; margin-bottom: 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; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<img 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>
|
||||
</div>
|
||||
</body>
|
||||
</html>"#,
|
||||
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,
|
||||
}
|
||||
})))
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 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,
|
||||
}
|
||||
|
||||
12
src/main.rs
12
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 接口
|
||||
|
||||
@@ -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<String>,
|
||||
pub alipay_private_key: Option<String>,
|
||||
pub alipay_alipay_public_key: Option<String>,
|
||||
pub alipay_gateway: Option<String>,
|
||||
}
|
||||
|
||||
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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
<style>
|
||||
#download-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 9999;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transition: opacity 0.3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
#download-overlay .msg {
|
||||
display: inline-block;
|
||||
background: rgba(0,0,0,0.75);
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
#download-overlay .retry-btn {
|
||||
display: inline-block;
|
||||
margin-left: 12px;
|
||||
background: #1677ff;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
</style>
|
||||
<span class="msg" id="dl-msg"></span>
|
||||
<span class="retry-btn" id="dl-retry" style="display:none" onclick="WeatherReport.retryDownload()">重新下载</span>
|
||||
`;
|
||||
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,
|
||||
};
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user