feat(analytics): implement statistics, financial reports, and audit logging
Backend (evolution-service): - Add analytics module with scheduled statistics aggregation - Implement daily_statistics aggregation (OVERALL, CHANNEL, CATEGORY) - Add monthly financial report generation and management - Create audit log service for operation tracking - Schedule cron jobs for automatic data aggregation Frontend (admin-client): - Replace dashboard mock data with real API calls - Add analytics page with trend charts and dimension breakdown - Add financial reports page with confirm/lock workflow - Add audit logs page with filtering and detail view - Update navigation with analytics submenu Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
65c0bdd17c
commit
042d2e1456
|
|
@ -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() {
|
|||
<Route index element={<DashboardPage />} />
|
||||
<Route path="knowledge" element={<KnowledgePage />} />
|
||||
<Route path="experience" element={<ExperiencePage />} />
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="reports" element={<ReportsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
<Route path="users" element={<div className="p-6">用户管理(开发中)</div>} />
|
||||
<Route path="settings" element={<div className="p-6">系统设置(开发中)</div>} />
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from './useAnalytics';
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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<string, TrendDataPoint[]>;
|
||||
|
||||
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<string, number>;
|
||||
revenueByChannel: Record<string, number>;
|
||||
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<string, unknown> | null;
|
||||
newValues: Record<string, unknown> | 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<DailyStatisticsDto | null> => {
|
||||
const response = await api.get('/analytics/statistics/today');
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getDailyStatistics: async (startDate: string, endDate: string, dimension?: string): Promise<DailyStatisticsDto[]> => {
|
||||
const response = await api.get('/analytics/statistics/daily', {
|
||||
params: { startDate, endDate, dimension },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getTrendData: async (days: number, metrics: string[]): Promise<TrendData> => {
|
||||
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<DailyStatisticsDto[]> => {
|
||||
const response = await api.get('/analytics/statistics/by-channel', {
|
||||
params: { startDate, endDate },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getStatisticsByCategory: async (startDate: string, endDate: string): Promise<DailyStatisticsDto[]> => {
|
||||
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<FinancialReportDto[]> => {
|
||||
const response = await api.get('/analytics/financial-reports', {
|
||||
params: { year: year?.toString(), status },
|
||||
});
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getFinancialReport: async (month: string): Promise<FinancialReportDto> => {
|
||||
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<FinancialReportDto> => {
|
||||
const response = await api.put(`/analytics/financial-reports/${month}/confirm`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
lockFinancialReport: async (month: string): Promise<FinancialReportDto> => {
|
||||
const response = await api.put(`/analytics/financial-reports/${month}/lock`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
updateReportNotes: async (month: string, notes: string): Promise<FinancialReportDto> => {
|
||||
const response = await api.put(`/analytics/financial-reports/${month}/notes`, { notes });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
// Audit Logs
|
||||
getAuditLogs: async (params: AuditLogQueryParams): Promise<PaginatedAuditLogs> => {
|
||||
const response = await api.get('/audit/logs', { params });
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getAuditLog: async (id: string): Promise<AuditLogDto> => {
|
||||
const response = await api.get(`/audit/logs/${id}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getEntityHistory: async (entityType: string, entityId: string): Promise<AuditLogDto[]> => {
|
||||
const response = await api.get(`/audit/logs/entity/${entityType}/${entityId}`);
|
||||
return response.data.data;
|
||||
},
|
||||
|
||||
getActorHistory: async (actorId: string, page = 1, pageSize = 50): Promise<PaginatedAuditLogs> => {
|
||||
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;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './analytics.api';
|
||||
|
|
@ -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<string, string> = {
|
||||
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<string[]>([
|
||||
'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<string, string | number> = { 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<DailyStatisticsDto> = [
|
||||
{
|
||||
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 (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Title level={4} className="mb-0">统计分析</Title>
|
||||
<Space>
|
||||
<Button
|
||||
icon={<SyncOutlined />}
|
||||
onClick={handleRefresh}
|
||||
loading={refreshMutation.isPending}
|
||||
>
|
||||
刷新今日
|
||||
</Button>
|
||||
<Button
|
||||
icon={<HistoryOutlined />}
|
||||
onClick={() => setBackfillModalOpen(true)}
|
||||
>
|
||||
历史回填
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Date Range Filter */}
|
||||
<Card className="mb-4">
|
||||
<Space>
|
||||
<span>日期范围:</span>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setDateRange([dates[0], dates[1]]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Summary Statistics */}
|
||||
<Card title="统计概览" className="mb-4">
|
||||
<Spin spinning={loadingDaily}>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Statistic
|
||||
title="新增用户"
|
||||
value={summary.newUsers}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Statistic
|
||||
title="新增对话"
|
||||
value={summary.newConversations}
|
||||
prefix={<MessageOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Statistic
|
||||
title="支付订单"
|
||||
value={summary.paidOrders}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Statistic
|
||||
title="总收入"
|
||||
value={summary.totalPaidAmount}
|
||||
prefix={<DollarOutlined />}
|
||||
suffix="元"
|
||||
precision={2}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* Trend Analysis */}
|
||||
<Card title="趋势分析" className="mb-4">
|
||||
<div className="mb-4">
|
||||
<Space>
|
||||
<span>时间跨度:</span>
|
||||
<Select
|
||||
value={trendDays}
|
||||
onChange={setTrendDays}
|
||||
options={[
|
||||
{ value: 7, label: '7天' },
|
||||
{ value: 14, label: '14天' },
|
||||
{ value: 30, label: '30天' },
|
||||
]}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
<span className="ml-4">指标:</span>
|
||||
<Select
|
||||
mode="multiple"
|
||||
value={selectedMetrics}
|
||||
onChange={setSelectedMetrics}
|
||||
options={METRIC_OPTIONS}
|
||||
style={{ minWidth: 300 }}
|
||||
maxTagCount={3}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
<Spin spinning={loadingTrend}>
|
||||
<ResponsiveContainer width="100%" height={400}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{selectedMetrics.map((metric) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={METRIC_COLORS[metric] || '#1890ff'}
|
||||
name={METRIC_OPTIONS.find((o) => o.value === metric)?.label || metric}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* Dimension Breakdown */}
|
||||
<Card title="维度对比">
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'channel',
|
||||
label: '按渠道',
|
||||
children: (
|
||||
<Spin spinning={loadingChannel}>
|
||||
<Table
|
||||
columns={dimensionColumns}
|
||||
dataSource={channelStats || []}
|
||||
rowKey={(r) => r.dimensionValue || 'unknown'}
|
||||
pagination={false}
|
||||
/>
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: '按类别',
|
||||
children: (
|
||||
<Spin spinning={loadingCategory}>
|
||||
<Table
|
||||
columns={dimensionColumns}
|
||||
dataSource={categoryStats || []}
|
||||
rowKey={(r) => r.dimensionValue || 'unknown'}
|
||||
pagination={false}
|
||||
/>
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Backfill Modal */}
|
||||
<Modal
|
||||
title="历史数据回填"
|
||||
open={backfillModalOpen}
|
||||
onOk={handleBackfill}
|
||||
onCancel={() => setBackfillModalOpen(false)}
|
||||
confirmLoading={backfillMutation.isPending}
|
||||
>
|
||||
<Form form={backfillForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="dateRange"
|
||||
label="回填日期范围"
|
||||
rules={[{ required: true, message: '请选择日期范围' }]}
|
||||
>
|
||||
<RangePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<p className="text-gray-500 text-sm">
|
||||
回填操作会重新计算指定日期范围内的统计数据,可能需要较长时间。
|
||||
</p>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
SUCCESS: 'success',
|
||||
FAILED: 'error',
|
||||
};
|
||||
|
||||
const ACTOR_TYPE_LABELS: Record<string, string> = {
|
||||
USER: '用户',
|
||||
ADMIN: '管理员',
|
||||
SYSTEM: '系统',
|
||||
};
|
||||
|
||||
export function AuditPage() {
|
||||
// Filter state
|
||||
const [filters, setFilters] = useState<AuditLogQueryParams>({
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
});
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
|
||||
// Detail drawer
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [selectedLog, setSelectedLog] = useState<AuditLogDto | null>(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<AuditLogDto> = [
|
||||
{
|
||||
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) => (
|
||||
<Tag color={RESULT_COLORS[v]}>{v === 'SUCCESS' ? '成功' : '失败'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 80,
|
||||
render: (_, record) => (
|
||||
<Button size="small" onClick={() => showDetail(record)}>
|
||||
详情
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Title level={4} className="mb-0">审计日志</Title>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => refetch()}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-4">
|
||||
<Space wrap>
|
||||
<span>操作类型:</span>
|
||||
<Select
|
||||
value={filters.action}
|
||||
onChange={(v) => handleFilterChange('action', v)}
|
||||
options={[
|
||||
{ value: undefined, label: '全部' },
|
||||
...(actionTypesData?.actions || []).map((a) => ({ value: a, label: a })),
|
||||
]}
|
||||
style={{ width: 180 }}
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
/>
|
||||
|
||||
<span className="ml-4">实体类型:</span>
|
||||
<Select
|
||||
value={filters.entityType}
|
||||
onChange={(v) => handleFilterChange('entityType', v)}
|
||||
options={[
|
||||
{ value: undefined, label: '全部' },
|
||||
...(entityTypesData?.entityTypes || []).map((e) => ({ value: e, label: e })),
|
||||
]}
|
||||
style={{ width: 150 }}
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
/>
|
||||
|
||||
<span className="ml-4">操作者类型:</span>
|
||||
<Select
|
||||
value={filters.actorType}
|
||||
onChange={(v) => handleFilterChange('actorType', v)}
|
||||
options={[
|
||||
{ value: undefined, label: '全部' },
|
||||
{ value: 'USER', label: '用户' },
|
||||
{ value: 'ADMIN', label: '管理员' },
|
||||
{ value: 'SYSTEM', label: '系统' },
|
||||
]}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
/>
|
||||
|
||||
<span className="ml-4">结果:</span>
|
||||
<Select
|
||||
value={filters.result}
|
||||
onChange={(v) => handleFilterChange('result', v)}
|
||||
options={[
|
||||
{ value: undefined, label: '全部' },
|
||||
{ value: 'SUCCESS', label: '成功' },
|
||||
{ value: 'FAILED', label: '失败' },
|
||||
]}
|
||||
style={{ width: 100 }}
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
/>
|
||||
|
||||
<span className="ml-4">日期范围:</span>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(dates) => {
|
||||
if (dates && dates[0] && dates[1]) {
|
||||
setDateRange([dates[0], dates[1]]);
|
||||
} else {
|
||||
setDateRange(null);
|
||||
}
|
||||
setFilters((prev) => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button onClick={handleReset}>重置</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Logs Table */}
|
||||
<Card>
|
||||
<Spin spinning={isLoading}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={logsData?.items || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
current: filters.page,
|
||||
pageSize: filters.pageSize,
|
||||
total: logsData?.total || 0,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
onChange: (page, pageSize) => {
|
||||
setFilters((prev) => ({ ...prev, page, pageSize }));
|
||||
},
|
||||
}}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* Detail Drawer */}
|
||||
<Drawer
|
||||
title="审计日志详情"
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
width={600}
|
||||
>
|
||||
{selectedLog && (
|
||||
<div>
|
||||
<Descriptions bordered column={1} size="small">
|
||||
<Descriptions.Item label="日志ID">{selectedLog.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">
|
||||
{dayjs(selectedLog.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作者类型">
|
||||
{ACTOR_TYPE_LABELS[selectedLog.actorType] || selectedLog.actorType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作者ID">
|
||||
{selectedLog.actorId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作者名称">
|
||||
{selectedLog.actorName || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">{selectedLog.action}</Descriptions.Item>
|
||||
<Descriptions.Item label="实体类型">
|
||||
{selectedLog.entityType}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="实体ID">
|
||||
{selectedLog.entityId || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="描述">
|
||||
{selectedLog.description || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结果">
|
||||
<Tag color={RESULT_COLORS[selectedLog.result]}>
|
||||
{selectedLog.result === 'SUCCESS' ? '成功' : '失败'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
{selectedLog.errorMessage && (
|
||||
<Descriptions.Item label="错误信息">
|
||||
<Text type="danger">{selectedLog.errorMessage}</Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
<Descriptions.Item label="IP地址">
|
||||
{selectedLog.ipAddress || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="请求ID">
|
||||
{selectedLog.requestId || '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* Changed Fields */}
|
||||
{selectedLog.changedFields && selectedLog.changedFields.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<Text strong>变更字段</Text>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded">
|
||||
{selectedLog.changedFields.map((field) => (
|
||||
<Tag key={field} className="mb-1">
|
||||
{field}
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Old Values */}
|
||||
{selectedLog.oldValues && Object.keys(selectedLog.oldValues).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<Text strong>原值</Text>
|
||||
<pre className="mt-2 p-3 bg-gray-50 rounded text-sm overflow-auto max-h-48">
|
||||
{JSON.stringify(selectedLog.oldValues, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Values */}
|
||||
{selectedLog.newValues && Object.keys(selectedLog.newValues).length > 0 && (
|
||||
<div className="mt-4">
|
||||
<Text strong>新值</Text>
|
||||
<pre className="mt-2 p-3 bg-gray-50 rounded text-sm overflow-auto max-h-48">
|
||||
{JSON.stringify(selectedLog.newValues, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Agent */}
|
||||
{selectedLog.userAgent && (
|
||||
<div className="mt-4">
|
||||
<Text strong>User Agent</Text>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded text-sm break-all">
|
||||
{selectedLog.userAgent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
DRAFT: 'default',
|
||||
CONFIRMED: 'success',
|
||||
LOCKED: 'purple',
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<string, string> = {
|
||||
DRAFT: '草稿',
|
||||
CONFIRMED: '已确认',
|
||||
LOCKED: '已锁定',
|
||||
};
|
||||
|
||||
export function ReportsPage() {
|
||||
const currentYear = dayjs().year();
|
||||
const [selectedYear, setSelectedYear] = useState<number | undefined>(currentYear);
|
||||
const [selectedStatus, setSelectedStatus] = useState<string | undefined>();
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false);
|
||||
const [selectedReport, setSelectedReport] = useState<FinancialReportDto | null>(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<FinancialReportDto> = [
|
||||
{
|
||||
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) => (
|
||||
<span style={{ color: Number(v) >= 0 ? '#52c41a' : '#f5222d' }}>
|
||||
¥{Number(v).toFixed(2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '毛利率',
|
||||
dataIndex: 'grossMargin',
|
||||
key: 'grossMargin',
|
||||
render: (v) => `${(Number(v) * 100).toFixed(1)}%`,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status) => (
|
||||
<Tag color={STATUS_COLORS[status]}>{STATUS_LABELS[status] || status}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => showDetail(record)}>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'DRAFT' && (
|
||||
<Popconfirm
|
||||
title="确认报表"
|
||||
description="确认后报表数据将不可修改,是否继续?"
|
||||
onConfirm={() => confirmMutation.mutate(record.reportMonth)}
|
||||
okText="确认"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckCircleOutlined />}
|
||||
loading={confirmMutation.isPending}
|
||||
>
|
||||
确认
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{record.status === 'CONFIRMED' && (
|
||||
<Popconfirm
|
||||
title="锁定报表"
|
||||
description="锁定后报表将无法再修改,是否继续?"
|
||||
onConfirm={() => lockMutation.mutate(record.reportMonth)}
|
||||
okText="锁定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<LockOutlined />}
|
||||
loading={lockMutation.isPending}
|
||||
>
|
||||
锁定
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Generate year options (last 3 years)
|
||||
const yearOptions = Array.from({ length: 3 }, (_, i) => ({
|
||||
value: currentYear - i,
|
||||
label: `${currentYear - i}年`,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<Title level={4} className="mb-0">财务报表</Title>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setGenerateModalOpen(true)}
|
||||
>
|
||||
生成报表
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-4">
|
||||
<Space>
|
||||
<span>年份:</span>
|
||||
<Select
|
||||
value={selectedYear}
|
||||
onChange={setSelectedYear}
|
||||
options={[{ value: undefined, label: '全部' }, ...yearOptions]}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
/>
|
||||
<span className="ml-4">状态:</span>
|
||||
<Select
|
||||
value={selectedStatus}
|
||||
onChange={setSelectedStatus}
|
||||
options={[
|
||||
{ value: undefined, label: '全部' },
|
||||
{ value: 'DRAFT', label: '草稿' },
|
||||
{ value: 'CONFIRMED', label: '已确认' },
|
||||
{ value: 'LOCKED', label: '已锁定' },
|
||||
]}
|
||||
style={{ width: 120 }}
|
||||
allowClear
|
||||
placeholder="全部"
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* Reports Table */}
|
||||
<Card>
|
||||
<Spin spinning={isLoading}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={reports || []}
|
||||
rowKey="id"
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
showTotal: (total) => `共 ${total} 条`,
|
||||
}}
|
||||
/>
|
||||
</Spin>
|
||||
</Card>
|
||||
|
||||
{/* Detail Modal */}
|
||||
<Modal
|
||||
title={`财务报表详情 - ${selectedReport?.reportMonth}`}
|
||||
open={detailModalOpen}
|
||||
onCancel={() => setDetailModalOpen(false)}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => setDetailModalOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
width={800}
|
||||
>
|
||||
{selectedReport && (
|
||||
<div>
|
||||
<Descriptions bordered column={2} size="small">
|
||||
<Descriptions.Item label="报表月份">
|
||||
{selectedReport.reportMonth}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLORS[selectedReport.status]}>
|
||||
{STATUS_LABELS[selectedReport.status]}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总收入">
|
||||
¥{Number(selectedReport.totalRevenue).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="评估收入">
|
||||
¥{Number(selectedReport.assessmentRevenue).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="咨询收入">
|
||||
¥{Number(selectedReport.consultationRevenue).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="其他收入">
|
||||
¥{Number(selectedReport.otherRevenue).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="退款总额">
|
||||
¥{Number(selectedReport.totalRefunds).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="净收入">
|
||||
¥{Number(selectedReport.netRevenue).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="API成本">
|
||||
¥{Number(selectedReport.apiCost).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="支付手续费">
|
||||
¥{Number(selectedReport.paymentFees).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="其他成本">
|
||||
¥{Number(selectedReport.otherCosts).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总成本">
|
||||
¥{Number(selectedReport.totalCosts).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="毛利">
|
||||
<span style={{ color: Number(selectedReport.grossProfit) >= 0 ? '#52c41a' : '#f5222d' }}>
|
||||
¥{Number(selectedReport.grossProfit).toFixed(2)}
|
||||
</span>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="毛利率">
|
||||
{(Number(selectedReport.grossMargin) * 100).toFixed(1)}%
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="总订单数">
|
||||
{selectedReport.totalOrders}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="成功订单数">
|
||||
{selectedReport.successfulOrders}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平均订单金额">
|
||||
¥{Number(selectedReport.avgOrderAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="确认人">
|
||||
{selectedReport.confirmedBy || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="确认时间">
|
||||
{selectedReport.confirmedAt
|
||||
? dayjs(selectedReport.confirmedAt).format('YYYY-MM-DD HH:mm:ss')
|
||||
: '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">
|
||||
{dayjs(selectedReport.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
{/* Revenue by Category */}
|
||||
<div className="mt-4">
|
||||
<Text strong>按类别收入</Text>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded">
|
||||
{Object.entries(selectedReport.revenueByCategory || {}).length > 0 ? (
|
||||
Object.entries(selectedReport.revenueByCategory).map(([key, value]) => (
|
||||
<Tag key={key} className="mb-1">
|
||||
{key}: ¥{Number(value).toFixed(2)}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">暂无数据</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue by Channel */}
|
||||
<div className="mt-4">
|
||||
<Text strong>按渠道收入</Text>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded">
|
||||
{Object.entries(selectedReport.revenueByChannel || {}).length > 0 ? (
|
||||
Object.entries(selectedReport.revenueByChannel).map(([key, value]) => (
|
||||
<Tag key={key} className="mb-1">
|
||||
{key}: ¥{Number(value).toFixed(2)}
|
||||
</Tag>
|
||||
))
|
||||
) : (
|
||||
<Text type="secondary">暂无数据</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
{selectedReport.notes && (
|
||||
<div className="mt-4">
|
||||
<Text strong>备注</Text>
|
||||
<div className="mt-2 p-3 bg-gray-50 rounded">
|
||||
<Text>{selectedReport.notes}</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Generate Report Modal */}
|
||||
<Modal
|
||||
title="生成财务报表"
|
||||
open={generateModalOpen}
|
||||
onOk={handleGenerate}
|
||||
onCancel={() => {
|
||||
setGenerateModalOpen(false);
|
||||
generateForm.resetFields();
|
||||
}}
|
||||
confirmLoading={generateMutation.isPending}
|
||||
>
|
||||
<Form form={generateForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="month"
|
||||
label="报表月份"
|
||||
rules={[
|
||||
{ required: true, message: '请输入报表月份' },
|
||||
{ pattern: /^\d{4}-\d{2}$/, message: '格式应为 YYYY-MM' },
|
||||
]}
|
||||
>
|
||||
<Input placeholder="例如: 2024-01" />
|
||||
</Form.Item>
|
||||
<p className="text-gray-500 text-sm">
|
||||
生成指定月份的财务报表。如果报表已存在且为草稿状态,将会更新数据。
|
||||
</p>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, string> = {
|
||||
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() {
|
|||
<div className="p-6">
|
||||
<Title level={4} className="mb-6">仪表盘</Title>
|
||||
|
||||
{/* 核心指标 */}
|
||||
{/* Core Metrics */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日用户"
|
||||
value={156}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-green-500">+12%</span>
|
||||
</div>
|
||||
<Spin spinning={loadingToday}>
|
||||
<Statistic
|
||||
title="今日用户"
|
||||
value={todayStats?.newUsers ?? 0}
|
||||
prefix={<UserOutlined />}
|
||||
valueStyle={{ color: '#1890ff' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
活跃用户: {todayStats?.activeUsers ?? 0}
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日对话"
|
||||
value={428}
|
||||
prefix={<MessageOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-green-500">+8%</span>
|
||||
</div>
|
||||
<Spin spinning={loadingToday}>
|
||||
<Statistic
|
||||
title="今日对话"
|
||||
value={todayStats?.newConversations ?? 0}
|
||||
prefix={<MessageOutlined />}
|
||||
valueStyle={{ color: '#52c41a' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
消息数: {todayStats?.totalMessages ?? 0}
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="今日收入"
|
||||
value={3580}
|
||||
prefix={<DollarOutlined />}
|
||||
suffix="元"
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-green-500">+15%</span>
|
||||
</div>
|
||||
<Spin spinning={loadingToday}>
|
||||
<Statistic
|
||||
title="今日收入"
|
||||
value={todayStats?.totalPaidAmount ?? 0}
|
||||
prefix={<DollarOutlined />}
|
||||
suffix="元"
|
||||
precision={2}
|
||||
valueStyle={{ color: '#faad14' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
订单数: {todayStats?.paidOrders ?? 0}
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title="转化率"
|
||||
value={8.5}
|
||||
suffix="%"
|
||||
prefix={<RobotOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
较昨日 <span className="text-red-500">-2%</span>
|
||||
</div>
|
||||
<Spin spinning={loadingToday}>
|
||||
<Statistic
|
||||
title="转化率"
|
||||
value={((todayStats?.conversionRate ?? 0) * 100).toFixed(1)}
|
||||
suffix="%"
|
||||
prefix={<RobotOutlined />}
|
||||
valueStyle={{ color: '#722ed1' }}
|
||||
/>
|
||||
<div className="mt-2 text-gray-500 text-sm">
|
||||
API成本: ${(todayStats?.estimatedApiCost ?? 0).toFixed(2)}
|
||||
</div>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 趋势图表 */}
|
||||
{/* Trend Charts */}
|
||||
<Row gutter={[16, 16]} className="mt-4">
|
||||
<Col xs={24} lg={16}>
|
||||
<Card title="对话趋势">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={mockTrendData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="conversations"
|
||||
stroke="#1890ff"
|
||||
name="对话数"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
stroke="#52c41a"
|
||||
name="用户数"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<Card title="对话趋势 (近7天)">
|
||||
<Spin spinning={loadingTrend}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="conversations"
|
||||
stroke="#1890ff"
|
||||
name="对话数"
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="users"
|
||||
stroke="#52c41a"
|
||||
name="用户数"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={8}>
|
||||
<Card title="类别分布">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={mockCategoryData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={({ name, percent }) =>
|
||||
`${name} ${(percent * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{mockCategoryData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<Card title="类别分布 (近7天)">
|
||||
<Spin spinning={loadingCategory}>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label={({ name, percent }) =>
|
||||
`${name} ${(percent * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</Spin>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 系统状态 */}
|
||||
{/* System Status */}
|
||||
<Row gutter={[16, 16]} className="mt-4">
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ import {
|
|||
LogoutOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
BarChartOutlined,
|
||||
FileTextOutlined,
|
||||
AuditOutlined,
|
||||
LineChartOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
|
|
@ -33,6 +37,28 @@ const menuItems: MenuProps['items'] = [
|
|||
icon: <RobotOutlined />,
|
||||
label: '系统经验',
|
||||
},
|
||||
{
|
||||
key: 'analytics-group',
|
||||
icon: <BarChartOutlined />,
|
||||
label: '数据分析',
|
||||
children: [
|
||||
{
|
||||
key: '/analytics',
|
||||
icon: <LineChartOutlined />,
|
||||
label: '统计分析',
|
||||
},
|
||||
{
|
||||
key: '/reports',
|
||||
icon: <FileTextOutlined />,
|
||||
label: '财务报表',
|
||||
},
|
||||
{
|
||||
key: '/audit',
|
||||
icon: <AuditOutlined />,
|
||||
label: '审计日志',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: '/users',
|
||||
icon: <UserOutlined />,
|
||||
|
|
@ -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() {
|
|||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
defaultOpenKeys={defaultOpenKeys}
|
||||
items={menuItems}
|
||||
onClick={handleMenuClick}
|
||||
className="border-none"
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
"@nestjs/config": "^3.2.0",
|
||||
"@nestjs/core": "^10.0.0",
|
||||
"@nestjs/platform-express": "^10.0.0",
|
||||
"@nestjs/schedule": "^4.0.0",
|
||||
"@nestjs/typeorm": "^10.0.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,278 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Param,
|
||||
Body,
|
||||
Headers,
|
||||
UnauthorizedException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { StatisticsAggregationService, DailyStatisticsDto } from '../../application/services/statistics-aggregation.service';
|
||||
import { FinancialReportService, FinancialReportDto } from '../../application/services/financial-report.service';
|
||||
import { AnalyticsSchedulerService } from '../../infrastructure/scheduling/analytics-scheduler.service';
|
||||
import { AdminService } from '../../../application/services/admin.service';
|
||||
|
||||
@Controller('analytics')
|
||||
export class AnalyticsController {
|
||||
private readonly logger = new Logger(AnalyticsController.name);
|
||||
|
||||
constructor(
|
||||
private readonly statisticsService: StatisticsAggregationService,
|
||||
private readonly financialReportService: FinancialReportService,
|
||||
private readonly schedulerService: AnalyticsSchedulerService,
|
||||
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 };
|
||||
}
|
||||
|
||||
// ==================== 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<DailyStatisticsDto[]> {
|
||||
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<DailyStatisticsDto | null> {
|
||||
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<Record<string, { date: string; value: number }[]>> {
|
||||
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<DailyStatisticsDto[]> {
|
||||
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<DailyStatisticsDto[]> {
|
||||
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<FinancialReportDto[]> {
|
||||
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<FinancialReportDto> {
|
||||
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<FinancialReportDto> {
|
||||
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<FinancialReportDto> {
|
||||
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<FinancialReportDto> {
|
||||
await this.verifyAdmin(auth);
|
||||
return this.financialReportService.updateNotes(month, body.notes);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<PaginatedAuditLogs> {
|
||||
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<AuditLogDto> {
|
||||
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<AuditLogDto[]> {
|
||||
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<PaginatedAuditLogs> {
|
||||
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',
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
@ -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<string, unknown>;
|
||||
newValues?: Record<string, unknown>;
|
||||
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<string, unknown> | null;
|
||||
newValues: Record<string, unknown> | 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<AuditLogORM>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Log an audit event
|
||||
*/
|
||||
async log(params: CreateAuditLogParams): Promise<void> {
|
||||
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<PaginatedAuditLogs> {
|
||||
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<AuditLogDto | null> {
|
||||
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<AuditLogDto[]> {
|
||||
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<PaginatedAuditLogs> {
|
||||
return this.queryLogs({ actorId, page, pageSize });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a system event
|
||||
*/
|
||||
async logSystem(
|
||||
action: string,
|
||||
entityType: string,
|
||||
entityId?: string,
|
||||
description?: string,
|
||||
): Promise<void> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, number>;
|
||||
revenueByChannel: Record<string, number>;
|
||||
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<MonthlyFinancialReportORM>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate monthly financial report
|
||||
*/
|
||||
async generateMonthlyReport(reportMonth: string): Promise<void> {
|
||||
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<FinancialReportDto[]> {
|
||||
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<FinancialReportDto> {
|
||||
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<FinancialReportDto> {
|
||||
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<FinancialReportDto> {
|
||||
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<FinancialReportDto> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DailyStatisticsORM>,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Aggregate daily statistics for a specific date
|
||||
*/
|
||||
async aggregateDailyStats(date: Date): Promise<void> {
|
||||
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<void> {
|
||||
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<DailyStatisticsDto[]> {
|
||||
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<DailyStatisticsDto | null> {
|
||||
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<Record<string, TrendDataPoint[]>> {
|
||||
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<string, TrendDataPoint[]> = {};
|
||||
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<DailyStatisticsDto[]> {
|
||||
return this.getDailyStatistics({
|
||||
startDate,
|
||||
endDate,
|
||||
dimension: 'CHANNEL',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics by category
|
||||
*/
|
||||
async getStatisticsByCategory(startDate: string, endDate: string): Promise<DailyStatisticsDto[]> {
|
||||
return this.getDailyStatistics({
|
||||
startDate,
|
||||
endDate,
|
||||
dimension: 'CATEGORY',
|
||||
});
|
||||
}
|
||||
|
||||
private async aggregateOverallStats(statDate: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<string, unknown> | null;
|
||||
|
||||
@Column({ name: 'new_values', type: 'jsonb', nullable: true })
|
||||
newValues: Record<string, unknown> | 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { DailyStatisticsORM } from './daily-statistics.orm';
|
||||
export { MonthlyFinancialReportORM } from './monthly-financial-report.orm';
|
||||
export { AuditLogORM } from './audit-log.orm';
|
||||
|
|
@ -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<string, number>;
|
||||
|
||||
@Column({ name: 'revenue_by_channel', type: 'jsonb', default: '{}' })
|
||||
revenueByChannel: Record<string, number>;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue