feat(auth): 添加 Refresh Token 双 Token 机制

- 添加 /api/refresh-token 接口支持 Token 续期
- 登录接口返回 access_token 和 refresh_token
- 新增 refresh_tokens 表存储 refresh_token
- 部署脚本添加数据库备份和迁移功能
- deploy.sh 添加 4 项 API 测试
- 更新 AGENTS.md 文档
This commit is contained in:
2026-04-19 16:01:41 +08:00
parent 52991dcfd5
commit e14c85436b
11 changed files with 872 additions and 98 deletions

View File

@@ -46,13 +46,53 @@ config/
migrations/ # 数据库迁移 SQL migrations/ # 数据库迁移 SQL
tests/ # 集成测试 tests/ # 集成测试
static/ # 静态文件KaTeX 等) static/ # 静态文件
│ ├── css/
│ │ ├── report.css # PDF 报表样式
│ │ └── style.css # 通用样式
│ ├── js/
│ │ ├── afterbody.js # PDF 生成模块html2pdf
│ │ └── ...
│ └── katex/ # KaTeX 数学公式
deploy.sh # 部署脚本 deploy.sh # 部署脚本
.env.example # 环境变量模板 .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+ 个天气测量字段列 -- 30+ 个天气测量字段列
``` ```
#### refresh_tokens 表
```sql
id SERIAL PRIMARY KEY
user_id INTEGER users
token VARCHARRefresh Token
expires_at TIMESTAMPTZ
created_at TIMESTAMPTZ
```
--- ---
## 认证流程 ## 认证流程
1. **登录**`POST /api/login` 传入微信 code → 调用微信 API → UPSERT 用户 → 返回 JWT 1. **登录**`POST /api/login` 传入微信 code → 调用微信 API → UPSERT 用户 → 返回双 Token
2. **JWT 声明**`{exp, iat, user_id, openid, user_type}`24 小时过期) 2. **双 Token 机制**
3. **中间件**`jwt_middleware` 提取 Bearer 令牌,验证后将 Claims 插入请求扩展 - `token`: access_token24小时用于 API 认证
4. **处理器访问**:通过 `claims: web::ReqData<Claims>` 参数获取 - `refresh_token`: 7天有效期用于续期 access_token
3. **JWT 声明**`{exp, iat, user_id, openid, user_type}`
4. **中间件**`jwt_middleware` 提取 Bearer 令牌,验证后将 Claims 插入请求扩展
5. **处理器访问**:通过 `claims: web::ReqData<Claims>` 参数获取
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 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 ```bash
./deploy.sh production --rollback # 回滚生产环境 ./deploy.sh production --rollback # 回滚生产环境(仅二进制)
``` ```
**Webhook 通知(可选):** **Webhook 通知(可选):**
@@ -385,6 +463,7 @@ WEBHOOK_URL="https://example.com/webhook" ./deploy.sh production
**deploy.sh 已包含的安全保障:** **deploy.sh 已包含的安全保障:**
- ✅ 部署前自动备份旧版本 - ✅ 部署前自动备份旧版本
- ✅ 部署前自动备份数据库
- ✅ 自动检查依赖 - ✅ 自动检查依赖
- ✅ 自动测试 - ✅ 自动测试
- ✅ 部署日志记录 - ✅ 部署日志记录

1
Cargo.lock generated
View File

@@ -1964,6 +1964,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"actix-files", "actix-files",
"actix-web", "actix-web",
"base64",
"chrono", "chrono",
"dotenvy", "dotenvy",
"error", "error",

View File

@@ -23,6 +23,7 @@ sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "chr
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
toml = "0.8" toml = "0.8"
base64 = "0.22"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }

520
deploy.sh
View File

@@ -27,6 +27,9 @@ SKIP_TESTS=false
ROLLBACK=false ROLLBACK=false
LIST_BACKUPS=false LIST_BACKUPS=false
# 全局变量(部署流程中使用)
BACKUP_PATH=""
# 解析选项 # 解析选项
shift 2>/dev/null || true shift 2>/dev/null || true
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -70,12 +73,20 @@ case "${APP_ENV}" in
development) development)
REMOTE_DIR="/root/rust/rust_backend_dev" REMOTE_DIR="/root/rust/rust_backend_dev"
SERVICE_NAME="rust-backend-dev.service" 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) production)
REMOTE_DIR="/root/rust/rust_backend" REMOTE_DIR="/root/rust/rust_backend"
SERVICE_NAME="rust-backend.service" SERVICE_NAME="rust-backend.service"
TEST_DOMAIN="https://xmclassmate.top" TEST_DOMAIN="https://xmclassmate.top"
BACKEND_PORT="4433"
DB_CONTAINER="1Panel-postgresql-FtMo"
DB_NAME="milkydata"
DB_USER="milkydata"
;; ;;
*) *)
echo "无效的环境: ${APP_ENV}" echo "无效的环境: ${APP_ENV}"
@@ -174,7 +185,7 @@ list_backups() {
echo "" echo ""
} }
# 回滚 # 回滚(手动触发)
rollback() { rollback() {
log_step "查找最新备份..." log_step "查找最新备份..."
local latest_backup local latest_backup
@@ -226,8 +237,85 @@ rollback() {
fi fi
log_info "回滚完成" log_info "回滚完成"
log_to_file "ROLLBACK to $(basename $latest_backup)" log_to_file "MANUAL_ROLLBACK to $(basename $latest_backup)"
send_webhook "rollback" "回滚完成: $(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 fi
} }
# 备份旧版本 # 备份旧版本并保存回滚标记
backup_old_version() { backup_old_version() {
log_step "备份旧版本..." log_step "备份旧版本..."
local binary_path="./target/release/${PROJECT_NAME}" local binary_path="./target/release/${PROJECT_NAME}"
local timestamp local timestamp
timestamp=$(date +%Y%m%d_%H%M%S) 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 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 return 0
fi fi
ssh "${REMOTE_USER}@${REMOTE_HOST}" " ssh "${REMOTE_USER}@${REMOTE_HOST}" "
if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then
cp '${REMOTE_DIR}/${PROJECT_NAME}' '${backup_path}' cp '${REMOTE_DIR}/${PROJECT_NAME}' '${BACKUP_PATH}'
echo '已备份到: ${backup_path}' echo '${BACKUP_PATH}' > '${REMOTE_DIR}/.last_deployed'
echo '已备份到: ${BACKUP_PATH}'
else else
echo '无旧版本需要备份' echo '无旧版本需要备份'
fi fi
@@ -350,6 +439,23 @@ upload_config() {
log_info "配置文件上传完成" 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() { upload_test_script() {
log_step "上传测试脚本..." log_step "上传测试脚本..."
@@ -363,6 +469,24 @@ upload_test_script() {
log_info "测试脚本上传完成" 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() { restart_service() {
log_step "重启服务 '${SERVICE_NAME}'..." log_step "重启服务 '${SERVICE_NAME}'..."
@@ -381,11 +505,11 @@ restart_service() {
sleep 3 sleep 3
} }
# 运行部署后测试
run_tests() { run_tests() {
log_step "执行部署后测试..." log_step "执行部署后测试..."
if [ "$DRY_RUN" = true ]; then 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 return 0
fi fi
@@ -394,16 +518,353 @@ run_tests() {
return 0 return 0
fi fi
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "TEST_DOMAIN=\"${TEST_DOMAIN}\" ${REMOTE_DIR}/test_deployment.sh"; then log_step "步骤1: 测试本地后端..."
log_info "测试通过" if ! test_local_backend; then
else rollback_and_exit "本地后端测试失败"
log_error "测试失败!请检查服务状态。"
fi 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() { confirm_deploy() {
if [ "$SKIP_CONFIRM" = true ]; then if [ "$DRY_RUN" = true ] || [ "$SKIP_CONFIRM" = true ]; then
return 0 return 0
fi fi
@@ -474,20 +935,31 @@ main() {
ssh_connect ssh_connect
build_project build_project
backup_old_version backup_old_version
cleanup_old_backups 5
upload_binary upload_binary
upload_config upload_config
upload_migrations
upload_test_script upload_test_script
upload_cleanup_script
restart_service restart_service
run_tests run_migrations
log_to_file "DEPLOY success" if run_tests; then
send_webhook "success" "部署完成" log_to_file "DEPLOY success"
send_webhook "success" "部署完成"
echo "" echo ""
log_info "========== 部署完成! ==========" log_info "========== 部署完成! =========="
echo -e " Git: ${GIT_VERSION}" echo -e " Git: ${GIT_VERSION}"
echo -e " 环境: ${APP_ENV}" echo -e " 环境: ${APP_ENV}"
echo "" echo ""
else
log_error "========== 部署失败,已自动回滚 =========="
echo -e " Git: ${GIT_VERSION}"
echo -e " 环境: ${APP_ENV}"
echo ""
exit 1
fi
} }
main main

View File

@@ -1,52 +1,54 @@
use chrono::Utc; use chrono::{Utc, Duration};
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; 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 crate::models::{Claims, TempTokenClaims};
use actix_web::{ use actix_web::{
Error, HttpMessage, body::MessageBody, dev::ServiceRequest, dev::ServiceResponse, Error, HttpMessage, body::MessageBody, dev::ServiceRequest, dev::ServiceResponse,
middleware::Next, middleware::Next,
}; // 新增:用于包装中间件函数 };
// 常量
const ACCESS_TOKEN_EXPIRE_HOURS: i64 = 24;
const REFRESH_TOKEN_EXPIRE_DAYS: i64 = 7;
// 中间件函数:泛型 B 约束为 MessageBody返回 Result<ServiceResponse<B>, Error>
pub async fn jwt_middleware<B: MessageBody>( pub async fn jwt_middleware<B: MessageBody>(
req: ServiceRequest, req: ServiceRequest,
next: Next<B>, next: Next<B>,
) -> Result<ServiceResponse<B>, Error> { ) -> Result<ServiceResponse<B>, Error> {
// (保持原有逻辑不变)
let auth_header = req let auth_header = req
.headers() .headers()
.get("Authorization") .get("Authorization")
.ok_or_else(|| actix_web::error::ErrorUnauthorized("缺少Authorization头"))? .ok_or_else(|| AppError::Unauthorized("请先登录".to_string()))?
.to_str() .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(|| { let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
actix_web::error::ErrorUnauthorized("Authorization格式应为 Bearer <token>") AppError::Unauthorized("请使用有效的登录凭证".to_string())
})?; })?;
let jwt_secret = std::env::var("JWT_SECRET") 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) 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); req.extensions_mut().insert(claims);
next.call(req).await next.call(req).await
} }
// 生成JWT的函数 // 生成 access_token
pub fn generate_token( pub fn generate_token(
user_id: i32, user_id: i32,
openid: &str, openid: &str,
user_type: i32, user_type: i32,
secret: &str, secret: &str,
) -> Result<String, String> { ) -> Result<String, String> {
// 设置过期时间:当前时间 + 24小时86400秒
let now = Utc::now(); 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(); let iat = now.timestamp();
// 构建Claims
let claims = Claims { let claims = Claims {
exp, exp,
iat, iat,
@@ -55,15 +57,51 @@ pub fn generate_token(
user_type, user_type,
}; };
// 生成token
encode( encode(
&Header::new(Algorithm::HS256), // 使用HS256算法 &Header::new(Algorithm::HS256),
&claims, &claims,
&EncodingKey::from_secret(secret.as_bytes()), // 签名密钥 &EncodingKey::from_secret(secret.as_bytes()),
) )
.map_err(|e| format!("生成JWT失败: {}", e)) .map_err(|e| format!("生成JWT失败: {}", e))
} }
// 生成 refresh_token简单 base64 编码的随机字符串)
pub fn generate_refresh_token(user_id: i32, secret: &str) -> Result<String, String> {
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的函数 // 验证并解析JWT的函数
pub fn verify_token(token: &str, secret: &str) -> Result<Claims, String> { pub fn verify_token(token: &str, secret: &str) -> Result<Claims, String> {
// 验证配置指定算法默认会检查exp等字段 // 验证配置指定算法默认会检查exp等字段

View File

@@ -1,4 +1,4 @@
use sqlx::{PgPool, postgres::PgPoolOptions}; use sqlx::{PgPool, postgres::PgPoolOptions, FromRow};
use std::env; use std::env;
use std::error::Error; use std::error::Error;
use chrono::Utc; use chrono::Utc;
@@ -476,3 +476,89 @@ pub async fn set_weather_favorite(
Ok(()) 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<chrono::Utc>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub async fn create_refresh_token(
pool: &PgPool,
user_id: i32,
token: &str,
expires_at: chrono::DateTime<chrono::Utc>,
) -> 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<RefreshToken, AppError> {
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<u64, AppError> {
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())
}

View File

@@ -1,11 +1,15 @@
use actix_web::{web, HttpResponse, Responder, post}; use actix_web::{web, HttpResponse, Responder, post};
use chrono::{Utc, Duration};
use reqwest::Client; use reqwest::Client;
use sqlx::postgres::PgPool; use sqlx::postgres::PgPool;
use tracing::{debug, error, info, warn}; 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::{ use crate::models::{
AppState, ErrorResponse, TokenResponse, WeChatApiResponse, WeChatLoginRequest, AppState, LoginResponse, RefreshTokenRequest, TokenRefreshResponse,
WeChatApiResponse, WeChatLoginRequest,
}; };
#[post("/api/login")] #[post("/api/login")]
@@ -28,11 +32,7 @@ pub async fn login(
} }
Err(e) => { Err(e) => {
error!("微信API请求失败: {}", e); error!("微信API请求失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse { return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("请求微信服务失败"));
error: "请求微信服务失败".to_string(),
errcode: None,
errmsg: None,
});
} }
}; };
@@ -43,11 +43,7 @@ pub async fn login(
} }
Err(e) => { Err(e) => {
error!("微信API响应解析失败: {}", e); error!("微信API响应解析失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse { return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("解析微信响应失败"));
error: "解析微信响应失败".to_string(),
errcode: None,
errmsg: None,
});
} }
}; };
@@ -56,11 +52,11 @@ pub async fn login(
"微信登录验证失败 - 错误码: {}, 错误信息: {:?}", "微信登录验证失败 - 错误码: {}, 错误信息: {:?}",
errcode, wechat_data.errmsg errcode, wechat_data.errmsg
); );
return HttpResponse::BadRequest().json(ErrorResponse { let errmsg = wechat_data.errmsg.unwrap_or_default();
error: "微信登录失败".to_string(), return HttpResponse::BadRequest().json(ErrorResponse::<()>::error(format!(
errcode: Some(errcode), "微信登录失败: {} {}",
errmsg: wechat_data.errmsg, errcode, errmsg
}); )));
} }
let openid = match wechat_data.openid { let openid = match wechat_data.openid {
@@ -71,11 +67,7 @@ pub async fn login(
} }
None => { None => {
error!("微信API响应中未包含openid"); error!("微信API响应中未包含openid");
return HttpResponse::InternalServerError().json(ErrorResponse { return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("微信API未返回openid"));
error: "微信API未返回openid".to_string(),
errcode: None,
errmsg: None,
});
} }
}; };
@@ -97,11 +89,7 @@ pub async fn login(
} }
Err(e) => { Err(e) => {
error!("用户信息处理失败: {}", e); error!("用户信息处理失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse { return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("用户信息处理失败"));
error: "用户信息处理失败".to_string(),
errcode: Some(500),
errmsg: None,
});
} }
}; };
@@ -112,14 +100,93 @@ pub async fn login(
} }
Err(e) => { Err(e) => {
error!("JWT生成失败: {}", e); error!("JWT生成失败: {}", e);
return HttpResponse::InternalServerError().json(ErrorResponse { return HttpResponse::InternalServerError().json(ErrorResponse::<()>::error("生成身份令牌失败"));
error: "生成身份令牌失败".to_string(),
errcode: Some(500),
errmsg: None,
});
} }
}; };
let token_response = TokenResponse::new(token); let refresh_token_str = match generate_refresh_token(user_id, &app_state.jwt_secret) {
HttpResponse::Ok().json(token_response) 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<PgPool>,
req: web::Json<RefreshTokenRequest>,
app_state: web::Data<AppState>,
) -> 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)
} }

View File

@@ -3,6 +3,7 @@ pub mod admin;
pub mod auth; pub mod auth;
pub mod favorites; pub mod favorites;
pub mod health; pub mod health;
pub mod meta;
pub mod payment; pub mod payment;
pub mod static_files; pub mod static_files;
pub mod user; 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_get_user;
pub use admin::admin_update_user_payment; pub use admin::admin_update_user_payment;
pub use auth::login; pub use auth::login;
pub use auth::refresh_token;
pub use favorites::{add_favorite, get_favorites, remove_favorite}; pub use favorites::{add_favorite, get_favorites, remove_favorite};
pub use health::health_check; pub use health::health_check;
pub use meta::root;
pub use static_files::serve_static_files; pub use static_files::serve_static_files;
pub use user::get_current_user_profile; pub use user::get_current_user_profile;
pub use user::save_user_profile; pub use user::save_user_profile;

View File

@@ -1,6 +1,5 @@
use actix_web::middleware::from_fn; use actix_web::middleware::from_fn;
use actix_web::{App, HttpServer, web}; use actix_web::{App, HttpServer, web};
use include_dir::{Dir, include_dir};
use tracing::{error, info}; use tracing::{error, info};
use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod}; use openssl::ssl::{SslAcceptor, SslAcceptorBuilder, SslFiletype, SslMethod};
use reqwest::Client; use reqwest::Client;
@@ -21,8 +20,8 @@ use handlers::{
admin_get_user, admin_update_user_payment, add_favorite, create_order, admin_get_user, admin_update_user_payment, add_favorite, create_order,
delete_weather, generate_temp_token_handler, get_current_user_profile, delete_weather, generate_temp_token_handler, get_current_user_profile,
get_favorites, get_user_quota, get_weather_brief, get_weather_details, get_favorites, get_user_quota, get_weather_brief, get_weather_details,
health_check, login, mock_confirm, post_weather_data, remove_favorite, health_check, login, mock_confirm, post_weather_data, refresh_token,
save_user_profile, serve_static_files, remove_favorite, root, save_user_profile, serve_static_files,
}; };
use models::AppState; use models::AppState;
@@ -60,10 +59,13 @@ fn create_server_config(
.app_data(web::Data::new(pool)) .app_data(web::Data::new(pool))
.app_data(web::Data::new(http_client)) .app_data(web::Data::new(http_client))
.app_data(web::Data::new(app_state)) .app_data(web::Data::new(app_state))
// 根路径(无需认证)
.service(root) // #[get("/")] - 返回服务信息
// 静态文件(无需认证) // 静态文件(无需认证)
.service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files))) .service(web::resource("/static/{tail:.*}").route(web::get().to(serve_static_files)))
// API 接口 // API 接口
.service(login) // #[post("/api/login")] .service(login) // #[post("/api/login")]
.service(refresh_token) // #[post("/api/refresh-token")](公开接口,无需认证)
.service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token公开接口 .service(get_weather_details) // #[get("/weather/details")](支持 JWT 或 temp_token公开接口
.service(health_check) // #[get("/health")](公开接口,无需认证) .service(health_check) // #[get("/health")](公开接口,无需认证)
// 受保护接口JWT // 受保护接口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("SSL_CERT_PATH", &app_config.ssl_cert_path);
std::env::set_var("RUST_LOG", &app_config.rust_log); 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("FREE_USER_DATA_LIMIT", app_config.free_user_data_limit.to_string());
std::env::set_var("APP_VERSION", &app_config.app_version);
} }
// 初始化文件日志JSON 格式,带轮转) // 初始化文件日志JSON 格式,带轮转)

View File

@@ -28,14 +28,28 @@ pub struct TempTokenClaims {
pub resource_id: i32, // 允许访问的资源ID如天气数据id 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)] #[derive(Debug, Serialize, Clone)]
pub struct TokenResponse { pub struct TokenResponse {
/// 操作状态true表示登录成功false表示失败
pub success: bool, pub success: bool,
/// 登录成功后生成的JWT令牌客户端后续请求需携带此令牌
pub token: String, 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)] #[derive(Debug, Deserialize)]
pub struct WeChatLoginRequest { pub struct WeChatLoginRequest {
pub code: String, pub code: String,
@@ -60,13 +88,6 @@ pub struct WeChatApiResponse {
pub errmsg: Option<String>, pub errmsg: Option<String>,
} }
#[derive(Debug, Serialize)]
pub struct ErrorResponse {
pub error: String,
pub errcode: Option<i32>,
pub errmsg: Option<String>,
}
// 定义新类型,包装 Vec<f64>(当前 crate 内的类型) // 定义新类型,包装 Vec<f64>(当前 crate 内的类型)
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FloatVec(pub Vec<f64>); pub struct FloatVec(pub Vec<f64>);
@@ -316,6 +337,7 @@ pub struct AppState {
pub wechat_appid: String, pub wechat_appid: String,
pub wechat_secret: String, pub wechat_secret: String,
pub free_user_data_limit: i32, pub free_user_data_limit: i32,
pub app_version: String,
} }
impl AppState { impl AppState {
@@ -330,6 +352,8 @@ impl AppState {
free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT") free_user_data_limit: std::env::var("FREE_USER_DATA_LIMIT")
.map(|v| v.parse().unwrap_or(20)) .map(|v| v.parse().unwrap_or(20))
.unwrap_or(20), .unwrap_or(20),
app_version: std::env::var("APP_VERSION")
.unwrap_or_else(|_| "unknown".to_string()),
}) })
} }
} }

View File

@@ -31,7 +31,7 @@ check_health() {
} }
check_api() { check_api() {
local url="${1}/dev" local url="${1}"
log_info "检查 API 端点: ${url}" log_info "检查 API 端点: ${url}"
local status_code local status_code