feat: multi-module iteration

This commit is contained in:
2026-08-04 21:38:49 +08:00
parent 9d96c73246
commit 71f508e02b
1366 changed files with 202004 additions and 0 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`);
@@ -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 @@
/**
* 上传小程序「资质公示」静态图到 OSSstatic/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);