wip: baseline changes before channel-conversation-task boundary implementation

This commit is contained in:
2026-07-08 16:23:09 +08:00
parent 89c9e6a066
commit 1298a558e8
139 changed files with 11781 additions and 666 deletions

32
.env.example Normal file
View File

@@ -0,0 +1,32 @@
# MySQL
MYSQL_ROOT_PASSWORD=root
MYSQL_DATABASE=sino_mci
MYSQL_USER=root
MYSQL_PORT=3306
# Redis
REDIS_PORT=6379
# RabbitMQ
RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest
RABBITMQ_AMQP_PORT=5672
RABBITMQ_MGMT_PORT=15672
# SeaweedFS
SEAWEEDFS_MASTER_PORT=9333
SEAWEEDFS_S3_PORT=8888
# Application ports
MCI_SERVER_PORT=8080
ADMIN_WEB_PORT=80
CHANNEL_SERVICE_PORT=8000
# JVM options for mci-server
JAVA_OPTS=-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0
# Spring profile
SPRING_PROFILES_ACTIVE=dev
# OpenAI API key (optional)
OPENAI_API_KEY=your-openai-api-key-here

5
.gitignore vendored
View File

@@ -15,6 +15,11 @@ __pycache__/
venv/
.env
# Local runtime data
data/
*-data/
logs/
# Node
node_modules/
dist/

View File

@@ -6,20 +6,70 @@ export function login(data: { tenantCode: string; username: string; password: st
})
}
export function createTenant(data: { tenantCode: string; tenantName: string }) {
export function createTenant(data: {
tenantCode: string
tenantName: string
inboundWebhookUrl?: string
initAdminUsername?: string
initAdminPassword?: string
}) {
return client.post('/api/v1/account/tenant', data)
}
export function updateTenant(
id: number,
data: { tenantName: string; inboundWebhookUrl?: string; status?: number }
) {
return client.put(`/api/v1/account/tenant/${id}`, data)
}
export function deleteTenant(id: number) {
return client.delete(`/api/v1/account/tenant/${id}`)
}
export function listTenants() {
return client.get('/api/v1/account/tenant/list')
}
export function createUser(data: { username: string; password: string; realName?: string; roleCodes?: string[] }) {
return client.post('/api/v1/account/user', data)
export function listRoles() {
return client.get('/api/v1/account/role/list')
}
export function listUsers() {
return client.get('/api/v1/account/user/list')
export function createUser(
tenantCode: string,
data: {
username: string
password: string
realName?: string
phone?: string
roleCodes?: string[]
}
) {
return client.post('/api/v1/account/user', data, {
headers: { 'X-Tenant-Code': tenantCode },
})
}
export function updateUser(
id: number,
data: {
realName: string
phone?: string
status?: number
roleCodes?: string[]
}
) {
return client.put(`/api/v1/account/user/${id}`, data)
}
export function deleteUser(id: number) {
return client.delete(`/api/v1/account/user/${id}`)
}
export function listUsers(tenantCode?: string) {
return client.get('/api/v1/account/user/list', {
params: tenantCode ? { tenantCode } : {},
})
}
export function me() {

View File

@@ -1,24 +1,6 @@
import client from './client'
export function createChannelSubject(data: any) {
return client.post('/api/v1/channel-subject', data)
}
export function listChannelSubjects() {
return client.get('/api/v1/channel-subject/list')
}
export function createChannelAccount(data: any) {
return client.post('/api/v1/channel-account', data)
}
export function listChannelAccounts() {
return client.get('/api/v1/channel-account/list')
}
export function initChannelAccount(data: any) {
return client.post('/api/v1/channel-account/init', data)
}
export * from './channelSubject'
export * from './channelAccount'
export function createConversation(data: any) {
return client.post('/api/v1/conversation', data)
@@ -28,6 +10,14 @@ export function listConversations() {
return client.get('/api/v1/conversation/list')
}
export function updateConversation(id: number, data: any) {
return client.put(`/api/v1/conversation/${id}`, data)
}
export function deleteConversation(id: number) {
return client.delete(`/api/v1/conversation/${id}`)
}
export function bindConversationAccount(conversationId: number, data: any) {
return client.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
}

View File

@@ -0,0 +1,33 @@
import client from './client'
export function createChannelAccount(data: any) {
return client.post('/api/v1/channel-account', data)
}
export function listChannelAccounts() {
return client.get('/api/v1/channel-account/list')
}
export function updateChannelAccount(id: number, data: any) {
return client.put(`/api/v1/channel-account/${id}`, data)
}
export function deleteChannelAccount(id: number) {
return client.delete(`/api/v1/channel-account/${id}`)
}
export function updateChannelAccountStatus(id: number, data: any) {
return client.patch(`/api/v1/channel-account/${id}/status`, data)
}
export function getChannelAccountStatus(id: number) {
return client.get(`/api/v1/channel-account/${id}/status`)
}
export function initChannelAccount(data: any) {
return client.post('/api/v1/channel-account/init', data)
}
export function syncChannelAccountConversations(id: number) {
return client.post(`/api/v1/channel-account/${id}/sync-conversations`)
}

View File

@@ -0,0 +1,21 @@
import client from './client'
export function createChannelSubject(data: any) {
return client.post('/api/v1/channel-subject', data)
}
export function listChannelSubjects() {
return client.get('/api/v1/channel-subject/list')
}
export function updateChannelSubject(id: number, data: any) {
return client.put(`/api/v1/channel-subject/${id}`, data)
}
export function deleteChannelSubject(id: number) {
return client.delete(`/api/v1/channel-subject/${id}`)
}
export function updateChannelSubjectStatus(id: number, data: any) {
return client.patch(`/api/v1/channel-subject/${id}/status`, data)
}

View File

@@ -21,7 +21,13 @@ client.interceptors.request.use((config) => {
})
client.interceptors.response.use(
(response) => response.data,
(response) => {
const data = response.data
if (data && typeof data === 'object' && 'code' in data && data.code !== 0) {
return Promise.reject(new Error(data.message || '请求失败'))
}
return data
},
(error) => {
if (error.response?.status === 401) {
localStorage.clear()

View File

@@ -8,10 +8,42 @@ export function listPersons() {
return client.get('/api/v1/person/list')
}
export function listTags() {
return client.get('/api/v1/tag/list')
export function updatePerson(id: number, data: any) {
return client.put(`/api/v1/person/${id}`, data)
}
export function deletePerson(id: number) {
return client.delete(`/api/v1/person/${id}`)
}
export function listPersonIdentities(personId: number) {
return client.get(`/api/v1/person/${personId}/identities`)
}
export function createPersonIdentity(personId: number, data: any) {
return client.post(`/api/v1/person/${personId}/identities`, data)
}
export function deletePersonIdentity(personId: number, identityId: number) {
return client.delete(`/api/v1/person/${personId}/identities/${identityId}`)
}
export function bindPersonTags(personId: number, data: any) {
return client.post(`/api/v1/person/${personId}/bind-tags`, data)
}
export function createTag(data: any) {
return client.post('/api/v1/tag', data)
}
export function listTags() {
return client.get('/api/v1/tag/list')
}
export function updateTag(id: number, data: any) {
return client.put(`/api/v1/tag/${id}`, data)
}
export function deleteTag(id: number) {
return client.delete(`/api/v1/tag/${id}`)
}

View File

@@ -0,0 +1,75 @@
import client from './client'
export interface FlowNodeConfig {
[key: string]: any
}
export interface FlowNode {
id: string
type: string
config: FlowNodeConfig
}
export interface FlowEdge {
source: string
target: string
label?: string
}
export interface FlowDefinition {
nodes: FlowNode[]
edges: FlowEdge[]
}
export interface FlowRecord {
id: number
flowCode: string
flowName: string
flowType: number
status: number
definitionJson: string | FlowDefinition
version?: number
createTime?: string
updateTime?: string
}
export interface SaveFlowPayload {
flowCode: string
flowName: string
flowType?: string
definitionJson: FlowDefinition
}
export function listFlows() {
return client.get('/api/v1/inbound/flow/list')
}
export function getFlow(flowCode: string) {
return client.get(`/api/v1/inbound/flow/${flowCode}`)
}
export function saveFlow(data: SaveFlowPayload) {
return client.post('/api/v1/inbound/flow', {
...data,
flowType: data.flowType || 'inbound',
})
}
export function updateFlow(flowCode: string, data: SaveFlowPayload) {
return client.post(`/api/v1/inbound/flow/${flowCode}`, {
...data,
flowType: data.flowType || 'inbound',
})
}
export function publishFlow(flowCode: string) {
return client.post(`/api/v1/inbound/flow/${flowCode}/publish`)
}
export function offlineFlow(flowCode: string) {
return client.post(`/api/v1/inbound/flow/${flowCode}/offline`)
}
export function deleteFlow(flowCode: string) {
return client.delete(`/api/v1/inbound/flow/${flowCode}`)
}

View File

@@ -0,0 +1,13 @@
import client from './client'
export function getInboundWebhookConfig() {
return client.get('/api/v1/account/tenant/webhook')
}
export function updateInboundWebhookConfig(data: any) {
return client.post('/api/v1/account/tenant/webhook', data)
}
export function testInboundWebhook(data: { url: string }) {
return client.post('/api/v1/account/tenant/webhook/test', data)
}

View File

@@ -0,0 +1,75 @@
import client from './client'
export interface FlowNodeConfig {
[key: string]: any
}
export interface FlowNode {
id: string
type: string
config: FlowNodeConfig
}
export interface FlowEdge {
source: string
target: string
label?: string
}
export interface FlowDefinition {
nodes: FlowNode[]
edges: FlowEdge[]
}
export interface FlowRecord {
id: number
flowCode: string
flowName: string
flowType: number
status: number
definitionJson: string | FlowDefinition
version?: number
createTime?: string
updateTime?: string
}
export interface SaveFlowPayload {
flowCode: string
flowName: string
flowType?: string
definitionJson: FlowDefinition
}
export function listFlows() {
return client.get('/api/v1/flow/list')
}
export function getFlow(flowCode: string) {
return client.get(`/api/v1/flow/${flowCode}`)
}
export function saveFlow(data: SaveFlowPayload) {
return client.post('/api/v1/flow', {
...data,
flowType: data.flowType || 'ivr',
})
}
export function updateFlow(flowCode: string, data: SaveFlowPayload) {
return client.post(`/api/v1/flow/${flowCode}`, {
...data,
flowType: data.flowType || 'ivr',
})
}
export function publishFlow(flowCode: string) {
return client.post(`/api/v1/flow/${flowCode}/publish`)
}
export function offlineFlow(flowCode: string) {
return client.post(`/api/v1/flow/${flowCode}/offline`)
}
export function deleteFlow(flowCode: string) {
return client.delete(`/api/v1/flow/${flowCode}`)
}

View File

@@ -0,0 +1,25 @@
import client from './client'
export function listPolicyGroups() {
return client.get('/api/v1/policy-group/list')
}
export function getPolicyGroup(groupCode: string) {
return client.get(`/api/v1/policy-group/${groupCode}`)
}
export function createPolicyGroup(data: any) {
return client.post('/api/v1/policy-group', data)
}
export function updatePolicyGroup(groupCode: string, data: any) {
return client.post(`/api/v1/policy-group/${groupCode}`, data)
}
export function updatePolicyGroupStatus(groupCode: string, status: number) {
return client.post(`/api/v1/policy-group/${groupCode}/status`, { status })
}
export function deletePolicyGroup(groupCode: string) {
return client.post(`/api/v1/policy-group/${groupCode}/delete`)
}

View File

@@ -0,0 +1,31 @@
import client from './client'
export interface QueryRecordsParams {
taskId?: string
start?: string
end?: string
channelType?: string
status?: number | string
page?: number
size?: number
}
export function queryRecords(params: QueryRecordsParams) {
return client.get('/api/v1/send/records', { params })
}
export function getTaskStatus(taskId: string) {
return client.get(`/api/v1/send/${taskId}/status`)
}
export function cancelTask(taskId: string) {
return client.post(`/api/v1/send/${taskId}/cancel`)
}
export function testSend(data: any) {
return client.post('/api/v1/send/test', data)
}
export function getStatistics(start: string, end: string) {
return client.get('/api/v1/send/statistics', { params: { start, end } })
}

View File

@@ -27,12 +27,21 @@
<el-menu-item index="/inbound-flow">
<span>入站流程</span>
</el-menu-item>
<el-menu-item index="/ivr-flow">
<span>IVR 流程</span>
</el-menu-item>
<el-menu-item index="/flow-trace">
<span>流程追踪</span>
</el-menu-item>
<el-menu-item index="/message-test">
<span>消息测试</span>
</el-menu-item>
<el-menu-item index="/inbound-webhook">
<span>入站 Webhook</span>
</el-menu-item>
<el-menu-item index="/send-policy">
<span>发送策略</span>
</el-menu-item>
<el-menu-item v-if="authStore.roleCodes.includes('platform_admin')" index="/tenant-management">
<span>租户管理</span>
</el-menu-item>

View File

@@ -8,10 +8,13 @@ import Person from '../views/Person.vue'
import Tag from '../views/Tag.vue'
import SendRecord from '../views/SendRecord.vue'
import InboundFlow from '../views/InboundFlow.vue'
import IvrFlow from '../views/IvrFlow.vue'
import FlowTrace from '../views/FlowTrace.vue'
import MessageTest from '../views/MessageTest.vue'
import TenantManagement from '../views/TenantManagement.vue'
import UserManagement from '../views/UserManagement.vue'
import InboundWebhook from '../views/InboundWebhook.vue'
import SendPolicy from '../views/SendPolicy.vue'
import Login from '../views/Login.vue'
import { useAuthStore } from '../stores/auth'
@@ -34,8 +37,11 @@ const routes = [
{ path: 'tag', component: Tag, meta: { title: '标签' } },
{ path: 'send-record', component: SendRecord, meta: { title: '发送记录' } },
{ path: 'inbound-flow', component: InboundFlow, meta: { title: '入站流程' } },
{ path: 'ivr-flow', component: IvrFlow, meta: { title: 'IVR 流程' } },
{ path: 'flow-trace', component: FlowTrace, meta: { title: '流程追踪' } },
{ path: 'message-test', component: MessageTest, meta: { title: '消息测试' } },
{ path: 'inbound-webhook', component: InboundWebhook, meta: { title: '入站 Webhook' } },
{ path: 'send-policy', component: SendPolicy, meta: { title: '发送策略' } },
{ path: 'tenant-management', component: TenantManagement, meta: { title: '租户管理' } },
{ path: 'user-management', component: UserManagement, meta: { title: '用户管理' } },
],

View File

@@ -1,11 +1,21 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
function parseJson(value: string | null): string[] {
if (!value) return []
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
export const useAuthStore = defineStore('auth', () => {
const token = ref(localStorage.getItem('mci-token') || '')
const tenantCode = ref(localStorage.getItem('mci-tenant-code') || '')
const username = ref('')
const roleCodes = ref<string[]>([])
const username = ref(localStorage.getItem('mci-username') || '')
const roleCodes = ref<string[]>(parseJson(localStorage.getItem('mci-role-codes')))
const isLoggedIn = computed(() => !!token.value)
@@ -16,6 +26,8 @@ export const useAuthStore = defineStore('auth', () => {
roleCodes.value = roles
localStorage.setItem('mci-token', t)
localStorage.setItem('mci-tenant-code', tc)
localStorage.setItem('mci-username', user)
localStorage.setItem('mci-role-codes', JSON.stringify(roles))
}
function clearAuth() {
@@ -25,6 +37,8 @@ export const useAuthStore = defineStore('auth', () => {
roleCodes.value = []
localStorage.removeItem('mci-token')
localStorage.removeItem('mci-tenant-code')
localStorage.removeItem('mci-username')
localStorage.removeItem('mci-role-codes')
}
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }

View File

@@ -1,18 +1,76 @@
<template>
<div>
<el-form :model="form" inline>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道账号</el-button>
</div>
<el-table :data="accounts" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="accountType" label="类型" width="140">
<template #default="{ row }">
{{ accountTypeLabel(row.accountType) }}
</template>
</el-table-column>
<el-table-column prop="channelType" label="渠道" width="100">
<template #default="{ row }">
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="accountId" label="账号ID" />
<el-table-column prop="accountName" label="名称" />
<el-table-column prop="loginStatus" label="登录状态" width="100">
<template #default="{ row }">
<el-tag :type="row.loginStatus === 1 ? 'success' : 'info'">
{{ row.loginStatus === 1 ? '已登录' : '未登录' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="在线状态" width="160">
<template #default="{ row }">
<span class="status-dot" :class="row.onlineStatus === 1 ? 'online' : 'offline'" />
{{ row.onlineStatus === 1 ? '在线' : '离线' }}
<div v-if="row.lastHeartbeat" class="heartbeat">{{ row.lastHeartbeat }}</div>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="420">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 8px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<el-button size="small" type="primary" @click="handleInit(row)">初始化</el-button>
<el-button size="small" @click="handleSync(row)">同步会话</el-button>
<el-button size="small" @click="handleRefreshStatus(row)">刷新状态</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑渠道账号' : '创建渠道账号'" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="账号类型">
<el-select v-model="form.accountType" placeholder="类型">
<el-select v-model="dialogForm.accountType" placeholder="类型">
<el-option label="企微内部账号" :value="1" />
<el-option label="企微应用" :value="2" />
<el-option label="pywechat 个人号" :value="3" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="form.channelType" placeholder="wecom" />
<el-input v-model="dialogForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="所属主体">
<el-select v-model="form.subjectId" placeholder="选择主体">
<el-select v-model="dialogForm.subjectId" placeholder="选择主体" clearable>
<el-option
v-for="s in subjects"
:key="s.id"
@@ -22,47 +80,113 @@
</el-select>
</el-form-item>
<el-form-item label="账号ID">
<el-input v-model="form.accountId" placeholder="账号唯一标识" />
<el-input v-model="dialogForm.accountId" placeholder="账号唯一标识" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.accountName" placeholder="名称" />
<el-input v-model="dialogForm.accountName" placeholder="名称" />
</el-form-item>
<el-form-item label="会话存档">
<el-switch v-model="form.sessionArchiveEnabled" />
<el-switch v-model="dialogForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
<el-form-item label="Server Host">
<el-input v-model="dialogForm.serverHost" placeholder="服务地址" />
</el-form-item>
<el-form-item label="扩展信息">
<el-input v-model="dialogForm.extInfo" type="textarea" :rows="3" placeholder="JSON 扩展信息" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<el-table :data="accounts" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="accountType" label="类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="accountId" label="账号ID" />
<el-table-column prop="accountName" label="名称" />
<el-table-column prop="loginStatus" label="登录状态" />
<el-table-column prop="onlineStatus" label="在线状态" />
</el-table>
<el-dialog v-model="initDialogVisible" title="初始化结果" width="480px">
<div v-if="initResult">
<el-result
:icon="initResult.success ? 'success' : 'warning'"
:title="initResult.success ? '初始化成功' : '初始化失败'"
:sub-title="initResult.message"
/>
<div v-if="initResult.qrCodeUrl" class="qr-code">
<p>请使用微信扫描二维码登录</p>
<el-image :src="initResult.qrCodeUrl" fit="contain" style="width: 200px; height: 200px" />
</div>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createChannelAccount, listChannelAccounts, listChannelSubjects } from '@/api/channel'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listChannelSubjects } from '@/api/channelSubject'
import {
createChannelAccount,
listChannelAccounts,
updateChannelAccount,
deleteChannelAccount,
updateChannelAccountStatus,
getChannelAccountStatus,
initChannelAccount,
syncChannelAccountConversations,
} from '@/api/channelAccount'
const form = reactive({
const accounts = ref<any[]>([])
const subjects = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
accountType: 1,
channelType: 'wecom',
subjectId: undefined as number | undefined,
accountId: '',
accountName: '',
sessionArchiveEnabled: false,
sessionArchiveEnabled: 0,
serverHost: '',
extInfo: '',
})
const accounts = ref<any[]>([])
const subjects = ref<any[]>([])
const initDialogVisible = ref(false)
const initResult = ref<any>(null)
function resetDialogForm() {
dialogForm.accountType = 1
dialogForm.channelType = 'wecom'
dialogForm.subjectId = undefined
dialogForm.accountId = ''
dialogForm.accountName = ''
dialogForm.sessionArchiveEnabled = 0
dialogForm.serverHost = ''
dialogForm.extInfo = ''
}
function fillDialogForm(row: any) {
dialogForm.accountType = row.accountType ?? 1
dialogForm.channelType = row.channelType ?? 'wecom'
dialogForm.subjectId = row.subjectId
dialogForm.accountId = row.accountId ?? ''
dialogForm.accountName = row.accountName ?? ''
dialogForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
dialogForm.serverHost = row.serverHost ?? ''
dialogForm.extInfo = row.extInfo ?? ''
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
async function loadAccounts() {
try {
@@ -82,18 +206,138 @@ async function loadSubjects() {
}
}
async function handleCreate() {
async function handleSave() {
try {
const res: any = await createChannelAccount(form)
accounts.value.unshift(res.data)
if (isEdit.value && editingId.value != null) {
await updateChannelAccount(editingId.value, dialogForm)
ElMessage.success('更新成功')
} else {
await createChannelAccount(dialogForm)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该渠道账号?', '提示', { type: 'warning' })
await deleteChannelAccount(row.id)
ElMessage.success('删除成功')
await loadAccounts()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateChannelAccountStatus(row.id, { status: val })
ElMessage.success('状态更新成功')
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
await loadAccounts()
}
}
async function handleInit(row: any) {
try {
const res: any = await initChannelAccount({
channelType: row.channelType,
accountType: row.accountType,
accountId: row.id,
subjectId: row.subjectId,
config: {},
})
initResult.value = res.data || null
initDialogVisible.value = true
if (initResult.value?.success) {
await loadAccounts()
}
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleSync(row: any) {
try {
const res: any = await syncChannelAccountConversations(row.id)
ElMessage.success(`同步完成,共 ${res.data?.syncedCount ?? 0} 条会话`)
await loadAccounts()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleRefreshStatus(row: any) {
try {
const res: any = await getChannelAccountStatus(row.id)
const idx = accounts.value.findIndex((a) => a.id === row.id)
if (idx >= 0) {
accounts.value[idx] = { ...accounts.value[idx], ...res.data }
}
ElMessage.success('状态已刷新')
} catch (e: any) {
ElMessage.error(e.message)
}
}
function accountTypeLabel(type: number) {
const map: Record<number, string> = {
1: '企微内部账号',
2: '企微应用',
3: 'pywechat 个人号',
}
return map[type] || type
}
function channelLabel(type: string) {
const map: Record<string, string> = {
wecom: '企微',
wechat_personal: '个微',
dingtalk: '钉钉',
feishu: '飞书',
ivr: 'IVR',
}
return map[type] || type
}
onMounted(() => {
loadAccounts()
loadSubjects()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.status-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
margin-right: 6px;
}
.status-dot.online {
background-color: #67c23a;
}
.status-dot.offline {
background-color: #909399;
}
.heartbeat {
font-size: 12px;
color: #909399;
margin-top: 4px;
}
.qr-code {
text-align: center;
margin-top: 12px;
}
</style>

View File

@@ -1,59 +1,173 @@
<template>
<div>
<el-form :model="form" inline>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建渠道主体</el-button>
</div>
<el-table :data="subjects" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道">
<template #default="{ row }">
{{ channelLabel(row.channelType) }}
</template>
</el-table-column>
<el-table-column prop="subjectCode" label="编码" />
<el-table-column prop="subjectName" label="名称" />
<el-table-column prop="corpId" label="CorpID" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="340">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
<el-button size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑渠道主体' : '创建渠道主体'" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="渠道">
<el-select v-model="form.channelType" placeholder="选择渠道">
<el-select v-model="dialogForm.channelType" placeholder="选择渠道">
<el-option label="企微" value="wecom" />
<el-option label="钉钉" value="dingtalk" />
<el-option label="飞书" value="feishu" />
</el-select>
</el-form-item>
<el-form-item label="主体编码">
<el-input v-model="form.subjectCode" placeholder="主体编码" />
<el-input v-model="dialogForm.subjectCode" placeholder="主体编码" />
</el-form-item>
<el-form-item label="主体名称">
<el-input v-model="form.subjectName" placeholder="主体名称" />
<el-input v-model="dialogForm.subjectName" placeholder="主体名称" />
</el-form-item>
<el-form-item label="CorpID">
<el-input v-model="form.corpId" placeholder="CorpID" />
<el-input v-model="dialogForm.corpId" placeholder="CorpID" />
</el-form-item>
<el-form-item label="CorpSecret">
<el-input v-model="dialogForm.corpSecret" placeholder="CorpSecret" />
</el-form-item>
<el-form-item label="AgentID">
<el-input v-model="form.agentId" placeholder="AgentID" />
<el-input v-model="dialogForm.agentId" placeholder="AgentID" />
</el-form-item>
<el-form-item label="AgentSecret">
<el-input v-model="form.agentSecret" placeholder="AgentSecret" />
<el-input v-model="dialogForm.agentSecret" placeholder="AgentSecret" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
<el-form-item label="会话存档">
<el-switch v-model="dialogForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
</el-form-item>
<el-form-item label="会话存档私钥">
<el-input v-model="dialogForm.sessionArchivePrivateKey" type="textarea" :rows="3" placeholder="会话存档私钥" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<el-table :data="subjects" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="subjectCode" label="编码" />
<el-table-column prop="subjectName" label="名称" />
<el-table-column prop="corpId" label="CorpID" />
<el-table-column prop="status" label="状态" />
</el-table>
<el-drawer v-model="drawerVisible" title="渠道主体详情" size="420px">
<el-descriptions :column="1" border>
<el-descriptions-item label="ID">{{ currentSubject.id }}</el-descriptions-item>
<el-descriptions-item label="渠道">{{ channelLabel(currentSubject.channelType) }}</el-descriptions-item>
<el-descriptions-item label="主体编码">{{ currentSubject.subjectCode }}</el-descriptions-item>
<el-descriptions-item label="主体名称">{{ currentSubject.subjectName }}</el-descriptions-item>
<el-descriptions-item label="CorpID">{{ mask(currentSubject.corpId) }}</el-descriptions-item>
<el-descriptions-item label="CorpSecret">{{ mask(currentSubject.corpSecret) }}</el-descriptions-item>
<el-descriptions-item label="AgentID">{{ mask(currentSubject.agentId) }}</el-descriptions-item>
<el-descriptions-item label="AgentSecret">{{ mask(currentSubject.agentSecret) }}</el-descriptions-item>
<el-descriptions-item label="会话存档">{{ currentSubject.sessionArchiveEnabled === 1 ? '开启' : '关闭' }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ currentSubject.status === 1 ? '启用' : '禁用' }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ currentSubject.createTime }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createChannelSubject, listChannelSubjects } from '@/api/channel'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
createChannelSubject,
listChannelSubjects,
updateChannelSubject,
deleteChannelSubject,
updateChannelSubjectStatus,
} from '@/api/channelSubject'
const form = reactive({
const subjects = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
channelType: 'wecom',
subjectCode: '',
subjectName: '',
corpId: '',
corpSecret: '',
agentId: '',
agentSecret: '',
sessionArchiveEnabled: 0,
sessionArchivePrivateKey: '',
})
const subjects = ref<any[]>([])
const drawerVisible = ref(false)
const currentSubject = ref<any>({})
function resetDialogForm() {
dialogForm.channelType = 'wecom'
dialogForm.subjectCode = ''
dialogForm.subjectName = ''
dialogForm.corpId = ''
dialogForm.corpSecret = ''
dialogForm.agentId = ''
dialogForm.agentSecret = ''
dialogForm.sessionArchiveEnabled = 0
dialogForm.sessionArchivePrivateKey = ''
}
function fillDialogForm(row: any) {
dialogForm.channelType = row.channelType ?? 'wecom'
dialogForm.subjectCode = row.subjectCode ?? ''
dialogForm.subjectName = row.subjectName ?? ''
dialogForm.corpId = row.corpId ?? ''
dialogForm.corpSecret = row.corpSecret ?? ''
dialogForm.agentId = row.agentId ?? ''
dialogForm.agentSecret = row.agentSecret ?? ''
dialogForm.sessionArchiveEnabled = row.sessionArchiveEnabled ?? 0
dialogForm.sessionArchivePrivateKey = row.sessionArchivePrivateKey ?? ''
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
function openDetail(row: any) {
currentSubject.value = { ...row }
drawerVisible.value = true
}
async function loadSubjects() {
try {
@@ -64,15 +178,66 @@ async function loadSubjects() {
}
}
async function handleCreate() {
async function handleSave() {
try {
const res: any = await createChannelSubject(form)
subjects.value.unshift(res.data)
if (isEdit.value && editingId.value != null) {
await updateChannelSubject(editingId.value, dialogForm)
ElMessage.success('更新成功')
} else {
await createChannelSubject(dialogForm)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadSubjects()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确认删除该渠道主体?', '提示', { type: 'warning' })
await deleteChannelSubject(row.id)
ElMessage.success('删除成功')
await loadSubjects()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateChannelSubjectStatus(row.id, { status: val })
ElMessage.success('状态更新成功')
await loadSubjects()
} catch (e: any) {
ElMessage.error(e.message)
await loadSubjects()
}
}
function channelLabel(type: string) {
const map: Record<string, string> = {
wecom: '企微',
dingtalk: '钉钉',
feishu: '飞书',
}
return map[type] || type
}
function mask(value: string) {
if (!value) return '-'
if (value.length <= 4) return '****'
return value.slice(0, 2) + '****' + value.slice(-2)
}
onMounted(loadSubjects)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,23 +1,71 @@
<template>
<div>
<el-form :model="form" inline>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建会话</el-button>
</div>
<el-table :data="conversations" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="conversationType" label="类型" width="100">
<template #default="{ row }">
{{ formatConversationType(row.conversationType) }}
</template>
</el-table-column>
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelConversationId" label="渠道会话ID" show-overflow-tooltip />
<el-table-column prop="conversationName" label="名称" />
<el-table-column prop="ownerAccountId" label="负责人">
<template #default="{ row }">
{{ formatAccountName(row.ownerAccountId) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
<div class="op-row" style="margin-top: 6px;">
<el-button link type="primary" size="small" @click="openBindAccount(row)">绑定账号</el-button>
<el-button link type="primary" size="small" @click="openBindTags(row)">绑定标签</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑会话' : '创建会话'" width="600px">
<el-form :model="dialogForm" label-width="120px">
<el-form-item label="会话类型">
<el-select v-model="form.conversationType" placeholder="类型">
<el-select v-model="dialogForm.conversationType" placeholder="类型" style="width: 100%">
<el-option label="单聊" :value="1" />
<el-option label="群聊" :value="2" />
</el-select>
</el-form-item>
<el-form-item label="渠道">
<el-input v-model="form.channelType" placeholder="wecom" />
<el-input v-model="dialogForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="渠道会话ID">
<el-input v-model="form.channelConversationId" placeholder="roomid_xxx" />
<el-input v-model="dialogForm.channelConversationId" :disabled="isEdit" placeholder="roomid_xxx" />
</el-form-item>
<el-form-item label="名称">
<el-input v-model="form.conversationName" placeholder="群名称" />
<el-input v-model="dialogForm.conversationName" placeholder="群名称" />
</el-form-item>
<el-form-item label="负责人">
<el-select v-model="form.ownerAccountId" placeholder="选择负责人账号">
<el-select v-model="dialogForm.ownerAccountId" placeholder="选择负责人账号" style="width: 100%">
<el-option
v-for="a in accounts"
:key="a.id"
@@ -26,29 +74,26 @@
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<el-table :data="conversations" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="conversationType" label="类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelConversationId" label="渠道会话ID" />
<el-table-column prop="conversationName" label="名称" />
<el-table-column prop="status" label="状态" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="primary" @click="openBind(row)">绑定账号</el-button>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<el-dialog v-model="bindVisible" title="绑定账号" width="500px">
<el-form :model="bindForm" label-width="100px">
<el-dialog v-model="bindAccountVisible" title="绑定账号" width="500px">
<el-form :model="bindAccountForm" label-width="100px">
<el-form-item label="账号">
<el-select v-model="bindForm.accountId" placeholder="选择账号">
<el-select v-model="bindAccountForm.accountId" placeholder="选择账号" style="width: 100%">
<el-option
v-for="a in accounts"
:key="a.id"
@@ -58,7 +103,7 @@
</el-select>
</el-form-item>
<el-form-item label="绑定类型">
<el-select v-model="bindForm.bindingType" placeholder="选择绑定类型">
<el-select v-model="bindAccountForm.bindingType" placeholder="选择绑定类型" style="width: 100%">
<el-option label="发送主号" :value="1" />
<el-option label="发送备用号" :value="2" />
<el-option label="接收主号" :value="3" />
@@ -67,40 +112,114 @@
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindVisible = false">取消</el-button>
<el-button type="primary" @click="handleBind">确认</el-button>
<el-button @click="bindAccountVisible = false">取消</el-button>
<el-button type="primary" @click="handleBindAccount">确认</el-button>
</template>
</el-dialog>
<el-dialog v-model="bindTagsVisible" title="绑定标签" width="500px">
<el-form :model="bindTagsForm" label-width="80px">
<el-form-item label="标签">
<el-select v-model="bindTagsForm.tagIds" multiple placeholder="选择标签" style="width: 100%">
<el-option
v-for="t in tags"
:key="t.id"
:label="t.tagName"
:value="t.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindTagsVisible = false">取消</el-button>
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindConversationAccount,
bindConversationTags,
createConversation,
deleteConversation,
listChannelAccounts,
listConversations,
updateConversation,
} from '@/api/channel'
import { listTags } from '@/api/crm'
const form = reactive({
const conversations = ref<any[]>([])
const accounts = ref<any[]>([])
const tags = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
conversationType: 2,
channelType: 'wecom',
channelConversationId: '',
conversationName: '',
ownerAccountId: undefined as number | undefined,
status: 1,
})
const conversations = ref<any[]>([])
const accounts = ref<any[]>([])
const bindVisible = ref(false)
const currentConversationId = ref<number | undefined>()
const bindForm = reactive({
accountId: undefined as number | undefined,
bindingType: 1,
function resetDialogForm() {
dialogForm.conversationType = 2
dialogForm.channelType = 'wecom'
dialogForm.channelConversationId = ''
dialogForm.conversationName = ''
dialogForm.ownerAccountId = undefined
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.conversationType = row.conversationType ?? 2
dialogForm.channelType = row.channelType ?? 'wecom'
dialogForm.channelConversationId = row.channelConversationId ?? ''
dialogForm.conversationName = row.conversationName ?? ''
dialogForm.ownerAccountId = row.ownerAccountId
dialogForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
const accountMap = computed(() => {
const map = new Map<number, string>()
for (const a of accounts.value) {
map.set(a.id, a.accountName)
}
return map
})
function formatAccountName(id: number | undefined) {
if (id == null) return '-'
return accountMap.value.get(id) || id
}
function formatConversationType(type: number | undefined) {
if (type === 1) return '单聊'
if (type === 2) return '群聊'
return type ?? '-'
}
async function loadConversations() {
try {
const res: any = await listConversations()
@@ -119,37 +238,136 @@ async function loadAccounts() {
}
}
async function handleCreate() {
async function loadTags() {
try {
const res: any = await createConversation(form)
conversations.value.unshift(res.data)
ElMessage.success('创建成功')
const res: any = await listTags()
tags.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function openBind(row: any) {
currentConversationId.value = row.id
bindForm.accountId = undefined
bindForm.bindingType = 1
bindVisible.value = true
async function handleSave() {
if (!dialogForm.channelType || !dialogForm.channelConversationId) {
ElMessage.warning('请填写渠道和渠道会话ID')
return
}
try {
const payload = {
conversationType: dialogForm.conversationType,
channelType: dialogForm.channelType,
channelConversationId: dialogForm.channelConversationId,
conversationName: dialogForm.conversationName,
ownerAccountId: dialogForm.ownerAccountId,
status: dialogForm.status,
}
if (isEdit.value && editingId.value != null) {
await updateConversation(editingId.value, payload)
ElMessage.success('更新成功')
} else {
const res: any = await createConversation(payload)
conversations.value.unshift(res.data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleBind() {
async function handleStatusChange(row: any, val: any) {
try {
await updateConversation(row.id, {
conversationType: row.conversationType,
channelType: row.channelType,
channelConversationId: row.channelConversationId,
conversationName: row.conversationName,
ownerAccountId: row.ownerAccountId,
status: val,
})
ElMessage.success('状态更新成功')
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
await loadConversations()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除会话「${row.conversationName || row.channelConversationId}」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteConversation(row.id)
ElMessage.success('删除成功')
await loadConversations()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
const bindAccountVisible = ref(false)
const currentConversationId = ref<number | undefined>()
const bindAccountForm = reactive({
accountId: undefined as number | undefined,
bindingType: 1,
})
function openBindAccount(row: any) {
currentConversationId.value = row.id
bindAccountForm.accountId = undefined
bindAccountForm.bindingType = 1
bindAccountVisible.value = true
}
async function handleBindAccount() {
if (!currentConversationId.value) return
try {
await bindConversationAccount(currentConversationId.value, bindForm)
await bindConversationAccount(currentConversationId.value, bindAccountForm)
ElMessage.success('绑定成功')
bindVisible.value = false
bindAccountVisible.value = false
await loadConversations()
} catch (e: any) {
ElMessage.error(e.message)
}
}
const bindTagsVisible = ref(false)
const bindTagsForm = reactive({
tagIds: [] as number[],
})
function openBindTags(row: any) {
currentConversationId.value = row.id
bindTagsForm.tagIds = []
bindTagsVisible.value = true
}
async function handleBindTags() {
if (!currentConversationId.value) return
try {
await bindConversationTags(currentConversationId.value, { tagIds: bindTagsForm.tagIds })
ElMessage.success('绑定成功')
bindTagsVisible.value = false
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadConversations()
loadAccounts()
loadTags()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,13 +1,9 @@
<template>
<div>
<el-form inline>
<el-form-item label="事件ID">
<el-input v-model="eventId" placeholder="请输入 eventId" clearable />
</el-form-item>
<el-form-item>
<div class="toolbar">
<el-input v-model="eventId" placeholder="请输入 eventId" clearable style="width: 300px; margin-right: 12px" />
<el-button type="primary" @click="handleQuery">查询</el-button>
</el-form-item>
</el-form>
</div>
<el-timeline v-if="traces.length > 0">
<el-timeline-item
@@ -50,6 +46,9 @@ async function handleQuery() {
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
.error-msg {
color: #f56c6c;
}

View File

@@ -1,111 +1,734 @@
<template>
<div class="inbound-flow">
<el-form :model="form" label-width="100px">
<el-form-item label="流程编码">
<el-input v-model="form.flowCode" placeholder="flow_code" />
</el-form-item>
<el-form-item label="流程名称">
<el-input v-model="form.flowName" placeholder="流程名称" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">保存流程</el-button>
</el-form-item>
</el-form>
<div class="flow-layout">
<!-- Left sidebar: flow list + node palette -->
<div class="left-panel">
<div class="panel-section flow-list">
<div class="section-header">
<span class="section-title">流程列表</span>
<el-button type="primary" size="small" @click="createNewFlow">新建</el-button>
</div>
<el-table
:data="flows"
highlight-current-row
size="small"
@row-click="handleFlowRowClick"
>
<el-table-column prop="flowCode" label="编码" show-overflow-tooltip />
<el-table-column prop="flowName" label="名称" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="70">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small">
{{ statusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="55">
<template #default="scope">
<span>v{{ scope.row.version || 1 }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel-section node-palette">
<div class="section-header">
<span class="section-title">节点组件</span>
</div>
<div class="palette-grid">
<div
v-for="item in paletteNodes"
:key="item.type"
class="palette-item"
:class="`palette-${item.type}`"
draggable
@click="addNode(item.type)"
@dragstart="onDragStart($event, item.type)"
>
{{ item.label }}
</div>
</div>
</div>
</div>
<!-- Center canvas -->
<div class="canvas-area" @drop="onDrop" @dragover="onDragOver">
<div class="flow-toolbar">
<el-input
v-model="form.flowCode"
placeholder="流程编码"
style="width: 160px"
:disabled="isEdit"
/>
<el-input
v-model="form.flowName"
placeholder="流程名称"
style="width: 180px"
/>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button type="success" @click="handlePublish">发布</el-button>
<el-button type="warning" @click="handleOffline">下线</el-button>
<el-button type="danger" @click="handleDelete">删除</el-button>
<el-button @click="resetCanvas">清空画布</el-button>
</div>
<div class="flow-canvas">
<VueFlow v-model="elements" fit-view-on-init>
<VueFlow
v-model="elements"
fit-view-on-init
:default-zoom="1"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
>
<Background pattern-color="#aaa" :gap="16" />
<Controls />
<template #node-flowNode="nodeProps">
<div
class="flow-node"
:class="[
`node-${nodeProps.data.nodeType}`,
{ 'node-selected': nodeProps.selected },
]"
>
<Handle type="target" :position="Position.Top" />
<div class="node-icon">{{ nodeTypeIcon(nodeProps.data.nodeType) }}</div>
<div class="node-label">{{ nodeProps.label }}</div>
<Handle type="source" :position="Position.Bottom" />
</div>
</template>
</VueFlow>
</div>
</div>
<!-- Right config panel -->
<div v-if="selectedNode || selectedEdge" class="right-panel">
<template v-if="selectedNode">
<div class="section-header">
<span class="section-title">节点配置</span>
<span class="node-type-tag">{{ nodeTypeLabel(selectedNode.data.nodeType) }}</span>
</div>
<el-form label-width="80px" class="config-form">
<!-- receive -->
<template v-if="selectedNode.data.nodeType === 'receive'">
<el-form-item label="渠道类型">
<el-select v-model="selectedNode.data.config.channel_type" style="width: 100%">
<el-option label="企业微信" value="wecom" />
<el-option label="个人微信" value="wechat_personal" />
</el-select>
</el-form-item>
</template>
<!-- intent -->
<template v-if="selectedNode.data.nodeType === 'intent'">
<el-form-item label="识别模式">
<el-select v-model="selectedNode.data.config.mode" style="width: 100%">
<el-option label="规则" value="rule" />
<el-option label="LLM" value="llm" />
<el-option label="混合" value="both" />
</el-select>
</el-form-item>
<el-form-item label="提示词" v-if="showIntentPrompt(selectedNode.data.config.mode)">
<el-input
v-model="selectedNode.data.config.prompt"
type="textarea"
:rows="6"
placeholder="请输入 LLM 识别提示词"
/>
</el-form-item>
</template>
<!-- condition -->
<template v-if="selectedNode.data.nodeType === 'condition'">
<el-form-item label="表达式">
<el-input
v-model="selectedNode.data.config.expression"
type="textarea"
:rows="4"
placeholder="例如intent.intent == 'create_order'"
/>
</el-form-item>
<div class="form-hint">提示出边标签可填写 true / false 用于分支判断</div>
</template>
<!-- webhook -->
<template v-if="selectedNode.data.nodeType === 'webhook'">
<el-form-item label="请求地址">
<el-input v-model="selectedNode.data.config.url" placeholder="https://" />
</el-form-item>
<el-form-item label="请求方法">
<el-select v-model="selectedNode.data.config.method" style="width: 100%">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
</template>
<!-- reply -->
<template v-if="selectedNode.data.nodeType === 'reply'">
<el-form-item label="内容类型">
<el-select v-model="selectedNode.data.config.content.type" style="width: 100%">
<el-option label="文本" value="text" />
<el-option label="图片" value="image" />
<el-option label="图文卡片" value="news" />
</el-select>
</el-form-item>
<el-form-item label="内容">
<el-input
v-model="selectedNode.data.config.content.body"
type="textarea"
:rows="6"
placeholder="请输入回复内容"
/>
</el-form-item>
</template>
</el-form>
</template>
<template v-if="selectedEdge">
<div class="section-header">
<span class="section-title">边配置</span>
</div>
<el-form label-width="80px" class="config-form">
<el-form-item label="标签">
<el-input v-model="selectedEdge.label" placeholder="true / false" />
</el-form-item>
</el-form>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { VueFlow } from '@vue-flow/core'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueFlow, Handle, Position, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import { createFlowDefinition } from '@/api/flow'
import {
listFlows,
getFlow,
saveFlow,
updateFlow,
publishFlow,
offlineFlow,
deleteFlow,
} from '@/api/inboundFlow'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const form = reactive({
const { project } = useVueFlow()
interface FlowForm {
flowCode: string
flowName: string
}
const form = reactive<FlowForm>({
flowCode: '',
flowName: '',
})
const elements = ref<any[]>([
{
id: '1',
type: 'input',
label: '接收消息',
position: { x: 250, y: 5 },
data: { nodeType: 'receive' },
},
{
id: '2',
label: '意图识别',
position: { x: 250, y: 120 },
data: { nodeType: 'intent', intent: 'rescue_request' },
},
{
id: '3',
label: 'Webhook 推送',
position: { x: 250, y: 240 },
data: { nodeType: 'webhook', webhookUrl: '' },
},
{ id: 'e1-2', source: '1', target: '2' },
{ id: 'e2-3', source: '2', target: '3' },
])
const flows = ref<any[]>([])
const elements = ref<any[]>([])
const selectedNode = ref<any>(null)
const selectedEdge = ref<any>(null)
const isEdit = ref(false)
async function handleCreate() {
const paletteNodes = [
{ type: 'receive', label: '接收消息' },
{ type: 'intent', label: '意图识别' },
{ type: 'condition', label: '条件判断' },
{ type: 'webhook', label: 'Webhook' },
{ type: 'reply', label: '回复消息' },
]
const nodeTypeConfig: Record<string, () => any> = {
receive: () => ({ channel_type: 'wecom' }),
intent: () => ({ mode: 'llm', prompt: '' }),
condition: () => ({ expression: '' }),
webhook: () => ({ url: '', method: 'POST' }),
reply: () => ({ content: { type: 'text', body: '' } }),
}
const nodeTypeLabels: Record<string, string> = {
receive: '接收消息',
intent: '意图识别',
condition: '条件判断',
webhook: 'Webhook',
reply: '回复消息',
}
const nodeTypeIcons: Record<string, string> = {
receive: '▶',
intent: '💡',
condition: '◆',
webhook: '⚡',
reply: '💬',
}
function nodeTypeLabel(type: string) {
return nodeTypeLabels[type] || type
}
function nodeTypeIcon(type: string) {
return nodeTypeIcons[type] || '•'
}
function statusText(status: number) {
if (status === 1) return '已发布'
if (status === 2) return '已下线'
return '草稿'
}
function statusTagType(status: number) {
if (status === 1) return 'success'
if (status === 2) return 'warning'
return 'info'
}
function showIntentPrompt(mode: string) {
return mode === 'llm' || mode === 'both'
}
function createNodeId() {
return `node_${Date.now()}_${Math.floor(Math.random() * 1000)}`
}
function createEdgeId(source: string, target: string) {
return `e_${source}_${target}`
}
function getDefaultPosition() {
const count = elements.value.filter((el) => !('source' in el)).length
const row = Math.floor(count / 2)
const col = count % 2
return { x: 200 + col * 220, y: 80 + row * 140 }
}
function addNode(type: string, position?: { x: number; y: number }) {
const id = createNodeId()
const config = nodeTypeConfig[type]()
elements.value.push({
id,
type: 'flowNode',
label: nodeTypeLabels[type],
position: position || getDefaultPosition(),
data: { nodeType: type, config },
})
}
function onDragStart(event: DragEvent, type: string) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow-node', type)
event.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (!event.dataTransfer) return
const type = event.dataTransfer.getData('application/vueflow-node')
if (!type) return
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect()
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
})
addNode(type, position)
}
function onNodeClick(event: any) {
const node = event.node
selectedEdge.value = null
const found = elements.value.find((el) => el.id === node.id)
selectedNode.value = found || node
}
function onEdgeClick(event: any) {
const edge = event.edge
selectedNode.value = null
const found = elements.value.find((el) => el.id === edge.id)
selectedEdge.value = found || edge
}
function onPaneClick() {
selectedNode.value = null
selectedEdge.value = null
}
function resetCanvas() {
elements.value = []
form.flowCode = ''
form.flowName = ''
isEdit.value = false
selectedNode.value = null
selectedEdge.value = null
}
function createNewFlow() {
resetCanvas()
form.flowCode = `flow_${Date.now()}`
form.flowName = '新建入站流程'
addNode('receive')
addNode('intent')
}
function handleFlowRowClick(row: any) {
loadFlow(row.flowCode)
}
function parseDefinitionJson(row: any): { nodes: any[]; edges: any[] } {
if (!row.definitionJson) return { nodes: [], edges: [] }
if (typeof row.definitionJson === 'string') {
try {
const nodes = elements.value
.filter((el) => !('source' in el))
.map((el) => {
const { nodeType, ...config } = el.data || {}
return JSON.parse(row.definitionJson)
} catch {
return { nodes: [], edges: [] }
}
}
return row.definitionJson
}
async function loadFlow(flowCode: string) {
try {
const res: any = await getFlow(flowCode)
const row = res.data
if (!row) {
ElMessage.warning('流程不存在')
return
}
form.flowCode = row.flowCode
form.flowName = row.flowName
isEdit.value = true
const definition = parseDefinitionJson(row)
const nodes = (definition.nodes || []).map((n: any, index: number) => {
const type = n.type || n.nodeType || 'receive'
const config = n.config || {}
return {
nodeType,
config,
position: el.position,
id: n.id || createNodeId(),
type: 'flowNode',
label: nodeTypeLabels[type] || type,
position: n.position || {
x: 200 + (index % 3) * 220,
y: 80 + Math.floor(index / 3) * 140,
},
data: { nodeType: type, config },
}
})
const nodeIds = new Set(nodes.map((n) => n.id))
const edges = (definition.edges || [])
.filter((e: any) => nodeIds.has(e.source) && nodeIds.has(e.target))
.map((e: any) => ({
id: e.id || createEdgeId(e.source, e.target),
source: e.source,
target: e.target,
label: e.label,
}))
elements.value = [...nodes, ...edges]
selectedNode.value = null
selectedEdge.value = null
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadFlows() {
try {
const res: any = await listFlows()
flows.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function buildPayload() {
const nodes = elements.value
.filter((el) => !('source' in el))
.map((el) => ({
id: el.id,
type: el.data?.nodeType,
config: el.data?.config || {},
position: el.position,
}))
const edges = elements.value
.filter((el) => 'source' in el)
.map((el) => ({
source: el.source,
target: el.target,
label: el.label,
}))
const res: any = await createFlowDefinition({
return {
flowCode: form.flowCode,
flowName: form.flowName,
flowType: 'inbound',
definitionJson: { nodes, edges },
})
}
}
async function handleSave() {
if (!form.flowCode.trim()) {
ElMessage.warning('请输入流程编码')
return
}
if (!form.flowName.trim()) {
ElMessage.warning('请输入流程名称')
return
}
try {
const payload = buildPayload()
if (isEdit.value) {
await updateFlow(form.flowCode, payload)
ElMessage.success('更新成功')
} else {
await saveFlow(payload)
ElMessage.success('保存成功')
console.log(res)
isEdit.value = true
}
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handlePublish() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择或保存流程')
return
}
try {
await publishFlow(form.flowCode)
ElMessage.success('发布成功')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleOffline() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await offlineFlow(form.flowCode)
ElMessage.success('已下线')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await ElMessageBox.confirm(`确定删除流程 ${form.flowCode} 吗?`, '提示', { type: 'warning' })
await deleteFlow(form.flowCode)
ElMessage.success('删除成功')
resetCanvas()
await loadFlows()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
onMounted(() => {
loadFlows()
})
</script>
<style scoped>
.inbound-flow {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
height: calc(100vh - 120px);
}
.flow-layout {
display: flex;
flex: 1;
gap: 12px;
min-height: 0;
}
.left-panel {
width: 260px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.panel-section {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
overflow: auto;
}
.flow-list {
flex: 1;
min-height: 0;
}
.node-palette {
flex-shrink: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-title {
font-weight: 500;
font-size: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.palette-item {
padding: 10px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
text-align: center;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.palette-item:hover {
border-color: #409eff;
color: #409eff;
background: #ecf5ff;
}
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e4e7ed;
border-radius: 4px;
background: #fff;
}
.flow-toolbar {
padding: 12px;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-canvas {
flex: 1;
min-height: 0;
}
.right-panel {
width: 300px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
min-height: 400px;
padding: 12px;
overflow: auto;
}
.config-form {
margin-top: 12px;
}
.form-hint {
font-size: 12px;
color: #909399;
line-height: 1.5;
padding: 0 0 12px 80px;
}
.node-type-tag {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
padding: 2px 8px;
border-radius: 4px;
}
.flow-node {
padding: 10px 16px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
min-width: 110px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
}
.flow-node.node-selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.node-icon {
font-size: 16px;
}
.node-label {
font-size: 13px;
font-weight: 500;
}
.node-receive {
border-left: 4px solid #67c23a;
}
.node-intent {
border-left: 4px solid #e6a23c;
}
.node-condition {
border-left: 4px solid #f56c6c;
}
.node-webhook {
border-left: 4px solid #409eff;
}
.node-reply {
border-left: 4px solid #909399;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div>
<el-form :model="form" label-width="140px" style="max-width: 600px;">
<el-form-item label="入站 Webhook URL">
<el-input v-model="form.inbound_webhook_url" placeholder="https://example.com/webhook" />
</el-form-item>
<el-form-item label="签名算法">
<el-select v-model="form.sign_algorithm" placeholder="选择签名算法">
<el-option label="HMAC-SHA256" value="hmac-sha256" />
<el-option label="HMAC-SHA512" value="hmac-sha512" />
</el-select>
</el-form-item>
<el-form-item label="Webhook Secret">
<el-input v-model="form.webhook_secret" type="password" show-password placeholder="用于签名验证的密钥" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button @click="handleTest">测试推送</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getInboundWebhookConfig, updateInboundWebhookConfig, testInboundWebhook } from '@/api/inboundWebhook'
const form = reactive({
inbound_webhook_url: '',
sign_algorithm: 'hmac-sha256',
webhook_secret: '',
})
async function loadConfig() {
try {
const res: any = await getInboundWebhookConfig()
const data = res.data || {}
form.inbound_webhook_url = data.inbound_webhook_url || ''
const authConfig = data.auth_config || {}
form.sign_algorithm = authConfig.sign_algorithm || 'hmac-sha256'
form.webhook_secret = authConfig.webhook_secret || ''
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleSave() {
try {
await updateInboundWebhookConfig({
inbound_webhook_url: form.inbound_webhook_url,
auth_config: {
sign_algorithm: form.sign_algorithm,
webhook_secret: form.webhook_secret,
},
})
ElMessage.success('保存成功')
await loadConfig()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleTest() {
if (!form.inbound_webhook_url) {
ElMessage.warning('请先填写 Webhook URL')
return
}
try {
const res: any = await testInboundWebhook({ url: form.inbound_webhook_url })
const result = res.data || {}
ElMessageBox.alert(result.message || '测试完成', result.success ? '测试成功' : '测试失败', {
type: result.success ? 'success' : 'error',
})
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadConfig()
})
</script>

View File

@@ -0,0 +1,780 @@
<template>
<div class="ivr-flow">
<div class="flow-layout">
<!-- Left sidebar: flow list + node palette -->
<div class="left-panel">
<div class="panel-section flow-list">
<div class="section-header">
<span class="section-title">流程列表</span>
<el-button type="primary" size="small" @click="createNewFlow">新建</el-button>
</div>
<el-table
:data="flows"
highlight-current-row
size="small"
@row-click="handleFlowRowClick"
>
<el-table-column prop="flowCode" label="编码" show-overflow-tooltip />
<el-table-column prop="flowName" label="名称" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="70">
<template #default="scope">
<el-tag :type="statusTagType(scope.row.status)" size="small">
{{ statusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="version" label="版本" width="55">
<template #default="scope">
<span>v{{ scope.row.version || 1 }}</span>
</template>
</el-table-column>
</el-table>
</div>
<div class="panel-section node-palette">
<div class="section-header">
<span class="section-title">节点组件</span>
</div>
<div class="palette-grid">
<div
v-for="item in paletteNodes"
:key="item.type"
class="palette-item"
:class="`palette-${item.type}`"
draggable
@click="addNode(item.type)"
@dragstart="onDragStart($event, item.type)"
>
{{ item.label }}
</div>
</div>
</div>
</div>
<!-- Center canvas -->
<div class="canvas-area" @drop="onDrop" @dragover="onDragOver">
<div class="flow-toolbar">
<el-input
v-model="form.flowCode"
placeholder="流程编码"
style="width: 160px"
:disabled="isEdit"
/>
<el-input
v-model="form.flowName"
placeholder="流程名称"
style="width: 180px"
/>
<el-button type="primary" @click="handleSave">保存</el-button>
<el-button type="success" @click="handlePublish">发布</el-button>
<el-button type="warning" @click="handleOffline">下线</el-button>
<el-button type="danger" @click="handleDelete">删除</el-button>
<el-button @click="resetCanvas">清空画布</el-button>
</div>
<div class="flow-canvas">
<VueFlow
v-model="elements"
fit-view-on-init
:default-zoom="1"
@node-click="onNodeClick"
@edge-click="onEdgeClick"
@pane-click="onPaneClick"
>
<Background pattern-color="#aaa" :gap="16" />
<Controls />
<template #node-flowNode="nodeProps">
<div
class="flow-node"
:class="[
`node-${nodeProps.data.nodeType}`,
{ 'node-selected': nodeProps.selected },
]"
>
<Handle type="target" :position="Position.Top" />
<div class="node-icon">{{ nodeTypeIcon(nodeProps.data.nodeType) }}</div>
<div class="node-label">{{ nodeProps.label }}</div>
<Handle type="source" :position="Position.Bottom" />
</div>
</template>
</VueFlow>
</div>
</div>
<!-- Right config panel -->
<div v-if="selectedNode || selectedEdge" class="right-panel">
<template v-if="selectedNode">
<div class="section-header">
<span class="section-title">节点配置</span>
<span class="node-type-tag">{{ nodeTypeLabel(selectedNode.data.nodeType) }}</span>
</div>
<el-form label-width="90px" class="config-form">
<!-- start -->
<template v-if="selectedNode.data.nodeType === 'start'">
<el-form-item label="触发类型">
<el-select v-model="selectedNode.data.config.triggerType" style="width: 100%">
<el-option label="呼入电话" value="inbound_call" />
<el-option label="外呼触发" value="outbound_call" />
</el-select>
</el-form-item>
<el-form-item label="电话号码">
<el-input v-model="selectedNode.data.config.phoneNumber" placeholder="接入号码/外呼号码" />
</el-form-item>
</template>
<!-- play -->
<template v-if="selectedNode.data.nodeType === 'play'">
<el-form-item label="播放类型">
<el-select v-model="selectedNode.data.config.playType" style="width: 100%">
<el-option label="音频文件" value="audio" />
<el-option label="TTS 文本" value="tts" />
</el-select>
</el-form-item>
<el-form-item label="音频 URL" v-if="selectedNode.data.config.playType === 'audio'">
<el-input v-model="selectedNode.data.config.audioUrl" placeholder="https://" />
</el-form-item>
<el-form-item label="TTS 文本" v-if="selectedNode.data.config.playType === 'tts'">
<el-input
v-model="selectedNode.data.config.ttsText"
type="textarea"
:rows="4"
placeholder="请输入语音播报文本"
/>
</el-form-item>
</template>
<!-- menu -->
<template v-if="selectedNode.data.nodeType === 'menu'">
<el-form-item label="播报提示">
<el-input
v-model="selectedNode.data.config.prompt"
type="textarea"
:rows="3"
placeholder="请输入按键菜单播报内容"
/>
</el-form-item>
<el-form-item label="超时时间">
<el-input-number v-model="selectedNode.data.config.timeout" :min="1" :max="60" style="width: 100%" />
</el-form-item>
<el-form-item label="按键选项">
<div class="menu-options">
<div v-for="(opt, index) in selectedNode.data.config.options" :key="index" class="menu-option-row">
<el-input v-model="opt.key" placeholder="键" style="width: 60px" />
<el-input v-model="opt.label" placeholder="描述" style="flex: 1" />
<el-button type="danger" link size="small" @click="removeMenuOption(index)">删除</el-button>
</div>
<el-button type="primary" link size="small" @click="addMenuOption">+ 添加选项</el-button>
</div>
</el-form-item>
</template>
<!-- transfer -->
<template v-if="selectedNode.data.nodeType === 'transfer'">
<el-form-item label="转接号码">
<el-input v-model="selectedNode.data.config.destination" placeholder="坐席/队列号码" />
</el-form-item>
</template>
<!-- hangup -->
<template v-if="selectedNode.data.nodeType === 'hangup'">
<el-form-item label="挂断原因">
<el-input v-model="selectedNode.data.config.reason" placeholder="正常结束" />
</el-form-item>
</template>
<!-- api -->
<template v-if="selectedNode.data.nodeType === 'api'">
<el-form-item label="请求地址">
<el-input v-model="selectedNode.data.config.url" placeholder="https://" />
</el-form-item>
<el-form-item label="请求方法">
<el-select v-model="selectedNode.data.config.method" style="width: 100%">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
<el-form-item label="请求体模板">
<el-input
v-model="selectedNode.data.config.bodyTemplate"
type="textarea"
:rows="6"
placeholder='{"phone":"{{phone}}"}'
/>
</el-form-item>
</template>
</el-form>
</template>
<template v-if="selectedEdge">
<div class="section-header">
<span class="section-title">边配置</span>
</div>
<el-form label-width="80px" class="config-form">
<el-form-item label="标签">
<el-input v-model="selectedEdge.label" placeholder="分支标签,如 1 / 2 / default" />
</el-form-item>
</el-form>
</template>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { VueFlow, Handle, Position, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import {
listFlows,
getFlow,
saveFlow,
updateFlow,
publishFlow,
offlineFlow,
deleteFlow,
} from '@/api/ivrFlow'
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
const { project } = useVueFlow()
interface FlowForm {
flowCode: string
flowName: string
}
const form = reactive<FlowForm>({
flowCode: '',
flowName: '',
})
const flows = ref<any[]>([])
const elements = ref<any[]>([])
const selectedNode = ref<any>(null)
const selectedEdge = ref<any>(null)
const isEdit = ref(false)
const paletteNodes = [
{ type: 'start', label: '开始' },
{ type: 'play', label: '播放语音' },
{ type: 'menu', label: '按键菜单' },
{ type: 'transfer', label: '转人工' },
{ type: 'hangup', label: '挂断' },
{ type: 'api', label: 'API 调用' },
]
const defaultMenuOptions = () => [
{ key: '1', label: '' },
{ key: '2', label: '' },
]
const nodeTypeConfig: Record<string, () => any> = {
start: () => ({ triggerType: 'inbound_call', phoneNumber: '' }),
play: () => ({ playType: 'audio', audioUrl: '', ttsText: '' }),
menu: () => ({ prompt: '', timeout: 10, options: defaultMenuOptions() }),
transfer: () => ({ destination: '' }),
hangup: () => ({ reason: '' }),
api: () => ({ url: '', method: 'POST', bodyTemplate: '' }),
}
const nodeTypeLabels: Record<string, string> = {
start: '开始',
play: '播放语音',
menu: '按键菜单',
transfer: '转人工',
hangup: '挂断',
api: 'API 调用',
}
const nodeTypeIcons: Record<string, string> = {
start: '▶',
play: '🔊',
menu: '⌨',
transfer: '📞',
hangup: '⏹',
api: '⚡',
}
function nodeTypeLabel(type: string) {
return nodeTypeLabels[type] || type
}
function nodeTypeIcon(type: string) {
return nodeTypeIcons[type] || '•'
}
function statusText(status: number) {
if (status === 1) return '已发布'
if (status === 2) return '已下线'
return '草稿'
}
function statusTagType(status: number) {
if (status === 1) return 'success'
if (status === 2) return 'warning'
return 'info'
}
function createNodeId() {
return `node_${Date.now()}_${Math.floor(Math.random() * 1000)}`
}
function createEdgeId(source: string, target: string) {
return `e_${source}_${target}`
}
function getDefaultPosition() {
const count = elements.value.filter((el) => !('source' in el)).length
const row = Math.floor(count / 2)
const col = count % 2
return { x: 200 + col * 220, y: 80 + row * 140 }
}
function addNode(type: string, position?: { x: number; y: number }) {
const id = createNodeId()
const config = nodeTypeConfig[type]()
elements.value.push({
id,
type: 'flowNode',
label: nodeTypeLabels[type],
position: position || getDefaultPosition(),
data: { nodeType: type, config },
})
}
function addMenuOption() {
if (!selectedNode.value) return
const options = selectedNode.value.data.config.options
options.push({ key: String(options.length + 1), label: '' })
}
function removeMenuOption(index: number | string) {
if (!selectedNode.value) return
selectedNode.value.data.config.options.splice(Number(index), 1)
}
function onDragStart(event: DragEvent, type: string) {
if (event.dataTransfer) {
event.dataTransfer.setData('application/vueflow-node', type)
event.dataTransfer.effectAllowed = 'move'
}
}
function onDragOver(event: DragEvent) {
event.preventDefault()
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move'
}
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (!event.dataTransfer) return
const type = event.dataTransfer.getData('application/vueflow-node')
if (!type) return
const bounds = (event.currentTarget as HTMLElement).getBoundingClientRect()
const position = project({
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
})
addNode(type, position)
}
function onNodeClick(event: any) {
const node = event.node
selectedEdge.value = null
const found = elements.value.find((el) => el.id === node.id)
selectedNode.value = found || node
}
function onEdgeClick(event: any) {
const edge = event.edge
selectedNode.value = null
const found = elements.value.find((el) => el.id === edge.id)
selectedEdge.value = found || edge
}
function onPaneClick() {
selectedNode.value = null
selectedEdge.value = null
}
function resetCanvas() {
elements.value = []
form.flowCode = ''
form.flowName = ''
isEdit.value = false
selectedNode.value = null
selectedEdge.value = null
}
function createNewFlow() {
resetCanvas()
form.flowCode = `ivr_${Date.now()}`
form.flowName = '新建 IVR 流程'
addNode('start')
addNode('play')
}
function handleFlowRowClick(row: any) {
loadFlow(row.flowCode)
}
function parseDefinitionJson(row: any): { nodes: any[]; edges: any[] } {
if (!row.definitionJson) return { nodes: [], edges: [] }
if (typeof row.definitionJson === 'string') {
try {
return JSON.parse(row.definitionJson)
} catch {
return { nodes: [], edges: [] }
}
}
return row.definitionJson
}
async function loadFlow(flowCode: string) {
try {
const res: any = await getFlow(flowCode)
const row = res.data
if (!row) {
ElMessage.warning('流程不存在')
return
}
form.flowCode = row.flowCode
form.flowName = row.flowName
isEdit.value = true
const definition = parseDefinitionJson(row)
const nodes = (definition.nodes || []).map((n: any, index: number) => {
const type = n.type || n.nodeType || 'start'
const config = n.config || nodeTypeConfig[type]()
return {
id: n.id || createNodeId(),
type: 'flowNode',
label: nodeTypeLabels[type] || type,
position: n.position || {
x: 200 + (index % 3) * 220,
y: 80 + Math.floor(index / 3) * 140,
},
data: { nodeType: type, config },
}
})
const nodeIds = new Set(nodes.map((n) => n.id))
const edges = (definition.edges || [])
.filter((e: any) => nodeIds.has(e.source) && nodeIds.has(e.target))
.map((e: any) => ({
id: e.id || createEdgeId(e.source, e.target),
source: e.source,
target: e.target,
label: e.label,
}))
elements.value = [...nodes, ...edges]
selectedNode.value = null
selectedEdge.value = null
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadFlows() {
try {
const res: any = await listFlows()
flows.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function buildPayload() {
const nodes = elements.value
.filter((el) => !('source' in el))
.map((el) => ({
id: el.id,
type: el.data?.nodeType,
config: el.data?.config || {},
position: el.position,
}))
const edges = elements.value
.filter((el) => 'source' in el)
.map((el) => ({
source: el.source,
target: el.target,
label: el.label,
}))
return {
flowCode: form.flowCode,
flowName: form.flowName,
flowType: 'ivr',
definitionJson: { nodes, edges },
}
}
async function handleSave() {
if (!form.flowCode.trim()) {
ElMessage.warning('请输入流程编码')
return
}
if (!form.flowName.trim()) {
ElMessage.warning('请输入流程名称')
return
}
try {
const payload = buildPayload()
if (isEdit.value) {
await updateFlow(form.flowCode, payload)
ElMessage.success('更新成功')
} else {
await saveFlow(payload)
ElMessage.success('保存成功')
isEdit.value = true
}
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handlePublish() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择或保存流程')
return
}
try {
await publishFlow(form.flowCode)
ElMessage.success('发布成功')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleOffline() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await offlineFlow(form.flowCode)
ElMessage.success('已下线')
await loadFlows()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete() {
if (!form.flowCode.trim()) {
ElMessage.warning('请先选择流程')
return
}
try {
await ElMessageBox.confirm(`确定删除流程 ${form.flowCode} 吗?`, '提示', { type: 'warning' })
await deleteFlow(form.flowCode)
ElMessage.success('删除成功')
resetCanvas()
await loadFlows()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
onMounted(() => {
loadFlows()
})
</script>
<style scoped>
.ivr-flow {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
}
.flow-layout {
display: flex;
flex: 1;
gap: 12px;
min-height: 0;
}
.left-panel {
width: 260px;
display: flex;
flex-direction: column;
gap: 12px;
min-height: 0;
}
.panel-section {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
overflow: auto;
}
.flow-list {
flex: 1;
min-height: 0;
}
.node-palette {
flex-shrink: 0;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-title {
font-weight: 500;
font-size: 14px;
}
.palette-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.palette-item {
padding: 10px 8px;
border: 1px solid #dcdfe6;
border-radius: 4px;
text-align: center;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.palette-item:hover {
border-color: #409eff;
color: #409eff;
background: #ecf5ff;
}
.canvas-area {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border: 1px solid #e4e7ed;
border-radius: 4px;
background: #fff;
}
.flow-toolbar {
padding: 12px;
border-bottom: 1px solid #e4e7ed;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-canvas {
flex: 1;
min-height: 0;
}
.right-panel {
width: 320px;
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
padding: 12px;
overflow: auto;
}
.config-form {
margin-top: 12px;
}
.node-type-tag {
font-size: 12px;
color: #409eff;
background: #ecf5ff;
padding: 2px 8px;
border-radius: 4px;
}
.menu-options {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.menu-option-row {
display: flex;
align-items: center;
gap: 8px;
}
.flow-node {
padding: 10px 16px;
border-radius: 6px;
border: 1px solid #dcdfe6;
background: #fff;
display: flex;
align-items: center;
gap: 8px;
min-width: 110px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.06);
}
.flow-node.node-selected {
border-color: #409eff;
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.2);
}
.node-icon {
font-size: 16px;
}
.node-label {
font-size: 13px;
font-weight: 500;
}
.node-start {
border-left: 4px solid #67c23a;
}
.node-play {
border-left: 4px solid #409eff;
}
.node-menu {
border-left: 4px solid #e6a23c;
}
.node-transfer {
border-left: 4px solid #f56c6c;
}
.node-hangup {
border-left: 4px solid #909399;
}
.node-api {
border-left: 4px solid #9254de;
}
</style>

View File

@@ -34,6 +34,9 @@
登录
</el-button>
</el-form>
<div class="login-hint">
全局管理员租户 system / 用户名 superadmin / 密码 admin123
</div>
</el-card>
</div>
</template>
@@ -107,4 +110,10 @@ async function handleSubmit() {
width: 100%;
margin-top: 8px;
}
.login-hint {
margin-top: 16px;
font-size: 12px;
color: #909399;
text-align: center;
}
</style>

View File

@@ -1,6 +1,5 @@
<template>
<div>
<h2>消息测试</h2>
<el-tabs v-model="activeTab">
<el-tab-pane label="出站测试" name="outbound">
<el-form :model="outboundForm" label-width="120px">

View File

@@ -1,52 +1,201 @@
<template>
<div>
<el-form :model="form" inline>
<el-form-item label="编码">
<el-input v-model="form.personCode" placeholder="人员编码" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="form.personName" placeholder="姓名" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="form.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="form.personType" placeholder="类型">
<el-option label="内部人员" :value="1" />
<el-option label="供应商人员" :value="2" />
<el-option label="客户" :value="3" />
<el-option label="司机" :value="4" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建联系人</el-button>
</div>
<el-table :data="persons" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="personCode" label="编码" />
<el-table-column prop="personName" label="姓名" />
<el-table-column prop="phone" label="手机" />
<el-table-column prop="personType" label="类型" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="personType" label="类型" width="100">
<template #default="{ row }">
{{ formatPersonType(row.personType) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
<div class="op-row" style="margin-top: 6px;">
<el-button link type="primary" size="small" @click="openIdentities(row)">渠道身份</el-button>
<el-button link type="primary" size="small" @click="openBindTags(row)">绑定标签</el-button>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑联系人' : '创建联系人'" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="编码">
<el-input v-model="dialogForm.personCode" placeholder="人员编码" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model="dialogForm.personName" placeholder="姓名" />
</el-form-item>
<el-form-item label="手机">
<el-input v-model="dialogForm.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="dialogForm.personType" placeholder="类型" style="width: 100%">
<el-option label="内部人员" :value="1" />
<el-option label="供应商人员" :value="2" />
<el-option label="客户" :value="3" />
<el-option label="司机" :value="4" />
</el-select>
</el-form-item>
<el-form-item v-if="isEdit" label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="identitiesVisible" title="渠道身份" width="700px">
<el-table :data="identities" border style="margin-bottom: 16px;">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="channelUserId" label="渠道用户ID" />
<el-table-column prop="channelUserName" label="渠道用户名" />
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="handleDeleteIdentity(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-divider />
<el-form :model="identityForm" label-width="100px">
<el-form-item label="渠道">
<el-input v-model="identityForm.channelType" placeholder="wecom" />
</el-form-item>
<el-form-item label="渠道用户ID">
<el-input v-model="identityForm.channelUserId" placeholder="渠道用户ID" />
</el-form-item>
<el-form-item label="渠道用户名">
<el-input v-model="identityForm.channelUserName" placeholder="渠道用户名" />
</el-form-item>
<el-form-item label="主体ID">
<el-input-number v-model="identityForm.subjectId" :min="0" :controls="false" placeholder="主体ID" style="width: 100%" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="identitiesVisible = false">关闭</el-button>
<el-button type="primary" @click="handleCreateIdentity">新增身份</el-button>
</template>
</el-dialog>
<el-dialog v-model="bindTagsVisible" title="绑定标签" width="500px">
<el-form :model="bindTagsForm" label-width="80px">
<el-form-item label="标签">
<el-select v-model="bindTagsForm.tagIds" multiple placeholder="选择标签" style="width: 100%">
<el-option
v-for="t in tags"
:key="t.id"
:label="t.tagName"
:value="t.id"
/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="bindTagsVisible = false">取消</el-button>
<el-button type="primary" @click="handleBindTags">确认</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createPerson, listPersons } from '@/api/crm'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
bindPersonTags,
createPerson,
createPersonIdentity,
deletePerson,
deletePersonIdentity,
listPersonIdentities,
listPersons,
listTags,
updatePerson,
} from '@/api/crm'
const form = reactive({
const persons = ref<any[]>([])
const tags = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
personCode: '',
personName: '',
phone: '',
personType: 3,
status: 1,
})
const persons = ref<any[]>([])
function resetDialogForm() {
dialogForm.personCode = ''
dialogForm.personName = ''
dialogForm.phone = ''
dialogForm.personType = 3
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.personCode = row.personCode ?? ''
dialogForm.personName = row.personName ?? ''
dialogForm.phone = row.phone ?? ''
dialogForm.personType = row.personType ?? 3
dialogForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
function formatPersonType(type: number | undefined) {
if (type === 3) return '客户'
if (type === 1 || type === 2 || type === 4) return '员工'
return '未知'
}
async function loadPersons() {
try {
@@ -57,15 +206,183 @@ async function loadPersons() {
}
}
async function handleCreate() {
async function loadTags() {
try {
const res: any = await createPerson(form)
persons.value.unshift(res.data)
ElMessage.success('创建成功')
const res: any = await listTags()
tags.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadPersons)
async function handleSave() {
if (!dialogForm.personName) {
ElMessage.warning('请填写姓名')
return
}
try {
const payload = {
personCode: dialogForm.personCode,
personName: dialogForm.personName,
phone: dialogForm.phone,
personType: dialogForm.personType,
status: dialogForm.status,
}
if (isEdit.value && editingId.value != null) {
await updatePerson(editingId.value, payload)
ElMessage.success('更新成功')
} else {
const res: any = await createPerson(payload)
persons.value.unshift(res.data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadPersons()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updatePerson(row.id, {
personCode: row.personCode,
personName: row.personName,
phone: row.phone,
personType: row.personType,
status: val,
})
ElMessage.success('状态更新成功')
await loadPersons()
} catch (e: any) {
ElMessage.error(e.message)
await loadPersons()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除联系人「${row.personName || row.personCode}」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deletePerson(row.id)
ElMessage.success('删除成功')
await loadPersons()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
const identitiesVisible = ref(false)
const identities = ref<any[]>([])
const currentPersonId = ref<number | undefined>()
const identityForm = reactive({
channelType: '',
channelUserId: '',
channelUserName: '',
subjectId: undefined as number | undefined,
})
function resetIdentityForm() {
identityForm.channelType = ''
identityForm.channelUserId = ''
identityForm.channelUserName = ''
identityForm.subjectId = undefined
}
async function openIdentities(row: any) {
currentPersonId.value = row.id
identitiesVisible.value = true
resetIdentityForm()
await loadIdentities()
}
async function loadIdentities() {
if (!currentPersonId.value) return
try {
const res: any = await listPersonIdentities(currentPersonId.value)
identities.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCreateIdentity() {
if (!currentPersonId.value) return
if (!identityForm.channelType || !identityForm.channelUserId) {
ElMessage.warning('请填写渠道和渠道用户ID')
return
}
try {
const payload: any = {
channelType: identityForm.channelType,
channelUserId: identityForm.channelUserId,
channelUserName: identityForm.channelUserName,
}
if (identityForm.subjectId != null) {
payload.subjectId = identityForm.subjectId
}
await createPersonIdentity(currentPersonId.value, payload)
ElMessage.success('新增成功')
resetIdentityForm()
await loadIdentities()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDeleteIdentity(row: any) {
if (!currentPersonId.value) return
try {
await ElMessageBox.confirm('确定删除该渠道身份吗?', '删除确认', {
confirmButtonText: '删除',
cancelButtonText: '取消',
type: 'warning',
})
await deletePersonIdentity(currentPersonId.value, row.id)
ElMessage.success('删除成功')
await loadIdentities()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
const bindTagsVisible = ref(false)
const bindTagsForm = reactive({
tagIds: [] as number[],
})
function openBindTags(row: any) {
currentPersonId.value = row.id
bindTagsForm.tagIds = []
bindTagsVisible.value = true
}
async function handleBindTags() {
if (!currentPersonId.value) return
try {
await bindPersonTags(currentPersonId.value, { tagIds: bindTagsForm.tagIds })
ElMessage.success('绑定成功')
bindTagsVisible.value = false
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadPersons()
loadTags()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,183 @@
<template>
<div>
<div style="margin-bottom: 16px;">
<el-button type="primary" @click="openCreateDialog">新增策略组</el-button>
</div>
<el-table :data="policyGroups" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="groupCode" label="策略组编码" />
<el-table-column prop="groupName" label="策略组名称" />
<el-table-column prop="channelType" label="渠道类型" />
<el-table-column prop="status" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.status === 1" type="success">启用</el-tag>
<el-tag v-else type="info">禁用</el-tag>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" />
<el-table-column label="操作" width="220">
<template #default="scope">
<el-button link type="primary" @click="openEditDialog(scope.row)">编辑</el-button>
<el-button v-if="scope.row.status === 1" link type="warning" @click="handleToggleStatus(scope.row, 0)">禁用</el-button>
<el-button v-else link type="success" @click="handleToggleStatus(scope.row, 1)">启用</el-button>
<el-button link type="danger" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑策略组' : '新增策略组'" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="策略组编码">
<el-input v-model="dialogForm.group_code" :disabled="isEdit" placeholder="唯一编码" />
</el-form-item>
<el-form-item label="策略组名称">
<el-input v-model="dialogForm.group_name" placeholder="显示名称" />
</el-form-item>
<el-form-item label="渠道类型">
<el-select v-model="dialogForm.channel_type" placeholder="选择渠道类型" style="width: 100%">
<el-option label="企微" value="wecom" />
<el-option label="个人微信" value="wechat_personal" />
<el-option label="短信" value="sms" />
<el-option label="IVR" value="ivr" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="dialogForm.status" placeholder="状态">
<el-option label="启用" :value="1" />
<el-option label="禁用" :value="0" />
</el-select>
</el-form-item>
<el-form-item label="策略配置">
<el-input v-model="dialogForm.policies" type="textarea" :rows="10" placeholder='JSON 数组,例如:[{"rule":"channel_type","value":"wecom"}]' />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
listPolicyGroups,
createPolicyGroup,
updatePolicyGroup,
updatePolicyGroupStatus,
deletePolicyGroup,
} from '@/api/sendPolicy'
const policyGroups = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const dialogForm = reactive({
group_code: '',
group_name: '',
channel_type: '',
status: 1,
policies: '[]',
})
async function loadPolicyGroups() {
try {
const res: any = await listPolicyGroups()
policyGroups.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
function resetDialogForm() {
dialogForm.group_code = ''
dialogForm.group_name = ''
dialogForm.channel_type = ''
dialogForm.status = 1
dialogForm.policies = '[]'
}
function openCreateDialog() {
isEdit.value = false
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
dialogForm.group_code = row.groupCode
dialogForm.group_name = row.groupName
dialogForm.channel_type = row.channelType
dialogForm.status = row.status
try {
const policies = row.policies ? JSON.parse(row.policies) : []
dialogForm.policies = JSON.stringify(policies, null, 2)
} catch {
dialogForm.policies = row.policies || '[]'
}
dialogVisible.value = true
}
function buildPayload() {
let policies
try {
policies = JSON.parse(dialogForm.policies)
} catch {
throw new Error('策略配置不是有效的 JSON')
}
return {
groupCode: dialogForm.group_code,
groupName: dialogForm.group_name,
channelType: dialogForm.channel_type,
status: dialogForm.status,
policies,
}
}
async function handleSave() {
try {
const payload = buildPayload()
if (isEdit.value) {
await updatePolicyGroup(dialogForm.group_code, payload)
ElMessage.success('更新成功')
} else {
await createPolicyGroup(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadPolicyGroups()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleToggleStatus(row: any, status: number) {
try {
await updatePolicyGroupStatus(row.groupCode, status)
ElMessage.success(status === 1 ? '已启用' : '已禁用')
await loadPolicyGroups()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm('确定删除该策略组吗?', '提示', { type: 'warning' })
await deletePolicyGroup(row.groupCode)
ElMessage.success('删除成功')
await loadPolicyGroups()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message)
}
}
}
onMounted(() => {
loadPolicyGroups()
})
</script>

View File

@@ -1,93 +1,439 @@
<template>
<div>
<el-form :model="form" label-width="80px">
<!-- 统计卡片 -->
<el-row :gutter="16">
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="今日发送总数" :value="statistics.total" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="成功" :value="statistics.successCount">
<template #suffix>
<el-tag type="success" size="small">成功</el-tag>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="失败" :value="statistics.failedCount">
<template #suffix>
<el-tag type="danger" size="small">失败</el-tag>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover">
<el-statistic title="待发送" :value="statistics.pendingCount">
<template #suffix>
<el-tag type="info" size="small">待发送</el-tag>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<!-- 测试发送 -->
<el-card class="mt-4" shadow="never">
<template #header>
<span>测试发送</span>
</template>
<el-form :model="testForm" label-width="80px" inline>
<el-form-item label="目标类型">
<el-select v-model="form.targetType">
<el-select v-model="testForm.targetType" style="width: 140px">
<el-option label="会话" value="conversation" />
<el-option label="人员" value="person" />
</el-select>
</el-form-item>
<el-form-item label="目标" v-if="form.targetType === 'conversation'">
<el-select v-model="selectedConversationId" placeholder="选择会话">
<el-option v-for="c in conversations" :key="c.id" :label="c.conversationName" :value="c.id" />
<el-form-item label="目标" v-if="testForm.targetType === 'conversation'" style="width: 280px">
<el-select v-model="selectedConversationId" placeholder="选择会话" clearable>
<el-option
v-for="c in conversations"
:key="c.id"
:label="c.conversationName"
:value="c.id"
/>
</el-select>
</el-form-item>
<el-form-item label="目标" v-if="testForm.targetType === 'person'" style="width: 280px">
<el-input v-model="personIdInput" placeholder="输入 person_id" />
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="testForm.channelStrategy.channel_type" style="width: 140px">
<el-option label="企微" value="wecom" />
<el-option label="微信个人号" value="wechat_personal" />
</el-select>
</el-form-item>
<el-form-item label="内容类型"><el-input v-model="form.contentType" disabled /></el-form-item>
<el-form-item label="内容">
<el-input v-model="textContent" type="textarea" :rows="3" placeholder="输入文本消息" />
<el-input v-model="textContent" placeholder="输入文本消息" style="width: 260px" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
</el-form-item>
</el-form>
</el-card>
<el-divider />
<!-- 筛选栏 -->
<el-card class="mt-4" shadow="never">
<el-form :model="query" inline>
<el-form-item label="任务 ID">
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
</el-form-item>
<el-form-item label="发送时间">
<el-date-picker
v-model="dateRange"
type="datetimerange"
range-separator=""
start-placeholder="开始时间"
end-placeholder="结束时间"
value-format="YYYY-MM-DD[T]HH:mm:ss"
style="width: 360px"
/>
</el-form-item>
<el-form-item label="渠道">
<el-select v-model="query.channelType" placeholder="全部" clearable style="width: 140px">
<el-option label="企微" value="wecom" />
<el-option label="微信个人号" value="wechat_personal" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="query.status" placeholder="全部" clearable style="width: 120px">
<el-option label="待发送" :value="0" />
<el-option label="发送中" :value="1" />
<el-option label="成功" :value="2" />
<el-option label="失败" :value="3" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="handleReset">重置</el-button>
</el-form-item>
</el-form>
</el-card>
<el-table :data="records" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="taskId" label="任务ID" />
<el-table-column prop="targetType" label="目标类型" />
<el-table-column prop="channelType" label="渠道" />
<el-table-column prop="contentType" label="内容类型" />
<el-table-column prop="sendStatus" label="状态">
<template #default="scope">
<el-tag v-if="scope.row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="scope.row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">待发送</el-tag>
<!-- 记录列表 -->
<el-card class="mt-4" shadow="never">
<el-table :data="records" border v-loading="loading">
<el-table-column prop="taskId" label="任务 ID" min-width="180" show-overflow-tooltip />
<el-table-column prop="targetType" label="目标类型" width="110" />
<el-table-column prop="channelType" label="渠道" width="120" />
<el-table-column prop="sendStatus" label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.sendStatus === 0" type="info">待发送</el-tag>
<el-tag v-else-if="row.sendStatus === 1" type="warning">发送中</el-tag>
<el-tag v-else-if="row.sendStatus === 2" type="success">成功</el-tag>
<el-tag v-else-if="row.sendStatus === 3" type="danger">失败</el-tag>
<el-tag v-else type="info">未知</el-tag>
</template>
</el-table-column>
<el-table-column prop="sendTime" label="发送时间" min-width="160" />
<el-table-column prop="finishTime" label="完成时间" min-width="160" />
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button
v-if="row.sendStatus === 0"
link
type="danger"
@click="handleCancel(row.taskId)"
>
取消
</el-button>
</template>
</el-table-column>
<el-table-column prop="createTime" label="创建时间" />
</el-table>
<div class="pagination-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@size-change="handleSizeChange"
@current-change="loadRecords"
/>
</div>
</el-card>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" title="发送详情" size="520px">
<div v-if="currentRecord" class="detail-content">
<div class="detail-item">
<span class="detail-label">任务 ID</span>
<span class="detail-value">{{ currentRecord.taskId }}</span>
</div>
<div class="detail-item">
<span class="detail-label">目标类型</span>
<span class="detail-value">{{ currentRecord.targetType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">目标值</span>
<pre>{{ formatJson(currentRecord.targetValue) }}</pre>
</div>
<div class="detail-item">
<span class="detail-label">渠道</span>
<span class="detail-value">{{ currentRecord.channelType }}</span>
</div>
<div class="detail-item">
<span class="detail-label">内容</span>
<pre>{{ formatJson(currentRecord.contentBody) }}</pre>
</div>
<div class="detail-item">
<span class="detail-label">渠道结果</span>
<pre>{{ formatJson(currentRecord.channelResult) }}</pre>
</div>
<div class="detail-item">
<span class="detail-label">回调地址</span>
<span class="detail-value">{{ currentRecord.callbackUrl || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">发送时间</span>
<span class="detail-value">{{ currentRecord.sendTime || '-' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">完成时间</span>
<span class="detail-value">{{ currentRecord.finishTime || '-' }}</span>
</div>
</div>
</el-drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import { ElMessage } from 'element-plus'
import { testSendMessage, listSendRecords } from '@/api/send'
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
queryRecords,
getStatistics,
cancelTask,
testSend,
type QueryRecordsParams,
} from '@/api/sendRecord'
import { listConversations } from '@/api/channel'
const form = reactive({ targetType: 'conversation', contentType: 'text', channelStrategy: { channel_type: 'wecom', strategy: 'primary' } })
const selectedConversationId = ref<number | null>(null)
const textContent = ref('')
const records = ref<any[]>([])
const conversations = ref<any[]>([])
const targetValue = computed(() => {
if (form.targetType === 'conversation' && selectedConversationId.value) {
return { conversation_id: selectedConversationId.value }
interface RecordItem {
id: number
taskId: string
targetType: string
targetValue?: string
channelType: string
sendStatus: number
sendTime?: string
finishTime?: string
contentBody?: string
channelResult?: string
callbackUrl?: string
}
return {}
const statistics = reactive({
total: 0,
pendingCount: 0,
sendingCount: 0,
successCount: 0,
failedCount: 0,
})
async function loadRecords() {
const res: any = await listSendRecords()
records.value = res.data || []
const query = reactive<QueryRecordsParams>({
taskId: '',
channelType: '',
status: '',
})
const dateRange = ref<string[]>([])
const pagination = reactive({
page: 1,
size: 20,
total: 0,
})
const records = ref<RecordItem[]>([])
const loading = ref(false)
const detailVisible = ref(false)
const currentRecord = ref<RecordItem | null>(null)
const testForm = reactive({
targetType: 'conversation',
contentType: 'text',
channelStrategy: { channel_type: 'wecom', strategy: 'primary' },
})
const selectedConversationId = ref<number | null>(null)
const personIdInput = ref('')
const textContent = ref('')
const conversations = ref<any[]>([])
function buildTargetValue() {
if (testForm.targetType === 'conversation' && selectedConversationId.value) {
return { conversation_id: selectedConversationId.value }
}
if (testForm.targetType === 'person' && personIdInput.value) {
return { person_id: Number(personIdInput.value) }
}
return {}
}
async function loadConversations() {
const res: any = await listConversations()
conversations.value = res.data || []
async function loadRecords() {
loading.value = true
try {
const params: QueryRecordsParams = {
page: pagination.page,
size: pagination.size,
taskId: query.taskId || undefined,
channelType: query.channelType || undefined,
status: query.status === '' ? undefined : query.status,
}
if (dateRange.value && dateRange.value.length === 2) {
params.start = dateRange.value[0]
params.end = dateRange.value[1]
}
const res: any = await queryRecords(params)
const data = res.data || {}
records.value = data.records || data || []
pagination.total = data.total || records.value.length
} catch (e: any) {
ElMessage.error(e.message)
} finally {
loading.value = false
}
}
function handleSearch() {
pagination.page = 1
loadRecords()
}
function handleReset() {
query.taskId = ''
query.channelType = ''
query.status = ''
dateRange.value = []
pagination.page = 1
loadRecords()
}
function handleSizeChange() {
pagination.page = 1
loadRecords()
}
async function loadStatistics() {
const now = new Date()
const start = formatDateTime(new Date(now.getFullYear(), now.getMonth(), now.getDate(), 0, 0, 0))
const end = formatDateTime(now)
try {
const res: any = await getStatistics(start, end)
Object.assign(statistics, res.data || {})
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleTestSend() {
const targetValue = buildTargetValue()
if (Object.keys(targetValue).length === 0) {
ElMessage.warning('请选择或填写目标')
return
}
try {
await testSendMessage({
targetType: form.targetType,
targetValue: targetValue.value,
await testSend({
targetType: testForm.targetType,
targetValue,
contentType: 'text',
content: { text: textContent.value },
channelStrategy: form.channelStrategy,
channelStrategy: testForm.channelStrategy,
})
ElMessage.success('测试发送已提交')
textContent.value = ''
await loadRecords()
await loadStatistics()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCancel(taskId: string) {
try {
await ElMessageBox.confirm('确定取消该任务的待发送记录?', '提示', { type: 'warning' })
await cancelTask(taskId)
ElMessage.success('取消成功')
await loadRecords()
await loadStatistics()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '取消失败')
}
}
}
function openDetail(row: RecordItem) {
currentRecord.value = row
detailVisible.value = true
}
function formatJson(value?: string) {
if (!value) return '-'
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch {
return value
}
}
function formatDateTime(date: Date): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
async function loadConversations() {
try {
const res: any = await listConversations()
conversations.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(() => {
loadRecords()
loadConversations()
loadRecords()
loadStatistics()
})
</script>
<style scoped>
.mt-4 {
margin-top: 16px;
}
.pagination-bar {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.detail-content {
font-size: 14px;
}
.detail-item {
margin-bottom: 16px;
}
.detail-label {
display: block;
color: #606266;
margin-bottom: 6px;
}
.detail-value {
color: #303133;
word-break: break-all;
}
pre {
margin: 0;
padding: 12px;
background: #f5f7fa;
border-radius: 4px;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@@ -1,11 +1,49 @@
<template>
<div>
<el-form :model="form" inline>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建标签</el-button>
</div>
<el-table :data="tags" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tagName" label="名称" />
<el-table-column prop="tagPath" label="路径" show-overflow-tooltip />
<el-table-column prop="level" label="层级" width="100">
<template #default="{ row }">
{{ formatLevel(row.level) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<div class="op-row">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin-left: 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</div>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑标签' : '创建标签'" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="标签名">
<el-input v-model="form.tagName" placeholder="标签名" />
<el-input v-model="dialogForm.tagName" placeholder="标签名" />
</el-form-item>
<el-form-item label="父标签">
<el-select v-model="form.parentId" placeholder="选择父标签">
<el-select v-model="dialogForm.parentId" placeholder="选择父标签" style="width: 100%">
<el-option label="无" :value="0" />
<el-option
v-for="t in tags"
@@ -15,33 +53,72 @@
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<el-table :data="tags" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tagName" label="名称" />
<el-table-column prop="tagPath" label="路径" />
<el-table-column prop="level" label="层级" />
<el-table-column prop="status" label="状态" />
</el-table>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createTag, listTags } from '@/api/crm'
const form = reactive({
tagName: '',
parentId: 0,
})
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTag, deleteTag, listTags, updateTag } from '@/api/crm'
const tags = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
tagName: '',
parentId: 0,
status: 1,
})
function resetDialogForm() {
dialogForm.tagName = ''
dialogForm.parentId = 0
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.tagName = row.tagName ?? ''
dialogForm.parentId = row.parentId ?? 0
dialogForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
function formatLevel(level: number | undefined) {
if (level == null) return '-'
return `${level}`
}
async function loadTags() {
try {
const res: any = await listTags()
@@ -51,15 +128,69 @@ async function loadTags() {
}
}
async function handleCreate() {
async function handleSave() {
if (!dialogForm.tagName) {
ElMessage.warning('请填写标签名')
return
}
try {
const res: any = await createTag(form)
const payload = {
tagName: dialogForm.tagName,
parentId: dialogForm.parentId,
status: dialogForm.status,
}
if (isEdit.value && editingId.value != null) {
await updateTag(editingId.value, payload)
ElMessage.success('更新成功')
} else {
const res: any = await createTag(payload)
tags.value.unshift(res.data)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadTags()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateTag(row.id, {
tagName: row.tagName,
parentId: row.parentId,
status: val,
})
ElMessage.success('状态更新成功')
await loadTags()
} catch (e: any) {
ElMessage.error(e.message)
await loadTags()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除标签「${row.tagName}」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteTag(row.id)
ElMessage.success('删除成功')
await loadTags()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
onMounted(loadTags)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,38 +1,126 @@
<template>
<div>
<h2>租户管理</h2>
<el-form :model="form" inline>
<el-form-item label="租户编码">
<el-input v-model="form.tenantCode" placeholder="租户编码" />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="form.tenantName" placeholder="租户名称" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" @click="openCreateDialog">创建租户</el-button>
</div>
<el-table :data="tenants" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="tenantName" label="租户名称" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="inboundWebhookUrl" label="入站 Webhook" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑租户' : '创建租户'" width="600px">
<el-form :model="dialogForm" label-width="140px">
<el-form-item label="租户编码">
<el-input v-model="dialogForm.tenantCode" :disabled="isEdit" placeholder="租户编码" />
</el-form-item>
<el-form-item label="租户名称">
<el-input v-model="dialogForm.tenantName" placeholder="租户名称" />
</el-form-item>
<el-form-item label="入站 Webhook">
<el-input v-model="dialogForm.inboundWebhookUrl" placeholder="https://example.com/webhook" />
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
<template v-if="!isEdit">
<el-form-item label="初始化管理员">
<el-checkbox v-model="dialogForm.createAdmin">同时创建租户管理员</el-checkbox>
</el-form-item>
<el-form-item v-if="dialogForm.createAdmin" label="管理员账号">
<el-input v-model="dialogForm.initAdminUsername" placeholder="admin" />
</el-form-item>
<el-form-item v-if="dialogForm.createAdmin" label="初始密码">
<el-input v-model="dialogForm.initAdminPassword" type="password" placeholder="初始密码" />
</el-form-item>
</template>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createTenant, listTenants } from '@/api/account'
const form = reactive({
tenantCode: '',
tenantName: '',
})
import { ElMessage, ElMessageBox } from 'element-plus'
import { createTenant, deleteTenant, listTenants, updateTenant } from '@/api/account'
const tenants = ref<any[]>([])
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
tenantCode: '',
tenantName: '',
inboundWebhookUrl: '',
status: 1,
createAdmin: true,
initAdminUsername: 'admin',
initAdminPassword: '',
})
function resetDialogForm() {
dialogForm.tenantCode = ''
dialogForm.tenantName = ''
dialogForm.inboundWebhookUrl = ''
dialogForm.status = 1
dialogForm.createAdmin = true
dialogForm.initAdminUsername = 'admin'
dialogForm.initAdminPassword = ''
}
function fillDialogForm(row: any) {
dialogForm.tenantCode = row.tenantCode ?? ''
dialogForm.tenantName = row.tenantName ?? ''
dialogForm.inboundWebhookUrl = row.inboundWebhookUrl ?? ''
dialogForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
async function loadTenants() {
try {
@@ -43,15 +131,80 @@ async function loadTenants() {
}
}
async function handleCreate() {
async function handleSave() {
if (!dialogForm.tenantCode || !dialogForm.tenantName) {
ElMessage.warning('请填写租户编码和租户名称')
return
}
try {
await createTenant(form)
if (isEdit.value && editingId.value != null) {
await updateTenant(editingId.value, {
tenantName: dialogForm.tenantName,
inboundWebhookUrl: dialogForm.inboundWebhookUrl,
status: dialogForm.status,
})
ElMessage.success('更新成功')
} else {
const payload: any = {
tenantCode: dialogForm.tenantCode,
tenantName: dialogForm.tenantName,
inboundWebhookUrl: dialogForm.inboundWebhookUrl,
}
if (dialogForm.createAdmin) {
if (!dialogForm.initAdminUsername || !dialogForm.initAdminPassword) {
ElMessage.warning('请填写初始化管理员账号和密码')
return
}
payload.initAdminUsername = dialogForm.initAdminUsername
payload.initAdminPassword = dialogForm.initAdminPassword
}
await createTenant(payload)
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleStatusChange(row: any, val: any) {
try {
await updateTenant(row.id, {
tenantName: row.tenantName,
inboundWebhookUrl: row.inboundWebhookUrl,
status: val,
})
ElMessage.success('状态更新成功')
await loadTenants()
} catch (e: any) {
ElMessage.error(e.message)
await loadTenants()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除租户「${row.tenantName}${row.tenantCode})」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteTenant(row.id)
ElMessage.success('删除成功')
await loadTenants()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
onMounted(loadTenants)
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1,62 +1,296 @@
<template>
<div>
<h2>用户管理</h2>
<el-form :model="form" inline>
<el-form-item label="用户名">
<el-input v-model="form.username" placeholder="用户名" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="form.realName" placeholder="真实姓名" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleCreate">创建</el-button>
</el-form-item>
</el-form>
<div class="toolbar">
<el-select
v-model="selectedTenant"
placeholder="选择租户"
clearable
style="width: 200px; margin-right: 12px"
:disabled="!isPlatformAdmin"
@change="loadUsers"
>
<el-option
v-for="t in tenants"
:key="t.id"
:label="`${t.tenantName}${t.tenantCode}`"
:value="t.tenantCode"
/>
</el-select>
<el-button type="primary" @click="openCreateDialog">创建用户</el-button>
</div>
<el-table :data="users" border>
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="tenantCode" label="租户编码" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="realName" label="真实姓名" />
<el-table-column prop="roleCodes" label="角色" />
<el-table-column prop="status" label="状态" />
<el-table-column prop="phone" label="手机号" />
<el-table-column prop="roleCodes" label="角色">
<template #default="{ row }">
{{ formatRoles(row.roleCodes) }}
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button size="small" @click="openEditDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
<el-switch
v-model="row.status"
:active-value="1"
:inactive-value="0"
style="margin: 0 12px"
@change="(val: any) => handleStatusChange(row, val)"
/>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑用户' : '创建用户'" width="600px">
<el-form :model="dialogForm" label-width="100px">
<el-form-item label="租户">
<el-select v-model="dialogForm.tenantCode" :disabled="isEdit" style="width: 100%">
<el-option
v-for="t in tenants"
:key="t.id"
:label="`${t.tenantName}${t.tenantCode}`"
:value="t.tenantCode"
/>
</el-select>
</el-form-item>
<el-form-item label="用户名">
<el-input v-model="dialogForm.username" :disabled="isEdit" placeholder="用户名" />
</el-form-item>
<el-form-item v-if="!isEdit" label="密码">
<el-input v-model="dialogForm.password" type="password" placeholder="密码" />
</el-form-item>
<el-form-item label="真实姓名">
<el-input v-model="dialogForm.realName" placeholder="真实姓名" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model="dialogForm.phone" placeholder="手机号" />
</el-form-item>
<el-form-item label="角色">
<el-select v-model="dialogForm.roleCodes" multiple placeholder="选择角色" style="width: 100%">
<el-option
v-for="r in roles"
:key="r.id"
:label="r.roleName"
:value="r.roleCode"
/>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-switch
v-model="dialogForm.status"
:active-value="1"
:inactive-value="0"
active-text="启用"
inactive-text="禁用"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSave">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { createUser, listUsers } from '@/api/account'
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import {
createUser,
deleteUser,
listRoles,
listTenants,
listUsers,
updateUser,
} from '@/api/account'
const form = reactive({
const authStore = useAuthStore()
const isPlatformAdmin = computed(() => authStore.roleCodes.includes('platform_admin'))
const users = ref<any[]>([])
const tenants = ref<any[]>([])
const roles = ref<any[]>([])
const selectedTenant = ref(authStore.tenantCode || '')
const dialogVisible = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const dialogForm = reactive({
tenantCode: '',
username: '',
password: '',
realName: '',
phone: '',
roleCodes: [] as string[],
status: 1,
})
const users = ref<any[]>([])
function resetDialogForm() {
dialogForm.tenantCode = isPlatformAdmin.value ? '' : (authStore.tenantCode || '')
dialogForm.username = ''
dialogForm.password = ''
dialogForm.realName = ''
dialogForm.phone = ''
dialogForm.roleCodes = []
dialogForm.status = 1
}
function fillDialogForm(row: any) {
dialogForm.tenantCode = row.tenantCode ?? ''
dialogForm.username = row.username ?? ''
dialogForm.password = ''
dialogForm.realName = row.realName ?? ''
dialogForm.phone = row.phone ?? ''
dialogForm.roleCodes = row.roleCodes || []
dialogForm.status = row.status ?? 1
}
function openCreateDialog() {
isEdit.value = false
editingId.value = null
resetDialogForm()
dialogVisible.value = true
}
function openEditDialog(row: any) {
isEdit.value = true
editingId.value = row.id
fillDialogForm(row)
dialogVisible.value = true
}
function formatRoles(codes: string[] | undefined) {
if (!codes || codes.length === 0) return '-'
return codes
.map((code) => {
const role = roles.value.find((r) => r.roleCode === code)
return role ? role.roleName : code
})
.join('')
}
async function loadTenants() {
try {
const res: any = await listTenants()
tenants.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadRoles() {
try {
const res: any = await listRoles()
roles.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function loadUsers() {
try {
const res: any = await listUsers()
const res: any = await listUsers(selectedTenant.value || undefined)
users.value = res.data || []
} catch (e: any) {
ElMessage.error(e.message)
}
}
async function handleCreate() {
async function handleSave() {
if (!dialogForm.username) {
ElMessage.warning('请填写用户名')
return
}
if (!isEdit.value && !dialogForm.password) {
ElMessage.warning('请填写密码')
return
}
if (!dialogForm.tenantCode) {
ElMessage.warning('请选择租户')
return
}
try {
await createUser({ ...form, roleCodes: ['tenant_admin'] })
if (isEdit.value && editingId.value != null) {
await updateUser(editingId.value, {
realName: dialogForm.realName,
phone: dialogForm.phone,
status: dialogForm.status,
roleCodes: dialogForm.roleCodes,
})
ElMessage.success('更新成功')
} else {
await createUser(dialogForm.tenantCode, {
username: dialogForm.username,
password: dialogForm.password,
realName: dialogForm.realName,
phone: dialogForm.phone,
roleCodes: dialogForm.roleCodes,
})
ElMessage.success('创建成功')
}
dialogVisible.value = false
await loadUsers()
} catch (e: any) {
ElMessage.error(e.message)
}
}
onMounted(loadUsers)
async function handleStatusChange(row: any, val: any) {
try {
await updateUser(row.id, {
realName: row.realName,
phone: row.phone,
status: val,
roleCodes: row.roleCodes || [],
})
ElMessage.success('状态更新成功')
await loadUsers()
} catch (e: any) {
ElMessage.error(e.message)
await loadUsers()
}
}
async function handleDelete(row: any) {
try {
await ElMessageBox.confirm(
`确定删除用户「${row.username}${row.tenantCode})」吗?`,
'删除确认',
{ confirmButtonText: '删除', cancelButtonText: '取消', type: 'warning' }
)
await deleteUser(row.id)
ElMessage.success('删除成功')
await loadUsers()
} catch (e: any) {
if (e !== 'cancel') {
ElMessage.error(e.message || '删除失败')
}
}
}
onMounted(() => {
loadTenants()
loadRoles()
loadUsers()
})
</script>
<style scoped>
.toolbar {
margin-bottom: 16px;
}
</style>

View File

@@ -1 +1 @@
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/flow.ts","./src/api/send.ts","./src/router/index.ts","./src/app.vue","./src/components/layout.vue","./src/views/channelaccount.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/inboundflow.vue","./src/views/person.vue","./src/views/sendrecord.vue","./src/views/tag.vue"],"version":"6.0.3"}
{"root":["./src/main.ts","./src/vite-env.d.ts","./src/api/account.ts","./src/api/channel.ts","./src/api/channelaccount.ts","./src/api/channelsubject.ts","./src/api/client.ts","./src/api/crm.ts","./src/api/dashboard.ts","./src/api/flow.ts","./src/api/inbound.ts","./src/api/inboundflow.ts","./src/api/inboundwebhook.ts","./src/api/ivrflow.ts","./src/api/send.ts","./src/api/sendpolicy.ts","./src/api/sendrecord.ts","./src/router/index.ts","./src/stores/auth.ts","./src/app.vue","./src/components/layout.vue","./src/views/channelaccount.vue","./src/views/channelsubject.vue","./src/views/conversation.vue","./src/views/dashboard.vue","./src/views/flowtrace.vue","./src/views/inboundflow.vue","./src/views/inboundwebhook.vue","./src/views/ivrflow.vue","./src/views/login.vue","./src/views/messagetest.vue","./src/views/person.vue","./src/views/sendpolicy.vue","./src/views/sendrecord.vue","./src/views/tag.vue","./src/views/tenantmanagement.vue","./src/views/usermanagement.vue"],"version":"6.0.3"}

View File

@@ -1,10 +1,14 @@
# syntax=docker/dockerfile:1
FROM eclipse-temurin:21-jre-alpine
FROM eclipse-temurin:21-jre
RUN apk add --no-cache curl
RUN groupadd -g 1001 app && \
useradd -u 1001 -g app -s /usr/sbin/nologin -M app
WORKDIR /app
COPY target/*.jar app.jar
RUN chown app:app app.jar
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]

View File

@@ -70,6 +70,11 @@
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>1.39.0</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0-M6</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>

View File

@@ -5,7 +5,10 @@ import com.sino.mci.admin.dto.CreateUserRequest;
import com.sino.mci.admin.dto.LoginRequest;
import com.sino.mci.admin.dto.PlatformUserResponse;
import com.sino.mci.admin.dto.TokenResponse;
import com.sino.mci.admin.dto.UpdateTenantRequest;
import com.sino.mci.admin.dto.UpdateUserRequest;
import com.sino.mci.admin.dto.UserResponse;
import com.sino.mci.admin.entity.PlatformRole;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.admin.service.AccountService;
@@ -28,11 +31,29 @@ public class AccountController {
return ApiResponse.ok(accountService.createTenant(request));
}
@PutMapping("/tenant/{id}")
public ApiResponse<Tenant> updateTenant(
@PathVariable Long id,
@Valid @RequestBody UpdateTenantRequest request) {
return ApiResponse.ok(accountService.updateTenant(id, request));
}
@DeleteMapping("/tenant/{id}")
public ApiResponse<Void> deleteTenant(@PathVariable Long id) {
accountService.deleteTenant(id);
return ApiResponse.ok();
}
@GetMapping("/tenant/list")
public ApiResponse<List<Tenant>> listTenants() {
return ApiResponse.ok(accountService.listTenants());
}
@GetMapping("/role/list")
public ApiResponse<List<PlatformRole>> listRoles() {
return ApiResponse.ok(accountService.listRoles());
}
@PostMapping("/user")
public ApiResponse<UserResponse> createUser(
@RequestHeader("X-Tenant-Code") String tenantCode,
@@ -40,9 +61,23 @@ public class AccountController {
return ApiResponse.ok(accountService.createUser(tenantCode, request));
}
@PutMapping("/user/{id}")
public ApiResponse<UserResponse> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
return ApiResponse.ok(accountService.updateUser(id, request));
}
@DeleteMapping("/user/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id) {
accountService.deleteUser(id);
return ApiResponse.ok();
}
@GetMapping("/user/list")
public ApiResponse<List<PlatformUserResponse>> listUsers() {
return ApiResponse.ok(accountService.listUsers(AuthContext.currentTenantCode()));
public ApiResponse<List<PlatformUserResponse>> listUsers(
@RequestParam(required = false) String tenantCode) {
return ApiResponse.ok(accountService.listUsers(tenantCode));
}
@PostMapping("/login")

View File

@@ -0,0 +1,36 @@
package com.sino.mci.admin.controller;
import com.sino.mci.admin.dto.TestWebhookRequest;
import com.sino.mci.admin.dto.TestWebhookResponse;
import com.sino.mci.admin.dto.TenantWebhookResponse;
import com.sino.mci.admin.dto.UpdateTenantWebhookRequest;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.admin.service.TenantService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/account/tenant")
@RequiredArgsConstructor
public class TenantController {
private final TenantService tenantService;
@GetMapping("/webhook")
public ApiResponse<TenantWebhookResponse> getWebhookConfig() {
return ApiResponse.ok(tenantService.getWebhookConfig(AuthContext.currentTenantCode()));
}
@PostMapping("/webhook")
public ApiResponse<TenantWebhookResponse> updateWebhookConfig(
@Valid @RequestBody UpdateTenantWebhookRequest request) {
return ApiResponse.ok(tenantService.updateWebhookConfig(AuthContext.currentTenantCode(), request));
}
@PostMapping("/webhook/test")
public ApiResponse<TestWebhookResponse> testWebhook(@Valid @RequestBody TestWebhookRequest request) {
return ApiResponse.ok(tenantService.testWebhook(AuthContext.currentTenantCode(), request));
}
}

View File

@@ -13,4 +13,8 @@ public class CreateTenantRequest {
private String tenantName;
private String inboundWebhookUrl;
private String initAdminUsername;
private String initAdminPassword;
}

View File

@@ -0,0 +1,16 @@
package com.sino.mci.admin.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
@Data
public class TenantWebhookResponse {
@JsonProperty("inbound_webhook_url")
private String inboundWebhookUrl;
@JsonProperty("auth_config")
private Map<String, Object> authConfig;
}

View File

@@ -0,0 +1,11 @@
package com.sino.mci.admin.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class TestWebhookRequest {
@NotBlank
private String url;
}

View File

@@ -0,0 +1,14 @@
package com.sino.mci.admin.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TestWebhookResponse {
private boolean success;
private String message;
}

View File

@@ -0,0 +1,15 @@
package com.sino.mci.admin.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateTenantRequest {
@NotBlank
private String tenantName;
private String inboundWebhookUrl;
private Integer status;
}

View File

@@ -0,0 +1,18 @@
package com.sino.mci.admin.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class UpdateTenantWebhookRequest {
@NotBlank
@JsonProperty("inbound_webhook_url")
private String inboundWebhookUrl;
@JsonProperty("auth_config")
private Map<String, Object> authConfig;
}

View File

@@ -0,0 +1,19 @@
package com.sino.mci.admin.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.List;
@Data
public class UpdateUserRequest {
@NotBlank
private String realName;
private String phone;
private Integer status;
private List<String> roleCodes;
}

View File

@@ -14,5 +14,6 @@ public class Tenant extends BaseEntity {
private String tenantName;
private String authConfig;
private String inboundWebhookUrl;
private String extInfo;
private Integer status;
}

View File

@@ -0,0 +1,117 @@
package com.sino.mci.admin.init;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.entity.PlatformRole;
import com.sino.mci.admin.entity.PlatformUser;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.entity.UserRole;
import com.sino.mci.admin.repository.PlatformRoleMapper;
import com.sino.mci.admin.repository.PlatformUserMapper;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.admin.repository.UserRoleMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class DataInitializer implements CommandLineRunner {
private static final String DEFAULT_TENANT_CODE = "system";
private static final String DEFAULT_TENANT_NAME = "系统默认租户";
private static final String GLOBAL_ADMIN_USERNAME = "superadmin";
private static final String GLOBAL_ADMIN_PASSWORD = "admin123";
private static final String GLOBAL_ADMIN_ROLE_CODE = "platform_admin";
private static final String TENANT_ADMIN_ROLE_CODE = "tenant_admin";
private final TenantMapper tenantMapper;
private final PlatformUserMapper userMapper;
private final PlatformRoleMapper roleMapper;
private final UserRoleMapper userRoleMapper;
@Override
@Transactional
public void run(String... args) {
initDefaultTenant();
initPlatformAdminRole();
initTenantAdminRole();
initGlobalAdmin();
}
private void initDefaultTenant() {
Tenant existing = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, DEFAULT_TENANT_CODE));
if (existing != null) {
return;
}
Tenant tenant = new Tenant();
tenant.setTenantCode(DEFAULT_TENANT_CODE);
tenant.setTenantName(DEFAULT_TENANT_NAME);
tenant.setStatus(1);
tenantMapper.insert(tenant);
log.info("已创建默认租户: {}", DEFAULT_TENANT_CODE);
}
private void initPlatformAdminRole() {
PlatformRole existing = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, GLOBAL_ADMIN_ROLE_CODE));
if (existing != null) {
return;
}
PlatformRole role = new PlatformRole();
role.setRoleCode(GLOBAL_ADMIN_ROLE_CODE);
role.setRoleName("平台管理员");
role.setPermissions("[\"tenant:manage\", \"user:manage\", \"*\"]");
role.setStatus(1);
roleMapper.insert(role);
log.info("已创建平台管理员角色: {}", GLOBAL_ADMIN_ROLE_CODE);
}
private void initTenantAdminRole() {
PlatformRole existing = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, TENANT_ADMIN_ROLE_CODE));
if (existing != null) {
return;
}
PlatformRole role = new PlatformRole();
role.setRoleCode(TENANT_ADMIN_ROLE_CODE);
role.setRoleName("租户管理员");
role.setPermissions("[\"user:manage\", \"tenant:view\"]");
role.setStatus(1);
roleMapper.insert(role);
log.info("已创建租户管理员角色: {}", TENANT_ADMIN_ROLE_CODE);
}
private void initGlobalAdmin() {
PlatformUser existing = userMapper.selectOne(
new LambdaQueryWrapper<PlatformUser>()
.eq(PlatformUser::getTenantCode, DEFAULT_TENANT_CODE)
.eq(PlatformUser::getUsername, GLOBAL_ADMIN_USERNAME));
if (existing != null) {
return;
}
PlatformUser user = new PlatformUser();
user.setTenantCode(DEFAULT_TENANT_CODE);
user.setUsername(GLOBAL_ADMIN_USERNAME);
user.setPassword(BCrypt.hashpw(GLOBAL_ADMIN_PASSWORD, BCrypt.gensalt()));
user.setRealName("平台管理员");
user.setStatus(1);
userMapper.insert(user);
PlatformRole role = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, GLOBAL_ADMIN_ROLE_CODE));
if (role != null) {
UserRole userRole = new UserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRoleMapper.insert(userRole);
}
log.info("已创建全局管理员: {}/{}", DEFAULT_TENANT_CODE, GLOBAL_ADMIN_USERNAME);
}
}

View File

@@ -7,6 +7,8 @@ import com.sino.mci.admin.dto.CreateUserRequest;
import com.sino.mci.admin.dto.LoginResponse;
import com.sino.mci.admin.dto.PlatformUserResponse;
import com.sino.mci.admin.dto.TokenResponse;
import com.sino.mci.admin.dto.UpdateTenantRequest;
import com.sino.mci.admin.dto.UpdateUserRequest;
import com.sino.mci.admin.dto.UserResponse;
import com.sino.mci.admin.entity.PlatformRole;
import com.sino.mci.admin.entity.PlatformUser;
@@ -16,6 +18,7 @@ import com.sino.mci.admin.repository.PlatformRoleMapper;
import com.sino.mci.admin.repository.PlatformUserMapper;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.admin.repository.UserRoleMapper;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.exception.BusinessException;
import com.sino.mci.exception.UnauthorizedException;
import lombok.RequiredArgsConstructor;
@@ -23,14 +26,21 @@ import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AccountService {
private static final String TENANT_ADMIN_ROLE_CODE = "tenant_admin";
private static final String PLATFORM_ADMIN_ROLE_CODE = "platform_admin";
private static final String SYSTEM_TENANT_CODE = "system";
private static final String GLOBAL_ADMIN_USERNAME = "superadmin";
private final TenantMapper tenantMapper;
private final PlatformUserMapper userMapper;
private final PlatformRoleMapper roleMapper;
@@ -50,11 +60,51 @@ public class AccountService {
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
tenant.setStatus(1);
tenantMapper.insert(tenant);
if (StringUtils.hasText(request.getInitAdminUsername()) && StringUtils.hasText(request.getInitAdminPassword())) {
createUserInternal(tenant.getTenantCode(), request.getInitAdminUsername(), request.getInitAdminPassword(),
request.getInitAdminUsername(), null, List.of(TENANT_ADMIN_ROLE_CODE));
}
return tenant;
}
@Transactional
public Tenant updateTenant(Long id, UpdateTenantRequest request) {
Tenant tenant = tenantMapper.selectById(id);
if (tenant == null) {
throw new BusinessException("租户不存在: " + id);
}
tenant.setTenantName(request.getTenantName());
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
if (request.getStatus() != null) {
tenant.setStatus(request.getStatus());
}
tenantMapper.updateById(tenant);
return tenant;
}
@Transactional
public void deleteTenant(Long id) {
Tenant tenant = tenantMapper.selectById(id);
if (tenant == null) {
throw new BusinessException("租户不存在: " + id);
}
if (SYSTEM_TENANT_CODE.equals(tenant.getTenantCode())) {
throw new BusinessException("不能删除系统默认租户");
}
tenantMapper.deleteById(id);
}
@Transactional
public UserResponse createUser(String tenantCode, CreateUserRequest request) {
PlatformUser user = createUserInternal(tenantCode, request.getUsername(), request.getPassword(),
request.getRealName(), request.getPhone(), request.getRoleCodes());
return toUserResponse(user);
}
private PlatformUser createUserInternal(String tenantCode, String username, String password,
String realName, String phone, List<String> roleCodes) {
Tenant tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
if (tenant == null) {
@@ -64,37 +114,57 @@ public class AccountService {
PlatformUser existing = userMapper.selectOne(
new LambdaQueryWrapper<PlatformUser>()
.eq(PlatformUser::getTenantCode, tenantCode)
.eq(PlatformUser::getUsername, request.getUsername()));
.eq(PlatformUser::getUsername, username));
if (existing != null) {
throw new BusinessException("用户名已存在: " + request.getUsername());
throw new BusinessException("用户名已存在: " + username);
}
PlatformUser user = new PlatformUser();
user.setTenantCode(tenantCode);
user.setUsername(request.getUsername());
user.setPassword(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()));
user.setRealName(request.getRealName());
user.setPhone(request.getPhone());
user.setUsername(username);
user.setPassword(BCrypt.hashpw(password, BCrypt.gensalt()));
user.setRealName(realName);
user.setPhone(phone);
user.setStatus(1);
userMapper.insert(user);
if (!CollectionUtils.isEmpty(request.getRoleCodes())) {
for (String roleCode : request.getRoleCodes()) {
PlatformRole role = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, roleCode));
if (role == null) {
throw new BusinessException("角色不存在: " + roleCode);
}
UserRole userRole = new UserRole();
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRoleMapper.insert(userRole);
}
bindUserRoles(user.getId(), roleCodes);
return user;
}
@Transactional
public UserResponse updateUser(Long id, UpdateUserRequest request) {
PlatformUser user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在: " + id);
}
user.setRealName(request.getRealName());
user.setPhone(request.getPhone());
if (request.getStatus() != null) {
user.setStatus(request.getStatus());
}
userMapper.updateById(user);
bindUserRoles(user.getId(), request.getRoleCodes());
return toUserResponse(user);
}
@Transactional
public void deleteUser(Long id) {
PlatformUser user = userMapper.selectById(id);
if (user == null) {
throw new BusinessException("用户不存在: " + id);
}
if (SYSTEM_TENANT_CODE.equals(user.getTenantCode()) && GLOBAL_ADMIN_USERNAME.equals(user.getUsername())) {
throw new BusinessException("不能删除全局管理员");
}
AuthContext.AuthUser current = AuthContext.get();
if (current != null && Objects.equals(current.getUserId(), user.getId())) {
throw new BusinessException("不能删除当前登录用户");
}
userMapper.deleteById(id);
}
public TokenResponse login(String tenantCode, String username, String password) {
PlatformUser user = userMapper.selectOne(
new LambdaQueryWrapper<PlatformUser>()
@@ -125,12 +195,48 @@ public class AccountService {
return tenantMapper.selectList(null);
}
public List<PlatformRole> listRoles() {
return roleMapper.selectList(null);
}
public List<PlatformUserResponse> listUsers(String tenantCode) {
List<PlatformUser> users = userMapper.selectList(
new LambdaQueryWrapper<PlatformUser>().eq(PlatformUser::getTenantCode, tenantCode));
LambdaQueryWrapper<PlatformUser> wrapper = new LambdaQueryWrapper<>();
if (StringUtils.hasText(tenantCode)) {
wrapper.eq(PlatformUser::getTenantCode, tenantCode);
} else if (!isPlatformAdmin()) {
String currentTenant = AuthContext.currentTenantCode();
if (currentTenant != null) {
wrapper.eq(PlatformUser::getTenantCode, currentTenant);
}
}
List<PlatformUser> users = userMapper.selectList(wrapper);
return users.stream().map(this::toPlatformUserResponse).collect(Collectors.toList());
}
private boolean isPlatformAdmin() {
AuthContext.AuthUser current = AuthContext.get();
return current != null && current.getRoleCodes() != null
&& current.getRoleCodes().contains(PLATFORM_ADMIN_ROLE_CODE);
}
private void bindUserRoles(Long userId, List<String> roleCodes) {
if (CollectionUtils.isEmpty(roleCodes)) {
return;
}
userRoleMapper.delete(new LambdaQueryWrapper<UserRole>().eq(UserRole::getUserId, userId));
for (String roleCode : roleCodes) {
PlatformRole role = roleMapper.selectOne(
new LambdaQueryWrapper<PlatformRole>().eq(PlatformRole::getRoleCode, roleCode));
if (role == null) {
throw new BusinessException("角色不存在: " + roleCode);
}
UserRole userRole = new UserRole();
userRole.setUserId(userId);
userRole.setRoleId(role.getId());
userRoleMapper.insert(userRole);
}
}
private UserResponse toUserResponse(PlatformUser user) {
UserResponse response = new UserResponse();
response.setId(user.getId());

View File

@@ -0,0 +1,129 @@
package com.sino.mci.admin.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.dto.TestWebhookRequest;
import com.sino.mci.admin.dto.TestWebhookResponse;
import com.sino.mci.admin.dto.TenantWebhookResponse;
import com.sino.mci.admin.dto.UpdateTenantWebhookRequest;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.exception.BusinessException;
import com.sino.mci.inbound.webhook.WebhookSignature;
import com.sino.mci.inbound.webhook.WebhookSignatureService;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.LinkedHashMap;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class TenantService {
private static final String AUTH_CONFIG_WEBHOOK_SECRET = "webhook_secret";
private static final String AUTH_CONFIG_SIGN_ALGORITHM = "sign_algorithm";
private static final String DEFAULT_ALGORITHM = "hmac-sha256";
private final TenantMapper tenantMapper;
private final RestTemplate restTemplate;
private final WebhookSignatureService signatureService;
public TenantWebhookResponse getWebhookConfig(String tenantCode) {
Tenant tenant = getTenant(tenantCode);
TenantWebhookResponse response = new TenantWebhookResponse();
response.setInboundWebhookUrl(tenant.getInboundWebhookUrl() != null ? tenant.getInboundWebhookUrl() : "");
response.setAuthConfig(parseAuthConfig(tenant.getAuthConfig()));
return response;
}
@Transactional
public TenantWebhookResponse updateWebhookConfig(String tenantCode, UpdateTenantWebhookRequest request) {
Tenant tenant = getTenant(tenantCode);
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
tenant.setAuthConfig(JsonUtils.toJson(request.getAuthConfig()));
tenantMapper.updateById(tenant);
return getWebhookConfig(tenantCode);
}
public TestWebhookResponse testWebhook(String tenantCode, TestWebhookRequest request) {
Tenant tenant = getTenant(tenantCode);
Map<String, Object> authConfig = parseAuthConfig(tenant.getAuthConfig());
String secret = getString(authConfig, AUTH_CONFIG_WEBHOOK_SECRET);
if (!StringUtils.hasText(secret)) {
return new TestWebhookResponse(false, "未配置 webhook_secret无法签名测试请求");
}
String algorithm = getString(authConfig, AUTH_CONFIG_SIGN_ALGORITHM);
if (!StringUtils.hasText(algorithm)) {
algorithm = DEFAULT_ALGORITHM;
}
String payloadJson = JsonUtils.toJson(buildSamplePayload(tenantCode));
WebhookSignature signature = signatureService.sign(tenantCode, payloadJson, secret, algorithm);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Msg-Platform-Signature", signature.signature());
headers.set("X-Msg-Platform-Timestamp", String.valueOf(signature.timestamp()));
headers.set("X-Msg-Platform-Tenant", tenantCode);
HttpEntity<String> entity = new HttpEntity<>(payloadJson, headers);
try {
ResponseEntity<String> response = restTemplate.exchange(
request.getUrl(), HttpMethod.POST, entity, String.class);
if (response.getStatusCode().is2xxSuccessful()) {
return new TestWebhookResponse(true, "测试推送成功HTTP " + response.getStatusCode());
}
return new TestWebhookResponse(false, "测试推送失败HTTP " + response.getStatusCode());
} catch (RestClientException e) {
log.warn("Webhook test failed for tenant {} to {}", tenantCode, request.getUrl(), e);
return new TestWebhookResponse(false, "测试推送失败: " + e.getMessage());
}
}
private Tenant getTenant(String tenantCode) {
Tenant tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
if (tenant == null) {
throw new BusinessException("租户不存在: " + tenantCode);
}
return tenant;
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseAuthConfig(String authConfigJson) {
if (authConfigJson == null || authConfigJson.isBlank()) {
return new LinkedHashMap<>();
}
Map<String, Object> config = JsonUtils.fromJson(authConfigJson, Map.class);
return config != null ? config : new LinkedHashMap<>();
}
private String getString(Map<String, Object> config, String key) {
Object value = config.get(key);
return value != null ? value.toString() : null;
}
private Map<String, Object> buildSamplePayload(String tenantCode) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("event_id", "test_evt_001");
payload.put("tenant_code", tenantCode);
payload.put("channel_type", "wecom");
payload.put("direction", "in");
payload.put("event_type", "message");
payload.put("content", Map.of("text", "这是一条 webhook 测试消息"));
payload.put("timestamp", System.currentTimeMillis() / 1000);
return payload;
}
}

View File

@@ -5,6 +5,7 @@ import com.sino.mci.channel.dto.ChannelAccountInitRequest;
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
import com.sino.mci.channel.dto.HeartbeatRequest;
import com.sino.mci.channel.dto.SyncResult;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.service.ChannelAccountService;
import com.sino.mci.shared.web.ApiResponse;
@@ -28,6 +29,37 @@ public class ChannelAccountController {
return ApiResponse.ok(accountService.createAccount(tenantCode, request));
}
@PutMapping("/{id}")
public ApiResponse<?> updateAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody CreateChannelAccountRequest request) {
return ApiResponse.ok(accountService.updateAccount(id, tenantCode, request));
}
@DeleteMapping("/{id}")
public ApiResponse<?> deleteAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
accountService.deleteAccount(id, tenantCode);
return ApiResponse.ok(null);
}
@PatchMapping("/{id}/status")
public ApiResponse<?> updateAccountStatus(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody UpdateStatusRequest request) {
return ApiResponse.ok(accountService.updateAccountStatus(id, tenantCode, request));
}
@GetMapping("/{id}/status")
public ApiResponse<ChannelAccount> getAccountStatus(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
return ApiResponse.ok(accountService.getAccountStatus(id, tenantCode));
}
@PostMapping("/heartbeat")
public ApiResponse<ChannelAccount> heartbeat(@Valid @RequestBody HeartbeatRequest request) {
return ApiResponse.ok(accountService.heartbeat(request.getAccountId(), request));

View File

@@ -2,6 +2,7 @@ package com.sino.mci.channel.controller;
import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.service.ChannelSubjectService;
import com.sino.mci.shared.web.ApiResponse;
@@ -25,6 +26,30 @@ public class ChannelSubjectController {
return ApiResponse.ok(subjectService.createSubject(tenantCode, request));
}
@PutMapping("/{id}")
public ApiResponse<?> updateSubject(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody CreateChannelSubjectRequest request) {
return ApiResponse.ok(subjectService.updateSubject(id, tenantCode, request));
}
@DeleteMapping("/{id}")
public ApiResponse<?> deleteSubject(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id) {
subjectService.deleteSubject(id, tenantCode);
return ApiResponse.ok(null);
}
@PatchMapping("/{id}/status")
public ApiResponse<?> updateSubjectStatus(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("id") Long id,
@Valid @RequestBody UpdateStatusRequest request) {
return ApiResponse.ok(subjectService.updateSubjectStatus(id, tenantCode, request));
}
@GetMapping("/list")
public ApiResponse<List<ChannelSubject>> listSubjects() {
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));

View File

@@ -4,6 +4,7 @@ import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.channel.dto.BindConversationAccountRequest;
import com.sino.mci.channel.dto.BindConversationTagsRequest;
import com.sino.mci.channel.dto.CreateConversationRequest;
import com.sino.mci.channel.dto.UpdateConversationRequest;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.service.ConversationService;
import com.sino.mci.shared.web.ApiResponse;
@@ -39,6 +40,22 @@ public class ConversationController {
return ApiResponse.ok(conversationService.getConversation(tenantCode, conversationId));
}
@PutMapping("/{conversation_id}")
public ApiResponse<?> updateConversation(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("conversation_id") Long conversationId,
@Valid @RequestBody UpdateConversationRequest request) {
return ApiResponse.ok(conversationService.updateConversation(tenantCode, conversationId, request));
}
@DeleteMapping("/{conversation_id}")
public ApiResponse<?> deleteConversation(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("conversation_id") Long conversationId) {
conversationService.deleteConversation(tenantCode, conversationId);
return ApiResponse.ok();
}
@PostMapping("/{conversation_id}/bind-account")
public ApiResponse<?> bindAccount(
@RequestHeader("X-Tenant-Code") String tenantCode,

View File

@@ -15,6 +15,8 @@ public class ChannelAccountInitRequest {
@NotNull
private Integer accountType;
private Long accountId;
private Long subjectId;
private Map<String, Object> config;

View File

@@ -0,0 +1,18 @@
package com.sino.mci.channel.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.util.Map;
@Data
public class UpdateConversationRequest {
@NotBlank
private String conversationName;
private Long ownerAccountId;
private Long subjectId;
private Integer status;
private Map<String, Object> receiveConfig;
}

View File

@@ -0,0 +1,11 @@
package com.sino.mci.channel.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Data
public class UpdateStatusRequest {
@NotNull
private Integer status;
}

View File

@@ -4,6 +4,7 @@ import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
@@ -41,4 +42,14 @@ public class RabbitConfig {
template.setMessageConverter(new Jackson2JsonMessageConverter());
return template;
}
@Bean
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
factory.setConnectionFactory(connectionFactory);
Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter(
"com.sino.mci.*", "java.util", "java.lang");
factory.setMessageConverter(converter);
return factory;
}
}

View File

@@ -0,0 +1,102 @@
package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.common.model.ChannelType;
import com.sino.mci.channel.entity.ChannelAccount;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.repository.ChannelAccountConversationMapper;
import com.sino.mci.channel.repository.ChannelAccountMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
@Slf4j
@Service
@RequiredArgsConstructor
public class AccountSelector {
private static final int BINDING_SEND_PRIMARY = 1;
private static final int BINDING_SEND_BACKUP = 2;
private static final int STATUS_ENABLED = 1;
private static final int ONLINE_STATUS_ONLINE = 1;
private static final int RISK_LEVEL_HIGH = 3;
private static final int ACCOUNT_TYPE_PYWECHAT = 3;
private static final int HEARTBEAT_TIMEOUT_MINUTES = 5;
private final ChannelAccountConversationMapper accountConversationMapper;
private final ChannelAccountMapper accountMapper;
public Long selectSendAccount(String tenantCode, Long conversationId, ChannelType channelType, String preferredStrategy) {
List<Long> accounts = selectSendAccounts(tenantCode, conversationId, channelType, preferredStrategy);
return CollectionUtils.isEmpty(accounts) ? null : accounts.get(0);
}
public List<Long> selectSendAccounts(String tenantCode, Long conversationId, ChannelType channelType, String preferredStrategy) {
if (conversationId == null || channelType == null) {
return Collections.emptyList();
}
List<ChannelAccountConversation> bindings = accountConversationMapper.selectList(
new LambdaQueryWrapper<ChannelAccountConversation>()
.eq(ChannelAccountConversation::getConversationId, conversationId)
.in(ChannelAccountConversation::getBindingType, BINDING_SEND_PRIMARY, BINDING_SEND_BACKUP)
.eq(ChannelAccountConversation::getStatus, STATUS_ENABLED)
.orderByAsc(ChannelAccountConversation::getBindingType)
.orderByAsc(ChannelAccountConversation::getSortOrder));
if (CollectionUtils.isEmpty(bindings)) {
return Collections.emptyList();
}
if ("backup".equals(preferredStrategy)) {
bindings.sort(Comparator.comparingInt(ChannelAccountConversation::getBindingType).reversed()
.thenComparingInt(b -> b.getSortOrder() == null ? 0 : b.getSortOrder()));
}
List<Long> healthyAccountIds = new ArrayList<>();
for (ChannelAccountConversation binding : bindings) {
ChannelAccount account = accountMapper.selectById(binding.getAccountId());
if (isHealthy(account, tenantCode, channelType)) {
healthyAccountIds.add(account.getId());
}
}
return healthyAccountIds;
}
private boolean isHealthy(ChannelAccount account, String tenantCode, ChannelType channelType) {
if (account == null) {
return false;
}
if (tenantCode != null && !tenantCode.equals(account.getTenantCode())) {
return false;
}
if (account.getStatus() == null || account.getStatus() != STATUS_ENABLED) {
return false;
}
if (!channelType.name().equalsIgnoreCase(account.getChannelType())) {
return false;
}
if (account.getRiskLevel() != null && account.getRiskLevel() >= RISK_LEVEL_HIGH) {
return false;
}
if (account.getAccountType() != null && account.getAccountType() == ACCOUNT_TYPE_PYWECHAT) {
if (account.getOnlineStatus() == null || account.getOnlineStatus() != ONLINE_STATUS_ONLINE) {
return false;
}
LocalDateTime lastHeartbeat = account.getLastHeartbeat();
if (lastHeartbeat == null
|| lastHeartbeat.isBefore(LocalDateTime.now().minusMinutes(HEARTBEAT_TIMEOUT_MINUTES))) {
return false;
}
}
return true;
}
}

View File

@@ -2,6 +2,7 @@ package com.sino.mci.channel.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
import com.sino.mci.channel.dto.UpdateStatusRequest;
import com.sino.mci.channel.entity.ChannelSubject;
import com.sino.mci.channel.repository.ChannelSubjectMapper;
import com.sino.mci.exception.BusinessException;
@@ -25,6 +26,14 @@ public class ChannelSubjectService {
.orderByDesc(ChannelSubject::getId));
}
public ChannelSubject getSubject(Long id, String tenantCode) {
ChannelSubject subject = subjectMapper.selectById(id);
if (subject == null || !tenantCode.equals(subject.getTenantCode())) {
throw new BusinessException("渠道主体不存在: " + id);
}
return subject;
}
@Transactional
public ChannelSubject createSubject(String tenantCode, CreateChannelSubjectRequest request) {
ChannelSubject existing = subjectMapper.selectOne(
@@ -51,4 +60,47 @@ public class ChannelSubjectService {
subjectMapper.insert(subject);
return subject;
}
@Transactional
public ChannelSubject updateSubject(Long id, String tenantCode, CreateChannelSubjectRequest request) {
ChannelSubject subject = getSubject(id, tenantCode);
if (!subject.getSubjectCode().equals(request.getSubjectCode())) {
ChannelSubject existing = subjectMapper.selectOne(
new LambdaQueryWrapper<ChannelSubject>()
.eq(ChannelSubject::getTenantCode, tenantCode)
.eq(ChannelSubject::getChannelType, request.getChannelType())
.eq(ChannelSubject::getSubjectCode, request.getSubjectCode()));
if (existing != null) {
throw new BusinessException("渠道主体编码已存在: " + request.getSubjectCode());
}
}
subject.setChannelType(request.getChannelType());
subject.setSubjectCode(request.getSubjectCode());
subject.setSubjectName(request.getSubjectName());
subject.setCorpId(request.getCorpId());
subject.setCorpSecret(request.getCorpSecret());
subject.setAgentId(request.getAgentId());
subject.setAgentSecret(request.getAgentSecret());
subject.setSessionArchiveEnabled(request.getSessionArchiveEnabled() != null ? request.getSessionArchiveEnabled() : 0);
subject.setSessionArchivePrivateKey(request.getSessionArchivePrivateKey());
subjectMapper.updateById(subject);
return subject;
}
@Transactional
public void deleteSubject(Long id, String tenantCode) {
ChannelSubject subject = getSubject(id, tenantCode);
subject.setStatus(0);
subjectMapper.updateById(subject);
}
@Transactional
public ChannelSubject updateSubjectStatus(Long id, String tenantCode, UpdateStatusRequest request) {
ChannelSubject subject = getSubject(id, tenantCode);
subject.setStatus(request.getStatus());
subjectMapper.updateById(subject);
return subject;
}
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.dto.BindConversationAccountRequest;
import com.sino.mci.channel.dto.BindConversationTagsRequest;
import com.sino.mci.channel.dto.CreateConversationRequest;
import com.sino.mci.channel.dto.UpdateConversationRequest;
import com.sino.mci.channel.entity.ChannelAccountConversation;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationTagBinding;
@@ -73,6 +74,32 @@ public class ConversationService {
return conversation;
}
@Transactional
public Conversation updateConversation(String tenantCode, Long conversationId, UpdateConversationRequest request) {
Conversation conversation = getConversation(tenantCode, conversationId);
conversation.setConversationName(request.getConversationName());
if (request.getOwnerAccountId() != null) {
conversation.setOwnerAccountId(request.getOwnerAccountId());
}
if (request.getSubjectId() != null) {
conversation.setSubjectId(request.getSubjectId());
}
if (request.getStatus() != null) {
conversation.setStatus(request.getStatus());
}
if (request.getReceiveConfig() != null) {
conversation.setReceiveConfig(JsonUtils.toJson(request.getReceiveConfig()));
}
conversationMapper.updateById(conversation);
return conversation;
}
@Transactional
public void deleteConversation(String tenantCode, Long conversationId) {
getConversation(tenantCode, conversationId);
conversationMapper.deleteById(conversationId);
}
@Transactional
public void bindAccount(String tenantCode, Long conversationId, BindConversationAccountRequest request) {
getConversation(tenantCode, conversationId);

View File

@@ -6,6 +6,7 @@ import com.sino.mci.channel.wecom.archive.FinanceSdkClient;
import com.sino.mci.channel.wecom.archive.model.FinanceMessage;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.inbound.service.InboundMessageService;
import com.sino.mci.message.entity.MessageMedia;
import com.sino.mci.message.repository.MessageMediaMapper;
import com.sino.mci.shared.util.JsonUtils;
@@ -40,6 +41,7 @@ public class SessionArchivePullService {
private final StringRedisTemplate redisTemplate;
private final MessageEventMapper messageEventMapper;
private final MessageMediaMapper messageMediaMapper;
private final InboundMessageService inboundMessageService;
/**
* 消费会话存档拉取命令。
@@ -131,6 +133,7 @@ public class SessionArchivePullService {
event.setIsProcessed(0);
event.setStatus(1);
messageEventMapper.insert(event);
inboundMessageService.processInboundMessage(event);
}
private void saveMediaIfNeeded(SessionArchivePullCommand command, FinanceMessage msg, String decryptedJson) {

View File

@@ -4,6 +4,7 @@ import com.sino.mci.admin.security.AuthContext;
import com.sino.mci.crm.dto.BindTagsRequest;
import com.sino.mci.crm.dto.CreateIdentityRequest;
import com.sino.mci.crm.dto.CreatePersonRequest;
import com.sino.mci.crm.dto.UpdatePersonRequest;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.service.PersonService;
import com.sino.mci.shared.web.ApiResponse;
@@ -39,6 +40,22 @@ public class PersonController {
return ApiResponse.ok(personService.getPerson(tenantCode, personId));
}
@PutMapping("/{person_id}")
public ApiResponse<?> updatePerson(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("person_id") Long personId,
@Valid @RequestBody UpdatePersonRequest request) {
return ApiResponse.ok(personService.updatePerson(tenantCode, personId, request));
}
@DeleteMapping("/{person_id}")
public ApiResponse<?> deletePerson(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("person_id") Long personId) {
personService.deletePerson(tenantCode, personId);
return ApiResponse.ok();
}
@PostMapping("/{person_id}/identities")
public ApiResponse<?> createIdentity(
@RequestHeader("X-Tenant-Code") String tenantCode,
@@ -55,6 +72,15 @@ public class PersonController {
return ApiResponse.ok(personService.listIdentities(tenantCode, personId));
}
@DeleteMapping("/{person_id}/identities/{identity_id}")
public ApiResponse<?> deleteIdentity(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("person_id") Long personId,
@PathVariable("identity_id") Long identityId) {
personService.deleteIdentity(tenantCode, personId, identityId);
return ApiResponse.ok();
}
@PostMapping("/{person_id}/bind-tags")
public ApiResponse<?> bindTags(
@RequestHeader("X-Tenant-Code") String tenantCode,

View File

@@ -1,6 +1,7 @@
package com.sino.mci.crm.controller;
import com.sino.mci.crm.dto.CreateTagRequest;
import com.sino.mci.crm.dto.UpdateTagRequest;
import com.sino.mci.crm.service.TagService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
@@ -25,4 +26,20 @@ public class TagController {
public ApiResponse<?> listTags(@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(tagService.listTags(tenantCode));
}
@PutMapping("/{id}")
public ApiResponse<?> updateTag(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable Long id,
@Valid @RequestBody UpdateTagRequest request) {
return ApiResponse.ok(tagService.updateTag(tenantCode, id, request));
}
@DeleteMapping("/{id}")
public ApiResponse<?> deleteTag(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable Long id) {
tagService.deleteTag(tenantCode, id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,17 @@
package com.sino.mci.crm.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdatePersonRequest {
@NotBlank
private String personName;
private String phone;
private Integer personType;
private Integer status;
private Long institutionId;
private Long supplierId;
}

View File

@@ -0,0 +1,13 @@
package com.sino.mci.crm.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class UpdateTagRequest {
@NotBlank
private String tagName;
private Integer status;
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.crm.dto.BindTagsRequest;
import com.sino.mci.crm.dto.CreateIdentityRequest;
import com.sino.mci.crm.dto.CreatePersonRequest;
import com.sino.mci.crm.dto.UpdatePersonRequest;
import com.sino.mci.crm.entity.ChannelIdentity;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.entity.PersonTagBinding;
@@ -62,6 +63,35 @@ public class PersonService {
return person;
}
@Transactional
public Person updatePerson(String tenantCode, Long personId, UpdatePersonRequest request) {
Person person = getPerson(tenantCode, personId);
person.setPersonName(request.getPersonName());
if (request.getPhone() != null) {
person.setPhone(request.getPhone());
}
if (request.getPersonType() != null) {
person.setPersonType(request.getPersonType());
}
if (request.getStatus() != null) {
person.setStatus(request.getStatus());
}
if (request.getInstitutionId() != null) {
person.setInstitutionId(request.getInstitutionId());
}
if (request.getSupplierId() != null) {
person.setSupplierId(request.getSupplierId());
}
personMapper.updateById(person);
return person;
}
@Transactional
public void deletePerson(String tenantCode, Long personId) {
getPerson(tenantCode, personId);
personMapper.deleteById(personId);
}
public List<ChannelIdentity> listIdentities(String tenantCode, Long personId) {
getPerson(tenantCode, personId);
return identityMapper.selectList(
@@ -96,6 +126,17 @@ public class PersonService {
return identity;
}
@Transactional
public void deleteIdentity(String tenantCode, Long personId, Long identityId) {
getPerson(tenantCode, personId);
ChannelIdentity identity = identityMapper.selectById(identityId);
if (identity == null || !tenantCode.equals(identity.getTenantCode())
|| !personId.equals(identity.getPersonId())) {
throw new BusinessException("渠道身份不存在");
}
identityMapper.deleteById(identityId);
}
@Transactional
public void bindTags(String tenantCode, Long personId, BindTagsRequest request) {
getPerson(tenantCode, personId);

View File

@@ -2,6 +2,7 @@ package com.sino.mci.crm.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.crm.dto.CreateTagRequest;
import com.sino.mci.crm.dto.UpdateTagRequest;
import com.sino.mci.crm.entity.Tag;
import com.sino.mci.crm.repository.TagMapper;
import com.sino.mci.exception.BusinessException;
@@ -54,6 +55,30 @@ public class TagService {
return tag;
}
public Tag getTag(String tenantCode, Long tagId) {
Tag tag = tagMapper.selectById(tagId);
if (tag == null || !tenantCode.equals(tag.getTenantCode())) {
throw new BusinessException("标签不存在");
}
return tag;
}
@Transactional
public Tag updateTag(String tenantCode, Long tagId, UpdateTagRequest request) {
Tag tag = getTag(tenantCode, tagId);
if (request.getStatus() != null) {
tag.setStatus(request.getStatus());
}
tagMapper.updateById(tag);
return tag;
}
@Transactional
public void deleteTag(String tenantCode, Long tagId) {
getTag(tenantCode, tagId);
tagMapper.deleteById(tagId);
}
public List<Tag> listTags(String tenantCode) {
return tagMapper.selectList(
new LambdaQueryWrapper<Tag>()

View File

@@ -0,0 +1,20 @@
package com.sino.mci.exception;
/**
* 入站 webhook 推送异常。
*
* <p>当向业务系统推送入站消息失败时抛出,由流程引擎捕获并记录执行轨迹。</p>
*/
public class WebhookPushException extends BusinessException {
public WebhookPushException(String message) {
super(message);
}
public WebhookPushException(String message, Throwable cause) {
super(message);
if (cause != null) {
initCause(cause);
}
}
}

View File

@@ -56,4 +56,12 @@ public class FlowDefinitionController {
flowDefinitionService.offlineFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
@DeleteMapping("/{flow_code}")
public ApiResponse<?> deleteFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode) {
flowDefinitionService.deleteFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,69 @@
package com.sino.mci.flow.controller;
import com.sino.mci.flow.dto.CreateFlowRequest;
import com.sino.mci.flow.service.FlowDefinitionService;
import com.sino.mci.shared.web.ApiResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/flow")
@RequiredArgsConstructor
public class IvrFlowController {
private final FlowDefinitionService flowDefinitionService;
private static final Integer IVR_FLOW_TYPE = 2;
@PostMapping
public ApiResponse<?> createFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@Valid @RequestBody CreateFlowRequest request) {
return ApiResponse.ok(flowDefinitionService.createFlow(tenantCode, request));
}
@GetMapping("/{flow_code}")
public ApiResponse<?> getFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode) {
return ApiResponse.ok(flowDefinitionService.getFlow(tenantCode, flowCode));
}
@GetMapping("/list")
public ApiResponse<?> listFlows(@RequestHeader("X-Tenant-Code") String tenantCode) {
return ApiResponse.ok(flowDefinitionService.listFlowsByType(tenantCode, IVR_FLOW_TYPE));
}
@PostMapping("/{flow_code}")
public ApiResponse<?> updateFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode,
@Valid @RequestBody CreateFlowRequest request) {
return ApiResponse.ok(flowDefinitionService.updateFlow(tenantCode, flowCode, request));
}
@PostMapping("/{flow_code}/publish")
public ApiResponse<?> publishFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode) {
flowDefinitionService.publishFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
@PostMapping("/{flow_code}/offline")
public ApiResponse<?> offlineFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode) {
flowDefinitionService.offlineFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
@DeleteMapping("/{flow_code}")
public ApiResponse<?> deleteFlow(
@RequestHeader("X-Tenant-Code") String tenantCode,
@PathVariable("flow_code") String flowCode) {
flowDefinitionService.deleteFlow(tenantCode, flowCode);
return ApiResponse.ok();
}
}

View File

@@ -58,6 +58,15 @@ public class FlowDefinitionService {
.orderByDesc(FlowDefinition::getCreateTime));
}
public List<FlowDefinition> listFlowsByType(String tenantCode, Integer flowType) {
return flowDefinitionMapper.selectList(
new LambdaQueryWrapper<FlowDefinition>()
.eq(FlowDefinition::getTenantCode, tenantCode)
.eq(FlowDefinition::getFlowType, flowType)
.eq(FlowDefinition::getDeleted, 0)
.orderByDesc(FlowDefinition::getCreateTime));
}
@Transactional
public FlowDefinition updateFlow(String tenantCode, String flowCode, CreateFlowRequest request) {
FlowDefinition flow = getFlow(tenantCode, flowCode);
@@ -78,10 +87,16 @@ public class FlowDefinitionService {
@Transactional
public void offlineFlow(String tenantCode, String flowCode) {
FlowDefinition flow = getFlow(tenantCode, flowCode);
flow.setStatus(0);
flow.setStatus(2);
flowDefinitionMapper.updateById(flow);
}
@Transactional
public void deleteFlow(String tenantCode, String flowCode) {
FlowDefinition flow = getFlow(tenantCode, flowCode);
flowDefinitionMapper.deleteById(flow.getId());
}
private Integer parseFlowType(String flowType) {
if (flowType == null || flowType.isBlank()) {
return 1;

View File

@@ -0,0 +1,31 @@
package com.sino.mci.inbound.controller;
import com.sino.mci.inbound.dto.IntentRecognizeRequest;
import com.sino.mci.inbound.intent.IntentRecognitionService;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.shared.web.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 意图识别调试接口。
*/
@RestController
@RequestMapping("/api/v1/intent")
@RequiredArgsConstructor
public class IntentController {
private final IntentRecognitionService intentRecognitionService;
@PostMapping("/recognize")
public ApiResponse<IntentResult> recognize(
@RequestHeader("X-Tenant-Code") String tenantCode,
@RequestBody IntentRecognizeRequest request) {
return ApiResponse.ok(intentRecognitionService.recognize(
tenantCode, request.getText(), request.getContext()));
}
}

View File

@@ -0,0 +1,15 @@
package com.sino.mci.inbound.dto;
import lombok.Data;
import java.util.Map;
/**
* 意图识别调试请求。
*/
@Data
public class IntentRecognizeRequest {
private String text;
private Map<String, Object> context;
}

View File

@@ -0,0 +1,54 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import lombok.Data;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 流程执行上下文。
*
* <p>包含当前消息事件、业务上下文、意图识别结果以及流程变量,
* 供条件表达式SpEL与节点执行使用。</p>
*/
@Data
public class FlowContext {
private final MessageEvent event;
private final Map<String, Object> businessContext;
private IntentResult intent;
private final Map<String, Object> variables = new HashMap<>();
public FlowContext(MessageEvent event, Map<String, Object> businessContext) {
this.event = event;
this.businessContext = businessContext != null ? businessContext : new LinkedHashMap<>();
}
/**
* 构建用于 SpEL 表达式求值的根对象。
*/
public Map<String, Object> toEvaluationMap() {
Map<String, Object> root = new HashMap<>();
root.put("event", event);
root.put("businessContext", businessContext);
root.put("intent", toIntentMap());
root.put("variables", variables);
return root;
}
private Map<String, Object> toIntentMap() {
if (intent == null) {
return null;
}
Map<String, Object> map = new LinkedHashMap<>();
map.put("intent", intent.getIntent());
map.put("confidence", intent.getConfidence());
map.put("slots", intent.getSlots());
map.put("missingSlots", intent.getMissingSlots());
map.put("requiresConfirmation", intent.isRequiresConfirmation());
return map;
}
}

View File

@@ -0,0 +1,85 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.shared.util.JsonUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 流程定义 JSON 解析器。
*
* <p>将 {@code mci_flow_definition.definition_json} 中的 {@code nodes} 与 {@code edges}
* 解析为 {@link FlowNode} 与 {@link FlowEdge} 对象。</p>
*/
public final class FlowDefinitionParser {
private FlowDefinitionParser() {
}
@SuppressWarnings("unchecked")
public static List<FlowNode> parseNodes(String definitionJson) {
Map<String, Object> definition = JsonUtils.fromJson(definitionJson, Map.class);
if (definition == null) {
return List.of();
}
Object nodesObj = definition.get("nodes");
if (!(nodesObj instanceof List<?>)) {
return List.of();
}
List<FlowNode> nodes = new ArrayList<>();
for (Object item : (List<?>) nodesObj) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
String id = toString(map.get("id"));
String type = toString(map.get("type"));
if (id == null || type == null) {
continue;
}
nodes.add(new FlowNode(id, type, toConfigMap(map.get("config"))));
}
return nodes;
}
@SuppressWarnings("unchecked")
public static List<FlowEdge> parseEdges(String definitionJson) {
Map<String, Object> definition = JsonUtils.fromJson(definitionJson, Map.class);
if (definition == null) {
return List.of();
}
Object edgesObj = definition.get("edges");
if (!(edgesObj instanceof List<?>)) {
return List.of();
}
List<FlowEdge> edges = new ArrayList<>();
for (Object item : (List<?>) edgesObj) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
String source = toString(map.get("source"));
String target = toString(map.get("target"));
if (source == null || target == null) {
continue;
}
edges.add(new FlowEdge(source, target, toString(map.get("label"))));
}
return edges;
}
private static Map<String, Object> toConfigMap(Object configObj) {
if (!(configObj instanceof Map<?, ?> map)) {
return new HashMap<>();
}
Map<String, Object> config = new HashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
config.put(String.valueOf(entry.getKey()), entry.getValue());
}
return config;
}
private static String toString(Object value) {
return value != null ? value.toString() : null;
}
}

View File

@@ -0,0 +1,7 @@
package com.sino.mci.inbound.engine;
/**
* 入站流程定义边。
*/
public record FlowEdge(String source, String target, String label) {
}

View File

@@ -5,15 +5,21 @@ import com.sino.mci.flow.entity.FlowDefinition;
import com.sino.mci.flow.repository.FlowDefinitionMapper;
import com.sino.mci.inbound.entity.FlowExecutionTrace;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.inbound.repository.FlowExecutionTraceMapper;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.send.dto.SendRequest;
import com.sino.mci.send.service.SendService;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Slf4j
@Component
@@ -25,6 +31,7 @@ public class FlowEngine {
private final IntentRecognitionService intentRecognitionService;
private final WebhookPushService webhookPushService;
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
private final SendService sendService;
public void executeInboundFlow(Long eventId) {
MessageEvent event = messageEventMapper.selectById(eventId);
@@ -33,7 +40,7 @@ public class FlowEngine {
return;
}
trace(event, null, "flow_start", event.getEventId(), null, 1, now(), null);
trace(event, null, "flow_start", null, event.getEventId(), null, 1, now(), null);
FlowDefinition flow = findPublishedFlow(event.getTenantCode());
Long flowDefinitionId = flow != null ? flow.getId() : null;
@@ -44,35 +51,184 @@ public class FlowEngine {
}
try {
Map<String, Object> definition = JsonUtils.fromJson(flow.getDefinitionJson(), Map.class);
if (definition == null) {
log.warn("Invalid flow definition JSON for flow {}", flow.getFlowCode());
List<FlowNode> nodes = FlowDefinitionParser.parseNodes(flow.getDefinitionJson());
List<FlowEdge> edges = FlowDefinitionParser.parseEdges(flow.getDefinitionJson());
if (nodes.isEmpty()) {
log.warn("Flow {} has no nodes", flow.getFlowCode());
markProcessed(event, flowDefinitionId);
return;
}
long recognizeStart = now();
IntentRecognitionService.IntentResult intent = intentRecognitionService.recognize(event);
trace(event, flowDefinitionId, "intent_recognition",
event.getContent(), JsonUtils.toJson(intent), 1, recognizeStart, null);
log.info("Intent recognized: eventId={}, intent={}, confidence={}",
event.getEventId(), intent.intent(), intent.confidence());
long pushStart = now();
try {
webhookPushService.pushToBusiness(event.getTenantCode(), event, intent);
trace(event, flowDefinitionId, "webhook_push", event.getEventId(), "ok", 1, pushStart, null);
} catch (Exception e) {
trace(event, flowDefinitionId, "webhook_push", event.getEventId(), null, 2, pushStart, e.getMessage());
throw e;
FlowNode receiveNode = nodes.stream()
.filter(n -> "receive".equals(n.type()))
.findFirst()
.orElse(null);
if (receiveNode == null) {
log.warn("Flow {} has no receive node", flow.getFlowCode());
markProcessed(event, flowDefinitionId);
return;
}
Map<String, FlowNode> nodeMap = nodes.stream()
.collect(Collectors.toMap(FlowNode::id, n -> n));
Map<String, List<FlowEdge>> adjacency = edges.stream()
.collect(Collectors.groupingBy(FlowEdge::source));
Map<String, Object> businessContext = parseBusinessContext(event.getBusinessContext());
FlowContext context = new FlowContext(event, businessContext);
FlowNode current = receiveNode;
while (current != null) {
long nodeStart = now();
NodeResult result;
try {
result = executeNode(current, context, flowDefinitionId, adjacency, nodeStart);
} catch (Exception e) {
trace(event, flowDefinitionId, current.type(), JsonUtils.toJson(current.config()),
current.id(), e.getMessage(), 2, nodeStart, e.getMessage());
log.error("Node execution failed: eventId={}, nodeId={}, nodeType={}",
event.getEventId(), current.id(), current.type(), e);
return;
}
if (result.terminal() || result.nextNodeId() == null) {
if (result.success()) {
markProcessed(event, flowDefinitionId);
}
return;
}
current = nodeMap.get(result.nextNodeId());
if (current == null) {
log.warn("Next node {} not found in flow {}", result.nextNodeId(), flow.getFlowCode());
return;
}
}
} catch (Exception e) {
log.error("Flow execution failed for event {}", eventId, e);
}
}
private NodeResult executeNode(FlowNode node, FlowContext context, Long flowDefinitionId,
Map<String, List<FlowEdge>> adjacency, long startMs) {
String nodeConfigJson = JsonUtils.toJson(node.config());
MessageEvent event = context.getEvent();
switch (node.type()) {
case "receive" -> {
Object expectedChannel = node.config().get("channel_type");
if (expectedChannel != null && !expectedChannel.toString().equalsIgnoreCase(event.getChannelType())) {
throw new IllegalStateException("Channel type mismatch: expected " + expectedChannel
+ ", actual " + event.getChannelType());
}
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getEventId(), event.getChannelType(), 1, startMs, null);
return new NodeResult(firstTarget(adjacency.get(node.id())), false, true, "ok");
}
case "intent" -> {
IntentResult intent = intentRecognitionService.recognize(event);
context.setIntent(intent);
String intentJson = JsonUtils.toJson(intent);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getContent(), intentJson, 1, startMs, null);
return new NodeResult(firstTarget(adjacency.get(node.id())), false, true, intentJson);
}
case "condition" -> {
String expression = node.config().get("expression") != null
? node.config().get("expression").toString() : "";
if (expression.isBlank()) {
String nextNodeId = firstTarget(adjacency.get(node.id()));
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
expression, "empty -> first", 1, startMs, null);
return new NodeResult(nextNodeId, false, true, "empty");
}
boolean expressionResult = SpelConditionEvaluator.evaluate(expression, context);
String nextNodeId = selectConditionTarget(adjacency.get(node.id()), expressionResult);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
expression, String.valueOf(expressionResult), 1, startMs, null);
return new NodeResult(nextNodeId, false, true, String.valueOf(expressionResult));
}
case "webhook" -> {
String customUrl = node.config().get("url") != null
? node.config().get("url").toString() : null;
webhookPushService.pushToBusiness(event.getTenantCode(), event, context.getIntent(), customUrl);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getEventId(), "pushed", 1, startMs, null);
return new NodeResult(null, true, true, "pushed");
}
case "reply" -> {
SendRequest request = buildReplyRequest(event, node.config());
sendService.send(event.getTenantCode(), request);
trace(event, flowDefinitionId, node.type(), nodeConfigJson,
event.getEventId(), JsonUtils.toJson(request), 1, startMs, null);
return new NodeResult(null, true, true, "sent");
}
default -> throw new IllegalArgumentException("Unsupported node type: " + node.type());
}
}
private String firstTarget(List<FlowEdge> edges) {
if (edges == null || edges.isEmpty()) {
return null;
}
return edges.get(0).target();
}
private String selectConditionTarget(List<FlowEdge> edges, boolean result) {
if (edges == null || edges.isEmpty()) {
return null;
}
String expectedLabel = String.valueOf(result);
return edges.stream()
.filter(e -> expectedLabel.equalsIgnoreCase(e.label()))
.findFirst()
.map(FlowEdge::target)
.orElseGet(() -> edges.get(0).target());
}
private SendRequest buildReplyRequest(MessageEvent event, Map<String, Object> config) {
Object contentObj = config.get("content");
Map<String, Object> content;
if (contentObj instanceof Map<?, ?> map) {
content = new HashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
content.put(String.valueOf(entry.getKey()), entry.getValue());
}
} else {
content = new HashMap<>();
content.put("type", "text");
content.put("body", contentObj != null ? contentObj.toString() : "");
}
SendRequest request = new SendRequest();
request.setContentType(String.valueOf(content.get("type")));
request.setContent(content);
Map<String, Object> channelStrategy = new HashMap<>();
channelStrategy.put("channel_type", event.getChannelType());
request.setChannelStrategy(channelStrategy);
if (event.getConversationId() != null) {
request.setTargetType("conversation");
request.setTargetValue(Map.of("conversation_id", event.getConversationId()));
} else if (event.getPersonId() != null) {
request.setTargetType("person");
request.setTargetValue(Map.of("person_id", event.getPersonId()));
} else {
throw new IllegalStateException("Cannot reply: message event has no conversation_id or person_id");
}
return request;
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseBusinessContext(String businessContextJson) {
if (businessContextJson == null || businessContextJson.isBlank()) {
return new LinkedHashMap<>();
}
Map<String, Object> context = JsonUtils.fromJson(businessContextJson, Map.class);
return context != null ? context : new LinkedHashMap<>();
}
private FlowDefinition findPublishedFlow(String tenantCode) {
List<FlowDefinition> flows = flowDefinitionMapper.selectList(
new LambdaQueryWrapper<FlowDefinition>()
@@ -86,16 +242,16 @@ public class FlowEngine {
private void markProcessed(MessageEvent event, Long flowDefinitionId) {
event.setIsProcessed(1);
messageEventMapper.updateById(event);
trace(event, flowDefinitionId, "mark_processed", event.getEventId(), "processed", 1, now(), null);
trace(event, flowDefinitionId, "mark_processed", null, event.getEventId(), "processed", 1, now(), null);
}
private void trace(MessageEvent event, Long flowDefinitionId, String nodeType,
private void trace(MessageEvent event, Long flowDefinitionId, String nodeType, String nodeConfig,
String input, String output, int status, long startMs, String errorMsg) {
FlowExecutionTrace record = new FlowExecutionTrace();
record.setEventId(event.getEventId());
record.setFlowDefinitionId(flowDefinitionId);
record.setNodeType(nodeType);
record.setNodeConfig(null);
record.setNodeConfig(nodeConfig);
record.setInput(input);
record.setOutput(output);
record.setStatus(status);
@@ -107,4 +263,7 @@ public class FlowEngine {
private long now() {
return System.currentTimeMillis();
}
private record NodeResult(String nextNodeId, boolean terminal, boolean success, String output) {
}
}

View File

@@ -0,0 +1,9 @@
package com.sino.mci.inbound.engine;
import java.util.Map;
/**
* 入站流程定义节点。
*/
public record FlowNode(String id, String type, Map<String, Object> config) {
}

View File

@@ -1,11 +1,9 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
public interface IntentRecognitionService {
IntentResult recognize(MessageEvent event);
record IntentResult(String intent, double confidence, String reason) {
}
}

View File

@@ -1,31 +1,43 @@
package com.sino.mci.inbound.engine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.shared.util.JsonUtils;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class MockIntentRecognitionService implements IntentRecognitionService {
private static final Pattern CUSTOMER_NAME_PATTERN = Pattern.compile("客户\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern ADDRESS_PATTERN = Pattern.compile("(?:地址|在)\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern PHONE_PATTERN = Pattern.compile("(1[3-9]\\d{9})");
@Override
public IntentResult recognize(MessageEvent event) {
String text = extractText(event);
if (text == null || text.isBlank()) {
return new IntentResult("unknown", 0.0, "empty content");
return unknownResult();
}
String lower = text.toLowerCase();
if (lower.contains("救援") || lower.contains("拖车") || lower.contains("故障")) {
return new IntentResult("rescue_request", 0.95, "contains rescue keywords");
Map<String, String> slots = extractSlots(text);
List<String> missing = resolveMissingSlots(List.of("location", "contact_phone"), slots);
return new IntentResult("rescue_request", 0.95, slots, missing, !missing.isEmpty());
}
if (lower.contains("咨询") || lower.contains("问一下")) {
return new IntentResult("consult", 0.85, "contains consult keywords");
return new IntentResult("consult", 0.85, Map.of(), List.of(), false);
}
if (lower.contains("投诉") || lower.contains("不满")) {
return new IntentResult("complaint", 0.88, "contains complaint keywords");
return new IntentResult("complaint", 0.88, Map.of(), List.of(), false);
}
return new IntentResult("other", 0.6, "no clear intent matched");
return new IntentResult("other", 0.6, Map.of(), List.of(), false);
}
private String extractText(MessageEvent event) {
@@ -40,4 +52,39 @@ public class MockIntentRecognitionService implements IntentRecognitionService {
Object body = content.get("body");
return body != null ? body.toString() : null;
}
private Map<String, String> extractSlots(String text) {
Map<String, String> slots = new HashMap<>();
Matcher customerMatcher = CUSTOMER_NAME_PATTERN.matcher(text);
if (customerMatcher.find()) {
slots.put("customer_name", customerMatcher.group(1).trim());
}
Matcher addressMatcher = ADDRESS_PATTERN.matcher(text);
if (addressMatcher.find()) {
slots.put("address", addressMatcher.group(1).trim());
}
Matcher phoneMatcher = PHONE_PATTERN.matcher(text);
if (phoneMatcher.find()) {
slots.put("phone", phoneMatcher.group(1).trim());
}
return slots;
}
private List<String> resolveMissingSlots(List<String> expectedSlots, Map<String, String> slots) {
List<String> missing = new ArrayList<>();
for (String slot : expectedSlots) {
if (!slots.containsKey(slot) || slots.get(slot) == null || slots.get(slot).isBlank()) {
missing.add(slot);
}
}
return missing;
}
private IntentResult unknownResult() {
return new IntentResult("unknown", 0.0, Map.of(), List.of(), false);
}
}

View File

@@ -0,0 +1,38 @@
package com.sino.mci.inbound.engine;
import org.springframework.expression.spel.standard.SpelExpressionParser;
import org.springframework.expression.spel.support.MapAccessor;
import org.springframework.expression.spel.support.StandardEvaluationContext;
import org.springframework.util.StringUtils;
/**
* SpEL 条件表达式求值器。
*/
public final class SpelConditionEvaluator {
private static final SpelExpressionParser PARSER = new SpelExpressionParser();
private SpelConditionEvaluator() {
}
/**
* 对给定表达式进行求值。
*
* @param expression SpEL 表达式;为空或空白时返回 {@code true}
* @param context 流程执行上下文
* @return 表达式求值结果
*/
public static boolean evaluate(String expression, FlowContext context) {
if (!StringUtils.hasText(expression)) {
return true;
}
try {
StandardEvaluationContext evaluationContext = new StandardEvaluationContext(context.toEvaluationMap());
evaluationContext.addPropertyAccessor(new MapAccessor());
Boolean result = PARSER.parseExpression(expression).getValue(evaluationContext, Boolean.class);
return Boolean.TRUE.equals(result);
} catch (Exception e) {
throw new IllegalArgumentException("Failed to evaluate condition expression: " + expression, e);
}
}
}

View File

@@ -3,16 +3,25 @@ package com.sino.mci.inbound.engine;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.exception.WebhookPushException;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.inbound.webhook.WebhookPayloadBuilder;
import com.sino.mci.inbound.webhook.WebhookSignature;
import com.sino.mci.inbound.webhook.WebhookSignatureService;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@@ -20,39 +29,91 @@ import java.util.Map;
@RequiredArgsConstructor
public class WebhookPushService {
private final TenantMapper tenantMapper;
private final RestTemplate restTemplate = new RestTemplate();
private static final String AUTH_CONFIG_WEBHOOK_SECRET = "webhook_secret";
private static final String AUTH_CONFIG_SIGN_ALGORITHM = "sign_algorithm";
public void pushToBusiness(String tenantCode, MessageEvent event, IntentRecognitionService.IntentResult intent) {
private final TenantMapper tenantMapper;
private final RestTemplate restTemplate;
private final WebhookPayloadBuilder payloadBuilder;
private final WebhookSignatureService signatureService;
public void pushToBusiness(String tenantCode, MessageEvent event, IntentResult intent) {
pushToBusiness(tenantCode, event, intent, null);
}
public void pushToBusiness(String tenantCode, MessageEvent event, IntentResult intent, String customUrl) {
Tenant tenant = loadTenant(tenantCode);
String webhookUrl = resolveWebhookUrl(tenant, customUrl);
Map<String, Object> authConfig = parseAuthConfig(tenant.getAuthConfig());
String secret = getString(authConfig, AUTH_CONFIG_WEBHOOK_SECRET);
if (!StringUtils.hasText(secret)) {
throw new WebhookPushException("Tenant " + tenantCode + " has no webhook_secret configured");
}
String algorithm = getString(authConfig, AUTH_CONFIG_SIGN_ALGORITHM);
if (!StringUtils.hasText(algorithm)) {
algorithm = "hmac-sha256";
}
Map<String, Object> payload = payloadBuilder.buildPayload(event, intent);
String payloadJson = JsonUtils.toJson(payload);
if (payloadJson == null) {
throw new WebhookPushException("Failed to serialize webhook payload for tenant " + tenantCode);
}
WebhookSignature signature = signatureService.sign(tenantCode, payloadJson, secret, algorithm);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.set("X-Msg-Platform-Signature", signature.signature());
headers.set("X-Msg-Platform-Timestamp", String.valueOf(signature.timestamp()));
headers.set("X-Msg-Platform-Tenant", tenantCode);
HttpEntity<String> entity = new HttpEntity<>(payloadJson, headers);
try {
ResponseEntity<String> response = restTemplate.exchange(
webhookUrl, HttpMethod.POST, entity, String.class);
if (!response.getStatusCode().is2xxSuccessful()) {
throw new WebhookPushException("Webhook push failed with status " + response.getStatusCode()
+ " for tenant " + tenantCode);
}
log.info("Webhook pushed to {}, status={}, tenant={}", webhookUrl, response.getStatusCode(), tenantCode);
} catch (RestClientException e) {
throw new WebhookPushException("Webhook push failed for tenant " + tenantCode + ": " + e.getMessage(), e);
}
}
private Tenant loadTenant(String tenantCode) {
Tenant tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
if (tenant == null) {
log.warn("Tenant not found for webhook push: {}", tenantCode);
return;
throw new WebhookPushException("Tenant not found for webhook push: " + tenantCode);
}
return tenant;
}
private String resolveWebhookUrl(Tenant tenant, String customUrl) {
if (StringUtils.hasText(customUrl)) {
return customUrl;
}
String webhookUrl = tenant.getInboundWebhookUrl();
if (webhookUrl == null || webhookUrl.isBlank()) {
log.warn("Tenant {} has no inbound webhook URL configured", tenantCode);
return;
if (!StringUtils.hasText(webhookUrl)) {
throw new WebhookPushException("Tenant " + tenant.getTenantCode() + " has no inbound webhook URL configured");
}
return webhookUrl;
}
Map<String, Object> payload = new HashMap<>();
payload.put("eventId", event.getEventId());
payload.put("tenantCode", tenantCode);
payload.put("channelType", event.getChannelType());
payload.put("conversationId", event.getConversationId());
payload.put("personId", event.getPersonId());
payload.put("accountId", event.getAccountId());
payload.put("content", event.getContent());
payload.put("intent", intent.intent());
payload.put("confidence", intent.confidence());
payload.put("reason", intent.reason());
@SuppressWarnings("unchecked")
private Map<String, Object> parseAuthConfig(String authConfigJson) {
if (authConfigJson == null || authConfigJson.isBlank()) {
return Map.of();
}
Map<String, Object> config = JsonUtils.fromJson(authConfigJson, Map.class);
return config != null ? config : Map.of();
}
try {
ResponseEntity<String> response = restTemplate.postForEntity(webhookUrl, payload, String.class);
log.info("Webhook pushed to {}, status={}", webhookUrl, response.getStatusCode());
} catch (RestClientException e) {
log.warn("Webhook push failed: {}", e.getMessage());
}
private String getString(Map<String, Object> config, String key) {
Object value = config.get(key);
return value != null ? value.toString() : null;
}
}

View File

@@ -0,0 +1,287 @@
package com.sino.mci.inbound.intent;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.admin.entity.Tenant;
import com.sino.mci.admin.repository.TenantMapper;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 规则 + LLM 意图识别服务。
*
* <p>优先使用租户自定义规则(配置在 {@code mci_tenant.ext_info.intent_rules}
* 其次使用默认规则;规则未命中时调用 LLMLLM 未配置时返回 {@code unknown}。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class IntentRecognitionService {
private static final double RULE_CONFIDENCE_THRESHOLD = 0.9;
private static final List<IntentRule> DEFAULT_RULES = List.of(
new IntentRule(List.of("下单", "我要下单"), "create_order", 1.0,
List.of("customer_name", "address", "phone")),
new IntentRule(List.of("查状态", "状态"), "query_status", 1.0, List.of())
);
private static final Pattern CUSTOMER_NAME_PATTERN = Pattern.compile("客户\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern ADDRESS_PATTERN = Pattern.compile("(?:地址|在)\\s*[:]?\\s*([^\\s,]+)");
private static final Pattern PHONE_PATTERN = Pattern.compile("(1[3-9]\\d{9})");
private final TenantMapper tenantMapper;
@Autowired(required = false)
private ChatClient.Builder chatClientBuilder;
@Value("${spring.ai.openai.api-key:}")
private String apiKey;
/**
* 识别用户消息的意图。
*
* @param tenantCode 租户编码
* @param text 用户消息文本
* @param businessContext 业务上下文
* @return 意图识别结果
*/
public IntentResult recognize(String tenantCode, String text, Map<String, Object> businessContext) {
if (text == null || text.isBlank()) {
return unknownResult();
}
List<IntentRule> rules = resolveRules(tenantCode);
for (IntentRule rule : rules) {
if (matches(rule, text)) {
Map<String, String> slots = extractSlots(text);
List<String> missingSlots = resolveMissingSlots(rule, slots);
return new IntentResult(rule.intent(), rule.confidence(), slots,
missingSlots, !missingSlots.isEmpty());
}
}
if (!isLlmConfigured()) {
log.debug("No rule matched and LLM is not configured, returning unknown intent");
return unknownResult();
}
return callLlm(text, businessContext);
}
private List<IntentRule> resolveRules(String tenantCode) {
List<IntentRule> rules = new ArrayList<>(loadTenantRules(tenantCode));
rules.addAll(DEFAULT_RULES);
return rules;
}
private List<IntentRule> loadTenantRules(String tenantCode) {
Tenant tenant;
try {
tenant = tenantMapper.selectOne(
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
} catch (Exception e) {
log.warn("Failed to load tenant rules for {}", tenantCode, e);
return List.of();
}
if (tenant == null || tenant.getExtInfo() == null || tenant.getExtInfo().isBlank()) {
return List.of();
}
Map<String, Object> ext = JsonUtils.fromJson(tenant.getExtInfo(), Map.class);
if (ext == null) {
return List.of();
}
Object rulesObj = ext.get("intent_rules");
if (!(rulesObj instanceof List<?>)) {
return List.of();
}
List<IntentRule> rules = new ArrayList<>();
for (Object item : (List<?>) rulesObj) {
if (!(item instanceof Map<?, ?> map)) {
continue;
}
List<String> keywords = toStringList(map.get("keywords"));
String intent = toString(map.get("intent"));
Double confidence = toDouble(map.get("confidence"));
List<String> expectedSlots = toStringList(map.get("expectedSlots"));
if (intent != null && !intent.isBlank() && !keywords.isEmpty()) {
rules.add(new IntentRule(keywords, intent,
confidence != null ? confidence : 1.0,
expectedSlots));
}
}
return rules;
}
private boolean matches(IntentRule rule, String text) {
for (String keyword : rule.keywords()) {
if (text.contains(keyword)) {
return true;
}
}
return false;
}
private Map<String, String> extractSlots(String text) {
Map<String, String> slots = new LinkedHashMap<>();
Matcher customerMatcher = CUSTOMER_NAME_PATTERN.matcher(text);
if (customerMatcher.find()) {
slots.put("customer_name", customerMatcher.group(1).trim());
}
Matcher addressMatcher = ADDRESS_PATTERN.matcher(text);
if (addressMatcher.find()) {
slots.put("address", addressMatcher.group(1).trim());
}
Matcher phoneMatcher = PHONE_PATTERN.matcher(text);
if (phoneMatcher.find()) {
slots.put("phone", phoneMatcher.group(1).trim());
}
return slots;
}
private List<String> resolveMissingSlots(IntentRule rule, Map<String, String> slots) {
List<String> missing = new ArrayList<>();
for (String slot : rule.expectedSlots()) {
if (!slots.containsKey(slot) || slots.get(slot) == null || slots.get(slot).isBlank()) {
missing.add(slot);
}
}
return missing;
}
private boolean isLlmConfigured() {
return chatClientBuilder != null && apiKey != null && !apiKey.isBlank();
}
private IntentResult callLlm(String text, Map<String, Object> businessContext) {
String prompt = buildPrompt(text, businessContext);
try {
String content = chatClientBuilder.build()
.prompt()
.user(prompt)
.call()
.content();
return parseLlmResponse(content);
} catch (Exception e) {
log.warn("LLM intent recognition failed: {}", e.getMessage());
return unknownResult();
}
}
private String buildPrompt(String text, Map<String, Object> businessContext) {
return "You are an intent classifier. Given the user message and context, output ONLY a JSON object "
+ "with fields: intent, confidence (0-1), slots (object), missingSlots (array), "
+ "requiresConfirmation (boolean).\n\n"
+ "User message: " + text + "\n"
+ "Context: " + JsonUtils.toJson(businessContext);
}
@SuppressWarnings("unchecked")
private IntentResult parseLlmResponse(String content) {
if (content == null || content.isBlank()) {
return unknownResult();
}
String json = stripMarkdownCodeBlock(content);
Map<String, Object> map = JsonUtils.fromJson(json, Map.class);
if (map == null) {
return unknownResult();
}
String intent = toString(map.get("intent"));
if (intent == null || intent.isBlank()) {
intent = "unknown";
}
double confidence = toDouble(map.get("confidence"));
Map<String, String> slots = toStringMap(map.get("slots"));
List<String> missingSlots = toStringList(map.get("missingSlots"));
boolean requiresConfirmation = Boolean.TRUE.equals(map.get("requiresConfirmation"));
return new IntentResult(intent, confidence, slots, missingSlots, requiresConfirmation);
}
private String stripMarkdownCodeBlock(String content) {
String trimmed = content.trim();
if (trimmed.startsWith("```")) {
int firstNewline = trimmed.indexOf('\n');
int lastFence = trimmed.lastIndexOf("```");
if (firstNewline > 0 && lastFence > firstNewline) {
return trimmed.substring(firstNewline + 1, lastFence).trim();
}
}
return trimmed;
}
private IntentResult unknownResult() {
return new IntentResult("unknown", 0.0, Map.of(), List.of(), false);
}
private static String toString(Object value) {
return value != null ? value.toString() : null;
}
private static Double toDouble(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number number) {
return number.doubleValue();
}
try {
return Double.parseDouble(value.toString());
} catch (NumberFormatException e) {
return null;
}
}
@SuppressWarnings("unchecked")
private static List<String> toStringList(Object value) {
if (!(value instanceof Collection<?> collection)) {
return List.of();
}
List<String> result = new ArrayList<>();
for (Object item : collection) {
if (item != null) {
result.add(item.toString());
}
}
return result;
}
@SuppressWarnings("unchecked")
private static Map<String, String> toStringMap(Object value) {
if (!(value instanceof Map<?, ?> map)) {
return new HashMap<>();
}
Map<String, String> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (entry.getValue() != null) {
result.put(entry.getKey().toString(), entry.getValue().toString());
}
}
return result;
}
private record IntentRule(List<String> keywords, String intent, double confidence,
List<String> expectedSlots) {
}
}

View File

@@ -0,0 +1,35 @@
package com.sino.mci.inbound.intent;
import lombok.Data;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 意图识别结果。
*/
@Data
public class IntentResult {
private String intent;
private double confidence;
private Map<String, String> slots;
private List<String> missingSlots;
private boolean requiresConfirmation;
public IntentResult() {
this.slots = new HashMap<>();
this.missingSlots = new ArrayList<>();
}
public IntentResult(String intent, double confidence, Map<String, String> slots,
List<String> missingSlots, boolean requiresConfirmation) {
this.intent = intent;
this.confidence = confidence;
this.slots = slots != null ? slots : new HashMap<>();
this.missingSlots = missingSlots != null ? missingSlots : new ArrayList<>();
this.requiresConfirmation = requiresConfirmation;
}
}

View File

@@ -0,0 +1,40 @@
package com.sino.mci.inbound.intent;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 手动装配 OpenAI ChatClient。
*
* <p>Spring AI 1.0.0-M6 的 {@code OpenAiAutoConfiguration} 引用了 Spring Boot 4.1 中
* 不再存在的 {@code RestClientAutoConfiguration},因此排除官方自动配置;本配置在
* {@code spring.ai.openai.api-key} 存在时创建一个基于 {@code RestClient} 的
* {@link ChatModel},并据此生成 {@link ChatClient.Builder}。</p>
*/
@Configuration
@ConditionalOnExpression("'${spring.ai.openai.api-key:}'.trim() != ''")
public class OpenAiChatClientConfig {
@Value("${spring.ai.openai.api-key}")
private String apiKey;
@Value("${spring.ai.openai.base-url:https://api.openai.com}")
private String baseUrl;
@Value("${spring.ai.openai.chat.options.model:gpt-4o-mini}")
private String model;
@Bean
public ChatModel openAiChatModel() {
return new OpenAiRestChatModel(baseUrl, apiKey, model);
}
@Bean
public ChatClient.Builder openAiChatClientBuilder(ChatModel openAiChatModel) {
return ChatClient.builder(openAiChatModel);
}
}

View File

@@ -0,0 +1,89 @@
package com.sino.mci.inbound.intent;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
/**
* 基于 {@code RestClient} 的 OpenAI ChatModel 最小实现。
*
* <p>Spring AI 1.0.0-M6 的 {@code OpenAiChatModel} 与当前 Spring Boot 4.1 / Spring Framework 7
* 存在二进制不兼容({@code HttpHeaders.addAll} 返回类型变化),因此使用该实现替代,
* 仍通过 Spring AI {@link ChatModel} 接入 {@code ChatClient}。</p>
*/
@Slf4j
public class OpenAiRestChatModel implements ChatModel {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final String baseUrl;
private final String apiKey;
private final String model;
private final RestClient restClient;
public OpenAiRestChatModel(String baseUrl, String apiKey, String model) {
this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
this.apiKey = apiKey;
this.model = model;
this.restClient = RestClient.builder()
.baseUrl(this.baseUrl)
.defaultHeader("Authorization", "Bearer " + apiKey)
.build();
}
@Override
public ChatResponse call(Prompt prompt) {
String text = prompt.getContents();
Map<String, Object> request = Map.of(
"model", model,
"messages", List.of(Map.of("role", "user", "content", text)));
try {
String responseBody = restClient.post()
.uri("/v1/chat/completions")
.body(request)
.retrieve()
.body(String.class);
String content = extractContent(responseBody);
return new ChatResponse(List.of(new Generation(new AssistantMessage(content))));
} catch (Exception e) {
log.warn("OpenAI REST call failed: {}", e.getMessage());
throw new RuntimeException("LLM call failed", e);
}
}
@SuppressWarnings("unchecked")
private String extractContent(String responseBody) {
if (responseBody == null || responseBody.isBlank()) {
return "";
}
try {
Map<String, Object> root = OBJECT_MAPPER.readValue(responseBody, Map.class);
Object choices = root.get("choices");
if (!(choices instanceof List<?> list) || list.isEmpty()) {
return "";
}
Object first = list.get(0);
if (!(first instanceof Map<?, ?> choice)) {
return "";
}
Object message = choice.get("message");
if (!(message instanceof Map<?, ?> msg)) {
return "";
}
Object content = msg.get("content");
return content != null ? content.toString() : "";
} catch (Exception e) {
log.warn("Failed to parse LLM response: {}", e.getMessage());
return "";
}
}
}

View File

@@ -0,0 +1,35 @@
package com.sino.mci.inbound.mq;
import lombok.Data;
import java.io.Serializable;
/**
* 入站消息事件 MQ 消息体。
*
* <p>用于将已丰富业务上下文的消息事件发送到 {@code mci.inbound.exchange}
* 供下游阶段(意图识别、流程编排等)消费。</p>
*/
@Data
public class InboundEventMessage implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
private String eventId;
private String tenantCode;
private String channelType;
private Long channelSubjectId;
private Integer direction;
private String eventType;
private String messageSource;
private Long conversationId;
private Long personId;
private Long accountId;
private String content;
private String rawPayload;
private String businessContext;
private Integer isProcessed;
private Integer status;
private String createTime;
}

View File

@@ -0,0 +1,39 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.engine.FlowEngine;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.mq.config.MqConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
/**
* 入站流程事件消费者。
*
* <p>消费 {@code mci.inbound.flow.queue} 中意图识别后的事件,
* 加载完整 {@link MessageEvent} 并交由 {@link FlowEngine} 执行入站流程。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundFlowConsumer {
private final FlowEngine flowEngine;
private final MessageEventMapper messageEventMapper;
@RabbitListener(queues = MqConfig.INBOUND_FLOW_QUEUE)
public void handle(InboundEventMessage message) {
log.info("收到入站流程事件: eventId={}", message.getEventId());
MessageEvent event = messageEventMapper.selectById(message.getId());
if (event == null) {
log.warn("入站流程事件不存在: eventId={}", message.getEventId());
return;
}
flowEngine.executeInboundFlow(event.getId());
log.info("入站流程执行完成: eventId={}", event.getEventId());
}
}

View File

@@ -0,0 +1,56 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.mq.config.MqConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
/**
* 意图识别后的事件 MQ 生产者。
*
* <p>将已识别意图的入站消息事件发送到 {@code mci.inbound.flow.exchange}
* 供下游流程引擎Phase 3.8)消费。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundFlowMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发布意图识别后的入站消息事件。
*
* @param event 消息事件
*/
public void publish(MessageEvent event) {
InboundEventMessage message = convert(event);
rabbitTemplate.convertAndSend(MqConfig.INBOUND_FLOW_EXCHANGE,
MqConfig.INBOUND_FLOW_ROUTING_KEY, message);
log.info("Inbound flow event published: eventId={}", event.getEventId());
}
private InboundEventMessage convert(MessageEvent event) {
InboundEventMessage message = new InboundEventMessage();
message.setId(event.getId());
message.setEventId(event.getEventId());
message.setTenantCode(event.getTenantCode());
message.setChannelType(event.getChannelType());
message.setChannelSubjectId(event.getChannelSubjectId());
message.setDirection(event.getDirection());
message.setEventType(event.getEventType());
message.setMessageSource(event.getMessageSource());
message.setConversationId(event.getConversationId());
message.setPersonId(event.getPersonId());
message.setAccountId(event.getAccountId());
message.setContent(event.getContent());
message.setRawPayload(event.getRawPayload());
message.setBusinessContext(event.getBusinessContext());
message.setIsProcessed(event.getIsProcessed());
message.setStatus(event.getStatus());
message.setCreateTime(event.getCreateTime() != null ? event.getCreateTime().toString() : null);
return message;
}
}

View File

@@ -0,0 +1,79 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentRecognitionService;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.mq.config.MqConfig;
import com.sino.mci.shared.util.JsonUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 入站消息事件消费者。
*
* <p>消费 {@code mci.inbound.queue} 中的事件,进行意图识别,
* 将识别结果写回业务上下文,并发布到 {@code mci.inbound.flow.exchange}
* 供流程引擎处理。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundMessageConsumer {
private final MessageEventMapper messageEventMapper;
private final IntentRecognitionService intentRecognitionService;
private final InboundFlowMessageProducer inboundFlowMessageProducer;
@RabbitListener(queues = MqConfig.INBOUND_QUEUE)
public void handle(InboundEventMessage message) {
log.info("收到入站消息事件: eventId={}", message.getEventId());
MessageEvent event = messageEventMapper.selectById(message.getId());
if (event == null) {
log.warn("入站消息事件不存在: eventId={}", message.getEventId());
return;
}
String text = extractText(message.getContent());
Map<String, Object> businessContext = parseBusinessContext(event.getBusinessContext());
IntentResult intent = intentRecognitionService.recognize(
event.getTenantCode(), text, businessContext);
businessContext.put("intent", intent);
event.setBusinessContext(JsonUtils.toJson(businessContext));
event.setIsProcessed(0);
messageEventMapper.updateById(event);
inboundFlowMessageProducer.publish(event);
log.info("入站消息意图识别完成: eventId={}, intent={}, confidence={}",
event.getEventId(), intent.getIntent(), intent.getConfidence());
}
private String extractText(String contentJson) {
if (contentJson == null || contentJson.isBlank()) {
return null;
}
Map<String, Object> content = JsonUtils.fromJson(contentJson, Map.class);
if (content == null) {
return null;
}
Object body = content.get("body");
return body != null ? body.toString() : null;
}
@SuppressWarnings("unchecked")
private Map<String, Object> parseBusinessContext(String businessContextJson) {
if (businessContextJson == null || businessContextJson.isBlank()) {
return new LinkedHashMap<>();
}
Map<String, Object> context = JsonUtils.fromJson(businessContextJson, Map.class);
return context != null ? context : new LinkedHashMap<>();
}
}

View File

@@ -0,0 +1,52 @@
package com.sino.mci.inbound.mq;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.mq.config.MqConfig;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
/**
* 入站消息事件 MQ 生产者。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class InboundMessageProducer {
private final RabbitTemplate rabbitTemplate;
/**
* 发布已丰富的入站消息事件。
*
* @param event 消息事件
*/
public void publish(MessageEvent event) {
InboundEventMessage message = convert(event);
rabbitTemplate.convertAndSend(MqConfig.INBOUND_EXCHANGE, MqConfig.INBOUND_ROUTING_KEY, message);
log.info("Inbound event published: eventId={}", event.getEventId());
}
private InboundEventMessage convert(MessageEvent event) {
InboundEventMessage message = new InboundEventMessage();
message.setId(event.getId());
message.setEventId(event.getEventId());
message.setTenantCode(event.getTenantCode());
message.setChannelType(event.getChannelType());
message.setChannelSubjectId(event.getChannelSubjectId());
message.setDirection(event.getDirection());
message.setEventType(event.getEventType());
message.setMessageSource(event.getMessageSource());
message.setConversationId(event.getConversationId());
message.setPersonId(event.getPersonId());
message.setAccountId(event.getAccountId());
message.setContent(event.getContent());
message.setRawPayload(event.getRawPayload());
message.setBusinessContext(event.getBusinessContext());
message.setIsProcessed(event.getIsProcessed());
message.setStatus(event.getStatus());
message.setCreateTime(event.getCreateTime() != null ? event.getCreateTime().toString() : null);
return message;
}
}

View File

@@ -0,0 +1,146 @@
package com.sino.mci.inbound.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.sino.mci.channel.entity.Conversation;
import com.sino.mci.channel.entity.ConversationTagBinding;
import com.sino.mci.channel.repository.ConversationMapper;
import com.sino.mci.channel.repository.ConversationTagBindingMapper;
import com.sino.mci.crm.entity.Person;
import com.sino.mci.crm.entity.PersonTagBinding;
import com.sino.mci.crm.entity.Tag;
import com.sino.mci.crm.repository.PersonMapper;
import com.sino.mci.crm.repository.PersonTagBindingMapper;
import com.sino.mci.crm.repository.TagMapper;
import com.sino.mci.inbound.entity.MessageEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* 入站消息上下文匹配服务。
*
* <p>根据消息事件关联的会话与联系人,解析业务上下文(合同、机构、服务商、标签等),
* 供后续入站流程编排意图识别、webhook 推送)使用。</p>
*/
@Service
@RequiredArgsConstructor
public class InboundContextService {
private final ConversationMapper conversationMapper;
private final PersonMapper personMapper;
private final ConversationTagBindingMapper conversationTagBindingMapper;
private final PersonTagBindingMapper personTagBindingMapper;
private final TagMapper tagMapper;
/**
* 根据消息事件解析业务上下文。
*
* <p>解析规则:</p>
* <ul>
* <li>会话维度:合同、服务商、机构、业务渠道编码、会话标签、群主账号;</li>
* <li>联系人维度:人员类型、机构、服务商、人员标签;</li>
* <li>事件维度:渠道类型、收发账号。</li>
* </ul>
* <p>当会话与联系人的机构/服务商不一致时,优先保留会话维度的值。</p>
*
* @param event 消息事件
* @return 业务上下文键值对
*/
public Map<String, Object> resolveContext(MessageEvent event) {
Map<String, Object> context = new LinkedHashMap<>();
resolveConversationContext(event, context);
resolvePersonContext(event, context);
if (event.getChannelType() != null) {
context.put("channel_type", event.getChannelType());
}
if (event.getAccountId() != null) {
context.put("account_id", event.getAccountId());
}
return context;
}
private void resolveConversationContext(MessageEvent event, Map<String, Object> context) {
Long conversationId = event.getConversationId();
if (conversationId == null) {
return;
}
Conversation conversation = conversationMapper.selectById(conversationId);
if (conversation == null) {
return;
}
putIfNotNull(context, "contract_id", conversation.getContractId());
putIfNotNull(context, "supplier_id", conversation.getSupplierId());
putIfNotNull(context, "institution_id", conversation.getInstitutionId());
putIfNotNull(context, "channel_code", conversation.getChannelCode());
List<String> tagPaths = conversationTagBindingMapper.selectList(
new LambdaQueryWrapper<ConversationTagBinding>()
.eq(ConversationTagBinding::getConversationId, conversationId))
.stream()
.map(ConversationTagBinding::getTagId)
.map(tagMapper::selectById)
.filter(Objects::nonNull)
.map(Tag::getTagPath)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (!tagPaths.isEmpty()) {
context.put("conversation_tags", tagPaths);
}
putIfNotNull(context, "owner_account_id", conversation.getOwnerAccountId());
}
private void resolvePersonContext(MessageEvent event, Map<String, Object> context) {
Long personId = event.getPersonId();
if (personId == null) {
return;
}
Person person = personMapper.selectById(personId);
if (person == null) {
return;
}
putIfNotNull(context, "person_type", person.getPersonType());
putIfAbsentAndNotNull(context, "institution_id", person.getInstitutionId());
putIfAbsentAndNotNull(context, "supplier_id", person.getSupplierId());
List<String> tagPaths = personTagBindingMapper.selectList(
new LambdaQueryWrapper<PersonTagBinding>()
.eq(PersonTagBinding::getPersonId, personId))
.stream()
.map(PersonTagBinding::getTagId)
.map(tagMapper::selectById)
.filter(Objects::nonNull)
.map(Tag::getTagPath)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (!tagPaths.isEmpty()) {
context.put("person_tags", tagPaths);
}
}
private void putIfNotNull(Map<String, Object> context, String key, Object value) {
if (value != null) {
context.put(key, value);
}
}
private void putIfAbsentAndNotNull(Map<String, Object> context, String key, Object value) {
if (value != null) {
context.computeIfAbsent(key, k -> value);
}
}
}

View File

@@ -5,6 +5,7 @@ import com.sino.mci.inbound.dto.ReceiveMessageRequest;
import com.sino.mci.inbound.entity.FlowExecutionTrace;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.engine.FlowEngine;
import com.sino.mci.inbound.mq.InboundMessageProducer;
import com.sino.mci.inbound.repository.FlowExecutionTraceMapper;
import com.sino.mci.inbound.repository.MessageEventMapper;
import com.sino.mci.shared.util.JsonUtils;
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@@ -23,6 +25,8 @@ public class InboundMessageService {
private final MessageEventMapper messageEventMapper;
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
private final FlowEngine flowEngine;
private final InboundContextService inboundContextService;
private final InboundMessageProducer inboundMessageProducer;
@Transactional
public MessageEvent receiveMessage(String tenantCode, ReceiveMessageRequest request) {
@@ -46,6 +50,8 @@ public class InboundMessageService {
log.info("Inbound message received: tenant={}, eventId={}, conversationId={}",
tenantCode, request.getEventId(), request.getConversationId());
processInboundMessage(event);
FlowExecutionTrace receiveTrace = new FlowExecutionTrace();
receiveTrace.setEventId(event.getEventId());
receiveTrace.setNodeType("receive");
@@ -58,6 +64,20 @@ public class InboundMessageService {
return messageEventMapper.selectById(event.getId());
}
/**
* 处理入站消息:解析上下文、持久化并发布到 MQ。
*
* @param event 已持久化的入站消息事件
*/
public void processInboundMessage(MessageEvent event) {
Map<String, Object> context = inboundContextService.resolveContext(event);
event.setBusinessContext(JsonUtils.toJson(context));
messageEventMapper.updateById(event);
inboundMessageProducer.publish(event);
log.info("Inbound message processed and published: eventId={}, conversationId={}, personId={}",
event.getEventId(), event.getConversationId(), event.getPersonId());
}
public List<FlowExecutionTrace> listTraces(String eventId) {
return flowExecutionTraceMapper.selectList(
new LambdaQueryWrapper<FlowExecutionTrace>()

View File

@@ -0,0 +1,81 @@
package com.sino.mci.inbound.webhook;
import com.sino.mci.inbound.entity.MessageEvent;
import com.sino.mci.inbound.intent.IntentResult;
import com.sino.mci.shared.util.JsonUtils;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 入站 webhook 结构化 payload 构造器。
*
* <p>将 {@link MessageEvent} 与意图识别结果转换为业务系统可消费的标准 JSON 结构,
* 字段命名与架构文档 1.8 节保持一致snake_case。</p>
*/
@Component
public class WebhookPayloadBuilder {
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
public Map<String, Object> buildPayload(MessageEvent event, IntentResult intent) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("event_id", event.getEventId());
payload.put("tenant_code", event.getTenantCode());
payload.put("channel_type", event.getChannelType());
payload.put("direction", mapDirection(event.getDirection()));
payload.put("event_type", event.getEventType());
payload.put("conversation_id", event.getConversationId());
payload.put("person_id", event.getPersonId());
payload.put("account_id", event.getAccountId());
payload.put("content", parseJson(event.getContent()));
payload.put("intent", buildIntentMap(intent));
payload.put("business_context", parseJson(event.getBusinessContext()));
payload.put("raw_payload", parseJson(event.getRawPayload()));
payload.put("create_time", formatTime(event.getCreateTime()));
return payload;
}
private Object buildIntentMap(IntentResult intent) {
if (intent == null) {
return null;
}
Map<String, Object> map = new LinkedHashMap<>();
map.put("intent", intent.getIntent());
map.put("confidence", intent.getConfidence());
map.put("slots", intent.getSlots());
map.put("missing_slots", intent.getMissingSlots());
map.put("requires_confirmation", intent.isRequiresConfirmation());
return map;
}
@SuppressWarnings("unchecked")
private Object parseJson(String json) {
if (json == null || json.isBlank()) {
return null;
}
Object result = JsonUtils.fromJson(json, Object.class);
return result != null ? result : json;
}
private String mapDirection(Integer direction) {
if (direction == null) {
return "in";
}
return switch (direction) {
case 1 -> "in";
case 2 -> "out";
default -> String.valueOf(direction);
};
}
private String formatTime(LocalDateTime time) {
if (time == null) {
return null;
}
return time.format(ISO_FORMATTER);
}
}

View File

@@ -0,0 +1,11 @@
package com.sino.mci.inbound.webhook;
/**
* 入站 webhook 签名结果。
*
* @param signature Base64 编码的 HMAC 签名
* @param algorithm 签名算法,如 hmac-sha256 / hmac-sha512
* @param timestamp 签名时的时间戳Unix 秒)
*/
public record WebhookSignature(String signature, String algorithm, long timestamp) {
}

View File

@@ -0,0 +1,67 @@
package com.sino.mci.inbound.webhook;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* 入站 webhook 签名服务。
*
* <p>使用 HMAC-SHA256 / HMAC-SHA512 对原始 payload 进行签名,
* 输出 Base64 编码签名、算法标识及时间戳,供业务系统验签使用。</p>
*/
@Slf4j
@Service
public class WebhookSignatureService {
private static final String DEFAULT_ALGORITHM = "hmac-sha256";
private static final String HMAC_SHA256 = "HmacSHA256";
private static final String HMAC_SHA512 = "HmacSHA512";
/**
* 对原始 payload 进行签名。
*
* @param tenantCode 租户编码(用于日志/追踪,不参与签名计算)
* @param payload 待签名的原始 payload 字符串
* @param secret 租户 webhook 密钥
* @param algorithm 签名算法,支持 hmac-sha256默认和 hmac-sha512
* @return 签名结果,包含签名值、算法及时间戳
*/
public WebhookSignature sign(String tenantCode, String payload, String secret, String algorithm) {
String normalizedAlgorithm = normalizeAlgorithm(algorithm);
String signature = hmacSign(tenantCode, payload, secret, normalizedAlgorithm);
long timestamp = System.currentTimeMillis() / 1000;
return new WebhookSignature(signature, normalizedAlgorithm, timestamp);
}
private String normalizeAlgorithm(String algorithm) {
if (algorithm == null || algorithm.isBlank()) {
return DEFAULT_ALGORITHM;
}
return switch (algorithm.toLowerCase().replace("_", "-")) {
case "hmac-sha256", "sha256" -> "hmac-sha256";
case "hmac-sha512", "sha512" -> "hmac-sha512";
default -> DEFAULT_ALGORITHM;
};
}
private String hmacSign(String tenantCode, String payload, String secret, String algorithm) {
String hmacAlgorithm = algorithm.equals("hmac-sha512") ? HMAC_SHA512 : HMAC_SHA256;
try {
Mac mac = Mac.getInstance(hmacAlgorithm);
SecretKeySpec keySpec = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), hmacAlgorithm);
mac.init(keySpec);
byte[] signatureBytes = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(signatureBytes);
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
log.error("Failed to sign webhook payload for tenant {}", tenantCode, e);
throw new IllegalStateException("Webhook signature failed: " + algorithm, e);
}
}
}

View File

@@ -0,0 +1,119 @@
package com.sino.mci.mq.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
/**
* 消息发送 RabbitMQ 配置。
* <p>
* 重试机制主队列设置死信交换机DLX和死信路由键发送失败需要延迟重试时
* 将消息发送到 DLX 并设置 per-message TTL到期后由 DLX 重新路由回主队列消费。
*/
@Configuration
public class MqConfig {
public static final String SEND_EXCHANGE = "mci.send.exchange";
public static final String SEND_QUEUE = "mci.send.queue";
public static final String SEND_ROUTING_KEY = "mci.send";
public static final String SEND_DLX_EXCHANGE = "mci.send.dlx.exchange";
public static final String SEND_DLX_ROUTING_KEY = "mci.send.retry";
public static final String CALLBACK_EXCHANGE = "mci.callback.exchange";
public static final String CALLBACK_QUEUE = "mci.callback.queue";
public static final String CALLBACK_ROUTING_KEY = "mci.callback";
public static final String INBOUND_EXCHANGE = "mci.inbound.exchange";
public static final String INBOUND_QUEUE = "mci.inbound.queue";
public static final String INBOUND_ROUTING_KEY = "mci.inbound";
public static final String INBOUND_FLOW_EXCHANGE = "mci.inbound.flow.exchange";
public static final String INBOUND_FLOW_QUEUE = "mci.inbound.flow.queue";
public static final String INBOUND_FLOW_ROUTING_KEY = "mci.inbound.flow";
@Bean
public Queue sendQueue() {
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", SEND_DLX_EXCHANGE);
args.put("x-dead-letter-routing-key", SEND_DLX_ROUTING_KEY);
return new Queue(SEND_QUEUE, true, false, false, args);
}
@Bean
public DirectExchange sendExchange() {
return new DirectExchange(SEND_EXCHANGE, true, false);
}
@Bean
public Binding sendBinding(Queue sendQueue, DirectExchange sendExchange) {
return BindingBuilder.bind(sendQueue).to(sendExchange).with(SEND_ROUTING_KEY);
}
@Bean
public DirectExchange sendDlxExchange() {
return new DirectExchange(SEND_DLX_EXCHANGE, true, false);
}
@Bean
public Binding sendDlxBinding(Queue sendQueue, DirectExchange sendDlxExchange) {
return BindingBuilder.bind(sendQueue).to(sendDlxExchange).with(SEND_DLX_ROUTING_KEY);
}
@Bean
public Queue callbackQueue() {
return new Queue(CALLBACK_QUEUE, true);
}
@Bean
public DirectExchange callbackExchange() {
return new DirectExchange(CALLBACK_EXCHANGE, true, false);
}
@Bean
public Binding callbackBinding(Queue callbackQueue, DirectExchange callbackExchange) {
return BindingBuilder.bind(callbackQueue).to(callbackExchange).with(CALLBACK_ROUTING_KEY);
}
@Bean
public Queue inboundQueue() {
return new Queue(INBOUND_QUEUE, true);
}
@Bean
public DirectExchange inboundExchange() {
return new DirectExchange(INBOUND_EXCHANGE, true, false);
}
@Bean
public Binding inboundBinding(Queue inboundQueue, DirectExchange inboundExchange) {
return BindingBuilder.bind(inboundQueue).to(inboundExchange).with(INBOUND_ROUTING_KEY);
}
@Bean
public Queue inboundFlowQueue() {
return new Queue(INBOUND_FLOW_QUEUE, true);
}
@Bean
public DirectExchange inboundFlowExchange() {
return new DirectExchange(INBOUND_FLOW_EXCHANGE, true, false);
}
@Bean
public Binding inboundFlowBinding(Queue inboundFlowQueue, DirectExchange inboundFlowExchange) {
return BindingBuilder.bind(inboundFlowQueue).to(inboundFlowExchange).with(INBOUND_FLOW_ROUTING_KEY);
}
@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}

View File

@@ -0,0 +1,44 @@
package com.sino.mci.policy.engine;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Policy {
private PolicyType type;
// rate_limit
@JsonProperty("scope")
private String scope;
@JsonProperty("limit")
private Integer limit;
@JsonProperty("window")
private String window;
// time_window
@JsonProperty("allow")
private List<String> allow;
// blacklist
@JsonProperty("person_ids")
private List<Long> personIds;
@JsonProperty("conversation_ids")
private List<Long> conversationIds;
// retry
@JsonProperty("max_retry")
private Integer maxRetry;
@JsonProperty("interval")
private List<Integer> intervals;
}

View File

@@ -0,0 +1,18 @@
package com.sino.mci.policy.engine;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PolicyCheckResult {
private boolean allowed;
private String reasonCode;
private String reasonMessage;
private RetryConfig retryConfig;
}

View File

@@ -0,0 +1,240 @@
package com.sino.mci.policy.engine;
import com.sino.mci.send.entity.SendRecord;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
@Slf4j
@Service
@RequiredArgsConstructor
public class PolicyEngine {
private static final DateTimeFormatter HOURLY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHH");
private static final DateTimeFormatter DAILY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
private static final DateTimeFormatter WEEKLY_FORMATTER = DateTimeFormatter.ofPattern("YYYYww");
private final StringRedisTemplate redisTemplate;
public PolicyCheckResult check(SendRecord record, PolicyGroupConfig config) {
return check(record, config, LocalDateTime.now());
}
public PolicyCheckResult check(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
if (config == null || CollectionUtils.isEmpty(config.getPolicies())) {
return allowed(null);
}
PolicyCheckResult blacklistResult = checkBlacklist(record, config);
if (blacklistResult != null) {
return blacklistResult;
}
PolicyCheckResult timeWindowResult = checkTimeWindow(config, now);
if (timeWindowResult != null) {
return timeWindowResult;
}
PolicyCheckResult rateLimitResult = checkRateLimit(record, config, now);
if (rateLimitResult != null) {
return rateLimitResult;
}
return allowed(config.getRetryConfig().orElse(null));
}
public PolicyPreviewResult previewCheck(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
PolicyPreviewResult result = PolicyPreviewResult.builder()
.timeWindowOk(true)
.blacklistHit(0)
.build();
if (config == null || CollectionUtils.isEmpty(config.getPolicies())) {
return result;
}
if (isBlacklisted(record, config)) {
result.setBlacklistHit(1);
}
PolicyCheckResult timeWindowResult = checkTimeWindow(config, now);
result.setTimeWindowOk(timeWindowResult == null);
result.setRateLimitWarning(checkRateLimitDryRun(record, config, now));
return result;
}
private PolicyCheckResult allowed(RetryConfig retryConfig) {
return PolicyCheckResult.builder()
.allowed(true)
.retryConfig(retryConfig)
.build();
}
private PolicyCheckResult reject(String reasonCode, String reasonMessage) {
return PolicyCheckResult.builder()
.allowed(false)
.reasonCode(reasonCode)
.reasonMessage(reasonMessage)
.build();
}
private PolicyCheckResult checkBlacklist(SendRecord record, PolicyGroupConfig config) {
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.BLACKLIST) {
continue;
}
if (!CollectionUtils.isEmpty(policy.getPersonIds())
&& record.getPersonId() != null
&& policy.getPersonIds().contains(record.getPersonId())) {
return reject("blacklist_person", "发送目标人员在黑名单中");
}
if (!CollectionUtils.isEmpty(policy.getConversationIds())
&& record.getConversationId() != null
&& policy.getConversationIds().contains(record.getConversationId())) {
return reject("blacklist_conversation", "发送目标会话在黑名单中");
}
}
return null;
}
private boolean isBlacklisted(SendRecord record, PolicyGroupConfig config) {
return checkBlacklist(record, config) != null;
}
private PolicyCheckResult checkTimeWindow(PolicyGroupConfig config, LocalDateTime now) {
LocalTime current = now.toLocalTime();
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.TIME_WINDOW || CollectionUtils.isEmpty(policy.getAllow())) {
continue;
}
boolean insideAny = policy.getAllow().stream()
.map(this::parseTimeRange)
.filter(Optional::isPresent)
.map(Optional::get)
.anyMatch(range -> range.contains(current));
if (!insideAny) {
return reject("time_window_not_allowed", "当前时间不在允许发送窗口内");
}
}
return null;
}
private Optional<TimeRange> parseTimeRange(String range) {
if (range == null || !range.contains("-")) {
return Optional.empty();
}
String[] parts = range.split("-", 2);
try {
LocalTime start = LocalTime.parse(parts[0].trim());
LocalTime end = LocalTime.parse(parts[1].trim());
return Optional.of(new TimeRange(start, end));
} catch (Exception e) {
log.warn("时间窗口格式错误: {}", range);
return Optional.empty();
}
}
private PolicyCheckResult checkRateLimit(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.RATE_LIMIT) {
continue;
}
Long scopeId = resolveScopeId(record, policy.getScope());
if (scopeId == null || policy.getLimit() == null || policy.getLimit() <= 0) {
continue;
}
String key = buildRateLimitKey(record.getTenantCode(), policy.getScope(), scopeId, policy.getWindow(), now);
long ttlSeconds = resolveTtlSeconds(policy.getWindow());
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, Duration.ofSeconds(ttlSeconds));
}
if (count != null && count > policy.getLimit()) {
return reject("rate_limit_exceeded",
String.format("发送频率超过限制: scope=%s, limit=%d, window=%s", policy.getScope(), policy.getLimit(), policy.getWindow()));
}
}
return null;
}
private String checkRateLimitDryRun(SendRecord record, PolicyGroupConfig config, LocalDateTime now) {
for (Policy policy : config.getPolicies()) {
if (policy.getType() != PolicyType.RATE_LIMIT) {
continue;
}
Long scopeId = resolveScopeId(record, policy.getScope());
if (scopeId == null || policy.getLimit() == null || policy.getLimit() <= 0) {
continue;
}
String key = buildRateLimitKey(record.getTenantCode(), policy.getScope(), scopeId, policy.getWindow(), now);
String value = redisTemplate.opsForValue().get(key);
long count = 0L;
if (value != null) {
try {
count = Long.parseLong(value);
} catch (NumberFormatException e) {
log.warn("Redis 限流计数格式错误: key={}, value={}", key, value);
}
}
if (count >= policy.getLimit()) {
return String.format("scope=%s, id=%d 已超过频率限制: limit=%d, window=%s, current=%d",
policy.getScope(), scopeId, policy.getLimit(), policy.getWindow(), count);
}
if (count >= policy.getLimit() * 0.8) {
return String.format("scope=%s, id=%d 接近频率上限: limit=%d, window=%s, current=%d",
policy.getScope(), scopeId, policy.getLimit(), policy.getWindow(), count);
}
}
return null;
}
private Long resolveScopeId(SendRecord record, String scope) {
return switch (scope) {
case "account" -> record.getAccountId();
case "person" -> record.getPersonId();
case "conversation" -> record.getConversationId();
default -> null;
};
}
private String buildRateLimitKey(String tenantCode, String scope, Long scopeId, String window, LocalDateTime now) {
String period = switch (window) {
case "1h" -> now.format(HOURLY_FORMATTER);
case "7d" -> now.format(WEEKLY_FORMATTER);
default -> now.format(DAILY_FORMATTER);
};
return String.format("mci:rate:%s:%s:%s:%s", tenantCode, scope, scopeId, period);
}
private long resolveTtlSeconds(String window) {
return switch (window) {
case "1h" -> 3600L;
case "7d" -> 7 * 86400L;
default -> 86400L;
};
}
private record TimeRange(LocalTime start, LocalTime end) {
boolean contains(LocalTime time) {
if (!start.isAfter(end)) {
return !time.isBefore(start) && !time.isAfter(end);
}
return !time.isBefore(start) || !time.isAfter(end);
}
}
}

View File

@@ -0,0 +1,47 @@
package com.sino.mci.policy.engine;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Slf4j
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PolicyGroupConfig {
private List<Policy> policies = new ArrayList<>();
public static PolicyGroupConfig parseJson(String json) {
if (!StringUtils.hasText(json)) {
return new PolicyGroupConfig();
}
ObjectMapper mapper = new ObjectMapper();
try {
List<Policy> list = mapper.readValue(json, new TypeReference<List<Policy>>() {
});
return new PolicyGroupConfig(list != null ? list : new ArrayList<>());
} catch (JsonProcessingException e) {
log.warn("解析策略组配置失败: {}", json, e);
return new PolicyGroupConfig();
}
}
public Optional<RetryConfig> getRetryConfig() {
return policies.stream()
.filter(p -> p.getType() == PolicyType.RETRY)
.findFirst()
.map(p -> new RetryConfig(
p.getMaxRetry() == null ? 0 : p.getMaxRetry(),
p.getIntervals()));
}
}

View File

@@ -0,0 +1,17 @@
package com.sino.mci.policy.engine;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PolicyPreviewResult {
private String rateLimitWarning;
private boolean timeWindowOk;
private int blacklistHit;
}

View File

@@ -0,0 +1,18 @@
package com.sino.mci.policy.engine;
import com.fasterxml.jackson.annotation.JsonProperty;
public enum PolicyType {
@JsonProperty("rate_limit")
RATE_LIMIT,
@JsonProperty("time_window")
TIME_WINDOW,
@JsonProperty("blacklist")
BLACKLIST,
@JsonProperty("retry")
RETRY
}

Some files were not shown because too many files have changed in this diff Show More