diff --git a/.cursor/rules/git-conventions.mdc b/.cursor/rules/git-conventions.mdc index c2c9067..0beeb12 100644 --- a/.cursor/rules/git-conventions.mdc +++ b/.cursor/rules/git-conventions.mdc @@ -29,4 +29,5 @@ chore(shared-types): add OrderStatus enum ## Agent 行为 - **仅用户明确要求时** 才执行 git commit / push -- 不 amend 已推送的 commit,不 force push main/master +- 不 amend 已推送的 commit,不 force push main/master/dev +- 发版:`dev`→测试(staging),`main`→生产;用户只说「发布」默认发测试(见 dukang-release skill) diff --git a/.cursor/skills/dukang-release/SKILL.md b/.cursor/skills/dukang-release/SKILL.md index 61eede8..1fdbeff 100644 --- a/.cursor/skills/dukang-release/SKILL.md +++ b/.cursor/skills/dukang-release/SKILL.md @@ -1,89 +1,89 @@ --- name: dukang-release description: >- - 杜康好客生产发布流程:dev_jacy 提交合并到 dev,并用 deploy.sh 发版。 - Use when the user says 发布、上传、deploy、上线、发版, or @杜康发布 / @dukang-release. + 杜康好客发布流程:dev→测试(staging),main→生产;同机双栈。 + Use when the user says 发布、上传、deploy、上线、发版、发测试、发生产, + or @杜康发布 / @dukang-release. --- # 杜康发布 -将已完成改动发到生产(`aliyun-smarthr`)。**仅用户明确要求发布时执行。** +同机双栈:`/opt/dukang-staging`(测试)与 `/opt/dukang`(生产)。**仅用户明确要求发布时执行。** -## 分支约定 +## 分支与环境 -| 分支 | 用途 | -|------|------| -| `dev_jacy` | 日常开发 / 提交 | -| `dev` | **发布分支**(服务器拉这个) | +| 分支 | 环境 | 目录 | 端口 | 域名 | +|------|------|------|------|------| +| `dev_jacy` | 日常开发 | 本机 | — | — | +| `dev` | **staging 测试** | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` | +| `main` | **production 生产** | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` | -默认:`dev_jacy` → merge → `dev` → 部署。勿 force push `main`/`dev`。 +默认:`dev_jacy` → merge → `dev` → **发测试**。 +生产:`dev` 验收通过 → merge → `main` → **发生产**。勿 force push `main`/`dev`。 + +用户只说「发布」且未指明生产时 → **发测试(staging)**。 +用户说「发生产 / 上线生产」→ 用 `deploy-prod.sh`。 ## 标准流程(Windows) -PowerShell 下 **分支名必须加引号**(否则 `checkout` 与 `dev` 会被粘成 `checkoutdev`)。 +PowerShell 下 **分支名必须加引号**。 -### 1. 提交(在 `dev_jacy`) - -```powershell -git status -sb -git add -git commit -m @" -fix(scope): summary - -Optional body. -"@ -``` - -- Conventional Commits;不提交 `.env` / `dist` / `node_modules` -- 用户未要求 commit 时不要擅自提交 - -### 2. 推送并合并到 `dev` +### A. 发测试(日常) ```powershell +# 1. 在 dev_jacy 提交(用户要求时) git push -u origin HEAD git checkout "dev" git pull origin "dev" git merge "dev_jacy" -m "merge(dev_jacy): <简述>" git push origin "dev" git checkout "dev_jacy" + +# 2. 发 staging +& "C:\Program Files\Git\bin\bash.exe" "/deploy/deploy-staging.sh" -- --skip-db ``` -### 3. 远程发版 - -本机无 WSL bash 时用 **Git Bash**: +### B. 发生产(验收后) ```powershell -& "C:\Program Files\Git\bin\bash.exe" "/deploy/deploy.sh" --branch "dev" -- --skip-db +git checkout "main" +git pull origin "main" +git merge "dev" -m "merge(dev): <简述>" +git push origin "main" +git checkout "dev_jacy" + +& "C:\Program Files\Git\bin\bash.exe" "/deploy/deploy-prod.sh" -- --skip-db ``` - 默认加 `--skip-db`(无迁移时) -- 有 Prisma 迁移时去掉 `--skip-db`,按用户确认是否 `--seed` / `--accept-data-loss` -- `deploy/deploy.env` 提供 `DEPLOY_HOST` 等(勿提交密钥) +- 有 Prisma 迁移:先在 **staging** 跑通(可 `--seed` / `--accept-data-loss`),再生产去掉 `--skip-db` +- Staging 微信:正式号 + 全 Mock(`.env.staging`) +- `deploy/deploy.env` 提供 `DEPLOY_HOST`、`STAGING_APP_ROOT`、`PROD_APP_ROOT`(勿提交密钥) -### 4. 等待完成 +### 首次启用 Staging -后台跑部署时轮询终端输出,成功标志: +服务器一次性:`bash deploy/bootstrap-staging.sh`(见脚本说明)→ 配 `.env.staging` → DNS `*-test` → nginx → `deploy-staging.sh -- --seed` + +### 同步环境变量 + +```powershell +bash deploy/sync-api-env.sh staging +bash deploy/sync-api-env.sh production +``` + +## 等待完成 - 出现 `发版完成` - `exit_code: 0` -- `mini-user(h5): 200`(或对应前端 200) +- `mini-user(h5): 200` / `api: 200` -说明:API 健康检查短暂 `FAIL`/`000` 多为重启中,以 `发版完成` + `exit_code: 0` 为准。 +## 回复用户 -### 5. 回复用户 - -一句说明:已合并 commit、发版结果、建议微信强刷验证。 - -## 常用变体 - -| 场景 | 做法 | -|------|------| -| 仅部署已推送的 `dev` | 跳过 commit/merge,直接跑 `deploy.sh --branch "dev" -- --skip-db` | -| 检查服务器 | `deploy.sh --branch "dev" --check` | -| 含 DB | `deploy.sh --branch "dev" --`(无 skip-db)或按用户给 seed 参数 | +说明:环境(测试/生产)、commit、建议硬刷验证。测试后台有橙色「测试环境」条。 ## 禁止 -- 不 amend 已推送 commit;不 force push `main`/`master`/`dev` -- 不把 `.env` / 私钥打进仓库 -- 用户只说「修好」未说发布时 → **只改代码,不发版** +- 不 amend 已推送 commit;不 force push `main`/`dev` +- 不把 `.env*` 私钥打进仓库 +- **禁止**再用 `dev` 直发生产 +- 用户只说「修好」未说发布 → 只改代码,不发版 diff --git a/.gitignore b/.gitignore index eb4afb0..3ee4448 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ server/dukang-api/.env server/dukang-api/.env.development server/dukang-api/.env.production server/dukang-api/.env.production.local +server/dukang-api/.env.staging +server/dukang-api/.env.staging.local deploy/deploy.env .DS_Store coverage/ diff --git a/AGENTS.md b/AGENTS.md index a150ea3..1b2ceb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,19 @@ C 端门店仅 status=OPEN 订单 Tab:待付款 | 已付款 | 已完成 ``` +## 环境与发版 + +| 环境 | 分支 | 目录 | 端口 | 域名 | +|------|------|------|------|------| +| local | `dev_jacy` | 本机 Docker 6016/6017 | API `:3000` | — | +| **staging 测试** | `dev` | `/opt/dukang-staging` | 8190–8194 | `*-test.dukanghaoke.com` | +| **production 生产** | `main` | `/opt/dukang` | 8090–8094 | `*.dukanghaoke.com` | + +- 日常发布 → `deploy/deploy-staging.sh`(`dev`) +- 生产发布 → `deploy/deploy-prod.sh`(`main`,验收后) +- Staging:正式微信 + 全 Mock;配置见 `.env.staging.example` +- 详情:`@dukang-release` / `.cursor/skills/dukang-release/SKILL.md` + ## 提交与 PR - Conventional Commits:`feat(trade):`、`fix(redeem):`;scope = 端或模块 diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 726ac90..2fd6e51 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -204,6 +204,8 @@ function filterMenuItems(items: MenuProps['items'], permissionKeys: string[]): M .filter(Boolean) as MenuProps['items']; } +const IS_STAGING = import.meta.env.VITE_APP_ENV === 'staging'; + export default function AdminLayout() { const navigate = useNavigate(); const location = useLocation(); @@ -239,16 +241,35 @@ export default function AdminLayout() { return ( + {IS_STAGING ? ( +
+ 测试环境 · staging · 数据与生产隔离 · Mock 开启 +
+ ) : null} -
+
- 杜康 HQ + 杜康 HQ{IS_STAGING ? ' · 测试' : ''}
- +
已存在 $STAGING_ROOT,跳过 clone" +else + if [[ -z "$GIT_REMOTE_URL" ]]; then + if [[ -d "$PROD_ROOT/.git" ]]; then + GIT_REMOTE_URL="$(git -C "$PROD_ROOT" remote get-url origin)" + else + echo "错误: 请设置 GIT_REMOTE_URL,或确保 $PROD_ROOT 为 git 仓库" >&2 + exit 1 + fi + fi + echo "==> clone $GIT_REMOTE_URL → $STAGING_ROOT (branch $GIT_BRANCH)" + git clone --branch "$GIT_BRANCH" "$GIT_REMOTE_URL" "$STAGING_ROOT" +fi + +mkdir -p "$STAGING_ROOT/server/dukang-api" +if [[ ! -f "$STAGING_ROOT/server/dukang-api/.env.staging" ]]; then + if [[ -f "$STAGING_ROOT/server/dukang-api/.env.staging.example" ]]; then + cp "$STAGING_ROOT/server/dukang-api/.env.staging.example" \ + "$STAGING_ROOT/server/dukang-api/.env.staging" + echo "==> 已生成 .env.staging(请编辑 DATABASE_URL / Redis / 微信后 sync 或本机改)" + else + echo "WARN: 无 .env.staging.example,请手动创建 .env.staging" + fi +fi + +echo "==> 下一步:" +echo " 1. 创建 RDS 库 dukang_staging,填好 $STAGING_ROOT/server/dukang-api/.env.staging" +echo " 2. DNS: api-test/user-test/shop-test/partner-test/admin-test.dukanghaoke.com → 本机" +echo " 3. 启用 nginx: ln -sf $STAGING_ROOT/deploy/nginx-dukang-staging.conf /etc/nginx/sites-enabled/" +echo " nginx -t && systemctl reload nginx(证书需含 *-test SAN)" +echo " 4. 本机发版: bash deploy/deploy-staging.sh -- --seed --accept-data-loss" +echo "==> bootstrap 完成" diff --git a/deploy/deploy-prod.sh b/deploy/deploy-prod.sh new file mode 100644 index 0000000..681a392 --- /dev/null +++ b/deploy/deploy-prod.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# 发生产环境:分支默认 main → /opt/dukang(8090–8094) +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +echo "==> 即将发往 PRODUCTION(main → /opt/dukang)" +exec bash "$SCRIPT_DIR/deploy.sh" --env production "$@" diff --git a/deploy/deploy-staging.sh b/deploy/deploy-staging.sh new file mode 100644 index 0000000..72813ad --- /dev/null +++ b/deploy/deploy-staging.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# 发测试环境:分支默认 dev → /opt/dukang-staging(8190–8194) +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +exec bash "$SCRIPT_DIR/deploy.sh" --env staging "$@" diff --git a/deploy/deploy.env.example b/deploy/deploy.env.example index 46842f0..dad6300 100644 --- a/deploy/deploy.env.example +++ b/deploy/deploy.env.example @@ -4,8 +4,14 @@ DEPLOY_USER=root DEPLOY_PORT=22 # DEPLOY_SSH_KEY=~/.ssh/id_rsa +# 同机双栈目录 +PROD_APP_ROOT=/opt/dukang +STAGING_APP_ROOT=/opt/dukang-staging +# 兼容旧字段(未指定 --env 时可能用到) APP_ROOT=/opt/dukang + # 生产仓库(SSH) GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git GIT_REMOTE=origin -GIT_BRANCH=dev +# 默认分支已由 --env 决定:staging→dev,production→main +# GIT_BRANCH=dev diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 3885fde..490a4db 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# 本地执行:SSH 到服务器拉代码并发布 +# 本地执行:SSH 到服务器拉代码并发布(支持 staging / production 同机双栈) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -9,33 +9,35 @@ DEPLOY_HOST="" DEPLOY_USER="root" DEPLOY_PORT="22" DEPLOY_SSH_KEY="" -APP_ROOT="/opt/dukang" +APP_ROOT="" GIT_REMOTE="origin" -GIT_BRANCH="dev" +GIT_BRANCH="" +DUKANG_DEPLOY_ENV="" CHECK_ONLY=false RELEASE_ARGS=() usage() { cat <<'EOF' -用法: deploy/deploy.sh [选项] [-- 远程 release 参数] +用法: deploy/deploy.sh --env staging|production [选项] [-- 远程 release 参数] 环境配置: deploy/deploy.env(可复制 deploy.env.example) 选项: + --env ENV staging(测试)| production(生产)【必填,或用 deploy-staging/prod.sh】 --host HOST 服务器地址(覆盖 deploy.env) --user USER SSH 用户,默认 root --port PORT SSH 端口,默认 22 --key PATH SSH 私钥路径 - --branch BRANCH 发布分支,默认 dev + --branch BRANCH 发布分支(staging 默认 dev,production 默认 main) --check 仅 SSH 连接并查看 PM2 / 端口状态 -h, --help 显示帮助 示例: - ./deploy/deploy.sh - ./deploy/deploy.sh --branch main - ./deploy/deploy.sh -- --skip-db - ./deploy/deploy.sh -- --seed --accept-data-loss + ./deploy/deploy-staging.sh -- --skip-db + ./deploy/deploy-prod.sh -- --skip-db + ./deploy/deploy.sh --env staging --branch dev -- --skip-db + ./deploy/deploy.sh --env production --branch main -- --skip-db EOF } @@ -48,6 +50,7 @@ load_env() { while [[ $# -gt 0 ]]; do case "$1" in + --env) DUKANG_DEPLOY_ENV="$2"; shift 2 ;; --host) DEPLOY_HOST="$2"; shift 2 ;; --user) DEPLOY_USER="$2"; shift 2 ;; --port) DEPLOY_PORT="$2"; shift 2 ;; @@ -66,6 +69,46 @@ done load_env +# 兼容旧调用:未传 --env 时,若 GIT_BRANCH=main 视为生产,否则 staging +if [[ -z "$DUKANG_DEPLOY_ENV" ]]; then + if [[ "${GIT_BRANCH:-}" == "main" || "${GIT_BRANCH:-}" == "master" ]]; then + DUKANG_DEPLOY_ENV=production + else + DUKANG_DEPLOY_ENV=staging + echo "WARN: 未指定 --env,默认按 staging 发测试环境。生产请用 --env production 或 deploy-prod.sh" >&2 + fi +fi + +case "$DUKANG_DEPLOY_ENV" in + staging|stage|test) DUKANG_DEPLOY_ENV=staging ;; + production|prod) DUKANG_DEPLOY_ENV=production ;; + *) + echo "错误: --env 须为 staging 或 production" >&2 + exit 1 + ;; +esac + +if [[ "$DUKANG_DEPLOY_ENV" == "staging" ]]; then + APP_ROOT="${STAGING_APP_ROOT:-${APP_ROOT:-/opt/dukang-staging}}" + # 若 deploy.env 里 APP_ROOT 仍是生产路径,staging 强制用 STAGING_APP_ROOT 或默认 + if [[ -n "${STAGING_APP_ROOT:-}" ]]; then + APP_ROOT="$STAGING_APP_ROOT" + elif [[ "$APP_ROOT" == "/opt/dukang" ]]; then + APP_ROOT="/opt/dukang-staging" + fi + GIT_BRANCH="${GIT_BRANCH:-dev}" + PORT_GREP='819[0-4]' +else + APP_ROOT="${PROD_APP_ROOT:-${APP_ROOT:-/opt/dukang}}" + if [[ -n "${PROD_APP_ROOT:-}" ]]; then + APP_ROOT="$PROD_APP_ROOT" + elif [[ "$APP_ROOT" == "/opt/dukang-staging" ]]; then + APP_ROOT="/opt/dukang" + fi + GIT_BRANCH="${GIT_BRANCH:-main}" + PORT_GREP='809[0-4]' +fi + if [[ -z "$DEPLOY_HOST" ]]; then echo "错误: 未配置 DEPLOY_HOST。请创建 deploy/deploy.env 或使用 --host" >&2 exit 1 @@ -82,8 +125,8 @@ run_remote() { } if [[ "$CHECK_ONLY" == true ]]; then - echo "==> 检查 $TARGET" - run_remote "pm2 list; ss -tlnp | grep -E '809[0-3]' || true" + echo "==> 检查 $TARGET ($DUKANG_DEPLOY_ENV)" + run_remote "pm2 list; ss -tlnp | grep -E '$PORT_GREP' || true" exit 0 fi @@ -92,7 +135,7 @@ if [[ ${#RELEASE_ARGS[@]} -gt 0 ]]; then REMOTE_RELEASE_ARGS="${RELEASE_ARGS[*]}" fi -echo "==> 发布到 $TARGET ($GIT_BRANCH)" +echo "==> 发布到 $TARGET env=$DUKANG_DEPLOY_ENV branch=$GIT_BRANCH root=$APP_ROOT" run_remote bash -s <&2 + echo "错误: \$APP_ROOT 不是 git 仓库。请先 clone 到该目录(staging 建议 /opt/dukang-staging)" >&2 exit 1 fi +echo "==> 拉取代码" +git fetch "\$GIT_REMOTE" +git checkout "\$GIT_BRANCH" +git pull --ff-only "\$GIT_REMOTE" "\$GIT_BRANCH" + if [[ -n "\$RELEASE_ARGS" ]]; then - echo "==> 拉取代码" - git fetch "\$GIT_REMOTE" - git checkout "\$GIT_BRANCH" - git pull --ff-only "\$GIT_REMOTE" "\$GIT_BRANCH" bash deploy/remote-release.sh \$RELEASE_ARGS else - echo "==> 拉取代码并发布(auto-release)" - bash deploy/auto-release.sh + bash deploy/remote-release.sh --skip-db fi EOF -echo "==> 本地发布命令已完成" +echo "==> 本地发布命令已完成 ($DUKANG_DEPLOY_ENV)" diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs index e11a3c0..64b585a 100644 --- a/deploy/ecosystem.config.cjs +++ b/deploy/ecosystem.config.cjs @@ -1,5 +1,5 @@ /** PM2 ecosystem — 杜康好客生产部署 */ -const APP_ROOT = '/opt/dukang'; +const APP_ROOT = process.env.APP_ROOT || '/opt/dukang'; module.exports = { apps: [ @@ -11,6 +11,7 @@ module.exports = { exec_mode: 'fork', env: { NODE_ENV: 'production', + APP_ENV: 'production', PORT: 8090, }, }, @@ -62,6 +63,7 @@ module.exports = { exec_mode: 'fork', env: { NODE_ENV: 'production', + APP_ENV: 'production', }, }, ], diff --git a/deploy/ecosystem.staging.config.cjs b/deploy/ecosystem.staging.config.cjs new file mode 100644 index 0000000..3603f3c --- /dev/null +++ b/deploy/ecosystem.staging.config.cjs @@ -0,0 +1,55 @@ +/** PM2 ecosystem — 杜康好客 Staging(同机双栈,端口 8190–8194) */ +const APP_ROOT = process.env.APP_ROOT || '/opt/dukang-staging'; + +module.exports = { + apps: [ + { + name: 'dukang-stg-api', + cwd: `${APP_ROOT}/server/dukang-api`, + script: 'dist/main.js', + instances: 1, + exec_mode: 'fork', + env: { + NODE_ENV: 'production', + APP_ENV: 'staging', + PORT: 8190, + }, + }, + { + name: 'dukang-stg-h5-user', + cwd: `${APP_ROOT}/apps/mini-user`, + script: 'npx', + args: 'serve dist -l 8191 -c serve.json', + interpreter: 'none', + instances: 1, + exec_mode: 'fork', + }, + { + name: 'dukang-stg-h5-shop', + cwd: `${APP_ROOT}/apps/h5-shop`, + script: 'npx', + args: 'serve -s dist -l 8192', + interpreter: 'none', + instances: 1, + exec_mode: 'fork', + }, + { + name: 'dukang-stg-h5-partner', + cwd: `${APP_ROOT}/apps/h5-partner`, + script: 'npx', + args: 'serve -s dist -l 8193', + interpreter: 'none', + instances: 1, + exec_mode: 'fork', + }, + { + name: 'dukang-stg-admin-web', + cwd: `${APP_ROOT}/apps/admin-web`, + script: 'npx', + args: 'serve -s dist -l 8194', + interpreter: 'none', + instances: 1, + exec_mode: 'fork', + }, + ], +}; diff --git a/deploy/nginx-dukang-staging.conf b/deploy/nginx-dukang-staging.conf new file mode 100644 index 0000000..2976490 --- /dev/null +++ b/deploy/nginx-dukang-staging.conf @@ -0,0 +1,187 @@ +# 杜康好客 — Staging(测试)*-test.dukanghaoke.com → 8190–8194 +# 证书:可与生产共用 user.dukanghaoke.com 证书(SAN 含 *-test),或单独申请 +# 启用:ln -sf /opt/dukang-staging/deploy/nginx-dukang-staging.conf /etc/nginx/sites-enabled/ +# nginx -t && systemctl reload nginx + +# HTTP → HTTPS +server { + listen 80; + server_name user-test.dukanghaoke.com shop-test.dukanghaoke.com partner-test.dukanghaoke.com admin-test.dukanghaoke.com api-test.dukanghaoke.com; + + location ^~ /.well-known/acme-challenge/ { + root /var/www/certbot; + default_type "text/plain"; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# api-test.dukanghaoke.com → 8190 +server { + listen 443 ssl; + http2 on; + server_name api-test.dukanghaoke.com; + + ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + client_max_body_size 50m; + + location / { + proxy_pass http://127.0.0.1:8190; + 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 ""; + } +} + +# user-test.dukanghaoke.com → 8191 +server { + listen 443 ssl; + http2 on; + server_name user-test.dukanghaoke.com; + + ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + client_max_body_size 50m; + + location /api/ { + proxy_pass http://127.0.0.1:8190; + 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 ""; + } + + location / { + proxy_pass http://127.0.0.1:8191; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_cache_bypass $http_upgrade; + } +} + +# shop-test.dukanghaoke.com → 8192 +server { + listen 443 ssl; + http2 on; + server_name shop-test.dukanghaoke.com; + + ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + client_max_body_size 50m; + + location /api/ { + proxy_pass http://127.0.0.1:8190; + 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 ""; + } + + location / { + proxy_pass http://127.0.0.1:8192; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_cache_bypass $http_upgrade; + } +} + +# partner-test.dukanghaoke.com → 8193 +server { + listen 443 ssl; + http2 on; + server_name partner-test.dukanghaoke.com; + + ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + client_max_body_size 50m; + + location /api/ { + proxy_pass http://127.0.0.1:8190; + 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 ""; + } + + location / { + proxy_pass http://127.0.0.1:8193; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_cache_bypass $http_upgrade; + } +} + +# admin-test.dukanghaoke.com → 8194 +server { + listen 443 ssl; + http2 on; + server_name admin-test.dukanghaoke.com; + + ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + client_max_body_size 50m; + + location /api/ { + proxy_pass http://127.0.0.1:8190; + 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 ""; + } + + location / { + proxy_pass http://127.0.0.1:8194; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + 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_cache_bypass $http_upgrade; + } +} diff --git a/deploy/remote-release.sh b/deploy/remote-release.sh index 379a261..eddbe52 100644 --- a/deploy/remote-release.sh +++ b/deploy/remote-release.sh @@ -1,9 +1,50 @@ #!/usr/bin/env bash -# 在服务器上执行:拉取后的常规发版(不覆盖 .env.production、默认不 seed) +# 在服务器上执行:拉取后的常规发版(不覆盖 env 文件、默认不 seed) +# 通过 DUKANG_DEPLOY_ENV=staging|production 区分同机双栈 set -euo pipefail -APP_ROOT="${APP_ROOT:-/opt/dukang}" +DUKANG_DEPLOY_ENV="${DUKANG_DEPLOY_ENV:-production}" +case "$DUKANG_DEPLOY_ENV" in + staging|stage|test) DUKANG_DEPLOY_ENV=staging ;; + production|prod) DUKANG_DEPLOY_ENV=production ;; + *) + echo "错误: DUKANG_DEPLOY_ENV 须为 staging 或 production,当前=$DUKANG_DEPLOY_ENV" >&2 + exit 1 + ;; +esac + +if [[ "$DUKANG_DEPLOY_ENV" == "staging" ]]; then + APP_ROOT="${APP_ROOT:-/opt/dukang-staging}" + ENV_FILE_NAME=".env.staging" + ECOSYSTEM_FILE="ecosystem.staging.config.cjs" + VITE_API_DEFAULT="https://api-test.dukanghaoke.com" + API_PORT=8190 + USER_PORT=8191 + PORT_GREP='819[0-4]' + PM2_API=dukang-stg-api + PM2_USER=dukang-stg-h5-user + PM2_SHOP=dukang-stg-h5-shop + PM2_PARTNER=dukang-stg-h5-partner + PM2_ADMIN=dukang-stg-admin-web +else + APP_ROOT="${APP_ROOT:-/opt/dukang}" + ENV_FILE_NAME=".env.production" + ECOSYSTEM_FILE="ecosystem.config.cjs" + VITE_API_DEFAULT="https://api.dukanghaoke.com" + API_PORT=8090 + USER_PORT=8091 + PORT_GREP='809[0-4]' + PM2_API=dukang-api + PM2_USER=dukang-h5-user + PM2_SHOP=dukang-h5-shop + PM2_PARTNER=dukang-h5-partner + PM2_ADMIN=dukang-admin-web +fi + DEPLOY_DIR="$APP_ROOT/deploy" +export APP_ROOT +export APP_ENV="$DUKANG_DEPLOY_ENV" +export DUKANG_ENV_FILE="$ENV_FILE_NAME" SKIP_BUILD=false SKIP_DB=false @@ -11,9 +52,11 @@ RUN_SEED=false DB_PUSH_ACCEPT_DATA_LOSS=false usage() { - cat <<'EOF' + cat < 发版环境: $DUKANG_DEPLOY_ENV ($APP_ROOT)" + echo "==> 1. 启用 pnpm" corepack enable 2>/dev/null || true corepack prepare pnpm@11.2.2 --activate 2>/dev/null || true @@ -47,15 +92,16 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}" export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}" export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}" # C 端 H5 编译期注入的 API origin(勿落到 localhost) -export VITE_API_TARGET="${VITE_API_TARGET:-https://api.dukanghaoke.com}" +export VITE_API_TARGET="${VITE_API_TARGET:-$VITE_API_DEFAULT}" +export VITE_APP_ENV="$DUKANG_DEPLOY_ENV" pnpm approve-builds --all 2>/dev/null || true pnpm install --frozen-lockfile 2>/dev/null || pnpm install if [[ "$SKIP_DB" == false ]]; then - echo "==> 3. 数据库 schema 同步(构建前,读取 .env.production)" + echo "==> 3. 数据库 schema 同步(构建前,读取 $ENV_FILE_NAME)" cd "$APP_ROOT/server/dukang-api" - if [[ ! -f .env.production ]]; then - echo "错误: 未找到 server/dukang-api/.env.production" >&2 + if [[ ! -f "$ENV_FILE_NAME" ]]; then + echo "错误: 未找到 server/dukang-api/$ENV_FILE_NAME" >&2 exit 1 fi node scripts/with-api-env.cjs pnpm prisma:generate @@ -94,31 +140,30 @@ else fi fi -echo "==> 5. 重启 PM2" -if pm2 describe dukang-api &>/dev/null; then - # delete+start 以确保 cwd 从 h5-user 切到 mini-user - pm2 delete dukang-h5-user 2>/dev/null || true - pm2 start "$DEPLOY_DIR/ecosystem.config.cjs" --only dukang-h5-user - pm2 restart dukang-api dukang-h5-shop dukang-h5-partner dukang-admin-web 2>/dev/null \ - || pm2 restart dukang-api dukang-h5-shop dukang-h5-partner +echo "==> 5. 重启 PM2 ($ECOSYSTEM_FILE)" +if pm2 describe "$PM2_API" &>/dev/null; then + pm2 delete "$PM2_USER" 2>/dev/null || true + pm2 start "$DEPLOY_DIR/$ECOSYSTEM_FILE" --only "$PM2_USER" + pm2 restart "$PM2_API" "$PM2_SHOP" "$PM2_PARTNER" "$PM2_ADMIN" 2>/dev/null \ + || pm2 restart "$PM2_API" "$PM2_SHOP" "$PM2_PARTNER" else - pm2 start "$DEPLOY_DIR/ecosystem.config.cjs" + pm2 start "$DEPLOY_DIR/$ECOSYSTEM_FILE" fi pm2 save echo "==> 6. 健康检查" sleep 2 pm2 list -ss -tlnp | grep -E '809[0-4]' || true -curl -sf -o /dev/null -w "mini-user(h5): %{http_code}\n" http://127.0.0.1:8091/ || echo "mini-user(h5): FAIL" -curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/api/v1/health 2>/dev/null \ - || curl -sf -o /dev/null -w "api: %{http_code}\n" http://127.0.0.1:8090/ \ +ss -tlnp | grep -E "$PORT_GREP" || true +curl -sf -o /dev/null -w "mini-user(h5): %{http_code}\n" "http://127.0.0.1:${USER_PORT}/" || echo "mini-user(h5): FAIL" +curl -sf -o /dev/null -w "api: %{http_code}\n" "http://127.0.0.1:${API_PORT}/api/v1/health" 2>/dev/null \ + || curl -sf -o /dev/null -w "api: %{http_code}\n" "http://127.0.0.1:${API_PORT}/" \ || echo "api: FAIL" echo "==> 7. 记录系统版本" cd "$APP_ROOT/server/dukang-api" -APP_ROOT="$APP_ROOT" DEPLOY_TRIGGER="${DEPLOY_TRIGGER:-manual}" \ +APP_ROOT="$APP_ROOT" DEPLOY_TRIGGER="${DEPLOY_TRIGGER:-manual}" DUKANG_ENV_FILE="$ENV_FILE_NAME" \ node scripts/with-api-env.cjs node scripts/record-system-version.cjs \ || echo "WARN: system_version 写入失败" -echo "==> 发版完成" +echo "==> 发版完成 ($DUKANG_DEPLOY_ENV)" diff --git a/deploy/sync-api-env.sh b/deploy/sync-api-env.sh index e299b20..deda182 100644 --- a/deploy/sync-api-env.sh +++ b/deploy/sync-api-env.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# 同步 API 生产环境变量到服务器 .env.production -# 用法: deploy/sync-api-env.sh [production] +# 同步 API 环境变量到服务器 +# 用法: deploy/sync-api-env.sh staging|production set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -12,16 +12,31 @@ DEPLOY_USER="root" DEPLOY_PORT="22" DEPLOY_SSH_KEY="" APP_ROOT="/opt/dukang" +STAGING_APP_ROOT="/opt/dukang-staging" +PROD_APP_ROOT="/opt/dukang" -TARGET="${1:-production}" +TARGET="${1:-}" case "$TARGET" in - production|prod) SRC_ENV="$REPO_ROOT/server/dukang-api/.env.production" ;; + staging|stage|test) + TARGET=staging + SRC_ENV="$REPO_ROOT/server/dukang-api/.env.staging" + REMOTE_NAME=".env.staging" + PM2_NAME=dukang-stg-api + HEALTH_PORT=8190 + ;; + production|prod) + TARGET=production + SRC_ENV="$REPO_ROOT/server/dukang-api/.env.production" + REMOTE_NAME=".env.production" + PM2_NAME=dukang-api + HEALTH_PORT=8090 + ;; development|dev) - echo "错误: development 仅用于本地开发,请使用 .env / .env.development,不同步到服务器" >&2 + echo "错误: development 仅用于本地开发,请使用 .env,不同步到服务器" >&2 exit 1 ;; *) - echo "用法: $0 [production]" >&2 + echo "用法: $0 staging|production" >&2 exit 1 ;; esac @@ -31,8 +46,14 @@ if [[ -f "$ENV_FILE" ]]; then source "$ENV_FILE" fi +if [[ "$TARGET" == "staging" ]]; then + APP_ROOT="${STAGING_APP_ROOT:-/opt/dukang-staging}" +else + APP_ROOT="${PROD_APP_ROOT:-/opt/dukang}" +fi + if [[ ! -f "$SRC_ENV" ]]; then - echo "错误: 未找到 $SRC_ENV(可从 .env.production.example 复制)" >&2 + echo "错误: 未找到 $SRC_ENV(可从 ${SRC_ENV}.example 复制)" >&2 exit 1 fi @@ -46,7 +67,8 @@ SCP_OPTS=(-o "StrictHostKeyChecking=accept-new" -P "$DEPLOY_PORT") [[ -n "$DEPLOY_SSH_KEY" ]] && SSH_OPTS+=(-i "$DEPLOY_SSH_KEY") && SCP_OPTS+=(-i "$DEPLOY_SSH_KEY") REMOTE="${DEPLOY_USER}@${DEPLOY_HOST}" -echo "==> 同步 production 环境到 $REMOTE:$APP_ROOT/server/dukang-api/.env.production" -scp "${SCP_OPTS[@]}" "$SRC_ENV" "$REMOTE:$APP_ROOT/server/dukang-api/.env.production" -ssh "${SSH_OPTS[@]}" "$REMOTE" "pm2 restart dukang-api && sleep 2 && curl -sf -o /dev/null -w 'api-health:%{http_code}\n' http://127.0.0.1:8090/api/v1/health" +echo "==> 同步 $TARGET 环境到 $REMOTE:$APP_ROOT/server/dukang-api/$REMOTE_NAME" +scp "${SCP_OPTS[@]}" "$SRC_ENV" "$REMOTE:$APP_ROOT/server/dukang-api/$REMOTE_NAME" +ssh "${SSH_OPTS[@]}" "$REMOTE" \ + "pm2 restart $PM2_NAME && sleep 2 && curl -sf -o /dev/null -w 'api-health:%{http_code}\n' http://127.0.0.1:${HEALTH_PORT}/api/v1/health" echo "==> 完成" diff --git a/server/dukang-api/.env.production.example b/server/dukang-api/.env.production.example index c70678f..4771c84 100644 --- a/server/dukang-api/.env.production.example +++ b/server/dukang-api/.env.production.example @@ -1,12 +1,14 @@ # 生产环境(真实第三方,关闭 Mock) -# 用途:服务器 runtime 配置;bash deploy/sync-api-env.sh production 同步到远端 .env.production +# 用途:服务器 /opt/dukang;bash deploy/sync-api-env.sh production # 本地开发请使用 .env(从 .env.example 复制),勿与生产混用 +# 测试环境请使用 .env.staging(从 .env.staging.example 复制) +APP_ENV=production NODE_ENV=production # 生产:阿里云 RDS(密码中的 @ ! 需 URL 编码为 %40 %21) DATABASE_URL="mysql://dukangadmin:CHANGE_ME@rm-xxxxxxxx.mysql.rds.aliyuncs.com:3306/dukang_prod?charset=utf8mb4" -REDIS_URL="redis://localhost:6379" +REDIS_URL="redis://localhost:6379/0" JWT_SECRET="CHANGE_ME-strong-random-secret" JWT_EXPIRES_IN="7d" PORT=8090 diff --git a/server/dukang-api/.env.staging.example b/server/dukang-api/.env.staging.example new file mode 100644 index 0000000..39b3b4e --- /dev/null +++ b/server/dukang-api/.env.staging.example @@ -0,0 +1,67 @@ +# Staging / 测试环境(同机双栈,正式微信 AppId + 全 Mock) +# 用途:服务器 /opt/dukang-staging;sync: bash deploy/sync-api-env.sh staging +# 域名:api-test / user-test / shop-test / partner-test / admin-test.dukanghaoke.com +# 本地开发请用 .env,勿与本文件混用 + +APP_ENV=staging +NODE_ENV=production + +# 独立库(与生产 dukang_prod 隔离;可同 RDS 实例) +DATABASE_URL="mysql://dukangadmin:CHANGE_ME@rm-xxxxxxxx.mysql.rds.aliyuncs.com:3306/dukang_staging?charset=utf8mb4" +# 建议用 Redis DB 1,与生产 DB 0 隔离 +REDIS_URL="redis://localhost:6379/1" +JWT_SECRET="CHANGE_ME-staging-secret-different-from-prod" +JWT_EXPIRES_IN="7d" +PORT=8190 + +# 全 Mock:不发真实短信、不扣真实微信支付 +MOCK_SMS=true +MOCK_PAY=true +MOCK_DELIVERY_AUTO=true +MOCK_WECHAT=true +AUTO_APPROVE_STORE=true + +ALIYUN_SMS_SIGN_NAME= +ALIYUN_SMS_TEMPLATE_CODE= +ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM= +ALIYUN_SMS_TEMPLATE_PROXY_ORDER= +ALIYUN_SMS_ACCESS_KEY_ID= +ALIYUN_SMS_ACCESS_KEY_SECRET= + +TRUST_PROXY=true + +# C 端 H5(测试域) +USER_H5_URL=https://user-test.dukanghaoke.com/user + +# 正式号配置可与生产相同,但 Mock 打开后不走真实支付 +WX_APP_ID= +WX_APP_SECRET= +WX_MINI_APP_ID= +WX_MINI_APP_SECRET= +WX_MINI_PROMO_PAGE=pages/home/index +# WX_MINI_ENV_VERSION=trial +WX_MCH_ID= +WX_MCH_SERIAL_NO= +WX_MCH_PRIVATE_KEY= +WX_API_V3_KEY= +WX_PLATFORM_CERT= +WX_PAY_NOTIFY_URL=https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay +WX_MINI_MSG_TOKEN= +WX_MINI_MSG_AES_KEY= + +WECOM_AIBOT_ENABLED=false + +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= +OSS_BUCKET=dukang-dev +OSS_REGION=oss-cn-beijing +OSS_CDN_BASE=https://dukang-dev.oss-cn-beijing.aliyuncs.com +OSS_UPLOAD_PREFIX=staging/uploads +OSS_UPLOAD_EXPIRE_SECONDS=900 +OSS_MAX_UPLOAD_BYTES=10485760 + +COURIER_PROVIDER=xiaofeixia +XIAOFEIXIA_API_URL=https://beta.51xiaoju.cn/app/api/interface.do +XIAOFEIXIA_MCH_ID= +XIAOFEIXIA_API_KEY= +XIAOFEIXIA_SIGN_TYPE=MD5 diff --git a/server/dukang-api/scripts/gen-staging-env-from-prod.cjs b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs new file mode 100644 index 0000000..2d83289 --- /dev/null +++ b/server/dukang-api/scripts/gen-staging-env-from-prod.cjs @@ -0,0 +1,123 @@ +const fs = require('fs'); +const path = require('path'); + +const apiRoot = path.join(__dirname, '..'); +const prod = fs.readFileSync(path.join(apiRoot, '.env.production'), 'utf8'); +const map = new Map(); +for (const line of prod.split(/\r?\n/)) { + const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (m) map.set(m[1], m[2]); +} + +function unq(v) { + if (!v) return ''; + if ( + (v.startsWith('"') && v.endsWith('"')) || + (v.startsWith("'") && v.endsWith("'")) + ) { + return v.slice(1, -1); + } + return v; +} + +function q(v) { + return JSON.stringify(String(v ?? '')); +} + +const db = unq(map.get('DATABASE_URL') || ''); +if (!db.includes('dukang_prod')) { + console.error('prod DATABASE_URL unexpected, refuse to rewrite'); + process.exit(1); +} +const stagingDb = db.replace('/dukang_prod', '/dukang_staging'); +const jwt = `${unq(map.get('JWT_SECRET') || 'prod')}-staging`; + +const fixed = { + APP_ENV: 'staging', + NODE_ENV: 'production', + DATABASE_URL: stagingDb, + REDIS_URL: 'redis://localhost:6379/1', + JWT_SECRET: jwt, + JWT_EXPIRES_IN: unq(map.get('JWT_EXPIRES_IN') || '7d'), + PORT: '8190', + MOCK_SMS: 'true', + MOCK_PAY: 'true', + MOCK_DELIVERY_AUTO: 'true', + MOCK_WECHAT: 'true', + AUTO_APPROVE_STORE: 'true', + TRUST_PROXY: 'true', + USER_H5_URL: 'https://user-test.dukanghaoke.com/user', + WX_PAY_NOTIFY_URL: 'https://api-test.dukanghaoke.com/api/v1/callbacks/wechat/pay', + OSS_UPLOAD_PREFIX: 'staging/uploads', + WECOM_AIBOT_ENABLED: 'false', +}; + +const preferFromProd = [ + 'ALIYUN_SMS_SIGN_NAME', + 'ALIYUN_SMS_TEMPLATE_CODE', + 'ALIYUN_SMS_TEMPLATE_CONFIRM_REDEEM', + 'ALIYUN_SMS_TEMPLATE_PROXY_ORDER', + 'ALIYUN_SMS_ACCESS_KEY_ID', + 'ALIYUN_SMS_ACCESS_KEY_SECRET', + 'WX_APP_ID', + 'WX_APP_SECRET', + 'WX_MINI_APP_ID', + 'WX_MINI_APP_SECRET', + 'WX_MINI_PROMO_PAGE', + 'WX_MCH_ID', + 'WX_MCH_SERIAL_NO', + 'WX_MCH_PRIVATE_KEY', + 'WX_API_V3_KEY', + 'WX_PLATFORM_CERT', + 'WX_MINI_MSG_TOKEN', + 'WX_MINI_MSG_AES_KEY', + 'OSS_ACCESS_KEY_ID', + 'OSS_ACCESS_KEY_SECRET', + 'OSS_BUCKET', + 'OSS_REGION', + 'OSS_CDN_BASE', + 'OSS_ENDPOINT', + 'OSS_UPLOAD_EXPIRE_SECONDS', + 'OSS_MAX_UPLOAD_BYTES', + 'COURIER_PROVIDER', + 'XIAOFEIXIA_API_URL', + 'XIAOFEIXIA_MCH_ID', + 'XIAOFEIXIA_API_KEY', + 'XIAOFEIXIA_SIGN_TYPE', + 'SHIP_FROM_NAME', + 'SHIP_FROM_MOBILE', + 'SHIP_FROM_ADDRESS', + 'SHIP_FROM_ADDRESS_DETAIL', + 'SHIP_FROM_LNG', + 'SHIP_FROM_LAT', + 'TENCENT_LBS_KEY', + 'TENCENT_LBS_SECRET_KEY', + 'DEPLOY_WEBHOOK_URL', + 'DEPLOY_WEBHOOK_SECRET', +]; + +const out = [ + '# Generated from .env.production for staging (full Mock + *-test domains)', + '# Do not commit. Sync: bash deploy/sync-api-env.sh staging', + '', +]; + +for (const [k, v] of Object.entries(fixed)) { + out.push(`${k}=${q(v)}`); +} +out.push(''); +for (const k of preferFromProd) { + if (map.has(k) && !(k in fixed)) { + out.push(`${k}=${q(unq(map.get(k)))}`); + } +} + +const skip = new Set([...Object.keys(fixed), ...preferFromProd]); +for (const [k, raw] of map.entries()) { + if (skip.has(k)) continue; + out.push(`${k}=${q(unq(raw))}`); +} + +fs.writeFileSync(path.join(apiRoot, '.env.staging'), `${out.join('\n')}\n`); +const host = (stagingDb.match(/@([^/:]+)/) || [])[1] || '?'; +console.log(`ok db=dukang_staging host=${host} redis=/1 mocks=on`); diff --git a/server/dukang-api/scripts/with-api-env.cjs b/server/dukang-api/scripts/with-api-env.cjs index 57ea4fd..152a97c 100644 --- a/server/dukang-api/scripts/with-api-env.cjs +++ b/server/dukang-api/scripts/with-api-env.cjs @@ -4,7 +4,8 @@ const { existsSync } = require('fs'); const { resolve } = require('path'); const apiRoot = resolve(__dirname, '..'); -const envFile = process.env.DUKANG_ENV_FILE ?? '.env.production'; +const envFile = process.env.DUKANG_ENV_FILE + ?? (process.env.APP_ENV === 'staging' ? '.env.staging' : '.env.production'); const envPath = resolve(apiRoot, envFile); if (!existsSync(envPath)) { diff --git a/server/dukang-api/src/load-env.ts b/server/dukang-api/src/load-env.ts index e826b08..3455a88 100644 --- a/server/dukang-api/src/load-env.ts +++ b/server/dukang-api/src/load-env.ts @@ -5,19 +5,33 @@ import { resolve } from 'path'; /** * 环境变量加载策略(后加载的文件覆盖先前的同名键): * - * - 本地开发:仅 `.env` + `.env.local` - * `.env.development` 仅作参考模板,不自动加载 + * - local:`.env` + `.env.local` + * - staging:`.env.staging` + `.env.staging.local`(同机测试栈,APP_ENV=staging) + * - production:`.env.production` + `.env.production.local` * - * - 服务器(NODE_ENV=production):仅 `.env.production` + `.env.production.local` - * 不使用 `.env`,避免与本地开发配置混淆 + * 判定:优先 `APP_ENV`;未设置时 `NODE_ENV=production` → production,否则 local。 + * staging 在 PM2 中仍设 `NODE_ENV=production`(构建产物),靠 `APP_ENV=staging` 区分。 */ const apiRoot = resolve(__dirname, '..'); const nodeEnv = process.env.NODE_ENV ?? 'development'; -const isProduction = nodeEnv === 'production'; -const layers = isProduction - ? [resolve(apiRoot, '.env.production'), resolve(apiRoot, '.env.production.local')] - : [resolve(apiRoot, '.env'), resolve(apiRoot, '.env.local')]; +function resolveAppEnv(): 'local' | 'staging' | 'production' { + const raw = (process.env.APP_ENV ?? '').trim().toLowerCase(); + if (raw === 'staging' || raw === 'stage' || raw === 'test') return 'staging'; + if (raw === 'production' || raw === 'prod') return 'production'; + if (raw === 'local' || raw === 'development' || raw === 'dev') return 'local'; + if (nodeEnv === 'production') return 'production'; + return 'local'; +} + +const appEnv = resolveAppEnv(); + +const layers = + appEnv === 'staging' + ? [resolve(apiRoot, '.env.staging'), resolve(apiRoot, '.env.staging.local')] + : appEnv === 'production' + ? [resolve(apiRoot, '.env.production'), resolve(apiRoot, '.env.production.local')] + : [resolve(apiRoot, '.env'), resolve(apiRoot, '.env.local')]; for (const file of layers) { if (existsSync(file)) { @@ -28,3 +42,6 @@ for (const file of layers) { if (!process.env.NODE_ENV) { process.env.NODE_ENV = nodeEnv; } +if (!process.env.APP_ENV) { + process.env.APP_ENV = appEnv; +}