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

@@ -44,43 +44,15 @@ final class AuthConstant
*/
public const CREDENTIAL_STATUS_ENABLED = 1;
/**
* API 签名类型MD5。
*/
public const API_SIGN_TYPE_MD5 = 0;
/**
* API 签名类型SHA256WithRSA。
*/
public const API_SIGN_TYPE_SHA256_WITH_RSA = 1;
/**
* API 签名类型名称MD5。
*/
public const API_SIGN_NAME_MD5 = 'MD5';
/**
* API 签名类型名称:SHA256WithRSA。
* API 签名类型名称RSA。
*/
public const API_SIGN_NAME_SHA256_WITH_RSA = 'SHA256WithRSA';
/**
* API 签名类型归一化名称SHA256WITHRSA。
*/
public const API_SIGN_NORMALIZED_SHA256_WITH_RSA = 'SHA256WITHRSA';
/**
* 获取签名类型映射。
*
* @return array<int, string> 签名类型名称表
*/
public static function signTypeMap(): array
{
return [
self::API_SIGN_TYPE_MD5 => self::API_SIGN_NAME_MD5,
self::API_SIGN_TYPE_SHA256_WITH_RSA => self::API_SIGN_NAME_SHA256_WITH_RSA,
];
}
public const API_SIGN_NAME_RSA = 'RSA';
/**
* 获取接口凭证状态映射。
@@ -108,5 +80,3 @@ final class AuthConstant
];
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace app\common\constant;
/**
* ePay 协议固定值。
*/
final class EpayProtocolConstant
{
/**
* 旧版 ePay 协议版本。
*/
public const VERSION_V1 = 'v1';
/**
* 新版 ePay 协议版本。
*/
public const VERSION_V2 = 'v2';
/**
* 页面跳转提交。
*/
public const SUBMIT_TYPE_PAGE = 'page';
/**
* API 直连提交。
*/
public const SUBMIT_TYPE_API = 'api';
/**
* 电脑浏览器。
*/
public const DEVICE_PC = 'pc';
/**
* 手机浏览器。
*/
public const DEVICE_MOBILE = 'mobile';
/**
* 手机 QQ 内浏览器。
*/
public const DEVICE_QQ = 'qq';
/**
* 微信内浏览器。
*/
public const DEVICE_WECHAT = 'wechat';
/**
* 支付宝客户端。
*/
public const DEVICE_ALIPAY = 'alipay';
/**
* 仅返回支付跳转 URL。
*/
public const DEVICE_JUMP = 'jump';
/**
* V1 支持的设备类型。
*
* @return array<int, string>
*/
public static function v1Devices(): array
{
return [
self::DEVICE_PC,
self::DEVICE_MOBILE,
self::DEVICE_QQ,
self::DEVICE_WECHAT,
self::DEVICE_ALIPAY,
self::DEVICE_JUMP,
];
}
/**
* V2 支持的设备类型。
*
* @return array<int, string>
*/
public static function v2Devices(): array
{
return [
self::DEVICE_PC,
self::DEVICE_MOBILE,
self::DEVICE_QQ,
self::DEVICE_WECHAT,
self::DEVICE_ALIPAY,
];
}
}

View File

@@ -35,6 +35,11 @@ final class EventConstant
*/
public const PAYMENT_PAY_ORDER_TIMEOUT = 'payment.pay_order.timeout';
/**
* 网页流水监听相关配置已变更。
*/
public const PAYMENT_RECEIPT_WATCHER_CONFIG_CHANGED = 'payment.receipt_watcher.config.changed';
/**
* 退款单进入成功态。
*/

View File

@@ -0,0 +1,63 @@
<?php
namespace app\common\constant;
/**
* 商户资金冻结常量。
*
* 冻结记录是提现、退款、通知等高风险动作的统一风控依据。
*/
final class FundFreezeConstant
{
/**
* 支付订单冻结。
*/
public const TYPE_PAY_ORDER = 1;
/**
* 人工指定金额冻结。
*/
public const TYPE_MANUAL_AMOUNT = 2;
/**
* 支付平台服务费预冻结。
*/
public const TYPE_PAY_FEE = 3;
/**
* 冻结中。
*/
public const STATUS_ACTIVE = 1;
/**
* 已解冻。
*/
public const STATUS_RELEASED = 2;
/**
* 获取冻结类型文案。
*
* @return array<int, string> 冻结类型文案
*/
public static function typeMap(): array
{
return [
self::TYPE_PAY_ORDER => '支付订单',
self::TYPE_MANUAL_AMOUNT => '人工指定金额',
self::TYPE_PAY_FEE => '支付平台服务费',
];
}
/**
* 获取冻结状态文案。
*
* @return array<int, string> 冻结状态文案
*/
public static function statusMap(): array
{
return [
self::STATUS_ACTIVE => '冻结中',
self::STATUS_RELEASED => '已解冻',
];
}
}

View File

@@ -43,6 +43,14 @@ final class LedgerConstant
* 转账释放流水。
*/
public const BIZ_TYPE_TRANSFER_RELEASE = 8;
/**
* 风控资金冻结流水。
*/
public const BIZ_TYPE_RISK_FREEZE = 9;
/**
* 风控资金释放流水。
*/
public const BIZ_TYPE_RISK_RELEASE = 10;
/**
* 账务事件的创建动作。
@@ -87,6 +95,8 @@ final class LedgerConstant
self::BIZ_TYPE_TRANSFER_DEDUCT => '转账扣款',
self::BIZ_TYPE_TRANSFER_FEE => '转账手续费',
self::BIZ_TYPE_TRANSFER_RELEASE => '转账释放',
self::BIZ_TYPE_RISK_FREEZE => '风控冻结',
self::BIZ_TYPE_RISK_RELEASE => '风控释放',
];
}
@@ -119,4 +129,3 @@ final class LedgerConstant
}
}

View File

@@ -20,6 +20,16 @@ final class NotifyConstant
*/
public const EVENT_SETTLEMENT_SUCCESS = 'SETTLEMENT_SUCCESS';
/**
* 商户通知成功响应。
*/
public const MERCHANT_SUCCESS_RESPONSE = 'success';
/**
* ePay 通知交易成功状态。
*/
public const EPAY_TRADE_STATUS_SUCCESS = 'TRADE_SUCCESS';
/**
* 异步通知类型。
*/

View File

@@ -0,0 +1,88 @@
<?php
namespace app\common\constant;
/**
* 支付订单后台操作常量。
*
* 操作码是后端与管理前端之间的展示协议,前端只根据本类对应的
* actions 结果渲染按钮,不再自行硬编码订单状态规则。
*/
final class PayOrderActionConstant
{
/**
* 手动补单。
*/
public const MANUAL_SUCCESS = 'manual_success';
/**
* 重新通知商户。
*/
public const RENOTIFY = 'renotify';
/**
* 主动查询上游。
*/
public const ACTIVE_QUERY = 'active_query';
/**
* API 退款。
*/
public const API_REFUND = 'api_refund';
/**
* 手动退款。
*/
public const MANUAL_REFUND = 'manual_refund';
/**
* 冻结订单。
*/
public const FREEZE = 'freeze';
/**
* 解冻订单。
*/
public const UNFREEZE = 'unfreeze';
/**
* 订单未冻结。
*/
public const FREEZE_STATUS_NORMAL = 0;
/**
* 订单已冻结。
*/
public const FREEZE_STATUS_FROZEN = 1;
/**
* 获取后台操作文案。
*
* @return array<string, string> 操作文案映射
*/
public static function actionLabelMap(): array
{
return [
self::MANUAL_SUCCESS => '手动补单',
self::RENOTIFY => '重新通知',
self::ACTIVE_QUERY => '主动查询',
self::API_REFUND => 'API退款',
self::MANUAL_REFUND => '手动退款',
self::FREEZE => '冻结订单',
self::UNFREEZE => '解冻订单',
];
}
/**
* 获取冻结状态文案。
*
* @return array<int, string> 冻结状态文案映射
*/
public static function freezeStatusMap(): array
{
return [
self::FREEZE_STATUS_NORMAL => '正常',
self::FREEZE_STATUS_FROZEN => '已冻结',
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace app\common\constant;
/**
* 支付队列名称常量。
*
* 队列名是生产者与消费者之间的协议,不建议写在业务服务或消费者里。
* 新增支付域队列时优先在本类登记,再分别实现投递服务与消费者。
*/
final class PaymentQueueConstant
{
/**
* 商户异步通知队列名。
*/
public const MERCHANT_NOTIFY = 'merchant_notify';
/**
* 退款上游派发队列名。
*/
public const REFUND_DISPATCH = 'refund_dispatch';
/**
* 转账上游派发队列名。
*/
public const TRANSFER_DISPATCH = 'transfer_dispatch';
/**
* 转账上游查单队列名。
*/
public const TRANSFER_QUERY = 'transfer_query';
/**
* 清算自动入账队列名。
*/
public const SETTLEMENT_COMPLETE = 'settlement_complete';
/**
* 网页流水监听通知队列名。
*/
public const RECEIPT_FLOW_NOTIFY = 'receipt_flow_notify';
}

View File

@@ -15,7 +15,7 @@ final class RouteConstant
public const CHANNEL_TYPE_PLATFORM_COLLECT = 0;
/**
* 商户自通道类型。
* 商户自通道类型。
*/
public const CHANNEL_TYPE_MERCHANT_SELF = 1;
@@ -53,7 +53,7 @@ final class RouteConstant
{
return [
self::CHANNEL_TYPE_PLATFORM_COLLECT => '平台代收',
self::CHANNEL_TYPE_MERCHANT_SELF => '商户自',
self::CHANNEL_TYPE_MERCHANT_SELF => '商户自',
];
}
@@ -86,4 +86,3 @@ final class RouteConstant
}

View File

@@ -54,21 +54,21 @@ final class TradeConstant
public const ORDER_STATUS_TIMEOUT = 5;
/**
* 手续费未处理。
* 平台服务费未处理。
*/
public const FEE_STATUS_NONE = 0;
public const SERVICE_FEE_STATUS_NONE = 0;
/**
* 手续费已冻结。
* 平台服务费已冻结。
*/
public const FEE_STATUS_FROZEN = 1;
public const SERVICE_FEE_STATUS_FROZEN = 1;
/**
* 手续费已扣除。
* 平台服务费已扣除。
*/
public const FEE_STATUS_DEDUCTED = 2;
public const SERVICE_FEE_STATUS_DEDUCTED = 2;
/**
* 手续费已释放。
* 平台服务费已释放。
*/
public const FEE_STATUS_RELEASED = 3;
public const SERVICE_FEE_STATUS_RELEASED = 3;
/**
* 清算状态为空。
@@ -142,17 +142,17 @@ final class TradeConstant
}
/**
* 获取手续费状态映射。
* 获取平台服务费状态映射。
*
* @return array<int, string> 手续费状态名称表
* @return array<int, string> 平台服务费状态名称表
*/
public static function feeStatusMap(): array
public static function serviceFeeStatusMap(): array
{
return [
self::FEE_STATUS_NONE => '无',
self::FEE_STATUS_FROZEN => '冻结',
self::FEE_STATUS_DEDUCTED => '已扣',
self::FEE_STATUS_RELEASED => '已释放',
self::SERVICE_FEE_STATUS_NONE => '无',
self::SERVICE_FEE_STATUS_FROZEN => '冻结',
self::SERVICE_FEE_STATUS_DEDUCTED => '已扣',
self::SERVICE_FEE_STATUS_RELEASED => '已释放',
];
}
@@ -301,5 +301,3 @@ final class TradeConstant
}
}

View File

@@ -11,14 +11,22 @@ final class TransferConstant
* 转账待处理状态。
*/
public const TRANSFER_STATUS_PENDING = 0;
/**
* 转账处理中状态。
*/
public const TRANSFER_STATUS_PROCESSING = 1;
/**
* 转账成功状态。
*/
public const TRANSFER_STATUS_SUCCESS = 1;
public const TRANSFER_STATUS_SUCCESS = 2;
/**
* 转账失败状态。
*/
public const TRANSFER_STATUS_FAILED = 2;
public const TRANSFER_STATUS_FAILED = 3;
/**
* 转账关闭状态。
*/
public const TRANSFER_STATUS_CLOSED = 4;
/**
* 获取转账状态映射。
@@ -29,8 +37,25 @@ final class TransferConstant
{
return [
self::TRANSFER_STATUS_PENDING => '待处理',
self::TRANSFER_STATUS_PROCESSING => '处理中',
self::TRANSFER_STATUS_SUCCESS => '成功',
self::TRANSFER_STATUS_FAILED => '失败',
self::TRANSFER_STATUS_CLOSED => '关闭',
];
}
/**
* 判断是否为转账终态。
*
* @param int $status 转账状态
* @return bool 是否终态
*/
public static function isTerminalStatus(int $status): bool
{
return in_array($status, [
self::TRANSFER_STATUS_SUCCESS,
self::TRANSFER_STATUS_FAILED,
self::TRANSFER_STATUS_CLOSED,
], true);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace app\common\interface;
use support\Request;
/**
* HTTP 通道级通知定位接口。
*
* 适用于 SmsForwarder 这类通过 /api/pay/{chanId}/notify 进入的 HTTP 通知。
* 通知内容通常不携带本系统支付单号,服务层先调用此方法定位 pay_no
* 定位成功后继续把同一个 Request 交给插件 notify() 走标准回调流程。
*/
interface ChannelNotifyInterface
{
/**
* 根据 HTTP 通道级通知定位支付单。
*
* @param Request $request 请求对象
* @return array{pay_no:string} 定位结果
*/
public function channelNotify(Request $request): array;
}

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace app\common\interface;
/**
* 非 HTTP 通道级数组载荷通知接口。
*
* 适用于 receipt_watcher 这类队列入口:外部监听工具已经把平台流水归一化为数组,
* 并投递到 Redis 队列。服务层先调用 channelNotifyPayload() 定位 pay_no再调用
* notifyPayload() 获取标准插件通知结果,后续复用订单状态推进、回调日志和商户通知链路。
*
* 该接口不接收 Request也不用于 /api/pay/{chanId}/notify 这类 HTTP 入口。
*/
interface ChannelNotifyPayloadInterface
{
/**
* 根据数组载荷定位支付单。
*
* @param array<string, mixed> $payload 已归一化的通道通知载荷
* @return array{pay_no:string} 定位结果
*/
public function channelNotifyPayload(array $payload): array;
/**
* 解析数组载荷并返回标准插件通知结果。
*
* @param array<string, mixed> $payload 已归一化的通道通知载荷
* @return array<string, mixed> 插件回调结果
*/
public function notifyPayload(array $payload): array;
}

View File

@@ -24,18 +24,25 @@ interface PaymentInterface
* 发起支付下单。
*
* 插件必须返回系统标准结构,服务层会严格校验后写入支付单 `ext_json.presentation`
* - `pay_product`:支付产品或上游支付方式,例如 `alipay`、`wxpay`、`alipay_h5`
* - `pay_action`:支付动作,例如 `jump`、`qrcode`、`html`、`jsapi`
* - `pay_params.type`:收银台承接类型,支持 `jump`、`web`、`h5`、`qrcode`、`html`、`jsapi`、`urlscheme`、`mini`、`pos`、`transfer`、`json`、`error`
* - `chan_order_no`:渠道订单号,必须返回
* - `pay_page`:收银台承接页类型,支持 `qrcode`、`html`、`jump`、`jsapi`、`urlscheme`、`error`、`ok`、`page`
* - `pay_type`:支付方式,例如 `alipay`、`wxpay`、`unionpay`
* - `pay_product`:插件调用的支付产品,例如 `scan`、`h5`、`jsapi`;没有更细产品时可与 `pay_type` 相同
* - `pay_action`:插件调用动作或方法,例如 `qrcode`、`jump`、`html`、`jsapi`
* - `pay_params`:插件调用支付产品返回的支付参数,平台不向其中补写展示字段
* - `chan_order_no`:渠道订单号;`error`、`html`、`ok` 场景允许为空
* - `chan_trade_no`:渠道交易号,可选;未生成时返回空字符串
* - `ext_json`:插件私有轻量信息,可选;原始响应不要塞入支付单扩展
*
* `pay_params` 必须带上对应 `type` 的必要载荷
* - 跳转类:`redirect_url` / `payurl` / `mweb_url`
* - 二维码类:`qrcode_text` / `qrcode_data` / `qrcode_url`
* - 表单类:`html` 或 `action`
* - JSAPI / URL Scheme / 小程序:对应拉起参数或跳转参数
* `pay_params` 按对应 `pay_page` 放入承接页需要的固定字段
* - `qrcode``qrcode`,二维码文字内容。
* - `html``html`,插件返回的 HTML 代码。
* - `jump``url`,需要跳转的支付地址。
* - `jsapi`:微信、支付宝官方 JSAPI 拉起参数。
* - `urlscheme``urlscheme`,需要打开的应用或小程序地址。
* - `error``error_msg`,承接页展示的错误信息。
* - `ok`:无需固定支付参数,承接页会优先使用订单 `return_url` 返回商户。
* - `page``_page`,已在收银台白名单注册的组件名,其他字段由该组件消费。
*
* 插件可把上游原始响应放入 `pay_params.raw` 方便排障,这是推荐约定,不由平台强制校验。
*
* @param array $order 订单参数
* @return array 下单结果
@@ -52,7 +59,6 @@ interface PaymentInterface
* - `channel_status`:渠道原始状态,可选
* - `message`:查询说明,可选
* - `paid_at` / `failed_at`:终态时间,可选
* - `ext_json`:插件私有轻量补充信息,可选
*
* @param array $order 订单参数
* @return array 查询结果
@@ -87,8 +93,6 @@ interface PaymentInterface
* - `message`:回调处理说明,可选
* - `channel_error_code` / `channel_error_msg`:渠道失败原因,可选
* - `paid_at` / `failed_at`:支付成功或失败时间,可选
* - `fee_actual_amount`:实际手续费,单位分,可选
* - `ext_json`:插件私有的轻量补充信息,可选;原始回调和解析结果会进入回调日志,不要塞进支付单扩展
*
* 插件在验签失败、报文非法或关键字段缺失时,应直接抛出 `PaymentException`。
* 只有在回调可信时,才返回标准结果数组。

View File

@@ -0,0 +1,31 @@
<?php
namespace app\common\interface;
use Throwable;
/**
* 队列任务接口。
*
* Consumer 只关心消息消费协议,具体业务处理统一交给 Job便于后续按任务维度管理、
* 测试、统计和扩展失败处理。
*/
interface QueueJobInterface
{
/**
* 处理队列消息。
*
* @param array<string, mixed> $data 队列消息
* @return void
*/
public function handle(array $data): void;
/**
* 处理消费失败。
*
* @param Throwable $exception 异常
* @param array<string, mixed> $package 原始队列包
* @return void
*/
public function failed(Throwable $exception, array $package): void;
}

View File

@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace app\common\interface;
/**
* 支付插件的可选转账能力接口。
*
* 支付插件默认不要求支持转账;只有显式实现本接口,并在插件元信息中声明
* transfer_types 的插件,才会被转账链路选中。
*/
interface TransferPluginInterface
{
/**
* 发起转账。
*
* @param array<string, mixed> $order 转账订单参数
* @return array<string, mixed> 转账结果
*/
public function transfer(array $order): array;
/**
* 查询转账状态。
*
* @param array<string, mixed> $order 转账订单参数
* @return array<string, mixed> 查询结果
*/
public function transferQuery(array $order): array;
/**
* 查询转账余额。
*
* @param array<string, mixed> $order 查询参数
* @return array<string, mixed> 余额结果
*/
public function transferBalance(array $order): array;
}

View File

@@ -0,0 +1,780 @@
<?php
declare(strict_types=1);
namespace app\common\payment;
use app\common\base\BasePayment;
use app\common\constant\PaymentPluginStatusConstant;
use app\common\interface\ChannelNotifyInterface;
use app\common\interface\PaymentInterface;
use app\common\interface\PayPluginInterface;
use app\common\util\FormatHelper;
use app\exception\PaymentException;
use app\model\payment\PayOrder;
use app\repository\payment\trade\PayOrderRepository;
use support\Cache;
use support\Db;
use support\Request;
use support\Response;
/**
* 支付宝个人收款监听插件。
*
* 面向 SmsForwarder 手机通知栏监听场景,不对接支付宝官方 API。
*
* 典型流程:
* 1. pay() 返回个人支付宝收款码,并按配置分配“金额变动”或“付款备注”识别信息。
* 2. /api/pay/{chanId}/notify 先调用 channelNotify() 定位 pay_no。
* 3. 服务层确认支付单后再调用 notify(),由插件恢复原始金额并返回标准支付成功结果。
*
* 金额口径:
* - 变动后的金额只用于通知反查订单,不作为业务单统计金额。
* - 通知确认后会把支付单金额恢复到原始金额,并把实际付款金额写入 ext_json。
*/
class AlipayReceiptPayment extends BasePayment implements PaymentInterface, PayPluginInterface, ChannelNotifyInterface
{
/**
* 构造方法。
*
* 支付单读取统一走仓库,插件只保留收款识别和通知解析逻辑。
*
* @param PayOrderRepository $payOrderRepository 支付单仓库
*/
public function __construct(
private readonly PayOrderRepository $payOrderRepository
) {
}
/**
* 插件元信息和后台配置表单。
*
* 这里配置的是个人收款监听所需信息匹配模式、识别有效期、金额偏移、SmsForwarder 密钥和收款码。
*
* @var array<string, mixed>
*/
protected array $paymentInfo = [
'code' => 'alipay_receipt',
'name' => '支付宝个人收款监听',
'author' => 'MPAY',
'version' => '1.0.0',
'pay_types' => ['alipay'],
'transfer_types' => [],
'config_schema' => [
[
'type' => 'radio',
'field' => 'receipt_match_mode',
'title' => '订单匹配模式',
'value' => 'amount',
'options' => [
['label' => '金额变动', 'value' => 'amount'],
['label' => '付款备注', 'value' => 'remark'],
],
'validate' => [
['required' => true, 'message' => '订单匹配模式不能为空'],
],
],
[
'type' => 'inputNumber',
'field' => 'receipt_valid_seconds',
'title' => '识别有效期(秒)',
'value' => 300,
'props' => [
'min' => 60,
'max' => 1800,
'step' => 60,
],
],
[
'type' => 'inputNumber',
'field' => 'amount_offset_max',
'title' => '最大金额偏移(分)',
'value' => 99,
'props' => [
'min' => 0,
'max' => 99,
'step' => 1,
],
],
[
'type' => 'input',
'field' => 'sms_forwarder_secret',
'title' => 'SmsForwarder密钥',
'value' => '',
'props' => [
'placeholder' => '用于校验 SmsForwarder sign',
'type' => 'password',
],
'validate' => [
['required' => true, 'message' => 'SmsForwarder密钥不能为空'],
],
],
[
'type' => 'inputNumber',
'field' => 'sms_forwarder_time_tolerance',
'title' => '签名时间容差(秒)',
'value' => 300,
'props' => [
'min' => 30,
'max' => 1800,
'step' => 30,
],
],
[
'type' => 'textarea',
'field' => 'receipt_qrcode_content',
'title' => '支付宝收款码内容',
'value' => '',
'props' => [
'placeholder' => '可填写支付宝收款码解码后的内容,优先用于二维码承接页',
'rows' => 4,
],
],
[
'type' => 'input',
'field' => 'receipt_qrcode_image',
'title' => '支付宝收款码图片',
'value' => '',
'props' => [
'placeholder' => '收款码图片 URL未配置收款码内容时使用',
],
],
],
];
/**
* 发起个人收款。
*
* 不调用支付宝官方接口,只准备收银台二维码承接参数。
* 金额模式会分配一个有效期内唯一金额;备注模式会分配一个 4 位备注码缓存。
*
* @param array<string, mixed> $order 标准插件下单参数
* @return array<string, mixed>
*/
public function pay(array $order): array
{
$payNo = (string) $order['pay_no'];
$mode = $this->receiptMatchMode();
$prepared = $mode === 'remark'
? $this->prepareRemarkReceipt($payNo)
: $this->prepareAmountReceipt($payNo);
$qrcode = trim((string) $this->getConfig('receipt_qrcode_content', ''));
$image = trim((string) $this->getConfig('receipt_qrcode_image', ''));
if ($qrcode === '' && $image === '') {
throw new PaymentException('支付宝个人收款插件未配置收款码', 40200);
}
$params = [
'_page' => 'receiptQrcode',
'amount' => FormatHelper::amount((int) $prepared['pay_amount']),
'original_amount' => FormatHelper::amount((int) $prepared['original_amount']),
'receipt_match_mode' => $mode,
'receipt_valid_seconds' => $this->receiptValidSeconds(),
'expire_at' => (string) $prepared['expire_at'],
'expire_at_timestamp' => (int) strtotime((string) $prepared['expire_at']),
'description' => $mode === 'remark'
? '请使用支付宝扫码,并在付款备注中填写识别码。'
: '请使用支付宝扫码,并按页面金额完成付款。',
];
if ($mode === 'remark') {
$params['remark_code'] = (string) $prepared['remark_code'];
$params['tips'] = '付款备注:' . (string) $prepared['remark_code'];
}
if ($qrcode !== '') {
$params['qrcode'] = $qrcode;
}
if ($image !== '') {
$params['qrcode_image'] = $image;
}
return $this->payResult('page', $params, $payNo);
}
/**
* 通道级通知定位支付单。
*
* 这里是第一阶段,只根据 SmsForwarder 内容确认 pay_no不做支付成功处理。
* 后续验签、幂等、订单状态流转仍由支付服务层继续调用 notify() 完成。
*
* @param Request $request 请求对象
* @return array{pay_no:string}
*/
public function channelNotify(Request $request): array
{
$payload = $this->verifiedSmsForwarderPayload($request);
return ['pay_no' => $this->locatePayNo($payload)];
}
/**
* 主动查单不适用于通知栏监听,保持支付中。
*
* @param array<string, mixed> $order 订单参数
* @return array<string, mixed>
*/
public function query(array $order): array
{
return [
'success' => true,
'status' => PaymentPluginStatusConstant::PENDING,
'channel_order_no' => (string) ($order['channel_order_no'] ?? $order['pay_no'] ?? ''),
'channel_trade_no' => (string) ($order['channel_trade_no'] ?? $order['pay_no'] ?? ''),
'message' => '个人收款监听通道等待 SmsForwarder 通知',
];
}
/**
* 个人收款无上游关单接口。
*
* @param array<string, mixed> $order 订单参数
* @return array<string, mixed>
*/
public function close(array $order): array
{
return [
'success' => true,
'msg' => '个人收款监听通道无需上游关单',
];
}
/**
* 个人收款不支持接口退款。
*
* @param array<string, mixed> $order 订单参数
* @return array<string, mixed>
*/
public function refund(array $order): array
{
throw new PaymentException('支付宝个人收款监听不支持接口退款', 40200);
}
/**
* 解析并校验 SmsForwarder 通知。
*
* 这是第二阶段:服务层确认 pay_no 后调用。插件再次校验通知并恢复原始金额,
* 然后返回统一的支付成功结果给核心支付流程。
*
* @param Request $request 回调请求
* @return array<string, mixed>
*/
public function notify(Request $request): array
{
$payload = $this->verifiedSmsForwarderPayload($request);
$content = (string) $payload['content'];
$tradeNo = $this->channelTradeNo($payload);
$payNo = $this->locatePayNo($payload);
$notifiedAmount = $this->receiptMatchMode() === 'amount'
? $this->amountFromContent($content)
: null;
$this->restoreOriginalPayAmount($payNo, $payload, $tradeNo, $notifiedAmount);
return [
'status' => PaymentPluginStatusConstant::SUCCESS,
'pay_no' => $payNo,
'message' => mb_strcut(preg_replace('/\s+/', ' ', $content) ?? $content, 0, 180, 'UTF-8'),
'channel_order_no' => $tradeNo,
'channel_trade_no' => $tradeNo,
'channel_status' => 'sms_forwarder_received',
'paid_at' => $this->paidAtFromPayload($payload),
];
}
/**
* 返回监听工具要求的成功应答。
*/
public function notifySuccess(): string|Response
{
return 'success';
}
/**
* 返回监听工具要求的失败应答。
*/
public function notifyFail(): string|Response
{
return 'fail';
}
/**
* 包装个人收款承接页返回值。
*
* @param string $payPage 承接页类型
* @param array<string, mixed> $params 承接参数
* @param string $payNo 支付单号
* @return array<string, mixed>
*/
private function payResult(string $payPage, array $params, string $payNo): array
{
return [
'pay_page' => $payPage,
'pay_type' => 'alipay',
'pay_product' => 'receipt',
'pay_action' => 'sms_forwarder',
'pay_params' => $params,
'chan_order_no' => $payNo,
'chan_trade_no' => '',
];
}
/**
* 读取后台配置的订单匹配模式。
*
* @return string 匹配模式
*/
private function receiptMatchMode(): string
{
return (string) $this->getConfig('receipt_match_mode', 'amount') === 'remark' ? 'remark' : 'amount';
}
/**
* 读取识别有效期,最低 60 秒。
*
* @return int 有效期秒数
*/
private function receiptValidSeconds(): int
{
return max(60, (int) $this->getConfig('receipt_valid_seconds', 300));
}
/**
* 读取最大金额偏移,单位分。
*
* @return int 最大金额偏移,单位分
*/
private function amountOffsetMax(): int
{
return min(99, max(0, (int) $this->getConfig('amount_offset_max', 99)));
}
/**
* 准备金额变动收款参数。
*
* 在同一通道锁内扫描有效期内待支付订单,始终选择最小可用偏移金额。
* 例如 10.01 已过期时,会重新使用 10.01,而不是继续累加到 10.04。
*
* @param string $payNo 支付单号
* @return array<string, mixed>
*/
private function prepareAmountReceipt(string $payNo): array
{
return $this->withChannelLock(function () use ($payNo): array {
return Db::transaction(function () use ($payNo): array {
$payOrder = $this->lockedPayOrder($payNo);
$originalAmount = $this->originalAmount($payOrder);
$expireAt = $this->expireAt();
$usedAmounts = $this->payOrderRepository->listUsedReceiptAmounts(
[(int) $this->getConfig('channel_id')],
$payNo,
date('Y-m-d H:i:s')
);
$used = array_fill_keys($usedAmounts, true);
$receiptAmount = 0;
for ($offset = 0; $offset <= $this->amountOffsetMax(); $offset++) {
$candidate = $originalAmount + $offset;
if (!isset($used[$candidate])) {
$receiptAmount = $candidate;
break;
}
}
if ($receiptAmount <= 0) {
throw new PaymentException('当前通道可用金额偏移已用尽', 40200, [
'pay_no' => $payNo,
'channel_id' => (int) $this->getConfig('channel_id'),
]);
}
$this->persistReceiptMeta($payOrder, [
'mode' => 'amount',
'original_amount' => $originalAmount,
'receipt_amount' => $receiptAmount,
'offset_amount' => $receiptAmount - $originalAmount,
'expire_at' => $expireAt,
]);
return [
'original_amount' => $originalAmount,
'pay_amount' => $receiptAmount,
'expire_at' => $expireAt,
];
});
});
}
/**
* 准备备注收款参数。
*
* 为当前支付单分配 4 位备注码并写入缓存,通知时通过备注码反查 pay_no。
*
* @param string $payNo 支付单号
* @return array<string, mixed>
*/
private function prepareRemarkReceipt(string $payNo): array
{
return $this->withChannelLock(function () use ($payNo): array {
return Db::transaction(function () use ($payNo): array {
$payOrder = $this->lockedPayOrder($payNo);
$originalAmount = $this->originalAmount($payOrder);
$expireAt = $this->expireAt();
$remarkCode = $this->allocateRemarkCode($payNo);
$this->persistReceiptMeta($payOrder, [
'mode' => 'remark',
'original_amount' => $originalAmount,
'receipt_amount' => $originalAmount,
'remark_code' => $remarkCode,
'expire_at' => $expireAt,
]);
return [
'original_amount' => $originalAmount,
'pay_amount' => $originalAmount,
'remark_code' => $remarkCode,
'expire_at' => $expireAt,
];
});
});
}
/**
* 加锁读取支付单。
*
* @param string $payNo 支付单号
* @return PayOrder
*/
private function lockedPayOrder(string $payNo): PayOrder
{
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
if (!$payOrder) {
throw new PaymentException('支付单不存在', 40402, ['pay_no' => $payNo]);
}
return $payOrder;
}
/**
* 写入个人收款元数据。
*
* 金额模式会临时改写 pay_amount 作为识别金额,原始金额保存在 ext_json.personal_receipt。
*
* @param PayOrder $payOrder 支付单
* @param array<string, mixed> $meta 收款元数据
* @return void
*/
private function persistReceiptMeta(PayOrder $payOrder, array $meta): void
{
$payOrder->pay_amount = (int) $meta['receipt_amount'];
$payOrder->expire_at = (string) $meta['expire_at'];
$extJson = (array) ($payOrder->ext_json ?? []);
$extJson['personal_receipt'] = $meta;
$payOrder->ext_json = $extJson;
$payOrder->save();
}
/**
* 获取原始订单金额。
*
* 二次发起或刷新承接页时,优先从 ext_json 读取,避免把上一次偏移金额当成原始金额。
*
* @param PayOrder $payOrder 支付单
* @return int 原始订单金额,单位分
*/
private function originalAmount(PayOrder $payOrder): int
{
$extJson = (array) ($payOrder->ext_json ?? []);
$receiptMeta = (array) ($extJson['personal_receipt'] ?? []);
$originalAmount = (int) ($receiptMeta['original_amount'] ?? 0);
return $originalAmount > 0 ? $originalAmount : (int) $payOrder->pay_amount;
}
/**
* 通知识别完成后恢复支付单金额,避免变动金额进入业务单统计。
*
* @param string $payNo 支付单号
* @param array<string, mixed> $payload 通知载荷
* @param string $tradeNo 渠道交易号
* @param int|null $notifiedAmount 通知中的实际付款金额
* @return void
*/
private function restoreOriginalPayAmount(string $payNo, array $payload, string $tradeNo, ?int $notifiedAmount): void
{
Db::transaction(function () use ($payNo, $payload, $tradeNo, $notifiedAmount): void {
$payOrder = $this->lockedPayOrder($payNo);
$extJson = (array) ($payOrder->ext_json ?? []);
$receiptMeta = (array) ($extJson['personal_receipt'] ?? []);
$originalAmount = (int) ($receiptMeta['original_amount'] ?? 0);
if ($originalAmount > 0) {
$payOrder->pay_amount = $originalAmount;
}
$receiptMeta['notified_at'] = $this->paidAtFromPayload($payload) ?? date('Y-m-d H:i:s');
$receiptMeta['channel_trade_no'] = $tradeNo;
if ($notifiedAmount !== null) {
$receiptMeta['notified_amount'] = $notifiedAmount;
}
$extJson['personal_receipt'] = $receiptMeta;
$payOrder->ext_json = $extJson;
$payOrder->save();
});
}
/**
* 申请 4 位备注码。
*
* 备注码缓存有效期与订单识别有效期一致,同一通道下短时间内不重复。
*
* @param string $payNo 支付单号
* @return string 备注码
*/
private function allocateRemarkCode(string $payNo): string
{
for ($i = 0; $i < 30; $i++) {
$code = (string) random_int(1000, 9999);
$key = $this->remarkCacheKey($code);
if (!Cache::has($key)) {
Cache::set($key, $payNo, $this->receiptValidSeconds());
return $code;
}
}
throw new PaymentException('付款备注码已用尽,请稍后重试', 40200);
}
/**
* 对同一通道的识别信息分配加锁。
*
* 防止并发发起支付时分配到相同金额或相同备注码。
*
* @param callable $callback 回调
* @return mixed
*/
private function withChannelLock(callable $callback): mixed
{
$key = 'mpay_personal_receipt_lock_' . (int) $this->getConfig('channel_id');
$token = bin2hex(random_bytes(8));
for ($i = 0; $i < 20; $i++) {
if (!Cache::has($key)) {
Cache::set($key, $token, 10);
}
if ((string) Cache::get($key) === $token) {
try {
return $callback();
} finally {
if ((string) Cache::get($key) === $token) {
Cache::delete($key);
}
}
}
usleep(50000);
}
throw new PaymentException('当前通道正在分配收款标识,请稍后重试', 40200);
}
/**
* 计算本次个人收款识别的过期时间。
*
* @return string 过期时间
*/
private function expireAt(): string
{
return date('Y-m-d H:i:s', time() + $this->receiptValidSeconds());
}
/**
* 校验并读取 SmsForwarder 载荷。
*
* 签名规则按 SmsForwarder 文档:使用 timestamp、密钥和 HMAC-SHA256 校验 sign。
*
* @param Request $request 请求对象
* @return array<string, mixed>
*/
private function verifiedSmsForwarderPayload(Request $request): array
{
$payload = $this->requestPayload($request);
$timestamp = trim((string) ($payload['timestamp'] ?? ''));
$sign = trim((string) ($payload['sign'] ?? ''));
$secret = (string) $this->getConfig('sms_forwarder_secret', '');
if ($timestamp === '' || $sign === '' || $secret === '') {
throw new PaymentException('SmsForwarder 通知签名参数不完整', 40200);
}
$timestampSeconds = (int) floor(((int) $timestamp) / 1000);
$tolerance = max(30, (int) $this->getConfig('sms_forwarder_time_tolerance', 300));
if ($timestampSeconds <= 0 || abs(time() - $timestampSeconds) > $tolerance) {
throw new PaymentException('SmsForwarder 通知时间已失效', 40200);
}
$expected = base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true));
if (!hash_equals($expected, rawurldecode($sign))) {
throw new PaymentException('SmsForwarder 通知签名校验失败', 40200);
}
if (trim((string) ($payload['content'] ?? '')) === '') {
throw new PaymentException('SmsForwarder 通知内容为空', 40200);
}
return $payload;
}
/**
* 读取请求载荷。
*
* Webman Request 已统一处理 query、form 和 JSON 请求体,这里直接使用 all()。
*
* @param Request $request 请求对象
* @return array<string, mixed>
*/
private function requestPayload(Request $request): array
{
return (array) $request->all();
}
/**
* 金额模式下通过通知金额定位唯一支付单。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 支付单号
*/
private function locatePayNoByAmount(array $payload): string
{
$amount = $this->amountFromContent((string) $payload['content']);
$orders = $this->payOrderRepository->listMutableReceiptOrdersByAmount(
[(int) $this->getConfig('channel_id')],
$amount,
0,
date('Y-m-d H:i:s'),
['pay_no']
);
if ($orders->count() !== 1) {
throw new PaymentException('金额通知未匹配到唯一支付单', 40200, [
'amount' => FormatHelper::amount($amount),
'matched_count' => $orders->count(),
]);
}
return (string) $orders->first()->pay_no;
}
/**
* 根据配置选择金额匹配或备注匹配。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 支付单号
*/
private function locatePayNo(array $payload): string
{
return $this->receiptMatchMode() === 'remark'
? $this->locatePayNoByRemark($payload)
: $this->locatePayNoByAmount($payload);
}
/**
* 备注模式下通过缓存中的 4 位备注码定位支付单。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 支付单号
*/
private function locatePayNoByRemark(array $payload): string
{
$remarkCode = $this->remarkFromContent((string) $payload['content']);
$payNo = (string) Cache::get($this->remarkCacheKey($remarkCode), '');
if ($payNo === '') {
throw new PaymentException('付款备注已失效或不存在', 40200, ['remark_code' => $remarkCode]);
}
return $payNo;
}
/**
* 从通知文本中提取收款金额。
*
* @param string $content 通知内容
* @return int 金额,单位分
*/
private function amountFromContent(string $content): int
{
if (preg_match('/(?:收款|到账|收钱|付款|支付|转账)[^\d]{0,20}(\d+(?:\.\d{1,2})?)\s*元/u', $content, $matches) !== 1
&& preg_match('/(?<!\d)(\d+(?:\.\d{1,2})?)\s*元/u', $content, $matches) !== 1
) {
throw new PaymentException('通知内容未识别到收款金额', 40200);
}
return $this->moneyToCents((string) $matches[1]);
}
/**
* 从通知文本中提取 4 位付款备注码。
*
* @param string $content 通知内容
* @return string 备注码
*/
private function remarkFromContent(string $content): string
{
if (preg_match('/(?:备注|留言|附言|付款备注|收款备注)[:\s]*([0-9]{4})/u', $content, $matches) !== 1) {
throw new PaymentException('通知内容未识别到付款备注', 40200);
}
return (string) $matches[1];
}
/**
* 将金额文本转换为分。
*
* @param string $money 金额文本
* @return int 金额,单位分
*/
private function moneyToCents(string $money): int
{
if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
throw new PaymentException('通知金额格式不合法', 40200, ['money' => $money]);
}
[$integer, $fraction] = array_pad(explode('.', $money, 2), 2, '');
return (int) $integer * 100 + (int) str_pad(substr($fraction, 0, 2), 2, '0');
}
/**
* 生成备注码缓存键。
*
* @param string $code 备注码
* @return string 缓存键
*/
private function remarkCacheKey(string $code): string
{
return 'mpay_personal_receipt_remark_' . (int) $this->getConfig('channel_id') . '_' . $code;
}
/**
* 为 SmsForwarder 通知生成稳定的渠道交易号。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 渠道交易号
*/
private function channelTradeNo(array $payload): string
{
return 'SF' . substr(hash('sha256', (string) ($payload['from'] ?? '') . '|' . (string) $payload['timestamp'] . '|' . (string) $payload['content']), 0, 30);
}
/**
* 从 SmsForwarder 毫秒时间戳提取支付时间。
*
* @param array<string, mixed> $payload 通知载荷
* @return string|null 支付时间
*/
private function paidAtFromPayload(array $payload): ?string
{
$timestamp = (int) ($payload['timestamp'] ?? 0);
return $timestamp > 0 ? date('Y-m-d H:i:s', (int) floor($timestamp / 1000)) : null;
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,780 @@
<?php
declare(strict_types=1);
namespace app\common\payment;
use app\common\base\BasePayment;
use app\common\constant\PaymentPluginStatusConstant;
use app\common\interface\ChannelNotifyInterface;
use app\common\interface\PaymentInterface;
use app\common\interface\PayPluginInterface;
use app\common\util\FormatHelper;
use app\exception\PaymentException;
use app\model\payment\PayOrder;
use app\repository\payment\trade\PayOrderRepository;
use support\Cache;
use support\Db;
use support\Request;
use support\Response;
/**
* 微信个人收款监听插件。
*
* 面向 SmsForwarder 手机通知栏监听场景,不对接微信官方支付 API。
*
* 典型流程:
* 1. pay() 返回个人微信收款码,并按配置分配“金额变动”或“付款备注”识别信息。
* 2. /api/pay/{chanId}/notify 先调用 channelNotify() 定位 pay_no。
* 3. 服务层确认支付单后再调用 notify(),由插件恢复原始金额并返回标准支付成功结果。
*
* 金额口径:
* - 变动后的金额只用于通知反查订单,不作为业务单统计金额。
* - 通知确认后会把支付单金额恢复到原始金额,并把实际付款金额写入 ext_json。
*/
class WechatReceiptPayment extends BasePayment implements PaymentInterface, PayPluginInterface, ChannelNotifyInterface
{
/**
* 构造方法。
*
* 支付单读取统一走仓库,插件只保留收款识别和通知解析逻辑。
*
* @param PayOrderRepository $payOrderRepository 支付单仓库
*/
public function __construct(
private readonly PayOrderRepository $payOrderRepository
) {
}
/**
* 插件元信息和后台配置表单。
*
* 这里配置的是个人收款监听所需信息匹配模式、识别有效期、金额偏移、SmsForwarder 密钥和收款码。
*
* @var array<string, mixed>
*/
protected array $paymentInfo = [
'code' => 'wxpay_receipt',
'name' => '微信个人收款监听',
'author' => 'MPAY',
'version' => '1.0.0',
'pay_types' => ['wxpay'],
'transfer_types' => [],
'config_schema' => [
[
'type' => 'radio',
'field' => 'receipt_match_mode',
'title' => '订单匹配模式',
'value' => 'amount',
'options' => [
['label' => '金额变动', 'value' => 'amount'],
['label' => '付款备注', 'value' => 'remark'],
],
'validate' => [
['required' => true, 'message' => '订单匹配模式不能为空'],
],
],
[
'type' => 'inputNumber',
'field' => 'receipt_valid_seconds',
'title' => '识别有效期(秒)',
'value' => 300,
'props' => [
'min' => 60,
'max' => 1800,
'step' => 60,
],
],
[
'type' => 'inputNumber',
'field' => 'amount_offset_max',
'title' => '最大金额偏移(分)',
'value' => 99,
'props' => [
'min' => 0,
'max' => 99,
'step' => 1,
],
],
[
'type' => 'input',
'field' => 'sms_forwarder_secret',
'title' => 'SmsForwarder密钥',
'value' => '',
'props' => [
'placeholder' => '用于校验 SmsForwarder sign',
'type' => 'password',
],
'validate' => [
['required' => true, 'message' => 'SmsForwarder密钥不能为空'],
],
],
[
'type' => 'inputNumber',
'field' => 'sms_forwarder_time_tolerance',
'title' => '签名时间容差(秒)',
'value' => 300,
'props' => [
'min' => 30,
'max' => 1800,
'step' => 30,
],
],
[
'type' => 'textarea',
'field' => 'receipt_qrcode_content',
'title' => '微信收款码内容',
'value' => '',
'props' => [
'placeholder' => '可填写微信收款码解码后的内容,优先用于二维码承接页',
'rows' => 4,
],
],
[
'type' => 'input',
'field' => 'receipt_qrcode_image',
'title' => '微信收款码图片',
'value' => '',
'props' => [
'placeholder' => '收款码图片 URL未配置收款码内容时使用',
],
],
],
];
/**
* 发起个人收款。
*
* 不调用微信官方支付接口,只准备收银台二维码承接参数。
* 金额模式会分配一个有效期内唯一金额;备注模式会分配一个 4 位备注码缓存。
*
* @param array<string, mixed> $order 标准插件下单参数
* @return array<string, mixed>
*/
public function pay(array $order): array
{
$payNo = (string) $order['pay_no'];
$mode = $this->receiptMatchMode();
$prepared = $mode === 'remark'
? $this->prepareRemarkReceipt($payNo)
: $this->prepareAmountReceipt($payNo);
$qrcode = trim((string) $this->getConfig('receipt_qrcode_content', ''));
$image = trim((string) $this->getConfig('receipt_qrcode_image', ''));
if ($qrcode === '' && $image === '') {
throw new PaymentException('微信个人收款插件未配置收款码', 40200);
}
$params = [
'_page' => 'receiptQrcode',
'amount' => FormatHelper::amount((int) $prepared['pay_amount']),
'original_amount' => FormatHelper::amount((int) $prepared['original_amount']),
'receipt_match_mode' => $mode,
'receipt_valid_seconds' => $this->receiptValidSeconds(),
'expire_at' => (string) $prepared['expire_at'],
'expire_at_timestamp' => (int) strtotime((string) $prepared['expire_at']),
'description' => $mode === 'remark'
? '请使用微信扫码,并在付款备注中填写识别码。'
: '请使用微信扫码,并按页面金额完成付款。',
];
if ($mode === 'remark') {
$params['remark_code'] = (string) $prepared['remark_code'];
$params['tips'] = '付款备注:' . (string) $prepared['remark_code'];
}
if ($qrcode !== '') {
$params['qrcode'] = $qrcode;
}
if ($image !== '') {
$params['qrcode_image'] = $image;
}
return $this->payResult('page', $params, $payNo);
}
/**
* 通道级通知定位支付单。
*
* 这里是第一阶段,只根据 SmsForwarder 内容确认 pay_no不做支付成功处理。
* 后续验签、幂等、订单状态流转仍由支付服务层继续调用 notify() 完成。
*
* @param Request $request 请求对象
* @return array{pay_no:string}
*/
public function channelNotify(Request $request): array
{
$payload = $this->verifiedSmsForwarderPayload($request);
return ['pay_no' => $this->locatePayNo($payload)];
}
/**
* 主动查单不适用于通知栏监听,保持支付中。
*
* @param array<string, mixed> $order 订单参数
* @return array<string, mixed>
*/
public function query(array $order): array
{
return [
'success' => true,
'status' => PaymentPluginStatusConstant::PENDING,
'channel_order_no' => (string) ($order['channel_order_no'] ?? $order['pay_no'] ?? ''),
'channel_trade_no' => (string) ($order['channel_trade_no'] ?? $order['pay_no'] ?? ''),
'message' => '个人收款监听通道等待 SmsForwarder 通知',
];
}
/**
* 个人收款无上游关单接口。
*
* @param array<string, mixed> $order 订单参数
* @return array<string, mixed>
*/
public function close(array $order): array
{
return [
'success' => true,
'msg' => '个人收款监听通道无需上游关单',
];
}
/**
* 个人收款不支持接口退款。
*
* @param array<string, mixed> $order 订单参数
* @return array<string, mixed>
*/
public function refund(array $order): array
{
throw new PaymentException('微信个人收款监听不支持接口退款', 40200);
}
/**
* 解析并校验 SmsForwarder 通知。
*
* 这是第二阶段:服务层确认 pay_no 后调用。插件再次校验通知并恢复原始金额,
* 然后返回统一的支付成功结果给核心支付流程。
*
* @param Request $request 回调请求
* @return array<string, mixed>
*/
public function notify(Request $request): array
{
$payload = $this->verifiedSmsForwarderPayload($request);
$content = (string) $payload['content'];
$tradeNo = $this->channelTradeNo($payload);
$payNo = $this->locatePayNo($payload);
$notifiedAmount = $this->receiptMatchMode() === 'amount'
? $this->amountFromContent($content)
: null;
$this->restoreOriginalPayAmount($payNo, $payload, $tradeNo, $notifiedAmount);
return [
'status' => PaymentPluginStatusConstant::SUCCESS,
'pay_no' => $payNo,
'message' => mb_strcut(preg_replace('/\s+/', ' ', $content) ?? $content, 0, 180, 'UTF-8'),
'channel_order_no' => $tradeNo,
'channel_trade_no' => $tradeNo,
'channel_status' => 'sms_forwarder_received',
'paid_at' => $this->paidAtFromPayload($payload),
];
}
/**
* 返回监听工具要求的成功应答。
*/
public function notifySuccess(): string|Response
{
return 'success';
}
/**
* 返回监听工具要求的失败应答。
*/
public function notifyFail(): string|Response
{
return 'fail';
}
/**
* 包装个人收款承接页返回值。
*
* @param string $payPage 承接页类型
* @param array<string, mixed> $params 承接参数
* @param string $payNo 支付单号
* @return array<string, mixed>
*/
private function payResult(string $payPage, array $params, string $payNo): array
{
return [
'pay_page' => $payPage,
'pay_type' => 'wxpay',
'pay_product' => 'receipt',
'pay_action' => 'sms_forwarder',
'pay_params' => $params,
'chan_order_no' => $payNo,
'chan_trade_no' => '',
];
}
/**
* 读取后台配置的订单匹配模式。
*
* @return string 匹配模式
*/
private function receiptMatchMode(): string
{
return (string) $this->getConfig('receipt_match_mode', 'amount') === 'remark' ? 'remark' : 'amount';
}
/**
* 读取识别有效期,最低 60 秒。
*
* @return int 有效期秒数
*/
private function receiptValidSeconds(): int
{
return max(60, (int) $this->getConfig('receipt_valid_seconds', 300));
}
/**
* 读取最大金额偏移,单位分。
*
* @return int 最大金额偏移,单位分
*/
private function amountOffsetMax(): int
{
return min(99, max(0, (int) $this->getConfig('amount_offset_max', 99)));
}
/**
* 准备金额变动收款参数。
*
* 在同一通道锁内扫描有效期内待支付订单,始终选择最小可用偏移金额。
* 例如 10.01 已过期时,会重新使用 10.01,而不是继续累加到 10.04。
*
* @param string $payNo 支付单号
* @return array<string, mixed>
*/
private function prepareAmountReceipt(string $payNo): array
{
return $this->withChannelLock(function () use ($payNo): array {
return Db::transaction(function () use ($payNo): array {
$payOrder = $this->lockedPayOrder($payNo);
$originalAmount = $this->originalAmount($payOrder);
$expireAt = $this->expireAt();
$usedAmounts = $this->payOrderRepository->listUsedReceiptAmounts(
[(int) $this->getConfig('channel_id')],
$payNo,
date('Y-m-d H:i:s')
);
$used = array_fill_keys($usedAmounts, true);
$receiptAmount = 0;
for ($offset = 0; $offset <= $this->amountOffsetMax(); $offset++) {
$candidate = $originalAmount + $offset;
if (!isset($used[$candidate])) {
$receiptAmount = $candidate;
break;
}
}
if ($receiptAmount <= 0) {
throw new PaymentException('当前通道可用金额偏移已用尽', 40200, [
'pay_no' => $payNo,
'channel_id' => (int) $this->getConfig('channel_id'),
]);
}
$this->persistReceiptMeta($payOrder, [
'mode' => 'amount',
'original_amount' => $originalAmount,
'receipt_amount' => $receiptAmount,
'offset_amount' => $receiptAmount - $originalAmount,
'expire_at' => $expireAt,
]);
return [
'original_amount' => $originalAmount,
'pay_amount' => $receiptAmount,
'expire_at' => $expireAt,
];
});
});
}
/**
* 准备备注收款参数。
*
* 为当前支付单分配 4 位备注码并写入缓存,通知时通过备注码反查 pay_no。
*
* @param string $payNo 支付单号
* @return array<string, mixed>
*/
private function prepareRemarkReceipt(string $payNo): array
{
return $this->withChannelLock(function () use ($payNo): array {
return Db::transaction(function () use ($payNo): array {
$payOrder = $this->lockedPayOrder($payNo);
$originalAmount = $this->originalAmount($payOrder);
$expireAt = $this->expireAt();
$remarkCode = $this->allocateRemarkCode($payNo);
$this->persistReceiptMeta($payOrder, [
'mode' => 'remark',
'original_amount' => $originalAmount,
'receipt_amount' => $originalAmount,
'remark_code' => $remarkCode,
'expire_at' => $expireAt,
]);
return [
'original_amount' => $originalAmount,
'pay_amount' => $originalAmount,
'remark_code' => $remarkCode,
'expire_at' => $expireAt,
];
});
});
}
/**
* 加锁读取支付单。
*
* @param string $payNo 支付单号
* @return PayOrder
*/
private function lockedPayOrder(string $payNo): PayOrder
{
$payOrder = $this->payOrderRepository->findForUpdateByPayNo($payNo);
if (!$payOrder) {
throw new PaymentException('支付单不存在', 40402, ['pay_no' => $payNo]);
}
return $payOrder;
}
/**
* 写入个人收款元数据。
*
* 金额模式会临时改写 pay_amount 作为识别金额,原始金额保存在 ext_json.personal_receipt。
*
* @param PayOrder $payOrder 支付单
* @param array<string, mixed> $meta 收款元数据
* @return void
*/
private function persistReceiptMeta(PayOrder $payOrder, array $meta): void
{
$payOrder->pay_amount = (int) $meta['receipt_amount'];
$payOrder->expire_at = (string) $meta['expire_at'];
$extJson = (array) ($payOrder->ext_json ?? []);
$extJson['personal_receipt'] = $meta;
$payOrder->ext_json = $extJson;
$payOrder->save();
}
/**
* 获取原始订单金额。
*
* 二次发起或刷新承接页时,优先从 ext_json 读取,避免把上一次偏移金额当成原始金额。
*
* @param PayOrder $payOrder 支付单
* @return int 原始订单金额,单位分
*/
private function originalAmount(PayOrder $payOrder): int
{
$extJson = (array) ($payOrder->ext_json ?? []);
$receiptMeta = (array) ($extJson['personal_receipt'] ?? []);
$originalAmount = (int) ($receiptMeta['original_amount'] ?? 0);
return $originalAmount > 0 ? $originalAmount : (int) $payOrder->pay_amount;
}
/**
* 通知识别完成后恢复支付单金额,避免变动金额进入业务单统计。
*
* @param string $payNo 支付单号
* @param array<string, mixed> $payload 通知载荷
* @param string $tradeNo 渠道交易号
* @param int|null $notifiedAmount 通知中的实际付款金额
* @return void
*/
private function restoreOriginalPayAmount(string $payNo, array $payload, string $tradeNo, ?int $notifiedAmount): void
{
Db::transaction(function () use ($payNo, $payload, $tradeNo, $notifiedAmount): void {
$payOrder = $this->lockedPayOrder($payNo);
$extJson = (array) ($payOrder->ext_json ?? []);
$receiptMeta = (array) ($extJson['personal_receipt'] ?? []);
$originalAmount = (int) ($receiptMeta['original_amount'] ?? 0);
if ($originalAmount > 0) {
$payOrder->pay_amount = $originalAmount;
}
$receiptMeta['notified_at'] = $this->paidAtFromPayload($payload) ?? date('Y-m-d H:i:s');
$receiptMeta['channel_trade_no'] = $tradeNo;
if ($notifiedAmount !== null) {
$receiptMeta['notified_amount'] = $notifiedAmount;
}
$extJson['personal_receipt'] = $receiptMeta;
$payOrder->ext_json = $extJson;
$payOrder->save();
});
}
/**
* 申请 4 位备注码。
*
* 备注码缓存有效期与订单识别有效期一致,同一通道下短时间内不重复。
*
* @param string $payNo 支付单号
* @return string 备注码
*/
private function allocateRemarkCode(string $payNo): string
{
for ($i = 0; $i < 30; $i++) {
$code = (string) random_int(1000, 9999);
$key = $this->remarkCacheKey($code);
if (!Cache::has($key)) {
Cache::set($key, $payNo, $this->receiptValidSeconds());
return $code;
}
}
throw new PaymentException('付款备注码已用尽,请稍后重试', 40200);
}
/**
* 对同一通道的识别信息分配加锁。
*
* 防止并发发起支付时分配到相同金额或相同备注码。
*
* @param callable $callback 回调
* @return mixed
*/
private function withChannelLock(callable $callback): mixed
{
$key = 'mpay_personal_receipt_lock_' . (int) $this->getConfig('channel_id');
$token = bin2hex(random_bytes(8));
for ($i = 0; $i < 20; $i++) {
if (!Cache::has($key)) {
Cache::set($key, $token, 10);
}
if ((string) Cache::get($key) === $token) {
try {
return $callback();
} finally {
if ((string) Cache::get($key) === $token) {
Cache::delete($key);
}
}
}
usleep(50000);
}
throw new PaymentException('当前通道正在分配收款标识,请稍后重试', 40200);
}
/**
* 计算本次个人收款识别的过期时间。
*
* @return string 过期时间
*/
private function expireAt(): string
{
return date('Y-m-d H:i:s', time() + $this->receiptValidSeconds());
}
/**
* 校验并读取 SmsForwarder 载荷。
*
* 签名规则按 SmsForwarder 文档:使用 timestamp、密钥和 HMAC-SHA256 校验 sign。
*
* @param Request $request 请求对象
* @return array<string, mixed>
*/
private function verifiedSmsForwarderPayload(Request $request): array
{
$payload = $this->requestPayload($request);
$timestamp = trim((string) ($payload['timestamp'] ?? ''));
$sign = trim((string) ($payload['sign'] ?? ''));
$secret = (string) $this->getConfig('sms_forwarder_secret', '');
if ($timestamp === '' || $sign === '' || $secret === '') {
throw new PaymentException('SmsForwarder 通知签名参数不完整', 40200);
}
$timestampSeconds = (int) floor(((int) $timestamp) / 1000);
$tolerance = max(30, (int) $this->getConfig('sms_forwarder_time_tolerance', 300));
if ($timestampSeconds <= 0 || abs(time() - $timestampSeconds) > $tolerance) {
throw new PaymentException('SmsForwarder 通知时间已失效', 40200);
}
$expected = base64_encode(hash_hmac('sha256', $timestamp . "\n" . $secret, $secret, true));
if (!hash_equals($expected, rawurldecode($sign))) {
throw new PaymentException('SmsForwarder 通知签名校验失败', 40200);
}
if (trim((string) ($payload['content'] ?? '')) === '') {
throw new PaymentException('SmsForwarder 通知内容为空', 40200);
}
return $payload;
}
/**
* 读取请求载荷。
*
* Webman Request 已统一处理 query、form 和 JSON 请求体,这里直接使用 all()。
*
* @param Request $request 请求对象
* @return array<string, mixed>
*/
private function requestPayload(Request $request): array
{
return (array) $request->all();
}
/**
* 金额模式下通过通知金额定位唯一支付单。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 支付单号
*/
private function locatePayNoByAmount(array $payload): string
{
$amount = $this->amountFromContent((string) $payload['content']);
$orders = $this->payOrderRepository->listMutableReceiptOrdersByAmount(
[(int) $this->getConfig('channel_id')],
$amount,
0,
date('Y-m-d H:i:s'),
['pay_no']
);
if ($orders->count() !== 1) {
throw new PaymentException('金额通知未匹配到唯一支付单', 40200, [
'amount' => FormatHelper::amount($amount),
'matched_count' => $orders->count(),
]);
}
return (string) $orders->first()->pay_no;
}
/**
* 备注模式下通过缓存中的 4 位备注码定位支付单。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 支付单号
*/
private function locatePayNoByRemark(array $payload): string
{
$remarkCode = $this->remarkFromContent((string) $payload['content']);
$payNo = (string) Cache::get($this->remarkCacheKey($remarkCode), '');
if ($payNo === '') {
throw new PaymentException('付款备注已失效或不存在', 40200, ['remark_code' => $remarkCode]);
}
return $payNo;
}
/**
* 根据配置选择金额匹配或备注匹配。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 支付单号
*/
private function locatePayNo(array $payload): string
{
return $this->receiptMatchMode() === 'remark'
? $this->locatePayNoByRemark($payload)
: $this->locatePayNoByAmount($payload);
}
/**
* 从通知文本中提取收款金额。
*
* @param string $content 通知内容
* @return int 金额,单位分
*/
private function amountFromContent(string $content): int
{
if (preg_match('/(?:收款|到账|收钱|付款|支付|转账)[^\d]{0,20}(\d+(?:\.\d{1,2})?)\s*元/u', $content, $matches) !== 1
&& preg_match('/(?<!\d)(\d+(?:\.\d{1,2})?)\s*元/u', $content, $matches) !== 1
) {
throw new PaymentException('通知内容未识别到收款金额', 40200);
}
return $this->moneyToCents((string) $matches[1]);
}
/**
* 从通知文本中提取 4 位付款备注码。
*
* @param string $content 通知内容
* @return string 备注码
*/
private function remarkFromContent(string $content): string
{
if (preg_match('/(?:备注|留言|附言|付款备注|收款备注)[:\s]*([0-9]{4})/u', $content, $matches) !== 1) {
throw new PaymentException('通知内容未识别到付款备注', 40200);
}
return (string) $matches[1];
}
/**
* 将金额文本转换为分。
*
* @param string $money 金额文本
* @return int 金额,单位分
*/
private function moneyToCents(string $money): int
{
if (!preg_match('/^\d+(?:\.\d{1,2})?$/', $money)) {
throw new PaymentException('通知金额格式不合法', 40200, ['money' => $money]);
}
[$integer, $fraction] = array_pad(explode('.', $money, 2), 2, '');
return (int) $integer * 100 + (int) str_pad(substr($fraction, 0, 2), 2, '0');
}
/**
* 生成备注码缓存键。
*
* @param string $code 备注码
* @return string 缓存键
*/
private function remarkCacheKey(string $code): string
{
return 'mpay_personal_receipt_remark_' . (int) $this->getConfig('channel_id') . '_' . $code;
}
/**
* 为 SmsForwarder 通知生成稳定的渠道交易号。
*
* @param array<string, mixed> $payload 通知载荷
* @return string 渠道交易号
*/
private function channelTradeNo(array $payload): string
{
return 'SF' . substr(hash('sha256', (string) ($payload['from'] ?? '') . '|' . (string) $payload['timestamp'] . '|' . (string) $payload['content']), 0, 30);
}
/**
* 从 SmsForwarder 毫秒时间戳提取支付时间。
*
* @param array<string, mixed> $payload 通知载荷
* @return string|null 支付时间
*/
private function paidAtFromPayload(array $payload): ?string
{
$timestamp = (int) ($payload['timestamp'] ?? 0);
return $timestamp > 0 ? date('Y-m-d H:i:s', (int) floor($timestamp / 1000)) : null;
}
}