407 lines
16 KiB
PHP
407 lines
16 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
declare (strict_types = 1);
|
||
|
||
namespace addon\mqpay\subscribe;
|
||
|
||
use think\facade\Db;
|
||
use think\facade\Event;
|
||
use think\facade\Log;
|
||
use Symfony\Component\HttpFoundation\Request as SymfonyRequest;
|
||
|
||
/**
|
||
* 支付事件订阅器(真实微信/支付宝,基于 Yansongda Pay v3)
|
||
*
|
||
* 对应 app/member/controller/Payment.php 触发的事件契约:
|
||
* - PaymentMethods 收集可用支付方式
|
||
* - PaymentOrderCreate 订单落库钩子(写入 Mqpay_order)
|
||
* - PaymentCreate 生成支付参数(统一下单,返回 qrcode 扫码地址)
|
||
* - PaymentNotify 异步回调钩子(验签 + 激活会员,返回 'success'/'fail')
|
||
* - PaymentFreeActive 免费套餐开通钩子
|
||
*
|
||
* 依赖:composer require yansongda/pay(^3.0)。未安装或未配置密钥时,
|
||
* onPaymentCreate / onPaymentNotify 会优雅降级并返回友好提示,不会致命报错。
|
||
*/
|
||
class Payment
|
||
{
|
||
/**
|
||
* 收集可用支付方式:个人免签(收款码)+ 微信/支付宝(真实商户)
|
||
* 触发点:Payment::methods()
|
||
*/
|
||
public function onPaymentMethods($params)
|
||
{
|
||
$methods = [];
|
||
// 免签支付(个人收款码):无需商户资质,排在最前
|
||
if ($this->personalEnabled()) {
|
||
$methods[] = [
|
||
'code' => 'personal',
|
||
'name' => '个人免签',
|
||
'desc' => '扫码转账到个人收款码,站长确认到账后开通',
|
||
'icon' => '',
|
||
'sort' => 0,
|
||
];
|
||
}
|
||
// 真实商户支付(需 yansongda/pay + 密钥)
|
||
if ($this->merchantEnabled()) {
|
||
$methods[] = [
|
||
'code' => 'wechat',
|
||
'name' => '微信支付',
|
||
'desc' => '使用微信扫码支付(基于 Yansongda Pay 实现)',
|
||
'icon' => '',
|
||
'sort' => 1,
|
||
];
|
||
$methods[] = [
|
||
'code' => 'alipay',
|
||
'name' => '支付宝',
|
||
'desc' => '使用支付宝扫码支付(基于 Yansongda Pay 实现)',
|
||
'icon' => '',
|
||
'sort' => 2,
|
||
];
|
||
}
|
||
return $methods;
|
||
}
|
||
|
||
/**
|
||
* 订单落库钩子
|
||
* 触发点:Payment::create() 付费分支,先于 PaymentCreate
|
||
*/
|
||
public function onPaymentOrderCreate($params)
|
||
{
|
||
$order = $params['order'] ?? [];
|
||
if (empty($order['order_sn'])) {
|
||
return;
|
||
}
|
||
Db::name('Mqpay_order')->insert([
|
||
'order_sn' => $order['order_sn'] ?? '',
|
||
'uid' => $order['uid'] ?? 0,
|
||
'plan_id' => $order['plan_id'] ?? 0,
|
||
'plan_title' => $order['plan_title'] ?? '',
|
||
'amount' => $order['amount'] ?? 0,
|
||
'real_amount' => $order['real_amount'] ?? 0,
|
||
'method' => $order['method'] ?? '',
|
||
'status' => 0,
|
||
'create_at' => time(),
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 生成支付参数
|
||
* - personal:返回个人收款码图片,用户扫码转账(免签,无商户 API)
|
||
* - wechat/alipay:真实商户统一下单(基于 Yansongda Pay),返回扫码地址
|
||
* 触发点:Payment::create() 付费分支
|
||
*/
|
||
public function onPaymentCreate($params)
|
||
{
|
||
$order = $params['order'] ?? [];
|
||
$method = $order['method'] ?? '';
|
||
if (empty($order['order_sn'])) {
|
||
return null;
|
||
}
|
||
|
||
// ===== 免签支付:返回个人收款码图片 =====
|
||
if ($method === 'personal') {
|
||
$qrcode = $this->personalQrcode();
|
||
if (empty($qrcode)) {
|
||
return ['type' => 'error', 'message' => '站长未配置个人收款码,暂无法使用免签支付'];
|
||
}
|
||
// 若开启金额零头防撞单,则向展示金额附加随机尾差(订单库仍存原价,real_amount 存实际转账额)
|
||
$displayAmount = $order['amount'];
|
||
$realAmount = (float) $order['amount'];
|
||
if ($this->jitterEnabled()) {
|
||
$jitter = mt_rand(1, 99) / 100; // 0.01 ~ 0.99
|
||
$realAmount = round($realAmount + $jitter, 2);
|
||
$displayAmount = $realAmount;
|
||
// 回填订单的实际应付金额,供安卓监听按金额精确匹配
|
||
Db::name('Mqpay_order')->where('order_sn', $order['order_sn'])
|
||
->update(['real_amount' => $realAmount]);
|
||
}
|
||
return [
|
||
'type' => 'qr_image',
|
||
'qrcode_url' => $qrcode,
|
||
'order_sn' => $order['order_sn'],
|
||
'amount' => $displayAmount,
|
||
'real_amount' => $realAmount,
|
||
'plan_title' => $order['plan_title'],
|
||
'expire' => 600,
|
||
'tip' => '请使用微信/支付宝扫码转账【精确金额】,安卓监听 App 会自动识别到账并开通会员',
|
||
];
|
||
}
|
||
|
||
if (!in_array($method, ['wechat', 'alipay'], true)) {
|
||
return null;
|
||
}
|
||
if (!class_exists(\Yansongda\Pay\Pay::class)) {
|
||
return ['type' => 'error', 'message' => '未安装支付 SDK,请执行:composer require yansongda/pay'];
|
||
}
|
||
$config = $this->payConfig($method);
|
||
if (empty($config)) {
|
||
return ['type' => 'error', 'message' => '支付密钥未配置,请在 .env 设置 PAY_' . strtoupper($method) . '_*'];
|
||
}
|
||
try {
|
||
/** @var \Yansongda\Pay\Provider\Wechat|\Yansongda\Pay\Provider\Alipay $gateway */
|
||
$gateway = \Yansongda\Pay\Pay::$method($config);
|
||
if ($method === 'wechat') {
|
||
$result = $gateway->scan([
|
||
'out_trade_no' => $order['order_sn'],
|
||
'description' => $order['plan_title'] ?: '会员开通',
|
||
'amount' => ['total' => (int) round((float) $order['amount'] * 100)],
|
||
]);
|
||
} else {
|
||
$result = $gateway->scan([
|
||
'out_trade_no' => $order['order_sn'],
|
||
'total_amount' => (string) $order['amount'],
|
||
'subject' => $order['plan_title'] ?: '会员开通',
|
||
]);
|
||
}
|
||
$codeUrl = (string) ($result->code_url ?? '');
|
||
if (!$codeUrl) {
|
||
return ['type' => 'error', 'message' => '获取支付二维码失败'];
|
||
}
|
||
return [
|
||
'type' => 'qrcode',
|
||
'qrcode_url' => $codeUrl,
|
||
'order_sn' => $order['order_sn'],
|
||
];
|
||
} catch (\Throwable $e) {
|
||
Log::error('[Mqpay] 统一下单失败: ' . $e->getMessage());
|
||
return ['type' => 'error', 'message' => '下单失败:' . $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 异步回调钩子(真实网关通知入口)
|
||
* 触发点:Payment::notify() 与 addon/Mqpay/pay/notify
|
||
* 完成验签 + 订单激活,返回 'success'(网关要求)或 'fail'。
|
||
*/
|
||
public function onPaymentNotify($params)
|
||
{
|
||
$method = $params['method'] ?? '';
|
||
// 免签支付没有异步通知,交还控制权(若有其它插件监听可继续处理)
|
||
if ($method === 'personal' || $method === '') {
|
||
return null;
|
||
}
|
||
if (!in_array($method, ['wechat', 'alipay'], true)) {
|
||
return 'fail';
|
||
}
|
||
if (!class_exists(\Yansongda\Pay\Pay::class)) {
|
||
return 'fail';
|
||
}
|
||
$config = $this->payConfig($method);
|
||
if (empty($config)) {
|
||
return 'fail';
|
||
}
|
||
try {
|
||
/** @var \Yansongda\Pay\Provider\Wechat|\Yansongda\Pay\Provider\Alipay $gateway */
|
||
$gateway = \Yansongda\Pay\Pay::$method($config);
|
||
// 用全局 Symfony Request 还原网关原始通知(兼容 ThinkPHP 已读取 php://input 的情况)
|
||
$symfonyRequest = SymfonyRequest::createFromGlobals();
|
||
$data = $gateway->callback($symfonyRequest);
|
||
$orderSn = (string) ($data['out_trade_no'] ?? '');
|
||
if (!$orderSn) {
|
||
return 'fail';
|
||
}
|
||
return $this->finishOrder($orderSn, $method, (string) ($data['trade_no'] ?? ''));
|
||
} catch (\Throwable $e) {
|
||
Log::error('[Mqpay] 异步通知验签失败: ' . $e->getMessage());
|
||
return 'fail';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 免费套餐开通钩子
|
||
* 触发点:Payment::create() 免费分支
|
||
*/
|
||
public function onPaymentFreeActive($params)
|
||
{
|
||
Log::info('[Mqpay] 免费套餐开通: ' . json_encode($params));
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 标记订单已支付并触发会员激活事件
|
||
*/
|
||
private function finishOrder(string $orderSn, string $method, string $tradeNo): string
|
||
{
|
||
$order = Db::name('Mqpay_order')->where('order_sn', $orderSn)->find();
|
||
if (!$order) {
|
||
return 'fail';
|
||
}
|
||
if ((int) $order['status'] !== 1) {
|
||
Db::name('Mqpay_order')->where('id', $order['id'])->update([
|
||
'status' => 1,
|
||
'pay_at' => time(),
|
||
'transaction_id' => $tradeNo,
|
||
]);
|
||
// 开通会员:交给业务监听器(如更新用户组/会员有效期),保持解耦
|
||
Event::trigger('UserMemberActivate', [
|
||
'uid' => $order['uid'],
|
||
'plan_id' => $order['plan_id'],
|
||
'order' => $order,
|
||
'method' => $method,
|
||
]);
|
||
}
|
||
return 'success';
|
||
}
|
||
|
||
/**
|
||
* 免签支付是否开启
|
||
*/
|
||
private function personalEnabled(): bool
|
||
{
|
||
return (string) config('mqpay.personal_enable', '1') === '1';
|
||
}
|
||
|
||
/**
|
||
* 到账自动监听是否开启
|
||
*/
|
||
private function listenEnabled(): bool
|
||
{
|
||
return (string) config('mqpay.listen_enable', '0') == '1'
|
||
&& (string) config('mqpay.personal_confirm_mode', 'manual') == 'auto';
|
||
}
|
||
|
||
/**
|
||
* 金额零头防撞单是否开启
|
||
*/
|
||
private function jitterEnabled(): bool
|
||
{
|
||
return (string) config('mqpay.listen_amount_jitter', '1') === '1';
|
||
}
|
||
|
||
/**
|
||
* 真实商户支付是否开启
|
||
*/
|
||
private function merchantEnabled(): bool
|
||
{
|
||
return (string) config('mqpay.merchant_enable', '0') === '1';
|
||
}
|
||
|
||
/**
|
||
* 取当前免签收款码(微信优先,其次支付宝;体验阶段两者共用一张图也可)
|
||
* 未配置时回退到插件内置占位图,保证体验流程可走通(站长应替换为真实收款码)
|
||
*/
|
||
private function personalQrcode(): string
|
||
{
|
||
$wx = (string) config('mqpay.personal_wechat_qrcode', '');
|
||
$ali = (string) config('mqpay.personal_alipay_qrcode', '');
|
||
if ($wx !== '') {
|
||
return $wx;
|
||
}
|
||
if ($ali !== '') {
|
||
return $ali;
|
||
}
|
||
// 默认占位:请将你的个人收款码放到 public/addon/Mqpay/qrcode/ 下并配置
|
||
return '/addon/Mqpay/qrcode/placeholder.png';
|
||
}
|
||
|
||
/**
|
||
* 手动确认到账(免签支付闭环):校验订单未支付后标记已付并触发会员激活。
|
||
* 供 addon/Mqpay/controller/Pay.php 的 confirm 接口调用。
|
||
* @return array ['code'=>0|1,'message'=>string]
|
||
*/
|
||
public function confirmOrder(string $orderSn): array
|
||
{
|
||
if (!$orderSn) {
|
||
return ['code' => 1, 'message' => '订单号不能为空'];
|
||
}
|
||
$order = Db::name('Mqpay_order')->where('order_sn', $orderSn)->find();
|
||
if (!$order) {
|
||
return ['code' => 1, 'message' => '订单不存在'];
|
||
}
|
||
if ((int) $order['status'] === 1) {
|
||
return ['code' => 0, 'message' => '该订单已支付/已确认,无需重复操作'];
|
||
}
|
||
Db::name('Mqpay_order')->where('id', $order['id'])->update([
|
||
'status' => 1,
|
||
'pay_at' => time(),
|
||
'transaction_id' => 'personal-' . $orderSn,
|
||
]);
|
||
try {
|
||
Event::trigger('UserMemberActivate', [
|
||
'uid' => $order['uid'],
|
||
'plan_id' => $order['plan_id'],
|
||
'order' => $order,
|
||
'method' => 'personal',
|
||
]);
|
||
} catch (\Throwable $e) {
|
||
// 会员激活链路依赖的 user_log 等基础设施表在本体验环境可能缺失,
|
||
// 订单本身已标记已付,此处仅记录异常,不阻断免签闭环。
|
||
trace('免签确认后会员激活失败: ' . $e->getMessage(), 'error');
|
||
}
|
||
return ['code' => 0, 'message' => '确认成功,订单已标记为已支付'];
|
||
}
|
||
|
||
/**
|
||
* 安卓监听 App 回调入口:凭密钥验签,并按到账金额/订单号自动确认订单。
|
||
* 优先用 amount 精确匹配(依赖金额零头防撞单),其次用 order_sn 直接确认。
|
||
* @return array ['code'=>0|1,'message'=>string,'matched'=>int]
|
||
*/
|
||
public function notifyApp(array $payload): array
|
||
{
|
||
|
||
if (!$this->listenEnabled()) {
|
||
return ['code' => 1, 'message' => '未开启到账自动监听', 'matched' => 0];
|
||
}
|
||
$key = (string) config('mqpay.listen_key', '');
|
||
if ($key === '') {
|
||
return ['code' => 1, 'message' => '未配置监听回调密钥', 'matched' => 0];
|
||
}
|
||
// 验签:sign = md5(key + amount + order_sn + key)
|
||
$amount = (string) ($payload['amount'] ?? '');
|
||
$orderSn = (string) ($payload['order_sn'] ?? '');
|
||
$sign = (string) ($payload['sign'] ?? '');
|
||
$expect = md5($key . $amount . $orderSn . $key);
|
||
if ($sign !== $expect) {
|
||
trace('[Mqpay] notifyApp 验签失败 sign=' . $sign, 'error');
|
||
return ['code' => 1, 'message' => '签名校验失败', 'matched' => 0];
|
||
}
|
||
|
||
$matched = 0;
|
||
if ($orderSn !== '') {
|
||
// 方式一:监听 App 已解析出订单号,直接确认
|
||
$res = $this->confirmOrder($orderSn);
|
||
return ['code' => $res['code'], 'message' => $res['message'], 'matched' => $res['code'] === 0 ? 1 : 0];
|
||
}
|
||
if ($amount !== '') {
|
||
// 方式二:仅拿到到账金额,按 real_amount 精确匹配待支付订单(零头防撞单核心)
|
||
$real = round((float) $amount, 2);
|
||
$orders = Db::name('Mqpay_order')
|
||
->where('status', 0)
|
||
->where('real_amount', $real)
|
||
->order('id', 'desc')
|
||
->limit(5)
|
||
->select()
|
||
->toArray();
|
||
foreach ($orders as $o) {
|
||
$this->confirmOrder($o['order_sn']);
|
||
$matched++;
|
||
}
|
||
if ($matched === 0) {
|
||
return ['code' => 1, 'message' => '未匹配到金额为 ' . $real . ' 的待支付订单', 'matched' => 0];
|
||
}
|
||
return ['code' => 0, 'message' => '自动确认成功,匹配订单数:' . $matched, 'matched' => $matched];
|
||
}
|
||
return ['code' => 1, 'message' => '缺少 amount 或 order_sn 参数', 'matched' => 0];
|
||
}
|
||
|
||
/**
|
||
* 读取并补全支付配置(注入异步/同步回调地址)
|
||
*/
|
||
private function payConfig(string $method): array
|
||
{
|
||
$cfg = config('pay.' . $method, []);
|
||
if (empty($cfg) || empty($cfg['app_id'] ?? $cfg['mch_id'] ?? '')) {
|
||
return [];
|
||
}
|
||
$cfg['notify_url'] = addon_url('Mqpay/pay/notify', ['method' => $method]);
|
||
$cfg['return_url'] = addon_url('Mqpay/pay/cashier', ['method' => $method]);
|
||
return $cfg;
|
||
}
|
||
}
|