From 562528a1f037b7791393326fd8d3c7acb8e52151 Mon Sep 17 00:00:00 2001 From: Milky0217 Date: Fri, 17 Apr 2026 12:45:53 +0800 Subject: [PATCH] feat: implement payment order system (create-order, mock-confirm, get-quota) --- Cargo.lock | 2 + Cargo.toml | 1 + src/db.rs | 93 +++++++++++++++++++++++ src/handlers/mod.rs | 5 ++ src/handlers/payment.rs | 160 ++++++++++++++++++++++++++++++++++++++++ src/main.rs | 10 ++- src/models.rs | 28 +++++++ 7 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 src/handlers/payment.rs diff --git a/Cargo.lock b/Cargo.lock index b5ccc9d..373a73f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1980,6 +1980,7 @@ dependencies = [ "tracing", "tracing-appender", "tracing-subscriber", + "uuid", ] [[package]] @@ -2982,6 +2983,7 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f33196643e165781c20a5ead5582283a7dacbb87855d867fbc2df3f81eddc1be" dependencies = [ + "getrandom 0.3.3", "js-sys", "wasm-bindgen", ] diff --git a/Cargo.toml b/Cargo.toml index 5453fbe..ef458c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ reqwest = { version = "0.12.23", features=["json"]} serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.143" sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid"] } +uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["full"] } toml = "0.8" diff --git a/src/db.rs b/src/db.rs index 94ed775..0c9d38d 100644 --- a/src/db.rs +++ b/src/db.rs @@ -311,3 +311,96 @@ pub async fn update_user_profile( Err(e) => Err(format!("更新用户个人信息失败: {}", e)), } } + +// ===== 支付系统 DB 函数 ===== + +/// 创建待支付订单 +pub async fn create_payment_order( + pool: &PgPool, + user_id: i32, + order_no: &str, + package_type: &str, + amount: i32, + expires_at: Option>, +) -> Result<(), String> { + let query = r#" + INSERT INTO payment_orders (user_id, order_no, package_type, amount, expires_at) + VALUES ($1, $2, $3, $4, $5) + "#; + + sqlx::query(query) + .bind(user_id) + .bind(order_no) + .bind(package_type) + .bind(amount) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| format!("创建订单失败: {}", e))?; + + Ok(()) +} + +/// 确认订单支付(模拟):更新订单状态 + 激活用户付费 +/// +/// 返回该订单的 expires_at(永久会员为 None) +pub async fn confirm_payment_order( + pool: &PgPool, + order_no: &str, + user_id: i32, +) -> Result>, String> { + let row = sqlx::query_as::<_, (i32, String, Option>)>( + r#"SELECT user_id, status, expires_at FROM payment_orders WHERE order_no = $1"#, + ) + .bind(order_no) + .fetch_optional(pool) + .await + .map_err(|e| format!("查询订单失败: {}", e))?; + + let (order_user_id, status, expires_at) = match row { + Some(r) => r, + None => return Err("订单不存在".to_string()), + }; + + if order_user_id != user_id { + return Err("无权操作此订单".to_string()); + } + + if status != "pending" { + return Err("订单状态异常,无法确认支付".to_string()); + } + + sqlx::query( + r#"UPDATE payment_orders SET status = 'paid', paid_at = NOW() WHERE order_no = $1"#, + ) + .bind(order_no) + .execute(pool) + .await + .map_err(|e| 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(pool) + .await + .map_err(|e| format!("更新用户付费状态失败: {}", e))?; + + Ok(expires_at) +} + +/// 获取用户配额信息 +/// +/// 返回 (已用条数, 是否付费活跃, 到期时间) +pub async fn get_user_quota( + pool: &PgPool, + user_id: i32, +) -> Result<(i64, bool, Option>), String> { + let user = get_user_by_id(pool, user_id).await?; + let is_paid_active = user.is_paid + && (user.paid_expires_at.is_none() + || user.paid_expires_at.unwrap() > chrono::Utc::now()); + let used = count_user_weather_data(pool, user_id).await?; + Ok((used, is_paid_active, user.paid_expires_at)) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 57ae302..d63e5c4 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -2,6 +2,7 @@ pub mod admin; pub mod auth; pub mod health; +pub mod payment; pub mod static_files; pub mod user; pub mod weather; @@ -24,3 +25,7 @@ pub use weather::generate_temp_token_handler; pub use weather::get_weather_brief; pub use weather::get_weather_details; pub use weather::post_weather_data; + +pub use payment::create_order; +pub use payment::get_user_quota; +pub use payment::mock_confirm; diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs new file mode 100644 index 0000000..96949b2 --- /dev/null +++ b/src/handlers/payment.rs @@ -0,0 +1,160 @@ +// handlers/payment.rs — 支付相关处理器 +use actix_web::{get, post, web, HttpResponse, Responder}; +use chrono::Utc; +use sqlx::postgres::PgPool; +use tracing::error; +use uuid::Uuid; + +use crate::db; +use crate::models::{Claims, CreateOrderRequest, MockConfirmRequest}; + +struct PackageInfo { + amount: i32, + display_amount: &'static str, + display_name: &'static str, + days: Option, +} + +fn get_package_info(package_type: &str) -> Option { + match package_type { + "monthly" => Some(PackageInfo { + amount: 990, + display_amount: "¥9.9", + display_name: "包月会员", + days: Some(30), + }), + "yearly" => Some(PackageInfo { + amount: 5900, + display_amount: "¥59", + display_name: "包年会员", + days: Some(365), + }), + "permanent" => Some(PackageInfo { + amount: 19900, + display_amount: "¥199", + display_name: "永久会员", + days: None, + }), + _ => None, + } +} + +/// POST /api/payment/create-order +#[post("/api/payment/create-order")] +pub async fn create_order( + pool: web::Data, + claims: web::ReqData, + body: web::Json, +) -> impl Responder { + let user_id = claims.user_id; + + let pkg = match get_package_info(&body.package_type) { + Some(p) => p, + None => { + return HttpResponse::Ok().json(serde_json::json!({ + "success": false, + "errcode": 400, + "errmsg": "无效的套餐类型" + })); + } + }; + + let order_no = Uuid::new_v4().to_string(); + let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d)); + + match db::create_payment_order( + pool.get_ref(), + user_id, + &order_no, + &body.package_type, + pkg.amount, + expires_at, + ) + .await + { + Ok(_) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": { + "order_id": order_no, + "package_type": body.package_type, + "amount": pkg.amount, + "display_amount": pkg.display_amount, + "display_name": pkg.display_name, + "expires_at": expires_at, + } + })), + Err(e) => { + error!("创建订单失败: {}", e); + HttpResponse::Ok().json(serde_json::json!({ + "success": false, + "errcode": 500, + "errmsg": "创建订单失败,请重试" + })) + } + } +} + +/// POST /api/payment/mock-confirm +#[post("/api/payment/mock-confirm")] +pub async fn mock_confirm( + pool: web::Data, + claims: web::ReqData, + body: web::Json, +) -> impl Responder { + let user_id = claims.user_id; + + match db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await { + Ok(expires_at) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": { + "is_paid_active": true, + "paid_expires_at": expires_at, + } + })), + Err(e) => { + error!("确认支付失败: {}", e); + HttpResponse::Ok().json(serde_json::json!({ + "success": false, + "errcode": 400, + "errmsg": "支付确认失败,请重试" + })) + } + } +} + +/// GET /api/user/quota +#[get("/api/user/quota")] +pub async fn get_user_quota( + pool: web::Data, + claims: web::ReqData, +) -> impl Responder { + let user_id = claims.user_id; + + match db::get_user_quota(pool.get_ref(), user_id).await { + Ok((used, is_paid_active, paid_expires_at)) => { + let limit: i64 = std::env::var("FREE_USER_DATA_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(20); + + HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": { + "used": used, + "limit": limit, + "unlimited": is_paid_active, + "is_paid_active": is_paid_active, + "paid_expires_at": paid_expires_at, + } + })) + } + Err(e) => { + error!("获取配额信息失败: {}", e); + HttpResponse::Ok().json(serde_json::json!({ + "success": false, + "errcode": 500, + "errmsg": "获取配额信息失败" + })) + } + } +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 61e3dc9..82fd3d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,9 +17,10 @@ use auth::jwt_middleware; use config::AppConfig; use db::create_pool; use handlers::{ - admin_get_user, admin_update_user_payment, delete_weather, generate_temp_token_handler, - get_current_user_profile, get_weather_brief, get_weather_details, health_check, login, - post_weather_data, save_user_profile, serve_static_files, + admin_get_user, admin_update_user_payment, create_order, delete_weather, + generate_temp_token_handler, get_current_user_profile, get_user_quota, + get_weather_brief, get_weather_details, health_check, login, + mock_confirm, post_weather_data, save_user_profile, serve_static_files, }; use models::AppState; @@ -74,6 +75,9 @@ fn create_server_config( .service(save_user_profile) // #[put("/api/user/profile")] .service(admin_get_user) // #[get("/api/admin/users/{id}")] .service(admin_update_user_payment) // #[put("/api/admin/users/{id}/payment")] + .service(create_order) // #[post("/api/payment/create-order")] + .service(mock_confirm) // #[post("/api/payment/mock-confirm")] + .service(get_user_quota) // #[get("/api/user/quota")] ) // 健康检查 .service(health_check) diff --git a/src/models.rs b/src/models.rs index 8b06f5c..3f480df 100644 --- a/src/models.rs +++ b/src/models.rs @@ -327,3 +327,31 @@ impl AppState { }) } } + +// ===== 支付系统 ===== + +/// 创建订单请求体 +#[derive(Debug, Deserialize)] +pub struct CreateOrderRequest { + pub package_type: String, +} + +/// 模拟确认支付请求体 +#[derive(Debug, Deserialize)] +pub struct MockConfirmRequest { + pub order_id: String, +} + +/// payment_orders 表行结构 +#[derive(Debug, Serialize, FromRow)] +pub struct PaymentOrder { + pub id: i32, + pub user_id: i32, + pub order_no: String, + pub package_type: String, + pub amount: i32, + pub status: String, + pub paid_at: Option>, + pub expires_at: Option>, + pub created_at: chrono::DateTime, +}