wip: baseline changes before channel-conversation-task boundary implementation
This commit is contained in:
32
.env.example
Normal file
32
.env.example
Normal 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
5
.gitignore
vendored
@@ -15,6 +15,11 @@ __pycache__/
|
|||||||
venv/
|
venv/
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# Local runtime data
|
||||||
|
data/
|
||||||
|
*-data/
|
||||||
|
logs/
|
||||||
|
|
||||||
# Node
|
# Node
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -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)
|
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() {
|
export function listTenants() {
|
||||||
return client.get('/api/v1/account/tenant/list')
|
return client.get('/api/v1/account/tenant/list')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createUser(data: { username: string; password: string; realName?: string; roleCodes?: string[] }) {
|
export function listRoles() {
|
||||||
return client.post('/api/v1/account/user', data)
|
return client.get('/api/v1/account/role/list')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listUsers() {
|
export function createUser(
|
||||||
return client.get('/api/v1/account/user/list')
|
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() {
|
export function me() {
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
import client from './client'
|
import client from './client'
|
||||||
|
export * from './channelSubject'
|
||||||
export function createChannelSubject(data: any) {
|
export * from './channelAccount'
|
||||||
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 function createConversation(data: any) {
|
export function createConversation(data: any) {
|
||||||
return client.post('/api/v1/conversation', data)
|
return client.post('/api/v1/conversation', data)
|
||||||
@@ -28,6 +10,14 @@ export function listConversations() {
|
|||||||
return client.get('/api/v1/conversation/list')
|
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) {
|
export function bindConversationAccount(conversationId: number, data: any) {
|
||||||
return client.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
|
return client.post(`/api/v1/conversation/${conversationId}/bind-account`, data)
|
||||||
}
|
}
|
||||||
|
|||||||
33
admin-web/src/api/channelAccount.ts
Normal file
33
admin-web/src/api/channelAccount.ts
Normal 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`)
|
||||||
|
}
|
||||||
21
admin-web/src/api/channelSubject.ts
Normal file
21
admin-web/src/api/channelSubject.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -21,7 +21,13 @@ client.interceptors.request.use((config) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
client.interceptors.response.use(
|
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) => {
|
(error) => {
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|||||||
@@ -8,10 +8,42 @@ export function listPersons() {
|
|||||||
return client.get('/api/v1/person/list')
|
return client.get('/api/v1/person/list')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listTags() {
|
export function updatePerson(id: number, data: any) {
|
||||||
return client.get('/api/v1/tag/list')
|
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) {
|
export function createTag(data: any) {
|
||||||
return client.post('/api/v1/tag', data)
|
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}`)
|
||||||
|
}
|
||||||
|
|||||||
75
admin-web/src/api/inboundFlow.ts
Normal file
75
admin-web/src/api/inboundFlow.ts
Normal 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}`)
|
||||||
|
}
|
||||||
13
admin-web/src/api/inboundWebhook.ts
Normal file
13
admin-web/src/api/inboundWebhook.ts
Normal 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)
|
||||||
|
}
|
||||||
75
admin-web/src/api/ivrFlow.ts
Normal file
75
admin-web/src/api/ivrFlow.ts
Normal 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}`)
|
||||||
|
}
|
||||||
25
admin-web/src/api/sendPolicy.ts
Normal file
25
admin-web/src/api/sendPolicy.ts
Normal 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`)
|
||||||
|
}
|
||||||
31
admin-web/src/api/sendRecord.ts
Normal file
31
admin-web/src/api/sendRecord.ts
Normal 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 } })
|
||||||
|
}
|
||||||
@@ -27,12 +27,21 @@
|
|||||||
<el-menu-item index="/inbound-flow">
|
<el-menu-item index="/inbound-flow">
|
||||||
<span>入站流程</span>
|
<span>入站流程</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/ivr-flow">
|
||||||
|
<span>IVR 流程</span>
|
||||||
|
</el-menu-item>
|
||||||
<el-menu-item index="/flow-trace">
|
<el-menu-item index="/flow-trace">
|
||||||
<span>流程追踪</span>
|
<span>流程追踪</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item index="/message-test">
|
<el-menu-item index="/message-test">
|
||||||
<span>消息测试</span>
|
<span>消息测试</span>
|
||||||
</el-menu-item>
|
</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">
|
<el-menu-item v-if="authStore.roleCodes.includes('platform_admin')" index="/tenant-management">
|
||||||
<span>租户管理</span>
|
<span>租户管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ import Person from '../views/Person.vue'
|
|||||||
import Tag from '../views/Tag.vue'
|
import Tag from '../views/Tag.vue'
|
||||||
import SendRecord from '../views/SendRecord.vue'
|
import SendRecord from '../views/SendRecord.vue'
|
||||||
import InboundFlow from '../views/InboundFlow.vue'
|
import InboundFlow from '../views/InboundFlow.vue'
|
||||||
|
import IvrFlow from '../views/IvrFlow.vue'
|
||||||
import FlowTrace from '../views/FlowTrace.vue'
|
import FlowTrace from '../views/FlowTrace.vue'
|
||||||
import MessageTest from '../views/MessageTest.vue'
|
import MessageTest from '../views/MessageTest.vue'
|
||||||
import TenantManagement from '../views/TenantManagement.vue'
|
import TenantManagement from '../views/TenantManagement.vue'
|
||||||
import UserManagement from '../views/UserManagement.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 Login from '../views/Login.vue'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
|
|
||||||
@@ -34,8 +37,11 @@ const routes = [
|
|||||||
{ path: 'tag', component: Tag, meta: { title: '标签' } },
|
{ path: 'tag', component: Tag, meta: { title: '标签' } },
|
||||||
{ path: 'send-record', component: SendRecord, meta: { title: '发送记录' } },
|
{ path: 'send-record', component: SendRecord, meta: { title: '发送记录' } },
|
||||||
{ path: 'inbound-flow', component: InboundFlow, 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: 'flow-trace', component: FlowTrace, meta: { title: '流程追踪' } },
|
||||||
{ path: 'message-test', component: MessageTest, 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: 'tenant-management', component: TenantManagement, meta: { title: '租户管理' } },
|
||||||
{ path: 'user-management', component: UserManagement, meta: { title: '用户管理' } },
|
{ path: 'user-management', component: UserManagement, meta: { title: '用户管理' } },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref, computed } from 'vue'
|
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', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const token = ref(localStorage.getItem('mci-token') || '')
|
const token = ref(localStorage.getItem('mci-token') || '')
|
||||||
const tenantCode = ref(localStorage.getItem('mci-tenant-code') || '')
|
const tenantCode = ref(localStorage.getItem('mci-tenant-code') || '')
|
||||||
const username = ref('')
|
const username = ref(localStorage.getItem('mci-username') || '')
|
||||||
const roleCodes = ref<string[]>([])
|
const roleCodes = ref<string[]>(parseJson(localStorage.getItem('mci-role-codes')))
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!token.value)
|
const isLoggedIn = computed(() => !!token.value)
|
||||||
|
|
||||||
@@ -16,6 +26,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
roleCodes.value = roles
|
roleCodes.value = roles
|
||||||
localStorage.setItem('mci-token', t)
|
localStorage.setItem('mci-token', t)
|
||||||
localStorage.setItem('mci-tenant-code', tc)
|
localStorage.setItem('mci-tenant-code', tc)
|
||||||
|
localStorage.setItem('mci-username', user)
|
||||||
|
localStorage.setItem('mci-role-codes', JSON.stringify(roles))
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAuth() {
|
function clearAuth() {
|
||||||
@@ -25,6 +37,8 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
roleCodes.value = []
|
roleCodes.value = []
|
||||||
localStorage.removeItem('mci-token')
|
localStorage.removeItem('mci-token')
|
||||||
localStorage.removeItem('mci-tenant-code')
|
localStorage.removeItem('mci-tenant-code')
|
||||||
|
localStorage.removeItem('mci-username')
|
||||||
|
localStorage.removeItem('mci-role-codes')
|
||||||
}
|
}
|
||||||
|
|
||||||
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }
|
return { token, tenantCode, username, roleCodes, isLoggedIn, setAuth, clearAuth }
|
||||||
|
|||||||
@@ -1,68 +1,192 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form :model="form" inline>
|
<div class="toolbar">
|
||||||
<el-form-item label="账号类型">
|
<el-button type="primary" @click="openCreateDialog">创建渠道账号</el-button>
|
||||||
<el-select v-model="form.accountType" placeholder="类型">
|
</div>
|
||||||
<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-form-item>
|
|
||||||
<el-form-item label="所属主体">
|
|
||||||
<el-select v-model="form.subjectId" placeholder="选择主体">
|
|
||||||
<el-option
|
|
||||||
v-for="s in subjects"
|
|
||||||
:key="s.id"
|
|
||||||
:label="s.subjectName"
|
|
||||||
:value="s.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="账号ID">
|
|
||||||
<el-input v-model="form.accountId" placeholder="账号唯一标识" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="名称">
|
|
||||||
<el-input v-model="form.accountName" placeholder="名称" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="会话存档">
|
|
||||||
<el-switch v-model="form.sessionArchiveEnabled" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleCreate">创建</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<el-table :data="accounts" border>
|
<el-table :data="accounts" border>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="accountType" label="类型" />
|
<el-table-column prop="accountType" label="类型" width="140">
|
||||||
<el-table-column prop="channelType" label="渠道" />
|
<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="accountId" label="账号ID" />
|
||||||
<el-table-column prop="accountName" label="名称" />
|
<el-table-column prop="accountName" label="名称" />
|
||||||
<el-table-column prop="loginStatus" label="登录状态" />
|
<el-table-column prop="loginStatus" label="登录状态" width="100">
|
||||||
<el-table-column prop="onlineStatus" label="在线状态" />
|
<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-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="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="dialogForm.channelType" placeholder="wecom" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="所属主体">
|
||||||
|
<el-select v-model="dialogForm.subjectId" placeholder="选择主体" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="s in subjects"
|
||||||
|
:key="s.id"
|
||||||
|
:label="s.subjectName"
|
||||||
|
:value="s.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="账号ID">
|
||||||
|
<el-input v-model="dialogForm.accountId" placeholder="账号唯一标识" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="dialogForm.accountName" placeholder="名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="会话存档">
|
||||||
|
<el-switch v-model="dialogForm.sessionArchiveEnabled" :active-value="1" :inactive-value="0" />
|
||||||
|
</el-form-item>
|
||||||
|
<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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createChannelAccount, listChannelAccounts, listChannelSubjects } from '@/api/channel'
|
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,
|
accountType: 1,
|
||||||
channelType: 'wecom',
|
channelType: 'wecom',
|
||||||
subjectId: undefined as number | undefined,
|
subjectId: undefined as number | undefined,
|
||||||
accountId: '',
|
accountId: '',
|
||||||
accountName: '',
|
accountName: '',
|
||||||
sessionArchiveEnabled: false,
|
sessionArchiveEnabled: 0,
|
||||||
|
serverHost: '',
|
||||||
|
extInfo: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const accounts = ref<any[]>([])
|
const initDialogVisible = ref(false)
|
||||||
const subjects = ref<any[]>([])
|
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() {
|
async function loadAccounts() {
|
||||||
try {
|
try {
|
||||||
@@ -82,18 +206,138 @@ async function loadSubjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
const res: any = await createChannelAccount(form)
|
if (isEdit.value && editingId.value != null) {
|
||||||
accounts.value.unshift(res.data)
|
await updateChannelAccount(editingId.value, dialogForm)
|
||||||
ElMessage.success('创建成功')
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createChannelAccount(dialogForm)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadAccounts()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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(() => {
|
onMounted(() => {
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
loadSubjects()
|
loadSubjects()
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,59 +1,173 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form :model="form" inline>
|
<div class="toolbar">
|
||||||
<el-form-item label="渠道">
|
<el-button type="primary" @click="openCreateDialog">创建渠道主体</el-button>
|
||||||
<el-select v-model="form.channelType" placeholder="选择渠道">
|
</div>
|
||||||
<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-form-item>
|
|
||||||
<el-form-item label="主体名称">
|
|
||||||
<el-input v-model="form.subjectName" placeholder="主体名称" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="CorpID">
|
|
||||||
<el-input v-model="form.corpId" placeholder="CorpID" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="AgentID">
|
|
||||||
<el-input v-model="form.agentId" placeholder="AgentID" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="AgentSecret">
|
|
||||||
<el-input v-model="form.agentSecret" placeholder="AgentSecret" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleCreate">创建</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<el-table :data="subjects" border>
|
<el-table :data="subjects" border>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="channelType" label="渠道" />
|
<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="subjectCode" label="编码" />
|
||||||
<el-table-column prop="subjectName" label="名称" />
|
<el-table-column prop="subjectName" label="名称" />
|
||||||
<el-table-column prop="corpId" label="CorpID" />
|
<el-table-column prop="corpId" label="CorpID" />
|
||||||
<el-table-column prop="status" label="状态" />
|
<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-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="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="dialogForm.subjectCode" placeholder="主体编码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="主体名称">
|
||||||
|
<el-input v-model="dialogForm.subjectName" placeholder="主体名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="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="dialogForm.agentId" placeholder="AgentID" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="AgentSecret">
|
||||||
|
<el-input v-model="dialogForm.agentSecret" placeholder="AgentSecret" />
|
||||||
|
</el-form-item>
|
||||||
|
<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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createChannelSubject, listChannelSubjects } from '@/api/channel'
|
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',
|
channelType: 'wecom',
|
||||||
subjectCode: '',
|
subjectCode: '',
|
||||||
subjectName: '',
|
subjectName: '',
|
||||||
corpId: '',
|
corpId: '',
|
||||||
|
corpSecret: '',
|
||||||
agentId: '',
|
agentId: '',
|
||||||
agentSecret: '',
|
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() {
|
async function loadSubjects() {
|
||||||
try {
|
try {
|
||||||
@@ -64,15 +178,66 @@ async function loadSubjects() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleSave() {
|
||||||
try {
|
try {
|
||||||
const res: any = await createChannelSubject(form)
|
if (isEdit.value && editingId.value != null) {
|
||||||
subjects.value.unshift(res.data)
|
await updateChannelSubject(editingId.value, dialogForm)
|
||||||
ElMessage.success('创建成功')
|
ElMessage.success('更新成功')
|
||||||
|
} else {
|
||||||
|
await createChannelSubject(dialogForm)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
await loadSubjects()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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)
|
onMounted(loadSubjects)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,54 +1,99 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form :model="form" inline>
|
<div class="toolbar">
|
||||||
<el-form-item label="会话类型">
|
<el-button type="primary" @click="openCreateDialog">创建会话</el-button>
|
||||||
<el-select v-model="form.conversationType" placeholder="类型">
|
</div>
|
||||||
<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-form-item>
|
|
||||||
<el-form-item label="渠道会话ID">
|
|
||||||
<el-input v-model="form.channelConversationId" placeholder="roomid_xxx" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="名称">
|
|
||||||
<el-input v-model="form.conversationName" placeholder="群名称" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="负责人">
|
|
||||||
<el-select v-model="form.ownerAccountId" placeholder="选择负责人账号">
|
|
||||||
<el-option
|
|
||||||
v-for="a in accounts"
|
|
||||||
:key="a.id"
|
|
||||||
:label="a.accountName"
|
|
||||||
:value="a.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleCreate">创建</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<el-table :data="conversations" border>
|
<el-table :data="conversations" border>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="conversationType" label="类型" />
|
<el-table-column prop="conversationType" label="类型" width="100">
|
||||||
<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 }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openBind(row)">绑定账号</el-button>
|
{{ 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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<el-dialog v-model="bindVisible" title="绑定账号" width="500px">
|
<el-dialog v-model="dialogVisible" :title="isEdit ? '编辑会话' : '创建会话'" width="600px">
|
||||||
<el-form :model="bindForm" label-width="100px">
|
<el-form :model="dialogForm" label-width="120px">
|
||||||
|
<el-form-item label="会话类型">
|
||||||
|
<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="dialogForm.channelType" placeholder="wecom" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="渠道会话ID">
|
||||||
|
<el-input v-model="dialogForm.channelConversationId" :disabled="isEdit" placeholder="roomid_xxx" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="dialogForm.conversationName" placeholder="群名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="负责人">
|
||||||
|
<el-select v-model="dialogForm.ownerAccountId" placeholder="选择负责人账号" style="width: 100%">
|
||||||
|
<el-option
|
||||||
|
v-for="a in accounts"
|
||||||
|
:key="a.id"
|
||||||
|
:label="a.accountName"
|
||||||
|
:value="a.id"
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<el-dialog v-model="bindAccountVisible" title="绑定账号" width="500px">
|
||||||
|
<el-form :model="bindAccountForm" label-width="100px">
|
||||||
<el-form-item label="账号">
|
<el-form-item label="账号">
|
||||||
<el-select v-model="bindForm.accountId" placeholder="选择账号">
|
<el-select v-model="bindAccountForm.accountId" placeholder="选择账号" style="width: 100%">
|
||||||
<el-option
|
<el-option
|
||||||
v-for="a in accounts"
|
v-for="a in accounts"
|
||||||
:key="a.id"
|
:key="a.id"
|
||||||
@@ -58,7 +103,7 @@
|
|||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="绑定类型">
|
<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="1" />
|
||||||
<el-option label="发送备用号" :value="2" />
|
<el-option label="发送备用号" :value="2" />
|
||||||
<el-option label="接收主号" :value="3" />
|
<el-option label="接收主号" :value="3" />
|
||||||
@@ -67,40 +112,114 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="bindVisible = false">取消</el-button>
|
<el-button @click="bindAccountVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="handleBind">确认</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>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import {
|
import {
|
||||||
bindConversationAccount,
|
bindConversationAccount,
|
||||||
|
bindConversationTags,
|
||||||
createConversation,
|
createConversation,
|
||||||
|
deleteConversation,
|
||||||
listChannelAccounts,
|
listChannelAccounts,
|
||||||
listConversations,
|
listConversations,
|
||||||
|
updateConversation,
|
||||||
} from '@/api/channel'
|
} 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,
|
conversationType: 2,
|
||||||
channelType: 'wecom',
|
channelType: 'wecom',
|
||||||
channelConversationId: '',
|
channelConversationId: '',
|
||||||
conversationName: '',
|
conversationName: '',
|
||||||
ownerAccountId: undefined as number | undefined,
|
ownerAccountId: undefined as number | undefined,
|
||||||
|
status: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
const conversations = ref<any[]>([])
|
function resetDialogForm() {
|
||||||
const accounts = ref<any[]>([])
|
dialogForm.conversationType = 2
|
||||||
const bindVisible = ref(false)
|
dialogForm.channelType = 'wecom'
|
||||||
const currentConversationId = ref<number | undefined>()
|
dialogForm.channelConversationId = ''
|
||||||
const bindForm = reactive({
|
dialogForm.conversationName = ''
|
||||||
accountId: undefined as number | undefined,
|
dialogForm.ownerAccountId = undefined
|
||||||
bindingType: 1,
|
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() {
|
async function loadConversations() {
|
||||||
try {
|
try {
|
||||||
const res: any = await listConversations()
|
const res: any = await listConversations()
|
||||||
@@ -119,37 +238,136 @@ async function loadAccounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function loadTags() {
|
||||||
try {
|
try {
|
||||||
const res: any = await createConversation(form)
|
const res: any = await listTags()
|
||||||
conversations.value.unshift(res.data)
|
tags.value = res.data || []
|
||||||
ElMessage.success('创建成功')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
ElMessage.error(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openBind(row: any) {
|
async function handleSave() {
|
||||||
currentConversationId.value = row.id
|
if (!dialogForm.channelType || !dialogForm.channelConversationId) {
|
||||||
bindForm.accountId = undefined
|
ElMessage.warning('请填写渠道和渠道会话ID')
|
||||||
bindForm.bindingType = 1
|
return
|
||||||
bindVisible.value = true
|
}
|
||||||
|
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
|
if (!currentConversationId.value) return
|
||||||
try {
|
try {
|
||||||
await bindConversationAccount(currentConversationId.value, bindForm)
|
await bindConversationAccount(currentConversationId.value, bindAccountForm)
|
||||||
ElMessage.success('绑定成功')
|
ElMessage.success('绑定成功')
|
||||||
bindVisible.value = false
|
bindAccountVisible.value = false
|
||||||
await loadConversations()
|
await loadConversations()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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(() => {
|
onMounted(() => {
|
||||||
loadConversations()
|
loadConversations()
|
||||||
loadAccounts()
|
loadAccounts()
|
||||||
|
loadTags()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form inline>
|
<div class="toolbar">
|
||||||
<el-form-item label="事件ID">
|
<el-input v-model="eventId" placeholder="请输入 eventId" clearable style="width: 300px; margin-right: 12px" />
|
||||||
<el-input v-model="eventId" placeholder="请输入 eventId" clearable />
|
<el-button type="primary" @click="handleQuery">查询</el-button>
|
||||||
</el-form-item>
|
</div>
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleQuery">查询</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<el-timeline v-if="traces.length > 0">
|
<el-timeline v-if="traces.length > 0">
|
||||||
<el-timeline-item
|
<el-timeline-item
|
||||||
@@ -50,6 +46,9 @@ async function handleQuery() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
.error-msg {
|
.error-msg {
|
||||||
color: #f56c6c;
|
color: #f56c6c;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,111 +1,734 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="inbound-flow">
|
<div class="inbound-flow">
|
||||||
<el-form :model="form" label-width="100px">
|
<div class="flow-layout">
|
||||||
<el-form-item label="流程编码">
|
<!-- Left sidebar: flow list + node palette -->
|
||||||
<el-input v-model="form.flowCode" placeholder="flow_code" />
|
<div class="left-panel">
|
||||||
</el-form-item>
|
<div class="panel-section flow-list">
|
||||||
<el-form-item label="流程名称">
|
<div class="section-header">
|
||||||
<el-input v-model="form.flowName" placeholder="流程名称" />
|
<span class="section-title">流程列表</span>
|
||||||
</el-form-item>
|
<el-button type="primary" size="small" @click="createNewFlow">新建</el-button>
|
||||||
<el-form-item>
|
</div>
|
||||||
<el-button type="primary" @click="handleCreate">保存流程</el-button>
|
<el-table
|
||||||
</el-form-item>
|
:data="flows"
|
||||||
</el-form>
|
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="flow-canvas">
|
<div class="panel-section node-palette">
|
||||||
<VueFlow v-model="elements" fit-view-on-init>
|
<div class="section-header">
|
||||||
<Background pattern-color="#aaa" :gap="16" />
|
<span class="section-title">节点组件</span>
|
||||||
<Controls />
|
</div>
|
||||||
</VueFlow>
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { VueFlow } from '@vue-flow/core'
|
import { VueFlow, Handle, Position, useVueFlow } from '@vue-flow/core'
|
||||||
import { Background } from '@vue-flow/background'
|
import { Background } from '@vue-flow/background'
|
||||||
import { Controls } from '@vue-flow/controls'
|
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/style.css'
|
||||||
import '@vue-flow/core/dist/theme-default.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: '',
|
flowCode: '',
|
||||||
flowName: '',
|
flowName: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const elements = ref<any[]>([
|
const flows = ref<any[]>([])
|
||||||
{
|
const elements = ref<any[]>([])
|
||||||
id: '1',
|
const selectedNode = ref<any>(null)
|
||||||
type: 'input',
|
const selectedEdge = ref<any>(null)
|
||||||
label: '接收消息',
|
const isEdit = ref(false)
|
||||||
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' },
|
|
||||||
])
|
|
||||||
|
|
||||||
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 {
|
||||||
|
return JSON.parse(row.definitionJson)
|
||||||
|
} catch {
|
||||||
|
return { nodes: [], edges: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row.definitionJson
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFlow(flowCode: string) {
|
||||||
try {
|
try {
|
||||||
const nodes = elements.value
|
const res: any = await getFlow(flowCode)
|
||||||
.filter((el) => !('source' in el))
|
const row = res.data
|
||||||
.map((el) => {
|
if (!row) {
|
||||||
const { nodeType, ...config } = el.data || {}
|
ElMessage.warning('流程不存在')
|
||||||
return {
|
return
|
||||||
nodeType,
|
}
|
||||||
config,
|
|
||||||
position: el.position,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const edges = elements.value
|
form.flowCode = row.flowCode
|
||||||
.filter((el) => 'source' in el)
|
form.flowName = row.flowName
|
||||||
.map((el) => ({
|
isEdit.value = true
|
||||||
source: el.source,
|
|
||||||
target: el.target,
|
|
||||||
}))
|
|
||||||
|
|
||||||
const res: any = await createFlowDefinition({
|
const definition = parseDefinitionJson(row)
|
||||||
flowCode: form.flowCode,
|
const nodes = (definition.nodes || []).map((n: any, index: number) => {
|
||||||
flowName: form.flowName,
|
const type = n.type || n.nodeType || 'receive'
|
||||||
flowType: 'inbound',
|
const config = n.config || {}
|
||||||
definitionJson: { nodes, edges },
|
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 },
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
ElMessage.success('保存成功')
|
const nodeIds = new Set(nodes.map((n) => n.id))
|
||||||
console.log(res)
|
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) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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: '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('保存成功')
|
||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.inbound-flow {
|
.inbound-flow {
|
||||||
|
height: calc(100vh - 120px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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 {
|
.flow-canvas {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
width: 300px;
|
||||||
|
background: #fff;
|
||||||
border: 1px solid #e4e7ed;
|
border: 1px solid #e4e7ed;
|
||||||
border-radius: 4px;
|
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>
|
</style>
|
||||||
|
|||||||
83
admin-web/src/views/InboundWebhook.vue
Normal file
83
admin-web/src/views/InboundWebhook.vue
Normal 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>
|
||||||
780
admin-web/src/views/IvrFlow.vue
Normal file
780
admin-web/src/views/IvrFlow.vue
Normal 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>
|
||||||
@@ -34,6 +34,9 @@
|
|||||||
登录
|
登录
|
||||||
</el-button>
|
</el-button>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
<div class="login-hint">
|
||||||
|
全局管理员:租户 system / 用户名 superadmin / 密码 admin123
|
||||||
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -107,4 +110,10 @@ async function handleSubmit() {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
}
|
}
|
||||||
|
.login-hint {
|
||||||
|
margin-top: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>消息测试</h2>
|
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<el-tab-pane label="出站测试" name="outbound">
|
<el-tab-pane label="出站测试" name="outbound">
|
||||||
<el-form :model="outboundForm" label-width="120px">
|
<el-form :model="outboundForm" label-width="120px">
|
||||||
|
|||||||
@@ -1,52 +1,201 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form :model="form" inline>
|
<div class="toolbar">
|
||||||
<el-form-item label="编码">
|
<el-button type="primary" @click="openCreateDialog">创建联系人</el-button>
|
||||||
<el-input v-model="form.personCode" placeholder="人员编码" />
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
<el-table :data="persons" border>
|
<el-table :data="persons" border>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="personCode" label="编码" />
|
<el-table-column prop="personCode" label="编码" />
|
||||||
<el-table-column prop="personName" label="姓名" />
|
<el-table-column prop="personName" label="姓名" />
|
||||||
<el-table-column prop="phone" label="手机" />
|
<el-table-column prop="phone" label="手机" />
|
||||||
<el-table-column prop="personType" label="类型" />
|
<el-table-column prop="personType" label="类型" width="100">
|
||||||
<el-table-column prop="status" label="状态" />
|
<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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createPerson, listPersons } from '@/api/crm'
|
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: '',
|
personCode: '',
|
||||||
personName: '',
|
personName: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
personType: 3,
|
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() {
|
async function loadPersons() {
|
||||||
try {
|
try {
|
||||||
@@ -57,15 +206,183 @@ async function loadPersons() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function loadTags() {
|
||||||
try {
|
try {
|
||||||
const res: any = await createPerson(form)
|
const res: any = await listTags()
|
||||||
persons.value.unshift(res.data)
|
tags.value = res.data || []
|
||||||
ElMessage.success('创建成功')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
183
admin-web/src/views/SendPolicy.vue
Normal file
183
admin-web/src/views/SendPolicy.vue
Normal 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>
|
||||||
@@ -1,93 +1,439 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form :model="form" label-width="80px">
|
<!-- 统计卡片 -->
|
||||||
<el-form-item label="目标类型">
|
<el-row :gutter="16">
|
||||||
<el-select v-model="form.targetType">
|
<el-col :span="6">
|
||||||
<el-option label="会话" value="conversation" />
|
<el-card shadow="hover">
|
||||||
<el-option label="人员" value="person" />
|
<el-statistic title="今日发送总数" :value="statistics.total" />
|
||||||
</el-select>
|
</el-card>
|
||||||
</el-form-item>
|
</el-col>
|
||||||
<el-form-item label="目标" v-if="form.targetType === 'conversation'">
|
<el-col :span="6">
|
||||||
<el-select v-model="selectedConversationId" placeholder="选择会话">
|
<el-card shadow="hover">
|
||||||
<el-option v-for="c in conversations" :key="c.id" :label="c.conversationName" :value="c.id" />
|
<el-statistic title="成功" :value="statistics.successCount">
|
||||||
</el-select>
|
<template #suffix>
|
||||||
</el-form-item>
|
<el-tag type="success" size="small">成功</el-tag>
|
||||||
<el-form-item label="内容类型"><el-input v-model="form.contentType" disabled /></el-form-item>
|
</template>
|
||||||
<el-form-item label="内容">
|
</el-statistic>
|
||||||
<el-input v-model="textContent" type="textarea" :rows="3" placeholder="输入文本消息" />
|
</el-card>
|
||||||
</el-form-item>
|
</el-col>
|
||||||
<el-form-item>
|
<el-col :span="6">
|
||||||
<el-button type="primary" @click="handleTestSend">测试发送</el-button>
|
<el-card shadow="hover">
|
||||||
</el-form-item>
|
<el-statistic title="失败" :value="statistics.failedCount">
|
||||||
</el-form>
|
<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-divider />
|
<!-- 测试发送 -->
|
||||||
|
<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="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="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="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-table :data="records" border>
|
<!-- 筛选栏 -->
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-card class="mt-4" shadow="never">
|
||||||
<el-table-column prop="taskId" label="任务ID" />
|
<el-form :model="query" inline>
|
||||||
<el-table-column prop="targetType" label="目标类型" />
|
<el-form-item label="任务 ID">
|
||||||
<el-table-column prop="channelType" label="渠道" />
|
<el-input v-model="query.taskId" placeholder="SND..." clearable style="width: 220px" />
|
||||||
<el-table-column prop="contentType" label="内容类型" />
|
</el-form-item>
|
||||||
<el-table-column prop="sendStatus" label="状态">
|
<el-form-item label="发送时间">
|
||||||
<template #default="scope">
|
<el-date-picker
|
||||||
<el-tag v-if="scope.row.sendStatus === 2" type="success">成功</el-tag>
|
v-model="dateRange"
|
||||||
<el-tag v-else-if="scope.row.sendStatus === 3" type="danger">失败</el-tag>
|
type="datetimerange"
|
||||||
<el-tag v-else type="info">待发送</el-tag>
|
range-separator="至"
|
||||||
</template>
|
start-placeholder="开始时间"
|
||||||
</el-table-column>
|
end-placeholder="结束时间"
|
||||||
<el-table-column prop="createTime" label="创建时间" />
|
value-format="YYYY-MM-DD[T]HH:mm:ss"
|
||||||
</el-table>
|
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-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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref, computed } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { testSendMessage, listSendRecords } from '@/api/send'
|
import {
|
||||||
|
queryRecords,
|
||||||
|
getStatistics,
|
||||||
|
cancelTask,
|
||||||
|
testSend,
|
||||||
|
type QueryRecordsParams,
|
||||||
|
} from '@/api/sendRecord'
|
||||||
import { listConversations } from '@/api/channel'
|
import { listConversations } from '@/api/channel'
|
||||||
|
|
||||||
const form = reactive({ targetType: 'conversation', contentType: 'text', channelStrategy: { channel_type: 'wecom', strategy: 'primary' } })
|
interface RecordItem {
|
||||||
const selectedConversationId = ref<number | null>(null)
|
id: number
|
||||||
const textContent = ref('')
|
taskId: string
|
||||||
const records = ref<any[]>([])
|
targetType: string
|
||||||
const conversations = ref<any[]>([])
|
targetValue?: string
|
||||||
|
channelType: string
|
||||||
const targetValue = computed(() => {
|
sendStatus: number
|
||||||
if (form.targetType === 'conversation' && selectedConversationId.value) {
|
sendTime?: string
|
||||||
return { conversation_id: selectedConversationId.value }
|
finishTime?: string
|
||||||
}
|
contentBody?: string
|
||||||
return {}
|
channelResult?: string
|
||||||
})
|
callbackUrl?: string
|
||||||
|
|
||||||
async function loadRecords() {
|
|
||||||
const res: any = await listSendRecords()
|
|
||||||
records.value = res.data || []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadConversations() {
|
const statistics = reactive({
|
||||||
const res: any = await listConversations()
|
total: 0,
|
||||||
conversations.value = res.data || []
|
pendingCount: 0,
|
||||||
|
sendingCount: 0,
|
||||||
|
successCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
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 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() {
|
async function handleTestSend() {
|
||||||
|
const targetValue = buildTargetValue()
|
||||||
|
if (Object.keys(targetValue).length === 0) {
|
||||||
|
ElMessage.warning('请选择或填写目标')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await testSendMessage({
|
await testSend({
|
||||||
targetType: form.targetType,
|
targetType: testForm.targetType,
|
||||||
targetValue: targetValue.value,
|
targetValue,
|
||||||
contentType: 'text',
|
contentType: 'text',
|
||||||
content: { text: textContent.value },
|
content: { text: textContent.value },
|
||||||
channelStrategy: form.channelStrategy,
|
channelStrategy: testForm.channelStrategy,
|
||||||
})
|
})
|
||||||
ElMessage.success('测试发送已提交')
|
ElMessage.success('测试发送已提交')
|
||||||
|
textContent.value = ''
|
||||||
await loadRecords()
|
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) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
ElMessage.error(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadRecords()
|
|
||||||
loadConversations()
|
loadConversations()
|
||||||
|
loadRecords()
|
||||||
|
loadStatistics()
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -1,47 +1,124 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<el-form :model="form" inline>
|
<div class="toolbar">
|
||||||
<el-form-item label="标签名">
|
<el-button type="primary" @click="openCreateDialog">创建标签</el-button>
|
||||||
<el-input v-model="form.tagName" placeholder="标签名" />
|
</div>
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="父标签">
|
|
||||||
<el-select v-model="form.parentId" placeholder="选择父标签">
|
|
||||||
<el-option label="无" :value="0" />
|
|
||||||
<el-option
|
|
||||||
v-for="t in tags"
|
|
||||||
:key="t.id"
|
|
||||||
:label="t.tagName"
|
|
||||||
:value="t.id"
|
|
||||||
/>
|
|
||||||
</el-select>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" @click="handleCreate">创建</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<el-table :data="tags" border>
|
<el-table :data="tags" border>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="tagName" label="名称" />
|
<el-table-column prop="tagName" label="名称" />
|
||||||
<el-table-column prop="tagPath" label="路径" />
|
<el-table-column prop="tagPath" label="路径" show-overflow-tooltip />
|
||||||
<el-table-column prop="level" label="层级" />
|
<el-table-column prop="level" label="层级" width="100">
|
||||||
<el-table-column prop="status" label="状态" />
|
<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-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.tagName" placeholder="标签名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="父标签">
|
||||||
|
<el-select v-model="dialogForm.parentId" placeholder="选择父标签" style="width: 100%">
|
||||||
|
<el-option label="无" :value="0" />
|
||||||
|
<el-option
|
||||||
|
v-for="t in tags"
|
||||||
|
:key="t.id"
|
||||||
|
:label="t.tagName"
|
||||||
|
:value="t.id"
|
||||||
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createTag, listTags } from '@/api/crm'
|
import { createTag, deleteTag, listTags, updateTag } from '@/api/crm'
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
tagName: '',
|
|
||||||
parentId: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const tags = ref<any[]>([])
|
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() {
|
async function loadTags() {
|
||||||
try {
|
try {
|
||||||
const res: any = await listTags()
|
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 {
|
try {
|
||||||
const res: any = await createTag(form)
|
const payload = {
|
||||||
tags.value.unshift(res.data)
|
tagName: dialogForm.tagName,
|
||||||
ElMessage.success('创建成功')
|
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) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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)
|
onMounted(loadTags)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,38 +1,126 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>租户管理</h2>
|
<div class="toolbar">
|
||||||
<el-form :model="form" inline>
|
<el-button type="primary" @click="openCreateDialog">创建租户</el-button>
|
||||||
<el-form-item label="租户编码">
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
<el-table :data="tenants" border>
|
<el-table :data="tenants" border>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
<el-table-column prop="tenantCode" label="租户编码" />
|
<el-table-column prop="tenantCode" label="租户编码" />
|
||||||
<el-table-column prop="tenantName" 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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createTenant, listTenants } from '@/api/account'
|
import { createTenant, deleteTenant, listTenants, updateTenant } from '@/api/account'
|
||||||
|
|
||||||
const form = reactive({
|
|
||||||
tenantCode: '',
|
|
||||||
tenantName: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tenants = ref<any[]>([])
|
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() {
|
async function loadTenants() {
|
||||||
try {
|
try {
|
||||||
@@ -43,15 +131,80 @@ async function loadTenants() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleSave() {
|
||||||
|
if (!dialogForm.tenantCode || !dialogForm.tenantName) {
|
||||||
|
ElMessage.warning('请填写租户编码和租户名称')
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await createTenant(form)
|
if (isEdit.value && editingId.value != null) {
|
||||||
ElMessage.success('创建成功')
|
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()
|
await loadTenants()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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)
|
onMounted(loadTenants)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,62 +1,296 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h2>用户管理</h2>
|
<div class="toolbar">
|
||||||
<el-form :model="form" inline>
|
<el-select
|
||||||
<el-form-item label="用户名">
|
v-model="selectedTenant"
|
||||||
<el-input v-model="form.username" placeholder="用户名" />
|
placeholder="选择租户"
|
||||||
</el-form-item>
|
clearable
|
||||||
<el-form-item label="密码">
|
style="width: 200px; margin-right: 12px"
|
||||||
<el-input v-model="form.password" type="password" placeholder="密码" />
|
:disabled="!isPlatformAdmin"
|
||||||
</el-form-item>
|
@change="loadUsers"
|
||||||
<el-form-item label="真实姓名">
|
>
|
||||||
<el-input v-model="form.realName" placeholder="真实姓名" />
|
<el-option
|
||||||
</el-form-item>
|
v-for="t in tenants"
|
||||||
<el-form-item>
|
:key="t.id"
|
||||||
<el-button type="primary" @click="handleCreate">创建</el-button>
|
:label="`${t.tenantName}(${t.tenantCode})`"
|
||||||
</el-form-item>
|
:value="t.tenantCode"
|
||||||
</el-form>
|
/>
|
||||||
|
</el-select>
|
||||||
|
<el-button type="primary" @click="openCreateDialog">创建用户</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-table :data="users" border>
|
<el-table :data="users" border>
|
||||||
<el-table-column prop="id" label="ID" width="80" />
|
<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="username" label="用户名" />
|
||||||
<el-table-column prop="realName" label="真实姓名" />
|
<el-table-column prop="realName" label="真实姓名" />
|
||||||
<el-table-column prop="roleCodes" label="角色" />
|
<el-table-column prop="phone" label="手机号" />
|
||||||
<el-table-column prop="status" 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-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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { createUser, listUsers } from '@/api/account'
|
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: '',
|
username: '',
|
||||||
password: '',
|
password: '',
|
||||||
realName: '',
|
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() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const res: any = await listUsers()
|
const res: any = await listUsers(selectedTenant.value || undefined)
|
||||||
users.value = res.data || []
|
users.value = res.data || []
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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 {
|
try {
|
||||||
await createUser({ ...form, roleCodes: ['tenant_admin'] })
|
if (isEdit.value && editingId.value != null) {
|
||||||
ElMessage.success('创建成功')
|
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()
|
await loadUsers()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
ElMessage.error(e.message)
|
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>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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"}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# 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
|
WORKDIR /app
|
||||||
COPY target/*.jar app.jar
|
COPY target/*.jar app.jar
|
||||||
|
RUN chown app:app app.jar
|
||||||
|
|
||||||
|
USER app
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]
|
||||||
|
|||||||
@@ -70,6 +70,11 @@
|
|||||||
<artifactId>sa-token-spring-boot3-starter</artifactId>
|
<artifactId>sa-token-spring-boot3-starter</artifactId>
|
||||||
<version>1.39.0</version>
|
<version>1.39.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.ai</groupId>
|
||||||
|
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
|
||||||
|
<version>1.0.0-M6</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ import com.sino.mci.admin.dto.CreateUserRequest;
|
|||||||
import com.sino.mci.admin.dto.LoginRequest;
|
import com.sino.mci.admin.dto.LoginRequest;
|
||||||
import com.sino.mci.admin.dto.PlatformUserResponse;
|
import com.sino.mci.admin.dto.PlatformUserResponse;
|
||||||
import com.sino.mci.admin.dto.TokenResponse;
|
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.dto.UserResponse;
|
||||||
|
import com.sino.mci.admin.entity.PlatformRole;
|
||||||
import com.sino.mci.admin.entity.Tenant;
|
import com.sino.mci.admin.entity.Tenant;
|
||||||
import com.sino.mci.admin.security.AuthContext;
|
import com.sino.mci.admin.security.AuthContext;
|
||||||
import com.sino.mci.admin.service.AccountService;
|
import com.sino.mci.admin.service.AccountService;
|
||||||
@@ -28,11 +31,29 @@ public class AccountController {
|
|||||||
return ApiResponse.ok(accountService.createTenant(request));
|
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")
|
@GetMapping("/tenant/list")
|
||||||
public ApiResponse<List<Tenant>> listTenants() {
|
public ApiResponse<List<Tenant>> listTenants() {
|
||||||
return ApiResponse.ok(accountService.listTenants());
|
return ApiResponse.ok(accountService.listTenants());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/role/list")
|
||||||
|
public ApiResponse<List<PlatformRole>> listRoles() {
|
||||||
|
return ApiResponse.ok(accountService.listRoles());
|
||||||
|
}
|
||||||
|
|
||||||
@PostMapping("/user")
|
@PostMapping("/user")
|
||||||
public ApiResponse<UserResponse> createUser(
|
public ApiResponse<UserResponse> createUser(
|
||||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||||
@@ -40,9 +61,23 @@ public class AccountController {
|
|||||||
return ApiResponse.ok(accountService.createUser(tenantCode, request));
|
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")
|
@GetMapping("/user/list")
|
||||||
public ApiResponse<List<PlatformUserResponse>> listUsers() {
|
public ApiResponse<List<PlatformUserResponse>> listUsers(
|
||||||
return ApiResponse.ok(accountService.listUsers(AuthContext.currentTenantCode()));
|
@RequestParam(required = false) String tenantCode) {
|
||||||
|
return ApiResponse.ok(accountService.listUsers(tenantCode));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,4 +13,8 @@ public class CreateTenantRequest {
|
|||||||
private String tenantName;
|
private String tenantName;
|
||||||
|
|
||||||
private String inboundWebhookUrl;
|
private String inboundWebhookUrl;
|
||||||
|
|
||||||
|
private String initAdminUsername;
|
||||||
|
|
||||||
|
private String initAdminPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -14,5 +14,6 @@ public class Tenant extends BaseEntity {
|
|||||||
private String tenantName;
|
private String tenantName;
|
||||||
private String authConfig;
|
private String authConfig;
|
||||||
private String inboundWebhookUrl;
|
private String inboundWebhookUrl;
|
||||||
|
private String extInfo;
|
||||||
private Integer status;
|
private Integer status;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import com.sino.mci.admin.dto.CreateUserRequest;
|
|||||||
import com.sino.mci.admin.dto.LoginResponse;
|
import com.sino.mci.admin.dto.LoginResponse;
|
||||||
import com.sino.mci.admin.dto.PlatformUserResponse;
|
import com.sino.mci.admin.dto.PlatformUserResponse;
|
||||||
import com.sino.mci.admin.dto.TokenResponse;
|
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.dto.UserResponse;
|
||||||
import com.sino.mci.admin.entity.PlatformRole;
|
import com.sino.mci.admin.entity.PlatformRole;
|
||||||
import com.sino.mci.admin.entity.PlatformUser;
|
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.PlatformUserMapper;
|
||||||
import com.sino.mci.admin.repository.TenantMapper;
|
import com.sino.mci.admin.repository.TenantMapper;
|
||||||
import com.sino.mci.admin.repository.UserRoleMapper;
|
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.BusinessException;
|
||||||
import com.sino.mci.exception.UnauthorizedException;
|
import com.sino.mci.exception.UnauthorizedException;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
@@ -23,14 +26,21 @@ import org.springframework.security.crypto.bcrypt.BCrypt;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AccountService {
|
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 TenantMapper tenantMapper;
|
||||||
private final PlatformUserMapper userMapper;
|
private final PlatformUserMapper userMapper;
|
||||||
private final PlatformRoleMapper roleMapper;
|
private final PlatformRoleMapper roleMapper;
|
||||||
@@ -50,11 +60,51 @@ public class AccountService {
|
|||||||
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
|
tenant.setInboundWebhookUrl(request.getInboundWebhookUrl());
|
||||||
tenant.setStatus(1);
|
tenant.setStatus(1);
|
||||||
tenantMapper.insert(tenant);
|
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;
|
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
|
@Transactional
|
||||||
public UserResponse createUser(String tenantCode, CreateUserRequest request) {
|
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(
|
Tenant tenant = tenantMapper.selectOne(
|
||||||
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
|
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
|
||||||
if (tenant == null) {
|
if (tenant == null) {
|
||||||
@@ -64,37 +114,57 @@ public class AccountService {
|
|||||||
PlatformUser existing = userMapper.selectOne(
|
PlatformUser existing = userMapper.selectOne(
|
||||||
new LambdaQueryWrapper<PlatformUser>()
|
new LambdaQueryWrapper<PlatformUser>()
|
||||||
.eq(PlatformUser::getTenantCode, tenantCode)
|
.eq(PlatformUser::getTenantCode, tenantCode)
|
||||||
.eq(PlatformUser::getUsername, request.getUsername()));
|
.eq(PlatformUser::getUsername, username));
|
||||||
if (existing != null) {
|
if (existing != null) {
|
||||||
throw new BusinessException("用户名已存在: " + request.getUsername());
|
throw new BusinessException("用户名已存在: " + username);
|
||||||
}
|
}
|
||||||
|
|
||||||
PlatformUser user = new PlatformUser();
|
PlatformUser user = new PlatformUser();
|
||||||
user.setTenantCode(tenantCode);
|
user.setTenantCode(tenantCode);
|
||||||
user.setUsername(request.getUsername());
|
user.setUsername(username);
|
||||||
user.setPassword(BCrypt.hashpw(request.getPassword(), BCrypt.gensalt()));
|
user.setPassword(BCrypt.hashpw(password, BCrypt.gensalt()));
|
||||||
user.setRealName(request.getRealName());
|
user.setRealName(realName);
|
||||||
user.setPhone(request.getPhone());
|
user.setPhone(phone);
|
||||||
user.setStatus(1);
|
user.setStatus(1);
|
||||||
userMapper.insert(user);
|
userMapper.insert(user);
|
||||||
|
|
||||||
if (!CollectionUtils.isEmpty(request.getRoleCodes())) {
|
bindUserRoles(user.getId(), roleCodes);
|
||||||
for (String roleCode : request.getRoleCodes()) {
|
return user;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
@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);
|
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) {
|
public TokenResponse login(String tenantCode, String username, String password) {
|
||||||
PlatformUser user = userMapper.selectOne(
|
PlatformUser user = userMapper.selectOne(
|
||||||
new LambdaQueryWrapper<PlatformUser>()
|
new LambdaQueryWrapper<PlatformUser>()
|
||||||
@@ -125,12 +195,48 @@ public class AccountService {
|
|||||||
return tenantMapper.selectList(null);
|
return tenantMapper.selectList(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<PlatformRole> listRoles() {
|
||||||
|
return roleMapper.selectList(null);
|
||||||
|
}
|
||||||
|
|
||||||
public List<PlatformUserResponse> listUsers(String tenantCode) {
|
public List<PlatformUserResponse> listUsers(String tenantCode) {
|
||||||
List<PlatformUser> users = userMapper.selectList(
|
LambdaQueryWrapper<PlatformUser> wrapper = new LambdaQueryWrapper<>();
|
||||||
new LambdaQueryWrapper<PlatformUser>().eq(PlatformUser::getTenantCode, tenantCode));
|
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());
|
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) {
|
private UserResponse toUserResponse(PlatformUser user) {
|
||||||
UserResponse response = new UserResponse();
|
UserResponse response = new UserResponse();
|
||||||
response.setId(user.getId());
|
response.setId(user.getId());
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.sino.mci.channel.dto.ChannelAccountInitRequest;
|
|||||||
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
|
import com.sino.mci.channel.dto.CreateChannelAccountRequest;
|
||||||
import com.sino.mci.channel.dto.HeartbeatRequest;
|
import com.sino.mci.channel.dto.HeartbeatRequest;
|
||||||
import com.sino.mci.channel.dto.SyncResult;
|
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.entity.ChannelAccount;
|
||||||
import com.sino.mci.channel.service.ChannelAccountService;
|
import com.sino.mci.channel.service.ChannelAccountService;
|
||||||
import com.sino.mci.shared.web.ApiResponse;
|
import com.sino.mci.shared.web.ApiResponse;
|
||||||
@@ -28,6 +29,37 @@ public class ChannelAccountController {
|
|||||||
return ApiResponse.ok(accountService.createAccount(tenantCode, request));
|
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")
|
@PostMapping("/heartbeat")
|
||||||
public ApiResponse<ChannelAccount> heartbeat(@Valid @RequestBody HeartbeatRequest request) {
|
public ApiResponse<ChannelAccount> heartbeat(@Valid @RequestBody HeartbeatRequest request) {
|
||||||
return ApiResponse.ok(accountService.heartbeat(request.getAccountId(), request));
|
return ApiResponse.ok(accountService.heartbeat(request.getAccountId(), request));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.sino.mci.channel.controller;
|
|||||||
|
|
||||||
import com.sino.mci.admin.security.AuthContext;
|
import com.sino.mci.admin.security.AuthContext;
|
||||||
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
|
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.entity.ChannelSubject;
|
||||||
import com.sino.mci.channel.service.ChannelSubjectService;
|
import com.sino.mci.channel.service.ChannelSubjectService;
|
||||||
import com.sino.mci.shared.web.ApiResponse;
|
import com.sino.mci.shared.web.ApiResponse;
|
||||||
@@ -25,6 +26,30 @@ public class ChannelSubjectController {
|
|||||||
return ApiResponse.ok(subjectService.createSubject(tenantCode, request));
|
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")
|
@GetMapping("/list")
|
||||||
public ApiResponse<List<ChannelSubject>> listSubjects() {
|
public ApiResponse<List<ChannelSubject>> listSubjects() {
|
||||||
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));
|
return ApiResponse.ok(subjectService.listSubjects(AuthContext.currentTenantCode()));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.sino.mci.admin.security.AuthContext;
|
|||||||
import com.sino.mci.channel.dto.BindConversationAccountRequest;
|
import com.sino.mci.channel.dto.BindConversationAccountRequest;
|
||||||
import com.sino.mci.channel.dto.BindConversationTagsRequest;
|
import com.sino.mci.channel.dto.BindConversationTagsRequest;
|
||||||
import com.sino.mci.channel.dto.CreateConversationRequest;
|
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.entity.Conversation;
|
||||||
import com.sino.mci.channel.service.ConversationService;
|
import com.sino.mci.channel.service.ConversationService;
|
||||||
import com.sino.mci.shared.web.ApiResponse;
|
import com.sino.mci.shared.web.ApiResponse;
|
||||||
@@ -39,6 +40,22 @@ public class ConversationController {
|
|||||||
return ApiResponse.ok(conversationService.getConversation(tenantCode, conversationId));
|
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")
|
@PostMapping("/{conversation_id}/bind-account")
|
||||||
public ApiResponse<?> bindAccount(
|
public ApiResponse<?> bindAccount(
|
||||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ public class ChannelAccountInitRequest {
|
|||||||
@NotNull
|
@NotNull
|
||||||
private Integer accountType;
|
private Integer accountType;
|
||||||
|
|
||||||
|
private Long accountId;
|
||||||
|
|
||||||
private Long subjectId;
|
private Long subjectId;
|
||||||
|
|
||||||
private Map<String, Object> config;
|
private Map<String, Object> config;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import org.springframework.amqp.core.Binding;
|
|||||||
import org.springframework.amqp.core.BindingBuilder;
|
import org.springframework.amqp.core.BindingBuilder;
|
||||||
import org.springframework.amqp.core.Queue;
|
import org.springframework.amqp.core.Queue;
|
||||||
import org.springframework.amqp.core.TopicExchange;
|
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.connection.ConnectionFactory;
|
||||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||||
@@ -41,4 +42,14 @@ public class RabbitConfig {
|
|||||||
template.setMessageConverter(new Jackson2JsonMessageConverter());
|
template.setMessageConverter(new Jackson2JsonMessageConverter());
|
||||||
return template;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.sino.mci.channel.service;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.sino.mci.channel.dto.CreateChannelSubjectRequest;
|
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.entity.ChannelSubject;
|
||||||
import com.sino.mci.channel.repository.ChannelSubjectMapper;
|
import com.sino.mci.channel.repository.ChannelSubjectMapper;
|
||||||
import com.sino.mci.exception.BusinessException;
|
import com.sino.mci.exception.BusinessException;
|
||||||
@@ -25,6 +26,14 @@ public class ChannelSubjectService {
|
|||||||
.orderByDesc(ChannelSubject::getId));
|
.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
|
@Transactional
|
||||||
public ChannelSubject createSubject(String tenantCode, CreateChannelSubjectRequest request) {
|
public ChannelSubject createSubject(String tenantCode, CreateChannelSubjectRequest request) {
|
||||||
ChannelSubject existing = subjectMapper.selectOne(
|
ChannelSubject existing = subjectMapper.selectOne(
|
||||||
@@ -51,4 +60,47 @@ public class ChannelSubjectService {
|
|||||||
subjectMapper.insert(subject);
|
subjectMapper.insert(subject);
|
||||||
return 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.BindConversationAccountRequest;
|
||||||
import com.sino.mci.channel.dto.BindConversationTagsRequest;
|
import com.sino.mci.channel.dto.BindConversationTagsRequest;
|
||||||
import com.sino.mci.channel.dto.CreateConversationRequest;
|
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.ChannelAccountConversation;
|
||||||
import com.sino.mci.channel.entity.Conversation;
|
import com.sino.mci.channel.entity.Conversation;
|
||||||
import com.sino.mci.channel.entity.ConversationTagBinding;
|
import com.sino.mci.channel.entity.ConversationTagBinding;
|
||||||
@@ -73,6 +74,32 @@ public class ConversationService {
|
|||||||
return conversation;
|
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
|
@Transactional
|
||||||
public void bindAccount(String tenantCode, Long conversationId, BindConversationAccountRequest request) {
|
public void bindAccount(String tenantCode, Long conversationId, BindConversationAccountRequest request) {
|
||||||
getConversation(tenantCode, conversationId);
|
getConversation(tenantCode, conversationId);
|
||||||
|
|||||||
@@ -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.channel.wecom.archive.model.FinanceMessage;
|
||||||
import com.sino.mci.inbound.entity.MessageEvent;
|
import com.sino.mci.inbound.entity.MessageEvent;
|
||||||
import com.sino.mci.inbound.repository.MessageEventMapper;
|
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.entity.MessageMedia;
|
||||||
import com.sino.mci.message.repository.MessageMediaMapper;
|
import com.sino.mci.message.repository.MessageMediaMapper;
|
||||||
import com.sino.mci.shared.util.JsonUtils;
|
import com.sino.mci.shared.util.JsonUtils;
|
||||||
@@ -40,6 +41,7 @@ public class SessionArchivePullService {
|
|||||||
private final StringRedisTemplate redisTemplate;
|
private final StringRedisTemplate redisTemplate;
|
||||||
private final MessageEventMapper messageEventMapper;
|
private final MessageEventMapper messageEventMapper;
|
||||||
private final MessageMediaMapper messageMediaMapper;
|
private final MessageMediaMapper messageMediaMapper;
|
||||||
|
private final InboundMessageService inboundMessageService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 消费会话存档拉取命令。
|
* 消费会话存档拉取命令。
|
||||||
@@ -131,6 +133,7 @@ public class SessionArchivePullService {
|
|||||||
event.setIsProcessed(0);
|
event.setIsProcessed(0);
|
||||||
event.setStatus(1);
|
event.setStatus(1);
|
||||||
messageEventMapper.insert(event);
|
messageEventMapper.insert(event);
|
||||||
|
inboundMessageService.processInboundMessage(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void saveMediaIfNeeded(SessionArchivePullCommand command, FinanceMessage msg, String decryptedJson) {
|
private void saveMediaIfNeeded(SessionArchivePullCommand command, FinanceMessage msg, String decryptedJson) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.sino.mci.admin.security.AuthContext;
|
|||||||
import com.sino.mci.crm.dto.BindTagsRequest;
|
import com.sino.mci.crm.dto.BindTagsRequest;
|
||||||
import com.sino.mci.crm.dto.CreateIdentityRequest;
|
import com.sino.mci.crm.dto.CreateIdentityRequest;
|
||||||
import com.sino.mci.crm.dto.CreatePersonRequest;
|
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.entity.Person;
|
||||||
import com.sino.mci.crm.service.PersonService;
|
import com.sino.mci.crm.service.PersonService;
|
||||||
import com.sino.mci.shared.web.ApiResponse;
|
import com.sino.mci.shared.web.ApiResponse;
|
||||||
@@ -39,6 +40,22 @@ public class PersonController {
|
|||||||
return ApiResponse.ok(personService.getPerson(tenantCode, personId));
|
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")
|
@PostMapping("/{person_id}/identities")
|
||||||
public ApiResponse<?> createIdentity(
|
public ApiResponse<?> createIdentity(
|
||||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||||
@@ -55,6 +72,15 @@ public class PersonController {
|
|||||||
return ApiResponse.ok(personService.listIdentities(tenantCode, personId));
|
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")
|
@PostMapping("/{person_id}/bind-tags")
|
||||||
public ApiResponse<?> bindTags(
|
public ApiResponse<?> bindTags(
|
||||||
@RequestHeader("X-Tenant-Code") String tenantCode,
|
@RequestHeader("X-Tenant-Code") String tenantCode,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.sino.mci.crm.controller;
|
package com.sino.mci.crm.controller;
|
||||||
|
|
||||||
import com.sino.mci.crm.dto.CreateTagRequest;
|
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.crm.service.TagService;
|
||||||
import com.sino.mci.shared.web.ApiResponse;
|
import com.sino.mci.shared.web.ApiResponse;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -25,4 +26,20 @@ public class TagController {
|
|||||||
public ApiResponse<?> listTags(@RequestHeader("X-Tenant-Code") String tenantCode) {
|
public ApiResponse<?> listTags(@RequestHeader("X-Tenant-Code") String tenantCode) {
|
||||||
return ApiResponse.ok(tagService.listTags(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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.BindTagsRequest;
|
||||||
import com.sino.mci.crm.dto.CreateIdentityRequest;
|
import com.sino.mci.crm.dto.CreateIdentityRequest;
|
||||||
import com.sino.mci.crm.dto.CreatePersonRequest;
|
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.ChannelIdentity;
|
||||||
import com.sino.mci.crm.entity.Person;
|
import com.sino.mci.crm.entity.Person;
|
||||||
import com.sino.mci.crm.entity.PersonTagBinding;
|
import com.sino.mci.crm.entity.PersonTagBinding;
|
||||||
@@ -62,6 +63,35 @@ public class PersonService {
|
|||||||
return person;
|
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) {
|
public List<ChannelIdentity> listIdentities(String tenantCode, Long personId) {
|
||||||
getPerson(tenantCode, personId);
|
getPerson(tenantCode, personId);
|
||||||
return identityMapper.selectList(
|
return identityMapper.selectList(
|
||||||
@@ -96,6 +126,17 @@ public class PersonService {
|
|||||||
return identity;
|
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
|
@Transactional
|
||||||
public void bindTags(String tenantCode, Long personId, BindTagsRequest request) {
|
public void bindTags(String tenantCode, Long personId, BindTagsRequest request) {
|
||||||
getPerson(tenantCode, personId);
|
getPerson(tenantCode, personId);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.sino.mci.crm.service;
|
|||||||
|
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.sino.mci.crm.dto.CreateTagRequest;
|
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.entity.Tag;
|
||||||
import com.sino.mci.crm.repository.TagMapper;
|
import com.sino.mci.crm.repository.TagMapper;
|
||||||
import com.sino.mci.exception.BusinessException;
|
import com.sino.mci.exception.BusinessException;
|
||||||
@@ -54,6 +55,30 @@ public class TagService {
|
|||||||
return tag;
|
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) {
|
public List<Tag> listTags(String tenantCode) {
|
||||||
return tagMapper.selectList(
|
return tagMapper.selectList(
|
||||||
new LambdaQueryWrapper<Tag>()
|
new LambdaQueryWrapper<Tag>()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,4 +56,12 @@ public class FlowDefinitionController {
|
|||||||
flowDefinitionService.offlineFlow(tenantCode, flowCode);
|
flowDefinitionService.offlineFlow(tenantCode, flowCode);
|
||||||
return ApiResponse.ok();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,15 @@ public class FlowDefinitionService {
|
|||||||
.orderByDesc(FlowDefinition::getCreateTime));
|
.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
|
@Transactional
|
||||||
public FlowDefinition updateFlow(String tenantCode, String flowCode, CreateFlowRequest request) {
|
public FlowDefinition updateFlow(String tenantCode, String flowCode, CreateFlowRequest request) {
|
||||||
FlowDefinition flow = getFlow(tenantCode, flowCode);
|
FlowDefinition flow = getFlow(tenantCode, flowCode);
|
||||||
@@ -78,10 +87,16 @@ public class FlowDefinitionService {
|
|||||||
@Transactional
|
@Transactional
|
||||||
public void offlineFlow(String tenantCode, String flowCode) {
|
public void offlineFlow(String tenantCode, String flowCode) {
|
||||||
FlowDefinition flow = getFlow(tenantCode, flowCode);
|
FlowDefinition flow = getFlow(tenantCode, flowCode);
|
||||||
flow.setStatus(0);
|
flow.setStatus(2);
|
||||||
flowDefinitionMapper.updateById(flow);
|
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) {
|
private Integer parseFlowType(String flowType) {
|
||||||
if (flowType == null || flowType.isBlank()) {
|
if (flowType == null || flowType.isBlank()) {
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.sino.mci.inbound.engine;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 入站流程定义边。
|
||||||
|
*/
|
||||||
|
public record FlowEdge(String source, String target, String label) {
|
||||||
|
}
|
||||||
@@ -5,15 +5,21 @@ import com.sino.mci.flow.entity.FlowDefinition;
|
|||||||
import com.sino.mci.flow.repository.FlowDefinitionMapper;
|
import com.sino.mci.flow.repository.FlowDefinitionMapper;
|
||||||
import com.sino.mci.inbound.entity.FlowExecutionTrace;
|
import com.sino.mci.inbound.entity.FlowExecutionTrace;
|
||||||
import com.sino.mci.inbound.entity.MessageEvent;
|
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.FlowExecutionTraceMapper;
|
||||||
import com.sino.mci.inbound.repository.MessageEventMapper;
|
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 com.sino.mci.shared.util.JsonUtils;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
@@ -25,6 +31,7 @@ public class FlowEngine {
|
|||||||
private final IntentRecognitionService intentRecognitionService;
|
private final IntentRecognitionService intentRecognitionService;
|
||||||
private final WebhookPushService webhookPushService;
|
private final WebhookPushService webhookPushService;
|
||||||
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
|
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
|
||||||
|
private final SendService sendService;
|
||||||
|
|
||||||
public void executeInboundFlow(Long eventId) {
|
public void executeInboundFlow(Long eventId) {
|
||||||
MessageEvent event = messageEventMapper.selectById(eventId);
|
MessageEvent event = messageEventMapper.selectById(eventId);
|
||||||
@@ -33,7 +40,7 @@ public class FlowEngine {
|
|||||||
return;
|
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());
|
FlowDefinition flow = findPublishedFlow(event.getTenantCode());
|
||||||
Long flowDefinitionId = flow != null ? flow.getId() : null;
|
Long flowDefinitionId = flow != null ? flow.getId() : null;
|
||||||
@@ -44,35 +51,184 @@ public class FlowEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, Object> definition = JsonUtils.fromJson(flow.getDefinitionJson(), Map.class);
|
List<FlowNode> nodes = FlowDefinitionParser.parseNodes(flow.getDefinitionJson());
|
||||||
if (definition == null) {
|
List<FlowEdge> edges = FlowDefinitionParser.parseEdges(flow.getDefinitionJson());
|
||||||
log.warn("Invalid flow definition JSON for flow {}", flow.getFlowCode());
|
if (nodes.isEmpty()) {
|
||||||
|
log.warn("Flow {} has no nodes", flow.getFlowCode());
|
||||||
markProcessed(event, flowDefinitionId);
|
markProcessed(event, flowDefinitionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long recognizeStart = now();
|
FlowNode receiveNode = nodes.stream()
|
||||||
IntentRecognitionService.IntentResult intent = intentRecognitionService.recognize(event);
|
.filter(n -> "receive".equals(n.type()))
|
||||||
trace(event, flowDefinitionId, "intent_recognition",
|
.findFirst()
|
||||||
event.getContent(), JsonUtils.toJson(intent), 1, recognizeStart, null);
|
.orElse(null);
|
||||||
log.info("Intent recognized: eventId={}, intent={}, confidence={}",
|
if (receiveNode == null) {
|
||||||
event.getEventId(), intent.intent(), intent.confidence());
|
log.warn("Flow {} has no receive node", flow.getFlowCode());
|
||||||
|
markProcessed(event, flowDefinitionId);
|
||||||
long pushStart = now();
|
return;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
markProcessed(event, flowDefinitionId);
|
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) {
|
} catch (Exception e) {
|
||||||
log.error("Flow execution failed for event {}", eventId, 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) {
|
private FlowDefinition findPublishedFlow(String tenantCode) {
|
||||||
List<FlowDefinition> flows = flowDefinitionMapper.selectList(
|
List<FlowDefinition> flows = flowDefinitionMapper.selectList(
|
||||||
new LambdaQueryWrapper<FlowDefinition>()
|
new LambdaQueryWrapper<FlowDefinition>()
|
||||||
@@ -86,16 +242,16 @@ public class FlowEngine {
|
|||||||
private void markProcessed(MessageEvent event, Long flowDefinitionId) {
|
private void markProcessed(MessageEvent event, Long flowDefinitionId) {
|
||||||
event.setIsProcessed(1);
|
event.setIsProcessed(1);
|
||||||
messageEventMapper.updateById(event);
|
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) {
|
String input, String output, int status, long startMs, String errorMsg) {
|
||||||
FlowExecutionTrace record = new FlowExecutionTrace();
|
FlowExecutionTrace record = new FlowExecutionTrace();
|
||||||
record.setEventId(event.getEventId());
|
record.setEventId(event.getEventId());
|
||||||
record.setFlowDefinitionId(flowDefinitionId);
|
record.setFlowDefinitionId(flowDefinitionId);
|
||||||
record.setNodeType(nodeType);
|
record.setNodeType(nodeType);
|
||||||
record.setNodeConfig(null);
|
record.setNodeConfig(nodeConfig);
|
||||||
record.setInput(input);
|
record.setInput(input);
|
||||||
record.setOutput(output);
|
record.setOutput(output);
|
||||||
record.setStatus(status);
|
record.setStatus(status);
|
||||||
@@ -107,4 +263,7 @@ public class FlowEngine {
|
|||||||
private long now() {
|
private long now() {
|
||||||
return System.currentTimeMillis();
|
return System.currentTimeMillis();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record NodeResult(String nextNodeId, boolean terminal, boolean success, String output) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
package com.sino.mci.inbound.engine;
|
package com.sino.mci.inbound.engine;
|
||||||
|
|
||||||
import com.sino.mci.inbound.entity.MessageEvent;
|
import com.sino.mci.inbound.entity.MessageEvent;
|
||||||
|
import com.sino.mci.inbound.intent.IntentResult;
|
||||||
|
|
||||||
public interface IntentRecognitionService {
|
public interface IntentRecognitionService {
|
||||||
|
|
||||||
IntentResult recognize(MessageEvent event);
|
IntentResult recognize(MessageEvent event);
|
||||||
|
|
||||||
record IntentResult(String intent, double confidence, String reason) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,43 @@
|
|||||||
package com.sino.mci.inbound.engine;
|
package com.sino.mci.inbound.engine;
|
||||||
|
|
||||||
import com.sino.mci.inbound.entity.MessageEvent;
|
import com.sino.mci.inbound.entity.MessageEvent;
|
||||||
|
import com.sino.mci.inbound.intent.IntentResult;
|
||||||
import com.sino.mci.shared.util.JsonUtils;
|
import com.sino.mci.shared.util.JsonUtils;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class MockIntentRecognitionService implements IntentRecognitionService {
|
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
|
@Override
|
||||||
public IntentResult recognize(MessageEvent event) {
|
public IntentResult recognize(MessageEvent event) {
|
||||||
String text = extractText(event);
|
String text = extractText(event);
|
||||||
if (text == null || text.isBlank()) {
|
if (text == null || text.isBlank()) {
|
||||||
return new IntentResult("unknown", 0.0, "empty content");
|
return unknownResult();
|
||||||
}
|
}
|
||||||
String lower = text.toLowerCase();
|
String lower = text.toLowerCase();
|
||||||
if (lower.contains("救援") || lower.contains("拖车") || lower.contains("故障")) {
|
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("问一下")) {
|
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("不满")) {
|
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) {
|
private String extractText(MessageEvent event) {
|
||||||
@@ -40,4 +52,39 @@ public class MockIntentRecognitionService implements IntentRecognitionService {
|
|||||||
Object body = content.get("body");
|
Object body = content.get("body");
|
||||||
return body != null ? body.toString() : null;
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,25 @@ package com.sino.mci.inbound.engine;
|
|||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.sino.mci.admin.entity.Tenant;
|
import com.sino.mci.admin.entity.Tenant;
|
||||||
import com.sino.mci.admin.repository.TenantMapper;
|
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.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 com.sino.mci.shared.util.JsonUtils;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.client.RestClientException;
|
||||||
import org.springframework.web.client.RestTemplate;
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@@ -20,39 +29,91 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WebhookPushService {
|
public class WebhookPushService {
|
||||||
|
|
||||||
private final TenantMapper tenantMapper;
|
private static final String AUTH_CONFIG_WEBHOOK_SECRET = "webhook_secret";
|
||||||
private final RestTemplate restTemplate = new RestTemplate();
|
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(
|
Tenant tenant = tenantMapper.selectOne(
|
||||||
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
|
new LambdaQueryWrapper<Tenant>().eq(Tenant::getTenantCode, tenantCode));
|
||||||
if (tenant == null) {
|
if (tenant == null) {
|
||||||
log.warn("Tenant not found for webhook push: {}", tenantCode);
|
throw new WebhookPushException("Tenant not found for webhook push: " + tenantCode);
|
||||||
return;
|
}
|
||||||
|
return tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveWebhookUrl(Tenant tenant, String customUrl) {
|
||||||
|
if (StringUtils.hasText(customUrl)) {
|
||||||
|
return customUrl;
|
||||||
}
|
}
|
||||||
String webhookUrl = tenant.getInboundWebhookUrl();
|
String webhookUrl = tenant.getInboundWebhookUrl();
|
||||||
if (webhookUrl == null || webhookUrl.isBlank()) {
|
if (!StringUtils.hasText(webhookUrl)) {
|
||||||
log.warn("Tenant {} has no inbound webhook URL configured", tenantCode);
|
throw new WebhookPushException("Tenant " + tenant.getTenantCode() + " has no inbound webhook URL configured");
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
return webhookUrl;
|
||||||
|
}
|
||||||
|
|
||||||
Map<String, Object> payload = new HashMap<>();
|
@SuppressWarnings("unchecked")
|
||||||
payload.put("eventId", event.getEventId());
|
private Map<String, Object> parseAuthConfig(String authConfigJson) {
|
||||||
payload.put("tenantCode", tenantCode);
|
if (authConfigJson == null || authConfigJson.isBlank()) {
|
||||||
payload.put("channelType", event.getChannelType());
|
return Map.of();
|
||||||
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());
|
|
||||||
|
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
|
Map<String, Object> config = JsonUtils.fromJson(authConfigJson, Map.class);
|
||||||
|
return config != null ? config : Map.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getString(Map<String, Object> config, String key) {
|
||||||
|
Object value = config.get(key);
|
||||||
|
return value != null ? value.toString() : null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}),
|
||||||
|
* 其次使用默认规则;规则未命中时调用 LLM;LLM 未配置时返回 {@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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import com.sino.mci.inbound.dto.ReceiveMessageRequest;
|
|||||||
import com.sino.mci.inbound.entity.FlowExecutionTrace;
|
import com.sino.mci.inbound.entity.FlowExecutionTrace;
|
||||||
import com.sino.mci.inbound.entity.MessageEvent;
|
import com.sino.mci.inbound.entity.MessageEvent;
|
||||||
import com.sino.mci.inbound.engine.FlowEngine;
|
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.FlowExecutionTraceMapper;
|
||||||
import com.sino.mci.inbound.repository.MessageEventMapper;
|
import com.sino.mci.inbound.repository.MessageEventMapper;
|
||||||
import com.sino.mci.shared.util.JsonUtils;
|
import com.sino.mci.shared.util.JsonUtils;
|
||||||
@@ -14,6 +15,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -23,6 +25,8 @@ public class InboundMessageService {
|
|||||||
private final MessageEventMapper messageEventMapper;
|
private final MessageEventMapper messageEventMapper;
|
||||||
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
|
private final FlowExecutionTraceMapper flowExecutionTraceMapper;
|
||||||
private final FlowEngine flowEngine;
|
private final FlowEngine flowEngine;
|
||||||
|
private final InboundContextService inboundContextService;
|
||||||
|
private final InboundMessageProducer inboundMessageProducer;
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public MessageEvent receiveMessage(String tenantCode, ReceiveMessageRequest request) {
|
public MessageEvent receiveMessage(String tenantCode, ReceiveMessageRequest request) {
|
||||||
@@ -46,6 +50,8 @@ public class InboundMessageService {
|
|||||||
log.info("Inbound message received: tenant={}, eventId={}, conversationId={}",
|
log.info("Inbound message received: tenant={}, eventId={}, conversationId={}",
|
||||||
tenantCode, request.getEventId(), request.getConversationId());
|
tenantCode, request.getEventId(), request.getConversationId());
|
||||||
|
|
||||||
|
processInboundMessage(event);
|
||||||
|
|
||||||
FlowExecutionTrace receiveTrace = new FlowExecutionTrace();
|
FlowExecutionTrace receiveTrace = new FlowExecutionTrace();
|
||||||
receiveTrace.setEventId(event.getEventId());
|
receiveTrace.setEventId(event.getEventId());
|
||||||
receiveTrace.setNodeType("receive");
|
receiveTrace.setNodeType("receive");
|
||||||
@@ -58,6 +64,20 @@ public class InboundMessageService {
|
|||||||
return messageEventMapper.selectById(event.getId());
|
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) {
|
public List<FlowExecutionTrace> listTraces(String eventId) {
|
||||||
return flowExecutionTraceMapper.selectList(
|
return flowExecutionTraceMapper.selectList(
|
||||||
new LambdaQueryWrapper<FlowExecutionTrace>()
|
new LambdaQueryWrapper<FlowExecutionTrace>()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user