# T525 — opportunity-model-repository > Status: `done` > Milestone: `M2` > Kind: `feat` > Est. change: `~190 lines` ## Goal 完成後系統應:Opportunity domain 與 repository 就位,狀態機依 spec §3.2,同 owner 同 `external_id` 去重且可記錄多個觸發 term。 ## Depends on - T501 ## Inputs - Spec:`../spec.md` §3.2、§3.3、§8、§9.2(OP-06、SW-06) - 既有 module 模式:`apps/backend/internal/module/scout/**` ## Outputs ### 程式變更(預期路徑) | 路徑 | 動作 | 說明 | |------|------|------| | `apps/backend/internal/module/radar/domain/opportunity.go` | add | struct+status 轉移+`intent_band` 由 score 推導(80/50)+`reasons[]` 五維度驗證 | | `apps/backend/internal/module/radar/repository/opportunity_mongo.go` | add | `UpsertByExternalID`、`ListByOwner`(band/status/watch/日期 filter)、`CountToday`、`Get`、`UpdateStatus`、`SetOverride` | | `apps/backend/internal/module/radar/repository/opportunity_memory.go` | add | 測試用 | ### 行為變更 - `reasons[]` 不足五維度時 repository **拒絕寫入**(回明確錯誤)——OP-06。 - `UpsertByExternalID`:命中既有則只把新的觸發 term 併入 `matched_terms[]`,不重複建立、不重跑判定。 - `region_match` 為三態 `match|mismatch|unknown`;`status` 含 `judging|qualified|rejected|accepted|dismissed`。 - `override` 記 `{from_band, to_band, from_status, to_status, actor_uid, at}`。 ## Out of scope - 判定演算法(T529)與寫入流程(T530) - HTTP logic(T545/T546/T547) ## Acceptance - [x] unit test:缺一條 reason 被拒;同 `external_id` 兩次 upsert 只有一筆且 `matched_terms` 有兩個 - [x] score 79→`mid`、80→`high`、49→`low` - [x] 指令: ```bash cd apps/backend && go test ./internal/module/radar/... -count=1 ``` ## Notes band 門檻 80/50 為 spec 鎖定值,**不可**在此 task 調整。 ### 實作落點 - `domain/opportunity.go`:status 轉移、`BandFromScore`(80/50)、`ValidateReasons` 五維度、`UTCDayBounds` - `repository/opportunity_{memory,mongo}.go`:`UpsertByExternalID` 命中只併 `matched_terms`;`CountToday`/`ListOpportunities`/`SetOpportunityOverride` - 測試:`domain/opportunity_test.go`、`repository/opportunity_memory_test.go`