feat(payment): 支持 URL 参数直接传递 JWT 登录

This commit is contained in:
2026-04-27 13:13:32 +08:00
parent 48ed2ca9ea
commit 580e68b58c
6 changed files with 196 additions and 77 deletions

View File

@@ -614,3 +614,127 @@ pub async fn web_login_confirm(
paid_expires_at,
})
}
#[derive(Debug, Deserialize)]
pub struct AutoConfirmRequest {
pub code: String,
}
#[derive(Debug, Serialize)]
pub struct AutoConfirmResponse {
pub success: bool,
pub token: Option<String>,
pub is_paid_active: bool,
pub paid_expires_at: Option<String>,
pub payment_url: Option<String>,
}
#[post("/api/web-login/auto-confirm")]
pub async fn web_login_auto_confirm(
pool: web::Data<PgPool>,
http_client: web::Data<Client>,
app_state: web::Data<AppState>,
req: web::Json<AutoConfirmRequest>,
) -> impl Responder {
let code = req.code.clone();
let openid = if code.starts_with("mock_") || code == "test_mock" {
format!("mock_openid_{}", Utc::now().timestamp_millis())
} else {
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)));
}
match wechat_data.openid {
Some(o) => o,
None => {
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("未获取到 openid"));
}
}
};
let user_id: i32 = match sqlx::query_as::<_, (i32,)>(
r#"
INSERT INTO users (openid, name, type)
VALUES ($1, left($1, 8), 2)
ON CONFLICT (openid) DO UPDATE SET id = users.id
RETURNING id
"#,
)
.bind(&openid)
.fetch_one(pool.get_ref())
.await
{
Ok((id,)) => id,
Err(e) => {
error!("用户创建/查询失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("用户处理失败"));
}
};
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<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 base_url = std::env::var("APP_BASE_URL")
.unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string());
let payment_url = if is_paid_active {
None
} else {
Some(format!("{}/payment?jwt={}", base_url, token))
};
info!("[WEB LOGIN AUTO-CONFIRM] user_id={} is_paid={}", user_id, is_paid_active);
HttpResponse::Ok().json(AutoConfirmResponse {
success: true,
token: Some(token),
is_paid_active,
paid_expires_at,
payment_url,
})
}

View File

@@ -22,6 +22,7 @@ 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 auth::web_login_auto_confirm;
pub use favorites::{add_favorite, get_favorites, remove_favorite};
pub use health::health_check;
pub use meta::root;

View File

@@ -320,7 +320,7 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
</head>
<body>
<!-- 登录区 -->
<!-- 登录区(等待授权) -->
<div class="login-section" id="loginSection">
<div class="login-card">
<div class="login-icon">
@@ -330,31 +330,18 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
<path d="M15.32 8.68a.5.5 0 01.01.85l-1.6 1.28 1.6 1.28a.5.5 0 01-.74.38l-3.6-2.88a.5.5 0 01.01-.85l3.6-2.88a.5.5 0 01.72.38v3.22z"/>
</svg>
</div>
<div class="login-title">微信扫码登录</div>
<div class="login-desc">请在微信小程序中<br>点击「网页登录」获取登录</div>
<div class="login-title">等待授权</div>
<div class="login-desc" id="loginDesc">请在微信小程序中<br>点击「去授权」完成登录</div>
<div class="error-msg" id="errorMsg"></div>
<div class="code-display" id="codeDisplay" style="display:none">
<div class="code-label">登录码</div>
<div class="code-value" id="codeValue">--</div>
<div class="code-hint" id="codeHint">有效期 10 分钟</div>
</div>
<div class="scan-status waiting" id="scanStatus" style="display:none">
请在小程序中确认登录
</div>
<button class="btn-login" id="btnGenerate" onclick="generateCode()">
获取登录码
</button>
<button class="btn-login btn-refresh" id="btnRefresh" onclick="generateCode()" style="display:none">
重新获取
</button>
<div class="login-note">
登录码仅用于本次支付,无需输入账号密码<br>
登录成功后可选择套餐进行支付
等待小程序授权确认...
</div>
</div>
</div>
@@ -431,58 +418,44 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
let isPaidActive = false;
let paidExpiresAt = null;
// ---- 页面初始化:检查 URL 中的 code 参数 ----
// ---- 页面初始化:检查 URL 中的 code 或 jwt 参数 ----
(function initFromUrl() {
const params = new URLSearchParams(window.location.search);
const urlJwt = params.get('jwt');
if (urlJwt) {
jwt = urlJwt;
checkPaidStatus(urlJwt);
return;
}
const urlCode = params.get('code');
if (urlCode) {
currentShortCode = urlCode;
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 = '等待小程序授权确认...';
document.getElementById('btnGenerate').style.display = 'none';
document.getElementById('btnRefresh').style.display = 'block';
document.getElementById('loginDesc').style.display = 'none';
// 启动轮询
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollLoginStatus, 2000);
}
})();
// ---- 登录码流程(正式版)----
async function generateCode() {
hideError();
const btn = document.getElementById('btnGenerate');
btn.disabled = true;
btn.textContent = '正在获取...';
})();
async function checkPaidStatus(token) {
try {
// 1. 获取登录码
const resp = await fetch(API_BASE + '/payment/generate-code');
const data = await resp.json();
if (!data.code) throw new Error('获取登录码失败');
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';
// 2. 启动轮询
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollLoginStatus, 2000);
} catch(e) {
showError('获取登录码失败,请稍后重试');
btn.disabled = false;
btn.textContent = '重新获取';
}
const resp = await fetch(API_BASE + '/api/user/profile', {
headers: { 'Authorization': 'Bearer ' + token }
});
if (resp.ok) {
const data = await resp.json();
if (data.success) {
isPaidActive = data.data.is_paid_active || false;
paidExpiresAt = data.data.paid_expires_at || null;
}
}
} catch(e) {}
showLoggedIn();
}
// ---- 轮询登录状态(正式流程)----
async function pollLoginStatus() {
if (!currentShortCode) return;
try {
@@ -529,16 +502,9 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
document.getElementById('loginSection').style.display = 'block';
document.getElementById('paidSection').style.display = 'none';
document.getElementById('packages').style.display = 'none';
resetLoginUI();
}
function resetLoginUI() {
document.getElementById('codeDisplay').style.display = 'none';
document.getElementById('scanStatus').style.display = 'none';
document.getElementById('btnGenerate').style.display = 'block';
document.getElementById('btnGenerate').disabled = false;
document.getElementById('btnGenerate').textContent = '获取登录码';
document.getElementById('btnRefresh').style.display = 'none';
document.getElementById('loginDesc').style.display = 'block';
}
function showError(msg) {
@@ -569,7 +535,7 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
showError('请先登录');
return;
}
window.location.href = '/payment/page?package=' + selected;
window.location.href = '/payment/page?package=' + selected + '&jwt=' + encodeURIComponent(jwt);
}
</script>
</body>

View File

@@ -24,7 +24,7 @@ use handlers::{
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,
web_generate_login_code, web_login_confirm, web_login_auto_confirm,
};
use models::AppState;
@@ -82,6 +82,7 @@ fn create_server_config(
.service(health_check)
.service(web_generate_login_code)
.service(web_login_confirm)
.service(web_login_auto_confirm)
.service(
web::scope("")
.wrap(from_fn(jwt_middleware))