Browse Source

refactor(backend): 列表排序字段白名单统一走 SortUtils 防 SQL 注入

- 新增 SortUtils.resolveColumn 统一排序字段白名单映射,未知字段回退默认列
- 各 Controller/Service 列表接口新增 sortField/sortOrder 参数并接入白名单
- 前端列表页列加 sorter 并透传服务端排序参数
Spring-AI-1.1.2
wanghanlin 2 weeks ago
parent
commit
dc8aa7a9d1
  1. 7
      frontend/src/api/api-key.ts
  2. 4
      frontend/src/api/conversation.ts
  3. 4
      frontend/src/api/faq.ts
  4. 4
      frontend/src/api/llm-trace.ts
  5. 7
      frontend/src/api/mcp-server.ts
  6. 4
      frontend/src/api/model-config.ts
  7. 8
      frontend/src/api/sensitive-word.ts
  8. 4
      frontend/src/api/user.ts
  9. 7
      frontend/src/api/webhook.ts
  10. 2
      frontend/src/views/AccountManager.vue
  11. 10
      frontend/src/views/ApiKeyManager.vue
  12. 10
      frontend/src/views/AuditLogManager.vue
  13. 23
      frontend/src/views/CategoryManager.vue
  14. 10
      frontend/src/views/ConversationManager.vue
  15. 4
      frontend/src/views/DashboardPanel.vue
  16. 16
      frontend/src/views/FaqManager.vue
  17. 16
      frontend/src/views/FeedbackOps.vue
  18. 10
      frontend/src/views/McpServerManager.vue
  19. 16
      frontend/src/views/ModelConfigManager.vue
  20. 40
      frontend/src/views/PromptTracePanel.vue
  21. 18
      frontend/src/views/SensitiveWordManager.vue
  22. 14
      frontend/src/views/UserManager.vue
  23. 10
      frontend/src/views/WebhookManager.vue
  24. 40
      src/main/java/com/wok/supportbot/common/SortUtils.java
  25. 6
      src/main/java/com/wok/supportbot/controller/AiModelConfigController.java
  26. 6
      src/main/java/com/wok/supportbot/controller/ApiKeyController.java
  27. 6
      src/main/java/com/wok/supportbot/controller/ConversationController.java
  28. 6
      src/main/java/com/wok/supportbot/controller/FaqController.java
  29. 6
      src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java
  30. 6
      src/main/java/com/wok/supportbot/controller/McpServerConfigController.java
  31. 6
      src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java
  32. 22
      src/main/java/com/wok/supportbot/controller/SensitiveWordController.java
  33. 6
      src/main/java/com/wok/supportbot/controller/SysUserController.java
  34. 6
      src/main/java/com/wok/supportbot/controller/WebhookController.java
  35. 29
      src/main/java/com/wok/supportbot/service/AiModelConfigService.java
  36. 17
      src/main/java/com/wok/supportbot/service/ApiKeyService.java
  37. 24
      src/main/java/com/wok/supportbot/service/ConversationService.java
  38. 21
      src/main/java/com/wok/supportbot/service/FaqService.java
  39. 24
      src/main/java/com/wok/supportbot/service/LlmCallTraceService.java
  40. 21
      src/main/java/com/wok/supportbot/service/McpServerConfigService.java
  41. 22
      src/main/java/com/wok/supportbot/service/MessageFeedbackService.java
  42. 19
      src/main/java/com/wok/supportbot/service/SensitiveWordService.java
  43. 19
      src/main/java/com/wok/supportbot/service/SysUserService.java
  44. 17
      src/main/java/com/wok/supportbot/service/WebhookService.java

7
frontend/src/api/api-key.ts

@ -1,8 +1,11 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listApiKeys(page = 1, size = 20): Promise<ApiResponse> {
return request.get(`/api-key/list?page=${page}&size=${size}`).then(r => r.data)
export function listApiKeys(page = 1, size = 20, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/api-key/list?page=${page}&size=${size}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
/** 拉取全部 API Key(供筛选下拉,最多 1000 条) */
export function listAllApiKeys(): Promise<ApiResponse> {

4
frontend/src/api/conversation.ts

@ -1,11 +1,13 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listConversations(page = 1, size = 10, keyword?: string, accountId?: string, roleId?: string): Promise<ApiResponse> {
export function listConversations(page = 1, size = 10, keyword?: string, accountId?: string, roleId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/conversation/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (accountId) path += `&accountId=${encodeURIComponent(accountId)}`
if (roleId) path += `&roleId=${encodeURIComponent(roleId)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
export function getConversationDetail(conversationId: string): Promise<ApiResponse> { return request.get(`/conversation/${conversationId}`).then(r => r.data) }

4
frontend/src/api/faq.ts

@ -1,11 +1,13 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listFaqs(page = 1, size = 20, keyword?: string, categoryId?: string, status?: string): Promise<ApiResponse> {
export function listFaqs(page = 1, size = 20, keyword?: string, categoryId?: string, status?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/faq/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (categoryId) path += `&categoryId=${categoryId}`
if (status) path += `&status=${encodeURIComponent(status)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
export function createFaq(data: any): Promise<ApiResponse> { return request.post('/faq', data).then(r => r.data) }

4
frontend/src/api/llm-trace.ts

@ -13,6 +13,8 @@ export interface LlmTraceQuery {
keyword?: string
apiKeyId?: string
errorType?: string
sortField?: string
sortOrder?: string
}
/** 分页查询调用记录 */
@ -28,6 +30,8 @@ export function listLlmTraces(query: LlmTraceQuery = {}): Promise<ApiResponse> {
if (query.keyword) params.set('keyword', query.keyword)
if (query.apiKeyId) params.set('apiKeyId', query.apiKeyId)
if (query.errorType) params.set('errorType', query.errorType)
if (query.sortField) params.set('sortField', query.sortField)
if (query.sortOrder) params.set('sortOrder', query.sortOrder)
return request.get(`/llm-trace/list?${params.toString()}`).then(r => r.data)
}

7
frontend/src/api/mcp-server.ts

@ -1,7 +1,12 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listMcpServers(page = 1, size = 10): Promise<ApiResponse> { return request.get(`/mcp-server/list?page=${page}&size=${size}`).then(r => r.data) }
export function listMcpServers(page = 1, size = 10, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/mcp-server/list?page=${page}&size=${size}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
export function getMcpServerDetail(id: string): Promise<ApiResponse> { return request.get(`/mcp-server/${id}`).then(r => r.data) }
export function createMcpServer(data: any): Promise<ApiResponse> { return request.post('/mcp-server', data).then(r => r.data) }
export function updateMcpServer(id: string, data: any): Promise<ApiResponse> { return request.put(`/mcp-server/${id}`, data).then(r => r.data) }

4
frontend/src/api/model-config.ts

@ -1,9 +1,11 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listModelConfigs(page = 1, size = 10, appType?: string): Promise<ApiResponse> {
export function listModelConfigs(page = 1, size = 10, appType?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/model-config/list?page=${page}&size=${size}`
if (appType) path += `&appType=${encodeURIComponent(appType)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
export function getModelConfigDetail(id: string): Promise<ApiResponse> { return request.get(`/model-config/${id}`).then(r => r.data) }

8
frontend/src/api/sensitive-word.ts

@ -1,11 +1,13 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string, level?: number): Promise<ApiResponse> {
export function listSensitiveWords(page = 1, size = 20, keyword?: string, category?: string, level?: number, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/sensitive-word/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (category) path += `&category=${encodeURIComponent(category)}`
if (level !== undefined && level !== null) path += `&level=${level}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
export function createSensitiveWord(data: any): Promise<ApiResponse> { return request.post('/sensitive-word', data).then(r => r.data) }
@ -14,8 +16,10 @@ export function updateSensitiveWord(id: string, data: any): Promise<ApiResponse>
export function toggleSensitiveWord(id: string, isActive: boolean): Promise<ApiResponse> { return updateSensitiveWord(id, { isActive }) }
export function deleteSensitiveWord(id: string): Promise<ApiResponse> { return request.delete(`/sensitive-word/${id}`).then(r => r.data) }
export function batchImportSensitiveWords(data: { words: string[]; category: string; level: number }): Promise<ApiResponse> { return request.post('/sensitive-word/batch-import', data).then(r => r.data) }
export function listAuditLogs(page = 1, size = 20, sessionId?: string): Promise<ApiResponse> {
export function listAuditLogs(page = 1, size = 20, sessionId?: string, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/sensitive-word/audit-log?page=${page}&size=${size}`
if (sessionId) path += `&sessionId=${encodeURIComponent(sessionId)}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}

4
frontend/src/api/user.ts

@ -1,10 +1,12 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listSysUsers(page = 1, size = 20, keyword?: string, enabled?: boolean): Promise<ApiResponse> {
export function listSysUsers(page = 1, size = 20, keyword?: string, enabled?: boolean, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/sys-user/list?page=${page}&size=${size}`
if (keyword) path += `&keyword=${encodeURIComponent(keyword)}`
if (enabled !== undefined && enabled !== null) path += `&enabled=${enabled}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
export function getSysUserDetail(id: string): Promise<ApiResponse> { return request.get(`/sys-user/${id}`).then(r => r.data) }

7
frontend/src/api/webhook.ts

@ -1,7 +1,12 @@
import request from './request'
import type { ApiResponse } from '@/types/api'
export function listWebhooks(page = 1, size = 20): Promise<ApiResponse> { return request.get(`/webhook/list?page=${page}&size=${size}`).then(r => r.data) }
export function listWebhooks(page = 1, size = 20, sortField?: string, sortOrder?: string): Promise<ApiResponse> {
let path = `/webhook/list?page=${page}&size=${size}`
if (sortField) path += `&sortField=${sortField}`
if (sortOrder) path += `&sortOrder=${sortOrder}`
return request.get(path).then(r => r.data)
}
export function createWebhook(data: any): Promise<ApiResponse> { return request.post('/webhook', data).then(r => r.data) }
export function updateWebhook(id: string, data: any): Promise<ApiResponse> { return request.put(`/webhook/${id}`, data).then(r => r.data) }
export function deleteWebhook(id: string): Promise<ApiResponse> { return request.delete(`/webhook/${id}`).then(r => r.data) }

2
frontend/src/views/AccountManager.vue

@ -37,7 +37,7 @@ const { confirm } = useConfirm()
const accounts=ref<any[]>([])
const columns=[{colKey:'name',title:'账号名称',width:160},{colKey:'roles',title:'绑定角色',width:240},{colKey:'op',title:'操作',width:120}]
const columns=[{colKey:'name',title:'账号名称',width:160,sorter:true},{colKey:'roles',title:'绑定角色',width:240,sorter:true},{colKey:'op',title:'操作',width:120}]
const modal=reactive({visible:false,isEdit:false,id:null as any,name:''})

10
frontend/src/views/ApiKeyManager.vue

@ -5,8 +5,8 @@
<div style="flex:1;" /><span class="total-hint"> {{ total }} Key</span>
</div>
<t-table :data="keys" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<t-table :data="keys" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #keyValue="{row}">
<code class="key-code">{{ maskKey(row.keyValue) }}</code>
<t-button size="small" variant="text" @click="copyKey(row.keyValue)" title="复制">📋</t-button>
@ -95,7 +95,8 @@ const form=ref({name:'',description:'',rateLimit:60})
const createdKey=ref('')
const allRoles=ref<any[]>([]);const selectedRoleIds=ref<string[]>([]);const bindingKeyId=ref<any>(null);const bindingKeyName=ref('')
const columns=[{colKey:'name',title:'名称',width:140},{colKey:'keyValue',title:'Key',width:200},{colKey:'rateLimit',title:'频率限制',width:80},{colKey:'roleIds',title:'角色',width:90},{colKey:'enabled',title:'状态',width:80},{colKey:'op',title:'操作',width:260}]
const columns=[{colKey:'name',title:'名称',width:140,sorter:true},{colKey:'keyValue',title:'Key',width:200},{colKey:'rateLimit',title:'频率限制',width:80,sorter:true},{colKey:'roleIds',title:'角色',width:90},{colKey:'enabled',title:'状态',width:80,sorter:true},{colKey:'op',title:'操作',width:260}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>{loadList();loadRoles()})
@ -103,8 +104,9 @@ function maskKey(k:string){if(!k||k.length<12)return k||'—';return k.slice(0,6
function getBoundRoleCount(ids:any){try{const a=typeof ids==='string'?JSON.parse(ids):ids;return Array.isArray(a)?a.length:0}catch{return 0}}
async function loadList(){loading.value=true
try{const r=await listApiKeys(page.value,pageSize.value);if(r.success){keys.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
try{const r=await listApiKeys(page.value,pageSize.value,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){keys.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
async function loadRoles(){try{const r=await getAllRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}}

10
frontend/src/views/AuditLogManager.vue

@ -1,8 +1,8 @@
<template>
<t-card title="📋 内容审计日志" :bordered="false">
<p class="desc-text">记录所有敏感词命中事件包含输入/输出方向违规内容命中词及处理方式</p>
<t-table :data="logs" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<t-table :data="logs" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #direction="{row}"><span :style="{color:row.direction==='INPUT'?'var(--color-primary)':'var(--color-success)'}">{{ row.direction==='INPUT'?'输入':'输出' }}</span></template>
<template #originalText="{row}"><span :title="row.originalText" class="ellipsis-td">{{ row.originalText }}</span></template>
<template #actionTaken="{row}"><span :style="{color:row.actionTaken==='BLOCK'?'var(--color-error)':row.actionTaken==='MASK'?'var(--color-warning)':'var(--color-success)'}">{{ row.actionTaken==='BLOCK'?'拦截':row.actionTaken==='MASK'?'脱敏':'通过' }}</span></template>
@ -19,14 +19,16 @@ import { formatDate } from '@/utils/format'
const logs=ref<any[]>([]);const loading=ref(false);const page=ref(1);const pageSize=ref(30);const total=ref(0)
const columns=[{colKey:'createTime',title:'时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.createTime)},{colKey:'direction',title:'方向',width:60},{colKey:'originalText',title:'违规内容',width:260},{colKey:'hitWords',title:'命中词',width:180},{colKey:'actionTaken',title:'处理方式',width:80}]
const columns=[{colKey:'createTime',title:'时间',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.createTime)},{colKey:'direction',title:'方向',width:60,sorter:true},{colKey:'originalText',title:'违规内容',width:260},{colKey:'hitWords',title:'命中词',width:180},{colKey:'actionTaken',title:'处理方式',width:80,sorter:true}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>loadLogs())
async function loadLogs(){loading.value=true
try{const r=await listAuditLogs(page.value,pageSize.value);if(r.success){logs.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}else toast('加载失败:'+(r.message||''),'error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
try{const r=await listAuditLogs(page.value,pageSize.value,undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){logs.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}else toast('加载失败:'+(r.message||''),'error')}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadLogs()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadLogs()}
function formatHitWords(hw:any):string{
if(!hw)return'';if(hw.type==='jsonb'&&hw.value){try{hw=JSON.parse(hw.value)}catch{return hw.value}}

23
frontend/src/views/CategoryManager.vue

@ -4,10 +4,8 @@
<div class="create-bar card-box">
<h3 class="create-title">创建新分类</h3>
<t-space size="small" style="flex-wrap:wrap;">
<t-select v-model="newParentId" :options="parentOpts" placeholder="顶级分类" size="small" style="width:180px;" />
<t-input v-model="newName" placeholder="分类名称" size="small" style="width:160px;" @enter="create" />
<t-input v-model="newDescription" placeholder="描述(可选)" size="small" style="width:180px;" />
<t-input-number v-model="newSortOrder" placeholder="排序" size="small" style="width:80px;" :min="0" />
<t-button theme="success" size="small" @click="create">创建</t-button>
</t-space>
</div>
@ -39,7 +37,6 @@
<t-form label-align="top">
<t-form-item label="分类名称"><t-input v-model="editModal.name" placeholder="分类名称" /></t-form-item>
<t-form-item label="描述"><t-input v-model="editModal.description" placeholder="描述(可选)" /></t-form-item>
<t-form-item label="排序权重"><t-input-number v-model="editModal.sortOrder" :min="0" style="width:120px;" /></t-form-item>
</t-form>
<div class="dialog-footer">
<t-button variant="outline" @click="closeEdit">取消</t-button>
@ -60,16 +57,10 @@ const { confirm } = useConfirm()
const categoryStore = useCategoryStore()
const newName=ref(''); const newDescription=ref(''); const newParentId=ref(''); const newSortOrder=ref(0)
const parentOpts = computed(() => {
const opts = [{label:'顶级分类',value:''}]
function walk(cats: any[], level: number) { for(const c of cats){ opts.push({label:'─'.repeat(level)+' '+c.name,value:String(c.id)}); if(c.children?.length && level<2) walk(c.children,level+1) } }
walk(categoryTree.value,0); return opts
})
const newName=ref(''); const newDescription=ref('')
const expandedIds = ref(new Set<string>())
const editModal = ref({visible:false,id:null as any,name:'',description:'',sortOrder:0})
const editModal = ref({visible:false,id:null as any,name:'',description:''})
const categoryTree = computed(() => {
const cats = categoryStore.categories||[]; const m=new Map<string,any>(); const roots:any[]=[]
@ -95,14 +86,14 @@ function expandAll(){const s=new Set<string>();function walk(nodes:any[]){for(co
onMounted(async () => { await categoryStore.loadCategories(); setTimeout(expandAll,100) })
async function create(){if(!newName.value.trim()){toast('请输入分类名称','error');return}
try{const r=await createCategory({name:newName.value.trim(),description:newDescription.value.trim(),parentId:newParentId.value||null,sortOrder:newSortOrder.value})
if(r.success){toast('分类创建成功','success');newName.value='';newDescription.value='';newParentId.value='';newSortOrder.value=0;await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'创建失败','error')}catch(e:any){toast('创建失败:'+e.message,'error')}}
try{const r=await createCategory({name:newName.value.trim(),description:newDescription.value.trim()})
if(r.success){toast('分类创建成功','success');newName.value='';newDescription.value='';await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'创建失败','error')}catch(e:any){toast('创建失败:'+e.message,'error')}}
function openEdit(node:any){editModal.value={visible:true,id:node.id,name:node.name,description:node.description||'',sortOrder:node.sortOrder||0}}
function closeEdit(){editModal.value={visible:false,id:null,name:'',description:'',sortOrder:0}}
function openEdit(node:any){editModal.value={visible:true,id:node.id,name:node.name,description:node.description||''}}
function closeEdit(){editModal.value={visible:false,id:null,name:'',description:''}}
async function saveEdit(){if(!editModal.value.name.trim()){toast('请输入分类名称','error');return}
try{const r=await updateCategory(editModal.value.id,{name:editModal.value.name.trim(),description:editModal.value.description.trim(),sortOrder:editModal.value.sortOrder})
try{const r=await updateCategory(editModal.value.id,{name:editModal.value.name.trim(),description:editModal.value.description.trim()})
if(r.success){toast('分类更新成功','success');closeEdit();await categoryStore.loadCategories();setTimeout(expandAll,100)}else toast(r.message||'更新失败','error')}catch(e:any){toast('更新失败:'+e.message,'error')}}
async function remove(node:any){const cc=countDescendants(node);const m=cc>0?`\n注意:下有 ${cc} 个子分类`:''

10
frontend/src/views/ConversationManager.vue

@ -17,8 +17,8 @@
<t-button variant="outline" size="small" @click="load()">刷新</t-button>
</div>
<t-table :data="conversations" :columns="columns" row-key="conversationId"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<t-table :data="conversations" :columns="columns" row-key="conversationId" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #op="{row}">
<t-space :size="4">
<t-button size="small" variant="text" @click="viewMessages(row.conversationId)">查看消息</t-button>
@ -112,7 +112,8 @@ const canSubmitFaq = computed(() => {
return false
})
const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true},{colKey:'accountId',title:'外部用户ID',width:100},{colKey:'roleName',title:'角色',width:80},{colKey:'messageCount',title:'消息数',width:60},{colKey:'lastMessageTime',title:'最后消息时间',width:140,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}]
const columns=[{colKey:'conversationId',title:'会话ID',width:160,ellipsis:true,sorter:true},{colKey:'accountId',title:'外部用户ID',width:100,sorter:true},{colKey:'roleName',title:'角色',width:80,sorter:true},{colKey:'messageCount',title:'消息数',width:60,sorter:true},{colKey:'lastMessageTime',title:'最后消息时间',width:140,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.lastMessageTime)},{colKey:'lastMessagePreview',title:'最后消息预览',width:250,ellipsis:true},{colKey:'op',title:'操作',width:200}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>{loadFilters();load();loadStats();loadCategories()})
@ -131,8 +132,9 @@ function reasonLabel(c: string): string {
}
async function load(p=1){page.value=p
try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
try{const r=await listConversations(p,pageSize.value,keyword.value||undefined,accountFilter.value||undefined,roleFilter.value||undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){conversations.value=r.data||[];total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;load(1)}
async function loadFilters(){try{const r=await getRoleList();if(r.success){roles.value=r.data||[];roleOpts.value=[{label:'全部角色',value:''},...roles.value.map((ro:any)=>({label:ro.name,value:String(ro.id)}))]}}catch(e){console.error(e)}}

4
frontend/src/views/DashboardPanel.vue

@ -79,8 +79,8 @@ const satisfactionChartRef = ref<HTMLCanvasElement | null>(null)
const hitColumns = [
{ colKey: 'idx', title: '排名', width: 50, cell: (_h: any, { rowIndex }: any) => rowIndex + 1 },
{ colKey: 'document_title', title: '文档标题', ellipsis: true },
{ colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0 },
{ colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-' },
{ colKey: 'hit_count', title: '命中次数', width: 80, cell: (_h: any, { row }: any) => row.hit_count ?? row.cnt ?? 0, sorter: (a: any, b: any) => (a.hit_count ?? 0) - (b.hit_count ?? 0) },
{ colKey: 'avg_score', title: '平均得分', width: 80, cell: (_h: any, { row }: any) => row.avg_score != null ? Number(row.avg_score).toFixed(3) : '-', sorter: (a: any, b: any) => (a.avg_score ?? 0) - (b.avg_score ?? 0) },
]
const satisfactionColor = computed(() => {

16
frontend/src/views/FaqManager.vue

@ -19,8 +19,8 @@
</div>
<!-- FAQ 表格 -->
<t-table :data="faqs" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<t-table :data="faqs" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #status="{ row }">
<span @click="toggleStatus(row)" style="cursor:pointer;" :style="{color: row.status === 'ENABLED' ? 'var(--td-success-color)' : 'var(--td-error-color)'}">
{{ row.status === 'ENABLED' ? '✅ 启用' : '❌ 禁用' }}
@ -133,15 +133,16 @@ async function handleUploadImg(files: File[], callback: (urls: string[]) => void
}
const columns = [
{ colKey: 'question', title: '问题', width: 200, ellipsis: true },
{ colKey: 'question', title: '问题', width: 200, ellipsis: true, sorter: true },
{ colKey: 'answer', title: '答案(摘要)', width: 200, ellipsis: true, cell: (_h: any, { row }: any) => stripMarkdown(row.answer || '') },
{ colKey: 'categoryName', title: '分类', width: 100, cell: (_h:any,{row}:any)=>categoryMap.value[row.categoryId]||'-' },
{ colKey: 'source', title: '来源', width: 70, cell: (_h:any,{row}:any)=>sourceLabel(row.source) },
{ colKey: 'priority', title: '优先级', width: 60 },
{ colKey: 'hitCount', title: '命中', width: 55 },
{ colKey: 'status', title: '状态', width: 75 },
{ colKey: 'priority', title: '优先级', width: 60, sorter: true },
{ colKey: 'hitCount', title: '命中', width: 55, sorter: true },
{ colKey: 'status', title: '状态', width: 75, sorter: true },
{ colKey: 'op', title: '操作', width: 120 },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
const statusOptions = [{label:'全部状态',value:''},{label:'启用',value:'ENABLED'},{label:'禁用',value:'DISABLED'}]
const categoryOptions = computed(()=>[{label:'全部分类',value:''},...flatCategories.value.map(c=>({label:c.path,value:String(c.id)}))])
@ -159,13 +160,14 @@ function flattenTree(tree: any[], prefix = ''): any[] {
}
async function loadList() { loading.value = true
try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined)
try { const r = await listFaqs(page.value,pageSize.value,searchKeyword.value||undefined,filterCategoryId.value||undefined,filterStatus.value||undefined,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined)
if (r.success) { faqs.value = r.data?.records||r.data||[]; total.value = r.data?.total||r.total||0 } } catch(e:any){ toast('加载失败:'+e.message,'error') }
finally { loading.value = false } }
async function loadStats() { try { const r = await getFaqStats(); if(r.success) stats.value=r.data } catch {/* */} }
function onPageChange(info:{current:number;pageSize:number}) { page.value=info.current; pageSize.value=info.pageSize; loadList() }
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
const debouncedSearch = debounce(() => { page.value = 1; loadList() })
/** 表单当前值快照,用于判断是否有未保存修改 */

16
frontend/src/views/FeedbackOps.vue

@ -11,8 +11,8 @@
</div>
<!-- 反馈列表 -->
<t-table :data="feedbacks" :columns="columns" row-key="conversationId" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange"
<t-table :data="feedbacks" :columns="columns" row-key="conversationId" :loading="loading" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange"
empty="暂无反馈数据,用户在 SDK 端对 AI 回复点赞或点踩后将在此展示。">
<template #feedbackType="{ row }">
<t-tag :theme="row.feedbackType === 'THUMBS_UP' ? 'success' : 'danger'" variant="light">
@ -133,15 +133,16 @@ const processedOptions = [
]
const columns = [
{ colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType' },
{ colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory' },
{ colKey: 'feedbackType', title: '反馈', width: 90, cell: 'feedbackType', sorter: true },
{ colKey: 'reasonCategory', title: '原因', width: 80, cell: 'reasonCategory', sorter: true },
{ colKey: 'reasonComment', title: '补充说明', width: 160, ellipsis: true, cell: 'reasonComment' },
{ colKey: 'userQuestion', title: '用户问题', width: 200, ellipsis: true, cell: 'userQuestion' },
{ colKey: 'assistantAnswer', title: 'AI 回复', width: 200, ellipsis: true, cell: 'assistantAnswer' },
{ colKey: 'processed', title: '状态', width: 70, cell: 'processed' },
{ colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime) },
{ colKey: 'processed', title: '状态', width: 70, cell: 'processed', sorter: true },
{ colKey: 'feedbackTime', title: '时间', width: 150, cell: (_: any, { row }: any) => formatDate(row.feedbackTime), sorter: true },
{ colKey: 'op', title: '操作', width: 240, cell: 'op' },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
const canSubmit = computed(() => {
const m = faqModal.value
@ -201,6 +202,8 @@ async function loadList(p: number) {
page.value = p; loading.value = true
try {
const params: Record<string, any> = { page: p, size: pageSize.value }
if (sortInfo.value?.sortBy) params.sortField = sortInfo.value.sortBy
if (sortInfo.value) params.sortOrder = sortInfo.value.descending ? 'desc' : 'asc'
if (filterFeedbackType.value) params.feedbackType = filterFeedbackType.value
if (filterReasonCategory.value) params.reasonCategory = filterReasonCategory.value
if (filterProcessed.value) params.processed = filterProcessed.value === 'true'
@ -222,6 +225,7 @@ async function loadList(p: number) {
}
function onPageChange(i: { current: number; pageSize: number }) { pageSize.value = i.pageSize; loadList(i.current) }
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList(1) }
async function viewDetail(row: any) {
try {

10
frontend/src/views/McpServerManager.vue

@ -9,8 +9,8 @@
<span class="total-hint"> {{ total }} 条配置</span>
</div>
<t-table :data="servers" :columns="columns" row-key="id"
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<t-table :data="servers" :columns="columns" row-key="id" :sort="sortInfo"
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #transport_type="{ row }"><t-tag size="small" :theme="row.transport_type==='sse'?'primary':'default'" variant="light">{{ row.transport_type==='sse'?'SSE':'Stdio' }}</t-tag></template>
<template #displayUrl="{ row }"><span class="ellipsis" :title="getDisplayUrl(row)">{{ getDisplayUrl(row)||'-' }}</span></template>
<template #is_active="{ row }"><t-switch v-model="row._active" size="small" @change="toggleActive(row)" /></template>
@ -74,14 +74,16 @@ const form=ref({name:'',transportType:'sse',url:'',command:'',argsText:'',envTex
const testLoading=ref<Record<string,boolean>>({}); const testResult=ref({visible:false,success:false,message:'' as any}); let testTimer:any=null
const refreshLoading=ref(false)
const columns=[{colKey:'name',title:'名称',width:130},{colKey:'transport_type',title:'传输类型',width:90},{colKey:'displayUrl',title:'URL/命令',width:200,ellipsis:true},{colKey:'is_active',title:'状态',width:70},{colKey:'description',title:'描述',width:140,ellipsis:true},{colKey:'create_time',title:'创建时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:170,}]
const columns=[{colKey:'name',title:'名称',width:130,sorter:true},{colKey:'transport_type',title:'传输类型',width:90,sorter:true},{colKey:'displayUrl',title:'URL/命令',width:200,ellipsis:true},{colKey:'is_active',title:'状态',width:70,sorter:true},{colKey:'description',title:'描述',width:140,ellipsis:true,sorter:true},{colKey:'create_time',title:'创建时间',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:170,}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>load())
onUnmounted(()=>{if(testTimer)clearTimeout(testTimer)})
async function load(p=1){currentPage.value=p
try{const r=await api.listMcpServers(p,pageSize.value);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
try{const r=await api.listMcpServers(p,pageSize.value,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){servers.value=(r.data||[]).map((s:any)=>({...s,_active:s.is_active}));total.value=r.total||0}else toast(r.message||'查询失败','error')}catch(e:any){toast('加载失败:'+e.message,'error')}}
function onPageChange(i:{current:number;pageSize:number}){pageSize.value=i.pageSize;load(i.current)}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;load(1)}
function createEmpty(){return{name:'',transportType:'sse',url:'',command:'',argsText:'',envText:'',headersText:'',description:''}}
function openAddModal(){modalMode.value='add';editId.value=null;form.value=createEmpty();showModal.value=true}

16
frontend/src/views/ModelConfigManager.vue

@ -68,9 +68,9 @@
<!-- 配置列表表格 -->
<div style="overflow-x:auto;">
<t-table :data="configs" :columns="tableColumns" row-key="id" :loading="tableLoading" hover stripe
<t-table :data="configs" :columns="tableColumns" row-key="id" :loading="tableLoading" hover stripe :sort="sortInfo"
:pagination="{ current: currentPage, total: total, pageSize: pageSize, showJumper: true }"
@page-change="onPageChange">
@page-change="onPageChange" @sort-change="onSortChange">
<template #app_type="{ row }">
<t-tag size="small" variant="light" theme="primary">{{ getAppTypeLabel(row.app_type) }}</t-tag>
</template>
@ -355,14 +355,15 @@ const importConflictOptions = [
// ==================== ====================
const tableColumns = [
{ colKey: 'name', title: '配置名称', width: 160 },
{ colKey: 'app_type', title: '应用类型', width: 100 },
{ colKey: 'model_name', title: '模型名称', width: 160 },
{ colKey: 'name', title: '配置名称', width: 160, sorter: true },
{ colKey: 'app_type', title: '应用类型', width: 100, sorter: true },
{ colKey: 'model_name', title: '模型名称', width: 160, sorter: true },
{ colKey: 'base_url', title: 'API 地址', width: 180 },
{ colKey: 'api_key', title: 'API Key', width: 180 },
{ colKey: 'status', title: '状态', width: 120 },
{ colKey: 'status', title: '状态', width: 120, sorter: true },
{ colKey: 'op', title: '操作', width: 240 },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
// ==================== ====================
const configs = ref<any[]>([])
@ -514,7 +515,7 @@ async function load(p = currentPage.value) {
tableLoading.value = true
currentPage.value = p
try {
const json = await api.listModelConfigs(p, pageSize.value, filterAppType.value || undefined)
const json = await api.listModelConfigs(p, pageSize.value, filterAppType.value || undefined, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
if (json.success) {
configs.value = json.data || []
total.value = json.total || 0
@ -525,6 +526,7 @@ async function load(p = currentPage.value) {
}
function onPageChange(pageInfo: { current: number; pageSize: number }) { pageSize.value = pageInfo.pageSize; load(pageInfo.current) }
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; load(1) }
// ==================== ====================
async function loadHealthStatus() {

40
frontend/src/views/PromptTracePanel.vue

@ -32,10 +32,10 @@
</div>
<!-- 调用列表 -->
<t-table :data="traces" :columns="columns" row-key="id" :loading="loading"
<t-table :data="traces" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:selected-row-keys="selectedRowKeys"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }"
@page-change="onPageChange" @select-change="onSelectChange">
@page-change="onPageChange" @select-change="onSelectChange" @sort-change="onSortChange">
<template #roleName="{ row }">
<span v-if="row.roleName">{{ row.roleName }}</span>
<span v-else class="muted">(无角色)</span>
@ -263,17 +263,18 @@ const selectedRowKeys = ref<string[]>([])
const columns = [
{ colKey: 'row-select', type: 'multiple', width: 40 },
{ colKey: 'createTime', title: '时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.createTime) },
{ colKey: 'roleName', title: '角色', width: 120 },
{ colKey: 'intent', title: '意图', width: 100 },
{ colKey: 'enableRag', title: 'RAG', width: 70 },
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true },
{ colKey: 'latencyMs', title: '耗时', width: 90 },
{ colKey: 'faqHit', title: 'FAQ', width: 80 },
{ colKey: 'status', title: '状态', width: 100 },
{ colKey: 'createTime', title: '时间', width: 160, sorter: true, cell: (_: any, { row }: any) => formatDate(row.createTime) },
{ colKey: 'roleName', title: '角色', width: 120, sorter: true },
{ colKey: 'intent', title: '意图', width: 100, sorter: true },
{ colKey: 'enableRag', title: 'RAG', width: 70, sorter: true },
{ colKey: 'modelName', title: '模型', width: 140, ellipsis: true, sorter: true },
{ colKey: 'latencyMs', title: '耗时', width: 90, sorter: true },
{ colKey: 'faqHit', title: 'FAQ', width: 80, sorter: true },
{ colKey: 'status', title: '状态', width: 100, sorter: true },
{ colKey: 'userMessage', title: '用户消息', ellipsis: true },
{ colKey: 'op', title: '操作', width: 150, fixed: 'right' },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
// ===== =====
const groupBy = ref<'role' | 'model'>('role')
@ -281,9 +282,9 @@ const stats = ref<any[]>([])
const statsLoading = ref(false)
const statsColumns = [
{ colKey: 'key', title: '分组', ellipsis: true },
{ colKey: 'count', title: '调用数', width: 100 },
{ colKey: 'avgLatencyMs', title: '平均耗时(ms)', width: 120 },
{ colKey: 'faqHitRate', title: 'FAQ 命中率', width: 110, cell: (_: any, { row }: any) => `${((row.faqHitRate || 0) * 100).toFixed(1)}%` },
{ colKey: 'count', title: '调用数', width: 100, sorter: (a: any, b: any) => (a.count ?? 0) - (b.count ?? 0) },
{ colKey: 'avgLatencyMs', title: '平均耗时(ms)', width: 120, sorter: (a: any, b: any) => (a.avgLatencyMs ?? 0) - (b.avgLatencyMs ?? 0) },
{ colKey: 'faqHitRate', title: 'FAQ 命中率', width: 110, cell: (_: any, { row }: any) => `${((row.faqHitRate || 0) * 100).toFixed(1)}%`, sorter: (a: any, b: any) => (a.faqHitRate ?? 0) - (b.faqHitRate ?? 0) },
]
// ===== =====
@ -299,16 +300,16 @@ const detailHistory = computed(() => parseJsonArray(detail.value?.historyMessage
const ragHitsColumns = [
{ colKey: 'title', title: '标题', ellipsis: true },
{ colKey: 'documentId', title: '文档 ID', width: 150, ellipsis: true },
{ colKey: 'chunkIndex', title: '分块', width: 70 },
{ colKey: 'score', title: '相似度', width: 90, cell: (_: any, { row }: any) => formatScore(row.score) },
{ colKey: 'searchMode', title: '检索模式', width: 100 },
{ colKey: 'chunkIndex', title: '分块', width: 70, sorter: (a: any, b: any) => (a.chunkIndex ?? 0) - (b.chunkIndex ?? 0) },
{ colKey: 'score', title: '相似度', width: 90, cell: (_: any, { row }: any) => formatScore(row.score), sorter: (a: any, b: any) => (a.score ?? 0) - (b.score ?? 0) },
{ colKey: 'searchMode', title: '检索模式', width: 100, sorter: true },
]
const toolCallsColumns = [
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true },
{ colKey: 'tool', title: '工具', width: 150, ellipsis: true, sorter: true },
{ colKey: 'input', title: '输入', ellipsis: true },
{ colKey: 'result', title: '结果', ellipsis: true },
{ colKey: 'latencyMs', title: '耗时', width: 90 },
{ colKey: 'latencyMs', title: '耗时', width: 90, sorter: (a: any, b: any) => (a.latencyMs ?? 0) - (b.latencyMs ?? 0) },
{ colKey: 'error', title: '错误', width: 70 },
]
@ -434,6 +435,8 @@ async function loadList() {
keyword: filterKeyword.value || undefined,
apiKeyId: filterApiKeyId.value || undefined,
errorType: filterErrorType.value || undefined,
sortField: sortInfo.value?.sortBy || undefined,
sortOrder: sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined,
})
if (r.success) {
traces.value = r.data?.records || r.data || []
@ -454,6 +457,7 @@ function onPageChange(info: { current: number; pageSize: number }) {
selectedRowKeys.value = []
loadList()
}
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList() }
function onFilterChange() {
page.value = 1

18
frontend/src/views/SensitiveWordManager.vue

@ -12,8 +12,8 @@
<span class="total-hint"> {{ total }} </span>
</div>
<t-table :data="words" :columns="columns" row-key="id" :loading="loading"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<t-table :data="words" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #category="{ row }">
<t-tag size="small" :style="{ background: catColor(row.category) }" variant="light">{{ catLabel(row.category) }}</t-tag>
</template>
@ -114,14 +114,15 @@ const importCategory = ref('custom')
const importLevel = ref(1)
const columns = [
{ colKey: 'word', title: '敏感词', width: 160, ellipsis: true },
{ colKey: 'category', title: '分类', width: 100 },
{ colKey: 'level', title: '级别', width: 80 },
{ colKey: 'isActive', title: '状态', width: 70 },
{ colKey: 'word', title: '敏感词', width: 160, ellipsis: true, sorter: true },
{ colKey: 'category', title: '分类', width: 100, sorter: true },
{ colKey: 'level', title: '级别', width: 80, sorter: true },
{ colKey: 'isActive', title: '状态', width: 70, sorter: true },
{ colKey: 'remark', title: '备注', width: 140, ellipsis: true },
{ colKey: 'updateTime', title: '更新时间', width: 160, cell: (_: any, { row }: any) => formatDate(row.updateTime) },
{ colKey: 'updateTime', title: '更新时间', width: 160, sorter: true, cell: (_: any, { row }: any) => formatDate(row.updateTime) },
{ colKey: 'op', title: '操作', width: 120 },
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(() => loadList())
@ -129,7 +130,7 @@ async function loadList() {
loading.value = true
try {
const level = filterLevel.value ? Number(filterLevel.value) : undefined
const r = await listSensitiveWords(page.value, pageSize.value, searchKeyword.value || undefined, filterCategory.value || undefined, level)
const r = await listSensitiveWords(page.value, pageSize.value, searchKeyword.value || undefined, filterCategory.value || undefined, level, sortInfo.value?.sortBy || undefined, sortInfo.value ? (sortInfo.value.descending ? 'desc' : 'asc') : undefined)
if (r.success) {
const list = r.data?.records || r.data || []
words.value = list.map((w: any) => ({ ...w, _active: w.isActive }))
@ -147,6 +148,7 @@ function onPageChange(info: { current: number; pageSize: number }) {
pageSize.value = info.pageSize
loadList()
}
function onSortChange(s: any) { sortInfo.value = s && s.sortBy ? { sortBy: s.sortBy, descending: !!s.descending } : null; loadList() }
function onFilterChange() {
page.value = 1

14
frontend/src/views/UserManager.vue

@ -9,8 +9,8 @@
<t-button theme="primary" size="small" @click="openCreate">+ 新建用户</t-button>
</div>
<t-table :data="users" :columns="columns" row-key="id"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange">
<t-table :data="users" :columns="columns" row-key="id" :sort="sortInfo"
:pagination="{ current: page, total: total, pageSize: pageSize, showJumper: true }" @page-change="onPageChange" @sort-change="onSortChange">
<template #role_names="{ row }">
<t-tag v-for="r in (row.role_names||'').split(',').filter(Boolean)" :key="r" size="small" variant="light" theme="primary" style="margin-right:2px;">{{ r }}</t-tag>
<t-tag v-if="!row.role_names" size="small" variant="light" theme="default">未分配</t-tag>
@ -79,10 +79,11 @@ const keyword=ref(''); const filterEnabled=ref<boolean|null>(null); const allRol
const statusOpts=[{label:'全部状态',value:null},{label:'已启用',value:true},{label:'已禁用',value:false}]
const columns = [
{colKey:'username',title:'用户名',width:100},{colKey:'nickname',title:'昵称',width:80},{colKey:'email',title:'邮箱',width:140},
{colKey:'role_names',title:'角色',width:160},{colKey:'enabled',title:'状态',width:60},{colKey:'last_login_time',title:'最后登录',width:160,cell:(_:any,{row}:any)=>formatDate(row.last_login_time)},
{colKey:'create_time',title:'创建时间',width:160,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:220},
{colKey:'username',title:'用户名',width:100,sorter:true},{colKey:'nickname',title:'昵称',width:80,sorter:true},{colKey:'email',title:'邮箱',width:140,sorter:true},
{colKey:'role_names',title:'角色',width:160},{colKey:'enabled',title:'状态',width:60,sorter:true},{colKey:'last_login_time',title:'最后登录',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.last_login_time)},
{colKey:'create_time',title:'创建时间',width:160,sorter:true,cell:(_:any,{row}:any)=>formatDate(row.create_time)},{colKey:'op',title:'操作',width:220},
]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
const modal = reactive({visible:false,isEdit:false,userId:null as any,username:'',password:'',nickname:'',email:'',phone:''})
const roleModal = reactive({visible:false,userId:null as any,username:'',selectedRoleIds:[] as string[]})
@ -90,9 +91,10 @@ const pwdModal = reactive({visible:false,userId:null as any,username:'',newPassw
onMounted(()=>{loadData();loadRoles()})
async function loadData(){try{const r=await listSysUsers(page.value,pageSize.value,keyword.value,filterEnabled.value as any);if(r.success){users.value=r.data.records||[];total.value=r.data.total||0}}catch(e){console.error(e)}}
async function loadData(){try{const r=await listSysUsers(page.value,pageSize.value,keyword.value,filterEnabled.value as any,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){users.value=r.data.records||[];total.value=r.data.total||0}}catch(e){console.error(e)}}
async function loadRoles(){try{const r=await getAllSysRoles();if(r.success)allRoles.value=r.data||[]}catch(e){console.error(e)}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadData()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadData()}
function openCreate(){Object.assign(modal,{visible:true,isEdit:false,userId:null,username:'',password:'',nickname:'',email:'',phone:''})}
function openEdit(u:any){Object.assign(modal,{visible:true,isEdit:true,userId:u.id,username:u.username,nickname:u.nickname||'',email:u.email||'',phone:u.phone||'',password:''})}

10
frontend/src/views/WebhookManager.vue

@ -5,8 +5,8 @@
<div style="flex:1;" /><span class="total-hint"> {{ total }} </span>
</div>
<t-table :data="webhooks" :columns="columns" row-key="id" :loading="loading"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange">
<t-table :data="webhooks" :columns="columns" row-key="id" :loading="loading" :sort="sortInfo"
:pagination="{current:page,total:total,pageSize:pageSize,showJumper:true}" @page-change="onPageChange" @sort-change="onSortChange">
<template #events="{row}">
<t-tag v-for="ev in getEvents(row)" :key="ev" size="small" variant="light" style="margin-right:4px;">{{ ev }}</t-tag>
</template>
@ -55,15 +55,17 @@ const form=ref({name:'',url:'',events:[] as string[],enabled:true})
const availableEvents=[{value:'document.ready',label:'文档处理完成'},{value:'document.failed',label:'文档处理失败'},{value:'feedback.new',label:'收到用户反馈'}]
const columns=[{colKey:'name',title:'名称',width:140},{colKey:'url',title:'URL',width:200,ellipsis:true},{colKey:'events',title:'订阅事件',width:200},{colKey:'enabled',title:'状态',width:80},{colKey:'op',title:'操作',width:160}]
const columns=[{colKey:'name',title:'名称',width:140,sorter:true},{colKey:'url',title:'URL',width:200,ellipsis:true,sorter:true},{colKey:'events',title:'订阅事件',width:200},{colKey:'enabled',title:'状态',width:80,sorter:true},{colKey:'op',title:'操作',width:160}]
const sortInfo = ref<{ sortBy: string; descending: boolean } | null>(null)
onMounted(()=>loadList())
function getEvents(w:any){try{return Array.isArray(w.events)?w.events:typeof w.events==='string'?JSON.parse(w.events):[]}catch{return[]}}
async function loadList(){loading.value=true
try{const r=await listWebhooks(page.value,pageSize.value);if(r.success){webhooks.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
try{const r=await listWebhooks(page.value,pageSize.value,sortInfo.value?.sortBy||undefined,sortInfo.value?(sortInfo.value.descending?'desc':'asc'):undefined);if(r.success){webhooks.value=r.data?.records||r.data||[];total.value=r.data?.total||r.total||0}}catch(e:any){toast('加载失败:'+e.message,'error')}finally{loading.value=false}}
function onPageChange(i:{current:number;pageSize:number}){page.value=i.current;pageSize.value=i.pageSize;loadList()}
function onSortChange(s:any){sortInfo.value=s&&s.sortBy?{sortBy:s.sortBy,descending:!!s.descending}:null;loadList()}
function openCreateDialog(){editingId.value=null;form.value={name:'',url:'',events:[],enabled:true};showFormDialog.value=true}
function openEditDialog(w:any){editingId.value=w.id;form.value={name:w.name||'',url:w.url||'',events:getEvents(w),enabled:w.enabled};showFormDialog.value=true}

40
src/main/java/com/wok/supportbot/common/SortUtils.java

@ -0,0 +1,40 @@
package com.wok.supportbot.common;
import java.util.Map;
/**
* 列表排序工具类
* <p>
* 用于统一处理后台列表接口的排序参数白名单映射 SQL 注入sortField 判空回退sortOrder 二值化
* 避免在每个 Service 里重复书写白名单 + getOrDefault(null) 判空 + asc/desc逻辑
*/
public final class SortUtils {
private SortUtils() {
}
/**
* 解析排序字段对应的数据库列名
* <p>
* sortField null 或不在白名单内时回退到 defaultColumn杜绝将用户输入直接拼入 ORDER BY
*
* @param sortField 前端传入的 colKey可为 null
* @param whitelist 前端 colKey 数据库列名可含表别名的白名单映射
* @param defaultColumn 回退列名通常为 create_time
* @return 安全列名
*/
public static String resolveColumn(String sortField, Map<String, String> whitelist, String defaultColumn) {
if (sortField == null) {
return defaultColumn;
}
String column = whitelist.get(sortField);
return column != null ? column : defaultColumn;
}
/**
* 排序方向二值化 asc 视为升序其余 nulldesc其他值一律降序
*/
public static boolean isAscending(String sortOrder) {
return "asc".equalsIgnoreCase(sortOrder);
}
}

6
src/main/java/com/wok/supportbot/controller/AiModelConfigController.java

@ -58,9 +58,11 @@ public class AiModelConfigController {
public ResponseEntity<Map<String, Object>> listConfigs(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String appType) {
@RequestParam(required = false) String appType,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = aiModelConfigService.listConfigs(appType, page, size);
Map<String, Object> result = aiModelConfigService.listConfigs(appType, page, size, sortField, sortOrder);
Map<String, Object> data = new java.util.LinkedHashMap<>();
data.put("success", true);
data.put("data", result.get("records"));

6
src/main/java/com/wok/supportbot/controller/ApiKeyController.java

@ -39,9 +39,11 @@ public class ApiKeyController {
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = apiKeyService.listKeys(page, size);
Map<String, Object> result = apiKeyService.listKeys(page, size, sortField, sortOrder);
Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true);

6
src/main/java/com/wok/supportbot/controller/ConversationController.java

@ -48,9 +48,11 @@ public class ConversationController {
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String accountId,
@RequestParam(required = false) Long roleId) {
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId);
Map<String, Object> result = conversationService.listConversations(page, size, keyword, accountId, roleId, sortField, sortOrder);
Map<String, Object> data = new java.util.HashMap<>();
data.put("success", true);
data.put("data", result.get("records"));

6
src/main/java/com/wok/supportbot/controller/FaqController.java

@ -36,9 +36,11 @@ public class FaqController {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String status) {
@RequestParam(required = false) String status,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> data = faqService.list(page, size, keyword, categoryId, status);
Map<String, Object> data = faqService.list(page, size, keyword, categoryId, status, sortField, sortOrder);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "查询成功",

6
src/main/java/com/wok/supportbot/controller/LlmCallTraceController.java

@ -55,10 +55,12 @@ public class LlmCallTraceController {
@RequestParam(required = false) String startTime,
@RequestParam(required = false) String endTime,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String errorType) {
@RequestParam(required = false) String errorType,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = llmCallTraceService.pageQuery(
page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType);
page, size, roleId, conversationId, intent, startTime, endTime, keyword, errorType, sortField, sortOrder);
Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true);
data.put("data", result.get("records"));

6
src/main/java/com/wok/supportbot/controller/McpServerConfigController.java

@ -42,9 +42,11 @@ public class McpServerConfigController {
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> listConfigs(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size);
Map<String, Object> result = mcpServerConfigService.listConfigs(page, size, sortField, sortOrder);
Map<String, Object> data = new LinkedHashMap<>();
data.put("success", true);
data.put("data", result.get("records"));

6
src/main/java/com/wok/supportbot/controller/MessageFeedbackController.java

@ -79,10 +79,12 @@ public class MessageFeedbackController {
@RequestParam(required = false) String reasonCategory,
@RequestParam(required = false) Boolean processed,
@RequestParam(required = false) String startDate,
@RequestParam(required = false) String endDate) {
@RequestParam(required = false) String endDate,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> data = messageFeedbackService.listConversationsByFeedback(
page, size, feedbackType, reasonCategory, processed, startDate, endDate);
page, size, feedbackType, reasonCategory, processed, startDate, endDate, sortField, sortOrder);
return ResponseEntity.ok(Map.of(
"success", true,
"data", data

22
src/main/java/com/wok/supportbot/controller/SensitiveWordController.java

@ -1,5 +1,6 @@
package com.wok.supportbot.controller;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.entity.SensitiveWord;
import com.wok.supportbot.service.SensitiveWordService;
import lombok.extern.slf4j.Slf4j;
@ -27,6 +28,12 @@ public class SensitiveWordController {
@Autowired
private JdbcTemplate jdbcTemplate;
/** 审计日志排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> AUDIT_LOG_SORT_COLUMNS = Map.of(
"createTime", "create_time",
"direction", "direction",
"actionTaken", "action_taken");
// ==================== 敏感词 CRUD ====================
/**
@ -39,9 +46,11 @@ public class SensitiveWordController {
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String category,
@RequestParam(required = false) Integer level) {
@RequestParam(required = false) Integer level,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = sensitiveWordService.list(page, size, keyword, category, level);
Map<String, Object> result = sensitiveWordService.list(page, size, keyword, category, level, sortField, sortOrder);
Map<String, Object> data = new HashMap<>();
data.put("success", true);
@ -202,7 +211,9 @@ public class SensitiveWordController {
public ResponseEntity<Map<String, Object>> auditLog(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(required = false) String sessionId) {
@RequestParam(required = false) String sessionId,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
// 构建 WHERE 子句
StringBuilder whereClause = new StringBuilder("WHERE 1=1");
@ -217,8 +228,11 @@ public class SensitiveWordController {
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
// 查询列表
// 排序字段白名单解析 SQL 注入默认按创建时间倒序
String sortColumn = SortUtils.resolveColumn(sortField, AUDIT_LOG_SORT_COLUMNS, "create_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
String listSql = "SELECT * FROM content_audit_log " + whereClause +
" ORDER BY create_time DESC LIMIT ? OFFSET ?";
" ORDER BY " + orderBy + " LIMIT ? OFFSET ?";
List<Object> queryParams = new java.util.ArrayList<>(params);
queryParams.add(size);
queryParams.add((long) (page - 1) * size);

6
src/main/java/com/wok/supportbot/controller/SysUserController.java

@ -32,9 +32,11 @@ public class SysUserController {
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Boolean enabled) {
@RequestParam(required = false) Boolean enabled,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> data = sysUserService.listUsers(page, size, keyword, enabled);
Map<String, Object> data = sysUserService.listUsers(page, size, keyword, enabled, sortField, sortOrder);
return ResponseEntity.ok(Map.of("success", true, "data", data));
} catch (Exception e) {
log.error("查询用户列表失败", e);

6
src/main/java/com/wok/supportbot/controller/WebhookController.java

@ -36,9 +36,11 @@ public class WebhookController {
@PreAuthorize("hasRole('admin')")
public ResponseEntity<Map<String, Object>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "20") int size) {
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sortField,
@RequestParam(required = false) String sortOrder) {
try {
Map<String, Object> result = webhookService.listWebhooks(page, size);
Map<String, Object> result = webhookService.listWebhooks(page, size, sortField, sortOrder);
Map<String, Object> data = new HashMap<>();
data.put("success", true);

29
src/main/java/com/wok/supportbot/service/AiModelConfigService.java

@ -1,7 +1,9 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.AiModelConfigMapper;
import com.wok.supportbot.entity.AiModelConfig;
import lombok.extern.slf4j.Slf4j;
@ -35,6 +37,14 @@ public class AiModelConfigService {
/** 活跃配置本地缓存:appType → AiModelConfig(含完整 API Key),写操作时自动失效 */
private final ConcurrentHashMap<String, AiModelConfig> activeConfigCache = new ConcurrentHashMap<>();
/** 模型配置列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> AI_MODEL_SORT_COLUMNS = Map.of(
"name", "name",
"app_type", "app_type",
"model_name", "model_name",
"status", "is_active",
"priority", "priority");
// ==================== 分页列表 ====================
/**
@ -45,7 +55,7 @@ public class AiModelConfigService {
* @param size 每页大小
* @return 分页结果
*/
public Map<String, Object> listConfigs(String appType, int page, int size) {
public Map<String, Object> listConfigs(String appType, int page, int size, String sortField, String sortOrder) {
// 构建查询条件共用基础条件
LambdaQueryWrapper<AiModelConfig> baseCondition = new LambdaQueryWrapper<>();
if (appType != null && !appType.isEmpty()) {
@ -57,12 +67,21 @@ public class AiModelConfigService {
if (total == null) total = 0L;
// 查询列表带排序和分页
LambdaQueryWrapper<AiModelConfig> listWrapper = new LambdaQueryWrapper<>();
QueryWrapper<AiModelConfig> listWrapper = new QueryWrapper<>();
if (appType != null && !appType.isEmpty()) {
listWrapper.eq(AiModelConfig::getAppType, appType);
listWrapper.eq("app_type", appType);
}
// 排序未指定时保持 priority + create_time 双列默认指定时按白名单单列
String sortColumn = sortField != null ? AI_MODEL_SORT_COLUMNS.get(sortField) : null;
if (sortColumn != null) {
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
} else {
listWrapper.orderByDesc("priority").orderByDesc("create_time");
}
listWrapper.orderByDesc(AiModelConfig::getPriority)
.orderByDesc(AiModelConfig::getCreateTime);
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size));
List<AiModelConfig> records = aiModelConfigMapper.selectList(listWrapper);

17
src/main/java/com/wok/supportbot/service/ApiKeyService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.ApiKeyMapper;
@ -28,6 +29,13 @@ public class ApiKeyService {
private final ObjectMapper objectMapper = new ObjectMapper();
/** API Key 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> API_KEY_SORT_COLUMNS = Map.of(
"name", "name",
"rateLimit", "rate_limit",
"enabled", "enabled",
"createTime", "create_time");
/**
* 生成新的 API Key
*
@ -68,12 +76,17 @@ public class ApiKeyService {
* @param size 每页条数
* @return 包含 records/total/page/size/pages 的结果
*/
public Map<String, Object> listKeys(int page, int size) {
public Map<String, Object> listKeys(int page, int size, String sortField, String sortOrder) {
QueryWrapper<ApiKey> countWrapper = new QueryWrapper<>();
Long total = apiKeyMapper.selectCount(countWrapper);
QueryWrapper<ApiKey> listWrapper = new QueryWrapper<>();
listWrapper.orderByDesc("create_time");
String sortColumn = SortUtils.resolveColumn(sortField, API_KEY_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<ApiKey> records = apiKeyMapper.selectList(listWrapper);

24
src/main/java/com/wok/supportbot/service/ConversationService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.ChatMessageMapper;
import com.wok.supportbot.entity.ChatMessage;
import com.wok.supportbot.entity.MessageFeedback;
@ -28,6 +29,15 @@ public class ConversationService {
@Autowired
private JdbcTemplate jdbcTemplate;
/** 会话列表排序字段白名单(前端 colKey -> SQL 列名/聚合别名),防 SQL 注入 */
private static final Map<String, String> CONVERSATION_SORT_COLUMNS = Map.of(
"conversationId", "cm1.conversation_id",
"accountId", "cs.external_account_id",
"roleName", "csr.name",
"messageCount", "message_count",
"firstMessageTime", "first_message_time",
"lastMessageTime", "last_message_time");
// ==================== 会话列表 ====================
/**
@ -39,7 +49,7 @@ public class ConversationService {
* @return 分页会话列表
*/
public Map<String, Object> listConversations(int page, int size, String keyword) {
return listConversations(page, size, keyword, null, null);
return listConversations(page, size, keyword, null, null, null, null);
}
/**
@ -47,7 +57,8 @@ public class ConversationService {
* 会话归属通过 conversation_session 表的 accountId / roleId 关联
* 管理员可通过上述字段按需筛选
*/
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId) {
public Map<String, Object> listConversations(int page, int size, String keyword, String accountId, Long roleId,
String sortField, String sortOrder) {
StringBuilder whereClause = new StringBuilder("WHERE cm1.is_delete = false ");
List<Object> params = new ArrayList<>();
@ -86,6 +97,9 @@ public class ConversationService {
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
if (total == null) total = 0L;
// 排序字段白名单解析 SQL 注入默认按最后消息时间倒序
String sortColumn = SortUtils.resolveColumn(sortField, CONVERSATION_SORT_COLUMNS, "last_message_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
String listSql = """
SELECT
cm1.conversation_id,
@ -105,9 +119,9 @@ public class ConversationService {
LEFT JOIN customer_service_role csr ON csr.id = cs.role_id AND csr.is_delete = false
""" + whereClause + """
GROUP BY cm1.conversation_id, cs.external_account_id, cs.role_id, csr.name
ORDER BY last_message_time DESC
ORDER BY %s
LIMIT ? OFFSET ?
""";
""".formatted(orderBy);
List<Object> listParams = new ArrayList<>(params);
listParams.add(size);
@ -470,7 +484,7 @@ public class ConversationService {
empty.put("pages", 0L);
return empty;
}
return listConversations(page, size, null, accountId, roleId);
return listConversations(page, size, null, accountId, roleId, null, null);
}
/**

21
src/main/java/com/wok/supportbot/service/FaqService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.FaqFeedbackLinkMapper;
@ -41,6 +42,14 @@ public class FaqService {
private final ObjectMapper objectMapper = new ObjectMapper();
/** FAQ 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> FAQ_SORT_COLUMNS = Map.of(
"question", "question",
"priority", "priority",
"hitCount", "hit_count",
"status", "status",
"createTime", "create_time");
// ==================== 分页查询 ====================
/**
@ -53,7 +62,7 @@ public class FaqService {
* @param status 状态过滤可选
* @return 分页结果 Map {total, records}
*/
public Map<String, Object> list(int page, int size, String keyword, Long categoryId, String status) {
public Map<String, Object> list(int page, int size, String keyword, Long categoryId, String status, String sortField, String sortOrder) {
if (page < 1) page = 1;
if (size < 1) size = 20;
if (size > 100) size = 100;
@ -72,7 +81,17 @@ public class FaqService {
Long total = faqMapper.selectCount(wrapper);
// 排序未指定时保持 priority + create_time 双列默认指定时按白名单单列
String sortColumn = sortField != null ? FAQ_SORT_COLUMNS.get(sortField) : null;
if (sortColumn != null) {
if (SortUtils.isAscending(sortOrder)) {
wrapper.orderByAsc(sortColumn);
} else {
wrapper.orderByDesc(sortColumn);
}
} else {
wrapper.orderByDesc("priority").orderByDesc("create_time");
}
wrapper.last("LIMIT " + size + " OFFSET " + (page - 1) * size);
List<KnowledgeFaq> records = faqMapper.selectList(wrapper);

24
src/main/java/com/wok/supportbot/service/LlmCallTraceService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.LlmCallTraceMapper;
import com.wok.supportbot.entity.LlmCallTrace;
import lombok.extern.slf4j.Slf4j;
@ -43,6 +44,17 @@ public class LlmCallTraceService {
Set.of("system_prompt", "global_prompt", "role_prompt", "rag_context", "ai_response",
"error_message", "tool_calls_json", "rag_hits_json", "history_messages_json");
/** LLM 调用追踪列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> LLM_TRACE_SORT_COLUMNS = Map.of(
"createTime", "create_time",
"roleName", "role_name",
"intent", "intent",
"modelName", "model_name",
"latencyMs", "latency_ms",
"faqHit", "faq_hit",
"status", "status",
"enableRag", "enable_rag");
/** 默认保留天数 */
private static final int DEFAULT_RETENTION_DAYS = 30;
@ -66,7 +78,7 @@ public class LlmCallTraceService {
*/
public Map<String, Object> pageQuery(int page, int size, Long roleId, String conversationId,
String intent, String startTime, String endTime, String keyword,
String errorType) {
String errorType, String sortField, String sortOrder) {
if (page < 1) page = 1;
if (page > 10000) page = 10000;
if (size < 1 || size > 100) size = 20;
@ -80,7 +92,17 @@ public class LlmCallTraceService {
QueryWrapper<LlmCallTrace> listWrapper = buildWhere(
new QueryWrapper<>(), roleId, conversationId, intent, startTime, endTime, keyword, errorType);
listWrapper.select(LlmCallTrace.class, field -> !BIG_TEXT_FIELDS.contains(field.getColumn()));
// 排序未指定时保持 create_time + id 双列默认指定时按白名单单列
String sortColumn = sortField != null ? LLM_TRACE_SORT_COLUMNS.get(sortField) : null;
if (sortColumn != null) {
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
} else {
listWrapper.orderByDesc("create_time").orderByDesc("id");
}
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1L) * size));
List<LlmCallTrace> records = llmCallTraceMapper.selectList(listWrapper);

21
src/main/java/com/wok/supportbot/service/McpServerConfigService.java

@ -1,7 +1,9 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.wok.supportbot.common.SortUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.wok.supportbot.dao.McpServerConfigMapper;
import com.wok.supportbot.entity.McpServerConfig;
@ -30,6 +32,14 @@ public class McpServerConfigService {
/** 复用的 JSON 序列化器,避免每次持久化都新建实例 */
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/** MCP Server 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> MCP_SORT_COLUMNS = Map.of(
"name", "name",
"transport_type", "transport_type",
"is_active", "is_active",
"description", "description",
"create_time", "create_time");
// ==================== 分页列表 ====================
/**
@ -39,15 +49,20 @@ public class McpServerConfigService {
* @param size 每页大小
* @return 分页结果records/total/page/size/pages
*/
public Map<String, Object> listConfigs(int page, int size) {
public Map<String, Object> listConfigs(int page, int size, String sortField, String sortOrder) {
// 查询总数
LambdaQueryWrapper<McpServerConfig> countWrapper = new LambdaQueryWrapper<>();
Long total = mcpServerConfigMapper.selectCount(countWrapper);
if (total == null) total = 0L;
// 查询列表带排序和分页
LambdaQueryWrapper<McpServerConfig> listWrapper = new LambdaQueryWrapper<>();
listWrapper.orderByDesc(McpServerConfig::getCreateTime);
QueryWrapper<McpServerConfig> listWrapper = new QueryWrapper<>();
String sortColumn = SortUtils.resolveColumn(sortField, MCP_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + ((page - 1) * size));
List<McpServerConfig> records = mcpServerConfigMapper.selectList(listWrapper);

22
src/main/java/com/wok/supportbot/service/MessageFeedbackService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.MessageFeedbackMapper;
import com.wok.supportbot.entity.MessageFeedback;
import lombok.extern.slf4j.Slf4j;
@ -27,6 +28,13 @@ public class MessageFeedbackService {
@Autowired(required = false)
private WebhookService webhookService;
/** 反馈会话列表排序字段白名单(前端 colKey -> 内层查询列别名),防 SQL 注入 */
private static final Map<String, String> FEEDBACK_SORT_COLUMNS = Map.of(
"feedbackType", "feedback_type",
"reasonCategory", "reason_category",
"processed", "processed",
"feedbackTime", "feedback_time");
/**
* 按反馈条件筛选会话列表供反馈运营看板
*
@ -41,7 +49,8 @@ public class MessageFeedbackService {
*/
public Map<String, Object> listConversationsByFeedback(int page, int size,
String feedbackType, String reasonCategory,
Boolean processed, String startDate, String endDate) {
Boolean processed, String startDate, String endDate,
String sortField, String sortOrder) {
StringBuilder where = new StringBuilder("WHERE mf.is_delete = false ");
List<Object> params = new ArrayList<>();
@ -70,8 +79,15 @@ public class MessageFeedbackService {
String countSql = "SELECT COUNT(DISTINCT mf.conversation_id) FROM message_feedback mf " + where;
Long total = jdbcTemplate.queryForObject(countSql, Long.class, params.toArray());
// 排序字段白名单解析 SQL 注入默认按反馈时间倒序
// DISTINCT ON 要求 ORDER BY conversation_id 开头故将原查询包成子查询
// 外层按白名单列排序避免破坏去重语义
String sortColumn = SortUtils.resolveColumn(sortField, FEEDBACK_SORT_COLUMNS, "feedback_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
// 查询会话摘要按最后反馈时间倒序附带最后一条反馈信息
String querySql = """
SELECT * FROM (
SELECT DISTINCT ON (mf.conversation_id)
mf.id::TEXT AS feedback_id,
mf.conversation_id AS conversation_id,
@ -98,8 +114,8 @@ public class MessageFeedbackService {
AND cm_assistant.metadata ->> 'msgId' = mf.message_id
""" + where + "\n" + """
ORDER BY mf.conversation_id, mf.create_time DESC
LIMIT ? OFFSET ?
""";
) t
""" + " ORDER BY " + orderBy + " LIMIT ? OFFSET ?";
params.add(size);
params.add((page - 1) * size);
List<Map<String, Object>> rows = jdbcTemplate.queryForList(querySql, params.toArray());

19
src/main/java/com/wok/supportbot/service/SensitiveWordService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.SensitiveWordMapper;
import com.wok.supportbot.entity.SensitiveWord;
import lombok.extern.slf4j.Slf4j;
@ -22,6 +23,15 @@ public class SensitiveWordService {
@Autowired
private SensitiveWordMapper sensitiveWordMapper;
/** 敏感词列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> SENSITIVE_WORD_SORT_COLUMNS = Map.of(
"word", "word",
"category", "category",
"level", "level",
"isActive", "is_active",
"updateTime", "update_time",
"createTime", "create_time");
@Autowired
private ContentSafetyService contentSafetyService;
@ -35,7 +45,7 @@ public class SensitiveWordService {
* @param level 级别筛选1=警告2=拦截可为空
* @return 包含 records/total/page/size/pages 的结果
*/
public Map<String, Object> list(int page, int size, String keyword, String category, Integer level) {
public Map<String, Object> list(int page, int size, String keyword, String category, Integer level, String sortField, String sortOrder) {
// 构建计数条件
QueryWrapper<SensitiveWord> countWrapper = new QueryWrapper<>();
if (keyword != null && !keyword.isBlank()) {
@ -60,7 +70,12 @@ public class SensitiveWordService {
if (level != null) {
listWrapper.eq("level", level);
}
listWrapper.orderByDesc("create_time");
String sortColumn = SortUtils.resolveColumn(sortField, SENSITIVE_WORD_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<SensitiveWord> records = sensitiveWordMapper.selectList(listWrapper);

19
src/main/java/com/wok/supportbot/service/SysUserService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.SysRoleMapper;
import com.wok.supportbot.dao.SysUserMapper;
import com.wok.supportbot.dao.SysUserRoleMapper;
@ -37,6 +38,15 @@ public class SysUserService implements UserDetailsService {
private final PasswordValidator passwordValidator;
private final JdbcTemplate jdbcTemplate;
/** 用户列表排序字段白名单(前端 colKey -> SQL 列名),防 SQL 注入 */
private static final Map<String, String> USER_SORT_COLUMNS = Map.of(
"username", "u.username",
"nickname", "u.nickname",
"email", "u.email",
"enabled", "u.enabled",
"last_login_time", "u.last_login_time",
"create_time", "u.create_time");
public SysUserService(SysUserMapper userMapper, SysRoleMapper roleMapper,
SysUserRoleMapper userRoleMapper, PasswordEncoder passwordEncoder,
PasswordValidator passwordValidator, JdbcTemplate jdbcTemplate) {
@ -108,7 +118,7 @@ public class SysUserService implements UserDetailsService {
/**
* 分页查询用户列表
*/
public Map<String, Object> listUsers(int page, int size, String keyword, Boolean enabled) {
public Map<String, Object> listUsers(int page, int size, String keyword, Boolean enabled, String sortField, String sortOrder) {
StringBuilder where = new StringBuilder("WHERE u.is_delete = FALSE");
List<Object> params = new ArrayList<>();
@ -130,6 +140,9 @@ public class SysUserService implements UserDetailsService {
// 注意id 必须 CAST TEXT否则 Jackson BIGINT 序列化为 JSON number
// 前端 JS 解析大雪花 ID 时会丢失精度超过 Number.MAX_SAFE_INTEGER
int offset = (page - 1) * size;
// 排序字段白名单解析 SQL 注入默认按创建时间倒序
String sortColumn = SortUtils.resolveColumn(sortField, USER_SORT_COLUMNS, "u.create_time");
String orderBy = sortColumn + (SortUtils.isAscending(sortOrder) ? " ASC" : " DESC");
String dataSql = """
SELECT u.id::TEXT AS id, u.username, u.nickname, u.email, u.phone, u.enabled,
u.last_login_time, u.create_time,
@ -140,9 +153,9 @@ public class SysUserService implements UserDetailsService {
LEFT JOIN sys_role r ON r.id = ur.role_id AND r.is_delete = FALSE
%s
GROUP BY u.id
ORDER BY u.create_time DESC
ORDER BY %s
LIMIT ? OFFSET ?
""".formatted(where);
""".formatted(where, orderBy);
List<Object> dataParams = new ArrayList<>(params);
dataParams.add(size);
dataParams.add(offset);

17
src/main/java/com/wok/supportbot/service/WebhookService.java

@ -1,6 +1,7 @@
package com.wok.supportbot.service;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.wok.supportbot.common.SortUtils;
import com.wok.supportbot.dao.WebhookConfigMapper;
import com.wok.supportbot.entity.WebhookConfig;
import lombok.extern.slf4j.Slf4j;
@ -22,6 +23,13 @@ public class WebhookService {
@Autowired
private WebhookConfigMapper webhookConfigMapper;
/** Webhook 列表排序字段白名单(前端 colKey -> 数据库列名),防 SQL 注入 */
private static final Map<String, String> WEBHOOK_SORT_COLUMNS = Map.of(
"name", "name",
"url", "url",
"enabled", "enabled",
"createTime", "create_time");
/**
* 分页查询 Webhook 列表
*
@ -29,12 +37,17 @@ public class WebhookService {
* @param size 每页条数
* @return 包含 records/total/page/size/pages 的结果
*/
public Map<String, Object> listWebhooks(int page, int size) {
public Map<String, Object> listWebhooks(int page, int size, String sortField, String sortOrder) {
QueryWrapper<WebhookConfig> countWrapper = new QueryWrapper<>();
Long total = webhookConfigMapper.selectCount(countWrapper);
QueryWrapper<WebhookConfig> listWrapper = new QueryWrapper<>();
listWrapper.orderByDesc("create_time");
String sortColumn = SortUtils.resolveColumn(sortField, WEBHOOK_SORT_COLUMNS, "create_time");
if (SortUtils.isAscending(sortOrder)) {
listWrapper.orderByAsc(sortColumn);
} else {
listWrapper.orderByDesc(sortColumn);
}
listWrapper.last("LIMIT " + size + " OFFSET " + (long) (page - 1) * size);
List<WebhookConfig> records = webhookConfigMapper.selectList(listWrapper);

Loading…
Cancel
Save