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
@@ -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)) {