feat: multi-module iteration
This commit is contained in:
@@ -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`);
|
||||
@@ -0,0 +1,27 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const apiRoot = path.join(__dirname, '..');
|
||||
const prod = fs.readFileSync(path.join(apiRoot, '.env.production'), 'utf8');
|
||||
const m = prod.match(/^DATABASE_URL=(.*)$/m);
|
||||
if (!m) throw new Error('no prod DATABASE_URL');
|
||||
let url = m[1].trim();
|
||||
if (
|
||||
(url.startsWith('"') && url.endsWith('"')) ||
|
||||
(url.startsWith("'") && url.endsWith("'"))
|
||||
) {
|
||||
url = url.slice(1, -1);
|
||||
}
|
||||
if (!url.includes('/dukang_prod')) throw new Error('unexpected prod url');
|
||||
const stg = url.replace('/dukang_prod', '/dukang_staging');
|
||||
|
||||
const envPath = path.join(apiRoot, '.env.staging');
|
||||
let t = fs.readFileSync(envPath, 'utf8');
|
||||
if (!/^DATABASE_URL=/m.test(t)) {
|
||||
t = `DATABASE_URL=\n${t}`;
|
||||
}
|
||||
t = t.replace(/^DATABASE_URL=.*$/m, `DATABASE_URL=${JSON.stringify(stg)}`);
|
||||
fs.writeFileSync(envPath, t);
|
||||
|
||||
const u = new URL(stg);
|
||||
console.log(`ok ${u.hostname}:${u.port || 3306}${u.pathname}`);
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 发版成功后写入 system_version。
|
||||
* 须经 with-api-env.cjs 加载 DATABASE_URL;失败不阻断发版(exit 0)。
|
||||
*/
|
||||
const { execSync } = require('child_process');
|
||||
const { resolve } = require('path');
|
||||
|
||||
const apiRoot = resolve(__dirname, '..');
|
||||
const appRoot = process.env.APP_ROOT || resolve(apiRoot, '../..');
|
||||
|
||||
function git(cmd) {
|
||||
try {
|
||||
return execSync(cmd, { cwd: appRoot, encoding: 'utf8' }).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const commitId = git('git rev-parse HEAD');
|
||||
if (!commitId) {
|
||||
console.warn('[record-system-version] WARN: 无法读取 git HEAD,跳过');
|
||||
return;
|
||||
}
|
||||
|
||||
let commitMessage = git('git log -1 --pretty=%s') || '';
|
||||
if (commitMessage.length > 512) {
|
||||
commitMessage = commitMessage.slice(0, 512);
|
||||
}
|
||||
|
||||
const gitTag = git('git describe --tags --exact-match') || null;
|
||||
const branchRaw = git('git rev-parse --abbrev-ref HEAD');
|
||||
const branch = branchRaw && branchRaw !== 'HEAD' ? branchRaw : null;
|
||||
|
||||
const trigger = (process.env.DEPLOY_TRIGGER || '').trim();
|
||||
let deployedBy = 'manual';
|
||||
if (trigger) {
|
||||
if (trigger === 'manual' || trigger === 'admin') {
|
||||
deployedBy = trigger;
|
||||
} else {
|
||||
deployedBy = 'webhook';
|
||||
}
|
||||
}
|
||||
|
||||
const { PrismaClient } = require('@prisma/client');
|
||||
const prisma = new PrismaClient();
|
||||
try {
|
||||
const row = await prisma.systemVersion.create({
|
||||
data: {
|
||||
gitTag,
|
||||
commitId,
|
||||
commitMessage: commitMessage || '(no message)',
|
||||
branch,
|
||||
deployedBy,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`[record-system-version] ok id=${row.id} commit=${commitId.slice(0, 7)} tag=${gitTag || '-'} by=${deployedBy}`,
|
||||
);
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.warn('[record-system-version] WARN:', err instanceof Error ? err.message : err);
|
||||
process.exit(0);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import '../src/load-env';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../src/app.module';
|
||||
import { PrismaService } from '../src/common/prisma/prisma.module';
|
||||
import { RedeemService } from '../src/modules/redeem/redeem.service';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, { logger: ['error', 'warn'] });
|
||||
const prisma = app.get(PrismaService);
|
||||
const redeem = app.get(RedeemService);
|
||||
|
||||
let coupon = await prisma.benefitCoupon.findFirst({
|
||||
where: { status: 'ACTIVE', balance: { gt: 10 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!coupon) {
|
||||
const user = await prisma.user.findFirst({ orderBy: { id: 'asc' } });
|
||||
const city = await prisma.commonCity.findFirst();
|
||||
const product = await prisma.commonProductItem.findFirst();
|
||||
if (!user || !city || !product) throw new Error('seed base data missing');
|
||||
const order = await prisma.order.create({
|
||||
data: {
|
||||
orderNo: `T${Date.now()}`,
|
||||
userId: user.id,
|
||||
cityId: city.id,
|
||||
status: 'COMPLETED',
|
||||
payStatus: 'PAID',
|
||||
deliveryType: 'LOCAL',
|
||||
productId: product.id,
|
||||
barcode69: product.barcode69,
|
||||
productName: product.name,
|
||||
productSpec: product.spec ?? '500ml',
|
||||
quantity: 2,
|
||||
listUnitPrice: 100,
|
||||
listAmount: 200,
|
||||
productAmount: 200,
|
||||
payAmount: 200,
|
||||
benefitAmount: 200,
|
||||
receiverName: 'test',
|
||||
receiverPhone: user.phone ?? '13800000001',
|
||||
receiverProvince: '河南',
|
||||
receiverCity: '郑州',
|
||||
receiverDistrict: '金水',
|
||||
receiverAddress: 'test addr',
|
||||
},
|
||||
});
|
||||
coupon = await prisma.benefitCoupon.create({
|
||||
data: {
|
||||
couponNo: `TEST${Date.now()}`,
|
||||
userId: user.id,
|
||||
orderId: order.id,
|
||||
totalAmount: 200,
|
||||
usedAmount: 0,
|
||||
balance: 200,
|
||||
status: 'ACTIVE',
|
||||
sourceProduct: product.name,
|
||||
},
|
||||
});
|
||||
console.log('created test coupon', coupon.id.toString());
|
||||
}
|
||||
|
||||
const storeAccount = await prisma.storeAccount.findFirst({
|
||||
where: { status: 'ACTIVE', store: { status: 'OPEN' } },
|
||||
include: { store: true },
|
||||
});
|
||||
if (!storeAccount) throw new Error('no active store account on OPEN store');
|
||||
|
||||
console.log('userId', coupon.userId.toString());
|
||||
console.log('storeId', storeAccount.storeId.toString());
|
||||
console.log('coupon balance', coupon.balance.toString());
|
||||
|
||||
const tokenRes = await redeem.createToken(coupon.userId, {
|
||||
amount: 10,
|
||||
storeId: storeAccount.storeId.toString(),
|
||||
});
|
||||
console.log('token created', tokenRes.token);
|
||||
|
||||
try {
|
||||
const preview = await redeem.previewRedeem(storeAccount.id, tokenRes.token);
|
||||
console.log('preview ok', JSON.stringify(preview));
|
||||
} catch (e) {
|
||||
console.error('preview failed:', e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await redeem.confirmRedeem(storeAccount.id, { token: tokenRes.token });
|
||||
console.log('confirm ok', JSON.stringify(record));
|
||||
} catch (e) {
|
||||
console.error('confirm failed:', e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
await app.close();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 上传小程序「资质公示」静态图到 OSS:static/mini-user/qualification-disclosure.png
|
||||
* 用法(在 server/dukang-api):
|
||||
* node scripts/upload-qualification-disclosure.mjs [本地 png 路径]
|
||||
* 也可设环境变量 QUALIFICATION_DISCLOSURE_LOCAL。
|
||||
*/
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { resolve, dirname, isAbsolute } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const OSS = require('ali-oss');
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const apiRoot = resolve(__dirname, '..');
|
||||
|
||||
function loadEnvFile(path) {
|
||||
if (!existsSync(path)) return {};
|
||||
const out = {};
|
||||
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
|
||||
const m = line.match(/^([A-Z0-9_]+)=(.*)$/);
|
||||
if (!m) continue;
|
||||
let v = m[2];
|
||||
if (
|
||||
(v.startsWith('"') && v.endsWith('"')) ||
|
||||
(v.startsWith("'") && v.endsWith("'"))
|
||||
) {
|
||||
v = v.slice(1, -1);
|
||||
}
|
||||
out[m[1]] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const env = {
|
||||
...loadEnvFile(resolve(apiRoot, '.env.production')),
|
||||
...loadEnvFile(resolve(apiRoot, '.env.development')),
|
||||
...loadEnvFile(resolve(apiRoot, '.env')),
|
||||
};
|
||||
|
||||
const accessKeyId = env.OSS_ACCESS_KEY_ID || '';
|
||||
const accessKeySecret = env.OSS_ACCESS_KEY_SECRET || '';
|
||||
const bucket = env.OSS_BUCKET || '';
|
||||
const region = env.OSS_REGION || 'oss-cn-beijing';
|
||||
const cdnBase = (env.OSS_CDN_BASE || '').replace(/\/$/, '');
|
||||
|
||||
if (!accessKeyId || !accessKeySecret || !bucket) {
|
||||
console.error('缺少 OSS_ACCESS_KEY_ID / OSS_ACCESS_KEY_SECRET / OSS_BUCKET');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const argPath = process.argv[2]?.trim() || env.QUALIFICATION_DISCLOSURE_LOCAL?.trim() || '';
|
||||
if (!argPath) {
|
||||
console.error(
|
||||
'请传入本地 png 路径,例如:\n node scripts/upload-qualification-disclosure.mjs D:/tmp/qualification-disclosure.png',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const localFile = isAbsolute(argPath) ? argPath : resolve(process.cwd(), argPath);
|
||||
if (!existsSync(localFile)) {
|
||||
console.error('本地文件不存在:', localFile);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ossKey = 'static/mini-user/qualification-disclosure.png';
|
||||
const client = new OSS({
|
||||
region,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
bucket,
|
||||
});
|
||||
|
||||
const buffer = readFileSync(localFile);
|
||||
await client.put(ossKey, buffer, {
|
||||
mime: 'image/png',
|
||||
headers: {
|
||||
'Content-Disposition': 'inline',
|
||||
'Cache-Control': 'public, max-age=31536000',
|
||||
},
|
||||
});
|
||||
|
||||
const url = cdnBase
|
||||
? `${cdnBase}/${ossKey}`
|
||||
: `https://${bucket}.${region}.aliyuncs.com/${ossKey}`;
|
||||
|
||||
console.log('uploaded:', ossKey);
|
||||
console.log('url:', url);
|
||||
@@ -0,0 +1,46 @@
|
||||
import '../src/load-env';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { hashPassword } from '../src/common/crypto/password.util';
|
||||
|
||||
const LOGIN_NAME = process.env.SUPER_ADMIN_LOGIN ?? 'admin';
|
||||
const PASSWORD = process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!';
|
||||
const PLACEHOLDER_PHONE = process.env.SUPER_ADMIN_PHONE ?? '19900000001';
|
||||
|
||||
async function main() {
|
||||
const prisma = new PrismaClient();
|
||||
const passwordHash = hashPassword(PASSWORD);
|
||||
try {
|
||||
const deleted = await prisma.$executeRaw`
|
||||
DELETE FROM hq_account WHERE admin_role = 'SUPER_ADMIN'
|
||||
`;
|
||||
console.log(`Deleted ${deleted} SUPER_ADMIN account row(s).`);
|
||||
|
||||
await prisma.$executeRaw`
|
||||
INSERT INTO hq_account (
|
||||
phone, login_name, password_hash, name, admin_role, status, created_at, updated_at
|
||||
) VALUES (
|
||||
${PLACEHOLDER_PHONE},
|
||||
${LOGIN_NAME},
|
||||
${passwordHash},
|
||||
${'超级管理员'},
|
||||
${'SUPER_ADMIN'},
|
||||
${'ACTIVE'},
|
||||
NOW(3),
|
||||
NOW(3)
|
||||
)
|
||||
`;
|
||||
|
||||
console.log('Created SUPER_ADMIN:', {
|
||||
loginName: LOGIN_NAME,
|
||||
phone: PLACEHOLDER_PHONE,
|
||||
password: '(hidden)',
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
const { config } = require('dotenv');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { existsSync } = require('fs');
|
||||
const { resolve } = require('path');
|
||||
|
||||
const apiRoot = resolve(__dirname, '..');
|
||||
const envFile = process.env.DUKANG_ENV_FILE
|
||||
?? (process.env.APP_ENV === 'staging' ? '.env.staging' : '.env.production');
|
||||
const envPath = resolve(apiRoot, envFile);
|
||||
|
||||
if (!existsSync(envPath)) {
|
||||
console.error(`[with-api-env] 未找到 ${envPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config({ path: envPath, override: true });
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0) {
|
||||
console.error('用法: node scripts/with-api-env.cjs <command> [args...]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = spawnSync(args[0], args.slice(1), {
|
||||
cwd: apiRoot,
|
||||
stdio: 'inherit',
|
||||
env: process.env,
|
||||
shell: true,
|
||||
});
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
Reference in New Issue
Block a user