|
| 1 | +import { NextResponse } from "next/server"; |
| 2 | +import { getCurrentUserId } from "@/lib/auth/current-user"; |
| 3 | +import type { DailyWeather } from "@/lib/weather"; |
| 4 | + |
| 5 | +/** |
| 6 | + * 予定の日・その地点の天気予報(Open-Meteo)。 |
| 7 | + * |
| 8 | + * **Open-Meteoを選んだのは、APIキーが要らず座標と日付でそのまま引けるため。** |
| 9 | + * 天気サービスのページは日付を指定して開けない(`lib/weather.ts`)ので、 |
| 10 | + * 「その日の天気をAIに聞く」リンクは残したまま、アイコンだけを実際の予報に合わせる。 |
| 11 | + * データはCC-BY 4.0(出典表示が要る。画面のツールチップとREADMEに書いてある)。 |
| 12 | + * |
| 13 | + * **複数の地点を1回のリクエストでまとめて引く**(座標をカンマ区切りで渡せる)。 |
| 14 | + * 旅程の1画面に何十件も並ぶので、行ごとに引くと同じ日の同じ予報を何度も取りに行く。 |
| 15 | + * |
| 16 | + * `timezone=auto`は地点ごとに解決されるので、国外のスポットでも「その土地の1日」で返る。 |
| 17 | + */ |
| 18 | + |
| 19 | +/** 予報の対象にできる日の範囲。Open-Meteoの許容(過去92日〜先15日)より内側に取る */ |
| 20 | +const PAST_DAYS = 85; |
| 21 | +const FUTURE_DAYS = 14; |
| 22 | + |
| 23 | +/** 座標を丸める桁。予報の格子は数kmあるので、100m単位まで見れば十分細かい */ |
| 24 | +const COORD_DIGITS = 3; |
| 25 | + |
| 26 | +/** 1回のリクエストで引ける地点数。これより多い分は予報なしとして返す */ |
| 27 | +const MAX_POINTS = 60; |
| 28 | + |
| 29 | +const CACHE_TTL_MS = 30 * 60 * 1000; |
| 30 | +const CACHE_MAX = 2000; |
| 31 | +/** 上流へ連続で投げない間隔。無料の公開APIなので自分で間隔を空ける */ |
| 32 | +const MIN_UPSTREAM_INTERVAL_MS = 250; |
| 33 | + |
| 34 | +const cache = new Map<string, { at: number; weather: DailyWeather | null }>(); |
| 35 | +let lastUpstreamAt = 0; |
| 36 | +/** 上流への呼び出しを直列につなぐ鎖(同時に何本も投げない) */ |
| 37 | +let upstreamChain: Promise<unknown> = Promise.resolve(); |
| 38 | + |
| 39 | +interface OpenMeteoDaily { |
| 40 | + daily?: { |
| 41 | + time?: string[]; |
| 42 | + weather_code?: (number | null)[]; |
| 43 | + temperature_2m_max?: (number | null)[]; |
| 44 | + temperature_2m_min?: (number | null)[]; |
| 45 | + precipitation_probability_max?: (number | null)[]; |
| 46 | + }; |
| 47 | +} |
| 48 | + |
| 49 | +/** 今日(JST)。予報の範囲に入っているかの判定に使う */ |
| 50 | +function todayJst(): Date { |
| 51 | + const now = new Date(); |
| 52 | + return new Date( |
| 53 | + Math.floor((now.getTime() + 9 * 3600_000) / 86_400_000) * 86_400_000 |
| 54 | + ); |
| 55 | +} |
| 56 | + |
| 57 | +/** その日が予報を引ける範囲にあるか(範囲外は上流が400を返すので、投げる前に落とす) */ |
| 58 | +function inForecastRange(date: string): boolean { |
| 59 | + const target = new Date(date + "T00:00:00Z").getTime(); |
| 60 | + if (Number.isNaN(target)) return false; |
| 61 | + const today = todayJst().getTime(); |
| 62 | + return ( |
| 63 | + target >= today - PAST_DAYS * 86_400_000 && |
| 64 | + target <= today + FUTURE_DAYS * 86_400_000 |
| 65 | + ); |
| 66 | +} |
| 67 | + |
| 68 | +/** "35.658,139.701;34.702,135.495" → 座標の配列。壊れた要素はnullにして位置を保つ */ |
| 69 | +function parsePoints(raw: string): ({ lat: number; lng: number } | null)[] { |
| 70 | + return raw.split(";").map((part) => { |
| 71 | + const [lat, lng] = part.split(",").map(Number); |
| 72 | + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null; |
| 73 | + if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return null; |
| 74 | + return { lat: Number(lat.toFixed(COORD_DIGITS)), lng: Number(lng.toFixed(COORD_DIGITS)) }; |
| 75 | + }); |
| 76 | +} |
| 77 | + |
| 78 | +function cacheKey(point: { lat: number; lng: number }, date: string): string { |
| 79 | + return `${point.lat},${point.lng}|${date}`; |
| 80 | +} |
| 81 | + |
| 82 | +function readCache(key: string): DailyWeather | null | undefined { |
| 83 | + const hit = cache.get(key); |
| 84 | + if (!hit) return undefined; |
| 85 | + if (Date.now() - hit.at > CACHE_TTL_MS) { |
| 86 | + cache.delete(key); |
| 87 | + return undefined; |
| 88 | + } |
| 89 | + return hit.weather; |
| 90 | +} |
| 91 | + |
| 92 | +function writeCache(key: string, weather: DailyWeather | null): void { |
| 93 | + // 取り直せるデータなので、溢れたら丸ごと捨てる(凝った追い出しをする理由がない) |
| 94 | + if (cache.size >= CACHE_MAX) cache.clear(); |
| 95 | + cache.set(key, { at: Date.now(), weather }); |
| 96 | +} |
| 97 | + |
| 98 | +/** 上流を叩く。前回から間を空け、同時には1本しか投げない */ |
| 99 | +async function fetchUpstream( |
| 100 | + points: { lat: number; lng: number }[], |
| 101 | + date: string |
| 102 | +): Promise<(DailyWeather | null)[]> { |
| 103 | + const run = async () => { |
| 104 | + const wait = MIN_UPSTREAM_INTERVAL_MS - (Date.now() - lastUpstreamAt); |
| 105 | + if (wait > 0) await new Promise((r) => setTimeout(r, wait)); |
| 106 | + lastUpstreamAt = Date.now(); |
| 107 | + const params = new URLSearchParams({ |
| 108 | + latitude: points.map((p) => p.lat).join(","), |
| 109 | + longitude: points.map((p) => p.lng).join(","), |
| 110 | + daily: |
| 111 | + "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max", |
| 112 | + timezone: "auto", |
| 113 | + start_date: date, |
| 114 | + end_date: date, |
| 115 | + }); |
| 116 | + const res = await fetch("https://api.open-meteo.com/v1/forecast?" + params, { |
| 117 | + headers: { |
| 118 | + // 相手のログでどのアプリか分かるようにする(個人の連絡先は載せない) |
| 119 | + "User-Agent": "travel-log-personal-app/1.0", |
| 120 | + Accept: "application/json", |
| 121 | + }, |
| 122 | + }); |
| 123 | + if (!res.ok) return points.map(() => null); |
| 124 | + const body: unknown = await res.json(); |
| 125 | + // 地点が1つのときはオブジェクト、複数のときは配列で返る |
| 126 | + const list: OpenMeteoDaily[] = Array.isArray(body) |
| 127 | + ? (body as OpenMeteoDaily[]) |
| 128 | + : [body as OpenMeteoDaily]; |
| 129 | + return points.map((_, i) => { |
| 130 | + const daily = list[i]?.daily; |
| 131 | + const code = daily?.weather_code?.[0]; |
| 132 | + if (daily?.time?.[0] !== date || code == null) return null; |
| 133 | + return { |
| 134 | + code, |
| 135 | + tmax: daily.temperature_2m_max?.[0] ?? null, |
| 136 | + tmin: daily.temperature_2m_min?.[0] ?? null, |
| 137 | + pop: daily.precipitation_probability_max?.[0] ?? null, |
| 138 | + }; |
| 139 | + }); |
| 140 | + }; |
| 141 | + const queued = upstreamChain.then(run, run); |
| 142 | + // 失敗しても鎖を切らない(次のリクエストが投げられなくなるため) |
| 143 | + upstreamChain = queued.catch(() => undefined); |
| 144 | + return queued; |
| 145 | +} |
| 146 | + |
| 147 | +export async function GET(request: Request) { |
| 148 | + const userId = await getCurrentUserId(); |
| 149 | + if (!userId) { |
| 150 | + return NextResponse.json({ error: "unauthorized" }, { status: 401 }); |
| 151 | + } |
| 152 | + |
| 153 | + const { searchParams } = new URL(request.url); |
| 154 | + const date = searchParams.get("date") ?? ""; |
| 155 | + const rawPoints = searchParams.get("points") ?? ""; |
| 156 | + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { |
| 157 | + return NextResponse.json({ error: "date is required" }, { status: 400 }); |
| 158 | + } |
| 159 | + if (!rawPoints) { |
| 160 | + return NextResponse.json({ error: "points is required" }, { status: 400 }); |
| 161 | + } |
| 162 | + |
| 163 | + const points = parsePoints(rawPoints); |
| 164 | + // 範囲外の日・多すぎる地点は「予報なし」で返す。呼び出し側は |
| 165 | + // 予報が無いときの見せ方(「天気」ボタン)を必ず持っているので、エラーにはしない |
| 166 | + if (!inForecastRange(date)) { |
| 167 | + return NextResponse.json({ data: points.map(() => null) }); |
| 168 | + } |
| 169 | + |
| 170 | + const results: (DailyWeather | null)[] = points.map(() => null); |
| 171 | + const missing = new Map<string, { lat: number; lng: number }>(); |
| 172 | + points.forEach((point, i) => { |
| 173 | + if (!point || i >= MAX_POINTS) return; |
| 174 | + const key = cacheKey(point, date); |
| 175 | + const cached = readCache(key); |
| 176 | + if (cached !== undefined) results[i] = cached; |
| 177 | + else missing.set(key, point); |
| 178 | + }); |
| 179 | + |
| 180 | + if (missing.size > 0) { |
| 181 | + const keys = [...missing.keys()]; |
| 182 | + let fetched: (DailyWeather | null)[]; |
| 183 | + try { |
| 184 | + fetched = await fetchUpstream([...missing.values()], date); |
| 185 | + } catch { |
| 186 | + fetched = keys.map(() => null); |
| 187 | + } |
| 188 | + keys.forEach((key, i) => { |
| 189 | + // 取れなかったものは覚えない(通信の失敗を30分引きずらないため) |
| 190 | + if (fetched[i]) writeCache(key, fetched[i]); |
| 191 | + }); |
| 192 | + points.forEach((point, i) => { |
| 193 | + if (!point || i >= MAX_POINTS) return; |
| 194 | + const at = keys.indexOf(cacheKey(point, date)); |
| 195 | + if (at >= 0) results[i] = fetched[at] ?? null; |
| 196 | + }); |
| 197 | + } |
| 198 | + |
| 199 | + return NextResponse.json({ data: results }); |
| 200 | +} |
0 commit comments