feat(notifications): support feishu
This commit is contained in:
1 parent
1b761b9122
commit
fd0baf1d59
19 files changed
+896
-18
No files matched your search
@@ -3173,7 +3173,7 @@ const messages = {
|
||||
channel: '测试渠道',
|
||||
scene: '测试场景',
|
||||
target: '发送目标',
|
||||
targetPlaceholder: '填写邮箱或 Telegram Chat ID',
|
||||
targetPlaceholder: '填写邮箱、Telegram Chat ID 或飞书接收 ID',
|
||||
useFirstRecipient: '使用首个已配置接收人',
|
||||
hint: '测试发送会根据所选场景自动填充示例订单、充值或库存告警内容。',
|
||||
sendAction: '发送测试通知',
|
||||
@@ -3234,6 +3234,28 @@ const messages = {
|
||||
recipients: '接收人 Chat ID',
|
||||
recipientsPlaceholder: '每行一个 Chat ID,支持负数群组 ID',
|
||||
},
|
||||
feishu: {
|
||||
title: '飞书推送',
|
||||
subtitle: '使用飞书自建应用机器人发送通知(非群自定义 Webhook 机器人)',
|
||||
enabled: '启用飞书通知',
|
||||
appID: 'App ID',
|
||||
appIDPlaceholder: '例如:cli_xxxxxxxxxxxxxxxx',
|
||||
appSecret: 'App Secret',
|
||||
appSecretPlaceholder: '填写飞书应用 App Secret',
|
||||
appSecretHintKeep: '当前已配置 App Secret,留空将保持不变',
|
||||
appSecretHintEmpty: '当前尚未配置 App Secret',
|
||||
receiveIDType: '接收 ID 类型',
|
||||
receiveIDTypes: {
|
||||
chatID: '群聊 Chat ID(chat_id)',
|
||||
openID: '用户 Open ID(open_id)',
|
||||
userID: '用户 User ID(user_id)',
|
||||
unionID: '用户 Union ID(union_id)',
|
||||
email: '用户邮箱(email)',
|
||||
},
|
||||
recipients: '接收 ID',
|
||||
recipientsPlaceholder: '每行一个与所选类型匹配的接收 ID',
|
||||
permissionHint: '飞书应用需开启机器人能力、发布版本并授予 im:message:send_as_bot 权限;向群聊发送时还需先将机器人加入群聊。',
|
||||
},
|
||||
},
|
||||
scenes: {
|
||||
title: '通知场景',
|
||||
@@ -7550,7 +7572,7 @@ const messages = {
|
||||
channel: '測試渠道',
|
||||
scene: '測試場景',
|
||||
target: '發送目標',
|
||||
targetPlaceholder: '填寫郵箱或 Telegram Chat ID',
|
||||
targetPlaceholder: '填寫郵箱、Telegram Chat ID 或飛書接收 ID',
|
||||
useFirstRecipient: '使用首個已配置接收人',
|
||||
hint: '測試發送會依所選場景自動填入示例訂單、儲值或庫存告警內容。',
|
||||
sendAction: '發送測試通知',
|
||||
@@ -7611,6 +7633,28 @@ const messages = {
|
||||
recipients: '接收人 Chat ID',
|
||||
recipientsPlaceholder: '每行一個 Chat ID,支援負數群組 ID',
|
||||
},
|
||||
feishu: {
|
||||
title: '飛書推送',
|
||||
subtitle: '使用飛書自建應用機器人發送通知(非群自訂 Webhook 機器人)',
|
||||
enabled: '啟用飛書通知',
|
||||
appID: 'App ID',
|
||||
appIDPlaceholder: '例如:cli_xxxxxxxxxxxxxxxx',
|
||||
appSecret: 'App Secret',
|
||||
appSecretPlaceholder: '填寫飛書應用 App Secret',
|
||||
appSecretHintKeep: '目前已配置 App Secret,留空將保持不變',
|
||||
appSecretHintEmpty: '目前尚未配置 App Secret',
|
||||
receiveIDType: '接收 ID 類型',
|
||||
receiveIDTypes: {
|
||||
chatID: '群聊 Chat ID(chat_id)',
|
||||
openID: '使用者 Open ID(open_id)',
|
||||
userID: '使用者 User ID(user_id)',
|
||||
unionID: '使用者 Union ID(union_id)',
|
||||
email: '使用者郵箱(email)',
|
||||
},
|
||||
recipients: '接收 ID',
|
||||
recipientsPlaceholder: '每行一個與所選類型相符的接收 ID',
|
||||
permissionHint: '飛書應用需開啟機器人能力、發佈版本並授予 im:message:send_as_bot 權限;向群聊發送時還需先將機器人加入群聊。',
|
||||
},
|
||||
},
|
||||
scenes: {
|
||||
title: '通知場景',
|
||||
@@ -11927,7 +11971,7 @@ const messages = {
|
||||
channel: 'Test channel',
|
||||
scene: 'Test scene',
|
||||
target: 'Send target',
|
||||
targetPlaceholder: 'Enter an email address or Telegram Chat ID',
|
||||
targetPlaceholder: 'Enter an email, Telegram Chat ID, or Feishu recipient ID',
|
||||
useFirstRecipient: 'Use first configured recipient',
|
||||
hint: 'The selected scene automatically fills sample order, recharge, or inventory alert content.',
|
||||
sendAction: 'Send test notification',
|
||||
@@ -11988,6 +12032,28 @@ const messages = {
|
||||
recipients: 'Recipient Chat IDs',
|
||||
recipientsPlaceholder: 'One Chat ID per line, negative group IDs supported',
|
||||
},
|
||||
feishu: {
|
||||
title: 'Feishu Notifications',
|
||||
subtitle: 'Send through a Feishu custom-app bot (not a group custom webhook bot)',
|
||||
enabled: 'Enable Feishu notifications',
|
||||
appID: 'App ID',
|
||||
appIDPlaceholder: 'e.g. cli_xxxxxxxxxxxxxxxx',
|
||||
appSecret: 'App Secret',
|
||||
appSecretPlaceholder: 'Enter the Feishu app secret',
|
||||
appSecretHintKeep: 'An App Secret is configured. Leave blank to keep it.',
|
||||
appSecretHintEmpty: 'No App Secret is configured yet.',
|
||||
receiveIDType: 'Recipient ID type',
|
||||
receiveIDTypes: {
|
||||
chatID: 'Group Chat ID (chat_id)',
|
||||
openID: 'User Open ID (open_id)',
|
||||
userID: 'User ID (user_id)',
|
||||
unionID: 'User Union ID (union_id)',
|
||||
email: 'User email (email)',
|
||||
},
|
||||
recipients: 'Recipient IDs',
|
||||
recipientsPlaceholder: 'One recipient ID matching the selected type per line',
|
||||
permissionHint: 'Enable bot capability, publish the app, and grant im:message:send_as_bot. Add the bot to a group before sending to that chat.',
|
||||
},
|
||||
},
|
||||
scenes: {
|
||||
title: 'Notification Scenes',
|
||||
|
||||
@@ -59,6 +59,14 @@ const notificationData = reactive({
|
||||
enabled: false,
|
||||
recipients_text: '',
|
||||
},
|
||||
feishu: {
|
||||
enabled: false,
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
has_app_secret: false,
|
||||
receive_id_type: 'chat_id',
|
||||
recipients_text: '',
|
||||
},
|
||||
},
|
||||
scenes: {
|
||||
wallet_recharge_success: true,
|
||||
@@ -138,6 +146,9 @@ const currentChannelTargets = computed(() => {
|
||||
if (testForm.channel === 'telegram') {
|
||||
return splitRecipients(notificationData.channels.telegram.recipients_text)
|
||||
}
|
||||
if (testForm.channel === 'feishu') {
|
||||
return splitRecipients(notificationData.channels.feishu.recipients_text)
|
||||
}
|
||||
return splitRecipients(notificationData.channels.email.recipients_text)
|
||||
})
|
||||
|
||||
@@ -174,10 +185,17 @@ const fetchSettings = async () => {
|
||||
const notifChannels = notification.channels as Record<string, Record<string, unknown>> | undefined
|
||||
const notifEmail = notifChannels?.email
|
||||
const notifTelegram = notifChannels?.telegram
|
||||
const notifFeishu = notifChannels?.feishu
|
||||
notificationData.channels.email.enabled = !!notifEmail?.enabled
|
||||
notificationData.channels.email.recipients_text = joinRecipients(notifEmail?.recipients)
|
||||
notificationData.channels.telegram.enabled = !!notifTelegram?.enabled
|
||||
notificationData.channels.telegram.recipients_text = joinRecipients(notifTelegram?.recipients)
|
||||
notificationData.channels.feishu.enabled = !!notifFeishu?.enabled
|
||||
notificationData.channels.feishu.app_id = String(notifFeishu?.app_id || '')
|
||||
notificationData.channels.feishu.app_secret = ''
|
||||
notificationData.channels.feishu.has_app_secret = !!notifFeishu?.has_app_secret
|
||||
notificationData.channels.feishu.receive_id_type = String(notifFeishu?.receive_id_type || 'chat_id')
|
||||
notificationData.channels.feishu.recipients_text = joinRecipients(notifFeishu?.recipients)
|
||||
|
||||
const notifScenes = notification.scenes as Record<string, unknown> | undefined
|
||||
notificationData.scenes.wallet_recharge_success = !!notifScenes?.wallet_recharge_success
|
||||
@@ -249,6 +267,7 @@ const changeNotificationLogPage = (page: number) => {
|
||||
|
||||
const notificationChannelLabel = (value: string) => {
|
||||
if (value === 'telegram') return t('admin.settings.notification.channels.telegram.title')
|
||||
if (value === 'feishu') return t('admin.settings.notification.channels.feishu.title')
|
||||
return t('admin.settings.notification.channels.email.title')
|
||||
}
|
||||
|
||||
@@ -349,6 +368,7 @@ onMounted(async () => {
|
||||
<SelectContent>
|
||||
<SelectItem value="email">{{ t('admin.settings.notification.channels.email.title') }}</SelectItem>
|
||||
<SelectItem value="telegram">{{ t('admin.settings.notification.channels.telegram.title') }}</SelectItem>
|
||||
<SelectItem value="feishu">{{ t('admin.settings.notification.channels.feishu.title') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -412,6 +432,7 @@ onMounted(async () => {
|
||||
<SelectItem value="__all__">{{ t('admin.settings.notification.logs.filters.allChannels') }}</SelectItem>
|
||||
<SelectItem value="email">{{ t('admin.settings.notification.channels.email.title') }}</SelectItem>
|
||||
<SelectItem value="telegram">{{ t('admin.settings.notification.channels.telegram.title') }}</SelectItem>
|
||||
<SelectItem value="feishu">{{ t('admin.settings.notification.channels.feishu.title') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select v-model="notificationLogFilters.status" @update:modelValue="handleNotificationLogSearch">
|
||||
|
||||
@@ -36,6 +36,14 @@ interface NotificationData {
|
||||
enabled: boolean
|
||||
recipients_text: string
|
||||
}
|
||||
feishu: {
|
||||
enabled: boolean
|
||||
app_id: string
|
||||
app_secret: string
|
||||
has_app_secret: boolean
|
||||
receive_id_type: string
|
||||
recipients_text: string
|
||||
}
|
||||
}
|
||||
scenes: {
|
||||
wallet_recharge_success: boolean
|
||||
@@ -99,6 +107,14 @@ const form = reactive({
|
||||
enabled: false,
|
||||
recipients_text: '',
|
||||
},
|
||||
feishu: {
|
||||
enabled: false,
|
||||
app_id: '',
|
||||
app_secret: '',
|
||||
has_app_secret: false,
|
||||
receive_id_type: 'chat_id',
|
||||
recipients_text: '',
|
||||
},
|
||||
},
|
||||
scenes: {
|
||||
wallet_recharge_success: true,
|
||||
@@ -125,6 +141,12 @@ const syncFromProps = () => {
|
||||
form.channels.email.recipients_text = props.data.channels.email.recipients_text
|
||||
form.channels.telegram.enabled = props.data.channels.telegram.enabled
|
||||
form.channels.telegram.recipients_text = props.data.channels.telegram.recipients_text
|
||||
form.channels.feishu.enabled = props.data.channels.feishu.enabled
|
||||
form.channels.feishu.app_id = props.data.channels.feishu.app_id
|
||||
form.channels.feishu.app_secret = ''
|
||||
form.channels.feishu.has_app_secret = props.data.channels.feishu.has_app_secret
|
||||
form.channels.feishu.receive_id_type = props.data.channels.feishu.receive_id_type || 'chat_id'
|
||||
form.channels.feishu.recipients_text = props.data.channels.feishu.recipients_text
|
||||
Object.assign(form.scenes, props.data.scenes)
|
||||
form.templates.wallet_recharge_success = deepCloneTemplate(props.data.templates.wallet_recharge_success)
|
||||
form.templates.order_paid_success = deepCloneTemplate(props.data.templates.order_paid_success)
|
||||
@@ -259,6 +281,13 @@ const save = async () => {
|
||||
enabled: form.channels.telegram.enabled,
|
||||
recipients: splitRecipients(form.channels.telegram.recipients_text),
|
||||
},
|
||||
feishu: {
|
||||
enabled: form.channels.feishu.enabled,
|
||||
app_id: form.channels.feishu.app_id.trim(),
|
||||
app_secret: form.channels.feishu.app_secret.trim(),
|
||||
receive_id_type: form.channels.feishu.receive_id_type,
|
||||
recipients: splitRecipients(form.channels.feishu.recipients_text),
|
||||
},
|
||||
},
|
||||
scenes: {
|
||||
wallet_recharge_success: form.scenes.wallet_recharge_success,
|
||||
@@ -423,6 +452,69 @@ defineExpose({ save, submitting })
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border md:col-span-2">
|
||||
<div class="border-b border-border bg-muted/30 px-4 py-3">
|
||||
<h3 class="text-sm font-semibold">{{ t('admin.settings.notification.channels.feishu.title') }}</h3>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ t('admin.settings.notification.channels.feishu.subtitle') }}</p>
|
||||
</div>
|
||||
<div class="space-y-4 p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch v-model="form.channels.feishu.enabled" />
|
||||
<Label class="text-sm">{{ t('admin.settings.notification.channels.feishu.enabled') }}</Label>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.notification.channels.feishu.appID') }}</label>
|
||||
<Input
|
||||
v-model="form.channels.feishu.app_id"
|
||||
:placeholder="t('admin.settings.notification.channels.feishu.appIDPlaceholder')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.notification.channels.feishu.appSecret') }}</label>
|
||||
<Input
|
||||
v-model="form.channels.feishu.app_secret"
|
||||
type="password"
|
||||
:placeholder="t('admin.settings.notification.channels.feishu.appSecretPlaceholder')"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ t(form.channels.feishu.has_app_secret
|
||||
? 'admin.settings.notification.channels.feishu.appSecretHintKeep'
|
||||
: 'admin.settings.notification.channels.feishu.appSecretHintEmpty') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.notification.channels.feishu.receiveIDType') }}</label>
|
||||
<Select v-model="form.channels.feishu.receive_id_type">
|
||||
<SelectTrigger class="h-10">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="chat_id">{{ t('admin.settings.notification.channels.feishu.receiveIDTypes.chatID') }}</SelectItem>
|
||||
<SelectItem value="open_id">{{ t('admin.settings.notification.channels.feishu.receiveIDTypes.openID') }}</SelectItem>
|
||||
<SelectItem value="user_id">{{ t('admin.settings.notification.channels.feishu.receiveIDTypes.userID') }}</SelectItem>
|
||||
<SelectItem value="union_id">{{ t('admin.settings.notification.channels.feishu.receiveIDTypes.unionID') }}</SelectItem>
|
||||
<SelectItem value="email">{{ t('admin.settings.notification.channels.feishu.receiveIDTypes.email') }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-xs font-medium text-muted-foreground">{{ t('admin.settings.notification.channels.feishu.recipients') }}</label>
|
||||
<Textarea
|
||||
v-model="form.channels.feishu.recipients_text"
|
||||
rows="4"
|
||||
:placeholder="t('admin.settings.notification.channels.feishu.recipientsPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">{{ t('admin.settings.notification.channels.feishu.permissionHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-muted/20 p-4">
|
||||
|
||||
@@ -11,6 +11,7 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hibiken/asynq v0.25.1
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.10
|
||||
github.com/mojocn/base64Captcha v1.3.8
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
|
||||
@@ -92,6 +92,7 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
@@ -114,6 +115,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -140,6 +142,8 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
@@ -151,6 +155,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.10 h1:MfAGO8Hf/Izgt6mkvId3bP75MgcASc4laAP0xWzW8CQ=
|
||||
github.com/larksuite/oapi-sdk-go/v3 v3.9.10/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
@@ -235,6 +241,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
|
||||
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
@@ -255,6 +263,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
|
||||
@@ -272,6 +282,8 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/image v0.23.0 h1:HseQ7c2OpPKTPVzNjG5fwJsOTCiiwS4QdsYi5XU6H68=
|
||||
golang.org/x/image v0.23.0/go.mod h1:wJJBTdLfCCf3tiHa1fNxpZmUI4mmoZvwMCPP0ddoNKY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
@@ -280,8 +292,11 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
@@ -300,6 +315,8 @@ golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
@@ -310,6 +327,8 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -367,6 +386,8 @@ golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
@@ -374,6 +395,9 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
downstreamcallbackqueue "github.com/dujiao-next/internal/modules/downstreamcallback/infrastructure/queueadapter"
|
||||
notificationapp "github.com/dujiao-next/internal/modules/notification/application"
|
||||
notificationasyncqueue "github.com/dujiao-next/internal/modules/notification/infrastructure/asyncqueue"
|
||||
notificationfeishu "github.com/dujiao-next/internal/modules/notification/infrastructure/feishu"
|
||||
paymentapp "github.com/dujiao-next/internal/modules/payment/application"
|
||||
paymentqueue "github.com/dujiao-next/internal/modules/payment/infrastructure/queueadapter"
|
||||
procurementapp "github.com/dujiao-next/internal/modules/procurement/application"
|
||||
@@ -54,6 +55,7 @@ func (c *Container) initIntegrationServices() {
|
||||
c.DashboardService,
|
||||
c.NotificationLogService,
|
||||
telegramNotifyService,
|
||||
notificationfeishu.New(),
|
||||
)
|
||||
c.ApiCredentialService = apicredentialapp.NewService(c.ApiCredentialRepo)
|
||||
c.SiteConnectionService = siteconnectionapp.NewService(c.SiteConnectionRepo, c.Config.App.SecretKey, "uploads")
|
||||
|
||||
@@ -17,7 +17,7 @@ func TestNotificationImplementationLivesInBoundedContextDirectories(t *testing.T
|
||||
|
||||
assertFileDeclaresTypes(t, filepath.Join(domainRoot, "log.go"), []string{"NotificationLog"})
|
||||
assertFileDeclaresTypes(t, filepath.Join(contractRoot, "ports.go"), []string{
|
||||
"SettingsReader", "EmailSender", "DispatchQueue", "DashboardAlertReader", "TelegramSender",
|
||||
"SettingsReader", "EmailSender", "DispatchQueue", "DashboardAlertReader", "TelegramSender", "FeishuSender",
|
||||
"OrderStatusEmailInput", "LogRepository", "LogListFilter", "EnqueueInput", "NotificationEnqueuer", "TestSendInput", "TestSender",
|
||||
})
|
||||
assertFileDeclaresTypes(t, filepath.Join(applicationRoot, "service.go"), []string{"Service"})
|
||||
|
||||
@@ -344,6 +344,13 @@ const (
|
||||
NotificationEventExceptionAlertCheck = "exception_alert_check"
|
||||
)
|
||||
|
||||
// 通知中心渠道常量
|
||||
const (
|
||||
NotificationChannelEmail = "email"
|
||||
NotificationChannelTelegram = "telegram"
|
||||
NotificationChannelFeishu = "feishu"
|
||||
)
|
||||
|
||||
// 通知中心异常阈值类型常量
|
||||
const (
|
||||
NotificationAlertTypeOutOfStockProducts = "out_of_stock_products"
|
||||
|
||||
@@ -51,7 +51,7 @@ func ResolveLocale(locale, fallback string) string {
|
||||
return normalizeNotificationLocale(locale)
|
||||
}
|
||||
|
||||
func ComposeTelegramMessage(title, body string) string {
|
||||
func ComposePlainTextMessage(title, body string) string {
|
||||
title = strings.TrimSpace(title)
|
||||
body = strings.TrimSpace(body)
|
||||
if title == "" {
|
||||
@@ -63,6 +63,10 @@ func ComposeTelegramMessage(title, body string) string {
|
||||
return title + "\n\n" + body
|
||||
}
|
||||
|
||||
func ComposeTelegramMessage(title, body string) string {
|
||||
return ComposePlainTextMessage(title, body)
|
||||
}
|
||||
|
||||
func JSONToMap(data jsonmap.JSON) map[string]interface{} {
|
||||
if data == nil {
|
||||
return map[string]interface{}{}
|
||||
|
||||
@@ -36,6 +36,32 @@ func (notificationEmailStub) SendCustomEmail(recipient, _, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type notificationFeishuCall struct {
|
||||
appID string
|
||||
appSecret string
|
||||
receiveIDType string
|
||||
receiveID string
|
||||
message string
|
||||
}
|
||||
|
||||
type notificationFeishuStub struct {
|
||||
calls []notificationFeishuCall
|
||||
}
|
||||
|
||||
func (s *notificationFeishuStub) SendMessage(_ context.Context, appID, appSecret, receiveIDType, receiveID, message string) error {
|
||||
s.calls = append(s.calls, notificationFeishuCall{
|
||||
appID: appID,
|
||||
appSecret: appSecret,
|
||||
receiveIDType: receiveIDType,
|
||||
receiveID: receiveID,
|
||||
message: message,
|
||||
})
|
||||
if strings.Contains(receiveID, "failure") {
|
||||
return errors.New("simulated feishu failure")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type notificationLogRepositoryStub struct {
|
||||
items []domain.NotificationLog
|
||||
}
|
||||
@@ -93,7 +119,7 @@ func setupLogService(t *testing.T) (*Service, *LogService) {
|
||||
},
|
||||
},
|
||||
}
|
||||
service := NewService(notificationSettingsStub{notification: setting}, notificationEmailStub{}, nil, nil, logService, nil)
|
||||
service := NewService(notificationSettingsStub{notification: setting}, notificationEmailStub{}, nil, nil, logService, nil, nil)
|
||||
return service, logService
|
||||
}
|
||||
|
||||
@@ -165,3 +191,60 @@ func TestServiceDispatchSingleEventRecordsPerRecipientResult(t *testing.T) {
|
||||
t.Fatalf("failure recipient status mismatch: %v", statuses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceDispatchSingleEventSendsFeishuAndRecordsEachRecipient(t *testing.T) {
|
||||
logService := NewLogService(¬ificationLogRepositoryStub{})
|
||||
feishuSender := ¬ificationFeishuStub{}
|
||||
setting := settingsmessaging.NotificationCenterDefaultSetting()
|
||||
setting.DefaultLocale = constants.LocaleZhCN
|
||||
setting.Channels.Feishu = settingsmessaging.FeishuNotificationChannelSetting{
|
||||
Enabled: true,
|
||||
AppID: "cli_demo",
|
||||
AppSecret: "secret",
|
||||
ReceiveIDType: settingsmessaging.FeishuReceiveIDTypeChatID,
|
||||
Recipients: []string{"oc_success", "oc_failure"},
|
||||
}
|
||||
service := NewService(notificationSettingsStub{notification: setting}, nil, nil, nil, logService, nil, feishuSender)
|
||||
|
||||
err := service.dispatchSingleEvent(context.Background(), setting, queue.NotificationDispatchPayload{
|
||||
EventType: constants.NotificationEventOrderPaidSuccess,
|
||||
BizType: constants.NotificationBizTypeOrder,
|
||||
BizID: 99,
|
||||
Locale: constants.LocaleZhCN,
|
||||
Force: true,
|
||||
Data: map[string]interface{}{
|
||||
"order_no": "DJ-FEISHU-99",
|
||||
"customer_email": "member@example.com",
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, contract.ErrSendFailed) {
|
||||
t.Fatalf("expected one feishu failure, got %v", err)
|
||||
}
|
||||
if len(feishuSender.calls) != 2 {
|
||||
t.Fatalf("expected two feishu calls, got %d", len(feishuSender.calls))
|
||||
}
|
||||
firstCall := feishuSender.calls[0]
|
||||
if firstCall.appID != "cli_demo" || firstCall.appSecret != "secret" || firstCall.receiveIDType != settingsmessaging.FeishuReceiveIDTypeChatID {
|
||||
t.Fatalf("unexpected feishu credentials or id type: %#v", firstCall)
|
||||
}
|
||||
if !strings.Contains(firstCall.message, "DJ-FEISHU-99") {
|
||||
t.Fatalf("rendered feishu message missing order number: %q", firstCall.message)
|
||||
}
|
||||
|
||||
items, total, listErr := logService.ListForAdmin(contract.LogListFilter{
|
||||
Page: 1, PageSize: 10, Channel: constants.NotificationChannelFeishu,
|
||||
})
|
||||
if listErr != nil {
|
||||
t.Fatalf("list feishu notification logs: %v", listErr)
|
||||
}
|
||||
if total != 2 || len(items) != 2 {
|
||||
t.Fatalf("expected two feishu logs, total=%d len=%d", total, len(items))
|
||||
}
|
||||
statuses := map[string]string{}
|
||||
for _, item := range items {
|
||||
statuses[item.Recipient] = item.Status
|
||||
}
|
||||
if statuses["oc_success"] != notificationLogStatusSuccess || statuses["oc_failure"] != notificationLogStatusFailed {
|
||||
t.Fatalf("unexpected feishu log statuses: %#v", statuses)
|
||||
}
|
||||
}
|
||||
@@ -57,7 +57,7 @@ func (s *Service) SendTest(ctx context.Context, input contract.TestSendInput) er
|
||||
}
|
||||
|
||||
switch channel {
|
||||
case "email":
|
||||
case constants.NotificationChannelEmail:
|
||||
sendErr := contract.ErrSendFailed
|
||||
if s.emailService != nil {
|
||||
sendErr = s.emailService.SendCustomEmail(target, title, body)
|
||||
@@ -74,12 +74,39 @@ func (s *Service) SendTest(ctx context.Context, input contract.TestSendInput) er
|
||||
sendErr: sendErr,
|
||||
})
|
||||
return sendErr
|
||||
case "telegram":
|
||||
case constants.NotificationChannelTelegram:
|
||||
sendErr := contract.ErrSendFailed
|
||||
gatewayCtx, cancel := detachOutboundRequestContext(ctx)
|
||||
defer cancel()
|
||||
if s.telegramSender != nil {
|
||||
sendErr = s.telegramSender.SendMessage(gatewayCtx, target, format.ComposeTelegramMessage(title, body))
|
||||
sendErr = s.telegramSender.SendMessage(gatewayCtx, target, format.ComposePlainTextMessage(title, body))
|
||||
}
|
||||
s.recordSendAttempt(notificationSendAttempt{
|
||||
eventType: scene,
|
||||
channel: channel,
|
||||
recipient: target,
|
||||
locale: locale,
|
||||
title: title,
|
||||
body: body,
|
||||
variables: variables,
|
||||
isTest: true,
|
||||
sendErr: sendErr,
|
||||
})
|
||||
return sendErr
|
||||
case constants.NotificationChannelFeishu:
|
||||
sendErr := contract.ErrSendFailed
|
||||
gatewayCtx, cancel := detachOutboundRequestContext(ctx)
|
||||
defer cancel()
|
||||
if s.feishuSender != nil {
|
||||
feishu := setting.Channels.Feishu
|
||||
sendErr = s.feishuSender.SendMessage(
|
||||
gatewayCtx,
|
||||
feishu.AppID,
|
||||
feishu.AppSecret,
|
||||
feishu.ReceiveIDType,
|
||||
target,
|
||||
format.ComposePlainTextMessage(title, body),
|
||||
)
|
||||
}
|
||||
s.recordSendAttempt(notificationSendAttempt{
|
||||
eventType: scene,
|
||||
@@ -134,7 +161,7 @@ func (s *Service) dispatchSingleEvent(ctx context.Context, setting settingsmessa
|
||||
eventType: payload.EventType,
|
||||
bizType: payload.BizType,
|
||||
bizID: payload.BizID,
|
||||
channel: "email",
|
||||
channel: constants.NotificationChannelEmail,
|
||||
recipient: recipient,
|
||||
locale: locale,
|
||||
title: title,
|
||||
@@ -157,7 +184,7 @@ func (s *Service) dispatchSingleEvent(ctx context.Context, setting settingsmessa
|
||||
}
|
||||
}
|
||||
if setting.Channels.Telegram.Enabled && len(setting.Channels.Telegram.Recipients) > 0 {
|
||||
message := format.ComposeTelegramMessage(title, body)
|
||||
message := format.ComposePlainTextMessage(title, body)
|
||||
for _, recipient := range setting.Channels.Telegram.Recipients {
|
||||
var sendErr error
|
||||
if s.telegramSender == nil {
|
||||
@@ -169,7 +196,7 @@ func (s *Service) dispatchSingleEvent(ctx context.Context, setting settingsmessa
|
||||
eventType: payload.EventType,
|
||||
bizType: payload.BizType,
|
||||
bizID: payload.BizID,
|
||||
channel: "telegram",
|
||||
channel: constants.NotificationChannelTelegram,
|
||||
recipient: recipient,
|
||||
locale: locale,
|
||||
title: title,
|
||||
@@ -191,6 +218,48 @@ func (s *Service) dispatchSingleEvent(ctx context.Context, setting settingsmessa
|
||||
}
|
||||
}
|
||||
}
|
||||
if setting.Channels.Feishu.Enabled && len(setting.Channels.Feishu.Recipients) > 0 {
|
||||
message := format.ComposePlainTextMessage(title, body)
|
||||
for _, recipient := range setting.Channels.Feishu.Recipients {
|
||||
var sendErr error
|
||||
if s.feishuSender == nil {
|
||||
sendErr = contract.ErrSendFailed
|
||||
} else {
|
||||
sendErr = s.feishuSender.SendMessage(
|
||||
ctx,
|
||||
setting.Channels.Feishu.AppID,
|
||||
setting.Channels.Feishu.AppSecret,
|
||||
setting.Channels.Feishu.ReceiveIDType,
|
||||
recipient,
|
||||
message,
|
||||
)
|
||||
}
|
||||
s.recordSendAttempt(notificationSendAttempt{
|
||||
eventType: payload.EventType,
|
||||
bizType: payload.BizType,
|
||||
bizID: payload.BizID,
|
||||
channel: constants.NotificationChannelFeishu,
|
||||
recipient: recipient,
|
||||
locale: locale,
|
||||
title: title,
|
||||
body: body,
|
||||
variables: variables,
|
||||
sendErr: sendErr,
|
||||
})
|
||||
if sendErr != nil {
|
||||
logger.Warnw("notification_feishu_send_failed",
|
||||
"event_type", payload.EventType,
|
||||
"biz_type", payload.BizType,
|
||||
"biz_id", payload.BizID,
|
||||
"recipient", recipient,
|
||||
"error", sendErr,
|
||||
)
|
||||
if firstErr == nil {
|
||||
firstErr = sendErr
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
return fmt.Errorf("%w: %v", contract.ErrSendFailed, firstErr)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ type Service struct {
|
||||
dashboardSvc contract.DashboardAlertReader
|
||||
logService *LogService
|
||||
telegramSender contract.TelegramSender
|
||||
feishuSender contract.FeishuSender
|
||||
}
|
||||
|
||||
// NewService 创建通知中心服务。
|
||||
@@ -28,6 +29,7 @@ func NewService(
|
||||
dashboardSvc contract.DashboardAlertReader,
|
||||
logService *LogService,
|
||||
telegramSender contract.TelegramSender,
|
||||
feishuSender contract.FeishuSender,
|
||||
) *Service {
|
||||
return &Service{
|
||||
settingService: settingService,
|
||||
@@ -36,6 +38,7 @@ func NewService(
|
||||
dashboardSvc: dashboardSvc,
|
||||
logService: logService,
|
||||
telegramSender: telegramSender,
|
||||
feishuSender: feishuSender,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,10 @@ type TelegramSender interface {
|
||||
SendMessage(ctx context.Context, chatID, message string) error
|
||||
}
|
||||
|
||||
type FeishuSender interface {
|
||||
SendMessage(ctx context.Context, appID, appSecret, receiveIDType, receiveID, message string) error
|
||||
}
|
||||
|
||||
type LogRepository interface {
|
||||
Create(log *domain.NotificationLog) error
|
||||
ListAdmin(filter LogListFilter) ([]domain.NotificationLog, int64, error)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dujiao-next/internal/modules/notification/contract"
|
||||
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1"
|
||||
)
|
||||
|
||||
const feishuRequestTimeout = 10 * time.Second
|
||||
|
||||
type messageClient interface {
|
||||
SendText(ctx context.Context, receiveIDType, receiveID, message string) error
|
||||
}
|
||||
|
||||
type clientFactory func(appID, appSecret string) messageClient
|
||||
|
||||
// Sender 使用飞书官方 SDK 通过自建应用机器人发送通知。
|
||||
// 它会复用当前凭据对应的 SDK Client,从而复用 SDK 内置的 tenant token 缓存。
|
||||
type Sender struct {
|
||||
mu sync.Mutex
|
||||
appID string
|
||||
appSecret string
|
||||
client messageClient
|
||||
factory clientFactory
|
||||
}
|
||||
|
||||
var _ contract.FeishuSender = (*Sender)(nil)
|
||||
|
||||
// New 创建飞书机器人通知发送器。
|
||||
func New() *Sender {
|
||||
return newSender(func(appID, appSecret string) messageClient {
|
||||
return &sdkMessageClient{client: lark.NewClient(
|
||||
appID,
|
||||
appSecret,
|
||||
lark.WithReqTimeout(feishuRequestTimeout),
|
||||
lark.WithLogLevel(larkcore.LogLevelError),
|
||||
)}
|
||||
})
|
||||
}
|
||||
|
||||
func newSender(factory clientFactory) *Sender {
|
||||
if factory == nil {
|
||||
panic("feishu notification sender: client factory is nil")
|
||||
}
|
||||
return &Sender{factory: factory}
|
||||
}
|
||||
|
||||
// SendMessage 向指定飞书用户或群聊发送纯文本消息。
|
||||
func (s *Sender) SendMessage(ctx context.Context, appID, appSecret, receiveIDType, receiveID, message string) error {
|
||||
appID = strings.TrimSpace(appID)
|
||||
appSecret = strings.TrimSpace(appSecret)
|
||||
receiveIDType = strings.ToLower(strings.TrimSpace(receiveIDType))
|
||||
receiveID = strings.TrimSpace(receiveID)
|
||||
message = strings.TrimSpace(message)
|
||||
if s == nil || appID == "" || appSecret == "" || receiveID == "" || message == "" || !isSupportedReceiveIDType(receiveIDType) {
|
||||
return contract.ErrConfigInvalid
|
||||
}
|
||||
return s.clientFor(appID, appSecret).SendText(ctx, receiveIDType, receiveID, message)
|
||||
}
|
||||
|
||||
func (s *Sender) clientFor(appID, appSecret string) messageClient {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.client == nil || s.appID != appID || s.appSecret != appSecret {
|
||||
s.client = s.factory(appID, appSecret)
|
||||
s.appID = appID
|
||||
s.appSecret = appSecret
|
||||
}
|
||||
return s.client
|
||||
}
|
||||
|
||||
func isSupportedReceiveIDType(value string) bool {
|
||||
switch value {
|
||||
case "chat_id", "open_id", "user_id", "union_id", "email":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type sdkMessageClient struct {
|
||||
client *lark.Client
|
||||
}
|
||||
|
||||
func (c *sdkMessageClient) SendText(ctx context.Context, receiveIDType, receiveID, message string) error {
|
||||
content, err := json.Marshal(struct {
|
||||
Text string `json:"text"`
|
||||
}{Text: message})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: encode feishu message: %v", contract.ErrSendFailed, err)
|
||||
}
|
||||
|
||||
req := larkim.NewCreateMessageReqBuilder().
|
||||
ReceiveIdType(receiveIDType).
|
||||
Body(larkim.NewCreateMessageReqBodyBuilder().
|
||||
ReceiveId(receiveID).
|
||||
MsgType("text").
|
||||
Content(string(content)).
|
||||
Build()).
|
||||
Build()
|
||||
resp, err := c.client.Im.V1.Message.Create(ctx, req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: feishu sdk request: %v", contract.ErrSendFailed, err)
|
||||
}
|
||||
if resp == nil {
|
||||
return fmt.Errorf("%w: empty feishu response", contract.ErrSendFailed)
|
||||
}
|
||||
if !resp.Success() {
|
||||
requestID := ""
|
||||
if resp.ApiResp != nil {
|
||||
requestID = resp.RequestId()
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"%w: feishu api code=%d msg=%s request_id=%s",
|
||||
contract.ErrSendFailed,
|
||||
resp.Code,
|
||||
strings.TrimSpace(resp.Msg),
|
||||
requestID,
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package feishu
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/dujiao-next/internal/modules/notification/contract"
|
||||
)
|
||||
|
||||
type fakeMessageClient struct {
|
||||
calls int
|
||||
receiveIDType string
|
||||
receiveID string
|
||||
message string
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *fakeMessageClient) SendText(_ context.Context, receiveIDType, receiveID, message string) error {
|
||||
c.calls++
|
||||
c.receiveIDType = receiveIDType
|
||||
c.receiveID = receiveID
|
||||
c.message = message
|
||||
return c.err
|
||||
}
|
||||
|
||||
func TestSenderSendsTrimmedMessageAndReusesClientForCredentials(t *testing.T) {
|
||||
var clients []*fakeMessageClient
|
||||
var credentials [][2]string
|
||||
sender := newSender(func(appID, appSecret string) messageClient {
|
||||
client := &fakeMessageClient{}
|
||||
clients = append(clients, client)
|
||||
credentials = append(credentials, [2]string{appID, appSecret})
|
||||
return client
|
||||
})
|
||||
|
||||
if err := sender.SendMessage(context.Background(), " cli_demo ", " secret ", " CHAT_ID ", " oc_first ", " hello "); err != nil {
|
||||
t.Fatalf("send first message: %v", err)
|
||||
}
|
||||
if err := sender.SendMessage(context.Background(), "cli_demo", "secret", "chat_id", "oc_second", "world"); err != nil {
|
||||
t.Fatalf("send second message: %v", err)
|
||||
}
|
||||
if len(clients) != 1 {
|
||||
t.Fatalf("same credentials should reuse one client, got %d", len(clients))
|
||||
}
|
||||
if credentials[0] != [2]string{"cli_demo", "secret"} {
|
||||
t.Fatalf("unexpected factory credentials: %#v", credentials[0])
|
||||
}
|
||||
if clients[0].calls != 2 || clients[0].receiveID != "oc_second" || clients[0].message != "world" {
|
||||
t.Fatalf("unexpected send calls: %#v", clients[0])
|
||||
}
|
||||
|
||||
if err := sender.SendMessage(context.Background(), "cli_other", "secret-2", "open_id", "ou_user", "next"); err != nil {
|
||||
t.Fatalf("send with changed credentials: %v", err)
|
||||
}
|
||||
if len(clients) != 2 {
|
||||
t.Fatalf("changed credentials should create another client, got %d", len(clients))
|
||||
}
|
||||
if clients[1].receiveIDType != "open_id" || clients[1].receiveID != "ou_user" {
|
||||
t.Fatalf("unexpected changed-client request: %#v", clients[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderRejectsInvalidConfiguration(t *testing.T) {
|
||||
sender := newSender(func(_, _ string) messageClient { return &fakeMessageClient{} })
|
||||
tests := []struct {
|
||||
name string
|
||||
appID string
|
||||
appSecret string
|
||||
receiveIDType string
|
||||
receiveID string
|
||||
message string
|
||||
}{
|
||||
{"missing app id", "", "secret", "chat_id", "oc_demo", "message"},
|
||||
{"missing app secret", "cli_demo", "", "chat_id", "oc_demo", "message"},
|
||||
{"unsupported receive id type", "cli_demo", "secret", "department_id", "od_demo", "message"},
|
||||
{"missing receive id", "cli_demo", "secret", "chat_id", "", "message"},
|
||||
{"missing message", "cli_demo", "secret", "chat_id", "oc_demo", ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := sender.SendMessage(context.Background(), test.appID, test.appSecret, test.receiveIDType, test.receiveID, test.message)
|
||||
if !errors.Is(err, contract.ErrConfigInvalid) {
|
||||
t.Fatalf("expected invalid config, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSenderReturnsClientError(t *testing.T) {
|
||||
wantErr := errors.New("feishu unavailable")
|
||||
sender := newSender(func(_, _ string) messageClient { return &fakeMessageClient{err: wantErr} })
|
||||
err := sender.SendMessage(context.Background(), "cli_demo", "secret", "chat_id", "oc_demo", "message")
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("expected client error, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/dujiao-next/internal/constants"
|
||||
"github.com/dujiao-next/internal/modules/notification/contract"
|
||||
"github.com/dujiao-next/internal/modules/notification/domain"
|
||||
settingsmessaging "github.com/dujiao-next/internal/modules/settings/schema/messaging"
|
||||
@@ -126,7 +127,9 @@ func (h *AdminHandler) TestNotificationCenterSettings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
channel := strings.ToLower(strings.TrimSpace(req.Channel))
|
||||
if channel != "email" && channel != "telegram" {
|
||||
if channel != constants.NotificationChannelEmail &&
|
||||
channel != constants.NotificationChannelTelegram &&
|
||||
channel != constants.NotificationChannelFeishu {
|
||||
ginutil.RespondError(c, response.CodeBadRequest, "error.bad_request", nil)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package notificationhttp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -17,6 +19,15 @@ type notificationLogServiceStub struct {
|
||||
items []domain.NotificationLog
|
||||
}
|
||||
|
||||
type notificationTestSenderStub struct {
|
||||
input contract.TestSendInput
|
||||
}
|
||||
|
||||
func (s *notificationTestSenderStub) SendTest(_ context.Context, input contract.TestSendInput) error {
|
||||
s.input = input
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s notificationLogServiceStub) ListForAdmin(filter contract.LogListFilter) ([]domain.NotificationLog, int64, error) {
|
||||
result := make([]domain.NotificationLog, 0, len(s.items))
|
||||
for _, item := range s.items {
|
||||
@@ -101,3 +112,26 @@ func TestListNotificationLogsFiltersStatusAndChannel(t *testing.T) {
|
||||
t.Fatalf("unexpected status: %s", resp.Data[0].Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationCenterTestSendAcceptsFeishuChannel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
sender := ¬ificationTestSenderStub{}
|
||||
h := &AdminHandler{sender: sender}
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/admin/settings/notification-center/test",
|
||||
strings.NewReader(`{"channel":" FEISHU ","target":" oc_demo ","scene":"exception_alert","locale":"zh-CN"}`),
|
||||
)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.TestNotificationCenterSettings(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status want 200 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if sender.input.Channel != "feishu" || sender.input.Target != "oc_demo" {
|
||||
t.Fatalf("unexpected test send input: %#v", sender.input)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,22 @@ var notificationSupportedLocales = map[string]struct{}{
|
||||
|
||||
var telegramChatIDPattern = regexp.MustCompile(`^-?\d{5,20}$`)
|
||||
|
||||
const (
|
||||
FeishuReceiveIDTypeChatID = "chat_id"
|
||||
FeishuReceiveIDTypeOpenID = "open_id"
|
||||
FeishuReceiveIDTypeUserID = "user_id"
|
||||
FeishuReceiveIDTypeUnionID = "union_id"
|
||||
FeishuReceiveIDTypeEmail = "email"
|
||||
)
|
||||
|
||||
var feishuSupportedReceiveIDTypes = map[string]struct{}{
|
||||
FeishuReceiveIDTypeChatID: {},
|
||||
FeishuReceiveIDTypeOpenID: {},
|
||||
FeishuReceiveIDTypeUserID: {},
|
||||
FeishuReceiveIDTypeUnionID: {},
|
||||
FeishuReceiveIDTypeEmail: {},
|
||||
}
|
||||
|
||||
const (
|
||||
notificationInventoryAlertIntervalDefaultSeconds = 1800
|
||||
notificationInventoryAlertIntervalMinSeconds = 60
|
||||
@@ -41,10 +57,20 @@ type NotificationChannelSetting struct {
|
||||
Recipients []string `json:"recipients"`
|
||||
}
|
||||
|
||||
// FeishuNotificationChannelSetting 飞书自建应用机器人通知配置。
|
||||
type FeishuNotificationChannelSetting struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AppID string `json:"app_id"`
|
||||
AppSecret string `json:"app_secret"`
|
||||
ReceiveIDType string `json:"receive_id_type"`
|
||||
Recipients []string `json:"recipients"`
|
||||
}
|
||||
|
||||
// NotificationChannelsSetting 通知渠道集合
|
||||
type NotificationChannelsSetting struct {
|
||||
Email NotificationChannelSetting `json:"email"`
|
||||
Telegram NotificationChannelSetting `json:"telegram"`
|
||||
Email NotificationChannelSetting `json:"email"`
|
||||
Telegram NotificationChannelSetting `json:"telegram"`
|
||||
Feishu FeishuNotificationChannelSetting `json:"feishu"`
|
||||
}
|
||||
|
||||
// NotificationSceneSetting 通知场景开关
|
||||
@@ -104,8 +130,9 @@ type NotificationCenterSettingPatch struct {
|
||||
|
||||
// NotificationChannelsPatch 通知渠道补丁
|
||||
type NotificationChannelsPatch struct {
|
||||
Email *NotificationChannelPatch `json:"email"`
|
||||
Telegram *NotificationChannelPatch `json:"telegram"`
|
||||
Email *NotificationChannelPatch `json:"email"`
|
||||
Telegram *NotificationChannelPatch `json:"telegram"`
|
||||
Feishu *FeishuNotificationChannelPatch `json:"feishu"`
|
||||
}
|
||||
|
||||
// NotificationChannelPatch 通知渠道补丁
|
||||
@@ -114,6 +141,15 @@ type NotificationChannelPatch struct {
|
||||
Recipients *[]string `json:"recipients"`
|
||||
}
|
||||
|
||||
// FeishuNotificationChannelPatch 飞书机器人通知配置补丁。
|
||||
type FeishuNotificationChannelPatch struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
AppID *string `json:"app_id"`
|
||||
AppSecret *string `json:"app_secret"`
|
||||
ReceiveIDType *string `json:"receive_id_type"`
|
||||
Recipients *[]string `json:"recipients"`
|
||||
}
|
||||
|
||||
// NotificationScenePatch 通知场景补丁
|
||||
type NotificationScenePatch struct {
|
||||
WalletRechargeSuccess *bool `json:"wallet_recharge_success"`
|
||||
@@ -156,6 +192,11 @@ func NotificationCenterDefaultSetting() NotificationCenterSetting {
|
||||
Enabled: false,
|
||||
Recipients: []string{},
|
||||
},
|
||||
Feishu: FeishuNotificationChannelSetting{
|
||||
Enabled: false,
|
||||
ReceiveIDType: FeishuReceiveIDTypeChatID,
|
||||
Recipients: []string{},
|
||||
},
|
||||
},
|
||||
Scenes: NotificationSceneSetting{
|
||||
WalletRechargeSuccess: true,
|
||||
@@ -234,6 +275,10 @@ func NormalizeNotificationCenterSetting(setting NotificationCenterSetting) Notif
|
||||
setting.DefaultLocale = NormalizeNotificationLocale(setting.DefaultLocale)
|
||||
setting.Channels.Email.Recipients = normalizeEmailRecipients(setting.Channels.Email.Recipients)
|
||||
setting.Channels.Telegram.Recipients = normalizeTelegramRecipients(setting.Channels.Telegram.Recipients)
|
||||
setting.Channels.Feishu.AppID = strings.TrimSpace(setting.Channels.Feishu.AppID)
|
||||
setting.Channels.Feishu.AppSecret = strings.TrimSpace(setting.Channels.Feishu.AppSecret)
|
||||
setting.Channels.Feishu.ReceiveIDType = normalizeFeishuReceiveIDType(setting.Channels.Feishu.ReceiveIDType)
|
||||
setting.Channels.Feishu.Recipients = normalizeFeishuRecipients(setting.Channels.Feishu.Recipients, setting.Channels.Feishu.ReceiveIDType)
|
||||
setting.DedupeTTLSeconds = normalizeNotificationDedupeTTL(setting.DedupeTTLSeconds)
|
||||
setting.InventoryAlertIntervalSeconds = NormalizeNotificationInventoryAlertInterval(setting.InventoryAlertIntervalSeconds)
|
||||
setting.PaymentOrderAlertIntervalSeconds = NormalizeNotificationPaymentOrderAlertInterval(setting.PaymentOrderAlertIntervalSeconds)
|
||||
@@ -256,6 +301,9 @@ func ValidateNotificationCenterSetting(setting NotificationCenterSetting) error
|
||||
if normalized.Channels.Telegram.Enabled && len(normalized.Channels.Telegram.Recipients) == 0 {
|
||||
return fmt.Errorf("%w: Telegram 渠道已启用但未配置接收人ID", ErrNotificationConfigInvalid)
|
||||
}
|
||||
if normalized.Channels.Feishu.Enabled && len(normalized.Channels.Feishu.Recipients) == 0 {
|
||||
return fmt.Errorf("%w: 飞书渠道已启用但未配置接收人ID", ErrNotificationConfigInvalid)
|
||||
}
|
||||
if normalized.Channels.Email.Enabled {
|
||||
for _, recipient := range normalized.Channels.Email.Recipients {
|
||||
if _, err := mail.ParseAddress(recipient); err != nil {
|
||||
@@ -270,6 +318,24 @@ func ValidateNotificationCenterSetting(setting NotificationCenterSetting) error
|
||||
}
|
||||
}
|
||||
}
|
||||
if normalized.Channels.Feishu.Enabled {
|
||||
if normalized.Channels.Feishu.AppID == "" {
|
||||
return fmt.Errorf("%w: 飞书 App ID 不能为空", ErrNotificationConfigInvalid)
|
||||
}
|
||||
if normalized.Channels.Feishu.AppSecret == "" {
|
||||
return fmt.Errorf("%w: 飞书 App Secret 不能为空", ErrNotificationConfigInvalid)
|
||||
}
|
||||
if _, ok := feishuSupportedReceiveIDTypes[normalized.Channels.Feishu.ReceiveIDType]; !ok {
|
||||
return fmt.Errorf("%w: 飞书接收人ID类型不合法", ErrNotificationConfigInvalid)
|
||||
}
|
||||
if normalized.Channels.Feishu.ReceiveIDType == FeishuReceiveIDTypeEmail {
|
||||
for _, recipient := range normalized.Channels.Feishu.Recipients {
|
||||
if _, err := mail.ParseAddress(recipient); err != nil {
|
||||
return fmt.Errorf("%w: 飞书接收邮箱格式不合法", ErrNotificationConfigInvalid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if normalized.DedupeTTLSeconds < 30 || normalized.DedupeTTLSeconds > 86400 {
|
||||
return fmt.Errorf("%w: 去重时长需在 30-86400 秒之间", ErrNotificationConfigInvalid)
|
||||
@@ -300,6 +366,13 @@ func NotificationCenterSettingToMap(setting NotificationCenterSetting) map[strin
|
||||
"enabled": normalized.Channels.Telegram.Enabled,
|
||||
"recipients": settingsvalue.CloneStringSlice(normalized.Channels.Telegram.Recipients),
|
||||
},
|
||||
"feishu": map[string]interface{}{
|
||||
"enabled": normalized.Channels.Feishu.Enabled,
|
||||
"app_id": normalized.Channels.Feishu.AppID,
|
||||
"app_secret": normalized.Channels.Feishu.AppSecret,
|
||||
"receive_id_type": normalized.Channels.Feishu.ReceiveIDType,
|
||||
"recipients": settingsvalue.CloneStringSlice(normalized.Channels.Feishu.Recipients),
|
||||
},
|
||||
},
|
||||
"scenes": map[string]interface{}{
|
||||
"wallet_recharge_success": normalized.Scenes.WalletRechargeSuccess,
|
||||
@@ -324,7 +397,18 @@ func NotificationCenterSettingToMap(setting NotificationCenterSetting) map[strin
|
||||
// MaskNotificationCenterSettingForAdmin 返回管理端可用配置
|
||||
func MaskNotificationCenterSettingForAdmin(setting NotificationCenterSetting) jsonmap.JSON {
|
||||
normalized := NormalizeNotificationCenterSetting(setting)
|
||||
return jsonmap.JSON(NotificationCenterSettingToMap(normalized))
|
||||
result := jsonmap.JSON(NotificationCenterSettingToMap(normalized))
|
||||
channels := settingsvalue.ToStringAnyMap(result["channels"])
|
||||
if channels == nil {
|
||||
return result
|
||||
}
|
||||
feishu := settingsvalue.ToStringAnyMap(channels["feishu"])
|
||||
if feishu == nil {
|
||||
return result
|
||||
}
|
||||
feishu["app_secret"] = ""
|
||||
feishu["has_app_secret"] = normalized.Channels.Feishu.AppSecret != ""
|
||||
return result
|
||||
}
|
||||
|
||||
// ApplyNotificationCenterSettingPatch 把补丁应用到当前通知中心配置并完成校验。
|
||||
@@ -365,6 +449,25 @@ func ApplyNotificationCenterSettingPatch(current NotificationCenterSetting, patc
|
||||
next.Channels.Telegram.Recipients = settingsvalue.CloneStringSlice(*patch.Channels.Telegram.Recipients)
|
||||
}
|
||||
}
|
||||
if patch.Channels.Feishu != nil {
|
||||
if patch.Channels.Feishu.Enabled != nil {
|
||||
next.Channels.Feishu.Enabled = *patch.Channels.Feishu.Enabled
|
||||
}
|
||||
if patch.Channels.Feishu.AppID != nil {
|
||||
next.Channels.Feishu.AppID = strings.TrimSpace(*patch.Channels.Feishu.AppID)
|
||||
}
|
||||
if patch.Channels.Feishu.AppSecret != nil {
|
||||
if appSecret := strings.TrimSpace(*patch.Channels.Feishu.AppSecret); appSecret != "" {
|
||||
next.Channels.Feishu.AppSecret = appSecret
|
||||
}
|
||||
}
|
||||
if patch.Channels.Feishu.ReceiveIDType != nil {
|
||||
next.Channels.Feishu.ReceiveIDType = strings.ToLower(strings.TrimSpace(*patch.Channels.Feishu.ReceiveIDType))
|
||||
}
|
||||
if patch.Channels.Feishu.Recipients != nil {
|
||||
next.Channels.Feishu.Recipients = settingsvalue.CloneStringSlice(*patch.Channels.Feishu.Recipients)
|
||||
}
|
||||
}
|
||||
}
|
||||
if patch.Scenes != nil {
|
||||
if patch.Scenes.WalletRechargeSuccess != nil {
|
||||
@@ -469,6 +572,13 @@ func DecodeNotificationCenterSetting(raw jsonmap.JSON, fallback NotificationCent
|
||||
next.Channels.Telegram.Enabled = settingsvalue.ReadBool(telegramMap, "enabled", next.Channels.Telegram.Enabled)
|
||||
next.Channels.Telegram.Recipients = settingsvalue.ReadStringList(telegramMap, "recipients", next.Channels.Telegram.Recipients)
|
||||
}
|
||||
if feishuMap := settingsvalue.ToStringAnyMap(channelsMap["feishu"]); feishuMap != nil {
|
||||
next.Channels.Feishu.Enabled = settingsvalue.ReadBool(feishuMap, "enabled", next.Channels.Feishu.Enabled)
|
||||
next.Channels.Feishu.AppID = settingsvalue.ReadString(feishuMap, "app_id", next.Channels.Feishu.AppID)
|
||||
next.Channels.Feishu.AppSecret = settingsvalue.ReadString(feishuMap, "app_secret", next.Channels.Feishu.AppSecret)
|
||||
next.Channels.Feishu.ReceiveIDType = settingsvalue.ReadString(feishuMap, "receive_id_type", next.Channels.Feishu.ReceiveIDType)
|
||||
next.Channels.Feishu.Recipients = settingsvalue.ReadStringList(feishuMap, "recipients", next.Channels.Feishu.Recipients)
|
||||
}
|
||||
}
|
||||
|
||||
if scenesMap := settingsvalue.ToStringAnyMap(raw["scenes"]); scenesMap != nil {
|
||||
@@ -495,6 +605,7 @@ func DecodeNotificationCenterSetting(raw jsonmap.JSON, fallback NotificationCent
|
||||
if !legacyEnabled {
|
||||
next.Channels.Email.Enabled = false
|
||||
next.Channels.Telegram.Enabled = false
|
||||
next.Channels.Feishu.Enabled = false
|
||||
}
|
||||
|
||||
return next
|
||||
@@ -612,6 +723,18 @@ func normalizeTelegramRecipients(items []string) []string {
|
||||
return filtered
|
||||
}
|
||||
|
||||
func normalizeFeishuReceiveIDType(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
if normalized == "" {
|
||||
return FeishuReceiveIDTypeChatID
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeFeishuRecipients(items []string, receiveIDType string) []string {
|
||||
return normalizeNotificationStringList(items, receiveIDType == FeishuReceiveIDTypeEmail)
|
||||
}
|
||||
|
||||
func normalizeNotificationStringList(items []string, lower bool) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
seen := make(map[string]struct{}, len(items))
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package settingsmessaging
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
settingsvalue "github.com/dujiao-next/internal/modules/settings/schema/value"
|
||||
"github.com/dujiao-next/internal/shared/jsonmap"
|
||||
)
|
||||
|
||||
func TestNotificationCenterFeishuPatchKeepsSecretAndNormalizesRecipients(t *testing.T) {
|
||||
current := NotificationCenterDefaultSetting()
|
||||
current.Channels.Feishu.AppSecret = "saved-secret"
|
||||
enabled := true
|
||||
appID := " cli_demo "
|
||||
emptySecret := " "
|
||||
receiveIDType := " OPEN_ID "
|
||||
recipients := []string{" ou_first ", "ou_first", "ou_second"}
|
||||
|
||||
next, err := ApplyNotificationCenterSettingPatch(current, NotificationCenterSettingPatch{
|
||||
Channels: &NotificationChannelsPatch{
|
||||
Feishu: &FeishuNotificationChannelPatch{
|
||||
Enabled: &enabled,
|
||||
AppID: &appID,
|
||||
AppSecret: &emptySecret,
|
||||
ReceiveIDType: &receiveIDType,
|
||||
Recipients: &recipients,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("apply feishu patch: %v", err)
|
||||
}
|
||||
if next.Channels.Feishu.AppID != "cli_demo" {
|
||||
t.Fatalf("unexpected app id: %q", next.Channels.Feishu.AppID)
|
||||
}
|
||||
if next.Channels.Feishu.AppSecret != "saved-secret" {
|
||||
t.Fatalf("empty patch must preserve secret, got %q", next.Channels.Feishu.AppSecret)
|
||||
}
|
||||
if next.Channels.Feishu.ReceiveIDType != FeishuReceiveIDTypeOpenID {
|
||||
t.Fatalf("unexpected receive id type: %q", next.Channels.Feishu.ReceiveIDType)
|
||||
}
|
||||
if !reflect.DeepEqual(next.Channels.Feishu.Recipients, []string{"ou_first", "ou_second"}) {
|
||||
t.Fatalf("unexpected recipients: %#v", next.Channels.Feishu.Recipients)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationCenterFeishuMaskAndPersistenceRoundTrip(t *testing.T) {
|
||||
setting := NotificationCenterDefaultSetting()
|
||||
setting.Channels.Feishu = FeishuNotificationChannelSetting{
|
||||
Enabled: true,
|
||||
AppID: "cli_demo",
|
||||
AppSecret: "top-secret",
|
||||
ReceiveIDType: FeishuReceiveIDTypeChatID,
|
||||
Recipients: []string{"oc_demo"},
|
||||
}
|
||||
|
||||
encoded := jsonmap.JSON(NotificationCenterSettingToMap(setting))
|
||||
decoded := DecodeNotificationCenterSetting(encoded, NotificationCenterDefaultSetting())
|
||||
if decoded.Channels.Feishu.AppSecret != "top-secret" {
|
||||
t.Fatalf("persisted secret was not decoded: %q", decoded.Channels.Feishu.AppSecret)
|
||||
}
|
||||
if !reflect.DeepEqual(decoded.Channels.Feishu.Recipients, []string{"oc_demo"}) {
|
||||
t.Fatalf("unexpected decoded recipients: %#v", decoded.Channels.Feishu.Recipients)
|
||||
}
|
||||
|
||||
masked := MaskNotificationCenterSettingForAdmin(setting)
|
||||
channels := settingsvalue.ToStringAnyMap(masked["channels"])
|
||||
feishu := settingsvalue.ToStringAnyMap(channels["feishu"])
|
||||
if feishu["app_secret"] != "" {
|
||||
t.Fatalf("admin response leaked app secret: %#v", feishu["app_secret"])
|
||||
}
|
||||
if feishu["has_app_secret"] != true {
|
||||
t.Fatalf("admin response must expose configured state: %#v", feishu["has_app_secret"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNotificationCenterFeishuConfiguration(t *testing.T) {
|
||||
valid := NotificationCenterDefaultSetting()
|
||||
valid.Channels.Feishu = FeishuNotificationChannelSetting{
|
||||
Enabled: true,
|
||||
AppID: "cli_demo",
|
||||
AppSecret: "secret",
|
||||
ReceiveIDType: FeishuReceiveIDTypeChatID,
|
||||
Recipients: []string{"oc_demo"},
|
||||
}
|
||||
if err := ValidateNotificationCenterSetting(valid); err != nil {
|
||||
t.Fatalf("expected valid feishu configuration, got %v", err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*NotificationCenterSetting)
|
||||
}{
|
||||
{"missing app id", func(setting *NotificationCenterSetting) { setting.Channels.Feishu.AppID = "" }},
|
||||
{"missing app secret", func(setting *NotificationCenterSetting) { setting.Channels.Feishu.AppSecret = "" }},
|
||||
{"missing recipients", func(setting *NotificationCenterSetting) { setting.Channels.Feishu.Recipients = nil }},
|
||||
{"unsupported receive id type", func(setting *NotificationCenterSetting) { setting.Channels.Feishu.ReceiveIDType = "department_id" }},
|
||||
{"invalid recipient email", func(setting *NotificationCenterSetting) {
|
||||
setting.Channels.Feishu.ReceiveIDType = FeishuReceiveIDTypeEmail
|
||||
setting.Channels.Feishu.Recipients = []string{"not-an-email"}
|
||||
}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
setting := valid
|
||||
test.mutate(&setting)
|
||||
if err := ValidateNotificationCenterSetting(setting); !errors.Is(err, ErrNotificationConfigInvalid) {
|
||||
t.Fatalf("expected invalid notification config, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user