diff --git a/.env b/.env index f53b2fc..55ebed0 100644 --- a/.env +++ b/.env @@ -9,3 +9,10 @@ RUST_LOG=info APP_VERSION="0.2.0" FREE_USER_DATA_LIMIT=20 SSH_SERVER=root@1panel-server + +# 支付宝沙箱配置 +ALIPAY_APP_ID=9021000162697595 +ALIPAY_PRIVATE_KEY="MIIEogIBAAKCAQEAi6jZv++OYKnuOqXv2tGd88vgn2M7YGItW/YpvZecy2ea45OUhDWgfMUiJpScCVCBAM218pfADc3YLqSMPgIhqKVAxwPZ5/VhWL8nr0REkAToetKvz+voGhY5H8t7JRP5wFJoT0hQoiZoLVJs4qQVIsKx3SG5bzjDQII9gLgYeYdcdWfewqPW970lU7KE09VbOrrd+q3DRzIrcO+JJM6DW6ynU5Ok7nXOUPNWqidfW24qj/9vyo2DgrdAj0UVCGkcRivq5hkRPiwpzT+ycU6Umx/MSY+lMNeqlsAVX6unNqPyRq7R0sL4sgGbCDNyrvu/bs7D8FXwBG3apI4OsZe0NQIDAQABAoIBAHYBGtQJhwT4MHnPocYDnfHUT5x9TTtYWTk92vD+fRaFGSO3nCLF6S/jpcJxiRJ5ujbCmj07qaRATKzEjlcKmAQWdZBzGZjcBf/lrJN0HHaQHxtAkBsgGoKISoYp+YhPw5SUTNzInpET4KyC28v4ln9DgOP2zedDT/9ezLbJVqy/w/B1R6bWYAKeHaf4P3M3aUxua4heZyBAtNpwjKHqpF9t0wZd+Vp7QbxNvrCsFv99e6aow90XyGOkpGlc2cLnsanDrTftIm/2MzUNx4aqJHhvzNPFqjR7TWr2FZGNY/bEWjOJN+Zk2zuTnu2sGgFGn+g7VgMoExltxRClpTsjiwECgYEA53m9nSBLqcP9vs2Lf7e2kDgHsTs5mnz/xT1kxv703VvpJla1LhUZdXLQdC7ZKP65MwXJJdxJaKs2Smc9U9g7s7ZyUeBgn4OeN5zJ8ybWs3bQaI1ayHDQfddS1sODisdCwm/DEcKYeG4U7eU+RyBGU3RVtGLyk+EJRpnzks4O2kECgYEAmnTOSHnG+2AN3Hdb85+YWmKYuzPmF2w3Vzs24eyjlLCtO47smk45LcTwRZe/VH4JZFjEUv4VQexC0GCCMeOxI4+HfSW4LKfZ9QDoVxAQAQwaSv13q3RBGg0QQS1tGks8QPDMdHlPAGUkQbEEu5TYW227p3/l0gvSGHA0e2nj1PUCgYAoqelWhgrkuuqDr2Y3PJXUxhML3oSjEBx6kOt4TefET1YpBwhzHP/I8y+NiYFaw0zTmsuYXBeFTLaP4Y0TgqQSURXKLmmq/Bs6V8/eaE9FXbUE+PgeyDrIMKB73ALqyVCACdQx9YaFeYkxtZj4M8JG/OPleZB1zA2uGnOAsbRIQQKBgEVO5MHg40kpcQ7dAIXxt4vXcnGOeFFKXgrmp1n5te2i2hk/qSwUrdO765VO4qwnYk+FBZO4MBHE0rZul/2LDpHGdiXSJzLinwsxvRBURNozdBjyZCCrqgrmkcStfaeu2vrJEvWw+aJTwFE5/cWupOdh9jLTUW1PoJcWwnYerf2BAoGAVRYA379sCxTOrWdesElRDy2YbXfvhyYZIbZuWhJwXg+sfP01DkcHuAnRmqS5NgEjHHsIY/JuAttYyIWYuOVxP48my9M2LhFRMeFHwWtrICMKqY9D6a7DuHo4sFz7Ld1jc9v/8Qe7ZhAQIEZbF3kLZDuCJZ5wOdD2nZZ60uWILc0=" +ALIPAY_ALIPAY_PUBLIC_KEY="MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwThMt6iRtRXGKAxfsJXgFOrL95PjZinbAeZKWnX8xCRm4q5SHoE1kDkQ+totcebMzQkepcdyHImVOuPEEKx5RBx1LybqiuHJaXvVj1SIqQNUCX8vNwum+giI/hI+dBIfu/XDdgXoPd1tHpMyEJu7n43t+EKwKGchyt1qXOJ0VEHTjxc+LPM9BzxO0Igo85OEi6PMoqMLt24QuHppRW2T4Qk4a7Njow3zZO0Hbqcy4uLYIGAD0qYbXICGKJVJ6G3voGInYOwnhc2zeOsl4MTTpMB533V7u4kG9BwI8Oo9cPqCxusZ3FAzGuO+2XyjBNHhFDC+q65q4ei3nYRafIR53QIDAQAB" +ALIPAY_GATEWAY="https://openapi-sandbox.dl.alipaydev.com/gateway.do" +APP_BASE_URL="https://dev.xmclassmate.top" diff --git a/AGENTS.md b/AGENTS.md index 8374199..da8ddfc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,6 +284,15 @@ created_at TIMESTAMPTZ(创建时间) | `PUT /api/admin/users/{id}/payment` | 更新用户支付状态 | | `GET /api/admin/users/{id}` | 获取用户信息 | +### 登录码接口(网页授权) + +| 接口 | 说明 | +|------|------| +| `POST /api/web-login/code` | 小程序用微信 code 换取 display_code | +| `POST /api/web-login/confirm` | 小程序确认登录(需 JWT + code) | +| `GET /payment/generate-code` | 网页端生成登录码(已废弃,不建议使用) | +| `GET /payment/login-status` | 网页端轮询登录状态 | + ### 支付接口 | 接口 | 说明 | @@ -436,25 +445,36 @@ ORDER BY "desc" ## 支付系统 -### 支付流程(真实支付宝) +### 支付流程(登录码授权模式) -小程序内无法直接接入支付宝支付,采用以下方案: +小程序内无法直接接入支付宝支付,采用外部浏览器中转方案: ``` -小程序 → outter页面 → 外部浏览器 → /payment?jwt=xxx +小程序(升级页) → outter页面 → 外部浏览器 → /payment?code=xxx → 支付宝 ``` -1. 用户在小程序选择套餐 → 点击「立即开通」 -2. 小程序调用 `POST /api/payment/create-order` 创建订单(需 JWT) -3. 小程序跳转 `outter` 页面,URL 指向 `/payment?jwt=xxx&package=xxx`(outter 页面复制链接到剪贴板) -4. 用户切换到外部浏览器,打开 `/payment?jwt=xxx` -5. 后端验证 JWT,返回套餐选择页(已携带 package 参数则预选套餐) -6. 用户选择套餐,点击「去支付」→ 跳转到 `/payment/pay?order_no=xxx&package_type=xxx` -7. 后端调用 `alipay.trade.page.pay` API,生成支付表单或跳转 URL -8. 支付宝沙箱/正式环境支付页面展示 -9. 支付完成后,支付宝异步通知 `/payment/notify` -10. 后端验证签名,确认订单状态 -11. 跳转成功页 `/payment/success?order_no=xxx` +1. 用户在小程序升级页点击「输入登录码」 +2. 小程序获取微信 code,换取 display_code +3. 小程序自动调用 `POST /api/web-login/confirm` 确认登录(无需用户操作) +4. 小程序跳转 `outter` 页面,URL 指向 `/payment?code=xxx` +5. outter 页面提示用户在外部浏览器打开 +6. 用户在手机浏览器打开 `/payment?code=xxx` +7. 网页轮询 `/payment/login-status?code=xxx`,检测到已确认后自动登录 +8. 显示套餐选择页 +9. 用户选择套餐,点击「去支付」→ 跳转到 `/payment/page?package=xxx&jwt=xxx` +10. 后端调用 `alipay.trade.page.pay` API,返回支付跳转 +11. 支付宝沙箱/正式环境展示支付页面 +12. 支付完成后,支付宝异步通知 `/payment/notify` +13. 跳转成功页 `/payment/success?order_no=xxx` + +### 登录码相关接口 + +| 接口 | 说明 | +|------|------| +| `POST /api/web-login/code` | 小程序用微信 code 换取 display_code | +| `POST /api/web-login/confirm` | 小程序确认登录(需 JWT + code) | +| `GET /payment/generate-code` | 网页端生成登录码(已废弃,不建议使用) | +| `GET /payment/login-status` | 网页端轮询登录状态(返回 token 表示已确认) | ### Mock 登录(沙箱测试) diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index dd272d2..5b3c4ff 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -614,3 +614,127 @@ pub async fn web_login_confirm( paid_expires_at, }) } + +#[derive(Debug, Deserialize)] +pub struct AutoConfirmRequest { + pub code: String, +} + +#[derive(Debug, Serialize)] +pub struct AutoConfirmResponse { + pub success: bool, + pub token: Option, + pub is_paid_active: bool, + pub paid_expires_at: Option, + pub payment_url: Option, +} + +#[post("/api/web-login/auto-confirm")] +pub async fn web_login_auto_confirm( + pool: web::Data, + http_client: web::Data, + app_state: web::Data, + req: web::Json, +) -> impl Responder { + let code = req.code.clone(); + + let openid = if code.starts_with("mock_") || code == "test_mock" { + 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 (is_paid_active, paid_expires_at): (bool, Option) = + match sqlx::query_as::<_, (bool, Option>)>( + "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 base_url = std::env::var("APP_BASE_URL") + .unwrap_or_else(|_| "https://dev.xmclassmate.top".to_string()); + let payment_url = if is_paid_active { + None + } else { + Some(format!("{}/payment?jwt={}", base_url, token)) + }; + + info!("[WEB LOGIN AUTO-CONFIRM] user_id={} is_paid={}", user_id, is_paid_active); + HttpResponse::Ok().json(AutoConfirmResponse { + success: true, + token: Some(token), + is_paid_active, + paid_expires_at, + payment_url, + }) +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 88a0737..c79a455 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -22,6 +22,7 @@ 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 auth::web_login_auto_confirm; pub use favorites::{add_favorite, get_favorites, remove_favorite}; pub use health::health_check; pub use meta::root; diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 53d481d..5ad894d 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -320,7 +320,7 @@ pub async fn payment_index() -> Result { - + @@ -431,58 +418,44 @@ pub async fn payment_index() -> Result { let isPaidActive = false; let paidExpiresAt = null; - // ---- 页面初始化:检查 URL 中的 code 参数 ---- + // ---- 页面初始化:检查 URL 中的 code 或 jwt 参数 ---- (function initFromUrl() { const params = new URLSearchParams(window.location.search); + const urlJwt = params.get('jwt'); + if (urlJwt) { + jwt = urlJwt; + checkPaidStatus(urlJwt); + return; + } const urlCode = params.get('code'); if (urlCode) { currentShortCode = urlCode; document.getElementById('codeValue').textContent = currentShortCode; document.getElementById('codeDisplay').style.display = 'block'; document.getElementById('scanStatus').style.display = 'block'; - document.getElementById('scanStatus').className = 'scan-status waiting'; - document.getElementById('scanStatus').textContent = '等待小程序授权确认...'; - document.getElementById('btnGenerate').style.display = 'none'; - document.getElementById('btnRefresh').style.display = 'block'; + document.getElementById('loginDesc').style.display = 'none'; // 启动轮询 if (pollTimer) clearInterval(pollTimer); pollTimer = setInterval(pollLoginStatus, 2000); } - })(); - - // ---- 登录码流程(正式版)---- - async function generateCode() { - hideError(); - const btn = document.getElementById('btnGenerate'); - btn.disabled = true; - btn.textContent = '正在获取...'; +})(); + async function checkPaidStatus(token) { try { - // 1. 获取登录码 - const resp = await fetch(API_BASE + '/payment/generate-code'); - const data = await resp.json(); - if (!data.code) throw new Error('获取登录码失败'); - - currentShortCode = data.code; - document.getElementById('codeValue').textContent = currentShortCode; - document.getElementById('codeDisplay').style.display = 'block'; - document.getElementById('scanStatus').style.display = 'block'; - document.getElementById('scanStatus').className = 'scan-status waiting'; - document.getElementById('scanStatus').textContent = '请在微信小程序中确认登录'; - btn.style.display = 'none'; - document.getElementById('btnRefresh').style.display = 'block'; - - // 2. 启动轮询 - if (pollTimer) clearInterval(pollTimer); - pollTimer = setInterval(pollLoginStatus, 2000); - } catch(e) { - showError('获取登录码失败,请稍后重试'); - btn.disabled = false; - btn.textContent = '重新获取'; - } + const resp = await fetch(API_BASE + '/api/user/profile', { + headers: { 'Authorization': 'Bearer ' + token } + }); + if (resp.ok) { + const data = await resp.json(); + if (data.success) { + isPaidActive = data.data.is_paid_active || false; + paidExpiresAt = data.data.paid_expires_at || null; + } + } + } catch(e) {} + showLoggedIn(); } - // ---- 轮询登录状态(正式流程)---- async function pollLoginStatus() { if (!currentShortCode) return; try { @@ -529,16 +502,9 @@ pub async fn payment_index() -> Result { 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'; + document.getElementById('loginDesc').style.display = 'block'; } function showError(msg) { @@ -569,7 +535,7 @@ pub async fn payment_index() -> Result { showError('请先登录'); return; } - window.location.href = '/payment/page?package=' + selected; + window.location.href = '/payment/page?package=' + selected + '&jwt=' + encodeURIComponent(jwt); } diff --git a/src/main.rs b/src/main.rs index 15f222d..3ac7c45 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,7 +24,7 @@ use handlers::{ health_check, login, mock_login, mock_confirm, payment_index, payment_login_status, payment_page, payment_success, post_weather_data, refresh_token, remove_favorite, root, save_user_profile, serve_static_files, - web_generate_login_code, web_login_confirm, + web_generate_login_code, web_login_confirm, web_login_auto_confirm, }; use models::AppState; @@ -82,6 +82,7 @@ fn create_server_config( .service(health_check) .service(web_generate_login_code) .service(web_login_confirm) + .service(web_login_auto_confirm) .service( web::scope("") .wrap(from_fn(jwt_middleware))