799 lines
27 KiB
Rust
799 lines
27 KiB
Rust
use actix_web::{web, HttpResponse, HttpRequest, Responder, post, get};
|
||
use chrono::{Utc, Duration};
|
||
use serde::{Deserialize, Serialize};
|
||
use reqwest::Client;
|
||
use sqlx::postgres::PgPool;
|
||
use tracing::{debug, error, info, warn};
|
||
|
||
use crate::auth::{generate_token, generate_refresh_token};
|
||
use crate::db;
|
||
use crate::error::ErrorResponse;
|
||
use crate::models::{
|
||
AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse,
|
||
WeChatApiResponse, WeChatLoginRequest,
|
||
};
|
||
use crate::rate_limiter::LOGIN_RATE_LIMITER;
|
||
|
||
#[post("/api/login")]
|
||
pub async fn login(
|
||
pool: web::Data<PgPool>,
|
||
req: web::Json<WeChatLoginRequest>,
|
||
http_client: web::Data<Client>,
|
||
app_state: web::Data<AppState>,
|
||
http_req: HttpRequest,
|
||
) -> impl Responder {
|
||
let client_ip = http_req
|
||
.headers()
|
||
.get("X-Forwarded-For")
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||
.or_else(|| {
|
||
http_req
|
||
.headers()
|
||
.get("X-Real-IP")
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.to_string())
|
||
})
|
||
.unwrap_or_else(|| "unknown".to_string());
|
||
|
||
if let Err(e) = LOGIN_RATE_LIMITER.check_rate_limit(&client_ip).await {
|
||
warn!("登录请求被限流: client_ip={}", client_ip);
|
||
return HttpResponse::TooManyRequests().json(ErrorResponse::<()>::error(e.to_string()));
|
||
}
|
||
|
||
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, req.code
|
||
);
|
||
debug!("准备调用微信API: {}", url.replace(&app_state.wechat_secret, "***"));
|
||
|
||
let wechat_response = match http_client.get(&url).send().await {
|
||
Ok(response) => {
|
||
debug!("微信API请求成功,状态码: {}", response.status());
|
||
response
|
||
}
|
||
Err(e) => {
|
||
error!("微信API请求失败: {}", e);
|
||
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("请求微信服务失败"));
|
||
}
|
||
};
|
||
|
||
let wechat_data: WeChatApiResponse = match wechat_response.json().await {
|
||
Ok(data) => {
|
||
debug!("微信API响应解析成功");
|
||
data
|
||
}
|
||
Err(e) => {
|
||
error!("微信API响应解析失败: {}", e);
|
||
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("解析微信响应失败"));
|
||
}
|
||
};
|
||
|
||
if let Some(errcode) = wechat_data.errcode {
|
||
warn!(
|
||
"微信登录验证失败 - 错误码: {}, 错误信息: {:?}",
|
||
errcode, wechat_data.errmsg
|
||
);
|
||
let errmsg = wechat_data.errmsg.unwrap_or_default();
|
||
return HttpResponse::BadRequest().json(ErrorResponse::<()>::error(format!(
|
||
"微信登录失败: {} {}",
|
||
errcode, errmsg
|
||
)));
|
||
}
|
||
|
||
let openid = match wechat_data.openid {
|
||
Some(id) => {
|
||
let masked_openid = format!("{}***{}", &id[0..4], &id[id.len() - 4..]);
|
||
debug!("获取到用户openid: {}", masked_openid);
|
||
id
|
||
}
|
||
None => {
|
||
error!("微信API响应中未包含openid");
|
||
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("微信API未返回openid"));
|
||
}
|
||
};
|
||
|
||
let upsert_query = r#"
|
||
INSERT INTO users (openid, name, type)
|
||
VALUES ($1, left($1, 8), 2)
|
||
ON CONFLICT (openid) DO UPDATE SET id = users.id
|
||
RETURNING id
|
||
"#;
|
||
|
||
let user_id = match sqlx::query_as::<_, (i32,)>(upsert_query)
|
||
.bind(&openid)
|
||
.fetch_one(pool.get_ref())
|
||
.await
|
||
{
|
||
Ok((id,)) => {
|
||
info!("用户登录/注册成功,用户ID: {}", 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) => {
|
||
debug!("JWT生成成功: {}", &t[0..8]);
|
||
t
|
||
}
|
||
Err(e) => {
|
||
error!("JWT生成失败: {}", e);
|
||
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成身份令牌失败"));
|
||
}
|
||
};
|
||
|
||
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 {
|
||
error!("保存 refresh_token 失败: {}", e);
|
||
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("保存刷新令牌失败"));
|
||
}
|
||
|
||
let login_response = LoginResponse::new(token, refresh_token_str);
|
||
HttpResponse::Ok().json(login_response)
|
||
}
|
||
|
||
#[post("/api/refresh-token")]
|
||
pub async fn refresh_token(
|
||
pool: web::Data<PgPool>,
|
||
req: web::Json<RefreshTokenRequest>,
|
||
app_state: web::Data<AppState>,
|
||
http_req: HttpRequest,
|
||
) -> impl Responder {
|
||
let client_ip = http_req
|
||
.headers()
|
||
.get("X-Forwarded-For")
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.split(',').next().unwrap_or(s).trim().to_string())
|
||
.or_else(|| {
|
||
http_req
|
||
.headers()
|
||
.get("X-Real-IP")
|
||
.and_then(|v| v.to_str().ok())
|
||
.map(|s| s.to_string())
|
||
})
|
||
.unwrap_or_else(|| {
|
||
http_req
|
||
.peer_addr()
|
||
.map(|a| a.ip().to_string())
|
||
.unwrap_or_else(|| "unknown".to_string())
|
||
});
|
||
if let Err(e) = LOGIN_RATE_LIMITER.check_rate_limit(&client_ip).await {
|
||
warn!("refresh_token 请求被限流: client_ip={}", client_ip);
|
||
return HttpResponse::TooManyRequests().json(ErrorResponse::<()>::error(e.to_string()));
|
||
}
|
||
|
||
let refresh_token = &req.refresh_token;
|
||
|
||
let refresh_token_record = match db::verify_refresh_token(pool.get_ref(), refresh_token).await {
|
||
Ok(record) => record,
|
||
Err(e) => {
|
||
warn!("Refresh token 验证失败: {}", e);
|
||
return HttpResponse::Unauthorized().json(ErrorResponse::<()>::error("Refresh token 无效或已过期"));
|
||
}
|
||
};
|
||
|
||
let user_id = refresh_token_record.user_id;
|
||
|
||
let user_query = r#"SELECT openid FROM users WHERE id = $1"#;
|
||
let openid: String = match sqlx::query_as::<_, (String,)>(user_query)
|
||
.bind(user_id)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
{
|
||
Ok(Some((openid,))) => openid,
|
||
Ok(None) => {
|
||
error!("用户不存在: {}", user_id);
|
||
return HttpResponse::Unauthorized().json(ErrorResponse::<()>::error("用户不存在"));
|
||
}
|
||
Err(e) => {
|
||
error!("查询用户失败: {}", e);
|
||
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("查询用户信息失败"));
|
||
}
|
||
};
|
||
|
||
let new_access_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 new_refresh_token = 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("生成刷新令牌失败"));
|
||
}
|
||
};
|
||
|
||
if let Err(e) = db::delete_specific_refresh_token(pool.get_ref(), refresh_token).await {
|
||
warn!("删除旧 refresh_token 失败(继续): {}", e);
|
||
}
|
||
|
||
let new_expires_at = Utc::now() + Duration::days(7);
|
||
if let Err(e) = db::create_refresh_token(pool.get_ref(), user_id, &new_refresh_token, new_expires_at).await {
|
||
error!("保存新 refresh_token 失败: {}", e);
|
||
return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("保存刷新令牌失败"));
|
||
}
|
||
|
||
let response = TokenRefreshResponse {
|
||
success: true,
|
||
token: new_access_token,
|
||
refresh_token: new_refresh_token,
|
||
};
|
||
HttpResponse::Ok().json(response)
|
||
}
|
||
|
||
/// 模拟登录请求(仅供测试/沙箱环境使用)
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct MockLoginRequest {
|
||
/// 指定用户 ID(如不指定则自动创建测试用户)
|
||
pub user_id: Option<i32>,
|
||
}
|
||
|
||
/// 模拟登录响应
|
||
#[derive(Debug, Serialize)]
|
||
pub struct MockLoginResponse {
|
||
pub success: bool,
|
||
pub token: String,
|
||
pub refresh_token: String,
|
||
pub user_id: i32,
|
||
}
|
||
|
||
/// GET /api/mock-login
|
||
/// 沙箱测试用:无需微信 code,直接返回 JWT
|
||
/// 通过环境变量 MOCK_LOGIN_ENABLED + MOCK_LOGIN_KEY 控制启用
|
||
/// MOCK_LOGIN_ENABLED=true 且未设 MOCK_LOGIN_KEY:任何请求可用(不安全)
|
||
/// MOCK_LOGIN_ENABLED=true 且设了 MOCK_LOGIN_KEY:需携带 X-Mock-Key 请求头
|
||
#[get("/api/mock-login")]
|
||
pub async fn mock_login(
|
||
pool: web::Data<PgPool>,
|
||
query: web::Query<MockLoginRequest>,
|
||
app_state: web::Data<AppState>,
|
||
req: HttpRequest,
|
||
) -> impl Responder {
|
||
// 检查是否启用
|
||
if std::env::var("MOCK_LOGIN_ENABLED").ok() != Some("true".to_string()) {
|
||
return HttpResponse::NotFound().json(ErrorResponse::<()>::error("模拟登录未启用"));
|
||
}
|
||
|
||
// 如果设置了 MOCK_LOGIN_KEY,验证请求头
|
||
if let Ok(key) = std::env::var("MOCK_LOGIN_KEY") && !key.is_empty() {
|
||
let header_key = req.headers()
|
||
.get("X-Mock-Key")
|
||
.and_then(|v| v.to_str().ok())
|
||
.unwrap_or("");
|
||
if header_key != key {
|
||
return HttpResponse::Forbidden().json(ErrorResponse::<()>::error("模拟登录密钥错误"));
|
||
}
|
||
}
|
||
|
||
let user_id = match query.user_id {
|
||
Some(id) => {
|
||
// 验证用户存在,同时检查是否管理员
|
||
match sqlx::query_as::<_, (i32, bool)>("SELECT id, is_admin FROM users WHERE id = $1" )
|
||
.bind(id)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
{
|
||
Ok(Some((uid, is_admin))) => {
|
||
if is_admin {
|
||
return HttpResponse::Forbidden()
|
||
.json(ErrorResponse::<()>::error("模拟登录不能用于管理员账户"));
|
||
}
|
||
uid
|
||
},
|
||
Ok(None) => {
|
||
return HttpResponse::BadRequest()
|
||
.json(ErrorResponse::<()>::error("用户不存在"));
|
||
}
|
||
Err(e) => {
|
||
error!("查询用户失败: {}", e);
|
||
return HttpResponse::InternalServerError()
|
||
.json(ErrorResponse::<()>::error("数据库错误"));
|
||
}
|
||
}
|
||
}
|
||
None => {
|
||
// 创建或获取测试用户
|
||
let test_openid = format!("mock_test_{}", Utc::now().timestamp_millis());
|
||
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(&test_openid)
|
||
.bind(&test_openid[..8.min(test_openid.len())])
|
||
.fetch_one(pool.get_ref())
|
||
.await
|
||
{
|
||
Ok((id,)) => id,
|
||
Err(e) => {
|
||
error!("创建测试用户失败: {}", e);
|
||
return HttpResponse::InternalServerError()
|
||
.json(ErrorResponse::<()>::error("创建测试用户失败"));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// 获取 openid
|
||
let openid: String = match sqlx::query_as::<_, (String,)>("SELECT openid FROM users WHERE id = $1")
|
||
.bind(user_id)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
{
|
||
Ok(Some((o,))) => o,
|
||
Ok(None) => {
|
||
return HttpResponse::InternalServerError()
|
||
.json(ErrorResponse::<()>::error("用户无 openid"));
|
||
}
|
||
Err(e) => {
|
||
error!("查询 openid 失败: {}", e);
|
||
return HttpResponse::InternalServerError()
|
||
.json(ErrorResponse::<()>::error("数据库错误"));
|
||
}
|
||
};
|
||
|
||
// 生成 token
|
||
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("生成令牌失败"));
|
||
}
|
||
};
|
||
|
||
// 生成 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);
|
||
}
|
||
|
||
info!("[MOCK LOGIN] user_id={} 登录成功", user_id);
|
||
HttpResponse::Ok().json(MockLoginResponse {
|
||
success: true,
|
||
token,
|
||
refresh_token: refresh_token_str,
|
||
user_id,
|
||
})
|
||
}
|
||
|
||
// ===== 网页端微信扫码登录 =====
|
||
|
||
/// 生成网页端登录码(小程序调用)
|
||
/// POST /api/web-login/code
|
||
/// Body: { code: string } (小程序的微信登录 code)
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct WebLoginCodeRequest {
|
||
/// 小程序 wx.login() 得到的 code
|
||
pub code: String,
|
||
}
|
||
|
||
/// 登录码生成响应
|
||
#[derive(Debug, Serialize)]
|
||
pub struct WebLoginCodeResponse {
|
||
pub success: bool,
|
||
/// 展示给用户的登录码,如 "ASD-XR7K2M"
|
||
pub display_code: String,
|
||
/// 轮询用的简短码(不含前缀,方便输入)
|
||
pub short_code: String,
|
||
pub expires_in: i64, // 有效期秒数
|
||
}
|
||
|
||
/// 生成登录码:后端先用 code 换 openid(不创建 JWT),存入临时表
|
||
#[post("/api/web-login/code")]
|
||
pub async fn web_generate_login_code(
|
||
pool: web::Data<PgPool>,
|
||
req: web::Json<WebLoginCodeRequest>,
|
||
http_client: web::Data<Client>,
|
||
app_state: web::Data<AppState>,
|
||
) -> impl Responder {
|
||
let code = req.code.trim();
|
||
|
||
// 1. 用 code 换取 openid(和登录流程一样)
|
||
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)));
|
||
}
|
||
|
||
let openid = match wechat_data.openid {
|
||
Some(o) => o,
|
||
None => {
|
||
return HttpResponse::InternalServerError()
|
||
.json(ErrorResponse::<()>::error("未获取到 openid"));
|
||
}
|
||
};
|
||
|
||
// 2. 通过 openid 查询或创建用户(不依赖 JWT)
|
||
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("用户处理失败"));
|
||
}
|
||
};
|
||
|
||
// 3. 清理该 openid 的旧登录码(避免重复使用)
|
||
if let Err(e) = sqlx::query("DELETE FROM web_login_codes WHERE openid = $1")
|
||
.bind(&openid)
|
||
.execute(pool.get_ref())
|
||
.await
|
||
{
|
||
warn!("清理旧登录码失败(继续): {}", e);
|
||
}
|
||
|
||
// 3. 生成随机登录码
|
||
let random_part: String = (0..6)
|
||
.map(|_| {
|
||
let chars = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||
let idx = (Utc::now().timestamp_millis() % 36) as u8;
|
||
|
||
chars[(idx % 36) as usize] as char
|
||
})
|
||
.collect();
|
||
let ts = Utc::now().timestamp();
|
||
let short_code = format!(
|
||
"{:X}{}",
|
||
ts % 0xFFFF,
|
||
&random_part[..4]
|
||
);
|
||
let display_code = format!("ASD-{}", &short_code[..7].to_uppercase());
|
||
let code_for_db = display_code.clone();
|
||
|
||
// 4. 存入临时表(关联 user_id)
|
||
let expires_at = Utc::now() + chrono::Duration::minutes(10);
|
||
if let Err(e) = sqlx::query(
|
||
"INSERT INTO web_login_codes (code, openid, user_id, expires_at) VALUES ($1, $2, $3, $4)",
|
||
)
|
||
.bind(&code_for_db)
|
||
.bind(&openid)
|
||
.bind(user_id)
|
||
.bind(expires_at)
|
||
.execute(pool.get_ref())
|
||
.await
|
||
{
|
||
error!("保存登录码失败: {}", e);
|
||
return HttpResponse::InternalServerError()
|
||
.json(ErrorResponse::<()>::error("生成登录码失败"));
|
||
}
|
||
|
||
info!("[WEB LOGIN CODE] user_id={} openid={} code={}", user_id, openid, display_code);
|
||
HttpResponse::Ok().json(WebLoginCodeResponse {
|
||
success: true,
|
||
display_code,
|
||
short_code,
|
||
expires_in: 600,
|
||
})
|
||
}
|
||
|
||
/// 确认网页端登录(小程序调用)
|
||
/// POST /api/web-login/confirm
|
||
/// Body: { code: string }
|
||
/// 后端查询登录码,找到了就生成 JWT,更新 token 字段,删除登录码
|
||
#[derive(Debug, Deserialize)]
|
||
pub struct WebLoginConfirmRequest {
|
||
pub code: String,
|
||
}
|
||
|
||
/// 确认登录响应
|
||
#[derive(Debug, Serialize)]
|
||
pub struct WebLoginConfirmResponse {
|
||
pub success: bool,
|
||
pub token: Option<String>,
|
||
pub is_active_member: bool,
|
||
pub membership_expires_at: Option<String>,
|
||
}
|
||
|
||
#[post("/api/web-login/confirm")]
|
||
pub async fn web_login_confirm(
|
||
pool: web::Data<PgPool>,
|
||
req: web::Json<WebLoginConfirmRequest>,
|
||
app_state: web::Data<AppState>,
|
||
) -> impl Responder {
|
||
let short_code = req.code.trim();
|
||
tracing::info!("[web_login_confirm] received code={}", short_code);
|
||
|
||
// 精确匹配登录码
|
||
let record: Option<(String, String, chrono::DateTime<chrono::Utc>, Option<i32>)> =
|
||
sqlx::query_as(
|
||
"SELECT code, openid, expires_at, user_id FROM web_login_codes WHERE code = $1",
|
||
)
|
||
.bind(short_code)
|
||
.fetch_optional(pool.get_ref())
|
||
.await
|
||
.inspect_err(|e| tracing::warn!("数据库查询登录码失败: {}", e))
|
||
.ok()
|
||
.flatten();
|
||
|
||
let (code, openid, expires_at, existing_user_id) = match record {
|
||
Some(r) => r,
|
||
None => {
|
||
return HttpResponse::Ok().json(WebLoginConfirmResponse {
|
||
success: false,
|
||
token: None,
|
||
is_active_member: false,
|
||
membership_expires_at: None,
|
||
});
|
||
}
|
||
};
|
||
|
||
// 检查是否过期
|
||
if Utc::now() > expires_at {
|
||
// 清理过期码
|
||
if let Err(e) = sqlx::query("DELETE FROM web_login_codes WHERE code = $1")
|
||
.bind(&code)
|
||
.execute(pool.get_ref())
|
||
.await
|
||
{
|
||
tracing::warn!("清理过期登录码失败: {}", e);
|
||
}
|
||
return HttpResponse::Ok().json(WebLoginConfirmResponse {
|
||
success: false,
|
||
token: None,
|
||
is_active_member: false,
|
||
membership_expires_at: None,
|
||
});
|
||
}
|
||
|
||
// 如果已有用户,验证并获取最新 is_member 信息
|
||
let user_id = if let Some(uid) = existing_user_id {
|
||
uid
|
||
} else {
|
||
// 创建或获取用户(UPSERT)
|
||
let name = format!("user_{}", &openid[..8.min(openid.len())]);
|
||
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(&openid)
|
||
.bind(&name)
|
||
.fetch_one(pool.get_ref())
|
||
.await
|
||
{
|
||
Ok((uid,)) => uid,
|
||
Err(e) => {
|
||
error!("创建用户失败: {}", e);
|
||
return HttpResponse::InternalServerError()
|
||
.json(ErrorResponse::<()>::error("创建用户失败"));
|
||
}
|
||
}
|
||
};
|
||
|
||
// 生成 JWT
|
||
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_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),
|
||
};
|
||
|
||
// 更新登录码记录,设置 token(而非删除,让轮询接口能查到)
|
||
if let Err(e) = sqlx::query("UPDATE web_login_codes SET token = $1 WHERE code = $2")
|
||
.bind(&token)
|
||
.bind(&code)
|
||
.execute(pool.get_ref())
|
||
.await
|
||
{
|
||
tracing::warn!("更新登录码 token 失败: {}", e);
|
||
}
|
||
|
||
info!("[WEB LOGIN CONFIRM] user_id={} is_member={}", user_id, is_active_member);
|
||
HttpResponse::Ok().json(WebLoginConfirmResponse {
|
||
success: true,
|
||
token: Some(token),
|
||
is_active_member,
|
||
membership_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_active_member: bool,
|
||
pub membership_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")
|
||
&& std::env::var("MOCK_LOGIN_ENABLED").ok() == Some("true".to_string())
|
||
{
|
||
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 paid_info = 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()
|
||
.flatten();
|
||
|
||
let (is_active_member, membership_expires_at): (bool, Option<String>) = match paid_info {
|
||
Some((is_member, expires)) => {
|
||
let active = is_member && expires.is_none_or(|e| e > Utc::now());
|
||
(active, expires.map(|e| e.to_rfc3339()))
|
||
}
|
||
None => (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_active_member {
|
||
None
|
||
} else {
|
||
Some(format!("{}/payment?jwt={}", base_url, token))
|
||
};
|
||
|
||
info!("[WEB LOGIN AUTO-CONFIRM] user_id={} is_member={}", user_id, is_active_member);
|
||
HttpResponse::Ok().json(AutoConfirmResponse {
|
||
success: true,
|
||
token: Some(token),
|
||
is_active_member,
|
||
membership_expires_at,
|
||
payment_url,
|
||
})
|
||
}
|