For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把 Telegram / Discord 通知設定從 .env 搬到 per-tenant system_configs,並提供前端設定頁 + 後端測試送訊息端點。
Architecture: 沿用既有 jedi-system-config 套件 + RLS tenant 隔離;channel 級設定(enabled / secret / chat_id)以 (group=NOTIFY_CONFIG, key=DISCORD|TELEGRAM) 兩筆 row 儲存;caller 改在主 thread 預解析 cfg 後傳入 threading.Thread kwargs(避開 ContextVar 不跨 thread 問題);批次 suppress 改用 complete_job(suppress_notify=True) signature 穿透。
Tech Stack: Flask + Flask-RESTful + flask-apispec / SQLAlchemy + PostgreSQL 15 RLS / dependency-injector / jedi-common / jedi-system-config / jedi-notification / Vue 3 + PrimeVue + Vite / pytest.
Spec reference: docs/features/FR-020-2604-notify-config/design.md
Behavior 規範:
pytest + grep 驗證 + manual smoke 確認後才推進下一個 Phasepytest 失敗、grep 仍找到殘留 os.getenv("SEND_NOTIFY_MODULES")、UI 操作異常 → 停下來檢視錯誤,不繼續主專案兩個測試目錄(重要):
test/ — pure unit tests,沒有 conftest.py,純 mock/patch,pytest 直接跑tests/ — integration tests,有 tests/conftest.py 提供 fixtures app / client / admin_headers / tenant2_headers / no_auth_headers / mock_captcha / mock_notification / admin_tokentest/、route 整合測試放 tests/主專案 envelope 格式(重要 — 已驗證自 source code):
return_response(True, data)):{"status": true, "data": ...} (common/util/response_util.py:4-11)register_error_handlers 包 ClientError/ServerError):{"error_code": "<code_string>", "msg": "<message>"} (jedi-common/handler/handler.py:18-19) —— 無 status/data 包BadRequestError → 400)BaseService.js 看 status === true 判定 success;失敗則 axios 自己看 status code 4xx 進 catchcode:0 / code=1 / data.message」舊字眼皆已修正BadRequestError 等例外簽名(驗證自 jedi_common/handler/exception.py:22-25):
BadRequestError(error_code: BaseCode) —— 只接 error_code,不支援 detail / message kwarglogger.exception(...) 寫 BE log,不傳到前端| 檔案 | 責任 |
|---|---|
api/notify_config/__init__.py |
create_module() 無參數、return blueprint |
api/notify_config/routes/__init__.py |
routes package |
api/notify_config/routes/notify_config_route.py |
POST /notify-config/<channel>/test route |
api/notify_config/serializers/__init__.py |
serializers package |
api/notify_config/serializers/notify_config.py |
NotifyConfigTestRequest / NotifyConfigTestResponse schemas |
app/notify_config/__init__.py |
模組空 init |
app/notify_config/service/__init__.py |
service package |
app/notify_config/service/notify_config_test_service.py |
NotifyConfigTestService.test_channel(channel, value, change_pwd) |
di_containers/notify_config/__init__.py |
container package |
di_containers/notify_config/containers.py |
NotifyConfigContainer |
scripts/sql/2026-04-27-add-notify-config-permissions.sql |
ui_routes / capabilities / route_capabilities / role_capabilities migration |
test/test_notification_service.py |
unit 測試 NotificationService(pure mock/patch,無 conftest) |
test/test_notify_config_test_service.py |
unit 測試 NotifyConfigTestService(pure mock/patch) |
tests/test_notify_config_route.py |
整合測試 test endpoint(放 tests/,用 admin_headers) |
tests/test_system_config_hidden_secret.py |
整合測試 GET 不外洩 NOTIFY_CONFIG secret |
docs/changelog/2026-04-27-feat-notify-config.md |
變更紀錄 |
| 檔案 | 責任變更 |
|---|---|
app/notification/service/notification_service.py |
重寫:移除 os.getenv、加 get_channel_config(channel)、send_* 加 cfg kwarg、加 logger.warning、加 user_context 檢查 |
app/flow_engine/service/workflow_execution_service.py |
改 complete_job / revert_job signature 加 suppress_notify;3 處 notify_* fan-out 移除 os.getenv("SEND_NOTIFY_MODULES") 改主 thread 預解析 cfg |
app/grc/service/project_service.py:644 |
移除 os.getenv + 主 thread 預解析 |
app/task_survey/service/task_survey_service.py:444 |
同上 |
app/grc/service/job_batch_complete_service.py:38-90, 116 |
拔掉 os.environ 騷操作;改傳 suppress_notify=True 給 complete_job;line 116 _send_batch_summary_notification 移除 os.getenv 改預解析 |
api/system_config/routes/system_config_route.py:18 |
HIDDEN_SECRET 加 'NOTIFY_CONFIG' |
common/code/error_code.py |
新增 5 個 NOTIFY_* code;刪除 NOTIFICATION_DISCORD/TELEGRAM_CONFIG_NOT_FOUND;NOTIFICATION_SMTP_CONFIG_NOT_FOUND code 改為 NOTIFY_404001 |
di_containers/containers.py |
wire NotifyConfigContainer |
config/app_modules.py |
REGISTERED_APPS 加 'notify_config' |
config/translations/zh_Hant_TW/LC_MESSAGES/messages.po |
新增 NOTIFY_* 翻譯 + 移除已刪 keys |
config/translations/en/LC_MESSAGES/messages.po |
同上 |
.env / .env.example |
移除 SEND_NOTIFY_MODULES / TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID / DISCORD_WEBHOOK_URL |
| 檔案 | 責任 |
|---|---|
src/views/notify-config/NotifyConfigForm.vue |
雙 tab 表單(Discord / Telegram)+ 測試按鈕 + SMTP 提示 |
| 檔案 | 變更 |
|---|---|
src/config/api/api.js |
新增 NOTIFY_CONFIG_TEST 常數 |
src/config/router/index.js |
新增 /system/notify-config 路由(sort=175) |
src/config/locales/i18n/zh-tw/notify-config.json |
新增(per-page namespace JSON) |
src/config/locales/i18n/en/notify-config.json |
同上(en 翻譯) |
src/config/locales/i18n/zh-cn/notify-config.json |
同上(zh-cn 翻譯,依既有 locale 數量補齊) |
src/config/locales/i18n/zh-tw/common.json |
若 save_failed 不存在則新增 |
目的:把 spec 標註「實作前必驗」的事項全部跑完,避免後續寫到一半發現假設錯誤。
Files: N/A(純 DB query 驗證)
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg <<'SQL'
\d public.capabilities
\d public.role_capabilities
\d public.route_capabilities
\d public.ui_routes
SELECT id, name, tenant_id FROM public.roles WHERE name = 'Administrator' AND tenant_id IS NULL;
SQLExpected:
capabilities: (id, name, resource_type, action, description),UNIQUE on namerole_capabilities: PK (role_id, capability_id),無 tenant_idroute_capabilities: PK (route_id, capability_id),含 requirement 欄位(CHECK in ('ALL','ANY'))ui_routes: 含 (id, uid, pid, name, url, icon, enable, description, sort),name 無 UNIQUE→ 若 schema 有差異,停下來反映給 spec author 修 spec。
Files: N/A(grep)
grep -rnE "SEND_NOTIFY_MODULES|TELEGRAM_BOT_TOKEN|DISCORD_WEBHOOK_URL" \
app/ api/ common/ config/ tests/ scripts/ --include="*.py"Expected 結果應該完全對應 spec §4.2 表格列出的 5 個 caller,外加 app/notification/service/notification_service.py 自身。若多出來新檔案,新增到 plan 對應 Task。
grep -rnE "\.complete_job\(|\.revert_job\(|notify_user_todo_job\(" \
app/ api/ --include="*.py" | grep -v "def "Expected:確認 complete_job / revert_job 從哪些地方被呼叫。
# 對每個 grep 結果手動確認
sed -n '<line>,+5p' <file>→ 若 caller 用 positional 呼叫 complete_job 把所有參數帶進去(罕見但可能),加 suppress_notify 在最末位 default=False 不會破壞既有呼叫;但若 caller 用 **kwargs 動態帶入,需確認 schema。實際所有 caller 應該都是 kwargs= 風格(如 JobBatchCompleteService:60)。記下任何 positional caller,列入 Phase 3 Task 3.1 額外處理。
ls -R config/translations/Expected: config/translations/{en,zh_Hant_TW}/LC_MESSAGES/messages.{po,mo}。若不是這個結構,停下來檢視 babel.cfg。
pybabel compile -d config/translationsExpected: compiling catalog ... to ...messages.mo 無錯誤。
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg <<'SQL'
SELECT * FROM public.capabilities WHERE resource_type = 'cloud_integration';
SELECT * FROM public.ui_routes WHERE name = 'cloud-integrations';
SELECT rc.*, c.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 r.name = 'cloud-integrations';
SELECT rc.*, c.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 = 'cloud_integration' AND r.name = 'Administrator';
SQLExpected: 看到 4 capability + 1 ui_route + 4 route_capability + 4 role_capability。確認 spec §7.1 的 SQL 風格與此一致。
依記憶 reference_dev_env_setup.md:
cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
set -a && source .env && set +a
python main_socketio.pyExpected: 無 error 啟動於 port 8000,http://localhost:8000/swagger-ui/ 可開。
pytest test/ tests/ -x --co -q 2>&1 | tail -30Expected: collect 成功,無 import error。記下既有 pass/fail 數作 baseline。
grep -E "@pytest.fixture" tests/conftest.pyExpected fixture:app / client / admin_headers / tenant2_headers / no_auth_headers / admin_token / mock_captcha / mock_notification。
⚠️ 沒有 non-admin headers fixture —— 本 plan 整合測試對「非 admin」case 改用 monkeypatch get_user_context 在 service-level unit test 裡驗(不在 route integration test 驗)。
Files:
common/code/error_code.pygrep -n "NOTIFICATION_" common/code/error_code.py記下行號範圍。
新增 test/test_error_code_uniqueness.py(如果還沒有):
from common.code.error_code import ErrorCode
def test_no_duplicate_codes():
codes = [v[1] for v in ErrorCode.__dict__.values() if isinstance(v, tuple)]
assert len(codes) == len(set(codes)), \
f"Duplicate codes found: {[c for c in codes if codes.count(c) > 1]}"pytest test/test_error_code_uniqueness.py -vExpected: FAIL,列出 NOTIFICATION_404001 重複三次。
NOTIFICATION_DISCORD_CONFIG_NOT_FOUND 與 NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUNDNOTIFICATION_SMTP_CONFIG_NOT_FOUND code 從 "NOTIFICATION_404001" 改為 "NOTIFY_404001" 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")pytest test/test_error_code_uniqueness.py -vExpected: PASS。
grep -rnE "NOTIFICATION_DISCORD_CONFIG_NOT_FOUND|NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND" \
app/ api/ common/ config/ test/ --include="*.py"Expected: 僅 app/notification/service/notification_service.py 仍有引用——這個會在 Phase 2 一併處理(caller 改成靜默 skip 後就不再 raise)。
git add common/code/error_code.py test/test_error_code_uniqueness.py
git commit -m "refactor(error-code): unify NOTIFY_* prefix, fix NOTIFICATION_404001 共用 bug"Files:
config/translations/zh_Hant_TW/LC_MESSAGES/messages.poconfig/translations/en/LC_MESSAGES/messages.pogrep -n "NOTIFICATION_" config/translations/zh_Hant_TW/LC_MESSAGES/messages.po刪除:
msgid "NOTIFICATION_DISCORD_CONFIG_NOT_FOUND"
msgstr "..."
msgid "NOTIFICATION_TELEGRAM_CONFIG_NOT_FOUND"
msgstr "..."
新增:
msgid "NOTIFY_404001"
msgstr "SMTP 設定不存在"
msgid "NOTIFY_400001"
msgstr "通知測試失敗"
msgid "NOTIFY_400002"
msgstr "無既存 secret 可沿用,請填入完整 token"
msgid "NOTIFY_400003"
msgstr "不支援的通知頻道"
msgid "NOTIFY_403001"
msgstr "僅系統管理員可執行通知測試"
msgid "NOTIFY_500001"
msgstr "缺少 tenant context,無法讀取通知設定"
同上,但 msgstr 改為英文。
pybabel compile -d config/translationsExpected: compiling catalog ... messages.mo 兩個 locale 都成功。
# 確認 .gitignore 是否排除 .mo
grep -E "\.mo$" .gitignore || echo "WARN: .mo not gitignored"
git add config/translations/**/*.po
git commit -m "i18n(notify-config): add NOTIFY_* translations, remove deprecated NOTIFICATION_* keys"⚠️ .mo 是編譯產物,慣例不入 git。若 .gitignore 沒排除,先補規則或這次手動排除。
Files:
test/test_notification_service.py# test/test_notification_service.py
from unittest.mock import MagicMock, patch
import pytest
from jedi_common.handler.exception import ServerError, NotFound
from common.code.error_code import ErrorCode
@pytest.fixture
def mock_user_context():
user = MagicMock()
user.tenant_id = 102
user.is_admin = True
return user
@pytest.fixture
def system_config_service():
return MagicMock()
@pytest.fixture
def notification_service(system_config_service):
from app.notification.service.notification_service import NotificationService
return NotificationService(system_config_service)
# ─── get_channel_config ───
class TestGetChannelConfig:
def test_no_user_context_raises(self, notification_service):
# ⚠️ ClientError/ServerError 屬性是 .error_code 不是 .code
with patch("app.notification.service.notification_service.get_user_context", return_value=None):
with pytest.raises(ServerError) as exc:
notification_service.get_channel_config("DISCORD")
assert exc.value.error_code == "NOTIFY_500001"
def test_with_context_and_existing_row(
self, notification_service, system_config_service, mock_user_context
):
cfg_dto = MagicMock(value={"enabled": True, "secret": "webhook"})
system_config_service.get_system_config_by_key.return_value = cfg_dto
with patch("app.notification.service.notification_service.get_user_context", return_value=mock_user_context):
result = notification_service.get_channel_config("DISCORD")
assert result == {"enabled": True, "secret": "webhook"}
def test_with_context_and_no_row(
self, notification_service, system_config_service, mock_user_context
):
system_config_service.get_system_config_by_key.side_effect = NotFound(
ErrorCode.NOTIFY_404001
)
with patch("app.notification.service.notification_service.get_user_context", return_value=mock_user_context):
result = notification_service.get_channel_config("DISCORD")
assert result is None
# ─── send_discord_notification ───
class TestSendDiscord:
def test_no_cfg_no_user_context_raises(self, notification_service):
with patch("app.notification.service.notification_service.get_user_context", return_value=None):
with pytest.raises(ServerError):
notification_service.send_discord_notification("hello")
def test_with_cfg_disabled_skips(self, notification_service, caplog):
cfg = {"enabled": False, "secret": "webhook"}
notification_service.send_discord_notification("hello", cfg=cfg)
assert "Discord notification skipped" in caplog.text
def test_with_cfg_no_secret_skips(self, notification_service, caplog):
cfg = {"enabled": True}
notification_service.send_discord_notification("hello", cfg=cfg)
assert "Discord notification skipped" in caplog.text
def test_with_valid_cfg_calls_notifier(self, notification_service):
cfg = {"enabled": True, "secret": "https://discord.com/api/webhooks/xxx"}
with patch("app.notification.service.notification_service.Notifier") as MockNotifier:
instance = MockNotifier.return_value
notification_service.send_discord_notification("hello", cfg=cfg)
MockNotifier.assert_called_once()
instance.send_notification.assert_called_once()
# ─── send_telegram_notification ───
class TestSendTelegram:
def test_with_cfg_missing_chat_id_skips(self, notification_service, caplog):
cfg = {"enabled": True, "secret": "bot_token"} # 缺 chat_id
notification_service.send_telegram_notification("hello", cfg=cfg)
assert "Telegram notification skipped" in caplog.text
def test_with_valid_cfg_calls_notifier(self, notification_service):
cfg = {"enabled": True, "secret": "bot_token", "chat_id": "123"}
with patch("app.notification.service.notification_service.Notifier") as MockNotifier:
notification_service.send_telegram_notification("hello", cfg=cfg)
MockNotifier.assert_called_once()
# ─── send_mail_notification regression ───
class TestSendMailRegression:
def test_smtp_missing_raises_notfound(
self, notification_service, system_config_service, mock_user_context
):
system_config_service.get_system_config_by_key.side_effect = NotFound(ErrorCode.NOTIFY_404001)
with patch("app.notification.service.notification_service.get_user_context", return_value=mock_user_context):
with pytest.raises(NotFound):
notification_service.send_mail_notification("a@b.com", "subj", "content")pytest test/test_notification_service.py -vExpected: 多數 FAIL(NotificationService 還沒改寫)。
git add test/test_notification_service.py
git commit -m "test(notify-config): add NotificationService unit tests (RED)"Files:
app/notification/service/notification_service.pycat app/notification/service/notification_service.py# -*- coding: utf-8 -*-
import logging
from jedi_common.handler.exception import NotFound, ServerError
from jedi_common.session.auth.auth_context import get_user_context
from jedi_system_config.app.service.system_config_service import SystemConfigService
from jedi_notification.app.service.notification_service import NotificationService as Notifier
from jedi_notification.app.dto.notification_request_dto import (
MailRequestDTO, TelegramRequestDTO, DiscordRequestDTO,
)
from jedi_notification.app.dto.smtp_email_config_dto import SmtpEmailConfigDTO
from jedi_notification.app.dto.discord_config_dto import DiscordConfigDTO
from jedi_notification.app.dto.telegram_config_dto import TelegramConfigDTO
from common.code.error_code import ErrorCode
logger = logging.getLogger(__name__)
class NotificationService:
def __init__(self, system_config_service: SystemConfigService):
self.system_config_service = system_config_service
def _tenant_id_for_log(self) -> str:
try:
ctx = get_user_context()
return str(ctx.tenant_id) if ctx else "unknown(thread)"
except Exception:
return "unknown"
def get_channel_config(self, channel: str) -> dict | None:
"""讀當前 tenant 的 NOTIFY_CONFIG/<channel>。
必須在主 thread(有 user_context)呼叫,子 thread 內 ContextVar 不繼承。
"""
if get_user_context() is None:
# jedi_common 沒有 InternalServerError 子類,直接用 base ServerError
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 get_notifier(self):
"""既有 SMTP/* 邏輯,不動。"""
smtp_config = self.system_config_service.get_system_config_by_key("SMTP", "*")
if not smtp_config:
raise NotFound(ErrorCode.NOTIFY_404001)
smtp_config = smtp_config.value
smtp_config_dto = SmtpEmailConfigDTO(
smtp_server=smtp_config.get("host"),
port=smtp_config.get("port"),
from_address=smtp_config.get("from"),
from_name=smtp_config.get("from_name"),
username=smtp_config.get("user"),
password=smtp_config.get("secret"),
tls=smtp_config.get("tls"),
is_html=smtp_config.get("is_html"),
)
return Notifier("SMTP_MAIL", smtp_config_dto.dict())
def send_mail_notification(self, to, subject, content, is_html=False):
mail_request_dto = MailRequestDTO(
to=[to], subject=subject, content=content, is_html=is_html
)
return self.get_notifier().send_notification(mail_request_dto)
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: disabled or missing config (tenant_id=%s)",
self._tenant_id_for_log(),
)
return None
config = DiscordConfigDTO(webhook_url=cfg["secret"])
return 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: 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"],
)
return Notifier("TELEGRAM", config.dict()).send_notification(
TelegramRequestDTO(message=content)
)pytest test/test_notification_service.py -vExpected: 全 PASS。若仍有 fail,依錯誤訊息逐個修。
git add app/notification/service/notification_service.py
git commit -m "refactor(notification): rewrite NotificationService for per-tenant NOTIFY_CONFIG (GREEN)"complete_job / revert_job signature 加 suppress_notifyFiles:
app/flow_engine/service/workflow_execution_service.py:553-, 576-新增 test/test_workflow_execution_suppress_notify.py:
# 整合測試:複用 conftest.py 既有 fixture
from unittest.mock import patch
def test_complete_job_suppress_notify_true_skips_notification(client, headers):
"""設 suppress_notify=True 後,notify_user_todo_job 不被呼叫。"""
with patch("app.flow_engine.service.workflow_execution_service.WorkflowExecutionService.notify_user_todo_job") as mock_notify:
# 呼叫 complete_job 透過 batch_complete service(最容易觸發)
# ... 視既有 conftest 提供哪些 fixture,可能需建立 minimal scenario
# 若難以整合測試,改為 pure unit test patch 整個 method
pass
# 退而求其次的 unit test
def test_complete_job_signature_accepts_suppress_notify():
from app.flow_engine.service.workflow_execution_service import WorkflowExecutionService
import inspect
sig = inspect.signature(WorkflowExecutionService.complete_job)
assert "suppress_notify" in sig.parameters
assert sig.parameters["suppress_notify"].default is Falsepytest test/test_workflow_execution_suppress_notify.py -vExpected: FAIL(signature 還沒改)。
workflow_execution_service.py:553 附近找到 def complete_job(...),在 signature 末加 suppress_notify: bool = False。
line 572 附近原本:
self.notify_user_todo_job(job_execution.id)改為:
if not suppress_notify:
self.notify_user_todo_job(job_execution.id)revert_job (line 576-) 同樣處理:signature 加 suppress_notify: bool = False,line 635 附近 notify_user_todo_job 呼叫包 if not suppress_notify。
pytest test/test_workflow_execution_suppress_notify.py -vExpected: PASS。
pytest test/ -k "workflow_execution" -vExpected: 既有測試全 PASS(default 是 False,行為不變)。
git add app/flow_engine/service/workflow_execution_service.py test/test_workflow_execution_suppress_notify.py
git commit -m "feat(workflow-execution): add suppress_notify param to complete_job/revert_job"Files:
app/flow_engine/service/workflow_execution_service.py(三 method:notify_users_batch_assigned / notify_user_todo_job / notify_control_reviewers_on_task_complete)⚠️ 行號為實作當下 grep 出來,而非寫死:
grep -nE "def notify_users_batch_assigned|def notify_user_todo_job|def notify_control_reviewers_on_task_complete" \
app/flow_engine/service/workflow_execution_service.pynotify_users_batch_assigned刪除:
notify_modules_value = os.getenv("SEND_NOTIFY_MODULES", None)
if not notify_modules_value:
return True
notify_modules = notify_modules_value.split(",")method 開頭改加:
discord_cfg = self.notification_service.get_channel_config("DISCORD")
telegram_cfg = self.notification_service.get_channel_config("TELEGRAM")迴圈內三段 if "MAIL/TELEGRAM/DISCORD" in notify_modules 改:
threading.Thread(
target=self.notification_service.send_mail_notification,
kwargs={"to": user.email, "subject": subject, "content": message, "is_html": True},
).start() # MAIL 永遠送
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},
).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()pytest test/ tests/ --co -q 2>&1 | tail -10git add app/flow_engine/service/workflow_execution_service.py
git commit -m "refactor(workflow-execution): cfg pre-resolve in notify_users_batch_assigned"⚠️ Full regression 留到 Task 3.2c 三段都改完一起跑(避免重複跑同一套 9 分鐘)。
notify_user_todo_job刪除 notify_modules_value block。預解析放在 if job.status != JobStatus.PROCESSING: return True 與 project status check 之後、assignees 迴圈之前:
discord_cfg = self.notification_service.get_channel_config("DISCORD")
telegram_cfg = self.notification_service.get_channel_config("TELEGRAM")迴圈內 fan-out 改成同 Task 3.2a 模式。
pytest test/ tests/ --co -q 2>&1 | tail -10git add app/flow_engine/service/workflow_execution_service.py
git commit -m "refactor(workflow-execution): cfg pre-resolve in notify_user_todo_job"notify_control_reviewers_on_task_complete同上模式,預解析放在 reviewer filter 完成、確定有對象後。
pytest test/ tests/ -k "workflow or flow_engine" -vExpected: 全 PASS。
grep -nE "SEND_NOTIFY_MODULES" app/flow_engine/service/workflow_execution_service.pyExpected: 無輸出。
git add app/flow_engine/service/workflow_execution_service.py
git commit -m "refactor(workflow-execution): cfg pre-resolve in notify_control_reviewers_on_task_complete"Files:
app/grc/service/project_service.py:644仿 Task 3.2 模式,移除 SEND_NOTIFY_MODULES block,預解析放 method 開頭。
pytest test/ -k "grc.project or project_service" -vgit add app/grc/service/project_service.py
git commit -m "refactor(grc-project): main-thread cfg pre-resolve for notify fan-out"Files:
app/task_survey/service/task_survey_service.py:444預解析放在 early return 後、迴圈前。
pytest test/ -k "task_survey" -vgit add app/task_survey/service/task_survey_service.py
git commit -m "refactor(task-survey): main-thread cfg pre-resolve for notify fan-out"Files:
app/grc/service/job_batch_complete_service.py:38-90, 108-, 116刪除:
original_notify = os.environ.get("SEND_NOTIFY_MODULES")
os.environ["SEND_NOTIFY_MODULES"] = ""與對應 finally block。
try 區塊保留(其他 logic)但內部 complete_job 呼叫加 suppress_notify=True:
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,整個刪掉。
line 116 notify_modules_value = os.getenv("SEND_NOTIFY_MODULES", None) 開始的 block 拔掉,預解析改放 method 開頭,fan-out 套 cfg kwargs。
logger.info(f"[batch_complete] SEND_NOTIFY_MODULES={os.environ.get('SEND_NOTIFY_MODULES')}")這行刪掉(資訊已過時)。
pytest test/ -k "batch_complete or job_batch" -vgrep -rnE "SEND_NOTIFY_MODULES" app/ api/ common/ config/ test/ scripts/ --include="*.py"Expected: 完全沒有結果(docs/ 目錄忽略)。
git add app/grc/service/job_batch_complete_service.py
git commit -m "refactor(batch-complete): replace os.environ hack with suppress_notify param"Files:
.env.env.example(如果有)-SEND_NOTIFY_MODULES=MAIL,DISCORD,TELEGRAM
-TELEGRAM_BOT_TOKEN=...
-TELEGRAM_CHAT_ID=...
-DISCORD_WEBHOOK_URL=...
+# 通知設定改至 UI 設定頁:/system/notify-config(per-tenant)
+# SMTP 設定改至 UI 設定頁:/system/smtp-config-manage(per-tenant)set -a && source .env && set +a && python main_socketio.pyExpected: 啟動成功。
git add .env.example # 若沒有 .env.example,先建一份
git commit -m "chore(env): drop SEND_NOTIFY_MODULES + telegram/discord vars (moved to per-tenant config UI)"⚠️ .env 一般不入 git,本機改完即可。
Files:
app/notify_config/__init__.pyapp/notify_config/service/__init__.pyapp/notify_config/service/notify_config_test_service.pytest/test_notify_config_test_service.py:
from unittest.mock import MagicMock, patch
import pytest
from jedi_common.handler.exception import BadRequestError, ForbiddenError, NotFound
from common.code.error_code import ErrorCode
@pytest.fixture
def mock_admin_user():
user = MagicMock()
user.is_admin = True
return user
@pytest.fixture
def mock_non_admin_user():
user = MagicMock()
user.is_admin = False
return user
@pytest.fixture
def system_config_service():
return MagicMock()
@pytest.fixture
def service(system_config_service):
from app.notify_config.service.notify_config_test_service import NotifyConfigTestService
return NotifyConfigTestService(system_config_service)
class TestTestChannel:
def test_non_admin_raises_forbidden(self, service, mock_non_admin_user):
with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_non_admin_user):
with pytest.raises(ForbiddenError) as exc:
service.test_channel("DISCORD", {"secret": "x", "enabled": True})
assert exc.value.error_code == "NOTIFY_403001"
def test_unsupported_channel_raises(self, service, mock_admin_user):
with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
with pytest.raises(BadRequestError):
service.test_channel("UNKNOWN", {})
def test_mail_channel_unsupported(self, service, mock_admin_user):
with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
with pytest.raises(BadRequestError):
service.test_channel("MAIL", {})
def test_change_pwd_false_no_existing_secret_raises(
self, service, system_config_service, mock_admin_user
):
system_config_service.get_system_config_by_key.side_effect = NotFound(ErrorCode.NOTIFY_404001)
with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
with pytest.raises(BadRequestError):
service.test_channel("DISCORD", {"enabled": True}, change_pwd=False)
def test_discord_success_returns_metadata(
self, service, mock_admin_user
):
with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
with patch.object(service, "_test_discord", return_value=None):
result = service.test_channel(
"DISCORD",
{"enabled": True, "secret": "https://discord.com/api/webhooks/x"},
)
assert result["channel"] == "DISCORD"
assert "sent_at" in result
def test_discord_failure_raises_test_failed(
self, service, mock_admin_user
):
# ⚠️ BadRequestError 簽名 (error_code) 只接 1 參數
# ⚠️ ClientError 用 .error_code 屬性(非 .code),參考 jedi_common/handler/exception.py
with patch("app.notify_config.service.notify_config_test_service.get_user_context", return_value=mock_admin_user):
with patch.object(service, "_test_discord", side_effect=RuntimeError("401")):
with pytest.raises(BadRequestError) as exc:
service.test_channel(
"DISCORD",
{"enabled": True, "secret": "x"},
)
assert exc.value.error_code == "NOTIFY_400001"pytest test/test_notify_config_test_service.py -vExpected: ALL FAIL(service 還沒建)。
app/notify_config/__init__.py、app/notify_config/service/__init__.py 為空檔。
app/notify_config/service/notify_config_test_service.py:
import logging
from datetime import datetime
from jedi_common.handler.exception import (
BadRequestError, ForbiddenError, NotFound,
)
from jedi_common.session.auth.auth_context import get_user_context
from jedi_common.session.database.db import transaction
from jedi_notification.app.service.notification_service import NotificationService as Notifier
from jedi_notification.app.dto.notification_request_dto import (
DiscordRequestDTO, TelegramRequestDTO,
)
from jedi_notification.app.dto.discord_config_dto import DiscordConfigDTO
from jedi_notification.app.dto.telegram_config_dto import TelegramConfigDTO
from common.code.error_code import ErrorCode
logger = logging.getLogger(__name__)
class NotifyConfigTestService:
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:
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:
user = get_user_context()
if not user or not user.is_admin:
raise ForbiddenError(ErrorCode.NOTIFY_CHANNEL_TEST_FORBIDDEN)
if channel not in ("DISCORD", "TELEGRAM"):
raise BadRequestError(ErrorCode.NOTIFY_CHANNEL_NOT_SUPPORTED)
resolved = self._resolve_value(channel, value, change_pwd)
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)
def _test_discord(self, value: dict):
config = DiscordConfigDTO(webhook_url=value["secret"])
Notifier("DISCORD", config.dict()).send_notification(
DiscordRequestDTO(content="✅ Test message from Guidant.AI")
)
def _test_telegram(self, value: dict):
config = TelegramConfigDTO(
bot_token=value["secret"],
chat_id=value["chat_id"],
)
Notifier("TELEGRAM", config.dict()).send_notification(
TelegramRequestDTO(message="✅ Test message from Guidant.AI")
)pytest test/test_notify_config_test_service.py -vgit add app/notify_config/ test/test_notify_config_test_service.py
git commit -m "feat(notify-config): add NotifyConfigTestService (GREEN)"Files:
di_containers/notify_config/__init__.pydi_containers/notify_config/containers.pydi_containers/containers.pydi_containers/notify_config/__init__.py 空。
di_containers/notify_config/containers.py:
from dependency_injector import containers, providers
from app.notify_config.service.notify_config_test_service import NotifyConfigTestService
class NotifyConfigContainer(containers.DeclarativeContainer):
system_config_service = providers.Dependency()
notify_config_test_service = providers.Singleton(
NotifyConfigTestService,
system_config_service=system_config_service,
)Read 既有檔案找到 system_config_container 定義位置,下方新增:
from di_containers.notify_config.containers import NotifyConfigContainer
# 在 Containers class 內:
notify_config_container = providers.Container(
NotifyConfigContainer,
system_config_service=system_config_container.system_config_service,
)set -a && source .env && set +a && python main_socketio.py 2>&1 | head -30Expected: 無 import / DI error。
git add di_containers/notify_config/ di_containers/containers.py
git commit -m "feat(di): wire NotifyConfigContainer"Files:
api/notify_config/__init__.pyapi/notify_config/routes/__init__.pyapi/notify_config/routes/notify_config_route.pyapi/notify_config/serializers/__init__.pyapi/notify_config/serializers/notify_config.pyconfig/app_modules.pytests/test_notify_config_route.py:
from unittest.mock import patch
class TestNotifyConfigTestEndpoint:
"""整合測試只驗 happy path + 4xx wire-level;403 (非 admin) 由 service unit test 涵蓋。"""
def test_no_jwt_returns_401(self, client, no_auth_headers):
res = client.post(
"/api/1.0/notify-config/DISCORD/test",
json={"value": {}},
headers=no_auth_headers,
)
assert res.status_code == 401
def test_unsupported_channel_returns_400(self, client, admin_headers):
res = client.post(
"/api/1.0/notify-config/MAIL/test",
json={"value": {}, "changePwd": True},
headers=admin_headers,
)
assert res.status_code == 400
body = res.get_json()
# ⚠️ 失敗 envelope 是 {"error_code", "msg"}(jedi-handler 序列化),無 status/data 包
assert body["error_code"] == "NOTIFY_400003"
def test_discord_success_returns_200(self, client, admin_headers):
with patch(
"app.notify_config.service.notify_config_test_service.NotifyConfigTestService._test_discord",
return_value=None,
):
res = client.post(
"/api/1.0/notify-config/DISCORD/test",
json={
"value": {"enabled": True, "secret": "https://discord.com/api/webhooks/x"},
"changePwd": True,
},
headers=admin_headers,
)
assert res.status_code == 200
body = res.get_json()
# 成功 envelope 是 {"status": True, "data": {...}}
assert body["status"] is True
assert body["data"]["channel"] == "DISCORD"
assert "sent_at" in body["data"]
def test_discord_failure_returns_400(self, client, admin_headers):
with patch(
"app.notify_config.service.notify_config_test_service.NotifyConfigTestService._test_discord",
side_effect=RuntimeError("401 Unauthorized"),
):
res = client.post(
"/api/1.0/notify-config/DISCORD/test",
json={
"value": {"enabled": True, "secret": "x"},
"changePwd": True,
},
headers=admin_headers,
)
assert res.status_code == 400
body = res.get_json()
assert body["error_code"] == "NOTIFY_400001"⚠️ admin_headers / no_auth_headers 由 tests/conftest.py 提供,無 non-admin headers fixture,因此「非 admin → 403」case 留給 service-level unit test (Task 5.1) 涵蓋。
pytest tests/test_notify_config_route.py -vExpected: ALL FAIL(route 還沒建)。
api/notify_config/serializers/notify_config.py:
from marshmallow import Schema, fields
class NotifyConfigTestRequest(Schema):
value = fields.Dict(required=True)
changePwd = fields.Boolean(load_default=True)
class NotifyConfigTestResponse(Schema):
channel = fields.String()
sent_at = fields.String()api/notify_config/routes/notify_config_route.py:
from dependency_injector.wiring import inject, Provide
from flask import request
from flask_apispec import MethodResource, doc, marshal_with, use_kwargs
from flask_jwt_extended import jwt_required
from app.notify_config.service.notify_config_test_service import NotifyConfigTestService
from api.notify_config.serializers.notify_config import (
NotifyConfigTestRequest, NotifyConfigTestResponse,
)
from common.enum.schema_code import AUTH_PARAMS
from common.util.response_util import return_response
from di_containers.containers import Containers
class NotifyConfigTestRoute(MethodResource):
@doc(description='測試通知頻道', tags=['Notify Config'], params=AUTH_PARAMS)
@use_kwargs(NotifyConfigTestRequest, location='json', apply=False)
@marshal_with(NotifyConfigTestResponse, apply=False)
@jwt_required()
@inject
def post(
self,
channel,
notify_config_test_service: NotifyConfigTestService = Provide[
Containers.notify_config_container.notify_config_test_service
],
):
payload = request.get_json(silent=True) or {}
result = notify_config_test_service.test_channel(
channel=channel,
value=payload.get("value", {}),
change_pwd=payload.get("changePwd", True),
)
return return_response(True, result)⚠️ 既有 create_module() 無參數、return blueprint(不接 app、不主動 register_blueprint、不主動 docs.register,由 main.py 外層處理)。直接複製貼上:
"""
Notify Config Module - Flask Blueprint Configuration
路由配置
"""
from flask import Blueprint
from flask_restful import Api
def create_module():
from api.notify_config.routes.notify_config_route import NotifyConfigTestRoute
bp = Blueprint('notify-config', __name__, url_prefix='/api/1.0')
api = Api(bp)
api.add_resource(NotifyConfigTestRoute, '/notify-config/<string:channel>/test')
return bp
def create_doc():
passREGISTERED_APPS 加入 'notify_config'(順序視既有慣例插在 'system_config' 後)。
pytest tests/test_notify_config_route.py -v# 啟動前先清乾淨
lsof -ti :8000 | xargs kill -9 2>/dev/null
set -a && source .env && set +a
python main_socketio.py &
BE_PID=$!
sleep 3
curl -X POST http://localhost:8000/api/1.0/notify-config/DISCORD/test \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"value": {"enabled": true, "secret": "https://discord.com/api/webhooks/INVALID"}, "changePwd": true}'
# 收尾
kill -9 $BE_PID 2>/dev/null
lsof -ti :8000 | xargs kill -9 2>/dev/nullExpected: 400 + envelope {"error_code": "NOTIFY_400001", "msg": "通知測試失敗"}(無 status/data 包)。
git add api/notify_config/ config/app_modules.py tests/test_notify_config_route.py
git commit -m "feat(notify-config): add POST /notify-config/<channel>/test endpoint"Files:
scripts/sql/2026-04-27-add-notify-config-permissions.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>
-- Idempotent:可重複執行
BEGIN;
-- ⚠️ 寫 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,改用 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';PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg \
-f scripts/sql/2026-04-27-add-notify-config-permissions.sqlExpected: BEGIN ... INSERT 1 ... INSERT 2 ... INSERT 2 ... INSERT 2 ... COMMIT
PGPASSWORD='jedi@123!' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_stg \
-f scripts/sql/2026-04-27-add-notify-config-permissions.sqlExpected: BEGIN ... INSERT 0 ... INSERT 0 ... INSERT 0 ... INSERT 0 ... COMMIT(全部 skip)。
SELECT * FROM public.capabilities WHERE resource_type = 'notify_config';
SELECT * FROM public.ui_routes WHERE name = 'notify-config';
SELECT rc.*, c.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 r.name = 'notify-config';
SELECT rc.*, c.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';Expected: 2 capability + 1 ui_route + 2 route_capability + 2 role_capability。
git add scripts/sql/2026-04-27-add-notify-config-permissions.sql
git commit -m "migration(notify-config): add ui_routes/capabilities/role_capabilities (2026-04-27)"Files:
src/config/api/api.jssrc/config/locales/i18n/zh-tw/notify-config.jsonsrc/config/locales/i18n/en/notify-config.jsonsrc/config/locales/i18n/zh-cn/notify-config.jsonsrc/config/locales/i18n/{en,zh-tw,zh-cn}/common.json(若 save_failed 不存在)⚠️ 前端 i18n 結構:每個 view 一個 namespace JSON(非 JS object);命名 kebab-case;目錄為 zh-tw(非 zh_Hant_TW)。
src/config/api/api.js 末尾加:
NOTIFY_CONFIG_TEST: getUrl('/notify-config'),# 對照既有 view 怎麼用 t()
grep -nE "t\('common\.save" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/views/issue-integrate-config/IssueIntegrateConfigForm.vue
# 看 common.json 已有什麼 keys
grep -E "save_" ~/Projects/Billows/Audit-Manager/compliance-manager-fe/src/config/locales/i18n/zh-tw/common.json→ 若 save_failed 不存在,三個 locale 的 common.json 各加一筆。
src/config/locales/i18n/zh-tw/notify-config.json:
{
"title": "通知設定",
"mail_help": "Email 通知由 SMTP 設定頁控制",
"mail_help_link": "前往 SMTP 設定",
"discord": "Discord",
"telegram": "Telegram",
"enabled": "啟用",
"webhook_url": "Webhook URL",
"bot_token": "Bot Token",
"chat_id": "Chat ID",
"save_button": "儲存",
"test_button": "測試送一則",
"test_success": "測試訊息已送出",
"test_failed": "測試失敗"
}{
"title": "Notification Settings",
"mail_help": "Email notifications are controlled via SMTP settings",
"mail_help_link": "Go to SMTP settings",
"discord": "Discord",
"telegram": "Telegram",
"enabled": "Enabled",
"webhook_url": "Webhook URL",
"bot_token": "Bot Token",
"chat_id": "Chat ID",
"save_button": "Save",
"test_button": "Send Test Message",
"test_success": "Test message sent",
"test_failed": "Test failed"
}複製 zh-tw 為簡體(保留 key、translations 改簡體字)。若團隊未維護 zh-cn 同步,照既有最新一個 view 的 zh-cn 完整度處理。
src/config/locales/index.js 是否自動掃 JSON 還是要手動 register?grep 確認:
grep -nE "import|require|notify-config" src/config/locales/index.js src/config/locales/i18n/sync-translations.js | head -10→ 若是 auto-scan,不必動;若手動,補一行 import。
git add src/config/api/api.js src/config/locales/i18n/
git commit -m "feat(fe-notify-config): add API const + i18n namespace"Files:
src/views/notify-config/NotifyConfigForm.vuecat src/views/issue-integrate-config/IssueIntegrateConfigForm.vue抄結構。
範本(依實際範本可能要微調):
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
// ⚠️ 對齊既有 IssueIntegrateConfigForm.vue:5-6 import 慣例:
// - BaseService 是 named export,要實例化
// - API 是 default export
import { BaseService } from '@/service/BaseService';
import API from '@/config/api/api';
import { useToastUtil } from '@/utils/toastUtil';
const { showError, showSuccess } = useToastUtil();
const { t } = useI18n();
const baseService = new BaseService();
const tabIndex = ref(0);
const submitted = ref(false);
const discord = ref({ enabled: false, webhook_url: '', changePwd: false });
const telegram = ref({ enabled: false, bot_token: '', chat_id: '', changePwd: false });
const SECRET_PLACEHOLDER = '********';
onMounted(async () => {
await Promise.all([loadDiscord(), loadTelegram()]);
});
async function loadDiscord() {
try {
const res = await baseService.get(API.SYSTEM_CONFIG + '/NOTIFY_CONFIG/DISCORD');
if (res?.value) {
discord.value.enabled = res.value.enabled ?? false;
discord.value.webhook_url = res.value.secret !== undefined ? '' : SECRET_PLACEHOLDER;
// 後端 GET 已 strip secret,前端用 placeholder 顯示「已存在」
}
} catch (e) {
// 沒設過 → 表單空白即可
}
}
async function loadTelegram() {
try {
const res = await baseService.get(API.SYSTEM_CONFIG + '/NOTIFY_CONFIG/TELEGRAM');
if (res?.value) {
telegram.value.enabled = res.value.enabled ?? false;
telegram.value.bot_token = SECRET_PLACEHOLDER;
telegram.value.chat_id = res.value.chat_id ?? '';
}
} catch (e) {
// 沒設過
}
}
function buildPayload(channel) {
if (channel === 'DISCORD') {
return {
value: {
enabled: discord.value.enabled,
secret: discord.value.webhook_url === SECRET_PLACEHOLDER ? '' : discord.value.webhook_url,
},
changePwd: discord.value.webhook_url !== SECRET_PLACEHOLDER && discord.value.webhook_url !== '',
};
}
return {
value: {
enabled: telegram.value.enabled,
secret: telegram.value.bot_token === SECRET_PLACEHOLDER ? '' : telegram.value.bot_token,
chat_id: telegram.value.chat_id,
},
changePwd: telegram.value.bot_token !== SECRET_PLACEHOLDER && telegram.value.bot_token !== '',
};
}
// ⚠️ BaseService 已自動把 envelope `{status:true, data:...}` 解開回傳 `data`,
// 失敗時 reject `data` 本體(jedi-handler 包的 `{message, error_code}`)—— 不是 axios error
// ⚠️ vue-i18n 用 namespace.key 取(namespace = JSON 檔名 kebab-case)
// e.g. t('common.save_success'), t('notify-config.test_success')
// Phase 7 Task 7.1 Step 2 grep 確認既有用法
async function onSave(channel) {
submitted.value = true;
try {
const payload = buildPayload(channel);
await baseService.put(API.SYSTEM_CONFIG + `/NOTIFY_CONFIG/${channel}`, payload);
showSuccess(t('common.save_success'));
if (channel === 'DISCORD') await loadDiscord();
else await loadTelegram();
} catch (e) {
// axios 4xx response → e.response.data.msg(jedi-handler envelope)
const errMsg = e?.response?.data?.msg || t('common.save_failed');
showError(errMsg);
} finally {
submitted.value = false;
}
}
async function onTest(channel) {
submitted.value = true;
try {
const payload = buildPayload(channel);
await baseService.post(API.NOTIFY_CONFIG_TEST + `/${channel}/test`, payload);
showSuccess(t('notify-config.test_success'));
} catch (e) {
// BadRequestError 已拔掉 detail kwarg,前端只顯示固定 msg
// 失敗 envelope 是 {"error_code", "msg"}(無 status/data 包)
const errMsg = e?.response?.data?.msg || t('notify-config.test_failed');
showError(errMsg);
} finally {
submitted.value = false;
}
}
</script>
<template>
<div class="p-4">
<h3>{{ t('notify-config.title') }}</h3>
<Message severity="info" :closable="false">
{{ t('notify-config.mail_help') }}
<router-link to="/system/smtp-config-manage" class="ml-2">
{{ t('notify-config.mail_help_link') }}
</router-link>
</Message>
<TabView v-model:activeIndex="tabIndex" class="mt-3">
<TabPanel :header="t('notify-config.discord')">
<div class="field-checkbox">
<Checkbox v-model="discord.enabled" inputId="d-enabled" :binary="true" />
<label for="d-enabled" class="ml-2">{{ t('notify-config.enabled') }}</label>
</div>
<div class="field mt-3">
<label>{{ t('notify-config.webhook_url') }}</label>
<InputText v-model="discord.webhook_url" type="password" class="w-full" />
</div>
<div class="mt-3">
<Button :label="t('notify-config.save_button')" :loading="submitted" @click="onSave('DISCORD')" />
<Button :label="t('notify-config.test_button')" :loading="submitted" @click="onTest('DISCORD')" class="ml-2" severity="secondary" />
</div>
</TabPanel>
<TabPanel :header="t('notify-config.telegram')">
<div class="field-checkbox">
<Checkbox v-model="telegram.enabled" inputId="t-enabled" :binary="true" />
<label for="t-enabled" class="ml-2">{{ t('notify-config.enabled') }}</label>
</div>
<div class="field mt-3">
<label>{{ t('notify-config.bot_token') }}</label>
<InputText v-model="telegram.bot_token" type="password" class="w-full" />
</div>
<div class="field mt-3">
<label>{{ t('notify-config.chat_id') }}</label>
<InputText v-model="telegram.chat_id" class="w-full" />
</div>
<div class="mt-3">
<Button :label="t('notify-config.save_button')" :loading="submitted" @click="onSave('TELEGRAM')" />
<Button :label="t('notify-config.test_button')" :loading="submitted" @click="onTest('TELEGRAM')" class="ml-2" severity="secondary" />
</div>
</TabPanel>
</TabView>
</div>
</template>
⚠️ 範本依實際 IssueIntegrateConfigForm.vue 細節調整(component import、樣式 class)。
src/config/router/index.js,找到 /system/issue-integrate-config 那塊,前面加:
{
path: '/system/notify-config',
component: AppLayout,
children: [
{
path: '/system/notify-config',
name: 'notify-config',
component: () => import('@/views/notify-config/NotifyConfigForm.vue'),
meta: {
breadcrumb: [{ label: 'notify-config' }],
requiresAuth: true,
}
}
]
},# 啟前端前清乾淨 port 5180
lsof -ti :5180 | xargs kill -9 2>/dev/null
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
npm run dev &
FE_PID=$!
sleep 5
# 開瀏覽器:http://localhost:5180/system/notify-config,blsadmin 登入
# 驗證完收尾
kill -9 $FE_PID 2>/dev/null
lsof -ti :5180 | xargs kill -9 2>/dev/null驗證項目:
********cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe
git add src/views/notify-config/ src/config/router/index.js
git commit -m "feat(fe-notify-config): add NotifyConfigForm view + route"cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
pytest test/ -v 2>&1 | tail -50Expected: PASS 數量 ≥ Phase 0 baseline + 新增的測試。任何 NEW failures 必須處理。
依 spec §8.2 跑完整 checklist:
| 場景 | 結果 |
|---|---|
| dev 登入 blsadmin → 進「通知設定」頁 → 兩 tab | ☐ |
| 填 Discord webhook URL + 「測試」→ Discord 收到 | ☐ |
| webhook URL 故意填錯 → 「測試」 → toast 紅 + detail | ☐ |
儲存後重新整理 → secret 顯示 ******** |
☐ |
觸發 notify_users_batch_assigned → Discord 收到(依 dev 環境是否有觸發路徑) |
☐ |
enabled=false → 觸發事件 → Discord 不發 + log line |
☐ |
| 跨 tenant 測(用不同 tenant 的不同帳號)→ 設定不互通 | ☐ |
job_batch_complete → 期間不送逐筆通知 → 完成後彙總一次 |
☐ |
Files:
docs/changelog/2026-04-27-feat-notify-config.md---
type: feat
breaking: false
modules: [notification, system-config, workflow-execution, batch-complete, task-survey, grc-project]
issue: # (no issue, brainstorming-driven)
commit: <fill after final commit>
---
# 通知設定 per-tenant 化
## 需求說明(為什麼)
`.env` 裡的 `TELEGRAM_BOT_TOKEN` / `DISCORD_WEBHOOK_URL` 為單租戶設計,多客戶共用部署時 A 客戶的審計通知會打到 B 客戶的頻道。multi-tenant SaaS roadmap 也需要把通知設定下放給 tenant admin 自助管理。
## 變更範圍
### 後端
- `app/notification/service/notification_service.py` 重寫
- 新模組 `api/notify_config/` + `app/notify_config/`
- `app/flow_engine/service/workflow_execution_service.py`:3 處 fan-out 改主 thread 預解析 cfg;`complete_job` / `revert_job` 加 `suppress_notify` 參數
- `app/grc/service/project_service.py`、`app/task_survey/service/task_survey_service.py`、`app/grc/service/job_batch_complete_service.py`:移除 `os.getenv("SEND_NOTIFY_MODULES")`
- `api/system_config/routes/system_config_route.py:18` HIDDEN_SECRET 加 NOTIFY_CONFIG
- `common/code/error_code.py` 新增 NOTIFY_* + 修 NOTIFICATION_404001 共用 bug
- `scripts/sql/2026-04-27-add-notify-config-permissions.sql`
- pytest unit + 整合測試
- i18n .po 更新 + pybabel compile
### 前端
- `src/views/notify-config/NotifyConfigForm.vue` 新增(Discord / Telegram 雙 tab)
- router / api.js / i18n 對應更新
### .env
- 移除 `SEND_NOTIFY_MODULES` / `TELEGRAM_*` / `DISCORD_WEBHOOK_URL`
## API 變更
新增:
- `POST /api/1.0/notify-config/<channel>/test` — body `{value, changePwd}`,回傳測試結果
CRUD 重用既有 `/api/1.0/system/config/NOTIFY_CONFIG/<channel>`。
## 測試結果<pytest output 摘要貼這>
## 參考資訊
- Spec: `docs/features/FR-020-2604-notify-config/design.md`
- Plan: `docs/features/FR-020-2604-notify-config/implementation-plan.md`
- 後續工作:見 spec §12 Future Considerations
⚠️ 不要寫 changelog 自己的 hash(雞生蛋)。commit: 應指向最能代表本次 feature 主體的 commit——通常是 Phase 7 Task 7.2 的 NotifyConfigForm.vue commit hash(前端使用者看得到的入口):
# 在前端 repo 抓出 Phase 7 Task 7.2 的 commit hash
cd ~/Projects/Billows/Audit-Manager/compliance-manager-fe && git log --oneline -1 | awk '{print $1}'
# 把它填進 changelog frontmatter 的 commit: 欄位cd ~/Projects/Billows/Audit-Manager/compliance-manager-be
git add docs/changelog/2026-04-27-feat-notify-config.md
git commit -m "docs(changelog): notify-config per-tenant feature"→ changelog 自己的 commit 不需要回填 hash。commit: 欄位指向 feature 主體 commit 即可。
總計:~26 個 task,預估 15-18 小時
threading.Thread 內呼叫 notification_service.send_* 都必須由 caller 主 thread 預解析 cfg 後傳入。spec §10 規範:未來新 caller 也要遵守。complete_job / revert_job signature 變更 — default 是 False 對既有 caller 透明,但若有 monkey-patch 既有 method 的測試 / 程式碼,可能要對應更新。Phase 3 Task 3.1 的回歸測試應該抓得到。INSERT ... SELECT WHERE NOT EXISTS 寫法。reference_dev_db_psql_port.md,不要漏。feedback_sql_migration_use_cmmgr.md,cm_app 受 RLS 擋會靜默失敗。api/system_config/routes/ 全 method 仍只 @jwt_required,不做後端 capability gate(spec §9.1)。Test endpoint 例外(spec §5.3.2 強制 is_admin)。