企業後台網域切割(wport.me → business.wport.me)PRD
產出 persona:CEO with PM Background(+ Research-Driven Technical PM 補技術裁決)。
目標 repo:W101-Web(apps/recruitment為主,apps/main-site/packages/*連帶)、W101-TalentSearchHub、W101-AMS、邊緣路由(Cloudflare/nginx)。
關聯:pm_51(複製公開網址)、pm_40(company_pay)、pm_37/pm_41/pm_48(Enterprise API/CLI/MCP)、pm_39(第三方登入)。
1. 文件資訊
- 文件類型:需求規格書(平台基礎建設;跨 repo;不產 Storybook)
- 適用對象:前端(main-site/recruitment/user)、後端(TSH/AMS)、DevOps、產品、QA、客服營運、行銷
- 最後更新:2026/08/20
- 版本:1.2.4
- PRD 識別碼:
pm_52 - 對應 Storybook:N/A(無新畫面;見 ADR-010)
- Storybook(local-prd):N/A — 以本 PRD 為 SoT
- Storybook(online):N/A
- 文件入口:
doc/feature/pm_52/business-domain-split-prd.md - 建立日期:2026/08/18
- 現況網域:企業後台
https://wport.me/recruitment/* - 目標網域:企業後台
https://business.wport.me/*(root path)
1.1 修訂紀錄
| 版本 | 日期 | 摘要 |
|---|---|---|
| 1.0.0 | 2026/08/18 | 初版。依 W101-Web/W101-TalentSearchHub/W101-AMS 實查撰寫:確立 root path、集中式登入、cookie 為 session SoT、同源反向代理、302→301 兩階段切換;含 12 條 ADR 與跨 repo sync-fix list |
| 1.1.0 | 2026/08/18 | 依 Eric 回饋補強:§3.2 新增「切換企業模式常因 session 卡住」為驅動力並附誠實說明(切網域不會自動修好,是本 PRD 的 session 工作在修);§3.5 移除原 S1(與新條件重複)並新增 S6「切換成企業模式不再卡住」,其餘重新編號;§4.3 新增職缺/公司「留 vs 搬」對照表與同路徑不同 host 的注意事項 |
| 1.2.0 | 2026/08/19 | 兩批修訂。(一)依 PM 決議調整驗收環境:移除 staging 相關規劃(staging-business 暫不做),改為 dev 切出 developers-business.wport.me 作為驗收場;連帶更新 §5.3、§9、§10.4、§11、ADR-008、§13.0、§13.2、§14。(二)依後端 spec 的 codebase 實查修正 13 處事實錯誤與遺漏:§6.2 SEC-DEEPLINK 全段重寫(承接頁行為必須改、去前綴發生在下游 app 而非 main-site、redirect_path 可能為空字串)、§8.1 補聊天室 origin 設定、§8.2 後端設定修正、§8.3 移除「必須同版上線」並改為 R1/R2 形狀、ADR-006 Consequences 補聊天室例外、§13.4 補 #4b/#5b/#5c/#6b/#8b 與漏列檔案。新增 §13.3 問題 #7–#11 |
| 1.2.1 | 2026/08/19 | 修正 §8.3 與 §13.3 #9 對 target_origin 的說明:其作用是「讓契約顯式」而非「修 bug」(v1.2.0 誤寫)。新增 §13.3 問題 #12——buildRedirectPath() 回空字串導致通知點擊停在空白頁,屬既有缺陷、與本案無關、另案處理 |
| 1.2.2 | 2026/08/19 | 修正兩處自相矛盾:(一) 檔頭版本/日期停在 1.1.0/08-18,與修訂紀錄脫節(本表也一併依版號排序)。(二) §8.2 與 §13.1 寫「禁止在後端字串保留 /recruitment 前綴」,但 §13.4 #5b 把同一件事標為「選配 cleanup」;兩處措辭改為區分「新寫的網址組裝一律不得帶前綴(硬規則)」與「既有 redirect_path 的去前綴=選配 cleanup(#5b)」。來源:後端 spec v1.2 的 Codex 覆審 |
| 1.2.3 | 2026/08/20 | 依「修正擴散」原則回掃 v1.2.2 未掃到的同主題現場,修 3 處:(一) §7 ACT-6 仍把「後端 redirect_path 去前綴」寫成必做,與 §8.2/§13.4 #5b 的「選配」矛盾(v1.2.2 只改了 §8.2、§13.1 兩處),並移除已被 §8.3 廢止的「scope 決定 origin」措辭。(二) §13.3 問題清單列序 …9, 12, 10, 11,#12 插入時未重排,改為遞增。(三) §8.3 決議新增的 target_origin 欄位是 BE 工作,卻沒有對應的 §13.4 sync-fix 列 → 補 #5d。來源:本次 PRD 全文一致性回掃 |
| 1.2.4 | 2026/08/20 | §13.3 #6 補記 BR-042 與 pm_46 撞號:business-rules.md v1.10.0 配到 BR-041,本 PRD 與 pm_46 都提案佔用 BR-042,且 pm_46 的 BR-039/040/041 直接覆蓋 pm_48/pm_41/pm_38 已配發的規則。回寫前須以 scripts/prd-lint.py --all(C10)確認號碼未被占用。來源:跨文件一致性檢查 |
2. 開發進度與設計來源
開發進度
- 前端 PR #(
W101-Web):TBD - 後端 PR #(
W101-TalentSearchHub):TBD - 後端 PR #(
W101-AMS):TBD - 邊緣路由 / DNS:TBD(Cloudflare 設定,非 repo 內)
設計來源(本 PRD 的事實基礎=實查程式碼,非推測)
| 事實 | 來源 |
|---|---|
企業後台為 Vite SPA,base: '/recruitment' | apps/recruitment/vite.config.ts |
路由表本身已是 root-relative(/dashboard、/jobs…) | apps/recruitment/src/constants/navLink.ts、src/router/** |
靜態檔由 nginx 以 location /recruitment/ 服務 | nginx/prod/main-web.conf |
API 走相對路徑(VITE_API_BASE_URL 未設定 → axios baseURL undefined) | apps/recruitment/src/utils/http.ts、apps/recruitment/.env.production |
同一 host 下的後端分流:/api→legacy、/v2/api→TSH、/ams-api→AMS、/chat+/socket.io→WS | nginx/proxy.dev.conf |
token/x-company-id-enc 採 localStorage + 無 Domain 屬性的 cookie 雙寫 | packages/auth-session/src/browser-session-storage.ts |
main-site(Nuxt)以 useCookie 寫同名 cookie,同樣未設 domain | apps/main-site/app/utils/nuxt-session-storage.ts |
跨 app 連結以 window.location.origin 拼接 | packages/utils/src/getUrl.ts |
| auth 完成後跳轉明確拒絕絕對 URL(open-redirect 防護) | apps/main-site/app/utils/auth-redirect.ts |
後端郵件/通知硬串 /recruitment/* | TSH company-invitation.service.ts:30、notification-link-token.service.ts:43;AMS company-event.service.ts:163 |
TSH CORS 由 CORS_ORIGIN 逗號清單控制、credentials: true | W101-TalentSearchHub/src/main.ts:80-93 |
WebSocket CORS 由 WS_CORS_ORIGIN 控制 | W101-TalentSearchHub/src/modules/chat/chat.gateway.ts:27-41 |
wport.me 走 Cloudflare;admin/staging 走 CloudFront;business.wport.me 尚未存在 | DNS 實查(2026/08/18) |
Cloudflare Origin 憑證 SAN=*.wport.me, wport.me,效期至 2040/12 | nginx/wport-origin.pem |
設計稿
- Figma:N/A(無新畫面;既有 Header/Footer/登入頁不改版)
3. 摘要
3.1 功能概述
- 功能名稱:企業後台網域切割(Business Domain Split)
- 主要使用者角色:企業使用者(Owner/Admin/Manager/Member)=主要受影響者;求職者與訪客不應感受到任何變化
- 核心目標:
- 讓「公開內容」與「企業營運後台」在網域層級分家:
wport.me= 對外品牌與 SEO 資產,business.wport.me= 企業工作台 - 讓兩者可獨立部署、獨立快取、獨立防護規則、獨立回滾,縮小每次上線的爆炸半徑
- 讓企業客戶有一個可以口頭傳達、可寫進採購文件、可加進公司防火牆白名單的專屬入口
- 為後續
admin.wport.me(內部)/business.wport.me(企業)/wport.me(公開)三層命名體系定調
- 讓「公開內容」與「企業營運後台」在網域層級分家:
3.2 為什麼現在做(策略理由)
| 驅動力 | 說明 | 不做的代價 |
|---|---|---|
| 銷售與品牌 | 企業客戶問「後台網址是什麼」時,wport.me/recruitment 講不清楚也記不住;business.wport.me 可直接印在名片、報價單、教育訓練文件 | 每次導入都要口述路徑;企業 IT 也難以白名單 |
| 部署解耦 | 目前 Nuxt SSR 主站與企業 SPA 綁在同一組邊緣規則下;主站的 SEO/快取策略與後台的「絕不快取 index.html」互相牽制 | 主站調快取=有機率打到後台;後台出事=主站一起回滾 |
| 防護分層 | 分開後可對 business.* 單獨套 WAF/rate limit/未來的 Cloudflare Access 或 IP 允許清單(大型客戶常見要求) | 只能對整站套同一組規則,過鬆或過緊二選一 |
| SEO 邊界 | 後台路徑不再存在於公開站的 URL 空間,robots.txt/noindex 的責任邊界一刀切乾淨 | 持續要靠 Disallow: /recruitment 這種「事後補丁」維持 |
| 既有痛點:切換成企業模式常卡住 | 使用者從求職者切成人資/企業模式時,經常卡在 403、被迫重登、或切了沒生效。根因在 session 契約分散(localStorage 與 cookie 雙寫但以 localStorage 為準、切公司與導頁之間有 race window、多處直讀 localStorage 繞過抽象層),AUTH-SESSION.md §8 已列為已知清單但目前處置是「容忍小機率+401 直接登出」 | 痛點會一直存在;而且不先把這條線收斂,切網域只會讓它更嚴重(多一個 origin=多一份 localStorage) |
| 時機成本最低 | 企業後台路由表已經是 root-relative(base 由 Vite 注入),現在切換幾乎不動路由;等 pm_45/pm_46 人才 CRM/獵頭模組進來後路徑只會更多 | 每晚一季,要改的連結與已寄出的郵件就更多 |
關於「切換卡住」的誠實說明:網域切割本身不會修好這個問題 —— 它只是把問題推到必須面對的位置。真正修它的是本 PRD 的 session 工作:cookie 升為單一 SoT(ADR-004)、讀取順序反轉、切公司「先寫入再跳轉」的順序約束(§6.2 SEC-SWITCH)、以及 6 處直讀
localStorage的修正(§13 sync-fix #2)。換句話說:這條線遲早要收,切網域只是強迫我們一次做對。反過來說,若跳過這些工作直接切網域,
business.wport.me會有自己獨立的 localStorage,現況「偶爾卡住」會惡化成「幾乎每次切換都要重登」—— 所以 §13.2 才把這幾項全部標為 v1 Blocking。
3.3 SaaS Benchmark(公開可觀察之網域慣例,RD/PM 可自行驗證)
| 平台 | 公開/求職端 | 企業/雇主端 | 形態 |
|---|---|---|---|
| Indeed | indeed.com | employers.indeed.com | 子網域 |
| SEEK | seek.com.au | talent.seek.com.au | 子網域 |
| 104 人力銀行 | 104.com.tw | pro.104.com.tw | 子網域 |
| 1111 人力銀行 | 1111.com.tw | hr.1111.com.tw | 子網域 |
| Greenhouse | greenhouse.io | app.greenhouse.io | 子網域 |
| Lever | lever.co | hire.lever.co | 子網域 |
| Stripe | stripe.com | dashboard.stripe.com | 子網域 |
| Shopify | shopify.com | admin.shopify.com | 子網域 |
linkedin.com | linkedin.com/talent | 路徑(少數) |
結論:招募/B2B SaaS 的主流是「公開站 apex + 企業端獨立子網域,且子網域走 root path」。wport 選 business.* 而非 employers.*/hr.*,理由是 wport 的企業端不只招募(含 CRM、方案購買、API 金鑰、未來獵頭),business 語意涵蓋面最廣且中英文都好念。
3.4 範圍界定
v1(必含)
- 新網域上線:
business.wport.me提供企業後台 SPA(root path),DNS/TLS/邊緣路由到位 - 同源後端:
business.wport.me下同時代理/api、/v2/api、/ams-api、/chat、/socket.io(ADR-006) - Session 跨站可用:登入狀態、選定公司(
x-company-id-enc)在wport.me與business.wport.me之間一致(ADR-004) - 舊網址轉址:
wport.me/recruitment/*→business.wport.me/*(先 302、觀察期後轉 301;ADR-005),保留 query - 相容轉址:
business.wport.me/recruitment/*→business.wport.me/*(吃下漏改的舊絕對路徑) - 前端跨站連結修正:
getUrl()由 origin 拼接改為 app origin 解析;Header/Footer/UserDropdown/切換身分/登出等跨站入口全數改用解析結果(ADR-007) - 登入回跳:未登入進
business.wport.me→ 導wport.me/auth?redirect=...,登入後跳回原目標(ADR-003) - 通知 deep-link:
scope=2(企業)解析到 business origin,維持「query 不放絕對 URL」的防護(ADR-007) - 後端連結改用新網域:TSH 邀請信/通知 redirect_path、AMS 審核通知改讀
BUSINESS_DOMAIN - SEO/爬蟲邊界:
business.wport.me/robots.txt回Disallow: /+X-Robots-Tag: noindex - 直接讀
localStorage.getItem('token')的 6 處改走getToken()(否則新網域首次進站必定判定未登入;見 §13 sync-fix #2)
Out of scope
- ❌ 求職者後台
/user不搬(維持wport.me/user/*;ADR-011) - ❌ 公開頁(職缺、公司、活動、內容頁)不搬、URL 不變 ——
pm_51複製出來的網址仍是wport.me - ❌
admin.wport.me不動 - ❌ 不做企業後台改版/不改任何後台功能行為
- ❌ 不做 Storybook(ADR-010)
- ❌ 不改 JWT 結構、不改權限模型、不改
currentUserType判定機制 - ❌ 不導入 HttpOnly cookie + refresh token 輪替(列 v2;ADR-012)
- ❌ 不換 email 寄件網域(SPF/DKIM/DMARC 完全不動)
3.5 成功條件
| # | 條件 | 量測 |
|---|---|---|
| S1 | 所有舊書籤/舊郵件連結可用 | 轉址規則覆蓋 §5.4 全表;上線後 30 天 /recruitment/* 404 數 = 0 |
| S2 | 企業後台既有功能零行為變更 | 既有 unit/Cypress 通過;聊天室 WebSocket 連線成功率不低於切換前 |
| S3 | 求職者端與公開頁零影響 | main-site Playwright smoke 全綠;GSC 索引數不下降 |
| S4 | 可秒級回滾 | 邊緣轉址規則關閉即回舊行為(觀察期用 302 才成立;ADR-005) |
| S5 | 企業後台工作階段數不下滑 | GA4:切換前後 7 日 business + /recruitment 合計工作階段數差異 < 5% |
| S6 | 切換成企業模式不再卡住 —— 切換後不需重新登入、不出現 403、選定公司立即生效 | dev 環境以「同時具備兩種身分且隸屬 2 間以上公司」的帳號跑完 §10.4 全部 Auth & Lifecycle 情境;上線後追蹤 auth.session_bridge_failed 與切換相關客訴數 |
4. 商業規則對齊
4.1 本功能使用到的業務規則
| 規則 ID | 規則摘要 | 是否關鍵 | 備註 |
|---|---|---|---|
| BR-001 | 完成 email 驗證才能登入 | ❌ 否 | 登入邏輯不變,只是入口網域固定在 wport.me/auth |
| BR-007 / BR-016 / BR-017 | 成員角色與權限 | ❌ 否 | 權限判定不變;但 x-company-id-enc 必須跨站一致,否則會誤判權限(見 §10.4) |
| BR-014 / BR-015 | 公司/職缺搜尋顯示條件 | ❌ 否 | 公開搜尋留在 wport.me,完全不受影響 |
| BR-018 | 郵件發送規則 | ✅ 是 | 郵件內連結網域變更(寄件網域不變);模板文案不變,僅 URL 來源改讀新 env |
4.2 新規則提案
BR-042(提案):網域職責分界
wport.me(含www):公開內容與求職者端。可被索引,是唯一對外分享的網域。business.wport.me:企業後台。一律noindex+Disallow: /,不得放置任何需要被搜尋引擎收錄的內容。admin.wport.me:內部後台,僅內部人員。- 任何對外複製/分享的網址一律指向
wport.me(含pm_51複製公開網址、社群分享、QR code)。- 新增任何
*.wport.me子網域前,須評估其對 session cookie 的影響(見 ADR-012)。
建議於本 PRD 通過後回寫 business-rules.md(版本 → 1.11.0)。
4.3 衝突檢查
無硬衝突。
公開頁識別碼不受影響(明確聲明):公司/職缺公開頁的 enc_id 由 DB id 加密而來,與網域無關;公開頁 https://wport.me/companies/{enc_id}、https://wport.me/job/{enc_id} 留在 apps/main-site(pages/companies/[id].vue、pages/job/[id].vue),URL 一字不改。既有分享連結、SEO 索引、pm_50 的 Admin 開公司頁動線皆不受本次切換影響。
註:同一個
enc_id也被當作x-company-id-enc(選定公司)使用 —— 值不變,但載體變了:它必須跟 token 一樣升級為Domain=.wport.me的共享 cookie,否則兩站選定公司會不一致 → 403(見 ADR-004、§10.4)。
職缺/公司:哪些留、哪些搬(RD 開工對照表)
切割線沿著系統既有的「公開頁 vs 後台管理頁」分界走,不是沿著資料實體走:
| 實體 | 留在 wport.me | 搬到 business.wport.me |
|---|---|---|
| 職缺 | /job/{enc_id} 公開頁、/jobs 求職者搜尋列表 | /jobs 職缺管理、/jobs/create、/edit-job/、/preview-job、/view-job/ |
| 公司 | /companies/{enc_id} 公開頁、/companies 求職者搜尋列表 | /company-info、/edit-company-info、/preview-company-info、/audit-certification |
- 判準(= BR-042):要被索引、要能對外分享的 →
wport.me;要登入才看得到的 →business.wport.me - 企業端的「預覽頁」屬於後台:
/preview-job、/preview-company-info視覺上長得像公開頁,但它是需登入的後台路由,跟著搬 - ⚠️ 同路徑不同 host 的組合:切換後會同時存在
wport.me/jobs(求職者搜尋)與business.wport.me/jobs(職缺管理)。功能上無衝突,但這正是「後台連結必須明確帶 origin、不得用相對路徑或location.origin」的直接理由(§13 sync-fix #3/#7)
唯一需要點名的語意衝突:pm_51「複製公開網址」若以 window.location.origin 拼接,切換後會複製出 https://business.wport.me/job/{enc_id}(死連結)。pm_51 PRD 已將公開 URL 定義為常數 https://wport.me/job/{enc_id}、https://wport.me/companies/{enc_id},實作必須照常數走,不得用 origin 拼接(見 §13 sync-fix #7)。
5. 資料實體與設定契約
Rule 31:本節描述「需要被追蹤/設定的資訊與用途」,不鎖死最終 env 變數名或 JSON 結構;命名供跨 repo 對齊參考。
5.1 實體列表
| 實體 | 說明 | 使用者 |
|---|---|---|
| AppOriginMap | 各前端 app 的對外 origin 對照(main/business/user/admin) | 三個前端 app、packages/utils |
| SessionCookieContract | 跨站共享的 session cookie 契約(名稱、domain、屬性、TTL) | packages/auth-session、main-site Nuxt、後端(僅讀) |
| BackendLinkDomains | 後端組信件/通知連結時使用的網域 | TSH、AMS |
| RedirectRuleSet | 邊緣轉址規則(舊 → 新) | Cloudflare/nginx |
5.2 語意定義(參考介面,非最終實作)
/** 各 app 對外 origin。由 build-time env 注入,禁止用 window.location.origin 推導 */
interface AppOriginMap {
main: string; // https://wport.me (公開站/登入入口/求職者端)
business: string; // https://business.wport.me(企業後台)
user: string; // https://wport.me (求職者後台,本次仍在 main 之下)
}
/** 跨站 session cookie 契約 */
interface SessionCookieContract {
name: string; // 'token' / 'x-company-id-enc'(可加環境前綴,見 ADR-008)
domain: string; // '.wport.me' ← 本次新增,現況為 host-only
path: '/';
secure: true; // 本次新增
sameSite: 'Lax'; // 本次明確化(wport.me 與 business.wport.me 屬同 site,Lax 即可)
httpOnly: false; // v1 維持 false(前端需讀);v2 目標改 true,見 ADR-012
maxAgeDays: 7; // 維持現況(亦符合 Safari ITP 對 JS 寫入 cookie 的 7 天上限)
}
5.3 網域對照表(常數)
| 用途 | 現況 | 目標 |
|---|---|---|
| 公開站/求職者端/登入 | https://wport.me | 不變 |
| 企業後台 | https://wport.me/recruitment/* | https://business.wport.me/* |
| 內部後台 | https://admin.wport.me | 不變 |
| Staging(公開站) | https://staging.wport.me | 不變(本次不動;staging 不切企業網域,見下) |
| dev(公開站/開發機) | https://developers.wport.me | 不變 |
| dev(企業後台) | N/A | https://developers-business.wport.me ← 單層子網域(*.wport.me 憑證只覆蓋一層,business.developers.wport.me 會憑證失效)。這是本案的驗收場 |
5.4 轉址對照表(v1 必須覆蓋)
通用規則:https://wport.me/recruitment/(.*) → https://business.wport.me/$1,保留 query string(fragment 由瀏覽器自動保留)。
| 舊網址 | 新網址 | 來源 |
|---|---|---|
/recruitment/ 或 /recruitment | /(SPA 內部再 redirect 到 /jobs) | Header「企業刊登」、store-core defaultUrlWhenCompany |
/recruitment/dashboard | /dashboard | web-components header |
/recruitment/jobs | /jobs | 公司註冊完成頁 |
/recruitment/messages | /messages | 通知中心、MessageButton |
/recruitment/talent-pool | /talent-pool | HeaderNavLinks |
/recruitment/account-center | /account-center | 刪除帳號流程(apps/user) |
/recruitment/account-center/api-keys | /account-center/api-keys | main-site 企業功能介紹頁(pm_37/pm_41/pm_48 動線) |
/recruitment/audit-certification | /audit-certification | AMS 審核通知信 |
/recruitment/purchase-plan | /purchase-plan | web-components header(pm_40 動線) |
/recruitment/invite/{enc_company_id}?token=… | /invite/{enc_company_id}?token=… | TSH 成員邀請信(pm_25) |
/recruitment/deep-link?to=…&scope=2&… | /deep-link?to=…&scope=2&… | 通知 deep-link(to 內含的 /recruitment/messages 亦需一併轉換,見 §7 ACT-6) |
5.5 容量/限制
| 項目 | 限制 | 備註 |
|---|---|---|
| 轉址保留期 | 永久(至少 24 個月不得移除) | 已寄出的郵件無法回收 |
| Cookie TTL | 7 天(不變) | 對齊 Safari ITP 對 JS 寫入 cookie 的上限 |
| 觀察期(302 → 301) | 14 天 | ADR-005 |
| 新增/建立上限 | N/A | 本功能無 create 行為 |
6. 畫面區塊與資料需求
本功能不新增畫面,只改變既有元件產生連結與判斷狀態的方式。
6.1 區塊列表
| 區塊 ID | 區塊名稱 | 說明 |
|---|---|---|
| SEC-NAV | 共用 Header/Footer 跨站連結 | packages/common-components/packages/web-components/packages/utils/HeaderNavLinks |
| SEC-SWITCH | 切換身分(求職者 ⇄ 企業) | store-core setSelectedCompany() 的導頁目標 |
| SEC-AUTH | 登入回跳 | main-site /auth + auth-redirect.ts |
| SEC-DEEPLINK | 通知 deep-link 承接 | main-site notification-links → business /deep-link |
| SEC-SESSION | Session 橋接與跨站一致性 | auth-session cookie 契約 + 可見性重讀 |
| SEC-EDGE | 邊緣路由與轉址 | Cloudflare/nginx |
6.2 各區塊需求
SEC-NAV 共用 Header/Footer
- 現況問題:
getUrl()以window.location.origin拼接,在 business 站會產生https://business.wport.me/media-section/(404) - 要求:所有跨站連結改用 AppOriginMap 解析。呼叫端需標明目標 app,例如
getUrl('/media-section/', 'main')、getUrl('/jobs', 'business') - 相容:未標明 app 時維持「同 origin 相對路徑」語意,避免一次改動全站
- 涵蓋清單:
packages/utils/src/constants/HeaderNavLinks.ts、packages/web-components/src/layouts/header.js(含/recruitment/、/recruitment/dashboard、/recruitment/purchase-plan)、footer.js(${this.hostName}/recruitment)、packages/common-components/src/components/W101Header/**、W101Footer/**
SEC-SWITCH 切換身分
- 現況:
setSelectedCompany(encId)→refreshToken()→navigate('/recruitment/') - 目標:導向
AppOriginMap.business + '/';因為跨 origin,必須是 hard navigation(window.location.assign),不可用 router.push - UI 要求:跨站跳轉期間顯示既有 loading/遮罩直到頁面卸載,避免使用者看到「按了沒反應」後重複點擊
- 順序約束:先確定新 JWT 與
x-company-id-enc已寫入共享 cookie,再跳轉。否則新站讀到舊值 → 403(見 §10.4)
SEC-AUTH 登入回跳
- 未登入直接開
business.wport.me/jobs→ 導https://wport.me/auth?redirect=%2Fjobs&app=business - 登入成功後 main-site 依
app參數解析 origin 後 hard navigate - 安全:
redirect參數維持只接受/開頭的相對路徑(現況防護不放寬);跨站以獨立的app參數表達,且app只接受列舉值(main|business|user),任何非列舉值一律當main(ADR-007)
SEC-DEEPLINK 通知 deep-link
- 後端
notification-linksAPI 回傳redirect_path+identity_scope(已存在)
實際呼叫鏈(v1.2.0 依實查修正)——main-site 不消費 redirect_path,它只把值 encodeURIComponent
後包進 to 參數轉手(apps/main-site/app/pages/notification-links/index.vue:68-95),真正解析的是下游 app:
BE resolve → redirect_path
→ main-site 包成 /recruitment/deep-link?to=<redirect_path>&scope=2&…(硬跳)
→ apps/recruitment/src/views/deep-link/index.vue 解析 to
-
承接頁
/deep-link的行為必須改(v1.1.0 誤寫「不變」)。該檔兩個分支對前綴的假設不一致:分支 行 現行程式 給無前綴的 /messagesA:不用切公司 35-36 to.replace(/^\/(recruitment|user)/, '')後router.push剛好能動 B:需要切公司 20-27 finalUrl = to + '?enc_id=',經packages/common-components/src/stores/modules/global.ts:29做location.href,未去前綴❌ 跳到網站根目錄 → main-site catch-all pages/[...slug].vue:14-16執行router.replace('/')→ 靜默導回首頁 -
要求:兩個分支統一為「先去
/recruitment前綴 → 再由 app 自己補上正確的 base/origin」。apps/user/src/views/deep-link/是同款寫法,一併處理。 -
效果:改完之後同一段程式對「帶前綴的舊值」與「不帶前綴的新值」都正確 → 前後端不需要同版上線(見 §8.3 修正)。
-
⚠️
redirect_path可能是空字串:buildRedirectPath()對PUBLIC_TARGET_TYPES(目前只有COMPANY=公開公司頁)回傳'',未知 target type 回'/'。這兩條與identity_scope無關, 目標實際在 main origin。若前端照「scope=2→ business origin」硬對應會導錯。 目前後端只發CONVERSATION/APPLICATION,此路徑休眠但存在 → 見 §13.3 問題 #9。 -
既有的 token 水合與
switch-role語意不變,改的只有路徑與 origin 的組法。
SEC-SESSION Session 橋接
- Cookie 升為 session 的 SoT;localStorage 降為同源快取(ADR-004)
- 進站時:先讀 cookie,再回填 localStorage(現有
getToken()的 fallback 順序需反轉為 cookie 優先,否則兩站不同步時舊值會贏) visibilitychange/focus時重讀 cookie;與記憶體中的 token 不一致 → 重新水合或登出- 401 攔截 →
signOut()(維持現況)
SEC-EDGE 邊緣路由
business.wport.me 需具備與現行 wport.me 相同的後端分流:
| Path | 目的地 | 備註 |
|---|---|---|
/ 及所有前端路由 | recruitment SPA 靜態檔,try_files … /index.html | index.html 不得快取;/assets/* 可長快取(檔名帶 hash) |
/api/* | legacy API 上游 | 同 wport.me 現況 |
/v2/api/* | TSH | |
/ams-api/* | AMS | |
/chat、/socket.io/ | WebSocket 上游 | 需 proxy_http_version 1.1 + Upgrade/Connection header;Cloudflare 需允許 WebSocket |
/robots.txt | User-agent: * / Disallow: / |
7. 使用者動作與後端需求
| 動作 ID | 動作名稱 | 類型 | 需要後端 | 說明 |
|---|---|---|---|---|
| ACT-1 | 從 wport.me 登入後進企業後台 | 導頁 | ❌ | main-site 解析 business origin 後 hard navigate;session 由共享 cookie 帶過去 |
| ACT-2 | 直接開 business.wport.me/*(已登入) | 讀取 | ❌ | 讀共享 cookie → 水合 → 正常進站 |
| ACT-3 | 直接開 business.wport.me/*(未登入) | 導頁 | ❌ | 導 wport.me/auth?redirect=…&app=business |
| ACT-4 | 切換公司/切換身分 | 寫入+導頁 | ✅ | switchRole 不變;新 JWT 必須寫入共享 cookie 後才跳轉 |
| ACT-5 | 登出 | 寫入 | ❌ | 必須以相同 Domain 刪除 cookie,否則另一站殘留殭屍 session(見 ADR-004) |
| ACT-6 | 點通知信/站內通知進企業訊息 | 導頁 | ✅ | 三件事要分開看(v1.2.3 釐清):FE 必做(R1)——/deep-link 兩分支統一去 /recruitment 前綴,新舊值皆正確(§6.2、§13.4 #5);BE 已決議加——resolve 回應新增顯式 target_origin,FE 優先讀、讀不到才回退 identity_scope 推導(additive,§8.3、§13.4 #5d);BE 選配——既有 redirect_path 去前綴屬 cleanup,不做也不會壞(§13.4 #5b) |
| ACT-7 | 點成員邀請信 | 導頁 | ✅ | TSH 邀請信 URL 改讀 BUSINESS_DOMAIN |
| ACT-8 | 點審核/補件通知信 | 導頁 | ✅ | AMS 通知 URL 改讀 BUSINESS_DOMAIN |
| ACT-9 | 開啟舊書籤 wport.me/recruitment/* | 導頁 | ❌ | 邊緣 302/301 |
| ACT-10 | 企業後台內複製公開網址(pm_51) | UI | ❌ | 一律輸出 wport.me 常數,不得用 location.origin |
| ACT-11 | 企業後台聊天室 | WebSocket | ✅ | 同源 /chat;若採跨源則需 WS_CORS_ORIGIN 加白 |
8. API/基礎建設 Hints(非最終設計)
8.1 前端設定(build-time)
| 設定 | 用途 | 現況 | 目標 |
|---|---|---|---|
| business origin | 產生企業後台連結 | 無 | 新增(三個 app 都要,含 main-site 的 runtime config) |
| main origin | business 站回主站的連結 | 無(用 location.origin) | 新增 |
| SPA base path | Vite base | /recruitment | / |
| cookie domain | session 共享範圍 | 未設(host-only) | .wport.me |
| cookie 名稱前綴 | 環境隔離 | 無 | 由 env 提供(ADR-008) |
| 聊天室連線 origin | socket.io 連線目標 | VITE_CHAT_API_ORIGIN / NUXT_PUBLIC_CHAT_API_ORIGIN,各環境指向自己的網域 | 新網域需要自己一份:VITE_CHAT_API_ORIGIN=https://business.wport.me |
⚠️ 聊天室不走 axios(v1.1.0 的 ADR-006 未涵蓋)。
packages/common-components/src/stores/modules/chat.ts:322-334讀VITE_CHAT_API_ORIGIN,packages/chat-core/src/chat.ts:89以io(\${apiOrigin}${namespace}`)連**完整網址**, 不是相對路徑。現況各環境皆已設(.env.staging.example:11,31=https://staging.wport.me`、.env.production.example:11,31=https://wport.me),但 business 網域需要新增一份 build 設定, 漏設會 fallback 到硬編的舊網域。同款硬編 fallback 共三處 (common-components/.../chat.ts、chat-core呼叫端、apps/main-site/app/stores/modules/chat.ts:119-120),建議一併清理。
8.2 後端設定
| Repo | 設定 | 用途 |
|---|---|---|
| TSH | 企業側網域來源(新) | 邀請信 URL、公司資料管理頁 URL、通知 redirect_path。建議吃單一完整 URL(如 BUSINESS_BASE_URL)並在缺值時 throw——既有 getBaseUrl() 缺值會產出 https://undefined 並照樣送進信裡 |
| TSH | CORS_ORIGIN 追加 business origin(dev 與 prod 各一處) | ADR-006 採同源,此項為保險。已實查:staging.wport.me 不在 prod 清單內卻正常運作,證明同源請求不經 CORS |
| TSH | WS_CORS_ORIGIN 追加 business origin | 同上。附註:chat.gateway.ts:39 的 WS_CORS_CREDENTIALS === 'true' || true 恆為 true,該旗標實質失效(既有缺陷,非本案造成) |
| AMS | BUSINESS_DOMAIN(新) | 審核/補件通知 URL |
⚠️ 現行 TSH/AMS 皆以
getBaseUrl()/getTalentUrl()字串相接產生連結。本次不要求重構,但必須新增獨立的企業側網域來源。前綴的兩種情況要分開看(v1.2.2 釐清,原措辭「禁止把
/recruitment前綴留在後端字串裡」與 §13.4 #5b 的「選配」互相矛盾):
情況 規定 新寫的網址組裝(邀請信 acceptUrl、公司資料管理頁 URL、任何走getBusinessUrl()的地方)硬規則:不得帶 /recruitment前綴。 新網域是 root path,帶了就是死連結既有的 redirect_path常數(notification-link-token.service.ts:43)選配 cleanup(#5b)。 前端 R1 的去前綴邏輯對新舊兩種值皆正確,且 ADR-002 第二條相容轉址會接住;不做的唯一代價是後端字串殘留前綴
8.3 Read/Write 契約
- 無新 API endpoint。所有既有 API 的路徑、payload、錯誤碼一律不變。
- 唯一契約變更:通知 API 回傳的
redirect_path語意由「main-site 可直接使用的絕對路徑」改為「目標 app 內的相對路徑」。此為語意變更(欄位名與型別皆不變,值的意思變了)。
v1.2.0 修正——不需要同版上線。 v1.1.0 此處寫「前後端必須同版上線」,與 §13.0 P3、§10.4 (皆稱 BE 可晚於 FE、相容轉址會接住)互相矛盾。正確的是後者。落地形狀:
| 段 | 誰 | 做什麼 | 完成後 |
|---|---|---|---|
| R1 | FE | §6.2 SEC-DEEPLINK 的兩分支統一去前綴,新舊兩種值都正確 | 解耦完成 |
| R2 | BE | redirect_path 去掉 /recruitment 前綴 | 後端字串不再殘留前綴 |
| R3 | — | 不需要(R1 的邏輯對兩種輸入皆正確,可永久保留) | — |
R1 未上線前不得執行 R2(否則 SEC-DEEPLINK 表中的分支 B 會靜默失敗)。R1 上線後 R2 可隨時做, 甚至不做也不會壞(舊值由 ADR-002 的相容轉址接住),故 R2 屬 cleanup 而非阻塞項。
請求欄位與回應欄位的方向相反:請求欄位因
forbidNonWhitelisted: true必須 BE 先接受; 回應欄位的消費者是 FE,寬容層要放在 FE。
新增回應欄位(v1.2.0,依 §13.3 問題 #9 決議):通知 resolve 回應的 target 物件新增一個
顯式的目標 origin 欄位,讓前端不必從 identity_scope 推導要拼哪個網域。
| 項目 | 內容 |
|---|---|
| 建議欄位名 | target_origin(實際命名由 BE spec 與 FE 對齊後定案) |
| 值域 | main | business | user(對齊 §5.2 AppOriginMap 的鍵) |
| phase | expand(additive)。舊前端不讀此欄位也不會壞 |
| 為什麼需要 | 讓「目標在哪個網域」由後端明說,不靠前端從 identity_scope 推導。 identity_scope 表達的是「收件人以什麼身分行動」,與「目標頁在哪個 app」是兩件事,只是目前實際在用的兩種通知(CONVERSATION/APPLICATION)剛好一致 |
| 不是為了什麼 | ⚠️ 這個欄位不修任何現存 bug。目前的推導都正確;buildRedirectPath() 對 PUBLIC_TARGET_TYPES 回空字串的問題,加了此欄位也不會變好(見 §13.3 問題 #12,屬既有缺陷、另案處理) |
| 取捨 | 屬「現在多做一點讓契約清楚」的等級,不是必要項。加不加都不會壞;已決議加 |
| 相依 | 與 §6.2 SEC-DEEPLINK 的 R1 一起上;FE 優先讀此欄位,讀不到才回退原推導 |
8.4 Auth/合約最小集
| 項目 | 要求 |
|---|---|
| Auth | 沿用現行 JWT Bearer;不新增 token 種類 |
| TTL | JWT 沿用後端現行效期;cookie 7 天(不變) |
| Revoke | 沿用:登出清 cookie+localStorage;401 攔截強制 signOut() |
| 過期行為 | cookie 過期 → 兩站同時失效(因共享);行為與現況一致 |
| Idempotency | 轉址為 GET/冪等;switchRole 沿用現況 |
| 即時權限重查 | 不變(getMePermissions() 進企業後台時抓) |
| 跨站一致性 | cookie 為 SoT;x-company-id-enc 亦需同 domain,否則兩站選定公司可能不同 → 403 |
9. 導航/交付 Map
- Storybook:N/A
- dev 驗收:
https://developers.wport.me+https://developers-business.wport.me(內網,192.168.50.143) - Production:
https://wport.me+https://business.wport.me - 驗收方式:dev 全流程手測(§10.4 全表)+ main-site Playwright smoke + recruitment 既有 unit/Cypress
- ⚠️ dev 驗不到的一項:邊緣轉址規則。prod 走 Cloudflare、dev 走內網 nginx,是兩套設定。轉址仍為 prod 首次上路,靠 ADR-005 的「先 302、關規則即秒回滾」管理風險(見 §13.3 問題 #7)
10. Flowcharts
10.1 User Flow — 企業使用者主動線
flowchart TD
A["使用者開啟 wport.me"] --> B{已登入?}
B -- 否 --> C["wport.me/auth 登入"]
C --> D["寫入共享 cookie<br/>Domain=.wport.me"]
B -- 是 --> D
D --> E{有企業身分?}
E -- 否 --> F[留在 wport.me 求職者動線]
E -- 是 --> G[選定公司 setSelectedCompany]
G --> H[switchRole 取得新 JWT<br/>寫入共享 cookie]
H --> I["hard navigate 至<br/>business.wport.me/"]
I --> J[business 讀 cookie 水合 session]
J --> K{cookie 有效?}
K -- 是 --> L["進入 /jobs 企業後台"]
K -- 否 --> M["導回 wport.me/auth<br/>帶 redirect + app=business"]
M --> C
N["開啟舊書籤<br/>wport.me/recruitment/jobs"] --> O["邊緣 302/301"]
O --> P["business.wport.me/jobs"]
P --> J
10.2 System Flow — Session 跨站橋接
sequenceDiagram
participant U as 使用者瀏覽器
participant M as wport.me (Nuxt main-site)
participant E as 邊緣 (Cloudflare/nginx)
participant B as business.wport.me (recruitment SPA)
participant API as TSH / AMS
U->>M: 登入
M->>API: POST 登入
API-->>M: JWT
M->>U: Set-Cookie token(Domain=.wport.me, Secure, SameSite=Lax)
Note over M,U: 同時寫 localStorage(僅同源快取)
U->>M: 選定公司
M->>API: POST /switchRole (帶 x-company-id-enc)
API-->>M: 新 JWT (currentUserType=2)
M->>U: 更新共享 cookie(token + x-company-id-enc)
M-->>U: location.assign(business origin)
U->>E: GET business.wport.me/ (自動帶 .wport.me cookie)
E-->>U: SPA index.html(no-store)
U->>B: 啟動
B->>B: getToken(): cookie 優先 → 回填 localStorage
B->>API: GET /v2/api/... (同源,帶 Bearer)
API-->>B: 200
Note over U,B: 登出時:以相同 Domain 刪除 cookie<br/>兩站下次請求皆 401 → signOut()
10.3 Navigation — 網域職責
flowchart LR
subgraph Public["wport.me(公開・可索引)"]
Home["首頁/職缺/公司頁"]
Auth["/auth 登入(唯一入口)"]
UserApp["/user/* 求職者後台"]
end
subgraph Business["business.wport.me(企業・noindex)"]
Jobs["/jobs 職缺管理"]
Msg["/messages"]
Acc["/account-center"]
end
subgraph Admin["admin.wport.me(內部)"]
AdminApp[Site Admin]
end
Auth -->|"登入後・hard nav"| Jobs
Jobs -->|"複製公開網址 pm_51(常數)"| Home
Jobs -->|"切回求職者模式"| UserApp
Public -.->|"301 /recruitment/*"| Business
AdminApp -->|"開公司公開頁 pm_50"| Home
10.4 Edge Case Coverage
| 類別 | 情境 | 系統行為 | UI 回饋 | 可重試 | 資料一致性 | 備註 |
|---|---|---|---|---|---|---|
| Network & Performance | 跨站跳轉時網路中斷 | 停在原站 | 既有 loading 需有逾時解除 | Yes | 未寫入 | 不可讓遮罩永久卡住 |
| Network & Performance | 邊緣轉址增加一次 RTT | 單跳(不得產生鏈式轉址) | 無感 | — | — | /recruitment/x → business /x 必須一次到位 |
| Network & Performance | index.html 被 CDN 快取 | 舊版 SPA 載到新版 API | 白畫面/404 chunk | Yes | — | index.html 一律 no-store |
| Network & Performance | WebSocket 升級失敗 | 聊天無法連線 | 既有重連提示 | Yes | — | 邊緣需帶 Upgrade/Connection |
| Data & Input | 舊網址帶 query/中文編碼 | 轉址保留原 query | 無感 | — | — | fragment 由瀏覽器保留 |
| Data & Input | redirect 參數帶絕對 URL | 一律視為非法 → 回 / | 無提示 | — | — | 現況 open-redirect 防護不放寬 |
| Data & Input | app 參數非列舉值 | 視為 main | 無提示 | — | — | ADR-007 |
| Data & Input | 漏改的 business.wport.me/recruitment/x | 相容轉址 → /x | 無感 | — | — | 保底規則 |
| User Interruption | 跳轉途中關閉分頁 | 無副作用 | — | Yes | cookie 已寫入,下次進站即生效 | |
| User Interruption | 兩站分頁同時開著 | 兩份 localStorage 各自快取 | — | — | cookie 為 SoT | 見下列 Auth 情境 |
| Auth & Lifecycle | A 站登出、B 站分頁仍開著 | 共享 cookie 已刪 → B 站下次 API 401 → signOut();visibilitychange 時亦會重讀 cookie 提前發現 | 導回登入 | Yes | 不會出現「看起來還登入、實際 401」的長期狀態 | ADR-009;不追求毫秒級即時廣播 |
| Auth & Lifecycle | A 站切公司、B 站分頁仍開著 | B 站重讀 x-company-id-enc → 不一致則 reload | 頁面重載 | Yes | 以 cookie 為準 | 不重載會 403 |
| Auth & Lifecycle | 升級當下既有使用者持有 host-only 舊 cookie | 進站時先刪 host-only 同名 cookie,再以 .wport.me 重寫 | 無感 | — | 避免同名雙 cookie 讀取順序不確定 | 必做,見 §13.0 Migration |
| Auth & Lifecycle | localStorage 有 token、cookie 已失效 | 以 cookie 為準 → 判定未登入 | 導登入 | Yes | 反轉現行讀取順序 | 否則 B 站會用過期快取 |
| Auth & Lifecycle | Safari ITP 限制 JS 寫入 cookie 為 7 天 | 現行 TTL 即 7 天,無退化 | — | — | — | 不需額外處理 |
| Auth & Lifecycle | 第三方 cookie 封鎖 | 不受影響 | — | — | — | wport.me 與 business.wport.me 屬同 site,為 first-party;勿誤設 SameSite=None |
| Logical Inconsistency | 直接讀 localStorage.getItem('token') 的程式碼 | 新站首次進站必判未登入 | 誤導回登入 | — | — | 6 處必改(§13 sync-fix #2) |
| Logical Inconsistency | getUrl() 用 origin 拼接 | business 站產生 business.wport.me/media-section/ 404 | 死連結 | — | — | SEC-NAV 必改 |
| Logical Inconsistency | pm_51 用 origin 拼公開網址 | 複製出 business.wport.me/job/x 死連結 | 使用者分享後才發現 | — | — | 必須用常數 |
| Logical Inconsistency | 後台內以相對路徑指向公開頁(router.push('/job/{enc_id}')、openCompany('/companies/{enc_id}')) | root path 後解析成 business.wport.me/job/x、business.wport.me/companies/x —— 看起來像公開頁,實際是 business 站不存在的路由 | 404/空白 | — | — | 切換前有 /recruitment 前綴時不會長得像公開頁,切換後才會混淆;一律改用 main origin 解析 |
| Logical Inconsistency | dev/staging 與 prod 共用 .wport.me cookie | dev 會收到 prod token(反之亦然) | 難以察覺的狀態污染 | — | — | ADR-008 cookie 名稱環境前綴 |
| Logical Inconsistency | 後端仍回舊 redirect_path 而前端已升級 | deep-link 導到 business.wport.me/recruitment/messages | 相容轉址接住 | — | — | 因此相容轉址是必要不是保險 |
| Auth & Lifecycle | 企業客戶防火牆未放行新網域 | 後台完全打不開 | 企業端回報「壞掉」 | — | — | 上線前 2 週主動通知既有付費客戶(§13 sync-fix #10) |
11. Mock Data Schema
- 無 Storybook:不需要 mock schema。
- 測試資料要求:dev 需至少一組「同時具備求職者+企業身分、且隸屬 2 間以上公司」的帳號,用於驗證切換公司的跨站一致性。
12. ADR(架構決策紀錄)
ADR-001: 企業後台使用獨立子網域 business.wport.me
- Status: Accepted
- Context: 企業後台目前掛在公開站的
/recruitment路徑下,與 SEO 資產、快取策略、防護規則混在同一個 URL 空間;企業客戶也難以口頭傳達入口。可選:(a) 維持路徑;(b) 獨立子網域;(c) 獨立 apex(如wport-business.com)。 - Decision: 採 (b) 獨立子網域
business.wport.me。 - Consequences:
- ✅ 與
admin.wport.me形成一致命名體系;Cloudflare Origin 憑證 SAN 已含*.wport.me,不需新憑證 - ✅ 與
wport.me屬同 site,cookie 可共享 → SSO 成本最低;SameSite=Lax即可運作 - ⚠️ 同 site 也代表沒有真正的信任邊界:
*.wport.me任一子網域的 XSS 都可讀到 session cookie(見 ADR-012)
- ✅ 與
- Alternative rejected: (c) 獨立 apex —— 會變成跨 site,cookie 無法共享,必須自建 SSO 交接(一次性 ticket)+
SameSite=None,成本高出一個量級,且對 7 人團隊不成比例。(a) 維持路徑 —— 無法達成部署/防護解耦,且不符合 §3.3 業界慣例。 - Decision maker: Eric | Date: 2026-08-18
ADR-002: 新網域採 root path,/recruitment 前綴只保留為相容轉址
- Status: Accepted
- Context: 兩種做法:(a)
business.wport.me/recruitment/*(只換 host,改動最小);(b)business.wport.me/*(同時去掉前綴)。實查後發現 recruitment 的路由表本身已是 root-relative(NavLink全部是/dashboard、/jobs…),/recruitment只由 Vitebase注入;app 內硬寫/recruitment的地方僅 9 處(多為 public 圖檔路徑)。 - Decision: 採 (b)。同時建立兩條相容轉址:
wport.me/recruitment/*→business.wport.me/*,以及business.wport.me/recruitment/*→business.wport.me/*。 - Consequences:
- ✅
business.wport.me/jobs語意乾淨,符合業界慣例 - ✅ 第二條相容轉址讓「後端還沒改完 env」也不會壞 → 前後端可分批上線,不必同一秒切換
- ⚠️ 需同步處理 public 靜態資產路徑(
/recruitment/icon/...)與 Vitebase
- ✅
- Alternative rejected: (a) ——
business.wport.me/recruitment/jobs語意重複,且等於把技術債搬家;既然路由表本來就 root-relative,這次不做以後成本只會更高。 - Decision maker: Eric | Date: 2026-08-18
ADR-003: 登入入口集中於 wport.me/auth,business 站不複製登入頁
- Status: Accepted
- Context: 登入/註冊/第三方登入(pm_39,含 Apple Service ID 的
redirect_uri = https://wport.me)全部實作在 main-site(Nuxt)。若 business 站要自帶登入,等於在 Vite SPA 複製一套 auth,並新增 OAuth redirect URI 與 Apple Service ID 設定。 - Decision: business 站不做登入頁。未登入 → 導
wport.me/auth?redirect=…&app=business,登入後跳回。 - Consequences:
- ✅ 第三方登入設定(Google/Apple)完全不需異動
- ✅ 只有一份 auth 實作要維護;BR-001 等規則不會分岔
- ⚠️ 企業使用者「輸入 business 網址 → 被彈回 wport.me 登入 → 再跳回」會有一次跨站往返,需靠 loading 與文案讓過程可理解
- Alternative rejected: business 自建登入頁 —— 為了省一次跳轉,換來兩套 auth、兩組 OAuth 設定與長期分岔風險。
- Decision maker: Eric | Date: 2026-08-18
ADR-004: Session 以 Domain=.wport.me 共享 cookie;cookie 升為 SoT,localStorage 降為快取
- Status: Accepted
- Context: 實查現況:
browserSessionStorage與nuxtSessionStorage都同時寫 localStorage 與 cookie,但 cookie 未設Domain(host-only),且讀取順序是 localStorage 優先、cookie fallback。localStorage 是 per-origin,business.wport.me會是全新的空間 → 不處理則跨站必定要求重新登入。 - Decision:
- token 與
x-company-id-enc的 cookie 一律加Domain=.wport.me; Path=/; Secure; SameSite=Lax - 讀取順序反轉為 cookie 優先,localStorage 僅作同源快取
- 刪除 cookie 時必須帶相同的
Domain,否則刪不掉 → 另一站殘留殭屍 session - 升級時一次性清掉舊的 host-only 同名 cookie(見 §13.0)
- token 與
- Consequences:
- ✅ 跨站 SSO 免後端改動、免新 API
- ⚠️ 同名 cookie 若 host-only 與 domain 版本並存,
document.cookie讀取順序不確定 → 必須一次性清理 - ⚠️ cookie 會被送往所有
*.wport.me(含api/dev-api/staging/developers/admin)→ 見 ADR-008、ADR-012
- Alternative rejected: 一次性 handoff ticket 交接(各站 host-only cookie)—— 安全性較佳但需後端新增簽發/兌換 API、跨站登出要另做,成本不成比例;列為 v2 與 ADR-012 綁定評估。
- Decision maker: Eric | Date: 2026-08-18
ADR-005: 舊網址先 302 觀察 14 天,再轉 301
- Status: Accepted
- Context: 301 會被瀏覽器永久快取。若上線後發現重大問題想回滾,已經吃過 301 的使用者瀏覽器仍會自行跳到新網域,等同無法回滾。
- Decision: 切換首 14 天使用 302(暫時轉址);穩定後改 301(永久轉址)並長期保留。
- Consequences:
- ✅ 觀察期內關掉邊緣規則即可秒級回滾(達成 S4)
- ⚠️ 302 期間 SEO 不傳遞權重 —— 本案不影響,因
/recruitment本就Disallow+非索引資產 - ⚠️ 需要一張明確的「轉 301」工單,否則會一直停在 302
- Decision maker: Eric | Date: 2026-08-18
ADR-006: API 與 WebSocket 走同源反向代理,不採 CORS 跨源
- Status: Accepted
- Context: recruitment 的 axios
baseURL實際為 undefined(VITE_API_BASE_URL未設定),所有請求都是相對路徑,且withCredentials: true。若讓 business 站跨源打wport.meAPI,需要:改所有請求的 base、TSHCORS_ORIGIN/WS_CORS_ORIGIN加白、每個非簡單請求多一次 preflight、且credentials: true下不得使用萬用字元來源。 - Decision: 在
business.wport.me下複製現行的後端分流(/api、/v2/api、/ams-api、/chat、/socket.io),維持同源。 - Consequences:
- ✅ 走 axios 的 API 層零改動;無 preflight 額外延遲;無 CORS 設定漂移風險
- ⚠️ 例外:聊天室不走 axios(v1.2.0 補)。它讀
VITE_CHAT_API_ORIGIN連完整網址, 所以「同源」不是自動達成的,而是要靠新網域的 build 把該值設成https://business.wport.me。 漏設會 fallback 到硬編的舊網域 → 聊天室連不上。詳見 §8.1 - ✅ 設定正確時 WebSocket 同源,
WS_CORS_ORIGIN追加僅為保險(已實查佐證:staging.wport.me不在 prod 清單內卻正常運作) - ⚠️ 邊緣設定要維護兩份(
wport.me與business.wport.me)→ 要求以同一份 template 產生,避免漂移 - 🛟 仍建議把
https://business.wport.me加進CORS_ORIGIN/WS_CORS_ORIGIN作為保險,成本近乎零
- Alternative rejected: 跨源 + CORS —— 改動面更大、延遲更差、且把安全設定分散到環境變數裡。
- Decision maker: Eric | Date: 2026-08-18
ADR-007: 跨站跳轉一律「相對路徑 + app 代號」,query 內禁止絕對 URL
- Status: Accepted
- Context:
apps/main-site/app/utils/auth-redirect.ts目前明確拒絕絕對 URL 與 protocol-relative//,這是既有的 open-redirect 防護。跨站之後,redirect 目標確實需要落在另一個 origin,最直覺的做法是「允許絕對 URL 但比對白名單」。 - Decision: 不放寬
redirect參數的限制(仍只接受/開頭相對路徑)。跨站目標以獨立的app列舉參數表達(main|business|user),由前端在最後一刻用 AppOriginMap 解析成 origin;非列舉值一律 fallback 到main。通知 deep-link 沿用既有的scope參數,語意相同。 - Consequences:
- ✅ open-redirect 防護維持「預設拒絕」,不需維護 URL 白名單比對邏輯(正規化、大小寫、port、尾綴等常見繞過點全部不存在)
- ✅ 新增網域時只要擴充 AppOriginMap,不必改防護邏輯
- ⚠️ 呼叫端需同時傳 path 與 app,既有呼叫點要逐一檢視
- Alternative rejected: 允許絕對 URL + origin 白名單 —— 白名單比對是 open-redirect 最常見的破口來源,收益只有「少傳一個參數」。
- Decision maker: Eric | Date: 2026-08-18
ADR-008: Session cookie 名稱加環境前綴,隔離 prod/非 prod
- Status: Accepted
- Context: cookie 一旦設為
Domain=.wport.me,就會同時送到staging.wport.me、developers.wport.me、developers-business.wport.me、dev-api.wport.me、api.wport.me、admin.wport.me。實查 DNS:staging.wport.me、admin.wport.me(CloudFront)與api/dev-api.wport.me(Cloudflare)皆公開可達;developers.wport.me為內網。prod token 被送進非 prod 環境(或反之)會造成難以察覺的狀態污染——該站讀到另一個環境的 session,兩邊資料庫與使用者 id 不同,症狀難以追查。 本案讓這件事變得更相關:v1.2.0 新增的developers-business.wport.me同樣在.wport.me底下,dev 與 prod 會互相污染。 - Decision: cookie 名稱由環境變數提供前綴(例如 prod
token/devdev_token/stagingstg_token),三個前端 app 與 main-site SSR 一致。prod 維持現名以免既有 session 全數失效。 - Consequences:
- ✅ 環境間 session 不再互相污染
- ⚠️ 讀寫 cookie 的地方必須全部走
SessionStorage介面(現況已有此規範,但有 6 處違規直讀 —— 正好一併修) - ⚠️ dev/staging 使用者需重新登入一次(可接受)
v1.2.0 補述(依 §13.3 問題 #11 決議):
- 殘留風險:prod 維持原名
token,所以忘了替新環境設前綴時,該環境會靜默讀到 prod 的 session—— 失敗方向是最差的那邊(不會報錯,只會某天有人發現「dev 站怎麼讀到正式站資料」)。 - 對策(已決議):把「新增任何
*.wport.me環境時必須設定 cookie 名稱前綴」列為部署檢查項, 不靠人記得。未採「每個環境都加前綴(含 prod)」的 fail-closed 版本,因為代價是正式站使用者全部登出一次。 - 這是治標,不是治本。根本原因是「所有環境共用
wport.me這個註冊網域」,治本有二: ①非 prod 改用另一個註冊網域;②不共用 cookie,改一次性交接票(ADR-004 已評估為「成本高一個量級」)。 兩者皆列 v2,與 ADR-012 一起評估。 - ⚠️ 釐清一個常見誤解:ADR-012 的 v2 目標(HttpOnly + refresh token)解不了本問題。
HttpOnly 擋的是「XSS 讀得到 token」,不是「cookie 送到別的環境」。加了 HttpOnly,
Domain=.wport.me的 cookie 照樣送到所有*.wport.me。兩件事要分開處理。 - Decision maker: Eric | Date: 2026-08-18
ADR-009: 跨站狀態同步採「cookie 為準 + 可見性重讀 + 401 攔截」,不做即時廣播
- Status: Accepted
- Context: 現行跨分頁同步靠
storageevent(AUTH-SESSION.md§7.7),但storageevent 與BroadcastChannel都是 same-origin,切站後wport.me與business.wport.me之間完全收不到彼此的廣播。 - Decision: 不追求即時。採三層:(1) cookie 為 SoT;(2)
visibilitychange/focus時重讀 cookie,與記憶體狀態不一致則重新水合或登出/reload;(3) 既有 401 攔截 →signOut()。同 origin 內的storageevent 行為維持不變。 - Consequences:
- ✅ 零後端成本,覆蓋「另一站登出/切公司」的實際使用情境(使用者切回分頁時必然觸發
visibilitychange) - ⚠️ 使用者若把兩站分頁並排放著不切換,最長會延遲到下一次 API 呼叫才發現失效 —— 明確接受
- ✅ 零後端成本,覆蓋「另一站登出/切公司」的實際使用情境(使用者切回分頁時必然觸發
- Alternative rejected: 後端 session 版本號輪詢 —— 為低頻情境增加持續性請求量,不划算。
- Decision maker: Eric | Date: 2026-08-18
ADR-010: 不做 Storybook
- Status: Accepted
- Context: 本次無新畫面,改動集中在路由、設定、連結產生方式。
- Decision: 不產 Storybook;以本 PRD + dev 驗收為準。
- Consequences: §1 Storybook 欄位標 N/A;驗收改以 §10.4 edge case 清單逐條手測。
- Decision maker: Eric | Date: 2026-08-18
ADR-011: 求職者後台 /user 本次不搬
- Status: Accepted
- Context:
apps/user同樣是掛在wport.me/user/*的 SPA,理論上可一併切到my.wport.me之類。但求職者端與公開站的動線高度交織(看職缺 → 投遞 → 我的應徵),拆開會增加跨站跳轉次數,反而傷 C 端轉換率。 - Decision: 本次只切企業後台。
/user維持現狀。 - Consequences:
- ✅ 範圍可控,C 端零風險
- ✅ 本次建立的 AppOriginMap/cookie 契約已預留
user欄位,未來要切成本很低 - ⚠️ 短期內網域體系不對稱(企業有專屬網域、求職者沒有)—— 可接受,因為兩者的商業角色本就不同
- Decision maker: Eric | Date: 2026-08-18
ADR-012: 接受 .wport.me cookie 共享的爆炸半徑,並立下子網域守則;HttpOnly + refresh token 列 v2
- Status: Accepted(附條件)
- Context: session cookie 目前不是 HttpOnly(前端需讀取),改為
Domain=.wport.me後,*.wport.me上任一應用的 XSS 都能讀到 token —— 這是真實的安全退化,不能因為「同 site 所以沒差」而略過。實查 DNS 現況:n8n.wport.me、developers.wport.me、storybook.wport.me、dev-admin.wport.me解析到內網 IP(192.168.50.143,非公開可達);公開可達的是www/media/api/dev-api/admin/staging。 - Decision:
- v1 接受共享 cookie,因為目前公開可達的子網域全部是自家可控資產
- 立守則(併入 BR-042):任何第三方或低信任應用(含 n8n、外掛、實驗性服務)不得掛在
*.wport.me的公開可達位置;若必須,須先移到獨立 apex 或加 Cloudflare Access dev/staging以 cookie 名稱前綴隔離(ADR-008)- v2 目標:token 改 HttpOnly + 短效 access token + refresh 輪替,屆時 ADR-004 的共享 cookie 可退場
- Consequences:
- ⚠️ 明確記錄「這是用安全換交付速度」,不假裝沒有代價
- ✅ 守則讓風險可管理而非放任
- 📌 v2 若採一次性 handoff ticket,可同時解掉 ADR-004 的共享 cookie 與本 ADR 的爆炸半徑
- Decision maker: Eric | Date: 2026-08-18
13. 實作備註
13.0 Migration / 相容 / Rollback(Rule 23 必填)
既有使用者的 session 遷移
| 步驟 | 動作 | 為什麼 |
|---|---|---|
| M1 | 進站時若偵測到 host-only 的同名 cookie,先以無 Domain 的方式刪除,再以 Domain=.wport.me 重寫 | 同名雙 cookie 時 document.cookie 讀取順序不確定,會造成「時好時壞」的登入狀態 |
| M2 | 讀取順序改為 cookie → localStorage | 讓兩站以同一份事實為準 |
| M3 | 不強制登出既有使用者 | prod cookie 名稱不變(ADR-008 只對非 prod 生效),M1 完成即可無縫接續 |
| M4 | dev/staging 使用者需重新登入一次 | cookie 名稱加前綴 |
M1 為冪等操作,重複執行無副作用;三個 app 都要做(誰先被開啟誰先遷移)。
分階段上線
| Phase | 內容 | 對外可見? | 可獨立回滾? |
|---|---|---|---|
| P0 | cookie 契約升級(M1–M2)、getUrl() 改 AppOriginMap(預設值=現行同源,行為不變)、6 處直讀 localStorage 改走 getToken()、app 參數解析邏輯 | ❌ 完全無感 | ✅ 純前端,可單獨回滾 |
| P1 | business.wport.me DNS + 邊緣路由上線,與 wport.me/recruitment 雙活 | ⚠️ 僅知道網址的人 | ✅ 撤 DNS 即可 |
| P2 | 前端切換所有企業後台入口至 business origin;wport.me/recruitment/* 302 | ✅ 正式切換 | ✅ 關掉轉址規則 |
| P3 | TSH/AMS 郵件與 redirect_path 改讀 BUSINESS_DOMAIN | ✅ 新寄出的信 | ✅ 改回 env(舊值仍被相容轉址接住) |
| P4 | 觀察 14 天後 302 → 301 | ✅ | ⚠️ 301 後回滾困難(故需 P2–P3 全綠才執行) |
關鍵:P3 之所以可以晚於 P2,是因為 ADR-002 的第二條相容轉址(
business.wport.me/recruitment/*→/*)會接住後端還沒改的舊路徑。這是刻意設計的安全網,不是冗餘。
Rollback 決策點
- P2 出問題 → 關閉邊緣轉址規則 + 前端 origin 設定改回 main(一次部署)
- P3 出問題 → 後端 env 改回(郵件連結退回舊網址,仍可用)
- P4 之後 → 已吃 301 的瀏覽器無法回滾,故 P4 需獨立核准
13.1 禁止事項
- ❌ 用
window.location.origin產生任何跨 app 的連結 - ❌ 在
redirectquery 放絕對 URL(ADR-007) - ❌ 為了跨站把 cookie 設成
SameSite=None—— 兩者屬同 site,不需要,且是安全退化 - ❌ 直讀
localStorage.getItem('token')(一律走SessionStorage介面) - ❌ 讓
business.wport.me的index.html進 CDN 快取 - ❌ 在新寫的後端網址組裝裡帶
/recruitment前綴(新網域是 root path,帶了即死連結)- ⚠️ 這條不涵蓋既有的
redirect_path常數——那一處是選配 cleanup(§13.4 #5b),不是禁止事項。兩者的差別見 §8.2 的對照表
- ⚠️ 這條不涵蓋既有的
- ❌ 在
business.wport.me放任何需要被索引的內容 - ❌ 直接上 301(必須先 302 觀察)
13.2 一致性表(Stage 5/6)
Field mapping
| 欄位/概念 | PRD | W101-Web | TSH/AMS | SoT | Version |
|---|---|---|---|---|---|
| business origin | ✅ | 待新增(build env) | 待新增 BUSINESS_DOMAIN | PRD §5.3 | v1 |
| main origin | ✅ | 待新增(取代 location.origin) | 既有 FRONTEND_DOMAIN | PRD §5.3 | v1 |
cookie token | ✅ 加 Domain/Secure/SameSite | auth-session/nuxt-session-storage | 僅讀 Bearer,不受影響 | PRD §5.2 | v1 |
cookie x-company-id-enc | ✅ 同上 | 同上 | header 語意不變 | PRD §5.2 | v1 |
| SPA base path | / | vite.config.ts base | — | PRD §8.1 | v1 |
redirect_path(通知) | 目標 app 內相對路徑 | main-site 解析 | TSH 產生 | PRD §8.3 | v1 |
scope / app 參數 | 1=求職者/2=企業;app 為列舉 | auth-redirect.ts | TSH identity_scope | PRD §6.2 | v1 |
Constants
| 常數 | 值 | SoT | Version |
|---|---|---|---|
| 企業後台 origin(prod) | https://business.wport.me | PRD | v1 |
| 企業後台 origin(dev) | https://developers-business.wport.me | PRD | v1 |
| 公開站 origin | https://wport.me | PRD | v1 |
| cookie domain | .wport.me | PRD | v1 |
| cookie TTL | 7 天 | 既有 | v1 |
| 轉址碼(觀察期) | 302 | PRD/ADR-005 | v1 |
| 轉址碼(穩定後) | 301 | PRD/ADR-005 | v1 |
| 觀察期 | 14 天 | PRD | v1 |
| 轉址保留期 | ≥ 24 個月 | PRD | v1 |
Event dictionary
| 事件 | 觸發 | Payload 重點 | SoT |
|---|---|---|---|
auth.cross_site_navigate | 由 wport.me 跳往 business(或反向) | from_app, to_app, has_token | PRD v1(建議實作,用於量測 S6) |
auth.session_bridge_failed | business 站讀不到有效 session 而被導回登入 | reason(no_cookie/expired/mismatch) | PRD v1(建議必做,是 S6 的唯一可觀測訊號) |
legacy_url.redirected | 邊緣轉址命中 | path(可由邊緣 log 取得,不必前端埋) | 邊緣 log v1 |
GA4 說明:
wport.me與business.wport.me同屬一個註冊網域,GA4 預設 cookie 網域為最高層級註冊網域,工作階段不會斷,不需要設定跨網域評估;但仍建議把business.wport.me加入「不需要的參照連結網址」清單以防自我參照。此段請行銷/RD 於 GA4 後台實查確認後再定案。
Terminology
| 中文 | 英文 | 定義 |
|---|---|---|
| 企業後台 | Business console | apps/recruitment,切換後位於 business.wport.me |
| 求職者後台 | User console | apps/user,維持 wport.me/user/* |
| 公開站 | Main site | apps/main-site,wport.me |
| 內部後台 | Site admin | W101-Admin-Web,admin.wport.me |
| Session 橋接 | Session bridge | 以共享 cookie 讓兩個 origin 使用同一份登入狀態 |
| 相容轉址 | Compat redirect | business.wport.me/recruitment/* → /* 的保底規則 |
| 同 site / 同 origin | same-site / same-origin | 同 site 依註冊網域判定(cookie 用);同 origin 需 scheme+host+port 全同(CORS 用)。本案是同 site、跨 origin |
Version freeze
| 項目 | v1/v2 | Blocking? | 理由 | Decision maker | Date |
|---|---|---|---|---|---|
business.wport.me 上線(root path) | v1 | ✅ | 核心 | Eric | 2026-08-18 |
| 同源反向代理(API/WS) | v1 | ✅ | 不做則前端 API 層要大改 | Eric | 2026-08-18 |
| 共享 cookie + cookie 為 SoT | v1 | ✅ | 不做則跨站要重新登入 | Eric | 2026-08-18 |
| 6 處直讀 localStorage 修正 | v1 | ✅ | 不做則新站首次進站必判未登入 | Eric | 2026-08-18 |
getUrl() → AppOriginMap | v1 | ✅ | 不做則 business 站導覽全死連結 | Eric | 2026-08-18 |
| 舊網址轉址(302) | v1 | ✅ | 書籤與已寄出郵件 | Eric | 2026-08-18 |
後端 BUSINESS_DOMAIN | v1 | ✅ | 新寄出的信要對 | Eric | 2026-08-18 |
| cookie 環境前綴(dev/staging) | v1 | ✅ | 防 prod/非 prod 狀態污染 | Eric | 2026-08-18 |
| 302 → 301 | v1(P4) | ⚠️ 條件 | 需 P2/P3 全綠並獨立核准 | Eric | 2026-08-18 |
auth.session_bridge_failed 事件 | v1 | ❌ | 強烈建議,非硬性 | Eric | 2026-08-18 |
| Cloudflare Access/IP 允許清單 | v2 | ❌ | 有客戶要求再做 | Eric | 2026-08-18 |
| HttpOnly + refresh token 輪替 | v2 | ❌ | 解 ADR-012 的根因 | Eric | 2026-08-18 |
/user 搬遷 | v2+ | ❌ | ADR-011 | Eric | 2026-08-18 |
| Storybook | — | ❌ | ADR-010 明確不做 | Eric | 2026-08-18 |
13.3 PRD 問題清單
| # | 段落 | 問題 | 建議 | 類別 | 影響 | SoT |
|---|---|---|---|---|---|---|
| 1 | §6.2 SEC-EDGE | prod 邊緣設定不在 repo 內(repo 內只有 dev/staging vhost 與容器內 nginx);wport.me 走 Cloudflare、admin/staging 走 CloudFront,實際 /api//v2/api 分流落在哪一層無法從 repo 確認 | RD/DevOps 先產出一份現行 prod 邊緣路由圖,再據此複製 business vhost;建議把設定納入版控 | 技術前提 | 高——決定 P1 能否如期 | RD |
| 2 | §5.3 | dev 企業後台命名 | 必須是單層子網域(developers-business.wport.me);business.developers.wport.me 會超出 *.wport.me 憑證覆蓋範圍 | 命名 | 中——錯了會憑證失效 | PRD |
| 3 | §13.2 Event | GA4 是否需要設定「不需要的參照連結網址」與是否沿用同一容器(recruitment 目前為 GTM-MGLNLCG5) | 行銷/RD 於 GA4 後台實查後回填 | 量測 | 中——影響 S5 判讀 | 行銷 |
| 4 | §5.4 | pm_40 company_pay 若上線,金流的 return/callback URL 需直接註冊新網域 | 目前後端查無金流 return URL 實作,屬未來相依;請在 pm_40 開工時對照本表 | 相依 | 中 | RD |
| 5 | §3.4 | 企業客戶內網/防火牆白名單 | 上線前 2 週由業務/客服主動通知既有付費企業客戶新增網域 | 營運 | 高——大型客戶可能整間打不開 | 業務 |
| 6 | §4.2 | BR-042 尚未寫入 business-rules.md。⚠️ 且與 pm_46(獵頭模組)撞號——該 PRD §8.2 也把 BR-042 提案為「夥伴綁定」(另外它的 BR-039/040/041 更是直接覆蓋 pm_48/pm_41/pm_38 已配發的規則)。business-rules.md v1.10.0 目前配到 BR-041,兩份 PRD 都寫「通過後回寫」,誰先寫誰占用 | 本 PRD 通過後回寫,版本 → 1.11.0。回寫前必跑 python3 scripts/prd-lint.py --all 確認 C10 無搶號告警;回寫當下要通知 pm_46 改號(或依 pm_46 §8.2 的替代方案由本 PRD 讓號) | 文件 | 中(撞號會靜默覆蓋) | PM |
| 7 | §9、ADR-005 | 邊緣轉址規則在 dev 驗不到:prod 走 Cloudflare、dev 走內網 nginx,是兩套設定。wport.me/recruitment/* → business.wport.me/* 仍為 prod 首次上路 | ✅ 已決議(2026-08-19):接受。靠 ADR-005 的「先 302、關規則即秒回滾」管理;上線後盯邊緣 log 的 /recruitment/* 命中數與 404 | 技術限制 | 中 | DevOps |
| 8 | §5.3、§9 | staging 本次不切企業網域(PM 決議,v1.2.0)。staging(CloudFront)與 prod(Cloudflare)本就是不同邊緣堆疊,建 staging-business 也無法忠實演練 prod 的 Cloudflare 設定 → 不建的損失有限 | ✅ 已確認(2026-08-19):理由存證無誤,決議不做。若日後 staging 要切,須先確認它與 prod 用同一套邊緣設定模板 | 範圍 | — | 已結案 |
| 9 | §6.2 SEC-DEEPLINK、§8.3 | 前端從 identity_scope 推導「目標在哪個網域」。此推導在目前兩種實際使用的通知類型上都正確,但兩者語意本就不同(一個是「收件人身分」、一個是「目標頁位置」),新增通知類型時容易踩到 | ✅ 已決議(2026-08-19):新增顯式的目標 origin 欄位(additive,舊前端不讀也不會壞)。契約見 §8.3。⚠️ 理由是「讓契約顯式」,不是修 bug —— 此欄位不修任何現存缺陷 | 契約 | 低(預防性) | RD |
| 10 | §13.4 #4 | auth-initializer.client.ts 的企業身分強制導向曾造成三次同款故障(/company-register、/notification-links、公開職缺頁) | ✅ 已結案(2026-08-19):前端已完成,止血與根治都做了。判定邏輯抽成 apps/main-site/app/utils/companyRedirect.ts(COMPANY_REDIRECT_SKIP_PATHS 新增 /job/、/companies/;/job/ 刻意帶尾斜線以排除 /jobs 求職者列表),求職者專屬動作由頁面各自擋(components/job/JobDetailView.vue:119 的 isCompany 分支)。見 origin/develop | 範圍 | — | 已完成 |
| 11 | ADR-008 | 前綴只加在非 prod、prod 維持 token → 忘了替新環境設前綴時,該環境會靜默讀到 prod session(失敗方向最差) | ✅ 已決議(2026-08-19):採①——維持「前綴只給非 prod」,並把「新增任何 *.wport.me 環境時必須設定 cookie 名稱前綴」寫成部署檢查項(見 ADR-008 Consequences 補述),不靠人記得。未採②(每個環境都加前綴)是因為代價是正式站使用者全部登出一次。根治列 v2,見 ADR-008 補述 | 安全/維運 | — | 已結案 |
| 12 | notification-link-token.service.ts:377-384 | 既有缺陷(非本案造成):buildRedirectPath() 對 PUBLIC_TARGET_TYPES(目前只有 COMPANY=公開公司頁)回空字串,而前端全鏈路無任何空值檢查——空字串照樣被 encodeURIComponent 塞進 to=、不觸發「連結無效」頁、下游 deep-link 直接 route.query.to as string 使用。實際結果:使用者點通知信後停在空白的承接頁。目前後端未發此類 target 故未曝光 | 另案處理,不納入 pm_52(與網域切割無關,切不切都存在)。新增 target_origin 欄位不會修好它——空字串依然是空字串 | 既有缺陷 | 中(休眠) | RD |
13.4 Sync-fix list
| # | 動作 | Repo/位置 | 負責人 | Blocking? |
|---|---|---|---|---|
| 1 | cookie 契約升級(Domain/Secure/SameSite/刪除時帶 Domain/讀取順序反轉/M1 一次性清理) | packages/auth-session/src/browser-session-storage.ts、apps/main-site/app/utils/nuxt-session-storage.ts | FE | ✅ |
| 2 | 6 處直讀 localStorage.getItem('token') 改走 getToken() | apps/recruitment/src/router/guard/loginGuard.ts、apps/user/src/router/guards/loginGuard.ts、packages/web-components/src/util/axiosHelper.js、.../component/jobList.js(2 處)、.../layouts/header.js | FE | ✅ |
| 3 | getUrl() 改 AppOriginMap + 呼叫端標明目標 app | packages/utils/src/getUrl.ts + common-components/web-components 全部呼叫點 | FE | ✅ |
| 4 | 企業後台入口全面改用 business origin | packages/utils/src/constants/HeaderNavLinks.ts、packages/store-core/src/global/index.ts(defaultUrlWhenCompany)、packages/web-components/src/layouts/{header,footer}.js、main-site auth-initializer.client.ts/auth-redirect.ts/MessageButton.vue/Step5CompanyDone.vue/EnterpriseShowcaseSection.vue/company-register/index.vue、apps/user AccountDeletionDialog.vue/permissionGuard.ts | FE | ✅ |
| 4b | /job/、/companies/apps/main-site/app/utils/companyRedirect.ts,並在頁面層擋住求職者專屬動作 | apps/main-site/app/utils/companyRedirect.ts、components/job/JobDetailView.vue:119 | FE | ✅ Done(origin/develop) |
| 5 | 通知 deep-link:兩分支統一去前綴 + 由 app 自補 base(真正要改的是下游 app,不是 main-site) | apps/recruitment/src/views/deep-link/index.vue:20-27,35-36、apps/user/src/views/deep-link/、main-site pages/notification-links/index.vue(僅需處理 redirect_path === '') | FE | ✅ 先做(R1) |
| 5b | 通知 redirect_path 去掉 /recruitment 前綴 | TSH notification-link-token.service.ts:43 | BE | ⭕ 選配 cleanup,前置為 #5;不做也不會壞 |
| 5c | deep-link 回歸測試:to 不帶前綴仍導到正確位置。三個 app 現有 fixture 全部寫死帶前綴的值,無任何無前綴案例 | main-site/.../notification-links.spec.ts:37,45、recruitment/.../deepLink.spec.ts:25、deepLinkRealRouter.spec.ts:19、apps/user/.../deep-link/__tests__/ | FE | ✅ |
| 5d | 通知 resolve 回應新增顯式 target_origin(值域 main|business|user,對齊 §5.2 AppOriginMap)。v1.2.3 補列——§8.3 已決議加,但先前未進本表。additive/expand,舊前端不讀也不會壞;與 #5 的 R1 一起上,FE 優先讀此欄位、讀不到才回退 identity_scope 推導 | TSH notification-link-token.service.ts + 三個 app 的 deep-link 消費端 | BE + FE | ⭕ 非阻塞(預防性契約改善,見 §13.3 #9;不修任何現存 bug) |
| 6 | 邀請信/審核信 URL 改讀企業側網域來源 | TSH company-invitation.service.ts:30、TSH companies/utils/company-page.util.ts:23(getCompanyManagePageUrl,v1.2.0 補漏)、AMS company-event.service.ts:163 | BE | ✅ |
| 6b | company-page.util.ts 的呼叫端是排程(company-profile-grace.service.ts:214,每日 cron,寄給真實企業)。改動需同步 company-profile-grace.service.spec.ts:6,114 的 mock 字串;⚠️ 驗證不可真跑該 cron(無公司過濾、全庫掃描、會寫下架期限),改以對 util 本身的單元測試驗 | TSH | BE | ✅ |
| 7 | 所有指向公開頁的連結一律以 main origin 解析:pm_51 複製網址用 wport.me 常數;JobManager/ViewJob/index.vue:17(router.push('/job/…'))、JobManager/ViewJob/JobDetailView.vue:296,859(openJob('/job/…'))、common/Company/CompanyDetailView.vue:163,327(openCompany('/companies/…'),目前為空函式 no-op,實作時務必用 main origin)、views/company-info/preview-company-info/index.vue:16 | apps/recruitment | FE | ✅ |
| 8 | Vite base → /、public 靜態資產路徑(/recruitment/icon/...)修正 | apps/recruitment | FE | ✅ |
| 8b | 新增一份 business 環境的 build 設定,至少含 VITE_CHAT_API_ORIGIN=https://business.wport.me 與 §8.1 的 main/business origin(現有只有 .env.staging.example/.env.production.example 兩套) | W101-Web 根目錄 | FE | ✅ |
| 9 | 邊緣:DNS、TLS、SPA fallback、後端分流、WebSocket、robots、index.html no-store、轉址規則 | Cloudflare/nginx | DevOps | ✅ |
| 10 | 既有付費企業客戶通知(防火牆白名單/教育訓練文件更新) | — | 業務/客服 | ✅ |
| 11 | CORS_ORIGIN/WS_CORS_ORIGIN 追加 business origin(保險) | TSH | BE | ❌ |
| 12 | business-rules.md 新增 BR-042 | prd repo | PM | ❌ |
| 13 | 更新 doc/feature/README.md 索引 | prd repo | PM | ✅ |
| 14 | Coda backlog 新增列 PRD=pm_52 | prd repo | PM | ✅ |
| 15 | 302 → 301 轉換工單(P4) | Cloudflare | DevOps | ❌(但不可遺忘) |
14. 下一步
- RD/DevOps 先回答 §13.3 問題 #1(prod 邊緣路由現況)—— 這是唯一會擋住排程的技術前提
- FE 開 P0(cookie 契約 + AppOriginMap + 6 處直讀修正),可立即開工、與 P1 平行
- DevOps 建
developers-business.wport.me(內網 DNS + nginx vhost)走完 P1,用 §10.4 全表在 dev 驗收 - 業務/客服準備企業客戶通知稿與教育訓練文件更新(新網址)
- P2 切換 → 觀察 14 天(盯
auth.session_bridge_failed與邊緣 404)→ P3 → P4 轉 301 - 上線後回寫
business-rules.mdBR-042
文件結束