| 項目 | 內容 |
|---|---|
| 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) |
.env 裡 TELEGRAM_BOT_TOKEN / DISCORD_WEBHOOK_URL 為單租戶設計,多客戶共用部署時 A 客戶的審計通知會打到 B 客戶的頻道。ENABLE_MULTI_TENANT=true 已啟用,但通知層仍走 os.getenv,是 multi-tenant 設計漏洞。.env 移至 system_configs + 既有 HIDDEN_SECRET 隱藏機制.env → system_configs UI」的 SOPsystem_configs 表 + TenantScopedMixinModel + RLS 自動處理租戶隔離HIDDEN_SECRET + changePwd 慣例(api/system_config/routes/system_config_route.py:18)system_configs 兩筆 row:NOTIFY_CONFIG/DISCORD、NOTIFY_CONFIG/TELEGRAMapp/notification/service/notification_service.py 改寫(移除 os.getenv,改讀 SystemConfigService,且支援 caller 預解析 cfg 後傳入以避開 thread-local 問題)api/notify_config/ + app/notify_config/ 提供 test 端點SEND_NOTIFY_MODULES + os.environ 騷操作 — 完整清單見 §4.2)src/views/notify-config/NotifyConfigForm.vueui_routes / capabilities / route_capabilities / role_capabilities 四表的 INSERT,依既有 2026-04-27-add-audit-and-cloud-integration-permissions.sql 慣例與實際 DB schemaSMTP/* 已 per-tenant、預設都要開、SMTP 設定頁已有測試功能)system_config_route 缺 capability check 的技術債compliance-manager-test/)NOTIFY_CONFIG / DISCORD
value: {"enabled": bool, "secret": "<webhook_url>"}
NOTIFY_CONFIG / TELEGRAM
value: {"enabled": bool, "chat_id": "...", "secret": "<bot_token>"}
secret 沿用既有命名慣例chat_id 公開可見,獨立放外層依「比照 .env 的做法」:缺 row → 該 channel 直接 skip。不寫 seed data、不寫 tenant_id IS NULL 全系統預設(避免跨 tenant 共用通道)。
api/system_config/routes/system_config_route.py:18 的 HIDDEN_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 排除。
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_serviceauth_context 用 contextvars.ContextVar(jedi-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。
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 線索。
| 檔案 | 行 | 改動 | 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=True 給 complete_job |
— |
app/grc/service/job_batch_complete_service.py _send_batch_summary_notification |
108- / 116 | 同上 fan-out 模式 | method 開頭 |
@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 採同樣模式改寫。
job_batch_complete_service 批次 suppress 替代設計既有行為:批次完成期間用 os.environ["SEND_NOTIFY_MODULES"] = "" 暫停逐筆通知,最後統一送一次彙總。
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 才能傳達意圖。
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 方案(在 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 範例同模式。
app/notify_config/service/notify_config_test_service.pyTest endpoint 接受任意 value.secret 並真打外部 HTTP(Discord webhook / Telegram bot)。若只 @jwt_required 不擋角色:
value.secret 既存資料的隱私changePwd=false 場景下,viewer 帳號可借管理員設定送訊息給該 tenant 的 Discord 頻道→ Test endpoint service 層強制檢查 is_admin(既有 UserContextDTO.is_admin 屬性,pattern 對齊 app/cloud_integration/service/google_drive_integration_service.py 的 is_admin 檢查)。
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 自動處理錯誤。
新增 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.py 在 Containers 內新增:
notify_config_container = providers.Container(
NotifyConfigContainer,
system_config_service=system_config_container.system_config_service,
)config/app_modules.py 的 REGISTERED_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。
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")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_FOUND 與 NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUNDNOTIFICATION_SMTP_CONFIG_NOT_FOUND 但 code 改為 NOTIFY_404001(修共用 bug)config/translations/{en,zh_Hant_TW}/LC_MESSAGES/messages.po 移除已刪 key、新增新 NOTIFY_* code 翻譯統一前綴 NOTIFY_*(含變數名與 code 字串),不留 v1/v2 過渡期混用。
| 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 名單一行常數。
POST /notify-config/<channel>/test
<channel> ∈ {DISCORD, TELEGRAM}。MAIL → 400 NOTIFY_CHANNEL_NOT_SUPPORTED。
{
"value": {"enabled": true, "secret": "https://discord.com/api/webhooks/...", "chat_id": "..."},
"changePwd": true
}⚠️ 主專案實際 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。
/system/config/... PUT 與 GET 沿用既有 @jwt_required()。後端目前無 capability decorator(jedi-auth / common/middleware/ 皆無,capability 機制目前純前端)—— 整批 system_config 家族補洞屬 §9.1 技術債,本次不修。
→ 前端透過 notify_config.read / notify_config.update capability 控制選單與按鈕。
⚠️ Test endpoint 與 CRUD 不同 —— 它直接觸發外部 HTTP 請求(送 message 到 webhook / Telegram bot)。為避免被當 spam relay / 跨角色濫用既存 secret,強制後端檢查 is_admin:
NotifyConfigTestService.test_channel 第一行 get_user_context().is_admin checkForbiddenError(NOTIFY_CHANNEL_TEST_FORBIDDEN) → 403notify_config.update capability 控制 enable(雙重保護)⚠️ 後端不靠 capability decorator,是因為沒有現成可用。Service 層直接讀 UserContextDTO.is_admin 是既有模式(pattern 對齊 app/cloud_integration/service/google_drive_integration_service.py)。
→ 加這道防護是「真打外部網路的 endpoint 必須有後端 role gate」,比一般 CRUD 嚴格。
實作前 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.pySystemConfig model 已套 TenantScopedMixinModel → SQLAlchemy session 的 tenant_id 自動注入;RLS policy 擋跨 tenant SELECT/UPDATE。理論上 RLS 已涵蓋,但 §8.1.2 仍加整合測試 case 驗證「tenant A user PUT 後 tenant B 看不到」。
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
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: 測試送一則
兩按鈕互不依賴,但有 UX 順序提示:
| 動作 | 行為 |
|---|---|
| 點「測試」 | 不寫 DB;以當前 form values + changePwd 呼叫 POST /notify-config/error.response.data.msg |
| 點「儲存」 | 標準 PUT /system/config/NOTIFY_CONFIG/code 結果 |
| 同時 disable 條件 | 任一在飛行中時兩按鈕都 disable + spinner |
| 是否強制先測試再儲存 | ❌ 不強制(避免 UX 摩擦) |
input ******** 顯示已存在 secret;使用者沒輸入新值時 changePwd: false,後端沿用 DB 既存 secret。
src/config/api/api.js:
NOTIFY_CONFIG_TEST: getUrl('/notify-config'), // 後接 /<channel>/testCRUD 重用 API.SYSTEM_CONFIG。
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。
新增 lang.notify_config.*(zh_Hant_TW + en):
title, mail_help, discord, telegramenabled, webhook_url, bot_token, chat_idsave_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 要手動跑)
AppMenu.vue 從 useUserUtil().permissions 動態撈。後端 migration 加 notify_config.read 給 Administrator role 後自動顯示。
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 nameroute_capabilities 含 requirement 欄位('ALL' / 'ANY',CHECK constraint)role_capabilities 無 tenant_id 欄位(PK (role_id, capability_id))roles 系統管理員為 name = 'Administrator' AND tenant_id IS NULLui_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)-- 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;GRANT ON TABLE ... TO cm_appsystem_configs 既有 policy 涵蓋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_key 與 Notifier.send_notification,不打真網路。get_user_context 用 monkeypatch 控制。
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 確認後端目前不擋)。
pytest test/ -k "workflow or oscal_audit or notification or batch_complete or task_survey"caller 5 處改動後既有 workflow / batch_complete / task_survey 測試應照常通過。
| 場景 | 預期 |
|---|---|
| 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) |
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。
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:
jedi-auth / common/middleware/ 加 @require_capabilities([...]) decorator→ 移到本 PR 範圍內處理。
send_mail_notification → get_notifier → get_system_config_by_key("SMTP", "*") 在 child thread 跑,get_user_context() 回 None → session_scope 走 super_admin='t' → multi-tenant 下會撈到任意 tenant 的 SMTP row。
→ 既存 bug,與 NOTIFY_CONFIG 同根源,本次只做 NOTIFY 不擴大破口。SMTP 部分由獨立 PR 一起套用「caller 預解析 cfg + thread kwarg 傳入」模式處理。
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 leakget_channel_config 強制驗 get_user_context(),無 context 時 raise NOTIFICATION_NO_TENANT_CONTEXT。
→ 背景任務若要發通知,必須用 set_user_context(...) 注入 user context(或 tenant_context(tenant_id=N) helper),通知完 clear。
| # | 決策 | 選項 | 結論 | 理由 |
|---|---|---|---|---|
| 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 過渡期混用 |
實作完成後另開 spec/PR 處理:
system_config_route 全家族 capability check 補洞(§9.1)compliance-manager-test/)| 檔案 | 動作 |
|---|---|
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/... |
同上 |
docs/changelog/2026-04-27-feat-notify-config.md(feat 類型)實作前必驗:
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(目前不太可能但保險)。
| 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 範圍) |
| 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 None 在 get_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"} 雙路徑差異) |