feat: 技术支持工单/企微权限/开发版本管理/消息推送等迭代

This commit is contained in:
2026-08-04 21:32:13 +08:00
parent c8ea5a3119
commit 9d96c73246
1341 changed files with 0 additions and 195605 deletions
-89
View File
@@ -1,89 +0,0 @@
/**
* 为 OSS Bucket 配置浏览器直传所需的 CORS 规则。
* 使用 server/dukang-api/.env 中的 OSS 凭证。
*
* 用法:pnpm oss:cors
* 可选环境变量 OSS_CORS_ORIGINS(逗号分隔),默认包含本地 H5 / admin 端口。
*/
import { readFileSync, existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = dirname(fileURLToPath(import.meta.url));
const apiRoot = resolve(__dirname, '../server/dukang-api');
const require = createRequire(resolve(apiRoot, 'package.json'));
const OSS = require('ali-oss');
const envPath = resolve(apiRoot, '.env');
function loadEnvFile(path: string) {
if (!existsSync(path)) return;
for (const line of readFileSync(path, 'utf8').split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eq = trimmed.indexOf('=');
if (eq <= 0) continue;
const key = trimmed.slice(0, eq).trim();
let value = trimmed.slice(eq + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
if (process.env[key] === undefined) process.env[key] = value;
}
}
loadEnvFile(envPath);
const {
OSS_ACCESS_KEY_ID: accessKeyId,
OSS_ACCESS_KEY_SECRET: accessKeySecret,
OSS_BUCKET: bucket,
OSS_REGION: region = 'oss-cn-hangzhou',
OSS_CORS_ORIGINS,
} = process.env;
if (!accessKeyId || !accessKeySecret || !bucket) {
console.error('请在 server/dukang-api/.env 配置 OSS_ACCESS_KEY_ID、OSS_ACCESS_KEY_SECRET、OSS_BUCKET');
process.exit(1);
}
const defaultOrigins = [
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:5175',
'http://127.0.0.1:5173',
'http://127.0.0.1:5174',
'http://127.0.0.1:5175',
'http://localhost:5173/user',
'http://localhost:5174/shop',
'http://localhost:5175/partner',
'https://user.runxian.top',
];
const allowedOrigin = (OSS_CORS_ORIGINS ?? defaultOrigins.join(','))
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const client = new OSS({
region,
accessKeyId,
accessKeySecret,
bucket,
});
const rules = [
{
allowedOrigin,
allowedMethod: ['GET', 'POST', 'PUT', 'HEAD'],
allowedHeader: ['*'],
exposeHeader: ['ETag', 'x-oss-request-id'],
maxAgeSeconds: 600,
},
];
console.log(`配置 Bucket「${bucket}」CORS,允许来源:`);
for (const origin of allowedOrigin) console.log(` - ${origin}`);
await client.putBucketCORS(bucket, rules);
console.log('CORS 规则已写入。若 H5 端仍直传 OSS,请确认来源域名已包含在列表中。');
-167
View File
@@ -1,167 +0,0 @@
/**
* mini-user H5 本地预览:
* Taro Vite `--watch` 在本机易 OOM(峰值 >8GB),改为:
* 1) 先完整 build(约 30s
* 2) 静态托管 dist + /api 代理
* 3) 监听 src 变更后防抖重建
*/
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import http from 'node:http';
import https from 'node:https';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const APP_ROOT = path.resolve(__dirname, '../apps/mini-user');
const DIST = path.resolve(APP_ROOT, 'dist');
const PORT = Number(process.env.PORT || 5177);
const API_ORIGIN = (process.env.VITE_API_TARGET ?? 'http://localhost:3000').replace(/\/$/, '');
const TARO_BIN = path.resolve(APP_ROOT, 'node_modules/@tarojs/cli/bin/taro');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.woff': 'font/woff',
'.woff2': 'font/woff2',
'.map': 'application/json',
};
function runBuild() {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
['--max-old-space-size=8192', TARO_BIN, 'build', '--type', 'h5'],
{
cwd: APP_ROOT,
stdio: 'inherit',
env: process.env,
},
);
child.on('exit', (code) => {
if (code === 0) resolve();
else reject(new Error(`taro build failed with code ${code}`));
});
});
}
function sendFile(res, filePath) {
const ext = path.extname(filePath).toLowerCase();
res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
fs.createReadStream(filePath).pipe(res);
}
function proxyApi(req, res) {
const target = new URL(req.url || '/', API_ORIGIN);
const lib = target.protocol === 'https:' ? https : http;
const headers = { ...req.headers, host: target.host };
delete headers['accept-encoding'];
const upstream = lib.request(
{
protocol: target.protocol,
hostname: target.hostname,
port: target.port || undefined,
path: target.pathname + target.search,
method: req.method,
headers,
},
(up) => {
res.writeHead(up.statusCode || 502, up.headers);
up.pipe(res);
},
);
upstream.on('error', (err) => {
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end(`API proxy error: ${err.message}`);
});
req.pipe(upstream);
}
function startServer() {
const server = http.createServer((req, res) => {
const urlPath = decodeURIComponent((req.url || '/').split('?')[0]);
if (urlPath.startsWith('/api')) {
proxyApi(req, res);
return;
}
const candidates = [
path.join(DIST, urlPath),
path.join(DIST, urlPath, 'index.html'),
path.join(DIST, 'index.html'),
];
const file = candidates.find((p) => fs.existsSync(p) && fs.statSync(p).isFile());
if (!file) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Not Found');
return;
}
sendFile(res, file);
});
server.listen(PORT, '0.0.0.0', () => {
console.log(`\n mini-user H5 preview: http://localhost:${PORT}`);
console.log(` API proxy -> ${API_ORIGIN}`);
console.log(' Watching apps/mini-user/src for changes...\n');
});
}
function watchSrc() {
const srcDir = path.join(APP_ROOT, 'src');
let timer = null;
let building = false;
let queued = false;
const schedule = () => {
if (timer) clearTimeout(timer);
timer = setTimeout(async () => {
if (building) {
queued = true;
return;
}
building = true;
try {
console.log('\n[dev] src changed, rebuilding...');
await runBuild();
console.log('[dev] rebuild done — refresh browser');
} catch (e) {
console.error('[dev] rebuild failed:', e instanceof Error ? e.message : e);
} finally {
building = false;
if (queued) {
queued = false;
schedule();
}
}
}, 800);
};
fs.watch(srcDir, { recursive: true }, (_event, filename) => {
if (!filename) return;
if (/\.(tsx?|jsx?|css|scss|json|png|jpg|svg)$/i.test(filename)) {
schedule();
}
});
}
async function main() {
if (!fs.existsSync(TARO_BIN)) {
throw new Error(`Taro CLI not found: ${TARO_BIN}`);
}
console.log('[dev] initial H5 build...');
await runBuild();
startServer();
watchSrc();
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env node
/**
* 总部 H5 静态预览:托管 dist 并将 /api 代理到后端(与 admin-web vite proxy 行为一致)
*/
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
import { platform } from 'node:os';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DIST = path.resolve(__dirname, '../apps/mini-hq/dist');
const PORT = Number(process.env.HQ_PREVIEW_PORT || 5176);
const API_TARGET = (process.env.VITE_API_TARGET || 'http://localhost:3000').replace(/\/$/, '');
const MIME = {
'.html': 'text/html; charset=utf-8',
'.js': 'application/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.json': 'application/json',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
};
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** 释放预览端口(pnpm preview:hq 重复执行时自动重启) */
function freePort(port) {
try {
if (platform() === 'win32') {
const out = execSync(`netstat -ano | findstr :${port}`, { encoding: 'utf8' });
const pids = new Set();
for (const line of out.split('\n')) {
if (!line.includes('LISTENING')) continue;
const pid = line.trim().split(/\s+/).pop();
if (pid && /^\d+$/.test(pid)) pids.add(pid);
}
for (const pid of pids) {
try {
execSync(`taskkill /PID ${pid} /F`, { stdio: 'ignore' });
} catch {
/* ignore */
}
}
return;
}
execSync(`lsof -ti :${port} | xargs kill -9 2>/dev/null || true`, {
shell: true,
stdio: 'ignore',
});
} catch {
/* 端口可能本就空闲 */
}
}
function sendFile(res, filePath) {
const ext = path.extname(filePath);
const type = MIME[ext] || 'application/octet-stream';
fs.readFile(filePath, (err, data) => {
if (err) {
res.writeHead(404);
res.end('Not found');
return;
}
res.writeHead(200, { 'Content-Type': type });
res.end(data);
});
}
function proxyApi(req, res) {
const target = new URL(req.url, API_TARGET);
const headers = { ...req.headers, host: target.host };
const proxyReq = http.request(
{
hostname: target.hostname,
port: target.port || (target.protocol === 'https:' ? 443 : 80),
path: target.pathname + target.search,
method: req.method,
headers,
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
proxyRes.pipe(res);
},
);
proxyReq.on('error', () => {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: 502, message: 'API 不可达,请先启动 pnpm dev:api' }));
});
req.pipe(proxyReq);
}
function createServer() {
return http.createServer((req, res) => {
const urlPath = req.url?.split('?')[0] || '/';
if (urlPath.startsWith('/api')) {
proxyApi(req, res);
return;
}
let filePath = path.join(DIST, urlPath === '/' ? 'index.html' : urlPath);
if (!filePath.startsWith(DIST)) {
res.writeHead(403);
res.end('Forbidden');
return;
}
fs.stat(filePath, (err, stat) => {
if (!err && stat.isFile()) {
sendFile(res, filePath);
return;
}
sendFile(res, path.join(DIST, 'index.html'));
});
});
}
function listen(server, port) {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(port, () => {
server.off('error', reject);
resolve();
});
});
}
async function main() {
if (!fs.existsSync(DIST)) {
console.error('未找到 apps/mini-hq/dist,请先执行:pnpm --filter @dukang/mini-hq build');
process.exit(1);
}
freePort(PORT);
await sleep(400);
const server = createServer();
try {
await listen(server, PORT);
} catch (err) {
if (err && err.code === 'EADDRINUSE') {
console.error(`端口 ${PORT} 仍被占用,请手动结束进程后重试,或设置 HQ_PREVIEW_PORT 换端口。`);
process.exit(1);
}
throw err;
}
console.log(`mini-hq preview: http://localhost:${PORT}`);
console.log(`API proxy: ${API_TARGET}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
-97
View File
@@ -1,97 +0,0 @@
import { loginWithSms, resolveSmsCode } from './sms-test-helper.mjs';
const API = 'http://localhost:3000/api/v1';
async function req(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
return json.data;
}
async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
await req(clientApp, sendPath, {
method: 'POST',
body: JSON.stringify({ phone, scene }),
});
}
async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') {
return loginWithSms(clientApp, phone, scene, loginPath, sendPath);
}
async function main() {
console.log('1. Health');
await req('USER_H5', '/health');
console.log('2. User login');
const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN');
const userToken = userLogin.accessToken;
console.log('3. Catalog');
const products = await req('USER_H5', '/catalog/products');
if (products.length < 4) throw new Error('Expected 4 products');
console.log('4. Create address + order + mock pay');
const addr = await req('USER_H5', '/user/addresses', {
method: 'POST',
token: userToken,
body: JSON.stringify({
receiverName: '测试',
phone: '13800000001',
province: '河南省',
city: '郑州市',
district: '金水区',
detail: '冒烟测试地址1号',
isDefault: true,
}),
});
const order = await req('USER_H5', '/trade/orders', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
});
await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken });
console.log('5. Shop login + redeem preview');
const shopLogin = await loginSms(
'SHOP_H5',
'13910000001',
'STORE_LOGIN',
'/shop/auth/login/sms',
'/shop/auth/sms/send',
);
const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken });
if (!coupons.length) throw new Error('No coupons');
const tokenRes = await req('USER_H5', '/redeem/tokens', {
method: 'POST',
token: userToken,
body: JSON.stringify({ amount: 50 }),
});
await req('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
token: shopLogin.accessToken,
body: JSON.stringify({ token: tokenRes.token }),
});
console.log('6. Partner login');
await loginSms(
'PARTNER_H5',
'13700000001',
'PARTNER_LOGIN',
'/partner/auth/login/sms',
'/partner/auth/sms/send',
);
console.log('preV1 smoke OK');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-285
View File
@@ -1,285 +0,0 @@
import { loginWithSms } from './sms-test-helper.mjs';
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
async function req(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
return json.data;
}
async function expectFail(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code === 0) throw new Error(`${path}: expected failure`);
return json.message;
}
async function sendSms(clientApp, phone, scene, sendPath = '/auth/sms/send') {
await req(clientApp, sendPath, {
method: 'POST',
body: JSON.stringify({ phone, scene }),
});
}
async function loginSms(clientApp, phone, scene, loginPath = '/auth/login/sms', sendPath = '/auth/sms/send') {
return loginWithSms(clientApp, phone, scene, loginPath, sendPath);
}
async function adminLogin() {
return loginSms(
'HQ_WEB',
'13600000001',
'HQ_LOGIN',
'/admin/auth/login/sms',
'/admin/auth/sms/send',
);
}
async function main() {
console.log('1. Health');
await req('USER_H5', '/health');
console.log('2. User login');
const userLogin = await loginSms('USER_H5', '13800000001', 'USER_LOGIN');
const userToken = userLogin.accessToken;
console.log('3. Cities + products');
const cities = await req('USER_H5', '/catalog/cities');
const cityCode = cities[0]?.code ?? 'ZZ';
const products = await req('USER_H5', `/catalog/products?cityCode=${cityCode}`);
if (products.length < 4) throw new Error(`Expected >=4 products, got ${products.length}`);
console.log('4. Min purchase boundaries');
const addr = await req('USER_H5', '/user/addresses', {
method: 'POST',
token: userToken,
body: JSON.stringify({
receiverName: '测试',
phone: '13800000001',
province: '河南省',
city: '郑州市',
district: '金水区',
detail: 'V3冒烟地址',
isDefault: true,
}),
});
await expectFail('USER_H5', '/trade/orders/preview', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 1, addressId: addr.id }),
});
const preview2 = await req('USER_H5', '/trade/orders/preview', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
});
if (preview2.payAmount <= 0) throw new Error('Preview qty=2 failed');
console.log('5. Create order + pay + benefit');
const order = await req('USER_H5', '/trade/orders', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
});
await req('USER_H5', `/trade/orders/${order.id}/pay`, { method: 'POST', token: userToken });
const paidOrder = await req('USER_H5', `/trade/orders/${order.id}`, { token: userToken });
if (paidOrder.status !== 'PENDING_SHIP') throw new Error('Order not PENDING_SHIP after pay');
const coupons = await req('USER_H5', '/benefit/coupons', { token: userToken });
if (!coupons.length) throw new Error('No coupon after pay');
const summary = await req('USER_H5', '/benefit/summary', { token: userToken });
console.log('6. Direct redeem (no couponId)');
const directAmount = Math.min(100, Number(summary.totalBalance ?? summary.balance ?? 100));
const directToken = await req('USER_H5', '/redeem/tokens', {
method: 'POST',
token: userToken,
body: JSON.stringify({ amount: directAmount }),
});
if (!directToken.token) throw new Error('Direct redeem token missing');
console.log('7. Coupon redeem cap');
await expectFail('USER_H5', '/redeem/tokens', {
method: 'POST',
token: userToken,
body: JSON.stringify({ couponId: coupons[0].id, amount: Number(coupons[0].balance) + 1 }),
});
console.log('7b. Shop sms guard + session');
const unboundMsg = await expectFail('SHOP_H5', '/shop/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone: '13899999999', scene: 'STORE_LOGIN' }),
});
if (!unboundMsg.includes('未绑定门店')) {
throw new Error(`Expected unbound store message, got: ${unboundMsg}`);
}
console.log('8. Shop confirm redeem');
const shopLogin = await loginSms(
'SHOP_H5',
'13910000001',
'STORE_LOGIN',
'/shop/auth/login/sms',
'/shop/auth/sms/send',
);
let shopToken = shopLogin.accessToken;
let shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken });
if (!shopMe?.phone) throw new Error('Shop /shop/auth/me failed');
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: shopLogin.refreshToken }),
});
if (!refreshed?.accessToken) throw new Error('Shop token refresh failed');
shopToken = refreshed.accessToken;
shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken });
if (!shopMe.storeId && shopMe.stores?.[0]?.storeId) {
const selected = await req('SHOP_H5', '/shop/auth/select-store', {
method: 'POST',
token: shopToken,
body: JSON.stringify({ storeId: shopMe.stores[0].storeId }),
});
shopToken = selected.accessToken || shopToken;
shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopToken });
}
if (!shopMe.storeId) throw new Error('Shop storeId missing after select-store');
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
method: 'POST',
token: shopToken,
body: JSON.stringify({ token: directToken.token }),
});
if (!preview.amount) throw new Error('Redeem preview failed');
await req('SHOP_H5', '/shop/redeem/confirm', {
method: 'POST',
token: shopToken,
body: JSON.stringify({ token: directToken.token }),
});
console.log('9. Store withdraw (OPT-010)');
const admin = await adminLogin();
const shopStoreId = shopMe.storeId || shopMe.stores?.[0]?.storeId;
if (!shopStoreId) throw new Error('Shop storeId missing after login');
const withdrawSummary = await req('SHOP_H5', '/shop/withdraw/summary', {
token: shopToken,
});
if (!(withdrawSummary.availableAmount > 0)) {
throw new Error('Expected available withdraw amount after redeem');
}
const applied = await req('SHOP_H5', '/shop/withdraw', {
method: 'POST',
token: shopToken,
body: JSON.stringify({}),
});
if (applied.status !== 'PENDING_REVIEW') {
throw new Error(`Expected PENDING_REVIEW withdraw, got ${applied.status}`);
}
const pendingDup = await expectFail('SHOP_H5', '/shop/withdraw', {
method: 'POST',
token: shopToken,
body: JSON.stringify({}),
});
if (!String(pendingDup).includes('待审核')) {
throw new Error(`Expected pending-request reject, got: ${pendingDup}`);
}
// 锁定中的明细不可再出账(T+1 排除 withdrawItem
const summaryAfter = await req('SHOP_H5', '/shop/withdraw/summary', {
token: shopToken,
});
if (summaryAfter.availableAmount !== 0) {
throw new Error('Expected availableAmount=0 while withdraw pending');
}
await req('HQ_WEB', `/admin/store-withdrawals/${applied.id}/approve`, {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({ paymentRef: 'smoke-withdraw' }),
});
const withdrawList = await req('SHOP_H5', '/shop/withdraw/requests?status=PAID', {
token: shopToken,
});
const settled = withdrawList.items?.find((w) => w.id === applied.id);
if (!settled || settled.status !== 'PAID') {
throw new Error('Expected settled withdraw request on shop side');
}
const overdue = await req('HQ_WEB', '/admin/store-withdrawals/overdue-summary', {
token: admin.accessToken,
});
if (typeof overdue.pendingCount !== 'number') {
throw new Error('overdue-summary missing pendingCount');
}
console.log('10. Refund ticket flow');
const order2 = await req('USER_H5', '/trade/orders', {
method: 'POST',
token: userToken,
body: JSON.stringify({ productId: products[0].id, quantity: 2, addressId: addr.id }),
});
await req('USER_H5', `/trade/orders/${order2.id}/pay`, { method: 'POST', token: userToken });
await req('USER_H5', `/trade/orders/${order2.id}/refund-requests`, {
method: 'POST',
token: userToken,
body: JSON.stringify({ remark: 'smoke refund' }),
});
const tickets = await req('HQ_WEB', '/admin/tickets?ticketType=REFUND', {
token: admin.accessToken,
});
const refundTicket = tickets.items?.find((t) => t.refId === order2.id);
if (!refundTicket) throw new Error('Refund ticket not created');
await req('HQ_WEB', `/admin/tickets/${refundTicket.id}/approve`, {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({}),
});
const refundedOrder = await req('USER_H5', `/trade/orders/${order2.id}`, { token: userToken });
if (refundedOrder.status !== 'REFUNDED' || refundedOrder.payStatus !== 'REFUNDED') {
throw new Error(
`Refund order status expected REFUNDED/REFUNDED, got ${refundedOrder.status}/${refundedOrder.payStatus}`,
);
}
console.log('11. Partner phone gate');
const unknownPartner = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
method: 'POST',
body: JSON.stringify({ phone: '13899999999' }),
});
if (!unknownPartner.includes('未找到合伙人账号')) {
throw new Error(`Expected partner phone gate, got: ${unknownPartner}`);
}
console.log('12. Partner bill');
const partners = await req('HQ_WEB', '/admin/partners', { token: admin.accessToken });
const partnerId = partners.items?.[0]?.id;
if (partnerId) {
const now = new Date();
await req('HQ_WEB', '/admin/partner-bills/generate', {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({ partnerId, year: now.getFullYear(), month: now.getMonth() + 1 }),
});
}
console.log('\n✅ V3 smoke passed');
}
main().catch((e) => {
console.error('❌ V3 smoke failed:', e.message);
process.exit(1);
});
-85
View File
@@ -1,85 +0,0 @@
import { execSync } from 'node:child_process';
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-v1-redis';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchJson(path, options = {}) {
const res = await fetch(`${API}${path}`, options);
return res.json();
}
function readSmsCodeFromRedis(phone, scene) {
const key = `dukang:sms:${scene}:${phone}`;
try {
const code = execSync(`docker exec ${REDIS_CONTAINER} redis-cli GET "${key}"`, {
encoding: 'utf8',
}).trim();
if (code && code !== '(nil)') return code;
} catch {
/* docker/redis unavailable */
}
return null;
}
export async function loadClientConfig() {
const json = await fetchJson('/common/client-config');
if (json.code !== 0) throw new Error(`client-config: ${json.message}`);
return json.data;
}
export async function resolveSmsCode(phone, scene) {
const fromRedis = readSmsCodeFromRedis(phone, scene);
if (fromRedis) return fromRedis;
throw new Error(
`SMS code not found for ${phone} (${scene}); send SMS first and ensure Redis is reachable`,
);
}
export async function sendSmsOnce(clientApp, sendPath, phone, scene) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
};
const res = await fetch(`${API}${sendPath}`, {
method: 'POST',
headers,
body: JSON.stringify({ phone, scene }),
});
return res.json();
}
export async function sendSmsWithCooldown(clientApp, sendPath, phone, scene) {
let json = await sendSmsOnce(clientApp, sendPath, phone, scene);
if (json.code !== 0 && String(json.message).includes('过于频繁')) {
await sleep(65_000);
json = await sendSmsOnce(clientApp, sendPath, phone, scene);
}
if (json.code !== 0) {
const existing = readSmsCodeFromRedis(phone, scene);
if (existing) return { reusedCode: true };
throw new Error(`${sendPath}: ${json.message}`);
}
return json.data;
}
export async function loginWithSms(clientApp, phone, scene, loginPath, sendPath) {
await sendSmsWithCooldown(clientApp, sendPath, phone, scene);
const code = await resolveSmsCode(phone, scene);
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
};
const res = await fetch(`${API}${loginPath}`, {
method: 'POST',
headers,
body: JSON.stringify({ phone, code }),
});
const json = await res.json();
if (json.code !== 0) throw new Error(`${loginPath}: ${json.message}`);
return json.data;
}
-197
View File
@@ -1,197 +0,0 @@
/**
* Sync Stitch shared project "4端1.0版本" prototypes to pages/
* Usage: STITCH_API_KEY=xxx node scripts/sync-stitch.mjs [--force]
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const PAGES = path.join(ROOT, 'pages');
const PROJECT_ID = process.env.STITCH_PROJECT_ID || '9513024892894556686';
const FORCE = process.argv.includes('--force');
const API_KEY =
process.env.STITCH_API_KEY ||
(() => {
try {
const mcp = JSON.parse(fs.readFileSync(path.join(process.env.USERPROFILE || '', '.cursor', 'mcp.json'), 'utf8'));
return mcp?.mcpServers?.stitch?.headers?.['X-Goog-Api-Key'];
} catch {
return null;
}
})();
if (!API_KEY) {
console.error('Missing STITCH_API_KEY');
process.exit(1);
}
async function mcpCall(tool, args = {}) {
const res = await fetch('https://stitch.googleapis.com/mcp', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Goog-Api-Key': API_KEY },
body: JSON.stringify({
jsonrpc: '2.0',
id: Date.now(),
method: 'tools/call',
params: { name: tool, arguments: args },
}),
});
const json = await res.json();
if (json.error) throw new Error(json.error.message || JSON.stringify(json.error));
if (json.result?.isError) throw new Error(json.result.content?.[0]?.text || 'MCP error');
return json.result.structuredContent ?? JSON.parse(json.result.content?.[0]?.text || '{}');
}
async function download(url, dest) {
if (!url) return false;
const res = await fetch(url);
if (!res.ok) return false;
fs.writeFileSync(dest, Buffer.from(await res.arrayBuffer()));
return true;
}
function classify(title) {
const t = title || '';
if (/\.md$|logo\.png|全案交付|Stitch提示词/i.test(t)) return 'asset';
if (/总部|开城管理|开城页面|城市参数|商品管理页|拦截配送|数据报表|预警详情|补发处理页面|跨城发货处理|财务结算中心|财务对账|合同管理|子账号|我的员工|推广码|数据周报|总部管理|在线客服对话页.*总部|客服中心.*跨城|申请打款|资产明细|账单明细详情|超时订单|订单售后详情.*补发与退款|补发订单详情|添加商品|编辑城市页面|门店审核管理|门店审核记录|添加子账号|新增开城|生成推广码|推广码详情|推广码管理|合伙人贡献榜|合伙人待确认账单.*(?!合伙人中心)/i.test(t)) {
if (/合伙人待确认账单|合伙人中心|合伙人订单|录入新门店|合伙人门店|合伙人工作台|城市合伙人登录|城市合伙人快捷|城市合伙人订单/.test(t)) {
// keep partner-specific
} else if (/总部|开城|拦截|数据报表|预警|补发处理页|跨城发货|财务结算|财务对账|合同|子账号|我的员工|推广码|数据周报|申请打款|资产明细|账单明细|超时订单|添加商品|编辑城市|门店审核管理|门店审核记录|添加子账号|新增开城|商品管理页/.test(t)) {
return 'skip';
}
}
if (/总部|开城管理|拦截配送管理|数据报表中心|预警详情|补发处理页面|跨城发货处理|财务结算中心|财务对账|合同管理|子账号|我的员工|推广码管理|数据周报|总部管理|申请打款|资产明细|账单明细详情|超时订单详情|添加商品页面|编辑城市页面|门店审核管理|门店审核记录|添加子账号|新增开城|生成推广码|推广码详情|合伙人贡献榜|商品管理页|订单中心管理页|订单售后详情|补发订单详情|在线客服对话页|总部客服|总部端订单|总部管理中心|总部管理登录|总部管理一键|拦截配送|城市参数配置/i.test(t)) {
return 'skip';
}
if (/门店端|门店管理|S-0|门店核销|门店营业|门店登录|门店管理登录|门店管理首页|门店管理一键|门店核销成功|门店核销确认|门店核销记录/i.test(t) && !/合伙人门店|录入新门店/.test(t)) {
return 'shop';
}
if (/合伙人|录入新门店|城市合伙人/i.test(t)) return 'partner';
if (/C端|小程序|用户登录|商品详情|确认订单|地址|我的订单|订单详情|好客权益|个人中心|餐券核销|核销码|可用门店|门店详情|微信支付|定位授权|微信|核销成功及评价|核销成功.*评价/i.test(t)) {
return 'user';
}
if (/门店详情页$/.test(t) && !/编辑/.test(t)) return 'user';
if (/门店详情编辑/.test(t)) return 'partner';
return 'skip';
}
function slug(title, screenId) {
const base = (title || 'screen')
.replace(/[^\w\u4e00-\u9fa5]+/g, '_')
.replace(/^_|_$/g, '')
.slice(0, 40);
return `${base}_${screenId.slice(0, 8)}`;
}
async function main() {
console.log('Fetching project...');
const project = await mcpCall('get_project', { name: `projects/${PROJECT_ID}` });
const designMd = project.designTheme?.designMd;
if (designMd) {
const designPath = path.join(PAGES, 'stitch_4_1.0', 'DESIGN.md');
fs.mkdirSync(path.dirname(designPath), { recursive: true });
fs.writeFileSync(designPath, designMd, 'utf8');
console.log('Updated DESIGN.md');
}
console.log('Listing screens...');
const { screens = [] } = await mcpCall('list_screens', { projectId: PROJECT_ID });
console.log(`Found ${screens.length} screens`);
const manifest = {
projectId: PROJECT_ID,
projectTitle: '4端1.0版本',
syncedAt: new Date().toISOString(),
screens: [],
};
let idx = { user: 0, shop: 0, partner: 0, skip: 0, asset: 0 };
for (const screen of screens) {
const screenId = screen.name?.replace(/.*\/screens\//, '') || screen.screenId;
const title = screen.title || screen.displayName || '';
const category = classify(title);
idx[category] = (idx[category] || 0) + 1;
const entry = {
screenId,
title,
category,
route: null,
priority: category === 'skip' || category === 'asset' ? 'skip' : 'P1',
};
if (category === 'asset') {
const assetDir = path.join(PAGES, '_assets');
fs.mkdirSync(assetDir, { recursive: true });
manifest.screens.push(entry);
continue;
}
if (category === 'skip') {
const archiveDir = path.join(PAGES, '_archive', slug(title, screenId));
fs.mkdirSync(archiveDir, { recursive: true });
entry.dir = path.relative(ROOT, archiveDir);
manifest.screens.push(entry);
if (FORCE || !fs.existsSync(path.join(archiveDir, 'meta.json'))) {
try {
const detail = await mcpCall('get_screen', { name: `projects/${PROJECT_ID}/screens/${screenId}`, projectId: PROJECT_ID, screenId });
fs.writeFileSync(path.join(archiveDir, 'meta.json'), JSON.stringify({ title, screenId, syncedAt: new Date().toISOString() }, null, 2));
if (detail.htmlCode?.downloadUrl) await download(detail.htmlCode.downloadUrl, path.join(archiveDir, 'code.html'));
if (detail.screenshot?.downloadUrl) await download(detail.screenshot.downloadUrl, path.join(archiveDir, 'screen.png'));
} catch (e) {
console.warn(` skip download failed: ${title}`, e.message);
}
}
continue;
}
const num = String(++idx[category]).padStart(2, '0');
const dir = path.join(PAGES, category, `${num}_${slug(title, screenId)}`);
fs.mkdirSync(dir, { recursive: true });
entry.dir = path.relative(ROOT, dir);
const metaPath = path.join(dir, 'meta.json');
if (!FORCE && fs.existsSync(metaPath) && fs.existsSync(path.join(dir, 'code.html'))) {
manifest.screens.push(entry);
continue;
}
try {
const detail = await mcpCall('get_screen', {
name: `projects/${PROJECT_ID}/screens/${screenId}`,
projectId: PROJECT_ID,
screenId,
});
const meta = { title, screenId, category, syncedAt: new Date().toISOString() };
fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2));
if (detail.htmlCode?.downloadUrl) {
const ok = await download(detail.htmlCode.downloadUrl, path.join(dir, 'code.html'));
if (!ok) console.warn(` HTML download failed: ${title}`);
}
if (detail.screenshot?.downloadUrl) {
await download(detail.screenshot.downloadUrl, path.join(dir, 'screen.png'));
}
console.log(` [${category}] ${title}`);
} catch (e) {
console.warn(` Failed: ${title}`, e.message);
}
manifest.screens.push(entry);
}
fs.writeFileSync(path.join(PAGES, 'SCREEN_MAP.json'), JSON.stringify(manifest, null, 2), 'utf8');
console.log('\nDone.');
console.log(` user: ${manifest.screens.filter((s) => s.category === 'user').length}`);
console.log(` shop: ${manifest.screens.filter((s) => s.category === 'shop').length}`);
console.log(` partner: ${manifest.screens.filter((s) => s.category === 'partner').length}`);
console.log(` skip/archive: ${manifest.screens.filter((s) => s.category === 'skip').length}`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-83
View File
@@ -1,83 +0,0 @@
/**
* 合伙人登录 / 手机号校验专项冒烟(需 API 已启动)
* 用法: node scripts/test-partner-auth.mjs
*/
import { loginWithSms } from './sms-test-helper.mjs';
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
async function req(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
return json.data;
}
async function expectFail(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code === 0) throw new Error(`${path}: expected failure`);
return json.message;
}
async function main() {
console.log('1. PARTNER phone/check unknown');
const unknownCheck = await expectFail('PARTNER_H5', '/partner/auth/phone/check', {
method: 'POST',
body: JSON.stringify({ phone: '13899999999' }),
});
if (!unknownCheck.includes('未找到合伙人账号')) throw new Error(`unexpected: ${unknownCheck}`);
console.log('2. PARTNER sms/send unknown');
const unknownSms = await expectFail('PARTNER_H5', '/partner/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone: '13899999999', scene: 'PARTNER_LOGIN' }),
});
if (!unknownSms.includes('未找到合伙人账号')) throw new Error(`unexpected: ${unknownSms}`);
console.log('3. PARTNER sms/login bound phone');
const login = await loginWithSms(
'PARTNER_H5',
'13700000001',
'PARTNER_LOGIN',
'/partner/auth/login/sms',
'/partner/auth/sms/send',
);
if (!login.accessToken) throw new Error('login missing token');
console.log('4. PARTNER /partner/me after login');
const me = await req('PARTNER_H5', '/partner/me', { token: login.accessToken });
if (!me?.id || !me?.phone) throw new Error('partner/me missing profile');
console.log('5. Admin partner logs list');
const admin = await req('HQ_WEB', '/admin/auth/login/password', {
method: 'POST',
body: JSON.stringify({
loginName: process.env.SUPER_ADMIN_LOGIN ?? 'admin',
password: process.env.SUPER_ADMIN_PASSWORD ?? 'dukang@123!',
}),
});
const logs = await req('HQ_WEB', '/admin/logs/partners?page=1&pageSize=10&category=login', {
token: admin.accessToken,
});
if (!Array.isArray(logs.items)) throw new Error('partner logs missing items');
const hasLogin = logs.items.some((r) => r.eventName === 'partner_sms_login' || r.eventName === 'partner_login_success');
if (!hasLogin) throw new Error('partner login log not found');
console.log('\n✅ partner-auth tests passed');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
-86
View File
@@ -1,86 +0,0 @@
/**
* 门店登录 / 建店短信校验专项冒烟(需 API 已启动且 MOCK_SMS 开启)
* 用法: node scripts/test-shop-auth.mjs
*/
import { resolveSmsCode, sendSmsWithCooldown } from './sms-test-helper.mjs';
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
async function req(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
return json.data;
}
async function expectFail(clientApp, path, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-Client-App': clientApp,
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
};
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
const json = await res.json();
if (json.code === 0) throw new Error(`${path}: expected failure`);
return json.message;
}
async function main() {
console.log('1. STORE_LOGIN unbound phone');
const unbound = await expectFail('SHOP_H5', '/shop/auth/sms/send', {
method: 'POST',
body: JSON.stringify({ phone: '13899999999', scene: 'STORE_LOGIN' }),
});
if (!unbound.includes('未绑定门店')) throw new Error(`unexpected: ${unbound}`);
console.log('2. STORE_LOGIN bound phone');
await sendSmsWithCooldown('SHOP_H5', '/shop/auth/sms/send', '13910000001', 'STORE_LOGIN');
const code = await resolveSmsCode('13910000001', 'STORE_LOGIN');
const login = await req('SHOP_H5', '/shop/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13910000001', code }),
});
if (!login.accessToken || !login.refreshToken) throw new Error('login missing tokens');
console.log('3. Shop session me + refresh');
const me = await req('SHOP_H5', '/shop/auth/me', { token: login.accessToken });
if (me.phone !== '13910000001') throw new Error('me phone mismatch');
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
method: 'POST',
body: JSON.stringify({ refreshToken: login.refreshToken }),
});
if (!refreshed.accessToken) throw new Error('refresh failed');
console.log('4. Admin create store rejects occupied phone');
await sendSmsWithCooldown('HQ_WEB', '/admin/auth/sms/send', '13600000001', 'HQ_LOGIN');
const adminCode = await resolveSmsCode('13600000001', 'HQ_LOGIN');
const admin = await req('HQ_WEB', '/admin/auth/login/sms', {
method: 'POST',
body: JSON.stringify({ phone: '13600000001', code: adminCode }),
});
const occupied = await expectFail('HQ_WEB', '/admin/stores', {
method: 'POST',
token: admin.accessToken,
body: JSON.stringify({
partnerId: '1',
cityId: '1',
name: '重复手机号测试店',
phone: '13910000001',
district: '测试区',
address: '测试地址1号',
}),
});
if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`);
console.log('\n✅ shop-auth tests passed');
}
main().catch((e) => {
console.error(e);
process.exit(1);
});