取代 v1 design(dialog-based + chain confirm hack) 設計討論完成日:2026-05-02 v1 已上線 commit
565e8b5(BE) /1f66640(FE),本 v2 是針對下一輪重構
讓使用者把 SSP docx 範本一鍵建立 / 一鍵匯入到既有資源庫, 不再受 dialog 嵌套限制;解析範圍擴大到 metadata / parties / leveraged services。
| # | 主題 | 決定 | 理由 |
|---|---|---|---|
| 1 | 文件預覽 | iframe /upload-file/<uid>/preview-as-pdf |
既有 endpoint 自動 docx→PDF,零新依賴 |
| 2 | 右側 panel | scrollable Fieldset + 每區獨立 SectionComponent | 跟合規框架預覽 UI 一致 |
| 3 | 左右同步滾動 | ❌ 不做(v1) | 第一版實作成本高,先觀察使用者需求 |
| 4 | 編輯持久化 | BE parse_job.parsed_result JSONB(7 天 TTL)+ FE localStorage(session 防丟) |
BE 7 天足夠;localStorage 防 refresh |
| 5 | 重新上傳 | 新 parse_uid,舊的自然過期 | 簡單;user 確認多次上傳是罕見場景 |
| 6 | UI 流程 | 5-A revised:雙獨立入口(手動 / docx) | 取消 dialog 嵌套,docx 一條龍建立 |
| 7 | 帳號鉤稽 | email 完全相符(同 tenant);soft reference user_id integer |
對齊既有 cross-schema FK 規範 |
| 8 | 未連結 party | 純文字保留(A 案);編輯頁加「參與單位 / 人員」section 補連結 | 不污染 user 表,事後可補 |
| 9 | Org-unit 鉤稽 | name 完全相符;soft reference org_unit_id integer |
同 person 模式 |
| 10 | 多 framework | Parser core + framework adapter 兩層;CMMC 特例走 oscal_props |
v1 只實作 CMMC,但不綁死 |
| 11 | UI 擴充性 | hard-coded Fieldset + SectionComponent 拆乾淨;不做 config-driven | v1 區塊有限,過度抽象不划算 |
| 12 | Storage 擴充性 | 核心走 column;偶爾擴充走 oscal_props;1:N 走新表 |
跟既有 OSCAL 設計對齊 |
[合規資源庫管理 首頁]
├─ 「手動新增」Button
│ → Dialog (3 step: framework + baseline 勾選 + 確認)
│ → 建立空殼 module_frame
│ → redirect to 編輯現況與程序書頁
│
├─ 「從 docx 建立」Button ← 新增
│ → /compliance-resource/import-docx (獨立頁,create mode)
│
└─ [既有資源庫] → 編輯現況與程序書頁
└─ 批次維護 → 「從 docx 匯入」
→ /compliance-resource/<uid>/import-docx (獨立頁,update mode)
[專案規劃頁]
└─ 批次維護 → 「從 docx 匯入」
→ /project/<id>/ssp-import-docx (獨立頁,SSP update mode)
| Mode | route | 可編輯範圍 |
|---|---|---|
| create | /compliance-resource/import-docx |
framework / version / baseline / 全部內容 / parties |
| update (MF) | /compliance-resource/<uid>/import-docx |
controls / AOs / parties;framework + baseline 唯讀 |
| update (SSP) | /project/<id>/ssp-import-docx |
同 update MF,目標寫入 SSP 而非 module_frame |
三條路共用同一個 Vue page component,差別只是 props(mode + 目標 uid)。
┌─ Header ────────────────────────────────────────────┐
│ [從 docx 建立 / 匯入] [重新上傳] [取消] [確認] │
└─────────────────────────────────────────────────────┘
┌─ Splitter ──────────────────────────────────────────┐
│ ┌─ Left (50%) ──────┐ ┌─ Right (50%) ─────────────┐ │
│ │ │ │ │ │
│ │ <iframe> │ │ Fieldset 「基本資料」 │ │
│ │ 原始 docx │ │ <MetadataSection> │ │
│ │ (PDF) │ │ Fieldset 「參與單位/人員」│ │
│ │ │ │ <PartiesSection> │ │
│ │ scrollable │ │ Fieldset 「外部利用服務」 │ │
│ │ │ │ <LeveragedSection> │ │
│ │ │ │ Fieldset 「控制項實作」 │ │
│ │ │ │ <ControlImplSection> │ │
│ │ │ │ Fieldset 「未匹配段落」 │ │
│ │ │ │ <UnmatchedSection> │ │
│ │ │ │ scrollable │ │
│ └───────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
1. 點「從 docx 建立」/「從 docx 匯入」
→ 跳到獨立頁
2. user 上傳 docx
→ BE upload + parse → 拿 parse_uid + parsed_result
→ 左 iframe 顯示 PDF
→ 右各 Section 自動填入 parsed 資料
3. user 編輯任一欄位
→ 立即更新 localStorage[ssp_docx_draft:<parse_uid>]
4. user F5 / 切 tab 回來
→ mount 時從 localStorage 還原編輯狀態
5. user 重新上傳
→ 棄置舊 parse_uid(BE 7 天後過期)+ 清掉 localStorage key
→ 新 parse_uid → 從 step 2 重來
6. user 點「確認」
→ POST confirm with merged payload (BE parsed + localStorage edits)
→ BE 寫入 SSP/MF (含 parties / leveraged / controls / AOs)
→ 清 BE parse_job + FE localStorage
→ redirect to 編輯現況與程序書頁
7. 在編輯頁的「參與單位 / 人員」section 可補連結未連結的 party
┌─ DocxParserCore (既有,純結構抽取,framework-agnostic) ──┐
│ _extract_structure(doc) -> StructureParagraph[]/Table[] │
│ _is_list_item(p), _promote_pseudo_h3, _heading_level │
└──────────────────────────────────────────────────────────┘
↓
StructureBundle (intermediate)
↓
┌─ ISspDocxAdapter (新增 interface) ────────────────────────┐
│ adapt(structure, candidates) -> ParsedSsp │
└──────────────────────────────────────────────────────────┘
↓
┌───────────┴───────────┐
↓ ↓
CmmcSspAdapter (v1) Iso27001SspAdapter (未來)
- 抓 5 個固定角色 - ...
- FCI 類型 → props
- FedRAMP → leveraged
↓
ParsedSsp (OSCAL-aligned schema)
↓
┌─ WriteStrategy (既有,已涵蓋 controls + AOs) ─────────────┐
│ ModuleFrameWriteStrategy / SspWriteStrategy │
│ v2 擴充:寫 parties / leveraged-auth / metadata │
└──────────────────────────────────────────────────────────┘
@dataclass
class ParsedSsp:
metadata: ParsedMetadata
system_characteristics: ParsedSystemCharacteristics
parties: list[ParsedParty]
leveraged_services: list[ParsedLeveragedService]
matched_controls: list[MatchedControl] # 既有
information_types: list[ParsedInformationType]
unmatched_paragraphs: list[UnmatchedParagraph] # 既有
summary: dict
framework_specific_props: dict[str, Any] # CMMC FCI 等特例
@dataclass
class ParsedParty:
name: str
party_type: Literal['person', 'organization']
title: str | None # person 的職稱
email_address: str | None
telephone_number: str | None
address: str | None
role: str # 'system-owner' / 'security-officer' / ...
matched_user_id: int | None # email exact match → user_id
matched_org_unit_id: int | None # name exact match → org_unit_idALTER TABLE oscal.oscal_parties
ADD COLUMN title varchar(255),
ADD COLUMN email_address text,
ADD COLUMN telephone_number varchar(50),
ADD COLUMN address text,
ADD COLUMN user_id integer, -- soft ref to public.members.id
ADD COLUMN org_unit_id integer; -- soft ref to public.org_units.id
CREATE INDEX ix_oscal_parties_email ON oscal.oscal_parties(email_address);
CREATE INDEX ix_oscal_parties_user_id ON oscal.oscal_parties(user_id);
CREATE INDEX ix_oscal_parties_org_unit_id ON oscal.oscal_parties(org_unit_id);
GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.oscal_parties TO cm_app;
user_id/org_unit_id二擇一 nullable:
party_type='person'→ 用user_id,org_unit_id必為 NULLparty_type='organization'→ 用org_unit_id,user_id必為 NULL
ssp_docx_parse_jobs (既有表)
├─ uid
├─ source_type / source_uid (update mode 用)
├─ framework / mode (mode='full' 為 create / 'statement_only' 為 update)
├─ parsed_result JSONB (immutable parser 輸出)
├─ status (awaiting_review / completed / discarded)
├─ created_at / created_user
└─ ...
TTL: 7 天(cron 清 status='awaiting_review' 且 created_at < NOW() - 7d)
oscal_propsoscal_props (既有表,key-value)
├─ name='cmmc:fci-type'
├─ value='合約文件,包括與政府代表進行溝通...'
├─ prop_class='information_type_extension'
└─ ns='http://cmmc.example.com'
oscal_metadata_props (M2M 既有)
→ 連結到對應 OSCAL 主節點
不新增 fci_* 欄位污染 OSCAL 通用 schema。
src/
├─ views/compliance-framework/
│ └─ ImportDocxPage.vue ← 新增!create + update 共用
├─ views/project/
│ └─ ProjectSspImportDocxPage.vue ← create-style export 用 SSP 路徑
├─ components/grc/ssp-docx-import/
│ ├─ sections/ ← v2 新增子目錄
│ │ ├─ MetadataSection.vue
│ │ ├─ PartiesSection.vue
│ │ ├─ LeveragedSection.vue
│ │ ├─ ControlImplSection.vue
│ │ ├─ UnmatchedSection.vue
│ │ └─ PartyLinkDialog.vue ← 補連結 dialog
│ └─ ImportPageHeader.vue ← header bar (按鈕區)
├─ stores/sspDocxImportV2.js ← 重寫 store(捨棄 v1 wizard 邏輯)
└─ composables/useSspDocxDraft.js ← localStorage 同步 helper
新增 tab/section「參與單位 / 人員」:
┌─ Tab 「參與單位 / 人員」──────────────────────────┐
│ │
│ 角色:System Owner │
│ 王大明 (wang@inner.com) ✓ 已連結 [User] [改連結] │
│ │
│ 角色:Security Officer │
│ 李小美 (lee@external.com) ⚠ 未連結 [補連結] │
│ │
│ 角色:Responsible Organization │
│ AIR ASIA Co., Ltd. ⚠ 未連結 [補連結] │
│ │
└────────────────────────────────────────────────────┘
點「補連結」→ PartyLinkDialog:
| 欄位 | 來源 docx | OSCAL 欄位 | 編輯方式 |
|---|---|---|---|
| 文件編號 | 封面 T0 | oscal_props (cmmc:document-serial-no) |
InputText |
| Version | 封面 T0 | oscal_metadatas.version |
InputText |
| Issue Date | 封面 T0 | oscal_metadatas.published |
DatePicker |
| 修訂紀錄 | Appendix 2 T24 | 用 oscal_props 暫存 list(v1) |
唯讀表 |
每個角色顯示:
5 個固定角色(CMMC adapter 抽出):
未來 framework 可不同(adapter 決定)。
T6 + T7 的內容合併顯示:
oscal_props)每行可編輯。
跟現有 PreselectSummary 邏輯類似:
parsed_implementation_description markdownparsed_description跟現有 PreselectSummary 一樣的「疑似漏掉的控制項 + 雜訊計數」分段呈現。
| 欄位 | OSCAL 欄位 |
|---|---|
| System Name | system_security_plans_system_characteristics.name |
| Description | description |
| Sensitivity Level | security_sensitivity_level |
| C / I / A Impact | compliance.information_systems.security_objective_* |
| Authorization Boundary | compliance.information_systems.authorization_boundary |
| Deployment Model | compliance.information_systems.deployment_model |
def match_person(party: ParsedParty, tenant_id: int) -> Optional[int]:
if not party.email_address:
return None
user = users.get_one_by(
email=party.email_address.lower().strip(),
tenant_id=tenant_id, # 同 tenant 才連
is_active=True,
)
return user.id if user else Nonedef match_organization(party: ParsedParty, tenant_id: int) -> Optional[int]:
if not party.name:
return None
org = org_units.get_one_by(
name=party.name.strip(), # exact, case-insensitive 由 DB 端 collation 處理
tenant_id=tenant_id,
)
return org.id if org else None兩者無 fuzzy / partial 比對;找不到就回 None,store 為純文字 party。
metadata_revisions)system_information_types 獨立 1:N 表leveraged_authorizations 獨立表(先用 system_implementation 之 implementation_type + props)system_implementation_components 結構化清單表從 v1 廢棄:
| 項目 | 廢棄原因 |
|---|---|
SspDocxImportDialog.vue |
dialog 模式不再使用 |
PreselectSummary.vue |
整合到新 SectionComponent 內 |
StepUpload.vue / StepPreview.vue / StepResult.vue |
wizard 模式不再使用 |
| ModuleFrame.vue「從 docx 預選」按鈕 | 入口改為合規資源庫管理首頁 |
pendingParseUid / pendingPredictedControls 暫存 |
state 改放 import page 內 |
bindingFrameworkVersionTreeData 防洗 guard |
flow 重新設計後不需要 |
| chain confirm 邏輯 | confirm 直接帶 create payload,BE 一筆 transaction 完成 |
| 風險 | 對策 |
|---|---|
| docx PDF 轉檔失敗(複雜版面) | iframe fallback:onerror 顯示「PDF 預覽不可用,請檢查右側結構化資料」 |
| localStorage 配額用滿 | 一個 parse_uid 一個 key,confirm 後即清;異常 capture 警告 user |
| Adapter 介面設計不夠抽象(未來加 ISO 才發現) | 先寫 CmmcSspAdapter 時刻意挑「最 generic 的 method signature」;ISO 加入時容許小幅 adjust interface |
| Email 鉤稽到別 tenant 的 user | match 強制帶 tenant_id filter |
| 既有 v1 用戶的 module_frame 怎辦 | v2 不影響既有資料,只改新建 / 更新流程;既有 module_frame 仍可正常編輯 |
v2 規劃為 fresh ship:
parse_jobs 表 schema 不變,向後相容;既有資料保留詳細 deployment 流程見 release-notes-v2.md(v2 ship 前產生)。