2026年8月22日 星期六

網站上線後才是開始: Log、備份、監控與效能

封包旅行記 EP07|從部署走向營運

網站上線後才是開始:
Log、備份、監控與效能

Django網站可以開啟,只代表部署完成;智慧養殖、產銷平台與水井三寶場域真正要長期運作,還必須在出錯時找得到原因、資料遺失時救得回來、異常發生時有人知道、使用量增加時仍然回得動。

一、上線不是終點,而是系統生命週期的起點

Log|留下證據誰在何時做了什麼?在哪一層失敗?可否用Request ID串起整條路徑?
備份|保住成果資料刪錯、主機故障或程式更新失敗時,能恢復到哪個時間點?
監控|提早知道網站掛掉、API變慢、錯誤率上升或ESP32斷線時,誰會收到通知?
效能|量測改善慢在網路、Django、資料庫、檔案系統,還是外部服務?

營運能力=看得見+救得回+叫得到人+持續改善。沒有Log只能猜,沒有還原演練的備份只是希望,沒有告警的監控只是漂亮圖表,沒有量測的效能改善只是碰運氣。

二、先定義「什麼叫系統正常」

智慧養殖系統不能只監看首頁是否回200。即使網站正常,裝置可能已經兩小時沒有上傳,儀表板仍顯示昨天最後一筆水溫。

層級正常條件例失敗時代表
網站存活/health/live/能快速回200Web worker、程式或平台可能無法服務
服務就緒/health/ready/可連資料庫與必要服務網站進程活著,但尚不能完成真正工作
API品質成功率、P95延遲符合門檻部分功能壞掉或逐漸變慢
資料新鮮度各ESP32最近上傳時間未超過預期裝置、Wi-Fi、MQTT/HTTP或排程可能中斷
業務正確性溶氧警報能產生、通知並被確認技術層看似正常,但場域流程失效

三、Log第一課:不要只寫「發生錯誤」

一筆有用的Log至少要回答:時間、嚴重程度、事件、request_id、裝置或場域代碼、結果與可採取的下一步。

2026-08-22T07:45:10+08:00 INFO
event=reading.accepted request_id=7bc2...
device_id=POND01-ESP32-01 event_uuid=POND01-000062
status=201 duration_ms=84

2026-08-22T07:46:03+08:00 WARNING
event=reading.rejected request_id=2f91...
device_id=POND01-ESP32-01 reason=ph_out_of_range
status=400 duration_ms=12

應該記錄

事件名稱、Request ID、匿名或內部裝置ID、狀態碼、耗時、錯誤分類、重試次數與程式版本。

不應記錄

完整Token、密碼、Session Cookie、個資、完整信用卡資料或未遮罩的敏感Payload。

Log本身也是敏感資料。必須限制存取、設定保存期限、避免無限增長,且不能讓使用者輸入破壞Log格式或偽造紀錄。

四、Django Logging設定與使用

# settings.py:教學版,以console交給部署平台收集
LOGGING = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "verbose": {
            "format": "{asctime} {levelname} {name} {message}",
            "style": "{",
        },
    },
    "handlers": {
        "console": {
            "class": "logging.StreamHandler",
            "formatter": "verbose",
        },
    },
    "loggers": {
        "aquaculture": {
            "handlers": ["console"],
            "level": "INFO",
            "propagate": False,
        },
        "django.request": {
            "handlers": ["console"],
            "level": "WARNING",
            "propagate": False,
        },
    },
}
# views.py
import logging
logger = logging.getLogger("aquaculture.api")


def record_success(request_id, device_id, event_uuid, duration_ms):
    logger.info(
        "event=reading.accepted request_id=%s device_id=%s "
        "event_uuid=%s duration_ms=%d",
        request_id, device_id, event_uuid, duration_ms,
    )


def record_failure(request_id, device_id):
    logger.exception(
        "event=reading.failed request_id=%s device_id=%s",
        request_id, device_id,
    )

logger.exception()應在例外處理區使用,它會留下Stack Trace。Django官方建議以Python logging取得具名Logger,並透過LOGGING設定Handler、Formatter與Level。

Level用途場域例
DEBUG開發診斷細節序列化前後欄位;正式環境通常不大量開啟
INFO正常的重要事件資料接收、備份完成、裝置重新上線
WARNING尚能服務但值得注意資料過舊、重試增加、磁碟逼近門檻
ERROR某項操作失敗資料庫寫入失敗、外部通知失敗
CRITICAL系統性重大故障核心資料庫不可用或大量API全面失敗

五、Request ID:把ESP32、API與資料庫串成一條線

ESP32每筆事件已有event_uuid,HTTP請求再加入X-Request-ID。若Client未提供,Django產生新的UUID,回應時也帶回,學生便能從裝置Serial Log一路查到伺服器Log。

# middleware.py(簡化示例)
import uuid


class RequestIdMiddleware:
    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        incoming = request.headers.get("X-Request-ID", "")
        request.request_id = incoming[:80] if incoming else str(uuid.uuid4())

        response = self.get_response(request)
        response["X-Request-ID"] = request.request_id
        return response

不要無條件信任Header。限制長度與格式,避免把任意使用者輸入直接插入結構化Log;Request ID只用於追蹤,不是安全憑證。

六、PythonAnywhere上要看哪幾種Log?

Log主要內容適合回答
Access LogURL、狀態碼、回應大小與回應時間哪個API慢?錯誤率何時上升?
Error Log載入、WSGI與部署錯誤為何Reload後網站無法啟動?
Server/應用LogDjango logger、Traceback及自訂事件是哪個裝置或資料造成例外?
Task LogScheduled/Always-on Task執行結果備份、清理或監控排程是否真的完成?

平台介面與方案限制可能調整,請以PythonAnywhere目前帳號的Web與Tasks頁面為準。正式營運應建立固定巡檢責任,而不是出事後才第一次找Log。

七、備份不是複製檔案,而是一套可還原的制度

備什麼資料庫、媒體檔、環境設定、部署文件與程式版本。
多久一次依可接受資料損失RPO決定,例如每日完整+更頻繁增量。
放哪裡不可只留在同一帳號或同一主機;至少一份異地/離線。
怎麼證明定期在隔離環境還原,核對筆數、附件與核心流程。

先定義RPO與RTO

指標問題範例
RPO最多可接受遺失多久的資料?水質資料最多遺失1小時
RTO故障後多久必須恢復服務?4小時內恢復API與儀表板

3-2-1原則:至少3份資料、2種不同媒介、1份異地。再加上加密、權限、保留週期與還原演練,才形成真正的備份策略。

八、不同資料庫的備份方式不能混用

# PostgreSQL:自訂格式,使用pg_restore還原
pg_dump -Fc -d DATABASE_NAME -f backup_YYYYMMDD.dump
pg_restore --clean --if-exists -d RESTORE_TEST_DB backup_YYYYMMDD.dump

# MySQL:邏輯備份示意
mysqldump --single-transaction DATABASE_NAME > backup_YYYYMMDD.sql

# SQLite:網站低流量或停止寫入時,以sqlite3一致性備份
sqlite3 db.sqlite3 ".backup 'backup_YYYYMMDD.sqlite3'"

帳密不要直接寫在可被其他使用者看到的命令或程式碼中;應使用平台提供的安全環境變數或憑證機制。命令選項需依資料庫版本、權限與託管平台調整。

不要直接複製正在大量寫入的SQLite檔案當成可靠備份。也不要只備資料庫而忘記使用者上傳的照片、附件與對應程式版本。

還原演練的五步驟

  1. 建立隔離的空白測試資料庫。
  2. 用備份檔還原,不覆蓋正式環境。
  3. 執行Migration/相容性檢查。
  4. 核對關鍵表筆數、最新時間與附件。
  5. 用Postman/pytest跑核心API流程並記錄實際RTO。

九、監控:圖表不是目的,能採取行動才是

LatencyP50、P95與P99延遲,不只看平均值。
Traffic每分鐘請求數、活躍裝置與資料寫入量。
Errors4xx、5xx、例外與失敗工作比例。
SaturationCPU、記憶體、磁碟、連線池與Worker負荷。

智慧養殖還要增加資料新鮮度:每座魚塭最後上傳時間、離線裝置數、離線佇列深度、警報未確認時間。

監控項目告警例第一個行動
網站可用性連續3次健康檢查失敗確認平台狀態、Error Log與最近部署
API錯誤率5分鐘內5xx超過門檻依Request ID抽樣Traceback
API延遲P95連續10分鐘超標分解網路、Web、DB與外部服務時間
裝置資料新鮮度預期5分鐘上傳,15分鐘未收到查LWT、Wi-Fi、電源與離線佇列
備份排程失敗、檔案為0或超過期限停止自動刪除舊備份並人工處理
磁碟可用空間低於設定門檻查Log、媒體與備份增長來源

十、健康檢查:Liveness與Readiness要分開

# views.py(簡化示例)
from django.db import connection
from django.http import JsonResponse


def live(request):
    return JsonResponse({"status": "alive"})


def ready(request):
    try:
        with connection.cursor() as cursor:
            cursor.execute("SELECT 1")
            cursor.fetchone()
        return JsonResponse({"status": "ready"})
    except Exception:
        return JsonResponse({"status": "not_ready"}, status=503)

健康端點要快、穩定且不洩密。不要回傳資料庫密碼、Token、完整例外或伺服器內部路徑;也不要讓Liveness依賴所有外部服務,否則短暫外部故障可能引起不必要重啟。

十一、告警要包含「誰、何時、怎麼處理」

不好的告警可行動的告警
網站壞了!PROD API 5xx達12%,開始07:42,影響/api/v1/readings/,Runbook:OPS-API-01,值班:A組
裝置離線POND01-ESP32-01已15分鐘無資料,最後RSSI -76、最後LWT offline、最近event_uuid與場域聯絡方式

同一問題重複發送數百次會造成告警疲勞。應設定持續時間、合併、冷卻與恢復通知;重大告警必須有負責人及Runbook。

十二、效能第一原則:先量測,找出真正瓶頸

瀏覽器/ESP32總耗時Access Log伺服器耗時網路傳輸與連線成本

PythonAnywhere官方說明建議比較瀏覽器Network總時間與Access Log回應時間,先區分網路與Web App。接著再把Django處理拆成資料庫、檔案、外部API與Python運算。

瓶頸觀察證據常見改善
網路/TLSClient慢,但Access Log處理快縮小Payload、連線重用、選擇合適部署區域
N+1查詢清單筆數越多,SQL數量線性增加select_related()prefetch_related()
缺索引依device_id、measured_at查詢越來越慢依實際Query與執行計畫建立索引
傳太多資料API回傳數萬筆水質紀錄分頁、時間區間、只取必要欄位
同步重工作寄信、AI分析或報表拖慢Request移至背景工作並提供狀態
靜態/媒體檔大量圖片由Django直接處理正確Static/Media服務、壓縮與快取
Worker不足單次不慢,但多人同時使用就排隊縮短請求、增加合適Worker或方案資源

十三、Django ORM效能:少查、查對、只取需要的資料

# 不佳:迴圈中每次存取pond,可能形成N+1查詢
readings = PondReading.objects.all()[:100]
for reading in readings:
    print(reading.pond.name)

# 改善:ForeignKey使用select_related一次Join
readings = (
    PondReading.objects
    .select_related("pond")
    .only("event_uuid", "measured_at", "water_temp_c", "pond__name")
    .order_by("-measured_at")[:100]
)
# 常用查詢可考慮複合索引,但必須先量測
class PondReading(models.Model):
    device_id = models.CharField(max_length=80)
    measured_at = models.DateTimeField()

    class Meta:
        indexes = [
            models.Index(fields=["device_id", "-measured_at"]),
        ]

索引不是免費午餐。它會占空間並增加寫入成本;應以真實查詢、資料量與執行計畫決定。快取也會帶來資料過期與失效策略問題,不應拿來掩蓋錯誤查詢。

十四、部署後的日、週、月維運節奏

頻率建議工作
每日自動健康檢查、備份結果、5xx、裝置離線、磁碟與憑證到期監控
每週人工抽查Log、慢API、備份檔、未確認告警及使用量趨勢
每月隔離還原演練、帳號/Token盤點、套件更新評估、效能基準比較
每次部署Migration備份、部署清單、Smoke Test、版本標記與Rollback方案
每次事故後無責檢討:時間線、根因、影響、復原與防止再發措施

PythonAnywhere排程:可用Scheduled Task執行備份檢查或Django Management Command;功能與額度依帳號方案而異。排程「被建立」不代表「有成功」,必須監控Exit Code、輸出與備份新鮮度。

十五、故障演練:讓學生真的把系統救回來

No-AI|Log追蹤

教師提供ESP32 Serial Log、Access Log與Django Traceback;學生用event_uuid與Request ID重建故障時間線,找出第一個失敗點。

AI Pair|維運假設

請AI提出網站變慢的可能原因,學生依量測證據排序、駁回無證據猜測,並設計每個假設的最小驗證。

Challenge AI|災難復原

在隔離環境模擬資料誤刪、備份還原、裝置斷線與API延遲;量測RPO/RTO並完成Runbook與事後檢討。

十六、上線營運驗收清單

檢核通過證據
Log可追查可用Request ID從ESP32追到API結果,且無敏感資料
備份可還原在隔離環境成功還原並通過Postman/pytest核心測試
監控可告警網站、5xx、延遲、資料新鮮度、備份與磁碟皆有門檻
告警有人處理每項重大告警有負責人、Runbook與升級路徑
效能有基準記錄P50/P95、Query數與資料量,改善前後可比較
部署可回復每個版本有標記、Migration計畫、Smoke Test與Rollback方案

十七、延伸閱讀(官方文件)

部署平台、資料庫與Django版本會變動;正式操作前應以目前使用版本及帳號方案的官方文件為準。

封包旅行記 EP07|真正的上線,不是網址能打開;而是故障能追、資料能救、異常有人知道、系統能持續變好。

用Postman與pytest 驗證智慧養殖API

封包旅行記 EP06|從人工驗證到自動化測試

用Postman與pytest
驗證智慧養殖API

「Postman回201」不等於API已經可靠。智慧養殖API還要證明:未授權請求會被拒絕、異常水質資料不會寫入、裝置不能冒用別座魚塭、斷線重送不會產生重複紀錄,而且每次改版都能自動重測。

一、Postman與pytest不是二選一

Postman探索人工送出請求,快速觀察Header、JSON、狀態碼與回應內容。
Postman契約把環境變數、Request與斷言整理成Collection,形成可分享規格。
pytest回歸每次修改Serializer、View或Model後,自動執行相同測試。
CI守門測試失敗就阻止不合格版本進入場域或正式主機。
Postman看懂請求寫出預期結果pytest固定成測試持續整合自動執行

核心觀念:測試不是證明「程式沒有錯」,而是累積可重複的證據,證明已知的重要行為仍符合API契約。

二、先定義智慧養殖API契約

本文以POST /api/v1/readings/為例。每台ESP32使用自己的Token,送出一筆水質資料:

{
  "event_uuid": "POND01-20260822-000001",
  "device_id": "POND01-ESP32-01",
  "measured_at": "2026-08-22T07:30:00+08:00",
  "water_temp_c": 28.4,
  "dissolved_oxygen_mg_l": 5.8,
  "ph": 7.6
}
情境預期狀態必須驗證
合法Token+合法資料201 Created回傳event_uuid,資料庫只增加一筆
沒有Token或Token錯誤401 Unauthorized資料庫不變
合法Token但冒用別台device_id403 Forbidden不能跨裝置寫入
缺欄位、錯型別或超出合理範圍400 Bad Request錯誤指出欄位,資料庫不變
相同event_uuid重送依契約採200409或其他明確結果資料庫仍只有一筆,行為必須一致

狀態碼要先約定:例如重複event_uuid究竟回200或409,沒有唯一答案;團隊必須先定義契約,再讓Postman與pytest依同一規則驗證。

三、Postman第一步:建立不洩密的環境

變數開發環境例用途
base_urlhttp://127.0.0.1:8000切換本機、測試站與正式站
device_token不在教材顯示真值Authorization Header
device_idPOND01-ESP32-01測試裝置身分
event_uuid由Pre-request Script產生每次測試的唯一事件
POST {{base_url}}/api/v1/readings/
Authorization: Token {{device_token}}
Content-Type: application/json

不要把真實Token寫進Collection、截圖或Git。敏感值應保存在個人/祕密儲存範圍;分享或匯出前先檢查。測試Token也只應擁有測試裝置的最小權限。

四、Postman第二步:讓每次Request自動產生UUID

在Request的Pre-request Script中產生測試值,避免手動修改造成誤判:

const id = pm.variables.replaceIn("{{$guid}}");
const eventUuid = `POSTMAN-${id}`;

pm.collectionVariables.set("event_uuid", eventUuid);
pm.collectionVariables.set("measured_at", new Date().toISOString());
{
  "event_uuid": "{{event_uuid}}",
  "device_id": "{{device_id}}",
  "measured_at": "{{measured_at}}",
  "water_temp_c": 28.4,
  "dissolved_oxygen_mg_l": 5.8,
  "ph": 7.6
}

UUID只用來避免碰撞,不應被當成授權憑證。伺服器仍需依Token確認裝置身分。

五、Postman第三步:不要只看綠色的201

在Post-response Script加入斷言,讓Postman自動判定是否符合契約:

pm.test("建立成功:HTTP 201", function () {
  pm.response.to.have.status(201);
});

pm.test("回應為JSON", function () {
  pm.expect(pm.response.headers.get("Content-Type"))
    .to.include("application/json");
});

pm.test("回傳正確event_uuid", function () {
  const data = pm.response.json();
  pm.expect(data.event_uuid).to.eql(
    pm.collectionVariables.get("event_uuid")
  );
});

pm.test("回應時間在教學門檻內", function () {
  pm.expect(pm.response.responseTime).to.be.below(1500);
});

效能門檻要看環境:本機、校園網路與PythonAnywhere的延遲不同。應先建立基準,再設定合理門檻;單次responseTime不能取代正式負載測試。

最少建立六個Request

01 Create valid reading

合法Token、資料與201。

02 Missing token

移除Authorization,預期401且不寫入。

03 Wrong device

合法Token冒用別台device_id,預期403。

04 Missing field

缺少event_uuid或measured_at,預期400。

05 Out-of-range value

例如pH 99,預期400。

06 Duplicate UUID

相同Body送兩次,確認不產生兩筆。

六、從Postman案例轉成pytest測試矩陣

測試面向正向案例反向/邊界案例
Authentication有效Token缺Token、錯Token、撤銷Token
Authorization寫入自己裝置冒用其他device_id
Validation正常水溫、DO、pH缺欄位、字串代替數字、NaN、極端值
Idempotency新event_uuid相同event_uuid重送
Time含時區ISO 8601未來過久、過舊、格式錯誤
Persistence成功後增加一筆4xx後資料庫完全不變

七、pytest+pytest-django基本設定

pip install pytest pytest-django djangorestframework
# pytest.ini
[pytest]
DJANGO_SETTINGS_MODULE = config.settings
python_files = tests.py test_*.py *_tests.py
addopts = -q

pytest-django預設禁止未宣告的資料庫存取。需要資料庫的測試加上@pytest.mark.django_db或使用dbfixture;測試資料庫與正式資料庫應完全分離。

絕對不要讓測試指向正式智慧養殖資料庫。測試會建立、修改與回滾資料;部署前應再次確認測試settings、環境變數及資料庫名稱。

八、用Fixture建立可重複的裝置身分

# tests/conftest.py
import pytest
from django.contrib.auth import get_user_model
from rest_framework.authtoken.models import Token
from rest_framework.test import APIClient


@pytest.fixture
def device_user(db):
    User = get_user_model()
    return User.objects.create_user(
        username="pond01_device",
        password="test-only-password",
    )


@pytest.fixture
def token(device_user):
    return Token.objects.create(user=device_user)


@pytest.fixture
def api_client(token):
    client = APIClient()
    client.credentials(HTTP_AUTHORIZATION=f"Token {token.key}")
    return client


@pytest.fixture
def valid_payload():
    return {
        "event_uuid": "TEST-POND01-0001",
        "device_id": "POND01-ESP32-01",
        "measured_at": "2026-08-22T07:30:00+08:00",
        "water_temp_c": 28.4,
        "dissolved_oxygen_mg_l": 5.8,
        "ph": 7.6,
    }

實際專案需再建立「使用者/Token與device_id的授權關聯」。本文Fixture為教學骨架,請依你的Device Model與Permission調整。

九、第一組pytest:成功、未授權與資料庫結果

# tests/test_readings_api.py
import pytest
from rest_framework.test import APIClient
from aquaculture.models import PondReading

URL = "/api/v1/readings/"


@pytest.mark.django_db
def test_create_valid_reading(api_client, valid_payload):
    response = api_client.post(URL, valid_payload, format="json")

    assert response.status_code == 201
    assert response.data["event_uuid"] == valid_payload["event_uuid"]
    assert PondReading.objects.filter(
        event_uuid=valid_payload["event_uuid"]
    ).count() == 1


@pytest.mark.django_db
def test_reject_request_without_token(valid_payload):
    client = APIClient()
    before = PondReading.objects.count()

    response = client.post(URL, valid_payload, format="json")

    assert response.status_code == 401
    assert PondReading.objects.count() == before

三層斷言:同時檢查HTTP狀態、Response Body與Database State。只檢查其中一層,很容易把「回應看似成功但沒有存檔」或「拒絕了請求卻留下髒資料」漏掉。

十、參數化測試:一次驗證多個不合法數值

import pytest
from aquaculture.models import PondReading


@pytest.mark.django_db
@pytest.mark.parametrize(
    ("field", "bad_value"),
    [
        ("water_temp_c", -20),
        ("water_temp_c", 80),
        ("dissolved_oxygen_mg_l", -1),
        ("ph", -0.1),
        ("ph", 14.1),
        ("ph", "not-a-number"),
    ],
)
def test_reject_invalid_water_values(
    api_client, valid_payload, field, bad_value
):
    payload = {**valid_payload, field: bad_value}
    payload["event_uuid"] = f"BAD-{field}-{str(bad_value)}"
    before = PondReading.objects.count()

    response = api_client.post(URL, payload, format="json")

    assert response.status_code == 400
    assert field in response.data
    assert PondReading.objects.count() == before

合理範圍不是自然常數:上述界線只是測試示例。應由養殖專業、感測器規格與場域需求共同定義,並區分「資料不合法」與「數值合法但需告警」。

十一、驗證斷線重送:相同UUID不能重複入庫

@pytest.mark.django_db
def test_duplicate_event_is_idempotent(api_client, valid_payload):
    first = api_client.post(URL, valid_payload, format="json")
    second = api_client.post(URL, valid_payload, format="json")

    assert first.status_code == 201
    assert second.status_code in (200, 409)  # 依團隊契約固定成其中一種
    assert PondReading.objects.filter(
        event_uuid=valid_payload["event_uuid"]
    ).count() == 1

資料庫Model應對event_uuid建立唯一約束,不能只靠「先查再寫」;並發請求可能同時通過查詢。View/Serializer需捕捉重複情況並回傳團隊約定結果。

# models.py(概念示例)
class PondReading(models.Model):
    event_uuid = models.CharField(max_length=80, unique=True)
    device_id = models.CharField(max_length=80)
    measured_at = models.DateTimeField()
    water_temp_c = models.FloatField()
    dissolved_oxygen_mg_l = models.FloatField()
    ph = models.FloatField()

十二、不要用force_authenticate取代所有安全測試

DRF的force_authenticate()可快速繞過認證流程,適合專注測試View邏輯;但若所有測試都使用它,就無法驗證Token Header、401與真正的Authentication設定。

方法適合限制
client.credentials(...)驗證真實TokenAuthentication流程需建立Token,較接近整合測試
client.force_authenticate(user=...)隔離測試View、Serializer與Permission邏輯不會證明Authorization Header真的可用
Postman呼叫測試站驗證URL、TLS、Proxy、部署與完整HTTP路徑速度較慢,資料與環境需管理

十三、如何讀懂pytest失敗訊息

失敗先判斷不要急著做
預期201,實際401Token、Authentication Class、Header格式不要把Permission關掉
預期400,實際201Serializer是否真的驗證範圍不要改測試去接受201
資料庫筆數多一筆重複UUID、Transaction與錯誤流程不要只刪掉筆數斷言
單獨通過、整批失敗測試共享狀態、固定UUID、順序依賴不要依靠測試執行順序
本機通過、部署失敗環境變數、資料庫、時區、Proxy與套件版本不要直接在正式站手動改資料

十四、建議的專案測試目錄

project/
├─ aquaculture/
│  ├─ models.py
│  ├─ serializers.py
│  ├─ permissions.py
│  └─ views.py
├─ tests/
│  ├─ conftest.py
│  ├─ test_authentication.py
│  ├─ test_permissions.py
│  ├─ test_reading_validation.py
│  ├─ test_reading_idempotency.py
│  └─ test_reading_api.py
├─ pytest.ini
└─ requirements.txt

依失敗原因分類,比把所有測試塞進一個檔案更容易維護。測試名稱應直接描述行為,例如test_device_cannot_write_other_pond

十五、學生實作:從會按Send到能守住品質

No-AI|人工建立證據

完成六個Postman Request;每個案例記錄Request、預期狀態碼、實際回應與資料庫是否改變,並解釋原因。

AI Pair|測試案例審查

讓AI補充邊界案例,但學生要逐項判斷是否符合智慧養殖語意;把合理案例轉成Postman斷言與pytest。

Challenge AI|故意破壞API

教師提供含漏洞的Serializer或Permission;學生先寫會失敗的測試,再修程式直到通過,並證明修補沒有破壞其他功能。

十六、驗收清單

檢核項目通過證據
Postman環境可切換base_url與Token不寫死,匯出檔無祕密
正向與反向案例齊全至少涵蓋201、400、401、403與重複UUID
三層斷言HTTP、Response Body與Database State一致
測試彼此獨立任意順序與單獨執行都能通過
正式資料不受影響使用獨立測試資料庫與測試裝置憑證
修改後自動回歸pytest完整通過後才允許部署

十七、延伸閱讀(官方文件)

工具介面與API會隨版本更新;實作時應以目前安裝版本的官方文件為準。

封包旅行記 EP06|Postman讓我們看見一次請求;pytest讓每一次改版都重新證明:智慧養殖API仍然可信。

Django REST API安全第一課: HTTPS、CSRF、CORS與Token

封包旅行記 EP05|API安全邊界

Django REST API安全第一課:
HTTPS、CSRF、CORS與Token

ESP32把JSON送到Django,不只要問「送到了嗎」,還要問:途中有沒有被看見或竄改?伺服器怎麼辨認裝置?瀏覽器為何擋下請求?Cookie與Token應該用哪一套防護?

一、先記住:四個機制保護的是不同問題

HTTPS保護傳輸途中,提供加密、完整性與伺服器身分驗證。
CSRF防止瀏覽器在自動攜帶Cookie時,被惡意網站借用登入身分送出操作。
CORS是瀏覽器的跨來源讀取規則,決定哪個網頁來源可讀取API回應。
Token讓API辨認呼叫者;仍須搭配權限、過期/輪替、撤銷與HTTPS。

HTTPS+身分驗證+權限+輸入驗證才是一條完整防線。CORS不是登入機制,CSRF Token也不是API存取權杖,Token更不能取代HTTPS。

二、兩條連線路徑,安全需求並不相同

瀏覽器管理介面

人員登入Session CookieCSRF

Django後台或同站儀表板通常使用Session。瀏覽器會自動帶Cookie,因此POST、PUT、PATCH、DELETE等修改操作需要CSRF防護。

ESP32裝置API

裝置身分Authorization HeaderToken

ESP32不是瀏覽器,不受瀏覽器同源政策約束;通常使用HTTPS並在Header主動攜帶裝置Token,不使用使用者Session Cookie。

呼叫者常見驗證CSRFCORS
Django同站網頁Session Cookie修改資料時需要同來源通常不涉及
不同網域的前端SPACookie或Header Token使用Cookie驗證時仍要正確處理瀏覽器需要允許該Origin
ESP32/伺服器程式Token、API Key或更強的裝置憑證若不依賴瀏覽器自動帶Cookie,通常不是此威脅模型不由瀏覽器執行,CORS不會保護或阻擋它

三、HTTPS:先保護道路,再談通行證

HTTP明文傳輸時,Token、感測資料與控制命令可能被同網段或傳輸路徑上的攻擊者讀取或竄改。HTTPS透過TLS建立安全通道,但它不會自動判斷使用者能不能查看某座魚塭,也不會驗證JSON欄位是否合理。

ESP32驗證伺服器憑證TLS加密通道Authorization TokenDjango權限與資料驗證

Django正式環境安全設定示意

# settings.py(正式環境示意,需依部署架構調整)
DEBUG = False
ALLOWED_HOSTS = ["api.example.org"]

SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 31536000
SECURE_HSTS_INCLUDE_SUBDOMAINS = True

# 只有在「可信任的反向代理」確實設定此Header時才使用:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

不要盲目複製:HSTS與Proxy Header設定錯誤可能造成網站無法存取或偽造HTTPS判定。應先確認Nginx、平台代理及網域均已正確提供HTTPS,再逐項啟用並測試。

ESP32端也要驗證憑證。僅使用「insecure」模式雖然畫面上有HTTPS,卻放棄了伺服器身分驗證,可能受到中間人攻擊。應正確同步時間並配置可信任CA。

四、CSRF:防止別的網站借用你的Cookie

假設教師已登入Django後台,Session Cookie保存在瀏覽器。若瀏覽器被誘導開啟惡意網站,該網站可能嘗試向Django送出「刪除資料」請求;瀏覽器可能自動附上Cookie。CSRF Token用來證明這次修改操作來自可信任頁面流程。

<form method="post">
  {% csrf_token %}
  <button type="submit">更新設備名稱</button>
</form>

AJAX使用SessionAuthentication時,需取得CSRF Cookie並在不安全方法的Request Header傳送X-CSRFToken。Django REST Framework官方文件指出,SessionAuthentication下的POSTPUTPATCHDELETE需要有效CSRF Token。

不要用@csrf_exempt當作通用除錯方法。若真正需求是讓ESP32呼叫API,應建立清楚的Token驗證API View,而不是關掉整個網站的CSRF保護。

五、CORS:瀏覽器的讀取許可,不是API門鎖

Origin由通訊協定+主機+Port組成。https://dashboard.example.org中的JavaScript呼叫https://api.example.org就是跨來源;瀏覽器可能先送出OPTIONS預檢,伺服器再以CORS Header表示是否允許。

CORS能做什麼

讓受信任的前端網頁在瀏覽器中讀取API回應;限制哪些Origin、Method與Header可使用。

CORS不能做什麼

不能阻止curl、ESP32或攻擊者伺服器直接呼叫API,也不能取代Authentication與Permission。

# pip install django-cors-headers

INSTALLED_APPS = [
    # ...
    "corsheaders",
]

MIDDLEWARE = [
    "corsheaders.middleware.CorsMiddleware",
    "django.middleware.common.CommonMiddleware",
    # ...
]

CORS_ALLOWED_ORIGINS = [
    "https://dashboard.example.org",
]
CORS_URLS_REGEX = r"^/api/.*$"

# 僅Cookie跨站情境才評估開啟,並搭配CSRF與Cookie策略:
CORS_ALLOW_CREDENTIALS = False

正式環境避免CORS_ALLOW_ALL_ORIGINS = True應列出真正需要的Origin;CORS與CSRF_TRUSTED_ORIGINS是兩套不同設定,不要以為允許CORS就自動通過CSRF。

六、Token:回答「你是誰」,Permission回答「你能做什麼」

DRF內建TokenAuthentication適合教學與簡單Client–Server情境。Client以Header送出Authorization: Token <key>。官方文件也提醒,內建Token是較簡單的實作;正式系統若需要每裝置多Token、到期、輪替或細緻權限,應評估更完整的方案。

# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "rest_framework.authtoken",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
}

# 設定後執行:python manage.py migrate
# views.py
from rest_framework.authentication import TokenAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

class DeviceEventView(APIView):
    authentication_classes = [TokenAuthentication]
    permission_classes = [IsAuthenticated]

    def post(self, request):
        event_uuid = request.data.get("event_uuid")
        if not event_uuid:
            return Response({"error": "event_uuid required"}, status=400)

        # 還要驗證:此Token可否代表這台device_id、欄位型別與資料範圍
        return Response({"accepted": True, "event_uuid": event_uuid}, status=201)
curl -X POST "https://api.example.org/api/events/" \
  -H "Authorization: Token YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"event_uuid":"DEVICE-001-0001","event_type":"STORY_START"}'
一台裝置一個身分:不要把同一個管理員Token燒錄到所有ESP32。每台裝置使用獨立憑證,伺服器限制其可操作的device_id;外洩時才能只撤銷單一裝置。

七、常見迷思:看起來能用,實際上仍不安全

迷思問題正確觀念
用了HTTPS就安全任何持有Token者仍可能越權HTTPS之外還要驗證、權限、輸入驗證與日誌
開放CORS讓ESP32能連ESP32不受瀏覽器CORS限制查DNS、TLS、Token、API路徑與防火牆
CSRF錯誤就加csrf_exempt移除重要防線且混淆驗證模式Session走CSRF;裝置API走Header Token與Permission
Token放在URL比較方便可能出現在日誌、瀏覽紀錄與Referer放在Authorization Header,且避免輸出到log
前端藏起按鈕就代表沒權限攻擊者可直接呼叫API每個API在伺服器端檢查Permission與物件所有權
所有裝置共用一把Token一台外洩等於整個場域失守個別憑證、最小權限、可撤銷及輪替

八、從ESP32到Django的安全檢查順序

  1. TLS:網址是否為HTTPS?憑證鏈、主機名稱與ESP32時間是否正確?
  2. Authentication:Header格式是否正確?Token是否有效、遭撤銷或誤寫到log?
  3. Permission:此裝置能否寫入指定device_id與API?
  4. Validation:JSON欄位、型別、長度、數值範圍與時間是否合理?
  5. Idempotency:QoS或重試造成重複請求時,event_uuid是否能去重?
  6. Audit:記錄結果、裝置ID、時間與Request ID,但不得記錄完整Token。
HTTP結果判讀下一步
401 Unauthorized沒有提供或無法驗證身分檢查Authorization Header與Token狀態
403 Forbidden身分已知但不允許,或Session情境的CSRF失敗查Permission、物件所有權及CSRF日誌
400 Bad Request資料格式或欄位驗證失敗查看安全且不洩密的錯誤內容
瀏覽器顯示CORS錯誤瀏覽器無法讀取回應;伺服器仍可能已收到請求看Browser Network與Server log,確認Origin、OPTIONS及Header

九、場域最低安全基線

傳輸層

全程HTTPS、有效憑證、ESP32驗證CA、停用明文API。

身分與權限

每台裝置獨立Token、最小權限、可撤銷、定期輪替。

應用與營運

Serializer驗證、節流、UUID去重、安全日誌、備份與更新。

祕密不進Git:使用環境變數或平台祕密管理保存Django SECRET_KEY、資料庫密碼與API憑證;Token不可寫進公開程式碼、截圖或教學log。

十、學生實作:安全不是抄設定,而是建立威脅模型

No-AI|四機制分類

針對「封包被偷看、惡意網站借Cookie、前端跨網域、ESP32身分冒用」四個情境,分別判斷HTTPS、CSRF、CORS、Token誰負責,並說明誰不能解決。

AI Pair|安全審查

提供一份去除真實密鑰的settings.py與API View,請AI列出風險;學生必須逐條對照官方文件,標示接受、修正或駁回及理由。

Challenge AI|紅藍隊驗證

測試無Token、錯Token、越權device_id、重複event_uuid、錯誤Origin與無CSRF請求;完成預期狀態碼、Server log與修補證據。

十一、上線前驗收清單

檢核通過證據
HTTPS與憑證驗證HTTP會安全導向HTTPS;ESP32拒絕錯誤憑證
Session與CSRF合法表單可操作;缺少或錯誤CSRF的修改請求被拒絕
CORS最小開放只有指定Origin可由瀏覽器讀取API,且僅套用需要的路徑
Token與Permission每台裝置獨立;不可寫入別台設備資料
不洩漏祕密程式庫、log、錯誤頁與截圖均無Token或密碼
異常與撤銷演練外洩Token可單獨停用,服務不中斷且留下稽核紀錄

十二、延伸閱讀(官方文件)

安全設定會隨框架版本與部署平台調整;實作時應以目前使用版本的官方文件為準。

封包旅行記 EP05|真正的API安全,不是把錯誤訊息消掉,而是讓每條連線都能回答:誰在呼叫、能做什麼、資料是否安全、失敗後如何追查。

MQTT不只是能傳: QoS、Retain、LWT與斷線重連

封包旅行記 EP04|可靠的 IoT 訊息設計

MQTT不只是能傳:
QoS、Retain、LWT與斷線重連

感測值送得出去,只代表展示成功;真正能進入水井村智慧養殖、樹藝故事機或場域儀表板的 MQTT,還必須回答:訊息可否重複?新訂閱者需不需要立刻看到狀態?裝置突然離線時誰來通知?斷線後如何安全恢復?

一、MQTT為什麼適合IoT?

ESP32 PublisherMQTT BrokerDjango/Node-RED/儀表板 Subscriber

MQTT採用發布/訂閱模式。ESP32不必知道Django在哪裡,只要把訊息發布到Topic;Broker負責把訊息分送給訂閱者。這種解耦讓裝置、雲端與儀表板可以分別擴充。

Topicsite/pond01/water/temp
訊息分類與路由名稱
Payload{"value":28.4}
真正傳送的資料內容
BrokerMosquitto等訊息中介站,接收並轉送訊息
ClientESP32、Django服務、手機或儀表板

重點:Broker收到訊息,不等於資料一定寫入資料庫;Publisher成功送出,也不等於每個Subscriber都已完成業務處理。

二、QoS:不是越高越好,而是語意要正確

QoS 0|最多一次

送出後不等待MQTT層確認;可能遺失,但額外負擔最低。

適合高頻、下一筆可取代上一筆的即時感測值。

QoS 1|至少一次

未收到確認會重送,因此比較不易遺失,卻可能收到重複訊息。

適合警報、控制事件、播放紀錄等重要事件。

QoS 2|恰好一次

用更完整的交換流程降低重複交付,成本與延遲最高;裝置、Broker與函式庫均須支援。

適合極少數不能遺失也不能重複的交換。

資料情境建議起點為什麼
每5秒水溫QoS 0少一筆通常可由下一筆補足;仍可在裝置端另做離線保存
溶氧過低警報QoS 1不希望輕易遺失,但接收端必須能處理重複
故事播放開始事件QoS 1+事件UUID重送時以UUID去重,避免播放次數被重複計算
燈光即時亮度滑桿QoS 0最新狀態通常比每次變化都抵達更重要
QoS的保證範圍有限:它處理的是MQTT傳遞,不會替你保證Django已寫入資料庫。重要事件仍應加入event_uuid、時間戳與應用層確認。

三、Retain:讓新訂閱者立刻取得「最後狀態」

Publisher把訊息以Retain方式發布後,Broker會為該Topic保留最後一筆Retained Message。新的Subscriber一訂閱,就會立即收到它,不必等ESP32下一次發布。

適合Retain

裝置目前在線狀態、最後水溫、目前模式、韌體版本、開關的目標狀態。

不適合Retain

「餵魚一次」、「播放故事一次」、「重新啟動」等一次性命令。新裝置訂閱時若重播舊命令,可能造成危險。

TopicRetain理由
site/pond01/status/online儀表板訂閱後立即知道裝置狀態
site/pond01/telemetry/water_temp視需求可顯示最後讀值,但介面必須同時呈現時間,避免把舊值誤認成即時值
site/pond01/cmd/feed_once避免斷線重連後再次執行舊命令

清除Retained Message:依MQTT慣例,對同一Topic發布空Payload並設定Retain,可要求Broker刪除該Retained Message;實際操作仍需依使用的Client函式庫確認。

四、LWT:裝置來不及說再見時,由Broker代為通知

LWT(Last Will and Testament,遺囑訊息)是在Client連線時,預先交給Broker的一則訊息。如果ESP32因斷電、訊號中斷或程式崩潰而非正常離線,Broker會代替它發布LWT。

連線時登記:若我異常消失請發布 offline儀表板收到並告警
  1. ESP32連線Broker時設定LWT:Topic為site/pond01/status/online,Payload為offline,並設為Retain。
  2. 連線成功後,ESP32主動在同一Topic發布Retained online
  3. 若正常關機,可先發布offline再主動Disconnect;若異常消失,由Broker發布LWT。

LWT不是即時斷線感測器:Broker通常要等到Keep Alive逾時或網路連線被判定中止後才發布,因此告警時間會受Keep Alive與網路狀況影響。

五、斷線重連:不要用while把整台ESP32卡死

初學範例常用無限while (!client.connected())反覆連線。場域系統若Broker故障,主迴圈就可能被卡住,感測、按鍵、JQ6500播放與離線保存全部停擺。

正確思路

採非阻塞重連、限制頻率、逐步增加等待時間;即使MQTT離線,裝置本地功能仍要運作。

恢復後要做

重新訂閱Topic、發布online、補送離線佇列、同步目標狀態,並防止重複事件。

重連次數等待例目的
第1次1秒+隨機抖動快速恢復短暫斷線
連續失敗2、4、8、16…秒指數退避,減少對Broker與網路的壓力
到達上限例如60秒避免等待時間無限增長
為何要加Jitter(隨機抖動)?停電復電後,數十台ESP32若同時重連,可能形成「驚群效應」;稍微錯開時間可降低Broker瞬間負荷。

六、ESP32範例:LWT、Retain與非阻塞重連

#include <WiFi.h>
#include <PubSubClient.h>

WiFiClient net;
PubSubClient mqtt(net);

const char* broker = "192.168.1.10";
const char* statusTopic = "site/pond01/status/online";
const char* commandTopic = "site/pond01/cmd/#";

unsigned long nextRetryAt = 0;
unsigned long retryDelayMs = 1000;
const unsigned long maxRetryMs = 60000;

void onMessage(char* topic, byte* payload, unsigned int length) {
  // 驗證Topic、Payload、長度與權限後再執行控制
}

bool connectMqtt() {
  String clientId = "pond01-" + String((uint32_t)ESP.getEfuseMac(), HEX);

  // clientId, user, password, willTopic, willQoS, willRetain, willMessage
  bool ok = mqtt.connect(clientId.c_str(), "device_user", "device_password",
                         statusTopic, 1, true, "offline");
  if (ok) {
    mqtt.publish(statusTopic, "online", true); // Retained online
    mqtt.subscribe(commandTopic, 1);           // 重連後重新訂閱
    retryDelayMs = 1000;
  }
  return ok;
}

void maintainMqtt() {
  if (WiFi.status() != WL_CONNECTED) return;

  if (mqtt.connected()) {
    mqtt.loop();
    return;
  }

  unsigned long now = millis();
  if ((long)(now - nextRetryAt) < 0) return;

  if (!connectMqtt()) {
    unsigned long jitter = random(0, 500);
    nextRetryAt = now + retryDelayMs + jitter;
    retryDelayMs = min(retryDelayMs * 2, maxRetryMs);
  }
}

void setup() {
  Serial.begin(115200);
  mqtt.setServer(broker, 1883);
  mqtt.setCallback(onMessage);
  mqtt.setKeepAlive(30);
}

void loop() {
  maintainMqtt();
  // 感測、按鍵、播放與離線佇列仍持續執行
}

此程式著重架構,帳密與Broker位址僅為示意。不同版本的MQTT函式庫支援的QoS與API可能不同,正式使用前應查閱所採用函式庫文件;外網連線應使用TLS、憑證驗證與安全保存的憑證。

七、四個功能不能互相取代

機制解決的問題不能保證
QoSClient與Broker間的MQTT傳遞品質Django一定成功處理或寫入資料庫
Retain新訂閱者立即取得某Topic最後保留值訊息是最新現場狀態;必須搭配時間戳
LWTClient異常離線時由Broker發布預設訊息零延遲偵測,也不代表設備硬體一定故障
重連網路恢復後重新建立Session與訂閱斷線期間資料自動存在;仍需離線佇列

八、常見錯誤與診斷線索

現象可能原因先檢查
連上Broker卻收不到命令重連後忘記重新Subscribe、ACL拒絕、Topic拼錯CONNACK、訂閱結果、完整Topic與權限
儀表板一直顯示online未設LWT、LWT未Retain、狀態Topic不同斷電測試與Broker日誌
一重連就再次啟動設備一次性Command被設為Retain清除舊Retained Message並重整Topic設計
資料庫出現重複紀錄QoS 1重送或應用程式重試event_uuid、唯一約束、冪等寫入
Broker恢復後大量設備仍離線阻塞重連、重試太頻繁、驚群效應指數退避、Jitter、資源使用量
顯示最後水溫但其實是昨天Retained值缺少時間Payload加入ts,UI顯示資料年齡

九、場域Topic設計範例

site/{site_id}/{device_id}/telemetry/water_temp
site/{site_id}/{device_id}/telemetry/dissolved_oxygen
site/{site_id}/{device_id}/event/story_start
site/{site_id}/{device_id}/status/online
site/{site_id}/{device_id}/status/firmware
site/{site_id}/{device_id}/cmd/set_mode
site/{site_id}/{device_id}/ack/{command_id}
  • Telemetry:週期性量測,可依資料價值選QoS 0或1。
  • Event:一次性事實,使用QoS 1、事件UUID及伺服器去重。
  • Status:目前狀態,適合Retain並附時間戳。
  • Command:控制要求,通常不Retain;加入command_id、期限與授權。
  • Ack:設備實際接受或完成命令的應用層回覆,不能只看MQTT QoS。

十、學生實作:從「能傳」走向「可靠」

No-AI|觀察封包行為

用兩個MQTT Client測試QoS 0與1;比較Retain開關前後,新Subscriber第一次收到的內容,並記錄時間。

AI Pair|設計決策辯論

請AI分別扮演裝置工程師、Broker管理者與資料工程師,評論水溫、警報、控制命令應採用的QoS與Retain;學生查證後完成決策表。

Challenge AI|斷線演練

拔除網路、重啟Broker、讓ESP32斷電;驗證LWT、非阻塞重連、重新訂閱、離線補送、UUID去重與儀表板狀態是否正確。

十一、驗收清單

檢核項目通過證據
QoS符合資料語意說得出可遺失、可重複與不可重複的差異
Retain沒有誤用於一次性命令新Client訂閱不會觸發舊動作
LWT可辨識異常離線直接斷電後,Broker會發布Retained offline
斷線不阻塞本地功能Broker關閉時,感測與按鍵仍正常
恢復後不漏不重完成重新訂閱、佇列補送與UUID去重
安全配置完成獨立帳號、最小Topic ACL、TLS與憑證管理
封包旅行記 EP04|可靠的MQTT,不是每筆都用最高QoS,而是讓每一種訊息都有正確的生命週期、失敗策略與驗證證據。

Wi-Fi連上了, 為何資料還是送不到?

封包旅行記 EP03|分層找故障

Wi-Fi連上了,
為何資料還是送不到?

ESP32 顯示 WL_CONNECTED,只代表它已經加入無線基地台,不代表資料已抵達 Django。真正的連線,要連續通過「Wi-Fi → IP → Gateway → DNS → TCP/TLS → HTTP → 應用程式」七道關卡。

一、先破解最常見的誤會

Wi-Fi connected ≠ Internet available ≠ Server reachable ≠ Data accepted
「連上 Wi-Fi」是第一關成功;只要後面任何一層失敗,Django 儀表板仍然看不到資料。
1Wi-Fi連上 AP
2IP取得位址
3Gateway走出區網
4DNS找到伺服器
5TCP/TLS建立通道
6HTTP/App接受資料

診斷原則:不要一看到「資料沒上傳」就立刻改程式。由下往上逐層驗證,先找到第一個失敗點。

二、Wi-Fi連線只證明了什麼?

已經證明

SSID、密碼與無線訊號大致可用;ESP32 已與基地台完成關聯。

還沒證明

是否取得正確 IP、Subnet、Gateway、DNS,網路是否能通往外部。

更沒證明

Django 網址、Port、憑證、API 路徑、權杖、JSON 格式與資料庫都正確。

先把 ESP32 的網路身分印出來

#include <WiFi.h>

void printNetworkInfo() {
  Serial.println("=== NETWORK INFO ===");
  Serial.print("SSID: ");    Serial.println(WiFi.SSID());
  Serial.print("IP: ");      Serial.println(WiFi.localIP());
  Serial.print("Subnet: ");  Serial.println(WiFi.subnetMask());
  Serial.print("Gateway: "); Serial.println(WiFi.gatewayIP());
  Serial.print("DNS: ");     Serial.println(WiFi.dnsIP());
  Serial.print("RSSI: ");    Serial.println(WiFi.RSSI());
}

若 IP 是 0.0.0.0,表示尚未完成網路配置;若是 169.254.x.x,通常代表 DHCP 沒有成功提供位址。RSSI 約 -30 dBm 很強、-67 dBm 尚可,低於 -75 dBm 時傳輸容易不穩。

三、從區域網路到 Django:封包實際走哪裡?

ESP32Wi-Fi AP/RouterNAT/InternetWeb ServerDjango URLView/Database
關卡封包需要知道常見失敗
Wi-FiSSID、密碼、訊號密碼錯、2.4/5 GHz 不相容、訊號弱
IP/Subnet本機 IP 與同網段判斷DHCP 失敗、固定 IP 衝突、Subnet 設錯
Gateway/NAT跨網段的下一跳Gateway 錯、訪客網路隔離、路由器無外網
DNS網域名稱對應的 IPDNS 未配置、解析失敗、網址拼錯
TCP/TLS目標 Port 與安全連線Port 關閉、防火牆阻擋、時間或憑證錯誤
HTTPMethod、URL、Header、BodyPOST/GET 用錯、路徑錯、JSON 或 Content-Type 錯
Django路由、驗證、資料模型404、403、CSRF、500、欄位驗證失敗

四、七層診斷法:先找第一個失敗點

① 無線層

狀態WiFi.status() == WL_CONNECTED
訊號觀察 RSSI 是否持續過低或大幅波動。

② IP 配置層

確認 IP、Subnet、Gateway、DNS 不是空值;檢查固定 IP 是否與其他設備重複。

③ 本地網路層

用同一 Wi-Fi 的電腦或手機測試 Router 與區網伺服器。若訪客 Wi-Fi 開啟 Client Isolation,設備彼此可能不能連線。

④ DNS 層

先用 IP 測,再用網域測。IP 可通、網域不通,問題多半在 DNS;不要把 https:// 一起當作主機名稱解析。

⑤ TCP/TLS 層

確認 Port:HTTP 常用 80,HTTPS 常用 443。HTTPS 還要檢查 ESP32 時間、CA 憑證與 TLS 相容性。

⑥ HTTP 層

序列埠必須印出完整 URL、HTTP 回應碼與回應內容。只有「send failed」不足以定位問題。

⑦ Django 應用層

同步查看伺服器 log。請求有進來但回 4xx/5xx,代表網路大致已通,問題已經移到 API 或程式端。

⑧ 儀表板與資料庫

API 回 200/201 仍未顯示時,檢查資料是否真正寫入、查詢條件、時區、快取與前端刷新。

五、HTTP回應碼就是伺服器留下的線索

回應代表意義優先檢查
200 OK201 Created請求成功或資料已建立若畫面仍沒資料,檢查資料庫與前端查詢
301/302網址被重新導向HTTP→HTTPS、尾端斜線、網域設定
400 Bad Request請求內容無法解析JSON、必填欄位、資料型別
401/403身分驗證或權限失敗API Key、Token、CSRF、權限
404 Not Found找不到 API 路徑Django urls.py、版本路徑、尾斜線
405 Method Not AllowedHTTP 方法不被接受伺服器要 POST,裝置卻送 GET
415 Unsupported Media Type資料格式宣告不符Content-Type: application/json
500502503應用程式或伺服器端異常Django log、反向代理、服務狀態
-1 或連線逾時常是 HTTP 之前就失敗DNS、TCP、TLS、Port、防火牆

六、ESP32上傳時,至少留下這些診斷資訊

#include <HTTPClient.h>

void postEvent(const String& json) {
  const char* url = "https://example.org/api/events/";
  HTTPClient http;

  Serial.print("POST URL: "); Serial.println(url);
  Serial.print("WiFi RSSI: "); Serial.println(WiFi.RSSI());

  if (!http.begin(url)) {
    Serial.println("HTTP begin failed");
    return;
  }

  http.addHeader("Content-Type", "application/json");
  int code = http.POST(json);
  Serial.print("HTTP code: "); Serial.println(code);

  if (code > 0) {
    Serial.print("Response: "); Serial.println(http.getString());
  } else {
    Serial.print("Transport error: ");
    Serial.println(http.errorToString(code));
  }
  http.end();
}

上例用 example.org 作教材占位網址。實際部署 HTTPS 時,應依函式庫版本正確設定 CA 憑證與系統時間;不要為了省事而長期停用憑證驗證。

離線佇列很重要:IoT 裝置不應假設網路永遠在線。每筆事件加入唯一 event_uuid,失敗時先保存,恢復連線後再重送;伺服器依 UUID 去除重複資料。

七、最有效率的交叉測試

測試結果可推論的範圍
手機使用同一 Wi-Fi 也打不開 API先查 Router、DNS、伺服器或 API,不急著改 ESP32
手機可用,ESP32 不行查 ESP32 的 DNS、TLS、時間、憑證、記憶體與程式
用 IP 可連,用網域不行DNS 解析問題
HTTP 可用,HTTPS 不行TLS、CA 憑證、系統時間或 SNI
API 收得到,但回 400/403/404網路已走到應用層,查 Header、Token、路徑與資料格式
API 回 201,但儀表板沒顯示查資料庫、查詢條件、時區與前端刷新

八、場域版故障排除順序

  1. 記錄現象:時間、裝置 ID、IP、RSSI、目標網址、HTTP code。
  2. 確認影響範圍:一台裝置、同一基地台,還是所有場域都失敗?
  3. 由下往上測:Wi-Fi → DHCP → Gateway → DNS → TCP/TLS → HTTP → Django。
  4. 做單一變因測試:不要同時換 Wi-Fi、改網址又重寫程式。
  5. 恢復後驗證:即時資料與離線佇列是否都完成補送,是否產生重複事件。

好系統不只是「正常時會動」:還要在斷網時保留資料、恢復時自動重送、出錯時留下足以診斷的證據。

九、學生實作:同一問題,三種學習層次

No-AI|人工診斷

抄錄 ESP32 的 IP、Subnet、Gateway、DNS、RSSI與 HTTP code;依七層表判斷第一個失敗點,畫出封包停在哪裡。

AI Pair|協作除錯

把「去識別化後」的序列埠紀錄與 Django log 交給 AI,要求它提出三個假設、每個假設的驗證方法,以及不可直接下結論的原因。

Challenge AI|場域韌性

設計斷網佇列、指數退避重試、event_uuid 去重與狀態儀表板;實測關閉 Wi-Fi 5 分鐘後能否完整補送。

十、學習檢核

我能做到完成證據
說明 Wi-Fi connected 與資料送達的差異能指出封包必須通過的七個關卡
讀懂 ESP32 的網路配置能解釋 IP、Subnet、Gateway、DNS與RSSI
利用 HTTP code 定位問題能區分傳輸錯誤、用戶端錯誤與伺服器錯誤
完成可重現的故障報告包含時間、環境、步驟、log、假設、測試與結果
設計斷線不漏資料的 IoT 裝置具備佇列、重試、去重與恢復驗證
封包旅行記 EP03|「連上」只是狀態;能逐層驗證、找到第一個失敗點,才是真正的網路能力。

192.168.x.x是什麼? DHCP、Subnet、Gateway與NAT

封包旅行記 EP02|區域網路配置

192.168.x.x是什麼?
DHCP、Subnet、Gateway與NAT

ESP32顯示「Wi-Fi connected,IP=192.168.1.119」之後,這串數字代表什麼?它如何知道誰在同一個網路、誰必須交給Gateway,又如何透過NAT走向Internet?

Private IPDHCPSubnetGatewayARPNAT/PAT

192.168.1.119不是Internet上的地址嗎?

當ESP32、手機或電腦連上家中、學校或展場Wi-Fi後,常會得到192.168.x.x。它是區域網路中的「私有IPv4位址」,可以在內部重複使用,但不會直接在全球Internet上被路由。

最重要的觀念:192.168.1.119只說明ESP32在目前LAN中的位置;要前往Django網站,仍需知道Subnet、Gateway、DNS,並由Router執行NAT。

學習目標

看懂位址

區分私有IP、公網IP、Network Address、Host Address與Broadcast。

判斷路徑

使用Subnet Mask判斷目的地在同一LAN,還是必須交給Gateway。

解釋出網

說明DHCP給了什麼,以及NAT/PAT如何讓多台裝置共用一個公網IP。

先看一個智慧場域網路

ESP32-A192.168.1.119
ESP32-B192.168.1.120
手機192.168.1.50
RouterLAN:192.168.1.1
InternetDjango Cloud

在這個例子中,三台終端裝置都位於192.168.1.0/24。它們彼此通訊時留在LAN內;要連Django、DNS或其他外部服務時,則把封包交給192.168.1.1

一、192.168.x.x是私有IPv4位址

IPv4位址由32個bit組成,通常用四個0~255的十進位數字表示。IANA保留三段IPv4範圍供私有網路使用:

私有範圍CIDR常見場景
10.0.0.0 ~ 10.255.255.25510.0.0.0/8大型組織、雲端VPC、校園網路
172.16.0.0 ~ 172.31.255.255172.16.0.0/12企業、容器與內部服務
192.168.0.0 ~ 192.168.255.255192.168.0.0/16家庭、教室、小型場域與IoT

私有IP

只在內部網路使用,可由不同家庭或場域重複使用。例如很多人的Router都可能是192.168.1.1

公網IP

在Internet上具有全球可路由性,通常由ISP提供給Router或雲端伺服器。

私有IP不等於安全。即使裝置不能直接從Internet被路由,區域網路內仍可能有未授權存取;Router設定、Wi-Fi密碼、服務驗證、防火牆與更新仍然必要。

二、DHCP:自動發放網路設定

ESP32加入Wi-Fi後,通常不必手動輸入IP。DHCP Server(多半位於Router)透過DORA流程協助裝置取得租約。

Discover有人可以給我IP嗎?
Offer可以使用這組設定
Request我要接受這個租約
Acknowledge租約成立

DHCP不只給IP

DHCP提供範例功能
IP Address192.168.1.119ESP32在目前LAN中的位址
Subnet Mask255.255.255.0分辨Network與Host部分
Default Gateway192.168.1.1通往其他網路的預設出口
DNS Server192.168.1.1或外部DNS把Domain解析成IP
Lease Time例如24小時這組設定可使用多久
ESP32輸出網路設定
#include <WiFi.h>

void printNetworkInfo() {
  Serial.println("===== NETWORK INFO =====");
  Serial.print("IP      : "); Serial.println(WiFi.localIP());
  Serial.print("MASK    : "); Serial.println(WiFi.subnetMask());
  Serial.print("GATEWAY : "); Serial.println(WiFi.gatewayIP());
  Serial.print("DNS     : "); Serial.println(WiFi.dnsIP());
  Serial.print("RSSI    : "); Serial.println(WiFi.RSSI());
}
教學建議:每一篇ESP32連網文章都應固定輸出IP、Mask、Gateway、DNS與RSSI。這五項資訊是分層診斷的起點。

三、Subnet:誰和我在同一個網路?

IPv4位址同時包含「Network部分」與「Host部分」。Subnet Mask用1標示Network bit,用0標示Host bit。最常見的:

255.255.255.0 = /24 = 前24個bit是Network,後8個bit是Host
Network:192.168.1
Host:119
項目192.168.1.119/24的結果用途
Network Address192.168.1.0代表整個Subnet,不分配給一般Host
可用Host範圍192.168.1.1 ~ 192.168.1.254Router、ESP32、手機與電腦可使用
Broadcast Address192.168.1.255傳送給該Subnet內的所有Host
總位址數2562^(32-24)
一般可用Host數254扣除Network與Broadcast

用AND運算找Network Address

設備會把自己的IP或目的IP與Subnet Mask做bitwise AND:

IP:    192.168.1.119
Mask:  255.255.255.0
AND:   192.168.1.0   ← Network Address

若ESP32與目的設備計算出的Network Address相同,就在同一個Subnet;不同則交給Gateway。

ESP32目的位址/24判斷下一步
192.168.1.119192.168.1.50同為192.168.1.0留在LAN,先用ARP找目的MAC
192.168.1.119192.168.2.50Network不同交給Default Gateway
192.168.1.119Django公網IPNetwork不同交給Default Gateway

四、Gateway:通往其他網路的出口

取得目的IP套用Subnet Mask比較Network同Subnet:直接傳不同Subnet:交給Gateway

Default Gateway通常是Router的LAN位址,例如192.168.1.1。ESP32要連Django時,IP Packet的目的IP仍是Django Server;但目前這一跳的Wi-Fi/Ethernet Frame會先送到Gateway的MAC。

常見誤解:ESP32不是把「目的IP改成Gateway」。IP目的地仍是遠端伺服器;Gateway只是下一跳的設備。

ARP在這裡做什麼?

ESP32知道Gateway的IP,卻還不知道它在目前LAN中的MAC,因此發出ARP Request詢問:「誰是192.168.1.1?」Router回覆MAC後,ESP32才能建立這一跳的Frame。

五、NAT/PAT:讓私有IP走向Internet

私有IP不能直接在Internet上被路由。Router執行Source NAT,並常搭配Port Address Translation,讓多台內部裝置共用一個公網IP。

192.168.1.119:49152→ NAT/PAT →203.0.113.20:62001
192.168.1.120:49153→ NAT/PAT →203.0.113.20:62002
192.168.1.50:51000→ NAT/PAT →203.0.113.20:62003

上例的203.0.113.20是文件示意用公網IP。Router建立一張暫時對照表,讓Django回應到達後,可以交回正確的ESP32或手機。

位置來源目的
ESP32送往Router前192.168.1.119:49152Django-IP:443
Router送往Internet後Public-IP:62001Django-IP:443
Django回應Django-IP:443Public-IP:62001
Router交回LANDjango-IP:443192.168.1.119:49152

NAT不等於Port Forwarding

一般出站NAT讓內部設備主動連出去;Port Forwarding則把外部進站連線轉交給指定內部設備,風險與用途不同。

NAT不等於防火牆

NAT改寫位址;防火牆依規則允許或阻擋流量。兩者常在同一台Router上,但功能不同。

六、DHCP與固定IP如何選?

方式優點風險/注意適合情境
一般DHCP設定簡單、自動避免多數衝突租約更新後IP可能改變手機、筆電、一般ESP32 Client
DHCP Reservation由Router依MAC固定發同一IP需管理MAC與Router設定場域Gateway、Dashboard、Home Assistant
裝置手動Static IP不依賴DHCP租約容易設定錯Mask/Gateway/DNS或造成IP衝突封閉、受管理且有完整清冊的網路
場域建議:ESP32主動向Django送資料時,多半不需要固定IP;若其他設備要主動連回ESP32或本地Server,優先考慮DHCP Reservation,較方便集中管理。

七、常見錯誤與分層診斷

Wi-FiDHCPIPMaskGatewayDNSNAT/Internet
現象可能原因取得證據
IP顯示0.0.0.0尚未連線、DHCP失敗或狀態讀取太早WiFi.status、連線時間、DHCP Server
IP是169.254.x.x未取得正常DHCP租約,設備使用Link-localRouter DHCP服務、租約數量與網路阻擋
同LAN裝置無法互通Mask錯誤、AP Isolation、防火牆或IP衝突IP/Mask比較、ARP、Router無線隔離設定
能連Gateway,不能用DomainDNS設定或解析失敗DNS IP、Domain解析結果
能解析Domain但無法連DjangoInternet、Routing、Port、TLS或Server問題目的IP、TCP連線、TLS錯誤與HTTP狀態
偶爾換一個IP後系統失效程式依賴動態IP或租約改變DHCP Lease、Router紀錄、是否應用Reservation
兩台設備時好時壞IP衝突裝置清冊、ARP表、Router租約與MAC
不要只寫「網路不通」。診斷紀錄至少要包含:本機IP、Mask、Gateway、DNS、RSSI、目的Domain、解析IP、失敗時間與錯誤碼。

八、場域網路規劃範例

水井三寶展覽、智慧養殖或慢食平台若逐步增加設備,建議先建立網路與裝置清冊。

類型建議位址方式範例管理重點
Router/Gateway固定192.168.10.1管理密碼、韌體、防火牆、備份
本地Server/Home AssistantDHCP Reservation192.168.10.10服務Port、備份、健康檢查
ESP32感測節點DHCP192.168.10.101~150Device ID、MAC、場域位置、韌體版本
管理者手機/筆電DHCP192.168.10.51~100帳號、權限與訪客網路分離
訪客獨立Guest Network192.168.20.0/24禁止直接存取IoT與管理設備
網路規劃不是只分IP:還要處理SSID、頻段、訊號覆蓋、訪客隔離、裝置身分、更新、金鑰、資料回傳、離線機制與維運責任。

三項學生任務

No-AI|找出我的網路

記錄電腦或ESP32的IP、Mask、Gateway、DNS,計算Network、Broadcast與可用Host範圍。

AI Pair|比較三個目的IP

讓AI協助判斷哪些位於同Subnet,再由學生用AND運算驗證並修正AI解釋。

Challenge|網路故障闖關

教師設定錯誤Mask、錯誤Gateway、錯誤DNS或重複IP,學生依證據定位並完成修復報告。

學習檢核

問題應回答的核心
為什麼不同家庭都能使用192.168.1.1它是私有IP,只在各自LAN內有意義,可重複使用。
DHCP只負責分配IP嗎?還提供Mask、Gateway、DNS、Lease等設定。
192.168.1.119/24的Network與Broadcast是什麼?192.168.1.0192.168.1.255
為什麼連Django要交給Gateway?Django的目的IP不在本機Subnet。
NAT改變什麼?出站封包的私有來源IP/Port被轉成公網IP/Port,並建立回程對照。
NAT是否等於防火牆?不是;NAT做位址轉換,防火牆依安全規則管控流量。

192.168.x.x,是智慧場域走向Internet的起點

DHCP讓裝置取得設定;Subnet決定目的地是否在同一個LAN;Gateway提供通往其他網路的出口;NAT/PAT則把私有位址轉換成可在Internet上回應的連線。

看懂IP,不是記住四個數字,而是能判斷「我是誰、誰和我同網、下一跳要交給誰、如何走到雲端」。

系列定位:《封包旅行記》EP02。建議先閱讀EP01《一個封包如何從ESP32走到Django?》。本文適用於大學部網際網路應用、物聯網與智慧生活、ESP32聯網、Django雲端平台及USR場域專題。文中的公網IP 203.0.113.20屬文件示意用途,不代表實際服務位址。

一個封包如何從ESP32走到Django?

封包旅行記 EP01|大學部網際網路應用

一個封包如何從
ESP32走到Django?

按下按鈕、讀到水溫或播放一段故事之後,資料如何穿越Wi-Fi、路由器、DNS、Internet與HTTPS,最後被Django接收並寫進Database?

ESP32Wi-FiDNSTCP/TLSHTTP/JSONDjango

一個按鈕按下後,真正發生了什麼?

在智慧生活系統中,我們常看到ESP32序列監控視窗顯示「POST成功」,Django Dashboard也出現一筆新資料。看起來只是幾行程式,但在這短短幾秒內,資料其實經過多個設備、位址與協定。

本篇的重點不是再做一個能上傳資料的Demo,而是能清楚回答:資料從哪裡產生、每一層加了什麼資訊、封包如何找到伺服器,以及失敗時應該檢查哪一層。

學習目標

說得清架構

說明ESP32、AP、Router、DNS、Internet、Django與Database的責任。

看得懂封包

分辨MAC、IP、TCP、TLS、HTTP與JSON各自負責的工作。

找得到故障

使用分層診斷,不再把所有問題都歸因於「Wi-Fi不好」。

先看完整路線

感測器/按鈕
ESP32
Wi-Fi AP
Router/NAT
DNS
Internet
Django
Database

回應則沿著已建立的連線反方向返回:Database → Django → HTTPS Response → Internet → Router → ESP32。

五層看懂同一筆資料

應用層HTTP+JSON+REST API定義要送什麼資料、送到哪一個功能入口
安全/傳輸層TLS+TCP加密、建立可靠連線、排序、重送與確認
網路層IP+Routing決定封包從來源IP送往目的IP的路徑
資料連結層Wi-Fi+MAC在目前區域網路的一跳內傳送Frame
實體層2.4GHz無線電波把位元轉成無線訊號,在空氣中傳遞
重要:日常口語常把所有傳輸資料都叫「封包」,但精確來說,應用層是資料、TCP是Segment、IP是Packet、Wi-Fi/Ethernet是Frame。本篇在描述整體旅程時沿用「封包」作為易懂稱呼。

封包出發前:先定義這次事件

以水井三寶智慧互動展覽為例,使用者按下「烏龜故事」按鈕後,ESP32可以把事件整理成JSON。這份JSON是應用層要傳遞的內容,不包含MAC、IP或TCP資訊。

JSON Payload
{
  "event_uuid": "SHUIJING-001-0000000062",
  "event_type": "STORY_START",
  "story": "002",
  "source": "button",
  "network_status": "online",
  "metadata": {
    "firmware": "Layer4-V1.4",
    "jq_confirmed": true
  }
}

為什麼需要event_uuid?

網路逾時後ESP32可能重送同一事件。Django可用唯一識別碼判斷是否重複,避免同一筆資料寫入兩次。

為什麼要有metadata?

保留韌體版本、確認狀態及診斷資訊,方便日後比較不同設備與版本的行為。

八站完成一次封包旅行

01

ESP32加入Wi-Fi,取得網路身分

Association → Authentication → DHCP

ESP32先以STA模式加入無線基地台。連線成功後,通常透過DHCP取得四項重要資訊:

資訊範例用途
本機IP192.168.1.119ESP32在目前LAN中的位址
Subnet Mask255.255.255.0判斷目的地是否位於同一區域網路
Default Gateway192.168.1.1前往其他網路與Internet的出口
DNS Server192.168.1.1或ISP DNS將網域名稱解析成IP位址
檢查證據:序列監控應輸出SSID、本機IP、Gateway、DNS與RSSI。只有顯示 WiFi.status()==WL_CONNECTED,還不能證明Internet或Django已正常。
02

DNS把網域名稱翻譯成IP

Domain Name → DNS Query → Server IP

程式使用的網址可能是:

https://shuijingtreasures.pythonanywhere.com/api/events/

ESP32並不知道這個字串位於世界哪裡,因此會向DNS Server詢問:shuijingtreasures.pythonanywhere.com對應哪一個IP?DNS回覆後,ESP32才能建立後續連線。

診斷觀念:STA=ONLINE只代表取得區域網路連線。若DNS失敗,ESP32仍然無法用網域名稱連到Django。測試時可分別檢查「能否到Gateway」、「能否解析Domain」與「能否連上Server」。
03

ARP找到Gateway的MAC位址

下一跳不是遠端Django,而是本地Router

Django不在同一個Subnet,所以ESP32不會直接找Django的MAC。它先透過ARP找出Default Gateway的MAC位址,再把Wi-Fi Frame交給Router。

Wi-Fi來源MACESP32的MAC
Wi-Fi目的MACAP/Gateway在這一跳使用的MAC
IP來源位址192.168.1.119
IP目的位址DNS解析得到的Django主機IP

每經過一個路由節點,Frame的MAC資訊可能改變;但IP Packet的目的IP仍指向遠端伺服器。

04

Router執行NAT並選擇路徑

Private IP → Public IP → Internet

192.168.1.119是私有IP,不能直接在全球Internet上被路由。Router會執行NAT/PAT,把ESP32的私有IP與來源Port轉換成路由器的公網IP與暫時Port,並記錄對應關係。

位置來源目的
LAN內192.168.1.119:隨機來源PortServer-IP:443
Internet側Public-IP:轉換後PortServer-IP:443

伺服器回應抵達Router後,Router依NAT表把資料交回原本的ESP32。

05

TCP建立可靠連線

SYN → SYN-ACK → ACK

HTTPS一般建立在TCP之上。ESP32與伺服器先進行三向交握,確認雙方都能收發資料。TCP負責:

可靠性

透過Sequence Number、ACK、逾時與重送,降低資料遺失造成的錯誤。

有序傳輸

即使Segment經過不同路徑或抵達順序不同,也能重新排列成正確資料。

Timeout不一定是Django錯誤:TCP連線建立失敗,可能來自DNS錯誤、Port被阻擋、Server未回應、路由問題或訊號品質不穩。
06

TLS確認伺服器並加密資料

Certificate → Key Exchange → Encrypted Channel

因為網址使用HTTPS,TCP建立後還要進行TLS Handshake。ESP32檢查憑證是否可信、網域是否相符、憑證是否在有效期限內,再協商加密金鑰。

不要把正式系統設成「不驗證憑證」。使用不安全Client或略過憑證驗證,雖可能暫時連線成功,卻失去確認伺服器身分的能力。教學測試與正式部署應清楚區分。
07

HTTP把JSON送進Django API

POST+Header+Body

安全通道建立後,ESP32送出HTTP Request。概念上包含以下內容:

Request LinePOST /api/events/ HTTP/1.1
Hostshuijingtreasures.pythonanywhere.com
Content-Typeapplication/json
AuthorizationBearer DEVICE_TOKEN(範例,依系統設計)
Body{"event_uuid":"...","event_type":"STORY_START",...}

HTTP定義「怎麼送」,JSON定義「送什麼」,REST API定義「送到哪一個功能入口」。

08

Django驗證、處理並寫入Database

URL → View → Validation → Model → Response

Django收到Request後,通常依序進行:

  1. URL Router將 /api/events/交給對應View。
  2. 驗證Method、Content-Type、Token與JSON格式。
  3. 檢查必要欄位、資料型別及event_uuid是否重複。
  4. 使用Model寫入Database。
  5. 回傳JSON與適當HTTP Status Code。
Django View示意
import json
from django.http import JsonResponse
from django.views.decorators.http import require_POST
from .models import DeviceEvent

@require_POST
def create_event(request):
    try:
        data = json.loads(request.body)
        event_uuid = data["event_uuid"]

        event, created = DeviceEvent.objects.get_or_create(
            event_uuid=event_uuid,
            defaults={
                "event_type": data["event_type"],
                "story": data.get("story", ""),
                "source": data.get("source", "unknown"),
                "metadata": data.get("metadata", {}),
            },
        )

        return JsonResponse(
            {"ok": True, "created": created, "id": event.id},
            status=201 if created else 200,
        )
    except (KeyError, json.JSONDecodeError) as exc:
        return JsonResponse(
            {"ok": False, "error": str(exc)}, status=400
        )
成功不只看200:新增資源通常可回傳201 Created;重複事件可回200並標示created:false;格式錯誤用400;未授權用401;伺服器錯誤用500

ESP32送出HTTPS POST的示意程式

以下程式聚焦資料旅程,憑證、Token、逾時、重送與離線Queue仍需依正式系統補齊。不得把真實密鑰直接寫進公開文章或GitHub。

Arduino/ESP32示意
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <HTTPClient.h>

const char* WIFI_SSID = "YOUR_WIFI_SSID";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
const char* API_URL =
  "https://shuijingtreasures.pythonanywhere.com/api/events/";

void postEvent(const String& payload) {
  if (WiFi.status() != WL_CONNECTED) {
    Serial.println("NETWORK ERROR: Wi-Fi disconnected");
    return;  // 正式版應排入離線Queue
  }

  WiFiClientSecure client;
  // 正式版:設定並驗證正確CA憑證
  // client.setCACert(ROOT_CA);

  HTTPClient https;
  https.setTimeout(8000);

  if (!https.begin(client, API_URL)) {
    Serial.println("HTTPS BEGIN FAILED");
    return;
  }

  https.addHeader("Content-Type", "application/json");
  https.addHeader("Authorization", "Bearer DEVICE_TOKEN");

  int statusCode = https.POST(payload);
  String response = https.getString();

  Serial.printf("HTTP STATUS: %d\n", statusCode);
  Serial.println("RESPONSE: " + response);

  https.end();
}

封包裡到底包了什麼?

「封裝」可以想像成一層一層加上信封。最裡面是JSON,外面依序包上HTTP、TLS、TCP、IP與Wi-Fi Frame。

由內到外主要資訊在哪裡被處理
JSON資料事件類型、故事編號、裝置資訊ESP32程式與Django View
HTTPMethod、Path、Header、Body、Status CodeHTTPClient、Web Server、Django
TLS憑證、加密參數、加密後內容ESP32安全Client與HTTPS伺服器
TCP來源/目的Port、Sequence、ACK兩端作業系統或網路堆疊
IP來源IP、目的IP、TTLESP32、Router及Internet路由器
Wi-Fi Frame本地一跳使用的MAC與錯誤檢查ESP32、AP與區域網路

出錯時,從哪裡開始查?

PowerDeviceWi-FiIPGatewayDNSTCP/TLSHTTPAPIDatabase
現象可能層級應取得的證據
無法取得IPWi-Fi/DHCPSSID、密碼、WiFi狀態、DHCP紀錄
有IP但Domain解析失敗DNSDNS Server、解析結果、其他Domain測試
連線逾時Routing/TCP/FirewallGateway、目的IP、Port 443、Timeout位置
TLS失敗憑證/時間/網域憑證錯誤、系統時間、CA與Host名稱
HTTP 400JSON/API驗證Request Body、Content-Type、Django錯誤回應
HTTP 401/403身分與權限Token、Header、裝置權限與ACL
HTTP 500Django/DatabaseServer Log、Exception、Migration與資料庫狀態
ESP32顯示成功但Dashboard沒資料API/Database/Dashboard QueryResponse、資料表紀錄、查詢條件與時間範圍
工程判斷要用證據:不要只說「網路不穩」或「伺服器壞了」。應記錄失敗在哪一層、看到什麼狀態碼、哪一個測試成功、哪一個測試失敗。

三項學生任務

No-AI|畫出封包旅行圖

標示ESP32、AP、Router、DNS、Internet、Django與Database,並為每段寫出主要協定。

AI Pair|解釋封裝

請AI協助比較Frame、Packet、Segment與HTTP資料,再以自己的話修正與重畫。

Challenge|故障闖關

教師設定錯誤DNS、錯誤API Path、無效Token或重複event_uuid,學生用證據定位與修復。

學習檢核

問題學生應能回答的重點
ESP32已取得IP,為何仍可能無法連到Django?Gateway、DNS、Routing、TCP、TLS、HTTP與API仍可能失敗。
為什麼目的MAC不是Django伺服器的MAC?MAC只負責本地一跳;跨網路時先交給Gateway。
NAT做了什麼?把私有IP與Port轉換為公網IP與Port,並保存回程對應。
HTTP、JSON與REST API如何分工?HTTP定義傳送方式,JSON定義資料格式,REST API定義資源入口與操作。
為何需要event_uuid?讓伺服器辨識重送與重複事件,支援冪等處理。
如何證明事件已成功?同時檢查ESP32 Log、HTTP Status、Django Response、Database與Dashboard。

一個封包的旅程,就是一套系統的縮影

ESP32負責把實體事件轉成資料;Wi-Fi與Router把資料送出場域;DNS找到服務;TCP與TLS提供可靠且安全的通道;HTTP與JSON定義溝通;Django與Database把事件保存成可分析的資訊。

真正學會網際網路,不是只讓資料送成功,而是能解釋每一站、驗證每一層、修復每一種失敗。

系列定位:《封包旅行記》EP01。適用於大學部網際網路應用、物聯網與智慧生活、ESP32聯網、Django雲端平台及USR場域型專題。範例中的網址、Token、IP與資料內容均為教學示意,正式系統應採安全憑證、環境變數、裝置身分、權限控管與完整異常處理。