文件
shanghaipeiwan/server/src/index.ts

5650 行
225 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

// 已读取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 形如 28 位字母数字(o 开头);演示种子数据里的 wx_openid_1001 不是真实 openid
function isPlausibleWechatOpenid(openid: any): boolean {
const value = String(openid || '').trim();
if (!value) return false;
if (/^wx_openid_/i.test(value)) return false;
return /^[A-Za-z0-9_-]{20,64}$/.test(value);
}
// 读取用户微信 openid(用于微信官方内容安全 security.msgSecCheck 溯源),失败或非真实 openid 返回 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 isPlausibleWechatOpenid(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'] || req.headers['x-admin-token'] || '');
const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : auth.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 }>();
// 周期性淘汰防爆破记录与地理编码缓存,杜绝常驻进程内存泄漏
const securitySweepTimer = setInterval(() => {
const now = Date.now();
for (const [k, v] of loginFailureMap) {
if (now - v.firstAt > LOGIN_WINDOW_MS) loginFailureMap.delete(k);
}
if (loginFailureMap.size > 20000) loginFailureMap.clear();
for (const [k, v] of geocodeCache) {
if (now - v.at > 10 * 60_000) geocodeCache.delete(k);
}
if (geocodeCache.size > 5000) geocodeCache.clear();
}, 5 * 60 * 1000);
if (typeof (securitySweepTimer as any).unref === 'function') (securitySweepTimer as any).unref();
/** 地址 -> 坐标(腾讯 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;
invalidatePlatformConfigsCache();
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) {
const isAdmin = isVerifiedAdminRequest(req);
if (!isAdmin) {
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' },
{ key: 'system_alerts', count: Number(todos?.systemAlertsCount || 0), title: '系统告警', content: '资金/订单状态异常待处理', link: '/safety/system-alerts' },
];
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 unread = await repo.getUserUnreadNotificationCount(uid);
if (Number(req.query.page) > 0) {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
const total = await repo.getUserNotificationTotal(uid);
const list = await repo.getUserNotifications(uid, pageSize, (page - 1) * pageSize);
return res.json({
code: 20000,
data: {
list,
unread,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + list.length < total,
},
});
}
const list = await repo.getUserNotifications(uid, Number(req.query.limit) || 50);
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 filters = {
keyword: String(req.query.keyword || '').trim(),
payType: String(req.query.payType || '').trim(),
creatorId: Number(req.query.creatorId) || 0,
category: String(req.query.category || '').trim(),
categoryName: String(req.query.categoryName || '').trim(),
};
const paged = Number(req.query.page) > 0;
if (!paged) {
const acts = await repo.getActivities(filters);
return res.json({ code: 20000, data: acts });
}
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
const acts = await repo.getActivities({ ...filters, page, pageSize });
const total = await repo.countActivities(filters);
res.json({
code: 20000,
data: {
list: acts,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + acts.length < total,
},
});
} 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) => {
// 公开目录不返回内部审核备注、用户 id 与证件脱敏串
const { rejectReason, userId, idCardMasked, ...rest } = c;
return rest;
});
res.json({
code: 20000,
data: publicList
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
// 即时匹配:仅返回真实注册、审核通过、心跳在线的接单大神;带坐标时按 5km 距离过滤并排序
app.get('/api/companions/match', async (req: Request, res: Response) => {
try {
const lat = Number(req.query.lat);
const lng = Number(req.query.lng);
const hasGeo =
Number.isFinite(lat) && Number.isFinite(lng) &&
Math.abs(lat) <= 90 && Math.abs(lng) <= 180 &&
!(lat === 0 && lng === 0);
const radiusKm = Math.min(50, Math.max(1, Number(req.query.radius) || 5));
const limit = Math.min(20, Math.max(1, Number(req.query.limit) || 10));
const excludeUserId = resolveRequestUserId(req) || 0;
const list = await repo.matchCompanions({
lat: hasGeo ? lat : null,
lng: hasGeo ? lng : null,
radiusKm,
excludeUserId,
limit,
});
res.json({
code: 20000,
data: { list, total: list.length, radiusKm, hasGeo },
});
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message || '即时匹配异常' });
}
});
// 大神接单心跳:刷新 last_active_at(有定位时同步最近坐标,坐标不对外返回)
app.post('/api/companions/heartbeat', rateLimit({ name: 'companion_heartbeat', windowMs: 60 * 1000, max: 40, key: rateWriteKey }), async (req: Request, res: Response) => {
try {
const uid = resolveSecureUserId(
req,
Number(req.body?.userId || req.headers['x-user-id'] || 0),
'大神接单心跳'
);
const comp = await repo.getCompanionByUserId(uid);
if (!comp) {
return res.status(404).json({ code: 40400, message: '尚未入驻大神,无法上报接单状态' });
}
if (comp.audit_status !== '已通过') {
return res.status(403).json({ code: 40300, message: '大神档案未通过审核,无法进入匹配池' });
}
const lat = Number(req.body?.lat);
const lng = Number(req.body?.lng);
await repo.updateCompanionHeartbeat(Number(comp.id), lat, lng);
res.json({
code: 20000,
data: {
companionId: Number(comp.id),
isOnline: comp.status === '接单中',
lastActiveAt: new Date().toISOString(),
},
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', 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, userId, idCardMasked, ...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);
logAdminAudit(req, action === 'approve' ? '通过大神入驻' : '驳回大神入驻', 'companion', companionId, reason ? `原因:${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 {
const status = String(req.query.status || '').trim();
const paged = Number(req.query.page) > 0;
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(req.query.pageSize) || 20)));
const queryOpts = paged ? { page, pageSize, status } : { status };
// 管理端(服务端签名 admin token)可取全量;普通用户仅能取与自己相关的订单
if (isVerifiedAdminRequest(req)) {
const orders = await repo.getOrders(queryOpts);
if (!paged) {
return res.json({
code: 20000,
data: orders
});
}
const total = await repo.countOrders({ status });
return res.json({
code: 20000,
data: {
list: orders,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + orders.length < total,
}
});
}
const uid = resolveSecureUserId(
req,
Number(req.query.userId || req.headers['x-user-id'] || 0),
'查看订单列表'
);
const orders = await repo.getOrdersForUser(uid, queryOpts);
if (!paged) {
return res.json({
code: 20000,
data: orders
});
}
const total = await repo.countOrdersForUser(uid, { status });
res.json({
code: 20000,
data: {
list: orders,
total,
page,
pageSize,
hasMore: (page - 1) * pageSize + orders.length < total,
}
});
} 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);
// 已登录的大神不展示自己发布的订单(禁止自接自单)
const excludeBuyerId = resolveRequestUserId(req) || 0;
// 先取不带半径的全量池:为历史无坐标订单做地理编码兜底(每轮最多 5 条,结果回写入库)
let pre = await repo.getOrdersPool({ fromLat: hasFrom ? lat : null, fromLng: hasFrom ? lng : null, excludeBuyerId });
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, excludeBuyerId });
}
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 });
}
});
// 静态高频接口的进程内 TTL 缓存(降低 MySQL 重复读取开销)
interface FastCacheEntry<T> {
data: T;
expireAt: number;
}
const STATIC_CACHE_TTL_MS = 60 * 1000;
let cachedBanners: FastCacheEntry<any[]> | null = null;
export function invalidateBannersCache(): void {
cachedBanners = null;
}
let cachedNavCategories: FastCacheEntry<any[]> | null = null;
export function invalidateNavCategoriesCache(): void {
cachedNavCategories = null;
}
let cachedPlatformConfigs: FastCacheEntry<{ [key: string]: any }> | null = null;
export function invalidatePlatformConfigsCache(): void {
cachedPlatformConfigs = null;
}
// 首页运营 Banner 列表 (小程序端读取启用状态)
app.get('/api/banners', async (_req: Request, res: Response) => {
try {
const now = Date.now();
if (cachedBanners && now < cachedBanners.expireAt) {
return res.json({ code: 20000, data: cachedBanners.data });
}
const banners = await repo.getBanners();
cachedBanners = { data: banners, expireAt: now + STATIC_CACHE_TTL_MS };
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
});
invalidateBannersCache();
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: '轮播图不存在或已被删除' });
}
invalidateBannersCache();
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: '轮播图不存在' });
}
invalidateBannersCache();
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: '轮播图不存在或已被删除' });
}
invalidateBannersCache();
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 now = Date.now();
if (cachedNavCategories && now < cachedNavCategories.expireAt) {
return res.json({ code: 20000, data: cachedNavCategories.data });
}
const list = await repo.getNavCategories();
cachedNavCategories = { data: list, expireAt: now + STATIC_CACHE_TTL_MS };
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
});
invalidateNavCategoriesCache();
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: '品类导航不存在或已被删除' });
}
invalidateNavCategoriesCache();
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: '品类导航不存在' });
}
invalidateNavCategoriesCache();
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: '品类导航不存在或已被删除' });
}
invalidateNavCategoriesCache();
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 now = Date.now();
if (cachedPlatformConfigs && now < cachedPlatformConfigs.expireAt) {
return res.json({ code: 20000, data: cachedPlatformConfigs.data });
}
const configs = await repo.getPlatformConfigs();
cachedPlatformConfigs = { data: configs, expireAt: now + STATIC_CACHE_TTL_MS };
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);
}
invalidatePlatformConfigsCache();
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 });
// 平台派单(余额支付)直接进入待接单池:通知在线认证大神
if (String(order?.status) === '待接单') {
void notifyNearbyExpertsForOrder(order);
}
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'
);
void notifyNearbyExpertsForOrder(order);
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);
if (requested === '待接单') void notifyNearbyExpertsForOrder(order);
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
});
invalidatePlatformConfigsCache();
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));
}
/** 后台敏感操作审计:余额调整、封禁、删除、审核、打款等 */
function logAdminAudit(req: Request, action: string, targetType: string, targetId: string | number, detail: string): void {
void repo
.createAdminAuditLog({
operator: 'admin',
action,
targetType,
targetId: String(targetId || ''),
detail,
ip: clientIpOf(req),
})
.catch((e: any) => logServerError('logAdminAudit', e));
}
/**
* 新派单通知:给当前在线、审核通过的认证大神推送站内消息(含红点)。
* 说明:大神档案暂无实时坐标,先按"在线接单中"广播;后续可结合经纬度做 5km 精确推送。
*/
async function notifyNearbyExpertsForOrder(order: any): Promise<void> {
try {
const orderId = String(order?.id || '');
const buyerId = Number(order?.buyer_id || order?.buyerId || 0);
if (!orderId) return;
const [rows]: any = await pool.query(`
SELECT DISTINCT c.user_id as userId
FROM companions c
JOIN users u ON u.id = c.user_id
WHERE c.audit_status = '已通过'
AND c.status = '接单中'
AND c.user_id IS NOT NULL
AND u.status NOT IN ('已封禁', '封禁')
LIMIT 60
`);
const title = order?.service_title || order?.serviceTitle || '附近有新需求单';
const track = order?.track || '同城陪伴';
const amount = Number(order?.amount || 0).toFixed(2);
for (const row of rows || []) {
const uid = Number(row.userId) || 0;
if (!uid || uid === buyerId) continue;
pushUserNotification(
uid,
'ORDER_POOL',
'附近有新需求单',
`【${track}】${title} · ¥${amount},先到先得`,
'/pages/expert/radar'
);
}
} catch (e) {
logServerError('notifyNearbyExpertsForOrder', 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 (!isPlausibleWechatOpenid(openid)) {
logServerError('notifyBySubscribe', new Error(`订阅消息[${templateKey}]跳过:用户 ${uid} 没有有效的微信 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 || '微信手机号快捷登录异常' });
}
});
// 微信小程序 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);
logAdminAudit(req, '驳回提现', 'withdrawal', wdId, 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');
logAdminAudit(req, '提现打款', 'withdrawal', wdId, `¥${Number(wd.amount || 0).toFixed(2)} · 转账单 ${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);
logAdminAudit(req, '提现发起转账', 'withdrawal', wdId, `¥${Number(wd.amount || 0).toFixed(2)} · 转账单 ${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'
);
logAdminAudit(
req,
direction === 'IN' ? '余额充值' : '余额扣减',
'user',
userId,
`¥${result.amount}${reason ? ` · ${reason}` : ''} → 余额 ¥${result.after}`
);
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.get('/api/admin/system/alerts', requireAdmin, async (req: Request, res: Response) => {
try {
const raw = String(req.query.status || 'OPEN').toUpperCase();
const status = raw === 'RESOLVED' ? 'RESOLVED' : 'OPEN';
const list = await repo.listSystemAlerts(status, Number(req.query.limit) || 100);
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, message: err.message });
}
});
app.post('/api/admin/system/alerts/:id/resolve', requireAdmin, async (req: Request, res: Response) => {
try {
const ok = await repo.resolveSystemAlert(String(req.params.id));
if (!ok) return res.status(404).json({ code: 40400, message: '告警不存在或已处理' });
logAdminAudit(req, '处理系统告警', 'system_alert', String(req.params.id), '标记为已处理');
res.json({ code: 20000, data: { ok: true }, message: '已标记处理' });
} catch (err: any) {
res.status(400).json({ code: 40000, message: err.message });
}
});
app.get('/api/admin/audit-logs', requireAdmin, async (req: Request, res: Response) => {
try {
const list = await repo.listAdminAuditLogs(Number(req.query.limit) || 200);
res.json({ code: 20000, data: list });
} catch (err: any) {
res.status(500).json({ code: 50000, 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);
logAdminAudit(req, status === '正常' ? '解除封禁' : '封禁用户', 'user', 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: '用户不存在或已被删除' });
}
logAdminAudit(req, '删除用户', 'user', userId, '永久删除用户档案及关联数据');
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 openidRaw = uRows && uRows[0] && uRows[0].wechat_openid;
const openidValid = isPlausibleWechatOpenid(openidRaw);
const openid = openidValid ? String(openidRaw) : '';
const diagnostics = {
enablePush,
hasTemplateId: Boolean(templateId),
hasOpenid: Boolean(openidRaw),
openidValid,
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' ? '(该类型为可选:核销码已随「订单状态提醒」的温馨提示下发)' : ''}`
: (!openidRaw
? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)`
: (!openidValid
? `目标用户(ID=${targetUserId || 0})的 openid 不是有效微信 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`);
}
// 资金/状态异常巡检:每 5 分钟扫描一次,异常写入 system_alerts(按业务主键去重)
const SYSTEM_ALERT_SCAN_MS = Number(process.env.SYSTEM_ALERT_SCAN_MS || 5 * 60 * 1000);
let systemAlertTimer: NodeJS.Timeout | null = null;
let systemAlertRunning = false;
async function runSystemAlertScan(): Promise<void> {
try {
// 1) 提现打款卡在「处理中」超过 30 分钟
const [stuckWithdrawals]: any = await pool.query(`
SELECT id, companion_id as userId, amount, transfer_status, updated_at
FROM withdrawals
WHERE status = '处理中' AND updated_at <= DATE_SUB(NOW(), INTERVAL 30 MINUTE)
LIMIT 50
`);
for (const w of stuckWithdrawals || []) {
await repo.createSystemAlert({
key: `withdraw_stuck_${w.id}`,
level: 'ERROR',
title: '提现打款卡在「处理中」',
detail: `工单 ${w.id} · 用户 ${w.userId} · ¥${Number(w.amount || 0).toFixed(2)} · 转账状态 ${w.transfer_status || '-'} · 最后更新 ${w.updated_at}`,
});
}
// 2) 支付已确认但订单仍是待付款(回调/状态流转异常)
const [paidUnpaid]: any = await pool.query(`
SELECT o.id, o.order_no, o.status, COALESCE(pi.paid_at, pi.created_at) as paidAt
FROM payment_intents pi
JOIN orders o ON o.id = pi.order_id
WHERE pi.status = 'SUCCESS'
AND o.status IN ('待付款', '待支付')
AND COALESCE(pi.paid_at, pi.created_at) <= DATE_SUB(NOW(), INTERVAL 10 MINUTE)
LIMIT 50
`);
for (const o of paidUnpaid || []) {
await repo.createSystemAlert({
key: `paid_unpaid_${o.id}`,
level: 'ERROR',
title: '支付已成功但订单仍为待付款',
detail: `订单 ${o.order_no || o.id} · 状态 ${o.status} · 支付时间 ${o.paidAt},请人工核对支付意图与订单状态`,
});
}
// 3) 待接单超时未自动退款(调度器异常)
const [stalePool]: any = await pool.query(`
SELECT id, order_no, created_at
FROM orders
WHERE status = '待接单' AND created_at <= DATE_SUB(NOW(), INTERVAL 40 MINUTE)
LIMIT 50
`);
for (const o of stalePool || []) {
await repo.createSystemAlert({
key: `pool_stale_${o.id}`,
level: 'WARN',
title: '待接单订单超时未退款',
detail: `订单 ${o.order_no || o.id} · 创建于 ${o.created_at},已超过 40 分钟仍在派单池,请检查超时退款任务`,
});
}
} catch (e) {
logServerError('runSystemAlertScan', e);
}
}
function startSystemAlertScheduler(): void {
if (process.env.DISABLE_SYSTEM_ALERT_SCAN === '1') {
console.log('[Peiwan Scheduler] 系统异常巡检已禁用 (DISABLE_SYSTEM_ALERT_SCAN=1)');
return;
}
if (systemAlertTimer) return;
const intervalMs = Number.isFinite(SYSTEM_ALERT_SCAN_MS) ? Math.max(60000, SYSTEM_ALERT_SCAN_MS) : 300000;
void runSystemAlertScan();
systemAlertTimer = setInterval(async () => {
if (systemAlertRunning) return;
systemAlertRunning = true;
try {
await runSystemAlertScan();
} finally {
systemAlertRunning = false;
}
}, intervalMs);
if (typeof (systemAlertTimer as any).unref === 'function') {
(systemAlertTimer 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();
startSystemAlertScheduler();
// 载入后台登录态版本号,并周期同步(支持多实例下的"吊销所有会话")
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;