AlipayChannel::class, 'wechat' => WechatChannel::class, ]; /** 支付成功事件名(业务在 app/event.php 监听) */ public const EVENT_PAID = 'PayPaid'; /** * 取渠道实例 * @throws PayException 未知渠道 */ public static function channel(string $code): Channel { $class = self::CHANNELS[$code] ?? null; if ($class === null) { throw new PayException('不支持的支付渠道:' . $code, 400); } return new $class(); } /** * 可用支付方式(供前台收银台渲染):只返回「后台启用 + 商户参数已配置」的渠道。 * @return array */ public static function methods(): array { $list = []; foreach (self::CHANNELS as $code => $class) { if (!self::enabled($code)) { continue; } /** @var Channel $ch */ $ch = new $class(); $list[] = [ 'code' => $ch->code(), 'name' => $ch->name(), 'scenes' => $ch->scenes(), 'scene' => self::defaultScene($code), ]; } return $list; } /** 渠道是否可用(后台开关 + 商户参数齐备 + SDK 已安装) */ public static function enabled(string $code): bool { if (!isset(self::CHANNELS[$code]) || !PayFactory::available()) { return false; } if ((string) self::cfg($code . '_enable', '1') !== '1') { return false; } return self::channel($code)->configured(); } /** 是否有任意一个渠道可用(业务据此决定「能否真实支付 / 只能 mock」) */ public static function anyEnabled(): bool { foreach (array_keys(self::CHANNELS) as $code) { if (self::enabled($code)) { return true; } } return false; } public static function defaultChannel(): string { $channel = (string) self::cfg('default_channel', 'alipay'); return isset(self::CHANNELS[$channel]) ? $channel : 'alipay'; } public static function defaultScene(string $code): string { $key = $code === 'wechat' ? 'wechat_scene' : 'alipay_scene'; $scene = (string) self::cfg($key, $code === 'wechat' ? 'qr' : 'web'); $scenes = self::channel($code)->scenes(); return in_array($scene, $scenes, true) ? $scene : ($scenes[0] ?? 'web'); } /** * 插件统一回调地址(业务不传 notify 时用)。 * 形如 https://host/paycenter/api/notify?channel=alipay */ public static function notifyUrl(string $code): string { return self::domain() . '/paycenter/api/notify?channel=' . $code; } /** * 拉起支付。 * * @param string $channel alipay / wechat * @param array $order ['trade_no','amount','subject','biz','biz_id','attach','openid'?] * @param string $scene 留空则用后台配置的默认场景 * @param array $urls ['notify'=>..., 'return'=>...];notify 留空用插件统一回调地址 * @return array{type:string,content:mixed,trade_no:string} * @throws PayException 渠道不可用 / 下单失败 */ public static function create(string $channel, array $order, string $scene = '', array $urls = []): array { if (!self::enabled($channel)) { throw new PayException('该支付方式未启用或尚未配置商户参数(' . $channel . ')', 400); } if (empty($order['trade_no'])) { throw new PayException('缺少订单号 trade_no', 400); } $scene = $scene !== '' ? $scene : self::defaultScene($channel); $urls = [ 'notify' => (string) ($urls['notify'] ?? '') ?: self::notifyUrl($channel), 'return' => (string) ($urls['return'] ?? ''), ]; // 下单前先落流水:即便网关调用失败,后台也能看到「谁在什么时候试图付哪笔钱」 TradeLog::record($order, $channel, $scene); try { return self::channel($channel)->create($order, $scene, $urls); } catch (PayException $e) { throw $e; } catch (\Throwable $e) { Log::error('[paycenter] 下单失败 ' . $channel . '/' . $scene . ':' . $e->getMessage()); throw new PayException('支付发起失败:' . $e->getMessage(), 500); } } /** * 主动查单(回调丢失 / 用户跳回落地页时兜底)。 * 查询失败一律按「未支付」处理:宁可让用户多轮询一次,也不能凭一次失败的响应发货。 * * @return array{paid:bool,amount:float,gateway_no:string} */ public static function query(string $channel, string $tradeNo): array { try { if (!self::enabled($channel)) { return ['paid' => false, 'amount' => 0.0, 'gateway_no' => '']; } return self::channel($channel)->query($tradeNo); } catch (\Throwable $e) { Log::warning('[paycenter] 主动查单失败:' . $e->getMessage(), ['trade_no' => $tradeNo, 'channel' => $channel]); return ['paid' => false, 'amount' => 0.0, 'gateway_no' => '']; } } /** * 异步回调入口:验签 → 记流水 → 触发 PayPaid → 应答网关。 * * 业务侧(应用市场)在 app/event.php 监听 PayPaid 完成发货; * 本方法只保证「钱确实收到了」,不关心收到钱之后做什么。 */ public static function notify(string $channel): Response { $ch = self::channel($channel); try { $data = $ch->notify(); } catch (\Throwable $e) { Log::warning('[paycenter] 回调验签失败:' . $e->getMessage(), ['channel' => $channel]); return $ch->ack(false); } if (empty($data['trade_no'])) { return $ch->ack(false); } $trade = TradeLog::touchNotify($data, $channel); // 非「支付成功」的通知(如退款通知、待支付通知)也要正常应答,否则网关会一直重试 if (empty($data['paid'])) { return $ch->ack(true); } $trade = TradeLog::markPaid($data) ?: $trade; Event::trigger(self::EVENT_PAID, [ 'trade_no' => (string) $data['trade_no'], 'channel' => $channel, 'amount' => (float) $data['amount'], 'gateway_no' => (string) ($data['gateway_no'] ?? ''), 'biz' => (string) ($trade['biz'] ?? ''), 'biz_id' => (int) ($trade['biz_id'] ?? 0), 'attach' => $trade['attach'] ?? '', 'scene' => (string) ($trade['scene'] ?? ''), 'trade' => $trade, ]); return $ch->ack(true); } /** * 退款(按流水查渠道,业务无需知道这笔单当初用的哪个渠道)。 * @return array{ok:bool,message:string} */ public static function refund(string $tradeNo, ?float $amount = null): array { $trade = TradeLog::find($tradeNo); if (!$trade) { return ['ok' => false, 'message' => '支付流水不存在:' . $tradeNo]; } $channel = (string) $trade['channel']; if (!isset(self::CHANNELS[$channel])) { return ['ok' => false, 'message' => '未知的支付渠道:' . $channel]; } $total = (float) $trade['amount']; $amount = $amount === null || $amount <= 0 ? $total : $amount; $result = self::channel($channel)->refund($tradeNo, $amount, $total); if (!empty($result['ok'])) { TradeLog::markRefunded($tradeNo); } return $result; } /** 读插件配置(后台开关优先,其次 config.php 默认值) */ private static function cfg(string $key, $default = null) { static $conf = null; if ($conf === null) { $conf = \ywxapp\AddonBase::readConfig('paycenter'); } return $conf[$key] ?? $default; } /** 当前站点根地址(CLI 下取 APP_HOST) */ private static function domain(): string { $req = function_exists('request') ? request() : null; if ($req) { try { $domain = (string) $req->domain(true); if ($domain !== '') { return rtrim($domain, '/'); } } catch (\Throwable) { // CLI 下 request() 可能不可用 } } return rtrim((string) env('APP_HOST', ''), '/'); } }