feat(payment): 重构网页端支付登录流程——小程序生成临时码、网页端扫码确认

- 后端新增 /api/web-login/code (生成临时登录码) 和 /api/web-login/confirm (确认登录)
- 后端新增 /payment/login-status (GET, 轮询登录状态)
- payment_index 页面完全重写:未登录时显示微信扫码登录入口;登录后显示套餐或会员信息
- 沙箱环境下 /payment 通过 mock-login 跳过真实微信认证,方便测试
This commit is contained in:
2026-04-24 09:43:18 +08:00
parent 375f0affcf
commit 31bafee0ab
4 changed files with 495 additions and 24 deletions

View File

@@ -317,3 +317,250 @@ pub async fn mock_login(
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 的旧登录码(避免重复使用)
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 idx = (Utc::now().timestamp_millis() % 36) as u8;
let ch = if idx < 10 { b'0' + idx } else { b'A' + idx - 10 };
ch 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, expires_at) VALUES ($1, $2, $3)",
)
.bind(&code_for_db)
.bind(&openid)
.bind(expires_at)
.execute(pool.get_ref())
.await
{
error!("保存登录码失败: {}", e);
return HttpResponse::InternalServerError()
.json(ErrorResponse::<()>::error("生成登录码失败"));
}
info!("[WEB LOGIN CODE] openid={} code={}", openid, display_code);
HttpResponse::Ok().json(WebLoginCodeResponse {
success: true,
display_code,
short_code,
expires_in: 600,
})
}
/// 确认网页端登录(小程序调用)
/// POST /api/web-login/confirm
/// Body: { short_code: string }
/// 后端查询登录码,找到了就生成 JWT更新 token 字段,删除登录码
#[derive(Debug, Deserialize)]
pub struct WebLoginConfirmRequest {
pub short_code: String,
}
/// 确认登录响应
#[derive(Debug, Serialize)]
pub struct WebLoginConfirmResponse {
pub success: bool,
pub token: Option<String>,
pub is_paid_active: bool,
pub paid_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.short_code.trim();
// 查找登录码(模糊匹配,因为存入的是 ASD-XXXXXX 格式)
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 LIKE $1",
)
.bind(format!("%{}%", short_code))
.fetch_optional(pool.get_ref())
.await
.unwrap_or(None);
let (code, openid, expires_at, existing_user_id) = match record {
Some(r) => r,
None => {
return HttpResponse::Ok().json(WebLoginConfirmResponse {
success: 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(&code)
.execute(pool.get_ref())
.await;
return HttpResponse::Ok().json(WebLoginConfirmResponse {
success: false,
token: None,
is_paid_active: false,
paid_expires_at: None,
});
}
// 如果已有用户,验证并获取最新 is_paid 信息
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_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(&code)
.execute(pool.get_ref())
.await;
info!("[WEB LOGIN CONFIRM] user_id={} is_paid={}", user_id, is_paid_active);
HttpResponse::Ok().json(WebLoginConfirmResponse {
success: true,
token: Some(token),
is_paid_active,
paid_expires_at,
})
}