Files
YwxAppThink/addon/paycenter/service/Payment.php
T
yangwx 18101cf11a feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用
安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

284 lines
9.6 KiB
PHP
Raw 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
declare(strict_types=1);
namespace addon\paycenter\service;
use addon\paycenter\library\AlipayChannel;
use addon\paycenter\library\contract\Channel;
use addon\paycenter\library\PayException;
use addon\paycenter\library\PayFactory;
use addon\paycenter\library\TradeLog;
use addon\paycenter\library\WechatChannel;
use think\facade\Event;
use think\facade\Log;
use think\Response;
/**
* 支付门面(业务侧唯一入口)。
*
* 业务只做两件事:
* 1) Payment::create(...) 拉起支付;
* 2) 监听 PayPaid 事件发货(应用市场见 app\appmall\listener\OrderPaid)。
* 验签、查单、退款、回调应答全部在插件内,业务代码里不该出现任何 `Pay::alipay()`。
*
* 业务侧调用前用 class_exists(Payment::class) 判一下:插件未安装时不致命。
*/
class Payment
{
/** 渠道登记表:新增渠道只改这里 */
public const CHANNELS = [
'alipay' => 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<int,array{code:string,name:string,scenes:array,scene:string}>
*/
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', ''), '/');
}
}