---
title: "週期任務與條件通知 — 設計文件 (FR-137)"
brand: "Guidant AI · **FR-137** 週期任務與條件通知"
eyebrow: "FR-137 · 已上線／收尾 · 2026-10-14"
h1: "任務時間到在同一筆上再跑一輪、掃描自己跑、掃到新的高風險多通知一個人"
subtitle: "資料模型、每 5 分鐘到期排程、掃描自動執行、SBOM 門檻通知與紀錄、支援期提醒"
lede: "專案管理者在規劃頁替任務設週期；下次啟動時間一到，系統就在**同一筆任務**上再跑一輪——已完成的任務重開成進行中、寄信提醒執行人，檢測工具任務開了自動執行就直接開掃，證據與執行紀錄都進同一筆任務。SBOM 掃完若比這筆任務上一次成功掃描多出 high 以上的弱點，就寄給額外指定的人（例如 CRA 的通報聯絡人），每封都留紀錄可查；資訊系統版本支援期剩 90／30／7 天提醒 CRA 專案管理者，記在同一張紀錄表。本文件寫現在的規則、為什麼這樣定、踩到會怎樣。"
chips: [
  {text: "已上線 Done（2026-10-14）", kind: ok},
  {text: "週期任務始終是同一筆，不複製", kind: accent},
  {text: "jedi-flow-engine 1.4.3、jedi-source-scan 0.1.1 已發版；六支 migration 已進出貨基線", kind: accent},
  {text: "門檻通知只做 SBOM", kind: warn},
  {text: "陷阱：多副本無鎖，冪等靠條件式 UPDATE 與紀錄表唯一鍵", kind: crit},
  {text: "陷阱：排程同步跑派掃與寄信，每輪上限 500", kind: crit}
]
footer: "FR-137 · 設計文件 · 2026-10-14 · 收尾：[SUMMARY](handoff/2026-10-14-FR-137-SUMMARY.md) · 討論稿：[discussion.md](discussion.md) · 探脈：[explore-findings.md](explore-findings.md) · 雛形：[prototype/index.html](prototype/index.html) · 支援期提醒輸入：FR-135 [phase3-deferred-to-recurring-tasks-fr](../FR-135-2610-cra-compliance-framework/phase3-deferred-to-recurring-tasks-fr.md) · 沿革見 SUMMARY 與 git log"
---

> 狀態：**已上線／收尾（2026-10-14，全部卡 Done）**｜收尾彙整：[`handoff/2026-10-14-FR-137-SUMMARY.md`](handoff/2026-10-14-FR-137-SUMMARY.md)｜討論稿：[`discussion.md`](discussion.md)｜雛形：[`prototype/index.html`](prototype/index.html)

> 本文件描述上線的現況與決策理由。雛形三頁是設計階段的畫面，週期列文字與 Dialog 內容以 §6.7 為準。檔案路徑會隨程式漂移，查不到時以函式名重新定位。路徑慣例：BE 路徑相對 `compliance-manager-be/`；`pkg/` 代表 `~/Projects/Jedicogy/module/jedi-python-package/`；FE 路徑相對 `compliance-manager-fe/`。

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|---|---|---|
| 2026-10-12 | v1：D1～D19 設計定案；資料模型、排程、自動執行、門檻通知、收件人、畫面、支援期提醒；五卡拆分與端到端驗收 | CM-2721 |
| 2026-10-14 | 收尾：改寫成上線現況——週期任務始終同一筆（`run_count`／`last_run_at`）、排程每 5 分鐘、週期單位含每小時、自動執行直呼派工入口、上一次掃描以同一筆任務為範圍；決策表、§6、拆分與驗收同步 | CM-2714 |

---

## 需求背景與端到端流程 {#background nav="背景"}

稽核裡很多工作不是做一次就好——每月掃一次源碼弱點、每季填一次問卷、每年檢視一次程序書。本案讓任務帶一組週期設定：時間到了，系統在**同一筆任務**上再跑一輪並提醒執行人。對使用者來說一個週期任務就是一張卡，每一輪多一筆執行紀錄、多一批證據，都在同一筆任務裡。

掃描類任務更進一步：開了自動執行，時間到直接開掃，不用人按。檢測工具派給 agent，SBOM 原始碼掃描沿用上次上傳的源碼包。

CRA 要求製造商知悉高風險漏洞要及時處理，負責對外通報的人常常不是執行人。所以 SBOM 掃完時與這筆任務上一次成功的掃描比，**新出現** high（含）以上的弱點時，多寄一封信給指定的人，每封留一筆紀錄，稽核時可拿來證明「知悉後有通知」。同一批老弱點不會每輪重寄。

資訊系統的版本支援期到期前 90／30／7 天提醒，走同一支排程與同一張紀錄表。

```{.mermaid cap="圖 1 — 從設週期到門檻通知"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart TD
    MF[資源庫 frequency] -.建專案時帶預設頻率.-> P[規劃頁任務週期設定<br/>頻率・間隔・下次啟動・自動執行・額外通知]
    P --> T{{每 5 分鐘 tick<br/>撈 next_due_at 已到的任務}}
    T --> S{輪次結案或<br/>專案結束？}
    S -- 是 --> Z[清空 next_due_at，不再啟動]
    S -- 否 --> U[條件式推進：next_due_at 往後推一格<br/>run_count＋1・last_run_at]
    U --> O[任務已完成就重開成進行中]
    O --> K{檢測工具任務且<br/>自動執行開著？}
    K -- 是 --> A[直呼派工入口開掃<br/>執行紀錄 created_user=system]
    K -- 否 --> M
    A --> M[寄信提醒執行人]
    A --> D2[SBOM：沿用上次源碼包排背景掃]
    D2 --> C[掃描完成回呼 on_scan_completed]
    C --> F{與同一筆任務上一次成功掃描比<br/>有新出現 high 以上？}
    F -- 否 --> X[不寄・不記]
    F -- 是 --> R[寄給額外通知對象<br/>＋寫通知紀錄]
```

---

## 分工概述（30 秒版） {#overview nav="概述"}

整案一句話：**任務上多一組週期設定；每 5 分鐘排程把到期的任務在原地重開、推進下次啟動時間並提醒執行人或直接開掃；SBOM 掃完比對同一筆任務上一次成功掃描，新出現 high 以上就寄給額外通知對象並記一筆。**

| 卡 | 做什麼 | 產出 |
|---|---|---|
| **① 週期設定畫面** | 規劃頁任務表單多一行週期摘要＋「設定」開週期 Dialog；任務頁籤有週期的加 ↻；頻率選項進 `system_menus` | migration（任務週期欄、頻率 seed）、jedi-flow-engine ORM 帶欄、寫入驗證、FE 週期 Dialog 子元件、FE 四處頻率改讀 menu |
| **② 到期處理＋提醒執行人** | 排程查到期任務，在同一筆上推進下次啟動、必要時重開，寄信給執行人；輪次結案或專案結束即停 | 排程 job、`task_recurrence_app_service`、任務抽屜週期列 |
| **③ 掃描自動執行** | 自動執行開著的檢測工具任務，到期直接開掃 | `task_auto_execute_service`、抽屜執行紀錄「自動執行」標記 |
| **④ 門檻通知＋紀錄** | SBOM 掃完比上一次，新出現 high 以上就寄給額外通知對象，每封記一筆 | 通知紀錄表、jedi-source-scan 掃完回呼、BE 門檻判斷、專案內「通知紀錄」頁 |
| **⑤ 支援期提醒** | 版本支援期剩 90／30／7 天提醒 CRA 專案管理者，記進同一張紀錄表 | 排程尾端一段、版本→專案反查、紀錄表 `object_type`／`stage` |

---

## 決策 D1～D19 {#decisions nav="決策"}

| 項 | 現行規則 | 理由 | 被排除 |
|---|---|---|---|
| **D1** 重開語意 | 週期到了在**同一筆任務**上再跑一輪：任務已完成就重開成進行中，證據與執行紀錄都進同一筆；`run_count` 記排程已啟動幾次 | 對使用者來說就是同一個任務——設了週期掃描，時間到去掃，證據上傳到同一個任務內、多一個執行紀錄就好；複製出來的新任務使用者不認得，還要另管複製鏈與冪等 | 每輪複製成一筆新任務 |
| **D2** 屬哪一輪 | 任務不變，自然留在所屬的稽核輪次；不另立「週期輪」 | 輪次是稽核範圍的單位，週期是任務層的節奏 | 另立「週期輪」概念 |
| **D3** 週期設定存哪 | 任務層欄位（頻率、間隔、下次啟動、自動執行、額外通知對象）；資源庫 `module_frames.frequency` 只在建專案時帶入預選頻率，不影響既有專案 | 一個評估目標底下會有多筆任務、各自頻率不同；`config_schema` 只到租戶層且平台不讀 | 放評估目標（AO）；放任務型別的 `config_schema` |
| **D4** 到期誰判斷 | BE 排程每 5 分鐘 tick 一支 `task_recurrence_tick` 直接查任務表；不在 jedi-task-platform 加到期掛鉤 | 週期是產品業務規則，比照檢測派掃從平台搬出的原則；週期單位有每小時，檢查間隔要明顯短於最小單位 | 在任務平台申報表加到期／完成掛鉤 |
| **D5** | **作廢**。SBOM 重新執行本來就沿用上次上傳的源碼包，週期自動掃走同一條路 | — | — |
| **D6** 兩種掃描的派工 | 到期判斷都在同一支 tick；開掃直呼派工入口 `detection_orchestration.start_execution`，檢測工具建 `agent_tasks`、SBOM 走伺服器端派工沿用綁定上的源碼包 | 兩條派掃路都已存在且可靠，排程只負責「何時開始」；平台啟動掛鉤排除 upload 模式（SBOM 正是 upload），且只派沒有執行紀錄的任務，週期任務第二輪起一定有紀錄（§6.4） | 為兩種掃描各做排程；走平台啟動掛鉤 |
| **D7** 門檻等級 | SBOM 標準六級，high（含）以上＝CRITICAL、HIGH；檢測工具結果沒有統一等級，不做 | 檢測工具單次結果只有各工具不同鍵的 summary，要先正規化才談得上門檻 | 連檢測工具一起做 |
| **D8** 何時通知 | 與同一筆任務**本次之前最近一筆成功掃描**比 fingerprint 差集，差集裡有 high 以上才通知 | 同一批老弱點每輪都寄等於沒寄；fingerprint 不含版本，升版仍受同一弱點影響不算新的 | 每次掃完有 high 以上就寄 |
| **D9** 通知紀錄範圍 | 紀錄表只記門檻通知與支援期提醒，放 BE 主專案，不動 jedi-notification | 決策者要的是「哪次掃描、什麼等級、何時通知誰」可查；指派／完成信納入要改所有既有寄信點 | 所有寄信都記；放進 jedi-notification |
| **D10** 語系時區 | 信件 zh_Hant_TW、時區 Asia/Taipei | 使用者與租戶都沒有語系／時區欄位，補上是獨立工程 | 本案補使用者／租戶語系時區 |
| **D11** 冪等 | 推進用條件式 UPDATE（`WHERE next_due_at = 舊值`），多副本同時 tick 只有一個推得動；門檻通知與支援期提醒靠紀錄表唯一索引先搶列才寄；承諾單副本，不加分散式鎖 | 既有排程 job 都靠冪等，沒有分散式鎖；條件式 UPDATE 不需要額外的表或鍵 | 分散式鎖 |
| **D12** 頻率選項與閒置欄 | 頻率值進 `system_menus` group `task_frequency`（hourly／daily／weekly／monthly／quarterly／semi_annually／annually 七個 key），FE 四處改讀 menu；`module_frames.last_execute_date`／`next_execute_date` 不啟用；`TASK_TYPE` 五 key 與申報型別對不上一事不處理 | 下拉選項不寫死是專案鐵則；閒置欄語意是範本的執行日，跟任務層週期是兩回事 | 啟用資源庫兩個閒置欄；順手對齊 `TASK_TYPE` |
| **D13** 問卷 | 週期問卷任務沿用同一份問卷快照，不建新快照；重開時**不清**上一輪答案（是否清空待決策者看畫面再裁）；「填完問卷不自動完成任務」不處理 | 任務不複製就沒有新快照可掛；清不清答案牽涉「上一輪的回覆算不算證據」，要看實際畫面再定 | 每輪建空白快照 |
| **通知對象** | 預設＝任務執行人員；檢測工具任務多一個「掃到新出現 high（含）以上時額外通知誰」，可選專案參與人員或系統責任角色（CRA 填通報聯絡人 `vulnerability-contact`） | 執行人是做事的人；對外通報的責任人常常不是執行人 | — |
| **D14** 動兩支套件 | jedi-flow-engine 的 `job_executions` 直接加週期欄；jedi-source-scan 在 SBOM 掃描完成處加 `on_scan_completed` 回呼，由 BE 註冊。已發版 jedi-flow-engine 1.4.3、jedi-source-scan 0.1.1 | 週期是任務本身的資料，放套件不另開 sidecar 表；回呼只影響 SBOM，與 D7 一致，範圍比改 jedi-detection 的匯合點小 | BE 另開一對一任務週期表（sidecar）；改 jedi-detection 匯合點 `_close_group_if_terminal` |
| **D15** 支援期提醒記哪 | 通知紀錄表以 `object_type`／`object_uid`／`stage` 區分種類，支援期 90／30／7 天提醒記在同一張表，唯一索引含這幾欄 | 「寄過了沒」與「何時寄給誰」只有一處可查 | 版本列單欄戳記（三個階段要三欄，且查不到寄給誰） |
| **D16** 通報期限提醒 | 只做支援期提醒；24 小時預警／72 小時通報／最終報告提醒不做 | 起算點是通報案件的「知悉時間」，系統裡沒有這筆資料 | 一併做通報三階段提醒 |
| **D17** 首掃 | 沒有上一次可比的掃描，high 以上全部當新出現、照寄 | 第一次掃描就是第一次知道它們存在 | 首掃不寄 |
| **D18** 頻率 menu group 名 | `system_menus` group＝`task_frequency`，任務與資源庫共用 | 與近期新增的小寫 group（`ssp_party_role`、`risk_reference_standard`）一致 | — |
| **D19** 何時停止 | 任務所在輪次已結案（`project_audit_rounds.status = closed`）或專案已結束（`projects.status` 為 `completed`／`archived`）時，到期不再啟動並清空 `next_due_at`；週期開關關掉同樣清空。每輪按「完成」只代表這一輪做完，到期仍會重開 | 排程是系統動作、繞過規劃期守門，要另設停止條件；結案後再動任務會讓已結案的輪次被改動 | 按完成就整條停（每輪本來就會完成，週期會失效） |

上一輪還沒做完、下一次啟動時間又到了：照樣推進並提醒，任務維持進行中，不會疊出第二筆。

---

## 現況接入點 {#inventory nav="接入點"}

| 元件 | 位置 | 本案怎麼用 |
|---|---|---|
| 任務表 `compliance.job_executions` | ORM 在 `pkg/jedi-flow-engine/jedi_flow_engine/infra/models/job_execution.py`（entity／mapper 同套件） | 加週期欄（§6.1）；寫入只走套件的 `update_recurrence`，不經整筆蓋回的 `update_job_execution` |
| 週期寫入與驗證 | `app/flow_control/service/job_recurrence_service.py`、`job_recurrence_validator.py`；`JobService.update_job` 收 `recurrence` 物件一行轉交 | 規劃頁存檔（卡 ①） |
| 建專案帶預設 | `app/flow_control/service/prep_job_generation_service.py` 以 `recur_unit_from_frequency` 寫 `recur_unit` | 只預選頻率，`next_due_at` 留空 |
| 排程 | `core/scheduler.py` 的 `task_recurrence_tick`（`@_job_scoped`＋`system_context`）；只有 `RUN_MODE=api` 起 | 每 5 分鐘一輪（§6.2） |
| 到期處理 | `app/flow_control/service/task_recurrence_app_service.py`；SQL 在 `infra/flow_control/repository/task_recurrence_query.py`；時間推算 `task_recurrence_schedule.py` | §6.2 |
| 自動執行 | `app/flow_control/service/task_auto_execute_service.py` → `detection_orchestration.start_execution` | §6.4 |
| 任務提醒信 | `notify_user_todo_job`（`app/flow_engine/service/workflow_execution_service.py`，只在任務 PROCESSING 時寄，寄每位指派人） | 每輪沿用 |
| SBOM 掃完 | `pkg/jedi-source-scan/jedi_source_scan/app/jobs/sbom_scan_job.py` 的 `_notify_completed`；契約 `plugin/contract.py` 的 `SourceScanAdapters.on_scan_completed` | 門檻通知觸發點（§6.5） |
| 差異比對 | `pkg/jedi-source-scan/jedi_source_scan/plugin/scan_alert_diff.py` 的 `new_findings(previous_scan_uid, scan_uid)`，配對鍵 fingerprint | 取新出現再濾 CRITICAL／HIGH |
| 門檻通知 | `app/task_alert/service/scan_alert_app_service.py`、`scan_alert_recipients.py`；讀模型 `infra/readmodel/detection/scan_alert_context_query.py` | §6.5、§6.6 |
| 通知紀錄 | `domain/task_alert/`、`infra/task_alert/`、`app/task_alert/service/task_alert_notification_app_service.py`、`api/task_alert/` | §6.1 ②、§6.7 |
| 支援期提醒 | `app/task_alert/service/support_end_reminder_service.py`；讀模型 `infra/readmodel/detection/support_end_query.py` | §6.8 |
| 寄信 | `NotificationService.send_mail_notification`；SMTP 未啟用回 False 不丟例外 | 回 False 記 `failed` |
| Party 與角色 | SSP parties，email 鍵 `email_address`；角色選單 `ssp_party_role` | 收件人解析（§6.6） |
| 頻率選項 | FE `src/composables/useTaskFrequencyOptions.js`；`menuStore.js`、`ModuleFrameBasicInfoPanel.vue`、`SspDocxImportPage.vue`、`SheetPreviewBasic.vue` 讀 menu | D12、D18 |
| 規劃頁 | `src/components/grc/planning/AoTaskSection.vue`（頁籤 ↻）、`AoTaskForm.vue` 一行引用 `TaskRecurrenceSummary.vue` | §6.7 |
| 任務抽屜 | `JobExecutionDrawer.vue` 引用 `src/components/grc/job-drawer/JobRecurrenceRows.vue` | §6.7 |
| 專案概覽 | `src/views/project/ProjectAuditorOverview.vue` 頁首「通知紀錄」；新頁 `ProjectAlertNotificationsView.vue` | §6.7 |

---

## 詳細設計 {#design nav="設計"}

### 6.1 資料模型 {#design-model}

**① `compliance.job_executions` 週期欄**（jedi-flow-engine ORM／entity／mapper 三處帶欄；主線 migration `2026-10-12-fr137-job-recurrence.sql` 與 `2026-10-13-fr137-job-recurrence-same-task.sql`，出貨基線已含最終結構）：

| 欄 | 型別 | 用途 |
|---|---|---|
| `recur_unit` | varchar(32) 可空 | 週期單位，值＝`task_frequency` 的 key；空＝不重複 |
| `recur_interval` | int NOT NULL DEFAULT 1 | 每幾個單位一次（每 2 個月＝monthly＋2） |
| `next_due_at` | timestamptz 可空 | 下次啟動時間；排程只看這欄，空＝停止 |
| `auto_execute` | boolean NOT NULL DEFAULT false | 到了啟動時間是否直接開掃；只有檢測工具任務可設 |
| `alert_recipients` | jsonb 可空 | 新出現 high 以上時額外通知誰：`[{"type":"participant","user_id":12}, {"type":"party_role","role_id":"vulnerability-contact"}]`；只有檢測工具任務可設 |
| `run_count` | int NOT NULL DEFAULT 0 | 排程已在這筆任務上啟動過幾次；畫面顯示「第 `run_count`＋1 輪」 |
| `last_run_at` | timestamptz 可空 | 排程最近一次啟動時間 |

```sql
CREATE INDEX IF NOT EXISTS ix_job_executions_next_due_at
    ON compliance.job_executions (next_due_at) WHERE next_due_at IS NOT NULL;
```

**寫入驗證**（`job_recurrence_validator.py`）：`recur_unit` 必為 `task_frequency` 的 enable key；`recur_interval` ≥ 1；開重複時 `next_due_at` 必填；`auto_execute`／`alert_recipients` 只接受檢測工具任務，其他型別送來回 400；`alert_recipients` 每筆 `type` 限 `participant`（前端送 `user_uid`，必為本專案參與者，存成 `user_id`）或 `party_role`（`role_id` 必在 `ssp_party_role` menu）。錯誤碼 `GRC_400141`～`GRC_400145`（`common/code/flow_control_error_code.py`，`GRC_TASK_RECUR_*`）。

**② 表 `compliance.task_alert_notifications`**（主線 migration `2026-10-12-fr137-task-alert-notifications.sql`、`2026-10-13-fr137-task-alert-notifications-object.sql`）：

| 欄 | 型別 | 用途 |
|---|---|---|
| `id` | bigserial PK | |
| `uid` | varchar(36) NOT NULL UNIQUE | |
| `tenant_id` | int NOT NULL | FK → `public.tenants` ON DELETE CASCADE |
| `org_unit_id` | int | |
| `project_id` | bigint NOT NULL | 專案內頁依此列；FK → `compliance.projects` ON DELETE CASCADE |
| `object_type` | varchar(32) NOT NULL DEFAULT `'scan_alert'` | `scan_alert`（SBOM 門檻）／`support_end`（支援期提醒） |
| `object_uid` | varchar(36) | `support_end`：資訊系統版本 uid（soft-ref） |
| `stage` | varchar(16) | `support_end`：`d90`／`d30`／`d7` |
| `job_execution_id` | bigint 可空 | 哪一筆任務（`scan_alert` 才有） |
| `scan_uid` | varchar(36) 可空 | 哪一次掃描（`scan_alert` 才有） |
| `vs_scan_uid` | varchar(36) 可空 | 跟哪一次比；首掃為空 |
| `new_critical_count`／`new_high_count` | int NOT NULL DEFAULT 0 | 新出現 CRITICAL／HIGH 筆數 |
| `recipient_email` | varchar(320) NOT NULL | 收件信箱（一人一列） |
| `recipient_label` | varchar(255) | 顯示用：參與者暱稱或 Party 名稱＋角色 |
| `sent_at` | timestamptz | 寄出時間 |
| `result` | varchar(16) NOT NULL DEFAULT `'pending'` | `pending`／`sent`／`failed` |
| `error` | text | 失敗原因（例：郵件伺服器未啟用） |
| `created_at`／`updated_at` | timestamptz NOT NULL DEFAULT now() | |

唯一索引 `uq_task_alert_notifications_object_recipient (object_type, COALESCE(scan_uid, object_uid), COALESCE(stage, ''), recipient_email)`（D11、D15）；CHECK `object_type IN ('scan_alert','support_end') AND (scan_uid IS NOT NULL OR object_uid IS NOT NULL)`。RLS 比照 `compliance.source_scans`：ENABLE＋FORCE、一條 FOR ALL policy、GRANT `cm_app` 含 sequence。

**③ 內頁登記**（`2026-10-12-fr137-alert-notifications-route.sql`）：`public.ui_routes` 加 `project-alert-notifications`（`/project/projects/:id/alert-notifications`，pid 掛 `project-auditor-overview`，不上側欄），進入門檻 `ALL grc.project.read`。🔴 name 必須與 FE 路由 name 一字不差，沒登記就誰都進不去。

**④ `public.system_menus` 頻率 seed**（`phase=seed`，`ON CONFLICT ("group", key) DO NOTHING`；`2026-10-12-fr137-task-frequency-menu.sql`、`2026-10-13-fr137-task-frequency-hourly.sql`）：

| group | key | sort |
|---|---|---|
| `task_frequency` | `hourly` | 5 |
| `task_frequency` | `daily` | 10 |
| `task_frequency` | `weekly` | 20 |
| `task_frequency` | `monthly` | 30 |
| `task_frequency` | `quarterly` | 40 |
| `task_frequency` | `semi_annually` | 50 |
| `task_frequency` | `annually` | 60 |

i18n 標籤走 `lang.task_frequency.<key>`（FE `options.json`，zh-tw／en）。資源庫的 `none` 不進 menu：任務上「不重複」由週期開關表示。

### 6.2 到期排程 {#design-scheduler}

`core/scheduler.py` 的 `task_recurrence_tick`：cron 每 5 分鐘（`minute="*/5"`，UTC），`max_instances=1, coalesce=True`，寫法照既有 `@_job_scoped`＋`app.app_context()`＋`system_context`。邏輯本體在 `TaskRecurrenceAppService.run_once()`，支援期提醒（§6.8）接在同一支 tick 尾端。

每輪：

1. **撈到期**（`system_context`）：`next_due_at <= now()` 的 USER 任務，每輪上限 500 筆。**先套停止條件（D19）**：沒有所屬專案、輪次 `closed`、專案 `completed`／`archived` 的，直接清空 `next_due_at` 並記 info log。其餘依租戶分組。
2. **逐租戶進 `tenant_context`**，每筆各包一個 SAVEPOINT（單筆失敗不擋同批，失敗時 `next_due_at` 不動，下一輪再試）：
   - **算下次啟動**：從**本次的 `next_due_at`** 依 `recur_unit`×`recur_interval` 往後推（不從 now() 算，排程延遲不會讓週期漂移）；推完仍 ≤ now() 就繼續推到未來，停擺多格也只跑一輪。月以上單位以「原點＋第 k 格」計算，31 號遇小月取月底，下個大月仍回到 31 號。
   - **條件式推進**：`UPDATE ... SET next_due_at = 新值, run_count = run_count + 1, last_run_at = now() WHERE id = :id AND next_due_at = :舊值`；更新 0 列＝另一副本已推過，本副本跳過。
   - **重開**：任務狀態是 `COMPLETED` 就改回 `PROCESSING`、清 `end_time`；所屬流程實例已完成一併改回 `PROCESSING`。
3. **開掃與提醒**：開了自動執行的檢測工具任務先開掃（§6.4），再以 `force_locale("zh_Hant_TW")` 呼叫 `notify_user_todo_job` 寄信提醒執行人。開掃失敗不影響寄信，寄信失敗不影響已推進的任務。

`next_due_at` 在畫面以 Asia/Taipei 顯示、存 UTC（D10），可選到時分。

### 6.3 為什麼不複製任務 {#design-same-task}

週期任務始終是同一筆：不複製任務、不在流程圖掛新節點、問卷不建新快照。使用者眼中一個週期任務就是一張卡，每一輪的成果是「多一筆執行紀錄、多一批證據」，都在同一筆任務的抽屜裡、依時間排序（證據最新在上）；第幾輪由 `run_count` 表示。複製的做法會長出使用者不認得的任務，還要另外處理複製鏈、唯一鍵、指派／部門／設備／檢測綁定的逐項複製，任一處沒對齊就靜默停掉週期。

### 6.4 掃描自動執行 {#design-auto-execute}

`TaskAutoExecuteService.start(job, tenant_id)`，caller 已在交易內且已進 `tenant_context`：

1. 任務不是 `PROCESSING` 就不開掃、記 warning——還在排隊（上游未完成）的任務不硬轉進行中，那會跳過流程順序。已完成的任務已由 §6.2 先重開。
2. SAVEPOINT 內呼叫 `detection_orchestration.start_execution(job_uid, "system", tenant_id)`。檢測工具建 `agent_tasks`；SBOM 走伺服器端派工，沿用綁定上上次上傳的源碼包排背景掃描。
3. 執行紀錄的 `created_user = "system"`，抽屜據此標「自動執行」。結果與證據進同一筆任務。

**為什麼不走平台啟動掛鉤**：掛鉤挑任務時刻意排除 upload 模式（防首次無檔 400 迴圈），SBOM 正是 upload 模式，走掛鉤永遠派不出去；且掛鉤只派「沒有執行紀錄」的任務，週期任務第二輪起一定有紀錄。直呼派工入口兩個問題都沒有。

派工失敗（例如從未上傳過源碼包）只記 warning，執行人仍收到提醒信，可從抽屜手動執行；Dialog 在檢測工具任務沒有源碼包時提示「需先手動執行一次」。

### 6.5 門檻通知 {#design-alert}

**套件端（jedi-source-scan 0.1.1）**：

- `plugin/contract.py` 的 `SourceScanAdapters` 有選填 `on_scan_completed: Optional[Callable[[str], None]]`（參數＝`scan_uid`）。
- `SbomScanJob` 在掃描完成並轉回檢測結果之後呼叫；回呼失敗只記 `logger.exception`，不會把已完成的掃描改成失敗。
- `plugin/scan_alert_diff.py` 的 `new_findings(previous_scan_uid, scan_uid)` 給宿主用：`previous_scan_uid` 為空＝首掃，這次全部算新出現。

**BE 端**：`core/plugins/source_scan.py` 的 `build_adapters` 註冊 `on_scan_completed`，轉交 `ScanAlertAppService.handle(scan_uid)`（worker 行程、以建單者身分、RLS 照常）：

1. 由掃描找到任務；掃描不是 `done`、任務沒有 `alert_recipients` 或沒有所屬專案就結束。
2. **找上一次**：同一筆任務底下、狀態 `done`、不是本次、完成時間早於本次的掃描中最近一筆；沒有＝首掃（D17）。
3. **取新出現**：`new_findings(上一次, 這一次)` 濾 `severity in ("CRITICAL", "HIGH")`。為空就結束——不寄、不記。
4. **解析收件人**（§6.6），去重後逐人：先搶紀錄列（唯一索引），搶到才寄；搶不到跳過。
5. 寄信：`send_mail_notification(..., is_html=True)`；回 True 記 `sent`，回 False 或丟例外記 `failed`、`error` 寫原因。不重試、不自動補寄。

**不用 `summary.delta`**：`summary.delta` 以掃描對象（`target_uid`）找上一次，任務發起的掃描不建掃描對象，`target_uid` 為空、delta 永遠是空的；照它判斷會每次都當首掃、整批老弱點每輪重寄，違反 D8。

**信件**：主旨與內文 msgid 在 `config/translations/` zh_Hant_TW，排程與背景 job 沒有 request，以 `force_locale("zh_Hant_TW")` 取字串（D10）。內文列：專案、任務名、新出現 CRITICAL／HIGH 筆數、前五筆弱點編號與套件、掃描結果頁連結。使用者輸入的字串一律 `html.escape`。

### 6.6 收件人解析 {#design-recipients}

`app/task_alert/service/scan_alert_recipients.py`：

| `type` | 解析 |
|---|---|
| `participant` | `user_id` → 使用者 email；`recipient_label`＝暱稱（無則登入名）；已不是本專案參與者則略過 |
| `party_role` | 專案 `living_ssp_id` → SSP parties，取該角色已填名字的 Party，每個 Party 的 `email_address` 各一位；`recipient_label`＝`<Party 名>（<角色顯示名>）` |

排程與回呼裡沒有登入者，Party 走 domain service，不呼叫帶 `require_participant` 守門的 app service。同一信箱（不分大小寫）只寄一封。沒有任何可用信箱就記 warning 結束，不寫紀錄。

### 6.7 畫面 {#design-ui}

**規劃頁（卡 ①）**：

- `AoTaskForm.vue`「任務類型」下方一行引用 `TaskRecurrenceSummary.vue`：有週期時顯示 `↻ 每月 · 第 N 輪 · 下次啟動 2026-11-01 09:00 · 自動執行 · 高風險額外通知 2 個對象　[設定]`，沒設時顯示「不重複　[＋ 設定週期]」。
- `TaskRecurrenceDialog.vue`：重複開關、頻率（讀 `task_frequency`）、間隔、下次啟動時間（可選到時分）；檢測工具任務多「自動執行」與「掃到新出現的 high（含）以上時，額外通知」（專案參與人員／系統責任角色，可多筆）；一般與問卷任務底部提示「到了啟動時間，這筆任務會重新開啟並通知指派人員」。沒有歷次列表。
- 「確定」只回寫表單 state，存檔走表單既有「儲存」；BE 在既有 update job 端點收 `recurrence` 物件。
- `AoTaskSection.vue` 頁籤列：有週期的任務名稱後加 ↻。
- 建專案時資源庫 `frequency` 不是 `none` 就寫 `recur_unit`，`next_due_at` 留空（只預選頻率），專案管理者在 Dialog 填下次啟動後才生效。

**任務抽屜（卡 ②③）**：

- `JobRecurrenceRows.vue`：檢測工具任務放在檢測設定區、與工具／掃描模式／完成模式同一個 grid；一般與問卷任務放在描述與指南之後。週期列：`每小時 · 第 N 輪 · 下次啟動 …`（N＝`run_count`＋1）；停掉後 `已停止重複 · 第 N 輪`。檢測工具任務另列自動執行（是／否）與高風險額外通知對象。
- 執行紀錄：`created_user == "system"` 的那筆標「自動執行」，提示「到了啟動時間系統自動開始，沿用上次上傳的源碼包」。
- 證據清單最新在上。

**通知紀錄（卡 ④⑤）**：

- `ProjectAuditorOverview.vue` 頁首「通知紀錄」一顆（所有參與者可見）。
- `ProjectAlertNotificationsView.vue`，路由 name `project-alert-notifications`，有返回鍵；上方三個計數（全部、已寄出、寄送失敗），有失敗時提示郵件伺服器未啟用、失敗的不會自動補寄；篩選：結果、期間。
- 表格欄：時間、掃描（任務名＋源碼包名＋「首次掃描」或「vs 上一次」；支援期列顯示「支援期提醒・<系統名> <版本>・剩 N 天」）、新出現 high 以上（CRITICAL n／HIGH n）、收件人（名稱＋信箱）、結果。
- 端點 `POST /api/1.0/project/<project_uid>/alert-notifications`（分頁，守門：專案參與者），回傳補任務名、掃描源碼包名、支援期列的系統名與版本。

### 6.8 支援期提醒 {#design-support-reminder}

- **排程**：`task_recurrence_tick` 尾端呼叫 `SupportEndReminderService.run_once()`。撈 `support_end_date - today(Asia/Taipei)` 等於 90、30、7 天的版本；tick 一天跑很多次，同一天同一階段靠唯一索引只寄一次。
- **收件人**（`infra/readmodel/detection/support_end_query.py`）：版本 → 資訊系統 uid → `oscal.ssp_inventory_items.props` 的 `ref-id`＝該 uid → SSP → `compliance.projects.living_ssp_id` → 專案，濾框架 `CRA_2024`、未刪除、狀態不是 `completed`／`archived` → 該專案參與者中 role＝manager 的 email。沒掛任何 CRA 專案的版本不寄。
- **紀錄**：寫入 `task_alert_notifications`，`object_type='support_end'`、`object_uid`＝版本 uid、`stage`＝`d90`／`d30`／`d7`、`project_id`＝收件人所屬專案；先搶列才寄，寫紀錄時逐專案切 `tenant_context`。
- 不做：通報三階段期限提醒（D16）。

---

## 拆分 {#breakdown nav="拆分"}

| 卡 | 做什麼 | 動哪些 repo／檔 | migration | 套件 | 狀態 |
|---|---|---|---|---|---|
| **① 週期設定畫面**<br/>[CM-2722](https://app.notion.com/p/FR-137-Dialog-3f7346da4cd0812da7abd1d884b2388e) | §6.1 ①④、§6.7 規劃頁；建專案帶資源庫頻率預設 | pkg：jedi-flow-engine model／entity／mapper、`update_recurrence`；BE：`job_recurrence_service.py`、`job_recurrence_validator.py`、`prep_job_generation_service.py`、error code；FE：`TaskRecurrenceSummary.vue`、`TaskRecurrenceDialog.vue`、`AoTaskForm.vue` 一行、`AoTaskSection.vue` ↻、四處頻率讀 menu、i18n | `fr137-job-recurrence`（active）、`fr137-task-frequency-menu`（seed） | jedi-flow-engine | ✅ Done |
| **② 到期處理＋提醒執行人**<br/>[CM-2723](https://app.notion.com/p/FR-137-3f7346da4cd081edaf6fcd60c70358c6) | §6.2、§6.7 抽屜 | BE：`core/scheduler.py` 一支 job、`task_recurrence_app_service.py`、`task_recurrence_schedule.py`、`task_recurrence_query.py`；FE：`JobRecurrenceRows.vue`、`JobExecutionDrawer.vue` 引用 | — | — | ✅ Done |
| **③ 掃描自動執行**<br/>[CM-2724](https://app.notion.com/p/FR-137-SBOM-3f7346da4cd081eea311f738dcff2335) | §6.4 | BE：`task_auto_execute_service.py`、DI；FE：抽屜執行紀錄「自動執行」 | — | — | ✅ Done |
| **④ 門檻通知＋紀錄**<br/>[CM-2725](https://app.notion.com/p/FR-137-SBOM-CRITICAL-HIGH-3f7346da4cd0813195a4ec8293f43258) | §6.1 ②③、§6.5、§6.6、§6.7 通知紀錄 | pkg：jedi-source-scan `contract.py`、`sbom_scan_job.py`、`scan_alert_diff.py`；BE：`core/plugins/source_scan.py`、`app/task_alert/`（新模組）、`domain/task_alert/`、`infra/task_alert/`、`api/task_alert/`、信件 msgid；FE：`ProjectAuditorOverview.vue` 一顆、`ProjectAlertNotificationsView.vue`、路由、i18n | `fr137-task-alert-notifications`、`fr137-alert-notifications-route`（active） | jedi-source-scan | ✅ Done |
| **⑤ 支援期提醒**<br/>[CM-2726](https://app.notion.com/p/FR-137-90-30-7-CRA-3f7346da4cd081799662da870b0f5123) | §6.1 ②、§6.8 | BE：`support_end_reminder_service.py`、`support_end_query.py`、排程尾端一行；FE：通知紀錄頁支援期列 | `fr137-task-alert-notifications-object`（active） | — | ✅ Done |
| 小卡 CM-2730 | SSP Excel 範本分頁「參與人員」改名「責任人員」，舊名仍可匯入 | BE `sheet_definitions`、parser 別名表 | — | — | ✅ Done |
| 小卡 CM-2731 | 週期單位加每小時、下次啟動可選到時分 | BE 排程間隔；FE Dialog | `fr137-task-frequency-hourly`（seed） | — | ✅ Done |
| 改向 CM-2732 | 週期任務始終同一筆（`run_count`／`last_run_at`、條件式推進、原地重開） | BE `task_recurrence_*`；FE 週期列；pkg jedi-flow-engine | `fr137-job-recurrence-same-task`（active） | jedi-flow-engine | ✅ Done |

另：`2026-10-13-fn-purge-projects.sql`（維運函式 `compliance.purge_projects(ids, dry_run)`，硬刪專案整棵資料樹，預設試算）在本 FR 期間加入，隨同一批進出貨基線。

```{.mermaid cap="圖 2 — 五張卡的依賴（實線＝必須先做）"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
    C1[① 週期設定畫面] --> C2[② 到期處理<br/>＋提醒執行人]
    C2 --> C3[③ 掃描自動執行]
    C1 --> C4[④ 門檻通知＋紀錄]
    C2 --> C5[⑤ 支援期提醒]
    C4 --> C5
```

---

## 端到端驗收 {#acceptance nav="驗收"}

用一個有 SBOM 原始碼掃描任務的 CRA 專案（SSP 已填「漏洞通報聯絡人」Party 與信箱），郵件設定已啟用，批次跑完再一次回報：

1. 規劃頁開該任務 →「設定」→ 開重複、每月、間隔 1、下次啟動填明天 09:00、開自動執行、額外通知加「通報聯絡人」角色與一位參與者 → 確定、儲存；重整後摘要列（`↻ 每月 · 第 1 輪 · 下次啟動 …`）與 Dialog 都還在；頁籤有 ↻。
2. 開始執行任務後，手動執行一次掃描（首掃）→ 兩位收件人各收到一封門檻信；專案概覽頁頂端「通知紀錄」→ 兩列「首次掃描」。
3. 把該任務 `next_due_at` 改成過去時間 → 5 分鐘內：同一筆任務（沒有新任務出現）回到進行中、執行人收到提醒信、抽屜週期列變「第 2 輪」、下次啟動往後推一個月、執行紀錄多一筆「自動執行」且沿用上次源碼包。
4. 該輪用同一份源碼包掃完 → 不寄、通知紀錄頁沒有多列。
5. 抽屜上傳一份多帶一個新 HIGH 的源碼包、按重新執行 → 兩人各收一封、通知紀錄頁多兩列，顯示「vs 上一次」與 HIGH 1。
6. 同一份源碼包再重掃一次 → 不寄、不多列。
7. 證據與執行紀錄全在同一筆任務，最新在上。
8. 把該輪次結案後，再把 `next_due_at` 改成過去 → 5 分鐘後任務沒有被重開，下次啟動已清空、週期列顯示「已停止重複」（D19）。
9. 某資訊系統建一個 30 天後到期的版本、掛進該 CRA 專案 SSP 盤點 → 5 分鐘內專案 manager 收到一封、紀錄頁多一列「支援期提醒・剩 30 天」；之後的 tick 不重寄。
10. CMMC 專案的規劃頁、任務抽屜、專案概覽頁，除了任務表單多一行「不重複　[＋ 設定週期]」與頁首多一顆「通知紀錄」，其餘不變。

---

## 風險與陷阱 {#risks nav="風險"}

::: {.callout .crit}
**🔴 多副本沒有分散式鎖，冪等靠條件式 UPDATE 與紀錄表唯一索引**

排程只在 `RUN_MODE=api` 起，承諾單副本；多個 api 副本同時 tick 時，推進靠 `WHERE next_due_at = 舊值` 只讓一個副本成功，門檻信與支援期提醒靠唯一索引先搶列才寄。兩處都必須「先寫到才往下做」，不能先查再寫。
:::

| 陷阱 | 後果 | 對策 |
|---|---|---|
| 排程同步跑派掃與寄信，跑在共用執行緒池，每輪上限 500 筆 | 到期任務一多，單輪變長、拖到其他排程 | 現量撐得住；「排程只入列、worker 執行」列為另案 |
| `notify_user_todo_job` 只在任務 PROCESSING 時寄 | 還在排隊（上游未完成）的任務到期不會收到信 | 屬預期：排隊中的任務本來就還不能做；上游完成時既有流程會寄 |
| 排隊中的任務開了自動執行 | 本輪不開掃 | 刻意不硬轉進行中，避免跳過流程順序；warning 記 log |
| 任務發起的 SBOM 掃描沒有掃描對象，`summary.delta` 永遠為空 | 每次都被當首掃，老弱點每輪重寄 | 「上一次」以同一筆任務為範圍自己找（§6.5） |
| `next_due_at` 從 now() 起算 | 排程延遲幾分鐘，週期會一路往後漂 | 從本次 `next_due_at` 往後推（§6.2） |
| 週期欄寫入走 `update_job_execution` | 會把狀態、時間等整筆欄位一起蓋回 | 只走套件的 `update_recurrence` |
| 從未上傳過源碼包的 SBOM 任務開了自動執行 | 派工失敗、畫面沒有新的執行紀錄 | 只記 warning、照寄提醒信；Dialog 提示需先手動執行一次 |
| SMTP 未啟用時 `send_mail_notification` 回 False、不丟例外 | 誤記成已寄出 | 回 False 一律記 `failed` |
| 排程裡沒有 request | `gettext` 取不到語系、`get_user_context` 為空 | 信件字串以 `force_locale` 取；操作者寫 `system`；寫入包 `tenant_context` |
| 問卷任務重開時保留上一輪答案 | 執行人可能直接送出舊答案 | 是否清空待決策者看畫面再裁 |
| `AoTaskForm.vue`、`JobExecutionDrawer.vue`、`ProjectPlanningView.vue`、`ProjectAuditorOverview.vue`、`workflow_execution_service.py` 超標或貼線 | 往裡加會被 reviewer 判阻斷 | 只准一行引用，新邏輯一律新開子元件／新檔 |

---

## 不做與後續 {#future nav="後續"}

| 項目 | 去向 |
|---|---|
| 排程只入列、worker 執行（派掃與寄信移出排程執行緒） | 架構優化另案 |
| `core/scheduler.py` 各 job 寫法重構 | 另開卡 |
| 問卷任務重開時是否清空上一輪答案 | 待決策者看畫面再裁 |
| 檢測工具結果的門檻通知（D7） | 檢測結果等級正規化後另案 |
| 使用者或租戶層級的語系、時區（D10） | 獨立工程 |
| `system_menus` `TASK_TYPE` 與申報型別對齊（D12） | 另案 |
| 填完問卷自動完成任務（D13） | 另案；`next_status_on_answer_complete` 已定義、無呼叫端 |
| 指派、完成、專案啟動信納入通知紀錄（D9） | 另案 |
| 通知設定頁（「哪些事件通知誰」的全域設定）；專案首頁的提醒卡 | 另案 |
| 通報案件三階段期限提醒（D16） | 恢復通報案件時再接同一套排程與紀錄表 |
| 多副本分散式鎖（D11） | 多副本部署成為需求時再評估 |
| 「已比對、無新增」也記一筆當證據 | 需要時加 `result='no_new'` |
