feat(payment): 重构网页端支付登录流程——小程序生成临时码、网页端扫码确认
- 后端新增 /api/web-login/code (生成临时登录码) 和 /api/web-login/confirm (确认登录) - 后端新增 /payment/login-status (GET, 轮询登录状态) - payment_index 页面完全重写:未登录时显示微信扫码登录入口;登录后显示套餐或会员信息 - 沙箱环境下 /payment 通过 mock-login 跳过真实微信认证,方便测试
This commit is contained in:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user