NOTIFY_CONFIG — Per-Tenant 通知設定 Design Spec

項目 內容
Date 2026-04-27
Author raymond + Claude
Status Draft → 待 user 確認後進實作計劃
範圍 Backend + Frontend,僅 NOTIFY_CONFIG 一個子系統
不在範圍 SECURITY_CONFIG / DRIVE_CONFIG / AI_PROVIDER / BRANDING(各自獨立 spec 後續處理)
Revisions v1 (initial) → v2 (thread-local / SQL schema / capability decorator absence / caller list) → v3 (round 2 reviewer: batch suppress 真實呼叫鏈、test endpoint admin role、ui_routes idempotent、envelope 對齊 codebase、刪除 ContextVar 方案 B)

1. Why — 為什麼要做

1.1 驅動力

  1. 客戶反映 / 多租戶痛點(Driver A).envTELEGRAM_BOT_TOKEN / DISCORD_WEBHOOK_URL 為單租戶設計,多客戶共用部署時 A 客戶的審計通知會打到 B 客戶的頻道。
  2. Multi-tenant SaaS roadmap(Driver B)ENABLE_MULTI_TENANT=true 已啟用,但通知層仍走 os.getenv,是 multi-tenant 設計漏洞。

1.2 額外效益

  • 消除 onboarding 摩擦
  • 將 secret 從 .env 移至 system_configs + 既有 HIDDEN_SECRET 隱藏機制
  • 建立「.envsystem_configs UI」的 SOP

1.3 既有 infra 已就位

  • system_configs 表 + TenantScopedMixinModel + RLS 自動處理租戶隔離
  • HIDDEN_SECRET + changePwd 慣例(api/system_config/routes/system_config_route.py:18
  • 4 個既有設定頁範本可抄

2. Scope

2.1 In Scope

  • 後端
    • system_configs 兩筆 row:NOTIFY_CONFIG/DISCORDNOTIFY_CONFIG/TELEGRAM
    • app/notification/service/notification_service.py 改寫(移除 os.getenv,改讀 SystemConfigService,且支援 caller 預解析 cfg 後傳入以避開 thread-local 問題)
    • 新模組 api/notify_config/ + app/notify_config/ 提供 test 端點
    • 5 個 caller 清理(移除 SEND_NOTIFY_MODULES + os.environ 騷操作 — 完整清單見 §4.2)
    • 新增 error code
    • logger.warning 加在「靜默 skip」路徑(保留可觀測性)
  • 前端
    • 新 view src/views/notify-config/NotifyConfigForm.vue
    • i18n / router / api.js 對應更新
  • Migration:ui_routes / capabilities / route_capabilities / role_capabilities 四表的 INSERT,依既有 2026-04-27-add-audit-and-cloud-integration-permissions.sql 慣例與實際 DB schema
  • 測試:pytest unit + 整合 + 手動驗收

2.2 Out of Scope(YAGNI / 留紀錄)

  • ❌ MAIL channel 加入 NOTIFY_CONFIG(既有 SMTP/* 已 per-tenant、預設都要開、SMTP 設定頁已有測試功能)
  • system_config_route 缺 capability check 的技術債
  • ❌ 通知歷史紀錄頁
  • ❌ Per-user 通知偏好
  • ❌ Channel template 客製
  • ❌ 多個 Discord / Telegram channel
  • ❌ Test endpoint 打真網路的 contract test
  • ❌ Load test
  • ❌ E2E 測試(屬 compliance-manager-test/
  • 既有 SMTP/ 在 thread fan-out 下的 thread-local user_context 問題*(與本次相同模式但本就在 multi-tenant 場景下有相同 bug — 單獨 PR 處理,本次只新增 channel 不擴大破口)
  • ❌ Notification dispatcher refactor

3. Data Schema

3.1 兩筆 row(per-tenant)

NOTIFY_CONFIG / DISCORD
  value: {"enabled": bool, "secret": "<webhook_url>"}

NOTIFY_CONFIG / TELEGRAM
  value: {"enabled": bool, "chat_id": "...", "secret": "<bot_token>"}

3.2 設計重點

  • secret 沿用既有命名慣例
  • Discord webhook URL 視為 secret(含可寄訊息的 token)
  • Telegram chat_id 公開可見,獨立放外層

3.3 Fallback

依「比照 .env 的做法」:缺 row → 該 channel 直接 skip。不寫 seed data、不寫 tenant_id IS NULL 全系統預設(避免跨 tenant 共用通道)。

3.4 Secret 隱藏

api/system_config/routes/system_config_route.py:18HIDDEN_SECRET 名單加 'NOTIFY_CONFIG'

⚠️ 實作前 grep 確認沒有其他繞過 HIDDEN_SECRET 的讀取路徑

grep -rE "system_config.*get|SystemConfig.*from_entity" \
  api/ app/ --include="*.py"

若發現有額外 endpoint 直接序列化 value JSONB 出去(未過 HIDDEN_SECRET),須一併補 hide 邏輯或在那條路徑加 NOTIFY_CONFIG 排除。


4. Service Layer

4.1 app/notification/service/notification_service.py — 重寫

既有問題

class NotificationService:
    def __init__(self, system_config_service):
        # ❌ os.getenv 在 __init__ 跑一次後 cache 在 instance 上
        self.telegram_bot_token = os.getenv("TELEGRAM_BOT_TOKEN", None)
        self.telegram_chat_id = os.getenv("TELEGRAM_CHAT_ID", None)
        self.discord_webhook_url = os.getenv("DISCORD_WEBHOOK_URL", None)
        self.system_config_service = system_config_service

Thread-local 重要前提

auth_contextcontextvars.ContextVarjedi-common/jedi_common/session/auth/auth_context.py:7)。threading.Thread 預設不繼承 ContextVar——子 thread get_user_context()None

→ 既有 caller 5 處全部用 threading.Thread(target=notification_service.send_xxx_notification).start() fan-out。如果 send_* 內部直接從 user_context 讀 tenant config,子 thread 一律拿到 None

解法:caller 在主 thread 預先解析 cfg,作為 thread kwargs 傳入

from jedi_common.session.auth.auth_context import get_user_context
from jedi_common.handler.exception import ServerError, NotFound
from common.code.error_code import ErrorCode

class NotificationService:
    def __init__(self, system_config_service: SystemConfigService):
        self.system_config_service = system_config_service

    def get_channel_config(self, channel: str) -> dict | None:
        """讀當前 tenant 的 NOTIFY_CONFIG/<channel>。
        Caller 必須在 user_context 內呼叫(通常是主 request thread)。
        Thread fan-out 場景:caller 應在主 thread 先呼叫此 method 拿 cfg dict,
        再把 cfg 作為 kwarg 傳入 send_xxx_notification 的 thread target。
        """
        if get_user_context() is None:
            # ⚠️ jedi_common 沒有 InternalServerError 子類,直接用 base ServerError(message, error_code, status_code)
            raise RuntimeError(
                f"NOTIFY_CONFIG read requires user_context (channel={channel})"
            )  # jedi-handler @app.errorhandler(Exception) fallback → 500
        try:
            cfg = self.system_config_service.get_system_config_by_key(
                "NOTIFY_CONFIG", channel
            )
            return cfg.value if cfg else None
        except NotFound:
            return None

    def send_mail_notification(self, to, subject, content, is_html=False):
        """MAIL 不在 NOTIFY_CONFIG 範圍 — 維持既有 SMTP/* 邏輯。
        ⚠️ 既有 SMTP/* 也有相同 thread-local 問題(thread 內 get_system_config_by_key
        會走 super_admin RLS bypass),multi-tenant 下會撈到任意 tenant 的 SMTP — 既存 bug,OOS。
        """
        # ... 既有實作不動

    def send_discord_notification(self, message, cfg: dict | None = None):
        """cfg:caller 在主 thread 預解析後傳入;省略則嘗試從 user_context 讀(同步呼叫)。"""
        if cfg is None:
            cfg = self.get_channel_config("DISCORD")
        if not cfg or not cfg.get("enabled") or not cfg.get("secret"):
            logger.warning(
                "Discord notification skipped: "
                "channel disabled or missing config (tenant_id=%s)",
                self._tenant_id_for_log(),
            )
            return None
        config = DiscordConfigDTO(webhook_url=cfg["secret"])
        Notifier("DISCORD", config.dict()).send_notification(
            DiscordRequestDTO(content=message)
        )

    def send_telegram_notification(self, content, cfg: dict | None = None):
        if cfg is None:
            cfg = self.get_channel_config("TELEGRAM")
        if (not cfg or not cfg.get("enabled")
                or not cfg.get("secret") or not cfg.get("chat_id")):
            logger.warning(
                "Telegram notification skipped: "
                "channel disabled or missing config (tenant_id=%s)",
                self._tenant_id_for_log(),
            )
            return None
        config = TelegramConfigDTO(
            bot_token=cfg["secret"],
            chat_id=cfg["chat_id"],
        )
        Notifier("TELEGRAM", config.dict()).send_notification(
            TelegramRequestDTO(message=content)
        )

    def _tenant_id_for_log(self) -> str:
        """log 用 tenant_id(拿不到回 'unknown',不 raise)。"""
        try:
            ctx = get_user_context()
            return str(ctx.tenant_id) if ctx else "unknown(thread)"
        except Exception:
            return "unknown"

行為變更

場景 改造前 改造後
Discord/Telegram 缺設定 raise NOTIFICATION_*_CONFIG_NOT_FOUND return None + logger.warning
改設定後 要重啟 BE 立刻生效
多租戶 全系統共用 .env 一組 各 tenant 獨立
Thread fan-out 直接從 .env 讀(無 tenant 概念) caller 主 thread 預解析 cfg、kwarg 傳入
主 thread 缺 user_context 不檢查 → 跨租戶 leak raise NOTIFICATION_NO_TENANT_CONTEXT

→ raise 改 return None+log 是因為 caller 用 Thread 平行送,單一 channel 缺設定不該炸事件處理,但要留 log 線索。

4.2 5 個 Caller 清理

檔案 改動 cfg 預解析放置點
workflow_execution_service.py notify_users_batch_assigned 1041- 移除 os.getenv + Thread kwargs cfg user 迴圈前(method 開頭)
workflow_execution_service.py notify_user_todo_job 1086- 同上 JobStatus.PROCESSING + ProjectStatus.IN_PROGRESS short-circuit 通過後、assignees 迴圈前
workflow_execution_service.py notify_control_reviewers_on_task_complete 1162- 同上 reviewer filter 完成、確定有對象後
workflow_execution_service.py complete_job 572 suppress_notify 參數;if not suppress_notify: notify_user_todo_job(...)
workflow_execution_service.py revert_job 635 complete_job 加參數
app/grc/service/project_service.py 644 同上 fan-out 模式 method 開頭
app/task_survey/service/task_survey_service.py 444 同上;保留 assignees 為空 early return early return 後再預解析
app/grc/service/job_batch_complete_service.py 38-40 / 87-90 拔掉 os.environ["SEND_NOTIFY_MODULES"] = "" 騷操作;改傳 suppress_notify=Truecomplete_job
app/grc/service/job_batch_complete_service.py _send_batch_summary_notification 108- / 116 同上 fan-out 模式 method 開頭

Caller 改造範例(以 workflow_execution_service.py 1041- 為例)

@transaction
def notify_users_batch_assigned(self, user_job_counts: list) -> bool:
    # 主 thread(仍在 @transaction + user_context 內)預解析三 channel
    discord_cfg = self.notification_service.get_channel_config("DISCORD")
    telegram_cfg = self.notification_service.get_channel_config("TELEGRAM")
    # MAIL 不需要預解析(既有 SMTP/* 邏輯不變)

    system_name = os.getenv("SYSTEM_NAME", "Guidant.AI")
    for user, count in user_job_counts:
        subject = _("batch_task_notification_subject") % {"system": system_name, "count": count}
        message = _("batch_task_notification_content") % {
            "username": user.nickname,
            "count": count,
        }

        # MAIL 永遠送(依比照 .env 的決策)
        threading.Thread(
            target=self.notification_service.send_mail_notification,
            kwargs={
                "to": user.email,
                "subject": subject,
                "content": message,
                "is_html": True,
            },
        ).start()

        if discord_cfg and discord_cfg.get("enabled"):
            threading.Thread(
                target=self.notification_service.send_discord_notification,
                kwargs={
                    "message": message.replace("<br>", "\n"),
                    "cfg": discord_cfg,    # ← 重點:cfg 跨 thread 傳入
                },
            ).start()

        if telegram_cfg and telegram_cfg.get("enabled"):
            threading.Thread(
                target=self.notification_service.send_telegram_notification,
                kwargs={
                    "content": message.replace("<br>", "\n"),
                    "cfg": telegram_cfg,
                },
            ).start()

    return True

→ 三個 fan-out method 採同樣模式改寫。

4.2.1 job_batch_complete_service 批次 suppress 替代設計

既有行為:批次完成期間用 os.environ["SEND_NOTIFY_MODULES"] = "" 暫停逐筆通知,最後統一送一次彙總。

真實呼叫鏈(已 grep 驗證)

JobBatchCompleteService.batch_complete:60
  → self._wf_svc.complete_job(...)
       (workflow_execution_service.py)
       └── 內部 line 572 同步呼叫 self.notify_user_todo_job(...)
       └── revert_job:635 也同步呼叫
  + participant/task_assignee_service.py:160 也呼叫 notify_user_todo_job

→ batch 那層直接呼叫 complete_job,不是 notify_user_todo_job。Suppress 必須穿透 complete_job signature 才能傳達意圖。

改造方案:caller 端 suppress_notify 顯式參數

# workflow_execution_service.py 改 complete_job 與 revert_job signature
@transaction
def complete_job(
    self,
    workflow_execution_uid,
    job_id,
    user,
    comment,
    user_nickname,
    suppress_notify: bool = False,   # ← 新增(default False 保留既有行為)
):
    ...
    # line 572 改成
    if not suppress_notify:
        self.notify_user_todo_job(job_execution.id)

@transaction
def revert_job(
    self, workflow_execution_uid, job_id, revert_to_job_id, comment, user, user_nickname,
    suppress_notify: bool = False,
):
    ...
    if not suppress_notify:
        self.notify_user_todo_job(revert_job_execution.id)
# job_batch_complete_service.py:38-90 整段拔掉 os.environ 騷操作
# 60 行改成
self._wf_svc.complete_job(
    workflow_execution_uid=wf_uid,
    job_id=template_job_id,
    user=login_name,
    comment=comment,
    user_nickname=nickname,
    suppress_notify=True,    # ← 新增
)
# finally 區塊整段移除(不再需要還原 os.environ)

為何不用 ContextVar 方案

ContextVar 方案(在 send_* method 內檢查 notify_suppressed: ContextVar會失效——notification_service.send_*threading.Thread 內被呼叫,threading.Thread 不繼承 ContextVar,子 thread 內 notify_suppressed.get() 永遠是 default False

→ 顯式 suppress_notify 參數是唯一可靠解。雖然要動 complete_job / revert_job signature(兩個 method),但加 default 值 False 對既有 caller 透明。

task_assignee_service:160 那條 caller

不在 batch 範圍(assignee 變更時觸發),不需 suppress 設計。但仍須 §4.2 主表中改成「主 thread 預解析 cfg + thread kwarg」模式。

_send_batch_summary_notification (job_batch_complete:108-)

completed_job_uids 不為空時才送(既有 short-circuit 保留)。內部目前讀 os.getenv("SEND_NOTIFY_MODULES")(line 116)—— 改成主 thread 預解析 Discord/Telegram cfg + Thread kwargs 傳入,與 §4.2 caller 範例同模式。

4.3 Test Service — app/notify_config/service/notify_config_test_service.py

4.3.1 安全考量:admin role check

Test endpoint 接受任意 value.secret 並真打外部 HTTP(Discord webhook / Telegram bot)。若只 @jwt_required 不擋角色:

  • 任何登入使用者可借 prod webhook 送任意 message → 抵觸 value.secret 既存資料的隱私
  • changePwd=false 場景下,viewer 帳號可借管理員設定送訊息給該 tenant 的 Discord 頻道
  • 無 rate-limit 下可被當 spam relay → Discord webhook abuse detection

Test endpoint service 層強制檢查 is_admin(既有 UserContextDTO.is_admin 屬性,pattern 對齊 app/cloud_integration/service/google_drive_integration_service.pyis_admin 檢查)。

4.3.2 Service 實作

from jedi_common.session.auth.auth_context import get_user_context
from jedi_common.handler.exception import BadRequestError, ForbiddenError, NotFound
from common.code.error_code import ErrorCode

class NotifyConfigTestService:
    """送測試訊息給指定 channel。
    - resolve 階段(@transaction 內):讀 DB 沿用既存 secret(changePwd=false 時)
    - send 階段(@transaction 外):對外打 HTTP,避免 hold DB connection
    """

    def __init__(self, system_config_service):
        self.system_config_service = system_config_service

    @transaction
    def _resolve_value(self, channel: str, value: dict, change_pwd: bool) -> dict:
        """純讀 DB:若 changePwd=False,從既存 row 拿 secret 補進 value。"""
        if change_pwd:
            return value
        try:
            existing = self.system_config_service.get_system_config_by_key(
                "NOTIFY_CONFIG", channel
            )
        except NotFound:
            existing = None
        if not existing or not existing.value.get("secret"):
            raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_TEST_NO_EXISTING_SECRET)
        return {**value, "secret": existing.value["secret"]}

    def test_channel(self, channel: str, value: dict, change_pwd: bool = True) -> dict:
        # 1. 驗 admin(業務邏輯層擋,不依賴 capability decorator)
        user = get_user_context()
        if not user or not user.is_admin:
            raise ForbiddenError(ErrorCode.NOTIFY_CHANNEL_TEST_FORBIDDEN)

        # 2. 驗 channel
        if channel not in ("DISCORD", "TELEGRAM"):
            raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_NOT_SUPPORTED)

        # 3. resolve(讀 DB)
        resolved = self._resolve_value(channel, value, change_pwd)

        # 4. send(不 hold DB connection)— 失敗以 4xx envelope 回傳,對齊 codebase 慣例
        try:
            if channel == "DISCORD":
                self._test_discord(resolved)
            else:
                self._test_telegram(resolved)
            return {"channel": channel, "sent_at": datetime.utcnow().isoformat() + "Z"}
        except Exception as e:
            # BadRequestError 不支援 detail kwarg;具體錯誤寫 BE log,前端只顯示固定 message
            logger.exception("Notify channel test failed: channel=%s detail=%s", channel, str(e))
            raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_TEST_FAILED)

⚠️ Test service 不改 DB,只直接呼叫 Notifier。失敗改用 raise BadRequestError,由 jedi-handler 包成 4xx + {"error_code", "msg"} envelope,對齊整個 codebase 慣例(驗證自 jedi-common/handler/handler.py:18-19),前端 axios interceptor 自動處理錯誤。

4.4 DI

新增 di_containers/notify_config/containers.py

class NotifyConfigContainer(containers.DeclarativeContainer):
    system_config_service = providers.Dependency()
    notify_config_test_service = providers.Singleton(
        NotifyConfigTestService,
        system_config_service=system_config_service,
    )

di_containers/containers.pyContainers 內新增:

notify_config_container = providers.Container(
    NotifyConfigContainer,
    system_config_service=system_config_container.system_config_service,
)

config/app_modules.pyREGISTERED_APPS 新增 'notify_config'(依既有 entry 格式 copy 一行 from 'cloud_integration')。

⚠️ config/di_modules.py auto-scan pattern 為 api/**/*_router.py / api/**/routes/*_route.py。新模組路徑 api/notify_config/routes/notify_config_route.py 符合,會自動 wire。

4.5 Error Codes — 新增 + cleanup 既有 bug

4.5.1 新增

common/code/error_code.py

NOTIFY_CHANNEL_TEST_FAILED              = ("通知測試失敗", "NOTIFY_400001")
NOTIFY_CHANNEL_TEST_NO_EXISTING_SECRET  = ("無既存 secret 可沿用,請填入完整 token", "NOTIFY_400002")
NOTIFY_CHANNEL_NOT_SUPPORTED            = ("不支援的通知頻道", "NOTIFY_400003")
NOTIFY_CHANNEL_TEST_FORBIDDEN           = ("僅系統管理員可執行通知測試", "NOTIFY_403001")
NOTIFY_NO_TENANT_CONTEXT                = ("缺少 tenant context,無法讀取通知設定", "NOTIFY_500001")

4.5.2 既有 bug 一併清理

common/code/error_code.py:41-43 既有 bug:三筆 NOTIFICATION error code 共用同一 code NOTIFICATION_404001

NOTIFICATION_SMTP_CONFIG_NOT_FOUND      = (..., "NOTIFICATION_404001")  # ❌
NOTIFICATION_DISCORD_CONFIG_NOT_FOUND   = (..., "NOTIFICATION_404001")  # ❌
NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND  = (..., "NOTIFICATION_404001")  # ❌

caller 改成靜默 skip 後 Discord / Telegram 兩筆完全不再被 raise。本次 PR:

  • 直接刪除 NOTIFICATION_DISCORD_CONFIG_NOT_FOUNDNOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND
  • 保留 NOTIFICATION_SMTP_CONFIG_NOT_FOUND 但 code 改為 NOTIFY_404001(修共用 bug)
  • 同步更新 config/translations/{en,zh_Hant_TW}/LC_MESSAGES/messages.po 移除已刪 key、新增新 NOTIFY_* code 翻譯
  • pybabel compile

統一前綴 NOTIFY_*(含變數名與 code 字串),不留 v1/v2 過渡期混用。


5. API Endpoints

5.1 CRUD — 重用既有 system_config 端點

Method URL 對應 service
GET /system/configs/NOTIFY_CONFIG get_system_config_by_group
GET /system/config/NOTIFY_CONFIG/<channel> get_system_config_by_key
PUT /system/config/NOTIFY_CONFIG/<channel> update_config_by_group_key

→ 後端零工,只改 HIDDEN_SECRET 名單一行常數。

5.2 新增 test 端點

POST /notify-config/<channel>/test

<channel> ∈ {DISCORD, TELEGRAM}。MAIL → 400 NOTIFY_CHANNEL_NOT_SUPPORTED

Request body

{
  "value": {"enabled": true, "secret": "https://discord.com/api/webhooks/...", "chat_id": "..."},
  "changePwd": true
}

Response 規範(envelope 對齊整個 codebase 慣例)

⚠️ 主專案實際 envelope 格式為 {"status": true|false, "data": ...}(驗證自 common/util/response_util.py:4-11)—— 非 {"code": 1, "data": ...}。前端 BaseService.js 也是讀 status === true 判定。

結果類型 HTTP envelope 前端 BaseService 行為
測試送達 200 {"status": true, "data": {"channel": "DISCORD", "sent_at": "..."}} resolve(data 本體)
測試發送失敗(webhook 401 / token 錯) 400 jedi-handler 包:{"status": false, "data": {"message": "...", "error_code": "NOTIFY_400001"}} reject
缺少既存 secret(changePwd=false 但無 row) 400 同上 with NOTIFY_400002 reject
非 admin 403 同上 with NOTIFY_403001 reject

不採用「200 + data.success=false」雙 flag 模式——違反 codebase 慣例。

→ Notifier 拋例外時 service 層轉成 raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_TEST_FAILED)BadRequestError 簽名僅接 error_code(驗證自 jedi_common/handler/exception.py:22-25),不支援 detail kwarg。具體錯誤訊息(如 webhook 401 detail)改用 logger.exception(...) 寫進 server log,前端只顯示固定 message「通知測試失敗」。詳細排錯靠 BE log。

5.3 權限模型

5.3.1 CRUD(沿用既有 system_config 家族)

/system/config/... PUT 與 GET 沿用既有 @jwt_required()。後端目前無 capability decorator(jedi-auth / common/middleware/ 皆無,capability 機制目前純前端)—— 整批 system_config 家族補洞屬 §9.1 技術債,本次不修。

→ 前端透過 notify_config.read / notify_config.update capability 控制選單與按鈕。

5.3.2 Test Endpoint(強制後端 admin 檢查)

⚠️ Test endpoint 與 CRUD 不同 —— 它直接觸發外部 HTTP 請求(送 message 到 webhook / Telegram bot)。為避免被當 spam relay / 跨角色濫用既存 secret,強制後端檢查 is_admin

  • service 層 NotifyConfigTestService.test_channel 第一行 get_user_context().is_admin check
  • 失敗 raise ForbiddenError(NOTIFY_CHANNEL_TEST_FORBIDDEN) → 403
  • 前端「測試」按鈕也透過 notify_config.update capability 控制 enable(雙重保護)

⚠️ 後端不靠 capability decorator,是因為沒有現成可用。Service 層直接讀 UserContextDTO.is_admin 是既有模式(pattern 對齊 app/cloud_integration/service/google_drive_integration_service.py)。

→ 加這道防護是「真打外部網路的 endpoint 必須有後端 role gate」,比一般 CRUD 嚴格。

5.3.3 PUT 跨 tenant 寫入驗證

實作前 grep 確認 update_config_by_group_key 是否依賴 RLS 隔離跨 tenant 寫入:

grep -nE "tenant_id|TenantScopedMixinModel" \
  ~/Projects/Jedicogy/module/jedi-python-package/jedi-system-config/jedi_system_config/infra/repository/system_config_repo_impl.py

SystemConfig model 已套 TenantScopedMixinModel → SQLAlchemy session 的 tenant_id 自動注入;RLS policy 擋跨 tenant SELECT/UPDATE。理論上 RLS 已涵蓋,但 §8.1.2 仍加整合測試 case 驗證「tenant A user PUT 後 tenant B 看不到」。

5.4 模組結構

api/notify_config/
├── __init__.py                    # create_module() 返 blueprint
├── routes/
│   └── notify_config_route.py     # POST /notify-config/<channel>/test
└── serializers/
    └── notify_config.py           # NotifyConfigTestRequest / NotifyConfigTestResponse

app/notify_config/
├── __init__.py
└── service/
    └── notify_config_test_service.py

di_containers/notify_config/
└── containers.py

6. Frontend

6.1 View

src/views/notify-config/
└── NotifyConfigForm.vue

範本:抄 IssueIntegrateConfigForm.vue 的雙 tab 結構。

結構

頁面頂端
└── <Message severity="info">
    Email 通知由 SMTP 設定頁控制 → [前往 SMTP 設定]

TabView
├── Tab "Discord"
│   ├── Checkbox: enabled
│   ├── InputText (mask=password): webhook_url (mapped to value.secret)
│   ├── Hidden flag: changePwd (true only when user types new value)
│   ├── Button: 儲存
│   └── Button: 測試送一則
└── Tab "Telegram"
    ├── Checkbox: enabled
    ├── InputText (mask=password): bot_token (mapped to value.secret)
    ├── InputText: chat_id
    ├── Hidden flag: changePwd
    ├── Button: 儲存
    └── Button: 測試送一則

Save / 測試 互動規格(明確)

兩按鈕互不依賴,但有 UX 順序提示:

動作 行為
點「測試」 不寫 DB;以當前 form values + changePwd 呼叫 POST /notify-config//test;axios resolve → toast green / reject (4xx) → toast red 顯示 error.response.data.msg
點「儲存」 標準 PUT /system/config/NOTIFY_CONFIG/;toast 顯示 envelope code 結果
同時 disable 條件 任一在飛行中時兩按鈕都 disable + spinner
是否強制先測試再儲存 ❌ 不強制(避免 UX 摩擦)

Secret mask 慣例

input ******** 顯示已存在 secret;使用者沒輸入新值時 changePwd: false,後端沿用 DB 既存 secret。

6.2 API service

src/config/api/api.js

NOTIFY_CONFIG_TEST: getUrl('/notify-config'),  // 後接 /<channel>/test

CRUD 重用 API.SYSTEM_CONFIG

6.3 Router

src/config/router/index.js/system/* 群組加:

{
    path: '/system/notify-config',
    name: 'notify-config',
    component: () => import('@/views/notify-config/NotifyConfigForm.vue'),
    meta: { breadcrumb: [{ label: 'notify-config' }], requiresAuth: true }
}

放在 ldap-config (170) 與 issue-integrate-config (180) 之間,sort=175。

6.4 i18n

前端

新增 lang.notify_config.*(zh_Hant_TW + en):

  • title, mail_help, discord, telegram
  • enabled, webhook_url, bot_token, chat_id
  • save_button, test_button, test_success, test_failed

後端

主專案翻譯實際路徑為 config/translations/(非 CLAUDE.md 寫的 app/translations/,CLAUDE.md 文字過舊;以實際目錄為準)。

新增 NOTIFY error code 對應翻譯後執行:

pybabel compile -d config/translations

.po.mo 才會生效;CI / Docker image 應已含此步驟,本機 dev 要手動跑)

6.5 選單顯示

AppMenu.vueuseUserUtil().permissions 動態撈。後端 migration 加 notify_config.readAdministrator role 後自動顯示。


7. Migration

scripts/sql/2026-04-27-add-notify-config-permissions.sql

完全比照 2026-04-27-add-audit-and-cloud-integration-permissions.sql 的 schema、命名、idempotent 慣例:

  • capabilities schema 為 (id, name, resource_type, action, description),UNIQUE on name
  • route_capabilitiesrequirement 欄位('ALL' / 'ANY',CHECK constraint)
  • role_capabilities tenant_id 欄位(PK (role_id, capability_id)
  • roles 系統管理員為 name = 'Administrator' AND tenant_id IS NULL
  • ui_routes INSERT 含 uid gen_random_uuid(),欄位順序 (uid, pid, name, url, icon, enable, description, sort),無 root 欄位
  • 命名慣例 notify_config.read / notify_config.update(dot + snake_case action)

7.1 SQL 內容

-- Date: 2026-04-27
-- 將「通知設定」納入權限管理系統(per-tenant Discord / Telegram channel 設定)
-- 設計依據:docs/features/FR-020-2604-notify-config/design.md
--
-- 執行身份:建議用 cmmgr(DB 擁有者)跑,避免 RLS 擋住系統層級資料:
--   PGPASSWORD='...' psql -h <host> -p 25432 -U cmmgr -d <db> -f <this_file>
-- 若用 cm_app(應用層 user)跑,會被 RLS 擋;SQL 內已加 SET LOCAL app.is_super_admin='t' 旁路
--
-- Idempotent:可重複執行,已存在的資料以 ON CONFLICT DO NOTHING 跳過

BEGIN;

-- ⚠️ 重要:本 migration 會寫入 role_capabilities 給 Administrator 系統角色
-- (roles.tenant_id IS NULL),未設 super_admin context 會被 RLS 擋住,
-- 導致 INSERT 0 rows。必須先 bypass RLS。
SET LOCAL app.is_super_admin = 't';

-- 1. 新增 notify_config 模組能力點 (2026-04-27)
INSERT INTO public.capabilities (name, resource_type, action, description) VALUES
    ('notify_config.read',   'notify_config', 'read',   '檢視通知設定'),
    ('notify_config.update', 'notify_config', 'update', '修改通知設定(含測試送訊息)')
ON CONFLICT (name) DO NOTHING;

-- 2. 新增 notify-config 路由 (2026-04-27)
-- ⚠️ ui_routes.name 無 UNIQUE constraint(驗證自 jedi_auth/infra/models/ui_route.py),
--     不能用 ON CONFLICT (name);改用 INSERT ... SELECT WHERE NOT EXISTS 確保 idempotent
INSERT INTO public.ui_routes (uid, pid, name, url, icon, enable, description, sort)
SELECT gen_random_uuid(), 0, 'notify-config', '/system/notify-config', 'pi-bell', 1, '通知設定', 175
WHERE NOT EXISTS (
    SELECT 1 FROM public.ui_routes WHERE name = 'notify-config'
);

-- 3. 綁定 notify-config ↔ capabilities (read=ALL, update=ANY) (2026-04-27)
WITH r AS (SELECT id FROM public.ui_routes WHERE name = 'notify-config'),
     caps AS (
         SELECT id, name FROM public.capabilities
         WHERE resource_type = 'notify_config'
     )
INSERT INTO public.route_capabilities (route_id, capability_id, requirement)
SELECT r.id, caps.id,
       CASE WHEN caps.name LIKE '%.read' THEN 'ALL' ELSE 'ANY' END
FROM r, caps
ON CONFLICT (route_id, capability_id) DO NOTHING;

-- 4. 系統管理員 (Administrator) 角色預設擁有 notify_config.* 全部能力 (2026-04-27)
INSERT INTO public.role_capabilities (role_id, capability_id)
SELECT r.id, c.id
FROM public.roles r
JOIN public.capabilities c ON c.resource_type = 'notify_config'
WHERE r.name = 'Administrator' AND r.tenant_id IS NULL
ON CONFLICT (role_id, capability_id) DO NOTHING;

COMMIT;

-- 驗證查詢(COMMIT 後可跑):
-- SELECT * FROM public.capabilities WHERE resource_type = 'notify_config';
-- SELECT * FROM public.ui_routes WHERE name = 'notify-config';
-- SELECT rc.*, c.name AS cap_name, r.name AS route_name
--   FROM public.route_capabilities rc
--   JOIN public.capabilities c ON c.id = rc.capability_id
--   JOIN public.ui_routes r ON r.id = rc.route_id
--   WHERE c.resource_type = 'notify_config';
-- SELECT rc.*, c.name AS cap_name
--   FROM public.role_capabilities rc
--   JOIN public.capabilities c ON c.id = rc.capability_id
--   JOIN public.roles r ON r.id = rc.role_id
--   WHERE c.resource_type = 'notify_config' AND r.name = 'Administrator';

-- Rollback (commented):
-- BEGIN;
-- SET LOCAL app.is_super_admin = 't';
-- DELETE FROM public.role_capabilities
--   WHERE capability_id IN (SELECT id FROM public.capabilities WHERE resource_type = 'notify_config');
-- DELETE FROM public.route_capabilities
--   WHERE capability_id IN (SELECT id FROM public.capabilities WHERE resource_type = 'notify_config');
-- DELETE FROM public.capabilities WHERE resource_type = 'notify_config';
-- DELETE FROM public.ui_routes WHERE name = 'notify-config';
-- DELETE FROM public.system_configs WHERE "group" = 'NOTIFY_CONFIG';  -- ⚠️ 會清掉 tenant 已建設定
-- COMMIT;

7.2 不需要

  • ❌ 新建 table → 不需要 GRANT ON TABLE ... TO cm_app
  • ❌ Seed data → 沒設 = skip 是 §3.3 fallback
  • ❌ 新 RLS policy → system_configs 既有 policy 涵蓋

8. Testing Strategy

8.1 主專案 pytest

8.1.1 NotificationService 單元測試 — test/test_notification_service.py(新增)

Case 預期
send_discord_notification(message) 無 user_context(主 thread 直呼) raise NOTIFICATION_NO_TENANT_CONTEXT
send_discord_notification(message, cfg=None) 有 user_context、無 NOTIFY_CONFIG/DISCORD row return None + logger.warning
send_discord_notification(message, cfg={...enabled:false}) return None + logger.warning
send_discord_notification(message, cfg={...enabled:true, secret:'webhook'}) 呼叫 Notifier('DISCORD', ...) send
send_discord_notification(message, cfg={...secret 缺}) return None + logger.warning
send_telegram_notification 4 個對應 case 同上模式(缺 chat_id 也要 skip)
send_mail_notification SMTP/* 缺(既有行為 regression) raise NotFound
get_channel_config 無 user_context raise NOTIFICATION_NO_TENANT_CONTEXT
get_channel_config 有 user_context 且 row 存在 回 dict

Mock 策略:patch SystemConfigService.get_system_config_by_keyNotifier.send_notification,不打真網路。get_user_contextmonkeypatch 控制。

8.1.2 NotifyConfig test endpoint 整合測試 — test/test_notify_config_route.py(新增)

Case 預期
POST /notify-config/DISCORD/test 完整 value 200 + {"status": true, "data": {"channel", "sent_at"}}
POST /notify-config/DISCORD/test Notifier 拋例外 400 + {"error_code": "NOTIFY_400001", "msg": "通知測試失敗"}
POST /notify-config/DISCORD/test 無 JWT 401
POST /notify-config/MAIL/test 400 NOTIFY_CHANNEL_NOT_SUPPORTED
POST /notify-config/UNKNOWN/test 400 NOTIFY_CHANNEL_NOT_SUPPORTED
changePwd=false 且 DB 無 row 400 NOTIFY_CHANNEL_TEST_NO_EXISTING_SECRET
changePwd=false 且 DB 有 row 用 DB secret 測試(mock Notifier 驗證收到 DB 那把 secret)

⚠️ 驗證後端 capability check(§5.3 確認後端目前不擋)。

8.1.3 既有測試回歸

pytest test/ -k "workflow or oscal_audit or notification or batch_complete or task_survey"

caller 5 處改動後既有 workflow / batch_complete / task_survey 測試應照常通過。

8.2 手動驗收(changelog 內附)

場景 預期
dev 登入 blsadmin → 進「通知設定」頁 → 兩 tab 顯示 Discord / Telegram
填 Discord webhook URL + 「測試」 Discord 頻道收到 test 訊息
webhook URL 填錯 → 「測試」 toast 顯示失敗 + detail
儲存後重新整理 → secret 顯示 ******** secret 不外洩
觸發既有 workflow 通知事件(如 batch assign)→ Discord 真的收到
enabled=false → 觸發事件 → Discord 不發 ✅ + log line Discord notification skipped
用 tenant B 帳號(不同 tenant 的不同使用者帳號 — 不是改 X-Tenant-ID header,per memory)登入後重做設定 + 測試 → tenant A 的 webhook 不會被打到 RLS 隔離有效
job_batch_complete_service 批次完成 → 期間不送逐筆通知 → 完成後彙總一次 suppress 機制有效(取決於 §4.2.1 採方案 A 或 B)

8.3 Verification before claiming done

pytest test/test_notification_service.py test/test_notify_config_route.py -v
pytest test/ -k "workflow or batch_complete or task_survey or oscal_audit"  # 既有回歸

pytest output 摘要貼進 changelog。


9. Known Issues / 不修

9.1 system_config_route + 全家族缺 capability check(既有技術債)

api/system_config/routes/system_config_route.py 全 method 只 @jwt_required,任何登入使用者都能 PUT 任意 group/key。整個 capability 機制目前純粹是前端選單過濾。

這次不修(範圍會爆,影響 SMTP / LDAP / Storage / Issue / NOTIFY 全部)。

→ 列為 known issue,留給後續安全強化 PR:

  1. jedi-auth / common/middleware/@require_capabilities([...]) decorator
  2. 各 system_config 家族 route 套上 decorator
  3. 補對應整合測試

9.2 舊 error codes 殘留(本 PR 一併修,已轉到 §4.5.2)

→ 移到本 PR 範圍內處理。

9.3 SMTP/* 在 thread fan-out 下的 thread-local 問題

send_mail_notification → get_notifier → get_system_config_by_key("SMTP", "*") 在 child thread 跑,get_user_context() 回 None → session_scopesuper_admin='t' → multi-tenant 下會撈到任意 tenant 的 SMTP row。

既存 bug,與 NOTIFY_CONFIG 同根源,本次只做 NOTIFY 不擴大破口。SMTP 部分由獨立 PR 一起套用「caller 預解析 cfg + thread kwarg 傳入」模式處理。


10. 跨租戶通知洩漏防護(背景任務 caveat)

10.1 風險

session_scope() 在無 user_context 時會把 app.is_super_admin = 't' → RLS 完全失效。若未來有背景任務(APScheduler / batch / Celery)發通知:

# ❌ 危險範例
def daily_report_tick():
    with session_scope():  # 無 user_context → super_admin='t' → RLS bypass
        notification_service.send_discord_notification("...")
        # → 撈到任意 tenant 的 webhook → 跨 tenant leak

10.2 防護機制

get_channel_config 強制驗 get_user_context(),無 context 時 raise NOTIFICATION_NO_TENANT_CONTEXT

→ 背景任務若要發通知,必須用 set_user_context(...) 注入 user context(或 tenant_context(tenant_id=N) helper),通知完 clear。

10.3 為什麼現在加(不是 YAGNI)

  • 加防護成本 ≈ 3 行 code + 1 個 error code(5 分鐘)
  • 不加的 bug 是沉默的(不報錯、只是訊息打到別家)
  • 在 dev 單一 tenant 環境永遠測不出來
  • 上 prod 才炸 → 客戶投訴才能 debug
  • 本質是 boundary validation 而非 feature → 不在 YAGNI 涵蓋範圍

11. Decision Log(brainstorming 結論)

# 決策 選項 結論 理由
1 Scope 範圍 A: 只 NOTIFY / B: NOTIFY+DRIVE / C: 全 5 個 A 風險最低、解鎖 SOP
2 驅動力 A: 客戶痛點 / B: SaaS roadmap / C: 內部整潔 A+B 三 channel 都要做且要 scale
3 資料結構 A: 一 group 多 key / B: 巢狀 JSONB / C: 多 group A 對齊 ISSUE_INTEGRATE_CONFIG 慣例
4 Fallback 行為 A: NULL row / B: skip / C: warning / D: 混合 D(後改) 比照 .env — 缺值 skip,零 seed
5 Caller 介面 A: 最小改 / B: dispatcher / C: A+kill switch A YAGNI;廢棄 SEND_NOTIFY_MODULES
6 Test 按鈕 A: 三 channel / B: 不做 / C: 部分 A → 後改 2 channel 客戶痛點高 ROI
7 MAIL 移除 保留 / 移除 移除 SMTP 已 per-tenant;MAIL 預設都要開
8 背景任務 caveat A1 加 check / A2 註解警告 / C 不處理 A1 3 行成本擋永久風險
9 Capabilities 切分 三 (read/write/test) / 二 (read/update) test 算寫入操作的一環;對齊既有 capabilities 命名(dot + snake_case action)
10 Seed data 寫 / 不寫 不寫 fallback 是「沒 row = skip」
11 Test endpoint 後端 admin check 不擋 / @jwt_required only / service 層讀 is_admin service 層讀 is_admin Test endpoint 真打外部 HTTP,不可僅靠前端 hide;後端 decorator 不存在但既有 service-level is_admin pattern 可用(cloud_integration 已有先例)
12 SMTP/* 同根源 thread-local bug 一併修 / 拆 PR / 不修 拆獨立 PR(§9.3) 範圍會爆(5 caller 全要動 SMTP signature);本次先把 NOTIFY 的 pattern 跑通,SMTP 套用相同模式即可
13 Batch suppress 機制 A: caller 顯式參數 / B: ContextVar A B 在 thread fan-out 下失效(spec §4.1 已闡明 ContextVar 不跨 thread);A 雖動兩 method signature 但加 default 對 caller 透明
14 Test endpoint 失敗回應 200+success=false 雙 flag / 4xx + jedi-handler envelope 4xx + {"error_code", "msg"} envelope 對齊 jedi-handler 既有失敗序列化(jedi-common/handler/handler.py:18-19);前端 axios interceptor 自動處理
15 NOTIFICATION_404001 共用 bug 推 cleanup PR / 本 PR 一併修 本 PR 一併修 三筆共用 code 中兩筆(Discord/Telegram)本來就不再被 raise,剩一筆(SMTP)改 NOTIFY_404001 順手;分批反而造成 prefix 過渡期混用

12. Future Considerations

實作完成後另開 spec/PR 處理:

  • SECURITY_CONFIG(MFA per-tenant)
  • DRIVE_CONFIG(檔案大小 per-tenant)
  • AI_PROVIDER(BYO key per-tenant)
  • BRANDING(white-label per-tenant)
  • system_config_route 全家族 capability check 補洞(§9.1)
  • 舊 error codes cleanup + 修 NOTIFICATION_404001 共用 bug(§9.2)
  • SMTP/ thread fan-out 套用同樣 cfg 預解析模式*(§9.3)
  • Notification dispatcher refactor
  • E2E 測試(屬 compliance-manager-test/
  • 通知歷史紀錄頁
  • Per-user 通知偏好
  • Channel template 客製

Appendix A — 影響檔案清單

後端

檔案 動作
app/notification/service/notification_service.py 重寫(移除 os.getenv、加 cfg kwarg、加 logger.warning、加 user_context check)
app/flow_engine/service/workflow_execution_service.py 1041-、1086-、1166- 三處 caller 改用 cfg 預解析 + thread kwarg
app/grc/service/project_service.py 644 同上模式
app/task_survey/service/task_survey_service.py 444 同上模式
app/grc/service/job_batch_complete_service.py 38-40 / 87-90 / 116 拔掉 os.environ 騷操作 + 改用 service-level suppress flag(§4.2.1)+ caller 改 cfg 預解析
api/system_config/routes/system_config_route.py:18 HIDDEN_SECRET'NOTIFY_CONFIG'
common/code/error_code.py 新增 4 個 NOTIFY error code
api/notify_config/__init__.py 新增(create_module)
api/notify_config/routes/notify_config_route.py 新增
api/notify_config/serializers/notify_config.py 新增
app/notify_config/__init__.py 新增
app/notify_config/service/notify_config_test_service.py 新增
di_containers/notify_config/containers.py 新增
di_containers/containers.py wire NotifyConfigContainer
config/app_modules.py REGISTERED_APPS'notify_config'
config/translations/zh_Hant_TW/LC_MESSAGES/messages.po 新增 NOTIFY error code 翻譯
config/translations/en/LC_MESSAGES/messages.po 新增 NOTIFY error code 翻譯
scripts/sql/2026-04-27-add-notify-config-permissions.sql 新增
test/test_notification_service.py 新增
test/test_notify_config_route.py 新增

前端

檔案 動作
src/views/notify-config/NotifyConfigForm.vue 新增
src/config/api/api.js 新增 NOTIFY_CONFIG_TEST 常數
src/config/router/index.js 新增 /system/notify-config 路由
src/i18n/lang/zh_Hant_TW/... 新增 lang.notify_config.*
src/i18n/lang/en/... 同上

Changelog

  • docs/changelog/2026-04-27-feat-notify-config.md(feat 類型)

Appendix B — 環境變數變更

實作前必驗:

grep -rE "TELEGRAM_BOT_TOKEN|DISCORD_WEBHOOK_URL|SEND_NOTIFY_MODULES" \
  config/ app/ api/ common/ --include="*.py"

(記憶 feedback_full_read_no_partial_grep.md 規範:完整 grep 不要 partial)

確認無漏網 caller 後,.env / .env.example 拔除:

SEND_NOTIFY_MODULES=MAIL,DISCORD,TELEGRAM
TELEGRAM_BOT_TOKEN=...
TELEGRAM_CHAT_ID=...
DISCORD_WEBHOOK_URL=...

.env.example 加註解說明改至 UI 設定。

config/config.py 各 env Config class 也要 grep 確認沒有 hardcode 讀這幾個 env var(目前不太可能但保險)。


Appendix D — Reviewer findings round 2 → spec 對照(v2 → v3)

Finding 處理
C-1 batch suppress 呼叫鏈描述錯誤 §4.2.1 完全重寫:以實際 grep 結果 (complete_job:572 / revert_job:635) 為準,suppress 透過 complete_job(suppress_notify=True) signature 穿透
C-2 test endpoint 純 jwt_required 是 abuse vector §4.3.1 + §5.3.2 強制 service 層 is_admin 檢查 + 新增 NOTIFY_CHANNEL_TEST_FORBIDDEN 403 code;Decision Log #11 改寫
C-3 ui_routes INSERT 不 idempotent §7.1 step 2 改用 INSERT ... SELECT WHERE NOT EXISTS;註明 name 無 UNIQUE constraint
M-1 Appendix C m-8 自相矛盾 Appendix C m-8 改寫
M-2 5 caller 只展開 1 個 §4.2 表格各 caller 加「cfg 預解析放置點」短註;§4.2.1 expand _send_batch_summary_notification 細節;完整 before/after diff 留給 implementation plan 階段
M-3 envelope 違反 codebase 慣例 §5.2 + §4.3.2 改用 4xx + jedi-handler {"error_code", "msg"} envelope;NOTIFY_400001 從 m-4 死碼救活
M-4 ContextVar 方案 B 自打嘴巴 §4.2.1「為何不用 ContextVar 方案」段明確刪除;Decision Log #13
M-5 PUT 跨 tenant 驗證 §5.3.3 加驗證步驟 + §8.1.2 加整合測試 case
m-1 NOTIFY_400001 死碼 M-3 處理後變主流
m-3 prefix 不一致 + 共用 code bug §4.5.2 本 PR 一併修;Decision Log #15
m-9 decision log 缺 v2 取捨 Decision Log 補 #12 (SMTP OOS) / #13 (suppress 方案) / #14 (envelope) / #15 (cleanup 範圍)

Appendix C — Reviewer findings round 1 → spec 對照(v1 → v2)

Finding 處理
C-1 capability decorator 不存在 §5.3 改為「後端不擋、純前端」、§8.1.2 移除 403 case、§9.1 描述明確、Decision Log #11
C-2 thread-local user_context §4.1 加「Thread-local 重要前提」段、send_*cfg kwarg、§4.2 caller 改造範例展示主 thread 預解析 + thread kwarg 傳入、§9.3 列既有 SMTP 同根源 bug 為 OOS
C-3 SQL schema 全錯 §7 完全重寫對齊既有 2026-04-27-add-audit-and-cloud-integration-permissions.sql 與實際 DB schema
M-1 caller 漏列 + batch suppress 替代設計 §4.2 列 5 個 caller、§4.2.1 batch suppress 兩方案、Appendix A sync
M-2 靜默 skip 無 log send_*logger.warning、§8.1.1 對應測試 case
M-3 test endpoint @transaction + UX §4.3 拆 _resolve_value (in transaction) 與 send (out)、§6.1「Save / 測試 互動規格」段
M-4 pybabel compile + path §6.4 加 pybabel compile -d config/translations 並校正 CLAUDE.md 過時路徑
M-5 HIDDEN_SECRET 覆蓋面 §3.4 加 grep 檢查步驟
M-6 idempotent migration §7.1 全 INSERT 加 ON CONFLICT DO NOTHING
m-1 error code prefix 不一致 §4.5 註明 NOTIFY_* 新前綴;舊 NOTIFICATION_* cleanup PR 處理(§9.2)
m-2 §7.2 實作前必驗 SQL 移除 — schema 已在 §7.1 對照既有 migration 寫死,不需驗
m-3 REGISTERED_APPS entry 格式 §4.4 標明「copy from 'cloud_integration'
m-4 NOTIFY_400001 unused §4.5 保留,未來若 raise 才用
m-5 完整 grep 環境變數 Appendix B 加 grep 命令
m-6 mail 整合 case §8.1.1 已有 SMTP 缺 raise 的 regression case
m-7 跨租戶測試方法 §8.2 改為「用不同 tenant 的不同使用者帳號重做」
m-8 cfg=None code 走不到 已校正:jedi-system-config app service:38 確認 raise NotFound(不是回 None);try/except NotFound: return Noneget_channel_config 是必要防禦;cfg.value if cfg else None 中的 cfg else None 分支在 service 層 raise 時走不到(except NotFound 已先攔截),確實多餘但保留為防禦性判斷不會出問題
m-9 envelope 響應碼 §5.2 已重寫:採 4xx + jedi-handler {"error_code", "msg"} envelope(v3 修改,並於後續 plan review round 2 校正成功 {"status", "data"} / 失敗 {"error_code", "msg"} 雙路徑差異)