2025-07-07 Line 訂單系統-C 階段(LINE 訂單整合:前端 + 後端串接)

2025-07-07 Line 訂單系統-C 階段(LINE 訂單整合:前端 + 後端串接)

C 階段(LINE 訂單整合:前端 + 後端串接) 的設計與完整實作步驟交給你。
我會從「用戶在 LINE 下單的 UX 流程」開始,接著給出資料庫變更、後端(Node.js)實作、前端(Vue3)如何顯示訂單、LINE webhook 處理 postback 的範例,並補上測試、例外處理、以及實務要注意的安全/可靠性建議。你可以直接複製貼上程式碼並執行。


一、用戶下單流程(UX 簡述)

  1. 使用者在 LINE 傳「我要點餐」或按主選單 → Bot 回傳商品清單(Flex Carousel,每件商品含「購買」按鈕)。
  2. 使用者按「購買」按鈕(postback),按鈕會送出 action=buy&productId=XXX&qty=1(或 showConfirm 先顯示確認視窗)。
  3. Bot 接收到 postback → 後端建立訂單(orders + order_items) → 回覆「訂單已建立(編號:A0001)」並顯示訂單明細。
  4. 管理後台(Vue)即時或短輪詢取得新訂單,可變更訂單狀態(pending → confirmed → shipped)。
  5. 狀態更新時,後端可用 LINE push/message 回覆給訂購用戶(或用 reply when possible)。

設計重點:盡量用 postback 與 payload 包資料,減少「逐步輸入暫存狀態」的複雜性;必要時可用 Redis 保存臨時狀態。


二、資料庫(MySQL)變更與新增表

backend/db_init.sql(或新增 migration)加入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
USE line_order_db;

-- 商品 (若尚未建立)
CREATE TABLE IF NOT EXISTS products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
price DECIMAL(10,2) NOT NULL,
description TEXT,
image_url VARCHAR(255),
stock INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 訂單主表
CREATE TABLE IF NOT EXISTS orders (
id INT AUTO_INCREMENT PRIMARY KEY,
order_no VARCHAR(30) UNIQUE,
user_line_id VARCHAR(50),
customer_name VARCHAR(100),
phone VARCHAR(50),
total_price DECIMAL(10,2),
status ENUM('pending','confirmed','cancelled','paid','shipped','completed') DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- 訂單明細
CREATE TABLE IF NOT EXISTS order_items (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT,
product_id INT,
product_name VARCHAR(200),
qty INT DEFAULT 1,
unit_price DECIMAL(10,2),
subtotal DECIMAL(10,2),
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);

-- 訂單狀態歷程(選用)
CREATE TABLE IF NOT EXISTS order_status_history (
id INT AUTO_INCREMENT PRIMARY KEY,
order_id INT,
old_status VARCHAR(50),
new_status VARCHAR(50),
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

order_no 的生成策略:YYYYMMDD-XXXXA0001 類型(下文會示範簡單生成器)。


三、後端(Node.js)實作重點

1) 新增 / 修改後端路由檔案

backend/routes/ 加兩個檔: products.js(若已存在)與 orders.js(更完善) — 我會把 orders.js 的重點流程示範出來,並在 line.js 中加上處理 postback 的邏輯。

backend/routes/orders.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// backend/routes/orders.js
import express from "express";
import { pool } from "../db.js";

const router = express.Router();

// 產生簡單 order_no(建議可改成更穩定的序號服務)
function genOrderNo() {
const d = new Date();
const y = d.getFullYear().toString().slice(-2);
const m = String(d.getMonth()+1).padStart(2,'0');
const day = String(d.getDate()).padStart(2,'0');
const r = Math.floor(1000 + Math.random() * 9000); // 4-digit random
return `${y}${m}${day}-${r}`;
}

// 取得所有訂單(包含明細)
router.get("/", async (req, res) => {
try {
const [orders] = await pool.query(
`SELECT o.*, (SELECT JSON_ARRAYAGG(JSON_OBJECT('product_id', oi.product_id, 'product_name', oi.product_name, 'qty', oi.qty, 'unit_price', oi.unit_price, 'subtotal', oi.subtotal)) FROM order_items oi WHERE oi.order_id = o.id) AS items
FROM orders o ORDER BY o.id DESC`
);
res.json(orders);
} catch (err) {
console.error(err);
res.status(500).json({ error: "Server error" });
}
});

// 建立訂單(給 LINE or 前端 呼叫)
router.post("/", async (req, res) => {
const { user_line_id, customer_name, phone, items } = req.body;
// items = [{product_id, qty}, ...]
if (!items || !Array.isArray(items) || items.length === 0) {
return res.status(400).json({ error: "No items" });
}

const conn = await pool.getConnection();
try {
await conn.beginTransaction();

// 計算總價並準備明細
let total = 0.0;
const itemRows = [];
for (const it of items) {
const [rows] = await conn.query("SELECT id, name, price FROM products WHERE id = ?", [it.product_id]);
if (!rows || rows.length === 0) throw new Error("Product not found: " + it.product_id);
const p = rows[0];
const qty = parseInt(it.qty || 1);
const subtotal = Number(p.price) * qty;
total += subtotal;
itemRows.push({
product_id: p.id,
product_name: p.name,
qty,
unit_price: p.price,
subtotal
});
}

const orderNo = genOrderNo();

const [result] = await conn.query(
"INSERT INTO orders (order_no, user_line_id, customer_name, phone, total_price) VALUES (?, ?, ?, ?, ?)",
[orderNo, user_line_id || null, customer_name || null, phone || null, total]
);
const orderId = result.insertId;

// insert order_items
for (const ir of itemRows) {
await conn.query(
"INSERT INTO order_items (order_id, product_id, product_name, qty, unit_price, subtotal) VALUES (?, ?, ?, ?, ?, ?)",
[orderId, ir.product_id, ir.product_name, ir.qty, ir.unit_price, ir.subtotal]
);
}

await conn.commit();

// 回傳訂單資料
res.json({ orderId, orderNo, total, items: itemRows });
} catch (err) {
await conn.rollback();
console.error("Create order error:", err);
res.status(500).json({ error: "Create order failed" });
} finally {
conn.release();
}
});

// 更新訂單狀態
router.put("/:id/status", async (req, res) => {
const id = req.params.id;
const { status } = req.body;
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [[cur]] = await conn.query("SELECT status FROM orders WHERE id = ?", [id]);
await conn.query("UPDATE orders SET status = ? WHERE id = ?", [status, id]);
await conn.query("INSERT INTO order_status_history (order_id, old_status, new_status) VALUES (?, ?, ?)", [id, cur.status, status]);
await conn.commit();
res.json({ message: "Status updated" });
} catch (err) {
await conn.rollback();
console.error(err);
res.status(500).json({ error: "Update failed" });
} finally {
conn.release();
}
});

export default router;

注意:這段用 transaction(事務)保證 orders 與 order_items 的一致性。


2) 修改 backend/routes/line.js:處理 postback 與 message 指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// backend/routes/line.js (重點段)
import express from "express";
import { middleware, Client } from "@line/bot-sdk";
import dotenv from "dotenv";
import { pool } from "../db.js";
dotenv.config();

const router = express.Router();
const config = {
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
channelSecret: process.env.LINE_CHANNEL_SECRET,
};
const client = new Client(config);
router.post('/webhook', middleware(config), async (req, res) => {
try {
const events = req.body.events || [];
for (const event of events) {
// 文字訊息:啟動選單
if (event.type === 'message' && event.message.type === 'text') {
const text = event.message.text.trim();
if (text === '我要點餐' || text === '點餐') {
// 讀取商品並送 Flex carousel
const [products] = await pool.query('SELECT id, name, price, description, image_url FROM products WHERE stock > 0 LIMIT 10');
const bubbles = products.map(p => ({
type: 'bubble',
hero: p.image_url ? { type: 'image', url: p.image_url, size: 'full', aspectRatio: '20:13', aspectMode: 'cover' } : undefined,
body: {
type: 'box',
layout: 'vertical',
contents: [
{ type: 'text', text: p.name, weight: 'bold', size: 'lg', wrap: true },
{ type: 'text', text: `價格:$${p.price}`, size: 'sm', wrap: true, margin: 'md' },
{ type: 'text', text: p.description || '', size: 'sm', wrap: true, margin: 'md' }
]
},
footer: {
type: 'box',
layout: 'vertical',
spacing: 'sm',
contents: [
{ type: 'button', style: 'primary', action: { type: 'postback', label: '購買', data: `action=buy&productId=${p.id}&qty=1` } }
]
}
}));
const flex = { type: 'carousel', contents: bubbles.length ? bubbles : [{ type: 'bubble', body: { type: 'box', layout: 'vertical', contents: [{ type: 'text', text: '目前無商品' }] } }] };
await client.replyMessage(event.replyToken, { type: 'flex', altText: '商品列表', contents: flex });
continue;
}
// 其他文字預設回覆
await client.replyMessage(event.replyToken, { type: 'text', text: '輸入「我要點餐」開始點餐流程' });
}

// postback event(按購買)
else if (event.type === 'postback') {
// postback.data 會是 URL style query string
const qs = new URLSearchParams(event.postback.data);
const action = qs.get('action');
if (action === 'buy') {
const productId = parseInt(qs.get('productId'));
const qty = parseInt(qs.get('qty') || '1');
const userId = event.source.userId;

// 從 DB 取得產品資訊
const [[prod]] = await pool.query('SELECT id, name, price FROM products WHERE id = ?', [productId]);
if (!prod) {
await client.replyMessage(event.replyToken, { type: 'text', text: '找不到該商品,請稍後再試。' });
continue;
}

// 建立訂單(簡單版,customer info 為 null)
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const subtotal = Number(prod.price) * qty;
const orderNo = `L${Date.now().toString().slice(-6)}`;
const [r] = await conn.query('INSERT INTO orders (order_no, user_line_id, total_price) VALUES (?, ?, ?)', [orderNo, userId, subtotal]);
const orderId = r.insertId;
await conn.query('INSERT INTO order_items (order_id, product_id, product_name, qty, unit_price, subtotal) VALUES (?, ?, ?, ?, ?, ?)', [orderId, prod.id, prod.name, qty, prod.price, subtotal]);
await conn.commit();

// 回覆使用者 order info
const text = `已建立訂單\n編號:${orderNo}\n商品:${prod.name}\n數量:${qty}\n總價:$${subtotal}\n我們將盡快聯絡您。`;
await client.replyMessage(event.replyToken, { type: 'text', text });

// 可選:通知管理者(若你在 .env 放 ADMIN_LINE_ID)
const adminId = process.env.ADMIN_LINE_ID;
if (adminId) {
await client.pushMessage(adminId, { type: 'text', text: `新訂單:${orderNo},買家:${userId},商品:${prod.name} x${qty}` });
}
} catch (err) {
await conn.rollback();
console.error(err);
await client.replyMessage(event.replyToken, { type: 'text', text: '建立訂單失敗,請稍後再試。' });
} finally {
conn.release();
}
}
}
} // end for events

res.status(200).send('OK');
} catch (err) {
console.error('Webhook error', err);
res.status(500).end();
}
});

export default router;

重點:postback.data 常用 URLQuery 格式來傳遞 action / productId / qty,接到就做事。


四、前端(管理後台)改造:顯示訂單詳情與狀態通知

前面 B 階段已有 Orders.vue。補強要點:

  • 顯示 items(從 orders API 中取得 JSON Array)
  • 新增「通知買家」按鈕(call backend route 更新狀態並推送 LINE message)
  • 若想即時呈現新訂單:可採短輪詢(每 5–10 秒請求 /orders)或 WebSocket(進階)。

簡單的 Orders.vue 擴充範例(只顯示 items):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<template>
<div>
<h2>訂單管理</h2>
<table>
<tr><th>ID</th><th>編號</th><th>總價</th><th>狀態</th><th>明細</th><th>操作</th></tr>
<tr v-for="o in orders" :key="o.id">
<td>{{ o.id }}</td>
<td>{{ o.order_no }}</td>
<td>{{ o.total_price }}</td>
<td>{{ o.status }}</td>
<td>
<ul>
<li v-for="it in JSON.parse(o.items || '[]')" :key="it.product_id">
{{ it.product_name }} x{{ it.qty }} (${ { it.unit_price } }) 小計:{{ it.subtotal }}
</li>
</ul>
</td>
<td>
<button @click="updateStatus(o.id,'confirmed')">確認</button>
<button @click="notifyUser(o.id)">通知用戶</button>
</td>
</tr>
</table>
</div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
const api = "http://localhost:3000/orders";
const orders = ref([]);
async function load() { const res = await axios.get(api); orders.value = res.data; }
async function updateStatus(id, status) { await axios.put(`${api}/${id}/status`, { status }); await load(); }
async function notifyUser(id) { await axios.post(`http://localhost:3000/orders/${id}/notify`); alert('已通知'); }
onMounted(() => { load(); setInterval(load, 10000); });
</script>

注意:o.itemsorders route 我用 JSON_ARRAYAGG 回傳為 JSON 字串;前端須 parse。


五、後端:增加通知 API(管理者按鈕呼叫)

orders.js 增加通知 route:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// notify endpoint
router.post("/:id/notify", async (req, res) => {
const id = req.params.id;
try {
const [[order]] = await pool.query("SELECT * FROM orders WHERE id = ?", [id]);
if (!order) return res.status(404).json({ error: "not found" });
// 取得買家 user_line_id 與訂單明細
const [items] = await pool.query("SELECT * FROM order_items WHERE order_id = ?", [id]);

// 準備訊息
const text = `您的訂單 ${order.order_no} 已更新。狀態:${order.status}。`;
// 使用 client.pushMessage(需在 route 取得 client 或在更高層注入)
// 這裡假設你把 LINE client export 或在此檔 require
import { Client } from '@line/bot-sdk'; // 需放在檔案頂端
const client = new Client({ channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN });
if (order.user_line_id) {
await client.pushMessage(order.user_line_id, { type: 'text', text });
}

res.json({ message: 'Notified' });
} catch (err) {
console.error(err);
res.status(500).json({ error: "Notify fail" });
}
});

(實際請把 Client 建在共用檔或 index.js,不要每次新建)


六、測試方法(逐步)

  1. 啟動 MySQL、後端(npm run dev)、ngrok(ngrok http 3000)並把 webhook 設好。
  2. 在後台 Products 新增一兩個測試商品(或直接在 MySQL insert)。
  3. 用手機在 LINE 發「我要點餐」 → 應收到 Flex 商品清單(含「購買」)。
  4. 按「購買」→ Bot 回覆「已建立訂單」與訂單編號。
  5. 後台 /orders 應立即可見該訂單(或短輪詢後出現)。
  6. 在後台將狀態改為 confirmed → 點「通知用戶」→ 檢查 LINE 使用者是否收到推播。

模擬 postback 測試(curl)
你可以用 curl 模擬 LINE 的 postback event(開發時):

1
2
3
curl -X POST http://localhost:3000/line/webhook \
-H "Content-Type: application/json" \
-d '{"events":[{"type":"postback","replyToken":"000","source":{"userId":"U111"},"postback":{"data":"action=buy&productId=1&qty=1"}}]}'

注意:@line/bot-sdk middleware 會驗證簽章,真實 LINE 事件會有 signature。為本地測試可臨時用 route 不帶 middleware(僅測功能),或用工具繞過。


七、可靠性、安全與實務注意事項

  • Webhook 驗簽:在開發(公開測試)時使用 middleware(config);部署時絕對不要關掉驗簽。
  • 事務處理:建立訂單時使用 DB transaction(範例已示)。
  • 重複請求:postback 或 webhook 可能重送。建議在 orders 表用 order_no 或提供 client_request_id 來做去重檢查(idempotency key)。
  • 資料驗證:在後端對 itemsproduct_idqty 做型別與範圍檢查。
  • 資安LINE_CHANNEL_ACCESS_TOKEN / LINE_CHANNEL_SECRET / DB 密碼 放 .env,不管測試或上線都不要提交到 Git。
  • 錯誤回補:如果 DB 寫入成功但回覆失敗,須有 logging 與重試機制(排程或 queue)。
  • 異地通知:若想把通知給管理者或多個 admin,可把 admin list 存到 DB 或 env 中並迴圈 push。
  • 效能:高頻查詢訂單狀態時可將熱門欄位快取到 Redis(例如 order status, last 50 orders)。
  • 使用者資料:建議把 LINE user profile(displayName、picture)存於 users 表,第一次互動用 client.getProfile(userId) 取得並儲存。

八、擴充建議(下一步可以做)

  • 付款整合(LINE Pay /第三方):建立 payments 表並把付款狀態與訂單關聯。
  • 多商品一次下單:目前示例是單一 product buy,可擴展為購物車(multiple items)流程 (要設計「選擇數量」「加入購物車」「結帳畫面」)。
  • 狀態 WebSocket 即時推送管理後台:用 Socket.io 把新訂單即時推給管理者頁面。
  • 自然語言解析:用簡單關鍵字或 webhook 傳到 NLP(例如 Rasa)解析複雜句子(「我要兩個紅豆麵包明天 10 點取貨」),再轉成結構化 order payload。
  • 測試自動化:用 Postman collection + Newman 或 Jest + Supertest 做 webhook 與 API 自動測試。

九、Checkpoint(C 階段完成判定)

  • LINE 我要點餐 能回傳商品 Flex carousel。
  • 使用者按「購買」後,後端在 ordersorder_items 生成正確資料。
  • 後台 Orders 頁能看到新訂單與明細(items)。
  • 管理後台可改狀態並通知使用者(LINE push)。
  • 有錯誤處理與 DB 事務保證,不會造成半成品訂單。

📘 階段 C 主要任務(你目前所在的位置)

這個階段的重點是:

讓 LINE Bot 能和你的後端、前端真正「串起來」,自動處理訂單。

✅ 已準備完成的內容

  • .env 範例 ✅
  • db_init.sql
  • 專案資料夾結構(backend + frontend)✅

🧩 下一步實作建議(C 階段要做的 4 大步)

步驟 名稱 任務內容 目的
C1 設定 LINE Webhook LINE Developers 建立 Messaging API、設 webhook URL、設定 channel token/secret 讓 LINE 傳訊息能送到你的 Node.js 伺服器
C2 Node.js webhook 處理 建立 /webhook 路由,驗證 LINE 簽章,接收訊息事件 能處理使用者的 LINE 訊息(例如「查詢商品」)
C3 訂單整合邏輯 使用者輸入商品代號 → Node.js 查詢 MySQL → 回傳訂單資訊 讓 Bot 可以真實回覆商品資料
C4 Vue3 後台整合顯示 新增「LINE 訂單紀錄」頁面,顯示 MySQL 訂單狀態 讓管理者在前端後台看到來自 LINE 的訂單紀錄


如果你願意,我可以立刻幫你產出

  1. 已修改好的 backend/routes/orders.jsbackend/routes/line.js 完整檔案(可直接放入你 repo)。
  2. 一個簡易的 Products seed SQL(幾筆商品)與 Orders 測試腳本(curl 範例)。
  3. 前端 Orders.vue 的完整可直接替換版本,包含 notify 按鈕對應的 API 呼叫。

你要我先把哪一份檔案內容直接貼給你(或打包成可以 git clone 的 commit 範例)?