文件版本:1.2 撰寫日期:2026-05-06(初版);最後修訂:2026-05-07(review 後對齊) 文件性質:SD — System Design(架構與實作策略) Raw requirement:
./raw-requirement.md狀態:已實作完成(BE 35 + FE 60 + Test 1 = 96 commits);code review (Critical 6 / Important 12) 已修;§11 記錄所有與設計的偏差(含 review 後新增 §11.13–11.16)。⚠️ 閱讀注意:本文件保留原始 brainstorm 設計(§1–§10)作為歷史脈絡。實作中有若干修正,各節內文已加
(updated 2026-05-07: see §11.x)標記,詳細偏差原因請見 §11。設計守則:
- 不加新表、不改既有 endpoint URL,只擴充 parse / confirm 的 request/response schema
- 不修改 jedi-oscal 套件,新邏輯落在主專案
- 沿用既有
SspDocxImportPage.vue元件結構,Step 2 為新插入步驟、Step 3 為既有元件微改- 嚴守 CLAUDE.md DDD 層級規範(Route 不查 DB / App Service
@transaction/ Repo session lazy / 權限檢查在 app service 層)- Smart default 邏輯落在 BE,FE 只渲染 + 收集 user 決策
痛點:使用者重新上傳 SSP 文件時,目前 import-docx UI 只顯示「分析完的結果」(parsed value),看不到「DB 現值」(current value)。如果之前手動編輯或刪減過內容,按下確認等於無提示覆蓋,造成「不小心整個覆蓋過去」的風險。
目標:新增 Step 2 Diff 解決頁,逐項顯示「現值 vs 新值」並讓使用者決策(保留 / 採用新值 / 跳過),預設保守(有差異時 default 不覆蓋),最後 confirm 時只寫入勾選為 use_docx 的項目。
[Step 1] 上傳 docx
│
▼
[POST /ssp-docx-imports/parse] ← 既有 endpoint,擴充 update mode 邏輯
│ parse + match controls + load DB current state(含 current_parties 新增)
│ compute smart default per item(§2.5 邏輯序列化進 response)
│ filter 完全相同的項目(不出現在 diff 範圍)
│ return { has_diff, diff_summary, sections: {controls, objectives, parties} }
│
▼
┌─ has_diff == false (create mode 或全部相同) ──┐
│ │
└─ has_diff == true │
│ │
▼ │
[Step 2 Diff 解決頁 ← 新做] │
3 sections(控制條文 / 目標 / Parties),每 section 有:
• 篩選器 [全部 / 已修改 / 新增 / 待決策](預設「待決策」)
• 批次按鈕 [全部採用新值] / [全部保留現值](含防呆 confirm dialog)
• 條目卡:side-by-side 並排(左=DB 現值、右=docx 新值),紅綠 word-level highlight
• 決策 pills: [保留現值 / 採用新值 / 跳過]
│ │
▼ │
[Step 3 內容微調頁 ← 既有 UI 微改] ◄────────────┘
接收 diff 決議結果:
• action=skip → 該項不顯示
• action=keep_current → 顯示但唯讀(鎖 inline edit input)
• action=use_docx → 顯示且可 inline 編輯(同現有)
│
▼
[POST /ssp-docx-import/<uid>/confirm] ← 既有 endpoint,extends decisions schema
payload: { decisions, parties_decisions, content_overrides }
only items with action=use_docx are written
核心原則:
依使用者確認,diff 涵蓋當前 import-docx 頁面可編輯的 3 大類:
| 類別 | 內容 | 既有 BE response 是否含 current | Diff feature 改動 |
|---|---|---|---|
| 1. 控制條文 | 每條 matched_control 的 implementation_description |
✅ current_implementation_description |
FE 渲染 + 加 default_action 序列化 |
| 2. 目標 (a/b/c) | 每條 control 下的 objectives[].description |
✅ current_objectives_descriptions map |
同上 |
| 3. Parties | 對應人員 / 組織(name / role / email / matched_user_id / matched_org_unit_id) | ❌ 目前無 current_parties 概念 | BE 新增 _load_current_parties() |
不在範圍:metadata(name / version / group / provider / description / frequency)— SSP 重新上傳場景中此類欄位很少由 docx 帶入,且使用者明確排除。
針對每個 diff 項目,BE 依 current vs parsed 內容狀態,預先計算 default_action + diff_status 並回傳。
(updated 2026-05-07: see §11.1, §11.2, §11.3) — 原始設計中
added預設use_docx、unchanged/gone過濾掉。實作後全部改動:見下表「實際行為」欄。
原始設計(Phase 1 brainstorm):
current 狀態 |
parsed 狀態 |
diff_status |
default_action |
是否出現在 diff response |
|---|---|---|---|---|
| 空 / null | 有值 | "added" |
"use_docx" |
✅ 顯示,預設採用 |
| 有值 | 跟 current 完全相同(.strip() 後) |
"unchanged" |
n/a | ❌ filter 掉 |
| 有值 | 跟 current 有差異 | "changed" |
"keep_current" |
✅ 顯示,預設保留 |
| 有值 | 不在這次 docx 中 | "gone" |
n/a | ❌ filter 掉(silent preserve) |
實際行為(shipped v1):
current 狀態 |
parsed 狀態 |
diff_status |
default_action |
是否在 BE response |
|---|---|---|---|---|
| 空 / null | 有值 | "added" |
null(user 必選) |
✅ 顯示 |
| 有值 | 跟 current 完全相同 | "unchanged" |
null |
✅ BE 保留(Step 3 需要全部資料) |
| 有值 | 跟 current 有差異 | "changed" |
"keep_current" |
✅ 顯示,預設保留 |
| 有值 | 不在這次 docx 中 | "gone" |
"keep_current"(user 可改 use_docx 來清空) |
✅ 顯示 |
FE 責任分攤:BE annotate 保留所有項目(不做 filter);Step 2 FE 過濾 unchanged 不顯示;Step 3 使用完整清單(含 unchanged)以正確呈現預覽。
邏輯位置:app/oscal/service/ssp_docx_diff_service.py(純邏輯類)+ ssp_docx_import_app_service.py 內調用。
/ssp-docx-import/<parse_uid>)(updated 2026-05-07: see §11.2) — BE annotate 保留所有項目(
diff_status不限),不預先 filter。FE Step 2 按diff_statusfilter 顯示;Step 3 使用完整清單。(updated 2026-05-07) —
mode欄位值為"full"/"statement_only"(代表 parse mode),不是"create"/"update"。FE 偵測「是否需 Step 2」依diff_summary.controls.changed > 0或其他 section changed/added/gone > 0 判斷。
{
"parse_uid": "...",
"mode": "full", // 既有(parse mode: full/statement_only)
"has_diff": true, // 新增
"diff_summary": { // 新增
"controls": { "changed": 3, "added": 1, "total_with_diff": 4 },
"objectives": { "changed": 5, "added": 2, "total_with_diff": 7 },
"parties": { "changed": 1, "added": 2, "total_with_diff": 3 }
},
"matched_controls": [
{
"control_id": "AC-2",
"current_implementation_description": "系統採用 RBAC...", // 既有
"parsed_implementation_description": "系統採用 ABAC...", // 既有
"default_action": "keep_current", // 新增
"diff_status": "changed", // 新增
"objectives": [
{
"objective_key": "(a)",
"current_description": "...",
"parsed_description": "...",
"default_action": "keep_current", // 新增
"diff_status": "changed" // 新增
}
]
}
],
"parties": [
{
"party_uid": "...", // matched 時 = current 的既有 uid;added 時 = BE synthesize 的暫時 uid(FE 用於 v-for key + confirm payload reference)
"party_type": "person",
"current_values": { // 新增
"name": "王小明",
"email": "ming@old.com",
"role": "系統管理員",
"matched_user_id": 42,
"matched_org_unit_id": null
},
"parsed_values": { // 新增
"name": "王小明",
"email": "ming@new.com",
"role": "系統管理員",
"matched_user_id": 42,
"matched_org_unit_id": null
},
"default_action": "keep_current", // 新增
"diff_status": "changed" // 新增
}
]
}
/ssp-docx-import/<parse_uid>/confirm)action 三值語意(沿用既有 ssp-doc-parser):
"use_docx" — 採用新值寫入 DB"keep_current" — 保留現值(DB 不動)"skip" — 跳過此項(DB 不動,且編輯頁不顯示)對 diff_status="added" 項目(current 為空):
keep_current 跟 skip 行為等價(兩者都不寫入),FE 可只渲染 [採用新值 / 跳過] 兩按鈕,BE 收到任一視為不寫入即可{
"decisions": [
{
"control_id": "AC-2",
"action": "use_docx", // 既有
"objectives": [
{ "objective_key": "(a)", "action": "keep_current" } // 既有
]
}
],
"parties_decisions": [ // 新增
{ "party_uid": "...", "action": "use_docx" }
],
"content_overrides": { ... } // 既有
}
檔案:app/oscal/service/ssp_docx_import_app_service.py
| 方法 | 變更 |
|---|---|
_load_candidates() |
既有,控制條文 + 目標 already 載 current。新增載 current_parties |
_load_current_parties(ssp_uid) |
新方法 — 透過 responsible_party_domain_service 查 SSP 既有 parties |
_compute_default_actions(parse_result) |
新方法 — 依 §3 smart default 邏輯算每項 default_action + diff_status,filter 掉 unchanged / gone |
_match_parties(current_parties, parsed_parties) |
新方法 — 用 (name+email) 模糊比對配對 current ↔︎ parsed,無對應的視為兩筆獨立 |
_build_diff_summary(parse_result) |
新方法 — 統計各 section 的 changed / added / total_with_diff |
confirm_import() |
改 — 接收 parties_decisions,filter parties by action=use_docx 才寫 |
重要修正:
SspWriteStrategy.write_parties()已存在於ssp_write_strategy.py:282(含_upsert_party()/_upsert_responsible_party()helpers),ModuleFrameWriteStrategy.write_parties()也已存在。本 feature 不需新增 strategy method,只需在 app serviceconfirm_import()內依parties_decisionsfilter 後再呼叫既有write_parties()。
(updated 2026-05-07: see §11.5) —
ParsedParty新增target_party_uid欄位,讓 strategy 能用精準 uid 更新已比對到的 party(而非靠 name 查找 — name 有可能已改,造成 append 新 party 的 bug)。(updated 2026-05-07: see §11.12) —
_upsert_responsible_party()對已存在的 link record(同 party_uid + role + context 組合)採 idempotent return(ResponsiblePartyEntity無uid屬性,update path 無法成立)。
| Strategy | 變更 |
|---|---|
SspWriteStrategy.write_parties() |
既有,不改動。Caller (app service) 負責 filter by action 後傳入 |
SspWriteStrategy._upsert_party() |
改動:優先用 target_party_uid(若有)查找,避免 name 改變造成 append |
ModuleFrameWriteStrategy.write_parties() |
既有,不改動 |
SspWriteStrategy.write() / write_objectives() |
既有,行為不變 |
呼叫順序(app service confirm 內):
filtered_controls = filter_by_decisions(parsed_controls, decisions)
strategy.write(filtered_controls, content_overrides) # 既有
filtered_parties = filter_by_parties_decisions(parsed_parties, parties_decisions)
strategy.write_parties(filtered_parties, source_uid, user_id) # 既有domain/grc/responsible_party_domain_service.py — 若無 list_by_ssp(ssp_id),新增;若無此 domain service,新建(含 repo + interface)infra/grc/repository/responsible_party_repo_impl.py — 同上,視既有狀態決定是否新建(updated 2026-05-07: see §11.12) — 實作中確認沿用
BaseRepoImpl.list_by(QueryEntity)standard pattern,不需新增 domain service 層。_load_current_parties內直接用responsible_party_domain_service.list_by()拿到 link records 後,joinPartyEntity(viaparty_uuid)取 name / email 等欄位。
(see §11.7)
DB current parties dict 使用 email 欄位名;parsed parties(from docx parser)使用 email_address。ssp_docx_diff_service.py 的 _get_party_field() helper 統一 normalize 兩個 key name,確保 diff 比對不因欄位名差異誤判。
(see §11.8)
原設計對所有 party 統一比對 5 個欄位(name / email / role / matched_user_id / matched_org_unit_id)。實作後改為 party_type-aware:
person:不比 matched_org_unit_idorganization:不比 matched_user_id避免 person 因 org_unit_id 為 null / organization 因 user_id 為 null 被誤判為有 diff。
(see §11.9)
當 DB 內某條 control 有 N 個 AO,但 docx 這次只帶 M 個(M < N),多出的 N-M 個 AO 不會出現在 parsed result 中,導致 diff 頁漏掉它們。BE 在 annotate 之前新增 _inject_current_objective_descriptions() pass:遍歷 DB objectives,若某個 objective_key 在 parsed result 中不存在,合成一筆 parsed_description="" → 使其出現為 diff_status="gone"。
(see §11.6)
原設計 parties response 只帶 matched_user_id(整數 ID)。實作中發現這是資安問題(暴露內部 ID)且 FE 無法顯示對人名。BE 新增 _enrich_party_user_org_names() 批次查詢:
matched_user_id → 填入 matched_user_nickname / matched_user_login_namematched_org_unit_id → 填入 matched_org_unit_namecurrent parties 和 parsed parties 都會做 enrichment。FE 顯示 ✓ 已連結 ${nickname} (${login_name}) 而非 (user 17)。
(see §11.11)
原設計只考慮 project_ssp source type。實際上 module_frame import 是主要 user flow。_load_current_parties 擴充:當 source_type = 'module_frame' 時,查詢 responsible_party with context_type='module_frame' 和對應 context_id,而非走 SSP 路徑。
current_* 全空、has_diff=false、不顯示 Step 2 → 既有 create flow 完全不變(updated 2026-05-07: see §11.4, §11.10) — Filter 從 5 選項簡化為 3;「已決策」語意為互斥;bulkApply cascade 到 AOs;Steps 用
activeStepprop;responsible-organization不再隱藏。詳見各 updated 標記。
SspDocxImportPage.vue (既有 wrapper,加 stepper progression)
├─ ImportOutline.vue (既有,加 diff_summary 顯示)
├─ Step 1: UploadStep.vue (既有 upload UI)
│
├─ Step 2: DiffResolutionStep.vue ← 【新】
│ │
│ ├─ DiffSectionShell.vue (shared header) ← 【新】
│ │ • title + filter [全部 / 待決策 / 已決策](原設計 5 選項,改為 3;updated 2026-05-07: see §11.4)
│ │ • 批次按鈕 [全部採用新值] / [全部保留現值](含防呆 dialog;bulkApply cascade 到所有 AOs)
│ │ • collapse/expand 該 section
│ │
│ ├─ ControlDiffSection.vue ← 【新】
│ │ • 套 DiffSectionShell
│ │ • for each matched_control with diff_status≠"unchanged"(FE filter,BE 保留全部)
│ │ └─ ControlDiffCard.vue ← 【新】
│ │ • side-by-side 並排(current vs parsed)
│ │ • word-level highlight(FE 用 jsdiff lib 算)
│ │ • 決策 pills [保留現值 / 採用新值 / 跳過]
│ │ • 巢狀 ObjectiveDiffRow.vue(每個 (a)/(b)/(c))
│ │
│ └─ PartiesDiffSection.vue ← 【新】
│ • 套 DiffSectionShell
│ • for each party with diff_status≠"unchanged"
│ └─ PartyDiffCard.vue ← 【新】
│ • side-by-side(current_values vs parsed_values)
│ • field-level highlight(name/role/email/matched_user)
│ • 決策 pills("added" 項目沒「保留現值」,只有採用/跳過)
│ • 顯示所有 role 包含 responsible-organization(updated 2026-05-07: see §11.4)
│
├─ Step 3: ContentAdjustmentStep.vue (既有 ControlImplSection + PartiesSection 重構)
│ • 接收 decisions,filter rendering:
│ - action=skip → 不顯示
│ - action=keep_current → 顯示,值取 current_description(原設計為 readonly;實作改為 inline edit 仍開放,決策決定「寫什麼」而非「能否編輯」)
│ - action=use_docx → 顯示,值取 parsed_description;可 inline 編輯
│ - action=unchanged / null → 顯示原值
│ • 「上一步」保留 decisions 狀態,回 Step 2(若有 diff)或 Step 1
│
└─ Step 4: ConfirmStep.vue (既有)
diff (jsdiff,輕量、ESM、無 dep)diffWords / diffWordsWithSpace(中英文混排夠用)src/utils/textDiff.js,輸出 [{value, added, removed}],ControlDiffCard 跟 PartyDiffCard 共用src/stores/ssp-docx-import.js
(updated 2026-05-07: see §11.4) —
modestate 意義改變:mode是 parse mode(full/statement_only),不再是'create'/'update'。FE 改用hasDiff(computed fromdiff_summary)決定是否需要 Step 2。
state: {
parseUid: null,
hasDiff: false, // computed from diff_summary(updated 2026-05-07)
parseResult: { ... }, // 完整 parse response
decisions: {
controls: { [control_id]: { action, objectives: { [key]: action } } },
parties: { [party_uid]: { action } }
},
contentOverrides: { ... } // inline 編輯結果(同既有)
}
actions: {
loadParseResult(uid),
initDefaultDecisions(), // 從 parse_result.default_action 初始化
setControlDecision(control_id, action),
setObjectiveDecision(control_id, key, action),
setPartyDecision(party_uid, action),
bulkApply(section, action), // 批次操作
filterDiffItems(section, filter), // 篩選器
submit() // 組 confirm payload,POST
}為何用 Pinia:decisions 跨 Step 2 跟 Step 3 元件樹,需要全局狀態。既有 SspDocxImportPage 內 ref 不夠用。
/module-frame/:uid/import-docx (既有,create / update mode 用)
/ssp/:uid/import-docx (既有,update mode 用)
Stepper 內部頁切換用 v-if 而非 vue-router 子路由 — 共用 Pinia state、避免 route 切換 reset state。
ControlImplSection.vue:新增 prop decisions,v-for 過濾 action !== 'skip',v-if action === 'keep_current' → input 改 readonly + badge。約 30 行PartiesSection.vue:同模式,依 parties_decisions 過濾跟鎖定。約 20 行MetadataSection.vue:完全不動依 brainstorming visual 階段確認:
Stepper / InputText / Button / Tag / Dropdown| 情境 | 處理 |
|---|---|
| Parse 過期(>1hr) | Step 2/3 操作時 BE 回 404 → FE 顯示 dialog「Parse 已過期,請重新上傳」→ 導回 Step 1 |
| All decisions = skip | Confirm 前防呆 dialog「您沒有勾選任何要寫入的項目,繼續會等於沒匯入」→ 確認才送出 |
| Browser refresh | Decisions 在 Pinia 是 in-memory,refresh reset。v1 不做 persistence(決策快、refresh 重來成本低)。未來可考慮 localStorage 持久化 by parse_uid |
| 用戶在另分頁手動改 DB(current 已變動) | Diff 頁 current 是 parse 當時 snapshot,不重新拉。接受 stale;confirm 用 @transaction 保證寫入一致性 |
| Unmatched paragraphs(docx 段落沒對到任何控制項) | 不算 diff 範圍,沿用既有 UnmatchedSection 拖拉指派處理 |
| 同一個 SSP 兩個 user 同時 parse | 各自獨立 parse_uid,互不干擾;最後 confirm 覆蓋先 confirm。v1 不做樂觀鎖 |
| 篩選器選「待決策」但已無待決項目 | 顯示空狀態:「太棒了!所有差異都已決策完成」+ 「下一步」按鈕 |
| 某 section 沒任何 diff | Section header collapsed + badge「無差異」 |
current_parties vs parsed_parties 無共同 party_uid |
Match 邏輯:(name+email) 模糊比對;比不到視為兩筆獨立(gone 顯示為 keep_current;added 顯示,user 必選;updated 2026-05-07: see §11.3) |
新增 edge cases(updated 2026-05-07: see §11.10):
| 情境 | 處理 |
|---|---|
PrimeVue Steps prop 名稱 |
PrimeVue 3.53 用 activeStep,舊文件寫 activeIndex(見 §11.10) |
SelectButton 點已選選項 → null |
需加 unselectable="false" prop,否則點同一按鈕回傳 null,破壞決策狀態 |
Dropdown value=null 顯示 placeholder |
PrimeVue 3.x null value 不匹配任何 option,顯示 placeholder 而非 option label。用 sentinel 字串(如 "__inherit__")替代,存檔前轉換回 null |
| Mode 偵測 | BE response mode 值是 full/statement_only(parse mode),不是 create/update。FE 改用 `diff_summary.*.changed > 0 |
沿用既有 ssp-doc-parser 已定義之 error code(避免重複):
| Code | 用途 | 來源 |
|---|---|---|
GRC_DOCX_PARSE_JOB_NOT_FOUND (GRC_404022) |
Parse 任務不存在或已過期 | ssp-doc-parser §5 |
本 feature 新增:
GRC_SSP_PARSE_ALREADY_CONFIRMED = ("此 Parse 已 confirm,請重新上傳", "GRC_409XXX")
GRC_SSP_DECISIONS_INVALID = ("Decisions 包含未知 control_id", "GRC_400XXX")
GRC_SSP_PARTIES_DECISION_INVALID = ("Parties decisions 包含未知 party_uid", "GRC_400XXX")具體序號於 implementation plan 階段依當時最新 GRC error code 序列分配。
@transaction decorator → 全有或全無write_controls() → write_parties()(新)→ (metadata 不在範圍)依既有 import-docx 權限:parse / confirm 必須 manager 角色(在 SSP 所屬 project)。
participant_domain_service.get_one() → 確認 role=manager.strip() 後完全相等才算「相同」(避免空白差異被誤判 diff)compliance-manager-be/test/)| 測試檔 | 範圍 |
|---|---|
test_ssp_docx_import_smart_default.py |
_compute_default_actions() — 4 case:current 空 / 完全相同 / 有差異 / current 有 docx 沒 |
test_ssp_docx_import_diff_summary.py |
_build_diff_summary() 統計正確性 |
test_ssp_docx_import_current_parties.py |
_load_current_parties() 從 DB 撈現有 parties |
test_ssp_write_strategy_parties.py |
write_parties() filter by action、寫入 responsible_party |
test/integration/)| 測試檔 | 範圍 |
|---|---|
test_ssp_docx_import_diff_flow.py |
parse update mode → 驗 response 含 has_diff/default_action → confirm 帶混合 decisions → 驗 DB 只寫 use_docx 項目 |
test_ssp_docx_import_create_no_diff.py |
create 模式 has_diff=false → confirm 全寫入(regression test) |
test_ssp_docx_import_decisions_validation.py |
Decisions 帶未知 control_id / party_uid → 400 with 對應 error code |
compliance-manager-fe/)| 測試 | 範圍 |
|---|---|
ControlDiffCard.spec.js |
給定 current + parsed → 渲染 side-by-side + word-level highlight 正確 |
PartiesDiffSection.spec.js |
篩選器 / 批次按鈕 / decisions 更新 |
ssp-docx-import.store.spec.js |
Pinia store actions:initDefaultDecisions / bulkApply / submit payload 組裝 |
textDiff.spec.js |
jsdiff 包裝層:中英混排、極長字串 fallback |
compliance-manager-test/)依 CLAUDE.md「測試專案路徑」,在獨立 repo 用 Cucumber + Playwright:
Feature: SSP 重新上傳顯示 diff
Background:
Given 我已登入為 manager
And 該 SSP 已存在控制條文 AC-2 (內容: "RBAC機制") 跟 parties [王小明 ming@old.com]
Scenario: 偵測差異並進入 Diff 頁
When 我重新上傳含 AC-2 (內容: "ABAC機制") + 王小明 ming@new.com 的 docx
Then 我會看到 Step 2 Diff 解決頁
And AC-2 卡片顯示 side-by-side,"RBAC" 紅底劃線、"ABAC" 綠底
And 王小明 卡片顯示 email 從 ming@old.com → ming@new.com
Scenario: 預設 keep_current,主動切換才覆蓋
Given 我在 Diff 頁
When 我把 AC-2 切到「採用新值」
And 王小明 維持「保留現值」(預設)
And 點下一步 → 編輯頁 → 確認匯入
Then DB 中 AC-2 是 "ABAC機制"
And DB 中王小明 email 仍是 ming@old.com
Scenario: 批次套用全部採用新值
Given Diff 頁有 5 條控制差異
When 我點「全部採用新值」並確認 dialog
Then 5 條都切到「採用新值」狀態
Scenario: Create 模式跳過 Diff 頁
Given 該 SSP 是空的
When 我上傳 docx
Then 我不會看到 Step 2 Diff 解決頁
And 我直接進到 Step 3 編輯頁
新增檔案位置(依 compliance-manager-test 既有結構):
requirements/ssp-update-diff.mdspecs/ssp-update-diff.featurefeatures/steps/ssp-update-diff.steps.jsfeatures/pages/SspDocxImportDiffPage.js| 層 | 覆蓋率 |
|---|---|
BE 新方法(_compute_default_actions / _load_current_parties / write_parties) |
≥ 90% |
| FE 新元件 | smoke test(render OK + key interactions) |
| E2E | 上述 4 條 scenario |
依 CLAUDE.md feature-development-workflow Phase 4,implementation plan 落地後用 feature-test-planner agent 產 test-plan.md,含 BE pytest + FE Vitest + E2E BDD 完整 case list 跟 traceability matrix。本段只列方向。
_load_current_parties / _compute_default_actions / _build_diff_summary / _match_partiesparties_decisions 處理SspWriteStrategy.write_parties() 實作ssp-docx-import.js storeDiffResolutionStep + 6 個新元件 + textDiff.js utilControlImplSection / PartiesSection 接收 decisions| 決策點 | 結論 | 原因 |
|---|---|---|
| Diff 視覺風格 | A. Side-by-side 並排(左舊右新、紅綠 word highlight) | 長段落最直觀,符合「看得清楚 vs 過去」的心智模型 |
| Diff 範圍 | 1. 控制條文 + 2. 目標 + 4. Parties(不含 metadata) | 使用者確認當前頁面可編輯就這 3 類,metadata 不在 SSP 重新上傳場景 |
| 預設動作 | C. Smart default — 空→use_docx, 同→不顯示, 異→keep_current | 兼顧首次匯入便利性 + 後續更新安全性 |
| 流程模式 | 模式 1 — 兩階段獨立頁(Diff 頁 → 編輯頁) | 強制 user 先審 diff 最防呆,既有編輯頁不大改 |
| Diff 頁出現時機 | 只 update 模式 | Create 模式 current 全空、無比對基準 |
| Parties 排版 | Layout 1 — 每人一張卡 + side-by-side | 跟控制條文視覺一致 |
| 批次操作 | D. 全部套用按鈕 + 篩選器 | 100+ 條 diff 場景下省力,全部套用加防呆 dialog |
| 「DB 有但 docx 沒」處理 | 不顯示、原樣保留 | 從 flow 自然推出(無 diff),不增加複雜度;user 想清理應走編輯流程而非匯入 |
(updated 2026-05-07) — 以下議題均已在實作中解決,記錄結論供追溯:
responsible_party table 確切結構 — ✅ 已確認:沿用 BaseRepoImpl.list_by(QueryEntity) pattern,join PartyEntity via party_uuid,不需新建 domain serviceGRC_400022 / GRC_400023 / GRC_409021_load_current_parties 依 source_type 分支處理(module_frame vs project_ssp);FE 不條件 render,BE 已補足 current partiesdiff package(jsdiff 5.x),效能足夠狀態:✅ 已實作完成(2026-05-07)。詳見 §11 偏差記錄 + docs/conversation-history/2026-05-06-ssp-update-diff/。
本節記錄實作過程中與原始設計(§1–§10)的所有重大偏差及其原因。各節已於內文加 (updated 2026-05-07: see §11.x) 標記,此處提供完整 rationale。
與 changelog 的差異:changelog 說「做了什麼」,本節說「為什麼跟設計不同」。
use_docx 改為 null原始設計:added items 預設 use_docx(自動匯入)。
實際行為:added items 的 default_action = null;user 必須明確選擇才算決策。
原因:預設 use_docx 讓 added items 在 user 沒互動的情況下就被計入「已決策」,導致「待決策」filter 看起來空了但其實 user 沒真正審視。User feedback:改成 null 強制 user 對每個 added item 都主動選一次,防呆語意更清楚。
原始設計:BE filter 掉 unchanged / gone,只回傳有差異的項目。
實際行為:BE annotate 方法保留所有項目(所有 diff_status);FE Step 2 自行 filter unchanged 不顯示;Step 3 使用完整清單。
原因:Step 3(預覽/確認頁)需要完整控制清單才能正確渲染所有 controls + objectives。若 BE 預先 filter,Step 3 只看得到有 diff 的 controls,unchanged controls 就消失了。架構決策:data layer 保持完整,presentation layer 各自 filter。
gone 項目改為 user 可決策(非 silent preserve)原始設計:gone(DB 有但 docx 沒帶)filter 掉,原樣保留不顯示給 user。
實際行為:gone 項目出現在 Step 2,預設 keep_current,user 可切換為 use_docx。
use_docx = 寫入空字串(implementation_description = "",記錄保留、內容清空)use_docx = 移除(unlink) 該 party 在當前 entity 的 link record(oscal_responsible_parties 該 row 刪除)。底層 oscal_parties row 不刪,因為可能被其他 SSP / module_frame 引用。FE pill label 對 parties 用「移除」(不用「採用新值」/「清空」)以反映語意差異。為何兩者語意不同:controls / objectives 是 entity 內的內容欄位,「清空」就是把欄位寫空;parties 是 entity 之間的關聯(透過 junction table),「不再關聯」自然對應 link 刪除。底層實體(party row)的生命週期由其他 entity 共同決定,不是匯入流程能單方面砍的。
Hard delete 限制:oscal_responsible_parties schema 沒 is_active / 無 audit columns,移除即不可復原(v1 接受此限制;如需 undo 需 schema migration)。
Multi-role 行為:一個 party 在同 context 可能有多 role(如同一人是 system-owner + reviewer);user 選「移除」會刪該 party 在該 context 的所有 role link,符合 user 心智模型「移除這個人」而非「移除這個 role binding」。
原因:User feedback:「DB 有 docx 沒帶」這種情況 user 應該有機會決定「保留」還是「清空」,特別是 user 可能希望用新 docx 版本來去掉舊的目標描述。原本 silent preserve 對 user 不透明。Parties 的 unlink path 在 v1.0 shipped 時為 silent drop,2026-05-07 review 後補完整實作(見 changelog tweak-ssp-update-diff-gone-parties-unlink.md)。
原始設計:5 個篩選器(全部 / 已修改 / 新增 / 將清空 / 待決策)。
實際行為:3 個(全部 / 待決策 / 已決策),兩者互斥語意:
default_action(未 user-touch)原因:User feedback:diff_status 維度的篩選太技術性,user 更關心「還有什麼要我決定的 / 我都決定了什麼」的任務進度語意。互斥設計讓 filter 行為更直覺。bulkApply cascade 到 AOs 確保 bulk 操作後整條 control 能算作「已決策」。
target_party_uid 加到 ParsedParty原始設計:_upsert_party() 用 (party_type, name) 查找。
實際行為:diff service 在 match_parties 時,把比對到的 current party 的 uid 存入 ParsedParty.target_party_uid;strategy _upsert_party() 優先用 uid 查找。
原因:當 docx 改了 party 的 name,用 old name 查找會失敗 → _upsert_party() 找不到現有 party → 判斷為新 party → APPEND 一筆而非 UPDATE。加 target_party_uid 讓已比對的 party 能精準 update。未比對的(genuinely new)party target_party_uid = None,走原本 append 路徑。
原始設計:parties response 含 matched_user_id(整數 ID)。
實際行為:BE _enrich_party_user_org_names() 批次查詢後,填入 matched_user_nickname / matched_user_login_name / matched_org_unit_name;current + parsed parties 都做 enrichment。FE 顯示 ✓ 已連結 ${nickname} (${login_name}) 而非 (user 17)。
原因:暴露 internal user_id 是資安問題(雖然前後端同 domain,仍不應 leak);且 FE 無法將 user_id 還原成顯示名,需 BE 提供可讀名稱。
email vs email_address)原始設計:未提及欄位名不對稱問題。
實際行為:DB current parties dict 用 email;docx parser 產出的 parsed parties 用 email_address。ssp_docx_diff_service.py 的 _get_party_field() 統一 normalize 兩個 key name。
原因:DB schema 歷史遺留 email 欄位名;jedi-oscal parser 產出的 intermediate 用 email_address(OSCAL 欄位名)。兩邊都不改,在 diff service 中間層 normalize。
原始設計:統一比對 5 個欄位(name / email / role / matched_user_id / matched_org_unit_id)。
實際行為:person 不比 matched_org_unit_id;organization 不比 matched_user_id。
原因:person 的 matched_org_unit_id 永遠 null(不 applicable);organization 的 matched_user_id 也永遠 null。若統一比,null vs null 不 diff,沒問題;但 null vs 有值 / 有值 vs null 的情況中,錯誤的欄位型態比較可能誤判為 changed。party_type-aware 比較確保不會用錯型態欄位誤判 diff。
gone原始設計:未處理「DB 有 AO 但 docx 沒帶」的情境。
實際行為:BE _inject_current_objective_descriptions() 在 annotate 前做一遍:遍歷 DB AOs,若某 objective_key 在 parsed result 不存在,合成一筆 parsed_description="" → 此 AO 出現為 diff_status="gone",讓 user 可以決定保留或清空。
原因:follow-on from §11.3(gone 也要顯示)。若不合成,docx 沒帶的 AO 完全消失在 diff 頁,user 不知道「有幾個 AO 沒被新 docx 覆蓋到」。
原始設計:未預見 PrimeVue 版本差異問題。
實際踩到的三個雷:
Steps prop 名:PrimeVue 3.53 用 activeStep(接受 index 值);舊文件 / 舊版用 activeIndex。傳錯 prop 不會有 error,只是 stepper 不動。SelectButton 預設 unselectable:點已選的選項會 emit null,破壞 decisions store(null 覆蓋掉之前的 action)。解法:加 unselectable="false"。Dropdown value=null 顯示 placeholder:PrimeVue 3.x Dropdown 當 modelValue=null 時,不匹配任何 option,顯示 placeholder 而非選中的 option label。解法:用 sentinel 字串(如 "__inherit__")替代 null,存檔前 transform 回 null。原始設計:_load_current_parties 只處理 project_ssp source。
實際行為:_load_current_parties 依 source_type 分支:若 module_frame,查詢 responsible_party with context_type='module_frame' + 對應 context_id。
原因:Module_frame import 是比 project_ssp import 更常用的 user flow(用於匯入合規框架模板)。設計階段低估了這個 source 的使用頻率,smoke test 期間 user 即從 module_frame 流程測試,發現 parties 顯示為空。
原始設計(implementation-plan):隱含假設 _load_current_parties 可以從 ResponsiblePartyEntity 直接讀 name / email_address 等欄位。
實際行為:ResponsiblePartyEntity 是 junction table row,只有 (party_uuid, role_id, context_type, context_id) 幾個欄位。完整 party info(name / email / type)在 PartyEntity,需透過 party_uuid join。實作 mirror ModuleFramePartyService 的現有 pattern:先拿 link records,再 batch 查 PartyEntity。
_upsert_responsible_party() 對已存在的 link record(相同 party_uid + role + context 組合)採 idempotent return:ResponsiblePartyEntity 無 uid 欄位,ORM update path 不成立;(party_uid, role_id, context_type, context_id) tuple 本身就是自然 identity,重複 upsert 不需更新任何欄位。
use_docx 是 unlink 語意(review I4 + I10,2026-05-07)原始設計:parties gone + use_docx 在 _filter_parties_for_write silent log + drop(沒實際 delete path),FE Step 2 也直接隱藏 gone parties。導致 user 看不到「DB 有但 docx 沒帶」的 party,無法決定要不要清掉。
實際行為:
PartiesDiffSection 顯示 gone parties,提供「移除 / 保留」兩個 SelectButton(隱藏「採用新值」— gone 沒新值)confirm_import 收到 parties_decisions[].action='use_docx' 且 party 屬 gone → 走 strategy.delete_parties(party_uids) 路徑刪 link record;其他 SSP/module_frame 引用同 oscal_party 不受影響_flattenDiffParties 對 gone+use_docx 的 party 直接過濾(等於要刪掉);keep_current 的 gone party 顯示為 readonly原始設計:v2 adapter 解析失敗(app_service.py 內 try/except)只 logger.warning + 把 parsed_dict 部分欄位(parties / leveraged_services / info_types)留空。User 看 Step 3「沒有 parties」直接按確認 → docx 內容永久遺失。
實際行為:
adapter_status(per-section dict) + adapter_error(per-section user-friendly 訊息對照表)原始設計:3 步 stepper 永遠存在,BE 用 mode === 'update' 判斷是否需 Step 2(已知為錯誤的 mode 比對 — §11.4)。
實際行為:
isCreate 決定:create mode 永遠 2 格(隱藏「解決差異」),update mode 永遠 3 格(即使 docx 無 diff 也佔位)step.value 維持 1/2/3 編碼避免大改 template,新增 activeStepIndex computed mapping完整 review 報告:docs/review/2026-05-07-ssp-update-diff-review.md(5-agent parallel review)。
Critical 6 全部修完(C1-C6):
diffStore.reset() filter 改回 'pending' 回歸 bug → 改回 'all'parties_decisions payload fallback 用 'keep_current' vs store 的 'skip' 不一致 → 統一走 buildConfirmPayload()confirm_import parties 寫入吞所有 DB 錯回傳 success → 移除外層 catch,DB 錯一律 raise_load_current_parties source 不存在 silent 回 [] → 改 raise NotFound + GET 路徑加 _check_permission_filter_parties_for_write annotated=None + decisions 非空 → fallback 寫所有 parties → 改成嚴格按 decisions 走Important 12 處理大宗(I1-I12):narrow bare excepts / module-level logger / docstring tightening / I12 Step 3→Step 1 confirm dialog 等。剩餘 minor 已記錄於 SUMMARY §5。