文件
xiaozhongchuf/application/admin/command/ShoproPayReconcile.php

183 行
6.8 KiB
PHP

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

<?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
$isCompleted = false;
Db::transaction(function () use ($orderSn, $paidInfo, $gateway, &$isCompleted) {
$order = Order::nopay()->where('order_sn', $orderSn)->lock(true)->find();
if (!$order) {
return;
}
$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);
$isCompleted = true;
});
if (!$isCompleted) {
// 已被回调或其他进程完成
$output->info("skip {$orderSn}: already processed");
return false;
}
$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,
];
}
}