diff --git a/docs/maintenance/STATUS.md b/docs/maintenance/STATUS.md index 9bbc502..b674727 100644 --- a/docs/maintenance/STATUS.md +++ b/docs/maintenance/STATUS.md @@ -36,6 +36,12 @@ Resend 发信、常规 SMTP、配额回退、投递事件回调、master 一次 用户授权继续后,已补齐 EPay 回调成功状态、金额(含手续费)、商户及渠道归属校验,并按官方 V1 规则生成签名。隔离测试 5 项、20 个断言通过,覆盖正常/重复回调、错误金额/状态/商户/渠道、缺失/错误签名、取消订单和冲突交易号。语法检查通过。修正已提交推送并通过 Git fast-forward 部署至生产版本 60457487726b95dd7811eea16a625b01cf5c9d69;发布前备份了旧插件和生产既有差异,发布后插件哈希、PHP-FPM 与 Horizon 状态已核对。生产调用空中支付商户查询 API 返回 HTTP 200、业务成功且商户正常。尚未进行真实付款或真实成功回调验收。用户随后要求仅线上联调,本地支付配置保持不动。服务商支付测试页面目前仅展示支付宝按钮;微信通道可用性尚未确认,不能把网站启用微信配置视为通道验收完成。 +## 不付款验收(2026-10-11) + +用户明确要求以不做真实支付的方式验收。新增真实控制器与 PaymentService/EPay 插件调用链的隔离测试,使用 SQLite 内存数据库、虚构商户及订单;拦截订单开通任务、禁止外部 HTTP 请求,不操作正式订单。与插件单元测试合计 8 项、53 个断言通过。成功回调返回 success、记录回调号并只派发一次开通任务;签名正确但状态、金额、商户、支付类型或订单不匹配,以及取消订单均不会开通。 + +本轮验收目标是代码逻辑及接口可达性,不包含真实收款、服务商实际成功回调和正式套餐开通;微信通道仍未得到真实验证。线上隔离运行及回调入口检查完成后单独追加结果。 + ## 每次维护更新本文件 记录任务、修改文件、测试、提交、是否推送/部署、真实验收结果、尚未完成项与敏感边界。禁止写密码、令牌、原始用户邮件或未经核实的完成结论。 diff --git a/tests/Feature/Payments/EpayNotifyTest.php b/tests/Feature/Payments/EpayNotifyTest.php new file mode 100644 index 0000000..8618b0f --- /dev/null +++ b/tests/Feature/Payments/EpayNotifyTest.php @@ -0,0 +1,99 @@ +'sqlite','database.connections.sqlite.database'=>':memory:', + 'cache.default'=>'array','cache.stores.redis'=>['driver'=>'array'],'queue.default'=>'sync']); + DB::purge('sqlite'); + Cache::forgetDriver(['redis','array']); + $this->app->instance('hook.actions', []); + $this->app->instance('hook.filters', []); + Schema::create('v2_payment', function(Blueprint $t) { + $t->id();$t->string('uuid');$t->string('payment');$t->string('name');$t->text('config'); + $t->boolean('enable');$t->string('notify_domain')->nullable();$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable(); + }); + Schema::create('v2_order', function(Blueprint $t) { + $t->id();$t->string('trade_no');$t->integer('payment_id');$t->integer('total_amount');$t->integer('handling_amount')->nullable(); + $t->integer('status');$t->string('callback_no')->nullable();$t->integer('paid_at')->nullable();$t->integer('created_at')->nullable();$t->integer('updated_at')->nullable(); + }); + $this->payment=Payment::create(['uuid'=>'isolated-gate','payment'=>'EPay','name'=>'Isolated EPay','enable'=>true, + 'config'=>['pid'=>'isolated-merchant','key'=>'isolated-secret','type'=>'alipay','url'=>'https://example.test']]); + $this->order=Order::create(['trade_no'=>'isolated-order','payment_id'=>$this->payment->id,'total_amount'=>1000,'handling_amount'=>50,'status'=>Order::STATUS_PENDING]); + $plugin=new Plugin('epay');$plugin->boot(); + $manager=Mockery::mock(PluginManager::class); + $manager->shouldReceive('getEnabledPaymentPlugins')->andReturn([$plugin]); + $this->app->instance(PluginManager::class,$manager); + Bus::fake([OrderHandleJob::class]); + Http::preventStrayRequests(); + } + + private function notify(array $changes=[]): mixed + { + $params=array_replace(['pid'=>'isolated-merchant','type'=>'alipay','money'=>'10.50','trade_status'=>'TRADE_SUCCESS', + 'out_trade_no'=>'isolated-order','trade_no'=>'isolated-gateway-order','param'=>''],$changes); + ksort($params,SORT_STRING);$parts=[]; + foreach($params as $k=>$v)if($v!==''&&$v!==null)$parts[]=$k.'='.$v; + $params['sign']=md5(implode('&',$parts).'isolated-secret');$params['sign_type']='MD5'; + return (new PaymentController())->notify('EPay','isolated-gate',Request::create('/isolated-notify','GET',$params)); + } + + public function test_success_acknowledges_and_dispatches_fulfilment_only_once(): void + { + $this->assertSame('success',$this->notify()); + $this->order->refresh(); + $this->assertSame(Order::STATUS_PROCESSING,(int)$this->order->status); + $this->assertSame('isolated-gateway-order',$this->order->callback_no); + $this->assertNotNull($this->order->paid_at); + $this->assertSame('success',$this->notify()); + Bus::assertDispatchedSync(OrderHandleJob::class,1); + Http::assertNothingSent(); + } + + public function test_signed_invalid_status_amount_and_channel_leave_order_unchanged(): void + { + foreach([['trade_status'=>'WAIT_BUYER_PAY'],['money'=>'10.00'],['pid'=>'other'],['type'=>'wxpay'],['out_trade_no'=>'unknown']] as $changes){ + $response=$this->notify($changes); + $this->assertNotSame('success',$response); + $this->order->refresh(); + $this->assertSame(Order::STATUS_PENDING,(int)$this->order->status); + $this->assertNull($this->order->paid_at); + $this->assertNull($this->order->callback_no); + } + Bus::assertNothingDispatched(); + Http::assertNothingSent(); + } + + public function test_cancelled_order_does_not_dispatch_fulfilment(): void + { + $this->order->status=Order::STATUS_CANCELLED;$this->order->save(); + $this->assertNotSame('success',$this->notify()); + $this->assertSame(Order::STATUS_CANCELLED,(int)$this->order->fresh()->status); + Bus::assertNothingDispatched(); + Http::assertNothingSent(); + } +}