Stage 1:多 AP 基礎建設 — 設計規格

日期:2026-03-24 狀態:Approved

目標

讓現有 GRC API 支援「一個專案 = 多個 AP(Assessment Plan)」,每個 AP 代表一輪稽核。 為 Phase 3-4(外部稽核、矯正)打下基礎。

前置條件

  • DB schema 不需變更:project_assessment_plan_mapping 已支援一個 project 對應多筆 AP
  • 現有 Phase 1-2 功能(job CRUD、evidence、comment、review、participant 等)不受影響

1. 新增 API:AP 列表

端點

GET /api/1.0/grc/project/<project_uid>/assessment-plans/menu

DDD 各層

檔案 新增/修改
Route api/grc/routes/assessment_plan_route.py 新增
Serializer api/grc/serializers/assessment_plan.py 新增
App Service 複用 ProjectService 新增方法 修改
Repo infra/grc/repository/grc_project_repo_impl.py 新增方法 修改
DTO app/grc/dto/assessment_plan_dto.py 新增
Entity domain/grc/entities/grc_assessment_plan_entity.py 新增
Blueprint api/grc/__init__.py 註冊新 route 修改

Response 格式

{
  "code": 1,
  "data": [
    {
      "uid": "string",
      "name": "string",
      "round_number": 1,
      "status": "preparing | in_progress | auditing | remediation | closed",
      "created_at": "2025-03-15T00:00:00"
    }
  ]
}

Repo 查詢邏輯

session.query(OscalAssessmentPlan)
    .join(ProjectAssessmentPlanMapping)
    .join(Project)
    .filter(Project.uid == project_uid)
    .order_by(OscalAssessmentPlan.created_at.desc())
    .all()

round_number 由查詢結果的順序推導(按 created_at 排序後的序號),不需要額外欄位。


2. 調整現有 Route — URL 加 ap_uid

變更規則

所有查詢 AP 控制項結構的 route,URL 從:

/grc/project/<project_uid>/...

改為:

/grc/project/<project_uid>/ap/<ap_uid>/...

需調整的 Route Class 清單

檔案 Resource Class 現有 URL 新 URL
control_group_route.py ProjectControlGroupListResource POST .../control-groups/list POST .../ap/<ap_uid>/control-groups/list
control_group_route.py ProjectControlGroupDetailResource GET .../control-group/<gid> GET .../ap/<ap_uid>/control-group/<gid>
control_route.py ProjectControlListResource POST .../control-group/<gid>/controls/list POST .../ap/<ap_uid>/control-group/<gid>/controls/list
control_route.py ProjectControlDetailResource GET .../control-group/<gid>/control/<cid> GET .../ap/<ap_uid>/control-group/<gid>/control/<cid>
control_route.py ProjectControlWithAoDetailResource GET .../control-group/<gid>/control/<cid>/detail GET .../ap/<ap_uid>/control-group/<gid>/control/<cid>/detail
assessment_object_route.py ProjectAOListResource POST .../assessment-objects/list POST .../ap/<ap_uid>/.../assessment-objects/list
job_route.py ProjectJobListResource POST .../assessment-object/<ao>/jobs/list POST .../ap/<ap_uid>/.../assessment-object/<ao>/jobs/list
job_route.py ProjectJobCreateResource POST .../assessment-object/<ao>/jobs POST .../ap/<ap_uid>/assessment-object/<ao>/jobs
task_setup_route.py ProjectTaskSetupTreeResource GET .../task-setup/tree GET .../ap/<ap_uid>/task-setup/tree
review_route.py ControlReviewResource POST/DELETE .../control/<cid>/review POST/DELETE .../ap/<ap_uid>/control/<cid>/review
review_route.py AoReviewResource POST/DELETE .../control/<cid>/ao/<ao>/review POST/DELETE .../ap/<ap_uid>/control/<cid>/ao/<ao>/review

Route 層改動模式

每個 Resource class 的改動一致:

  1. URL 加 ap/<ap_uid> 路徑段
  2. handler method 簽名加 ap_uid: str 參數
  3. 傳遞 ap_uid 到 service 層
# 舊
class ProjectControlGroupListResource(MethodResource):
    def post(self, project_uid: str, ...):
        result = service.list_control_groups(project_uid=project_uid, ...)

# 新
class ProjectControlGroupListResource(MethodResource):
    def post(self, project_uid: str, ap_uid: str, ...):
        result = service.list_control_groups(ap_uid=ap_uid, ...)

不改的 Route

Route 原因
project_route.py 全部 專案層級,不涉及 AP 結構
job_route.py — Detail/Update/Delete/MyJobs by job_uid 直查
job_comment_route.py by job_execution_uid
dashboard_route.py 後端自動取最新 AP

3. 調整 Service 層

改動模式

所有受影響的 service method 簽名:project_uid 改為 ap_uid

# 舊
def list_control_groups(self, user_id, is_admin, project_uid, pager, sorts, **filters):

# 新
def list_control_groups(self, user_id, is_admin, ap_uid, pager, sorts, **filters):

受影響的 Service 清單

Service 方法
ControlGroupService list_control_groups(), get_control_group()
ControlService list_controls(), get_control(), get_control_with_ao_detail()
AssessmentObjectService list_assessment_objects()
JobService list_jobs(), create_job()
TaskSetupService get_task_setup_tree()
ReviewService mark_control(), unmark_control(), mark_ao(), unmark_ao()
DashboardService get_dashboard_summary() — 內部改為取最新 AP

4. 調整 Repo 層

核心改動

所有 repo 的查詢入口從:

# 舊:從 project_uid 推導 ap_id(假設 1:1)
mapping = session.query(ProjectAssessmentPlanMapping) \
    .join(Project) \
    .filter(Project.uid == project_uid) \
    .first()
ap_id = mapping.assessment_plan_id

改為:

# 新:直接用 ap_uid 查
ap = session.query(OscalAssessmentPlan) \
    .filter(OscalAssessmentPlan.uid == ap_uid) \
    .first()
if not ap:
    raise NotFound(GrcErrorCode.GRC_AP_NOT_FOUND)
ap_id = ap.id

受影響的 Repo 清單

Repo 需改的方法
grc_control_group_repo_impl.py list_control_groups(), get_control_group_by_uid()
grc_control_repo_impl.py list_controls(), get_control_by_uid(), get_control_with_ao_detail()
grc_assessment_object_repo_impl.py list_assessment_objects()
grc_job_repo_impl.py list_jobs(), create_job()
grc_task_setup_repo_impl.py get_task_setup_tree()
grc_review_repo_impl.py add_review(), remove_review()
grc_dashboard_repo_impl.py get_dashboard_summary()

Dashboard 特殊處理

Dashboard 不接收 ap_uid,內部改為取每個 project 最新的 AP:

# 取最新 AP(created_at 最大的那筆)
latest_mapping = session.query(ProjectAssessmentPlanMapping) \
    .filter(ProjectAssessmentPlanMapping.project_id == project_id) \
    .join(OscalAssessmentPlan) \
    .order_by(OscalAssessmentPlan.created_at.desc()) \
    .first()

需新增的 Error Code

# common/code/grc_error_code.py
GRC_AP_NOT_FOUND = ("稽核計畫不存在", "GRC_404015")

5. 向下相容

策略

ap_uid 為 URL path 必填參數,不做 fallback

理由:

  • URL path parameter 不適合做 optional(Flask routing 會衝突)
  • 前端需要明確選擇 AP,不應有隱式行為
  • 現有專案只有一個 AP,前端呼叫 AP menu 後取唯一那筆即可
  • Dashboard 是唯一例外,後端自動取最新

遷移期

舊 URL 保留一段時間(不刪除),但內部一律 fallback 到最新 AP,並 log warning。 前端完成遷移後移除舊 URL。


6. 不改的部分

類別 說明
DB schema project_assessment_plan_mapping 已支援多筆,不需 migration
專案 CRUD project_route.pyProjectServicegrc_project_repo_impl 不動
My Jobs 透過 workflow_execution_uid 直查,不涉及 AP 選擇
Job Detail/Update/Delete by job_uid 直查
Job Comment by job_execution_uid
Job Evidence by job_execution_uid
SSP 編輯 by ssp_uid
Participant CRUD by project_id
DI Container 不需要調整(service 簽名變更不影響 DI)

7. 檔案變更清單

新增檔案

檔案 用途
api/grc/routes/assessment_plan_route.py AP 列表 route
api/grc/serializers/assessment_plan.py AP response schema
app/grc/dto/assessment_plan_dto.py AP DTO
domain/grc/entities/grc_assessment_plan_entity.py AP entity

修改檔案

檔案 改動
api/grc/__init__.py 註冊 AP route
api/grc/routes/control_group_route.py URL + handler 簽名
api/grc/routes/control_route.py URL + handler 簽名
api/grc/routes/assessment_object_route.py URL + handler 簽名
api/grc/routes/job_route.py URL + handler 簽名(list + create)
api/grc/routes/task_setup_route.py URL + handler 簽名
api/grc/routes/review_route.py URL + handler 簽名
app/grc/service/control_group_service.py method 簽名
app/grc/service/control_service.py method 簽名
app/grc/service/assessment_object_service.py method 簽名
app/grc/service/job_service.py method 簽名
app/grc/service/task_setup_service.py method 簽名
app/grc/service/review_service.py method 簽名
app/grc/service/dashboard_service.py 內部取最新 AP
infra/grc/repository/grc_control_group_repo_impl.py 查詢入口
infra/grc/repository/grc_control_repo_impl.py 查詢入口
infra/grc/repository/grc_assessment_object_repo_impl.py 查詢入口
infra/grc/repository/grc_job_repo_impl.py 查詢入口
infra/grc/repository/grc_task_setup_repo_impl.py 查詢入口
infra/grc/repository/grc_review_repo_impl.py 查詢入口
infra/grc/repository/grc_dashboard_repo_impl.py 查詢入口
domain/grc/service/grc_control_group_domain_service.py method 簽名
domain/grc/service/grc_control_domain_service.py method 簽名
domain/grc/service/grc_assessment_object_domain_service.py method 簽名
domain/grc/service/grc_job_domain_service.py method 簽名
domain/grc/service/grc_task_setup_domain_service.py method 簽名
domain/grc/service/grc_review_domain_service.py method 簽名
domain/grc/service/grc_dashboard_domain_service.py method 簽名
common/code/grc_error_code.py 新增 GRC_AP_NOT_FOUND