本卡屬 FR-137(母卡 CM-2714),實作卡 ④/5:SBOM 掃完比上一次,新出現 high 以上就寄給額外通知對象並留紀錄;專案內通知紀錄頁。前置:卡 ①(
alert_recipients欄位);可與 ②③ 平行。設計:docs/features/FR-137-2610-recurring-tasks-and-conditional-notify/design.md§6.1 ②、§6.5、§6.6、§6.7 通知紀錄;雛形頁 3docs/features/FR-137-2610-recurring-tasks-and-conditional-notify/prototype/notify-log.html。
SBOM 原始碼掃描跑完後,跟同一個任務的上一次掃描比:如果多出了「上次沒有、這次才有」的 CRITICAL 或 HIGH 弱點,就寄信給任務上設定的額外通知對象(例如 CRA 的通報聯絡人),每封都記一筆。同一批老弱點不重寄。專案概覽頁頂端多一顆「通知紀錄」,點進去看本專案寄過哪些。
jedi_source_scan/plugin/contract.py 的 SourceScanAdapters 加選填 on_scan_completed: Optional[Callable[[str], None]] = None(參數=scan_uid)。jedi_source_scan/app/jobs/sbom_scan_job.py 的 SbomScanJob.run:在 self._steps.complete(job, output) 成功、_forward_to_detection 之後呼叫;包 try/except,失敗只 logger.exception,不影響掃描與轉證據;只在 job.detection_execution_uid 有值時呼叫。版號照 jedi-package-dev skill。core/plugins/source_scan.py 的 build_adapters 填 on_scan_completed=lambda uid: <門檻 service>.handle(uid)。新模組 app/task_alert/:門檻 service app/task_alert/service/scan_alert_app_service.py(@transaction)、列表 app service、route、DI container、config/app_modules.py 註冊。source_scans.detection_execution_uid → compliance.detection_executions.job_execution_uid → 任務;沒有 alert_recipients 就結束。source_job_id 往回找最近一期有 done 的 SBOM 掃描;同一期內若有更早的 done 掃描,以那次優先(重掃比的是上一次)。都沒有=首掃。不要用 summary.delta:任務發起的掃描 target_uid 為空,DEV 4 筆全部 summary.delta 為空,用它會每期都當首掃、老弱點每月重寄(違反 D8)。pkg/jedi-source-scan/jedi_source_scan/app/service/source_scan_diff_service.py 的 diff(上一次 findings, 這一次 findings).new(配對鍵 fingerprint,不含版本),濾 severity in ("CRITICAL","HIGH")。首掃時全部算新出現(D17)。濾完為空就結束:不寄、不記。INSERT ... ON CONFLICT (scan_uid, recipient_email) DO NOTHING RETURNING id,搶到列才寄。send_mail_notification(to, subject, content, is_html=True)(app/notification/service/notification_service.py:75-84);回 True → result='sent';回 False 或例外 → result='failed'、error 寫原因(SMTP 未啟用時它回 False 不丟例外,不可誤記成已寄)。不重試、不補寄。config/translations/ zh_Hant_TW),以 flask_babel.force_locale("zh_Hant_TW") 取字串;內文列專案、任務名+期數、新出現 CRITICAL/HIGH 筆數、前五筆弱點編號與套件、掃描結果頁連結(FE 路由 /source-scan/scans/:uid);使用者輸入字串一律 html.escape。participant:user_id → user_domain_service.get_user_by_ids → email;recipient_label=暱稱;已不是本專案參與者就略過並記 warning。party_role:任務 → 專案 → projects.living_ssp_id → SSP parties,named_parties(parties, role_id) 取已填名字的 Party,各取 email_address;label=<Party 名>(<角色顯示名>)。ssp_party_app_service.list_parties 有 require_participant 守門、排程裡沒登入者,改走其下的 domain service(ssp_service.list_parties(metadata_id))。compliance.task_alert_notifications(主線 migration)id bigserial PK | uid varchar(36) NOT NULL UNIQUE | tenant_id int NOT NULL | org_unit_id int
project_id bigint NOT NULL FK → compliance.projects.id ON DELETE CASCADE
job_execution_id bigint NOT NULL | scan_uid varchar(36) NOT NULL | vs_scan_uid varchar(36) 可空(首掃空)
new_critical_count int NOT NULL DEFAULT 0 | new_high_count int NOT NULL DEFAULT 0
recipient_email varchar(320) NOT NULL | recipient_label varchar(255)
sent_at timestamptz | result varchar(16) NOT NULL DEFAULT 'pending'(pending/sent/failed) | error text
created_at / updated_at timestamptz NOT NULL DEFAULT now()
UNIQUE (scan_uid, recipient_email)