mirror of
https://github.com/Smile-QWQ/SubTracker.git
synced 2026-05-06 23:33:57 +08:00
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:
@@ -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[]
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 * * *'
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
177
apps/api/src/services/reminder-rules.service.ts
Normal file
177
apps/api/src/services/reminder-rules.service.ts
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -56,4 +56,5 @@ describe('notification routes', () => {
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(notificationMocks.sendTestTelegramNotificationMock).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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: [] }>()
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
37
apps/web/src/utils/reminder-rules.ts
Normal file
37
apps/web/src/utils/reminder-rules.ts
Normal 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(';')
|
||||
}
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user