2025-07-08 Line 訂單系統-C 階段程式範例

2025-07-08 Line 訂單系統-C 階段程式範例

這三份檔案,設計為可以直接放進你的現有 LINE + Vue 後台專案中執行


📁 目錄結構(放置說明)

1
2
3
4
5
6
7
8
9
10
11
12
project-root/
├─ backend/
│ ├─ routes/
│ │ ├─ line.js ← (A) LINE webhook + 訂單建立整合
│ │ ├─ orders.js ← (B) 訂單 REST API(含通知功能)
│ └─ db/
│ └─ seed_products.sql ← (C) 商品測試資料

└─ frontend/
└─ src/
└─ views/
└─ Orders.vue ← (D) Vue 後台訂單頁

🧩 (A) backend/routes/line.js

👉 功能
處理「我要點餐」文字訊息、回傳商品 Flex、接收 postback 購買事件、建立訂單、通知管理者。

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
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
// 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);

// --- LINE Webhook 主入口 ---
router.post("/webhook", middleware(config), async (req, res) => {
try {
const events = req.body.events || [];

for (const event of events) {
// 🎯 1️⃣ 使用者傳文字:我要點餐
if (event.type === "message" && event.message.type === "text") {
const text = event.message.text.trim();
if (text === "我要點餐" || text === "點餐") {
const [products] = await pool.query(
"SELECT id, name, price, description, image_url FROM products 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" },
{ type: "text", text: `價格:$${p.price}`, size: "sm" },
{ type: "text", text: p.description || "", size: "sm", wrap: true },
],
},
footer: {
type: "box",
layout: "vertical",
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: "請輸入「我要點餐」開始下單流程。",
});
}

// 🎯 2️⃣ postback event(購買商品)
else if (event.type === "postback") {
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;

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;
}

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();

await client.replyMessage(event.replyToken, {
type: "text",
text: `✅ 已建立訂單\n編號:${orderNo}\n商品:${prod.name}\n數量:${qty}\n金額:$${subtotal}`,
});

// 通知管理者
if (process.env.ADMIN_LINE_ID) {
await client.pushMessage(process.env.ADMIN_LINE_ID, {
type: "text",
text: `📦 新訂單:${orderNo}\n商品:${prod.name} x${qty}`,
});
}
} catch (err) {
await conn.rollback();
console.error("Create order failed:", err);
await client.replyMessage(event.replyToken, {
type: "text",
text: "建立訂單失敗,請稍後再試。",
});
} finally {
conn.release();
}
}
}
}

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

export default router;

🧩 (B) backend/routes/orders.js

👉 功能
提供 REST API 給 Vue 後台使用(查詢訂單、修改狀態、通知買家)。

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
// backend/routes/orders.js
import express from "express";
import { pool } from "../db.js";
import { Client } from "@line/bot-sdk";
import dotenv from "dotenv";
dotenv.config();

const router = express.Router();

const client = new Client({
channelAccessToken: process.env.LINE_CHANNEL_ACCESS_TOKEN,
});

// 查詢所有訂單(含明細)
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" });
}
});

// 修改訂單狀態
router.put("/:id/status", async (req, res) => {
const id = req.params.id;
const { status } = req.body;
try {
await pool.query("UPDATE orders SET status = ? WHERE id = ?", [status, id]);
res.json({ message: "狀態已更新" });
} catch (err) {
console.error(err);
res.status(500).json({ error: "更新失敗" });
}
});

// 通知買家
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 || !order.user_line_id)
return res.status(404).json({ error: "找不到訂單或用戶" });

const text = `您的訂單 ${order.order_no} 狀態更新為:${order.status}`;
await client.pushMessage(order.user_line_id, { type: "text", text });
res.json({ message: "已通知用戶" });
} catch (err) {
console.error(err);
res.status(500).json({ error: "通知失敗" });
}
});

export default router;

🧩 (C) backend/db/seed_products.sql

👉 功能
初始化測試用商品資料。

1
2
3
4
5
6
7
8
USE line_order_db;

INSERT INTO products (name, price, description, image_url, stock)
VALUES
('紅豆麵包', 35.00, '手作紅豆餡,每日新鮮出爐', 'https://i.imgur.com/LK8X4FJ.jpg', 20),
('奶酥麵包', 30.00, '香濃奶酥,入口即化', 'https://i.imgur.com/WR5qI1S.jpg', 20),
('抹茶波蘿', 40.00, '抹茶香氣濃郁,酥皮脆口', 'https://i.imgur.com/T5Y8Msv.jpg', 20),
('可可貝果', 45.00, '低糖健康可可貝果', 'https://i.imgur.com/W4IY1w1.jpg', 15);

🧩 (D) frontend/src/views/Orders.vue

👉 功能
Vue 後台管理訂單,可顯示明細、修改狀態、通知買家。

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
<template>
<div class="p-6">
<h2 class="text-xl font-bold mb-4">📦 訂單管理</h2>
<table border="1" cellpadding="8">
<thead>
<tr>
<th>編號</th>
<th>總價</th>
<th>狀態</th>
<th>明細</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-for="o in orders" :key="o.id">
<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 }} × {{ it.qty }} = ${{ it.subtotal }}
</li>
</ul>
</td>
<td>
<select v-model="o.newStatus">
<option>pending</option>
<option>confirmed</option>
<option>paid</option>
<option>shipped</option>
<option>completed</option>
<option>cancelled</option>
</select>
<button @click="updateStatus(o)" class="ml-2">更新</button>
<button @click="notifyUser(o.id)" class="ml-2">通知</button>
</td>
</tr>
</tbody>
</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 loadOrders() {
const res = await axios.get(api);
orders.value = res.data.map((o) => ({
...o,
newStatus: o.status,
}));
}

async function updateStatus(o) {
await axios.put(`${api}/${o.id}/status`, { status: o.newStatus });
await loadOrders();
}

async function notifyUser(id) {
await axios.post(`${api}/${id}/notify`);
alert("已通知用戶!");
}

onMounted(() => {
loadOrders();
setInterval(loadOrders, 10000);
});
</script>

<style scoped>
table {
width: 100%;
border-collapse: collapse;
}
th {
background: #f0f0f0;
}
button {
background: #4caf50;
color: white;
border: none;
padding: 4px 8px;
cursor: pointer;
}
button:hover {
background: #45a049;
}
</style>

✅ 測試步驟快速摘要

  1. 匯入 seed_products.sql

    1
    mysql -u root -p line_order_db < backend/db/seed_products.sql
  2. 啟動後端 + 前端:

    1
    2
    3
    cd backend
    npm run dev
    ngrok http 3000 # 把 ngrok URL 填到 LINE webhook
  3. 手機 LINE 輸入:「我要點餐」
    → 會出現商品列表
    → 按「購買」後自動在後台 Orders 出現新訂單。

  4. 打開前端 Orders.vue → 更新狀態 → 點「通知」 → LINE 用戶會收到推播。


是否要我幫你:
✅ 生成一份完整 .env 範例 + db_init.sql
(讓你直接可在 Replit 或本機初始化 DB + LINE key)?