Tangency InWin · 技術評估
inwin-plugin 的可行性評估與執行流程
把 inwin-plugin(早期功能外掛)、inwin-template-plugin(現行樣板外掛)、at-contact-us-plugin(新聯絡我們,僅規格)
收束成一個外掛,並將現行外掛更名為 inwin-plugin。前提是站台正在營運、資料已存在,整合過程不得破壞任何既有關鍵字。
先給判斷,再給依據。
可行,而且風險比預期低。但「全部合併」實際上是三件性質完全不同的工作,必須拆開排程,不能當成同一件事做。
三方 identifier 衝突
1
僅 Protype\Library\ 一處,且現在就已在線上打架
需要 schema migration
0
三方皆無自訂資料表,全在 WP 原生表
activation hook 補償邏輯
0
兩邊 activate/deactivate 皆為空方法
新 Contact 未決事項
55
含 6 個開工阻塞項、2 個需外部確認
整合本身會修好一個線上既存缺陷
兩個外掛用完全相同的 PSR-4 mapping 宣告同一組類別,目前由載入順序決定勝負。合併後只剩一份,這個潛伏問題自動消失(詳見關鍵發現 #1)。
合併之前,先確認各自到底有多大、裡面有什麼。
| 項目 | inwin-plugin | inwin-template-plugin | at-contact-us-plugin |
|---|---|---|---|
| 版本 | 0.23.0(線上 0.22.0) | 0.39.0 | — |
| PHP 檔/行數 | 54 檔 / 29,167 行 | 227 檔 / 45,301 行 | 0 檔 |
| 扣除資料檔後的實際邏輯 | 約 4,400 行 | 約 45,000 行 | 0 |
| 功能模組 | Seller、Contact、Metabox library | iLayout/iSlot/iWidget/iTemplate/iValue/Product/ProductCategory/ProductCompare/VisualEditor | — |
| 單元測試 | 2 檔 | 61 檔 / 603 個 test method | 0 |
| 自訂資料表 | 無 | 無 | 規格指定要建 5–7 張 |
| REST route | 無 | 26 條(inwin-template-plugin/v1) | 未指定 |
| 最後 commit | 2026-04-09 | 2026-05-18 | 2026-07-28 |
| CI | 無 | 無 | 無 |
inwin_local 實測)| 資料 | 數量 | 歸屬 |
|---|---|---|
seller CPT | 564 筆 publish | inwin-plugin |
seller_type taxonomy | 163 個 term(三層) | inwin-plugin |
seller_* post meta | 各 564 筆 | inwin-plugin |
contact CPT | 0 筆(但 Admin Columns 設定殘留 → 曾經有) | inwin-plugin |
itemplate CPT | 98 筆 | inwin-template-plugin |
itemplate_data_* 動態 meta | 數百組 key | inwin-template-plugin |
引用 iTemplate 系列的 _elementor_data | 1,194 筆 | inwin-template-plugin |
使用 dynamic tag 的 _elementor_data | 3,185 筆 | inwin-template-plugin |
| 自訂資料表 | 0 | — |
本地為 2026-03 快照。正式站與 demo1 的 contact 筆數必須另行確認,這決定要不要寫資料遷移(見 Phase 0)。
會直接改變執行順序的事實,皆經實際讀檔或資料庫/runtime 實測。
#1 線上此刻就存在一個隱性缺陷 —— 而且合併會修好它
兩個外掛的 composer.json 都把 Protype\Library\ 映射到各自的 library/,宣告同一組完整類別名。由 active_plugins 順序決定誰贏,而 inwin-plugin 排在前面。
WP-CLI 實測結果:
Base\Field 實際載入自: .../plugins/inwin-plugin/library/Metabox/Fields/Base/Field.php
有 is_empty_value(): NO
有 apply_default_value(): NO
Gallery\Field 載入自: .../inwin-template-plugin/library/Metabox/Fields/Gallery/Field.php
Gallery 的父類檔案: .../plugins/inwin-plugin/library/Metabox/Fields/Base/Field.php
也就是說:template-plugin 自己的 Gallery/Repeater/ColorPicker 等欄位,目前繼承的是 inwin-plugin 的舊版父類,新版的 default 預設值機制與空值判定從來沒有生效過。合併後只剩一份 library,這個問題消失 —— 但同時代表合併會改變既有行為,必須做回歸測試,不能當成純搬移。
#2 inwin-plugin 這個名字已經被佔用,而且正在運作
wp-content/plugins/inwin-plugin/ 是實體目錄(非 symlink),且 active_plugins 同時啟用兩個外掛。所以「更名」在技術上是取代而非改名。好消息是這反而是最安全的路徑:沿用 inwin-plugin/inwin-plugin.php 這個 slug,active_plugins 完全不用動。壞消息是新舊絕不可並存 —— 同名全域函式 activate_inwin_plugin()、同名常數 _INWIN_PLUGIN_* 會直接 fatal。
#3 最硬的一組關鍵字是 Elementor 序列化名,而它們與外掛名稱無關
ilayout/iwidget(document type)、islot_widget(widget)、ivalue-*-tag(dynamic tag)、collect_contact_us(form action)、post-info-plus(widget)—— 這些字串直接序列化在每一筆 _elementor_data JSON 內。只要不主動去碰,更名對它們零影響。反之若有人「順手統一前綴」,全站樣板立刻變成未知元件,且無法自動修復。
#4 collect_contact_us 的失效模式是「靜默」的
這個字串已存在正式頁面的 _elementor_data 裡。若改動它,表單照樣顯示送出成功,但後台再也收不到任何聯絡紀錄,且不會有任何錯誤訊息。注意 Contact.php 註冊時傳的 'contact_action' 只是 registrar key,不是資料庫綁定值,很容易誤判成可以改。
#5 Where-to-Buy 用 term「名稱」當程式判斷依據
前台元件以 $term->name === 'Country' / 'Type' 找根節點,並把 Distributor/Online Store/Reseller/Retailer Shop/SI 這五個 term 名稱直接輸出成前端 tab 的比對值。這 163 個 term 的名稱實質上是不可改的 identifier,合併期間任何 term 整理(包含 WPML 翻譯 term name)都會讓前台直接空白,且沒有 fallback。
#6 三個最典型的合併風險,在這個案子完全不存在
activate()/deactivate() 都是空方法,無需補償uninstall.php 都只有守衛、無清理邏輯,移除外掛不會掉資料整合過程中一個字都不能動的清單。這張表是「不破壞既有資料」這個前提的具體展開。
| 類型 | 值 | 寫在哪 |
|---|---|---|
| Document type | ilayout iwidget | _elementor_template_type postmeta |
| Widget name | islot_widget post-info-plus | _elementor_data 的 widgetType |
| Dynamic tag group | ivalue-tags iwidget-product-fields | _elementor_data 的 __dynamic__ |
| Dynamic tag name | ivalue-text-tag ivalue-image-tag ivalue-number-tag ivalue-url-tag ivalue-color-tag ivalue-media-tag ivalue-gallery-tag iwidget-product-description | 同上 |
| Widget/tag control key | slot_esid slot_id slot_name slot_description value_esid value_id value_name value_group value_desc specified_term_ids specified_term_parent_ids | 同上 |
| Form action | collect_contact_us | _elementor_data 的 submit_actions |
| 表單欄位 label(當成陣列 key) | Country / Location Product Type Purpose of contact First Name Last Name E-mail Phone Number No Label private Message Insert | 線上表單 widget 的 label 文字 |
| 類型 | 值 |
|---|---|
| Post type | itemplate seller contact |
| Taxonomy | ilayout_category iwidget_category itemplate_category seller_type |
| Seller post meta | seller_address seller_map_url seller_site_url seller_phone seller_email seller_fax |
| Contact post meta | cid status privacy location product_type subject first_name last_name email phone message attachment |
| Contact status 列舉 | new in-progress resolved closed |
| iTemplate post meta | itemplate_esid ilayout_esid iwidget_esid itemplate_settings_main_en(含 region/locale 變體)itemplate_data_{id}_{region}_{locale} itemplate_id itemplate_type {itemplate,ilayout,iwidget}-{title,description,image} iwidget-injection iwidget-expand-options |
| Product 系列 meta | _product_spec _product_spec_tablepress_id _product_image_gallery _product_{field}_{locale} _inwin_product_hidden_visibility product-{features,overview,specifications}-* |
| Term meta | _product_category_image_gallery _product_category_description _product_category_{field}_{locale} is_hidden |
| User meta | _inwin_compare_list |
| Options | inwin_enable_ilayout inwin_enable_iwidget inwin_enable_itemplate inwin_admin_per_page inwin_admin_thumbnail_size inwin_import_seller_data_250226 |
| Shortcode | inwin_where_to_buy inwin_compare_button inwin_product_compare |
| ESID 前綴 | itemplate- ilayout- islot- iwidget- ivalue- |
| 自訂 hook(對外承諾) | inwin_contact_form_submitted inwin_product_compare_visible_ids |
| Seller term 名稱 | Country Type + 5 個 sales type + 9 個產品線 + 107 個國家名 |
| 類型 | 值 | 存活期 |
|---|---|---|
| Cookie | inwin_region | 30 天 |
| localStorage | inwin_compare_list inwin_auto_save_enabled | 永久(至使用者清除) |
| JS 全域物件 | inwinWtbData | 頁面生命週期 |
這是整個計畫的核心決策,其他步驟都由它推導出來。
wp-content/plugins/
└── inwin-plugin/ ← 合併後的唯一外掛(接管舊 slug)
├── inwin-plugin.php ← Plugin Name: Tangency InWin Plugin
├── includes/ ← 骨架(採 template-plugin 版)
├── library/ ← Protype\Library 只留一份(採 template-plugin 版)
└── modules/
├── iLayout/ iSlot/ iWidget/ iTemplate/ iValue/
├── Product/ ProductCategory/ ProductCompare/ VisualEditor/
├── Seller/ ← 自 inwin-plugin 移入(目錄層級必須維持兩層)
└── Contact/ ← 自 inwin-plugin 移入(暫時原樣,待 Phase 5 汰換)
為什麼是「接管 slug」而不是「改名」
active_plugins 這個 option 存的是 目錄/主檔.php。既有值裡本來就有 inwin-plugin/inwin-plugin.php,讓合併後的外掛落在這個路徑,這筆設定完全不用動,也不需要任何資料庫層面的補償。要處理的只是移除 inwin-template-plugin/… 那一筆。
| 項目 | 處理 | 理由 |
|---|---|---|
| 外掛目錄名、主檔名 | 必須改 | 更名的本體 |
6 處硬編碼主檔名的 plugins_url() | 必須改 | 漏改的症狀是「靜默的資源 404」,最容易漏 |
常數 _INWIN_TEMPLATE_PLUGIN_* | 必須改 | 與被取代的舊外掛常數同名,須統一為 _INWIN_PLUGIN_* |
全域 class/函式 inwin_template_plugin* | 必須改 | 僅 6 個檔案,成本低;避免與舊外掛殘留衝突 |
根目錄 .gitignore | 必須改 | 現有規則全寫成 inwin-plugin/…(歷史化石),目前失效、更名後會突然生效並排除掉 composer.lock |
PSR-4 namespace inwin_template_plugin\ | 建議不改 | 452 處、200+ 檔;不寫進資料庫;唯一 runtime 字串組裝點只有一行。風險遠大於收益 |
| Text domain(690 處) | 建議不改 | 目前只有 .pot、無任何 .mo,改與不改都不影響顯示 |
REST namespace inwin-template-plugin/v1 | 建議不改 | 26 條路由 + 3 支前端 JS 硬編碼;改動只有壞處 |
後台選單 slug inwin-templates | 建議不改 | 站上裝了 Admin Menu Editor,其排序設定綁這個 slug |
| A/B/C 三類凍結關鍵字 | 絕對不動 | 見上一節 |
刻意接受的取捨
採用上述策略後,外掛叫 inwin-plugin,但內部命名空間、text domain、REST namespace、後台選單 slug 仍然叫 inwin_template_plugin / inwin-templates。這個不一致是刻意換來的安全性。若日後要統一,應該是一個單獨的、不夾帶任何功能變更的 PR,並以 603 個既有單元測試當防護網。
六個階段。Phase 0–4 是一條連續的路徑,Phase 5 應獨立立案。
mlab 連不到 demo1(ssh 無法解析),remote-* 系列 CLI 也不在 mlab。以下步驟一律從 air-2019 執行。
這一步的產出直接決定 Phase 5 要不要寫資料遷移,以及切換窗口需要多長。
remote-wp cli "post list --post_type=contact --post_status=any --format=count"
remote-wp cli "post list --post_type=seller --post_status=any --format=count"
remote-wp cli "option get active_plugins --format=json"
remote-wp cli "db query \"SELECT COUNT(*) FROM wp_postmeta
WHERE meta_key='_elementor_data' AND meta_value LIKE '%collect_contact_us%'\""
remote-wp cli "db query \"SELECT COUNT(*) FROM wp_postmeta
WHERE meta_key='_elementor_data' AND meta_value LIKE '%post-info-plus%'\""
remote-wp cli "db query \"SELECT COUNT(*) FROM wp_posts
WHERE post_content LIKE '%inwin_where_to_buy%'\""
驗收:一張表列出 contact/seller 筆數、使用 collect_contact_us 與 post-info-plus 的頁面數、含 WTB shortcode 的頁面數。
資料庫全量 dump + wp-content/plugins/ 打包。這是唯一的 rollback 依據。
驗收:備份檔可在本地還原並開得起來。
把「待拍板」一節的 5 個決策點結掉,特別是 D5(Contact 是否納入本批)。
驗收:決策記錄寫進 docs/plans/。
先做這一步,因為它是整個計畫中唯一「合併本身就會改變行為」的部分(見關鍵發現 #1)。把它單獨隔離出來,行為變化才追得清楚。
在動任何程式碼之前,先把目前(舊版父類生效下)的儲存/讀取行為寫成測試並讓它通過。這組測試的作用是量化「合併會改變什麼」。
default 預設值機制、is_empty_value() 的空值判定、Select 新增的 sanitize_text_field、空值改走 delete_meta驗收:測試綠燈,且能明確指出哪些欄位行為會變。
template-plugin 版是嚴格超集(多出 Checkbox/ColorPicker/Gallery/Radio/Repeater/Unique 六種欄位型別)。
驗收:composer8.0 test:unit 全綠(603 個 test method)。
針對 seller-info(6 欄位)與 contact-history(12 欄位)兩個 metabox 做實際存檔驗證,確認 meta 值與改動前一致。
驗收:存檔前後 wp_postmeta 逐欄位比對無非預期差異;有差異者需為 T1-1 已預期並記錄的項目。
五個檔案 + assets 整組移入 modules/Seller/,namespace 由 inwin_plugin\Module\Seller 改為 inwin_template_plugin\Module\Seller,主 class 加一行註冊。
WhereToBuyShortcode.php 用 plugin_dir_url(dirname(dirname(__FILE__))) 推導資源路徑,放深一層就整組 404,前台清單直接空白seller、taxonomy seller_type、6 個 meta key、shortcode inwin_where_to_buy、handle inwin-wtb、JS 全域 inwinWtbData、CSS class inwin-wtb__*、以及所有 term 名稱驗收:停用舊外掛後,後台 564 筆 seller 完整、前台 WTB 篩選/tab/無限捲動全部正常。
三個檔案 497 行,只有 4 個外部耦合點。本階段刻意不重寫 —— 它即將在 Phase 5 被取代,移植的唯一目的是不中斷服務,最小改動最合理。
contact、12 個 meta key、4 個 status 值、collect_contact_us、10 個 Elementor 欄位 label、CID 格式 CN{Ymd}{post_id:04d}、hook inwin_contact_form_submitted、metabox id contact-historyadd_form_action() 在 Elementor Pro 3.5+ 已 deprecated驗收:從 Elementor 表單實際送出一筆,後台出現對應紀錄,12 個 meta 值格式與舊版逐欄位一致。
移入時補上 class_exists 守衛。這是修 bug 不是重構 —— 現況在「只裝 Elementor 免費版、沒有 Pro」的環境會直接 fatal。
驗收:停用 Elementor Pro 後站台仍可正常載入。
common/snippets/ 一次性匯入工具 —— 每個 request 都在載入 917 KB 的 import-data.php,而匯入任務早在 2025-02 完成。這是本次最直接的效能收穫common/woodmart/ 的 require —— 目錄根本不存在,只因 hook 被註解掉才沒 fatalcommon/admin/public 空殼骨架 —— 這 6 個資產檔因為把檔案系統路徑當 URL 傳,從來沒有真正載入過PostModel/PostModelFields、4 個 sample-*.jsonwp-tests-config-remote.php —— 含遠端資料庫名稱與帳號且已進版控,不應帶進新外掛驗收:移除後全站功能無異動;option inwin_import_seller_data_250226 保留在資料庫(無害,不需清)。
兩邊 autoload-dev 都用 Tests\ 前綴,WP mock function 也各有一套,重複宣告會是合併後的第一批紅燈。
驗收:composer8.0 test:unit 全綠。
inwin-template-plugin/ → inwin-plugin/,主檔同步更名,Plugin Header 的 Name 改為 📦 Tangency InWin Plugin。
已定位完成:iWidget.php:197,204,213、iLayout.php:200,207、ProductCompare.php:39。
驗收:後台三個管理頁的 CSS/JS 與比較功能資源皆 200,DevTools Network 無 404。
_INWIN_TEMPLATE_PLUGIN_* → _INWIN_PLUGIN_*(約 40 處),includes/ 下 6 個全域 class 與 3 個全域函式同步改名(含檔名)。
現有規則是「這個 repo 曾經叫 inwin-plugin」留下的化石,目前失效,更名後會突然生效並把已追蹤的 composer.lock 與 package.json 排除掉。更名 PR 必須同時修它。
Gitea 上 Tangency/inwin-plugin 已存在,直接改名會撞。順序:舊 repo 先改名封存(如 inwin-plugin-legacy),再把 inwin-template-plugin 改名為 inwin-plugin,最後更新本地 remote。
CLAUDE.md、docs/commands/、remote-* 指令說明中的路徑。
驗收:本地 symlink 重建 + 強制清 PHP 8.0 opcache(opcache 是 inode-based,切 symlink 不會自動失效),全站功能清單逐項通過。
為什麼一定要維護窗口
合併版與舊 inwin-template-plugin 不能同時啟用(會重複註冊 itemplate CPT 與 islot_widget),與舊 inwin-plugin 也不能並存(同名全域函式與常數直接 fatal)。因此切換必須是原子操作,中間有一段短暫的「兩者皆停用」。
# 1. 備份(再做一次,取最新狀態)
# 2. 開啟維護模式
wp plugin deactivate inwin-template-plugin inwin-plugin
# 3. 實體刪除兩個舊目錄(必做 —— 留著會被誤啟用,導致雙重註冊)
# 4. 部署合併版到 wp-content/plugins/inwin-plugin/
wp plugin activate inwin-plugin
# 5. 強制清 PHP 8.0 opcache
# 6. 關閉維護模式 → 執行驗證清單
Rollback 條件:驗證清單任一項失敗且 15 分鐘內無法定位 → 還原備份的 plugins/ 目錄與 active_plugins option。因為沒有任何 schema 變更,rollback 不需要處理資料。
為什麼建議切開
$wpdb + dbDelta + schema 版本管理規範contact CPT 資料的遷移(所有文件的「舊站」都是指 Laravel 舊站,不是現行 WordPress 站)好消息:命名幾乎全部留白等定案。規格作者已明文寫「配合 Wake 規劃 —— 三個外掛收束成一個」,plugin slug/class prefix/text domain/常數/namespace/資料表名/option key 全部未指定。真正鎖死的只有 4 項:shortcode [at_contact_form] / [at_contact_records]、CSS 前綴 at-、Email 模板路徑、10 個 WPML 語系碼。
另有兩個必須先解的落差:規格假設 PHP 8.2,而本專案強制 PHP 8.0(不可用 enum 等);規格用 WPML 原生語系碼(zh-hant/ja),而本專案用內部 4 字元碼(hant/japn)。
建議順序:R1 規格收斂(先解 6 個阻塞項與 4 個內部矛盾)→ R2 正式站 contact 資料盤點 → R3 建立本外掛的 DB migration 規範 → R4 ContactUs 模組開發 → R5 資料遷移 → R6 前台由 Elementor 表單切換為 shortcode(需頁面端配合)→ R7 舊 Contact 模組下線。
每一項都附建議。若無異議,即依建議執行。
| # | 決策 | 建議 | 理由 |
|---|---|---|---|
| D1 | PSR-4 namespace inwin_template_plugin\ 要不要一併改成 inwin_plugin\? |
不改 | 452 處、200+ 檔的機械改動,不寫進資料庫、無實質收益。若要做,應是單獨 PR 且不夾帶任何功能變更 |
| D2 | Text domain(690 處)要不要改? | 不改 | 目前只有 .pot、無 .mo,介面靠原始字串顯示,改了沒有任何可見效果 |
| D3 | REST namespace inwin-template-plugin/v1 要不要改? |
不改 | 26 條路由 + 3 支前端 JS 硬編碼;改動只會打斷既有整合與快取 |
| D4 | Contact 模組移植時要不要順手修 deprecated 的 add_form_action()? |
不修,記錄待辦 | 符合外科手術原則;且該模組即將在 Phase 5 被整個取代,投資在此不划算 |
| D5 | at-contact-us 是否納入本批? | 不納入 | 0 行實作、55 題未答、2 個需外部確認的環境問題。綁進來會讓一個低風險合併變成高風險長專案 |
| 風險 | 等級 | 失效表現 | 控制措施 |
|---|---|---|---|
| 誤改 Elementor 序列化名 | 極高 | 全站樣板變成未知元件、白畫面,且無法自動修復 | 凍結清單 A 類納入 PR 檢查項;code review 明列「本 PR 未觸碰任何 A 類關鍵字」 |
改動 collect_contact_us |
極高 | 靜默失敗 —— 表單顯示成功但後台永遠收不到 | 移植後必須實際送出一筆做端到端驗證,不能只看程式碼 |
| 新舊外掛短暫並存 | 高 | 同名函式/常數 fatal,或 CPT/widget 重複註冊 | 切換設計為原子操作;舊目錄實體刪除而非僅停用 |
| Library 統一造成行為變化 | 中 | metabox 欄位的預設值/空值儲存行為改變 | Phase 1 獨立進行,先寫特徵測試量化差異,再做 18 個欄位的存檔回歸 |
| Seller 模組放錯目錄層級 | 中 | WTB 前台 CSS/JS 全 404,清單完全空白 | 維持 modules/Seller/ 兩層結構;驗收含前台實際操作 |
| 漏改硬編碼主檔名 | 中 | 後台資源靜默 404,管理頁樣式跑掉 | 6 處已全數定位;驗收看 DevTools Network |
| opcache 未清 | 中 | 切換後仍執行舊程式碼,症狀難以理解 | PHP 8.0 isolate FPM 不會被 herd restart 重啟,須以臨時 mu-plugin 強制 opcache_reset() |
| Seller term 名稱被更動 | 中 | Where-to-Buy 前台直接空白,無 fallback | 整合期間凍結所有 term 編輯與 WPML term 翻譯操作 |
| 正式站 contact 已有資料 | 未知 | Phase 5 汰換時歷史案件遺失 | P0-1 必須先確認;本地為 0 筆但 Admin Columns 設定殘留顯示曾經有過 |
每一項都要實際操作,不接受「看起來沒問題」。
active_plugins 只剩 inwin-plugin/inwin-plugin.php,無失效條目islot_widget 正常顯示且可編輯ivalue-* 全系列,選取後正常取值admin.php?page=visual-editor)可開啟且能存檔post-info-plus widget 的頁面正常顯示inwin-template-plugin/v1 各端點回應正常