feat: 添加访客登录 API (POST /api/guest-login) 支持网站独立支付
Some checks failed
Deploy Backend / deploy (push) Has been cancelled

- 新增 web_guest_login handler: 创建访客用户(免微信 code),返回 JWT
- 路由注册在公共区(无需 JWT 认证)
- /payment 页面新增「访客登录」按钮,直接选择套餐支付
- 保留微信小程序登录作为备选
- 已部署到 dev.xmclassmate.top 验证通过
This commit is contained in:
2026-07-20 15:08:21 +08:00
parent 2c86219c82
commit a7ffd387d9
4 changed files with 158 additions and 7 deletions

View File

@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use reqwest::Client; use reqwest::Client;
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
use uuid::Uuid;
use crate::auth::{generate_token, generate_refresh_token}; use crate::auth::{generate_token, generate_refresh_token};
use crate::db; use crate::db;
@@ -253,6 +254,99 @@ pub struct MockLoginResponse {
pub user_id: i32, pub user_id: i32,
} }
// ===== 访客登录(免微信,直接网站支付用) =====
#[derive(Debug, Serialize)]
pub struct GuestLoginResponse {
pub success: bool,
pub token: String,
pub refresh_token: String,
pub user_id: i32,
pub is_active_member: bool,
pub membership_expires_at: Option<String>,
}
/// POST /api/guest-login
/// 网站独立支付用:无需微信 code直接创建访客用户并返回 JWT
#[post("/api/guest-login")]
pub async fn web_guest_login(
pool: web::Data<PgPool>,
app_state: web::Data<AppState>,
) -> impl Responder {
let guest_openid = format!("web_guest_{}", Uuid::new_v4());
let guest_name = format!("访客_{}", &guest_openid[10..18]);
// 创建访客用户
let user_id = 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(&guest_openid)
.bind(&guest_name)
.fetch_one(pool.get_ref())
.await
{
Ok((id,)) => id,
Err(e) => {
error!("创建访客用户失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("创建用户失败"));
}
};
// 生成 JWT
let token = match generate_token(user_id, &guest_openid, 2, &app_state.jwt_secret) {
Ok(t) => t,
Err(e) => {
error!("JWT 生成失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("生成令牌失败"));
}
};
// 生成 refresh_token
let refresh_token_str = match generate_refresh_token(user_id, &app_state.jwt_secret) {
Ok(t) => t,
Err(e) => {
error!("refresh_token 生成失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("生成刷新令牌失败"));
}
};
let refresh_expires_at = Utc::now() + Duration::days(7);
if let Err(e) =
db::create_refresh_token(pool.get_ref(), user_id, &refresh_token_str, refresh_expires_at).await
{
warn!("保存 refresh_token 失败(继续): {}", e);
}
// 查询付费状态
let (is_active_member, membership_expires_at): (bool, Option<String>) =
match sqlx::query_as::<_, (bool, Option<chrono::DateTime<chrono::Utc>>)>(
"SELECT is_member, membership_expires_at FROM users WHERE id = $1",
)
.bind(user_id)
.fetch_optional(pool.get_ref())
.await
{
Ok(Some((is_member, expires))) => {
let active = is_member && expires.is_none_or(|e| e > Utc::now());
(active, expires.map(|e| e.to_rfc3339()))
}
_ => (false, None),
};
info!("[GUEST LOGIN] user_id={} 访客登录成功", user_id);
HttpResponse::Ok().json(GuestLoginResponse {
success: true,
token,
refresh_token: refresh_token_str,
user_id,
is_active_member,
membership_expires_at,
})
}
/// GET /api/mock-login /// GET /api/mock-login
/// 沙箱测试用:无需微信 code直接返回 JWT /// 沙箱测试用:无需微信 code直接返回 JWT
/// 通过环境变量 MOCK_LOGIN_ENABLED + MOCK_LOGIN_KEY 控制启用 /// 通过环境变量 MOCK_LOGIN_ENABLED + MOCK_LOGIN_KEY 控制启用

View File

@@ -23,6 +23,7 @@ pub use admin::admin_force_confirm_order;
pub use admin::admin_refund_order; pub use admin::admin_refund_order;
pub use auth::login; pub use auth::login;
pub use auth::mock_login; pub use auth::mock_login;
pub use auth::web_guest_login;
pub use auth::refresh_token; pub use auth::refresh_token;
pub use auth::web_generate_login_code; pub use auth::web_generate_login_code;
pub use auth::web_login_confirm; pub use auth::web_login_confirm;

View File

@@ -307,9 +307,17 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
.btn-login { display: block; width: 100%; background: #07c160; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; margin-bottom: 16px; } .btn-login { display: block; width: 100%; background: #07c160; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; margin-bottom: 16px; }
.btn-login:disabled { background: #d9d9d9; cursor: not-allowed; } .btn-login:disabled { background: #d9d9d9; cursor: not-allowed; }
.btn-refresh { background: #fff; color: #666; border: 1px solid #d9d9d9; } .btn-refresh { background: #fff; color: #666; border: 1px solid #d9d9d9; }
.login-note { font-size: 12px; color: #bbb; margin-top: 12px; } .login-note { font-size: 12px; color: #bbb; margin-top: 12px; }
/* ===== 访客登录 ===== */
.guest-divider { display: flex; align-items: center; margin: 20px 0; color: #ccc; font-size: 13px; gap: 12px; }
.guest-divider::before, .guest-divider::after { content: ""; flex: 1; border-top: 1px solid #eee; }
.btn-guest { display: block; width: 100%; background: #fff; color: #333; border: 1px solid #d9d9d9; border-radius: 12px; padding: 14px; font-size: 15px; cursor: pointer; margin-bottom: 8px; }
.btn-guest:hover { border-color: #07c160; color: #07c160; }
.btn-guest:disabled { background: #f5f5f5; color: #ccc; cursor: not-allowed; border-color: #eee; }
.guest-hint { font-size: 12px; color: #bbb; text-align: center; margin-bottom: 4px; }
/* ===== 会员区域 ===== */ /* ===== 会员区域 ===== */
.paid-section { display: none; } .paid-section { display: none; }
.paid-status { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; padding: 36px 20px; text-align: center; } .paid-status { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; padding: 36px 20px; text-align: center; }
@@ -371,18 +379,27 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
<!-- 登录区(等待授权) --> <!-- 登录区(等待授权) -->
<div class="login-section" id="loginSection"> <div class="login-section" id="loginSection">
<div class="login-card"> <div class="login-card">
<div class="login-icon">🔒</div> <div class="login-icon">🔓</div>
<div class="login-title">等待授权</div> <div class="login-title" id="loginTitle">登录</div>
<div class="login-desc" id="loginDesc">请在微信小程序中<br>点击「去授权」完成登录</div> <div class="login-desc" id="loginDesc">选择登录方式:</div>
<div class="error-msg" id="errorMsg"></div> <div class="error-msg" id="errorMsg"></div>
<!-- 访客登录(网站独立支付) -->
<button class="btn-guest" id="guestBtn" onclick="guestLogin()">📱 访客登录 - 直接选择套餐</button>
<div class="guest-hint">无需微信,自动创建体验账号</div>
<div class="guest-divider">或</div>
<div class="code-display" id="codeDisplay" style="display:none"> <div class="code-display" id="codeDisplay" style="display:none">
<div class="code-label">登录码</div> <div class="code-label">登录码</div>
<div class="code-value" id="codeValue">--</div> <div class="code-value" id="codeValue">--</div>
</div> </div>
<button class="btn-login" id="mpBtn" onclick="showMpLogin()">从微信小程序登录</button>
<div class="login-note">需要已安装「小明计算助手」小程序</div>
<div class="scan-status waiting" id="scanStatus" style="display:none"> <div class="scan-status waiting" id="scanStatus" style="display:none">
等待小程序授权确认... 等待小程序授权确认...
</div> </div>
@@ -498,14 +515,52 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
currentShortCode = urlCode; currentShortCode = urlCode;
document.getElementById('codeValue').textContent = currentShortCode; document.getElementById('codeValue').textContent = currentShortCode;
document.getElementById('codeDisplay').style.display = 'block'; document.getElementById('codeDisplay').style.display = 'block';
document.getElementById('mpBtn').style.display = 'none';
document.getElementById('loginDesc').textContent = '请在微信小程序中输入此登录码:';
document.getElementById('scanStatus').style.display = 'block'; document.getElementById('scanStatus').style.display = 'block';
document.getElementById('loginDesc').style.display = 'none';
// 启动轮询 // 启动轮询
if (pollTimer) clearInterval(pollTimer); if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollLoginStatus, 2000); pollTimer = setInterval(pollLoginStatus, 2000);
} }
})(); })();
// ---- 访客登录(免微信) ----
async function guestLogin() {
var btn = document.getElementById('guestBtn');
btn.disabled = true;
btn.textContent = '登录中...';
try {
var resp = await fetch('/api/guest-login', { method: 'POST' });
var data = await resp.json();
if (data.success) {
jwt = data.token;
isPaidActive = data.is_active_member || false;
paidExpiresAt = data.membership_expires_at || null;
showLoggedIn();
} else {
showError('登录失败,请重试');
btn.disabled = false;
btn.textContent = '📱 访客登录 - 直接选择套餐';
}
} catch(e) {
showError('网络错误,请重试');
btn.disabled = false;
btn.textContent = '📱 访客登录 - 直接选择套餐';
}
}
// ---- 显示微信小程序登录 ----
function showMpLogin() {
document.getElementById('guestBtn').style.display = 'none';
document.querySelector('.guest-divider').style.display = 'none';
document.querySelector('.guest-hint').style.display = 'none';
document.getElementById('mpBtn').style.display = 'none';
document.getElementById('loginDesc').textContent = '请在微信小程序中\n点击「去授权」完成登录';
document.getElementById('codeDisplay').style.display = 'block';
document.getElementById('scanStatus').style.display = 'block';
document.getElementById('loginTitle').textContent = '等待授权';
}
async function checkPaidStatus(token) { async function checkPaidStatus(token) {
try { try {
const resp = await fetch(API_BASE + '/api/user/profile', { const resp = await fetch(API_BASE + '/api/user/profile', {

View File

@@ -25,7 +25,7 @@ use handlers::{
get_user_orders, health_check, list_notifications, login, mark_all_read, mark_notification_read, unread_count, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success, get_user_orders, health_check, list_notifications, login, mark_all_read, mark_notification_read, unread_count, mock_login, mock_confirm, sync_order, payment_index, payment_login_status, payment_page, payment_success,
post_weather_data, report_frontend_error, post_weather_data, report_frontend_error,
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_login_auto_confirm, web_generate_login_code, web_guest_login, web_login_confirm, web_login_auto_confirm,
}; };
use models::AppState; use models::AppState;
@@ -98,6 +98,7 @@ fn create_server_config(
.service(web_generate_login_code) .service(web_generate_login_code)
.service(web_login_confirm) .service(web_login_confirm)
.service(web_login_auto_confirm) .service(web_login_auto_confirm)
.service(web_guest_login) // POST /api/guest-login — 访客登录(免微信)
.service(report_frontend_error) // POST /api/sentry/events .service(report_frontend_error) // POST /api/sentry/events
.service( .service(
web::scope("") web::scope("")