From 31bafee0ab179b89319a8304c6216ca37c355b8b Mon Sep 17 00:00:00 2001 From: Milky0217 Date: Fri, 24 Apr 2026 09:43:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(payment):=20=E9=87=8D=E6=9E=84=E7=BD=91?= =?UTF-8?q?=E9=A1=B5=E7=AB=AF=E6=94=AF=E4=BB=98=E7=99=BB=E5=BD=95=E6=B5=81?= =?UTF-8?q?=E7=A8=8B=E2=80=94=E2=80=94=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=94=9F?= =?UTF-8?q?=E6=88=90=E4=B8=B4=E6=97=B6=E7=A0=81=E3=80=81=E7=BD=91=E9=A1=B5?= =?UTF-8?q?=E7=AB=AF=E6=89=AB=E7=A0=81=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端新增 /api/web-login/code (生成临时登录码) 和 /api/web-login/confirm (确认登录) - 后端新增 /payment/login-status (GET, 轮询登录状态) - payment_index 页面完全重写:未登录时显示微信扫码登录入口;登录后显示套餐或会员信息 - 沙箱环境下 /payment 通过 mock-login 跳过真实微信认证,方便测试 --- src/handlers/auth.rs | 247 +++++++++++++++++++++++++++++++++++++ src/handlers/mod.rs | 2 + src/handlers/payment.rs | 265 ++++++++++++++++++++++++++++++++++++---- src/main.rs | 5 +- 4 files changed, 495 insertions(+), 24 deletions(-) diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index a1359d7..15386d2 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -317,3 +317,250 @@ pub async fn mock_login( user_id, }) } + +// ===== 网页端微信扫码登录 ===== + +/// 生成网页端登录码(小程序调用) +/// POST /api/web-login/code +/// Body: { code: string } (小程序的微信登录 code) +#[derive(Debug, Deserialize)] +pub struct WebLoginCodeRequest { + /// 小程序 wx.login() 得到的 code + pub code: String, +} + +/// 登录码生成响应 +#[derive(Debug, Serialize)] +pub struct WebLoginCodeResponse { + pub success: bool, + /// 展示给用户的登录码,如 "ASD-XR7K2M" + pub display_code: String, + /// 轮询用的简短码(不含前缀,方便输入) + pub short_code: String, + pub expires_in: i64, // 有效期秒数 +} + +/// 生成登录码:后端先用 code 换 openid(不创建 JWT),存入临时表 +#[post("/api/web-login/code")] +pub async fn web_generate_login_code( + pool: web::Data, + req: web::Json, + http_client: web::Data, + app_state: web::Data, +) -> impl Responder { + let code = req.code.trim(); + + // 1. 用 code 换取 openid(和登录流程一样) + let url = format!( + "https://api.weixin.qq.com/sns/jscode2session?appid={}&secret={}&js_code={}&grant_type=authorization_code", + app_state.wechat_appid, app_state.wechat_secret, code + ); + + let wechat_response = match http_client.get(&url).send().await { + Ok(r) => r, + Err(e) => { + error!("微信 API 请求失败: {}", e); + return HttpResponse::InternalServerError() + .json(ErrorResponse::<()>::error("微信服务请求失败")); + } + }; + + let wechat_data: WeChatApiResponse = match wechat_response.json().await { + Ok(d) => d, + Err(e) => { + error!("微信响应解析失败: {}", e); + return HttpResponse::InternalServerError() + .json(ErrorResponse::<()>::error("微信响应解析失败")); + } + }; + + if let Some(errcode) = wechat_data.errcode { + let errmsg = wechat_data.errmsg.unwrap_or_default(); + error!("微信 code 换取 openid 失败: {} - {}", errcode, errmsg); + return HttpResponse::BadRequest() + .json(ErrorResponse::<()>::error(format!("微信登录失败: {}", errmsg))); + } + + let openid = match wechat_data.openid { + Some(o) => o, + None => { + return HttpResponse::InternalServerError() + .json(ErrorResponse::<()>::error("未获取到 openid")); + } + }; + + // 2. 清理该 openid 的旧登录码(避免重复使用) + if let Err(e) = sqlx::query("DELETE FROM web_login_codes WHERE openid = $1") + .bind(&openid) + .execute(pool.get_ref()) + .await + { + warn!("清理旧登录码失败(继续): {}", e); + } + + // 3. 生成随机登录码 + let random_part: String = (0..6) + .map(|_| { + let idx = (Utc::now().timestamp_millis() % 36) as u8; + let ch = if idx < 10 { b'0' + idx } else { b'A' + idx - 10 }; + ch as char + }) + .collect(); + // 避免前面几位太规律,多取几位时间戳混合 + let ts = Utc::now().timestamp(); + let short_code = format!( + "{:X}{}", + ts % 0xFFFF, + &random_part[..4] + ); + let display_code = format!("ASD-{}", &short_code[..7].to_uppercase()); + let code_for_db = display_code.clone(); + + // 4. 存入临时表(user_id 稍后确认时写入) + let expires_at = Utc::now() + chrono::Duration::minutes(10); + if let Err(e) = sqlx::query( + "INSERT INTO web_login_codes (code, openid, expires_at) VALUES ($1, $2, $3)", + ) + .bind(&code_for_db) + .bind(&openid) + .bind(expires_at) + .execute(pool.get_ref()) + .await + { + error!("保存登录码失败: {}", e); + return HttpResponse::InternalServerError() + .json(ErrorResponse::<()>::error("生成登录码失败")); + } + + info!("[WEB LOGIN CODE] openid={} code={}", openid, display_code); + HttpResponse::Ok().json(WebLoginCodeResponse { + success: true, + display_code, + short_code, + expires_in: 600, + }) +} + +/// 确认网页端登录(小程序调用) +/// POST /api/web-login/confirm +/// Body: { short_code: string } +/// 后端查询登录码,找到了就生成 JWT,更新 token 字段,删除登录码 +#[derive(Debug, Deserialize)] +pub struct WebLoginConfirmRequest { + pub short_code: String, +} + +/// 确认登录响应 +#[derive(Debug, Serialize)] +pub struct WebLoginConfirmResponse { + pub success: bool, + pub token: Option, + pub is_paid_active: bool, + pub paid_expires_at: Option, +} + +#[post("/api/web-login/confirm")] +pub async fn web_login_confirm( + pool: web::Data, + req: web::Json, + app_state: web::Data, +) -> impl Responder { + let short_code = req.short_code.trim(); + + // 查找登录码(模糊匹配,因为存入的是 ASD-XXXXXX 格式) + let record: Option<(String, String, chrono::DateTime, Option)> = sqlx::query_as( + "SELECT code, openid, expires_at, user_id FROM web_login_codes WHERE code LIKE $1", + ) + .bind(format!("%{}%", short_code)) + .fetch_optional(pool.get_ref()) + .await + .unwrap_or(None); + + let (code, openid, expires_at, existing_user_id) = match record { + Some(r) => r, + None => { + return HttpResponse::Ok().json(WebLoginConfirmResponse { + success: false, + token: None, + is_paid_active: false, + paid_expires_at: None, + }); + } + }; + + // 检查是否过期 + if Utc::now() > expires_at { + // 清理过期码 + let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") + .bind(&code) + .execute(pool.get_ref()) + .await; + return HttpResponse::Ok().json(WebLoginConfirmResponse { + success: false, + token: None, + is_paid_active: false, + paid_expires_at: None, + }); + } + + // 如果已有用户,验证并获取最新 is_paid 信息 + let user_id = if let Some(uid) = existing_user_id { + uid + } else { + // 创建或获取用户(UPSERT) + let name = format!("user_{}", &openid[..8.min(openid.len())]); + match sqlx::query_as::<_, (i32,)>( + r#"INSERT INTO users (openid, name, type) VALUES ($1, $2, 2) + ON CONFLICT (openid) DO UPDATE SET id = users.id RETURNING id"#, + ) + .bind(&openid) + .bind(&name) + .fetch_one(pool.get_ref()) + .await + { + Ok((uid,)) => uid, + Err(e) => { + error!("创建用户失败: {}", e); + return HttpResponse::InternalServerError() + .json(ErrorResponse::<()>::error("创建用户失败")); + } + } + }; + + // 生成 JWT + let token = match generate_token(user_id, &openid, 2, &app_state.jwt_secret) { + Ok(t) => t, + Err(e) => { + error!("JWT 生成失败: {}", e); + return HttpResponse::InternalServerError() + .json(ErrorResponse::<()>::error("生成令牌失败")); + } + }; + + // 查询付费状态 + let (is_paid_active, paid_expires_at): (bool, Option) = + match sqlx::query_as::<_, (bool, Option>)>( + "SELECT is_paid_active($1)", + ) + .bind(user_id) + .fetch_optional(pool.get_ref()) + .await + { + Ok(Some((active, expires))) => (active, expires.map(|e| e.to_rfc3339())), + _ => (false, None), + }; + + // 删除已使用的登录码 + let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") + .bind(&code) + .execute(pool.get_ref()) + .await; + + info!("[WEB LOGIN CONFIRM] user_id={} is_paid={}", user_id, is_paid_active); + HttpResponse::Ok().json(WebLoginConfirmResponse { + success: true, + token: Some(token), + is_paid_active, + paid_expires_at, + }) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index f730bbe..7b0c7cb 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -20,6 +20,8 @@ pub use admin::admin_update_user_payment; pub use auth::login; pub use auth::mock_login; pub use auth::refresh_token; +pub use auth::web_generate_login_code; +pub use auth::web_login_confirm; pub use favorites::{add_favorite, get_favorites, remove_favorite}; pub use health::health_check; pub use meta::root; diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 06c44ab..8d8687e 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -237,7 +237,7 @@ fn get_jwt_secret() -> String { std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()) } -// ===== Handler: GET /payment — 套餐选择页 ===== +// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) ===== #[get("/payment")] pub async fn payment_index() -> Result { @@ -249,36 +249,141 @@ pub async fn payment_index() -> Result { 开通会员 - 大气稳定度判定 -
-

开通会员

-

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

+ + + + + + + +
+
选择会员套餐
+
推荐
包月会员
@@ -289,6 +394,7 @@ pub async fn payment_index() -> Result {
查看完整历史记录
+
包年会员
¥59/年
@@ -298,6 +404,7 @@ pub async fn payment_index() -> Result {
查看完整历史记录
+
超值
永久会员
@@ -309,14 +416,122 @@ pub async fn payment_index() -> Result {
优先体验新功能
+ + + +
支付成功后额度将自动到账
- -
支付成功后额度将自动到账,如有疑问请联系客服
+ diff --git a/src/main.rs b/src/main.rs index 3be47d7..4290893 100644 --- a/src/main.rs +++ b/src/main.rs @@ -19,10 +19,11 @@ use db::create_pool; use handlers::{ 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, - 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_login, mock_confirm, payment_index, payment_page, payment_success, post_weather_data, refresh_token, remove_favorite, root, save_user_profile, serve_static_files, + web_generate_login_code, web_login_confirm, }; use models::AppState; @@ -73,6 +74,8 @@ fn create_server_config( // API 接口 .service(login) // #[post("/api/login")] .service(mock_login) // #[get("/api/mock-login")](沙箱测试用) + .service(web_generate_login_code) // #[post("/api/web-login/code")](网页端微信登录) + .service(web_login_confirm) // #[post("/api/web-login/confirm")](网页端登录确认) .service(refresh_token) // #[post("/api/refresh-token")](公开接口,无需认证) .service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token,公开接口) .service(health_check) // #[get("/health")](公开接口,无需认证)