[{"data":1,"prerenderedAt":7736},["ShallowReactive",2],{"portfolio-list":3},[4,791,1310,2308,3051,4133,4489,4854,5451,6254,6663,7277],{"id":5,"title":6,"award":7,"body":8,"cover":765,"description":222,"extension":766,"featured":280,"link":7,"meta":767,"navigation":280,"order":768,"path":769,"pinned":280,"repo":770,"role":771,"seo":772,"stack":773,"stem":787,"summary":788,"year":789,"__hash__":790},"portfolio\u002Fportfolio\u002Fecommerce-microservices-observability.md","電商微服務可觀測性專案",null,{"type":9,"value":10,"toc":754},"minimark",[11,15,28,32,39,101,132,136,139,146,167,174,178,181,584,587,594,636,655,658,661,705,708,740,743,750],[12,13,14],"h2",{"id":14},"專案概述",[16,17,18,19,23,24,27],"p",{},"一個以訂單（Order）、付款（Payment）、物流（Shipping）三個微服務組成的電商練\n習專案，目的不是做出完整電商功能，而是紮實地練習 ",[20,21,22],"strong",{},"DDD 戰略與戰術設計、\nEvent Sourcing"," 與",[20,25,26],{},"可觀測性（Observability）"," 在 .NET 微服務中的落地方\n式。每個服務都各自擁有獨立資料庫，並透過領域事件串接彼此的業務流程。",[12,29,31],{"id":30},"ddd-戰略設計strategic-design","DDD 戰略設計（Strategic Design）",[16,33,34,35,38],{},"三個服務對應三個獨立的 ",[20,36,37],{},"Bounded Context","，各自有自己的資料庫、程式碼庫與通\n用語言（Ubiquitous Language），彼此不共用資料表、也不直接呼叫對方的 API：",[40,41,42,57],"table",{},[43,44,45],"thead",{},[46,47,48,51,54],"tr",{},[49,50,37],"th",{},[49,52,53],{},"核心聚合",[49,55,56],{},"負責的通用語言",[58,59,60,75,88],"tbody",{},[46,61,62,66,72],{},[63,64,65],"td",{},"OrderService",[63,67,68],{},[69,70,71],"code",{},"Order",[63,73,74],{},"建立訂單、確認付款、揀貨、完成訂單、取消訂單",[46,76,77,80,85],{},[63,78,79],{},"PaymentService",[63,81,82],{},[69,83,84],{},"Payment",[63,86,87],{},"請款、付款完成、付款失敗、取消付款",[46,89,90,93,98],{},[63,91,92],{},"ShippingService",[63,94,95],{},[69,96,97],{},"Shipment",[63,99,100],{},"請求出貨、已派送、已送達",[16,102,103,104,107,108,110,111,114,115,117,118,120,121,124,125,128,129,131],{},"Context 之間",[20,105,106],{},"只透過 Kafka 上的領域事件溝通","，不共享資料庫、不同步呼叫對\n方，屬於以「Published Language（事件即契約）」串接的 Context Mapping：\n",[69,109,65],{}," 發布 ",[69,112,113],{},"OrderCreatedEvent"," 後即結束自己的職責，完全不知道\n",[69,116,79],{}," 內部如何處理請款；",[69,119,79],{}," 處理完也只發布\n",[69,122,123],{},"PaymentCompletedEvent"," \u002F ",[69,126,127],{},"PaymentFailedEvent","，同樣不知道 ",[69,130,65],{},"\n收到後會做什麼。這種鬆耦合讓三個服務可以獨立開發、獨立部署、獨立擴展。",[133,134,135],"h3",{"id":135},"事件流程示意圖",[16,137,138],{},"下圖是訂單成立、付款、出貨（以及付款失敗導致取消訂單）的完整事件流：",[16,140,141],{},[142,143],"img",{"alt":144,"src":145},"電商訂單事件流程圖","\u002Fimages\u002Fportfolio\u002Fecommerce-order-event-flow.png",[147,148,149,159],"ul",{},[150,151,152,155,156],"li",{},[20,153,154],{},"成功路徑","：",[69,157,158],{},"User → 建立訂單 → OrderCreated → 發起支付請求 → PaymentRequested → 支付完成 → PaymentCompleted → 確認付款 → OrderPaid → 發起配送請求 → ShippingRequested",[150,160,161,155,164],{},[20,162,163],{},"失敗路徑",[69,165,166],{},"支付失敗 → PaymentFailed → 取消訂單 → OrderCancelled",[16,168,169,170,173],{},"整個流程是典型的",[20,171,172],{},"事件編舞（Choreography）","：沒有一個中控服務下指令，每個\n服務只是「訂閱自己關心的事件、做完事、發布下一個事件」，服務間的協作關係完全\n從事件的訂閱／發布關係中浮現出來。",[12,175,177],{"id":176},"ddd-戰術設計tactical-design","DDD 戰術設計（Tactical Design）",[16,179,180],{},"在單一 Bounded Context 內部，則落實了完整的戰術設計工具箱：",[147,182,183,487,510,537,548,565],{},[150,184,185,155,188,190,191,190,193,195,196,199,200,203,204,207,208,211,212,215,216],{},[20,186,187],{},"Aggregate Root + Event Sourcing",[69,189,71],{},"、",[69,192,84],{},[69,194,97],{}," 都繼承\n共用的 ",[69,197,198],{},"AggregateRoot\u003CTId>","。狀態",[20,201,202],{},"不能被直接賦值修改","，只能透過\n",[69,205,206],{},"Apply(DomainEvent)"," 產生事件、在 ",[69,209,210],{},"When()"," 中依事件重建狀態，並在\n",[69,213,214],{},"EnsureValidState()"," 中檢查不變條件（Invariant），修改狀態與驗證規則被綁在\n一起，不會有「改了忘記驗證」的問題\n",[217,218,223],"pre",{"className":219,"code":220,"language":221,"meta":222,"style":222},"language-csharp shiki shiki-themes github-dark","public void ConfirmPayment()\n{\n    Apply(new OrderPaidEvent(Id, ShipmentAddress));\n}\n\nprotected override void When(DomainEvent domainEvent)\n{\n    switch (domainEvent)\n    {\n        case OrderCreatedEvent e:\n            Status = Status.PendingPayment;\n            break;\n        case OrderPaidEvent:\n            Status = Status.Paid;\n            break;\n        \u002F\u002F ...\n    }\n}\n\nprotected override void EnsureValidState()\n{\n    if (Status == Status.Cancelled && string.IsNullOrEmpty(CancellationReason))\n        throw new OrderDomainException(\"Cancellation reason is required.\");\n}\n","csharp","",[69,224,225,245,251,269,275,282,307,312,321,327,342,354,363,372,382,389,396,402,407,412,426,431,461,482],{"__ignoreMap":222},[226,227,230,234,237,241],"span",{"class":228,"line":229},"line",1,[226,231,233],{"class":232},"snl16","public",[226,235,236],{"class":232}," void",[226,238,240],{"class":239},"svObZ"," ConfirmPayment",[226,242,244],{"class":243},"s95oV","()\n",[226,246,248],{"class":228,"line":247},2,[226,249,250],{"class":243},"{\n",[226,252,254,257,260,263,266],{"class":228,"line":253},3,[226,255,256],{"class":239},"    Apply",[226,258,259],{"class":243},"(",[226,261,262],{"class":232},"new",[226,264,265],{"class":239}," OrderPaidEvent",[226,267,268],{"class":243},"(Id, ShipmentAddress));\n",[226,270,272],{"class":228,"line":271},4,[226,273,274],{"class":243},"}\n",[226,276,278],{"class":228,"line":277},5,[226,279,281],{"emptyLinePlaceholder":280},true,"\n",[226,283,285,288,291,293,296,298,301,304],{"class":228,"line":284},6,[226,286,287],{"class":232},"protected",[226,289,290],{"class":232}," override",[226,292,236],{"class":232},[226,294,295],{"class":239}," When",[226,297,259],{"class":243},[226,299,300],{"class":239},"DomainEvent",[226,302,303],{"class":239}," domainEvent",[226,305,306],{"class":243},")\n",[226,308,310],{"class":228,"line":309},7,[226,311,250],{"class":243},[226,313,315,318],{"class":228,"line":314},8,[226,316,317],{"class":232},"    switch",[226,319,320],{"class":243}," (domainEvent)\n",[226,322,324],{"class":228,"line":323},9,[226,325,326],{"class":243},"    {\n",[226,328,330,333,336,339],{"class":228,"line":329},10,[226,331,332],{"class":232},"        case",[226,334,335],{"class":239}," OrderCreatedEvent",[226,337,338],{"class":239}," e",[226,340,341],{"class":243},":\n",[226,343,345,348,351],{"class":228,"line":344},11,[226,346,347],{"class":243},"            Status ",[226,349,350],{"class":232},"=",[226,352,353],{"class":243}," Status.PendingPayment;\n",[226,355,357,360],{"class":228,"line":356},12,[226,358,359],{"class":232},"            break",[226,361,362],{"class":243},";\n",[226,364,366,368,370],{"class":228,"line":365},13,[226,367,332],{"class":232},[226,369,265],{"class":239},[226,371,341],{"class":243},[226,373,375,377,379],{"class":228,"line":374},14,[226,376,347],{"class":243},[226,378,350],{"class":232},[226,380,381],{"class":243}," Status.Paid;\n",[226,383,385,387],{"class":228,"line":384},15,[226,386,359],{"class":232},[226,388,362],{"class":243},[226,390,392],{"class":228,"line":391},16,[226,393,395],{"class":394},"sAwPA","        \u002F\u002F ...\n",[226,397,399],{"class":228,"line":398},17,[226,400,401],{"class":243},"    }\n",[226,403,405],{"class":228,"line":404},18,[226,406,274],{"class":243},[226,408,410],{"class":228,"line":409},19,[226,411,281],{"emptyLinePlaceholder":280},[226,413,415,417,419,421,424],{"class":228,"line":414},20,[226,416,287],{"class":232},[226,418,290],{"class":232},[226,420,236],{"class":232},[226,422,423],{"class":239}," EnsureValidState",[226,425,244],{"class":243},[226,427,429],{"class":228,"line":428},21,[226,430,250],{"class":243},[226,432,434,437,440,443,446,449,452,455,458],{"class":228,"line":433},22,[226,435,436],{"class":232},"    if",[226,438,439],{"class":243}," (Status ",[226,441,442],{"class":232},"==",[226,444,445],{"class":243}," Status.Cancelled ",[226,447,448],{"class":232},"&&",[226,450,451],{"class":232}," string",[226,453,454],{"class":243},".",[226,456,457],{"class":239},"IsNullOrEmpty",[226,459,460],{"class":243},"(CancellationReason))\n",[226,462,464,467,470,473,475,479],{"class":228,"line":463},23,[226,465,466],{"class":232},"        throw",[226,468,469],{"class":232}," new",[226,471,472],{"class":239}," OrderDomainException",[226,474,259],{"class":243},[226,476,478],{"class":477},"sU2Wk","\"Cancellation reason is required.\"",[226,480,481],{"class":243},");\n",[226,483,485],{"class":228,"line":484},24,[226,486,274],{"class":243},[150,488,489,155,492,190,495,190,498,501,502,505,506,509],{},[20,490,491],{},"強型別 ID（Value Object）",[69,493,494],{},"OrderId",[69,496,497],{},"PaymentId",[69,499,500],{},"ShipmentId","、\n",[69,503,504],{},"MemberId"," 都是獨立的 Value Object，而不是裸的 ",[69,507,508],{},"Guid","，避免把訂單 ID 誤傳\n成會員 ID 這種編譯期抓不到的錯誤",[150,511,512,155,515,517,518,521,522,501,525,528,529,532,533,536],{},[20,513,514],{},"狀態機驅動",[69,516,71],{}," 從 ",[69,519,520],{},"PendingPayment"," 開始，依序經過 ",[69,523,524],{},"Paid",[69,526,527],{},"PickingInProgress","，最終到達 ",[69,530,531],{},"Completed","，或在任何節點被 ",[69,534,535],{},"Cancelled","；狀\n態轉換完全由事件決定，畫面上看到的狀態永遠是「套用過哪些事件」的結果",[150,538,539,542,543,190,545,547],{},[20,540,541],{},"事件即事實，而非指令","：領域事件一律用過去式命名\n（",[69,544,113],{},[69,546,123],{},"），代表「已經發生的事\n實」，而不是「請你做什麼」的命令，這也是它能安全地被多個服務訂閱、不需要協\n調誰先誰後的原因",[150,549,550,553,554,124,557,560,561,564],{},[20,551,552],{},"Null Object Pattern","：以 ",[69,555,556],{},"INullObject",[69,558,559],{},"Order.Null"," 取代 ",[69,562,563],{},"null"," 判\n斷，减少呼叫端重複的防禦性檢查",[150,566,567,155,570,573,574,501,577,190,580,583],{},[20,568,569],{},"事件溯源基礎設施",[69,571,572],{},"AggregateRoot"," 內建 ",[69,575,576],{},"Version",[69,578,579],{},"GetUncommittedEvents()",[69,581,582],{},"Load(history)","，聚合可以完全由事件歷史重建，而\n非只依賴目前快照",[12,585,586],{"id":586},"系統架構",[16,588,589,590,593],{},"三個服務都採用 ",[20,591,592],{},"Hexagonal Architecture（Ports & Adapters）","，並拆成獨立的\n專案分層：",[147,595,596,602,618,624,630],{},[150,597,598,601],{},[69,599,600],{},"Entities","：聚合根與領域事件，對應各服務的狀態機",[150,603,604,607,608,501,611,190,614,617],{},[69,605,606],{},"UseCase","：應用層服務與 In\u002FOut Port 介面（如 ",[69,609,610],{},"CreateOrderService",[69,612,613],{},"RequestPaymentService",[69,615,616],{},"DispatchOrderService","）",[150,619,620,623],{},[69,621,622],{},"Adapter.Out","：以 EF Core 實作的資料庫存取層與 Migration",[150,625,626,629],{},[69,627,628],{},"WebApi","：Minimal API 端點與事件訂閱者（Event Handler）",[150,631,632,635],{},[69,633,634],{},"MainComponent","：各服務的組裝根（DI 註冊）",[16,637,638,639,642,643,646,647,650,651,654],{},"共用套件則抽成獨立專案：",[69,640,641],{},"E-Commerce.SeedWork","（AggregateRoot、DomainEvent 等\nDDD 基礎建構）、",[69,644,645],{},"E-Commerce.EventBus","（以 Wolverine 包裝 Kafka 的領域事件匯流\n排）、",[69,648,649],{},"E-Commerce.Diagnostics","（Tracing／Metrics／AOP）。並以 ",[20,652,653],{},".NET Aspire","\n作為本機開發時的服務編排（AppHost）。",[12,656,657],{"id":657},"可觀測性亮點",[16,659,660],{},"這個專案真正想練習的重點：",[147,662,663,674,684,691,698],{},[150,664,665,666,669,670,673],{},"以 .NET 內建 ",[69,667,668],{},"ActivitySource"," 在三個服務中發送 Trace，並將 ",[20,671,672],{},"TraceId 寫入\n領域事件本身","，讓一筆訂單從建立、付款到出貨的完整流程，能在分散式追蹤中被\n串成同一條 Trace",[150,675,676,677,680,681],{},"自訂 ",[69,678,679],{},"MethodTracingAttribute","（AOP）快速為方法加上追蹤埋點，不需手動包\n",[69,682,683],{},"using activity",[150,685,686,687,690],{},"透過 ",[69,688,689],{},"IMeterFactory"," 建立自訂 Metrics（計數器、直方圖、浮動計數器），例如\n訂單金額分佈、待處理付款數量",[150,692,693,694,697],{},"以 OpenTelemetry Collector 統一收集 Trace／Metric／Log，轉送至 ",[20,695,696],{},"Tempo、\nPrometheus、Loki","，並用 Grafana 儀表板呈現",[150,699,700,701,704],{},"整合 ",[20,702,703],{},"Grafana MCP 與 Tempo MCP","，讓開發時能直接用 TraceQL／PromQL／LogQL\n在編輯器內查詢追蹤與指標，不必切換到 Grafana 介面除錯",[12,706,707],{"id":707},"使用技術",[147,709,710,716,722,728,734],{},[150,711,712,715],{},[20,713,714],{},"應用層","：.NET 8、ASP.NET Core Minimal API、Wolverine、Kafka",[150,717,718,721],{},[20,719,720],{},"架構","：Domain-Driven Design（戰略＋戰術設計）、Event Sourcing、\nHexagonal Architecture",[150,723,724,727],{},[20,725,726],{},"資料層","：EF Core、Code First、MSSQL",[150,729,730,733],{},[20,731,732],{},"可觀測性","：OpenTelemetry、Grafana、Tempo、Loki、Prometheus",[150,735,736,739],{},[20,737,738],{},"開發體驗","：.NET Aspire、xUnit、Docker Compose",[12,741,742],{"id":742},"目前狀態",[16,744,745,746,749],{},"專案仍在持續補充中，目前已完整展示三服務間的事件編舞（Choreography）與可觀測\n性串接；後續規劃導入 ",[20,747,748],{},"SAGA Pattern"," 讓跨服務交易一致性有明確的補償流程可\n循，並在服務前加上 API Gateway。",[751,752,753],"style",{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":222,"searchDepth":247,"depth":247,"links":755},[756,757,760,761,762,763,764],{"id":14,"depth":247,"text":14},{"id":30,"depth":247,"text":31,"children":758},[759],{"id":135,"depth":253,"text":135},{"id":176,"depth":247,"text":177},{"id":586,"depth":247,"text":586},{"id":657,"depth":247,"text":657},{"id":707,"depth":247,"text":707},{"id":742,"depth":247,"text":742},"https:\u002F\u002Fraw.githubusercontent.com\u002Ftom3019\u002FE-Commerce\u002Fmaster\u002Fimgs\u002Ftracing.png","md",{},1.5,"\u002Fportfolio\u002Fecommerce-microservices-observability","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FE-Commerce","個人 Side Project ‧ 架構設計與開發",{"title":6,"description":222},[774,775,776,777,778,779,780,653,781,782,783,784,785,786],".NET 8","ASP.NET Core Minimal API","DDD","Event Sourcing","Hexagonal Architecture","Wolverine","Kafka","OpenTelemetry","Grafana","Tempo","Loki","Prometheus","EF Core","portfolio\u002Fecommerce-microservices-observability","訂單、付款、物流三個微服務組成的電商練習專案，以 DDD 戰略／戰術設計 + Hexagonal Architecture 實作 Event Sourcing，並完整導入 OpenTelemetry 可觀測性堆疊與 Grafana／Tempo MCP 查詢工具。",2024,"uAOzN5MK_-PBR8yFYoG4EJtJrgar0vzEby5kl5zuZDU",{"id":792,"title":793,"award":7,"body":794,"cover":1290,"description":222,"extension":766,"featured":280,"link":7,"meta":1291,"navigation":280,"order":277,"path":1292,"pinned":1293,"repo":1294,"role":1295,"seo":1296,"stack":1297,"stem":1306,"summary":1307,"year":1308,"__hash__":1309},"portfolio\u002Fportfolio\u002Faws-terraform-iac.md","AWS 基礎設施即程式碼（Terraform 模組）",{"type":9,"value":795,"toc":1281},[796,798,801,804,812,830,833,836,964,987,991,994,1043,1058,1062,1076,1245,1252,1255,1272,1275,1278],[12,797,14],{"id":14},[16,799,800],{},"建立公司的 Terraform 標準規範：一套遠端 State 後端，以及一系列可重複使用、附\n完整文件與測試的模組，讓各團隊能以一致的方式建置 AWS 基礎設施，而非各自土法\n煉鋼。",[12,802,803],{"id":803},"專案結構",[217,805,810],{"className":806,"code":808,"language":809},[807],"language-text","infrastracture\u002F\n  global\u002F         # 只需要存在一份的共用資源：State 後端、鎖表、IAM Role\n  development\u002F\n  stage\u002F\n  production\u002F\nmodules\u002F\n  aws\u002F\n    alb\u002F\n    cloudfront\u002F\n    ec2\u002F\n    s3\u002F\n","text",[69,811,808],{"__ignoreMap":222},[16,813,814,817,818,821,822,825,826,829],{},[69,815,816],{},"global"," 存放 Terraform 自身需要的基礎設施——狀態儲存與鎖定機制，",[69,819,820],{},"development","\n／",[69,823,824],{},"stage","／",[69,827,828],{},"production"," 則各自獨立，同一份模組在不同環境用不同的變數值套用，\n彼此的 State 完全隔離。",[12,831,832],{"id":832},"遠端後端與存取控制",[16,834,835],{},"Bootstrap 一次性建立好 Terraform 自己需要的基礎設施後，往後所有模組都指向同\n一組遠端後端：",[217,837,841],{"className":838,"code":839,"language":840,"meta":222,"style":222},"language-hcl shiki shiki-themes github-dark","terraform {\n  backend \"s3\" {\n    bucket         = \"tom-terraform-backend\"\n    key            = \"global\u002Fs3\u002Ftom-terraform-backend\u002Fterraform.tfstate\"\n    region         = \"ap-northeast-1\"\n    dynamodb_table = \"tom-terraform-backend-lock\"\n    encrypt        = true\n    assume_role = {\n      role_arn    = \"arn\u002FS_Terraform_Role\"\n      policy_arns = [\"arn\u002FS_Terraform_Role-policy\"]\n    }\n  }\n}\n","hcl",[69,842,843,851,862,873,884,894,905,916,925,935,951,955,960],{"__ignoreMap":222},[226,844,845,848],{"class":228,"line":229},[226,846,847],{"class":239},"terraform",[226,849,850],{"class":243}," {\n",[226,852,853,856,860],{"class":228,"line":247},[226,854,855],{"class":239},"  backend",[226,857,859],{"class":858},"sDLfK"," \"s3\"",[226,861,850],{"class":243},[226,863,864,867,870],{"class":228,"line":253},[226,865,866],{"class":243},"    bucket",[226,868,869],{"class":232},"         =",[226,871,872],{"class":477}," \"tom-terraform-backend\"\n",[226,874,875,878,881],{"class":228,"line":271},[226,876,877],{"class":243},"    key",[226,879,880],{"class":232},"            =",[226,882,883],{"class":477}," \"global\u002Fs3\u002Ftom-terraform-backend\u002Fterraform.tfstate\"\n",[226,885,886,889,891],{"class":228,"line":277},[226,887,888],{"class":243},"    region",[226,890,869],{"class":232},[226,892,893],{"class":477}," \"ap-northeast-1\"\n",[226,895,896,899,902],{"class":228,"line":284},[226,897,898],{"class":243},"    dynamodb_table",[226,900,901],{"class":232}," =",[226,903,904],{"class":477}," \"tom-terraform-backend-lock\"\n",[226,906,907,910,913],{"class":228,"line":309},[226,908,909],{"class":243},"    encrypt",[226,911,912],{"class":232},"        =",[226,914,915],{"class":858}," true\n",[226,917,918,921,923],{"class":228,"line":314},[226,919,920],{"class":243},"    assume_role",[226,922,901],{"class":232},[226,924,850],{"class":243},[226,926,927,930,932],{"class":228,"line":323},[226,928,929],{"class":243},"      role_arn    ",[226,931,350],{"class":232},[226,933,934],{"class":477}," \"arn\u002FS_Terraform_Role\"\n",[226,936,937,940,942,945,948],{"class":228,"line":329},[226,938,939],{"class":243},"      policy_arns ",[226,941,350],{"class":232},[226,943,944],{"class":243}," [",[226,946,947],{"class":477},"\"arn\u002FS_Terraform_Role-policy\"",[226,949,950],{"class":243},"]\n",[226,952,953],{"class":228,"line":344},[226,954,401],{"class":243},[226,956,957],{"class":228,"line":356},[226,958,959],{"class":243},"  }\n",[226,961,962],{"class":228,"line":365},[226,963,274],{"class":243},[147,965,966,980],{},[150,967,968,971,972,975,976,979],{},[20,969,970],{},"S3"," 儲存 State、",[20,973,974],{},"DynamoDB"," 做 State Lock，避免多人同時 ",[69,977,978],{},"apply"," 造成競\n爭寫入",[150,981,982,983,986],{},"所有操作都透過 ",[69,984,985],{},"assume_role"," 走一個專屬的 Terraform 執行角色，而不是用長期\n存在的 IAM User AccessKey，降低憑證外洩風險",[12,988,990],{"id":989},"模組文件terraform-registry-風格","模組文件（Terraform Registry 風格）",[16,992,993],{},"每個模組（ALB、CloudFront、EC2、S3）都附上 Registry 慣用的四段式文件：",[40,995,996,1006],{},[43,997,998],{},[46,999,1000,1003],{},[49,1001,1002],{},"項目",[49,1004,1005],{},"說明",[58,1007,1008,1019,1027,1035],{},[46,1009,1010,1013],{},[63,1011,1012],{},"Usage",[63,1014,1015,1018],{},[69,1016,1017],{},"module"," 區塊的最小可用範例",[46,1020,1021,1024],{},[63,1022,1023],{},"Requirements",[63,1025,1026],{},"Terraform／Provider 版本限制",[46,1028,1029,1032],{},[63,1030,1031],{},"Inputs",[63,1033,1034],{},"每個變數的型別、預設值、是否必填",[46,1036,1037,1040],{},[63,1038,1039],{},"Outputs",[63,1041,1042],{},"模組回傳的屬性（如 ARN、ID）",[16,1044,1045,1046,1049,1050,1053,1054,1057],{},"以 S3 模組為例，Inputs 包含 ",[69,1047,1048],{},"s3_bucket_name","（必填）、\n",[69,1051,1052],{},"s3_bucket_versioning","（預設開啟版本控制）、",[69,1055,1056],{},"prevent_destroy","（預設鎖住刪\n除，避免誤刪正式環境的 bucket），呼叫端不需要讀原始碼就知道怎麼用。",[12,1059,1061],{"id":1060},"用-goterratest驗證模組","用 Go（Terratest）驗證模組",[16,1063,1064,1065,1068,1069,1071,1072,1075],{},"不是只做 ",[69,1066,1067],{},"terraform validate","，而是每個模組都有一支 Go 整合測試，實際\n",[69,1070,978],{}," 一份範例設定、檢查輸出的屬性符合預期、再 ",[69,1073,1074],{},"destroy"," 乾淨：",[217,1077,1081],{"className":1078,"code":1079,"language":1080,"meta":222,"style":222},"language-go shiki shiki-themes github-dark","func TestS3Example(t *testing.T) {\n    ops := &terraform.Options{TerraformDir: \"..\u002Fexample\"}\n    defer terraform.Destroy(t, ops)\n\n    terraform.InitAndApply(t, ops)\n\n    bucketArn := terraform.Output(t, ops, \"bucket_arn\")\n    bucketTags := terraform.OutputMap(t, ops, \"tags\")\n    assert.NotNil(t, bucketArn)\n    assert.Equal(t, \"expected-bucket-name\", bucketTags[\"Name\"])\n}\n","go",[69,1082,1083,1111,1137,1151,1155,1165,1169,1189,1208,1219,1241],{"__ignoreMap":222},[226,1084,1085,1088,1091,1093,1097,1100,1103,1105,1108],{"class":228,"line":229},[226,1086,1087],{"class":232},"func",[226,1089,1090],{"class":239}," TestS3Example",[226,1092,259],{"class":243},[226,1094,1096],{"class":1095},"s9osk","t",[226,1098,1099],{"class":232}," *",[226,1101,1102],{"class":239},"testing",[226,1104,454],{"class":243},[226,1106,1107],{"class":239},"T",[226,1109,1110],{"class":243},") {\n",[226,1112,1113,1116,1119,1122,1124,1126,1129,1132,1135],{"class":228,"line":247},[226,1114,1115],{"class":243},"    ops ",[226,1117,1118],{"class":232},":=",[226,1120,1121],{"class":232}," &",[226,1123,847],{"class":239},[226,1125,454],{"class":243},[226,1127,1128],{"class":239},"Options",[226,1130,1131],{"class":243},"{TerraformDir: ",[226,1133,1134],{"class":477},"\"..\u002Fexample\"",[226,1136,274],{"class":243},[226,1138,1139,1142,1145,1148],{"class":228,"line":253},[226,1140,1141],{"class":232},"    defer",[226,1143,1144],{"class":243}," terraform.",[226,1146,1147],{"class":239},"Destroy",[226,1149,1150],{"class":243},"(t, ops)\n",[226,1152,1153],{"class":228,"line":271},[226,1154,281],{"emptyLinePlaceholder":280},[226,1156,1157,1160,1163],{"class":228,"line":277},[226,1158,1159],{"class":243},"    terraform.",[226,1161,1162],{"class":239},"InitAndApply",[226,1164,1150],{"class":243},[226,1166,1167],{"class":228,"line":284},[226,1168,281],{"emptyLinePlaceholder":280},[226,1170,1171,1174,1176,1178,1181,1184,1187],{"class":228,"line":309},[226,1172,1173],{"class":243},"    bucketArn ",[226,1175,1118],{"class":232},[226,1177,1144],{"class":243},[226,1179,1180],{"class":239},"Output",[226,1182,1183],{"class":243},"(t, ops, ",[226,1185,1186],{"class":477},"\"bucket_arn\"",[226,1188,306],{"class":243},[226,1190,1191,1194,1196,1198,1201,1203,1206],{"class":228,"line":314},[226,1192,1193],{"class":243},"    bucketTags ",[226,1195,1118],{"class":232},[226,1197,1144],{"class":243},[226,1199,1200],{"class":239},"OutputMap",[226,1202,1183],{"class":243},[226,1204,1205],{"class":477},"\"tags\"",[226,1207,306],{"class":243},[226,1209,1210,1213,1216],{"class":228,"line":323},[226,1211,1212],{"class":243},"    assert.",[226,1214,1215],{"class":239},"NotNil",[226,1217,1218],{"class":243},"(t, bucketArn)\n",[226,1220,1221,1223,1226,1229,1232,1235,1238],{"class":228,"line":329},[226,1222,1212],{"class":243},[226,1224,1225],{"class":239},"Equal",[226,1227,1228],{"class":243},"(t, ",[226,1230,1231],{"class":477},"\"expected-bucket-name\"",[226,1233,1234],{"class":243},", bucketTags[",[226,1236,1237],{"class":477},"\"Name\"",[226,1239,1240],{"class":243},"])\n",[226,1242,1243],{"class":228,"line":344},[226,1244,274],{"class":243},[16,1246,1247,1248,1251],{},"這讓模組的正確性不是靠人工檢查 ",[69,1249,1250],{},".tf"," 檔案，而是每次修改都能重新驗證「這個模組\n套用後，AWS 上真的長出預期的資源與屬性」。",[12,1253,1254],{"id":1254},"實作內容",[147,1256,1257,1260,1263,1266,1269],{},[150,1258,1259],{},"使用 S3 + DynamoDB + IAM 建立 Terraform 遠端後端，並以 tfvars 管理可編輯人員",[150,1261,1262],{},"製作 ALB、CloudFront、EC2、S3 等可重複使用模組，依環境（development／\nstage／production）切分",[150,1264,1265],{},"用 Go（Terratest）為模組撰寫 Apply → Assert → Destroy 的整合測試",[150,1267,1268],{},"撰寫 Registry 風格的模組使用說明文件與內部 Terraform 教學訓練",[150,1270,1271],{},"盤點並調整公司現有 IAM User，改用 AssumeRole 降低常駐憑證數量",[12,1273,1274],{"id":1274},"成果",[16,1276,1277],{},"讓每個團隊都能以一致、經過測試的方式建置 AWS 基礎設施，取代過去各專案各自為\n政、缺乏文件與遠端後端管理的 Terraform 用法。",[751,1279,1280],{},"html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}",{"title":222,"searchDepth":247,"depth":247,"links":1282},[1283,1284,1285,1286,1287,1288,1289],{"id":14,"depth":247,"text":14},{"id":803,"depth":247,"text":803},{"id":832,"depth":247,"text":832},{"id":989,"depth":247,"text":990},{"id":1060,"depth":247,"text":1061},{"id":1254,"depth":247,"text":1254},{"id":1274,"depth":247,"text":1274},"\u002Fimages\u002Fportfolio\u002Faws-web-services-logo.svg",{},"\u002Fportfolio\u002Faws-terraform-iac",false,"https:\u002F\u002Fgithub.com\u002Ftom3019\u002FIaC.Aws","DevOps \u002F SRE ‧ 房仲業",{"title":793,"description":222},[1298,1299,1300,1301,974,970,1302,1303,1304,1305],"Terraform","AWS","Go","Terratest","IAM","EC2","CloudFront","ALB","portfolio\u002Faws-terraform-iac","依環境切分的 Terraform 專案結構，附上 Terraform Registry 風格的模組文件（Requirements／Providers／Inputs／Outputs）與 Go（Terratest）整合測試。",2025,"wMzMBY2PaZh_D-oJxL3LoM2wVsYnXoXfFC4YZd5Ydh0",{"id":1311,"title":1312,"award":7,"body":1313,"cover":1471,"description":222,"extension":766,"featured":1293,"link":7,"meta":2290,"navigation":280,"order":229,"path":2291,"pinned":1293,"repo":7,"role":2292,"seo":2293,"stack":2294,"stem":2304,"summary":2305,"year":2306,"__hash__":2307},"portfolio\u002Fportfolio\u002Frealty-platform-rebuild.md","房仲平台核心系統重構",{"type":9,"value":1314,"toc":2278},[1315,1317,1324,1331,1334,1359,1361,1367,1370,1388,1391,1394,1397,1459,1462,1466,1472,1479,1485,1626,1637,1803,1814,1817,1847,1851,1857,1864,1869,1876,1881,1892,2081,2084,2104,2107,2112,2119,2124,2137,2148,2152,2162,2173,2177,2188,2190,2223,2225,2275],[12,1316,14],{"id":14},[16,1318,1319,1320,1323],{},"主導公司主力房仲平台的主要流量頻道重構，包含",[20,1321,1322],{},"買屋、成交行情、社區與快售","等\n頻道，將系統從 .NET Framework\u002FIIS 遷移至 Kubernetes 上的 CQRS + Event Sourcing\n架構。",[16,1325,1326,1327,1330],{},"重構前，每個頻道都是一個獨立的 IIS 站台，各自直接查詢 MSSQL，再把查詢結果整包\n放進",[20,1328,1329],{},"記憶體快取","來撐住流量——每個站台光快取就吃掉約 10G 記憶體，資料更新要等\n快取過期，部署時快取要重新暖機，一次部署就要 30 分鐘。搜尋類的查詢（多條件篩\n選、地圖範圍）對關聯式資料庫本來就不友善，複雜一點的條件要 20 秒才回得來。",[16,1332,1333],{},"這次重構的方向是把「讀」和「寫」拆開：",[147,1335,1336,1342,1352],{},[150,1337,1338,1341],{},[20,1339,1340],{},"寫入端","維持在 MSSQL，作為資料的唯一真實來源（Single Source of Truth）",[150,1343,1344,1347,1348,1351],{},[20,1345,1346],{},"讀取端","全部改由 Elasticsearch 提供，並依業務領域拆成",[20,1349,1350],{},"買屋、行情、社區三\n大資料中台","，前端與集團內其他系統都透過中台取資料，不再直接碰資料庫",[150,1353,1354,1355,1358],{},"中間用",[20,1356,1357],{},"排程 + RabbitMQ"," 把 MSSQL 的異動同步進 Elasticsearch",[12,1360,586],{"id":586},[16,1362,1363],{},[142,1364],{"alt":1365,"src":1366},"房仲平台架構圖：使用者經由 DMZ Kubernetes 上的 API Gateway 進入內網，內網 Kubernetes 上有前端網站與買屋、行情、社區三大中台，中台都查詢 Elasticsearch；MSSQL 原始資料透過 BCH 批次全量轉入、同步排程與 RabbitMQ 增量同步到 Elasticsearch；遙測資料送到 OpenTelemetry Collector 再由 Grafana 呈現，Argo CD 依 Git 上的設定同步 DMZ 與內網叢集","\u002Fimages\u002Fportfolio\u002Frealty-platform-architecture.svg",[16,1368,1369],{},"整體分成兩個網路區域、兩座 Kubernetes 叢集：",[147,1371,1372,1382],{},[150,1373,1374,1377,1378,1381],{},[20,1375,1376],{},"DMZ","：只放 ",[20,1379,1380],{},"API Gateway","，是整個平台唯一對外開放的入口。外部請求一律先\n進 Gateway，再依路由規則轉進內網，內網的服務本身不對外暴露",[150,1383,1384,1387],{},[20,1385,1386],{},"內網","：前端網站與三大中台都跑在內網的 Kubernetes 上；Elasticsearch、\nRabbitMQ、MSSQL 等資料服務也都只在內網可達",[16,1389,1390],{},"把對外入口收斂到一個點的好處是，所有安全控管、流量監控、路由調整都只需要在\nGateway 這一層處理；內網的中台可以專心做資料查詢，不用各自處理對外的防護。",[12,1392,1393],{"id":1393},"三大資料中台",[16,1395,1396],{},"三個中台都是 ASP.NET Core Web API，背後都只查詢 Elasticsearch：",[40,1398,1399,1412],{},[43,1400,1401],{},[46,1402,1403,1406,1409],{},[49,1404,1405],{},"中台",[49,1407,1408],{},"提供的資料",[49,1410,1411],{},"主要查詢",[58,1413,1414,1431,1446],{},[46,1415,1416,1421,1424],{},[63,1417,1418],{},[20,1419,1420],{},"買屋中台",[63,1422,1423],{},"在售物件",[63,1425,1426,1427,1430],{},"關鍵字、多條件篩選、自動補全、",[20,1428,1429],{},"地圖搜尋","、鄰近物件推薦",[46,1432,1433,1438,1441],{},[63,1434,1435],{},[20,1436,1437],{},"行情中台",[63,1439,1440],{},"實價登錄 \u002F 成交行情",[63,1442,1443,1444],{},"區域與社區行情、",[20,1445,1429],{},[46,1447,1448,1453,1456],{},[63,1449,1450],{},[20,1451,1452],{},"社區中台",[63,1454,1455],{},"社區（建案）資訊",[63,1457,1458],{},"社區基本資料、社區內物件與行情彙整",[16,1460,1461],{},"以「業務領域」而不是「頁面」來切中台，讓同一份資料只有一個出口：前端的買屋頁、\nApp、集團內的其他品牌網站要查在售物件，都是打同一個買屋中台，不會再出現各站台\n各寫一套查詢、結果卻對不起來的情況。",[12,1463,1465],{"id":1464},"地圖搜尋elasticsearch-geo","地圖搜尋：Elasticsearch GEO",[16,1467,1468],{},[142,1469],{"alt":1470,"src":1471},"地圖搜尋畫面：在信義區地圖上依可視範圍顯示每個物件與成交案件的價格標籤","\u002Fimages\u002Fportfolio\u002Frealty-map-search.png",[16,1473,1474,1475,1478],{},"買屋與行情頻道都有",[20,1476,1477],{},"地圖找房","：使用者拖曳、縮放地圖時，畫面上要即時顯示這個\n範圍內的物件與成交價格。這類「範圍內有哪些點」的查詢，正好是 Elasticsearch\nGEO 的強項。",[16,1480,1481,1482,155],{},"每一筆物件 \u002F 成交資料在建立索引時，就把經緯度存成 ",[69,1483,1484],{},"geo_point",[217,1486,1490],{"className":1487,"code":1488,"language":1489,"meta":222,"style":222},"language-json shiki shiki-themes github-dark","PUT \u002Fbuy-house\n{\n  \"mappings\": {\n    \"properties\": {\n      \"location\":   { \"type\": \"geo_point\" },\n      \"price\":      { \"type\": \"scaled_float\", \"scaling_factor\": 10 },\n      \"city\":       { \"type\": \"keyword\" },\n      \"district\":   { \"type\": \"keyword\" },\n      \"updateTime\": { \"type\": \"date\" }\n    }\n  }\n}\n","json",[69,1491,1492,1497,1501,1509,1516,1536,1564,1581,1596,1614,1618,1622],{"__ignoreMap":222},[226,1493,1494],{"class":228,"line":229},[226,1495,1496],{"class":243},"PUT \u002Fbuy-house\n",[226,1498,1499],{"class":228,"line":247},[226,1500,250],{"class":243},[226,1502,1503,1506],{"class":228,"line":253},[226,1504,1505],{"class":858},"  \"mappings\"",[226,1507,1508],{"class":243},": {\n",[226,1510,1511,1514],{"class":228,"line":271},[226,1512,1513],{"class":858},"    \"properties\"",[226,1515,1508],{"class":243},[226,1517,1518,1521,1524,1527,1530,1533],{"class":228,"line":277},[226,1519,1520],{"class":858},"      \"location\"",[226,1522,1523],{"class":243},":   { ",[226,1525,1526],{"class":858},"\"type\"",[226,1528,1529],{"class":243},": ",[226,1531,1532],{"class":477},"\"geo_point\"",[226,1534,1535],{"class":243}," },\n",[226,1537,1538,1541,1544,1546,1548,1551,1554,1557,1559,1562],{"class":228,"line":284},[226,1539,1540],{"class":858},"      \"price\"",[226,1542,1543],{"class":243},":      { ",[226,1545,1526],{"class":858},[226,1547,1529],{"class":243},[226,1549,1550],{"class":477},"\"scaled_float\"",[226,1552,1553],{"class":243},", ",[226,1555,1556],{"class":858},"\"scaling_factor\"",[226,1558,1529],{"class":243},[226,1560,1561],{"class":858},"10",[226,1563,1535],{"class":243},[226,1565,1566,1569,1572,1574,1576,1579],{"class":228,"line":309},[226,1567,1568],{"class":858},"      \"city\"",[226,1570,1571],{"class":243},":       { ",[226,1573,1526],{"class":858},[226,1575,1529],{"class":243},[226,1577,1578],{"class":477},"\"keyword\"",[226,1580,1535],{"class":243},[226,1582,1583,1586,1588,1590,1592,1594],{"class":228,"line":314},[226,1584,1585],{"class":858},"      \"district\"",[226,1587,1523],{"class":243},[226,1589,1526],{"class":858},[226,1591,1529],{"class":243},[226,1593,1578],{"class":477},[226,1595,1535],{"class":243},[226,1597,1598,1601,1604,1606,1608,1611],{"class":228,"line":323},[226,1599,1600],{"class":858},"      \"updateTime\"",[226,1602,1603],{"class":243},": { ",[226,1605,1526],{"class":858},[226,1607,1529],{"class":243},[226,1609,1610],{"class":477},"\"date\"",[226,1612,1613],{"class":243}," }\n",[226,1615,1616],{"class":228,"line":329},[226,1617,401],{"class":243},[226,1619,1620],{"class":228,"line":344},[226,1621,959],{"class":243},[226,1623,1624],{"class":228,"line":356},[226,1625,274],{"class":243},[16,1627,1628,1629,1632,1633,1636],{},"前端在地圖移動後，把目前",[20,1630,1631],{},"可視範圍的左上角與右下角座標","送給中台，中台組成\n",[69,1634,1635],{},"geo_bounding_box"," 查詢，再疊上使用者選的篩選條件（總價、房數、屋齡…）：",[217,1638,1640],{"className":1487,"code":1639,"language":1489,"meta":222,"style":222},"GET \u002Fbuy-house\u002F_search\n{\n  \"query\": {\n    \"bool\": {\n      \"filter\": [\n        {\n          \"geo_bounding_box\": {\n            \"location\": {\n              \"top_left\":     { \"lat\": 25.0480, \"lon\": 121.5500 },\n              \"bottom_right\": { \"lat\": 25.0300, \"lon\": 121.5800 }\n            }\n          }\n        },\n        { \"range\": { \"price\": { \"lte\": 3000 } } }\n      ]\n    }\n  }\n}\n",[69,1641,1642,1647,1651,1658,1665,1673,1678,1685,1692,1720,1745,1750,1755,1760,1786,1791,1795,1799],{"__ignoreMap":222},[226,1643,1644],{"class":228,"line":229},[226,1645,1646],{"class":243},"GET \u002Fbuy-house\u002F_search\n",[226,1648,1649],{"class":228,"line":247},[226,1650,250],{"class":243},[226,1652,1653,1656],{"class":228,"line":253},[226,1654,1655],{"class":858},"  \"query\"",[226,1657,1508],{"class":243},[226,1659,1660,1663],{"class":228,"line":271},[226,1661,1662],{"class":858},"    \"bool\"",[226,1664,1508],{"class":243},[226,1666,1667,1670],{"class":228,"line":277},[226,1668,1669],{"class":858},"      \"filter\"",[226,1671,1672],{"class":243},": [\n",[226,1674,1675],{"class":228,"line":284},[226,1676,1677],{"class":243},"        {\n",[226,1679,1680,1683],{"class":228,"line":309},[226,1681,1682],{"class":858},"          \"geo_bounding_box\"",[226,1684,1508],{"class":243},[226,1686,1687,1690],{"class":228,"line":314},[226,1688,1689],{"class":858},"            \"location\"",[226,1691,1508],{"class":243},[226,1693,1694,1697,1700,1703,1705,1708,1710,1713,1715,1718],{"class":228,"line":323},[226,1695,1696],{"class":858},"              \"top_left\"",[226,1698,1699],{"class":243},":     { ",[226,1701,1702],{"class":858},"\"lat\"",[226,1704,1529],{"class":243},[226,1706,1707],{"class":858},"25.0480",[226,1709,1553],{"class":243},[226,1711,1712],{"class":858},"\"lon\"",[226,1714,1529],{"class":243},[226,1716,1717],{"class":858},"121.5500",[226,1719,1535],{"class":243},[226,1721,1722,1725,1727,1729,1731,1734,1736,1738,1740,1743],{"class":228,"line":329},[226,1723,1724],{"class":858},"              \"bottom_right\"",[226,1726,1603],{"class":243},[226,1728,1702],{"class":858},[226,1730,1529],{"class":243},[226,1732,1733],{"class":858},"25.0300",[226,1735,1553],{"class":243},[226,1737,1712],{"class":858},[226,1739,1529],{"class":243},[226,1741,1742],{"class":858},"121.5800",[226,1744,1613],{"class":243},[226,1746,1747],{"class":228,"line":344},[226,1748,1749],{"class":243},"            }\n",[226,1751,1752],{"class":228,"line":356},[226,1753,1754],{"class":243},"          }\n",[226,1756,1757],{"class":228,"line":365},[226,1758,1759],{"class":243},"        },\n",[226,1761,1762,1765,1768,1770,1773,1775,1778,1780,1783],{"class":228,"line":374},[226,1763,1764],{"class":243},"        { ",[226,1766,1767],{"class":858},"\"range\"",[226,1769,1603],{"class":243},[226,1771,1772],{"class":858},"\"price\"",[226,1774,1603],{"class":243},[226,1776,1777],{"class":858},"\"lte\"",[226,1779,1529],{"class":243},[226,1781,1782],{"class":858},"3000",[226,1784,1785],{"class":243}," } } }\n",[226,1787,1788],{"class":228,"line":384},[226,1789,1790],{"class":243},"      ]\n",[226,1792,1793],{"class":228,"line":391},[226,1794,401],{"class":243},[226,1796,1797],{"class":228,"line":398},[226,1798,959],{"class":243},[226,1800,1801],{"class":228,"line":404},[226,1802,274],{"class":243},[16,1804,1805,1806,1809,1810,1813],{},"物件詳細頁的「",[20,1807,1808],{},"附近物件推薦","」則改用 ",[69,1811,1812],{},"geo_distance","，以目前物件為圓心找出一定\n半徑內的其他物件，再依距離排序。",[16,1815,1816],{},"幾個實作上的考量：",[147,1818,1819,1831,1841],{},[150,1820,1821,1827,1828,1830],{},[20,1822,1823,1824],{},"條件都放在 ",[69,1825,1826],{},"filter","：地圖範圍與篩選條件都是「符合 \u002F 不符合」，不需要算相關\n性分數，放在 ",[69,1829,1826],{}," 裡 Elasticsearch 可以快取結果，拖曳地圖時的重複查詢會快\n很多",[150,1832,1833,1836,1837,1840],{},[20,1834,1835],{},"只回傳畫面需要的欄位","：地圖上的標籤只需要座標、價格與 ID，用 ",[69,1838,1839],{},"_source"," 過\n濾掉其他欄位，點進去時才查詳細資料",[150,1842,1843,1846],{},[20,1844,1845],{},"限制單次回傳數量","：縮放到很大的範圍時物件數量會暴增，中台會限制單次筆數，\n避免一次把幾萬筆點丟給前端畫",[12,1848,1850],{"id":1849},"資料同步從-mssql-到-elasticsearch","資料同步：從 MSSQL 到 Elasticsearch",[16,1852,1853],{},[142,1854],{"alt":1855,"src":1856},"資料同步流程圖：全量時由 BCH 批次把 MSSQL 整批轉入 Elasticsearch；增量時 DBA 更新資料會一併寫入異動欄位，同步排程定期比對異動欄位撈出變動資料，送進 RabbitMQ，再由同步消費者重組文件並更新索引","\u002Fimages\u002Fportfolio\u002Frealty-platform-data-sync.svg",[16,1858,1859,1860,1863],{},"讀取全部改走 Elasticsearch 之後，最關鍵的問題就變成：",[20,1861,1862],{},"MSSQL 的資料改了，索引\n要怎麼跟上？"," 同步分成兩條路線：",[16,1865,1866],{},[20,1867,1868],{},"① 全量：BCH 批次",[16,1870,1871,1872,1875],{},"原始資料由 ",[20,1873,1874],{},"BCH"," 批次從 MSSQL 整批讀出、轉成 Elasticsearch 的文件格式後寫入，\n用在第一次建立索引，或是 mapping 調整後需要整份重建的時候。",[16,1877,1878],{},[20,1879,1880],{},"② 增量：異動欄位 + 排程 + RabbitMQ",[16,1882,1883,1884,1887,1888,1891],{},"日常的資料更新不可能每次都整批重跑，所以和 DBA 約定：",[20,1885,1886],{},"資料在更新時，一併更新\n一個異動欄位","。我寫的同步排程（Hangfire）定期比對這個欄位，找出上次同步之後\n有變動的資料，把每一筆異動包成事件送進 ",[20,1889,1890],{},"RabbitMQ","，再由同步消費者重組成\nElasticsearch 文件、更新對應的索引。",[217,1893,1895],{"className":219,"code":1894,"language":221,"meta":222,"style":222},"\u002F\u002F 示意：排程只負責「找出變動、發出事件」，不直接寫 ES\npublic async Task DetectChangesAsync()\n{\n    var checkpoint = await _checkpointStore.GetAsync(\"buy-house\");\n    var changed = await _repository.GetChangedSinceAsync(checkpoint);\n\n    foreach (var item in changed)\n    {\n        await _publisher.PublishAsync(new HouseChangedEvent(item.Id, item.ChangeMark));\n    }\n\n    if (changed.Count > 0)\n    {\n        await _checkpointStore.SaveAsync(\"buy-house\", changed.Max(x => x.ChangeMark));\n    }\n}\n",[69,1896,1897,1902,1917,1921,1947,1967,1971,1991,1995,2016,2020,2024,2039,2043,2073,2077],{"__ignoreMap":222},[226,1898,1899],{"class":228,"line":229},[226,1900,1901],{"class":394},"\u002F\u002F 示意：排程只負責「找出變動、發出事件」，不直接寫 ES\n",[226,1903,1904,1906,1909,1912,1915],{"class":228,"line":247},[226,1905,233],{"class":232},[226,1907,1908],{"class":232}," async",[226,1910,1911],{"class":239}," Task",[226,1913,1914],{"class":239}," DetectChangesAsync",[226,1916,244],{"class":243},[226,1918,1919],{"class":228,"line":253},[226,1920,250],{"class":243},[226,1922,1923,1926,1929,1931,1934,1937,1940,1942,1945],{"class":228,"line":271},[226,1924,1925],{"class":232},"    var",[226,1927,1928],{"class":239}," checkpoint",[226,1930,901],{"class":232},[226,1932,1933],{"class":232}," await",[226,1935,1936],{"class":243}," _checkpointStore.",[226,1938,1939],{"class":239},"GetAsync",[226,1941,259],{"class":243},[226,1943,1944],{"class":477},"\"buy-house\"",[226,1946,481],{"class":243},[226,1948,1949,1951,1954,1956,1958,1961,1964],{"class":228,"line":277},[226,1950,1925],{"class":232},[226,1952,1953],{"class":239}," changed",[226,1955,901],{"class":232},[226,1957,1933],{"class":232},[226,1959,1960],{"class":243}," _repository.",[226,1962,1963],{"class":239},"GetChangedSinceAsync",[226,1965,1966],{"class":243},"(checkpoint);\n",[226,1968,1969],{"class":228,"line":284},[226,1970,281],{"emptyLinePlaceholder":280},[226,1972,1973,1976,1979,1982,1985,1988],{"class":228,"line":309},[226,1974,1975],{"class":232},"    foreach",[226,1977,1978],{"class":243}," (",[226,1980,1981],{"class":232},"var",[226,1983,1984],{"class":239}," item",[226,1986,1987],{"class":232}," in",[226,1989,1990],{"class":243}," changed)\n",[226,1992,1993],{"class":228,"line":314},[226,1994,326],{"class":243},[226,1996,1997,2000,2003,2006,2008,2010,2013],{"class":228,"line":323},[226,1998,1999],{"class":232},"        await",[226,2001,2002],{"class":243}," _publisher.",[226,2004,2005],{"class":239},"PublishAsync",[226,2007,259],{"class":243},[226,2009,262],{"class":232},[226,2011,2012],{"class":239}," HouseChangedEvent",[226,2014,2015],{"class":243},"(item.Id, item.ChangeMark));\n",[226,2017,2018],{"class":228,"line":329},[226,2019,401],{"class":243},[226,2021,2022],{"class":228,"line":344},[226,2023,281],{"emptyLinePlaceholder":280},[226,2025,2026,2028,2031,2034,2037],{"class":228,"line":356},[226,2027,436],{"class":232},[226,2029,2030],{"class":243}," (changed.Count ",[226,2032,2033],{"class":232},">",[226,2035,2036],{"class":858}," 0",[226,2038,306],{"class":243},[226,2040,2041],{"class":228,"line":365},[226,2042,326],{"class":243},[226,2044,2045,2047,2049,2052,2054,2056,2059,2062,2064,2067,2070],{"class":228,"line":374},[226,2046,1999],{"class":232},[226,2048,1936],{"class":243},[226,2050,2051],{"class":239},"SaveAsync",[226,2053,259],{"class":243},[226,2055,1944],{"class":477},[226,2057,2058],{"class":243},", changed.",[226,2060,2061],{"class":239},"Max",[226,2063,259],{"class":243},[226,2065,2066],{"class":239},"x",[226,2068,2069],{"class":232}," =>",[226,2071,2072],{"class":243}," x.ChangeMark));\n",[226,2074,2075],{"class":228,"line":384},[226,2076,401],{"class":243},[226,2078,2079],{"class":228,"line":391},[226,2080,274],{"class":243},[16,2082,2083],{},"中間多放一層 RabbitMQ，而不是排程撈到資料就直接寫 Elasticsearch，原因是：",[147,2085,2086,2092,2098],{},[150,2087,2088,2091],{},[20,2089,2090],{},"偵測與寫入解耦","：排程只負責「找出哪些資料變了」，寫索引的工作交給消費者。\n某一段時間異動量暴增時，事件會先在佇列裡排隊，消費者照自己的速度處理，不會把\n排程或 Elasticsearch 壓垮",[150,2093,2094,2097],{},[20,2095,2096],{},"失敗可重試","：單筆文件寫入失敗時，事件可以重新投遞或進入死信佇列，不會因為一\n筆壞資料讓整批同步中斷",[150,2099,2100,2103],{},[20,2101,2102],{},"一份異動、多個訂閱者","：同一筆物件異動，買屋索引要更新、社區的物件彙整也可能\n要更新，各自訂閱同一個事件即可，不需要在排程裡寫死所有下游",[12,2105,2106],{"id":2106},"安全與可觀測性",[16,2108,2109],{},[20,2110,2111],{},"NeuVector：DMZ 流量監控",[16,2113,2114,2115,2118],{},"DMZ 的 Kubernetes 上部署了 ",[20,2116,2117],{},"NeuVector","，監控容器之間與進出叢集的網路流量。\nAPI Gateway 是唯一對外的服務，也是最常被掃描、攻擊的地方；NeuVector 會依平常的\n流量模式學習出正常的連線行為，出現異常的連線或流量時發出告警，讓問題在進到內網\n之前就被看見。",[16,2120,2121],{},[20,2122,2123],{},"OpenTelemetry + Grafana：全面可觀測性",[16,2125,2126,2127,2129,2130,2133,2134,2136],{},"DMZ 與內網的服務都接上 ",[20,2128,781],{},"，Traces、Metrics、Logs 統一送到\n",[20,2131,2132],{},"OpenTelemetry Collector","，由 Collector 集中處理後轉送到後端儲存，最後在\n",[20,2135,782],{}," 上用儀表板呈現：",[147,2138,2139,2142,2145],{},[150,2140,2141],{},"一個請求從 API Gateway 進來、經過哪個中台、查了哪個索引、花了多少時間，都能從\n同一條 Trace 串起來",[150,2143,2144],{},"各中台的請求量、延遲、錯誤率，以及同步佇列的堆積狀況，都在 Grafana 儀表板上\n一眼看得到",[150,2146,2147],{},"應用程式只需要對接 Collector，之後要更換或新增後端儲存，只要改 Collector 的設\n定，不必改程式",[12,2149,2151],{"id":2150},"部署argo-cd-iac","部署：Argo CD + IaC",[16,2153,2154,2155,2158,2159,155],{},"兩座 Kubernetes 叢集都交給 ",[20,2156,2157],{},"Argo CD"," 管理，基礎設施與 Kubernetes 的部署設定\n",[20,2160,2161],{},"全部以程式碼描述、放在 Git 上",[147,2163,2164,2167,2170],{},[150,2165,2166],{},"要部署或調整設定，就是發一個 PR；合併後 Argo CD 自動把叢集同步成 Git 上描述的\n狀態",[150,2168,2169],{},"叢集的實際狀態若被手動改動、和 Git 不一致，Argo CD 會標示出差異，避免「線上\n跟設定檔對不起來」的情況",[150,2171,2172],{},"每一次變更都有 Git 紀錄可以追溯，出問題時可以直接回退到上一個版本",[12,2174,2176],{"id":2175},"遷移策略新舊系統並行","遷移策略：新舊系統並行",[16,2178,2179,2180,2183,2184,2187],{},"重構不是一次切換。API Gateway 除了是對外入口，也是遷移期間的",[20,2181,2182],{},"路由開關","：新的\n中台與頁面完成一個，就在 Gateway 上把對應的路由切到新系統，還沒完成的部分繼續導\n回舊的 IIS 站台。整個遷移過程新舊系統並行運作，",[20,2185,2186],{},"對使用者完全沒有中斷","，某個\n新功能有問題時也能立刻把路由切回舊系統。",[12,2189,1274],{"id":1274},[147,2191,2192,2199,2206,2213,2220],{},[150,2193,2194,2195,2198],{},"移除每個 IIS 站台 10G 的記憶體快取，記憶體資源減少 ",[20,2196,2197],{},"超過 80%","（10G → 800Mb）",[150,2200,2201,2202,2205],{},"數據中台上線後，查詢回應速度提升 ",[20,2203,2204],{},"10 倍以上","（20 秒 → 1.5 秒）",[150,2207,2208,2209,2212],{},"容器化並移除記憶體快取後，後端部署效率提升 ",[20,2210,2211],{},"5 倍以上","（30 分鐘 → 5 分鐘）",[150,2214,2215,2216,2219],{},"前後端各別容器化後，前端部署效率提升 ",[20,2217,2218],{},"3 倍以上","（15 分鐘 → 5 分鐘）",[150,2221,2222],{},"買屋、行情、社區資料由三大中台統一提供，集團內其他系統可直接共用",[12,2224,707],{"id":707},[147,2226,2227,2233,2246,2252,2258,2264,2269],{},[150,2228,2229,2232],{},[20,2230,2231],{},"後端","：C#、ASP.NET Core Web API、CQRS、Event Sourcing、Hangfire",[150,2234,2235,2238,2239,190,2241,190,2243,2245],{},[20,2236,2237],{},"資料","：MSSQL、Elasticsearch（",[69,2240,1484],{},[69,2242,1635],{},[69,2244,1812],{},"）、BCH 批次",[150,2247,2248,2251],{},[20,2249,2250],{},"訊息","：RabbitMQ",[150,2253,2254,2257],{},[20,2255,2256],{},"平台","：Kubernetes（DMZ \u002F 內網兩座叢集）、API Gateway",[150,2259,2260,2263],{},[20,2261,2262],{},"安全","：NeuVector",[150,2265,2266,2268],{},[20,2267,732],{},"：OpenTelemetry、OpenTelemetry Collector、Grafana",[150,2270,2271,2274],{},[20,2272,2273],{},"部署","：Argo CD（GitOps）、IaC",[751,2276,2277],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}",{"title":222,"searchDepth":247,"depth":247,"links":2279},[2280,2281,2282,2283,2284,2285,2286,2287,2288,2289],{"id":14,"depth":247,"text":14},{"id":586,"depth":247,"text":586},{"id":1393,"depth":247,"text":1393},{"id":1464,"depth":247,"text":1465},{"id":1849,"depth":247,"text":1850},{"id":2106,"depth":247,"text":2106},{"id":2150,"depth":247,"text":2151},{"id":2175,"depth":247,"text":2176},{"id":1274,"depth":247,"text":1274},{"id":707,"depth":247,"text":707},{},"\u002Fportfolio\u002Frealty-platform-rebuild","後端技術負責人 ‧ 房仲業",{"title":1312,"description":222},[2295,2296,2297,777,2298,1890,2299,2300,2301,2302,1380,2117,781,782,2157,2303],"C#","ASP.NET Core","CQRS","Hangfire","Elasticsearch","Elasticsearch GEO","MSSQL","Kubernetes","IaC","portfolio\u002Frealty-platform-rebuild","將大型房仲平台的主要流量頻道，從 .NET Framework\u002FIIS 重構為 Kubernetes 上的 CQRS + Event Sourcing 架構：DMZ 以 API Gateway 作為唯一入口，內網拆出買屋、行情、社區三大資料中台，讀取全部走 Elasticsearch（含 GEO 地圖搜尋），MSSQL 異動透過排程與 RabbitMQ 同步進索引，並以 OpenTelemetry、Grafana、NeuVector 與 Argo CD 建立可觀測性與 GitOps 維運。",2023,"VzhHXppQmjQWMgiSMYEkFhRdwzj-lgkHTOOR1_saF5c",{"id":2309,"title":2310,"award":7,"body":2311,"cover":3038,"description":222,"extension":766,"featured":1293,"link":7,"meta":3039,"navigation":280,"order":271,"path":3040,"pinned":1293,"repo":3041,"role":3042,"seo":3043,"stack":3044,"stem":3048,"summary":3049,"year":2306,"__hash__":3050},"portfolio\u002Fportfolio\u002Fnexusgpt.md","NexusGPT — 企業內部 ChatGPT 平台",{"type":9,"value":2312,"toc":3025},[2313,2315,2322,2329,2332,2336,2343,2373,2376,2378,2384,2387,2414,2418,2425,2436,2439,2636,2639,2662,2666,2669,2676,2702,2709,2712,2718,2724,2730,2733,2736,2792,2795,2966,2973,2977,2997,2999,3015,3019,3022],[12,2314,14],{"id":14},[16,2316,2317,2318,2321],{},"ChatGPT 推出之後，公司裡很多同仁開始自己拿個人帳號用 GPT 寫程式、整理資料、寫\n文案。問題也跟著來：每個人各自付費，費用分散又難管理；更麻煩的是，開發人員會直\n接把",[20,2319,2320],{},"內部程式碼、客戶資料","貼到外部工具上，資安上完全無法掌握。",[16,2323,2324,2325,2328],{},"於是我設計並開發了 ",[20,2326,2327],{},"NexusGPT","：一個只在公司內部使用的 ChatGPT 平台。同仁用\n公司帳號登入，就能使用 GPT-4 與 DALL-E 3，所有對話都留在公司自己的系統裡，費用\n也由公司統一向 OpenAI 採購 API 用量。",[16,2330,2331],{},"核心程式碼後來以 Hexagonal Architecture 整理成獨立的開源專案，拿掉了內部串接的\n部分。",[12,2333,2335],{"id":2334},"介面做得跟-chatgpt-一樣","介面：做得跟 ChatGPT 一樣",[16,2337,2338,2339,2342],{},"一個內部工具要讓大家願意用，最快的方式就是",[20,2340,2341],{},"讓大家不用重新學","。當時同仁已經習\n慣 ChatGPT 的操作方式，所以 NexusGPT 的前端刻意做得和 ChatGPT 幾乎一模一樣：",[147,2344,2345,2351,2357,2363],{},[150,2346,2347,2350],{},[20,2348,2349],{},"深色主題","：整個介面採用黑底配色，和當時的 ChatGPT 一致",[150,2352,2353,2356],{},[20,2354,2355],{},"左側聊天室列表","：依「今天、過去 7 天」分組，點一下就切換對話；上方是「新對\n話」按鈕，下方顯示目前登入的帳號",[150,2358,2359,2362],{},[20,2360,2361],{},"右側對話區","：使用者的訊息靠右、以圓角氣泡呈現，AI 的回覆靠左，支援 Markdown\n表格、程式碼區塊與檔案下載",[150,2364,2365,2368,2369,2372],{},[20,2366,2367],{},"底部輸入框","：圓角輸入列與送出按鈕，送出後回覆會",[20,2370,2371],{},"一個字一個字","出現在畫面上",[16,2374,2375],{},"因為介面和操作方式幾乎沒有落差，導入時不需要額外辦教育訓練，同仁打開就會用。",[12,2377,586],{"id":586},[16,2379,2380],{},[142,2381],{"alt":2382,"src":2383},"NexusGPT 架構圖：前端先透過 OpenID Connect 以公司帳號登入取得 Token，再經由 SignalR WebSocket 送出訊息；後端驗證 Token 取出使用者 ID，向 OpenAI API 以串流方式取得回覆並逐字推送回前端，聊天室與訊息依使用者 ID 存入 MSSQL，DALL-E 生成的圖片存到 AWS S3","\u002Fimages\u002Fportfolio\u002Fnexusgpt-architecture.svg",[16,2385,2386],{},"整個流程分成四步：",[2388,2389,2390,2396,2402,2408],"ol",{},[150,2391,2392,2395],{},[20,2393,2394],{},"登入","：前端導向公司的 OpenID Connect 服務，以公司帳號登入後取得 Token",[150,2397,2398,2401],{},[20,2399,2400],{},"送出訊息","：前端透過 SignalR 建立的 WebSocket 連線，把訊息送到後端",[150,2403,2404,2407],{},[20,2405,2406],{},"串流回覆","：後端以串流模式呼叫 OpenAI API，GPT 每產生一小段文字就收到一段",[150,2409,2410,2413],{},[20,2411,2412],{},"即時推送","：每收到一段，就立刻透過同一條 WebSocket 推回前端，畫面上逐字長出\n回覆",[12,2415,2417],{"id":2416},"即時對話signalr-串流回覆","即時對話：SignalR + 串流回覆",[16,2419,2420,2421,2424],{},"GPT-4 產生一段完整的回答常常要十幾秒到幾十秒。如果用一般的 HTTP 請求，使用者要\n盯著空白畫面等到整段生成完才看得到結果，體驗很差。ChatGPT 之所以感覺「很快」，\n是因為它",[20,2422,2423],{},"邊生成邊顯示","。",[16,2426,2427,2428,2431,2432,2435],{},"要做到這點，後端需要能「主動、連續地」把資料推給前端，所以選用 ",[20,2429,2430],{},"SignalR","——\nASP.NET Core 的即時通訊框架，底層優先使用 ",[20,2433,2434],{},"WebSocket","，連線建立後雙方都能隨時\n傳送訊息。",[16,2437,2438],{},"後端的做法是把 OpenAI 的串流回覆，原封不動地接到 SignalR 的串流上：",[217,2440,2442],{"className":219,"code":2441,"language":221,"meta":222,"style":222},"\u002F\u002F 示意：OpenAI 每回傳一小段文字，就立刻推給前端\npublic async IAsyncEnumerable\u003Cstring> SendMessage(\n    Guid chatRoomId,\n    string message,\n    [EnumeratorCancellation] CancellationToken cancellationToken)\n{\n    var userId = Context.UserIdentifier!;\n    var answer = new StringBuilder();\n\n    await foreach (var chunk in _chatService.StreamAsync(userId, chatRoomId, message, cancellationToken))\n    {\n        answer.Append(chunk);\n        yield return chunk;\n    }\n\n    \u002F\u002F 串流結束後，把完整的回答與 Token 用量寫回該使用者的聊天室\n    await _chatService.SaveAnswerAsync(userId, chatRoomId, answer.ToString());\n}\n",[69,2443,2444,2449,2473,2484,2494,2513,2517,2534,2551,2555,2581,2585,2596,2601,2605,2609,2614,2632],{"__ignoreMap":222},[226,2445,2446],{"class":228,"line":229},[226,2447,2448],{"class":394},"\u002F\u002F 示意：OpenAI 每回傳一小段文字，就立刻推給前端\n",[226,2450,2451,2453,2455,2458,2461,2464,2467,2470],{"class":228,"line":247},[226,2452,233],{"class":232},[226,2454,1908],{"class":232},[226,2456,2457],{"class":239}," IAsyncEnumerable",[226,2459,2460],{"class":243},"\u003C",[226,2462,2463],{"class":232},"string",[226,2465,2466],{"class":243},"> ",[226,2468,2469],{"class":239},"SendMessage",[226,2471,2472],{"class":243},"(\n",[226,2474,2475,2478,2481],{"class":228,"line":253},[226,2476,2477],{"class":239},"    Guid",[226,2479,2480],{"class":239}," chatRoomId",[226,2482,2483],{"class":243},",\n",[226,2485,2486,2489,2492],{"class":228,"line":271},[226,2487,2488],{"class":232},"    string",[226,2490,2491],{"class":239}," message",[226,2493,2483],{"class":243},[226,2495,2496,2499,2502,2505,2508,2511],{"class":228,"line":277},[226,2497,2498],{"class":243},"    [",[226,2500,2501],{"class":239},"EnumeratorCancellation",[226,2503,2504],{"class":243},"] ",[226,2506,2507],{"class":239},"CancellationToken",[226,2509,2510],{"class":239}," cancellationToken",[226,2512,306],{"class":243},[226,2514,2515],{"class":228,"line":284},[226,2516,250],{"class":243},[226,2518,2519,2521,2524,2526,2529,2532],{"class":228,"line":309},[226,2520,1925],{"class":232},[226,2522,2523],{"class":239}," userId",[226,2525,901],{"class":232},[226,2527,2528],{"class":243}," Context.UserIdentifier",[226,2530,2531],{"class":232},"!",[226,2533,362],{"class":243},[226,2535,2536,2538,2541,2543,2545,2548],{"class":228,"line":314},[226,2537,1925],{"class":232},[226,2539,2540],{"class":239}," answer",[226,2542,901],{"class":232},[226,2544,469],{"class":232},[226,2546,2547],{"class":239}," StringBuilder",[226,2549,2550],{"class":243},"();\n",[226,2552,2553],{"class":228,"line":323},[226,2554,281],{"emptyLinePlaceholder":280},[226,2556,2557,2560,2563,2565,2567,2570,2572,2575,2578],{"class":228,"line":329},[226,2558,2559],{"class":232},"    await",[226,2561,2562],{"class":232}," foreach",[226,2564,1978],{"class":243},[226,2566,1981],{"class":232},[226,2568,2569],{"class":239}," chunk",[226,2571,1987],{"class":232},[226,2573,2574],{"class":243}," _chatService.",[226,2576,2577],{"class":239},"StreamAsync",[226,2579,2580],{"class":243},"(userId, chatRoomId, message, cancellationToken))\n",[226,2582,2583],{"class":228,"line":344},[226,2584,326],{"class":243},[226,2586,2587,2590,2593],{"class":228,"line":356},[226,2588,2589],{"class":243},"        answer.",[226,2591,2592],{"class":239},"Append",[226,2594,2595],{"class":243},"(chunk);\n",[226,2597,2598],{"class":228,"line":365},[226,2599,2600],{"class":243},"        yield return chunk;\n",[226,2602,2603],{"class":228,"line":374},[226,2604,401],{"class":243},[226,2606,2607],{"class":228,"line":384},[226,2608,281],{"emptyLinePlaceholder":280},[226,2610,2611],{"class":228,"line":391},[226,2612,2613],{"class":394},"    \u002F\u002F 串流結束後，把完整的回答與 Token 用量寫回該使用者的聊天室\n",[226,2615,2616,2618,2620,2623,2626,2629],{"class":228,"line":398},[226,2617,2559],{"class":232},[226,2619,2574],{"class":243},[226,2621,2622],{"class":239},"SaveAnswerAsync",[226,2624,2625],{"class":243},"(userId, chatRoomId, answer.",[226,2627,2628],{"class":239},"ToString",[226,2630,2631],{"class":243},"());\n",[226,2633,2634],{"class":228,"line":404},[226,2635,274],{"class":243},[16,2637,2638],{},"幾個實作上的細節：",[147,2640,2641,2647,2656],{},[150,2642,2643,2646],{},[20,2644,2645],{},"串流中不寫資料庫","：每一小段文字只推給前端，等整段回覆結束後才一次把完整訊\n息寫回聊天室，避免每個字都打一次資料庫",[150,2648,2649,2652,2653,2655],{},[20,2650,2651],{},"支援中途停止","：使用者按下停止或關掉頁面時，",[69,2654,2507],{}," 會一路傳到\nOpenAI 的請求，立刻中斷生成，不會在背景繼續消耗 Token",[150,2657,2658,2661],{},[20,2659,2660],{},"前端只負責拼接","：前端收到一段就接在目前回覆的後面，並重新渲染 Markdown，\n表格和程式碼區塊在生成過程中就會逐步成形",[12,2663,2665],{"id":2664},"帳號整合openid-connect","帳號整合：OpenID Connect",[16,2667,2668],{},"一開始的版本，聊天紀錄是跟著「瀏覽器」走的：換一台電腦、清掉瀏覽器資料，之前的\n對話就找不回來，也沒辦法知道某段對話是誰發起的。",[16,2670,2671,2672,2675],{},"後來把 NexusGPT 串上公司的 ",[20,2673,2674],{},"OpenID Connect"," 服務：",[147,2677,2678,2684,2690,2696],{},[150,2679,2680,2683],{},[20,2681,2682],{},"公司帳號登入","：同仁直接用既有的公司帳號登入，不需要另外註冊；離職的帳號停\n用後，自然也就無法再使用",[150,2685,2686,2689],{},[20,2687,2688],{},"對話跟著使用者走","：後端驗證 Token 後，從中取出使用者的唯一識別碼，所有聊天\n室都以這個 ID 存放與查詢。不論在公司電腦、筆電還是換了瀏覽器，登入後看到的都\n是自己的聊天室列表與完整對話紀錄",[150,2691,2692,2695],{},[20,2693,2694],{},"只看得到自己的資料","：查詢、改名、刪除聊天室時，一律以 Token 中的使用者 ID 過\n濾，不能靠改網址或參數看到別人的對話",[150,2697,2698,2701],{},[20,2699,2700],{},"用量可以歸戶","：每段對話的 GPT Token 費用都記在對應的使用者底下，各部門的實\n際用量一目了然",[16,2703,2704,2705,2708],{},"SignalR 的連線同樣需要帶上 Token，Hub 透過 ",[69,2706,2707],{},"Context.UserIdentifier"," 就能知道目前\n是哪位使用者在說話，即時對話與一般 API 共用同一套身分驗證。",[12,2710,2711],{"id":2711},"功能",[16,2713,2714,2717],{},[20,2715,2716],{},"聊天室","：建立、改名、刪除、列表、取得內容、上傳、分享、搜尋聊天室",[16,2719,2720,2723],{},[20,2721,2722],{},"聊天室訊息","：支援一般文字對話（串流回覆），以及 DALL-E 3 圖片訊息",[16,2725,2726,2729],{},[20,2727,2728],{},"資料分析","：上傳資料讓 GPT-4 分析，並可把 AI 回覆內容匯出成 Excel",[12,2731,2732],{"id":2732},"架構設計",[16,2734,2735],{},"同樣採用 Hexagonal Architecture，並把「圖片儲存」做成可抽換的 Port／Adapter：",[147,2737,2738,2748,2754,2760,2773,2779],{},[150,2739,2740,2743,2744,2747],{},[69,2741,2742],{},"NexusGPT.Entities","：聚合根與領域事件（",[69,2745,2746],{},"Events.cs","），以 Aggregate Root、\nCode First 方式建模",[150,2749,2750,2753],{},[69,2751,2752],{},"NexusGPT.UseCase","：應用層服務（建立\u002F變更聊天室、新增文字訊息、新增圖片訊\n息、以 Stream 方式新增訊息）",[150,2755,2756,2759],{},[69,2757,2758],{},"NexusGPT.Adapter.Out","：以 EF Core 存取 SQL Server 的資料庫存取層",[150,2761,2762,124,2765,2768,2769,2772],{},[69,2763,2764],{},"NexusGPT.Adapter.Out.ImageStorage.Local",[69,2766,2767],{},"NexusGPT.Adapter.Out.ImageStorage.S3","：\n",[20,2770,2771],{},"同一個 Port 的兩種 Adapter 實作","——本機測試用本地檔案系統，正式環境換成\nAWS S3，上層程式完全不需要改動",[150,2774,2775,2778],{},[69,2776,2777],{},"NexusGPT.EventBus","：領域事件匯流排",[150,2780,2781,2784,2785,2788,2789,617],{},[69,2782,2783],{},"NexusGPT.WebApplication","：Web API 進入點，整合 SignalR（",[69,2786,2787],{},"SignalRSwaggerGen","\n讓 SignalR Hub 也能出現在 Swagger 文件中）與 API 版本控制\n（",[69,2790,2791],{},"Asp.Versioning.Mvc.ApiExplorer",[12,2793,2794],{"id":2794},"部署方式",[217,2796,2800],{"className":2797,"code":2798,"language":2799,"meta":222,"style":222},"language-bash shiki shiki-themes github-dark","# 建立 Migration\ndotnet ef migrations add InitialCreate \\\n  --project NexusGPT.Adapter.Out --startup-project NexusGPT.WebApplication\n\n# 打包 Docker image\ndocker build -f NexusGPT.WebApplication\u002FDockerfile -t nexusgpt:0.0.1 .\n\n# 使用 AWS S3 儲存圖片\ndocker run -d -p 8080:80 --name nexusgpt \\\n  -e ConnectionString=\"Your ConnectionString\" \\\n  -e AWS_ACCESSKEY=\"Your AWSAccessKey\" \\\n  -e AWS_SECRET=\"Your AWSSecretKey\" \\\n  -e AWS_REGION=\"Your AWSRegion\" \\\n  -e AWS_BUCKET=\"Your AWSBucketName\" \\\n  -e OPENAI_API_KEY=\"Your OpenAI API Key\" \\\n  nexusgpt:0.0.1\n","bash",[69,2801,2802,2807,2827,2841,2845,2850,2873,2877,2882,2906,2916,2925,2934,2943,2952,2961],{"__ignoreMap":222},[226,2803,2804],{"class":228,"line":229},[226,2805,2806],{"class":394},"# 建立 Migration\n",[226,2808,2809,2812,2815,2818,2821,2824],{"class":228,"line":247},[226,2810,2811],{"class":239},"dotnet",[226,2813,2814],{"class":477}," ef",[226,2816,2817],{"class":477}," migrations",[226,2819,2820],{"class":477}," add",[226,2822,2823],{"class":477}," InitialCreate",[226,2825,2826],{"class":858}," \\\n",[226,2828,2829,2832,2835,2838],{"class":228,"line":253},[226,2830,2831],{"class":858},"  --project",[226,2833,2834],{"class":477}," NexusGPT.Adapter.Out",[226,2836,2837],{"class":858}," --startup-project",[226,2839,2840],{"class":477}," NexusGPT.WebApplication\n",[226,2842,2843],{"class":228,"line":271},[226,2844,281],{"emptyLinePlaceholder":280},[226,2846,2847],{"class":228,"line":277},[226,2848,2849],{"class":394},"# 打包 Docker image\n",[226,2851,2852,2855,2858,2861,2864,2867,2870],{"class":228,"line":284},[226,2853,2854],{"class":239},"docker",[226,2856,2857],{"class":477}," build",[226,2859,2860],{"class":858}," -f",[226,2862,2863],{"class":477}," NexusGPT.WebApplication\u002FDockerfile",[226,2865,2866],{"class":858}," -t",[226,2868,2869],{"class":477}," nexusgpt:0.0.1",[226,2871,2872],{"class":477}," .\n",[226,2874,2875],{"class":228,"line":309},[226,2876,281],{"emptyLinePlaceholder":280},[226,2878,2879],{"class":228,"line":314},[226,2880,2881],{"class":394},"# 使用 AWS S3 儲存圖片\n",[226,2883,2884,2886,2889,2892,2895,2898,2901,2904],{"class":228,"line":323},[226,2885,2854],{"class":239},[226,2887,2888],{"class":477}," run",[226,2890,2891],{"class":858}," -d",[226,2893,2894],{"class":858}," -p",[226,2896,2897],{"class":477}," 8080:80",[226,2899,2900],{"class":858}," --name",[226,2902,2903],{"class":477}," nexusgpt",[226,2905,2826],{"class":858},[226,2907,2908,2911,2914],{"class":228,"line":329},[226,2909,2910],{"class":858},"  -e",[226,2912,2913],{"class":477}," ConnectionString=\"Your ConnectionString\"",[226,2915,2826],{"class":858},[226,2917,2918,2920,2923],{"class":228,"line":344},[226,2919,2910],{"class":858},[226,2921,2922],{"class":477}," AWS_ACCESSKEY=\"Your AWSAccessKey\"",[226,2924,2826],{"class":858},[226,2926,2927,2929,2932],{"class":228,"line":356},[226,2928,2910],{"class":858},[226,2930,2931],{"class":477}," AWS_SECRET=\"Your AWSSecretKey\"",[226,2933,2826],{"class":858},[226,2935,2936,2938,2941],{"class":228,"line":365},[226,2937,2910],{"class":858},[226,2939,2940],{"class":477}," AWS_REGION=\"Your AWSRegion\"",[226,2942,2826],{"class":858},[226,2944,2945,2947,2950],{"class":228,"line":374},[226,2946,2910],{"class":858},[226,2948,2949],{"class":477}," AWS_BUCKET=\"Your AWSBucketName\"",[226,2951,2826],{"class":858},[226,2953,2954,2956,2959],{"class":228,"line":384},[226,2955,2910],{"class":858},[226,2957,2958],{"class":477}," OPENAI_API_KEY=\"Your OpenAI API Key\"",[226,2960,2826],{"class":858},[226,2962,2963],{"class":228,"line":391},[226,2964,2965],{"class":477},"  nexusgpt:0.0.1\n",[16,2967,2968,2969,2972],{},"換成本地檔案儲存則只需要把 S3 相關環境變數換成一個掛載路徑（",[69,2970,2971],{},"-v \u002Fpath\u002Fto\u002Flocal\u002Ffile:\u002Fapp\u002FImages","），不需要改任何程式碼——這就是 Port／Adapter\n模式的意義所在。",[12,2974,2976],{"id":2975},"實際導入內容公司內部版本","實際導入內容（公司內部版本）",[147,2978,2979,2982,2985,2988,2991,2994],{},[150,2980,2981],{},"仿 ChatGPT 的深色對話介面，同仁無需教育訓練即可上手",[150,2983,2984],{},"串接公司 OpenID Connect 登入，聊天室與對話紀錄依使用者保存，跨裝置都找得回來",[150,2986,2987],{},"建立個人聊天室，儲存對話紀錄、計算每段對話的 GPT Token 費用，並支援聊天室分享",[150,2989,2990],{},"串接 GPT-4，支援上傳資料進行分析，並可將 AI 回覆內容匯出成 Excel",[150,2992,2993],{},"串接 DALL-E 3 圖片對話，並將生成圖片儲存至 AWS S3",[150,2995,2996],{},"搭配 SignalR（WebSocket）與 OpenAI 串流回覆，實現逐字顯示的即時對話",[12,2998,1274],{"id":1274},[147,3000,3001,3006,3009,3012],{},[150,3002,3003,3004],{},"公司採購 GPT-4 的費用減省 ",[20,3005,2218],{},[150,3007,3008],{},"有效過濾資安風險，防止開發人員將內部程式碼提供給外部 GPT 工具",[150,3010,3011],{},"提升企劃、行政等單位的作業效率，如圖片生成、報表分析與資料整理",[150,3013,3014],{},"所有對話與用量都能對應到個人帳號，管理與費用歸屬一目了然",[12,3016,3018],{"id":3017},"尚未完成規劃中","尚未完成／規劃中",[16,3020,3021],{},"開源版本 README 上列出的後續項目：支援 AWS DynamoDB、支援 MongoDB、加上使用\n者驗證、加上 OpenTelemetry 可觀測性。（使用者驗證在公司內部版本是以 OpenID\nConnect 完成，開源版本因為拿掉了內部串接，尚未補上。）",[751,3023,3024],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}",{"title":222,"searchDepth":247,"depth":247,"links":3026},[3027,3028,3029,3030,3031,3032,3033,3034,3035,3036,3037],{"id":14,"depth":247,"text":14},{"id":2334,"depth":247,"text":2335},{"id":586,"depth":247,"text":586},{"id":2416,"depth":247,"text":2417},{"id":2664,"depth":247,"text":2665},{"id":2711,"depth":247,"text":2711},{"id":2732,"depth":247,"text":2732},{"id":2794,"depth":247,"text":2794},{"id":2975,"depth":247,"text":2976},{"id":1274,"depth":247,"text":1274},{"id":3017,"depth":247,"text":3018},"\u002Fimages\u002Fportfolio\u002Fnexusgpt-cover.svg",{},"\u002Fportfolio\u002Fnexusgpt","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FNexusGPT","後端開發與架構設計 ‧ 房仲業",{"title":2310,"description":222},[774,2296,2430,2434,2674,776,777,778,786,2301,3045,3046,3047],"OpenAI API","AWS S3","Docker","portfolio\u002Fnexusgpt","公司內部使用的 ChatGPT 平台，前端做成和 ChatGPT 幾乎一樣的深色對話介面，後端以 SignalR（WebSocket）把 GPT 回覆逐字即時推送到畫面上，並串接公司的 OpenID Connect 登入，讓每個人的聊天室跟著帳號走、換電腦也找得回來；具備聊天室、圖片生成與 AI 資料分析功能，使外部 GPT 費用節省 3 倍以上。開源版本以 Hexagonal Architecture 實作，儲存後端可在本機檔案與 AWS S3 間直接抽換。","xqkDy8O92TGTrOjHnhpXaxJj60-Iz76w9WamLjDmWK0",{"id":3052,"title":3053,"award":7,"body":3054,"cover":4117,"description":222,"extension":766,"featured":1293,"link":7,"meta":4118,"navigation":280,"order":309,"path":4119,"pinned":1293,"repo":4120,"role":4121,"seo":4122,"stack":4123,"stem":4130,"summary":4131,"year":2306,"__hash__":4132},"portfolio\u002Fportfolio\u002Feventbus-dotnet.md","EventBus（.NET）",{"type":9,"value":3055,"toc":4108},[3056,3058,3077,3084,3136,3139,3146,3152,3163,3170,3174,3180,3198,3260,3263,3319,3330,3333,3348,3620,3623,3657,3667,3670,3676,3700,3706,3730,3834,3839,3910,3920,4002,4004,4080,4082,4105],[12,3057,14],{"id":14},[16,3059,3060,3061,3064,3065,3068,3069,3072,3073,3076],{},"一個小型開源函式庫，把 .NET 中的",[20,3062,3063],{},"事件發布與訂閱（Pub\u002FSub）抽象化","：應用程式\n只需要面對 ",[69,3066,3067],{},"IEventBus"," 和 ",[69,3070,3071],{},"IEventHandler\u003CT>"," 兩個介面，不需要在意事件是透過記\n憶體派送，還是透過 RabbitMQ 傳遞——",[20,3074,3075],{},"換傳輸方式只需要換一行 DI 設定","，不需要改\n任何業務程式碼。",[16,3078,3079,3080,3083],{},"已透過 ",[69,3081,3082],{},"GeneratePackageOnBuild"," 發布成三個獨立的 NuGet 套件：",[217,3085,3089],{"className":3086,"code":3087,"language":3088,"meta":222,"style":222},"language-shell shiki shiki-themes github-dark","Install-Package EventBus -Version 0.0.1-beta            # 核心介面\nInstall-Package EventBus.InMemory -Version 0.0.1-beta    # 記憶體傳輸\nInstall-Package EventBus.RabbitMQ -Version 0.0.1-beta    # RabbitMQ 傳輸（底層為 EasyNetQ）\n","shell",[69,3090,3091,3108,3122],{"__ignoreMap":222},[226,3092,3093,3096,3099,3102,3105],{"class":228,"line":229},[226,3094,3095],{"class":239},"Install-Package",[226,3097,3098],{"class":477}," EventBus",[226,3100,3101],{"class":858}," -Version",[226,3103,3104],{"class":477}," 0.0.1-beta",[226,3106,3107],{"class":394},"            # 核心介面\n",[226,3109,3110,3112,3115,3117,3119],{"class":228,"line":247},[226,3111,3095],{"class":239},[226,3113,3114],{"class":477}," EventBus.InMemory",[226,3116,3101],{"class":858},[226,3118,3104],{"class":477},[226,3120,3121],{"class":394},"    # 記憶體傳輸\n",[226,3123,3124,3126,3129,3131,3133],{"class":228,"line":253},[226,3125,3095],{"class":239},[226,3127,3128],{"class":477}," EventBus.RabbitMQ",[226,3130,3101],{"class":858},[226,3132,3104],{"class":477},[226,3134,3135],{"class":394},"    # RabbitMQ 傳輸（底層為 EasyNetQ）\n",[12,3137,3138],{"id":3138},"為什麼做這個",[16,3140,3141,3142,3145],{},"在事件驅動的專案裡，Pub\u002FSub 幾乎每個服務都會用到，但每次都直接寫 RabbitMQ 的程\n式碼，業務邏輯就會和訊息佇列綁在一起：開發、單元測試時要先架好一台 RabbitMQ，\n想換成別的傳輸方式也要整個重寫。所以我想要的是一個",[20,3143,3144],{},"抽象化的 Pub\u002FSub","——業務\n程式碼只說「發布這個事件」「我要處理這個事件」，至於事件怎麼送過去，交給底層決\n定。",[16,3147,3148,3149,155],{},"市面上當然有現成的選擇，例如 Azure Service Bus 的 SDK、MassTransit，但實際評估\n後發現它們對我的需求來說",[20,3150,3151],{},"都做得太複雜了",[147,3153,3154,3157,3160],{},[150,3155,3156],{},"功能涵蓋 Saga、排程訊息、請求／回應、多種傳輸的細部設定……真正會用到的只有\n「發布」和「訂閱」，卻得先理解一整套框架的概念與設定方式",[150,3158,3159],{},"框架本身的抽象層很厚，出問題時要追到框架內部才知道訊息卡在哪裡",[150,3161,3162],{},"團隊裡的每個人都要先學會這套框架，才能開始寫一個簡單的事件處理",[16,3164,3165,3166,3169],{},"我需要的其實只是一層很薄的抽象：",[20,3167,3168],{},"介面夠少、行為好預測、程式碼讀得完","。所以就\n自己做了一個，把實務上反覆用到的 Pub\u002FSub 模式抽出來，發布成獨立的函式庫，之後的\n專案直接引用。",[12,3171,3173],{"id":3172},"設計概念比照-servicecollection","設計概念：比照 ServiceCollection",[16,3175,3176],{},[142,3177],{"alt":3178,"src":3179},"EventBus 訂閱機制示意圖：啟動時 AddEventHandlersFromAssembly 以反射掃描組件，找出實作 IEventHandler 泛型介面的類別並取出事件型別，登記到 SubscriptionCollection；執行時 IEventBus 發布事件經過傳輸層，背景服務依事件型別查表，從 DI 取出對應的 Handler 並呼叫 HandleAsync","\u002Fimages\u002Fportfolio\u002Feventbus-subscription.svg",[16,3181,3182,3183,3186,3187,3190,3191,3197],{},".NET 開發者每天都在用 ",[69,3184,3185],{},"IServiceCollection","：啟動時把服務註冊進去，執行時由\n",[69,3188,3189],{},"ServiceProvider"," 依型別解析出實例。我希望訂閱事件的方式也一樣直覺，所以",[20,3192,3193,3194],{},"比照\nServiceCollection 的設計，多做了一個 ",[69,3195,3196],{},"SubscriptionCollection"," 專門管理訂閱\n者：",[40,3199,3200,3211],{},[43,3201,3202],{},[46,3203,3204,3206,3209],{},[49,3205],{},[49,3207,3208],{},"ServiceCollection",[49,3210,3196],{},[58,3212,3213,3224,3234,3245],{},[46,3214,3215,3218,3221],{},[63,3216,3217],{},"登記的是什麼",[63,3219,3220],{},"服務型別 → 實作型別",[63,3222,3223],{},"事件型別 → 訂閱者（Handler）型別",[46,3225,3226,3229,3232],{},[63,3227,3228],{},"什麼時候登記",[63,3230,3231],{},"應用程式啟動時",[63,3233,3231],{},[46,3235,3236,3239,3242],{},[63,3237,3238],{},"什麼時候用到",[63,3240,3241],{},"需要服務時解析出實例",[63,3243,3244],{},"收到事件時找出要通知哪些 Handler",[46,3246,3247,3250,3255],{},[63,3248,3249],{},"註冊方式",[63,3251,3252],{},[69,3253,3254],{},"services.AddScoped\u003CIFoo, Foo>()",[63,3256,3257],{},[69,3258,3259],{},"s.AddEventHandlersFromAssembly()",[16,3261,3262],{},"使用者寫起來就和註冊一般服務沒有兩樣：",[217,3264,3266],{"className":219,"code":3265,"language":221,"meta":222,"style":222},"builder.Services.AddEventBus(o => o\n    .UseSubscriptions(s => s.AddEventHandlersFromAssembly())\n    .UseInMemory());\n",[69,3267,3268,3286,3310],{"__ignoreMap":222},[226,3269,3270,3273,3276,3278,3281,3283],{"class":228,"line":229},[226,3271,3272],{"class":243},"builder.Services.",[226,3274,3275],{"class":239},"AddEventBus",[226,3277,259],{"class":243},[226,3279,3280],{"class":239},"o",[226,3282,2069],{"class":232},[226,3284,3285],{"class":243}," o\n",[226,3287,3288,3291,3294,3296,3299,3301,3304,3307],{"class":228,"line":247},[226,3289,3290],{"class":243},"    .",[226,3292,3293],{"class":239},"UseSubscriptions",[226,3295,259],{"class":243},[226,3297,3298],{"class":239},"s",[226,3300,2069],{"class":232},[226,3302,3303],{"class":243}," s.",[226,3305,3306],{"class":239},"AddEventHandlersFromAssembly",[226,3308,3309],{"class":243},"())\n",[226,3311,3312,3314,3317],{"class":228,"line":253},[226,3313,3290],{"class":243},[226,3315,3316],{"class":239},"UseInMemory",[226,3318,2631],{"class":243},[16,3320,3321,3323,3324,3326,3327,3329],{},[69,3322,3196],{}," 只負責記錄「哪個事件要交給哪些 Handler」，Handler 本\n身仍然註冊在 .NET 原本的 ",[69,3325,3185],{}," 裡。收到事件時，先查\n",[69,3328,3196],{}," 找出 Handler 型別，再從 DI 建立實例——所以 Handler 一\n樣可以用建構子注入 Repository、Logger 等任何服務，生命週期也交給 DI 管理。",[12,3331,3332],{"id":3332},"以反射自動找出訂閱者",[16,3334,3335,3336,3339,3340,3343,3344,3347],{},"如果每新增一個事件處理器，都要回到 ",[69,3337,3338],{},"Program.cs"," 手動寫一行 ",[69,3341,3342],{},"Subscribe","，久了一\n定會有人忘記。所以訂閱的登記交給",[20,3345,3346],{},"反射","自動完成：",[217,3349,3351],{"className":219,"code":3350,"language":221,"meta":222,"style":222},"\u002F\u002F 示意：掃描組件，找出所有實作 IEventHandler\u003CT> 的類別\npublic SubscriptionCollection AddEventHandlersFromAssembly(Assembly assembly)\n{\n    var handlers = assembly.GetTypes()\n        .Where(t => t is { IsClass: true, IsAbstract: false })\n        .SelectMany(t => t.GetInterfaces()\n            .Where(i => i.IsGenericType &&\n                        i.GetGenericTypeDefinition() == typeof(IEventHandler\u003C>))\n            .Select(i => (EventType: i.GetGenericArguments()[0], HandlerType: t)));\n\n    foreach (var (eventType, handlerType) in handlers)\n    {\n        Add(eventType, handlerType);              \u002F\u002F 登記到 SubscriptionCollection\n        _services.AddScoped(handlerType);         \u002F\u002F Handler 本身交給 DI 管理\n    }\n\n    return this;\n}\n",[69,3352,3353,3358,3378,3382,3399,3434,3455,3475,3499,3538,3542,3569,3573,3584,3598,3602,3606,3616],{"__ignoreMap":222},[226,3354,3355],{"class":228,"line":229},[226,3356,3357],{"class":394},"\u002F\u002F 示意：掃描組件，找出所有實作 IEventHandler\u003CT> 的類別\n",[226,3359,3360,3362,3365,3368,3370,3373,3376],{"class":228,"line":247},[226,3361,233],{"class":232},[226,3363,3364],{"class":239}," SubscriptionCollection",[226,3366,3367],{"class":239}," AddEventHandlersFromAssembly",[226,3369,259],{"class":243},[226,3371,3372],{"class":239},"Assembly",[226,3374,3375],{"class":239}," assembly",[226,3377,306],{"class":243},[226,3379,3380],{"class":228,"line":253},[226,3381,250],{"class":243},[226,3383,3384,3386,3389,3391,3394,3397],{"class":228,"line":271},[226,3385,1925],{"class":232},[226,3387,3388],{"class":239}," handlers",[226,3390,901],{"class":232},[226,3392,3393],{"class":243}," assembly.",[226,3395,3396],{"class":239},"GetTypes",[226,3398,244],{"class":243},[226,3400,3401,3404,3407,3409,3411,3413,3416,3419,3422,3425,3428,3431],{"class":228,"line":277},[226,3402,3403],{"class":243},"        .",[226,3405,3406],{"class":239},"Where",[226,3408,259],{"class":243},[226,3410,1096],{"class":239},[226,3412,2069],{"class":232},[226,3414,3415],{"class":243}," t ",[226,3417,3418],{"class":232},"is",[226,3420,3421],{"class":243}," { IsClass: ",[226,3423,3424],{"class":858},"true",[226,3426,3427],{"class":243},", IsAbstract: ",[226,3429,3430],{"class":858},"false",[226,3432,3433],{"class":243}," })\n",[226,3435,3436,3438,3441,3443,3445,3447,3450,3453],{"class":228,"line":284},[226,3437,3403],{"class":243},[226,3439,3440],{"class":239},"SelectMany",[226,3442,259],{"class":243},[226,3444,1096],{"class":239},[226,3446,2069],{"class":232},[226,3448,3449],{"class":243}," t.",[226,3451,3452],{"class":239},"GetInterfaces",[226,3454,244],{"class":243},[226,3456,3457,3460,3462,3464,3467,3469,3472],{"class":228,"line":309},[226,3458,3459],{"class":243},"            .",[226,3461,3406],{"class":239},[226,3463,259],{"class":243},[226,3465,3466],{"class":239},"i",[226,3468,2069],{"class":232},[226,3470,3471],{"class":243}," i.IsGenericType ",[226,3473,3474],{"class":232},"&&\n",[226,3476,3477,3480,3483,3486,3488,3491,3493,3496],{"class":228,"line":314},[226,3478,3479],{"class":243},"                        i.",[226,3481,3482],{"class":239},"GetGenericTypeDefinition",[226,3484,3485],{"class":243},"() ",[226,3487,442],{"class":232},[226,3489,3490],{"class":232}," typeof",[226,3492,259],{"class":243},[226,3494,3495],{"class":239},"IEventHandler",[226,3497,3498],{"class":243},"\u003C>))\n",[226,3500,3501,3503,3506,3508,3510,3512,3514,3517,3520,3523,3526,3529,3532,3535],{"class":228,"line":323},[226,3502,3459],{"class":243},[226,3504,3505],{"class":239},"Select",[226,3507,259],{"class":243},[226,3509,3466],{"class":239},[226,3511,2069],{"class":232},[226,3513,1978],{"class":243},[226,3515,3516],{"class":239},"EventType",[226,3518,3519],{"class":243},": i.",[226,3521,3522],{"class":239},"GetGenericArguments",[226,3524,3525],{"class":243},"()[",[226,3527,3528],{"class":858},"0",[226,3530,3531],{"class":243},"], ",[226,3533,3534],{"class":239},"HandlerType",[226,3536,3537],{"class":243},": t)));\n",[226,3539,3540],{"class":228,"line":329},[226,3541,281],{"emptyLinePlaceholder":280},[226,3543,3544,3546,3548,3550,3552,3555,3557,3560,3563,3566],{"class":228,"line":344},[226,3545,1975],{"class":232},[226,3547,1978],{"class":243},[226,3549,1981],{"class":232},[226,3551,1978],{"class":243},[226,3553,3554],{"class":239},"eventType",[226,3556,1553],{"class":243},[226,3558,3559],{"class":239},"handlerType",[226,3561,3562],{"class":243},") ",[226,3564,3565],{"class":232},"in",[226,3567,3568],{"class":243}," handlers)\n",[226,3570,3571],{"class":228,"line":356},[226,3572,326],{"class":243},[226,3574,3575,3578,3581],{"class":228,"line":365},[226,3576,3577],{"class":239},"        Add",[226,3579,3580],{"class":243},"(eventType, handlerType);              ",[226,3582,3583],{"class":394},"\u002F\u002F 登記到 SubscriptionCollection\n",[226,3585,3586,3589,3592,3595],{"class":228,"line":374},[226,3587,3588],{"class":243},"        _services.",[226,3590,3591],{"class":239},"AddScoped",[226,3593,3594],{"class":243},"(handlerType);         ",[226,3596,3597],{"class":394},"\u002F\u002F Handler 本身交給 DI 管理\n",[226,3599,3600],{"class":228,"line":384},[226,3601,401],{"class":243},[226,3603,3604],{"class":228,"line":391},[226,3605,281],{"emptyLinePlaceholder":280},[226,3607,3608,3611,3614],{"class":228,"line":398},[226,3609,3610],{"class":232},"    return",[226,3612,3613],{"class":858}," this",[226,3615,362],{"class":243},[226,3617,3618],{"class":228,"line":404},[226,3619,274],{"class":243},[16,3621,3622],{},"流程是：",[2388,3624,3625,3631,3638,3651],{},[150,3626,3627,3628],{},"列出組件中所有",[20,3629,3630],{},"非抽象的類別",[150,3632,3633,3634,3637],{},"檢查它實作的介面，找出 ",[69,3635,3636],{},"IEventHandler\u003C>"," 這個泛型介面",[150,3639,3640,3641,3644,3645,3647,3648],{},"用 ",[69,3642,3643],{},"GetGenericArguments()"," 取出泛型參數 ",[69,3646,1107],{},"，也就是",[20,3649,3650],{},"它要處理的事件型別",[150,3652,3653,3654,3656],{},"把「事件型別 → Handler 型別」登記到 ",[69,3655,3196],{},"，同時把 Handler\n註冊進 DI",[16,3658,3659,3660,3662,3663,3666],{},"一個類別也可以同時實作多個 ",[69,3661,3071],{},"，處理多種事件；同一個事件也可以有\n多個 Handler 訂閱，發布時會逐一通知。反射只在",[20,3664,3665],{},"啟動時","跑一次，執行期間派送事件\n只是查表，不會有額外的效能負擔。",[12,3668,3669],{"id":3669},"使用方式",[16,3671,3672,3675],{},[20,3673,3674],{},"註冊（記憶體版）","：適合單一服務內的事件，或開發、測試時使用",[217,3677,3679],{"className":219,"code":3678,"language":221,"meta":222,"style":222},"builder.Services.AddEventBus(o => o.UseInMemory());\n",[69,3680,3681],{"__ignoreMap":222},[226,3682,3683,3685,3687,3689,3691,3693,3696,3698],{"class":228,"line":229},[226,3684,3272],{"class":243},[226,3686,3275],{"class":239},[226,3688,259],{"class":243},[226,3690,3280],{"class":239},[226,3692,2069],{"class":232},[226,3694,3695],{"class":243}," o.",[226,3697,3316],{"class":239},[226,3699,2631],{"class":243},[16,3701,3702,3705],{},[20,3703,3704],{},"註冊（RabbitMQ 版）","：跨服務傳遞事件",[217,3707,3709],{"className":219,"code":3708,"language":221,"meta":222,"style":222},"builder.Services.AddEventBus(o => o.UseRabbitMQ());\n",[69,3710,3711],{"__ignoreMap":222},[226,3712,3713,3715,3717,3719,3721,3723,3725,3728],{"class":228,"line":229},[226,3714,3272],{"class":243},[226,3716,3275],{"class":239},[226,3718,259],{"class":243},[226,3720,3280],{"class":239},[226,3722,2069],{"class":232},[226,3724,3695],{"class":243},[226,3726,3727],{"class":239},"UseRabbitMQ",[226,3729,2631],{"class":243},[217,3731,3733],{"className":1487,"code":3732,"language":1489,"meta":222,"style":222},"{\n  \"RabbitMqOption\": {\n    \"ExchangeName\": \"sample.exchange\",\n    \"QueueName\": \"sample.queue\",\n    \"ConnectionString\": \"localhost:5672\",\n    \"PublishRetryCount\": 3,\n    \"SubscribeRetryTime\": 3,\n    \"MaxPriority\": 10,\n    \"ConsumePrefetchCount\": 2\n  }\n}\n",[69,3734,3735,3739,3746,3758,3770,3782,3794,3805,3816,3826,3830],{"__ignoreMap":222},[226,3736,3737],{"class":228,"line":229},[226,3738,250],{"class":243},[226,3740,3741,3744],{"class":228,"line":247},[226,3742,3743],{"class":858},"  \"RabbitMqOption\"",[226,3745,1508],{"class":243},[226,3747,3748,3751,3753,3756],{"class":228,"line":253},[226,3749,3750],{"class":858},"    \"ExchangeName\"",[226,3752,1529],{"class":243},[226,3754,3755],{"class":477},"\"sample.exchange\"",[226,3757,2483],{"class":243},[226,3759,3760,3763,3765,3768],{"class":228,"line":271},[226,3761,3762],{"class":858},"    \"QueueName\"",[226,3764,1529],{"class":243},[226,3766,3767],{"class":477},"\"sample.queue\"",[226,3769,2483],{"class":243},[226,3771,3772,3775,3777,3780],{"class":228,"line":277},[226,3773,3774],{"class":858},"    \"ConnectionString\"",[226,3776,1529],{"class":243},[226,3778,3779],{"class":477},"\"localhost:5672\"",[226,3781,2483],{"class":243},[226,3783,3784,3787,3789,3792],{"class":228,"line":284},[226,3785,3786],{"class":858},"    \"PublishRetryCount\"",[226,3788,1529],{"class":243},[226,3790,3791],{"class":858},"3",[226,3793,2483],{"class":243},[226,3795,3796,3799,3801,3803],{"class":228,"line":309},[226,3797,3798],{"class":858},"    \"SubscribeRetryTime\"",[226,3800,1529],{"class":243},[226,3802,3791],{"class":858},[226,3804,2483],{"class":243},[226,3806,3807,3810,3812,3814],{"class":228,"line":314},[226,3808,3809],{"class":858},"    \"MaxPriority\"",[226,3811,1529],{"class":243},[226,3813,1561],{"class":858},[226,3815,2483],{"class":243},[226,3817,3818,3821,3823],{"class":228,"line":323},[226,3819,3820],{"class":858},"    \"ConsumePrefetchCount\"",[226,3822,1529],{"class":243},[226,3824,3825],{"class":858},"2\n",[226,3827,3828],{"class":228,"line":329},[226,3829,959],{"class":243},[226,3831,3832],{"class":228,"line":344},[226,3833,274],{"class":243},[16,3835,3836],{},[20,3837,3838],{},"發布事件",[217,3840,3842],{"className":219,"code":3841,"language":221,"meta":222,"style":222},"app.MapGet(\"\u002Fweatherforecast\", async (IEventBus eventBus) =>\n{\n    await eventBus.PublishAsync(new TestEvent { Message = \"Test\" });\n});\n",[69,3843,3844,3874,3878,3905],{"__ignoreMap":222},[226,3845,3846,3849,3852,3854,3857,3859,3862,3864,3866,3869,3871],{"class":228,"line":229},[226,3847,3848],{"class":243},"app.",[226,3850,3851],{"class":239},"MapGet",[226,3853,259],{"class":243},[226,3855,3856],{"class":477},"\"\u002Fweatherforecast\"",[226,3858,1553],{"class":243},[226,3860,3861],{"class":232},"async",[226,3863,1978],{"class":243},[226,3865,3067],{"class":239},[226,3867,3868],{"class":239}," eventBus",[226,3870,3562],{"class":243},[226,3872,3873],{"class":232},"=>\n",[226,3875,3876],{"class":228,"line":247},[226,3877,250],{"class":243},[226,3879,3880,3882,3885,3887,3889,3891,3894,3897,3899,3902],{"class":228,"line":253},[226,3881,2559],{"class":232},[226,3883,3884],{"class":243}," eventBus.",[226,3886,2005],{"class":239},[226,3888,259],{"class":243},[226,3890,262],{"class":232},[226,3892,3893],{"class":239}," TestEvent",[226,3895,3896],{"class":243}," { Message ",[226,3898,350],{"class":232},[226,3900,3901],{"class":477}," \"Test\"",[226,3903,3904],{"class":243}," });\n",[226,3906,3907],{"class":228,"line":271},[226,3908,3909],{"class":243},"});\n",[16,3911,3912,3915,3916,3919],{},[20,3913,3914],{},"訂閱事件","：實作 ",[69,3917,3918],{},"IEventHandler\u003CTEvent>"," 就好，啟動時會被自動掃描登記",[217,3921,3923],{"className":219,"code":3922,"language":221,"meta":222,"style":222},"public class TestEventHandler : IEventHandler\u003CTestEvent>\n{\n    public Task HandleAsync(TestEvent @event)\n    {\n        Console.WriteLine(@event.Message);\n        return Task.CompletedTask;\n    }\n}\n",[69,3924,3925,3948,3952,3971,3975,3986,3994,3998],{"__ignoreMap":222},[226,3926,3927,3929,3932,3935,3938,3940,3942,3945],{"class":228,"line":229},[226,3928,233],{"class":232},[226,3930,3931],{"class":232}," class",[226,3933,3934],{"class":239}," TestEventHandler",[226,3936,3937],{"class":243}," : ",[226,3939,3495],{"class":239},[226,3941,2460],{"class":243},[226,3943,3944],{"class":239},"TestEvent",[226,3946,3947],{"class":243},">\n",[226,3949,3950],{"class":228,"line":247},[226,3951,250],{"class":243},[226,3953,3954,3957,3959,3962,3964,3966,3969],{"class":228,"line":253},[226,3955,3956],{"class":232},"    public",[226,3958,1911],{"class":239},[226,3960,3961],{"class":239}," HandleAsync",[226,3963,259],{"class":243},[226,3965,3944],{"class":239},[226,3967,3968],{"class":239}," @event",[226,3970,306],{"class":243},[226,3972,3973],{"class":228,"line":271},[226,3974,326],{"class":243},[226,3976,3977,3980,3983],{"class":228,"line":277},[226,3978,3979],{"class":243},"        Console.",[226,3981,3982],{"class":239},"WriteLine",[226,3984,3985],{"class":243},"(@event.Message);\n",[226,3987,3988,3991],{"class":228,"line":284},[226,3989,3990],{"class":232},"        return",[226,3992,3993],{"class":243}," Task.CompletedTask;\n",[226,3995,3996],{"class":228,"line":309},[226,3997,401],{"class":243},[226,3999,4000],{"class":228,"line":314},[226,4001,274],{"class":243},[12,4003,1254],{"id":1254},[147,4005,4006,4022,4037,4051,4064,4067],{},[150,4007,4008,4009,124,4011,4013,4014,4017,4018,4021],{},"抽象化 ",[69,4010,3067],{},[69,4012,3071],{}," 介面，",[69,4015,4016],{},"EventBus.InMemory"," 與\n",[69,4019,4020],{},"EventBus.RabbitMq"," 分別實作記憶體與 RabbitMQ（底層包裝 EasyNetQ）兩種傳輸",[150,4023,4024,4025,4027,4028,4030,4031,4034,4035],{},"比照 ",[69,4026,3185],{}," 設計 ",[69,4029,3196],{}," 管理訂閱者，並提供\n",[69,4032,4033],{},"AddEventHandlersFromAssembly()"," 以反射自動掃描、登記所有 ",[69,4036,3071],{},[150,4038,4039,4040,155,4043,4046,4047,4050],{},"RabbitMQ 版本支援以 ",[20,4041,4042],{},"Attribute 宣告事件的路由設定",[69,4044,4045],{},"[Exchange]"," 指定\nExchange 名稱與類型、",[69,4048,4049],{},"[Event]"," 指定訊息優先權（Priority）與事件版本\n（Version），路由設定跟著事件類別走，不用另外維護一份對照表",[150,4052,4053,124,4056,4059,4060,4063],{},[69,4054,4055],{},"SubscriptionBackgroundService",[69,4057,4058],{},"SubscriberBackgroundService"," 以背景服務\n持續消費訊息，",[69,4061,4062],{},"BackgroundQueue"," 處理記憶體版本的非同步派送",[150,4065,4066],{},"發布與消費都可設定重試次數，RabbitMQ 版本另外支援訊息優先權與 Prefetch 數量",[150,4068,4069,4070,190,4073,4076,4077,617],{},"為兩種傳輸方式撰寫單元測試（",[69,4071,4072],{},"EventBus.Tests",[69,4074,4075],{},"EventBus.InMemory.Tests","）與\n整合測試（",[69,4078,4079],{},"EventBus.InMemory.IntegrationTests",[12,4081,1274],{"id":1274},[147,4083,4084,4092,4095,4098],{},[150,4085,4086,4087,124,4089,4091],{},"業務程式碼只依賴 ",[69,4088,3067],{},[69,4090,3071],{},"，傳輸方式從記憶體換成\nRabbitMQ 只需要改一行設定",[150,4093,4094],{},"單元測試與本機開發改用記憶體版本，不需要先架 RabbitMQ",[150,4096,4097],{},"新增事件處理器只要實作介面，不需要再回頭修改註冊程式碼",[150,4099,4100,4101,4104],{},"後來 ",[4102,4103,6],"a",{"href":769},"\n裡的事件驅動設計，也是以這套函式庫的概念為基礎",[751,4106,4107],{},"html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":222,"searchDepth":247,"depth":247,"links":4109},[4110,4111,4112,4113,4114,4115,4116],{"id":14,"depth":247,"text":14},{"id":3138,"depth":247,"text":3138},{"id":3172,"depth":247,"text":3173},{"id":3332,"depth":247,"text":3332},{"id":3669,"depth":247,"text":3669},{"id":1254,"depth":247,"text":1254},{"id":1274,"depth":247,"text":1274},"\u002Fimages\u002Fportfolio\u002Feventbus-cover.svg",{},"\u002Fportfolio\u002Feventbus-dotnet","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FEventBus","Side Project ‧ 開源函式庫",{"title":3053,"description":222},[774,4124,1890,4125,4126,4127,4128,4129],"Pub\u002FSub","EasyNetQ","Dependency Injection","Reflection","xUnit","NuGet","portfolio\u002Feventbus-dotnet","一個輕量的 Pub\u002FSub 抽象層，已發布為 NuGet 套件。設計上比照 .NET 的 ServiceCollection，多做了一個 SubscriptionCollection 管理訂閱者，並以反射自動找出所有實作 IEventHandler\u003CT> 的類別；同一組 API 可在記憶體與 RabbitMQ（EasyNetQ）之間直接切換，並支援以 Attribute 宣告事件的 Exchange、優先權與版本。","DKcPpcdg6fck-9W2Qojmu1yLd4f2mFi76ud0fFRf56s",{"id":4134,"title":4135,"award":7,"body":4136,"cover":4477,"description":222,"extension":766,"featured":1293,"link":7,"meta":4478,"navigation":280,"order":247,"path":4479,"pinned":1293,"repo":7,"role":4480,"seo":4481,"stack":4482,"stem":4485,"summary":4486,"year":4487,"__hash__":4488},"portfolio\u002Fportfolio\u002Ffranchise-realty-platform.md","加盟房仲平台",{"type":9,"value":4137,"toc":4464},[4138,4140,4151,4161,4219,4230,4232,4238,4241,4265,4269,4272,4279,4299,4306,4308,4317,4324,4327,4353,4356,4363,4367,4374,4381,4388,4391,4397,4407,4409,4430,4432],[12,4139,14],{"id":14},[16,4141,4142,4143,4146,4147,4150],{},"集團除了直營的主力品牌之外，旗下還有",[20,4144,4145],{},"多個加盟房仲品牌","，每個品牌都有自己的\n對外網站。這個專案由我獨立設計並開發，負責",[20,4148,4149],{},"所有加盟品牌網站","背後的平台：從\n資料同步、資料中台、搜尋，到各品牌的前端網站。",[16,4152,4153,4154,4157,4158,155],{},"加盟品牌的物件資料，原本就是由各加盟店在集團的主力平台上建檔、維護，加盟網站要\n做的是把",[20,4155,4156],{},"屬於加盟品牌的物件","即時拿過來，再依品牌呈現在各自的網站上。所以整體\n架構和主力平台很像，最大的差別在於",[20,4159,4160],{},"資料來源",[40,4162,4163,4175],{},[43,4164,4165],{},[46,4166,4167,4169,4172],{},[49,4168],{},[49,4170,4171],{},"主力平台",[49,4173,4174],{},"加盟平台",[58,4176,4177,4187,4198,4208],{},[46,4178,4179,4181,4184],{},[63,4180,4160],{},[63,4182,4183],{},"直接來自 MSSQL 原始資料",[63,4185,4186],{},"接收主力平台發布的物件事件",[46,4188,4189,4192,4195],{},[63,4190,4191],{},"資料範圍",[63,4193,4194],{},"直營物件",[63,4196,4197],{},"只挑出加盟品牌的物件",[46,4199,4200,4203,4206],{},[63,4201,4202],{},"查詢方式",[63,4204,4205],{},"買屋中台等資料中台 + Elasticsearch",[63,4207,4205],{},[46,4209,4210,4213,4216],{},[63,4211,4212],{},"對外網站",[63,4214,4215],{},"一個品牌",[63,4217,4218],{},"多個加盟品牌網站",[16,4220,4221,4222,4225,4226,4229],{},"這也是公司",[20,4223,4224],{},"第一個完全容器化的對外網站","，以及",[20,4227,4228],{},"第一個正式採用\nElasticsearch"," 的專案。",[12,4231,586],{"id":586},[16,4233,4234],{},[142,4235],{"alt":4236,"src":4237},"加盟房仲平台架構圖：集團主力平台發布物件異動事件到 RabbitMQ，同步消費者只挑出加盟物件並轉成加盟用的資料格式，寫入 MSSQL 持久化、寫入 Elasticsearch 供查詢；加盟物件資料中台以 ASP.NET Core Web API 查詢 Elasticsearch，提供買屋搜尋、GEO 鄰近推薦與四大區推薦排序給各加盟品牌網站","\u002Fimages\u002Fportfolio\u002Ffranchise-realty-architecture.svg",[16,4239,4240],{},"整個平台分成三層：",[147,4242,4243,4249,4259],{},[150,4244,4245,4248],{},[20,4246,4247],{},"資料同步","：透過 RabbitMQ 接收主力平台的物件事件，挑出加盟物件後，同時寫入\n加盟專用的 MSSQL（持久化）與 Elasticsearch（查詢）",[150,4250,4251,4254,4255,4258],{},[20,4252,4253],{},"資料中台","：以 ASP.NET Core Web API 實作的",[20,4256,4257],{},"加盟物件資料中台","，包含買屋中台\n等服務，所有查詢都走 Elasticsearch",[150,4260,4261,4264],{},[20,4262,4263],{},"品牌網站","：各加盟品牌的前端網站，透過中台取得資料，只呈現屬於自己品牌的物件",[12,4266,4268],{"id":4267},"資料同步從主力平台接收物件事件","資料同步：從主力平台接收物件事件",[16,4270,4271],{},"加盟物件在主力平台上建檔之後，加盟網站要「馬上」看得到；物件改價、下架，加盟網\n站也要同步反映。如果用排程定期去主力平台的資料庫撈資料，不只會有時間差，也會讓\n加盟平台和主力平台的資料庫綁死在一起。",[16,4273,4274,4275,4278],{},"所以改成",[20,4276,4277],{},"事件驅動","：主力平台在物件新增、修改、下架時發布事件到 RabbitMQ，加\n盟平台訂閱這些事件：",[2388,4280,4281,4287,4293],{},[150,4282,4283,4286],{},[20,4284,4285],{},"過濾","：收到事件後，先判斷這筆物件是否屬於加盟品牌，不是就直接略過",[150,4288,4289,4292],{},[20,4290,4291],{},"轉換","：把主力平台的物件格式，轉成加盟平台需要的資料結構，並標上所屬的\n加盟品牌",[150,4294,4295,4298],{},[20,4296,4297],{},"寫入","：同時寫入加盟專用的 MSSQL 與 Elasticsearch；下架事件則把物件從索引中\n移除",[16,4300,4301,4302,4305],{},"這樣的好處是兩個平台",[20,4303,4304],{},"只透過事件溝通","：主力平台不需要知道有哪些系統在接收它\n的事件，加盟平台也不需要讀主力平台的資料庫。日後主力平台內部怎麼改，只要事件\n格式不變，加盟平台都不受影響。",[12,4307,4257],{"id":4257},[16,4309,4310,4311,4313,4314,4316],{},"和主力平台一樣，加盟平台也把查詢收斂到",[20,4312,4253],{},"，前端網站不直接碰資料庫。\n中台以 ASP.NET Core Web API 實作，包含",[20,4315,1420],{},"等服務，背後全部查詢加盟專用\n的 Elasticsearch。",[16,4318,4319,4320,4323],{},"所有加盟品牌共用同一套中台，查詢時帶上",[20,4321,4322],{},"品牌條件","，每個品牌網站就只會拿到自\n己的物件。新增一個加盟品牌時，不需要再複製一套後端，只要加上品牌的設定即可。",[133,4325,4326],{"id":4326},"物件搜尋",[147,4328,4329,4335,4341],{},[150,4330,4331,4334],{},[20,4332,4333],{},"關鍵字搜尋","：可以用區域、路名、社區名稱、捷運站等關鍵字找物件",[150,4336,4337,4340],{},[20,4338,4339],{},"多條件篩選","：總價、坪數、房數、屋齡、物件類型等條件組合查詢",[150,4342,4343,4346,4347,4349,4350,4352],{},[20,4344,4345],{},"GEO 鄰近物件推薦","：每筆物件建立索引時就把經緯度存成 ",[69,4348,1484],{},"，物件詳細頁\n以目前物件為中心，用 ",[69,4351,1812],{}," 找出附近的其他物件並依距離排序",[133,4354,4355],{"id":4355},"四大區推薦排序",[16,4357,4358,4359,4362],{},"首頁與推薦區塊的物件，依台灣",[20,4360,4361],{},"北、中、南、東四大區","計算並排序：使用者所在或正\n在瀏覽的區域，優先推薦同區的物件，再依序帶出其他區域。加盟店遍布全台，這樣能讓\n各地的使用者先看到離自己近、真正有機會去看屋的物件，而不是被單一區域的大量物件\n洗版。",[12,4364,4366],{"id":4365},"前端angular-express","前端：Angular + Express",[16,4368,4369,4370,4373],{},"房仲網站非常依賴",[20,4371,4372],{},"搜尋引擎流量","，物件頁一定要能被 Google 正確收錄。原本的做法\n是 Angular 搭配 ASP.NET Core MVC 來處理 SEO，前後端技術混在一起，部署和維護都\n比較麻煩。",[16,4375,4376,4377,4380],{},"這次把前端改成 ",[20,4378,4379],{},"Angular + Express","：由 Express 負責在伺服器端渲染頁面，搜尋\n引擎拿到的是完整的 HTML，使用者進到頁面後再由 Angular 接手互動。前端與後端 API\n各自獨立，也各自打包成容器部署。",[16,4382,4383,4384,4387],{},"各加盟品牌的網站共用同一套前端架構，依品牌設定切換",[20,4385,4386],{},"配色、Logo 與版型","，品牌\n之間的差異集中在設定裡，不需要為每個品牌各維護一份程式碼。",[12,4389,4390],{"id":4390},"容器化",[16,4392,4393,4394,4396],{},"這是公司",[20,4395,4224],{},"：前端（Angular + Express）、資料中台\n（ASP.NET Core）、同步消費者都各自打包成 Docker 映像部署。",[16,4398,4399,4400,4402,4403,4406],{},"當時公司其他網站都還跑在 IIS 上，並且依賴大量的",[20,4401,1329],{},"撐住查詢流量，每\n個站台都吃掉大量記憶體。加盟平台因為查詢全部改走 Elasticsearch，",[20,4404,4405],{},"不需要在應\n用程式裡放記憶體快取","，服務本身是無狀態的，也才能順利容器化、隨流量增減實例。",[12,4408,1274],{"id":1274},[147,4410,4411,4418,4421,4424,4427],{},[150,4412,4413,4414,4417],{},"一套平台支撐",[20,4415,4416],{},"集團所有加盟品牌網站","，新增品牌只需要加上設定",[150,4419,4420],{},"加盟物件透過事件即時同步，主力平台上架、改價、下架，加盟網站同步反映",[150,4422,4423],{},"公司第一個完全獨立開發、對外的容器化專案",[150,4425,4426],{},"公司第一個移除記憶體快取、解決 IIS 浪費大量記憶體資源問題的專案",[150,4428,4429],{},"公司第一個正式採用 Elasticsearch 的專案",[12,4431,707],{"id":707},[147,4433,4434,4439,4445,4455,4459],{},[150,4435,4436,4438],{},[20,4437,2231],{},"：C#、ASP.NET Core Web API",[150,4440,4441,4444],{},[20,4442,4443],{},"前端","：Angular、Express（伺服器端渲染）",[150,4446,4447,4449,4450,190,4452,4454],{},[20,4448,2237],{},"：Elasticsearch（",[69,4451,1484],{},[69,4453,1812],{},"）、MSSQL",[150,4456,4457,2251],{},[20,4458,2250],{},[150,4460,4461,4463],{},[20,4462,2273],{},"：Docker",{"title":222,"searchDepth":247,"depth":247,"links":4465},[4466,4467,4468,4469,4473,4474,4475,4476],{"id":14,"depth":247,"text":14},{"id":586,"depth":247,"text":586},{"id":4267,"depth":247,"text":4268},{"id":4257,"depth":247,"text":4257,"children":4470},[4471,4472],{"id":4326,"depth":253,"text":4326},{"id":4355,"depth":253,"text":4355},{"id":4365,"depth":247,"text":4366},{"id":4390,"depth":247,"text":4390},{"id":1274,"depth":247,"text":1274},{"id":707,"depth":247,"text":707},"\u002Fimages\u002Fportfolio\u002Ffranchise-realty-cover.svg",{},"\u002Fportfolio\u002Ffranchise-realty-platform","獨立開發 ‧ 房仲業",{"title":4135,"description":222},[2295,2296,4483,4484,1890,2299,2300,2301,3047],"Angular","Express","portfolio\u002Ffranchise-realty-platform","獨立開發集團旗下所有加盟房仲品牌的網站平台。架構與主力平台相同——由買屋中台等資料中台提供查詢、背後以 Elasticsearch 支撐關鍵字與 GEO 搜尋——差別在於資料來源：透過 RabbitMQ 即時接收主力平台的物件事件，挑出加盟物件同步進加盟專用的索引。是公司第一個完全容器化的對外網站，也是第一個採用 Elasticsearch 的專案。",2022,"pdce6vukbNYMQP73mXpgL4AEFqduOBZ8BWfndr7PTW8",{"id":4490,"title":4491,"award":7,"body":4492,"cover":4558,"description":222,"extension":766,"featured":1293,"link":7,"meta":4845,"navigation":280,"order":253,"path":4846,"pinned":1293,"repo":7,"role":4847,"seo":4848,"stack":4849,"stem":4851,"summary":4852,"year":4487,"__hash__":4853},"portfolio\u002Fportfolio\u002Fb2b-realtor-listing-platform.md","B 端房仲刊登平台",{"type":9,"value":4493,"toc":4831},[4494,4496,4503,4510,4536,4550,4553,4559,4562,4588,4595,4597,4603,4607,4626,4630,4637,4663,4670,4674,4677,4704,4708,4711,4731,4734,4738,4745,4762,4765,4768,4771,4782,4784,4802,4804],[12,4495,14],{"id":14},[16,4497,4498,4499,4502],{},"一般人在房仲網站上看到的物件，背後都要有人「刊登」上去。這個專案做的是",[20,4500,4501],{},"給房仲\n經紀人（B 端）使用的刊登平台","：經紀人在平台上建立物件、上架到前台，並可以付費\n購買各種曝光方案，讓自己的物件被更多人看到。",[16,4504,4505,4506,4509],{},"對公司來說，這是一個",[20,4507,4508],{},"直接向房仲收費的營收產品","。收費項目包括：",[147,4511,4512,4518,4524,4530],{},[150,4513,4514,4517],{},[20,4515,4516],{},"物件刊登","：依刊登件數與天數收費，件數買得越多、單價越低",[150,4519,4520,4523],{},[20,4521,4522],{},"物件排序","：刊登期間每天自動更新數次，讓物件重新回到列表前面",[150,4525,4526,4529],{},[20,4527,4528],{},"物件置頂（關注焦點）","：在列表最上方的重點曝光區塊固定顯示一段時間",[150,4531,4532,4535],{},[20,4533,4534],{},"刊登廣告","：售屋、租屋各自有刊登與廣告方案",[16,4537,4538,4539,4542,4543,4546,4547,2424],{},"整個系統分成 ",[20,4540,4541],{},"B 端物件、點數、會員"," 三大核心，再加上負責推播的",[20,4544,4545],{},"通知中心","，\n並且",[20,4548,4549],{},"全部建置在雲端",[12,4551,4552],{"id":4552},"收費方案",[16,4554,4555],{},[142,4556],{"alt":4557,"src":4558},"B 端刊登平台的收費方案頁面：左側為售屋刊登與廣告、租屋刊登與廣告、點數加值等選單；右側列出物件刊登 90 天（每筆每天自動更新 3 次）的 1、20、50、70 件方案與對應折扣，以及關注焦點 7 天（列表最置頂）方案","\u002Fimages\u002Fportfolio\u002Fb2b-listing-pricing.png",[16,4560,4561],{},"上圖是平台上的收費方案頁面，可以看到幾個設計重點：",[147,4563,4564,4570,4576,4582],{},[150,4565,4566,4569],{},[20,4567,4568],{},"方案 = 件數 × 天數 × 權益","：例如「物件刊登 90 天，每筆每天自動更新 3 次」，\n購買 20 件、50 件、70 件分別有不同折扣，買越多日單價越低",[150,4571,4572,4575],{},[20,4573,4574],{},"廣告天數與使用期限分開計算","：「關注焦點 7 天」是指置頂曝光 7 天，但購買後在\n90 天內都可以挑一個時間使用，讓經紀人自己決定要把置頂用在哪個物件、哪個時機",[150,4577,4578,4581],{},[20,4579,4580],{},"方案之間有相依關係","：關注焦點要搭配物件刊登使用——物件必須先在刊登中，才能\n被置頂",[150,4583,4584,4587],{},[20,4585,4586],{},"售屋與租屋分開","：售屋、租屋的方案與價格各自獨立",[16,4589,4590,4591,4594],{},"這些規則都不是寫死在畫面上，而是由後端的",[20,4592,4593],{},"方案設定","決定：新增一個方案、調整價\n格與折扣、改變天數，只需要調整設定，不需要改程式。",[12,4596,586],{"id":586},[16,4598,4599],{},[142,4600],{"alt":4601,"src":4602},"B 端刊登平台架構圖：房仲經紀人登入會員後，以點數購買方案取得權益；B 端物件以 CQRS + Event Sourcing 實作，寫入端刊登時檢查權益並寫入 MSSQL，讀取端 Elasticsearch 依付費權益決定排序，提供 C 端前台搜尋；新刊登與降價事件經由 RabbitMQ 送到通知中心，比對訂閱條件後推播給使用者","\u002Fimages\u002Fportfolio\u002Fb2b-listing-architecture.svg",[133,4604,4606],{"id":4605},"會員全站統一認證","會員：全站統一認證",[147,4608,4609,4612,4619],{},[150,4610,4611],{},"依 RESTful API 規範建立會員 API，管理經紀人與所屬店鋪的資料",[150,4613,4614,4615,4618],{},"串接 ",[20,4616,4617],{},"OAuth","，B 端的所有網站共用同一套會員登入，經紀人登入一次就能在刊登後\n台、開發工具、店鋪管理之間切換",[150,4620,4621,4622,4625],{},"以 ",[20,4623,4624],{},"JWT"," 在各服務之間傳遞身分，每個 API 都能從 Token 中知道是哪位經紀人在操\n作，只能管理自己的物件、點數與方案",[133,4627,4629],{"id":4628},"點數所有收費的基礎","點數：所有收費的基礎",[16,4631,4632,4633,4636],{},"經紀人先",[20,4634,4635],{},"儲值點數","，再用點數購買各種方案。所有收費都走點數，讓「付款」和「購\n買方案」拆成兩件事：",[147,4638,4639,4645,4651,4657],{},[150,4640,4641,4644],{},[20,4642,4643],{},"加值","：經紀人儲值後，點數進入自己的點數帳戶",[150,4646,4647,4650],{},[20,4648,4649],{},"扣點","：購買方案時扣除對應點數，並取得方案的權益（件數、天數、使用期限）",[150,4652,4653,4656],{},[20,4654,4655],{},"退點","：方案取消或異常時，依規則退回點數",[150,4658,4659,4662],{},[20,4660,4661],{},"交易紀錄","：每一筆加值、扣點、退點都留下紀錄，帳戶餘額隨時可以由紀錄重新算\n出來、對得起來",[16,4664,4665,4666,4669],{},"點數牽涉到錢，所以每一次異動都",[20,4667,4668],{},"不直接修改餘額","，而是新增一筆交易紀錄，再由紀\n錄計算出目前的餘額；同一筆購買重複送出時，也只會扣一次點數。",[133,4671,4673],{"id":4672},"b-端物件cqrs-event-sourcing","B 端物件：CQRS + Event Sourcing",[16,4675,4676],{},"物件是整個平台最核心的資料，寫入和讀取的需求差異很大：",[147,4678,4679,4692],{},[150,4680,4681,4683,4684,4687,4688,4691],{},[20,4682,1340],{},"：經紀人刊登、修改、下架物件時，先檢查這位經紀人",[20,4685,4686],{},"目前的權益","——\n還有沒有剩餘的刊登件數、方案是否還在使用期限內——通過後才寫入。每一次變更都\n以",[20,4689,4690],{},"事件","的形式記錄下來（刊登、改價、下架、套用置頂……），物件的歷史可以完整\n追溯",[150,4693,4694,4696,4697,4699,4700,4703],{},[20,4695,1346],{},"：物件資料同步到 ",[20,4698,2299],{},"，提供前台的搜尋與列表。物件的",[20,4701,4702],{},"付\n費權益","也一起寫進索引，前台查詢時直接依權益決定排序",[133,4705,4707],{"id":4706},"排序與置頂付費權益如何變成曝光","排序與置頂：付費權益如何變成曝光",[16,4709,4710],{},"前台的物件列表，排序規則大致是：",[2388,4712,4713,4719,4725],{},[150,4714,4715,4718],{},[20,4716,4717],{},"關注焦點","：購買置頂且仍在曝光期間的物件，固定顯示在列表最上方的重點區塊",[150,4720,4721,4724],{},[20,4722,4723],{},"自動更新","：刊登方案中「每天自動更新 N 次」的物件，系統會在一天中定時更新它\n的排序時間，讓物件重新回到列表前段",[150,4726,4727,4730],{},[20,4728,4729],{},"其他條件","：再依使用者選擇的條件（最新、價格、坪數等）排序",[16,4732,4733],{},"方案到期時，排程會把對應的權益從物件上移除，物件就回到一般排序。整個曝光邏輯都\n建立在 Elasticsearch 的索引欄位上，前台查詢不需要再回頭查方案與點數的資料。",[133,4735,4737],{"id":4736},"通知中心訂閱與推播","通知中心：訂閱與推播",[16,4739,4740,4741,4744],{},"物件刊登之後，還要讓",[20,4742,4743],{},"有興趣的人知道","。通知中心負責物件的訂閱與推播：",[147,4746,4747,4750,4759],{},[150,4748,4749],{},"使用者可以訂閱特定條件（區域、價格帶、社區）或特定物件",[150,4751,4752,4753,4756,4757],{},"物件",[20,4754,4755],{},"新刊登、降價","時，寫入端發布事件到 ",[20,4758,1890],{},[150,4760,4761],{},"通知中心訂閱這些事件，比對有哪些使用者的訂閱條件符合，再把通知推播出去",[16,4763,4764],{},"刊登服務只負責發布事件，不需要知道誰訂閱了什麼；之後要新增其他通知方式或通知類\n型，也只需要在通知中心擴充。",[12,4766,4767],{"id":4767},"全面雲端化",[16,4769,4770],{},"整個平台——會員、點數、B 端物件、通知中心，以及背後的資料庫、Elasticsearch、\nRabbitMQ——全部建置在雲端上，不依賴公司機房：",[147,4772,4773,4776,4779],{},[150,4774,4775],{},"各服務各自部署、各自擴展，刊登尖峰時只需要擴充物件相關的服務",[150,4777,4778],{},"不需要自行維護實體主機，環境可以快速建立與複製",[150,4780,4781],{},"對外服務的可用性不再受限於單一機房",[12,4783,1274],{"id":1274},[147,4785,4786,4793,4796,4799],{},[150,4787,4788,4789,4792],{},"公司得以依靠",[20,4790,4791],{},"物件刊登、物件排序、關注焦點","等方案，直接向房仲收取費用，成為穩\n定的營收來源",[150,4794,4795],{},"方案、價格、折扣都由設定決定，業務單位推出新方案不需要等工程排程",[150,4797,4798],{},"點數交易全部留有紀錄，帳務可以完整追溯",[150,4800,4801],{},"會員以 OAuth 統一認證，B 端所有網站共用同一套登入",[12,4803,707],{"id":707},[147,4805,4806,4811,4816,4820,4826],{},[150,4807,4808,4810],{},[20,4809,2231],{},"：C#、ASP.NET Core Web API、CQRS、Event Sourcing",[150,4812,4813,4815],{},[20,4814,2237],{},"：MSSQL、Elasticsearch",[150,4817,4818,2251],{},[20,4819,2250],{},[150,4821,4822,4825],{},[20,4823,4824],{},"認證","：OAuth、JWT",[150,4827,4828,4830],{},[20,4829,2273],{},"：雲端",{"title":222,"searchDepth":247,"depth":247,"links":4832},[4833,4834,4835,4842,4843,4844],{"id":14,"depth":247,"text":14},{"id":4552,"depth":247,"text":4552},{"id":586,"depth":247,"text":586,"children":4836},[4837,4838,4839,4840,4841],{"id":4605,"depth":253,"text":4606},{"id":4628,"depth":253,"text":4629},{"id":4672,"depth":253,"text":4673},{"id":4706,"depth":253,"text":4707},{"id":4736,"depth":253,"text":4737},{"id":4767,"depth":247,"text":4767},{"id":1274,"depth":247,"text":1274},{"id":707,"depth":247,"text":707},{},"\u002Fportfolio\u002Fb2b-realtor-listing-platform","後端開發 ‧ 房仲業",{"title":4491,"description":222},[2295,2296,2297,777,1890,2299,2301,4617,4624,4850],"Cloud","portfolio\u002Fb2b-realtor-listing-platform","給房仲經紀人使用的 B 端物件刊登平台，涵蓋 B 端物件、點數、會員與通知中心。經紀人以點數購買刊登、排序、物件置頂（關注焦點）等廣告方案，平台依付費權益決定物件在前台的曝光位置，讓公司能直接向房仲收取刊登與廣告費用；整個系統全部建置在雲端。","DILqVxX1prwoKW-h-lIzEkul_b-EmdiCtRjMhvT8994",{"id":4855,"title":4856,"award":7,"body":4857,"cover":5438,"description":222,"extension":766,"featured":1293,"link":7,"meta":5439,"navigation":280,"order":314,"path":5440,"pinned":1293,"repo":5441,"role":5442,"seo":5443,"stack":5444,"stem":5447,"summary":5448,"year":5449,"__hash__":5450},"portfolio\u002Fportfolio\u002Ftraining-hangfire.md","氣象開放資料排程系統",{"type":9,"value":4858,"toc":5430},[4859,4861,4864,4867,4881,4884,4907,5149,5153,5202,5206,5220,5278,5304,5395,5398,5419,5427],[12,4860,14],{"id":14},[16,4862,4863],{},"剛入行時寫的練習專案，目的不是做出產品，而是紮實地把「分層架構＋背景排程」\n這套組合走過一遍：串接台灣中央氣象局開放資料 API，抓取各地觀測站的氣象資\n料，用 Hangfire 排程定期同步進資料庫。因為是練習專案，程式庫是私有的，這裡\n把設計上的重點整理出來。",[12,4865,4866],{"id":4866},"分層架構",[16,4868,4869,4870,4873,4874,4877,4878,2424],{},"Controller → Service → Repository → UnitOfWork 四層，Repository 用泛型\n",[69,4871,4872],{},"IRepository\u003CTEntity>"," 統一封裝 CRUD，UnitOfWork 再統一管理 ",[69,4875,4876],{},"SaveChanges","\n與交易邊界，Service 層只依賴介面，不直接碰 ",[69,4879,4880],{},"DbContext",[12,4882,4883],{"id":4883},"排程流程",[16,4885,4886,4887,4890,4891,4894,4895,4898,4899,4902,4903,4906],{},"伺服器啟動時，",[69,4888,4889],{},"HangfireJobTrigger.OnStart()"," 會先清掉既有的\nRecurring Job 再重新註冊一次，避免重新部署造成同一個排程被註冊兩次；接著用\n兩段式的 Job 串接：先由排程觸發的 ",[69,4892,4893],{},"CreateJob"," 建立一筆工作紀錄，再用\n",[69,4896,4897],{},"BackgroundJob.Enqueue"," 呼叫實際抓資料的 ",[69,4900,4901],{},"UpdateMeteorologicalDataAsync","，\n並把前一個 Job 的 Id 當作 ",[69,4904,4905],{},"sourceJobId"," 傳進去，方便事後追查某次同步是哪個\n排程觸發的。",[217,4908,4910],{"className":219,"code":4909,"language":221,"meta":222,"style":222},"public void OnStart()\n{\n    \u002F\u002F 清除已有的排程工作，避免重新部署造成排程重複註冊\n    using (var connection = JobStorage.Current.GetConnection())\n    {\n        foreach (var recurringJob in connection.GetRecurringJobs())\n            RecurringJob.RemoveIfExists(recurringJob.Id);\n    }\n\n    \u002F\u002F 每 10 分鐘觸發一次\n    RecurringJob.AddOrUpdate\u003CIHangfireJobs>(\n        x => x.CreateJob(null),\n        cronExpression: \"0\u002F10 * * * *\",\n        timeZone: TimeZoneInfo.Local);\n}\n\npublic async Task UpdateMeteorologicalDataAsync(PerformContext context, string sourceJobId)\n{\n    \u002F\u002F 呼叫氣象局 API 取得原始資料\n    var sourceData = await _meteorologicalOpenDataService.GetSourceDataAsync();\n\n    \u002F\u002F 清洗、轉換後寫入資料庫（同觀測站的舊資料先刪除再整批寫入）\n    await _meteorologicalService.UpdateAsync(sourceData);\n}\n",[69,4911,4912,4923,4927,4932,4954,4958,4980,4991,4995,4999,5004,5020,5039,5051,5059,5063,5067,5095,5099,5104,5123,5127,5132,5145],{"__ignoreMap":222},[226,4913,4914,4916,4918,4921],{"class":228,"line":229},[226,4915,233],{"class":232},[226,4917,236],{"class":232},[226,4919,4920],{"class":239}," OnStart",[226,4922,244],{"class":243},[226,4924,4925],{"class":228,"line":247},[226,4926,250],{"class":243},[226,4928,4929],{"class":228,"line":253},[226,4930,4931],{"class":394},"    \u002F\u002F 清除已有的排程工作，避免重新部署造成排程重複註冊\n",[226,4933,4934,4937,4939,4941,4944,4946,4949,4952],{"class":228,"line":271},[226,4935,4936],{"class":232},"    using",[226,4938,1978],{"class":243},[226,4940,1981],{"class":232},[226,4942,4943],{"class":239}," connection",[226,4945,901],{"class":232},[226,4947,4948],{"class":243}," JobStorage.Current.",[226,4950,4951],{"class":239},"GetConnection",[226,4953,3309],{"class":243},[226,4955,4956],{"class":228,"line":277},[226,4957,326],{"class":243},[226,4959,4960,4963,4965,4967,4970,4972,4975,4978],{"class":228,"line":284},[226,4961,4962],{"class":232},"        foreach",[226,4964,1978],{"class":243},[226,4966,1981],{"class":232},[226,4968,4969],{"class":239}," recurringJob",[226,4971,1987],{"class":232},[226,4973,4974],{"class":243}," connection.",[226,4976,4977],{"class":239},"GetRecurringJobs",[226,4979,3309],{"class":243},[226,4981,4982,4985,4988],{"class":228,"line":309},[226,4983,4984],{"class":243},"            RecurringJob.",[226,4986,4987],{"class":239},"RemoveIfExists",[226,4989,4990],{"class":243},"(recurringJob.Id);\n",[226,4992,4993],{"class":228,"line":314},[226,4994,401],{"class":243},[226,4996,4997],{"class":228,"line":323},[226,4998,281],{"emptyLinePlaceholder":280},[226,5000,5001],{"class":228,"line":329},[226,5002,5003],{"class":394},"    \u002F\u002F 每 10 分鐘觸發一次\n",[226,5005,5006,5009,5012,5014,5017],{"class":228,"line":344},[226,5007,5008],{"class":243},"    RecurringJob.",[226,5010,5011],{"class":239},"AddOrUpdate",[226,5013,2460],{"class":243},[226,5015,5016],{"class":239},"IHangfireJobs",[226,5018,5019],{"class":243},">(\n",[226,5021,5022,5025,5027,5030,5032,5034,5036],{"class":228,"line":356},[226,5023,5024],{"class":239},"        x",[226,5026,2069],{"class":232},[226,5028,5029],{"class":243}," x.",[226,5031,4893],{"class":239},[226,5033,259],{"class":243},[226,5035,563],{"class":858},[226,5037,5038],{"class":243},"),\n",[226,5040,5041,5044,5046,5049],{"class":228,"line":365},[226,5042,5043],{"class":239},"        cronExpression",[226,5045,1529],{"class":243},[226,5047,5048],{"class":477},"\"0\u002F10 * * * *\"",[226,5050,2483],{"class":243},[226,5052,5053,5056],{"class":228,"line":374},[226,5054,5055],{"class":239},"        timeZone",[226,5057,5058],{"class":243},": TimeZoneInfo.Local);\n",[226,5060,5061],{"class":228,"line":384},[226,5062,274],{"class":243},[226,5064,5065],{"class":228,"line":391},[226,5066,281],{"emptyLinePlaceholder":280},[226,5068,5069,5071,5073,5075,5078,5080,5083,5086,5088,5090,5093],{"class":228,"line":398},[226,5070,233],{"class":232},[226,5072,1908],{"class":232},[226,5074,1911],{"class":239},[226,5076,5077],{"class":239}," UpdateMeteorologicalDataAsync",[226,5079,259],{"class":243},[226,5081,5082],{"class":239},"PerformContext",[226,5084,5085],{"class":239}," context",[226,5087,1553],{"class":243},[226,5089,2463],{"class":232},[226,5091,5092],{"class":239}," sourceJobId",[226,5094,306],{"class":243},[226,5096,5097],{"class":228,"line":404},[226,5098,250],{"class":243},[226,5100,5101],{"class":228,"line":409},[226,5102,5103],{"class":394},"    \u002F\u002F 呼叫氣象局 API 取得原始資料\n",[226,5105,5106,5108,5111,5113,5115,5118,5121],{"class":228,"line":414},[226,5107,1925],{"class":232},[226,5109,5110],{"class":239}," sourceData",[226,5112,901],{"class":232},[226,5114,1933],{"class":232},[226,5116,5117],{"class":243}," _meteorologicalOpenDataService.",[226,5119,5120],{"class":239},"GetSourceDataAsync",[226,5122,2550],{"class":243},[226,5124,5125],{"class":228,"line":428},[226,5126,281],{"emptyLinePlaceholder":280},[226,5128,5129],{"class":228,"line":433},[226,5130,5131],{"class":394},"    \u002F\u002F 清洗、轉換後寫入資料庫（同觀測站的舊資料先刪除再整批寫入）\n",[226,5133,5134,5136,5139,5142],{"class":228,"line":463},[226,5135,2559],{"class":232},[226,5137,5138],{"class":243}," _meteorologicalService.",[226,5140,5141],{"class":239},"UpdateAsync",[226,5143,5144],{"class":243},"(sourceData);\n",[226,5146,5147],{"class":228,"line":484},[226,5148,274],{"class":243},[12,5150,5152],{"id":5151},"資料轉換automapper","資料轉換（AutoMapper）",[16,5154,5155,5156,5159,5160,5159,5163,5166,5167,5170,5171,5174,5175,190,5178,501,5181,190,5184,5187,5188,5191,5192,124,5195,124,5198,5201],{},"氣象局回傳的原始 JSON（",[69,5157,5158],{},"Records"," → ",[69,5161,5162],{},"Location",[69,5164,5165],{},"WeatherElement"," \u002F\n",[69,5168,5169],{},"Parameter","）跟系統內部要用的模型長得不一樣，中間用四個 AutoMapper\n",[69,5172,5173],{},"Profile","（",[69,5176,5177],{},"ObservatoryInfoDtoProfile",[69,5179,5180],{},"WeatherInfoDtoProfile",[69,5182,5183],{},"RegionInfoDtoProfile",[69,5185,5186],{},"MeteorologicalDtoProfile","）把觀測站資訊、天氣資\n訊、地區資訊分開轉換，再由 Service 層組成一個完整的 ",[69,5189,5190],{},"MeteorologicalDto","，\n最後才落地成 ",[69,5193,5194],{},"ObservatoryInfoModel",[69,5196,5197],{},"WeatherInfoModel",[69,5199,5200],{},"RegionInfoModel","\n三張表。同一個觀測站再次同步時，是先刪除舊的關聯資料再整批寫入新的，而不是\n逐欄位比對更新。",[12,5203,5205],{"id":5204},"測試策略mstest","測試策略（MSTest）",[16,5207,5208,5209,5174,5212,5215,5216,5219],{},"專案拆成三個測試專案，",[20,5210,5211],{},"全部使用 MSTest",[69,5213,5214],{},"MSTest.TestFramework"," +\n",[69,5217,5218],{},"MSTest.TestAdapter","），搭配 FluentAssertions 斷言、NSubstitute 做替身、\nAutoFixture 產生測試資料：",[147,5221,5222,5252,5260],{},[150,5223,5224,5229,5230,5233,5234,5237,5238,501,5241,190,5244,5247,5248,5251],{},[20,5225,5226],{},[69,5227,5228],{},"Training.Hangfire.RepositoryTests","：用 ",[69,5231,5232],{},"RichardSzalay.MockHttp","\n假造 ",[69,5235,5236],{},"HttpClient"," 的回應，搭配固定的假 JSON（",[69,5239,5240],{},"SuccessFalse.json",[69,5242,5243],{},"locationNull.json",[69,5245,5246],{},"TwoLocation.json","）驗證 Repository 在「空白回\n應」「",[69,5249,5250],{},"success"," 為 false」「地點筆數為 0」「正常回傳兩筆」等情境下的解析\n邏輯，完全不需要真的打氣象局 API",[150,5253,5254,5259],{},[20,5255,5256],{},[69,5257,5258],{},"Training.Hangfire.ServiceTests","：針對 DTO 轉換與商業邏輯做單元測試",[150,5261,5262,5229,5267,5270,5271,5166,5274,5277],{},[20,5263,5264],{},[69,5265,5266],{},"Training.Hangfire.IntegrationTest",[69,5268,5269],{},"TestEnvironment.Docker"," 在\n測試時起一個真的 MSSQL 容器，每個測試用 ",[69,5272,5273],{},"BeginTransaction",[69,5275,5276],{},"RollbackTransaction"," 包起來，測完自動回滾，驗證「同觀測站舊資料存在時\n會先刪除再更新」「不存在時直接新增」這類跟真實資料庫行為有關的邏輯",[16,5279,5280,5281,5166,5284,124,5287,124,5290,5293,5294,190,5297,190,5300,5303],{},"測試方法命名走 Given-When-Then 的中文敘述，搭配 ",[69,5282,5283],{},"[TestClass]",[69,5285,5286],{},"[TestMethod]",[69,5288,5289],{},"[TestInitialize]",[69,5291,5292],{},"[TestCleanup]"," 以及\n",[69,5295,5296],{},"[Owner]",[69,5298,5299],{},"[TestCategory]",[69,5301,5302],{},"[TestProperty]"," 這些 MSTest 的屬性標記測試\n的負責人與被測方法：",[217,5305,5307],{"className":219,"code":5306,"language":221,"meta":222,"style":222},"[TestMethod()]\n[Owner(\"G8672\")]\n[TestCategory(nameof(ObservatoryInfoService))]\n[TestProperty(nameof(ObservatoryInfoService), nameof(ObservatoryInfoService.UpdateAsync))]\npublic async Task UpdateAsync_輸入StationId為10點371770並且資料庫存在此筆資料_觸發刪除並完成更新()\n{\n    \u002F\u002F arrange \u002F act \u002F assert\n}\n",[69,5308,5309,5320,5335,5350,5369,5382,5386,5391],{"__ignoreMap":222},[226,5310,5311,5314,5317],{"class":228,"line":229},[226,5312,5313],{"class":243},"[",[226,5315,5316],{"class":239},"TestMethod",[226,5318,5319],{"class":243},"()]\n",[226,5321,5322,5324,5327,5329,5332],{"class":228,"line":247},[226,5323,5313],{"class":243},[226,5325,5326],{"class":239},"Owner",[226,5328,259],{"class":243},[226,5330,5331],{"class":477},"\"G8672\"",[226,5333,5334],{"class":243},")]\n",[226,5336,5337,5339,5342,5344,5347],{"class":228,"line":253},[226,5338,5313],{"class":243},[226,5340,5341],{"class":239},"TestCategory",[226,5343,259],{"class":243},[226,5345,5346],{"class":232},"nameof",[226,5348,5349],{"class":243},"(ObservatoryInfoService))]\n",[226,5351,5352,5354,5357,5359,5361,5364,5366],{"class":228,"line":271},[226,5353,5313],{"class":243},[226,5355,5356],{"class":239},"TestProperty",[226,5358,259],{"class":243},[226,5360,5346],{"class":232},[226,5362,5363],{"class":243},"(ObservatoryInfoService), ",[226,5365,5346],{"class":232},[226,5367,5368],{"class":243},"(ObservatoryInfoService.UpdateAsync))]\n",[226,5370,5371,5373,5375,5377,5380],{"class":228,"line":277},[226,5372,233],{"class":232},[226,5374,1908],{"class":232},[226,5376,1911],{"class":239},[226,5378,5379],{"class":239}," UpdateAsync_輸入StationId為10點371770並且資料庫存在此筆資料_觸發刪除並完成更新",[226,5381,244],{"class":243},[226,5383,5384],{"class":228,"line":284},[226,5385,250],{"class":243},[226,5387,5388],{"class":228,"line":309},[226,5389,5390],{"class":394},"    \u002F\u002F arrange \u002F act \u002F assert\n",[226,5392,5393],{"class":228,"line":314},[226,5394,274],{"class":243},[12,5396,5397],{"id":5397},"學到的東西",[147,5399,5400,5403,5409,5416],{},[150,5401,5402],{},"第一次完整體會「為什麼要把資料存取邏輯抽到 Repository／UnitOfWork」，而不\n是把 EF Core 直接寫在 Service 裡",[150,5404,5405,5406,5408],{},"Hangfire 排程工作的基本用法與常見陷阱（部署時如何避免排程重複註冊、用\n",[69,5407,4905],{}," 串起因果關係）",[150,5410,5411,5412,5415],{},"用假的 HTTP 回應（",[69,5413,5414],{},"MockHttp"," + fixture JSON）驗證會呼叫外部 API 的\nRepository，讓測試穩定、不依賴氣象局 API 是否可用",[150,5417,5418],{},"用 Docker 容器起一個真的 MSSQL 做整合測試，比起完全 Mock 資料庫，更能驗\n證交易、關聯資料刪除等真實資料庫才會出現的行為，也是後來重視整合測試的\n起點",[16,5420,5421,5422,5426],{},"這個專案後來也成了 ",[4102,5423,5425],{"href":5424},"\u002Fportfolio\u002Finvoice-system","訂單開立發票系統"," 的架構起\n點——同一套分層模式，套用到不同的業務情境。",[751,5428,5429],{},"html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":222,"searchDepth":247,"depth":247,"links":5431},[5432,5433,5434,5435,5436,5437],{"id":14,"depth":247,"text":14},{"id":4866,"depth":247,"text":4866},{"id":4883,"depth":247,"text":4883},{"id":5151,"depth":247,"text":5152},{"id":5204,"depth":247,"text":5205},{"id":5397,"depth":247,"text":5397},"https:\u002F\u002Fpicsum.photos\u002Fseed\u002Ftraining-hangfire\u002F1200\u002F800",{},"\u002Fportfolio\u002Ftraining-hangfire","https:\u002F\u002Fgithub.com\u002Ftom3019\u002FTraining.Hangfire","個人練習專案 ‧ 剛入行",{"title":4856,"description":222},[2296,2295,2298,786,5445,5446,2301],"AutoMapper","MSTest","portfolio\u002Ftraining-hangfire","剛入行時的練習專案：串接中央氣象局開放資料，用 Hangfire 定期抓取、清洗並寫入資料庫，第一次完整走過分層架構、AutoMapper 與三層測試（Repository／Service／整合測試）。",2021,"Fy0ZXkxWAariezmBzXlj0O7Q_XbB76N1K97vwp17-k0",{"id":5452,"title":5425,"award":7,"body":5453,"cover":6246,"description":222,"extension":766,"featured":1293,"link":7,"meta":6247,"navigation":280,"order":323,"path":5424,"pinned":1293,"repo":6248,"role":5442,"seo":6249,"stack":6250,"stem":6251,"summary":6252,"year":5449,"__hash__":6253},"portfolio\u002Fportfolio\u002Finvoice-system.md",{"type":9,"value":5454,"toc":6238},[5455,5457,5462,5465,5484,5657,5667,5671,5686,5820,5847,5850,5868,5870,5889,5941,6207,6209,6235],[12,5456,14],{"id":14},[16,5458,5459,5461],{},[4102,5460,4856],{"href":5440}," 之後的練習專案，把同一\n套「分層架構＋Hangfire 排程」的模式，套用到更貼近實務的業務情境：訂單成立\n後，由排程工作每日批次為訂單開立發票。程式庫是私有的練習專案，這裡整理設計\n上的重點。",[12,5463,5464],{"id":5464},"業務流程",[16,5466,5467,5470,5471,124,5473,5476,5477,124,5480,5483],{},[69,5468,5469],{},"Product","（商品）→ ",[69,5472,71],{},[69,5474,5475],{},"OrderDetail","（訂單／訂單明細）→\n",[69,5478,5479],{},"Invoice",[69,5481,5482],{},"InvoiceDetail","（發票／發票明細）層層關聯：建立商品、下訂單\n後，背景排程每天固定時間抓取所有訂單，批次呼叫開立發票的邏輯，並把執行\n過程記錄下來（工作 ID、來源工作 ID、起訖時間），方便事後追查某天的發票是哪\n次排程工作產生的。",[217,5485,5487],{"className":219,"code":5486,"language":221,"meta":222,"style":222},"\u002F\u002F 排程工作：先建立一筆「工作紀錄」，再串接呼叫實際開立發票的工作，\n\u002F\u002F 並用 sourceJobId 把兩個工作串起來方便追查\npublic void CreateJob(PerformContext context)\n{\n    var jobId = context.BackgroundJob.Id;\n    \u002F\u002F ...寫入 LogContent...\n\n    BackgroundJob.Enqueue\u003CIHangfireJobs>(x => x.CreateInvoiceAsync(null, jobId));\n}\n\npublic async Task CreateInvoiceAsync(PerformContext context, string sourceJobId)\n{\n    var orders = await _orderService.GetAllAsync();\n    var result = await _invoiceService.CreateManyAsync(orders);\n    \u002F\u002F ...記錄結果...\n}\n",[69,5488,5489,5494,5499,5516,5520,5532,5537,5541,5572,5576,5580,5605,5609,5628,5648,5653],{"__ignoreMap":222},[226,5490,5491],{"class":228,"line":229},[226,5492,5493],{"class":394},"\u002F\u002F 排程工作：先建立一筆「工作紀錄」，再串接呼叫實際開立發票的工作，\n",[226,5495,5496],{"class":228,"line":247},[226,5497,5498],{"class":394},"\u002F\u002F 並用 sourceJobId 把兩個工作串起來方便追查\n",[226,5500,5501,5503,5505,5508,5510,5512,5514],{"class":228,"line":253},[226,5502,233],{"class":232},[226,5504,236],{"class":232},[226,5506,5507],{"class":239}," CreateJob",[226,5509,259],{"class":243},[226,5511,5082],{"class":239},[226,5513,5085],{"class":239},[226,5515,306],{"class":243},[226,5517,5518],{"class":228,"line":271},[226,5519,250],{"class":243},[226,5521,5522,5524,5527,5529],{"class":228,"line":277},[226,5523,1925],{"class":232},[226,5525,5526],{"class":239}," jobId",[226,5528,901],{"class":232},[226,5530,5531],{"class":243}," context.BackgroundJob.Id;\n",[226,5533,5534],{"class":228,"line":284},[226,5535,5536],{"class":394},"    \u002F\u002F ...寫入 LogContent...\n",[226,5538,5539],{"class":228,"line":309},[226,5540,281],{"emptyLinePlaceholder":280},[226,5542,5543,5546,5549,5551,5553,5556,5558,5560,5562,5565,5567,5569],{"class":228,"line":314},[226,5544,5545],{"class":243},"    BackgroundJob.",[226,5547,5548],{"class":239},"Enqueue",[226,5550,2460],{"class":243},[226,5552,5016],{"class":239},[226,5554,5555],{"class":243},">(",[226,5557,2066],{"class":239},[226,5559,2069],{"class":232},[226,5561,5029],{"class":243},[226,5563,5564],{"class":239},"CreateInvoiceAsync",[226,5566,259],{"class":243},[226,5568,563],{"class":858},[226,5570,5571],{"class":243},", jobId));\n",[226,5573,5574],{"class":228,"line":323},[226,5575,274],{"class":243},[226,5577,5578],{"class":228,"line":329},[226,5579,281],{"emptyLinePlaceholder":280},[226,5581,5582,5584,5586,5588,5591,5593,5595,5597,5599,5601,5603],{"class":228,"line":344},[226,5583,233],{"class":232},[226,5585,1908],{"class":232},[226,5587,1911],{"class":239},[226,5589,5590],{"class":239}," CreateInvoiceAsync",[226,5592,259],{"class":243},[226,5594,5082],{"class":239},[226,5596,5085],{"class":239},[226,5598,1553],{"class":243},[226,5600,2463],{"class":232},[226,5602,5092],{"class":239},[226,5604,306],{"class":243},[226,5606,5607],{"class":228,"line":356},[226,5608,250],{"class":243},[226,5610,5611,5613,5616,5618,5620,5623,5626],{"class":228,"line":365},[226,5612,1925],{"class":232},[226,5614,5615],{"class":239}," orders",[226,5617,901],{"class":232},[226,5619,1933],{"class":232},[226,5621,5622],{"class":243}," _orderService.",[226,5624,5625],{"class":239},"GetAllAsync",[226,5627,2550],{"class":243},[226,5629,5630,5632,5635,5637,5639,5642,5645],{"class":228,"line":374},[226,5631,1925],{"class":232},[226,5633,5634],{"class":239}," result",[226,5636,901],{"class":232},[226,5638,1933],{"class":232},[226,5640,5641],{"class":243}," _invoiceService.",[226,5643,5644],{"class":239},"CreateManyAsync",[226,5646,5647],{"class":243},"(orders);\n",[226,5649,5650],{"class":228,"line":384},[226,5651,5652],{"class":394},"    \u002F\u002F ...記錄結果...\n",[226,5654,5655],{"class":228,"line":391},[226,5656,274],{"class":243},[16,5658,5659,5660,5663,5664,5666],{},"Hangfire 排程改為每日定時批次開立發票（cron ",[69,5661,5662],{},"0 0 * * *","），而不是即時觸\n發，符合發票通常是「日結」的業務性質；",[69,5665,5644],{}," 會先篩掉已經開過\n發票的訂單，只針對尚未開票的訂單批次寫入，避免重複開票。",[12,5668,5670],{"id":5669},"api-輸入驗證fluentvalidation-actionfilter","API 輸入驗證（FluentValidation + ActionFilter）",[16,5672,5673,5674,5677,5678,5681,5682,5685],{},"API 層的輸入驗證改用 ",[20,5675,5676],{},"FluentValidation","，並且不是在每個 Action 手動呼叫\n驗證，而是包成一個共用的 ",[69,5679,5680],{},"ParameterValidatorAttribute","：Attribute 建構時\n指定要用哪個 Validator 類別，執行時從 Action 的參數裡取出目標物件跑驗證，\n驗證失敗就直接組出帶錯誤碼／CorrelationId 的 ",[69,5683,5684],{},"FailResultViewModel"," 短路回\n傳，Action 本體完全不用寫任何驗證邏輯：",[217,5687,5689],{"className":219,"code":5688,"language":221,"meta":222,"style":222},"[HttpPost]\n[CoreProfiling]\n[ParameterValidator(typeof(OrderCreateParameterValidator))]\npublic async Task\u003CIActionResult> CreateAsync([FromBody] OrderCreateParameter orderCreateParameter)\n{\n    var dto = _mapper.Map\u003COrderCreateParameter, OrderDto>(orderCreateParameter);\n    var result = await _orderService.CreateAsync(dto);\n    \u002F\u002F ...\n}\n",[69,5690,5691,5700,5709,5729,5763,5767,5794,5811,5816],{"__ignoreMap":222},[226,5692,5693,5695,5698],{"class":228,"line":229},[226,5694,5313],{"class":243},[226,5696,5697],{"class":239},"HttpPost",[226,5699,950],{"class":243},[226,5701,5702,5704,5707],{"class":228,"line":247},[226,5703,5313],{"class":243},[226,5705,5706],{"class":239},"CoreProfiling",[226,5708,950],{"class":243},[226,5710,5711,5713,5716,5718,5721,5723,5726],{"class":228,"line":253},[226,5712,5313],{"class":243},[226,5714,5715],{"class":239},"ParameterValidator",[226,5717,259],{"class":243},[226,5719,5720],{"class":232},"typeof",[226,5722,259],{"class":243},[226,5724,5725],{"class":239},"OrderCreateParameterValidator",[226,5727,5728],{"class":243},"))]\n",[226,5730,5731,5733,5735,5737,5739,5742,5744,5747,5750,5753,5755,5758,5761],{"class":228,"line":271},[226,5732,233],{"class":232},[226,5734,1908],{"class":232},[226,5736,1911],{"class":239},[226,5738,2460],{"class":243},[226,5740,5741],{"class":239},"IActionResult",[226,5743,2466],{"class":243},[226,5745,5746],{"class":239},"CreateAsync",[226,5748,5749],{"class":243},"([",[226,5751,5752],{"class":239},"FromBody",[226,5754,2504],{"class":243},[226,5756,5757],{"class":239},"OrderCreateParameter",[226,5759,5760],{"class":239}," orderCreateParameter",[226,5762,306],{"class":243},[226,5764,5765],{"class":228,"line":277},[226,5766,250],{"class":243},[226,5768,5769,5771,5774,5776,5779,5782,5784,5786,5788,5791],{"class":228,"line":284},[226,5770,1925],{"class":232},[226,5772,5773],{"class":239}," dto",[226,5775,901],{"class":232},[226,5777,5778],{"class":243}," _mapper.",[226,5780,5781],{"class":239},"Map",[226,5783,2460],{"class":243},[226,5785,5757],{"class":239},[226,5787,1553],{"class":243},[226,5789,5790],{"class":239},"OrderDto",[226,5792,5793],{"class":243},">(orderCreateParameter);\n",[226,5795,5796,5798,5800,5802,5804,5806,5808],{"class":228,"line":309},[226,5797,1925],{"class":232},[226,5799,5634],{"class":239},[226,5801,901],{"class":232},[226,5803,1933],{"class":232},[226,5805,5622],{"class":243},[226,5807,5746],{"class":239},[226,5809,5810],{"class":243},"(dto);\n",[226,5812,5813],{"class":228,"line":314},[226,5814,5815],{"class":394},"    \u002F\u002F ...\n",[226,5817,5818],{"class":228,"line":323},[226,5819,274],{"class":243},[16,5821,5822,825,5824,5827,5828,825,5831,5834,5835,5838,5839,5842,5843,5846],{},[69,5823,5725],{},[69,5825,5826],{},"OrderUpdateParameterValidator","／\n",[69,5829,5830],{},"ProductCreateParameterValidator",[69,5832,5833],{},"ProductUpdateParameterValidator"," 依\nCreate／Update 情境分別寫規則，跟另一層內部模型驗證（",[69,5836,5837],{},"ModelValidator","，\n封裝 ",[69,5840,5841],{},"DataAnnotations"," 的 ",[69,5844,5845],{},"Validator.TryValidateObject","）分開職責：前者管\n「API 請求格式對不對」，後者管「往下傳的內部模型合不合法」，兩層不混在一起。",[12,5848,5849],{"id":5849},"自動化效能量測",[16,5851,5852,5853,5856,5857,124,5860,5863,5864,5867],{},"同樣用 ActionFilter 包一層效能量測（",[69,5854,5855],{},"CoreProfilingAttribute","，底層是\nCoreProfiler），在 ",[69,5858,5859],{},"OnActionExecuting",[69,5861,5862],{},"OnActionExecuted"," 自動開始／結\n束一個 Profiling Step，Action 上只要掛一個 ",[69,5865,5866],{},"[CoreProfiling]"," 就好，不需要\n每個 Action 手動加碼計時。",[12,5869,5205],{"id":5204},[16,5871,5872,5873,5876,5877,5880,5881,5174,5884,5215,5886,5888],{},"延續 Controller／Service／Repository／UnitOfWork 分層，並補上\n",[69,5874,5875],{},"InvoiceSystem.ServiceTests"," 與 ",[69,5878,5879],{},"InvoiceSystem.IntegrationTest"," 兩個測試\n專案，",[20,5882,5883],{},"同樣全部使用 MSTest",[69,5885,5214],{},[69,5887,5218],{},"），搭配 FluentAssertions、NSubstitute、AutoFixture：",[147,5890,5891,5923],{},[150,5892,5893,5229,5897,5899,5900,5903,5904,501,5907,190,5910,190,5913,190,5916,5919,5920,5922],{},[20,5894,5895],{},[69,5896,5879],{},[69,5898,5269],{}," 起一個\n真的 MSSQL 容器，測試前先用 ",[69,5901,5902],{},"DbScript"," 目錄下的 SQL 腳本（",[69,5905,5906],{},"Product_Data",[69,5908,5909],{},"Order_Data",[69,5911,5912],{},"OrderDetail_Data",[69,5914,5915],{},"Invoice_Data",[69,5917,5918],{},"InvoiceDetail_Data","）\n灌一批固定的測試資料，每個測試包在交易裡、跑完 ",[69,5921,5276],{},"，驗\n證「訂單已開過發票就不能重複開立」「批次開立時只處理尚未開票的訂單」這類\n跟真實資料庫狀態有關的行為",[150,5924,5925,5926,5931,5932,825,5934,825,5937,5940],{},"大量使用 MSTest 的 ",[20,5927,5928],{},[69,5929,5930],{},"[DataRow]"," 做參數化測試，例如驗證發票查詢在\n",[69,5933,494],{},[69,5935,5936],{},"ProductId",[69,5938,5939],{},"InvoiceId"," 七種組合下都能篩出正確筆數，一個測試\n方法涵蓋七種情境，不用複製貼上七個測試",[217,5942,5944],{"className":219,"code":5943,"language":221,"meta":222,"style":222},"[TestMethod()]\n[DataRow(1, 0, 0, DisplayName = \"輸入orderId為1\")]\n[DataRow(0, 1, 0, DisplayName = \"輸入productId為1\")]\n[DataRow(0, 0, 1, DisplayName = \"輸入invoiceId為1\")]\n[DataRow(1, 1, 0, DisplayName = \"輸入orderId為1與productId為1\")]\n[DataRow(0, 1, 1, DisplayName = \"輸入productId為1與invoiceId為1\")]\n[DataRow(1, 0, 1, DisplayName = \"輸入orderId為1與invoiceId為1\")]\n[DataRow(1, 1, 1, DisplayName = \"輸入orderId為1與productId為1與invoiceId為1\")]\npublic async Task GetAsync_輸入特定參數_輸出相關資料數量(int orderId, int productId, int invoiceId)\n{\n    \u002F\u002F arrange \u002F act \u002F assert\n}\n",[69,5945,5946,5954,5986,6015,6044,6073,6102,6131,6160,6195,6199,6203],{"__ignoreMap":222},[226,5947,5948,5950,5952],{"class":228,"line":229},[226,5949,5313],{"class":243},[226,5951,5316],{"class":239},[226,5953,5319],{"class":243},[226,5955,5956,5958,5961,5963,5966,5968,5970,5972,5974,5976,5979,5981,5984],{"class":228,"line":247},[226,5957,5313],{"class":243},[226,5959,5960],{"class":239},"DataRow",[226,5962,259],{"class":243},[226,5964,5965],{"class":858},"1",[226,5967,1553],{"class":243},[226,5969,3528],{"class":858},[226,5971,1553],{"class":243},[226,5973,3528],{"class":858},[226,5975,1553],{"class":243},[226,5977,5978],{"class":239},"DisplayName",[226,5980,901],{"class":232},[226,5982,5983],{"class":477}," \"輸入orderId為1\"",[226,5985,5334],{"class":243},[226,5987,5988,5990,5992,5994,5996,5998,6000,6002,6004,6006,6008,6010,6013],{"class":228,"line":253},[226,5989,5313],{"class":243},[226,5991,5960],{"class":239},[226,5993,259],{"class":243},[226,5995,3528],{"class":858},[226,5997,1553],{"class":243},[226,5999,5965],{"class":858},[226,6001,1553],{"class":243},[226,6003,3528],{"class":858},[226,6005,1553],{"class":243},[226,6007,5978],{"class":239},[226,6009,901],{"class":232},[226,6011,6012],{"class":477}," \"輸入productId為1\"",[226,6014,5334],{"class":243},[226,6016,6017,6019,6021,6023,6025,6027,6029,6031,6033,6035,6037,6039,6042],{"class":228,"line":271},[226,6018,5313],{"class":243},[226,6020,5960],{"class":239},[226,6022,259],{"class":243},[226,6024,3528],{"class":858},[226,6026,1553],{"class":243},[226,6028,3528],{"class":858},[226,6030,1553],{"class":243},[226,6032,5965],{"class":858},[226,6034,1553],{"class":243},[226,6036,5978],{"class":239},[226,6038,901],{"class":232},[226,6040,6041],{"class":477}," \"輸入invoiceId為1\"",[226,6043,5334],{"class":243},[226,6045,6046,6048,6050,6052,6054,6056,6058,6060,6062,6064,6066,6068,6071],{"class":228,"line":277},[226,6047,5313],{"class":243},[226,6049,5960],{"class":239},[226,6051,259],{"class":243},[226,6053,5965],{"class":858},[226,6055,1553],{"class":243},[226,6057,5965],{"class":858},[226,6059,1553],{"class":243},[226,6061,3528],{"class":858},[226,6063,1553],{"class":243},[226,6065,5978],{"class":239},[226,6067,901],{"class":232},[226,6069,6070],{"class":477}," \"輸入orderId為1與productId為1\"",[226,6072,5334],{"class":243},[226,6074,6075,6077,6079,6081,6083,6085,6087,6089,6091,6093,6095,6097,6100],{"class":228,"line":284},[226,6076,5313],{"class":243},[226,6078,5960],{"class":239},[226,6080,259],{"class":243},[226,6082,3528],{"class":858},[226,6084,1553],{"class":243},[226,6086,5965],{"class":858},[226,6088,1553],{"class":243},[226,6090,5965],{"class":858},[226,6092,1553],{"class":243},[226,6094,5978],{"class":239},[226,6096,901],{"class":232},[226,6098,6099],{"class":477}," \"輸入productId為1與invoiceId為1\"",[226,6101,5334],{"class":243},[226,6103,6104,6106,6108,6110,6112,6114,6116,6118,6120,6122,6124,6126,6129],{"class":228,"line":309},[226,6105,5313],{"class":243},[226,6107,5960],{"class":239},[226,6109,259],{"class":243},[226,6111,5965],{"class":858},[226,6113,1553],{"class":243},[226,6115,3528],{"class":858},[226,6117,1553],{"class":243},[226,6119,5965],{"class":858},[226,6121,1553],{"class":243},[226,6123,5978],{"class":239},[226,6125,901],{"class":232},[226,6127,6128],{"class":477}," \"輸入orderId為1與invoiceId為1\"",[226,6130,5334],{"class":243},[226,6132,6133,6135,6137,6139,6141,6143,6145,6147,6149,6151,6153,6155,6158],{"class":228,"line":314},[226,6134,5313],{"class":243},[226,6136,5960],{"class":239},[226,6138,259],{"class":243},[226,6140,5965],{"class":858},[226,6142,1553],{"class":243},[226,6144,5965],{"class":858},[226,6146,1553],{"class":243},[226,6148,5965],{"class":858},[226,6150,1553],{"class":243},[226,6152,5978],{"class":239},[226,6154,901],{"class":232},[226,6156,6157],{"class":477}," \"輸入orderId為1與productId為1與invoiceId為1\"",[226,6159,5334],{"class":243},[226,6161,6162,6164,6166,6168,6171,6173,6176,6179,6181,6183,6186,6188,6190,6193],{"class":228,"line":323},[226,6163,233],{"class":232},[226,6165,1908],{"class":232},[226,6167,1911],{"class":239},[226,6169,6170],{"class":239}," GetAsync_輸入特定參數_輸出相關資料數量",[226,6172,259],{"class":243},[226,6174,6175],{"class":232},"int",[226,6177,6178],{"class":239}," orderId",[226,6180,1553],{"class":243},[226,6182,6175],{"class":232},[226,6184,6185],{"class":239}," productId",[226,6187,1553],{"class":243},[226,6189,6175],{"class":232},[226,6191,6192],{"class":239}," invoiceId",[226,6194,306],{"class":243},[226,6196,6197],{"class":228,"line":329},[226,6198,250],{"class":243},[226,6200,6201],{"class":228,"line":344},[226,6202,5390],{"class":394},[226,6204,6205],{"class":228,"line":356},[226,6206,274],{"class":243},[12,6208,5397],{"id":5397},[147,6210,6211,6214,6220,6226,6232],{},[150,6212,6213],{},"同一套分層架構重複套用在第二個專案上，能明顯感覺到「哪些是可以直接複用的\n骨架、哪些要依業務情境重新設計」",[150,6215,6216,6217,6219],{},"排程工作之間用 ",[69,6218,4905],{}," 串起因果關係，是後來理解「如何追蹤一連串背景\n工作」的起點",[150,6221,6222,6223,6225],{},"把請求驗證（FluentValidation + ActionFilter）跟內部模型驗證\n（",[69,6224,5841],{},"）分成兩層，職責更清楚，也是後來重視分層設計的起點",[150,6227,6228,6229,6231],{},"用 MSTest 的 ",[69,6230,5930],{}," 把同一個方法的多種輸入組合收斂成一個測試方法，\n比每種情境各寫一個測試方法更好維護",[150,6233,6234],{},"用 Docker 化的 MSSQL 搭配 SQL 腳本灌測試資料、每個測試跑完自動回滾，讓\n整合測試可以重複執行也不互相污染",[751,6236,6237],{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}",{"title":222,"searchDepth":247,"depth":247,"links":6239},[6240,6241,6242,6243,6244,6245],{"id":14,"depth":247,"text":14},{"id":5464,"depth":247,"text":5464},{"id":5669,"depth":247,"text":5670},{"id":5849,"depth":247,"text":5849},{"id":5204,"depth":247,"text":5205},{"id":5397,"depth":247,"text":5397},"https:\u002F\u002Fpicsum.photos\u002Fseed\u002Finvoice-system\u002F1200\u002F800",{},"https:\u002F\u002Fgithub.com\u002Ftom3019\u002FInvoiceSystem",{"title":5425,"description":222},[2296,2295,2298,786,5676,5446,2301],"portfolio\u002Finvoice-system","延續氣象排程練習專案的分層架構，套用到「訂單→發票」的業務情境：每日排程批次開立發票，並補上 FluentValidation 請求驗證、ActionFilter 效能量測，以及用 Docker 化 MSSQL 跑的 MSTest 整合測試。","31fVne_Q8OTuDS58G_ORSOMMQX0u_biZadgy82TXT4E",{"id":6255,"title":6256,"award":6257,"body":6258,"cover":6647,"description":222,"extension":766,"featured":1293,"link":7,"meta":6648,"navigation":280,"order":329,"path":6649,"pinned":1293,"repo":7,"role":6650,"seo":6651,"stack":6652,"stem":6659,"summary":6660,"year":6661,"__hash__":6662},"portfolio\u002Fportfolio\u002Flego-line-follower-robot.md","LEGO 循跡避障機器人","第一名",{"type":9,"value":6259,"toc":6638},[6260,6262,6275,6290,6293,6317,6323,6327,6333,6357,6364,6368,6374,6377,6384,6407,6410,6416,6435,6439,6445,6452,6534,6537,6565,6568,6582,6585,6605,6607],[12,6261,14],{"id":14},[16,6263,6264,6265,6268,6269,6272,6273,2424],{},"2018 年我參加了",[20,6266,6267],{},"全能機器人國際邀請賽","的",[20,6270,6271],{},"循跡競賽","，最後拿下",[20,6274,6257],{},[16,6276,6277,6278,6281,6282,6285,6286,6289],{},"循跡競賽的規則很直觀：機器人要沿著場地上的黑色軌道從起點跑到終點，比的是",[20,6279,6280],{},"完\n成度與速度","。難的地方在於軌道上會擺放",[20,6283,6284],{},"寶特瓶障礙物","——機器人不能撞倒它，必\n須自己偵測、繞開，再",[20,6287,6288],{},"回到軌道上","繼續前進。也就是說，這不只是「跟著線走」，而\n是要讓機器人在「循線」與「避障」兩種模式之間可靠地切換。",[16,6291,6292],{},"我的機器人以 LEGO 打造，設計上有三個關鍵：",[147,6294,6295,6301,6307],{},[150,6296,6297,6300],{},[20,6298,6299],{},"左右兩顆顏色感測器","夾著軌道，用兩邊的反射光差值判斷偏移方向與幅度",[150,6302,6303,6306],{},[20,6304,6305],{},"前方一顆超音波感測器","，負責提早發現寶特瓶障礙物",[150,6308,6309,6312,6313,6316],{},[20,6310,6311],{},"四顆全向輪（萬向輪）","，讓機器人可以",[20,6314,6315],{},"不轉彎、直接橫向平移","閃避障礙，這\n是整台車在避障時又快又穩的關鍵",[6318,6319,6320],"blockquote",{},[16,6321,6322],{},"比賽至今已經好幾年，當時的機器人實體與程式都已經沒有保留下來，只剩下獎狀。\n這篇是依照我記得的設計重新整理，圖與程式碼皆為重新繪製、改寫的示意。",[12,6324,6326],{"id":6325},"機構設計感測器配置與-x-型全向輪","機構設計：感測器配置與 X 型全向輪",[16,6328,6329],{},[142,6330],{"alt":6331,"src":6332},"機器人俯視圖：車身四個角各一顆 45 度斜放的全向輪，車頭前方是超音波感測器，下方左右兩顆顏色感測器夾著黑線；右側說明全向輪可以前進、橫向平移與原地旋轉","\u002Fimages\u002Fportfolio\u002Flego-line-follower-layout.svg",[147,6334,6335,6341,6347],{},[150,6336,6337,6340],{},[20,6338,6339],{},"顏色感測器（左、右）","：裝在車頭下方、貼近地面，間距略大於軌道寬度。正常\n行駛時黑線剛好落在兩顆感測器中間，兩邊都讀到白色；一旦車子偏移，其中一邊就\n會先讀到黑線",[150,6342,6343,6346],{},[20,6344,6345],{},"超音波感測器（前方）","：朝向行進方向，持續量測前方距離，距離小於門檻就代表\n寶特瓶出現在軌道上",[150,6348,6349,6352,6353,6356],{},[20,6350,6351],{},"四顆全向輪，X 型配置","：四顆輪子各自由一顆馬達驅動，以 45 度斜放在車身四\n角。全向輪的輪框上有一圈可以自由滾動的小滾輪，所以每顆輪子只在自己的方向上\n「出力」，側向不會產生阻力；四顆輪子的出力向量疊加起來，就能組合出",[20,6354,6355],{},"前後、\n左右、旋轉","三種動作",[16,6358,6359,6360,6363],{},"選擇全向輪是整台車最重要的設計決策。一般兩輪差速車遇到障礙物時，必須「轉\n彎 → 前進 → 轉回來 → 再轉彎找線」，每一次轉彎都會累積角度誤差，回到軌道時車頭\n方向常常是歪的，很容易一找到線就又衝出去。全向輪可以",[20,6361,6362],{},"保持車頭方向不變、直接\n側移","，繞完障礙物後車身仍然和軌道平行，回到線上馬上就能繼續循跡。",[12,6365,6367],{"id":6366},"循跡控制兩顆顏色感測器的差值控制","循跡控制：兩顆顏色感測器的差值控制",[16,6369,6370],{},[142,6371],{"alt":6372,"src":6373},"循跡判斷示意圖：線在正中間時左右感測器都讀到白色，直走；線偏到左邊時左感測器讀到黑色，往左修正；線偏到右邊時右感測器讀到黑色，往右修正","\u002Fimages\u002Fportfolio\u002Flego-line-follower-tracking.svg",[16,6375,6376],{},"最直覺的寫法是「左邊看到黑就左轉、右邊看到黑就右轉」，但這種**開關式（Bang-\nBang）**控制只有兩種動作，車子會在線上不停左右擺盪，速度一快就甩出軌道。",[16,6378,6379,6380,6383],{},"改善的方式是使用顏色感測器的",[20,6381,6382],{},"反射光強度","（0–100 的連續數值），而不是只判斷黑\n白：",[2388,6385,6386,6395,6401],{},[150,6387,6388,155,6391,6394],{},[20,6389,6390],{},"計算誤差",[69,6392,6393],{},"error = 左反射值 − 右反射值","。線在正中間時兩邊差不多，誤差接近\n0；線越偏向某一邊，那一邊越暗，誤差的絕對值就越大",[150,6396,6397,6400],{},[20,6398,6399],{},"比例修正（P）","：轉向量與誤差成正比——偏一點就修一點、偏很多就修很多，車子\n的行進軌跡會平滑很多",[150,6402,6403,6406],{},[20,6404,6405],{},"微分修正（D）","：再加上「誤差的變化量」，在車子快速擺回中線時提前減少修\n正，抑制過衝造成的來回擺盪",[16,6408,6409],{},"當時程式是用 EV3 的圖形化積木撰寫，以下用虛擬碼表達核心邏輯：",[217,6411,6414],{"className":6412,"code":6413,"language":809,"meta":222},[807],"Kp = 0.8           \u002F\u002F 比例增益，依實測調整\nKd = 4.0           \u002F\u002F 微分增益\nBASE_SPEED = 45    \u002F\u002F 直線時的基礎速度\nlastError = 0\n\nloop:\n    error = leftSensor.reflected - rightSensor.reflected\n    turn  = Kp * error + Kd * (error - lastError)\n    lastError = error\n\n    \u002F\u002F X 型全向輪：前進分量 + 旋轉分量\n    drive(forward = BASE_SPEED, strafe = 0, rotate = turn)\n",[69,6415,6413],{"__ignoreMap":222},[16,6417,6418,190,6421,6424,6425,6427,6428,6430,6431,6434],{},[69,6419,6420],{},"Kp",[69,6422,6423],{},"Kd"," 和基礎速度沒有標準答案，只能在場地上反覆實測：先只調 ",[69,6426,6420],{}," 到車子剛\n好開始擺盪，再慢慢加上 ",[69,6429,6423],{}," 把擺盪壓下來，最後才把速度往上拉。另外每次換場地\n都會先",[20,6432,6433],{},"校正感測器","——讀一次白底、一次黑線的反射值，因為比賽場地的燈光和練習\n時不同，數值會跟著偏移。",[12,6436,6438],{"id":6437},"避障狀態機-橫向平移","避障：狀態機 + 橫向平移",[16,6440,6441],{},[142,6442],{"alt":6443,"src":6444},"避障路徑示意圖：超音波偵測到寶特瓶後停車，往右平移離開軌道，直走越過瓶身，再往左平移直到顏色感測器重新看到黑線，回到循跡模式","\u002Fimages\u002Fportfolio\u002Flego-line-follower-avoidance.svg",[16,6446,6447,6448,6451],{},"避障的流程用一個簡單的",[20,6449,6450],{},"狀態機","實作，每個狀態只做一件事，結束條件明確，不容\n易在比賽中「卡在中間」：",[40,6453,6454,6467],{},[43,6455,6456],{},[46,6457,6458,6461,6464],{},[49,6459,6460],{},"狀態",[49,6462,6463],{},"動作",[49,6465,6466],{},"切換條件",[58,6468,6469,6485,6503,6518],{},[46,6470,6471,6476,6479],{},[63,6472,6473],{},[69,6474,6475],{},"FOLLOW",[63,6477,6478],{},"循跡（上一段的差值控制）",[63,6480,6481,6482],{},"超音波距離 \u003C 門檻 → ",[69,6483,6484],{},"STEP_OUT",[46,6486,6487,6491,6497],{},[63,6488,6489],{},[69,6490,6484],{},[63,6492,6493,6494],{},"停車，往右",[20,6495,6496],{},"橫向平移",[63,6498,6499,6500],{},"平移固定距離（馬達角度） → ",[69,6501,6502],{},"PASS",[46,6504,6505,6509,6512],{},[63,6506,6507],{},[69,6508,6502],{},[63,6510,6511],{},"直線前進",[63,6513,6514,6515],{},"前進超過瓶身長度 → ",[69,6516,6517],{},"STEP_IN",[46,6519,6520,6524,6529],{},[63,6521,6522],{},[69,6523,6517],{},[63,6525,6526,6527],{},"往左",[20,6528,6496],{},[63,6530,6531,6532],{},"任一顏色感測器讀到黑線 → ",[69,6533,6475],{},[16,6535,6536],{},"幾個在練習時踩過、後來特別處理的細節：",[147,6538,6539,6545,6551,6559],{},[150,6540,6541,6544],{},[20,6542,6543],{},"距離門檻要留緩衝","：超音波量測有延遲，車速越快煞停距離越長。門檻設得太近會\n直接撞上瓶子，太遠又可能誤判場邊的物體，最後依實際車速調出一個剛好的距離",[150,6546,6547,6550],{},[20,6548,6549],{},"用馬達角度量距離，而不是用時間","：平移與前進的距離都以馬達編碼器的旋轉角度\n判斷，不會因為電池電量下降、馬達變慢而走不夠遠",[150,6552,6553,155,6556,6558],{},[20,6554,6555],{},"回線時用「找到線」當結束條件",[69,6557,6517],{}," 不是平移固定距離，而是一直平移到顏\n色感測器重新看到黑線為止，即使前面幾步有誤差也能自我修正",[150,6560,6561,6564],{},[20,6562,6563],{},"回線後先降速","：剛回到軌道時車身位置還不穩定，先以較低速度循跡一小段，再恢\n復正常速度",[12,6566,6567],{"id":6567},"競賽成果",[147,6569,6570,6579],{},[150,6571,6572,6573,6576,6577],{},"在 ",[20,6574,6575],{},"2018 全能機器人國際邀請賽 循跡競賽"," 中拿下 ",[20,6578,6257],{},[150,6580,6581],{},"機器人能穩定完成循線，遇到寶特瓶障礙物時自動繞開、回到軌道，全程不需人工介\n入",[12,6583,6584],{"id":6584},"收穫",[147,6586,6587,6593,6599],{},[150,6588,6589,6592],{},[20,6590,6591],{},"先把問題拆成狀態，再處理每個狀態","：循跡和避障如果寫在同一個大迴圈裡，條件\n會互相干擾；拆成狀態機後，每個狀態只要關心自己的動作與結束條件，除錯時也能很\n快知道車子卡在哪一步。這和我後來設計後端流程、事件驅動系統時的思考方式是一樣\n的",[150,6594,6595,6598],{},[20,6596,6597],{},"回饋控制的直覺","：從開關式控制改成比例 + 微分控制，是我第一次實際感受到\n「依誤差大小調整反應」的威力。後來在 Kubernetes 上設定 HPA、調整告警門檻\n時，背後其實是同一套回饋迴路的觀念",[150,6600,6601,6604],{},[20,6602,6603],{},"硬體選型決定了軟體的難度","：選擇全向輪讓避障邏輯簡化成「平移出去、平移回\n來」，省掉大量轉彎角度的修正程式。好的架構選擇，往往能讓後面的程式寫得更簡\n單",[12,6606,707],{"id":707},[147,6608,6609,6614,6620,6626,6632],{},[150,6610,6611,6613],{},[20,6612,2256],{},"：LEGO MINDSTORMS EV3（主控制器、四顆大型伺服馬達）",[150,6615,6616,6619],{},[20,6617,6618],{},"感測器","：顏色感測器 × 2（反射光強度模式）、超音波感測器 × 1",[150,6621,6622,6625],{},[20,6623,6624],{},"機構","：X 型配置四顆全向輪（萬向輪），支援全向移動",[150,6627,6628,6631],{},[20,6629,6630],{},"控制","：PD 循跡控制、避障狀態機、馬達編碼器距離控制、感測器校正",[150,6633,6634,6637],{},[20,6635,6636],{},"開發工具","：EV3 圖形化程式（積木式）",{"title":222,"searchDepth":247,"depth":247,"links":6639},[6640,6641,6642,6643,6644,6645,6646],{"id":14,"depth":247,"text":14},{"id":6325,"depth":247,"text":6326},{"id":6366,"depth":247,"text":6367},{"id":6437,"depth":247,"text":6438},{"id":6567,"depth":247,"text":6567},{"id":6584,"depth":247,"text":6584},{"id":707,"depth":247,"text":707},"\u002Fimages\u002Fportfolio\u002Flego-line-follower-cover.svg",{},"\u002Fportfolio\u002Flego-line-follower-robot","競賽作品 ‧ 機構設計與控制程式",{"title":6256,"description":222},[6653,6654,6655,6656,6657,6450,6658],"LEGO MINDSTORMS EV3","顏色感測器","超音波感測器","全向輪（萬向輪）","PD 控制","機器人競賽","portfolio\u002Flego-line-follower-robot","2018 年參加全能機器人國際邀請賽，以 LEGO 機器人拿下循跡競賽第一名。機器人左右各一顆顏色感測器夾著軌道循線，前方以超音波感測器偵測寶特瓶障礙物，搭配四顆全向輪（萬向輪）直接橫向平移閃避，繞過障礙後再自動找回軌道。",2018,"2dIKeceHoD7hisk3v18eS2AZt_49t-c02RqunQcoFF8",{"id":6664,"title":6665,"award":7,"body":6666,"cover":7264,"description":222,"extension":766,"featured":1293,"link":7,"meta":7265,"navigation":280,"order":344,"path":7266,"pinned":1293,"repo":7,"role":7267,"seo":7268,"stack":7269,"stem":7274,"summary":7275,"year":6661,"__hash__":7276},"portfolio\u002Fportfolio\u002Fsmart-home-appliances.md","智慧家電控制系統",{"type":9,"value":6667,"toc":7255},[6668,6670,6676,6679,6718,6729,6733,6736,6742,6796,6799,6803,6818,6824,6831,6889,7051,7054,7080,7083,7090,7096,7133,7137,7143,7195,7198,7217,7219,7252],[12,6669,14],{"id":14},[16,6671,6672,6673,2424],{},"這個專題想解決的是很實際的小麻煩：出門後才想到房間燈沒關、想在回家前先把電腦\n開機、出遠門幾天家裡的盆栽沒人澆水。市面上的智慧家電要整套換掉，於是我決定",[20,6674,6675],{},"自\n己把現有的家電接上網路",[16,6677,6678],{},"整個系統分成三個部分：",[147,6680,6681,6699,6709],{},[150,6682,6683,6686,6687,6690,6691,6694,6695,6698],{},[20,6684,6685],{},"網路","：利用家裡數據機的",[20,6688,6689],{},"固定 IP","，透過 ",[20,6692,6693],{},"Port Forwarding"," 把外網請求\n導進我自己切出來的 ",[20,6696,6697],{},"\u002F24 內網","，讓手機在任何地方都能連回家",[150,6700,6701,6704,6705,6708],{},[20,6702,6703],{},"控制器","：每個區域（客廳、我的房間、電腦、澆花）各放一組 ",[20,6706,6707],{},"Arduino +\nESP8266 Wi-Fi 模組","，用繼電器控制電源開關",[150,6710,6711,5229,6714,6717],{},[20,6712,6713],{},"App",[20,6715,6716],{},"MIT App Inventor"," 做手機 App，一個畫面就能看到並切換所有設備\n的狀態",[16,6719,6720,6721,6724,6725,6728],{},"這個專題讓我第一次把",[20,6722,6723],{},"網路規劃","和",[20,6726,6727],{},"嵌入式控制","放在同一個系統裡思考，做完之\n後也發現自己對「網路」這一塊特別有興趣。",[12,6730,6732],{"id":6731},"網路架構固定-ip-port-forwarding-24-內網","網路架構：固定 IP + Port Forwarding + \u002F24 內網",[16,6734,6735],{},"要讓手機從外面控制家裡的設備，第一個問題是「外面怎麼找得到家裡的 Arduino？」\n家用網路的設備都躲在 NAT 後面、只有私有 IP，外網是看不到的。我的做法是：",[16,6737,6738],{},[142,6739],{"alt":6740,"src":6741},"網路拓樸圖：手機從外網連到數據機的固定公網 IP，依 Port Forwarding 規則轉送到 192.168.10.0\u002F24 內網中各區域的控制器","\u002Fimages\u002Fportfolio\u002Fsmart-home-network.svg",[147,6743,6744,6750,6783],{},[150,6745,6746,6749],{},[20,6747,6748],{},"固定 IP 當入口","：家裡的數據機申請了固定 IP，App 只要記住一個不會變的位\n址，不需要額外架 DDNS",[150,6751,6752,6755,6756,6759,6760,6763,6764,5174,6767,6770,6771,6774,6775,6778,6779,6782],{},[20,6753,6754],{},"切出專用的 \u002F24 網段","：在內網規劃一個 ",[69,6757,6758],{},"192.168.10.0\u002F24"," 給這套系統使用，\nGateway 為 ",[69,6761,6762],{},".1","，控制器依區域",[20,6765,6766],{},"固定分配 IP",[69,6768,6769],{},".11"," 客廳、",[69,6772,6773],{},".12"," 房間、",[69,6776,6777],{},".13","\n電腦、",[69,6780,6781],{},".14"," 澆花）。IP 固定是 Port Forwarding 能運作的前提——如果控制器靠\nDHCP 拿到浮動 IP，轉送規則隨時可能指錯設備",[150,6784,6785,6788,6789,501,6792,6795],{},[20,6786,6787],{},"一個外部 Port 對應一台控制器","：數據機上設定 ",[69,6790,6791],{},":8081 → .11:80",[69,6793,6794],{},":8082 → .12:80","… 以此類推。外部用不同 Port 區分設備，內部每台控制器都只要\n開一個標準的 80 Port，程式可以完全共用",[16,6797,6798],{},"IP 規劃的好處在後續擴充也看得出來：\u002F24 有 254 個可用位址，新增設備只要挑一個\n沒用過的 IP、在數據機加一條轉送規則，App 多一個按鈕即可，不會動到既有設備。",[12,6800,6802],{"id":6801},"控制器arduino-esp8266-繼電器","控制器：Arduino + ESP8266 + 繼電器",[16,6804,6805,6806,6809,6810,6813,6814,6817],{},"每個區域的控制器硬體組成都一樣：",[20,6807,6808],{},"Arduino"," 負責邏輯、",[20,6811,6812],{},"ESP8266"," 負責 Wi-Fi\n連線與 HTTP、",[20,6815,6816],{},"繼電器模組","負責實際切換 110V 電源，讓低電壓的控制電路和家電的\n高電壓完全隔開。",[16,6819,6820],{},[142,6821],{"alt":6822,"src":6823},"控制流程圖：App 發出 HTTP GET 到公網 IP 的指定 Port，數據機 NAT 轉送到內網的 ESP8266，Arduino 解析路徑後切換繼電器，並把目前狀態回傳給 App 更新畫面","\u002Fimages\u002Fportfolio\u002Fsmart-home-control-flow.svg",[16,6825,6826,6827,6830],{},"控制介面刻意設計得非常簡單：",[20,6828,6829],{},"一個動作就是一個 URL 路徑","，回傳值就是目前狀\n態的純文字，App 端不需要解析任何複雜格式。",[40,6832,6833,6845],{},[43,6834,6835],{},[46,6836,6837,6840,6842],{},[49,6838,6839],{},"路徑",[49,6841,6463],{},[49,6843,6844],{},"回傳",[58,6846,6847,6862,6877],{},[46,6848,6849,6854,6857],{},[63,6850,6851],{},[69,6852,6853],{},"\u002Fon",[63,6855,6856],{},"繼電器吸合，設備通電",[63,6858,6859],{},[69,6860,6861],{},"living:ON",[46,6863,6864,6869,6872],{},[63,6865,6866],{},[69,6867,6868],{},"\u002Foff",[63,6870,6871],{},"繼電器斷開，設備斷電",[63,6873,6874],{},[69,6875,6876],{},"living:OFF",[46,6878,6879,6884,6887],{},[63,6880,6881],{},[69,6882,6883],{},"\u002Fstatus",[63,6885,6886],{},"只查詢不切換",[63,6888,742],{},[217,6890,6894],{"className":6891,"code":6892,"language":6893,"meta":222,"style":222},"language-cpp shiki shiki-themes github-dark","#include \u003CSoftwareSerial.h>\n#include \u003CWiFiEsp.h>\n\nconst int RELAY_PIN = 7;\nSoftwareSerial esp(2, 3);            \u002F\u002F RX, TX：與 ESP8266 溝通\nIPAddress ip(192, 168, 10, 11);      \u002F\u002F 每台控制器燒錄時改成自己的固定 IP\nWiFiEspServer server(80);\nbool isOn = false;\n\nvoid setup() {\n  pinMode(RELAY_PIN, OUTPUT);\n  esp.begin(9600);\n  WiFi.init(&esp);\n  WiFi.config(ip);\n  WiFi.begin(\"HomeIoT\", \"********\");\n  server.begin();\n}\n\nvoid loop() {\n  WiFiEspClient client = server.available();\n  if (!client) return;\n\n  String request = client.readStringUntil('\\r');\n  if (request.indexOf(\"GET \u002Fon\") >= 0)  isOn = true;\n  if (request.indexOf(\"GET \u002Foff\") >= 0) isOn = false;\n  digitalWrite(RELAY_PIN, isOn ? HIGH : LOW);\n\n  client.print(\"HTTP\u002F1.1 200 OK\\r\\nContent-Type: text\u002Fplain\\r\\n\\r\\n\");\n  client.print(isOn ? \"living:ON\" : \"living:OFF\");\n  client.stop();\n}\n","cpp",[69,6895,6896,6901,6906,6910,6915,6920,6925,6930,6935,6939,6944,6949,6954,6959,6964,6969,6974,6978,6982,6987,6992,6997,7001,7006,7011,7017,7023,7028,7034,7040,7046],{"__ignoreMap":222},[226,6897,6898],{"class":228,"line":229},[226,6899,6900],{},"#include \u003CSoftwareSerial.h>\n",[226,6902,6903],{"class":228,"line":247},[226,6904,6905],{},"#include \u003CWiFiEsp.h>\n",[226,6907,6908],{"class":228,"line":253},[226,6909,281],{"emptyLinePlaceholder":280},[226,6911,6912],{"class":228,"line":271},[226,6913,6914],{},"const int RELAY_PIN = 7;\n",[226,6916,6917],{"class":228,"line":277},[226,6918,6919],{},"SoftwareSerial esp(2, 3);            \u002F\u002F RX, TX：與 ESP8266 溝通\n",[226,6921,6922],{"class":228,"line":284},[226,6923,6924],{},"IPAddress ip(192, 168, 10, 11);      \u002F\u002F 每台控制器燒錄時改成自己的固定 IP\n",[226,6926,6927],{"class":228,"line":309},[226,6928,6929],{},"WiFiEspServer server(80);\n",[226,6931,6932],{"class":228,"line":314},[226,6933,6934],{},"bool isOn = false;\n",[226,6936,6937],{"class":228,"line":323},[226,6938,281],{"emptyLinePlaceholder":280},[226,6940,6941],{"class":228,"line":329},[226,6942,6943],{},"void setup() {\n",[226,6945,6946],{"class":228,"line":344},[226,6947,6948],{},"  pinMode(RELAY_PIN, OUTPUT);\n",[226,6950,6951],{"class":228,"line":356},[226,6952,6953],{},"  esp.begin(9600);\n",[226,6955,6956],{"class":228,"line":365},[226,6957,6958],{},"  WiFi.init(&esp);\n",[226,6960,6961],{"class":228,"line":374},[226,6962,6963],{},"  WiFi.config(ip);\n",[226,6965,6966],{"class":228,"line":384},[226,6967,6968],{},"  WiFi.begin(\"HomeIoT\", \"********\");\n",[226,6970,6971],{"class":228,"line":391},[226,6972,6973],{},"  server.begin();\n",[226,6975,6976],{"class":228,"line":398},[226,6977,274],{},[226,6979,6980],{"class":228,"line":404},[226,6981,281],{"emptyLinePlaceholder":280},[226,6983,6984],{"class":228,"line":409},[226,6985,6986],{},"void loop() {\n",[226,6988,6989],{"class":228,"line":414},[226,6990,6991],{},"  WiFiEspClient client = server.available();\n",[226,6993,6994],{"class":228,"line":428},[226,6995,6996],{},"  if (!client) return;\n",[226,6998,6999],{"class":228,"line":433},[226,7000,281],{"emptyLinePlaceholder":280},[226,7002,7003],{"class":228,"line":463},[226,7004,7005],{},"  String request = client.readStringUntil('\\r');\n",[226,7007,7008],{"class":228,"line":484},[226,7009,7010],{},"  if (request.indexOf(\"GET \u002Fon\") >= 0)  isOn = true;\n",[226,7012,7014],{"class":228,"line":7013},25,[226,7015,7016],{},"  if (request.indexOf(\"GET \u002Foff\") >= 0) isOn = false;\n",[226,7018,7020],{"class":228,"line":7019},26,[226,7021,7022],{},"  digitalWrite(RELAY_PIN, isOn ? HIGH : LOW);\n",[226,7024,7026],{"class":228,"line":7025},27,[226,7027,281],{"emptyLinePlaceholder":280},[226,7029,7031],{"class":228,"line":7030},28,[226,7032,7033],{},"  client.print(\"HTTP\u002F1.1 200 OK\\r\\nContent-Type: text\u002Fplain\\r\\n\\r\\n\");\n",[226,7035,7037],{"class":228,"line":7036},29,[226,7038,7039],{},"  client.print(isOn ? \"living:ON\" : \"living:OFF\");\n",[226,7041,7043],{"class":228,"line":7042},30,[226,7044,7045],{},"  client.stop();\n",[226,7047,7049],{"class":228,"line":7048},31,[226,7050,274],{},[16,7052,7053],{},"三種設備在接線上的差異：",[147,7055,7056,7067],{},[150,7057,7058,7061,7062,190,7064,7066],{},[20,7059,7060],{},"客廳、我的房間","：繼電器串接在電燈與插座的電源線上，",[69,7063,6853],{},[69,7065,6868],{}," 直接對\n應通電與斷電",[150,7068,7069,7072,7073,7076,7077,7079],{},[20,7070,7071],{},"電腦","：電腦不能直接斷電，所以繼電器改成",[20,7074,7075],{},"並聯在主機板的電源按鈕接腳","\n上。收到 ",[69,7078,6853],{}," 時讓繼電器吸合約 0.5 秒再放開，模擬「按一下電源鍵」，讓電腦走\n正常的開機流程",[12,7081,7082],{"id":7082},"自動澆花系統",[16,7084,7085,7086,7089],{},"澆花控制器在同一套架構上多了一個 ",[20,7087,7088],{},"DS3231 RTC 時鐘模組","。Arduino 本身沒有可\n靠的時鐘，一斷電時間就歸零；RTC 模組有自己的電池，停電後再復電仍然知道現在幾\n點，排程不會亂掉。",[16,7091,7092],{},[142,7093],{"alt":7094,"src":7095},"自動澆花流程圖：控制器每秒讀取 RTC 時間，到了設定時間就開啟水泵、澆 N 秒後自動關閉；手機 App 可遠端調整澆花時間、澆水秒數或立即手動澆一次","\u002Fimages\u002Fportfolio\u002Fsmart-home-watering.svg",[147,7097,7098,7104,7118,7127],{},[150,7099,7100,7103],{},[20,7101,7102],{},"定時澆花","：控制器每秒讀一次 RTC，到了設定的時間（例如每天 07:00）就讓繼電\n器吸合、啟動小型水泵，澆滿設定秒數後自動關閉",[150,7105,7106,7109,7110,7113,7114,7117],{},[20,7107,7108],{},"遠端調整","：App 可以透過 ",[69,7111,7112],{},"\u002Fschedule?h=7&m=0"," 修改澆花時間、\n",[69,7115,7116],{},"\u002Fduration?s=15"," 修改澆水秒數，出門在外也能依天氣調整",[150,7119,7120,155,7123,7126],{},[20,7121,7122],{},"手動澆一次",[69,7124,7125],{},"\u002Fwater"," 立即澆一次，適合天氣特別熱的時候額外補水",[150,7128,7129,7132],{},[20,7130,7131],{},"安全上限","：不論收到什麼參數，單次澆水時間都有上限，避免參數打錯或網路重\n送請求時，水泵一直開著把陽台淹掉",[12,7134,7136],{"id":7135},"手機-appapp-inventor","手機 App：App Inventor",[16,7138,7139,7140,7142],{},"App 用 ",[20,7141,6716],{}," 開發，整個畫面就是一排依區域分組的按鈕：客廳、我的\n房間、電腦、澆花。",[147,7144,7145,7166,7189],{},[150,7146,7147,7150,7151,7154,7155,7158,7159,7162,7163],{},[20,7148,7149],{},"送出指令","：每個按鈕對應一組 URL，點擊時用 ",[69,7152,7153],{},"Web"," 元件把 ",[69,7156,7157],{},"Url"," 設為\n",[69,7160,7161],{},"http:\u002F\u002F\u003C固定IP>:\u003CPort>\u002F\u003C動作>"," 再呼叫 ",[69,7164,7165],{},"Web.Get",[150,7167,7168,155,7171,7174,7175,124,7178,7181,7182,7184,7185,7188],{},[20,7169,7170],{},"同步狀態",[69,7172,7173],{},"Web.GotText"," 收到控制器回傳的文字後，依 ",[69,7176,7177],{},"ON",[69,7179,7180],{},"OFF"," 把按鈕\n改成對應的顏色與文字；App 開啟時則對每台控制器打一次 ",[69,7183,6883],{},"，讓畫面顯示的\n永遠是設備的",[20,7186,7187],{},"實際狀態","，而不是上次按了什麼",[150,7190,7191,7194],{},[20,7192,7193],{},"連線失敗提示","：控制器沒有回應時（斷電、斷線）跳出提示，而不是讓使用者以為\n已經關掉了",[12,7196,7197],{"id":7197},"成果與收穫",[147,7199,7200,7203,7210],{},[150,7201,7202],{},"完成可以實際在家使用的系統：人在外面就能用手機關掉忘記關的燈、提早把電腦開\n機，盆栽也能按時自動澆水",[150,7204,7205,7206,7209],{},"第一次從頭規劃一個網段：",[20,7207,7208],{},"IP 位址規劃、固定 IP 分配、NAT 與 Port\nForwarding"," 這些原本只在課本上的觀念，在這裡都要自己設定、自己除錯。設定錯\n一條轉送規則、或控制器的 IP 被 DHCP 換掉，App 就連不到設備，這些坑踩過一次\n就記住了，也讓我更確定畢業後想往網路工程的方向走",[150,7211,7212,7213,7216],{},"目前最大的問題是安全性：控制器直接把 HTTP 服務開在公網上，沒有加密也沒有身\n分驗證，只要知道 IP 和 Port 就能控制家電。下一步想研究的是讓設備",[20,7214,7215],{},"不直接對外\n開 Port","，例如手機先 VPN 回家再控制，或改用 MQTT 讓設備主動連到外面的 Broker，\n再加上加密與帳號驗證",[12,7218,707],{"id":707},[147,7220,7221,7226,7232,7238],{},[150,7222,7223,7225],{},[20,7224,6685],{},"：固定 IP、NAT \u002F Port Forwarding、\u002F24 子網路規劃、靜態 IP 分配",[150,7227,7228,7231],{},[20,7229,7230],{},"硬體","：Arduino、ESP8266 Wi-Fi 模組、繼電器模組、DS3231 RTC 時鐘模組、小\n型水泵",[150,7233,7234,7237],{},[20,7235,7236],{},"韌體","：Arduino C\u002FC++（WiFiEsp HTTP Server、繼電器控制、RTC 排程）",[150,7239,7240,7242,7243,190,7245,190,7248,7251],{},[20,7241,6713],{},"：MIT App Inventor（",[69,7244,7153],{},[69,7246,7247],{},"Button",[69,7249,7250],{},"Notifier"," 元件）、Android",[751,7253,7254],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}",{"title":222,"searchDepth":247,"depth":247,"links":7256},[7257,7258,7259,7260,7261,7262,7263],{"id":14,"depth":247,"text":14},{"id":6731,"depth":247,"text":6732},{"id":6801,"depth":247,"text":6802},{"id":7082,"depth":247,"text":7082},{"id":7135,"depth":247,"text":7136},{"id":7197,"depth":247,"text":7197},{"id":707,"depth":247,"text":707},"\u002Fimages\u002Fportfolio\u002Fsmart-home-cover.svg",{},"\u002Fportfolio\u002Fsmart-home-appliances","個人專題 ‧ 網路規劃與硬體整合",{"title":6665,"description":222},[6808,6812,6816,7270,6716,6693,7271,7272,7273],"DS3231 RTC","NAT","IP 子網路規劃","IoT","portfolio\u002Fsmart-home-appliances","以家中數據機的固定 IP 為入口，透過 Port Forwarding 連進自己規劃的 \u002F24 內網；每個區域各放一組 Arduino + Wi-Fi 模組控制繼電器，用 App Inventor 做的手機 App 就能從外網開關客廳、房間的電器與電腦，並加上一套可定時、也能遠端調整的自動澆花系統。","c_gQZTdcUqAOiI6eOJjsvPTD5INmu_TYlbN1e2_duTA",{"id":7278,"title":7279,"award":7,"body":7280,"cover":7725,"description":222,"extension":766,"featured":1293,"link":7,"meta":7726,"navigation":280,"order":356,"path":7727,"pinned":1293,"repo":7,"role":7728,"seo":7729,"stack":7730,"stem":7733,"summary":7734,"year":6661,"__hash__":7735},"portfolio\u002Fportfolio\u002Fsmart-tissue-box.md","智慧衛生紙盒",{"type":9,"value":7281,"toc":7717},[7282,7284,7291,7297,7314,7317,7319,7330,7336,7361,7364,7368,7375,7381,7384,7416,7553,7556,7587,7591,7597,7600,7632,7635,7661,7663,7685,7687,7715],[12,7283,14],{"id":14},[16,7285,7286,7287,7290],{},"這是我大學的畢業專題。出發點很生活化：公共場所（餐廳、辦公室、廁所）的衛生\n紙盒常常要等到",[20,7288,7289],{},"已經空了","才被發現，補貨永遠慢一步；同時，衛生紙盒又是一個\n大家每天都會盯著看幾秒的物件，本身就是一個被忽略的「廣告版位」。",[16,7292,7293,7294,7296],{},"於是我們做了一個",[20,7295,7279],{},"，把兩件事合在一起：",[147,7298,7299,7305],{},[150,7300,7301,7304],{},[20,7302,7303],{},"感知剩餘量","：在盒蓋內側裝上超音波感測器，量測衛生紙堆的高度，換算成\n「還剩幾 %」，低於門檻時主動提醒補貨",[150,7306,7307,7310,7311,7313],{},[20,7308,7309],{},"前方螢幕做廣告","：盒子前面放一塊螢幕，平常輪播廣告與促銷活動，並同時顯示\n目前剩餘量；螢幕上的 App 以 ",[20,7312,6716],{}," 開發",[16,7315,7316],{},"這也是我第一次把「感測器 → 微控制器 → 無線傳輸 → 手機 App」整條鏈路自己串\n起來的經驗。",[12,7318,586],{"id":586},[16,7320,7321,7322,7325,7326,7329],{},"整個系統分成兩端：",[20,7323,7324],{},"衛生紙盒內的硬體端","負責量測與運算，",[20,7327,7328],{},"前方螢幕上的 App","\n負責呈現與互動，兩者之間以藍牙序列埠溝通。",[16,7331,7332],{},[142,7333],{"alt":7334,"src":7335},"智慧衛生紙盒系統架構圖：超音波感測器量測衛生紙堆高度，Arduino 換算成百分比後經藍牙送到前方螢幕的 App Inventor App，App 顯示剩餘量、輪播廣告與促銷活動","\u002Fimages\u002Fportfolio\u002Fsmart-tissue-box-architecture.svg",[147,7337,7338,7344,7349,7355],{},[150,7339,7340,7343],{},[20,7341,7342],{},"超音波感測器（HC-SR04）","：固定在盒蓋內側、朝下對準衛生紙堆，每隔固定時間\n發射一次超音波並接收回波",[150,7345,7346,7348],{},[20,7347,6808],{},"：讀取回波時間、換算成距離，再換算成剩餘百分比",[150,7350,7351,7354],{},[20,7352,7353],{},"藍牙模組","：把計算好的百分比以序列埠字串送出，硬體端不需要知道螢幕上要顯示\n什麼",[150,7356,7357,7360],{},[20,7358,7359],{},"App Inventor App","：接收百分比，更新畫面上的剩餘量，並負責所有廣告、促銷與\n提醒的呈現邏輯",[16,7362,7363],{},"把「量測」和「呈現」切開的好處是兩邊可以各自修改：要換廣告素材、調整促銷活\n動，只需要改 App；要換感測器或校正數值，只需要改 Arduino 程式。",[12,7365,7367],{"id":7366},"剩餘量量測從超音波到百分比","剩餘量量測：從超音波到百分比",[16,7369,7370,7371,7374],{},"這個專題的核心問題是：",[20,7372,7373],{},"怎麼知道盒子裡還剩多少衛生紙？"," 我們選擇量「高度」\n而不是「重量」，因為衛生紙很輕，秤重感測器在這個重量區間的誤差反而大；而超音波\n感測器便宜、非接觸，裝在盒蓋上也不影響抽取。",[16,7376,7377],{},[142,7378],{"alt":7379,"src":7380},"超音波量測示意圖：感測器在盒蓋往下量到衛生紙堆頂面的距離 d，再依滿盒與空盒的距離換算成剩餘百分比","\u002Fimages\u002Fportfolio\u002Fsmart-tissue-box-measurement.svg",[16,7382,7383],{},"換算分成兩步：",[2388,7385,7386,7398],{},[150,7387,7388,7391,7392,7394,7395],{},[20,7389,7390],{},"回波時間 → 距離","：超音波從發射到接收的時間 ",[69,7393,1096],{}," 是「來回」的時間，音速約\n每微秒 0.0343 公分，所以距離 ",[69,7396,7397],{},"d = t × 0.0343 ÷ 2",[150,7399,7400,7403,7404,7407,7408,7411,7412,7415],{},[20,7401,7402],{},"距離 → 剩餘百分比","：事先校正兩個基準值——滿盒時感測器到頂面的距離\n",[69,7405,7406],{},"D_full","、空盒時感測器到盒底的距離 ",[69,7409,7410],{},"D_empty","——目前的剩餘量就是 ",[69,7413,7414],{},"d"," 落在這\n兩者之間的比例",[217,7417,7419],{"className":6891,"code":7418,"language":6893,"meta":222,"style":222},"const int TRIG_PIN = 9;\nconst int ECHO_PIN = 10;\nconst float D_FULL = 3.0;   \u002F\u002F 滿盒時到頂面的距離（cm），實際依盒子校正\nconst float D_EMPTY = 12.0; \u002F\u002F 空盒時到盒底的距離（cm）\n\nfloat readDistanceCm() {\n  digitalWrite(TRIG_PIN, LOW);\n  delayMicroseconds(2);\n  digitalWrite(TRIG_PIN, HIGH);\n  delayMicroseconds(10);\n  digitalWrite(TRIG_PIN, LOW);\n  long duration = pulseIn(ECHO_PIN, HIGH);\n  return duration * 0.0343 \u002F 2;\n}\n\nvoid loop() {\n  float sum = 0;\n  for (int i = 0; i \u003C 5; i++) {      \u002F\u002F 取多次平均，過濾雜訊\n    sum += readDistanceCm();\n    delay(60);\n  }\n  float d = sum \u002F 5;\n  int percent = (D_EMPTY - d) \u002F (D_EMPTY - D_FULL) * 100;\n  percent = constrain(percent, 0, 100);\n\n  Serial.println(percent);           \u002F\u002F 經藍牙模組送到 App\n  delay(1000);\n}\n",[69,7420,7421,7426,7431,7436,7441,7445,7450,7455,7460,7465,7470,7474,7479,7484,7488,7492,7496,7501,7506,7511,7516,7520,7525,7530,7535,7539,7544,7549],{"__ignoreMap":222},[226,7422,7423],{"class":228,"line":229},[226,7424,7425],{},"const int TRIG_PIN = 9;\n",[226,7427,7428],{"class":228,"line":247},[226,7429,7430],{},"const int ECHO_PIN = 10;\n",[226,7432,7433],{"class":228,"line":253},[226,7434,7435],{},"const float D_FULL = 3.0;   \u002F\u002F 滿盒時到頂面的距離（cm），實際依盒子校正\n",[226,7437,7438],{"class":228,"line":271},[226,7439,7440],{},"const float D_EMPTY = 12.0; \u002F\u002F 空盒時到盒底的距離（cm）\n",[226,7442,7443],{"class":228,"line":277},[226,7444,281],{"emptyLinePlaceholder":280},[226,7446,7447],{"class":228,"line":284},[226,7448,7449],{},"float readDistanceCm() {\n",[226,7451,7452],{"class":228,"line":309},[226,7453,7454],{},"  digitalWrite(TRIG_PIN, LOW);\n",[226,7456,7457],{"class":228,"line":314},[226,7458,7459],{},"  delayMicroseconds(2);\n",[226,7461,7462],{"class":228,"line":323},[226,7463,7464],{},"  digitalWrite(TRIG_PIN, HIGH);\n",[226,7466,7467],{"class":228,"line":329},[226,7468,7469],{},"  delayMicroseconds(10);\n",[226,7471,7472],{"class":228,"line":344},[226,7473,7454],{},[226,7475,7476],{"class":228,"line":356},[226,7477,7478],{},"  long duration = pulseIn(ECHO_PIN, HIGH);\n",[226,7480,7481],{"class":228,"line":365},[226,7482,7483],{},"  return duration * 0.0343 \u002F 2;\n",[226,7485,7486],{"class":228,"line":374},[226,7487,274],{},[226,7489,7490],{"class":228,"line":384},[226,7491,281],{"emptyLinePlaceholder":280},[226,7493,7494],{"class":228,"line":391},[226,7495,6986],{},[226,7497,7498],{"class":228,"line":398},[226,7499,7500],{},"  float sum = 0;\n",[226,7502,7503],{"class":228,"line":404},[226,7504,7505],{},"  for (int i = 0; i \u003C 5; i++) {      \u002F\u002F 取多次平均，過濾雜訊\n",[226,7507,7508],{"class":228,"line":409},[226,7509,7510],{},"    sum += readDistanceCm();\n",[226,7512,7513],{"class":228,"line":414},[226,7514,7515],{},"    delay(60);\n",[226,7517,7518],{"class":228,"line":428},[226,7519,959],{},[226,7521,7522],{"class":228,"line":433},[226,7523,7524],{},"  float d = sum \u002F 5;\n",[226,7526,7527],{"class":228,"line":463},[226,7528,7529],{},"  int percent = (D_EMPTY - d) \u002F (D_EMPTY - D_FULL) * 100;\n",[226,7531,7532],{"class":228,"line":484},[226,7533,7534],{},"  percent = constrain(percent, 0, 100);\n",[226,7536,7537],{"class":228,"line":7013},[226,7538,281],{"emptyLinePlaceholder":280},[226,7540,7541],{"class":228,"line":7019},[226,7542,7543],{},"  Serial.println(percent);           \u002F\u002F 經藍牙模組送到 App\n",[226,7545,7546],{"class":228,"line":7025},[226,7547,7548],{},"  delay(1000);\n",[226,7550,7551],{"class":228,"line":7030},[226,7552,274],{},[16,7554,7555],{},"實作時遇到的幾個實際問題：",[147,7557,7558,7568,7577],{},[150,7559,7560,7563,7564,7567],{},[20,7561,7562],{},"衛生紙表面不平整","：抽取後頂面會有一張翹起來的紙，單次量測會突然變短。以\n",[20,7565,7566],{},"多次取樣平均","並限制在 0–100 之間，讓畫面上的數字不會亂跳",[150,7569,7570,7573,7574,7576],{},[20,7571,7572],{},"最小量測距離","：HC-SR04 在太近的距離量不準，所以感測器不能直接貼著衛生紙，\n要在盒蓋上預留一點高度，這也是 ",[69,7575,7406],{}," 不是 0 的原因",[150,7578,7579,155,7582,190,7584,7586],{},[20,7580,7581],{},"不同盒子要重新校正",[69,7583,7406],{},[69,7585,7410],{}," 會隨盒子尺寸改變，所以設計成常\n數集中在程式開頭，換盒子時只需要量兩次距離",[12,7588,7590],{"id":7589},"前方螢幕app-inventor-製作的廣告與促銷畫面","前方螢幕：App Inventor 製作的廣告與促銷畫面",[16,7592,7593,7594,7596],{},"盒子前方的螢幕是一台 Android 裝置，上面跑的 App 用 ",[20,7595,6716],{}," 開發。\nApp Inventor 以拖拉積木（Blocks）的方式寫程式，對我們來說可以把時間花在\n「畫面要呈現什麼」，而不是卡在 Android 開發環境上。",[16,7598,7599],{},"畫面由上而下分成三塊：",[147,7601,7602,7616,7626],{},[150,7603,7604,7607,7608,7611,7612,7615],{},[20,7605,7606],{},"廣告輪播區","：佔畫面最大的版位，用 ",[69,7609,7610],{},"Clock"," 元件每隔幾秒觸發一次，依序切換\n",[69,7613,7614],{},"Image"," 的圖片來源，達到輪播效果",[150,7617,7618,7621,7622,7625],{},[20,7619,7620],{},"剩餘量區","：顯示目前百分比與進度條；",[69,7623,7624],{},"BluetoothClient"," 收到 Arduino 送來的數\n字後即時更新",[150,7627,7628,7631],{},[20,7629,7630],{},"促銷活動區","：顯示限時優惠與折價券 QR Code，讓使用者在抽衛生紙的幾秒鐘內就\n能掃碼帶走優惠",[16,7633,7634],{},"App 端的主要邏輯（以積木表達的流程）：",[147,7636,7637,7643,7649,7655],{},[150,7638,7639,7642],{},[69,7640,7641],{},"Screen.Initialize","：連線到衛生紙盒的藍牙模組，啟動輪播與讀值的計時器",[150,7644,7645,7648],{},[69,7646,7647],{},"ReadClock.Timer","：若藍牙有資料可讀，就讀出百分比、更新文字與進度條",[150,7650,7651,7654],{},[69,7652,7653],{},"AdClock.Timer","：輪播索引加一，從廣告清單中換下一張圖",[150,7656,7657,7660],{},[20,7658,7659],{},"低量提醒","：當百分比低於設定門檻（例如 20%），剩餘量區改成紅色並顯示「請\n補充衛生紙」，讓現場人員一眼就看得到",[12,7662,7197],{"id":7197},[147,7664,7665,7668,7675,7682],{},[150,7666,7667],{},"完成一個可實際展示的原型：抽取衛生紙時，前方螢幕上的剩餘百分比會跟著下降，\n同時持續輪播廣告與促銷內容",[150,7669,7670,7671,7674],{},"第一次完整經歷",[20,7672,7673],{},"感測器 → 微控制器 → 無線傳輸 → App"," 的端到端整合，也體會到\n硬體資料「先清洗再使用」的重要性——真實世界的感測值永遠比課本上的雜",[150,7676,7677,7678,7681],{},"把「量測」和「呈現」拆成兩個獨立的部分、用一個簡單的協定（只傳一個數字）溝\n通，專題後期組員分頭修改 Arduino 和 App 時幾乎不會互相影響，讓我體會到",[20,7679,7680],{},"職責\n分離、介面越簡單越好","的價值",[150,7683,7684],{},"還想改進的地方：目前一個紙盒只能對應一台螢幕，而且補貨提醒只有現場看得到。\n如果之後能改用 Wi-Fi 把多個紙盒的剩餘量集中回報到一個後台，清潔人員就能一次\n看到所有位置的狀態，廣告內容也能從後台統一更換",[12,7686,707],{"id":707},[147,7688,7689,7694,7699,7709],{},[150,7690,7691,7693],{},[20,7692,7230],{},"：Arduino、HC-SR04 超音波感測器、藍牙序列埠模組",[150,7695,7696,7698],{},[20,7697,7236],{},"：Arduino C\u002FC++（回波量測、距離換算、取樣平均）",[150,7700,7701,7242,7703,190,7705,190,7707,7251],{},[20,7702,6713],{},[69,7704,7624],{},[69,7706,7610],{},[69,7708,7614],{},[150,7710,7711,7714],{},[20,7712,7713],{},"概念","：IoT 感測與資料平滑、數位看板（Digital Signage）廣告輪播",[751,7716,7254],{},{"title":222,"searchDepth":247,"depth":247,"links":7718},[7719,7720,7721,7722,7723,7724],{"id":14,"depth":247,"text":14},{"id":586,"depth":247,"text":586},{"id":7366,"depth":247,"text":7367},{"id":7589,"depth":247,"text":7590},{"id":7197,"depth":247,"text":7197},{"id":707,"depth":247,"text":707},"\u002Fimages\u002Fportfolio\u002Fsmart-tissue-box-cover.svg",{},"\u002Fportfolio\u002Fsmart-tissue-box","大學專題 ‧ 硬體整合與 App 開發",{"title":7279,"description":222},[6808,7731,7353,6716,7732,7273],"HC-SR04 超音波感測器","Android","portfolio\u002Fsmart-tissue-box","大學畢業專題。在衛生紙盒蓋內裝上超音波感測器，即時量測衛生紙堆的高度並換算成剩餘百分比，再透過藍牙送到盒子前方的螢幕；螢幕上以 App Inventor 製作的 App 同時顯示剩餘量、輪播廣告與促銷活動，讓一個日常耗材變成可以感知、也能帶來曝光的小型物聯網裝置。","CBBJEN-Ixas74_oH1SA0uXqSPMHRq56P7VGN-UR1LWw",1792959149539]