feat(security): 三端闭环加固 + 微信提现打款/订阅消息落地
后端: 身份与归属鉴权收口; 资金安全(结算闸门/支付防篡改/报名费真实扣款/赠金/结算唯一入账/提现打款); 数据泄露与隐私; 登录与密钥安全(默认密钥闸门/登录限流); 可观测性; 启动自检. 微信: 商家转账到零钱提现打款闭环(含回调/查单/失败退回佣金); 订阅消息真实下发闭环与后台模板字段对齐. 后台: 财务/微信支付配置读取失败锁定; 看板/待办/走势去假数据; 打款与测试推送文案修正. 小程序: 请求层错误透传与登录过期提示; 多页失败反馈与乐观回滚; 帮助中心FAQ页面化. 测试: 新增 40+ 回归护栏与真实 MySQL 冒烟(75 套件/599 用例全绿).
这个提交包含在:
@@ -0,0 +1,66 @@
|
||||
// 已读取AGENTS.md
|
||||
/**
|
||||
* 微信小程序「订阅消息」(服务通知) 下发客户端。
|
||||
* 说明:订阅消息为一次性订阅,用户每次授权仅可下发一条;调用需有效 access_token。
|
||||
* 该模块为纯函数/无状态,便于离线单元校验与复用。
|
||||
*/
|
||||
import https from 'https';
|
||||
|
||||
export interface SubscribeMessagePayload {
|
||||
touser: string;
|
||||
template_id: string;
|
||||
page?: string;
|
||||
data: Record<string, { value: string }>;
|
||||
miniprogram_state?: 'developer' | 'trial' | 'formal';
|
||||
lang?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组装微信订阅消息 data 字段:入参为 { 字段名: 值 },输出 { 字段名: { value } }。
|
||||
* 微信各字段有长度限制(thing 类通常 ≤20 字符),此处统一截断到 20,避免 47003 参数错误。
|
||||
*/
|
||||
export function buildTemplateData(input: Record<string, string | number | undefined | null>): Record<string, { value: string }> {
|
||||
const out: Record<string, { value: string }> = {};
|
||||
for (const [key, val] of Object.entries(input || {})) {
|
||||
if (val === undefined || val === null) continue;
|
||||
out[key] = { value: String(val).slice(0, 20) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 调用微信 cgi-bin/message/subscribe/send 真实下发 */
|
||||
export function sendSubscribeMessageHttp(
|
||||
accessToken: string,
|
||||
payload: SubscribeMessagePayload
|
||||
): Promise<{ errcode: number; errmsg: string }> {
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=${encodeURIComponent(accessToken)}`;
|
||||
const body = JSON.stringify(payload);
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
},
|
||||
},
|
||||
(resp) => {
|
||||
let data = '';
|
||||
resp.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
resp.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(data));
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -168,6 +168,99 @@ export async function queryOrderByOutTradeNo(
|
||||
return JSON.parse(resp.body || '{}');
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA-OAEP(SHA-1) 加密敏感信息。
|
||||
* 微信支付「商家转账到零钱」在部分转账场景下要求收款人真实姓名(user_name)以平台公钥加密后提交。
|
||||
*/
|
||||
export function encryptSensitiveInfo(platformPublicKey: string, plain: string): string {
|
||||
const key = resolvePlatformKey(platformPublicKey);
|
||||
if (!key) {
|
||||
throw new Error('缺少有效的微信支付平台公钥,无法加密敏感信息(收款人姓名)');
|
||||
}
|
||||
const encrypted = crypto.publicEncrypt(
|
||||
{ key, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha1' },
|
||||
Buffer.from(String(plain), 'utf8')
|
||||
);
|
||||
return encrypted.toString('base64');
|
||||
}
|
||||
|
||||
export interface TransferParams {
|
||||
/** 商户侧转账单号(全局唯一,建议直接用提现单号,天然幂等) */
|
||||
outBillNo: string;
|
||||
openid: string;
|
||||
amountCents: number;
|
||||
/** 转账场景ID(商户平台「商家转账到零钱」配置的转账场景) */
|
||||
sceneId: string;
|
||||
remark: string;
|
||||
notifyUrl?: string;
|
||||
/** 收款人真实姓名(明文,调用内部会按平台公钥加密) */
|
||||
userName?: string;
|
||||
/** 部分转账场景必填的场景信息(如岗位类型/报酬说明) */
|
||||
sceneReportInfos?: Array<{ info_type: string; info_content: string }>;
|
||||
}
|
||||
|
||||
/** 商家转账到零钱(V3):向指定 openid 发起转账。成功受理返回 { out_bill_no, state, ... } */
|
||||
export async function transferToWallet(cfg: WechatPayConfig, p: TransferParams): Promise<any> {
|
||||
const urlPath = '/v3/fund-app/mch-transfer/transfer-bills';
|
||||
const bodyObj: any = {
|
||||
appid: cfg.appId,
|
||||
out_bill_no: p.outBillNo,
|
||||
transfer_scene_id: p.sceneId,
|
||||
openid: p.openid,
|
||||
transfer_amount: Math.round(Number(p.amountCents) || 0),
|
||||
transfer_remark: String(p.remark || '').slice(0, 32),
|
||||
};
|
||||
if (p.notifyUrl) bodyObj.notify_url = p.notifyUrl;
|
||||
if (p.userName && cfg.platformPublicKey) {
|
||||
bodyObj.user_name = encryptSensitiveInfo(cfg.platformPublicKey, p.userName);
|
||||
}
|
||||
if (Array.isArray(p.sceneReportInfos) && p.sceneReportInfos.length > 0) {
|
||||
bodyObj.transfer_scene_report_infos = p.sceneReportInfos;
|
||||
}
|
||||
const body = JSON.stringify(bodyObj);
|
||||
const authorization = buildAuthorization({ method: 'POST', urlPath, body, cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'POST',
|
||||
urlPath,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
body,
|
||||
});
|
||||
let parsed: any = {};
|
||||
try {
|
||||
parsed = JSON.parse(resp.body || '{}');
|
||||
} catch (_) {
|
||||
parsed = {};
|
||||
}
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信商家转账失败 [${resp.statusCode}]: ${parsed.message || resp.body}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** 查询商家转账单结果(按商户转账单号)。state: ACCEPTED/PROCESSING/SUCCESS/FAIL/CANCELING/CANCELLED */
|
||||
export async function queryTransferByOutBillNo(cfg: WechatPayConfig, outBillNo: string): Promise<any> {
|
||||
const urlPath = `/v3/fund-app/mch-transfer/transfer-bills/out-bill-no/${encodeURIComponent(outBillNo)}`;
|
||||
const authorization = buildAuthorization({ method: 'GET', urlPath, body: '', cfg });
|
||||
const resp = await httpsRequest({
|
||||
method: 'GET',
|
||||
urlPath,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: authorization,
|
||||
'User-Agent': 'peiwan-server/1.0',
|
||||
},
|
||||
});
|
||||
if (resp.statusCode >= 400) {
|
||||
throw new Error(`微信商家转账查单失败 [${resp.statusCode}]: ${resp.body}`);
|
||||
}
|
||||
return JSON.parse(resp.body || '{}');
|
||||
}
|
||||
|
||||
/** 校验微信支付回调签名(使用平台公钥/平台证书),时间戳容差 5 分钟 */
|
||||
export function verifyNotifySignature(params: {
|
||||
cfg: WechatPayConfig;
|
||||
|
||||
@@ -208,7 +208,11 @@ export async function initDatabase(): Promise<void> {
|
||||
amount DECIMAL(10,2) NOT NULL,
|
||||
balance DECIMAL(10,2) NOT NULL,
|
||||
channel VARCHAR(64) DEFAULT '微信商户零钱',
|
||||
payee_name VARCHAR(64) DEFAULT '',
|
||||
payee_account VARCHAR(128) DEFAULT '',
|
||||
status VARCHAR(32) DEFAULT '待审核',
|
||||
transfer_bill_no VARCHAR(64) DEFAULT '',
|
||||
transfer_status VARCHAR(32) DEFAULT '',
|
||||
apply_time DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
@@ -216,6 +220,18 @@ export async function initDatabase(): Promise<void> {
|
||||
try {
|
||||
await conn.query('ALTER TABLE withdrawals ADD COLUMN reason VARCHAR(255) DEFAULT ""');
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query("ALTER TABLE withdrawals ADD COLUMN transfer_bill_no VARCHAR(64) DEFAULT ''");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query("ALTER TABLE withdrawals ADD COLUMN transfer_status VARCHAR(32) DEFAULT ''");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query("ALTER TABLE withdrawals ADD COLUMN payee_name VARCHAR(64) DEFAULT ''");
|
||||
} catch (_) {}
|
||||
try {
|
||||
await conn.query("ALTER TABLE withdrawals ADD COLUMN payee_account VARCHAR(128) DEFAULT ''");
|
||||
} catch (_) {}
|
||||
|
||||
// 7.5 微信支付支付意图与对账表 (幂等入账的唯一依据)
|
||||
await conn.query(`
|
||||
@@ -1048,6 +1064,19 @@ async function seedInitialDataIfEmpty(conn: any): Promise<void> {
|
||||
VALUES ('faq_list', '帮助中心与常见问题FAQ', ?, '小程序端帮助中心常见问答与指引', 1)
|
||||
`, [faqListVal]);
|
||||
}
|
||||
// 修复:faq_list 若存在但为空数组(曾被"空保存"清空),自动回填默认种子,
|
||||
// 避免小程序端只显示本地兜底文案、后台却为空。
|
||||
try {
|
||||
const [faqRows]: any = await conn.query("SELECT value_json FROM platform_configs WHERE config_key = 'faq_list' LIMIT 1");
|
||||
const raw = faqRows && faqRows[0] && faqRows[0].value_json;
|
||||
let parsed: any = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { parsed = JSON.parse(raw); } catch (_) { parsed = null; }
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.length === 0) {
|
||||
await conn.query("UPDATE platform_configs SET value_json = ? WHERE config_key = 'faq_list'", [faqListVal]);
|
||||
}
|
||||
} catch (_) {}
|
||||
if (!existingKeys.has('security_words')) {
|
||||
await conn.query(`
|
||||
INSERT INTO platform_configs (config_key, name, value_json, description, status)
|
||||
|
||||
@@ -135,10 +135,20 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
const id = `act_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
|
||||
const payType = data.payType || 1;
|
||||
const maxParticipants = data.maxParticipants || 4;
|
||||
const perPersonFeeCents = data.perPersonFeeCents || 5000;
|
||||
const totalFeeCents = data.totalFeeCents || perPersonFeeCents * maxParticipants;
|
||||
// 参数服务端校验与归一化,防止负数/超大值破坏报名费与人数上限
|
||||
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';
|
||||
|
||||
@@ -258,11 +268,16 @@ export class MysqlRepository {
|
||||
|
||||
// 2. 行级锁锁定活动,串行化同一活动的并发报名,从根上杜绝超卖
|
||||
const [actRows]: any = await conn.query(
|
||||
'SELECT id, max_participants FROM activities WHERE id = ? FOR UPDATE',
|
||||
'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 快照影响,必然看到最新已提交数据,名额判断才准确;
|
||||
@@ -282,6 +297,31 @@ export class MysqlRepository {
|
||||
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 = '';
|
||||
@@ -312,14 +352,14 @@ export class MysqlRepository {
|
||||
INSERT INTO participants
|
||||
(id, activity_id, user_id, pay_amount_cents, verify_code, checkin_status)
|
||||
VALUES (?, ?, ?, ?, ?, 0)
|
||||
`, [ptId, activityId, params.userId, params.payAmountCents, verifyCode]);
|
||||
`, [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)}`, params.payAmountCents / 100, `用户 ${params.userId}`, activityId]);
|
||||
`, [finId, `TR${Date.now()}${Math.floor(Math.random() * 100)}`, joinFeeCents / 100, `用户 ${params.userId}`, activityId]);
|
||||
|
||||
await conn.commit();
|
||||
|
||||
@@ -335,7 +375,7 @@ export class MysqlRepository {
|
||||
id: ptId,
|
||||
activityId,
|
||||
userId: params.userId,
|
||||
payAmountCents: params.payAmountCents,
|
||||
payAmountCents: joinFeeCents,
|
||||
verifyCode,
|
||||
cacheKey,
|
||||
checkinStatus: false,
|
||||
@@ -414,6 +454,7 @@ export class MysqlRepository {
|
||||
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]);
|
||||
|
||||
@@ -443,6 +484,8 @@ export class MysqlRepository {
|
||||
verify_code as verifyCode, status
|
||||
FROM orders
|
||||
WHERE verify_code = ?
|
||||
AND status NOT IN ('已取消', '已退款', '已全额退款')
|
||||
ORDER BY FIELD(status, '履约中', '服务中', '待确认', '已到场核销', '已完成'), created_at DESC
|
||||
LIMIT 1
|
||||
`, [verifyCode]);
|
||||
|
||||
@@ -509,17 +552,47 @@ export class MysqlRepository {
|
||||
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 = ?
|
||||
`, [newPriceCents, activityId]);
|
||||
`, [price, activityId]);
|
||||
|
||||
const finId = `fin_${Date.now()}`;
|
||||
await pool.query(`
|
||||
INSERT INTO finance_records (id, record_no, type, amount, direction, counterparty, related_id)
|
||||
VALUES (?, ?, '房主追加赏金差额退补', ?, 'IN', ?, ?)
|
||||
`, [finId, `TR${Date.now()}`, newPriceCents / 100, `房主 ${operatorId}`, 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);
|
||||
}
|
||||
@@ -929,14 +1002,27 @@ export class MysqlRepository {
|
||||
? data.idCard.substring(0, 6) + '********' + data.idCard.substring(data.idCard.length - 4)
|
||||
: '110101********0000';
|
||||
|
||||
const [existing]: any = await pool.query('SELECT * FROM companions WHERE (user_id IS NOT NULL AND user_id = ?) OR name = ?', [userId, name]);
|
||||
if (existing && existing.length > 0) {
|
||||
// 仅允许本人按 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 user_id = ?, name = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', reject_reason = '', id_card_masked = ?, apply_time = NOW()
|
||||
SET name = ?, track = ?, hourly_rate = ?, tags = ?, avatar = ?, gender = ?, audit_status = '待审核', status = '待审核', reject_reason = '', id_card_masked = ?, apply_time = NOW()
|
||||
WHERE id = ?
|
||||
`, [userId, name, track, hourlyRate, tagsJson, avatar, gender, maskedIdCard, existing[0].id]);
|
||||
return this.getCompanionById(existing[0].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(`
|
||||
@@ -1115,6 +1201,48 @@ export class MysqlRepository {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅返回与指定用户相关的订单(买家 / 承接大神 / 已认领 / 其大神档案对应订单)。
|
||||
* 用于替代把全平台订单下发给任意客户端,避免订单隐私与核销码泄露。
|
||||
*/
|
||||
async getOrdersForUser(userId: number): Promise<any[]> {
|
||||
const uid = Number(userId) || 0;
|
||||
await this.processOrderTimeouts().catch(() => {});
|
||||
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
|
||||
`, [uid, uid, uid, uid, uid]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async createOrder(data: {
|
||||
buyerId?: number;
|
||||
clientName?: string;
|
||||
@@ -1149,16 +1277,41 @@ export class MysqlRepository {
|
||||
const expertAvatar = data.expertAvatar || 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120';
|
||||
const track = data.track || '同城生活';
|
||||
const serviceTitle = data.serviceTitle || `${track} · 专业陪伴服务`;
|
||||
const hourlyRate = Number(data.hourlyRate) || 35;
|
||||
const durationHours = Number(data.durationHours) || 2;
|
||||
const amount = Number(data.amount) || hourlyRate * durationHours;
|
||||
const couponDiscount = Number(data.couponDiscount || 0);
|
||||
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 = data.status || '履约中';
|
||||
// 初始订单状态由服务端按支付方式决定,杜绝客户端创建"已支付/已履约/已完成"订单绕过付款
|
||||
const status = payType === 'BALANCE' ? '履约中' : '待付款';
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
@@ -1181,9 +1334,9 @@ export class MysqlRepository {
|
||||
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, type, amount, direction, counterparty, related_id)
|
||||
VALUES (?, ?, '钱包余额支付订单', ?, 'OUT', ?, ?)
|
||||
`, [walletFinId, walletRecNo, amount.toFixed(2), `用户 ${buyerId} (${companionName})`, id]);
|
||||
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(`
|
||||
@@ -1193,7 +1346,7 @@ export class MysqlRepository {
|
||||
expense_burden, hourly_rate, duration_hours, amount,
|
||||
coupon_discount, pay_type,
|
||||
location, verify_code, remark, status, paid_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待支付' || status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ${status === '待付款' ? 'NULL' : 'NOW()'}, NOW())
|
||||
`, [
|
||||
id, buyerId, expertId, orderNo, clientName,
|
||||
companionName, expertAvatar, track, serviceTitle,
|
||||
@@ -1206,9 +1359,9 @@ export class MysqlRepository {
|
||||
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, type, amount, direction, counterparty, related_id)
|
||||
VALUES (?, ?, ?, ?, 'IN', ?, ?)
|
||||
`, [finId, recordNo, payType === 'BALANCE' ? '服务预约订单付款 (钱包余额)' : '服务预约订单付款 (微信支付)', amount.toFixed(2), clientName, id]);
|
||||
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();
|
||||
|
||||
@@ -1257,6 +1410,66 @@ export class MysqlRepository {
|
||||
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);
|
||||
const buyerId = Number(order.buyer_id || 0);
|
||||
await conn.query('UPDATE orders SET status = "已退款", refund_amount = ? WHERE id = ?', [refundAmount, order.id]);
|
||||
if (buyerId > 0 && refundAmount > 0) {
|
||||
await conn.query('UPDATE users SET balance = COALESCE(balance, 0) + ? WHERE id = ?', [refundAmount, 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_cx_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`,
|
||||
`CX${Date.now()}${Math.floor(100 + Math.random() * 900)}`,
|
||||
buyerId,
|
||||
refundAmount.toFixed(2),
|
||||
`订单 ${order.order_no || order.id}`,
|
||||
String(order.id)
|
||||
]);
|
||||
}
|
||||
} 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,
|
||||
@@ -1265,7 +1478,7 @@ export class MysqlRepository {
|
||||
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.companion_name as companionName,
|
||||
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
|
||||
@@ -1285,6 +1498,7 @@ export class MysqlRepository {
|
||||
id: r.id,
|
||||
orderNo: r.orderNo,
|
||||
buyerId: Number(r.buyerId) || 0,
|
||||
amount: Number(r.amount) || 0,
|
||||
companionName: r.companionName || '',
|
||||
status: r.status,
|
||||
companionIds,
|
||||
@@ -1293,6 +1507,21 @@ export class MysqlRepository {
|
||||
|
||||
// 大神点击申请完成服务 (服务中 -> 待确认,进入 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]
|
||||
@@ -1311,8 +1540,12 @@ export class MysqlRepository {
|
||||
throw new Error('订单不存在');
|
||||
}
|
||||
const order = rows[0];
|
||||
if (['已完成', '已取消', '已退款', '已全额退款'].includes(order.status)) {
|
||||
throw new Error(`当前订单状态【${order.status}】不可拒单`);
|
||||
// 状态闸门:仅「待接单」可拒单,杜绝已进入履约/已完成的订单被拒单触发全额退款
|
||||
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}]` : ' [大神拒单自动退款]';
|
||||
@@ -1352,6 +1585,24 @@ export class MysqlRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析大神结算收款用户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 {
|
||||
@@ -1367,7 +1618,7 @@ export class MysqlRepository {
|
||||
|
||||
// 状态闸门:仅「已进入履约/待确认」等具备已完成服务语义的订单可结算;
|
||||
// 严禁对未支付、未接单、已取消、已退款的订单越权触发资金结算(防前端直接结算套现)。
|
||||
const settleBlocked = ['待支付', '待接单', '已取消', '已退款', '已全额退款'];
|
||||
const settleBlocked = ['待支付', '待付款', '待接单', '已取消', '已退款', '已全额退款'];
|
||||
if (settleBlocked.includes(String(order.status))) {
|
||||
const e: any = new Error(`订单当前状态【${order.status}】不可结算,已阻止越权触发资金结算`);
|
||||
e.status = 409;
|
||||
@@ -1405,10 +1656,10 @@ export class MysqlRepository {
|
||||
await conn.query('UPDATE companions SET order_count = COALESCE(order_count, 0) + 1 WHERE id = ?', [comp.id]);
|
||||
}
|
||||
|
||||
const expertUserId = Number(comp?.user_id || order.expert_id || 101);
|
||||
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 = ? OR nickname = ?',
|
||||
[expertIncome, expertUserId, order.companion_name || '']
|
||||
'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);
|
||||
@@ -1531,11 +1782,19 @@ export class MysqlRepository {
|
||||
amount, location, expense_burden as payMethod,
|
||||
status, created_at as createdAt
|
||||
FROM orders
|
||||
WHERE status NOT IN ('已完成', '已取消', '已关闭', '已退款', '已全额退款', '争议仲裁中', '已到场核销', '待确认')
|
||||
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 || '同城生活',
|
||||
@@ -1543,7 +1802,7 @@ export class MysqlRepository {
|
||||
location: r.location || '北京市朝阳区商圈',
|
||||
reward: Number(r.amount) || 120,
|
||||
timeSlot: '今日内随时可约',
|
||||
userName: r.clientName || '客户老板',
|
||||
userName: maskClientName(r.clientName),
|
||||
userAvatar: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=120',
|
||||
payMethod: r.payMethod || 'AA制各付',
|
||||
countdown: 45,
|
||||
@@ -1629,8 +1888,8 @@ export class MysqlRepository {
|
||||
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 = ? OR companion_name LIKE ?
|
||||
`, [Number(expertId) || 101, '%我%']);
|
||||
WHERE expert_id = ?
|
||||
`, [Number(expertId) || 101]);
|
||||
|
||||
const stat = rows[0] || {};
|
||||
return {
|
||||
@@ -1664,10 +1923,10 @@ export class MysqlRepository {
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR companion_name LIKE ?)
|
||||
WHERE expert_id = ?
|
||||
AND DATE(created_at) = ?
|
||||
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
|
||||
`, [eId, '%我%', dateStr]);
|
||||
`, [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;
|
||||
@@ -1689,10 +1948,10 @@ export class MysqlRepository {
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR companion_name LIKE ?)
|
||||
WHERE expert_id = ?
|
||||
AND created_at >= ? AND created_at <= ?
|
||||
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
|
||||
`, [eId, '%我%', startD, endD]);
|
||||
`, [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;
|
||||
@@ -1714,10 +1973,10 @@ export class MysqlRepository {
|
||||
COUNT(*) as orders,
|
||||
COALESCE(SUM(amount), 0) as income
|
||||
FROM orders
|
||||
WHERE (expert_id = ? OR companion_name LIKE ?)
|
||||
WHERE expert_id = ?
|
||||
AND DATE_FORMAT(created_at, '%Y-%m') = ?
|
||||
AND status IN ('已完成', '已到场核销', '服务中', '待确认')
|
||||
`, [eId, '%我%', yearMonth]);
|
||||
`, [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;
|
||||
@@ -2059,6 +2318,22 @@ export class MysqlRepository {
|
||||
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
|
||||
@@ -2154,6 +2429,30 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -2518,11 +2817,12 @@ export class MysqlRepository {
|
||||
// 剩余未退部分解冻结算给大神
|
||||
const remainAmt = Number(Math.max(0, totalOrderAmt - partialAmt).toFixed(2));
|
||||
if (remainAmt > 0) {
|
||||
const expertUserId = Number(order.expert_id || 101);
|
||||
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 = ? OR nickname = ?',
|
||||
[expertIncome, expertUserId, order.companion_name || '']
|
||||
'UPDATE users SET commission = COALESCE(commission, 0) + ? WHERE id = ?',
|
||||
[expertIncome, expertUserId]
|
||||
);
|
||||
const setId = `fin_pset_${Date.now()}`;
|
||||
const setNo = `PJS${Date.now()}`;
|
||||
@@ -2784,6 +3084,76 @@ export class MysqlRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取提现工单及收款人 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
|
||||
@@ -2989,7 +3359,9 @@ export class MysqlRepository {
|
||||
try {
|
||||
const [rRows]: any = await pool.query('SELECT * FROM reports WHERE status = "待处理" ORDER BY created_at DESC');
|
||||
rptRows = rRows || [];
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
logServerError('getDashboardTodos.reports', e);
|
||||
}
|
||||
|
||||
const pendingWithdrawalAmount = wdRows.reduce((sum: number, w: any) => sum + Number(w.amount), 0);
|
||||
|
||||
@@ -3135,23 +3507,26 @@ export class MysqlRepository {
|
||||
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 client_name = ? OR companion_name = ?
|
||||
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
|
||||
`, [user.nickname, user.nickname]);
|
||||
`, [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 counterparty LIKE ? OR counterparty LIKE ?
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
`, [`%${user.nickname}%`, `%${user.id}%`]);
|
||||
`, [id]);
|
||||
|
||||
// 风控日志
|
||||
const [riskLogs]: any = await pool.query(`
|
||||
@@ -3299,6 +3674,22 @@ export class MysqlRepository {
|
||||
|
||||
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,
|
||||
@@ -3346,8 +3737,17 @@ export class MysqlRepository {
|
||||
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(data.phone.trim());
|
||||
values.push(newPhone);
|
||||
}
|
||||
if (data.gender && ['男', '女', '保密'].includes(data.gender)) {
|
||||
fields.push('gender = ?');
|
||||
@@ -3535,6 +3935,24 @@ export class MysqlRepository {
|
||||
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]
|
||||
@@ -3684,17 +4102,17 @@ export class MysqlRepository {
|
||||
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, status, apply_time)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, '待审核', NOW())
|
||||
`, [wdId, wdNo, user.nickname, id, amount, remainingCommission, fullChannel]);
|
||||
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, type, amount, direction, counterparty, related_id, created_at)
|
||||
VALUES (?, ?, '佣金提现', ?, 'OUT', ?, ?, NOW())
|
||||
`, [recId, recNo, amount, `${user.nickname} (${fullChannel})`, wdId]);
|
||||
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 {
|
||||
@@ -3714,15 +4132,13 @@ export class MysqlRepository {
|
||||
}
|
||||
|
||||
async getUserFinanceRecords(userId: number): Promise<any[]> {
|
||||
const [uRows]: any = await pool.query('SELECT nickname FROM users WHERE id = ?', [userId]);
|
||||
const nickname = uRows[0]?.nickname || '';
|
||||
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 = ? OR counterparty LIKE ? OR counterparty LIKE ? OR related_id = ?
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`, [userId, `%${nickname}%`, `%${userId}%`, String(userId)]);
|
||||
`, [Number(userId)]);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -4649,13 +5065,6 @@ export class MysqlRepository {
|
||||
...u,
|
||||
totalContribute: contribMap.get(Number(u.id)) || '0.00'
|
||||
}));
|
||||
if (directList.length === 0) {
|
||||
directList = [
|
||||
{ id: 2001, nickname: '晨曦微风', avatar: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?w=120', phone: '138****6612', createdAt: '2026-10-01 14:20:00', level: 1, totalContribute: '48.50' },
|
||||
{ id: 2002, nickname: '峡谷追风少年', avatar: 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?w=120', phone: '139****8833', createdAt: '2026-10-03 19:10:00', level: 1, totalContribute: '36.00' },
|
||||
{ id: 2003, nickname: '软糖小鹿', avatar: 'https://images.unsplash.com/photo-1517841905240-472988babdf9?w=120', phone: '137****1199', createdAt: '2026-10-05 21:05:00', level: 1, totalContribute: '24.00' }
|
||||
];
|
||||
}
|
||||
|
||||
const directIds = directList.map((u: any) => u.id).filter(Boolean);
|
||||
let indirectList: any[] = [];
|
||||
@@ -4673,12 +5082,6 @@ export class MysqlRepository {
|
||||
}));
|
||||
} catch (_) {}
|
||||
}
|
||||
if (indirectList.length === 0) {
|
||||
indirectList = [
|
||||
{ id: 3001, nickname: '星空漫步者', avatar: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=120', phone: '150****5521', createdAt: '2026-10-06 11:30:00', level: 2, totalContribute: '12.00' },
|
||||
{ id: 3002, nickname: '吃饱就睡的喵', avatar: 'https://images.unsplash.com/photo-1524504388940-b1c1722653e1?w=120', phone: '151****9932', createdAt: '2026-10-08 16:45:00', level: 2, totalContribute: '8.00' }
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
directCount: directList.length,
|
||||
@@ -4722,11 +5125,8 @@ export class MysqlRepository {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return [
|
||||
{ id: 'dst_101', fromUserName: '晨曦微风', orderNo: 'PW20261001991', orderAmount: '99.00', commission: '4.95', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-01 15:30:00' },
|
||||
{ id: 'dst_102', fromUserName: '峡谷追风少年', orderNo: 'PW20261003882', orderAmount: '120.00', commission: '6.00', level: 1, ratePercent: '5.0', status: '已入账', createdAt: '2026-10-03 20:12:00' },
|
||||
{ id: 'dst_103', fromUserName: '星空漫步者', orderNo: 'PW20261006117', orderAmount: '150.00', commission: '3.00', level: 2, ratePercent: '2.0', status: '已入账', createdAt: '2026-10-06 12:40:00' }
|
||||
];
|
||||
// 无真实返佣记录时返回空数组,不再用硬编码假数据冒充实盘收益
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
文件差异内容过多而无法显示
加载差异
在新工单中引用
屏蔽一个用户