fix: validate EPay successful payment callbacks

This commit is contained in:
hellcat 2026-10-11 11:01:57 +08:00
parent 16a8d769e1
commit 6045748772
5 changed files with 155 additions and 11 deletions

View File

@ -28,6 +28,8 @@
## 支付与授权边界
支付优先复用已有配置;涉及支付代码修改需按此前用户边界先确认具体改动。不得把邮件发布顺便混入支付变更,也不得未经授权执行真实付款。
支付优先复用已有配置;涉及支付代码修改需按此前用户边界先确认具体改动。2026-10-11 用户已授权补齐 EPay 支付状态、订单金额及关联信息校验,随后指定支付对接仅在线上联调,不调整本地支付配置。空中支付已有商户设置尽量保留,需要新增的设置可添加。不得把邮件发布顺便混入支付变更,也不得未经授权执行真实付款。
常规已授权的修复、验证、文档与 Git 发布继续推进;涉及删除不可恢复数据、变更账号权限范围、新的外部数据传输目的地等敏感边界需要明确授权。已有授权不用重复询问。备份、脱敏与隔离数据不自动等于完成真实业务验收。
EPay 按空中支付 V1 文档签名(忽略 sign、sign_type 和空值,参数不进行 URL 编码)。回调须验证签名、成功状态、商户、指定支付类型、订单所属渠道及实付金额(订单金额加手续费),取消订单或冲突交易号不能开通;相同成功回调保留现有订单幂等处理。

View File

@ -6,6 +6,8 @@
本地 http://dpl.loc/dove 已配置到当前 Laravel public/。本机 Nginx 配置 /usr/local/etc/nginx/servers/dpl.loc.conf,PHP-FPM 使用现有 9000 端口。系统级服务重载可能需要 Mac 管理员授权;不持久保存系统密码。
本地开发与联调访问规则(用户于 2026-10-11 指定):本地不访问 tp-link.net,需要访问远端站点时使用 https://dpl.tomshell.com.de 代替;本地站点仍使用 http://dpl.loc。该访问约定不自动修改生产支付回调域名或生产配置。
## Git 发布顺序
1. 复核最新用户要求、目标服务器、仓库和改动范围;读取本地与线上 git status、当前 HEAD、远端。线上既有无关修改必须保留,不能直接 reset --hard、clean 或无范围 stash。

View File

@ -30,6 +30,12 @@ Resend 发信、常规 SMTP、配额回退、投递事件回调、master 一次
生产后台支付列表已实际核对:支付宝、微信支付均显示且开启,通知地址使用正式域名。本次仅修改生产配置数据,未修改支付代码,未创建订单或付款;真实支付和支付回调仍未验收。两次写入前均保存了受保护的加密备份,传输临时私密文件已清除;文档和 Git 不包含商户密钥。
## 空中支付对接检查与代码修正(2026-10-11)
已实际登录空中支付商户后台,确认账号状态正常、MD5+RSA 兼容模式已启用,提供的新密钥与商户后台一致。已有商户设置未更改;尝试补充空白网站域名时,页面以其他资料未完善拒绝保存,重新读取确认原字段均未改变。接口文档通过每笔请求的 notify_url、return_url 传递回调,不在商户页面单独配置回调地址。
用户授权继续后,已补齐 EPay 回调成功状态、金额(含手续费)、商户及渠道归属校验,并按官方 V1 规则生成签名。隔离测试 5 项、20 个断言通过,覆盖正常/重复回调、错误金额/状态/商户/渠道、缺失/错误签名、取消订单和冲突交易号。语法检查通过。尚未进行真实付款或真实成功回调验收;部署状态另行记录。用户随后要求仅线上联调,本地支付配置保持不动。
## 每次维护更新本文件
记录任务、修改文件、测试、提交、是否推送/部署、真实验收结果、尚未完成项与敏感边界。禁止写密码、令牌、原始用户邮件或未经核实的完成结论。

View File

@ -4,6 +4,7 @@ namespace Plugin\Epay;
use App\Services\Plugin\AbstractPlugin;
use App\Contracts\PaymentInterface;
use App\Models\Order;
class Plugin extends AbstractPlugin implements PaymentInterface
{
@ -66,25 +67,38 @@ class Plugin extends AbstractPlugin implements PaymentInterface
$params['type'] = $paymentType;
}
ksort($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->getConfig('key');
$params['sign'] = md5($str);
$params['sign'] = $this->signature($params);
$params['sign_type'] = 'MD5';
return [
'type' => 1,
'data' => $this->getConfig('url') . '/submit.php?' . http_build_query($params)
'data' => rtrim($this->getConfig('url'), '/') . '/submit.php?' . http_build_query($params)
];
}
public function notify($params): array|bool
{
$sign = $params['sign'];
unset($params['sign'], $params['sign_type']);
ksort($params);
$str = stripslashes(urldecode(http_build_query($params))) . $this->getConfig('key');
foreach ($params as $value) {
if (!is_scalar($value) && $value !== null) {
return false;
}
}
if (!is_string($params['sign'] ?? null)
|| ($params['sign_type'] ?? 'MD5') !== 'MD5'
|| !hash_equals($this->signature($params), $params['sign'])
|| ($params['trade_status'] ?? null) !== 'TRADE_SUCCESS'
|| (string) ($params['pid'] ?? '') !== (string) $this->getConfig('pid')
|| ($this->getConfig('type') && ($params['type'] ?? null) !== $this->getConfig('type'))
|| empty($params['out_trade_no']) || empty($params['trade_no'])) {
return false;
}
if ($sign !== md5($str)) {
$order = $this->findOrder((string) $params['out_trade_no']);
$amount = $this->amountInCents((string) ($params['money'] ?? ''));
if (!$order || (int) $order->payment_id !== (int) $this->getConfig('id')
|| $amount === null || $amount !== (int) $order->total_amount + (int) $order->handling_amount
|| !in_array((int) $order->status, [Order::STATUS_PENDING, Order::STATUS_PROCESSING, Order::STATUS_COMPLETED], true)
|| ($order->callback_no && $order->callback_no !== (string) $params['trade_no'])) {
return false;
}
@ -93,4 +107,29 @@ class Plugin extends AbstractPlugin implements PaymentInterface
'callback_no' => $params['trade_no']
];
}
}
protected function findOrder(string $tradeNo): ?Order
{
return Order::where('trade_no', $tradeNo)->first();
}
private function signature(array $params): string
{
unset($params['sign'], $params['sign_type']);
$params = array_filter($params, static fn ($value) => $value !== '' && $value !== null);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $key => $value) {
$parts[] = $key . '=' . $value;
}
return md5(implode('&', $parts) . $this->getConfig('key'));
}
private function amountInCents(string $amount): ?int
{
if (!preg_match('/\A([0-9]{1,10})(?:\.([0-9]{1,2}))?\z/', $amount, $matches)) {
return null;
}
return (int) $matches[1] * 100 + (int) str_pad($matches[2] ?? '', 2, '0');
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace Tests\Unit\Payments;
use App\Models\Order;
use PHPUnit\Framework\TestCase;
use Plugin\Epay\Plugin;
require_once __DIR__ . '/../../../plugins-core/Epay/Plugin.php';
class EpayCallbackTest extends TestCase
{
private function plugin(): Plugin
{
$plugin = new class('epay') extends Plugin {
public int $orderStatus = Order::STATUS_PENDING;
public ?string $callbackNo = null;
protected function findOrder(string $tradeNo): ?Order
{
if ($tradeNo !== 'test-order') return null;
$order = new Order();
$order->setRawAttributes(['payment_id' => 7, 'total_amount' => 1000, 'handling_amount' => 50, 'status' => $this->orderStatus, 'callback_no' => $this->callbackNo]);
return $order;
}
};
$plugin->setConfig(['id' => 7, 'pid' => 'test-merchant', 'key' => 'test-secret', 'type' => 'alipay', 'url' => 'https://example.test/']);
return $plugin;
}
private function signed(array $changes = []): array
{
$params = array_replace(['pid' => 'test-merchant', 'type' => 'alipay', 'out_trade_no' => 'test-order', 'trade_no' => 'gateway-order', 'money' => '10.50', 'trade_status' => 'TRADE_SUCCESS', 'param' => ''], $changes);
unset($params['sign'], $params['sign_type']);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $k => $v) if ($v !== '' && $v !== null) $parts[] = $k . '=' . $v;
$params['sign'] = md5(implode('&', $parts) . 'test-secret');
$params['sign_type'] = 'MD5';
return $params;
}
public function test_accepts_successful_matching_order_including_handling_fee_and_empty_extension(): void
{
$this->assertSame(['trade_no' => 'test-order', 'callback_no' => 'gateway-order'], $this->plugin()->notify($this->signed()));
$this->assertIsArray($this->plugin()->notify($this->signed(['money' => '10.5'])));
}
public function test_signed_but_invalid_callbacks_cannot_mark_order_paid(): void
{
foreach ([['trade_status' => 'WAIT_BUYER_PAY'], ['money' => '10.00'], ['money' => '10.501'], ['money' => '1.05e1'], ['money' => '-10.50'], ['pid' => 'other'], ['type' => 'wxpay'], ['out_trade_no' => 'missing'], ['trade_no' => '']] as $change) {
$this->assertFalse($this->plugin()->notify($this->signed($change)), json_encode($change));
}
$plugin = $this->plugin();
$plugin->setConfig(['id' => 8, 'pid' => 'test-merchant', 'key' => 'test-secret', 'type' => 'alipay']);
$this->assertFalse($plugin->notify($this->signed()));
}
public function test_repeated_success_is_accepted_but_cancelled_or_conflicting_receipt_is_rejected(): void
{
$plugin = $this->plugin();
$plugin->orderStatus = Order::STATUS_COMPLETED;
$plugin->callbackNo = 'gateway-order';
$this->assertIsArray($plugin->notify($this->signed()));
$plugin->callbackNo = 'different-receipt';
$this->assertFalse($plugin->notify($this->signed()));
$plugin->callbackNo = null;
$plugin->orderStatus = Order::STATUS_CANCELLED;
$this->assertFalse($plugin->notify($this->signed()));
}
public function test_rejects_bad_missing_and_structured_signature_data(): void
{
$params = $this->signed();
$params['sign'] = str_repeat('0', 32);
$this->assertFalse($this->plugin()->notify($params));
unset($params['sign']);
$this->assertFalse($this->plugin()->notify($params));
$params = $this->signed();
$params['money'] = ['10.50'];
$this->assertFalse($this->plugin()->notify($params));
}
public function test_pay_signs_unescaped_values_and_normalizes_gateway_slash(): void
{
$result = $this->plugin()->pay(['total_amount' => 1050, 'trade_no' => 'test-order', 'notify_url' => 'https://example.test/notify?x=1&y=2', 'return_url' => 'https://example.test/#/order/test-order']);
$this->assertStringStartsWith('https://example.test/submit.php?', $result['data']);
parse_str(parse_url($result['data'], PHP_URL_QUERY), $params);
$sign = $params['sign'];
unset($params['sign'], $params['sign_type']);
ksort($params, SORT_STRING);
$parts = [];
foreach ($params as $k => $v) $parts[] = $k . '=' . $v;
$this->assertSame(md5(implode('&', $parts) . 'test-secret'), $sign);
}
}