fix(pay): 增加支付掉单补偿命令 shopro:pay-reconcile
- PayService 新增 query():主动向微信/支付宝网关查询订单状态 - 新增命令:定期扫描未付款订单,网关已付款且金额一致则走与回调 相同的 paymentProcess 完成订单,避免回调丢失导致已付款仍显示未付款 - 支持 --dry-run / --limit / --min-age / --max-age
这个提交包含在:
@@ -236,6 +236,20 @@ class PayService
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 主动查询网关侧订单状态(支付对账/掉单补偿用,无需验签回调)
|
||||
public function query($out_trade_no)
|
||||
{
|
||||
$pay = $this->getPay();
|
||||
|
||||
$order = ['out_trade_no' => $out_trade_no];
|
||||
|
||||
if ($this->payment === 'wechat') {
|
||||
return $pay->find($order);
|
||||
}
|
||||
|
||||
return $pay->find($order, 'web');
|
||||
}
|
||||
|
||||
|
||||
public function notifyRefund($callback)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
namespace app\admin\command;
|
||||
|
||||
use addons\shopro\model\Order;
|
||||
use think\Db;
|
||||
use think\Log;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 支付掉单补偿:回调丢失/失败导致“已付款仍显示未付款”时,
|
||||
* 主动向微信/支付宝查询网关侧状态,确认已付款且金额一致后走与回调相同的 paymentProcess 完成订单。
|
||||
*
|
||||
* 建议 cron 每 5 分钟执行:
|
||||
* php think shopro:pay-reconcile
|
||||
*/
|
||||
class ShoproPayReconcile extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('shopro:pay-reconcile')
|
||||
->addOption('dry-run', null, Option::VALUE_NONE, 'only report, do not complete orders')
|
||||
->addOption('limit', null, Option::VALUE_REQUIRED, 'max orders to check per run', 50)
|
||||
->addOption('min-age', null, Option::VALUE_REQUIRED, 'only check orders created at least N minutes ago', 15)
|
||||
->addOption('max-age', null, Option::VALUE_REQUIRED, 'only check orders created within N hours', 72)
|
||||
->setDescription('Reconcile unpaid shopro orders against wechat/alipay gateway status');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$dryRun = (bool)$input->getOption('dry-run');
|
||||
$limit = max(1, min(500, (int)$input->getOption('limit')));
|
||||
$minAge = max(1, (int)$input->getOption('min-age'));
|
||||
$maxAge = max(1, (int)$input->getOption('max-age'));
|
||||
|
||||
$now = time();
|
||||
$candidates = Db::name('shopro_order')
|
||||
->where('status', 0)
|
||||
->where('pay_type', 'in', ['wechat', 'alipay', 'group'])
|
||||
->where('createtime', '<=', $now - $minAge * 60)
|
||||
->where('createtime', '>=', $now - $maxAge * 3600)
|
||||
->order('id', 'asc')
|
||||
->limit($limit)
|
||||
->select();
|
||||
|
||||
$checked = 0;
|
||||
$completed = 0;
|
||||
foreach ($candidates as $row) {
|
||||
$checked++;
|
||||
try {
|
||||
$done = $this->reconcileOne($row, $dryRun, $output);
|
||||
if ($done) {
|
||||
$completed++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
$msg = "pay-reconcile order {$row['order_sn']} failed: " . $e->getMessage();
|
||||
Log::write($msg, 'error');
|
||||
$output->error($msg);
|
||||
}
|
||||
}
|
||||
|
||||
$output->info(($dryRun ? '[dry-run] ' : '') . "checked {$checked}, completed {$completed}.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $row
|
||||
* @return bool 是否完成(或 dry-run 下是否“可完成”)
|
||||
*/
|
||||
private function reconcileOne($row, $dryRun, $output)
|
||||
{
|
||||
$orderSn = $row['order_sn'];
|
||||
$payType = $row['pay_type'];
|
||||
$platform = $row['platform'];
|
||||
|
||||
// group(组合支付)走微信网关查询
|
||||
$gateway = $payType === 'group' ? 'wechat' : $payType;
|
||||
|
||||
if ($gateway === 'wechat'
|
||||
&& !in_array($platform, ['wxMiniProgram', 'wxOfficialAccount', 'H5', 'App'], true)
|
||||
) {
|
||||
$output->comment("skip {$orderSn}: unsupported platform {$platform}");
|
||||
return false;
|
||||
}
|
||||
|
||||
$pay = new \addons\shopro\library\PayService($gateway, $platform);
|
||||
$data = $pay->query($orderSn);
|
||||
|
||||
$paidInfo = $this->parseGatewayResult($gateway, $data);
|
||||
if (!$paidInfo) {
|
||||
$output->comment("skip {$orderSn}: gateway reports unpaid/unknown");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 金额必须与本地应付一致,否则只告警、不自动完成
|
||||
if (bccomp((string)$paidInfo['pay_fee'], (string)$row['pay_fee'], 2) !== 0) {
|
||||
$msg = "pay-reconcile amount mismatch {$orderSn}: gateway {$paidInfo['pay_fee']} vs local {$row['pay_fee']}";
|
||||
Log::write($msg, 'error');
|
||||
$output->error($msg . ', manual review required');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
$output->info("would complete {$orderSn} ({$paidInfo['pay_fee']})");
|
||||
return true;
|
||||
}
|
||||
|
||||
// 与 Pay::notifyx 回调相同的完成路径:加锁复查 + 事务 + paymentProcess
|
||||
$order = Order::nopay()->where('order_sn', $orderSn)->lock(true)->find();
|
||||
if (!$order) {
|
||||
// 已被回调或其他进程完成
|
||||
$output->info("skip {$orderSn}: already processed");
|
||||
return false;
|
||||
}
|
||||
|
||||
Db::transaction(function () use ($order, $orderSn, $paidInfo, $gateway) {
|
||||
$notify = [
|
||||
'order_sn' => $orderSn,
|
||||
'transaction_id' => $paidInfo['transaction_id'],
|
||||
'notify_time' => $paidInfo['notify_time'],
|
||||
'buyer_email' => $paidInfo['buyer'],
|
||||
'payment_json' => json_encode($paidInfo['raw']),
|
||||
'pay_fee' => $paidInfo['pay_fee'],
|
||||
'pay_type' => $order->pay_type == 'group' ? 'group' : $gateway,
|
||||
];
|
||||
$order->paymentProcess($order, $notify);
|
||||
});
|
||||
|
||||
$msg = "pay-reconcile completed {$orderSn} ({$paidInfo['pay_fee']})";
|
||||
Log::write($msg);
|
||||
$output->info($msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析网关查询结果,已确认付款返回统一结构,否则返回 null
|
||||
*/
|
||||
private function parseGatewayResult($gateway, $data)
|
||||
{
|
||||
$arr = $data instanceof \think\Collection ? $data->all() : (array)$data;
|
||||
|
||||
if ($gateway === 'wechat') {
|
||||
if (($arr['result_code'] ?? '') !== 'SUCCESS' || ($arr['trade_state'] ?? '') !== 'SUCCESS') {
|
||||
return null;
|
||||
}
|
||||
$notifyTime = isset($arr['time_end']) && ($ts = strtotime($arr['time_end']))
|
||||
? date('Y-m-d H:i:s', $ts)
|
||||
: date('Y-m-d H:i:s');
|
||||
return [
|
||||
'transaction_id' => $arr['transaction_id'],
|
||||
'notify_time' => $notifyTime,
|
||||
'buyer' => $arr['openid'] ?? '',
|
||||
'pay_fee' => bcdiv((string)$arr['total_fee'], '100', 2),
|
||||
'raw' => $arr,
|
||||
];
|
||||
}
|
||||
|
||||
// alipay: alipay.trade.query
|
||||
if (($arr['code'] ?? '') !== '10000' || ($arr['trade_status'] ?? '') !== 'TRADE_SUCCESS') {
|
||||
return null;
|
||||
}
|
||||
$notifyTime = isset($arr['gmt_payment']) && ($ts = strtotime($arr['gmt_payment']))
|
||||
? date('Y-m-d H:i:s', $ts)
|
||||
: date('Y-m-d H:i:s');
|
||||
return [
|
||||
'transaction_id' => $arr['trade_no'],
|
||||
'notify_time' => $notifyTime,
|
||||
'buyer' => $arr['buyer_logon_id'] ?? '',
|
||||
'pay_fee' => (string)$arr['total_amount'],
|
||||
'raw' => $arr,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -18,4 +18,5 @@ return [
|
||||
'app\admin\command\Addon',
|
||||
'app\admin\command\Api',
|
||||
'app\admin\command\ShoproPrintRetry',
|
||||
'app\admin\command\ShoproPayReconcile',
|
||||
];
|
||||
|
||||
在新工单中引用
屏蔽一个用户