diff --git a/docs/maintenance/BUSINESS_RULES.md b/docs/maintenance/BUSINESS_RULES.md index a89ee0a..37d3d19 100644 --- a/docs/maintenance/BUSINESS_RULES.md +++ b/docs/maintenance/BUSINESS_RULES.md @@ -28,6 +28,8 @@ ## 支付与授权边界 -支付优先复用已有配置;涉及支付代码修改需按此前用户边界先确认具体改动。不得把邮件发布顺便混入支付变更,也不得未经授权执行真实付款。 +支付优先复用已有配置;涉及支付代码修改需按此前用户边界先确认具体改动。2026-10-11 用户已授权补齐 EPay 支付状态、订单金额及关联信息校验,随后指定支付对接仅在线上联调,不调整本地支付配置。空中支付已有商户设置尽量保留,需要新增的设置可添加。不得把邮件发布顺便混入支付变更,也不得未经授权执行真实付款。 常规已授权的修复、验证、文档与 Git 发布继续推进;涉及删除不可恢复数据、变更账号权限范围、新的外部数据传输目的地等敏感边界需要明确授权。已有授权不用重复询问。备份、脱敏与隔离数据不自动等于完成真实业务验收。 + +EPay 按空中支付 V1 文档签名(忽略 sign、sign_type 和空值,参数不进行 URL 编码)。回调须验证签名、成功状态、商户、指定支付类型、订单所属渠道及实付金额(订单金额加手续费),取消订单或冲突交易号不能开通;相同成功回调保留现有订单幂等处理。 diff --git a/docs/maintenance/DEPLOYMENT.md b/docs/maintenance/DEPLOYMENT.md index c5354b4..e9ddca4 100644 --- a/docs/maintenance/DEPLOYMENT.md +++ b/docs/maintenance/DEPLOYMENT.md @@ -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。 diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 8598178..04968b4 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -30,6 +30,12 @@ Resend 发信、常规 SMTP、配额回退、投递事件回调、master 一次 生产后台支付列表已实际核对:支付宝、微信支付均显示且开启,通知地址使用正式域名。本次仅修改生产配置数据,未修改支付代码,未创建订单或付款;真实支付和支付回调仍未验收。两次写入前均保存了受保护的加密备份,传输临时私密文件已清除;文档和 Git 不包含商户密钥。 +## 空中支付对接检查与代码修正(2026-10-11) + +已实际登录空中支付商户后台,确认账号状态正常、MD5+RSA 兼容模式已启用,提供的新密钥与商户后台一致。已有商户设置未更改;尝试补充空白网站域名时,页面以其他资料未完善拒绝保存,重新读取确认原字段均未改变。接口文档通过每笔请求的 notify_url、return_url 传递回调,不在商户页面单独配置回调地址。 + +用户授权继续后,已补齐 EPay 回调成功状态、金额(含手续费)、商户及渠道归属校验,并按官方 V1 规则生成签名。隔离测试 5 项、20 个断言通过,覆盖正常/重复回调、错误金额/状态/商户/渠道、缺失/错误签名、取消订单和冲突交易号。语法检查通过。尚未进行真实付款或真实成功回调验收;部署状态另行记录。用户随后要求仅线上联调,本地支付配置保持不动。 + ## 每次维护更新本文件 记录任务、修改文件、测试、提交、是否推送/部署、真实验收结果、尚未完成项与敏感边界。禁止写密码、令牌、原始用户邮件或未经核实的完成结论。 diff --git a/plugins-core/Epay/Plugin.php b/plugins-core/Epay/Plugin.php index 56e4804..aaedd73 100644 --- a/plugins-core/Epay/Plugin.php +++ b/plugins-core/Epay/Plugin.php @@ -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'] ]; } -} \ No newline at end of file + + 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'); + } +} diff --git a/tests/Unit/Payments/EpayCallbackTest.php b/tests/Unit/Payments/EpayCallbackTest.php new file mode 100644 index 0000000..a0dfb83 --- /dev/null +++ b/tests/Unit/Payments/EpayCallbackTest.php @@ -0,0 +1,95 @@ +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); + } +}