PHP 工程師:打造多租戶 SaaS 與 AI 推薦服務——以 SaaSumi 民宿管理平台為例
前言:SaaS 浪潮與智能未來
各位 PHP 工程師朋友們,SaaS(軟體即服務)已經成為當代軟體開發的主流模式。多租戶架構不僅能大幅降低營運成本,還能為客戶提供白牌(White-label)自定義品牌的彈性。而隨著 AI 技術的蓬勃發展,如何將智能服務整合到我們的應用中,更是提升產品競爭力的關鍵。
今天,我將以一個實際的專案 SaaSumi - 民宿管理 SaaS 平台 為例,帶您深入了解如何使用 Laravel 打造多租戶後端,並巧妙地整合 FastAPI 實現 AI 房型推薦,同時兼顧前端體驗和在地化需求。
您可以透過以下 GitHub 連結檢閱本專案的原始碼:https://github.com/BpsEason/SaaSumi.git
SaaSumi 專案概覽:雲端民宿管理,智能推薦未來
SaaSumi 是一個專為日本市場設計的模組化多租戶民宿管理 SaaS 平台。它的核心目標是讓不同的民宿業者(租戶)在同一個平台上,獨立管理自己的訂房、住客、帳單和 LINE 通知,同時透過 AI 推薦系統提升住客體驗。
專案亮點速覽:
多租戶架構:使用
stancl/tenancy
實現資料庫隔離,每個民宿擁有獨立資料庫與設定。AI 房型推薦:整合 FastAPI 與
sentence-transformers
,實現日文關鍵詞的語義分析和房型推薦。LINE Notify 整合:提供完整的 OAuth 授權流程,支援租戶特定的 LINE 通知。
日語在地化:前端介面全日語化,支援日元金額和日式日期格式。
模組化設計:後端 Laravel、前端 Vue.js 採用模組化和組件化設計,易於擴展和維護。
Docker Compose & CI/CD:透過 Docker 快速部署,並具備 GitHub Actions 的 CI/CD 流程概念。
系統架構:Monorepo 與服務協作
SaaSumi 採用 Monorepo 結構,將前後端、AI 服務和基礎設施配置集中管理,提升開發效率。
graph TD
A[用戶瀏覽器] -->|HTTP 請求| B{Nginx 反向代理}
B -->|API 請求 (含 X-Tenant-Domain)| C[Laravel 後端服務]
B -->|AI 推薦請求| D[FastAPI AI 服務]
B -->|前端靜態資源| E[Vue.js 前端]
C -->|資料庫操作| F[MySQL 資料庫 (多租戶隔離)]
C -->|快取 / 佇列| G[Redis]
C -->|日誌寫入| H[OpenSearch (可選)]
C -->|發送通知| I[LINE Notify API]
D -->|語義模型載入| J[Sentence Transformers 模型]
E -->|API 呼叫| C
E -->|AI 推薦呼叫| D
Nginx:作為門戶,負責前端靜態資源服務,並根據請求的域名或路徑將 API 請求分發給 Laravel 後端或 FastAPI AI 服務。尤其會解析
X-Tenant-Domain
頭部,確保多租戶路由正確。Laravel 後端:核心業務邏輯處理者,負責租戶管理、用戶認證、住客 CRUD、KPI 計算、LINE Notify 整合等。
FastAPI AI 服務:獨立的 Python 微服務,專門處理 AI 房型推薦的複雜邏輯,與 Laravel 透過 HTTP 請求溝通。
Vue.js 前端:基於 Vue 3 和 Tailwind CSS 的單頁應用,提供友善的日語操作介面。
MySQL & Redis:資料持久化和高效能快取/佇列。
OpenSearch:用於集中收集日誌,方便監控與審計(目前為佔位實現)。
核心技術詳解與程式碼示範
1. Laravel 多租戶架構 (stancl/tenancy
)
這是 SaaSumi 的基石。stancl/tenancy
讓您無需為每個租戶手動配置資料庫連線,一切自動化。
安裝與基本配置:
composer require stancl/tenancy
php artisan tenancy:install
php artisan migrate # 會創建中央資料庫的 tenants 表
config/tenancy.php
關鍵配置:
// backend/config/tenancy.php (部分)
return [
'storage_driver' => Stancl\Tenancy\StorageDrivers\DatabaseStorageDriver::class,
'tenant_model' => App\Models\Tenant::class,
'database' => [
'prefix' => 'tenant', // 租戶資料庫前綴,如 tenant_yourdomain
'suffix' => '',
],
// ... 其他配置,如 Redis 和 Filesystem 隔離
'routes' => [
'web' => __DIR__ . '/../routes/tenant.php', // 租戶的 web 路由
],
'features' => [
// 啟用各種租戶特性,如快取、事件、路由、資產隔離
Stancl\Tenancy\Features\TenantConfig::class,
Stancl\Tenancy\Features\TenantCache::class,
Stancl\Tenancy\Features\TenantEvents::class,
Stancl\Tenancy\Features\TenantRoutes::class,
Stancl\Tenancy\Features\TenantAssets::class,
],
'exempt_domains' => [ // 不受租戶模式影響的中央域名
'localhost',
'admin.localhost',
],
];
租戶路由 (routes/tenant.php
):
// backend/routes/tenant.php (部分)
use Illuminate\Support\Facades\Route;
use Stancl\Tenancy\Middleware\InitializeTenancyByDomain;
use Stancl\Tenancy\Middleware\PreventAccessFromCentralDomains;
Route::middleware([
'web',
InitializeTenancyByDomain::class, // 透過域名初始化租戶
PreventAccessFromCentralDomains::class, // 防止中央域名訪問租戶路由
])->group(function () {
Route::get('/', function () {
return '這是您的多租戶應用程式。您的租戶 ID 是 ' . tenant('id');
});
// 在此定義租戶專屬的 web 路由
});
住客管理 API (GuestController.php
):
透過 BelongsToTenant
Trait(或在查詢時手動加入 where('tenant_id', tenant()->id)
),確保對 Guest
模型的操作都只影響當前租戶的資料。
// backend/app/Http/Controllers/GuestController.php (部分)
namespace App\Http\Controllers;
use App\Models\Guest; // 假設 Guest 模型已定義並與租戶關聯
use Illuminate\Http\Request;
class GuestController extends Controller
{
// 獲取當前租戶的住客列表
public function index(Request $request)
{
// 實際應用中,會根據租戶 ID 過濾
// $guests = Guest::where('tenant_id', tenant()->id)->orderBy('id', 'desc')->get();
// 這裡為了範例簡化,假設 Guest 模型已配置 BelongsToTenant
$guests = Guest::orderBy('id', 'desc')->get();
$query = $request->query('query');
$sortBy = $request->query('sortBy', 'id');
$sortDirection = $request->query('sortDirection', 'asc');
if ($query) {
$guests = $guests->filter(function($guest) use ($query) {
return str_contains(strtolower($guest->name), strtolower($query)) ||
str_contains(strtolower($guest->email), strtolower($query));
});
}
$guests = $guests->sortBy(function($guest) use ($sortBy) {
return is_string($guest->{$sortBy}) ? strtolower($guest->{$sortBy}) : $guest->{$sortBy};
}, SORT_NATURAL | SORT_FLAG_CASE, $sortDirection === 'desc');
return response()->json($guests->values());
}
// 新增住客
public function store(Request $request)
{
$validatedData = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|unique:guests,email',
'phone' => 'nullable|string|max:50',
'country' => 'nullable|string|max:100',
'stay_count' => 'nullable|integer|min:0',
'last_stay_date' => 'nullable|date',
'status' => 'nullable|string|in:active,inactive',
]);
// $validatedData['tenant_id'] = tenant()->id; // 實際應用中解註
$guest = Guest::create($validatedData);
return response()->json($guest, 201);
}
// ... update, show, destroy 方法
}
2. 整合 AI 推薦服務 (Laravel + FastAPI)
將 AI 邏輯從 Laravel 中解耦,使用 FastAPI 獨立提供服務,實現關注點分離。
FastAPI AI 推薦引擎 (fastapi-recommend/main.py
):
# fastapi-recommend/main.py (部分)
from fastapi import FastAPI, HTTPException
from sentence_transformers import SentenceTransformer, util
from typing import List, Dict
import logging
# 載入多語言語義模型 (僅載入一次)
model = SentenceTransformer('intfloat/multilingual-e5-large')
app = FastAPI(
title="AI Recommendation Engine",
description="Content-based recommendation API for hotel rooms.",
version="1.0.0",
)
# 模擬房型資料 (實際會從資料庫獲取)
sample_rooms = [
{"id": 1, "name": "豪華和室套房", "description": "傳統榻榻米房間,配有私人溫泉。享受日式庭園美景。", "image_url": "https://placehold.co/150x100/4A90E2/FFFFFF?text=和室套房"},
# ... 其他房型
]
# AI 房型推薦端點
@app.post("/api/recommend")
async def get_recommendations(keywords: str, limit: int = 5) -> Dict:
"""
根據日文關鍵詞推薦房型。
"""
if not keywords:
raise HTTPException(status_code=400, detail="關鍵詞不能為空。")
try:
# 1. 將查詢關鍵詞轉換為向量
query_embedding = model.encode(keywords, convert_to_tensor=True)
# 2. 將所有房型描述轉換為向量
corpus_embeddings = model.encode(
[room['description'] for room in sample_rooms],
convert_to_tensor=True
)
# 3. 計算餘弦相似度
cosine_scores = util.cos_sim(query_embedding, corpus_embeddings)[0]
# 4. 獲取相似度最高的 N 個結果
top_results_indices = cosine_scores.argsort(descending=True)[:limit]
recommendations = []
for idx in top_results_indices:
room_data = sample_rooms[idx]
recommendations.append({
'id': room_data['id'],
'name': room_data['name'],
'description': room_data['description'],
'image_url': room_data.get('image_url', ''),
'score': float(cosine_scores[idx]),
'explanation': f"此推薦基於您的關鍵詞「{keywords}」與房間描述的語義相似度。"
})
return {"recommendations": recommendations}
except Exception as e:
logging.error(f"AI 推薦錯誤: {str(e)}")
raise HTTPException(status_code=500, detail="AI 推薦服務內部錯誤。")
Laravel AI 代理服務 (AIProxyService.php
):
Laravel 透過這個服務與 FastAPI 溝通,像呼叫本地服務一樣。
// backend/app/Services/AIProxyService.php
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class AIProxyService
{
protected $fastApiUrl;
public function __construct()
{
// FastAPI 服務的內部 Docker 網路地址
$this->fastApiUrl = env('FASTAPI_RECOMMEND_URL', 'http://fastapi-recommend:8001');
}
public function recommendRooms(string $keywords, int $limit = 5): array
{
try {
$response = Http::timeout(10)->post("{$this->fastApiUrl}/api/recommend", [
'keywords' => $keywords,
'limit' => $limit,
]);
$response->throw(); // 如果響應是錯誤狀態碼 (4xx 或 5xx),會拋出異常
return $response->json();
} catch (\Illuminate\Http\Client\RequestException $e) {
Log::error("AI Recommendation API 錯誤: " . $e->getMessage());
return ['recommendations' => []]; // 返回空陣列或預設值
} catch (\Exception $e) {
Log::error("AIProxyService 錯誤: " . $e->getMessage());
return ['recommendations' => []];
}
}
}
3. 前端介面與在地化 (Vue.js + Tailwind CSS)
前端提供直觀的日語介面,並處理數據展示與用戶互動。
儀表板 (Dashboard
組件) 與 Chart.js 整合:
儀表板現在能動態獲取 KPI 數據並使用 Chart.js 繪製圖表。
<!-- frontend/index.html 內的 Vue.js <script> 標籤中 -->
<script>
// ... (其他組件定義) ...
const Dashboard = {
template: `
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- KPI 卡片 -->
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-lg font-semibold text-gray-500 mb-2">今月の予約数</h3>
<p class="text-3xl font-bold text-gray-800">{{ kpis.monthly_bookings }}</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-md">
<h3 class="text-lg font-semibold text-gray-500 mb-2">総収益 (月)</h3>
<p class="text-3xl font-bold text-gray-800">{{ formatCurrency(kpis.total_revenue_month) }}</p>
</div>
<!-- ... 其他 KPI 卡片 ... -->
<!-- 月次収益趨勢圖 -->
<div class="lg:col-span-4 bg-white p-6 rounded-lg shadow-md mt-6">
<h3 class="text-xl font-semibold mb-4 text-gray-700">月次予約トレンド</h3>
<canvas id="revenueChart" class="h-64"></canvas>
<div v-if="!chartLoaded" class="h-64 flex items-center justify-center text-gray-400">
<i class="fas fa-chart-bar fa-3x"></i>
<span class="ml-3 text-lg">グラフを読み込み中...</span>
</div>
</div>
</div>
`,
setup() {
const kpis = reactive({ /* 初始值 */ });
const chartLoaded = ref(false);
let chartInstance = null;
const formatCurrency = (amount) => {
return new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY', minimumFractionDigits: 0 }).format(amount);
};
const fetchKpis = async () => {
try {
const data = await fetchApi('dashboard/kpis'); // 呼叫後端 KPI API
Object.assign(kpis, data);
renderChart(); // 數據更新後重新繪製圖表
} catch (error) {
console.error("無法取得 KPI 資料:", error);
}
};
const renderChart = () => {
if (chartInstance) {
chartInstance.destroy(); // 銷毀舊圖表實例
}
const ctx = document.getElementById('revenueChart');
if (!ctx) return;
const labels = kpis.revenue_trend.map(item => item.month);
const dataPoints = kpis.revenue_trend.map(item => item.revenue);
chartInstance = new Chart(ctx, {
type: 'bar', // 或 'line'
data: {
labels: labels,
datasets: [{
label: '収益',
data: dataPoints,
backgroundColor: '#4A90E2',
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: { /* ... */ }
}
});
chartLoaded.value = true;
};
onMounted(() => {
fetchKpis(); // 組件掛載時獲取數據
});
return { kpis, formatCurrency, chartLoaded };
}
};
</script>
住客管理 (GuestManagement
組件) 與詳情/編輯彈窗:
現在住客資料從後端動態獲取,並提供新增、編輯、刪除和查看詳情功能。
<!-- frontend/index.html 內的 Vue.js <script> 標籤中 -->
<script>
// ... (其他組件定義) ...
const GuestManagement = {
template: `
<div class="bg-white p-8 rounded-lg shadow-xl">
<!-- 搜尋、排序、新增按鈕 -->
<div class="flex flex-col md:flex-row justify-between items-center mb-6 space-y-4 md:space-y-0">
<input type="text" v-model="searchQuery" @input="fetchGuests" placeholder="名前またはメールアドレスで検索..." class="w-full md:w-1/3 ...">
<div class="flex space-x-4">
<button @click="openAddGuestModal" class="bg-green-500 ...">
<i class="fas fa-plus mr-2"></i> 新規追加
</button>
<select v-model="sortBy" @change="fetchGuests" class="px-4 py-3 ..."> ... </select>
<button @click="toggleSortDirection" class="px-4 py-3 ..."> ... </button>
</div>
</div>
<!-- 住客列表表格 -->
<div class="overflow-x-auto relative shadow-md sm:rounded-lg">
<table class="w-full ...">
<thead> ... </thead>
<tbody>
<tr v-for="guest in guests" :key="guest.id" @click="viewGuestDetails(guest)">
<td>{{ guest.id }}</td>
<td>{{ guest.name }}</td>
<td>{{ guest.email }}</td>
<td>{{ guest.stay_count }}</td>
<td>{{ formatDate(guest.last_stay_date) }}</td>
<td>{{ guest.status === 'active' ? 'アクティブ' : '非アクティブ' }}</td>
<td>
<button @click.stop="openEditGuestModal(guest)">編集</button>
<button @click.stop="deleteGuest(guest.id)">削除</button>
</td>
</tr>
</tbody>
</table>
</div>
<div v-if="guests.length === 0 && !isLoadingGuests" class="text-center py-10 text-gray-500">
該当する宿泊者が見つかりません。
</div>
<div v-if="isLoadingGuests" class="text-center py-10 text-gray-500 flex justify-center items-center">
<div class="spinner-small mr-2"></div> 宿泊者データを読み込み中...
</div>
<!-- Guest Details Modal -->
<div v-if="showDetailModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-8 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-2xl font-bold mb-4">宿泊者詳細</h3>
<div v-if="selectedGuest">
<p><span>名前:</span> {{ selectedGuest.name }}</p>
<!-- ... 其他詳細資訊 ... -->
<button @click="showDetailModal = false">閉じる</button>
</div>
</div>
</div>
<!-- Add/Edit Guest Modal -->
<div v-if="showGuestFormModal" class="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white p-8 rounded-lg shadow-xl w-full max-w-md">
<h3 class="text-2xl font-bold mb-4">{{ isEditingGuest ? '宿泊者情報を編集' : '新規宿泊者を追加' }}</h3>
<form @submit.prevent="saveGuest">
<div class="mb-4">
<label>名前</label>
<input type="text" v-model="guestForm.name" required class="w-full ...">
</div>
<!-- ... 其他表單字段 ... -->
<div class="flex justify-end space-x-4">
<button type="button" @click="showGuestFormModal = false">キャンセル</button>
<button type="submit" :disabled="isSavingGuest">保存</button>
</div>
</form>
</div>
</div>
</div>
`,
setup() {
const guests = ref([]);
const searchQuery = ref('');
const sortBy = ref('id');
const sortDirection = ref('asc');
const isLoadingGuests = ref(false);
const showDetailModal = ref(false);
const selectedGuest = ref(null);
const showGuestFormModal = ref(false);
const isEditingGuest = ref(false);
const guestForm = reactive({ /* 初始表單數據 */ });
const isSavingGuest = ref(false);
const fetchGuests = async () => {
isLoadingGuests.value = true;
try {
const params = new URLSearchParams({
query: searchQuery.value, sortBy: sortBy.value, sortDirection: sortDirection.value
});
const data = await fetchApi(`guests?${params.toString()}`); // 呼叫後端 API 獲取住客
guests.value = data;
} catch (error) { console.error("獲取住客失敗:", error); }
finally { isLoadingGuests.value = false; }
};
const formatDate = (dateString) => { /* ... */ };
const viewGuestDetails = (guest) => { selectedGuest.value = guest; showDetailModal.value = true; };
const openAddGuestModal = () => { /* ... */ showGuestFormModal.value = true; };
const openEditGuestModal = (guest) => { /* ... */ showGuestFormModal.value = true; };
const saveGuest = async () => {
isSavingGuest.value = true;
try {
let response;
if (isEditingGuest.value) {
response = await fetchApi(`guests/${guestForm.id}`, { method: 'PUT', body: JSON.stringify(guestForm) });
} else {
response = await fetchApi('guests', { method: 'POST', body: JSON.stringify(guestForm) });
}
showCustomModal({ title: '成功', message: `住客資訊已成功${isEditingGuest.value ? '更新' : '新增'}。`, buttons: [{ text: 'OK', className: '...', handler: () => { showGuestFormModal.value = false; fetchGuests(); }}]});
} catch (error) { console.error("儲存住客失敗:", error); }
finally { isSavingGuest.value = false; }
};
const deleteGuest = async (id) => {
showCustomModal({
title: '確認', message: '確定要刪除此住客嗎?',
buttons: [
{ text: '取消', className: '...' },
{ text: '刪除', className: 'px-4 py-2 rounded-lg bg-red-500 text-white hover:bg-red-600 transition-colors', handler: async () => {
try {
await fetchApi(`guests/${id}`, { method: 'DELETE' });
showCustomModal({ title: '成功', message: '住客已成功刪除。', buttons: [{ text: 'OK', className: '...', handler: fetchGuests }]});
} catch (error) { console.error("刪除住客失敗:", error); }
}}
]
});
};
onMounted(fetchGuests); // 組件掛載時獲取住客列表
watch(searchQuery, () => { /* debounce ... */ setTimeout(fetchGuests, 300); });
return { /* ... */ };
}
};
</script>
4. Docker Compose:一鍵啟動所有服務
Monorepo 的優勢在於,所有服務都可以通過單一 docker-compose.yml
文件進行協調和啟動。
# docker-compose.yml (部分)
version: '3.8'
services:
backend:
build:
context: ./backend # Laravel 後端
dockerfile: Dockerfile
ports:
- "8000:80" # 映射到主機的 8000 端口
volumes:
- ./backend:/var/www/html # 程式碼映射
depends_on:
- mysql
- redis
- fastapi-recommend
environment:
- DB_HOST=mysql
- REDIS_HOST=redis
- FASTAPI_RECOMMEND_URL=http://fastapi-recommend:8001 # 內部網路地址
networks:
- monorepo-network
fastapi-recommend:
build:
context: ./fastapi-recommend # FastAPI AI 服務
dockerfile: Dockerfile
ports:
- "8001:8001" # 映射到主機的 8001 端口
volumes:
- ./fastapi-recommend:/app
- model_cache:/root/.cache/torch/sentence_transformers # 模型緩存持久化
networks:
- monorepo-network
nginx:
image: nginx:latest
ports:
- "8080:80" # 前端網頁端口
- "8000:8000" # 將主機 8000 映射到 Nginx 的 8000,供租戶域名使用
volumes:
- ./frontend:/usr/share/nginx/html:ro # 前端靜態檔案
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro # Nginx 配置
depends_on:
- backend
- fastapi-recommend
networks:
- monorepo-network
mysql: # MySQL 資料庫
image: mysql:8.0
ports: ["3306:3306"]
environment:
MYSQL_ROOT_PASSWORD: root_password
MYSQL_DATABASE: saas_central
volumes: ["mysql_data:/var/lib/mysql"]
networks: ["monorepo-network"]
redis: # Redis 快取與佇列
image: redis:alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
networks: ["monorepo-network"]
# 定義持久化卷和網路
volumes:
mysql_data:
redis_data:
model_cache:
networks:
monorepo-network:
driver: bridge
5. Nginx 配置:多租戶路由與代理
Nginx 負責將不同租戶的請求正確轉發給後端,並將 AI 請求轉發給 FastAPI。
# nginx/nginx.conf (部分)
events { worker_connections 1024; }
http {
# ... 其他基本配置 ...
upstream backend_app { server backend:80; } # Laravel 後端容器
upstream fastapi_recommend { server fastapi-recommend:8001; } # FastAPI 容器
# 主要前端服務 (http://localhost:8080)
server {
listen 8080;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html; # Vue.js SPA 路由
}
# 代理到 Laravel 後端的所有 API 和 LINE Notify 回調
location ~ ^/(api|line-notify)/ {
proxy_set_header Host $http_host; # 傳遞原始 Host 頭部
proxy_set_header X-Tenant-Domain $http_x_tenant_domain; # 確保租戶頭部傳遞
proxy_pass http://backend_app/$request_uri;
}
# 代理到 FastAPI AI 服務
location /api/recommend {
proxy_pass http://fastapi_recommend/api/recommend;
proxy_set_header Host $host;
}
}
# 租戶特定域名服務 (例如 http://your-tenant.localhost:8000)
server {
listen 8000;
server_name ~^(?<tenant_domain>.+)\.localhost$; # 捕獲租戶子域名
location / {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Tenant-Domain $tenant_domain.localhost; # 顯式傳遞租戶頭部
proxy_pass http://backend_app/$request_uri; # 所有請求代理到 Laravel 後端
}
}
}
部署與運行:實踐您的 SaaSumi
保存腳本:將所有上述程式碼片段,根據其所屬檔案路徑,整理到一個名為
create_project.sh
的腳本中,並確保它擁有執行權限 (chmod +x create_project.sh
)。執行腳本:在您想要建立專案的根目錄下執行
bash create_project.sh
。腳本會自動建立所有目錄和檔案。配置環境變數:複製根目錄下的
.env.example
為.env
,並根據您的需求更新 LINE Notify 憑證等配置。啟動 Docker 服務:執行
docker compose up --build -d
,這會自動構建映像並啟動所有服務。進入 Laravel 容器並安裝依賴:
docker exec -it backend_app bash composer install php artisan key:generate php artisan migrate --force # php artisan db:seed (如果需要測試資料)
建立租戶:
php artisan tenant:create your-tenant-domain # 例如 php artisan tenant:create tokyo-inn
修改 Hosts 檔案:
在您的本機電腦上,將 127.0.0.1 your-tenant-domain.localhost(例如 127.0.0.1 tokyo-inn.localhost)添加到您的 hosts 檔案中,以便瀏覽器能正確解析租戶域名。
訪問應用:
前端:
http://localhost:8080
租戶後台 API:
http://your-tenant-domain.localhost:8000/api
AI 推薦 API:
http://localhost:8001/api/recommend
總結與展望
通過 SaaSumi 專案,我們展示了如何利用 PHP (Laravel) 的強大功能和 Python (FastAPI) 在 AI 領域的優勢,共同構建一個現代、模組化且具備智能服務的多租戶 SaaS 平台。
這個專案為您提供了一個紮實的起點。未來您可以繼續探索:
更完善的認證:實現基於租戶的真實使用者認證。
動態 AI 數據:將 FastAPI 的房型資料從靜態改為從 Laravel 後端動態獲取,或整合專門的向量資料庫。
完整的測試覆蓋:為前後端撰寫更多的自動化測試。
生產環境優化:如前端打包、更複雜的 CI/CD 流程、服務監控等。
希望這篇教學能激發您的靈感,並幫助您在 PHP 工程師的道路上更進一步!如果您有任何問題或想法,歡迎在下方留言交流。
沒有留言:
張貼留言