# 03 資料模型與儲存

主要讀者：DBA、後端研發工程師、測試平台維護者。

```mermaid
erDiagram
  PROJECT ||--o{ ENVIRONMENT : "project_num"
  PROJECT ||--o{ SUITE : "project_num"
  PROJECT ||--o{ PLAN : "project_num"
  ENVIRONMENT ||--o{ CONFIG : "env_num"
  SUITE ||--o{ CASE_DEF : "suite_num"
  CASE_DEF ||--o{ STEP_DEF : "case_num"
  PLAN ||--o{ PLAN_CASE : "plan_num"
  CASE_DEF ||--o{ PLAN_CASE : "case_num"
  PLAN ||--o{ TRIGGER_DEF : "plan_num"
  ENVIRONMENT ||--o{ TRIGGER_DEF : "env_num"
  TRIGGER_DEF ||--o{ RUN : "trigger_num"
  RUN ||--o{ RUN_SUITE : "run_num"
  SUITE ||--o{ RUN_SUITE : "suite_num"
  RUN_SUITE ||--o{ RUN_CASE : "run_suite_num"
  CASE_DEF ||--o{ RUN_CASE : "case_num"
  RUN_CASE ||--o{ RUN_STEP : "run_case_num"
  STEP_DEF ||--o{ RUN_STEP : "step_num"
  RUN ||--o{ COMPARE : "run_num"
  COMPARE ||--o{ COMPARE_DETAIL : "compare_num"
  RUN ||--o{ ARTIFACT : "source_type 1 的 source_num"
  PROJECT {
    Long project_num PK
    String project_code
    Long com_num
  }
  ENVIRONMENT {
    Long env_num PK
    Long project_num FK
    String env_name
  }
  CONFIG {
    Long config_num PK
    Long env_num FK
    String config_key
    String config_value
  }
  SUITE {
    Long suite_num PK
    Long project_num FK
    Long parent_num
  }
  CASE_DEF {
    Long case_num PK
    Long suite_num FK
    String case_type
    String depends_on
  }
  STEP_DEF {
    Long step_num PK
    Long case_num FK
    String step_type
    String step_exec
  }
  PLAN {
    Long plan_num PK
    Long project_num FK
  }
  PLAN_CASE {
    Long case_plan_num PK
    Long plan_num FK
    Long case_num FK
  }
  TRIGGER_DEF {
    Long trigger_num PK
    Long plan_num FK
    Long env_num FK
    String cron
  }
  RUN {
    Long run_num PK
    Long trigger_num FK
    String run_status
    String plan_record
    String env_record
  }
  RUN_SUITE {
    Long run_suite_num PK
    Long run_num FK
    Long suite_num FK
    String suite_record
  }
  RUN_CASE {
    Long run_case_num PK
    Long run_suite_num FK
    Long case_num FK
    String case_record
  }
  RUN_STEP {
    Long run_step_num PK
    Long run_case_num FK
    Long step_num FK
    String step_exec
    String step_result
  }
  COMPARE {
    Long compare_num PK
    Long run_num FK
    Long case_num FK
    Long step_num FK
  }
  COMPARE_DETAIL {
    Long compare_dtl_num PK
    Long compare_num FK
    String column_name
  }
  ARTIFACT {
    Long qa_file_num PK
    Long source_num
    String source_type
    String remote_file_path
  }
  INVOKE_TYPE {
    String class_name PK
    String class_use_type
  }
```

## 1 範圍與責任

本章只描述 **Crystal／Sentry／Northstar E2E 測試平台**。受測 API 與其業務資料是測試對象，不納入本平台實體字典。Sentry 是共用核心 library，並非另一個獨立資料服務。

圖中的型別是 Java 映射型別。PK 表示映射識別鍵，FK 表示邏輯關聯；實體約束是否已部署尚未查驗。歷史建表雖有部分 PK/FK 宣告，但不足以證明現場 schema 與工作樹一致。完整欄位與歷史／現行差異見 [04 資料字典](05_DATA_DICTIONARY.md)。

### 1-1 實體對照與資料來源

| 圖中實體 | 真實資料表 | 權威內容與負責元件 |
| :--- | :--- | :--- |
| PROJECT | `qa.qa_c001` | Crystal 維護公司與專案 |
| ENVIRONMENT／CONFIG | `qa.qa_c002`／`qa.qa_c012` | Crystal 維護環境與設定；執行時由 Northstar 載入 |
| SUITE／CASE_DEF／STEP_DEF | `qa.qa_c003`／`qa.qa_c004`／`qa.qa_c013` | Crystal 維護測試群組、案例及六類步驟定義 |
| PLAN／PLAN_CASE | `qa.qa_c005`／`qa.qa_c006` | 計畫選取哪些 Case；不等同選取整個 Suite |
| TRIGGER_DEF | `qa.qa_c007` | 計畫、環境、cron、下次執行時間與通知群組 |
| RUN／RUN_SUITE／RUN_CASE／RUN_STEP | `qa.qa_c008`～`qa.qa_c011` | Northstar 透過 Sentry 執行，經 Crystal API 回寫本次結果 |
| COMPARE／COMPARE_DETAIL | `qa.qa_r001`／`qa.qa_r002` | 既有 SQL 比對資料與報表契約；有寫入 API，不保證每條現行 handler 都填入 |
| ARTIFACT | `qa.qa_u001` | 執行產物的檔名、來源與遠端位置；實際儲存位置及停機後可用性待環境核對 |
| INVOKE_TYPE | `qa.qa_z000` | 參數型別及可選呼叫類別的清單；不是方法執行紀錄 |

### 1-2 定義、結果、業務資料分開

| 資料層 | 保存什麼 | 不代表什麼 |
| :--- | :--- | :--- |
| 定義 | 下次執行的案例與設定 | 不包含某次實際回應或最後一次結果 |
| 執行結果 | 每次 Run 的狀態、時間、設定記錄與步驟證據 | Run FINISHED 不等於全部 Case PASSED |
| 測試資料 | `_in` 基準來源、受測表、`_qa` 預期資料及 SQL 操作結果 | 不是每個 Run 都有獨立副本；不是平台 schema 固定欄位 |
| 執行變數 | 環境設定與 RUN／CASE 暫存值 | 不是持久工作佇列，也不是環境隔離 |
| 產物 | SQL 比對 JSON、報告與其他已上傳檔案 | 檔案路徑存在不等於檔案仍可下載 |

早上供同仁使用及半夜測試，使用**同一套 EKS 環境和資料**。時間分流本身沒有資料隔離、互斥或每次 Run 還原到同一資料狀態的保證。夜間固定會強制關閉，早上再次啟動後須能查到前一夜的線索；不因測試異常或 Run 尚未結束而持續保留執行環境。關閉時可能仍有 RUNNING 紀錄，須在早上核對最後回報。這是已確認需求，尚未驗證完整持久化及強制關閉實作；目前 17 張表未提供完整環境啟停生命週期欄位，不能將 Run 的完成狀態當作 EKS 已關閉的證據。

## 2 識別碼、hooks 與結果快照

### 2-1 必須保留每次執行的識別碼

| 層次 | 定義識別碼 | 當次結果識別碼 | 解讀方式 |
| :--- | :--- | :--- | :--- |
| Suite | suite_num | run_suite_num | 同一群組可出現在多次 Run |
| Case | case_num | run_case_num | 同一 Case／hook 在同一 Run 也可能被呼叫多次 |
| Step | step_num | run_step_num | 指向本次呼叫的實際結果，不只指向可重用定義 |

例如 BeforeEach 會在每個一般 Case 前執行，因此不能將 `(runNum, caseNum)` 設為所有結果的唯一鍵。結果查詢應沿 `run_num → run_suite_num → run_case_num → run_step_num` 取得證據。重跑會新增結果，不把第一次失敗改成第二次成功。

### 2-2 設定記錄的界限

| 結果層 | 保存的設定 | 限制 |
| :--- | :--- | :--- |
| Run | plan_record、env_record、trigger_record | 各物件序列化內容；不是全部定義在同一時間鎖定版本 |
| RunSuite | suite_record | 不能推論報表所有名稱都只讀此欄 |
| RunCase | case_record | hooks 重複呼叫會有多筆結果；部分例外欄位未保證填值 |
| RunStep | step_exec、step_name、step_type、step_order | 能追查當時步驟設定，不能完整還原受測系統及外部資料 |

結果列在執行時仍會更新狀態、時間與回應。部分報告仍 JOIN 現行 Case／Step 名稱；修改定義後，舊報告所有顯示內容並不保證不變。因此不能宣稱現況是不可變快照或完整可重播系統。

## 3 一致性與防呆

| 規則 | 現況 | 應如何驗證／改善 |
| :--- | :--- | :--- |
| Plan 選案 | 透過 qa_c006 關聯 Case | 核對 `(plan_num, case_num)` 重複資料及是否有組合唯一約束 |
| 案例與步驟排序 | case_order／step_order 決定執行順序 | 重複排序值、同時修改及完整清單保存需 round-trip 測試 |
| Case 相依 | 依 depends_on 取相依 Case 的結果 | 現況只攔截查得的 FAILED，查詢未限定本次 Run／RunSuite；ERROR／未執行與跨 Run 影響須修正及驗證 |
| hooks | BEFORE_ALL、BEFORE_EACH、AFTER_EACH、AFTER_ALL 也都是 Case | 不可另造固定一對一結果；after hooks 不因名稱就有 finally 清理保證 |
| 環境設定 | qa_c012 以 env_num、config_key 表達 | 未確認組合唯一，重複鍵及敏感內容保存須核對 |
| 同步快照 | 各層執行時保存自己的設定 | 需要統一定義版本是改善提案，並非現況 |
| RunSuite 狀態 | 存在 suite_status 欄位 | 目前新增 RunSuite 段未填此欄，不能用欄位存在推論完整狀態流 |
| 比對輸出 | 現行兩類 SQL handler 保存 JSON 產物 | qa_r001 報表資料與檔案內容是否同步需獨立核對 |
| 執行產物 | qa_u001 以 source_num 查詢檔案 | 查詢未限制 source_type；混用來源類型前先處理識別碰撞 |
| EKS 停機 | 夜間固定強制關閉，早上啟動後查線索 | 關閉前持續保存關聯識別、最後狀態與錯誤；停機結果與證據可讀性分別驗證，不以 run_status=3 代替 |

完整組合欄位規則見 [字典第 6 節](05_DATA_DICTIONARY.md#constraints)，整合錯誤規格見 [06 整合與交易時序](03_INTEGRATION_AND_SEQUENCE.md)。

## 4 索引與並行控制

### 4-1 索引候選

以下來自現行查詢模式，是**待評估的索引設計**，不是已部署清單。先查現有索引並使用測試環境的查詢計畫評估，避免重複建立。

| 用途 | 表 | 候選欄位順序 | 注意事項 |
| :--- | :--- | :--- | :--- |
| 公司內找專案 | qa_c001 | `(com_num, project_code)` | 若要唯一，先清查重複與業務規則 |
| 群組案例排序 | qa_c004 | `(suite_num, is_deleted, case_order)` | 同時考慮 category 篩選，勿直接宣稱排序值唯一 |
| 案例步驟排序 | qa_c013 | `(case_num, step_order)` | 依案例讀完整步驟 |
| 計畫選案 | qa_c006 | `(plan_num, case_num)` | 集合語意可考慮 Unique；目前未證實已部署 |
| 排程領取 | qa_c007 | `(is_enabled, next_run_time)` | 索引只能加速查詢，不能防止兩個 worker 重複領取 |
| 單次結果階層 | qa_c009／qa_c010／qa_c011 | 分別 `(run_num)`／`(run_suite_num, case_num)`／`(run_case_num, step_order)` | 保留各層執行 PK，不以 case_num 覆蓋歷史結果 |
| Run 清單與保留查詢 | qa_c008 | `(project_num, create_at)` | 清理政策需另外確認，欄位存在不代表已排程清理 |
| 環境設定 | qa_c012 | `(env_num, config_key)` | 唯一性需先核對實際資料 |
| 比對報表 | qa_r001／qa_r002 | `(run_num, case_num, step_num)`／`(compare_num, row_num, column_name)` | 同一 hook／步驟多次執行的粒度需額外確認 |
| 產物查詢 | qa_u001 | `(source_num)`；改善後評估 `(source_type, source_num)` | 查詢條件與索引一起調整 |

### 4-2 現況與改善邊界

| 控制項目 | 已知現況 | 不可宣稱／後續設計 |
| :--- | :--- | :--- |
| 樂觀鎖 | 本範圍沒有通用 version 欄位及 compare-and-set 證據 | 新增版本檢查屬改善，現在仍須測試兩人同時編輯的覆寫風險 |
| 悲觀鎖 | 本範圍未確認使用 SELECT FOR UPDATE 領取排程或鎖環境 | 若要跨 worker 互斥，需設計持久領取與原子狀態轉移 |
| Trigger 防重 | 現有流程依 triggerNum 使用快取旗標 | 不等於同環境不同 Trigger 互斥，也不等於持久佇列 |
| 狀態回寫 | Run／Case／Step 分別寫入 | 程序中斷時可能只保存部分結果；不得把缺筆當通過 |
| SQL 比對 JSON | 讀取現有檔案、加入步驟結果後覆寫 | 同一 Run 的並行寫入可能競爭；目前未做壓力與故障測試 |
| 資料準備 | PRE_SQL／SQL_TRANSFER 可修改測試資料 | 同環境其他使用者可能受影響；目前沒有每次 Run 的資料分區 |
| 重送冪等 | 有各層識別碼，但不等於所有寫 API 都去重 | 目標冪等合約見整合規格；不可盲目重送會有副作用的步驟 |

若後續建立環境使用權與固定版本，需新增相應模型及交易設計。這些提案不能填成目前資料庫已存在的表、欄位或狀態。

## 5 產物、報表與保留

### 5-1 現有產物與報表

SQL 比對 handler 目前將結果加入 `SqlCompareReport_<runNum>.json`。Crystal 同時保有 qa_r001／qa_r002 的寫入介面及報告查詢；兩套儲存的資料來源須分別驗證，不能假設每個新 Run 都自動填滿比對表。

Email 一般案例統計只包含已記錄的 NORMAL RunCase：PASSED 為通過，FAILED／ERROR 合併為失敗，其他狀態以餘數歸入略過。零案例現況回傳 100。必須另外核對原定案例與實際結果筆數，才能判斷測試是否完整。

### 5-2 固定強制關閉與隔日追查

**已確認需求：夜間固定強制關閉，但要留下隔天早上啟動後可查的線索。** 因此證據應在執行過程持續送出，不能只依靠正常完成、最後一個 after hook 或關閉時的 finally 才保存。

| 必須保留的線索 | 使用既有資料 | 目標保存與隔日檢查 |
| :--- | :--- | :--- |
| 本次執行範圍 | run_num、trigger_num、plan_num、env_num 及既有設定記錄 | 可定位前一夜的 Run 與執行設定，不靠記憶體 RunContext |
| 最後進度 | run_status、run_start_time／run_end_time、run_suite_num、run_case_num、run_step_num | 保留實際最後回報；沒有終態或結束時間就顯示未完成／待核對，不算通過 |
| 失敗原因 | step_error、step_result、post_proc_resp、run_log 及執行日誌 | 以同一組 Run／Case／Step ID 串起線索；資料尚未回寫時，外部日誌應能補充時間與最後動作 |
| 已產生證據 | qa_u001 的檔名、remote_file_path、來源識別及比對 JSON | 關閉後檔案內容仍可讀；只有 metadata 不足以證明檔案已保存 |
| 關閉事件 | 環境控制與平台日誌中可關聯該夜間 Run 的時間、原因與結果 | 不新增虛構資料表欄位；需核對實際環境控制提供的紀錄 |

**持久化是目標驗收條件，尚未證實部署已達成。** 結果資料庫、日誌及產物必須位於不會隨本次執行容器／節點關閉而遺失的儲存；目前只有寫入邏輯及路徑資訊，尚未查驗磁碟掛載、外部儲存、最後寫入完成或停機後讀取。證據未確認保存是需告警與追查的缺口，不將它解讀為可以取消固定強制關閉。

強制關閉可能來不及更新終態。早上先查前一夜 RUNNING 或缺少結束時間的紀錄，對照最後步驟、日誌與關閉事件，再依實際結果補註中斷原因；不能直接把所有殘留 RUNNING 判成正常完成。狀態整理是目標流程，尚未證實已有自動 reconciliation。保留原本結果與狀態修正線索；人工重跑另建新 Run，不用新結果覆寫前一夜的失敗或不完整證據。

### 5-3 保留政策

結果與產物保留期限若採既有需求的 90 天，應包含關聯結果、比對明細及檔案清理策略；目前未證實排程清理已實作。本文件不執行刪除，清理前應先驗證保留期間及下游引用。

## 6 DBA 唯讀核對

本次沒有連線查詢實際資料庫。以下 PostgreSQL metadata 查詢可用於確認測試環境的部署事實；不需讀取 config_value 或實際案例內容。

### 6-1 欄位與實體型別

```sql
SELECT table_name, ordinal_position, column_name,
       data_type, udt_name, character_maximum_length,
       numeric_precision, numeric_scale, is_nullable, column_default
FROM information_schema.columns
WHERE table_schema = 'qa'
  AND table_name IN ('qa_c001','qa_c002','qa_c003','qa_c004','qa_c005',
                     'qa_c006','qa_c007','qa_c008','qa_c009','qa_c010',
                     'qa_c011','qa_c012','qa_c013','qa_r001','qa_r002',
                     'qa_u001','qa_z000')
ORDER BY table_name, ordinal_position;
```

### 6-2 完整約束與索引

```sql
SELECT t.relname AS table_name, c.conname, c.contype,
       pg_get_constraintdef(c.oid) AS definition
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'qa'
  AND t.relname IN ('qa_c001','qa_c002','qa_c003','qa_c004','qa_c005',
                    'qa_c006','qa_c007','qa_c008','qa_c009','qa_c010',
                    'qa_c011','qa_c012','qa_c013','qa_r001','qa_r002',
                    'qa_u001','qa_z000')
ORDER BY t.relname, c.conname;

SELECT tablename, indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'qa'
  AND tablename IN ('qa_c001','qa_c002','qa_c003','qa_c004','qa_c005',
                    'qa_c006','qa_c007','qa_c008','qa_c009','qa_c010',
                    'qa_c011','qa_c012','qa_c013','qa_r001','qa_r002',
                    'qa_u001','qa_z000')
ORDER BY tablename, indexname;
```

### 6-3 組合鍵重複檢查

```sql
SELECT plan_num, case_num, count(*) AS duplicate_count
FROM qa.qa_c006
GROUP BY plan_num, case_num
HAVING count(*) > 1;

SELECT env_num, config_key, count(*) AS duplicate_count
FROM qa.qa_c012
GROUP BY env_num, config_key
HAVING count(*) > 1;
```

不要對 RunCase 執行 `(runNum, caseNum)` 去重：BeforeEach／AfterEach 的重複結果可能是正確紀錄。物理約束、資料完整性與並行寫入尚待實測，驗收方式見 [08 UAT 與驗證](06_UAT_AND_VERIFICATION.md)。
