feat(miniapi): add Android visa catalog endpoints

This commit is contained in:
gaofeng
2026-08-04 16:59:00 +08:00
parent 3ba5f51220
commit c988b03c97
10 changed files with 2060 additions and 1 deletions

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace app\miniapi\controller;
use app\miniapi\service\Product\AppCatalogService;
use app\miniapi\service\Product\ProductService;
use think\facade\Config;
class AppCatalog extends Base
{
public function bootstrap()
{
$site = (array)Config::get('app.miniapi_site', []);
$config = (array)Config::get('mini_app_catalog', []);
return $this->ok([
'brand' => [
'name' => trim((string)($config['brand_name'] ?? '')) ?: '九州签',
'company_name' => (string)($site['company_name'] ?? ''),
'logo' => (string)($site['logo'] ?? ''),
'logo_icon' => (string)($site['logo_icon'] ?? ''),
],
'support' => [
'phone' => (string)($site['phone'] ?? ''),
'hours' => (string)($config['support_hours'] ?? ''),
],
'privacy_version' => (string)($config['privacy_version'] ?? '1.0'),
'agreement_versions' => [
'privacy' => (string)($config['privacy_version'] ?? '1.0'),
'user_agreement' => (string)($config['user_agreement_version'] ?? '1.0'),
],
'features' => ['visa_catalog' => true],
'upload_limits' => (array)($config['upload_limits'] ?? []),
]);
}
public function home()
{
$catalog = $this->catalog();
$featured = array_values(array_filter(
$catalog,
static fn(array $item): bool => !empty($item['sellable']) && !empty($item['featured']),
));
return $this->ok([
'featured' => $featured,
'popular_destinations' => $this->destinations($catalog),
'service_guarantees' => (array)Config::get('mini_app_catalog.service_guarantees', []),
]);
}
public function visas()
{
$page = (int)$this->request->get('page', 1);
$pageSize = (int)$this->request->get('page_size', 50);
if ($page < 1 || $pageSize < 1 || $pageSize > 50) {
return $this->fail('invalid pagination', 422);
}
$catalog = array_values(array_filter(
$this->catalog(),
static fn(array $item): bool => !empty($item['sellable']),
));
return $this->ok([
'items' => array_slice($catalog, ($page - 1) * $pageSize, $pageSize),
'page' => $page,
'page_size' => $pageSize,
'total' => count($catalog),
]);
}
public function detail(string $product_id)
{
foreach ($this->catalog() as $item) {
if ((string)$item['id'] === $product_id) {
return $this->ok($item);
}
}
return $this->fail('product not found', 404);
}
public function schema(string $product_id)
{
foreach ($this->catalog() as $item) {
if ((string)$item['id'] === $product_id) {
$schema = (array)($item['schema'] ?? []);
return $this->ok([
'product_id' => $product_id,
'schema_version' => (int)($schema['schema_version'] ?? 1),
'sections' => (array)($schema['sections'] ?? []),
'materials' => (array)($schema['materials'] ?? []),
]);
}
}
return $this->fail('product not found', 404);
}
private function catalog(): array
{
$definitions = (array)Config::get('mini_app_catalog.products', []);
$productService = new ProductService();
$packages = [];
foreach ($definitions as $definition) {
$id = (string)($definition['id'] ?? '');
$business = (string)($definition['business'] ?? $id);
if ($id !== '' && $business !== '') {
$packages[$id] = $productService->list($this->currentMiniAppContext($business));
}
}
return (new AppCatalogService())->build($definitions, $packages);
}
private function destinations(array $catalog): array
{
$destinations = [];
foreach ($catalog as $item) {
if (empty($item['sellable'])) {
continue;
}
$code = (string)($item['country_code'] ?? '');
if ($code !== '' && !isset($destinations[$code])) {
$destinations[$code] = [
'code' => $code,
'name' => (string)($item['country_name'] ?? ''),
'name_en' => (string)($item['country_name_en'] ?? ''),
];
}
}
return array_values($destinations);
}
protected function ok($data = [], string $msg = 'ok')
{
return json([
'code' => 1,
'msg' => $msg,
'data' => $data,
'request_id' => $this->requestId(),
]);
}
protected function fail(string $msg = '操作失败', int $code = 0, $data = [])
{
$httpStatus = $code >= 400 && $code <= 599 ? $code : 500;
return json([
'code' => $code,
'msg' => $msg,
'data' => $data,
'request_id' => $this->requestId(),
], $httpStatus);
}
private function requestId(): string
{
$requestId = trim((string)$this->request->header('X-Request-Id', ''));
return preg_match('/^[A-Za-z0-9._-]{1,128}$/', $requestId) === 1
? $requestId
: bin2hex(random_bytes(16));
}
}

13
app/miniapi/route/app.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
use think\facade\Route;
Route::pattern(['product_id' => '[A-Za-z0-9_-]+']);
Route::group('app', function () {
Route::get('bootstrap', 'AppCatalog/bootstrap');
Route::get('home', 'AppCatalog/home');
Route::get('visas/:product_id/schema', 'AppCatalog/schema');
Route::get('visas/:product_id', 'AppCatalog/detail');
Route::get('visas', 'AppCatalog/visas');
});

View File

@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace app\miniapi\service\Product;
class AppCatalogService
{
public function build(array $definitions, array $packagesByProduct): array
{
foreach ($definitions as &$definition) {
if (!is_array($definition)) {
continue;
}
$id = (string)($definition['id'] ?? '');
$definition['packages'] = (array)($packagesByProduct[$id] ?? []);
}
unset($definition);
return $this->normalize($definitions);
}
public function normalize(array $products): array
{
$normalized = [];
foreach ($products as $product) {
if (!is_array($product)) {
continue;
}
$id = trim((string)($product['id'] ?? ''));
$name = trim((string)($product['name'] ?? ''));
if ($id === '' || $name === '') {
continue;
}
$packages = array_values(array_filter(
(array)($product['packages'] ?? []),
static fn($package): bool => is_array($package),
));
foreach ($packages as &$package) {
$package['price_display'] = trim((string)($package['price_display'] ?? ''))
?: $this->lowestPriceDisplay([$package]);
}
unset($package);
$product['id'] = $id;
$product['name'] = $name;
$product['packages'] = $packages;
$product['price_display'] = trim((string)($product['price_display'] ?? ''))
?: $this->lowestPriceDisplay($packages);
$product['sellable'] = array_key_exists('sellable', $product)
? (bool)$product['sellable']
: !empty($packages);
$product['expedited'] = array_key_exists('expedited', $product)
? (bool)$product['expedited']
: count($packages) > 1;
$normalized[] = $product;
}
return $normalized;
}
private function lowestPriceDisplay(array $packages): string
{
$prices = [];
foreach ($packages as $package) {
$value = (string)($package['value'] ?? '');
if (is_numeric($value) && (int)$value > 0) {
$prices[] = (int)$value;
}
}
if (empty($prices)) {
return '';
}
$cents = min($prices);
return $cents % 100 === 0
? '¥' . intdiv($cents, 100)
: '¥' . number_format($cents / 100, 2, '.', '');
}
}

View File

@@ -30,6 +30,7 @@
"alibabacloud/oss-v2": "^0.4.0" "alibabacloud/oss-v2": "^0.4.0"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^10.5",
"topthink/think-dumper": "^1.0", "topthink/think-dumper": "^1.0",
"topthink/think-trace": "^1.0" "topthink/think-trace": "^1.0"
}, },

1654
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,65 @@
<?php
return [
'brand_name' => env('APP_BRAND_NAME', '九州签'),
'privacy_version' => env('APP_PRIVACY_VERSION', '1.0'),
'user_agreement_version' => env('APP_USER_AGREEMENT_VERSION', '1.0'),
'support_hours' => env('APP_SUPPORT_HOURS', '工作日 09:00-18:00'),
'upload_limits' => [
'image_mb' => 10,
'pdf_mb' => 20,
],
'service_guarantees' => [
['title' => '资料安全', 'description' => '敏感资料加密传输与存储'],
['title' => '进度清晰', 'description' => '订单状态和补充材料要求及时可查'],
['title' => '电话客服', 'description' => '办理过程中可通过电话联系客服'],
],
'products' => [
[
'id' => 'evus',
'business' => 'evus',
'name' => '美国 EVUS',
'country_code' => 'US',
'country_name' => '美国',
'country_name_en' => 'United States',
'continent' => '北美洲',
'product_type' => 'EVUS',
'service_type' => '电子登记',
'processing_time' => '以所选服务套餐说明为准',
'applicable_audience' => '具体适用条件以相关机构最新要求和申请页面提示为准',
'validity' => '以相关机构审核结果为准',
'stay_duration' => '不适用',
'entries' => '不适用',
'materials_summary' => ['护照资料', '美国签证资料', '个人及行程信息'],
'notices' => ['请提交真实、完整的信息,提交前仔细核对。'],
'refund_rule' => '以订单确认页展示的退款规则为准',
'disclaimer' => '九州签为代办服务平台,最终结果以相关机构审核为准。',
'keywords' => ['美国', 'EVUS', '电子登记'],
'featured' => true,
'schema' => ['schema_version' => 1, 'sections' => [], 'materials' => []],
],
[
'id' => 'esta',
'business' => 'esta',
'name' => '美国 ESTA',
'country_code' => 'US',
'country_name' => '美国',
'country_name_en' => 'United States',
'continent' => '北美洲',
'product_type' => 'ESTA',
'service_type' => '旅行授权',
'processing_time' => '以所选服务套餐说明为准',
'applicable_audience' => '具体适用条件以相关机构最新要求和申请页面提示为准',
'validity' => '以相关机构审核结果为准',
'stay_duration' => '以入境机构决定为准',
'entries' => '以相关机构审核结果为准',
'materials_summary' => ['护照资料', '个人资料', '行程与资格信息'],
'notices' => ['请提交真实、完整的信息,提交前仔细核对。'],
'refund_rule' => '以订单确认页展示的退款规则为准',
'disclaimer' => '九州签为代办服务平台,最终结果以相关机构审核为准。',
'keywords' => ['美国', 'ESTA', '旅行授权'],
'featured' => true,
'schema' => ['schema_version' => 1, 'sections' => [], 'materials' => []],
],
],
];

View File

@@ -20,5 +20,11 @@ return [
'wechat_secret' => env('WECHAT_MINI_SECRET_BJ', ''), 'wechat_secret' => env('WECHAT_MINI_SECRET_BJ', ''),
'status' => 1, 'status' => 1,
], ],
'jiuzhouqian-android' => [
'company_code' => env('ANDROID_COMPANY_CODE', 'jn'),
'company_name' => env('ANDROID_COMPANY_NAME', 'jinan'),
'site_domain' => env('ANDROID_SITE_DOMAIN', env('SITE_DOMAIN', 'www.evus.cn')),
'status' => 1,
],
], ],
]; ];

12
phpunit.xml Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd"
bootstrap="tests/bootstrap.php"
cacheDirectory="runtime/phpunit"
colors="true">
<testsuites>
<testsuite name="MiniAPI">
<directory>tests/unit</directory>
</testsuite>
</testsuites>
</phpunit>

4
tests/bootstrap.php Normal file
View File

@@ -0,0 +1,4 @@
<?php
declare(strict_types=1);
require dirname(__DIR__) . '/vendor/autoload.php';

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace tests\unit;
use app\miniapi\service\Product\AppCatalogService;
use PHPUnit\Framework\TestCase;
final class AppCatalogServiceTest extends TestCase
{
public function testCatalogKeepsServerPriceTextAndSellableFlag(): void
{
$items = (new AppCatalogService())->normalize([
[
'id' => 'evus',
'name' => '美国 EVUS',
'price_display' => '¥299',
'sellable' => true,
],
]);
self::assertSame('¥299', $items[0]['price_display']);
self::assertTrue($items[0]['sellable']);
}
public function testCatalogDerivesCheapestPriceFromExistingPackages(): void
{
$items = (new AppCatalogService())->normalize([
[
'id' => 'evus',
'name' => '美国 EVUS',
'packages' => [
['value' => '49900', 'name' => '加急登记'],
['value' => '29900', 'name' => '标准登记'],
],
],
]);
self::assertSame('¥299', $items[0]['price_display']);
self::assertSame('¥499', $items[0]['packages'][0]['price_display']);
self::assertTrue($items[0]['sellable']);
}
public function testCatalogUsesPackagesForMatchingProductOnly(): void
{
$items = (new AppCatalogService())->build(
[
['id' => 'evus', 'name' => '美国 EVUS'],
['id' => 'korea', 'name' => '韩国电子签'],
],
[
'evus' => [['value' => '29900', 'name' => '标准登记']],
],
);
self::assertTrue($items[0]['sellable']);
self::assertFalse($items[1]['sellable']);
self::assertSame([], $items[1]['packages']);
}
}