From e14c85436b7842188d671d1fe87cd5907ef1d6a9 Mon Sep 17 00:00:00 2001 From: Milky0217 Date: Sun, 19 Apr 2026 16:01:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=B7=BB=E5=8A=A0=20Refresh=20To?= =?UTF-8?q?ken=20=E5=8F=8C=20Token=20=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 /api/refresh-token 接口支持 Token 续期 - 登录接口返回 access_token 和 refresh_token - 新增 refresh_tokens 表存储 refresh_token - 部署脚本添加数据库备份和迁移功能 - deploy.sh 添加 4 项 API 测试 - 更新 AGENTS.md 文档 --- AGENTS.md | 93 +++++++- Cargo.lock | 1 + Cargo.toml | 1 + deploy.sh | 520 +++++++++++++++++++++++++++++++++++++++++-- src/auth.rs | 70 ++++-- src/db.rs | 88 +++++++- src/handlers/auth.rs | 135 ++++++++--- src/handlers/mod.rs | 3 + src/main.rs | 9 +- src/models.rs | 48 +++- test_deployment.sh | 2 +- 11 files changed, 872 insertions(+), 98 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f5974f6..7262c62 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,13 +46,53 @@ config/ migrations/ # 数据库迁移 SQL tests/ # 集成测试 -static/ # 静态文件(KaTeX 等) +static/ # 静态文件 +│ ├── css/ +│ │ ├── report.css # PDF 报表样式 +│ │ └── style.css # 通用样式 +│ ├── js/ +│ │ ├── afterbody.js # PDF 生成模块(html2pdf) +│ │ └── ... +│ └── katex/ # KaTeX 数学公式 deploy.sh # 部署脚本 .env.example # 环境变量模板 ``` --- +## PDF 报表生成 + +### 功能位置 + +`static/js/afterbody.js` - 使用 `html2pdf` 库生成 PDF 报表 + +### 依赖 + +- `html2pdf` - HTML 转 PDF 库 +- `jspdf` - PDF 生成库 +- `html2canvas` - HTML 转图片库 + +### 静态文件依赖 + +| 文件 | 用途 | +|------|------| +| `/static/css/style.css` | PDF 报表样式 | +| `/static/js/afterbody.js` | PDF 生成逻辑 | + +### 生成流程 + +1. 前端调用后端获取天气数据 +2. 后端返回 `weatherData` JSON +3. 前端加载 `afterbody.js`,将数据注入 `window.weatherData` +4. `afterbody.js` 构建 HTML 并调用 `html2pdf` 生成 PDF + +### 注意事项 + +- ⚠️ 生成 PDF 前需确保 `/static/css/report.css` 存在 +- 建议在 PDF 生成前检测静态文件可用性 + +--- + ## 配置管理 ### 配置文件方式(推荐) @@ -123,14 +163,35 @@ user_id INTEGER(外键,关联 users 表) -- 30+ 个天气测量字段列 ``` +#### refresh_tokens 表 + +```sql +id SERIAL PRIMARY KEY +user_id INTEGER(外键,关联 users 表) +token VARCHAR(Refresh Token 字符串) +expires_at TIMESTAMPTZ(过期时间) +created_at TIMESTAMPTZ(创建时间) +``` + --- ## 认证流程 -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 用户 → 返回双 Token +2. **双 Token 机制**: + - `token`: access_token(24小时),用于 API 认证 + - `refresh_token`: 7天有效期,用于续期 access_token +3. **JWT 声明**:`{exp, iat, user_id, openid, user_type}` +4. **中间件**:`jwt_middleware` 提取 Bearer 令牌,验证后将 Claims 插入请求扩展 +5. **处理器访问**:通过 `claims: web::ReqData` 参数获取 +6. **Token 刷新**:`POST /api/refresh-token` 用 refresh_token 换取新的 access_token 和 refresh_token + +### 公开接口 + +| 接口 | 说明 | +|------|------| +| `POST /api/login` | 微信登录,返回双 Token | +| `POST /api/refresh-token` | 刷新 access_token | --- @@ -359,11 +420,28 @@ let new_expires = base_time + days(pkg_days); ./deploy.sh production --backup-list # 列出可用备份 ``` -**deploy.sh 功能**:依赖检查 → 编译 → 备份旧版本 → 上传二进制/配置 → 重启服务 → 部署后测试 → 记录日志 → Webhook 通知 +**deploy.sh 功能**:依赖检查 → 编译 → 备份旧版本 → 清理旧备份(保留5个)→ 上传二进制/配置/迁移/脚本 → 重启服务 → **数据库备份** → **执行迁移** → 部署后测试 → 记录日志 → Webhook 通知 + +**二进制备份**:自动保留最近 5 个备份,超出数量的旧备份在每次部署时自动清理。 + +**数据库备份**: +- 位置:`${REMOTE_DIR}/backups/${DB_NAME}_*.dump` +- 保留数量:1个 +- 格式:`pg_dump -Fc`(自定义格式,可压缩) + +**数据库迁移**: +- 位置:`${REMOTE_DIR}/migrations/*.sql` +- 自动检测:检查表是否存在,跳过已执行的迁移 +- 迁移文件名格式:`{序号}_{描述}.sql` + +**自动回滚**:部署后测试失败时,自动回滚到上一个正常版本并重启服务。 +- 回滚标记保存在 `${REMOTE_DIR}/.last_deployed` +- 测试失败后自动执行,无需手动干预 +- Webhook 通知会发送失败回滚消息 **回滚:** ```bash -./deploy.sh production --rollback # 回滚生产环境 +./deploy.sh production --rollback # 回滚生产环境(仅二进制) ``` **Webhook 通知(可选):** @@ -385,6 +463,7 @@ WEBHOOK_URL="https://example.com/webhook" ./deploy.sh production **deploy.sh 已包含的安全保障:** - ✅ 部署前自动备份旧版本 +- ✅ 部署前自动备份数据库 - ✅ 自动检查依赖 - ✅ 自动测试 - ✅ 部署日志记录 diff --git a/Cargo.lock b/Cargo.lock index 373a73f..49534c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1964,6 +1964,7 @@ version = "0.1.0" dependencies = [ "actix-files", "actix-web", + "base64", "chrono", "dotenvy", "error", diff --git a/Cargo.toml b/Cargo.toml index ef458c4..ba0421c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "chr uuid = { version = "1", features = ["v4"] } tokio = { version = "1", features = ["full"] } toml = "0.8" +base64 = "0.22" [dev-dependencies] tokio = { version = "1", features = ["full"] } diff --git a/deploy.sh b/deploy.sh index 31709b2..6d37d97 100755 --- a/deploy.sh +++ b/deploy.sh @@ -27,6 +27,9 @@ SKIP_TESTS=false ROLLBACK=false LIST_BACKUPS=false +# 全局变量(部署流程中使用) +BACKUP_PATH="" + # 解析选项 shift 2>/dev/null || true while [[ $# -gt 0 ]]; do @@ -70,12 +73,20 @@ case "${APP_ENV}" in development) REMOTE_DIR="/root/rust/rust_backend_dev" SERVICE_NAME="rust-backend-dev.service" - TEST_DOMAIN="https://xmclassmate.top/dev" + TEST_DOMAIN="https://dev.xmclassmate.top" + BACKEND_PORT="8080" + DB_CONTAINER="1Panel-postgresql-FtMo" + DB_NAME="milkydata_dev" + DB_USER="milkydata" ;; production) REMOTE_DIR="/root/rust/rust_backend" SERVICE_NAME="rust-backend.service" TEST_DOMAIN="https://xmclassmate.top" + BACKEND_PORT="4433" + DB_CONTAINER="1Panel-postgresql-FtMo" + DB_NAME="milkydata" + DB_USER="milkydata" ;; *) echo "无效的环境: ${APP_ENV}" @@ -174,7 +185,7 @@ list_backups() { echo "" } -# 回滚 +# 回滚(手动触发) rollback() { log_step "查找最新备份..." local latest_backup @@ -226,8 +237,85 @@ rollback() { fi log_info "回滚完成" - log_to_file "ROLLBACK to $(basename $latest_backup)" - send_webhook "rollback" "回滚完成: $(basename $latest_backup)" + log_to_file "MANUAL_ROLLBACK to $(basename $latest_backup)" + send_webhook "rollback" "手动回滚完成: $(basename $latest_backup)" +} + +# 回滚到上一个正常版本(测试失败时自动触发) +rollback_on_failure() { + local backup_path="$1" + + if [ -z "$backup_path" ]; then + log_error "回滚失败: 未指定备份路径" + return 1 + fi + + echo "" + log_error "========== 测试失败,自动回滚 ==========" + echo -e " 原因: 部署后测试未通过" + echo -e " 回滚到: ${backup_path}" + echo -e " 服务: ${SERVICE_NAME}" + echo -e "==========================================" + echo "" + + log_step "停止服务..." + if [ "$DRY_RUN" = false ]; then + ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl stop ${SERVICE_NAME}" 2>/dev/null || log_warn "停止服务失败,继续尝试..." + else + log_dry "systemctl stop ${SERVICE_NAME}" + fi + + log_step "恢复上一个正常版本..." + if [ "$DRY_RUN" = false ]; then + ssh "${REMOTE_USER}@${REMOTE_HOST}" "cp '${backup_path}' '${REMOTE_DIR}/${PROJECT_NAME}'" + else + log_dry "cp '${backup_path}' '${REMOTE_DIR}/${PROJECT_NAME}'" + fi + + log_step "启动服务..." + if [ "$DRY_RUN" = false ]; then + ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl start ${SERVICE_NAME}" + sleep 3 + else + log_dry "systemctl start ${SERVICE_NAME}" + fi + + log_info "自动回滚完成" + log_to_file "AUTO_ROLLBACK to $(basename $backup_path) - tests failed" + send_webhook "failure" "部署测试失败,已自动回滚到: $(basename $backup_path)" +} + +# 清理旧备份,只保留最近 N 个 +cleanup_old_backups() { + local keep_count="${1:-5}" + + if [ "$DRY_RUN" = true ]; then + log_dry "保留最近 ${keep_count} 个备份,删除其余" + return 0 + fi + + local backup_count + backup_count=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null | wc -l" || echo "0") + + if [ "$backup_count" -le "$keep_count" ]; then + return 0 + fi + + local to_delete + to_delete=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null | tail -n +$((keep_count + 1))" || echo "") + + if [ -z "$to_delete" ]; then + return 0 + fi + + log_step "清理旧备份(保留 ${keep_count} 个)..." + echo "$to_delete" | while read -r file; do + if [ -n "$file" ]; then + ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f '${file}'" 2>/dev/null && echo " 已删除: ${file}" || true + fi + done } # 检查依赖 @@ -296,23 +384,24 @@ build_project() { fi } -# 备份旧版本 +# 备份旧版本并保存回滚标记 backup_old_version() { log_step "备份旧版本..." local binary_path="./target/release/${PROJECT_NAME}" local timestamp timestamp=$(date +%Y%m%d_%H%M%S) - local backup_path="${REMOTE_DIR}/${PROJECT_NAME}.backup.${timestamp}" + BACKUP_PATH="${REMOTE_DIR}/${PROJECT_NAME}.backup.${timestamp}" if [ "$DRY_RUN" = true ]; then - log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'if [ -f ${REMOTE_DIR}/${PROJECT_NAME} ]; then cp ${REMOTE_DIR}/${PROJECT_NAME} ${backup_path}; fi'" + log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'if [ -f ${REMOTE_DIR}/${PROJECT_NAME} ]; then cp ${REMOTE_DIR}/${PROJECT_NAME} ${BACKUP_PATH}; echo \"\${BACKUP_PATH}\" > ${REMOTE_DIR}/.last_deployed; fi'" return 0 fi ssh "${REMOTE_USER}@${REMOTE_HOST}" " if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then - cp '${REMOTE_DIR}/${PROJECT_NAME}' '${backup_path}' - echo '已备份到: ${backup_path}' + cp '${REMOTE_DIR}/${PROJECT_NAME}' '${BACKUP_PATH}' + echo '${BACKUP_PATH}' > '${REMOTE_DIR}/.last_deployed' + echo '已备份到: ${BACKUP_PATH}' else echo '无旧版本需要备份' fi @@ -350,6 +439,23 @@ upload_config() { log_info "配置文件上传完成" } +# 上传迁移文件 +upload_migrations() { + log_step "上传迁移文件..." + if [ "$DRY_RUN" = true ]; then + log_dry "rsync -avzP ./migrations/ '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/migrations/'" + return 0 + fi + + if [ ! -d "./migrations" ]; then + log_warn "迁移目录不存在,跳过" + return 0 + fi + + rsync -avzP --progress ./migrations/ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/migrations/" + log_info "迁移文件上传完成" +} + # 上传测试脚本 upload_test_script() { log_step "上传测试脚本..." @@ -363,6 +469,24 @@ upload_test_script() { log_info "测试脚本上传完成" } +# 上传清理脚本(定时任务用) +upload_cleanup_script() { + log_step "上传清理脚本..." + if [ "$DRY_RUN" = true ]; then + log_dry "rsync -avzP ./scripts/cleanup_refresh_tokens.sh '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/'" + return 0 + fi + + mkdir -p ./scripts 2>/dev/null || true + if [ -f "./scripts/cleanup_refresh_tokens.sh" ]; then + rsync -avzP --progress ./scripts/cleanup_refresh_tokens.sh "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/" + ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x ${REMOTE_DIR}/scripts/cleanup_refresh_tokens.sh" + log_info "清理脚本上传完成" + else + log_warn "清理脚本不存在,跳过" + fi +} + # 重启远程服务 restart_service() { log_step "重启服务 '${SERVICE_NAME}'..." @@ -381,11 +505,11 @@ restart_service() { sleep 3 } -# 运行部署后测试 run_tests() { log_step "执行部署后测试..." if [ "$DRY_RUN" = true ]; then - log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'TEST_DOMAIN=\"${TEST_DOMAIN}\" ${REMOTE_DIR}/test_deployment.sh'" + log_dry "步骤1: curl http://127.0.0.1:${BACKEND_PORT}/health" + log_dry "步骤2: curl ${TEST_DOMAIN}/health" return 0 fi @@ -394,16 +518,353 @@ run_tests() { return 0 fi - if ssh "${REMOTE_USER}@${REMOTE_HOST}" "TEST_DOMAIN=\"${TEST_DOMAIN}\" ${REMOTE_DIR}/test_deployment.sh"; then - log_info "测试通过" - else - log_error "测试失败!请检查服务状态。" + log_step "步骤1: 测试本地后端..." + if ! test_local_backend; then + rollback_and_exit "本地后端测试失败" fi + + log_step "步骤2: 测试域名访问..." + if ! test_domain_access; then + rollback_and_exit "域名访问测试失败" + fi + + log_info "测试通过" + return 0 +} + +test_local_backend() { + local max_retries=3 + local retry=0 + + while [ $retry -lt $max_retries ]; do + retry=$((retry + 1)) + log_info "尝试 ${retry}/${max_retries}: 测试本地后端 (127.0.0.1:${BACKEND_PORT})" + + # 测试 1: 健康检查 + log_info " [1/4] 测试 /health..." + local health_code + health_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:${BACKEND_PORT}/health" 2>/dev/null || echo "000") + + if [ "$health_code" != "200" ]; then + log_warn " /health 异常 (HTTP ${health_code})" + sleep 2 + continue + fi + log_info " /health 正常" + + # 测试 2: 登录接口存在(预期 400,微信 code 无效) + log_info " [2/4] 测试 /api/login..." + local login_code + login_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test_code\"'" 2>/dev/null || echo "000") + + if [ "$login_code" = "400" ]; then + log_info " /api/login 正常 (HTTP ${login_code}, 微信返回 400 expected)" + elif [ "$login_code" = "200" ]; then + log_info " /api/login 正常 (HTTP ${login_code})" + else + log_warn " /api/login 异常 (HTTP ${login_code})" + sleep 2 + continue + fi + + # 测试 3: Refresh Token 接口存在 + log_info " [3/4] 测试 /api/refresh-token..." + local refresh_code + refresh_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/refresh-token -H 'Content-Type: application/json' -d '{\"refresh_token\":\"invalid\"}'" 2>/dev/null || echo "000") + + if [ "$refresh_code" = "401" ]; then + log_info " /api/refresh-token 正常 (HTTP ${refresh_code}, 无效 token expected)" + elif [ "$refresh_code" = "200" ]; then + log_info " /api/refresh-token 正常 (HTTP ${refresh_code})" + else + log_warn " /api/refresh-token 异常 (HTTP ${refresh_code})" + sleep 2 + continue + fi + + # 测试 4: 根路径 + log_info " [4/4] 测试 / (根路径)..." + local root_code + root_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:${BACKEND_PORT}/" 2>/dev/null || echo "000") + + if [ "$root_code" = "200" ] || [ "$root_code" = "404" ]; then + log_info " / 正常 (HTTP ${root_code})" + else + log_warn " / 异常 (HTTP ${root_code})" + sleep 2 + continue + fi + + log_info "本地后端所有测试通过" + return 0 + done + + log_error "本地后端测试失败" + return 1 +} + +test_domain_access() { + local max_retries=3 + local retry=0 + + while [ $retry -lt $max_retries ]; do + retry=$((retry + 1)) + log_info "尝试 ${retry}/${max_retries}: 测试域名访问 (${TEST_DOMAIN})" + + # 测试 1: 健康检查 + log_info " [1/4] 测试 /health..." + local health_code + health_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k ${TEST_DOMAIN}/health" 2>/dev/null || echo "000") + + if [ "$health_code" != "200" ]; then + log_warn " /health 异常 (HTTP ${health_code})" + sleep 2 + continue + fi + log_info " /health 正常" + + # 测试 2: 登录接口 + log_info " [2/4] 测试 /api/login..." + local login_code + login_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k -X POST ${TEST_DOMAIN}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test\"'" 2>/dev/null || echo "000") + + if [ "$login_code" = "400" ] || [ "$login_code" = "200" ]; then + log_info " /api/login 正常 (HTTP ${login_code})" + else + log_warn " /api/login 异常 (HTTP ${login_code})" + sleep 2 + continue + fi + + # 测试 3: Refresh Token 接口 + log_info " [3/4] 测试 /api/refresh-token..." + local refresh_code + refresh_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k -X POST ${TEST_DOMAIN}/api/refresh-token -H 'Content-Type: application/json' -d '{\"refresh_token\":\"invalid\"}'" 2>/dev/null || echo "000") + + if [ "$refresh_code" = "401" ] || [ "$refresh_code" = "200" ]; then + log_info " /api/refresh-token 正常 (HTTP ${refresh_code})" + else + log_warn " /api/refresh-token 异常 (HTTP ${refresh_code})" + sleep 2 + continue + fi + + # 测试 4: 根路径 + log_info " [4/4] 测试 / (根路径)..." + local root_code + root_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k ${TEST_DOMAIN}/" 2>/dev/null || echo "000") + + if [ "$root_code" = "200" ] || [ "$root_code" = "404" ]; then + log_info " / 正常 (HTTP ${root_code})" + else + log_warn " / 异常 (HTTP ${root_code})" + sleep 2 + continue + fi + + log_info "域名访问所有测试通过" + return 0 + done + + log_error "域名访问测试失败" + return 1 +} + +rollback_and_exit() { + local reason="$1" + log_error "测试失败: ${reason}" + log_error "自动回滚到上一个正常版本..." + + local last_known_good + last_known_good=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat ${REMOTE_DIR}/.last_deployed 2>/dev/null || echo """) + + if [ -z "$last_known_good" ]; then + log_error "无法回滚: 未找到上一个正常版本记录" + exit 1 + fi + + rollback_on_failure "$last_known_good" + exit 1 +} + +# 备份数据库 +backup_database() { + log_step "备份数据库..." + + if [ "$DRY_RUN" = true ]; then + log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'bash -c \"docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > ${REMOTE_DIR}/backups/backup.dump\"'" + return 0 + fi + + local timestamp + timestamp=$(date +%Y%m%d_%H%M%S) + local backup_file="${REMOTE_DIR}/backups/${DB_NAME}_${timestamp}.dump" + + log_info "创建数据库备份..." + + # 创建备份目录 + ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}/backups" + + # 在远程执行备份(使用 stdout 重定向) + if ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "bash -c 'docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${timestamp}.dump && mv /tmp/backup_${timestamp}.dump \"${backup_file}\"'" 2>&1; then + log_info "数据库备份成功: ${DB_NAME}_${timestamp}.dump" + + # 清理旧数据库备份(保留2个) + cleanup_old_db_backups + else + log_warn "数据库备份失败,继续部署" + fi +} + +# 清理旧数据库备份(保留2个) +cleanup_old_db_backups() { + local keep_count="${1:-1}" + + local backup_count + backup_count=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | wc -l" || echo "0") + + if [ "$backup_count" -le "$keep_count" ]; then + return 0 + fi + + local to_delete + to_delete=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | tail -n +$((keep_count + 1))" || echo "") + + if [ -z "$to_delete" ]; then + return 0 + fi + + log_step "清理旧数据库备份(保留 ${keep_count} 个)..." + echo "$to_delete" | while read -r file; do + if [ -n "$file" ]; then + ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f '${file}'" 2>/dev/null && echo " 已删除: $(basename ${file})" || true + fi + done +} + +# 回滚数据库(手动触发) +rollback_database() { + local backup_file="$1" + + if [ -z "$backup_file" ]; then + log_error "回滚失败: 未指定备份文件" + return 1 + fi + + log_step "回滚数据库..." + log_warn "从备份恢复: ${backup_file}" + + if [ "$DRY_RUN" = true ]; then + log_dry "pg_restore -c -U ${DB_USER} -d ${DB_NAME} ${backup_file}" + return 0 + fi + + # 检查备份文件是否存在 + if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "[ -f '${backup_file}' ]"; then + log_error "备份文件不存在: ${backup_file}" + return 1 + fi + + # 停止服务避免数据库冲突 + log_info "停止服务..." + ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true + + # 执行恢复(通过 stdin 管道) + if ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat '${backup_file}'" | \ + ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "docker exec -i ${DB_CONTAINER} pg_restore -c -U ${DB_USER} -d ${DB_NAME}" 2>&1; then + log_info "数据库恢复成功" + else + log_error "数据库恢复失败" + return 1 + fi + + # 重启服务 + log_info "重启服务..." + ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl start ${SERVICE_NAME}" + + log_info "数据库回滚完成" +} + +# 执行数据库迁移 +run_migrations() { + log_step "执行数据库迁移..." + + if [ "$DRY_RUN" = true ]; then + log_dry "检查并执行 migrations/*.sql" + local migrations + migrations=$(ls ./migrations/*.sql 2>/dev/null | wc -l || echo "0") + log_dry "发现 ${migrations} 个迁移文件" + return 0 + fi + + # 检查是否有迁移文件 + local sql_files + sql_files=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "") + + if [ -n "$sql_files" ]; then + # 有迁移文件,先备份数据库 + backup_database + else + log_info "没有发现迁移文件,跳过" + fi + + log_info "检查迁移文件..." + if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "[ -d ${REMOTE_DIR}/migrations ]"; then + log_warn "迁移目录不存在,跳过" + return 0 + fi + + local sql_files + sql_files=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "") + + if [ -z "$sql_files" ]; then + log_warn "没有找到迁移文件" + return 0 + fi + + for sql_file in $sql_files; do + local filename + filename=$(basename "$sql_file") + local table_check=$(echo "$filename" | sed 's/[0-9]*_//' | sed 's/.sql$//') + + log_info "检查迁移: $filename" + + if [ "$table_check" = "add_refresh_tokens" ]; then + local exists + exists=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \ + "docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT 1 FROM information_schema.tables WHERE table_name = 'refresh_tokens'\" 2>/dev/null" || echo "0") + if [ "$exists" = "1" ]; then + log_info " 表 refresh_tokens 已存在,跳过" + continue + fi + fi + + log_info " 执行迁移: $filename" + if ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" < "$sql_file"; then + log_info " 完成: $filename" + else + log_warn " 迁移可能已执行或失败,继续" + fi + done + + log_info "数据库迁移完成" } # 确认部署 confirm_deploy() { - if [ "$SKIP_CONFIRM" = true ]; then + if [ "$DRY_RUN" = true ] || [ "$SKIP_CONFIRM" = true ]; then return 0 fi @@ -474,20 +935,31 @@ main() { ssh_connect build_project backup_old_version + cleanup_old_backups 5 upload_binary upload_config + upload_migrations upload_test_script + upload_cleanup_script restart_service - run_tests + run_migrations - log_to_file "DEPLOY success" - send_webhook "success" "部署完成" + if run_tests; then + log_to_file "DEPLOY success" + send_webhook "success" "部署完成" - echo "" - log_info "========== 部署完成! ==========" - echo -e " Git: ${GIT_VERSION}" - echo -e " 环境: ${APP_ENV}" - echo "" + echo "" + log_info "========== 部署完成! ==========" + echo -e " Git: ${GIT_VERSION}" + echo -e " 环境: ${APP_ENV}" + echo "" + else + log_error "========== 部署失败,已自动回滚 ==========" + echo -e " Git: ${GIT_VERSION}" + echo -e " 环境: ${APP_ENV}" + echo "" + exit 1 + fi } main diff --git a/src/auth.rs b/src/auth.rs index a254632..7b27902 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,52 +1,54 @@ -use chrono::Utc; +use chrono::{Utc, Duration}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; +use crate::error::AppError; use crate::models::{Claims, TempTokenClaims}; use actix_web::{ Error, HttpMessage, body::MessageBody, dev::ServiceRequest, dev::ServiceResponse, middleware::Next, -}; // 新增:用于包装中间件函数 +}; + +// 常量 +const ACCESS_TOKEN_EXPIRE_HOURS: i64 = 24; +const REFRESH_TOKEN_EXPIRE_DAYS: i64 = 7; -// 中间件函数:泛型 B 约束为 MessageBody,返回 Result, Error> pub async fn jwt_middleware( req: ServiceRequest, next: Next, ) -> Result, Error> { - // (保持原有逻辑不变) let auth_header = req .headers() .get("Authorization") - .ok_or_else(|| actix_web::error::ErrorUnauthorized("缺少Authorization头"))? + .ok_or_else(|| AppError::Unauthorized("请先登录".to_string()))? .to_str() - .map_err(|_| actix_web::error::ErrorUnauthorized("Authorization格式无效"))?; + .map_err(|_| AppError::Unauthorized("Authorization格式无效".to_string()))?; let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| { - actix_web::error::ErrorUnauthorized("Authorization格式应为 Bearer ") + AppError::Unauthorized("请使用有效的登录凭证".to_string()) })?; let jwt_secret = std::env::var("JWT_SECRET") - .map_err(|_| actix_web::error::ErrorInternalServerError("服务器未配置JWT密钥"))?; + .map_err(|_| AppError::Internal("服务器配置错误".to_string()))?; let claims = verify_token(token, &jwt_secret) - .map_err(|e| actix_web::error::ErrorUnauthorized(format!("无效的token: {}", e)))?; + .map_err(|e| AppError::Unauthorized(format!("登录已过期,请重新登录: {}", e)))?; req.extensions_mut().insert(claims); next.call(req).await } -// 生成JWT的函数 +// 生成 access_token pub fn generate_token( user_id: i32, openid: &str, user_type: i32, secret: &str, ) -> Result { - // 设置过期时间:当前时间 + 24小时(86400秒) let now = Utc::now(); - let exp = (now + chrono::Duration::hours(24)).timestamp(); + let exp = (now + Duration::hours(ACCESS_TOKEN_EXPIRE_HOURS)).timestamp(); let iat = now.timestamp(); - // 构建Claims let claims = Claims { exp, iat, @@ -55,15 +57,51 @@ pub fn generate_token( user_type, }; - // 生成token encode( - &Header::new(Algorithm::HS256), // 使用HS256算法 + &Header::new(Algorithm::HS256), &claims, - &EncodingKey::from_secret(secret.as_bytes()), // 签名密钥 + &EncodingKey::from_secret(secret.as_bytes()), ) .map_err(|e| format!("生成JWT失败: {}", e)) } +// 生成 refresh_token(简单 base64 编码的随机字符串) +pub fn generate_refresh_token(user_id: i32, secret: &str) -> Result { + let now = Utc::now(); + let exp = (now + Duration::days(REFRESH_TOKEN_EXPIRE_DAYS)).timestamp(); + + let payload = format!("{}:{}:{}", user_id, exp, secret); + let token = BASE64.encode(payload.as_bytes()); + + Ok(token) +} + +// 解析 refresh_token,返回 (user_id, expires_at) +pub fn verify_refresh_token(token: &str, secret: &str) -> Result<(i32, i64), String> { + let decoded = BASE64.decode(token) + .map_err(|e| format!("Refresh token 格式错误: {}", e))?; + + let payload = String::from_utf8(decoded) + .map_err(|_| "Refresh token 解析失败".to_string())?; + + let parts: Vec<&str> = payload.split(':').collect(); + if parts.len() != 3 { + return Err("Refresh token 结构错误".to_string()); + } + + let user_id: i32 = parts[0].parse() + .map_err(|_| "Refresh token user_id 解析失败".to_string())?; + let exp: i64 = parts[1].parse() + .map_err(|_| "Refresh token exp 解析失败".to_string())?; + + let now = Utc::now().timestamp(); + if now > exp { + return Err("Refresh token 已过期".to_string()); + } + + Ok((user_id, exp)) +} + // 验证并解析JWT的函数 pub fn verify_token(token: &str, secret: &str) -> Result { // 验证配置(指定算法,默认会检查exp等字段) diff --git a/src/db.rs b/src/db.rs index b6ef3eb..3570c75 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use sqlx::{PgPool, postgres::PgPoolOptions}; +use sqlx::{PgPool, postgres::PgPoolOptions, FromRow}; use std::env; use std::error::Error; use chrono::Utc; @@ -476,3 +476,89 @@ pub async fn set_weather_favorite( Ok(()) } + +// ===== Refresh Token DB 函数 ===== + +#[derive(Debug, FromRow)] +pub struct RefreshToken { + pub id: i32, + pub user_id: i32, + pub token: String, + pub expires_at: chrono::DateTime, + pub created_at: chrono::DateTime, +} + +pub async fn create_refresh_token( + pool: &PgPool, + user_id: i32, + token: &str, + expires_at: chrono::DateTime, +) -> Result<(), AppError> { + let query = r#" + INSERT INTO refresh_tokens (user_id, token, expires_at) + VALUES ($1, $2, $3) + "#; + + sqlx::query(query) + .bind(user_id) + .bind(token) + .bind(expires_at) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("创建 refresh_token 失败: {}", e)))?; + + Ok(()) +} + +pub async fn verify_refresh_token( + pool: &PgPool, + token: &str, +) -> Result { + let query = r#" + SELECT id, user_id, token, expires_at, created_at + FROM refresh_tokens + WHERE token = $1 AND expires_at > NOW() + "#; + + sqlx::query_as::<_, RefreshToken>(query) + .bind(token) + .fetch_optional(pool) + .await + .map_err(|e| AppError::Database(format!("验证 refresh_token 失败: {}", e)))? + .ok_or_else(|| AppError::Unauthorized("Refresh token 无效或已过期".to_string())) +} + +pub async fn delete_refresh_token(pool: &PgPool, user_id: i32) -> Result<(), AppError> { + let query = r#"DELETE FROM refresh_tokens WHERE user_id = $1"#; + + sqlx::query(query) + .bind(user_id) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("删除 refresh_token 失败: {}", e)))?; + + Ok(()) +} + +pub async fn delete_specific_refresh_token(pool: &PgPool, token: &str) -> Result<(), AppError> { + let query = r#"DELETE FROM refresh_tokens WHERE token = $1"#; + + sqlx::query(query) + .bind(token) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("删除 refresh_token 失败: {}", e)))?; + + Ok(()) +} + +pub async fn cleanup_expired_refresh_tokens(pool: &PgPool) -> Result { + let query = r#"DELETE FROM refresh_tokens WHERE expires_at <= NOW()"#; + + let result = sqlx::query(query) + .execute(pool) + .await + .map_err(|e| AppError::Database(format!("清理过期 refresh_token 失败: {}", e)))?; + + Ok(result.rows_affected()) +} diff --git a/src/handlers/auth.rs b/src/handlers/auth.rs index be3e672..c2e0660 100644 --- a/src/handlers/auth.rs +++ b/src/handlers/auth.rs @@ -1,11 +1,15 @@ use actix_web::{web, HttpResponse, Responder, post}; +use chrono::{Utc, Duration}; use reqwest::Client; use sqlx::postgres::PgPool; use tracing::{debug, error, info, warn}; -use crate::auth::generate_token; +use crate::auth::{generate_token, generate_refresh_token, verify_refresh_token}; +use crate::db; +use crate::error::ErrorResponse; use crate::models::{ - AppState, ErrorResponse, TokenResponse, WeChatApiResponse, WeChatLoginRequest, + AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse, + WeChatApiResponse, WeChatLoginRequest, }; #[post("/api/login")] @@ -28,11 +32,7 @@ pub async fn login( } Err(e) => { error!("微信API请求失败: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "请求微信服务失败".to_string(), - errcode: None, - errmsg: None, - }); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("请求微信服务失败")); } }; @@ -43,11 +43,7 @@ pub async fn login( } Err(e) => { error!("微信API响应解析失败: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "解析微信响应失败".to_string(), - errcode: None, - errmsg: None, - }); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("解析微信响应失败")); } }; @@ -56,11 +52,11 @@ pub async fn login( "微信登录验证失败 - 错误码: {}, 错误信息: {:?}", errcode, wechat_data.errmsg ); - return HttpResponse::BadRequest().json(ErrorResponse { - error: "微信登录失败".to_string(), - errcode: Some(errcode), - errmsg: wechat_data.errmsg, - }); + let errmsg = wechat_data.errmsg.unwrap_or_default(); + return HttpResponse::BadRequest().json(ErrorResponse::<()>::error(format!( + "微信登录失败: {} {}", + errcode, errmsg + ))); } let openid = match wechat_data.openid { @@ -71,11 +67,7 @@ pub async fn login( } None => { error!("微信API响应中未包含openid"); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "微信API未返回openid".to_string(), - errcode: None, - errmsg: None, - }); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("微信API未返回openid")); } }; @@ -97,11 +89,7 @@ pub async fn login( } Err(e) => { error!("用户信息处理失败: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "用户信息处理失败".to_string(), - errcode: Some(500), - errmsg: None, - }); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("用户信息处理失败")); } }; @@ -112,14 +100,93 @@ pub async fn login( } Err(e) => { error!("JWT生成失败: {}", e); - return HttpResponse::InternalServerError().json(ErrorResponse { - error: "生成身份令牌失败".to_string(), - errcode: Some(500), - errmsg: None, - }); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成身份令牌失败")); } }; - let token_response = TokenResponse::new(token); - HttpResponse::Ok().json(token_response) + let refresh_token_str = match generate_refresh_token(user_id, &app_state.jwt_secret) { + Ok(t) => t, + Err(e) => { + error!("Refresh token 生成失败: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成刷新令牌失败")); + } + }; + + let refresh_expires_at = Utc::now() + Duration::days(7); + if let Err(e) = db::create_refresh_token(pool.get_ref(), user_id, &refresh_token_str, refresh_expires_at).await { + error!("保存 refresh_token 失败: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("保存刷新令牌失败")); + } + + let login_response = LoginResponse::new(token, refresh_token_str); + HttpResponse::Ok().json(login_response) +} + +#[post("/api/refresh-token")] +pub async fn refresh_token( + pool: web::Data, + req: web::Json, + app_state: web::Data, +) -> impl Responder { + let refresh_token = &req.refresh_token; + + let refresh_token_record = match db::verify_refresh_token(pool.get_ref(), refresh_token).await { + Ok(record) => record, + Err(e) => { + warn!("Refresh token 验证失败: {}", e); + return HttpResponse::Unauthorized().json(ErrorResponse::<()>::error("Refresh token 无效或已过期")); + } + }; + + let user_id = refresh_token_record.user_id; + + let user_query = r#"SELECT openid FROM users WHERE id = $1"#; + let openid: String = match sqlx::query_as::<_, (String,)>(user_query) + .bind(user_id) + .fetch_optional(pool.get_ref()) + .await + { + Ok(Some((openid,))) => openid, + Ok(None) => { + error!("用户不存在: {}", user_id); + return HttpResponse::Unauthorized().json(ErrorResponse::<()>::error("用户不存在")); + } + Err(e) => { + error!("查询用户失败: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("查询用户信息失败")); + } + }; + + let new_access_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 new_refresh_token = match generate_refresh_token(user_id, &app_state.jwt_secret) { + Ok(t) => t, + Err(e) => { + error!("生成新 refresh_token 失败: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成刷新令牌失败")); + } + }; + + if let Err(e) = db::delete_specific_refresh_token(pool.get_ref(), refresh_token).await { + warn!("删除旧 refresh_token 失败(继续): {}", e); + } + + let new_expires_at = Utc::now() + Duration::days(7); + if let Err(e) = db::create_refresh_token(pool.get_ref(), user_id, &new_refresh_token, new_expires_at).await { + error!("保存新 refresh_token 失败: {}", e); + return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("保存刷新令牌失败")); + } + + let response = TokenRefreshResponse { + success: true, + token: new_access_token, + refresh_token: new_refresh_token, + }; + HttpResponse::Ok().json(response) } diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index bcd4673..388322e 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -3,6 +3,7 @@ pub mod admin; pub mod auth; pub mod favorites; pub mod health; +pub mod meta; pub mod payment; pub mod static_files; pub mod user; @@ -17,8 +18,10 @@ pub static TEMPLATES_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/templates"); pub use admin::admin_get_user; pub use admin::admin_update_user_payment; pub use auth::login; +pub use auth::refresh_token; pub use favorites::{add_favorite, get_favorites, remove_favorite}; pub use health::health_check; +pub use meta::root; pub use static_files::serve_static_files; pub use user::get_current_user_profile; pub use user::save_user_profile; diff --git a/src/main.rs b/src/main.rs index a32921d..7196127 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ use actix_web::middleware::from_fn; use actix_web::{App, HttpServer, web}; -use include_dir::{Dir, include_dir}; use tracing::{error, info}; use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod}; use reqwest::Client; @@ -21,8 +20,8 @@ use handlers::{ admin_get_user, admin_update_user_payment, add_favorite, create_order, delete_weather, generate_temp_token_handler, get_current_user_profile, get_favorites, get_user_quota, get_weather_brief, get_weather_details, - health_check, login, mock_confirm, post_weather_data, remove_favorite, - save_user_profile, serve_static_files, + health_check, login, mock_confirm, post_weather_data, refresh_token, + remove_favorite, root, save_user_profile, serve_static_files, }; use models::AppState; @@ -60,10 +59,13 @@ fn create_server_config( .app_data(web::Data::new(pool)) .app_data(web::Data::new(http_client)) .app_data(web::Data::new(app_state)) + // 根路径(无需认证) + .service(root) // #[get("/")] - 返回服务信息 // 静态文件(无需认证) .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) // API 接口 .service(login) // #[post("/api/login")] + .service(refresh_token) // #[post("/api/refresh-token")](公开接口,无需认证) .service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token,公开接口) .service(health_check) // #[get("/health")](公开接口,无需认证) // 受保护接口(JWT) @@ -110,6 +112,7 @@ async fn main() -> std::io::Result<()> { std::env::set_var("SSL_CERT_PATH", &app_config.ssl_cert_path); std::env::set_var("RUST_LOG", &app_config.rust_log); std::env::set_var("FREE_USER_DATA_LIMIT", app_config.free_user_data_limit.to_string()); + std::env::set_var("APP_VERSION", &app_config.app_version); } // 初始化文件日志(JSON 格式,带轮转) diff --git a/src/models.rs b/src/models.rs index c412734..a6b6920 100644 --- a/src/models.rs +++ b/src/models.rs @@ -28,14 +28,28 @@ pub struct TempTokenClaims { pub resource_id: i32, // 允许访问的资源ID(如天气数据id) } -// 登录成功后的令牌响应结构体 -/// 仅包含操作状态和令牌信息 +// 登录成功后的令牌响应结构体(双 token) +#[derive(Debug, Serialize, Clone)] +pub struct LoginResponse { + pub success: bool, + pub token: String, + pub refresh_token: String, +} + +impl LoginResponse { + pub fn new(token: String, refresh_token: String) -> Self { + Self { + success: true, + token, + refresh_token, + } + } +} + +// 兼容旧的 TokenResponse #[derive(Debug, Serialize, Clone)] pub struct TokenResponse { - /// 操作状态:true表示登录成功,false表示失败 pub success: bool, - - /// 登录成功后生成的JWT令牌,客户端后续请求需携带此令牌 pub token: String, } @@ -48,6 +62,20 @@ impl TokenResponse { } } +// Refresh Token 请求 +#[derive(Debug, Deserialize)] +pub struct RefreshTokenRequest { + pub refresh_token: String, +} + +// Token 刷新响应 +#[derive(Debug, Serialize)] +pub struct TokenRefreshResponse { + pub success: bool, + pub token: String, + pub refresh_token: String, +} + #[derive(Debug, Deserialize)] pub struct WeChatLoginRequest { pub code: String, @@ -60,13 +88,6 @@ pub struct WeChatApiResponse { pub errmsg: Option, } -#[derive(Debug, Serialize)] -pub struct ErrorResponse { - pub error: String, - pub errcode: Option, - pub errmsg: Option, -} - // 定义新类型,包装 Vec(当前 crate 内的类型) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FloatVec(pub Vec); @@ -316,6 +337,7 @@ pub struct AppState { pub wechat_appid: String, pub wechat_secret: String, pub free_user_data_limit: i32, + pub app_version: String, } impl AppState { @@ -330,6 +352,8 @@ impl AppState { free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT") .map(|v| v.parse().unwrap_or(20)) .unwrap_or(20), + app_version: std::env::var("APP_VERSION") + .unwrap_or_else(|_| "unknown".to_string()), }) } } diff --git a/test_deployment.sh b/test_deployment.sh index 4a9538e..24d838a 100755 --- a/test_deployment.sh +++ b/test_deployment.sh @@ -31,7 +31,7 @@ check_health() { } check_api() { - local url="${1}/dev" + local url="${1}" log_info "检查 API 端点: ${url}" local status_code