Initial commit with 2026 World Cup Quant Platform core modules and CI/CD

This commit is contained in:
QuantBot
2026-06-13 23:18:18 +08:00
commit 073abf98c1
155 changed files with 19539 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
# 192.168.0.XXX 主機快速評估與正式上線建議
## 目前可觀測結果(已完成)
檢查指標:
- ping 可達性
- SSH(22)/HTTP(80) 開放
- HTTP 回應(首頁)
- 若你要正式流量,另外請再補 `CPU/MEM/磁碟/I/O/端口衝突/服務穩定性` 實測
結果:
- `192.168.0.110`: HTTP 301可能有既有反向代理/導向)
- `192.168.0.188`: HTTP 200直接可回應
- `192.168.0.120`: port 80 未開
- `192.168.0.121`: port 80 未開
對外正式網址建議:`2026fifa.wooo.work`
## 建議
- **主站上線:`192.168.0.188`**
- 優先原因:已能直接回 HTTP 200網頁入口可立即承接。
- 作為正式環境主節點建議。
- 備援方案:
- `192.168.0.110` 可作為備援節點,需先確認 301 重導目標與既有服務衝突後再掛上。
- `120` / `121` 建議先做服務預配置(開放 80/443 + 反代),再參與正式流量。
## 正式上線前最小實測
1. CPU 平均負載、記憶體、磁碟 I/O、開放端口檢查
2. 24 小時壓測與 API 快速刷新測試(以 20~60 秒輪詢為基準)
3. `The Odds API` 呼叫量與速率配額驗證
4. 新聞源可用率RSS/NewsAPI失敗 fallback 驗證
5. 自動重啟PM2與 crash 回復演練
6. 回滾流程:新版本失敗 2 分鐘內可回到上版前容器

62
ops/deploy-production.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${1:-192.168.0.188}"
PROJECT_ROOT="${2:-/opt/fifa2026}"
PUBLIC_ORIGIN="${3:-https://2026fifa.wooo.work}"
DEPLOY_USER="${DEPLOY_USER:-${USER}}"
LOCAL_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ENV_FILE="${LOCAL_DIR}/.env"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "[deploy] ERROR: .env not found at ${ENV_FILE}"
exit 1
fi
echo "[deploy] target=${HOST} path=${PROJECT_ROOT}"
echo "[deploy] rsync source=${LOCAL_DIR}"
ssh -o StrictHostKeyChecking=accept-new "${DEPLOY_USER}@${HOST}" "mkdir -p ${PROJECT_ROOT}/shared ${PROJECT_ROOT}/logs"
rsync -avz --delete \
--exclude node_modules \
--exclude .git \
--exclude .env \
"${LOCAL_DIR}/" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/current/"
scp "${ENV_FILE}" "${DEPLOY_USER}@${HOST}:${PROJECT_ROOT}/.env"
ssh "${DEPLOY_USER}@${HOST}" "PROJECT_ROOT='${PROJECT_ROOT}'; \
mkdir -p ${PROJECT_ROOT}/current; \
cd ${PROJECT_ROOT}/current; \
if [[ -f .env ]]; then :; else cp ${PROJECT_ROOT}/.env .env; fi; \
if ! grep -q '^APP_PUBLIC_ORIGIN=' ${PROJECT_ROOT}/.env; then \
echo \"APP_PUBLIC_ORIGIN=${PUBLIC_ORIGIN}\" >> ${PROJECT_ROOT}/.env; \
fi; \
if ! grep -q '^APP_TIME_ZONE=' ${PROJECT_ROOT}/.env; then \
echo 'APP_TIME_ZONE=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \
fi; \
if ! grep -q '^TZ=' ${PROJECT_ROOT}/.env; then \
echo 'TZ=Asia/Taipei' >> ${PROJECT_ROOT}/.env; \
fi; \
if ! grep -q '^KELLY_SCALE=' ${PROJECT_ROOT}/.env; then \
echo 'KELLY_SCALE=0.6' >> ${PROJECT_ROOT}/.env; \
fi; \
node -v >/tmp/node-version.txt 2>/dev/null || true; \
if ! command -v npm >/dev/null; then \
echo '[deploy] ERROR: npm not found'; exit 1; \
fi; \
npm install --omit=dev; \
if ! command -v pm2 >/dev/null; then \
npm install pm2 >/tmp/pm2-install.log 2>&1; \
fi; \
PM2_BIN=\"\$(command -v pm2 || true)\"; \
if [ -z \"\${PM2_BIN}\" ]; then \
PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \
fi; \
[ -x \"\${PM2_BIN}\" ] || PM2_BIN=\"\${PROJECT_ROOT}/current/node_modules/.bin/pm2\"; \
[ -x \"\${PM2_BIN}\" ] || (echo '[deploy] ERROR: pm2 not found' && exit 1); \
TZ=Asia/Taipei APP_TIME_ZONE=Asia/Taipei NODE_ENV=production \"\${PM2_BIN}\" startOrReload \"\${PROJECT_ROOT}/current/ops/pm2.config.js\" --env production 2>&1 | cat; \
\"\${PM2_BIN}\" save"
echo "[deploy] deploy done"

62
ops/healthcheck-production.sh Executable file
View File

@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
HOST="${1:-192.168.0.188}"
PORT="${2:-3108}"
PUBLIC_HOST="${3:-2026fifa.wooo.work}"
normalize_host() {
local host="$1"
if [[ "$host" == http://* ]] || [[ "$host" == https://* ]]; then
printf '%s' "$host"
return
fi
printf 'https://%s' "$host"
}
PUBLIC_URL="$(normalize_host "$PUBLIC_HOST")"
[[ "$PUBLIC_URL" == */ ]] && PUBLIC_URL="${PUBLIC_URL%/}"
PUBLIC_URL="${PUBLIC_URL}/api/health"
INTERNAL_URL="http://${HOST}:${PORT}/api/health"
echo "[health] check public=${PUBLIC_URL}"
echo "[health] check internal=${INTERNAL_URL} (fallback)"
for i in {1..30}; do
if command -v jq >/dev/null 2>&1; then
data=""
if resp="$(curl -sS --max-time 6 "${PUBLIC_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then
data="${resp}"
elif resp="$(curl -sS --max-time 6 "${INTERNAL_URL}")" && echo "${resp}" | jq -e '.status' >/dev/null 2>&1; then
data="${resp}"
fi
if [[ -z "${data}" ]]; then
sleep 4
continue
fi
status="$(echo "${data}" | jq -r '.status')"
count="$(echo "${data}" | jq -r '.matchCount')"
publicOrigin="$(echo "${data}" | jq -r '.publicOrigin // "-"')"
tz="$(echo "${data}" | jq -r '.timeZone // "unknown"')"
updated="$(echo "${data}" | jq -r '.lastUpdatedTaipei // "-"')"
echo "[health] #${i} status=${status} matches=${count} public=${publicOrigin} timezone=${tz} updated=${updated}"
if [[ "${status}" == "ready" && "${count}" != "0" ]]; then
echo "[health] production api ready"
exit 0
fi
else
code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${PUBLIC_URL}")"
if [[ "${code}" != "200" ]]; then
code="$(curl -o /dev/null -sS --max-time 6 -w '%{http_code}' "${INTERNAL_URL}")"
fi
echo "[health] #${i} http=${code}"
if [[ "${code}" == "200" ]]; then
echo "[health] production endpoint returns ok"
exit 0
fi
fi
sleep 4
done
echo "[health] ERROR: not healthy"
exit 1

37
ops/nginx-2026fifa.conf Normal file
View File

@@ -0,0 +1,37 @@
server {
listen 80;
listen [::]:80;
server_name 2026fifa.wooo.work;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name 2026fifa.wooo.work;
ssl_certificate /etc/letsencrypt/live/2026fifa.wooo.work/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/2026fifa.wooo.work/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header Referrer-Policy same-origin;
location /.well-known/acme-challenge/ {
root /var/www/html;
}
client_max_body_size 16m;
location / {
proxy_pass http://127.0.0.1:3108;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
}
}

25
ops/pm2.config.js Normal file
View File

@@ -0,0 +1,25 @@
module.exports = {
apps: [
{
name: 'fifa2026-betting-desk',
script: './src/server.js',
instances: 1,
exec_mode: 'fork',
env: {
NODE_ENV: 'production',
TZ: 'Asia/Taipei',
APP_TIME_ZONE: 'Asia/Taipei',
APP_PUBLIC_ORIGIN: 'https://2026fifa.wooo.work',
},
env_file: '/opt/fifa2026/.env',
out_file: '/opt/fifa2026/logs/out.log',
error_file: '/opt/fifa2026/logs/error.log',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
max_memory_restart: '700M',
max_restarts: 15,
restart_delay: 2000,
merge_logs: true,
watch: false,
},
],
};