feat: 添加访客登录 API (POST /api/guest-login) 支持网站独立支付
Some checks failed
Deploy Backend / deploy (push) Has been cancelled
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:
@@ -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 控制启用
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', {
|
||||||
|
|||||||
@@ -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("")
|
||||||
|
|||||||
Reference in New Issue
Block a user