perf: trim main statistics and ai summary inputs

- keep the existing overview DTO surface while reducing duplicated projected statistics work under the hood

- shrink dashboard AI summary inputs to top and non-empty slices so generated copy stays lighter without changing the response contract

- add regression coverage for overview field compatibility and the reduced AI summary payload shape
This commit is contained in:
SmileQWQ
2026-05-16 20:47:23 +08:00
parent 080e8c81c6
commit 3edff87727
4 changed files with 186 additions and 75 deletions

View File

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

View File

@@ -103,7 +103,7 @@ function buildBudgetEntry(spent: number, budget: number | null | undefined): Bud
}
}
function buildProjectedMonthlyTrend(
function buildProjectedSeries(
subscriptions: StatisticsSubscription[],
baseCurrency: string,
rates: Awaited<ReturnType<typeof ensureExchangeRates>>,
@@ -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<string, number>()
const upcomingMap = new Map<string, { count: number; amount: number }>()
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<ReturnType<typeof ensureExchangeRates>>,
timezone: string
) {
const startDay = toTimezonedDayjs(startOfDayDateInTimezone(new Date(), timezone), timezone)
const endDay = startDay.add(89, 'day').endOf('day')
const upcomingMap = new Map<string, { count: number; amount: number }>()
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<TagBudgetUsageEntry>(([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,

View File

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

View File

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