CI/CD 自動部署到 POS 設備
GitHub Actions CI/CD Pipeline
💡 比喻:自動化的工廠生產線 CI/CD 就像一條自動化工廠生產線: 你把原料(程式碼)放上去, 機器會自動檢查品質(Test)、組裝(Build)、包裝(Package)、 然後送到客戶手上(Deploy)。 全程不需要人工介入,又快又可靠。
GitHub Actions 工作流程
# .github/workflows/pos-deploy.yml // CI/CD 工作流程設定檔
name: POS System CI/CD # 工作流程名稱 // 在 GitHub 上顯示的名稱
on: # 觸發條件 // 什麼時候執行這個流程
push: # 推送程式碼時觸發
branches: [ main ] # 只在 main 分支觸發 // 避免每個分支都部署
pull_request: # PR 時觸發 // 用於檢查程式碼品質
branches: [ main ] # 只在 main 分支的 PR
env: # 環境變數 // 全域設定
DOTNET_VERSION: '8.0.x' # .NET 版本 // 使用 8.0 LTS
NODE_VERSION: '20' # Node.js 版本 // LTS 版本
jobs: # 工作定義 // 包含所有要執行的步驟
# === 階段 1:建置和測試 === // Build + Test
build-and-test: # 工作名稱
runs-on: ubuntu-latest # 執行環境 // 使用最新 Ubuntu
steps: # 步驟清單
- uses: actions/checkout@v4 # 取出程式碼 // 從 Git 倉庫
- name: Setup .NET # 安裝 .NET SDK
uses: actions/setup-dotnet@v4 # 使用官方 Action
with: # 設定參數
dotnet-version: ${{ env.DOTNET_VERSION }} # 使用指定版本
- name: Restore dependencies # 還原 NuGet 套件
run: dotnet restore # 下載所有相依套件
- name: Build # 建置專案
run: dotnet build --no-restore --configuration Release # Release 模式建置
- name: Test # 執行測試
run: dotnet test --no-build --configuration Release --verbosity normal # 執行所有測試
- name: Publish # 發佈應用程式
run: dotnet publish -c Release -r linux-arm64 --self-contained -o ./publish # 發佈為 ARM64 Linux
- name: Upload artifact # 上傳建置結果
uses: actions/upload-artifact@v4 # 使用上傳 Action
with: # 設定參數
name: pos-app # 產出物名稱
path: ./publish # 要上傳的路徑
# === 階段 2:部署 === // Deploy to Pi
deploy: # 部署工作
needs: build-and-test # 依賴建置工作 // 建置成功才部署
runs-on: ubuntu-latest # 執行環境
if: github.ref == 'refs/heads/main' # 只在 main 分支部署 // PR 不部署
steps: # 步驟清單
- name: Download artifact # 下載建置結果
uses: actions/download-artifact@v4 # 使用下載 Action
with: # 設定參數
name: pos-app # 產出物名稱
path: ./publish # 下載到的路徑
- name: Deploy to Raspberry Pi # 部署到 Pi
uses: appleboy/scp-action@v0.1.7 # 使用 SCP Action // 透過 SSH 複製檔案
with: # 設定參數
host: ${{ secrets.PI_HOST }} # Pi 的 IP // 從 GitHub Secrets 取得
username: ${{ secrets.PI_USER }} # SSH 帳號 // 從 Secrets 取得
key: ${{ secrets.PI_SSH_KEY }} # SSH 私鑰 // 從 Secrets 取得
source: './publish/*' # 來源檔案 // 建置結果
target: '/opt/pos-app' # 目標路徑 // Pi 上的安裝目錄
- name: Restart POS Service # 重啟 POS 服務
uses: appleboy/ssh-action@v1.0.3 # 使用 SSH Action // 遠端執行指令
with: # 設定參數
host: ${{ secrets.PI_HOST }} # Pi 的 IP
username: ${{ secrets.PI_USER }} # SSH 帳號
key: ${{ secrets.PI_SSH_KEY }} # SSH 私鑰
script: | # 要執行的腳本
sudo systemctl restart pos-app # 重啟 POS 服務 // 使用 systemd
sudo systemctl status pos-app # 檢查服務狀態 // 確認正常運作
用 C# 描述 CI/CD Pipeline
// 定義 CI/CD Pipeline 階段的列舉 // 每個階段代表一個步驟
public enum PipelineStage // Pipeline 階段列舉
{
Checkout, // 取出程式碼 // 從 Git 倉庫下載
Restore, // 還原套件 // NuGet restore
Build, // 建置專案 // 編譯程式碼
Test, // 執行測試 // 跑單元測試
Publish, // 發佈應用 // 打包成可部署的檔案
Deploy, // 部署到設備 // SCP 到 Pi
Restart // 重啟服務 // 讓新版本生效
}
// 定義 Pipeline 執行結果的類別 // 記錄每個階段的結果
public class PipelineResult // Pipeline 結果類別
{
public PipelineStage Stage { get; set; } // 階段名稱 // 哪個步驟
public bool Success { get; set; } // 是否成功 // true/false
public string Message { get; set; } = ""; // 訊息 // 成功或錯誤描述
public TimeSpan Duration { get; set; } // 執行時間 // 花了多久
}
// 定義 Pipeline 執行器的類別 // 模擬 CI/CD 流程
public class PipelineRunner // Pipeline 執行器
{
private readonly List<PipelineResult> _results = new(); // 結果清單 // 記錄所有階段
// 執行完整 Pipeline 的方法 // 依序執行每個階段
public async Task<bool> RunPipeline() // 執行 Pipeline 方法
{
var stages = Enum.GetValues<PipelineStage>(); // 取得所有階段 // 列舉值
foreach (var stage in stages) // 逐一執行每個階段
{
Console.WriteLine($"▶ 執行階段:{stage}..."); // 顯示目前階段
var startTime = DateTime.Now; // 記錄開始時間
var success = await ExecuteStage(stage); // 執行該階段 // 回傳是否成功
var result = new PipelineResult // 建立結果物件
{
Stage = stage, // 設定階段
Success = success, // 設定結果
Duration = DateTime.Now - startTime, // 計算執行時間
Message = success ? "完成" : "失敗" // 設定訊息
}; // 結果物件建立完成
_results.Add(result); // 加入結果清單
Console.WriteLine($" {(success ? "✅" : "❌")} {stage}: {result.Duration.TotalSeconds:F1}s"); // 顯示結果
if (!success) // 如果失敗
{
Console.WriteLine($"❌ Pipeline 在 {stage} 階段失敗!"); // 顯示失敗訊息
return false; // 回傳失敗 // 停止後續階段
}
}
Console.WriteLine("✅ Pipeline 完成!所有階段成功"); // 全部完成
return true; // 回傳成功
}
// 執行單一階段的方法 // 模擬各階段操作
private async Task<bool> ExecuteStage(PipelineStage stage) // 執行階段方法
{
await Task.Delay(100); // 模擬執行時間 // 實際上會花更久
return true; // 回傳成功 // 模擬用
}
}
自動 Build → Test → Deploy 到 Server
// 定義部署設定的類別 // 描述部署到 Pi 的參數
public class DeploymentConfig // 部署設定類別
{
public string ServerHost { get; set; } = ""; // 伺服器 IP // Pi 的位址
public int ServerPort { get; set; } = 22; // SSH 埠號 // 預設 22
public string Username { get; set; } = ""; // SSH 帳號 // 登入用
public string SshKeyPath { get; set; } = ""; // SSH 金鑰路徑 // 免密碼登入用
public string RemotePath { get; set; } = "/opt/pos-app"; // 遠端安裝路徑 // Pi 上的目錄
public string ServiceName { get; set; } = "pos-app"; // 服務名稱 // systemd 服務名
public string BuildConfig { get; set; } = "Release"; // 建置組態 // Release 模式
public string RuntimeId { get; set; } = "linux-arm64"; // 目標平台 // ARM64 Linux
}
// 定義自動部署器的類別 // 執行自動化部署
public class AutoDeployer // 自動部署器
{
private readonly DeploymentConfig _config; // 部署設定 // 建構時傳入
public AutoDeployer(DeploymentConfig config) // 建構函式 // 接收設定
{
_config = config; // 儲存設定 // 後續步驟會用到
}
// 執行完整部署流程的方法 // Build → Test → Deploy
public void Deploy() // 部署方法
{
Console.WriteLine("=== 開始自動部署流程 ==="); // 顯示開始
// 步驟 1:建置 // dotnet publish
Console.WriteLine("[1/4] 建置應用程式..."); // 顯示步驟
Console.WriteLine($" dotnet publish -c {_config.BuildConfig} -r {_config.RuntimeId}"); // 顯示指令
// 步驟 2:測試 // dotnet test
Console.WriteLine("[2/4] 執行測試..."); // 顯示步驟
Console.WriteLine(" dotnet test --configuration Release"); // 顯示指令
// 步驟 3:部署 // SCP 檔案到 Pi
Console.WriteLine("[3/4] 部署到 Raspberry Pi..."); // 顯示步驟
Console.WriteLine($" scp -r ./publish/* {_config.Username}@{_config.ServerHost}:{_config.RemotePath}"); // SCP 指令
// 步驟 4:重啟 // 重啟 systemd 服務
Console.WriteLine("[4/4] 重啟 POS 服務..."); // 顯示步驟
Console.WriteLine($" ssh {_config.Username}@{_config.ServerHost} 'sudo systemctl restart {_config.ServiceName}'"); // SSH 重啟
Console.WriteLine("=== 部署完成!==="); // 顯示完成
}
}
Raspberry Pi 自動更新機制(Pull 或 Webhook)
💡 比喻:快遞到府 vs. 自己去取件 Pull 模式就像你每天去郵局問「有我的包裹嗎?」(定時檢查)。 Webhook 模式就像快遞員直接送到你家門口(伺服器主動通知)。 兩種都可以,但 Webhook 更即時。
// 定義更新機制的列舉 // 兩種更新方式
public enum UpdateMode // 更新模式列舉
{
Pull, // 拉取模式 // Pi 定時去問有沒有新版
Webhook // 推送模式 // 伺服器有新版主動通知 Pi
}
// 定義 Pull 模式更新器的類別 // 定時檢查更新
public class PullUpdater // Pull 更新器
{
private readonly string _updateUrl; // 更新檢查 URL // 伺服器的版本檢查端點
private readonly TimeSpan _checkInterval; // 檢查間隔 // 多久檢查一次
public PullUpdater(string updateUrl, TimeSpan interval) // 建構函式
{
_updateUrl = updateUrl; // 設定更新 URL
_checkInterval = interval; // 設定檢查間隔
}
// 開始定時檢查的方法 // 背景執行
public async Task StartChecking() // 開始檢查方法
{
while (true) // 無限迴圈 // 持續檢查
{
Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] 檢查更新..."); // 顯示檢查訊息
try // 嘗試檢查更新
{
var currentVersion = GetCurrentVersion(); // 取得目前版本 // 本地版本號
Console.WriteLine($" 目前版本:{currentVersion}"); // 顯示目前版本
// 模擬呼叫 API 檢查最新版本 // 實際上要用 HttpClient
var latestVersion = "1.0.1"; // 模擬最新版本 // 從伺服器取得
Console.WriteLine($" 最新版本:{latestVersion}"); // 顯示最新版本
if (currentVersion != latestVersion) // 如果版本不同
{
Console.WriteLine(" 🔄 發現新版本,開始更新..."); // 開始更新
await DownloadAndApplyUpdate(latestVersion); // 下載並套用更新
}
else // 版本相同
{
Console.WriteLine(" ✅ 已是最新版本"); // 不需要更新
}
}
catch (Exception ex) // 檢查失敗
{
Console.WriteLine($" ❌ 檢查更新失敗:{ex.Message}"); // 顯示錯誤
}
await Task.Delay(_checkInterval); // 等待下次檢查 // 按照設定的間隔
}
}
private string GetCurrentVersion() => "1.0.0"; // 取得目前版本 // 簡化示範
private async Task DownloadAndApplyUpdate(string version) // 下載並套用更新方法
{
Console.WriteLine($" 下載版本 {version}..."); // 顯示下載訊息
await Task.Delay(100); // 模擬下載 // 實際上要下載檔案
Console.WriteLine(" 更新完成,重啟服務..."); // 顯示更新完成
}
}
Docker 容器化 POS 應用
# POS 系統的 Dockerfile // 多階段建置
# === 階段 1:Build === // 用 SDK Image 編譯
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# 設定工作目錄 // 所有操作在此目錄下
WORKDIR /src
# 複製專案檔 // 利用 Docker 層快取
COPY *.csproj ./
# 還原 NuGet 套件 // 這層變動少可以快取
RUN dotnet restore
# 複製所有原始碼 // 這層每次 build 都會變
COPY . .
# 發佈為 Release 模式 // 輸出到 /app/publish
RUN dotnet publish -c Release -o /app/publish
# === 階段 2:Runtime === // 用精簡的 Runtime Image
FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim-arm64 AS runtime
# 設定工作目錄
WORKDIR /app
# 從 build 階段複製發佈結果 // 只要成品不要原始碼
COPY --from=build /app/publish .
# 設定時區 // 台灣時區
ENV TZ=Asia/Taipei
# 對外開放的埠號 // POS 系統的 Web 埠
EXPOSE 5000
# 啟動指令 // 執行 POS 應用程式
ENTRYPOINT ["dotnet", "PosApp.dll"]
Docker Compose 整合
# docker-compose.yml // 整合所有 POS 服務
version: '3.8' # Compose 版本 // 使用 3.8
services: # 服務定義 // 所有容器
pos-web: # POS Web 應用 // 主要的 POS 系統
build: . # 從當前目錄建置 // 使用 Dockerfile
ports: # 埠號對應
- "5000:5000" # 主機 5000 → 容器 5000
environment: # 環境變數
- ASPNETCORE_URLS=http://+:5000 # 監聽所有介面的 5000 埠
- ConnectionStrings__DefaultConnection=Data Source=/data/pos.db # SQLite 路徑
volumes: # 掛載資料卷
- pos-data:/data # 資料庫持久化 // 重啟不會遺失資料
restart: always # 自動重啟 // 異常時自動恢復
print-agent: # Print Agent 服務 // Node.js 列印代理
build: ./print-agent # 從 print-agent 目錄建置
ports: # 埠號對應
- "9100:9100" # WebSocket 埠
devices: # 裝置對應 // 讓容器存取 USB 印表機
- /dev/usb/lp0:/dev/usb/lp0 # USB 印表機裝置
privileged: true # 特權模式 // 存取硬體需要
restart: always # 自動重啟
volumes: # 資料卷定義
pos-data: # POS 資料卷 // 儲存 SQLite 資料庫
多台 POS 設備管理(Fleet Management)
💡 比喻:管理一群遙控汽車 如果你的店有 5 台 POS 機,每台都要更新、監控、維護。 Fleet Management 就像一個「遙控器」, 可以同時控制所有 POS 機,一次更新全部。
// 定義 POS 設備的類別 // 描述單一 POS 機的資訊
public class PosDevice // POS 設備類別
{
public string DeviceId { get; set; } = ""; // 設備 ID // 唯一識別碼
public string Hostname { get; set; } = ""; // 主機名稱 // 例如 pos-01
public string IpAddress { get; set; } = ""; // IP 位址 // 網路位址
public string Version { get; set; } = ""; // 應用版本 // 目前的版本號
public string Status { get; set; } = "offline"; // 狀態 // online/offline/updating
public DateTime LastSeen { get; set; } // 最後上線時間 // 心跳檢測
public string Location { get; set; } = ""; // 位置 // 例如「一樓櫃台」
}
// 定義設備管理器的類別 // 管理所有 POS 設備
public class FleetManager // 設備管理器
{
private readonly List<PosDevice> _devices = new(); // 設備清單 // 所有 POS 機
// 註冊新設備的方法 // POS 機上線時呼叫
public void RegisterDevice(PosDevice device) // 註冊設備方法
{
_devices.Add(device); // 加入設備清單 // 新設備加入管理
Console.WriteLine($"設備已註冊:{device.Hostname} ({device.IpAddress})"); // 顯示註冊訊息
}
// 取得所有設備狀態的方法 // 儀表板用
public void PrintDashboard() // 顯示儀表板方法
{
Console.WriteLine("╔════════════════════════════════════════════════════╗"); // 表格上框
Console.WriteLine("║ POS 設備管理儀表板 ║"); // 標題
Console.WriteLine("╠════════════════════════════════════════════════════╣"); // 分隔線
Console.WriteLine("║ 名稱 IP 版本 狀態 位置 ║"); // 欄位標題
Console.WriteLine("╠════════════════════════════════════════════════════╣"); // 分隔線
foreach (var d in _devices) // 逐一列出設備
{
var statusIcon = d.Status switch // 根據狀態顯示圖示
{
"online" => "🟢", // 上線 // 綠色圓點
"offline" => "🔴", // 離線 // 紅色圓點
"updating" => "🟡", // 更新中 // 黃色圓點
_ => "⚪" // 未知 // 白色圓點
}; // switch 結束
Console.WriteLine($"║ {d.Hostname,-10} {d.IpAddress,-15} {d.Version,-8} {statusIcon,-4} {d.Location,-6} ║"); // 設備資訊
}
Console.WriteLine("╚════════════════════════════════════════════════════╝"); // 表格下框
}
// 批次更新所有設備的方法 // 一次更新全部 POS 機
public async Task UpdateAll(string newVersion) // 批次更新方法
{
Console.WriteLine($"開始批次更新到 v{newVersion}..."); // 顯示更新開始
foreach (var device in _devices) // 逐一更新設備
{
device.Status = "updating"; // 設定狀態為更新中
Console.WriteLine($" 更新 {device.Hostname}..."); // 顯示更新進度
await Task.Delay(200); // 模擬更新過程 // 實際上是 SCP + 重啟
device.Version = newVersion; // 更新版本號
device.Status = "online"; // 恢復上線狀態
Console.WriteLine($" ✅ {device.Hostname} 更新完成"); // 顯示完成
}
Console.WriteLine($"全部 {_devices.Count} 台設備更新完成!"); // 顯示全部完成
}
}
監控與遠端維護(SSH Tunnel、VPN)
// 定義監控設定的類別 // 描述監控和遠端維護的設定
public class MonitoringConfig // 監控設定類別
{
public int HealthCheckIntervalSeconds { get; set; } = 30; // 健康檢查間隔 // 30 秒一次
public string SshTunnelHost { get; set; } = ""; // SSH Tunnel 伺服器 // 跳板機
public int SshTunnelPort { get; set; } = 22; // SSH Tunnel 埠號
public string VpnServer { get; set; } = ""; // VPN 伺服器 // 安全連線用
public bool EnableAlerts { get; set; } = true; // 是否啟用警報 // 異常時通知
}
// 定義健康檢查結果的類別 // 單次檢查的結果
public class HealthCheckResult // 健康檢查結果
{
public string DeviceId { get; set; } = ""; // 設備 ID // 哪台機器
public DateTime CheckTime { get; set; } // 檢查時間 // 什麼時候檢查的
public bool IsHealthy { get; set; } // 是否健康 // 正常/異常
public double CpuUsage { get; set; } // CPU 使用率 // 百分比
public double MemoryUsage { get; set; } // 記憶體使用率 // 百分比
public double DiskUsage { get; set; } // 硬碟使用率 // 百分比
public bool PrinterOnline { get; set; } // 印表機是否在線 // 列印服務狀態
public bool NetworkOnline { get; set; } // 網路是否在線 // 連線狀態
}
// 定義設備監控器的類別 // 持續監控所有 POS 設備
public class DeviceMonitor // 設備監控器
{
private readonly MonitoringConfig _config; // 監控設定 // 建構時傳入
public DeviceMonitor(MonitoringConfig config) // 建構函式
{
_config = config; // 儲存設定
}
// 執行健康檢查的方法 // 檢查單一設備
public HealthCheckResult CheckDevice(PosDevice device) // 檢查設備方法
{
var result = new HealthCheckResult // 建立檢查結果
{
DeviceId = device.DeviceId, // 設備 ID
CheckTime = DateTime.Now, // 檢查時間
CpuUsage = 35.2, // 模擬 CPU 使用率 // 實際要用 SSH 查詢
MemoryUsage = 62.8, // 模擬記憶體使用率
DiskUsage = 45.0, // 模擬硬碟使用率
PrinterOnline = true, // 印表機在線
NetworkOnline = true // 網路在線
}; // 結果建立完成
result.IsHealthy = result.CpuUsage < 90 // CPU 低於 90%
&& result.MemoryUsage < 90 // 記憶體低於 90%
&& result.DiskUsage < 90 // 硬碟低於 90%
&& result.PrinterOnline // 印表機在線
&& result.NetworkOnline; // 網路在線
if (!result.IsHealthy && _config.EnableAlerts) // 如果不健康且啟用警報
{
Console.WriteLine($"⚠️ 警報:{device.Hostname} 狀態異常!"); // 顯示警報
Console.WriteLine($" CPU: {result.CpuUsage}% | RAM: {result.MemoryUsage}% | Disk: {result.DiskUsage}%"); // 詳細數據
}
return result; // 回傳檢查結果
}
}
資料同步(離線→上線自動同步)
💡 比喻:出差回來整理報帳 POS 設備離線期間(網路斷了)的交易就像出差時的花費, 先用小本子記下來,等回到公司再整理報帳。 資料同步就是把「小本子上的記錄」匯入公司的系統。
// 定義同步項目的類別 // 需要同步的單筆資料
public class SyncItem // 同步項目
{
public string Id { get; set; } = Guid.NewGuid().ToString(); // 唯一 ID // UUID 格式
public string Type { get; set; } = ""; // 資料類型 // order, product, inventory
public string JsonData { get; set; } = ""; // JSON 資料 // 序列化的資料內容
public DateTime CreatedAt { get; set; } = DateTime.Now; // 建立時間 // 離線時記錄
public bool IsSynced { get; set; } = false; // 是否已同步 // 同步後設為 true
public int RetryCount { get; set; } = 0; // 重試次數 // 同步失敗累計
}
// 定義資料同步管理器的類別 // 處理離線→上線的資料同步
public class SyncManager // 同步管理器
{
private readonly List<SyncItem> _pendingItems = new(); // 待同步清單 // 離線期間累積的資料
private readonly string _serverUrl; // 伺服器 URL // 總部伺服器的位址
public SyncManager(string serverUrl) // 建構函式 // 接收伺服器位址
{
_serverUrl = serverUrl; // 儲存伺服器 URL
}
// 加入待同步項目的方法 // 離線時呼叫
public void AddPending(string type, string jsonData) // 加入待同步方法
{
var item = new SyncItem // 建立同步項目
{
Type = type, // 設定資料類型
JsonData = jsonData // 設定 JSON 資料
}; // 項目建立完成
_pendingItems.Add(item); // 加入待同步清單
Console.WriteLine($"新增待同步項目:{type}(共 {_pendingItems.Count} 筆待同步)"); // 顯示訊息
}
// 執行同步的方法 // 網路恢復時呼叫
public async Task SyncAll() // 同步全部方法
{
var unsyncedItems = _pendingItems // 篩選未同步的項目
.Where(i => !i.IsSynced) // 只處理未同步的
.OrderBy(i => i.CreatedAt) // 按建立時間排序 // 先進先出
.ToList(); // 轉為清單
if (unsyncedItems.Count == 0) // 沒有待同步的
{
Console.WriteLine("沒有待同步的項目"); // 顯示訊息
return; // 結束
}
Console.WriteLine($"開始同步 {unsyncedItems.Count} 筆資料..."); // 顯示開始
var successCount = 0; // 成功計數 // 統計用
var failCount = 0; // 失敗計數 // 統計用
foreach (var item in unsyncedItems) // 逐筆同步
{
try // 嘗試同步
{
Console.WriteLine($" 同步 {item.Type} ({item.Id[..8]}...)"); // 顯示同步項目
await Task.Delay(50); // 模擬 API 呼叫 // 實際要用 HttpClient POST
item.IsSynced = true; // 標記為已同步
successCount++; // 成功計數加 1
}
catch (Exception ex) // 同步失敗
{
item.RetryCount++; // 重試次數加 1
failCount++; // 失敗計數加 1
Console.WriteLine($" ❌ 同步失敗(第 {item.RetryCount} 次):{ex.Message}"); // 顯示錯誤
if (item.RetryCount >= 5) // 重試超過 5 次
{
Console.WriteLine($" ⚠️ 項目 {item.Id[..8]} 已超過重試上限,標記為需人工處理"); // 需要人工介入
}
}
}
Console.WriteLine($"同步完成!成功 {successCount} 筆,失敗 {failCount} 筆"); // 顯示結果
}
}
安全考量(HTTPS、防火牆、VPN)
// 定義安全設定的類別 // POS 系統的安全相關設定
public class SecurityConfig // 安全設定類別
{
public bool EnableHttps { get; set; } = true; // 啟用 HTTPS // 加密傳輸
public bool EnableFirewall { get; set; } = true; // 啟用防火牆 // 限制網路存取
public bool EnableVpn { get; set; } = false; // 啟用 VPN // 遠端管理用
public int SessionTimeoutMinutes { get; set; } = 30; // 工作階段逾時 // 閒置自動登出
public List<string> AllowedPorts { get; set; } = new() // 允許的埠號 // 防火牆規則
{
"22", // SSH // 遠端管理
"443", // HTTPS // Web 加密連線
"5000", // POS Web // 應用程式埠
"9100" // Print Agent // 列印服務埠
}; // 允許的埠號清單
}
// 定義安全檢查器的類別 // 檢查 POS 系統的安全狀態
public class SecurityAuditor // 安全檢查器
{
// 執行安全稽核的方法 // 檢查所有安全設定
public List<string> Audit(SecurityConfig config) // 安全稽核方法
{
var findings = new List<string>(); // 發現清單 // 收集所有問題
if (!config.EnableHttps) // 如果沒啟用 HTTPS
findings.Add("⚠️ 高風險:未啟用 HTTPS,交易資料可能被攔截"); // 加入警告
if (!config.EnableFirewall) // 如果沒啟用防火牆
findings.Add("⚠️ 高風險:未啟用防火牆,POS 設備暴露在網路中"); // 加入警告
if (config.AllowedPorts.Contains("80")) // 如果允許 HTTP(不安全)
findings.Add("⚠️ 中風險:開放了 HTTP port 80,建議只用 HTTPS"); // 加入警告
if (config.SessionTimeoutMinutes > 60) // 如果工作階段逾時太長
findings.Add("⚠️ 低風險:工作階段逾時設定過長,建議 30 分鐘內"); // 加入警告
if (findings.Count == 0) // 沒有發現問題
findings.Add("✅ 所有安全設定都正常"); // 加入通過訊息
return findings; // 回傳稽核結果
}
}
實際部署案例分析
POS 系統實際部署案例:小型咖啡廳
📍 店家規模:
- 1 間店面,2 台 POS 設備
- 1 台熱感應印表機
- 1 個錢箱
- 觸控螢幕
🔧 硬體配置:
┌─────────────────────────────────────────────┐
│ POS 機 1(前台) │
│ ├── Raspberry Pi 4 (4GB) │
│ ├── 10 吋觸控螢幕 │
│ ├── 熱感應印表機(USB) │
│ ├── 錢箱(RJ-11 接印表機) │
│ └── 條碼掃描器(USB) │
│ │
│ POS 機 2(外帶區) │
│ ├── Raspberry Pi 4 (4GB) │
│ ├── 7 吋觸控螢幕 │
│ └── 條碼掃描器(USB) │
│ │
│ 伺服器(後台管理) │
│ ├── 雲端 VPS 或本地 NAS │
│ ├── 資料庫(PostgreSQL) │
│ └── 管理後台(報表、庫存) │
└─────────────────────────────────────────────┘
💰 成本估算:
Pi 4 (4GB) x2 = NT$ 4,000
觸控螢幕 x2 = NT$ 6,000
熱感應印表機 x1 = NT$ 3,000
錢箱 x1 = NT$ 2,000
條碼掃描器 x2 = NT$ 2,000
SD 卡 x2 = NT$ 600
─────────────────────────────
總計 ≈ NT$ 17,600
vs. 市售 POS 系統 ≈ NT$ 50,000+(還不含月租費)
省下超過 60% 的成本!
用 C# 模擬部署規劃
// 定義部署規劃的類別 // 描述完整的部署方案
public class DeploymentPlan // 部署規劃類別
{
public string StoreName { get; set; } = ""; // 店家名稱 // 部署的對象
public int PosCount { get; set; } // POS 數量 // 幾台收銀機
public List<PosDevice> Devices { get; set; } = new(); // 設備清單 // 所有 POS 機
public decimal EstimatedCost { get; set; } // 預估成本 // 硬體+軟體
// 產生部署報告的方法 // 給店家看的評估報告
public void GenerateReport() // 產生報告方法
{
Console.WriteLine("╔══════════════════════════════════════╗"); // 報告框線
Console.WriteLine($"║ POS 系統部署規劃報告 ║"); // 報告標題
Console.WriteLine($"║ 店家:{StoreName,-28}║"); // 店家名稱
Console.WriteLine("╠══════════════════════════════════════╣"); // 分隔線
Console.WriteLine($"║ POS 設備數量:{PosCount} 台 ║"); // POS 數量
Console.WriteLine($"║ 預估總成本:NT$ {EstimatedCost:N0} ║"); // 預估成本
foreach (var device in Devices) // 逐一列出設備
{
Console.WriteLine($"║ {device.Hostname}: {device.Location,-20}║"); // 設備名稱和位置
}
Console.WriteLine("╚══════════════════════════════════════╝"); // 報告底框
}
}
🤔 我這樣寫為什麼會錯?
❌ 錯誤 1:把密碼和金鑰寫死在程式碼裡
// ❌ 錯誤:密碼和金鑰寫死在原始碼 // 推上 GitHub 全世界都看得到
var config = new DeploymentConfig // 部署設定
{
ServerHost = "192.168.1.100", // Pi 的 IP
Username = "admin", // ❌ 帳號寫在程式碼裡
SshKeyPath = "/home/user/.ssh/id_rsa" // ❌ 金鑰路徑寫在程式碼裡
}; // 這些資訊推上 Git 就洩漏了
// ✅ 正確:使用環境變數或 Secrets // 永遠不把敏感資訊放在程式碼裡
var secureConfig = new DeploymentConfig // 安全的部署設定
{
ServerHost = Environment.GetEnvironmentVariable("PI_HOST") ?? "", // 從環境變數讀取 // 不會進版控
Username = Environment.GetEnvironmentVariable("PI_USER") ?? "", // 從環境變數讀取
SshKeyPath = Environment.GetEnvironmentVariable("PI_SSH_KEY") ?? "" // 從環境變數讀取
}; // 敏感資訊不會出現在 Git 歷史中
❌ 錯誤 2:Docker 容器直接用 root 執行
# ❌ 錯誤:用 root 身份執行應用程式 // 安全隱患
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
# 沒有切換使用者 // 預設就是 root // 如果應用程式被入侵,攻擊者就有 root 權限
ENTRYPOINT ["dotnet", "PosApp.dll"]
# ✅ 正確:建立專用使用者 // 最小權限原則
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
# 建立非 root 使用者 // 專用的應用程式帳號
RUN adduser --disabled-password --gecos '' appuser
# 切換到非 root 使用者 // 降低安全風險
USER appuser
ENTRYPOINT ["dotnet", "PosApp.dll"]
❌ 錯誤 3:同步時沒有處理衝突
// ❌ 錯誤:同步時直接覆蓋 // 離線期間的修改會被蓋掉
public async Task SyncNaive(string jsonData) // 天真的同步方法
{
// 直接 POST 到伺服器 // 不管伺服器上的資料有沒有被別人改過
Console.WriteLine("直接覆蓋伺服器資料..."); // 暴力覆蓋 // 可能遺失其他 POS 機的交易
}
// ✅ 正確:使用時間戳和衝突偵測 // 確保不會遺失資料
public async Task SyncWithConflictCheck(SyncItem item) // 有衝突檢查的同步方法
{
Console.WriteLine($"檢查衝突:{item.Type} ({item.Id[..8]})"); // 顯示檢查訊息
// 1. 先查詢伺服器上的最新版本 // 比較時間戳
Console.WriteLine(" 查詢伺服器版本..."); // 取得伺服器資料
// 2. 如果伺服器版本比本地舊,直接同步 // 本地是最新的
// 3. 如果伺服器版本比本地新,需要合併 // 有人在我離線時改了資料
// 4. 對於訂單資料,通常用「追加」而非「覆蓋」// 新訂單直接加入
Console.WriteLine(" 使用追加模式同步訂單資料"); // 訂單只增不改 // 最安全的方式
item.IsSynced = true; // 標記為已同步
Console.WriteLine(" ✅ 同步完成(無衝突)"); // 顯示完成
}