diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fff8c1f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +# 依赖与构建产物(镜像内重新构建) +**/node_modules +web/dist +admin/dist +bin/ +server/internal/webfs/dist/* +!server/internal/webfs/dist/index.html +server/internal/webfs/admin_dist/* +!server/internal/webfs/admin_dist/index.html + +# 本地/敏感 +.env +*.db +.git +design-assets/ +.DS_Store diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..fd86b82 --- /dev/null +++ b/.env.production.example @@ -0,0 +1,25 @@ +# 部署机 192.168.100.132:/home/workspace/sundynix-site/.env +# 一次性手动放置,CI 不覆盖它(敏感信息不进流水线) +# compose 的 env_file 读取本文件 + +# 内网 MySQL(127 那台),库名 sundynix_site +SUNDYNIX_DB=root:sundynix@tcp(192.168.100.127:3307)/sundynix_site?charset=utf8mb4&parseTime=True&loc=Local + +# 监听地址(容器内固定 8090,勿改,与 compose ports 对应) +SUNDYNIX_ADDR=:8090 + +# 管理端账号(务必改掉默认) +SUNDYNIX_ADMIN_USER=admin +SUNDYNIX_ADMIN_PASS=请改成强密码 + +# JWT 密钥(openssl rand -hex 32 生成一个填进来) +SUNDYNIX_JWT_SECRET=请填32字节以上随机串 + +# 雪花节点号(多实例时区分) +SUNDYNIX_NODE_ID=1 + +# 对外站点地址(RSS / OG 链接用) +SUNDYNIX_SITE_URL=https://site.sundynix.cn + +# 版本回退(gitea 无 release 时官网显示的版本) +SUNDYNIX_FALLBACK_VERSION=v0.1.2 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..51a6f80 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,67 @@ +name: build-and-deploy + +on: + push: + branches: [dev] + workflow_dispatch: + +env: + IMAGE: sundynix-site:latest + REMOTE_DIR: /home/workspace/sundynix-site + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - name: 检出代码 + uses: actions/checkout@v4 + + - name: 构建镜像 + run: docker build -t "$IMAGE" . + + - name: 导出镜像为压缩包 + run: docker save "$IMAGE" | gzip > image.tar.gz + + - name: 安装 ssh 工具 + run: | + if command -v apt-get >/dev/null; then + apt-get update && apt-get install -y sshpass openssh-client + elif command -v apk >/dev/null; then + apk add --no-cache sshpass openssh-client + fi + + - name: 传输镜像与 compose 到部署机 + env: + SSHPASS: ${{ secrets.DEPLOY_PASSWORD }} + run: | + H="${{ secrets.DEPLOY_HOST }}" + U="${{ secrets.DEPLOY_USER }}" + sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "mkdir -p $REMOTE_DIR" + sshpass -e scp -o StrictHostKeyChecking=no \ + image.tar.gz docker-compose.yml "$U@$H:$REMOTE_DIR/" + + - name: 部署机加载镜像并重启 + env: + SSHPASS: ${{ secrets.DEPLOY_PASSWORD }} + run: | + H="${{ secrets.DEPLOY_HOST }}" + U="${{ secrets.DEPLOY_USER }}" + sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\ + cd $REMOTE_DIR && \ + gunzip -c image.tar.gz | docker load && \ + rm -f image.tar.gz && \ + docker compose up -d && \ + docker image prune -f" + + - name: 健康检查 + env: + SSHPASS: ${{ secrets.DEPLOY_PASSWORD }} + run: | + H="${{ secrets.DEPLOY_HOST }}" + U="${{ secrets.DEPLOY_USER }}" + sshpass -e ssh -o StrictHostKeyChecking=no "$U@$H" "\ + for i in \$(seq 1 15); do \ + wget -qO- http://127.0.0.1:8090/api/healthz && exit 0; \ + sleep 2; \ + done; \ + echo '健康检查失败'; docker logs --tail 50 sundynix-site; exit 1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..019541b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# ── 1. 构建前端(web 用户端 + admin 管理端)── +FROM node:20-alpine AS web +WORKDIR /app/web +COPY web/package*.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build + +FROM node:20-alpine AS admin +WORKDIR /app/admin +COPY admin/package*.json ./ +RUN npm ci +COPY admin/ ./ +RUN npm run build + +# ── 2. 编译 Go(embed 两份前端产物)── +FROM golang:1.26-alpine AS server +WORKDIR /app/server +COPY server/go.mod server/go.sum ./ +RUN go mod download +COPY server/ ./ +# 用真实构建产物替换 embed 占位目录 +COPY --from=web /app/web/dist ./internal/webfs/dist +COPY --from=admin /app/admin/dist ./internal/webfs/admin_dist +RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ + -o /sundynix-site ./cmd + +# ── 3. 运行镜像 ── +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates tzdata && \ + adduser -D -u 10001 app +ENV TZ=Asia/Shanghai +COPY --from=server /sundynix-site /usr/local/bin/sundynix-site +USER app +EXPOSE 8090 +ENTRYPOINT ["/usr/local/bin/sundynix-site"] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..5909c36 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,58 @@ +# 部署说明 + +## 拓扑 + +``` +push dev ──▶ Gitea(192.168.100.125:3000) + │ 触发 .gitea/workflows/deploy.yml + ▼ + act_runner(192.168.100.128) + │ docker build → save → scp + ▼ + 部署机(192.168.100.132) /home/workspace/sundynix-site + │ docker load → compose up → 容器 :8090 + │ │ 连 + │ MySQL(192.168.100.127:3307) + ▼ + 内网穿透 ──▶ 公网服务器 nginx ──▶ https://site.sundynix.cn +``` + +## 一次性准备 + +### 1. Gitea 仓库 Secrets(仓库 → Settings → Actions → Secrets) + +| Secret | 值 | +|--------|-----| +| `DEPLOY_HOST` | `192.168.100.132` | +| `DEPLOY_USER` | `root` | +| `DEPLOY_PASSWORD` | `sundynix` | + +### 2. 部署机 132 准备 + +```bash +# 装好 docker + compose 插件后: +mkdir -p /home/workspace/sundynix-site +cd /home/workspace/sundynix-site +# 放置生产配置(参考仓库 .env.production.example) +vi .env # 改 admin 密码、JWT 密钥 +``` + +`.env` 关键项见 [.env.production.example](../.env.production.example)。数据库指向内网 `192.168.100.127:3307/sundynix_site`(库不存在会自动建表 + 种子)。 + +### 3. act_runner 128 要求 + +- 能访问 docker daemon(`docker build/save` 可用) +- 能 ssh 到 132(workflow 用 sshpass 走密码) + +### 4. 公网服务器 nginx + +1. 内网穿透把 132:8090 映射到公网机本地端口 +2. 用 [nginx-site.sundynix.cn.conf](nginx-site.sundynix.cn.conf) 配置反代,改 `upstream` 为穿透实际端口 +3. 证书:`certbot certonly --webroot -w /var/www/certbot -d site.sundynix.cn` + +## 日常 + +- push 到 `dev` 分支自动构建部署;也可在 Gitea Actions 页手动 `workflow_dispatch` +- 回滚:132 上 `docker tag` 保留的旧镜像,或重跑上一次成功的 commit +- 查日志:`docker logs -f sundynix-site` +- 本地手动出包(不走 CI):仓库根 `docker build -t sundynix-site:latest .` diff --git a/deploy/nginx-site.sundynix.cn.conf b/deploy/nginx-site.sundynix.cn.conf new file mode 100644 index 0000000..b2ff1f1 --- /dev/null +++ b/deploy/nginx-site.sundynix.cn.conf @@ -0,0 +1,65 @@ +# ─────────────────────────────────────────────────────────── +# 公网服务器上的 nginx 配置 +# site.sundynix.cn → 内网穿透隧道 → 192.168.100.132:8090 容器 +# +# upstream 里的地址是「穿透隧道在公网服务器这一侧的入口」: +# frp 场景:frps 把 132:8090 映射到公网机 127.0.0.1:<某端口> +# nps 场景:同理,填映射后的本地端口 +# 请把 127.0.0.1:8090 改成你穿透实际暴露的地址:端口。 +# 放到 /etc/nginx/conf.d/site.sundynix.cn.conf,nginx -t && nginx -s reload +# ─────────────────────────────────────────────────────────── + +upstream sundynix_site { + server 127.0.0.1:8090; # ← 改成穿透隧道的本地入口 + keepalive 16; +} + +# HTTP:证书申请放行 + 其余跳 HTTPS +server { + listen 80; + listen [::]:80; + server_name site.sundynix.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS:反代到穿透隧道 +server { + listen 443 ssl; + listen [::]:443 ssl; + http2 on; + server_name site.sundynix.cn; + + ssl_certificate /etc/letsencrypt/live/site.sundynix.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/site.sundynix.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + ssl_session_cache shared:SSL:10m; + + # 管理端 markdown 正文可能较大 + client_max_body_size 10m; + + # 静态资源带 hash,可长缓存(SPA 的 index.html 不缓存,由后端控制) + location /assets/ { + proxy_pass http://sundynix_site; + proxy_set_header Host $host; + expires 30d; + add_header Cache-Control "public, immutable"; + } + + location / { + proxy_pass http://sundynix_site; + 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 Connection ""; + proxy_read_timeout 60s; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4dc49f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +# 部署机 192.168.100.132:/home/workspace/sundynix-site/ 使用 +# 镜像由 CI 用 docker save + scp + docker load 送入,标签固定 sundynix-site:latest +services: + site: + image: sundynix-site:latest + container_name: sundynix-site + restart: unless-stopped + env_file: + - .env + ports: + # 暴露给本机内网穿透客户端;公网 nginx 经隧道回源到这里 + - "8090:8090" + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8090/api/healthz"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s