Merge pull request 'Fix/ecommerce 502 bug' (#1) from fix/ecommerce-502-bug into master

Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-06-03 02:53:24 +00:00
6 changed files with 203 additions and 4 deletions
+20
View File
@@ -10,6 +10,7 @@
"dependencies": {
"alipay-sdk": "^4.14.0",
"bcryptjs": "^2.4.3",
"busboy": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
@@ -159,6 +160,17 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1527,6 +1539,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/superagent": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/superagent/-/superagent-8.0.6.tgz",
+1
View File
@@ -19,6 +19,7 @@
"dependencies": {
"alipay-sdk": "^4.14.0",
"bcryptjs": "^2.4.3",
"busboy": "^1.6.0",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.21.0",
+15 -2
View File
@@ -57,10 +57,10 @@ async function main() {
// CORS
app.use(cors(buildCorsOptions()))
// Rate limiting: global (100 req/min per IP)
// Rate limiting: global (300 req/min per IP)
const globalLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: '请求过于频繁,请稍后再试' },
@@ -90,6 +90,19 @@ async function main() {
app.use('/api/ai/image', aiGenerationLimiter)
app.use('/api/ai/video', aiGenerationLimiter)
// Rate limiting: AI chat endpoint (60 req/min per IP — ecommerce flows need ~7 sequential calls)
const aiChatLimiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'AI对话请求过于频繁,请稍后再试' },
})
app.use('/api/ai/chat', aiChatLimiter)
// Skip JSON body-parser for binary upload routes (busboy handles multipart parsing)
app.use('/api/oss/upload-binary', (req, res, next) => { req._body = true; next(); })
// JSON body limit: 5MB globally (upload routes override locally)
app.use('/api/oss/upload', express.json({ limit: '200mb' }))
app.use(express.json({ limit: process.env.JSON_BODY_LIMIT || '5mb' }))
+94
View File
@@ -0,0 +1,94 @@
const crypto = require('node:crypto');
const { pool, requireAuth, requireAdmin } = require('./context');
const MAX_ERRORS_PER_PAGE = 50;
const ERROR_RETENTION_HOURS = 72;
async function initTable(client) {
await client.query(`
CREATE TABLE IF NOT EXISTS client_errors (
id SERIAL PRIMARY KEY,
message TEXT NOT NULL,
stack TEXT,
source VARCHAR(32),
url TEXT,
user_agent TEXT,
session_id TEXT,
user_id INTEGER,
count INTEGER DEFAULT 1,
first_seen TIMESTAMP DEFAULT NOW(),
last_seen TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_client_errors_last_seen ON client_errors(last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_client_errors_user ON client_errors(user_id);
`);
}
async function storeError(client, error) {
const normalizedMessage = error.message.slice(0, 1000);
const { rows } = await client.query(
`SELECT id, count FROM client_errors WHERE message = $1 AND url = $2 AND source = $3 AND last_seen > NOW() - INTERVAL '10 minutes'`,
[normalizedMessage, error.url || '', error.source || 'manual']
);
if (rows.length > 0) {
await client.query(
`UPDATE client_errors SET count = count + 1, last_seen = NOW(), stack = COALESCE($1, stack) WHERE id = $2`,
[error.stack, rows[0].id]
);
} else {
await client.query(
`INSERT INTO client_errors (message, stack, source, url, user_agent, session_id, user_id) VALUES ($1,$2,$3,$4,$5,$6,$7)`,
[normalizedMessage, error.stack, error.source, error.url, error.userAgent, error.sessionId, error.userId || null]
);
}
}
module.exports = function mountClientErrorRoutes(router) {
router.post('/client-errors', requireAuth, async (req, res) => {
try {
const { errors } = req.body;
if (!Array.isArray(errors) || !errors.length) return res.json({ ok: true });
const { pool: db } = require('./context');
const client = await db.connect();
try {
await initTable(client);
for (const err of errors.slice(0, 10)) {
await storeError(client, { ...err, userId: req.user?.id });
}
} finally { client.release(); }
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.get('/client-errors', requireAuth, requireAdmin, async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const { pool: db } = require('./context');
const client = await db.connect();
try {
await initTable(client);
await client.query(`DELETE FROM client_errors WHERE last_seen < NOW() - INTERVAL '$1 hours'`, [ERROR_RETENTION_HOURS]);
const { rows } = await client.query(
`SELECT * FROM client_errors ORDER BY last_seen DESC LIMIT $1 OFFSET $2`,
[MAX_ERRORS_PER_PAGE, (page - 1) * MAX_ERRORS_PER_PAGE]
);
const { rows: countRows } = await client.query('SELECT COUNT(*) as total FROM client_errors');
res.json({ items: rows, total: parseInt(countRows[0]?.total) || 0, page, pageSize: MAX_ERRORS_PER_PAGE });
} finally { client.release(); }
} catch (e) {
res.status(500).json({ error: e.message });
}
});
router.delete('/client-errors', requireAuth, requireAdmin, async (req, res) => {
try {
const { pool: db } = require('./context');
await db.query('DELETE FROM client_errors');
res.json({ ok: true });
} catch (e) {
res.status(500).json({ error: e.message });
}
});
};
+2 -1
View File
@@ -17,7 +17,8 @@ const { registerConversationRoutes } = require('./conversations')
const { registerReportRoutes } = require('./reports')
const { registerAssetRoutes } = require('./assets')
const { registerNotificationRoutes } = require('./notifications')
const { registerDraftRoutes } = require('./drafts')
const { registerDraftRoutes } = require('./drafts');
const mountClientErrorRoutes = require('./clientErrors')
const router = express.Router()
+71 -1
View File
@@ -1,6 +1,7 @@
const crypto = require("node:crypto");
const dns = require("node:dns");
const { requireAuth } = require("./context");
const Busboy = require("busboy");
const { putObject, isOssConfigured, createSignedReadUrl } = require("../ossClient");
const DATA_URL_PATTERN = /^data:([^;,]+);base64,([A-Za-z0-9+/=\s]+)$/;
@@ -61,6 +62,9 @@ const MIME_EXTENSIONS = {
"image/jpeg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/avif": "avif",
"image/heic": "heic",
"image/heif": "heif",
"image/gif": "gif",
"video/mp4": "mp4",
"video/webm": "webm",
@@ -166,7 +170,7 @@ function parseUploadPayload(body) {
const rawData = String(body?.dataUrl || body?.data || "");
const dataUrlMatch = rawData.match(DATA_URL_PATTERN);
const mimeType = normalizeMimeType(body?.mimeType || dataUrlMatch?.[1]);
const base64 = (dataUrlMatch?.[2] || rawData).replace(/\s+/g, "");
const base64 = (dataUrlMatch?.[2] || rawData.replace(/^data:[^;,]+;base64,/, "")).replace(/\s+/g, "");
if (!base64) {
const error = new Error("Missing upload data");
error.status = 400;
@@ -317,6 +321,72 @@ function registerOssRoutes(router) {
if (!res.headersSent) res.status(502).json({ error: "Proxy failed" });
}
});
router.post("/oss/upload-binary", requireAuth, (req, res) => {
if (!isOssConfigured()) {
return res.status(501).json({ error: "OSS 未配置" });
}
const busboy = Busboy({ headers: req.headers, limits: { fileSize: 10 * 1024 * 1024 } });
let fileBuffer = null;
let fileMimeType = "application/octet-stream";
let fileName = "upload";
let scope = "";
busboy.on("file", (fieldname, stream, info) => {
if (fieldname !== "file") {
stream.resume();
return;
}
fileName = info.filename || "upload";
fileMimeType = info.mimeType || "application/octet-stream";
const chunks = [];
stream.on("data", (chunk) => chunks.push(chunk));
stream.on("end", () => {
fileBuffer = Buffer.concat(chunks);
});
stream.on("error", (err) => {
console.error("[oss/upload-binary] stream error:", err.message);
res.status(500).json({ error: "文件读取失败" });
});
});
busboy.on("field", (fieldname, val) => {
if (fieldname === "scope") scope = String(val).trim();
if (fieldname === "mimeType") fileMimeType = String(val).trim();
});
busboy.on("finish", async () => {
try {
if (!fileBuffer) {
return res.status(400).json({ error: "未收到文件" });
}
const normalizedMimeType = normalizeMimeType(fileMimeType);
const ext = MIME_EXTENSIONS[normalizedMimeType] || "bin";
const assetDir = getAssetDirectory(normalizedMimeType);
const safeUserId = String(req.user.id).replace(/[^a-zA-Z0-9_-]/g, "");
const objectKey =
getProfileObjectKey(scope, req.user.id, ext, normalizedMimeType) ||
getCommunityObjectKey(scope, req.user.id, ext, normalizedMimeType) ||
`tmp/${safeUserId}/generation-inputs/${assetDir}/${Date.now()}_${crypto.randomUUID()}.${ext}`;
await putObject(objectKey, fileBuffer, normalizedMimeType, { "x-oss-object-acl": "public-read" });
const url = buildOssPublicUrl(objectKey);
const signedUrl = typeof createSignedReadUrl === "function" ? createSignedReadUrl(objectKey) : url;
console.info("[oss/upload-binary] mimeType:", normalizedMimeType, "size:", fileBuffer.length, "userId:", req.user.id);
res.status(201).json({ ossKey: objectKey, url, signedUrl });
} catch (err) {
const status = err.status || 500;
console.error("[oss/upload-binary] failed:", err.message);
res.status(status).json({ error: err.message || "上传素材失败" });
}
});
busboy.on("error", (err) => {
console.error("[oss/upload-binary] busboy error:", err.message);
res.status(500).json({ error: "文件解析失败" });
});
req.pipe(busboy);
});
}
module.exports = {