From a4e78b864a2d6253860f63f7a33429564dd7e421 Mon Sep 17 00:00:00 2001 From: yinjianm Date: Tue, 28 Apr 2026 13:32:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(api):=20=E4=BF=AE=E5=A4=8D=E9=82=AE?= =?UTF-8?q?=E4=BB=B6=E9=98=9F=E5=88=97=E8=B6=85=E6=97=B6=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E9=BD=90=E8=B0=83=E5=BA=A6=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 延长 SendEmailJob 超时并改为超时直接失败,补充重试退避、 失败日志与收件人脱敏,避免 send_email 队列批量超时重试。 新增 MAIL_TIMEOUT 与 QUEUE_RETRY_AFTER 配置,并抽出邮件运行时 配置与 HTML 内容服务,确保 Horizon 常驻进程使用最新邮件配置。 为 Docker、supervisor 与 compose 样例补齐 scheduler 进程,并在 节点管理端开启墙检测托管时立即触发一次检测,保证定时任务持续生效。 --- .docker/entrypoint.sh | 6 +- .docker/supervisor/supervisord.conf | 19 +- .env.example | 2 + .helloagents/CHANGELOG.md | 18 +- .helloagents/INDEX.md | 6 +- .../.status.json | 1 + .../proposal.md | 160 ++++++++++++++++ .../tasks.md | 85 +++++++++ .../proposal.md | 174 ++++++++++++++++++ .../tasks.md | 77 ++++++++ .helloagents/archive/_index.md | 4 + .helloagents/context.md | 3 + .helloagents/modules/_index.md | 2 + .helloagents/modules/deploy.md | 28 +++ .helloagents/modules/node-gfw-check.md | 4 +- .helloagents/modules/queue-mail.md | 32 ++++ Dockerfile | 1 + Dockerfile.local | 1 + admin-frontend/src/views/nodes/NodesView.vue | 29 ++- app/Jobs/SendEmailJob.php | 58 +++++- app/Services/MailHtmlContent.php | 96 ++++++++++ app/Services/MailRuntimeConfig.php | 110 +++++++++++ app/Services/MailService.php | 94 +--------- compose.sample.yaml | 13 ++ compose.split.sample.yaml | 3 + config/mail.php | 2 + config/queue.php | 6 +- deploy/xboard-server/.env.example | 59 ++++++ deploy/xboard-server/.gitignore | 6 + deploy/xboard-server/README.md | 134 ++++++++++++++ deploy/xboard-server/scripts/deploy.sh | 18 ++ deploy/xboard-server/scripts/init.sh | 20 ++ deploy/xboard-server/scripts/status.sh | 25 +++ deploy/xboard-server/scripts/update.sh | 45 +++++ tests/Unit/Jobs/SendEmailJobTest.php | 69 +++++++ tests/Unit/MailServiceConfigTest.php | 56 ++++++ 36 files changed, 1359 insertions(+), 107 deletions(-) create mode 100644 .helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/.status.json create mode 100644 .helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/proposal.md create mode 100644 .helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/tasks.md create mode 100644 .helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/proposal.md create mode 100644 .helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/tasks.md create mode 100644 .helloagents/modules/deploy.md create mode 100644 .helloagents/modules/queue-mail.md create mode 100644 app/Services/MailHtmlContent.php create mode 100644 app/Services/MailRuntimeConfig.php create mode 100644 deploy/xboard-server/.env.example create mode 100644 deploy/xboard-server/.gitignore create mode 100644 deploy/xboard-server/README.md create mode 100644 deploy/xboard-server/scripts/deploy.sh create mode 100644 deploy/xboard-server/scripts/init.sh create mode 100644 deploy/xboard-server/scripts/status.sh create mode 100644 deploy/xboard-server/scripts/update.sh create mode 100644 tests/Unit/Jobs/SendEmailJobTest.php create mode 100644 tests/Unit/MailServiceConfigTest.php diff --git a/.docker/entrypoint.sh b/.docker/entrypoint.sh index d114edf..7f94b30 100644 --- a/.docker/entrypoint.sh +++ b/.docker/entrypoint.sh @@ -106,12 +106,14 @@ fi : "${HORIZON_WORKER_MEMORY_MB:=$auto_horizon_mem}" : "${HORIZON_WORKER_MAX_TIME:=0}" : "${HORIZON_WORKER_MAX_JOBS:=0}" +: "${ENABLE_WEB:=true}" +: "${ENABLE_SCHEDULE:=$ENABLE_WEB}" export OCTANE_WORKERS OCTANE_TASK_WORKERS OCTANE_MAX_REQUESTS \ OCTANE_GARBAGE_MB OCTANE_MAX_EXECUTION_TIME \ HORIZON_DATA_PIPELINE_MAX HORIZON_BUSINESS_MAX HORIZON_NOTIFICATION_MAX \ HORIZON_WORKER_MEMORY_MB HORIZON_WORKER_MAX_TIME HORIZON_WORKER_MAX_JOBS \ - RESOURCE_PROFILE + RESOURCE_PROFILE ENABLE_SCHEDULE echo "[entrypoint] Auto-tune (profile=${RESOURCE_PROFILE}): cpus=${CPUS} mem=${MEM_MIB}MiB slots=${SLOTS} -> octane=${OCTANE_WORKERS} horizon(dp/biz/notif)=${HORIZON_DATA_PIPELINE_MAX}/${HORIZON_BUSINESS_MAX}/${HORIZON_NOTIFICATION_MAX} horizon_worker_mem=${HORIZON_WORKER_MEMORY_MB}MB" echo "[entrypoint] Horizon supervisors use balance=auto with minProcesses=1, so they scale up to the cap on demand and back down when idle." @@ -143,7 +145,7 @@ else fi fi -echo "[entrypoint] Starting services (caddy=${ENABLE_CADDY} web=${ENABLE_WEB} horizon=${ENABLE_HORIZON} ws=${ENABLE_WS_SERVER})..." +echo "[entrypoint] Starting services (caddy=${ENABLE_CADDY} web=${ENABLE_WEB} horizon=${ENABLE_HORIZON} scheduler=${ENABLE_SCHEDULE} ws=${ENABLE_WS_SERVER})..." # Drop stale Octane/WorkerMan state files so the new master does not signal # PIDs left over from a previous container run (causes Swoole kill EPERM). rm -f /www/storage/logs/octane-server-state.json /www/storage/logs/xboard-ws-server.pid 2>/dev/null || true diff --git a/.docker/supervisor/supervisord.conf b/.docker/supervisor/supervisord.conf index 8aebbb5..79b7b1c 100644 --- a/.docker/supervisor/supervisord.conf +++ b/.docker/supervisor/supervisord.conf @@ -40,6 +40,23 @@ stopasgroup=true killasgroup=true priority=200 +[program:scheduler] +process_name=%(program_name)s_%(process_num)02d +command=php /www/artisan schedule:work +autostart=%(ENV_ENABLE_SCHEDULE)s +autorestart=true +user=www +redirect_stderr=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stdout_logfile_backups=0 +numprocs=1 +stopwaitsecs=5 +stopsignal=TERM +stopasgroup=true +killasgroup=true +priority=250 + [program:redis] process_name=%(program_name)s_%(process_num)02d command=redis-server --dir /data @@ -96,4 +113,4 @@ stopwaitsecs=5 stopsignal=TERM stopasgroup=true killasgroup=true -priority=500 \ No newline at end of file +priority=500 diff --git a/.env.example b/.env.example index 120d9b2..cf57a12 100755 --- a/.env.example +++ b/.env.example @@ -20,11 +20,13 @@ REDIS_PORT=6379 BROADCAST_DRIVER=log CACHE_DRIVER=redis QUEUE_CONNECTION=redis +QUEUE_RETRY_AFTER=90 MASS_EMAIL_HOURLY_LIMIT=500 MAIL_DRIVER=smtp MAIL_HOST=smtp.mailtrap.io MAIL_PORT=2525 +MAIL_TIMEOUT=30 MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_ENCRYPTION=null diff --git a/.helloagents/CHANGELOG.md b/.helloagents/CHANGELOG.md index 7dc09ae..5d5b74e 100644 --- a/.helloagents/CHANGELOG.md +++ b/.helloagents/CHANGELOG.md @@ -1,11 +1,25 @@ # CHANGELOG +## [0.6.6] - 2026-04-28 + +### 新增 +- **[deploy]**: 新增 `deploy/xboard-server` 可复用服务器部署模板,基于生产 compose 拓扑补齐 `scheduler` 服务,并提供 `.env.example`、初始化/部署/更新/状态检查脚本和部署说明 — by yinjianm + - 方案: [202604281303_xboard-reusable-server-deploy](archive/2026-04/202604281303_xboard-reusable-server-deploy/) + - 决策: xboard-reusable-server-deploy#D001(使用独立 scheduler 服务驱动 Laravel Scheduler), xboard-reusable-server-deploy#D002(默认不把 MySQL 纳入一键模板) + +## [0.6.5] - 2026-04-28 + +### 修复 +- **[queue-mail]**: 修复 `SendEmailJob` 10 秒超时导致 `send_email` 队列邮件作业批量失败的问题;邮件 job 现在使用 60 秒超时、明确 backoff、timeout 失败直接 fail,并把邮件发送错误交给队列异常机制处理。同时新增 `MAIL_TIMEOUT` / `QUEUE_RETRY_AFTER` 配置、刷新 Horizon 长驻 worker 的运行时 mailer 配置,并对 `MailLog.config` 中的敏感字段脱敏 — by yinjianm + - 方案: [202604281258_fix-send-email-job-timeout](archive/2026-04/202604281258_fix-send-email-job-timeout/) + - 决策: fix-send-email-job-timeout#D001(保留队列结构并修复 job 与 mail transport 超时) + ## [0.6.4] - 2026-04-28 ### 修复 -- **[node-gfw-check]**: 修复墙检测任务卡在 `pending/checking` 后会长期占用 active 状态的问题;超过 5 分钟未被节点端领取或未上报的任务会标记为检测失败,管理端区分展示“等待节点领取”和“检测中”。同时修正 mi-node 的 ping 成功判定,避免正常可达但平均延迟解析不到时被误判为超时 — by yinjianm +- **[node-gfw-check]**: 修复墙检测任务卡在 `pending/checking` 后会长期占用 active 状态的问题;超过 5 分钟未被节点端领取或未上报的任务会标记为检测失败,管理端区分展示“等待节点领取”和“检测中”,并在开启父节点墙检测托管时立即发起一次检测。同时补齐 Docker/supervisor 的 `schedule:work` 进程和 compose scheduler 样例,确保自动墙检测调度会持续运行;修正 mi-node 的 ping 成功判定,避免正常可达但平均延迟解析不到时被误判为超时 — by yinjianm - 类型: 快速修改(无方案包) - - 文件: app/Services/ServerGfwCheckService.php, app/Console/Commands/SyncServerGfwChecks.php, admin-frontend/src/utils/nodes.ts, admin-frontend/src/views/nodes/NodesView.vue, E:/code/go/mi-node/internal/gfwcheck/gfwcheck.go + - 文件: app/Services/ServerGfwCheckService.php, app/Console/Commands/SyncServerGfwChecks.php, admin-frontend/src/utils/nodes.ts, admin-frontend/src/views/nodes/NodesView.vue, .docker/supervisor/supervisord.conf, .docker/entrypoint.sh, Dockerfile, compose.sample.yaml, E:/code/go/mi-node/internal/gfwcheck/gfwcheck.go ## [0.6.3] - 2026-04-28 diff --git a/.helloagents/INDEX.md b/.helloagents/INDEX.md index a7578c4..c0edffa 100644 --- a/.helloagents/INDEX.md +++ b/.helloagents/INDEX.md @@ -10,14 +10,16 @@ active_package: 无 ## 项目概览 - 类型: PHP Laravel 主仓 + `admin-frontend` Vue3 管理端前端 -- 当前重点模块: `admin-frontend`、`node-gfw-check`、`order-payment`、`subscription-protocols` -- 最新归档: `202604280024_node-gfw-auto-check-and-online` +- 当前重点模块: `admin-frontend`、`deploy`、`node-gfw-check`、`order-payment`、`queue-mail`、`subscription-protocols` +- 最新归档: `202604281303_xboard-reusable-server-deploy` ## 活跃模块 - [admin-frontend](modules/admin-frontend.md): 管理端登录、主布局、仪表盘、用户/节点/订阅/系统管理与管理 API 前端封装 +- [deploy](modules/deploy.md): 可复制到服务器的 Xboard Compose 部署模板、环境变量模板和运维脚本 - [node-gfw-check](modules/node-gfw-check.md): 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 - [order-payment](modules/order-payment.md): 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 +- [queue-mail](modules/queue-mail.md): 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 - [subscription-protocols](modules/subscription-protocols.md): 客户端订阅导出入口、协议适配器与版本兼容过滤 ## 归档与变更 diff --git a/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/.status.json b/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/.status.json new file mode 100644 index 0000000..de8be40 --- /dev/null +++ b/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/.status.json @@ -0,0 +1 @@ +{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"代码修复、配置调整、测试补充、知识库同步和可用验证已完成;PHP 运行环境缺失导致语法检查与 PHPUnit 待补跑","updated_at":"2026-04-28 13:11:00"} diff --git a/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/proposal.md b/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/proposal.md new file mode 100644 index 0000000..07cc3ce --- /dev/null +++ b/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/proposal.md @@ -0,0 +1,160 @@ +# 变更提案: fix-send-email-job-timeout + +## 元信息 +```yaml +类型: 修复 +方案类型: implementation +优先级: P1 +状态: 已确认 +创建: 2026-04-28 +``` + +--- + +## 1. 需求 + +### 背景 +Horizon 失败作业中共有 59 条 `send_email` 队列失败,作业均为 `App\Jobs\SendEmailJob`,错误摘要均为 `Illuminate\Queue\TimeoutExceededException: App\Jobs\SendEmailJob has timed out.`。代码排查发现 `SendEmailJob` 当前 `$timeout = 10`,明显短于生产环境 SMTP 连接、TLS 握手和邮件服务商慢响应的常见耗时,也短于 `config/horizon.php` 中 notification supervisor 的 60 秒 timeout。 + +### 目标 +- 降低正常邮件发送因 10 秒 job 超时导致失败的概率。 +- 让邮件发送失败走 Laravel 队列异常/重试机制,避免手动 `release()` 隐藏真实失败原因。 +- 给 SMTP 传输设置明确超时,避免单次发送无限阻塞直到 worker 超时。 +- 保留邮件失败可观测性,同时避免将邮件密码等敏感配置写入日志或数据库。 + +### 约束条件 +```yaml +时间约束: 当前回合内完成代码修复和可执行验证。 +性能约束: 不引入同步批量发信,不扩大单个 worker 的无限等待时间。 +兼容性约束: 保持现有 Laravel 12 + Horizon 队列结构,继续兼容当前 legacy mail config。 +业务约束: 不直接清空、重试或修改生产 Horizon 失败作业;生产重试需要另行确认。 +``` + +### 验收标准 +- [ ] `SendEmailJob` 的 job timeout 不再是 10 秒,并且小于 Redis `retry_after`。 +- [ ] 邮件发送返回错误时抛出异常,由队列统一处理 retries/backoff/fail,而不是直接 `release(60)`。 +- [ ] SMTP 配置包含可调 `MAIL_TIMEOUT`,运行时邮件配置能在 Horizon 长驻 worker 中生效。 +- [ ] `MailLog` 中保存的邮件配置不包含明文 `password`。 +- [ ] 新增或更新测试覆盖 job 超时配置、失败抛出、邮件配置脱敏。 + +--- + +## 2. 方案 + +### 技术方案 +采用唯一修复路径:保留现有 `SendEmailJob` 和 `MailService` 架构,不拆分新队列、不引入新依赖。将 `SendEmailJob` 调整为更适合 SMTP 的超时和 backoff 策略;邮件发送失败时抛出异常交给 Laravel Queue/Horizon;在 `MailService` 中统一应用运行时邮件配置、设置 SMTP timeout、刷新 Laravel MailManager 缓存,并对写入 `MailLog` 的配置做敏感字段脱敏。 + +### 影响范围 +```yaml +涉及模块: + - queue-mail: SendEmailJob 的超时、重试、失败处理行为。 + - mail-service: 运行时 SMTP 配置、发送日志、敏感信息脱敏。 + - config: MAIL_TIMEOUT 与队列 retry_after 可配置化。 +预计变更文件: 5-7 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 超时时间过长导致 worker 被慢 SMTP 占用 | 中 | SMTP transport timeout 默认 30 秒,job timeout 60 秒,仍小于 retry_after。 | +| 失败后抛异常可能让失败摘要变化 | 低 | 这是期望行为,能暴露真实邮件错误;超时仍由 Horizon 记录。 | +| 生产已有失败作业重试后可能重复发信 | 中 | 本方案不自动重试历史失败作业,生产重试需按作业内容另行确认。 | +| 运行时刷新 MailManager 影响同 worker 后续邮件 | 低 | 仅在每次发信前应用当前配置,符合动态后台邮件配置的预期。 | + +### 方案取舍 +```yaml +唯一方案理由: 故障根因集中在当前 job 过短 timeout 和邮件发送失败处理方式,局部修复能直接降低失败率且不改变业务入口。 +放弃的替代路径: + - 单纯把 Horizon notification timeout 调大: 无法覆盖 job 自身 timeout=10,也不能解决 SMTP 无限阻塞和错误摘要不清晰。 + - 新增邮件服务商 API SDK: 需要新的凭据、配置和迁移成本,超出当前故障修复范围。 + - 自动重试全部失败作业: 可能造成重复邮件,属于生产副作用,不在本次代码修复中执行。 +回滚边界: 可独立回退 SendEmailJob、MailService、config 与测试变更;不会修改数据库结构。 +``` + +--- + +## 3. 技术设计 + +### 核心流程 +```mermaid +flowchart TD + A[SendEmailJob handle] --> B[MailService::sendEmail] + B --> C[应用运行时 mail 配置和 timeout] + C --> D[刷新 MailManager 缓存] + D --> E[发送邮件并写入 MailLog] + E -->|success| F[Job completed] + E -->|error| G[SendEmailJob 抛 RuntimeException] + G --> H[Queue retries/backoff/fail] +``` + +### 配置边界 +- `MAIL_TIMEOUT` 控制 SMTP transport timeout,默认 30 秒。 +- `SendEmailJob::$timeout` 控制单个 job 最大执行时长,计划设置为 60 秒。 +- `queue.redis.retry_after` 保持大于 job timeout;改为可通过 `QUEUE_RETRY_AFTER` 调整,默认 90 秒。 + +--- + +## 4. 核心场景 + +### 场景: 单封邮件发送成功 +**模块**: queue-mail +**条件**: SMTP 服务在 `MAIL_TIMEOUT` 内响应成功。 +**行为**: `SendEmailJob` 调用 `MailService::sendEmail()`。 +**结果**: job 正常完成,`MailLog.error = null`。 + +### 场景: SMTP 返回错误 +**模块**: queue-mail +**条件**: SMTP 认证失败、连接失败或服务商返回错误。 +**行为**: `MailService` 记录脱敏配置和错误摘要,`SendEmailJob` 抛出异常。 +**结果**: Horizon 按 job tries/backoff 重试,失败摘要展示真实错误。 + +### 场景: SMTP 长时间无响应 +**模块**: queue-mail +**条件**: SMTP 连接或读写超过 `MAIL_TIMEOUT`。 +**行为**: 邮件传输超时后返回错误,若仍阻塞则 job timeout 兜底。 +**结果**: 单个 job 不会被无限占用,且 `retry_after` 不早于 timeout 触发。 + +--- + +## 5. 技术决策 + +### fix-send-email-job-timeout#D001: 保留队列结构并修复 job 与 mail transport 超时 +**日期**: 2026-04-28 +**状态**: ✅采纳 +**背景**: 失败作业都集中在 `SendEmailJob has timed out`,当前 job timeout 只有 10 秒。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 局部修复 job timeout、mail timeout 和错误处理 | 改动小,直接命中根因,易验证和回滚 | 仍依赖 SMTP 服务商自身稳定性 | +| B: 只增大 Horizon timeout | 改动更少 | job 自身 timeout=10 仍会失败,且缺少传输层超时 | +| C: 改造为第三方邮件 API | 长期可观测性更好 | 需要新配置和迁移,超出当前故障修复 | +**决策**: 选择方案 A。 +**理由**: 当前故障由代码事实直接定位到 job timeout 过短和失败处理不清晰,局部修复收益最高、风险最低。 +**影响**: `SendEmailJob`、`MailService`、`config/mail.php`、`config/queue.php`、`.env.example`、相关单元测试。 + +--- + +## 6. 验证策略 + +```yaml +verifyMode: test-first +reviewerFocus: + - app/Jobs/SendEmailJob.php 的 timeout/tries/backoff/failOnTimeout 与 retry_after 关系。 + - app/Services/MailService.php 是否刷新 mailer 缓存并避免明文 password 写入 MailLog。 + - config/mail.php 与 config/queue.php 是否保持默认值可部署。 +testerFocus: + - vendor/bin/phpunit --filter SendEmailJobTest + - vendor/bin/phpunit --filter MailServiceTest + - php -l app/Jobs/SendEmailJob.php + - php -l app/Services/MailService.php +uiValidation: none +riskBoundary: + - 不执行 horizon:forget、queue:retry、horizon:terminate 等会影响生产队列的命令。 + - 不读取或修改 .env 中的真实 SMTP 密码。 +``` + +--- + +## 7. 成果设计 + +N/A。此任务不涉及视觉产出。 diff --git a/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/tasks.md b/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/tasks.md new file mode 100644 index 0000000..d21d1ad --- /dev/null +++ b/.helloagents/archive/2026-04/202604281258_fix-send-email-job-timeout/tasks.md @@ -0,0 +1,85 @@ +# 任务清单: fix-send-email-job-timeout + +> **@status:** completed | 2026-04-28 13:10 + +```yaml +@feature: fix-send-email-job-timeout +@created: 2026-04-28 +@status: completed +@mode: R2 +@type: implementation +@complexity: moderate +``` + +## LIVE_STATUS + +```json +{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"代码修复、配置调整、测试补充、知识库同步和可用验证已完成;PHP 运行环境缺失导致语法检查与 PHPUnit 待补跑","updated_at":"2026-04-28 13:11:00"} +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 5 | 0 | 0 | 5 | + +--- + +## 任务列表 + +### 1. 邮件队列执行策略 + +- [√] 1.1 修改 `app/Jobs/SendEmailJob.php` + - 预期变更: 将 job timeout 从 10 秒提升到适合 SMTP 的值,增加 timeout 失败处理、backoff,并在邮件发送返回错误时抛出异常。 + - 完成标准: job timeout 大于 `MAIL_TIMEOUT` 默认值且小于 Redis `retry_after` 默认值;失败摘要不再依赖手动 `release(60)`。 + - 验证方式: `php -l app/Jobs/SendEmailJob.php`;相关单元测试断言 timeout/backoff/失败抛出。 + - depends_on: [] + +### 2. 邮件服务运行时配置 + +- [√] 2.1 修改 `app/Services/MailService.php` + - 预期变更: 统一应用运行时 SMTP 配置和 timeout,刷新 MailManager 缓存,并对写入 `MailLog` 的 config 脱敏。 + - 完成标准: `MAIL_TIMEOUT` 能写入 legacy 和 smtp mailer 配置;`password` 不以明文进入 `MailLog.config`。 + - 验证方式: `php -l app/Services/MailService.php`;相关单元测试断言配置和脱敏行为。 + - depends_on: [] + +### 3. 队列与环境配置 + +- [√] 3.1 修改 `config/mail.php`、`config/queue.php` 和 `.env.example` + - 预期变更: 增加 `MAIL_TIMEOUT` 默认配置;Redis/database/beanstalkd `retry_after` 支持环境变量,默认值保持大于邮件 job timeout。 + - 完成标准: 默认 `MAIL_TIMEOUT=30`、`QUEUE_RETRY_AFTER=90`,不会低于 `SendEmailJob::$timeout=60`。 + - 验证方式: 文件检查;`php -l config/mail.php`;`php -l config/queue.php`。 + - depends_on: [1.1] + +### 4. 测试覆盖 + +- [√] 4.1 新增邮件队列相关单元测试 + - 预期变更: 覆盖 `SendEmailJob` 的 timeout/backoff/失败抛出,以及 `MailService` 邮件配置脱敏。 + - 完成标准: 测试不依赖真实 SMTP,不读取真实 `.env` 密码。 + - 验证方式: `vendor/bin/phpunit --filter SendEmailJobTest`;`vendor/bin/phpunit --filter MailServiceTest`。 + - depends_on: [1.1, 2.1, 3.1] + +### 5. 验收与知识库同步 + +- [√] 5.1 执行验证并同步知识库 + - 预期变更: 运行可用的语法检查/单测,更新 `.helloagents` 知识库和方案包状态。 + - 完成标准: 验证结果记录在执行日志;相关知识库模块或 CHANGELOG 反映本次修复。 + - 验证方式: 查看命令输出、`tasks.md` 状态和 `.helloagents/CHANGELOG.md`。 + - depends_on: [4.1] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-04-28 13:11 | 5.1 | completed | 已完成 diff 检查、方案包校验、知识库同步;本地缺少 php/composer/docker,PHP 语法检查和 PHPUnit 待在目标环境补跑 | +| 2026-04-28 13:08 | 1.1-4.1 | completed | 已完成邮件 job、运行时邮件配置、环境配置和单测补充;本地缺少 php/composer,自动化执行待补跑 | +| 2026-04-28 12:58 | DESIGN | completed | 已完成上下文收集和唯一方案规划 | + +--- + +## 执行备注 + +- 生产历史失败作业不在本方案内自动重试,避免重复邮件。 +- 当前环境未安装 `php`、`composer` 或 `docker`,无法执行 `php -l` 与 PHPUnit;已完成静态 diff 检查、方案包校验和人工代码审查,仍需在具备 PHP/Composer 的环境补跑命令。 diff --git a/.helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/proposal.md b/.helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/proposal.md new file mode 100644 index 0000000..4843054 --- /dev/null +++ b/.helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/proposal.md @@ -0,0 +1,174 @@ +# 变更提案: xboard-reusable-server-deploy + +## 元信息 +```yaml +类型: 新功能 +方案类型: implementation +优先级: P1 +状态: 已确认 +创建: 2026-04-28 +``` + +--- + +## 1. 需求 + +### 背景 +当前服务器部署使用自定义 compose,包含 `web / horizon / admin / ws-server / redis`,但缺少独立 `scheduler` 服务,导致 Laravel Scheduler 可能没有持续运行,进而影响 `sync:server-gfw-checks` 自动墙检测等定时任务。用户希望在本地仓库内新增一套可复制到其他服务器的一键部署目录,包含 compose、环境变量模板、目录初始化和常用部署命令。 + +### 目标 +- 新增 `deploy/xboard-server/` 自包含部署模板。 +- 模板保留用户当前部署拓扑,并补齐 `scheduler` 服务运行 `php artisan schedule:work`。 +- 提供 `.env.example`,覆盖 Laravel、镜像、端口、数据库、Redis、邮件和管理前端上游等必要变量。 +- 提供脚本创建挂载目录、初始化 `.env`、拉取镜像、启动服务、执行迁移和查看状态。 +- 提供 README,说明首次部署、更新、迁移、日志、调度检查和墙检测排查命令。 + +### 约束条件 +```yaml +时间约束: 本轮完成模板目录、脚本、说明和静态验证 +性能约束: 不新增运行时服务以外的额外常驻依赖;scheduler 单进程即可 +兼容性约束: 兼容当前 ghcr.io/micah123321/xboard:new 和 ghcr.io/micah123321/xboard-admin-frontend:new 镜像 +业务约束: 不写入真实 APP_KEY、数据库密码、邮箱密码或域名;不自动执行生产数据库破坏性操作 +``` + +### 验收标准 +- [ ] `deploy/xboard-server/compose.yaml` 存在,并包含 `web / horizon / scheduler / admin / ws-server / redis` 六个服务。 +- [ ] `.env.example` 与 compose 变量匹配,复制为 `.env` 后可作为部署起点。 +- [ ] 初始化脚本可创建 `.docker/.data`、`storage/logs`、`storage/theme`、`plugins` 等挂载目录。 +- [ ] README 覆盖首次部署、更新、迁移、日志、scheduler 检查和墙检测手动触发命令。 +- [ ] shell 脚本通过 `sh -n` 语法检查,YAML 至少通过文本结构检查。 + +--- + +## 2. 方案 + +### 技术方案 +在仓库新增 `deploy/xboard-server/`,将服务器部署所需内容收敛到独立目录: + +- `compose.yaml`: 以用户当前 compose 为基础,新增 `scheduler` 服务;所有服务复用同一 `.env`、日志目录、插件目录和 Redis socket 卷。 +- `.env.example`: 同时作为 Docker Compose 变量文件和 Laravel 环境变量模板,保留镜像 tag、端口、数据库、Redis、邮件等占位项。 +- `scripts/init.sh`: 创建挂载目录,并在 `.env` 不存在时从 `.env.example` 复制。 +- `scripts/deploy.sh`: 执行初始化、拉取镜像并启动服务;默认不自动迁移,避免生产 DB 风险。 +- `scripts/update.sh`: 拉取镜像、重启服务,并提供可选 `--migrate` 执行迁移。 +- `scripts/status.sh`: 输出 compose 服务状态、scheduler 日志尾部、Laravel schedule 状态和墙检测手动命令提示。 +- `README.md`: 作为部署操作手册。 + +### 影响范围 +```yaml +涉及模块: + - deploy: 新增可复制部署模板目录 + - node-gfw-check: 明确 scheduler 对自动墙检测的部署依赖 +预计变更文件: 7-9 +``` + +### 风险评估 +| 风险 | 等级 | 应对 | +|------|------|------| +| 用户误以为脚本会自动完成交互式安装 | 中 | README 明确 `xboard:install` 和 `migrate` 命令需按部署阶段执行 | +| `.env` 同时被 Compose 和 Laravel 使用导致变量混杂 | 低 | Laravel 会忽略未知变量;README 标注变量用途 | +| scheduler 多实例重复运行 | 中 | 模板只定义一个 scheduler 服务;split/supervisor 规则中仅 web 角色默认启用 scheduler | +| 生产数据库迁移风险 | 中 | `deploy.sh` 默认不迁移,`update.sh --migrate` 才执行 | + +### 方案取舍 +```yaml +唯一方案理由: 自包含目录最贴近用户“复制到其他服务器一键部署”的使用方式;不侵入现有根目录 compose,也不会要求用户记住多处文档。 +放弃的替代路径: + - 只修改根目录 compose.sample.yaml: 对新服务器仍缺少初始化目录、env 和排查命令,不够一键化。 + - 生成生产 .env: 会引入真实密钥和凭据风险,不适合提交仓库。 + - 把 MySQL 纳入 compose: 用户当前部署未包含 MySQL,强行加入会改变部署拓扑。 +回滚边界: 删除 `deploy/xboard-server/` 目录即可回滚本模板;不会影响运行时代码。 +``` + +--- + +## 3. 技术设计 + +### 架构设计 +```mermaid +flowchart TD + A[admin :80] -->|/api| B[web :7001] + C[ws-server :8076] --> D[redis socket volume] + B --> D + E[horizon] --> D + F[scheduler schedule:work] --> D + F --> G[sync:server-gfw-checks] + G --> C +``` + +### 数据模型 +无数据库结构变更。 + +--- + +## 4. 核心场景 + +### 场景: 首次复制部署 +**模块**: deploy +**条件**: 新服务器已安装 Docker Compose +**行为**: 复制 `deploy/xboard-server/`,执行 `scripts/init.sh`,填写 `.env`,启动 compose +**结果**: 必需目录存在,服务按 compose 启动 + +### 场景: 自动墙检测持续运行 +**模块**: node-gfw-check +**条件**: scheduler 服务在线,节点开启墙检测托管 +**行为**: `php artisan schedule:work` 触发 `sync:server-gfw-checks` +**结果**: 自动为开启托管的父节点创建检测任务 + +--- + +## 5. 技术决策 + +### xboard-reusable-server-deploy#D001: 使用独立 scheduler 服务驱动 Laravel Scheduler +**日期**: 2026-04-28 +**状态**: ✅采纳 +**背景**: 用户当前 compose 中 `web` 只运行 Octane,`horizon` 只运行队列,缺少持续执行 `schedule:work` 的进程。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 独立 `scheduler` 服务 | 与 Laravel 推荐模式一致,状态可独立查看,适合 compose 部署 | 多一个容器 | +| B: 依赖 Octane tick 调 `schedule:run` | 服务数量少 | 当前 Provider 在 console 入口 return,实际可靠性不足 | +| C: 让 horizon 顺带跑 scheduler | 容器少 | 职责混杂,日志和重启策略不清晰 | +**决策**: 选择方案 A +**理由**: 定时任务是自动墙检测和自动上线的运行前提,必须有显式、可排查、可重启的进程。 +**影响**: `deploy/xboard-server/compose.yaml`、部署文档。 + +### xboard-reusable-server-deploy#D002: 默认不把 MySQL 纳入一键模板 +**日期**: 2026-04-28 +**状态**: ✅采纳 +**背景**: 用户当前生产 compose 不包含 MySQL,数据库可能由宿主机、面板或云数据库提供。 +**选项分析**: +| 选项 | 优点 | 缺点 | +|------|------|------| +| A: 保持外部 MySQL | 与当前生产部署一致,迁移成本低 | 需要用户填写 DB_HOST | +| B: 模板内新增 MySQL | 更完整 | 会改变现有架构,数据持久化与迁移风险更高 | +**决策**: 选择方案 A +**理由**: 部署模板应优先复刻已验证拓扑,避免把数据库迁移纳入本轮。 +**影响**: `.env.example`、README。 + +--- + +## 6. 验证策略 + +```yaml +verifyMode: review-first +reviewerFocus: + - compose 服务拓扑是否包含 scheduler + - env 变量是否与 compose 使用一致 + - 脚本是否避免自动执行生产数据库风险操作 +testerFocus: + - sh -n scripts/*.sh + - docker compose config + - docker compose ps scheduler + - docker compose exec web php artisan schedule:list +uiValidation: none +riskBoundary: + - 不写入真实密钥 + - 不自动连接或修改生产数据库 + - 不覆盖用户现有服务器 compose +``` + +--- + +## 7. 成果设计 + +N/A,本任务不产生视觉 UI。 diff --git a/.helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/tasks.md b/.helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/tasks.md new file mode 100644 index 0000000..4544a95 --- /dev/null +++ b/.helloagents/archive/2026-04/202604281303_xboard-reusable-server-deploy/tasks.md @@ -0,0 +1,77 @@ +# 任务清单: xboard-reusable-server-deploy + +> **@status:** completed | 2026-04-28 13:15 + +```yaml +@feature: xboard-reusable-server-deploy +@created: 2026-04-28 +@status: completed +@mode: R2 +``` + +## LIVE_STATUS +```json +{"status":"completed","completed":5,"failed":0,"pending":0,"total":5,"percent":100,"current":"部署模板、脚本、说明和知识库同步完成","updated_at":"2026-04-28 13:15:33"} +``` + +## 进度概览 + +| 完成 | 失败 | 跳过 | 总数 | +|------|------|------|------| +| 5 | 0 | 0 | 5 | + +--- + +## 任务列表 + +### 1. 部署模板 + +- [√] 1.1 新增 `deploy/xboard-server/compose.yaml` + - 预期变更: 基于用户当前服务器 compose 结构新增可复用模板,包含 `web / horizon / scheduler / admin / ws-server / redis` 服务。 + - 完成标准: `scheduler` 执行 `php artisan schedule:work`;服务镜像、端口、volume、depends_on 可通过 `.env` 配置。 + - 验证方式: 代码审查 YAML 结构;可用时执行 `docker compose config`。 + - depends_on: [] + +- [√] 1.2 新增 `deploy/xboard-server/.env.example` 与 `.gitignore` + - 预期变更: 提供部署变量模板,并避免提交真实 `.env` 与运行时数据目录。 + - 完成标准: `.env.example` 覆盖镜像、端口、Laravel、数据库、Redis、邮件、上传代理等关键变量。 + - 验证方式: 检查 compose 中引用的变量均有默认或示例值。 + - depends_on: [1.1] + +- [√] 1.3 新增 `deploy/xboard-server/scripts/*.sh` + - 预期变更: 提供初始化、部署、更新、状态检查脚本。 + - 完成标准: 脚本创建所需目录;默认不自动迁移生产数据库;`update.sh --migrate` 可显式执行迁移。 + - 验证方式: `sh -n deploy/xboard-server/scripts/*.sh`。 + - depends_on: [1.2] + +### 2. 文档与知识库 + +- [√] 2.1 新增 `deploy/xboard-server/README.md` + - 预期变更: 说明首次部署、更新、迁移、日志、scheduler 检查、墙检测手动触发和常见问题。 + - 完成标准: 用户可按文档从空服务器完成目录初始化与服务启动。 + - 验证方式: 人工审查命令顺序与当前 compose 拓扑一致。 + - depends_on: [1.1, 1.2, 1.3] + +- [√] 2.2 同步 `.helloagents` 知识库与变更记录 + - 预期变更: 更新部署模块说明或 CHANGELOG,记录 scheduler 对墙检测自动化的依赖。 + - 完成标准: 知识库反映 `deploy/xboard-server` 的用途和文件范围。 + - 验证方式: 检查 `.helloagents/modules` 与 `CHANGELOG.md` 相关条目。 + - depends_on: [2.1] + +--- + +## 执行日志 + +| 时间 | 任务 | 状态 | 备注 | +|------|------|------|------| +| 2026-04-28 13:15 | 验证 | completed | `sh -n` 通过;Compose 结构文本检查通过;`git diff --check` 通过,本机无 docker/php/composer | +| 2026-04-28 13:14 | 知识库同步 | completed | 新增 deploy 模块,更新 context、node-gfw-check 与 CHANGELOG | +| 2026-04-28 13:13 | 部署模板 | completed | 新增 compose、env 模板、脚本与 README | +| 2026-04-28 13:03 | 方案设计 | completed | 确定新增 `deploy/xboard-server` 自包含部署模板 | + +--- + +## 执行备注 + +- 用户当前生产 compose 没有 scheduler 服务,是自动墙检测不持续执行的主要部署风险。 +- 模板不包含 MySQL 服务,沿用用户现有外部数据库模式。 diff --git a/.helloagents/archive/_index.md b/.helloagents/archive/_index.md index a079537..ba34df3 100644 --- a/.helloagents/archive/_index.md +++ b/.helloagents/archive/_index.md @@ -7,6 +7,8 @@ | 时间戳 | 名称 | 类型 | 涉及模块 | 决策 | 结果 | |--------|------|------|---------|------|------| +| 202604281303 | xboard-reusable-server-deploy | implementation | deploy,node-gfw-check | xboard-reusable-server-deploy#D001,#D002 | ✅完成 | +| 202604281258 | fix-send-email-job-timeout | implementation | queue-mail | fix-send-email-job-timeout#D001 | ✅完成 | | 202604280024 | node-gfw-auto-check-and-online | implementation | node-gfw-check,admin-frontend | node-gfw-auto-check-and-online#D001,#D002 | ✅完成 | | 202604272338 | admin-frontend-node-auto-online | - | - | - | ✅完成 | | 202604272325 | node-gfw-check | implementation | node-gfw-check,admin-frontend,mi-node | node-gfw-check#D001,#D002 | ✅完成 | @@ -40,6 +42,8 @@ ## 按月归档 ### 2026-04 +- [202604281303_xboard-reusable-server-deploy](./2026-04/202604281303_xboard-reusable-server-deploy/) - 新增可复制到服务器的 Xboard Compose 部署模板,补齐独立 `scheduler` 服务,并提供 `.env.example`、初始化/部署/更新/状态检查脚本和部署说明 +- [202604281258_fix-send-email-job-timeout](./2026-04/202604281258_fix-send-email-job-timeout/) - 修复 `SendEmailJob` 10 秒超时导致 `send_email` 队列批量失败的问题,补齐邮件 job 超时/backoff、SMTP transport timeout、运行时 mailer 刷新和 MailLog 配置脱敏 - [202604280024_node-gfw-auto-check-and-online](./2026-04/202604280024_node-gfw-auto-check-and-online/) - 为节点墙状态检测打通自动检测与自动显隐,支持开启托管的父节点定时检测、疑似被墙自动隐藏、恢复正常自动显示,并让自动上线尊重 blocked 状态 - [202604272325_node-gfw-check](./2026-04/202604272325_node-gfw-check/) - 新增节点墙状态检测闭环,支持父节点检测、子节点继承、管理端展示筛选,以及 mi-node WS/REST 检测上报 - [202604272310_ticket-chat-image-dnd-paste-upload](./2026-04/202604272310_ticket-chat-image-dnd-paste-upload/) - 为工单工作台回复区补齐图片拖拽上传与剪贴板粘贴上传,并将上传逻辑与样式从超大 SFC 中拆分 diff --git a/.helloagents/context.md b/.helloagents/context.md index 982d208..45c421c 100644 --- a/.helloagents/context.md +++ b/.helloagents/context.md @@ -17,6 +17,7 @@ - 管理端 API 通过 `window.settings.secure_path` 或 `VITE_ADMIN_PATH` 解析 `/api/v2/{secure_path}` 前缀 - 登录接口复用 `/api/v2/passport/auth/login` - 工单回复链路当前以 `TicketService::reply()` 为统一真相源:管理员或用户再次回复已关闭工单时都会自动把工单状态改回开启,同时继续维护 `reply_status` 与 `last_reply_user_id` +- 邮件发送链路当前以 `SendEmailJob` + `MailService` 为统一入口:`send_email` 队列的单个 job 超时为 60 秒,SMTP 传输超时默认由 `MAIL_TIMEOUT=30` 控制,Redis `retry_after` 默认由 `QUEUE_RETRY_AFTER=90` 控制。 - 管理端仪表盘现已接入: - `stat/getStats` - `stat/getOrder` @@ -104,6 +105,7 @@ - 主仓仍以 Laravel 为后端真相源 - `admin-frontend` 负责独立管理后台 UI 与交互逻辑 - `admin-frontend` 现在同时支持两种交付路径:仓内构建产物写回 `public/assets/admin`,或独立构建为 GHCR 静态镜像供 compose 分支部署 +- `deploy/xboard-server/` 是可复制到服务器的一键部署模板,包含 `web / horizon / scheduler / admin / ws-server / redis` Compose 拓扑、`.env.example`、初始化/部署/更新/状态检查脚本和部署说明 - 订阅协议导出由 Laravel 主仓内的 `app/Protocols/*` 提供,客户端兼容问题需以对应导出器实现为准 - `public/assets/admin` 为构建产物输出位置 @@ -114,6 +116,7 @@ - `#/nodes` 当前已升级为真实节点工作台:支持搜索、在线 / 离线筛选、父/子节点筛选、墙状态筛选、分页浏览、显隐切换、自动上线托管开关、墙检测托管开关、刷新数据、复制、单节点置顶、仅对已勾选节点生效的批量修改 / 批量删除,以及 11 种协议的新增 / 编辑弹窗和排序对话框 - 节点自动上线由后端 `sync:server-auto-online` 定时命令执行,只处理 `auto_online=1` 的节点:在线 / 待同步时自动 `show=1`,离线时自动 `show=0`;未开启自动上线的节点继续保持手动显隐控制;墙状态为 `blocked` 或仍处于 `gfw_auto_hidden` 且未恢复正常时会否决自动显示 - 节点自动墙检测由后端 `sync:server-gfw-checks` 定时命令执行,只为开启 `gfw_check_enabled` 的父节点创建检测任务;子节点不独立检测,但可控制是否随父节点自动隐藏 / 恢复 +- Compose 部署必须确保 Laravel Scheduler 持续运行;`deploy/xboard-server/compose.yaml` 通过独立 `scheduler` 服务执行 `php artisan schedule:work`,否则自动墙检测只会在手动触发时创建任务 - Bearer Token 存储于 `sessionStorage/localStorage` - `admin-frontend` 的视觉方向当前以 Apple 风格为基线,优先纯色分区、系统字体栈和低装饰成本 diff --git a/.helloagents/modules/_index.md b/.helloagents/modules/_index.md index 24f72d9..0336993 100644 --- a/.helloagents/modules/_index.md +++ b/.helloagents/modules/_index.md @@ -3,6 +3,8 @@ | 模块名 | 说明 | 最近更新 | |--------|------|----------| | [admin-frontend](admin-frontend.md) | 管理端前端登录、布局、仪表盘、用户管理、节点管理与管理 API 封装 | 2026-04-28 | +| [deploy](deploy.md) | 可复制到服务器的 Xboard Compose 部署模板、环境变量模板和运维脚本 | 2026-04-28 | | [node-gfw-check](node-gfw-check.md) | 节点墙状态检测任务、父/子节点继承规则、mi-node 检测上报链路 | 2026-04-28 | | [order-payment](order-payment.md) | 订单支付成功快照、第三方回调元信息透传与后台支付成功信息展示 | 2026-04-25 | +| [queue-mail](queue-mail.md) | 邮件发送队列、SMTP 运行时配置、Horizon 超时与失败重试边界 | 2026-04-28 | | [subscription-protocols](subscription-protocols.md) | 用户订阅导出入口、协议适配器与 Stash / Clash 系列兼容过滤 | 2026-04-24 | diff --git a/.helloagents/modules/deploy.md b/.helloagents/modules/deploy.md new file mode 100644 index 0000000..9f9fdb2 --- /dev/null +++ b/.helloagents/modules/deploy.md @@ -0,0 +1,28 @@ +# deploy + +## 职责 + +- 维护可复制到服务器的一键部署模板 +- 收敛 Docker Compose 服务拓扑、环境变量模板、运行目录初始化脚本和常用运维命令 +- 为依赖 Laravel Scheduler 的后台任务提供明确的部署进程入口 + +## 行为规范 + +- `deploy/xboard-server/` 是面向服务器复制部署的自包含目录,不依赖仓库根目录的 compose 样例 +- `compose.yaml` 默认包含 `web / horizon / scheduler / admin / ws-server / redis` 六个服务 +- `scheduler` 服务固定执行 `php artisan schedule:work`,用于持续触发 `sync:server-gfw-checks`、`sync:server-auto-online` 和其他 Laravel Scheduler 任务 +- 模板默认使用外部 MySQL,不在 compose 中创建数据库服务,避免改变现有生产拓扑 +- `.env.example` 同时覆盖 Docker Compose 变量和 Laravel 运行变量,但不得包含真实 `APP_KEY`、数据库密码、邮箱密码或真实业务域名 +- `scripts/init.sh` 只创建挂载目录并在 `.env` 不存在时复制模板,不执行数据库迁移 +- `scripts/deploy.sh` 只负责初始化、拉取镜像和启动服务,不自动执行生产数据库迁移 +- `scripts/update.sh --migrate` 才会显式执行 `php artisan migrate --force` +- `scripts/status.sh` 输出 compose 状态、scheduler 日志、`schedule:list` 结果和手动墙检测同步命令 + +## 依赖关系 + +- 依赖 `ghcr.io/micah123321/xboard:new` 作为后端默认镜像 +- 依赖 `ghcr.io/micah123321/xboard-admin-frontend:new` 作为管理端默认镜像 +- 依赖 `redis:8-alpine` 提供 `/data/redis.sock` +- 依赖外部 MySQL,由 `.env` 中的 `DB_*` 配置提供 +- 依赖 `admin-frontend/Caddyfile` 支持 `XBOARD_BACKEND_UPSTREAM` 和 `XBOARD_UPLOAD_UPSTREAM` +- 依赖 `app/Console/Kernel.php` 注册 `sync:server-gfw-checks` 等定时任务 diff --git a/.helloagents/modules/node-gfw-check.md b/.helloagents/modules/node-gfw-check.md index 93276e3..cf58abd 100644 --- a/.helloagents/modules/node-gfw-check.md +++ b/.helloagents/modules/node-gfw-check.md @@ -14,8 +14,9 @@ - `server_gfw_checks.status` 使用 `pending / checking / normal / blocked / partial / failed / skipped` - 管理端 `POST server/manage/checkGfw` 接收 `{ ids: number[] }`,响应中区分 `started` 与 `skipped` - 后端定时命令 `sync:server-gfw-checks` 会自动为 `gfw_check_enabled=1` 的父节点创建检测任务;已有未超时的 `pending/checking` 任务时跳过,超过 5 分钟未领取或未上报的任务会自动标记为 `failed` +- Docker all-in-one 镜像通过 supervisor 独立运行 `php artisan schedule:work`;`compose.sample.yaml` 的分进程样例和 `deploy/xboard-server/compose.yaml` 服务器部署模板也包含 `scheduler` 服务,确保 `sync:server-gfw-checks` 和其他 Laravel Scheduler 任务会持续执行 - 节点端 `GET server/gfw/task` 只向父节点返回待执行任务;节点端 `POST server/gfw/report` 必须校验 `check_id` 归属当前节点 -- `v2_server.gfw_check_enabled` 控制节点是否参与自动墙检测与墙状态自动显隐;父节点开启时会自动创建检测任务,子节点不独立检测但可单独关闭随父节点自动隐藏 / 恢复 +- `v2_server.gfw_check_enabled` 控制节点是否参与自动墙检测与墙状态自动显隐;管理端开启父节点墙检测托管时会立即发起一次检测,后续由定时命令持续检测;子节点不独立检测但可单独关闭随父节点自动隐藏 / 恢复 - `blocked` 结果会自动隐藏仍开启墙检测托管且当前显示中的父节点及其子节点,并设置 `gfw_auto_hidden=1` - `normal` 结果只恢复 `gfw_auto_hidden=1` 的节点,避免误恢复管理员手动隐藏的节点;`partial/failed` 只记录状态,不触发自动上线或下线 - `sync:server-auto-online` 会把最新墙状态 `blocked` 和未恢复的 `gfw_auto_hidden` 作为显示否决条件,防止自动上线重新发布疑似被墙节点 @@ -32,6 +33,7 @@ - 依赖 `app/Http/Controllers/V2/Server/ServerController.php` 暴露节点端任务领取和上报接口 - 依赖 `app/Services/NodeSyncService.php` 与 Workerman WS 通道向在线节点推送 `gfw.check` - 依赖 `app/Console/Commands/SyncServerGfwChecks.php` 与 Laravel Scheduler 自动创建检测任务 +- 依赖 `.docker/supervisor/supervisord.conf`、`deploy/xboard-server/compose.yaml` 中的 `scheduler` 服务,或部署环境中的 `schedule:work` / `cron + schedule:run` 持续驱动 Laravel Scheduler - 依赖 `app/Services/ServerAutoOnlineService.php` 在自动上线同步时尊重墙状态否决 - 依赖 `E:/code/go/mi-node/internal/gfwcheck` 执行 ping 检测和结果判定 - 依赖 `E:/code/go/mi-node/internal/panel`、`internal/controlplane` 与 `internal/service` 接收任务、轮询兜底并上报结果 diff --git a/.helloagents/modules/queue-mail.md b/.helloagents/modules/queue-mail.md new file mode 100644 index 0000000..ede5fd2 --- /dev/null +++ b/.helloagents/modules/queue-mail.md @@ -0,0 +1,32 @@ +# queue-mail + +## 职责 + +- 承接注册验证码、登录链接、工单通知、订阅到期/流量提醒和后台群发邮件的异步发送。 +- 通过 `App\Jobs\SendEmailJob` 统一进入 `send_email` 或 `send_email_mass` 队列。 +- 通过 `App\Services\MailService` 统一渲染邮件模板、应用运行时 SMTP 配置、发送邮件并写入 `MailLog`。 + +## 行为规范 + +- `SendEmailJob` 的默认 `timeout` 为 60 秒,`tries` 为 3,`backoff()` 为 `[60, 300]`。 +- `SendEmailJob::$failOnTimeout = true`,超时作业应直接失败,避免同一封邮件在不确定是否已发出的情况下反复重试。 +- 邮件发送返回错误时,`SendEmailJob` 抛出 `RuntimeException`,由 Laravel Queue/Horizon 统一处理重试和失败记录;不再手动 `release(60)`。 +- SMTP 传输超时由 `MAIL_TIMEOUT` 控制,默认 30 秒;`QUEUE_RETRY_AFTER` 默认 90 秒,必须大于邮件 job timeout。 +- Horizon 长驻 worker 每次发送前会通过 `MailRuntimeConfig` 应用后台邮件配置,并刷新已解析 mailer,避免后台 SMTP 配置变更后仍使用旧连接。 +- `MailLog.config` 只保存脱敏后的邮件配置,`password`、`secret`、`token`、`key` 字段不得以明文持久化。 +- `send_email_mass` 队列仍会在邮件正文追加 `[Send-Time: ...]` 标记,用于区分批量发送内容。 + +## 依赖关系 + +- 队列配置: `config/queue.php` +- Horizon supervisor: `config/horizon.php` +- 邮件配置: `config/mail.php` +- 运行时配置: `App\Services\MailRuntimeConfig` +- HTML 通知内容: `App\Services\MailHtmlContent` +- 邮件日志模型: `App\Models\MailLog` + +## 验证要点 + +- `SendEmailJob::$timeout` 小于 `config('queue.connections.redis.retry_after')`。 +- `MAIL_TIMEOUT` 小于 `SendEmailJob::$timeout`,确保网络层先于 job 层超时。 +- 单测应覆盖 job 超时/backoff、邮件错误抛出、批量邮件发送时间标记和 `MailLog` 配置脱敏。 diff --git a/Dockerfile b/Dockerfile index 9599c8c..82f2c94 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,6 +32,7 @@ RUN composer install --no-cache --no-dev --no-security-blocking \ ENV ENABLE_WEB=true \ ENABLE_HORIZON=true \ ENABLE_REDIS=true \ + ENABLE_SCHEDULE=true \ ENABLE_WS_SERVER=true \ ENABLE_CADDY=true diff --git a/Dockerfile.local b/Dockerfile.local index c598cf5..f9943c0 100644 --- a/Dockerfile.local +++ b/Dockerfile.local @@ -27,6 +27,7 @@ RUN composer install --no-cache --no-dev \ ENV ENABLE_WEB=true \ ENABLE_HORIZON=true \ + ENABLE_SCHEDULE=true \ ENABLE_REDIS=false EXPOSE 7001 diff --git a/admin-frontend/src/views/nodes/NodesView.vue b/admin-frontend/src/views/nodes/NodesView.vue index c5032c0..f068c71 100644 --- a/admin-frontend/src/views/nodes/NodesView.vue +++ b/admin-frontend/src/views/nodes/NodesView.vue @@ -312,9 +312,22 @@ async function handleBatchSubmit(payload: NodeBatchEditPayload) { batchSubmitting.value = true await batchUpdateNodes(updatePayload) + let started = 0 + let skipped = 0 + if (payload.gfw_check_enabled === true) { + const response = await checkNodeGfw(updatePayload.ids) + started = response.data?.started?.length ?? 0 + skipped = response.data?.skipped?.length ?? 0 + } batchEditVisible.value = false clearSelection() - ElMessage.success(`已批量更新 ${updatePayload.ids.length} 个节点`) + if (started > 0) { + ElMessage.success(`已批量更新 ${updatePayload.ids.length} 个节点,并发起 ${started} 个父节点墙检测`) + } else if (payload.gfw_check_enabled === true && skipped > 0) { + ElMessage.info('已批量开启墙检测托管,所选父节点已有任务或所选节点为子节点') + } else { + ElMessage.success(`已批量更新 ${updatePayload.ids.length} 个节点`) + } await loadNodeBoard() } catch (error) { if (error === 'cancel' || error === 'close') { @@ -476,7 +489,19 @@ async function handleToggleGfwCheck(node: AdminNodeItem, nextValue: boolean) { id: node.id, gfw_check_enabled: nextValue, }) - ElMessage.success(nextValue ? '已开启墙检测托管' : '已关闭墙检测托管') + if (nextValue && !node.parent_id) { + const response = await checkNodeGfw([node.id]) + const started = response.data?.started?.length ?? 0 + if (started > 0) { + ElMessage.success('已开启墙检测托管,并发起墙状态检测') + } else { + const reason = response.data?.skipped?.[0]?.reason + ElMessage.info(reason || '已开启墙检测托管,已有检测任务等待节点领取或上报') + } + } else { + ElMessage.success(nextValue ? '已开启墙检测托管' : '已关闭墙检测托管') + } + await loadNodeBoard() } catch (error) { node.gfw_check_enabled = previous ElMessage.error(error instanceof Error ? error.message : '墙检测托管状态更新失败') diff --git a/app/Jobs/SendEmailJob.php b/app/Jobs/SendEmailJob.php index c5df45f..4f2a20d 100644 --- a/app/Jobs/SendEmailJob.php +++ b/app/Jobs/SendEmailJob.php @@ -8,6 +8,9 @@ use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Log; +use RuntimeException; +use Throwable; class SendEmailJob implements ShouldQueue { @@ -15,7 +18,8 @@ class SendEmailJob implements ShouldQueue protected $params; public $tries = 3; - public $timeout = 10; + public $timeout = 60; + public $failOnTimeout = true; /** * Create a new job instance. @@ -33,7 +37,7 @@ class SendEmailJob implements ShouldQueue * * @return void */ - public function handle() + public function handle(): void { $params = $this->params; if ( @@ -46,9 +50,55 @@ class SendEmailJob implements ShouldQueue $params['template_value']['content'] = (string) $params['template_value']['content'] . "\r\n\r\n[Send-Time: {$timestamp}]"; } - $mailLog = MailService::sendEmail($params); + $mailLog = $this->sendEmail($params); if ($mailLog['error']) { - $this->release(60); + throw new RuntimeException('Failed to send email: ' . $mailLog['error']); } } + + /** + * Return retry delays for transient mail delivery failures. + */ + public function backoff(): array + { + return [60, 300]; + } + + /** + * Record a failed send attempt without exposing the full recipient address. + */ + public function failed(Throwable $exception): void + { + Log::error('Send email job failed', [ + 'queue' => $this->queue, + 'email' => $this->maskedEmail(), + 'error' => $exception->getMessage(), + ]); + } + + /** + * Send the email payload. + */ + protected function sendEmail(array $params): array + { + return MailService::sendEmail($params); + } + + /** + * Return a partially masked recipient address for operational logs. + */ + private function maskedEmail(): ?string + { + $email = $this->params['email'] ?? null; + if (!is_string($email) || !str_contains($email, '@')) { + return null; + } + + [$local, $domain] = explode('@', $email, 2); + if ($local === '' || $domain === '') { + return null; + } + + return substr($local, 0, 1) . str_repeat('*', max(1, strlen($local) - 1)) . '@' . $domain; + } } diff --git a/app/Services/MailHtmlContent.php b/app/Services/MailHtmlContent.php new file mode 100644 index 0000000..e1175ad --- /dev/null +++ b/app/Services/MailHtmlContent.php @@ -0,0 +1,96 @@ +format('Y-m-d H:i:s')); + + $cta = ''; + $url = isset($templateValue['url']) ? (string) $templateValue['url'] : ''; + if (filter_var($url, FILTER_VALIDATE_URL)) { + $safeUrl = self::escapeHtml($url); + $cta = 'Open Link'; + } + + return ' + + + + + ' . $title . ' + + +
+
+
+
Message
+

' . $title . '

+
+
+
' . $content . '
+
' . $cta . '
+
+
+
Sender: ' . $brand . '
+
Sent at: ' . $sendTime . '
+
+
+
+ +'; + } + + /** + * Send a pre-rendered HTML email using the available Laravel mail API. + */ + public static function sendHtmlMail(string $email, string $subject, string $html): void + { + $mailer = Mail::getFacadeRoot(); + if ($mailer && method_exists($mailer, 'html')) { + Mail::html($html, function ($message) use ($email, $subject) { + $message->to($email)->subject($subject); + }); + return; + } + + Mail::send([], [], function ($message) use ($email, $subject, $html) { + $message->to($email)->subject($subject); + + if (method_exists($message, 'getSymfonyMessage')) { + $symfonyMessage = $message->getSymfonyMessage(); + if ($symfonyMessage && method_exists($symfonyMessage, 'html')) { + $symfonyMessage->html($html); + return; + } + } + + if (method_exists($message, 'setBody')) { + $message->setBody($html, 'text/html'); + return; + } + + throw new RuntimeException('Unsupported mail message driver for html body.'); + }); + } + + /** + * Escape text before inserting it into an HTML email template. + */ + private static function escapeHtml(string $text): string + { + return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); + } +} diff --git a/app/Services/MailRuntimeConfig.php b/app/Services/MailRuntimeConfig.php new file mode 100644 index 0000000..826635c --- /dev/null +++ b/app/Services/MailRuntimeConfig.php @@ -0,0 +1,110 @@ + $timeout]; + + if (admin_setting('email_host')) { + Config::set('mail.default', 'smtp'); + Config::set('mail.driver', 'smtp'); + + $smtpConfig = [ + 'host' => admin_setting('email_host', config('mail.host')), + 'port' => admin_setting('email_port', config('mail.port')), + 'encryption' => admin_setting('email_encryption', config('mail.encryption')), + 'username' => admin_setting('email_username', config('mail.username')), + 'password' => admin_setting('email_password', config('mail.password')), + 'timeout' => self::normalizeTimeout(admin_setting('email_timeout', $timeout)), + ]; + + foreach ($smtpConfig as $key => $value) { + Config::set("mail.{$key}", $value); + } + + Config::set('mail.from.address', admin_setting('email_from_address', config('mail.from.address'))); + } else { + Config::set('mail.timeout', $timeout); + } + + Config::set('mail.from.name', $appName); + self::setSmtpMailerConfig($smtpConfig); + self::forgetResolvedMailers(); + } + + /** + * Return a mail configuration snapshot safe for persistence. + */ + public static function configForLog(array $config): array + { + foreach ($config as $key => $value) { + if (is_array($value)) { + $config[$key] = self::configForLog($value); + continue; + } + + if (self::isSensitiveMailConfigKey((string) $key) && $value !== null && $value !== '') { + $config[$key] = '******'; + } + } + + return $config; + } + + /** + * Normalize a user-provided timeout into the supported range. + */ + private static function normalizeTimeout(mixed $timeout): int + { + $timeout = (int) $timeout; + if ($timeout <= 0) { + return self::DEFAULT_MAIL_TIMEOUT; + } + + return min($timeout, self::MAX_MAIL_TIMEOUT); + } + + /** + * Mirror legacy SMTP settings into Laravel's modern mailer config shape. + */ + private static function setSmtpMailerConfig(array $smtpConfig): void + { + Config::set('mail.mailers.smtp.transport', 'smtp'); + + foreach ($smtpConfig as $key => $value) { + Config::set("mail.mailers.smtp.{$key}", $value); + } + } + + /** + * Clear resolved mailers so long-running workers use fresh settings. + */ + private static function forgetResolvedMailers(): void + { + $mailManager = Mail::getFacadeRoot(); + if ($mailManager && method_exists($mailManager, 'forgetMailers')) { + $mailManager->forgetMailers(); + } + } + + /** + * Determine whether a mail config key contains sensitive material. + */ + private static function isSensitiveMailConfigKey(string $key): bool + { + return in_array(strtolower($key), ['password', 'secret', 'token', 'key'], true); + } +} diff --git a/app/Services/MailService.php b/app/Services/MailService.php index 3063723..2f0b377 100644 --- a/app/Services/MailService.php +++ b/app/Services/MailService.php @@ -8,7 +8,6 @@ use App\Models\MailTemplate; use App\Models\User; use App\Utils\CacheKey; use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; @@ -236,15 +235,7 @@ class MailService $appName = 'Notification Service'; } - if (admin_setting('email_host')) { - Config::set('mail.host', admin_setting('email_host', config('mail.host'))); - Config::set('mail.port', admin_setting('email_port', config('mail.port'))); - Config::set('mail.encryption', admin_setting('email_encryption', config('mail.encryption'))); - Config::set('mail.username', admin_setting('email_username', config('mail.username'))); - Config::set('mail.password', admin_setting('email_password', config('mail.password'))); - Config::set('mail.from.address', admin_setting('email_from_address', config('mail.from.address'))); - } - Config::set('mail.from.name', $appName); + MailRuntimeConfig::apply($appName); $params['template_value'] = isset($params['template_value']) && is_array($params['template_value']) ? $params['template_value'] @@ -301,8 +292,8 @@ class MailService $logTemplateName = $params['template_name']; try { if ($originTemplateName === 'notify') { - $html = self::buildModernNotifyHtml($params['template_value'], $subject, $appName); - self::sendHtmlMail($email, $subject, $html); + $html = MailHtmlContent::buildModernNotifyHtml($params['template_value'], $subject, $appName); + MailHtmlContent::sendHtmlMail($email, $subject, $html); $logTemplateName = 'mail.modern.notify'; } else { Mail::send( @@ -324,7 +315,7 @@ class MailService 'subject' => $subject, 'template_name' => $logTemplateName, 'error' => $error, - 'config' => config('mail'), + 'config' => MailRuntimeConfig::configForLog((array) config('mail', [])), ]; MailLog::create($log); @@ -339,81 +330,4 @@ class MailService return trim((string) $cleaned); } - private static function escapeHtml(string $text): string - { - return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); - } - - private static function buildModernNotifyHtml(array $templateValue, string $subject, string $appName): string - { - $title = self::escapeHtml($subject); - $brand = self::escapeHtml((string) ($templateValue['name'] ?? $appName)); - $content = self::escapeHtml((string) ($templateValue['content'] ?? '')); - $content = nl2br($content, false); - $sendTime = self::escapeHtml(now()->format('Y-m-d H:i:s')); - - $cta = ''; - $url = isset($templateValue['url']) ? (string) $templateValue['url'] : ''; - if (filter_var($url, FILTER_VALIDATE_URL)) { - $safeUrl = self::escapeHtml($url); - $cta = 'Open Link'; - } - - return ' - - - - - ' . $title . ' - - -
-
-
-
Message
-

' . $title . '

-
-
-
' . $content . '
-
' . $cta . '
-
-
-
Sender: ' . $brand . '
-
Sent at: ' . $sendTime . '
-
-
-
- -'; - } - - private static function sendHtmlMail(string $email, string $subject, string $html): void - { - $mailer = Mail::getFacadeRoot(); - if ($mailer && method_exists($mailer, 'html')) { - Mail::html($html, function ($message) use ($email, $subject) { - $message->to($email)->subject($subject); - }); - return; - } - - Mail::send([], [], function ($message) use ($email, $subject, $html) { - $message->to($email)->subject($subject); - - if (method_exists($message, 'getSymfonyMessage')) { - $symfonyMessage = $message->getSymfonyMessage(); - if ($symfonyMessage && method_exists($symfonyMessage, 'html')) { - $symfonyMessage->html($html); - return; - } - } - - if (method_exists($message, 'setBody')) { - $message->setBody($html, 'text/html'); - return; - } - - throw new \RuntimeException('Unsupported mail message driver for html body.'); - }); - } } diff --git a/compose.sample.yaml b/compose.sample.yaml index a3558e7..3e82c32 100644 --- a/compose.sample.yaml +++ b/compose.sample.yaml @@ -43,6 +43,19 @@ services: command: php artisan horizon depends_on: - redis + scheduler: + image: xboard-local:latest + volumes: + - ./.docker/.data/redis/:/data/ + - ./.env:/www/.env + - ./.docker/.data/:/www/.docker/.data + - ./storage/logs:/www/storage/logs + - ./plugins:/www/plugins + restart: on-failure + # network_mode: host + command: php artisan schedule:work + depends_on: + - redis ws-server: image: xboard-local:latest volumes: diff --git a/compose.split.sample.yaml b/compose.split.sample.yaml index 8234aa6..64a77ef 100644 --- a/compose.split.sample.yaml +++ b/compose.split.sample.yaml @@ -35,6 +35,7 @@ services: docker: "true" ENABLE_CADDY: "false" ENABLE_HORIZON: "false" + ENABLE_SCHEDULE: "true" ENABLE_WS_SERVER: "false" horizon: @@ -47,6 +48,7 @@ services: docker: "true" ENABLE_CADDY: "false" ENABLE_WEB: "false" + ENABLE_SCHEDULE: "false" ENABLE_WS_SERVER: "false" ws-server: @@ -60,6 +62,7 @@ services: ENABLE_CADDY: "false" ENABLE_WEB: "false" ENABLE_HORIZON: "false" + ENABLE_SCHEDULE: "false" WS_HOST: "0.0.0.0" redis: diff --git a/config/mail.php b/config/mail.php index 3c65eb3..6a17c04 100755 --- a/config/mail.php +++ b/config/mail.php @@ -44,6 +44,8 @@ return [ 'port' => env('MAIL_PORT', 587), + 'timeout' => env('MAIL_TIMEOUT', 30), + /* |-------------------------------------------------------------------------- | Global "From" Address diff --git a/config/queue.php b/config/queue.php index 495c858..479a9a3 100755 --- a/config/queue.php +++ b/config/queue.php @@ -38,14 +38,14 @@ return [ 'driver' => 'database', 'table' => 'jobs', 'queue' => 'default', - 'retry_after' => 90, + 'retry_after' => (int) env('QUEUE_RETRY_AFTER', 90), ], 'beanstalkd' => [ 'driver' => 'beanstalkd', 'host' => 'localhost', 'queue' => 'default', - 'retry_after' => 90, + 'retry_after' => (int) env('QUEUE_RETRY_AFTER', 90), 'block_for' => 0, ], @@ -62,7 +62,7 @@ return [ 'driver' => 'redis', 'connection' => 'default', 'queue' => env('REDIS_QUEUE', 'default'), - 'retry_after' => 90, + 'retry_after' => (int) env('QUEUE_RETRY_AFTER', 90), 'block_for' => null, ], diff --git a/deploy/xboard-server/.env.example b/deploy/xboard-server/.env.example new file mode 100644 index 0000000..4f3f26f --- /dev/null +++ b/deploy/xboard-server/.env.example @@ -0,0 +1,59 @@ +# Docker Compose images and ports +XBOARD_IMAGE=ghcr.io/micah123321/xboard:new +XBOARD_ADMIN_IMAGE=ghcr.io/micah123321/xboard-admin-frontend:new +REDIS_IMAGE=redis:8-alpine +WEB_PORT=7001 +ADMIN_PORT=7002 +WS_PORT=8076 + +# Admin frontend reverse proxy targets inside the compose network +XBOARD_BACKEND_UPSTREAM=http://web:7001 +XBOARD_UPLOAD_UPSTREAM=https://pic.535888.xyz + +# Laravel application +APP_NAME=XBoard +APP_ENV=production +APP_KEY= +APP_DEBUG=false +APP_URL=https://your-domain.example +LOG_CHANNEL=stack + +# Database: this template expects an external MySQL server +DB_CONNECTION=mysql +DB_HOST=your-mysql-host +DB_PORT=3306 +DB_DATABASE=xboard +DB_USERNAME=xboard +DB_PASSWORD=change-me + +# Redis: shared unix socket from the redis-data volume +REDIS_HOST=/data/redis.sock +REDIS_PASSWORD=null +REDIS_PORT=0 + +# Queue, cache and session +BROADCAST_DRIVER=log +CACHE_DRIVER=redis +QUEUE_CONNECTION=redis +QUEUE_RETRY_AFTER=90 +SESSION_DRIVER=file +MASS_EMAIL_HOURLY_LIMIT=500 + +# Mail +MAIL_DRIVER=smtp +MAIL_HOST=smtp.example.com +MAIL_PORT=587 +MAIL_TIMEOUT=30 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_ENCRYPTION=tls +MAIL_FROM_ADDRESS=no-reply@example.com +MAIL_FROM_NAME=XBoard +MAILGUN_DOMAIN= +MAILGUN_SECRET= + +# Backup and installation flags +ENABLE_AUTO_BACKUP_AND_UPDATE=false +GOOGLE_CLOUD_KEY_FILE=config/googleCloudStorageKey.json +GOOGLE_CLOUD_STORAGE_BUCKET= +INSTALLED=false diff --git a/deploy/xboard-server/.gitignore b/deploy/xboard-server/.gitignore new file mode 100644 index 0000000..2a377b1 --- /dev/null +++ b/deploy/xboard-server/.gitignore @@ -0,0 +1,6 @@ +.env +.docker/.data/ +storage/logs/ +storage/theme/ +plugins/ +*.log diff --git a/deploy/xboard-server/README.md b/deploy/xboard-server/README.md new file mode 100644 index 0000000..21b0e78 --- /dev/null +++ b/deploy/xboard-server/README.md @@ -0,0 +1,134 @@ +# Xboard 服务器部署模板 + +这个目录是一套可复制到服务器的 Compose 部署模板,拓扑对齐当前生产用法: + +- `web`: Laravel Octane HTTP 服务,默认发布宿主机 `7001` +- `horizon`: 队列进程 +- `scheduler`: Laravel Scheduler,负责持续触发 `sync:server-gfw-checks` 等定时任务 +- `admin`: 独立管理端前端容器,默认发布宿主机 `7002` +- `ws-server`: 节点 WebSocket 服务,默认发布宿主机 `8076` +- `redis`: 通过 `redis-data` 卷提供 `/data/redis.sock` + +模板默认不包含 MySQL。数据库继续使用宿主机、面板或云数据库中的外部 MySQL。 + +## 首次部署 + +服务器需要已安装 Docker,并支持 `docker compose` 命令。 + +```sh +cd xboard-server +sh ./scripts/init.sh +vi .env +sh ./scripts/deploy.sh +``` + +`.env` 至少需要检查这些项: + +- `APP_URL`: 对外访问域名,例如 `https://example.com` +- `APP_KEY`: 新安装可留空后通过 `xboard:install` 生成;已安装实例必须填原来的值 +- `DB_HOST / DB_PORT / DB_DATABASE / DB_USERNAME / DB_PASSWORD`: 外部 MySQL 连接 +- `MAIL_*`: 邮件发送配置 +- `WEB_PORT / ADMIN_PORT / WS_PORT`: 宿主机端口,和现有服务冲突时修改 +- `XBOARD_UPLOAD_UPSTREAM`: 管理端图片上传反向代理目标 + +## 初始化或迁移数据库 + +全新安装时,先确认 `.env` 里的数据库指向正确,再执行交互式安装: + +```sh +docker compose exec web php artisan xboard:install +``` + +已有数据库升级时,不要重新执行安装命令。需要迁移时执行: + +```sh +docker compose exec -T web php artisan migrate --force +``` + +项目自带更新命令也会执行迁移、默认插件检查和缓存刷新: + +```sh +docker compose exec -T web php artisan xboard:update +``` + +## 启动与更新 + +启动或重新拉起服务: + +```sh +docker compose up -d +``` + +更新镜像但不自动迁移数据库: + +```sh +sh ./scripts/update.sh +``` + +更新镜像并显式执行数据库迁移: + +```sh +sh ./scripts/update.sh --migrate +``` + +查看服务状态: + +```sh +docker compose ps +``` + +查看日志: + +```sh +docker compose logs -f web +docker compose logs -f horizon +docker compose logs -f scheduler +docker compose logs -f ws-server +docker compose logs -f admin +``` + +## Scheduler 检查 + +自动墙检测依赖 `scheduler` 容器持续运行。该容器执行 `php artisan schedule:work`。节点开启墙检测托管后,`sync:server-gfw-checks` 默认每 30 分钟由 Laravel Scheduler 创建检测任务。 + +常用检查命令: + +```sh +docker compose ps scheduler +docker compose logs -f scheduler +docker compose exec -T web php artisan schedule:list +``` + +手动触发一次墙检测同步: + +```sh +docker compose exec -T web php artisan sync:server-gfw-checks +``` + +如果节点页一直显示“未检测”或“等待节点领取”,优先检查: + +- `scheduler` 是否在线 +- `php artisan schedule:list` 是否能列出 `sync:server-gfw-checks` +- `ws-server` 是否在线,节点端是否已连接 +- 节点是否是父节点;子节点不会单独创建检测任务 +- 目标节点是否开启了墙检测托管 + +## 管理端代理 + +`admin` 容器通过环境变量把管理端请求代理到后端: + +```env +XBOARD_BACKEND_UPSTREAM=http://web:7001 +XBOARD_UPLOAD_UPSTREAM=https://pic.535888.xyz +``` + +在默认 Compose 网络内,`http://web:7001` 是后端服务地址,不需要改成宿主机 IP。只有上传服务需要按你的实际图片上传入口调整。 + +## 目录说明 + +- `.env`: 服务器真实配置,不应提交到仓库 +- `.docker/.data/`: Xboard 容器运行时数据 +- `storage/logs/`: Laravel 日志 +- `storage/theme/`: 主题资源 +- `plugins/`: 插件目录 +- `redis-data`: Docker 命名卷,保存 Redis socket 和持久化数据 diff --git a/deploy/xboard-server/scripts/deploy.sh b/deploy/xboard-server/scripts/deploy.sh new file mode 100644 index 0000000..64df61d --- /dev/null +++ b/deploy/xboard-server/scripts/deploy.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." + +sh ./scripts/init.sh + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose plugin is required. Install Docker with the 'docker compose' command." + exit 1 +fi + +docker compose pull +docker compose up -d +docker compose ps + +echo "Deployment started." +echo "Check scheduler with: sh ./scripts/status.sh" diff --git a/deploy/xboard-server/scripts/init.sh b/deploy/xboard-server/scripts/init.sh new file mode 100644 index 0000000..2e1f6e8 --- /dev/null +++ b/deploy/xboard-server/scripts/init.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." + +mkdir -p .docker/.data storage/logs storage/theme plugins + +if [ ! -f .env ]; then + cp .env.example .env + echo "Created .env from .env.example." + echo "Edit .env before starting services." +else + echo ".env already exists." +fi + +echo "Runtime directories are ready:" +echo " .docker/.data" +echo " storage/logs" +echo " storage/theme" +echo " plugins" diff --git a/deploy/xboard-server/scripts/status.sh b/deploy/xboard-server/scripts/status.sh new file mode 100644 index 0000000..27f2dc1 --- /dev/null +++ b/deploy/xboard-server/scripts/status.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose plugin is required. Install Docker with the 'docker compose' command." + exit 1 +fi + +docker compose ps + +echo +echo "Recent scheduler logs:" +docker compose logs --tail=80 scheduler || true + +echo +echo "Laravel schedule list:" +if ! docker compose exec -T web php artisan schedule:list; then + echo "schedule:list failed. Ensure the web container is running and .env is valid." +fi + +echo +echo "Manual GFW sync command:" +echo " docker compose exec -T web php artisan sync:server-gfw-checks" diff --git a/deploy/xboard-server/scripts/update.sh b/deploy/xboard-server/scripts/update.sh new file mode 100644 index 0000000..5907f66 --- /dev/null +++ b/deploy/xboard-server/scripts/update.sh @@ -0,0 +1,45 @@ +#!/bin/sh +set -eu + +cd "$(dirname "$0")/.." + +run_migrate=false + +for arg in "$@"; do + case "$arg" in + --migrate) + run_migrate=true + ;; + -h|--help) + echo "Usage: sh ./scripts/update.sh [--migrate]" + echo " --migrate Run 'php artisan migrate --force' after containers are updated." + exit 0 + ;; + *) + echo "Unknown option: $arg" + echo "Usage: sh ./scripts/update.sh [--migrate]" + exit 1 + ;; + esac +done + +if [ ! -f .env ]; then + echo ".env is missing. Run: sh ./scripts/init.sh" + exit 1 +fi + +if ! docker compose version >/dev/null 2>&1; then + echo "Docker Compose plugin is required. Install Docker with the 'docker compose' command." + exit 1 +fi + +docker compose pull +docker compose up -d + +if [ "$run_migrate" = "true" ]; then + docker compose exec -T web php artisan migrate --force +else + echo "Migration skipped. Re-run with --migrate when the release requires database migrations." +fi + +docker compose ps diff --git a/tests/Unit/Jobs/SendEmailJobTest.php b/tests/Unit/Jobs/SendEmailJobTest.php new file mode 100644 index 0000000..eca93a2 --- /dev/null +++ b/tests/Unit/Jobs/SendEmailJobTest.php @@ -0,0 +1,69 @@ + 'user@example.com', + 'subject' => 'Subject', + 'template_name' => 'notify', + ]); + + $this->assertSame(3, $job->tries); + $this->assertSame(60, $job->timeout); + $this->assertTrue($job->failOnTimeout); + $this->assertSame([60, 300], $job->backoff()); + } + + public function test_handle_throws_when_mail_service_returns_error(): void + { + $job = new class([ + 'email' => 'user@example.com', + 'subject' => 'Subject', + 'template_name' => 'notify', + ]) extends SendEmailJob { + protected function sendEmail(array $params): array + { + return ['error' => 'SMTP connection failed']; + } + }; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to send email: SMTP connection failed'); + + $job->handle(); + } + + public function test_mass_email_content_gets_send_time_marker_before_send(): void + { + $job = new class([ + 'email' => 'user@example.com', + 'subject' => 'Subject', + 'template_name' => 'notify', + 'template_value' => [ + 'content' => 'Hello', + ], + ], 'send_email_mass') extends SendEmailJob { + public array $capturedParams = []; + + protected function sendEmail(array $params): array + { + $this->capturedParams = $params; + + return ['error' => null]; + } + }; + + $job->handle(); + + $this->assertStringStartsWith('Hello', $job->capturedParams['template_value']['content']); + $this->assertStringContainsString('[Send-Time:', $job->capturedParams['template_value']['content']); + } +} diff --git a/tests/Unit/MailServiceConfigTest.php b/tests/Unit/MailServiceConfigTest.php new file mode 100644 index 0000000..bcd00b8 --- /dev/null +++ b/tests/Unit/MailServiceConfigTest.php @@ -0,0 +1,56 @@ + 'smtp.example.com', + 'password' => 'secret-password', + 'mailers' => [ + 'smtp' => [ + 'username' => 'mailer', + 'password' => 'nested-secret', + 'timeout' => 30, + ], + 'ses' => [ + 'key' => 'aws-key', + 'secret' => 'aws-secret', + ], + ], + ]; + + $sanitized = $this->mailConfigForLog($config); + + $this->assertSame('smtp.example.com', $sanitized['host']); + $this->assertSame('******', $sanitized['password']); + $this->assertSame('mailer', $sanitized['mailers']['smtp']['username']); + $this->assertSame('******', $sanitized['mailers']['smtp']['password']); + $this->assertSame(30, $sanitized['mailers']['smtp']['timeout']); + $this->assertSame('******', $sanitized['mailers']['ses']['key']); + $this->assertSame('******', $sanitized['mailers']['ses']['secret']); + } + + public function test_mail_log_config_keeps_empty_sensitive_values_empty(): void + { + $sanitized = $this->mailConfigForLog([ + 'password' => null, + 'secret' => '', + 'timeout' => 30, + ]); + + $this->assertNull($sanitized['password']); + $this->assertSame('', $sanitized['secret']); + $this->assertSame(30, $sanitized['timeout']); + } + + private function mailConfigForLog(array $config): array + { + return MailRuntimeConfig::configForLog($config); + } +}