diff --git a/Cargo.toml b/Cargo.toml index 7678737..21465d1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ include_dir = "0.7.4" jsonwebtoken = "9.3.1" log = "0.4.28" openssl = "0.10.73" +rand = "0.8" reqwest = { version = "0.12.23", features=["json"]} serde = { version = "1.0.219", features = ["derive"] } serde_json = "1.0.143" diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index 15386d2..ab1a22b 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -8,6 +8,7 @@ use tracing::{debug, error, info, warn}; use crate::auth::{generate_token, generate_refresh_token, verify_refresh_token}; use crate::db; use crate::error::ErrorResponse; +use crate::models::Claims; use crate::models::{ AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse, WeChatApiResponse, WeChatLoginRequest, @@ -345,10 +346,12 @@ pub struct WebLoginCodeResponse { pub async fn web_generate_login_code( pool: web::Data, req: web::Json, + claims: web::ReqData, http_client: web::Data, app_state: web::Data, ) -> impl Responder { let code = req.code.trim(); + let user_id = claims.user_id; // 从 JWT 获取当前用户 // 1. 用 code 换取 openid(和登录流程一样) let url = format!( @@ -401,12 +404,12 @@ pub async fn web_generate_login_code( // 3. 生成随机登录码 let random_part: String = (0..6) .map(|_| { + let chars = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 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 + let ch = chars[(idx % 36) as usize] as char; + ch }) .collect(); - // 避免前面几位太规律,多取几位时间戳混合 let ts = Utc::now().timestamp(); let short_code = format!( "{:X}{}", @@ -416,13 +419,14 @@ pub async fn web_generate_login_code( let display_code = format!("ASD-{}", &short_code[..7].to_uppercase()); let code_for_db = display_code.clone(); - // 4. 存入临时表(user_id 稍后确认时写入) + // 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)", + "INSERT INTO web_login_codes (code, openid, user_id, expires_at) VALUES ($1, $2, $3, $4)", ) .bind(&code_for_db) .bind(&openid) + .bind(user_id) .bind(expires_at) .execute(pool.get_ref()) .await @@ -432,7 +436,7 @@ pub async fn web_generate_login_code( .json(ErrorResponse::<()>::error("生成登录码失败")); } - info!("[WEB LOGIN CODE] openid={} code={}", openid, display_code); + info!("[WEB LOGIN CODE] user_id={} openid={} code={}", user_id, openid, display_code); HttpResponse::Ok().json(WebLoginCodeResponse { success: true, display_code, diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 7b0c7cb..88a0737 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -37,8 +37,10 @@ pub use weather::post_weather_data; pub use payment::alipay_notify; pub use payment::alipay_pay_page; pub use payment::create_order; +pub use payment::generate_code; pub use payment::get_user_quota; pub use payment::mock_confirm; pub use payment::payment_index; +pub use payment::payment_login_status; pub use payment::payment_page; pub use payment::payment_success; diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 8d8687e..e5c9e3b 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use sha2::Sha256; use sqlx::postgres::PgPool; use std::collections::BTreeMap; +use tracing::info; use uuid::Uuid; use crate::db; @@ -430,7 +431,7 @@ pub async fn payment_index() -> Result { let isPaidActive = false; let paidExpiresAt = null; - // ---- 登录码流程 ---- + // ---- 登录码流程(正式版)---- async function generateCode() { hideError(); const btn = document.getElementById('btnGenerate'); @@ -438,22 +439,23 @@ pub async fn payment_index() -> Result { btn.textContent = '正在获取...'; try { - // 1. 先获取小程序登录 code(静默的) - // 注意:这里需要小程序在外部浏览器无法做到, - // 所以采用简化方案:直接在后端生成临时码(沙箱模式下可跳过微信认证) - const resp = await fetch(API_BASE + '/api/mock-login'); + // 1. 获取登录码 + const resp = await fetch(API_BASE + '/payment/generate-code'); const data = await resp.json(); - if (!data.success) throw new Error('获取失败'); + if (!data.code) throw new Error('获取登录码失败'); - // 2. 用 mock JWT 查询登录状态(is_paid_active) - // 这种方式仅限沙箱,正式环境需要小程序配合 - // 这里直接用返回的 token 作为 jwt - jwt = data.token; - currentShortCode = 'MOCK' + Math.random().toString(36).slice(2,8).toUpperCase(); - isPaidActive = false; // mock 用户默认未付费 + currentShortCode = data.code; + document.getElementById('codeValue').textContent = currentShortCode; + document.getElementById('codeDisplay').style.display = 'block'; + document.getElementById('scanStatus').style.display = 'block'; + document.getElementById('scanStatus').className = 'scan-status waiting'; + document.getElementById('scanStatus').textContent = '请在微信小程序中确认登录'; + btn.style.display = 'none'; + document.getElementById('btnRefresh').style.display = 'block'; - // 显示登录成功(简化流程) - showLoggedIn(); + // 2. 启动轮询 + if (pollTimer) clearInterval(pollTimer); + pollTimer = setInterval(pollLoginStatus, 2000); } catch(e) { showError('获取登录码失败,请稍后重试'); btn.disabled = false; @@ -894,3 +896,155 @@ pub async fn get_user_quota( } }))) } + +/// GET /payment/login-status?code=ASD-XXXXX +/// 网页端轮询:查询登录码是否已被小程序确认 +#[derive(Debug, Deserialize)] +pub struct LoginStatusQuery { + pub code: String, +} + +#[derive(Debug, serde::Serialize)] +pub struct LoginStatusResponse { + pub success: bool, + pub confirmed: bool, + pub token: Option, + pub is_paid_active: bool, + pub paid_expires_at: Option, +} + +// ===== Handler: GET /payment/generate-code — 网页端生成登录码 ===== + +#[derive(Debug, serde::Serialize)] +pub struct GenerateCodeResponse { + pub code: String, + pub expires_in: i64, // 秒 +} + +/// 生成随机登录码(网页端专用,无需认证) +#[get("/payment/generate-code")] +pub async fn generate_code( + pool: web::Data, +) -> Result { + use rand::Rng; + + let mut rng = rand::thread_rng(); + let suffix: String = (0..6) + .map(|_| { + let idx = rng.gen_range(0..36); + if idx < 10 { (b'0' + idx) as char } else { (b'A' + idx - 10) as char } + }) + .collect(); + let code = format!("ASD-{}", suffix); + + let expires_at = Utc::now() + chrono::Duration::minutes(10); + + sqlx::query("INSERT INTO web_login_codes (code, expires_at) VALUES ($1, $2)") + .bind(&code) + .bind(expires_at) + .execute(pool.get_ref()) + .await + .map_err(|e| AppError::Internal(format!("建码失败: {}", e)))?; + + Ok(HttpResponse::Ok().json(GenerateCodeResponse { + code, + expires_in: 600, + })) +} + +#[get("/payment/login-status")] +pub async fn payment_login_status( + pool: web::Data, + query: web::Query, + app_state: web::Data, +) -> Result { + let code = query.code.trim(); + + // 查询登录码记录 + let record: Option<(String, chrono::DateTime, Option)> = + sqlx::query_as( + "SELECT code, expires_at, user_id FROM web_login_codes WHERE code = $1", + ) + .bind(code) + .fetch_optional(pool.get_ref()) + .await + .map_err(|e| AppError::Internal(format!("数据库查询失败: {}", e)))?; + + let (db_code, expires_at, user_id) = match record { + Some(r) => r, + None => { + return Ok(HttpResponse::Ok().json(LoginStatusResponse { + success: false, + confirmed: 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(&db_code) + .execute(pool.get_ref()) + .await; + return Ok(HttpResponse::Ok().json(LoginStatusResponse { + success: false, + confirmed: false, + token: None, + is_paid_active: false, + paid_expires_at: None, + })); + } + + // 登录码存在但还没被小程序确认(user_id 为空 = 刚生成,还没点确认) + // 这种情况返回 confirmed=false,继续轮询 + if user_id.is_none() { + return Ok(HttpResponse::Ok().json(LoginStatusResponse { + success: true, + confirmed: false, + token: None, + is_paid_active: false, + paid_expires_at: None, + })); + } + + // 已确认 → 获取用户信息和 openid,生成 JWT + let user_id = user_id.unwrap(); + + let (openid,): (String,) = sqlx::query_as("SELECT openid FROM users WHERE id = $1") + .bind(user_id) + .fetch_optional(pool.get_ref()) + .await + .map_err(|e| AppError::Internal(format!("数据库查询失败: {}", e)))? + .ok_or_else(|| AppError::Internal("用户不存在".to_string()))?; + + let token = crate::auth::generate_token(user_id, &openid, 2, &app_state.jwt_secret) + .map_err(|e| AppError::Internal(format!("生成令牌失败: {}", e)))?; + + 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(&db_code) + .execute(pool.get_ref()) + .await; + + info!("[LOGIN STATUS] user_id={} confirmed=true", user_id); + Ok(HttpResponse::Ok().json(LoginStatusResponse { + success: true, + confirmed: true, + token: Some(token), + is_paid_active, + paid_expires_at, + })) +} diff --git a/src/main.rs b/src/main.rs index 4290893..5a00c6d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,9 +18,9 @@ use config::AppConfig; 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, - health_check, login, mock_login, mock_confirm, payment_index, payment_page, payment_success, + create_order, delete_weather, generate_code, generate_temp_token_handler, get_current_user_profile, + get_favorites, get_user_quota, get_weather_brief, get_weather_details, + health_check, login, mock_login, mock_confirm, payment_index, payment_login_status, 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, @@ -65,17 +65,17 @@ fn create_server_config( .service(root) // #[get("/")] - 返回服务信息 // 支付页面(无需认证,外部浏览器访问) .service(payment_index) // GET /payment — 套餐选择页 + .service(generate_code) // GET /payment/generate-code — 网页生成登录码 .service(payment_page) // GET /payment/page(需 JWT) .service(payment_success) .service(alipay_pay_page) // GET /payment/pay(需 JWT) .service(alipay_notify) // POST /payment/notify(支付宝异步回调) + .service(payment_login_status) // GET /payment/login-status(网页轮询) // 静态文件(无需认证) .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) // 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")](公开接口,无需认证) @@ -97,6 +97,8 @@ fn create_server_config( .service(get_favorites) // #[get("/api/favorites")] .service(add_favorite) // #[post("/api/favorites/{id}")] .service(remove_favorite) // #[delete("/api/favorites/{id}")] + .service(web_generate_login_code) // #[post("/api/web-login/code")](需 JWT) + .service(web_login_confirm) // #[post("/api/web-login/confirm")](需 JWT) ) // 健康检查 .service(health_check)