feat: 完善支付通道和收款监听链路

新增 ChannelNotifyPayloadInterface 等支付插件通知契约,规范 pay_no 定位和插件返回校验。

新增微信、支付宝、收钱吧、Postar 个人收款插件适配,支持余额识别与备注识别。

新增 receipt-watcher 后端进程、Redis 队列 job 和平台事件监听,覆盖收款流水通知、商户通知、退款派发、转账派发与清算完成。

补齐个人收款监听相关系统配置、仓储、服务费冻结明细、订单后台操作和通道测试能力。

重构支付单创建、回调、费用、风控、结算和通道统计链路,统一状态流转与幂等处理。
This commit is contained in:
技术老胡
2026-05-11 16:28:48 +08:00
parent 0e5de50337
commit fd1f53f2ee
136 changed files with 14416 additions and 3992 deletions

View File

@@ -3,6 +3,7 @@
namespace app\repository\payment\config;
use app\common\base\BaseRepository;
use app\common\constant\CommonConstant;
use app\model\payment\PaymentChannel;
/**
@@ -122,6 +123,83 @@ class PaymentChannelRepository extends BaseRepository
->where('merchant_id', $merchantId)
->count();
}
/**
* 查询网页流水监听可用通道。
*
* @param array<int, string> $pluginCodes 支持监听的插件编码
* @param array $columns 字段列表
* @return \Illuminate\Database\Eloquent\Collection<int, PaymentChannel> 通道列表
*/
public function listReceiptWatcherChannels(array $pluginCodes, array $columns = ['*'])
{
$pluginCodes = array_values(array_filter(array_map(static fn ($code): string => trim((string) $code), $pluginCodes)));
if ($pluginCodes === []) {
return $this->model->newCollection();
}
return $this->model->newQuery()
->where('status', CommonConstant::STATUS_ENABLED)
->where('api_config_id', '>', 0)
->whereIn('plugin_code', $pluginCodes)
->orderBy('api_config_id')
->orderBy('id')
->get($columns);
}
/**
* 查询指定插件配置下的通道 ID。
*
* @param string $pluginCode 插件编码
* @param int $apiConfigId 插件配置ID
* @return array<int, int> 通道 ID 列表
*/
public function idsByPluginConfig(string $pluginCode, int $apiConfigId): array
{
if (trim($pluginCode) === '' || $apiConfigId <= 0) {
return [];
}
return $this->model->newQuery()
->where('plugin_code', $pluginCode)
->where('api_config_id', $apiConfigId)
->pluck('id')
->map(fn ($id): int => (int) $id)
->all();
}
/**
* 根据插件配置和支付方式解析流水对应通道。
*
* @param string $pluginCode 插件编码
* @param int $apiConfigId 插件配置ID
* @param string $payTypeCode 支付方式编码
* @return PaymentChannel|null 支付通道
*/
public function findReceiptFlowChannel(string $pluginCode, int $apiConfigId, string $payTypeCode): ?PaymentChannel
{
$pluginCode = trim($pluginCode);
$payTypeCode = trim($payTypeCode);
if ($pluginCode === '' || $apiConfigId <= 0) {
return null;
}
$query = $this->model->newQuery()
->from('ma_payment_channel as c')
->where('c.plugin_code', $pluginCode)
->where('c.api_config_id', $apiConfigId)
->where('c.status', CommonConstant::STATUS_ENABLED)
->orderBy('c.sort_no')
->orderBy('c.id');
if ($payTypeCode !== '') {
$query->join('ma_payment_type as t', 'c.pay_type_id', '=', 't.id')
->where('t.code', $payTypeCode);
}
/** @var PaymentChannel|null $channel */
$channel = $query->first(['c.*']);
return $channel;
}
}

View File

@@ -53,9 +53,27 @@ class PaymentPluginConfRepository extends BaseRepository
->whereKey($id)
->first($columns);
}
/**
* 根据配置 ID 批量查询插件配置。
*
* @param array<int, int> $ids 配置 ID 列表
* @param array $columns 字段列表
* @return \Illuminate\Database\Eloquent\Collection<int, PaymentPluginConf> 插件配置列表
*/
public function listByIds(array $ids, array $columns = ['*'])
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
if ($ids === []) {
return $this->model->newCollection();
}
return $this->model->newQuery()
->whereIn('id', $ids)
->get($columns);
}
}

View File

@@ -61,6 +61,7 @@ class PaymentPollGroupBindRepository extends BaseRepository
'b.remark',
't.code as pay_type_code',
't.name as pay_type_name',
't.icon as pay_type_icon',
'p.group_name as poll_group_name',
'p.route_mode',
]);
@@ -70,4 +71,3 @@ class PaymentPollGroupBindRepository extends BaseRepository

View File

@@ -49,10 +49,28 @@ class PaymentTypeRepository extends BaseRepository
->where('code', $code)
->first($columns);
}
/**
* 根据支付方式 ID 批量查询字典。
*
* @param array<int, int> $ids 支付方式 ID 列表
* @param array $columns 字段列表
* @return \Illuminate\Database\Eloquent\Collection<int, PaymentType> 支付方式列表
*/
public function listByIds(array $ids, array $columns = ['*'])
{
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
if ($ids === []) {
return $this->model->newCollection();
}
return $this->model->newQuery()
->whereIn('id', $ids)
->get($columns);
}
}

View File

@@ -226,8 +226,156 @@ class PayOrderRepository extends BaseRepository
'c.name as channel_name',
]);
}
/**
* 查询网页流水监听需要关注的待支付订单。
*
* @param array<int, string> $pluginCodes 插件编码列表
* @param string $now 当前时间
* @param int $limit 限制条数
* @return \Illuminate\Database\Eloquent\Collection<int, PayOrder> 支付单列表
*/
public function listReceiptWatcherPendingOrders(array $pluginCodes, string $now, int $limit = 500)
{
$pluginCodes = array_values(array_filter(array_map(static fn ($code): string => trim((string) $code), $pluginCodes)));
if ($pluginCodes === []) {
return $this->model->newCollection();
}
return $this->model->newQuery()
->from('ma_pay_order as po')
->join('ma_payment_channel as c', 'po.channel_id', '=', 'c.id')
->leftJoin('ma_payment_type as t', 'po.pay_type_id', '=', 't.id')
->whereIn('po.status', TradeConstant::orderMutableStatuses())
->whereIn('c.plugin_code', $pluginCodes)
->where('c.status', 1)
->where(function ($query) use ($now): void {
$query->whereNull('po.expire_at')
->orWhere('po.expire_at', '>', $now);
})
->orderBy('po.request_at')
->orderBy('po.id')
->limit(max(1, $limit))
->get([
'po.id',
'po.pay_no',
'po.channel_id',
'po.pay_type_id',
'po.pay_amount',
'po.channel_order_no',
'po.channel_trade_no',
'po.request_at',
'po.expire_at',
'po.created_at',
'c.plugin_code',
'c.api_config_id',
't.code as pay_type',
]);
}
/**
* 查询同一收款账号下已占用的识别金额。
*
* @param array<int, int> $channelIds 通道ID列表
* @param string $excludePayNo 排除的支付单号
* @param string $now 当前时间
* @return array<int, int> 已占用金额列表,单位分
*/
public function listUsedReceiptAmounts(array $channelIds, string $excludePayNo, string $now): array
{
$channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds))));
if ($channelIds === []) {
return [];
}
return $this->model->newQuery()
->whereIn('channel_id', $channelIds)
->where('pay_no', '<>', $excludePayNo)
->whereIn('status', TradeConstant::orderMutableStatuses())
->where('expire_at', '>', $now)
->lockForUpdate()
->pluck('pay_amount')
->map(fn ($amount): int => (int) $amount)
->all();
}
/**
* 根据第三方流水号匹配支付单。
*
* @param array<int, int> $channelIds 通道ID列表
* @param string $orderNo 第三方流水号
* @param array $columns 字段列表
* @return PayOrder|null 支付单
*/
public function findByReceiptChannelOrder(array $channelIds, string $orderNo, array $columns = ['*']): ?PayOrder
{
$channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds))));
$orderNo = trim($orderNo);
if ($channelIds === [] || $orderNo === '') {
return null;
}
return $this->model->newQuery()
->whereIn('channel_id', $channelIds)
->where(function ($query) use ($orderNo): void {
$query->where('channel_order_no', $orderNo)
->orWhere('channel_trade_no', $orderNo);
})
->orderByDesc('id')
->first($columns);
}
/**
* 根据金额查询同一收款账号下有效待支付订单。
*
* @param array<int, int> $channelIds 通道ID列表
* @param int $amount 金额,单位分
* @param int $payTypeId 支付方式ID
* @param string $now 当前时间
* @param array $columns 字段列表
* @return \Illuminate\Database\Eloquent\Collection<int, PayOrder> 支付单列表
*/
public function listMutableReceiptOrdersByAmount(array $channelIds, int $amount, int $payTypeId, string $now, array $columns = ['*'])
{
$channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds))));
if ($channelIds === []) {
return $this->model->newCollection();
}
$query = $this->model->newQuery()
->whereIn('channel_id', $channelIds)
->where('pay_amount', $amount)
->whereIn('status', TradeConstant::orderMutableStatuses())
->where('expire_at', '>', $now);
if ($payTypeId > 0) {
$query->where('pay_type_id', $payTypeId);
}
return $query->get($columns);
}
/**
* 查询同一收款账号下有效待支付订单,用于备注匹配。
*
* @param array<int, int> $channelIds 通道ID列表
* @param string $now 当前时间
* @param array $columns 字段列表
* @return \Illuminate\Database\Eloquent\Collection<int, PayOrder> 支付单列表
*/
public function listMutableReceiptOrders(array $channelIds, string $now, array $columns = ['*'])
{
$channelIds = array_values(array_unique(array_filter(array_map('intval', $channelIds))));
if ($channelIds === []) {
return $this->model->newCollection();
}
return $this->model->newQuery()
->whereIn('channel_id', $channelIds)
->whereIn('status', TradeConstant::orderMutableStatuses())
->where('expire_at', '>', $now)
->get($columns);
}
}

View File

@@ -157,6 +157,23 @@ class RefundOrderRepository extends BaseRepository
->first($columns);
}
/**
* 锁定指定支付单下会占用可退余额的退款单。
*
* @param string $payNo 支付单号
* @param array<int, int> $statuses 状态列表
* @param array $columns 字段列表
* @return \Illuminate\Database\Eloquent\Collection<int, RefundOrder> 退款单列表
*/
public function listForUpdateByPayNoAndStatuses(string $payNo, array $statuses, array $columns = ['*'])
{
return $this->model->newQuery()
->where('pay_no', $payNo)
->whereIn('status', $statuses)
->lockForUpdate()
->get($columns);
}
/**
* 统计商户下的退款订单数量。
*
@@ -172,4 +189,3 @@ class RefundOrderRepository extends BaseRepository
}