diff --git a/AGENTS.md b/AGENTS.md index fc212fb..8ca9e7d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,7 +30,6 @@ src/ ├── models.rs # 数据结构(Claims、User、WeatherData 等) ├── config.rs # 配置加载(支持多环境:config/*.toml) ├── error.rs # 错误处理 -├── alipay.rs # 支付宝签名模块(RSA2 sign/verify、URL encode) ├── rate_limiter.rs # 滑动窗口 Rate Limiter(5次/分钟/IP) └── handlers/ # 路由处理器模块 ├── mod.rs # 模块导出 diff --git a/Cargo.lock b/Cargo.lock index de9bffc..9776cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,29 +19,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "actix-files" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c0d87f10d70e2948ad40e8edea79c8e77c6c66e0250a4c1f09b690465199576" -dependencies = [ - "actix-http", - "actix-service", - "actix-utils", - "actix-web", - "bitflags", - "bytes", - "derive_more", - "futures-core", - "http-range", - "log", - "mime", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "v_htmlescape", -] - [[package]] name = "actix-http" version = "3.11.1" @@ -649,16 +626,6 @@ dependencies = [ "windows-sys 0.61.0", ] -[[package]] -name = "error" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e606f14042bb87cc02ef6a14db6c90ab92ed6f62d87e69377bc759fd7987cc" -dependencies = [ - "traitobject", - "typeable", -] - [[package]] name = "etcetera" version = "0.8.0" @@ -1005,12 +972,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" - [[package]] name = "httparse" version = "1.10.1" @@ -1461,16 +1422,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1963,17 +1914,12 @@ dependencies = [ name = "rust-backend" version = "0.3.0" dependencies = [ - "actix-files", "actix-web", "base64", "chrono", - "digest", "dotenvy", - "error", - "hex", "include_dir", "jsonwebtoken", - "log", "openssl", "pkcs8", "rand 0.8.5", @@ -2898,36 +2844,18 @@ dependencies = [ "tracing-serde", ] -[[package]] -name = "traitobject" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04a79e25382e2e852e8da874249358d382ebaf259d0d34e75d8db16a7efabbc7" - [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typeable" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1410f6f91f21d1612654e7cc69193b0334f909dcf2c790c4826254fbb86f8887" - [[package]] name = "typenum" version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2996,12 +2924,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "v_htmlescape" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" - [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 21465d1..ec1cfe0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,17 +4,14 @@ version = "0.3.0" edition = "2024" [dependencies] -actix-files = "0.6.8" actix-web = {version = "4.11.0", features=["openssl"]} chrono = {version = "0.4.41", features=["serde"]} dotenvy = "0.15.7" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } tracing-appender = "0.2" -error = "0.1.9" include_dir = "0.7.4" jsonwebtoken = "9.3.1" -log = "0.4.28" openssl = "0.10.73" rand = "0.8" reqwest = { version = "0.12.23", features=["json"]} @@ -28,8 +25,6 @@ base64 = "0.22" rsa = { version = "0.9", features = ["pem", "sha2"] } pkcs8 = "0.10" sha2 = "0.10" -hex = "0.4" -digest = "0.10" [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..7514327 --- /dev/null +++ b/README.md @@ -0,0 +1,127 @@ +# ASD Rust Backend + +大气稳定度判定系统的后端服务,基于 Rust + actix-web 4。 + +## 快速开始 + +```bash +# 1. 复制环境配置模板 +cp .env.example .env +# 编辑 .env 填入实际值(数据库连接、微信凭证等) + +# 2. 运行开发服务器 +APP_ENV=development cargo run + +# 3. 运行测试 +cargo test +``` + +## 技术栈 + +| 技术 | 用途 | +|------|------| +| Rust (edition 2024) | 主力语言 | +| actix-web 4.11 | Web 框架 | +| sqlx 0.8.6 | PostgreSQL 数据库驱动 | +| jsonwebtoken 9.3 | JWT 认证 | +| reqwest 0.12 | HTTP 客户端 | +| chrono 0.4 | 时间处理 | + +## 项目结构 + +``` +src/ +├── main.rs # 入口、服务器配置、路由注册 +├── auth.rs # JWT 中间件、令牌生成/验证 +├── db.rs # 数据库操作 +├── models.rs # 数据结构 +├── config.rs # 多环境配置加载 +├── error.rs # 错误处理 +├── rate_limiter.rs # 滑动窗口 Rate Limiter +└── handlers/ # 路由处理器 + ├── mod.rs + ├── meta.rs # 服务状态页 + ├── auth.rs # 登录/注册 + ├── weather.rs # 天气数据 CRUD + ├── user.rs # 用户信息 + ├── admin.rs # 管理员功能 + ├── payment.rs # 支付(支付宝/Mock) + ├── favorites.rs # 收藏 + ├── health.rs # 健康检查 + └── static_files.rs # 静态文件服务 + +config/ # 环境配置文件 +migrations/ # 数据库迁移 +scripts/ # 运维脚本 +tests/ # 集成测试 +``` + +## 环境管理 + +### 配置文件 + +通过 `APP_ENV` 环境变量选择配置: + +```bash +APP_ENV=development cargo run # 本地开发 +APP_ENV=production cargo run # 生产环境 +``` + +### 环境变量(.env) + +**敏感信息仅通过 `.env` 文件加载**,不出现在 Git 跟踪的配置文件中: + +```env +DATABASE_URL=postgres://user:pass@host:5432/dbname +WECHAT_APPID=wx... +WECHAT_SECRET=xxx +JWT_SECRET=openssl rand -base64 32 生成的随机密钥 +``` + +## 部署 + +```bash +# 部署到开发服务器 +./deploy.sh development + +# 部署到生产服务器 +./deploy.sh production + +# 初始化服务器 .env(生成随机 JWT_SECRET) +./deploy.sh production --init-env + +# 更多选项 +./deploy.sh --help +``` + +## 关键 API + +| 端点 | 说明 | +|------|------| +| `GET /health` | 健康检查 | +| `POST /api/login` | 微信登录 | +| `POST /api/post-weather-data` | 上传天气数据 | +| `GET /weather` | 分页查询天气数据 | +| `GET /payment` | 套餐选择页 | +| `POST /api/payment/sync-order` | 同步会员状态 | + +完整 API 文档见 [AGENTS.md](./AGENTS.md)。 + +## 运维 + +```bash +# 数据库备份 +./scripts/backup-db.sh + +# 清理过期 refresh_token +./scripts/cleanup_refresh_tokens.sh + +# 查看日志 +journalctl -u rust-backend.service -n 100 +``` + +## 开发规范 + +- 所有提交使用 `jj`(jujutsu v0.41+),详见 AGENTS.md +- 支付宝沙箱测试使用 `MOCK_LOGIN_ENABLED=true` +- 无支付宝配置时自动启用 Mock 支付模式 diff --git a/config/default.toml b/config/default.toml index cb08520..f9a23ba 100644 --- a/config/default.toml +++ b/config/default.toml @@ -7,4 +7,3 @@ ssl_cert_path = "" rust_log = "debug" environment = "development" free_user_data_limit = 100 -server_host = "0.0.0.0" \ No newline at end of file diff --git a/config/development.toml b/config/development.toml index db82191..0e7c532 100644 --- a/config/development.toml +++ b/config/development.toml @@ -6,4 +6,3 @@ ssl_cert_path = "" rust_log = "debug" environment = "development" free_user_data_limit = 100 -server_host = "0.0.0.0" \ No newline at end of file diff --git a/config/production.toml b/config/production.toml index 2544232..ad8bc15 100644 --- a/config/production.toml +++ b/config/production.toml @@ -5,4 +5,3 @@ ssl_cert_path = "/etc/ssl/certs/full_chain.pem" rust_log = "info" environment = "production" free_user_data_limit = 20 -server_host = "0.0.0.0" \ No newline at end of file diff --git a/deploy.sh b/deploy.sh index 7335ffc..391c590 100755 --- a/deploy.sh +++ b/deploy.sh @@ -856,6 +856,24 @@ upload_cleanup_script() { fi } +# 上传备份脚本 +upload_backup_script() { + log_step "上传备份脚本..." + if [ "$DRY_RUN" = true ]; then + log_dry "rsync -avzP ./scripts/backup-db.sh '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/'" + return 0 + fi + + mkdir -p ./scripts 2>/dev/null || true + if [ -f "./scripts/backup-db.sh" ]; then + rsync -avzP --progress ./scripts/backup-db.sh "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/" + ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x ${REMOTE_DIR}/scripts/backup-db.sh" + log_info "备份脚本上传完成" + else + log_warn "备份脚本不存在,跳过" + fi +} + # 重启远程服务 restart_service() { log_step "重启服务 '${SERVICE_NAME}'..." @@ -1525,6 +1543,7 @@ main() { upload_migrations upload_test_script upload_cleanup_script + upload_backup_script restart_service run_migrations diff --git a/src/alipay.rs b/src/alipay.rs deleted file mode 100644 index d3d6652..0000000 --- a/src/alipay.rs +++ /dev/null @@ -1,284 +0,0 @@ -// src/alipay.rs — 支付宝 RSA2 签名与请求封装 -use rsa::pkcs1v15::{Pkcs1v15Sign, SigningKey, VerifyingKey}; -use rsa::pkcs8::{DecodePrivateKey, DecodePublicKey, EncodePrivateKey, LineEnding}; -use rsa::signature::{Signer, Verifier}; -use rsa::RsaPrivateKey; -use rsa::RsaPublicKey; -use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; -use std::env; - -/// 支付宝配置(从环境变量读取) -pub struct AlipayConfig { - pub app_id: String, - pub private_key: String, - pub alipay_public_key: String, - pub gateway: String, -} - -impl AlipayConfig { - pub fn from_env() -> Option { - let app_id = env::var("ALIPAY_APP_ID").ok()?; - let private_key = env::var("ALIPAY_PRIVATE_KEY").ok()?; - let alipay_public_key = env::var("ALIPAY_ALIPAY_PUBLIC_KEY").ok()?; - let gateway = env::var("ALIPAY_GATEWAY") - .unwrap_or_else(|_| "https://openapi.alipay.com/gateway.do".to_string()); - Some(Self { - app_id, - private_key, - alipay_public_key, - gateway, - }) - } - - pub fn isConfigured() -> bool { - Self::from_env().is_some() - } -} - -/// 对 map 按 key 排序后构建 query string(用于签名) -fn build_query_string(params: &BTreeMap<&str, &str>) -> String { - params - .iter() - .filter(|(_, v)| !v.is_empty()) - .map(|(k, v)| format!("{}={}", k, urlencoding(v))) - .collect::>() - .join("&") -} - -/// URL 编码(简单实现) -fn urlencoding(s: &str) -> String { - let mut result = String::new(); - for c in s.chars() { - match c { - 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => result.push(c), - _ => { - for b in c.to_string().as_bytes() { - result.push_str(&format!("%{:02X}", b)); - } - } - } - } - result -} - -/// 使用 RSA2 (SHA256) 对内容签名 -pub fn rsa2_sign(content: &str, private_key_pem: &str) -> Result { - // 解析 PKCS8 格式的私钥 - // 支持两种格式: - // 1. PEM 格式字符串 (-----BEGIN PRIVATE KEY-----...) - // 2. Base64 编码的 PEM 字符串 (用于环境变量配置) - let private_key = match RsaPrivateKey::from_pkcs8_pem(private_key_pem) { - Ok(key) => key, - Err(_) => { - // 尝试作为 base64 编码的 PEM 解码 - let decoded = base64::Engine::decode( - &base64::engine::general_purpose::STANDARD, - private_key_pem, - ) - .map_err(|e| format!("Base64 解码失败: {}", e))?; - - let pem_str = String::from_utf8(decoded) - .map_err(|e| format!("PEM 字符串格式错误: {}", e))?; - - RsaPrivateKey::from_pkcs8_pem(&pem_str) - .map_err(|e| format!("私钥解析失败: {}", e))? - } - }; - - let signing_key = SigningKey::::new(private_key); - let signature = signing_key.sign(content.as_bytes()); - Ok(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - signature.to_bytes().as_ref(), - )) -} - -/// 验证 RSA2 签名 -pub fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result { - use sha2::Digest; - let public_key = RsaPublicKey::from_public_key_pem(public_key_pem) - .map_err(|e| format!("支付宝公钥解析失败: {}", e))?; - - let sig_bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, sign) - .map_err(|e| format!("签名 Base64 解码失败: {}", e))?; - - let hashed = Sha256::digest(content.as_bytes()); - public_key - .verify(rsa::Pkcs1v15Sign::new::(), &hashed, &sig_bytes) - .map_err(|e| format!("签名验证失败: {}", e))?; - Ok(true) -} - -/// 构建支付宝请求 URL(含签名) -/// 返回 (url, sign),sign 已 URL 编码 -pub fn build_signed_request( - config: &AlipayConfig, - biz_content: &str, - other_params: Option>, -) -> Result<(String, String), String> { - let mut params: BTreeMap<&str, &str> = BTreeMap::new(); - - params.insert("app_id", &config.app_id); - params.insert("method", "alipay.trade.page.pay"); - params.insert("format", "JSON"); - params.insert("charset", "utf-8"); - params.insert("sign_type", "RSA2"); - params.insert( - "timestamp", - &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), - ); - params.insert("version", "1.0"); - params.insert("biz_content", biz_content); - - // 加入其他参数(如 return_url, notify_url) - if let Some(ref extra) = other_params { - for (k, v) in extra { - params.insert(k, v); - } - } - - // 按 RFC 3986 编码后拼接待签名内容 - let sign_source: String = params - .iter() - .map(|(k, v)| format!("{}={}", k, urlencoding(v))) - .collect::>() - .join("&"); - - let sign = rsa2_sign(&sign_source, &config.private_key)?; - - // 构建最终 URL - let query = build_query_string(¶ms) - + "&sign=" - + &urlencoding(&sign); - - let url = config.gateway.clone() + "?" + &query; - - Ok((url, sign)) -} - -/// 调用支付宝接口并解析响应 -pub async fn call_alipay( - config: &AlipayConfig, - biz_content: &str, - other_params: Option>, -) -> Result { - let client = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(30)) - .build() - .map_err(|e| format!("HTTP 客户端创建失败: {}", e))?; - - let mut params: BTreeMap<&str, &str> = BTreeMap::new(); - params.insert("app_id", &config.app_id); - params.insert("method", "alipay.trade.page.pay"); - params.insert("format", "JSON"); - params.insert("charset", "utf-8"); - params.insert("sign_type", "RSA2"); - params.insert( - "timestamp", - &chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string(), - ); - params.insert("version", "1.0"); - params.insert("biz_content", biz_content); - - if let Some(ref extra) = other_params { - for (k, v) in extra { - params.insert(k, v); - } - } - - let sign_source: String = params - .iter() - .map(|(k, v)| format!("{}={}", k, urlencoding(v))) - .collect::>() - .join("&"); - - let sign = rsa2_sign(&sign_source, &config.private_key)?; - - let query = build_query_string(¶ms) - + "&sign=" - + &urlencoding(&sign); - - let url = config.gateway.clone(); - - let resp = client - .post(&url) - .header("Content-Type", "application/x-www-form-urlencoded") - .body(query) - .send() - .await - .map_err(|e| format!("请求支付宝失败: {}", e))?; - - let body = resp.text().await.map_err(|e| format!("读取响应失败: {}", e))?; - - // 支付宝返回格式: alipay_trade_page_pay_response={...}&sign=xxx - let parts: Vec<&str> = body.splitn(2, "&sign=").collect(); - if parts.len() != 2 { - return Err(format!("支付宝响应格式异常: {}", body)); - } - - let json_str = parts[0] - .strip_prefix("alipay_trade_page_pay_response=") - .unwrap_or(parts[0]); - - let sign_from_alipay = parts[1]; - - // 验签(确保响应来自支付宝) - if !rsa2_verify(json_str, sign_from_alipay, &config.alipay_public_key)? { - return Err("支付宝响应验签失败".to_string()); - } - - let json: serde_json::Value = - serde_json::from_str(json_str).map_err(|e| format!("JSON 解析失败: {}", e))?; - - if json.get("code").and_then(|v| v.as_str()) != Some("10000") { - let sub_msg = json.get("sub_msg").and_then(|v| v.as_str()).unwrap_or(""); - return Err(format!( - "支付宝接口错误: {} - {}", - json.get("msg").and_then(|v| v.as_str()).unwrap_or("未知"), - sub_msg - )); - } - - Ok(json) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_url_encoding() { - assert_eq!(urlencoding("hello"), "hello"); - assert_eq!(urlencoding("hello world"), "hello%20world"); - assert_eq!(urlencoding("中文"), "%E4%B8%AD%E6%96%87"); - } -} - -#[cfg(test)] -mod signature_tests { - use super::*; - - #[test] - fn test_openssl_signature_consistency() { - // This test verifies our signing matches OpenSSL - // Key from .env (same key as Python test) - let private_key_pem = std::env::var("TEST_ALIPAY_PRIVATE_KEY") - .unwrap_or_else(|_| { - // Default test key - the one we've been using - "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2lBZ0VBQW9JQ0FRREFRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQQovZ2dFQQBFBX".to_string() - }); - - let content = "test content for signature comparison"; - - // Sign with our function - let result = rsa2_sign(content, &private_key_pem); - assert!(result.is_ok(), "Signing failed: {:?}", result.err()); - let signature = result.unwrap(); - - tracing::debug!("Generated signature for test verification ({} chars)", signature.len()); - - // To verify this matches OpenSSL, you would need to run: - // echo -n "test content..." | openssl dgst -sha256 -sign key.pem | base64 - } -} diff --git a/src/config.rs b/src/config.rs index 4ee3de5..de5a697 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,7 +16,6 @@ pub struct AppConfig { pub rust_log: String, pub environment: String, pub free_user_data_limit: i32, - pub server_host: String, } impl AppConfig { diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index fbff9b6..c4097f3 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -509,7 +509,7 @@ pub async fn web_login_confirm( app_state: web::Data, ) -> impl Responder { let short_code = req.code.trim(); - log::info!("[web_login_confirm] received code={}", short_code); + tracing::info!("[web_login_confirm] received code={}", short_code); // 精确匹配登录码 let record: Option<(String, String, chrono::DateTime, Option)> = @@ -519,6 +519,7 @@ pub async fn web_login_confirm( .bind(short_code) .fetch_optional(pool.get_ref()) .await + .inspect_err(|e| tracing::warn!("数据库查询登录码失败: {}", e)) .ok() .flatten(); @@ -537,10 +538,13 @@ pub async fn web_login_confirm( // 检查是否过期 if Utc::now() > expires_at { // 清理过期码 - let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") + if let Err(e) = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") .bind(&code) .execute(pool.get_ref()) - .await; + .await + { + tracing::warn!("清理过期登录码失败: {}", e); + } return HttpResponse::Ok().json(WebLoginConfirmResponse { success: false, token: None, @@ -597,12 +601,14 @@ pub async fn web_login_confirm( }; // 更新登录码记录,设置 token(而非删除,让轮询接口能查到) - sqlx::query("UPDATE web_login_codes SET token = $1 WHERE code = $2") + 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 - .ok(); + { + tracing::warn!("更新登录码 token 失败: {}", e); + } info!("[WEB LOGIN CONFIRM] user_id={} is_paid={}", user_id, is_paid_active); HttpResponse::Ok().json(WebLoginConfirmResponse { diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index bcc4122..160c87d 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -159,7 +159,6 @@ function confirmMockPay() {{ } /// 验证 RSA2 签名 -#[allow(dead_code)] fn rsa2_verify(content: &str, sign: &str, public_key_pem: &str) -> Result { use rsa::pkcs8::DecodePublicKey; use rsa::RsaPublicKey; @@ -266,7 +265,7 @@ fn extract_token(req: &HttpRequest) -> Option { } fn get_jwt_secret() -> String { - std::env::var("JWT_SECRET").unwrap_or_else(|_| "default_secret".to_string()) + std::env::var("JWT_SECRET").expect("JWT_SECRET must be set") } // ===== Handler: GET /payment — 套餐选择页(网页端微信扫码登录) ===== @@ -710,42 +709,56 @@ pub async fn alipay_pay_page( // ===== Handler: POST /payment/notify — 支付宝异步回调 ===== -#[derive(Debug, Deserialize)] -pub struct AlipayNotify { - pub out_trade_no: String, - pub trade_no: String, - pub trade_status: String, - pub total_amount: Option, - pub app_id: Option, - pub sign: Option, -} - #[post("/payment/notify")] pub async fn alipay_notify( pool: web::Data, - body: web::Form, + body: web::Form>, ) -> HttpResponse { let body = body.into_inner(); + let out_trade_no = body.get("out_trade_no").cloned().unwrap_or_default(); + let trade_status = body.get("trade_status").cloned().unwrap_or_default(); + tracing::info!( "收到支付宝回调: out_trade_no={}, trade_status={}", - body.out_trade_no, - body.trade_status + out_trade_no, + trade_status ); // 1. 检查交易状态 - if body.trade_status != "TRADE_SUCCESS" && body.trade_status != "TRADE_FINISHED" { + if trade_status != "TRADE_SUCCESS" && trade_status != "TRADE_FINISHED" { return HttpResponse::Ok().body("success"); } - // 2. 确认订单(通过 order_no,不校验 user_id) - match db::confirm_payment_order_by_orderno(pool.get_ref(), &body.out_trade_no).await { + // 2. 验证 RSA2 签名(防止伪造回调) + let Some(config) = AlipayConfig::from_env() else { + tracing::warn!("支付宝配置不存在,无法验证签名"); + return HttpResponse::Ok().body("fail"); + }; + + let sign = body.get("sign").cloned().unwrap_or_default(); + + // BTreeMap 已按 key 排序,直接拼接除 sign 和 sign_type 外的所有参数 + let sign_source: String = body + .iter() + .filter(|(k, _)| *k != "sign" && *k != "sign_type") + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>() + .join("&"); + + if let Err(e) = rsa2_verify(&sign_source, &sign, &config.alipay_public_key) { + tracing::warn!("支付宝签名验证失败: {}", e); + return HttpResponse::Ok().body("fail"); + } + + // 3. 确认订单(通过 order_no,不校验 user_id) + match db::confirm_payment_order_by_orderno(pool.get_ref(), &out_trade_no).await { Ok(_) => { - tracing::info!("订单 {} 支付确认成功", body.out_trade_no); + tracing::info!("订单 {} 支付确认成功", out_trade_no); HttpResponse::Ok().body("success") } Err(e) => { - tracing::error!("订单 {} 确认失败: {}", body.out_trade_no, e); + tracing::error!("订单 {} 确认失败: {}", out_trade_no, e); HttpResponse::Ok().body("fail") } } @@ -1072,10 +1085,13 @@ pub async fn payment_login_status( // 检查是否过期 if Utc::now() > expires_at { - let _ = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") + if let Err(e) = sqlx::query("DELETE FROM web_login_codes WHERE code = $1") .bind(&db_code) .execute(pool.get_ref()) - .await; + .await + { + tracing::warn!("清理过期登录码失败: {}", e); + } return Ok(HttpResponse::Ok().json(LoginStatusResponse { success: false, confirmed: false, diff --git a/src/handlers/weather.rs b/src/handlers/weather.rs index 0f5e922..81634fb 100644 --- a/src/handlers/weather.rs +++ b/src/handlers/weather.rs @@ -83,11 +83,11 @@ pub async fn get_weather_details( (temp_claims.openid, temp_claims.resource_id) } else if let Some(claims) = claims_from_header() { let weather_id = match query.get("id") { - Some(v) if v.is_i64() => v.as_i64().unwrap() as i32, + Some(v) if v.is_i64() => v.as_i64().unwrap_or(0) as i32, Some(v) if v.is_string() => { v.as_str().and_then(|s| s.parse::().ok()).unwrap_or(0) } - Some(v) if v.is_number() => v.as_f64().unwrap() as i32, + Some(v) if v.is_number() => v.as_f64().map(|f| f as i32).unwrap_or(0), _ => { return Err(AppError::BadRequest("缺少资源ID参数(id)".to_string())); } diff --git a/src/main.rs b/src/main.rs index 737f383..2b856b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,4 @@ -use actix_web::middleware::from_fn; +use actix_web::middleware::{from_fn, DefaultHeaders}; use actix_web::{App, HttpServer, web}; use tracing::{error, info}; use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod}; @@ -62,6 +62,15 @@ fn create_server_config( .app_data(web::Data::new(pool)) .app_data(web::Data::new(http_client)) .app_data(web::Data::new(app_state)) + // 安全响应头(全局中间件) + .wrap( + actix_web::middleware::DefaultHeaders::new() + .add(("X-Content-Type-Options", "nosniff")) + .add(("X-Frame-Options", "DENY")) + .add(("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net; connect-src 'self'")) + .add(("Referrer-Policy", "no-referrer-when-downgrade")) + .add(("Permissions-Policy", "geolocation=(), microphone=(), camera=()")) + ) // 根路径(无需认证) .service(root) // #[get("/")] - 返回服务信息 // 支付页面(无需认证,外部浏览器访问) @@ -144,12 +153,26 @@ async fn main() -> std::io::Result<()> { // 保持文件 guard 存活(使用 Box 泄漏) std::mem::forget(_guard); + use std::str::FromStr; + let default_directive = tracing_subscriber::filter::Directive::from_str("info").unwrap(); + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::from_default_env() - .add_directive(format!("rust_backend={}", app_config.rust_log).parse().unwrap()) - .add_directive(format!("actix_web={}", app_config.rust_log).parse().unwrap()) - .add_directive("sqlx=warn".parse().unwrap()) + .add_directive( + format!("rust_backend={}", app_config.rust_log) + .parse() + .unwrap_or(default_directive.clone()) + ) + .add_directive( + format!("actix_web={}", app_config.rust_log) + .parse() + .unwrap_or(default_directive) + ) + .add_directive( + "sqlx=warn".parse() + .expect("sqlx=warn 是合法的日志指令") + ) ) .with_target(true) .with_thread_ids(false) // 生产环境可开启