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