- 新接口 POST /api/admin/users/:id/balance(requireAdmin):事务锁行、扣减不可为负、单次上限 100 万、写 finance_records 并推送站内通知 - 后台用户列表每行新增「调余额」按钮与弹窗(当前余额/方向/金额/备注) - 新增 admin-balance-adjust 护栏;113 套件/737 用例全绿
5324 行
212 KiB
TypeScript
5324 行
212 KiB
TypeScript
// 已读取AGENTS.md
|
||
import express, { Request, Response, NextFunction } from 'express';
|
||
import cors from 'cors';
|
||
import path from 'path';
|
||
import fs from 'fs';
|
||
import https from 'https';
|
||
import crypto from 'crypto';
|
||
import multer from 'multer';
|
||
import { ActivityService, ActivityPayType } from './core/activity/activity.service';
|
||
import { CompanionOrderService } from './core/order/companion-order.service';
|
||
import { AuthRoleManager, UserRoleFlag, WorkMode } from './core/auth/auth-role.manager';
|
||
import { FinanceCalculator } from './core/finance/finance.calculator';
|
||
import { ServiceCategoryManager, CompanionTrack, ExpenseBurdenType } from './core/companion/service-category';
|
||
import { ContentSecurityService } from './core/security/content-security.service';
|
||
import {
|
||
signUserToken as signUserTokenRaw,
|
||
verifyUserToken as verifyUserTokenRaw,
|
||
} from './core/security/user-token';
|
||
import { initDatabase, pool } from './db/mysql';
|
||
import { repo } from './db/repository';
|
||
import {
|
||
createJsapiPrepay,
|
||
buildJsapiPayParams,
|
||
verifyNotifySignature,
|
||
decryptNotifyResource,
|
||
queryOrderByOutTradeNo,
|
||
transferToWallet,
|
||
queryTransferByOutBillNo,
|
||
createRefund,
|
||
} from './core/pay/wechat-pay-v3';
|
||
import { sendSubscribeMessageHttp, buildTemplateData } from './core/notify/wechat-subscribe.service';
|
||
import { setWeChatRefundFn } from './core/pay/refund-bridge';
|
||
|
||
const app = express();
|
||
const port = process.env.PORT || 3000;
|
||
|
||
// ===== 运行模式与安全开关(必须最先定义:鉴权密钥/闸门依赖它们)=====
|
||
const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
||
// 危险兜底必须显式开启:
|
||
// - ALLOW_INSECURE_DEV_AUTH=1:仅在本地联调时允许"信任客户端声明 userId"
|
||
// - ALLOW_SANDBOX_PAY=1:仅在联调时允许沙箱支付(无真实收款推进订单)
|
||
const ALLOW_INSECURE_DEV_AUTH = process.env.ALLOW_INSECURE_DEV_AUTH === '1';
|
||
const ALLOW_SANDBOX_PAY = process.env.ALLOW_SANDBOX_PAY === '1';
|
||
// 安全闸门不依赖 NODE_ENV(线上曾以 NODE_ENV=development 运行导致闸门全部失效):
|
||
// 默认按生产口径收紧,只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。
|
||
const SECURE_MODE = !ALLOW_INSECURE_DEV_AUTH;
|
||
// 订阅消息跳转版本:线上若以 NODE_ENV=development 运行,默认会落到 developer(真实用户收不到),
|
||
// 因此支持用 MINIPROGRAM_STATE=formal|trial|developer 显式指定。
|
||
const MINIPROGRAM_STATE = ((): 'formal' | 'trial' | 'developer' => {
|
||
const v = String(process.env.MINIPROGRAM_STATE || '').toLowerCase();
|
||
if (v === 'formal' || v === 'trial' || v === 'developer') return v;
|
||
return IS_PRODUCTION ? 'formal' : 'developer';
|
||
})();
|
||
|
||
// 反向代理(Nginx)后正确识别客户端 IP(登录限流等使用)
|
||
app.set('trust proxy', 1);
|
||
// 隐藏技术栈标识
|
||
app.disable('x-powered-by');
|
||
|
||
// CORS 收敛:默认仅同源可用;如需跨域,配置 CORS_ORIGINS(逗号分隔)显式放行
|
||
const configuredCorsOrigins = String(process.env.CORS_ORIGINS || '')
|
||
.split(',')
|
||
.map((s) => s.trim())
|
||
.filter(Boolean);
|
||
const allowedCorsOrigins = new Set(configuredCorsOrigins);
|
||
if (process.env.NODE_ENV !== 'production') {
|
||
// 本地开发默认允许 Vite dev server 直连 API(若未使用代理)
|
||
allowedCorsOrigins.add('http://localhost:5173');
|
||
allowedCorsOrigins.add('http://127.0.0.1:5173');
|
||
allowedCorsOrigins.add('http://localhost:3000');
|
||
}
|
||
app.use(
|
||
cors({
|
||
origin(origin, cb) {
|
||
// 非浏览器请求(无 Origin,如小程序/服务端)放行
|
||
if (!origin) return cb(null, true);
|
||
return cb(null, allowedCorsOrigins.has(origin));
|
||
},
|
||
credentials: false,
|
||
})
|
||
);
|
||
|
||
// 基础安全响应头
|
||
app.use((_req: Request, res: Response, next: NextFunction) => {
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('X-Frame-Options', 'DENY');
|
||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||
res.setHeader('X-XSS-Protection', '1; mode=block');
|
||
res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
|
||
if (process.env.NODE_ENV === 'production') {
|
||
res.setHeader('Strict-Transport-Security', 'max-age=15552000; includeSubDomains');
|
||
}
|
||
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();
|
||
};
|
||
}
|
||
|
||
// 全局限流(宽松,主要抵御暴力刷接口/爬取)。优先按登录用户分桶,未登录退化到 IP,
|
||
// 避免反向代理未透传 X-Forwarded-For 时全站用户共用同一 IP 桶被误伤。
|
||
app.use('/api', rateLimit({ name: 'global', windowMs: 60 * 1000, max: 1200, key: rateWriteKey }));
|
||
|
||
// 写接口限流键:优先按登录用户,未登录退化到 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 回调验签使用
|
||
verify: (req: any, _res, buf: Buffer) => {
|
||
req.rawBody = buf ? buf.toString('utf8') : '';
|
||
},
|
||
})
|
||
);
|
||
|
||
// 静态文件与对象存储目录配置
|
||
const uploadDir = path.resolve(__dirname, '../public/uploads');
|
||
if (!fs.existsSync(uploadDir)) {
|
||
fs.mkdirSync(uploadDir, { recursive: true });
|
||
}
|
||
app.use('/uploads', express.static(uploadDir));
|
||
|
||
// Multer 文件上传引擎配置
|
||
const storage = multer.diskStorage({
|
||
destination: (_req, _file, cb) => {
|
||
cb(null, uploadDir);
|
||
},
|
||
filename: (_req, file, cb) => {
|
||
const ext = path.extname(file.originalname).toLowerCase() || '.png';
|
||
const uniqueSuffix = `${Date.now()}_${Math.round(Math.random() * 1e9)}`;
|
||
cb(null, `img_${uniqueSuffix}${ext}`);
|
||
}
|
||
});
|
||
|
||
const upload = multer({
|
||
storage,
|
||
limits: { fileSize: 10 * 1024 * 1024 }, // 限制单个文件最大 10MB
|
||
fileFilter: (_req, file, cb) => {
|
||
const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif', 'image/jpg'];
|
||
if (allowed.includes(file.mimetype)) {
|
||
cb(null, true);
|
||
} else {
|
||
cb(new Error('仅支持上传 JPG、PNG、WEBP、GIF 格式的图片文件'));
|
||
}
|
||
}
|
||
});
|
||
|
||
// 实例化领域服务(作为核心算法与计算引擎)
|
||
export const activityService = new ActivityService();
|
||
export const orderService = new CompanionOrderService();
|
||
export const authRoleManager = new AuthRoleManager();
|
||
export const financeCalculator = new FinanceCalculator();
|
||
export const categoryManager = new ServiceCategoryManager();
|
||
export const contentSecurityService = new ContentSecurityService();
|
||
|
||
// 1. 基础探活与健康检查
|
||
app.get('/', (_req: Request, res: Response) => {
|
||
res.json({
|
||
status: 'ok',
|
||
service: 'peiwan-server',
|
||
storage: 'MySQL 8.0 (peiwan database)',
|
||
description: '陪玩与同城生活陪伴服务核心API',
|
||
version: '1.0.0',
|
||
time: new Date().toISOString()
|
||
});
|
||
});
|
||
|
||
app.get('/api/health', (_req: Request, res: Response) => {
|
||
// features 用于部署自检:小程序请求新接口 404 时,可先 curl 本接口确认线上构建是否已更新
|
||
res.json({
|
||
status: 'healthy',
|
||
database: 'connected',
|
||
uptime: process.uptime(),
|
||
build: '2026-10-19',
|
||
// 运行模式自检:非 production 时用户身份校验会放宽、且允许沙箱支付(严禁用于公网)
|
||
mode: IS_PRODUCTION ? 'production' : 'development',
|
||
sandboxPayAllowed: ALLOW_SANDBOX_PAY,
|
||
features: {
|
||
userToken: true,
|
||
adminPasswordGate: true,
|
||
securityHeaders: true,
|
||
rateLimit: true,
|
||
errorSanitize: true,
|
||
adminTokenRevoke: true,
|
||
notifications: true,
|
||
expertServicesSelfManage: true,
|
||
coupons: true,
|
||
companionSearch: true,
|
||
},
|
||
});
|
||
});
|
||
|
||
// 统一校验用户是否处于封禁状态
|
||
async function assertUserNotBanned(userId: number, actionName: string = '操作'): Promise<void> {
|
||
if (!userId || isNaN(Number(userId))) return;
|
||
const user = await repo.getUserById(Number(userId));
|
||
if (user && (user.status === '已封禁' || user.status === '封禁')) {
|
||
const err: any = new Error(`您的账号已被管理员封禁,无法${actionName}`);
|
||
err.status = 403;
|
||
err.code = 40300;
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
// 读取用户微信 openid(用于微信官方内容安全 security.msgSecCheck 溯源),失败返回 undefined
|
||
async function resolveUserOpenid(userId: number): Promise<string | undefined> {
|
||
try {
|
||
const [rows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(userId)]);
|
||
const openid = rows && rows[0] && rows[0].wechat_openid;
|
||
return openid ? String(openid) : undefined;
|
||
} catch (_) {
|
||
return undefined;
|
||
}
|
||
}
|
||
|
||
// ===== 管理后台鉴权(HMAC 签名 token,防止客户端伪造管理员身份)=====
|
||
const DEFAULT_ADMIN_TOKEN_SECRET = 'peiwan_admin_token_secret_v1';
|
||
// 生产环境安全闸门:若未配置 ADMIN_TOKEN_SECRET,绝不使用仓库内置默认密钥
|
||
// (该默认值公开于代码库,可被用于伪造管理员 token 触发全站管理端越权)。
|
||
// 生产环境改为启用进程内随机密钥——默认密钥签发的 token 一律失效,并高优告警要求尽快配置固定密钥。
|
||
const ADMIN_TOKEN_SECRET = (() => {
|
||
const provided = String(process.env.ADMIN_TOKEN_SECRET || '').trim();
|
||
if (provided) return provided;
|
||
if (SECURE_MODE) {
|
||
console.error(
|
||
'[Peiwan Server][SECURITY] 未配置 ADMIN_TOKEN_SECRET,已临时改用进程内随机密钥(默认密钥签发的 token 一律失效)。' +
|
||
'请尽快在环境变量中设置固定的强随机 ADMIN_TOKEN_SECRET(并建议单独设置 USER_TOKEN_SECRET),否则每次重启后台登录都会失效。'
|
||
);
|
||
return crypto.randomBytes(48).toString('base64url');
|
||
}
|
||
return DEFAULT_ADMIN_TOKEN_SECRET;
|
||
})();
|
||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || 'admin';
|
||
const ADMIN_PASSWORD_SET = String(process.env.ADMIN_PASSWORD || '').trim();
|
||
const ADMIN_PASSWORDS = (ADMIN_PASSWORD_SET || 'admin|admin123').split('|').filter(Boolean);
|
||
// 生产环境安全闸门:未配置 ADMIN_PASSWORD(或仍使用内置默认口令)时,禁止后台登录,避免默认口令被爆破
|
||
const ADMIN_PASSWORD_CONFIGURED = Boolean(ADMIN_PASSWORD_SET) && !/^(admin|admin123)$/i.test(ADMIN_PASSWORD_SET);
|
||
|
||
// 后台令牌有效期(默认 24 小时,可用 ADMIN_TOKEN_TTL_HOURS 调整)
|
||
const ADMIN_TOKEN_TTL_MS = Math.max(1, Number(process.env.ADMIN_TOKEN_TTL_HOURS || 24)) * 3600 * 1000;
|
||
|
||
// 后台登录态版本号:令牌携带签发时的版本,与当前版本不一致即失效(支持"吊销所有会话")。
|
||
// 版本持久化在 platform_configs,重启后依然生效;多实例部署下每 30 秒同步一次。
|
||
let adminTokenEpoch = 1;
|
||
|
||
async function loadAdminTokenEpoch(): Promise<void> {
|
||
try {
|
||
const rec = await repo.getPlatformConfigByKey('admin_token_epoch');
|
||
const v = Number(rec && rec.valueJson && (rec.valueJson as any).epoch);
|
||
if (Number.isFinite(v) && v >= 1) adminTokenEpoch = v;
|
||
} catch (_) {
|
||
// 读取失败时保持当前内存值
|
||
}
|
||
}
|
||
|
||
function signAdminToken(): string {
|
||
const payload = Buffer.from(JSON.stringify({ role: 'admin', ts: Date.now(), ver: adminTokenEpoch })).toString('base64url');
|
||
const sig = crypto.createHmac('sha256', ADMIN_TOKEN_SECRET).update(payload).digest('base64url');
|
||
return `${payload}.${sig}`;
|
||
}
|
||
|
||
function verifyAdminToken(token: string): boolean {
|
||
const parts = String(token || '').split('.');
|
||
if (parts.length !== 2) return false;
|
||
const [payload, sig] = parts;
|
||
const expected = crypto.createHmac('sha256', ADMIN_TOKEN_SECRET).update(payload).digest('base64url');
|
||
const sigBuf = Buffer.from(sig);
|
||
const expBuf = Buffer.from(expected);
|
||
if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return false;
|
||
try {
|
||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
||
if (data.role !== 'admin' || !data.ts) return false;
|
||
// 版本不一致 = 该令牌已被吊销
|
||
if (Number(data.ver) !== adminTokenEpoch) return false;
|
||
if (Date.now() - Number(data.ts) > ADMIN_TOKEN_TTL_MS) return false;
|
||
return true;
|
||
} catch (_) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function requireAdmin(req: Request, res: Response, next: NextFunction): void {
|
||
const auth = String(req.headers['authorization'] || '');
|
||
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||
if (!token || !verifyAdminToken(token)) {
|
||
// 采用 code 50008(HTTP 200)以契合后台拦截器的"登录失效→重新登录"约定
|
||
res.json({ code: 50008, message: '登录状态已失效,请重新登录管理后台' });
|
||
return;
|
||
}
|
||
next();
|
||
}
|
||
|
||
// 仅依据服务端签名校验通过的 admin token 判定管理员请求;
|
||
// 严禁信任任何客户端可伪造的角色字段(operatorRole / x-user-role)。
|
||
function isVerifiedAdminRequest(req: Request): boolean {
|
||
const auth = String(req.headers['authorization'] || '');
|
||
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||
return Boolean(token) && verifyAdminToken(token);
|
||
}
|
||
|
||
// 常量时间字符串比较(避免口令比较的时序侧信道)
|
||
function safeStringEqual(a: string, b: string): boolean {
|
||
const ba = Buffer.from(String(a));
|
||
const bb = Buffer.from(String(b));
|
||
if (ba.length !== bb.length) return false;
|
||
return crypto.timingSafeEqual(ba, bb);
|
||
}
|
||
|
||
// 管理端登录暴力破解防护(内存级:按 IP+账号 统计失败次数,超限锁定一段时间)
|
||
const LOGIN_MAX_FAILURES = 5;
|
||
const LOGIN_WINDOW_MS = 10 * 60 * 1000;
|
||
const loginFailureMap = new Map<string, { count: number; firstAt: number }>();
|
||
|
||
function clientIpOf(req: Request): string {
|
||
const xff = req.headers['x-forwarded-for'];
|
||
const raw = Array.isArray(xff) ? xff[0] : (xff || (req.socket && req.socket.remoteAddress) || 'unknown');
|
||
return String(raw).split(',')[0].trim() || 'unknown';
|
||
}
|
||
function isLoginLocked(key: string): boolean {
|
||
const rec = loginFailureMap.get(key);
|
||
if (!rec) return false;
|
||
if (Date.now() - rec.firstAt > LOGIN_WINDOW_MS) {
|
||
loginFailureMap.delete(key);
|
||
return false;
|
||
}
|
||
return rec.count >= LOGIN_MAX_FAILURES;
|
||
}
|
||
function recordLoginFailure(key: string): void {
|
||
const rec = loginFailureMap.get(key);
|
||
const now = Date.now();
|
||
if (!rec || now - rec.firstAt > LOGIN_WINDOW_MS) {
|
||
loginFailureMap.set(key, { count: 1, firstAt: now });
|
||
} else {
|
||
rec.count += 1;
|
||
}
|
||
}
|
||
|
||
// ===== 小程序用户会话 token(HMAC 签名,防止客户端伪造他人 userId 进行资金操作)=====
|
||
const USER_TOKEN_SECRET = process.env.USER_TOKEN_SECRET || `${ADMIN_TOKEN_SECRET}_user`;
|
||
const USER_TOKEN_TTL_MS = 7 * 24 * 3600 * 1000;
|
||
|
||
function signUserToken(userId: number): string {
|
||
return signUserTokenRaw(Number(userId), USER_TOKEN_SECRET, USER_TOKEN_TTL_MS);
|
||
}
|
||
|
||
function verifyUserToken(token: string): number | null {
|
||
return verifyUserTokenRaw(token, USER_TOKEN_SECRET, USER_TOKEN_TTL_MS);
|
||
}
|
||
|
||
function extractBearerToken(req: Request): string {
|
||
const auth = String(req.headers['authorization'] || '');
|
||
if (auth.startsWith('Bearer ')) return auth.slice(7).trim();
|
||
const alt = req.headers['x-user-token'];
|
||
return typeof alt === 'string' ? alt.trim() : '';
|
||
}
|
||
|
||
// 解析请求携带的用户会话身份(无 token / token 非法时返回 null)
|
||
function resolveRequestUserId(req: Request): number | null {
|
||
const token = extractBearerToken(req);
|
||
if (!token) return null;
|
||
return verifyUserToken(token);
|
||
}
|
||
|
||
/**
|
||
* 资金与敏感操作的身份校验:
|
||
* - 携带合法用户 token:以 token 内 userId 为准;若与客户端声明不一致则拒绝(杜绝伪造他人身份)。
|
||
* - 未携带 token:生产环境必须登录(401);非生产环境保留客户端身份以兼容本地联调 demo。
|
||
*/
|
||
function resolveSecureUserId(req: Request, claimedUserId: number, action = '操作'): number {
|
||
const tokenUid = resolveRequestUserId(req);
|
||
if (tokenUid) {
|
||
if (claimedUserId && Number(claimedUserId) !== tokenUid) {
|
||
const err: any = new Error(`身份校验失败:无权以他人身份${action}`);
|
||
err.status = 403;
|
||
err.code = 40300;
|
||
throw err;
|
||
}
|
||
return tokenUid;
|
||
}
|
||
// 身份兜底(信任客户端声明的 userId)默认关闭:必须显式设置 ALLOW_INSECURE_DEV_AUTH=1,
|
||
// 且生产环境永不开放。避免"NODE_ENV 忘记设成 production"导致线上可任意伪造身份。
|
||
if (IS_PRODUCTION || !ALLOW_INSECURE_DEV_AUTH) {
|
||
const err: any = new Error(`请先登录后再${action}`);
|
||
err.status = 401;
|
||
err.code = 40100;
|
||
throw err;
|
||
}
|
||
return Number(claimedUserId) || 0;
|
||
}
|
||
|
||
// 统一服务端错误日志(替代静默吞错,保证资金/结算异常可观测)
|
||
function logServerError(scope: string, err: any): void {
|
||
try {
|
||
const msg = err && err.message ? err.message : String(err);
|
||
console.error(`[SERVER_ERROR][${scope}] ${msg}`);
|
||
} catch (_) {}
|
||
}
|
||
|
||
// 解析订单操作者角色:管理员 / 买家 / 承接大神(三者皆非则无权操作)
|
||
function resolveOrderActor(req: Request, own: any, action: string) {
|
||
if (isVerifiedAdminRequest(req)) {
|
||
return { callerId: 0, isAdmin: true, isBuyer: false, isCompanion: false };
|
||
}
|
||
const claimedId = Number(req.body?.userId || req.query?.userId || req.headers['x-user-id'] || 0);
|
||
const callerId = resolveSecureUserId(req, claimedId, action);
|
||
const buyerId = Number(own?.buyerId || 0);
|
||
const companionIds: number[] = Array.isArray(own?.companionIds) ? own.companionIds : [];
|
||
return {
|
||
callerId,
|
||
isAdmin: false,
|
||
isBuyer: callerId > 0 && callerId === buyerId,
|
||
isCompanion: callerId > 0 && companionIds.includes(callerId),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 大神资料/服务写接口的身份校验:
|
||
* - 管理员放行;
|
||
* - 生产环境必须为已绑定 user_id 的大神本人(companions.user_id === 会话用户);
|
||
* - 非生产环境兼容历史未绑定数据,允许本地联调。
|
||
*/
|
||
function assertCompanionActor(
|
||
req: Request,
|
||
own: { companionId: number; ownerUserId: number | null } | null,
|
||
action: string
|
||
): number {
|
||
if (isVerifiedAdminRequest(req)) return 0;
|
||
const claimedId = Number(req.body?.userId || req.query?.userId || req.headers['x-user-id'] || 0);
|
||
const callerId = resolveSecureUserId(req, claimedId, action);
|
||
if (own && own.ownerUserId != null && callerId === own.ownerUserId) {
|
||
return callerId;
|
||
}
|
||
if (!IS_PRODUCTION && ALLOW_INSECURE_DEV_AUTH) {
|
||
// 本地联调:兼容未绑定 user_id 的历史大神数据(需显式开启 ALLOW_INSECURE_DEV_AUTH=1)
|
||
if (own && own.companionId != null && callerId === own.companionId) return callerId;
|
||
return callerId;
|
||
}
|
||
const e: any = new Error(`无权${action}(非本人大神资料)`);
|
||
e.status = 403;
|
||
e.code = 40300;
|
||
throw e;
|
||
}
|
||
|
||
// ===== 腾讯位置服务(仅服务端调用,Key 绝不下发小程序)=====
|
||
let mapKeyCache: { key: string; at: number } | null = null;
|
||
|
||
async function resolveTencentMapKey(): Promise<string> {
|
||
const envKey = String(process.env.TENCENT_MAP_KEY || '').trim();
|
||
if (envKey) return envKey;
|
||
const now = Date.now();
|
||
if (mapKeyCache && now - mapKeyCache.at < 60_000) return mapKeyCache.key;
|
||
let key = '';
|
||
try {
|
||
const record = await repo.getPlatformConfigByKey('map_location_config');
|
||
const value = (record && record.valueJson) || {};
|
||
key = String(value.mapKey || '').trim();
|
||
} catch (e) {
|
||
logServerError('resolveTencentMapKey', e);
|
||
}
|
||
mapKeyCache = { key, at: now };
|
||
return key;
|
||
}
|
||
|
||
function httpsGetJson(url: string, timeoutMs = 4000): Promise<any> {
|
||
return new Promise((resolve, reject) => {
|
||
const req = https.get(url, (resp) => {
|
||
let data = '';
|
||
resp.on('data', (chunk) => {
|
||
data += chunk;
|
||
});
|
||
resp.on('end', () => {
|
||
try {
|
||
resolve(JSON.parse(data));
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
});
|
||
});
|
||
req.setTimeout(timeoutMs, () => req.destroy(new Error('腾讯位置服务请求超时')));
|
||
req.on('error', reject);
|
||
});
|
||
}
|
||
|
||
const geocodeCache = new Map<string, { geo: { lat: number; lng: number } | null; at: number }>();
|
||
|
||
/** 地址 -> 坐标(腾讯 WebService 地理编码),带进程内缓存 */
|
||
async function geocodeAddress(address: string): Promise<{ lat: number; lng: number } | null> {
|
||
const text = String(address || '').trim();
|
||
if (!text) return null;
|
||
const cached = geocodeCache.get(text);
|
||
if (cached && Date.now() - cached.at < 10 * 60_000) return cached.geo;
|
||
const key = await resolveTencentMapKey();
|
||
if (!key) return null;
|
||
try {
|
||
const url = `https://apis.map.qq.com/ws/geocoder/v1/?address=${encodeURIComponent(text)}&key=${encodeURIComponent(key)}`;
|
||
const data = await httpsGetJson(url);
|
||
if (data && data.status === 0 && data.result && data.result.location) {
|
||
const geo = { lat: Number(data.result.location.lat), lng: Number(data.result.location.lng) };
|
||
if (Number.isFinite(geo.lat) && Number.isFinite(geo.lng)) {
|
||
geocodeCache.set(text, { geo, at: Date.now() });
|
||
return geo;
|
||
}
|
||
}
|
||
logServerError('geocodeAddress', new Error(`腾讯地理编码失败: ${data && (data.message || data.status)}`));
|
||
geocodeCache.set(text, { geo: null, at: Date.now() });
|
||
} catch (e) {
|
||
logServerError('geocodeAddress', e);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 坐标 -> 行政区/地址(腾讯 WebService 逆地址解析) */
|
||
async function reverseGeocode(lat: number, lng: number): Promise<any | null> {
|
||
const key = await resolveTencentMapKey();
|
||
if (!key) return null;
|
||
try {
|
||
const url = `https://apis.map.qq.com/ws/geocoder/v1/?location=${lat},${lng}&key=${encodeURIComponent(key)}&get_poi=0`;
|
||
const data = await httpsGetJson(url);
|
||
if (data && data.status === 0 && data.result) {
|
||
const r = data.result;
|
||
const comp = r.address_component || {};
|
||
const city = comp.city || '';
|
||
const district = comp.district || '';
|
||
return {
|
||
name: [city, district].filter(Boolean).join(' · ') || r.address || '当前位置',
|
||
address: r.address || '',
|
||
latitude: lat,
|
||
longitude: lng,
|
||
};
|
||
}
|
||
} catch (e) {
|
||
logServerError('reverseGeocode', e);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 管理端:吊销所有后台登录态(版本号 +1,所有已签发 admin token 立即失效)
|
||
app.post('/api/admin/session/revoke-all', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const next = adminTokenEpoch + 1;
|
||
await repo.updatePlatformConfig('admin_token_epoch', {
|
||
name: '后台登录态版本号',
|
||
valueJson: { epoch: next },
|
||
description: '内部标记:用于吊销全部后台登录态,勿手动修改',
|
||
status: 1,
|
||
});
|
||
adminTokenEpoch = next;
|
||
console.warn(`[Peiwan Admin][AUDIT] 后台登录态已全部吊销 version->${next} ip=${clientIpOf(req)}`);
|
||
res.json({ code: 20000, message: '已吊销所有后台登录态,请重新登录' });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 1.1 管理员鉴权与登录接口 (供 PC 运营管理后台生产环境调用)
|
||
app.post('/api/user/login', (req: Request, res: Response) => {
|
||
const { username, password } = req.body || {};
|
||
if (!username || !password) {
|
||
return res.status(400).json({ code: 50000, message: '用户名和密码不能为空' });
|
||
}
|
||
const loginKey = `${clientIpOf(req)}|${String(username)}`;
|
||
if (isLoginLocked(loginKey)) {
|
||
return res.status(429).json({ code: 50000, message: '登录失败次数过多,请 10 分钟后再试' });
|
||
}
|
||
// 安全 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录。
|
||
// 不依赖 NODE_ENV(线上曾以 development 运行导致默认口令可直接登录)。
|
||
if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) {
|
||
logServerError('adminLogin', new Error('未配置 ADMIN_PASSWORD(或仍为默认口令),已禁用后台登录'));
|
||
return res.status(503).json({
|
||
code: 50000,
|
||
message: '后台登录口令未配置:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务',
|
||
});
|
||
}
|
||
// 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖
|
||
const passwordOk = ADMIN_PASSWORDS.some((p) => safeStringEqual(String(password), p));
|
||
if (safeStringEqual(String(username), ADMIN_USERNAME) && passwordOk) {
|
||
loginFailureMap.delete(loginKey);
|
||
return res.json({
|
||
code: 20000,
|
||
data: {
|
||
token: signAdminToken(),
|
||
},
|
||
message: '登录成功'
|
||
});
|
||
}
|
||
recordLoginFailure(loginKey);
|
||
return res.status(401).json({ code: 50000, message: '账号或密码错误' });
|
||
});
|
||
|
||
// 管理端专属命名空间统一鉴权(小程序端不访问以下路径,可安全收紧)
|
||
app.use('/api/admin', requireAdmin);
|
||
app.use('/api/dashboard', requireAdmin);
|
||
app.use('/api/finance', requireAdmin);
|
||
app.use('/api/arbitrations', requireAdmin);
|
||
|
||
app.all('/api/user/info', (_req: Request, res: Response) => {
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
name: '超级管理员',
|
||
avatar: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="%23165DFF"/><text x="50" y="62" font-size="36" text-anchor="middle" fill="white" font-family="sans-serif">AD</text></svg>',
|
||
email: '',
|
||
job: 'super_admin',
|
||
jobName: '全权运营总监',
|
||
organization: 'Platform',
|
||
organizationName: '同城陪伴运营总部',
|
||
location: 'beijing',
|
||
locationName: '北京',
|
||
introduction: '具备平台最高运营、风控与财务审核权限',
|
||
personalWebsite: '',
|
||
phone: '',
|
||
registrationDate: '2026-09-01 10:00:00',
|
||
accountId: '10001',
|
||
certification: 1,
|
||
role: 'admin',
|
||
roles: ['admin'],
|
||
}
|
||
});
|
||
});
|
||
|
||
app.post('/api/user/logout', (_req: Request, res: Response) => {
|
||
res.json({
|
||
code: 20000,
|
||
data: null,
|
||
message: '登出成功'
|
||
});
|
||
});
|
||
|
||
app.all('/api/user/menu', (_req: Request, res: Response) => {
|
||
res.json({
|
||
code: 20000,
|
||
data: []
|
||
});
|
||
});
|
||
|
||
// 1.2 个人中心与管理员辅助接口 (用户设置与用户信息页)
|
||
// 平台未接入企业实名认证:返回"未认证"空态,不再返回伪造的企业信息
|
||
app.all('/api/user/certification', (_req: Request, res: Response) => {
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
enterpriseInfo: {
|
||
accountType: 1,
|
||
status: 0,
|
||
time: '',
|
||
legalPerson: '',
|
||
certificateType: '',
|
||
authenticationNumber: '',
|
||
enterpriseName: '',
|
||
enterpriseCertificateType: '',
|
||
organizationCode: '',
|
||
},
|
||
record: []
|
||
}
|
||
});
|
||
});
|
||
|
||
// 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads)
|
||
const handleFileUpload = (req: Request, res: Response) => {
|
||
// 上传要求登录,避免匿名上传被滥用(刷盘/托管非法内容)。
|
||
// 不依赖 NODE_ENV:只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。
|
||
if (SECURE_MODE) {
|
||
try {
|
||
resolveSecureUserId(req, 0, '上传文件');
|
||
} catch (e: any) {
|
||
return res.status(e.status || 401).json({ code: e.code || 40100, message: e.message || '请先登录后再上传' });
|
||
}
|
||
}
|
||
upload.single('file')(req, res, (err: any) => {
|
||
if (err) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: err.message || '文件上传失败'
|
||
});
|
||
}
|
||
if (!req.file) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: '未接收到有效文件,请使用 file 字段提交'
|
||
});
|
||
}
|
||
|
||
// 微信内容安全多媒体合规性检测 (mediaCheckAsync)
|
||
const mediaCheck = contentSecurityService.checkImage(
|
||
req.file.originalname,
|
||
req.file.mimetype,
|
||
req.file.size
|
||
);
|
||
if (!mediaCheck.pass) {
|
||
// 若拦截到非法文件特征,立即删除已写入的临时文件
|
||
try {
|
||
fs.unlinkSync(req.file.path);
|
||
} catch (e) {}
|
||
return res.status(400).json({
|
||
code: mediaCheck.code || 87014,
|
||
message: mediaCheck.message
|
||
});
|
||
}
|
||
|
||
const publicUrl = `/uploads/${req.file.filename}`;
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
url: publicUrl,
|
||
filename: req.file.filename,
|
||
size: req.file.size,
|
||
mimetype: req.file.mimetype
|
||
},
|
||
message: '图片上传成功并已持久化存储'
|
||
});
|
||
});
|
||
};
|
||
|
||
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) => {
|
||
try {
|
||
let callerId = 0;
|
||
if (SECURE_MODE) {
|
||
// 要求登录,避免匿名滥用微信内容安全额度
|
||
callerId = resolveSecureUserId(req, Number(req.body?.userId || req.headers['x-user-id'] || 0), '内容安全检测');
|
||
}
|
||
const { content, scene } = req.body || {};
|
||
// openid 以服务端会话用户为准,杜绝伪造他人 openid
|
||
const openid = callerId > 0 ? await resolveUserOpenid(callerId) : req.body?.openid;
|
||
const checkResult = await contentSecurityService.checkTextAsync(content, openid, typeof scene === 'number' ? scene : undefined);
|
||
if (!checkResult.pass) {
|
||
return res.status(400).json({
|
||
code: checkResult.code,
|
||
data: {
|
||
suggest: checkResult.suggest,
|
||
label: checkResult.label
|
||
},
|
||
message: checkResult.message
|
||
});
|
||
}
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
suggest: checkResult.suggest
|
||
},
|
||
message: '内容合规通过'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 1.3 顶部通知中心接口
|
||
// 小程序获取订阅消息模板ID(用于 wx.requestSubscribeMessage 请求用户授权;模板ID非敏感)
|
||
app.get('/api/message/subscribe-templates', async (_req: Request, res: Response) => {
|
||
try {
|
||
const record = await repo.getPlatformConfigByKey('message_template_rules');
|
||
const rules = (record && record.valueJson) || {};
|
||
// 过滤占位符(tmpl_xxx)与不可见字符,避免小程序拿无效模板ID去请求授权
|
||
const clean = (v: any) => {
|
||
const s = normalizeTemplateId(v);
|
||
return /^tmpl_/i.test(s) ? '' : s;
|
||
};
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
enablePush: Number(rules.enablePush) === 1,
|
||
orderStatusTemplateId: clean(rules.orderStatusTemplateId),
|
||
activityJoinTemplateId: clean(rules.activityJoinTemplateId),
|
||
withdrawSuccessTemplateId: clean(rules.withdrawSuccessTemplateId),
|
||
verifyCodeTemplateId: clean(rules.verifyCodeTemplateId)
|
||
}
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理端消息铃铛:真实待办聚合(待接单 / 待审核大神 / 待审核提现 / 待处理举报 / 待仲裁)
|
||
function buildAdminTodoItems(todos: any, readMap: Record<string, number>): any[] {
|
||
const defs = [
|
||
{ key: 'pending_orders', count: Number(todos?.pendingOrdersCount || 0), title: '待接单订单', content: '有订单等待大神接单', link: '/order/list' },
|
||
{ key: 'pending_audits', count: Number(todos?.pendingAuditsCount || 0), title: '待审核大神', content: '有大神入驻申请待审核', link: '/companion/audit' },
|
||
{ key: 'pending_withdrawals', count: Number(todos?.pendingWithdrawalsCount || 0), title: '待审核提现', content: `待审核提现合计 ¥${Number(todos?.pendingWithdrawalAmount || 0).toFixed(2)}`, link: '/finance/withdraw' },
|
||
{ key: 'pending_reports', count: Number(todos?.pendingReportsCount || 0), title: '待处理举报', content: '有违规举报工单待处置', link: '/safety/reports' },
|
||
{ key: 'pending_arbitrations', count: Number(todos?.pendingArbitrationsCount || 0), title: '待仲裁工单', content: '有争议仲裁待平台介入', link: '/order/arbitration' },
|
||
];
|
||
return defs
|
||
.filter((d) => d.count > 0)
|
||
.map((d, i) => {
|
||
const readCount = Number(readMap[d.key] || 0);
|
||
return {
|
||
id: i + 1,
|
||
key: d.key,
|
||
type: 'todo',
|
||
title: d.title,
|
||
subTitle: `待处理 ${d.count} 条`,
|
||
content: d.content,
|
||
time: formatCnTime(),
|
||
status: d.count > readCount ? 0 : 1,
|
||
messageType: 1,
|
||
count: d.count,
|
||
link: d.link,
|
||
};
|
||
});
|
||
}
|
||
|
||
app.post('/api/message/list', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const todos = await repo.getDashboardTodos();
|
||
const readMap = await repo.getAdminTodoReadCounts();
|
||
res.json({ code: 20000, data: buildAdminTodoItems(todos, readMap) });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/message/read', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((x: any) => Number(x)) : [];
|
||
const keys = Array.isArray(req.body?.keys) ? req.body.keys.map((x: any) => String(x)) : [];
|
||
const todos = await repo.getDashboardTodos();
|
||
const readMap = await repo.getAdminTodoReadCounts();
|
||
const items = buildAdminTodoItems(todos, readMap);
|
||
const targets = items.filter((it: any) => ids.includes(it.id) || keys.includes(it.key));
|
||
const toMark = (targets.length ? targets : items).map((it: any) => ({ key: it.key, count: it.count }));
|
||
const affected = await repo.markAdminTodosRead(toMark);
|
||
res.json({ code: 20000, data: { affected }, message: '已标记为已读' });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// ===== 站内消息通知中心(用户端真实数据源)=====
|
||
// 关键动作(支付/接单/拒单/完成/提现到账/报名/退款/仲裁)都会写入 user_notifications,
|
||
// 即使未授权微信订阅消息,用户仍可在小程序「消息中心」看到完整通知历史。
|
||
app.get('/api/notifications', async (req: Request, res: Response) => {
|
||
try {
|
||
const uid = resolveSecureUserId(
|
||
req,
|
||
Number(req.query.userId || req.headers['x-user-id'] || 0),
|
||
'查看消息通知'
|
||
);
|
||
const list = await repo.getUserNotifications(uid, Number(req.query.limit) || 50);
|
||
const unread = await repo.getUserUnreadNotificationCount(uid);
|
||
res.json({ code: 20000, data: { list, unread } });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/notifications/read', async (req: Request, res: Response) => {
|
||
try {
|
||
const uid = resolveSecureUserId(
|
||
req,
|
||
Number(req.body?.userId || req.headers['x-user-id'] || 0),
|
||
'标记消息已读'
|
||
);
|
||
const ids = Array.isArray(req.body?.ids) ? req.body.ids : undefined;
|
||
const affected = await repo.markUserNotificationsRead(uid, ids);
|
||
res.json({ code: 20000, data: { affected }, message: '消息已标记为已读' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 2. 运营后台统计大盘接口 (真实读取 MySQL)
|
||
app.get('/api/dashboard/stats', async (_req: Request, res: Response) => {
|
||
try {
|
||
const stats = await repo.getDashboardStats();
|
||
res.json({
|
||
code: 20000,
|
||
data: stats
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 2.1 综合运营待办与预警中心接口 (真实读取 MySQL 待办项)
|
||
app.get('/api/dashboard/todos', async (_req: Request, res: Response) => {
|
||
try {
|
||
const todos = await repo.getDashboardTodos();
|
||
res.json({
|
||
code: 20000,
|
||
data: todos
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 3. 活动与4位核销码相关接口 (MySQL 持久化)
|
||
app.get('/api/activities', async (_req: Request, res: Response) => {
|
||
try {
|
||
const acts = await repo.getActivities();
|
||
res.json({
|
||
code: 20000,
|
||
data: acts
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/activities/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const act = await repo.getActivityById(String(req.params.id));
|
||
if (!act) {
|
||
return res.status(404).json({ code: 40400, message: '活动不存在' });
|
||
}
|
||
// 核销码属于该参与人的"门票",严禁向他人泄露:
|
||
// 仅管理员可见全部,普通访问者仅可见本人的核销码,其余参与者剥离 verifyCode。
|
||
const isAdmin = isVerifiedAdminRequest(req);
|
||
let callerId = 0;
|
||
if (!isAdmin) {
|
||
try {
|
||
callerId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看组局详情');
|
||
} catch {
|
||
callerId = 0;
|
||
}
|
||
}
|
||
const participants = (act.participants || []).map((p: any) => {
|
||
if (isAdmin || (callerId > 0 && Number(p.userId) === callerId)) return p;
|
||
const { verifyCode, ...rest } = p;
|
||
return rest;
|
||
});
|
||
res.json({ code: 20000, data: { ...act, participants } });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/activities', async (req: Request, res: Response) => {
|
||
try {
|
||
const { title, location, startTime } = req.body || {};
|
||
// 身份以服务端会话为准,杜绝冒充他人发起组局(发起人即资金结算归属人)
|
||
const creatorId = resolveSecureUserId(
|
||
req,
|
||
Number(req.body?.creatorId || req.headers['x-user-id'] || 0),
|
||
'发起组局'
|
||
);
|
||
await assertUserNotBanned(creatorId, '发起组局或发布活动');
|
||
|
||
if (!title || !String(title).trim()) {
|
||
return res.status(400).json({ code: 40000, message: '请填写活动标题' });
|
||
}
|
||
if (!startTime || !String(startTime).trim()) {
|
||
return res.status(400).json({ code: 40000, message: '请选择活动时段' });
|
||
}
|
||
if (!location || !String(location).trim()) {
|
||
return res.status(400).json({ code: 40000, message: '请填写或选择活动地点' });
|
||
}
|
||
// 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层)
|
||
const openid = await resolveUserOpenid(creatorId);
|
||
const checkTitle = await contentSecurityService.checkTextAsync(title, openid, 2);
|
||
if (!checkTitle.pass) {
|
||
return res.status(400).json({ code: checkTitle.code, message: checkTitle.message });
|
||
}
|
||
const checkLoc = await contentSecurityService.checkTextAsync(location, openid, 2);
|
||
if (!checkLoc.pass) {
|
||
return res.status(400).json({ code: checkLoc.code, message: checkLoc.message });
|
||
}
|
||
|
||
const act = await repo.createActivity(req.body);
|
||
res.json({ code: 20000, data: act, message: '活动创建成功并已写入MySQL' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 报名并生成4位数字核销码 (落盘 MySQL)
|
||
app.post('/api/activities/:id/join', async (req: Request, res: Response) => {
|
||
try {
|
||
const actId = String(req.params.id);
|
||
const { userId, payAmountCents } = req.body || {};
|
||
const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 2008), '报名参加活动');
|
||
await assertUserNotBanned(uid, '报名参加活动');
|
||
|
||
const participant = await repo.joinActivity(actId, {
|
||
userId: uid,
|
||
payAmountCents: Number(payAmountCents || 0)
|
||
});
|
||
try {
|
||
const act = await repo.getActivityById(actId);
|
||
pushUserNotification(uid, 'ACTIVITY', '报名成功', `你已成功报名「${String(act?.title || '同城组局活动')}」,开始时间 ${toCnTimeOrNow(act?.startTime)}。`, '/pages/activity/my-ticket');
|
||
void notifyBySubscribe('activityJoin', {
|
||
userId: uid,
|
||
page: '/pages/activity/my-ticket',
|
||
data: {
|
||
thing1: String(act?.title || '同城组局活动'),
|
||
time2: formatCnTime(),
|
||
time3: toCnTimeOrNow(act?.startTime),
|
||
thing4: String(act?.location || '详见活动页')
|
||
}
|
||
});
|
||
} catch (e) {
|
||
logServerError('joinActivity.notify', e);
|
||
}
|
||
res.json({
|
||
code: 20000,
|
||
data: participant,
|
||
message: '报名成功,现场出示4位核销码签到核销'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 房主/管理员核销 4 位核销码 (防重落盘 MySQL)
|
||
app.post('/api/activities/:id/verify', async (req: Request, res: Response) => {
|
||
try {
|
||
const actId = String(req.params.id);
|
||
const { operatorId, verifyCode } = req.body;
|
||
// 操作人身份以服务端会话 token 为准,防止伪造 operatorId 越权核销他人组局
|
||
let operator = resolveSecureUserId(req, Number(operatorId || 0), '核销组局门票');
|
||
if (isVerifiedAdminRequest(req)) {
|
||
const act = await repo.getActivityById(actId);
|
||
if (act && act.creatorId) operator = Number(act.creatorId);
|
||
}
|
||
const result = await repo.verifyCheckin(actId, String(verifyCode), operator);
|
||
res.json({ code: 20000, data: result, message: '核销成功,已完成现场到场签到' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 通用4位核销码反查信息 (支持组局活动与同城线下订单)
|
||
app.get('/api/verify/query/:code', async (req: Request, res: Response) => {
|
||
try {
|
||
const operator = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询核销码');
|
||
const info = await repo.queryCheckinCodeInfo(String(req.params.code));
|
||
// 仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情,
|
||
// 防止对 4 位纯数字核销码做批量枚举探测(不依赖 NODE_ENV)。
|
||
if (SECURE_MODE && !isVerifiedAdminRequest(req)) {
|
||
let allowed = false;
|
||
if (info.type === 'ACTIVITY') {
|
||
allowed = Number(info.creatorId) === operator;
|
||
} else if (info.type === 'ORDER') {
|
||
const own = await repo.getOrderOwnership(String(info.id));
|
||
allowed = Boolean(own) && own!.companionIds.includes(operator);
|
||
}
|
||
if (!allowed) {
|
||
return res.status(403).json({ code: 40300, message: '无权查看该核销码信息' });
|
||
}
|
||
}
|
||
res.json({ code: 20000, data: info });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 通用4位核销码核销接口 (支持大神接单工作台一键核验打卡)
|
||
app.post('/api/verify/universal', async (req: Request, res: Response) => {
|
||
try {
|
||
const { verifyCode, operatorId } = req.body;
|
||
const operator = resolveSecureUserId(req, Number(operatorId || 0), '核销门票');
|
||
// 校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销(不依赖 NODE_ENV)
|
||
if (SECURE_MODE && !isVerifiedAdminRequest(req)) {
|
||
const info = await repo.queryCheckinCodeInfo(String(verifyCode));
|
||
let allowed = false;
|
||
if (info.type === 'ACTIVITY') {
|
||
allowed = Number(info.creatorId) === operator;
|
||
} else if (info.type === 'ORDER') {
|
||
const own = await repo.getOrderOwnership(String(info.id));
|
||
allowed = Boolean(own) && own!.companionIds.includes(operator);
|
||
}
|
||
if (!allowed) {
|
||
return res.status(403).json({ code: 40300, message: '无权核销该门票(非活动发起人或承接大神)' });
|
||
}
|
||
}
|
||
const result = await repo.universalVerifyCheckin(String(verifyCode), operator);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: '核销成功,已完成现场签到并打卡'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 房主手动改价与追加赏金 (落盘 MySQL 并记录流水)
|
||
app.post('/api/activities/:id/adjust-price', async (req: Request, res: Response) => {
|
||
try {
|
||
const actId = String(req.params.id);
|
||
const { operatorId, newPriceCents } = req.body;
|
||
const operator = resolveSecureUserId(req, Number(operatorId || 0), '调整组局出价');
|
||
const updated = await repo.adjustPrice(actId, Number(newPriceCents), operator);
|
||
res.json({ code: 20000, data: updated, message: '出价调整成功,老成员同享调价差额' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 房主或平台管理员取消/违规下架组局并触发全额原路退款
|
||
app.post('/api/activities/:id/cancel', async (req: Request, res: Response) => {
|
||
try {
|
||
const actId = String(req.params.id);
|
||
const { hostId, reason } = req.body || {};
|
||
// 管理员特权只能来自服务端签名校验通过的 admin token;
|
||
// 严禁信任客户端可伪造的 operatorRole / x-user-role,防止普通用户提权解散他人组局。
|
||
const isAdmin = isVerifiedAdminRequest(req);
|
||
// 非管理员操作人身份以服务端会话为准,杜绝冒充房主解散他人组局并触发退款
|
||
let callerId = 0;
|
||
if (!isAdmin) {
|
||
callerId = resolveSecureUserId(req, Number(hostId || req.headers['x-user-id'] || 0), '解散组局');
|
||
}
|
||
const result = await repo.cancelActivity(actId, callerId, reason, isAdmin);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: `组局已成功解散下架${result.refundedCount > 0 ? `,已原路全额退款 ${result.refundedCount} 位搭子` : ''}`
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 报名成员主动退席与退款
|
||
app.post('/api/activities/:id/leave', async (req: Request, res: Response) => {
|
||
try {
|
||
const actId = String(req.params.id);
|
||
const { userId } = req.body;
|
||
const uid = resolveSecureUserId(req, Number(userId), '退出组局');
|
||
const result = await repo.leaveActivity(actId, uid);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: '已成功退出组局,报名款已原路退回您的钱包'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 用户“我的组局”(发起的 + 报名的)
|
||
app.get('/api/users/:id/my-activities', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(req, Number(req.params.id), '查看我的组局');
|
||
const data = await repo.getMyActivities(userId);
|
||
res.json({ code: 20000, data });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 4. 陪玩与大神管理接口 (落盘 MySQL)
|
||
app.get('/api/companions', async (req: Request, res: Response) => {
|
||
try {
|
||
const keyword = req.query.keyword ? String(req.query.keyword).slice(0, 40) : undefined;
|
||
const companions = await repo.getCompanions(keyword);
|
||
// 管理端可见全量(含待审核/已驳回及内部备注);公开目录仅展示已通过审核的大神,并剥离内部审核备注
|
||
if (isVerifiedAdminRequest(req)) {
|
||
return res.json({ code: 20000, data: companions });
|
||
}
|
||
const publicList = companions
|
||
.filter((c: any) => c.auditStatus === '已通过')
|
||
.map((c: any) => {
|
||
const { rejectReason, ...rest } = c;
|
||
return rest;
|
||
});
|
||
res.json({
|
||
code: 20000,
|
||
data: publicList
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/companions/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const companion = await repo.getCompanionById(String(req.params.id));
|
||
if (!companion) {
|
||
return res.status(404).json({ code: 40400, message: '未找到指定陪伴师资料' });
|
||
}
|
||
// 公开详情剥离内部审核备注;管理端保留完整字段
|
||
if (isVerifiedAdminRequest(req)) {
|
||
return res.json({ code: 20000, data: companion });
|
||
}
|
||
const { rejectReason, ...publicCompanion } = companion;
|
||
res.json({
|
||
code: 20000,
|
||
data: publicCompanion
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神资质与实名入驻真实申请接口
|
||
app.post('/api/companions/apply', async (req: Request, res: Response) => {
|
||
try {
|
||
const { userId, name, idCard, track, hourlyRate, tags, avatar, gender } = req.body;
|
||
if (!name || !name.trim()) {
|
||
return res.status(400).json({ code: 40000, message: '真实姓名不能为空' });
|
||
}
|
||
if (!idCard || !idCard.trim()) {
|
||
return res.status(400).json({ code: 40000, message: '身份证号不能为空' });
|
||
}
|
||
|
||
// 身份以服务端会话为准,杜绝以他人身份提交入驻申请
|
||
const applyUid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '申请大神入驻');
|
||
|
||
// 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层)
|
||
const applyOpenid = await resolveUserOpenid(applyUid);
|
||
const sec = await contentSecurityService.checkTextAsync(`${name} ${track || ''} ${(tags || []).join(' ')}`, applyOpenid, 1);
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
|
||
// 校验价格与标签
|
||
const expertRulesConfig = await repo.getPlatformConfigByKey('expert_rules');
|
||
const rules = expertRulesConfig?.valueJson || { minHourlyRate: 30, maxHourlyRate: 500, maxTagsCount: 6 };
|
||
if (hourlyRate) {
|
||
const rateNum = Number(hourlyRate);
|
||
if (rateNum < (rules.minHourlyRate || 30) || rateNum > (rules.maxHourlyRate || 500)) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: `服务单价需在 ¥${rules.minHourlyRate || 30} - ¥${rules.maxHourlyRate || 500} 之间`
|
||
});
|
||
}
|
||
}
|
||
|
||
const companion = await repo.applyCompanion({
|
||
userId: applyUid,
|
||
name: name.trim(),
|
||
idCard: idCard.trim(),
|
||
track: track || '同城陪吃',
|
||
hourlyRate: Number(hourlyRate) || rules.defaultHourlyRate || 88,
|
||
tags: Array.isArray(tags) ? tags.slice(0, rules.maxTagsCount || 6) : [],
|
||
avatar,
|
||
gender
|
||
});
|
||
|
||
res.json({
|
||
code: 20000,
|
||
data: companion,
|
||
message: '大神实名认证申请已提交,等待运营人工审核'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/companions/:id/audit', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const companionId = Number(req.params.id);
|
||
const { action, reason, operatorRole } = req.body; // action: 'approve' | 'reject'
|
||
const headerRole = req.headers['x-user-role'];
|
||
const callerRole = operatorRole || headerRole || 'admin';
|
||
if (callerRole === 'user' || callerRole === 'expert' || callerRole === 'PLAYER' || callerRole === 'EXPERT') {
|
||
return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行审核' });
|
||
}
|
||
const result = await repo.auditCompanion(companionId, action, reason);
|
||
res.json({ code: 20000, data: result, message: result.message });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神服务单价、技能标签与资料修改 (校验价格边界与标签库规范)
|
||
app.put('/api/companions/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const companionId = String(req.params.id);
|
||
const { hourlyRate, tags, rankName, status } = req.body;
|
||
|
||
// 身份校验:仅本人大神或管理员可修改资料,杜绝越权篡改他人单价/接单状态
|
||
const own = await repo.getCompanionOwnership(companionId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '大神不存在' });
|
||
}
|
||
assertCompanionActor(req, own, '修改大神资料');
|
||
|
||
// 1. 读取平台配置的大神规则
|
||
const expertRulesConfig = await repo.getPlatformConfigByKey('expert_rules');
|
||
const rules = expertRulesConfig?.valueJson || {
|
||
minHourlyRate: 30,
|
||
maxHourlyRate: 500,
|
||
maxTagsCount: 6,
|
||
};
|
||
|
||
// 2. 校验服务单价上下限
|
||
if (hourlyRate !== undefined) {
|
||
const rateNum = Number(hourlyRate);
|
||
if (isNaN(rateNum) || rateNum <= 0) {
|
||
return res.status(400).json({ code: 40000, message: '服务单价必须为有效正数' });
|
||
}
|
||
if (rateNum < (rules.minHourlyRate || 30)) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: `服务单价不能低于平台最低限价 ¥${rules.minHourlyRate || 30}/小时`
|
||
});
|
||
}
|
||
if (rateNum > (rules.maxHourlyRate || 500)) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: `服务单价不能超过平台最高限价 ¥${rules.maxHourlyRate || 500}/小时`
|
||
});
|
||
}
|
||
}
|
||
|
||
// 3. 校验技能标签
|
||
if (tags !== undefined) {
|
||
if (!Array.isArray(tags)) {
|
||
return res.status(400).json({ code: 40000, message: '技能标签必须为数组格式' });
|
||
}
|
||
const maxCount = rules.maxTagsCount || 6;
|
||
if (tags.length > maxCount) {
|
||
return res.status(400).json({ code: 40000, message: `技能标签最多选择或设置 ${maxCount} 个` });
|
||
}
|
||
// 敏感词安全校验
|
||
const textToCheck = tags.join(' ');
|
||
const sec = contentSecurityService.checkText(textToCheck);
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
}
|
||
|
||
// 4. 执行更新
|
||
const updated = await repo.updateCompanionProfile(companionId, {
|
||
hourlyRate: hourlyRate !== undefined ? Number(hourlyRate) : undefined,
|
||
tags: tags !== undefined ? tags : undefined,
|
||
rankName,
|
||
status
|
||
});
|
||
|
||
if (!updated) {
|
||
return res.json({
|
||
code: 20000,
|
||
data: { id: companionId, hourlyRate, tags, rankName, status: status || '接单中' },
|
||
message: '陪伴师状态已同步'
|
||
});
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: '陪伴师配置及标签已更新' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 兼容 PUT /api/companions/:id/profile 路由别名
|
||
app.put('/api/companions/:id/profile', async (req: Request, res: Response) => {
|
||
try {
|
||
const companionId = String(req.params.id);
|
||
const { hourlyRate, tags, rankName, status } = req.body;
|
||
const own = await repo.getCompanionOwnership(companionId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '大神不存在' });
|
||
}
|
||
assertCompanionActor(req, own, '修改大神资料');
|
||
const updated = await repo.updateCompanionProfile(companionId, {
|
||
hourlyRate: hourlyRate !== undefined ? Number(hourlyRate) : undefined,
|
||
tags: tags !== undefined ? tags : undefined,
|
||
rankName,
|
||
status
|
||
});
|
||
res.json({
|
||
code: 20000,
|
||
data: updated || { id: companionId, hourlyRate, tags, rankName, status: status || '接单中' },
|
||
message: '陪伴师资料与接单状态已更新'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
|
||
// 5. 订单与仲裁接口 (落盘 MySQL)
|
||
app.get('/api/orders', async (req: Request, res: Response) => {
|
||
try {
|
||
// 管理端(服务端签名 admin token)可取全量;普通用户仅能取与自己相关的订单
|
||
if (isVerifiedAdminRequest(req)) {
|
||
const orders = await repo.getOrders();
|
||
return res.json({
|
||
code: 20000,
|
||
data: orders
|
||
});
|
||
}
|
||
const uid = resolveSecureUserId(
|
||
req,
|
||
Number(req.query.userId || req.headers['x-user-id'] || 0),
|
||
'查看订单列表'
|
||
);
|
||
const orders = await repo.getOrdersForUser(uid);
|
||
res.json({
|
||
code: 20000,
|
||
data: orders
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 获取即时可抢订单池 (落盘 MySQL)
|
||
app.get('/api/orders/pool', async (req: Request, res: Response) => {
|
||
try {
|
||
const lat = Number(req.query.lat);
|
||
const lng = Number(req.query.lng);
|
||
const radius = Number(req.query.radius);
|
||
const hasFrom = Number.isFinite(lat) && Number.isFinite(lng) && Math.abs(lat) <= 90 && Math.abs(lng) <= 180 && !(lat === 0 && lng === 0);
|
||
|
||
// 先取不带半径的全量池:为历史无坐标订单做地理编码兜底(每轮最多 5 条,结果回写入库)
|
||
let pre = await repo.getOrdersPool(hasFrom ? { fromLat: lat, fromLng: lng } : {});
|
||
if (hasFrom) {
|
||
const missing = pre.filter((o: any) => !o.hasGeo && o.location).slice(0, 5);
|
||
let changed = false;
|
||
for (const o of missing) {
|
||
const geo = await geocodeAddress(o.location);
|
||
if (geo) {
|
||
await repo.updateOrderGeo(o.id, geo.lat, geo.lng);
|
||
changed = true;
|
||
}
|
||
}
|
||
if (changed) pre = await repo.getOrdersPool({ fromLat: lat, fromLng: lng });
|
||
}
|
||
|
||
const list = hasFrom && Number.isFinite(radius) && radius > 0
|
||
? pre.filter((o: any) => o.distanceKm !== null && o.distanceKm <= radius)
|
||
: pre;
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 坐标反查行政区/地址(雷达/接单大厅顶部定位文案;仅登录用户可调用,避免刷额度)
|
||
app.get('/api/lbs/reverse', rateLimit({ name: 'lbs_reverse', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), async (req: Request, res: Response) => {
|
||
try {
|
||
if (SECURE_MODE) resolveSecureUserId(req, Number(req.query.userId || 0), '解析当前位置');
|
||
const lat = Number(req.query.lat);
|
||
const lng = Number(req.query.lng);
|
||
if (!Number.isFinite(lat) || !Number.isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) {
|
||
return res.status(400).json({ code: 40000, message: '经纬度参数不合法' });
|
||
}
|
||
const data = await reverseGeocode(lat, lng);
|
||
res.json({ code: 20000, data });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
res.status(statusCode).json({ code: err.code || 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神抢单/接单认领 (落盘 MySQL)
|
||
app.post('/api/orders/:id/claim', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
const { isCertified, operatorRole } = req.body || {};
|
||
if (isCertified === false || operatorRole === 'user' || operatorRole === 'PLAYER') {
|
||
return res.status(403).json({ code: 40300, message: '未认证用户无法接单' });
|
||
}
|
||
const operatorId = resolveSecureUserId(
|
||
req,
|
||
Number(req.body?.expertId || req.body?.companionId || req.body?.userId || req.headers['x-user-id'] || 0),
|
||
'接单'
|
||
);
|
||
if (operatorId > 0) {
|
||
await assertUserNotBanned(operatorId, '接单');
|
||
}
|
||
const order = await repo.claimOrder(orderId, { ...(req.body || {}), expertId: operatorId });
|
||
pushUserNotification(Number(order?.buyer_id || order?.buyerId || 0), 'ORDER_STATUS', '大神已接单', `订单 ${order?.order_no || orderId} 已被大神接单,服务即将开始。`, '/pages/order/list');
|
||
res.json({ code: 20000, data: order, message: '抢单成功,服务已锁定为履约中' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神今日接单数据汇总 (落盘 MySQL)
|
||
app.get('/api/expert/stats/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
// 收益数据属敏感信息:仅该大神本人或管理员可查看
|
||
const own = await repo.getCompanionOwnership(String(req.params.id));
|
||
assertCompanionActor(req, own, '查看大神收益数据');
|
||
const stats = await repo.getExpertStats(String(req.params.id));
|
||
res.json({ code: 20000, data: stats });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神日/周/月多维收益走势统计分析接口
|
||
app.get('/api/expert/stats/:id/trends', async (req: Request, res: Response) => {
|
||
try {
|
||
// 收益走势属敏感信息:仅该大神本人或管理员可查看
|
||
const own = await repo.getCompanionOwnership(String(req.params.id));
|
||
assertCompanionActor(req, own, '查看大神收益走势');
|
||
const trends = await repo.getExpertRevenueTrends(String(req.params.id));
|
||
res.json({ code: 20000, data: trends });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
|
||
// 首页运营 Banner 列表 (小程序端读取启用状态)
|
||
app.get('/api/banners', async (_req: Request, res: Response) => {
|
||
try {
|
||
const banners = await repo.getBanners();
|
||
res.json({ code: 20000, data: banners });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 获取全部 Banner 列表 (含停用状态)
|
||
app.get('/api/admin/banners', async (_req: Request, res: Response) => {
|
||
try {
|
||
const banners = await repo.getAdminBanners();
|
||
res.json({ code: 20000, data: banners });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 新增 Banner
|
||
app.post('/api/admin/banners', async (req: Request, res: Response) => {
|
||
try {
|
||
const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body;
|
||
if (!title || !title.trim()) {
|
||
return res.status(400).json({ code: 40000, message: 'Banner 标题不能为空' });
|
||
}
|
||
|
||
// 微信内容安全检测
|
||
const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || ''));
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
|
||
const banner = await repo.createBanner({
|
||
title: title.trim(),
|
||
subtitle: subtitle ? subtitle.trim() : '',
|
||
tag,
|
||
badge,
|
||
badgeType,
|
||
bg,
|
||
image,
|
||
link,
|
||
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
|
||
status: status !== undefined ? Number(status) : 1
|
||
});
|
||
|
||
res.json({ code: 20000, data: banner, message: '轮播图创建成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 修改 Banner
|
||
app.put('/api/admin/banners/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const bannerId = String(req.params.id);
|
||
const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body;
|
||
|
||
if (title) {
|
||
const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || ''));
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
}
|
||
|
||
const updated = await repo.updateBanner(bannerId, {
|
||
title,
|
||
subtitle,
|
||
tag,
|
||
badge,
|
||
badgeType,
|
||
bg,
|
||
image,
|
||
link,
|
||
sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined,
|
||
status: status !== undefined ? Number(status) : undefined
|
||
});
|
||
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: '轮播图修改成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 切换 Banner 上架/下线状态
|
||
app.post('/api/admin/banners/:id/status', async (req: Request, res: Response) => {
|
||
try {
|
||
const bannerId = String(req.params.id);
|
||
const { status } = req.body;
|
||
if (status === undefined || (status !== 0 && status !== 1)) {
|
||
return res.status(400).json({ code: 40000, message: '状态参数必须为 0 或 1' });
|
||
}
|
||
|
||
const updated = await repo.updateBannerStatus(bannerId, Number(status));
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '轮播图不存在' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: status === 1 ? '轮播图已上架生效' : '轮播图已下线隐藏' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 删除 Banner
|
||
app.delete('/api/admin/banners/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const bannerId = String(req.params.id);
|
||
const ok = await repo.deleteBanner(bannerId);
|
||
if (!ok) {
|
||
return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: null, message: '轮播图已删除' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 首页金刚区活动品类导航列表 (小程序端读取上架状态)
|
||
app.get('/api/nav-categories', async (_req: Request, res: Response) => {
|
||
try {
|
||
const list = await repo.getNavCategories();
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 获取全部品类导航列表 (含停用状态)
|
||
app.get('/api/admin/nav-categories', async (_req: Request, res: Response) => {
|
||
try {
|
||
const list = await repo.getAdminNavCategories();
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 新增品类导航
|
||
app.post('/api/admin/nav-categories', async (req: Request, res: Response) => {
|
||
try {
|
||
const { code, name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status } = req.body;
|
||
if (!name || !name.trim()) {
|
||
return res.status(400).json({ code: 40000, message: '品类名称不能为空' });
|
||
}
|
||
if (!icon || !icon.trim()) {
|
||
return res.status(400).json({ code: 40000, message: '品类图标不能为空' });
|
||
}
|
||
|
||
// 微信内容安全检测
|
||
const secCheck = contentSecurityService.checkText(name + ' ' + (badgeText || ''));
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
|
||
const item = await repo.createNavCategory({
|
||
code,
|
||
name: name.trim(),
|
||
icon: icon.trim(),
|
||
bgClass,
|
||
cat,
|
||
targetUrl,
|
||
badgeText,
|
||
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
|
||
status: status !== undefined ? Number(status) : 1
|
||
});
|
||
|
||
res.json({ code: 20000, data: item, message: '品类导航创建成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 修改品类导航
|
||
app.put('/api/admin/nav-categories/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const catId = String(req.params.id);
|
||
const { name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status } = req.body;
|
||
|
||
if (name) {
|
||
const secCheck = contentSecurityService.checkText(name + ' ' + (badgeText || ''));
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
}
|
||
|
||
const updated = await repo.updateNavCategory(catId, {
|
||
name,
|
||
icon,
|
||
bgClass,
|
||
cat,
|
||
targetUrl,
|
||
badgeText,
|
||
sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined,
|
||
status: status !== undefined ? Number(status) : undefined
|
||
});
|
||
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: '品类导航修改成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 切换品类导航显示/隐藏状态
|
||
app.post('/api/admin/nav-categories/:id/status', async (req: Request, res: Response) => {
|
||
try {
|
||
const catId = String(req.params.id);
|
||
const { status } = req.body;
|
||
if (status === undefined || (status !== 0 && status !== 1)) {
|
||
return res.status(400).json({ code: 40000, message: '状态参数必须为 0 或 1' });
|
||
}
|
||
|
||
const updated = await repo.updateNavCategoryStatus(catId, Number(status));
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '品类导航不存在' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: status === 1 ? '品类导航已上架' : '品类导航已下线隐藏' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 删除品类导航
|
||
app.delete('/api/admin/nav-categories/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const catId = String(req.params.id);
|
||
const ok = await repo.deleteNavCategory(catId);
|
||
if (!ok) {
|
||
return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: null, message: '品类导航已删除' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// ===================== 营销中心 API =====================
|
||
|
||
// 1. 充值阶梯接口
|
||
// 移动端:获取启用的充值阶梯
|
||
app.get('/api/marketing/recharge-tiers', async (_req: Request, res: Response) => {
|
||
try {
|
||
const list = await repo.getRechargeTiers();
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:获取全部充值阶梯
|
||
app.get('/api/admin/recharge-tiers', async (_req: Request, res: Response) => {
|
||
try {
|
||
const list = await repo.getAdminRechargeTiers();
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:新增充值阶梯
|
||
app.post('/api/admin/recharge-tiers', async (req: Request, res: Response) => {
|
||
try {
|
||
const { amount, bonusAmount, tag, sortOrder, status } = req.body;
|
||
if (amount === undefined || Number(amount) <= 0) {
|
||
return res.status(400).json({ code: 40000, message: '充值金额必须大于0' });
|
||
}
|
||
|
||
const tier = await repo.createRechargeTier({
|
||
amount: Number(amount),
|
||
bonusAmount: bonusAmount !== undefined ? Number(bonusAmount) : 0,
|
||
tag: tag || '',
|
||
sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0,
|
||
status: status !== undefined ? Number(status) : 1
|
||
});
|
||
|
||
res.json({ code: 20000, data: tier, message: '充值阶梯创建成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:修改充值阶梯
|
||
app.put('/api/admin/recharge-tiers/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const tierId = String(req.params.id);
|
||
const { amount, bonusAmount, tag, sortOrder, status } = req.body;
|
||
|
||
const updated = await repo.updateRechargeTier(tierId, {
|
||
amount: amount !== undefined ? Number(amount) : undefined,
|
||
bonusAmount: bonusAmount !== undefined ? Number(bonusAmount) : undefined,
|
||
tag,
|
||
sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined,
|
||
status: status !== undefined ? Number(status) : undefined
|
||
});
|
||
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '充值阶梯不存在或已被删除' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: '充值阶梯修改成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:删除充值阶梯
|
||
app.delete('/api/admin/recharge-tiers/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const tierId = String(req.params.id);
|
||
const ok = await repo.deleteRechargeTier(tierId);
|
||
if (!ok) {
|
||
return res.status(404).json({ code: 40400, message: '充值阶梯不存在或已被删除' });
|
||
}
|
||
res.json({ code: 20000, data: null, message: '充值阶梯已删除' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 2. 优惠券接口
|
||
// 移动端:领券中心或下单结算可用券列表
|
||
app.get('/api/marketing/coupons', async (req: Request, res: Response) => {
|
||
try {
|
||
const category = req.query.category ? String(req.query.category) : undefined;
|
||
const uid = Number(req.query.userId || req.headers['x-user-id'] || 0) || 0;
|
||
const list = await repo.getCouponsWithClaimed(uid, category);
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 领取优惠券(同一用户同一券仅可领一次)
|
||
app.post('/api/marketing/coupons/:id/claim', async (req: Request, res: Response) => {
|
||
try {
|
||
const couponId = Number(req.params.id);
|
||
const uid = resolveSecureUserId(
|
||
req,
|
||
Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0),
|
||
'领取优惠券'
|
||
);
|
||
const claimed = await repo.claimCoupon(uid, couponId);
|
||
res.json({ code: 20000, data: claimed, message: '优惠券领取成功' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 我的优惠券(status: UNUSED | USED | EXPIRED,缺省返回全部)
|
||
app.get('/api/marketing/my-coupons', async (req: Request, res: Response) => {
|
||
try {
|
||
const uid = resolveSecureUserId(
|
||
req,
|
||
Number(req.query.userId || req.headers['x-user-id'] || 0),
|
||
'查看我的优惠券'
|
||
);
|
||
const status = req.query.status ? String(req.query.status) : undefined;
|
||
const list = await repo.getUserCoupons(uid, status);
|
||
const usable = list.filter((c: any) => c.status === 'UNUSED').length;
|
||
res.json({ code: 20000, data: { list, usable } });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:获取全部优惠券
|
||
app.get('/api/admin/coupons', async (_req: Request, res: Response) => {
|
||
try {
|
||
const list = await repo.getAdminCoupons();
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:新增优惠券
|
||
app.post('/api/admin/coupons', async (req: Request, res: Response) => {
|
||
try {
|
||
const { name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status } = req.body;
|
||
if (!name || !name.trim()) {
|
||
return res.status(400).json({ code: 40000, message: '优惠券名称不能为空' });
|
||
}
|
||
if (!discountAmount || Number(discountAmount) <= 0) {
|
||
return res.status(400).json({ code: 40000, message: '减免面额必须大于0' });
|
||
}
|
||
|
||
// 微信内容安全检测
|
||
const sec = contentSecurityService.checkText(name + ' ' + (description || ''));
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
|
||
const coupon = await repo.createCoupon({
|
||
name: name.trim(),
|
||
discountAmount: Number(discountAmount),
|
||
minSpend: minSpend !== undefined ? Number(minSpend) : 0,
|
||
validDays: validDays !== undefined ? Number(validDays) : 7,
|
||
totalCount: totalCount !== undefined ? Number(totalCount) : 1000,
|
||
categoryLimit: categoryLimit || 'ALL',
|
||
description: description || '',
|
||
status: status !== undefined ? Number(status) : 1
|
||
});
|
||
|
||
res.json({ code: 20000, data: coupon, message: '优惠券创建成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:修改优惠券
|
||
app.put('/api/admin/coupons/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const couponId = String(req.params.id);
|
||
const { name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status } = req.body;
|
||
|
||
if (name) {
|
||
const sec = contentSecurityService.checkText(name + ' ' + (description || ''));
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
}
|
||
|
||
const updated = await repo.updateCoupon(couponId, {
|
||
name: name ? name.trim() : undefined,
|
||
discountAmount: discountAmount !== undefined ? Number(discountAmount) : undefined,
|
||
minSpend: minSpend !== undefined ? Number(minSpend) : undefined,
|
||
validDays: validDays !== undefined ? Number(validDays) : undefined,
|
||
totalCount: totalCount !== undefined ? Number(totalCount) : undefined,
|
||
categoryLimit,
|
||
description,
|
||
status: status !== undefined ? Number(status) : undefined
|
||
});
|
||
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '优惠券不存在或已被删除' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: '优惠券修改成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:删除优惠券
|
||
app.delete('/api/admin/coupons/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const couponId = String(req.params.id);
|
||
const ok = await repo.deleteCoupon(couponId);
|
||
if (!ok) {
|
||
return res.status(404).json({ code: 40400, message: '优惠券不存在或已被删除' });
|
||
}
|
||
|
||
res.json({ code: 20000, data: null, message: '优惠券已删除' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 3. 平台官方客服、履约公告与规范协议配置接口
|
||
// 移动端:获取全部有效系统通用配置
|
||
app.get('/api/system/configs', async (_req: Request, res: Response) => {
|
||
try {
|
||
const configs = await repo.getPlatformConfigs();
|
||
res.json({ code: 20000, data: configs });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:获取全部配置项列表
|
||
app.get('/api/admin/system/configs', async (_req: Request, res: Response) => {
|
||
try {
|
||
const list = await repo.getAdminPlatformConfigs();
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
function extractSerialNoFromCertPem(certPem?: string): string {
|
||
if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) return '';
|
||
try {
|
||
const x509 = new crypto.X509Certificate(certPem.trim());
|
||
return (x509.serialNumber || '').toUpperCase().replace(/[^0-9A-F]/g, '');
|
||
} catch (_) {
|
||
return '';
|
||
}
|
||
}
|
||
|
||
// 管理后台:更新指定配置项
|
||
app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) => {
|
||
try {
|
||
const configKey = String(req.params.key);
|
||
const { name, valueJson, description, status } = req.body;
|
||
|
||
// 微信内容安全审查(检查文本内容或字符串化后的配置,security_words 黑名单库与微信支付证书配置项除外)
|
||
if (configKey !== 'security_words' && configKey !== 'wechat_pay_config') {
|
||
const textToCheck = `${name || ''} ${description || ''} ${JSON.stringify(valueJson || '')}`;
|
||
const sec = contentSecurityService.checkText(textToCheck);
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
}
|
||
|
||
if (configKey === 'wechat_pay_config' && valueJson && typeof valueJson === 'object') {
|
||
if (!String(valueJson.serialNo || '').trim() && valueJson.certPem) {
|
||
const autoSerial = extractSerialNoFromCertPem(String(valueJson.certPem));
|
||
if (autoSerial) {
|
||
valueJson.serialNo = autoSerial;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 订阅消息模板ID落库前规范化:剔除零宽字符/全角字符等不可见字符(否则原样发给微信会 40037)
|
||
if (configKey === 'message_template_rules' && valueJson && typeof valueJson === 'object') {
|
||
const normId = (v: any) => {
|
||
const s = normalizeTemplateId(v);
|
||
return /^tmpl_/i.test(s) ? '' : s;
|
||
};
|
||
valueJson.orderStatusTemplateId = normId(valueJson.orderStatusTemplateId);
|
||
valueJson.activityJoinTemplateId = normId(valueJson.activityJoinTemplateId);
|
||
valueJson.withdrawSuccessTemplateId = normId(valueJson.withdrawSuccessTemplateId);
|
||
valueJson.verifyCodeTemplateId = normId(valueJson.verifyCodeTemplateId);
|
||
}
|
||
|
||
const updated = await repo.updatePlatformConfig(configKey, {
|
||
name,
|
||
valueJson,
|
||
description,
|
||
status: status !== undefined ? Number(status) : undefined
|
||
});
|
||
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '配置项不存在' });
|
||
}
|
||
|
||
// 若更新的是自定义敏感词库,实时热更新内存过滤引擎
|
||
if (configKey === 'security_words' && Array.isArray(valueJson)) {
|
||
contentSecurityService.setCustomKeywords(valueJson);
|
||
}
|
||
|
||
res.json({ code: 20000, data: updated, message: '系统配置更新成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 创建新订单 (落盘 MySQL)
|
||
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,
|
||
Number(req.body?.buyerId || req.body?.userId || req.headers['x-user-id'] || 0),
|
||
'创建订单'
|
||
);
|
||
if (!buyerId) {
|
||
return res.status(400).json({ code: 40000, message: '缺少下单用户身份,无法创建订单' });
|
||
}
|
||
await assertUserNotBanned(buyerId, '预约陪玩或发单');
|
||
|
||
const order = await repo.createOrder({ ...req.body, buyerId });
|
||
res.json({
|
||
code: 20000,
|
||
data: order,
|
||
message: '订单已创建并已写入MySQL'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 取消订单
|
||
app.post('/api/orders/:id/cancel', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
const own = await repo.getOrderOwnership(orderId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '订单不存在' });
|
||
}
|
||
const role = resolveOrderActor(req, own, '取消订单');
|
||
if (!role.isAdmin && !role.isBuyer) {
|
||
return res.status(403).json({ code: 40300, message: '仅买家本人或平台管理员可取消该订单' });
|
||
}
|
||
// 买家取消:按状态安全处理(已付款待接单会原路退款;已进入履约需走退款申诉),避免静默吞掉已付款资金
|
||
const order = role.isAdmin
|
||
? await repo.updateOrderStatus(orderId, '已取消')
|
||
: await repo.buyerCancelOrder(orderId);
|
||
res.json({ code: 20000, data: order, message: '订单已取消' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 支付订单 (支持流转为待接单或履约中)
|
||
app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
|
||
// 归属校验:仅该订单的下单买家本人(或管理员)可确认支付,杜绝替他人订单付款/推进状态
|
||
const own = await repo.getOrderOwnership(orderId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '订单不存在' });
|
||
}
|
||
if (!isVerifiedAdminRequest(req)) {
|
||
const buyerId = Number(own.buyerId || 0);
|
||
const caller = resolveSecureUserId(
|
||
req,
|
||
Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0),
|
||
'确认订单支付'
|
||
);
|
||
if (buyerId > 0 && caller !== buyerId) {
|
||
return res.status(403).json({ code: 40300, message: '仅下单买家本人可确认支付该订单' });
|
||
}
|
||
}
|
||
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
|
||
// 生产 LIVE:必须存在服务端确认的微信支付收款,杜绝前端直接改状态
|
||
if (cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive) {
|
||
const paid = await confirmWechatPayment(cfg, orderId);
|
||
if (!paid) {
|
||
return res.status(400).json({ code: 40000, message: '微信支付尚未完成,无法确认订单支付' });
|
||
}
|
||
const isMarketplace = !Number(own.expertId);
|
||
const paidStatus = isMarketplace ? '待接单' : '履约中';
|
||
const order = await repo.updateOrderStatus(orderId, paidStatus);
|
||
const buyer = Number(own.buyerId || 0);
|
||
if (isMarketplace) {
|
||
pushUserNotification(
|
||
buyer,
|
||
'ORDER_STATUS',
|
||
'支付成功,等待大神接单',
|
||
`订单 ${order?.order_no || orderId} 已支付,正在为您匹配附近大神;30 分钟内无人接单将自动全额退款。`,
|
||
'/pages/order/list'
|
||
);
|
||
return res.json({ code: 20000, data: order, message: '支付成功,已进入派单池,等待附近大神接单' });
|
||
}
|
||
pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入履约中${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list');
|
||
void notifyBySubscribe('orderStatus', {
|
||
userId: buyer,
|
||
page: '/pages/order/list',
|
||
data: {
|
||
character_string1: String(order?.order_no || orderId),
|
||
phrase2: '已开始服务',
|
||
thing3: String(order?.service_title || order?.track || '同城陪伴服务'),
|
||
thing4: order?.verify_code ? `核销码${order.verify_code},请出示` : '请按时到场,感谢支持'
|
||
}
|
||
});
|
||
return res.json({ code: 20000, data: order, message: '支付成功,订单已进入履约中' });
|
||
}
|
||
|
||
// 非 LIVE(沙箱):生产环境默认禁止「前端直接置为已支付」,需显式 ALLOW_SANDBOX_PAY=1 放行
|
||
if (!ALLOW_SANDBOX_PAY) {
|
||
return res.status(403).json({ code: 40300, message: '支付未通过服务端收款确认,禁止直接变更订单支付状态' });
|
||
}
|
||
|
||
// 沙箱仅允许推进到「已支付待履约」的合法状态,严禁客户端任意指定终态或退款态
|
||
const requested = String(req.body?.targetStatus || (Number(own.expertId) ? '履约中' : '待接单'));
|
||
const allowedSandboxStatuses = ['履约中', '待接单'];
|
||
if (!allowedSandboxStatuses.includes(requested)) {
|
||
return res.status(400).json({ code: 40000, message: `非法的目标订单状态: ${requested}` });
|
||
}
|
||
const order = await repo.updateOrderStatus(orderId, requested);
|
||
const buyer = Number(own.buyerId || 0);
|
||
pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入${requested}${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list');
|
||
void notifyBySubscribe('orderStatus', {
|
||
userId: buyer,
|
||
page: '/pages/order/list',
|
||
data: {
|
||
character_string1: String(order?.order_no || orderId),
|
||
phrase2: '已开始服务',
|
||
thing3: String(order?.service_title || order?.track || '同城陪伴服务'),
|
||
thing4: order?.verify_code ? `核销码${order.verify_code},请出示` : '请按时到场,感谢支持'
|
||
}
|
||
});
|
||
res.json({ code: 20000, data: order, message: `支付成功,订单已进入${requested}` });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 解析微信支付运行时配置 (优先读取后台数据库配置 wechat_pay_config,兜底读取环境变量)
|
||
async function resolveWechatPayRuntimeConfig(overrideCfg?: any) {
|
||
let dbCfg: any = {};
|
||
try {
|
||
const record = await repo.getPlatformConfigByKey('wechat_pay_config');
|
||
if (record && record.valueJson && typeof record.valueJson === 'object') {
|
||
dbCfg = record.valueJson;
|
||
}
|
||
} catch (e) {
|
||
logServerError('resolveWechatPayRuntimeConfig', e);
|
||
}
|
||
|
||
const merged = {
|
||
mode: 'AUTO',
|
||
appId: process.env.WX_APP_ID || '',
|
||
appSecret: process.env.WX_APP_SECRET || '',
|
||
mchId: process.env.WX_MCH_ID || '',
|
||
apiV3Key: process.env.WX_API_V3_KEY || '',
|
||
serialNo: process.env.WX_SERIAL_NO || '',
|
||
privateKey: process.env.WX_PRIVATE_KEY || '',
|
||
certPem: '',
|
||
platformPublicKeyId: '',
|
||
platformPublicKey: '',
|
||
notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify',
|
||
remark: '微信支付V3直连商户参数配置',
|
||
...dbCfg,
|
||
...(overrideCfg || {})
|
||
};
|
||
|
||
const appId = String(merged.appId || '').trim();
|
||
const appSecret = String(merged.appSecret || '').trim();
|
||
const mchId = String(merged.mchId || '').trim();
|
||
const apiV3Key = String(merged.apiV3Key || '').trim();
|
||
const certPem = String(merged.certPem || '').trim();
|
||
let serialNo = String(merged.serialNo || '').trim();
|
||
if (!serialNo && certPem) {
|
||
serialNo = extractSerialNoFromCertPem(certPem);
|
||
}
|
||
const privateKey = String(merged.privateKey || '').trim();
|
||
const platformPublicKeyId = String(merged.platformPublicKeyId || '').trim();
|
||
const platformPublicKey = String(merged.platformPublicKey || '').trim();
|
||
// 商家转账到零钱(提现打款)相关配置
|
||
const transferSceneId = String(merged.transferSceneId || '').trim();
|
||
const transferRemark = String(merged.transferRemark || '').trim();
|
||
const transferNotifyUrl = String(merged.transferNotifyUrl || '').trim();
|
||
const refundNotifyUrl = String(merged.refundNotifyUrl || '').trim();
|
||
const transferUserNameEnabled = merged.transferUserNameEnabled === true || merged.transferUserNameEnabled === 'true' || Number(merged.transferUserNameEnabled) === 1;
|
||
const transferSceneReportInfos = Array.isArray(merged.transferSceneReportInfos) ? merged.transferSceneReportInfos : [];
|
||
const isReadyForLive = Boolean(appId && mchId && apiV3Key.length === 32 && privateKey);
|
||
const isReadyForTransfer = Boolean(isReadyForLive && transferSceneId);
|
||
|
||
let effectiveMode: 'LIVE' | 'SANDBOX' = 'SANDBOX';
|
||
if (merged.mode === 'LIVE') {
|
||
effectiveMode = isReadyForLive ? 'LIVE' : 'SANDBOX';
|
||
} else if (merged.mode === 'SANDBOX') {
|
||
effectiveMode = 'SANDBOX';
|
||
} else {
|
||
effectiveMode = isReadyForLive ? 'LIVE' : 'SANDBOX';
|
||
}
|
||
|
||
return {
|
||
...merged,
|
||
appId,
|
||
appSecret,
|
||
mchId,
|
||
apiV3Key,
|
||
serialNo,
|
||
privateKey,
|
||
certPem,
|
||
platformPublicKeyId,
|
||
platformPublicKey,
|
||
transferSceneId,
|
||
transferRemark,
|
||
transferNotifyUrl,
|
||
refundNotifyUrl,
|
||
transferUserNameEnabled,
|
||
transferSceneReportInfos,
|
||
isReadyForLive,
|
||
isReadyForTransfer,
|
||
effectiveMode
|
||
};
|
||
}
|
||
|
||
// 运行模式与安全开关已在文件顶部统一声明(鉴权常量依赖其初始化顺序),此处不再重复定义。
|
||
|
||
/**
|
||
* 确认某笔微信支付已成功:优先读本地意图,其次主动向微信支付查单(含短暂重试),
|
||
* 确认后落库对账。返回是否已确认收款。用于前端支付完成后的服务端二次确认。
|
||
*/
|
||
async function confirmWechatPayment(cfg: any, outTradeNo: string): Promise<boolean> {
|
||
if (!outTradeNo) return false;
|
||
for (let i = 0; i < 3; i++) {
|
||
const intent = await repo.getPaymentIntentByOutTradeNo(outTradeNo);
|
||
if (intent && intent.status === 'SUCCESS') return true;
|
||
try {
|
||
const q = await queryOrderByOutTradeNo(cfg, outTradeNo);
|
||
if (q && q.trade_state === 'SUCCESS') {
|
||
await repo.applyPaymentSuccess(outTradeNo, {
|
||
transactionId: q.transaction_id,
|
||
amountTotalCents: q.amount ? Number(q.amount.total) : undefined,
|
||
});
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
logServerError('confirmWechatPayment.query', e);
|
||
}
|
||
if (i < 2) await new Promise((r) => setTimeout(r, 600));
|
||
}
|
||
const finalIntent = await repo.getPaymentIntentByOutTradeNo(outTradeNo);
|
||
return Boolean(finalIntent && finalIntent.status === 'SUCCESS');
|
||
}
|
||
|
||
// 管理后台:解析商户证书 apiclient_cert.pem 自动提取 40 位证书序列号
|
||
app.post('/api/admin/pay/wechat/parse-cert', (req: Request, res: Response) => {
|
||
try {
|
||
const certPem = String(req.body?.certPem || '').trim();
|
||
if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) {
|
||
return res.status(400).json({ code: 40000, message: '请提供有效的 PEM 格式证书内容 (apiclient_cert.pem)' });
|
||
}
|
||
const x509 = new crypto.X509Certificate(certPem);
|
||
const serialNo = (x509.serialNumber || '').toUpperCase().replace(/[^0-9A-F]/g, '');
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
serialNo,
|
||
subject: x509.subject,
|
||
validFrom: x509.validFrom,
|
||
validTo: x509.validTo
|
||
},
|
||
message: `已成功从证书提取序列号: ${serialNo}`
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: `解析证书失败: ${err.message}` });
|
||
}
|
||
});
|
||
|
||
// 管理后台:获取微信支付商户号与证书配置
|
||
app.get('/api/admin/pay/wechat/config', async (_req: Request, res: Response) => {
|
||
try {
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
res.json({ code: 20000, data: cfg });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:保存微信支付商户号与证书配置
|
||
app.post('/api/admin/pay/wechat/config', async (req: Request, res: Response) => {
|
||
try {
|
||
const incoming = req.body?.valueJson || req.body || {};
|
||
const certPem = String(incoming.certPem || '').trim();
|
||
let serialNo = String(incoming.serialNo || '').trim();
|
||
if (!serialNo && certPem) {
|
||
serialNo = extractSerialNoFromCertPem(certPem);
|
||
}
|
||
const payload = {
|
||
mode: incoming.mode || 'AUTO',
|
||
appId: String(incoming.appId || '').trim(),
|
||
appSecret: String(incoming.appSecret || '').trim(),
|
||
mchId: String(incoming.mchId || '').trim(),
|
||
apiV3Key: String(incoming.apiV3Key || '').trim(),
|
||
serialNo,
|
||
privateKey: String(incoming.privateKey || '').trim(),
|
||
certPem,
|
||
platformPublicKeyId: String(incoming.platformPublicKeyId || '').trim(),
|
||
platformPublicKey: String(incoming.platformPublicKey || '').trim(),
|
||
notifyUrl: String(incoming.notifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/notify').trim(),
|
||
transferSceneId: String(incoming.transferSceneId || '').trim(),
|
||
transferRemark: String(incoming.transferRemark || '').trim(),
|
||
transferNotifyUrl: String(incoming.transferNotifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/transfer-notify').trim(),
|
||
refundNotifyUrl: String(incoming.refundNotifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/refund-notify').trim(),
|
||
transferUserNameEnabled: incoming.transferUserNameEnabled === true || incoming.transferUserNameEnabled === 'true' || Number(incoming.transferUserNameEnabled) === 1,
|
||
transferSceneReportInfos: Array.isArray(incoming.transferSceneReportInfos) ? incoming.transferSceneReportInfos : [],
|
||
remark: String(incoming.remark || '微信支付V3直连商户参数配置').trim()
|
||
};
|
||
|
||
await repo.updatePlatformConfig('wechat_pay_config', {
|
||
name: '微信支付商户号与证书配置',
|
||
description: '微信支付V3商户号、APIv3密钥与证书参数配置',
|
||
status: 1,
|
||
valueJson: payload
|
||
});
|
||
|
||
const latest = await resolveWechatPayRuntimeConfig();
|
||
res.json({
|
||
code: 20000,
|
||
data: latest,
|
||
message: latest.effectiveMode === 'LIVE'
|
||
? '微信支付配置已保存,已激活实盘支付模式 (LIVE)'
|
||
: '微信支付配置已保存,当前运行于沙箱收银台模式 (SANDBOX)'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:微信支付商户参数完整性与 RSA 签名自检
|
||
app.post('/api/admin/pay/wechat/test', async (req: Request, res: Response) => {
|
||
try {
|
||
const cfg = await resolveWechatPayRuntimeConfig(req.body || {});
|
||
const checks: Array<{ item: string; pass: boolean; detail: string }> = [];
|
||
|
||
checks.push({
|
||
item: '小程序 AppID (appId)',
|
||
pass: /^wx[a-zA-Z0-9]{10,24}$/.test(cfg.appId),
|
||
detail: cfg.appId ? `已填写 (${cfg.appId})` : '未填写(生产环境需填写 wx 开头的小程序 AppID)'
|
||
});
|
||
|
||
checks.push({
|
||
item: '小程序 AppSecret (appSecret)',
|
||
pass: /^[0-9a-fA-F]{32}$/.test(cfg.appSecret),
|
||
detail: cfg.appSecret ? `已填写 (${cfg.appSecret.slice(0, 6)}...${cfg.appSecret.slice(-4)})` : '未填写(需在微信公众平台-开发管理中获取32位AppSecret)'
|
||
});
|
||
|
||
checks.push({
|
||
item: '微信支付商户号 (mchId)',
|
||
pass: /^\d{8,16}$/.test(cfg.mchId),
|
||
detail: cfg.mchId ? `已填写 (${cfg.mchId})` : '未填写(需填写 10 位微信支付直连商户号)'
|
||
});
|
||
|
||
checks.push({
|
||
item: 'APIv3 密钥 (apiV3Key)',
|
||
pass: cfg.apiV3Key.length === 32,
|
||
detail: cfg.apiV3Key
|
||
? `当前长度 ${cfg.apiV3Key.length} 位 ${cfg.apiV3Key.length === 32 ? '(符合32位标准)' : '(需严格为32位字符)'}`
|
||
: '未填写(需在微信商户平台-API安全中设置32位密钥)'
|
||
});
|
||
|
||
checks.push({
|
||
item: '商户证书序列号 (serialNo)',
|
||
pass: /^[0-9A-Fa-f]{16,64}$/.test(cfg.serialNo),
|
||
detail: cfg.serialNo ? `已就绪 (${cfg.serialNo})` : '未填写(可手动填入或上传 apiclient_cert.pem 自动提取)'
|
||
});
|
||
|
||
let signPass = false;
|
||
let samplePaySign = '';
|
||
let signDetail = '未提供商户私钥 apiclient_key.pem';
|
||
if (cfg.privateKey) {
|
||
try {
|
||
const message = `${cfg.appId || 'wx_test'}\n${Math.floor(Date.now() / 1000)}\ntest_nonce\nprepay_id=wx_test\n`;
|
||
const signer = crypto.createSign('RSA-SHA256');
|
||
signer.update(message);
|
||
samplePaySign = signer.sign(cfg.privateKey, 'base64');
|
||
signPass = Boolean(samplePaySign);
|
||
signDetail = `RSA-SHA256 签名验证通过 (签名摘要: ${samplePaySign.slice(0, 24)}...)`;
|
||
} catch (e: any) {
|
||
signDetail = `私钥 PEM 格式解析或签名失败: ${e.message}`;
|
||
}
|
||
}
|
||
checks.push({
|
||
item: '商户 API 私钥 (apiclient_key.pem)',
|
||
pass: signPass,
|
||
detail: signDetail
|
||
});
|
||
|
||
if (cfg.platformPublicKey || cfg.platformPublicKeyId) {
|
||
let pubKeyPass = false;
|
||
let pubKeyDetail = '';
|
||
if (cfg.platformPublicKey) {
|
||
try {
|
||
crypto.createPublicKey(cfg.platformPublicKey);
|
||
pubKeyPass = true;
|
||
pubKeyDetail = `微信支付平台公钥校验通过${cfg.platformPublicKeyId ? ` (ID: ${cfg.platformPublicKeyId})` : '(建议同时填写公钥ID)'}`;
|
||
} catch (e: any) {
|
||
pubKeyDetail = `微信支付平台公钥 PEM 格式无效: ${e.message}`;
|
||
}
|
||
} else {
|
||
pubKeyDetail = `已填写公钥 ID (${cfg.platformPublicKeyId}),待上传 pub_key.pem 公钥内容`;
|
||
}
|
||
checks.push({
|
||
item: '微信支付平台公钥 (pub_key.pem)',
|
||
pass: pubKeyPass,
|
||
detail: pubKeyDetail
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
isReadyForLive: cfg.isReadyForLive && signPass,
|
||
effectiveMode: cfg.isReadyForLive && signPass && cfg.mode !== 'SANDBOX' ? 'LIVE' : 'SANDBOX',
|
||
serialNo: cfg.serialNo,
|
||
checks,
|
||
samplePaySign
|
||
},
|
||
message: '微信支付配置自检完成'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 微信小程序 jscode2session 登录与换取真实 openid 接口
|
||
app.post('/api/wechat/code2session', async (req: Request, res: Response) => {
|
||
try {
|
||
const { code, userId } = req.body || {};
|
||
if (!code) {
|
||
return res.status(400).json({ code: 40000, message: '微信登录凭证 code 不能为空' });
|
||
}
|
||
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const appId = String(cfg.appId || '').trim();
|
||
const appSecret = String(cfg.appSecret || '').trim();
|
||
|
||
if (!appId || !appSecret) {
|
||
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppID/AppSecret,无法换取 openid' });
|
||
}
|
||
|
||
const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}&js_code=${encodeURIComponent(code)}&grant_type=authorization_code`;
|
||
|
||
const wxData: any = await new Promise((resolve, reject) => {
|
||
https.get(wxUrl, (resp) => {
|
||
let data = '';
|
||
resp.on('data', chunk => { data += chunk; });
|
||
resp.on('end', () => {
|
||
try {
|
||
resolve(JSON.parse(data));
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
});
|
||
}).on('error', reject);
|
||
});
|
||
|
||
if (wxData.errcode) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: `微信接口换取 openid 失败: [${wxData.errcode}] ${wxData.errmsg}`,
|
||
data: wxData
|
||
});
|
||
}
|
||
|
||
const { openid, unionid } = wxData;
|
||
if (openid && userId) {
|
||
try {
|
||
// 绑定 openid 必须与会话身份一致,杜绝把他人 openid 关联到指定 userId 造成账号劫持
|
||
const claimed = resolveSecureUserId(req, Number(userId), '绑定微信账户');
|
||
await repo.updateUserOpenid(claimed, openid);
|
||
} catch (e) {
|
||
logServerError('login.updateUserOpenid', e);
|
||
}
|
||
}
|
||
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
openid,
|
||
unionid
|
||
},
|
||
message: '微信 openid 获取成功'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 内存缓存微信全局 access_token
|
||
let cachedWechatAccessToken = {
|
||
token: '',
|
||
expireAt: 0
|
||
};
|
||
|
||
async function getWechatAccessToken(appId: string, appSecret: string): Promise<string> {
|
||
if (cachedWechatAccessToken.token && Date.now() < cachedWechatAccessToken.expireAt) {
|
||
return cachedWechatAccessToken.token;
|
||
}
|
||
const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}`;
|
||
const tokenData: any = await new Promise((resolve, reject) => {
|
||
https.get(tokenUrl, (resp) => {
|
||
let data = '';
|
||
resp.on('data', chunk => { data += chunk; });
|
||
resp.on('end', () => {
|
||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||
});
|
||
}).on('error', reject);
|
||
});
|
||
|
||
if (tokenData.errcode) {
|
||
throw new Error(`获取微信 access_token 失败: [${tokenData.errcode}] ${tokenData.errmsg}`);
|
||
}
|
||
|
||
const token = tokenData.access_token;
|
||
const expiresIn = Number(tokenData.expires_in || 7200);
|
||
cachedWechatAccessToken = {
|
||
token,
|
||
expireAt: Date.now() + Math.max(60, expiresIn - 300) * 1000 // 提前5分钟刷新
|
||
};
|
||
return token;
|
||
}
|
||
|
||
/**
|
||
* 微信订阅消息(服务通知)下发。
|
||
* 仅当后台开启推送且配置了对应模板ID、且目标用户有 openid 时真实下发;
|
||
* 失败仅记录日志,绝不阻塞/影响主业务。
|
||
*/
|
||
// 微信 time 类型字段建议格式:YYYY年MM月DD日 HH:mm
|
||
function formatCnTime(d: Date = new Date()): string {
|
||
const p = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}年${p(d.getMonth() + 1)}月${p(d.getDate())}日 ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||
}
|
||
|
||
// 将任意时间字符串转为微信 time 字段可接受的格式;非标准日期(如"今晚")则回退为当前时间,避免 47003
|
||
function toCnTimeOrNow(value?: string): string {
|
||
const s = String(value || '').trim();
|
||
if (!s) return formatCnTime();
|
||
const looksDateLike = /\d{4}[-/年]\d{1,2}|\d{1,2}:\d{2}/.test(s);
|
||
if (!looksDateLike) return formatCnTime();
|
||
const d = new Date(s.replace(/年|月/g, '/').replace(/日/g, ' ').trim());
|
||
if (isNaN(d.getTime())) return formatCnTime();
|
||
return formatCnTime(d);
|
||
}
|
||
|
||
// 站内消息落库(fire-and-forget,绝不影响主业务;与微信订阅消息并行)
|
||
function pushUserNotification(userId: number, type: string, title: string, content: string, link = ''): void {
|
||
const uid = Number(userId) || 0;
|
||
if (uid <= 0) return;
|
||
void repo
|
||
.createUserNotification({ userId: uid, type, title, content, link })
|
||
.catch((e: any) => logServerError('pushUserNotification', e));
|
||
}
|
||
|
||
async function notifyBySubscribe(
|
||
templateKey: 'orderStatus' | 'activityJoin' | 'withdrawSuccess' | 'verifyCode',
|
||
opts: { userId: number; page?: string; data: Record<string, string | number> }
|
||
): Promise<void> {
|
||
try {
|
||
const uid = Number(opts.userId) || 0;
|
||
if (uid <= 0) return;
|
||
const rulesRecord = await repo.getPlatformConfigByKey('message_template_rules');
|
||
const rules = (rulesRecord && rulesRecord.valueJson) || {};
|
||
if (Number(rules.enablePush) !== 1) return;
|
||
const keyMap: Record<string, string> = {
|
||
orderStatus: 'orderStatusTemplateId',
|
||
activityJoin: 'activityJoinTemplateId',
|
||
withdrawSuccess: 'withdrawSuccessTemplateId',
|
||
verifyCode: 'verifyCodeTemplateId',
|
||
};
|
||
const templateId = normalizeTemplateId(rules[keyMap[templateKey]]);
|
||
if (!templateId || /^tmpl_/i.test(templateId)) return;
|
||
|
||
const payCfg = await resolveWechatPayRuntimeConfig();
|
||
const appId = String(payCfg.appId || '').trim();
|
||
const appSecret = String(payCfg.appSecret || '').trim();
|
||
if (!appId || !appSecret) return;
|
||
|
||
const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [uid]);
|
||
const openid = uRows && uRows[0] && uRows[0].wechat_openid;
|
||
if (!openid) return;
|
||
|
||
const accessToken = await getWechatAccessToken(appId, appSecret);
|
||
const result = await sendSubscribeMessageHttp(accessToken, {
|
||
touser: String(openid),
|
||
template_id: templateId,
|
||
page: opts.page,
|
||
data: buildTemplateData(opts.data || {}),
|
||
miniprogram_state: MINIPROGRAM_STATE,
|
||
});
|
||
if (result && result.errcode && result.errcode !== 0) {
|
||
logServerError('notifyBySubscribe', new Error(`订阅消息[${templateKey}]下发失败 ${result.errcode} ${result.errmsg}`));
|
||
}
|
||
} catch (e) {
|
||
logServerError('notifyBySubscribe', e);
|
||
}
|
||
}
|
||
|
||
// 注册微信原路退款执行器(供仓储层退款时调用;未就绪时返回 skipped,由调用方回退余额退款)
|
||
setWeChatRefundFn(async (orderId, refundCents, totalCents, reason, outRefundNo) => {
|
||
try {
|
||
const cfg: any = await resolveWechatPayRuntimeConfig();
|
||
if (!(cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive)) {
|
||
return { ok: false, skipped: true, error: '微信支付未就绪' };
|
||
}
|
||
// 幂等:已成功的退款直接返回
|
||
const existing = await repo.getRefundByOutRefundNo(outRefundNo);
|
||
if (existing && existing.status === 'SUCCESS') {
|
||
return { ok: true, status: 'SUCCESS', refundId: existing.refund_id };
|
||
}
|
||
const resp = await createRefund(cfg, {
|
||
outTradeNo: orderId,
|
||
outRefundNo,
|
||
refundCents,
|
||
totalCents,
|
||
reason,
|
||
notifyUrl: cfg.refundNotifyUrl || undefined,
|
||
});
|
||
await repo.upsertRefundRecord({
|
||
outRefundNo,
|
||
outTradeNo: orderId,
|
||
orderId,
|
||
refundId: resp && resp.refund_id,
|
||
refundCents,
|
||
totalCents,
|
||
status: String((resp && resp.status) || 'PROCESSING'),
|
||
reason,
|
||
});
|
||
return { ok: true, status: String((resp && resp.status) || 'PROCESSING'), refundId: resp && resp.refund_id };
|
||
} catch (e: any) {
|
||
logServerError('wechatRefund', e);
|
||
return { ok: false, error: (e && e.message) || String(e) };
|
||
}
|
||
});
|
||
|
||
// 注册微信官方内容安全 msgSecCheck(v2) 真实检测器:
|
||
// 本地词库先做即时拦截,本地放行后再交由微信官方机器审核做第二层真实防御。
|
||
// 未配置真实小程序凭证时(appId/appSecret 为空),自动跳过远端并降级本地词库。
|
||
contentSecurityService.setRemoteChecker(async (text: string, openid?: string, scene?: number) => {
|
||
try {
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const appId = String(cfg.appId || '').trim();
|
||
const appSecret = String(cfg.appSecret || '').trim();
|
||
|
||
// 仅当显式配置了真实小程序凭证时才调用官方内容审核(不再有内置占位值兜底)
|
||
if (!appId || !appSecret) return null;
|
||
|
||
const accessToken = await getWechatAccessToken(appId, appSecret);
|
||
if (!accessToken) return null;
|
||
|
||
const payload = JSON.stringify({
|
||
version: 2,
|
||
openid: openid || '',
|
||
scene: typeof scene === 'number' ? scene : 2,
|
||
content: text
|
||
});
|
||
const wxUrl = `https://api.weixin.qq.com/wxa/msg_sec_check?access_token=${encodeURIComponent(accessToken)}`;
|
||
const data: any = await new Promise((resolve, reject) => {
|
||
const wxReq = https.request(wxUrl, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }
|
||
}, (resp) => {
|
||
let raw = '';
|
||
resp.on('data', (chunk) => { raw += chunk; });
|
||
resp.on('end', () => {
|
||
try { resolve(JSON.parse(raw)); } catch (e) { reject(e); }
|
||
});
|
||
});
|
||
wxReq.on('error', reject);
|
||
wxReq.write(payload);
|
||
wxReq.end();
|
||
});
|
||
|
||
if (data && data.errcode === 87014) {
|
||
return { pass: false, code: 87014, label: 'WECHAT_RISKY', suggest: 'block' as const, message: '内容涉嫌违规,已被微信内容安全系统拦截' };
|
||
}
|
||
if (data && data.errcode === 0 && data.result) {
|
||
const suggest = String(data.result.suggest || 'pass');
|
||
if (suggest === 'risky') {
|
||
return { pass: false, code: 87014, label: String(data.result.label || 'WECHAT_RISKY'), suggest: 'block' as const, message: '内容涉嫌违规,已被微信内容安全系统拦截' };
|
||
}
|
||
if (suggest === 'review') {
|
||
return { pass: true, code: 0, label: String(data.result.label || 'WECHAT_REVIEW'), suggest: 'review' as const, message: '内容已提交微信人工复核' };
|
||
}
|
||
return { pass: true, code: 0, suggest: 'pass' as const, message: '微信内容安全检测通过' };
|
||
}
|
||
// token 失效 / 限频 / 其他异常:降级为本地词库结果(fail-open)
|
||
return null;
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
});
|
||
|
||
// 微信小程序获取真实手机号一键授权登录接口 (调用官方 getuserphonenumber 接口解密真实手机号)
|
||
app.post('/api/auth/wechat-phone', async (req: Request, res: Response) => {
|
||
try {
|
||
const { phoneCode, wxCode } = req.body || {};
|
||
if (!phoneCode) {
|
||
return res.status(400).json({ code: 40000, message: '微信手机号授权凭证 phoneCode 不能为空' });
|
||
}
|
||
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const appId = String(cfg.appId || '').trim();
|
||
const appSecret = String(cfg.appSecret || '').trim();
|
||
|
||
if (!appId || !appSecret) {
|
||
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppID/AppSecret' });
|
||
}
|
||
|
||
// 1. 若前端传递了 wxCode,顺便获取当前用户的真实 openid
|
||
let openid = '';
|
||
if (wxCode) {
|
||
try {
|
||
const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}&js_code=${encodeURIComponent(wxCode)}&grant_type=authorization_code`;
|
||
const sData: any = await new Promise((resolve, reject) => {
|
||
https.get(wxUrl, (resp) => {
|
||
let data = '';
|
||
resp.on('data', chunk => { data += chunk; });
|
||
resp.on('end', () => {
|
||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||
});
|
||
}).on('error', reject);
|
||
});
|
||
if (sData && sData.openid) {
|
||
openid = sData.openid;
|
||
}
|
||
} catch (e) {
|
||
logServerError('login.openidFallback', e);
|
||
}
|
||
}
|
||
|
||
// 2. 获取并复用微信 access_token
|
||
const accessToken = await getWechatAccessToken(appId, appSecret);
|
||
|
||
// 3. 调用微信官方 getuserphonenumber 接口
|
||
const phoneUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${encodeURIComponent(accessToken)}`;
|
||
const phonePayload = JSON.stringify({ code: phoneCode });
|
||
|
||
const phoneRes: any = await new Promise((resolve, reject) => {
|
||
const reqObj = https.request(phoneUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(phonePayload)
|
||
}
|
||
}, (resp) => {
|
||
let data = '';
|
||
resp.on('data', chunk => { data += chunk; });
|
||
resp.on('end', () => {
|
||
try { resolve(JSON.parse(data)); } catch (e) { reject(e); }
|
||
});
|
||
});
|
||
reqObj.on('error', reject);
|
||
reqObj.write(phonePayload);
|
||
reqObj.end();
|
||
});
|
||
|
||
if (phoneRes.errcode !== 0) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: `微信手机号解密失败: [${phoneRes.errcode}] ${phoneRes.errmsg}`,
|
||
data: phoneRes
|
||
});
|
||
}
|
||
|
||
const phoneInfo = phoneRes.phone_info || {};
|
||
const realPhone = phoneInfo.phoneNumber || phoneInfo.purePhoneNumber;
|
||
if (!realPhone) {
|
||
return res.status(400).json({ code: 40000, message: '未能从微信服务获取到有效手机号' });
|
||
}
|
||
|
||
// 4. 在数据库中查询或创建真实用户账号
|
||
const user = await repo.findOrCreateUserByPhone(realPhone, openid);
|
||
|
||
if (user && (user.status === '已封禁' || user.status === '封禁')) {
|
||
return res.status(403).json({
|
||
code: 40300,
|
||
message: '您的账号已被管理员封禁,无法登录系统',
|
||
data: { user, status: '已封禁' }
|
||
});
|
||
}
|
||
|
||
return res.json({
|
||
code: 20000,
|
||
message: '微信手机号快捷登录成功',
|
||
data: {
|
||
user,
|
||
userId: user.id,
|
||
phone: realPhone,
|
||
token: signUserToken(user.id),
|
||
userToken: signUserToken(user.id)
|
||
}
|
||
});
|
||
} catch (err: any) {
|
||
return res.status(500).json({ code: 50000, message: err.message || '微信手机号快捷登录异常' });
|
||
}
|
||
});
|
||
|
||
// ===== 体验账号(演示/评审):清单与文案全部来自 DB 配置,小程序端不再写死 =====
|
||
async function loadDemoAccounts(): Promise<Array<{ userId: number; roleTag: string; roleDesc: string; desc: string }>> {
|
||
try {
|
||
const record = await repo.getPlatformConfigByKey('demo_accounts');
|
||
const value = (record && record.valueJson) || {};
|
||
if (value.enabled === false) return [];
|
||
const accounts = Array.isArray(value.accounts) ? value.accounts : [];
|
||
return accounts
|
||
.map((a: any) => ({
|
||
userId: Number(a.userId) || 0,
|
||
roleTag: String(a.roleTag || ''),
|
||
roleDesc: String(a.roleDesc || ''),
|
||
desc: String(a.desc || ''),
|
||
}))
|
||
.filter((a: any) => a.userId > 0)
|
||
.slice(0, 10);
|
||
} catch (e) {
|
||
logServerError('loadDemoAccounts', e);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
app.get('/api/auth/demo-accounts', async (_req: Request, res: Response) => {
|
||
try {
|
||
const accounts = await loadDemoAccounts();
|
||
const list = [];
|
||
for (const acc of accounts) {
|
||
const u: any = await repo.getUserById(acc.userId);
|
||
if (!u) continue;
|
||
if (u.status === '已封禁' || u.status === '封禁') continue;
|
||
list.push({
|
||
userId: u.id,
|
||
nickname: u.nickname || `用户${u.id}`,
|
||
avatar: u.avatar || '',
|
||
roleTag: acc.roleTag,
|
||
roleDesc: acc.roleDesc,
|
||
desc: acc.desc,
|
||
});
|
||
}
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 体验登录:只对 DB 配置内的账号签发真实 token,避免"无 token 的假登录"造成的 401/身份错配
|
||
app.post('/api/auth/demo-login', rateLimit({ name: 'demo_login', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = Number(req.body?.userId) || 0;
|
||
const accounts = await loadDemoAccounts();
|
||
if (!accounts.some((a) => a.userId === userId)) {
|
||
return res.status(403).json({ code: 40300, message: '该账号未开启体验登录' });
|
||
}
|
||
const user: any = await repo.getUserById(userId);
|
||
if (!user) {
|
||
return res.status(404).json({ code: 40400, message: '体验账号不存在或已被清理' });
|
||
}
|
||
if (user.status === '已封禁' || user.status === '封禁') {
|
||
return res.status(403).json({ code: 40300, message: '该体验账号已被封禁' });
|
||
}
|
||
const token = signUserToken(user.id);
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
userId: user.id,
|
||
user: {
|
||
id: user.id,
|
||
nickname: user.nickname,
|
||
avatar: user.avatar,
|
||
gender: user.gender,
|
||
phone: user.phone,
|
||
},
|
||
userToken: token,
|
||
token,
|
||
},
|
||
message: '体验登录成功',
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message || '体验登录异常' });
|
||
}
|
||
});
|
||
|
||
// 微信小程序 JSAPI 统一下单预支付接口 (支持真实商户号配置与沙盒收银台无缝切换)
|
||
app.post('/api/pay/wechat/prepay', rateLimit({ name: 'prepay', windowMs: 60 * 1000, max: 30, key: rateWriteKey }), async (req: Request, res: Response) => {
|
||
try {
|
||
const {
|
||
orderId,
|
||
amount,
|
||
title,
|
||
scene = 'ORDER',
|
||
userId = 1001,
|
||
wxCode,
|
||
openid: inputOpenid,
|
||
} = req.body || {};
|
||
|
||
// 身份以服务端会话为准,杜绝替他人发起支付
|
||
const uid = resolveSecureUserId(req, Number(userId), '发起微信支付');
|
||
|
||
let payAmount = Number(amount || 0);
|
||
|
||
// 订单支付:金额必须以订单为准,且仅下单买家本人(或管理员)可发起,
|
||
// 杜绝通过伪造低价金额实现「低价支付高价订单」。
|
||
if (String(scene) === 'ORDER' && orderId) {
|
||
const own = await repo.getOrderOwnership(String(orderId));
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '订单不存在' });
|
||
}
|
||
if (!isVerifiedAdminRequest(req)) {
|
||
const buyerId = Number(own.buyerId || 0);
|
||
if (buyerId > 0 && uid !== buyerId) {
|
||
return res.status(403).json({ code: 40300, message: '仅下单买家本人可发起该订单支付' });
|
||
}
|
||
}
|
||
payAmount = Number(own.amount) || 0;
|
||
}
|
||
|
||
if (isNaN(payAmount) || payAmount <= 0) {
|
||
return res.status(400).json({ code: 40000, message: '支付金额必须大于0' });
|
||
}
|
||
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const appId = String(cfg.appId || '').trim();
|
||
if (!appId) {
|
||
return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppID(后台「微信支付商户配置」或环境变量 WX_APP_ID),无法发起微信支付' });
|
||
}
|
||
const outTradeNo = orderId ? String(orderId) : `WX${scene === 'RECHARGE' ? 'R' : 'O'}${Date.now()}${uid}`;
|
||
const desc = title || (scene === 'RECHARGE' ? '钱包余额充值' : '同城陪伴担保订单');
|
||
|
||
// 解析 payer openid:入参 > 数据库 > wxCode 换取
|
||
let openid = String(inputOpenid || '').trim();
|
||
if (!openid && uid) {
|
||
try {
|
||
const u: any = await repo.getUserById(uid);
|
||
openid = (u && (u.wechatOpenid || u.wechat_openid)) || '';
|
||
} catch (e) {
|
||
logServerError('prepay.resolveOpenid', e);
|
||
}
|
||
}
|
||
if (!openid && wxCode && cfg.appSecret) {
|
||
try {
|
||
const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(cfg.appSecret)}&js_code=${encodeURIComponent(wxCode)}&grant_type=authorization_code`;
|
||
const sData: any = await new Promise((resolve, reject) => {
|
||
https
|
||
.get(wxUrl, (resp) => {
|
||
let d = '';
|
||
resp.on('data', (c) => {
|
||
d += c;
|
||
});
|
||
resp.on('end', () => {
|
||
try {
|
||
resolve(JSON.parse(d));
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
});
|
||
})
|
||
.on('error', reject);
|
||
});
|
||
if (sData && sData.openid) {
|
||
openid = sData.openid;
|
||
if (uid) {
|
||
try {
|
||
await repo.updateUserOpenid(uid, openid);
|
||
} catch (e) {
|
||
logServerError('prepay.updateUserOpenid', e);
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
logServerError('prepay.code2session', e);
|
||
}
|
||
}
|
||
|
||
// 登记支付意图(对账与幂等入账的唯一依据)
|
||
const amountCents = Math.round(payAmount * 100);
|
||
// 充值赠金一律由服务端按平台阶梯权威计算,忽略客户端 bonusAmount,防止充值套取平台赠金
|
||
const bonusCents =
|
||
String(scene) === 'RECHARGE' ? await repo.computeRechargeBonusCents(amountCents) : 0;
|
||
|
||
await repo.createPaymentIntent({
|
||
outTradeNo,
|
||
scene: String(scene),
|
||
userId: uid,
|
||
orderId: orderId ? String(orderId) : '',
|
||
title: desc,
|
||
amountCents,
|
||
bonusCents,
|
||
});
|
||
|
||
// LIVE:调用微信支付 V3 JSAPI 下单,返回真实签名参数
|
||
if (cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive) {
|
||
if (!openid) {
|
||
return res.status(400).json({ code: 40000, message: '缺少支付用户 openid,请先完成微信登录授权' });
|
||
}
|
||
const prepayId = await createJsapiPrepay(
|
||
{ ...cfg, notifyUrl: cfg.notifyUrl },
|
||
{
|
||
outTradeNo,
|
||
description: desc,
|
||
amountTotalCents: Math.round(payAmount * 100),
|
||
openid,
|
||
}
|
||
);
|
||
const payParams = buildJsapiPayParams(cfg, prepayId);
|
||
return res.json({
|
||
code: 20000,
|
||
data: {
|
||
mode: 'LIVE',
|
||
appId,
|
||
timeStamp: payParams.timeStamp,
|
||
nonceStr: payParams.nonceStr,
|
||
package: payParams.package,
|
||
signType: payParams.signType,
|
||
paySign: payParams.paySign,
|
||
outTradeNo,
|
||
amount: payAmount.toFixed(2),
|
||
title: desc,
|
||
scene,
|
||
},
|
||
message: '预支付交易单生成成功',
|
||
});
|
||
}
|
||
|
||
// SANDBOX 兜底(仅显式 ALLOW_SANDBOX_PAY=1 的联调环境)
|
||
if (!ALLOW_SANDBOX_PAY) {
|
||
return res.status(503).json({
|
||
code: 50001,
|
||
message: '微信支付未就绪:请在后台完成微信支付商户配置后重试'
|
||
});
|
||
}
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
mode: 'SANDBOX',
|
||
appId,
|
||
timeStamp: String(Math.floor(Date.now() / 1000)),
|
||
nonceStr: `pw_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`,
|
||
package: `prepay_id=sandbox_${Date.now()}`,
|
||
signType: 'RSA',
|
||
paySign: 'SANDBOX_SIGN',
|
||
outTradeNo,
|
||
amount: payAmount.toFixed(2),
|
||
title: desc,
|
||
scene,
|
||
},
|
||
message: '预支付交易单生成成功(沙箱联调模式)',
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 微信支付 V3 异步结果通知回调接口 (验签 + APIv3 解密 + 幂等入账)
|
||
app.post('/api/pay/wechat/notify', async (req: Request, res: Response) => {
|
||
try {
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const rawBody = (req as any).rawBody || JSON.stringify(req.body || {});
|
||
const timestamp = String(req.headers['wechatpay-timestamp'] || '');
|
||
const nonce = String(req.headers['wechatpay-nonce'] || '');
|
||
const signature = String(req.headers['wechatpay-signature'] || '');
|
||
const serial = String(req.headers['wechatpay-serial'] || '');
|
||
|
||
// 沙箱联调:无签名头时按模拟回调处理(仅非生产环境或显式放行)
|
||
if (!signature) {
|
||
if (cfg.effectiveMode === 'SANDBOX' && ALLOW_SANDBOX_PAY) {
|
||
const { outTradeNo, tradeState } = req.body || {};
|
||
if (outTradeNo && tradeState === 'SUCCESS') {
|
||
await repo.applyPaymentSuccess(String(outTradeNo));
|
||
}
|
||
return res.json({ code: 'SUCCESS', message: '成功' });
|
||
}
|
||
return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' });
|
||
}
|
||
|
||
if (!cfg.apiV3Key) {
|
||
return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' });
|
||
}
|
||
|
||
const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial });
|
||
if (!verified) {
|
||
return res.status(401).json({ code: 'FAIL', message: '微信支付回调验签失败' });
|
||
}
|
||
|
||
const payload: any = req.body || {};
|
||
const resource = payload.resource;
|
||
if (resource && resource.ciphertext) {
|
||
const plain = decryptNotifyResource(cfg.apiV3Key, resource);
|
||
if (plain && plain.trade_state === 'SUCCESS' && plain.out_trade_no) {
|
||
await repo.applyPaymentSuccess(String(plain.out_trade_no), {
|
||
transactionId: plain.transaction_id,
|
||
amountTotalCents: plain.amount ? Number(plain.amount.total) : undefined,
|
||
});
|
||
}
|
||
}
|
||
res.json({ code: 'SUCCESS', message: '成功' });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 'FAIL', message: err.message });
|
||
}
|
||
});
|
||
|
||
// 微信「商家转账到零钱」结果回调(验签 + 解密 + 幂等落库)
|
||
app.post('/api/pay/wechat/transfer-notify', async (req: Request, res: Response) => {
|
||
try {
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const rawBody = (req as any).rawBody || JSON.stringify(req.body || {});
|
||
const timestamp = String(req.headers['wechatpay-timestamp'] || '');
|
||
const nonce = String(req.headers['wechatpay-nonce'] || '');
|
||
const signature = String(req.headers['wechatpay-signature'] || '');
|
||
const serial = String(req.headers['wechatpay-serial'] || '');
|
||
if (!signature) {
|
||
return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' });
|
||
}
|
||
if (!cfg.apiV3Key) {
|
||
return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' });
|
||
}
|
||
const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial });
|
||
if (!verified) {
|
||
return res.status(401).json({ code: 'FAIL', message: '转账回调验签失败' });
|
||
}
|
||
const payload: any = req.body || {};
|
||
const resource = payload.resource;
|
||
if (resource && resource.ciphertext) {
|
||
const plain = decryptNotifyResource(cfg.apiV3Key, resource);
|
||
const outBillNo = plain && (plain.out_bill_no || plain.outBillNo);
|
||
const state = plain && (plain.state || plain.transfer_state);
|
||
if (outBillNo && state) {
|
||
const r: any = await repo.finalizeWithdrawalTransfer(String(outBillNo), String(state));
|
||
if (r && r.status === '已打款' && r.userId) {
|
||
pushUserNotification(Number(r.userId), 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(r.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records');
|
||
void notifyBySubscribe('withdrawSuccess', {
|
||
userId: Number(r.userId),
|
||
page: '/pages/user/withdraw-records',
|
||
data: {
|
||
amount2: Number(r.amount || 0).toFixed(2),
|
||
phrase5: '已到账',
|
||
time7: formatCnTime(),
|
||
amount8: Number(r.amount || 0).toFixed(2)
|
||
}
|
||
});
|
||
}
|
||
}
|
||
}
|
||
res.json({ code: 'SUCCESS', message: '成功' });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 'FAIL', message: err.message });
|
||
}
|
||
});
|
||
|
||
// 微信支付「退款」结果回调(验签 + 解密 + 幂等更新退款记录)
|
||
app.post('/api/pay/wechat/refund-notify', async (req: Request, res: Response) => {
|
||
try {
|
||
const cfg: any = await resolveWechatPayRuntimeConfig();
|
||
const rawBody = (req as any).rawBody || JSON.stringify(req.body || {});
|
||
const timestamp = String(req.headers['wechatpay-timestamp'] || '');
|
||
const nonce = String(req.headers['wechatpay-nonce'] || '');
|
||
const signature = String(req.headers['wechatpay-signature'] || '');
|
||
const serial = String(req.headers['wechatpay-serial'] || '');
|
||
if (!signature) {
|
||
return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' });
|
||
}
|
||
if (!cfg.apiV3Key) {
|
||
return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' });
|
||
}
|
||
const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial });
|
||
if (!verified) {
|
||
return res.status(401).json({ code: 'FAIL', message: '退款回调验签失败' });
|
||
}
|
||
const payload: any = req.body || {};
|
||
const resource = payload.resource;
|
||
if (resource && resource.ciphertext) {
|
||
const plain = decryptNotifyResource(cfg.apiV3Key, resource);
|
||
const outRefundNo = plain && (plain.out_refund_no || plain.outRefundNo);
|
||
const refundStatus = plain && (plain.refund_status || plain.status);
|
||
if (outRefundNo && refundStatus) {
|
||
const existing = await repo.getRefundByOutRefundNo(String(outRefundNo));
|
||
await repo.upsertRefundRecord({
|
||
outRefundNo: String(outRefundNo),
|
||
outTradeNo: String((plain && plain.out_trade_no) || (existing && existing.out_trade_no) || ''),
|
||
orderId: String((existing && existing.order_id) || ''),
|
||
refundId: String((plain && plain.refund_id) || (existing && existing.refund_id) || ''),
|
||
refundCents: Number((existing && existing.refund_cents) || 0),
|
||
totalCents: Number((existing && existing.total_cents) || 0),
|
||
status: String(refundStatus),
|
||
reason: String((existing && existing.reason) || ''),
|
||
});
|
||
}
|
||
}
|
||
res.json({ code: 'SUCCESS', message: '成功' });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 'FAIL', message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神拒单自动全额退款
|
||
app.post('/api/orders/:id/reject', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
const own = await repo.getOrderOwnership(orderId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '订单不存在' });
|
||
}
|
||
const role = resolveOrderActor(req, own, '拒单');
|
||
if (!role.isAdmin && !role.isCompanion) {
|
||
return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可拒单' });
|
||
}
|
||
const order = await repo.rejectOrder(orderId, req.body);
|
||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '订单已被拒单', `订单 ${orderId} 已被大神拒单,款项将原路退回。`, '/pages/order/list');
|
||
res.json({ code: 20000, data: order, message: '已拒单,订单款项已自动原路退还买家' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口)
|
||
app.post('/api/orders/:id/apply-complete', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
const own = await repo.getOrderOwnership(orderId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '订单不存在' });
|
||
}
|
||
const role = resolveOrderActor(req, own, '申请完成服务');
|
||
if (!role.isAdmin && !role.isCompanion) {
|
||
return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可申请完成服务' });
|
||
}
|
||
const order = await repo.applyCompleteOrder(orderId);
|
||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '大神申请完成服务', `订单 ${orderId} 大神已申请完成服务,请及时确认验收(48 小时未处理将自动确认)。`, '/pages/order/list');
|
||
res.json({ code: 20000, data: order, message: '已发起完成服务申请,等待买家确认验收(48小时自动确认)' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 买家确认完成验收 (待确认 -> 已完成,触发资金解冻结算)
|
||
app.post('/api/orders/:id/complete', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
const own = await repo.getOrderOwnership(orderId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '订单不存在' });
|
||
}
|
||
const role = resolveOrderActor(req, own, '确认完成订单');
|
||
if (!role.isAdmin && !role.isBuyer && !role.isCompanion) {
|
||
return res.status(403).json({ code: 40300, message: '仅交易双方或平台管理员可完成该订单' });
|
||
}
|
||
const order = await repo.updateOrderStatus(orderId, '已完成');
|
||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '订单已完成', `订单 ${orderId} 已完成,感谢使用,欢迎评价。`, '/pages/order/list');
|
||
const companionIds: number[] = Array.isArray(own?.companionIds) ? own.companionIds : [];
|
||
companionIds.forEach((cid) =>
|
||
pushUserNotification(Number(cid) || 0, 'ORDER_STATUS', '订单已完成结算', `订单 ${orderId} 已完成,佣金将按平台规则结算入账。`, '/pages/expert/workspace')
|
||
);
|
||
res.json({ code: 20000, data: order, message: '订单已确认完成' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 扫描处理超时订单 (15m未支付自动取消 / 30m未接单自动退款 / 48h待确认自动完成)
|
||
// 订单超时自动流转扫描 (自动取消/自动退款/自动完成结算):仅管理员可手动触发
|
||
app.post('/api/orders/process-timeouts', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const result = await repo.processOrderTimeouts();
|
||
res.json({ code: 20000, data: result, message: '订单超时自动流转扫描完毕' });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 申请退款/售后 (写入仲裁工单:退款理由 + 详细说明 + 凭证截图)
|
||
app.post('/api/orders/:id/refund', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
const own = await repo.getOrderOwnership(orderId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '订单不存在' });
|
||
}
|
||
const role = resolveOrderActor(req, own, '发起退款申诉');
|
||
if (!role.isAdmin && !role.isBuyer) {
|
||
return res.status(403).json({ code: 40300, message: '仅买家本人或平台管理员可发起退款申诉' });
|
||
}
|
||
const { reason, description, evidence } = req.body || {};
|
||
if (description) {
|
||
const sec = contentSecurityService.checkText(String(description));
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
}
|
||
const order = await repo.applyOrderRefund(orderId, { reason, description, evidence });
|
||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '退款申诉已受理', `订单 ${orderId} 的退款申诉已受理,进入争议仲裁流程。`, '/pages/user/reports');
|
||
res.json({ code: 20000, data: order, message: '退款申诉已受理,进入争议仲裁流程' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 举报不良陪玩 (涉黄/涉赌/线下欺诈等,进入平台风控处置池)
|
||
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) {
|
||
return res.status(400).json({ code: 40000, message: '举报对象与举报类型不能为空' });
|
||
}
|
||
if (description) {
|
||
const sec = contentSecurityService.checkText(String(description));
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
}
|
||
const report = await repo.createReport({
|
||
reporterId: resolveSecureUserId(req, Number(reporterId || req.headers['x-user-id'] || 0), '提交举报'),
|
||
targetType,
|
||
targetId: String(targetId),
|
||
targetName,
|
||
reason: String(reason),
|
||
description,
|
||
evidence
|
||
});
|
||
res.json({ code: 20000, data: report, message: '举报已提交,平台将在 24 小时内核实处理' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/reports', async (req: Request, res: Response) => {
|
||
try {
|
||
// 管理端可取全量;普通用户仅能查看自己提交的举报工单
|
||
if (isVerifiedAdminRequest(req)) {
|
||
const reporterId = req.query.reporterId ? Number(req.query.reporterId) : undefined;
|
||
return res.json({ code: 20000, data: await repo.getReports(reporterId) });
|
||
}
|
||
const uid = resolveSecureUserId(req, Number(req.query.reporterId || req.headers['x-user-id'] || 0), '查看我的举报');
|
||
const list = await repo.getReports(uid);
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理员处置不良违规举报工单
|
||
app.post('/api/reports/:id/handle', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { id } = req.params;
|
||
const { action, handleNote, penaltyOptions } = req.body || {};
|
||
if (!action || !['punish', 'reject'].includes(action)) {
|
||
return res.status(400).json({ code: 40000, message: '请指定合法的处理动作 (punish 或 reject)' });
|
||
}
|
||
const result = await repo.handleReport(String(id), action, handleNote, penaltyOptions);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: action === 'punish' ? '已核实举报并执行违规处置' : '已驳回该违规举报工单'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/arbitrations', async (_req: Request, res: Response) => {
|
||
try {
|
||
const arbitrations = await repo.getArbitrations();
|
||
res.json({
|
||
code: 20000,
|
||
data: arbitrations
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/arbitrations/:id/handle', async (req: Request, res: Response) => {
|
||
try {
|
||
const arbId = String(req.params.id);
|
||
const { decision, refundAmount, operatorRole } = req.body; // 'refund_full' | 'refund_partial' | 'reject' | 'deduct_credit'
|
||
const headerRole = req.headers['x-user-role'];
|
||
const callerRole = operatorRole || headerRole || 'admin';
|
||
if (callerRole === 'user' || callerRole === 'expert' || callerRole === 'PLAYER' || callerRole === 'EXPERT') {
|
||
return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行管理员仲裁' });
|
||
}
|
||
const result = await repo.handleArbitration(arbId, decision, refundAmount !== undefined ? Number(refundAmount) : undefined);
|
||
res.json({ code: 20000, data: result, message: `仲裁处理完毕: ${result.status}` });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 6. 财务与分销管理接口 (落盘 MySQL)
|
||
app.get('/api/finance/records', async (_req: Request, res: Response) => {
|
||
try {
|
||
const records = await repo.getFinanceRecords();
|
||
res.json({
|
||
code: 20000,
|
||
data: records
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/finance/withdrawals', async (_req: Request, res: Response) => {
|
||
try {
|
||
const withdrawals = await repo.getWithdrawals();
|
||
res.json({
|
||
code: 20000,
|
||
data: withdrawals
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Response) => {
|
||
try {
|
||
const wdId = String(req.params.id);
|
||
const { action, rejectReason } = req.body; // 'approve' | 'reject'
|
||
if (!['approve', 'reject'].includes(String(action))) {
|
||
return res.status(400).json({ code: 40000, message: 'action 必须为 approve 或 reject' });
|
||
}
|
||
|
||
// 驳回:退还预扣佣金
|
||
if (action === 'reject') {
|
||
const result = await repo.reviewWithdrawal(wdId, 'reject', rejectReason);
|
||
return res.json({ code: 20000, data: result, message: `提现驳回: ${rejectReason || '风控核验未通过'}` });
|
||
}
|
||
|
||
// 通过:按运行模式决定是否真实发起「微信商家转账到零钱」
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const wd = await repo.getWithdrawalForTransfer(wdId);
|
||
if (!wd) {
|
||
return res.status(404).json({ code: 40400, message: '提现工单不存在' });
|
||
}
|
||
if (wd.status !== '待审核') {
|
||
return res.status(409).json({ code: 40900, message: `当前提现工单已处理,状态为【${wd.status}】` });
|
||
}
|
||
|
||
const useRealTransfer = cfg.effectiveMode === 'LIVE' && cfg.isReadyForTransfer;
|
||
if (useRealTransfer) {
|
||
if (!wd.userOpenid) {
|
||
return res.status(400).json({ code: 40000, message: '该大神缺少微信 openid,无法商家转账到零钱;请其重新微信登录授权后再打款' });
|
||
}
|
||
const amountCents = Math.round(Number(wd.amount) * 100);
|
||
if (!amountCents || amountCents <= 0) {
|
||
return res.status(400).json({ code: 40000, message: '提现金额无效' });
|
||
}
|
||
const outBillNo = String(wd.withdraw_no || wd.id);
|
||
let bill: any;
|
||
try {
|
||
bill = await transferToWallet(cfg, {
|
||
outBillNo,
|
||
openid: String(wd.userOpenid),
|
||
amountCents,
|
||
sceneId: cfg.transferSceneId,
|
||
remark: cfg.transferRemark || '佣金提现',
|
||
notifyUrl: cfg.transferNotifyUrl || undefined,
|
||
userName: cfg.transferUserNameEnabled ? (wd.payee_name || undefined) : undefined,
|
||
sceneReportInfos: Array.isArray(cfg.transferSceneReportInfos) ? cfg.transferSceneReportInfos : undefined,
|
||
});
|
||
} catch (e: any) {
|
||
return res.status(400).json({ code: 40000, message: `微信商家转账失败:${e.message}` });
|
||
}
|
||
const state = String(bill.state || 'ACCEPTED');
|
||
const billNo = String(bill.out_bill_no || outBillNo);
|
||
if (state === 'SUCCESS') {
|
||
const r = await repo.markWithdrawalTransfer(wdId, billNo, '已打款', 'SUCCESS');
|
||
pushUserNotification(Number(wd.companion_id) || 0, 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records');
|
||
void notifyBySubscribe('withdrawSuccess', {
|
||
userId: Number(wd.companion_id) || 0,
|
||
page: '/pages/user/withdraw-records',
|
||
data: {
|
||
amount2: Number(wd.amount || 0).toFixed(2),
|
||
phrase5: '已到账',
|
||
time7: formatCnTime(),
|
||
amount8: Number(wd.amount || 0).toFixed(2)
|
||
}
|
||
});
|
||
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款成功,款项已进入大神微信零钱' });
|
||
}
|
||
const r = await repo.markWithdrawalTransfer(wdId, billNo, '处理中', state);
|
||
if (state === 'WAIT_USER_CONFIRM') {
|
||
pushUserNotification(
|
||
Number(wd.companion_id) || 0,
|
||
'WITHDRAW',
|
||
'提现待你确认收款',
|
||
`提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已发起微信转账,请在微信「服务通知」中确认收款(24 小时内有效,逾期自动失效)。`,
|
||
'/pages/user/withdraw-records'
|
||
);
|
||
return res.json({
|
||
code: 20000,
|
||
data: { ...r, transfer: bill },
|
||
message: '已发起微信转账,待收款人在微信「服务通知」中确认收款(24 小时内有效)'
|
||
});
|
||
}
|
||
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款已受理(微信处理中),到账以后端最终状态为准' });
|
||
}
|
||
|
||
// 未开启真实打款:禁止"只改状态、不发钱"(不依赖 NODE_ENV,避免线上以 development 运行时静默放行)。
|
||
if (!ALLOW_SANDBOX_PAY) {
|
||
return res.status(503).json({
|
||
code: 50001,
|
||
message: '微信商家转账未就绪:请在后台填写「转账场景ID」并确保商户号已开通"商家转账到零钱"产品'
|
||
});
|
||
}
|
||
const result = await repo.reviewWithdrawal(wdId, 'approve', rejectReason);
|
||
return res.json({ code: 20000, data: result, message: '(沙箱/联调)提现审核通过并标记为已打款' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/finance/config', async (_req: Request, res: Response) => {
|
||
try {
|
||
const config = await repo.getFinanceConfig();
|
||
res.json({
|
||
code: 20000,
|
||
data: config
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/finance/config', async (req: Request, res: Response) => {
|
||
try {
|
||
const config = await repo.updateFinanceConfig(req.body);
|
||
res.json({
|
||
code: 20000,
|
||
data: config,
|
||
message: '财务抽成与分销费率配置已更新并落盘存储'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7. 场所与风控接口 (落盘 MySQL)
|
||
app.get('/api/safety/places', async (_req: Request, res: Response) => {
|
||
try {
|
||
const places = await repo.getSafetyPlaces();
|
||
res.json({
|
||
code: 20000,
|
||
data: places
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/safety/places', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { name, area, category } = req.body;
|
||
if (!name || !area) {
|
||
return res.status(400).json({ code: 40000, message: '场所名称和商圈不能为空' });
|
||
}
|
||
const sec = contentSecurityService.checkText(`${name} ${area}`);
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
const place = await repo.createSafetyPlace(req.body);
|
||
res.json({
|
||
code: 20000,
|
||
data: place,
|
||
message: '合规场所准入录入成功并持久化'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 修改场所信息
|
||
app.put('/api/safety/places/:id', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const placeId = String(req.params.id);
|
||
const { name, area, category, status } = req.body;
|
||
if (name || area) {
|
||
const sec = contentSecurityService.checkText(`${name || ''} ${area || ''}`);
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
}
|
||
const updated = await repo.updateSafetyPlace(placeId, { name, area, category, status });
|
||
if (!updated) {
|
||
return res.status(404).json({ code: 40400, message: '场所不存在' });
|
||
}
|
||
res.json({ code: 20000, data: updated, message: '场所信息已更新' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台: 删除场所
|
||
app.delete('/api/safety/places/:id', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const placeId = String(req.params.id);
|
||
const ok = await repo.deleteSafetyPlace(placeId);
|
||
if (!ok) {
|
||
return res.status(404).json({ code: 40400, message: '场所不存在或已被移除' });
|
||
}
|
||
res.json({ code: 20000, data: null, message: '场所已移除' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/safety/audit-logs', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const logs = await repo.getSafetyAuditLogs();
|
||
res.json({
|
||
code: 20000,
|
||
data: logs
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.1 全平台用户管理接口 (真实读取与更新 MySQL)
|
||
app.get('/api/users', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { keyword, role, status } = req.query;
|
||
const users = await repo.getUsers({
|
||
keyword: keyword ? String(keyword) : undefined,
|
||
role: role ? String(role) : undefined,
|
||
status: status ? String(status) : undefined,
|
||
});
|
||
res.json({
|
||
code: 20000,
|
||
data: users
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理后台:人工调整用户钱包余额(运营/测试用,写入财务流水)
|
||
app.post('/api/admin/users/:id/balance', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
const amount = Number(req.body?.amount);
|
||
const direction = String(req.body?.direction || 'IN') === 'OUT' ? 'OUT' : 'IN';
|
||
const reason = String(req.body?.reason || '').trim().slice(0, 80);
|
||
|
||
if (!userId || !Number.isFinite(amount) || amount <= 0) {
|
||
return res.status(400).json({ code: 40000, message: '请填写有效的调整金额' });
|
||
}
|
||
|
||
const result = await repo.adjustUserBalance(userId, amount, direction as 'IN' | 'OUT', reason, '平台运营');
|
||
pushUserNotification(
|
||
userId,
|
||
'SYSTEM',
|
||
direction === 'IN' ? '余额已充值' : '余额已扣减',
|
||
`平台${direction === 'IN' ? '充值' : '扣减'} ¥${result.amount}${reason ? `(${reason})` : ''},当前余额 ¥${result.after}。`,
|
||
'/pages/user/recharge'
|
||
);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: direction === 'IN' ? `充值成功,当前余额 ¥${result.after}` : `扣减成功,当前余额 ¥${result.after}`
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message || '余额调整失败' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/users/:id/status', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
const { status } = req.body;
|
||
if (!status || !['正常', '已封禁'].includes(status)) {
|
||
return res.status(400).json({ code: 40000, message: '无效的状态参数' });
|
||
}
|
||
const updated = await repo.updateUserStatus(userId, status);
|
||
res.json({
|
||
code: 20000,
|
||
data: updated,
|
||
message: `用户状态已更新为: ${status}`
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/users/:id', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
if (!userId || isNaN(userId)) {
|
||
return res.status(400).json({ code: 40000, message: '无效的用户ID' });
|
||
}
|
||
const success = await repo.deleteUser(userId);
|
||
if (!success) {
|
||
return res.status(404).json({ code: 40400, message: '用户不存在或已被删除' });
|
||
}
|
||
res.json({
|
||
code: 20000,
|
||
data: { id: userId },
|
||
message: '用户档案及关联数据已成功彻底删除'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/users/batch-delete', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { ids } = req.body;
|
||
if (!Array.isArray(ids) || ids.length === 0) {
|
||
return res.status(400).json({ code: 40000, message: '请提供要删除的用户ID列表' });
|
||
}
|
||
const numIds = ids.map((id: any) => Number(id)).filter((id: number) => !isNaN(id) && id > 0);
|
||
const count = await repo.deleteUsers(numIds);
|
||
res.json({
|
||
code: 20000,
|
||
data: { count },
|
||
message: `已成功批量删除 ${count} 位用户`
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.2 用户360度全景综合数据看板 (涵盖活动/核销码/订单/财务流水/风控评级)
|
||
app.get('/api/users/:id/detail', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
const detail = await repo.getUserDetail(userId);
|
||
if (!detail) {
|
||
return res.status(404).json({ code: 40400, message: '用户不存在' });
|
||
}
|
||
res.json({
|
||
code: 20000,
|
||
data: detail
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.2.1 移动端用户资产与基本信息
|
||
app.get('/api/users/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
let user = await repo.getUserById(userId);
|
||
if (!user) {
|
||
return res.status(404).json({ code: 40400, message: '用户未找到' });
|
||
}
|
||
// 敏感字段(手机号 / 余额 / 佣金 / 邀请关系)仅本人或管理员可见
|
||
const isAdmin = isVerifiedAdminRequest(req);
|
||
let isSelf = false;
|
||
if (!isAdmin) {
|
||
if (!IS_PRODUCTION && ALLOW_INSECURE_DEV_AUTH) {
|
||
// 本地联调兼容:仅在显式开启 ALLOW_INSECURE_DEV_AUTH=1 时按本人视角返回
|
||
isSelf = true;
|
||
} else {
|
||
try {
|
||
const caller = resolveSecureUserId(req, 0, '查看用户信息');
|
||
isSelf = caller > 0 && (caller === Number(user.id) || caller === userId);
|
||
} catch {
|
||
isSelf = false;
|
||
}
|
||
}
|
||
}
|
||
if (isAdmin || isSelf) {
|
||
return res.json({ code: 20000, data: user });
|
||
}
|
||
const publicUser = {
|
||
id: user.id,
|
||
userNo: user.userNo,
|
||
nickname: user.nickname,
|
||
avatar: user.avatar,
|
||
gender: user.gender,
|
||
signature: user.signature,
|
||
role: user.role,
|
||
roleFlags: user.roleFlags,
|
||
isExpert: user.isExpert,
|
||
orderCount: user.orderCount,
|
||
activityCount: user.activityCount,
|
||
companionId: user.companionId,
|
||
companionStatus: user.companionStatus,
|
||
auditStatus: user.auditStatus,
|
||
status: user.status,
|
||
};
|
||
res.json({ code: 20000, data: publicUser });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.2.1 个人资料修改 (真实修改 users 表 nickname, avatar, phone, gender, signature)
|
||
app.post('/api/users/:id/profile', async (req: Request, res: Response) => {
|
||
try {
|
||
// 资料修改身份以服务端会话 token 为准,生产环境必须登录,防止越权修改他人资料
|
||
const userId = resolveSecureUserId(req, Number(req.params.id), '修改个人资料');
|
||
const { nickname, avatar, phone, gender, signature } = req.body;
|
||
const textToCheck = `${nickname || ''} ${signature || ''}`.trim();
|
||
if (textToCheck) {
|
||
const sec = contentSecurityService.checkText(textToCheck);
|
||
if (!sec.pass) {
|
||
return res.status(400).json({ code: sec.code, message: sec.message });
|
||
}
|
||
}
|
||
|
||
// 联系方式(11位手机号/微信号/脱敏掩码)合法性格式校验
|
||
if (phone !== undefined && phone !== null && String(phone).trim() !== '') {
|
||
const cleanPhone = String(phone).trim();
|
||
const isMobile = /^1[3-9]\d{9}$/.test(cleanPhone);
|
||
const isWechat = /^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/.test(cleanPhone);
|
||
const isMaskedMobile = /^1[3-9]\d\*{4}\d{4}$/.test(cleanPhone);
|
||
if (!isMobile && !isWechat && !isMaskedMobile) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: '联系方式格式不正确,请输入有效的11位手机号或微信号'
|
||
});
|
||
}
|
||
}
|
||
|
||
const updated = await repo.updateUserProfile(userId, { nickname, avatar, phone, gender, signature });
|
||
res.json({
|
||
code: 20000,
|
||
data: updated,
|
||
message: '个人信息修改成功'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.2.1.b 用户隐私安全与通知订阅偏好持久化接口
|
||
// 用户设置文件存放于对外不可见的私有目录,避免被 /uploads 静态服务公开读取
|
||
const userSettingsDir = path.resolve(__dirname, '../data');
|
||
if (!fs.existsSync(userSettingsDir)) {
|
||
try {
|
||
fs.mkdirSync(userSettingsDir, { recursive: true });
|
||
} catch (_) {
|
||
// 目录创建失败时降级为读取空设置
|
||
}
|
||
}
|
||
const userSettingsFilePath = path.resolve(userSettingsDir, 'user_settings.json');
|
||
function readAllUserSettings(): Record<string, { privacyHidePhone: boolean; noticeSubscribe: boolean; updatedAt: string }> {
|
||
try {
|
||
if (fs.existsSync(userSettingsFilePath)) {
|
||
const raw = fs.readFileSync(userSettingsFilePath, 'utf-8');
|
||
return JSON.parse(raw) || {};
|
||
}
|
||
} catch (e) {
|
||
logServerError('userSettings.read', e);
|
||
}
|
||
return {};
|
||
}
|
||
function writeAllUserSettings(map: Record<string, { privacyHidePhone: boolean; noticeSubscribe: boolean; updatedAt: string }>) {
|
||
try {
|
||
fs.writeFileSync(userSettingsFilePath, JSON.stringify(map, null, 2), 'utf-8');
|
||
} catch (e) {
|
||
logServerError('userSettings.write', e);
|
||
}
|
||
}
|
||
|
||
app.get('/api/users/:id/settings', (req: Request, res: Response) => {
|
||
try {
|
||
const userId = String(resolveSecureUserId(req, Number(req.params.id), '查看个人设置'));
|
||
const all = readAllUserSettings();
|
||
const current = all[userId] || {
|
||
privacyHidePhone: false,
|
||
noticeSubscribe: true,
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
userId: Number(userId),
|
||
...current
|
||
}
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/users/:id/settings', (req: Request, res: Response) => {
|
||
try {
|
||
const userId = String(resolveSecureUserId(req, Number(req.params.id), '保存个人设置'));
|
||
const all = readAllUserSettings();
|
||
const prev = all[userId] || {
|
||
privacyHidePhone: false,
|
||
noticeSubscribe: true,
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
const next = {
|
||
privacyHidePhone: typeof req.body?.privacyHidePhone === 'boolean' ? req.body.privacyHidePhone : prev.privacyHidePhone,
|
||
noticeSubscribe: typeof req.body?.noticeSubscribe === 'boolean' ? req.body.noticeSubscribe : prev.noticeSubscribe,
|
||
updatedAt: new Date().toISOString()
|
||
};
|
||
all[userId] = next;
|
||
writeAllUserSettings(all);
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
userId: Number(userId),
|
||
...next
|
||
},
|
||
message: '设置已同步保存'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
|
||
// 7.2.2 钱包充值接口 (真实落盘 balance 与 finance_records)
|
||
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), '充值余额');
|
||
await assertUserNotBanned(userId, '充值余额');
|
||
|
||
const { amount, outTradeNo } = req.body;
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
|
||
// 生产 LIVE:仅对已完成的微信支付订单入账(主动查单二次确认 + 幂等),杜绝前端直接加钱
|
||
if (cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive) {
|
||
const targetOutTradeNo = outTradeNo
|
||
? String(outTradeNo)
|
||
: (await repo.getLatestRechargeIntent(userId))?.out_trade_no || '';
|
||
const paid = await confirmWechatPayment(cfg, targetOutTradeNo);
|
||
if (!paid) {
|
||
return res.status(400).json({ code: 40000, message: '微信支付尚未完成,无法入账' });
|
||
}
|
||
const result = await repo.reconcileRecharge(userId, targetOutTradeNo);
|
||
return res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: `充值成功 ¥${Number(result.totalCredit || result.rechargedAmount || 0).toFixed(2)},余额已更新`,
|
||
});
|
||
}
|
||
|
||
// 沙箱联调:仅非生产环境(或显式 ALLOW_SANDBOX_PAY=1)保留直接入账
|
||
if (!ALLOW_SANDBOX_PAY) {
|
||
return res.status(503).json({ code: 50001, message: '微信支付未开通,暂不支持充值' });
|
||
}
|
||
const amountYuan = Number(amount);
|
||
// 赠金按平台阶梯服务端计算,忽略客户端传入的 bonusAmount
|
||
const bonusCents = await repo.computeRechargeBonusCents(Math.round(amountYuan * 100));
|
||
const result = await (repo as any).rechargeUserBalance(userId, amountYuan, bonusCents / 100);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: `成功充值 ¥${Number(amount).toFixed(2)},余额已更新`
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.2.3 佣金提现申请接口 (真实扣减 commission 并落盘 withdrawals 与 finance_records)
|
||
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 为准,生产环境必须登录,严禁以他人身份提现
|
||
const userId = resolveSecureUserId(req, pathUserId, '申请提现');
|
||
if (!userId) {
|
||
return res.status(400).json({ code: 40000, message: '缺少提现用户身份' });
|
||
}
|
||
await assertUserNotBanned(userId, '申请提现');
|
||
|
||
// 单笔上限:受微信商家转账额度约束(商户平台可调),默认 200 元,避免审核时被微信拒绝
|
||
const financeCfg = await repo.getPlatformConfigByKey('finance_rules');
|
||
const financeRules = (financeCfg && financeCfg.valueJson) || {};
|
||
const maxWithdraw = Number(financeRules.maxWithdrawAmount) || 200;
|
||
const requestAmount = Number(req.body?.amount);
|
||
if (Number.isFinite(requestAmount) && requestAmount > maxWithdraw) {
|
||
return res.status(400).json({
|
||
code: 40000,
|
||
message: `单笔提现不能超过 ¥${maxWithdraw}(微信商家转账单笔限额),请分多笔提现或联系平台调整额度`
|
||
});
|
||
}
|
||
|
||
const { amount, channel, account, realName, bankName } = req.body;
|
||
const result = await repo.withdrawUserCommission(userId, Number(amount), channel, { account, realName, bankName });
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: '提现申请已提交至后台审核,将在1个工作日内到账'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 用户提现记录列表接口 (状态跟踪:待审核、处理中、已打款、已驳回)
|
||
app.get('/api/users/:id/withdrawals', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(req, Number(req.params.id), '查看提现记录');
|
||
const list = await repo.getUserWithdrawals(userId);
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.2.4 用户个人财务流水列表
|
||
app.get('/api/users/:id/finance-records', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(req, Number(req.params.id), '查看财务流水');
|
||
const list = await repo.getUserFinanceRecords(userId);
|
||
res.json({ code: 20000, data: list });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
|
||
// 7.3 友圈广场动态接口 (落盘 MySQL)
|
||
app.get('/api/posts', async (req: Request, res: Response) => {
|
||
try {
|
||
const category = req.query.category ? String(req.query.category) : undefined;
|
||
// 身份优先取服务端会话,客户端声明仅作匿名浏览时的兼容
|
||
const tokenUid = resolveRequestUserId(req);
|
||
const userId = tokenUid || Number(req.query.userId || req.headers['x-user-id']) || 1001;
|
||
const posts = await repo.getPosts(category, undefined, '正常', userId);
|
||
res.json({
|
||
code: 20000,
|
||
data: posts
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理端:全量动态管理与检索
|
||
app.get('/api/admin/posts', async (req: Request, res: Response) => {
|
||
try {
|
||
const category = req.query.category ? String(req.query.category) : undefined;
|
||
const keyword = req.query.keyword ? String(req.query.keyword) : undefined;
|
||
const status = req.query.status ? String(req.query.status) : undefined;
|
||
const posts = await repo.getPosts(category, keyword, status);
|
||
res.json({ code: 20000, data: posts });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理端:切换置顶状态
|
||
app.post('/api/admin/posts/:id/top', async (req: Request, res: Response) => {
|
||
try {
|
||
const postId = String(req.params.id);
|
||
const { isTop } = req.body;
|
||
await repo.togglePostTop(postId, Boolean(isTop));
|
||
res.json({ code: 20000, message: isTop ? '置顶成功' : '已取消置顶' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理端:切换显示/隐藏状态
|
||
app.post('/api/admin/posts/:id/status', async (req: Request, res: Response) => {
|
||
try {
|
||
const postId = String(req.params.id);
|
||
const { status } = req.body;
|
||
await repo.updatePostStatus(postId, String(status));
|
||
res.json({ code: 20000, message: '动态状态更新成功' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理端:违规下架彻底删除
|
||
app.delete('/api/admin/posts/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const postId = String(req.params.id);
|
||
await repo.adminDeletePost(postId);
|
||
res.json({ code: 20000, message: '违规动态及关联评论已彻底删除' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理端:删除单条不当评论
|
||
app.delete('/api/admin/posts/comments/:commentId', async (req: Request, res: Response) => {
|
||
try {
|
||
const commentId = String(req.params.commentId);
|
||
await repo.adminDeletePostComment(commentId);
|
||
res.json({ code: 20000, message: '违规评论已删除' });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 微信 GET 接口(返回 JSON)
|
||
function wechatHttpGetJson(url: string): Promise<any> {
|
||
return new Promise((resolve, reject) => {
|
||
https
|
||
.get(url, (resp) => {
|
||
let data = '';
|
||
resp.on('data', (chunk) => {
|
||
data += chunk;
|
||
});
|
||
resp.on('end', () => {
|
||
try {
|
||
resolve(JSON.parse(data));
|
||
} catch (e) {
|
||
reject(e);
|
||
}
|
||
});
|
||
})
|
||
.on('error', reject);
|
||
});
|
||
}
|
||
|
||
/** 脱敏展示 appId(用于自检,避免完整明文) */
|
||
function maskTail(s: string): string {
|
||
const v = String(s || '');
|
||
return v ? `${v.slice(0, 6)}****${v.slice(-4)}` : '';
|
||
}
|
||
|
||
/**
|
||
* 规范化微信模板ID:微信模板ID只由 [A-Za-z0-9_-] 组成。
|
||
* 粘贴时极易混入零宽空格(U+200B)、全角字符、换行等不可见字符——trim() 无法清除零宽字符,
|
||
* 会导致"看起来一样却比对不上",并且**原样发给微信会直接 40037**。
|
||
*/
|
||
function normalizeTemplateId(v: any): string {
|
||
return String(v == null ? '' : v).replace(/[^A-Za-z0-9_-]/g, '');
|
||
}
|
||
|
||
// 微信订阅消息错误码中文化,便于后台定位真实原因
|
||
function describeWechatSubscribeError(r: any): string {
|
||
const code = Number(r && r.errcode) || 0;
|
||
const dict: Record<number, string> = {
|
||
40003: 'touser 不是有效 openid',
|
||
40037: '模板ID无效(请在微信公众平台核对模板ID)',
|
||
41030: 'page 路径不存在',
|
||
43101: '用户未授权该模板订阅(用户需在小程序内授权;一次性订阅每条仅可下发 1 次)',
|
||
45009: '接口调用频率超限(access_token 复用不足或调用过快)',
|
||
40001: 'access_token 无效(appId 与 appSecret 不匹配)',
|
||
40125: 'appSecret 无效',
|
||
47003: '模板参数不合法(字段名或长度与模板不一致)',
|
||
};
|
||
const extra = dict[code] ? `(${dict[code]})` : '';
|
||
return `微信订阅消息下发失败: [${code}] ${(r && r.errmsg) || '未知错误'}${extra}`;
|
||
}
|
||
|
||
// 管理端:微信订阅消息(服务通知)真实测试推送接口(模板ID/目标用户 openid/小程序凭证就绪时真实下发,否则返回明确原因)
|
||
app.post('/api/admin/message/test-send', async (req: Request, res: Response) => {
|
||
try {
|
||
const { templateType, targetUserId, previewData, data, page } = req.body || {};
|
||
const typeKeyMap: Record<string, string> = {
|
||
order_status: 'orderStatus',
|
||
activity_join: 'activityJoin',
|
||
withdraw_success: 'withdrawSuccess',
|
||
verify_code: 'verifyCode',
|
||
};
|
||
const tKey = typeKeyMap[String(templateType)] || 'orderStatus';
|
||
const rulesRecord = await repo.getPlatformConfigByKey('message_template_rules');
|
||
const rules = (rulesRecord && rulesRecord.valueJson) || {};
|
||
const idMap: Record<string, string> = {
|
||
orderStatus: 'orderStatusTemplateId',
|
||
activityJoin: 'activityJoinTemplateId',
|
||
withdrawSuccess: 'withdrawSuccessTemplateId',
|
||
verifyCode: 'verifyCodeTemplateId',
|
||
};
|
||
const rawTemplateId = String(rules[idMap[tKey]] || '');
|
||
// 规范化后判断:过滤占位符 tmpl_xxx,并剔除不可见字符
|
||
const normalizedTemplateId = normalizeTemplateId(rawTemplateId);
|
||
const isPlaceholder = /^tmpl_/i.test(normalizedTemplateId);
|
||
const templateId = isPlaceholder ? '' : normalizedTemplateId;
|
||
const enablePush = Number(rules.enablePush) === 1;
|
||
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(targetUserId) || 0]);
|
||
const openid = uRows && uRows[0] && uRows[0].wechat_openid;
|
||
|
||
const diagnostics = {
|
||
enablePush,
|
||
hasTemplateId: Boolean(templateId),
|
||
hasOpenid: Boolean(openid),
|
||
hasAppId: Boolean(cfg.appId),
|
||
hasAppSecret: Boolean(cfg.appSecret),
|
||
appIdMasked: maskTail(cfg.appId),
|
||
};
|
||
|
||
if (templateId && openid && cfg.appId && cfg.appSecret) {
|
||
let accessToken = '';
|
||
try {
|
||
accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret);
|
||
} catch (e: any) {
|
||
return res.json({
|
||
code: 40000,
|
||
data: { mode: 'REAL_FAILED', templateType: tKey, templateId, targetUserId, diagnostics, error: 'ACCESS_TOKEN_FAILED' },
|
||
message: `获取微信 access_token 失败:${(e && e.message) || e}(请核对小程序 appId/appSecret)`
|
||
});
|
||
}
|
||
// 与所选模板字段对齐的示例数据(字段名必须与模板一致,否则 47003)
|
||
const sampleData: Record<string, Record<string, string>> = {
|
||
orderStatus: { character_string1: 'ORD20261017001', phrase2: '已开始服务', thing3: '同城陪伴服务', thing4: '核销码8392,请出示' },
|
||
activityJoin: { thing1: '同城剧本杀局', time2: formatCnTime(), time3: formatCnTime(), thing4: '朝阳区三里屯商圈' },
|
||
withdrawSuccess: { amount2: '50.00', phrase5: '已到账', time7: formatCnTime(), amount8: '50.00' },
|
||
verifyCode: { character_string1: '8392', thing2: '请向大神出示4位核销码' }
|
||
};
|
||
const payloadData = data && typeof data === 'object' && Object.keys(data).length > 0 ? data : (sampleData[tKey] || {});
|
||
const result = await sendSubscribeMessageHttp(accessToken, {
|
||
touser: String(openid),
|
||
template_id: templateId,
|
||
page,
|
||
data: buildTemplateData(payloadData),
|
||
miniprogram_state: MINIPROGRAM_STATE,
|
||
});
|
||
const ok = !result || !result.errcode || result.errcode === 0;
|
||
return res.json({
|
||
code: ok ? 20000 : 40000,
|
||
data: {
|
||
mode: ok ? 'REAL' : 'REAL_FAILED',
|
||
templateType: tKey,
|
||
templateId,
|
||
targetUserId,
|
||
diagnostics,
|
||
result,
|
||
status: ok ? 'REAL' : 'REAL_FAILED',
|
||
wechatMsgId: `wx_msg_${Date.now()}`,
|
||
sendTime: new Date().toISOString(),
|
||
previewData: previewData || {}
|
||
},
|
||
message: ok ? '微信订阅消息已真实下发' : describeWechatSubscribeError(result)
|
||
});
|
||
}
|
||
|
||
// 未就绪:返回模拟结果并给出明确原因(后台据此提示,避免被误解为"已发送成功")
|
||
const typeLabels: Record<string, string> = {
|
||
orderStatus: '订单履约状态变动',
|
||
activityJoin: '组局搭子报名与成局',
|
||
withdrawSuccess: '大神佣金提现打款到账',
|
||
verifyCode: '现场4位核销码签到提醒',
|
||
};
|
||
const rawDisplay = String(rawTemplateId).trim();
|
||
const reasonDetail = !rawDisplay
|
||
? ''
|
||
: (isPlaceholder
|
||
? `(当前值为占位符 ${normalizedTemplateId})`
|
||
: `(当前值 ${rawDisplay.slice(0, 64)} 不是有效的微信模板ID)`);
|
||
const reason = !enablePush
|
||
? '订阅消息总开关未开启'
|
||
: (!templateId
|
||
? `未配置「${typeLabels[tKey] || tKey}」模板ID${reasonDetail}${tKey === 'verifyCode' ? '(该类型为可选:核销码已随「订单状态提醒」的温馨提示下发)' : ''}`
|
||
: (!openid
|
||
? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)`
|
||
: '小程序 appId/appSecret 未配置'));
|
||
const result = {
|
||
templateType: tKey,
|
||
targetUserId: targetUserId || 1001,
|
||
sendTime: new Date().toISOString(),
|
||
status: 'SIMULATED',
|
||
wechatMsgId: `wx_msg_${Date.now()}`,
|
||
previewData: previewData || { title: '订单履约提醒', detail: '您的同城生活陪伴订单已开始服务' }
|
||
};
|
||
res.json({ code: 20000, data: { mode: 'SIMULATED', reason, diagnostics, ...result }, message: `未真实下发(${reason})` });
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理端:拉取当前小程序已存在的「订阅消息」模板列表
|
||
// 用于核对后台填写的模板ID是否属于当前 appId(40037 invalid template_id 的根因排查)
|
||
app.get('/api/admin/message/templates', async (_req: Request, res: Response) => {
|
||
try {
|
||
const cfg = await resolveWechatPayRuntimeConfig();
|
||
if (!cfg.appId || !cfg.appSecret) {
|
||
return res.json({
|
||
code: 40000,
|
||
message: '小程序 AppID/AppSecret 未配置:请在后台「平台配置 → 微信支付商户配置」填写本小程序的 AppID 与 32 位 AppSecret(或设置环境变量 WX_APP_ID/WX_APP_SECRET),保存后重试',
|
||
});
|
||
}
|
||
const accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret);
|
||
const data = await wechatHttpGetJson(
|
||
`https://api.weixin.qq.com/wxaapi/newtmpl/gettemplate?access_token=${encodeURIComponent(accessToken)}`
|
||
);
|
||
const list = data && Array.isArray(data.data) ? data.data : [];
|
||
const record = await repo.getPlatformConfigByKey('message_template_rules');
|
||
const rules = (record && record.valueJson) || {};
|
||
const clean = (v: any) => normalizeTemplateId(v);
|
||
const configured = {
|
||
orderStatus: clean(rules.orderStatusTemplateId),
|
||
activityJoin: clean(rules.activityJoinTemplateId),
|
||
withdrawSuccess: clean(rules.withdrawSuccessTemplateId),
|
||
verifyCode: clean(rules.verifyCodeTemplateId),
|
||
};
|
||
// 以服务端为准做匹配:两侧均按微信ID字符集规范化,避免不可见字符导致的误报
|
||
const existing = new Set(list.map((t: any) => clean(t.priTmplId)));
|
||
const mismatched = Object.entries(configured)
|
||
.filter(([, id]) => Boolean(id) && !existing.has(id))
|
||
.map(([key, id]) => ({ key, templateId: id, length: id.length }));
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
appIdMasked: maskTail(cfg.appId),
|
||
errcode: Number(data && data.errcode) || 0,
|
||
errmsg: (data && data.errmsg) || 'ok',
|
||
count: list.length,
|
||
templates: list.map((t: any) => ({
|
||
templateId: clean(t.priTmplId),
|
||
title: t.title,
|
||
type: t.type,
|
||
content: t.content,
|
||
example: t.example,
|
||
})),
|
||
configured,
|
||
mismatched,
|
||
},
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
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), '发布动态');
|
||
await assertUserNotBanned(uid, '发布动态');
|
||
|
||
// 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层)
|
||
const openid = await resolveUserOpenid(uid);
|
||
const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2);
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
|
||
const post = await repo.createPost({ ...(req.body || {}), userId: uid });
|
||
res.json({
|
||
code: 20000,
|
||
data: post,
|
||
message: '动态发布成功并已写入MySQL'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
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,
|
||
Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0),
|
||
'点赞动态'
|
||
);
|
||
const action = req.body?.action as 'like' | 'unlike' | undefined;
|
||
const result = await repo.likePost(String(req.params.id), userId, action);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: result.liked ? '点赞成功' : '已取消点赞'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
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({
|
||
code: 20000,
|
||
data: result,
|
||
message: '动态浏览量已累加'
|
||
});
|
||
} catch (err: any) {
|
||
res.status(400).json({ code: 40000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.4 订单评价与评分接口 (落盘 MySQL order_reviews)
|
||
app.post('/api/orders/:id/review', async (req: Request, res: Response) => {
|
||
try {
|
||
const orderId = String(req.params.id);
|
||
const { rating, tags, content, userId, userNickname, userAvatar, expertId } = req.body;
|
||
// 身份以服务端会话为准,杜绝冒充买家提交订单评价
|
||
const reviewerId = resolveSecureUserId(
|
||
req,
|
||
Number(userId || req.headers['x-user-id'] || 0),
|
||
'提交订单评价'
|
||
);
|
||
// 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层)
|
||
const openid = await resolveUserOpenid(reviewerId);
|
||
const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2);
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
|
||
const review = await repo.createOrderReview({
|
||
orderId,
|
||
expertId: expertId ? Number(expertId) : undefined,
|
||
userId: reviewerId,
|
||
userNickname,
|
||
userAvatar,
|
||
rating: Number(rating) || 5,
|
||
tags,
|
||
content
|
||
});
|
||
res.json({
|
||
code: 20000,
|
||
data: review,
|
||
message: '评价提交成功并已落盘入库'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.5 大神历史真实评价列表接口
|
||
app.get('/api/companions/:id/reviews', async (req: Request, res: Response) => {
|
||
try {
|
||
const expertId = Number(req.params.id);
|
||
const reviews = await repo.getOrderReviews(expertId);
|
||
res.json({
|
||
code: 20000,
|
||
data: reviews
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神感谢回复买家评价接口
|
||
app.post('/api/reviews/:id/reply', async (req: Request, res: Response) => {
|
||
try {
|
||
const reviewId = String(req.params.id);
|
||
const { replyContent } = req.body || {};
|
||
if (!replyContent || !replyContent.trim()) {
|
||
return res.status(400).json({ code: 40000, message: '回复内容不能为空' });
|
||
}
|
||
// 身份校验:仅被评价的大神本人或管理员可回复,杜绝任意用户冒充大神回复
|
||
const own = await repo.getReviewOwnership(reviewId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '评价不存在' });
|
||
}
|
||
assertCompanionActor(req, own, '回复评价');
|
||
const secCheck = contentSecurityService.checkText(replyContent);
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
const result = await repo.replyOrderReview(reviewId, replyContent.trim());
|
||
res.json({ code: 20000, data: result, message: '回复感谢老板成功' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理员全量查询评价与回复列表
|
||
// 管理员全量查询评价与回复列表 (仅服务端签名管理员 token 可执行)
|
||
app.get('/api/reviews', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const expertId = req.query.expertId ? Number(req.query.expertId) : undefined;
|
||
const orderId = req.query.orderId ? String(req.query.orderId) : undefined;
|
||
const reviews = await repo.getOrderReviews(expertId, orderId);
|
||
res.json({
|
||
code: 20000,
|
||
data: reviews
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 管理员违规下架/删除评价 (仅服务端签名管理员 token 可执行)
|
||
app.delete('/api/reviews/:id', requireAdmin, async (req: Request, res: Response) => {
|
||
try {
|
||
const { id } = req.params;
|
||
await repo.deleteReview(String(id));
|
||
res.json({ code: 20000, message: '违规评价已成功删除,大神信誉评分已重新计算' });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 友圈本人动态删除接口 (服务端强校验:仅作者本人可删除)
|
||
app.delete('/api/posts/:id', async (req: Request, res: Response) => {
|
||
try {
|
||
const postId = String(req.params.id);
|
||
const uid = resolveSecureUserId(
|
||
req,
|
||
Number(req.query.userId || req.body?.userId || req.headers['x-user-id'] || 0),
|
||
'删除动态'
|
||
);
|
||
await assertUserNotBanned(uid, '删除动态');
|
||
const ok = await repo.deletePost(postId, uid);
|
||
if (!ok) {
|
||
return res.status(404).json({ code: 40400, message: '动态不存在或已被删除' });
|
||
}
|
||
res.json({ code: 20000, message: '动态删除成功' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神服务包管理接口 (获取与上下架切换)
|
||
app.get('/api/companions/:id/services', async (req: Request, res: Response) => {
|
||
try {
|
||
const expertId = Number(req.params.id);
|
||
const services = await repo.getExpertServices(expertId);
|
||
res.json({ code: 20000, data: services });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.put('/api/companions/:id/services/:serviceId/status', async (req: Request, res: Response) => {
|
||
try {
|
||
const serviceId = String(req.params.serviceId);
|
||
const { status } = req.body;
|
||
// 身份校验:仅该服务归属的大神本人或管理员可上下架,杜绝越权操作他人服务
|
||
const own = await repo.getExpertServiceOwnership(serviceId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '服务不存在' });
|
||
}
|
||
assertCompanionActor(req, own, '调整服务上下架');
|
||
const result = await repo.updateExpertServiceStatus(serviceId, Boolean(status));
|
||
res.json({ code: 20000, data: result, message: '服务状态更新成功' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神工作台:读取当前登录用户的大神档案(用于自助管理服务包/收益)
|
||
app.get('/api/expert/me', async (req: Request, res: Response) => {
|
||
try {
|
||
const uid = resolveSecureUserId(
|
||
req,
|
||
Number(req.query.userId || req.headers['x-user-id'] || 0),
|
||
'查看大神工作台'
|
||
);
|
||
const comp = await repo.getCompanionByUserId(uid);
|
||
if (!comp) {
|
||
return res.json({ code: 20000, data: null, message: '尚未入驻大神' });
|
||
}
|
||
res.json({
|
||
code: 20000,
|
||
data: {
|
||
id: Number(comp.id),
|
||
name: comp.name,
|
||
avatar: comp.avatar,
|
||
track: comp.track,
|
||
hourlyRate: Number(comp.hourly_rate) || 0,
|
||
voiceIntroUrl: comp.voice_intro_url || '',
|
||
voiceDuration: Number(comp.voice_duration) || 0,
|
||
voiceTitle: comp.voice_title || '',
|
||
status: comp.status,
|
||
auditStatus: comp.audit_status,
|
||
isCompanion: true
|
||
}
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// ===== 大神语音介绍(录音试听)上传 =====
|
||
const VOICE_ALLOWED_MIME = [
|
||
'audio/mpeg', 'audio/mp3', 'audio/mp4', 'audio/x-m4a', 'audio/aac', 'audio/wav', 'audio/x-wav', 'audio/ogg', 'audio/webm',
|
||
];
|
||
const voiceStorage = multer.diskStorage({
|
||
destination: (_req, _file, cb) => cb(null, uploadDir),
|
||
filename: (_req, file, cb) => {
|
||
const ext = path.extname(file.originalname).toLowerCase();
|
||
const allowedExt = ['.mp3', '.m4a', '.aac', '.wav', '.ogg', '.webm'];
|
||
const safeExt = allowedExt.includes(ext) ? ext : '.mp3';
|
||
cb(null, `voice_${Date.now()}_${Math.round(Math.random() * 1e9)}${safeExt}`);
|
||
},
|
||
});
|
||
const uploadVoice = multer({
|
||
storage: voiceStorage,
|
||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||
fileFilter: (_req, file, cb) => {
|
||
if (VOICE_ALLOWED_MIME.includes(String(file.mimetype).toLowerCase())) return cb(null, true);
|
||
cb(new Error('仅支持 mp3 / m4a / aac / wav / ogg 格式的语音文件'));
|
||
},
|
||
});
|
||
|
||
app.post(
|
||
'/api/companions/:id/voice',
|
||
rateLimit({ name: 'voice_upload', windowMs: 60 * 1000, max: 10, key: rateWriteKey }),
|
||
(req: Request, res: Response) => {
|
||
uploadVoice.single('file')(req, res, async (err: any) => {
|
||
const cleanup = () => {
|
||
try { if (req.file && req.file.path) fs.unlinkSync(req.file.path); } catch (_) {}
|
||
};
|
||
try {
|
||
if (err) {
|
||
return res.status(400).json({ code: 40000, message: err.message || '语音上传失败' });
|
||
}
|
||
const companionId = Number(req.params.id);
|
||
const own = await repo.getCompanionOwnership(companionId);
|
||
if (!own) {
|
||
cleanup();
|
||
return res.status(404).json({ code: 40400, message: '大神不存在' });
|
||
}
|
||
assertCompanionActor(req, own, '上传语音介绍');
|
||
if (!req.file) {
|
||
return res.status(400).json({ code: 40000, message: '未接收到有效语音文件,请使用 file 字段提交' });
|
||
}
|
||
const duration = Math.min(60, Math.max(1, Math.round(Number(req.body?.duration) || 15)));
|
||
const voiceIntroUrl = `/uploads/${req.file.filename}`;
|
||
const updated = await repo.updateCompanionVoice(companionId, voiceIntroUrl, duration);
|
||
res.json({
|
||
code: 20000,
|
||
data: { voiceIntroUrl, voiceDuration: duration, companion: updated },
|
||
message: '语音介绍已更新',
|
||
});
|
||
} catch (e: any) {
|
||
cleanup();
|
||
const statusCode = e.status || 400;
|
||
const errCode = e.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: e.message || '语音上传失败' });
|
||
}
|
||
});
|
||
}
|
||
);
|
||
|
||
// 服务包数量上限(按大神的服务项个数)
|
||
const SERVICE_MAX_PER_EXPERT = 20;
|
||
|
||
/**
|
||
* 校验服务包提交内容(新增/编辑共用):名称、类目、单价区间(平台规则)、长度与内容安全。
|
||
* requireAll=true 表示名称与单价必填(新增与整体覆盖编辑)。
|
||
*/
|
||
async function validateExpertServicePayload(
|
||
req: Request
|
||
): Promise<{ title: string; category: string; price: number; unit: string; mode: string; desc: string }> {
|
||
const body = req.body || {};
|
||
const title = String(body.title == null ? '' : body.title).trim();
|
||
const category = String(body.category == null ? '' : body.category).trim() || '同城陪伴';
|
||
const unit = String(body.unit == null ? '' : body.unit).trim() || '小时';
|
||
const mode = String(body.mode == null ? '' : body.mode).trim() || '排位/匹配';
|
||
const desc = String(body.desc == null ? '' : body.desc).trim();
|
||
const price = Number(body.price);
|
||
|
||
const bad = (msg: string) => {
|
||
const e: any = new Error(msg);
|
||
e.status = 400;
|
||
e.code = 40000;
|
||
return e;
|
||
};
|
||
|
||
if (!title) throw bad('服务名称不能为空');
|
||
if (title.length > 40) throw bad('服务名称最多 40 个字符');
|
||
if (category.length > 32) throw bad('服务类目最多 32 个字符');
|
||
if (unit.length > 16) throw bad('计价单位最多 16 个字符');
|
||
if (mode.length > 32) throw bad('服务模式最多 32 个字符');
|
||
if (desc.length > 200) throw bad('服务说明最多 200 个字符');
|
||
if (!Number.isFinite(price) || price <= 0) throw bad('服务单价必须为有效正数');
|
||
|
||
const rulesRec = await repo.getPlatformConfigByKey('expert_rules');
|
||
const rules = (rulesRec && rulesRec.valueJson) || {};
|
||
const minRate = Number(rules.minHourlyRate) || 30;
|
||
const maxRate = Number(rules.maxHourlyRate) || 500;
|
||
if (price < minRate || price > maxRate) {
|
||
throw bad(`服务单价需在 ¥${minRate} ~ ¥${maxRate} 之间(平台定价规范)`);
|
||
}
|
||
|
||
const sec = contentSecurityService.checkText(`${title} ${category} ${desc}`);
|
||
if (!sec.pass) {
|
||
const e: any = new Error(sec.message || '内容包含违规信息,请调整后重试');
|
||
e.status = 400;
|
||
e.code = sec.code || 40000;
|
||
throw e;
|
||
}
|
||
|
||
return { title, category, price, unit, mode, desc };
|
||
}
|
||
|
||
// 大神自助新增服务包
|
||
app.post('/api/companions/:id/services', async (req: Request, res: Response) => {
|
||
try {
|
||
const companionId = Number(req.params.id);
|
||
const own = await repo.getCompanionOwnership(companionId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '大神不存在' });
|
||
}
|
||
assertCompanionActor(req, own, '新增服务包');
|
||
const existingCount = await repo.countExpertServices(companionId);
|
||
if (existingCount >= SERVICE_MAX_PER_EXPERT) {
|
||
return res.status(400).json({ code: 40000, message: `服务包数量已达上限(${SERVICE_MAX_PER_EXPERT} 个),请先删除部分服务` });
|
||
}
|
||
const payload = await validateExpertServicePayload(req);
|
||
const created = await repo.createExpertService(companionId, payload);
|
||
res.json({ code: 20000, data: created, message: '服务包已创建' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神自助编辑服务包(整体覆盖)
|
||
app.put('/api/companions/:id/services/:serviceId', async (req: Request, res: Response) => {
|
||
try {
|
||
const companionId = Number(req.params.id);
|
||
const serviceId = String(req.params.serviceId);
|
||
const own = await repo.getExpertServiceOwnership(serviceId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '服务不存在' });
|
||
}
|
||
if (Number(own.companionId) !== companionId) {
|
||
return res.status(403).json({ code: 40300, message: '该服务不属于当前大神' });
|
||
}
|
||
assertCompanionActor(req, own, '编辑服务包');
|
||
const payload = await validateExpertServicePayload(req);
|
||
const updated = await repo.updateExpertService(serviceId, payload);
|
||
res.json({ code: 20000, data: updated, message: '服务包已更新' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 大神自助删除服务包
|
||
app.delete('/api/companions/:id/services/:serviceId', async (req: Request, res: Response) => {
|
||
try {
|
||
const companionId = Number(req.params.id);
|
||
const serviceId = String(req.params.serviceId);
|
||
const own = await repo.getExpertServiceOwnership(serviceId);
|
||
if (!own) {
|
||
return res.status(404).json({ code: 40400, message: '服务不存在' });
|
||
}
|
||
if (Number(own.companionId) !== companionId) {
|
||
return res.status(403).json({ code: 40300, message: '该服务不属于当前大神' });
|
||
}
|
||
assertCompanionActor(req, own, '删除服务包');
|
||
await repo.deleteExpertService(serviceId);
|
||
res.json({ code: 20000, data: true, message: '服务包已删除' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.6 友圈动态互动评论区接口
|
||
app.get('/api/posts/:id/comments', async (req: Request, res: Response) => {
|
||
try {
|
||
const postId = String(req.params.id);
|
||
const comments = await repo.getPostComments(postId);
|
||
res.json({
|
||
code: 20000,
|
||
data: comments
|
||
});
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
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 || {};
|
||
const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '发表评论');
|
||
await assertUserNotBanned(uid, '发表评论');
|
||
|
||
if (!content || !content.trim()) {
|
||
return res.status(400).json({ code: 40000, message: '评论内容不能为空' });
|
||
}
|
||
|
||
// 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层)
|
||
const openid = await resolveUserOpenid(uid);
|
||
const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2);
|
||
if (!secCheck.pass) {
|
||
return res.status(400).json({ code: secCheck.code, message: secCheck.message });
|
||
}
|
||
|
||
const comment = await repo.createPostComment({
|
||
postId,
|
||
content: content.trim(),
|
||
userId: uid,
|
||
author,
|
||
avatar
|
||
});
|
||
res.json({
|
||
code: 20000,
|
||
data: comment,
|
||
message: '评论发表成功'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.7 友圈搭子互动上车约伴申请接口
|
||
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 || {};
|
||
const uid = resolveSecureUserId(req, Number(applicantId || req.headers['x-user-id'] || 0), '申请加入搭子车队');
|
||
await assertUserNotBanned(uid, '申请加入搭子车队');
|
||
|
||
const result = await repo.applyPostParty({
|
||
postId,
|
||
applicantId: uid,
|
||
applicantName,
|
||
applicantAvatar
|
||
});
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: '搭子互动上车申请已送达'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 获取指定动态或全部搭子上车申请列表
|
||
app.get('/api/posts/:id/applies', async (req: Request, res: Response) => {
|
||
try {
|
||
const postId = String(req.params.id);
|
||
// 归属校验:仅动态发布者本人或管理员可查看该动态的上车申请名单
|
||
if (!isVerifiedAdminRequest(req)) {
|
||
const [postRows]: any = await pool.query('SELECT user_id FROM posts WHERE id = ? LIMIT 1', [postId]);
|
||
const ownerId = Number((postRows && postRows[0] && postRows[0].user_id) || 0);
|
||
const callerId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看上车申请');
|
||
if (!ownerId || ownerId !== callerId) {
|
||
return res.status(403).json({ code: 40300, message: '仅动态发布者本人可查看该动态的上车申请' });
|
||
}
|
||
}
|
||
const applies = await repo.getPostApplies(postId);
|
||
res.json({ code: 20000, data: applies });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 全量上车申请列表 (仅服务端签名管理员 token 可执行)
|
||
app.get('/api/post-applies', requireAdmin, async (_req: Request, res: Response) => {
|
||
try {
|
||
const applies = await repo.getPostApplies();
|
||
res.json({ code: 20000, data: applies });
|
||
} catch (err: any) {
|
||
res.status(500).json({ code: 50000, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 审核/处置搭子上车申请 (同意/谢绝):仅动态发布者本人或已验证管理员可操作
|
||
app.post('/api/post-applies/:id/handle', async (req: Request, res: Response) => {
|
||
try {
|
||
const applyId = String(req.params.id);
|
||
const { status, operatorId } = req.body || {};
|
||
if (!status || !['ACCEPTED', 'REJECTED'].includes(status)) {
|
||
return res.status(400).json({ code: 40000, message: '状态参数必须为 ACCEPTED 或 REJECTED' });
|
||
}
|
||
|
||
const apply = await repo.getPostApplyById(applyId);
|
||
if (!apply) {
|
||
return res.status(404).json({ code: 40400, message: '上车申请不存在' });
|
||
}
|
||
|
||
// 越权校验:非管理员时必须是该动态的发布者本人
|
||
if (!isVerifiedAdminRequest(req)) {
|
||
const [postRows]: any = await pool.query('SELECT user_id FROM posts WHERE id = ? LIMIT 1', [String(apply.post_id)]);
|
||
const ownerId = Number((postRows && postRows[0] && postRows[0].user_id) || 0);
|
||
const callerId = resolveSecureUserId(req, Number(operatorId || req.headers['x-user-id'] || 0), '处置上车申请');
|
||
if (!callerId || !ownerId || ownerId !== callerId) {
|
||
return res.status(403).json({ code: 40300, message: '仅动态发布者本人可处置该上车申请' });
|
||
}
|
||
}
|
||
|
||
const result = await repo.handlePostApply(applyId, status);
|
||
res.json({ code: 20000, data: result, message: status === 'ACCEPTED' ? '已同意搭子上车申请' : '已谢绝搭子上车申请' });
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.8 陪玩收藏管理接口 (收藏/取消收藏/列表/状态探测)
|
||
app.get('/api/favorites', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看收藏列表');
|
||
const list = await repo.getFavorites(userId);
|
||
res.json({
|
||
code: 20000,
|
||
data: list,
|
||
message: '获取收藏列表成功'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/favorites/check', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询收藏状态');
|
||
const expertId = Number(req.query.expertId);
|
||
const isFav = await repo.isFavorite(userId, expertId);
|
||
res.json({
|
||
code: 20000,
|
||
data: { isFavorite: isFav }
|
||
});
|
||
} catch (err: any) {
|
||
// 保留 401/403 语义(未登录/越权),不要把鉴权失败吞成 500
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/favorites', async (req: Request, res: Response) => {
|
||
try {
|
||
const { userId, expertId } = req.body;
|
||
if (!expertId) {
|
||
return res.status(400).json({ code: 40000, message: 'expertId 不能为空' });
|
||
}
|
||
const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '收藏大神');
|
||
const result = await repo.addFavorite(uid, Number(expertId));
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: result.message
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.delete('/api/favorites/:expertId', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(
|
||
req,
|
||
Number(req.query.userId || req.headers['x-user-id'] || 0),
|
||
'取消收藏'
|
||
);
|
||
const expertId = Number(req.params.expertId);
|
||
const result = await repo.removeFavorite(userId, expertId);
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: result.message
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 7.9 分销团队与返佣订单流水接口
|
||
app.get('/api/distribution/team', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看分销团队');
|
||
const teamData = await repo.getDistributionTeam(userId);
|
||
res.json({
|
||
code: 20000,
|
||
data: teamData,
|
||
message: '获取团队列表成功'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.get('/api/distribution/records', async (req: Request, res: Response) => {
|
||
try {
|
||
const userId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看返佣明细');
|
||
const records = await repo.getDistributionRecords(userId);
|
||
res.json({
|
||
code: 20000,
|
||
data: records,
|
||
message: '获取返佣明细成功'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 500;
|
||
const errCode = err.code || 50000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
app.post('/api/distribution/bind', async (req: Request, res: Response) => {
|
||
try {
|
||
const { userId, inviteCode } = req.body || {};
|
||
if (!inviteCode || !String(inviteCode).trim()) {
|
||
return res.status(400).json({ code: 40000, message: '邀请码不能为空' });
|
||
}
|
||
const result = await repo.bindInviteCode(resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '绑定邀请码'), String(inviteCode).trim());
|
||
res.json({
|
||
code: 20000,
|
||
data: result,
|
||
message: result.message || '邀请码绑定成功'
|
||
});
|
||
} catch (err: any) {
|
||
const statusCode = err.status || 400;
|
||
const errCode = err.code || 40000;
|
||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||
}
|
||
});
|
||
|
||
// 8. 启动服务器并初始化数据库
|
||
// 启动配置自检:仅输出"就绪状态",绝不打印任何密钥内容(便于部署验收)
|
||
async function logStartupConfigSelfCheck(): Promise<void> {
|
||
try {
|
||
const isProd = process.env.NODE_ENV === 'production';
|
||
console.log(`[Peiwan Config] 运行模式=${isProd ? 'production' : 'development'} ALLOW_SANDBOX_PAY=${ALLOW_SANDBOX_PAY} ALLOW_INSECURE_DEV_AUTH=${ALLOW_INSECURE_DEV_AUTH}`);
|
||
|
||
const adminSecretSet = Boolean(String(process.env.ADMIN_TOKEN_SECRET || '').trim());
|
||
const userSecretSet = Boolean(String(process.env.USER_TOKEN_SECRET || '').trim());
|
||
if (SECURE_MODE && !adminSecretSet) {
|
||
console.warn('[Peiwan Config][WARN] 未设置 ADMIN_TOKEN_SECRET:已启用进程内随机密钥(默认公开密钥已禁用),重启后登录态失效;请尽快设置固定密钥。');
|
||
} else {
|
||
console.log(`[Peiwan Config] ADMIN_TOKEN_SECRET=${adminSecretSet ? '已设置' : '开发默认值'} USER_TOKEN_SECRET=${userSecretSet ? '已设置' : '按 ADMIN 派生'}`);
|
||
}
|
||
|
||
// 后台登录口令闸门:未配置有效口令时后台登录被禁用(不再依赖 NODE_ENV)
|
||
if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) {
|
||
console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):后台登录已被禁用,请尽快设置后重启。');
|
||
}
|
||
|
||
// 危险兜底自检:仅在显式开启时提示(默认全部关闭 = fail-closed)
|
||
if (ALLOW_INSECURE_DEV_AUTH) {
|
||
console.warn('[Peiwan Config][WARN] 已开启 ALLOW_INSECURE_DEV_AUTH=1:未登录请求将采用客户端声明的 userId,仅限本地联调,切勿用于公网部署!');
|
||
}
|
||
if (ALLOW_SANDBOX_PAY) {
|
||
console.warn('[Peiwan Config][WARN] 已开启 ALLOW_SANDBOX_PAY=1:允许沙箱支付(无真实收款推进订单),仅限联调环境!');
|
||
}
|
||
if (!isProd) {
|
||
console.warn(`[Peiwan Config] NODE_ENV 非 production(仅影响日志/订阅消息默认状态,安全闸门由 SECURE_MODE 控制);MINIPROGRAM_STATE=${MINIPROGRAM_STATE}。若已正式发布,请设置 MINIPROGRAM_STATE=formal 让真实用户收到订阅消息。`);
|
||
}
|
||
|
||
const cfg: any = await resolveWechatPayRuntimeConfig();
|
||
const missing: string[] = [];
|
||
if (!cfg.appId) missing.push('appId');
|
||
if (!cfg.appSecret) missing.push('appSecret');
|
||
if (!cfg.mchId) missing.push('mchId');
|
||
if (String(cfg.apiV3Key || '').length !== 32) missing.push('apiV3Key(需32位)');
|
||
if (!cfg.privateKey && !cfg.certPem) missing.push('privateKey/certPem');
|
||
if (!cfg.platformPublicKey && !cfg.serialNo) missing.push('平台公钥或证书序列号');
|
||
|
||
console.log(`[Peiwan Config] 微信支付: 模式=${cfg.effectiveMode} 实盘就绪=${cfg.isReadyForLive ? '是' : '否'} notifyUrl=${String(cfg.notifyUrl || '')}`);
|
||
if (!cfg.isReadyForLive) {
|
||
console.warn(`[Peiwan Config][WARN] 微信支付未达 LIVE 就绪,待确认项: ${missing.join('、') || '(mode 未设为 LIVE 或参数未齐)'}`);
|
||
}
|
||
|
||
console.log(`[Peiwan Config] 商家转账到零钱(提现打款): 就绪=${cfg.isReadyForTransfer ? '是' : '否'} 转账场景ID=${cfg.transferSceneId ? '已配置' : '未配置'}`);
|
||
if (cfg.effectiveMode === 'LIVE' && !cfg.isReadyForTransfer) {
|
||
console.warn('[Peiwan Config][WARN] 微信提现打款未就绪:请在后台配置「转账场景ID」并确认商户号已开通"商家转账到零钱"产品,否则生产环境将拒绝"通过打款"。');
|
||
}
|
||
} catch (e) {
|
||
logServerError('startupConfigSelfCheck', e);
|
||
}
|
||
}
|
||
|
||
// 订单超时自动流转定时任务:定期扫描并处理「超时未支付取消 / 超时未接单退款 / 48小时自动完成结算」
|
||
const ORDER_TIMEOUT_SCAN_MS = Number(process.env.ORDER_TIMEOUT_SCAN_MS || 60000);
|
||
let orderTimeoutTimer: NodeJS.Timeout | null = null;
|
||
let orderTimeoutRunning = false;
|
||
|
||
function startOrderTimeoutScheduler(): void {
|
||
if (process.env.DISABLE_ORDER_TIMEOUT_SCAN === '1') {
|
||
console.log('[Peiwan Scheduler] 订单超时扫描已禁用 (DISABLE_ORDER_TIMEOUT_SCAN=1)');
|
||
return;
|
||
}
|
||
if (orderTimeoutTimer) return;
|
||
const intervalMs = Number.isFinite(ORDER_TIMEOUT_SCAN_MS) ? Math.max(1000, ORDER_TIMEOUT_SCAN_MS) : 60000;
|
||
orderTimeoutTimer = setInterval(async () => {
|
||
if (orderTimeoutRunning) return; // 防重入:上一轮未结束则跳过本轮
|
||
orderTimeoutRunning = true;
|
||
try {
|
||
const r = await repo.processOrderTimeouts();
|
||
if (r && (r.cancelledCount || r.refundedCount || r.completedCount)) {
|
||
console.log(
|
||
`[Peiwan Scheduler] 订单超时扫描: 取消 ${r.cancelledCount} / 退款 ${r.refundedCount} / 完成结算 ${r.completedCount}`
|
||
);
|
||
}
|
||
} catch (e) {
|
||
logServerError('orderTimeoutScheduler', e);
|
||
} finally {
|
||
orderTimeoutRunning = false;
|
||
}
|
||
}, intervalMs);
|
||
if (typeof (orderTimeoutTimer as any).unref === 'function') {
|
||
(orderTimeoutTimer as any).unref();
|
||
}
|
||
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();
|
||
console.log('[Peiwan Server] MySQL 数据库核心表及种子数据已校验就绪');
|
||
|
||
// 载入自定义敏感词过滤库
|
||
const secWordsConfig = await repo.getPlatformConfigByKey('security_words');
|
||
if (secWordsConfig && Array.isArray(secWordsConfig.valueJson)) {
|
||
contentSecurityService.setCustomKeywords(secWordsConfig.valueJson);
|
||
console.log(`[Peiwan Server] 已成功装载 ${secWordsConfig.valueJson.length} 条自定义安全拦截词`);
|
||
}
|
||
} catch (err) {
|
||
console.error('[Peiwan Server] MySQL 初始化连接发生警告:', err);
|
||
}
|
||
|
||
await logStartupConfigSelfCheck();
|
||
startOrderTimeoutScheduler();
|
||
|
||
// 载入后台登录态版本号,并周期同步(支持多实例下的"吊销所有会话")
|
||
await loadAdminTokenEpoch();
|
||
const epochSyncTimer = setInterval(() => {
|
||
void loadAdminTokenEpoch();
|
||
}, 30 * 1000);
|
||
if (typeof (epochSyncTimer as any).unref === 'function') (epochSyncTimer as any).unref();
|
||
|
||
app.listen(port, () => {
|
||
console.log(`[Peiwan Server] HTTP server is running on http://localhost:${port}`);
|
||
});
|
||
}
|
||
|
||
bootstrap();
|
||
|
||
export default app;
|