feat: add flexible reminder rule configuration

- replace fixed reminder day controls with configurable reminder rule strings for advance and overdue notifications
- add shared parsing and normalization helpers, subscription-level override fields, and default seeded rules
- switch reminder scanning to minute-level rule matching while preserving merged notification behavior
- update settings, subscription forms, detail views, and types to use tooltip-guided rule inputs and normalized save flow
- refresh API and tests to cover the new reminder rule settings, storage, and notification matching behavior
This commit is contained in:
SmileQWQ
2026-04-20 18:47:08 +08:00
parent 59196858b0
commit eeaf5dab55
22 changed files with 823 additions and 233 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ReturnType<typeof getAppSettings>>) {
const missingEmailFields = [
@@ -49,6 +61,36 @@ function validateSettingsPayload(settings: Awaited<ReturnType<typeof getAppSetti
}
}
function normalizeReminderSettingsPayload(
payload: Partial<Awaited<ReturnType<typeof getAppSettings>>>,
currentSettings: Awaited<ReturnType<typeof getAppSettings>>
) {
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<typeof normalizeReminderSettingsPayload>
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)
})

View File

@@ -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<ReturnType<typeof resolveSubscriptionReminderFields>>
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 } : {}),

View File

@@ -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<Awaited<ReturnType<typeof getAppSettings>>, '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<ReturnType<typeof resolveReminderPhase>>
): 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<ReturnType<typeof getAppSettings>>
) {
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<NotificationScanResult> {
const appSettings = await getAppSettings()
export async function scanRenewalNotifications(
today = new Date(),
overrides: NotificationScanOverrides = {}
): Promise<NotificationScanResult> {
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<Noti
}
})
const currentDay = dayjs(today).startOf('day').toDate()
const now = dayjs(today).second(0).millisecond(0)
const currentDay = now.startOf('day')
const dispatchEntries: ReminderDispatchEntry[] = []
const notifications: NotificationScanResult['notifications'] = []
for (const sub of subscriptions) {
const daysOverdue = Math.max(dayjs(currentDay).diff(dayjs(sub.nextRenewalDate).startOf('day'), 'day'), 0)
const daysOverdue = Math.max(currentDay.diff(dayjs(sub.nextRenewalDate).startOf('day'), 'day'), 0)
if (daysOverdue >= 1 && sub.status !== 'expired') {
await prisma.subscription.update({
where: { id: sub.id },
@@ -238,13 +328,10 @@ export async function scanRenewalNotifications(today = new Date()): Promise<Noti
})
}
const resolved = resolveReminderPhase(currentDay, sub.nextRenewalDate, sub.notifyDaysBefore, {
notifyOnDueDay: appSettings.notifyOnDueDay,
overdueReminderDays: appSettings.overdueReminderDays
})
if (!resolved) continue
dispatchEntries.push(buildDispatchEntry(sub, currentDay, resolved))
const matches = resolveReminderMatches(now, sub, appSettings)
for (const match of matches) {
dispatchEntries.push(buildDispatchEntry(sub, match))
}
}
if (!appSettings.mergeMultiSubscriptionNotifications || dispatchEntries.length <= 1) {
@@ -282,7 +369,7 @@ export async function scanRenewalNotifications(today = new Date()): Promise<Noti
const channelResults = await dispatchNotificationEvent({
eventType: mergedEventType,
resourceKey: 'subscriptions:scan-summary',
periodKey: `${toIsoDate(currentDay)}:summary`,
periodKey: `${toIsoDate(now.toDate())}:summary:${now.format('HH:mm')}`,
payload: mergedPayload
})

View File

@@ -0,0 +1,177 @@
import {
DEFAULT_ADVANCE_REMINDER_RULES,
DEFAULT_OVERDUE_REMINDER_RULES,
DEFAULT_REMINDER_RULE_TIME
} from '@subtracker/shared'
export type ReminderRuleKind = 'advance' | 'overdue'
export type ReminderRule = {
days: number
time: string
hour: number
minute: number
}
const TIME_PATTERN = /^([01]\d|2[0-3]):([0-5]\d)$/
function parseRuleSegment(segment: string, kind: ReminderRuleKind): ReminderRule {
const [rawDays, rawTime, ...rest] = segment.split('&').map((item) => 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<string>()
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
}

View File

@@ -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<T>(key: string, fallback: T): Promise<T> {
const row = await prisma.setting.findUnique({ where: { key } })
@@ -19,13 +31,22 @@ export async function setSetting<T>(key: string, value: T): Promise<void> {
export async function getAppSettings(): Promise<SettingsInput> {
const baseCurrency = await getSetting('baseCurrency', config.baseCurrency)
const defaultNotifyDays = await getSetting('defaultNotifyDays', config.defaultNotifyDays)
const defaultAdvanceReminderRules = resolveDefaultAdvanceReminderRules(
await getSetting<string | null>('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<number | null>('monthlyBudgetBase', null)
const yearlyBudgetBase = await getSetting<number | null>('yearlyBudgetBase', null)
const enableTagBudgets = await getSetting('enableTagBudgets', false)
const overdueReminderDays = await getSetting<Array<1 | 2 | 3>>('overdueReminderDays', [1, 2, 3])
const defaultOverdueReminderRules = resolveDefaultOverdueReminderRules(
await getSetting<string | null>('defaultOverdueReminderRules', null),
await getSetting<Array<1 | 2 | 3>>('overdueReminderDays', [1, 2, 3])
)
const overdueReminderDays = deriveOverdueReminderDaysFromRules(defaultOverdueReminderRules)
const tagBudgets = await getSetting<Record<string, number>>('tagBudgets', {})
const emailNotificationsEnabled = await getSetting('emailNotificationsEnabled', false)
const pushplusNotificationsEnabled = await getSetting('pushplusNotificationsEnabled', false)
@@ -51,7 +72,8 @@ export async function getAppSettings(): Promise<SettingsInput> {
return SettingsSchema.parse({
baseCurrency,
defaultNotifyDays,
defaultNotifyDays: deriveNotifyDaysBeforeFromAdvanceRules(defaultAdvanceReminderRules) || defaultNotifyDays,
defaultAdvanceReminderRules,
rememberSessionDays,
notifyOnDueDay,
mergeMultiSubscriptionNotifications,
@@ -59,6 +81,7 @@ export async function getAppSettings(): Promise<SettingsInput> {
yearlyBudgetBase,
enableTagBudgets,
overdueReminderDays,
defaultOverdueReminderRules,
tagBudgets,
emailNotificationsEnabled,
pushplusNotificationsEnabled,

View File

@@ -56,4 +56,5 @@ describe('notification routes', () => {
expect(res.statusCode).toBe(200)
expect(notificationMocks.sendTestTelegramNotificationMock).toHaveBeenCalled()
})
})

View File

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

View File

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

View File

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

View File

@@ -39,7 +39,12 @@
<n-descriptions-item label="开始日期">{{ formatDate(detail.startDate) }}</n-descriptions-item>
<n-descriptions-item label="下次续订">{{ formatDate(detail.nextRenewalDate) }}</n-descriptions-item>
<n-descriptions-item label="原始金额">{{ formatMoney(detail.amount, detail.currency) }}</n-descriptions-item>
<n-descriptions-item label="提醒天数">{{ detail.notifyDaysBefore }} </n-descriptions-item>
<n-descriptions-item label="到期前提醒">
{{ formatReminderRulesText(detail.advanceReminderRules, 'advance') }}
</n-descriptions-item>
<n-descriptions-item label="过期提醒">
{{ formatReminderRulesText(detail.overdueReminderRules, 'overdue') }}
</n-descriptions-item>
<n-descriptions-item label="提醒通知">{{ detail.webhookEnabled ? '已启用' : '未启用' }}</n-descriptions-item>
<n-descriptions-item label="创建时间">{{ formatDateTime(detail.createdAt) }}</n-descriptions-item>
</n-descriptions>
@@ -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: [] }>()

View File

@@ -160,8 +160,35 @@
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="提醒天数">
<n-select v-model:value="form.notifyDaysBefore" :options="notifyDayOptions" placeholder="选择提醒天数" />
<n-form-item>
<template #label>
<span class="label-with-tip">
<span>到期前提醒规则</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon class="label-with-tip__icon" :component="helpCircleOutline" />
</template>
<span>格式说明天数&时间;例如 3&09:30; 表示提前 3 天在 09:30 提醒0&09:30; 表示到期当天提醒多条规则用 ; 分隔留空则沿用系统默认</span>
</n-tooltip>
</span>
</template>
<n-input v-model:value="form.advanceReminderRules" placeholder="留空则沿用系统默认例如3&09:30;0&09:30;" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item>
<template #label>
<span class="label-with-tip">
<span>过期提醒规则</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon class="label-with-tip__icon" :component="helpCircleOutline" />
</template>
<span>格式说明天数&时间;例如 1&09:30; 表示过期 1 天后在 09:30 提醒多条规则用 ; 分隔留空则沿用系统默认</span>
</n-tooltip>
</span>
</template>
<n-input v-model:value="form.overdueReminderRules" placeholder="留空则沿用系统默认例如1&09:30;2&09:30;" />
</n-form-item>
</n-grid-item>
</n-grid>
@@ -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<string>(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;

View File

@@ -39,47 +39,65 @@
<n-grid :cols="formCols" :x-gap="12">
<n-grid-item>
<n-form-item label="默认提前提醒天数">
<n-input-number v-model:value="settingsForm.defaultNotifyDays" :min="0" :max="365" style="width: 100%" />
<n-form-item>
<template #label>
<span class="label-with-tip">
<span>到期前提醒规则</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon class="label-with-tip__icon" :component="helpCircleOutline" />
</template>
<span>格式说明天数&时间;例如 3&09:30; 表示提前 3 天在 09:30 提醒0&09:30; 表示到期当天提醒多条规则用 ; 分隔</span>
</n-tooltip>
</span>
</template>
<n-input
v-model:value="settingsForm.defaultAdvanceReminderRules"
placeholder="例如3&09:30;0&09:30;"
/>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="过期提醒">
<n-checkbox-group v-model:value="settingsForm.overdueReminderDays">
<n-space :wrap="true" size="small">
<n-checkbox v-for="option in overdueReminderOptions" :key="option.value" :value="option.value">
{{ option.label }}
</n-checkbox>
</n-space>
</n-checkbox-group>
<n-form-item>
<template #label>
<span class="label-with-tip">
<span>过期提醒规则</span>
<n-tooltip trigger="hover">
<template #trigger>
<n-icon class="label-with-tip__icon" :component="helpCircleOutline" />
</template>
<span>格式说明天数&时间;例如 1&09:30; 表示过期 1 天后在 09:30 提醒多条规则用 ; 分隔</span>
</n-tooltip>
</span>
</template>
<n-input
v-model:value="settingsForm.defaultOverdueReminderRules"
placeholder="例如1&09:30;2&09:30;3&09:30;"
/>
</n-form-item>
</n-grid-item>
</n-grid>
<n-grid :cols="formCols" :x-gap="12">
<n-grid-item>
<n-form-item label="提醒策略">
<div class="switch-row">
<div class="switch-group">
<div class="switch-group__item">
<span class="switch-inline-label">到期当天提醒</span>
<n-switch v-model:value="settingsForm.notifyOnDueDay" />
</div>
<div class="switch-group__item">
<span class="switch-inline-label">多订阅合并通知</span>
<n-switch v-model:value="settingsForm.mergeMultiSubscriptionNotifications" />
</div>
</div>
</n-form-item>
</div>
</n-grid-item>
<n-grid-item>
<n-form-item label="其他">
<div class="switch-row">
<div class="switch-group switch-group--single">
<div class="switch-group__item">
<n-switch v-model:value="settingsForm.enableTagBudgets" />
<span class="switch-label">启用标签月预算</span>
</div>
</div>
</n-form-item>
</div>
</n-grid-item>
</n-grid>
@@ -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<AiProviderPreset, 'custom'>,
@@ -500,6 +524,7 @@ const AI_PROVIDER_PRESETS: Record<
const settingsForm = reactive<Settings>({
baseCurrency: 'CNY',
defaultNotifyDays: 3,
defaultAdvanceReminderRules: DEFAULT_ADVANCE_REMINDER_RULES,
rememberSessionDays: 7,
notifyOnDueDay: true,
mergeMultiSubscriptionNotifications: true,
@@ -507,6 +532,7 @@ const settingsForm = reactive<Settings>({
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));

View File

@@ -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<Tag[]>([])
const detail = ref<SubscriptionDetail | null>(null)
const paymentRecords = ref<PaymentRecord[]>([])
const currencies = ref<string[]>(['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) {

View File

@@ -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<Settings>({
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,

View File

@@ -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<string, number>
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

View File

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

View File

@@ -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: '',

View File

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