diff --git a/.gitignore b/.gitignore index fe46479..c622b30 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,5 @@ -/target -/.vscode -.sisyphus/ +# Ignore all files +* -# Local config and environment -.clawhub/ -.env.example -config/ -logs/ \ No newline at end of file +# But track these +!AGENTS.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index e0e9643..87fa33c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,66 +1,97 @@ -# Agent 文档 — rust-backend +# AGENTS.md - Rust 后端 ## 项目概述 微信小程序天气数据采集后端。用户通过微信认证,上传天气观测数据,并通过 REST API 管理数据。 +--- + ## 技术栈 -- **语言**: Rust (edition 2024) -- **Web 框架**: actix-web 4.11 -- **数据库**: PostgreSQL,通过 sqlx 0.8.6 连接 -- **认证**: JWT (HS256),使用 jsonwebtoken 9.3 -- **HTTP 客户端**: reqwest(用于调用微信 API) -- **序列化**: serde + serde_json -- **时间处理**: chrono +| 技术 | 版本 | 说明 | +|------|------|------| +| Rust | edition 2024 | 主力语言 | +| actix-web | 4.11 | Web 框架 | +| sqlx | 0.8.6 | PostgreSQL 连接 | +| serde | 1.0 | 序列化 | +| jsonwebtoken | 9.3 | JWT 认证 | +| reqwest | 0.12 | HTTP 客户端 | +| chrono | 0.4 | 时间处理 | + +--- ## 项目结构 ``` src/ ├── main.rs # 入口文件,服务器配置,路由注册 -├── auth.rs # JWT 中间件,令牌生成/验证 -├── db.rs # 数据库操作(通过 sqlx 执行原始 SQL) -├── models.rs # 数据结构(Claims、User、WeatherData 等) -├── config.rs # 配置加载(支持多环境:config/*.toml) -└── handlers/ # 路由处理器模块 - ├── mod.rs # 模块导出 - ├── auth.rs # 登录相关 (login) - ├── weather.rs # 天气数据 CRUD (post_weather_data, get_weather, delete_weather) - ├── user.rs # 用户相关 (get_user_profile) - ├── admin.rs # 管理员功能 (update_user_payment, get_user_by_admin) - ├── health.rs # 健康检查 (/health) - └── static_files.rs # 静态文件服务 (/static/{path}) +├── auth.rs # JWT 中间件,令牌生成/验证 +├── db.rs # 数据库操作(通过 sqlx 执行原始 SQL) +├── models.rs # 数据结构(Claims、User、WeatherData 等) +├── config.rs # 配置加载(支持多环境:config/*.toml) +└── handlers/ # 路由处理器模块 + ├── mod.rs # 模块导出 + ├── auth.rs # 登录相关 (login) + ├── weather.rs # 天气数据 CRUD + ├── user.rs # 用户相关 + ├── admin.rs # 管理员功能 + ├── payment.rs # 支付相关 + ├── health.rs # 健康检查 (/health) + └── static_files.rs # 静态文件服务 config/ -├── default.toml # 默认配置(所有环境的共同默认值) -├── development.toml # 开发/测试环境配置 -└── production.toml # 生产环境配置 +├── default.toml # 默认配置(所有环境的共同默认值) +├── development.toml # 开发/测试环境配置 +└── production.toml # 生产环境配置 -migrations/ -└── 001_add_payment_fields.sql # 用于添加支付字段的 ALTER TABLE - -static/ -└── katex/ # KaTeX 数学公式渲染库 - -test_deployment.sh # 部署后测试脚本(自动执行) -tests/ -└── integration_test.rs # 基础测试框架 - -deploy.sh # 部署脚本(支持 development/production 参数) -.env.example # 环境变量模板 +migrations/ # 数据库迁移 SQL +tests/ # 集成测试 +static/ # 静态文件(KaTeX 等) +deploy.sh # 部署脚本 +.env.example # 环境变量模板 ``` -## 服务器连接 +--- + +## 配置管理 + +### 配置文件方式(推荐) + +通过 `APP_ENV` 环境变量选择配置文件: -### SSH 连接 ```bash -ssh root@1panel-server +APP_ENV=development cargo run # 使用 config/development.toml +APP_ENV=production cargo run # 使用 config/production.toml ``` -### 数据库 +### 直接环境变量方式 -PostgreSQL 通过 Docker 部署: +最高优先级,可覆盖配置文件: + +```env +APP_DATABASE_URL=postgres://user:pass@host:5432/dbname +APP_JWT_SECRET=your_secret_key +APP_WECHAT_APPID=wx... +``` + +### 配置文件优先级 + +`环境变量 > production.toml > development.toml > default.toml` + +--- + +## 数据库 + +### 连接信息 + +| 环境 | 连接字符串 | +|------|-----------| +| 测试 | `postgres://milkydata:password@127.0.0.1:5432/milkydata_dev` | +| 生产 | `postgres://milkydata:password@127.0.0.1:5432/milkydata` | + +> ⚠️ Docker PostgreSQL 监听在 `127.0.0.1` 而非 `localhost` + +### 连接数据库 ```bash # 查看容器 @@ -68,19 +99,12 @@ docker ps | grep postgres # 连接数据库(容器内) docker exec -it postgres_container psql -U postgres -d milkydata_dev - -# 连接字符串 -# 测试环境 -postgres://milkydata:password@127.0.0.1:5432/milkydata_dev -# 生产环境 -postgres://milkydata:password@127.0.0.1:5432/milkydata ``` -> 注意:Docker PostgreSQL 监听在 `127.0.0.1` 而非 `localhost` +### 表结构 -## 数据库表结构 +#### users 表 -### users 表 ```sql id INTEGER PRIMARY KEY openid VARCHAR UNIQUE(微信用户 ID) @@ -91,81 +115,58 @@ is_admin BOOLEAN DEFAULT false paid_expires_at TIMESTAMPTZ DEFAULT NULL ``` -### weather_data 表 +#### weather_data 表 + ```sql id INTEGER PRIMARY KEY user_id INTEGER(外键,关联 users 表) -title, date, hour, min, longitude, latitude, ... -- 30+ 个天气测量字段列 ``` +--- + ## 认证流程 -1. **登录**: `POST /api/login` 传入微信 code → 调用微信 API → UPSERT 用户 → 返回 JWT -2. **JWT 声明**: `{exp, iat, user_id, openid, user_type}`(24 小时过期) -3. **中间件**: `jwt_middleware` 提取 Bearer 令牌,验证后将 Claims 插入请求扩展 -4. **处理器访问**: 通过 `claims: web::ReqData` 参数获取 +1. **登录**:`POST /api/login` 传入微信 code → 调用微信 API → UPSERT 用户 → 返回 JWT +2. **JWT 声明**:`{exp, iat, user_id, openid, user_type}`(24 小时过期) +3. **中间件**:`jwt_middleware` 提取 Bearer 令牌,验证后将 Claims 插入请求扩展 +4. **处理器访问**:通过 `claims: web::ReqData` 参数获取 + +--- ## API 接口 ### 公开接口(无需认证) -- `POST /api/login` — 微信登录 -- `GET /weather/details` — 获取天气详情(支持 JWT 或 temp_token,详见下文) -- `GET /static/{tail:*}` — 静态文件 + +| 接口 | 说明 | +|------|------| +| `POST /api/login` | 微信登录 | +| `GET /weather/details` | 获取天气详情(支持 JWT 或 temp_token) | +| `GET /static/{tail:*}` | 静态文件 | ### 受保护接口(需要 JWT) -- `POST /api/post-weather-data` — 上传天气数据(带配额检查) -- `GET /api/user/profile` — 获取当前用户信息(付费状态、管理员标识) -- `GET /weather` — 分页列出用户的天气数据 -- `POST /api/generate-temp-token/{resource_id}` — 生成 10 分钟分享令牌 -- `DELETE /weather/delete/{id}` — 删除天气记录 + +| 接口 | 说明 | +|------|------| +| `POST /api/post-weather-data` | 上传天气数据(带配额检查) | +| `GET /api/user/profile` | 获取当前用户信息 | +| `GET /weather` | 分页列出用户的天气数据 | +| `POST /api/generate-temp-token/{resource_id}` | 生成 10 分钟分享令牌 | +| `DELETE /weather/delete/{id}` | 删除天气记录 | ### 管理员接口(需要 JWT + is_admin) -- `PUT /api/admin/users/{id}/payment` — 更新用户支付状态 -- `GET /api/admin/users/{id}` — 获取用户信息 -## 支付系统 +| 接口 | 说明 | +|------|------| +| `PUT /api/admin/users/{id}/payment` | 更新用户支付状态 | +| `GET /api/admin/users/{id}` | 获取用户信息 | -### 支付模式 -| 模式 | 来源 | 处理方式 | -|------|------|---------| -| 微信支付 | `payment_orders` | 收到微信回调后确认,累积计算有效期 | -| 邀请码 | `invitation_codes` | 核销后直接激活,累积计算有效期 | -| 管理员开通 | 直接 UPDATE | 后台手动设置 | - -### 累积计算逻辑 -用户多次购买时,有效期会累加而非覆盖: -```rust -// 新到期时间 = MAX(当前到期, 现在) + 本次套餐天数 -let base_time = std::cmp::max(current_expires, Utc::now()); -let new_expires = base_time + days(pkg_days); -``` - -### 永久会员 -永久会员的 `expires_at` 设为 `2099-12-31` 而非 NULL。 - -### 配额限制 -- 未付费用户限制为 `FREE_USER_DATA_LIMIT` 条记录(环境变量,默认 20) -- 付费用户(活跃状态)无限制 -- 在 `insert_weather_data` 执行 INSERT 前进行检查 - -### 管理员 API -```json -PUT /api/admin/users/{id}/payment -{ - "is_paid": true, - "paid_expires_at": "2026-12-31T23:59:59Z" -} -``` - -### 支付处理器 -- 文件:`src/handlers/payment.rs` -- `mock-confirm` 接口仅用于测试环境 -- 套餐金额常量定义在 `get_package_info()` 函数中 +--- ## 代码模式 ### 处理器模式 + ```rust #[post("/api/endpoint")] async fn handler( @@ -174,13 +175,21 @@ async fn handler( body: web::Json, ) -> impl Responder { match db::function(pool.get_ref(), claims.user_id).await { - Ok(data) => HttpResponse::Ok().json(serde_json::json!({"success": true, "data": data})), - Err(e) => HttpResponse::Ok().json(serde_json::json!({"success": false, "errcode": 500, "errmsg": e})) + Ok(data) => HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": data + })), + Err(e) => HttpResponse::Ok().json(serde_json::json!({ + "success": false, + "errcode": 500, + "errmsg": e + })) } } ``` ### 数据库函数模式 + ```rust pub async fn function_name(pool: &PgPool, param: i32) -> Result { match sqlx::query_as::<_, Type>("SELECT ...") @@ -196,172 +205,224 @@ pub async fn function_name(pool: &PgPool, param: i32) -> Result { ``` ### 错误响应格式 + ```json {"success": false, "errcode": 403, "errmsg": "Error message"} ``` -## 环境变量 +--- -### 方式一:使用配置文件(推荐) +## ⚠️ 常见坑 -通过 `APP_ENV` 环境变量选择配置文件: +### 1. config crate 路径问题 -```bash -APP_ENV=development cargo run # 使用 config/development.toml -APP_ENV=production cargo run # 使用 config/production.toml +`File::with_name()` 使用当前工作目录,而非 `CARGO_MANIFEST_DIR`。 + +```rust +// ❌ 错误 +let config = Config::builder() + .add_source(File::with_name("config")) + .build(); + +// ✅ 正确:使用 CARGO_MANIFEST_DIR +let manifest_dir = env!("CARGO_MANIFEST_DIR"); +let config_path = Path::new(manifest_dir).join("config"); ``` -**配置文件**: +### 2. TOML 配置结构 -| 文件 | 用途 | -|------|------| -| `config/default.toml` | 所有环境的共同默认值 | -| `config/development.toml` | 开发/测试环境覆盖 | -| `config/production.toml` | 生产环境覆盖 | +**保持扁平结构,不使用 section headers**。 -**示例 production.toml**: ```toml -database_url = "postgres://user:pass@host:5432/dbname" -wechat_appid = "wx..." -jwt_secret = "your_secret_key" -rust_log = "info" -free_user_data_limit = 20 -server_host = "0.0.0.0" -server_ports = [4433, 8443, 8080, 3000, 8000, 8888] +# ❌ 错误:section headers 导致合并冲突 +[development] +database_url = "..." + +# ✅ 正确:扁平结构 +database_url = "..." ``` -### 方式二:直接环境变量 +### 3. serde skip 与查询冲突 -最高优先级,可覆盖配置文件: +`skip_deserializing` 会导致 SELECT 时字段缺失错误。 -```env -APP_DATABASE_URL=postgres://user:pass@host:5432/dbname -APP_JWT_SECRET=your_secret_key -APP_WECHAT_APPID=wx... -APP_RUST_LOG=info +```rust +// ❌ 错误:skip_deserializing 影响查询 +#[serde(rename = "isFavorite", skip_deserializing)] +pub is_favorite: bool, + +// ✅ 正确:使用 default +#[serde(rename = "isFavorite", default)] +pub is_favorite: bool, ``` -### systemd service 配置 +### 4. JWT 中不能添加 is_admin -测试服务和生产服务通过不同 systemd unit 和工作目录隔离: +**禁止在 JWT Claims 中添加 `is_admin`**,必须查询数据库验证。 -| 服务 | systemd unit | 工作目录 | APP_ENV | +### 5. 配额决策不能信任 JWT + +必须查询数据库检查 `is_paid_active`,而非信任 JWT 中的声明。 + +### 6. SQL 保留关键字 + +SQL 中使用 `desc` 等保留关键字时必须加双引号: + +```sql +-- ❌ 错误 +ORDER BY desc + +-- ✅ 正确 +ORDER BY "desc" +``` + +### 7. actix-ratelimit 不兼容 + +**actix-ratelimit 0.3.1 与 actix-web 4.x 不兼容**。 + +备选方案:actix-web-lab、手动 HashMap 实现、Nginx 层限流。 + +--- + +## 支付系统 + +### 支付模式 + +| 模式 | 来源 | 处理方式 | +|------|------|---------| +| 微信支付 | `payment_orders` | 收到微信回调后确认 | +| 邀请码 | `invitation_codes` | 核销后直接激活 | +| 管理员开通 | 直接 UPDATE | 后台手动设置 | + +### 累积计算逻辑 + +用户多次购买时,有效期会累加而非覆盖: + +```rust +let base_time = std::cmp::max(current_expires, Utc::now()); +let new_expires = base_time + days(pkg_days); +``` + +### 永久会员 + +永久会员的 `expires_at` 设为 `2099-12-31` 而非 NULL。 + +### 配额限制 + +- 未付费用户限制为 `FREE_USER_DATA_LIMIT` 条记录 +- 付费用户(活跃状态)无限制 + +--- + +## 部署 + +### 服务器信息 + +| 环境 | 域名 | 端口 | 远程目录 | +|------|------|------|---------| +| 测试 | xmclassmate.top/dev | 8080 | /root/rust/rust_backend_dev | +| 生产 | xmclassmate.top | 4433 | /root/rust/rust_backend | + +### systemd 服务 + +| 环境 | systemd unit | 工作目录 | APP_ENV | |------|-------------|---------|---------| | 测试 | `rust-backend-dev.service` | `/root/rust/rust_backend_dev` | `development` | | 生产 | `rust-backend.service` | `/root/rust/rust_backend` | (使用 .env) | -## 约束条件 +> ⚠️ systemd 配置使用 `Environment=` 而非 `EnvironmentFile=` -### 已知兼容性问题 -⚠️ **actix-ratelimit 0.3.1 与 actix-web 4.x 不兼容** -- 错误:`Transform` trait bound 不满足 -- 原因:该 crate 基于 actix-web 3.x 设计 -- 备选:actix-web-lab、手动 HashMap 实现、Nginx 层限流 +### 部署命令 -⚠️ **actix-web-prom 与当前架构不兼容** -- 错误:`ServiceFactory Response` 类型冲突(expected `ServiceResponse`, found `ServiceResponse>`) -- 原因:middleware 改变了 App 返回类型,与 `impl Trait` 返回类型冲突 -- 备选:actix-web-lab、手动 atomic 计数器、Nginx access log +```bash +./deploy.sh development # 部署到测试服务器 +./deploy.sh production # 部署到生产服务器 +``` -### 禁止事项 -- 在 JWT Claims 中添加 `is_admin`(必须查询数据库) -- 信任 JWT 中的 `is_paid` 来做配额决策(必须查询数据库) -- 使用 `as any`、`@ts-ignore` 或类型错误抑制 -- 添加审计日志 +**deploy.sh 功能**:依赖检查 → 编译 → 上传二进制/配置 → 重启服务 → 部署后测试 -### 必须事项 -- 管理员接口必须通过数据库查询验证管理员身份 -- 已认证处理器使用 `web::ReqData` -- 遵循现有的错误响应格式 -- 通过数据库查询检查 `is_paid_active`,而非 JWT -- SQL 中使用 `desc` 等保留关键字时必须加双引号:`"desc"` +### 前端部署 + +前端部署由微信开发者工具单独完成,详见 `ASD-fronted/AGENTS.md`: + +```typescript +// ASD-fronted/miniprogram/config/env.ts +const CURRENT_ENV: 'development' | 'production' = 'production'; // 发布前切换 +``` + +切换后通过微信开发者工具上传。 + +### 部署后测试 + +自动执行 `test_deployment.sh`,测试内容: +- 本地后端健康检查 +- 域名访问检查 +- 静态文件检查 + +--- + +## 禁止事项 + +- ❌ 在 JWT Claims 中添加 `is_admin`(必须查数据库) +- ❌ 信任 JWT 中的 `is_paid` 来做配额决策 +- ❌ 使用 `as any` 类型错误抑制 +- ❌ 添加审计日志 +- ❌ **直接操作服务器文件**(必须通过部署脚本) + +## 必须事项 + +- ✅ 管理员接口必须通过数据库查询验证管理员身份 +- ✅ 已认证处理器使用 `web::ReqData` +- ✅ 遵循现有的错误响应格式 +- ✅ 通过数据库查询检查 `is_paid_active` +- ✅ SQL 中使用保留关键字时加双引号 + +--- ## 常见任务 ### 添加新接口 + 1. 在 `main.rs` 中创建处理器函数 2. 如需要,在 `db.rs` 中添加数据库函数 -3. 在 `create_server_config` 中注册(如果是受保护接口,放在 JWT 作用域内) +3. 在 `create_server_config` 中注册 4. 如需要,在 `models.rs` 中添加请求/响应结构体 ### 路由配置结构 + ```rust -// create_server_config 函数中的 App 配置顺序很重要 App::new() - .app_data(web::Data::new(pool)) // 数据源 + .app_data(web::Data::new(pool)) .app_data(web::Data::new(http_client)) .app_data(web::Data::new(app_state)) // 公开接口(无 middleware) - .service(login) // POST /api/login - .service(get_weather_details) // GET /weather/details(支持 JWT 或 temp_token) + .service(login) // 受保护接口(JWT middleware) .service( web::scope("") - .wrap(from_fn(jwt_middleware)) // JWT 验证 - .service(post_weather_data) // POST /api/post-weather-data - // ... 其他受保护接口 + .wrap(from_fn(jwt_middleware)) + .service(post_weather_data) ) // 静态文件和健康检查放最后 .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) .service(health_check) ``` -### 路由优先级 -- `web::scope("/")` 内的路由路径不带前缀(handlers 已有 `#[post("/api/...")]` 等属性) -- 公开接口和受保护接口分开,便于添加不同的 middleware - ### 修改数据库 + 1. 在 `migrations/` 中创建迁移 SQL 2. 在 PostgreSQL 上手动执行迁移 -3. 如果表结构变更,更新 `models.rs` 中的 `User` 结构体 +3. 更新 `models.rs` 中的结构体 4. 更新 `db.rs` 中的数据库函数 -## 构建与运行 +--- + +## 开发命令 ```bash cargo build # 编译 -APP_ENV=development cargo run # 开发环境运行 -APP_ENV=production cargo run # 生产环境运行 -cargo test # 运行测试 +APP_ENV=development cargo run # 开发环境运行 +APP_ENV=production cargo run # 生产环境运行 +cargo test # 运行测试 cargo clippy # 代码检查 ``` - -**部署**: -```bash -./deploy.sh development # 部署到测试服务器(编译 + 传文件 + 重启服务) -./deploy.sh production # 部署到生产服务器 - -``` - -**deploy.sh 功能说明**: - -| 步骤 | 说明 | -|------|------| -| 依赖检查 | 验证 `cargo`、`rsync`、`ssh` 命令 | -| 编译 | `APP_ENV={环境} cargo build --release` | -| 上传二进制 | `rsync target/release/rust-backend → 远程目录` | -| 上传配置 | `rsync ./config/ → 远程目录/config/` | -| 上传测试脚本 | `rsync test_deployment.sh → 远程目录/` | -| 重启服务 | `systemctl restart {服务名}` | -| **部署后测试** | `ssh 远程执行 test_deployment.sh` | - -**远程目录与服务对应**: - -| 环境 | 远程目录 | systemd 服务 | -|------|---------|-------------| -| development | `/root/rust/rust_backend_dev` | `rust-backend-dev.service` | -| production | `/root/rust/rust_backend` | `rust-backend.service` | - -> 直接运行 `./deploy.sh` 默认部署 production 环境。 - -**test_deployment.sh 测试内容**: - -| 测试项 | 说明 | -|--------|------| -| 本地后端 | `GET http://127.0.0.1:8080/health` 返回 200 | -| 域名访问 | `GET https://xmclassmate.top/dev` 返回 200/301/302 | -| 静态文件 | CSS、JS、favicon 等文件返回 200 | - -> 部署后自动执行,测试通过才算部署成功。 - -服务器尝试端口顺序:4433、8443、8080、3000、8000、8888 diff --git a/IMPROVEMENTS.md b/IMPROVEMENTS.md index 4cc7390..5061743 100644 --- a/IMPROVEMENTS.md +++ b/IMPROVEMENTS.md @@ -1534,6 +1534,117 @@ pub struct Announcement { - [ ] 日志不记录敏感信息 - [ ] 生产环境使用强密钥 +## 十二、部署测试与自动回退 + +### 12.1 测试步骤要求 + +每次部署必须执行以下测试步骤: + +#### 部署前测试(本地) +```bash +# 1. 代码检查 +cargo clippy 2>&1 | grep -E "error|warning" || echo "✅ Clippy passed" + +# 2. 格式化检查 +cargo fmt --check || cargo fmt + +# 3. 编译测试 +APP_ENV=development cargo build 2>&1 | tail -5 + +# 4. 单元测试 +cargo test 2>&1 | tail -10 +``` + +#### 部署后测试(远程服务器) +```bash +# 部署后自动执行 test_deployment.sh,测试内容: +# 1. 健康检查 +curl -f http://127.0.0.1:8080/health || exit 1 + +# 2. 登录接口 +curl -f -X POST https://xmclassmate.top/dev/api/login \ + -H "Content-Type: application/json" \ + -d '{"code":"test"}' || exit 1 + +# 3. 静态资源 +curl -f https://xmclassmate.top/dev/assets/img/favicon.png || exit 1 +``` + +### 12.2 测试失败自动回退机制 + +部署脚本必须实现测试失败时的自动回退: + +```bash +#!/bin/bash +# deploy.sh 核心逻辑 + +ENV=${1:-production} +REMOTE_DIR="/root/rust/rust_backend" +SERVICE_NAME="rust-backend.service" +BACKUP_DIR="/root/rust/backups" + +# 1. 部署前备份 +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +if [ -f "$REMOTE_DIR/rust-backend" ]; then + mkdir -p $BACKUP_DIR + cp "$REMOTE_DIR/rust-backend" "$BACKUP_DIR/rust-backend-$TIMESTAMP" + echo "✅ 备份已创建: rust-backend-$TIMESTAMP" +fi + +# 2. 执行部署 +rsync target/release/rust-backend root@1panel-server:$REMOTE_DIR/ +ssh root@1panel-server "systemctl restart $SERVICE_NAME" +sleep 3 + +# 3. 执行测试 +echo "🔍 执行部署后测试..." +if ssh root@1panel-server "bash $REMOTE_DIR/test_deployment.sh"; then + echo "✅ 部署成功" +else + echo "❌ 测试失败,执行回退..." + + # 回退到上一个版本 + LAST_BACKUP=$(ls -t $BACKUP_DIR/rust-backend-* | head -1) + if [ -n "$LAST_BACKUP" ]; then + rsync "$LAST_BACKUP" root@1panel-server:$REMOTE_DIR/rust-backend + ssh root@1panel-server "systemctl restart $SERVICE_NAME" + echo "✅ 已回退到: $(basename $LAST_BACKUP)" + else + echo "⚠️ 无可用备份,回退失败" + exit 1 + fi +fi +``` + +### 12.3 回退触发条件 + +以下情况自动触发回退: + +| 测试项 | 失败条件 | 影响 | +|--------|----------|------| +| 健康检查 | `curl -f` 返回非0 | 服务无法启动 | +| 登录接口 | HTTP 状态码 != 200 | 核心功能不可用 | +| 静态资源 | HTTP 状态码 >= 400 | 页面显示异常 | +| systemd 状态 | `active (running)` 以外 | 服务启动失败 | + +### 12.4 回退操作限制 + +- **仅自动回退二进制文件**:`rust-backend` 可执行文件 +- **配置不回退**:配置文件(`config/*.toml`)保持新版本 +- **数据库不回退**:数据库变更需要手动处理 +- **日志保留**:回退后旧版本日志仍保存在 `$REMOTE_DIR/logs/` + +### 12.5 备份保留策略 + +```bash +# 保留最近 10 个备份 +BACKUP_COUNT=$(ls $BACKUP_DIR/rust-backend-* 2>/dev/null | wc -l) +if [ $BACKUP_COUNT -gt 10 ]; then + ls -t $BACKUP_DIR/rust-backend-* | tail -$((BACKUP_COUNT - 10)) | xargs rm -f + echo "🗑️ 已清理旧备份,保留最近 10 个" +fi +``` + --- **最后更新**:2026-04-17 diff --git a/src/db.rs b/src/db.rs index 1f4144c..b6ef3eb 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,9 +5,10 @@ use chrono::Utc; // 从 models 模块引入 WeatherData 结构体 use crate::models::{User, WeatherData, WeatherDataBrief, WeatherListResponse}; +use crate::error::AppError; // 用于插入weather_data的数据 -pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user_id: i32) -> Result { +pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user_id: i32) -> Result { // 配额检查:非付费用户数据条数限制 let user = get_user_by_id(pool, user_id).await?; let is_paid_active = user.is_paid && (user.paid_expires_at.is_none() || user.paid_expires_at.unwrap() > Utc::now()); @@ -20,7 +21,7 @@ pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user .unwrap_or(20); if current_count >= limit { - return Err("数据条数已达上限,请升级为付费用户".to_string()); + return Err(AppError::Forbidden("数据条数已达上限,请升级为付费用户".to_string())); } } @@ -68,13 +69,13 @@ pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user .bind(&weather_data.suitability_degree) .bind( serde_json::to_value(&weather_data.wind_direction) - .map_err(|e| format!("JSON 序列化失败: {}", e))?, + .map_err(|e| AppError::Internal(format!("JSON 序列化失败: {}", e)))?, ) .bind(weather_data.average_wind_direction) .bind(weather_data.wind_direction_standard_deviation) .bind( serde_json::to_value(&weather_data.wind_speed) - .map_err(|e| format!("JSON 序列化失败: {}", e))?, + .map_err(|e| AppError::Internal(format!("JSON 序列化失败: {}", e)))?, ) .bind(weather_data.average_wind_speed) .bind(&weather_data.wind_speed_suitability) @@ -91,7 +92,7 @@ pub async fn insert_weather_data(pool: &PgPool, weather_data: &WeatherData, user { Ok((id,)) => id, Err(e) => { - return Err(format!("插入数据失败: {}", e)); + return Err(AppError::Database(format!("插入数据失败: {}", e))); } }; @@ -116,7 +117,7 @@ pub async fn create_pool() -> Result> { } // 获取天气数据详情 -pub async fn get_weather_details(pool: &PgPool, weather_id: i32) -> Result { +pub async fn get_weather_details(pool: &PgPool, weather_id: i32) -> Result { let query = r#" SELECT wd.id, wd.title, wd.date, wd.hour, wd.min, @@ -142,8 +143,8 @@ pub async fn get_weather_details(pool: &PgPool, weather_id: i32) -> Result row, - Ok(None) => return Err(format!("未找到ID为 {} 的天气数据", weather_id)), - Err(e) => return Err(format!("查询天气数据失败: {}", e)), + Ok(None) => return Err(AppError::NotFound(format!("未找到ID为 {} 的天气数据", weather_id))), + Err(e) => return Err(AppError::Database(format!("查询天气数据失败: {}", e))), }; Ok(row) @@ -155,7 +156,7 @@ pub async fn get_weather_list( user_id: i32, page: i32, limit: i32, -) -> Result { +) -> Result { let offset = (page - 1) * limit; // 1. 查询符合条件的总条数 @@ -170,7 +171,7 @@ pub async fn get_weather_list( .await { Ok((count,)) => count, - Err(e) => return Err(format!("查询总条数失败: {}", e)), + Err(e) => return Err(AppError::Database(format!("查询总条数失败: {}", e))), }; // 2. 查询当前页数据列表 @@ -190,7 +191,7 @@ pub async fn get_weather_list( .await { Ok(data) => data, - Err(e) => return Err(format!("查询天气数据列表失败: {}", e)), + Err(e) => return Err(AppError::Database(format!("查询天气数据列表失败: {}", e))), }; // 3. 包装结果并返回 @@ -201,7 +202,7 @@ pub async fn delete_weather_data( pool: &PgPool, weather_id: i32, user_id: i32, -) -> Result<(), String> { +) -> Result<(), AppError> { let query = r#" DELETE FROM weather_data WHERE id = $1 AND user_id = $2 @@ -212,17 +213,17 @@ pub async fn delete_weather_data( .bind(user_id) .execute(pool) .await - .map_err(|e| format!("删除天气数据失败: {}", e))?; + .map_err(|e| AppError::Database(format!("删除天气数据失败: {}", e)))?; if result.rows_affected() == 0 { - return Err(format!("未找到ID为 {} 的天气数据或无权限删除", weather_id)); + return Err(AppError::NotFound(format!("未找到ID为 {} 的天气数据或无权限删除", weather_id))); } Ok(()) } // 根据用户ID获取用户信息 -pub async fn get_user_by_id(pool: &PgPool, user_id: i32) -> Result { +pub async fn get_user_by_id(pool: &PgPool, user_id: i32) -> Result { let query = r#" SELECT id, name, openid, phone, type, "desc", is_paid, is_admin, paid_expires_at, @@ -237,15 +238,15 @@ pub async fn get_user_by_id(pool: &PgPool, user_id: i32) -> Result .await { Ok(Some(row)) => row, - Ok(None) => return Err(format!("未找到ID为 {} 的用户", user_id)), - Err(e) => return Err(format!("查询用户信息失败: {}", e)), + Ok(None) => return Err(AppError::NotFound(format!("未找到ID为 {} 的用户", user_id))), + Err(e) => return Err(AppError::Database(format!("查询用户信息失败: {}", e))), }; Ok(row) } // 统计用户的天气数据条数 -pub async fn count_user_weather_data(pool: &PgPool, user_id: i32) -> Result { +pub async fn count_user_weather_data(pool: &PgPool, user_id: i32) -> Result { let query = r#" SELECT COUNT(*) FROM weather_data WHERE user_id = $1 "#; @@ -256,7 +257,7 @@ pub async fn count_user_weather_data(pool: &PgPool, user_id: i32) -> Result count, - Err(e) => return Err(format!("查询天气数据条数失败: {}", e)), + Err(e) => return Err(AppError::Database(format!("查询天气数据条数失败: {}", e))), }; Ok(count) @@ -268,7 +269,7 @@ pub async fn update_user_payment_status( user_id: i32, is_paid: bool, paid_expires_at: Option>, -) -> Result<(), String> { +) -> Result<(), AppError> { let query = r#" UPDATE users SET is_paid = $1, paid_expires_at = $2 @@ -283,7 +284,7 @@ pub async fn update_user_payment_status( .await { Ok(_) => Ok(()), - Err(e) => Err(format!("更新用户付费状态失败: {}", e)), + Err(e) => Err(AppError::Database(format!("更新用户付费状态失败: {}", e))), } } @@ -293,7 +294,7 @@ pub async fn update_user_profile( user_id: i32, nickname: &Option, avatar_url: &Option, -) -> Result<(), String> { +) -> Result<(), AppError> { let query = r#" UPDATE users SET nickname = COALESCE($1, nickname), @@ -309,7 +310,7 @@ pub async fn update_user_profile( .await { Ok(_) => Ok(()), - Err(e) => Err(format!("更新用户个人信息失败: {}", e)), + Err(e) => Err(AppError::Database(format!("更新用户个人信息失败: {}", e))), } } @@ -323,7 +324,7 @@ pub async fn create_payment_order( package_type: &str, amount: i32, expires_at: Option>, -) -> Result<(), String> { +) -> Result<(), AppError> { let query = r#" INSERT INTO payment_orders (user_id, order_no, package_type, amount, expires_at) VALUES ($1, $2, $3, $4, $5) @@ -337,7 +338,7 @@ pub async fn create_payment_order( .bind(expires_at) .execute(pool) .await - .map_err(|e| format!("创建订单失败: {}", e))?; + .map_err(|e| AppError::Database(format!("创建订单失败: {}", e)))?; Ok(()) } @@ -349,26 +350,26 @@ pub async fn confirm_payment_order( pool: &PgPool, order_no: &str, user_id: i32, -) -> Result>, String> { +) -> Result>, AppError> { let row = sqlx::query_as::<_, (i32, String, Option>)>( r#"SELECT user_id, status, expires_at FROM payment_orders WHERE order_no = $1"#, ) .bind(order_no) .fetch_optional(pool) .await - .map_err(|e| format!("查询订单失败: {}", e))?; + .map_err(|e| AppError::Database(format!("查询订单失败: {}", e)))?; let (order_user_id, status, expires_at) = match row { Some(r) => r, - None => return Err("订单不存在".to_string()), + None => return Err(AppError::NotFound("订单不存在".to_string())), }; if order_user_id != user_id { - return Err("无权操作此订单".to_string()); + return Err(AppError::Forbidden("无权操作此订单".to_string())); } if status != "pending" { - return Err("订单状态异常,无法确认支付".to_string()); + return Err(AppError::BadRequest("订单状态异常,无法确认支付".to_string())); } sqlx::query( @@ -377,7 +378,7 @@ pub async fn confirm_payment_order( .bind(order_no) .execute(pool) .await - .map_err(|e| format!("更新订单状态失败: {}", e))?; + .map_err(|e| AppError::Database(format!("更新订单状态失败: {}", e)))?; sqlx::query( r#"UPDATE users SET is_paid = true, paid_expires_at = $1 WHERE id = $2"#, @@ -386,7 +387,7 @@ pub async fn confirm_payment_order( .bind(user_id) .execute(pool) .await - .map_err(|e| format!("更新用户付费状态失败: {}", e))?; + .map_err(|e| AppError::Database(format!("更新用户付费状态失败: {}", e)))?; Ok(expires_at) } @@ -397,7 +398,7 @@ pub async fn confirm_payment_order( pub async fn get_user_quota( pool: &PgPool, user_id: i32, -) -> Result<(i64, bool, Option>), String> { +) -> Result<(i64, bool, Option>), AppError> { let user = get_user_by_id(pool, user_id).await?; let is_paid_active = user.is_paid && (user.paid_expires_at.is_none() @@ -414,7 +415,7 @@ pub async fn get_favorites_list( user_id: i32, page: i32, limit: i32, -) -> Result { +) -> Result { let offset = (page - 1) * limit; let total_query = r#" @@ -426,7 +427,7 @@ pub async fn get_favorites_list( .bind(user_id) .fetch_one(pool) .await - .map_err(|e| format!("查询收藏总数失败: {}", e))? + .map_err(|e| AppError::Database(format!("查询收藏总数失败: {}", e)))? .0; let list_query = r#" @@ -443,7 +444,7 @@ pub async fn get_favorites_list( .bind(offset) .fetch_all(pool) .await - .map_err(|e| format!("查询收藏列表失败: {}", e))?; + .map_err(|e| AppError::Database(format!("查询收藏列表失败: {}", e)))?; Ok(WeatherListResponse { list, total }) } @@ -454,7 +455,7 @@ pub async fn set_weather_favorite( weather_id: i32, user_id: i32, is_favorite: bool, -) -> Result<(), String> { +) -> Result<(), AppError> { let query = r#" UPDATE weather_data SET is_favorite = $1 @@ -467,10 +468,10 @@ pub async fn set_weather_favorite( .bind(user_id) .execute(pool) .await - .map_err(|e| format!("更新收藏状态失败: {}", e))?; + .map_err(|e| AppError::Database(format!("更新收藏状态失败: {}", e)))?; if result.rows_affected() == 0 { - return Err(format!("未找到ID为 {} 的天气数据或无权限修改", weather_id)); + return Err(AppError::NotFound(format!("未找到ID为 {} 的天气数据或无权限修改", weather_id))); } Ok(()) diff --git a/src/handlers/admin.rs b/src/handlers/admin.rs index 3f57167..4fd344a 100644 --- a/src/handlers/admin.rs +++ b/src/handlers/admin.rs @@ -1,57 +1,32 @@ -use actix_web::{web, get, put, HttpResponse, Responder}; +use actix_web::{web, get, put, HttpResponse}; use sqlx::postgres::PgPool; -use tracing::error; use chrono::{DateTime, Utc}; use crate::db; -use crate::models::{Claims, ErrorResponse, UpdatePaymentRequest}; +use crate::error::AppError; +use crate::models::{Claims, UpdatePaymentRequest}; #[get("/api/admin/users/{id}")] pub async fn admin_get_user( path: web::Path, pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let target_user_id = path.into_inner(); tracing::info!("管理员获取用户信息, 目标用户ID: {}", target_user_id); // 验证当前用户是否为管理员 - let current_user = match db::get_user_by_id(pool.get_ref(), claims.user_id).await { - Ok(user) => user, - Err(e) => { - error!("获取当前用户信息失败: {}", e); - return HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": "获取用户信息失败" - })); - } - }; - + let current_user = db::get_user_by_id(pool.get_ref(), claims.user_id).await?; if !current_user.is_admin { - return HttpResponse::Forbidden().json(serde_json::json!({ - "success": false, - "errcode": 403, - "errmsg": "无权限执行此操作" - })); + return Err(AppError::Forbidden("无权限执行此操作".to_string())); } // 获取目标用户信息 - match db::get_user_by_id(pool.get_ref(), target_user_id).await { - Ok(user) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "data": user - })), - Err(e) => { - error!("获取目标用户信息失败: {}", e); - let errcode = if e.starts_with("未找到") { 404 } else { 500 }; - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": errcode, - "errmsg": e - })) - } - } + let user = db::get_user_by_id(pool.get_ref(), target_user_id).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": user + }))) } #[put("/api/admin/users/{id}/payment")] @@ -60,29 +35,14 @@ pub async fn admin_update_user_payment( pool: web::Data, claims: web::ReqData, body: web::Json, -) -> impl Responder { +) -> Result { let target_user_id = path.into_inner(); tracing::info!("管理员更新用户付费状态, 目标用户ID: {}", target_user_id); // 验证当前用户是否为管理员 - let current_user = match db::get_user_by_id(pool.get_ref(), claims.user_id).await { - Ok(user) => user, - Err(e) => { - error!("获取当前用户信息失败: {}", e); - return HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": "获取用户信息失败" - })); - } - }; - + let current_user = db::get_user_by_id(pool.get_ref(), claims.user_id).await?; if !current_user.is_admin { - return HttpResponse::Forbidden().json(serde_json::json!({ - "success": false, - "errcode": 403, - "errmsg": "无权限执行此操作" - })); + return Err(AppError::Forbidden("无权限执行此操作".to_string())); } // 解析 paid_expires_at @@ -90,29 +50,16 @@ pub async fn admin_update_user_payment( Some(date_str) => match DateTime::parse_from_rfc3339(date_str) { Ok(dt) => Some(dt.with_timezone(&Utc)), Err(e) => { - return HttpResponse::BadRequest().json(serde_json::json!({ - "success": false, - "errcode": 400, - "errmsg": format!("日期格式错误: {}", e) - })); + return Err(AppError::BadRequest(format!("日期格式错误: {}", e))); } }, None => None, }; // 更新用户付费状态 - match db::update_user_payment_status(pool.get_ref(), target_user_id, body.is_paid, paid_expires_at).await { - Ok(_) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "message": "用户付费状态已更新" - })), - Err(e) => { - error!("更新用户付费状态失败: {}", e); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": e - })) - } - } + db::update_user_payment_status(pool.get_ref(), target_user_id, body.is_paid, paid_expires_at).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "用户付费状态已更新" + }))) } diff --git a/src/handlers/favorites.rs b/src/handlers/favorites.rs index a47ada8..ae54431 100644 --- a/src/handlers/favorites.rs +++ b/src/handlers/favorites.rs @@ -1,8 +1,9 @@ -use actix_web::{web, delete, get, post, HttpResponse, Responder}; +use actix_web::{web, delete, get, post, HttpResponse}; use sqlx::postgres::PgPool; -use tracing::{debug, error, info}; +use tracing::{debug, info}; use crate::db; +use crate::error::AppError; use crate::models::Claims; // GET /api/favorites - 获取收藏列表 @@ -11,31 +12,21 @@ pub async fn get_favorites( query: web::Query, pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let page = query.get("page").and_then(|v| v.as_i64()).unwrap_or(1).max(1) as i32; let limit = query.get("limit").and_then(|v| v.as_i64()).unwrap_or(10).clamp(1, 100) as i32; debug!("获取收藏列表, 页码: {}, 每页条数: {}", page, limit); - match db::get_favorites_list(pool.get_ref(), claims.user_id, page, limit).await { - Ok(response) => { - HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "data": response.list, - "page": page, - "limit": limit, - "total": response.total - })) - } - Err(error_msg) => { - error!("获取收藏列表失败: {}", error_msg); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": error_msg - })) - } - } + let response = db::get_favorites_list(pool.get_ref(), claims.user_id, page, limit).await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": response.list, + "page": page, + "limit": limit, + "total": response.total + }))) } // POST /api/favorites/{id} - 添加收藏 @@ -44,25 +35,16 @@ pub async fn add_favorite( path: web::Path, pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let weather_id = path.into_inner(); info!("添加收藏, weather_id: {}", weather_id); - match db::set_weather_favorite(pool.get_ref(), weather_id, claims.user_id, true).await { - Ok(_) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "message": format!("已添加收藏") - })), - Err(error_msg) => { - error!("添加收藏失败: {}", error_msg); - let errcode = if error_msg.starts_with("未找到") { 404 } else { 500 }; - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": errcode, - "errmsg": error_msg - })) - } - } + db::set_weather_favorite(pool.get_ref(), weather_id, claims.user_id, true).await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "已添加收藏" + }))) } // DELETE /api/favorites/{id} - 取消收藏 @@ -71,23 +53,14 @@ pub async fn remove_favorite( path: web::Path, pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let weather_id = path.into_inner(); info!("取消收藏, weather_id: {}", weather_id); - match db::set_weather_favorite(pool.get_ref(), weather_id, claims.user_id, false).await { - Ok(_) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "message": format!("已取消收藏") - })), - Err(error_msg) => { - error!("取消收藏失败: {}", error_msg); - let errcode = if error_msg.starts_with("未找到") { 404 } else { 500 }; - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": errcode, - "errmsg": error_msg - })) - } - } + db::set_weather_favorite(pool.get_ref(), weather_id, claims.user_id, false).await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "已取消收藏" + }))) } \ No newline at end of file diff --git a/src/handlers/payment.rs b/src/handlers/payment.rs index 96949b2..925eeb3 100644 --- a/src/handlers/payment.rs +++ b/src/handlers/payment.rs @@ -1,11 +1,11 @@ // handlers/payment.rs — 支付相关处理器 -use actix_web::{get, post, web, HttpResponse, Responder}; +use actix_web::{get, post, web, HttpResponse}; use chrono::Utc; use sqlx::postgres::PgPool; -use tracing::error; use uuid::Uuid; use crate::db; +use crate::error::AppError; use crate::models::{Claims, CreateOrderRequest, MockConfirmRequest}; struct PackageInfo { @@ -45,24 +45,20 @@ pub async fn create_order( pool: web::Data, claims: web::ReqData, body: web::Json, -) -> impl Responder { +) -> Result { let user_id = claims.user_id; let pkg = match get_package_info(&body.package_type) { Some(p) => p, None => { - return HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 400, - "errmsg": "无效的套餐类型" - })); + return Err(AppError::BadRequest("无效的套餐类型".to_string())); } }; let order_no = Uuid::new_v4().to_string(); let expires_at = pkg.days.map(|d| Utc::now() + chrono::Duration::days(d)); - match db::create_payment_order( + db::create_payment_order( pool.get_ref(), user_id, &order_no, @@ -70,28 +66,19 @@ pub async fn create_order( pkg.amount, expires_at, ) - .await - { - Ok(_) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "data": { - "order_id": order_no, - "package_type": body.package_type, - "amount": pkg.amount, - "display_amount": pkg.display_amount, - "display_name": pkg.display_name, - "expires_at": expires_at, - } - })), - Err(e) => { - error!("创建订单失败: {}", e); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": "创建订单失败,请重试" - })) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": { + "order_id": order_no, + "package_type": body.package_type, + "amount": pkg.amount, + "display_amount": pkg.display_amount, + "display_name": pkg.display_name, + "expires_at": expires_at, } - } + }))) } /// POST /api/payment/mock-confirm @@ -100,26 +87,18 @@ pub async fn mock_confirm( pool: web::Data, claims: web::ReqData, body: web::Json, -) -> impl Responder { +) -> Result { let user_id = claims.user_id; - match db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await { - Ok(expires_at) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "data": { - "is_paid_active": true, - "paid_expires_at": expires_at, - } - })), - Err(e) => { - error!("确认支付失败: {}", e); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 400, - "errmsg": "支付确认失败,请重试" - })) + let expires_at = db::confirm_payment_order(pool.get_ref(), &body.order_id, user_id).await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": { + "is_paid_active": true, + "paid_expires_at": expires_at, } - } + }))) } /// GET /api/user/quota @@ -127,34 +106,23 @@ pub async fn mock_confirm( pub async fn get_user_quota( pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let user_id = claims.user_id; - match db::get_user_quota(pool.get_ref(), user_id).await { - Ok((used, is_paid_active, paid_expires_at)) => { - let limit: i64 = std::env::var("FREE_USER_DATA_LIMIT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(20); + let (used, is_paid_active, paid_expires_at) = db::get_user_quota(pool.get_ref(), user_id).await?; + let limit: i64 = std::env::var("FREE_USER_DATA_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(20); - HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "data": { - "used": used, - "limit": limit, - "unlimited": is_paid_active, - "is_paid_active": is_paid_active, - "paid_expires_at": paid_expires_at, - } - })) + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": { + "used": used, + "limit": limit, + "unlimited": is_paid_active, + "is_paid_active": is_paid_active, + "paid_expires_at": paid_expires_at, } - Err(e) => { - error!("获取配额信息失败: {}", e); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": "获取配额信息失败" - })) - } - } + }))) } \ No newline at end of file diff --git a/src/handlers/user.rs b/src/handlers/user.rs index 5211bdc..e9828b1 100644 --- a/src/handlers/user.rs +++ b/src/handlers/user.rs @@ -1,47 +1,37 @@ -use actix_web::{web, get, put, HttpResponse, Responder}; +use actix_web::{web, get, put, HttpResponse}; use sqlx::postgres::PgPool; -use tracing::error; +use tracing::info; use serde::Deserialize; use crate::db; -use crate::models::{Claims, ErrorResponse}; +use crate::error::AppError; +use crate::models::Claims; #[get("/api/user/profile")] pub async fn get_current_user_profile( pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let user_id = claims.user_id; - tracing::info!("获取当前用户信息, 用户ID: {}", user_id); + info!("获取当前用户信息, 用户ID: {}", user_id); - match db::get_user_by_id(pool.get_ref(), user_id).await { - Ok(user) => { - let is_paid_active = user.is_paid && - (user.paid_expires_at.is_none() || user.paid_expires_at.unwrap() > chrono::Utc::now()); - - HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "data": { - "id": user.id, - "name": user.name, - "nickname": user.nickname, - "avatarUrl": user.avatar_url, - "is_paid": user.is_paid, - "is_paid_active": is_paid_active, - "is_admin": user.is_admin, - "paid_expires_at": user.paid_expires_at - } - })) + let user = db::get_user_by_id(pool.get_ref(), user_id).await?; + let is_paid_active = user.is_paid && + (user.paid_expires_at.is_none() || user.paid_expires_at.unwrap() > chrono::Utc::now()); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": { + "id": user.id, + "name": user.name, + "nickname": user.nickname, + "avatarUrl": user.avatar_url, + "is_paid": user.is_paid, + "is_paid_active": is_paid_active, + "is_admin": user.is_admin, + "paid_expires_at": user.paid_expires_at } - Err(e) => { - error!("获取用户信息失败: {}", e); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": e - })) - } - } + }))) } #[derive(Debug, Deserialize)] @@ -55,23 +45,12 @@ pub async fn save_user_profile( pool: web::Data, claims: web::ReqData, body: web::Json, -) -> impl Responder { +) -> Result { let user_id = claims.user_id; - tracing::info!("保存用户信息, 用户ID: {}", user_id); + info!("保存用户信息, 用户ID: {}", user_id); - match db::update_user_profile(pool.get_ref(), user_id, &body.nickname, &body.avatar_url).await { - Ok(_) => { - HttpResponse::Ok().json(serde_json::json!({ - "success": true - })) - } - Err(e) => { - error!("保存用户信息失败: {}", e); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": e - })) - } - } + db::update_user_profile(pool.get_ref(), user_id, &body.nickname, &body.avatar_url).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true + }))) } diff --git a/src/handlers/weather.rs b/src/handlers/weather.rs index e37b183..1944725 100644 --- a/src/handlers/weather.rs +++ b/src/handlers/weather.rs @@ -1,45 +1,35 @@ -use actix_web::{web, delete, get, post, HttpResponse, Responder}; +use actix_web::{web, delete, get, post, HttpResponse}; use sqlx::postgres::PgPool; -use tracing::{debug, error, info}; +use tracing::{debug, info}; use crate::auth; use crate::db; +use crate::error::AppError; use crate::handlers::TEMPLATES_DIR; -use crate::models::{AppState, Claims, ErrorResponse}; +use crate::models::{AppState, Claims}; #[post("/api/post-weather-data")] pub async fn post_weather_data( data: web::Json, pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { info!("Received weather data, preparing to insert into DB..."); let user_id = claims.user_id; // 调用 db.rs 中的函数来处理数据库逻辑 - match db::insert_weather_data(pool.get_ref(), &data, user_id).await { - Ok(inserted_id) => { - info!( - "Successfully inserted weather data with id: {}", - inserted_id - ); - HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "message": "Weather data inserted successfully", - "inserted_id": inserted_id, - "received_assignment": data.assignment_number - })) - } - Err(error_msg) => { - error!("Database operation failed: {}", error_msg); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": error_msg - })) - } - } + let inserted_id = db::insert_weather_data(pool.get_ref(), &data, user_id).await?; + info!( + "Successfully inserted weather data with id: {}", + inserted_id + ); + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": "Weather data inserted successfully", + "inserted_id": inserted_id, + "received_assignment": data.assignment_number + }))) } #[post("/api/generate-temp-token/{resource_id}")] @@ -48,45 +38,23 @@ pub async fn generate_temp_token_handler( pool: web::Data, claims: web::ReqData, app_state: web::Data, -) -> impl Responder { +) -> Result { let resource_id = path.into_inner(); let openid: &String = &claims.openid; - match db::get_weather_details(pool.get_ref(), resource_id).await { - Ok(weather_data) => { - if weather_data.openid != *openid { - return HttpResponse::Forbidden().json(ErrorResponse { - error: "无权限为该资源生成临时token".to_string(), - errcode: Some(403), - errmsg: None, - }); - } - } - Err(e) => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: format!("资源不存在: {}", e), - errcode: Some(404), - errmsg: None, - }); - } + let weather_data = db::get_weather_details(pool.get_ref(), resource_id).await?; + if weather_data.openid != *openid { + return Err(AppError::Forbidden("无权限为该资源生成临时token".to_string())); } - let temp_token = match auth::generate_temp_token(openid, resource_id, &app_state.jwt_secret, 10) { - Ok(token) => token, - Err(e) => { - return HttpResponse::InternalServerError().json(ErrorResponse { - error: format!("生成临时token失败: {}", e), - errcode: Some(500), - errmsg: None, - }); - } - }; + let temp_token = auth::generate_temp_token(openid, resource_id, &app_state.jwt_secret, 10) + .map_err(|e| AppError::Internal(format!("生成临时token失败: {}", e)))?; - HttpResponse::Ok().json(serde_json::json!({ + Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "temp_token": temp_token, "expire_minutes": 10, - })) + }))) } #[get("/weather/details")] @@ -95,22 +63,14 @@ pub async fn get_weather_details( claims: Option>, query: web::Query, app_state: web::Data, -) -> impl Responder { +) -> Result { let mut is_temp_token = false; let (openid, weather_id) = if let Some(temp_token) = query.get("temp_token").and_then(|v| v.as_str()) { is_temp_token = true; - let temp_claims = match auth::verify_temp_token(temp_token, &app_state.jwt_secret) { - Ok(c) => c, - Err(e) => { - return HttpResponse::Unauthorized().json(ErrorResponse { - error: format!("临时token无效: {}", e), - errcode: Some(401), - errmsg: None, - }); - } - }; + let temp_claims = auth::verify_temp_token(temp_token, &app_state.jwt_secret) + .map_err(|e| AppError::Unauthorized(format!("临时token无效: {}", e)))?; (temp_claims.openid, temp_claims.resource_id) } else if let Some(claims) = claims { let weather_id = match query.get("id") { @@ -120,57 +80,24 @@ pub async fn get_weather_details( } Some(v) if v.is_number() => v.as_f64().unwrap() as i32, _ => { - return HttpResponse::BadRequest().json(ErrorResponse { - error: "缺少资源ID参数(id)".to_string(), - errcode: Some(400), - errmsg: None, - }); + return Err(AppError::BadRequest("缺少资源ID参数(id)".to_string())); } }; (claims.openid.clone(), weather_id) } else { - return HttpResponse::Unauthorized().json(ErrorResponse { - error: "缺少token(需提供JWT或临时token)".to_string(), - errcode: Some(401), - errmsg: None, - }); + return Err(AppError::Unauthorized("缺少token(需提供JWT或临时token)".to_string())); }; - let weather_data = match db::get_weather_details(pool.get_ref(), weather_id).await { - Ok(data) => { - if data.openid != openid { - return HttpResponse::Forbidden().json(ErrorResponse { - error: "无权限访问该数据".to_string(), - errcode: Some(403), - errmsg: None, - }); - } - data - } - Err(error_msg) => { - error!("获取天气数据详情失败: {}", error_msg); - let errcode = if error_msg.starts_with("未找到") { - 404 - } else { - 500 - }; - return HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": errcode, - "errmsg": error_msg - })); - } - }; + let weather_data = db::get_weather_details(pool.get_ref(), weather_id).await?; + if weather_data.openid != openid { + return Err(AppError::Forbidden("无权限访问该数据".to_string())); + } if is_temp_token { let index_html = match TEMPLATES_DIR.get_file("index.html") { Some(file) => file.contents_utf8().unwrap_or_default(), None => { - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "无法找到模板文件".to_string(), - errcode: Some(500), - errmsg: None, - }); + return Err(AppError::Internal("无法找到模板文件".to_string())); } }; @@ -191,14 +118,14 @@ pub async fn get_weather_details( "", ); - HttpResponse::Ok() + Ok(HttpResponse::Ok() .content_type("text/html; charset=utf-8") - .body(rendered_html) + .body(rendered_html)) } else { - HttpResponse::Ok().json(serde_json::json!({ + Ok(HttpResponse::Ok().json(serde_json::json!({ "success": true, "data": weather_data - })) + }))) } } @@ -207,7 +134,7 @@ pub async fn get_weather_brief( query: web::Query, pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let page = match query.get("page") { Some(v) => { let raw = if let Some(num) = v.as_i64() { @@ -238,29 +165,18 @@ pub async fn get_weather_brief( debug!("获取天气数据列表, 页码: {}, 每页条数: {}", page, limit); - match db::get_weather_list(pool.get_ref(), claims.user_id, page, limit).await { - Ok(response) => { - debug!( - "获取到的天气列表: {:?}, 总条数: {}", - response.list, response.total - ); - HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "data": response.list, - "page": page, - "limit": limit, - "total": response.total - })) - } - Err(error_msg) => { - error!("获取天气数据列表失败: {}", error_msg); - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": 500, - "errmsg": error_msg - })) - } - } + let response = db::get_weather_list(pool.get_ref(), claims.user_id, page, limit).await?; + debug!( + "获取到的天气列表: {:?}, 总条数: {}", + response.list, response.total + ); + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "data": response.list, + "page": page, + "limit": limit, + "total": response.total + }))) } #[delete("/weather/delete/{id}")] @@ -268,27 +184,13 @@ pub async fn delete_weather( path: web::Path, pool: web::Data, claims: web::ReqData, -) -> impl Responder { +) -> Result { let weather_id = path.into_inner(); info!("删除天气数据, ID: {}", weather_id); - match db::delete_weather_data(pool.get_ref(), weather_id, claims.user_id).await { - Ok(_) => HttpResponse::Ok().json(serde_json::json!({ - "success": true, - "message": format!("天气数据 {} 已成功删除", weather_id) - })), - Err(error_msg) => { - error!("删除天气数据失败: {}", error_msg); - let errcode = if error_msg.starts_with("未找到") { - 404 - } else { - 500 - }; - HttpResponse::Ok().json(serde_json::json!({ - "success": false, - "errcode": errcode, - "errmsg": error_msg - })) - } - } + db::delete_weather_data(pool.get_ref(), weather_id, claims.user_id).await?; + Ok(HttpResponse::Ok().json(serde_json::json!({ + "success": true, + "message": format!("天气数据 {} 已成功删除", weather_id) + }))) } diff --git a/src/main.rs b/src/main.rs index 3c9eab8..a32921d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ use std::pin::Pin; mod auth; mod config; mod db; +mod error; mod handlers; mod models;