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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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`);
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user