feat: 添加自动化部署脚本、Webhook处理器与TDD测试套件
这个提交包含在:
42
AGENTS.md
普通文件
42
AGENTS.md
普通文件
@@ -0,0 +1,42 @@
|
||||
|
||||
|
||||
|
||||
|
||||
\### 站点信息
|
||||
|
||||
118.184.172.94
|
||||
|
||||
root
|
||||
|
||||
密码Bjsslxz1
|
||||
|
||||
目录
|
||||
|
||||
/www/wwwroot/www.gynhc.com
|
||||
|
||||
https://www.gynhc.com/a.php
|
||||
|
||||
|
||||
|
||||
远程代码仓
|
||||
|
||||
https://gitcode.gynhc.com/wikiboss/nahaichuan.git
|
||||
|
||||
|
||||
|
||||
浅色专业风
|
||||
|
||||
背景:#FFFFFF / #F8FAFC
|
||||
|
||||
主色:#2563EB 科技蓝
|
||||
|
||||
辅助:#06B6D4 青、#7C3AED 紫
|
||||
|
||||
文字:#0F172A / #475569
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
现代中式高端软件公司官网。主菜单:服务、产品、案例、关于我们、联系我们。独立页面:服务、产品、案例。风格:严谨商务、高级感、现代中式、浅色专业风。背景 #FFFFFF 与 #F8FAFC,主色 #2563EB,辅助 #06B6D4 与 #7C3AED,文字 #0F172A 与 #475569。标题宋体,正文黑体或 Inter。12 列网格,1280px,大留白,细线,水墨抽象,窗棂,印章点缀,圆角 4 至 6px。服务:软件开发、APP与小程序定制、智能体开发。产品:商城、收银、进销存、SaaS版本。首页含首屏、服务产品案例入口、流程、评价、CTA。服务页含分类、能力、流程、交付、FAQ。产品页含矩阵、功能、版本、定价、演示。案例页含筛选、挑战方案结果、数据。关于我们含品牌、团队、资质。联系我们含表单、地图、电话。响应式,禁止 emoji、卡通、霓虹、赛博朋克、国潮堆砌。
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
/**
|
||||
* Git Webhook 核心处理器
|
||||
*/
|
||||
class WebhookHandler
|
||||
{
|
||||
private $secret;
|
||||
private $targetBranch;
|
||||
private $deployExecutor;
|
||||
|
||||
public function __construct(string $secret, string $targetBranch = 'master', $deployExecutor = null)
|
||||
{
|
||||
$this->secret = $secret;
|
||||
$this->targetBranch = $targetBranch;
|
||||
$this->deployExecutor = $deployExecutor;
|
||||
}
|
||||
|
||||
public function handle(array $server, array $get, string $rawBody): array
|
||||
{
|
||||
// 1. 验证权限 (支持 Header 传 Token、URL Token 以及 HMAC-SHA256 签名)
|
||||
if (!$this->verifyAuth($server, $get, $rawBody)) {
|
||||
return ['code' => 403, 'data' => ['status' => 'error', 'message' => 'Invalid token']];
|
||||
}
|
||||
|
||||
// 2. 验证 HTTP 方式
|
||||
$method = strtoupper($server['REQUEST_METHOD'] ?? 'GET');
|
||||
if ($method !== 'POST') {
|
||||
return ['code' => 405, 'data' => ['status' => 'error', 'message' => 'Method not allowed']];
|
||||
}
|
||||
|
||||
// 3. 响应 Ping / Test 事件
|
||||
$event = strtolower($server['HTTP_X_GITEA_EVENT'] ?? $server['HTTP_X_GIT_EVENT'] ?? $server['HTTP_X_GITHUB_EVENT'] ?? '');
|
||||
if ($event === 'ping') {
|
||||
return ['code' => 200, 'data' => ['status' => 'success', 'message' => 'pong']];
|
||||
}
|
||||
|
||||
// 4. 分支过滤
|
||||
$payload = json_decode($rawBody, true) ?: [];
|
||||
$ref = $payload['ref'] ?? '';
|
||||
$branch = str_replace('refs/heads/', '', $ref);
|
||||
|
||||
if ($branch !== $this->targetBranch) {
|
||||
return [
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'status' => 'skipped',
|
||||
'message' => "Branch {$branch} ignored, target is {$this->targetBranch}"
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// 5. 触发部署
|
||||
$output = '';
|
||||
if (is_callable($this->deployExecutor)) {
|
||||
$output = call_user_func($this->deployExecutor, $payload);
|
||||
}
|
||||
|
||||
return [
|
||||
'code' => 200,
|
||||
'data' => [
|
||||
'status' => 'success',
|
||||
'message' => 'Deployment triggered',
|
||||
'branch' => $branch,
|
||||
'output' => $output
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function verifyAuth(array $server, array $get, string $rawBody): bool
|
||||
{
|
||||
// A. 明文 Token 校验
|
||||
$token = $server['HTTP_X_GITEA_TOKEN'] ?? $server['HTTP_X_GIT_TOKEN'] ?? $server['HTTP_X_GITLAB_TOKEN'] ?? ($get['token'] ?? '');
|
||||
if ($token !== '' && hash_equals($this->secret, $token)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// B. HMAC-SHA256 签名校验 (Gitea / GitHub)
|
||||
$signature = $server['HTTP_X_GITEA_SIGNATURE'] ?? $server['HTTP_X_HUB_SIGNATURE_256'] ?? '';
|
||||
if ($signature !== '') {
|
||||
if (strpos($signature, 'sha256=') === 0) {
|
||||
$signature = substr($signature, 7);
|
||||
}
|
||||
$expected = hash_hmac('sha256', $rawBody, $this->secret);
|
||||
if (hash_equals($expected, $signature)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
57
deploy.sh
普通文件
57
deploy.sh
普通文件
@@ -0,0 +1,57 @@
|
||||
#!/bin/bash
|
||||
# 纳海川官网自动部署脚本
|
||||
set -e
|
||||
|
||||
DEPLOY_DIR="/www/wwwroot/www.gynhc.com"
|
||||
LOG_FILE="${DEPLOY_DIR}/runtime/deploy.log"
|
||||
TARGET_BRANCH="${1:-master}"
|
||||
DATE=$(date '+%Y-%m-%d %H:%M:%S')
|
||||
|
||||
echo "[${DATE}] >>> 开始部署分支: ${TARGET_BRANCH}..." >> "${LOG_FILE}"
|
||||
|
||||
cd "${DEPLOY_DIR}"
|
||||
|
||||
# 验证是否为 git 仓库
|
||||
if [ ! -d ".git" ]; then
|
||||
echo "[${DATE}] 错误: ${DEPLOY_DIR} 尚未初始化为 Git 仓库" >> "${LOG_FILE}"
|
||||
echo "Git repo not initialized"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 备份关键环境配置
|
||||
if [ -f "application/database.php" ]; then
|
||||
cp -p application/database.php application/database.php.deploy_bak
|
||||
fi
|
||||
|
||||
# 忽略文件权限变化
|
||||
git config core.fileMode false
|
||||
|
||||
# 拉取最新代码并硬重置
|
||||
echo "[${DATE}] 执行 git fetch origin ${TARGET_BRANCH}..." >> "${LOG_FILE}"
|
||||
git fetch origin "${TARGET_BRANCH}" >> "${LOG_FILE}" 2>&1
|
||||
git reset --hard "origin/${TARGET_BRANCH}" >> "${LOG_FILE}" 2>&1
|
||||
|
||||
# 还原配置文件保护
|
||||
if [ -f "application/database.php.deploy_bak" ]; then
|
||||
cp -p application/database.php.deploy_bak application/database.php
|
||||
rm -f application/database.php.deploy_bak
|
||||
fi
|
||||
|
||||
# 保持目录可写
|
||||
chmod -R 777 runtime 2>/dev/null || true
|
||||
chmod -R 775 public/uploads 2>/dev/null || true
|
||||
|
||||
# 平滑重启 PHP-FPM 刷新 OPcache
|
||||
if [ -x "/etc/init.d/php-fpm-73" ]; then
|
||||
if command -v sudo >/dev/null 2>&1; then
|
||||
sudo -n /etc/init.d/php-fpm-73 reload >> "${LOG_FILE}" 2>&1 || true
|
||||
else
|
||||
/etc/init.d/php-fpm-73 reload >> "${LOG_FILE}" 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
COMMIT_INFO=$(git log -1 --pretty=format:"%h - %s (%an, %cd)")
|
||||
echo "[${DATE}] 当前最新版本: ${COMMIT_INFO}" >> "${LOG_FILE}"
|
||||
echo "[${DATE}] >>> 部署成功" >> "${LOG_FILE}"
|
||||
|
||||
echo "Deploy success: ${COMMIT_INFO}"
|
||||
49
public/webhook.php
普通文件
49
public/webhook.php
普通文件
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
/**
|
||||
* GitCode Webhook 自动部署入口
|
||||
*/
|
||||
|
||||
// 响应头设置
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
// Webhook 密钥 Token
|
||||
$secret = 'nahaichuan_deploy_webhook_2026';
|
||||
|
||||
// 项目根目录与部署脚本
|
||||
$rootDir = dirname(__DIR__);
|
||||
$deployScript = $rootDir . '/deploy.sh';
|
||||
$logFile = $rootDir . '/runtime/webhook.log';
|
||||
|
||||
// 加载核心处理类
|
||||
require_once $rootDir . '/application/common/library/WebhookHandler.php';
|
||||
|
||||
$deployExecutor = function (array $payload) use ($deployScript, $rootDir) {
|
||||
$branch = str_replace('refs/heads/', '', $payload['ref'] ?? 'master');
|
||||
// 用 bash 执行部署脚本并捕获输出
|
||||
$cmd = "bash " . escapeshellarg($deployScript) . " " . escapeshellarg($branch) . " 2>&1";
|
||||
$output = shell_exec($cmd);
|
||||
return trim($output ?? '');
|
||||
};
|
||||
|
||||
$handler = new \app\common\library\WebhookHandler($secret, 'master', $deployExecutor);
|
||||
$rawBody = file_get_contents('php://input') ?: '';
|
||||
$result = $handler->handle($_SERVER, $_GET, $rawBody);
|
||||
|
||||
// 记录日志
|
||||
$date = date('Y-m-d H:i:s');
|
||||
$ip = $_SERVER['REMOTE_ADDR'] ?? 'UNKNOWN';
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$status = $result['data']['status'] ?? 'unknown';
|
||||
$msg = $result['data']['message'] ?? '';
|
||||
$out = $result['data']['output'] ?? '';
|
||||
|
||||
$logEntry = "[{$date}] [{$ip}] {$method} => Status: {$status}, Msg: {$msg}";
|
||||
if (!empty($out)) {
|
||||
$logEntry .= "\nDeploy Output:\n" . $out;
|
||||
}
|
||||
$logEntry .= "\n" . str_repeat('-', 50) . "\n";
|
||||
|
||||
@file_put_contents($logFile, $logEntry, FILE_APPEND | LOCK_EX);
|
||||
|
||||
http_response_code($result['code']);
|
||||
echo json_encode($result['data'], JSON_UNESCAPED_UNICODE);
|
||||
175
tests/WebhookTest.php
普通文件
175
tests/WebhookTest.php
普通文件
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
/**
|
||||
* Webhook 单元测试 (TDD 模式)
|
||||
*/
|
||||
require_once __DIR__ . '/../application/common/library/WebhookHandler.php';
|
||||
|
||||
class WebhookTest
|
||||
{
|
||||
private $passed = 0;
|
||||
private $failed = 0;
|
||||
|
||||
public function run()
|
||||
{
|
||||
echo "=== 开始执行 Webhook 单元测试 (TDD) ===\n";
|
||||
$this->testInvalidTokenRejection();
|
||||
$this->testUrlTokenAllowed();
|
||||
$this->testHeaderTokenAllowed();
|
||||
$this->testHmacSha256SignatureAllowed();
|
||||
$this->testHubSignature256Allowed();
|
||||
$this->testNonPostMethodRejection();
|
||||
$this->testPingEventSuccess();
|
||||
$this->testIgnoredBranchSkipped();
|
||||
$this->testTargetBranchTriggersDeploy();
|
||||
|
||||
echo "\n测试结果: {$this->passed} 通过, {$this->failed} 失败\n";
|
||||
if ($this->failed > 0) {
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
private function assert($condition, $message)
|
||||
{
|
||||
if ($condition) {
|
||||
echo " [PASS] {$message}\n";
|
||||
$this->passed++;
|
||||
} else {
|
||||
echo " [FAIL] {$message}\n";
|
||||
$this->failed++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试非法 Token 被拦截
|
||||
*/
|
||||
public function testInvalidTokenRejection()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123');
|
||||
$server = ['REQUEST_METHOD' => 'POST', 'HTTP_X_GITEA_TOKEN' => 'wrong_token'];
|
||||
$res = $handler->handle($server, [], '{}');
|
||||
|
||||
$this->assert($res['code'] === 403, "非法 Token 应返回 HTTP 403");
|
||||
$this->assert($res['data']['status'] === 'error', "非法 Token 应为 error 状态");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试通过 URL 传 Token 允许通过
|
||||
*/
|
||||
public function testUrlTokenAllowed()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123');
|
||||
$server = ['REQUEST_METHOD' => 'POST'];
|
||||
$get = ['token' => 'test_secret_123'];
|
||||
$res = $handler->handle($server, $get, json_encode(['ref' => 'refs/heads/master']));
|
||||
|
||||
$this->assert($res['code'] === 200, "合法 URL Token 应返回 HTTP 200");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试通过 Header 传 Token 允许通过
|
||||
*/
|
||||
public function testHeaderTokenAllowed()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123');
|
||||
$server = ['REQUEST_METHOD' => 'POST', 'HTTP_X_GITEA_TOKEN' => 'test_secret_123'];
|
||||
$res = $handler->handle($server, [], json_encode(['ref' => 'refs/heads/master']));
|
||||
|
||||
$this->assert($res['code'] === 200, "合法 Header Token 应返回 HTTP 200");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试通过 Gitea HMAC-SHA256 签名校验
|
||||
*/
|
||||
public function testHmacSha256SignatureAllowed()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123');
|
||||
$body = json_encode(['ref' => 'refs/heads/master']);
|
||||
$sig = hash_hmac('sha256', $body, 'test_secret_123');
|
||||
$server = ['REQUEST_METHOD' => 'POST', 'HTTP_X_GITEA_SIGNATURE' => $sig];
|
||||
$res = $handler->handle($server, [], $body);
|
||||
|
||||
$this->assert($res['code'] === 200, "合法 HMAC-SHA256 签名应返回 HTTP 200");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试通过 GitHub/Gitea sha256= 前缀签名校验
|
||||
*/
|
||||
public function testHubSignature256Allowed()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123');
|
||||
$body = json_encode(['ref' => 'refs/heads/master']);
|
||||
$sig = 'sha256=' . hash_hmac('sha256', $body, 'test_secret_123');
|
||||
$server = ['REQUEST_METHOD' => 'POST', 'HTTP_X_HUB_SIGNATURE_256' => $sig];
|
||||
$res = $handler->handle($server, [], $body);
|
||||
|
||||
$this->assert($res['code'] === 200, "合法 X-Hub-Signature-256 签名应返回 HTTP 200");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试非 POST 请求被拒绝
|
||||
*/
|
||||
public function testNonPostMethodRejection()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123');
|
||||
$server = ['REQUEST_METHOD' => 'GET'];
|
||||
$get = ['token' => 'test_secret_123'];
|
||||
$res = $handler->handle($server, $get, '');
|
||||
|
||||
$this->assert($res['code'] === 405, "非 POST 请求应返回 HTTP 405");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Gitea Ping / Test 事件直接响应成功
|
||||
*/
|
||||
public function testPingEventSuccess()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123');
|
||||
$server = [
|
||||
'REQUEST_METHOD' => 'POST',
|
||||
'HTTP_X_GITEA_EVENT' => 'ping',
|
||||
'HTTP_X_GITEA_TOKEN' => 'test_secret_123'
|
||||
];
|
||||
$res = $handler->handle($server, [], '{}');
|
||||
|
||||
$this->assert($res['code'] === 200, "Ping 事件应返回 HTTP 200");
|
||||
$this->assert($res['data']['message'] === 'pong', "Ping 事件应返回 pong 响应");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试非目标分支自动跳过
|
||||
*/
|
||||
public function testIgnoredBranchSkipped()
|
||||
{
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123', 'master');
|
||||
$server = ['REQUEST_METHOD' => 'POST', 'HTTP_X_GITEA_TOKEN' => 'test_secret_123'];
|
||||
$body = json_encode(['ref' => 'refs/heads/feature_test']);
|
||||
$res = $handler->handle($server, [], $body);
|
||||
|
||||
$this->assert($res['code'] === 200, "非目标分支应返回 HTTP 200");
|
||||
$this->assert($res['data']['status'] === 'skipped', "非目标分支状态应为 skipped");
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试目标分支正确触发回调
|
||||
*/
|
||||
public function testTargetBranchTriggersDeploy()
|
||||
{
|
||||
$triggered = false;
|
||||
$handler = new \app\common\library\WebhookHandler('test_secret_123', 'master', function () use (&$triggered) {
|
||||
$triggered = true;
|
||||
return "Deploy OK";
|
||||
});
|
||||
|
||||
$server = ['REQUEST_METHOD' => 'POST', 'HTTP_X_GITEA_TOKEN' => 'test_secret_123'];
|
||||
$body = json_encode(['ref' => 'refs/heads/master']);
|
||||
$res = $handler->handle($server, [], $body);
|
||||
|
||||
$this->assert($res['code'] === 200, "目标分支应返回 HTTP 200");
|
||||
$this->assert($triggered === true, "目标分支必须触发回调");
|
||||
$this->assert($res['data']['status'] === 'success', "部署状态应为 success");
|
||||
$this->assert($res['data']['output'] === 'Deploy OK', "输出应一致");
|
||||
}
|
||||
}
|
||||
|
||||
$test = new WebhookTest();
|
||||
$test->run();
|
||||
在新工单中引用
屏蔽一个用户