feat(payment): 实现网页端登录码流程(无需微信登录)
- 新增 GET /payment/generate-code:网页端生成 ASD-XXXXXX 登录码 - 新增 GET /payment/login-status:网页轮询查询登录码确认状态 - web_generate_login_code 和 web_login_confirm 增加 JWT 保护 - payment_index JS 改为真实调用 generate-code 并轮询 - 添加 rand crate 依赖
This commit is contained in:
@@ -16,6 +16,7 @@ include_dir = "0.7.4"
|
|||||||
jsonwebtoken = "9.3.1"
|
jsonwebtoken = "9.3.1"
|
||||||
log = "0.4.28"
|
log = "0.4.28"
|
||||||
openssl = "0.10.73"
|
openssl = "0.10.73"
|
||||||
|
rand = "0.8"
|
||||||
reqwest = { version = "0.12.23", features=["json"]}
|
reqwest = { version = "0.12.23", features=["json"]}
|
||||||
serde = { version = "1.0.219", features = ["derive"] }
|
serde = { version = "1.0.219", features = ["derive"] }
|
||||||
serde_json = "1.0.143"
|
serde_json = "1.0.143"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use tracing::{debug, error, info, warn};
|
|||||||
use crate::auth::{generate_token, generate_refresh_token, verify_refresh_token};
|
use crate::auth::{generate_token, generate_refresh_token, verify_refresh_token};
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::error::ErrorResponse;
|
use crate::error::ErrorResponse;
|
||||||
|
use crate::models::Claims;
|
||||||
use crate::models::{
|
use crate::models::{
|
||||||
AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse,
|
AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse,
|
||||||
WeChatApiResponse, WeChatLoginRequest,
|
WeChatApiResponse, WeChatLoginRequest,
|
||||||
@@ -345,10 +346,12 @@ pub struct WebLoginCodeResponse {
|
|||||||
pub async fn web_generate_login_code(
|
pub async fn web_generate_login_code(
|
||||||
pool: web::Data<PgPool>,
|
pool: web::Data<PgPool>,
|
||||||
req: web::Json<WebLoginCodeRequest>,
|
req: web::Json<WebLoginCodeRequest>,
|
||||||
|
claims: web::ReqData<Claims>,
|
||||||
http_client: web::Data<Client>,
|
http_client: web::Data<Client>,
|
||||||
app_state: web::Data<AppState>,
|
app_state: web::Data<AppState>,
|
||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let code = req.code.trim();
|
let code = req.code.trim();
|
||||||
|
let user_id = claims.user_id; // 从 JWT 获取当前用户
|
||||||
|
|
||||||
// 1. 用 code 换取 openid(和登录流程一样)
|
// 1. 用 code 换取 openid(和登录流程一样)
|
||||||
let url = format!(
|
let url = format!(
|
||||||
@@ -401,12 +404,12 @@ pub async fn web_generate_login_code(
|
|||||||
// 3. 生成随机登录码
|
// 3. 生成随机登录码
|
||||||
let random_part: String = (0..6)
|
let random_part: String = (0..6)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
|
let chars = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
let idx = (Utc::now().timestamp_millis() % 36) as u8;
|
let idx = (Utc::now().timestamp_millis() % 36) as u8;
|
||||||
let ch = if idx < 10 { b'0' + idx } else { b'A' + idx - 10 };
|
let ch = chars[(idx % 36) as usize] as char;
|
||||||
ch as char
|
ch
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
// 避免前面几位太规律,多取几位时间戳混合
|
|
||||||
let ts = Utc::now().timestamp();
|
let ts = Utc::now().timestamp();
|
||||||
let short_code = format!(
|
let short_code = format!(
|
||||||
"{:X}{}",
|
"{:X}{}",
|
||||||
@@ -416,13 +419,14 @@ pub async fn web_generate_login_code(
|
|||||||
let display_code = format!("ASD-{}", &short_code[..7].to_uppercase());
|
let display_code = format!("ASD-{}", &short_code[..7].to_uppercase());
|
||||||
let code_for_db = display_code.clone();
|
let code_for_db = display_code.clone();
|
||||||
|
|
||||||
// 4. 存入临时表(user_id 稍后确认时写入)
|
// 4. 存入临时表(关联 user_id)
|
||||||
let expires_at = Utc::now() + chrono::Duration::minutes(10);
|
let expires_at = Utc::now() + chrono::Duration::minutes(10);
|
||||||
if let Err(e) = sqlx::query(
|
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(&code_for_db)
|
||||||
.bind(&openid)
|
.bind(&openid)
|
||||||
|
.bind(user_id)
|
||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
.execute(pool.get_ref())
|
.execute(pool.get_ref())
|
||||||
.await
|
.await
|
||||||
@@ -432,7 +436,7 @@ pub async fn web_generate_login_code(
|
|||||||
.json(ErrorResponse::<()>::error("生成登录码失败"));
|
.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 {
|
HttpResponse::Ok().json(WebLoginCodeResponse {
|
||||||
success: true,
|
success: true,
|
||||||
display_code,
|
display_code,
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ pub use weather::post_weather_data;
|
|||||||
pub use payment::alipay_notify;
|
pub use payment::alipay_notify;
|
||||||
pub use payment::alipay_pay_page;
|
pub use payment::alipay_pay_page;
|
||||||
pub use payment::create_order;
|
pub use payment::create_order;
|
||||||
|
pub use payment::generate_code;
|
||||||
pub use payment::get_user_quota;
|
pub use payment::get_user_quota;
|
||||||
pub use payment::mock_confirm;
|
pub use payment::mock_confirm;
|
||||||
pub use payment::payment_index;
|
pub use payment::payment_index;
|
||||||
|
pub use payment::payment_login_status;
|
||||||
pub use payment::payment_page;
|
pub use payment::payment_page;
|
||||||
pub use payment::payment_success;
|
pub use payment::payment_success;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use serde::Deserialize;
|
|||||||
use sha2::Sha256;
|
use sha2::Sha256;
|
||||||
use sqlx::postgres::PgPool;
|
use sqlx::postgres::PgPool;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
use tracing::info;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
@@ -430,7 +431,7 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
|||||||
let isPaidActive = false;
|
let isPaidActive = false;
|
||||||
let paidExpiresAt = null;
|
let paidExpiresAt = null;
|
||||||
|
|
||||||
// ---- 登录码流程 ----
|
// ---- 登录码流程(正式版)----
|
||||||
async function generateCode() {
|
async function generateCode() {
|
||||||
hideError();
|
hideError();
|
||||||
const btn = document.getElementById('btnGenerate');
|
const btn = document.getElementById('btnGenerate');
|
||||||
@@ -438,22 +439,23 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
|||||||
btn.textContent = '正在获取...';
|
btn.textContent = '正在获取...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 先获取小程序登录 code(静默的)
|
// 1. 获取登录码
|
||||||
// 注意:这里需要小程序在外部浏览器无法做到,
|
const resp = await fetch(API_BASE + '/payment/generate-code');
|
||||||
// 所以采用简化方案:直接在后端生成临时码(沙箱模式下可跳过微信认证)
|
|
||||||
const resp = await fetch(API_BASE + '/api/mock-login');
|
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (!data.success) throw new Error('获取失败');
|
if (!data.code) throw new Error('获取登录码失败');
|
||||||
|
|
||||||
// 2. 用 mock JWT 查询登录状态(is_paid_active)
|
currentShortCode = data.code;
|
||||||
// 这种方式仅限沙箱,正式环境需要小程序配合
|
document.getElementById('codeValue').textContent = currentShortCode;
|
||||||
// 这里直接用返回的 token 作为 jwt
|
document.getElementById('codeDisplay').style.display = 'block';
|
||||||
jwt = data.token;
|
document.getElementById('scanStatus').style.display = 'block';
|
||||||
currentShortCode = 'MOCK' + Math.random().toString(36).slice(2,8).toUpperCase();
|
document.getElementById('scanStatus').className = 'scan-status waiting';
|
||||||
isPaidActive = false; // mock 用户默认未付费
|
document.getElementById('scanStatus').textContent = '请在微信小程序中确认登录';
|
||||||
|
btn.style.display = 'none';
|
||||||
|
document.getElementById('btnRefresh').style.display = 'block';
|
||||||
|
|
||||||
// 显示登录成功(简化流程)
|
// 2. 启动轮询
|
||||||
showLoggedIn();
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollTimer = setInterval(pollLoginStatus, 2000);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
showError('获取登录码失败,请稍后重试');
|
showError('获取登录码失败,请稍后重试');
|
||||||
btn.disabled = false;
|
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<String>,
|
||||||
|
pub is_paid_active: bool,
|
||||||
|
pub paid_expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 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<PgPool>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
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<PgPool>,
|
||||||
|
query: web::Query<LoginStatusQuery>,
|
||||||
|
app_state: web::Data<crate::models::AppState>,
|
||||||
|
) -> Result<HttpResponse, AppError> {
|
||||||
|
let code = query.code.trim();
|
||||||
|
|
||||||
|
// 查询登录码记录
|
||||||
|
let record: Option<(String, chrono::DateTime<chrono::Utc>, Option<i32>)> =
|
||||||
|
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<String>) =
|
||||||
|
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>("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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|||||||
12
src/main.rs
12
src/main.rs
@@ -18,9 +18,9 @@ use config::AppConfig;
|
|||||||
use db::create_pool;
|
use db::create_pool;
|
||||||
use handlers::{
|
use handlers::{
|
||||||
admin_get_user, admin_update_user_payment, add_favorite, alipay_notify, alipay_pay_page,
|
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,
|
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,
|
get_favorites, get_user_quota, get_weather_brief, get_weather_details,
|
||||||
health_check, login, mock_login, mock_confirm, payment_index, payment_page, payment_success,
|
health_check, login, mock_login, mock_confirm, payment_index, payment_login_status, payment_page, payment_success,
|
||||||
post_weather_data,
|
post_weather_data,
|
||||||
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
|
refresh_token, remove_favorite, root, save_user_profile, serve_static_files,
|
||||||
web_generate_login_code, web_login_confirm,
|
web_generate_login_code, web_login_confirm,
|
||||||
@@ -65,17 +65,17 @@ fn create_server_config(
|
|||||||
.service(root) // #[get("/")] - 返回服务信息
|
.service(root) // #[get("/")] - 返回服务信息
|
||||||
// 支付页面(无需认证,外部浏览器访问)
|
// 支付页面(无需认证,外部浏览器访问)
|
||||||
.service(payment_index) // GET /payment — 套餐选择页
|
.service(payment_index) // GET /payment — 套餐选择页
|
||||||
|
.service(generate_code) // GET /payment/generate-code — 网页生成登录码
|
||||||
.service(payment_page) // GET /payment/page(需 JWT)
|
.service(payment_page) // GET /payment/page(需 JWT)
|
||||||
.service(payment_success)
|
.service(payment_success)
|
||||||
.service(alipay_pay_page) // GET /payment/pay(需 JWT)
|
.service(alipay_pay_page) // GET /payment/pay(需 JWT)
|
||||||
.service(alipay_notify) // POST /payment/notify(支付宝异步回调)
|
.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)))
|
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
|
||||||
// API 接口
|
// API 接口
|
||||||
.service(login) // #[post("/api/login")]
|
.service(login) // #[post("/api/login")]
|
||||||
.service(mock_login) // #[get("/api/mock-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(refresh_token) // #[post("/api/refresh-token")](公开接口,无需认证)
|
||||||
.service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token,公开接口)
|
.service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token,公开接口)
|
||||||
.service(health_check) // #[get("/health")](公开接口,无需认证)
|
.service(health_check) // #[get("/health")](公开接口,无需认证)
|
||||||
@@ -97,6 +97,8 @@ fn create_server_config(
|
|||||||
.service(get_favorites) // #[get("/api/favorites")]
|
.service(get_favorites) // #[get("/api/favorites")]
|
||||||
.service(add_favorite) // #[post("/api/favorites/{id}")]
|
.service(add_favorite) // #[post("/api/favorites/{id}")]
|
||||||
.service(remove_favorite) // #[delete("/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)
|
.service(health_check)
|
||||||
|
|||||||
Reference in New Issue
Block a user