小小商家一點靈的後端使用 Gin 框架與 LINE Bot SDK 製作,主要負責以下功能:
提供前端:充當前端 LIFF App 的伺服器。
轉送資料:將 Google 試算表中的資料轉送到 LIFF App 的點餐和訂單頁面,並將新訂單送進 Google 試算表。
計算顧客價值指標:使用 Insyra 計算每位顧客的 RFM 和 CAI。
推播訊息:推播 LINE 訊息給顧客。
Gin 框架
Gin 是 Go 語言生態系中最受歡迎的 Web 框架之一,自 2014 年發布以來就以其卓越的性能和簡潔的 API 設計受到開發者青睞。
為什麼選擇 Gin?
高性能
Gin 使用 httprouter 作為路由引擎,這是一個基於 Radix Tree(基數樹)實現的高效路由器。相較於其他框架,Gin 的路由查找速度極快,即使有成千上萬個路由,性能損耗也微乎其微。根據官方的基準測試,Gin 的性能比許多其他 Go Web 框架快 40 倍以上。
簡潔易用
Gin 的 API 設計非常直觀,學習曲線平緩。它提供了豐富的功能,但又不會讓人感到複雜。例如處理 JSON 請求只需要一行 c.ShouldBindJSON(),回傳 JSON 回應也只需要 c.JSON()。
中間件支援
Gin 內建完善的中間件機制,可以輕鬆加入認證、日誌、CORS、錯誤處理等功能。中間件可以串接使用,靈活組合,而且執行順序清晰明確。
豐富的功能
參數綁定:自動將 JSON、表單、URL 參數綁定到結構體。
參數驗證:內建驗證器,可以驗證必填欄位、格式等。
路由分組:方便組織和管理大量路由。
檔案上傳:簡化檔案處理流程。
自訂渲染:支援 HTML、JSON、XML 等多種格式。
活躍的社群
Gin 在 GitHub 上有超過 8 萬顆星,是 Go 語言最受歡迎的專案之一。這意味著:
文件完整且持續更新
遇到問題容易找到解決方案
有大量的第三方中間件和擴充套件可用
長期維護有保障
Gin 的核心概念
Context(上下文)
在 Gin 中,每個 HTTP 請求都會創建一個 gin.Context 物件。這個物件貫穿整個請求處理流程,包含了請求的所有資訊,也提供了回應的所有方法。例如:
func handler(c *gin.Context) {
// 取得請求參數
name := c.Query("name") // URL 參數
id := c.Param("id") // 路徑參數
// 解析 JSON
var data MyStruct
c.ShouldBindJSON(&data)
// 回傳回應
c.JSON(200, gin.H{"message": "success"})
}路由與路由群組
Gin 支援 RESTful 風格的路由設計,並可以用群組來組織相關路由:
r := gin.Default()
// 單一路由
r.GET("/ping", pingHandler)
// 路由群組
api := r.Group("/api")
{
api.GET("/users", getUsers)
api.POST("/users", createUser)
}中間件鏈
中間件會按照註冊順序執行,可以在請求到達 Handler 之前或之後進行處理:
r.Use(LoggerMiddleware()) // 第一個執行
r.Use(AuthMiddleware()) // 第二個執行
r.GET("/data", dataHandler) // 最後執行我為什麼選擇 Gin
對於小小商家一點靈這個專案,Gin 是最適合的選擇:
1. 效能需求:計算 RFM/CAI 需要處理大量資料,Gin 的高效能確保了快速回應。
2. 開發效率:專案時程有限,Gin 的簡潔 API 讓我能快速開發。
3. 中間件需求:需要實作 Bearer Token 認證,Gin 的中間件機制非常方便。
4. JSON 處理:與 Google Apps Script 和前端的溝通都使用 JSON,Gin 的 JSON 支援完善。
5. 靜態檔案服務:需要提供 LIFF App 的靜態檔案,Gin 內建這個功能。
相較於其他框架(如 Echo、Fiber),Gin 的生態系更成熟、文件更完整,對新手更友善,AI 也比較會使用(這才是重點😂)。
專案架構
小小商家一點靈的後端架構如下:
backend/
├── main.go # 程式入口
├── internal/
│ ├── config/ # 讀取設定檔(環境變數、密鑰等)
│ │ └── config.go
│ ├── handlers/ # 處理 HTTP 請求
│ │ ├── handler.go # 主要 Handler 結構
│ │ ├── data.go # 資料相關的 API
│ │ ├── calculate.go # RFM/CAI 計算
│ │ └── push.go # LINE 訊息推播
│ ├── services/ # 業務邏輯層
│ │ └── line.go # LINE Bot 相關服務
│ ├── models/ # 資料模型
│ │ ├── product.go
│ │ ├── category.go
│ │ ├── customer.go
│ │ └── order.go
│ ├── google_sheet/ # Google 試算表操作
│ │ ├── get.go # 讀取資料
│ │ └── post.go # 寫入資料
│ ├── middleware/ # 中間件
│ │ └── bearer_auth.go # Bearer Token 認證
│ └── routes/ # 路由設定
│ └── routes.go這種架構遵循分層設計的原則:
Handlers:處理 HTTP 請求和回應,負責參數驗證和格式轉換
Services:封裝業務邏輯,處理複雜的操作流程
Models:定義資料結構
Google Sheet:專門處理與 Google 試算表的互動
這樣的好處是每一層各司其職,程式碼清晰且容易維護。
主程式入口:main.go
package main
import (
"log"
"net/http"
"lineliteshop1.0/internal/config"
"lineliteshop1.0/internal/handlers"
"lineliteshop1.0/internal/routes"
"github.com/gin-gonic/gin"
"github.com/line/line-bot-sdk-go/v8/linebot/messaging_api"
)
func main() {
// 初始化 LINE Bot Messaging API 客戶端
client := &http.Client{}
bot, err := messaging_api.NewMessagingApiAPI(
config.LINE_CHANNEL_TOKEN,
messaging_api.WithHTTPClient(client),
)
if err != nil {
log.Fatal(err)
}
// 初始化處理器(內部會自動初始化服務層)
handler := handlers.NewHandler(bot)
// 初始化 Gin 路由器(使用預設中間件)
r := gin.Default()
// 設定路由
routes.SetupRoutes(r, handler)
// 啟動 Gin 伺服器
log.Println("伺服器啟動於 :10100 端口")
log.Fatal(r.Run(":10100"))
}主程式首先:
初始化 LINE Bot API 客戶端。
建立 Handler(處理器)。
建立 Gin 路由器。
設定所有路由。
啟動伺服器。
gin.Default() 會自動加入 Logger 和 Recovery 中間件,前者記錄每個請求,後者在程式出錯時避免整個伺服器崩潰。
路由設定:routes.go
接著我在 internal/routes/routes.go 中定義所有路由。
func SetupRoutes(r *gin.Engine, handler *handlers.Handler) {
// 健康檢查端點
r.GET("/", handler.HealthCheck)
apiGroup := r.Group("/api")
{
// LINE Bot webhook 回調端點
apiGroup.POST("/callback", handler.HandleWebhook)
// Rich Menu 控制端點
apiGroup.POST("/richmenu/switch/:userId/:richMenuId", handler.SwitchUserRichMenu)
apiDataGroup := apiGroup.Group("/data")
{
// 分類相關
apiDataGroup.GET("/category/:name", handler.HandleGetCategory)
apiDataGroup.GET("/categories", handler.HandleGetCategories)
// 商品相關
apiDataGroup.GET("/products", handler.HandleGetProducts)
// 顧客相關
apiDataGroup.GET("/customer/:id", handler.HandleGetCustomer)
apiDataGroup.GET("/customers", handler.HandleGetCustomers)
apiDataGroup.POST("/customer", handler.HandleCustomerRegister)
apiDataGroup.PUT("/customer", handler.HandleUpdateCustomer)
// 訂單相關
apiDataGroup.GET("/order/:id", handler.HandleGetOrder)
apiDataGroup.GET("/orders", handler.HandleGetOrders)
apiDataGroup.POST("/order", handler.HandlePostOrder)
apiDataGroup.PUT("/order", handler.HandleUpdateOrder)
}
// 需要認證的端點
apiGroup.POST("/calculate/:type",
middleware.BearerAuthMiddleware(config.GOOGLE_SHEET_API_TOKEN),
handler.HandleCalculate)
apiGroup.POST("/line/message",
middleware.BearerAuthMiddleware(config.GOOGLE_SHEET_API_TOKEN),
handler.HandlePushMessage)
}
// LIFF 前端應用服務
r.Static("/liff/assets", "./liff/dist/assets")
r.StaticFile("/liff/vite.svg", "./liff/dist/vite.svg")
r.GET("/liff", handler.ServeLIFF)
r.NoRoute(handler.ServeLIFF)
}Gin 的路由設定非常簡單。用 Group 可以把相關的路由組織在一起,並統一加上前綴。例如我把所有資料相關的 API 都放在 /api/data/ 底下。
關於認證:計算 RFM/CAI 和推播訊息這兩個功能比較敏感,我不希望任何人都能透過這個 API 白嫖我的伺服器算力,或是利用我的官方帳號推播訊息,所以加上了 Bearer Token 認證中間件。只有帶著正確 Token 的請求才能通過。這個 Token 會在 Google 試算表中定義,使用 Google Apps Script 讀取並包含在請求裡。
關於 LIFF 服務:後端不只提供 API,也負責提供 LIFF App 的靜態檔案。r.NoRoute(handler.ServeLIFF) 這行很重要,它讓所有找不到的路由都導向 index.html,讓前端處理路由。這是前端使用 SPA(Single Page Application)時必須的設定。
請求處理流程
當一個 HTTP 請求進入後端時,會經過以下處理流程:
請求 → Gin 路由器 → 中間件(可選)→ Handler → 回應我們用一個實際的例子來說明。假設 Google Apps Script 要呼叫計算 RFM 的 API:
1. 請求進入
POST /api/calculate/rfm
Headers:
Authorization: Bearer abc123token
Content-Type: application/json
Body:
{
"data": [[...]],
"config": {...}
}2. Gin 路由器匹配
Gin 收到請求後,會根據 HTTP 方法(POST)和路徑(/api/calculate/rfm)來找對應的路由。
在 routes.go 中,這個請求會匹配到:
apiGroup.POST("/calculate/:type",
middleware.BearerAuthMiddleware(config.GOOGLE_SHEET_API_TOKEN),
handler.HandleCalculate)這裡的 :type 是路徑參數,會被解析成 rfm。
3. 執行中間件
在執行 Handler 之前,請求會先經過 BearerAuthMiddleware 中間件。
func BearerAuthMiddleware(token string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
// 檢查是否有 Authorization header
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authorization header"})
c.Abort() // 終止請求,不會執行後面的 Handler
return
}
// 驗證 token 格式和內容
if len(authHeader) < 7 || authHeader[:7] != "Bearer " {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
providedToken := authHeader[7:]
if providedToken != token {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Next() // token 正確,繼續執行下一個處理器
}
}如果 token 驗證失敗c.Abort() 會終止請求,直接回應錯誤,不會執行後面的 Handler。如果驗證成功c.Next() 會讓請求繼續往下走。
4. 執行 Handler
通過中間件後,請求進入 handler.HandleCalculate:
func (h Handler) HandleCalculate(c gin.Context) {
// 1. 取得路徑參數
t := c.Param("type") // 取得 "rfm"
// 2. 解析 JSON 請求體
var jsonData map[string]any
if err := c.ShouldBindJSON(&jsonData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 3. 資料轉換和處理
data := jsonData["data"].([]any)
// ... 轉換成 DataTable
// 4. 根據 type 執行對應的計算
switch t {
case "rfm":
// 呼叫 Insyra 計算 RFM
result := mkt.RFM(dataTable, config)
// 5. 回傳結果
c.JSON(http.StatusOK, gin.H{
"RFM": result.ColNamesToFirstRow().To2DSlice(),
})
}
}Handler 的工作包括:
取得路徑參數(
:type)。解析請求體(JSON)。
呼叫業務邏輯(計算 RFM)。
格式化並回傳結果。
5. 回應
最後,Gin 會把 Handler 產生的回應送回給客戶端:
{
"RFM": [
["CustomerID", "R_Score", "F_Score", "M_Score", "RFM_Score"],
["A001", 5, 4, 5, 14],
["A002", 3, 2, 3, 8],
...
]
}中間件的作用
中間件就像是請求處理流程中的「檢查站」,可以在請求到達 Handler 之前或之後做一些事情。常見的用途包括:
認證與授權:檢查使用者是否有權限存取這個 API。
日誌記錄:記錄每個請求的資訊(
gin.Default()自動包含)。錯誤處理:捕捉錯誤避免伺服器崩潰(
gin.Default()自動包含)CORS 設定:處理跨域請求。
限流:防止 API 被濫用。
中間件可以串接多個,它們會按照設定的順序依次執行。例如:
apiGroup.POST("/calculate/:type",
middleware.LoggerMiddleware(), // 第一個中間件
middleware.BearerAuthMiddleware(), // 第二個中間件
handler.HandleCalculate) // 最後才是 Handler請求會先經過 Logger,再經過 Auth,最後才到 Handler。
資料 API:與 Google 試算表互動
讀取商品資料
這個 handler 負責把 Google 試算表中的商品資料傳給前端 LIFF App。
func (h *Handler) HandleGetProducts(c *gin.Context) {
// 呼叫 Google Sheet API 獲取商品資料
products, err := google_sheet.GetProducts()
if err != nil {
log.Printf("獲取商品資料失敗: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get products"})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Products retrieved successfully",
"data": products,
})
}Handler 的職責很單純:接收請求、呼叫對應的函數、回傳結果。真正與 Google 試算表互動的邏輯都封裝在 google_sheet 套件裡。
新增訂單
這個 handler 則是把前端傳來的訂單送進 Google 試算表。
func (h *Handler) HandlePostOrder(c *gin.Context) {
var order models.Order
if err := c.ShouldBindJSON(&order); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 將訂單寫入 Google Sheet
err := google_sheet.PostOrder(order)
if err != nil {
log.Printf("新增訂單失敗: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create order"})
return
}
c.JSON(http.StatusCreated, gin.H{
"status": "success",
"message": "Order created successfully",
})
}c.ShouldBindJSON(&order) 會自動把 JSON 請求體解析成 Order 結構,非常方便。
RFM/CAI 計算 API
這是後端最核心的功能之一,負責呼叫 Insyra 來計算顧客價值指標。
func (h *Handler) HandleCalculate(c *gin.Context) {
t := c.Param("type") // rfm、cai 或 all
var jsonData map[string]any
if err := c.ShouldBindJSON(&jsonData); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 將 JSON 資料轉換成 Insyra 的 DataTable
data := jsonData["data"].([]any)
var data2d [][]any
for _, row := range data {
data2d = append(data2d, row.([]any))
}
dataTable, err := insyra.Slice2DToDataTable(data2d)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
dataTable.SetRowToColNames(0) // 將第一行設為欄位名稱
config := jsonData["config"].(map[string]any)
switch t {
case "rfm":
c.JSON(http.StatusOK, gin.H{
"RFM": mkt.RFM(dataTable, mkt.RFMConfig{
CustomerIDColName: config["customerIDColName"].(string),
TradingDayColName: config["tradingDayColName"].(string),
AmountColName: config["amountColName"].(string),
TimeScale: mkt.TimeScaleDaily,
DateFormat: "yyyy/MM/dd HH:mm:ss",
NumGroups: 2,
}).ColNamesToFirstRow().To2DSlice(),
})
case "cai":
c.JSON(http.StatusOK, gin.H{
"CAI": mkt.CAI(dataTable, mkt.CAIConfig{
CustomerIDColName: config["customerIDColName"].(string),
TradingDayColName: config["tradingDayColName"].(string),
TimeScale: mkt.TimeScaleDaily,
DateFormat: "yyyy/MM/dd HH:mm:ss",
}).ColNamesToFirstRow().To2DSlice(),
})
case "all":
// 平行計算 RFM 和 CAI
var rfmResult, caiResult [][]any
calcRFM := func() {
rfmResult = mkt.RFM(dataTable, mkt.RFMConfig{
CustomerIDColName: config["customerIDColName"].(string),
TradingDayColName: config["tradingDayColName"].(string),
AmountColName: config["amountColName"].(string),
TimeScale: mkt.TimeScaleDaily,
DateFormat: "yyyy/MM/dd HH:mm:ss",
NumGroups: 2,
}).ColNamesToFirstRow().To2DSlice()
}
calcCAI := func() {
caiResult = mkt.CAI(dataTable, mkt.CAIConfig{
CustomerIDColName: config["customerIDColName"].(string),
TradingDayColName: config["tradingDayColName"].(string),
TimeScale: mkt.TimeScaleDaily,
DateFormat: "yyyy/MM/dd HH:mm:ss",
}).ColNamesToFirstRow().To2DSlice()
}
parallel.GroupUp(calcRFM, calcCAI).Run().AwaitNoResult()
c.JSON(http.StatusOK, gin.H{
"RFM": rfmResult,
"CAI": caiResult,
})
}
}這個 API 是設計給 Google Apps Script 呼叫的,Google 試算表透過 Apps Script 把資料送來,後端計算完再把結果送回去。
這段程式把 Google Apps Script 傳過來的交易資料轉成 Insyra 的 DataTable,計算完 RFM 和 CAI 之後,再把計算結果的 DataTable 轉成切片,使用 JSON 格式回傳。
它可以單獨計算 RFM 或 CAI,也可以同時平行計算兩者。為了節省時間,減少 Google Apps Script 觸發器的運行時數(有每日上限限制),Google Apps Script 目前實際上是呼叫平行化的計算模式。
LINE Bot Webhook
這是保留給未來如果要讓程式自動回覆 LINE 訊息所需的程式,目前並沒有任何作用。
當使用者在 LINE 跟官方帳號互動時,LINE 伺服器會發送 Webhook 請求到我們的後端,由這邊的程式處理並回覆使用者。
func (h *Handler) HandleWebhook(c *gin.Context) {
// 解析 LINE webhook 請求
cb, err := webhook.ParseRequest(config.LINE_CHANNEL_SECRET, c.Request)
if err != nil {
log.Printf("解析 webhook 失敗: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Bad Request"})
return
}
// 處理每一個 webhook 事件
for _, event := range cb.Events {
switch e := event.(type) {
case webhook.MessageEvent:
// 檢查是否為文字訊息
if textMsg, ok := e.Message.(webhook.TextMessageContent); ok {
// 處理文字訊息
err := h.lineService.HandleTextMessage(e.ReplyToken, textMsg.Text)
if err != nil {
log.Printf("處理文字訊息失敗: %v", err)
}
}
case webhook.StickerMessageContent:
// 處理貼圖訊息
log.Println("收到貼圖訊息")
default:
log.Printf("未處理的事件類型: %T", e)
}
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}Webhook 處理的流程:
驗證請求:使用 Channel Secret 驗證請求是否真的來自 LINE。
解析事件:一個請求可能包含多個事件。
分類處理:根據事件類型(文字、貼圖等)做不同處理。
回應 LINE:必須在一定時間內回應,否則 LINE 會認為請求失敗。
LINE 訊息推播
這個 API 也是設計給 Google Apps Script 呼叫的,我們到時候只要在 Google 試算表做依照顧客分群發送通知的功能,商家就可以在試算表點一點直接推播訊息給顧客。
func (h *Handler) HandlePushMessage(c *gin.Context) {
var request struct {
UserID string `json:"userId"`
Message string `json:"message"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
err := h.lineService.PushTextMessage(request.UserID, request.Message)
if err != nil {
log.Printf("推播訊息失敗: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to push message"})
return
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"message": "Message pushed successfully",
})
}Bearer Token 認證中間件
為了避免 API 被亂 call,消耗伺服器算力,或是亂發訊息,某些 API 需要認證才能使用。
我實作了一個簡單的 Bearer Token 中間件:
func BearerAuthMiddleware(token string) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing authorization header"})
c.Abort()
return
}
// 檢查是否為 Bearer token 格式
if len(authHeader) < 7 || authHeader[:7] != "Bearer " {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid authorization header format"})
c.Abort()
return
}
providedToken := authHeader[7:]
if providedToken != token {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid token"})
c.Abort()
return
}
c.Next()
}
}使用方式:
apiGroup.POST("/calculate/:type",
middleware.BearerAuthMiddleware(config.GOOGLE_SHEET_API_TOKEN),
handler.HandleCalculate)如果 Token 不正確,請求就會被 c.Abort() 中斷,不會執行後面的 Handler。
提供 LIFF 靜態檔案
後端不只是 API 伺服器,也要提供 LIFF App 的靜態檔案。
func (h *Handler) ServeLIFF(c *gin.Context) {
// 獲取請求的文件路徑
filePath := c.Param("filepath")
// 如果沒有指定文件路徑,默認提供 index.html
if filePath == "" || filePath == "/" {
c.File("./liff/dist/index.html")
return
}
// 構建完整的文件路徑
fullPath := filepath.Join("./liff", "dist", filePath)
// 檢查文件是否存在
if _, err := http.Dir(".").Open(fullPath); err != nil {
// 如果文件不存在,返回 index.html(SPA 路由支持)
c.File("./liff/dist/index.html")
return
}
// 根據文件副檔名設定正確的 MIME 類型
switch filepath.Ext(filePath) {
case ".js":
c.Header("Content-Type", "application/javascript")
case ".css":
c.Header("Content-Type", "text/css")
case ".html":
c.Header("Content-Type", "text/html")
case ".svg":
c.Header("Content-Type", "image/svg+xml")
}
// 提供靜態文件
c.File(fullPath)
}這個函數的重點在於支援 SPA 的路由。當使用者訪問 /liff/order 這種路徑時,實際上這個檔案並不存在,所以要回傳 index.html,讓前端的 Vue Router 來處理路由。
總結
小小商家一點靈的後端架構清晰,職責分明:
Gin 框架:處理 HTTP 請求和路由。
Handlers:驗證請求、格式轉換。
Services:封裝業務邏輯。
Google Sheet:資料儲存和讀取。
Insyra:計算 RFM 和 CAI。
LINE Bot SDK:與 LINE 平台互動。
這樣的架構讓每個模組都可以獨立測試和維護,也方便之後擴充新功能。例如如果要換掉 Google 試算表,改用真正的資料庫,只需要修改 google_sheet 套件,其他部分幾乎不用動。
完整程式碼:https://github.com/TimLai666/lineliteshop1.0
本篇文章使用的 Insyra 版本:v0.2.13
Insyra 官網:https://insyra.hazelnut-paradise.com
Insyra 說明文件:https://hazelnutparadise.github.io/insyra
Insyra GitHub 儲存庫:https://github.com/HazelnutParadise/insyra