feat(deploy): 添加 Gitea 自动部署 Webhook 及配套工具

新增 `/deploy/webhook` 接口,支持 Gitea HMAC 签名校验、指定分支部署、构建并发锁与超时控制
添加前端项目部署脚本 `scripts/deploy_front.sh`,负责拉取代码、安装依赖并执行构建
新增部署相关配置项并更新 `.env.example` 示例配置
更新项目文档说明自动部署的使用约定
补充 `.gitignore` 规则忽略证书和 PEM 格式文件
This commit is contained in:
wenfp
2026-07-27 11:51:55 +08:00
parent cd4fdc6079
commit 5717c651b2
8 changed files with 269 additions and 2 deletions
+9
View File
@@ -0,0 +1,9 @@
# 自动部署 webhook 配置
# 生产环境请换成一段足够长的随机密钥,不要提交真实 .env。
DEPLOY_WEBHOOK_SECRET=replace-with-a-long-random-secret
DEPLOY_PROJECT_DIR=/www/wwwroot/next.zizaifutian.com/admin
DEPLOY_FRONT_DIR=front
DEPLOY_BRANCH=main
DEPLOY_SCRIPT_PATH=/www/wwwroot/next.zizaifutian.com/admin/scripts/deploy_front.sh
DEPLOY_COMMAND_TIMEOUT=600
DEPLOY_LOCK_PATH=/tmp/py-web-deploy.lock
+2
View File
@@ -6,3 +6,5 @@ todos.sqlite3
app.sqlite3
front/node_modules/
front/dist/
certs/
*.pem
+3
View File
@@ -18,6 +18,7 @@
- `app/routers/products.py`:商品管理接口,提供 `/products` 的增删改查。
- `app/routers/orders.py`:订单管理接口,提供 `/orders` 的增删改查。
- `app/routers/payments.py`:微信支付接口,提供 JSAPI 预下单和支付通知处理。
- `app/routers/deploy.py`:自动部署 webhook 接口,供 Gitea 推送后触发前端构建。
- `app/routers/rituals.py`:法事管理接口,提供 `/ritual-services` 的增删改查。
- `app/routers/uploads.py`:图片上传接口,提供 `/uploads/images` 并上传到阿里云 OSS。
- `app/config.py`:项目运行配置,目前预留阿里云 OSS 配置。
@@ -91,6 +92,7 @@ pnpm dev
- 新增后端业务模块时,优先新增 `app/routers/<module>.py` 和对应 `app/schemas.py` 模型。
- 新增前端业务模块时,优先新增 `front/src/views/<module>/``front/src/api/<module>.ts`,并在 `front/src/router/index.ts` 注册路由。
- 登录认证已接入,后台路由由前端守卫检查登录状态;认证令牌由后端 HttpOnly Cookie 管理,前端不保存 token。
- 自动部署接口为 `/deploy/webhook`,生产环境经 Nginx 的 `/api/deploy/webhook` 暴露给 Gitea;部署密钥、项目目录、前端目录、部署分支和脚本路径通过 `.env` 或环境变量配置。
## 修改记录约定
@@ -113,3 +115,4 @@ pnpm dev
- 2026-07-23:新增登录认证功能,后端支持动态验证码、bcrypt 密码哈希、失败次数限制、账号临时锁定、HttpOnly/Secure Cookie 会话、退出登录和登录 IP/设备日志;前端登录页支持用户名、密码、验证码、自动聚焦、账号 trim、密码显示/隐藏、提交 loading、友好错误提示和路由守卫;`requirements.txt` 新增 `bcrypt`。验证:后端编译通过,前端 `pnpm typecheck``pnpm build` 通过,运行态登录测试待安装 `bcrypt` 后执行。
- 2026-07-23:商品模型新增商品描述字段,后端补充 `products.description` 迁移、Pydantic 模型和 CRUD 读写,前端商品 API 类型与商品编辑基础信息表单同步。验证:后端编译通过,前端 `pnpm typecheck``pnpm build` 通过,运行态接口测试待安装 `bcrypt` 后执行。
- 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,后端增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位,前端订单管理页增加微信预下单入口;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,前端 `pnpm typecheck``pnpm build` 通过,真实预下单和支付通知待填写微信商户配置后联调。
- 2026-07-27:新增 Gitea 自动部署 webhook,后端 `/deploy/webhook` 校验 Gitea HMAC 签名或部署 token 后执行 `scripts/deploy_front.sh`,脚本负责拉取代码、安装前端依赖并执行 `pnpm build`;新增 `.env.example` 记录部署环境变量。验证:后端编译通过,webhook 密钥与 Gitea 签名校验测试通过。
+10
View File
@@ -12,6 +12,7 @@
- `routers/products.py`:商品管理接口,按寺院隔离。
- `routers/orders.py`:订单管理接口,按寺院隔离。
- `routers/payments.py`:微信支付接口,按寺院隔离发起 JSAPI 预下单并处理支付通知。
- `routers/deploy.py`:自动部署 webhook 接口,校验 Gitea 签名或部署 token 后触发前端构建脚本。
- `routers/rituals.py`:法事管理接口,按寺院隔离。
- `routers/uploads.py`:图片上传接口,上传到阿里云 OSS。
- `config.py`:项目运行配置,目前预留阿里云 OSS 配置。
@@ -83,6 +84,14 @@ OSS_ENDPOINT、OSS_BUCKET_NAME、OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS
- 配置通过 `.env` 或环境变量提供:`WECHAT_PAY_APPID``WECHAT_PAY_MCH_ID``WECHAT_PAY_MCH_SERIAL_NO``WECHAT_PAY_API_V3_KEY``WECHAT_PAY_PRIVATE_KEY_PATH``WECHAT_PAY_PUBLIC_KEY_PATH``WECHAT_PAY_NOTIFY_URL`
- 支付通知需要验签并用 APIv3 密钥解密;生产环境必须配置微信支付公钥或平台证书,不应开启 `WECHAT_PAY_SKIP_NOTIFY_VERIFY`
## 自动部署约定
- 自动部署接口为 `/deploy/webhook`,生产环境通常通过 Nginx 映射为 `/api/deploy/webhook` 给 Gitea 调用。
- Gitea Webhook 应配置 `Secret`,后端会按原始请求体校验 `X-Gitea-Signature` 的 HMAC-SHA256 签名;也兼容 `X-Hub-Signature-256`
- 部署配置通过 `.env` 或环境变量提供:`DEPLOY_WEBHOOK_SECRET``DEPLOY_PROJECT_DIR``DEPLOY_FRONT_DIR``DEPLOY_BRANCH``DEPLOY_SCRIPT_PATH``DEPLOY_COMMAND_TIMEOUT``DEPLOY_LOCK_PATH`
- 部署脚本为 `scripts/deploy_front.sh`,负责在服务器项目目录执行 `git pull`、进入前端目录安装依赖并执行 `pnpm build`
- webhook 密钥不得写入源码或文档;`.env.example` 只能保留占位值。
## 验证
后端修改后优先执行:
@@ -103,3 +112,4 @@ OSS_ENDPOINT、OSS_BUCKET_NAME、OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS
- 2026-07-23:新增登录认证模块,支持服务端动态验证码、bcrypt 密码哈希、失败次数限制、账号临时锁定、HttpOnly/Secure Cookie 会话、退出登录、登录 IP/设备日志和新环境登录标记;`requirements.txt` 新增 `bcrypt`。验证:后端编译通过,运行态登录测试待安装 `bcrypt` 后执行。
- 2026-07-23:商品模型新增 `description` 商品描述字段,后端补充 `products.description` 轻量迁移、Pydantic 模型和 `/products` CRUD 读写。验证:后端编译通过,运行态接口测试待安装 `bcrypt` 后执行。
- 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。
- 2026-07-27:新增自动部署 webhook 模块,`/deploy/webhook` 支持 Gitea HMAC 签名校验、部署分支过滤、并发锁和构建脚本超时控制,`scripts/deploy_front.sh` 负责拉取代码并构建前端。验证:后端编译通过,webhook 密钥与 Gitea 签名校验测试通过。
+38 -1
View File
@@ -3,8 +3,11 @@ from dataclasses import dataclass
from pathlib import Path
PROJECT_ROOT = Path(__file__).parents[1].resolve()
def load_local_env() -> None:
env_path = Path(__file__).parents[1] / ".env"
env_path = PROJECT_ROOT / ".env"
if not env_path.exists():
return
@@ -43,6 +46,14 @@ WECHAT_PAY_NOTIFY_URL = os.getenv("WECHAT_PAY_NOTIFY_URL", "")
WECHAT_PAY_API_BASE_URL = os.getenv("WECHAT_PAY_API_BASE_URL", "https://api.mch.weixin.qq.com")
WECHAT_PAY_SKIP_NOTIFY_VERIFY = os.getenv("WECHAT_PAY_SKIP_NOTIFY_VERIFY", "0") == "1"
DEPLOY_WEBHOOK_SECRET = os.getenv("DEPLOY_WEBHOOK_SECRET", "")
DEPLOY_PROJECT_DIR = os.getenv("DEPLOY_PROJECT_DIR", str(PROJECT_ROOT))
DEPLOY_FRONT_DIR = os.getenv("DEPLOY_FRONT_DIR", "front")
DEPLOY_BRANCH = os.getenv("DEPLOY_BRANCH", "")
DEPLOY_SCRIPT_PATH = os.getenv("DEPLOY_SCRIPT_PATH", str(PROJECT_ROOT / "scripts" / "deploy_front.sh"))
DEPLOY_COMMAND_TIMEOUT = int(os.getenv("DEPLOY_COMMAND_TIMEOUT", "600"))
DEPLOY_LOCK_PATH = os.getenv("DEPLOY_LOCK_PATH", "/tmp/py-web-deploy.lock")
@dataclass(frozen=True)
class OSSSettings:
@@ -136,3 +147,29 @@ wechat_pay_settings = WeChatPaySettings(
api_base_url=WECHAT_PAY_API_BASE_URL,
skip_notify_verify=WECHAT_PAY_SKIP_NOTIFY_VERIFY,
)
@dataclass(frozen=True)
class DeploySettings:
webhook_secret: str
project_dir: str
front_dir: str
branch: str
script_path: str
command_timeout: int
lock_path: str
@property
def is_configured(self) -> bool:
return bool(self.webhook_secret and self.script_path)
deploy_settings = DeploySettings(
webhook_secret=DEPLOY_WEBHOOK_SECRET,
project_dir=DEPLOY_PROJECT_DIR,
front_dir=DEPLOY_FRONT_DIR,
branch=DEPLOY_BRANCH,
script_path=DEPLOY_SCRIPT_PATH,
command_timeout=DEPLOY_COMMAND_TIMEOUT,
lock_path=DEPLOY_LOCK_PATH,
)
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.database import lifespan
from app.routers import auth, orders, payments, products, rituals, temples, todos, uploads, users
from app.routers import auth, deploy, orders, payments, products, rituals, temples, todos, uploads, users
app = FastAPI(
@@ -43,3 +43,4 @@ app.include_router(orders.router)
app.include_router(payments.router)
app.include_router(rituals.router)
app.include_router(uploads.router)
app.include_router(deploy.router)
+171
View File
@@ -0,0 +1,171 @@
import hashlib
import hmac
import os
import subprocess
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs
from fastapi import APIRouter, HTTPException, Request, status
from app.config import deploy_settings
router = APIRouter(prefix="/deploy", tags=["deploy"])
def get_request_token(request: Request) -> str:
return (
request.headers.get("x-deploy-token")
or request.headers.get("x-gitlab-token")
or request.query_params.get("token")
or ""
)
def signature_matches(secret: str, body: bytes, signature: str | None) -> bool:
if not signature:
return False
expected = hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
normalized = signature.strip()
if normalized.startswith("sha256="):
normalized = normalized.removeprefix("sha256=")
return hmac.compare_digest(normalized, expected)
def verify_webhook_secret(request: Request, body: bytes) -> None:
if not deploy_settings.webhook_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="自动部署密钥未配置,请先设置 DEPLOY_WEBHOOK_SECRET",
)
token = get_request_token(request)
if token and hmac.compare_digest(token, deploy_settings.webhook_secret):
return
signatures = (
request.headers.get("x-hub-signature-256"),
request.headers.get("x-gitea-signature"),
request.headers.get("x-gogs-signature"),
)
if any(signature_matches(deploy_settings.webhook_secret, body, value) for value in signatures):
return
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="自动部署密钥不正确")
def parse_payload(body: bytes, content_type: str) -> dict[str, Any]:
if not body:
return {}
payload_body = body
if "application/x-www-form-urlencoded" in content_type.lower():
form = parse_qs(body.decode("utf-8", errors="replace"))
payload_values = form.get("payload")
if not payload_values:
return {}
payload_body = payload_values[0].encode("utf-8")
try:
import json
value = json.loads(payload_body)
except ValueError:
return {}
return value if isinstance(value, dict) else {}
def should_deploy(payload: dict[str, Any]) -> bool:
if not deploy_settings.branch:
return True
ref = str(payload.get("ref") or "")
branch = str(payload.get("branch") or "")
expected_ref = f"refs/heads/{deploy_settings.branch}"
return ref == expected_ref or branch == deploy_settings.branch
def output_tail(value: str, max_length: int = 4000) -> str:
value = value.strip()
if len(value) <= max_length:
return value
return value[-max_length:]
def acquire_lock() -> int:
try:
return os.open(deploy_settings.lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError as exc:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="前端正在构建中,请稍后再试",
) from exc
def release_lock(fd: int) -> None:
os.close(fd)
try:
os.unlink(deploy_settings.lock_path)
except FileNotFoundError:
pass
@router.post("/webhook")
async def deploy_webhook(request: Request) -> dict[str, Any]:
body = await request.body()
verify_webhook_secret(request, body)
payload = parse_payload(body, request.headers.get("content-type", ""))
if not should_deploy(payload):
return {
"status": "ignored",
"message": f"当前推送不是部署分支 {deploy_settings.branch}",
"ref": payload.get("ref"),
}
script_path = Path(deploy_settings.script_path)
if not script_path.exists():
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"部署脚本不存在:{script_path}",
)
lock_fd = acquire_lock()
try:
env = os.environ.copy()
env.update(
{
"DEPLOY_PROJECT_DIR": deploy_settings.project_dir,
"DEPLOY_FRONT_DIR": deploy_settings.front_dir,
"DEPLOY_BRANCH": deploy_settings.branch,
}
)
result = subprocess.run(
[str(script_path)],
cwd=deploy_settings.project_dir,
env=env,
capture_output=True,
text=True,
timeout=deploy_settings.command_timeout,
check=False,
)
except subprocess.TimeoutExpired as exc:
raise HTTPException(
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
detail=f"部署命令超过 {deploy_settings.command_timeout} 秒未完成",
) from exc
finally:
release_lock(lock_fd)
response = {
"status": "success" if result.returncode == 0 else "failed",
"returncode": result.returncode,
"stdout": output_tail(result.stdout),
"stderr": output_tail(result.stderr),
}
if result.returncode != 0:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=response)
return response
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
set -euo pipefail
PROJECT_DIR="${DEPLOY_PROJECT_DIR:-/www/wwwroot/next.zizaifutian.com/admin}"
FRONT_DIR="${DEPLOY_FRONT_DIR:-front}"
BRANCH="${DEPLOY_BRANCH:-}"
cd "$PROJECT_DIR"
if [ -n "$BRANCH" ]; then
git fetch origin "$BRANCH"
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
git checkout "$BRANCH"
else
git checkout -b "$BRANCH" "origin/$BRANCH"
fi
git pull --ff-only origin "$BRANCH"
else
git pull --ff-only
fi
cd "$FRONT_DIR"
if command -v corepack >/dev/null 2>&1; then
corepack enable >/dev/null 2>&1 || true
fi
if [ -f pnpm-lock.yaml ]; then
pnpm install --frozen-lockfile
else
pnpm install
fi
pnpm build