add prod docker
This commit is contained in:
parent
b7125abeac
commit
e820bc0368
|
|
@ -0,0 +1,9 @@
|
|||
.run
|
||||
.git
|
||||
web/node_modules
|
||||
web/dist
|
||||
worker/node_modules
|
||||
**/*_test.go
|
||||
**/.DS_Store
|
||||
*.md
|
||||
!deploy/**
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -7,3 +7,10 @@
|
|||
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
5:37:52 PM [vite] (client) hmr update /src/index.css
|
||||
5:37:55 PM [vite] (client) hmr update /src/index.css
|
||||
5:37:58 PM [vite] (client) hmr update /src/index.css
|
||||
5:37:58 PM [vite] (client) hmr update /src/index.css
|
||||
5:39:34 PM [vite] (client) hmr update /src/index.css
|
||||
5:41:23 PM [vite] (client) hmr update /src/index.css
|
||||
5:42:28 PM [vite] (client) hmr update /src/index.css
|
||||
|
|
|
|||
|
|
@ -75,3 +75,15 @@ node-worker-style-8d: ## 啟動 Node 8D 爬蟲 worker
|
|||
cd .. && npm run worker:style-8d
|
||||
|
||||
check: fmt test ## 格式化並測試
|
||||
|
||||
prod: ## 一鍵啟動 production Docker(API + Web + workers,分身數見 deploy/.env)
|
||||
bash scripts/prod-up.sh
|
||||
|
||||
prod-down: ## 停止 production Docker stack
|
||||
bash scripts/prod-down.sh
|
||||
|
||||
prod-logs: ## 追蹤 production logs(可傳 service 名,例:make prod-logs ARGS=api)
|
||||
bash scripts/prod-logs.sh $(ARGS)
|
||||
|
||||
prod-build: ## 只建置 production images(不啟動)
|
||||
cd deploy && docker compose -f docker-compose.prod.yml build
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"haixun-backend/internal/config"
|
||||
"haixun-backend/internal/svc"
|
||||
|
||||
"github.com/zeromicro/go-zero/core/conf"
|
||||
)
|
||||
|
||||
var configFile = flag.String("f", "etc/gateway.worker.yaml", "config file")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(*configFile, &c)
|
||||
if !c.JobWorker.Enabled {
|
||||
fmt.Fprintln(os.Stderr, "[worker] JobWorker.Enabled must be true")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
sc := svc.NewServiceContext(c)
|
||||
defer sc.Close(context.Background())
|
||||
|
||||
fmt.Printf(
|
||||
"[worker] started type=%s (scheduler=%v reaper=%v)\n",
|
||||
c.JobWorker.WorkerType,
|
||||
c.JobScheduler.Enabled,
|
||||
c.JobReaper.Enabled,
|
||||
)
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-ch
|
||||
fmt.Println("[worker] shutting down")
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# 複製為 deploy/.env 後再啟動:cp deploy/.env.example deploy/.env
|
||||
|
||||
# ── 對外埠 ──
|
||||
HAIXUN_WEB_PORT=8080
|
||||
|
||||
# ── Worker 分身數(make prod 會帶入 docker compose --scale)──
|
||||
GO_WORKER_REPLICAS=1
|
||||
NODE_STYLE8D_WORKER_REPLICAS=1
|
||||
|
||||
# ── Mongo / Redis(容器內預設,通常不用改)──
|
||||
HAIXUN_MONGO_URI=mongodb://mongo:27017
|
||||
HAIXUN_MONGO_DATABASE=haixun
|
||||
HAIXUN_REDIS_ADDR=redis:6379
|
||||
|
||||
# ── 安全金鑰(正式環境務必更換)──
|
||||
HAIXUN_AUTH_ACCESS_SECRET=change-me-access-secret
|
||||
HAIXUN_AUTH_REFRESH_SECRET=change-me-refresh-secret
|
||||
HAIXUN_WORKER_SECRET=change-me-worker-secret
|
||||
|
||||
# ── 首次初始化管理員(make prod 會自動跑 init;已存在則跳過建立)──
|
||||
INIT_TENANT_ID=default
|
||||
INIT_ADMIN_EMAIL=admin@haixun.local
|
||||
INIT_ADMIN_PASSWORD=Admin-Pass-1!
|
||||
|
||||
# ── Node 8D worker 選項 ──
|
||||
# HAIXUN_NODE_WORKER_ID=custom-node-worker-1
|
||||
# HAIXUN_WORKER_POLL_MS=3000
|
||||
|
||||
# ── 略過自動 init(已有資料庫時可設 1)──
|
||||
# HAIXUN_SKIP_INIT=1
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM golang:1.22-bookworm AS builder
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-api .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-worker ./cmd/worker
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/haixun-tool ./cmd/tool
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates gettext-base curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
COPY --from=builder /out/haixun-api /out/haixun-worker /out/haixun-tool /app/
|
||||
COPY deploy/config/gateway.runtime.yaml.tpl deploy/config/gateway.worker.runtime.yaml.tpl /app/deploy/config/
|
||||
COPY deploy/docker/entrypoint-api.sh deploy/docker/entrypoint-worker.sh deploy/docker/entrypoint-init.sh /app/deploy/docker/
|
||||
RUN chmod +x /app/deploy/docker/entrypoint-api.sh /app/deploy/docker/entrypoint-worker.sh /app/deploy/docker/entrypoint-init.sh
|
||||
EXPOSE 8890
|
||||
ENTRYPOINT ["/app/deploy/docker/entrypoint-api.sh"]
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM mcr.microsoft.com/playwright:v1.49.1-noble AS base
|
||||
WORKDIR /app
|
||||
COPY worker/package.json ./
|
||||
RUN npm install
|
||||
COPY worker/ ./
|
||||
ENV NODE_ENV=production
|
||||
CMD ["npx", "tsx", "style-8d-worker.ts"]
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-bookworm AS web-builder
|
||||
WORKDIR /src/web
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY web/ ./
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=web-builder /src/web/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
Name: haixun-backend
|
||||
Host: 0.0.0.0
|
||||
Port: 8890
|
||||
Timeout: 120000
|
||||
|
||||
Mongo:
|
||||
URI: ${HAIXUN_MONGO_URI}
|
||||
Database: ${HAIXUN_MONGO_DATABASE}
|
||||
TimeoutSeconds: 10
|
||||
|
||||
Redis:
|
||||
Addr: ${HAIXUN_REDIS_ADDR}
|
||||
DB: 0
|
||||
|
||||
Auth:
|
||||
AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
|
||||
RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
|
||||
AccessExpireSeconds: 900
|
||||
RefreshExpireSeconds: 2592000
|
||||
DevHeaderFallback: false
|
||||
|
||||
InternalWorker:
|
||||
Secret: ${HAIXUN_WORKER_SECRET}
|
||||
|
||||
JobWorker:
|
||||
Enabled: false
|
||||
WorkerType: go
|
||||
|
||||
JobScheduler:
|
||||
Enabled: true
|
||||
IntervalSeconds: 60
|
||||
|
||||
JobReaper:
|
||||
Enabled: true
|
||||
IntervalSeconds: 30
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
Name: haixun-worker
|
||||
Host: 0.0.0.0
|
||||
Port: 8891
|
||||
Timeout: 120000
|
||||
|
||||
Mongo:
|
||||
URI: ${HAIXUN_MONGO_URI}
|
||||
Database: ${HAIXUN_MONGO_DATABASE}
|
||||
TimeoutSeconds: 10
|
||||
|
||||
Redis:
|
||||
Addr: ${HAIXUN_REDIS_ADDR}
|
||||
DB: 0
|
||||
|
||||
Auth:
|
||||
AccessSecret: ${HAIXUN_AUTH_ACCESS_SECRET}
|
||||
RefreshSecret: ${HAIXUN_AUTH_REFRESH_SECRET}
|
||||
AccessExpireSeconds: 900
|
||||
RefreshExpireSeconds: 2592000
|
||||
DevHeaderFallback: false
|
||||
|
||||
InternalWorker:
|
||||
Secret: ${HAIXUN_WORKER_SECRET}
|
||||
|
||||
JobWorker:
|
||||
Enabled: true
|
||||
WorkerType: go
|
||||
|
||||
JobScheduler:
|
||||
Enabled: false
|
||||
IntervalSeconds: 60
|
||||
|
||||
JobReaper:
|
||||
Enabled: false
|
||||
IntervalSeconds: 30
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
name: haixun-prod
|
||||
|
||||
services:
|
||||
mongo:
|
||||
image: mongo:7
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: haixun
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
healthcheck:
|
||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 15s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
command: ["redis-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 12
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.api
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:8890/api/v1/health >/dev/null || exit 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 12
|
||||
start_period: 20s
|
||||
|
||||
go-worker:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.api
|
||||
restart: unless-stopped
|
||||
entrypoint: ["/app/deploy/docker/entrypoint-worker.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
node-worker-style-8d:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.node-worker
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
HAIXUN_BACKEND_URL: http://api:8890
|
||||
HAIXUN_WORKER_SECRET: ${HAIXUN_WORKER_SECRET}
|
||||
HAIXUN_NODE_WORKER_ID: ${HAIXUN_NODE_WORKER_ID:-}
|
||||
HAIXUN_WORKER_POLL_MS: ${HAIXUN_WORKER_POLL_MS:-3000}
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.web
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${HAIXUN_WEB_PORT:-8080}:80"
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
|
||||
init:
|
||||
profiles: ["init"]
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile.api
|
||||
entrypoint: ["/app/deploy/docker/entrypoint-init.sh"]
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
volumes:
|
||||
mongo_data:
|
||||
redis_data:
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
|
||||
: "${HAIXUN_MONGO_DATABASE:=haixun}"
|
||||
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
|
||||
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
|
||||
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
|
||||
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
|
||||
|
||||
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
|
||||
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
|
||||
|
||||
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
|
||||
exec /app/haixun-api -f /tmp/gateway.runtime.yaml
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
|
||||
: "${HAIXUN_MONGO_DATABASE:=haixun}"
|
||||
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
|
||||
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
|
||||
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
|
||||
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
|
||||
: "${INIT_TENANT_ID:=default}"
|
||||
: "${INIT_ADMIN_EMAIL:=admin@haixun.local}"
|
||||
: "${INIT_ADMIN_PASSWORD:?INIT_ADMIN_PASSWORD is required}"
|
||||
|
||||
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
|
||||
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
|
||||
|
||||
envsubst < /app/deploy/config/gateway.runtime.yaml.tpl > /tmp/gateway.runtime.yaml
|
||||
exec /app/haixun-tool init \
|
||||
-f /tmp/gateway.runtime.yaml \
|
||||
-tenant "$INIT_TENANT_ID" \
|
||||
-email "$INIT_ADMIN_EMAIL" \
|
||||
-password "$INIT_ADMIN_PASSWORD"
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${HAIXUN_MONGO_URI:=mongodb://mongo:27017}"
|
||||
: "${HAIXUN_MONGO_DATABASE:=haixun}"
|
||||
: "${HAIXUN_REDIS_ADDR:=redis:6379}"
|
||||
: "${HAIXUN_AUTH_ACCESS_SECRET:?HAIXUN_AUTH_ACCESS_SECRET is required}"
|
||||
: "${HAIXUN_AUTH_REFRESH_SECRET:?HAIXUN_AUTH_REFRESH_SECRET is required}"
|
||||
: "${HAIXUN_WORKER_SECRET:?HAIXUN_WORKER_SECRET is required}"
|
||||
|
||||
export HAIXUN_MONGO_URI HAIXUN_MONGO_DATABASE HAIXUN_REDIS_ADDR
|
||||
export HAIXUN_AUTH_ACCESS_SECRET HAIXUN_AUTH_REFRESH_SECRET HAIXUN_WORKER_SECRET
|
||||
|
||||
envsubst < /app/deploy/config/gateway.worker.runtime.yaml.tpl > /tmp/gateway.worker.runtime.yaml
|
||||
exec /app/haixun-worker -f /tmp/gateway.worker.runtime.yaml
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8890;
|
||||
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_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
Name: haixun-backend
|
||||
Host: 0.0.0.0
|
||||
Port: 8890
|
||||
Timeout: 120000
|
||||
|
||||
Mongo:
|
||||
URI: mongodb://mongo:27017
|
||||
Database: haixun
|
||||
TimeoutSeconds: 10
|
||||
|
||||
Redis:
|
||||
Addr: redis:6379
|
||||
DB: 0
|
||||
|
||||
Auth:
|
||||
AccessSecret: change-me-in-prod
|
||||
RefreshSecret: change-me-in-prod-too
|
||||
AccessExpireSeconds: 900
|
||||
RefreshExpireSeconds: 2592000
|
||||
DevHeaderFallback: false
|
||||
|
||||
InternalWorker:
|
||||
Secret: change-me-worker-secret
|
||||
|
||||
JobWorker:
|
||||
Enabled: false
|
||||
WorkerType: go
|
||||
|
||||
JobScheduler:
|
||||
Enabled: true
|
||||
IntervalSeconds: 60
|
||||
|
||||
JobReaper:
|
||||
Enabled: true
|
||||
IntervalSeconds: 30
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
Name: haixun-worker
|
||||
Host: 0.0.0.0
|
||||
Port: 8891
|
||||
Timeout: 120000
|
||||
|
||||
Mongo:
|
||||
URI: mongodb://mongo:27017
|
||||
Database: haixun
|
||||
TimeoutSeconds: 10
|
||||
|
||||
Redis:
|
||||
Addr: redis:6379
|
||||
DB: 0
|
||||
|
||||
Auth:
|
||||
AccessSecret: change-me-in-prod
|
||||
RefreshSecret: change-me-in-prod-too
|
||||
AccessExpireSeconds: 900
|
||||
RefreshExpireSeconds: 2592000
|
||||
DevHeaderFallback: false
|
||||
|
||||
InternalWorker:
|
||||
Secret: change-me-worker-secret
|
||||
|
||||
JobWorker:
|
||||
Enabled: true
|
||||
WorkerType: go
|
||||
|
||||
JobScheduler:
|
||||
Enabled: false
|
||||
IntervalSeconds: 60
|
||||
|
||||
JobReaper:
|
||||
Enabled: false
|
||||
IntervalSeconds: 30
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.prod.yml"
|
||||
|
||||
cd "$BACKEND_DIR/deploy"
|
||||
docker compose -f "$COMPOSE_FILE" down
|
||||
echo "[prod] stopped"
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
COMPOSE_FILE="$BACKEND_DIR/deploy/docker-compose.prod.yml"
|
||||
|
||||
cd "$BACKEND_DIR/deploy"
|
||||
docker compose -f "$COMPOSE_FILE" logs -f --tail=200 "${@:-}"
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BACKEND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
DEPLOY_DIR="$BACKEND_DIR/deploy"
|
||||
COMPOSE_FILE="$DEPLOY_DIR/docker-compose.prod.yml"
|
||||
ENV_FILE="$DEPLOY_DIR/.env"
|
||||
ENV_EXAMPLE="$DEPLOY_DIR/.env.example"
|
||||
|
||||
if [[ ! -f "$ENV_FILE" ]]; then
|
||||
if [[ -f "$ENV_EXAMPLE" ]]; then
|
||||
cp "$ENV_EXAMPLE" "$ENV_FILE"
|
||||
echo "[prod] created $ENV_FILE from .env.example — 請先修改密鑰與管理員密碼"
|
||||
else
|
||||
echo "[prod] missing $ENV_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
|
||||
GO_REPLICAS="${GO_WORKER_REPLICAS:-1}"
|
||||
NODE_REPLICAS="${NODE_STYLE8D_WORKER_REPLICAS:-1}"
|
||||
WEB_PORT="${HAIXUN_WEB_PORT:-8080}"
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[prod] docker is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$DEPLOY_DIR"
|
||||
|
||||
echo "[prod] building images..."
|
||||
docker compose -f "$COMPOSE_FILE" build
|
||||
|
||||
echo "[prod] starting mongo + redis..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d mongo redis
|
||||
|
||||
echo "[prod] waiting for mongo/redis..."
|
||||
for _ in $(seq 1 90); do
|
||||
mongo_ok=$(docker compose -f "$COMPOSE_FILE" ps --format json mongo 2>/dev/null | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
||||
redis_ok=$(docker compose -f "$COMPOSE_FILE" ps --format json redis 2>/dev/null | grep -o '"Health":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
||||
if [[ "$mongo_ok" == "healthy" && "$redis_ok" == "healthy" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
if [[ "${HAIXUN_SKIP_INIT:-0}" != "1" ]]; then
|
||||
echo "[prod] running bootstrap init..."
|
||||
docker compose -f "$COMPOSE_FILE" --profile init run --rm init
|
||||
else
|
||||
echo "[prod] skip init (HAIXUN_SKIP_INIT=1)"
|
||||
fi
|
||||
|
||||
echo "[prod] starting api, web, workers (go=${GO_REPLICAS}, node-style-8d=${NODE_REPLICAS})..."
|
||||
docker compose -f "$COMPOSE_FILE" up -d \
|
||||
--scale "go-worker=${GO_REPLICAS}" \
|
||||
--scale "node-worker-style-8d=${NODE_REPLICAS}" \
|
||||
api web go-worker node-worker-style-8d
|
||||
|
||||
echo "[prod] waiting for API health..."
|
||||
for _ in $(seq 1 60); do
|
||||
if curl -fsS "http://127.0.0.1:${WEB_PORT}/api/v1/health" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "[prod] stack is up"
|
||||
echo " Web: http://127.0.0.1:${WEB_PORT}"
|
||||
echo " API: http://127.0.0.1:${WEB_PORT}/api/v1/health (via nginx)"
|
||||
echo " Go worker: ${GO_REPLICAS} replica(s)"
|
||||
echo " Node 8D: ${NODE_REPLICAS} replica(s)"
|
||||
echo " Env: ${ENV_FILE}"
|
||||
echo " Stop: make -C haixun-backend prod-down"
|
||||
echo " Logs: make -C haixun-backend prod-logs"
|
||||
|
|
@ -2,84 +2,163 @@
|
|||
@import "taipei-sans-tc/dist/Regular/TaipeiSansTCBeta-Regular.css";
|
||||
@import "taipei-sans-tc/dist/Bold/TaipeiSansTCBeta-Bold.css";
|
||||
|
||||
/* ══ 淺色:沉穩田園色(低飽和、無貼圖感) ══ */
|
||||
/* ══ Pokémon palette — light(薄荷綠/灰藍) ══ */
|
||||
:root,
|
||||
[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--hx-canvas: #d8e2e8;
|
||||
--hx-canvas-grass: #d4dfd4;
|
||||
--hx-surface: #faf7f2;
|
||||
--hx-surface-muted: #f1ece4;
|
||||
--hx-ink: #3a3530;
|
||||
--hx-ink-secondary: #5a554e;
|
||||
--hx-muted: #5a6578;
|
||||
--hx-subtle: #8a847c;
|
||||
--hx-wood: #c4a882;
|
||||
--hx-wood-dark: #9a7d5c;
|
||||
--hx-wood-deep: #6f5a44;
|
||||
--hx-line: #ddd4c8;
|
||||
--hx-brand: #5a8f7b;
|
||||
--hx-brand-hover: #4d7f6c;
|
||||
--hx-brand-shadow: #3f6b59;
|
||||
--hx-brand-soft: #e6f0eb;
|
||||
--hx-glow: #dce8ee;
|
||||
--hx-glow-alt: #ebe4d8;
|
||||
--hx-accent: #6a8fa0;
|
||||
--hx-accent-hover: #5a7f90;
|
||||
--hx-accent-soft: #e8eff3;
|
||||
--hx-device: #6a9488;
|
||||
--hx-device-dark: #4f7568;
|
||||
--hx-success: #4a7f5e;
|
||||
--hx-success-soft: #e4efe8;
|
||||
--hx-warning: #9a7340;
|
||||
--hx-warning-soft: #f2ead8;
|
||||
--hx-danger: #b05a50;
|
||||
--hx-danger-soft: #f5e6e4;
|
||||
--hx-shadow-soft: 0 8px 28px -8px rgb(58 53 48 / 0.14);
|
||||
--hx-shadow-card: 0 1px 2px rgb(58 53 48 / 0.06), 0 6px 20px -4px rgb(58 53 48 / 0.1);
|
||||
--hx-hero-gradient: linear-gradient(165deg, #eef3f0 0%, #faf7f2 60%, #f1ece4 100%);
|
||||
--background: #fcfdfc;
|
||||
--foreground: #15281f;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #15281f;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #15281f;
|
||||
--primary: #bdcdc5;
|
||||
--primary-foreground: #0a0a0a;
|
||||
--secondary: #7b94a4;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #f2f5f7;
|
||||
--muted-foreground: #627784;
|
||||
--accent: #4a628b;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #ef4444;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #e0ebe6;
|
||||
--input: #e0ebe6;
|
||||
--ring: #99b2a6;
|
||||
--chart-1: #bdcdc5;
|
||||
--chart-2: #7b94a4;
|
||||
--chart-3: #4a628b;
|
||||
--chart-4: #bdcdc5;
|
||||
--chart-5: #7b94a4;
|
||||
--sidebar: #ffffff;
|
||||
--sidebar-foreground: #15281f;
|
||||
--sidebar-primary: #bdcdc5;
|
||||
--sidebar-primary-foreground: #0a0a0a;
|
||||
--sidebar-accent: #f2f5f7;
|
||||
--sidebar-accent-foreground: #15281f;
|
||||
--sidebar-border: #e0ebe6;
|
||||
--sidebar-ring: #99b2a6;
|
||||
|
||||
/* hx semantic — accent 作 CTA/導覽主色,primary 作柔和底色 */
|
||||
--hx-canvas: #c5d4d8;
|
||||
--hx-canvas-grass: #b8cdc4;
|
||||
--hx-surface: var(--card);
|
||||
--hx-surface-muted: var(--muted);
|
||||
--hx-ink: var(--foreground);
|
||||
--hx-ink-secondary: #2a4035;
|
||||
--hx-muted: var(--muted-foreground);
|
||||
--hx-subtle: #8a9aa4;
|
||||
--hx-wood: color-mix(in srgb, var(--primary) 45%, var(--border) 55%);
|
||||
--hx-wood-dark: color-mix(in srgb, var(--accent) 40%, var(--secondary) 60%);
|
||||
--hx-wood-deep: #2d3f52;
|
||||
--hx-line: var(--border);
|
||||
--hx-brand: var(--accent);
|
||||
--hx-brand-hover: #3d5275;
|
||||
--hx-brand-shadow: #32486a;
|
||||
--hx-brand-soft: color-mix(in srgb, var(--primary) 55%, var(--background) 45%);
|
||||
--hx-on-brand: var(--accent-foreground);
|
||||
--hx-glow: color-mix(in srgb, var(--primary) 50%, var(--background) 50%);
|
||||
--hx-glow-alt: color-mix(in srgb, var(--secondary) 24%, var(--muted) 76%);
|
||||
--hx-accent: var(--secondary);
|
||||
--hx-accent-hover: #6a8494;
|
||||
--hx-accent-soft: color-mix(in srgb, var(--secondary) 16%, var(--muted) 84%);
|
||||
--hx-device: var(--accent);
|
||||
--hx-device-dark: var(--hx-brand-shadow);
|
||||
--hx-success: #4a8a72;
|
||||
--hx-success-soft: color-mix(in srgb, var(--primary) 35%, var(--muted) 65%);
|
||||
--hx-warning: #c4923a;
|
||||
--hx-warning-soft: #f8f0e4;
|
||||
--hx-danger: var(--destructive);
|
||||
--hx-danger-soft: #fde8e8;
|
||||
--hx-shadow-soft: 0 8px 28px -8px rgb(21 40 31 / 0.14);
|
||||
--hx-shadow-card: 0 1px 2px rgb(21 40 31 / 0.06), 0 6px 20px -4px rgb(21 40 31 / 0.1);
|
||||
--hx-hero-gradient: linear-gradient(
|
||||
165deg,
|
||||
color-mix(in srgb, var(--primary) 42%, var(--background) 58%) 0%,
|
||||
var(--card) 58%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
--pocket-width: 28rem;
|
||||
--sidebar-width: 15.5rem;
|
||||
--pocket-screen-height: min(50rem, calc(100dvh - 6.5rem));
|
||||
}
|
||||
|
||||
/* ══ 深色:黃昏低對比 ══ */
|
||||
/* ══ Pokémon palette — dark(薄荷綠/灰藍) ══ */
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--hx-canvas: #1e2a32;
|
||||
--hx-canvas-grass: #243028;
|
||||
--hx-surface: #2c363c;
|
||||
--hx-surface-muted: #354048;
|
||||
--hx-ink: #ece6dc;
|
||||
--hx-ink-secondary: #c8c0b4;
|
||||
--hx-muted: #b8c4d6;
|
||||
--hx-subtle: #8a8278;
|
||||
--hx-wood: #6a5a48;
|
||||
--hx-wood-dark: #524438;
|
||||
--hx-wood-deep: #3a3028;
|
||||
--hx-line: #4a544c;
|
||||
--hx-brand: #7aab96;
|
||||
--hx-brand-hover: #8abba6;
|
||||
--hx-brand-shadow: #4f7568;
|
||||
--hx-brand-soft: #2a3c34;
|
||||
--hx-glow: #2a3840;
|
||||
--hx-glow-alt: #3a342c;
|
||||
--hx-accent: #7a9aa8;
|
||||
--hx-accent-hover: #8aaab8;
|
||||
--hx-accent-soft: #2a3840;
|
||||
--hx-device: #5a8070;
|
||||
--hx-device-dark: #3f5a50;
|
||||
--hx-success: #7aab96;
|
||||
--hx-success-soft: #2a3c34;
|
||||
--hx-warning: #c8a060;
|
||||
--hx-warning-soft: #3a3428;
|
||||
--hx-danger: #c89088;
|
||||
--hx-danger-soft: #3c2c2c;
|
||||
--hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.4);
|
||||
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.2), 0 6px 20px -4px rgb(0 0 0 / 0.32);
|
||||
--hx-hero-gradient: linear-gradient(165deg, #2a3840 0%, #2c363c 60%, #354048 100%);
|
||||
--background: #141a17;
|
||||
--foreground: #f4f6f5;
|
||||
--card: #1b2721;
|
||||
--card-foreground: #f4f6f5;
|
||||
--popover: #1b2721;
|
||||
--popover-foreground: #f4f6f5;
|
||||
--primary: #bccdc4;
|
||||
--primary-foreground: #0a0a0a;
|
||||
--secondary: #7b94a3;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #253037;
|
||||
--muted-foreground: #98a9b3;
|
||||
--accent: #6983b0;
|
||||
--accent-foreground: #ffffff;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #ffffff;
|
||||
--border: #2d4338;
|
||||
--input: #2d4338;
|
||||
--ring: #a8bdb3;
|
||||
--chart-1: #bdcdc5;
|
||||
--chart-2: #7b94a4;
|
||||
--chart-3: #4a628b;
|
||||
--chart-4: #bdcdc5;
|
||||
--chart-5: #7b94a4;
|
||||
--sidebar: #1b2721;
|
||||
--sidebar-foreground: #f4f6f5;
|
||||
--sidebar-primary: #bccdc4;
|
||||
--sidebar-primary-foreground: #0a0a0a;
|
||||
--sidebar-accent: #253037;
|
||||
--sidebar-accent-foreground: #f4f6f5;
|
||||
--sidebar-border: #2d4338;
|
||||
--sidebar-ring: #a8bdb3;
|
||||
|
||||
--hx-canvas: var(--background);
|
||||
--hx-canvas-grass: color-mix(in srgb, var(--muted) 68%, var(--background) 32%);
|
||||
--hx-surface: var(--card);
|
||||
--hx-surface-muted: var(--muted);
|
||||
--hx-ink: var(--foreground);
|
||||
--hx-ink-secondary: #c5d0cc;
|
||||
--hx-muted: var(--muted-foreground);
|
||||
--hx-subtle: #6f858f;
|
||||
--hx-wood: color-mix(in srgb, var(--border) 65%, var(--secondary) 35%);
|
||||
--hx-wood-dark: color-mix(in srgb, var(--accent) 38%, var(--background) 62%);
|
||||
--hx-wood-deep: #1a2a38;
|
||||
--hx-line: var(--border);
|
||||
--hx-brand: var(--accent);
|
||||
--hx-brand-hover: #7a94be;
|
||||
--hx-brand-shadow: #4e6890;
|
||||
--hx-brand-soft: color-mix(in srgb, var(--primary) 18%, var(--muted) 82%);
|
||||
--hx-on-brand: var(--accent-foreground);
|
||||
--hx-glow: color-mix(in srgb, var(--accent) 14%, var(--background) 86%);
|
||||
--hx-glow-alt: color-mix(in srgb, var(--muted) 52%, var(--background) 48%);
|
||||
--hx-accent: var(--secondary);
|
||||
--hx-accent-hover: #8fa8b8;
|
||||
--hx-accent-soft: color-mix(in srgb, var(--secondary) 14%, var(--muted) 86%);
|
||||
--hx-device: var(--accent);
|
||||
--hx-device-dark: var(--hx-brand-shadow);
|
||||
--hx-success: #6a9a88;
|
||||
--hx-success-soft: color-mix(in srgb, var(--primary) 14%, var(--muted) 86%);
|
||||
--hx-warning: #d4a84a;
|
||||
--hx-warning-soft: color-mix(in srgb, #d4a84a 14%, var(--muted) 86%);
|
||||
--hx-danger: var(--destructive);
|
||||
--hx-danger-soft: color-mix(in srgb, var(--destructive) 18%, var(--muted) 82%);
|
||||
--hx-shadow-soft: 0 8px 28px -8px rgb(0 0 0 / 0.42);
|
||||
--hx-shadow-card: 0 1px 2px rgb(0 0 0 / 0.22), 0 6px 20px -4px rgb(0 0 0 / 0.34);
|
||||
--hx-hero-gradient: linear-gradient(
|
||||
165deg,
|
||||
color-mix(in srgb, var(--accent) 14%, var(--card) 86%) 0%,
|
||||
var(--card) 55%,
|
||||
var(--muted) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
@ -1656,7 +1735,7 @@ h4 {
|
|||
}
|
||||
|
||||
.hx-source-chip__avatar {
|
||||
--hx-source-hue: 160;
|
||||
--hx-source-hue: 168;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
|
@ -1747,7 +1826,7 @@ h4 {
|
|||
}
|
||||
|
||||
.hx-source-preview__avatar {
|
||||
--hx-source-hue: 160;
|
||||
--hx-source-hue: 168;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
|
|
@ -2452,7 +2531,7 @@ th {
|
|||
background: color-mix(in srgb, var(--hx-surface) 90%, transparent 10%);
|
||||
backdrop-filter: blur(14px);
|
||||
-webkit-backdrop-filter: blur(14px);
|
||||
box-shadow: 4px 0 24px -12px rgb(58 53 48 / 0.12);
|
||||
box-shadow: 4px 0 24px -12px rgb(21 40 31 / 0.12);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
|
|
@ -2680,7 +2759,7 @@ th {
|
|||
border: 1px solid var(--hx-brand-shadow);
|
||||
background: linear-gradient(180deg, var(--hx-device) 0%, var(--hx-device-dark) 100%);
|
||||
padding: 0.7rem 1.1rem;
|
||||
color: #faf7f2;
|
||||
color: var(--hx-on-brand);
|
||||
font-family: var(--font-zh);
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
|
|
@ -3045,8 +3124,8 @@ th {
|
|||
box-shadow:
|
||||
inset 0 1px 0 rgb(255 255 255 / 0.22),
|
||||
inset 0 -2px 4px rgb(0 0 0 / 0.12),
|
||||
0 14px 36px -10px rgb(58 53 48 / 0.28),
|
||||
0 4px 14px rgb(58 53 48 / 0.12);
|
||||
0 14px 36px -10px rgb(21 40 31 / 0.28),
|
||||
0 4px 14px rgb(21 40 31 / 0.12);
|
||||
}
|
||||
|
||||
.ac-pocket-device::before {
|
||||
|
|
@ -3084,7 +3163,7 @@ th {
|
|||
border: 1px solid color-mix(in srgb, var(--hx-line) 85%, var(--hx-device-dark) 15%);
|
||||
background: var(--hx-surface);
|
||||
box-shadow:
|
||||
inset 0 2px 10px rgb(58 53 48 / 0.07),
|
||||
inset 0 2px 10px rgb(21 40 31 / 0.07),
|
||||
inset 0 0 0 1px rgb(255 255 255 / 0.55);
|
||||
}
|
||||
|
||||
|
|
@ -3259,9 +3338,9 @@ th {
|
|||
.ac-btn-primary {
|
||||
border: 1px solid var(--hx-brand-shadow);
|
||||
background: var(--hx-brand);
|
||||
color: #faf7f2;
|
||||
color: var(--hx-on-brand);
|
||||
font-weight: 600;
|
||||
box-shadow: 0 1px 2px rgb(58 53 48 / 0.12);
|
||||
box-shadow: 0 1px 2px rgb(21 40 31 / 0.12);
|
||||
}
|
||||
|
||||
.ac-btn-primary:hover {
|
||||
|
|
@ -3288,7 +3367,7 @@ th {
|
|||
.ac-btn-danger {
|
||||
border: 1px solid #8a4840;
|
||||
background: var(--hx-danger);
|
||||
color: #faf7f2;
|
||||
color: var(--hx-on-brand);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
|
@ -3333,7 +3412,7 @@ th {
|
|||
.ac-dock {
|
||||
background: var(--hx-surface);
|
||||
border-top: 2px solid var(--hx-line);
|
||||
box-shadow: 0 -4px 16px rgb(58 53 48 / 0.08);
|
||||
box-shadow: 0 -4px 16px rgb(21 40 31 / 0.08);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ac-dock {
|
||||
|
|
@ -3496,7 +3575,7 @@ th {
|
|||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.2;
|
||||
color: #faf7f2;
|
||||
color: var(--hx-on-brand);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
|
@ -3552,7 +3631,7 @@ th {
|
|||
font-size: 0.625rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
color: #faf7f2;
|
||||
color: var(--hx-on-brand);
|
||||
box-shadow: var(--hx-shadow-soft);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
|
|
@ -3567,7 +3646,7 @@ th {
|
|||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.2;
|
||||
color: #faf7f2;
|
||||
color: var(--hx-on-brand);
|
||||
white-space: nowrap;
|
||||
box-shadow: var(--hx-shadow-soft);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "haixun-node-worker",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"style-8d": "tsx style-8d-worker.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "^1.49.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue