|
|
|
|
@@ -74,6 +74,65 @@ app.use((_req: Request, res: Response, next: NextFunction) => {
|
|
|
|
|
}
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ===== 限流(内存级令牌桶:按 IP / IP+用户 计数,超限返回 429)=====
|
|
|
|
|
type RateBucket = { count: number; resetAt: number };
|
|
|
|
|
const rateBuckets = new Map<string, RateBucket>();
|
|
|
|
|
// 周期性清理过期桶,避免内存无界增长
|
|
|
|
|
const rateSweepTimer = setInterval(() => {
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
for (const [k, v] of rateBuckets) {
|
|
|
|
|
if (now > v.resetAt) rateBuckets.delete(k);
|
|
|
|
|
}
|
|
|
|
|
if (rateBuckets.size > 100000) rateBuckets.clear(); // 兜底保护
|
|
|
|
|
}, 60 * 1000);
|
|
|
|
|
if (typeof (rateSweepTimer as any).unref === 'function') (rateSweepTimer as any).unref();
|
|
|
|
|
|
|
|
|
|
function rateLimit(opts: { name: string; windowMs: number; max: number; key?: (req: Request) => string }) {
|
|
|
|
|
return (req: Request, res: Response, next: NextFunction): void => {
|
|
|
|
|
const id = opts.key ? opts.key(req) : clientIpOf(req);
|
|
|
|
|
const k = `${opts.name}:${id}`;
|
|
|
|
|
const now = Date.now();
|
|
|
|
|
const b = rateBuckets.get(k);
|
|
|
|
|
if (!b || now > b.resetAt) {
|
|
|
|
|
rateBuckets.set(k, { count: 1, resetAt: now + opts.windowMs });
|
|
|
|
|
return next();
|
|
|
|
|
}
|
|
|
|
|
b.count += 1;
|
|
|
|
|
if (b.count > opts.max) {
|
|
|
|
|
const retry = Math.max(1, Math.ceil((b.resetAt - now) / 1000));
|
|
|
|
|
res.setHeader('Retry-After', String(retry));
|
|
|
|
|
res.status(429).json({ code: 42900, message: `操作过于频繁,请 ${retry} 秒后再试` });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
return next();
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 全局限流(宽松,主要抵御暴力刷接口/爬取)
|
|
|
|
|
app.use('/api', rateLimit({ name: 'global', windowMs: 60 * 1000, max: 600 }));
|
|
|
|
|
|
|
|
|
|
// 写接口限流键:优先按登录用户,未登录退化到 IP
|
|
|
|
|
function rateWriteKey(req: Request): string {
|
|
|
|
|
try {
|
|
|
|
|
const uid = resolveRequestUserId(req);
|
|
|
|
|
if (uid) return `u${uid}`;
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
return clientIpOf(req);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 5xx 统一脱敏:内部错误只记服务端日志,对外返回通用提示,避免泄露 SQL/堆栈/内部细节
|
|
|
|
|
app.use((req: Request, res: Response, next: NextFunction) => {
|
|
|
|
|
const originalJson = res.json.bind(res);
|
|
|
|
|
(res as any).json = (body: any) => {
|
|
|
|
|
if (res.statusCode >= 500 && body && typeof body === 'object' && typeof body.message === 'string') {
|
|
|
|
|
logServerError(`http${res.statusCode} ${req.method} ${req.originalUrl}`, new Error(body.message));
|
|
|
|
|
body = { ...body, message: '服务器内部错误,请稍后重试' };
|
|
|
|
|
}
|
|
|
|
|
return originalJson(body);
|
|
|
|
|
};
|
|
|
|
|
next();
|
|
|
|
|
});
|
|
|
|
|
app.use(
|
|
|
|
|
express.json({
|
|
|
|
|
// 保留原始 HTTP 报文,供微信支付 V3 回调验签使用
|
|
|
|
|
@@ -578,8 +637,8 @@ const handleFileUpload = (req: Request, res: Response) => {
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
app.post('/api/upload', handleFileUpload);
|
|
|
|
|
app.post('/api/user/upload', handleFileUpload);
|
|
|
|
|
app.post('/api/upload', rateLimit({ name: 'upload', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), handleFileUpload);
|
|
|
|
|
app.post('/api/user/upload', rateLimit({ name: 'upload_user', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), handleFileUpload);
|
|
|
|
|
|
|
|
|
|
// 微信官方内容安全文本检测接口 (对接 security.msgSecCheck)
|
|
|
|
|
app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => {
|
|
|
|
|
@@ -1806,7 +1865,7 @@ app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) =>
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 创建新订单 (落盘 MySQL)
|
|
|
|
|
app.post('/api/orders', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/orders', rateLimit({ name: 'order_create', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const buyerId = resolveSecureUserId(
|
|
|
|
|
req,
|
|
|
|
|
@@ -2590,7 +2649,7 @@ app.post('/api/auth/wechat-phone', async (req: Request, res: Response) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 微信小程序 JSAPI 统一下单预支付接口 (支持真实商户号配置与沙盒收银台无缝切换)
|
|
|
|
|
app.post('/api/pay/wechat/prepay', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/pay/wechat/prepay', rateLimit({ name: 'prepay', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const {
|
|
|
|
|
orderId,
|
|
|
|
|
@@ -2998,7 +3057,7 @@ app.post('/api/orders/:id/refund', async (req: Request, res: Response) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 举报不良陪玩 (涉黄/涉赌/线下欺诈等,进入平台风控处置池)
|
|
|
|
|
app.post('/api/reports', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/reports', rateLimit({ name: 'report_create', windowMs: 60 * 1000, max: 5, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const { reporterId, targetType, targetId, targetName, reason, description, evidence } = req.body || {};
|
|
|
|
|
if (!targetId || !reason) {
|
|
|
|
|
@@ -3611,7 +3670,7 @@ app.post('/api/users/:id/settings', (req: Request, res: Response) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// 7.2.2 钱包充值接口 (真实落盘 balance 与 finance_records)
|
|
|
|
|
app.post('/api/users/:id/recharge', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/users/:id/recharge', rateLimit({ name: 'recharge', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
// 资金安全:充值身份以服务端会话 token 为准,严禁为他人的钱包充值/刷余额
|
|
|
|
|
const userId = resolveSecureUserId(req, Number(req.params.id), '充值余额');
|
|
|
|
|
@@ -3658,7 +3717,7 @@ app.post('/api/users/:id/recharge', async (req: Request, res: Response) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 7.2.3 佣金提现申请接口 (真实扣减 commission 并落盘 withdrawals 与 finance_records)
|
|
|
|
|
app.post('/api/users/:id/withdraw', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/users/:id/withdraw', rateLimit({ name: 'withdraw', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const pathUserId = Number(req.params.id);
|
|
|
|
|
// 资金安全:身份以服务端会话 token 为准,生产环境必须登录,严禁以他人身份提现
|
|
|
|
|
@@ -4007,7 +4066,7 @@ app.get('/api/admin/message/templates', async (_req: Request, res: Response) =>
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/posts', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/posts', rateLimit({ name: 'post_create', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const { content, userId } = req.body || {};
|
|
|
|
|
const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '发布动态');
|
|
|
|
|
@@ -4033,7 +4092,7 @@ app.post('/api/posts', async (req: Request, res: Response) => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/posts/:id/like', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/posts/:id/like', rateLimit({ name: 'post_like', windowMs: 60 * 1000, max: 60, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const userId = resolveSecureUserId(
|
|
|
|
|
req,
|
|
|
|
|
@@ -4054,7 +4113,7 @@ app.post('/api/posts/:id/like', async (req: Request, res: Response) => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/posts/:id/view', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/posts/:id/view', rateLimit({ name: 'post_view', windowMs: 3000, max: 1, key: (req) => `${clientIpOf(req)}:${req.params.id}` }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const result = await repo.viewPost(String(req.params.id));
|
|
|
|
|
res.json({
|
|
|
|
|
@@ -4241,7 +4300,7 @@ app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
app.post('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/posts/:id/comments', rateLimit({ name: 'post_comment', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const postId = String(req.params.id);
|
|
|
|
|
const { content, userId, author, avatar } = req.body || {};
|
|
|
|
|
@@ -4279,7 +4338,7 @@ app.post('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 7.7 友圈搭子互动上车约伴申请接口
|
|
|
|
|
app.post('/api/posts/:id/apply', async (req: Request, res: Response) => {
|
|
|
|
|
app.post('/api/posts/:id/apply', rateLimit({ name: 'post_apply', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => {
|
|
|
|
|
try {
|
|
|
|
|
const postId = String(req.params.id);
|
|
|
|
|
const { applicantId, applicantName, applicantAvatar } = req.body || {};
|
|
|
|
|
@@ -4575,6 +4634,17 @@ function startOrderTimeoutScheduler(): void {
|
|
|
|
|
console.log(`[Peiwan Scheduler] 订单超时扫描已启动,间隔 ${Math.round(intervalMs / 1000)}s`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 兜底错误处理:统一返回 JSON(避免 Express 默认 HTML 堆栈泄露);5xx 只记日志、对外通用提示
|
|
|
|
|
app.use((err: any, _req: Request, res: Response, _next: NextFunction) => {
|
|
|
|
|
const status = Number(err && err.status) || 500;
|
|
|
|
|
if (status >= 500) {
|
|
|
|
|
logServerError('unhandledError', err);
|
|
|
|
|
res.status(500).json({ code: 50000, message: '服务器内部错误,请稍后重试' });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
res.status(status).json({ code: 40000, message: (err && err.message) || '请求不合法' });
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
async function bootstrap() {
|
|
|
|
|
try {
|
|
|
|
|
await initDatabase();
|
|
|
|
|
|