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 { DashboardPage } from './features/dashboard/presentation/pages/DashboardPage';
|
||||||
import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage';
|
import { KnowledgePage } from './features/knowledge/presentation/pages/KnowledgePage';
|
||||||
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
import { ExperiencePage } from './features/experience/presentation/pages/ExperiencePage';
|
||||||
|
import { AnalyticsPage, ReportsPage, AuditPage } from './features/analytics';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -24,6 +25,9 @@ function App() {
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="knowledge" element={<KnowledgePage />} />
|
<Route path="knowledge" element={<KnowledgePage />} />
|
||||||
<Route path="experience" element={<ExperiencePage />} />
|
<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="users" element={<div className="p-6">用户管理(开发中)</div>} />
|
||||||
<Route path="settings" element={<div className="p-6">系统设置(开发中)</div>} />
|
<Route path="settings" element={<div className="p-6">系统设置(开发中)</div>} />
|
||||||
</Route>
|
</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 {
|
import {
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
|
|
@ -20,34 +21,77 @@ import {
|
||||||
Cell,
|
Cell,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { useEvolutionStatistics, useSystemHealth } from '../../application';
|
import { useEvolutionStatistics, useSystemHealth } from '../../application';
|
||||||
|
import { useTodayStatistics, useTrendData, useStatisticsByCategory } from '../../../analytics/application';
|
||||||
import type { HealthMetric } from '../../infrastructure';
|
import type { HealthMetric } from '../../infrastructure';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
// Mock数据 - 实际应该从API获取
|
// Category color mapping
|
||||||
const mockTrendData = [
|
const CATEGORY_COLORS: Record<string, string> = {
|
||||||
{ date: '01-01', conversations: 120, users: 45 },
|
QMAS: '#1890ff',
|
||||||
{ date: '01-02', conversations: 150, users: 52 },
|
GEP: '#52c41a',
|
||||||
{ date: '01-03', conversations: 180, users: 68 },
|
IANG: '#faad14',
|
||||||
{ date: '01-04', conversations: 145, users: 55 },
|
TTPS: '#722ed1',
|
||||||
{ date: '01-05', conversations: 200, users: 75 },
|
CIES: '#eb2f96',
|
||||||
{ date: '01-06', conversations: 230, users: 88 },
|
TechTAS: '#13c2c2',
|
||||||
{ date: '01-07', conversations: 210, users: 82 },
|
AEAS: '#f5222d',
|
||||||
];
|
OTHER: '#8c8c8c',
|
||||||
|
};
|
||||||
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' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { data: evolutionStats } = useEvolutionStatistics();
|
const { data: evolutionStats } = useEvolutionStatistics();
|
||||||
const { data: healthReport } = useSystemHealth();
|
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) => {
|
const getHealthColor = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'healthy':
|
case 'healthy':
|
||||||
|
|
@ -65,117 +109,130 @@ export function DashboardPage() {
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<Title level={4} className="mb-6">仪表盘</Title>
|
<Title level={4} className="mb-6">仪表盘</Title>
|
||||||
|
|
||||||
{/* 核心指标 */}
|
{/* Core Metrics */}
|
||||||
<Row gutter={[16, 16]}>
|
<Row gutter={[16, 16]}>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Spin spinning={loadingToday}>
|
||||||
title="今日用户"
|
<Statistic
|
||||||
value={156}
|
title="今日用户"
|
||||||
prefix={<UserOutlined />}
|
value={todayStats?.newUsers ?? 0}
|
||||||
valueStyle={{ color: '#1890ff' }}
|
prefix={<UserOutlined />}
|
||||||
/>
|
valueStyle={{ color: '#1890ff' }}
|
||||||
<div className="mt-2 text-gray-500 text-sm">
|
/>
|
||||||
较昨日 <span className="text-green-500">+12%</span>
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
</div>
|
活跃用户: {todayStats?.activeUsers ?? 0}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Spin spinning={loadingToday}>
|
||||||
title="今日对话"
|
<Statistic
|
||||||
value={428}
|
title="今日对话"
|
||||||
prefix={<MessageOutlined />}
|
value={todayStats?.newConversations ?? 0}
|
||||||
valueStyle={{ color: '#52c41a' }}
|
prefix={<MessageOutlined />}
|
||||||
/>
|
valueStyle={{ color: '#52c41a' }}
|
||||||
<div className="mt-2 text-gray-500 text-sm">
|
/>
|
||||||
较昨日 <span className="text-green-500">+8%</span>
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
</div>
|
消息数: {todayStats?.totalMessages ?? 0}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Spin spinning={loadingToday}>
|
||||||
title="今日收入"
|
<Statistic
|
||||||
value={3580}
|
title="今日收入"
|
||||||
prefix={<DollarOutlined />}
|
value={todayStats?.totalPaidAmount ?? 0}
|
||||||
suffix="元"
|
prefix={<DollarOutlined />}
|
||||||
valueStyle={{ color: '#faad14' }}
|
suffix="元"
|
||||||
/>
|
precision={2}
|
||||||
<div className="mt-2 text-gray-500 text-sm">
|
valueStyle={{ color: '#faad14' }}
|
||||||
较昨日 <span className="text-green-500">+15%</span>
|
/>
|
||||||
</div>
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
|
订单数: {todayStats?.paidOrders ?? 0}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} lg={6}>
|
<Col xs={24} sm={12} lg={6}>
|
||||||
<Card>
|
<Card>
|
||||||
<Statistic
|
<Spin spinning={loadingToday}>
|
||||||
title="转化率"
|
<Statistic
|
||||||
value={8.5}
|
title="转化率"
|
||||||
suffix="%"
|
value={((todayStats?.conversionRate ?? 0) * 100).toFixed(1)}
|
||||||
prefix={<RobotOutlined />}
|
suffix="%"
|
||||||
valueStyle={{ color: '#722ed1' }}
|
prefix={<RobotOutlined />}
|
||||||
/>
|
valueStyle={{ color: '#722ed1' }}
|
||||||
<div className="mt-2 text-gray-500 text-sm">
|
/>
|
||||||
较昨日 <span className="text-red-500">-2%</span>
|
<div className="mt-2 text-gray-500 text-sm">
|
||||||
</div>
|
API成本: ${(todayStats?.estimatedApiCost ?? 0).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 趋势图表 */}
|
{/* Trend Charts */}
|
||||||
<Row gutter={[16, 16]} className="mt-4">
|
<Row gutter={[16, 16]} className="mt-4">
|
||||||
<Col xs={24} lg={16}>
|
<Col xs={24} lg={16}>
|
||||||
<Card title="对话趋势">
|
<Card title="对话趋势 (近7天)">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<Spin spinning={loadingTrend}>
|
||||||
<LineChart data={mockTrendData}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<LineChart data={chartData}>
|
||||||
<XAxis dataKey="date" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<YAxis />
|
<XAxis dataKey="date" />
|
||||||
<Tooltip />
|
<YAxis />
|
||||||
<Line
|
<Tooltip />
|
||||||
type="monotone"
|
<Line
|
||||||
dataKey="conversations"
|
type="monotone"
|
||||||
stroke="#1890ff"
|
dataKey="conversations"
|
||||||
name="对话数"
|
stroke="#1890ff"
|
||||||
/>
|
name="对话数"
|
||||||
<Line
|
/>
|
||||||
type="monotone"
|
<Line
|
||||||
dataKey="users"
|
type="monotone"
|
||||||
stroke="#52c41a"
|
dataKey="users"
|
||||||
name="用户数"
|
stroke="#52c41a"
|
||||||
/>
|
name="用户数"
|
||||||
</LineChart>
|
/>
|
||||||
</ResponsiveContainer>
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} lg={8}>
|
<Col xs={24} lg={8}>
|
||||||
<Card title="类别分布">
|
<Card title="类别分布 (近7天)">
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
<Spin spinning={loadingCategory}>
|
||||||
<PieChart>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<Pie
|
<PieChart>
|
||||||
data={mockCategoryData}
|
<Pie
|
||||||
dataKey="value"
|
data={pieData}
|
||||||
nameKey="name"
|
dataKey="value"
|
||||||
cx="50%"
|
nameKey="name"
|
||||||
cy="50%"
|
cx="50%"
|
||||||
outerRadius={80}
|
cy="50%"
|
||||||
label={({ name, percent }) =>
|
outerRadius={80}
|
||||||
`${name} ${(percent * 100).toFixed(0)}%`
|
label={({ name, percent }) =>
|
||||||
}
|
`${name} ${(percent * 100).toFixed(0)}%`
|
||||||
>
|
}
|
||||||
{mockCategoryData.map((entry, index) => (
|
>
|
||||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
{pieData.map((entry, index) => (
|
||||||
))}
|
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||||
</Pie>
|
))}
|
||||||
<Tooltip />
|
</Pie>
|
||||||
</PieChart>
|
<Tooltip />
|
||||||
</ResponsiveContainer>
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</Spin>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* 系统状态 */}
|
{/* System Status */}
|
||||||
<Row gutter={[16, 16]} className="mt-4">
|
<Row gutter={[16, 16]} className="mt-4">
|
||||||
<Col xs={24} lg={12}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ import {
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
MenuFoldOutlined,
|
MenuFoldOutlined,
|
||||||
MenuUnfoldOutlined,
|
MenuUnfoldOutlined,
|
||||||
|
BarChartOutlined,
|
||||||
|
FileTextOutlined,
|
||||||
|
AuditOutlined,
|
||||||
|
LineChartOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useAuth } from '../hooks/useAuth';
|
import { useAuth } from '../hooks/useAuth';
|
||||||
|
|
||||||
|
|
@ -33,6 +37,28 @@ const menuItems: MenuProps['items'] = [
|
||||||
icon: <RobotOutlined />,
|
icon: <RobotOutlined />,
|
||||||
label: '系统经验',
|
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',
|
key: '/users',
|
||||||
icon: <UserOutlined />,
|
icon: <UserOutlined />,
|
||||||
|
|
@ -45,12 +71,20 @@ const menuItems: MenuProps['items'] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Analytics submenu paths
|
||||||
|
const analyticsRoutes = ['/analytics', '/reports', '/audit'];
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { admin, logout } = useAuth();
|
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 }) => {
|
const handleMenuClick = (e: { key: string }) => {
|
||||||
navigate(e.key);
|
navigate(e.key);
|
||||||
};
|
};
|
||||||
|
|
@ -96,6 +130,7 @@ export function MainLayout() {
|
||||||
<Menu
|
<Menu
|
||||||
mode="inline"
|
mode="inline"
|
||||||
selectedKeys={[location.pathname]}
|
selectedKeys={[location.pathname]}
|
||||||
|
defaultOpenKeys={defaultOpenKeys}
|
||||||
items={menuItems}
|
items={menuItems}
|
||||||
onClick={handleMenuClick}
|
onClick={handleMenuClick}
|
||||||
className="border-none"
|
className="border-none"
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
"@nestjs/config": "^3.2.0",
|
"@nestjs/config": "^3.2.0",
|
||||||
"@nestjs/core": "^10.0.0",
|
"@nestjs/core": "^10.0.0",
|
||||||
"@nestjs/platform-express": "^10.0.0",
|
"@nestjs/platform-express": "^10.0.0",
|
||||||
|
"@nestjs/schedule": "^4.0.0",
|
||||||
"@nestjs/typeorm": "^10.0.0",
|
"@nestjs/typeorm": "^10.0.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"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 { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
import { EvolutionModule } from './evolution/evolution.module';
|
import { EvolutionModule } from './evolution/evolution.module';
|
||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { AnalyticsModule } from './analytics/analytics.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|
@ -13,6 +15,9 @@ import { HealthModule } from './health/health.module';
|
||||||
envFilePath: ['.env.local', '.env'],
|
envFilePath: ['.env.local', '.env'],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// 定时任务模块
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
|
||||||
// 数据库连接
|
// 数据库连接
|
||||||
TypeOrmModule.forRootAsync({
|
TypeOrmModule.forRootAsync({
|
||||||
imports: [ConfigModule],
|
imports: [ConfigModule],
|
||||||
|
|
@ -37,6 +42,7 @@ import { HealthModule } from './health/health.module';
|
||||||
// 功能模块
|
// 功能模块
|
||||||
EvolutionModule,
|
EvolutionModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
|
AnalyticsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,9 @@ importers:
|
||||||
'@nestjs/platform-express':
|
'@nestjs/platform-express':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.4.21(@nestjs/common@10.4.21)(@nestjs/core@10.4.21)
|
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':
|
'@nestjs/typeorm':
|
||||||
specifier: ^10.0.0
|
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)
|
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
|
- supports-color
|
||||||
- utf-8-validate
|
- 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):
|
/@nestjs/schematics@10.2.3(chokidar@3.6.0)(typescript@5.7.2):
|
||||||
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
|
resolution: {integrity: sha512-4e8gxaCk7DhBxVUly2PjYL4xC2ifDFexCqq1/u4TtivLGXotVk0wHdYuPYe1tHTHuR1lsOkRbfOCpkdTnigLVg==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|
@ -3420,6 +3435,10 @@ packages:
|
||||||
'@types/node': 20.19.27
|
'@types/node': 20.19.27
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@types/luxon@3.4.2:
|
||||||
|
resolution: {integrity: sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@types/mdast@4.0.4:
|
/@types/mdast@4.0.4:
|
||||||
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -4810,6 +4829,13 @@ packages:
|
||||||
/create-require@1.1.1:
|
/create-require@1.1.1:
|
||||||
resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==}
|
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:
|
/cross-spawn@7.0.6:
|
||||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
|
|
@ -7145,6 +7171,11 @@ packages:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/luxon@3.5.0:
|
||||||
|
resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==}
|
||||||
|
engines: {node: '>=12'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/magic-string@0.30.8:
|
/magic-string@0.30.8:
|
||||||
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
|
resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
@ -10520,6 +10551,11 @@ packages:
|
||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
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:
|
/uuid@11.1.0:
|
||||||
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue