feat(notifications): support feishu

This commit is contained in:
Pylogmon committed 2026-08-08 17:04:30 +08:00
1 parent 1b761b9122
commit fd0baf1d59
19 files changed
+896 -18

No files matched your search

+69 -3
View File
@@ -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">
+1
View File
@@ -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
+24
View File
@@ -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"})
+7
View File
@@ -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(&notificationLogRepositoryStub{})
feishuSender := &notificationFeishuStub{}
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 := &notificationTestSenderStub{}
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)
}
})
}
}