feat(deploy): 添加 Gitea 自动部署 Webhook 及配套工具
新增 `/deploy/webhook` 接口,支持 Gitea HMAC 签名校验、指定分支部署、构建并发锁与超时控制 添加前端项目部署脚本 `scripts/deploy_front.sh`,负责拉取代码、安装依赖并执行构建 新增部署相关配置项并更新 `.env.example` 示例配置 更新项目文档说明自动部署的使用约定 补充 `.gitignore` 规则忽略证书和 PEM 格式文件
This commit is contained in:
@@ -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
|
||||||
@@ -6,3 +6,5 @@ todos.sqlite3
|
|||||||
app.sqlite3
|
app.sqlite3
|
||||||
front/node_modules/
|
front/node_modules/
|
||||||
front/dist/
|
front/dist/
|
||||||
|
certs/
|
||||||
|
*.pem
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
- `app/routers/products.py`:商品管理接口,提供 `/products` 的增删改查。
|
- `app/routers/products.py`:商品管理接口,提供 `/products` 的增删改查。
|
||||||
- `app/routers/orders.py`:订单管理接口,提供 `/orders` 的增删改查。
|
- `app/routers/orders.py`:订单管理接口,提供 `/orders` 的增删改查。
|
||||||
- `app/routers/payments.py`:微信支付接口,提供 JSAPI 预下单和支付通知处理。
|
- `app/routers/payments.py`:微信支付接口,提供 JSAPI 预下单和支付通知处理。
|
||||||
|
- `app/routers/deploy.py`:自动部署 webhook 接口,供 Gitea 推送后触发前端构建。
|
||||||
- `app/routers/rituals.py`:法事管理接口,提供 `/ritual-services` 的增删改查。
|
- `app/routers/rituals.py`:法事管理接口,提供 `/ritual-services` 的增删改查。
|
||||||
- `app/routers/uploads.py`:图片上传接口,提供 `/uploads/images` 并上传到阿里云 OSS。
|
- `app/routers/uploads.py`:图片上传接口,提供 `/uploads/images` 并上传到阿里云 OSS。
|
||||||
- `app/config.py`:项目运行配置,目前预留阿里云 OSS 配置。
|
- `app/config.py`:项目运行配置,目前预留阿里云 OSS 配置。
|
||||||
@@ -91,6 +92,7 @@ pnpm dev
|
|||||||
- 新增后端业务模块时,优先新增 `app/routers/<module>.py` 和对应 `app/schemas.py` 模型。
|
- 新增后端业务模块时,优先新增 `app/routers/<module>.py` 和对应 `app/schemas.py` 模型。
|
||||||
- 新增前端业务模块时,优先新增 `front/src/views/<module>/`、`front/src/api/<module>.ts`,并在 `front/src/router/index.ts` 注册路由。
|
- 新增前端业务模块时,优先新增 `front/src/views/<module>/`、`front/src/api/<module>.ts`,并在 `front/src/router/index.ts` 注册路由。
|
||||||
- 登录认证已接入,后台路由由前端守卫检查登录状态;认证令牌由后端 HttpOnly Cookie 管理,前端不保存 token。
|
- 登录认证已接入,后台路由由前端守卫检查登录状态;认证令牌由后端 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:新增登录认证功能,后端支持动态验证码、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:商品模型新增商品描述字段,后端补充 `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-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 签名校验测试通过。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
- `routers/products.py`:商品管理接口,按寺院隔离。
|
- `routers/products.py`:商品管理接口,按寺院隔离。
|
||||||
- `routers/orders.py`:订单管理接口,按寺院隔离。
|
- `routers/orders.py`:订单管理接口,按寺院隔离。
|
||||||
- `routers/payments.py`:微信支付接口,按寺院隔离发起 JSAPI 预下单并处理支付通知。
|
- `routers/payments.py`:微信支付接口,按寺院隔离发起 JSAPI 预下单并处理支付通知。
|
||||||
|
- `routers/deploy.py`:自动部署 webhook 接口,校验 Gitea 签名或部署 token 后触发前端构建脚本。
|
||||||
- `routers/rituals.py`:法事管理接口,按寺院隔离。
|
- `routers/rituals.py`:法事管理接口,按寺院隔离。
|
||||||
- `routers/uploads.py`:图片上传接口,上传到阿里云 OSS。
|
- `routers/uploads.py`:图片上传接口,上传到阿里云 OSS。
|
||||||
- `config.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`。
|
- 配置通过 `.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`。
|
- 支付通知需要验签并用 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:新增登录认证模块,支持服务端动态验证码、bcrypt 密码哈希、失败次数限制、账号临时锁定、HttpOnly/Secure Cookie 会话、退出登录、登录 IP/设备日志和新环境登录标记;`requirements.txt` 新增 `bcrypt`。验证:后端编译通过,运行态登录测试待安装 `bcrypt` 后执行。
|
||||||
- 2026-07-23:商品模型新增 `description` 商品描述字段,后端补充 `products.description` 轻量迁移、Pydantic 模型和 `/products` CRUD 读写。验证:后端编译通过,运行态接口测试待安装 `bcrypt` 后执行。
|
- 2026-07-23:商品模型新增 `description` 商品描述字段,后端补充 `products.description` 轻量迁移、Pydantic 模型和 `/products` CRUD 读写。验证:后端编译通过,运行态接口测试待安装 `bcrypt` 后执行。
|
||||||
- 2026-07-23:新增微信支付 JSAPI 预下单和支付通知处理,增加 `payment_transactions` 支付流水表、微信支付 API v3 签名/通知验签/回调解密工具和配置占位;`requirements.txt` 新增 `cryptography`。验证:后端编译通过,真实预下单和通知验签待填写微信商户配置后联调。
|
- 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
@@ -3,8 +3,11 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parents[1].resolve()
|
||||||
|
|
||||||
|
|
||||||
def load_local_env() -> None:
|
def load_local_env() -> None:
|
||||||
env_path = Path(__file__).parents[1] / ".env"
|
env_path = PROJECT_ROOT / ".env"
|
||||||
if not env_path.exists():
|
if not env_path.exists():
|
||||||
return
|
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_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"
|
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)
|
@dataclass(frozen=True)
|
||||||
class OSSSettings:
|
class OSSSettings:
|
||||||
@@ -136,3 +147,29 @@ wechat_pay_settings = WeChatPaySettings(
|
|||||||
api_base_url=WECHAT_PAY_API_BASE_URL,
|
api_base_url=WECHAT_PAY_API_BASE_URL,
|
||||||
skip_notify_verify=WECHAT_PAY_SKIP_NOTIFY_VERIFY,
|
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
@@ -2,7 +2,7 @@ from fastapi import FastAPI
|
|||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.database import lifespan
|
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(
|
app = FastAPI(
|
||||||
@@ -43,3 +43,4 @@ app.include_router(orders.router)
|
|||||||
app.include_router(payments.router)
|
app.include_router(payments.router)
|
||||||
app.include_router(rituals.router)
|
app.include_router(rituals.router)
|
||||||
app.include_router(uploads.router)
|
app.include_router(uploads.router)
|
||||||
|
app.include_router(deploy.router)
|
||||||
|
|||||||
@@ -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
|
||||||
Executable
+34
@@ -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
|
||||||
Reference in New Issue
Block a user