合規框架 PDF 匯入兩階段化 — 設計說明書(SD)

文件版本:1.1 建立日期:2026-05-03 最後更新:2026-05-04(合併 v2.1 迭代) 文件性質:SD — 系統設計(給後端工程師 / 前端工程師 / DBA) 對應 Phase 0raw-requirement.md 對應 SAapi-spec.md 對應 FE Specfrontend-spec.md


0. 迭代紀錄

日期 版本 變更摘要 對應 Changelog
2026-05-03 1.0 初版實作 — 4 端點 + staging table + jedi-oscal 0.0.16 拆分 2026-05-03-feat-framework-pdf-import-v2-be.md / 2026-05-03-feat-jedi-oscal-import-service-split.md
2026-05-04 1.1 v2.1 三項迭代:(a) Step 1 parser-only — metadata 移到 Step 2;(b) 重新上傳 PDF 覆蓋既有版本(target_version_uid);(c) 合規框架版本即時編輯 4 端點 + 雙軌守門 2026-05-04-tweak-framework-import-step1-parser-only.md / 2026-05-04-feat-framework-version-reupload-overwrite.md / 2026-05-04-tweak-backfill-framework-version-file-uid.md

1. 範圍

把合規框架版本(OSCAL Framework Version)的匯入流程從「Dialog → 上傳 PDF → 一次寫入 catalog/profile/framework_version」改為三階段:

  1. Step 1(上傳):FE 路由頁面收檔 + 版本 metadata,BE 解析 PDF 後存入 staging table(oscal.framework_parse_jobs),不寫 catalog 系列正式表
  2. Step 2(預覽編輯):FE 拿 staging 資料逐條顯示 group / control / AO,使用者可 Update / Delete / 跨 group 搬移;編輯狀態僅存於 LocalStorage
  3. Confirm:FE 把編輯結果(decisions + overrides)送回 BE,BE 套用後呼叫 jedi-oscal 寫入 catalog/profile/framework_version

不在範圍:

  • Excel 匯入流程(保留現有 OscalImportRoute 單階段路徑)
  • 新增 group/control/AO(僅允許 Update / Delete)
  • Per-control review / 審核 workflow

2. 架構與層級

api/oscal/routes/framework/
└── oscal_framework_parse_job_route.py   ← 新增:四端點

app/oscal/service/
└── oscal_framework_parse_job_service.py ← 新增:app service(@transaction,組合 domain + jedi-oscal)

domain/oscal/service/
└── oscal_framework_parse_job_domain_service.py  ← 新增:domain service

domain/oscal/repository/
└── oscal_framework_parse_job_repo.py    ← 新增:repo interface

infra/oscal/
├── model/oscal_framework_parse_job.py   ← 新增:ORM model
├── mapper/oscal_framework_parse_job_mapper.py
└── repository/oscal_framework_parse_job_repo_impl.py

di_containers/oscal/oscal_containers.py  ← 串接以上 + 注入 jedi-oscal `OscalImportService`

scripts/sql/2026-05-03-oscal-framework-parse-jobs.sql  ← migration

DDD 規範:

  • Route 不查 DB / 不 import ORM model
  • App Service 用 @transaction,組合 domain service 與 jedi-oscal 套件
  • Repo 繼承 BaseRepositoryImpl(session lazy),不在 __init__ 提前讀 session

3. 資料庫 Schema

3.1 新表:oscal.framework_parse_jobs

仿 oscal.ssp_docx_parse_jobs schema,欄位語意對應如下:

欄位 型別 必填 說明
id SERIAL Y PK
uid UUID Y 對外公開 identifier,default gen_random_uuid()
tenant_id INT Y RLS 隔離;同 tenant 共享 parse_job
created_user_id INT Y 上傳者 user.id(不做使用者過濾,僅留 audit)
status VARCHAR(32) Y pending / parsing / awaiting_review / completed / failed / expired
file_name VARCHAR(255) Y 上傳檔名
file_size BIGINT Y 上傳檔案大小(bytes)
parser_type VARCHAR(32) Y PDF 解析器類別(同現有 dialog 的 dropdown 值)
target_framework_uid UUID Y 目標 framework UID(Step 1 metadata)
target_version VARCHAR(64) Y 目標 version 字串
target_release_date DATE Y 發行日期
target_publish_status VARCHAR(32) Y draft / published / deprecated
target_parent_version_uid UUID N 若為子版本
parsed_result JSONB N jedi-oscal parse_pdf_to_dict() 輸出(catalog/groups/controls/assessments 階層)
error_code VARCHAR(32) N 解析失敗時填
error_message TEXT N 解析失敗訊息
result_framework_version_uid UUID N confirm 成功後填回的 framework_version.uid
is_active BOOLEAN Y default TRUE;TTL 過期或 confirm 完成後設 FALSE
created_user VARCHAR(64) Y login_name
updated_user VARCHAR(64) Y login_name
created_at TIMESTAMPTZ Y default now()
updated_at TIMESTAMPTZ Y default now()

Index

  • (tenant_id, is_active, status) — 列當前 tenant 進行中草稿(Q5 banner)
  • (uid) — 對外查詢
  • (created_at) — TTL cleanup

RLS:跟 ssp_docx_parse_jobs 同 policy(依 tenant_id 過濾)

權限授予

GRANT SELECT, INSERT, UPDATE, DELETE ON oscal.framework_parse_jobs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE oscal.framework_parse_jobs_id_seq TO cm_app;

3.2 不新增其他表

confirm 階段直接寫入既有的 oscal.catalogs / catalog_groups / catalog_controls / catalog_control_assessments / profiles / profile_controls / framework_versions,由 jedi-oscal import_from_dict() 處理。

4. 核心商業邏輯

4.1 狀態機(framework_parse_jobs.status

pending ─upload─▶ parsing ─parse OK──▶ awaiting_review ──confirm──▶ completed
                       │                       │
                       │                       └──discard──▶ (is_active=false)
                       │
                       └─parse fail──▶ failed
                                          │
                                          └──TTL 7d──▶ expired (cron)
  • pending 為 enum 預留,目前 parse 是同步 → 直接從 parsing 起算
  • awaiting_review / failed 兩個 terminal-ish 狀態都會經 7 天 TTL → expired(軟刪 is_active=false
  • completedis_active=false,但仍保留 row 給 result_framework_version_uid audit trail

4.2 Parse 流程(POST /parse

  1. App service 驗證 target_framework_uid 存在且屬當前 tenant
  2. 若帶 target_parent_version_uid:驗證該 version 屬同 framework
  3. 若帶 target_version_uid(v1.1 覆蓋模式):版本存在 + 屬同 framework + publish_status='draft' + 無引用;自動帶入既有版本的 version / release_date / publish_status 到 parse_job 的 target_*
  4. Metadata 三欄(version / release_date / publish_status)改為 optional(v1.1)— 若帶值就寫進去,沒帶就 NULL
  5. 建立 parse_job row(status=parsing
  6. 呼叫 jedi_oscal.OscalImportService.parse_pdf_to_dict(file_stream, parser_type)
    • 成功:將 dict 寫入 parsed_result,狀態轉 awaiting_review
    • 失敗:捕捉 exception → 寫入 error_code / error_message,狀態轉 failed
  7. 無論成功失敗都回 200,body 帶 parse_uidstatus,FE 統一導去 /import-version/<uid>,由該頁面依 status 決定渲染預覽或錯誤面板

v1.0 → v1.1 變化:版本重名檢查((framework_id, version) 衝突)從 parse 移到 confirm。原因 — Step 1 改 parser-only 後 version 可能還沒填,無法在 parse 階段預檢。

4.3 Confirm 流程(POST /<uid>/confirm

接收 payload:

{
  "decisions": [
    { "type": "group", "uid": "...", "action": "delete" },
    { "type": "control", "uid": "...", "action": "delete" }
  ],
  "overrides": {
    "groups":   [{ "uid": "...", "name": "...", "description": "...", "order_no": 1, "parent_group_uid": "..." }],
    "controls": [{ "uid": "...", "control_id": "...", "control_title": "...", "description": "...", "guidance": "...", "order_no": 1, "group_uid": "..." }],
    "assessments": [{ "uid": "...", "name": "...", "description": "...", "order_no": 1, "control_uid": "..." }]
  }
}

執行步驟(皆在同一 @transaction):

  1. 取出 parse_job,檢查 status='awaiting_review'is_active=true;否則 409
  2. target_version_uid 分流(v1.1)
    • 新建模式target_version_uid 為 null):
      • Metadata 三欄取 caller payloadparse_job.target_* 任一處有值;都沒有 → 400 GRC_FRAMEWORK_PARSE_JOB_METADATA_REQUIRED
      • 把 caller 帶的 metadata 寫回 parse_job(讓 retry 可一致)
      • 檢查 (framework_id, version) 不衝突(v1.1 從 parse 移到此處)
    • 覆蓋模式target_version_uid 有值):
      • 雙軌守門再驗一次(避免 race):版本仍是 draft + 仍無引用
      • Metadata 三欄忽略 caller 輸入,沿用既有版本
  3. 套用 decisions:從 parsed_result 移除被標 delete 的 group/control/AO
  4. 套用 overrides:對留下來的條目以 uid 為 key 合併 override 欄位(含 parent_group_uid / group_uid / control_uid 重綁)
  5. Validation:剩餘 controls > 0;parent_group_uid / group_uid / control_uid 不能指向已被刪除的 uid
  6. 呼叫 jedi_oscal.OscalImportService.import_from_dict(merged_dict, payload, curr_user, locale, version_uid=<優先 target_version_uid 否則 None>)
    • version_uid=None → 走 _create_version_from_hierarchical(新建)
    • version_uid=<既有> → 走 _update_version_from_hierarchical(版本 uid 不變、舊 catalog 標 DEPRECATED、新 catalog 接到同 framework_version、更新 file_uid)
  7. 寫回 parse_job.status='completed' / is_active=false / result_framework_version_uid=<新建/原版本 uid>
  8. 回傳 framework_version DTO

4.4 同 tenant 共享 / Last-write-wins

  • 任何同 tenant 使用者都看得到、編得到 awaiting_review 的 parse_job
  • 編輯狀態完全在 FE LocalStorage(key 含 parse_uid + 當前 user uid)— 不同使用者各自的 draft 互不影響
  • Confirm 時誰先 commit DB 誰勝;後到的會在 step 1 拿到 409 GRC_FRAMEWORK_PARSE_JOB_ALREADY_CONFIRMED,FE 顯示「已被 X 確認」並引導重新上傳

DB 層另以 UNIQUE(framework_id, version) 擋真正的 race(兩 confirm 同時建版本)。

4.5 TTL 清理

新增 cron job(core/scheduler.py):每天清一次 is_active=true AND created_at < now() - interval '7 days',將 is_activefalsestatus='expired'

5. jedi-oscal 改動(→ 0.0.16)

5.1 拆分 OscalImportService

# jedi_oscal/app/services/oscal_import_service.py

class OscalImportService:
    def parse_pdf_to_dict(
        self,
        file_stream: BinaryIO,
        parser_type: str,
    ) -> dict:
        """純解析,回傳 OSCAL-shaped dict,不寫 DB、不需 session。"""
        ...

    def import_from_dict(
        self,
        oscal_dict: dict,
        payload: ImportFrameworkVersionPayload,  # dataclass
        curr_user: str,
        locale: str = None,
        version_uid: str = None,
    ) -> FrameworkVersionEntity:
        """從 dict 寫 catalog/profile/framework_version。需在 @transaction 內呼叫。"""
        ...

    # Backward compat:保留現有單階段入口給 Excel 路徑與舊 caller
    def import_or_update_oscal_from_pdf(
        self, audit_file_type, file_stream, payload, curr_user, locale, version_uid=None
    ):
        oscal_dict = self.parse_pdf_to_dict(file_stream, audit_file_type)
        return self.import_from_dict(oscal_dict, payload, curr_user, locale, version_uid)

5.2 新增 ImportFrameworkVersionPayload DTO

取代既有散裝的 dict 參數:

@dataclass
class ImportFrameworkVersionPayload:
    framework_uid: str
    version: str
    release_date: date
    publish_status: str
    parent_version_uid: Optional[str] = None

5.3 進版

  • 版本:0.0.15 → 0.0.16
  • BE pyproject.toml jedi-oscal pin 改 ==0.0.16
  • 由使用者跑 poetry update(依 memory: feedback_poetry_update_only)

6. 與既有模組的整合點

模組 影響
OscalImportRoute(單階段 PDF/Excel) 不動,仍受其他流程使用;內部委託 jedi-oscal import_or_update_oscal_from_pdf(已被改為兩段呼叫)
ComplianceFrameworkVersionManage.vue 「匯入版本」按鈕改 router.push;移除 dialog template / handlers
RBAC 系統 新路由 /compliance-framework/import-version 需在 ui_routes / route_capabilities 註冊(依 reference_permission_system_sop)
core/scheduler.py 新增 cron job:framework_parse_job_cleanup(仿既有 ssp_docx_parse_job_cleanup 若有)

7. 安全 / 邊界

  • 上傳檔案大小:Flask MAX_CONTENT_LENGTH=50MB(沿用現有 config)
  • target_framework_uid 必須屬當前 tenant(透過既有 framework domain service get_by_uid + RLS 自動隔離)
  • target_parent_version_uid 若帶必須屬同 framework,且本身不為 deprecated
  • LocalStorage draft:key 含 user_uid,不同使用者瀏覽器互不污染
  • Parse 失敗的 error_message 可能含 PDF 解析 stack trace — BE 過濾後僅暴露 user-friendly message,原始 trace 寫 log

8. 觀測性

  • 每次 parse 寫 INFO log:framework_parse_job.parse_started uid=<u> file_name=<f> parser_type=<p>
  • 失敗寫 ERROR log + Sentry:含 file_size / parser_type / 摘要前 200 字 stack trace
  • Confirm 成功寫 INFO:framework_parse_job.confirmed uid=<u> result_version_uid=<v> deletes=<n_deletions> overrides=<n_overrides>

9. 變更紀錄需求

完成後在 docs/changelog/ 建立至少 3 份:

  • feat-framework-pdf-import-v2-be.md(含 schema migration、jedi-oscal 0.0.16 進版、四個新端點)
  • feat-framework-pdf-import-v2-fe.md(路由、頁面、入口替換)
  • tweak-framework-pdf-import-v2-cleanup-cron.md(TTL cleanup 新 scheduler job)

依各自影響範圍若需拆更細則拆。


10. 重新上傳 PDF 覆蓋流程(v1.1 新增)

10.1 動機

舊「更新文件」是單階段 PUT — 直接整本蓋掉,沒 preview,解析失敗或內容不對的話原版本已被破壞。沿用兩階段化的 parse_job,但標示「這次解析要覆蓋到既有版本」。

10.2 流程

  1. 編輯頁加「重新上傳 PDF」按鈕(draft + 無引用才開放)
  2. 點按鈕 → 選 parser + PDF → BE /parsetarget_version_uid
  3. BE 預檢雙軌守門 + 自動帶入既有版本的 version / release_date / publish_status 到 parse_job
  4. FE 跳到匯入頁 Step 2 預覽 + 編輯(基本資料 Tab 隱藏,header 顯示「重新上傳到 vX.Y」)
  5. Confirm 時 BE 偵測 target_version_uid → 走更新而非新建
  6. 中途任何時候可捨棄 / 離開頁面 — 原版本完全沒動(反悔友善)

10.3 Migration

scripts/sql/2026-05-04-framework-parse-jobs-add-target-version-uid.sql

ALTER TABLE oscal.framework_parse_jobs ADD COLUMN target_version_uid VARCHAR(36) NULL;
CREATE INDEX ... ON oscal.framework_parse_jobs (target_version_uid) WHERE target_version_uid IS NOT NULL;

11. 合規框架版本即時編輯(v1.1 新增)

11.1 範圍

匯入後的合規框架版本(draft)若想微調 group / control / assessment 的文字 / 結構,提供獨立的「編輯版本」頁面(無 staging,每個 patch / delete event 即時 PUT/DELETE BE)。

11.2 雙軌守門

守門條件 結果
publish_status != 'draft' 整頁 read-only(連欄位都不能改)
has_references = true readOnly=falseallowDelete=false(只允許改文字、禁 cascade delete)
兩者皆 false 完整可改可刪

11.3 Endpoints

Method URL 用途
GET /oscal-framework-version/<uid>/catalog-tree 一次取版本 + groups + controls + assessments + has_references
PUT /oscal-catalog-group/<uid> patch group 欄位
DELETE /oscal-catalog-group/<uid> cascade 刪 group + 子 controls + assessments
PUT /oscal-catalog-control/<uid> patch control 欄位
DELETE /oscal-catalog-control/<uid> cascade 刪 control + 子 assessments
PUT /oscal-catalog-control-assessment/<uid> patch assessment 欄位
DELETE /oscal-catalog-control-assessment/<uid> 刪 assessment

11.4 App Service

app/oscal/service/framework_version_edit_service.py

  • get_catalog_tree(uid) — 組合 framework_version + 全部 catalog 條目 + has_references 判斷
  • update_group / delete_group / update_control / delete_control / update_assessment / delete_assessment — 每個 method 進來先做 read 守門檢查 → 再寫
  • has_references 判斷:是否有任何 compliance.module_frames / compliance.profiles 引用此 version

11.5 寫入策略對比

流程 Staging 寫入時機
匯入兩階段 有 (parse_job + LocalStorage draft) confirm 時統一寫 catalog
即時編輯 每個 patch / delete event 即時 PUT/DELETE BE

選擇即時寫的原因:編輯既有版本通常是小幅微調、不會大量改動、沒有「捨棄」需求;對齊 module_frame default-edit 風格。


12. file_uid backfill(v1.1 附帶)

12.1 背景

合規框架 PDF 匯入兩階段化(v2)後,oscal_framework_versions.file_uid 由 jedi-file-upload 的 storage backend 集中管理;舊資料(v1 直接寫流程匯入)只存在 static/oscal/upload/pdf/<version_uid>.pdf,DB 沒有 file_uid。FE 預覽優先 file_uid → fallback legacy 端點。

12.2 一次性 backfill

scripts/python/2026-05-04-backfill-framework-version-file-uid.py

  • 掃描 oscal_framework_versions WHERE file_uid IS NULL
  • 對應 static/oscal/upload/pdf/<version_uid>.pdf 存在者,呼叫 storage adapter 上傳 → 拿 upload_files.uidUPDATE framework_versions SET file_uid=...
  • 找不到 archive 的 row 維持 NULL(FE legacy fallback 路徑仍可用)
  • 冪等:只動 file_uid IS NULL 的 row

12.3 為什麼繞過 create_app()

直接呼叫 core.app_factory.create_app() 會觸發 dependency-injector 的 NonCopyableArgumentError: Couldn't copy keyword argument system_config_domain_service(DI deepcopy 撞到 bidirectional override,已在 memory feedback_di_no_bidirectional_override.md 記錄過類似根因)。

backfill 不需要整個 DI / JWT / Redis / Scheduler,因此只做最小 init:Flask + ConfigUtils + init_db + import jedi_common.session.database.db_mw(載入 RLS hook)+ 直接從 system_configs 讀 storage 設定。