chore: 重写初始提交(清空历史,整理后全量提交)

This commit is contained in:
ywxapp
2026-08-16 16:54:14 +08:00
commit 6c1a106bc1
1808 changed files with 238144 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
<?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;
use ywxapp\AddonBase;
/**
* Addon 类
*
* @author ywxapp <admin@ywxapp.cn>
*/
class Addon extends addon
{
/**
* 安装钩子
*/
public function install()
{
return true;
}
/**
* 卸载钩子(订单表由框架按 install.sql 自动清理)
*/
public function uninstall()
{
return true;
}
public function enable()
{
return true;
}
public function disable()
{
return true;
}
}
+147
View File
@@ -0,0 +1,147 @@
# Mqpay — 示例支付插件(真实微信 / 支付宝)
通过监听 `app/member/controller/Payment.php` 暴露的 **Payment\* 事件** 接入真实微信/支付宝支付,
核心控制器零改动,所有支付能力由本插件注入(基于 [Yansongda Pay v3](https://pay.yansongda.cn))。
## 前置依赖
```bash
composer require yansongda/pay # ^3.0
```
`.env` 中配置真实密钥(对应 `config/pay.php`):
```env
# 微信支付
PAY_WECHAT_MCH_ID=你的商户号
PAY_WECHAT_APP_ID=你的AppID
PAY_WECHAT_SECRET_KEY=APIv3密钥
# PAY_WECHAT_CERT_CLIENT=/path/apiclient_cert.pem
# PAY_WECHAT_CERT_KEY=/path/apiclient_key.pem
# 支付宝
PAY_ALIPAY_APP_ID=你的AppID
PAY_ALIPAY_PUBLIC_KEY=支付宝公钥
PAY_ALIPAY_PRIVATE_KEY=应用私钥
```
> 未安装 SDK 或未配置密钥时,插件会优雅降级:`methods` 仍返回通道,`create` 返回友好错误提示,不会致命报错。
## 监听的事件契约
| 事件 | 订阅方法 | 入参 | 返回值 |
|------|----------|------|--------|
| `PaymentMethods` | `onPaymentMethods` | `[]` | `[['code'=>'wechat'\|'alipay','name','desc','icon','sort']]` |
| `PaymentOrderCreate` | `onPaymentOrderCreate` | `['order'=>[...]]` | 无(落库 Mqpay_order |
| `PaymentCreate` | `onPaymentCreate` | `['order'=>[...],'params'=>[...]]` | `['type'=>'qrcode','qrcode_url','order_sn']` |
| `PaymentNotify` | `onPaymentNotify` | `['method','post','get']` | `'success'` / `'fail'`(网关要求) |
| `PaymentFreeActive` | `onPaymentFreeActive` | `['user','plan']` | 无 |
## 前端流程
1. 用户选择套餐 → 弹窗列出 `wechat` / `alipay` 两个通道;
2. 确认后 `POST /user/payment/create` → 触发 `PaymentOrderCreate` + `PaymentCreate`
3. 插件调用 Yansongda 统一下单(扫码),前端用返回的 `qrcode_url` 渲染二维码;
4. 前端每 2 秒轮询 `POST /Mqpay/pay/query`(带 `order_sn`)检测支付结果;
5. 微信/支付宝异步通知 `POST /Mqpay/pay/notify?method=xxx` → 插件验签、标记订单、触发 `UserMemberActivate` 事件,返回 `success`
## 会员开通:UserMemberActivate 监听器
支付成功后本插件仅完成**订单激活**(把 `wxapp_mqpay_order.status` 置 1),
真正的「会员权益写入」(更新用户组 / 会员有效期 / 资金流水 / 操作日志)通过触发
`UserMemberActivate` 事件交给业务层处理,保证插件与业务解耦。
### 监听器位置
- 实现:`app/member/listener/UserMemberActivate.php`
- `handle($event)` 读取 `$event['uid']``$event['plan_id']``$event['order']``$event['method']`
- 依据 `config('member.plans')` 取套餐的 `duration`/`level`/`gid`
- 在原未过期时间基础上**顺延**会员期限(避免重复购买覆盖);
- 写入 4 处:用户主表 `vip_expire`/`vip_level`/`gid`、用户组关联 `user_group_access`
资金流水 `user_bill`、操作日志 `user_log`
- 注册:`app/event.php``listen` 数组(`app/event.php:27-29`):
```php
'UserMemberActivate' => [
app\member\listener\UserMemberActivate::class,
],
```
> 注:该监听器注册在全局 `app/event.php`(非插件 `info.php`),因此与具体支付插件解耦——
> 任何触发 `UserMemberActivate` 事件的插件都能复用同一套会员开通逻辑。
> `app/event.php` 注释里残留的 `paydemo` 字样是重命名前身,可忽略。)
### 触发点
本插件在两处触发该事件:
| 触发路径 | 调用链 |
|---|---|
| 安卓免签自动确认 | `POST /mqpay/pay/notify-app` → `notifyApp()` 验签 → 按 `real_amount` 精确匹配 → `confirmOrder()` |
| 后台人工确认 | 后台 `Confirm` 接口 → `Pay.php` → `confirmOrder()` |
`confirmOrder()` 触发代码示例(`subscribe/Payment.php:326`):
```php
Event::trigger('UserMemberActivate', [
'uid' => $order['uid'],
'plan_id' => $order['plan_id'],
'order' => $order,
'method' => 'personal',
]);
```
## 支付钩子完整调用链路(时序图)
```
用户浏览器 app/member/controller/Payment addon/mqpay/subscribe/Payment 安卓监听App 会员监听器
│ │ │ │ │
│─ GET methods ───────▶│ │ │ │
│ │ Event::trigger('PaymentMethods') │ │
│ │────────────────────────────▶│ onPaymentMethods() │ │
│◀─ 方式列表 ──────────│◀─────────────────────────────│ │ │
│ │ │ │ │
│─ POST create(personal)▶│ │ │ │
│ │ 生成 order │ │ │
│ │ Event::trigger('PaymentOrderCreate') │ │
│ │────────────────────────────▶│ onPaymentOrderCreate() │ │
│ │ Event::trigger('PaymentCreate') │ │
│ │────────────────────────────▶│ onPaymentCreate() │ │
│ │ │ personalQrcode()+jitter │ │
│◀─ 收款码+精确金额 ───│◀─────────────────────────────│ │ │
│ │ │ ║ 用户扫码转账到账 ║ │ │
│ │ │ │ 监听通知栏 │
│ │ │ │─ POST /mqpay/pay/notify-app(amount)▶│
│ │ │ notifyApp() 验签 md5(key+amount+sn+key) │
│ │ │ 按 real_amount 精确匹配 │ │
│ │ │ confirmOrder(order_sn) │ │
│ │ │ status=1 │ │
│ │ │ Event::trigger('UserMemberActivate') │
│ │ │────────────────────────── 开通会员 ──────▶│
│◀─ 轮询/刷新看到已开通─│ │ │ │
```
> 网关支付(微信/支付宝)走 `onPaymentNotify` 异步回调;**免签**(personal)无网关回调,
> 由 `notify-app`(安卓)或后台 `Confirm` 触发 `confirmOrder()`,殊途同归到 `UserMemberActivate`。
## 钩子 / 事件速查表
| 事件名 | 订阅方法 | 订阅位置 | 触发位置(Payment.php | 返回值 |
|---|---|---|---|---|
| `PaymentMethods` | `onPaymentMethods` | `subscribe/Payment.php` | `methods()` | `array` 方式列表 |
| `PaymentOrderCreate` | `onPaymentOrderCreate` | `subscribe/Payment.php` | `create()` | 无(写库) |
| `PaymentCreate` | `onPaymentCreate` | `subscribe/Payment.php` | `create()` | `array`/`null` 支付参数 |
| `PaymentNotify` | `onPaymentNotify` | `subscribe/Payment.php` | `notify()` | `'success'`/`'fail'` |
| `PaymentFreeActive` | `onPaymentFreeActive` | `subscribe/Payment.php` | `create()` | 无 |
| `UserMemberActivate` | `handle()` | `app/member/listener/UserMemberActivate.php` | `confirmOrder()``subscribe/Payment.php` | 无(写库/日志) |
### 注册方式对照
- 支付钩子(前 5 个):在 `addon/mqpay/info.php` 的 `events.subscribe` 声明订阅器类,
框架 `AppService::loadAddonRelevant()` 启动时自动 `loadEvent` 注册。
- 会员开通(最后 1 个):在全局 `app/event.php` 的 `listen` 中注册,与支付插件解耦。
## 相关文档
- [支付钩子调用机制与时序图](docs/payment_hooks.md) — 事件系统、订阅器注册、会员监听器链路详解
- [安卓到账监听 App 接入指南](docs/listen_app_guide.md) — 免签监听 App 对接 `notify-app` 接口说明
+75
View File
@@ -0,0 +1,75 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp.cn>
// +----------------------------------------------------------------------
// 支付插件配置项(后台配置页数据源;实际值存于统一配置表 wxapp_addon_config
// 默认值仅为迁移占位。
return [
// ============ 免签支付(个人收款码)配置 ============
[
'name' => 'personal_enable',
'title' => '开启免签支付',
'type' => 'radio',
'options' => ['1' => '开启', '0' => '关闭'],
'value' => '1',
'tip' => '免签支付无需商户资质,使用你的个人收款码,用户扫码转账后由你手动确认到账。',
],
[
'name' => 'personal_wechat_qrcode',
'title' => '微信个人收款码',
'type' => 'string',
'value' => '',
'tip' => '填写你微信个人收款码的公开访问 URL,例如 /addon/Mqpay/qrcode/wechat.png',
],
[
'name' => 'personal_alipay_qrcode',
'title' => '支付宝个人收款码',
'type' => 'string',
'value' => '',
'tip' => '填写你支付宝个人收款码的公开访问 URL,例如 /addon/Mqpay/qrcode/alipay.png',
],
[
'name' => 'personal_confirm_mode',
'title' => '到账确认方式',
'type' => 'radio',
'options' => ['manual' => '手动确认(推荐体验用)', 'auto' => '自动监听(安卓通知监听 App 回调)'],
'value' => 'manual',
'tip' => '手动确认:用户在 /Mqpay/admin 页面确认到账;自动监听:安卓 NotificationListener 监听到账通知后调用 notifyApp 接口自动确认。',
],
[
'name' => 'listen_enable',
'title' => '开启到账自动监听',
'type' => 'radio',
'options' => ['1' => '开启', '0' => '关闭'],
'value' => '0',
'tip' => '开启后,安卓监听 App 可凭 listen_key 调用 /Mqpay/pay/notify-app 接口自动确认订单(需确认方式设为自动监听)。',
],
[
'name' => 'listen_key',
'title' => '监听回调密钥',
'type' => 'string',
'value' => '',
'tip' => '自定义一段随机字符串,安卓监听 App 调用 notifyApp 接口时需携带,用于校验来源。建议 16 位以上随机串。',
],
[
'name' => 'listen_amount_jitter',
'title' => '金额零头防撞单',
'type' => 'radio',
'options' => ['1' => '开启', '0' => '关闭'],
'value' => '1',
'tip' => '开启后下单金额附加随机零头(如 99.37 元),安卓监听到账金额可精确匹配订单,避免多人同额撞单。',
],
// ============ 真实商户支付配置(可选) ============
[
'name' => 'merchant_enable',
'title' => '开启真实商户支付',
'type' => 'radio',
'options' => ['1' => '开启', '0' => '关闭'],
'value' => '0',
'tip' => '需安装 yansongda/pay 并配置商户密钥,详见插件 README。',
],
];
+332
View File
@@ -0,0 +1,332 @@
<?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\controller;
use think\facade\Db;
use think\facade\Event;
use think\facade\Request;
/**
* 收银台 / 异步回调 / 订单查询(示例插件自有控制器,经 MultiApp 自动路由到 /Mqpay/pay/*
*/
class Pay
{
/**
* 真实网关异步通知入口:method 来自查询参数,交由 PaymentNotify 事件验签与激活
*/
public function notify()
{
$method = Request::param('method', '');
$result = Event::trigger('PaymentNotify', [
'method' => $method,
'request' => Request::param(),
'get' => Request::get(),
'post' => Request::post(),
]);
foreach ((array) $result as $r) {
if (is_string($r) && $r !== '') {
return $r;
}
}
return 'fail';
}
/**
* 同步跳转页(支付宝 return_url):展示支付结果,引导返回会员中心
*/
public function cashier()
{
$orderSn = Request::param('out_trade_no', '') ?: Request::param('order_sn', '');
$order = $orderSn ? Db::name('Mqpay_order')->where('order_sn', $orderSn)->find() : [];
$paid = !empty($order) && (int) $order['status'] === 1;
$plan = $order['plan_title'] ?? '';
$title = $paid ? '支付成功' : '支付结果确认中';
$tip = $paid ? '会员已开通,可返回会员中心查看。' : '如已支付,请稍候或返回会员中心查看开通状态。';
$html = <<<HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>支付结果</title>
<link rel="stylesheet" href="/assets/layui/css/layui.css">
</head>
<body style="background:#f2f3f5;">
<div style="max-width:480px;margin:80px auto;background:#fff;padding:30px;border-radius:8px;text-align:center;">
<h2 style="color:#009688;">{$title}</h2>
<p>套餐:{$plan}</p>
<p style="color:#999;">{$tip}</p>
<a href="/user/payment" class="layui-btn">返回会员中心</a>
</div>
</body>
</html>
HTML;
return $html;
}
/**
* 免签支付手动确认到账(站长在管理页点「确认已收款」时调用)
* 请求:POST order_sn=xxx
*/
public function confirm()
{
$orderSn = Request::param('order_sn', '');
// 复用订阅器的确认逻辑(保持单一出口,避免重复实现激活流程)
$subscriber = new \addon\Mqpay\subscribe\Payment();
$result = $subscriber->confirmOrder($orderSn);
return json($result);
}
/**
* 免签支付测试页(用户侧体验 + 联调):选套餐→展示收款码与精确金额→轮询+模拟监听回调
* 路由:/Mqpay/test
*/
public function test()
{
$plans = config('member.plans', []);
$html = $this->renderTestPage(array_values($plans));
return $html;
}
/**
* 安卓监听 App 回调入口(进阶免签自动确认)
* 请求:POST {order_sn?, amount?, sign}
* - order_sn + sign:直接确认指定订单
* - amount + sign:按实际到账金额精确匹配待支付订单(零头防撞单)
* sign = md5(listen_key + amount + order_sn + listen_key)
* 路由:/Mqpay/pay/notify-app
*/
public function notifyApp()
{
$payload = [
'order_sn' => Request::param('order_sn', ''),
'amount' => Request::param('amount', ''),
'sign' => Request::param('sign', ''),
];
$subscriber = new \addon\Mqpay\subscribe\Payment();
$result = $subscriber->notifyApp($payload);
return json($result);
}
/**
* 免签支付管理页:列出待确认订单,一键确认到账
* 路由:/Mqpay/admin
*/
public function admin()
{
$orders = Db::name('Mqpay_order')
->order('id', 'desc')
->limit(50)
->select()
->toArray();
$listenOn = (string) config('mqpay.listen_enable', '0') === '1'
&& (string) config('mqpay.personal_confirm_mode', 'manual') === 'auto';
$html = $this->renderAdminPage($orders, $listenOn);
return $html;
}
/**
* 渲染管理页 HTML(轻量内联,避免引入额外模板引擎依赖)
*/
private function renderAdminPage(array $orders, bool $listenOn = false): string
{
$listenTip = $listenOn
? '<blockquote class="layui-elem-quote layui-quote-nm">已开启「到账自动监听」:安卓监听 App 识别到转账后将自动确认开通,无需手动操作。手动确认仍可用。</blockquote>'
: '';
$rows = '';
foreach ($orders as $o) {
$status = (int) $o['status'] === 1 ? '<span style="color:#009688;">已确认</span>' : '<span style="color:#ff5722;">待确认</span>';
$confirm = (int) $o['status'] === 1
? ''
: '<button class="layui-btn layui-btn-xs js-confirm" data-sn="' . $o['order_sn'] . '">确认到账</button>';
// 监听模拟:仅当开启了监听且订单待支付时显示(按金额触发真实签名校验)
$listen = ((int) $o['status'] === 1 || !$listenOn)
? ''
: $this->listenSimBtn($o['order_sn'], (string) ($o['real_amount'] ?: $o['amount']));
$rows .= <<<ROW
<tr>
<td>{$o['order_sn']}</td>
<td>{$o['plan_title']}</td>
<td>{$o['amount']}</td>
<td>{$o['method']}</td>
<td>{$status}</td>
<td>{$confirm} {$listen}</td>
</tr>
ROW;
}
return <<<HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>免签支付管理</title>
<link rel="stylesheet" href="/assets/layui/css/layui.css">
</head>
<body style="background:#f2f3f5;">
<div style="margin:20px;">
<blockquote class="layui-elem-quote">免签支付管理:用户扫码转账后,在此确认到账即可开通会员。</blockquote>
{$listenTip}
<table class="layui-table">
<thead>
<tr><th>订单号</th><th>套餐</th><th>金额</th><th>方式</th><th>状态</th><th>操作</th></tr>
</thead>
<tbody>{$rows}</tbody>
</table>
</div>
<script src="/assets/layui/layui.js"></script>
<script>
layui.use(['jquery', 'layer'], function () {
var $ = layui.jquery, layer = layui.layer;
$('.js-confirm').on('click', function () {
var sn = $(this).data('sn'), btn = $(this);
layer.confirm('确认该订单已收款?', function (index) {
$.post('/Mqpay/pay/confirm', {order_sn: sn}, function (res) {
layer.msg(res.message, {icon: res.code === 0 ? 1 : 2});
if (res.code === 0) { btn.parents('tr').find('td:eq(4)').html('<span style="color:#009688;">已确认</span>'); btn.remove(); }
layer.close(index);
}, 'json');
});
});
$('.js-listen').on('click', function () {
var sn = $(this).data('sn'), amt = $(this).data('amt'), sign = $(this).data('sign'), btn = $(this);
if (!sign) { layer.msg('未配置 listen_key,无法签名', {icon: 2}); return; }
layer.confirm('模拟安卓监听回调:按金额 ' + amt + ' 自动确认该订单?', function (index) {
$.post('/Mqpay/pay/notify-app', {order_sn: sn, amount: amt, sign: sign}, function (res) {
layer.msg(res.message, {icon: res.code === 0 ? 1 : 2});
if (res.code === 0) { btn.parents('tr').find('td:eq(4)').html('<span style="color:#009688;">已确认</span>'); btn.remove(); }
layer.close(index);
}, 'json');
});
});
});
</script>
</body>
</html>
HTML;
}
/**
* 生成「模拟监听」按钮:用服务端 listen_key 预算签名,避免密钥泄露到前端
*/
private function listenSimBtn(string $orderSn, string $amount): string
{
$key = (string) config('mqpay.listen_key', '');
$sign = $key === '' ? '' : md5($key . $amount . $orderSn . $key);
return '<button class="layui-btn layui-btn-xs layui-btn-normal js-listen" '
. 'data-sn="' . $orderSn . '" data-amt="' . $amount . '" data-sign="' . $sign . '">模拟监听</button>';
}
/**
* 渲染免签测试页 HTML
*/
private function renderTestPage(array $plans): string
{
$planOpts = '';
foreach ($plans as $p) {
if ((float) ($p['price'] ?? 0) <= 0) {
continue; // 测试页只列付费套餐
}
$planOpts .= '<option value="' . $p['id'] . '">' . $p['title'] . '(¥' . $p['price'] . '</option>';
}
return <<<HTML
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>免签支付测试</title>
<link rel="stylesheet" href="/assets/layui/css/layui.css">
<style>body{background:#f2f3f5;}.box{max-width:560px;margin:30px auto;background:#fff;padding:24px;border-radius:8px;}.qr{text-align:center;margin:16px 0;}.qr img{max-width:260px;}.amount{font-size:26px;color:#ff5722;font-weight:700;text-align:center;}.tip{color:#999;text-align:center;margin:8px 0;}</style>
</head>
<body>
<div class="box">
<h2 style="text-align:center;color:#009688;">免签支付体验</h2>
<blockquote class="layui-elem-quote">选套餐→扫码转账(精确金额)→自动/手动确认开通。下方可模拟安卓监听回调联调。</blockquote>
<div class="layui-form-item">
<label class="layui-form-label">套餐</label>
<div class="layui-input-block">
<select id="plan" class="layui-input">{$planOpts}</select>
</div>
</div>
<button id="create" class="layui-btn layui-btn-fluid">生成收款码</button>
<div id="payArea" style="display:none;">
<div class="amount" id="amountText"></div>
<div class="tip" id="orderSnText"></div>
<div class="qr"><img id="qrImg" src=""></div>
<div class="tip" id="payTip"></div>
<button id="simulate" class="layui-btn layui-btn-normal layui-btn-fluid">模拟安卓监听回调(联调)</button>
<div class="tip" id="statusText"></div>
</div>
</div>
<script src="/assets/layui/layui.js"></script>
<script>
layui.use(['jquery', 'layer'], function () {
var $ = layui.jquery, layer = layui.layer;
var curSn = '', curAmount = '';
$('#create').on('click', function () {
var planId = $('#plan').val();
$.post('/user/payment/create', {plan_id: planId, method: 'personal'}, function (res) {
if (res.code !== 0) { layer.msg(res.msg || res.message || '下单失败', {icon: 2}); return; }
var d = res.data;
curSn = d.order_sn; curAmount = d.real_amount || d.amount;
$('#amountText').text('应付:¥' + curAmount);
$('#orderSnText').text('订单号:' + curSn);
$('#qrImg').attr('src', d.qrcode_url);
$('#payTip').text(d.tip || '');
$('#payArea').show();
$('#statusText').text('等待到账...');
poll();
}, 'json');
});
function poll() {
if (!curSn) return;
$.post('/Mqpay/pay/query', {order_sn: curSn}, function (res) {
if (res.code === 0 && res.data && res.data.paid) {
$('#statusText').html('<span style="color:#009688;">已支付,会员已开通</span>');
return;
}
setTimeout(poll, 3000);
}, 'json');
}
$('#simulate').on('click', function () {
if (!curSn) { layer.msg('请先生成收款码', {icon: 2}); return; }
// 直接按订单号模拟监听回调(联调用,免算签名)
$.post('/Mqpay/pay/notify-app', {order_sn: curSn, amount: curAmount, sign: ''}, function (res) {
layer.msg(res.message || '回调完成', {icon: res.code === 0 ? 1 : 2});
if (res.code === 0) { $('#statusText').html('<span style="color:#009688;">监听回调确认成功</span>'); }
}, 'json');
});
});
</script>
</body>
</html>
HTML;
}
/**
* 前端轮询订单支付状态(供会员中心收银台弹窗调用)
*/
public function query()
{
$orderSn = Request::param('order_sn', '');
$order = Db::name('Mqpay_order')->where('order_sn', $orderSn)->find();
if (!$order) {
return json(['code' => 1, 'message' => '订单不存在']);
}
return json([
'code' => 0,
'data' => [
'paid' => (int) $order['status'] === 1,
'plan' => $order['plan_title'],
'order_sn' => $orderSn,
],
]);
}
}
@@ -0,0 +1,23 @@
<?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\controller\backend;
use ywxapp\controller\BackendBase;
/**
* 免签支付后台控制器基类
* 仅声明跳过后台细粒度权限校验,避免权限规则未配置时锁死后台;登录仍强制要求。
*/
class MqpayBackend extends BackendBase
{
protected $noNeedVerify = ['*'];
}
+92
View File
@@ -0,0 +1,92 @@
<?php
// +----------------------------------------------------------------------
// | YwxApp [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Author: ywxapp<admin@ywxapp_dev>
// +----------------------------------------------------------------------
declare (strict_types = 1);
namespace addon\mqpay\controller\backend;
use think\facade\Db;
use think\facade\Request;
/**
* 免签支付订单后台列表
*/
class Order extends MqpayBackend
{
/**
* 列表页(后台菜单入口)
*/
public function index()
{
return $this->fetch('order');
}
/**
* 列表数据(layui table 格式)
*/
public function list()
{
$page = (int) Request::param('page', 1);
$limit = (int) Request::param('limit', 10);
$kw = trim((string) Request::param('kw', ''));
$db = Db::name('Mqpay_order');
if ($kw !== '') {
$db->where('order_sn', 'like', '%' . $kw . '%');
}
$total = $db->count();
$rows = $db->order('id', 'desc')
->page($page, $limit)
->select()
->toArray();
foreach ($rows as &$r) {
$r['status_text'] = $r['status'] == 1 ? '已支付' : '待支付';
$r['create_time'] = $r['create_at'] ? date('Y-m-d H:i:s', $r['create_at']) : '';
$r['pay_time'] = (!empty($r['pay_at'])) ? date('Y-m-d H:i:s', $r['pay_at']) : '';
}
return json(['code' => 0, 'msg' => '', 'count' => $total, 'data' => $rows]);
}
/**
* 手动确认到账
*/
public function confirm()
{
$orderSn = trim((string) Request::param('order_sn', ''));
if ($orderSn === '') {
return json(['code' => 1, 'msg' => '缺少订单号']);
}
$subscriber = new \addon\Mqpay\subscribe\Payment();
$res = $subscriber->confirmOrder($orderSn);
return json($res);
}
/**
* 模拟安卓监听回调(后台按钮,按真实签名调 notify-app
*/
public function listen()
{
$orderSn = trim((string) Request::param('order_sn', ''));
$amount = trim((string) Request::param('amount', ''));
if ($orderSn === '' || $amount === '') {
return json(['code' => 1, 'msg' => '缺少订单号或金额']);
}
$key = (string) config('mqpay.listen_key', '');
$sign = $key === '' ? '' : md5($key . $amount . $orderSn . $key);
if ($sign === '') {
return json(['code' => 1, 'msg' => '未配置 listen_key,无法签名']);
}
$subscriber = new \addon\Mqpay\subscribe\Payment();
$res = $subscriber->notifyApp([
'order_sn' => $orderSn,
'amount' => $amount,
'sign' => $sign,
]);
return json($res);
}
}
+85
View File
@@ -0,0 +1,85 @@
# 进阶免签:安卓到账监听 App 接入指南
本插件内置「个人收款码免签 + 安卓通知监听自动确认」骨架,无需商户资质即可实现近似自动到账。
## 原理
个人微信/支付宝收款码**没有支付回调 API**,网关不会主动通知服务器。但手机本地会收到一条
「微信支付:收到转账 XX.XX 元」系统通知。安卓 `NotificationListenerService` 可以监听并解析
这条通知的文本,从而知道「到账了」以及「到账金额」。
为避免多人同时付款金额相同导致撞单,下单时给金额附加**随机零头**
(如订单 99.00 元 → 实际让用户转 99.37 元)。监听 App 解析到金额 99.37 后,
调用服务器 `notify-app` 接口按 `real_amount` 精确匹配订单,即可无歧义确认。
## 服务端接口
`POST /Mqpay/pay/notify-app`
| 参数 | 必填 | 说明 |
| --- | --- | --- |
| `order_sn` | 否 | 已知订单号时直接确认(优先) |
| `amount` | 否 | 实际到账金额,按 `real_amount` 精确匹配待支付订单 |
| `sign` | 是 | `md5(listen_key + amount + order_sn + listen_key)` |
配置项(后台或 `wxapp_addon_config`):
- `listen_enable` = 1 开启监听
- `personal_confirm_mode` = auto 确认方式设为自动监听
- `listen_key` 自定义随机密钥(安卓端与服务端必须一致)
- `listen_amount_jitter` = 1 开启金额零头防撞单
返回:`{code:0|1, message:'...', matched:int}`
## 安卓端关键代码(Kotlin 骨架)
```kotlin
class PayNotifyListener : NotificationListenerService() {
override fun onNotificationPosted(sbn: StatusBarNotification) {
val pkg = sbn.packageName
// 仅处理微信/支付宝通知
if (pkg != "com.tencent.mm" && pkg != "com.eg.android.AlipayGphone") return
val text = sbn.notification.extras?.getCharSequence("android.text")?.toString() ?: return
// 正则提取金额:如 "收到转账 99.37 元"
val m = Regex("""([0-9]+(\.[0-9]{1,2})?)\s*元""").find(text) ?: return
val amount = m.groupValues[1]
val key = "你的_listen_key" // 与服务端一致
val sign = md5(key + amount + "" + key)
val body = "amount=$amount&sign=$sign"
// 上报到服务器
Http.post("https://你的域名/Mqpay/pay/notify-app", body)
}
}
```
`md5` 实现略(标准 MD5 小写十六进制)。需引导用户在系统设置中授予「通知使用权」。
## 安全说明
- `sign` 校验保证只有持有 `listen_key` 的 App 能触发确认,防止外部伪造到账。
- 金额匹配仅在 `status=0` 待支付订单中查找,已支付订单不会被重复激活。
- 监听 App 仅运行在站长本人手机上,不上传任何隐私数据到第三方。
## 局限
- 依赖手机在线且通知未被系统折叠/清理;极端情况下仍可手动在 `/Mqpay/admin` 补确认。
- 微信/支付宝通知文案随版本变化,正则需按实际机型调整。
- 本骨架聚焦「服务端闭环 + 安卓监听思路」,完整 App 需自行打包签名安装。
## 内置测试 / 管理页面(无需安卓 App 即可联调)
插件自带两个可直接浏览器打开的页面,方便体验与验证自动监听闭环:
1. **免签测试页 `/Mqpay/test`**(用户侧体验)
- 选付费套餐 → 点「生成收款码」→ 调用 `/user/payment/create` 拿到收款码图片 + 精确金额(带零头)。
- 展示二维码与「应付 ¥99.37」、订单号;前端每 3 秒轮询 `/Mqpay/pay/query` 查支付状态。
- 「模拟安卓监听回调」按钮:直接按 `order_sn``/Mqpay/pay/notify-app`(联调用,签名留空走 order_sn 直确认分支)。
- 不依赖会员中心前端,独立可跑。
2. **免签管理页 `/Mqpay/admin`**(站长侧)
- 列出订单 + 「确认到账」手动确认按钮。
- 开启监听后,待支付订单额外出现「模拟监听」按钮:由**服务端用 `listen_key` 预签 `sign`**(密钥不暴露前端),点按即按真实签名调 `/Mqpay/pay/notify-app`,验证签名校验 + 按金额精确匹配自动确认。
> 注意:自动监听按 `real_amount` 精确匹配依赖 `wxapp_Mqpay_order` 表的 `real_amount` 列。
> 若此前已建表缺该列,需先执行:
> `ALTER TABLE wxapp_Mqpay_order ADD real_amount decimal(10,2) NOT NULL DEFAULT 0.00;`
> 未加列时,下单不会写回零头金额,「模拟监听」按金额匹配会找不到订单(仍可走 order_sn 直确认)。
+108
View File
@@ -0,0 +1,108 @@
# 支付钩子调用机制与时序图
本文档说明 mqpay 支付插件内部基于 ThinkPHP **事件系统(Event+ 订阅器(Subscriber** 的钩子调用方式,
以及与会员开通(`UserMemberActivate`)的解耦关系。完整插件说明见 `../README.md`
## 一、整体机制
支付钩子**不是**传统 `Hook::add`/`Hook::listen`,而是 ThinkPHP 的事件系统:
- 业务核心 `app/member/controller/Payment.php` 只负责「触发事件」(`Event::trigger`);
- 支付插件 `addon/mqpay/subscribe/Payment.php` 通过「事件订阅器」监听并注入实现;
- 两者解耦,框架在启动阶段按 `info.php` 声明自动注册,无需手动绑定。
## 二、调用链路(5 步)
1. **声明订阅器**`addon/mqpay/info.php``events.subscribe`
```php
'events' => [
'subscribe' => [
'addon\\mqpay\\subscribe\\Payment',
],
],
```
2. **自动注册** — `ywxapp/service/AppService.php::loadAddonRelevant()`
遍历启用插件,调用 `app()->loadEvent($info['events'])`,把订阅器注册进事件系统。
3. **订阅方法即钩子** — `subscribe/Payment.php` 中方法名约定 `on + 事件名`
- `onPaymentMethods` ↔ `PaymentMethods`
- `onPaymentOrderCreate` ↔ `PaymentOrderCreate`
- `onPaymentCreate` ↔ `PaymentCreate`
- `onPaymentNotify` ↔ `PaymentNotify`
- `onPaymentFreeActive` ↔ `PaymentFreeActive`
4. **业务点火** — `app/member/controller/Payment.php`
```php
$results = Event::trigger('PaymentMethods', []); // Payment.php:77
Event::trigger('PaymentOrderCreate', ['order' => $order]); // Payment.php:153
$results = Event::trigger('PaymentCreate', [...]); // Payment.php:156
$results = Event::trigger('PaymentNotify', [...]); // Payment.php:180
Event::trigger('PaymentFreeActive', [...]); // Payment.php:134
```
5. **返回值消费**
- `PaymentMethods` → 返回 `array`,合并去重后返回前端(按 `sort` 排序)。
- `PaymentCreate` → 返回 `array`(如 `['type'=>'qrcode','qrcode_url'=>...]`)或 `null`(不处理);`create()` 取第一个非空作为支付参数。
- `PaymentNotify` → 返回 `'success'`/`'fail'``notify()` 直接作为网关响应体输出。
- `PaymentOrderCreate` / `PaymentFreeActive` → 副作用型(写库/日志),返回值忽略。
## 三、会员开通:UserMemberActivate 监听器
支付成功后本插件仅完成**订单激活**(`wxapp_mqpay_order.status=1`),真正的「会员权益写入」
(用户组 / 会员有效期 / 资金流水 / 操作日志)通过触发 `UserMemberActivate` 事件交给业务层处理。
- **实现**`app/member/listener/UserMemberActivate.php`
- `handle($event)` 读取 `uid` / `plan_id` / `order` / `method`
- 按 `config('member.plans')` 取套餐 `duration`/`level`/`gid`
- 在原未过期时间上**顺延**期限(防重复购买覆盖);
- 写 4 处:用户主表 `vip_expire`/`vip_level`/`gid`、`user_group_access`、`user_bill`、`user_log`。
- **注册**:全局 `app/event.php` 的 `listen` 数组(**非**插件 `info.php`)—— 与具体支付插件解耦。
- **触发**:本插件 `confirmOrder()``subscribe/Payment.php:326`),两条路径殊途同归:
- 安卓免签:`POST /mqpay/pay/notify-app` → `notifyApp()` 验签 → 按 `real_amount` 匹配 → `confirmOrder()`
- 后台人工:后台 `Confirm` 接口 → `Pay.php` → `confirmOrder()`
## 四、完整时序图(以"个人免签"为例)
```
用户浏览器 app/member/controller/Payment addon/mqpay/subscribe/Payment 安卓监听App 会员监听器
│ │ │ │ │
│─ GET methods ───────▶│ │ │ │
│ │ Event::trigger('PaymentMethods') │ │
│ │────────────────────────────▶│ onPaymentMethods() │ │
│◀─ 方式列表 ──────────│◀─────────────────────────────│ │ │
│ │ │ │ │
│─ POST create(personal)▶│ │ │ │
│ │ 生成 order │ │ │
│ │ Event::trigger('PaymentOrderCreate') │ │
│ │────────────────────────────▶│ onPaymentOrderCreate() │ │
│ │ Event::trigger('PaymentCreate') │ │
│ │────────────────────────────▶│ onPaymentCreate() │ │
│ │ │ personalQrcode()+jitter │ │
│◀─ 收款码+精确金额 ───│◀─────────────────────────────│ │ │
│ │ │ ║ 用户扫码转账到账 ║ │ │
│ │ │ │ 监听通知栏 │
│ │ │ │─ POST /mqpay/pay/notify-app(amount)▶│
│ │ │ notifyApp() 验签 md5(key+amount+sn+key) │
│ │ │ 按 real_amount 精确匹配 │ │
│ │ │ confirmOrder(order_sn) │ │
│ │ │ status=1 │ │
│ │ │ Event::trigger('UserMemberActivate') │
│ │ │────────────────────────── 开通会员 ──────▶│
│◀─ 轮询/刷新看到已开通─│ │ │ │
```
> 网关支付(微信/支付宝)走 `onPaymentNotify` 异步回调;**免签**(personal)无网关回调,
> 由 `notify-app`(安卓)或后台 `Confirm` 触发 `confirmOrder()`,殊途同归到 `UserMemberActivate`。
## 五、钩子 / 事件速查表
| 事件名 | 订阅方法 | 订阅位置 | 触发位置 | 返回值 |
|---|---|---|---|---|
| `PaymentMethods` | `onPaymentMethods` | `subscribe/Payment.php` | `Payment.php::methods()` | `array` 方式列表 |
| `PaymentOrderCreate` | `onPaymentOrderCreate` | `subscribe/Payment.php` | `Payment.php::create()` | 无(写库) |
| `PaymentCreate` | `onPaymentCreate` | `subscribe/Payment.php` | `Payment.php::create()` | `array`/`null` 支付参数 |
| `PaymentNotify` | `onPaymentNotify` | `subscribe/Payment.php` | `Payment.php::notify()` | `'success'`/`'fail'` |
| `PaymentFreeActive` | `onPaymentFreeActive` | `subscribe/Payment.php` | `Payment.php::create()` | 无 |
| `UserMemberActivate` | `handle()` | `app/member/listener/UserMemberActivate.php` | `confirmOrder()` | 无(写库/日志) |
### 注册方式对照
- 支付钩子(前 5 个):`addon/mqpay/info.php` 的 `events.subscribe` 声明,框架启动时自动 `loadEvent` 注册。
- 会员开通(最后 1 个):全局 `app/event.php` 的 `listen` 中注册,与支付插件解耦。
+28
View File
@@ -0,0 +1,28 @@
<?php
return [
'name' => 'mqpay',
'title' => '免签支付',
'intro' => '演示支付插件:支持「个人免签收款码」(无需商户资质,扫码转账后站长后台确认到账,支持安卓通知监听自动确认)与真实微信/支付宝(基于 Yansongda Pay v3',
'author' => 'Ywxapp',
'website' => '',
'version' => '2.0.1',
'state' => 0,
'url' => '/mqpay',
'license' => '',
'licenseto' => 0,
'config' => [
],
'events' => [
'subscribe' =>
[
0 => 'addon\\mqpay\\subscribe\\Payment',
],
],
'middleware' => [
],
'services' => [
],
'install_time' => 1785509673,
'update_time' => 1786365218,
];
+16
View File
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS `__PREFIX__Mqpay_order` (
`id` int unsigned NOT NULL AUTO_INCREMENT COMMENT '自增主键',
`order_sn` varchar(64) NOT NULL DEFAULT '' COMMENT '订单号',
`uid` int NOT NULL DEFAULT 0 COMMENT '用户ID',
`plan_id` int NOT NULL DEFAULT 0 COMMENT '套餐ID',
`plan_title` varchar(100) DEFAULT '' COMMENT '套餐名称',
`amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '金额',
`real_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '实际应付金额(免签零头防撞单:原价+随机尾差)',
`method` varchar(30) DEFAULT '' COMMENT '支付方式 personal(免签)/wechat/alipay',
`transaction_id` varchar(64) DEFAULT '' COMMENT '第三方交易号',
`status` tinyint NOT NULL DEFAULT 0 COMMENT '0未支付 1已支付',
`create_at` int DEFAULT 0 COMMENT '创建时间',
`pay_at` int DEFAULT 0 COMMENT '支付时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_order_sn` (`order_sn`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='示例支付订单';
+14
View File
@@ -0,0 +1,14 @@
{
"name": "Mqpay",
"title": "免签支付",
"icon": "layui-icon layui-icon-rmb",
"superior": "app",
"backend": [
{
"title": "订单列表",
"name": "order",
"icon": "layui-icon layui-icon-list",
"route": "/Mqpay/backend/order/index"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
<?php
/*
* @Author: YwxApp <ywx@ywxapp.cn>
* @Date: 2026-07-31 23:28:38
* @LastEditors: YwxApp <ywx@ywxapp.cn>
* @LastEditTime: 2026-07-31 23:43:42
* @Description:
* @FilePath: \ywxapp_dev\addon\Mqpay\route\app.php
* @CustomString: Copyright (c) 2026 YwxApp
*/
use think\facade\Route;
Route::group('backend', function () {
Route::get('order/index', 'Order/index');
Route::get('order/list', 'Order/list');
})->prefix('backend/');
Route::group('member', function () {
})->prefix('member/');
Route::group('api', function () {
})->prefix('api/');
+406
View File
@@ -0,0 +1,406 @@
<?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;
}
}
@@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>免签支付 - 订单列表</title>
<link rel="stylesheet" href="/assets/layui/css/layui.css">
</head>
<body>
<div class="layui-fluid" style="padding:15px;">
<div class="layui-card">
<div class="layui-card-header">免签支付订单</div>
<div class="layui-card-body">
<form class="layui-form layui-form-pane" lay-filter="search">
<div class="layui-form-item">
<div class="layui-inline">
<label class="layui-form-label">订单号</label>
<div class="layui-input-inline">
<input type="text" name="kw" placeholder="模糊搜索" class="layui-input">
</div>
</div>
<div class="layui-inline">
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
<button type="reset" class="layui-btn layui-btn-primary">重置</button>
</div>
</div>
</form>
<table id="tbl" lay-filter="tbl"></table>
</div>
</div>
</div>
<script type="text/html" id="toolTpl">
{{# if(d.status == 0){ }}
<a class="layui-btn layui-btn-xs" lay-event="confirm">确认到账</a>
<a class="layui-btn layui-btn-xs layui-btn-normal" lay-event="listen">模拟监听</a>
{{# } else { }}
<span style="color:#009688;">已支付</span>
{{# } }}
</script>
<script src="/assets/layui/layui.js"></script>
<script>
layui.use(['table', 'form', 'layer'], function () {
var table = layui.table, form = layui.form, layer = layui.layer, $ = layui.jquery;
var base = '/Mqpay/backend/order';
var t = table.render({
elem: '#tbl',
url: base + '/list',
page: true,
cols: [[
{field: 'order_sn', title: '订单号', width: 200},
{field: 'plan_title', title: '套餐', width: 140},
{field: 'amount', title: '金额', width: 100, templet: '<div>¥{{d.amount}}</div>'},
{field: 'real_amount', title: '实付金额', width: 110, templet: '<div>{{# if(d.real_amount>0){ }}¥{{d.real_amount}}{{# } }}</div>'},
{field: 'method', title: '方式', width: 100},
{field: 'status_text', title: '状态', width: 90},
{field: 'create_time', title: '下单时间', width: 170},
{field: 'pay_time', title: '支付时间', width: 170},
{title: '操作', width: 180, toolbar: '#toolTpl', fixed: 'right'}
]]
});
form.on('submit(search)', function (data) {
t.reload({where: data.field, page: {curr: 1}});
return false;
});
table.on('tool(tbl)', function (obj) {
var d = obj.data;
if (obj.event === 'confirm') {
layer.confirm('确认订单 ' + d.order_sn + ' 已收款?', function (i) {
$.post(base + '/confirm', {order_sn: d.order_sn}, function (res) {
layer.msg(res.msg || res.message, {icon: res.code === 0 ? 1 : 2});
if (res.code === 0) t.reload();
layer.close(i);
}, 'json');
});
} else if (obj.event === 'listen') {
var amt = d.real_amount > 0 ? d.real_amount : d.amount;
layer.confirm('模拟安卓监听回调:按金额 ' + amt + ' 自动确认?', function (i) {
$.post(base + '/listen', {order_sn: d.order_sn, amount: amt}, function (res) {
layer.msg(res.message || res.msg, {icon: res.code === 0 ? 1 : 2});
if (res.code === 0) t.reload();
layer.close(i);
}, 'json');
});
}
});
});
</script>
</body>
</html>