feat(miniapi): add Android visa catalog endpoints
This commit is contained in:
166
app/miniapi/controller/AppCatalog.php
Normal file
166
app/miniapi/controller/AppCatalog.php
Normal 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
13
app/miniapi/route/app.php
Normal 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');
|
||||
});
|
||||
80
app/miniapi/service/Product/AppCatalogService.php
Normal file
80
app/miniapi/service/Product/AppCatalogService.php
Normal 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, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@
|
||||
"alibabacloud/oss-v2": "^0.4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"topthink/think-dumper": "^1.0",
|
||||
"topthink/think-trace": "^1.0"
|
||||
},
|
||||
|
||||
1654
composer.lock
generated
1654
composer.lock
generated
File diff suppressed because it is too large
Load Diff
65
config/mini_app_catalog.php
Normal file
65
config/mini_app_catalog.php
Normal 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' => []],
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -20,5 +20,11 @@ return [
|
||||
'wechat_secret' => env('WECHAT_MINI_SECRET_BJ', ''),
|
||||
'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
12
phpunit.xml
Normal 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
4
tests/bootstrap.php
Normal file
@@ -0,0 +1,4 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
60
tests/unit/AppCatalogServiceTest.php
Normal file
60
tests/unit/AppCatalogServiceTest.php
Normal 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']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user