- PayService 新增 query():主动向微信/支付宝网关查询订单状态 - 新增命令:定期扫描未付款订单,网关已付款且金额一致则走与回调 相同的 paymentProcess 完成订单,避免回调丢失导致已付款仍显示未付款 - 支持 --dry-run / --limit / --min-age / --max-age
177 行
6.6 KiB
PHP
177 行
6.6 KiB
PHP
<?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,
|
||
];
|
||
}
|
||
}
|