feat: 钉钉机器人支持加签安全设置
All checks were successful
continuous-integration/drone/push Build is passing

- 通知渠道增加 sign_secret 字段存储加签密钥
- 发送钉钉消息时自动计算签名
- 前端增加加签密钥输入框(仅钉钉机器人显示)
This commit is contained in:
2026-01-28 17:19:53 +08:00
parent 8430f9dbaa
commit 333bbe57eb
4 changed files with 66 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ class TaskNotifyChannel(Base):
channel_name = Column(String(100), nullable=False) channel_name = Column(String(100), nullable=False)
channel_type = Column(Enum('dingtalk_bot', 'wecom_bot'), nullable=False) channel_type = Column(Enum('dingtalk_bot', 'wecom_bot'), nullable=False)
webhook_url = Column(String(500), nullable=False) webhook_url = Column(String(500), nullable=False)
sign_secret = Column(String(200)) # 钉钉加签密钥
description = Column(String(255)) description = Column(String(255))
is_enabled = Column(Boolean, default=True) is_enabled = Column(Boolean, default=True)

View File

@@ -19,6 +19,7 @@ class ChannelCreate(BaseModel):
channel_name: str channel_name: str
channel_type: str # dingtalk_bot, wecom_bot channel_type: str # dingtalk_bot, wecom_bot
webhook_url: str webhook_url: str
sign_secret: Optional[str] = None # 钉钉加签密钥
description: Optional[str] = None description: Optional[str] = None
@@ -26,6 +27,7 @@ class ChannelUpdate(BaseModel):
channel_name: Optional[str] = None channel_name: Optional[str] = None
channel_type: Optional[str] = None channel_type: Optional[str] = None
webhook_url: Optional[str] = None webhook_url: Optional[str] = None
sign_secret: Optional[str] = None
description: Optional[str] = None description: Optional[str] = None
is_enabled: Optional[bool] = None is_enabled: Optional[bool] = None
@@ -77,6 +79,7 @@ async def create_channel(data: ChannelCreate, db: Session = Depends(get_db)):
channel_name=data.channel_name, channel_name=data.channel_name,
channel_type=data.channel_type, channel_type=data.channel_type,
webhook_url=data.webhook_url, webhook_url=data.webhook_url,
sign_secret=data.sign_secret,
description=data.description, description=data.description,
is_enabled=True is_enabled=True
) )
@@ -101,6 +104,8 @@ async def update_channel(channel_id: int, data: ChannelUpdate, db: Session = Dep
channel.channel_type = data.channel_type channel.channel_type = data.channel_type
if data.webhook_url is not None: if data.webhook_url is not None:
channel.webhook_url = data.webhook_url channel.webhook_url = data.webhook_url
if data.sign_secret is not None:
channel.sign_secret = data.sign_secret
if data.description is not None: if data.description is not None:
channel.description = data.description channel.description = data.description
if data.is_enabled is not None: if data.is_enabled is not None:
@@ -126,6 +131,11 @@ async def delete_channel(channel_id: int, db: Session = Depends(get_db)):
async def test_channel(channel_id: int, db: Session = Depends(get_db)): async def test_channel(channel_id: int, db: Session = Depends(get_db)):
"""测试通知渠道""" """测试通知渠道"""
import httpx import httpx
import time
import hmac
import hashlib
import base64
import urllib.parse
channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first() channel = db.query(TaskNotifyChannel).filter(TaskNotifyChannel.id == channel_id).first()
if not channel: if not channel:
@@ -134,7 +144,26 @@ async def test_channel(channel_id: int, db: Session = Depends(get_db)):
test_content = f"**测试消息**\n\n渠道名称: {channel.channel_name}\n发送时间: 测试中..." test_content = f"**测试消息**\n\n渠道名称: {channel.channel_name}\n发送时间: 测试中..."
try: try:
url = channel.webhook_url
if channel.channel_type == 'dingtalk_bot': if channel.channel_type == 'dingtalk_bot':
# 钉钉加签
if channel.sign_secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f'{timestamp}\n{channel.sign_secret}'
hmac_code = hmac.new(
channel.sign_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
# 拼接签名参数
if '?' in url:
url = f"{url}&timestamp={timestamp}&sign={sign}"
else:
url = f"{url}?timestamp={timestamp}&sign={sign}"
payload = { payload = {
"msgtype": "markdown", "msgtype": "markdown",
"markdown": { "markdown": {
@@ -151,7 +180,7 @@ async def test_channel(channel_id: int, db: Session = Depends(get_db)):
} }
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(channel.webhook_url, json=payload) response = await client.post(url, json=payload)
result = response.json() result = response.json()
# 钉钉返回 errcode=0企微返回 errcode=0 # 钉钉返回 errcode=0企微返回 errcode=0
@@ -174,6 +203,7 @@ def format_channel(channel: TaskNotifyChannel) -> dict:
"channel_name": channel.channel_name, "channel_name": channel.channel_name,
"channel_type": channel.channel_type, "channel_type": channel.channel_type,
"webhook_url": channel.webhook_url, "webhook_url": channel.webhook_url,
"sign_secret": channel.sign_secret,
"description": channel.description, "description": channel.description,
"is_enabled": channel.is_enabled, "is_enabled": channel.is_enabled,
"created_at": channel.created_at, "created_at": channel.created_at,

View File

@@ -282,7 +282,31 @@ class SchedulerService:
async def _send_to_channel(self, channel: TaskNotifyChannel, content: str, title: str): async def _send_to_channel(self, channel: TaskNotifyChannel, content: str, title: str):
"""发送消息到通知渠道""" """发送消息到通知渠道"""
import time
import hmac
import hashlib
import base64
import urllib.parse
url = channel.webhook_url
if channel.channel_type == 'dingtalk_bot': if channel.channel_type == 'dingtalk_bot':
# 钉钉加签
if channel.sign_secret:
timestamp = str(round(time.time() * 1000))
string_to_sign = f'{timestamp}\n{channel.sign_secret}'
hmac_code = hmac.new(
channel.sign_secret.encode('utf-8'),
string_to_sign.encode('utf-8'),
digestmod=hashlib.sha256
).digest()
sign = urllib.parse.quote_plus(base64.b64encode(hmac_code))
if '?' in url:
url = f"{url}&timestamp={timestamp}&sign={sign}"
else:
url = f"{url}?timestamp={timestamp}&sign={sign}"
payload = { payload = {
"msgtype": "markdown", "msgtype": "markdown",
"markdown": { "markdown": {
@@ -299,7 +323,7 @@ class SchedulerService:
} }
async with httpx.AsyncClient(timeout=10) as client: async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(channel.webhook_url, json=payload) response = await client.post(url, json=payload)
result = response.json() result = response.json()
if result.get('errcode') != 0: if result.get('errcode') != 0:
print(f"通知发送失败: {result}") print(f"通知发送失败: {result}")

View File

@@ -25,6 +25,7 @@ const form = reactive({
channel_name: '', channel_name: '',
channel_type: 'dingtalk_bot', channel_type: 'dingtalk_bot',
webhook_url: '', webhook_url: '',
sign_secret: '',
description: '' description: ''
}) })
@@ -78,6 +79,7 @@ function handleCreate() {
channel_name: '', channel_name: '',
channel_type: 'dingtalk_bot', channel_type: 'dingtalk_bot',
webhook_url: '', webhook_url: '',
sign_secret: '',
description: '' description: ''
}) })
dialogVisible.value = true dialogVisible.value = true
@@ -91,6 +93,7 @@ function handleEdit(row) {
channel_name: row.channel_name, channel_name: row.channel_name,
channel_type: row.channel_type, channel_type: row.channel_type,
webhook_url: row.webhook_url, webhook_url: row.webhook_url,
sign_secret: row.sign_secret || '',
description: row.description || '' description: row.description || ''
}) })
dialogVisible.value = true dialogVisible.value = true
@@ -258,6 +261,12 @@ onMounted(() => {
</template> </template>
</div> </div>
</el-form-item> </el-form-item>
<el-form-item v-if="form.channel_type === 'dingtalk_bot'" label="加签密钥">
<el-input v-model="form.sign_secret" placeholder="SEC开头的密钥可选" />
<div class="form-tip">
如果创建机器人时选择了加签安全设置请填写密钥 SEC 开头
</div>
</el-form-item>
<el-form-item label="描述"> <el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="2" placeholder="渠道描述(可选)" /> <el-input v-model="form.description" type="textarea" :rows="2" placeholder="渠道描述(可选)" />
</el-form-item> </el-form-item>