diff --git a/apps/api/src/services/ai-summary.service.ts b/apps/api/src/services/ai-summary.service.ts index 5f8ea0b..5a008ef 100644 --- a/apps/api/src/services/ai-summary.service.ts +++ b/apps/api/src/services/ai-summary.service.ts @@ -92,6 +92,25 @@ function extractChatCompletionText(payload: ChatCompletionPayload) { } function buildSummaryInput(overview: DashboardOverview) { + const tagSpendTop = [...overview.tagSpend] + .filter((item) => item.value > 0) + .sort((a, b) => b.value - a.value || a.name.localeCompare(b.name, 'zh-CN')) + .slice(0, 6) + const renewalModeDistribution = overview.renewalModeDistribution.filter((item) => item.count > 0 || item.amount > 0) + const currencyDistribution = overview.currencyDistribution + .filter((item) => item.amount > 0) + .sort((a, b) => b.amount - a.amount || a.currency.localeCompare(b.currency, 'en')) + .slice(0, 8) + const upcomingRenewalsTop = overview.upcomingRenewals.slice(0, 6) + const topSubscriptionsByMonthlyCost = overview.topSubscriptionsByMonthlyCost.slice(0, 6) + const upcomingByDayNonZero = overview.upcomingByDay + .filter((item) => item.count > 0 || item.amount > 0) + .slice(0, 10) + const tagBudgetUsageTop = (overview.tagBudgetUsage ?? []) + .filter((item) => item.ratio > 0 || item.spent > 0) + .sort((a, b) => b.ratio - a.ratio || b.spent - a.spent) + .slice(0, 6) + return { summary: { activeSubscriptions: overview.activeSubscriptions, @@ -105,21 +124,15 @@ function buildSummaryInput(overview: DashboardOverview) { yearlyBudgetUsageRatio: overview.yearlyBudgetUsageRatio ?? null }, budgetSummary: overview.budgetSummary, - tagSpendTop: [...overview.tagSpend] - .sort((a, b) => b.value - a.value || a.name.localeCompare(b.name, 'zh-CN')) - .slice(0, 8), + tagSpendTop, statusDistribution: overview.statusDistribution, - renewalModeDistribution: overview.renewalModeDistribution, - currencyDistribution: overview.currencyDistribution, - upcomingRenewalsTop: overview.upcomingRenewals.slice(0, 8), - topSubscriptionsByMonthlyCost: overview.topSubscriptionsByMonthlyCost.slice(0, 8), - upcomingByDayNonZero: overview.upcomingByDay.filter((item) => item.count > 0 || item.amount > 0).slice(0, 15), + renewalModeDistribution, + currencyDistribution, + upcomingRenewalsTop, + topSubscriptionsByMonthlyCost, + upcomingByDayNonZero, tagBudgetSummary: overview.tagBudgetSummary ?? null, - tagBudgetUsageTop: - (overview.tagBudgetUsage ?? []) - .filter((item) => item.ratio > 0) - .sort((a, b) => b.ratio - a.ratio || b.spent - a.spent) - .slice(0, 8) ?? [] + tagBudgetUsageTop } } diff --git a/apps/api/src/services/statistics.service.ts b/apps/api/src/services/statistics.service.ts index 156cd58..71102ad 100644 --- a/apps/api/src/services/statistics.service.ts +++ b/apps/api/src/services/statistics.service.ts @@ -103,7 +103,7 @@ function buildBudgetEntry(spent: number, budget: number | null | undefined): Bud } } -function buildProjectedMonthlyTrend( +function buildProjectedSeries( subscriptions: StatisticsSubscription[], baseCurrency: string, rates: Awaited>, @@ -111,15 +111,22 @@ function buildProjectedMonthlyTrend( ) { const startMonth = toTimezonedDayjs(startOfMonthDateInTimezone(new Date(), timezone), timezone) const endMonth = startMonth.add(11, 'month').endOf('month') + const startDay = toTimezonedDayjs(startOfDayDateInTimezone(new Date(), timezone), timezone) + const endDay = startDay.add(89, 'day').endOf('day') const monthlyTrendMap = new Map() + const upcomingMap = new Map() for (let index = 0; index < 12; index += 1) { monthlyTrendMap.set(startMonth.add(index, 'month').format('YYYY-MM'), 0) } + for (let index = 0; index < 90; index += 1) { + upcomingMap.set(startDay.add(index, 'day').format('YYYY-MM-DD'), { count: 0, amount: 0 }) + } + const projectedEvents = projectRenewalEvents(subscriptions, { start: startMonth.toDate(), - end: endMonth.toDate(), + end: endDay.isAfter(endMonth) ? endDay.toDate() : endMonth.toDate(), statuses: ['active', 'expired'], timezone }) @@ -128,49 +135,27 @@ function buildProjectedMonthlyTrend( const convertedAmount = convertAmount(event.amount, event.currency, baseCurrency, rates.baseCurrency, rates.rates) const key = monthKeyInTimezone(event.date, timezone) monthlyTrendMap.set(key, (monthlyTrendMap.get(key) ?? 0) + convertedAmount) + + if (!startDay.isAfter(event.date) && !toTimezonedDayjs(event.date, timezone).isAfter(endDay)) { + const dayKey = formatDateInTimezone(event.date, timezone) + const current = upcomingMap.get(dayKey) ?? { count: 0, amount: 0 } + current.count += 1 + current.amount += convertedAmount + upcomingMap.set(dayKey, current) + } } - return Array.from(monthlyTrendMap.entries()).map(([month, amount]) => ({ - month, - amount: Number(amount.toFixed(2)) - })) -} - -function buildUpcomingByDay( - subscriptions: StatisticsSubscription[], - baseCurrency: string, - rates: Awaited>, - timezone: string -) { - const startDay = toTimezonedDayjs(startOfDayDateInTimezone(new Date(), timezone), timezone) - const endDay = startDay.add(89, 'day').endOf('day') - const upcomingMap = new Map() - - for (let index = 0; index < 90; index += 1) { - upcomingMap.set(startDay.add(index, 'day').format('YYYY-MM-DD'), { count: 0, amount: 0 }) + return { + monthlyTrend: Array.from(monthlyTrendMap.entries()).map(([month, amount]) => ({ + month, + amount: Number(amount.toFixed(2)) + })), + upcomingByDay: Array.from(upcomingMap.entries()).map(([date, value]) => ({ + date, + count: value.count, + amount: Number(value.amount.toFixed(2)) + })) } - - const projectedEvents = projectRenewalEvents(subscriptions, { - start: startDay.toDate(), - end: endDay.toDate(), - statuses: ['active', 'expired'], - timezone - }) - - for (const event of projectedEvents) { - const convertedAmount = convertAmount(event.amount, event.currency, baseCurrency, rates.baseCurrency, rates.rates) - const key = formatDateInTimezone(event.date, timezone) - const current = upcomingMap.get(key) ?? { count: 0, amount: 0 } - current.count += 1 - current.amount += convertedAmount - upcomingMap.set(key, current) - } - - return Array.from(upcomingMap.entries()).map(([date, value]) => ({ - date, - count: value.count, - amount: Number(value.amount.toFixed(2)) - })) } async function buildStatisticsState() { @@ -265,8 +250,7 @@ async function buildStatisticsState() { } } - const monthlyTrend = buildProjectedMonthlyTrend(projectedSubscriptions, baseCurrency, rates, timezone) - const upcomingByDay = buildUpcomingByDay(projectedSubscriptions, baseCurrency, rates, timezone) + const { monthlyTrend, upcomingByDay } = buildProjectedSeries(projectedSubscriptions, baseCurrency, rates, timezone) const upcomingRenewals = projectedSubscriptions .filter((subscription) => { @@ -284,13 +268,11 @@ async function buildStatisticsState() { status: item.status })) - const tagLookup = new Map(tags.map((tag) => [tag.id, tag.name])) - const tagBudgetUsage = appSettings.enableTagBudgets ? Object.entries(appSettings.tagBudgets) .flatMap(([tagId, budget]) => { const item = tagBudgetMap.get(tagId) - const name = item?.name ?? tagLookup.get(tagId) + const name = item?.name ?? tags.find((tag) => tag.id === tagId)?.name if (!name) return [] const spent = Number((item?.spent ?? 0).toFixed(2)) @@ -319,21 +301,23 @@ async function buildStatisticsState() { yearly: buildBudgetEntry(yearlyEstimatedBase, appSettings.yearlyBudgetBase) } - const tagBudgetSummary = { - configuredCount: tagBudgetUsage.length, - warningCount: tagBudgetUsage.filter((item) => item.status === 'warning').length, - overBudgetCount: tagBudgetUsage.filter((item) => item.status === 'over').length, - topTags: tagBudgetUsage.slice(0, 3).map((item) => ({ - tagId: item.tagId, - name: item.name, - budget: item.budget, - spent: item.spent, - ratio: item.ratio, - remaining: item.remaining, - overBudget: item.overBudget, - status: item.status - })) - } + const tagBudgetSummary = appSettings.enableTagBudgets + ? { + configuredCount: tagBudgetUsage.length, + warningCount: tagBudgetUsage.filter((item) => item.status === 'warning').length, + overBudgetCount: tagBudgetUsage.filter((item) => item.status === 'over').length, + topTags: tagBudgetUsage.slice(0, 3).map((item) => ({ + tagId: item.tagId, + name: item.name, + budget: item.budget, + spent: item.spent, + ratio: item.ratio, + remaining: item.remaining, + overBudget: item.overBudget, + status: item.status + })) + } + : null return { appSettings, diff --git a/apps/api/tests/unit/ai-summary.service.test.ts b/apps/api/tests/unit/ai-summary.service.test.ts index 66b4fee..4915c47 100644 --- a/apps/api/tests/unit/ai-summary.service.test.ts +++ b/apps/api/tests/unit/ai-summary.service.test.ts @@ -358,4 +358,74 @@ describe('ai summary service', () => { expect(requestBody.messages[0].content).toContain('专门输出统计摘要的助手') expect(requestBody.messages[0].content).not.toContain('只返回 JSON') }) + + it('shrinks large summary arrays before sending them to AI', async () => { + const fetchMock = vi.fn(async () => + jsonResponse({ + choices: [ + { + message: { + content: '## 总览\n- 已缩减输入体积' + } + } + ] + }) + ) + vi.stubGlobal('fetch', fetchMock) + aiSummaryMocks.getOverviewStatisticsMock.mockResolvedValue( + buildOverview({ + tagSpend: Array.from({ length: 10 }, (_, index) => ({ name: `tag-${index}`, value: 10 - index })), + renewalModeDistribution: [ + { autoRenew: true, count: 4, amount: 120 }, + { autoRenew: false, count: 0, amount: 0 } + ], + currencyDistribution: Array.from({ length: 10 }, (_, index) => ({ currency: `C${index}`, amount: 20 - index })), + topSubscriptionsByMonthlyCost: Array.from({ length: 10 }, (_, index) => ({ + id: `top-${index}`, + name: `Top ${index}`, + amount: index + 1, + currency: 'CNY', + monthlyAmountBase: index + 1, + baseCurrency: 'CNY' + })), + upcomingRenewals: Array.from({ length: 10 }, (_, index) => ({ + id: `up-${index}`, + name: `Upcoming ${index}`, + nextRenewalDate: '2026-05-04', + amount: index + 1, + currency: 'CNY', + convertedAmount: index + 1, + status: 'active' + })), + upcomingByDay: [ + { date: '2026-05-03', count: 0, amount: 0 }, + ...Array.from({ length: 12 }, (_, index) => ({ date: `2026-05-${String(index + 4).padStart(2, '0')}`, count: 1, amount: index + 1 })) + ], + tagBudgetUsage: Array.from({ length: 10 }, (_, index) => ({ + tagId: `tb-${index}`, + name: `tag-budget-${index}`, + budget: 100, + spent: 90 - index, + ratio: 0.9 - index * 0.01, + remaining: 10 + index, + overBudget: 0, + status: 'warning' as const + })) + }) + ) + + await generateDashboardAiSummary() + + const requestBody = JSON.parse(String((((fetchMock.mock.calls[0] as unknown) as [unknown, RequestInit])[1])?.body)) + const content = String(requestBody.messages[1].content) + const payload = JSON.parse(content.slice(content.indexOf('{'))) + + expect(payload.tagSpendTop).toHaveLength(6) + expect(payload.renewalModeDistribution).toHaveLength(1) + expect(payload.currencyDistribution).toHaveLength(8) + expect(payload.upcomingRenewalsTop).toHaveLength(6) + expect(payload.topSubscriptionsByMonthlyCost).toHaveLength(6) + expect(payload.upcomingByDayNonZero).toHaveLength(10) + expect(payload.tagBudgetUsageTop).toHaveLength(6) + }) }) diff --git a/apps/api/tests/unit/statistics.service.test.ts b/apps/api/tests/unit/statistics.service.test.ts index 4e53652..d4da26c 100644 --- a/apps/api/tests/unit/statistics.service.test.ts +++ b/apps/api/tests/unit/statistics.service.test.ts @@ -200,4 +200,48 @@ describe('statistics service', () => { }) ]) }) + + it('keeps overview dto fields for dashboard and statistics pages', async () => { + statisticsSettings.enableTagBudgets = true + statisticsSettings.tagBudgets = { tag_video: 100 } + findManyTagsMock.mockResolvedValue([{ id: 'tag_video', name: 'Video' }]) + findManySubscriptionsMock.mockResolvedValue([ + createSubscription('active-video', { + amount: 40, + tags: [{ tag: { id: 'tag_video', name: 'Video', color: '#3b82f6', icon: 'apps-outline', sortOrder: 0 } }] + }) + ]) + + const result = await getOverviewStatistics() + + expect(result).toMatchObject({ + activeSubscriptions: expect.any(Number), + upcoming7Days: expect.any(Number), + upcoming30Days: expect.any(Number), + monthlyEstimatedBase: expect.any(Number), + yearlyEstimatedBase: expect.any(Number), + budgetSummary: { + monthly: expect.any(Object), + yearly: expect.any(Object) + }, + statusDistribution: expect.any(Array), + renewalModeDistribution: expect.any(Array), + currencyDistribution: expect.any(Array), + topSubscriptionsByMonthlyCost: expect.any(Array), + upcomingRenewals: expect.any(Array) + }) + expect(Array.isArray(result.monthlyTrend)).toBe(true) + expect(Array.isArray(result.upcomingByDay)).toBe(true) + expect(result.monthlyTrendMeta).toEqual({ + mode: 'projected', + months: 12 + }) + expect(result.tagBudgetSummary).not.toBeNull() + expect(result.tagBudgetUsage).toEqual([ + expect.objectContaining({ + tagId: 'tag_video', + budget: 100 + }) + ]) + }) })