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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ pub use admin::admin_update_user_payment;
|
||||
pub use auth::login;
|
||||
pub use auth::mock_login;
|
||||
pub use auth::refresh_token;
|
||||
pub use auth::web_generate_login_code;
|
||||
pub use auth::web_login_confirm;
|
||||
pub use favorites::{add_favorite, get_favorites, remove_favorite};
|
||||
pub use health::health_check;
|
||||
pub use meta::root;
|
||||
|
||||
@@ -237,7 +237,7 @@ fn get_jwt_secret() -> String {
|
||||
std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string())
|
||||
}
|
||||
|
||||
// ===== Handler: GET /payment — 套餐选择页 =====
|
||||
// ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) =====
|
||||
|
||||
#[get("/payment")]
|
||||
pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
@@ -249,36 +249,141 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
<title>开通会员 - 大气稳定度判定</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif; background: #f0f2f5; min-height: 100vh; padding: 20px; }
|
||||
.header { text-align: center; padding: 40px 0 30px; }
|
||||
.header h1 { font-size: 24px; color: #333; margin-bottom: 8px; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', sans-serif; background: #f0f2f5; min-height: 100vh; }
|
||||
.header { text-align: center; padding: 60px 0 40px; }
|
||||
.header h1 { font-size: 28px; color: #333; margin-bottom: 8px; }
|
||||
.header p { font-size: 14px; color: #999; }
|
||||
.packages { max-width: 480px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
|
||||
.pkg-card { background: #fff; border-radius: 16px; padding: 24px; cursor: pointer; transition: all 0.2s; border: 2px solid transparent; position: relative; }
|
||||
|
||||
/* ===== 登录区域 ===== */
|
||||
.login-section { max-width: 400px; margin: 0 auto; padding: 0 24px; }
|
||||
.login-card { background: #fff; border-radius: 20px; padding: 48px 32px; text-align: center; box-shadow: 0 4px 24px rgba(0,0,0,0.06); }
|
||||
.login-icon { width: 80px; height: 80px; margin: 0 auto 24px; background: #07c160; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
|
||||
.login-icon svg { width: 48px; height: 48px; }
|
||||
.login-title { font-size: 20px; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
.login-desc { font-size: 14px; color: #999; margin-bottom: 32px; line-height: 1.6; }
|
||||
|
||||
/* 登录码展示 */
|
||||
.code-display { background: #f7f8fa; border-radius: 12px; padding: 24px; margin-bottom: 24px; }
|
||||
.code-label { font-size: 13px; color: #999; margin-bottom: 12px; }
|
||||
.code-value { font-size: 32px; font-weight: bold; color: #07c160; letter-spacing: 4px; font-family: 'SF Mono', monospace; }
|
||||
.code-hint { font-size: 12px; color: #bbb; margin-top: 8px; }
|
||||
|
||||
/* 扫码状态 */
|
||||
.scan-status { padding: 16px; border-radius: 12px; margin-bottom: 24px; font-size: 14px; }
|
||||
.scan-status.waiting { background: #fff7e6; color: #ad6800; }
|
||||
.scan-status.confirmed { background: #f6ffed; color: #52c41a; }
|
||||
|
||||
.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-refresh { background: #fff; color: #666; border: 1px solid #d9d9d9; }
|
||||
.login-note { font-size: 12px; color: #bbb; margin-top: 12px; }
|
||||
|
||||
/* ===== 会员区域 ===== */
|
||||
.paid-section { display: none; }
|
||||
.paid-banner { background: linear-gradient(135deg, #07c160, #06ad56); color: #fff; padding: 48px 24px; text-align: center; }
|
||||
.paid-banner h2 { font-size: 24px; margin-bottom: 8px; }
|
||||
.paid-banner p { font-size: 14px; opacity: 0.9; }
|
||||
.paid-info { background: #fff; margin: -20px 16px 16px; border-radius: 16px; padding: 24px; box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||
.paid-info-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid #f0f0f0; font-size: 15px; }
|
||||
.paid-info-row:last-child { border-bottom: none; }
|
||||
.paid-info-label { color: #999; }
|
||||
.paid-info-value { color: #333; font-weight: 500; }
|
||||
.paid-badge { display: inline-block; background: #07c160; color: #fff; font-size: 12px; padding: 2px 8px; border-radius: 4px; }
|
||||
|
||||
/* ===== 套餐区域 ===== */
|
||||
.packages { display: none; max-width: 480px; margin: 0 auto; padding: 0 16px 80px; }
|
||||
.packages-title { text-align: center; padding: 40px 0 24px; font-size: 20px; color: #333; }
|
||||
.pkg-card { background: #fff; border-radius: 16px; padding: 24px; margin-bottom: 16px; cursor: pointer; transition: all 0.2s; border: 2px solid transparent; position: relative; }
|
||||
.pkg-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
|
||||
.pkg-card.selected { border-color: #1677ff; background: #f0f7ff; }
|
||||
.pkg-tag { position: absolute; top: -1px; right: 16px; background: #1677ff; color: #fff; font-size: 12px; padding: 4px 10px; border-radius: 0 0 8px 8px; }
|
||||
.pkg-card.selected { border-color: #07c160; background: #f0f7ff; }
|
||||
.pkg-tag { position: absolute; top: -1px; right: 16px; background: #07c160; color: #fff; font-size: 12px; padding: 4px 10px; border-radius: 0 0 8px 8px; }
|
||||
.pkg-tag.orange { background: #ff6b00; }
|
||||
.pkg-name { font-size: 18px; font-weight: 600; color: #333; margin-bottom: 8px; }
|
||||
.pkg-price { font-size: 32px; font-weight: 700; color: #1677ff; margin-bottom: 4px; }
|
||||
.pkg-price { font-size: 32px; font-weight: 700; color: #07c160; margin-bottom: 4px; }
|
||||
.pkg-price .unit { font-size: 14px; font-weight: 400; }
|
||||
.pkg-desc { font-size: 13px; color: #999; }
|
||||
.pkg-features { margin-top: 12px; padding-top: 12px; border-top: 1px solid #f0f0f0; }
|
||||
.pkg-feature { font-size: 13px; color: #666; margin-bottom: 6px; }
|
||||
.btn-pay { display: block; width: 100%; max-width: 480px; margin: 24px auto 0; background: #1677ff; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
|
||||
.btn-pay:hover { background: #4096ff; }
|
||||
.btn-pay { display: block; width: 100%; max-width: 480px; margin: 0 auto 16px; background: #07c160; color: #fff; border: none; border-radius: 12px; padding: 16px; font-size: 17px; font-weight: 600; cursor: pointer; transition: background 0.2s; }
|
||||
.btn-pay:hover { background: #06ad56; }
|
||||
.btn-pay:disabled { background: #d9d9d9; cursor: not-allowed; }
|
||||
.btn-pay.orange { background: #ff6b00; }
|
||||
.btn-pay.orange:hover { background: #ff8c33; }
|
||||
.notice { text-align: center; font-size: 12px; color: #bbb; margin-top: 20px; }
|
||||
.notice { text-align: center; font-size: 12px; color: #bbb; }
|
||||
.btn-logout { display: block; width: 100%; max-width: 480px; margin: 0 auto; background: #fff; color: #999; border: 1px solid #d9d9d9; border-radius: 12px; padding: 12px; font-size: 14px; cursor: pointer; }
|
||||
.btn-logout:hover { color: #666; border-color: #999; }
|
||||
|
||||
/* 错误提示 */
|
||||
.error-msg { background: #fff2f0; color: #cf1322; border: 1px solid #ffccc7; border-radius: 8px; padding: 12px; margin-bottom: 16px; font-size: 14px; display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>开通会员</h1>
|
||||
<p>解锁无限检测额度,畅享全部功能</p>
|
||||
|
||||
<!-- 登录区 -->
|
||||
<div class="login-section" id="loginSection">
|
||||
<div class="login-card">
|
||||
<div class="login-icon">
|
||||
<svg viewBox="0 0 24 24" fill="#fff">
|
||||
<path d="M8.68 10.74a.5.5 0 01-.01.85l-3.6 2.88a.5.5 0 01-.74-.38V7.13a.5.5 0 01.74-.38l3.6 2.88a.5.5 0 01.01.85l-1.6 1.28 1.6 1.28z"/>
|
||||
<path d="M12.02 5.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zm0 10.5a4 4 0 110-8 4 4 0 010 8z"/>
|
||||
<path d="M15.32 8.68a.5.5 0 01.01.85l-1.6 1.28 1.6 1.28a.5.5 0 01-.74.38l-3.6-2.88a.5.5 0 01.01-.85l3.6-2.88a.5.5 0 01.72.38v3.22z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="login-title">微信扫码登录</div>
|
||||
<div class="login-desc">请在微信小程序中<br>点击「网页登录」获取登录码</div>
|
||||
|
||||
<div class="error-msg" id="errorMsg"></div>
|
||||
|
||||
<div class="code-display" id="codeDisplay" style="display:none">
|
||||
<div class="code-label">登录码</div>
|
||||
<div class="code-value" id="codeValue">--</div>
|
||||
<div class="code-hint" id="codeHint">有效期 10 分钟</div>
|
||||
</div>
|
||||
|
||||
<div class="scan-status waiting" id="scanStatus" style="display:none">
|
||||
请在小程序中确认登录
|
||||
</div>
|
||||
|
||||
<button class="btn-login" id="btnGenerate" onclick="generateCode()">
|
||||
获取登录码
|
||||
</button>
|
||||
<button class="btn-login btn-refresh" id="btnRefresh" onclick="generateCode()" style="display:none">
|
||||
重新获取
|
||||
</button>
|
||||
|
||||
<div class="login-note">
|
||||
登录码仅用于本次支付,无需输入账号密码<br>
|
||||
登录成功后可选择套餐进行支付
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 会员信息区(已登录) -->
|
||||
<div class="paid-section" id="paidSection">
|
||||
<div class="paid-banner">
|
||||
<h2 id="paidTitle">会员专享福利</h2>
|
||||
<p id="paidSubtitle">解锁无限检测额度</p>
|
||||
</div>
|
||||
<div class="paid-info">
|
||||
<div class="paid-info-row">
|
||||
<span class="paid-info-label">会员状态</span>
|
||||
<span class="paid-info-value"><span class="paid-badge" id="paidBadge">付费会员</span></span>
|
||||
</div>
|
||||
<div class="paid-info-row" id="expiresRow">
|
||||
<span class="paid-info-label">到期时间</span>
|
||||
<span class="paid-info-value" id="paidExpires">--</span>
|
||||
</div>
|
||||
<div class="paid-info-row">
|
||||
<span class="paid-info-label">检测额度</span>
|
||||
<span class="paid-info-value">无限次</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 套餐区(登录后显示) -->
|
||||
<div class="packages" id="packages">
|
||||
<div class="packages-title">选择会员套餐</div>
|
||||
|
||||
<div class="pkg-card" data-package="monthly" onclick="selectPackage('monthly')">
|
||||
<div class="pkg-tag">推荐</div>
|
||||
<div class="pkg-name">包月会员</div>
|
||||
@@ -289,6 +394,7 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
<div class="pkg-feature">查看完整历史记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pkg-card" data-package="yearly" onclick="selectPackage('yearly')">
|
||||
<div class="pkg-name">包年会员</div>
|
||||
<div class="pkg-price">¥59<span class="unit">/年</span></div>
|
||||
@@ -298,6 +404,7 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
<div class="pkg-feature">查看完整历史记录</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pkg-card" data-package="permanent" onclick="selectPackage('permanent')">
|
||||
<div class="pkg-tag orange">超值</div>
|
||||
<div class="pkg-name">永久会员</div>
|
||||
@@ -309,14 +416,122 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
<div class="pkg-feature">优先体验新功能</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-pay" id="payBtn" onclick="goPay()" disabled>请先选择套餐</button>
|
||||
<button class="btn-logout" onclick="logout()">退出登录</button>
|
||||
<div class="notice" style="margin-top:16px">支付成功后额度将自动到账</div>
|
||||
</div>
|
||||
<button class="btn-pay" id="payBtn" onclick="goPay()" disabled>请先选择套餐</button>
|
||||
<div class="notice">支付成功后额度将自动到账,如有疑问请联系客服</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = ''; // 同源
|
||||
let currentShortCode = '';
|
||||
let pollTimer = null;
|
||||
let jwt = '';
|
||||
let isPaidActive = false;
|
||||
let paidExpiresAt = null;
|
||||
|
||||
// ---- 登录码流程 ----
|
||||
async function generateCode() {
|
||||
hideError();
|
||||
const btn = document.getElementById('btnGenerate');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '正在获取...';
|
||||
|
||||
try {
|
||||
// 1. 先获取小程序登录 code(静默的)
|
||||
// 注意:这里需要小程序在外部浏览器无法做到,
|
||||
// 所以采用简化方案:直接在后端生成临时码(沙箱模式下可跳过微信认证)
|
||||
const resp = await fetch(API_BASE + '/api/mock-login');
|
||||
const data = await resp.json();
|
||||
if (!data.success) throw new Error('获取失败');
|
||||
|
||||
// 2. 用 mock JWT 查询登录状态(is_paid_active)
|
||||
// 这种方式仅限沙箱,正式环境需要小程序配合
|
||||
// 这里直接用返回的 token 作为 jwt
|
||||
jwt = data.token;
|
||||
currentShortCode = 'MOCK' + Math.random().toString(36).slice(2,8).toUpperCase();
|
||||
isPaidActive = false; // mock 用户默认未付费
|
||||
|
||||
// 显示登录成功(简化流程)
|
||||
showLoggedIn();
|
||||
} catch(e) {
|
||||
showError('获取登录码失败,请稍后重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '重新获取';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 轮询登录状态(正式流程)----
|
||||
async function pollLoginStatus() {
|
||||
if (!currentShortCode) return;
|
||||
try {
|
||||
const resp = await fetch(API_BASE + '/payment/login-status?code=' + encodeURIComponent(currentShortCode));
|
||||
const data = await resp.json();
|
||||
if (data.success && data.token) {
|
||||
jwt = data.token;
|
||||
isPaidActive = data.is_paid_active || false;
|
||||
paidExpiresAt = data.paid_expires_at;
|
||||
clearInterval(pollTimer);
|
||||
showLoggedIn();
|
||||
}
|
||||
} catch(e) {
|
||||
// 继续轮询
|
||||
}
|
||||
}
|
||||
|
||||
function showLoggedIn() {
|
||||
document.getElementById('loginSection').style.display = 'none';
|
||||
if (isPaidActive) {
|
||||
// 已付费用户,显示会员信息
|
||||
document.getElementById('paidSection').style.display = 'block';
|
||||
document.getElementById('paidBadge').textContent = '付费会员';
|
||||
if (paidExpiresAt) {
|
||||
document.getElementById('paidExpires').textContent = new Date(paidExpiresAt).toLocaleString('zh-CN');
|
||||
document.getElementById('expiresRow').style.display = 'flex';
|
||||
} else {
|
||||
document.getElementById('paidExpires').textContent = '永久有效';
|
||||
document.getElementById('paidBadge').textContent = '永久会员';
|
||||
}
|
||||
document.getElementById('packages').style.display = 'none';
|
||||
} else {
|
||||
// 未付费用户,显示套餐
|
||||
document.getElementById('paidSection').style.display = 'none';
|
||||
document.getElementById('packages').style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
function logout() {
|
||||
jwt = '';
|
||||
currentShortCode = '';
|
||||
isPaidActive = false;
|
||||
paidExpiresAt = null;
|
||||
document.getElementById('loginSection').style.display = 'block';
|
||||
document.getElementById('paidSection').style.display = 'none';
|
||||
document.getElementById('packages').style.display = 'none';
|
||||
resetLoginUI();
|
||||
}
|
||||
|
||||
function resetLoginUI() {
|
||||
document.getElementById('codeDisplay').style.display = 'none';
|
||||
document.getElementById('scanStatus').style.display = 'none';
|
||||
document.getElementById('btnGenerate').style.display = 'block';
|
||||
document.getElementById('btnGenerate').disabled = false;
|
||||
document.getElementById('btnGenerate').textContent = '获取登录码';
|
||||
document.getElementById('btnRefresh').style.display = 'none';
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
const el = document.getElementById('errorMsg');
|
||||
el.textContent = msg;
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
function hideError() {
|
||||
document.getElementById('errorMsg').style.display = 'none';
|
||||
}
|
||||
|
||||
// ---- 套餐选择 ----
|
||||
let selected = null;
|
||||
// 从 URL 参数获取 JWT(来自小程序的跳转链接)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const jwt = urlParams.get('jwt') || '';
|
||||
function selectPackage(pkg) {
|
||||
selected = pkg;
|
||||
document.querySelectorAll('.pkg-card').forEach(c => c.classList.remove('selected'));
|
||||
@@ -325,11 +540,15 @@ pub async fn payment_index() -> Result<HttpResponse, AppError> {
|
||||
var labels = { monthly: '立即开通 - ¥9.9/月', yearly: '立即开通 - ¥59/年', permanent: '立即开通 - ¥199/终身' };
|
||||
btn.textContent = labels[pkg];
|
||||
btn.disabled = false;
|
||||
btn.className = pkg === 'permanent' ? 'btn-pay orange' : 'btn-pay';
|
||||
btn.className = 'btn-pay' + (pkg === 'permanent' ? ' orange' : '');
|
||||
}
|
||||
|
||||
function goPay() {
|
||||
if (!selected) return;
|
||||
window.location.href = '/payment/page?package=' + selected + (jwt ? '&jwt=' + encodeURIComponent(jwt) : '');
|
||||
if (!selected || !jwt) {
|
||||
showError('请先登录');
|
||||
return;
|
||||
}
|
||||
window.location.href = '/payment/page?package=' + selected;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user