mirror of
https://gitee.com/technical-laohu/mpay_v2_webman.git
synced 2026-05-17 06:20:25 +08:00
feat: 完善支付通道和收款监听链路
新增 ChannelNotifyPayloadInterface 等支付插件通知契约,规范 pay_no 定位和插件返回校验。 新增微信、支付宝、收钱吧、Postar 个人收款插件适配,支持余额识别与备注识别。 新增 receipt-watcher 后端进程、Redis 队列 job 和平台事件监听,覆盖收款流水通知、商户通知、退款派发、转账派发与清算完成。 补齐个人收款监听相关系统配置、仓储、服务费冻结明细、订单后台操作和通道测试能力。 重构支付单创建、回调、费用、风控、结算和通道统计链路,统一状态流转与幂等处理。
This commit is contained in:
@@ -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
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
92
app/common/constant/EpayProtocolConstant.php
Normal file
92
app/common/constant/EpayProtocolConstant.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 退款单进入成功态。
|
||||
*/
|
||||
|
||||
63
app/common/constant/FundFreezeConstant.php
Normal file
63
app/common/constant/FundFreezeConstant.php
Normal 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 => '已解冻',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
* 异步通知类型。
|
||||
*/
|
||||
|
||||
88
app/common/constant/PayOrderActionConstant.php
Normal file
88
app/common/constant/PayOrderActionConstant.php
Normal 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 => '已冻结',
|
||||
];
|
||||
}
|
||||
}
|
||||
42
app/common/constant/PaymentQueueConstant.php
Normal file
42
app/common/constant/PaymentQueueConstant.php
Normal 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';
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
24
app/common/interface/ChannelNotifyInterface.php
Normal file
24
app/common/interface/ChannelNotifyInterface.php
Normal 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;
|
||||
}
|
||||
32
app/common/interface/ChannelNotifyPayloadInterface.php
Normal file
32
app/common/interface/ChannelNotifyPayloadInterface.php
Normal 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;
|
||||
}
|
||||
@@ -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`。
|
||||
* 只有在回调可信时,才返回标准结果数组。
|
||||
|
||||
31
app/common/interface/QueueJobInterface.php
Normal file
31
app/common/interface/QueueJobInterface.php
Normal 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;
|
||||
}
|
||||
38
app/common/interface/TransferPluginInterface.php
Normal file
38
app/common/interface/TransferPluginInterface.php
Normal 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;
|
||||
}
|
||||
780
app/common/payment/AlipayReceiptPayment.php
Normal file
780
app/common/payment/AlipayReceiptPayment.php
Normal 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
1070
app/common/payment/PostarReceiptPayment.php
Normal file
1070
app/common/payment/PostarReceiptPayment.php
Normal file
File diff suppressed because it is too large
Load Diff
1055
app/common/payment/ShouQianBaReceiptPayment.php
Normal file
1055
app/common/payment/ShouQianBaReceiptPayment.php
Normal file
File diff suppressed because it is too large
Load Diff
780
app/common/payment/WechatReceiptPayment.php
Normal file
780
app/common/payment/WechatReceiptPayment.php
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user