import { pool } from './mysql'; import { tryRefundToWeChat } from '../core/pay/refund-bridge'; // 统一服务端错误日志(替代静默吞错,保证资金/结算异常可观测) function logServerError(scope: string, err: any): void { try { const msg = err && err.message ? err.message : String(err); console.error(`[SERVER_ERROR][${scope}] ${msg}`); } catch (_) {} } export interface ActivityRow { id: string; creatorId: number; title: string; gameCategory: string; location?: string; startTime?: string; creatorNickname?: string; creatorAvatar?: string; payType: number; totalFeeCents: number; perPersonFeeCents: number; maxParticipants: number; allowDynamicPrice: boolean; priceAdjustRule: string; joinedCount?: number; participants?: any[]; } export class MysqlRepository { // 活动相关 async getActivities(): Promise { const [actRows]: any = await pool.query(` SELECT a.id, a.creator_id as creatorId, a.title, a.game_category as gameCategory, a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents, a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants, a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule, COALESCE(a.status, 'RECRUITING') as status, a.created_at as createdAt, u.nickname as creatorNickname, u.avatar as creatorAvatar FROM activities a LEFT JOIN users u ON a.creator_id = u.id WHERE (u.status IS NULL OR u.status != '已封禁') ORDER BY a.created_at DESC `); const [ptRows]: any = await pool.query(` SELECT id, activity_id as activityId, user_id as userId, pay_amount_cents as payAmountCents, verify_code as verifyCode, checkin_status as checkinStatus, verified_at as verifiedAt, verified_by as verifiedBy, created_at as createdAt FROM participants `); return actRows.map((act: any) => { const parts = ptRows.filter((p: any) => p.activityId === act.id); return { ...act, allowDynamicPrice: Boolean(act.allowDynamicPrice), // 列表接口严禁下发核销码明文,防止任意访问者批量窃取后盗刷核销 participants: parts.map((p: any) => ({ id: p.id, activityId: p.activityId, userId: p.userId, payAmountCents: p.payAmountCents, checkinStatus: Boolean(p.checkinStatus), verifiedAt: p.verifiedAt, verifiedBy: p.verifiedBy, createdAt: p.createdAt, })), joinedCount: parts.length, }; }); } async getActivityById(id: string): Promise { const [actRows]: any = await pool.query(` SELECT a.id, a.creator_id as creatorId, a.title, a.game_category as gameCategory, a.location, COALESCE(a.start_time, '') as startTime, a.pay_type as payType, a.total_fee_cents as totalFeeCents, a.per_person_fee_cents as perPersonFeeCents, a.max_participants as maxParticipants, a.allow_dynamic_price as allowDynamicPrice, a.price_adjust_rule as priceAdjustRule, COALESCE(a.status, 'RECRUITING') as status, u.nickname as creatorNickname, u.avatar as creatorAvatar FROM activities a LEFT JOIN users u ON a.creator_id = u.id WHERE a.id = ? `, [id]); if (!actRows || actRows.length === 0) return null; const act = actRows[0]; const [ptRows]: any = await pool.query(` SELECT id, activity_id as activityId, user_id as userId, pay_amount_cents as payAmountCents, verify_code as verifyCode, checkin_status as checkinStatus, verified_at as verifiedAt, verified_by as verifiedBy, created_at as createdAt FROM participants WHERE activity_id = ? ORDER BY created_at ASC `, [id]); return { ...act, allowDynamicPrice: Boolean(act.allowDynamicPrice), participants: ptRows.map((p: any) => ({ ...p, checkinStatus: Boolean(p.checkinStatus) })), joinedCount: ptRows.length, }; } async createActivity(data: { creatorId: number; title: string; gameCategory: string; location?: string; startTime?: string; payType?: number; totalFeeCents?: number; perPersonFeeCents?: number; maxParticipants?: number; allowDynamicPrice?: number | boolean; priceAdjustRule?: string; }): Promise { const [userRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [data.creatorId]); if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) { const err: any = new Error('您的账号已被管理员封禁,无法发起组局或发布活动'); err.status = 403; err.code = 40300; throw err; } const id = `act_${Date.now()}_${Math.floor(Math.random() * 1000)}`; // 参数服务端校验与归一化,防止负数/超大值破坏报名费与人数上限 const payType = Number(data.payType) === 2 ? 2 : 1; const rawMax = Number(data.maxParticipants); const maxParticipants = Number.isFinite(rawMax) ? Math.min(100, Math.max(1, Math.round(rawMax))) : 4; const MAX_FEE_CENTS = 100000000; // ¥1,000,000 单人均上限 const rawFee = Number(data.perPersonFeeCents); const fee = Number.isFinite(rawFee) ? Math.min(MAX_FEE_CENTS, Math.max(0, Math.round(rawFee))) : 5000; // 房主请客(payType=2)人均为 0;AA(payType=1) 使用校验后的人均 const perPersonFeeCents = payType === 1 ? fee : 0; const rawTotal = Number(data.totalFeeCents); const totalFeeCents = Number.isFinite(rawTotal) && rawTotal >= perPersonFeeCents ? Math.min(MAX_FEE_CENTS * maxParticipants, Math.round(rawTotal)) : perPersonFeeCents * maxParticipants; const allowDynamicPrice = data.allowDynamicPrice !== undefined ? (data.allowDynamicPrice ? 1 : 0) : 1; const priceAdjustRule = data.priceAdjustRule || 'UNIFIED_HIGH'; await pool.query(` INSERT INTO activities (id, creator_id, title, game_category, location, start_time, pay_type, total_fee_cents, per_person_fee_cents, max_participants, allow_dynamic_price, price_adjust_rule) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, data.creatorId, data.title, data.gameCategory, data.location || '', data.startTime || '', payType, totalFeeCents, perPersonFeeCents, maxParticipants, allowDynamicPrice, priceAdjustRule, ]); return this.getActivityById(id); } private verifyCodeCache = new Map(); private async settleActivityCheckinAndReferral( activityId: string, creatorId: number, participantId: string, participantUserId: number, payAmountCents: number ): Promise { const feeYuan = Number((Number(payAmountCents || 0) / 100).toFixed(2)); if (feeYuan <= 0) return; try { const ts = Date.now(); const rand = () => Math.floor(100 + Math.random() * 900); // 1. 触发活动费用清算逻辑:将核销款项划入活动发起人账户并记录清算流水 if (creatorId > 0) { await pool.query( 'UPDATE users SET balance = COALESCE(balance, 0) + ?, activity_count = COALESCE(activity_count, 0) + 1 WHERE id = ?', [feeYuan, creatorId] ); await pool.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '活动核销费用清算', ?, 'IN', ?, ?, NOW())`, [ `fin_act_${ts}_${rand()}`, `AC${ts}${rand()}`, creatorId, feeYuan, `参与搭子 ${participantUserId} (凭证 ${participantId})`, activityId ] ); } // 2. 付费活动完成后触发一级(5%) / 二级(2%)分销返佣 const cfg = await this.getFinanceConfig(); const firstRefPct = Number(cfg.firstReferralPercent ?? 5); const secondRefPct = Number(cfg.secondReferralPercent ?? 2); const [buyerRows]: any = await pool.query( 'SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1', [participantUserId] ); const buyer = buyerRows && buyerRows[0]; const fromUserName = buyer?.nickname || `搭子_${participantUserId}`; if (buyer && buyer.inviter_id && firstRefPct > 0) { const lv1UserId = Number(buyer.inviter_id); const comm1 = Number(((feeYuan * firstRefPct) / 100).toFixed(2)); if (comm1 > 0 && lv1UserId > 0 && lv1UserId !== participantUserId) { await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm1, lv1UserId]); await pool.query( `INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, '已入账', NOW())`, [`dst_act1_${ts}_${rand()}`, lv1UserId, participantUserId, fromUserName, activityId, feeYuan, comm1, firstRefPct] ); } const [lv1Rows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [lv1UserId]); const lv1User = lv1Rows && lv1Rows[0]; if (lv1User && lv1User.inviter_id && secondRefPct > 0) { const lv2UserId = Number(lv1User.inviter_id); const comm2 = Number(((feeYuan * secondRefPct) / 100).toFixed(2)); if (comm2 > 0 && lv2UserId > 0 && lv2UserId !== participantUserId && lv2UserId !== lv1UserId) { await pool.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm2, lv2UserId]); await pool.query( `INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 2, ?, '已入账', NOW())`, [`dst_act2_${ts}_${rand()}`, lv2UserId, participantUserId, fromUserName, activityId, feeYuan, comm2, secondRefPct] ); } } } } catch (_) {} } async joinActivity(activityId: string, params: { userId: number; payAmountCents: number }): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); // 1. 封禁校验(事务内) const [userRows]: any = await conn.query('SELECT status FROM users WHERE id = ?', [params.userId]); if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) { const err: any = new Error('您的账号已被管理员封禁,无法报名参加活动'); err.status = 403; err.code = 40300; throw err; } // 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖 const [actRows]: any = await conn.query( 'SELECT id, max_participants, pay_type, per_person_fee_cents FROM activities WHERE id = ? FOR UPDATE', [activityId] ); if (!actRows || actRows.length === 0) throw new Error('活动不存在'); const maxParticipants = Number(actRows[0].max_participants || 0); // 报名费由服务端活动配置决定(pay_type=1 为 AA 收取人均分摊,其余为房主请客 0 元), // 严禁信任客户端传入的 payAmountCents,否则可"免费报名 → 退款/清算套现"。 const joinFeeCents = Number(actRows[0].pay_type) === 1 ? Math.max(0, Math.round(Number(actRows[0].per_person_fee_cents) || 0)) : 0; // 3. 锁内以「当前读 (FOR UPDATE)」拉取该活动全部报名明细: // - 当前读不受 REPEATABLE READ 快照影响,必然看到最新已提交数据,名额判断才准确; // - 同时复用于防重复报名与核销码去重。 const [ptRows]: any = await conn.query( 'SELECT id, user_id, verify_code FROM participants WHERE activity_id = ? FOR UPDATE', [activityId] ); const rowsList: any[] = ptRows || []; const joinedCount = rowsList.length; if (maxParticipants > 0 && joinedCount >= maxParticipants) { throw new Error('活动报名人数已满'); } // 4. 防重复报名(与 DB 唯一约束 uq_activity_user 双保险) if (rowsList.some((r: any) => Number(r.user_id) === Number(params.userId))) { throw new Error('用户已报名该活动'); } // 5. 真实扣款:AA 报名费从钱包余额扣除(杜绝免费报名后套取退款/清算) if (joinFeeCents > 0) { const joinFeeYuan = joinFeeCents / 100; const [balRows]: any = await conn.query('SELECT balance FROM users WHERE id = ? FOR UPDATE', [params.userId]); const bal = Number((balRows && balRows[0] && balRows[0].balance) || 0); if (bal < joinFeeYuan) { const err: any = new Error(`钱包余额不足,报名需支付 ¥${joinFeeYuan.toFixed(2)},请先充值`); err.status = 400; err.code = 40000; throw err; } await conn.query('UPDATE users SET balance = balance - ? WHERE id = ?', [joinFeeYuan, params.userId]); await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) VALUES (?, ?, ?, '活动报名扣款', ?, 'OUT', ?, ?) `, [ `fin_jn_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`, `JN${Date.now()}${Math.floor(100 + Math.random() * 900)}`, params.userId, joinFeeYuan.toFixed(2), `活动 ${activityId}`, activityId ]); } // 5. 生成活动内唯一的 4 位纯数字核销码 (1000 - 9999) const usedCodes = new Set(rowsList.map((r: any) => String(r.verify_code))); let verifyCode = ''; for (let i = 0; i < 100; i++) { const code = String(Math.floor(1000 + Math.random() * 9000)); if (!usedCodes.has(code)) { verifyCode = code; break; } } if (!verifyCode) { for (let n = 1000; n <= 9999; n++) { if (!usedCodes.has(String(n))) { verifyCode = String(n); break; } } } if (!verifyCode) { const err: any = new Error('该活动核销码已用尽,无法继续报名'); err.status = 409; err.code = 40900; throw err; } const ptId = `pt_${Date.now()}_${Math.floor(Math.random() * 1000)}`; await conn.query(` INSERT INTO participants (id, activity_id, user_id, pay_amount_cents, verify_code, checkin_status) VALUES (?, ?, ?, ?, ?, 0) `, [ptId, activityId, params.userId, joinFeeCents, verifyCode]); // 6. 写入一条财务流水 const finId = `fin_${Date.now()}_${Math.floor(Math.random() * 1000)}`; await conn.query(` INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id) VALUES (?, ?, '活动报名入账', ?, 'IN', ?, ?) `, [finId, `TR${Date.now()}${Math.floor(Math.random() * 100)}`, joinFeeCents / 100, `用户 ${params.userId}`, activityId]); await conn.commit(); // 7. 提交成功后再写入核销码缓存索引 act:verify:{activity_id}:{verify_code} -> participant_id (TTL 24h) const cacheKey = `act:verify:${activityId}:${verifyCode}`; this.verifyCodeCache.set(cacheKey, { participantId: ptId, userId: params.userId, expireAt: Date.now() + 24 * 3600 * 1000 }); return { id: ptId, activityId, userId: params.userId, payAmountCents: joinFeeCents, verifyCode, cacheKey, checkinStatus: false, }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async verifyCheckin(activityId: string, verifyCode: string, operatorId: number): Promise { const act = await this.getActivityById(activityId); if (!act) throw new Error('活动不存在'); if (Number(act.creatorId) !== Number(operatorId)) { throw new Error('无权核销该活动的核销码'); } const [rows]: any = await pool.query(` SELECT * FROM participants WHERE activity_id = ? AND verify_code = ? `, [activityId, verifyCode]); if (!rows || rows.length === 0) { throw new Error('核销码错误或该码不属于此活动'); } const pt = rows[0]; if (pt.checkin_status) { throw new Error('该核销码已核销过,不可重复核销(防重校验已生效)'); } // CAS 原子状态跃迁校验:防止高并发重复核销 const [updateRes]: any = await pool.query(` UPDATE participants SET checkin_status = 1, verified_at = NOW(), verified_by = ? WHERE id = ? AND checkin_status = 0 `, [operatorId, pt.id]); if (!updateRes || updateRes.affectedRows === 0) { throw new Error('该核销码已核销过,不可重复核销(防重校验已生效)'); } const cacheKey = `act:verify:${activityId}:${verifyCode}`; this.verifyCodeCache.delete(cacheKey); // 触发活动费用清算逻辑(划入发起人账户)及二级分销返佣 await this.settleActivityCheckinAndReferral( activityId, Number(act.creatorId), pt.id, Number(pt.user_id), Number(pt.pay_amount_cents || 0) ); return { success: true, participantId: pt.id, activityId, userId: pt.user_id, verifyCode, settledFeeYuan: Number((Number(pt.pay_amount_cents || 0) / 100).toFixed(2)), verifiedAt: new Date(), operatorId, }; } async queryCheckinCodeInfo(verifyCode: string): Promise { // 1. 优先查活动门票参与人表 const [ptRows]: any = await pool.query(` SELECT p.id as participantId, p.activity_id as activityId, p.user_id as userId, p.pay_amount_cents as payAmountCents, p.verify_code as verifyCode, p.checkin_status as checkinStatus, a.title as activityTitle, a.game_category as gameCategory, a.creator_id as creatorId FROM participants p LEFT JOIN activities a ON p.activity_id = a.id WHERE p.verify_code = ? ORDER BY p.checkin_status ASC, p.created_at DESC LIMIT 1 `, [verifyCode]); if (ptRows && ptRows.length > 0) { const pt = ptRows[0]; return { type: 'ACTIVITY', id: pt.participantId, relatedId: pt.activityId, userId: pt.userId, creatorId: pt.creatorId, payAmountCents: pt.payAmountCents, nickname: `搭子_${pt.userId}`, avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=150', title: pt.activityTitle || '同城组局活动', code: pt.verifyCode, fee: (pt.payAmountCents / 100).toFixed(2), checkedIn: Boolean(pt.checkinStatus), }; } // 2. 其次查订单表中的线下订单核销码 const [ordRows]: any = await pool.query(` SELECT id, order_no as orderNo, client_name as clientName, service_title as serviceTitle, track, amount, verify_code as verifyCode, status FROM orders WHERE verify_code = ? AND status NOT IN ('已取消', '已退款', '已全额退款') ORDER BY FIELD(status, '履约中', '服务中', '待确认', '已到场核销', '已完成'), created_at DESC LIMIT 1 `, [verifyCode]); if (ordRows && ordRows.length > 0) { const ord = ordRows[0]; return { type: 'ORDER', id: ord.id, relatedId: ord.orderNo, nickname: ord.clientName || '客户老板', avatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150', title: ord.serviceTitle || `${ord.track}专属陪伴`, code: ord.verifyCode, fee: Number(ord.amount).toFixed(2), checkedIn: ord.status === '已完成' || ord.status === '已到场核销', }; } throw new Error(`核销码 [${verifyCode}] 无效或不存在`); } async universalVerifyCheckin(verifyCode: string, operatorId: number = 1001): Promise { const info = await this.queryCheckinCodeInfo(verifyCode); if (info.checkedIn) { throw new Error(`核销码 [${verifyCode}] 已完成现场签到,不可重复核销(防重校验已生效)`); } if (info.type === 'ACTIVITY') { const [updateRes]: any = await pool.query(` UPDATE participants SET checkin_status = 1, verified_at = NOW(), verified_by = ? WHERE id = ? AND checkin_status = 0 `, [operatorId, info.id]); if (!updateRes || updateRes.affectedRows === 0) { throw new Error(`核销码 [${verifyCode}] 已完成现场签到,不可重复核销(防重校验已生效)`); } this.verifyCodeCache.delete(`act:verify:${info.relatedId}:${verifyCode}`); await this.settleActivityCheckinAndReferral( String(info.relatedId), Number(info.creatorId || operatorId), String(info.id), Number(info.userId || 0), Number(info.payAmountCents || Math.round(Number(info.fee || 0) * 100)) ); } else if (info.type === 'ORDER') { await pool.query(` UPDATE orders SET status = '已到场核销' WHERE id = ? `, [info.id]); } return { success: true, info, verifiedAt: new Date().toISOString() }; } async adjustPrice(activityId: string, newPriceCents: number, operatorId: number): Promise { const act = await this.getActivityById(activityId); if (!act) throw new Error('活动不存在'); if (Number(act.creatorId) !== Number(operatorId)) { throw new Error('只有发起人有权调整活动出价'); } // 校验调价金额:必须为非负整数且不超过平台单人均上限,防止负数/超大值破坏报名费与账目 const price = Number(newPriceCents); if (!Number.isFinite(price) || !Number.isInteger(price) || price < 0) { const err: any = new Error('调价金额必须为不小于 0 的整数(单位:分)'); err.status = 400; err.code = 40000; throw err; } const MAX_FEE_CENTS = 100000000; // ¥1,000,000 单人均上限 if (price > MAX_FEE_CENTS) { const err: any = new Error(`调价金额超出平台单人均上限 ¥${(MAX_FEE_CENTS / 100).toLocaleString()}`); err.status = 400; err.code = 40000; throw err; } const oldFee = Number(act.perPersonFeeCents || 0); const deltaCents = price - oldFee; await pool.query(` UPDATE activities SET per_person_fee_cents = ? WHERE id = ? `, [price, activityId]); // 仅按真实差额记账(避免把整份新价重复计入流水造成虚假营收) if (deltaCents !== 0) { const finId = `fin_${Date.now()}`; await pool.query(` INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id) VALUES (?, ?, ?, ?, ?, ?, ?) `, [ finId, `TR${Date.now()}`, deltaCents > 0 ? '房主追加赏金' : '房主下调出价差额', Math.abs(deltaCents) / 100, deltaCents > 0 ? 'IN' : 'OUT', `房主 ${operatorId}`, activityId ]); } return this.getActivityById(activityId); } async cancelActivity(id: string, hostId: number, reason?: string, isAdmin?: boolean): Promise { const act = await this.getActivityById(id); if (!act) { throw new Error('活动不存在'); } if (!isAdmin && Number(act.creatorId) !== Number(hostId)) { throw new Error('只有发起人或平台管理员有权解散此组局'); } const hasCheckedIn = act.participants.some((p: any) => p.checkinStatus); if (!isAdmin && hasCheckedIn) { throw new Error('聚会已有成员到场核销,不可随意解散'); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); // 1. 更新活动状态为已取消 await conn.query(`UPDATE activities SET status = 'CANCELLED' WHERE id = ?`, [id]); // 2. 遍历已报名成员,原路退还报名费并记录财务流水 const refundDetails = []; let totalRefundCents = 0; for (const p of act.participants) { const refundAmount = p.payAmountCents; totalRefundCents += refundAmount; refundDetails.push({ userId: p.userId, refundAmountCents: refundAmount }); // 退款至用户钱包余额 await conn.query(`UPDATE users SET balance = balance + ? WHERE id = ?`, [refundAmount / 100, p.userId]); // 插入财务流水记录 const finId = `fin_ref_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const recordNo = `RF${Date.now()}`; await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) VALUES (?, ?, ?, '组局解散全额退款', ?, 'IN', ?, ?) `, [finId, recordNo, p.userId, (refundAmount / 100).toFixed(2), `房主 ${hostId}`, id]); } await conn.commit(); return { success: true, status: 'CANCELLED', refundedCount: refundDetails.length, totalRefundCents, refundDetails, }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async leaveActivity(id: string, userId: number): Promise { const act = await this.getActivityById(id); if (!act) { throw new Error('活动不存在'); } const pt = act.participants.find((p: any) => Number(p.userId) === Number(userId)); if (!pt) { throw new Error('用户未报名该活动'); } if (pt.checkinStatus) { throw new Error('已核销到场,无法申请退席'); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); // 删除参与者记录 await conn.query(`DELETE FROM participants WHERE id = ?`, [pt.id]); // 原路退还报名费 const refundAmount = pt.payAmountCents; await conn.query(`UPDATE users SET balance = balance + ? WHERE id = ?`, [refundAmount / 100, userId]); // 记录财务流水 const finId = `fin_ref_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const recordNo = `RF${Date.now()}`; await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) VALUES (?, ?, ?, '成员退席原路退款', ?, 'IN', ?, ?) `, [finId, recordNo, userId, (refundAmount / 100).toFixed(2), `用户 ${userId} (组局退款)`, id]); await conn.commit(); return { success: true, refundAmountCents: refundAmount, }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async getMyActivities(userId: number): Promise { const allActivities = await this.getActivities(); const created = allActivities.filter((a: any) => Number(a.creatorId) === Number(userId)); const joined = allActivities.filter((a: any) => a.participants && a.participants.some((p: any) => Number(p.userId) === Number(userId)) ); return { created, joined, }; } // 大神相关 private async resolveCompanionRow(id: number | string): Promise { const rawId = String(id).trim(); const numId = Number(rawId); if (!isNaN(numId) && numId > 0) { const [res]: any = await pool.query( 'SELECT * FROM companions WHERE id = ? OR user_id = ? OR name = ? ORDER BY id ASC LIMIT 1', [numId, numId, rawId] ); if (res && res.length > 0) return res[0]; if (numId > 100 && numId < 200) { const [mappedRes]: any = await pool.query( 'SELECT * FROM companions WHERE id = ? LIMIT 1', [numId - 100] ); if (mappedRes && mappedRes.length > 0) return mappedRes[0]; } if (numId === 2006) { const [susuRes]: any = await pool.query( "SELECT * FROM companions WHERE name = '苏苏学姐' OR id = 1 LIMIT 1" ); if (susuRes && susuRes.length > 0) return susuRes[0]; } } else { const [res]: any = await pool.query('SELECT * FROM companions WHERE name = ? LIMIT 1', [rawId]); if (res && res.length > 0) return res[0]; } return null; } async getCompanions(keyword?: string): Promise { try { await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); } catch (_) {} let sql = ` SELECT id, user_id as userId, name, avatar, gender, age, track, rank_name as \`rank\`, hourly_rate as hourlyRate, voice_duration as voiceDuration, voice_title as voiceTitle, rating, order_count as orderCount, status, audit_status as auditStatus, COALESCE(reject_reason, '') as rejectReason, id_card_masked as idCardMasked, tags, apply_time as applyTime FROM companions `; const params: any[] = []; const kw = String(keyword == null ? '' : keyword).trim(); if (kw) { // 关键词搜索:昵称 / 陪玩品类 / 段位 / 技能标签(参数化,避免注入) sql += ' WHERE name LIKE ? OR track LIKE ? OR rank_name LIKE ? OR tags LIKE ? OR CAST(id AS CHAR) = ?'; const like = `%${kw}%`; params.push(like, like, like, like, kw); } sql += ' ORDER BY id ASC'; const [rows]: any = await pool.query(sql, params); return rows.map((r: any) => { let tagsArr = []; try { tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []); } catch (_) {} return { ...r, tags: tagsArr }; }); } async getCompanionById(id: number | string): Promise { let c = await this.resolveCompanionRow(id); // 是否为"非真实大神档案"的合成资料(用于对其隐藏虚构评分/价格/服务等) let synthetic = false; if (!c) { const rawId = String(id).trim(); const numId = Number(rawId); let postRow: any = null; let userRow: any = null; let cmtRow: any = null; try { if (!isNaN(numId) && numId > 0) { const [pRows]: any = await pool.query( 'SELECT * FROM posts WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [numId] ); if (pRows && pRows.length > 0) postRow = pRows[0]; const [uRows]: any = await pool.query( 'SELECT * FROM users WHERE id = ? LIMIT 1', [numId] ); if (uRows && uRows.length > 0) userRow = uRows[0]; const [cRows]: any = await pool.query( 'SELECT * FROM post_comments WHERE user_id = ? ORDER BY created_at DESC LIMIT 1', [numId] ); if (cRows && cRows.length > 0) cmtRow = cRows[0]; } else { const [pRows]: any = await pool.query( 'SELECT * FROM posts WHERE author = ? OR id = ? ORDER BY created_at DESC LIMIT 1', [rawId, rawId] ); if (pRows && pRows.length > 0) postRow = pRows[0]; const [uRows]: any = await pool.query( 'SELECT * FROM users WHERE nickname = ? LIMIT 1', [rawId] ); if (uRows && uRows.length > 0) userRow = uRows[0]; const [cRows]: any = await pool.query( 'SELECT * FROM post_comments WHERE id = ? OR author = ? ORDER BY created_at DESC LIMIT 1', [rawId, rawId] ); if (cRows && cRows.length > 0) cmtRow = cRows[0]; } } catch (_) {} let postImages: string[] = []; try { if (postRow?.images) { postImages = typeof postRow.images === 'string' ? JSON.parse(postRow.images) : postRow.images; } } catch (_) {} c = { id: userRow?.id || postRow?.user_id || cmtRow?.user_id || (!isNaN(numId) && numId > 0 ? numId : 2005), user_id: userRow?.id || postRow?.user_id || cmtRow?.user_id || null, name: postRow?.author || userRow?.nickname || cmtRow?.author || '同城用户', avatar: postRow?.avatar || userRow?.avatar || cmtRow?.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300', gender: userRow?.gender === '男' ? '男' : (userRow?.gender === '女' ? '女' : '保密'), age: 0, track: postRow?.category === 'game' ? '线上电竞' : '同城陪吃', rank_name: postRow?.tag || userRow?.role || '', hourly_rate: 0, voice_duration: 0, voice_title: '', rating: 0, order_count: 0, status: '休息中', audit_status: '未申请', tags: postRow?.topics || JSON.stringify([]), custom_bio: postRow?.content || userRow?.signature || cmtRow?.content || '', custom_city: postRow?.location || '', custom_photos: postImages.length > 0 ? postImages : null }; synthetic = true; } if (!c) return null; const game = c.track === 'OFFLINE_MEAL' ? '美食陪吃' : (c.track === 'OFFLINE_SING' ? '同城陪唱' : (c.track === 'OFFLINE_DRINK' ? '下午茶陪喝' : (c.track === 'OFFLINE_PLAY' ? '同城陪玩' : (c.track || c.game || '同城生活陪伴')))); const price = Number(c.hourly_rate) || 68; let parsedTags = synthetic ? [] : ['高颜值', '性格好', '秒回消息', '文明健康']; if (c.tags) { try { const t = typeof c.tags === 'string' ? JSON.parse(c.tags) : c.tags; if (Array.isArray(t) && t.length > 0) { parsedTags = t; } } catch (_) {} } let customServices: any[] = []; try { const displayExpertId = Number(c.id) < 100 ? Number(c.id) + 100 : Number(c.id); const [srvRows]: any = await pool.query( 'SELECT * FROM expert_services WHERE expert_id IN (?, ?) AND status = 1 ORDER BY created_at ASC', [Number(c.id), displayExpertId] ); if (srvRows && srvRows.length > 0) { customServices = srvRows.map((s: any) => ({ id: s.id, name: s.title, title: s.title, category: s.category, price: Number(s.price) || price, unit: s.unit || '小时', desc: s.desc_text || '正规公开商圈同城陪伴,AA制或协商消费' })); } } catch (_) {} const defaultServices = [ { id: 'srv_1', name: `${game} · 基础陪伴 (2小时)`, title: `${game} · 基础陪伴 (2小时)`, price: price, unit: '小时', desc: '正规公开商圈同城陪伴,AA制或协商消费' }, { id: 'srv_2', name: `${game} · 半日畅享 (3小时)`, title: `${game} · 半日畅享 (3小时)`, price: Math.round(price * 2.8), unit: '次', desc: '深度互动与陪伴交流,赠送专属点歌或语音祝福' } ]; const auditStatus = c.audit_status || '已通过'; const certStatus = auditStatus === '已通过' ? 2 : (auditStatus === '待审核' ? 1 : (auditStatus === '已驳回' ? 3 : 0)); return { id: String(c.id), userId: c.user_id || null, nickname: c.name, name: c.name, avatar: c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300', coverImage: (c.custom_photos && c.custom_photos[0]) || c.avatar || 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4?w=800', gender: c.gender === '男' ? 'male' : 'female', age: synthetic ? 0 : (Number(c.age) || 22), game: game, track: c.track || 'OFFLINE_MEAL', city: synthetic ? (c.custom_city || '') : (c.custom_city || '北京市 · 朝阳/海淀'), rankTag: c.rank_name || (synthetic ? '' : '认证大神'), levelTag: synthetic ? '普通用户' : '金牌认证', price: synthetic ? 0 : price, hourlyRate: synthetic ? 0 : price, rating: synthetic ? 0 : (Number(c.rating) || 5.0), orderCount: synthetic ? 0 : (Number(c.order_count) || 120), status: synthetic ? '休息中' : (c.status || '接单中'), auditStatus: synthetic ? '未申请' : auditStatus, certStatus: synthetic ? 0 : certStatus, rejectReason: c.reject_reason || '', isOnline: synthetic ? false : c.status === '接单中', isCompanion: !synthetic, voiceDuration: synthetic ? 0 : (Number(c.voice_duration) || 15), voiceType: 'voice', voiceTitle: synthetic ? '' : (c.voice_title || '语音招呼'), bio: c.custom_bio || (synthetic ? '' : '专业同城生活陪伴,热情礼貌,守时诚信。'), tags: parsedTags, photos: c.custom_photos || (synthetic ? [c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400'] : [ c.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=400', 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=400', 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=400' ]), services: synthetic ? [] : (customServices.length > 0 ? customServices : defaultServices) }; } async updateCompanionProfile(id: number | string, data: { hourlyRate?: number; tags?: string[]; rankName?: string; status?: string }): Promise { const comp = await this.resolveCompanionRow(id); if (!comp) return null; const fields: string[] = []; const values: any[] = []; if (data.hourlyRate !== undefined) { fields.push('hourly_rate = ?'); values.push(Number(data.hourlyRate)); } if (data.tags !== undefined) { fields.push('tags = ?'); values.push(JSON.stringify(data.tags)); } if (data.rankName !== undefined) { fields.push('rank_name = ?'); values.push(data.rankName); } if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); } if (fields.length > 0) { values.push(comp.id); await pool.query(`UPDATE companions SET ${fields.join(', ')} WHERE id = ?`, values); } return this.getCompanionById(comp.id); } /** * 解析大神归属关系(用于资料/服务等写接口的越权校验)。 * ownerUserId 为大神的真实登录用户ID(companions.user_id),历史数据可能为 NULL。 */ async getCompanionOwnership(id: number | string): Promise<{ companionId: number; ownerUserId: number | null } | null> { const comp = await this.resolveCompanionRow(id); if (!comp) return null; return { companionId: Number(comp.id), ownerUserId: comp.user_id != null ? Number(comp.user_id) : null, }; } async applyCompanion(data: { userId: number; name: string; idCard: string; track: string; hourlyRate?: number; tags?: string[]; avatar?: string; gender?: string; }): Promise { try { await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); } catch (_) {} const userId = Number(data.userId) || 1001; const name = data.name.trim(); const track = data.track || '同城陪吃'; const hourlyRate = Number(data.hourlyRate) || 88; const tagsJson = JSON.stringify(data.tags || []); const avatar = data.avatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=300'; const gender = data.gender || '女'; const maskedIdCard = data.idCard.length >= 8 ? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4) : '110101********0000'; // 仅允许本人按 user_id 重新提交/更新档案;严禁按昵称匹配到他人档案后抢占其归属 const [ownRows]: any = await pool.query( 'SELECT * FROM companions WHERE user_id IS NOT NULL AND user_id = ? LIMIT 1', [userId] ); if (ownRows && ownRows.length > 0) { await pool.query(` UPDATE companions SET name = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', reject_reason = '', id_card_masked = ?, apply_time = NOW() WHERE id = ? `, [name, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, ownRows[0].id]); return this.getCompanionById(ownRows[0].id); } // 昵称冲突校验:若昵称已被(他人)大神档案占用,拒绝提交,防止冒充/抢占他人大神身份 const [nameRows]: any = await pool.query('SELECT id, user_id FROM companions WHERE name = ? LIMIT 1', [name]); if (nameRows && nameRows.length > 0) { const err: any = new Error('该大神昵称已被占用,请更换昵称后重新提交'); err.status = 409; err.code = 40900; throw err; } const [res]: any = await pool.query(` INSERT INTO companions (user_id, name, avatar, gender, age, track, rank_name, hourly_rate, voice_duration, voice_title, rating, order_count, status, audit_status, reject_reason, id_card_masked, tags, apply_time) VALUES (?, ?, ?, ?, 23, ?, '申请中陪伴师', ?, 15, '热情语音介绍', 5.0, 0, '待审核', '待审核', '', ?, ?, NOW()) `, [userId, name, avatar, gender, track, hourlyRate, maskedIdCard, tagsJson]); return this.getCompanionById(res.insertId); } async auditCompanion(id: number, action: 'approve' | 'reject' | 'revoke', reason?: string): Promise { try { await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status"); } catch (_) {} try { await pool.query('ALTER TABLE users ADD COLUMN is_expert TINYINT DEFAULT 0'); } catch (_) {} const isApprove = action === 'approve'; const isRevoke = action === 'revoke'; const auditStatus = isApprove ? '已通过' : '已驳回'; const status = isApprove ? '接单中' : (isRevoke ? '休息中' : '已驳回'); const rejectReason = isApprove ? '' : isRevoke ? (reason || '平台已解除您的大神认证资质,请整改后重新提交认证') : (reason || '提交的资质或语音未通过人工核验,请修改后重新提交'); await pool.query(` UPDATE companions SET audit_status = ?, status = ?, reject_reason = ? WHERE id = ? `, [auditStatus, status, rejectReason, id]); const [rows]: any = await pool.query('SELECT * FROM companions WHERE id = ?', [id]); const comp = rows && rows[0]; // 联动更新关联用户的角色与大神状态及双身份标识 role_flags (玩家:1, 大神:2, 双身份:3) if (comp) { let uId = Number(comp.user_id || 0); if (uId <= 0 && comp.name) { try { const [uRows]: any = await pool.query('SELECT id FROM users WHERE nickname = ? LIMIT 1', [comp.name]); if (uRows && uRows.length > 0) { uId = Number(uRows[0].id); } } catch (_) {} } if (uId > 0) { try { if (isApprove) { await pool.query('UPDATE users SET role = "认证大神", is_expert = 1 WHERE id = ?', [uId]); await pool.query('UPDATE users SET role_flags = 3 WHERE id = ?', [uId]).catch(() => {}); } else { await pool.query('UPDATE users SET role = "普通用户", is_expert = 0 WHERE id = ?', [uId]); await pool.query('UPDATE users SET role_flags = 1 WHERE id = ?', [uId]).catch(() => {}); } } catch (_) { if (isApprove) { await pool.query('UPDATE users SET role = "认证大神" WHERE id = ?', [uId]); } else { await pool.query('UPDATE users SET role = "普通用户" WHERE id = ?', [uId]); } } } } return { ...(comp || {}), auditStatus, status, rejectReason, certStatus: isApprove ? 2 : 3, message: isApprove ? '资质审核通过,用户已获得大神接单权限' : isRevoke ? `已解除大神认证资质: ${rejectReason}` : `已驳回: ${rejectReason}` }; } /** * 判断订单是否通过微信支付成功付款(用于退款渠道分流)。 * 返回 totalCents 为原支付金额(分)。 */ private async orderPaidViaWechat(orderId: string): Promise<{ wechat: boolean; totalCents: number }> { try { const [rows]: any = await pool.query( `SELECT amount_cents FROM payment_intents WHERE scene = 'ORDER' AND status = 'SUCCESS' AND (order_id = ? OR out_trade_no = ?) ORDER BY id DESC LIMIT 1`, [orderId, orderId] ); if (rows && rows.length > 0) { return { wechat: true, totalCents: Number(rows[0].amount_cents) || 0 }; } } catch (e) { logServerError('orderPaidViaWechat', e); } return { wechat: false, totalCents: 0 }; } /** 查询微信退款记录 */ async getRefundByOutRefundNo(outRefundNo: string): Promise { const [rows]: any = await pool.query('SELECT * FROM payment_refunds WHERE out_refund_no = ? LIMIT 1', [outRefundNo]); return (rows && rows[0]) || null; } /** 写入/更新微信退款记录(幂等) */ async upsertRefundRecord(data: { outRefundNo: string; outTradeNo: string; orderId: string; refundId?: string; refundCents: number; totalCents: number; status: string; reason?: string; }): Promise { await pool.query( `INSERT INTO payment_refunds (out_refund_no, out_trade_no, order_id, refund_id, refund_cents, total_cents, status, reason, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE refund_id = VALUES(refund_id), status = VALUES(status), reason = VALUES(reason)`, [ data.outRefundNo, data.outTradeNo, data.orderId, data.refundId || '', Math.round(Number(data.refundCents) || 0), Math.round(Number(data.totalCents) || 0), data.status || 'PROCESSING', (data.reason || '').slice(0, 255), ] ); return { ...data }; } /** * 执行订单退款(按支付渠道分流): * - 微信支付订单:调用微信原路退款(幂等 out_refund_no),失败抛错(不回退余额,防重复退款); * - 余额支付订单:退回用户钱包余额。 */ private async refundOrderPayment( conn: any, order: any, refundCents: number, reason: string, financeType: string, tag: string ): Promise<{ viaWechat: boolean }> { const orderId = String(order.id); const cents = Math.max(0, Math.round(Number(refundCents) || 0)); const amountYuan = Number((cents / 100).toFixed(2)); if (amountYuan <= 0) return { viaWechat: false }; const paid = await this.orderPaidViaWechat(orderId); if (paid.wechat) { const outRefundNo = `${tag}_${orderId}`.slice(0, 64); const r = await tryRefundToWeChat(orderId, cents, paid.totalCents || cents, reason, outRefundNo); if (r && r.ok) { await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW()) `, [ `fin_wxr_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`, `WXR${Date.now()}${Math.floor(100 + Math.random() * 900)}`, Number(order.buyer_id) || 0, financeType, amountYuan.toFixed(2), '微信支付原路退款', orderId, ]); return { viaWechat: true }; } const err: any = new Error(`微信原路退款失败:${(r && r.error) || '未知错误'}`); err.status = 502; err.code = 50200; throw err; } // 余额支付:退回钱包余额 const buyerId = Number(order.buyer_id) || 0; if (buyerId > 0) { await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [amountYuan, buyerId]); await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW()) `, [ `fin_bref_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`, `BR${Date.now()}${Math.floor(100 + Math.random() * 900)}`, buyerId, financeType, amountYuan.toFixed(2), `${reason || '订单退款'} (${order.order_no || order.id})`, orderId, ]); } return { viaWechat: false }; } // 扫描并执行订单超时自动流转 (15分钟待付款自动取消 / 30分钟待接单自动退款 / 48小时待确认自动完成结算) async processOrderTimeouts(): Promise<{ cancelledCount: number; refundedCount: number; completedCount: number }> { let cancelledCount = 0; let refundedCount = 0; let completedCount = 0; try { // 1. 15分钟超时未支付 -> 自动流转为已取消 const [cancelRes]: any = await pool.query(` UPDATE orders SET status = '已取消' WHERE status IN ('待支付', '待付款') AND created_at <= DATE_SUB(NOW(), INTERVAL 15 MINUTE) `); cancelledCount = Number(cancelRes?.affectedRows || 0); } catch (_) {} try { // 2. 30分钟超时未接单 -> 自动流转为已全额退款并退还买家余额 const [expiredAcceptRows]: any = await pool.query(` SELECT * FROM orders WHERE status = '待接单' AND COALESCE(paid_at, created_at) <= DATE_SUB(NOW(), INTERVAL 30 MINUTE) `); for (const ord of expiredAcceptRows || []) { const [upd]: any = await pool.query( "UPDATE orders SET status = '已全额退款', refund_amount = amount WHERE id = ? AND status = '待接单'", [ord.id] ); if (upd && upd.affectedRows === 1) { const refundAmt = Number(ord.amount || 0); try { if (refundAmt > 0) { await this.refundOrderPayment(pool as any, ord, Math.round(refundAmt * 100), '超时未接单退款', '超时未接单自动退款', 'RFND_TMO'); } await this.releaseCouponWithConn(pool, ord.id); refundedCount += 1; } catch (e) { // 退款失败(如微信原路退款异常):回滚状态,等待下次扫描重试,避免"已退款但未到账" try { await pool.query("UPDATE orders SET status = '待接单', refund_amount = 0 WHERE id = ?", [ord.id]); } catch (_) {} logServerError('processOrderTimeouts.refund', e); } } } } catch (_) {} try { // 3. 48小时待确认超时 -> 自动确认完成并解冻结算至大神钱包 const [expiredConfirmRows]: any = await pool.query(` SELECT id FROM orders WHERE status = '待确认' AND COALESCE(finish_applied_at, created_at) <= DATE_SUB(NOW(), INTERVAL 48 HOUR) `); for (const ord of expiredConfirmRows || []) { await this.completeOrderWithSettlement(String(ord.id)); completedCount += 1; } } catch (_) {} return { cancelledCount, refundedCount, completedCount }; } // 订单相关 async getOrders(): Promise { // 订单超时自动流转统一由后台定时任务处理(见 index.ts startOrderTimeoutScheduler) const [rows]: any = await pool.query(` SELECT id, COALESCE(buyer_id, 1001) as buyerId, COALESCE(expert_id, 101) as expertId, order_no as orderNo, client_name as clientName, companion_name as companionName, expert_avatar as expertAvatar, track, COALESCE(service_title, CONCAT(track, ' · 专业服务')) as serviceTitle, expense_burden as expenseBurden, hourly_rate as hourlyRate, duration_hours as durationHours, amount, COALESCE(coupon_discount, 0.00) as couponDiscount, COALESCE(refund_amount, 0.00) as refundAmount, COALESCE(pay_type, 'WECHAT') as payType, location, verify_code as verifyCode, remark, status, paid_at as paidAt, finish_applied_at as finishAppliedAt, created_at as createdAt FROM orders ORDER BY created_at DESC LIMIT 500 `); return rows; } /** * 仅返回与指定用户相关的订单(买家 / 承接大神 / 已认领 / 其大神档案对应订单)。 * 用于替代把全平台订单下发给任意客户端,避免订单隐私与核销码泄露。 */ async getOrdersForUser(userId: number): Promise { const uid = Number(userId) || 0; // 订单超时自动流转统一由后台定时任务处理(见 index.ts startOrderTimeoutScheduler) const [rows]: any = await pool.query(` SELECT id, COALESCE(buyer_id, 1001) as buyerId, COALESCE(expert_id, 101) as expertId, order_no as orderNo, client_name as clientName, companion_name as companionName, expert_avatar as expertAvatar, track, COALESCE(service_title, CONCAT(track, ' · 专业服务')) as serviceTitle, expense_burden as expenseBurden, hourly_rate as hourlyRate, duration_hours as durationHours, amount, COALESCE(coupon_discount, 0.00) as couponDiscount, COALESCE(refund_amount, 0.00) as refundAmount, COALESCE(pay_type, 'WECHAT') as payType, location, verify_code as verifyCode, remark, status, paid_at as paidAt, finish_applied_at as finishAppliedAt, created_at as createdAt FROM orders WHERE buyer_id = ? OR expert_id = ? OR claimed_by = ? OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?) ORDER BY created_at DESC LIMIT 500 `, [uid, uid, uid, uid, uid]); return rows; } async createOrder(data: { buyerId?: number; clientName?: string; expertId?: number; companionName?: string; expertAvatar?: string; track?: string; serviceTitle?: string; hourlyRate?: number; durationHours?: number; amount?: number; couponDiscount?: number; userCouponId?: string; payType?: string; location?: string; expenseBurden?: string; remark?: string; status?: string; }): Promise { const id = `ord_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const orderNo = `PW${Date.now()}${Math.floor(100 + Math.random() * 900)}`; const buyerId = Number(data.buyerId) || 1001; const [userRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [buyerId]); if (userRows && userRows.length > 0 && (userRows[0].status === '已封禁' || userRows[0].status === '封禁')) { const err: any = new Error('您的账号已被管理员封禁,无法下单或预约陪玩'); err.status = 403; err.code = 40300; throw err; } const clientName = data.clientName || '超能电竞玩家'; const expertId = Number(data.expertId) || 101; const companionName = data.companionName || '同城大神'; const expertAvatar = data.expertAvatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120'; const track = data.track || '同城生活'; const serviceTitle = data.serviceTitle || `${track} · 专业陪伴服务`; const durationHours = Math.min(24, Math.max(1, Math.round(Number(data.durationHours) || 2))); // 服务单价以服务端大神档案为准(本人基础价,或该大神名下服务包价格),杜绝客户端自定义低价 let hourlyRate = Number(data.hourlyRate) || 35; try { const comp = await this.resolveCompanionRow(expertId); if (comp) { const compRate = Number(comp.hourly_rate) || 0; let valid = compRate > 0 && Number(hourlyRate) === compRate; if (!valid) { const services = await this.getExpertServices(comp.id).catch(() => []); valid = Array.isArray(services) && services.some((s: any) => Number(s.price) === Number(hourlyRate)); } if (!valid && compRate > 0) { hourlyRate = compRate; } } } catch (e) { logServerError('createOrder.validateRate', e); } // 优惠券:优先使用"我的券"(userCouponId,校验归属/门槛/类目/有效期),否则退回面额匹配平台有效券 const baseAmount = Number((hourlyRate * durationHours).toFixed(2)); let couponDiscount = 0; let userCouponId = ''; if (data.userCouponId) { const resolved = await this.resolveUserCoupon(String(data.userCouponId), buyerId, track, baseAmount); if (!resolved) { const err: any = new Error('所选优惠券不可用(不满足使用门槛/类目,或已使用/已过期)'); err.status = 400; err.code = 40000; throw err; } couponDiscount = resolved.discount; userCouponId = resolved.userCouponId; } else { const rawDiscount = Math.max(0, Number(data.couponDiscount || 0)); couponDiscount = rawDiscount > 0 ? await this.resolveCouponDiscount(rawDiscount, track, baseAmount) : 0; } // 订单金额由服务端权威计算,杜绝客户端伪造低价下单 const amount = Number(Math.max(baseAmount - couponDiscount, 0.01).toFixed(2)); const payType = data.payType === 'BALANCE' ? 'BALANCE' : 'WECHAT'; const location = data.location || '朝阳大悦城'; const expenseBurden = data.expenseBurden || 'AA制各自付'; const verifyCode = String(Math.floor(1000 + Math.random() * 9000)); const remark = data.remark || ''; // 初始订单状态由服务端按支付方式决定,杜绝客户端创建"已支付/已履约/已完成"订单绕过付款 const status = payType === 'BALANCE' ? '履约中' : '待付款'; const conn = await pool.getConnection(); try { await conn.beginTransaction(); // 如果是余额支付,先锁行校验扣减用户钱包余额 if (payType === 'BALANCE') { const [uRows]: any = await conn.query('SELECT balance FROM users WHERE id = ? FOR UPDATE', [buyerId]); if (!uRows || uRows.length === 0) { throw new Error('支付账户不存在'); } const userBalance = Number(uRows[0].balance || 0); if (userBalance < amount) { throw new Error(`钱包余额不足 (当前可用 ¥${userBalance.toFixed(2)},需支付 ¥${amount.toFixed(2)})`); } const newBalance = userBalance - amount; await conn.query('UPDATE users SET balance = ? WHERE id = ?', [newBalance, buyerId]); // 记一笔钱包扣款流水 const walletFinId = `fin_bal_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const walletRecNo = `WB${Date.now()}${Math.floor(Math.random() * 100)}`; await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) VALUES (?, ?, ?, '钱包余额支付订单', ?, 'OUT', ?, ?) `, [walletFinId, walletRecNo, buyerId, amount.toFixed(2), `用户 ${buyerId} (${companionName})`, id]); } await conn.query(` INSERT INTO orders ( id, buyer_id, expert_id, order_no, client_name, companion_name, expert_avatar, track, service_title, expense_burden, hourly_rate, duration_hours, amount, coupon_discount, user_coupon_id, pay_type, location, verify_code, remark, status, paid_at, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW()) `, [ id, buyerId, expertId, orderNo, clientName, companionName, expertAvatar, track, serviceTitle, expenseBurden, hourlyRate, durationHours, amount, couponDiscount, userCouponId || null, payType, location, verifyCode, remark, status ]); // 占用"我的券"(与订单同事务,避免下单成功但券未核销导致重复使用) if (userCouponId) { await conn.query( "UPDATE user_coupons SET status = 'USED', order_id = ?, used_at = NOW() WHERE id = ? AND status = 'UNUSED'", [id, userCouponId] ); } // 记一笔平台订单进账流水 const finId = `fin_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const recordNo = `TX${Date.now()}${Math.floor(Math.random() * 100)}`; await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id) VALUES (?, ?, ?, ?, ?, 'IN', ?, ?) `, [finId, recordNo, buyerId, payType === 'BALANCE' ? '服务预约订单付款 (钱包余额)' : '服务预约订单付款 (微信支付)', amount.toFixed(2), clientName, id]); await conn.commit(); return { id, orderNo, buyerId, clientName, expertId, companionName, expertAvatar, track, serviceTitle, expenseBurden, hourlyRate, durationHours, amount, couponDiscount, payType, location, verifyCode, remark, status, createdAt: new Date().toISOString() }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async updateOrderStatus(id: string, newStatus: string): Promise { if (newStatus === '已完成') { return this.completeOrderWithSettlement(id); } if (newStatus === '待接单' || newStatus === '履约中' || newStatus === '服务中') { await pool.query('UPDATE orders SET status = ?, paid_at = COALESCE(paid_at, NOW()) WHERE id = ? OR order_no = ?', [newStatus, id, id]); } else if (newStatus === '待确认') { await pool.query('UPDATE orders SET status = ?, finish_applied_at = NOW() WHERE id = ? OR order_no = ?', [newStatus, id, id]); } else { await pool.query('UPDATE orders SET status = ? WHERE id = ? OR order_no = ?', [newStatus, id, id]); } const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [id, id]); return rows[0] || null; } /** * 买家取消订单(仅限未进入履约的订单): * - 未支付(待支付/待付款):直接取消; * - 已支付待接单:取消并原路退回买家钱包余额(避免买家已付款却被静默吞掉资金); * - 已进入履约/已完成/已退款等:拒绝,引导走退款申诉(仲裁)。 */ async buyerCancelOrder(orderId: string): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [orderId, orderId]); if (!rows || rows.length === 0) { const err: any = new Error('订单不存在'); err.status = 404; err.code = 40400; throw err; } const order = rows[0]; const st = String(order.status); const blocked = ['履约中', '服务中', '待确认', '已到场核销', '已完成', '已取消', '已退款', '已全额退款', '已部分退款', '争议仲裁中']; if (blocked.includes(st)) { const err: any = new Error(`订单当前状态【${st}】不可直接取消,请发起退款申诉`); err.status = 409; err.code = 40900; throw err; } if (st === '待接单') { const refundAmount = Number(order.amount || 0); await conn.query('UPDATE orders SET status = "已退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]); await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '买家取消订单', '买家取消订单退款', 'RFND_CX'); await this.releaseCouponWithConn(conn, order.id); } else { await conn.query('UPDATE orders SET status = "已取消" WHERE id = ?', [order.id]); await this.releaseCouponWithConn(conn, order.id); } await conn.commit(); const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]); return updated[0] || order; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } /** * 解析订单归属关系(买家 / 大神),用于写接口的身份越权校验。 * 大神身份采用「并集」策略:claimed_by(抢单人)∪ expert_id ∪ companions.user_id, * 以兼容历史演示数据中 expert_id 可能是同伴ID或用户ID的混用情况。 */ async getOrderOwnership(id: string): Promise { const [rows]: any = await pool.query(` SELECT o.id, o.order_no as orderNo, o.buyer_id as buyerId, o.expert_id as expertId, o.claimed_by as claimedBy, o.status, o.amount, o.companion_name as companionName, c.user_id as companionUserId FROM orders o LEFT JOIN companions c ON c.id = o.expert_id WHERE o.id = ? OR o.order_no = ? LIMIT 1 `, [id, id]); if (!rows || rows.length === 0) return null; const r = rows[0]; const companionIds = Array.from( new Set( [r.claimedBy, r.expertId, r.companionUserId] .map((v: any) => Number(v)) .filter((v: number) => v > 0) ) ); return { id: r.id, orderNo: r.orderNo, buyerId: Number(r.buyerId) || 0, amount: Number(r.amount) || 0, companionName: r.companionName || '', status: r.status, companionIds, }; } // 大神点击申请完成服务 (服务中 -> 待确认,进入 48 小时买家确认窗口) async applyCompleteOrder(orderId: string): Promise { const [cur]: any = await pool.query('SELECT status FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]); if (!cur || cur.length === 0) { const err: any = new Error('订单不存在'); err.status = 404; err.code = 40400; throw err; } // 状态闸门:仅已进入履约的订单可申请完成,杜绝把未付款(待付款/待支付/待接单)订单推进为待确认再触发结算 const allowed = ['履约中', '服务中', '已到场核销', '待确认']; if (!allowed.includes(String(cur[0].status))) { const err: any = new Error(`当前订单状态【${cur[0].status}】不可申请完成`); err.status = 409; err.code = 40900; throw err; } await pool.query( "UPDATE orders SET status = '待确认', finish_applied_at = NOW() WHERE id = ? OR order_no = ?", [orderId, orderId] ); const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [orderId, orderId]); return rows[0] || null; } // 大神拒单自动全额退款 (待接单 -> 已退款,资金退回买家钱包余额) async rejectOrder(orderId: string, data?: { expertId?: number; reason?: string }): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [orderId, orderId]); if (!rows || rows.length === 0) { throw new Error('订单不存在'); } const order = rows[0]; // 状态闸门:仅「待接单」可拒单,杜绝已进入履约/已完成的订单被拒单触发全额退款 if (String(order.status) !== '待接单') { const err: any = new Error(`当前订单状态【${order.status}】不可拒单(仅待接单可拒单)`); err.status = 409; err.code = 40900; throw err; } const rejectNote = data?.reason ? ` [大神拒单: ${data.reason}]` : ' [大神拒单自动退款]'; await conn.query( "UPDATE orders SET status = '已全额退款', refund_amount = amount, remark = CONCAT(COALESCE(remark, ''), ?) WHERE id = ?", [rejectNote.slice(0, 60), order.id] ); const refundAmount = Number(order.amount || 0); if (refundAmount > 0) { await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '大神拒单', '大神拒单自动退款', 'RFND_REJ'); } await this.releaseCouponWithConn(conn, order.id); await conn.commit(); const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]); return updated[0] || order; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } /** * 解析大神结算收款用户ID(唯一): * 优先 companions.user_id;未绑定则按昵称唯一解析;再无则回落 expert_id。 * 严禁使用 `id = ? OR nickname = ?` 这类会同时命中多个用户的模糊匹配,防止改名重复入账。 */ private async resolveExpertCreditUserId(conn: any, order: any, comp: any): Promise { const byUser = Number(comp?.user_id || 0); if (byUser > 0) return byUser; try { const [uRows]: any = await conn.query('SELECT id FROM users WHERE nickname = ? LIMIT 1', [order.companion_name || '']); const uid = Number((uRows && uRows[0] && uRows[0].id) || 0); if (uid > 0) return uid; } catch (e) { logServerError('resolveExpertCreditUserId', e); } return Number(order.expert_id) || 101; } async completeOrderWithSettlement(id: string): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); // 行级锁锁定订单,串行化并发完成/结算(超时自动完成、买家确认、评价触发 可能并发) const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [id, id]); const order = rows && rows[0]; if (!order) { await conn.rollback(); return null; } // 状态闸门:仅「已进入履约/待确认」等具备已完成服务语义的订单可结算; // 严禁对未支付、未接单、已取消、已退款的订单越权触发资金结算(防前端直接结算套现)。 const settleBlocked = ['待支付', '待付款', '待接单', '已取消', '已退款', '已全额退款']; if (settleBlocked.includes(String(order.status))) { const e: any = new Error(`订单当前状态【${order.status}】不可结算,已阻止越权触发资金结算`); e.status = 409; e.code = 40900; throw e; } // 幂等:持锁后再次确认是否已结算,杜绝并发重复结算(同一订单只结算一次) const [settledRows]: any = await conn.query( "SELECT id FROM finance_records WHERE related_id = ? AND type = '大神完单收益结算' LIMIT 1", [String(order.id)] ); const alreadySettled = settledRows && settledRows.length > 0; await conn.query("UPDATE orders SET status = '已完成' WHERE id = ?", [order.id]); if (!alreadySettled) { const [cfgRows]: any = await conn.query('SELECT * FROM finance_config WHERE id = 1 LIMIT 1'); const cfg = (cfgRows && cfgRows[0]) || { platform_rate_percent: 10, first_referral_percent: 5, second_referral_percent: 2 }; const platformPct = Number(cfg.platform_rate_percent ?? 10); const firstRefPct = Number(cfg.first_referral_percent ?? 5); const secondRefPct = Number(cfg.second_referral_percent ?? 2); const orderAmount = Number(order.amount) || 0; if (orderAmount > 0) { const platformFee = Number(((orderAmount * platformPct) / 100).toFixed(2)); const expertIncome = Number(Math.max(0, orderAmount - platformFee).toFixed(2)); const comp = await this.resolveCompanionRow(order.expert_id || order.companion_name || 101); if (comp) { await conn.query('UPDATE companions SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [comp.id]); } const expertUserId = await this.resolveExpertCreditUserId(conn, order, comp); await conn.query( 'UPDATE users SET commission = COALESCE(commission, 0) + ?, order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [expertIncome, expertUserId] ); const buyerId = Number(order.buyer_id || 1001); await conn.query( 'UPDATE users SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [buyerId] ); const ts = Date.now(); const rand = () => Math.floor(100 + Math.random() * 900); await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '大神完单收益结算', ?, 'IN', ?, ?, NOW())`, [ `fin_settle_${ts}_${rand()}`, `JS${ts}${rand()}`, expertUserId, expertIncome, order.client_name || '买家客户', String(order.id) ] ); if (platformFee > 0) { await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, 0, ?, ?, 'IN', ?, ?, NOW())`, [ `fin_plat_${ts}_${rand()}`, `PT${ts}${rand()}`, `平台抽成 (${platformPct}%)`, platformFee, order.companion_name || '认证大神', String(order.id) ] ); } // 二级分销自动返佣结算 const [buyerRows]: any = await conn.query('SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1', [buyerId]); const buyer = buyerRows && buyerRows[0]; const fromUserName = buyer?.nickname || order.client_name || '客户玩家'; const orderNo = order.order_no || String(order.id); if (buyer && buyer.inviter_id && firstRefPct > 0) { const lv1UserId = Number(buyer.inviter_id); const comm1 = Number(((orderAmount * firstRefPct) / 100).toFixed(2)); if (comm1 > 0 && lv1UserId > 0 && lv1UserId !== buyerId) { await conn.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm1, lv1UserId]); await conn.query( `INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, '已入账', NOW())`, [`dst_1_${ts}_${rand()}`, lv1UserId, buyerId, fromUserName, orderNo, orderAmount, comm1, firstRefPct] ); await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`, [ `fin_ref1_${ts}_${rand()}`, `FX1${ts}${rand()}`, lv1UserId, `一级分销佣金 (${firstRefPct}%)`, comm1, fromUserName, String(order.id) ] ); } const [lv1Rows]: any = await conn.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [lv1UserId]); const lv1User = lv1Rows && lv1Rows[0]; if (lv1User && lv1User.inviter_id && secondRefPct > 0) { const lv2UserId = Number(lv1User.inviter_id); const comm2 = Number(((orderAmount * secondRefPct) / 100).toFixed(2)); if (comm2 > 0 && lv2UserId > 0 && lv2UserId !== buyerId && lv2UserId !== lv1UserId) { await conn.query('UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [comm2, lv2UserId]); await conn.query( `INSERT INTO distribution_records (id, user_id, from_user_id, from_user_name, order_no, order_amount, commission, level, rate_percent, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, 2, ?, '已入账', NOW())`, [`dst_2_${ts}_${rand()}`, lv2UserId, buyerId, fromUserName, orderNo, orderAmount, comm2, secondRefPct] ); await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, ?, ?, 'IN', ?, ?, NOW())`, [ `fin_ref2_${ts}_${rand()}`, `FX2${ts}${rand()}`, lv2UserId, `二级分销佣金 (${secondRefPct}%)`, comm2, fromUserName, String(order.id) ] ); } } } } } await conn.commit(); const [updatedRows]: any = await conn.query('SELECT * FROM orders WHERE id = ?', [order.id]); return updatedRows[0] || order; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async getOrdersPool(): Promise { const [rows]: any = await pool.query(` SELECT id, order_no as orderNo, client_name as clientName, track as category, service_title as serviceTitle, hourly_rate as hourlyRate, duration_hours as durationHours, amount, location, expense_burden as payMethod, status, created_at as createdAt FROM orders WHERE status = '待接单' AND (claimed_by IS NULL OR claimed_by = 0) ORDER BY created_at DESC LIMIT 20 `); // 默认池保障 const maskClientName = (n: any): string => { const s = String(n || '').trim(); if (!s) return '客户老板'; if (s.length <= 1) return `${s}**`; return `${s[0]}**`; }; const poolList = (rows || []).map((r: any) => ({ id: r.id, category: r.category || '同城生活', title: r.serviceTitle || `${r.category} · 专业同城陪伴`, location: r.location || '北京市朝阳区商圈', reward: Number(r.amount) || 120, timeSlot: '今日内随时可约', userName: maskClientName(r.clientName), userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120', payMethod: r.payMethod || 'AA制各付', countdown: 45, status: r.status, })); return poolList; } async claimOrder(orderId: string, expertData: { expertId: number; companionName: string; expertAvatar?: string }): Promise { const expertId = Number(expertData.expertId) || 0; if (expertId > 0) { const comp = await this.resolveCompanionRow(expertId); if (comp) { if (comp.audit_status && comp.audit_status !== '已通过') { throw new Error('大神档案未通过实名认证审核,无法接单'); } const [uRows]: any = await pool.query('SELECT status FROM users WHERE id = ?', [comp.user_id || comp.id]); if (uRows && uRows.length > 0 && (uRows[0].status === '已封禁' || uRows[0].status === '封禁')) { const err: any = new Error('您的账号已被管理员封禁,无法接单'); err.status = 403; err.code = 40300; throw err; } } } if (!expertId) { const err: any = new Error('缺少接单大神身份,无法接单'); err.status = 400; err.code = 40000; throw err; } const [curRows]: any = await pool.query('SELECT id, status, claimed_by FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]); const cur = curRows && curRows[0]; if (!cur) { const err: any = new Error('订单不存在'); err.status = 404; err.code = 40400; throw err; } // 状态闸门:仅「待接单」可被抢单,严禁抢已在履约/已服务/已核销的订单 if (String(cur.status) !== '待接单') { const err: any = new Error(`当前订单状态(${cur.status})不可接单(仅待接单可抢)`); err.status = 409; err.code = 40900; throw err; } const claimedBy = Number(cur.claimed_by || 0); if (claimedBy && claimedBy !== expertId) { const err: any = new Error('该订单已被其他大神接单'); err.status = 409; err.code = 40900; throw err; } // 原子 CAS:以 claimed_by 作为唯一认领标记,并发抢单时数据库行级更新保证只有一个赢家。 // 未认领(claimed_by IS NULL) 或 本人重入(claimed_by = 自己) 才允许写入。 const [res]: any = await pool.query(` UPDATE orders SET status = '履约中', expert_id = ?, companion_name = ?, expert_avatar = ?, claimed_by = ?, claimed_at = NOW(), paid_at = COALESCE(paid_at, NOW()) WHERE (id = ? OR order_no = ?) AND status = '待接单' AND (claimed_by IS NULL OR claimed_by = ?) `, [expertId, expertData.companionName || '我 (接单大神)', expertData.expertAvatar || '', expertId, orderId, orderId, expertId]); if (!res || res.affectedRows !== 1) { const err: any = new Error('该订单已被其他大神抢先接单,请刷新订单池'); err.status = 409; err.code = 40900; throw err; } const [rows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ?', [orderId, orderId]); return rows[0] || null; } async getExpertStats(expertId: number | string): Promise { const [rows]: any = await pool.query(` SELECT COUNT(*) as totalOrders, COALESCE(SUM(CASE WHEN status IN ('已完成', '已到场核销') THEN amount ELSE 0 END), 0) as totalIncome, COALESCE(SUM(CASE WHEN status IN ('履约中', '服务中') THEN 1 ELSE 0 END), 0) as waitingVerify FROM orders WHERE expert_id = ? `, [Number(expertId) || 101]); const stat = rows[0] || {}; return { income: Number(stat.totalIncome || 0).toFixed(2), orders: Number(stat.totalOrders || 0), waitingVerify: Number(stat.waitingVerify || 0), }; } // 大神收益走势分析 (日/周/月多维图表数据) async getExpertRevenueTrends(expertId: number | string): Promise<{ daily: { label: string; income: number; orders: number; avgPrice: number }[]; weekly: { label: string; income: number; orders: number; avgPrice: number }[]; monthly: { label: string; income: number; orders: number; avgPrice: number }[]; }> { const eId = Number(expertId) || 101; const now = new Date(); // 1. 近 7 天走势 const daily: { label: string; income: number; orders: number; avgPrice: number }[] = []; for (let i = 6; i >= 0; i--) { const d = new Date(now.getTime() - i * 86400000); const mm = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); const dateStr = `${d.getFullYear()}-${mm}-${dd}`; const label = `${mm}-${dd}`; try { const [dayRows]: any = await pool.query(` SELECT COUNT(*) as orders, COALESCE(SUM(amount), 0) as income FROM orders WHERE expert_id = ? AND DATE(created_at) = ? AND status IN ('已完成', '已到场核销', '服务中', '待确认') `, [eId, dateStr]); const count = Number(dayRows[0]?.orders || 0); const inc = Number(Number(dayRows[0]?.income || 0).toFixed(2)); const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0; daily.push({ label, income: inc, orders: count, avgPrice: avg }); } catch (_) { daily.push({ label, income: 0, orders: 0, avgPrice: 0 }); } } // 2. 近 4 周走势 const weekly: { label: string; income: number; orders: number; avgPrice: number }[] = []; for (let w = 3; w >= 0; w--) { const startD = new Date(now.getTime() - (w * 7 + 6) * 86400000); const endD = new Date(now.getTime() - w * 7 * 86400000); const label = w === 0 ? '本周' : `${w}周前`; try { const [wRows]: any = await pool.query(` SELECT COUNT(*) as orders, COALESCE(SUM(amount), 0) as income FROM orders WHERE expert_id = ? AND created_at >= ? AND created_at <= ? AND status IN ('已完成', '已到场核销', '服务中', '待确认') `, [eId, startD, endD]); const count = Number(wRows[0]?.orders || 0); const inc = Number(Number(wRows[0]?.income || 0).toFixed(2)); const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0; weekly.push({ label, income: inc, orders: count, avgPrice: avg }); } catch (_) { weekly.push({ label, income: 0, orders: 0, avgPrice: 0 }); } } // 3. 近 6 个月走势 const monthly: { label: string; income: number; orders: number; avgPrice: number }[] = []; for (let m = 5; m >= 0; m--) { const d = new Date(now.getFullYear(), now.getMonth() - m, 1); const label = `${d.getMonth() + 1}月`; const yearMonth = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; try { const [mRows]: any = await pool.query(` SELECT COUNT(*) as orders, COALESCE(SUM(amount), 0) as income FROM orders WHERE expert_id = ? AND DATE_FORMAT(created_at, '%Y-%m') = ? AND status IN ('已完成', '已到场核销', '服务中', '待确认') `, [eId, yearMonth]); const count = Number(mRows[0]?.orders || 0); const inc = Number(Number(mRows[0]?.income || 0).toFixed(2)); const avg = count > 0 ? Number((inc / count).toFixed(2)) : 0; monthly.push({ label, income: inc, orders: count, avgPrice: avg }); } catch (_) { monthly.push({ label, income: 0, orders: 0, avgPrice: 0 }); } } return { daily, weekly, monthly }; } private formatBanner(banner: any): any { if (!banner) return null; let image = banner.image || ''; if (image && image.startsWith('/uploads/')) { image = `https://peiww.gynhc.com${image}`; } return { ...banner, image, }; } async getBanners(): Promise { const [rows]: any = await pool.query(` SELECT id, title, subtitle, tag, badge, badge_type as badgeType, bg, image, link, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM banners WHERE status = 1 ORDER BY sort_order ASC, id DESC `); return (rows || []).map((b: any) => this.formatBanner(b)); } async getAdminBanners(): Promise { const [rows]: any = await pool.query(` SELECT id, title, subtitle, tag, badge, badge_type as badgeType, bg, image, link, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM banners ORDER BY sort_order ASC, id DESC `); return (rows || []).map((b: any) => this.formatBanner(b)); } async createBanner(data: { title: string; subtitle?: string; tag?: string; badge?: string; badgeType?: string; bg?: string; image?: string; link?: string; sortOrder?: number; status?: number; }): Promise { const title = data.title; const subtitle = data.subtitle || ''; const tag = data.tag || '热门活动'; const badge = data.badge || 'HOT 热门组局'; const badgeType = data.badgeType || 'badge-hot'; const bg = data.bg || 'linear-gradient(135deg, #F43F5E 0%, #FB7185 100%)'; const image = data.image || ''; const link = data.link || '/pages/activity/list'; const sortOrder = Number(data.sortOrder) || 0; const status = data.status !== undefined ? Number(data.status) : 1; const [res]: any = await pool.query(` INSERT INTO banners (title, subtitle, tag, badge, badge_type, bg, image, link, sort_order, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [title, subtitle, tag, badge, badgeType, bg, image, link, sortOrder, status]); const newId = res.insertId; const [rows]: any = await pool.query(` SELECT id, title, subtitle, tag, badge, badge_type as badgeType, bg, image, link, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM banners WHERE id = ? `, [newId]); return this.formatBanner(rows[0]); } async updateBanner(id: number | string, data: Partial<{ title: string; subtitle: string; tag: string; badge: string; badgeType: string; bg: string; image: string; link: string; sortOrder: number; status: number; }>): Promise { const fields: string[] = []; const values: any[] = []; if (data.title !== undefined) { fields.push('title = ?'); values.push(data.title); } if (data.subtitle !== undefined) { fields.push('subtitle = ?'); values.push(data.subtitle); } if (data.tag !== undefined) { fields.push('tag = ?'); values.push(data.tag); } if (data.badge !== undefined) { fields.push('badge = ?'); values.push(data.badge); } if (data.badgeType !== undefined) { fields.push('badge_type = ?'); values.push(data.badgeType); } if (data.bg !== undefined) { fields.push('bg = ?'); values.push(data.bg); } if (data.image !== undefined) { fields.push('image = ?'); values.push(data.image); } if (data.link !== undefined) { fields.push('link = ?'); values.push(data.link); } if (data.sortOrder !== undefined) { fields.push('sort_order = ?'); values.push(Number(data.sortOrder)); } if (data.status !== undefined) { fields.push('status = ?'); values.push(Number(data.status)); } if (fields.length > 0) { values.push(id); await pool.query(`UPDATE banners SET ${fields.join(', ')} WHERE id = ?`, values); } const [rows]: any = await pool.query(` SELECT id, title, subtitle, tag, badge, badge_type as badgeType, bg, image, link, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM banners WHERE id = ? `, [id]); return this.formatBanner(rows[0]) || null; } async updateBannerStatus(id: number | string, status: number): Promise { await pool.query('UPDATE banners SET status = ? WHERE id = ?', [Number(status), id]); const [rows]: any = await pool.query(` SELECT id, title, subtitle, tag, badge, badge_type as badgeType, bg, image, link, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM banners WHERE id = ? `, [id]); return this.formatBanner(rows[0]) || null; } async deleteBanner(id: number | string): Promise { const [res]: any = await pool.query('DELETE FROM banners WHERE id = ?', [id]); return res.affectedRows > 0; } async getNavCategories(): Promise { const [rows]: any = await pool.query(` SELECT id, code, name, icon, bg_class as bgClass, cat, target_url as targetUrl, badge_text as badgeText, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM nav_categories WHERE status = 1 ORDER BY sort_order ASC, id ASC `); return rows; } async getAdminNavCategories(): Promise { const [rows]: any = await pool.query(` SELECT id, code, name, icon, bg_class as bgClass, cat, target_url as targetUrl, badge_text as badgeText, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM nav_categories ORDER BY sort_order ASC, id ASC `); return rows; } async createNavCategory(data: { code?: string; name: string; icon: string; bgClass?: string; cat?: string; targetUrl?: string; badgeText?: string; sortOrder?: number; status?: number; }): Promise { const code = data.code || `cat_${Date.now()}`; const name = data.name; const icon = data.icon; const bgClass = data.bgClass || 'bg-indigo'; const cat = data.cat || 'all'; const targetUrl = data.targetUrl || '/pages/activity/list?category=ALL'; const badgeText = data.badgeText || ''; const sortOrder = Number(data.sortOrder) || 0; const status = data.status !== undefined ? Number(data.status) : 1; const [res]: any = await pool.query(` INSERT INTO nav_categories (code, name, icon, bg_class, cat, target_url, badge_text, sort_order, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [code, name, icon, bgClass, cat, targetUrl, badgeText, sortOrder, status]); const newId = res.insertId; const [rows]: any = await pool.query(` SELECT id, code, name, icon, bg_class as bgClass, cat, target_url as targetUrl, badge_text as badgeText, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM nav_categories WHERE id = ? `, [newId]); return rows[0]; } async updateNavCategory(id: number | string, data: Partial<{ name: string; icon: string; bgClass: string; cat: string; targetUrl: string; badgeText: string; sortOrder: number; status: number; }>): Promise { const fields: string[] = []; const values: any[] = []; if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); } if (data.icon !== undefined) { fields.push('icon = ?'); values.push(data.icon); } if (data.bgClass !== undefined) { fields.push('bg_class = ?'); values.push(data.bgClass); } if (data.cat !== undefined) { fields.push('cat = ?'); values.push(data.cat); } if (data.targetUrl !== undefined) { fields.push('target_url = ?'); values.push(data.targetUrl); } if (data.badgeText !== undefined) { fields.push('badge_text = ?'); values.push(data.badgeText); } if (data.sortOrder !== undefined) { fields.push('sort_order = ?'); values.push(Number(data.sortOrder)); } if (data.status !== undefined) { fields.push('status = ?'); values.push(Number(data.status)); } if (fields.length > 0) { values.push(id); await pool.query(`UPDATE nav_categories SET ${fields.join(', ')} WHERE id = ?`, values); } const [rows]: any = await pool.query(` SELECT id, code, name, icon, bg_class as bgClass, cat, target_url as targetUrl, badge_text as badgeText, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM nav_categories WHERE id = ? `, [id]); return rows[0] || null; } async updateNavCategoryStatus(id: number | string, status: number): Promise { await pool.query('UPDATE nav_categories SET status = ? WHERE id = ?', [Number(status), id]); const [rows]: any = await pool.query(` SELECT id, code, name, icon, bg_class as bgClass, cat, target_url as targetUrl, badge_text as badgeText, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM nav_categories WHERE id = ? `, [id]); return rows[0] || null; } async deleteNavCategory(id: number | string): Promise { const [res]: any = await pool.query('DELETE FROM nav_categories WHERE id = ?', [id]); return res.affectedRows > 0; } // 1. 充值阶梯 (Recharge Tiers) async getRechargeTiers(): Promise { const [rows]: any = await pool.query(` SELECT id, amount, bonus_amount as bonusAmount, tag, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM recharge_tiers WHERE status = 1 ORDER BY sort_order ASC, amount ASC `); return rows; } /** * 依据平台启用的充值阶梯,服务端权威计算赠金(分)。 * 规则:取「面额 <= 实充金额」的最高阶梯的赠金额;无匹配则赠金为 0。 * 严禁信任客户端自定义赠金,防止充值套取平台赠金。 */ async computeRechargeBonusCents(amountCents: number): Promise { const paid = Math.max(0, Math.round(Number(amountCents) || 0)); if (paid <= 0) return 0; const [rows]: any = await pool.query( 'SELECT bonus_amount FROM recharge_tiers WHERE status = 1 AND amount * 100 <= ? ORDER BY amount DESC LIMIT 1', [paid] ); if (!rows || rows.length === 0) return 0; return Math.max(0, Math.round(Number(rows[0].bonus_amount) * 100)); } async getAdminRechargeTiers(): Promise { const [rows]: any = await pool.query(` SELECT id, amount, bonus_amount as bonusAmount, tag, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM recharge_tiers ORDER BY sort_order ASC, amount ASC `); return rows; } async createRechargeTier(data: { amount: number; bonusAmount?: number; tag?: string; sortOrder?: number; status?: number; }): Promise { const amount = Number(data.amount); const bonusAmount = Number(data.bonusAmount || 0); const tag = data.tag || ''; const sortOrder = Number(data.sortOrder || 0); const status = data.status !== undefined ? Number(data.status) : 1; const [res]: any = await pool.query(` INSERT INTO recharge_tiers (amount, bonus_amount, tag, sort_order, status) VALUES (?, ?, ?, ?, ?) `, [amount, bonusAmount, tag, sortOrder, status]); const newId = res.insertId; const [rows]: any = await pool.query(` SELECT id, amount, bonus_amount as bonusAmount, tag, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM recharge_tiers WHERE id = ? `, [newId]); return rows[0]; } async updateRechargeTier(id: number | string, data: Partial<{ amount: number; bonusAmount: number; tag: string; sortOrder: number; status: number; }>): Promise { const fields: string[] = []; const values: any[] = []; if (data.amount !== undefined) { fields.push('amount = ?'); values.push(Number(data.amount)); } if (data.bonusAmount !== undefined) { fields.push('bonus_amount = ?'); values.push(Number(data.bonusAmount)); } if (data.tag !== undefined) { fields.push('tag = ?'); values.push(data.tag); } if (data.sortOrder !== undefined) { fields.push('sort_order = ?'); values.push(Number(data.sortOrder)); } if (data.status !== undefined) { fields.push('status = ?'); values.push(Number(data.status)); } if (fields.length > 0) { values.push(id); await pool.query(`UPDATE recharge_tiers SET ${fields.join(', ')} WHERE id = ?`, values); } const [rows]: any = await pool.query(` SELECT id, amount, bonus_amount as bonusAmount, tag, sort_order as sortOrder, status, created_at as createdAt, updated_at as updatedAt FROM recharge_tiers WHERE id = ? `, [id]); return rows[0] || null; } async deleteRechargeTier(id: number | string): Promise { const [res]: any = await pool.query('DELETE FROM recharge_tiers WHERE id = ?', [id]); return res.affectedRows > 0; } // 2. 优惠券 (Coupons) /** * 校验并归一化订单优惠券折扣(元):必须命中平台启用中的有效券 * (面额一致、门槛满足、品类匹配),否则返回 0(忽略该折扣),防止客户端自定义大额折扣。 */ async resolveCouponDiscount(discountYuan: number, track: string, baseAmountYuan: number): Promise { const d = Number(discountYuan) || 0; if (d <= 0) return 0; try { const [rows]: any = await pool.query( `SELECT discount_amount FROM coupons WHERE status = 1 AND discount_amount = ? AND (category_limit = 'ALL' OR category_limit = ?) AND min_spend <= ? LIMIT 1`, [d, track, baseAmountYuan] ); if (!rows || rows.length === 0) return 0; return Math.min(Number(rows[0].discount_amount) || 0, Number(baseAmountYuan) || 0); } catch (e) { logServerError('resolveCouponDiscount', e); return 0; } } /** 券中心:返回启用中的优惠券,并标记当前用户是否已领取 */ async getCouponsWithClaimed(userId: number, categoryLimit?: string): Promise { const list = await this.getCoupons(categoryLimit); const uid = Number(userId) || 0; let claimedIds = new Set(); if (uid > 0) { try { const [rows]: any = await pool.query('SELECT coupon_id FROM user_coupons WHERE user_id = ?', [uid]); claimedIds = new Set((rows || []).map((r: any) => Number(r.coupon_id))); } catch (_) {} } return list.map((c: any) => ({ ...c, claimed: claimedIds.has(Number(c.id)) })); } /** 领取优惠券(同一用户同一券仅可领一次;受 total_count 限制) */ async claimCoupon(userId: number, couponId: number): Promise { const uid = Number(userId) || 0; const cid = Number(couponId) || 0; const fail = (msg: string, status: number, code: number) => { const e: any = new Error(msg); e.status = status; e.code = code; return e; }; if (uid <= 0) throw fail('请先登录后再领取优惠券', 401, 40100); if (cid <= 0) throw fail('优惠券参数不合法', 400, 40000); const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [cRows]: any = await conn.query('SELECT * FROM coupons WHERE id = ? AND status = 1 FOR UPDATE', [cid]); if (!cRows || cRows.length === 0) throw fail('优惠券不存在或已下架', 404, 40400); const c = cRows[0]; const [own]: any = await conn.query( 'SELECT id FROM user_coupons WHERE user_id = ? AND coupon_id = ? LIMIT 1', [uid, cid] ); if (own && own.length > 0) throw fail('您已领取过该优惠券', 409, 40900); const total = Number(c.total_count) || 0; if (total > 0 && Number(c.received_count) >= total) throw fail('该优惠券已被领完', 400, 40000); const id = `uc_${cid}_${uid}_${Date.now()}`; const validDays = Number(c.valid_days) || 7; await conn.query( `INSERT INTO user_coupons (id, user_id, coupon_id, name, discount_amount, min_spend, category_limit, status, claimed_at, expired_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'UNUSED', NOW(), DATE_ADD(NOW(), INTERVAL ? DAY))`, [id, uid, cid, String(c.name), Number(c.discount_amount), Number(c.min_spend), String(c.category_limit || 'ALL'), validDays] ); await conn.query('UPDATE coupons SET received_count = received_count + 1 WHERE id = ?', [cid]); await conn.commit(); return { id, couponId: cid, name: String(c.name), discountAmount: Number(c.discount_amount), minSpend: Number(c.min_spend), categoryLimit: String(c.category_limit || 'ALL'), validDays, }; } catch (e) { try { await conn.rollback(); } catch (_) {} throw e; } finally { conn.release(); } } /** 我的优惠券(读取前顺手把已过期的标记为 EXPIRED) */ async getUserCoupons(userId: number, status?: string): Promise { const uid = Number(userId) || 0; if (uid <= 0) return []; try { await pool.query( "UPDATE user_coupons SET status = 'EXPIRED' WHERE user_id = ? AND status = 'UNUSED' AND expired_at IS NOT NULL AND expired_at < NOW()", [uid] ); } catch (_) {} let sql = ` SELECT id, coupon_id as couponId, name, discount_amount as discountAmount, min_spend as minSpend, category_limit as categoryLimit, status, order_id as orderId, claimed_at as claimedAt, expired_at as expiredAt, used_at as usedAt FROM user_coupons WHERE user_id = ? `; const params: any[] = [uid]; if (status && ['UNUSED', 'USED', 'EXPIRED'].includes(status)) { sql += ' AND status = ?'; params.push(status); } sql += ' ORDER BY (status = "UNUSED") DESC, discount_amount DESC, claimed_at DESC LIMIT 200'; const [rows]: any = await pool.query(sql, params); return rows; } /** 校验"我的券"是否可用于本单,并返回抵扣金额 */ async resolveUserCoupon( userCouponId: string, userId: number, track: string, baseAmountYuan: number ): Promise<{ userCouponId: string; discount: number } | null> { try { const [rows]: any = await pool.query( 'SELECT * FROM user_coupons WHERE id = ? AND user_id = ? LIMIT 1', [String(userCouponId), Number(userId) || 0] ); if (!rows || rows.length === 0) return null; const c = rows[0]; if (String(c.status) !== 'UNUSED') return null; if (c.expired_at && new Date(c.expired_at).getTime() < Date.now()) return null; if (Number(c.min_spend) > Number(baseAmountYuan)) return null; const limit = String(c.category_limit || 'ALL'); if (limit !== 'ALL' && limit !== String(track)) return null; const discount = Math.min(Number(c.discount_amount) || 0, Number(baseAmountYuan) || 0); if (discount <= 0) return null; return { userCouponId: String(c.id), discount: Number(discount.toFixed(2)) }; } catch (e) { logServerError('resolveUserCoupon', e); return null; } } /** 核销(下单时占用) */ async consumeUserCoupon(userCouponId: string, orderId: string): Promise { try { await pool.query( "UPDATE user_coupons SET status = 'USED', order_id = ?, used_at = NOW() WHERE id = ? AND status = 'UNUSED'", [String(orderId), String(userCouponId)] ); } catch (e) { logServerError('consumeUserCoupon', e); } } /** 回退(取消/拒单/退款时把券还给用户;可传入事务连接保证与退款同事务) */ private async releaseCouponWithConn(connOrPool: any, orderId: string): Promise { if (!orderId) return; try { await connOrPool.query( "UPDATE user_coupons SET status = 'UNUSED', order_id = NULL, used_at = NULL WHERE order_id = ? AND status = 'USED'", [String(orderId)] ); } catch (e) { logServerError('releaseCouponWithConn', e); } } async releaseUserCouponByOrder(orderId: string): Promise { try { await pool.query( "UPDATE user_coupons SET status = 'UNUSED', order_id = NULL, used_at = NULL WHERE order_id = ? AND status = 'USED'", [String(orderId)] ); } catch (e) { logServerError('releaseUserCouponByOrder', e); } } async getCoupons(categoryLimit?: string): Promise { let sql = ` SELECT id, name, discount_amount as discountAmount, min_spend as minSpend, valid_days as validDays, total_count as totalCount, received_count as receivedCount, category_limit as categoryLimit, description, status, created_at as createdAt, updated_at as updatedAt FROM coupons WHERE status = 1 `; const params: any[] = []; if (categoryLimit && categoryLimit !== 'ALL') { sql += ' AND (category_limit = "ALL" OR category_limit = ?)'; params.push(categoryLimit); } sql += ' ORDER BY discount_amount DESC, id ASC'; const [rows]: any = await pool.query(sql, params); return rows; } async getAdminCoupons(): Promise { const [rows]: any = await pool.query(` SELECT id, name, discount_amount as discountAmount, min_spend as minSpend, valid_days as validDays, total_count as totalCount, received_count as receivedCount, category_limit as categoryLimit, description, status, created_at as createdAt, updated_at as updatedAt FROM coupons ORDER BY id DESC `); return rows; } async createCoupon(data: { name: string; discountAmount: number; minSpend?: number; validDays?: number; totalCount?: number; categoryLimit?: string; description?: string; status?: number; }): Promise { const name = data.name; const discountAmount = Number(data.discountAmount); const minSpend = Number(data.minSpend || 0); const validDays = Number(data.validDays || 7); const totalCount = Number(data.totalCount || 1000); const categoryLimit = data.categoryLimit || 'ALL'; const description = data.description || ''; const status = data.status !== undefined ? Number(data.status) : 1; const [res]: any = await pool.query(` INSERT INTO coupons (name, discount_amount, min_spend, valid_days, total_count, received_count, category_limit, description, status) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?) `, [name, discountAmount, minSpend, validDays, totalCount, categoryLimit, description, status]); const newId = res.insertId; const [rows]: any = await pool.query(` SELECT id, name, discount_amount as discountAmount, min_spend as minSpend, valid_days as validDays, total_count as totalCount, received_count as receivedCount, category_limit as categoryLimit, description, status, created_at as createdAt, updated_at as updatedAt FROM coupons WHERE id = ? `, [newId]); return rows[0]; } async updateCoupon(id: number | string, data: Partial<{ name: string; discountAmount: number; minSpend: number; validDays: number; totalCount: number; categoryLimit: string; description: string; status: number; }>): Promise { const fields: string[] = []; const values: any[] = []; if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); } if (data.discountAmount !== undefined) { fields.push('discount_amount = ?'); values.push(Number(data.discountAmount)); } if (data.minSpend !== undefined) { fields.push('min_spend = ?'); values.push(Number(data.minSpend)); } if (data.validDays !== undefined) { fields.push('valid_days = ?'); values.push(Number(data.validDays)); } if (data.totalCount !== undefined) { fields.push('total_count = ?'); values.push(Number(data.totalCount)); } if (data.categoryLimit !== undefined) { fields.push('category_limit = ?'); values.push(data.categoryLimit); } if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description); } if (data.status !== undefined) { fields.push('status = ?'); values.push(Number(data.status)); } if (fields.length > 0) { values.push(id); await pool.query(`UPDATE coupons SET ${fields.join(', ')} WHERE id = ?`, values); } const [rows]: any = await pool.query(` SELECT id, name, discount_amount as discountAmount, min_spend as minSpend, valid_days as validDays, total_count as totalCount, received_count as receivedCount, category_limit as categoryLimit, description, status, created_at as createdAt, updated_at as updatedAt FROM coupons WHERE id = ? `, [id]); return rows[0] || null; } async deleteCoupon(id: number | string): Promise { const [res]: any = await pool.query('DELETE FROM coupons WHERE id = ?', [id]); return res.affectedRows > 0; } // 3. 平台通用配置与指引 (Platform Configs) async getPlatformConfigs(): Promise<{ [key: string]: any }> { const [rows]: any = await pool.query(` SELECT config_key as configKey, name, value_json as valueJson, description, status FROM platform_configs WHERE status = 1 `); // 移动端公开配置:严禁下发任何密钥/风控内部数据(该接口无需登录) const PUBLIC_EXCLUDED_KEYS = new Set(['security_words', 'map_location_config']); const SENSITIVE_FIELD = /(secret|private|apiv3|api_v3|password|passwd|token|pem|cert|mchid)/i; const stripSensitive = (val: any): any => { if (Array.isArray(val)) return val.map(stripSensitive); if (val && typeof val === 'object') { const out: { [k: string]: any } = {}; for (const [k, v] of Object.entries(val)) { if (SENSITIVE_FIELD.test(k)) continue; out[k] = stripSensitive(v); } return out; } return val; }; const result: { [key: string]: any } = {}; for (const r of rows) { if (PUBLIC_EXCLUDED_KEYS.has(r.configKey)) continue; let val = r.valueJson; if (typeof val === 'string') { try { val = JSON.parse(val); } catch (_) {} } if (r.configKey === 'wechat_pay_config' && val && typeof val === 'object') { result[r.configKey] = { mode: val.mode || 'AUTO', appId: val.appId || '', mchIdConfigured: Boolean(val.mchId && val.apiV3Key && val.privateKey) }; } else { result[r.configKey] = stripSensitive(val); } } return result; } async getPlatformConfigByKey(configKey: string): Promise { const [rows]: any = await pool.query(` SELECT id, config_key as configKey, name, value_json as valueJson, description, status FROM platform_configs WHERE config_key = ? `, [configKey]); if (!rows || rows.length === 0) return null; const r = rows[0]; let val = r.valueJson; if (typeof val === 'string') { try { val = JSON.parse(val); } catch (_) {} } return { ...r, valueJson: val }; } async getAdminPlatformConfigs(): Promise { const [rows]: any = await pool.query(` SELECT id, config_key as configKey, name, value_json as valueJson, description, status, created_at as createdAt, updated_at as updatedAt FROM platform_configs ORDER BY id ASC `); return rows.map((r: any) => { let val = r.valueJson; if (typeof val === 'string') { try { val = JSON.parse(val); } catch (_) {} } return { ...r, valueJson: val }; }); } async updatePlatformConfig(configKey: string, data: { name?: string; valueJson?: any; description?: string; status?: number }): Promise { const [existRows]: any = await pool.query( 'SELECT id FROM platform_configs WHERE config_key = ?', [configKey] ); if (!existRows || existRows.length === 0) { await pool.query( `INSERT INTO platform_configs (config_key, name, value_json, description, status) VALUES (?, ?, ?, ?, ?)`, [ configKey, data.name || configKey, JSON.stringify(data.valueJson !== undefined ? data.valueJson : {}), data.description || '', data.status !== undefined ? Number(data.status) : 1 ] ); } else { const fields: string[] = []; const values: any[] = []; if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); } if (data.valueJson !== undefined) { fields.push('value_json = ?'); values.push(JSON.stringify(data.valueJson)); } if (data.description !== undefined) { fields.push('description = ?'); values.push(data.description); } if (data.status !== undefined) { fields.push('status = ?'); values.push(Number(data.status)); } if (fields.length > 0) { values.push(configKey); await pool.query(`UPDATE platform_configs SET ${fields.join(', ')} WHERE config_key = ?`, values); } } const [rows]: any = await pool.query(` SELECT id, config_key as configKey, name, value_json as valueJson, description, status, created_at as createdAt, updated_at as updatedAt FROM platform_configs WHERE config_key = ? `, [configKey]); if (!rows || rows.length === 0) return null; const r = rows[0]; let val = r.valueJson; if (typeof val === 'string') { try { val = JSON.parse(val); } catch (_) {} } return { ...r, valueJson: val }; } async getArbitrations(): Promise { const [rows]: any = await pool.query(` SELECT a.id, a.order_id as orderId, a.order_no as orderNo, a.client_name as clientName, a.companion_name as companionName, a.reason, a.user_evidence as userEvidence, a.status, a.created_at as createdAt, COALESCE(o.amount, 0.00) as orderAmount, COALESCE(o.refund_amount, 0.00) as refundAmount FROM arbitrations a LEFT JOIN orders o ON a.order_id = o.id ORDER BY a.created_at DESC `); return rows; } async handleArbitration(id: string, decision: string, refundAmountInput?: number): Promise { const conn = await pool.getConnection(); let shouldSettleOrderId = ''; let finalRefundAmount = 0; try { await conn.beginTransaction(); // 幂等防线:仅允许"待平台仲裁"工单被处理,防止重复裁决导致重复退款 const [curArbRows]: any = await conn.query('SELECT status FROM arbitrations WHERE id = ? FOR UPDATE', [id]); if (!curArbRows || curArbRows.length === 0) { throw new Error('仲裁工单不存在'); } const curArbStatus = curArbRows[0].status; if (curArbStatus && curArbStatus !== '待平台仲裁') { throw new Error(`该仲裁工单已处理完毕(当前状态:${curArbStatus}),不可重复裁决`); } let statusText = '申诉已驳回'; if (decision === 'refund_full') statusText = '已全额退款用户'; if (decision === 'refund_partial') statusText = '已部分退款用户'; if (decision === 'deduct_credit') statusText = '已扣减大神信用分'; await conn.query('UPDATE arbitrations SET status = ? WHERE id = ?', [statusText, id]); const [arbRows]: any = await conn.query('SELECT order_id FROM arbitrations WHERE id = ? FOR UPDATE', [id]); if (arbRows.length > 0) { const orderId = arbRows[0].order_id; const [ordRows]: any = await conn.query('SELECT * FROM orders WHERE id = ? FOR UPDATE', [orderId]); const order = ordRows && ordRows[0]; if (decision === 'refund_full' && order) { const refundAmount = Number(order.amount || 0); finalRefundAmount = refundAmount; // 1. 更新订单为已全额退款 await conn.query('UPDATE orders SET status = "已全额退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]); // 2. 原路退款:微信支付订单走微信退款 API,余额支付退回钱包余额 if (refundAmount > 0) { await this.refundOrderPayment(conn, order, Math.round(refundAmount * 100), '平台仲裁退款', '仲裁退款', 'RFND_ARB'); } await this.releaseCouponWithConn(conn, order.id); // 4. 联动扣减违规大神评分 const expertId = Number(order.expert_id); if (expertId > 0) { const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId; await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]); } } else if (decision === 'refund_partial' && order) { const totalOrderAmt = Number(order.amount || 0); const partialAmt = refundAmountInput !== undefined && Number(refundAmountInput) > 0 ? Math.min(totalOrderAmt, Number(Number(refundAmountInput).toFixed(2))) : Number((totalOrderAmt * 0.5).toFixed(2)); finalRefundAmount = partialAmt; await conn.query('UPDATE orders SET status = "已部分退款", refund_amount = ? WHERE id = ?', [partialAmt, order.id]); // 原路退款(微信支付→微信退款;余额支付→退回余额) if (partialAmt > 0) { await this.refundOrderPayment(conn, order, Math.round(partialAmt * 100), '平台仲裁部分退款', '仲裁部分退款', 'RFND_ARBP'); } // 剩余未退部分解冻结算给大神 const remainAmt = Number(Math.max(0, totalOrderAmt - partialAmt).toFixed(2)); if (remainAmt > 0) { const comp = await this.resolveCompanionRow(order.expert_id || order.companion_name || 101); const expertUserId = await this.resolveExpertCreditUserId(conn, order, comp); const expertIncome = Number((remainAmt * 0.9).toFixed(2)); await conn.query( 'UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?', [expertIncome, expertUserId] ); const setId = `fin_pset_${Date.now()}`; const setNo = `PJS${Date.now()}`; await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '仲裁部分结算', ?, 'IN', ?, ?, NOW()) `, [setId, setNo, expertUserId, expertIncome, order.client_name || '买家客户', String(order.id)]); } } else if (order) { // 驳回退款申诉或仅扣减大神信用分:订单恢复为已完成并进入完单结算闭环 await conn.query('UPDATE orders SET status = "已完成" WHERE id = ?', [order.id]); shouldSettleOrderId = String(order.id); if (decision === 'deduct_credit') { const expertId = Number(order.expert_id); if (expertId > 0) { const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId; await conn.query('UPDATE companions SET rating = GREATEST(1.0, rating - 0.3) WHERE id IN (?, ?) OR user_id = ?', [expertId, altId, expertId]); } } } } await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } if (shouldSettleOrderId) { try { await this.completeOrderWithSettlement(shouldSettleOrderId); } catch (e) { logServerError('handleArbitration.settle', e); } } let statusText = '申诉已驳回'; if (decision === 'refund_full') statusText = '已全额退款用户'; if (decision === 'refund_partial') statusText = '已部分退款用户'; if (decision === 'deduct_credit') statusText = '已扣减大神信用分'; return { id, status: statusText, refundAmount: finalRefundAmount, orderStatus: decision === 'refund_full' ? '已全额退款' : (decision === 'refund_partial' ? '已部分退款' : '已完成') }; } // 买家发起退款/售后申诉:订单流转为争议仲裁中,同时写入仲裁工单 (含理由与凭证截图) async applyOrderRefund(orderId: string, data: { reason?: string; description?: string; evidence?: string[] }): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows]: any = await conn.query('SELECT * FROM orders WHERE id = ? OR order_no = ? FOR UPDATE', [orderId, orderId]); if (!rows || rows.length === 0) { throw new Error('订单不存在'); } const order = rows[0]; if (['已完成', '已取消', '已全额退款', '争议仲裁中'].includes(order.status)) { throw new Error(`当前订单状态【${order.status}】不可发起退款申诉`); } await conn.query('UPDATE orders SET status = ? WHERE id = ?', ['争议仲裁中', order.id]); const reason = (data.reason || '其他原因').slice(0, 60); const detail = (data.description || '').slice(0, 180); const evidence = Array.isArray(data.evidence) ? data.evidence.filter((u) => typeof u === 'string').slice(0, 6) : []; const arbId = `arb_${Date.now()}_${Math.floor(Math.random() * 1000)}`; await conn.query(` INSERT INTO arbitrations (id, order_id, order_no, client_name, companion_name, reason, user_evidence, status) VALUES (?, ?, ?, ?, ?, ?, ?, '待平台仲裁') `, [ arbId, order.id, order.order_no, order.client_name, order.companion_name, detail ? `${reason}:${detail}` : reason, JSON.stringify(evidence) ]); await conn.commit(); const [updated]: any = await pool.query('SELECT * FROM orders WHERE id = ?', [order.id]); return { ...(updated[0] || order), arbitrationId: arbId }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } // 用户举报不良陪玩 (涉黄/涉赌/线下欺诈等) async createReport(data: { reporterId: number; targetType?: string; targetId: string; targetName?: string; reason: string; description?: string; evidence?: string[]; }): Promise { const id = `rpt_${Date.now()}_${Math.floor(Math.random() * 1000)}`; const evidence = Array.isArray(data.evidence) ? data.evidence.filter((u) => typeof u === 'string').slice(0, 6) : []; await pool.query(` INSERT INTO reports (id, reporter_id, target_type, target_id, target_name, reason, description, evidence, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '待处理') `, [ id, Number(data.reporterId) || 0, data.targetType || 'EXPERT', String(data.targetId), (data.targetName || '').slice(0, 64), data.reason.slice(0, 64), (data.description || '').slice(0, 500), JSON.stringify(evidence) ]); return { id, status: '待处理' }; } async getReports(reporterId?: number): Promise { let sql = ` SELECT id, reporter_id as reporterId, target_type as targetType, target_id as targetId, target_name as targetName, reason, description, evidence, status, COALESCE(handle_result, '') as handleResult, COALESCE(handle_note, '') as handleNote, handled_at as handledAt, created_at as createdAt FROM reports `; const params: any[] = []; if (reporterId !== undefined && !isNaN(Number(reporterId)) && Number(reporterId) > 0) { sql += ' WHERE reporter_id = ?'; params.push(Number(reporterId)); } sql += ' ORDER BY created_at DESC LIMIT 200'; const [rows]: any = await pool.query(sql, params); return rows; } async handleReport( id: string, action: 'punish' | 'reject', handleNote?: string, penaltyOptions?: { deductCredit?: number; banDays?: number } ): Promise { const status = action === 'punish' ? '已核实处罚' : '已驳回'; const handleResult = action === 'punish' ? '违规属实,已给予处分' : '证据不足,予以驳回'; const note = handleNote || (action === 'punish' ? '经平台安全与风控团队核查,违规行为属实,已给予处分并记录违规档案。' : '经安全团队核验,现有证据不足以判定违规,予以驳回。'); await pool.query(` UPDATE reports SET status = ?, handle_result = ?, handle_note = ?, handled_at = NOW() WHERE id = ? `, [status, handleResult, note, id]); try { const [rows]: any = await pool.query('SELECT * FROM reports WHERE id = ?', [id]); const report = rows && rows[0]; if (report && action === 'punish' && report.target_type === 'EXPERT') { const expertId = Number(report.target_id); if (expertId > 0) { const altId = expertId > 100 && expertId < 200 ? expertId - 100 : expertId; await pool.query(` UPDATE companions SET rating = GREATEST(1.0, rating - 0.2) WHERE id IN (?, ?) OR user_id = ? `, [expertId, altId, expertId]); } } } catch (_) {} return { id, status, handleResult, handleNote: note, handledAt: new Date().toISOString() }; } // 财务相关 async getFinanceRecords(): Promise { const [rows]: any = await pool.query(` SELECT id, record_no as recordNo, type, amount, direction, counterparty, related_id as relatedId, created_at as createdAt FROM finance_records ORDER BY created_at DESC LIMIT 500 `); return rows; } // ===== 站内消息通知中心 ===== async createUserNotification(data: { userId: number; type?: string; title: string; content?: string; link?: string; }): Promise { const uid = Number(data.userId) || 0; if (uid <= 0 || !data.title) return null; const id = `ntf_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; await pool.query( `INSERT INTO user_notifications (id, user_id, type, title, content, link, is_read, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, NOW())`, [ id, uid, String(data.type || 'SYSTEM').slice(0, 32), String(data.title).slice(0, 120), String(data.content || '').slice(0, 500), String(data.link || '').slice(0, 250), ] ); return { id }; } async getUserNotifications(userId: number, limit = 50): Promise { const uid = Number(userId) || 0; if (uid <= 0) return []; const take = Math.min(200, Math.max(1, Number(limit) || 50)); const [rows]: any = await pool.query( `SELECT id, type, title, content, link, is_read as isRead, created_at as createdAt FROM user_notifications WHERE user_id = ? ORDER BY created_at DESC, id DESC LIMIT ${take}`, [uid] ); return rows; } async getUserUnreadNotificationCount(userId: number): Promise { const uid = Number(userId) || 0; if (uid <= 0) return 0; const [rows]: any = await pool.query( 'SELECT COUNT(*) as c FROM user_notifications WHERE user_id = ? AND is_read = 0', [uid] ); return Number((rows && rows[0] && rows[0].c) || 0); } async markUserNotificationsRead(userId: number, ids?: string[]): Promise { const uid = Number(userId) || 0; if (uid <= 0) return 0; if (Array.isArray(ids) && ids.length > 0) { const safe = ids.map((x) => String(x)).slice(0, 200); const ph = safe.map(() => '?').join(','); const [r]: any = await pool.query( `UPDATE user_notifications SET is_read = 1 WHERE user_id = ? AND id IN (${ph})`, [uid, ...safe] ); return Number((r && r.affectedRows) || 0); } const [r]: any = await pool.query( 'UPDATE user_notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0', [uid] ); return Number((r && r.affectedRows) || 0); } async getWithdrawals(): Promise { const [rows]: any = await pool.query(` SELECT id, withdraw_no as withdrawNo, companion_name as companionName, companion_id as companionId, amount, balance, channel, status, apply_time as applyTime FROM withdrawals ORDER BY apply_time DESC `); return rows; } async reviewWithdrawal(id: string, action: 'approve' | 'reject', rejectReason?: string): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows]: any = await conn.query('SELECT * FROM withdrawals WHERE id = ? FOR UPDATE', [id]); if (!rows || rows.length === 0) { throw new Error('提现工单不存在'); } const wd = rows[0]; if (wd.status !== '待审核') { throw new Error(`当前提现工单已处理,状态为【${wd.status}】`); } const statusText = action === 'approve' ? '已打款' : '已驳回'; const reason = rejectReason || (action === 'reject' ? '银行/微信账号信息不匹配或未通过财务初审' : ''); await conn.query(` UPDATE withdrawals SET status = ?, reason = ? WHERE id = ? `, [statusText, reason, id]); // 若被驳回,将预扣的提现佣金全额原路退还至用户 commission 佣金账户 if (action === 'reject') { const userId = Number(wd.companion_id); const refundAmt = Number(wd.amount); if (userId > 0 && refundAmt > 0) { await conn.query('UPDATE users SET commission = commission + ? WHERE id = ?', [refundAmt, userId]); // 记录一条冲正入账流水 const recId = `fin_wdr_${Date.now()}`; const recNo = `TH${Date.now()}`; await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '提现驳回退还', ?, 'IN', ?, ?, NOW()) `, [recId, recNo, userId, refundAmt, `财务审核驳回退还 (${reason})`, id]); } } await conn.commit(); return { id, status: statusText, reason }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } /** 读取提现工单及收款人 openid / 实名(用于真实微信打款) */ async getWithdrawalForTransfer(id: string): Promise { const [rows]: any = await pool.query(` SELECT w.*, u.wechat_openid as userOpenid, u.nickname as userNickname FROM withdrawals w LEFT JOIN users u ON u.id = w.companion_id WHERE w.id = ? LIMIT 1 `, [id]); if (!rows || rows.length === 0) return null; return rows[0]; } /** 记录打款受理状态(转账单号 + 状态) */ async markWithdrawalTransfer(id: string, billNo: string, status: string, transferStatus?: string): Promise { await pool.query( 'UPDATE withdrawals SET status = ?, transfer_bill_no = ?, transfer_status = ? WHERE id = ?', [status, billNo, transferStatus || status, id] ); return { id, status, transferBillNo: billNo, transferStatus: transferStatus || status }; } /** * 依据微信转账单最终状态落库: * SUCCESS -> 已打款;FAIL/CANCELLED -> 打款失败并回退预扣佣金;其它 -> 更新中间态。 */ async finalizeWithdrawalTransfer(outBillNo: string, state: string): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows]: any = await conn.query('SELECT * FROM withdrawals WHERE transfer_bill_no = ? FOR UPDATE', [outBillNo]); if (!rows || rows.length === 0) { await conn.rollback(); return { handled: false, reason: 'not_found', outBillNo }; } const wd = rows[0]; if (state === 'SUCCESS') { if (wd.status !== '已打款') { await conn.query("UPDATE withdrawals SET status = '已打款', transfer_status = 'SUCCESS', reason = '' WHERE id = ?", [wd.id]); } await conn.commit(); return { handled: true, status: '已打款', userId: Number(wd.companion_id) || 0, amount: Number(wd.amount) || 0 }; } if (state === 'FAIL' || state === 'CANCELLED') { if (wd.status !== '已打款' && wd.status !== '打款失败') { const userId = Number(wd.companion_id); const amt = Number(wd.amount); if (userId > 0 && amt > 0) { await conn.query('UPDATE users SET commission = commission + ? WHERE id = ?', [amt, userId]); await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '提现打款失败退回', ?, 'IN', ?, ?, NOW()) `, [`fin_wdf_${Date.now()}`, `WF${Date.now()}`, userId, amt, `微信商家转账失败(${state})退回`, String(wd.id)]); } await conn.query("UPDATE withdrawals SET status = '打款失败', transfer_status = ? WHERE id = ?", [state, wd.id]); } await conn.commit(); return { handled: true, status: '打款失败' }; } await conn.query('UPDATE withdrawals SET transfer_status = ? WHERE id = ?', [state, wd.id]); await conn.commit(); return { handled: true, status: wd.status, transferStatus: state }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async getFinanceConfig(): Promise { const [rows]: any = await pool.query(` SELECT platform_rate_percent as platformRatePercent, first_referral_percent as firstReferralPercent, second_referral_percent as secondReferralPercent, min_withdraw_amount as minWithdrawAmount, max_withdraw_amount as maxWithdrawAmount, withdraw_fee_percent as withdrawFeePercent FROM finance_config WHERE id = 1 `); return rows[0] || {}; } async updateFinanceConfig(data: any): Promise { await pool.query(` UPDATE finance_config SET platform_rate_percent = ?, first_referral_percent = ?, second_referral_percent = ?, min_withdraw_amount = ?, max_withdraw_amount = ?, withdraw_fee_percent = ? WHERE id = 1 `, [ data.platformRatePercent || 10, data.firstReferralPercent || 5, data.secondReferralPercent || 2, data.minWithdrawAmount || 50, data.maxWithdrawAmount || 20000, data.withdrawFeePercent || 0, ]); return this.getFinanceConfig(); } // 场所与安全 async getSafetyPlaces(): Promise { const [rows]: any = await pool.query('SELECT * FROM safety_places ORDER BY id ASC'); return rows; } async createSafetyPlace(data: { name: string; area: string; category?: string }): Promise { const id = `place_${Date.now()}`; await pool.query(` INSERT INTO safety_places (id, name, area, category, safety_level, status, approved) VALUES (?, ?, ?, ?, 'AAA级合规认证', '正常营业', 1) `, [id, data.name, data.area, data.category || '餐饮美食']); return { id, ...data }; } async updateSafetyPlace(id: string, data: Partial<{ name: string; area: string; category: string; status: string }>): Promise { const fields: string[] = []; const values: any[] = []; if (data.name !== undefined) { fields.push('name = ?'); values.push(data.name); } if (data.area !== undefined) { fields.push('area = ?'); values.push(data.area); } if (data.category !== undefined) { fields.push('category = ?'); values.push(data.category); } if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status); } if (fields.length > 0) { values.push(id); await pool.query(`UPDATE safety_places SET ${fields.join(', ')} WHERE id = ?`, values); } const [rows]: any = await pool.query('SELECT * FROM safety_places WHERE id = ?', [id]); return rows[0] || null; } async deleteSafetyPlace(id: string): Promise { const [res]: any = await pool.query('DELETE FROM safety_places WHERE id = ?', [id]); return res.affectedRows > 0; } async getSafetyAuditLogs(): Promise { const [rows]: any = await pool.query(` SELECT id, timestamp_str as timestamp, user_id as userId, type, content, action, status FROM safety_audit_logs ORDER BY id DESC `); return rows; } // 大盘统计 async getDashboardStats(): Promise { // 计入成交的有效订单状态(已付款或已履约),排除未付款/已取消/已退款 const paidStatuses = "('待接单','履约中','服务中','已到场核销','待确认','已完成')"; // 1. 今日成交 GMV 与订单数 let todayGmv = 0; let todayOrders = 0; try { const [todayRows]: any = await pool.query(` SELECT COUNT(*) as count, COALESCE(SUM(amount), 0) as total FROM orders WHERE DATE(created_at) = CURDATE() AND status IN ${paidStatuses} `); todayGmv = Number(Number(todayRows[0]?.total || 0).toFixed(2)); todayOrders = Number(todayRows[0]?.count || 0); } catch (e) { logServerError('getDashboardStats.today', e); } // 2. 接单中的大神数量 let onlineCompanions = 0; try { const [compRows]: any = await pool.query('SELECT COUNT(*) as count FROM companions WHERE status = "接单中"'); onlineCompanions = Number(compRows[0]?.count || 0); } catch (e) { logServerError('getDashboardStats.companions', e); } // 3. 今日活跃用户(下单或参与组局去重) let activeUsers = 0; try { const [auRows]: any = await pool.query(` SELECT COUNT(*) as count FROM ( SELECT buyer_id AS uid FROM orders WHERE DATE(created_at) = CURDATE() UNION SELECT user_id AS uid FROM participants WHERE DATE(created_at) = CURDATE() ) t `); activeUsers = Number(auRows[0]?.count || 0); } catch (e) { logServerError('getDashboardStats.activeUsers', e); try { const [auRows]: any = await pool.query( 'SELECT COUNT(DISTINCT buyer_id) as count FROM orders WHERE DATE(created_at) = CURDATE()' ); activeUsers = Number(auRows[0]?.count || 0); } catch (e2) { logServerError('getDashboardStats.activeUsersFallback', e2); } } // 4. 近 7 日 GMV 走势(缺失日期补 0) const trendMap: Record = {}; try { const [trendRows]: any = await pool.query(` SELECT DATE_FORMAT(created_at, '%m-%d') as d, COALESCE(SUM(amount), 0) as total FROM orders WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) AND status IN ${paidStatuses} GROUP BY DATE_FORMAT(created_at, '%m-%d') `); for (const r of trendRows) { trendMap[r.d] = Number(Number(r.total || 0).toFixed(2)); } } catch (e) { logServerError('getDashboardStats.trend', e); } const recentGmvTrend: { date: string; gmv: number }[] = []; const nowTs = Date.now(); for (let i = 6; i >= 0; i--) { const d = new Date(nowTs - i * 86400000); const label = `${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; recentGmvTrend.push({ date: label, gmv: trendMap[label] || 0 }); } // 5. 品类(赛道)成交额分布 let categoryDistribution: { name: string; value: number }[] = []; try { const [catRows]: any = await pool.query(` SELECT track as name, COALESCE(SUM(amount), 0) as value FROM orders WHERE status IN ${paidStatuses} GROUP BY track ORDER BY value DESC LIMIT 8 `); categoryDistribution = catRows.map((r: any) => ({ name: r.name || '其他', value: Number(Number(r.value || 0).toFixed(2)), })); } catch (e) { logServerError('getDashboardStats.category', e); } return { todayGmv, todayOrders, onlineCompanions, activeUsers, recentGmvTrend, categoryDistribution, }; } async getDashboardTodos(): Promise { const [auditRows]: any = await pool.query('SELECT * FROM companions WHERE audit_status = "待审核"'); const [arbRows]: any = await pool.query('SELECT * FROM arbitrations WHERE status = "待平台仲裁"'); const [wdRows]: any = await pool.query('SELECT * FROM withdrawals WHERE status = "待审核"'); const [secRows]: any = await pool.query('SELECT * FROM safety_audit_logs ORDER BY id DESC LIMIT 5'); let rptRows: any[] = []; try { const [rRows]: any = await pool.query('SELECT * FROM reports WHERE status = "待处理" ORDER BY created_at DESC'); rptRows = rRows || []; } catch (e) { logServerError('getDashboardTodos.reports', e); } let pendingOrdersCount = 0; try { const [oRows]: any = await pool.query('SELECT COUNT(*) as c FROM orders WHERE status = "待接单"'); pendingOrdersCount = Number((oRows && oRows[0] && oRows[0].c) || 0); } catch (e) { logServerError('getDashboardTodos.orders', e); } const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0); return { pendingAuditsCount: auditRows.length, pendingArbitrationsCount: arbRows.length, pendingWithdrawalsCount: wdRows.length, pendingWithdrawalAmount, pendingOrdersCount, pendingReportsCount: rptRows.length, riskInterceptionsCount: secRows.length, pendingAudits: auditRows.map((c: any) => ({ id: c.id, name: c.name, track: c.track, hourlyRate: c.hourly_rate, })), pendingArbitrations: arbRows.map((a: any) => ({ id: a.id, clientName: a.client_name, companionName: a.companion_name, reason: a.reason, })), pendingWithdrawals: wdRows.map((w: any) => ({ id: w.id, companionName: w.companion_name, amount: w.amount, channel: w.channel, })), pendingReports: rptRows.map((r: any) => ({ id: r.id, reporterId: r.reporter_id, targetName: r.target_name || r.target_id, reason: r.reason, description: r.description || '', })), recentRiskLogs: secRows, }; } /** 管理员待办"已读"状态:记录每个待办项已读时的数量,用于判断是否有新增待办 */ async getAdminTodoReadCounts(): Promise> { try { const [rows]: any = await pool.query('SELECT todo_key, read_count FROM admin_message_reads'); const map: Record = {}; for (const r of rows || []) map[String(r.todo_key)] = Number(r.read_count) || 0; return map; } catch (e) { logServerError('getAdminTodoReadCounts', e); return {}; } } async markAdminTodosRead(items: Array<{ key: string; count: number }>): Promise { let affected = 0; for (const it of items || []) { if (!it || !it.key) continue; try { await pool.query( `INSERT INTO admin_message_reads (todo_key, read_count, read_at) VALUES (?, ?, NOW()) ON DUPLICATE KEY UPDATE read_count = VALUES(read_count), read_at = NOW()`, [String(it.key), Number(it.count) || 0] ); affected += 1; } catch (e) { logServerError('markAdminTodosRead', e); } } return affected; } // 全平台用户管理相关 async getUsers(filter?: { keyword?: string; role?: string; status?: string }): Promise { let sql = ` SELECT id, user_no as userNo, nickname, avatar, phone, gender, role, balance, order_count as orderCount, activity_count as activityCount, status, wechat_openid as wechatOpenid, created_at as createdAt FROM users WHERE 1=1 `; const params: any[] = []; if (filter?.keyword) { sql += ' AND (nickname LIKE ? OR phone LIKE ? OR user_no LIKE ?)'; params.push(`%${filter.keyword}%`, `%${filter.keyword}%`, `%${filter.keyword}%`); } if (filter?.role && filter.role !== 'all') { sql += ' AND role = ?'; params.push(filter.role); } if (filter?.status && filter.status !== 'all') { sql += ' AND status = ?'; params.push(filter.status); } sql += ' ORDER BY id ASC LIMIT 500'; const [rows]: any = await pool.query(sql, params); return rows; } async updateUserStatus(id: number, status: '正常' | '已封禁'): Promise { await pool.query('UPDATE users SET status = ? WHERE id = ?', [status, id]); if (status === '已封禁') { try { await pool.query("UPDATE activities SET status = 'CANCELLED' WHERE creator_id = ? AND status = 'RECRUITING'", [id]); await pool.query("UPDATE companions SET is_online = 0 WHERE user_id = ?", [id]); } catch (_) {} } const [rows]: any = await pool.query('SELECT id, user_no as userNo, nickname, status FROM users WHERE id = ?', [id]); return rows[0]; } async deleteUser(id: number): Promise { const [existing]: any = await pool.query('SELECT id, user_no as userNo, nickname FROM users WHERE id = ?', [id]); if (!existing || existing.length === 0) { return false; } try { await pool.query('DELETE FROM favorites WHERE user_id = ?', [id]); } catch (_) {} try { await pool.query('DELETE FROM participants WHERE user_id = ?', [id]); } catch (_) {} try { await pool.query('DELETE FROM user_settings WHERE user_id = ?', [id]); } catch (_) {} const [result]: any = await pool.query('DELETE FROM users WHERE id = ?', [id]); return result.affectedRows > 0; } async deleteUsers(ids: number[]): Promise { if (!ids || ids.length === 0) return 0; const placeholders = ids.map(() => '?').join(','); try { await pool.query(`DELETE FROM favorites WHERE user_id IN (${placeholders})`, ids); } catch (_) {} try { await pool.query(`DELETE FROM participants WHERE user_id IN (${placeholders})`, ids); } catch (_) {} try { await pool.query(`DELETE FROM user_settings WHERE user_id IN (${placeholders})`, ids); } catch (_) {} const [result]: any = await pool.query(`DELETE FROM users WHERE id IN (${placeholders})`, ids); return result.affectedRows || 0; } async getUserDetail(id: number): Promise { const [userRows]: any = await pool.query(` SELECT id, user_no as userNo, nickname, avatar, phone, gender, role, balance, order_count as orderCount, activity_count as activityCount, status, wechat_openid as wechatOpenid, created_at as createdAt FROM users WHERE id = ? `, [id]); if (!userRows || userRows.length === 0) return null; const user = userRows[0]; // 发起的组局 const [createdActivities]: any = await pool.query(` SELECT id, title, game_category as gameCategory, location, pay_type as payType, total_fee_cents as totalFeeCents, max_participants as maxParticipants, created_at as createdAt FROM activities WHERE creator_id = ? ORDER BY created_at DESC `, [id]); // 报名的活动与4位核销码 const [joinedActivities]: any = await pool.query(` SELECT p.id as participantId, p.activity_id as activityId, p.verify_code as verifyCode, p.checkin_status as checkinStatus, p.pay_amount_cents as payAmountCents, p.created_at as joinTime, p.verified_at as verifiedAt, a.title, a.game_category as gameCategory, a.location FROM participants p LEFT JOIN activities a ON p.activity_id = a.id WHERE p.user_id = ? ORDER BY p.created_at DESC `, [id]); // 订单记录(按用户 id 关联,避免昵称重名导致错配) const [userOrders]: any = await pool.query(` SELECT id, order_no as orderNo, client_name as clientName, companion_name as companionName, track, expense_burden as expenseBurden, hourly_rate as hourlyRate, duration_hours as durationHours, amount, location, status, created_at as createdAt FROM orders WHERE buyer_id = ? OR expert_id = ? OR claimed_by = ? OR companion_name IN (SELECT name FROM companions WHERE user_id = ? OR id = ?) ORDER BY created_at DESC `, [id, id, id, id, id]); // 资金流水(按 user_id 精确归属) const [financeRecords]: any = await pool.query(` SELECT id, record_no as recordNo, type, amount, direction, counterparty, related_id as relatedId, created_at as createdAt FROM finance_records WHERE user_id = ? ORDER BY created_at DESC `, [id]); // 风控日志 const [riskLogs]: any = await pool.query(` SELECT id, timestamp_str as timestampStr, type, content, action, status, created_at as createdAt FROM safety_audit_logs WHERE user_id = ? ORDER BY id DESC `, [id]); return { ...user, creditScore: user.status === '已封禁' ? 35 : 100, riskLevel: user.status === '已封禁' ? '高风险阻断' : '安全健康', createdActivities, joinedActivities: joinedActivities.map((j: any) => ({ ...j, checkinStatus: Boolean(j.checkinStatus), })), orders: userOrders, financeRecords, riskLogs, }; } async getUserById(id: number): Promise { const [rows]: any = await pool.query(` SELECT id, user_no as userNo, nickname, avatar, phone, gender, signature, role, balance, commission, invited_count as invitedCount, inviter_id as inviterId, order_count as orderCount, activity_count as activityCount, status, wechat_openid as wechatOpenid, created_at as createdAt FROM users WHERE id = ? `, [id]); if (!rows || rows.length === 0) { if (id === 101) { return { 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, certStatus: 2, auditStatus: '已通过', rejectReason: '', companionStatus: '接单中', status: '正常' }; } if (id === 2006) { return { 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, certStatus: 2, auditStatus: '已通过', rejectReason: '', companionStatus: '接单中', status: '正常' }; } return null; } const u = rows[0]; let companionRow: any = null; try { const [compRows]: any = await pool.query( 'SELECT * FROM companions WHERE user_id = ? OR name = ? ORDER BY id DESC LIMIT 1', [u.id, u.nickname] ); if (compRows && compRows.length > 0) { companionRow = compRows[0]; } } catch (_) {} const roleHasExpert = (u.role || '').includes('大神'); const auditStatus = companionRow ? (companionRow.audit_status || '已通过') : (roleHasExpert ? '已通过' : '未申请'); const certStatus = auditStatus === '已通过' ? 2 : auditStatus === '待审核' ? 1 : auditStatus === '已驳回' ? 3 : 0; const rejectReason = companionRow?.reject_reason || ''; const companionStatus = companionRow ? (companionRow.status || '接单中') : (certStatus === 2 ? '接单中' : ''); const isCertExpert = companionRow ? certStatus === 2 : (certStatus === 2 || roleHasExpert); const roleFlags = isCertExpert ? 3 : 1; return { id: u.id, userNo: u.userNo, nickname: u.nickname, avatar: u.avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200', phone: u.phone || '', gender: u.gender, signature: u.signature || '', role: u.role, roleFlags, balance: Number(u.balance || 0).toFixed(2), commission: Number(u.commission || 0).toFixed(2), commissionBalance: Number(u.commission || 0).toFixed(2), invitedCount: Number(u.invitedCount || 0), inviterId: u.inviterId ? Number(u.inviterId) : null, orderCount: Number(u.orderCount || 0), activityCount: Number(u.activityCount || 0), companionId: companionRow ? Number(companionRow.id) : null, auditStatus, certStatus, rejectReason, companionStatus, isExpert: isCertExpert, status: u.status, }; } async updateUserOpenid(userId: number, openid: string): Promise { const [res]: any = await pool.query('UPDATE users SET wechat_openid = ? WHERE id = ?', [openid, userId]); return res.affectedRows > 0; } async findOrCreateUserByPhone(phone: string, openid?: string): Promise { const cleanPhone = phone.trim(); // 优先按微信 openid 精确匹配(微信身份全局唯一),避免手机号碰撞导致登录到他人账号 if (openid) { const [oidRows]: any = await pool.query( 'SELECT id, phone FROM users WHERE wechat_openid = ? LIMIT 1', [openid] ); if (oidRows && oidRows.length > 0) { const u = oidRows[0]; if (u.phone !== cleanPhone) { await pool.query('UPDATE users SET phone = ? WHERE id = ?', [cleanPhone, u.id]); } return this.getUserById(u.id); } } const [rows]: any = await pool.query( `SELECT id, user_no as userNo, nickname, avatar, phone, gender, signature, role, balance, commission, invited_count as invitedCount, inviter_id as inviterId, order_count as orderCount, activity_count as activityCount, status, wechat_openid as wechatOpenid, created_at as createdAt FROM users WHERE phone = ? LIMIT 1`, [cleanPhone] ); if (rows && rows.length > 0) { const u = rows[0]; if (openid && (!u.wechatOpenid || u.wechatOpenid !== openid)) { await pool.query('UPDATE users SET wechat_openid = ? WHERE id = ?', [openid, u.id]); } return this.getUserById(u.id); } const userNo = 'U' + Math.floor(100000 + Math.random() * 900000); const suffix = cleanPhone.length >= 4 ? cleanPhone.slice(-4) : cleanPhone; const defaultNickname = `微信用户_${suffix}`; const defaultAvatar = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; const [insertRes]: any = await pool.query( `INSERT INTO users (user_no, nickname, avatar, phone, gender, role, balance, order_count, activity_count, status, wechat_openid) VALUES (?, ?, ?, ?, '保密', '普通玩家', 0.00, 0, 0, '正常', ?)`, [userNo, defaultNickname, defaultAvatar, cleanPhone, openid || null] ); return this.getUserById(insertRes.insertId); } async updateUserProfile( id: number, data: { nickname?: string; avatar?: string; phone?: string; gender?: string; signature?: string } ): Promise { const fields: string[] = []; const values: any[] = []; if (data.nickname && data.nickname.trim()) { fields.push('nickname = ?'); values.push(data.nickname.trim()); } if (data.avatar && data.avatar.trim()) { fields.push('avatar = ?'); values.push(data.avatar.trim()); } if (data.phone && data.phone.trim()) { const newPhone = data.phone.trim(); // 联系方式唯一性:避免把手机号改成他人已用号码造成登录身份碰撞 const [dup]: any = await pool.query('SELECT id FROM users WHERE phone = ? AND id <> ? LIMIT 1', [newPhone, id]); if (dup && dup.length > 0) { const err: any = new Error('该联系方式已被其他账号使用,请更换后重试'); err.status = 409; err.code = 40900; throw err; } fields.push('phone = ?'); values.push(newPhone); } if (data.gender && ['男', '女', '保密'].includes(data.gender)) { fields.push('gender = ?'); values.push(data.gender); } if (typeof data.signature === 'string') { fields.push('signature = ?'); values.push(data.signature.trim().slice(0, 64)); } if (fields.length > 0) { values.push(id); await pool.query(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`, values); try { if (data.nickname && data.nickname.trim()) { await pool.query('UPDATE post_comments SET author = ? WHERE user_id = ?', [data.nickname.trim(), id]); await pool.query('UPDATE posts SET author = ? WHERE user_id = ?', [data.nickname.trim(), id]); } if (data.avatar && data.avatar.trim()) { await pool.query('UPDATE post_comments SET avatar = ? WHERE user_id = ?', [data.avatar.trim(), id]); await pool.query('UPDATE posts SET avatar = ? WHERE user_id = ?', [data.avatar.trim(), id]); } } catch (_) {} } return this.getUserById(id); } async rechargeUserBalance(id: number, amount: number, bonusAmount: number = 0): Promise { if (isNaN(amount) || amount <= 0) { throw new Error('充值金额必须大于0'); } const safeBonus = (!isNaN(bonusAmount) && bonusAmount > 0) ? Number(bonusAmount) : 0; const totalCredit = amount + safeBonus; const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [uRows]: any = await conn.query('SELECT nickname, balance, status FROM users WHERE id = ? FOR UPDATE', [id]); if (!uRows || uRows.length === 0) { throw new Error('用户不存在'); } const user = uRows[0]; if (user.status === '已封禁' || user.status === '封禁') { const err: any = new Error('您的账号已被管理员封禁,无法充值'); err.status = 403; err.code = 40300; throw err; } const newBalance = Number(user.balance) + totalCredit; await conn.query('UPDATE users SET balance = ? WHERE id = ?', [newBalance, id]); // 写入财务充值入账流水 const recId = `fin_rc_${Date.now()}`; const recNo = `RC${Date.now()}`; await conn.query(` INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, '钱包充值', ?, 'IN', '微信支付', ?, NOW()) `, [recId, recNo, amount, String(id)]); // 若有活动赠额,写入平台活动赠金财务流水 if (safeBonus > 0) { const bonusRecId = `fin_bn_${Date.now() + 1}`; const bonusRecNo = `BN${Date.now() + 1}`; await conn.query(` INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, '充值加赠', ?, 'IN', '平台活动赠金', ?, NOW()) `, [bonusRecId, bonusRecNo, safeBonus, String(id)]); } await conn.commit(); return { id, balance: newBalance.toFixed(2), rechargedAmount: amount.toFixed(2), bonusAmount: safeBonus.toFixed(2), totalCredit: totalCredit.toFixed(2), }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } // ===== 微信支付意图与幂等入账 (支付对账核心) ===== async createPaymentIntent(data: { outTradeNo: string; scene?: string; userId?: number; orderId?: string; title?: string; amountCents: number; bonusCents?: number; }): Promise { const id = `pi_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; await pool.query( `INSERT INTO payment_intents (id, out_trade_no, scene, user_id, order_id, title, amount_cents, bonus_cents, status, credited, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, NOW()) ON DUPLICATE KEY UPDATE scene = VALUES(scene), user_id = VALUES(user_id), order_id = VALUES(order_id), title = VALUES(title), amount_cents = VALUES(amount_cents), bonus_cents = VALUES(bonus_cents)`, [ id, data.outTradeNo, data.scene || 'ORDER', Number(data.userId) || 0, data.orderId || '', (data.title || '').slice(0, 120), Math.max(0, Math.round(Number(data.amountCents) || 0)), Math.max(0, Math.round(Number(data.bonusCents) || 0)), ] ); return { id, outTradeNo: data.outTradeNo, scene: data.scene || 'ORDER' }; } async getPaymentIntentByOutTradeNo(outTradeNo: string): Promise { const [rows]: any = await pool.query( 'SELECT * FROM payment_intents WHERE out_trade_no = ? LIMIT 1', [outTradeNo] ); return (rows && rows[0]) || null; } async getLatestRechargeIntent(userId: number): Promise { const [rows]: any = await pool.query( "SELECT * FROM payment_intents WHERE user_id = ? AND scene = 'RECHARGE' ORDER BY (status = 'SUCCESS' AND credited = 0) DESC, created_at DESC LIMIT 1", [userId] ); return (rows && rows[0]) || null; } // 在事务内为充值意图入账(幂等:仅 credited=0 时执行) private async creditRechargeInTx(conn: any, intent: any): Promise { if (Number(intent.credited) === 1) return false; const amountYuan = Number(intent.amount_cents) / 100; const bonusYuan = Number(intent.bonus_cents) / 100; const totalYuan = amountYuan + bonusYuan; const ts = Date.now(); const rand = () => Math.floor(100 + Math.random() * 900); await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [ totalYuan, intent.user_id, ]); await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '钱包充值', ?, 'IN', '微信支付', ?, NOW())`, [`fin_pr_${ts}_${rand()}`, `RC${ts}${rand()}`, intent.user_id, amountYuan.toFixed(2), intent.out_trade_no] ); if (bonusYuan > 0) { await conn.query( `INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '充值加赠', ?, 'IN', '平台活动赠金', ?, NOW())`, [`fin_pb_${ts}_${rand()}`, `BN${ts}${rand()}`, intent.user_id, bonusYuan.toFixed(2), intent.out_trade_no] ); } await conn.query('UPDATE payment_intents SET credited = 1 WHERE out_trade_no = ?', [intent.out_trade_no]); return true; } /** * 支付成功后落库对账(由回调或主动查单触发),幂等。 * RECHARGE -> 入账钱包余额;ORDER -> 订单标记已支付。 */ async applyPaymentSuccess( outTradeNo: string, opts?: { transactionId?: string; amountTotalCents?: number } ): Promise { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [rows]: any = await conn.query( 'SELECT * FROM payment_intents WHERE out_trade_no = ? FOR UPDATE', [outTradeNo] ); if (!rows || rows.length === 0) { await conn.rollback(); return { handled: false, reason: 'intent_not_found', outTradeNo }; } const intent = rows[0]; if (intent.status === 'SUCCESS') { await conn.rollback(); return { handled: true, already: true, scene: intent.scene, credited: Number(intent.credited) === 1 }; } // 金额一致性校验:微信实收金额必须与支付意图金额完全一致, // 杜绝伪造低价支付意图来「低价支付高价订单」或套取充值赠金。 const paidCents = Number(opts?.amountTotalCents); if (!isNaN(paidCents) && paidCents > 0 && paidCents !== Number(intent.amount_cents)) { await conn.rollback(); logServerError( 'applyPaymentSuccess.amountMismatch', new Error(`outTradeNo=${outTradeNo} 应收=${intent.amount_cents}分 实收=${paidCents}分`) ); return { handled: false, reason: 'amount_mismatch', expectedCents: Number(intent.amount_cents), paidCents, outTradeNo, }; } await conn.query( "UPDATE payment_intents SET status = 'SUCCESS', transaction_id = ?, paid_at = NOW() WHERE out_trade_no = ?", [opts?.transactionId || '', outTradeNo] ); let credited = false; if (intent.scene === 'RECHARGE') { credited = await this.creditRechargeInTx(conn, intent); } else if (intent.scene === 'ORDER' && intent.order_id) { await conn.query( `UPDATE orders SET status = CASE WHEN status IN ('待支付','待付款') THEN '履约中' ELSE status END, paid_at = COALESCE(paid_at, NOW()) WHERE id = ? OR order_no = ?`, [intent.order_id, intent.order_id] ); } await conn.commit(); return { handled: true, already: false, scene: intent.scene, credited }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } // 充值对账:依据已支付意图幂等入账;若意图尚未成功则显式报错(由路由层先主动查单) async reconcileRecharge( userId: number, outTradeNo?: string ): Promise { const intent = outTradeNo ? await this.getPaymentIntentByOutTradeNo(outTradeNo) : await this.getLatestRechargeIntent(userId); if (!intent) { const err: any = new Error('未找到对应的微信支付订单,无法入账'); err.status = 400; err.code = 40000; throw err; } if (String(intent.user_id) !== String(userId)) { const err: any = new Error('支付订单与当前用户不匹配'); err.status = 403; err.code = 40300; throw err; } if (intent.status !== 'SUCCESS') { const err: any = new Error('微信支付尚未完成,请稍后重试'); err.status = 400; err.code = 40000; throw err; } if (Number(intent.credited) === 0) { const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [cur]: any = await conn.query( 'SELECT * FROM payment_intents WHERE out_trade_no = ? FOR UPDATE', [intent.out_trade_no] ); if (cur && cur[0]) { await this.creditRechargeInTx(conn, cur[0]); } await conn.commit(); } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } const [uRows]: any = await pool.query('SELECT balance FROM users WHERE id = ?', [userId]); const balance = uRows && uRows[0] ? Number(uRows[0].balance) : 0; return { id: userId, balance: balance.toFixed(2), rechargedAmount: (Number(intent.amount_cents) / 100).toFixed(2), bonusAmount: (Number(intent.bonus_cents) / 100).toFixed(2), totalCredit: ((Number(intent.amount_cents) + Number(intent.bonus_cents)) / 100).toFixed(2), outTradeNo: intent.out_trade_no, }; } async withdrawUserCommission( id: number, amount: number, channel: string = '微信零钱', accountInfo?: { account?: string; realName?: string; bankName?: string } ): Promise { if (isNaN(amount) || amount <= 0) { throw new Error('提现金额必须大于0'); } // 格式化渠道详情 (支持 WECHAT, ALIPAY, BANK 与中文多渠道) let fullChannel = channel || '微信零钱'; if (channel === 'WECHAT') fullChannel = '微信零钱'; if (accountInfo?.account) { if (channel === '支付宝' || channel === 'ALIPAY') { fullChannel = `支付宝 (${accountInfo.account}${accountInfo.realName ? ' - ' + accountInfo.realName : ''})`; } else if (channel === '银行卡' || channel === 'BANK') { const bank = accountInfo.bankName || '银行卡'; const cardNo = accountInfo.account; const masked = cardNo.length > 8 ? `${cardNo.slice(0, 4)}****${cardNo.slice(-4)}` : cardNo; fullChannel = `${bank} (${masked}${accountInfo.realName ? ' - ' + accountInfo.realName : ''})`; } } // 动态读取财务提现配置限制门槛 let minWithdraw = 50; try { const fConfig = await this.getPlatformConfigByKey('finance_rules'); if (fConfig?.valueJson?.minWithdrawAmount) { minWithdraw = Number(fConfig.valueJson.minWithdrawAmount); } } catch (_) {} if (amount < minWithdraw) { throw new Error(`提现金额不能低于平台最低门槛 ¥${minWithdraw}`); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); const [uRows]: any = await conn.query('SELECT nickname, commission, balance, status FROM users WHERE id = ? FOR UPDATE', [id]); if (!uRows || uRows.length === 0) { throw new Error('用户不存在'); } const user = uRows[0]; if (user.status === '已封禁' || user.status === '封禁') { const err: any = new Error('您的账号已被管理员封禁,资金账户已冻结无法提现'); err.status = 403; err.code = 40300; throw err; } const currentCommission = Number(user.commission || 0); if (currentCommission < amount) { throw new Error(`可提现佣金不足,当前可提现为 ¥${currentCommission.toFixed(2)}`); } const remainingCommission = currentCommission - amount; await conn.query('UPDATE users SET commission = ? WHERE id = ?', [remainingCommission, id]); // 写入提现申请表 const wdId = `wd_${Date.now()}`; const wdNo = `TX${Date.now()}`; await conn.query(` INSERT INTO withdrawals (id, withdraw_no, companion_name, companion_id, amount, balance, channel, payee_name, payee_account, status, apply_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '待审核', NOW()) `, [wdId, wdNo, user.nickname, id, amount, remainingCommission, fullChannel, accountInfo?.realName || '', accountInfo?.account || '']); // 写入财务出账流水 const recId = `fin_wd_${Date.now()}`; const recNo = `TR${Date.now()}`; await conn.query(` INSERT INTO finance_records (id, record_no, user_id, type, amount, direction, counterparty, related_id, created_at) VALUES (?, ?, ?, '佣金提现', ?, 'OUT', ?, ?, NOW()) `, [recId, recNo, id, amount, `${user.nickname} (${fullChannel})`, wdId]); await conn.commit(); return { id, withdrawNo: wdNo, amount: amount.toFixed(2), commission: remainingCommission.toFixed(2), status: '待审核', channel: fullChannel, }; } catch (err) { await conn.rollback(); throw err; } finally { conn.release(); } } async getUserFinanceRecords(userId: number): Promise { const [rows]: any = await pool.query(` SELECT id, record_no as recordNo, user_id as userId, type, amount, direction, counterparty, related_id as relatedId, created_at as createdAt FROM finance_records WHERE user_id = ? ORDER BY created_at DESC LIMIT 100 `, [Number(userId)]); return rows; } // 友圈动态相关 async getPosts(category?: string, keyword?: string, status?: string, userId?: number): Promise { const currentUserId = userId !== undefined ? Number(userId) : 1001; const likedPostIdSet = new Set(); try { const [likedRows]: any = await pool.query( 'SELECT post_id FROM post_likes WHERE user_id = ?', [currentUserId] ); if (Array.isArray(likedRows)) { likedRows.forEach((row: any) => likedPostIdSet.add(String(row.post_id))); } } catch (_) {} const applyStatsMap = new Map(); try { const [applyRows]: any = await pool.query( 'SELECT post_id, applicant_id, status FROM post_applies ORDER BY created_at DESC' ); if (Array.isArray(applyRows)) { for (const ar of applyRows) { const pid = String(ar.post_id); const cur = applyStatsMap.get(pid) || { total: 0, pending: 0, myStatus: '' }; cur.total += 1; if (ar.status === 'PENDING') cur.pending += 1; if (Number(ar.applicant_id) === currentUserId && !cur.myStatus) { cur.myStatus = ar.status || 'PENDING'; } applyStatsMap.set(pid, cur); } } } catch (_) {} let sql = ` SELECT id, user_id as userId, author, avatar, tag, tag_type as tagType, location, distance, content, topics, images, action_text as actionText, likes, comments, views, category, is_top as isTop, status, created_at as createdAt FROM posts `; const params: any[] = []; const where: string[] = []; if (category && category !== 'all') { where.push('category = ?'); params.push(category); } if (keyword) { where.push('(content LIKE ? OR author LIKE ? OR location LIKE ?)'); params.push(`%${keyword}%`, `%${keyword}%`, `%${keyword}%`); } if (status && status !== 'all') { where.push('status = ?'); params.push(status); } if (where.length > 0) { sql += ' WHERE ' + where.join(' AND '); } sql += ' ORDER BY is_top DESC, created_at DESC LIMIT 200'; const [rows]: any = await pool.query(sql, params); return rows.map((r: any) => { let topicsArr = []; let imagesArr = []; try { topicsArr = typeof r.topics === 'string' ? JSON.parse(r.topics) : (r.topics || []); } catch (_) { topicsArr = []; } try { imagesArr = typeof r.images === 'string' ? JSON.parse(r.images) : (r.images || []); } catch (_) { imagesArr = []; } const appStat = applyStatsMap.get(String(r.id)) || { total: 0, pending: 0, myStatus: '' }; return { ...r, liked: likedPostIdSet.has(String(r.id)), isTop: Boolean(r.isTop), status: r.status || '正常', topics: topicsArr, images: imagesArr, applyCount: appStat.total, pendingApplyCount: appStat.pending, myApplyStatus: appStat.myStatus, }; }); } // 管理端切换置顶 async togglePostTop(postId: string, isTop: boolean): Promise { const [result]: any = await pool.query(` UPDATE posts SET is_top = ? WHERE id = ? `, [isTop ? 1 : 0, postId]); return result.affectedRows > 0; } // 管理端切换状态 (正常 / 隐藏) async updatePostStatus(postId: string, status: string): Promise { const [result]: any = await pool.query(` UPDATE posts SET status = ? WHERE id = ? `, [status, postId]); return result.affectedRows > 0; } // 管理端彻底删除动态及下属评论 async adminDeletePost(postId: string): Promise { try { await pool.query('DELETE FROM post_comments WHERE post_id = ?', [postId]); } catch (_) {} const [result]: any = await pool.query('DELETE FROM posts WHERE id = ?', [postId]); return result.affectedRows > 0; } // 管理端删除特定违规评论 async adminDeletePostComment(commentId: string): Promise { const [result]: any = await pool.query('DELETE FROM post_comments WHERE id = ?', [commentId]); return result.affectedRows > 0; } async createPost(data: { userId?: number; author?: string; avatar?: string; tag?: string; tagType?: string; location?: string; content: string; topics?: string[]; images?: string[]; actionText?: string; category?: string; }): Promise { const id = `post_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const userId = Number(data.userId) || 1001; const [uStatus]: any = await pool.query('SELECT status FROM users WHERE id = ?', [userId]); if (uStatus && uStatus.length > 0 && (uStatus[0].status === '已封禁' || uStatus[0].status === '封禁')) { const err: any = new Error('您的账号已被管理员封禁,无法发布动态'); err.status = 403; err.code = 40300; throw err; } let author = data.author ? data.author.trim() : ''; let avatar = data.avatar ? data.avatar.trim() : ''; try { const [uRows]: any = await pool.query('SELECT nickname, avatar FROM users WHERE id = ? LIMIT 1', [userId]); if (uRows && uRows.length > 0) { const dbUser = uRows[0]; if (!author || ['电竞玩家 (我)', '热心搭子', '电竞玩家', '点击登录 / 注册'].includes(author)) { author = dbUser.nickname || author; } if (!avatar || avatar.includes('photo-1535713875002-d1d0cf377fde')) { avatar = dbUser.avatar || avatar; } } } catch (_) {} author = author || '同城玩家'; avatar = avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=150'; const tag = data.tag || '同城搭子'; const tagType = data.tagType || 'warm-tag'; const location = data.location || '朝阳区 · 三里屯太古里'; const distance = '100m内'; const content = data.content; const topicsJson = JSON.stringify(data.topics || []); const imagesJson = JSON.stringify(data.images || []); const actionText = data.actionText || '加入车队'; const category = data.category || 'partner'; await pool.query(` INSERT INTO posts ( id, user_id, author, avatar, tag, tag_type, location, distance, content, topics, images, action_text, likes, comments, views, category, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, 1, ?, NOW()) `, [ id, userId, author, avatar, tag, tagType, location, distance, content, topicsJson, imagesJson, actionText, category ]); return { id, userId, author, avatar, tag, tagType, location, distance, content, topics: data.topics || [], images: data.images || [], actionText, likes: 0, comments: 0, views: 1, category, createdAt: new Date().toISOString() }; } async likePost(id: string, userId: number = 1001, action?: 'like' | 'unlike'): Promise { const currentUserId = Number(userId) || 1001; let liked = false; try { const [existing]: any = await pool.query( 'SELECT id FROM post_likes WHERE post_id = ? AND user_id = ?', [id, currentUserId] ); const isCurrentlyLiked = existing && existing.length > 0; const shouldLike = action ? action === 'like' : !isCurrentlyLiked; if (!shouldLike) { if (isCurrentlyLiked) { await pool.query('DELETE FROM post_likes WHERE post_id = ? AND user_id = ?', [id, currentUserId]); await pool.query('UPDATE posts SET likes = GREATEST(0, likes - 1) WHERE id = ?', [id]); } liked = false; } else { if (!isCurrentlyLiked) { const likeId = `like_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; await pool.query( 'INSERT IGNORE INTO post_likes (id, post_id, user_id, created_at) VALUES (?, ?, ?, NOW())', [likeId, id, currentUserId] ); await pool.query('UPDATE posts SET likes = likes + 1 WHERE id = ?', [id]); } liked = true; } } catch (_) { // 容错兜底递增 await pool.query('UPDATE posts SET likes = likes + 1 WHERE id = ?', [id]); liked = true; } const [rows]: any = await pool.query('SELECT likes FROM posts WHERE id = ?', [id]); return { id, likes: rows[0]?.likes || 0, liked }; } async viewPost(id: string): Promise { await pool.query('UPDATE posts SET views = views + 1 WHERE id = ?', [id]); const [rows]: any = await pool.query('SELECT views FROM posts WHERE id = ?', [id]); return { id, views: rows[0]?.views || 1 }; } // 13. 订单评价与评分业务(评价归属与结算触发均由服务端校验,杜绝越权/绕过) async createOrderReview(data: { orderId: string; expertId?: number; userId?: number; userNickname?: string; userAvatar?: string; rating: number; tags?: string[]; content?: string; }): Promise { const id = `rev_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const orderId = data.orderId; // 1. 校验订单存在并读取权威字段(买家 / 大神 / 状态) const [orderRows]: any = await pool.query('SELECT * FROM orders WHERE id = ? OR order_no = ? LIMIT 1', [orderId, orderId]); const order = orderRows && orderRows[0]; if (!order) { const err: any = new Error('订单不存在,无法评价'); err.status = 404; err.code = 40400; throw err; } const userId = Number(data.userId) || 1001; const buyerId = Number(order.buyer_id || 0); // 2. 仅下单买家本人可评价,服务端强制校验身份(不再信任前端入参) if (buyerId > 0 && buyerId !== userId) { const err: any = new Error('仅下单买家本人可以评价该订单'); err.status = 403; err.code = 40300; throw err; } // 3. 仅「已实际服务/已完成」的订单可评价,杜绝未履约(待付款/待接单等)即触发结算 const reviewable = ['履约中', '服务中', '已到场核销', '待确认', '已完成']; if (!reviewable.includes(String(order.status))) { const err: any = new Error(`当前订单状态(${order.status})不可评价`); err.status = 400; err.code = 40000; throw err; } // 4. 防重复评价(与 DB 唯一约束 uq_order_review 双保险) const [dupRows]: any = await pool.query( 'SELECT id FROM order_reviews WHERE order_id IN (?, ?) LIMIT 1', [String(order.id), String(order.order_no || '')] ); if (dupRows && dupRows.length > 0) { const err: any = new Error('该订单已评价,请勿重复提交'); err.status = 409; err.code = 40900; throw err; } // 5. 大神归属以订单为准,避免评分被伪造挂到他人名下 const expertId = Number(order.expert_id) || Number(data.expertId) || 101; const userNickname = data.userNickname || '买家用户'; const userAvatar = data.userAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; const rating = Math.max(1, Math.min(5, Number(data.rating) || 5)); const tagsJson = JSON.stringify(data.tags || []); const content = data.content || '体验非常棒,好评!'; // 6. 先完成结算(幂等且原子),再落库评价,避免"评价已存但结算失败"的半成品状态 await this.completeOrderWithSettlement(String(order.id)); await pool.query(` INSERT INTO order_reviews ( id, order_id, expert_id, user_id, user_nickname, user_avatar, rating, tags, content, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW()) `, [id, String(order.id), expertId, userId, userNickname, userAvatar, rating, tagsJson, content]); try { const altId = Number(expertId) > 100 && Number(expertId) < 200 ? Number(expertId) - 100 : (Number(expertId) < 100 ? Number(expertId) + 100 : Number(expertId)); const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id IN (?, ?)', [expertId, altId]); if (avgRows && avgRows[0]?.avgRating) { const newRating = Number(Number(avgRows[0].avgRating).toFixed(2)); await pool.query('UPDATE companions SET rating = ? WHERE id IN (?, ?) OR user_id = ?', [newRating, expertId, altId, expertId]); } } catch (_) {} return { id, orderId, expertId, userId, userNickname, userAvatar, rating, tags: data.tags || [], content, createdAt: new Date().toISOString() }; } async getOrderReviews(expertId?: number, orderId?: string): Promise { try { await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content'); } catch (_) {} try { await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_time DATETIME AFTER reply_content'); } catch (_) {} let sql = ` SELECT id, order_id as orderId, expert_id as expertId, user_id as userId, user_nickname as userNickname, user_avatar as userAvatar, rating, tags, content, COALESCE(reply_content, '') as replyContent, reply_time as replyTime, created_at as createdAt FROM order_reviews `; const params: any[] = []; const where: string[] = []; if (expertId) { const numExp = Number(expertId); const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); where.push('expert_id IN (?, ?)'); params.push(numExp, altId); } if (orderId) { where.push('order_id = ?'); params.push(orderId); } if (where.length > 0) { sql += ' WHERE ' + where.join(' AND '); } sql += ' ORDER BY created_at DESC LIMIT 200'; const [rows]: any = await pool.query(sql, params); return rows.map((r: any) => { let tagsArr = []; try { tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []); } catch (_) {} return { ...r, tags: tagsArr }; }); } async deleteReview(id: string): Promise { const [rows]: any = await pool.query('SELECT expert_id FROM order_reviews WHERE id = ?', [id]); const expertId = rows && rows[0]?.expert_id; const [res]: any = await pool.query('DELETE FROM order_reviews WHERE id = ?', [id]); if (expertId) { try { const numExp = Number(expertId); const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); const [avgRows]: any = await pool.query('SELECT AVG(rating) as avgRating FROM order_reviews WHERE expert_id IN (?, ?)', [numExp, altId]); const newRating = (avgRows && avgRows[0]?.avgRating) ? Number(Number(avgRows[0].avgRating).toFixed(2)) : 5.0; await pool.query('UPDATE companions SET rating = ? WHERE id IN (?, ?) OR user_id = ?', [newRating, numExp, altId, numExp]); } catch (_) {} } return res.affectedRows > 0; } // 14. 友圈动态评论 async getPostComments(postId: string): Promise { const [rows]: any = await pool.query(` SELECT pc.id, pc.post_id as postId, pc.user_id as userId, CASE WHEN u.nickname IS NOT NULL AND u.nickname != '' AND (pc.author IN ('电竞玩家 (我)', '热心搭子', '电竞玩家', '点击登录 / 注册', '') OR pc.user_id = u.id) THEN u.nickname ELSE COALESCE(NULLIF(pc.author, ''), u.nickname, '热心搭子') END as author, CASE WHEN u.avatar IS NOT NULL AND u.avatar != '' AND (pc.avatar IS NULL OR pc.avatar = '' OR pc.avatar LIKE '%photo-1535713875002-d1d0cf377fde%' OR pc.user_id = u.id) THEN u.avatar ELSE COALESCE(NULLIF(pc.avatar, ''), u.avatar, 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120') END as avatar, pc.content, pc.created_at as createdAt FROM post_comments pc LEFT JOIN users u ON pc.user_id = u.id WHERE pc.post_id = ? ORDER BY pc.created_at ASC `, [postId]); return rows; } async createPostComment(data: { postId: string; userId?: number; author?: string; avatar?: string; content: string; }): Promise { const id = `cmt_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; const postId = data.postId; const userId = Number(data.userId) || 1001; const [uStatus]: any = await pool.query('SELECT status FROM users WHERE id = ?', [userId]); if (uStatus && uStatus.length > 0 && (uStatus[0].status === '已封禁' || uStatus[0].status === '封禁')) { const err: any = new Error('您的账号已被管理员封禁,无法发表评论'); err.status = 403; err.code = 40300; throw err; } let author = data.author ? data.author.trim() : ''; let avatar = data.avatar ? data.avatar.trim() : ''; try { const [uRows]: any = await pool.query('SELECT nickname, avatar FROM users WHERE id = ? LIMIT 1', [userId]); if (uRows && uRows.length > 0) { const dbUser = uRows[0]; if (!author || ['电竞玩家 (我)', '热心搭子', '电竞玩家', '点击登录 / 注册'].includes(author)) { author = dbUser.nickname || author; } if (!avatar || avatar.includes('photo-1535713875002-d1d0cf377fde')) { avatar = dbUser.avatar || avatar; } } } catch (_) {} author = author || '热心搭子'; avatar = avatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; const content = data.content; await pool.query(` INSERT INTO post_comments (id, post_id, user_id, author, avatar, content, created_at) VALUES (?, ?, ?, ?, ?, ?, NOW()) `, [id, postId, userId, author, avatar, content]); await pool.query('UPDATE posts SET comments = comments + 1 WHERE id = ?', [postId]); return { id, postId, userId, author, avatar, content, createdAt: new Date().toISOString() }; } // 15. 友圈约伴上车申请 async applyPostParty(data: { postId: string; applicantId?: number; applicantName?: string; applicantAvatar?: string; }): Promise { const postId = data.postId; const applicantId = Number(data.applicantId) || 1001; const applicantName = data.applicantName || '搭子玩家'; const applicantAvatar = data.applicantAvatar || 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120'; try { const [existRows]: any = await pool.query( 'SELECT * FROM post_applies WHERE post_id = ? AND applicant_id = ? ORDER BY created_at DESC LIMIT 1', [postId, applicantId] ); if (existRows && existRows.length > 0) { const exist = existRows[0]; if (exist.status === 'REJECTED') { await pool.query( "UPDATE post_applies SET status = 'PENDING', applicant_name = ?, applicant_avatar = ?, created_at = NOW() WHERE id = ?", [applicantName, applicantAvatar, exist.id] ); return { id: exist.id, postId, applicantId, applicantName, applicantAvatar, status: 'PENDING', message: '已重新发起约伴上车申请,等待房主确认' }; } return { id: exist.id, postId, applicantId, applicantName: exist.applicant_name || applicantName, applicantAvatar: exist.applicant_avatar || applicantAvatar, status: exist.status || 'PENDING', message: exist.status === 'ACCEPTED' ? '房主已同意您的上车申请' : '约伴上车申请已送达,请等待房主确认' }; } } catch (e) { logServerError('applyPostParty.existCheck', e); } const id = `apply_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; await pool.query(` INSERT INTO post_applies (id, post_id, applicant_id, applicant_name, applicant_avatar, status, created_at) VALUES (?, ?, ?, ?, ?, 'PENDING', NOW()) `, [id, postId, applicantId, applicantName, applicantAvatar]); return { id, postId, applicantId, applicantName, applicantAvatar, status: 'PENDING', message: '约伴上车申请已送达房主' }; } async getPostApplies(postId?: string): Promise { let sql = ` SELECT id, post_id as postId, applicant_id as applicantId, applicant_name as applicantName, applicant_avatar as applicantAvatar, status, created_at as createdAt FROM post_applies `; const params: any[] = []; if (postId) { sql += ' WHERE post_id = ?'; params.push(postId); } sql += ' ORDER BY created_at DESC'; const [rows]: any = await pool.query(sql, params); return rows; } async getPostApplyById(applyId: string): Promise { const [rows]: any = await pool.query('SELECT * FROM post_applies WHERE id = ? LIMIT 1', [applyId]); return rows && rows[0] ? rows[0] : null; } async handlePostApply(applyId: string, status: 'ACCEPTED' | 'REJECTED'): Promise { // 仅允许「待处理」申请被处置一次,防止重复处置与越权确认 const [res]: any = await pool.query( "UPDATE post_applies SET status = ? WHERE id = ? AND status = 'PENDING'", [status, applyId] ); if (!res || res.affectedRows !== 1) { const [rows]: any = await pool.query('SELECT status FROM post_applies WHERE id = ? LIMIT 1', [applyId]); if (!rows || rows.length === 0) { const err: any = new Error('上车申请不存在'); err.status = 404; err.code = 40400; throw err; } const err: any = new Error(`该上车申请已处置(${rows[0].status}),请勿重复操作`); err.status = 409; err.code = 40900; throw err; } return { id: applyId, status }; } // 16. 用户个人提现记录查询 async getUserWithdrawals(userId: number): Promise { const [rows]: any = await pool.query(` SELECT id, withdraw_no as withdrawNo, companion_name as companionName, companion_id as companionId, amount, balance, channel, status, apply_time as applyTime, reason FROM withdrawals WHERE companion_id = ? ORDER BY apply_time DESC LIMIT 50 `, [Number(userId)]); return rows; } // 17. 大神对评价感谢回复 async replyOrderReview(reviewId: string, replyContent: string): Promise { const now = new Date(); try { await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_content TEXT AFTER content'); } catch (_) {} try { await pool.query('ALTER TABLE order_reviews ADD COLUMN reply_time DATETIME AFTER reply_content'); } catch (_) {} await pool.query(` UPDATE order_reviews SET reply_content = ?, reply_time = NOW() WHERE id = ? `, [replyContent, reviewId]); return { id: reviewId, replyContent, replyTime: now.toISOString() }; } // 18. 友圈本人动态删除 async deletePost(postId: string, userId: number): Promise { const uid = Number(userId); if (!uid || isNaN(uid)) { const err: any = new Error('缺少操作人身份,无法删除动态'); err.status = 400; err.code = 40000; throw err; } // 越权校验:仅动态作者本人可删除,杜绝传入任意 postId 删除他人动态 const [ownRows]: any = await pool.query('SELECT id, user_id FROM posts WHERE id = ? LIMIT 1', [postId]); const post = ownRows && ownRows[0]; if (!post) { const err: any = new Error('动态不存在或已被删除'); err.status = 404; err.code = 40400; throw err; } if (Number(post.user_id) !== uid) { const err: any = new Error('无权删除他人发布的动态'); err.status = 403; err.code = 40300; throw err; } // 级联清理下属评论 / 点赞 / 上车申请,避免残留脏数据 try { await pool.query('DELETE FROM post_comments WHERE post_id = ?', [postId]); } catch (_) {} try { await pool.query('DELETE FROM post_likes WHERE post_id = ?', [postId]); } catch (_) {} try { await pool.query('DELETE FROM post_applies WHERE post_id = ?', [postId]); } catch (_) {} const [result]: any = await pool.query('DELETE FROM posts WHERE id = ? AND user_id = ?', [postId, uid]); return result.affectedRows > 0; } // 19. 大神服务包管理 /** 确保 expert_services 表与索引存在(懒建表) */ async ensureExpertServicesTable(): Promise { await pool.query(` CREATE TABLE IF NOT EXISTS expert_services ( id VARCHAR(64) PRIMARY KEY, expert_id INT NOT NULL, title VARCHAR(64) NOT NULL, category VARCHAR(32) NOT NULL, price DECIMAL(10, 2) NOT NULL, unit VARCHAR(16) DEFAULT '小时', mode VARCHAR(32) DEFAULT '排位/匹配', status INT DEFAULT 1, desc_text TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); try { await pool.query('ALTER TABLE expert_services ADD INDEX idx_srv_expert (expert_id)'); } catch (_) { // 索引已存在:忽略 } } private mapExpertService(r: any): any { return { id: r.id, title: r.title, category: r.category, price: Number(r.price), unit: r.unit || '小时', mode: r.mode || '排位/匹配', status: r.status === 1, desc: r.desc_text || '' }; } /** 读取某大神的服务包列表(无服务时返回空数组,不再自动生成演示服务包) */ async getExpertServices(expertId: number): Promise { try { await this.ensureExpertServicesTable(); const [rows]: any = await pool.query( 'SELECT * FROM expert_services WHERE expert_id = ? ORDER BY created_at ASC, id ASC', [expertId] ); return (rows || []).map((r: any) => this.mapExpertService(r)); } catch (e) { logServerError('getExpertServices', e); return []; } } async countExpertServices(expertId: number): Promise { try { const [rows]: any = await pool.query('SELECT COUNT(*) as c FROM expert_services WHERE expert_id = ?', [Number(expertId)]); return Number((rows && rows[0] && rows[0].c) || 0); } catch (_) { return 0; } } async getExpertServiceById(serviceId: string): Promise { try { await this.ensureExpertServicesTable(); const [rows]: any = await pool.query('SELECT * FROM expert_services WHERE id = ? LIMIT 1', [String(serviceId)]); return rows && rows.length > 0 ? this.mapExpertService(rows[0]) : null; } catch (e) { logServerError('getExpertServiceById', e); return null; } } async createExpertService( expertId: number, data: { title: string; category: string; price: number; unit?: string; mode?: string; desc?: string; status?: boolean } ): Promise { await this.ensureExpertServicesTable(); const id = `srv_${Number(expertId)}_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`; await pool.query( `INSERT INTO expert_services (id, expert_id, title, category, price, unit, mode, status, desc_text, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NOW())`, [ id, Number(expertId), String(data.title), String(data.category), Number(data.price), String(data.unit || '小时'), String(data.mode || '排位/匹配'), data.status === false ? 0 : 1, String(data.desc || ''), ] ); return this.getExpertServiceById(id); } async updateExpertService( serviceId: string, data: { title?: string; category?: string; price?: number; unit?: string; mode?: string; desc?: string; status?: boolean } ): Promise { const fields: string[] = []; const values: any[] = []; if (data.title !== undefined) { fields.push('title = ?'); values.push(String(data.title)); } if (data.category !== undefined) { fields.push('category = ?'); values.push(String(data.category)); } if (data.price !== undefined) { fields.push('price = ?'); values.push(Number(data.price)); } if (data.unit !== undefined) { fields.push('unit = ?'); values.push(String(data.unit)); } if (data.mode !== undefined) { fields.push('mode = ?'); values.push(String(data.mode)); } if (data.desc !== undefined) { fields.push('desc_text = ?'); values.push(String(data.desc)); } if (data.status !== undefined) { fields.push('status = ?'); values.push(data.status ? 1 : 0); } if (fields.length === 0) return this.getExpertServiceById(serviceId); values.push(String(serviceId)); await pool.query(`UPDATE expert_services SET ${fields.join(', ')} WHERE id = ?`, values); return this.getExpertServiceById(serviceId); } async deleteExpertService(serviceId: string): Promise { const [r]: any = await pool.query('DELETE FROM expert_services WHERE id = ?', [String(serviceId)]); return Number((r && r.affectedRows) || 0) > 0; } /** 按登录用户解析其大神档案(大神工作台自助管理用) */ async getCompanionByUserId(userId: number): Promise { const uid = Number(userId) || 0; if (uid <= 0) return null; try { const [rows]: any = await pool.query( 'SELECT * FROM companions WHERE user_id = ? ORDER BY id ASC LIMIT 1', [uid] ); return rows && rows.length > 0 ? rows[0] : null; } catch (e) { logServerError('getCompanionByUserId', e); return null; } } async updateExpertServiceStatus(serviceId: string, status: boolean): Promise { try { await pool.query('UPDATE expert_services SET status = ? WHERE id = ?', [status ? 1 : 0, serviceId]); } catch (e) { logServerError('updateExpertServiceStatus', e); } return { id: serviceId, status }; } /** * 解析服务项归属(expert_services.expert_id -> companions),用于上下架越权校验。 */ async getExpertServiceOwnership(serviceId: string): Promise<{ companionId: number; ownerUserId: number | null } | null> { let expertId: number | null = null; try { const [rows]: any = await pool.query('SELECT expert_id FROM expert_services WHERE id = ? LIMIT 1', [serviceId]); if (rows && rows.length > 0) expertId = Number(rows[0].expert_id); } catch (e) { logServerError('getExpertServiceOwnership.query', e); } if (!expertId) { const m = String(serviceId).match(/^srv_(\d+)_/); if (m) expertId = Number(m[1]); } if (!expertId) return null; return this.getCompanionOwnership(expertId); } /** * 解析评价归属(order_reviews.expert_id -> companions),用于大神回复评价越权校验。 */ async getReviewOwnership(reviewId: string): Promise<{ companionId: number; ownerUserId: number | null } | null> { const [rows]: any = await pool.query('SELECT expert_id FROM order_reviews WHERE id = ? LIMIT 1', [reviewId]); if (!rows || rows.length === 0) return null; return this.getCompanionOwnership(Number(rows[0].expert_id)); } // 20. 收藏陪玩管理 (收藏喜欢的陪玩,实时高亮在接单状态) async getFavorites(userId: number): Promise { try { const [rows]: any = await pool.query(` SELECT f.id as favoriteId, f.expert_id as favExpertId, f.created_at as favoritedAt, c.id as compId, c.name as nickname, c.avatar, c.gender, c.age, c.track, c.rank_name as rankName, c.hourly_rate as hourlyRate, c.rating, c.order_count as orderCount, c.status, c.tags FROM favorites f JOIN companions c ON ( f.expert_id = c.id OR (f.expert_id > 100 AND f.expert_id < 200 AND (f.expert_id - 100) = c.id) OR (c.user_id IS NOT NULL AND f.expert_id = c.user_id) ) WHERE f.user_id = ? ORDER BY f.created_at DESC `, [Number(userId)]); if (rows && rows.length > 0) { return rows.map((r: any) => { let tagsArr: string[] = []; try { tagsArr = typeof r.tags === 'string' ? JSON.parse(r.tags) : (r.tags || []); } catch (_) {} const displayId = Number(r.compId) < 100 ? Number(r.compId) + 100 : Number(r.compId); return { ...r, id: displayId, dbId: Number(r.compId), tags: tagsArr, isOnline: r.status === '接单中' }; }); } } catch (_) {} return []; } async addFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> { try { await pool.query(` INSERT IGNORE INTO favorites (user_id, expert_id, created_at) VALUES (?, ?, NOW()) `, [Number(userId), Number(expertId)]); return { success: true, message: '收藏成功' }; } catch (err: any) { return { success: false, message: err.message || '收藏失败' }; } } async removeFavorite(userId: number, expertId: number): Promise<{ success: boolean; message: string }> { try { const numExp = Number(expertId); const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); await pool.query(` DELETE FROM favorites WHERE user_id = ? AND expert_id IN (?, ?) `, [Number(userId), numExp, altId]); return { success: true, message: '已取消收藏' }; } catch (err: any) { return { success: false, message: err.message || '取消收藏失败' }; } } async isFavorite(userId: number, expertId: number): Promise { try { const numExp = Number(expertId); const altId = numExp > 100 && numExp < 200 ? numExp - 100 : (numExp < 100 ? numExp + 100 : numExp); const [rows]: any = await pool.query(` SELECT id FROM favorites WHERE user_id = ? AND expert_id IN (?, ?) LIMIT 1 `, [Number(userId), numExp, altId]); return Boolean(rows && rows.length > 0); } catch (_) { return false; } } // 21. 分销下级团队与返佣记录 async bindInviteCode(userId: number, inviteCode: string): Promise { const uid = Number(userId); const cleaned = String(inviteCode || '').trim().toUpperCase().replace(/^PW/, '').replace(/^U/, ''); const inviterId = Number(cleaned); if (!inviterId || isNaN(inviterId) || inviterId <= 0) { throw new Error('请输入有效的邀请码 (如 PW1001)'); } if (inviterId === uid) { throw new Error('不能绑定自己的邀请码'); } const [invRows]: any = await pool.query('SELECT id, nickname, inviter_id FROM users WHERE id = ? LIMIT 1', [inviterId]); if (!invRows || invRows.length === 0) { throw new Error('邀请码对应的推荐人不存在'); } const inviter = invRows[0]; const level2InviterId = inviter.inviter_id ? Number(inviter.inviter_id) : null; const [curRows]: any = await pool.query('SELECT id, inviter_id FROM users WHERE id = ? LIMIT 1', [uid]); if (!curRows || curRows.length === 0) { throw new Error('当前用户不存在'); } if (curRows[0].inviter_id) { if (Number(curRows[0].inviter_id) === inviterId) { return { userId: uid, inviterId, level2InviterId, inviterName: inviter.nickname, message: `已绑定推荐人:${inviter.nickname}` }; } throw new Error('当前账号已终身绑定上级推荐人,不可重复修改绑定'); } await pool.query('UPDATE users SET inviter_id = ? WHERE id = ?', [inviterId, uid]); await pool.query('UPDATE users SET invited_count = COALESCE(invited_count, 0) + 1 WHERE id = ?', [inviterId]); try { await pool.query(` CREATE TABLE IF NOT EXISTS distribution_relations ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT UNIQUE NOT NULL, inviter_id INT NOT NULL, level2_inviter_id INT DEFAULT NULL, bind_source VARCHAR(64) DEFAULT 'INVITE_CODE', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_inviter (inviter_id), INDEX idx_level2_inviter (level2_inviter_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `); await pool.query( `INSERT INTO distribution_relations (user_id, inviter_id, level2_inviter_id, bind_source, created_at) VALUES (?, ?, ?, 'SUNCODE_OR_INVITE', NOW()) ON DUPLICATE KEY UPDATE inviter_id = VALUES(inviter_id), level2_inviter_id = VALUES(level2_inviter_id)`, [uid, inviterId, level2InviterId] ); } catch (_) {} return { userId: uid, inviterId, level2InviterId, inviterName: inviter.nickname, message: `成功绑定推荐人:${inviter.nickname}` }; } async getDistributionTeam(userId: number): Promise { const uid = Number(userId); try { const [contribRows]: any = await pool.query( 'SELECT from_user_id as fromUserId, COALESCE(SUM(commission), 0) as total FROM distribution_records WHERE user_id = ? GROUP BY from_user_id', [uid] ); const contribMap = new Map(); if (Array.isArray(contribRows)) { contribRows.forEach((r: any) => { contribMap.set(Number(r.fromUserId), Number(r.total || 0).toFixed(2)); }); } const [directRows]: any = await pool.query(` SELECT id, nickname, avatar, phone, created_at as createdAt, 1 as level FROM users WHERE inviter_id = ? ORDER BY created_at DESC `, [uid]); let directList = (directRows || []).map((u: any) => ({ ...u, totalContribute: contribMap.get(Number(u.id)) || '0.00' })); const directIds = directList.map((u: any) => u.id).filter(Boolean); let indirectList: any[] = []; if (directIds.length > 0) { try { const [indRows]: any = await pool.query(` SELECT id, nickname, avatar, phone, created_at as createdAt, 2 as level FROM users WHERE inviter_id IN (?) ORDER BY created_at DESC `, [directIds]); indirectList = (indRows || []).map((u: any) => ({ ...u, totalContribute: contribMap.get(Number(u.id)) || '0.00' })); } catch (_) {} } return { directCount: directList.length, indirectCount: indirectList.length, totalTeamCount: directList.length + indirectList.length, directList, indirectList }; } catch (_) { return { directCount: 0, indirectCount: 0, totalTeamCount: 0, directList: [], indirectList: [] }; } } async getDistributionRecords(userId: number): Promise { const uid = Number(userId); try { const [rows]: any = await pool.query(` SELECT id, from_user_name as fromUserName, order_no as orderNo, order_amount as orderAmount, commission, level, rate_percent as ratePercent, status, created_at as createdAt FROM distribution_records WHERE user_id = ? ORDER BY created_at DESC `, [uid]); if (rows && rows.length > 0) { return rows; } } catch (_) {} // 无真实返佣记录时返回空数组,不再用硬编码假数据冒充实盘收益 return []; } } export const repo = new MysqlRepository();