diff --git a/docker-compose.yml b/docker-compose.yml index 52bda7e..546cf1d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -290,6 +290,7 @@ services: ANTHROPIC_BASE_URL: ${ANTHROPIC_BASE_URL:-https://api.anthropic.com} KNOWLEDGE_SERVICE_URL: http://knowledge-service:3003 CORS_ORIGINS: https://iconsulting.szaiai.com,http://localhost:5173 + JWT_SECRET: ${JWT_SECRET:-your-jwt-secret-key} ports: - "3004:3004" healthcheck: diff --git a/packages/admin-client/src/App.tsx b/packages/admin-client/src/App.tsx index d2f65aa..148ac4d 100644 --- a/packages/admin-client/src/App.tsx +++ b/packages/admin-client/src/App.tsx @@ -7,6 +7,7 @@ import { KnowledgePage } from './features/knowledge/presentation/pages/Knowledge import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage'; import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics'; import { UsersPage } from './features/users'; +import { ConversationsPage } from './features/conversations'; import { SettingsPage } from './features/settings'; function App() { @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/packages/admin-client/src/features/conversations/application/index.ts b/packages/admin-client/src/features/conversations/application/index.ts new file mode 100644 index 0000000..526c462 --- /dev/null +++ b/packages/admin-client/src/features/conversations/application/index.ts @@ -0,0 +1 @@ +export * from './useConversations'; diff --git a/packages/admin-client/src/features/conversations/application/useConversations.ts b/packages/admin-client/src/features/conversations/application/useConversations.ts new file mode 100644 index 0000000..20af5dc --- /dev/null +++ b/packages/admin-client/src/features/conversations/application/useConversations.ts @@ -0,0 +1,66 @@ +import { useQuery } from '@tanstack/react-query'; +import { + conversationsApi, + ConversationQueryParams, + ConversationDto, + ConversationListItem, + MessageDto, + PaginatedConversations, + ConversationStatistics, +} from '../infrastructure/conversations.api'; + +// Conversation list query +export function useConversations(params: ConversationQueryParams) { + return useQuery({ + queryKey: ['conversations', 'list', params], + queryFn: () => conversationsApi.listConversations(params), + }); +} + +// Conversations by user query +export function useUserConversations(userId: string, limit = 10, enabled = true) { + return useQuery({ + queryKey: ['conversations', 'by-user', userId, limit], + queryFn: () => conversationsApi.getConversationsByUser(userId, limit), + enabled: enabled && !!userId, + }); +} + +// Conversation detail query +export function useConversationDetail(id: string, enabled = true) { + return useQuery({ + queryKey: ['conversations', 'detail', id], + queryFn: () => conversationsApi.getConversation(id), + enabled: enabled && !!id, + }); +} + +// Conversation messages query +export function useConversationMessages(id: string, enabled = true) { + return useQuery({ + queryKey: ['conversations', 'messages', id], + queryFn: () => conversationsApi.getConversationMessages(id), + enabled: enabled && !!id, + }); +} + +// Conversation statistics query +export function useConversationStatistics() { + return useQuery({ + queryKey: ['conversations', 'statistics'], + queryFn: () => conversationsApi.getStatistics(), + refetchInterval: 60000, // Refresh every minute + }); +} + +// Re-export types +export type { + ConversationDto, + ConversationListItem, + ConversationStatus, + DeviceInfo, + MessageDto, + PaginatedConversations, + ConversationStatistics, + ConversationQueryParams, +} from '../infrastructure/conversations.api'; diff --git a/packages/admin-client/src/features/conversations/index.ts b/packages/admin-client/src/features/conversations/index.ts new file mode 100644 index 0000000..31f725f --- /dev/null +++ b/packages/admin-client/src/features/conversations/index.ts @@ -0,0 +1,3 @@ +export * from './application'; +export * from './infrastructure'; +export * from './presentation/pages'; diff --git a/packages/admin-client/src/features/conversations/infrastructure/conversations.api.ts b/packages/admin-client/src/features/conversations/infrastructure/conversations.api.ts new file mode 100644 index 0000000..d87bf71 --- /dev/null +++ b/packages/admin-client/src/features/conversations/infrastructure/conversations.api.ts @@ -0,0 +1,127 @@ +import api from '../../../shared/utils/api'; + +// ==================== DTOs ==================== + +export type ConversationStatus = 'ACTIVE' | 'ENDED' | 'ARCHIVED'; + +export interface DeviceInfo { + ip?: string; + userAgent?: string; + fingerprint?: string; + region?: string; +} + +export interface ConversationDto { + id: string; + userId: string; + status: ConversationStatus; + title: string | null; + summary: string | null; + category: string | null; + messageCount: number; + userMessageCount: number; + assistantMessageCount: number; + totalInputTokens: number; + totalOutputTokens: number; + rating: number | null; + feedback: string | null; + hasConverted: boolean; + consultingStage: string | null; + consultingState?: Record | null; + collectedInfo?: Record | null; + recommendedPrograms?: string[] | null; + conversionPath?: string | null; + deviceInfo: DeviceInfo | null; + createdAt: string; + updatedAt: string; + endedAt: string | null; +} + +export interface ConversationListItem { + id: string; + userId: string; + status: ConversationStatus; + title: string | null; + messageCount: number; + hasConverted: boolean; + consultingStage: string | null; + deviceInfo: DeviceInfo | null; + createdAt: string; + updatedAt: string; + endedAt: string | null; +} + +export interface MessageDto { + id: string; + conversationId: string; + role: 'user' | 'assistant' | 'system'; + type: string; + content: string; + metadata?: Record; + createdAt: string; +} + +export interface PaginatedConversations { + items: ConversationDto[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface ConversationStatistics { + total: number; + active: number; + ended: number; + converted: number; + todayCount: number; + conversionRate: string; +} + +export interface ConversationQueryParams { + page?: number; + pageSize?: number; + status?: ConversationStatus; + userId?: string; + startDate?: string; + endDate?: string; + hasConverted?: boolean; + sortBy?: 'createdAt' | 'updatedAt' | 'messageCount'; + sortOrder?: 'ASC' | 'DESC'; +} + +// ==================== API ==================== + +export const conversationsApi = { + // List conversations with pagination + listConversations: async (params: ConversationQueryParams): Promise => { + const response = await api.get('/conversations/admin/list', { params }); + return response.data.data; + }, + + // Get conversations by user ID + getConversationsByUser: async (userId: string, limit = 10): Promise => { + const response = await api.get(`/conversations/admin/by-user/${userId}`, { + params: { limit }, + }); + return response.data.data; + }, + + // Get conversation detail + getConversation: async (id: string): Promise => { + const response = await api.get(`/conversations/admin/${id}`); + return response.data.data; + }, + + // Get conversation messages + getConversationMessages: async (id: string): Promise => { + const response = await api.get(`/conversations/admin/${id}/messages`); + return response.data.data; + }, + + // Get statistics + getStatistics: async (): Promise => { + const response = await api.get('/conversations/admin/statistics/overview'); + return response.data.data; + }, +}; diff --git a/packages/admin-client/src/features/conversations/infrastructure/index.ts b/packages/admin-client/src/features/conversations/infrastructure/index.ts new file mode 100644 index 0000000..93380eb --- /dev/null +++ b/packages/admin-client/src/features/conversations/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './conversations.api'; diff --git a/packages/admin-client/src/features/conversations/presentation/pages/ConversationsPage.tsx b/packages/admin-client/src/features/conversations/presentation/pages/ConversationsPage.tsx new file mode 100644 index 0000000..ec9ae6d --- /dev/null +++ b/packages/admin-client/src/features/conversations/presentation/pages/ConversationsPage.tsx @@ -0,0 +1,502 @@ +import { useState } from 'react'; +import { + Card, + Table, + Tag, + Space, + Select, + DatePicker, + Row, + Col, + Statistic, + Typography, + Drawer, + Descriptions, + Spin, + List, + Tooltip, +} from 'antd'; +import { + MessageOutlined, + CheckCircleOutlined, + GlobalOutlined, + LaptopOutlined, + BarChartOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { + useConversations, + useConversationStatistics, + useConversationDetail, + useConversationMessages, + type ConversationDto, + type ConversationStatus, + type ConversationQueryParams, + type MessageDto, +} from '../../application'; + +const { Title } = Typography; +const { RangePicker } = DatePicker; + +const STATUS_COLORS: Record = { + ACTIVE: 'green', + ENDED: 'default', + ARCHIVED: 'gray', +}; + +const STATUS_LABELS: Record = { + ACTIVE: '进行中', + ENDED: '已结束', + ARCHIVED: '已归档', +}; + +export function ConversationsPage() { + const [filters, setFilters] = useState({ + page: 1, + pageSize: 20, + sortBy: 'createdAt', + sortOrder: 'DESC', + }); + + const [dateRange, setDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null); + const [detailDrawerOpen, setDetailDrawerOpen] = useState(false); + const [selectedConversationId, setSelectedConversationId] = useState(null); + + // Queries + const { data: conversationsData, isLoading: loadingConversations } = useConversations(filters); + const { data: stats, isLoading: loadingStats } = useConversationStatistics(); + const { data: conversationDetail, isLoading: loadingDetail } = useConversationDetail( + selectedConversationId || '', + !!selectedConversationId && detailDrawerOpen + ); + const { data: messages, isLoading: loadingMessages } = useConversationMessages( + selectedConversationId || '', + !!selectedConversationId && detailDrawerOpen + ); + + const showConversationDetail = (conv: ConversationDto) => { + setSelectedConversationId(conv.id); + setDetailDrawerOpen(true); + }; + + const handleDateRangeChange = (dates: [dayjs.Dayjs | null, dayjs.Dayjs | null] | null) => { + setDateRange(dates); + if (dates && dates[0] && dates[1]) { + setFilters((prev) => ({ + ...prev, + startDate: dates[0]!.format('YYYY-MM-DD'), + endDate: dates[1]!.format('YYYY-MM-DD'), + page: 1, + })); + } else { + setFilters((prev) => ({ + ...prev, + startDate: undefined, + endDate: undefined, + page: 1, + })); + } + }; + + const handleTableChange = (pagination: { current?: number; pageSize?: number }) => { + setFilters((prev) => ({ + ...prev, + page: pagination.current || 1, + pageSize: pagination.pageSize || 20, + })); + }; + + const columns: ColumnsType = [ + { + title: '对话', + key: 'conversation', + render: (_, record) => ( +
+
{record.title || '未命名对话'}
+
ID: {record.id.slice(0, 8)}...
+
+ ), + }, + { + title: '用户', + dataIndex: 'userId', + key: 'userId', + width: 120, + render: (userId) => ( + + {userId.slice(0, 8)}... + + ), + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: ConversationStatus) => ( + {STATUS_LABELS[status]} + ), + }, + { + title: '消息数', + dataIndex: 'messageCount', + key: 'messageCount', + width: 80, + align: 'center', + }, + { + title: '转化', + dataIndex: 'hasConverted', + key: 'hasConverted', + width: 80, + align: 'center', + render: (converted) => + converted ? ( + + ) : ( + - + ), + }, + { + title: '访问IP', + key: 'ip', + width: 140, + render: (_, record) => record.deviceInfo?.ip || '-', + }, + { + title: '设备', + key: 'device', + width: 140, + render: (_, record) => { + if (!record.deviceInfo?.userAgent) return '-'; + return ( + + {parseUserAgent(record.deviceInfo.userAgent)} + + ); + }, + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 160, + render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm'), + }, + { + title: '操作', + key: 'action', + width: 80, + render: (_, record) => showConversationDetail(record)}>详情, + }, + ]; + + return ( +
+ 对话管理 + + {/* Statistics Cards */} + + + + + } + valueStyle={{ color: '#1890ff' }} + /> + + + + + + + } + valueStyle={{ color: '#722ed1' }} + /> + + + + + + + } + valueStyle={{ color: '#52c41a' }} + /> +
+ 转化率: {stats?.conversionRate ?? '0'}% +
+
+
+ + + + + } + valueStyle={{ color: '#faad14' }} + /> + + + +
+ + {/* Filters */} + + + setFilters((prev) => ({ ...prev, hasConverted: value, page: 1 }))} + style={{ width: 120 }} + options={[ + { value: true, label: '已转化' }, + { value: false, label: '未转化' }, + ]} + /> + + setFilters((prev) => ({ ...prev, sortOrder: value }))} + style={{ width: 100 }} + options={[ + { value: 'DESC', label: '降序' }, + { value: 'ASC', label: '升序' }, + ]} + /> + + + + {/* Conversations Table */} + + + `共 ${total} 条`, + }} + onChange={handleTableChange} + scroll={{ x: 1200 }} + /> + + + + {/* Conversation Detail Drawer */} + { + setDetailDrawerOpen(false); + setSelectedConversationId(null); + }} + > + + {conversationDetail && ( +
+ {/* Basic Info */} + + + {conversationDetail.id} + + + {conversationDetail.title || '-'} + + + {conversationDetail.userId.slice(0, 12)}... + + + + {STATUS_LABELS[conversationDetail.status]} + + + + {conversationDetail.messageCount} + + + {conversationDetail.hasConverted ? ( + 已转化 + ) : ( + 未转化 + )} + + + {dayjs(conversationDetail.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + {conversationDetail.endedAt + ? dayjs(conversationDetail.endedAt).format('YYYY-MM-DD HH:mm:ss') + : '-'} + + + + {/* Device Info */} + {conversationDetail.deviceInfo && ( + <> + + <Space> + <LaptopOutlined /> + 设备信息 + </Space> + + + + + + {conversationDetail.deviceInfo.ip || '-'} + {conversationDetail.deviceInfo.region && ( + {conversationDetail.deviceInfo.region} + )} + + + + + + {conversationDetail.deviceInfo.userAgent + ? parseUserAgent(conversationDetail.deviceInfo.userAgent) + : '-'} + + + + + + {conversationDetail.deviceInfo.fingerprint || '-'} + + + + + )} + + {/* Token Usage */} + + <Space> + <BarChartOutlined /> + Token 使用 + </Space> + + +
+ + + + + + + + + + + + {/* Messages */} + + <Space> + <MessageOutlined /> + 对话内容 + </Space> + + + ( + +
+
+ + {msg.role === 'user' ? '用户' : '助手'} + + + {dayjs(msg.createdAt).format('HH:mm:ss')} + +
+
{msg.content}
+
+
+ )} + /> +
+ + )} + + + + ); +} + +// Helper function to parse User-Agent into readable format +function parseUserAgent(ua: string): string { + if (!ua) return '-'; + + let browser = 'Unknown'; + let os = 'Unknown'; + + if (ua.includes('Chrome')) browser = 'Chrome'; + else if (ua.includes('Firefox')) browser = 'Firefox'; + else if (ua.includes('Safari')) browser = 'Safari'; + else if (ua.includes('Edge')) browser = 'Edge'; + + if (ua.includes('Windows')) os = 'Windows'; + else if (ua.includes('Mac')) os = 'macOS'; + else if (ua.includes('Linux')) os = 'Linux'; + else if (ua.includes('Android')) os = 'Android'; + else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS'; + + return `${browser} / ${os}`; +} diff --git a/packages/admin-client/src/features/conversations/presentation/pages/index.ts b/packages/admin-client/src/features/conversations/presentation/pages/index.ts new file mode 100644 index 0000000..97802cd --- /dev/null +++ b/packages/admin-client/src/features/conversations/presentation/pages/index.ts @@ -0,0 +1 @@ +export * from './ConversationsPage'; diff --git a/packages/admin-client/src/features/users/presentation/pages/UsersPage.tsx b/packages/admin-client/src/features/users/presentation/pages/UsersPage.tsx index 4bc9bbe..8ce0846 100644 --- a/packages/admin-client/src/features/users/presentation/pages/UsersPage.tsx +++ b/packages/admin-client/src/features/users/presentation/pages/UsersPage.tsx @@ -14,12 +14,19 @@ import { Descriptions, Spin, Avatar, + Divider, + List, + Tooltip, } from 'antd'; import { UserOutlined, TeamOutlined, UserAddOutlined, SearchOutlined, + MessageOutlined, + GlobalOutlined, + LaptopOutlined, + CheckCircleOutlined, } from '@ant-design/icons'; import type { ColumnsType } from 'antd/es/table'; import dayjs from 'dayjs'; @@ -30,6 +37,7 @@ import { type UserDto, type UserType, } from '../../application'; +import { useUserConversations, type ConversationListItem } from '../../../conversations/application'; const { Title } = Typography; const { Search } = Input; @@ -71,6 +79,11 @@ export function UsersPage() { selectedUserId || '', !!selectedUserId && detailDrawerOpen ); + const { data: userConversations, isLoading: loadingConversations } = useUserConversations( + selectedUserId || '', + 10, + !!selectedUserId && detailDrawerOpen + ); const showUserDetail = (user: UserDto) => { setSelectedUserId(user.id); @@ -269,7 +282,7 @@ export function UsersPage() { { setDetailDrawerOpen(false); @@ -319,6 +332,84 @@ export function UsersPage() { {dayjs(userDetail.lastActiveAt).format('YYYY-MM-DD HH:mm:ss')} + + {/* Recent Conversations */} + + + + 最近咨询记录 + + + + + {userConversations && userConversations.length > 0 ? ( + ( + +
+
+
+
+ {conv.title || '未命名对话'} +
+
+ {dayjs(conv.createdAt).format('YYYY-MM-DD HH:mm')} +
+
+ + + {conv.status === 'ACTIVE' ? '进行中' : '已结束'} + + {conv.hasConverted && ( + + + + )} + +
+ + {/* Device Info */} + {conv.deviceInfo && ( +
+ {conv.deviceInfo.ip && ( +
+ + IP: {conv.deviceInfo.ip} + {conv.deviceInfo.region && ( + ({conv.deviceInfo.region}) + )} +
+ )} + {conv.deviceInfo.userAgent && ( + +
+ + {parseUserAgent(conv.deviceInfo.userAgent)} +
+
+ )} + {conv.deviceInfo.fingerprint && ( +
+ 指纹: + {conv.deviceInfo.fingerprint.slice(0, 12)}... +
+ )} +
+ )} + +
+ 消息数: {conv.messageCount} +
+
+
+ )} + /> + ) : ( +
暂无咨询记录
+ )} +
)} @@ -326,3 +417,25 @@ export function UsersPage() { ); } + +// Helper function to parse User-Agent into readable format +function parseUserAgent(ua: string): string { + if (!ua) return '-'; + + // Simple parsing - extract browser and OS + let browser = 'Unknown'; + let os = 'Unknown'; + + if (ua.includes('Chrome')) browser = 'Chrome'; + else if (ua.includes('Firefox')) browser = 'Firefox'; + else if (ua.includes('Safari')) browser = 'Safari'; + else if (ua.includes('Edge')) browser = 'Edge'; + + if (ua.includes('Windows')) os = 'Windows'; + else if (ua.includes('Mac')) os = 'macOS'; + else if (ua.includes('Linux')) os = 'Linux'; + else if (ua.includes('Android')) os = 'Android'; + else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS'; + + return `${browser} / ${os}`; +} diff --git a/packages/admin-client/src/shared/components/MainLayout.tsx b/packages/admin-client/src/shared/components/MainLayout.tsx index 4efcdf0..e8c174e 100644 --- a/packages/admin-client/src/shared/components/MainLayout.tsx +++ b/packages/admin-client/src/shared/components/MainLayout.tsx @@ -15,6 +15,7 @@ import { FileTextOutlined, AuditOutlined, LineChartOutlined, + MessageOutlined, } from '@ant-design/icons'; import { useAuth } from '../hooks/useAuth'; @@ -64,6 +65,11 @@ const menuItems: MenuProps['items'] = [ icon: , label: '用户管理', }, + { + key: '/conversations', + icon: , + label: '对话管理', + }, { key: '/settings', icon: , diff --git a/packages/services/conversation-service/package.json b/packages/services/conversation-service/package.json index 209c8aa..848e449 100644 --- a/packages/services/conversation-service/package.json +++ b/packages/services/conversation-service/package.json @@ -38,9 +38,11 @@ "rxjs": "^7.8.0", "socket.io": "^4.8.3", "typeorm": "^0.3.19", - "uuid": "^9.0.0" + "uuid": "^9.0.0", + "jsonwebtoken": "^9.0.0" }, "devDependencies": { + "@types/jsonwebtoken": "^9.0.0", "@nestjs/cli": "^10.0.0", "@nestjs/testing": "^10.0.0", "@types/express": "^4.17.21", diff --git a/packages/services/conversation-service/src/adapters/inbound/admin-conversation.controller.ts b/packages/services/conversation-service/src/adapters/inbound/admin-conversation.controller.ts new file mode 100644 index 0000000..6b1d22f --- /dev/null +++ b/packages/services/conversation-service/src/adapters/inbound/admin-conversation.controller.ts @@ -0,0 +1,302 @@ +import { + Controller, + Get, + Query, + Param, + Headers, + UnauthorizedException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import * as jwt from 'jsonwebtoken'; +import { ConversationORM } from '../../infrastructure/database/postgres/entities/conversation.orm'; +import { MessageORM } from '../../infrastructure/database/postgres/entities/message.orm'; + +interface AdminPayload { + id: string; + username: string; + role: string; +} + +/** + * 管理员对话 API - 供 admin-client 使用 + * 需要管理员 JWT 认证 + */ +@Controller('conversations/admin') +export class AdminConversationController { + constructor( + @InjectRepository(ConversationORM) + private conversationRepo: Repository, + @InjectRepository(MessageORM) + private messageRepo: Repository, + ) {} + + /** + * 验证管理员 Token + */ + private verifyAdmin(authorization: string): AdminPayload { + const token = authorization?.replace('Bearer ', ''); + if (!token) { + throw new UnauthorizedException('Missing token'); + } + + try { + const secret = process.env.JWT_SECRET || 'your-jwt-secret-key'; + const payload = jwt.verify(token, secret) as AdminPayload; + return payload; + } catch { + throw new UnauthorizedException('Invalid token'); + } + } + + /** + * 获取所有对话列表(分页) + */ + @Get('list') + async listConversations( + @Headers('authorization') auth: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + @Query('status') status?: string, + @Query('userId') userId?: string, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('hasConverted') hasConverted?: string, + @Query('sortBy') sortBy?: string, + @Query('sortOrder') sortOrder?: string, + ) { + this.verifyAdmin(auth); + + const pageNum = parseInt(page || '1'); + const pageSizeNum = parseInt(pageSize || '20'); + + const queryBuilder = this.conversationRepo.createQueryBuilder('c'); + + // 应用筛选条件 + if (status) { + queryBuilder.andWhere('c.status = :status', { status }); + } + + if (userId) { + queryBuilder.andWhere('c.user_id = :userId', { userId }); + } + + if (startDate) { + queryBuilder.andWhere('c.created_at >= :startDate', { + startDate: new Date(startDate), + }); + } + + if (endDate) { + queryBuilder.andWhere('c.created_at <= :endDate', { + endDate: new Date(endDate), + }); + } + + if (hasConverted !== undefined) { + queryBuilder.andWhere('c.has_converted = :hasConverted', { + hasConverted: hasConverted === 'true', + }); + } + + // 排序 + const sortColumnMap: Record = { + createdAt: 'c.created_at', + updatedAt: 'c.updated_at', + messageCount: 'c.message_count', + }; + const validSortBy = sortBy && sortColumnMap[sortBy] ? sortBy : 'createdAt'; + const validSortOrder = sortOrder === 'ASC' ? 'ASC' : 'DESC'; + + queryBuilder.orderBy(sortColumnMap[validSortBy], validSortOrder); + + // 获取总数 + const total = await queryBuilder.getCount(); + + // 分页 + queryBuilder.skip((pageNum - 1) * pageSizeNum).take(pageSizeNum); + + const conversations = await queryBuilder.getMany(); + + return { + success: true, + data: { + items: conversations.map((c) => ({ + id: c.id, + userId: c.userId, + status: c.status, + title: c.title, + summary: c.summary, + category: c.category, + messageCount: c.messageCount, + userMessageCount: c.userMessageCount, + assistantMessageCount: c.assistantMessageCount, + totalInputTokens: c.totalInputTokens, + totalOutputTokens: c.totalOutputTokens, + rating: c.rating, + feedback: c.feedback, + hasConverted: c.hasConverted, + consultingStage: c.consultingStage, + deviceInfo: c.deviceInfo, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + endedAt: c.endedAt, + })), + total, + page: pageNum, + pageSize: pageSizeNum, + totalPages: Math.ceil(total / pageSizeNum), + }, + }; + } + + /** + * 获取用户的对话列表 + */ + @Get('by-user/:userId') + async getConversationsByUser( + @Headers('authorization') auth: string, + @Param('userId') userId: string, + @Query('limit') limit?: string, + ) { + this.verifyAdmin(auth); + + const limitNum = parseInt(limit || '10'); + + const conversations = await this.conversationRepo.find({ + where: { userId }, + order: { createdAt: 'DESC' }, + take: limitNum, + }); + + return { + success: true, + data: conversations.map((c) => ({ + id: c.id, + userId: c.userId, + status: c.status, + title: c.title, + messageCount: c.messageCount, + hasConverted: c.hasConverted, + consultingStage: c.consultingStage, + deviceInfo: c.deviceInfo, + createdAt: c.createdAt, + updatedAt: c.updatedAt, + endedAt: c.endedAt, + })), + }; + } + + /** + * 获取单个对话详情 + */ + @Get(':id') + async getConversation( + @Headers('authorization') auth: string, + @Param('id') id: string, + ) { + this.verifyAdmin(auth); + + const conversation = await this.conversationRepo.findOne({ + where: { id }, + }); + + if (!conversation) { + return { + success: false, + error: 'Conversation not found', + }; + } + + return { + success: true, + data: { + id: conversation.id, + userId: conversation.userId, + status: conversation.status, + title: conversation.title, + summary: conversation.summary, + category: conversation.category, + messageCount: conversation.messageCount, + userMessageCount: conversation.userMessageCount, + assistantMessageCount: conversation.assistantMessageCount, + totalInputTokens: conversation.totalInputTokens, + totalOutputTokens: conversation.totalOutputTokens, + rating: conversation.rating, + feedback: conversation.feedback, + hasConverted: conversation.hasConverted, + consultingStage: conversation.consultingStage, + consultingState: conversation.consultingState, + collectedInfo: conversation.collectedInfo, + recommendedPrograms: conversation.recommendedPrograms, + conversionPath: conversation.conversionPath, + deviceInfo: conversation.deviceInfo, + createdAt: conversation.createdAt, + updatedAt: conversation.updatedAt, + endedAt: conversation.endedAt, + }, + }; + } + + /** + * 获取对话的消息 + */ + @Get(':id/messages') + async getConversationMessages( + @Headers('authorization') auth: string, + @Param('id') conversationId: string, + ) { + this.verifyAdmin(auth); + + const messages = await this.messageRepo.find({ + where: { conversationId }, + order: { createdAt: 'ASC' }, + }); + + return { + success: true, + data: messages.map((m) => ({ + id: m.id, + conversationId: m.conversationId, + role: m.role, + type: m.type, + content: m.content, + metadata: m.metadata, + createdAt: m.createdAt, + })), + }; + } + + /** + * 获取对话统计 + */ + @Get('statistics/overview') + async getStatistics(@Headers('authorization') auth: string) { + this.verifyAdmin(auth); + + const total = await this.conversationRepo.count(); + const active = await this.conversationRepo.count({ where: { status: 'ACTIVE' } }); + const ended = await this.conversationRepo.count({ where: { status: 'ENDED' } }); + const converted = await this.conversationRepo.count({ where: { hasConverted: true } }); + + // 今日对话 + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayCount = await this.conversationRepo + .createQueryBuilder('c') + .where('c.created_at >= :today', { today }) + .getCount(); + + return { + success: true, + data: { + total, + active, + ended, + converted, + todayCount, + conversionRate: total > 0 ? ((converted / total) * 100).toFixed(1) : '0', + }, + }; + } +} diff --git a/packages/services/conversation-service/src/adapters/inbound/index.ts b/packages/services/conversation-service/src/adapters/inbound/index.ts index bc19b49..cc8c639 100644 --- a/packages/services/conversation-service/src/adapters/inbound/index.ts +++ b/packages/services/conversation-service/src/adapters/inbound/index.ts @@ -1,3 +1,4 @@ export * from './conversation.controller'; export * from './conversation.gateway'; export * from './internal.controller'; +export * from './admin-conversation.controller'; diff --git a/packages/services/conversation-service/src/conversation/conversation.module.ts b/packages/services/conversation-service/src/conversation/conversation.module.ts index 5aeeda5..dec00ce 100644 --- a/packages/services/conversation-service/src/conversation/conversation.module.ts +++ b/packages/services/conversation-service/src/conversation/conversation.module.ts @@ -12,11 +12,12 @@ import { TOKEN_USAGE_REPOSITORY } from '../domain/repositories/token-usage.repos import { ConversationService } from '../application/services/conversation.service'; import { ConversationController } from '../adapters/inbound/conversation.controller'; import { InternalConversationController } from '../adapters/inbound/internal.controller'; +import { AdminConversationController } from '../adapters/inbound/admin-conversation.controller'; import { ConversationGateway } from '../adapters/inbound/conversation.gateway'; @Module({ imports: [TypeOrmModule.forFeature([ConversationORM, MessageORM, TokenUsageORM])], - controllers: [ConversationController, InternalConversationController], + controllers: [ConversationController, InternalConversationController, AdminConversationController], providers: [ ConversationService, ConversationGateway, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1eb02e3..d49b5cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: ioredis: specifier: ^5.3.0 version: 5.9.1 + jsonwebtoken: + specifier: ^9.0.0 + version: 9.0.3 kafkajs: specifier: ^2.2.4 version: 2.2.4 @@ -169,6 +172,9 @@ importers: '@types/jest': specifier: ^29.5.0 version: 29.5.14 + '@types/jsonwebtoken': + specifier: ^9.0.0 + version: 9.0.10 '@types/node': specifier: ^20.10.0 version: 20.19.27