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
+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;
}
}