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:
93
AGENTS.md
93
AGENTS.md
@@ -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 VARCHAR(Refresh 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_token(24小时),用于 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
1
Cargo.lock
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
520
deploy.sh
@@ -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
|
||||||
|
|||||||
70
src/auth.rs
70
src/auth.rs
@@ -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等字段)
|
||||||
|
|||||||
88
src/db.rs
88
src/db.rs
@@ -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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 格式,带轮转)
|
||||||
|
|||||||
@@ -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()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user