From 5be2b76d88dc4711976d9ec486f305491647995f Mon Sep 17 00:00:00 2001 From: gaofeng <1212121@qq.com> Date: Fri, 11 Sep 2026 09:34:15 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/miniapi/controller/VisaApplication.php | 129 +++++++++++++++++-- tests/visa_application_facade_self_check.php | 6 + tests/visa_submission_payment_self_check.php | 92 +++++++++++++ 3 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 tests/visa_submission_payment_self_check.php diff --git a/app/miniapi/controller/VisaApplication.php b/app/miniapi/controller/VisaApplication.php index 26b47f0..c023bcb 100644 --- a/app/miniapi/controller/VisaApplication.php +++ b/app/miniapi/controller/VisaApplication.php @@ -8,8 +8,8 @@ use app\miniapi\service\VisaApplication\VisaApplicationService; final class VisaApplication extends Base { - public function directForm() { return $this->call(fn($service,$userId) => $service->directForm($userId,$this->postData(),$this->orderSource())); } - public function directStatus() { return $this->call(fn($service,$userId) => $service->directStatus($userId,$this->postData(),$this->orderSource())); } + public function directForm() { return $this->directCall(fn($service,$userId) => $service->directForm($userId,$this->postData(),$this->orderSource())); } + public function directStatus() { return $this->directCall(fn($service,$userId) => $service->directStatus($userId,$this->postData(),$this->orderSource())); } public function directSubmit() { return $this->submitOrder(true); } public function options() { return $this->call(fn($service, $userId) => $service->options($userId, $this->orderSource())); } public function start() { return $this->call(fn($service, $userId) => $service->start($userId, $this->postData(), $this->orderSource())); } @@ -22,19 +22,30 @@ final class VisaApplication extends Base $input = $this->postData(); $service = new VisaApplicationService(); $result = $direct ? $service->directSubmit($userId,$input,$this->orderSource()) : $service->submit($userId,$input); - if ((int)($result['code'] ?? 0) !== 1) return $this->fail((string)($result['msg'] ?? '服务异常,请稍后重试')); + if ((int)($result['code'] ?? 0) !== 1) { + $message = (string)($result['msg'] ?? '服务异常,请稍后重试'); + $fieldErrors = $direct ? $this->safeDirectFieldErrors((array)($result['data'] ?? []), $input) : []; + return $fieldErrors === [] ? $this->fail($message) : $this->fail($message, 0, ['field_errors' => $fieldErrors]); + } $data = (array)($result['data'] ?? []); $data['submission_status'] = 'submitted'; if (!empty($data['requires_payment'])) { if (($data['directory_synced'] ?? true) === false) $data['payment_message'] = '申请已提交,订单目录同步未完成,请联系客服核对'; elseif (count((array)($data['target_orders'] ?? [])) > 1) $data['payment_message'] = '申请已提交,请在我的申请中按申请人分别支付'; + elseif ($direct && $this->isAndroidVisaApp() && (($data['directory_synced'] ?? true) === false || empty($data['payment_ready']))) $data['payment_message'] = $data['payment_message'] ?? '申请已提交,请到我的申请查看支付状态'; elseif (array_key_exists('payment_ready', $data) && !$data['payment_ready']) $data['payment_message'] = $data['payment_message'] ?? '申请已提交,请到我的申请查看支付状态'; else { - $pay = $this->paymentParameters($data, $input); - if ((int)($pay['code'] ?? 0) === 1) $data['pay'] = $pay['data']; - else $data['payment_message'] = (string)($pay['msg'] ?? '支付参数暂不可用'); + if ($direct && $this->isAndroidVisaApp()) { + $appPayment = $this->appPaymentParameters($data); + if ($appPayment !== []) $data['app_payment'] = $appPayment; + else $data['payment_message'] = '当前服务暂不支持 App 支付,请稍后重试'; + } else { + $pay = $this->paymentParameters($data, $input); + if ((int)($pay['code'] ?? 0) === 1) $data['pay'] = $pay['data']; + else $data['payment_message'] = (string)($pay['msg'] ?? '支付参数暂不可用'); + } } - $data['payment_status'] = isset($data['pay']) ? 'ready' : 'pending'; + $data['payment_status'] = isset($data['pay']) || isset($data['app_payment']) ? 'ready' : 'pending'; } unset($data['target_orders'], $data['pay_url']); return $this->ok($data, '提交成功'); @@ -46,7 +57,13 @@ final class VisaApplication extends Base $input = $this->postData(); $result = (new VisaApplicationService())->orderPayment($userId, ['business' => (string)($input['business'] ?? ''), 'order_sn' => (string)($input['order_sn'] ?? '')]); if ((int)($result['code'] ?? 0) !== 1) return $this->fail((string)($result['msg'] ?? '支付状态需核对')); - $pay = $this->paymentParameters((array)$result['data'], $input); + $data = (array)$result['data']; + if ($this->isAndroidVisaApp()) { + // The successful order-pay lookup has already verified this public business against the account/order. + $appPayment = $this->appPaymentParameters($data, $this->safePublicBusiness((string)($input['business'] ?? ''))); + return $appPayment !== [] ? $this->ok(['order_sn' => $appPayment['order_sn'], 'app_payment' => $appPayment]) : $this->fail('当前服务暂不支持 App 支付,请稍后重试'); + } + $pay = $this->paymentParameters($data, $input); return (int)($pay['code'] ?? 0) === 1 ? $this->ok(['pay' => $pay['data']]) : $this->fail((string)($pay['msg'] ?? '支付参数暂不可用')); } private function paymentParameters(array $data, array $input): array @@ -58,6 +75,100 @@ final class VisaApplication extends Base return (new MiniPayService())->requestForTarget($this->currentMiniAppContext('visa'), $target['order_sn'], $target['money'], $openid, $target['target_table'], 'miniapi visa mini pay'); } catch (\Throwable $e) { return ['code' => 0, 'msg' => '支付参数暂不可用,请到我的申请继续支付']; } } + /** Android never receives internal payment targets. Direct form/status can also return an existing submitted result. */ + private function directCall(callable $callback) + { + $userId = $this->requireLogin(); if (!is_int($userId)) return $userId; + $result = $callback(new VisaApplicationService(), $userId); + if ((int)($result['code'] ?? 0) !== 1) return $this->fail((string)($result['msg'] ?? '服务异常,请稍后重试')); + $data = (array)($result['data'] ?? []); + if ($this->isAndroidVisaApp()) $data = $this->androidDirectData($data); + return $this->ok($data); + } + private function androidDirectData(array $data): array + { + if (!empty($data['requires_payment'])) { + $ready = ($data['directory_synced'] ?? true) !== false && ($data['payment_ready'] ?? false) === true; + $payment = $ready ? $this->appPaymentParameters($data) : []; + if ($payment !== []) $data['app_payment'] = $payment; + else $data['payment_message'] = $data['payment_message'] ?? '当前服务暂不支持 App 支付,请稍后重试'; + $data['payment_status'] = isset($data['app_payment']) ? 'ready' : 'pending'; + } + unset($data['target_orders'], $data['pay_url'], $data['pay']); + return $data; + } + /** Direct-submit failures may expose only bounded field locations/messages, never response payloads or payment data. */ + private function safeDirectFieldErrors(array $data, array $input): array + { + $raw = $data['field_errors'] ?? ($data['errors'] ?? []); + if (!is_array($raw)) return []; + $out = []; + foreach ($raw as $key => $item) { + if (count($out) >= 8) break; + $item = is_array($item) ? $item : ['path' => is_string($key) ? $key : '', 'message' => $item]; + $message = $item['inlineMessage'] ?? ($item['message'] ?? ($item['msg'] ?? '')); + if (!is_scalar($message)) continue; + $message = $this->safeFieldErrorText((string)$message, $input); + if ($message === '') continue; + $module = $this->safeFieldErrorPart((string)($item['module_key'] ?? ($item['module'] ?? '')), 80); + $field = $this->safeFieldErrorPart((string)($item['field_id'] ?? ($item['field'] ?? '')), 80); + $path = $this->safeFieldErrorPart((string)($item['path'] ?? (is_string($key) ? $key : '')), 160); + if ($field === '' && $path !== '') $field = $this->safeFieldErrorPart((string)preg_replace('/^.*[.\]]/', '', $path), 80); + if ($module === '' && $field === '' && $path === '') continue; + $out[] = ['module_key' => $module, 'field_id' => $field, 'path' => $path, 'message' => $message]; + } + return $out; + } + private function safeFieldErrorPart(string $value, int $limit): string + { + $value = substr(trim($value), 0, $limit); + return preg_match('/^[A-Za-z0-9_.\[\]-]*$/', $value) ? $value : ''; + } + private function safeFieldErrorText(string $value, array $input): string + { + $value = trim(preg_replace('/\s+/', ' ', strip_tags($value)) ?? ''); + foreach ($this->inputScalars($input) as $secret) if (strlen($secret) >= 4) $value = str_replace($secret, '[已隐藏]', $value); + return substr($value, 0, 240); + } + private function inputScalars(array $input): array + { + $out = []; + foreach ($input as $value) { + if (is_array($value)) $out = array_merge($out, $this->inputScalars($value)); + elseif (is_scalar($value)) $out[] = (string)$value; + } + return $out; + } + private function isAndroidVisaApp(): bool { return $this->currentMiniAppCode() === 'evus_jn_app'; } + private function appPaymentParameters(array $data, string $verifiedBusiness = ''): array + { + $target = VisaApplicationService::nativePaymentForCheck($data); + $prefix = defined('PAY_MARK') ? (string)constant('PAY_MARK') : ''; + $business = $verifiedBusiness !== '' ? $verifiedBusiness : $this->trustedPaymentBusiness($data); + if (!$target || !preg_match('/^[A-Za-z0-9_]+$/', $prefix) || $business === '') return []; + return ['order_sn' => $target['order_sn'], 'money' => $target['money'], 'mark' => $prefix . $target['target_table'], 'business' => $business]; + } + /** Only public business names returned by the upstream directory are accepted for direct submit/status. */ + private function trustedPaymentBusiness(array $data): string + { + foreach (['payment_business', 'business'] as $key) { + $business = $this->safePublicBusiness((string)($data[$key] ?? '')); + if ($business !== '') return $business; + } + foreach ((array)($data['target_orders'] ?? []) as $target) { + if (!is_array($target)) continue; + foreach (['payment_business', 'business', 'business_type'] as $key) { + $business = $this->safePublicBusiness((string)($target[$key] ?? '')); + if ($business !== '') return $business; + } + } + return ''; + } + private function safePublicBusiness(string $business): string + { + $business = trim($business); + return preg_match('/^[A-Za-z0-9_-]{1,64}$/', $business) ? $business : ''; + } public function agree() { $result = (new VisaApplicationService())->agree(trim((string)$this->request->param('doc_code', ''))); @@ -75,6 +186,6 @@ final class VisaApplication extends Base $result = $callback(new VisaApplicationService(), $userId); if ((int)($result['code'] ?? 0) !== 1) return $this->fail((string)($result['msg'] ?? '服务异常,请稍后重试')); return $this->ok((array)($result['data'] ?? []), $message); } - public static function sourceForCode(string $code): string { return $code === 'evus_jn_mp' ? 'jnwxmini' : ($code === 'evus_bj_mp' ? 'qlwxmini' : ''); } + public static function sourceForCode(string $code): string { return $code === 'evus_jn_app' ? 'jnandroid' : ($code === 'evus_jn_mp' ? 'jnwxmini' : ($code === 'evus_bj_mp' ? 'qlwxmini' : '')); } private function orderSource(): string { $source = self::sourceForCode($this->currentMiniAppCode()); if ($source === '') throw new \InvalidArgumentException('当前应用不支持签证办理'); return $source; } } diff --git a/tests/visa_application_facade_self_check.php b/tests/visa_application_facade_self_check.php index cd9ccf4..6b4fcd6 100644 --- a/tests/visa_application_facade_self_check.php +++ b/tests/visa_application_facade_self_check.php @@ -5,8 +5,12 @@ const MINIAPI_TOKEN_KEY = 'test'; function authcode($value, $operation = 'DECODE', $key = '', $expiry = 0) { return $value; } require dirname(__DIR__) . '/app/miniapi/service/VisaApplication/VisaApplicationService.php'; +class VisaApplicationTestBase {} +class_alias(VisaApplicationTestBase::class, 'app\\miniapi\\controller\\Base'); +require dirname(__DIR__) . '/app/miniapi/controller/VisaApplication.php'; use app\miniapi\service\VisaApplication\VisaApplicationService; +use app\miniapi\controller\VisaApplication; $service = new VisaApplicationService(); if ($service->endpoint('https://user.example.com/users/list', 'start') !== 'https://user.example.com/visa_service/start') throw new RuntimeException('endpoint construction failed'); @@ -16,6 +20,8 @@ if ($service->endpoint('https://user.example.com/users/list', 'invalid') !== '') $payload = $service->payload(9, ['order_source' => 'jnwxmini']); if (isset($payload['user_id']) || (json_decode((string)($payload['user_token'] ?? ''), true) !== ['purpose' => 'visa_api', 'user_id' => 9, 'order_source' => 'jnwxmini'])) throw new RuntimeException('trusted token payload failed'); if (str_contains((new ReflectionMethod($service, 'start'))->getFileName() ? file_get_contents((new ReflectionMethod($service, 'start'))->getFileName()) : '', '$data + [\'order_source\'')) throw new RuntimeException('start source override failed'); +if (VisaApplication::sourceForCode('evus_jn_app') !== 'jnandroid') throw new RuntimeException('android visa source mapping failed'); +if (VisaApplication::sourceForCode('evus_jn_mp') !== 'jnwxmini' || VisaApplication::sourceForCode('evus_bj_mp') !== 'qlwxmini' || VisaApplication::sourceForCode('unknown') !== '') throw new RuntimeException('visa source mapping regression'); if (!str_contains((string)file_get_contents(dirname(__DIR__) . '/app/miniapi/controller/VisaApplication.php'), "sourceForCode(string \$code)") || !str_contains((string)file_get_contents(dirname(__DIR__) . '/app/miniapi/controller/VisaApplication.php'), "\$code === 'evus_bj_mp'") || !str_contains((string)file_get_contents(dirname(__DIR__) . '/app/miniapi/controller/VisaApplication.php'), "\$filters['source_code'] = \$this->orderSource()")) throw new RuntimeException('visa source allowlist failed'); $neutral = VisaApplicationService::decorateSubmitForCheck(['code' => 1, 'data' => ['order_sn' => 'V1', 'requires_payment' => false]]); $payable = VisaApplicationService::decorateSubmitForCheck(['code' => 1, 'data' => ['order_sn' => 'V 1', 'requires_payment' => true]]); diff --git a/tests/visa_submission_payment_self_check.php b/tests/visa_submission_payment_self_check.php new file mode 100644 index 0000000..e3c598a --- /dev/null +++ b/tests/visa_submission_payment_self_check.php @@ -0,0 +1,92 @@ +0,'msg'=>'temporary failure']; + function requestForTarget(...$args) { self::$calls++; if (self::$throws) throw new \RuntimeException('offline'); return self::$result; } + } +} +namespace app\miniapi\controller { + class Base { + public array $input = ['openid'=>'test-openid']; + public string $appCode = 'evus_jn_mp'; + protected function requireLogin() { return 7; } + protected function postData() { return $this->input; } + protected function currentMiniAppCode() { return $this->appCode; } + protected function currentMiniAppContext($business) { return new \stdClass(); } + protected function ok($data, $msg = '') { return ['code'=>1,'data'=>$data,'msg'=>$msg]; } + protected function fail($msg, $code = 0, $data = []) { return ['code'=>$code,'msg'=>$msg,'data'=>$data]; } + } +} +namespace { + require __DIR__ . '/../app/miniapi/service/VisaApplication/VisaApplicationService.php'; + require __DIR__ . '/../app/miniapi/controller/VisaApplication.php'; + function checkSubmit($condition, $message) { if (!$condition) throw new RuntimeException($message); } + $controller = new app\miniapi\controller\VisaApplication(); + $data = ['order_sn'=>'TEST1','requires_payment'=>true,'total_price'=>7900,'payment_ready'=>true,'payment_business'=>'arrival_card','target_orders'=>[['target_table'=>'singapore','target_order_sn'=>'TEST1','applicant_index'=>0]]]; + api\Httpcurl::$result = ['code'=>1,'data'=>$data]; + $result = $controller->submit(); + checkSubmit($result['code'] === 1 && $result['data']['submission_status'] === 'submitted' && !isset($result['data']['pay']), 'parameter failure must not undo submission'); + app\miniapi\service\Payment\MiniPayService::$throws = true; + checkSubmit($controller->submit()['code'] === 1, 'transport exception must not undo submission'); + app\miniapi\service\Payment\MiniPayService::$throws = false; + $data['target_orders'][] = ['target_table'=>'singapore','target_order_sn'=>'TEST2','applicant_index'=>1]; + api\Httpcurl::$result['data'] = $data; + $before = app\miniapi\service\Payment\MiniPayService::$calls; + checkSubmit($controller->submit()['code'] === 1 && app\miniapi\service\Payment\MiniPayService::$calls === $before, 'batch must not auto-charge first applicant'); + api\Httpcurl::$result = ['code'=>0,'msg'=>'validation failed']; + checkSubmit($controller->submit()['code'] === 0, 'actual submission failure remains a failure'); + api\Httpcurl::$result = ['code'=>0,'msg'=>'validation failed','data'=>['field_errors'=>[['module_key'=>'traveler','field'=>'passport_number','path'=>'data.applicants.0.traveler.passport_number','message'=>'护照号格式不正确','value'=>'SECRET','money'=>'0.01','mark'=>'forged','target_table'=>'secret']],'target_orders'=>[['target_table'=>'secret']],'pay_url'=>'https://secret.test']]; + checkSubmit(($controller->submit()['data'] ?? []) === [], 'legacy submit rejection must not expose direct field metadata'); + $controller->appCode = 'evus_jn_app'; + $controller->input = ['request_id'=>str_repeat('d',64),'service_code'=>'TEST']; + api\Httpcurl::$result = ['code'=>1,'data'=>['order_sn'=>'TEST1','requires_payment'=>true,'total_price'=>7900,'payment_ready'=>true,'payment_business'=>'arrival_card','target_orders'=>[['target_table'=>'singapore','target_order_sn'=>'TEST1','applicant_index'=>0]],'submission_status'=>'submitted', 'pay_url'=>'https://secret.test']]; + $androidStatus = $controller->directStatus(); + checkSubmit(($androidStatus['data']['app_payment'] ?? null) === ['order_sn'=>'TEST1','money'=>'79.00','mark'=>'evuscnsingapore','business'=>'arrival_card'] && !isset($androidStatus['data']['target_orders'], $androidStatus['data']['pay_url']), 'android direct status must sanitize internal targets and expose trusted payment business'); + $androidForm = $controller->directForm(); + checkSubmit(($androidForm['data']['app_payment']['business'] ?? '') === 'arrival_card' && !isset($androidForm['data']['target_orders'], $androidForm['data']['pay_url']), 'android existing direct form result uses the same safe payment shape'); + api\Httpcurl::$result = ['code'=>1,'data'=>['order_sn'=>'TEST1','requires_payment'=>true,'total_price'=>7900,'payment_ready'=>false,'directory_synced'=>true,'payment_business'=>'arrival_card','target_orders'=>[['target_table'=>'singapore','target_order_sn'=>'TEST1','applicant_index'=>0]]]]; + checkSubmit(!isset($controller->directStatus()['data']['app_payment']), 'android status must not create payment before directory payment readiness'); + api\Httpcurl::$result = ['code'=>0,'msg'=>'validation failed','data'=>['field_errors'=>[['module_key'=>'traveler','field'=>'passport_number','path'=>'data.applicants.0.traveler.passport_number','message'=>'护照号格式不正确','value'=>'SECRET','money'=>'0.01','mark'=>'forged','target_table'=>'secret']],'target_orders'=>[['target_table'=>'secret']],'pay_url'=>'https://secret.test']]; + $controller->input = ['request_id'=>str_repeat('f',64),'service_code'=>'TEST','nationality_iso2'=>'CN','destination_iso2'=>'SG']; + $safeFailure = $controller->directSubmit(); + checkSubmit($safeFailure['code'] === 0 && ($safeFailure['data']['field_errors'] ?? null) === [['module_key'=>'traveler','field_id'=>'passport_number','path'=>'data.applicants.0.traveler.passport_number','message'=>'护照号格式不正确']], 'direct rejection forwards only bounded field location metadata'); + checkSubmit(!str_contains(json_encode($safeFailure['data']), 'SECRET') && !str_contains(json_encode($safeFailure['data']), 'forged') && !str_contains(json_encode($safeFailure['data']), 'secret'), 'direct rejection must drop sensitive upstream keys and values'); + $controller->appCode = 'evus_jn_mp'; + $controller->input = ['openid'=>'test-openid']; + api\Httpcurl::$result = ['code'=>1,'data'=>$data]; + $controller->input += ['business'=>'singapore','order_sn'=>'TEST1','total_price'=>1,'target_table'=>'evus']; + api\Httpcurl::$result = ['code'=>1,'data'=>array_merge($data, ['target_orders'=>[$data['target_orders'][0]]])]; + app\miniapi\service\Payment\MiniPayService::$result = ['code'=>1,'data'=>['package'=>'prepay_id=test']]; + checkSubmit($controller->orderPayment()['code'] === 1, 'per-person payment parameters are forwarded'); + checkSubmit(!isset(api\Httpcurl::$lastPayload['total_price'], api\Httpcurl::$lastPayload['target_table']), 'client cannot choose amount or target table'); + $controller->input = ['openid'=>'test-openid','request_id'=>str_repeat('a',64),'service_code'=>'TEST','nationality_iso2'=>'CN','destination_iso2'=>'SG']; + checkSubmit($controller->directSubmit()['code'] === 1, 'new direct submit still obtains original native payment parameters'); + checkSubmit(api\Httpcurl::$lastPayload['request_id'] === str_repeat('a',64) && !isset(api\Httpcurl::$lastPayload['application_token'],api\Httpcurl::$lastPayload['submission_token']), 'direct submit needs no application credentials'); + $claims=json_decode(api\Httpcurl::$lastPayload['user_token'],true); + checkSubmit($claims['order_source']==='jnwxmini' && $claims['user_id']===7, 'new endpoint forwards verified account and source'); + $controller->appCode = 'evus_jn_app'; + $controller->input = ['request_id'=>str_repeat('b',64),'service_code'=>'TEST','nationality_iso2'=>'CN','destination_iso2'=>'SG','mark'=>'forged','money'=>'0.01','target_table'=>'evus']; + $androidSubmit = $controller->directSubmit(); + checkSubmit(($androidSubmit['data']['app_payment'] ?? null) === ['order_sn'=>'TEST1','money'=>'79.00','mark'=>'evuscnsingapore','business'=>'arrival_card'] && !isset($androidSubmit['data']['pay']), 'android direct submit must expose only the server-derived app payment target and business'); + $controller->input = ['business'=>'singapore','order_sn'=>'TEST1','mark'=>'forged','money'=>'0.01','target_table'=>'evus']; + $androidRepay = $controller->orderPayment(); + checkSubmit(($androidRepay['data']['app_payment'] ?? null) === ['order_sn'=>'TEST1','money'=>'79.00','mark'=>'evuscnsingapore','business'=>'singapore'] && !isset($androidRepay['data']['pay']), 'android repeat payment must only echo the upstream-verified public business'); + echo "PASS visa_submission_payment_self_check\n"; +}