chore(deploy): add same-host staging stack next to production

dev deploys to /opt/dukang-staging (819x, *-test domains); main deploys to production. Staging uses full Mock with shared WeChat app ids.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 11:37:18 +08:00
parent 8d6c0f9ac0
commit 6a23e79f4c
20 changed files with 793 additions and 119 deletions
+4 -2
View File
@@ -1,12 +1,14 @@
# 生产环境(真实第三方,关闭 Mock)
# 用途:服务器 runtime 配置bash deploy/sync-api-env.sh production 同步到远端 .env.production
# 用途:服务器 /opt/dukangbash 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
+67
View File
@@ -0,0 +1,67 @@
# Staging / 测试环境(同机双栈,正式微信 AppId + 全 Mock
# 用途:服务器 /opt/dukang-stagingsync: 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
@@ -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`);
+2 -1
View File
@@ -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)) {
+25 -8
View File
@@ -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;
}