diff --git a/app/miniapi/controller/AppCatalog.php b/app/miniapi/controller/AppCatalog.php index e0a6ac3..90f9d1c 100644 --- a/app/miniapi/controller/AppCatalog.php +++ b/app/miniapi/controller/AppCatalog.php @@ -5,6 +5,7 @@ namespace app\miniapi\controller; use app\miniapi\service\Product\AppCatalogService; use app\miniapi\service\Product\ProductService; +use app\miniapi\service\AppUpdateService; use think\facade\Config; class AppCatalog extends Base @@ -13,6 +14,7 @@ class AppCatalog extends Base { $site = (array)Config::get('app.miniapi_site', []); $config = (array)Config::get('mini_app_catalog', []); + $updateService = new AppUpdateService(); return $this->ok([ 'brand' => [ @@ -32,9 +34,27 @@ class AppCatalog extends Base ], 'features' => ['visa_catalog' => true], 'upload_limits' => (array)($config['upload_limits'] ?? []), + 'android_update' => $updateService->bootstrap( + (array)($config['android_update'] ?? []), + (string)$this->request->get('channel', 'official'), + ), ]); } + public function update() + { + $config = (array)Config::get('mini_app_catalog.android_update', []); + $target = (new AppUpdateService())->storeTarget( + $config, + (string)$this->request->get('channel', 'official'), + ); + if ($target === '') { + return $this->fail('该渠道更新地址尚未配置', 404); + } + + return redirect($target); + } + public function home() { $catalog = $this->catalog(); diff --git a/app/miniapi/controller/EvusCorrection.php b/app/miniapi/controller/EvusCorrection.php new file mode 100644 index 0000000..7c21406 --- /dev/null +++ b/app/miniapi/controller/EvusCorrection.php @@ -0,0 +1,47 @@ +requireLogin(); + if (!is_int($userId)) { + return $userId; + } + + $orderSn = trim((string)$this->request->param('order_sn', '')); + if ($orderSn === '') { + return $this->fail('参数错误'); + } + $result = (new EvusCorrectionService())->detail($userId, $orderSn); + if ((int)($result['code'] ?? 0) !== 1) { + return $this->fail((string)($result['msg'] ?? '服务异常,请稍后重试')); + } + + return $this->ok((array)($result['data'] ?? [])); + } + + public function submit() + { + $userId = $this->requireLogin(); + if (!is_int($userId)) { + return $userId; + } + + $params = $this->postData(); + if (trim((string)($params['order_sn'] ?? '')) === '') { + return $this->fail('参数错误'); + } + $result = (new EvusCorrectionService())->submit($userId, $params); + if ((int)($result['code'] ?? 0) !== 1) { + return $this->fail((string)($result['msg'] ?? '提交失败,请稍后重试')); + } + + return $this->ok((array)($result['data'] ?? []), '提交成功'); + } +} diff --git a/app/miniapi/controller/Upload.php b/app/miniapi/controller/Upload.php index 0c22684..5e9c408 100644 --- a/app/miniapi/controller/Upload.php +++ b/app/miniapi/controller/Upload.php @@ -11,7 +11,7 @@ class Upload extends Base { $scene = strtolower(trim((string)$this->request->post('scene', ''))); $contactUserId = 0; - if (in_array($scene, ['passport', 'id_card'], true)) { + if (in_array($scene, ['passport', 'id_card', 'evus_correction'], true)) { $login = $this->requireLogin(); if (!is_int($login)) { return $login; @@ -55,9 +55,13 @@ class Upload extends Base } $fileName = date('YmdHis') . uniqid('', true) . '.' . $ext; - $objectKey = $contactUserId > 0 - ? 'contact/' . $contactUserId . '/' . $fileName - : $fileName; + if ($scene === 'evus_correction') { + $objectKey = 'evus-correction/' . $contactUserId . '/' . $fileName; + } elseif ($contactUserId > 0) { + $objectKey = 'contact/' . $contactUserId . '/' . $fileName; + } else { + $objectKey = $fileName; + } try { $client = SwooleService::getInstance(); diff --git a/app/miniapi/route/app.php b/app/miniapi/route/app.php index 7de93d3..35efb8f 100644 --- a/app/miniapi/route/app.php +++ b/app/miniapi/route/app.php @@ -6,6 +6,7 @@ Route::pattern(['product_id' => '[A-Za-z0-9_-]+']); Route::group('app', function () { Route::get('bootstrap', 'AppCatalog/bootstrap'); + Route::get('update', 'AppCatalog/update'); Route::get('home', 'AppCatalog/home'); Route::get('visas/:product_id/schema', 'AppCatalog/schema'); Route::get('visas/:product_id', 'AppCatalog/detail'); diff --git a/app/miniapi/service/AppUpdateService.php b/app/miniapi/service/AppUpdateService.php new file mode 100644 index 0000000..04af432 --- /dev/null +++ b/app/miniapi/service/AppUpdateService.php @@ -0,0 +1,60 @@ +normalizeChannel($requestedChannel); + $gateway = $this->safeHttpsUrl((string)($config['gateway_url'] ?? '')); + + return [ + 'latest_version_code' => max(0, (int)($config['latest_version_code'] ?? 0)), + 'latest_version_name' => trim((string)($config['latest_version_name'] ?? '')), + 'minimum_version_code' => max(0, (int)($config['minimum_version_code'] ?? 0)), + 'download_url' => $gateway === '' ? '' : $gateway . (str_contains($gateway, '?') ? '&' : '?') . http_build_query( + ['channel' => $channel], + '', + '&', + PHP_QUERY_RFC3986, + ), + 'changelog' => trim((string)($config['changelog'] ?? '')), + 'force_update' => filter_var($config['force_update'] ?? false, FILTER_VALIDATE_BOOLEAN), + ]; + } + + public function storeTarget(array $config, string $requestedChannel): string + { + $channel = $this->normalizeChannel($requestedChannel); + $urls = (array)($config['store_urls'] ?? []); + $target = $this->safeHttpsUrl((string)($urls[$channel] ?? '')); + if ($target !== '') { + return $target; + } + + return $this->safeHttpsUrl((string)($urls['official'] ?? $config['fallback_url'] ?? '')); + } + + private function safeHttpsUrl(string $value): string + { + $url = trim($value); + if ($url === '' || filter_var($url, FILTER_VALIDATE_URL) === false) { + return ''; + } + $parts = parse_url($url); + if (($parts['scheme'] ?? '') !== 'https' || empty($parts['host']) || isset($parts['user'])) { + return ''; + } + return $url; + } +} diff --git a/app/miniapi/service/EvusCorrection/EvusCorrectionService.php b/app/miniapi/service/EvusCorrection/EvusCorrectionService.php new file mode 100644 index 0000000..195f44f --- /dev/null +++ b/app/miniapi/service/EvusCorrection/EvusCorrectionService.php @@ -0,0 +1,64 @@ +request('detail', $userId, ['order_sn' => $orderSn]); + } + + public function submit(int $userId, array $params): array + { + return $this->request('submit', $userId, $params); + } + + public function payload(int $userId, array $params = []): array + { + unset($params['user_id']); + return ['user_id' => $userId] + $params; + } + + public function endpoint(string $userUrl, string $action): string + { + if (!in_array($action, ['detail', 'submit'], true)) { + return ''; + } + + $userUrl = rtrim(trim($userUrl), '/'); + $endpoint = preg_replace( + '#/users/[^/?]+(?:\?.*)?$#i', + '/evus_correction/' . $action, + $userUrl + ); + + return is_string($endpoint) && $endpoint !== $userUrl ? $endpoint : ''; + } + + private function request(string $action, int $userId, array $params): array + { + $url = $this->endpoint((string)config('app.user_url'), $action); + if ($url === '') { + return ['code' => 0, 'msg' => 'EVUS资料服务地址异常', 'data' => []]; + } + + $response = Httpcurl::request($url, 'post', $this->payload($userId, $params)); + if (!is_array($response) || !isset($response[0]) || !empty($response[3])) { + Log::error('miniapi evus correction ' . $action . ' request failed'); + return ['code' => 0, 'msg' => '服务异常,请稍后重试', 'data' => []]; + } + + $result = json_decode($response[0], true); + if (!is_array($result)) { + Log::error('miniapi evus correction ' . $action . ' response decode failed'); + return ['code' => 0, 'msg' => '服务异常,请稍后重试', 'data' => []]; + } + + return $result; + } +} diff --git a/app/miniapi/service/Legal/LegalDocumentService.php b/app/miniapi/service/Legal/LegalDocumentService.php index 60f4c03..52cd1d0 100644 --- a/app/miniapi/service/Legal/LegalDocumentService.php +++ b/app/miniapi/service/Legal/LegalDocumentService.php @@ -93,8 +93,9 @@ class LegalDocumentService { $companyName = $company['company_name'] ?: 'EVUS登记服务'; $siteUrl = $this->siteUrl((string)($company['site_domain'] ?? '')); - $phone = $company['phone'] ?: '请通过小程序客服入口联系我们'; + $phone = $company['phone'] ?: '请通过应用内客服入口联系我们'; $intro = $this->replaceTokens((string)($document['intro_template'] ?? ''), $company, $siteUrl, $phone); + $append = $this->replaceTokens((string)($document['append_content'] ?? ''), $company, $siteUrl, $phone); $subjectLabel = trim((string)($document['subject_label'] ?? '服务主体')); $prefix = [ @@ -108,7 +109,7 @@ class LegalDocumentService return trim((string)$line) !== ''; })); - return implode("\n", $prefix) . "\n\n" . $body; + return implode("\n", $prefix) . "\n\n" . $body . ($append !== '' ? "\n\n" . $append : ''); } private function replaceTokens(string $text, array $company, string $siteUrl, string $phone): string diff --git a/config/mini_app_catalog.php b/config/mini_app_catalog.php index 7dd14cc..eaa7971 100644 --- a/config/mini_app_catalog.php +++ b/config/mini_app_catalog.php @@ -9,6 +9,23 @@ return [ 'image_mb' => 10, 'pdf_mb' => 20, ], + 'android_update' => [ + 'latest_version_code' => (int)env('APP_ANDROID_LATEST_VERSION_CODE', 0), + 'latest_version_name' => env('APP_ANDROID_LATEST_VERSION_NAME', ''), + 'minimum_version_code' => (int)env('APP_ANDROID_MINIMUM_VERSION_CODE', 0), + 'gateway_url' => env('APP_ANDROID_UPDATE_GATEWAY_URL', 'https://miniapi.jzvisa.cn/miniapi/app/update'), + 'fallback_url' => env('APP_ANDROID_FALLBACK_URL', ''), + 'changelog' => env('APP_ANDROID_CHANGELOG', ''), + 'force_update' => env('APP_ANDROID_FORCE_UPDATE', false), + 'store_urls' => [ + 'official' => env('APP_ANDROID_STORE_OFFICIAL_URL', ''), + 'huawei' => env('APP_ANDROID_STORE_HUAWEI_URL', ''), + 'xiaomi' => env('APP_ANDROID_STORE_XIAOMI_URL', ''), + 'oppo' => env('APP_ANDROID_STORE_OPPO_URL', ''), + 'vivo' => env('APP_ANDROID_STORE_VIVO_URL', ''), + 'tencent' => env('APP_ANDROID_STORE_TENCENT_URL', ''), + ], + ], 'service_guarantees' => [ ['title' => '资料安全', 'description' => '敏感资料加密传输与存储'], ['title' => '进度清晰', 'description' => '订单状态和补充材料要求及时可查'], diff --git a/config/mini_legal.php b/config/mini_legal.php index 19e337d..ca3be7e 100644 --- a/config/mini_legal.php +++ b/config/mini_legal.php @@ -37,6 +37,19 @@ return [ 'evus_bj_mp' => [ 'company_code' => 'bj', ], + 'evus_jn_app' => [ + 'company_code' => 'jn', + 'documents' => [ + 'terms' => [ + 'intro_template' => '本条款与条件适用于{company_name}提供的eVUS移动应用服务。', + ], + 'privacy' => [ + 'intro_template' => '本隐私政策适用于{company_name}提供的eVUS移动应用服务。', + 'content' => "一、我们处理的信息\n1. 账号信息:短信登录所需的手机号码、验证码校验结果及登录状态。\n2. 申请人信息:姓名、出生日期、性别、国籍、身份证明号码、联系方式、家庭与工作信息,以及办理EVUS或ESTA所需的其他资料。\n3. 证件和材料:您主动拍摄或选择的护照、美国签证、身份证明及补充材料图片,以及OCR识别结果。\n4. 订单信息:订单编号、服务套餐、金额、申请人、订单状态、支付渠道和支付结果。我们不会保存支付宝或微信的支付密码。\n5. 必要技术信息:为保障网络通信、登录安全和故障排查所需的应用版本、渠道标识、网络请求时间及必要安全日志。\n\n二、处理目的与方式\n上述信息仅用于身份验证、资料预填与校验、提交登记申请、生成和管理订单、确认支付结果、查询办理进度、生成业务文件、申请发票及提供电话客服。相机或系统图片选择器仅在您主动拍摄或选择材料时使用;本应用不读取通讯录、通话记录或精确位置。\n\n三、共享、委托处理与对外提供\n为完成您主动选择的功能,我们可能向支付机构、OCR识别服务和文件存储服务传输完成该功能所必需的信息。除取得您的单独同意、履行服务所必需或法律法规另有规定外,我们不会出售或向无关第三方提供您的个人信息。第三方服务的具体情况见本政策后的《第三方SDK与服务说明》及App内《第三方SDK与服务清单》。\n\n四、存储与保存期限\n个人信息原则上存储在中华人民共和国境内,并仅在实现服务目的和满足法律、财税、争议处理及安全审计要求所必需的期限内保存。超过保存期限后,我们将删除或匿名化处理;法律法规另有要求的除外。申请草稿和部分发票记录可能加密保存在当前设备,清除应用数据或卸载应用后可能无法恢复。\n\n五、安全保护\n我们采用HTTPS加密传输、访问控制、敏感字段脱敏和本地安全存储等措施保护信息。请妥善保管短信验证码,不要向他人泄露。\n\n六、您的权利\n您可以在App内查看、修改或删除常用申请人资料,查看订单与申请进度,并可退出当前登录账号。如需查阅、更正、复制或删除其他个人信息,可通过App内电话客服联系我们。我们将在核实身份后依法处理。\n\n七、未成年人信息\n如您为未成年人提交资料,应由监护人操作或在监护人同意和指导下使用本服务。代他人提交资料前,请确保已取得申请人的合法授权。\n\n八、政策更新与联系我们\n当处理目的、信息类型或第三方服务发生重要变化时,我们将更新本政策并通过App内显著方式提示。对本政策有疑问,可通过App内“关于我们”所列电话联系我们。", + 'append_content' => "第三方 SDK 与服务说明\n支付宝 App 支付 SDK:仅在用户主动选择支付宝付款时,用于调起支付宝并完成订单支付。\n微信 OpenSDK:当前微信支付尚未启用;启用后仅在用户主动选择微信支付时调用。\nOCR 识别服务:仅在用户主动拍摄或上传证件材料时,用于识别文字并辅助填写。\n阿里云 OSS 文件存储服务:用于加密传输和存储用户主动提交的申请材料及业务文件。\n上述能力不会因浏览普通页面而调用,具体处理范围以 App 内《第三方 SDK 与服务清单》为准。", + ], + ], + ], ], ], ], diff --git a/route/app.php b/route/app.php index 29bde63..fbd46e4 100644 --- a/route/app.php +++ b/route/app.php @@ -44,6 +44,8 @@ Route::group('miniapi', function () { Route::get('ocr/signature', 'Ocr/signature'); Route::post('upload/image', 'Upload/image'); + Route::get('evus/correction', 'EvusCorrection/detail'); + Route::post('evus/correction/submit', 'EvusCorrection/submit'); Route::post('evus/lookup', 'Evus/lookup'); Route::post('evus/apply', 'Evus/apply'); Route::post('esta/apply', 'Esta/apply'); diff --git a/tests/customer_center_action_dispatch_self_check.php b/tests/customer_center_action_dispatch_self_check.php new file mode 100644 index 0000000..64580ac --- /dev/null +++ b/tests/customer_center_action_dispatch_self_check.php @@ -0,0 +1,25 @@ + $detail || $remove > $detail) { + throw new RuntimeException('Specific applicant routes must be registered before the detail route'); +} +if (!preg_match("/Route::post\('customer-center\\/applicant',\s*'CustomerCenter\\/applicant'\)->completeMatch\(\);/", $routes)) { + throw new RuntimeException('Applicant detail route must use complete matching'); +} + +echo "Customer center route order self-check passed\n"; diff --git a/tests/evus_correction_route_self_check.php b/tests/evus_correction_route_self_check.php new file mode 100644 index 0000000..66540d7 --- /dev/null +++ b/tests/evus_correction_route_self_check.php @@ -0,0 +1,51 @@ +endpoint('https://user.example.com/home/users/login', 'detail'), + 'Detail must use the existing user URL host and isolated upstream controller' +); +miniCorrectionAssertSame( + 'https://user.example.com/home/evus_correction/submit', + $service->endpoint('https://user.example.com/home/users/login?source=mini', 'submit'), + 'Submit must remove the existing users action and query string' +); +miniCorrectionAssertSame('', $service->endpoint('https://user.example.com/home/users/login', 'delete'), 'Unknown upstream actions must be rejected'); +miniCorrectionAssertSame( + ['user_id' => 88, 'order_sn' => 'EVUS-1'], + $service->payload(88, ['user_id' => 999, 'order_sn' => 'EVUS-1']), + 'Authenticated user ID must override an untrusted client value' +); + +$routes = file_get_contents(__DIR__ . '/../route/app.php'); +if (!is_string($routes) + || !str_contains($routes, "Route::get('evus/correction', 'EvusCorrection/detail');") + || !str_contains($routes, "Route::post('evus/correction/submit', 'EvusCorrection/submit');") +) { + throw new RuntimeException('The miniapi must register explicit correction detail and submit routes'); +} + +$upload = file_get_contents(__DIR__ . '/../app/miniapi/controller/Upload.php'); +if (!is_string($upload) + || !str_contains($upload, "'evus_correction'") + || !str_contains($upload, "'evus-correction/' . \$contactUserId . '/' . \$fileName") +) { + throw new RuntimeException('The correction upload scene must require login and use the account-scoped OSS prefix'); +} + +echo "Miniapi EVUS correction route self-check passed\n"; diff --git a/tests/legal_app_config_self_check.php b/tests/legal_app_config_self_check.php new file mode 100644 index 0000000..b0d7167 --- /dev/null +++ b/tests/legal_app_config_self_check.php @@ -0,0 +1,40 @@ +bootstrap([ + 'latest_version_code' => 21, + 'latest_version_name' => '1.0.19', + 'gateway_url' => 'https://miniapi.jzvisa.cn/miniapi/app/update', + ], 'huawei'); + + self::assertSame(21, $result['latest_version_code']); + self::assertSame('https://miniapi.jzvisa.cn/miniapi/app/update?channel=huawei', $result['download_url']); + } + + public function testUnknownChannelFallsBackToOfficial(): void + { + $service = new AppUpdateService(); + + self::assertSame('official', $service->normalizeChannel('unknown-store')); + self::assertSame('official', $service->normalizeChannel('')); + } + + public function testTextFalseDoesNotEnableForcedUpdate(): void + { + $result = (new AppUpdateService())->bootstrap([ + 'force_update' => 'false', + ], 'official'); + + self::assertFalse($result['force_update']); + } + + public function testStoreTargetFallsBackToOfficialAndRejectsUnsafeUrls(): void + { + $service = new AppUpdateService(); + $config = [ + 'store_urls' => [ + 'official' => 'https://jzvisa.cn/evus', + 'huawei' => 'javascript:alert(1)', + ], + ]; + + self::assertSame('https://jzvisa.cn/evus', $service->storeTarget($config, 'huawei')); + self::assertSame('https://jzvisa.cn/evus', $service->storeTarget($config, 'xiaomi')); + } +} diff --git a/tests/upload_transport_self_check.php b/tests/upload_transport_self_check.php new file mode 100644 index 0000000..4b28b19 --- /dev/null +++ b/tests/upload_transport_self_check.php @@ -0,0 +1,20 @@ + 0")) { + throw new RuntimeException('Upload responses must escape Unicode for wx.uploadFile compatibility'); +} + +echo "Upload transport self-check passed\n"; diff --git a/tests/wechat_phone_token_retry_self_check.php b/tests/wechat_phone_token_retry_self_check.php new file mode 100644 index 0000000..5bc0e63 --- /dev/null +++ b/tests/wechat_phone_token_retry_self_check.php @@ -0,0 +1,97 @@ + 200], 0, '']; + } + } +} + +namespace think\facade { + final class Cache + { + public static array $items = []; + + public static function get(string $key, $default = null) + { + return self::$items[$key] ?? $default; + } + + public static function set(string $key, $value, $ttl = null): bool + { + self::$items[$key] = $value; + return true; + } + + public static function delete(string $key): bool + { + unset(self::$items[$key]); + return true; + } + } + + final class Log + { + public static array $warnings = []; + + public static function warning(string $message): void + { + self::$warnings[] = $message; + } + + public static function error(string $message): void + { + } + } +} + +namespace { + require __DIR__ . '/../app/service/MiniProgramWechatService.php'; + + use api\Httpcurl; + use app\service\MiniProgramWechatService; + use think\facade\Cache; + + $appid = 'wx-test-appid'; + $cacheKey = 'miniapp_stable_access_token_' . md5('evus_bj_mp|' . $appid); + Cache::$items[$cacheKey] = 'stale-token'; + Httpcurl::$responses = [ + ['errcode' => 40001, 'errmsg' => 'invalid credential'], + ['access_token' => 'fresh-stable-token', 'expires_in' => 7200], + ['errcode' => 0, 'phone_info' => ['purePhoneNumber' => '18668962632']], + ]; + + $service = new MiniProgramWechatService('evus_bj_mp', [ + 'wechat_appid' => $appid, + 'wechat_secret' => 'test-secret', + ]); + $mobile = $service->mobileByPhoneCode('single-use-phone-code'); + + if ($mobile !== '18668962632') { + throw new RuntimeException('Phone lookup must retry once after an invalid access token'); + } + if (count(Httpcurl::$requests) !== 3) { + throw new RuntimeException('Phone lookup must make exactly one retry'); + } + if (!str_contains((string)Httpcurl::$requests[1]['url'], '/cgi-bin/stable_token')) { + throw new RuntimeException('Token refresh must use the stable access token endpoint'); + } + $refreshBody = json_decode((string)Httpcurl::$requests[1]['data'], true); + if (($refreshBody['force_refresh'] ?? null) !== true) { + throw new RuntimeException('Invalid access token retry must force one stable token refresh'); + } + if ((Cache::$items[$cacheKey] ?? '') !== 'fresh-stable-token') { + throw new RuntimeException('Refreshed stable access token must be cached'); + } + + echo "Wechat phone token retry self-check passed\n"; +}