LINE予約システムを自作する手順 コード付きの実装記録
LINE予約システムを
この
この
この
載せたwrangler devと
LINE予約システムは8つの手順で作る
作る
| 手順 | 作るもの | 詰まる所 | 詳しい記事 |
|---|---|---|---|
| 1 | チャネルと |
コンソールからは |
公式LINE連携の |
| 2 | Webhookの |
JSONに |
Webhookの |
| 3 | リッチメニュー | 画像だけドメインが |
段階で |
| 4 | 枠を |
ボタンは |
日本時間の |
| 5 | 二重予約の |
同じ |
DB制約と |
| 6 | 確認と |
応答トークンは |
前日リマインドの |
| 7 | キャンセル | 古い |
キャンセルの |
| 8 | 店側の画面 | 表示名を |
店舗ごとの |
ファイルの
line-booking/
├── src/index.js … 入口(Webhook・店の画面・定期実行に振り分ける)
├── src/line.js … 署名の検証とメッセージの送信
├── src/webhook.js … イベントごとの処理
├── src/booking.js … 枠と予約のSQL
├── src/time.js … 日本時間の計算
├── src/remind.js … 前日のリマインド
├── src/admin.js … 店側の画面
├── schema.sql … 表の定義
├── richmenu.mjs … リッチメニューを作るスクリプト
└── wrangler.jsonc手順1. Messaging APIのチャネルは、公式アカウント側から作る
Messaging APIの
進め方は
- LINE公式アカウントを
作る - LINE Official Account Managerで、
Messaging APIの 利用を 有効に する。 この ときプロバイダーを 選ぶ - LINE Developersコンソールの
「チャネル基本設定」で、 チャネルシークレットを 控える - 「Messaging API設定」で、
チャネルアクセストークンを 発行する - LINE Official Account Managerで、
あいさつ メッセージと 応答メッセージを 切る
詰まりやすいのは
トークンには
秘密のwrangler secret putで.dev.varsに置いて.gitignoreに
{
"name": "line-booking",
"main": "src/index.js",
"compatibility_date": "2026-10-01",
"triggers": { "crons": ["0 9 * * *", "0 10 * * *"] }, // UTC。日本時間の18時と19時
"vars": {
"LINE_API_BASE": "https://api.line.me"
},
"d1_databases": [
{ "binding": "DB", "database_name": "line-booking", "database_id": "00000000-0000-0000-0000-000000000000" }
]
}LINE_CHANNEL_SECRET=test-channel-secret-for-local-only
LINE_CHANNEL_ACCESS_TOKEN=test-token
LINE_API_BASE=http://127.0.0.1:8790
ADMIN_USER=staff
ADMIN_PASSWORD=local-only-passwordLINE_API_BASEを
手順2. Webhookは、文字列のまま署名を確かめてから読む
Webhookの
import { handleAdmin } from "./admin.js";
import { verifySignature } from "./line.js";
import { sendReminders } from "./remind.js";
import { handleEvents } from "./webhook.js";
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
if (url.pathname.startsWith("/admin")) return handleAdmin(request, env);
if (request.method !== "POST" || url.pathname !== "/webhook") return new Response("Not Found", { status: 404 });
// 本文は JSON にする前に、文字列のまま署名を確かめる
const rawBody = await request.text();
const ok = await verifySignature(env.LINE_CHANNEL_SECRET, rawBody, request.headers.get("x-line-signature") ?? "");
if (!ok) return new Response("Unauthorized", { status: 401 });
const { events = [] } = JSON.parse(rawBody);
ctx.waitUntil(handleEvents(env, events)); // 先に 200 を返し、処理は後に回す
return new Response("OK");
},
async scheduled(controller, env, ctx) {
ctx.waitUntil(sendReminders(env, controller.scheduledTime));
},
};export async function verifySignature(secret, rawBody, signature) {
if (!secret || !signature) return false;
const enc = new TextEncoder();
const key = await crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
let sig;
try {
sig = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0));
} catch {
return false; // Base64 として読めない値
}
return crypto.subtle.verify("HMAC", key, sig, enc.encode(rawBody));
}なopensslの
openssl: Qi+ynCRA2cQkBJOS75gA29VG8zLhHIkJtOdd9dBT3BU=
node : Qi+ynCRA2cQkBJOS75gA29VG8zLhHIkJtOdd9dBT3BU=
verifySignature(openssl の値): trueWorkerに
詰まるeventsが
手順3. リッチメニューは、スクリプトで作って画像を付ける
リッチメニューは、action=datesのような
// richmenu.mjs:リッチメニューを作り、画像を付け、全員の既定にする
import { readFileSync } from "node:fs";
const API = process.env.LINE_API_BASE ?? "https://api.line.me";
const DATA = process.env.LINE_DATA_BASE ?? "https://api-data.line.me"; // 画像だけドメインが違う
const auth = { Authorization: `Bearer ${process.env.LINE_CHANNEL_ACCESS_TOKEN}` };
const menu = {
size: { width: 2500, height: 843 },
selected: true,
name: "booking-v1",
chatBarText: "メニュー",
areas: [
{ bounds: { x: 0, y: 0, width: 1250, height: 843 }, action: { type: "postback", data: "action=dates", displayText: "予約する" } },
{ bounds: { x: 1250, y: 0, width: 1250, height: 843 }, action: { type: "postback", data: "action=mine", displayText: "予約の確認" } },
],
};
async function call(url, init) {
const res = await fetch(url, init);
const body = await res.text();
if (!res.ok) throw new Error(`${res.status} ${url}: ${body}`);
return body ? JSON.parse(body) : {};
}
const { richMenuId } = await call(`${API}/v2/bot/richmenu`, {
method: "POST",
headers: { ...auth, "Content-Type": "application/json" },
body: JSON.stringify(menu),
});
await call(`${DATA}/v2/bot/richmenu/${richMenuId}/content`, {
method: "POST",
headers: { ...auth, "Content-Type": "image/png" },
body: readFileSync("richmenu.png"),
});
await call(`${API}/v2/bot/user/all/richmenu/${richMenuId}`, { method: "POST", headers: auth });
console.log("default rich menu:", richMenuId);環境変数LINE_CHANNEL_ACCESS_TOKENをrichmenu.pngを置いて、node richmenu.mjsで
default rich menu: richmenu-test詰まる
- 画像だけドメインが
違う。 画像のアップロードは api-data.line.meで、ほかはapi.line.meです - 画像の
条件が JPEGか細かい。 PNG、 幅800〜2500ピクセル、 高さ250ピクセル以上、 幅÷高さが 1.45以上、 1MBまでです - 一度
付けた 画像を画像は 替えられない。 更新するには、 新しい リッチメニューを 作り直します。 スクリプトに しておくのは この ためです - タップできる
領域は ポストバックの20個まで、 chatBarTextは14文字まで。 dataは300文字までです
メニューのminとmaxは
予約前と
手順4. 枠は表に持ち、空きのある日と時間だけをボタンで見せる
枠はstatusがbookedの
-- 予約枠。時刻は UTC で持ち、画面と判定のときだけ日本時間に直す
CREATE TABLE slots (
id INTEGER PRIMARY KEY,
starts_at TEXT NOT NULL UNIQUE, -- '2026-11-05T01:00:00.000Z'
capacity INTEGER NOT NULL CHECK (capacity >= 0) -- 0 にすると店が閉じた枠になる
);
-- 予約。残りの数は持たず、status が booked の行を数える
CREATE TABLE bookings (
id TEXT PRIMARY KEY,
slot_id INTEGER NOT NULL REFERENCES slots(id),
line_user_id TEXT, -- 電話の予約は NULL
customer_name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'booked', -- booked / cancelled
retry_key TEXT NOT NULL, -- リマインドの X-Line-Retry-Key
reminded_at TEXT
);
CREATE INDEX bookings_by_slot ON bookings (slot_id, status);
-- 同じ人が同じ枠に2件入らない。取り消した行は対象の外
CREATE UNIQUE INDEX one_booking_per_person
ON bookings (slot_id, line_user_id)
WHERE status = 'booked' AND line_user_id IS NOT NULL;
-- 受け取った Webhook の記録。再送を飛ばすために使う
CREATE TABLE line_events (
webhook_event_id TEXT PRIMARY KEY
);枠の
-- 11月5日 10:00(日本時間)、定員3の枠
INSERT INTO slots (starts_at, capacity) VALUES ('2026-11-05T01:00:00.000Z', 3);時刻の
// 日本時間の日付と時刻。日本は夏時間がないので UTC+9 で固定できる
const JST_MS = 9 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
export const jstDate = (ms) => new Date(ms + JST_MS).toISOString().slice(0, 10); // '2026-11-05'
export const jstTime = (iso) => new Date(Date.parse(iso) + JST_MS).toISOString().slice(11, 16); // '10:00'
export const jstLabel = (iso) => `${jstDate(Date.parse(iso)).slice(5).replace("-", "/")} ${jstTime(iso)}`; // '11/05 10:00'
// 日本時間の1日を、UTC の [始まり, 次の日の始まり) にする
export function jstDayRange(date) {
const from = Date.parse(`${date}T00:00:00+09:00`);
return [new Date(from).toISOString(), new Date(from + DAY_MS).toISOString()];
}
// 取り消しの締め切り:枠の前日の18時(日本時間)
export function cancelDeadline(startsAt) {
const day = jstDate(Date.parse(startsAt) - DAY_MS);
return Date.parse(`${day}T18:00:00+09:00`);
}空きの
import { cancelDeadline, jstDayRange } from "./time.js";
const FREE = `s.capacity - (SELECT count(*) FROM bookings b WHERE b.slot_id = s.id AND b.status = 'booked')`;
// 空きのある枠を、今より後から days 日ぶん取り出す
export async function openSlots(env, nowMs, days = 14) {
const { results } = await env.DB.prepare(
`SELECT s.id, s.starts_at, ${FREE} AS free
FROM slots s
WHERE s.starts_at > ?1 AND s.starts_at < ?2 AND ${FREE} > 0
ORDER BY s.starts_at`,
)
.bind(new Date(nowMs).toISOString(), new Date(nowMs + days * 86_400_000).toISOString())
.all();
return results;
}ポストバックを
import { cancelByCustomer, myBookings, openSlots, reserve } from "./booking.js";
import { respond } from "./line.js";
import { jstDate, jstLabel, jstTime } from "./time.js";
const text = (t, quick) => ({
type: "text",
text: t,
...(quick?.length ? { quickReply: { items: quick } } : {}),
});
const button = (label, data) => ({ type: "action", action: { type: "postback", label, data, displayText: label } });
export async function handleEvents(env, events) {
for (const event of events) {
// 同じイベントは1回だけ処理する
const seen = await env.DB.prepare(
"INSERT INTO line_events (webhook_event_id) VALUES (?1) ON CONFLICT DO NOTHING",
)
.bind(event.webhookEventId)
.run();
if (seen.meta.changes === 0) continue;
try {
await handleEvent(env, event);
} catch (err) {
console.log(`failed ${event.webhookEventId}: ${err.message}`);
}
}
}
async function handleEvent(env, event) {
if (event.type === "follow") {
return respond(env, event, [text("友だち追加ありがとうございます。下のメニューの「予約する」から日付を選べます。")]);
}
if (event.type !== "postback" || !event.source.userId) return;
const p = new URLSearchParams(event.postback.data);
const nowMs = Date.now();
const userId = event.source.userId;
switch (p.get("action")) {
case "dates": {
const days = [...new Set((await openSlots(env, nowMs)).map((s) => jstDate(Date.parse(s.starts_at))))];
if (days.length === 0) return respond(env, event, [text("いま予約できる枠がありません。")]);
const quick = days.slice(0, 13).map((d) => button(d.slice(5).replace("-", "/"), `action=times&date=${d}`));
return respond(env, event, [text("日付を選んでください。", quick)]);
}
case "times": {
const date = p.get("date");
const slots = (await openSlots(env, nowMs)).filter((s) => jstDate(Date.parse(s.starts_at)) === date);
if (slots.length === 0) return respond(env, event, [text("この日の空きはなくなりました。もう一度「予約する」から選んでください。")]);
const quick = slots.slice(0, 13).map((s) => button(`${jstTime(s.starts_at)}(残り${s.free})`, `action=book&slot=${s.id}`));
return respond(env, event, [text(`${date.slice(5).replace("-", "/")} の時間を選んでください。`, quick)]);
}
case "book": {
const name = await displayName(env, userId);
const r = await reserve(env, { slotId: Number(p.get("slot")), lineUserId: userId, name, nowMs });
if (r.ok) return respond(env, event, [text("ご予約を受け付けました。確認と取り消しは、メニューの「予約の確認」からできます。")]);
if (r.reason === "already") return respond(env, event, [text("この時間はすでにご予約いただいています。")]);
return respond(env, event, [text("申し訳ありません。この時間は埋まりました。ほかの時間を選んでください。", [button("日付を選び直す", "action=dates")])]);
}
case "mine": {
const list = await myBookings(env, userId, nowMs);
if (list.length === 0) return respond(env, event, [text("いまのご予約はありません。")]);
const lines = list.map((b) => jstLabel(b.starts_at)).join("\n");
const quick = list.slice(0, 13).map((b) => button(`${jstLabel(b.starts_at)} 取消`, `action=cancel&booking=${b.id}`));
return respond(env, event, [text(`ご予約\n${lines}`, quick)]);
}
case "cancel": {
const r = await cancelByCustomer(env, { bookingId: p.get("booking"), lineUserId: userId, nowMs });
const msg = {
ok: "ご予約を取り消しました。",
late: "取り消しの締め切り(前日18時)を過ぎています。お電話でご連絡ください。",
none: "取り消せるご予約が見つかりませんでした。",
};
return respond(env, event, [text(msg[r])]);
}
}
}
async function displayName(env, userId) {
const res = await fetch(`${env.LINE_API_BASE}/v2/bot/profile/${userId}`, {
headers: { Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}` },
signal: AbortSignal.timeout(5_000),
});
return res.ok ? (await res.json()).displayName : "(名前を取得できず)";
}試すと、
日付:["11/01","11/05","11/06","11/07","11/10"]
時間:[{"label":"10:00(残り1)","data":"action=book&slot=1"},{"label":"11:00(残り3)","data":"action=book&slot=2"}]先頭の
詰まるslice(0, 13)で
もう
手順5. 二重予約は、空きの確認と登録を1文にして防ぐ
二重予約をINSERT ... SELECTの
// 枠を1つ確保する。空きの確認と登録を 1 文で行う
export async function reserve(env, { slotId, lineUserId, name, nowMs }) {
const id = crypto.randomUUID();
try {
const r = await env.DB.prepare(
`INSERT INTO bookings (id, slot_id, line_user_id, customer_name, retry_key)
SELECT ?1, s.id, ?2, ?3, ?4
FROM slots s
WHERE s.id = ?5 AND s.starts_at > ?6 AND ${FREE} > 0`,
)
.bind(id, lineUserId, name, crypto.randomUUID(), slotId, new Date(nowMs).toISOString())
.run();
if (r.meta.changes === 1) return { ok: true, id };
// 埋まった枠を取ったのが本人なら、「満席」ではなく「予約済み」と返す
const mine = await env.DB.prepare(
`SELECT 1 FROM bookings WHERE slot_id = ?1 AND line_user_id = ?2 AND status = 'booked'`,
)
.bind(slotId, lineUserId)
.first();
return { ok: false, reason: mine ? "already" : "full" };
} catch (err) {
// 空きがある枠に、同じ人の2件目が来た(ボタンの2度押しなど)
if (String(err.message).includes("UNIQUE constraint failed")) return { ok: false, reason: "already" };
throw err;
}
}空きがone_booking_per_personのwebhookEventIdも
手元の
| 試したこと | 結果 |
|---|---|
| 定員3の |
受付3件、 |
| 定員1の |
受付1件、 |
| 同じ |
受付1件、 |
同じwebhookEventIdの再送 |
返事を |
作っている
この1文はPostgreSQLに持っていけない
D1で
PostgreSQLは
| 書き方 | 入った |
|---|---|
| 空きを |
4・25・14・16・22、 |
| 条件つきの |
10回とも |
FOR UPDATEで |
10回とも |
1行目はINSERTは、UPDATEはWHEREの
PostgreSQLでbooked)のbookedを
WITH got AS (
UPDATE slots SET booked = booked + 1
WHERE id = $1 AND booked < capacity
RETURNING id
)
INSERT INTO bookings (slot_id, customer) SELECT id, $2 FROM got;二重予約のFOR UPDATEが
手順6. 確認は応答で返し、リマインドはプッシュで送る
お客様の
function post(env, path, body, headers = {}) {
return fetch(`${env.LINE_API_BASE}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${env.LINE_CHANNEL_ACCESS_TOKEN}`,
...headers,
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
}
export function push(env, to, messages, retryKey = crypto.randomUUID()) {
return post(env, "/v2/bot/message/push", { to, messages }, { "X-Line-Retry-Key": retryKey });
}
// 応答で返し、応答トークンが使えなかったときだけ push に切り替える
export async function respond(env, event, messages) {
const res = await post(env, "/v2/bot/message/reply", { replyToken: event.replyToken, messages });
if (res.ok) return;
if (res.status !== 400) throw new Error(`reply ${res.status}`); // 5xx は届いた可能性があるので重ねて送らない
const pushed = await push(env, event.source.userId, messages);
if (!pushed.ok) throw new Error(`push ${pushed.status}`);
}応答トークンはX-Line-Retry-Keyが
前日の
import { push } from "./line.js";
import { jstDate, jstDayRange, jstTime } from "./time.js";
// 明日(日本時間)の予約に1回だけ送る。送る前に印を付け、取れた実行だけが送る
export async function sendReminders(env, scheduledMs) {
const [from, to] = jstDayRange(jstDate(scheduledMs + 86_400_000));
const { results } = await env.DB.prepare(
`SELECT b.id, b.line_user_id, b.retry_key, s.starts_at
FROM bookings b JOIN slots s ON s.id = b.slot_id
WHERE b.status = 'booked' AND b.line_user_id IS NOT NULL AND b.reminded_at IS NULL
AND s.starts_at >= ?1 AND s.starts_at < ?2`,
)
.bind(from, to)
.all();
for (const b of results) {
const claim = await env.DB.prepare(
"UPDATE bookings SET reminded_at = ?1 WHERE id = ?2 AND reminded_at IS NULL AND status = 'booked'",
)
.bind(new Date(scheduledMs).toISOString(), b.id)
.run();
if (claim.meta.changes === 0) continue;
const res = await push(env, b.line_user_id, [{ type: "text", text: `明日 ${jstTime(b.starts_at)} からのご予約のお知らせです。` }], b.retry_key).catch(() => null);
// 届いたか分からない失敗は印を戻し、次の回に同じ retry key で送り直す
if (!res || res.status >= 500) {
await env.DB.prepare("UPDATE bookings SET reminded_at = NULL WHERE id = ?1").bind(b.id).run();
}
console.log(`remind ${b.id} ${res?.status ?? "no response"}`);
}
}ここはX-Line-Retry-Keyに
10月31日18時
18時 200 U_g 897a17ab
18時 500 U_500once a761b15f
18時 200 U_f d777d3d0
19時 200 U_500once a761b15f2つのU_gの
な
手順7. キャンセルは、本人と締め切りを確かめてから状態を変える
キャンセルでstatusを
// お客様の取り消し。本人の予約で、締め切り前のときだけ通す
export async function cancelByCustomer(env, { bookingId, lineUserId, nowMs }) {
const b = await env.DB.prepare(
`SELECT s.starts_at FROM bookings b JOIN slots s ON s.id = b.slot_id
WHERE b.id = ?1 AND b.line_user_id = ?2 AND b.status = 'booked'`,
)
.bind(bookingId, lineUserId)
.first();
if (!b) return "none";
if (nowMs >= cancelDeadline(b.starts_at)) return "late";
const r = await env.DB.prepare(`UPDATE bookings SET status = 'cancelled' WHERE id = ?1 AND status = 'booked'`)
.bind(bookingId)
.run();
return r.meta.changes === 1 ? "ok" : "none";
}「予約の
export async function myBookings(env, lineUserId, nowMs) {
const { results } = await env.DB.prepare(
`SELECT b.id, s.starts_at FROM bookings b JOIN slots s ON s.id = b.slot_id
WHERE b.line_user_id = ?1 AND b.status = 'booked' AND s.starts_at > ?2
ORDER BY s.starts_at`,
)
.bind(lineUserId, new Date(nowMs).toISOString())
.all();
return results;
}取り消しのaction=cancel&booking=予約IDです。
| 試したこと | 結果 |
|---|---|
| 別の |
「見つかりませんでした」で、 |
| 本人が |
取り消せる。 |
| 同じ |
「見つかりませんでした」 |
| 締め切りを |
「お電話で |
締め切りはcancelDeadlineを
OK 枠 2026-11-05T10:00:00 / 今 2026-11-04T17:59:59 → 取り消せる
OK 枠 2026-11-05T10:00:00 / 今 2026-11-04T18:00:00 → 締め切り後
OK 枠 2026-11-01T08:30:00 / 今 2026-10-31T17:59:59 → 取り消せる
OK 枠 2026-11-01T08:30:00 / 今 2026-10-31T18:00:00 → 締め切り後
OK 枠 2027-01-01T10:00:00 / 今 2026-12-31T17:59:59 → 取り消せる
OK 枠 2028-03-01T10:00:00 / 今 2028-02-29T18:00:00 → 締め切り後何時までを
手順8. 店側の画面は、一覧と取り消しと電話の予約から作る
店側の
// 店側の取り消し。締め切りは見ない
export function cancelByStaff(env, bookingId) {
return env.DB.prepare(`UPDATE bookings SET status = 'cancelled' WHERE id = ?1 AND status = 'booked'`)
.bind(bookingId)
.run();
}
// 店の画面に出す、その日の枠と予約
export async function dayBoard(env, date) {
const [from, to] = jstDayRange(date);
const { results } = await env.DB.prepare(
`SELECT s.id AS slot_id, s.starts_at, s.capacity, b.id AS booking_id, b.customer_name, b.line_user_id
FROM slots s LEFT JOIN bookings b ON b.slot_id = s.id AND b.status = 'booked'
WHERE s.starts_at >= ?1 AND s.starts_at < ?2
ORDER BY s.starts_at`,
)
.bind(from, to)
.all();
return results;
}import { cancelByStaff, dayBoard, reserve } from "./booking.js";
import { jstDate, jstTime } from "./time.js";
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
function authorized(request, env) {
const header = request.headers.get("authorization") ?? "";
if (!env.ADMIN_PASSWORD || !header.startsWith("Basic ")) return false; // 入れ忘れたまま開かないようにする
const enc = new TextEncoder();
let a;
try {
a = enc.encode(atob(header.slice(6)));
} catch {
return false;
}
const b = enc.encode(`${env.ADMIN_USER}:${env.ADMIN_PASSWORD}`);
// 長さが違うときも同じ比較を通し、処理時間の差から長さが分からないようにする
return a.byteLength === b.byteLength ? crypto.subtle.timingSafeEqual(a, b) : !crypto.subtle.timingSafeEqual(a, a);
}
export async function handleAdmin(request, env) {
if (!authorized(request, env)) {
return new Response("Unauthorized", { status: 401, headers: { "WWW-Authenticate": 'Basic realm="admin"' } });
}
const url = new URL(request.url);
if (request.method === "POST") {
// ほかのサイトからのフォーム送信を断る
if (request.headers.get("origin") !== url.origin) return new Response("Forbidden", { status: 403 });
const f = await request.formData();
if (url.pathname === "/admin/cancel") {
await cancelByStaff(env, f.get("booking"));
} else if (url.pathname === "/admin/book") {
// 電話の予約も、LINE と同じ関数で同じ枠を取る
const r = await reserve(env, { slotId: Number(f.get("slot")), lineUserId: null, name: f.get("name"), nowMs: Date.now() });
if (!r.ok) return new Response("この枠は埋まっています", { status: 409, headers: { "Content-Type": "text/plain; charset=utf-8" } });
} else if (url.pathname === "/admin/close") {
await env.DB.prepare("UPDATE slots SET capacity = 0 WHERE id = ?1").bind(Number(f.get("slot"))).run();
} else {
return new Response("Not Found", { status: 404 });
}
return Response.redirect(`${url.origin}/admin?date=${encodeURIComponent(f.get("date"))}`, 303);
}
const date = url.searchParams.get("date") ?? jstDate(Date.now());
const rows = await dayBoard(env, date);
const hidden = (name, value) => `<input type="hidden" name="${name}" value="${esc(value)}">`;
const body = rows
.map((r) => {
const who = r.booking_id
? `${esc(r.customer_name)}(${r.line_user_id ? "LINE" : "電話"})
<form method="post" action="/admin/cancel">${hidden("booking", r.booking_id)}${hidden("date", date)}<button>取消</button></form>`
: "空き";
return `<tr><td>${jstTime(r.starts_at)}</td><td>定員${r.capacity}</td><td>${who}</td>
<td><form method="post" action="/admin/book">${hidden("slot", r.slot_id)}${hidden("date", date)}<input name="name" required placeholder="電話のお客様"><button>入れる</button></form>
<form method="post" action="/admin/close">${hidden("slot", r.slot_id)}${hidden("date", date)}<button>閉じる</button></form></td></tr>`;
})
.join("");
const html = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>予約 ${esc(date)}</title>
<h1>${esc(date)} の予約</h1><table>${body}</table>`;
return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" } });
}いちばんreserveと
画面で
- 表示名は
エスケープする。 LINEの表示名は お客様が 自由に 決められます。 <script>alert(1)</script>という 表示名で 試すと、 <script>として 文字の まま 出ました - ほかの
サイトからの 別の送信を 断る。 OriginからのPOSTは 403に しました。 ブラウザーが Basic認証の 情報を 覚えていても、 ほかの サイトの フォームからは 操作できません - ログインは
Basic認証の ここでままに しない。 使ったのは 手元で 試すための 形です。 Cloudflareの ドキュメントでも、 Basic認証の 例は 本番向けではないと 書かれ、 本番向けの 認証には Cloudflare Accessが 勧められています。 本番では、 Accessで /adminの前に 許可した メールアドレスの ログインを 置きます。 この 設定は 試していません
もうreserveは、
業種ごとに変わるのは、枠の持ち方と聞く項目
8つの
| 業種 | 枠の単位 | 聞く項目 | 先に |
|---|---|---|---|
| パーソナルジム・スタジオ・整体院 | 担当者ごとの |
体験か |
指名を |
| 結婚式場 | 見学の |
希望日、 |
会場の |
| リフォーム会社 | 担当者が |
住所、 |
移動の |
どの
ここまでは自分で作れる、ここからは運用が重い
自分でwrangler.jsonc)を
自分で
- 1つの
枠に 決まった 定員が あり、 所要時間が 同じ - 担当者や
部屋を 選ばない - 店舗が1つ
- 受け口が
LINEと、 店が 手で 入れる 電話だけ - 決済がない
どれかが
| 足したい |
増える作業 | 続く手間 |
|---|---|---|
| 担当者と |
枠の |
シフトが |
| 日時の変更 | 新しい |
変更の |
| 外部の |
枠を |
食い |
| 複数の店舗 | すべての |
店ごとの |
| 事前の決済 | 取り消しと |
入金と |
時間の
もう
- 失敗した
イベントに 気づく 見張り。 先に 200を 返しているので、 失敗しても LINEは 再送しません。 ログを 読む 人か、 通知が 要ります - トークンを
発行し直す手順と、 担当者が 変わる ときの 引き継ぎ - LINEの
仕様が 変わった ときの 追従 line_eventsの古い 行を 消す処理と、 予約の 記録を 残す期間の 決まり - 友だち解除の
扱い。 ブロックした 人には リマインドが 届かないので、 別の 連絡手段が 要ります - データの
書き出しと、 戻す手順の 練習
予約の
やってはいけないこと
- 署名の
検証の LINEの前に JSONへ 変換する。 ドキュメントでは、 検証の前に パースや 整形を しないよう 書かれています。 中身が 同じでも 署名が 合わなくなり、 この 記事の 試験でも 401に なりました - 応答トークンを
取って 1回かぎりでおいて 後で 使う。 時間の 制限が あります。 リマインドのように 後から 送る ものは プッシュにします - 枠の
空きを、 ボタンの見せた ときの 数字で 判断する。 「残り1」は 表示した 時点の 数字です。 登録の 1文の 中で 数え直します - PostgreSQLへ
移る 上のときに SQLを そのまま 持っていく。 結果の とおり定員を 超えます - トークンや
シークレットを 漏れたコードに 書く。 疑いが あれば、 LINEの ドキュメントの とおり取り消して 発行し直します
公開前に確かめること
- 本物の
チャネルで、 コンソールの 「検証」が 成功する - iPhoneと
Androidの 両方で、 リッチメニューが 出て ボタンが 押せる - 友だち追加から
予約、 確認、 取り消しまでを 自分の スマホで 通す - 応答メッセージと
あいさつ メッセージの 自動応答を 切った - 秘密の
値が wrangler secretに入り、 gitの 履歴に 残っていない - 同じ枠に、
2台の スマホから ほぼ同時に 申し込んで、 定員どおりに 止まる - 取り消した
枠に、 次の 人が 入れる - リマインドが
日本時間の 決めた 時刻に 届く -
/adminがログインなしで 開かない - 失敗に
気づく 見張りと、 見る 人を 決めた - 止まった
ときの 連絡先を、 リッチメニューか プロフィールに 書いた
動かして確かめた範囲
2026年10月31日に、
| 項目 | バージョン・内容 |
|---|---|
| OS | macOS 26 |
| Node.js | 24.22.1 |
| wrangler | 4.156.0wrangler dev --test-scheduled、 |
| compatibility_date | 2026-10-01 |
| PostgreSQL | 18.6(embedded-postgresでpg 8.23.1) |
| LINEの |
手元の |
確かめた
wrangler devのWorkerへ 署名付きの イベントを 送り、 41項目の 試験を 3回続けて 流して、 3回とも 全項目が 通りました。 内容は、 署名、 予約の 流れ、 再送、 2度押し、 同時30件、 応答トークン切れ、 取り消し、 締め切り、 店の 画面の 認証と エスケープ、 リマインドです - 署名の
計算が opensslと一致する こと - リッチメニューの
3回の 呼び出しの 順番と、 画像の 大きさや 領域の 範囲が 公式の 条件に 収まる こと (条件の 判定は 自作の 真似る サーバーで 行った ものです) - 締め切りの
計算の 境界 - 本物の
PostgreSQLの サーバーでの 同時の 申込
確かめていない
- 本物の
LINEとの やり取りの すべて。 チャネルの 作成、 コンソールの 「検証」、 実際の Webhookの 形、 応答と プッシュの 到着、 リッチメニューの 表示、 スマホでの ボタンの 見え方は 見ていません。 真似る サーバーは ドキュメントを 読んで 書いた もので、 本物と 同じ 動きとは 限りません - Cloudflareへの
デプロイ。 本番の D1、 本番の Cron Triggers、 wrangler secret put、Cloudflare Accessは 使っていません。 D1の 同時の 動きは 手元の D1での 結果で、 本番の D1は 公式の 説明に 頼っています - 予約が
多い ときの 速さ、 D1から 外へ 出る 時間 - 試験の
コード ( test.mjs)その ものは 長いので 載せていません。 署名付きの イベントの 作り方はWebhookの 記事 のtest.mjsと同じです
本番に
npx wrangler d1 create line-booking # 出たIDを wrangler.jsonc の database_id に入れる
npx wrangler d1 execute line-booking --remote --file=schema.sql
npx wrangler secret put LINE_CHANNEL_SECRET
npx wrangler secret put LINE_CHANNEL_ACCESS_TOKEN
npx wrangler secret put ADMIN_USER
npx wrangler secret put ADMIN_PASSWORD
npx wrangler deploy自社でやる場合と、任せる場合
自社で
任せる
出典(一次情報)
確認日は
- LINE Developers
「Messaging APIを :コンソールから始めよう」 直接チャネルを 作れない こと、 公式アカウントからの 有効化、 プロバイダーを 変更できない こと - LINE Developers
「ユーザーIDを :ユーザーIDが取得する」 プロバイダーごとに 違う こと - LINE Developers
「ボットを :Webhook URLの作成する」 設定、 HTTPSと 自己署名の 証明書、 検証、 応答メッセージを 切る こと - LINE Developers
「チャネルアクセストークン」 :トークンの種類と 有効期間、 漏洩の 疑いが ある ときの 取り消し - LINE Developers
「Webhookの :HMAC-SHA256、署名を 検証する」 検証の 前に 本文を 変えない こと、 opensslの例 - LINE Developers
「メッセージ :非同期の(Webhook)を 受信する」 処理、 再送が 既定で 無効な こと、 webhookEventId - LINE Developers
「リッチメニューを :作成、使う」 画像、 既定への 設定の 順番 - LINE Developers
「ユーザー単位の :ユーザー単位のリッチメニューを 使う」 メニューの 優先 - LINE Developers
「クイックリプライを :ボタンが使う」 13個までな こと - LINE Developers
「Messaging APIリファレンス」 :応答トークンの制限、 ポストバックの dataの文字数、 日時選択アクション、 リッチメニューと 画像の 条件、 空の events、api-data.line.me、ブロックされた 相手への プッシュも 200が 返る こと - LINE Developers
「失敗した :APIリクエストを 再試行する」 X-Line-Retry-Keyの形式、 使える 送信の 種類、 24時間の 有効期間 - LINE Developers
「メッセージを :応答と送信する」 プッシュの 違い、 通数の 数え方 - LINE Developers
「Messaging APIの :通数に料金」 数えられる 送り方 - LINE Developers
「LINEミニアプリとは」 :LIFFとの関係と、 新規に 作る ときの 勧め - LINE Developers
「LIFFアプリおよびサーバーで :プロフィールをユーザー情報を 使用する」 サーバーへ 送らない こと、 IDトークンの 検証 - Cloudflare Docs
「Limits :各データベースが(D1)」 クエリを 1つずつ 処理する こと - Cloudflare Docs「Cron Triggers」:UTCで
動く こと、 手元での 試し方 - Cloudflare Docs「Context (ctx)」:
ctx.waitUntil() - Cloudflare Docs「Secrets」:
wrangler secret putと.dev.vars - Cloudflare Docs「HTTP Basic Authentication」:例が
本番向けではない こと、 timingSafeEqual、Accessの 勧め - Cloudflare Docs「Policies」:メールアドレスで
許可する ポリシー - PostgreSQL Documentation「Transaction Isolation」:Read Committedの
スナップショットと、 UPDATEの条件の 再評価 - LINEヤフー for Business
「料金プラン」 :プランごとの内容
よくある質問
LINEの予約システムは、LIFFやLINEミニアプリがなくても自作できますか?
作れます。
予約システムを自作するとき、二重予約はどう防げばよいですか?
空きの
予約管理システムの自作で、どこまでが現実的ですか?
担当者を
予約の確認やリマインドのメッセージは、月の通数に数えられますか?
LINEの
店側の画面には、最低限なにが要りますか?
日ごとの
