diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 04d398e..8de2c52 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -54,6 +54,8 @@ model Subscription { startDate DateTime nextRenewalDate DateTime notifyDaysBefore Int @default(3) + advanceReminderRules String? + overdueReminderRules String? webhookEnabled Boolean @default(true) notes String @default("") tags SubscriptionTag[] diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts index 2bae51c..2f8ecdd 100644 --- a/apps/api/prisma/seed.ts +++ b/apps/api/prisma/seed.ts @@ -1,4 +1,5 @@ import { PrismaClient } from '@prisma/client' +import { DEFAULT_ADVANCE_REMINDER_RULES, DEFAULT_OVERDUE_REMINDER_RULES } from '@subtracker/shared' const prisma = new PrismaClient() @@ -15,6 +16,12 @@ async function main() { create: { key: 'defaultNotifyDays', valueJson: 3 } }) + await prisma.setting.upsert({ + where: { key: 'defaultAdvanceReminderRules' }, + update: { valueJson: DEFAULT_ADVANCE_REMINDER_RULES }, + create: { key: 'defaultAdvanceReminderRules', valueJson: DEFAULT_ADVANCE_REMINDER_RULES } + }) + await prisma.setting.upsert({ where: { key: 'notifyOnDueDay' }, update: { valueJson: true }, @@ -27,6 +34,12 @@ async function main() { create: { key: 'overdueReminderDays', valueJson: [1, 2, 3] } }) + await prisma.setting.upsert({ + where: { key: 'defaultOverdueReminderRules' }, + update: { valueJson: DEFAULT_OVERDUE_REMINDER_RULES }, + create: { key: 'defaultOverdueReminderRules', valueJson: DEFAULT_OVERDUE_REMINDER_RULES } + }) + const defaults = [ { name: '开发工具', color: '#4f46e5', icon: 'code-slash-outline', sortOrder: 1 }, { name: '影音娱乐', color: '#ef4444', icon: 'film-outline', sortOrder: 2 }, diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts index da9e715..fcf932f 100644 --- a/apps/api/src/config.ts +++ b/apps/api/src/config.ts @@ -6,6 +6,6 @@ export const config = { defaultNotifyDays: Number(process.env.DEFAULT_NOTIFY_DAYS ?? 3), exchangeRateProvider: process.env.EXCHANGE_RATE_PROVIDER ?? 'er-api', exchangeRateUrl: process.env.EXCHANGE_RATE_URL ?? 'https://open.er-api.com/v6/latest', - cronScan: process.env.CRON_SCAN ?? '0 */3 * * *', + cronScan: process.env.CRON_SCAN ?? '* * * * *', cronRefreshRates: process.env.CRON_REFRESH_RATES ?? '0 2 * * *' } diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts index 928249e..c30931b 100644 --- a/apps/api/src/routes/notifications.ts +++ b/apps/api/src/routes/notifications.ts @@ -125,4 +125,5 @@ export async function notificationRoutes(app: FastifyInstance) { return sendError(reply, 400, 'webhook_test_failed', error instanceof Error ? error.message : 'Webhook test failed') } }) + } diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 753dfed..55b7992 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -1,8 +1,20 @@ import { FastifyInstance } from 'fastify' -import { SettingsSchema } from '@subtracker/shared' +import { + DEFAULT_ADVANCE_REMINDER_RULES, + DEFAULT_OVERDUE_REMINDER_RULES, + SettingsSchema +} from '@subtracker/shared' import { prisma } from '../db' import { sendError, sendOk } from '../http' import { getAppSettings, setSetting } from '../services/settings.service' +import { + buildAdvanceReminderRulesFromLegacy, + buildOverdueReminderRules, + deriveNotifyDaysBeforeFromAdvanceRules, + deriveNotifyOnDueDayFromAdvanceRules, + deriveOverdueReminderDaysFromRules, + normalizeReminderRules +} from '../services/reminder-rules.service' function validateSettingsPayload(settings: Awaited>) { const missingEmailFields = [ @@ -49,6 +61,36 @@ function validateSettingsPayload(settings: Awaited>>, + currentSettings: Awaited> +) { + const nextSettings = { + defaultAdvanceReminderRules: + payload.defaultAdvanceReminderRules !== undefined + ? normalizeReminderRules(payload.defaultAdvanceReminderRules, 'advance') + : payload.defaultNotifyDays !== undefined || payload.notifyOnDueDay !== undefined + ? buildAdvanceReminderRulesFromLegacy( + payload.defaultNotifyDays ?? currentSettings.defaultNotifyDays, + payload.notifyOnDueDay ?? currentSettings.notifyOnDueDay + ) || DEFAULT_ADVANCE_REMINDER_RULES + : currentSettings.defaultAdvanceReminderRules, + defaultOverdueReminderRules: + payload.defaultOverdueReminderRules !== undefined + ? normalizeReminderRules(payload.defaultOverdueReminderRules, 'overdue') + : payload.overdueReminderDays !== undefined + ? buildOverdueReminderRules(payload.overdueReminderDays) || DEFAULT_OVERDUE_REMINDER_RULES + : currentSettings.defaultOverdueReminderRules + } + + return { + ...nextSettings, + defaultNotifyDays: deriveNotifyDaysBeforeFromAdvanceRules(nextSettings.defaultAdvanceReminderRules), + notifyOnDueDay: deriveNotifyOnDueDayFromAdvanceRules(nextSettings.defaultAdvanceReminderRules), + overdueReminderDays: deriveOverdueReminderDaysFromRules(nextSettings.defaultOverdueReminderRules) + } +} + export async function settingsRoutes(app: FastifyInstance) { app.get('/settings/export/subscriptions', async (request, reply) => { const formatValue = (request.query as { format?: string } | undefined)?.format @@ -85,6 +127,8 @@ export async function settingsRoutes(app: FastifyInstance) { startDate: subscription.startDate.toISOString().slice(0, 10), nextRenewalDate: subscription.nextRenewalDate.toISOString().slice(0, 10), notifyDaysBefore: subscription.notifyDaysBefore, + advanceReminderRules: subscription.advanceReminderRules ?? '', + overdueReminderRules: subscription.overdueReminderRules ?? '', webhookEnabled: subscription.webhookEnabled, notes: subscription.notes, tags: subscription.tags @@ -128,6 +172,8 @@ export async function settingsRoutes(app: FastifyInstance) { 'startDate', 'nextRenewalDate', 'notifyDaysBefore', + 'advanceReminderRules', + 'overdueReminderRules', 'webhookEnabled', 'notes', 'tags', @@ -154,6 +200,8 @@ export async function settingsRoutes(app: FastifyInstance) { row.startDate, row.nextRenewalDate, row.notifyDaysBefore, + row.advanceReminderRules, + row.overdueReminderRules, row.webhookEnabled, row.notes, row.tags.map((tag) => tag.name).join(', '), @@ -182,9 +230,18 @@ export async function settingsRoutes(app: FastifyInstance) { } const currentSettings = await getAppSettings() + let normalizedReminderSettings: ReturnType + + try { + normalizedReminderSettings = normalizeReminderSettingsPayload(parsed.data, currentSettings) + } catch (error) { + return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid reminder rules') + } + const nextSettings = { ...currentSettings, ...parsed.data, + ...normalizedReminderSettings, emailConfig: parsed.data.emailConfig ? { ...currentSettings.emailConfig, ...parsed.data.emailConfig } : currentSettings.emailConfig, pushplusConfig: parsed.data.pushplusConfig ? { ...currentSettings.pushplusConfig, ...parsed.data.pushplusConfig } : currentSettings.pushplusConfig, telegramConfig: parsed.data.telegramConfig @@ -208,12 +265,26 @@ export async function settingsRoutes(app: FastifyInstance) { return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid settings payload') } - await Promise.all( - Object.entries(parsed.data).map(([key, value]) => { - return value === undefined ? Promise.resolve() : setSetting(key, value) - }) + const settingsToPersist: Array<[string, unknown]> = Object.entries(parsed.data).filter(([, value]) => value !== undefined) + const reminderRelatedKeys = new Set([ + 'defaultNotifyDays', + 'notifyOnDueDay', + 'overdueReminderDays', + 'defaultAdvanceReminderRules', + 'defaultOverdueReminderRules' + ]) + + const filteredEntries = settingsToPersist.filter(([key]) => !reminderRelatedKeys.has(key)) + filteredEntries.push( + ['defaultAdvanceReminderRules', normalizedReminderSettings.defaultAdvanceReminderRules], + ['defaultOverdueReminderRules', normalizedReminderSettings.defaultOverdueReminderRules], + ['defaultNotifyDays', normalizedReminderSettings.defaultNotifyDays], + ['notifyOnDueDay', normalizedReminderSettings.notifyOnDueDay], + ['overdueReminderDays', normalizedReminderSettings.overdueReminderDays] ) + await Promise.all(filteredEntries.map(([key, value]) => setSetting(key, value))) + const settings = await getAppSettings() return sendOk(reply, settings) }) diff --git a/apps/api/src/routes/subscriptions.ts b/apps/api/src/routes/subscriptions.ts index 2bf4e7f..48bd3dc 100644 --- a/apps/api/src/routes/subscriptions.ts +++ b/apps/api/src/routes/subscriptions.ts @@ -26,11 +26,48 @@ import { saveUploadedLogo, searchSubscriptionLogos } from '../services/logo.service' +import { + buildAdvanceReminderRulesFromLegacyWithDefault, + deriveNotifyDaysBeforeFromAdvanceRules, + normalizeOptionalReminderRules +} from '../services/reminder-rules.service' +import { getAppSettings } from '../services/settings.service' const subscriptionInclude = { tags: { include: { tag: true } } } as const +async function resolveSubscriptionReminderFields(payload: { + advanceReminderRules?: string | null + overdueReminderRules?: string | null + notifyDaysBefore?: number +}) { + const settings = await getAppSettings() + + const normalizedAdvanceReminderRules = + payload.advanceReminderRules !== undefined + ? normalizeOptionalReminderRules(payload.advanceReminderRules, 'advance') + : payload.notifyDaysBefore !== undefined + ? buildAdvanceReminderRulesFromLegacyWithDefault(payload.notifyDaysBefore, settings.defaultAdvanceReminderRules) + : undefined + + const normalizedOverdueReminderRules = + payload.overdueReminderRules !== undefined + ? normalizeOptionalReminderRules(payload.overdueReminderRules, 'overdue') + : undefined + + const derivedNotifyDaysBefore = + normalizedAdvanceReminderRules !== undefined + ? deriveNotifyDaysBeforeFromAdvanceRules(normalizedAdvanceReminderRules || settings.defaultAdvanceReminderRules) + : payload.notifyDaysBefore + + return { + advanceReminderRules: normalizedAdvanceReminderRules, + overdueReminderRules: normalizedOverdueReminderRules, + notifyDaysBefore: derivedNotifyDaysBefore + } +} + function parseBatchIds(input: unknown) { return z .object({ @@ -354,6 +391,13 @@ export async function subscriptionRoutes(app: FastifyInstance) { } const tagIds = normalizeTagIds(parsed.data.tagIds) + let reminderFields: Awaited> + + try { + reminderFields = await resolveSubscriptionReminderFields(parsed.data) + } catch (error) { + return sendError(reply, 422, 'validation_error', error instanceof Error ? error.message : 'Invalid reminder rules') + } const created = await prisma.$transaction(async (tx) => { const subscription = await tx.subscription.create({ @@ -367,7 +411,13 @@ export async function subscriptionRoutes(app: FastifyInstance) { autoRenew: parsed.data.autoRenew, startDate: dayjs(parsed.data.startDate).toDate(), nextRenewalDate: dayjs(parsed.data.nextRenewalDate).toDate(), - notifyDaysBefore: parsed.data.notifyDaysBefore, + notifyDaysBefore: reminderFields.notifyDaysBefore ?? parsed.data.notifyDaysBefore, + ...(reminderFields.advanceReminderRules !== undefined + ? { advanceReminderRules: reminderFields.advanceReminderRules } + : {}), + ...(reminderFields.overdueReminderRules !== undefined + ? { overdueReminderRules: reminderFields.overdueReminderRules } + : {}), webhookEnabled: parsed.data.webhookEnabled, notes: parsed.data.notes, websiteUrl: parsed.data.websiteUrl ?? null, @@ -402,6 +452,7 @@ export async function subscriptionRoutes(app: FastifyInstance) { const payload = parsed.data try { + const reminderFields = await resolveSubscriptionReminderFields(payload) const normalizedLogo = payload.logoUrl !== undefined || payload.logoSource !== undefined ? await normalizeLogoForStorage({ @@ -426,7 +477,13 @@ export async function subscriptionRoutes(app: FastifyInstance) { ...(payload.autoRenew !== undefined ? { autoRenew: payload.autoRenew } : {}), ...(payload.startDate !== undefined ? { startDate: dayjs(payload.startDate).toDate() } : {}), ...(payload.nextRenewalDate !== undefined ? { nextRenewalDate: dayjs(payload.nextRenewalDate).toDate() } : {}), - ...(payload.notifyDaysBefore !== undefined ? { notifyDaysBefore: payload.notifyDaysBefore } : {}), + ...(reminderFields.notifyDaysBefore !== undefined ? { notifyDaysBefore: reminderFields.notifyDaysBefore } : {}), + ...(reminderFields.advanceReminderRules !== undefined + ? { advanceReminderRules: reminderFields.advanceReminderRules } + : {}), + ...(reminderFields.overdueReminderRules !== undefined + ? { overdueReminderRules: reminderFields.overdueReminderRules } + : {}), ...(payload.webhookEnabled !== undefined ? { webhookEnabled: payload.webhookEnabled } : {}), ...(payload.notes !== undefined ? { notes: payload.notes } : {}), ...(payload.websiteUrl !== undefined ? { websiteUrl: payload.websiteUrl } : {}), diff --git a/apps/api/src/services/notification.service.ts b/apps/api/src/services/notification.service.ts index 685468e..38859ed 100644 --- a/apps/api/src/services/notification.service.ts +++ b/apps/api/src/services/notification.service.ts @@ -1,10 +1,15 @@ -import dayjs from 'dayjs' +import dayjs from 'dayjs' import { prisma } from '../db' import { toIsoDate } from '../utils/date' import { dispatchNotificationEvent, type NotificationChannelResult } from './channel-notification.service' +import { + buildAdvanceReminderRulesFromLegacyWithDefault, + parseReminderRules, + type ReminderRule +} from './reminder-rules.service' import { getAppSettings } from './settings.service' -export type ReminderPhase = 'upcoming' | 'due_today' | 'overdue_day_1' | 'overdue_day_2' | 'overdue_day_3' +export type ReminderPhase = 'upcoming' | 'due_today' | `overdue_day_${number}` export type NotificationScanResult = { processedCount: number @@ -20,16 +25,17 @@ export type NotificationScanResult = { }> } -type ReminderRuleSettings = { - notifyOnDueDay: boolean - overdueReminderDays: Array<1 | 2 | 3> -} +export type NotificationScanOverrides = Partial< + Pick>, 'defaultAdvanceReminderRules' | 'defaultOverdueReminderRules' | 'mergeMultiSubscriptionNotifications'> +> type ReminderSubscriptionLike = { id: string name: string nextRenewalDate: Date notifyDaysBefore: number + advanceReminderRules: string | null + overdueReminderRules: string | null amount: number currency: string status: string @@ -53,14 +59,17 @@ type ReminderEntryPayload = { tagNames: string[] websiteUrl: string notes: string - phase: 'upcoming' | 'due_today' | 'overdue' + phase: 'upcoming' | 'due_today' | 'overdue' | 'summary' daysUntilRenewal: number daysOverdue: number + reminderRuleTime: string + reminderRuleDays: number } type ReminderDispatchEntry = { eventType: 'subscription.reminder_due' | 'subscription.overdue' phase: ReminderPhase + title: string resourceKey: string periodKey: string subscriptionId: string @@ -74,73 +83,136 @@ type ReminderSummarySection = { subscriptions: ReminderEntryPayload[] } +type ReminderMatch = { + eventType: 'subscription.reminder_due' | 'subscription.overdue' + phase: ReminderPhase + title: string + daysUntilRenewal: number + daysOverdue: number + ruleTime: string + ruleKey: string +} + +function getOverduePhase(daysOverdue: number): ReminderPhase { + return `overdue_day_${daysOverdue}` +} + +function buildReminderTitle(eventType: 'subscription.reminder_due' | 'subscription.overdue', days: number) { + if (eventType === 'subscription.reminder_due') { + return days === 0 ? '今天到期' : `还有 ${days} 天到期` + } + + return `已过期第 ${days} 天` +} + function getSummaryPhaseTitle(phase: ReminderPhase) { - switch (phase) { - case 'upcoming': - return '即将到期' - case 'due_today': - return '今天到期' - case 'overdue_day_1': - return '已过期第 1 天' - case 'overdue_day_2': - return '已过期第 2 天' - case 'overdue_day_3': - return '已过期第 3 天' - default: - return phase + if (phase === 'upcoming') return '即将到期' + if (phase === 'due_today') return '今天到期' + + const overdueMatch = phase.match(/^overdue_day_(\d+)$/) + if (overdueMatch) { + return `已过期第 ${overdueMatch[1]} 天` } + + return phase } -export function resolveReminderPhase( - today: Date, +function resolveAdvanceRules(sub: ReminderSubscriptionLike, defaultAdvanceReminderRules: string) { + if (sub.advanceReminderRules === '') { + return parseReminderRules(defaultAdvanceReminderRules, 'advance') + } + + if (sub.advanceReminderRules?.trim()) { + return parseReminderRules(sub.advanceReminderRules, 'advance') + } + + const legacyRules = buildAdvanceReminderRulesFromLegacyWithDefault(sub.notifyDaysBefore, defaultAdvanceReminderRules) + return parseReminderRules(legacyRules || defaultAdvanceReminderRules, 'advance') +} + +function resolveOverdueRules(sub: ReminderSubscriptionLike, defaultOverdueReminderRules: string) { + if (sub.overdueReminderRules === '') { + return parseReminderRules(defaultOverdueReminderRules, 'overdue') + } + + if (sub.overdueReminderRules?.trim()) { + return parseReminderRules(sub.overdueReminderRules, 'overdue') + } + + return parseReminderRules(defaultOverdueReminderRules, 'overdue') +} + +function resolveRuleTriggerMoment(nextRenewalDate: Date, rule: ReminderRule, direction: 'advance' | 'overdue') { + const renewalDay = dayjs(nextRenewalDate).startOf('day') + const base = direction === 'advance' ? renewalDay.subtract(rule.days, 'day') : renewalDay.add(rule.days, 'day') + return base.hour(rule.hour).minute(rule.minute).second(0).millisecond(0) +} + +function matchReminderRule( + now: dayjs.Dayjs, nextRenewalDate: Date, - notifyDaysBefore: number, - settings: ReminderRuleSettings = { - notifyOnDueDay: true, - overdueReminderDays: [1, 2, 3] + rule: ReminderRule, + direction: 'advance' | 'overdue' +): ReminderMatch | null { + const trigger = resolveRuleTriggerMoment(nextRenewalDate, rule, direction) + if (!now.isSame(trigger, 'minute')) { + return null } -): { eventType: 'subscription.reminder_due' | 'subscription.overdue'; phase: ReminderPhase } | null { - const todayStart = dayjs(today).startOf('day') - const renewalStart = dayjs(nextRenewalDate).startOf('day') - const diffDays = todayStart.diff(renewalStart, 'day') - if (diffDays === 0 && settings.notifyOnDueDay) { + if (direction === 'advance') { return { eventType: 'subscription.reminder_due', - phase: 'due_today' + phase: rule.days === 0 ? 'due_today' : 'upcoming', + title: buildReminderTitle('subscription.reminder_due', rule.days), + daysUntilRenewal: rule.days, + daysOverdue: 0, + ruleTime: rule.time, + ruleKey: `advance-${rule.days}@${rule.time}` } } - if (notifyDaysBefore > 0 && diffDays === -Math.max(notifyDaysBefore, 0)) { - return { - eventType: 'subscription.reminder_due', - phase: 'upcoming' - } + return { + eventType: 'subscription.overdue', + phase: getOverduePhase(rule.days), + title: buildReminderTitle('subscription.overdue', rule.days), + daysUntilRenewal: 0, + daysOverdue: rule.days, + ruleTime: rule.time, + ruleKey: `overdue-${rule.days}@${rule.time}` } - - if (diffDays >= 1 && diffDays <= 3 && settings.overdueReminderDays.includes(diffDays as 1 | 2 | 3)) { - return { - eventType: 'subscription.overdue', - phase: `overdue_day_${diffDays}` as ReminderPhase - } - } - - return null } -function buildDispatchEntry( +function resolveReminderMatches( + now: dayjs.Dayjs, sub: ReminderSubscriptionLike, - currentDay: Date, - resolved: NonNullable> -): ReminderDispatchEntry { - const daysOverdue = Math.max(dayjs(currentDay).diff(dayjs(sub.nextRenewalDate).startOf('day'), 'day'), 0) - const daysUntilRenewal = Math.max(dayjs(sub.nextRenewalDate).startOf('day').diff(dayjs(currentDay), 'day'), 0) + settings: Awaited> +) { + const matches: ReminderMatch[] = [] + for (const rule of resolveAdvanceRules(sub, settings.defaultAdvanceReminderRules)) { + const match = matchReminderRule(now, sub.nextRenewalDate, rule, 'advance') + if (match) { + matches.push(match) + } + } + + for (const rule of resolveOverdueRules(sub, settings.defaultOverdueReminderRules)) { + const match = matchReminderRule(now, sub.nextRenewalDate, rule, 'overdue') + if (match) { + matches.push(match) + } + } + + return matches +} + +function buildDispatchEntry(sub: ReminderSubscriptionLike, resolved: ReminderMatch): ReminderDispatchEntry { return { eventType: resolved.eventType, phase: resolved.phase, + title: resolved.title, resourceKey: `subscription:${sub.id}`, - periodKey: `${toIsoDate(sub.nextRenewalDate)}:${resolved.phase}`, + periodKey: `${toIsoDate(sub.nextRenewalDate)}:${resolved.phase}:${resolved.ruleKey}`, subscriptionId: sub.id, payload: { id: sub.id, @@ -149,13 +221,15 @@ function buildDispatchEntry( notifyDaysBefore: sub.notifyDaysBefore, amount: sub.amount, currency: sub.currency, - status: daysOverdue > 0 ? 'expired' : sub.status, + status: resolved.daysOverdue > 0 ? 'expired' : sub.status, tagNames: sub.tags.map((item) => item.tag.name), websiteUrl: sub.websiteUrl ?? '', notes: sub.notes ?? '', - phase: resolved.phase === 'upcoming' ? 'upcoming' : resolved.phase === 'due_today' ? 'due_today' : 'overdue', - daysUntilRenewal, - daysOverdue + phase: resolved.eventType === 'subscription.overdue' ? 'overdue' : resolved.phase === 'due_today' ? 'due_today' : 'upcoming', + daysUntilRenewal: resolved.daysUntilRenewal, + daysOverdue: resolved.daysOverdue, + reminderRuleTime: resolved.ruleTime, + reminderRuleDays: resolved.eventType === 'subscription.overdue' ? resolved.daysOverdue : resolved.daysUntilRenewal } } } @@ -179,8 +253,15 @@ function buildMergedSummarySections(entries: ReminderDispatchEntry[]): ReminderS } return Array.from(groups.values()).sort((a, b) => { - const order: ReminderPhase[] = ['upcoming', 'due_today', 'overdue_day_1', 'overdue_day_2', 'overdue_day_3'] - return order.indexOf(a.phase) - order.indexOf(b.phase) + const phaseWeight = (phase: ReminderPhase) => { + if (phase === 'upcoming') return 1 + if (phase === 'due_today') return 2 + const overdueMatch = phase.match(/^overdue_day_(\d+)$/) + if (overdueMatch) return 100 + Number(overdueMatch[1]) + return 999 + } + + return phaseWeight(a.phase) - phaseWeight(b.phase) }) } @@ -205,12 +286,20 @@ function buildMergedPayload(entries: ReminderDispatchEntry[]) { phase: 'summary', daysUntilRenewal: Math.min(...flattenedSubscriptions.map((item) => item.daysUntilRenewal)), daysOverdue: Math.max(...flattenedSubscriptions.map((item) => item.daysOverdue)), + reminderRuleTime: flattenedSubscriptions[0]?.reminderRuleTime ?? '00:00', + reminderRuleDays: flattenedSubscriptions[0]?.reminderRuleDays ?? 0, subscriptions: flattenedSubscriptions } } -export async function scanRenewalNotifications(today = new Date()): Promise { - const appSettings = await getAppSettings() +export async function scanRenewalNotifications( + today = new Date(), + overrides: NotificationScanOverrides = {} +): Promise { + const appSettings = { + ...(await getAppSettings()), + ...overrides + } const subscriptions = await prisma.subscription.findMany({ where: { status: { in: ['active', 'expired'] }, @@ -225,12 +314,13 @@ export async function scanRenewalNotifications(today = new Date()): Promise= 1 && sub.status !== 'expired') { await prisma.subscription.update({ where: { id: sub.id }, @@ -238,13 +328,10 @@ export async function scanRenewalNotifications(today = new Date()): Promise item.trim()) + if (!rawDays || !rawTime || rest.length > 0) { + throw new Error(`规则 "${segment}" 格式无效,应为 天数&HH:mm`) + } + + if (!/^\d+$/.test(rawDays)) { + throw new Error(`规则 "${segment}" 中的天数必须为整数`) + } + + const days = Number(rawDays) + if (!Number.isInteger(days)) { + throw new Error(`规则 "${segment}" 中的天数必须为整数`) + } + + if (kind === 'advance' && days < 0) { + throw new Error(`规则 "${segment}" 中的天数不能小于 0`) + } + + if (kind === 'overdue' && days < 1) { + throw new Error(`规则 "${segment}" 中的天数必须大于等于 1`) + } + + const timeMatch = rawTime.match(TIME_PATTERN) + if (!timeMatch) { + throw new Error(`规则 "${segment}" 中的时间必须为 HH:mm`) + } + + return { + days, + time: rawTime, + hour: Number(timeMatch[1]), + minute: Number(timeMatch[2]) + } +} + +function compareRules(a: ReminderRule, b: ReminderRule, kind: ReminderRuleKind) { + if (a.days !== b.days) { + return kind === 'advance' ? b.days - a.days : a.days - b.days + } + return a.time.localeCompare(b.time) +} + +function dedupeRules(rules: ReminderRule[]) { + const seen = new Set() + const result: ReminderRule[] = [] + + for (const rule of rules) { + const key = `${rule.days}&${rule.time}` + if (seen.has(key)) continue + seen.add(key) + result.push(rule) + } + + return result +} + +export function parseReminderRules(value: string, kind: ReminderRuleKind) { + const compact = value.replace(/\s+/g, '') + if (!compact) return [] + + const segments = compact + .split(';') + .map((item) => item.trim()) + .filter(Boolean) + + const parsed = dedupeRules(segments.map((segment) => parseRuleSegment(segment, kind))) + parsed.sort((a, b) => compareRules(a, b, kind)) + return parsed +} + +export function normalizeReminderRules(value: string, kind: ReminderRuleKind) { + const parsed = parseReminderRules(value, kind) + return parsed.map((rule) => `${rule.days}&${rule.time};`).join('') +} + +export function normalizeOptionalReminderRules(value: string | null | undefined, kind: ReminderRuleKind) { + if (value === undefined || value === null) return null + const trimmed = value.trim() + return trimmed ? normalizeReminderRules(trimmed, kind) : '' +} + +export function buildAdvanceReminderRulesFromLegacy( + notifyDaysBefore: number, + includeDueDay: boolean, + dueDayTime = DEFAULT_REMINDER_RULE_TIME +) { + const segments: string[] = [] + + if (notifyDaysBefore > 0) { + segments.push(`${notifyDaysBefore}&${dueDayTime}`) + } + + if (includeDueDay) { + segments.push(`0&${dueDayTime}`) + } + + return normalizeReminderRules(segments.join(';'), 'advance') +} + +export function buildAdvanceReminderRulesFromLegacyWithDefault( + notifyDaysBefore: number, + defaultAdvanceReminderRules: string +) { + const dueDayRules = parseReminderRules(defaultAdvanceReminderRules, 'advance') + .filter((rule) => rule.days === 0) + .map((rule) => `${rule.days}&${rule.time}`) + + const segments = [ + ...(notifyDaysBefore > 0 ? [`${notifyDaysBefore}&${DEFAULT_REMINDER_RULE_TIME}`] : []), + ...dueDayRules + ] + + if (!segments.length) return '' + return normalizeReminderRules(segments.join(';'), 'advance') +} + +export function buildOverdueReminderRules(days: number[], time = DEFAULT_REMINDER_RULE_TIME) { + const segments = days.filter((day) => day >= 1).map((day) => `${day}&${time}`) + return segments.length ? normalizeReminderRules(segments.join(';'), 'overdue') : '' +} + +export function deriveNotifyDaysBeforeFromAdvanceRules(value: string | null | undefined) { + const rules = value ? parseReminderRules(value, 'advance') : [] + const firstPositive = rules.find((rule) => rule.days > 0) + return firstPositive?.days ?? 0 +} + +export function deriveNotifyOnDueDayFromAdvanceRules(value: string | null | undefined) { + return value ? parseReminderRules(value, 'advance').some((rule) => rule.days === 0) : false +} + +export function deriveOverdueReminderDaysFromRules(value: string | null | undefined) { + const days = value ? parseReminderRules(value, 'overdue').map((rule) => rule.days) : [] + return Array.from(new Set(days.filter((day): day is 1 | 2 | 3 => day === 1 || day === 2 || day === 3))).sort( + (a, b) => a - b + ) +} + +export function resolveDefaultAdvanceReminderRules( + storedAdvanceRules?: string | null, + legacyNotifyDays = 3, + legacyNotifyOnDueDay = true +) { + if (storedAdvanceRules && storedAdvanceRules.trim()) { + return normalizeReminderRules(storedAdvanceRules, 'advance') + } + + const built = buildAdvanceReminderRulesFromLegacy(legacyNotifyDays, legacyNotifyOnDueDay) + return built || DEFAULT_ADVANCE_REMINDER_RULES +} + +export function resolveDefaultOverdueReminderRules(storedOverdueRules?: string | null, legacyOverdueDays: number[] = [1, 2, 3]) { + if (storedOverdueRules && storedOverdueRules.trim()) { + return normalizeReminderRules(storedOverdueRules, 'overdue') + } + + const built = buildOverdueReminderRules(legacyOverdueDays) + return built || DEFAULT_OVERDUE_REMINDER_RULES +} diff --git a/apps/api/src/services/settings.service.ts b/apps/api/src/services/settings.service.ts index 96a9c6d..66ce712 100644 --- a/apps/api/src/services/settings.service.ts +++ b/apps/api/src/services/settings.service.ts @@ -1,6 +1,18 @@ -import { AiConfigSchema, DEFAULT_AI_CONFIG, SettingsSchema, type SettingsInput } from '@subtracker/shared' +import { + AiConfigSchema, + DEFAULT_AI_CONFIG, + SettingsSchema, + type SettingsInput +} from '@subtracker/shared' import { prisma } from '../db' import { config } from '../config' +import { + deriveNotifyDaysBeforeFromAdvanceRules, + deriveNotifyOnDueDayFromAdvanceRules, + deriveOverdueReminderDaysFromRules, + resolveDefaultAdvanceReminderRules, + resolveDefaultOverdueReminderRules +} from './reminder-rules.service' export async function getSetting(key: string, fallback: T): Promise { const row = await prisma.setting.findUnique({ where: { key } }) @@ -19,13 +31,22 @@ export async function setSetting(key: string, value: T): Promise { export async function getAppSettings(): Promise { const baseCurrency = await getSetting('baseCurrency', config.baseCurrency) const defaultNotifyDays = await getSetting('defaultNotifyDays', config.defaultNotifyDays) + const defaultAdvanceReminderRules = resolveDefaultAdvanceReminderRules( + await getSetting('defaultAdvanceReminderRules', null), + defaultNotifyDays, + await getSetting('notifyOnDueDay', true) + ) const rememberSessionDays = await getSetting('rememberSessionDays', 7) - const notifyOnDueDay = await getSetting('notifyOnDueDay', true) + const notifyOnDueDay = deriveNotifyOnDueDayFromAdvanceRules(defaultAdvanceReminderRules) const mergeMultiSubscriptionNotifications = await getSetting('mergeMultiSubscriptionNotifications', true) const monthlyBudgetBase = await getSetting('monthlyBudgetBase', null) const yearlyBudgetBase = await getSetting('yearlyBudgetBase', null) const enableTagBudgets = await getSetting('enableTagBudgets', false) - const overdueReminderDays = await getSetting>('overdueReminderDays', [1, 2, 3]) + const defaultOverdueReminderRules = resolveDefaultOverdueReminderRules( + await getSetting('defaultOverdueReminderRules', null), + await getSetting>('overdueReminderDays', [1, 2, 3]) + ) + const overdueReminderDays = deriveOverdueReminderDaysFromRules(defaultOverdueReminderRules) const tagBudgets = await getSetting>('tagBudgets', {}) const emailNotificationsEnabled = await getSetting('emailNotificationsEnabled', false) const pushplusNotificationsEnabled = await getSetting('pushplusNotificationsEnabled', false) @@ -51,7 +72,8 @@ export async function getAppSettings(): Promise { return SettingsSchema.parse({ baseCurrency, - defaultNotifyDays, + defaultNotifyDays: deriveNotifyDaysBeforeFromAdvanceRules(defaultAdvanceReminderRules) || defaultNotifyDays, + defaultAdvanceReminderRules, rememberSessionDays, notifyOnDueDay, mergeMultiSubscriptionNotifications, @@ -59,6 +81,7 @@ export async function getAppSettings(): Promise { yearlyBudgetBase, enableTagBudgets, overdueReminderDays, + defaultOverdueReminderRules, tagBudgets, emailNotificationsEnabled, pushplusNotificationsEnabled, diff --git a/apps/api/tests/integration/notifications-routes.test.ts b/apps/api/tests/integration/notifications-routes.test.ts index 58a4f78..256baa9 100644 --- a/apps/api/tests/integration/notifications-routes.test.ts +++ b/apps/api/tests/integration/notifications-routes.test.ts @@ -56,4 +56,5 @@ describe('notification routes', () => { expect(res.statusCode).toBe(200) expect(notificationMocks.sendTestTelegramNotificationMock).toHaveBeenCalled() }) + }) diff --git a/apps/api/tests/integration/settings-routes.test.ts b/apps/api/tests/integration/settings-routes.test.ts index 55b839a..b11f5d7 100644 --- a/apps/api/tests/integration/settings-routes.test.ts +++ b/apps/api/tests/integration/settings-routes.test.ts @@ -7,6 +7,7 @@ vi.mock('../../src/services/settings.service', () => ({ getAppSettings: vi.fn(async () => ({ baseCurrency: 'CNY', defaultNotifyDays: 3, + defaultAdvanceReminderRules: '3&09:30;0&09:30;', rememberSessionDays: 7, notifyOnDueDay: true, mergeMultiSubscriptionNotifications: (store.get('mergeMultiSubscriptionNotifications') as boolean) ?? true, @@ -14,6 +15,7 @@ vi.mock('../../src/services/settings.service', () => ({ yearlyBudgetBase: null, enableTagBudgets: false, overdueReminderDays: [1, 2, 3], + defaultOverdueReminderRules: '1&09:30;2&09:30;3&09:30;', tagBudgets: {}, emailNotificationsEnabled: (store.get('emailNotificationsEnabled') as boolean) ?? false, pushplusNotificationsEnabled: (store.get('pushplusNotificationsEnabled') as boolean) ?? false, @@ -121,4 +123,17 @@ describe('settings routes validation', () => { expect(res.statusCode).toBe(422) expect(res.json().error.message).toContain('启用邮箱通知时必须填写') }) + + it('rejects invalid reminder rules', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + defaultAdvanceReminderRules: '3&25:99;' + } + }) + + expect(res.statusCode).toBe(422) + expect(res.json().error.message).toContain('时间必须为 HH:mm') + }) }) diff --git a/apps/api/tests/unit/notification-merge.test.ts b/apps/api/tests/unit/notification-merge.test.ts index 1754d60..3b62163 100644 --- a/apps/api/tests/unit/notification-merge.test.ts +++ b/apps/api/tests/unit/notification-merge.test.ts @@ -8,6 +8,9 @@ const notificationState = vi.hoisted(() => ({ vi.mock('../../src/services/settings.service', () => ({ getAppSettings: vi.fn(async () => ({ + defaultAdvanceReminderRules: '3&09:30;0&09:30;', + defaultOverdueReminderRules: '1&09:30;2&09:30;3&09:30;', + defaultNotifyDays: 3, notifyOnDueDay: true, mergeMultiSubscriptionNotifications: notificationState.mergeMultiSubscriptionNotifications, overdueReminderDays: [1, 2, 3] @@ -25,8 +28,10 @@ vi.mock('../../src/db', () => ({ { id: 'sub-1', name: 'Netflix', - nextRenewalDate: new Date('2026-04-23T00:00:00.000Z'), + nextRenewalDate: new Date('2026-04-23T00:00:00'), notifyDaysBefore: 3, + advanceReminderRules: '', + overdueReminderRules: '', amount: 9.9, currency: 'USD', status: 'active', @@ -37,14 +42,30 @@ vi.mock('../../src/db', () => ({ { id: 'sub-2', name: 'Spotify', - nextRenewalDate: new Date('2026-04-22T00:00:00.000Z'), + nextRenewalDate: new Date('2026-04-22T00:00:00'), notifyDaysBefore: 5, + advanceReminderRules: '', + overdueReminderRules: '', amount: 12.9, currency: 'USD', status: 'active', websiteUrl: 'https://spotify.com', notes: 'music', tags: [{ tag: { name: '音乐' } }] + }, + { + id: 'sub-3', + name: 'Notion', + nextRenewalDate: new Date('2026-04-26T00:00:00'), + notifyDaysBefore: 3, + advanceReminderRules: '', + overdueReminderRules: '', + amount: 8.8, + currency: 'USD', + status: 'active', + websiteUrl: 'https://notion.so', + notes: 'workspace', + tags: [{ tag: { name: '办公' } }] } ]), update: notificationState.updateMock @@ -63,23 +84,23 @@ describe('scanRenewalNotifications merge behavior', () => { it('merges all reminders from the same scan into a single summary notification by default', async () => { notificationState.mergeMultiSubscriptionNotifications = true - await scanRenewalNotifications(new Date('2026-04-23T10:00:00.000Z')) + await scanRenewalNotifications(new Date('2026-04-23T09:30:00')) expect(notificationState.dispatchMock).toHaveBeenCalledTimes(1) const payload = notificationState.dispatchMock.mock.calls[0][0].payload expect(payload.merged).toBe(true) - expect(payload.mergedCount).toBe(2) - expect(payload.subscriptions).toHaveLength(2) - expect(payload.mergedSections).toHaveLength(2) - expect(payload.mergedSections.map((section: { title: string }) => section.title)).toEqual(['今天到期', '已过期第 1 天']) + expect(payload.mergedCount).toBe(3) + expect(payload.subscriptions).toHaveLength(3) + expect(payload.mergedSections).toHaveLength(3) + expect(payload.mergedSections.map((section: { title: string }) => section.title)).toEqual(['即将到期', '今天到期', '已过期第 1 天']) }) it('sends notifications separately when merging is disabled', async () => { notificationState.mergeMultiSubscriptionNotifications = false - await scanRenewalNotifications(new Date('2026-04-23T10:00:00.000Z')) + await scanRenewalNotifications(new Date('2026-04-23T09:30:00')) - expect(notificationState.dispatchMock).toHaveBeenCalledTimes(2) + expect(notificationState.dispatchMock).toHaveBeenCalledTimes(3) for (const call of notificationState.dispatchMock.mock.calls) { expect(call[0].payload.merged).not.toBe(true) } diff --git a/apps/api/tests/unit/notification-phase.test.ts b/apps/api/tests/unit/notification-phase.test.ts index 0fc9f2a..a525282 100644 --- a/apps/api/tests/unit/notification-phase.test.ts +++ b/apps/api/tests/unit/notification-phase.test.ts @@ -1,71 +1,44 @@ import { describe, expect, it } from 'vitest' -import { resolveReminderPhase } from '../../src/services/notification.service' +import { + buildAdvanceReminderRulesFromLegacy, + buildAdvanceReminderRulesFromLegacyWithDefault, + buildOverdueReminderRules, + deriveNotifyDaysBeforeFromAdvanceRules, + deriveNotifyOnDueDayFromAdvanceRules, + deriveOverdueReminderDaysFromRules, + normalizeReminderRules, + parseReminderRules +} from '../../src/services/reminder-rules.service' -describe('resolveReminderPhase', () => { - it('returns upcoming on notify day', () => { - expect(resolveReminderPhase(new Date('2026-04-20'), new Date('2026-04-23'), 3)).toEqual({ - eventType: 'subscription.reminder_due', - phase: 'upcoming' - }) +describe('reminder rules helpers', () => { + it('parses and normalizes advance rules', () => { + expect(parseReminderRules('0&09:30;3&10:00;', 'advance')).toEqual([ + { days: 3, time: '10:00', hour: 10, minute: 0 }, + { days: 0, time: '09:30', hour: 9, minute: 30 } + ]) + + expect(normalizeReminderRules(' 0&09:30 ; 3&10:00; ', 'advance')).toBe('3&10:00;0&09:30;') }) - it('returns due_today on renewal date', () => { - expect(resolveReminderPhase(new Date('2026-04-23'), new Date('2026-04-23'), 3)).toEqual({ - eventType: 'subscription.reminder_due', - phase: 'due_today' - }) + it('parses and normalizes overdue rules', () => { + expect(normalizeReminderRules('3&09:30;1&08:00;2&09:30;', 'overdue')).toBe('1&08:00;2&09:30;3&09:30;') }) - it('can disable due_today reminder', () => { - expect( - resolveReminderPhase(new Date('2026-04-23'), new Date('2026-04-23'), 3, { - notifyOnDueDay: false, - overdueReminderDays: [1, 2, 3] - }) - ).toBeNull() + it('rejects invalid reminder rule input', () => { + expect(() => parseReminderRules('abc', 'advance')).toThrow('格式无效') + expect(() => parseReminderRules('0&09:30;', 'overdue')).toThrow('必须大于等于 1') + expect(() => parseReminderRules('3&25:00;', 'advance')).toThrow('时间必须为 HH:mm') }) - it('returns overdue phases for first three overdue days only', () => { - expect(resolveReminderPhase(new Date('2026-04-24'), new Date('2026-04-23'), 3)).toEqual({ - eventType: 'subscription.overdue', - phase: 'overdue_day_1' - }) - expect(resolveReminderPhase(new Date('2026-04-25'), new Date('2026-04-23'), 3)).toEqual({ - eventType: 'subscription.overdue', - phase: 'overdue_day_2' - }) - expect(resolveReminderPhase(new Date('2026-04-26'), new Date('2026-04-23'), 3)).toEqual({ - eventType: 'subscription.overdue', - phase: 'overdue_day_3' - }) - expect(resolveReminderPhase(new Date('2026-04-27'), new Date('2026-04-23'), 3)).toBeNull() + it('derives legacy-compatible values from rules', () => { + expect(deriveNotifyDaysBeforeFromAdvanceRules('3&09:30;0&09:30;')).toBe(3) + expect(deriveNotifyOnDueDayFromAdvanceRules('3&09:30;0&09:30;')).toBe(true) + expect(deriveOverdueReminderDaysFromRules('1&09:30;2&09:30;5&09:30;')).toEqual([1, 2]) }) - it('only returns configured overdue reminder days', () => { - expect( - resolveReminderPhase(new Date('2026-04-24'), new Date('2026-04-23'), 3, { - notifyOnDueDay: true, - overdueReminderDays: [2, 3] - }) - ).toBeNull() - - expect( - resolveReminderPhase(new Date('2026-04-25'), new Date('2026-04-23'), 3, { - notifyOnDueDay: true, - overdueReminderDays: [2, 3] - }) - ).toEqual({ - eventType: 'subscription.overdue', - phase: 'overdue_day_2' - }) - }) - - it('does not treat notifyDaysBefore = 0 as an upcoming reminder on due date', () => { - expect( - resolveReminderPhase(new Date('2026-04-23'), new Date('2026-04-23'), 0, { - notifyOnDueDay: false, - overdueReminderDays: [1, 2, 3] - }) - ).toBeNull() + it('builds rules from legacy values', () => { + expect(buildAdvanceReminderRulesFromLegacy(3, true)).toBe('3&09:30;0&09:30;') + expect(buildAdvanceReminderRulesFromLegacyWithDefault(5, '3&09:30;0&09:30;')).toBe('5&09:30;0&09:30;') + expect(buildOverdueReminderRules([1, 3])).toBe('1&09:30;3&09:30;') }) }) diff --git a/apps/web/src/components/SubscriptionDetailDrawer.vue b/apps/web/src/components/SubscriptionDetailDrawer.vue index 5ff0479..73884dd 100644 --- a/apps/web/src/components/SubscriptionDetailDrawer.vue +++ b/apps/web/src/components/SubscriptionDetailDrawer.vue @@ -39,7 +39,12 @@ {{ formatDate(detail.startDate) }} {{ formatDate(detail.nextRenewalDate) }} {{ formatMoney(detail.amount, detail.currency) }} - {{ detail.notifyDaysBefore }} 天 + + {{ formatReminderRulesText(detail.advanceReminderRules, 'advance') }} + + + {{ formatReminderRulesText(detail.overdueReminderRules, 'overdue') }} + {{ detail.webhookEnabled ? '已启用' : '未启用' }} {{ formatDateTime(detail.createdAt) }} @@ -63,8 +68,9 @@ import { computed } from 'vue' import { useWindowSize } from '@vueuse/core' import { NCard, NDescriptions, NDescriptionsItem, NDrawer, NDrawerContent, NEmpty, NSpace, NTag } from 'naive-ui' import type { SubscriptionDetail } from '@/types/api' -import { getSubscriptionStatusTagType, getSubscriptionStatusText } from '@/utils/subscription-status' import { resolveLogoUrl } from '@/utils/logo' +import { formatReminderRulesText } from '@/utils/reminder-rules' +import { getSubscriptionStatusTagType, getSubscriptionStatusText } from '@/utils/subscription-status' const emit = defineEmits<{ close: [] }>() diff --git a/apps/web/src/components/SubscriptionFormModal.vue b/apps/web/src/components/SubscriptionFormModal.vue index 7c732cd..972a3be 100644 --- a/apps/web/src/components/SubscriptionFormModal.vue +++ b/apps/web/src/components/SubscriptionFormModal.vue @@ -160,8 +160,35 @@ - - + + + + + + + + + @@ -217,9 +244,10 @@ import { NSwitch, NTabPane, NTabs, + NTooltip, useMessage } from 'naive-ui' -import { CloseOutline, SearchOutline } from '@vicons/ionicons5' +import { CloseOutline, HelpCircleOutline, SearchOutline } from '@vicons/ionicons5' import { api } from '@/composables/api' import SubscriptionAiModal from '@/components/SubscriptionAiModal.vue' import { buildCurrencyOptions } from '@/utils/currency' @@ -234,7 +262,8 @@ const props = defineProps<{ model?: Subscription | null tags: Tag[] currencies?: string[] - defaultNotifyDays?: number + defaultAdvanceReminderRules?: string + defaultOverdueReminderRules?: string }>() const emit = defineEmits<{ @@ -244,6 +273,7 @@ const emit = defineEmits<{ const { width } = useWindowSize() const message = useMessage() +const helpCircleOutline = HelpCircleOutline const showAiModal = ref(false) const showLogoPanel = ref(false) const logoPanelTab = ref(LOGO_TAB_WEB) @@ -257,7 +287,7 @@ const syncingNextRenewal = ref(false) const layoutCols = computed(() => (width.value < 700 ? 1 : 2)) const moneyCols = computed(() => (width.value < 900 ? 2 : 4)) -const dateCols = computed(() => (width.value < 900 ? 1 : 3)) +const dateCols = computed(() => (width.value < 900 ? 1 : 2)) const intervalOptions = [ { label: '天', value: 'day' }, @@ -272,11 +302,6 @@ const frequencyOptions = Array.from({ length: 12 }, (_, index) => ({ value: index + 1 })) -const notifyDayOptions = [0, 1, 3, 5, 7, 10, 14, 30].map((day) => ({ - label: day === 0 ? '到期当天' : `提前 ${day} 天`, - value: day -})) - const tagOptions = computed(() => props.tags.map((item) => ({ label: item.name, @@ -299,7 +324,8 @@ const form = reactive({ autoRenew: false, startDateTs: dayjs().valueOf(), nextRenewalDateTs: dayjs().add(1, 'month').valueOf(), - notifyDaysBefore: props.defaultNotifyDays ?? 3, + advanceReminderRules: '', + overdueReminderRules: '', webhookEnabled: true, notes: '', websiteUrl: '', @@ -321,15 +347,6 @@ watch( { immediate: true } ) -watch( - () => props.defaultNotifyDays, - (value) => { - if (!props.model) { - form.notifyDaysBefore = value ?? 3 - } - } -) - watch( () => props.show, (value) => { @@ -366,7 +383,8 @@ function resetForm() { form.autoRenew = false form.startDateTs = dayjs().valueOf() form.nextRenewalDateTs = dayjs().add(1, 'month').valueOf() - form.notifyDaysBefore = props.defaultNotifyDays ?? 3 + form.advanceReminderRules = '' + form.overdueReminderRules = '' form.webhookEnabled = true form.notes = '' form.websiteUrl = '' @@ -389,7 +407,8 @@ function hydrateFromModel(model: Subscription) { form.startDateTs = dayjs(model.startDate).valueOf() form.nextRenewalDateTs = dayjs(model.nextRenewalDate).valueOf() nextRenewalDirty.value = true - form.notifyDaysBefore = model.notifyDaysBefore + form.advanceReminderRules = model.advanceReminderRules ?? '' + form.overdueReminderRules = model.overdueReminderRules ?? '' form.webhookEnabled = model.webhookEnabled form.notes = model.notes form.websiteUrl = model.websiteUrl ?? '' @@ -582,7 +601,11 @@ function applyAiResult(result: AiRecognitionResult) { form.nextRenewalDateTs = dayjs(result.nextRenewalDate).valueOf() nextRenewalDirty.value = true } - if (result.notifyDaysBefore !== undefined) form.notifyDaysBefore = result.notifyDaysBefore + if (result.notifyDaysBefore !== undefined) { + form.advanceReminderRules = `${result.notifyDaysBefore}&09:30;` + } + if (result.advanceReminderRules) form.advanceReminderRules = result.advanceReminderRules + if (result.overdueReminderRules) form.overdueReminderRules = result.overdueReminderRules if (result.websiteUrl) form.websiteUrl = result.websiteUrl if (result.notes) form.notes = result.notes } @@ -610,7 +633,8 @@ function submit() { autoRenew: form.autoRenew, startDate: dayjs(form.startDateTs).format('YYYY-MM-DD'), nextRenewalDate: dayjs(form.nextRenewalDateTs).format('YYYY-MM-DD'), - notifyDaysBefore: Number(form.notifyDaysBefore), + advanceReminderRules: form.advanceReminderRules.trim() || '', + overdueReminderRules: form.overdueReminderRules.trim() || '', webhookEnabled: form.webhookEnabled, notes: form.notes, websiteUrl: form.websiteUrl || null, @@ -886,6 +910,18 @@ function formatLogoSource(source: string) { flex-wrap: wrap; } +.label-with-tip { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.label-with-tip__icon { + color: #94a3b8; + font-size: 15px; + cursor: help; +} + @media (max-width: 900px) { .name-logo-row { flex-direction: column; diff --git a/apps/web/src/pages/SettingsPage.vue b/apps/web/src/pages/SettingsPage.vue index 62b6ef3..343d468 100644 --- a/apps/web/src/pages/SettingsPage.vue +++ b/apps/web/src/pages/SettingsPage.vue @@ -39,47 +39,65 @@ - - + + + - - - - - {{ option.label }} - - - + + + - +
-
- 到期当天提醒 - -
多订阅合并通知
- +
- +
启用标签月预算
- +
@@ -425,13 +443,17 @@ import dayjs from 'dayjs' import { computed, onMounted, reactive, ref } from 'vue' import { useWindowSize } from '@vueuse/core' import { useQueryClient } from '@tanstack/vue-query' -import { DEFAULT_AI_CONFIG, DEFAULT_AI_SUBSCRIPTION_PROMPT, DEFAULT_NOTIFICATION_WEBHOOK_PAYLOAD_TEMPLATE } from '@subtracker/shared' +import { + DEFAULT_ADVANCE_REMINDER_RULES, + DEFAULT_AI_CONFIG, + DEFAULT_AI_SUBSCRIPTION_PROMPT, + DEFAULT_NOTIFICATION_WEBHOOK_PAYLOAD_TEMPLATE, + DEFAULT_OVERDUE_REMINDER_RULES +} from '@subtracker/shared' import { NAlert, NButton, NCard, - NCheckbox, - NCheckboxGroup, NCollapse, NCollapseItem, NDataTable, @@ -448,9 +470,10 @@ import { NSpace, NSwitch, NTag, + NTooltip, useMessage } from 'naive-ui' -import { RefreshOutline, SaveOutline, SettingsOutline } from '@vicons/ionicons5' +import { HelpCircleOutline, RefreshOutline, SaveOutline, SettingsOutline } from '@vicons/ionicons5' import { api } from '@/composables/api' import PageHeader from '@/components/PageHeader.vue' import WallosImportModal from '@/components/WallosImportModal.vue' @@ -463,6 +486,7 @@ const message = useMessage() const authStore = useAuthStore() const queryClient = useQueryClient() const { width } = useWindowSize() +const helpCircleOutline = HelpCircleOutline const settingsOutline = SettingsOutline const AI_PROVIDER_PRESETS: Record< Exclude, @@ -500,6 +524,7 @@ const AI_PROVIDER_PRESETS: Record< const settingsForm = reactive({ baseCurrency: 'CNY', defaultNotifyDays: 3, + defaultAdvanceReminderRules: DEFAULT_ADVANCE_REMINDER_RULES, rememberSessionDays: 7, notifyOnDueDay: true, mergeMultiSubscriptionNotifications: true, @@ -507,6 +532,7 @@ const settingsForm = reactive({ yearlyBudgetBase: null, enableTagBudgets: false, overdueReminderDays: [1, 2, 3], + defaultOverdueReminderRules: DEFAULT_OVERDUE_REMINDER_RULES, tagBudgets: {}, emailNotificationsEnabled: false, pushplusNotificationsEnabled: false, @@ -558,7 +584,6 @@ const sourceCurrency = ref('USD') const targetCurrency = ref('CNY') const converterAmount = ref(1) const showWallosImportModal = ref(false) - const isMobile = computed(() => width.value < 960) const formCols = computed(() => (width.value < 640 ? 1 : 2)) const gridCols = computed(() => (isMobile.value ? 1 : 2)) @@ -579,12 +604,6 @@ const aiProviderPresetOptions = [ { label: '腾讯混元', value: 'tencent-hunyuan' }, { label: '火山方舟', value: 'volcengine-ark' } ] satisfies Array<{ label: string; value: AiProviderPreset }> -const overdueReminderOptions = [ - { label: '过期第 1 天', value: 1 }, - { label: '过期第 2 天', value: 2 }, - { label: '过期第 3 天', value: 3 } -] as const - function getMissingRequiredFields(fields: Array<[string, unknown]>) { return fields .filter(([, value]) => { @@ -689,28 +708,32 @@ async function loadWebhook() { } async function saveBasicSettings() { - await api.updateSettings({ - baseCurrency: settingsForm.baseCurrency.toUpperCase(), - defaultNotifyDays: settingsForm.defaultNotifyDays, - rememberSessionDays: settingsForm.rememberSessionDays, - notifyOnDueDay: settingsForm.notifyOnDueDay, - mergeMultiSubscriptionNotifications: settingsForm.mergeMultiSubscriptionNotifications, - monthlyBudgetBase: settingsForm.monthlyBudgetBase, - yearlyBudgetBase: settingsForm.yearlyBudgetBase, - enableTagBudgets: settingsForm.enableTagBudgets, - overdueReminderDays: [...settingsForm.overdueReminderDays].sort((a, b) => a - b) as Array<1 | 2 | 3>, - tagBudgets: settingsForm.tagBudgets - }) - message.success('基础设置已保存') - targetCurrency.value = settingsForm.baseCurrency.toUpperCase() - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['settings'] }), - queryClient.invalidateQueries({ queryKey: ['settings-budget-page'] }), - queryClient.invalidateQueries({ queryKey: ['app-menu-settings'] }), - queryClient.invalidateQueries({ queryKey: ['statistics-overview'] }), - queryClient.invalidateQueries({ queryKey: ['statistics-budgets'] }) - ]) - await loadSnapshot() + try { + const result = await api.updateSettings({ + baseCurrency: settingsForm.baseCurrency.toUpperCase(), + defaultAdvanceReminderRules: settingsForm.defaultAdvanceReminderRules, + rememberSessionDays: settingsForm.rememberSessionDays, + mergeMultiSubscriptionNotifications: settingsForm.mergeMultiSubscriptionNotifications, + monthlyBudgetBase: settingsForm.monthlyBudgetBase, + yearlyBudgetBase: settingsForm.yearlyBudgetBase, + enableTagBudgets: settingsForm.enableTagBudgets, + defaultOverdueReminderRules: settingsForm.defaultOverdueReminderRules, + tagBudgets: settingsForm.tagBudgets + }) + Object.assign(settingsForm, result) + message.success('基础设置已保存') + targetCurrency.value = settingsForm.baseCurrency.toUpperCase() + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['settings'] }), + queryClient.invalidateQueries({ queryKey: ['settings-budget-page'] }), + queryClient.invalidateQueries({ queryKey: ['app-menu-settings'] }), + queryClient.invalidateQueries({ queryKey: ['statistics-overview'] }), + queryClient.invalidateQueries({ queryKey: ['statistics-budgets'] }) + ]) + await loadSnapshot() + } catch (error) { + message.error(error instanceof Error ? error.message : '基础设置保存失败') + } } async function saveEmailSettings() { @@ -985,6 +1008,10 @@ function formatTime(value: string) { color: #475569; } +.switch-row { + padding-top: 6px; +} + .switch-inline-label { color: #475569; } @@ -1007,6 +1034,18 @@ function formatTime(value: string) { min-height: 34px; } +.label-with-tip { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.label-with-tip__icon { + color: #94a3b8; + font-size: 15px; + cursor: help; +} + .tag-budget-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); diff --git a/apps/web/src/pages/SubscriptionsPage.vue b/apps/web/src/pages/SubscriptionsPage.vue index 390a1a5..792f520 100644 --- a/apps/web/src/pages/SubscriptionsPage.vue +++ b/apps/web/src/pages/SubscriptionsPage.vue @@ -223,7 +223,8 @@ :model="editing" :tags="tags" :currencies="currencies" - :default-notify-days="defaultNotifyDays" + :default-advance-reminder-rules="defaultAdvanceReminderRules" + :default-overdue-reminder-rules="defaultOverdueReminderRules" @close="closeModal" @submit="submitSubscription" /> @@ -299,7 +300,8 @@ const tags = ref([]) const detail = ref(null) const paymentRecords = ref([]) const currencies = ref(['CNY', 'USD', 'EUR', 'GBP', 'JPY', 'HKD']) -const defaultNotifyDays = ref(3) +const defaultAdvanceReminderRules = ref('3&09:30;0&09:30;') +const defaultOverdueReminderRules = ref('1&09:30;2&09:30;3&09:30;') const filters = reactive({ q: '', @@ -684,7 +686,8 @@ async function loadCurrencies() { async function loadSettings() { const settings: Settings = await api.getSettings() - defaultNotifyDays.value = settings.defaultNotifyDays ?? 3 + defaultAdvanceReminderRules.value = settings.defaultAdvanceReminderRules + defaultOverdueReminderRules.value = settings.defaultOverdueReminderRules } function toggleTagFilter(tagId: string) { diff --git a/apps/web/src/stores/app.ts b/apps/web/src/stores/app.ts index 5524339..279d3f6 100644 --- a/apps/web/src/stores/app.ts +++ b/apps/web/src/stores/app.ts @@ -1,6 +1,6 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { DEFAULT_AI_CONFIG } from '@subtracker/shared' +import { DEFAULT_ADVANCE_REMINDER_RULES, DEFAULT_AI_CONFIG, DEFAULT_OVERDUE_REMINDER_RULES } from '@subtracker/shared' import { api } from '@/composables/api' import type { Settings } from '@/types/api' @@ -8,6 +8,7 @@ export const useAppStore = defineStore('app', () => { const settings = ref({ baseCurrency: 'CNY', defaultNotifyDays: 3, + defaultAdvanceReminderRules: DEFAULT_ADVANCE_REMINDER_RULES, rememberSessionDays: 7, notifyOnDueDay: true, mergeMultiSubscriptionNotifications: true, @@ -15,6 +16,7 @@ export const useAppStore = defineStore('app', () => { yearlyBudgetBase: null, enableTagBudgets: false, overdueReminderDays: [1, 2, 3], + defaultOverdueReminderRules: DEFAULT_OVERDUE_REMINDER_RULES, tagBudgets: {}, emailNotificationsEnabled: false, pushplusNotificationsEnabled: false, diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index 98c6c19..0ff3cbc 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -58,6 +58,8 @@ export interface Subscription { startDate: string nextRenewalDate: string notifyDaysBefore: number + advanceReminderRules?: string | null + overdueReminderRules?: string | null webhookEnabled: boolean notes: string createdAt: string @@ -205,6 +207,7 @@ export interface AiTestResponse { export interface Settings { baseCurrency: string defaultNotifyDays: number + defaultAdvanceReminderRules: string rememberSessionDays: number notifyOnDueDay: boolean mergeMultiSubscriptionNotifications: boolean @@ -212,6 +215,7 @@ export interface Settings { yearlyBudgetBase?: number | null enableTagBudgets: boolean overdueReminderDays: Array<1 | 2 | 3> + defaultOverdueReminderRules: string tagBudgets: Record emailNotificationsEnabled: boolean pushplusNotificationsEnabled: boolean @@ -263,6 +267,8 @@ export interface AiRecognitionResult { startDate?: string nextRenewalDate?: string notifyDaysBefore?: number + advanceReminderRules?: string + overdueReminderRules?: string websiteUrl?: string notes?: string confidence?: number diff --git a/apps/web/src/utils/reminder-rules.ts b/apps/web/src/utils/reminder-rules.ts new file mode 100644 index 0000000..d96f4f6 --- /dev/null +++ b/apps/web/src/utils/reminder-rules.ts @@ -0,0 +1,37 @@ +export type ReminderRulesKind = 'advance' | 'overdue' + +function parseReminderRules(value: string) { + return value + .split(';') + .map((item) => item.trim()) + .filter(Boolean) + .map((item) => { + const [days, time] = item.split('&') + return { + days: Number(days), + time + } + }) + .filter((item) => Number.isFinite(item.days) && item.time) +} + +export function formatReminderRulesText( + value: string | null | undefined, + kind: ReminderRulesKind, + fallback = '沿用系统默认' +) { + if (!value?.trim()) return fallback + + const parts = parseReminderRules(value) + if (!parts.length) return fallback + + return parts + .map((item) => { + if (kind === 'advance') { + return item.days === 0 ? `当天 ${item.time}` : `提前 ${item.days} 天 ${item.time}` + } + + return `过期 ${item.days} 天 ${item.time}` + }) + .join(';') +} diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 6853866..7898931 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { CreateSubscriptionSchema, SettingsSchema } from '../src/index' +import { + CreateSubscriptionSchema, + DEFAULT_ADVANCE_REMINDER_RULES, + DEFAULT_OVERDUE_REMINDER_RULES, + SettingsSchema +} from '../src/index' describe('shared schema', () => { it('should validate create subscription payload', () => { @@ -14,15 +19,18 @@ describe('shared schema', () => { expect(parsed.currency).toBe('USD') expect(parsed.billingIntervalCount).toBe(1) + expect(parsed.advanceReminderRules).toBeUndefined() }) it('should provide reminder-related setting defaults', () => { const parsed = SettingsSchema.parse({}) expect(parsed.defaultNotifyDays).toBe(3) + expect(parsed.defaultAdvanceReminderRules).toBe(DEFAULT_ADVANCE_REMINDER_RULES) expect(parsed.notifyOnDueDay).toBe(true) expect(parsed.mergeMultiSubscriptionNotifications).toBe(true) expect(parsed.overdueReminderDays).toEqual([1, 2, 3]) + expect(parsed.defaultOverdueReminderRules).toBe(DEFAULT_OVERDUE_REMINDER_RULES) expect(parsed.telegramNotificationsEnabled).toBe(false) expect(parsed.telegramConfig).toEqual({ botToken: '', diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1bbf930..31ca15c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -47,6 +47,10 @@ export const DEFAULT_NOTIFICATION_WEBHOOK_PAYLOAD_TEMPLATE = `{ } }` +export const DEFAULT_REMINDER_RULE_TIME = '09:30' +export const DEFAULT_ADVANCE_REMINDER_RULES = `3&${DEFAULT_REMINDER_RULE_TIME};0&${DEFAULT_REMINDER_RULE_TIME};` +export const DEFAULT_OVERDUE_REMINDER_RULES = `1&${DEFAULT_REMINDER_RULE_TIME};2&${DEFAULT_REMINDER_RULE_TIME};3&${DEFAULT_REMINDER_RULE_TIME};` + export const TagSchema = z.object({ id: z.string().cuid().optional(), name: z.string().min(1).max(100), @@ -76,6 +80,8 @@ export const CreateSubscriptionSchema = z startDate: z.string().date(), nextRenewalDate: z.string().date(), notifyDaysBefore: z.number().int().min(0).max(365).default(3), + advanceReminderRules: z.string().max(500).optional(), + overdueReminderRules: z.string().max(500).optional(), webhookEnabled: z.boolean().default(true), notes: z.string().max(1000).default('') }) @@ -163,6 +169,7 @@ export const AiConfigSchema = z.object({ export const SettingsSchema = z.object({ baseCurrency: z.string().length(3).default('CNY').transform((v) => v.toUpperCase()), defaultNotifyDays: z.number().int().min(0).max(365).default(3), + defaultAdvanceReminderRules: z.string().max(500).default(DEFAULT_ADVANCE_REMINDER_RULES), rememberSessionDays: z.number().int().min(1).max(365).default(7), notifyOnDueDay: z.boolean().default(true), mergeMultiSubscriptionNotifications: z.boolean().default(true), @@ -170,6 +177,7 @@ export const SettingsSchema = z.object({ yearlyBudgetBase: OptionalMoneySchema, enableTagBudgets: z.boolean().default(false), overdueReminderDays: z.array(z.union([z.literal(1), z.literal(2), z.literal(3)])).default([1, 2, 3]), + defaultOverdueReminderRules: z.string().max(500).default(DEFAULT_OVERDUE_REMINDER_RULES), tagBudgets: z.record(z.string(), z.number().nonnegative()).default({}), emailNotificationsEnabled: z.boolean().default(false), pushplusNotificationsEnabled: z.boolean().default(false), @@ -283,6 +291,8 @@ export interface AiRecognitionResultDto { startDate?: string nextRenewalDate?: string notifyDaysBefore?: number + advanceReminderRules?: string + overdueReminderRules?: string websiteUrl?: string notes?: string confidence?: number @@ -416,6 +426,8 @@ export interface WallosImportSubscriptionPreviewDto { startDate: string nextRenewalDate: string notifyDaysBefore: number + advanceReminderRules?: string | null + overdueReminderRules?: string | null webhookEnabled: boolean notes: string description: string