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:
hailin 2026-01-25 08:01:39 -08:00
parent 65c0bdd17c
commit 042d2e1456
25 changed files with 3758 additions and 103 deletions

View File

@ -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>

View File

@ -0,0 +1 @@
export * from './useAnalytics';

View File

@ -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,
};

View File

@ -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';

View File

@ -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;
},
};

View File

@ -0,0 +1 @@
export * from './analytics.api';

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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"

View File

@ -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",

View File

@ -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);
}
}

View File

@ -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',
],
};
}
}

View File

@ -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 {}

View File

@ -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,
};
}
}

View File

@ -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,
};
}
}

View File

@ -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),
};
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
export { DailyStatisticsORM } from './daily-statistics.orm';
export { MonthlyFinancialReportORM } from './monthly-financial-report.orm';
export { AuditLogORM } from './audit-log.orm';

View File

@ -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;
}

View File

@ -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');
}
}

View File

@ -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 {}

View File

@ -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