文件版本:1.1 建立日期:2026-05-03 最後更新:2026-05-04(合併 v2.1 迭代) 文件性質:SD — 系統設計(給後端工程師 / 前端工程師 / DBA) 對應 Phase 0:raw-requirement.md 對應 SA:api-spec.md 對應 FE Spec:frontend-spec.md
| 日期 | 版本 | 變更摘要 | 對應 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 |
把合規框架版本(OSCAL Framework Version)的匯入流程從「Dialog → 上傳 PDF → 一次寫入 catalog/profile/framework_version」改為三階段:
oscal.framework_parse_jobs),不寫 catalog 系列正式表不在範圍:
OscalImportRoute 單階段路徑)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 規範:
@transaction,組合 domain service 與 jedi-oscal 套件BaseRepositoryImpl(session lazy),不在 __init__ 提前讀 sessionoscal.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 cleanupRLS:跟 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;confirm 階段直接寫入既有的 oscal.catalogs / catalog_groups / catalog_controls / catalog_control_assessments / profiles / profile_controls / framework_versions,由 jedi-oscal import_from_dict() 處理。
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)completed 後 is_active=false,但仍保留 row 給 result_framework_version_uid audit trailPOST /parse)target_framework_uid 存在且屬當前 tenanttarget_parent_version_uid:驗證該 version 屬同 frameworktarget_version_uid(v1.1 覆蓋模式):版本存在 + 屬同 framework + publish_status='draft' + 無引用;自動帶入既有版本的 version / release_date / publish_status 到 parse_job 的 target_*parse_job row(status=parsing)jedi_oscal.OscalImportService.parse_pdf_to_dict(file_stream, parser_type)
parsed_result,狀態轉 awaiting_reviewerror_code / error_message,狀態轉 failedparse_uid 與 status,FE 統一導去 /import-version/<uid>,由該頁面依 status 決定渲染預覽或錯誤面板v1.0 → v1.1 變化:版本重名檢查(
(framework_id, version)衝突)從 parse 移到 confirm。原因 — Step 1 改 parser-only 後 version 可能還沒填,無法在 parse 階段預檢。
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):
status='awaiting_review' 且 is_active=true;否則 409target_version_uid 分流(v1.1):
target_version_uid 為 null):
caller payload 或 parse_job.target_* 任一處有值;都沒有 → 400 GRC_FRAMEWORK_PARSE_JOB_METADATA_REQUIRED(framework_id, version) 不衝突(v1.1 從 parse 移到此處)target_version_uid 有值):
parsed_result 移除被標 delete 的 group/control/AOuid 為 key 合併 override 欄位(含 parent_group_uid / group_uid / control_uid 重綁)parent_group_uid / group_uid / control_uid 不能指向已被刪除的 uidjedi_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)parse_job.status='completed' / is_active=false / result_framework_version_uid=<新建/原版本 uid>parse_uid + 當前 user uid)— 不同使用者各自的 draft 互不影響409 GRC_FRAMEWORK_PARSE_JOB_ALREADY_CONFIRMED,FE 顯示「已被 X 確認」並引導重新上傳DB 層另以 UNIQUE(framework_id, version) 擋真正的 race(兩 confirm 同時建版本)。
新增 cron job(core/scheduler.py):每天清一次 is_active=true AND created_at < now() - interval '7 days',將 is_active 設 false、status='expired'。
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)ImportFrameworkVersionPayload DTO取代既有散裝的 dict 參數:
@dataclass
class ImportFrameworkVersionPayload:
framework_uid: str
version: str
release_date: date
publish_status: str
parent_version_uid: Optional[str] = Nonepyproject.toml jedi-oscal pin 改 ==0.0.16poetry update(依 memory: feedback_poetry_update_only)| 模組 | 影響 |
|---|---|
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 若有) |
MAX_CONTENT_LENGTH=50MB(沿用現有 config)target_framework_uid 必須屬當前 tenant(透過既有 framework domain service get_by_uid + RLS 自動隔離)target_parent_version_uid 若帶必須屬同 framework,且本身不為 deprecateduser_uid,不同使用者瀏覽器互不污染error_message 可能含 PDF 解析 stack trace — BE 過濾後僅暴露 user-friendly message,原始 trace 寫 logframework_parse_job.parse_started uid=<u> file_name=<f> parser_type=<p>framework_parse_job.confirmed uid=<u> result_version_uid=<v> deletes=<n_deletions> overrides=<n_overrides>完成後在 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)依各自影響範圍若需拆更細則拆。
舊「更新文件」是單階段 PUT — 直接整本蓋掉,沒 preview,解析失敗或內容不對的話原版本已被破壞。沿用兩階段化的 parse_job,但標示「這次解析要覆蓋到既有版本」。
/parse 帶 target_version_uidtarget_version_uid → 走更新而非新建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;匯入後的合規框架版本(draft)若想微調 group / control / assessment 的文字 / 結構,提供獨立的「編輯版本」頁面(無 staging,每個 patch / delete event 即時 PUT/DELETE BE)。
| 守門條件 | 結果 |
|---|---|
publish_status != 'draft' |
整頁 read-only(連欄位都不能改) |
has_references = true |
readOnly=false 但 allowDelete=false(只允許改文字、禁 cascade delete) |
| 兩者皆 false | 完整可改可刪 |
| 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 |
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| 流程 | Staging | 寫入時機 |
|---|---|---|
| 匯入兩階段 | 有 (parse_job + LocalStorage draft) | confirm 時統一寫 catalog |
| 即時編輯 | 無 | 每個 patch / delete event 即時 PUT/DELETE BE |
選擇即時寫的原因:編輯既有版本通常是小幅微調、不會大量改動、沒有「捨棄」需求;對齊 module_frame default-edit 風格。
合規框架 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 端點。
scripts/python/2026-05-04-backfill-framework-version-file-uid.py:
oscal_framework_versions WHERE file_uid IS NULLstatic/oscal/upload/pdf/<version_uid>.pdf 存在者,呼叫 storage adapter 上傳 → 拿 upload_files.uid → UPDATE framework_versions SET file_uid=...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 設定。