From 5717c651b2bd8ad4690e4c4ea48174d5b54104bd Mon Sep 17 00:00:00 2001 From: wenfp Date: Mon, 27 Jul 2026 11:51:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(deploy):=20=E6=B7=BB=E5=8A=A0=20Gitea=20?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=83=A8=E7=BD=B2=20Webhook=20=E5=8F=8A?= =?UTF-8?q?=E9=85=8D=E5=A5=97=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 `/deploy/webhook` 接口,支持 Gitea HMAC 签名校验、指定分支部署、构建并发锁与超时控制 添加前端项目部署脚本 `scripts/deploy_front.sh`,负责拉取代码、安装依赖并执行构建 新增部署相关配置项并更新 `.env.example` 示例配置 更新项目文档说明自动部署的使用约定 补充 `.gitignore` 规则忽略证书和 PEM 格式文件 --- .env.example | 9 +++ .gitignore | 2 + AGENTS.md | 3 + app/AGENTS.md | 10 +++ app/config.py | 39 ++++++++- app/main.py | 3 +- app/routers/deploy.py | 171 ++++++++++++++++++++++++++++++++++++++++ scripts/deploy_front.sh | 34 ++++++++ 8 files changed, 269 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 app/routers/deploy.py create mode 100755 scripts/deploy_front.sh diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4ed02f6 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index 7bf55ba..ed65a6e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ todos.sqlite3 app.sqlite3 front/node_modules/ front/dist/ +certs/ +*.pem \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 6b97c69..4ef1ded 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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/.py` 和对应 `app/schemas.py` 模型。 - 新增前端业务模块时,优先新增 `front/src/views//`、`front/src/api/.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 签名校验测试通过。 diff --git a/app/AGENTS.md b/app/AGENTS.md index 38cdbb7..62061a7 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -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 签名校验测试通过。 diff --git a/app/config.py b/app/config.py index a750480..faa684c 100644 --- a/app/config.py +++ b/app/config.py @@ -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, +) diff --git a/app/main.py b/app/main.py index 0d594c2..23a76b0 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/routers/deploy.py b/app/routers/deploy.py new file mode 100644 index 0000000..d1b54b4 --- /dev/null +++ b/app/routers/deploy.py @@ -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 diff --git a/scripts/deploy_front.sh b/scripts/deploy_front.sh new file mode 100755 index 0000000..a5e294e --- /dev/null +++ b/scripts/deploy_front.sh @@ -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