1924 行
94 KiB
PHP
1924 行
94 KiB
PHP
<?php
|
||
|
||
namespace app\common\library;
|
||
|
||
use think\Db;
|
||
use think\Env;
|
||
use think\Log;
|
||
|
||
/**
|
||
* 支付与提现转账业务编排服务
|
||
*
|
||
* 设计红线:
|
||
* 1. 严禁在数据库事务(Db::startTrans)内部发起任何外部微信 APIv3 / 登录 HTTP 调用
|
||
* 2. 所有入账、核销、退款、提现转账同步均基于数据库行级悲观锁(FOR UPDATE)实现严格幂等
|
||
* 3. 防双重资损:已发起微信商家转账的提现单,必须确认微信侧已 FAIL/CANCELLED 后方可解冻退回余额
|
||
*/
|
||
class PaymentService
|
||
{
|
||
/**
|
||
* 获取当前提现额度与费率配置(统一读取独立配置中心 FinanceConfigService)
|
||
*/
|
||
public static function getWithdrawLimits()
|
||
{
|
||
$cfg = FinanceConfigService::getWithdrawConfig();
|
||
$dailyLimit = (float)$cfg['daily_amount'] > 0 ? (float)$cfg['daily_amount'] : (float)$cfg['max_amount'];
|
||
return [
|
||
'min_amount' => (float)$cfg['min_amount'],
|
||
'single_limit' => (float)$cfg['max_amount'],
|
||
'daily_user_limit' => $dailyLimit,
|
||
'fee_mode' => $cfg['fee_mode'],
|
||
'fee_rate' => $cfg['fee_mode'] === 'percent' ? round((float)$cfg['fee_rate'] / 100, 6) : 0.0,
|
||
'fee_fixed' => (float)$cfg['fee_fixed'],
|
||
'fee_min' => (float)$cfg['fee_min'],
|
||
'fee_max' => (float)$cfg['fee_max'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 解析并严格校验当前操作用户的微信 OpenID(必须在数据库事务外部调用)
|
||
*
|
||
* 规则:一旦前端传入 wxCode,必须实时向微信换取当前操作微信号的 OpenID,
|
||
* 严禁在换取失败时静默回退旧数据库记录,防止张冠李戴或掩盖 AppID/Secret 配置错误。
|
||
*
|
||
* @param int $userId 当前登录用户 ID
|
||
* @param string $wxCode 小程序前端实时获取的 wx.login code
|
||
* @return string
|
||
*/
|
||
public static function resolveUserOpenid($userId, $wxCode = '')
|
||
{
|
||
$userId = (int)$userId;
|
||
$wxCode = trim((string)$wxCode);
|
||
|
||
// 1. 传入实时 wx_code 时,必须向微信官方换取并校验与当前账号的绑定关系
|
||
if ($wxCode !== '') {
|
||
$wx = WxLogin::jscode2session($wxCode);
|
||
if (!is_array($wx) || !empty($wx['error']) || empty($wx['openid'])) {
|
||
$errMsg = (is_array($wx) && !empty($wx['error']))
|
||
? (string)$wx['error']
|
||
: '获取微信用户身份标识(OpenID)失败,请在微信小程序内重新登录后再试';
|
||
Log::error("[PaymentService::resolveUserOpenid] jscode2session 失败 user_id={$userId}: {$errMsg}");
|
||
throw new \RuntimeException($errMsg);
|
||
}
|
||
|
||
$liveOpenid = trim((string)$wx['openid']);
|
||
$unionid = isset($wx['unionid']) ? trim((string)$wx['unionid']) : '';
|
||
$sessionKey = isset($wx['session_key']) ? trim((string)$wx['session_key']) : '';
|
||
$now = time();
|
||
|
||
$existByOpenid = Db::name('user_wechat')->where('openid', $liveOpenid)->find();
|
||
if ($existByOpenid) {
|
||
$boundUid = (int)$existByOpenid['user_id'];
|
||
if ($boundUid > 0 && $boundUid !== $userId) {
|
||
Log::error("[PaymentService::resolveUserOpenid] OpenID归属冲突 current_uid={$userId}, bound_uid={$boundUid}");
|
||
throw new \InvalidArgumentException('当前微信号已绑定平台其他账号(用户ID:' . $boundUid . '),请确认当前登录手机号与微信号一致后再操作');
|
||
}
|
||
Db::name('user_wechat')->where('id', (int)$existByOpenid['id'])->update([
|
||
'user_id' => $userId,
|
||
'unionid' => $unionid !== '' ? $unionid : (string)$existByOpenid['unionid'],
|
||
'session_key' => $sessionKey !== '' ? $sessionKey : (string)$existByOpenid['session_key'],
|
||
'updatetime' => $now,
|
||
]);
|
||
} else {
|
||
Db::name('user_wechat')->insert([
|
||
'user_id' => $userId,
|
||
'openid' => $liveOpenid,
|
||
'unionid' => $unionid,
|
||
'session_key' => $sessionKey,
|
||
'createtime' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
}
|
||
|
||
return $liveOpenid;
|
||
}
|
||
|
||
// 2. 未传 wx_code 时(如非小程序直发场景或历史绑定核对),读取当前账号唯一绑定记录
|
||
if ($userId > 0) {
|
||
$bind = Db::name('user_wechat')
|
||
->where('user_id', $userId)
|
||
->where('openid', '<>', '')
|
||
->order('updatetime desc, id desc')
|
||
->find();
|
||
if ($bind && !empty($bind['openid'])) {
|
||
return trim((string)$bind['openid']);
|
||
}
|
||
}
|
||
|
||
throw new \InvalidArgumentException('未获取到当前微信授权信息(OpenID 缺失),请在微信小程序内打开并登录后再操作');
|
||
}
|
||
|
||
/**
|
||
* 用户端统一发起微信 JSAPI 支付(覆盖 order 首单支付 / quote 选师傅报价支付 / diff 施工增项补差价支付)
|
||
*
|
||
* @param array $order 已通过归属校验的订单记录
|
||
* @param int $operatorUid 当前点击支付的用户 ID
|
||
* @param string $scene order | quote | diff
|
||
* @param int $quoteId 当 scene=quote 时必填的报价单 ID
|
||
* @param string $wxCode 小程序端 wx.login code
|
||
* @return array
|
||
*/
|
||
public static function createOrderJsapiPay(array $order, $operatorUid, $scene = 'order', $quoteId = 0, $wxCode = '')
|
||
{
|
||
$orderId = (int)$order['id'];
|
||
$operatorUid = (int)$operatorUid;
|
||
$quoteId = (int)$quoteId;
|
||
$now = time();
|
||
|
||
if (!in_array($scene, ['order', 'quote', 'diff'], true)) {
|
||
throw new \InvalidArgumentException('不支持的支付场景');
|
||
}
|
||
|
||
$payAmount = 0.00;
|
||
$description = '优匠优服-' . ($order['service_name'] ?: '上门安装服务');
|
||
$prefix = 'PO';
|
||
|
||
if ($scene === 'order') {
|
||
if ((int)$order['pay_status'] === 1) {
|
||
throw new \InvalidArgumentException('订单已支付,请勿重复支付');
|
||
}
|
||
if ((int)$order['status'] !== OrderStatus::PENDING_PAY) {
|
||
throw new \InvalidArgumentException('当前订单状态不允许支付');
|
||
}
|
||
$payAmount = round((float)$order['pay_amount'], 2);
|
||
|
||
// 优惠券全额抵扣 0 元免付直通
|
||
if ($payAmount <= 0) {
|
||
return self::completeZeroOrderPay($orderId, $operatorUid);
|
||
}
|
||
$prefix = 'PO';
|
||
} elseif ($scene === 'quote') {
|
||
if ($order['price_type'] !== 'quote') {
|
||
throw new \InvalidArgumentException('该订单不是师傅报价订单');
|
||
}
|
||
if ((int)$order['status'] !== OrderStatus::QUOTING) {
|
||
throw new \InvalidArgumentException('当前状态(' . OrderStatus::label((int)$order['status']) . ')不可选择报价');
|
||
}
|
||
if ((int)$order['master_id'] > 0) {
|
||
throw new \InvalidArgumentException('该订单已选定师傅,请勿重复选择');
|
||
}
|
||
$quote = Db::name('order_quote')->where('id', $quoteId)->where('order_id', $orderId)->find();
|
||
if (!$quote) {
|
||
throw new \InvalidArgumentException('报价记录不存在');
|
||
}
|
||
if ($quote['status'] !== 'wait') {
|
||
throw new \InvalidArgumentException('该报价已失效,请选择其他师傅');
|
||
}
|
||
if ((int)$quote['expire_time'] > 0 && (int)$quote['expire_time'] < $now) {
|
||
Db::name('order_quote')->where('id', $quoteId)->update(['status' => 'expired']);
|
||
throw new \InvalidArgumentException('该报价已过期,请选择其他师傅');
|
||
}
|
||
$master = Db::name('master')->where('id', (int)$quote['master_id'])->find();
|
||
if (!$master) {
|
||
throw new \InvalidArgumentException('报价师傅信息不存在');
|
||
}
|
||
|
||
$quoteAmount = round((float)$quote['amount'], 2);
|
||
$targetTotalPay = max(0, round($quoteAmount - (float)$order['discount_amount'], 2));
|
||
$alreadyPaid = (int)$order['pay_status'] === 1 ? (float)$order['pay_amount'] : 0.00;
|
||
$payAmount = max(0, round($targetTotalPay - $alreadyPaid, 2));
|
||
|
||
// 无需补付(0元或已付足)直接完成选定师傅
|
||
if ($payAmount <= 0) {
|
||
return self::completeZeroQuoteChoose($orderId, $quoteId, $operatorUid);
|
||
}
|
||
$description = '优匠优服-选定师傅报价(' . $order['order_no'] . ')';
|
||
$prefix = 'PQ';
|
||
} elseif ($scene === 'diff') {
|
||
if ((int)$order['diff_status'] !== 1 || (float)$order['diff_amount'] <= 0) {
|
||
throw new \InvalidArgumentException('当前订单无待支付的增项补差价');
|
||
}
|
||
$payAmount = round((float)$order['diff_amount'], 2);
|
||
$description = '优匠优服-施工增项补差价(' . $order['order_no'] . ')';
|
||
$prefix = 'PD';
|
||
}
|
||
|
||
// 1. 事务外解析并严格校验付款人微信 OpenID
|
||
$openid = self::resolveUserOpenid($operatorUid, $wxCode);
|
||
$wxSvc = new WxPayV3Service();
|
||
|
||
// 2. 先核查同订单同场景历史未决待支付单,防漏回调重付或遗留未关旧单
|
||
$pendingOlds = Db::name('pay_order')
|
||
->where('order_id', $orderId)
|
||
->where('scene', $scene)
|
||
->where('status', 'pending')
|
||
->order('id desc')
|
||
->limit(3)
|
||
->select();
|
||
foreach ($pendingOlds as $oldPo) {
|
||
$oldOutNo = trim((string)$oldPo['out_trade_no']);
|
||
if ($oldOutNo === '') {
|
||
continue;
|
||
}
|
||
try {
|
||
$oldQuery = $wxSvc->queryOrderByOutTradeNo($oldOutNo);
|
||
$oldState = strtoupper((string)($oldQuery['trade_state'] ?? ''));
|
||
if ($oldState === 'SUCCESS') {
|
||
// 发现该笔旧单在微信侧实际已扣款成功,立即执行幂等核销并返回已支付结果,杜绝重复扣款
|
||
$confirmed = self::confirmOrderPay($oldOutNo, $oldQuery);
|
||
return array_merge($confirmed, [
|
||
'need_wxpay' => false,
|
||
'paid' => true,
|
||
'out_trade_no' => $oldOutNo,
|
||
'amount' => (float)$oldPo['amount'],
|
||
]);
|
||
}
|
||
if (in_array($oldState, ['NOTPAY', 'USERPAYING'], true)) {
|
||
$wxSvc->closeOrder($oldOutNo);
|
||
}
|
||
Db::name('pay_order')->where('id', (int)$oldPo['id'])->update([
|
||
'status' => 'closed',
|
||
'fail_code' => 'REPLACED_CLOSE',
|
||
'fail_reason' => '用户重新发起支付(原状态:' . self::tradeStateLabel($oldState) . '),系统已关闭旧待支付单',
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
} catch (\Throwable $oldEx) {
|
||
$oldFail = WxPayV3Service::extractFailInfo($oldEx);
|
||
if (in_array($oldFail['code'], ['ORDERNOTEXIST', 'ORDER_NOT_EXIST', 'ORDERCLOSED'], true)) {
|
||
Db::name('pay_order')->where('id', (int)$oldPo['id'])->update([
|
||
'status' => 'closed',
|
||
'fail_code' => $oldFail['code'],
|
||
'fail_reason' => '旧待支付单已失效或未在微信生成(' . $oldFail['reason'] . ')',
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
continue;
|
||
}
|
||
Db::name('pay_order')->where('id', (int)$oldPo['id'])->update([
|
||
'fail_code' => $oldFail['code'],
|
||
'fail_reason' => '重新下单前核查/关闭旧待支付单失败:' . $oldFail['reason'],
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
self::addOrderLog($orderId, (int)$order['status'], '核查旧待支付单(' . $oldOutNo . ')失败:' . $oldFail['reason'], 'system', $operatorUid);
|
||
throw $oldEx;
|
||
}
|
||
}
|
||
|
||
// 3. 创建本地待支付交易流水单
|
||
$outTradeNo = $prefix . date('YmdHis', $now) . str_pad((string)mt_rand(0, 999999), 6, '0', STR_PAD_LEFT);
|
||
$payOrderId = (int)Db::name('pay_order')->insertGetId([
|
||
'out_trade_no' => $outTradeNo,
|
||
'order_id' => $orderId,
|
||
'order_no' => (string)$order['order_no'],
|
||
'user_id' => $operatorUid,
|
||
'scene' => $scene,
|
||
'quote_id' => $quoteId,
|
||
'openid' => $openid,
|
||
'amount' => $payAmount,
|
||
'status' => 'pending',
|
||
'createtime' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
|
||
// 4. 事务外调用微信支付 APIv3 JSAPI 统一下单,任何失败均持久化写入管理员可读中文原因
|
||
try {
|
||
$wxResp = $wxSvc->createJsapiOrder([
|
||
'out_trade_no' => $outTradeNo,
|
||
'description' => $description,
|
||
'amount' => $payAmount,
|
||
'openid' => $openid,
|
||
'attach' => json_encode([
|
||
'scene' => $scene,
|
||
'order_id' => $orderId,
|
||
'quote_id' => $quoteId,
|
||
], JSON_UNESCAPED_UNICODE),
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
$fail = WxPayV3Service::extractFailInfo($e);
|
||
$errNow = time();
|
||
Db::name('pay_order')->where('id', $payOrderId)->update([
|
||
'status' => 'pay_failed',
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => $fail['reason'],
|
||
'last_sync_time' => $errNow,
|
||
'updatetime' => $errNow,
|
||
]);
|
||
self::addOrderLog($orderId, (int)$order['status'], '发起微信统一下单失败(' . $outTradeNo . '):' . $fail['reason'], 'system', $operatorUid);
|
||
Log::error("[PaymentService::createOrderJsapiPay] 统一下单失败 order_id={$orderId}, out_trade_no={$outTradeNo}, code={$fail['code']}, reason={$fail['reason']}");
|
||
throw $e;
|
||
}
|
||
|
||
Db::name('pay_order')->where('id', $payOrderId)->update([
|
||
'prepay_id' => (string)$wxResp['prepay_id'],
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
|
||
return [
|
||
'need_wxpay' => true,
|
||
'paid' => false,
|
||
'scene' => $scene,
|
||
'order_id' => $orderId,
|
||
'out_trade_no' => $outTradeNo,
|
||
'amount' => $payAmount,
|
||
'pay_params' => $wxResp['pay_params'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 确认并核销微信支付单(支持微信异步回调与小程序端支付完成后主动查单双重触发,100% 幂等)
|
||
*
|
||
* @param string $outTradeNo 商户支付单号
|
||
* @param array|null $wxTxData 异步回调已解密的交易数据;传 null 时自动向微信网关主动查单
|
||
* @return array
|
||
*/
|
||
public static function confirmOrderPay($outTradeNo, ?array $wxTxData = null)
|
||
{
|
||
$outTradeNo = trim((string)$outTradeNo);
|
||
if ($outTradeNo === '') {
|
||
throw new \InvalidArgumentException('缺少支付单号 out_trade_no');
|
||
}
|
||
|
||
$payOrder = Db::name('pay_order')->where('out_trade_no', $outTradeNo)->find();
|
||
if (!$payOrder) {
|
||
throw new \InvalidArgumentException('支付交易单不存在');
|
||
}
|
||
|
||
// 快速幂等返回
|
||
if (in_array($payOrder['status'], ['paid', 'refunding', 'refunded', 'refund_failed'], true)) {
|
||
return [
|
||
'paid' => true,
|
||
'already_processed' => true,
|
||
'order_id' => (int)$payOrder['order_id'],
|
||
'scene' => (string)$payOrder['scene'],
|
||
];
|
||
}
|
||
|
||
$wxSvc = new WxPayV3Service();
|
||
|
||
// 若未传入已验签解密的回调报文,则在事务外向微信支付网关主动查单核实
|
||
if ($wxTxData === null) {
|
||
try {
|
||
$wxTxData = $wxSvc->queryOrderByOutTradeNo($outTradeNo);
|
||
} catch (\Throwable $qe) {
|
||
$fail = WxPayV3Service::extractFailInfo($qe);
|
||
$errNow = time();
|
||
$upd = [
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => '向微信主动查询支付状态失败:' . $fail['reason'],
|
||
'last_sync_time' => $errNow,
|
||
'updatetime' => $errNow,
|
||
];
|
||
if (in_array($fail['code'], ['ORDERNOTEXIST', 'ORDER_NOT_EXIST'], true) && $payOrder['status'] === 'pending') {
|
||
$upd['status'] = 'closed';
|
||
}
|
||
Db::name('pay_order')->where('id', (int)$payOrder['id'])->update($upd);
|
||
Log::error("[PaymentService::confirmOrderPay] 查单失败 out_trade_no={$outTradeNo}, code={$fail['code']}, reason={$fail['reason']}");
|
||
throw $qe;
|
||
}
|
||
}
|
||
|
||
// 严格核对报文中 AppID / MchID 是否属于本系统配置,防止回调或应答串单
|
||
$respMchId = trim((string)($wxTxData['mchid'] ?? ''));
|
||
$respAppId = trim((string)($wxTxData['appid'] ?? ''));
|
||
if ($respMchId !== '' && $respMchId !== $wxSvc->getMchId()) {
|
||
$msg = '微信返回商户号(' . $respMchId . ')与本系统配置(' . $wxSvc->getMchId() . ')不一致,系统已拦截入账';
|
||
Db::name('pay_order')->where('id', (int)$payOrder['id'])->update([
|
||
'fail_code' => 'MCHID_MISMATCH',
|
||
'fail_reason' => $msg,
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
throw new \RuntimeException($msg);
|
||
}
|
||
if ($respAppId !== '' && $respAppId !== $wxSvc->getAppId()) {
|
||
$msg = '微信返回 AppID(' . $respAppId . ')与本系统配置(' . $wxSvc->getAppId() . ')不一致,系统已拦截入账';
|
||
Db::name('pay_order')->where('id', (int)$payOrder['id'])->update([
|
||
'fail_code' => 'APPID_MISMATCH',
|
||
'fail_reason' => $msg,
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
throw new \RuntimeException($msg);
|
||
}
|
||
|
||
$tradeState = strtoupper((string)($wxTxData['trade_state'] ?? ''));
|
||
$tradeStateDesc = trim((string)($wxTxData['trade_state_desc'] ?? ''));
|
||
$tradeStateZh = self::tradeStateLabel($tradeState, $tradeStateDesc);
|
||
|
||
if ($tradeState !== 'SUCCESS') {
|
||
$syncNow = time();
|
||
$upd = [
|
||
'last_sync_time' => $syncNow,
|
||
'updatetime' => $syncNow,
|
||
];
|
||
if ($tradeState === 'PAYERROR') {
|
||
$upd['status'] = 'pay_failed';
|
||
$upd['fail_code'] = 'PAYERROR';
|
||
$upd['fail_reason'] = $tradeStateZh;
|
||
} elseif (in_array($tradeState, ['CLOSED', 'REVOKED'], true)) {
|
||
$upd['status'] = 'closed';
|
||
$upd['fail_code'] = $tradeState;
|
||
$upd['fail_reason'] = $tradeStateZh;
|
||
} else {
|
||
$upd['fail_code'] = $tradeState ?: 'NOTPAY';
|
||
$upd['fail_reason'] = $tradeStateZh;
|
||
}
|
||
Db::name('pay_order')->where('id', (int)$payOrder['id'])->where('status', 'pending')->update($upd);
|
||
|
||
return [
|
||
'paid' => false,
|
||
'trade_state' => $tradeState ?: 'NOTPAY',
|
||
'trade_state_text' => $tradeStateZh,
|
||
'order_id' => (int)$payOrder['order_id'],
|
||
'scene' => (string)$payOrder['scene'],
|
||
];
|
||
}
|
||
|
||
// 严格核对实付分值金额,防御金额不一致风险
|
||
$paidFen = (int)($wxTxData['amount']['total'] ?? 0);
|
||
$expectFen = (int)round(((float)$payOrder['amount']) * 100);
|
||
if ($paidFen !== $expectFen) {
|
||
$mismatchMsg = '支付金额校验异常(应付 ' . $expectFen . ' 分,微信实付 ' . $paidFen . ' 分),系统已拦截入账,请联系财务核查';
|
||
Db::name('pay_order')->where('id', (int)$payOrder['id'])->update([
|
||
'fail_code' => 'AMOUNT_MISMATCH',
|
||
'fail_reason' => $mismatchMsg,
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
self::addOrderLog((int)$payOrder['order_id'], 0, '微信支付金额校验失败(' . $outTradeNo . '):' . $mismatchMsg, 'system', (int)$payOrder['user_id']);
|
||
Log::error("[PaymentService::confirmOrderPay] 金额校验异常 out_trade_no={$outTradeNo}, expect={$expectFen}, actual={$paidFen}");
|
||
throw new \RuntimeException($mismatchMsg);
|
||
}
|
||
|
||
$transactionId = trim((string)($wxTxData['transaction_id'] ?? ''));
|
||
$payTime = !empty($wxTxData['success_time']) ? (strtotime((string)$wxTxData['success_time']) ?: time()) : time();
|
||
$now = time();
|
||
|
||
$broadcastOrder = null;
|
||
$broadcastStatus = 0;
|
||
$autoRefundDuplicate = false;
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$lockedPay = Db::name('pay_order')->where('id', (int)$payOrder['id'])->lock(true)->find();
|
||
if (!$lockedPay || in_array($lockedPay['status'], ['paid', 'refunding', 'refunded', 'refund_failed'], true)) {
|
||
Db::commit();
|
||
return [
|
||
'paid' => true,
|
||
'already_processed' => true,
|
||
'order_id' => (int)$payOrder['order_id'],
|
||
'scene' => (string)$payOrder['scene'],
|
||
];
|
||
}
|
||
|
||
$orderId = (int)$lockedPay['order_id'];
|
||
$lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find();
|
||
if (!$lockedOrder) {
|
||
throw new \RuntimeException('关联业务工单不存在');
|
||
}
|
||
|
||
Db::name('pay_order')->where('id', (int)$lockedPay['id'])->update([
|
||
'status' => 'paid',
|
||
'transaction_id' => $transactionId,
|
||
'fail_code' => '',
|
||
'fail_reason' => '',
|
||
'pay_time' => $payTime,
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
|
||
$scene = (string)$lockedPay['scene'];
|
||
$uid = (int)$lockedPay['user_id'];
|
||
|
||
if ($scene === 'order') {
|
||
if ((int)$lockedOrder['pay_status'] === 1 || (int)$lockedOrder['status'] !== OrderStatus::PENDING_PAY) {
|
||
// 极端重复支付单:标记在事务外自动原路退回重复的一笔
|
||
$autoRefundDuplicate = true;
|
||
} else {
|
||
self::lockAndConsumeOrderCoupon($lockedOrder, $now);
|
||
|
||
$nextStatus = $lockedOrder['price_type'] === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_ACCEPT;
|
||
$nextRemark = $nextStatus === OrderStatus::QUOTING
|
||
? '微信支付成功,订单已进入报价大厅,等待师傅报价'
|
||
: '微信支付成功,订单已进入接单大厅';
|
||
|
||
Db::name('order')->where('id', $orderId)->update([
|
||
'status' => $nextStatus,
|
||
'pay_status' => 1,
|
||
'pay_type' => 'wechat',
|
||
'pay_time' => $payTime,
|
||
'transaction_id' => $transactionId,
|
||
'updatetime' => $now,
|
||
]);
|
||
self::addOrderLog($orderId, $nextStatus, $nextRemark, 'user', $uid);
|
||
self::addOrderLog($orderId, $nextStatus, $nextStatus === OrderStatus::QUOTING ? '订单进入师傅报价大厅' : '订单进入师傅接单大厅', 'system', 0);
|
||
|
||
$broadcastOrder = $lockedOrder;
|
||
$broadcastStatus = $nextStatus;
|
||
}
|
||
} elseif ($scene === 'quote') {
|
||
$quoteId = (int)$lockedPay['quote_id'];
|
||
$quote = Db::name('order_quote')->where('id', $quoteId)->where('order_id', $orderId)->lock(true)->find();
|
||
$master = $quote ? Db::name('master')->where('id', (int)$quote['master_id'])->find() : null;
|
||
|
||
if (
|
||
!$quote
|
||
|| !$master
|
||
|| (int)$lockedOrder['status'] !== OrderStatus::QUOTING
|
||
|| (int)$lockedOrder['master_id'] !== 0
|
||
) {
|
||
$autoRefundDuplicate = true;
|
||
} else {
|
||
$quoteAmount = round((float)$quote['amount'], 2);
|
||
$finalPayAmt = max(0, round($quoteAmount - (float)$lockedOrder['discount_amount'], 2));
|
||
$paidAmt = round((float)$lockedPay['amount'], 2);
|
||
$snapRefreshed = CommissionService::refreshQuoteOrderSnapshotOnAmountConfirmed(
|
||
$finalPayAmt > 0 ? $finalPayAmt : $quoteAmount,
|
||
$lockedOrder['commission_snapshot'] ?? null,
|
||
$lockedOrder['commission_rate'] ?? null
|
||
);
|
||
|
||
$remark = '客户已选择 ' . $master['realname'] . ' 的报价 ¥' . $quoteAmount . '(微信支付 ¥' . $paidAmt . '),等待师傅联系';
|
||
|
||
$quoteUpdateData = [
|
||
'status' => OrderStatus::ACCEPTED,
|
||
'master_id' => (int)$quote['master_id'],
|
||
'accept_time' => $payTime,
|
||
'base_amount' => $quoteAmount,
|
||
'floor_amount' => 0,
|
||
'urgent_amount' => 0,
|
||
'total_amount' => $quoteAmount,
|
||
'pay_amount' => $finalPayAmt,
|
||
'commission_rate' => $snapRefreshed['commission_rate'],
|
||
'pay_status' => 1,
|
||
'pay_type' => 'wechat',
|
||
'pay_time' => $payTime,
|
||
'transaction_id' => $transactionId,
|
||
'updatetime' => $now,
|
||
];
|
||
if ($snapRefreshed['commission_snapshot'] !== null) {
|
||
$quoteUpdateData['commission_snapshot'] = $snapRefreshed['commission_snapshot'];
|
||
}
|
||
Db::name('order')->where('id', $orderId)->update($quoteUpdateData);
|
||
self::addOrderLog($orderId, OrderStatus::ACCEPTED, $remark, 'user', $uid);
|
||
|
||
Db::name('order_quote')->where('id', $quoteId)->update(['status' => 'accept']);
|
||
Db::name('order_quote')->where('order_id', $orderId)->where('id', '<>', $quoteId)
|
||
->where('status', 'wait')->update(['status' => 'reject']);
|
||
|
||
self::sendNotify('user', (int)$lockedOrder['user_id'], '已选定服务师傅', $remark, 'order', $orderId);
|
||
self::sendNotify('master', (int)$master['id'], '您的报价已被选中', '订单 ' . $lockedOrder['order_no'] . ' 客户已选择您的报价 ¥' . $quoteAmount . ' 并完成支付托管,请尽快联系客户', 'order', $orderId);
|
||
}
|
||
} elseif ($scene === 'diff') {
|
||
if ((int)$lockedOrder['diff_status'] !== 1) {
|
||
$autoRefundDuplicate = true;
|
||
} else {
|
||
$diffAmount = round((float)$lockedPay['amount'], 2);
|
||
$newPayAmount = round((float)$lockedOrder['pay_amount'] + $diffAmount, 2);
|
||
|
||
Db::name('order')->where('id', $orderId)->update([
|
||
'diff_status' => 2,
|
||
'pay_amount' => $newPayAmount,
|
||
'updatetime' => $now,
|
||
]);
|
||
self::addOrderLog($orderId, (int)$lockedOrder['status'], '用户微信支付施工增项补差价 ¥' . $diffAmount, 'user', $uid);
|
||
|
||
if ((int)$lockedOrder['master_id'] > 0) {
|
||
self::sendNotify('master', (int)$lockedOrder['master_id'], '客户已支付增项补差价', '工单 ' . $lockedOrder['order_no'] . ' 客户已微信支付增项补差价 ¥' . $diffAmount, 'order', $orderId);
|
||
}
|
||
}
|
||
}
|
||
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
|
||
// 事务外:若检测到极端并发导致的重复付款单,立即自动原路退回该单;如自动退款失败必须持久落库并告警
|
||
$duplicateRefundError = '';
|
||
if ($autoRefundDuplicate) {
|
||
try {
|
||
self::refundSinglePayOrder((int)$payOrder['id'], '重复支付自动原路退回');
|
||
} catch (\Throwable $re) {
|
||
$fail = WxPayV3Service::extractFailInfo($re);
|
||
$duplicateRefundError = '检测到重复支付单(' . $outTradeNo . '),但自动原路退款失败:' . $fail['reason'];
|
||
Db::name('pay_order')->where('id', (int)$payOrder['id'])->update([
|
||
'status' => 'refund_failed',
|
||
'refund_status' => 'abnormal',
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => mb_substr($duplicateRefundError, 0, 480),
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
self::addOrderLog((int)$payOrder['order_id'], 0, '【高危财务异常】' . $duplicateRefundError, 'system', (int)$payOrder['user_id']);
|
||
Log::error('[PaymentService::confirmOrderPay] ' . $duplicateRefundError);
|
||
}
|
||
}
|
||
|
||
// 事务外:新工单广播给在线认证师傅
|
||
if ($broadcastOrder && in_array($broadcastStatus, [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true)) {
|
||
self::broadcastNewOrderToMasters($broadcastOrder);
|
||
}
|
||
|
||
return [
|
||
'paid' => true,
|
||
'already_processed' => false,
|
||
'order_id' => (int)$payOrder['order_id'],
|
||
'scene' => (string)$payOrder['scene'],
|
||
'transaction_id' => $transactionId,
|
||
'duplicate_refund_error' => $duplicateRefundError,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 取消订单时将该订单名下所有已支付的真实微信交易流水逐笔原路退回(必须在事务外部调用)
|
||
*
|
||
* @param int $orderId 业务订单 ID
|
||
* @param string $reason 退款原因
|
||
* @return float 实际触发或已完成的微信退款总额
|
||
*/
|
||
public static function refundOrder($orderId, $reason = '订单取消原路退款')
|
||
{
|
||
$orderId = (int)$orderId;
|
||
$order = Db::name('order')->where('id', $orderId)->find();
|
||
if (!$order) {
|
||
throw new \InvalidArgumentException('业务订单不存在,无法核对退款流水');
|
||
}
|
||
|
||
$allPayOrders = Db::name('pay_order')
|
||
->where('order_id', $orderId)
|
||
->order('id asc')
|
||
->select();
|
||
|
||
// 统计有效已付款流水(已支付、退款中、退款失败待重退、已退款)
|
||
$effectiveRows = [];
|
||
foreach ($allPayOrders as $po) {
|
||
if (in_array($po['status'], ['paid', 'refunding', 'refunded', 'refund_failed'], true)) {
|
||
$effectiveRows[] = $po;
|
||
}
|
||
}
|
||
|
||
$orderPayStatus = (int)$order['pay_status'];
|
||
$orderPayAmount = round((float)$order['pay_amount'], 2);
|
||
$orderPayType = trim((string)$order['pay_type']);
|
||
$orderTxId = trim((string)$order['transaction_id']);
|
||
|
||
if (empty($effectiveRows)) {
|
||
if ($orderPayStatus === 1 && $orderPayAmount > 0) {
|
||
$isNonWxChannel = ($orderPayType === 'mock' || $orderPayType === 'monthly'
|
||
|| strpos($orderTxId, 'MOCK') === 0 || strpos($orderTxId, 'MONTHLY') === 0);
|
||
if (!$isNonWxChannel) {
|
||
$errMsg = '订单状态为已支付(支付渠道:' . ($orderPayType ?: '未记录') . ',实付 ¥' . $orderPayAmount . '),但在支付流水表 fa_pay_order 中未查询到有效的微信支付交易单,为严防未向微信退款导致资金未原路退回,系统已拦截本次自动退款,请管理员先核查微信商户平台交易记录';
|
||
self::addOrderLog($orderId, (int)$order['status'], '【拦截假退款】' . $errMsg, 'system', 0);
|
||
Log::error("[PaymentService::refundOrder] 缺少微信支付流水拦截退款 order_id={$orderId}, pay_type={$orderPayType}");
|
||
throw new \RuntimeException($errMsg);
|
||
}
|
||
}
|
||
return 0.00;
|
||
}
|
||
|
||
$totalRefunded = 0.00;
|
||
foreach ($effectiveRows as $po) {
|
||
if ($po['status'] === 'refunded') {
|
||
$totalRefunded = round($totalRefunded + (float)$po['refund_amount'], 2);
|
||
continue;
|
||
}
|
||
if ($po['status'] === 'refunding') {
|
||
$synced = self::syncRefundPayOrder((int)$po['id']);
|
||
if ($synced['status'] === 'refund_failed') {
|
||
throw new \RuntimeException('历史退款单(' . $synced['refund_no'] . ')微信侧退款异常:' . $synced['fail_reason']);
|
||
}
|
||
$totalRefunded = round($totalRefunded + (float)$synced['amount'], 2);
|
||
continue;
|
||
}
|
||
// paid 或 refund_failed 均发起/重试原路退款
|
||
$after = self::refundSinglePayOrder((int)$po['id'], $reason);
|
||
$totalRefunded = round($totalRefunded + (float)($after['refund_amount'] ?: $po['amount']), 2);
|
||
}
|
||
|
||
return $totalRefunded;
|
||
}
|
||
|
||
/**
|
||
* 对单笔 fa_pay_order 执行微信 V3 原路退款(事务外远程调用 + 状态/失败原因持久落库)
|
||
*
|
||
* @return array 更新后的 pay_order 记录
|
||
*/
|
||
public static function refundSinglePayOrder($payOrderId, $reason = '订单取消原路退款')
|
||
{
|
||
$po = Db::name('pay_order')->where('id', (int)$payOrderId)->find();
|
||
if (!$po) {
|
||
throw new \InvalidArgumentException('支付交易单不存在');
|
||
}
|
||
if ($po['status'] === 'refunded') {
|
||
return $po;
|
||
}
|
||
if ($po['status'] === 'refunding') {
|
||
return self::syncRefundPayOrder((int)$po['id']);
|
||
}
|
||
if (!in_array($po['status'], ['paid', 'refund_failed'], true)) {
|
||
throw new \InvalidArgumentException('当前支付单状态(' . self::payOrderStatusLabel($po['status']) . ')不允许发起退款');
|
||
}
|
||
|
||
$amount = round((float)$po['amount'], 2);
|
||
if ($amount <= 0) {
|
||
throw new \InvalidArgumentException('退款金额必须大于 0');
|
||
}
|
||
|
||
$wxSvc = new WxPayV3Service();
|
||
$refundNo = trim((string)$po['refund_no']);
|
||
$now = time();
|
||
|
||
// 若上一笔单据微信侧已返回明确终态异常/关闭(abnormal/closed),或此前退款失败,先核实微信侧旧单状态再决定复用或换号重退
|
||
if ($refundNo !== '' && in_array($po['refund_status'], ['abnormal', 'closed'], true)) {
|
||
$suffixPos = strrpos($refundNo, 'R');
|
||
$nextVer = 1;
|
||
$baseNo = $refundNo;
|
||
if ($suffixPos !== false && $suffixPos > 14) {
|
||
$tail = substr($refundNo, $suffixPos + 1);
|
||
if (ctype_digit($tail)) {
|
||
$nextVer = (int)$tail + 1;
|
||
$baseNo = substr($refundNo, 0, $suffixPos);
|
||
}
|
||
}
|
||
$refundNo = substr($baseNo, 0, 34) . 'R' . $nextVer;
|
||
Db::name('pay_order')->where('id', (int)$po['id'])->update([
|
||
'refund_no' => $refundNo,
|
||
'updatetime' => $now,
|
||
]);
|
||
$po['refund_no'] = $refundNo;
|
||
} elseif ($refundNo === '') {
|
||
$refundNo = 'RF' . date('YmdHis', $now) . str_pad((string)$po['id'], 8, '0', STR_PAD_LEFT);
|
||
Db::name('pay_order')->where('id', (int)$po['id'])->where('refund_no', '')->update([
|
||
'refund_no' => $refundNo,
|
||
'updatetime' => $now,
|
||
]);
|
||
$po = Db::name('pay_order')->where('id', (int)$po['id'])->find();
|
||
$refundNo = (string)$po['refund_no'];
|
||
}
|
||
|
||
try {
|
||
$resp = $wxSvc->refund([
|
||
'out_trade_no' => (string)$po['out_trade_no'],
|
||
'out_refund_no' => $refundNo,
|
||
'refund_amount' => $amount,
|
||
'total_amount' => $amount,
|
||
'reason' => $reason,
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
$fail = WxPayV3Service::extractFailInfo($e);
|
||
$errNow = time();
|
||
Db::name('pay_order')->where('id', (int)$po['id'])->update([
|
||
'status' => 'refund_failed',
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => '向微信申请原路退款失败:' . $fail['reason'],
|
||
'last_sync_time' => $errNow,
|
||
'updatetime' => $errNow,
|
||
]);
|
||
self::addOrderLog((int)$po['order_id'], 0, '申请微信原路退款失败(支付单 ' . $po['out_trade_no'] . '):' . $fail['reason'], 'system', (int)$po['user_id']);
|
||
Log::error("[PaymentService::refundSinglePayOrder] 退款调用失败 out_trade_no={$po['out_trade_no']}, refund_no={$refundNo}, code={$fail['code']}, reason={$fail['reason']}");
|
||
throw $e;
|
||
}
|
||
|
||
return self::applyWxRefundResult($po, $resp, $amount);
|
||
}
|
||
|
||
/**
|
||
* 主动查询并同步单笔支付单的微信退款状态(后台点击同步或退款处理中复核)
|
||
*/
|
||
public static function syncRefundPayOrder($payOrderId)
|
||
{
|
||
$po = Db::name('pay_order')->where('id', (int)$payOrderId)->find();
|
||
if (!$po) {
|
||
throw new \InvalidArgumentException('支付交易单不存在');
|
||
}
|
||
$refundNo = trim((string)$po['refund_no']);
|
||
if ($refundNo === '') {
|
||
throw new \InvalidArgumentException('该支付单尚未生成退款单号,无需同步退款状态');
|
||
}
|
||
|
||
$wxSvc = new WxPayV3Service();
|
||
try {
|
||
$resp = $wxSvc->queryRefundByOutRefundNo($refundNo);
|
||
} catch (\Throwable $e) {
|
||
$fail = WxPayV3Service::extractFailInfo($e);
|
||
Db::name('pay_order')->where('id', (int)$po['id'])->update([
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => '同步微信退款状态失败:' . $fail['reason'],
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
throw $e;
|
||
}
|
||
|
||
return self::applyWxRefundResult($po, $resp, round((float)$po['amount'], 2));
|
||
}
|
||
|
||
/**
|
||
* 统一应用微信退款查单 / 申请 / 异步回调结果(准确区分 SUCCESS / PROCESSING / ABNORMAL / CLOSED)
|
||
*/
|
||
public static function applyWxRefundResult(array $po, array $wxRefundData, $fallbackAmount = 0.00)
|
||
{
|
||
$wxStatus = strtoupper(trim((string)($wxRefundData['status'] ?? $wxRefundData['refund_status'] ?? '')));
|
||
if ($wxStatus === '') {
|
||
throw new \RuntimeException('微信退款报文未返回有效的退款状态字段');
|
||
}
|
||
|
||
$refundId = trim((string)($wxRefundData['refund_id'] ?? $po['refund_id']));
|
||
$now = time();
|
||
$amount = round((float)$po['amount'], 2) ?: round((float)$fallbackAmount, 2);
|
||
if (isset($wxRefundData['amount']['refund']) && (int)$wxRefundData['amount']['refund'] > 0) {
|
||
$amount = round(((int)$wxRefundData['amount']['refund']) / 100, 2);
|
||
}
|
||
|
||
if ($wxStatus === 'SUCCESS') {
|
||
$succTime = !empty($wxRefundData['success_time'])
|
||
? (strtotime((string)$wxRefundData['success_time']) ?: $now)
|
||
: $now;
|
||
Db::name('pay_order')->where('id', (int)$po['id'])->update([
|
||
'status' => 'refunded',
|
||
'refund_status' => 'success',
|
||
'refund_id' => $refundId,
|
||
'refund_amount' => $amount,
|
||
'refund_time' => $succTime,
|
||
'fail_code' => '',
|
||
'fail_reason' => '',
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
self::addOrderLog((int)$po['order_id'], 0, '微信原路退款已到账(退款单 ' . $po['refund_no'] . ',金额 ¥' . $amount . ')', 'system', 0);
|
||
return Db::name('pay_order')->where('id', (int)$po['id'])->find();
|
||
}
|
||
|
||
if ($wxStatus === 'PROCESSING') {
|
||
Db::name('pay_order')->where('id', (int)$po['id'])->update([
|
||
'status' => 'refunding',
|
||
'refund_status' => 'processing',
|
||
'refund_id' => $refundId,
|
||
'refund_amount' => $amount,
|
||
'fail_code' => '',
|
||
'fail_reason' => '',
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
self::addOrderLog((int)$po['order_id'], 0, '微信已受理原路退款申请(处理中,退款单 ' . $po['refund_no'] . ',微信单号 ' . ($refundId ?: '-') . '),等待到账通知', 'system', 0);
|
||
return Db::name('pay_order')->where('id', (int)$po['id'])->find();
|
||
}
|
||
|
||
// ABNORMAL 或 CLOSED:绝对不可标为已退款!必须写入中文失败原因并告警
|
||
$reasonZh = $wxStatus === 'ABNORMAL'
|
||
? '微信原路退款异常(ABNORMAL):款项未能退到用户银行卡或微信零钱,常见原因为用户原银行卡已注销/受限或商户待结算资金不足,请登录微信商户平台【交易中心-退款管理】核实处置'
|
||
: '微信原路退款已关闭(CLOSED):该笔退款已被关闭未到账,请在微信商户平台核实并重新发起退款';
|
||
|
||
Db::name('pay_order')->where('id', (int)$po['id'])->update([
|
||
'status' => 'refund_failed',
|
||
'refund_status' => strtolower($wxStatus),
|
||
'refund_id' => $refundId,
|
||
'fail_code' => 'REFUND_' . $wxStatus,
|
||
'fail_reason' => $reasonZh,
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
self::addOrderLog((int)$po['order_id'], 0, '【退款异常告警】' . $reasonZh . '(退款单号:' . $po['refund_no'] . ')', 'system', 0);
|
||
Log::error("[PaymentService::applyWxRefundResult] 退款失败 out_trade_no={$po['out_trade_no']}, refund_no={$po['refund_no']}, wxStatus={$wxStatus}");
|
||
|
||
throw new \RuntimeException($reasonZh);
|
||
}
|
||
|
||
/**
|
||
* 后台审核通过师傅提现单时:向微信发起新版「商家转账(场景 1005 佣金报酬)」
|
||
*
|
||
* @param int $withdrawId 提现单 ID
|
||
* @param string $auditMsg 财务审核备注
|
||
* @return array 最新提现单信息
|
||
*/
|
||
public static function initiateWithdrawTransfer($withdrawId, $auditMsg = '')
|
||
{
|
||
$withdrawId = (int)$withdrawId;
|
||
$auditMsg = trim((string)$auditMsg);
|
||
|
||
$wd = Db::name('withdraw')->where('id', $withdrawId)->find();
|
||
if (!$wd) {
|
||
throw new \InvalidArgumentException('提现申请单不存在');
|
||
}
|
||
if ($wd['channel'] !== 'wechat') {
|
||
throw new \InvalidArgumentException('该提现单不是微信钱包渠道,无需调用微信商家转账');
|
||
}
|
||
if (in_array($wd['status'], ['done', 'reject'], true)) {
|
||
throw new \InvalidArgumentException('该提现单已处于终态,不可重复发起转账');
|
||
}
|
||
|
||
// 若已有历史商户转账单号,严禁跳过核查直接重复发起:
|
||
// - 待用户确认/受理中/超时未知状态 -> 必须先实时同步微信状态;
|
||
// - 同步后若为已到账/处理中 -> 直接返回最新状态;
|
||
// - 确认为 FAIL 或 NOT_FOUND(从未受理) -> 保留历史失败痕迹并生成递增重试后缀单号重新发起。
|
||
$retryCount = (int)($wd['retry_count'] ?? 0);
|
||
$existOutNo = trim((string)($wd['out_bill_no'] ?? ''));
|
||
$existState = strtoupper(trim((string)($wd['transfer_state'] ?? '')));
|
||
if ($existOutNo !== '') {
|
||
if (in_array($existState, ['ACCEPTED', 'PROCESSING', 'WAIT_USER_CONFIRM', 'TRANSFERING', 'CANCELING', 'UNKNOWN_TIMEOUT', 'SUCCESS'], true)
|
||
|| !in_array($existState, ['FAIL', 'NOT_FOUND'], true)
|
||
) {
|
||
$wd = self::syncWithdrawTransfer($withdrawId);
|
||
$existState = strtoupper(trim((string)($wd['transfer_state'] ?? '')));
|
||
if (!in_array($existState, ['FAIL', 'NOT_FOUND'], true)) {
|
||
return $wd;
|
||
}
|
||
if (in_array($wd['status'], ['done', 'reject'], true)) {
|
||
return $wd;
|
||
}
|
||
}
|
||
}
|
||
|
||
$master = Db::name('master')->where('id', (int)$wd['master_id'])->find();
|
||
if (!$master) {
|
||
self::recordWithdrawFailure($withdrawId, 'MASTER_NOT_FOUND', '提现师傅档案不存在,无法发起微信商家转账');
|
||
throw new \InvalidArgumentException('提现师傅档案不存在');
|
||
}
|
||
|
||
// 解析收款师傅微信 OpenID(优先读提现单快照,其次查微信绑定表)
|
||
$openid = trim((string)($wd['openid'] ?? ''));
|
||
if ($openid === '' && (int)$master['user_id'] > 0) {
|
||
$bind = Db::name('user_wechat')
|
||
->where('user_id', (int)$master['user_id'])
|
||
->where('openid', '<>', '')
|
||
->order('updatetime desc, id desc')
|
||
->find();
|
||
if ($bind && !empty($bind['openid'])) {
|
||
$openid = trim((string)$bind['openid']);
|
||
}
|
||
}
|
||
if ($openid === '') {
|
||
$msg = '该师傅尚未绑定微信小程序授权(缺少收款 OpenID),请提醒师傅在微信小程序内登录打开「我的钱包」后再试';
|
||
self::recordWithdrawFailure($withdrawId, 'OPENID_MISSING', $msg);
|
||
throw new \InvalidArgumentException($msg);
|
||
}
|
||
|
||
$arriveAmount = round((float)$wd['arrive_amount'], 2);
|
||
if ($arriveAmount < 0.10) {
|
||
$msg = '微信商家转账实际到账金额不能低于 0.10 元(当前:¥' . number_format($arriveAmount, 2, '.', '') . ')';
|
||
self::recordWithdrawFailure($withdrawId, 'AMOUNT_TOO_LOW', $msg);
|
||
throw new \InvalidArgumentException($msg);
|
||
}
|
||
|
||
// 生成唯一且不超过 32 位字母数字的商户转账单号(首次 WD+日期+ID;历史明确 FAIL/NOT_FOUND 后重试递增 R{n})
|
||
$createdTs = !empty($wd['createtime']) ? (int)$wd['createtime'] : time();
|
||
$baseNo = 'WD' . date('Ymd', $createdTs) . str_pad((string)$withdrawId, 8, '0', STR_PAD_LEFT);
|
||
if ($existOutNo === '') {
|
||
$outBillNo = $baseNo;
|
||
Db::name('withdraw')->where('id', $withdrawId)->where('out_bill_no', '')->update([
|
||
'openid' => $openid,
|
||
'out_bill_no' => $outBillNo,
|
||
'updatetime' => time(),
|
||
]);
|
||
$wd = Db::name('withdraw')->where('id', $withdrawId)->find();
|
||
$outBillNo = (string)$wd['out_bill_no'];
|
||
} else {
|
||
// 旧单已核实确属 FAIL/NOT_FOUND,生成递增版本号单号,严禁清空抹除旧单追踪
|
||
$retryCount++;
|
||
$outBillNo = substr($baseNo, 0, 26) . 'R' . $retryCount;
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'openid' => $openid,
|
||
'out_bill_no' => $outBillNo,
|
||
'retry_count' => $retryCount,
|
||
'transfer_state' => '',
|
||
'updatetime' => time(),
|
||
]);
|
||
}
|
||
|
||
// 事务外调用微信支付 APIv3 发起商家转账(动态读取独立配置中心报备参数)
|
||
$wdCfg = FinanceConfigService::getWithdrawConfig();
|
||
$wxSvc = new WxPayV3Service();
|
||
try {
|
||
$resp = $wxSvc->createTransferBill([
|
||
'out_bill_no' => $outBillNo,
|
||
'openid' => $openid,
|
||
'amount' => $arriveAmount,
|
||
'transfer_scene_id' => $wdCfg['wx_scene_id'],
|
||
'remark' => $wdCfg['wx_remark'],
|
||
'user_name' => (string)($master['realname'] ?? ''),
|
||
'job_type' => $wdCfg['wx_job_type'],
|
||
'reward_desc' => $wdCfg['wx_reward_desc'],
|
||
'recv_perception' => $wdCfg['wx_perception'],
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
$fail = WxPayV3Service::extractFailInfo($e);
|
||
$errNow = time();
|
||
$nextState = $fail['uncertain'] ? 'UNKNOWN_TIMEOUT' : 'FAIL';
|
||
$upd = [
|
||
'transfer_state' => $nextState,
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => $fail['reason'],
|
||
'last_sync_time' => $errNow,
|
||
'updatetime' => $errNow,
|
||
];
|
||
if ($auditMsg !== '') {
|
||
$upd['audit_msg'] = mb_substr($auditMsg . '|发起失败:' . $fail['reason'], 0, 480);
|
||
}
|
||
Db::name('withdraw')->where('id', $withdrawId)->update($upd);
|
||
Log::error("[PaymentService::initiateWithdrawTransfer] 发起商家转账失败 withdraw_id={$withdrawId}, out_bill_no={$outBillNo}, code={$fail['code']}, uncertain=" . ($fail['uncertain'] ? '1' : '0') . ", reason={$fail['reason']}");
|
||
throw $e;
|
||
}
|
||
|
||
$state = strtoupper((string)($resp['state'] ?? 'ACCEPTED'));
|
||
$transferBillNo = trim((string)($resp['transfer_bill_no'] ?? ''));
|
||
$packageInfo = trim((string)($resp['package_info'] ?? ''));
|
||
$failReason = trim((string)($resp['fail_reason'] ?? ''));
|
||
|
||
// 若刚受理瞬间处于 ACCEPTED/PROCESSING,短暂等待 0.6s 查询最新状态(严禁掩盖证书/签名等致命配置故障)
|
||
if (in_array($state, ['ACCEPTED', 'PROCESSING'], true)) {
|
||
usleep(600000);
|
||
try {
|
||
$qResp = $wxSvc->queryTransferBill($outBillNo);
|
||
if (!empty($qResp['state'])) {
|
||
$state = strtoupper((string)$qResp['state']);
|
||
}
|
||
if (!empty($qResp['transfer_bill_no'])) {
|
||
$transferBillNo = (string)$qResp['transfer_bill_no'];
|
||
}
|
||
if (!empty($qResp['package_info'])) {
|
||
$packageInfo = (string)$qResp['package_info'];
|
||
}
|
||
if (!empty($qResp['fail_reason'])) {
|
||
$failReason = (string)$qResp['fail_reason'];
|
||
}
|
||
} catch (\Throwable $qe) {
|
||
$qFail = WxPayV3Service::extractFailInfo($qe);
|
||
if (strpos($qFail['code'], 'VERIFY_SIGN') !== false || strpos($qFail['code'], 'CONFIG_') !== false) {
|
||
self::recordWithdrawFailure($withdrawId, $qFail['code'], '转账已受理但紧邻查单校验异常:' . $qFail['reason'], $state);
|
||
throw $qe;
|
||
}
|
||
Log::error("[PaymentService::initiateWithdrawTransfer] 受理后跟进查单未果 withdraw_id={$withdrawId}, out_bill_no={$outBillNo}: {$qFail['reason']}");
|
||
}
|
||
}
|
||
|
||
if ($state === 'FAIL') {
|
||
$reasonText = $failReason !== ''
|
||
? self::translateTransferFailReason($failReason)
|
||
: '微信商家转账受理后处理失败(未返回具体原因码,请登录微信商户平台按单号核查)';
|
||
// 保留失败单号、写入中文原因与错误码,严禁清空抹除单号线索
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'out_bill_no' => $outBillNo,
|
||
'transfer_bill_no' => $transferBillNo,
|
||
'transfer_state' => 'FAIL',
|
||
'fail_code' => $failReason ?: 'WX_FAIL',
|
||
'fail_reason' => $reasonText,
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
throw new \RuntimeException('微信商家转账发起失败:' . $reasonText);
|
||
}
|
||
|
||
$now = time();
|
||
Db::startTrans();
|
||
try {
|
||
$lockedWd = Db::name('withdraw')->where('id', $withdrawId)->lock(true)->find();
|
||
if (!$lockedWd || in_array($lockedWd['status'], ['done', 'reject'], true)) {
|
||
throw new \RuntimeException('提现单状态已变更,请刷新重试');
|
||
}
|
||
|
||
$update = [
|
||
'status' => 'pass',
|
||
'openid' => $openid,
|
||
'out_bill_no' => $outBillNo,
|
||
'transfer_bill_no' => $transferBillNo,
|
||
'transfer_state' => $state,
|
||
'fail_code' => '',
|
||
'fail_reason' => '',
|
||
'transfer_time' => $now,
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
];
|
||
if ($auditMsg !== '') {
|
||
$update['audit_msg'] = mb_substr($auditMsg, 0, 480);
|
||
}
|
||
if ($packageInfo !== '') {
|
||
$update['package_info'] = $packageInfo;
|
||
}
|
||
|
||
Db::name('withdraw')->where('id', $withdrawId)->update($update);
|
||
|
||
$amtStr = number_format((float)$lockedWd['amount'], 2, '.', '');
|
||
$arrStr = number_format((float)$lockedWd['arrive_amount'], 2, '.', '');
|
||
self::sendMasterAndUserMessage(
|
||
(int)$master['id'],
|
||
(int)$master['user_id'],
|
||
'提现已汇出,请确认收款',
|
||
"您的提现申请 ¥{$amtStr}(实际到账 ¥{$arrStr})已通过审核并发起微信转账,请在 24 小时内打开小程序「我的钱包」点击「确认收款」入账微信零钱。"
|
||
);
|
||
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
|
||
// 若极少数免确认场景微信直接返回 SUCCESS,顺手执行一次入账完结
|
||
if ($state === 'SUCCESS') {
|
||
return self::syncWithdrawTransfer($withdrawId, $resp);
|
||
}
|
||
|
||
return Db::name('withdraw')->where('id', $withdrawId)->find();
|
||
}
|
||
|
||
/**
|
||
* 同步微信商家转账单状态并联动结算/退回冻结资金(异步回调、师傅确认收款后、后台手动同步共用,严格幂等)
|
||
*
|
||
* @param int $withdrawId 提现单 ID
|
||
* @param array|null $wxBillData 微信回调已解密报文;传 null 则主动调用微信查单接口
|
||
* @return array 最新提现单行记录
|
||
*/
|
||
public static function syncWithdrawTransfer($withdrawId, ?array $wxBillData = null)
|
||
{
|
||
$withdrawId = (int)$withdrawId;
|
||
$wd = Db::name('withdraw')->where('id', $withdrawId)->find();
|
||
if (!$wd) {
|
||
throw new \InvalidArgumentException('提现记录不存在');
|
||
}
|
||
$outBillNo = trim((string)($wd['out_bill_no'] ?? ''));
|
||
if ($outBillNo === '') {
|
||
throw new \InvalidArgumentException('该提现单尚未生成微信商家转账单号,无法同步微信状态');
|
||
}
|
||
|
||
if ($wxBillData === null) {
|
||
$wxSvc = new WxPayV3Service();
|
||
try {
|
||
$wxBillData = $wxSvc->queryTransferBill($outBillNo);
|
||
} catch (\Throwable $qe) {
|
||
$fail = WxPayV3Service::extractFailInfo($qe);
|
||
$errNow = time();
|
||
if (in_array($fail['code'], ['NOT_FOUND', 'RESOURCE_NOT_EXISTS'], true)) {
|
||
$notFoundMsg = '经向微信查单核实,商户转账单号(' . $outBillNo . ')在微信侧不存在,证明上次发起未能立单出款(未产生资损),您可在核查原因后重新发起或驳回退回余额';
|
||
if (!in_array($wd['status'], ['done', 'reject'], true)) {
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'transfer_state' => 'NOT_FOUND',
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => $notFoundMsg,
|
||
'last_sync_time' => $errNow,
|
||
'updatetime' => $errNow,
|
||
]);
|
||
}
|
||
throw new \RuntimeException($notFoundMsg);
|
||
}
|
||
if (!in_array($wd['status'], ['done', 'reject'], true)) {
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'fail_code' => $fail['code'],
|
||
'fail_reason' => '同步微信转账状态失败:' . $fail['reason'],
|
||
'last_sync_time' => $errNow,
|
||
'updatetime' => $errNow,
|
||
]);
|
||
}
|
||
Log::error("[PaymentService::syncWithdrawTransfer] 查询转账单失败 withdraw_id={$withdrawId}, out_bill_no={$outBillNo}, code={$fail['code']}, reason={$fail['reason']}");
|
||
throw $qe;
|
||
}
|
||
}
|
||
|
||
$wxState = strtoupper(trim((string)($wxBillData['state'] ?? '')));
|
||
$transferBillNo = trim((string)($wxBillData['transfer_bill_no'] ?? $wd['transfer_bill_no']));
|
||
$newPackageInfo = trim((string)($wxBillData['package_info'] ?? ''));
|
||
$rawFailReason = trim((string)($wxBillData['fail_reason'] ?? ''));
|
||
$now = time();
|
||
|
||
if ($wxState === '') {
|
||
$msg = '微信查询转账单接口未返回有效的状态字段(state 为空),请稍后重试或登录微信商户平台核查';
|
||
self::recordWithdrawFailure($withdrawId, 'EMPTY_TRANSFER_STATE', $msg);
|
||
throw new \RuntimeException($msg);
|
||
}
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$lockedWd = Db::name('withdraw')->where('id', $withdrawId)->lock(true)->find();
|
||
if (!$lockedWd) {
|
||
Db::commit();
|
||
return $wd;
|
||
}
|
||
|
||
$masterId = (int)$lockedWd['master_id'];
|
||
$master = Db::name('master')->where('id', $masterId)->lock(true)->find();
|
||
$amount = round((float)$lockedWd['amount'], 2);
|
||
|
||
if ($wxState === 'SUCCESS') {
|
||
if ($lockedWd['status'] !== 'done') {
|
||
if ($master) {
|
||
$newFrozen = max(0, round((float)$master['frozen_money'] - $amount, 2));
|
||
Db::name('master')->where('id', $masterId)->update([
|
||
'frozen_money' => $newFrozen,
|
||
'updatetime' => $now,
|
||
]);
|
||
Db::name('wallet_log')->insert([
|
||
'user_type' => 'master',
|
||
'user_id' => $masterId,
|
||
'type' => 'withdraw',
|
||
'amount' => 0,
|
||
'balance' => (float)$master['money'],
|
||
'order_id' => 0,
|
||
'remark' => '微信零钱确认收款完成,冻结资金 ¥' . $amount . ' 已划出(单号 ' . ($transferBillNo ?: $lockedWd['out_bill_no']) . ')',
|
||
'createtime' => $now,
|
||
]);
|
||
self::sendMasterAndUserMessage(
|
||
$masterId,
|
||
(int)$master['user_id'],
|
||
'提现已到账微信零钱',
|
||
'您的提现 ¥' . $amount . '(实到 ¥' . $lockedWd['arrive_amount'] . ')已成功确认收款并入账微信零钱。'
|
||
);
|
||
}
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'status' => 'done',
|
||
'transfer_bill_no' => $transferBillNo,
|
||
'transfer_state' => 'SUCCESS',
|
||
'fail_code' => '',
|
||
'fail_reason' => '',
|
||
'confirm_time' => $now,
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
} else {
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'transfer_state' => 'SUCCESS',
|
||
'fail_code' => '',
|
||
'fail_reason' => '',
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
}
|
||
} elseif (in_array($wxState, ['FAIL', 'CANCELLED'], true)) {
|
||
$reasonCode = $rawFailReason !== '' ? $rawFailReason : ($wxState === 'CANCELLED' ? 'MERCHANT_REVOCATION' : 'WX_FAIL');
|
||
$reasonText = $rawFailReason !== ''
|
||
? self::translateTransferFailReason($rawFailReason)
|
||
: ($wxState === 'CANCELLED'
|
||
? '平台财务已撤销本次转账或收款超时关闭'
|
||
: '微信转账失败(微信未返回详细原因码,请核对微信商户平台转账单 ' . $outBillNo . ')');
|
||
|
||
if (!in_array($lockedWd['status'], ['reject', 'done'], true)) {
|
||
if ($master) {
|
||
$newMoney = round((float)$master['money'] + $amount, 2);
|
||
$newFrozen = max(0, round((float)$master['frozen_money'] - $amount, 2));
|
||
Db::name('master')->where('id', $masterId)->update([
|
||
'money' => $newMoney,
|
||
'frozen_money' => $newFrozen,
|
||
'updatetime' => $now,
|
||
]);
|
||
Db::name('wallet_log')->insert([
|
||
'user_type' => 'master',
|
||
'user_id' => $masterId,
|
||
'type' => 'refund',
|
||
'amount' => $amount,
|
||
'balance' => $newMoney,
|
||
'order_id' => 0,
|
||
'remark' => '提现未完成(' . $reasonText . '),申请金额 ¥' . $amount . ' 已退回钱包可用余额',
|
||
'createtime' => $now,
|
||
]);
|
||
self::sendMasterAndUserMessage(
|
||
$masterId,
|
||
(int)$master['user_id'],
|
||
'提现未完成退回余额通知',
|
||
'您的提现申请 ¥' . $amount . ' 未完成入账(原因:' . $reasonText . '),资金已自动解冻退回您的钱包可用余额,可随时重新发起提现。'
|
||
);
|
||
}
|
||
$mergedAudit = trim((string)$lockedWd['audit_msg']);
|
||
if ($mergedAudit === '') {
|
||
$mergedAudit = $reasonText;
|
||
} elseif (mb_strpos($mergedAudit, $reasonText) === false) {
|
||
$mergedAudit = mb_substr($mergedAudit . ';' . $reasonText, 0, 480);
|
||
}
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'status' => 'reject',
|
||
'transfer_bill_no' => $transferBillNo,
|
||
'transfer_state' => $wxState,
|
||
'fail_code' => $reasonCode,
|
||
'fail_reason' => $reasonText,
|
||
'audit_msg' => $mergedAudit,
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
} else {
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'transfer_state' => $wxState,
|
||
'fail_code' => $reasonCode,
|
||
'fail_reason' => $reasonText,
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
}
|
||
} else {
|
||
// 中间态(ACCEPTED / PROCESSING / WAIT_USER_CONFIRM / TRANSFERING / CANCELING):切勿清空已有 package_info
|
||
$upd = [
|
||
'status' => 'pass',
|
||
'transfer_bill_no' => $transferBillNo,
|
||
'transfer_state' => $wxState,
|
||
'fail_code' => '',
|
||
'fail_reason' => '',
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
];
|
||
if ($newPackageInfo !== '') {
|
||
$upd['package_info'] = $newPackageInfo;
|
||
}
|
||
Db::name('withdraw')->where('id', $withdrawId)->update($upd);
|
||
}
|
||
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
|
||
return Db::name('withdraw')->where('id', $withdrawId)->find();
|
||
}
|
||
|
||
/**
|
||
* 后台财务安全驳回提现单(防双重资损保护:若已发起微信商家转账,必须先向微信查单并撤销确认后才解冻退款)
|
||
*
|
||
* @param int $withdrawId
|
||
* @param string $auditMsg
|
||
* @return array ['status' => string, 'message' => string]
|
||
*/
|
||
public static function cancelOrRejectWithdraw($withdrawId, $auditMsg)
|
||
{
|
||
$withdrawId = (int)$withdrawId;
|
||
$auditMsg = trim((string)$auditMsg);
|
||
if ($auditMsg === '') {
|
||
throw new \InvalidArgumentException('驳回时必须填写驳回原因');
|
||
}
|
||
|
||
$wd = Db::name('withdraw')->where('id', $withdrawId)->find();
|
||
if (!$wd) {
|
||
throw new \InvalidArgumentException('提现申请单不存在');
|
||
}
|
||
if (in_array($wd['status'], ['done', 'reject'], true)) {
|
||
throw new \InvalidArgumentException('该提现单已处于终态,不可再次操作');
|
||
}
|
||
|
||
// 1. 尚未调用过微信转账接口(如刚提交处于 wait,或线下银行卡渠道):直接安全解冻退回余额
|
||
if (empty($wd['out_bill_no'])) {
|
||
$now = time();
|
||
Db::startTrans();
|
||
try {
|
||
$lockedWd = Db::name('withdraw')->where('id', $withdrawId)->lock(true)->find();
|
||
if (!$lockedWd || in_array($lockedWd['status'], ['done', 'reject'], true) || !empty($lockedWd['out_bill_no'])) {
|
||
throw new \RuntimeException('提现单状态已变更,请刷新重试');
|
||
}
|
||
$masterId = (int)$lockedWd['master_id'];
|
||
$amount = round((float)$lockedWd['amount'], 2);
|
||
$master = Db::name('master')->where('id', $masterId)->lock(true)->find();
|
||
if (!$master) {
|
||
throw new \RuntimeException('提现师傅不存在');
|
||
}
|
||
|
||
$newMoney = round((float)$master['money'] + $amount, 2);
|
||
$newFrozen = max(0, round((float)$master['frozen_money'] - $amount, 2));
|
||
|
||
Db::name('master')->where('id', $masterId)->update([
|
||
'money' => $newMoney,
|
||
'frozen_money' => $newFrozen,
|
||
'updatetime' => $now,
|
||
]);
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'status' => 'reject',
|
||
'audit_msg' => $auditMsg,
|
||
'updatetime' => $now,
|
||
]);
|
||
Db::name('wallet_log')->insert([
|
||
'user_type' => 'master',
|
||
'user_id' => $masterId,
|
||
'type' => 'refund',
|
||
'amount' => $amount,
|
||
'balance' => $newMoney,
|
||
'order_id' => 0,
|
||
'remark' => '提现驳回,申请金额退回钱包余额(原因:' . $auditMsg . ')',
|
||
'createtime' => $now,
|
||
]);
|
||
self::sendMasterAndUserMessage(
|
||
$masterId,
|
||
(int)$master['user_id'],
|
||
'提现申请已驳回',
|
||
'您的提现申请 ¥' . $amount . ' 未通过财务审核:' . $auditMsg . ',申请金额已退回钱包余额。'
|
||
);
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
return ['status' => 'reject', 'message' => '已驳回提现并解冻退回师傅余额'];
|
||
}
|
||
|
||
// 2. 已存在商户转账单号时:先实时向微信查单,确认为已失败/已撤销/微信侧不存在后,方可解冻退款(严防双重资损)
|
||
$outBillNo = (string)$wd['out_bill_no'];
|
||
$wxSvc = new WxPayV3Service();
|
||
try {
|
||
$wxBill = $wxSvc->queryTransferBill($outBillNo);
|
||
} catch (\Throwable $qe) {
|
||
$fail = WxPayV3Service::extractFailInfo($qe);
|
||
// 仅当微信明确返回 NOT_FOUND / RESOURCE_NOT_EXISTS(说明微信从未创建/受理该单)才安全解冻退回
|
||
if (in_array($fail['code'], ['NOT_FOUND', 'RESOURCE_NOT_EXISTS'], true)) {
|
||
self::forceRejectUnacceptedWithdraw(
|
||
$withdrawId,
|
||
$auditMsg,
|
||
$fail['code'],
|
||
'微信查单确认单号 ' . $outBillNo . ' 未在微信立单(' . $fail['reason'] . '),财务驳回退回'
|
||
);
|
||
return ['status' => 'reject', 'message' => '已向微信查单确认未出款,资金已安全驳回并退回师傅可用余额'];
|
||
}
|
||
self::recordWithdrawFailure($withdrawId, $fail['code'], '驳回前向微信核验转账状态失败(严禁盲目解冻):' . $fail['reason']);
|
||
throw $qe;
|
||
}
|
||
|
||
$wxState = strtoupper((string)($wxBill['state'] ?? ''));
|
||
|
||
if ($wxState === 'SUCCESS') {
|
||
self::syncWithdrawTransfer($withdrawId, $wxBill);
|
||
throw new \RuntimeException('师傅已完成微信确认收款,资金已真实入账零钱,无法驳回!系统已自动将该单同步为【已成功打款】');
|
||
}
|
||
|
||
if (in_array($wxState, ['FAIL', 'CANCELLED'], true)) {
|
||
Db::name('withdraw')->where('id', $withdrawId)->update(['audit_msg' => mb_substr($auditMsg, 0, 480)]);
|
||
self::syncWithdrawTransfer($withdrawId, $wxBill);
|
||
return ['status' => 'reject', 'message' => '微信转账单已处于未完成/撤销状态,系统已自动退回师傅钱包余额'];
|
||
}
|
||
|
||
if ($wxState === 'WAIT_USER_CONFIRM') {
|
||
try {
|
||
$cancelResp = $wxSvc->cancelTransferBill($outBillNo);
|
||
} catch (\Throwable $ce) {
|
||
$cFail = WxPayV3Service::extractFailInfo($ce);
|
||
self::recordWithdrawFailure($withdrawId, $cFail['code'], '撤销微信商家转账单失败:' . $cFail['reason']);
|
||
throw $ce;
|
||
}
|
||
|
||
$cancelState = strtoupper((string)($cancelResp['state'] ?? 'CANCELING'));
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'audit_msg' => mb_substr($auditMsg, 0, 480),
|
||
'transfer_state' => $cancelState,
|
||
'last_sync_time' => time(),
|
||
'updatetime' => time(),
|
||
]);
|
||
|
||
if ($cancelState !== 'CANCELLED') {
|
||
usleep(800000);
|
||
try {
|
||
$wxBillAfter = $wxSvc->queryTransferBill($outBillNo);
|
||
$afterState = strtoupper((string)($wxBillAfter['state'] ?? $cancelState));
|
||
if (in_array($afterState, ['CANCELLED', 'FAIL'], true)) {
|
||
self::syncWithdrawTransfer($withdrawId, $wxBillAfter);
|
||
return ['status' => 'reject', 'message' => '已成功撤销微信商家转账并解冻退回师傅余额'];
|
||
}
|
||
} catch (\Throwable $ae) {
|
||
$aFail = WxPayV3Service::extractFailInfo($ae);
|
||
self::recordWithdrawFailure($withdrawId, $aFail['code'], '提交撤销后复查状态失败:' . $aFail['reason'], 'CANCELING');
|
||
}
|
||
return [
|
||
'status' => 'pass',
|
||
'message' => '已向微信提交撤销申请(状态:撤销处理中),待微信完成资金退回后点击【同步微信状态】即可自动解冻退回师傅余额',
|
||
];
|
||
}
|
||
|
||
self::syncWithdrawTransfer($withdrawId, $cancelResp);
|
||
return ['status' => 'reject', 'message' => '已成功撤销微信商家转账并解冻退回师傅余额'];
|
||
}
|
||
|
||
if ($wxState === 'CANCELING') {
|
||
return [
|
||
'status' => 'pass',
|
||
'message' => '该微信转账单正在撤销处理中,请稍后点击【同步微信状态】完成解冻退款',
|
||
];
|
||
}
|
||
|
||
$denyMsg = '当前微信转账单处于【' . self::transferStateLabel($wxState) . '】状态,暂不支持直接撤销,请稍后同步状态再试';
|
||
self::recordWithdrawFailure($withdrawId, 'REJECT_DENY_' . $wxState, $denyMsg, $wxState);
|
||
throw new \RuntimeException($denyMsg);
|
||
}
|
||
|
||
/**
|
||
* 当微信查单明确证实未受理出款(NOT_FOUND)时安全驳回并解冻余额
|
||
*/
|
||
protected static function forceRejectUnacceptedWithdraw($withdrawId, $auditMsg, $failCode, $reasonZh)
|
||
{
|
||
$now = time();
|
||
Db::startTrans();
|
||
try {
|
||
$lockedWd = Db::name('withdraw')->where('id', $withdrawId)->lock(true)->find();
|
||
if (!$lockedWd || in_array($lockedWd['status'], ['done', 'reject'], true)) {
|
||
throw new \RuntimeException('提现单状态已变更,请刷新重试');
|
||
}
|
||
$masterId = (int)$lockedWd['master_id'];
|
||
$amount = round((float)$lockedWd['amount'], 2);
|
||
$master = Db::name('master')->where('id', $masterId)->lock(true)->find();
|
||
if ($master) {
|
||
$newMoney = round((float)$master['money'] + $amount, 2);
|
||
$newFrozen = max(0, round((float)$master['frozen_money'] - $amount, 2));
|
||
Db::name('master')->where('id', $masterId)->update([
|
||
'money' => $newMoney,
|
||
'frozen_money' => $newFrozen,
|
||
'updatetime' => $now,
|
||
]);
|
||
Db::name('wallet_log')->insert([
|
||
'user_type' => 'master',
|
||
'user_id' => $masterId,
|
||
'type' => 'refund',
|
||
'amount' => $amount,
|
||
'balance' => $newMoney,
|
||
'order_id' => 0,
|
||
'remark' => '提现驳回退回(' . $reasonZh . ')',
|
||
'createtime' => $now,
|
||
]);
|
||
self::sendMasterAndUserMessage(
|
||
$masterId,
|
||
(int)$master['user_id'],
|
||
'提现申请已退回余额',
|
||
'您的提现申请 ¥' . $amount . ' 已驳回退回(批注:' . $auditMsg . '),申请金额已全额退回钱包可用余额。'
|
||
);
|
||
}
|
||
Db::name('withdraw')->where('id', $withdrawId)->update([
|
||
'status' => 'reject',
|
||
'transfer_state' => 'NOT_FOUND',
|
||
'fail_code' => $failCode,
|
||
'fail_reason' => mb_substr($reasonZh, 0, 480),
|
||
'audit_msg' => mb_substr($auditMsg, 0, 480),
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
]);
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 统一向提现单持久化记录中文失败原因与错误码(不改变未决资金锁定)
|
||
*/
|
||
public static function recordWithdrawFailure($withdrawId, $failCode, $failReason, $transferState = null)
|
||
{
|
||
$now = time();
|
||
$upd = [
|
||
'fail_code' => mb_substr((string)$failCode, 0, 60),
|
||
'fail_reason' => mb_substr((string)$failReason, 0, 480),
|
||
'last_sync_time' => $now,
|
||
'updatetime' => $now,
|
||
];
|
||
if ($transferState !== null) {
|
||
$upd['transfer_state'] = (string)$transferState;
|
||
}
|
||
Db::name('withdraw')->where('id', (int)$withdrawId)->update($upd);
|
||
}
|
||
|
||
/**
|
||
* 支付流水状态中文映射
|
||
*/
|
||
public static function payOrderStatusLabel($status)
|
||
{
|
||
$map = [
|
||
'pending' => '待支付',
|
||
'paid' => '已支付',
|
||
'pay_failed' => '下单/支付失败',
|
||
'refunding' => '原路退款中',
|
||
'refunded' => '已原路退款',
|
||
'refund_failed' => '退款异常',
|
||
'closed' => '已关闭',
|
||
];
|
||
$k = strtolower(trim((string)$status));
|
||
return $map[$k] ?? '未知状态';
|
||
}
|
||
|
||
/**
|
||
* 微信退款状态中文映射
|
||
*/
|
||
public static function refundStatusLabel($status)
|
||
{
|
||
$map = [
|
||
'none' => '未退款',
|
||
'processing' => '退款处理中',
|
||
'success' => '退款已到账',
|
||
'abnormal' => '退款异常(需商户平台核查)',
|
||
'closed' => '退款已关闭',
|
||
];
|
||
$k = strtolower(trim((string)$status));
|
||
return $map[$k] ?? '退款未知状态';
|
||
}
|
||
|
||
/**
|
||
* 微信交易状态中文映射
|
||
*/
|
||
public static function tradeStateLabel($state, $rawDesc = '')
|
||
{
|
||
$map = [
|
||
'SUCCESS' => '支付成功',
|
||
'REFUND' => '已转入退款',
|
||
'NOTPAY' => '用户尚未完成支付',
|
||
'CLOSED' => '交易单已关闭',
|
||
'REVOKED' => '交易已撤销',
|
||
'USERPAYING' => '用户正在输入支付密码中',
|
||
'PAYERROR' => '微信扣款失败(余额不足、银行卡受限或风控拦截)',
|
||
];
|
||
$k = strtoupper(trim((string)$state));
|
||
if (isset($map[$k])) {
|
||
return $map[$k];
|
||
}
|
||
return $rawDesc !== '' ? ('交易状态异常(' . $k . ')') : '待确认交易状态';
|
||
}
|
||
|
||
/**
|
||
* 微信商家转账状态中文映射
|
||
*/
|
||
public static function transferStateLabel($state)
|
||
{
|
||
$map = [
|
||
'' => '待发起转账',
|
||
'ACCEPTED' => '微信已受理',
|
||
'PROCESSING' => '锁定资金处理中',
|
||
'WAIT_USER_CONFIRM' => '待师傅确认收款',
|
||
'TRANSFERING' => '微信转账入账中',
|
||
'SUCCESS' => '已确认收款到账',
|
||
'FAIL' => '转账失败',
|
||
'CANCELING' => '商户撤销处理中',
|
||
'CANCELLED' => '已撤销并退回',
|
||
'UNKNOWN_TIMEOUT' => '请求超时(状态待查单核实)',
|
||
'NOT_FOUND' => '微信查无此单(未受理出款)',
|
||
];
|
||
$key = strtoupper(trim((string)$state));
|
||
return $map[$key] ?? ('未知状态(' . $key . ')');
|
||
}
|
||
|
||
/**
|
||
* 全量翻译微信商家转账 fail_reason 枚举码为管理员可读简体中文(杜绝透传英文枚举)
|
||
*/
|
||
public static function translateTransferFailReason($reasonCode)
|
||
{
|
||
$raw = trim((string)$reasonCode);
|
||
$code = strtoupper($raw);
|
||
if ($code === '') {
|
||
return '转账未完成或已关闭(未返回具体原因码)';
|
||
}
|
||
$map = [
|
||
'OVERDUE_CLOSE' => '超过24小时未确认收款,微信系统自动关单退回',
|
||
'TRANSFER_REMARK_SET_FAIL' => '转账备注内容不符合微信校验规范',
|
||
'RECEIVE_ACCOUNT_NOT_CONFIGURE' => '微信商户后台未正确配置对应商家转账场景(请核对场景 ID 1005 报备)',
|
||
'EXCEED_MAX_SUBSCRIBE_COUNT' => '超出收款用户单月消息/收款订阅次数限制',
|
||
'MERCHANT_REVOCATION' => '平台财务主动发起撤销转账并退回',
|
||
'USER_CANCEL' => '收款用户主动退回转账',
|
||
'NOT_ENOUGH' => '微信商户运营账户可用余额不足,请充值后再试',
|
||
'ACCOUNT_FROZEN' => '收款人微信支付账户已被冻结,无法收款',
|
||
'REAL_NAME_CHECK_FAIL' => '收款师傅姓名与本人微信实名信息不一致,请核对实名认证信息',
|
||
'NAME_NOT_CORRECT' => '大额转账收款人姓名与微信实名不匹配',
|
||
'OPENID_INVALID' => '收款人微信 OpenID 无效或不属于当前小程序 AppID',
|
||
'TRANSFER_QUOTA_EXCEED' => '超出微信商户后台设置的单笔或单日商家转账额度上限',
|
||
'DAY_RECEIVED_QUOTA_EXCEED' => '超出收款人微信单日收款额度上限',
|
||
'MONTH_RECEIVED_QUOTA_EXCEED' => '超出收款人微信单月收款额度上限',
|
||
'DAY_RECEIVED_COUNT_EXCEED' => '超出收款人微信单日收款次数上限',
|
||
'PRODUCT_AUTH_CHECK_FAIL' => '商户号商家转账产品权限校验未通过或场景未生效',
|
||
'OVERDUE_CONFIRM' => '超过有效期未确认收款,微信系统自动关闭退回',
|
||
'TRANSFER_RISK' => '微信支付安全风控系统拦截本次转账,请核实账户合规状态',
|
||
'PAYEE_ACCOUNT_ABNORMAL' => '收款人微信账户状态异常或未绑定有效结算方式',
|
||
'PAYER_ACCOUNT_ABNORMAL' => '商户运营付款账户状态异常,请登录微信商户平台排查站内信通知',
|
||
'TRANSFER_SCENE_INVALID' => '商家转账场景 ID(1005)无效或报备字段内容不符合要求',
|
||
'USER_ACCOUNT_INVALID' => '收款用户微信账号异常或已注销',
|
||
'EXCEED_TRANSFER_AMOUNT_LIMIT' => '超出商户商家转账单笔最大额度限制(如平台单笔 200 元配置限制)',
|
||
'EXCEED_SINGLE_DAY_TRANSFER_LIMIT' => '超出微信商户单日总转账额度上限',
|
||
'FREQUENCY_LIMITED' => '向同一用户发起转账频率过快,触发微信限流',
|
||
'SYSTEMERROR' => '微信商家转账网关内部处理异常退回',
|
||
'WX_FAIL' => '微信商家转账受理后处理失败',
|
||
];
|
||
if (isset($map[$code])) {
|
||
return $map[$code];
|
||
}
|
||
return '微信转账失败(微信返回原因码:' . $code . ',请登录微信商户平台商家转账明细查询具体说明)';
|
||
}
|
||
|
||
/**
|
||
* 0元订单(全额优惠券抵扣)免调微信直接入账
|
||
*/
|
||
protected static function completeZeroOrderPay($orderId, $uid)
|
||
{
|
||
$now = time();
|
||
$broadcastOrder = null;
|
||
$nextStatus = OrderStatus::PENDING_ACCEPT;
|
||
|
||
Db::startTrans();
|
||
try {
|
||
$lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find();
|
||
if (!$lockedOrder || (int)$lockedOrder['pay_status'] === 1 || (int)$lockedOrder['status'] !== OrderStatus::PENDING_PAY) {
|
||
throw new \InvalidArgumentException('订单状态已变更,请刷新重试');
|
||
}
|
||
self::lockAndConsumeOrderCoupon($lockedOrder, $now);
|
||
|
||
$nextStatus = $lockedOrder['price_type'] === 'quote' ? OrderStatus::QUOTING : OrderStatus::PENDING_ACCEPT;
|
||
$nextRemark = $nextStatus === OrderStatus::QUOTING
|
||
? '优惠抵扣支付完成,订单已进入报价大厅'
|
||
: '优惠抵扣支付完成,订单已进入接单大厅';
|
||
|
||
Db::name('order')->where('id', $orderId)->update([
|
||
'status' => $nextStatus,
|
||
'pay_status' => 1,
|
||
'pay_type' => 'coupon',
|
||
'pay_time' => $now,
|
||
'transaction_id' => 'COUPON' . date('YmdHis', $now) . mt_rand(1000, 9999),
|
||
'updatetime' => $now,
|
||
]);
|
||
self::addOrderLog($orderId, $nextStatus, $nextRemark, 'user', $uid);
|
||
self::addOrderLog($orderId, $nextStatus, $nextStatus === OrderStatus::QUOTING ? '订单进入师傅报价大厅' : '订单进入师傅接单大厅', 'system', 0);
|
||
|
||
$broadcastOrder = $lockedOrder;
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
|
||
if ($broadcastOrder && in_array($nextStatus, [OrderStatus::PENDING_ACCEPT, OrderStatus::QUOTING], true)) {
|
||
self::broadcastNewOrderToMasters($broadcastOrder);
|
||
}
|
||
|
||
return [
|
||
'need_wxpay' => false,
|
||
'paid' => true,
|
||
'scene' => 'order',
|
||
'order_id' => $orderId,
|
||
'out_trade_no' => '',
|
||
'amount' => 0.00,
|
||
'pay_params' => null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 选定报价无需补差额(0元)直接锁定师傅
|
||
*/
|
||
protected static function completeZeroQuoteChoose($orderId, $quoteId, $uid)
|
||
{
|
||
$now = time();
|
||
Db::startTrans();
|
||
try {
|
||
$lockedOrder = Db::name('order')->where('id', $orderId)->lock(true)->find();
|
||
if (!$lockedOrder || (int)$lockedOrder['status'] !== OrderStatus::QUOTING || (int)$lockedOrder['master_id'] !== 0) {
|
||
throw new \InvalidArgumentException('该工单已选定师傅或状态已变更,请刷新重试');
|
||
}
|
||
$quote = Db::name('order_quote')->where('id', $quoteId)->where('order_id', $orderId)->lock(true)->find();
|
||
$master = $quote ? Db::name('master')->where('id', (int)$quote['master_id'])->find() : null;
|
||
if (!$quote || !$master || $quote['status'] !== 'wait') {
|
||
throw new \InvalidArgumentException('报价状态已变更,请刷新重试');
|
||
}
|
||
|
||
$quoteAmount = round((float)$quote['amount'], 2);
|
||
$payAmount = max(0, round($quoteAmount - (float)$lockedOrder['discount_amount'], 2));
|
||
$snapRefreshed = CommissionService::refreshQuoteOrderSnapshotOnAmountConfirmed(
|
||
$payAmount > 0 ? $payAmount : $quoteAmount,
|
||
$lockedOrder['commission_snapshot'] ?? null,
|
||
$lockedOrder['commission_rate'] ?? null
|
||
);
|
||
$remark = '客户已选择 ' . $master['realname'] . ' 的报价 ¥' . $quoteAmount . ',等待师傅联系';
|
||
|
||
$zeroQuoteUpdate = [
|
||
'status' => OrderStatus::ACCEPTED,
|
||
'master_id' => (int)$quote['master_id'],
|
||
'accept_time' => $now,
|
||
'base_amount' => $quoteAmount,
|
||
'floor_amount' => 0,
|
||
'urgent_amount' => 0,
|
||
'total_amount' => $quoteAmount,
|
||
'pay_amount' => $payAmount,
|
||
'commission_rate' => $snapRefreshed['commission_rate'],
|
||
'pay_status' => 1,
|
||
'updatetime' => $now,
|
||
];
|
||
if ($snapRefreshed['commission_snapshot'] !== null) {
|
||
$zeroQuoteUpdate['commission_snapshot'] = $snapRefreshed['commission_snapshot'];
|
||
}
|
||
Db::name('order')->where('id', $orderId)->update($zeroQuoteUpdate);
|
||
self::addOrderLog($orderId, OrderStatus::ACCEPTED, $remark, 'user', $uid);
|
||
|
||
Db::name('order_quote')->where('id', $quoteId)->update(['status' => 'accept']);
|
||
Db::name('order_quote')->where('order_id', $orderId)->where('id', '<>', $quoteId)
|
||
->where('status', 'wait')->update(['status' => 'reject']);
|
||
|
||
self::sendNotify('user', (int)$lockedOrder['user_id'], '已选定服务师傅', $remark, 'order', $orderId);
|
||
self::sendNotify('master', (int)$master['id'], '您的报价已被选中', '订单 ' . $lockedOrder['order_no'] . ' 客户已选择您的报价 ¥' . $quoteAmount . ',请尽快联系客户', 'order', $orderId);
|
||
|
||
Db::commit();
|
||
} catch (\Throwable $e) {
|
||
Db::rollback();
|
||
throw $e;
|
||
}
|
||
|
||
return [
|
||
'need_wxpay' => false,
|
||
'paid' => true,
|
||
'scene' => 'quote',
|
||
'order_id' => $orderId,
|
||
'out_trade_no' => '',
|
||
'amount' => 0.00,
|
||
'pay_params' => null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 在事务内校验并消费订单锁定的优惠券
|
||
*/
|
||
protected static function lockAndConsumeOrderCoupon(array $lockedOrder, $now)
|
||
{
|
||
$couponId = (int)($lockedOrder['coupon_id'] ?? 0);
|
||
$orderId = (int)$lockedOrder['id'];
|
||
if ($couponId <= 0) {
|
||
return;
|
||
}
|
||
$uc = Db::name('user_coupon')
|
||
->where('id', $couponId)
|
||
->where('user_id', (int)$lockedOrder['user_id'])
|
||
->lock(true)
|
||
->find();
|
||
if (!$uc) {
|
||
throw new \InvalidArgumentException('订单关联的优惠券不存在');
|
||
}
|
||
if ($uc['status'] === 'used' && (int)$uc['order_id'] === $orderId) {
|
||
return;
|
||
}
|
||
if ($uc['status'] === 'unused') {
|
||
$affectedUc = Db::name('user_coupon')
|
||
->where('id', (int)$uc['id'])
|
||
->where('user_id', (int)$lockedOrder['user_id'])
|
||
->where('status', 'unused')
|
||
->update([
|
||
'status' => 'used',
|
||
'order_id' => $orderId,
|
||
'use_time' => $now,
|
||
]);
|
||
if (!$affectedUc) {
|
||
throw new \InvalidArgumentException('优惠券状态异常,请重新下单');
|
||
}
|
||
return;
|
||
}
|
||
throw new \InvalidArgumentException('该优惠券已被其他订单使用或已过期');
|
||
}
|
||
|
||
protected static function broadcastNewOrderToMasters(array $order)
|
||
{
|
||
try {
|
||
$masters = Db::name('master')
|
||
->where('verify_status', 'pass')
|
||
->where('work_status', 'on')
|
||
->where('user_id', '<>', (int)$order['user_id'])
|
||
->limit(20)
|
||
->select();
|
||
foreach ($masters as $m) {
|
||
self::sendNotify('master', (int)$m['id'], '新工单就绪通知', '同城有新工单【' . $order['order_no'] . '】进入接单大厅,请尽快前往抢单!', 'order', (int)$order['id']);
|
||
}
|
||
} catch (\Throwable $e) {
|
||
Log::error('[PaymentService::broadcastNewOrderToMasters] ' . $e->getMessage());
|
||
}
|
||
}
|
||
|
||
protected static function addOrderLog($orderId, $status, $remark = '', $operatorType = 'system', $operatorId = 0)
|
||
{
|
||
Db::name('order_log')->insert([
|
||
'order_id' => (int)$orderId,
|
||
'status' => (int)$status,
|
||
'remark' => mb_substr((string)$remark, 0, 255),
|
||
'operator_type' => $operatorType,
|
||
'operator_id' => (int)$operatorId,
|
||
'createtime' => time(),
|
||
]);
|
||
}
|
||
|
||
protected static function sendNotify($receiveType, $receiveId, $title, $content, $type = 'order', $orderId = 0)
|
||
{
|
||
if ((int)$receiveId <= 0) {
|
||
return;
|
||
}
|
||
Db::name('message')->insert([
|
||
'receive_type' => $receiveType,
|
||
'receive_id' => (int)$receiveId,
|
||
'type' => $type,
|
||
'title' => mb_substr((string)$title, 0, 100),
|
||
'content' => mb_substr((string)$content, 0, 500),
|
||
'order_id' => (int)$orderId,
|
||
'is_read' => 0,
|
||
'createtime' => time(),
|
||
]);
|
||
}
|
||
|
||
protected static function sendMasterAndUserMessage($masterId, $userId, $title, $content)
|
||
{
|
||
$now = time();
|
||
$rows = [];
|
||
if ((int)$masterId > 0) {
|
||
$rows[] = [
|
||
'receive_type' => 'master',
|
||
'receive_id' => (int)$masterId,
|
||
'type' => 'audit',
|
||
'title' => mb_substr((string)$title, 0, 100),
|
||
'content' => mb_substr((string)$content, 0, 500),
|
||
'order_id' => 0,
|
||
'is_read' => 0,
|
||
'createtime' => $now,
|
||
];
|
||
}
|
||
if ((int)$userId > 0) {
|
||
$rows[] = [
|
||
'receive_type' => 'user',
|
||
'receive_id' => (int)$userId,
|
||
'type' => 'system',
|
||
'title' => mb_substr((string)$title, 0, 100),
|
||
'content' => mb_substr((string)$content, 0, 500),
|
||
'order_id' => 0,
|
||
'is_read' => 0,
|
||
'createtime' => $now,
|
||
];
|
||
}
|
||
if ($rows) {
|
||
Db::name('message')->insertAll($rows);
|
||
}
|
||
}
|
||
}
|