feat: 管理端推送 Webhook 配置与出站推送链路接入

1、修复了管理端设置在推送开关/配置项上的联动与保存问题。

2、新增 OutboundPushHookService、PushHook/InternalPushHook 接口、前端 PushHookConfigPanel 与小程序 pushHook 工具。

3、优化 Analyze/Auth/Test/支付回调触发路径,补充去重迁移脚本与实施文档。

Made-with: Cursor
This commit is contained in:
Ghost
2026-04-15 15:07:36 +08:00
parent c946584a7d
commit cf835ea585
29 changed files with 2964 additions and 111 deletions

View File

@@ -0,0 +1,394 @@
<template>
<div class="tab-content" v-loading="loading">
<div class="content-header">
<h3>通用 HTTP 出站推送</h3>
<p class="scope-badge" v-if="scopeLabel">
<span class="scope-pill">{{ scopeLabel }}</span>
<span v-if="enterpriseName" class="scope-name">{{ enterpriseName }}</span>
</p>
<p class="content-description">
向自有系统 POST JSON含事件名投递 ID业务负载可与飞书获客并行
<template v-if="scope === 'enterprise'">
当前为<strong>本企业专属</strong>配置若本企业未启用或 URL 无效事件将<strong>回落</strong>到超管配置的全平台默认
</template>
<template v-else>
当前为<strong>全平台默认</strong>无企业专属或超管维护业务归属某企业且该企业配置了专属 Hook 将优先推送到企业 URL
</template>
留空订阅事件表示三类事件全部推送
</p>
<div class="form-section test-tools">
<h4 class="test-tools-title">测试与调试</h4>
<p class="hint test-tools-hint">
<strong>发送连接测试</strong>只验证 URL 是否可达hook.ping不写去重表<strong>手动测试测评完成</strong>按库表
<code>test_results.id</code> 重放真实 <code>test.result_completed</code>人脸问卷简历等均可
</p>
<div class="test-tools-actions">
<el-button plain :loading="testLoading" @click="sendTest">发送连接测试</el-button>
</div>
<div class="manual-test-row">
<span class="manual-test-label">记录 ID</span>
<el-input
v-model.number="replayTestResultId"
type="number"
:min="1"
step="1"
placeholder="test_results 主键"
class="manual-test-input"
/>
<span class="manual-test-label force-label">强制</span>
<el-switch v-model="replayForce" />
<el-button
type="primary"
color="#7c3aed"
class="manual-test-btn"
:loading="replayLoading"
@click="replayTestResult"
>
手动测试测评完成
</el-button>
</div>
</div>
</div>
<div class="form-section">
<div class="form-item row-line">
<label>启用</label>
<el-switch v-model="form.enabled" />
</div>
<div class="form-item">
<label>接收 URL</label>
<el-input
v-model="form.url"
type="textarea"
:rows="2"
placeholder="https://example.com/hooks/mbti"
class="w-full"
/>
</div>
<div class="form-item">
<label>签名密钥可选</label>
<el-input
v-model="form.secret"
type="password"
show-password
placeholder="填写则请求头带 X-MBTI-Signature: sha256=…"
class="w-full"
/>
</div>
<div class="form-item">
<label>订阅事件</label>
<el-checkbox-group v-model="form.eventChecks">
<el-checkbox v-for="opt in eventOptions" :key="opt.value" :label="opt.value">
{{ opt.label }}
</el-checkbox>
</el-checkbox-group>
<p class="hint">与文档一致<code>lead.order_paid</code><code>lead.phone_bound</code><code>test.result_completed</code>全部勾选保存后将以空列表存库表示订阅全部上方手动测试测评完成与真实落库推送使用同一套订阅校验</p>
</div>
</div>
<div class="save-actions save-actions-row">
<el-button type="primary" color="#7c3aed" class="save-btn" :loading="loading" @click="save">
保存配置
</el-button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/utils/request'
const ALL_EVENTS = ['lead.order_paid', 'lead.phone_bound', 'test.result_completed'] as const
const props = withDefaults(
defineProps<{
/** 如 /admin 或 /superadmin对应 request 基路径下的 settings */
apiPrefix: string
}>(),
{ apiPrefix: '/admin' }
)
const eventOptions = [
{ value: 'lead.order_paid', label: '支付成功(订单)' },
{ value: 'lead.phone_bound', label: '首次绑定手机号' },
{ value: 'test.result_completed', label: '测评/分析结果落库' }
] as const
const loading = ref(false)
const testLoading = ref(false)
const replayLoading = ref(false)
const replayTestResultId = ref<number | null>(null)
const replayForce = ref(false)
/** platform | enterprise来自接口 */
const scope = ref<'platform' | 'enterprise'>('platform')
const enterpriseName = ref<string | null>(null)
const scopeLabel = computed(() => {
if (scope.value === 'enterprise') return '本企业专属'
return '全平台默认'
})
const form = reactive({
enabled: false,
url: '',
secret: '',
/** 勾选中的事件;与 ALL 一致时保存为空数组表示「全部」 */
eventChecks: [] as string[]
})
const normalizeLoadedEvents = (raw: unknown): string[] => {
if (!Array.isArray(raw) || raw.length === 0) {
return [...ALL_EVENTS]
}
const set = new Set<string>()
for (const e of raw) {
const s = String(e).trim()
if (s) set.add(s)
}
return ALL_EVENTS.filter((e) => set.has(e))
}
const eventsForSave = (): string[] => {
const checks = form.eventChecks
if (checks.length === ALL_EVENTS.length && ALL_EVENTS.every((e) => checks.includes(e))) {
return []
}
return [...checks]
}
const load = async () => {
loading.value = true
try {
const res: any = await request.get(`${props.apiPrefix}/settings/push-hook`)
if (res.code === 200 && res.data) {
const d = res.data
scope.value = d.scope === 'enterprise' ? 'enterprise' : 'platform'
enterpriseName.value = d.enterpriseName != null && d.enterpriseName !== '' ? String(d.enterpriseName) : null
form.enabled = !!d.enabled
form.url = d.url || ''
form.secret = d.secret || ''
form.eventChecks = normalizeLoadedEvents(d.events)
}
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const save = async () => {
loading.value = true
try {
const res: any = await request.put(`${props.apiPrefix}/settings/push-hook`, {
enabled: form.enabled,
url: form.url,
secret: form.secret,
events: eventsForSave()
})
if (res.code === 200) {
ElMessage.success('已保存')
} else {
ElMessage.error(res.msg || '保存失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '保存失败')
} finally {
loading.value = false
}
}
const replayTestResult = async () => {
const id = Number(replayTestResultId.value || 0)
if (!Number.isFinite(id) || id <= 0) {
ElMessage.warning('请填写有效的测试记录 ID')
return
}
replayLoading.value = true
try {
const res: any = await request.post(`${props.apiPrefix}/settings/push-hook/test-result`, {
testResultId: id,
force: replayForce.value ? 1 : 0
})
if (res.code === 200 && res.data) {
const d = res.data
const detail =
[d.businessHint && `业务提示:${d.businessHint}`, d.curlError && `网络:${d.curlError}`, d.responsePreview && `响应体:\n${d.responsePreview}`]
.filter(Boolean)
.join('\n\n') || ''
if (d.ok) {
ElMessage.success(d.message || res.msg || '重放已发出')
if (detail) {
await ElMessageBox.alert(detail, '对端返回详情', { confirmButtonText: '知道了' })
}
} else {
ElMessage.error(d.message || res.msg || '重放未成功')
await ElMessageBox.alert(detail || d.message || '请检查记录 ID 是否存在、是否已订阅 test.result_completed、URL 是否有效。', '排查信息', {
confirmButtonText: '知道了'
})
}
} else {
ElMessage.error(res.msg || '请求失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '请求失败')
} finally {
replayLoading.value = false
}
}
const sendTest = async () => {
testLoading.value = true
try {
const res: any = await request.post(`${props.apiPrefix}/settings/push-hook/test`)
if (res.code === 200 && res.data) {
const d = res.data
const detail =
[d.businessHint && `业务提示:${d.businessHint}`, d.curlError && `网络:${d.curlError}`, d.responsePreview && `响应体:\n${d.responsePreview}`]
.filter(Boolean)
.join('\n\n') || ''
if (d.ok) {
ElMessage.success(d.message || res.msg || '测试已发出')
if (detail) {
await ElMessageBox.alert(detail, '对端返回详情', { confirmButtonText: '知道了' })
}
} else {
ElMessage.error(d.message || res.msg || '测试未成功')
await ElMessageBox.alert(detail || d.message || '请检查 URL 是否为「可接收任意 JSON」的地址企微/飞书机器人需用对应协议,不能直接用通用 JSON。', '排查信息', {
confirmButtonText: '知道了'
})
}
} else {
ElMessage.error(res.msg || '请求失败')
}
} catch (e: any) {
ElMessage.error(e?.message || '请求失败')
} finally {
testLoading.value = false
}
}
onMounted(() => {
load()
})
</script>
<style scoped>
.content-header h3 {
margin: 0 0 8px;
font-size: 18px;
}
.scope-badge {
margin: 0 0 10px;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.scope-pill {
display: inline-block;
font-size: 12px;
font-weight: 600;
padding: 4px 10px;
border-radius: 999px;
background: #ede9fe;
color: #5b21b6;
}
.scope-name {
font-size: 13px;
color: #64748b;
}
.content-description {
margin: 0 0 20px;
color: #64748b;
font-size: 14px;
line-height: 1.5;
}
.form-section {
max-width: 640px;
}
.form-item {
margin-bottom: 16px;
}
.form-item label {
display: block;
margin-bottom: 6px;
font-size: 14px;
color: #334155;
}
.row-line {
display: flex;
align-items: center;
gap: 12px;
}
.row-line label {
margin-bottom: 0;
}
.hint {
margin: 8px 0 0;
font-size: 13px;
color: #64748b;
line-height: 1.5;
}
.hint code {
font-size: 12px;
background: #f1f5f9;
padding: 1px 4px;
border-radius: 4px;
}
.save-actions {
margin-top: 8px;
}
.save-actions-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
}
.test-tools {
margin-top: 8px;
padding: 16px 18px;
max-width: 640px;
background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%);
border: 1px solid #e2e8f0;
border-radius: 12px;
}
.test-tools-title {
margin: 0 0 10px;
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.test-tools-hint {
margin: 0 0 14px;
}
.test-tools-actions {
margin-bottom: 14px;
}
.manual-test-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 12px;
}
.manual-test-label {
font-size: 14px;
color: #334155;
white-space: nowrap;
}
.force-label {
margin-left: 4px;
}
.manual-test-input {
width: 160px;
max-width: 100%;
}
.manual-test-btn {
margin-left: auto;
}
@media (max-width: 640px) {
.manual-test-btn {
margin-left: 0;
width: 100%;
}
}
</style>

View File

@@ -134,6 +134,10 @@
</div>
</div>
<div v-if="activeTab === 'pushhook'" class="tab-content">
<PushHookConfigPanel api-prefix="/admin" />
</div>
<div v-if="activeTab === 'finance'" class="embed-wrap">
<Finance embedded />
</div>
@@ -150,8 +154,9 @@ import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import { getAdminRole } from '@/utils/authStorage'
import Finance from './Finance.vue'
import PushHookConfigPanel from './PushHookConfigPanel.vue'
const TAB_IDS = ['account', 'features', 'finance'] as const
const TAB_IDS = ['account', 'pushhook', 'features', 'finance'] as const
type TabId = (typeof TAB_IDS)[number]
function isTabId(s: string): s is TabId {
@@ -172,7 +177,10 @@ const canConfigureCunkebaoKeys = () => {
}
const tabs = computed(() => {
const rows: { label: string; value: TabId }[] = [{ label: '账号设置', value: 'account' }]
const rows: { label: string; value: TabId }[] = [
{ label: '账号设置', value: 'account' },
{ label: '出站推送', value: 'pushhook' }
]
if (isEnterpriseAdmin() || canConfigureCunkebaoKeys()) {
rows.push({ label: '功能开关', value: 'features' })
}

View File

@@ -335,6 +335,10 @@
</el-card>
</div>
<div v-if="activeTab === 'pushhook'" class="tab-content">
<PushHookConfigPanel api-prefix="/superadmin" />
</div>
<!-- 账户安全 -->
<div v-if="activeTab === 'security'" class="tab-content">
<el-card shadow="never" class="settings-card">
@@ -417,18 +421,21 @@ import {
Document,
ChatDotRound,
Postcard,
Reading
Reading,
Connection
} from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { request } from '@/utils/request'
import PosterEditor from './PosterEditor.vue'
import Questions from './Questions.vue'
import PushHookConfigPanel from '../admin/PushHookConfigPanel.vue'
const TAB_IDS = [
'review',
'system',
'prompts',
'poster',
'pushhook',
'security',
'questions'
] as const
@@ -449,6 +456,7 @@ const tabs: { label: string; value: TabId; icon: any }[] = [
{ label: '系统配置', value: 'system', icon: Setting },
{ label: '提示词配置', value: 'prompts', icon: ChatDotRound },
{ label: '海报配置', value: 'poster', icon: Postcard },
{ label: '出站推送', value: 'pushhook', icon: Connection },
{ label: '账户安全', value: 'security', icon: Lock },
{ label: '题库管理', value: 'questions', icon: Reading }
]