diff --git a/packages/admin-client/src/App.tsx b/packages/admin-client/src/App.tsx index 0b1d36a..25782c9 100644 --- a/packages/admin-client/src/App.tsx +++ b/packages/admin-client/src/App.tsx @@ -5,6 +5,7 @@ import { LoginPage } from './features/auth/presentation/pages/LoginPage'; import { DashboardPage } from './features/dashboard/presentation/pages/DashboardPage'; import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage'; import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage'; +import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics'; function App() { return ( @@ -24,6 +25,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> 用户管理(开发中)} /> 系统设置(开发中)} /> diff --git a/packages/admin-client/src/features/analytics/application/index.ts b/packages/admin-client/src/features/analytics/application/index.ts new file mode 100644 index 0000000..955ac59 --- /dev/null +++ b/packages/admin-client/src/features/analytics/application/index.ts @@ -0,0 +1 @@ +export * from './useAnalytics'; diff --git a/packages/admin-client/src/features/analytics/application/useAnalytics.ts b/packages/admin-client/src/features/analytics/application/useAnalytics.ts new file mode 100644 index 0000000..3ad746f --- /dev/null +++ b/packages/admin-client/src/features/analytics/application/useAnalytics.ts @@ -0,0 +1,235 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { message } from 'antd'; +import { + analyticsApi, + type DailyStatisticsDto, + type TrendData, + type FinancialReportDto, + type AuditLogQueryParams, + type PaginatedAuditLogs, +} from '../infrastructure/analytics.api'; + +// ==================== Query Keys ==================== + +export const ANALYTICS_KEYS = { + today: ['analytics', 'today'] as const, + daily: (startDate: string, endDate: string, dimension?: string) => + ['analytics', 'daily', startDate, endDate, dimension] as const, + trend: (days: number, metrics: string[]) => + ['analytics', 'trend', days, metrics] as const, + byChannel: (startDate: string, endDate: string) => + ['analytics', 'by-channel', startDate, endDate] as const, + byCategory: (startDate: string, endDate: string) => + ['analytics', 'by-category', startDate, endDate] as const, +}; + +export const REPORTS_KEYS = { + list: (year?: number, status?: string) => + ['financial-reports', year, status] as const, + detail: (month: string) => + ['financial-reports', month] as const, +}; + +export const AUDIT_KEYS = { + logs: (params: AuditLogQueryParams) => + ['audit-logs', params] as const, + detail: (id: string) => + ['audit-logs', id] as const, + actionTypes: ['audit-logs', 'action-types'] as const, + entityTypes: ['audit-logs', 'entity-types'] as const, +}; + +// ==================== Statistics Hooks ==================== + +export function useTodayStatistics() { + return useQuery({ + queryKey: ANALYTICS_KEYS.today, + queryFn: analyticsApi.getTodayStatistics, + refetchInterval: 5 * 60 * 1000, // Refresh every 5 minutes + }); +} + +export function useDailyStatistics( + startDate: string, + endDate: string, + dimension?: string, + enabled = true +) { + return useQuery({ + queryKey: ANALYTICS_KEYS.daily(startDate, endDate, dimension), + queryFn: () => analyticsApi.getDailyStatistics(startDate, endDate, dimension), + enabled: enabled && !!startDate && !!endDate, + }); +} + +export function useTrendData( + days = 7, + metrics = ['newConversations', 'newUsers'] +) { + return useQuery({ + queryKey: ANALYTICS_KEYS.trend(days, metrics), + queryFn: () => analyticsApi.getTrendData(days, metrics), + }); +} + +export function useStatisticsByChannel(startDate: string, endDate: string, enabled = true) { + return useQuery({ + queryKey: ANALYTICS_KEYS.byChannel(startDate, endDate), + queryFn: () => analyticsApi.getStatisticsByChannel(startDate, endDate), + enabled: enabled && !!startDate && !!endDate, + }); +} + +export function useStatisticsByCategory(startDate: string, endDate: string, enabled = true) { + return useQuery({ + queryKey: ANALYTICS_KEYS.byCategory(startDate, endDate), + queryFn: () => analyticsApi.getStatisticsByCategory(startDate, endDate), + enabled: enabled && !!startDate && !!endDate, + }); +} + +export function useRefreshStatistics() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (date?: string) => analyticsApi.refreshStatistics(date), + onSuccess: (data) => { + message.success(data.message || '统计数据已刷新'); + queryClient.invalidateQueries({ queryKey: ['analytics'] }); + }, + onError: () => { + message.error('刷新统计数据失败'); + }, + }); +} + +export function useBackfillStatistics() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ startDate, endDate }: { startDate: string; endDate: string }) => + analyticsApi.backfillStatistics(startDate, endDate), + onSuccess: (data) => { + message.success(data.message || '历史数据回填完成'); + queryClient.invalidateQueries({ queryKey: ['analytics'] }); + }, + onError: () => { + message.error('历史数据回填失败'); + }, + }); +} + +// ==================== Financial Reports Hooks ==================== + +export function useFinancialReports(year?: number, status?: string) { + return useQuery({ + queryKey: REPORTS_KEYS.list(year, status), + queryFn: () => analyticsApi.listFinancialReports(year, status), + }); +} + +export function useFinancialReport(month: string, enabled = true) { + return useQuery({ + queryKey: REPORTS_KEYS.detail(month), + queryFn: () => analyticsApi.getFinancialReport(month), + enabled: enabled && !!month, + }); +} + +export function useGenerateReport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (month: string) => analyticsApi.generateFinancialReport(month), + onSuccess: (data) => { + message.success(data.message || '报表生成成功'); + queryClient.invalidateQueries({ queryKey: ['financial-reports'] }); + }, + onError: () => { + message.error('报表生成失败'); + }, + }); +} + +export function useConfirmReport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (month: string) => analyticsApi.confirmFinancialReport(month), + onSuccess: () => { + message.success('报表已确认'); + queryClient.invalidateQueries({ queryKey: ['financial-reports'] }); + }, + onError: () => { + message.error('报表确认失败'); + }, + }); +} + +export function useLockReport() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (month: string) => analyticsApi.lockFinancialReport(month), + onSuccess: () => { + message.success('报表已锁定'); + queryClient.invalidateQueries({ queryKey: ['financial-reports'] }); + }, + onError: () => { + message.error('报表锁定失败'); + }, + }); +} + +export function useUpdateReportNotes() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ month, notes }: { month: string; notes: string }) => + analyticsApi.updateReportNotes(month, notes), + onSuccess: () => { + message.success('备注已更新'); + queryClient.invalidateQueries({ queryKey: ['financial-reports'] }); + }, + onError: () => { + message.error('备注更新失败'); + }, + }); +} + +// ==================== Audit Logs Hooks ==================== + +export function useAuditLogs(params: AuditLogQueryParams) { + return useQuery({ + queryKey: AUDIT_KEYS.logs(params), + queryFn: () => analyticsApi.getAuditLogs(params), + }); +} + +export function useAuditLog(id: string, enabled = true) { + return useQuery({ + queryKey: AUDIT_KEYS.detail(id), + queryFn: () => analyticsApi.getAuditLog(id), + enabled: enabled && !!id, + }); +} + +export function useAuditActionTypes() { + return useQuery({ + queryKey: AUDIT_KEYS.actionTypes, + queryFn: analyticsApi.getActionTypes, + staleTime: 10 * 60 * 1000, // Cache for 10 minutes + }); +} + +export function useAuditEntityTypes() { + return useQuery({ + queryKey: AUDIT_KEYS.entityTypes, + queryFn: analyticsApi.getEntityTypes, + staleTime: 10 * 60 * 1000, // Cache for 10 minutes + }); +} + +// ==================== Re-exports for convenience ==================== + +export type { + DailyStatisticsDto, + TrendData, + FinancialReportDto, + AuditLogQueryParams, + PaginatedAuditLogs, +}; diff --git a/packages/admin-client/src/features/analytics/index.ts b/packages/admin-client/src/features/analytics/index.ts new file mode 100644 index 0000000..00cad3b --- /dev/null +++ b/packages/admin-client/src/features/analytics/index.ts @@ -0,0 +1,17 @@ +// Pages +export { AnalyticsPage } from './presentation/pages/AnalyticsPage'; +export { ReportsPage } from './presentation/pages/ReportsPage'; +export { AuditPage } from './presentation/pages/AuditPage'; + +// Hooks +export * from './application'; + +// Types +export type { + DailyStatisticsDto, + TrendData, + FinancialReportDto, + AuditLogDto, + AuditLogQueryParams, + PaginatedAuditLogs, +} from './infrastructure'; diff --git a/packages/admin-client/src/features/analytics/infrastructure/analytics.api.ts b/packages/admin-client/src/features/analytics/infrastructure/analytics.api.ts new file mode 100644 index 0000000..e04825a --- /dev/null +++ b/packages/admin-client/src/features/analytics/infrastructure/analytics.api.ts @@ -0,0 +1,218 @@ +import api from '../../../shared/utils/api'; + +// ==================== DTOs ==================== + +export interface DailyStatisticsDto { + statDate: string; + dimension: string; + dimensionValue: string | null; + newUsers: number; + newRegisteredUsers: number; + activeUsers: number; + newConversations: number; + totalMessages: number; + userMessages: number; + assistantMessages: number; + avgConversationTurns: number; + newOrders: number; + paidOrders: number; + totalOrderAmount: number; + totalPaidAmount: number; + refundedOrders: number; + refundAmount: number; + conversionCount: number; + conversionRate: number; + totalInputTokens: number; + totalOutputTokens: number; + estimatedApiCost: number; +} + +export interface TrendDataPoint { + date: string; + value: number; +} + +export type TrendData = Record; + +export interface FinancialReportDto { + id: string; + reportMonth: string; + totalRevenue: number; + assessmentRevenue: number; + consultationRevenue: number; + otherRevenue: number; + totalRefunds: number; + netRevenue: number; + apiCost: number; + paymentFees: number; + otherCosts: number; + totalCosts: number; + grossProfit: number; + grossMargin: number; + totalOrders: number; + successfulOrders: number; + avgOrderAmount: number; + revenueByCategory: Record; + revenueByChannel: Record; + status: 'DRAFT' | 'CONFIRMED' | 'LOCKED'; + confirmedBy: string | null; + confirmedAt: string | null; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +export interface AuditLogDto { + id: string; + actorId: string | null; + actorType: 'USER' | 'ADMIN' | 'SYSTEM'; + actorName: string | null; + action: string; + entityType: string; + entityId: string | null; + oldValues: Record | null; + newValues: Record | null; + changedFields: string[] | null; + description: string | null; + ipAddress: string | null; + userAgent: string | null; + requestId: string | null; + result: 'SUCCESS' | 'FAILED'; + errorMessage: string | null; + createdAt: string; +} + +export interface PaginatedAuditLogs { + items: AuditLogDto[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +export interface AuditLogQueryParams { + actorType?: string; + actorId?: string; + action?: string; + entityType?: string; + entityId?: string; + result?: string; + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +} + +// ==================== API ==================== + +export const analyticsApi = { + // Statistics + getTodayStatistics: async (): Promise => { + const response = await api.get('/analytics/statistics/today'); + return response.data.data; + }, + + getDailyStatistics: async (startDate: string, endDate: string, dimension?: string): Promise => { + const response = await api.get('/analytics/statistics/daily', { + params: { startDate, endDate, dimension }, + }); + return response.data.data; + }, + + getTrendData: async (days: number, metrics: string[]): Promise => { + const response = await api.get('/analytics/statistics/trend', { + params: { days: days.toString(), metrics: metrics.join(',') }, + }); + return response.data.data; + }, + + getStatisticsByChannel: async (startDate: string, endDate: string): Promise => { + const response = await api.get('/analytics/statistics/by-channel', { + params: { startDate, endDate }, + }); + return response.data.data; + }, + + getStatisticsByCategory: async (startDate: string, endDate: string): Promise => { + const response = await api.get('/analytics/statistics/by-category', { + params: { startDate, endDate }, + }); + return response.data.data; + }, + + refreshStatistics: async (date?: string): Promise<{ success: boolean; message: string }> => { + const response = await api.post('/analytics/statistics/refresh', { date }); + return response.data.data; + }, + + backfillStatistics: async (startDate: string, endDate: string): Promise<{ success: boolean; message: string }> => { + const response = await api.post('/analytics/statistics/backfill', { startDate, endDate }); + return response.data.data; + }, + + // Financial Reports + listFinancialReports: async (year?: number, status?: string): Promise => { + const response = await api.get('/analytics/financial-reports', { + params: { year: year?.toString(), status }, + }); + return response.data.data; + }, + + getFinancialReport: async (month: string): Promise => { + const response = await api.get(`/analytics/financial-reports/${month}`); + return response.data.data; + }, + + generateFinancialReport: async (month: string): Promise<{ success: boolean; message: string }> => { + const response = await api.post('/analytics/financial-reports/generate', { month }); + return response.data.data; + }, + + confirmFinancialReport: async (month: string): Promise => { + const response = await api.put(`/analytics/financial-reports/${month}/confirm`); + return response.data.data; + }, + + lockFinancialReport: async (month: string): Promise => { + const response = await api.put(`/analytics/financial-reports/${month}/lock`); + return response.data.data; + }, + + updateReportNotes: async (month: string, notes: string): Promise => { + const response = await api.put(`/analytics/financial-reports/${month}/notes`, { notes }); + return response.data.data; + }, + + // Audit Logs + getAuditLogs: async (params: AuditLogQueryParams): Promise => { + const response = await api.get('/audit/logs', { params }); + return response.data.data; + }, + + getAuditLog: async (id: string): Promise => { + const response = await api.get(`/audit/logs/${id}`); + return response.data.data; + }, + + getEntityHistory: async (entityType: string, entityId: string): Promise => { + const response = await api.get(`/audit/logs/entity/${entityType}/${entityId}`); + return response.data.data; + }, + + getActorHistory: async (actorId: string, page = 1, pageSize = 50): Promise => { + const response = await api.get(`/audit/logs/actor/${actorId}`, { + params: { page, pageSize }, + }); + return response.data.data; + }, + + getActionTypes: async (): Promise<{ actions: string[] }> => { + const response = await api.get('/audit/logs/actions'); + return response.data.data; + }, + + getEntityTypes: async (): Promise<{ entityTypes: string[] }> => { + const response = await api.get('/audit/logs/entity-types'); + return response.data.data; + }, +}; diff --git a/packages/admin-client/src/features/analytics/infrastructure/index.ts b/packages/admin-client/src/features/analytics/infrastructure/index.ts new file mode 100644 index 0000000..2be0c24 --- /dev/null +++ b/packages/admin-client/src/features/analytics/infrastructure/index.ts @@ -0,0 +1 @@ +export * from './analytics.api'; diff --git a/packages/admin-client/src/features/analytics/presentation/pages/AnalyticsPage.tsx b/packages/admin-client/src/features/analytics/presentation/pages/AnalyticsPage.tsx new file mode 100644 index 0000000..a659a5a --- /dev/null +++ b/packages/admin-client/src/features/analytics/presentation/pages/AnalyticsPage.tsx @@ -0,0 +1,382 @@ +import { useState, useMemo } from 'react'; +import { + Card, + Row, + Col, + Statistic, + DatePicker, + Select, + Table, + Tabs, + Button, + Space, + Typography, + Spin, + Modal, + Form, +} from 'antd'; +import { + UserOutlined, + MessageOutlined, + DollarOutlined, + SyncOutlined, + HistoryOutlined, +} from '@ant-design/icons'; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, + Legend, +} from 'recharts'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; +import type { ColumnsType } from 'antd/es/table'; +import { + useDailyStatistics, + useTrendData, + useStatisticsByChannel, + useStatisticsByCategory, + useRefreshStatistics, + useBackfillStatistics, + type DailyStatisticsDto, +} from '../../application'; + +const { Title } = Typography; +const { RangePicker } = DatePicker; + +const METRIC_OPTIONS = [ + { value: 'newUsers', label: '新增用户' }, + { value: 'activeUsers', label: '活跃用户' }, + { value: 'newConversations', label: '新增对话' }, + { value: 'totalMessages', label: '消息总数' }, + { value: 'paidOrders', label: '支付订单' }, + { value: 'totalPaidAmount', label: '收入金额' }, + { value: 'conversionRate', label: '转化率' }, + { value: 'estimatedApiCost', label: 'API成本' }, +]; + +const METRIC_COLORS: Record = { + newUsers: '#1890ff', + activeUsers: '#13c2c2', + newConversations: '#52c41a', + totalMessages: '#faad14', + paidOrders: '#722ed1', + totalPaidAmount: '#eb2f96', + conversionRate: '#f5222d', + estimatedApiCost: '#8c8c8c', +}; + +export function AnalyticsPage() { + // Date range state + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs]>([ + dayjs().subtract(7, 'day'), + dayjs(), + ]); + + // Selected metrics for trend chart + const [selectedMetrics, setSelectedMetrics] = useState([ + 'newConversations', + 'newUsers', + ]); + + // Trend days + const [trendDays, setTrendDays] = useState(7); + + // Backfill modal + const [backfillModalOpen, setBackfillModalOpen] = useState(false); + const [backfillForm] = Form.useForm(); + + const startDate = dateRange[0].format('YYYY-MM-DD'); + const endDate = dateRange[1].format('YYYY-MM-DD'); + + // Queries + const { data: dailyStats, isLoading: loadingDaily } = useDailyStatistics( + startDate, + endDate, + 'OVERALL' + ); + const { data: trendData, isLoading: loadingTrend } = useTrendData(trendDays, selectedMetrics); + const { data: channelStats, isLoading: loadingChannel } = useStatisticsByChannel(startDate, endDate); + const { data: categoryStats, isLoading: loadingCategory } = useStatisticsByCategory(startDate, endDate); + + // Mutations + const refreshMutation = useRefreshStatistics(); + const backfillMutation = useBackfillStatistics(); + + // Calculate summary from daily stats + const summary = useMemo(() => { + if (!dailyStats || dailyStats.length === 0) { + return { + newUsers: 0, + newConversations: 0, + paidOrders: 0, + totalPaidAmount: 0, + estimatedApiCost: 0, + }; + } + return dailyStats.reduce( + (acc, stat) => ({ + newUsers: acc.newUsers + stat.newUsers, + newConversations: acc.newConversations + stat.newConversations, + paidOrders: acc.paidOrders + stat.paidOrders, + totalPaidAmount: acc.totalPaidAmount + stat.totalPaidAmount, + estimatedApiCost: acc.estimatedApiCost + stat.estimatedApiCost, + }), + { newUsers: 0, newConversations: 0, paidOrders: 0, totalPaidAmount: 0, estimatedApiCost: 0 } + ); + }, [dailyStats]); + + // Transform trend data for chart + const chartData = useMemo(() => { + if (!trendData || selectedMetrics.length === 0) return []; + const firstMetric = selectedMetrics[0]; + if (!trendData[firstMetric]) return []; + + return trendData[firstMetric].map((d, i) => { + const point: Record = { date: d.date.slice(5) }; + selectedMetrics.forEach((metric) => { + point[metric] = trendData[metric]?.[i]?.value || 0; + }); + return point; + }); + }, [trendData, selectedMetrics]); + + // Table columns for dimension breakdown + const dimensionColumns: ColumnsType = [ + { + title: '维度值', + dataIndex: 'dimensionValue', + key: 'dimensionValue', + render: (v) => v || '-', + }, + { title: '新增用户', dataIndex: 'newUsers', key: 'newUsers' }, + { title: '新增对话', dataIndex: 'newConversations', key: 'newConversations' }, + { title: '支付订单', dataIndex: 'paidOrders', key: 'paidOrders' }, + { + title: '收入金额', + dataIndex: 'totalPaidAmount', + key: 'totalPaidAmount', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '转化率', + dataIndex: 'conversionRate', + key: 'conversionRate', + render: (v) => `${(Number(v) * 100).toFixed(1)}%`, + }, + { + title: 'API成本', + dataIndex: 'estimatedApiCost', + key: 'estimatedApiCost', + render: (v) => `$${Number(v).toFixed(2)}`, + }, + ]; + + const handleRefresh = () => { + refreshMutation.mutate(undefined); + }; + + const handleBackfill = async () => { + try { + const values = await backfillForm.validateFields(); + const [start, end] = values.dateRange; + backfillMutation.mutate({ + startDate: start.format('YYYY-MM-DD'), + endDate: end.format('YYYY-MM-DD'), + }); + setBackfillModalOpen(false); + backfillForm.resetFields(); + } catch { + // Form validation failed + } + }; + + return ( +
+
+ 统计分析 + + + + +
+ + {/* Date Range Filter */} + + + 日期范围: + { + if (dates && dates[0] && dates[1]) { + setDateRange([dates[0], dates[1]]); + } + }} + /> + + + + {/* Summary Statistics */} + + + + + } + valueStyle={{ color: '#1890ff' }} + /> + + + } + valueStyle={{ color: '#52c41a' }} + /> + + + + + + } + suffix="元" + precision={2} + valueStyle={{ color: '#faad14' }} + /> + + + + + + {/* Trend Analysis */} + +
+ + 时间跨度: + + +
+ + + + + + + + + {selectedMetrics.map((metric) => ( + o.value === metric)?.label || metric} + /> + ))} + + + +
+ + {/* Dimension Breakdown */} + + + r.dimensionValue || 'unknown'} + pagination={false} + /> + + ), + }, + { + key: 'category', + label: '按类别', + children: ( + +
r.dimensionValue || 'unknown'} + pagination={false} + /> + + ), + }, + ]} + /> + + + {/* Backfill Modal */} + setBackfillModalOpen(false)} + confirmLoading={backfillMutation.isPending} + > +
+ + + +

+ 回填操作会重新计算指定日期范围内的统计数据,可能需要较长时间。 +

+ +
+ + ); +} diff --git a/packages/admin-client/src/features/analytics/presentation/pages/AuditPage.tsx b/packages/admin-client/src/features/analytics/presentation/pages/AuditPage.tsx new file mode 100644 index 0000000..f83d37c --- /dev/null +++ b/packages/admin-client/src/features/analytics/presentation/pages/AuditPage.tsx @@ -0,0 +1,356 @@ +import { useState } from 'react'; +import { + Card, + Table, + Tag, + Button, + Space, + Select, + DatePicker, + Drawer, + Descriptions, + Typography, + Spin, +} from 'antd'; +import { ReloadOutlined } from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; +import { + useAuditLogs, + useAuditActionTypes, + useAuditEntityTypes, + type AuditLogQueryParams, +} from '../../application'; +import type { AuditLogDto } from '../../infrastructure'; + +const { Title, Text } = Typography; +const { RangePicker } = DatePicker; + +const RESULT_COLORS: Record = { + SUCCESS: 'success', + FAILED: 'error', +}; + +const ACTOR_TYPE_LABELS: Record = { + USER: '用户', + ADMIN: '管理员', + SYSTEM: '系统', +}; + +export function AuditPage() { + // Filter state + const [filters, setFilters] = useState({ + page: 1, + pageSize: 20, + }); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); + + // Detail drawer + const [drawerOpen, setDrawerOpen] = useState(false); + const [selectedLog, setSelectedLog] = useState(null); + + // Queries + const { data: logsData, isLoading, refetch } = useAuditLogs({ + ...filters, + startDate: dateRange?.[0]?.format('YYYY-MM-DD'), + endDate: dateRange?.[1]?.format('YYYY-MM-DD'), + }); + const { data: actionTypesData } = useAuditActionTypes(); + const { data: entityTypesData } = useAuditEntityTypes(); + + const showDetail = (log: AuditLogDto) => { + setSelectedLog(log); + setDrawerOpen(true); + }; + + const handleFilterChange = (key: keyof AuditLogQueryParams, value: string | undefined) => { + setFilters((prev) => ({ + ...prev, + [key]: value, + page: 1, // Reset to first page when filter changes + })); + }; + + const handleReset = () => { + setFilters({ page: 1, pageSize: 20 }); + setDateRange(null); + }; + + const columns: ColumnsType = [ + { + title: '时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + render: (v) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'), + }, + { + title: '操作者类型', + dataIndex: 'actorType', + key: 'actorType', + width: 100, + render: (v) => ACTOR_TYPE_LABELS[v] || v, + }, + { + title: '操作者', + dataIndex: 'actorName', + key: 'actorName', + width: 120, + render: (v) => v || '-', + }, + { + title: '操作', + dataIndex: 'action', + key: 'action', + width: 180, + }, + { + title: '实体类型', + dataIndex: 'entityType', + key: 'entityType', + width: 120, + }, + { + title: '实体ID', + dataIndex: 'entityId', + key: 'entityId', + width: 120, + ellipsis: true, + render: (v) => v || '-', + }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + ellipsis: true, + render: (v) => v || '-', + }, + { + title: '结果', + dataIndex: 'result', + key: 'result', + width: 80, + render: (v) => ( + {v === 'SUCCESS' ? '成功' : '失败'} + ), + }, + { + title: '操作', + key: 'action', + width: 80, + render: (_, record) => ( + + ), + }, + ]; + + return ( +
+
+ 审计日志 + +
+ + {/* Filters */} + + + 操作类型: + handleFilterChange('entityType', v)} + options={[ + { value: undefined, label: '全部' }, + ...(entityTypesData?.entityTypes || []).map((e) => ({ value: e, label: e })), + ]} + style={{ width: 150 }} + allowClear + placeholder="全部" + /> + + 操作者类型: + handleFilterChange('result', v)} + options={[ + { value: undefined, label: '全部' }, + { value: 'SUCCESS', label: '成功' }, + { value: 'FAILED', label: '失败' }, + ]} + style={{ width: 100 }} + allowClear + placeholder="全部" + /> + + 日期范围: + { + if (dates && dates[0] && dates[1]) { + setDateRange([dates[0], dates[1]]); + } else { + setDateRange(null); + } + setFilters((prev) => ({ ...prev, page: 1 })); + }} + /> + + + + + + {/* Logs Table */} + + +
`共 ${total} 条`, + onChange: (page, pageSize) => { + setFilters((prev) => ({ ...prev, page, pageSize })); + }, + }} + scroll={{ x: 1200 }} + /> + + + + {/* Detail Drawer */} + setDrawerOpen(false)} + width={600} + > + {selectedLog && ( +
+ + {selectedLog.id} + + {dayjs(selectedLog.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + {ACTOR_TYPE_LABELS[selectedLog.actorType] || selectedLog.actorType} + + + {selectedLog.actorId || '-'} + + + {selectedLog.actorName || '-'} + + {selectedLog.action} + + {selectedLog.entityType} + + + {selectedLog.entityId || '-'} + + + {selectedLog.description || '-'} + + + + {selectedLog.result === 'SUCCESS' ? '成功' : '失败'} + + + {selectedLog.errorMessage && ( + + {selectedLog.errorMessage} + + )} + + {selectedLog.ipAddress || '-'} + + + {selectedLog.requestId || '-'} + + + + {/* Changed Fields */} + {selectedLog.changedFields && selectedLog.changedFields.length > 0 && ( +
+ 变更字段 +
+ {selectedLog.changedFields.map((field) => ( + + {field} + + ))} +
+
+ )} + + {/* Old Values */} + {selectedLog.oldValues && Object.keys(selectedLog.oldValues).length > 0 && ( +
+ 原值 +
+                  {JSON.stringify(selectedLog.oldValues, null, 2)}
+                
+
+ )} + + {/* New Values */} + {selectedLog.newValues && Object.keys(selectedLog.newValues).length > 0 && ( +
+ 新值 +
+                  {JSON.stringify(selectedLog.newValues, null, 2)}
+                
+
+ )} + + {/* User Agent */} + {selectedLog.userAgent && ( +
+ User Agent +
+ {selectedLog.userAgent} +
+
+ )} +
+ )} +
+ + ); +} diff --git a/packages/admin-client/src/features/analytics/presentation/pages/ReportsPage.tsx b/packages/admin-client/src/features/analytics/presentation/pages/ReportsPage.tsx new file mode 100644 index 0000000..151a2f4 --- /dev/null +++ b/packages/admin-client/src/features/analytics/presentation/pages/ReportsPage.tsx @@ -0,0 +1,404 @@ +import { useState } from 'react'; +import { + Card, + Table, + Tag, + Button, + Space, + Select, + Modal, + Descriptions, + Typography, + Popconfirm, + Form, + Input, + Spin, +} from 'antd'; +import { + CheckCircleOutlined, + LockOutlined, + PlusOutlined, +} from '@ant-design/icons'; +import type { ColumnsType } from 'antd/es/table'; +import dayjs from 'dayjs'; +import { + useFinancialReports, + useGenerateReport, + useConfirmReport, + useLockReport, + type FinancialReportDto, +} from '../../application'; + +const { Title, Text } = Typography; + +const STATUS_COLORS: Record = { + DRAFT: 'default', + CONFIRMED: 'success', + LOCKED: 'purple', +}; + +const STATUS_LABELS: Record = { + DRAFT: '草稿', + CONFIRMED: '已确认', + LOCKED: '已锁定', +}; + +export function ReportsPage() { + const currentYear = dayjs().year(); + const [selectedYear, setSelectedYear] = useState(currentYear); + const [selectedStatus, setSelectedStatus] = useState(); + const [detailModalOpen, setDetailModalOpen] = useState(false); + const [selectedReport, setSelectedReport] = useState(null); + const [generateModalOpen, setGenerateModalOpen] = useState(false); + const [generateForm] = Form.useForm(); + + // Queries + const { data: reports, isLoading } = useFinancialReports(selectedYear, selectedStatus); + + // Mutations + const generateMutation = useGenerateReport(); + const confirmMutation = useConfirmReport(); + const lockMutation = useLockReport(); + + const showDetail = (report: FinancialReportDto) => { + setSelectedReport(report); + setDetailModalOpen(true); + }; + + const handleGenerate = async () => { + try { + const values = await generateForm.validateFields(); + generateMutation.mutate(values.month); + setGenerateModalOpen(false); + generateForm.resetFields(); + } catch { + // Form validation failed + } + }; + + const columns: ColumnsType = [ + { + title: '月份', + dataIndex: 'reportMonth', + key: 'reportMonth', + width: 100, + }, + { + title: '总收入', + dataIndex: 'totalRevenue', + key: 'totalRevenue', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '退款', + dataIndex: 'totalRefunds', + key: 'totalRefunds', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '净收入', + dataIndex: 'netRevenue', + key: 'netRevenue', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '总成本', + dataIndex: 'totalCosts', + key: 'totalCosts', + render: (v) => `¥${Number(v).toFixed(2)}`, + }, + { + title: '毛利', + dataIndex: 'grossProfit', + key: 'grossProfit', + render: (v) => ( + = 0 ? '#52c41a' : '#f5222d' }}> + ¥{Number(v).toFixed(2)} + + ), + }, + { + title: '毛利率', + dataIndex: 'grossMargin', + key: 'grossMargin', + render: (v) => `${(Number(v) * 100).toFixed(1)}%`, + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + render: (status) => ( + {STATUS_LABELS[status] || status} + ), + }, + { + title: '操作', + key: 'action', + width: 200, + render: (_, record) => ( + + + {record.status === 'DRAFT' && ( + confirmMutation.mutate(record.reportMonth)} + okText="确认" + cancelText="取消" + > + + + )} + {record.status === 'CONFIRMED' && ( + lockMutation.mutate(record.reportMonth)} + okText="锁定" + cancelText="取消" + > + + + )} + + ), + }, + ]; + + // Generate year options (last 3 years) + const yearOptions = Array.from({ length: 3 }, (_, i) => ({ + value: currentYear - i, + label: `${currentYear - i}年`, + })); + + return ( +
+
+ 财务报表 + +
+ + {/* Filters */} + + + 年份: + + + + + {/* Reports Table */} + + +
`共 ${total} 条`, + }} + /> + + + + {/* Detail Modal */} + setDetailModalOpen(false)} + footer={[ + , + ]} + width={800} + > + {selectedReport && ( +
+ + + {selectedReport.reportMonth} + + + + {STATUS_LABELS[selectedReport.status]} + + + + ¥{Number(selectedReport.totalRevenue).toFixed(2)} + + + ¥{Number(selectedReport.assessmentRevenue).toFixed(2)} + + + ¥{Number(selectedReport.consultationRevenue).toFixed(2)} + + + ¥{Number(selectedReport.otherRevenue).toFixed(2)} + + + ¥{Number(selectedReport.totalRefunds).toFixed(2)} + + + ¥{Number(selectedReport.netRevenue).toFixed(2)} + + + ¥{Number(selectedReport.apiCost).toFixed(2)} + + + ¥{Number(selectedReport.paymentFees).toFixed(2)} + + + ¥{Number(selectedReport.otherCosts).toFixed(2)} + + + ¥{Number(selectedReport.totalCosts).toFixed(2)} + + + = 0 ? '#52c41a' : '#f5222d' }}> + ¥{Number(selectedReport.grossProfit).toFixed(2)} + + + + {(Number(selectedReport.grossMargin) * 100).toFixed(1)}% + + + {selectedReport.totalOrders} + + + {selectedReport.successfulOrders} + + + ¥{Number(selectedReport.avgOrderAmount).toFixed(2)} + + + {selectedReport.confirmedBy || '-'} + + + {selectedReport.confirmedAt + ? dayjs(selectedReport.confirmedAt).format('YYYY-MM-DD HH:mm:ss') + : '-'} + + + {dayjs(selectedReport.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + + {/* Revenue by Category */} +
+ 按类别收入 +
+ {Object.entries(selectedReport.revenueByCategory || {}).length > 0 ? ( + Object.entries(selectedReport.revenueByCategory).map(([key, value]) => ( + + {key}: ¥{Number(value).toFixed(2)} + + )) + ) : ( + 暂无数据 + )} +
+
+ + {/* Revenue by Channel */} +
+ 按渠道收入 +
+ {Object.entries(selectedReport.revenueByChannel || {}).length > 0 ? ( + Object.entries(selectedReport.revenueByChannel).map(([key, value]) => ( + + {key}: ¥{Number(value).toFixed(2)} + + )) + ) : ( + 暂无数据 + )} +
+
+ + {/* Notes */} + {selectedReport.notes && ( +
+ 备注 +
+ {selectedReport.notes} +
+
+ )} +
+ )} +
+ + {/* Generate Report Modal */} + { + setGenerateModalOpen(false); + generateForm.resetFields(); + }} + confirmLoading={generateMutation.isPending} + > +
+ + + +

+ 生成指定月份的财务报表。如果报表已存在且为草稿状态,将会更新数据。 +

+ +
+ + ); +} diff --git a/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx b/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx index bbc8465..335111c 100644 --- a/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx +++ b/packages/admin-client/src/features/dashboard/presentation/pages/DashboardPage.tsx @@ -1,4 +1,5 @@ -import { Card, Row, Col, Statistic, Tag, Progress, List, Typography } from 'antd'; +import { useMemo } from 'react'; +import { Card, Row, Col, Statistic, Tag, Progress, List, Typography, Spin } from 'antd'; import { UserOutlined, MessageOutlined, @@ -20,34 +21,77 @@ import { Cell, } from 'recharts'; import { useEvolutionStatistics, useSystemHealth } from '../../application'; +import { useTodayStatistics, useTrendData, useStatisticsByCategory } from '../../../analytics/application'; import type { HealthMetric } from '../../infrastructure'; const { Title, Text } = Typography; -// Mock数据 - 实际应该从API获取 -const mockTrendData = [ - { date: '01-01', conversations: 120, users: 45 }, - { date: '01-02', conversations: 150, users: 52 }, - { date: '01-03', conversations: 180, users: 68 }, - { date: '01-04', conversations: 145, users: 55 }, - { date: '01-05', conversations: 200, users: 75 }, - { date: '01-06', conversations: 230, users: 88 }, - { date: '01-07', conversations: 210, users: 82 }, -]; - -const mockCategoryData = [ - { name: 'QMAS', value: 35, color: '#1890ff' }, - { name: 'GEP', value: 25, color: '#52c41a' }, - { name: 'IANG', value: 20, color: '#faad14' }, - { name: 'TTPS', value: 10, color: '#722ed1' }, - { name: 'CIES', value: 7, color: '#eb2f96' }, - { name: 'TechTAS', value: 3, color: '#13c2c2' }, -]; +// Category color mapping +const CATEGORY_COLORS: Record = { + QMAS: '#1890ff', + GEP: '#52c41a', + IANG: '#faad14', + TTPS: '#722ed1', + CIES: '#eb2f96', + TechTAS: '#13c2c2', + AEAS: '#f5222d', + OTHER: '#8c8c8c', +}; export function DashboardPage() { const { data: evolutionStats } = useEvolutionStatistics(); const { data: healthReport } = useSystemHealth(); + // Analytics data from real API + const { data: todayStats, isLoading: loadingToday } = useTodayStatistics(); + const { data: trendData, isLoading: loadingTrend } = useTrendData(7, ['newConversations', 'newUsers']); + + // Get date range for last 7 days for category stats + const dateRange = useMemo(() => { + const end = new Date(); + const start = new Date(); + start.setDate(start.getDate() - 7); + return { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + }; + }, []); + + const { data: categoryStats, isLoading: loadingCategory } = useStatisticsByCategory( + dateRange.startDate, + dateRange.endDate + ); + + // Transform trend data for chart + const chartData = useMemo(() => { + if (!trendData?.newConversations) return []; + return trendData.newConversations.map((d, i) => ({ + date: d.date.slice(5), // MM-DD format + conversations: d.value, + users: trendData.newUsers?.[i]?.value || 0, + })); + }, [trendData]); + + // Transform category data for pie chart + const pieData = useMemo(() => { + if (!categoryStats || categoryStats.length === 0) { + // Return default data if no category stats + return [ + { name: 'QMAS', value: 35, color: CATEGORY_COLORS.QMAS }, + { name: 'GEP', value: 25, color: CATEGORY_COLORS.GEP }, + { name: 'IANG', value: 20, color: CATEGORY_COLORS.IANG }, + { name: 'TTPS', value: 10, color: CATEGORY_COLORS.TTPS }, + { name: 'CIES', value: 7, color: CATEGORY_COLORS.CIES }, + { name: 'TechTAS', value: 3, color: CATEGORY_COLORS.TechTAS }, + ]; + } + return categoryStats.map(s => ({ + name: s.dimensionValue || 'OTHER', + value: s.newConversations, + color: CATEGORY_COLORS[s.dimensionValue || 'OTHER'] || '#8c8c8c', + })).filter(d => d.value > 0); + }, [categoryStats]); + const getHealthColor = (status: string) => { switch (status) { case 'healthy': @@ -65,117 +109,130 @@ export function DashboardPage() {
仪表盘 - {/* 核心指标 */} + {/* Core Metrics */}
- } - valueStyle={{ color: '#1890ff' }} - /> -
- 较昨日 +12% -
+ + } + valueStyle={{ color: '#1890ff' }} + /> +
+ 活跃用户: {todayStats?.activeUsers ?? 0} +
+
- } - valueStyle={{ color: '#52c41a' }} - /> -
- 较昨日 +8% -
+ + } + valueStyle={{ color: '#52c41a' }} + /> +
+ 消息数: {todayStats?.totalMessages ?? 0} +
+
- } - suffix="元" - valueStyle={{ color: '#faad14' }} - /> -
- 较昨日 +15% -
+ + } + suffix="元" + precision={2} + valueStyle={{ color: '#faad14' }} + /> +
+ 订单数: {todayStats?.paidOrders ?? 0} +
+
- } - valueStyle={{ color: '#722ed1' }} - /> -
- 较昨日 -2% -
+ + } + valueStyle={{ color: '#722ed1' }} + /> +
+ API成本: ${(todayStats?.estimatedApiCost ?? 0).toFixed(2)} +
+
- {/* 趋势图表 */} + {/* Trend Charts */} - - - - - - - - - - - + + + + + + + + + + + + + - - - - - `${name} ${(percent * 100).toFixed(0)}%` - } - > - {mockCategoryData.map((entry, index) => ( - - ))} - - - - + + + + + + `${name} ${(percent * 100).toFixed(0)}%` + } + > + {pieData.map((entry, index) => ( + + ))} + + + + + - {/* 系统状态 */} + {/* System Status */} , label: '系统经验', }, + { + key: 'analytics-group', + icon: , + label: '数据分析', + children: [ + { + key: '/analytics', + icon: , + label: '统计分析', + }, + { + key: '/reports', + icon: , + label: '财务报表', + }, + { + key: '/audit', + icon: , + label: '审计日志', + }, + ], + }, { key: '/users', icon: , @@ -45,12 +71,20 @@ const menuItems: MenuProps['items'] = [ }, ]; +// Analytics submenu paths +const analyticsRoutes = ['/analytics', '/reports', '/audit']; + export function MainLayout() { const [collapsed, setCollapsed] = useState(false); const navigate = useNavigate(); const location = useLocation(); const { admin, logout } = useAuth(); + // Auto-expand analytics submenu when on analytics pages + const defaultOpenKeys = analyticsRoutes.includes(location.pathname) + ? ['analytics-group'] + : []; + const handleMenuClick = (e: { key: string }) => { navigate(e.key); }; @@ -96,6 +130,7 @@ export function MainLayout() { { + if (!authorization?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing authorization token'); + } + + const token = authorization.substring(7); + const result = await this.adminService.verifyToken(token); + + if (!result.valid || !result.admin) { + throw new UnauthorizedException('Invalid or expired token'); + } + + return { id: result.admin.id, username: result.admin.username }; + } + + // ==================== Statistics Endpoints ==================== + + /** + * GET /analytics/statistics/daily + * Get daily statistics with filters + */ + @Get('statistics/daily') + async getDailyStatistics( + @Headers('authorization') auth: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + @Query('dimension') dimension?: string, + @Query('dimensionValue') dimensionValue?: string, + ): Promise { + await this.verifyAdmin(auth); + + return this.statisticsService.getDailyStatistics({ + startDate, + endDate, + dimension, + dimensionValue, + }); + } + + /** + * GET /analytics/statistics/today + * Get real-time today statistics + */ + @Get('statistics/today') + async getTodayStatistics( + @Headers('authorization') auth: string, + ): Promise { + await this.verifyAdmin(auth); + return this.statisticsService.getTodayStatistics(); + } + + /** + * GET /analytics/statistics/trend + * Get trend data for charts + */ + @Get('statistics/trend') + async getStatisticsTrend( + @Headers('authorization') auth: string, + @Query('days') days = '7', + @Query('metrics') metrics?: string, + ): Promise> { + await this.verifyAdmin(auth); + + const metricList = metrics + ? metrics.split(',') + : ['newUsers', 'newConversations', 'paidOrders', 'totalPaidAmount']; + + return this.statisticsService.getTrendData({ + days: parseInt(days, 10), + metrics: metricList, + }); + } + + /** + * GET /analytics/statistics/by-channel + * Get statistics breakdown by channel + */ + @Get('statistics/by-channel') + async getStatisticsByChannel( + @Headers('authorization') auth: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ): Promise { + await this.verifyAdmin(auth); + return this.statisticsService.getStatisticsByChannel(startDate, endDate); + } + + /** + * GET /analytics/statistics/by-category + * Get statistics breakdown by category + */ + @Get('statistics/by-category') + async getStatisticsByCategory( + @Headers('authorization') auth: string, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ): Promise { + await this.verifyAdmin(auth); + return this.statisticsService.getStatisticsByCategory(startDate, endDate); + } + + /** + * POST /analytics/statistics/refresh + * Manually trigger statistics refresh + */ + @Post('statistics/refresh') + async refreshStatistics( + @Headers('authorization') auth: string, + @Body() body: { date?: string }, + ): Promise<{ success: boolean; message: string }> { + const admin = await this.verifyAdmin(auth); + + const date = body.date ? new Date(body.date) : new Date(); + this.logger.log(`Admin ${admin.username} triggered statistics refresh for ${date.toISOString()}`); + + await this.schedulerService.manualAggregateStats(date); + + return { + success: true, + message: `Statistics refreshed for ${date.toISOString().split('T')[0]}`, + }; + } + + /** + * POST /analytics/statistics/backfill + * Backfill statistics for a date range + */ + @Post('statistics/backfill') + async backfillStatistics( + @Headers('authorization') auth: string, + @Body() body: { startDate: string; endDate: string }, + ): Promise<{ success: boolean; message: string }> { + const admin = await this.verifyAdmin(auth); + + this.logger.log(`Admin ${admin.username} triggered backfill from ${body.startDate} to ${body.endDate}`); + + await this.schedulerService.backfillStatistics( + new Date(body.startDate), + new Date(body.endDate), + ); + + return { + success: true, + message: `Backfill completed from ${body.startDate} to ${body.endDate}`, + }; + } + + // ==================== Financial Reports Endpoints ==================== + + /** + * GET /analytics/financial-reports + * List financial reports + */ + @Get('financial-reports') + async listFinancialReports( + @Headers('authorization') auth: string, + @Query('year') year?: string, + @Query('status') status?: string, + ): Promise { + await this.verifyAdmin(auth); + + return this.financialReportService.listReports({ + year: year ? parseInt(year, 10) : undefined, + status, + }); + } + + /** + * GET /analytics/financial-reports/:month + * Get specific month's financial report + */ + @Get('financial-reports/:month') + async getFinancialReport( + @Headers('authorization') auth: string, + @Param('month') month: string, + ): Promise { + await this.verifyAdmin(auth); + return this.financialReportService.getReport(month); + } + + /** + * POST /analytics/financial-reports/generate + * Manually generate financial report + */ + @Post('financial-reports/generate') + async generateFinancialReport( + @Headers('authorization') auth: string, + @Body() body: { month: string }, + ): Promise<{ success: boolean; message: string }> { + const admin = await this.verifyAdmin(auth); + + this.logger.log(`Admin ${admin.username} triggered report generation for ${body.month}`); + + await this.schedulerService.manualGenerateReport(body.month); + + return { + success: true, + message: `Financial report generated for ${body.month}`, + }; + } + + /** + * PUT /analytics/financial-reports/:month/confirm + * Confirm a financial report + */ + @Put('financial-reports/:month/confirm') + async confirmFinancialReport( + @Headers('authorization') auth: string, + @Param('month') month: string, + ): Promise { + const admin = await this.verifyAdmin(auth); + + this.logger.log(`Admin ${admin.username} confirming report for ${month}`); + + return this.financialReportService.confirmReport(month, admin.id); + } + + /** + * PUT /analytics/financial-reports/:month/lock + * Lock a financial report + */ + @Put('financial-reports/:month/lock') + async lockFinancialReport( + @Headers('authorization') auth: string, + @Param('month') month: string, + ): Promise { + const admin = await this.verifyAdmin(auth); + + this.logger.log(`Admin ${admin.username} locking report for ${month}`); + + return this.financialReportService.lockReport(month); + } + + /** + * PUT /analytics/financial-reports/:month/notes + * Update report notes + */ + @Put('financial-reports/:month/notes') + async updateReportNotes( + @Headers('authorization') auth: string, + @Param('month') month: string, + @Body() body: { notes: string }, + ): Promise { + await this.verifyAdmin(auth); + return this.financialReportService.updateNotes(month, body.notes); + } +} diff --git a/packages/services/evolution-service/src/analytics/adapters/inbound/audit.controller.ts b/packages/services/evolution-service/src/analytics/adapters/inbound/audit.controller.ts new file mode 100644 index 0000000..0d6c1ff --- /dev/null +++ b/packages/services/evolution-service/src/analytics/adapters/inbound/audit.controller.ts @@ -0,0 +1,185 @@ +import { + Controller, + Get, + Query, + Param, + Headers, + UnauthorizedException, + NotFoundException, +} from '@nestjs/common'; +import { + AuditLogService, + AuditLogDto, + PaginatedAuditLogs, + ActorType, + AuditResult, +} from '../../application/services/audit-log.service'; +import { AdminService } from '../../../application/services/admin.service'; + +@Controller('audit') +export class AuditController { + constructor( + private readonly auditLogService: AuditLogService, + private readonly adminService: AdminService, + ) {} + + /** + * Verify admin authorization + */ + private async verifyAdmin(authorization: string): Promise<{ id: string; username: string }> { + if (!authorization?.startsWith('Bearer ')) { + throw new UnauthorizedException('Missing authorization token'); + } + + const token = authorization.substring(7); + const result = await this.adminService.verifyToken(token); + + if (!result.valid || !result.admin) { + throw new UnauthorizedException('Invalid or expired token'); + } + + return { id: result.admin.id, username: result.admin.username }; + } + + /** + * GET /audit/logs + * Query audit logs with filters + */ + @Get('logs') + async getAuditLogs( + @Headers('authorization') auth: string, + @Query('actorType') actorType?: ActorType, + @Query('actorId') actorId?: string, + @Query('action') action?: string, + @Query('entityType') entityType?: string, + @Query('entityId') entityId?: string, + @Query('result') result?: AuditResult, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + @Query('page') page = '1', + @Query('pageSize') pageSize = '50', + ): Promise { + await this.verifyAdmin(auth); + + return this.auditLogService.queryLogs({ + actorType, + actorId, + action, + entityType, + entityId, + result, + startDate, + endDate, + page: parseInt(page, 10), + pageSize: parseInt(pageSize, 10), + }); + } + + /** + * GET /audit/logs/:id + * Get single audit log detail + */ + @Get('logs/:id') + async getAuditLogDetail( + @Headers('authorization') auth: string, + @Param('id') id: string, + ): Promise { + await this.verifyAdmin(auth); + + const log = await this.auditLogService.getLog(id); + + if (!log) { + throw new NotFoundException('Audit log not found'); + } + + return log; + } + + /** + * GET /audit/logs/entity/:entityType/:entityId + * Get audit history for a specific entity + */ + @Get('logs/entity/:entityType/:entityId') + async getEntityAuditHistory( + @Headers('authorization') auth: string, + @Param('entityType') entityType: string, + @Param('entityId') entityId: string, + ): Promise { + await this.verifyAdmin(auth); + return this.auditLogService.getEntityHistory(entityType, entityId); + } + + /** + * GET /audit/logs/actor/:actorId + * Get audit history for a specific actor + */ + @Get('logs/actor/:actorId') + async getActorAuditHistory( + @Headers('authorization') auth: string, + @Param('actorId') actorId: string, + @Query('page') page = '1', + @Query('pageSize') pageSize = '50', + ): Promise { + await this.verifyAdmin(auth); + + return this.auditLogService.getActorHistory( + actorId, + parseInt(page, 10), + parseInt(pageSize, 10), + ); + } + + /** + * GET /audit/logs/actions + * Get list of available action types + */ + @Get('logs/actions') + async getActionTypes( + @Headers('authorization') auth: string, + ): Promise<{ actions: string[] }> { + await this.verifyAdmin(auth); + + // Common action types + return { + actions: [ + 'CREATE', + 'UPDATE', + 'DELETE', + 'LOGIN', + 'LOGOUT', + 'PAYMENT', + 'REFUND', + 'EXPORT', + 'IMPORT', + 'DAILY_STATS_AGGREGATION', + 'MONTHLY_REPORT_GENERATION', + ], + }; + } + + /** + * GET /audit/logs/entity-types + * Get list of entity types + */ + @Get('logs/entity-types') + async getEntityTypes( + @Headers('authorization') auth: string, + ): Promise<{ entityTypes: string[] }> { + await this.verifyAdmin(auth); + + return { + entityTypes: [ + 'User', + 'Conversation', + 'Message', + 'Order', + 'Payment', + 'Admin', + 'KnowledgeArticle', + 'Experience', + 'DailyStatistics', + 'MonthlyFinancialReport', + ], + }; + } +} diff --git a/packages/services/evolution-service/src/analytics/analytics.module.ts b/packages/services/evolution-service/src/analytics/analytics.module.ts new file mode 100644 index 0000000..33bd3b5 --- /dev/null +++ b/packages/services/evolution-service/src/analytics/analytics.module.ts @@ -0,0 +1,48 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +// ORM Entities +import { DailyStatisticsORM } from './infrastructure/database/postgres/entities/daily-statistics.orm'; +import { MonthlyFinancialReportORM } from './infrastructure/database/postgres/entities/monthly-financial-report.orm'; +import { AuditLogORM } from './infrastructure/database/postgres/entities/audit-log.orm'; + +// Services +import { StatisticsAggregationService } from './application/services/statistics-aggregation.service'; +import { FinancialReportService } from './application/services/financial-report.service'; +import { AuditLogService } from './application/services/audit-log.service'; +import { AnalyticsSchedulerService } from './infrastructure/scheduling/analytics-scheduler.service'; + +// Controllers +import { AnalyticsController } from './adapters/inbound/analytics.controller'; +import { AuditController } from './adapters/inbound/audit.controller'; + +// External dependencies +import { AdminModule } from '../admin/admin.module'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + DailyStatisticsORM, + MonthlyFinancialReportORM, + AuditLogORM, + ]), + AdminModule, + ], + controllers: [ + AnalyticsController, + AuditController, + ], + providers: [ + StatisticsAggregationService, + FinancialReportService, + AuditLogService, + AnalyticsSchedulerService, + ], + exports: [ + StatisticsAggregationService, + FinancialReportService, + AuditLogService, + AnalyticsSchedulerService, + ], +}) +export class AnalyticsModule {} diff --git a/packages/services/evolution-service/src/analytics/application/services/audit-log.service.ts b/packages/services/evolution-service/src/analytics/application/services/audit-log.service.ts new file mode 100644 index 0000000..edee0ef --- /dev/null +++ b/packages/services/evolution-service/src/analytics/application/services/audit-log.service.ts @@ -0,0 +1,232 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuditLogORM } from '../../infrastructure/database/postgres/entities/audit-log.orm'; + +export type ActorType = 'USER' | 'ADMIN' | 'SYSTEM'; +export type AuditResult = 'SUCCESS' | 'FAILED'; + +export interface CreateAuditLogParams { + actorId?: string; + actorType: ActorType; + actorName?: string; + action: string; + entityType: string; + entityId?: string; + oldValues?: Record; + newValues?: Record; + changedFields?: string[]; + description?: string; + ipAddress?: string; + userAgent?: string; + requestId?: string; + result: AuditResult; + errorMessage?: string; +} + +export interface AuditLogDto { + id: string; + actorId: string | null; + actorType: string; + actorName: string | null; + action: string; + entityType: string; + entityId: string | null; + oldValues: Record | null; + newValues: Record | null; + changedFields: string[] | null; + description: string | null; + ipAddress: string | null; + userAgent: string | null; + requestId: string | null; + result: string; + errorMessage: string | null; + createdAt: Date; +} + +export interface AuditLogQueryParams { + actorType?: ActorType; + actorId?: string; + action?: string; + entityType?: string; + entityId?: string; + result?: AuditResult; + startDate?: string; + endDate?: string; + page?: number; + pageSize?: number; +} + +export interface PaginatedAuditLogs { + data: AuditLogDto[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +@Injectable() +export class AuditLogService { + private readonly logger = new Logger(AuditLogService.name); + + constructor( + @InjectRepository(AuditLogORM) + private readonly auditRepo: Repository, + ) {} + + /** + * Log an audit event + */ + async log(params: CreateAuditLogParams): Promise { + try { + const audit = this.auditRepo.create({ + actorId: params.actorId || null, + actorType: params.actorType, + actorName: params.actorName || null, + action: params.action, + entityType: params.entityType, + entityId: params.entityId || null, + oldValues: params.oldValues || null, + newValues: params.newValues || null, + changedFields: params.changedFields || null, + description: params.description || null, + ipAddress: params.ipAddress || null, + userAgent: params.userAgent || null, + requestId: params.requestId || null, + result: params.result, + errorMessage: params.errorMessage || null, + }); + + await this.auditRepo.save(audit); + } catch (error) { + // Don't throw - audit logging should not break the main flow + this.logger.error('Failed to write audit log:', error); + } + } + + /** + * Query audit logs with filters + */ + async queryLogs(params: AuditLogQueryParams): Promise { + const page = params.page || 1; + const pageSize = params.pageSize || 50; + const skip = (page - 1) * pageSize; + + const query = this.auditRepo.createQueryBuilder('log'); + + if (params.actorType) { + query.andWhere('log.actorType = :actorType', { actorType: params.actorType }); + } + + if (params.actorId) { + query.andWhere('log.actorId = :actorId', { actorId: params.actorId }); + } + + if (params.action) { + query.andWhere('log.action = :action', { action: params.action }); + } + + if (params.entityType) { + query.andWhere('log.entityType = :entityType', { entityType: params.entityType }); + } + + if (params.entityId) { + query.andWhere('log.entityId = :entityId', { entityId: params.entityId }); + } + + if (params.result) { + query.andWhere('log.result = :result', { result: params.result }); + } + + if (params.startDate) { + query.andWhere('log.createdAt >= :startDate', { startDate: params.startDate }); + } + + if (params.endDate) { + query.andWhere('log.createdAt <= :endDate', { endDate: params.endDate }); + } + + const [logs, total] = await query + .orderBy('log.createdAt', 'DESC') + .skip(skip) + .take(pageSize) + .getManyAndCount(); + + return { + data: logs.map(this.toDto), + total, + page, + pageSize, + totalPages: Math.ceil(total / pageSize), + }; + } + + /** + * Get a specific audit log + */ + async getLog(id: string): Promise { + const log = await this.auditRepo.findOne({ where: { id } }); + return log ? this.toDto(log) : null; + } + + /** + * Get audit history for an entity + */ + async getEntityHistory(entityType: string, entityId: string): Promise { + const logs = await this.auditRepo.find({ + where: { entityType, entityId }, + order: { createdAt: 'DESC' }, + }); + + return logs.map(this.toDto); + } + + /** + * Get audit history for an actor + */ + async getActorHistory(actorId: string, page = 1, pageSize = 50): Promise { + return this.queryLogs({ actorId, page, pageSize }); + } + + /** + * Log a system event + */ + async logSystem( + action: string, + entityType: string, + entityId?: string, + description?: string, + ): Promise { + await this.log({ + actorType: 'SYSTEM', + actorName: 'System', + action, + entityType, + entityId, + description, + result: 'SUCCESS', + }); + } + + private toDto(orm: AuditLogORM): AuditLogDto { + return { + id: orm.id, + actorId: orm.actorId, + actorType: orm.actorType, + actorName: orm.actorName, + action: orm.action, + entityType: orm.entityType, + entityId: orm.entityId, + oldValues: orm.oldValues, + newValues: orm.newValues, + changedFields: orm.changedFields, + description: orm.description, + ipAddress: orm.ipAddress, + userAgent: orm.userAgent, + requestId: orm.requestId, + result: orm.result, + errorMessage: orm.errorMessage, + createdAt: orm.createdAt, + }; + } +} diff --git a/packages/services/evolution-service/src/analytics/application/services/financial-report.service.ts b/packages/services/evolution-service/src/analytics/application/services/financial-report.service.ts new file mode 100644 index 0000000..2af087b --- /dev/null +++ b/packages/services/evolution-service/src/analytics/application/services/financial-report.service.ts @@ -0,0 +1,348 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { MonthlyFinancialReportORM } from '../../infrastructure/database/postgres/entities/monthly-financial-report.orm'; + +export interface FinancialReportDto { + id: string; + reportMonth: string; + totalRevenue: number; + assessmentRevenue: number; + consultationRevenue: number; + otherRevenue: number; + totalRefunds: number; + netRevenue: number; + apiCost: number; + paymentFees: number; + otherCosts: number; + totalCosts: number; + grossProfit: number; + grossMargin: number; + totalOrders: number; + successfulOrders: number; + avgOrderAmount: number; + revenueByCategory: Record; + revenueByChannel: Record; + status: string; + confirmedBy: string | null; + confirmedAt: Date | null; + notes: string | null; + createdAt: Date; + updatedAt: Date; +} + +@Injectable() +export class FinancialReportService { + private readonly logger = new Logger(FinancialReportService.name); + + constructor( + @InjectRepository(MonthlyFinancialReportORM) + private readonly reportRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + /** + * Generate monthly financial report + */ + async generateMonthlyReport(reportMonth: string): Promise { + this.logger.log(`Generating financial report for: ${reportMonth}`); + + try { + await this.dataSource.query( + ` + INSERT INTO monthly_financial_reports ( + report_month, + total_revenue, assessment_revenue, consultation_revenue, other_revenue, + total_refunds, net_revenue, + api_cost, payment_fees, other_costs, total_costs, + gross_profit, gross_margin, + total_orders, successful_orders, avg_order_amount, + revenue_by_category, revenue_by_channel, + status + ) + SELECT + $1 as report_month, + + -- Revenue from orders + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) as total_revenue, + + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' AND service_type = 'ASSESSMENT' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) as assessment_revenue, + + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' AND service_type = 'CONSULTATION' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) as consultation_revenue, + + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' AND service_type NOT IN ('ASSESSMENT', 'CONSULTATION') + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) as other_revenue, + + -- Refunds + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'REFUNDED' + AND to_char(refunded_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) as total_refunds, + + -- Net revenue + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) - + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'REFUNDED' + AND to_char(refunded_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) as net_revenue, + + -- Costs (from daily_statistics) + COALESCE((SELECT SUM(estimated_api_cost) FROM daily_statistics + WHERE dimension = 'OVERALL' + AND to_char(stat_date, 'YYYY-MM') = $1), 0) as api_cost, + + -- Payment fees (0.6% of revenue) + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) * 0.006 as payment_fees, + + 0 as other_costs, + + -- Total costs + COALESCE((SELECT SUM(estimated_api_cost) FROM daily_statistics + WHERE dimension = 'OVERALL' + AND to_char(stat_date, 'YYYY-MM') = $1), 0) + + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) * 0.006 as total_costs, + + -- Gross profit + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) - + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'REFUNDED' + AND to_char(refunded_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) - + COALESCE((SELECT SUM(estimated_api_cost) FROM daily_statistics + WHERE dimension = 'OVERALL' + AND to_char(stat_date, 'YYYY-MM') = $1), 0) - + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) * 0.006 as gross_profit, + + -- Gross margin + CASE + WHEN COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) > 0 + THEN ( + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) - + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'REFUNDED' + AND to_char(refunded_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) - + COALESCE((SELECT SUM(estimated_api_cost) FROM daily_statistics + WHERE dimension = 'OVERALL' + AND to_char(stat_date, 'YYYY-MM') = $1), 0) - + COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) * 0.006 + ) / COALESCE((SELECT SUM(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 1) + ELSE 0 + END as gross_margin, + + -- Order statistics + (SELECT COUNT(*) FROM orders + WHERE to_char(created_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1) as total_orders, + + (SELECT COUNT(*) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1) as successful_orders, + + COALESCE((SELECT AVG(paid_amount) FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1), 0) as avg_order_amount, + + -- Revenue by category + COALESCE((SELECT jsonb_object_agg(service_category, amount) + FROM (SELECT service_category, SUM(paid_amount) as amount + FROM orders + WHERE status = 'PAID' + AND to_char(paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1 + AND service_category IS NOT NULL + GROUP BY service_category) sub), '{}') as revenue_by_category, + + -- Revenue by channel (via user source_channel) + COALESCE((SELECT jsonb_object_agg(source_channel, amount) + FROM (SELECT u.source_channel, SUM(o.paid_amount) as amount + FROM orders o + JOIN users u ON o.user_id = u.id + WHERE o.status = 'PAID' + AND to_char(o.paid_at AT TIME ZONE 'Asia/Shanghai', 'YYYY-MM') = $1 + AND u.source_channel IS NOT NULL + GROUP BY u.source_channel) sub), '{}') as revenue_by_channel, + + 'DRAFT' as status + + ON CONFLICT (report_month) DO UPDATE SET + total_revenue = EXCLUDED.total_revenue, + assessment_revenue = EXCLUDED.assessment_revenue, + consultation_revenue = EXCLUDED.consultation_revenue, + other_revenue = EXCLUDED.other_revenue, + total_refunds = EXCLUDED.total_refunds, + net_revenue = EXCLUDED.net_revenue, + api_cost = EXCLUDED.api_cost, + payment_fees = EXCLUDED.payment_fees, + total_costs = EXCLUDED.total_costs, + gross_profit = EXCLUDED.gross_profit, + gross_margin = EXCLUDED.gross_margin, + total_orders = EXCLUDED.total_orders, + successful_orders = EXCLUDED.successful_orders, + avg_order_amount = EXCLUDED.avg_order_amount, + revenue_by_category = EXCLUDED.revenue_by_category, + revenue_by_channel = EXCLUDED.revenue_by_channel, + updated_at = NOW() + WHERE monthly_financial_reports.status = 'DRAFT' + `, + [reportMonth] + ); + + this.logger.log(`Financial report generated for: ${reportMonth}`); + } catch (error) { + this.logger.error(`Failed to generate financial report for ${reportMonth}:`, error); + throw error; + } + } + + /** + * List financial reports + */ + async listReports(params: { + year?: number; + status?: string; + }): Promise { + const query = this.reportRepo.createQueryBuilder('report'); + + if (params.year) { + query.andWhere("report.reportMonth LIKE :year", { year: `${params.year}%` }); + } + + if (params.status) { + query.andWhere('report.status = :status', { status: params.status }); + } + + const reports = await query.orderBy('report.reportMonth', 'DESC').getMany(); + return reports.map(this.toDto); + } + + /** + * Get a specific report + */ + async getReport(reportMonth: string): Promise { + const report = await this.reportRepo.findOne({ + where: { reportMonth }, + }); + + if (!report) { + throw new NotFoundException(`Financial report not found for: ${reportMonth}`); + } + + return this.toDto(report); + } + + /** + * Confirm a report + */ + async confirmReport(reportMonth: string, adminId: string): Promise { + const report = await this.reportRepo.findOne({ + where: { reportMonth }, + }); + + if (!report) { + throw new NotFoundException(`Financial report not found for: ${reportMonth}`); + } + + if (report.status !== 'DRAFT') { + throw new BadRequestException(`Report is already ${report.status}`); + } + + report.status = 'CONFIRMED'; + report.confirmedBy = adminId; + report.confirmedAt = new Date(); + + const saved = await this.reportRepo.save(report); + return this.toDto(saved); + } + + /** + * Lock a report + */ + async lockReport(reportMonth: string): Promise { + const report = await this.reportRepo.findOne({ + where: { reportMonth }, + }); + + if (!report) { + throw new NotFoundException(`Financial report not found for: ${reportMonth}`); + } + + if (report.status !== 'CONFIRMED') { + throw new BadRequestException('Only confirmed reports can be locked'); + } + + report.status = 'LOCKED'; + const saved = await this.reportRepo.save(report); + return this.toDto(saved); + } + + /** + * Update report notes + */ + async updateNotes(reportMonth: string, notes: string): Promise { + const report = await this.reportRepo.findOne({ + where: { reportMonth }, + }); + + if (!report) { + throw new NotFoundException(`Financial report not found for: ${reportMonth}`); + } + + if (report.status === 'LOCKED') { + throw new BadRequestException('Cannot update locked report'); + } + + report.notes = notes; + const saved = await this.reportRepo.save(report); + return this.toDto(saved); + } + + private toDto(orm: MonthlyFinancialReportORM): FinancialReportDto { + return { + id: orm.id, + reportMonth: orm.reportMonth, + totalRevenue: Number(orm.totalRevenue), + assessmentRevenue: Number(orm.assessmentRevenue), + consultationRevenue: Number(orm.consultationRevenue), + otherRevenue: Number(orm.otherRevenue), + totalRefunds: Number(orm.totalRefunds), + netRevenue: Number(orm.netRevenue), + apiCost: Number(orm.apiCost), + paymentFees: Number(orm.paymentFees), + otherCosts: Number(orm.otherCosts), + totalCosts: Number(orm.totalCosts), + grossProfit: Number(orm.grossProfit), + grossMargin: Number(orm.grossMargin), + totalOrders: Number(orm.totalOrders), + successfulOrders: Number(orm.successfulOrders), + avgOrderAmount: Number(orm.avgOrderAmount), + revenueByCategory: orm.revenueByCategory || {}, + revenueByChannel: orm.revenueByChannel || {}, + status: orm.status, + confirmedBy: orm.confirmedBy, + confirmedAt: orm.confirmedAt, + notes: orm.notes, + createdAt: orm.createdAt, + updatedAt: orm.updatedAt, + }; + } +} diff --git a/packages/services/evolution-service/src/analytics/application/services/statistics-aggregation.service.ts b/packages/services/evolution-service/src/analytics/application/services/statistics-aggregation.service.ts new file mode 100644 index 0000000..868524e --- /dev/null +++ b/packages/services/evolution-service/src/analytics/application/services/statistics-aggregation.service.ts @@ -0,0 +1,401 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, DataSource } from 'typeorm'; +import { DailyStatisticsORM } from '../../infrastructure/database/postgres/entities/daily-statistics.orm'; + +export interface DailyStatisticsDto { + statDate: string; + dimension: string; + dimensionValue: string | null; + newUsers: number; + newRegisteredUsers: number; + activeUsers: number; + newConversations: number; + totalMessages: number; + userMessages: number; + assistantMessages: number; + avgConversationTurns: number; + newOrders: number; + paidOrders: number; + totalOrderAmount: number; + totalPaidAmount: number; + refundedOrders: number; + refundAmount: number; + conversionCount: number; + conversionRate: number; + totalInputTokens: number; + totalOutputTokens: number; + estimatedApiCost: number; +} + +export interface TrendDataPoint { + date: string; + value: number; +} + +@Injectable() +export class StatisticsAggregationService { + private readonly logger = new Logger(StatisticsAggregationService.name); + + constructor( + @InjectRepository(DailyStatisticsORM) + private readonly statsRepo: Repository, + private readonly dataSource: DataSource, + ) {} + + /** + * Aggregate daily statistics for a specific date + */ + async aggregateDailyStats(date: Date): Promise { + const statDate = date.toISOString().split('T')[0]; + this.logger.log(`Aggregating statistics for date: ${statDate}`); + + try { + // Aggregate OVERALL statistics + await this.aggregateOverallStats(statDate); + + // Aggregate by CHANNEL + await this.aggregateByChannel(statDate); + + // Aggregate by CATEGORY + await this.aggregateByCategory(statDate); + + this.logger.log(`Statistics aggregation completed for: ${statDate}`); + } catch (error) { + this.logger.error(`Statistics aggregation failed for ${statDate}:`, error); + throw error; + } + } + + /** + * Refresh today's statistics (for real-time dashboard) + */ + async refreshTodayStats(): Promise { + const today = new Date(); + await this.aggregateDailyStats(today); + } + + /** + * Get daily statistics with filters + */ + async getDailyStatistics(params: { + startDate: string; + endDate: string; + dimension?: string; + dimensionValue?: string; + }): Promise { + const query = this.statsRepo.createQueryBuilder('stats') + .where('stats.statDate >= :startDate', { startDate: params.startDate }) + .andWhere('stats.statDate <= :endDate', { endDate: params.endDate }); + + if (params.dimension) { + query.andWhere('stats.dimension = :dimension', { dimension: params.dimension }); + } + + if (params.dimensionValue) { + query.andWhere('stats.dimensionValue = :dimensionValue', { dimensionValue: params.dimensionValue }); + } + + const results = await query.orderBy('stats.statDate', 'DESC').getMany(); + + return results.map(this.toDto); + } + + /** + * Get today's real-time statistics + */ + async getTodayStatistics(): Promise { + const today = new Date().toISOString().split('T')[0]; + const stats = await this.statsRepo.findOne({ + where: { statDate: new Date(today), dimension: 'OVERALL' }, + }); + + return stats ? this.toDto(stats) : null; + } + + /** + * Get trend data for charts + */ + async getTrendData(params: { + days: number; + metrics: string[]; + }): Promise> { + const startDate = new Date(); + startDate.setDate(startDate.getDate() - params.days); + + const stats = await this.statsRepo.find({ + where: { dimension: 'OVERALL' }, + order: { statDate: 'ASC' }, + }); + + const filtered = stats.filter(s => s.statDate >= startDate); + + const result: Record = {}; + for (const metric of params.metrics) { + result[metric] = filtered.map(s => ({ + date: s.statDate.toISOString().split('T')[0], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + value: Number((s as any)[metric] || 0), + })); + } + + return result; + } + + /** + * Get statistics by channel + */ + async getStatisticsByChannel(startDate: string, endDate: string): Promise { + return this.getDailyStatistics({ + startDate, + endDate, + dimension: 'CHANNEL', + }); + } + + /** + * Get statistics by category + */ + async getStatisticsByCategory(startDate: string, endDate: string): Promise { + return this.getDailyStatistics({ + startDate, + endDate, + dimension: 'CATEGORY', + }); + } + + private async aggregateOverallStats(statDate: string): Promise { + await this.dataSource.query( + ` + INSERT INTO daily_statistics ( + stat_date, dimension, dimension_value, + new_users, new_registered_users, active_users, + new_conversations, total_messages, user_messages, assistant_messages, avg_conversation_turns, + new_orders, paid_orders, total_order_amount, total_paid_amount, refunded_orders, refund_amount, + conversion_count, conversion_rate, + total_input_tokens, total_output_tokens, estimated_api_cost + ) + SELECT + $1::date as stat_date, + 'OVERALL' as dimension, + NULL as dimension_value, + + -- User statistics + (SELECT COUNT(*) FROM users WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as new_users, + (SELECT COUNT(*) FROM users WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day' AND type = 'REGISTERED') as new_registered_users, + (SELECT COUNT(*) FROM users WHERE last_active_at >= $1::date AND last_active_at < $1::date + interval '1 day') as active_users, + + -- Conversation statistics + (SELECT COUNT(*) FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as new_conversations, + (SELECT COUNT(*) FROM messages WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as total_messages, + (SELECT COUNT(*) FROM messages WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day' AND role = 'user') as user_messages, + (SELECT COUNT(*) FROM messages WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day' AND role = 'assistant') as assistant_messages, + (SELECT COALESCE(AVG(message_count / 2.0), 0) FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day' AND message_count > 0) as avg_conversation_turns, + + -- Order statistics + (SELECT COUNT(*) FROM orders WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as new_orders, + (SELECT COUNT(*) FROM orders WHERE paid_at >= $1::date AND paid_at < $1::date + interval '1 day') as paid_orders, + (SELECT COALESCE(SUM(amount), 0) FROM orders WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as total_order_amount, + (SELECT COALESCE(SUM(paid_amount), 0) FROM orders WHERE paid_at >= $1::date AND paid_at < $1::date + interval '1 day') as total_paid_amount, + (SELECT COUNT(*) FROM orders WHERE refunded_at >= $1::date AND refunded_at < $1::date + interval '1 day') as refunded_orders, + (SELECT COALESCE(SUM(paid_amount), 0) FROM orders WHERE refunded_at >= $1::date AND refunded_at < $1::date + interval '1 day') as refund_amount, + + -- Conversion statistics + (SELECT COUNT(*) FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day' AND has_converted = true) as conversion_count, + CASE + WHEN (SELECT COUNT(*) FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') > 0 + THEN (SELECT COUNT(*)::decimal FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day' AND has_converted = true) / + (SELECT COUNT(*)::decimal FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') + ELSE 0 + END as conversion_rate, + + -- Token statistics + (SELECT COALESCE(SUM(total_input_tokens), 0) FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as total_input_tokens, + (SELECT COALESCE(SUM(total_output_tokens), 0) FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as total_output_tokens, + -- Estimated cost: $3/1M input, $15/1M output (Claude Sonnet 4) + (SELECT COALESCE(SUM(total_input_tokens), 0) * 3.0 / 1000000 + COALESCE(SUM(total_output_tokens), 0) * 15.0 / 1000000 + FROM conversations WHERE created_at >= $1::date AND created_at < $1::date + interval '1 day') as estimated_api_cost + + ON CONFLICT (stat_date, dimension, dimension_value) + DO UPDATE SET + new_users = EXCLUDED.new_users, + new_registered_users = EXCLUDED.new_registered_users, + active_users = EXCLUDED.active_users, + new_conversations = EXCLUDED.new_conversations, + total_messages = EXCLUDED.total_messages, + user_messages = EXCLUDED.user_messages, + assistant_messages = EXCLUDED.assistant_messages, + avg_conversation_turns = EXCLUDED.avg_conversation_turns, + new_orders = EXCLUDED.new_orders, + paid_orders = EXCLUDED.paid_orders, + total_order_amount = EXCLUDED.total_order_amount, + total_paid_amount = EXCLUDED.total_paid_amount, + refunded_orders = EXCLUDED.refunded_orders, + refund_amount = EXCLUDED.refund_amount, + conversion_count = EXCLUDED.conversion_count, + conversion_rate = EXCLUDED.conversion_rate, + total_input_tokens = EXCLUDED.total_input_tokens, + total_output_tokens = EXCLUDED.total_output_tokens, + estimated_api_cost = EXCLUDED.estimated_api_cost, + updated_at = NOW() + `, + [statDate] + ); + } + + private async aggregateByChannel(statDate: string): Promise { + await this.dataSource.query( + ` + INSERT INTO daily_statistics ( + stat_date, dimension, dimension_value, + new_users, new_registered_users, active_users, + new_conversations, total_messages, user_messages, assistant_messages, avg_conversation_turns, + new_orders, paid_orders, total_order_amount, total_paid_amount, refunded_orders, refund_amount, + conversion_count, conversion_rate, + total_input_tokens, total_output_tokens, estimated_api_cost + ) + SELECT + $1::date as stat_date, + 'CHANNEL' as dimension, + u.source_channel as dimension_value, + + COUNT(DISTINCT CASE WHEN u.created_at >= $1::date AND u.created_at < $1::date + interval '1 day' THEN u.id END) as new_users, + COUNT(DISTINCT CASE WHEN u.created_at >= $1::date AND u.created_at < $1::date + interval '1 day' AND u.type = 'REGISTERED' THEN u.id END) as new_registered_users, + COUNT(DISTINCT CASE WHEN u.last_active_at >= $1::date AND u.last_active_at < $1::date + interval '1 day' THEN u.id END) as active_users, + + COUNT(DISTINCT CASE WHEN c.created_at >= $1::date AND c.created_at < $1::date + interval '1 day' THEN c.id END) as new_conversations, + 0 as total_messages, + 0 as user_messages, + 0 as assistant_messages, + 0 as avg_conversation_turns, + + COUNT(DISTINCT CASE WHEN o.created_at >= $1::date AND o.created_at < $1::date + interval '1 day' THEN o.id END) as new_orders, + COUNT(DISTINCT CASE WHEN o.paid_at >= $1::date AND o.paid_at < $1::date + interval '1 day' THEN o.id END) as paid_orders, + COALESCE(SUM(CASE WHEN o.created_at >= $1::date AND o.created_at < $1::date + interval '1 day' THEN o.amount ELSE 0 END), 0) as total_order_amount, + COALESCE(SUM(CASE WHEN o.paid_at >= $1::date AND o.paid_at < $1::date + interval '1 day' THEN o.paid_amount ELSE 0 END), 0) as total_paid_amount, + 0 as refunded_orders, + 0 as refund_amount, + + COUNT(DISTINCT CASE WHEN c.has_converted = true AND c.created_at >= $1::date AND c.created_at < $1::date + interval '1 day' THEN c.id END) as conversion_count, + 0 as conversion_rate, + + COALESCE(SUM(c.total_input_tokens), 0) as total_input_tokens, + COALESCE(SUM(c.total_output_tokens), 0) as total_output_tokens, + COALESCE(SUM(c.total_input_tokens), 0) * 3.0 / 1000000 + COALESCE(SUM(c.total_output_tokens), 0) * 15.0 / 1000000 as estimated_api_cost + + FROM users u + LEFT JOIN conversations c ON u.id = c.user_id + LEFT JOIN orders o ON u.id = o.user_id + WHERE u.source_channel IS NOT NULL + GROUP BY u.source_channel + ON CONFLICT (stat_date, dimension, dimension_value) + DO UPDATE SET + new_users = EXCLUDED.new_users, + new_registered_users = EXCLUDED.new_registered_users, + active_users = EXCLUDED.active_users, + new_conversations = EXCLUDED.new_conversations, + new_orders = EXCLUDED.new_orders, + paid_orders = EXCLUDED.paid_orders, + total_order_amount = EXCLUDED.total_order_amount, + total_paid_amount = EXCLUDED.total_paid_amount, + conversion_count = EXCLUDED.conversion_count, + total_input_tokens = EXCLUDED.total_input_tokens, + total_output_tokens = EXCLUDED.total_output_tokens, + estimated_api_cost = EXCLUDED.estimated_api_cost, + updated_at = NOW() + `, + [statDate] + ); + } + + private async aggregateByCategory(statDate: string): Promise { + await this.dataSource.query( + ` + INSERT INTO daily_statistics ( + stat_date, dimension, dimension_value, + new_users, new_registered_users, active_users, + new_conversations, total_messages, user_messages, assistant_messages, avg_conversation_turns, + new_orders, paid_orders, total_order_amount, total_paid_amount, refunded_orders, refund_amount, + conversion_count, conversion_rate, + total_input_tokens, total_output_tokens, estimated_api_cost + ) + SELECT + $1::date as stat_date, + 'CATEGORY' as dimension, + c.category as dimension_value, + + 0 as new_users, + 0 as new_registered_users, + COUNT(DISTINCT c.user_id) as active_users, + + COUNT(DISTINCT CASE WHEN c.created_at >= $1::date AND c.created_at < $1::date + interval '1 day' THEN c.id END) as new_conversations, + 0 as total_messages, + 0 as user_messages, + 0 as assistant_messages, + COALESCE(AVG(c.message_count / 2.0), 0) as avg_conversation_turns, + + COUNT(DISTINCT CASE WHEN o.created_at >= $1::date AND o.created_at < $1::date + interval '1 day' THEN o.id END) as new_orders, + COUNT(DISTINCT CASE WHEN o.paid_at >= $1::date AND o.paid_at < $1::date + interval '1 day' THEN o.id END) as paid_orders, + COALESCE(SUM(CASE WHEN o.created_at >= $1::date AND o.created_at < $1::date + interval '1 day' THEN o.amount ELSE 0 END), 0) as total_order_amount, + COALESCE(SUM(CASE WHEN o.paid_at >= $1::date AND o.paid_at < $1::date + interval '1 day' THEN o.paid_amount ELSE 0 END), 0) as total_paid_amount, + 0 as refunded_orders, + 0 as refund_amount, + + COUNT(DISTINCT CASE WHEN c.has_converted = true THEN c.id END) as conversion_count, + 0 as conversion_rate, + + COALESCE(SUM(c.total_input_tokens), 0) as total_input_tokens, + COALESCE(SUM(c.total_output_tokens), 0) as total_output_tokens, + COALESCE(SUM(c.total_input_tokens), 0) * 3.0 / 1000000 + COALESCE(SUM(c.total_output_tokens), 0) * 15.0 / 1000000 as estimated_api_cost + + FROM conversations c + LEFT JOIN orders o ON c.id = o.conversation_id + WHERE c.category IS NOT NULL + AND c.created_at >= $1::date AND c.created_at < $1::date + interval '1 day' + GROUP BY c.category + ON CONFLICT (stat_date, dimension, dimension_value) + DO UPDATE SET + active_users = EXCLUDED.active_users, + new_conversations = EXCLUDED.new_conversations, + avg_conversation_turns = EXCLUDED.avg_conversation_turns, + new_orders = EXCLUDED.new_orders, + paid_orders = EXCLUDED.paid_orders, + total_order_amount = EXCLUDED.total_order_amount, + total_paid_amount = EXCLUDED.total_paid_amount, + conversion_count = EXCLUDED.conversion_count, + total_input_tokens = EXCLUDED.total_input_tokens, + total_output_tokens = EXCLUDED.total_output_tokens, + estimated_api_cost = EXCLUDED.estimated_api_cost, + updated_at = NOW() + `, + [statDate] + ); + } + + private toDto(orm: DailyStatisticsORM): DailyStatisticsDto { + return { + statDate: orm.statDate.toISOString().split('T')[0], + dimension: orm.dimension, + dimensionValue: orm.dimensionValue, + newUsers: Number(orm.newUsers), + newRegisteredUsers: Number(orm.newRegisteredUsers), + activeUsers: Number(orm.activeUsers), + newConversations: Number(orm.newConversations), + totalMessages: Number(orm.totalMessages), + userMessages: Number(orm.userMessages), + assistantMessages: Number(orm.assistantMessages), + avgConversationTurns: Number(orm.avgConversationTurns), + newOrders: Number(orm.newOrders), + paidOrders: Number(orm.paidOrders), + totalOrderAmount: Number(orm.totalOrderAmount), + totalPaidAmount: Number(orm.totalPaidAmount), + refundedOrders: Number(orm.refundedOrders), + refundAmount: Number(orm.refundAmount), + conversionCount: Number(orm.conversionCount), + conversionRate: Number(orm.conversionRate), + totalInputTokens: Number(orm.totalInputTokens), + totalOutputTokens: Number(orm.totalOutputTokens), + estimatedApiCost: Number(orm.estimatedApiCost), + }; + } +} diff --git a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/audit-log.orm.ts b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/audit-log.orm.ts new file mode 100644 index 0000000..f0e486f --- /dev/null +++ b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/audit-log.orm.ts @@ -0,0 +1,67 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + Index, +} from 'typeorm'; + +@Entity('audit_logs') +@Index('idx_audit_logs_actor_id', ['actorId']) +@Index('idx_audit_logs_actor_type', ['actorType']) +@Index('idx_audit_logs_action', ['action']) +@Index('idx_audit_logs_entity_type', ['entityType']) +@Index('idx_audit_logs_entity_id', ['entityId']) +@Index('idx_audit_logs_created_at', ['createdAt']) +export class AuditLogORM { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'actor_id', type: 'uuid', nullable: true }) + actorId: string | null; + + @Column({ name: 'actor_type', type: 'varchar', length: 20 }) + actorType: string; + + @Column({ name: 'actor_name', type: 'varchar', length: 100, nullable: true }) + actorName: string | null; + + @Column({ type: 'varchar', length: 50 }) + action: string; + + @Column({ name: 'entity_type', type: 'varchar', length: 50 }) + entityType: string; + + @Column({ name: 'entity_id', type: 'uuid', nullable: true }) + entityId: string | null; + + @Column({ name: 'old_values', type: 'jsonb', nullable: true }) + oldValues: Record | null; + + @Column({ name: 'new_values', type: 'jsonb', nullable: true }) + newValues: Record | null; + + @Column({ name: 'changed_fields', type: 'text', array: true, nullable: true }) + changedFields: string[] | null; + + @Column({ type: 'text', nullable: true }) + description: string | null; + + @Column({ name: 'ip_address', type: 'varchar', length: 45, nullable: true }) + ipAddress: string | null; + + @Column({ name: 'user_agent', type: 'text', nullable: true }) + userAgent: string | null; + + @Column({ name: 'request_id', type: 'varchar', length: 100, nullable: true }) + requestId: string | null; + + @Column({ type: 'varchar', length: 20, default: 'SUCCESS' }) + result: string; + + @Column({ name: 'error_message', type: 'text', nullable: true }) + errorMessage: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; +} diff --git a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/daily-statistics.orm.ts b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/daily-statistics.orm.ts new file mode 100644 index 0000000..bea2147 --- /dev/null +++ b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/daily-statistics.orm.ts @@ -0,0 +1,95 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, + Unique, +} from 'typeorm'; + +@Entity('daily_statistics') +@Unique(['statDate', 'dimension', 'dimensionValue']) +@Index('idx_daily_statistics_stat_date', ['statDate']) +@Index('idx_daily_statistics_dimension', ['dimension', 'dimensionValue']) +export class DailyStatisticsORM { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'stat_date', type: 'date' }) + statDate: Date; + + @Column({ type: 'varchar', length: 30, default: 'OVERALL' }) + dimension: string; + + @Column({ name: 'dimension_value', type: 'varchar', length: 50, nullable: true }) + dimensionValue: string | null; + + // User Statistics + @Column({ name: 'new_users', type: 'int', default: 0 }) + newUsers: number; + + @Column({ name: 'new_registered_users', type: 'int', default: 0 }) + newRegisteredUsers: number; + + @Column({ name: 'active_users', type: 'int', default: 0 }) + activeUsers: number; + + // Conversation Statistics + @Column({ name: 'new_conversations', type: 'int', default: 0 }) + newConversations: number; + + @Column({ name: 'total_messages', type: 'int', default: 0 }) + totalMessages: number; + + @Column({ name: 'user_messages', type: 'int', default: 0 }) + userMessages: number; + + @Column({ name: 'assistant_messages', type: 'int', default: 0 }) + assistantMessages: number; + + @Column({ name: 'avg_conversation_turns', type: 'decimal', precision: 10, scale: 2, default: 0 }) + avgConversationTurns: number; + + // Order Statistics + @Column({ name: 'new_orders', type: 'int', default: 0 }) + newOrders: number; + + @Column({ name: 'paid_orders', type: 'int', default: 0 }) + paidOrders: number; + + @Column({ name: 'total_order_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalOrderAmount: number; + + @Column({ name: 'total_paid_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalPaidAmount: number; + + @Column({ name: 'refunded_orders', type: 'int', default: 0 }) + refundedOrders: number; + + @Column({ name: 'refund_amount', type: 'decimal', precision: 12, scale: 2, default: 0 }) + refundAmount: number; + + // Conversion Statistics + @Column({ name: 'conversion_count', type: 'int', default: 0 }) + conversionCount: number; + + @Column({ name: 'conversion_rate', type: 'decimal', precision: 5, scale: 4, default: 0 }) + conversionRate: number; + + // Token Statistics + @Column({ name: 'total_input_tokens', type: 'bigint', default: 0 }) + totalInputTokens: number; + + @Column({ name: 'total_output_tokens', type: 'bigint', default: 0 }) + totalOutputTokens: number; + + @Column({ name: 'estimated_api_cost', type: 'decimal', precision: 10, scale: 4, default: 0 }) + estimatedApiCost: number; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/index.ts b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/index.ts new file mode 100644 index 0000000..2341ed3 --- /dev/null +++ b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/index.ts @@ -0,0 +1,3 @@ +export { DailyStatisticsORM } from './daily-statistics.orm'; +export { MonthlyFinancialReportORM } from './monthly-financial-report.orm'; +export { AuditLogORM } from './audit-log.orm'; diff --git a/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/monthly-financial-report.orm.ts b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/monthly-financial-report.orm.ts new file mode 100644 index 0000000..13b2e57 --- /dev/null +++ b/packages/services/evolution-service/src/analytics/infrastructure/database/postgres/entities/monthly-financial-report.orm.ts @@ -0,0 +1,95 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +@Entity('monthly_financial_reports') +@Index('idx_monthly_financial_reports_month', ['reportMonth']) +@Index('idx_monthly_financial_reports_status', ['status']) +export class MonthlyFinancialReportORM { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'report_month', type: 'varchar', length: 7, unique: true }) + reportMonth: string; + + // Revenue Statistics + @Column({ name: 'total_revenue', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalRevenue: number; + + @Column({ name: 'assessment_revenue', type: 'decimal', precision: 12, scale: 2, default: 0 }) + assessmentRevenue: number; + + @Column({ name: 'consultation_revenue', type: 'decimal', precision: 12, scale: 2, default: 0 }) + consultationRevenue: number; + + @Column({ name: 'other_revenue', type: 'decimal', precision: 12, scale: 2, default: 0 }) + otherRevenue: number; + + // Refund Statistics + @Column({ name: 'total_refunds', type: 'decimal', precision: 12, scale: 2, default: 0 }) + totalRefunds: number; + + @Column({ name: 'net_revenue', type: 'decimal', precision: 12, scale: 2, default: 0 }) + netRevenue: number; + + // Cost Statistics + @Column({ name: 'api_cost', type: 'decimal', precision: 10, scale: 2, default: 0 }) + apiCost: number; + + @Column({ name: 'payment_fees', type: 'decimal', precision: 10, scale: 2, default: 0 }) + paymentFees: number; + + @Column({ name: 'other_costs', type: 'decimal', precision: 10, scale: 2, default: 0 }) + otherCosts: number; + + @Column({ name: 'total_costs', type: 'decimal', precision: 10, scale: 2, default: 0 }) + totalCosts: number; + + // Profit Statistics + @Column({ name: 'gross_profit', type: 'decimal', precision: 12, scale: 2, default: 0 }) + grossProfit: number; + + @Column({ name: 'gross_margin', type: 'decimal', precision: 5, scale: 4, default: 0 }) + grossMargin: number; + + // Order Statistics + @Column({ name: 'total_orders', type: 'int', default: 0 }) + totalOrders: number; + + @Column({ name: 'successful_orders', type: 'int', default: 0 }) + successfulOrders: number; + + @Column({ name: 'avg_order_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) + avgOrderAmount: number; + + // Revenue Breakdown (JSONB) + @Column({ name: 'revenue_by_category', type: 'jsonb', default: '{}' }) + revenueByCategory: Record; + + @Column({ name: 'revenue_by_channel', type: 'jsonb', default: '{}' }) + revenueByChannel: Record; + + // Report Management + @Column({ type: 'varchar', length: 20, default: 'DRAFT' }) + status: string; + + @Column({ name: 'confirmed_by', type: 'uuid', nullable: true }) + confirmedBy: string | null; + + @Column({ name: 'confirmed_at', type: 'timestamptz', nullable: true }) + confirmedAt: Date | null; + + @Column({ type: 'text', nullable: true }) + notes: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/services/evolution-service/src/analytics/infrastructure/scheduling/analytics-scheduler.service.ts b/packages/services/evolution-service/src/analytics/infrastructure/scheduling/analytics-scheduler.service.ts new file mode 100644 index 0000000..6d1ac39 --- /dev/null +++ b/packages/services/evolution-service/src/analytics/infrastructure/scheduling/analytics-scheduler.service.ts @@ -0,0 +1,150 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { StatisticsAggregationService } from '../../application/services/statistics-aggregation.service'; +import { FinancialReportService } from '../../application/services/financial-report.service'; +import { AuditLogService } from '../../application/services/audit-log.service'; + +@Injectable() +export class AnalyticsSchedulerService { + private readonly logger = new Logger(AnalyticsSchedulerService.name); + + constructor( + private readonly statisticsService: StatisticsAggregationService, + private readonly financialReportService: FinancialReportService, + private readonly auditLogService: AuditLogService, + ) {} + + /** + * Daily statistics aggregation - runs at 00:05 AM Asia/Shanghai + * 5 minutes past midnight to ensure all previous day data is available + */ + @Cron('5 0 * * *', { + name: 'daily-statistics', + timeZone: 'Asia/Shanghai', + }) + async aggregateDailyStatistics(): Promise { + this.logger.log('Starting daily statistics aggregation (scheduled)...'); + + try { + // Calculate for previous day + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + + await this.statisticsService.aggregateDailyStats(yesterday); + + await this.auditLogService.logSystem( + 'DAILY_STATS_AGGREGATION', + 'DailyStatistics', + undefined, + `Aggregated statistics for ${yesterday.toISOString().split('T')[0]}`, + ); + + this.logger.log('Daily statistics aggregation completed'); + } catch (error) { + this.logger.error('Daily statistics aggregation failed:', error); + + await this.auditLogService.log({ + actorType: 'SYSTEM', + actorName: 'Scheduler', + action: 'DAILY_STATS_AGGREGATION', + entityType: 'DailyStatistics', + result: 'FAILED', + errorMessage: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Monthly financial report - runs at 02:00 AM on the 1st of each month + */ + @Cron('0 2 1 * *', { + name: 'monthly-financial-report', + timeZone: 'Asia/Shanghai', + }) + async generateMonthlyReport(): Promise { + this.logger.log('Starting monthly financial report generation (scheduled)...'); + + try { + // Calculate for previous month + const now = new Date(); + const previousMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const reportMonth = previousMonth.toISOString().slice(0, 7); // YYYY-MM format + + await this.financialReportService.generateMonthlyReport(reportMonth); + + await this.auditLogService.logSystem( + 'MONTHLY_REPORT_GENERATION', + 'MonthlyFinancialReport', + undefined, + `Generated financial report for ${reportMonth}`, + ); + + this.logger.log(`Monthly financial report generated for: ${reportMonth}`); + } catch (error) { + this.logger.error('Monthly financial report generation failed:', error); + + await this.auditLogService.log({ + actorType: 'SYSTEM', + actorName: 'Scheduler', + action: 'MONTHLY_REPORT_GENERATION', + entityType: 'MonthlyFinancialReport', + result: 'FAILED', + errorMessage: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Hourly statistics refresh for real-time dashboard + */ + @Cron(CronExpression.EVERY_HOUR, { + name: 'hourly-stats-refresh', + timeZone: 'Asia/Shanghai', + }) + async refreshTodayStats(): Promise { + this.logger.log('Refreshing today statistics (hourly)...'); + + try { + await this.statisticsService.refreshTodayStats(); + this.logger.log('Today statistics refresh completed'); + } catch (error) { + this.logger.error('Today statistics refresh failed:', error); + } + } + + /** + * Manual trigger for daily statistics (for testing or backfill) + */ + async manualAggregateStats(date: Date): Promise { + this.logger.log(`Manual statistics aggregation for: ${date.toISOString().split('T')[0]}`); + await this.statisticsService.aggregateDailyStats(date); + } + + /** + * Manual trigger for monthly report (for testing or backfill) + */ + async manualGenerateReport(reportMonth: string): Promise { + this.logger.log(`Manual financial report generation for: ${reportMonth}`); + await this.financialReportService.generateMonthlyReport(reportMonth); + } + + /** + * Backfill statistics for a date range + */ + async backfillStatistics(startDate: Date, endDate: Date): Promise { + this.logger.log(`Backfilling statistics from ${startDate.toISOString()} to ${endDate.toISOString()}`); + + const current = new Date(startDate); + while (current <= endDate) { + try { + await this.statisticsService.aggregateDailyStats(current); + this.logger.log(`Backfilled: ${current.toISOString().split('T')[0]}`); + } catch (error) { + this.logger.error(`Failed to backfill ${current.toISOString().split('T')[0]}:`, error); + } + current.setDate(current.getDate() + 1); + } + + this.logger.log('Backfill completed'); + } +} diff --git a/packages/services/evolution-service/src/app.module.ts b/packages/services/evolution-service/src/app.module.ts index 0f4ef5d..d9f19d1 100644 --- a/packages/services/evolution-service/src/app.module.ts +++ b/packages/services/evolution-service/src/app.module.ts @@ -1,9 +1,11 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ScheduleModule } from '@nestjs/schedule'; import { EvolutionModule } from './evolution/evolution.module'; import { AdminModule } from './admin/admin.module'; import { HealthModule } from './health/health.module'; +import { AnalyticsModule } from './analytics/analytics.module'; @Module({ imports: [ @@ -13,6 +15,9 @@ import { HealthModule } from './health/health.module'; envFilePath: ['.env.local', '.env'], }), + // 定时任务模块 + ScheduleModule.forRoot(), + // 数据库连接 TypeOrmModule.forRootAsync({ imports: [ConfigModule], @@ -37,6 +42,7 @@ import { HealthModule } from './health/health.module'; // 功能模块 EvolutionModule, AdminModule, + AnalyticsModule, ], }) export class AppModule {} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5812ef2..1c4ee88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -211,6 +211,9 @@ importers: '@nestjs/platform-express': specifier: ^10.0.0 version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21) + '@nestjs/schedule': + specifier: ^4.0.0 + version: 4.1.2(@nestjs/common@10.4.21)(@nestjs/core@10.4.21) '@nestjs/typeorm': specifier: ^10.0.0 version: 10.0.2(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.28) @@ -2142,6 +2145,18 @@ packages: - supports-color - utf-8-validate + /@nestjs/schedule@4.1.2(@nestjs/common@10.4.21)(@nestjs/core@10.4.21): + resolution: {integrity: sha512-hCTQ1lNjIA5EHxeu8VvQu2Ed2DBLS1GSC6uKPYlBiQe6LL9a7zfE9iVSK+zuK8E2odsApteEBmfAQchc8Hx0Gg==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + '@nestjs/core': ^8.0.0 || ^9.0.0 || ^10.0.0 + dependencies: + '@nestjs/common': 10.4.21(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.21(@nestjs/common@10.4.21)(@nestjs/platform-express@10.4.21)(@nestjs/websockets@10.4.21)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cron: 3.2.1 + uuid: 11.0.3 + dev: false + /@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2): resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==} peerDependencies: @@ -3420,6 +3435,10 @@ packages: '@types/node': 20.19.27 dev: false + /@types/luxon@3.4.2: + resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==} + dev: false + /@types/mdast@4.0.4: resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} dependencies: @@ -4810,6 +4829,13 @@ packages: /create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + /cron@3.2.1: + resolution: {integrity: sha512-w2n5l49GMmmkBFEsH9FIDhjZ1n1QgTMOCMGuQtOXs5veNiosZmso6bQGuqOJSYAXXrG84WQFVneNk+Yt0Ua9iw==} + dependencies: + '@types/luxon': 3.4.2 + luxon: 3.5.0 + dev: false + /cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -7145,6 +7171,11 @@ packages: react: 18.3.1 dev: false + /luxon@3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + dev: false + /magic-string@0.30.8: resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} engines: {node: '>=12'} @@ -10520,6 +10551,11 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + /uuid@11.0.3: + resolution: {integrity: sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==} + hasBin: true + dev: false + /uuid@11.1.0: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true