2026年8月22日 星期六

把社區故事放上網以前: 個資、肖像權、著作權與AI倫理

封包旅行記 EP08|地方知識的數位倫理

把社區故事放上網以前:
個資、肖像權、著作權與AI倫理

一段長者口述、一張兒童活動照、一件社區工藝、一首背景音樂,從手機進入Django網站後,就不再只是「分享」。它可能同時涉及可識別個人、人格利益、著作利用、文化詮釋與AI再製。最好的數位平台,不只是讓故事被看見,也讓說故事的人保有尊嚴、選擇與權利。

一、先從一句話開始:能拍,不代表能上網

個資照片、聲音、姓名、職業、家庭與活動經歷,可能直接或間接識別個人。
肖像與人格即使照片由你拍攝,被攝者仍有其肖像、隱私與人格利益。
著作權故事文字、照片、錄音、影片、音樂與工藝設計可能分屬不同權利人。
AI倫理AI可能誤寫、扭曲文化、洩漏資料、生成近似內容或讓人誤以為是真實紀錄。

上網前四問:這是誰的資料?誰的形象?誰的創作?誰承擔被誤用、被搜尋、被永久轉傳的風險?

本文是教育與場域治理教材,不是個案法律意見。實際爭議、敏感資料、商業授權或大型計畫,應由所屬機構法遵/法務或合格專業人士依最新法規及具體事實審查。

二、社區採集不是只有一種權利

拍攝「長者介紹姻緣花工藝」的短片,至少可能同時出現下列權利與責任:

素材層可能關係人上網前要確認
長者姓名、臉、聲音與經歷受訪者蒐集目的、公開範圍、使用期限、撤回/更正與聯絡方式
長者原創敘事或講稿講述/撰寫者是否具有創作性、是否授權錄音、改寫與公開傳輸
影片與照片攝影者、製作團隊著作權歸屬及網站、社群、教材、展覽的利用範圍
工藝作品與圖樣工藝師/共同創作者可否拍攝、改編、做成商品或交由AI再設計
背景音樂、字型與插圖各素材權利人授權條款是否涵蓋公開傳輸、剪輯與商業/非商業用途
地方傳說與共同記憶社區與敘事相關人法律之外,是否需要社區確認、共同詮釋與利益回饋

「照片是我拍的」只回答攝影著作的一部分。它沒有自動解決照片中人物的肖像、隱私、個資,也不表示可以任意利用被拍攝的工藝或背景素材。

三、個資:可識別,不只看有沒有姓名

依臺灣個人資料保護法,個人資料包含姓名、特徵、家庭、職業、聯絡方式、社會活動,以及其他得直接或間接識別自然人的資料。因此,「水井村唯一會做某工藝的82歲老師傅」即使沒有姓名,也可能被間接識別。

直接識別

姓名、清楚正臉、電話、住址、帳號、學號、車牌、完整語音自我介紹。

間接識別

村落+職業+年齡+家庭關係、地標旁住家、時間地點與罕見事件的組合。

蒐集前告知,不應只寫「同意拍照」

個資法第8條列有蒐集者名稱、目的、資料類別、利用期間/地區/對象/方式、當事人權利與不提供的影響等應告知事項。實務上可轉成易懂的六句話:

  1. 誰:由哪個學校、社群或計畫負責?聯絡窗口是誰?
  2. 為何:用於地方文化保存、USR教學、網站導覽,還是商品行銷?
  3. 收什麼:姓名、照片、聲音、故事、作品與聯絡資料哪些會被保存?
  4. 怎麼用:放在哪些網站、社群、教材與展覽?會不會交由AI處理?
  5. 多久與多遠:保存多久?可能在全球網路被讀取或轉傳嗎?
  6. 如何選擇:可以不同意某一用途嗎?如何查閱、更正、停止利用或申請下架?

同意必須可被證明,且目的不能無限擴張。「同意參加活動」不等於同意照片永久放在公開網站,更不等於同意拿去訓練AI或製作商品。

四、資料最小化:不是每個故事都要綁定真實身分

原始版本風險降低做法
完整姓名+住家位置+聯絡電話公開頁僅顯示經同意的稱呼與村落,聯絡資訊放在受控後台
兒童清楚正臉+學校班級+活動時間優先拍背影、手部作品或團體遠景;不公開可定位的作息資訊
長者健康狀況寫入故事若非敘事必要就刪除;必要時另做明確告知、評估敏感性與存取權限
原始錄音直接公開先轉錄、由受訪者校對,再選擇匿名文字版或經同意的剪輯版
照片保留GPS與裝置資訊上傳前移除EXIF定位與不必要中繼資料

最小化不是讓故事失去溫度:是只公開完成目的真正需要的資訊,把聯絡資料、原始檔與公開內容分層管理。

五、肖像權:攝影著作與被攝者權益是兩回事

臺灣法制並非以一部「肖像權法」處理所有情況;實務常從民法人格權、侵權行為、隱私及個資等脈絡判斷。司法院公開裁判資料也可見肖像被視為個人外部形象及人格利益。因此,社區網站最安全的做法仍是取得清楚、具體且可證明的同意。

場景風險判斷建議
公開活動全景,人物非主體仍須評估可識別性、活動告知與使用情境入口明確公告,另設不入鏡/識別標示機制
長者單人專訪高度可識別,且包含聲音與人生經歷採訪前說明,發布前校對,取得可證明的授權
兒童活動特寫兒少保護及未來數位足跡風險較高取得適當監護人同意,也用兒童聽得懂的方式詢問其意願
原同意為成果報告,後改作商品廣告用途明顯改變重新取得對應用途的同意,不沿用模糊舊表單
AI把真實長者做成年輕化代言人可能改變形象、語意與商業意涵另行取得明確同意,標示為AI合成,不虛構本人言論

倫理標準可高於最低法律標準:即使某些拍攝可能有法律上的例外或權衡空間,USR合作更重視長期信任;對方不舒服,就應提供不入鏡、匿名、更換照片或下架的可行選項。

六、兒童與長者:同意之外還要避免權力壓力

兒童

適當取得法定代理人/監護人的同意之外,也應向兒童用簡單語言說明;孩子拒絕拍攝、當下不舒服或後來反悔,都要有替代參與方式。

長者

避免因老師、學生或計畫團隊的身分造成「不好意思拒絕」。以口語慢慢說明,不把簽名當成唯一理解證據,必要時讓家屬或信任者協助。

不同意不應失去活動資格或服務。除非影像或資料確為活動不可缺少的必要部分,否則應提供不入鏡貼紙、特定座位、局部拍攝或不公開版本。

七、著作權:買到作品,不等於買到著作權

著作權法保護語文、音樂、美術、攝影、視聽、錄音、電腦程式等著作。把素材放上公開網站通常涉及重製及公開傳輸;修改、翻譯、剪輯或AI轉換還可能涉及改作。

所有權擁有木雕、照片沖印或手稿原件。
著作財產權控制重製、公開傳輸、改作等利用。
著作人格權公開發表、姓名表示與禁止不當歪曲等。
授權權利人允許特定人在約定範圍利用。

常見錯誤:社區買下工藝作品,就以為能掃描、製成Logo、AI改圖及販售周邊。原件所有權與著作權是不同問題,應以契約確認。

八、授權書要說清楚什麼?

欄位應說清楚的內容
素材哪一段故事、哪幾張照片、哪件作品、哪個錄音/影片
權利與行為拍攝、錄音、重製、剪輯、改寫、翻譯、公開傳輸、展示或製作教材
媒介與對象特定網站、社群、簡報、展覽、出版品或合作夥伴
期間與地區何時開始、多久、是否全球網路公開
營利與否僅非營利教育,或包含募款、商品與商業宣傳
姓名表示本名、別名、團體名、匿名,及署名位置
AI利用是否允許轉錄、摘要、翻譯、生成圖、聲音模擬或模型訓練
撤回/下架聯絡窗口、處理期限及已散布素材的實際限制
報酬與回饋是否有費用、成品、署名、收益分享或社區回饋

授權契約應配合實際權利關係與機構規範;上表是教學檢核,不是可直接取代法律審查的定型契約。

九、「教育用途」不等於全部都可以用

著作權法第52條等規定在特定條件下容許為教學、研究等正當目的,在合理範圍引用已公開著作;第64條要求特定利用明示出處,第65條則列出目的與性質、著作性質、利用質量比例、對市場影響等判斷因素。

問題比較安全的判斷方式
只要註明來源就能整張使用?不一定。標示來源不能取代授權,也不能自動成立合理使用。
非營利網站就一定合理使用?不一定。非營利只是考量因素之一,還要看比例、必要性與市場影響。
網路搜尋得到就是公開素材?公開可看不代表自由重製。先查權利人、授權條款或改用合法開放素材。
改顏色或裁切就變成自己的?不一定,仍可能涉及原著作的重製或改作。
引用地方新聞全文可以嗎?應評估必要範圍;優先摘要、短引、清楚標示並連結原文。

十、AI倫理:AI可以協作,但不能代替責任

AI用途主要風險治理做法
整理長者訪談把方言、年代或人物關係整理錯誤保留原始檔,逐段查證,發布前由受訪者或社區代表校對
生成地方故事AI補出不存在的傳說或把推測寫成史實區分史料、口述、推論與創作,不得把生成內容冒充真實見證
製作人物圖片冒用真實人形象、改變年齡或虛構發言取得明確同意,清楚標示AI合成,避免誤導
模擬長者聲音高度人格、詐騙及錯誤歸因風險原則上不做;如有必要,另行明確授權、限制用途及加上顯著揭露
上傳訪談給雲端AI個資、未公開文化知識可能被外部服務處理先去識別化、確認契約與資料政策,未經授權不輸入敏感內容
生成工藝衍生設計掩蓋原作者、近似他作、利益未回到社區保留創作歷程、權利檢查、署名、共同決策與收益/回饋機制

三不一要:不把個資直接丟給AI、不把AI幻覺當史實、不把AI生成冒充長者原話;要保留人類查證、編修、決策與負責的紀錄。

十一、AI生成內容的著作權,不要寫得太肯定

經濟部智慧財產局近年公開說明指出:若AI只是輔助工具,且有人類實際創意投入,相關創作成果部分仍可能受保護;若完全由AI演算獨立完成、沒有人類實際創意投入,則可能無法享有著作權。是否受保護或侵權,仍須依個案事實判斷。

保存人類創作證據

訪談研究、草稿、提示詞演進、選擇與編排、手工修改、事實查核及版本紀錄。

檢查輸出風險

反向搜尋圖像、檢查Logo與角色近似、查音樂與字型授權、避免要求模仿特定在世創作者。

「AI產生」不代表無人有權,也不代表一定能商用。還要確認AI服務條款、輸入素材權利、輸出是否與他人著作實質近似,以及真實人物與商標等其他權利。

十二、地方文化還有「法律之外」的倫理

共同詮釋由社區確認名稱、脈絡與禁忌,不由外部團隊單方面定義。
尊重不公開祭儀、家族故事、位置資訊或技藝祕訣可能不適合全面上網。
利益回饋流量、品牌、商品或研究成果應讓貢獻者及社區看得見回饋。
可持續修正網站應提供更正、補充、下架及版本紀錄,不把首次採集當永久定稿。

地方故事不是免費原料。USR的價值是與社區共同保存與再創造,而不是把故事帶走、由學校署名,再將社區留在網站之外。

十三、建立「紅黃綠」內容分級

等級內容例處理方式
可公開權利清楚的場域介紹、已授權作品、去識別化成果編輯校對後公開,保留來源與授權紀錄
限條件可識別人物、兒童活動、生活經歷、未確認口述史取得具體同意、縮小範圍、校對並設定期限/權限
暫不公開健康、財務、家庭衝突、精確住址、祭儀禁忌、未授權工藝祕訣不放公開網站;必要時受控保存並由專責人員審查

十四、從採集到下架:八道倫理閘門

目的告知同意/授權最小採集共同校對分級發布持續管理更正/下架
  1. 目的:先寫清楚為何需要這份素材,沒有必要就不蒐集。
  2. 告知:用對方聽得懂的方式說明用途、公開程度與AI處理。
  3. 同意/授權:個資、肖像與著作利用分開確認,保存證據。
  4. 最小採集:只取需要內容,原始檔與公開檔分層保存。
  5. 共同校對:人物、年代、地名、方言、署名與文化脈絡由社區確認。
  6. 分級發布:公開、限閱或不公開;設定必要的期限與權限。
  7. 持續管理:盤點授權到期、外部連結、AI標示與存取紀錄。
  8. 更正/下架:提供明確窗口、處理時限、備份與搜尋快取的後續處理。

十五、網站後台也要支援倫理治理

# Django Model概念:不要只存content,還要存治理資訊
class CommunityAsset(models.Model):
    title = models.CharField(max_length=200)
    visibility = models.CharField(max_length=20)  # public / restricted / private
    consent_record = models.FileField(upload_to="consents/", blank=True)
    rights_holder = models.CharField(max_length=200, blank=True)
    license_scope = models.TextField(blank=True)
    expires_at = models.DateTimeField(null=True, blank=True)
    ai_assisted = models.BooleanField(default=False)
    ai_disclosure = models.TextField(blank=True)
    review_status = models.CharField(max_length=20, default="pending")
    takedown_contact = models.EmailField()
    published_at = models.DateTimeField(null=True, blank=True)

授權書本身含有個資,不應放在公開Media URL。應使用受控儲存、權限檢查、加密與存取紀錄;公開頁只顯示必要的授權狀態與署名。

十六、發布頁面的透明標示範例

故事提供|王○○老師(依本人意願部分匿名)
訪談整理|尚虎雲USR學生團隊
影像拍攝|○○○
社區校對|水井村文化工作小組
AI協作說明|AI協助語句整理與版面草擬;
             人名、年代、地方用語及最終內容由團隊與受訪者查核。
授權範圍|本網站非營利教育展示;未經同意不得另行商業利用。
更正/下架|請聯絡:project@example.org
更新日期|2026-08-22

透明標示不是把責任推給AI,而是讓讀者知道內容如何形成、誰查核、可怎麼聯絡,也讓社區貢獻被看見。

十七、學生實作:從「會上傳」走向「負責任發布」

No-AI|權利拆解

選一段社區採訪影片,逐項列出人物、個資、故事、攝影、音樂、工藝與平台權利;任何一項不清楚就不得按發布。

AI Pair|倫理紅隊

提供已去識別化的文章草稿,請AI找風險;學生對照法規與場域倫理,判斷哪些建議正確、過度或不足,完成修訂紀錄。

Challenge AI|治理流程

設計Django素材後台:授權範圍、公開分級、到期提醒、AI揭露、審查、版本與下架流程,並以兒童活動照及長者口述做情境驗收。

十八、發布前60秒檢核

問題通過證據
有明確目的與合法/適當基礎嗎?告知內容、同意或其他依據可被查證
人物真的知道會公開上網嗎?用途、媒介、期間、AI處理與下架方式說明清楚
兒童與長者能自由說不嗎?有替代參與方案,拒絕不影響權益
每個素材都有權利來源嗎?照片、音樂、字型、故事與工藝均有授權或適用依據
只公開必要資料嗎?已移除住址、聯絡資料、EXIF及不必要背景人物
內容經本人/社區校對嗎?人名、年代、方言、文化脈絡與署名已確認
AI角色透明且可追查嗎?有AI協作標示、人工查核與版本紀錄
出錯能更正或下架嗎?公開聯絡窗口、內部處理人與時限

十九、延伸閱讀(臺灣官方來源)

個資法頁面顯示部分修正條文施行日期尚待決定;實作時應確認當下有效條文、所屬機構規範與個案事實。

封包旅行記 EP08|把故事放上網,不只是把檔案公開;而是用技術替每一位說故事的人保留選擇、署名、尊嚴與回家的路。

網站上線後才是開始: 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屬文件示意用途,不代表實際服務位址。