文件
shanghaipeiwan/server/src/db/repository.ts
xcbyy a61051fa96 security: 修复公开配置接口泄露密钥(map_location_config/security_words)
- /api/system/configs 无需登录,此前原样下发 map_location_config(腾讯位置服务 SecretKey)与 security_words(风控词库)
- getPlatformConfigs 改为:排除 security_words/map_location_config,并递归剔除 secret/private/apiv3/token/pem/cert/mchid 等敏感字段
- wechat_pay_config 仍仅返回脱敏字段(mode/appId/mchIdConfigured)
- 新增回归护栏 public-config-privacy.spec.ts(89 套件/644 用例全绿);冒烟 5/5
2026-10-19 13:39:04 +08:00

5244 行
198 KiB
TypeScript

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

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

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<ActivityRow[]> {
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<any | null> {
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<any> {
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<string, { participantId: string; userId: number; expireAt: number }>();
private async settleActivityCheckinAndReferral(
activityId: string,
creatorId: number,
participantId: string,
participantUserId: number,
payAmountCents: number
): Promise<void> {
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<any> {
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<any> {
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<any> {
// 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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any | null> {
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(): Promise<any[]> {
try {
await pool.query("ALTER TABLE companions ADD COLUMN reject_reason VARCHAR(255) DEFAULT '' AFTER audit_status");
} catch (_) {}
const [rows]: any = await pool.query(`
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
ORDER BY id ASC
`);
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<any> {
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<any> {
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<any> {
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<any> {
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<any | null> {
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<any> {
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');
}
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<any[]> {
// 订单超时自动流转统一由后台定时任务处理(见 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<any[]> {
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;
payType?: string;
location?: string;
expenseBurden?: string;
remark?: string;
status?: string;
}): Promise<any> {
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);
}
// 优惠券折扣必须命中平台启用中的有效券(面额 / 门槛 / 品类),否则忽略为 0
const baseAmount = Number((hourlyRate * durationHours).toFixed(2));
const rawDiscount = Math.max(0, Number(data.couponDiscount || 0));
const 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, 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, payType,
location, verifyCode, remark, status
]);
// 记一笔平台订单进账流水
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<any> {
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<any> {
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');
} else {
await conn.query('UPDATE orders SET status = "已取消" WHERE id = ?', [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<any | null> {
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<any> {
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<any> {
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 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<number> {
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<any> {
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<any[]> {
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<any> {
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<any> {
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<any[]> {
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<any[]> {
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<any> {
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<any> {
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<any> {
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<boolean> {
const [res]: any = await pool.query('DELETE FROM banners WHERE id = ?', [id]);
return res.affectedRows > 0;
}
async getNavCategories(): Promise<any[]> {
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<any[]> {
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<any> {
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<any> {
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<any> {
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<boolean> {
const [res]: any = await pool.query('DELETE FROM nav_categories WHERE id = ?', [id]);
return res.affectedRows > 0;
}
// 1. 充值阶梯 (Recharge Tiers)
async getRechargeTiers(): Promise<any[]> {
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<number> {
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<any[]> {
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<any> {
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<any> {
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<boolean> {
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<number> {
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 getCoupons(categoryLimit?: string): Promise<any[]> {
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<any[]> {
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<any> {
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<any> {
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<boolean> {
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<any> {
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<any[]> {
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<any> {
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<any[]> {
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<any> {
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');
}
// 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<any> {
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<any> {
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<any[]> {
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<any> {
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<any[]> {
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 getWithdrawals(): Promise<any[]> {
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<any> {
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<any | null> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any[]> {
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<any> {
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<any> {
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<boolean> {
const [res]: any = await pool.query('DELETE FROM safety_places WHERE id = ?', [id]);
return res.affectedRows > 0;
}
async getSafetyAuditLogs(): Promise<any[]> {
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<any> {
// 计入成交的有效订单状态(已付款或已履约),排除未付款/已取消/已退款
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<string, number> = {};
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<any> {
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);
}
const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0);
return {
pendingAuditsCount: auditRows.length,
pendingArbitrationsCount: arbRows.length,
pendingWithdrawalsCount: wdRows.length,
pendingWithdrawalAmount,
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 getUsers(filter?: { keyword?: string; role?: string; status?: string }): Promise<any[]> {
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<any> {
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<boolean> {
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<number> {
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<any> {
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<any> {
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<boolean> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any> {
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<boolean> {
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<any> {
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<any> {
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<any> {
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<any[]> {
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<any[]> {
const currentUserId = userId !== undefined ? Number(userId) : 1001;
const likedPostIdSet = new Set<string>();
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<string, { total: number; pending: number; myStatus: string }>();
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<boolean> {
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<boolean> {
const [result]: any = await pool.query(`
UPDATE posts SET status = ? WHERE id = ?
`, [status, postId]);
return result.affectedRows > 0;
}
// 管理端彻底删除动态及下属评论
async adminDeletePost(postId: string): Promise<boolean> {
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<boolean> {
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<any> {
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<any> {
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<any> {
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<any> {
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<any[]> {
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<boolean> {
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<any[]> {
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<any> {
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<any> {
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<any[]> {
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<any | null> {
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<any> {
// 仅允许「待处理」申请被处置一次,防止重复处置与越权确认
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<any[]> {
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<any> {
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<boolean> {
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. 大神服务包管理
async getExpertServices(expertId: number): Promise<any[]> {
const defaultList = [
{ id: `srv_${expertId}_1`, title: '排位保星连胜局', category: '王者荣耀', price: 45, unit: '小时', mode: '排位赛', status: true, desc: '荣耀王者实力带飞,保星上分,麦克风清晰互动' },
{ id: `srv_${expertId}_2`, title: '娱乐匹配快乐唠嗑局', category: '英雄联盟手游', price: 30, unit: '小时', mode: '匹配/大乱斗', status: true, desc: '轻松搞笑,幽默风趣,情绪价值拉满' },
{ id: `srv_${expertId}_3`, title: '英雄技术单项教学指导', category: '和平精英', price: 55, unit: '小时', mode: '一对一复盘', status: true, desc: '对线细节、走位身法与团战意识深度剖析复盘' }
];
try {
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 (_) {
// 索引已存在:忽略
}
const [rows]: any = await pool.query('SELECT * FROM expert_services WHERE expert_id = ? ORDER BY created_at ASC', [expertId]);
if (rows && rows.length > 0) {
return rows.map((r: any) => ({
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 || ''
}));
}
// 首次读取时自动将默认服务包落库,确保后续上下架切换真实持久化
for (const item of defaultList) {
await pool.query(
`INSERT IGNORE INTO expert_services (id, expert_id, title, category, price, unit, mode, status, desc_text)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`,
[item.id, Number(expertId), item.title, item.category, item.price, item.unit, item.mode, item.desc]
);
}
} catch (_) {}
return defaultList;
}
async updateExpertServiceStatus(serviceId: string, status: boolean): Promise<any> {
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<any[]> {
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<boolean> {
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<any> {
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<any> {
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<number, string>();
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<any[]> {
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();