// 已读取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; // 反向代理(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(); // 周期性清理过期桶,避免内存无界增长 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 { if (!userId || isNaN(Number(userId))) return; const user = await repo.getUserById(Number(userId)); if (user && (user.status === '已封禁' || user.status === '封禁')) { const err: any = new Error(`您的账号已被管理员封禁,无法${actionName}`); err.status = 403; err.code = 40300; throw err; } } // 读取用户微信 openid(用于微信官方内容安全 security.msgSecCheck 溯源),失败返回 undefined async function resolveUserOpenid(userId: number): Promise { try { const [rows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(userId)]); const openid = rows && rows[0] && rows[0].wechat_openid; return openid ? String(openid) : undefined; } catch (_) { return undefined; } } // ===== 管理后台鉴权(HMAC 签名 token,防止客户端伪造管理员身份)===== const DEFAULT_ADMIN_TOKEN_SECRET = 'peiwan_admin_token_secret_v1'; // 生产环境安全闸门:若未配置 ADMIN_TOKEN_SECRET,绝不使用仓库内置默认密钥 // (该默认值公开于代码库,可被用于伪造管理员 token 触发全站管理端越权)。 // 生产环境改为启用进程内随机密钥——默认密钥签发的 token 一律失效,并高优告警要求尽快配置固定密钥。 const ADMIN_TOKEN_SECRET = (() => { const provided = String(process.env.ADMIN_TOKEN_SECRET || '').trim(); if (provided) return provided; if (process.env.NODE_ENV === 'production') { 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 { try { const rec = await repo.getPlatformConfigByKey('admin_token_epoch'); const v = Number(rec && rec.valueJson && (rec.valueJson as any).epoch); if (Number.isFinite(v) && v >= 1) adminTokenEpoch = v; } catch (_) { // 读取失败时保持当前内存值 } } function signAdminToken(): string { const payload = Buffer.from(JSON.stringify({ role: 'admin', ts: Date.now(), ver: adminTokenEpoch })).toString('base64url'); const sig = crypto.createHmac('sha256', ADMIN_TOKEN_SECRET).update(payload).digest('base64url'); return `${payload}.${sig}`; } function verifyAdminToken(token: string): boolean { const parts = String(token || '').split('.'); if (parts.length !== 2) return false; const [payload, sig] = parts; const expected = crypto.createHmac('sha256', ADMIN_TOKEN_SECRET).update(payload).digest('base64url'); const sigBuf = Buffer.from(sig); const expBuf = Buffer.from(expected); if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) return false; try { const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')); if (data.role !== 'admin' || !data.ts) return false; // 版本不一致 = 该令牌已被吊销 if (Number(data.ver) !== adminTokenEpoch) return false; if (Date.now() - Number(data.ts) > ADMIN_TOKEN_TTL_MS) return false; return true; } catch (_) { return false; } } function requireAdmin(req: Request, res: Response, next: NextFunction): void { const auth = String(req.headers['authorization'] || ''); const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; if (!token || !verifyAdminToken(token)) { // 采用 code 50008(HTTP 200)以契合后台拦截器的"登录失效→重新登录"约定 res.json({ code: 50008, message: '登录状态已失效,请重新登录管理后台' }); return; } next(); } // 仅依据服务端签名校验通过的 admin token 判定管理员请求; // 严禁信任任何客户端可伪造的角色字段(operatorRole / x-user-role)。 function isVerifiedAdminRequest(req: Request): boolean { const auth = String(req.headers['authorization'] || ''); const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; return Boolean(token) && verifyAdminToken(token); } // 常量时间字符串比较(避免口令比较的时序侧信道) function safeStringEqual(a: string, b: string): boolean { const ba = Buffer.from(String(a)); const bb = Buffer.from(String(b)); if (ba.length !== bb.length) return false; return crypto.timingSafeEqual(ba, bb); } // 管理端登录暴力破解防护(内存级:按 IP+账号 统计失败次数,超限锁定一段时间) const LOGIN_MAX_FAILURES = 5; const LOGIN_WINDOW_MS = 10 * 60 * 1000; const loginFailureMap = new Map(); 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; } if (IS_PRODUCTION) { 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) { // 本地联调:兼容未绑定 user_id 的历史大神数据 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; } // 管理端:吊销所有后台登录态(版本号 +1,所有已签发 admin token 立即失效) app.post('/api/admin/session/revoke-all', requireAdmin, async (req: Request, res: Response) => { try { const next = adminTokenEpoch + 1; await repo.updatePlatformConfig('admin_token_epoch', { name: '后台登录态版本号', valueJson: { epoch: next }, description: '内部标记:用于吊销全部后台登录态,勿手动修改', status: 1, }); adminTokenEpoch = next; console.warn(`[Peiwan Admin][AUDIT] 后台登录态已全部吊销 version->${next} ip=${clientIpOf(req)}`); res.json({ code: 20000, message: '已吊销所有后台登录态,请重新登录' }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 1.1 管理员鉴权与登录接口 (供 PC 运营管理后台生产环境调用) app.post('/api/user/login', (req: Request, res: Response) => { const { username, password } = req.body || {}; if (!username || !password) { return res.status(400).json({ code: 50000, message: '用户名和密码不能为空' }); } const loginKey = `${clientIpOf(req)}|${String(username)}`; if (isLoginLocked(loginKey)) { return res.status(429).json({ code: 50000, message: '登录失败次数过多,请 10 分钟后再试' }); } // 生产环境 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录 if (IS_PRODUCTION && !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,AD', 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) => { // 生产环境要求登录后上传,避免匿名上传被滥用(刷盘/托管非法内容) if (IS_PRODUCTION) { 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 (IS_PRODUCTION) { // 生产环境要求登录,避免匿名滥用微信内容安全额度 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): any[] { const defs = [ { key: 'pending_orders', count: Number(todos?.pendingOrdersCount || 0), title: '待接单订单', content: '有订单等待大神接单', link: '/order/list' }, { key: 'pending_audits', count: Number(todos?.pendingAuditsCount || 0), title: '待审核大神', content: '有大神入驻申请待审核', link: '/companion/audit' }, { key: 'pending_withdrawals', count: Number(todos?.pendingWithdrawalsCount || 0), title: '待审核提现', content: `待审核提现合计 ¥${Number(todos?.pendingWithdrawalAmount || 0).toFixed(2)}`, link: '/finance/withdraw' }, { key: 'pending_reports', count: Number(todos?.pendingReportsCount || 0), title: '待处理举报', content: '有违规举报工单待处置', link: '/safety/reports' }, { key: 'pending_arbitrations', count: Number(todos?.pendingArbitrationsCount || 0), title: '待仲裁工单', content: '有争议仲裁待平台介入', link: '/order/arbitration' }, ]; return defs .filter((d) => d.count > 0) .map((d, i) => { const readCount = Number(readMap[d.key] || 0); return { id: i + 1, key: d.key, type: 'todo', title: d.title, subTitle: `待处理 ${d.count} 条`, content: d.content, time: formatCnTime(), status: d.count > readCount ? 0 : 1, messageType: 1, count: d.count, link: d.link, }; }); } app.post('/api/message/list', requireAdmin, async (_req: Request, res: Response) => { try { const todos = await repo.getDashboardTodos(); const readMap = await repo.getAdminTodoReadCounts(); res.json({ code: 20000, data: buildAdminTodoItems(todos, readMap) }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.post('/api/message/read', requireAdmin, async (req: Request, res: Response) => { try { const ids = Array.isArray(req.body?.ids) ? req.body.ids.map((x: any) => Number(x)) : []; const keys = Array.isArray(req.body?.keys) ? req.body.keys.map((x: any) => String(x)) : []; const todos = await repo.getDashboardTodos(); const readMap = await repo.getAdminTodoReadCounts(); const items = buildAdminTodoItems(todos, readMap); const targets = items.filter((it: any) => ids.includes(it.id) || keys.includes(it.key)); const toMark = (targets.length ? targets : items).map((it: any) => ({ key: it.key, count: it.count })); const affected = await repo.markAdminTodosRead(toMark); res.json({ code: 20000, data: { affected }, message: '已标记为已读' }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // ===== 站内消息通知中心(用户端真实数据源)===== // 关键动作(支付/接单/拒单/完成/提现到账/报名/退款/仲裁)都会写入 user_notifications, // 即使未授权微信订阅消息,用户仍可在小程序「消息中心」看到完整通知历史。 app.get('/api/notifications', async (req: Request, res: Response) => { try { const uid = resolveSecureUserId( req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看消息通知' ); const list = await repo.getUserNotifications(uid, Number(req.query.limit) || 50); const unread = await repo.getUserUnreadNotificationCount(uid); res.json({ code: 20000, data: { list, unread } }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.post('/api/notifications/read', async (req: Request, res: Response) => { try { const uid = resolveSecureUserId( req, Number(req.body?.userId || req.headers['x-user-id'] || 0), '标记消息已读' ); const ids = Array.isArray(req.body?.ids) ? req.body.ids : undefined; const affected = await repo.markUserNotificationsRead(uid, ids); res.json({ code: 20000, data: { affected }, message: '消息已标记为已读' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 2. 运营后台统计大盘接口 (真实读取 MySQL) app.get('/api/dashboard/stats', async (_req: Request, res: Response) => { try { const stats = await repo.getDashboardStats(); res.json({ code: 20000, data: stats }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 2.1 综合运营待办与预警中心接口 (真实读取 MySQL 待办项) app.get('/api/dashboard/todos', async (_req: Request, res: Response) => { try { const todos = await repo.getDashboardTodos(); res.json({ code: 20000, data: todos }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 3. 活动与4位核销码相关接口 (MySQL 持久化) app.get('/api/activities', async (_req: Request, res: Response) => { try { const acts = await repo.getActivities(); res.json({ code: 20000, data: acts }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.get('/api/activities/:id', async (req: Request, res: Response) => { try { const act = await repo.getActivityById(String(req.params.id)); if (!act) { return res.status(404).json({ code: 40400, message: '活动不存在' }); } // 核销码属于该参与人的"门票",严禁向他人泄露: // 仅管理员可见全部,普通访问者仅可见本人的核销码,其余参与者剥离 verifyCode。 const isAdmin = isVerifiedAdminRequest(req); let callerId = 0; if (!isAdmin) { try { callerId = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看组局详情'); } catch { callerId = 0; } } const participants = (act.participants || []).map((p: any) => { if (isAdmin || (callerId > 0 && Number(p.userId) === callerId)) return p; const { verifyCode, ...rest } = p; return rest; }); res.json({ code: 20000, data: { ...act, participants } }); } catch (err: any) { const statusCode = err.status || 500; const errCode = err.code || 50000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.post('/api/activities', async (req: Request, res: Response) => { try { const { title, location, startTime } = req.body || {}; // 身份以服务端会话为准,杜绝冒充他人发起组局(发起人即资金结算归属人) const creatorId = resolveSecureUserId( req, Number(req.body?.creatorId || req.headers['x-user-id'] || 0), '发起组局' ); await assertUserNotBanned(creatorId, '发起组局或发布活动'); if (!title || !String(title).trim()) { return res.status(400).json({ code: 40000, message: '请填写活动标题' }); } if (!startTime || !String(startTime).trim()) { return res.status(400).json({ code: 40000, message: '请选择活动时段' }); } if (!location || !String(location).trim()) { return res.status(400).json({ code: 40000, message: '请填写或选择活动地点' }); } // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) const openid = await resolveUserOpenid(creatorId); const checkTitle = await contentSecurityService.checkTextAsync(title, openid, 2); if (!checkTitle.pass) { return res.status(400).json({ code: checkTitle.code, message: checkTitle.message }); } const checkLoc = await contentSecurityService.checkTextAsync(location, openid, 2); if (!checkLoc.pass) { return res.status(400).json({ code: checkLoc.code, message: checkLoc.message }); } const act = await repo.createActivity(req.body); res.json({ code: 20000, data: act, message: '活动创建成功并已写入MySQL' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 报名并生成4位数字核销码 (落盘 MySQL) app.post('/api/activities/:id/join', async (req: Request, res: Response) => { try { const actId = String(req.params.id); const { userId, payAmountCents } = req.body || {}; const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 2008), '报名参加活动'); await assertUserNotBanned(uid, '报名参加活动'); const participant = await repo.joinActivity(actId, { userId: uid, payAmountCents: Number(payAmountCents || 0) }); try { const act = await repo.getActivityById(actId); pushUserNotification(uid, 'ACTIVITY', '报名成功', `你已成功报名「${String(act?.title || '同城组局活动')}」,开始时间 ${toCnTimeOrNow(act?.startTime)}。`, '/pages/activity/my-ticket'); void notifyBySubscribe('activityJoin', { userId: uid, page: '/pages/activity/my-ticket', data: { thing1: String(act?.title || '同城组局活动'), time2: formatCnTime(), time3: toCnTimeOrNow(act?.startTime), thing4: String(act?.location || '详见活动页') } }); } catch (e) { logServerError('joinActivity.notify', e); } res.json({ code: 20000, data: participant, message: '报名成功,现场出示4位核销码签到核销' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 房主/管理员核销 4 位核销码 (防重落盘 MySQL) app.post('/api/activities/:id/verify', async (req: Request, res: Response) => { try { const actId = String(req.params.id); const { operatorId, verifyCode } = req.body; // 操作人身份以服务端会话 token 为准,防止伪造 operatorId 越权核销他人组局 let operator = resolveSecureUserId(req, Number(operatorId || 0), '核销组局门票'); if (isVerifiedAdminRequest(req)) { const act = await repo.getActivityById(actId); if (act && act.creatorId) operator = Number(act.creatorId); } const result = await repo.verifyCheckin(actId, String(verifyCode), operator); res.json({ code: 20000, data: result, message: '核销成功,已完成现场到场签到' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 通用4位核销码反查信息 (支持组局活动与同城线下订单) app.get('/api/verify/query/:code', async (req: Request, res: Response) => { try { const operator = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询核销码'); const info = await repo.queryCheckinCodeInfo(String(req.params.code)); // 生产环境:仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情, // 防止对 4 位纯数字核销码做批量枚举探测。 if (IS_PRODUCTION && !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), '核销门票'); // 生产环境校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销 if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) { const info = await repo.queryCheckinCodeInfo(String(verifyCode)); let allowed = false; if (info.type === 'ACTIVITY') { allowed = Number(info.creatorId) === operator; } else if (info.type === 'ORDER') { const own = await repo.getOrderOwnership(String(info.id)); allowed = Boolean(own) && own!.companionIds.includes(operator); } if (!allowed) { return res.status(403).json({ code: 40300, message: '无权核销该门票(非活动发起人或承接大神)' }); } } const result = await repo.universalVerifyCheckin(String(verifyCode), operator); res.json({ code: 20000, data: result, message: '核销成功,已完成现场签到并打卡' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 房主手动改价与追加赏金 (落盘 MySQL 并记录流水) app.post('/api/activities/:id/adjust-price', async (req: Request, res: Response) => { try { const actId = String(req.params.id); const { operatorId, newPriceCents } = req.body; const operator = resolveSecureUserId(req, Number(operatorId || 0), '调整组局出价'); const updated = await repo.adjustPrice(actId, Number(newPriceCents), operator); res.json({ code: 20000, data: updated, message: '出价调整成功,老成员同享调价差额' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 房主或平台管理员取消/违规下架组局并触发全额原路退款 app.post('/api/activities/:id/cancel', async (req: Request, res: Response) => { try { const actId = String(req.params.id); const { hostId, reason } = req.body || {}; // 管理员特权只能来自服务端签名校验通过的 admin token; // 严禁信任客户端可伪造的 operatorRole / x-user-role,防止普通用户提权解散他人组局。 const isAdmin = isVerifiedAdminRequest(req); // 非管理员操作人身份以服务端会话为准,杜绝冒充房主解散他人组局并触发退款 let callerId = 0; if (!isAdmin) { callerId = resolveSecureUserId(req, Number(hostId || req.headers['x-user-id'] || 0), '解散组局'); } const result = await repo.cancelActivity(actId, callerId, reason, isAdmin); res.json({ code: 20000, data: result, message: `组局已成功解散下架${result.refundedCount > 0 ? `,已原路全额退款 ${result.refundedCount} 位搭子` : ''}` }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 报名成员主动退席与退款 app.post('/api/activities/:id/leave', async (req: Request, res: Response) => { try { const actId = String(req.params.id); const { userId } = req.body; const uid = resolveSecureUserId(req, Number(userId), '退出组局'); const result = await repo.leaveActivity(actId, uid); res.json({ code: 20000, data: result, message: '已成功退出组局,报名款已原路退回您的钱包' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 用户“我的组局”(发起的 + 报名的) app.get('/api/users/:id/my-activities', async (req: Request, res: Response) => { try { const userId = resolveSecureUserId(req, Number(req.params.id), '查看我的组局'); const data = await repo.getMyActivities(userId); res.json({ code: 20000, data }); } catch (err: any) { const statusCode = err.status || 500; const errCode = err.code || 50000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 4. 陪玩与大神管理接口 (落盘 MySQL) app.get('/api/companions', async (req: Request, res: Response) => { try { const keyword = req.query.keyword ? String(req.query.keyword).slice(0, 40) : undefined; const companions = await repo.getCompanions(keyword); // 管理端可见全量(含待审核/已驳回及内部备注);公开目录仅展示已通过审核的大神,并剥离内部审核备注 if (isVerifiedAdminRequest(req)) { return res.json({ code: 20000, data: companions }); } const publicList = companions .filter((c: any) => c.auditStatus === '已通过') .map((c: any) => { const { rejectReason, ...rest } = c; return rest; }); res.json({ code: 20000, data: publicList }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.get('/api/companions/:id', async (req: Request, res: Response) => { try { const companion = await repo.getCompanionById(String(req.params.id)); if (!companion) { return res.status(404).json({ code: 40400, message: '未找到指定陪伴师资料' }); } // 公开详情剥离内部审核备注;管理端保留完整字段 if (isVerifiedAdminRequest(req)) { return res.json({ code: 20000, data: companion }); } const { rejectReason, ...publicCompanion } = companion; res.json({ code: 20000, data: publicCompanion }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 大神资质与实名入驻真实申请接口 app.post('/api/companions/apply', async (req: Request, res: Response) => { try { const { userId, name, idCard, track, hourlyRate, tags, avatar, gender } = req.body; if (!name || !name.trim()) { return res.status(400).json({ code: 40000, message: '真实姓名不能为空' }); } if (!idCard || !idCard.trim()) { return res.status(400).json({ code: 40000, message: '身份证号不能为空' }); } // 身份以服务端会话为准,杜绝以他人身份提交入驻申请 const applyUid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '申请大神入驻'); // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) const applyOpenid = await resolveUserOpenid(applyUid); const sec = await contentSecurityService.checkTextAsync(`${name} ${track || ''} ${(tags || []).join(' ')}`, applyOpenid, 1); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } // 校验价格与标签 const expertRulesConfig = await repo.getPlatformConfigByKey('expert_rules'); const rules = expertRulesConfig?.valueJson || { minHourlyRate: 30, maxHourlyRate: 500, maxTagsCount: 6 }; if (hourlyRate) { const rateNum = Number(hourlyRate); if (rateNum < (rules.minHourlyRate || 30) || rateNum > (rules.maxHourlyRate || 500)) { return res.status(400).json({ code: 40000, message: `服务单价需在 ¥${rules.minHourlyRate || 30} - ¥${rules.maxHourlyRate || 500} 之间` }); } } const companion = await repo.applyCompanion({ userId: applyUid, name: name.trim(), idCard: idCard.trim(), track: track || '同城陪吃', hourlyRate: Number(hourlyRate) || rules.defaultHourlyRate || 88, tags: Array.isArray(tags) ? tags.slice(0, rules.maxTagsCount || 6) : [], avatar, gender }); res.json({ code: 20000, data: companion, message: '大神实名认证申请已提交,等待运营人工审核' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.post('/api/companions/:id/audit', requireAdmin, async (req: Request, res: Response) => { try { const companionId = Number(req.params.id); const { action, reason, operatorRole } = req.body; // action: 'approve' | 'reject' const headerRole = req.headers['x-user-role']; const callerRole = operatorRole || headerRole || 'admin'; if (callerRole === 'user' || callerRole === 'expert' || callerRole === 'PLAYER' || callerRole === 'EXPERT') { return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行审核' }); } const result = await repo.auditCompanion(companionId, action, reason); res.json({ code: 20000, data: result, message: result.message }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 大神服务单价、技能标签与资料修改 (校验价格边界与标签库规范) app.put('/api/companions/:id', async (req: Request, res: Response) => { try { const companionId = String(req.params.id); const { hourlyRate, tags, rankName, status } = req.body; // 身份校验:仅本人大神或管理员可修改资料,杜绝越权篡改他人单价/接单状态 const own = await repo.getCompanionOwnership(companionId); if (!own) { return res.status(404).json({ code: 40400, message: '大神不存在' }); } assertCompanionActor(req, own, '修改大神资料'); // 1. 读取平台配置的大神规则 const expertRulesConfig = await repo.getPlatformConfigByKey('expert_rules'); const rules = expertRulesConfig?.valueJson || { minHourlyRate: 30, maxHourlyRate: 500, maxTagsCount: 6, }; // 2. 校验服务单价上下限 if (hourlyRate !== undefined) { const rateNum = Number(hourlyRate); if (isNaN(rateNum) || rateNum <= 0) { return res.status(400).json({ code: 40000, message: '服务单价必须为有效正数' }); } if (rateNum < (rules.minHourlyRate || 30)) { return res.status(400).json({ code: 40000, message: `服务单价不能低于平台最低限价 ¥${rules.minHourlyRate || 30}/小时` }); } if (rateNum > (rules.maxHourlyRate || 500)) { return res.status(400).json({ code: 40000, message: `服务单价不能超过平台最高限价 ¥${rules.maxHourlyRate || 500}/小时` }); } } // 3. 校验技能标签 if (tags !== undefined) { if (!Array.isArray(tags)) { return res.status(400).json({ code: 40000, message: '技能标签必须为数组格式' }); } const maxCount = rules.maxTagsCount || 6; if (tags.length > maxCount) { return res.status(400).json({ code: 40000, message: `技能标签最多选择或设置 ${maxCount} 个` }); } // 敏感词安全校验 const textToCheck = tags.join(' '); const sec = contentSecurityService.checkText(textToCheck); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } } // 4. 执行更新 const updated = await repo.updateCompanionProfile(companionId, { hourlyRate: hourlyRate !== undefined ? Number(hourlyRate) : undefined, tags: tags !== undefined ? tags : undefined, rankName, status }); if (!updated) { return res.json({ code: 20000, data: { id: companionId, hourlyRate, tags, rankName, status: status || '接单中' }, message: '陪伴师状态已同步' }); } res.json({ code: 20000, data: updated, message: '陪伴师配置及标签已更新' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 兼容 PUT /api/companions/:id/profile 路由别名 app.put('/api/companions/:id/profile', async (req: Request, res: Response) => { try { const companionId = String(req.params.id); const { hourlyRate, tags, rankName, status } = req.body; const own = await repo.getCompanionOwnership(companionId); if (!own) { return res.status(404).json({ code: 40400, message: '大神不存在' }); } assertCompanionActor(req, own, '修改大神资料'); const updated = await repo.updateCompanionProfile(companionId, { hourlyRate: hourlyRate !== undefined ? Number(hourlyRate) : undefined, tags: tags !== undefined ? tags : undefined, rankName, status }); res.json({ code: 20000, data: updated || { id: companionId, hourlyRate, tags, rankName, status: status || '接单中' }, message: '陪伴师资料与接单状态已更新' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 5. 订单与仲裁接口 (落盘 MySQL) app.get('/api/orders', async (req: Request, res: Response) => { try { // 管理端(服务端签名 admin token)可取全量;普通用户仅能取与自己相关的订单 if (isVerifiedAdminRequest(req)) { const orders = await repo.getOrders(); return res.json({ code: 20000, data: orders }); } const uid = resolveSecureUserId( req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看订单列表' ); const orders = await repo.getOrdersForUser(uid); res.json({ code: 20000, data: orders }); } catch (err: any) { const statusCode = err.status || 500; const errCode = err.code || 50000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 获取即时可抢订单池 (落盘 MySQL) app.get('/api/orders/pool', async (_req: Request, res: Response) => { try { const list = await repo.getOrdersPool(); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 大神抢单/接单认领 (落盘 MySQL) app.post('/api/orders/:id/claim', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const { isCertified, operatorRole } = req.body || {}; if (isCertified === false || operatorRole === 'user' || operatorRole === 'PLAYER') { return res.status(403).json({ code: 40300, message: '未认证用户无法接单' }); } const operatorId = resolveSecureUserId( req, Number(req.body?.expertId || req.body?.companionId || req.body?.userId || req.headers['x-user-id'] || 0), '接单' ); if (operatorId > 0) { await assertUserNotBanned(operatorId, '接单'); } const order = await repo.claimOrder(orderId, { ...(req.body || {}), expertId: operatorId }); pushUserNotification(Number(order?.buyer_id || order?.buyerId || 0), 'ORDER_STATUS', '大神已接单', `订单 ${order?.order_no || orderId} 已被大神接单,服务即将开始。`, '/pages/order/list'); res.json({ code: 20000, data: order, message: '抢单成功,服务已锁定为履约中' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 大神今日接单数据汇总 (落盘 MySQL) app.get('/api/expert/stats/:id', async (req: Request, res: Response) => { try { // 收益数据属敏感信息:仅该大神本人或管理员可查看 const own = await repo.getCompanionOwnership(String(req.params.id)); assertCompanionActor(req, own, '查看大神收益数据'); const stats = await repo.getExpertStats(String(req.params.id)); res.json({ code: 20000, data: stats }); } catch (err: any) { const statusCode = err.status || 500; const errCode = err.code || 50000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 大神日/周/月多维收益走势统计分析接口 app.get('/api/expert/stats/:id/trends', async (req: Request, res: Response) => { try { // 收益走势属敏感信息:仅该大神本人或管理员可查看 const own = await repo.getCompanionOwnership(String(req.params.id)); assertCompanionActor(req, own, '查看大神收益走势'); const trends = await repo.getExpertRevenueTrends(String(req.params.id)); res.json({ code: 20000, data: trends }); } catch (err: any) { const statusCode = err.status || 500; const errCode = err.code || 50000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 首页运营 Banner 列表 (小程序端读取启用状态) app.get('/api/banners', async (_req: Request, res: Response) => { try { const banners = await repo.getBanners(); res.json({ code: 20000, data: banners }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台: 获取全部 Banner 列表 (含停用状态) app.get('/api/admin/banners', async (_req: Request, res: Response) => { try { const banners = await repo.getAdminBanners(); res.json({ code: 20000, data: banners }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台: 新增 Banner app.post('/api/admin/banners', async (req: Request, res: Response) => { try { const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body; if (!title || !title.trim()) { return res.status(400).json({ code: 40000, message: 'Banner 标题不能为空' }); } // 微信内容安全检测 const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || '')); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } const banner = await repo.createBanner({ title: title.trim(), subtitle: subtitle ? subtitle.trim() : '', tag, badge, badgeType, bg, image, link, sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0, status: status !== undefined ? Number(status) : 1 }); res.json({ code: 20000, data: banner, message: '轮播图创建成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台: 修改 Banner app.put('/api/admin/banners/:id', async (req: Request, res: Response) => { try { const bannerId = String(req.params.id); const { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status } = req.body; if (title) { const secCheck = contentSecurityService.checkText(title + ' ' + (subtitle || '')); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } } const updated = await repo.updateBanner(bannerId, { title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined, status: status !== undefined ? Number(status) : undefined }); if (!updated) { return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' }); } res.json({ code: 20000, data: updated, message: '轮播图修改成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台: 切换 Banner 上架/下线状态 app.post('/api/admin/banners/:id/status', async (req: Request, res: Response) => { try { const bannerId = String(req.params.id); const { status } = req.body; if (status === undefined || (status !== 0 && status !== 1)) { return res.status(400).json({ code: 40000, message: '状态参数必须为 0 或 1' }); } const updated = await repo.updateBannerStatus(bannerId, Number(status)); if (!updated) { return res.status(404).json({ code: 40400, message: '轮播图不存在' }); } res.json({ code: 20000, data: updated, message: status === 1 ? '轮播图已上架生效' : '轮播图已下线隐藏' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台: 删除 Banner app.delete('/api/admin/banners/:id', async (req: Request, res: Response) => { try { const bannerId = String(req.params.id); const ok = await repo.deleteBanner(bannerId); if (!ok) { return res.status(404).json({ code: 40400, message: '轮播图不存在或已被删除' }); } res.json({ code: 20000, data: null, message: '轮播图已删除' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 首页金刚区活动品类导航列表 (小程序端读取上架状态) app.get('/api/nav-categories', async (_req: Request, res: Response) => { try { const list = await repo.getNavCategories(); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台: 获取全部品类导航列表 (含停用状态) app.get('/api/admin/nav-categories', async (_req: Request, res: Response) => { try { const list = await repo.getAdminNavCategories(); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台: 新增品类导航 app.post('/api/admin/nav-categories', async (req: Request, res: Response) => { try { const { code, name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status } = req.body; if (!name || !name.trim()) { return res.status(400).json({ code: 40000, message: '品类名称不能为空' }); } if (!icon || !icon.trim()) { return res.status(400).json({ code: 40000, message: '品类图标不能为空' }); } // 微信内容安全检测 const secCheck = contentSecurityService.checkText(name + ' ' + (badgeText || '')); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } const item = await repo.createNavCategory({ code, name: name.trim(), icon: icon.trim(), bgClass, cat, targetUrl, badgeText, sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0, status: status !== undefined ? Number(status) : 1 }); res.json({ code: 20000, data: item, message: '品类导航创建成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台: 修改品类导航 app.put('/api/admin/nav-categories/:id', async (req: Request, res: Response) => { try { const catId = String(req.params.id); const { name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status } = req.body; if (name) { const secCheck = contentSecurityService.checkText(name + ' ' + (badgeText || '')); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } } const updated = await repo.updateNavCategory(catId, { name, icon, bgClass, cat, targetUrl, badgeText, sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined, status: status !== undefined ? Number(status) : undefined }); if (!updated) { return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' }); } res.json({ code: 20000, data: updated, message: '品类导航修改成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台: 切换品类导航显示/隐藏状态 app.post('/api/admin/nav-categories/:id/status', async (req: Request, res: Response) => { try { const catId = String(req.params.id); const { status } = req.body; if (status === undefined || (status !== 0 && status !== 1)) { return res.status(400).json({ code: 40000, message: '状态参数必须为 0 或 1' }); } const updated = await repo.updateNavCategoryStatus(catId, Number(status)); if (!updated) { return res.status(404).json({ code: 40400, message: '品类导航不存在' }); } res.json({ code: 20000, data: updated, message: status === 1 ? '品类导航已上架' : '品类导航已下线隐藏' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台: 删除品类导航 app.delete('/api/admin/nav-categories/:id', async (req: Request, res: Response) => { try { const catId = String(req.params.id); const ok = await repo.deleteNavCategory(catId); if (!ok) { return res.status(404).json({ code: 40400, message: '品类导航不存在或已被删除' }); } res.json({ code: 20000, data: null, message: '品类导航已删除' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // ===================== 营销中心 API ===================== // 1. 充值阶梯接口 // 移动端:获取启用的充值阶梯 app.get('/api/marketing/recharge-tiers', async (_req: Request, res: Response) => { try { const list = await repo.getRechargeTiers(); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台:获取全部充值阶梯 app.get('/api/admin/recharge-tiers', async (_req: Request, res: Response) => { try { const list = await repo.getAdminRechargeTiers(); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台:新增充值阶梯 app.post('/api/admin/recharge-tiers', async (req: Request, res: Response) => { try { const { amount, bonusAmount, tag, sortOrder, status } = req.body; if (amount === undefined || Number(amount) <= 0) { return res.status(400).json({ code: 40000, message: '充值金额必须大于0' }); } const tier = await repo.createRechargeTier({ amount: Number(amount), bonusAmount: bonusAmount !== undefined ? Number(bonusAmount) : 0, tag: tag || '', sortOrder: sortOrder !== undefined ? Number(sortOrder) : 0, status: status !== undefined ? Number(status) : 1 }); res.json({ code: 20000, data: tier, message: '充值阶梯创建成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台:修改充值阶梯 app.put('/api/admin/recharge-tiers/:id', async (req: Request, res: Response) => { try { const tierId = String(req.params.id); const { amount, bonusAmount, tag, sortOrder, status } = req.body; const updated = await repo.updateRechargeTier(tierId, { amount: amount !== undefined ? Number(amount) : undefined, bonusAmount: bonusAmount !== undefined ? Number(bonusAmount) : undefined, tag, sortOrder: sortOrder !== undefined ? Number(sortOrder) : undefined, status: status !== undefined ? Number(status) : undefined }); if (!updated) { return res.status(404).json({ code: 40400, message: '充值阶梯不存在或已被删除' }); } res.json({ code: 20000, data: updated, message: '充值阶梯修改成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台:删除充值阶梯 app.delete('/api/admin/recharge-tiers/:id', async (req: Request, res: Response) => { try { const tierId = String(req.params.id); const ok = await repo.deleteRechargeTier(tierId); if (!ok) { return res.status(404).json({ code: 40400, message: '充值阶梯不存在或已被删除' }); } res.json({ code: 20000, data: null, message: '充值阶梯已删除' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 2. 优惠券接口 // 移动端:领券中心或下单结算可用券列表 app.get('/api/marketing/coupons', async (req: Request, res: Response) => { try { const category = req.query.category ? String(req.query.category) : undefined; const uid = Number(req.query.userId || req.headers['x-user-id'] || 0) || 0; const list = await repo.getCouponsWithClaimed(uid, category); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 领取优惠券(同一用户同一券仅可领一次) app.post('/api/marketing/coupons/:id/claim', async (req: Request, res: Response) => { try { const couponId = Number(req.params.id); const uid = resolveSecureUserId( req, Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0), '领取优惠券' ); const claimed = await repo.claimCoupon(uid, couponId); res.json({ code: 20000, data: claimed, message: '优惠券领取成功' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 我的优惠券(status: UNUSED | USED | EXPIRED,缺省返回全部) app.get('/api/marketing/my-coupons', async (req: Request, res: Response) => { try { const uid = resolveSecureUserId( req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看我的优惠券' ); const status = req.query.status ? String(req.query.status) : undefined; const list = await repo.getUserCoupons(uid, status); const usable = list.filter((c: any) => c.status === 'UNUSED').length; res.json({ code: 20000, data: { list, usable } }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 管理后台:获取全部优惠券 app.get('/api/admin/coupons', async (_req: Request, res: Response) => { try { const list = await repo.getAdminCoupons(); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台:新增优惠券 app.post('/api/admin/coupons', async (req: Request, res: Response) => { try { const { name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status } = req.body; if (!name || !name.trim()) { return res.status(400).json({ code: 40000, message: '优惠券名称不能为空' }); } if (!discountAmount || Number(discountAmount) <= 0) { return res.status(400).json({ code: 40000, message: '减免面额必须大于0' }); } // 微信内容安全检测 const sec = contentSecurityService.checkText(name + ' ' + (description || '')); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } const coupon = await repo.createCoupon({ name: name.trim(), discountAmount: Number(discountAmount), minSpend: minSpend !== undefined ? Number(minSpend) : 0, validDays: validDays !== undefined ? Number(validDays) : 7, totalCount: totalCount !== undefined ? Number(totalCount) : 1000, categoryLimit: categoryLimit || 'ALL', description: description || '', status: status !== undefined ? Number(status) : 1 }); res.json({ code: 20000, data: coupon, message: '优惠券创建成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台:修改优惠券 app.put('/api/admin/coupons/:id', async (req: Request, res: Response) => { try { const couponId = String(req.params.id); const { name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status } = req.body; if (name) { const sec = contentSecurityService.checkText(name + ' ' + (description || '')); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } } const updated = await repo.updateCoupon(couponId, { name: name ? name.trim() : undefined, discountAmount: discountAmount !== undefined ? Number(discountAmount) : undefined, minSpend: minSpend !== undefined ? Number(minSpend) : undefined, validDays: validDays !== undefined ? Number(validDays) : undefined, totalCount: totalCount !== undefined ? Number(totalCount) : undefined, categoryLimit, description, status: status !== undefined ? Number(status) : undefined }); if (!updated) { return res.status(404).json({ code: 40400, message: '优惠券不存在或已被删除' }); } res.json({ code: 20000, data: updated, message: '优惠券修改成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台:删除优惠券 app.delete('/api/admin/coupons/:id', async (req: Request, res: Response) => { try { const couponId = String(req.params.id); const ok = await repo.deleteCoupon(couponId); if (!ok) { return res.status(404).json({ code: 40400, message: '优惠券不存在或已被删除' }); } res.json({ code: 20000, data: null, message: '优惠券已删除' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 3. 平台官方客服、履约公告与规范协议配置接口 // 移动端:获取全部有效系统通用配置 app.get('/api/system/configs', async (_req: Request, res: Response) => { try { const configs = await repo.getPlatformConfigs(); res.json({ code: 20000, data: configs }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台:获取全部配置项列表 app.get('/api/admin/system/configs', async (_req: Request, res: Response) => { try { const list = await repo.getAdminPlatformConfigs(); res.json({ code: 20000, data: list }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); function extractSerialNoFromCertPem(certPem?: string): string { if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) return ''; try { const x509 = new crypto.X509Certificate(certPem.trim()); return (x509.serialNumber || '').toUpperCase().replace(/[^0-9A-F]/g, ''); } catch (_) { return ''; } } // 管理后台:更新指定配置项 app.put('/api/admin/system/configs/:key', async (req: Request, res: Response) => { try { const configKey = String(req.params.key); const { name, valueJson, description, status } = req.body; // 微信内容安全审查(检查文本内容或字符串化后的配置,security_words 黑名单库与微信支付证书配置项除外) if (configKey !== 'security_words' && configKey !== 'wechat_pay_config') { const textToCheck = `${name || ''} ${description || ''} ${JSON.stringify(valueJson || '')}`; const sec = contentSecurityService.checkText(textToCheck); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } } if (configKey === 'wechat_pay_config' && valueJson && typeof valueJson === 'object') { if (!String(valueJson.serialNo || '').trim() && valueJson.certPem) { const autoSerial = extractSerialNoFromCertPem(String(valueJson.certPem)); if (autoSerial) { valueJson.serialNo = autoSerial; } } } // 订阅消息模板ID落库前规范化:剔除零宽字符/全角字符等不可见字符(否则原样发给微信会 40037) if (configKey === 'message_template_rules' && valueJson && typeof valueJson === 'object') { const normId = (v: any) => { const s = normalizeTemplateId(v); return /^tmpl_/i.test(s) ? '' : s; }; valueJson.orderStatusTemplateId = normId(valueJson.orderStatusTemplateId); valueJson.activityJoinTemplateId = normId(valueJson.activityJoinTemplateId); valueJson.withdrawSuccessTemplateId = normId(valueJson.withdrawSuccessTemplateId); valueJson.verifyCodeTemplateId = normId(valueJson.verifyCodeTemplateId); } const updated = await repo.updatePlatformConfig(configKey, { name, valueJson, description, status: status !== undefined ? Number(status) : undefined }); if (!updated) { return res.status(404).json({ code: 40400, message: '配置项不存在' }); } // 若更新的是自定义敏感词库,实时热更新内存过滤引擎 if (configKey === 'security_words' && Array.isArray(valueJson)) { contentSecurityService.setCustomKeywords(valueJson); } res.json({ code: 20000, data: updated, message: '系统配置更新成功' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 创建新订单 (落盘 MySQL) app.post('/api/orders', rateLimit({ name: 'order_create', windowMs: 60 * 1000, max: 20, key: rateWriteKey }), async (req: Request, res: Response) => { try { const buyerId = resolveSecureUserId( req, Number(req.body?.buyerId || req.body?.userId || req.headers['x-user-id'] || 0), '创建订单' ); if (!buyerId) { return res.status(400).json({ code: 40000, message: '缺少下单用户身份,无法创建订单' }); } await assertUserNotBanned(buyerId, '预约陪玩或发单'); const order = await repo.createOrder({ ...req.body, buyerId }); res.json({ code: 20000, data: order, message: '订单已创建并已写入MySQL' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 取消订单 app.post('/api/orders/:id/cancel', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const own = await repo.getOrderOwnership(orderId); if (!own) { return res.status(404).json({ code: 40400, message: '订单不存在' }); } const role = resolveOrderActor(req, own, '取消订单'); if (!role.isAdmin && !role.isBuyer) { return res.status(403).json({ code: 40300, message: '仅买家本人或平台管理员可取消该订单' }); } // 买家取消:按状态安全处理(已付款待接单会原路退款;已进入履约需走退款申诉),避免静默吞掉已付款资金 const order = role.isAdmin ? await repo.updateOrderStatus(orderId, '已取消') : await repo.buyerCancelOrder(orderId); res.json({ code: 20000, data: order, message: '订单已取消' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 支付订单 (支持流转为待接单或履约中) app.post('/api/orders/:id/pay', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); // 归属校验:仅该订单的下单买家本人(或管理员)可确认支付,杜绝替他人订单付款/推进状态 const own = await repo.getOrderOwnership(orderId); if (!own) { return res.status(404).json({ code: 40400, message: '订单不存在' }); } if (!isVerifiedAdminRequest(req)) { const buyerId = Number(own.buyerId || 0); const caller = resolveSecureUserId( req, Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0), '确认订单支付' ); if (buyerId > 0 && caller !== buyerId) { return res.status(403).json({ code: 40300, message: '仅下单买家本人可确认支付该订单' }); } } const cfg = await resolveWechatPayRuntimeConfig(); // 生产 LIVE:必须存在服务端确认的微信支付收款,杜绝前端直接改状态 if (cfg.effectiveMode === 'LIVE' && cfg.isReadyForLive) { const paid = await confirmWechatPayment(cfg, orderId); if (!paid) { return res.status(400).json({ code: 40000, message: '微信支付尚未完成,无法确认订单支付' }); } const order = await repo.updateOrderStatus(orderId, '履约中'); const buyer = Number(own.buyerId || 0); 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 || '履约中'); const allowedSandboxStatuses = ['履约中', '待接单']; if (!allowedSandboxStatuses.includes(requested)) { return res.status(400).json({ code: 40000, message: `非法的目标订单状态: ${requested}` }); } const order = await repo.updateOrderStatus(orderId, requested); const buyer = Number(own.buyerId || 0); pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入${requested}${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list'); void notifyBySubscribe('orderStatus', { userId: buyer, page: '/pages/order/list', data: { character_string1: String(order?.order_no || orderId), phrase2: '已开始服务', thing3: String(order?.service_title || order?.track || '同城陪伴服务'), thing4: order?.verify_code ? `核销码${order.verify_code},请出示` : '请按时到场,感谢支持' } }); res.json({ code: 20000, data: order, message: `支付成功,订单已进入${requested}` }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 解析微信支付运行时配置 (优先读取后台数据库配置 wechat_pay_config,兜底读取环境变量) async function resolveWechatPayRuntimeConfig(overrideCfg?: any) { let dbCfg: any = {}; try { const record = await repo.getPlatformConfigByKey('wechat_pay_config'); if (record && record.valueJson && typeof record.valueJson === 'object') { dbCfg = record.valueJson; } } catch (e) { logServerError('resolveWechatPayRuntimeConfig', e); } const merged = { mode: 'AUTO', appId: process.env.WX_APP_ID || '', appSecret: process.env.WX_APP_SECRET || '', mchId: process.env.WX_MCH_ID || '', apiV3Key: process.env.WX_API_V3_KEY || '', serialNo: process.env.WX_SERIAL_NO || '', privateKey: process.env.WX_PRIVATE_KEY || '', certPem: '', platformPublicKeyId: '', platformPublicKey: '', notifyUrl: 'https://peiww.gynhc.com/api/pay/wechat/notify', remark: '微信支付V3直连商户参数配置', ...dbCfg, ...(overrideCfg || {}) }; const appId = String(merged.appId || '').trim(); const appSecret = String(merged.appSecret || '').trim(); const mchId = String(merged.mchId || '').trim(); const apiV3Key = String(merged.apiV3Key || '').trim(); const certPem = String(merged.certPem || '').trim(); let serialNo = String(merged.serialNo || '').trim(); if (!serialNo && certPem) { serialNo = extractSerialNoFromCertPem(certPem); } const privateKey = String(merged.privateKey || '').trim(); const platformPublicKeyId = String(merged.platformPublicKeyId || '').trim(); const platformPublicKey = String(merged.platformPublicKey || '').trim(); // 商家转账到零钱(提现打款)相关配置 const transferSceneId = String(merged.transferSceneId || '').trim(); const transferRemark = String(merged.transferRemark || '').trim(); const transferNotifyUrl = String(merged.transferNotifyUrl || '').trim(); const refundNotifyUrl = String(merged.refundNotifyUrl || '').trim(); const transferUserNameEnabled = merged.transferUserNameEnabled === true || merged.transferUserNameEnabled === 'true' || Number(merged.transferUserNameEnabled) === 1; const transferSceneReportInfos = Array.isArray(merged.transferSceneReportInfos) ? merged.transferSceneReportInfos : []; const isReadyForLive = Boolean(appId && mchId && apiV3Key.length === 32 && privateKey); const isReadyForTransfer = Boolean(isReadyForLive && transferSceneId); let effectiveMode: 'LIVE' | 'SANDBOX' = 'SANDBOX'; if (merged.mode === 'LIVE') { effectiveMode = isReadyForLive ? 'LIVE' : 'SANDBOX'; } else if (merged.mode === 'SANDBOX') { effectiveMode = 'SANDBOX'; } else { effectiveMode = isReadyForLive ? 'LIVE' : 'SANDBOX'; } return { ...merged, appId, appSecret, mchId, apiV3Key, serialNo, privateKey, certPem, platformPublicKeyId, platformPublicKey, transferSceneId, transferRemark, transferNotifyUrl, refundNotifyUrl, transferUserNameEnabled, transferSceneReportInfos, isReadyForLive, isReadyForTransfer, effectiveMode }; } // 生产环境支付沙箱开关:生产环境默认关闭「沙箱直接入账」,可显式设置 ALLOW_SANDBOX_PAY=1 放行 const IS_PRODUCTION = process.env.NODE_ENV === 'production'; const ALLOW_SANDBOX_PAY = process.env.ALLOW_SANDBOX_PAY === '1' || !IS_PRODUCTION; /** * 确认某笔微信支付已成功:优先读本地意图,其次主动向微信支付查单(含短暂重试), * 确认后落库对账。返回是否已确认收款。用于前端支付完成后的服务端二次确认。 */ async function confirmWechatPayment(cfg: any, outTradeNo: string): Promise { if (!outTradeNo) return false; for (let i = 0; i < 3; i++) { const intent = await repo.getPaymentIntentByOutTradeNo(outTradeNo); if (intent && intent.status === 'SUCCESS') return true; try { const q = await queryOrderByOutTradeNo(cfg, outTradeNo); if (q && q.trade_state === 'SUCCESS') { await repo.applyPaymentSuccess(outTradeNo, { transactionId: q.transaction_id, amountTotalCents: q.amount ? Number(q.amount.total) : undefined, }); return true; } } catch (e) { logServerError('confirmWechatPayment.query', e); } if (i < 2) await new Promise((r) => setTimeout(r, 600)); } const finalIntent = await repo.getPaymentIntentByOutTradeNo(outTradeNo); return Boolean(finalIntent && finalIntent.status === 'SUCCESS'); } // 管理后台:解析商户证书 apiclient_cert.pem 自动提取 40 位证书序列号 app.post('/api/admin/pay/wechat/parse-cert', (req: Request, res: Response) => { try { const certPem = String(req.body?.certPem || '').trim(); if (!certPem || !certPem.includes('BEGIN CERTIFICATE')) { return res.status(400).json({ code: 40000, message: '请提供有效的 PEM 格式证书内容 (apiclient_cert.pem)' }); } const x509 = new crypto.X509Certificate(certPem); const serialNo = (x509.serialNumber || '').toUpperCase().replace(/[^0-9A-F]/g, ''); res.json({ code: 20000, data: { serialNo, subject: x509.subject, validFrom: x509.validFrom, validTo: x509.validTo }, message: `已成功从证书提取序列号: ${serialNo}` }); } catch (err: any) { res.status(400).json({ code: 40000, message: `解析证书失败: ${err.message}` }); } }); // 管理后台:获取微信支付商户号与证书配置 app.get('/api/admin/pay/wechat/config', async (_req: Request, res: Response) => { try { const cfg = await resolveWechatPayRuntimeConfig(); res.json({ code: 20000, data: cfg }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理后台:保存微信支付商户号与证书配置 app.post('/api/admin/pay/wechat/config', async (req: Request, res: Response) => { try { const incoming = req.body?.valueJson || req.body || {}; const certPem = String(incoming.certPem || '').trim(); let serialNo = String(incoming.serialNo || '').trim(); if (!serialNo && certPem) { serialNo = extractSerialNoFromCertPem(certPem); } const payload = { mode: incoming.mode || 'AUTO', appId: String(incoming.appId || '').trim(), appSecret: String(incoming.appSecret || '').trim(), mchId: String(incoming.mchId || '').trim(), apiV3Key: String(incoming.apiV3Key || '').trim(), serialNo, privateKey: String(incoming.privateKey || '').trim(), certPem, platformPublicKeyId: String(incoming.platformPublicKeyId || '').trim(), platformPublicKey: String(incoming.platformPublicKey || '').trim(), notifyUrl: String(incoming.notifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/notify').trim(), transferSceneId: String(incoming.transferSceneId || '').trim(), transferRemark: String(incoming.transferRemark || '').trim(), transferNotifyUrl: String(incoming.transferNotifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/transfer-notify').trim(), refundNotifyUrl: String(incoming.refundNotifyUrl || 'https://peiww.gynhc.com/api/pay/wechat/refund-notify').trim(), transferUserNameEnabled: incoming.transferUserNameEnabled === true || incoming.transferUserNameEnabled === 'true' || Number(incoming.transferUserNameEnabled) === 1, transferSceneReportInfos: Array.isArray(incoming.transferSceneReportInfos) ? incoming.transferSceneReportInfos : [], remark: String(incoming.remark || '微信支付V3直连商户参数配置').trim() }; await repo.updatePlatformConfig('wechat_pay_config', { name: '微信支付商户号与证书配置', description: '微信支付V3商户号、APIv3密钥与证书参数配置', status: 1, valueJson: payload }); const latest = await resolveWechatPayRuntimeConfig(); res.json({ code: 20000, data: latest, message: latest.effectiveMode === 'LIVE' ? '微信支付配置已保存,已激活实盘支付模式 (LIVE)' : '微信支付配置已保存,当前运行于沙箱收银台模式 (SANDBOX)' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理后台:微信支付商户参数完整性与 RSA 签名自检 app.post('/api/admin/pay/wechat/test', async (req: Request, res: Response) => { try { const cfg = await resolveWechatPayRuntimeConfig(req.body || {}); const checks: Array<{ item: string; pass: boolean; detail: string }> = []; checks.push({ item: '小程序 AppID (appId)', pass: /^wx[a-zA-Z0-9]{10,24}$/.test(cfg.appId), detail: cfg.appId ? `已填写 (${cfg.appId})` : '未填写(生产环境需填写 wx 开头的小程序 AppID)' }); checks.push({ item: '小程序 AppSecret (appSecret)', pass: /^[0-9a-fA-F]{32}$/.test(cfg.appSecret), detail: cfg.appSecret ? `已填写 (${cfg.appSecret.slice(0, 6)}...${cfg.appSecret.slice(-4)})` : '未填写(需在微信公众平台-开发管理中获取32位AppSecret)' }); checks.push({ item: '微信支付商户号 (mchId)', pass: /^\d{8,16}$/.test(cfg.mchId), detail: cfg.mchId ? `已填写 (${cfg.mchId})` : '未填写(需填写 10 位微信支付直连商户号)' }); checks.push({ item: 'APIv3 密钥 (apiV3Key)', pass: cfg.apiV3Key.length === 32, detail: cfg.apiV3Key ? `当前长度 ${cfg.apiV3Key.length} 位 ${cfg.apiV3Key.length === 32 ? '(符合32位标准)' : '(需严格为32位字符)'}` : '未填写(需在微信商户平台-API安全中设置32位密钥)' }); checks.push({ item: '商户证书序列号 (serialNo)', pass: /^[0-9A-Fa-f]{16,64}$/.test(cfg.serialNo), detail: cfg.serialNo ? `已就绪 (${cfg.serialNo})` : '未填写(可手动填入或上传 apiclient_cert.pem 自动提取)' }); let signPass = false; let samplePaySign = ''; let signDetail = '未提供商户私钥 apiclient_key.pem'; if (cfg.privateKey) { try { const message = `${cfg.appId || 'wx_test'}\n${Math.floor(Date.now() / 1000)}\ntest_nonce\nprepay_id=wx_test\n`; const signer = crypto.createSign('RSA-SHA256'); signer.update(message); samplePaySign = signer.sign(cfg.privateKey, 'base64'); signPass = Boolean(samplePaySign); signDetail = `RSA-SHA256 签名验证通过 (签名摘要: ${samplePaySign.slice(0, 24)}...)`; } catch (e: any) { signDetail = `私钥 PEM 格式解析或签名失败: ${e.message}`; } } checks.push({ item: '商户 API 私钥 (apiclient_key.pem)', pass: signPass, detail: signDetail }); if (cfg.platformPublicKey || cfg.platformPublicKeyId) { let pubKeyPass = false; let pubKeyDetail = ''; if (cfg.platformPublicKey) { try { crypto.createPublicKey(cfg.platformPublicKey); pubKeyPass = true; pubKeyDetail = `微信支付平台公钥校验通过${cfg.platformPublicKeyId ? ` (ID: ${cfg.platformPublicKeyId})` : '(建议同时填写公钥ID)'}`; } catch (e: any) { pubKeyDetail = `微信支付平台公钥 PEM 格式无效: ${e.message}`; } } else { pubKeyDetail = `已填写公钥 ID (${cfg.platformPublicKeyId}),待上传 pub_key.pem 公钥内容`; } checks.push({ item: '微信支付平台公钥 (pub_key.pem)', pass: pubKeyPass, detail: pubKeyDetail }); } res.json({ code: 20000, data: { isReadyForLive: cfg.isReadyForLive && signPass, effectiveMode: cfg.isReadyForLive && signPass && cfg.mode !== 'SANDBOX' ? 'LIVE' : 'SANDBOX', serialNo: cfg.serialNo, checks, samplePaySign }, message: '微信支付配置自检完成' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 微信小程序 jscode2session 登录与换取真实 openid 接口 app.post('/api/wechat/code2session', async (req: Request, res: Response) => { try { const { code, userId } = req.body || {}; if (!code) { return res.status(400).json({ code: 40000, message: '微信登录凭证 code 不能为空' }); } const cfg = await resolveWechatPayRuntimeConfig(); const appId = String(cfg.appId || '').trim(); const appSecret = String(cfg.appSecret || '').trim(); if (!appId || !appSecret) { return res.status(400).json({ code: 40000, message: '服务端未配置小程序 AppID/AppSecret,无法换取 openid' }); } const wxUrl = `https://api.weixin.qq.com/sns/jscode2session?appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}&js_code=${encodeURIComponent(code)}&grant_type=authorization_code`; const wxData: any = await new Promise((resolve, reject) => { https.get(wxUrl, (resp) => { let data = ''; resp.on('data', chunk => { data += chunk; }); resp.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } }); }).on('error', reject); }); if (wxData.errcode) { return res.status(400).json({ code: 40000, message: `微信接口换取 openid 失败: [${wxData.errcode}] ${wxData.errmsg}`, data: wxData }); } const { openid, unionid } = wxData; if (openid && userId) { try { // 绑定 openid 必须与会话身份一致,杜绝把他人 openid 关联到指定 userId 造成账号劫持 const claimed = resolveSecureUserId(req, Number(userId), '绑定微信账户'); await repo.updateUserOpenid(claimed, openid); } catch (e) { logServerError('login.updateUserOpenid', e); } } res.json({ code: 20000, data: { openid, unionid }, message: '微信 openid 获取成功' }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 内存缓存微信全局 access_token let cachedWechatAccessToken = { token: '', expireAt: 0 }; async function getWechatAccessToken(appId: string, appSecret: string): Promise { if (cachedWechatAccessToken.token && Date.now() < cachedWechatAccessToken.expireAt) { return cachedWechatAccessToken.token; } const tokenUrl = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${encodeURIComponent(appId)}&secret=${encodeURIComponent(appSecret)}`; const tokenData: any = await new Promise((resolve, reject) => { https.get(tokenUrl, (resp) => { let data = ''; resp.on('data', chunk => { data += chunk; }); resp.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } }); }).on('error', reject); }); if (tokenData.errcode) { throw new Error(`获取微信 access_token 失败: [${tokenData.errcode}] ${tokenData.errmsg}`); } const token = tokenData.access_token; const expiresIn = Number(tokenData.expires_in || 7200); cachedWechatAccessToken = { token, expireAt: Date.now() + Math.max(60, expiresIn - 300) * 1000 // 提前5分钟刷新 }; return token; } /** * 微信订阅消息(服务通知)下发。 * 仅当后台开启推送且配置了对应模板ID、且目标用户有 openid 时真实下发; * 失败仅记录日志,绝不阻塞/影响主业务。 */ // 微信 time 类型字段建议格式:YYYY年MM月DD日 HH:mm function formatCnTime(d: Date = new Date()): string { const p = (n: number) => String(n).padStart(2, '0'); return `${d.getFullYear()}年${p(d.getMonth() + 1)}月${p(d.getDate())}日 ${p(d.getHours())}:${p(d.getMinutes())}`; } // 将任意时间字符串转为微信 time 字段可接受的格式;非标准日期(如"今晚")则回退为当前时间,避免 47003 function toCnTimeOrNow(value?: string): string { const s = String(value || '').trim(); if (!s) return formatCnTime(); const looksDateLike = /\d{4}[-/年]\d{1,2}|\d{1,2}:\d{2}/.test(s); if (!looksDateLike) return formatCnTime(); const d = new Date(s.replace(/年|月/g, '/').replace(/日/g, ' ').trim()); if (isNaN(d.getTime())) return formatCnTime(); return formatCnTime(d); } // 站内消息落库(fire-and-forget,绝不影响主业务;与微信订阅消息并行) function pushUserNotification(userId: number, type: string, title: string, content: string, link = ''): void { const uid = Number(userId) || 0; if (uid <= 0) return; void repo .createUserNotification({ userId: uid, type, title, content, link }) .catch((e: any) => logServerError('pushUserNotification', e)); } async function notifyBySubscribe( templateKey: 'orderStatus' | 'activityJoin' | 'withdrawSuccess' | 'verifyCode', opts: { userId: number; page?: string; data: Record } ): Promise { 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 = { orderStatus: 'orderStatusTemplateId', activityJoin: 'activityJoinTemplateId', withdrawSuccess: 'withdrawSuccessTemplateId', verifyCode: 'verifyCodeTemplateId', }; const templateId = normalizeTemplateId(rules[keyMap[templateKey]]); if (!templateId || /^tmpl_/i.test(templateId)) return; const payCfg = await resolveWechatPayRuntimeConfig(); const appId = String(payCfg.appId || '').trim(); const appSecret = String(payCfg.appSecret || '').trim(); if (!appId || !appSecret) return; const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [uid]); const openid = uRows && uRows[0] && uRows[0].wechat_openid; if (!openid) return; const accessToken = await getWechatAccessToken(appId, appSecret); const result = await sendSubscribeMessageHttp(accessToken, { touser: String(openid), template_id: templateId, page: opts.page, data: buildTemplateData(opts.data || {}), miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer', }); 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 兜底(仅开发联调) res.json({ code: 20000, data: { mode: 'SANDBOX', appId, timeStamp: String(Math.floor(Date.now() / 1000)), nonceStr: `pw_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`, package: `prepay_id=sandbox_${Date.now()}`, signType: 'RSA', paySign: 'SANDBOX_SIGN', outTradeNo, amount: payAmount.toFixed(2), title: desc, scene, }, message: '预支付交易单生成成功(沙箱联调模式)', }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 微信支付 V3 异步结果通知回调接口 (验签 + APIv3 解密 + 幂等入账) app.post('/api/pay/wechat/notify', async (req: Request, res: Response) => { try { const cfg = await resolveWechatPayRuntimeConfig(); const rawBody = (req as any).rawBody || JSON.stringify(req.body || {}); const timestamp = String(req.headers['wechatpay-timestamp'] || ''); const nonce = String(req.headers['wechatpay-nonce'] || ''); const signature = String(req.headers['wechatpay-signature'] || ''); const serial = String(req.headers['wechatpay-serial'] || ''); // 沙箱联调:无签名头时按模拟回调处理(仅非生产环境或显式放行) if (!signature) { if (cfg.effectiveMode === 'SANDBOX' && ALLOW_SANDBOX_PAY) { const { outTradeNo, tradeState } = req.body || {}; if (outTradeNo && tradeState === 'SUCCESS') { await repo.applyPaymentSuccess(String(outTradeNo)); } return res.json({ code: 'SUCCESS', message: '成功' }); } return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' }); } if (!cfg.apiV3Key) { return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' }); } const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial }); if (!verified) { return res.status(401).json({ code: 'FAIL', message: '微信支付回调验签失败' }); } const payload: any = req.body || {}; const resource = payload.resource; if (resource && resource.ciphertext) { const plain = decryptNotifyResource(cfg.apiV3Key, resource); if (plain && plain.trade_state === 'SUCCESS' && plain.out_trade_no) { await repo.applyPaymentSuccess(String(plain.out_trade_no), { transactionId: plain.transaction_id, amountTotalCents: plain.amount ? Number(plain.amount.total) : undefined, }); } } res.json({ code: 'SUCCESS', message: '成功' }); } catch (err: any) { res.status(500).json({ code: 'FAIL', message: err.message }); } }); // 微信「商家转账到零钱」结果回调(验签 + 解密 + 幂等落库) app.post('/api/pay/wechat/transfer-notify', async (req: Request, res: Response) => { try { const cfg = await resolveWechatPayRuntimeConfig(); const rawBody = (req as any).rawBody || JSON.stringify(req.body || {}); const timestamp = String(req.headers['wechatpay-timestamp'] || ''); const nonce = String(req.headers['wechatpay-nonce'] || ''); const signature = String(req.headers['wechatpay-signature'] || ''); const serial = String(req.headers['wechatpay-serial'] || ''); if (!signature) { return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' }); } if (!cfg.apiV3Key) { return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' }); } const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial }); if (!verified) { return res.status(401).json({ code: 'FAIL', message: '转账回调验签失败' }); } const payload: any = req.body || {}; const resource = payload.resource; if (resource && resource.ciphertext) { const plain = decryptNotifyResource(cfg.apiV3Key, resource); const outBillNo = plain && (plain.out_bill_no || plain.outBillNo); const state = plain && (plain.state || plain.transfer_state); if (outBillNo && state) { const r: any = await repo.finalizeWithdrawalTransfer(String(outBillNo), String(state)); if (r && r.status === '已打款' && r.userId) { pushUserNotification(Number(r.userId), 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(r.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records'); void notifyBySubscribe('withdrawSuccess', { userId: Number(r.userId), page: '/pages/user/withdraw-records', data: { amount2: Number(r.amount || 0).toFixed(2), phrase5: '已到账', time7: formatCnTime(), amount8: Number(r.amount || 0).toFixed(2) } }); } } } res.json({ code: 'SUCCESS', message: '成功' }); } catch (err: any) { res.status(500).json({ code: 'FAIL', message: err.message }); } }); // 微信支付「退款」结果回调(验签 + 解密 + 幂等更新退款记录) app.post('/api/pay/wechat/refund-notify', async (req: Request, res: Response) => { try { const cfg: any = await resolveWechatPayRuntimeConfig(); const rawBody = (req as any).rawBody || JSON.stringify(req.body || {}); const timestamp = String(req.headers['wechatpay-timestamp'] || ''); const nonce = String(req.headers['wechatpay-nonce'] || ''); const signature = String(req.headers['wechatpay-signature'] || ''); const serial = String(req.headers['wechatpay-serial'] || ''); if (!signature) { return res.status(401).json({ code: 'FAIL', message: '缺少微信支付签名头' }); } if (!cfg.apiV3Key) { return res.status(500).json({ code: 'FAIL', message: '未配置 APIv3 密钥,无法解密回调' }); } const verified = verifyNotifySignature({ cfg, timestamp, nonce, body: rawBody, signature, serial }); if (!verified) { return res.status(401).json({ code: 'FAIL', message: '退款回调验签失败' }); } const payload: any = req.body || {}; const resource = payload.resource; if (resource && resource.ciphertext) { const plain = decryptNotifyResource(cfg.apiV3Key, resource); const outRefundNo = plain && (plain.out_refund_no || plain.outRefundNo); const refundStatus = plain && (plain.refund_status || plain.status); if (outRefundNo && refundStatus) { const existing = await repo.getRefundByOutRefundNo(String(outRefundNo)); await repo.upsertRefundRecord({ outRefundNo: String(outRefundNo), outTradeNo: String((plain && plain.out_trade_no) || (existing && existing.out_trade_no) || ''), orderId: String((existing && existing.order_id) || ''), refundId: String((plain && plain.refund_id) || (existing && existing.refund_id) || ''), refundCents: Number((existing && existing.refund_cents) || 0), totalCents: Number((existing && existing.total_cents) || 0), status: String(refundStatus), reason: String((existing && existing.reason) || ''), }); } } res.json({ code: 'SUCCESS', message: '成功' }); } catch (err: any) { res.status(500).json({ code: 'FAIL', message: err.message }); } }); // 大神拒单自动全额退款 app.post('/api/orders/:id/reject', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const own = await repo.getOrderOwnership(orderId); if (!own) { return res.status(404).json({ code: 40400, message: '订单不存在' }); } const role = resolveOrderActor(req, own, '拒单'); if (!role.isAdmin && !role.isCompanion) { return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可拒单' }); } const order = await repo.rejectOrder(orderId, req.body); pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '订单已被拒单', `订单 ${orderId} 已被大神拒单,款项将原路退回。`, '/pages/order/list'); res.json({ code: 20000, data: order, message: '已拒单,订单款项已自动原路退还买家' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口) app.post('/api/orders/:id/apply-complete', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const own = await repo.getOrderOwnership(orderId); if (!own) { return res.status(404).json({ code: 40400, message: '订单不存在' }); } const role = resolveOrderActor(req, own, '申请完成服务'); if (!role.isAdmin && !role.isCompanion) { return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可申请完成服务' }); } const order = await repo.applyCompleteOrder(orderId); pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '大神申请完成服务', `订单 ${orderId} 大神已申请完成服务,请及时确认验收(48 小时未处理将自动确认)。`, '/pages/order/list'); res.json({ code: 20000, data: order, message: '已发起完成服务申请,等待买家确认验收(48小时自动确认)' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 买家确认完成验收 (待确认 -> 已完成,触发资金解冻结算) app.post('/api/orders/:id/complete', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const own = await repo.getOrderOwnership(orderId); if (!own) { return res.status(404).json({ code: 40400, message: '订单不存在' }); } const role = resolveOrderActor(req, own, '确认完成订单'); if (!role.isAdmin && !role.isBuyer && !role.isCompanion) { return res.status(403).json({ code: 40300, message: '仅交易双方或平台管理员可完成该订单' }); } const order = await repo.updateOrderStatus(orderId, '已完成'); pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '订单已完成', `订单 ${orderId} 已完成,感谢使用,欢迎评价。`, '/pages/order/list'); const companionIds: number[] = Array.isArray(own?.companionIds) ? own.companionIds : []; companionIds.forEach((cid) => pushUserNotification(Number(cid) || 0, 'ORDER_STATUS', '订单已完成结算', `订单 ${orderId} 已完成,佣金将按平台规则结算入账。`, '/pages/expert/workspace') ); res.json({ code: 20000, data: order, message: '订单已确认完成' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 扫描处理超时订单 (15m未支付自动取消 / 30m未接单自动退款 / 48h待确认自动完成) // 订单超时自动流转扫描 (自动取消/自动退款/自动完成结算):仅管理员可手动触发 app.post('/api/orders/process-timeouts', requireAdmin, async (_req: Request, res: Response) => { try { const result = await repo.processOrderTimeouts(); res.json({ code: 20000, data: result, message: '订单超时自动流转扫描完毕' }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 申请退款/售后 (写入仲裁工单:退款理由 + 详细说明 + 凭证截图) app.post('/api/orders/:id/refund', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const own = await repo.getOrderOwnership(orderId); if (!own) { return res.status(404).json({ code: 40400, message: '订单不存在' }); } const role = resolveOrderActor(req, own, '发起退款申诉'); if (!role.isAdmin && !role.isBuyer) { return res.status(403).json({ code: 40300, message: '仅买家本人或平台管理员可发起退款申诉' }); } const { reason, description, evidence } = req.body || {}; if (description) { const sec = contentSecurityService.checkText(String(description)); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } } const order = await repo.applyOrderRefund(orderId, { reason, description, evidence }); pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '退款申诉已受理', `订单 ${orderId} 的退款申诉已受理,进入争议仲裁流程。`, '/pages/user/reports'); res.json({ code: 20000, data: order, message: '退款申诉已受理,进入争议仲裁流程' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 举报不良陪玩 (涉黄/涉赌/线下欺诈等,进入平台风控处置池) app.post('/api/reports', rateLimit({ name: 'report_create', windowMs: 60 * 1000, max: 5, key: rateWriteKey }), async (req: Request, res: Response) => { try { const { reporterId, targetType, targetId, targetName, reason, description, evidence } = req.body || {}; if (!targetId || !reason) { return res.status(400).json({ code: 40000, message: '举报对象与举报类型不能为空' }); } if (description) { const sec = contentSecurityService.checkText(String(description)); if (!sec.pass) { return res.status(400).json({ code: sec.code, message: sec.message }); } } const report = await repo.createReport({ reporterId: resolveSecureUserId(req, Number(reporterId || req.headers['x-user-id'] || 0), '提交举报'), targetType, targetId: String(targetId), targetName, reason: String(reason), description, evidence }); res.json({ code: 20000, data: report, message: '举报已提交,平台将在 24 小时内核实处理' }); } catch (err: any) { const statusCode = err.status || 500; const errCode = err.code || 50000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.get('/api/reports', async (req: Request, res: Response) => { try { // 管理端可取全量;普通用户仅能查看自己提交的举报工单 if (isVerifiedAdminRequest(req)) { const reporterId = req.query.reporterId ? Number(req.query.reporterId) : undefined; return res.json({ code: 20000, data: await repo.getReports(reporterId) }); } const uid = resolveSecureUserId(req, Number(req.query.reporterId || req.headers['x-user-id'] || 0), '查看我的举报'); const list = await repo.getReports(uid); res.json({ code: 20000, data: list }); } catch (err: any) { const statusCode = err.status || 500; const errCode = err.code || 50000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 管理员处置不良违规举报工单 app.post('/api/reports/:id/handle', requireAdmin, async (req: Request, res: Response) => { try { const { id } = req.params; const { action, handleNote, penaltyOptions } = req.body || {}; if (!action || !['punish', 'reject'].includes(action)) { return res.status(400).json({ code: 40000, message: '请指定合法的处理动作 (punish 或 reject)' }); } const result = await repo.handleReport(String(id), action, handleNote, penaltyOptions); res.json({ code: 20000, data: result, message: action === 'punish' ? '已核实举报并执行违规处置' : '已驳回该违规举报工单' }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.get('/api/arbitrations', async (_req: Request, res: Response) => { try { const arbitrations = await repo.getArbitrations(); res.json({ code: 20000, data: arbitrations }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.post('/api/arbitrations/:id/handle', async (req: Request, res: Response) => { try { const arbId = String(req.params.id); const { decision, refundAmount, operatorRole } = req.body; // 'refund_full' | 'refund_partial' | 'reject' | 'deduct_credit' const headerRole = req.headers['x-user-role']; const callerRole = operatorRole || headerRole || 'admin'; if (callerRole === 'user' || callerRole === 'expert' || callerRole === 'PLAYER' || callerRole === 'EXPERT') { return res.status(403).json({ code: 40300, message: '权限不足:买家与大神不可越权执行管理员仲裁' }); } const result = await repo.handleArbitration(arbId, decision, refundAmount !== undefined ? Number(refundAmount) : undefined); res.json({ code: 20000, data: result, message: `仲裁处理完毕: ${result.status}` }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 6. 财务与分销管理接口 (落盘 MySQL) app.get('/api/finance/records', async (_req: Request, res: Response) => { try { const records = await repo.getFinanceRecords(); res.json({ code: 20000, data: records }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.get('/api/finance/withdrawals', async (_req: Request, res: Response) => { try { const withdrawals = await repo.getWithdrawals(); res.json({ code: 20000, data: withdrawals }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Response) => { try { const wdId = String(req.params.id); const { action, rejectReason } = req.body; // 'approve' | 'reject' if (!['approve', 'reject'].includes(String(action))) { return res.status(400).json({ code: 40000, message: 'action 必须为 approve 或 reject' }); } // 驳回:退还预扣佣金 if (action === 'reject') { const result = await repo.reviewWithdrawal(wdId, 'reject', rejectReason); return res.json({ code: 20000, data: result, message: `提现驳回: ${rejectReason || '风控核验未通过'}` }); } // 通过:按运行模式决定是否真实发起「微信商家转账到零钱」 const cfg = await resolveWechatPayRuntimeConfig(); const wd = await repo.getWithdrawalForTransfer(wdId); if (!wd) { return res.status(404).json({ code: 40400, message: '提现工单不存在' }); } if (wd.status !== '待审核') { return res.status(409).json({ code: 40900, message: `当前提现工单已处理,状态为【${wd.status}】` }); } const useRealTransfer = cfg.effectiveMode === 'LIVE' && cfg.isReadyForTransfer; if (useRealTransfer) { if (!wd.userOpenid) { return res.status(400).json({ code: 40000, message: '该大神缺少微信 openid,无法商家转账到零钱;请其重新微信登录授权后再打款' }); } const amountCents = Math.round(Number(wd.amount) * 100); if (!amountCents || amountCents <= 0) { return res.status(400).json({ code: 40000, message: '提现金额无效' }); } const outBillNo = String(wd.withdraw_no || wd.id); let bill: any; try { bill = await transferToWallet(cfg, { outBillNo, openid: String(wd.userOpenid), amountCents, sceneId: cfg.transferSceneId, remark: cfg.transferRemark || '佣金提现', notifyUrl: cfg.transferNotifyUrl || undefined, userName: cfg.transferUserNameEnabled ? (wd.payee_name || undefined) : undefined, sceneReportInfos: Array.isArray(cfg.transferSceneReportInfos) ? cfg.transferSceneReportInfos : undefined, }); } catch (e: any) { return res.status(400).json({ code: 40000, message: `微信商家转账失败:${e.message}` }); } const state = String(bill.state || 'ACCEPTED'); const billNo = String(bill.out_bill_no || outBillNo); if (state === 'SUCCESS') { const r = await repo.markWithdrawalTransfer(wdId, billNo, '已打款', 'SUCCESS'); pushUserNotification(Number(wd.companion_id) || 0, 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records'); void notifyBySubscribe('withdrawSuccess', { userId: Number(wd.companion_id) || 0, page: '/pages/user/withdraw-records', data: { amount2: Number(wd.amount || 0).toFixed(2), phrase5: '已到账', time7: formatCnTime(), amount8: Number(wd.amount || 0).toFixed(2) } }); return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款成功,款项已进入大神微信零钱' }); } const r = await repo.markWithdrawalTransfer(wdId, billNo, '处理中', state); return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款已受理(微信处理中),到账以后端最终状态为准' }); } // 未开启真实打款:生产环境拒绝(避免"只改状态、不发钱"的误导);非生产保留沙箱仅改状态 if (IS_PRODUCTION && !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/users/:id/status', requireAdmin, async (req: Request, res: Response) => { try { const userId = Number(req.params.id); const { status } = req.body; if (!status || !['正常', '已封禁'].includes(status)) { return res.status(400).json({ code: 40000, message: '无效的状态参数' }); } const updated = await repo.updateUserStatus(userId, status); res.json({ code: 20000, data: updated, message: `用户状态已更新为: ${status}` }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); app.delete('/api/users/:id', requireAdmin, async (req: Request, res: Response) => { try { const userId = Number(req.params.id); if (!userId || isNaN(userId)) { return res.status(400).json({ code: 40000, message: '无效的用户ID' }); } const success = await repo.deleteUser(userId); if (!success) { return res.status(404).json({ code: 40400, message: '用户不存在或已被删除' }); } res.json({ code: 20000, data: { id: userId }, message: '用户档案及关联数据已成功彻底删除' }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.post('/api/users/batch-delete', requireAdmin, async (req: Request, res: Response) => { try { const { ids } = req.body; if (!Array.isArray(ids) || ids.length === 0) { return res.status(400).json({ code: 40000, message: '请提供要删除的用户ID列表' }); } const numIds = ids.map((id: any) => Number(id)).filter((id: number) => !isNaN(id) && id > 0); const count = await repo.deleteUsers(numIds); res.json({ code: 20000, data: { count }, message: `已成功批量删除 ${count} 位用户` }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 7.2 用户360度全景综合数据看板 (涵盖活动/核销码/订单/财务流水/风控评级) app.get('/api/users/:id/detail', requireAdmin, async (req: Request, res: Response) => { try { const userId = Number(req.params.id); const detail = await repo.getUserDetail(userId); if (!detail) { return res.status(404).json({ code: 40400, message: '用户不存在' }); } res.json({ code: 20000, data: detail }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 7.2.1 移动端用户资产与基本信息 app.get('/api/users/:id', async (req: Request, res: Response) => { try { const userId = Number(req.params.id); let user = await repo.getUserById(userId); if (!user) { if (userId === 101) { user = { id: 101, userNo: 'U101', nickname: '糖心小糯米', avatar: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=200', phone: '138****0101', gender: '女', signature: '人美声甜,擅长各类同城陪伴与开黑互动', role: '认证大神', balance: '2580.00', commission: '128.50', invitedCount: 6, orderCount: 88, activityCount: 16, isExpert: true, status: '正常' }; } else if (userId === 2006) { user = { id: 2006, userNo: 'U2006', nickname: '苏苏学姐', avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=120', phone: '139****2006', gender: '女', signature: '工体纯K量贩欢唱局!甜美情歌、粤语老歌、摇滚气氛拉满', role: '认证大神', balance: '5690.00', commission: '560.00', invitedCount: 12, orderCount: 120, activityCount: 35, isExpert: true, status: '正常' }; } else { return res.status(404).json({ code: 40400, message: '用户未找到' }); } } // 敏感字段(手机号 / 余额 / 佣金 / 邀请关系)仅本人或管理员可见 const isAdmin = isVerifiedAdminRequest(req); let isSelf = false; if (!isAdmin) { if (!IS_PRODUCTION) { // 本地联调兼容:非生产默认按本人视角返回 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 { 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) { 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, '申请提现'); 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 userId = 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 { 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 = { 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 = { 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 = { orderStatus: 'orderStatusTemplateId', activityJoin: 'activityJoinTemplateId', withdrawSuccess: 'withdrawSuccessTemplateId', verifyCode: 'verifyCodeTemplateId', }; const rawTemplateId = String(rules[idMap[tKey]] || ''); // 规范化后判断:过滤占位符 tmpl_xxx,并剔除不可见字符 const normalizedTemplateId = normalizeTemplateId(rawTemplateId); const isPlaceholder = /^tmpl_/i.test(normalizedTemplateId); const templateId = isPlaceholder ? '' : normalizedTemplateId; const enablePush = Number(rules.enablePush) === 1; const cfg = await resolveWechatPayRuntimeConfig(); const [uRows]: any = await pool.query('SELECT wechat_openid FROM users WHERE id = ? LIMIT 1', [Number(targetUserId) || 0]); const openid = uRows && uRows[0] && uRows[0].wechat_openid; const diagnostics = { enablePush, hasTemplateId: Boolean(templateId), hasOpenid: Boolean(openid), hasAppId: Boolean(cfg.appId), hasAppSecret: Boolean(cfg.appSecret), appIdMasked: maskTail(cfg.appId), }; if (templateId && openid && cfg.appId && cfg.appSecret) { let accessToken = ''; try { accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret); } catch (e: any) { return res.json({ code: 40000, data: { mode: 'REAL_FAILED', templateType: tKey, templateId, targetUserId, diagnostics, error: 'ACCESS_TOKEN_FAILED' }, message: `获取微信 access_token 失败:${(e && e.message) || e}(请核对小程序 appId/appSecret)` }); } // 与所选模板字段对齐的示例数据(字段名必须与模板一致,否则 47003) const sampleData: Record> = { 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: IS_PRODUCTION ? 'formal' : 'developer', }); 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 = { orderStatus: '订单履约状态变动', activityJoin: '组局搭子报名与成局', withdrawSuccess: '大神佣金提现打款到账', verifyCode: '现场4位核销码签到提醒', }; const rawDisplay = String(rawTemplateId).trim(); const reasonDetail = !rawDisplay ? '' : (isPlaceholder ? `(当前值为占位符 ${normalizedTemplateId})` : `(当前值 ${rawDisplay.slice(0, 64)} 不是有效的微信模板ID)`); const reason = !enablePush ? '订阅消息总开关未开启' : (!templateId ? `未配置「${typeLabels[tKey] || tKey}」模板ID${reasonDetail}${tKey === 'verifyCode' ? '(该类型为可选:核销码已随「订单状态提醒」的温馨提示下发)' : ''}` : (!openid ? `目标用户(ID=${targetUserId || 0})没有 wechat_openid(该用户需通过微信登录过小程序)` : '小程序 appId/appSecret 未配置')); const result = { templateType: tKey, targetUserId: targetUserId || 1001, sendTime: new Date().toISOString(), status: 'SIMULATED', wechatMsgId: `wx_msg_${Date.now()}`, previewData: previewData || { title: '订单履约提醒', detail: '您的同城生活陪伴订单已开始服务' } }; res.json({ code: 20000, data: { mode: 'SIMULATED', reason, diagnostics, ...result }, message: `未真实下发(${reason})` }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 管理端:拉取当前小程序已存在的「订阅消息」模板列表 // 用于核对后台填写的模板ID是否属于当前 appId(40037 invalid template_id 的根因排查) app.get('/api/admin/message/templates', async (_req: Request, res: Response) => { try { const cfg = await resolveWechatPayRuntimeConfig(); if (!cfg.appId || !cfg.appSecret) { return res.json({ code: 40000, message: '小程序 AppID/AppSecret 未配置:请在后台「平台配置 → 微信支付商户配置」填写本小程序的 AppID 与 32 位 AppSecret(或设置环境变量 WX_APP_ID/WX_APP_SECRET),保存后重试', }); } const accessToken = await getWechatAccessToken(cfg.appId, cfg.appSecret); const data = await wechatHttpGetJson( `https://api.weixin.qq.com/wxaapi/newtmpl/gettemplate?access_token=${encodeURIComponent(accessToken)}` ); const list = data && Array.isArray(data.data) ? data.data : []; const record = await repo.getPlatformConfigByKey('message_template_rules'); const rules = (record && record.valueJson) || {}; const clean = (v: any) => normalizeTemplateId(v); const configured = { orderStatus: clean(rules.orderStatusTemplateId), activityJoin: clean(rules.activityJoinTemplateId), withdrawSuccess: clean(rules.withdrawSuccessTemplateId), verifyCode: clean(rules.verifyCodeTemplateId), }; // 以服务端为准做匹配:两侧均按微信ID字符集规范化,避免不可见字符导致的误报 const existing = new Set(list.map((t: any) => clean(t.priTmplId))); const mismatched = Object.entries(configured) .filter(([, id]) => Boolean(id) && !existing.has(id)) .map(([key, id]) => ({ key, templateId: id, length: id.length })); res.json({ code: 20000, data: { appIdMasked: maskTail(cfg.appId), errcode: Number(data && data.errcode) || 0, errmsg: (data && data.errmsg) || 'ok', count: list.length, templates: list.map((t: any) => ({ templateId: clean(t.priTmplId), title: t.title, type: t.type, content: t.content, example: t.example, })), configured, mismatched, }, }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); app.post('/api/posts', rateLimit({ name: 'post_create', windowMs: 60 * 1000, max: 10, key: rateWriteKey }), async (req: Request, res: Response) => { try { const { content, userId } = req.body || {}; const uid = resolveSecureUserId(req, Number(userId || req.headers['x-user-id'] || 0), '发布动态'); await assertUserNotBanned(uid, '发布动态'); // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) const openid = await resolveUserOpenid(uid); const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } const post = await repo.createPost({ ...(req.body || {}), userId: uid }); res.json({ code: 20000, data: post, message: '动态发布成功并已写入MySQL' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.post('/api/posts/:id/like', rateLimit({ name: 'post_like', windowMs: 60 * 1000, max: 60, key: rateWriteKey }), async (req: Request, res: Response) => { try { const userId = resolveSecureUserId( req, Number(req.body?.userId || req.query.userId || req.headers['x-user-id'] || 0), '点赞动态' ); const action = req.body?.action as 'like' | 'unlike' | undefined; const result = await repo.likePost(String(req.params.id), userId, action); res.json({ code: 20000, data: result, message: result.liked ? '点赞成功' : '已取消点赞' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); app.post('/api/posts/:id/view', rateLimit({ name: 'post_view', windowMs: 3000, max: 1, key: (req) => `${clientIpOf(req)}:${req.params.id}` }), async (req: Request, res: Response) => { try { const result = await repo.viewPost(String(req.params.id)); res.json({ code: 20000, data: result, message: '动态浏览量已累加' }); } catch (err: any) { res.status(400).json({ code: 40000, message: err.message }); } }); // 7.4 订单评价与评分接口 (落盘 MySQL order_reviews) app.post('/api/orders/:id/review', async (req: Request, res: Response) => { try { const orderId = String(req.params.id); const { rating, tags, content, userId, userNickname, userAvatar, expertId } = req.body; // 身份以服务端会话为准,杜绝冒充买家提交订单评价 const reviewerId = resolveSecureUserId( req, Number(userId || req.headers['x-user-id'] || 0), '提交订单评价' ); // 微信内容安全检测 (本地词库 + 官方 msgSecCheck 双层) const openid = await resolveUserOpenid(reviewerId); const secCheck = await contentSecurityService.checkTextAsync(content, openid, 2); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } const review = await repo.createOrderReview({ orderId, expertId: expertId ? Number(expertId) : undefined, userId: reviewerId, userNickname, userAvatar, rating: Number(rating) || 5, tags, content }); res.json({ code: 20000, data: review, message: '评价提交成功并已落盘入库' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 7.5 大神历史真实评价列表接口 app.get('/api/companions/:id/reviews', async (req: Request, res: Response) => { try { const expertId = Number(req.params.id); const reviews = await repo.getOrderReviews(expertId); res.json({ code: 20000, data: reviews }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 大神感谢回复买家评价接口 app.post('/api/reviews/:id/reply', async (req: Request, res: Response) => { try { const reviewId = String(req.params.id); const { replyContent } = req.body || {}; if (!replyContent || !replyContent.trim()) { return res.status(400).json({ code: 40000, message: '回复内容不能为空' }); } // 身份校验:仅被评价的大神本人或管理员可回复,杜绝任意用户冒充大神回复 const own = await repo.getReviewOwnership(reviewId); if (!own) { return res.status(404).json({ code: 40400, message: '评价不存在' }); } assertCompanionActor(req, own, '回复评价'); const secCheck = contentSecurityService.checkText(replyContent); if (!secCheck.pass) { return res.status(400).json({ code: secCheck.code, message: secCheck.message }); } const result = await repo.replyOrderReview(reviewId, replyContent.trim()); res.json({ code: 20000, data: result, message: '回复感谢老板成功' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 管理员全量查询评价与回复列表 // 管理员全量查询评价与回复列表 (仅服务端签名管理员 token 可执行) app.get('/api/reviews', requireAdmin, async (req: Request, res: Response) => { try { const expertId = req.query.expertId ? Number(req.query.expertId) : undefined; const orderId = req.query.orderId ? String(req.query.orderId) : undefined; const reviews = await repo.getOrderReviews(expertId, orderId); res.json({ code: 20000, data: reviews }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 管理员违规下架/删除评价 (仅服务端签名管理员 token 可执行) app.delete('/api/reviews/:id', requireAdmin, async (req: Request, res: Response) => { try { const { id } = req.params; await repo.deleteReview(String(id)); res.json({ code: 20000, message: '违规评价已成功删除,大神信誉评分已重新计算' }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); // 友圈本人动态删除接口 (服务端强校验:仅作者本人可删除) app.delete('/api/posts/:id', async (req: Request, res: Response) => { try { const postId = String(req.params.id); const uid = resolveSecureUserId( req, Number(req.query.userId || req.body?.userId || req.headers['x-user-id'] || 0), '删除动态' ); await assertUserNotBanned(uid, '删除动态'); const ok = await repo.deletePost(postId, uid); if (!ok) { return res.status(404).json({ code: 40400, message: '动态不存在或已被删除' }); } res.json({ code: 20000, message: '动态删除成功' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 大神服务包管理接口 (获取与上下架切换) app.get('/api/companions/:id/services', async (req: Request, res: Response) => { try { const expertId = Number(req.params.id); const services = await repo.getExpertServices(expertId); res.json({ code: 20000, data: services }); } catch (err: any) { res.status(500).json({ code: 50000, message: err.message }); } }); app.put('/api/companions/:id/services/:serviceId/status', async (req: Request, res: Response) => { try { const serviceId = String(req.params.serviceId); const { status } = req.body; // 身份校验:仅该服务归属的大神本人或管理员可上下架,杜绝越权操作他人服务 const own = await repo.getExpertServiceOwnership(serviceId); if (!own) { return res.status(404).json({ code: 40400, message: '服务不存在' }); } assertCompanionActor(req, own, '调整服务上下架'); const result = await repo.updateExpertServiceStatus(serviceId, Boolean(status)); res.json({ code: 20000, data: result, message: '服务状态更新成功' }); } catch (err: any) { const statusCode = err.status || 400; const errCode = err.code || 40000; res.status(statusCode).json({ code: errCode, message: err.message }); } }); // 大神工作台:读取当前登录用户的大神档案(用于自助管理服务包/收益) app.get('/api/expert/me', async (req: Request, res: Response) => { try { const uid = resolveSecureUserId( req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查看大神工作台' ); const comp = await repo.getCompanionByUserId(uid); if (!comp) { return res.json({ code: 20000, data: null, message: '尚未入驻大神' }); } res.json({ code: 20000, data: { id: Number(comp.id), name: comp.name, avatar: comp.avatar, track: comp.track, hourlyRate: Number(comp.hourly_rate) || 0, 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 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) { res.status(500).json({ code: 50000, 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 { try { const isProd = process.env.NODE_ENV === 'production'; console.log(`[Peiwan Config] 运行模式=${isProd ? 'production' : 'development'} ALLOW_SANDBOX_PAY=${ALLOW_SANDBOX_PAY}`); const adminSecretSet = Boolean(String(process.env.ADMIN_TOKEN_SECRET || '').trim()); const userSecretSet = Boolean(String(process.env.USER_TOKEN_SECRET || '').trim()); if (isProd && !adminSecretSet) { console.warn('[Peiwan Config][WARN] 未设置 ADMIN_TOKEN_SECRET:已启用进程内随机密钥,重启后登录态失效;多实例部署务必设置固定密钥。'); } else { console.log(`[Peiwan Config] ADMIN_TOKEN_SECRET=${adminSecretSet ? '已设置' : '开发默认值'} USER_TOKEN_SECRET=${userSecretSet ? '已设置' : '按 ADMIN 派生'}`); } // 后台登录口令闸门:生产未配置(或为默认口令)时后台登录被禁用 if (isProd && !ADMIN_PASSWORD_CONFIGURED) { console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):生产环境后台登录已被禁用,请尽快设置后重启。'); } // 非生产环境身份校验放宽(允许客户端声明身份),禁止用于公网部署 if (!isProd) { console.warn('[Peiwan Config][WARN] 当前非 production:用户身份校验已放宽(未登录请求将采用客户端声明的 userId),仅限本地联调,切勿用于公网部署。'); } const cfg: any = await resolveWechatPayRuntimeConfig(); const missing: string[] = []; if (!cfg.appId) missing.push('appId'); if (!cfg.appSecret) missing.push('appSecret'); if (!cfg.mchId) missing.push('mchId'); if (String(cfg.apiV3Key || '').length !== 32) missing.push('apiV3Key(需32位)'); if (!cfg.privateKey && !cfg.certPem) missing.push('privateKey/certPem'); if (!cfg.platformPublicKey && !cfg.serialNo) missing.push('平台公钥或证书序列号'); console.log(`[Peiwan Config] 微信支付: 模式=${cfg.effectiveMode} 实盘就绪=${cfg.isReadyForLive ? '是' : '否'} notifyUrl=${String(cfg.notifyUrl || '')}`); if (!cfg.isReadyForLive) { console.warn(`[Peiwan Config][WARN] 微信支付未达 LIVE 就绪,待确认项: ${missing.join('、') || '(mode 未设为 LIVE 或参数未齐)'}`); } console.log(`[Peiwan Config] 商家转账到零钱(提现打款): 就绪=${cfg.isReadyForTransfer ? '是' : '否'} 转账场景ID=${cfg.transferSceneId ? '已配置' : '未配置'}`); if (cfg.effectiveMode === 'LIVE' && !cfg.isReadyForTransfer) { console.warn('[Peiwan Config][WARN] 微信提现打款未就绪:请在后台配置「转账场景ID」并确认商户号已开通"商家转账到零钱"产品,否则生产环境将拒绝"通过打款"。'); } } catch (e) { logServerError('startupConfigSelfCheck', e); } } // 订单超时自动流转定时任务:定期扫描并处理「超时未支付取消 / 超时未接单退款 / 48小时自动完成结算」 const ORDER_TIMEOUT_SCAN_MS = Number(process.env.ORDER_TIMEOUT_SCAN_MS || 60000); let orderTimeoutTimer: NodeJS.Timeout | null = null; let orderTimeoutRunning = false; function startOrderTimeoutScheduler(): void { if (process.env.DISABLE_ORDER_TIMEOUT_SCAN === '1') { console.log('[Peiwan Scheduler] 订单超时扫描已禁用 (DISABLE_ORDER_TIMEOUT_SCAN=1)'); return; } if (orderTimeoutTimer) return; const intervalMs = Number.isFinite(ORDER_TIMEOUT_SCAN_MS) ? Math.max(1000, ORDER_TIMEOUT_SCAN_MS) : 60000; orderTimeoutTimer = setInterval(async () => { if (orderTimeoutRunning) return; // 防重入:上一轮未结束则跳过本轮 orderTimeoutRunning = true; try { const r = await repo.processOrderTimeouts(); if (r && (r.cancelledCount || r.refundedCount || r.completedCount)) { console.log( `[Peiwan Scheduler] 订单超时扫描: 取消 ${r.cancelledCount} / 退款 ${r.refundedCount} / 完成结算 ${r.completedCount}` ); } } catch (e) { logServerError('orderTimeoutScheduler', e); } finally { orderTimeoutRunning = false; } }, intervalMs); if (typeof (orderTimeoutTimer as any).unref === 'function') { (orderTimeoutTimer as any).unref(); } console.log(`[Peiwan Scheduler] 订单超时扫描已启动,间隔 ${Math.round(intervalMs / 1000)}s`); } // 兜底错误处理:统一返回 JSON(避免 Express 默认 HTML 堆栈泄露);5xx 只记日志、对外通用提示 app.use((err: any, _req: Request, res: Response, _next: NextFunction) => { const status = Number(err && err.status) || 500; if (status >= 500) { logServerError('unhandledError', err); res.status(500).json({ code: 50000, message: '服务器内部错误,请稍后重试' }); return; } res.status(status).json({ code: 40000, message: (err && err.message) || '请求不合法' }); }); async function bootstrap() { try { await initDatabase(); console.log('[Peiwan Server] MySQL 数据库核心表及种子数据已校验就绪'); // 载入自定义敏感词过滤库 const secWordsConfig = await repo.getPlatformConfigByKey('security_words'); if (secWordsConfig && Array.isArray(secWordsConfig.valueJson)) { contentSecurityService.setCustomKeywords(secWordsConfig.valueJson); console.log(`[Peiwan Server] 已成功装载 ${secWordsConfig.valueJson.length} 条自定义安全拦截词`); } } catch (err) { console.error('[Peiwan Server] MySQL 初始化连接发生警告:', err); } await logStartupConfigSelfCheck(); startOrderTimeoutScheduler(); // 载入后台登录态版本号,并周期同步(支持多实例下的"吊销所有会话") await loadAdminTokenEpoch(); const epochSyncTimer = setInterval(() => { void loadAdminTokenEpoch(); }, 30 * 1000); if (typeof (epochSyncTimer as any).unref === 'function') (epochSyncTimer as any).unref(); app.listen(port, () => { console.log(`[Peiwan Server] HTTP server is running on http://localhost:${port}`); }); } bootstrap(); export default app;