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

520
deploy.sh
View File

@@ -27,6 +27,9 @@ SKIP_TESTS=false
ROLLBACK=false
LIST_BACKUPS=false
# 全局变量(部署流程中使用)
BACKUP_PATH=""
# 解析选项
shift 2>/dev/null || true
while [[ $# -gt 0 ]]; do
@@ -70,12 +73,20 @@ case "${APP_ENV}" in
development)
REMOTE_DIR="/root/rust/rust_backend_dev"
SERVICE_NAME="rust-backend-dev.service"
TEST_DOMAIN="https://xmclassmate.top/dev"
TEST_DOMAIN="https://dev.xmclassmate.top"
BACKEND_PORT="8080"
DB_CONTAINER="1Panel-postgresql-FtMo"
DB_NAME="milkydata_dev"
DB_USER="milkydata"
;;
production)
REMOTE_DIR="/root/rust/rust_backend"
SERVICE_NAME="rust-backend.service"
TEST_DOMAIN="https://xmclassmate.top"
BACKEND_PORT="4433"
DB_CONTAINER="1Panel-postgresql-FtMo"
DB_NAME="milkydata"
DB_USER="milkydata"
;;
*)
echo "无效的环境: ${APP_ENV}"
@@ -174,7 +185,7 @@ list_backups() {
echo ""
}
# 回滚
# 回滚(手动触发)
rollback() {
log_step "查找最新备份..."
local latest_backup
@@ -226,8 +237,85 @@ rollback() {
fi
log_info "回滚完成"
log_to_file "ROLLBACK to $(basename $latest_backup)"
send_webhook "rollback" "回滚完成: $(basename $latest_backup)"
log_to_file "MANUAL_ROLLBACK to $(basename $latest_backup)"
send_webhook "rollback" "手动回滚完成: $(basename $latest_backup)"
}
# 回滚到上一个正常版本(测试失败时自动触发)
rollback_on_failure() {
local backup_path="$1"
if [ -z "$backup_path" ]; then
log_error "回滚失败: 未指定备份路径"
return 1
fi
echo ""
log_error "========== 测试失败,自动回滚 =========="
echo -e " 原因: 部署后测试未通过"
echo -e " 回滚到: ${backup_path}"
echo -e " 服务: ${SERVICE_NAME}"
echo -e "=========================================="
echo ""
log_step "停止服务..."
if [ "$DRY_RUN" = false ]; then
ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl stop ${SERVICE_NAME}" 2>/dev/null || log_warn "停止服务失败,继续尝试..."
else
log_dry "systemctl stop ${SERVICE_NAME}"
fi
log_step "恢复上一个正常版本..."
if [ "$DRY_RUN" = false ]; then
ssh "${REMOTE_USER}@${REMOTE_HOST}" "cp '${backup_path}' '${REMOTE_DIR}/${PROJECT_NAME}'"
else
log_dry "cp '${backup_path}' '${REMOTE_DIR}/${PROJECT_NAME}'"
fi
log_step "启动服务..."
if [ "$DRY_RUN" = false ]; then
ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl start ${SERVICE_NAME}"
sleep 3
else
log_dry "systemctl start ${SERVICE_NAME}"
fi
log_info "自动回滚完成"
log_to_file "AUTO_ROLLBACK to $(basename $backup_path) - tests failed"
send_webhook "failure" "部署测试失败,已自动回滚到: $(basename $backup_path)"
}
# 清理旧备份,只保留最近 N 个
cleanup_old_backups() {
local keep_count="${1:-5}"
if [ "$DRY_RUN" = true ]; then
log_dry "保留最近 ${keep_count} 个备份,删除其余"
return 0
fi
local backup_count
backup_count=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null | wc -l" || echo "0")
if [ "$backup_count" -le "$keep_count" ]; then
return 0
fi
local to_delete
to_delete=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"ls -t ${REMOTE_DIR}/${PROJECT_NAME}.backup.* 2>/dev/null | tail -n +$((keep_count + 1))" || echo "")
if [ -z "$to_delete" ]; then
return 0
fi
log_step "清理旧备份(保留 ${keep_count} 个)..."
echo "$to_delete" | while read -r file; do
if [ -n "$file" ]; then
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f '${file}'" 2>/dev/null && echo " 已删除: ${file}" || true
fi
done
}
# 检查依赖
@@ -296,23 +384,24 @@ build_project() {
fi
}
# 备份旧版本
# 备份旧版本并保存回滚标记
backup_old_version() {
log_step "备份旧版本..."
local binary_path="./target/release/${PROJECT_NAME}"
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
local backup_path="${REMOTE_DIR}/${PROJECT_NAME}.backup.${timestamp}"
BACKUP_PATH="${REMOTE_DIR}/${PROJECT_NAME}.backup.${timestamp}"
if [ "$DRY_RUN" = true ]; then
log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'if [ -f ${REMOTE_DIR}/${PROJECT_NAME} ]; then cp ${REMOTE_DIR}/${PROJECT_NAME} ${backup_path}; fi'"
log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'if [ -f ${REMOTE_DIR}/${PROJECT_NAME} ]; then cp ${REMOTE_DIR}/${PROJECT_NAME} ${BACKUP_PATH}; echo \"\${BACKUP_PATH}\" > ${REMOTE_DIR}/.last_deployed; fi'"
return 0
fi
ssh "${REMOTE_USER}@${REMOTE_HOST}" "
if [ -f '${REMOTE_DIR}/${PROJECT_NAME}' ]; then
cp '${REMOTE_DIR}/${PROJECT_NAME}' '${backup_path}'
echo '已备份到: ${backup_path}'
cp '${REMOTE_DIR}/${PROJECT_NAME}' '${BACKUP_PATH}'
echo '${BACKUP_PATH}' > '${REMOTE_DIR}/.last_deployed'
echo '已备份到: ${BACKUP_PATH}'
else
echo '无旧版本需要备份'
fi
@@ -350,6 +439,23 @@ upload_config() {
log_info "配置文件上传完成"
}
# 上传迁移文件
upload_migrations() {
log_step "上传迁移文件..."
if [ "$DRY_RUN" = true ]; then
log_dry "rsync -avzP ./migrations/ '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/migrations/'"
return 0
fi
if [ ! -d "./migrations" ]; then
log_warn "迁移目录不存在,跳过"
return 0
fi
rsync -avzP --progress ./migrations/ "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/migrations/"
log_info "迁移文件上传完成"
}
# 上传测试脚本
upload_test_script() {
log_step "上传测试脚本..."
@@ -363,6 +469,24 @@ upload_test_script() {
log_info "测试脚本上传完成"
}
# 上传清理脚本(定时任务用)
upload_cleanup_script() {
log_step "上传清理脚本..."
if [ "$DRY_RUN" = true ]; then
log_dry "rsync -avzP ./scripts/cleanup_refresh_tokens.sh '${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/'"
return 0
fi
mkdir -p ./scripts 2>/dev/null || true
if [ -f "./scripts/cleanup_refresh_tokens.sh" ]; then
rsync -avzP --progress ./scripts/cleanup_refresh_tokens.sh "${REMOTE_USER}@${REMOTE_HOST}:${REMOTE_DIR}/scripts/"
ssh "${REMOTE_USER}@${REMOTE_HOST}" "chmod +x ${REMOTE_DIR}/scripts/cleanup_refresh_tokens.sh"
log_info "清理脚本上传完成"
else
log_warn "清理脚本不存在,跳过"
fi
}
# 重启远程服务
restart_service() {
log_step "重启服务 '${SERVICE_NAME}'..."
@@ -381,11 +505,11 @@ restart_service() {
sleep 3
}
# 运行部署后测试
run_tests() {
log_step "执行部署后测试..."
if [ "$DRY_RUN" = true ]; then
log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'TEST_DOMAIN=\"${TEST_DOMAIN}\" ${REMOTE_DIR}/test_deployment.sh'"
log_dry "步骤1: curl http://127.0.0.1:${BACKEND_PORT}/health"
log_dry "步骤2: curl ${TEST_DOMAIN}/health"
return 0
fi
@@ -394,16 +518,353 @@ run_tests() {
return 0
fi
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "TEST_DOMAIN=\"${TEST_DOMAIN}\" ${REMOTE_DIR}/test_deployment.sh"; then
log_info "测试通过"
else
log_error "测试失败!请检查服务状态。"
log_step "步骤1: 测试本地后端..."
if ! test_local_backend; then
rollback_and_exit "本地后端测试失败"
fi
log_step "步骤2: 测试域名访问..."
if ! test_domain_access; then
rollback_and_exit "域名访问测试失败"
fi
log_info "测试通过"
return 0
}
test_local_backend() {
local max_retries=3
local retry=0
while [ $retry -lt $max_retries ]; do
retry=$((retry + 1))
log_info "尝试 ${retry}/${max_retries}: 测试本地后端 (127.0.0.1:${BACKEND_PORT})"
# 测试 1: 健康检查
log_info " [1/4] 测试 /health..."
local health_code
health_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:${BACKEND_PORT}/health" 2>/dev/null || echo "000")
if [ "$health_code" != "200" ]; then
log_warn " /health 异常 (HTTP ${health_code})"
sleep 2
continue
fi
log_info " /health 正常"
# 测试 2: 登录接口存在(预期 400微信 code 无效)
log_info " [2/4] 测试 /api/login..."
local login_code
login_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test_code\"'" 2>/dev/null || echo "000")
if [ "$login_code" = "400" ]; then
log_info " /api/login 正常 (HTTP ${login_code}, 微信返回 400 expected)"
elif [ "$login_code" = "200" ]; then
log_info " /api/login 正常 (HTTP ${login_code})"
else
log_warn " /api/login 异常 (HTTP ${login_code})"
sleep 2
continue
fi
# 测试 3: Refresh Token 接口存在
log_info " [3/4] 测试 /api/refresh-token..."
local refresh_code
refresh_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST http://127.0.0.1:${BACKEND_PORT}/api/refresh-token -H 'Content-Type: application/json' -d '{\"refresh_token\":\"invalid\"}'" 2>/dev/null || echo "000")
if [ "$refresh_code" = "401" ]; then
log_info " /api/refresh-token 正常 (HTTP ${refresh_code}, 无效 token expected)"
elif [ "$refresh_code" = "200" ]; then
log_info " /api/refresh-token 正常 (HTTP ${refresh_code})"
else
log_warn " /api/refresh-token 异常 (HTTP ${refresh_code})"
sleep 2
continue
fi
# 测试 4: 根路径
log_info " [4/4] 测试 / (根路径)..."
local root_code
root_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 http://127.0.0.1:${BACKEND_PORT}/" 2>/dev/null || echo "000")
if [ "$root_code" = "200" ] || [ "$root_code" = "404" ]; then
log_info " / 正常 (HTTP ${root_code})"
else
log_warn " / 异常 (HTTP ${root_code})"
sleep 2
continue
fi
log_info "本地后端所有测试通过"
return 0
done
log_error "本地后端测试失败"
return 1
}
test_domain_access() {
local max_retries=3
local retry=0
while [ $retry -lt $max_retries ]; do
retry=$((retry + 1))
log_info "尝试 ${retry}/${max_retries}: 测试域名访问 (${TEST_DOMAIN})"
# 测试 1: 健康检查
log_info " [1/4] 测试 /health..."
local health_code
health_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k ${TEST_DOMAIN}/health" 2>/dev/null || echo "000")
if [ "$health_code" != "200" ]; then
log_warn " /health 异常 (HTTP ${health_code})"
sleep 2
continue
fi
log_info " /health 正常"
# 测试 2: 登录接口
log_info " [2/4] 测试 /api/login..."
local login_code
login_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k -X POST ${TEST_DOMAIN}/api/login -H 'Content-Type: application/json' -d '{\"code\":\"test\"'" 2>/dev/null || echo "000")
if [ "$login_code" = "400" ] || [ "$login_code" = "200" ]; then
log_info " /api/login 正常 (HTTP ${login_code})"
else
log_warn " /api/login 异常 (HTTP ${login_code})"
sleep 2
continue
fi
# 测试 3: Refresh Token 接口
log_info " [3/4] 测试 /api/refresh-token..."
local refresh_code
refresh_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k -X POST ${TEST_DOMAIN}/api/refresh-token -H 'Content-Type: application/json' -d '{\"refresh_token\":\"invalid\"}'" 2>/dev/null || echo "000")
if [ "$refresh_code" = "401" ] || [ "$refresh_code" = "200" ]; then
log_info " /api/refresh-token 正常 (HTTP ${refresh_code})"
else
log_warn " /api/refresh-token 异常 (HTTP ${refresh_code})"
sleep 2
continue
fi
# 测试 4: 根路径
log_info " [4/4] 测试 / (根路径)..."
local root_code
root_code=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"curl -s -o /dev/null -w '%{http_code}' --max-time 10 -k ${TEST_DOMAIN}/" 2>/dev/null || echo "000")
if [ "$root_code" = "200" ] || [ "$root_code" = "404" ]; then
log_info " / 正常 (HTTP ${root_code})"
else
log_warn " / 异常 (HTTP ${root_code})"
sleep 2
continue
fi
log_info "域名访问所有测试通过"
return 0
done
log_error "域名访问测试失败"
return 1
}
rollback_and_exit() {
local reason="$1"
log_error "测试失败: ${reason}"
log_error "自动回滚到上一个正常版本..."
local last_known_good
last_known_good=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat ${REMOTE_DIR}/.last_deployed 2>/dev/null || echo """)
if [ -z "$last_known_good" ]; then
log_error "无法回滚: 未找到上一个正常版本记录"
exit 1
fi
rollback_on_failure "$last_known_good"
exit 1
}
# 备份数据库
backup_database() {
log_step "备份数据库..."
if [ "$DRY_RUN" = true ]; then
log_dry "ssh ${REMOTE_USER}@${REMOTE_HOST} 'bash -c \"docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > ${REMOTE_DIR}/backups/backup.dump\"'"
return 0
fi
local timestamp
timestamp=$(date +%Y%m%d_%H%M%S)
local backup_file="${REMOTE_DIR}/backups/${DB_NAME}_${timestamp}.dump"
log_info "创建数据库备份..."
# 创建备份目录
ssh "${REMOTE_USER}@${REMOTE_HOST}" "mkdir -p ${REMOTE_DIR}/backups"
# 在远程执行备份(使用 stdout 重定向)
if ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"bash -c 'docker exec ${DB_CONTAINER} pg_dump -Fc -U ${DB_USER} ${DB_NAME} > /tmp/backup_${timestamp}.dump && mv /tmp/backup_${timestamp}.dump \"${backup_file}\"'" 2>&1; then
log_info "数据库备份成功: ${DB_NAME}_${timestamp}.dump"
# 清理旧数据库备份保留2个
cleanup_old_db_backups
else
log_warn "数据库备份失败,继续部署"
fi
}
# 清理旧数据库备份保留2个
cleanup_old_db_backups() {
local keep_count="${1:-1}"
local backup_count
backup_count=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | wc -l" || echo "0")
if [ "$backup_count" -le "$keep_count" ]; then
return 0
fi
local to_delete
to_delete=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"ls -t ${REMOTE_DIR}/backups/${DB_NAME}_*.dump 2>/dev/null | tail -n +$((keep_count + 1))" || echo "")
if [ -z "$to_delete" ]; then
return 0
fi
log_step "清理旧数据库备份(保留 ${keep_count} 个)..."
echo "$to_delete" | while read -r file; do
if [ -n "$file" ]; then
ssh "${REMOTE_USER}@${REMOTE_HOST}" "rm -f '${file}'" 2>/dev/null && echo " 已删除: $(basename ${file})" || true
fi
done
}
# 回滚数据库(手动触发)
rollback_database() {
local backup_file="$1"
if [ -z "$backup_file" ]; then
log_error "回滚失败: 未指定备份文件"
return 1
fi
log_step "回滚数据库..."
log_warn "从备份恢复: ${backup_file}"
if [ "$DRY_RUN" = true ]; then
log_dry "pg_restore -c -U ${DB_USER} -d ${DB_NAME} ${backup_file}"
return 0
fi
# 检查备份文件是否存在
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "[ -f '${backup_file}' ]"; then
log_error "备份文件不存在: ${backup_file}"
return 1
fi
# 停止服务避免数据库冲突
log_info "停止服务..."
ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl stop ${SERVICE_NAME}" 2>/dev/null || true
# 执行恢复(通过 stdin 管道)
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat '${backup_file}'" | \
ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"docker exec -i ${DB_CONTAINER} pg_restore -c -U ${DB_USER} -d ${DB_NAME}" 2>&1; then
log_info "数据库恢复成功"
else
log_error "数据库恢复失败"
return 1
fi
# 重启服务
log_info "重启服务..."
ssh "${REMOTE_USER}@${REMOTE_HOST}" "systemctl start ${SERVICE_NAME}"
log_info "数据库回滚完成"
}
# 执行数据库迁移
run_migrations() {
log_step "执行数据库迁移..."
if [ "$DRY_RUN" = true ]; then
log_dry "检查并执行 migrations/*.sql"
local migrations
migrations=$(ls ./migrations/*.sql 2>/dev/null | wc -l || echo "0")
log_dry "发现 ${migrations} 个迁移文件"
return 0
fi
# 检查是否有迁移文件
local sql_files
sql_files=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "")
if [ -n "$sql_files" ]; then
# 有迁移文件,先备份数据库
backup_database
else
log_info "没有发现迁移文件,跳过"
fi
log_info "检查迁移文件..."
if ! ssh "${REMOTE_USER}@${REMOTE_HOST}" "[ -d ${REMOTE_DIR}/migrations ]"; then
log_warn "迁移目录不存在,跳过"
return 0
fi
local sql_files
sql_files=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" "ls ${REMOTE_DIR}/migrations/*.sql 2>/dev/null | sort" || echo "")
if [ -z "$sql_files" ]; then
log_warn "没有找到迁移文件"
return 0
fi
for sql_file in $sql_files; do
local filename
filename=$(basename "$sql_file")
local table_check=$(echo "$filename" | sed 's/[0-9]*_//' | sed 's/.sql$//')
log_info "检查迁移: $filename"
if [ "$table_check" = "add_refresh_tokens" ]; then
local exists
exists=$(ssh "${REMOTE_USER}@${REMOTE_HOST}" \
"docker exec ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME} -tAc \"SELECT 1 FROM information_schema.tables WHERE table_name = 'refresh_tokens'\" 2>/dev/null" || echo "0")
if [ "$exists" = "1" ]; then
log_info " 表 refresh_tokens 已存在,跳过"
continue
fi
fi
log_info " 执行迁移: $filename"
if ssh "${REMOTE_USER}@${REMOTE_HOST}" "docker exec -i ${DB_CONTAINER} psql -U ${DB_USER} -d ${DB_NAME}" < "$sql_file"; then
log_info " 完成: $filename"
else
log_warn " 迁移可能已执行或失败,继续"
fi
done
log_info "数据库迁移完成"
}
# 确认部署
confirm_deploy() {
if [ "$SKIP_CONFIRM" = true ]; then
if [ "$DRY_RUN" = true ] || [ "$SKIP_CONFIRM" = true ]; then
return 0
fi
@@ -474,20 +935,31 @@ main() {
ssh_connect
build_project
backup_old_version
cleanup_old_backups 5
upload_binary
upload_config
upload_migrations
upload_test_script
upload_cleanup_script
restart_service
run_tests
run_migrations
log_to_file "DEPLOY success"
send_webhook "success" "部署完成"
if run_tests; then
log_to_file "DEPLOY success"
send_webhook "success" "部署完成"
echo ""
log_info "========== 部署完成! =========="
echo -e " Git: ${GIT_VERSION}"
echo -e " 环境: ${APP_ENV}"
echo ""
echo ""
log_info "========== 部署完成! =========="
echo -e " Git: ${GIT_VERSION}"
echo -e " 环境: ${APP_ENV}"
echo ""
else
log_error "========== 部署失败,已自动回滚 =========="
echo -e " Git: ${GIT_VERSION}"
echo -e " 环境: ${APP_ENV}"
echo ""
exit 1
fi
}
main