安全(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(市场 / 开发者中心 / 审核 / 分润)
190 lines
6.5 KiB
PHP
190 lines
6.5 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace addon\paycenter\library;
|
||
|
||
use addon\paycenter\library\contract\Channel;
|
||
use Psr\Http\Message\MessageInterface;
|
||
use think\Response;
|
||
use Yansongda\Artful\Rocket;
|
||
use Yansongda\Supports\Collection;
|
||
|
||
/**
|
||
* 微信支付渠道(API v3)。
|
||
*
|
||
* 场景映射(yansongda/pay 的 _type):
|
||
* qr → Native 扫码(返回 code_url)
|
||
* h5 → H5(返回 h5_url,手机浏览器跳微信)
|
||
* mp → 公众号 JSAPI(需 openid,返回前端唤起参数)
|
||
* mini → 小程序(需 openid,返回前端唤起参数)
|
||
* app → App(返回前端唤起参数)
|
||
*
|
||
* 注:微信早已下线官方沙箱,mode 只能留 0;验证需真实小额支付后退款。
|
||
* 平台证书(回调验签用)留空时 SDK 会凭 APIv3 密钥自动下载,正式环境建议落到
|
||
* runtime/cert/wechat/ 并配置 wechat_public_cert_path,避免每次请求都去拉取。
|
||
*/
|
||
class WechatChannel implements Channel
|
||
{
|
||
public function code(): string
|
||
{
|
||
return 'wechat';
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return '微信支付';
|
||
}
|
||
|
||
public function configured(): bool
|
||
{
|
||
$cfg = PayConfig::get('wechat');
|
||
|
||
return !empty($cfg['mch_id'])
|
||
&& !empty($cfg['mch_secret_key'])
|
||
&& !empty($cfg['mch_secret_cert'])
|
||
&& !empty($cfg['mch_public_cert_path'])
|
||
&& !empty($cfg['app_id']);
|
||
}
|
||
|
||
public function scenes(): array
|
||
{
|
||
return ['qr', 'h5', 'mp', 'mini', 'app'];
|
||
}
|
||
|
||
public function create(array $order, string $scene, array $urls): array
|
||
{
|
||
$params = [
|
||
'out_trade_no' => (string) $order['trade_no'],
|
||
'description' => (string) $order['subject'],
|
||
// 微信金额单位是【分】,且必须是整数
|
||
'amount' => [
|
||
'total' => (int) round((float) $order['amount'] * 100),
|
||
'currency' => 'CNY',
|
||
],
|
||
];
|
||
if (!empty($urls['notify'])) {
|
||
$params['notify_url'] = (string) $urls['notify'];
|
||
}
|
||
|
||
$pay = PayFactory::wechat();
|
||
switch ($scene) {
|
||
case 'h5':
|
||
$params['scene_info'] = [
|
||
'payer_client_ip' => (string) ($order['client_ip'] ?? (request()->ip() ?: '127.0.0.1')),
|
||
'h5_info' => ['type' => 'Wap'],
|
||
];
|
||
$result = $pay->h5($params);
|
||
break;
|
||
case 'mp':
|
||
case 'mini':
|
||
// JSAPI / 小程序必须带 openid,由业务侧传入(order['openid'])
|
||
if (empty($order['openid'])) {
|
||
throw new PayException('微信 JSAPI/小程序下单缺少 openid', 400);
|
||
}
|
||
$params['payer'] = ['openid' => (string) $order['openid']];
|
||
$result = $scene === 'mini' ? $pay->mini($params) : $pay->mp($params);
|
||
break;
|
||
case 'app':
|
||
$result = $pay->app($params);
|
||
break;
|
||
default:
|
||
$result = $pay->scan($params);
|
||
}
|
||
|
||
return ['trade_no' => (string) $order['trade_no']] + self::normalize($result);
|
||
}
|
||
|
||
public function query(string $tradeNo): array
|
||
{
|
||
$result = PayFactory::wechat()->query(['out_trade_no' => $tradeNo]);
|
||
|
||
return [
|
||
'paid' => (string) $result->get('trade_state', '') === 'SUCCESS',
|
||
'amount' => self::amount($result->get('amount')),
|
||
'gateway_no' => (string) $result->get('transaction_id', ''),
|
||
];
|
||
}
|
||
|
||
public function notify(): array
|
||
{
|
||
$data = PayFactory::wechat()->callback();
|
||
|
||
return [
|
||
'paid' => in_array((string) $data->get('trade_state', ''), ['SUCCESS', 'REFUND'], true),
|
||
'trade_no' => (string) $data->get('out_trade_no', ''),
|
||
'amount' => self::amount($data->get('amount')),
|
||
'gateway_no' => (string) $data->get('transaction_id', ''),
|
||
];
|
||
}
|
||
|
||
public function ack(bool $ok = true): Response
|
||
{
|
||
$body = $ok
|
||
? json_encode(['code' => 'SUCCESS', 'message' => '成功'], JSON_UNESCAPED_UNICODE)
|
||
: json_encode(['code' => 'FAIL', 'message' => '失败'], JSON_UNESCAPED_UNICODE);
|
||
|
||
return response($body, $ok ? 200 : 500, ['Content-Type' => 'application/json']);
|
||
}
|
||
|
||
public function refund(string $tradeNo, float $amount, float $total): array
|
||
{
|
||
try {
|
||
PayFactory::wechat()->refund([
|
||
'out_trade_no' => $tradeNo,
|
||
'out_refund_no' => 'R' . $tradeNo . date('His'),
|
||
'amount' => [
|
||
'refund' => (int) round($amount * 100),
|
||
'total' => (int) round($total * 100),
|
||
'currency' => 'CNY',
|
||
],
|
||
]);
|
||
|
||
return ['ok' => true, 'message' => '退款申请已提交'];
|
||
} catch (\Throwable $e) {
|
||
return ['ok' => false, 'message' => $e->getMessage()];
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 微信金额字段有两种形态:数组 ['total'=>100,'currency'=>'CNY'](分)或标量。
|
||
*/
|
||
private static function amount($amount): float
|
||
{
|
||
if (is_array($amount)) {
|
||
return round(((int) ($amount['total'] ?? 0)) / 100, 2);
|
||
}
|
||
|
||
return round((float) $amount, 2);
|
||
}
|
||
|
||
/**
|
||
* @return array{type: string, content: mixed}
|
||
*/
|
||
private static function normalize($result): array
|
||
{
|
||
if ($result instanceof Rocket) {
|
||
$result = $result->getDestination() ?? $result->getPayload();
|
||
}
|
||
if ($result instanceof MessageInterface) {
|
||
return ['type' => 'form', 'content' => $result->getBody()->getContents()];
|
||
}
|
||
if ($result instanceof Collection) {
|
||
$qr = (string) ($result->get('code_url') ?: $result->get('qr_code') ?: '');
|
||
if ($qr !== '') {
|
||
return ['type' => 'qrcode', 'content' => $qr];
|
||
}
|
||
$url = (string) ($result->get('h5_url') ?: $result->get('mweb_url') ?: '');
|
||
if ($url !== '') {
|
||
return ['type' => 'url', 'content' => $url];
|
||
}
|
||
|
||
return ['type' => 'params', 'content' => $result->toArray()];
|
||
}
|
||
if (is_string($result)) {
|
||
return ['type' => 'form', 'content' => $result];
|
||
}
|
||
|
||
throw new PayException('支付发起失败:网关返回了无法识别的响应', 500);
|
||
}
|
||
}
|