This commit is contained in:
gaofeng
2026-09-11 09:34:15 +08:00
parent 8a02b7fd11
commit 5be2b76d88
3 changed files with 218 additions and 9 deletions

View File

@@ -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; }
}

View File

@@ -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]]);

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace {
const MINIAPI_TOKEN_KEY = 'test';
const PAY_MARK = 'evuscn';
function authcode($value, ...$args) { return $value; }
function config($key) { return 'https://example.test/home/users/list'; }
}
namespace think\facade { class Log { static function warning($message) {} } }
namespace api {
class Httpcurl {
public static array $result = [];
public static array $lastPayload = [];
static function request($url, $method, $data) { parse_str($data, self::$lastPayload); return [json_encode(self::$result), null, null, '']; }
}
}
namespace app\miniapi\service\Payment {
class MiniPayService {
public static int $calls = 0;
public static bool $throws = false;
public static array $result = ['code'=>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";
}