Files

407 lines
16 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}