- /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
5244 行
198 KiB
TypeScript
5244 行
198 KiB
TypeScript
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();
|
||
|
||
|