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:
2026-04-24 10:51:06 +08:00
parent 31bafee0ab
commit 034aa66ed8
5 changed files with 188 additions and 25 deletions

View File

@@ -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<HttpResponse, AppError> {
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<HttpResponse, AppError> {
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<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,
}))
}