feat(integration): 完善外部 API 對接邏輯與安全性

1. 新增 API Rate Limiting (每分鐘 60 次)
2. 實作 ProductServiceInterface 與 findOrCreateWarehouseByName 解決跨模組耦合問題
3. 強化 OrderSync API 驗證 (price 欄位限制最小 0、payment_method 加上允許白名單)
4. 實作 OrderSync API 冪等性處理,重複訂單直接回傳現有資訊
5. 修正 ProductSync API 同步邏輯,每次同步皆會更新產品分類與單位
6. 完善 integration API 對接手冊內容與 UI 排版
This commit is contained in:
2026-02-23 10:10:03 +08:00
parent 29cdf37b71
commit a05acd96dc
13 changed files with 303 additions and 37 deletions

View File

@@ -6,66 +6,79 @@ use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Modules\Integration\Models\SalesOrder;
use App\Modules\Integration\Models\SalesOrderItem;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class OrderSyncController extends Controller
{
protected $inventoryService;
protected $productService;
public function __construct(InventoryService $inventoryService)
{
public function __construct(
InventoryServiceInterface $inventoryService,
ProductServiceInterface $productService
) {
$this->inventoryService = $inventoryService;
$this->productService = $productService;
}
public function store(Request $request)
{
// 冪等性處理:若訂單已存在,回傳已建立的訂單資訊
$existingOrder = SalesOrder::where('external_order_id', $request->external_order_id)->first();
if ($existingOrder) {
return response()->json([
'message' => 'Order already exists',
'order_id' => $existingOrder->id,
], 200);
}
$request->validate([
'external_order_id' => 'required|string|unique:sales_orders,external_order_id',
'warehouse' => 'nullable|string',
'warehouse_id' => 'nullable|exists:warehouses,id',
'items' => 'required|array',
'warehouse_id' => 'nullable|integer',
'payment_method' => 'nullable|string|in:cash,credit_card,line_pay,ecpay,transfer,other',
'sold_at' => 'nullable|date',
'items' => 'required|array|min:1',
'items.*.pos_product_id' => 'required|string',
'items.*.qty' => 'required|numeric|min:0.0001',
'items.*.price' => 'required|numeric',
'items.*.price' => 'required|numeric|min:0',
]);
try {
return DB::transaction(function () use ($request) {
// 1. Create Order
// 1. 建立訂單
$order = SalesOrder::create([
'external_order_id' => $request->external_order_id,
'status' => 'completed',
'payment_method' => $request->payment_method ?? 'cash',
'total_amount' => 0, // Will calculate
'total_amount' => 0,
'sold_at' => $request->sold_at ?? now(),
'raw_payload' => $request->all(),
]);
// Find Warehouse (Default to "銷售倉庫")
// 2. 查找或建立倉庫
$warehouseId = $request->warehouse_id;
if (empty($warehouseId)) {
$warehouseName = $request->warehouse ?: '銷售倉庫';
$warehouse = Warehouse::firstOrCreate(['name' => $warehouseName], [
'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))),
'type' => 'system_sales',
'is_active' => true,
]);
$warehouse = $this->inventoryService->findOrCreateWarehouseByName($warehouseName);
$warehouseId = $warehouse->id;
}
$totalAmount = 0;
// 3. 處理訂單明細
foreach ($request->items as $itemData) {
// Find product by external ID (Strict Check)
$product = Product::where('external_pos_id', $itemData['pos_product_id'])->first();
// 透過介面查找產品
$product = $this->productService->findByExternalPosId($itemData['pos_product_id']);
if (!$product) {
throw new \Exception("Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first.");
throw new \Exception(
"Product not found for POS ID: " . $itemData['pos_product_id'] . ". Please sync product first."
);
}
$qty = $itemData['qty'];
@@ -73,23 +86,23 @@ class OrderSyncController extends Controller
$lineTotal = $qty * $price;
$totalAmount += $lineTotal;
// 2. Create Order Item
// 建立訂單明細
SalesOrderItem::create([
'sales_order_id' => $order->id,
'product_id' => $product->id,
'product_name' => $product->name, // Snapshot name
'product_name' => $product->name,
'quantity' => $qty,
'price' => $price,
'total' => $lineTotal,
]);
// 3. Deduct Stock (Force negative allowed for POS orders)
// 4. 扣除庫存(強制模式,允許負庫存)
$this->inventoryService->decreaseStock(
$product->id,
$warehouseId,
$qty,
"POS Order: " . $order->external_order_id,
true // Force = true
true
);
}

View File

@@ -4,14 +4,14 @@ namespace App\Modules\Integration\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Modules\Inventory\Services\ProductService;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use Illuminate\Support\Facades\Log;
class ProductSyncController extends Controller
{
protected $productService;
public function __construct(ProductService $productService)
public function __construct(ProductServiceInterface $productService)
{
$this->productService = $productService;
}
@@ -21,7 +21,7 @@ class ProductSyncController extends Controller
$request->validate([
'external_pos_id' => 'required|string',
'name' => 'required|string',
'price' => 'nullable|numeric',
'price' => 'nullable|numeric|min:0',
'barcode' => 'nullable|string',
'category' => 'nullable|string',
'unit' => 'nullable|string',
@@ -40,7 +40,9 @@ class ProductSyncController extends Controller
]);
} catch (\Exception $e) {
Log::error('Product Sync Failed', ['error' => $e->getMessage(), 'payload' => $request->all()]);
return response()->json(['message' => 'Sync failed'], 500);
return response()->json([
'message' => 'Sync failed: ' . $e->getMessage(),
], 500);
}
}
}

View File

@@ -4,6 +4,9 @@ namespace App\Modules\Integration;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use App\Modules\Integration\Middleware\TenantIdentificationMiddleware;
class IntegrationServiceProvider extends ServiceProvider
@@ -13,8 +16,13 @@ class IntegrationServiceProvider extends ServiceProvider
$this->loadRoutesFrom(__DIR__ . '/Routes/api.php');
$this->loadMigrationsFrom(__DIR__ . '/Database/Migrations');
// Register Middleware Alias
// 註冊 Middleware 別名
Route::aliasMiddleware('integration.tenant', TenantIdentificationMiddleware::class);
// 定義 Integration API 速率限制(每分鐘 60 次,依 Token 使用者識別)
RateLimiter::for('integration', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
}
public function register()

View File

@@ -5,7 +5,7 @@ use App\Modules\Integration\Controllers\ProductSyncController;
use App\Modules\Integration\Controllers\OrderSyncController;
Route::prefix('api/v1/integration')
->middleware(['api', 'integration.tenant', 'auth:sanctum']) // integration.tenant middleware to identify tenant
->middleware(['api', 'throttle:integration', 'integration.tenant', 'auth:sanctum'])
->group(function () {
Route::post('products/upsert', [ProductSyncController::class, 'upsert']);
Route::post('orders', [OrderSyncController::class, 'store']);

View File

@@ -131,4 +131,12 @@ interface InventoryServiceInterface
* @return array
*/
public function getDashboardStats(): array;
/**
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
*
* @param string $warehouseName
* @return object
*/
public function findOrCreateWarehouseByName(string $warehouseName);
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Modules\Inventory\Contracts;
/**
* 產品服務介面 供跨模組使用(如 Integration 模組)。
*/
interface ProductServiceInterface
{
/**
* 透過外部 POS ID 進行產品新增或更新Upsert
*
* @param array $data
* @return object
*/
public function upsertFromPos(array $data);
/**
* 透過外部 POS ID 查找產品。
*
* @param string $externalPosId
* @return object|null
*/
public function findByExternalPosId(string $externalPosId);
}

View File

@@ -4,13 +4,16 @@ namespace App\Modules\Inventory;
use Illuminate\Support\ServiceProvider;
use App\Modules\Inventory\Contracts\InventoryServiceInterface;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use App\Modules\Inventory\Services\InventoryService;
use App\Modules\Inventory\Services\ProductService;
class InventoryServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->bind(InventoryServiceInterface::class, InventoryService::class);
$this->app->bind(ProductServiceInterface::class, ProductService::class);
}
public function boot(): void

View File

@@ -590,5 +590,22 @@ class InventoryService implements InventoryServiceInterface
'abnormalItems' => $abnormalItems,
];
}
}
/**
* 依倉庫名稱查找或建立倉庫(供外部整合用)。
*
* @param string $warehouseName
* @return Warehouse
*/
public function findOrCreateWarehouseByName(string $warehouseName)
{
return Warehouse::firstOrCreate(
['name' => $warehouseName],
[
'code' => 'SALES-' . strtoupper(bin2hex(random_bytes(4))),
'type' => 'system_sales',
'is_active' => true,
]
);
}
}

View File

@@ -2,13 +2,14 @@
namespace App\Modules\Inventory\Services;
use App\Modules\Inventory\Contracts\ProductServiceInterface;
use App\Modules\Inventory\Models\Product;
use App\Modules\Inventory\Models\Category;
use App\Modules\Inventory\Models\Unit;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ProductService
class ProductService implements ProductServiceInterface
{
/**
* Upsert product from external POS source.
@@ -45,8 +46,8 @@ class ProductService
$product->code = $data['code'] ?? $product->external_pos_id;
}
// Handle Category (Default: 未分類)
if (empty($product->category_id)) {
// Handle Category — 每次同步都更新(若有傳入)
if (!empty($data['category']) || empty($product->category_id)) {
$categoryName = $data['category'] ?? '未分類';
$category = Category::firstOrCreate(
['name' => $categoryName],
@@ -55,8 +56,8 @@ class ProductService
$product->category_id = $category->id;
}
// Handle Base Unit (Default: 個)
if (empty($product->base_unit_id)) {
// Handle Base Unit — 每次同步都更新(若有傳入)
if (!empty($data['unit']) || empty($product->base_unit_id)) {
$unitName = $data['unit'] ?? '個';
$unit = Unit::firstOrCreate(['name' => $unitName]);
$product->base_unit_id = $unit->id;
@@ -69,4 +70,15 @@ class ProductService
return $product;
});
}
/**
* 透過外部 POS ID 查找產品。
*
* @param string $externalPosId
* @return Product|null
*/
public function findByExternalPosId(string $externalPosId)
{
return Product::where('external_pos_id', $externalPosId)->first();
}
}