feat: 定时通知新增飞书自定义机器人渠道
原定时通知仅支持邮箱与 Server 酱,现新增飞书自定义机器人 Webhook 渠道,用户填写机器人地址(可选签名密钥)后即可将账单导入提醒推送到飞书群。消息以 post 富文本发送,标题保留为卡片标题、正文按行分段;开启签名校验的机器人会按飞书规则附带 timestamp 与 HMAC-SHA256 签名。飞书密钥与邮箱授权码一致处理:留空表示不修改,返回前端时脱敏。 变更内容: - 后端新增 NotificationChannelType.FEISHU 与 FeishuNotificationSender(post 富文本、可选加签、兼容 code/StatusCode 响应) - NotificationChannelServiceImpl 将敏感字段抽为 password/secret 集合,统一“留空保留原值”与脱敏逻辑,并补充 FEISHU 默认渠道名 - NotificationChannelVO 新增 hasSecret,返回时移除 secret 并以标记提示是否已配置 - 前端 api/notification.ts 补充 hasSecret,NotificationConfigPanel 增加飞书类型与 Webhook/签名密钥表单项及必填校验 - locales/messages.ts 新增飞书相关 zh/en/ja 文案并更新面板说明文案 - init.sql 同步通知渠道类型注释
This commit is contained in:
@@ -19,6 +19,7 @@ export interface NotificationChannel {
|
||||
enabled: boolean
|
||||
config: Record<string, any>
|
||||
hasPassword: boolean
|
||||
hasSecret: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
@@ -69,6 +69,7 @@ const testingId = ref<number | null>(null)
|
||||
const showChannelModal = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const editingHasPassword = ref(false)
|
||||
const editingHasSecret = ref(false)
|
||||
const savingChannel = ref(false)
|
||||
const channelForm = ref(emptyChannelForm())
|
||||
|
||||
@@ -85,6 +86,8 @@ function emptyChannelForm() {
|
||||
password: '',
|
||||
to: '',
|
||||
ssl: true,
|
||||
webhookUrl: '',
|
||||
secret: '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,16 +110,18 @@ const weekOptions = computed(() => [
|
||||
// 每月提醒日限制在 1-28,避免不同月份天数差异
|
||||
const monthDayOptions = computed(() => Array.from({ length: 28 }, (_, i) => ({ label: String(i + 1), value: i + 1 })))
|
||||
|
||||
// 前端开放 Server 酱与邮箱两种推送渠道
|
||||
// 前端开放 Server 酱、邮箱与飞书两种推送渠道
|
||||
const channelTypeOptions = computed(() => [
|
||||
{ label: t('notification.typeServerChan'), value: 'SERVERCHAN' },
|
||||
{ label: t('notification.typeEmail'), value: 'EMAIL' },
|
||||
{ label: t('notification.typeFeishu'), value: 'FEISHU' },
|
||||
])
|
||||
|
||||
/** 渠道类型显示名 */
|
||||
function channelTypeLabel(type: string | null) {
|
||||
if (type === 'SERVERCHAN') return t('notification.typeServerChan')
|
||||
if (type === 'EMAIL') return t('notification.typeEmail')
|
||||
if (type === 'FEISHU') return t('notification.typeFeishu')
|
||||
return type || ''
|
||||
}
|
||||
|
||||
@@ -124,6 +129,7 @@ function channelTypeLabel(type: string | null) {
|
||||
function channelTagType(type: string) {
|
||||
if (type === 'SERVERCHAN') return 'success'
|
||||
if (type === 'EMAIL') return 'warning'
|
||||
if (type === 'FEISHU') return 'primary'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
@@ -193,6 +199,7 @@ async function handleSaveConfig() {
|
||||
function openChannelModal(channel: NotificationChannel | null) {
|
||||
editingId.value = channel ? channel.id : null
|
||||
editingHasPassword.value = !!channel?.hasPassword
|
||||
editingHasSecret.value = !!channel?.hasSecret
|
||||
const base = emptyChannelForm()
|
||||
if (channel) {
|
||||
const cfg = channel.config || {}
|
||||
@@ -207,6 +214,8 @@ function openChannelModal(channel: NotificationChannel | null) {
|
||||
password: '',
|
||||
to: cfg.to || '',
|
||||
ssl: cfg.ssl !== false,
|
||||
webhookUrl: cfg.webhookUrl || '',
|
||||
secret: '',
|
||||
})
|
||||
}
|
||||
channelForm.value = base
|
||||
@@ -219,6 +228,13 @@ function buildChannelConfig() {
|
||||
if (form.type === 'SERVERCHAN') {
|
||||
return { sendKey: form.sendKey.trim() }
|
||||
}
|
||||
if (form.type === 'FEISHU') {
|
||||
const config: Record<string, any> = { webhookUrl: form.webhookUrl.trim() }
|
||||
if (form.secret) {
|
||||
config.secret = form.secret
|
||||
}
|
||||
return config
|
||||
}
|
||||
const config: Record<string, any> = {
|
||||
smtpHost: form.smtpHost.trim(),
|
||||
smtpPort: form.smtpPort,
|
||||
@@ -240,6 +256,11 @@ async function handleSaveChannel() {
|
||||
message.error(t('notification.sendKeyRequired'))
|
||||
return
|
||||
}
|
||||
} else if (form.type === 'FEISHU') {
|
||||
if (!form.webhookUrl.trim()) {
|
||||
message.error(t('notification.webhookUrlRequired'))
|
||||
return
|
||||
}
|
||||
} else if (!form.username.trim()) {
|
||||
message.error(t('notification.usernameRequired'))
|
||||
return
|
||||
@@ -475,6 +496,20 @@ onMounted(loadAll)
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<template v-else-if="channelForm.type === 'FEISHU'">
|
||||
<NFormItem :label="t('notification.webhookUrl')">
|
||||
<NInput v-model:value="channelForm.webhookUrl" :placeholder="t('notification.webhookUrlPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('notification.secret')">
|
||||
<NInput
|
||||
v-model:value="channelForm.secret"
|
||||
type="password"
|
||||
show-password-on="click"
|
||||
:placeholder="editingHasSecret ? t('notification.secretKeep') : t('notification.secretPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<NFormItem :label="t('notification.smtpHost')">
|
||||
<NInput v-model:value="channelForm.smtpHost" placeholder="smtp.qq.com" />
|
||||
|
||||
@@ -437,9 +437,9 @@ export default {
|
||||
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
|
||||
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
|
||||
intro: {
|
||||
zh: '按自定义周期,通过邮箱或 Server 酱提醒你及时导入新的账单。',
|
||||
en: 'Get reminded via email or ServerChan to import new bills on your own schedule.',
|
||||
ja: 'カスタム周期でメール・ServerChan を通じて、新しい明細のインポートをお知らせします。',
|
||||
zh: '按自定义周期,通过邮箱、Server 酱或飞书提醒你及时导入新的账单。',
|
||||
en: 'Get reminded via email, ServerChan, or Feishu to import new bills on your own schedule.',
|
||||
ja: 'カスタム周期でメール・ServerChan・Feishu を通じて、新しい明細のインポートをお知らせします。',
|
||||
},
|
||||
linkUrl: { zh: '通知链接地址', en: 'Notification link URL', ja: '通知リンク URL' },
|
||||
linkUrlPlaceholder: {
|
||||
@@ -501,6 +501,7 @@ export default {
|
||||
disabledLabel: { zh: '已停用', en: 'Disabled', ja: '無効' },
|
||||
typeEmail: { zh: '邮箱', en: 'Email', ja: 'メール' },
|
||||
typeServerChan: { zh: 'Server 酱', en: 'ServerChan', ja: 'ServerChan' },
|
||||
typeFeishu: { zh: '飞书', en: 'Feishu', ja: 'Feishu' },
|
||||
smtpHost: { zh: 'SMTP 服务器', en: 'SMTP host', ja: 'SMTP サーバー' },
|
||||
smtpPort: { zh: 'SMTP 端口', en: 'SMTP port', ja: 'SMTP ポート' },
|
||||
username: { zh: '邮箱账号', en: 'Email account', ja: 'メールアカウント' },
|
||||
@@ -520,6 +521,28 @@ export default {
|
||||
en: 'Please enter the ServerChan SendKey',
|
||||
ja: 'ServerChan の SendKey を入力してください',
|
||||
},
|
||||
webhookUrl: { zh: 'Webhook 地址', en: 'Webhook URL', ja: 'Webhook URL' },
|
||||
webhookUrlPlaceholder: {
|
||||
zh: '飞书机器人 Webhook 完整地址',
|
||||
en: 'Full webhook URL of the Feishu bot',
|
||||
ja: 'Feishu ボットの Webhook URL(完全なもの)',
|
||||
},
|
||||
webhookUrlRequired: {
|
||||
zh: '请填写飞书机器人 Webhook 地址',
|
||||
en: 'Please enter the Feishu bot webhook URL',
|
||||
ja: 'Feishu ボットの Webhook URL を入力してください',
|
||||
},
|
||||
secret: { zh: '签名密钥', en: 'Signing secret', ja: '署名シークレット' },
|
||||
secretPlaceholder: {
|
||||
zh: '开启签名校验时填写,否则留空',
|
||||
en: 'Enter if signature verification is enabled; otherwise leave blank',
|
||||
ja: '署名認証が有効な場合に入力。それ以外は空欄',
|
||||
},
|
||||
secretKeep: {
|
||||
zh: '已配置,留空表示不修改',
|
||||
en: 'Configured; leave blank to keep',
|
||||
ja: '設定済み。空欄なら変更しません',
|
||||
},
|
||||
password: { zh: '授权码', en: 'Auth code', ja: '認証コード' },
|
||||
passwordPlaceholder: { zh: '邮箱 SMTP 授权码', en: 'SMTP authorization code', ja: 'SMTP 認証コード' },
|
||||
passwordKeep: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import lombok.Data;
|
||||
@Data
|
||||
public class NotificationChannelRequest {
|
||||
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱,FEISHU-飞书 */
|
||||
private String type;
|
||||
|
||||
/** 渠道名称,便于用户识别 */
|
||||
@@ -21,6 +21,6 @@ public class NotificationChannelRequest {
|
||||
/** 是否启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 渠道配置,字段随类型不同而变化;EMAIL 的 password 留空表示保留原值 */
|
||||
/** 渠道配置,字段随类型不同而变化;EMAIL 的 password、FEISHU 的 secret 留空表示保留原值 */
|
||||
private Map<String, Object> config;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ public class NotificationChannel {
|
||||
/** 所属用户ID */
|
||||
private Long userId;
|
||||
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱,FEISHU-飞书 */
|
||||
private String type;
|
||||
|
||||
/** 渠道名称,便于用户识别 */
|
||||
|
||||
@@ -11,5 +11,8 @@ public enum NotificationChannelType {
|
||||
EMAIL,
|
||||
|
||||
/** Server 酱(推送到个人微信) */
|
||||
SERVERCHAN
|
||||
SERVERCHAN,
|
||||
|
||||
/** 飞书自定义机器人(Webhook) */
|
||||
FEISHU
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package cn.rosercode.ledgerly.notification;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import cn.rosercode.ledgerly.common.BusinessException;
|
||||
import cn.rosercode.ledgerly.enums.NotificationChannelType;
|
||||
|
||||
/**
|
||||
* 飞书通知发送器,通过自定义机器人 Webhook 推送消息。
|
||||
*
|
||||
* <p>用户将机器人 Webhook 完整地址(如
|
||||
* {@code https://open.feishu.cn/open-apis/bot/v2/hook/xxx})填入配置即可发送;
|
||||
* 若机器人开启了「签名校验」,可另外填写 secret,发送时按飞书规则附带 timestamp 与 sign。
|
||||
* 消息以 post 富文本形式发送,标题保留为卡片标题,正文按行拆分为多个段落。</p>
|
||||
*
|
||||
* @date 2026-10-13 09:05:17
|
||||
*/
|
||||
@Component
|
||||
public class FeishuNotificationSender extends AbstractHttpNotificationSender {
|
||||
|
||||
/** HMAC 签名算法 */
|
||||
private static final String HMAC_SHA256 = "HmacSHA256";
|
||||
|
||||
public FeishuNotificationSender(ObjectMapper objectMapper) {
|
||||
super(objectMapper);
|
||||
}
|
||||
|
||||
@Override
|
||||
public NotificationChannelType type() {
|
||||
return NotificationChannelType.FEISHU;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void send(Map<String, Object> config, String title, String content) {
|
||||
String webhookUrl = asString(config.get("webhookUrl"));
|
||||
if (isBlank(webhookUrl)) {
|
||||
throw new BusinessException("请配置飞书机器人 Webhook 地址");
|
||||
}
|
||||
|
||||
Map<String, Object> body = buildPostBody(title, content);
|
||||
// 开启了签名校验的机器人必须附带 timestamp 与 sign
|
||||
String secret = asString(config.get("secret"));
|
||||
if (!isBlank(secret)) {
|
||||
long timestamp = System.currentTimeMillis() / 1000;
|
||||
body.put("timestamp", String.valueOf(timestamp));
|
||||
body.put("sign", sign(timestamp, secret.trim()));
|
||||
}
|
||||
|
||||
checkResponse(postJson(webhookUrl.trim(), body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建 post 富文本消息体,正文按行拆分为多个段落。
|
||||
*
|
||||
* @param title 消息标题
|
||||
* @param content 消息正文
|
||||
* @return 请求体映射
|
||||
*/
|
||||
private Map<String, Object> buildPostBody(String title, String content) {
|
||||
List<List<Map<String, String>>> lines = new ArrayList<>();
|
||||
String text = content == null ? "" : content;
|
||||
for (String line : text.split("\n", -1)) {
|
||||
lines.add(List.of(Map.of("tag", "text", "text", line)));
|
||||
}
|
||||
|
||||
Map<String, Object> post = new LinkedHashMap<>();
|
||||
post.put("title", title == null ? "" : title);
|
||||
post.put("content", lines);
|
||||
|
||||
Map<String, Object> localized = new LinkedHashMap<>();
|
||||
localized.put("zh_cn", post);
|
||||
|
||||
Map<String, Object> contentWrapper = new LinkedHashMap<>();
|
||||
contentWrapper.put("post", localized);
|
||||
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("msg_type", "post");
|
||||
body.put("content", contentWrapper);
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按飞书规则计算签名:以 {@code timestamp + "\n" + secret} 为密钥,对空串做 HMAC-SHA256 后 Base64。
|
||||
*
|
||||
* @param timestamp 秒级时间戳
|
||||
* @param secret 机器人签名密钥
|
||||
* @return Base64 编码的签名
|
||||
*/
|
||||
private String sign(long timestamp, String secret) {
|
||||
String stringToSign = timestamp + "\n" + secret;
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_SHA256);
|
||||
mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), HMAC_SHA256));
|
||||
return Base64.getEncoder().encodeToString(mac.doFinal(new byte[0]));
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new BusinessException("生成飞书签名失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验飞书响应,{@code code} 或 {@code StatusCode} 非 0 视为失败。
|
||||
*
|
||||
* @param response 响应体
|
||||
*/
|
||||
private void checkResponse(String response) {
|
||||
try {
|
||||
JsonNode root = objectMapper.readTree(response);
|
||||
boolean hasCode = root.has("code");
|
||||
int code = hasCode ? root.path("code").asInt(0) : root.path("StatusCode").asInt(0);
|
||||
if (code != 0) {
|
||||
String message = hasCode ? root.path("msg").asText("") : root.path("StatusMessage").asText("");
|
||||
throw new BusinessException("飞书返回失败:" + message);
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
throw new BusinessException("解析飞书响应失败:" + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-7
@@ -3,6 +3,7 @@ package cn.rosercode.ledgerly.service.impl;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
@@ -30,6 +31,15 @@ import cn.rosercode.ledgerly.vo.NotificationChannelVO;
|
||||
@Slf4j
|
||||
public class NotificationChannelServiceImpl implements NotificationChannelService {
|
||||
|
||||
/** 邮箱渠道密码字段名 */
|
||||
private static final String PASSWORD_KEY = "password";
|
||||
|
||||
/** 飞书渠道签名密钥字段名 */
|
||||
private static final String SECRET_KEY = "secret";
|
||||
|
||||
/** 需脱敏、且留空表示保留原值的敏感字段 */
|
||||
private static final Set<String> SENSITIVE_KEYS = Set.of(PASSWORD_KEY, SECRET_KEY);
|
||||
|
||||
private final NotificationChannelMapper notificationChannelMapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final VoConverter voConverter;
|
||||
@@ -75,7 +85,7 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*
|
||||
* EMAIL 渠道的密码留空时保留原值;渠道类型变更时不再沿用旧配置。
|
||||
* 敏感字段(邮箱授权码、飞书密钥)留空时保留原值;渠道类型变更时不再沿用旧配置。
|
||||
*/
|
||||
@Override
|
||||
@Transactional
|
||||
@@ -136,14 +146,24 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
|
||||
private NotificationChannelVO toVO(NotificationChannel channel) {
|
||||
NotificationChannelVO vo = voConverter.toNotificationChannelVO(channel);
|
||||
|
||||
// 配置 JSON 解析并移除密码,仅保留是否已配置的标记
|
||||
// 配置 JSON 解析并移除敏感字段,仅保留是否已配置的标记
|
||||
Map<String, Object> config = parseConfig(channel.getConfig());
|
||||
Object password = config.remove("password");
|
||||
vo.setHasPassword(password != null && !String.valueOf(password).isBlank());
|
||||
vo.setHasPassword(isConfigured(config.remove(PASSWORD_KEY)));
|
||||
vo.setHasSecret(isConfigured(config.remove(SECRET_KEY)));
|
||||
vo.setConfig(config);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断敏感配置是否已填写。
|
||||
*
|
||||
* @param value 配置值
|
||||
* @return 非空且非空白时返回 true
|
||||
*/
|
||||
private boolean isConfigured(Object value) {
|
||||
return value != null && !String.valueOf(value).isBlank();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验渠道类型。
|
||||
*
|
||||
@@ -175,11 +195,12 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
|
||||
return switch (NotificationChannelType.valueOf(type)) {
|
||||
case EMAIL -> "邮箱";
|
||||
case SERVERCHAN -> "Server 酱";
|
||||
case FEISHU -> "飞书";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并新旧渠道配置:新值覆盖旧值,邮箱密码为空时不覆盖。
|
||||
* 合并新旧渠道配置:新值覆盖旧值,敏感字段(邮箱授权码、飞书密钥)为空时不覆盖。
|
||||
*
|
||||
* @param newConfig 新配置
|
||||
* @param oldJson 旧配置 JSON
|
||||
@@ -194,8 +215,8 @@ public class NotificationChannelServiceImpl implements NotificationChannelServic
|
||||
for (Map.Entry<String, Object> entry : newConfig.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
Object value = entry.getValue();
|
||||
// 密码留空表示保留原值
|
||||
if ("password".equals(key) && (value == null || String.valueOf(value).isBlank())) {
|
||||
// 敏感字段留空表示保留原值
|
||||
if (SENSITIVE_KEYS.contains(key) && (value == null || String.valueOf(value).isBlank())) {
|
||||
continue;
|
||||
}
|
||||
merged.put(key, value);
|
||||
|
||||
@@ -16,7 +16,7 @@ public class NotificationChannelVO {
|
||||
/** 渠道ID */
|
||||
private Long id;
|
||||
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
|
||||
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱,FEISHU-飞书 */
|
||||
private String type;
|
||||
|
||||
/** 渠道名称 */
|
||||
@@ -25,12 +25,15 @@ public class NotificationChannelVO {
|
||||
/** 是否启用 */
|
||||
private Boolean enabled;
|
||||
|
||||
/** 渠道配置(EMAIL 的 password 已移除) */
|
||||
/** 渠道配置(EMAIL 的 password、FEISHU 的 secret 已移除) */
|
||||
private Map<String, Object> config;
|
||||
|
||||
/** 是否已配置密码/授权码(EMAIL 渠道使用) */
|
||||
private Boolean hasPassword;
|
||||
|
||||
/** 是否已配置签名密钥(FEISHU 渠道使用) */
|
||||
private Boolean hasSecret;
|
||||
|
||||
/** 创建时间 */
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ CREATE TABLE IF NOT EXISTS t_notification_channel (
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
user_id BIGINT NOT NULL COMMENT '所属用户ID',
|
||||
type VARCHAR(20) NOT NULL COMMENT '渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱',
|
||||
type VARCHAR(20) NOT NULL COMMENT '渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱,FEISHU-飞书',
|
||||
name VARCHAR(50) COMMENT '渠道名称,便于用户识别',
|
||||
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-关闭',
|
||||
config TEXT COMMENT '渠道配置(JSON),如 webhook 地址、SMTP 账号等',
|
||||
|
||||
Reference in New Issue
Block a user