fix(settings): Telegram 检测/测试闭环与 8 项告警开关恢复
检测与测试前自动持久化 Token/Chat ID;getUpdates 清除 Webhook 并返回具体 API 错误;恢复 NOTIFY 分项开关与免密 reveal。
This commit is contained in:
@@ -376,3 +376,23 @@
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:44:37+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md 37lines"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-telegram-fix.md"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
|
||||
{"ts":"2026-06-07T19:44:50+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 审计 — 设置页 Telegram 全流程修复
|
||||
|
||||
## 范围
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `frontend/src/pages/SettingsPage.vue` | persist/save/test/detect、8 NOTIFY 开关、reveal 免密 |
|
||||
| `frontend/src/types/api.ts` | `NOTIFY_TOGGLE_ITEMS` |
|
||||
| `server/api/settings.py` | Webhook 清除、错误详情、reveal 免密 |
|
||||
| `tests/test_settings_telegram.py` | 6 用例 |
|
||||
| `deploy/gate_log.jsonl` | 门控记录(自动生成) |
|
||||
|
||||
## Step 3 规则扫描
|
||||
|
||||
| H | 规则 | 结论 |
|
||||
|---|------|------|
|
||||
| H1 | 测试/检测前持久化 Token+Chat ID | FIXED — `persistTelegramConfig` |
|
||||
| H2 | getUpdates Webhook 冲突 | FIXED — `deleteWebhook` before poll |
|
||||
| H3 | Telegram API 错误可见 | FIXED — `ok:false` / sendMessage description |
|
||||
| H4 | 8 项 NOTIFY 开关 UI | FIXED — Vue v-switch + PUT |
|
||||
| H5 | reveal 免密(用户确认) | FIXED — JWT + 审计保留 |
|
||||
|
||||
## Closure
|
||||
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1–H5 | FIXED | 代码 + pytest 6/6 |
|
||||
|
||||
## DoD
|
||||
|
||||
- [x] pytest `test_settings_telegram.py` 6 passed
|
||||
- [x] changelog / audit
|
||||
- [x] 无不可变 key 变更
|
||||
|
||||
## 验证
|
||||
|
||||
`.venv/bin/pytest tests/test_settings_telegram.py -q`
|
||||
@@ -0,0 +1,37 @@
|
||||
# Changelog — 设置页 Telegram 全流程修复 + 告警开关恢复
|
||||
|
||||
**日期**:2026-06-07
|
||||
|
||||
## 摘要
|
||||
|
||||
修复设置页 Telegram 推送相关功能:检测/测试前自动持久化配置、独立保存按钮、getUpdates Webhook 冲突处理、测试 API 返回 Telegram 具体错误;恢复 Vue 迁移遗漏的 8 项告警通知开关;API Key / Bot Token 查看免二次密码(JWT + 审计仍保留)。
|
||||
|
||||
## 动机
|
||||
|
||||
用户反馈「检测 Chat ID / 发送测试 / 多项功能无法正常使用」:测试按钮不保存草稿配置、告警分项开关在 SPA 中缺失、getUpdates 在 Bot 曾设 Webhook 时恒为空、错误提示过于笼统。
|
||||
|
||||
## 涉及文件
|
||||
|
||||
- `frontend/src/pages/SettingsPage.vue` — `persistTelegramConfig`、保存按钮、8 项 NOTIFY 开关
|
||||
- `frontend/src/types/api.ts` — `NOTIFY_TOGGLE_ITEMS`
|
||||
- `server/api/settings.py` — Webhook 清除、Telegram 错误详情、reveal 免密
|
||||
- `tests/test_settings_telegram.py` — 新增
|
||||
- `docs/audit/2026-06-07-settings-telegram-fix.md`
|
||||
|
||||
## 迁移 / 重启
|
||||
|
||||
- 无 DB 迁移
|
||||
- 需重新构建前端并重启 API(或全量部署)
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
.venv/bin/pytest tests/test_settings_telegram.py -q
|
||||
bash scripts/local_verify.sh
|
||||
bash deploy/pre_deploy_check.sh
|
||||
# 设置页:填 Token → 检测 Chat ID → 测试发送 → 切换告警开关
|
||||
```
|
||||
|
||||
## 说明
|
||||
|
||||
Layer-3 cron(`health_monitor.sh`)仍读 `NEXUS_TELEGRAM_*`(`.env.prod`),与 UI 保存的 MySQL 配置独立;应用内告警走 MySQL 热更新即可。
|
||||
@@ -63,18 +63,42 @@
|
||||
class="mb-3"
|
||||
:placeholder="telegramTokenSet ? '已配置(留空保持不变)' : '输入 Bot Token'"
|
||||
:type="showToken ? 'text' : 'password'"
|
||||
:append-inner-icon="showToken ? 'mdi-eye' : 'mdi-eye-off'"
|
||||
@click:append-inner="revealTelegramToken"
|
||||
:append-inner-icon="showToken ? 'mdi-eye-off' : 'mdi-eye'"
|
||||
@click:append-inner="toggleTelegramTokenVisibility"
|
||||
/>
|
||||
<v-chip v-if="telegramTokenSet && !telegramTokenDraft" size="small" color="success" variant="tonal" class="mb-3">Token 已配置</v-chip>
|
||||
<v-text-field v-model="settings.telegram_chat_id" label="Chat ID" variant="outlined" density="compact" class="mb-3" />
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
在 Telegram 向 Bot 发送任意消息后,可点击下方按钮自动检测 Chat ID。
|
||||
</p>
|
||||
<div class="d-flex flex-wrap ga-2">
|
||||
<div class="d-flex flex-wrap ga-2 mb-4">
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-radar" @click="detectTelegramChats" :loading="detectingTgChats">检测 Chat ID</v-btn>
|
||||
<v-btn size="small" variant="tonal" prepend-icon="mdi-message-text" @click="testTelegram" :loading="testingTg">发送测试消息</v-btn>
|
||||
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-content-save" @click="saveTelegramConfig" :loading="savingTelegram">保存 Telegram 配置</v-btn>
|
||||
</div>
|
||||
|
||||
<v-divider class="my-3" />
|
||||
<div class="text-subtitle-2 mb-1">告警通知项目</div>
|
||||
<p class="text-caption text-medium-emphasis mb-3">
|
||||
关闭后该类告警仍记录日志,但不发送 Telegram 消息。Bot Token 未配置时全部静默。
|
||||
</p>
|
||||
<template v-for="(groupItems, groupName) in notifyToggleGroups" :key="groupName">
|
||||
<div class="text-caption text-medium-emphasis font-weight-medium mt-2 mb-1">{{ groupName }}</div>
|
||||
<v-switch
|
||||
v-for="item in groupItems"
|
||||
:key="item.key"
|
||||
:model-value="notifyToggles[item.key] ?? true"
|
||||
:label="item.label"
|
||||
:hint="item.desc"
|
||||
persistent-hint
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details="auto"
|
||||
class="mb-1"
|
||||
:disabled="notifyToggleSaving === item.key"
|
||||
@update:model-value="(v: boolean | null) => toggleNotify(item.key, Boolean(v))"
|
||||
/>
|
||||
</template>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -154,7 +178,7 @@
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center ga-2">
|
||||
<v-text-field :model-value="showApiKey ? apiKeyValue : '••••••••'" label="API Key" variant="outlined" density="compact" readonly :type="showApiKey ? 'text' : 'password'" style="max-width: 320px" />
|
||||
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" />
|
||||
<v-btn variant="text" size="small" :icon="showApiKey ? 'mdi-eye-off' : 'mdi-eye'" @click="revealApiKey" :loading="revealingApiKey" />
|
||||
<v-btn variant="text" size="small" icon="mdi-content-copy" @click="copyApiKey" />
|
||||
</div>
|
||||
</v-card-text>
|
||||
@@ -250,24 +274,6 @@
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- API Key Reveal Dialog -->
|
||||
<v-dialog v-model="showRevealDialog" max-width="400">
|
||||
<v-card border>
|
||||
<v-card-title>{{ revealTarget === 'telegram' ? '查看 Bot Token' : '验证身份' }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-3">
|
||||
{{ revealTarget === 'telegram' ? '请输入当前密码以查看 Telegram Bot Token' : '请输入当前密码以查看 API Key' }}
|
||||
</div>
|
||||
<v-text-field v-model="revealPassword" label="当前密码" variant="outlined" density="compact" type="password" autofocus @keydown.enter="doRevealApiKey" :rules="[required('密码')]" />
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="showRevealDialog = false">取消</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="doRevealSecret" :loading="revealingKey">确认</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<!-- Telegram Chat ID picker -->
|
||||
<v-dialog v-model="tgChatDialog" max-width="480">
|
||||
<v-card border>
|
||||
@@ -327,6 +333,7 @@ import type {
|
||||
SubscriptionParseResponse,
|
||||
IpAllowlistSaveRequest,
|
||||
} from '@/types/api'
|
||||
import { NOTIFY_TOGGLE_ITEMS } from '@/types/api'
|
||||
|
||||
const snackbar = useSnackbar()
|
||||
|
||||
@@ -348,6 +355,23 @@ const detectingTgChats = ref(false)
|
||||
const tgChatDialog = ref(false)
|
||||
const tgChats = ref<TelegramChatItem[]>([])
|
||||
const showToken = ref(false)
|
||||
const savingTelegram = ref(false)
|
||||
const notifyToggles = ref<Record<string, boolean>>({})
|
||||
const notifyToggleSaving = ref<string | null>(null)
|
||||
|
||||
const notifyToggleGroups = computed(() => {
|
||||
const groups: Record<string, typeof NOTIFY_TOGGLE_ITEMS> = {}
|
||||
for (const item of NOTIFY_TOGGLE_ITEMS) {
|
||||
if (!groups[item.group]) groups[item.group] = []
|
||||
groups[item.group].push(item)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function parseNotifyEnabled(val: unknown): boolean {
|
||||
const s = String(val ?? 'true').trim().toLowerCase()
|
||||
return !['false', '0', 'no', 'off'].includes(s)
|
||||
}
|
||||
|
||||
// ── Password ──
|
||||
const pwForm = ref({ current: '', new_pw: '', confirm: '', totp_code: '' })
|
||||
@@ -366,10 +390,8 @@ const disablingTotp = ref(false)
|
||||
// ── API Key ──
|
||||
const showApiKey = ref(false)
|
||||
const apiKeyValue = ref('')
|
||||
const showRevealDialog = ref(false)
|
||||
const revealTarget = ref<'api_key' | 'telegram'>('api_key')
|
||||
const revealPassword = ref('')
|
||||
const revealingKey = ref(false)
|
||||
const revealingApiKey = ref(false)
|
||||
const revealingTelegramToken = ref(false)
|
||||
|
||||
// ── IP Allowlist ──
|
||||
const ipAllowlistEnabled = ref(false)
|
||||
@@ -399,6 +421,10 @@ async function loadSettings() {
|
||||
telegramTokenDraft.value = ''
|
||||
continue
|
||||
}
|
||||
if (key && NOTIFY_TOGGLE_ITEMS.some(n => n.key === key)) {
|
||||
notifyToggles.value[key] = parseNotifyEnabled(item.value)
|
||||
continue
|
||||
}
|
||||
if (key && knownKeys.includes(key)) {
|
||||
const val = item.value
|
||||
// Type-cast numeric strings back to numbers for number inputs
|
||||
@@ -435,32 +461,81 @@ async function loadAllowlist() {
|
||||
// ── Actions ──
|
||||
async function saveSettings() {
|
||||
saving.value = true
|
||||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||
try {
|
||||
const entries = Object.entries(settings.value).filter(([key]) =>
|
||||
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow', 'telegram_chat_id'].includes(key)
|
||||
['system_name', 'system_title', 'cpu_alert_threshold', 'mem_alert_threshold', 'disk_alert_threshold', 'db_pool_size', 'db_max_overflow'].includes(key)
|
||||
)
|
||||
if (tokenUpdate) {
|
||||
entries.push(['telegram_bot_token', tokenUpdate])
|
||||
}
|
||||
for (const [key, value] of entries) {
|
||||
await http.put(`/settings/${key}`, { value })
|
||||
}
|
||||
snackbar('设置已保存')
|
||||
if (tokenUpdate) {
|
||||
telegramTokenSet.value = true
|
||||
telegramTokenDraft.value = ''
|
||||
showToken.value = false
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : '保存失败'
|
||||
snackbar(msg, 'error')
|
||||
} finally { saving.value = false }
|
||||
}
|
||||
|
||||
async function persistTelegramConfig(options?: { requireToken?: boolean; requireChatId?: boolean }): Promise<boolean> {
|
||||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||
const chatId = String(settings.value.telegram_chat_id || '').trim()
|
||||
|
||||
if (options?.requireToken && !tokenUpdate && !telegramTokenSet.value) {
|
||||
snackbar('请先填写 Bot Token', 'error')
|
||||
return false
|
||||
}
|
||||
if (options?.requireChatId && !chatId) {
|
||||
snackbar('请先填写或检测 Chat ID', 'error')
|
||||
return false
|
||||
}
|
||||
|
||||
if (tokenUpdate) {
|
||||
await http.put('/settings/telegram_bot_token', { value: tokenUpdate })
|
||||
telegramTokenSet.value = true
|
||||
telegramTokenDraft.value = ''
|
||||
showToken.value = false
|
||||
}
|
||||
if (chatId) {
|
||||
await http.put('/settings/telegram_chat_id', { value: chatId })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function saveTelegramConfig() {
|
||||
savingTelegram.value = true
|
||||
try {
|
||||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||
const chatId = String(settings.value.telegram_chat_id || '').trim()
|
||||
if (!tokenUpdate && !chatId && !telegramTokenSet.value) {
|
||||
snackbar('请先填写 Bot Token 或 Chat ID', 'warning')
|
||||
return
|
||||
}
|
||||
await persistTelegramConfig()
|
||||
snackbar('Telegram 配置已保存')
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
|
||||
} finally {
|
||||
savingTelegram.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleNotify(key: string, enabled: boolean) {
|
||||
const prev = notifyToggles.value[key]
|
||||
notifyToggles.value[key] = enabled
|
||||
notifyToggleSaving.value = key
|
||||
try {
|
||||
await http.put(`/settings/${key}`, { value: enabled ? 'true' : 'false' })
|
||||
} catch (e: unknown) {
|
||||
notifyToggles.value[key] = prev
|
||||
snackbar(e instanceof Error ? e.message : '保存失败,已撤销', 'error')
|
||||
} finally {
|
||||
notifyToggleSaving.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function testTelegram() {
|
||||
testingTg.value = true
|
||||
try {
|
||||
if (!await persistTelegramConfig({ requireToken: true, requireChatId: true })) return
|
||||
await http.post('/settings/telegram/test')
|
||||
snackbar('测试消息已发送')
|
||||
} catch (e: unknown) {
|
||||
@@ -471,16 +546,7 @@ async function testTelegram() {
|
||||
async function detectTelegramChats() {
|
||||
detectingTgChats.value = true
|
||||
try {
|
||||
const tokenUpdate = telegramTokenDraft.value.trim()
|
||||
if (tokenUpdate) {
|
||||
await http.put('/settings/telegram_bot_token', { value: tokenUpdate })
|
||||
telegramTokenSet.value = true
|
||||
telegramTokenDraft.value = ''
|
||||
showToken.value = false
|
||||
} else if (!telegramTokenSet.value) {
|
||||
snackbar('请先填写 Bot Token', 'error')
|
||||
return
|
||||
}
|
||||
if (!await persistTelegramConfig({ requireToken: true })) return
|
||||
const res = await http.get<TelegramChatsResponse>('/settings/telegram/chats')
|
||||
tgChats.value = res.chats || []
|
||||
if (!tgChats.value.length) {
|
||||
@@ -586,61 +652,53 @@ async function revealApiKey() {
|
||||
showApiKey.value = false
|
||||
return
|
||||
}
|
||||
revealTarget.value = 'api_key'
|
||||
showRevealDialog.value = true
|
||||
revealPassword.value = ''
|
||||
}
|
||||
|
||||
async function revealTelegramToken() {
|
||||
if (showToken.value && telegramTokenDraft.value) {
|
||||
showToken.value = false
|
||||
telegramTokenDraft.value = ''
|
||||
return
|
||||
}
|
||||
revealTarget.value = 'telegram'
|
||||
showRevealDialog.value = true
|
||||
revealPassword.value = ''
|
||||
}
|
||||
|
||||
async function doRevealSecret() {
|
||||
if (!revealPassword.value) {
|
||||
snackbar('请输入密码', 'error')
|
||||
return
|
||||
}
|
||||
revealingKey.value = true
|
||||
revealingApiKey.value = true
|
||||
try {
|
||||
if (revealTarget.value === 'telegram') {
|
||||
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {
|
||||
current_password: revealPassword.value,
|
||||
})
|
||||
const res = await http.post<{ value: string }>('/settings/api-key/reveal', {})
|
||||
apiKeyValue.value = res.value
|
||||
showApiKey.value = true
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '获取 API Key 失败', 'error')
|
||||
} finally {
|
||||
revealingApiKey.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleTelegramTokenVisibility() {
|
||||
if (showToken.value) {
|
||||
showToken.value = false
|
||||
if (telegramTokenSet.value) {
|
||||
telegramTokenDraft.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
if (telegramTokenDraft.value.trim()) {
|
||||
showToken.value = true
|
||||
return
|
||||
}
|
||||
if (!telegramTokenSet.value) {
|
||||
showToken.value = true
|
||||
return
|
||||
}
|
||||
revealingTelegramToken.value = true
|
||||
try {
|
||||
const res = await http.post<{ value: string }>('/settings/telegram/reveal-token', {})
|
||||
telegramTokenDraft.value = res.value
|
||||
showToken.value = true
|
||||
} else {
|
||||
const res = await http.post<{ api_key: string }>('/settings/api-key/reveal', {
|
||||
current_password: revealPassword.value,
|
||||
})
|
||||
apiKeyValue.value = res.api_key
|
||||
showApiKey.value = true
|
||||
}
|
||||
showRevealDialog.value = false
|
||||
revealPassword.value = ''
|
||||
} catch (e: unknown) {
|
||||
snackbar(e instanceof Error ? e.message : '验证失败', 'error')
|
||||
snackbar(e instanceof Error ? e.message : '获取 Bot Token 失败', 'error')
|
||||
} finally {
|
||||
revealingKey.value = false
|
||||
revealingTelegramToken.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doRevealApiKey() {
|
||||
revealTarget.value = 'api_key'
|
||||
await doRevealSecret()
|
||||
}
|
||||
|
||||
function copyApiKey() {
|
||||
if (apiKeyValue.value) {
|
||||
if (!showApiKey.value || !apiKeyValue.value) {
|
||||
snackbar('请先点击眼睛图标显示 API Key', 'warning')
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(apiKeyValue.value)
|
||||
snackbar('已复制到剪贴板')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAllowlist() {
|
||||
|
||||
@@ -35,6 +35,25 @@ export interface TelegramChatsResponse {
|
||||
count: number
|
||||
}
|
||||
|
||||
/** Telegram notification toggle (settings key → label) */
|
||||
export interface NotifyToggleItem {
|
||||
key: string
|
||||
label: string
|
||||
desc: string
|
||||
group: string
|
||||
}
|
||||
|
||||
export const NOTIFY_TOGGLE_ITEMS: NotifyToggleItem[] = [
|
||||
{ key: 'notify_alert_cpu', label: 'CPU 超阈值告警', desc: 'CPU 使用率超过设定阈值时推送', group: '服务器资源' },
|
||||
{ key: 'notify_alert_mem', label: '内存超阈值告警', desc: '内存使用率超过设定阈值时推送', group: '服务器资源' },
|
||||
{ key: 'notify_alert_disk', label: '磁盘超阈值告警', desc: '磁盘使用率超过设定阈值时推送', group: '服务器资源' },
|
||||
{ key: 'notify_recovery', label: '资源恢复正常', desc: 'CPU/内存/磁盘告警解除时推送', group: '服务器资源' },
|
||||
{ key: 'notify_time_drift', label: '时钟偏差严重 (≥60s)', desc: '子机与主机时间偏差过大影响 TOTP/cron', group: '服务器' },
|
||||
{ key: 'notify_system_redis', label: 'Redis 连接异常 / 恢复', desc: 'Redis 不可达或恢复时推送', group: '系统' },
|
||||
{ key: 'notify_system_mysql', label: 'MySQL 连接异常 / 恢复', desc: 'MySQL 不可达或恢复时推送', group: '系统' },
|
||||
{ key: 'notify_restart', label: 'Nexus 后端重启', desc: 'Python 服务崩溃重启成功/失败时推送', group: '系统' },
|
||||
]
|
||||
|
||||
/** IP Allowlist response */
|
||||
export interface AllowlistResponse {
|
||||
enabled: boolean
|
||||
|
||||
+52
-14
@@ -291,14 +291,11 @@ async def get_setting(key: str, admin: Admin = Depends(get_current_admin), db: A
|
||||
|
||||
@router.post("/api-key/reveal", response_model=dict)
|
||||
async def reveal_api_key(
|
||||
payload: ApiKeyRevealRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal global API_KEY after re-entering account password."""
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
"""Reveal global API_KEY for authenticated admin (JWT required)."""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("api_key")
|
||||
if value is None:
|
||||
@@ -903,13 +900,34 @@ async def alert_stats(
|
||||
|
||||
# ── Telegram Test + Chat ID Detection ──
|
||||
|
||||
async def _telegram_clear_webhook_if_set(client, base_url: str) -> None:
|
||||
"""Remove active webhook so getUpdates can receive messages."""
|
||||
wh_resp = await client.get(f"{base_url}/getWebhookInfo")
|
||||
if wh_resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"Telegram getWebhookInfo 返回 {wh_resp.status_code}")
|
||||
wh_data = wh_resp.json()
|
||||
if not wh_data.get("ok"):
|
||||
desc = wh_data.get("description") or "getWebhookInfo 失败"
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||
if wh_data.get("result", {}).get("url"):
|
||||
del_resp = await client.post(
|
||||
f"{base_url}/deleteWebhook",
|
||||
params={"drop_pending_updates": "false"},
|
||||
)
|
||||
del_data = del_resp.json()
|
||||
if not del_data.get("ok"):
|
||||
desc = del_data.get("description") or "deleteWebhook 失败"
|
||||
raise HTTPException(status_code=502, detail=f"无法清除 Webhook: {desc}")
|
||||
|
||||
|
||||
@router.post("/telegram/test", response_model=dict)
|
||||
async def telegram_test_send(
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
):
|
||||
"""Send a test Telegram message to verify Bot Token + Chat ID configuration."""
|
||||
from server.infrastructure.telegram import send_telegram
|
||||
import httpx
|
||||
from server.config import settings as _settings
|
||||
from server.infrastructure.telegram import TELEGRAM_API_BASE
|
||||
|
||||
if not _settings.TELEGRAM_BOT_TOKEN:
|
||||
raise HTTPException(status_code=400, detail="TELEGRAM_BOT_TOKEN 未配置")
|
||||
@@ -918,26 +936,41 @@ async def telegram_test_send(
|
||||
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
ok = await send_telegram(
|
||||
message = (
|
||||
f"✅ <b>Nexus 测试消息</b>\n"
|
||||
f"Bot 配置正确,告警通知已就绪\n"
|
||||
f"时间: {now}"
|
||||
)
|
||||
if ok:
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(
|
||||
f"{TELEGRAM_API_BASE}/bot{_settings.TELEGRAM_BOT_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": _settings.TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
},
|
||||
)
|
||||
data = resp.json()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"请求失败: {e}") from e
|
||||
|
||||
if resp.status_code == 200 and data.get("ok"):
|
||||
return {"success": True}
|
||||
raise HTTPException(status_code=502, detail="Telegram API 返回错误,请检查 Bot Token 和 Chat ID")
|
||||
desc = data.get("description") or f"HTTP {resp.status_code}"
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||
|
||||
|
||||
@router.post("/telegram/reveal-token", response_model=dict)
|
||||
async def reveal_telegram_token(
|
||||
payload: ApiKeyRevealRequest,
|
||||
request: Request,
|
||||
admin: Admin = Depends(get_current_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""Reveal Telegram Bot Token after re-entering account password."""
|
||||
await _verify_reauth(db, admin, payload.current_password)
|
||||
|
||||
"""Reveal Telegram Bot Token for authenticated admin (JWT required)."""
|
||||
repo = SettingRepositoryImpl(db)
|
||||
value = await repo.get("telegram_bot_token")
|
||||
if value is None:
|
||||
@@ -973,13 +1006,18 @@ async def telegram_get_chats(
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
base_url = f"{TELEGRAM_API_BASE}/bot{token}"
|
||||
await _telegram_clear_webhook_if_set(client, base_url)
|
||||
resp = await client.get(
|
||||
f"{TELEGRAM_API_BASE}/bot{token}/getUpdates",
|
||||
params={"limit": 50, "allowed_updates": ["message", "channel_post"]},
|
||||
f"{base_url}/getUpdates",
|
||||
params={"limit": 50},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API 返回 {resp.status_code}")
|
||||
data = resp.json()
|
||||
if not data.get("ok"):
|
||||
desc = data.get("description") or "getUpdates 失败"
|
||||
raise HTTPException(status_code=502, detail=f"Telegram API: {desc}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Tests for Telegram settings endpoints and notify toggle hot-reload."""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from server.config import settings
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_test_400_when_token_missing():
|
||||
from server.api.settings import telegram_test_send
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = ""
|
||||
settings.TELEGRAM_CHAT_ID = ""
|
||||
admin = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_test_send(admin=admin)
|
||||
assert exc.value.status_code == 400
|
||||
assert "TELEGRAM_BOT_TOKEN" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_test_400_when_chat_id_missing():
|
||||
from server.api.settings import telegram_test_send
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
settings.TELEGRAM_CHAT_ID = ""
|
||||
admin = MagicMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_test_send(admin=admin)
|
||||
assert exc.value.status_code == 400
|
||||
assert "TELEGRAM_CHAT_ID" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_test_surfaces_api_error():
|
||||
from server.api.settings import telegram_test_send
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
settings.TELEGRAM_CHAT_ID = "999"
|
||||
admin = MagicMock()
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 400
|
||||
mock_resp.json.return_value = {"ok": False, "description": "Bad Request: chat not found"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_test_send(admin=admin)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert "chat not found" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_chats_surfaces_api_error_when_ok_false():
|
||||
from server.api.settings import telegram_get_chats
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
admin = MagicMock()
|
||||
|
||||
wh_resp = MagicMock()
|
||||
wh_resp.status_code = 200
|
||||
wh_resp.json.return_value = {"ok": True, "result": {"url": ""}}
|
||||
|
||||
updates_resp = MagicMock()
|
||||
updates_resp.status_code = 200
|
||||
updates_resp.json.return_value = {"ok": False, "description": "Unauthorized"}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get = AsyncMock(side_effect=[wh_resp, updates_resp])
|
||||
mock_client.post = AsyncMock()
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await telegram_get_chats(admin=admin)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert "Unauthorized" in exc.value.detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_telegram_chats_deletes_webhook_before_get_updates():
|
||||
from server.api.settings import telegram_get_chats
|
||||
|
||||
settings.TELEGRAM_BOT_TOKEN = "123456:ABC"
|
||||
admin = MagicMock()
|
||||
|
||||
wh_resp = MagicMock()
|
||||
wh_resp.status_code = 200
|
||||
wh_resp.json.return_value = {"ok": True, "result": {"url": "https://example.com/hook"}}
|
||||
|
||||
del_resp = MagicMock()
|
||||
del_resp.json.return_value = {"ok": True}
|
||||
|
||||
updates_resp = MagicMock()
|
||||
updates_resp.status_code = 200
|
||||
updates_resp.json.return_value = {
|
||||
"ok": True,
|
||||
"result": [
|
||||
{
|
||||
"message": {
|
||||
"chat": {"id": 42, "type": "private", "first_name": "Test"},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mock_client = AsyncMock()
|
||||
mock_client.__aenter__.return_value = mock_client
|
||||
mock_client.__aexit__.return_value = None
|
||||
mock_client.get = AsyncMock(side_effect=[wh_resp, updates_resp])
|
||||
mock_client.post = AsyncMock(return_value=del_resp)
|
||||
|
||||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||||
result = await telegram_get_chats(admin=admin)
|
||||
|
||||
assert result["count"] == 1
|
||||
assert result["chats"][0]["id"] == 42
|
||||
mock_client.post.assert_called_once()
|
||||
call_args = mock_client.post.call_args
|
||||
assert "deleteWebhook" in call_args[0][0]
|
||||
assert call_args[1]["params"]["drop_pending_updates"] == "false"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_notify_toggle_put_hot_reload():
|
||||
from server.api.schemas import SettingUpdatePayload
|
||||
from server.api.settings import set_setting
|
||||
from server.domain.models import Setting
|
||||
|
||||
settings.NOTIFY_ALERT_CPU = "true"
|
||||
admin = MagicMock(username="admin")
|
||||
request = MagicMock()
|
||||
request.client.host = "127.0.0.1"
|
||||
db = MagicMock()
|
||||
|
||||
mock_setting = Setting(key="notify_alert_cpu", value="false")
|
||||
|
||||
with patch("server.api.settings.SettingRepositoryImpl") as repo_cls, patch(
|
||||
"server.api.settings.AuditLogRepositoryImpl"
|
||||
) as audit_cls:
|
||||
repo = repo_cls.return_value
|
||||
repo.set = AsyncMock(return_value=mock_setting)
|
||||
audit_cls.return_value.create = AsyncMock()
|
||||
|
||||
await set_setting(
|
||||
"notify_alert_cpu",
|
||||
SettingUpdatePayload(value="false"),
|
||||
request,
|
||||
admin,
|
||||
db,
|
||||
)
|
||||
|
||||
assert settings.NOTIFY_ALERT_CPU == "false"
|
||||
Reference in New Issue
Block a user