feat: 新增定时账单导入提醒与通知直达导入页

- 新增通知配置、渠道、发送日志三张表(MySQL/SQLite 双建表脚本)及实体、Mapper、Service、Controller
- 支持邮箱(SMTP)与 Server 酱两种推送渠道,抽象 NotificationSender 工厂统一发送
- 新增 NotificationScheduler 定时任务,按每天/每周/每月周期检查并推送账单导入提醒
- 新增全局通知链接地址配置(/api/app/notification-url),随提醒一并下发
- 前端系统设置页新增「定时通知」面板,支持周期配置、渠道增删改测、立即发送与发送日志
- 新增 /bill-import 直达页,供提醒链接一键打开账单上传弹窗
- 登录支持 redirect 回跳,仅允许站内路径以防开放重定向
This commit is contained in:
2026-09-18 16:47:09 +08:00
parent c1f5d02564
commit e49b6b7b81
45 changed files with 3469 additions and 513 deletions
+1
View File
@@ -26,6 +26,7 @@ repositories {
dependencies { dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-mail'
implementation 'org.springframework.security:spring-security-crypto' implementation 'org.springframework.security:spring-security-crypto'
implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12' implementation 'com.baomidou:mybatis-plus-spring-boot3-starter:3.5.12'
+105
View File
@@ -0,0 +1,105 @@
// 定时通知相关 API 接口:提醒周期配置、推送渠道管理与发送日志
import request from './request'
/** 通知周期配置 */
export interface NotificationConfig {
enabled: boolean
frequency: string
dayOfMonth: number
dayOfWeek: number
sendTime: string
lastSentAt: string | null
}
/** 通知渠道 */
export interface NotificationChannel {
id: number
type: string
name: string
enabled: boolean
config: Record<string, any>
hasPassword: boolean
createdAt: string
updatedAt: string
}
/** 通知发送日志 */
export interface NotificationLog {
id: number
channelId: number | null
channelType: string | null
success: boolean
message: string | null
createdAt: string
}
/** 获取当前用户的通知配置 */
export function getNotificationConfig() {
return request.get('/notification/config')
}
/** 保存当前用户的通知配置 */
export function saveNotificationConfig(data: {
enabled: boolean
frequency: string
dayOfMonth: number
dayOfWeek: number
sendTime: string
}) {
return request.put('/notification/config', data)
}
/** 查询通知渠道列表 */
export function listNotificationChannels() {
return request.get('/notification/channels')
}
/** 新增通知渠道 */
export function createNotificationChannel(data: {
type: string
name: string
enabled: boolean
config: Record<string, any>
}) {
return request.post('/notification/channels', data)
}
/** 修改通知渠道,邮箱授权码留空表示不修改 */
export function updateNotificationChannel(id: number, data: {
type: string
name: string
enabled: boolean
config: Record<string, any>
}) {
return request.put(`/notification/channels/${id}`, data)
}
/** 删除通知渠道 */
export function deleteNotificationChannel(id: number) {
return request.delete(`/notification/channels/${id}`)
}
/** 向指定渠道发送测试消息(耗时取决于渠道,放宽超时) */
export function testNotificationChannel(id: number) {
return request.post(`/notification/channels/${id}/test`, {}, { timeout: 60000 })
}
/** 立即向所有已启用渠道发送账单导入提醒 */
export function sendNotificationNow() {
return request.post('/notification/send-now', {}, { timeout: 60000 })
}
/** 查询最近的发送日志 */
export function listNotificationLogs(limit = 20) {
return request.get('/notification/logs', { params: { limit } })
}
/** 获取通知链接地址(全局应用配置) */
export function getNotificationUrl() {
return request.get('/app/notification-url')
}
/** 保存通知链接地址(全局应用配置) */
export function updateNotificationUrl(url: string) {
return request.put('/app/notification-url', { url })
}
+1 -1
View File
@@ -146,7 +146,7 @@ onMounted(loadConfig)
</NInput> </NInput>
</NFormItem> </NFormItem>
<NFormItem> <NFormItem>
<NSpace> <NSpace justify="end" style="width: 100%;">
<NButton type="primary" :loading="saving" @click="handleSave"> <NButton type="primary" :loading="saving" @click="handleSave">
<template #icon> <template #icon>
<NIcon :component="SaveOutline" /> <NIcon :component="SaveOutline" />
@@ -208,7 +208,7 @@ onMounted(loadStatus)
</template> </template>
<NFormItem> <NFormItem>
<NSpace> <NSpace justify="end" style="width: 100%;">
<NButton :loading="testing" @click="handleTest"> <NButton :loading="testing" @click="handleTest">
<template #icon> <template #icon>
<NIcon :component="FlashOutline" /> <NIcon :component="FlashOutline" />
@@ -0,0 +1,548 @@
<script setup lang="ts">
// 定时通知配置面板:设置账单导入提醒周期,管理邮箱/Server 酱推送渠道,并查看发送日志。
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, useDialog, NCard, NForm, NFormItem, NInput, NInputNumber, NSwitch, NButton,
NSpace, NAlert, NText, NTag, NIcon, NSelect, NModal, NDivider,
} from 'naive-ui'
import {
NotificationsOutline, SaveOutline, AddOutline, TrashOutline, CreateOutline, SendOutline,
} from '@vicons/ionicons5'
import {
getNotificationConfig, saveNotificationConfig, listNotificationChannels,
createNotificationChannel, updateNotificationChannel, deleteNotificationChannel,
testNotificationChannel, sendNotificationNow, listNotificationLogs,
getNotificationUrl, updateNotificationUrl,
} from '../api/notification'
import type { NotificationChannel, NotificationLog } from '../api/notification'
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
// 提醒周期表单
const config = ref({ enabled: false, frequency: 'MONTHLY', dayOfMonth: 1, dayOfWeek: 1, sendTime: '09:00' })
const saving = ref(false)
const sendingNow = ref(false)
// 通知链接地址(全局应用配置,用于通知正文中的跳转链接)
const linkUrl = ref('')
const savingLink = ref(false)
// 渠道与日志
const channels = ref<NotificationChannel[]>([])
const logs = ref<NotificationLog[]>([])
const testingId = ref<number | null>(null)
// 渠道编辑弹窗
const showChannelModal = ref(false)
const editingId = ref<number | null>(null)
const editingHasPassword = ref(false)
const savingChannel = ref(false)
const channelForm = ref(emptyChannelForm())
/** 渠道表单默认值 */
function emptyChannelForm() {
return {
type: 'SERVERCHAN',
name: '',
enabled: true,
sendKey: '',
smtpHost: 'smtp.qq.com',
smtpPort: 465,
username: '',
password: '',
to: '',
ssl: true,
}
}
const frequencyOptions = computed(() => [
{ label: t('notification.freqDaily'), value: 'DAILY' },
{ label: t('notification.freqWeekly'), value: 'WEEKLY' },
{ label: t('notification.freqMonthly'), value: 'MONTHLY' },
])
const weekOptions = computed(() => [
{ label: t('notification.weekday1'), value: 1 },
{ label: t('notification.weekday2'), value: 2 },
{ label: t('notification.weekday3'), value: 3 },
{ label: t('notification.weekday4'), value: 4 },
{ label: t('notification.weekday5'), value: 5 },
{ label: t('notification.weekday6'), value: 6 },
{ label: t('notification.weekday7'), value: 7 },
])
// 每月提醒日限制在 1-28,避免不同月份天数差异
const monthDayOptions = computed(() => Array.from({ length: 28 }, (_, i) => ({ label: String(i + 1), value: i + 1 })))
// 前端开放 Server 酱与邮箱两种推送渠道
const channelTypeOptions = computed(() => [
{ label: t('notification.typeServerChan'), value: 'SERVERCHAN' },
{ label: t('notification.typeEmail'), value: 'EMAIL' },
])
/** 渠道类型显示名 */
function channelTypeLabel(type: string | null) {
if (type === 'SERVERCHAN') return t('notification.typeServerChan')
if (type === 'EMAIL') return t('notification.typeEmail')
return type || ''
}
/** 渠道类型标签颜色 */
function channelTagType(type: string) {
if (type === 'SERVERCHAN') return 'success'
if (type === 'EMAIL') return 'warning'
return 'info'
}
/** 格式化后端时间字符串用于展示 */
function formatTime(value: string | null) {
return value ? value.replace('T', ' ').slice(0, 19) : ''
}
/** 加载通知配置、渠道与日志 */
async function loadAll() {
try {
const [configRes, channelRes, logRes, linkRes]: any = await Promise.all([
getNotificationConfig(), listNotificationChannels(), listNotificationLogs(20), getNotificationUrl(),
])
linkUrl.value = linkRes.data || ''
const data = configRes.data
config.value = {
enabled: !!data.enabled,
frequency: data.frequency || 'MONTHLY',
dayOfMonth: data.dayOfMonth || 1,
dayOfWeek: data.dayOfWeek || 1,
sendTime: data.sendTime || '09:00',
}
channels.value = channelRes.data || []
logs.value = logRes.data || []
} catch (e: any) {
message.error(e.message || t('notification.loadFailed'))
}
}
/** 保存通知链接地址(全局应用配置) */
async function handleSaveLinkUrl() {
savingLink.value = true
try {
const res: any = await updateNotificationUrl(linkUrl.value.trim())
linkUrl.value = res.data || ''
message.success(t('notification.linkUrlSaveSuccess'))
} catch (e: any) {
message.error(e.message || t('notification.linkUrlSaveFailed'))
} finally {
savingLink.value = false
}
}
/** 保存提醒周期 */
async function handleSaveConfig() {
if (!/^\d{2}:\d{2}$/.test(config.value.sendTime)) {
message.error(t('notification.sendTimeInvalid'))
return
}
saving.value = true
try {
await saveNotificationConfig({ ...config.value })
message.success(t('notification.saveSuccess'))
await loadAll()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
saving.value = false
}
}
/** 打开渠道弹窗,传入渠道表示编辑 */
function openChannelModal(channel: NotificationChannel | null) {
editingId.value = channel ? channel.id : null
editingHasPassword.value = !!channel?.hasPassword
const base = emptyChannelForm()
if (channel) {
const cfg = channel.config || {}
Object.assign(base, {
type: channel.type,
name: channel.name,
enabled: channel.enabled,
sendKey: cfg.sendKey || '',
smtpHost: cfg.smtpHost || 'smtp.qq.com',
smtpPort: cfg.smtpPort || 465,
username: cfg.username || '',
password: '',
to: cfg.to || '',
ssl: cfg.ssl !== false,
})
}
channelForm.value = base
showChannelModal.value = true
}
/** 根据类型构建渠道配置,邮箱授权码为空时不提交 */
function buildChannelConfig() {
const form = channelForm.value
if (form.type === 'SERVERCHAN') {
return { sendKey: form.sendKey.trim() }
}
const config: Record<string, any> = {
smtpHost: form.smtpHost.trim(),
smtpPort: form.smtpPort,
username: form.username.trim(),
to: form.to.trim(),
ssl: form.ssl,
}
if (form.password) {
config.password = form.password
}
return config
}
/** 保存渠道 */
async function handleSaveChannel() {
const form = channelForm.value
if (form.type === 'SERVERCHAN') {
if (!form.sendKey.trim()) {
message.error(t('notification.sendKeyRequired'))
return
}
} else if (!form.username.trim()) {
message.error(t('notification.usernameRequired'))
return
}
savingChannel.value = true
try {
const payload = { type: form.type, name: form.name, enabled: form.enabled, config: buildChannelConfig() }
if (editingId.value) {
await updateNotificationChannel(editingId.value, payload)
} else {
await createNotificationChannel(payload)
}
message.success(t('notification.saveChannelSuccess'))
showChannelModal.value = false
await loadAll()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
savingChannel.value = false
}
}
/** 测试渠道 */
async function handleTestChannel(channel: NotificationChannel) {
testingId.value = channel.id
try {
await testNotificationChannel(channel.id)
message.success(t('notification.testSuccess'))
} catch (e: any) {
message.error(e.message || t('notification.testFailed'))
} finally {
testingId.value = null
await loadAll()
}
}
/** 删除渠道 */
function handleDeleteChannel(channel: NotificationChannel) {
dialog.warning({
title: t('common.confirmDelete'),
content: t('notification.confirmDeleteChannel', { name: channel.name }),
positiveText: t('common.confirm'),
negativeText: t('common.cancel'),
onPositiveClick: async () => {
try {
await deleteNotificationChannel(channel.id)
message.success(t('common.deleteSuccess'))
await loadAll()
} catch (e: any) {
message.error(e.message || t('common.deleteFailed'))
}
},
})
}
/** 立即发送提醒 */
async function handleSendNow() {
sendingNow.value = true
try {
const res: any = await sendNotificationNow()
message.success(t('notification.sendNowSuccess', { count: res.data }))
} catch (e: any) {
message.error(e.message || t('notification.sendNowFailed'))
} finally {
sendingNow.value = false
await loadAll()
}
}
onMounted(loadAll)
</script>
<template>
<NCard :bordered="false" class="config-card">
<template #header>
<NSpace align="center" :size="8">
<NIcon :component="NotificationsOutline" />
<span>{{ t('settings.notificationSection') }}</span>
</NSpace>
</template>
<template #header-extra>
<NSpace align="center" :size="8">
<NTag :type="config.enabled ? 'success' : 'default'" size="small" :bordered="false">
{{ config.enabled ? t('notification.statusOn') : t('notification.statusOff') }}
</NTag>
<NButton size="small" quaternary :loading="sendingNow" @click="handleSendNow">
<template #icon>
<NIcon :component="SendOutline" />
</template>
{{ t('notification.sendNow') }}
</NButton>
</NSpace>
</template>
<NAlert type="info" :show-icon="true" style="margin-bottom: 20px;">
{{ t('notification.intro') }}
</NAlert>
<NForm label-placement="left" label-width="130">
<NFormItem :label="t('notification.linkUrl')">
<NSpace align="center" :size="8" style="width: 100%;">
<NInput
v-model:value="linkUrl"
:placeholder="t('notification.linkUrlPlaceholder')"
style="max-width: 420px"
/>
<NButton :loading="savingLink" @click="handleSaveLinkUrl">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('notification.save') }}
</NButton>
</NSpace>
</NFormItem>
<NFormItem :label="t('notification.enabled')">
<div class="switch-row">
<NSwitch v-model:value="config.enabled" />
<NText depth="3" style="font-size: 12px;">{{ t('notification.enabledHint') }}</NText>
</div>
</NFormItem>
<NFormItem :label="t('notification.frequency')">
<NSpace align="center" :size="8">
<NSelect v-model:value="config.frequency" :options="frequencyOptions" style="width: 120px" />
<NSelect
v-if="config.frequency === 'WEEKLY'"
v-model:value="config.dayOfWeek"
:options="weekOptions"
style="width: 120px"
/>
<template v-else-if="config.frequency === 'MONTHLY'">
<NSelect v-model:value="config.dayOfMonth" :options="monthDayOptions" style="width: 90px" />
<NText depth="3">{{ t('notification.dayOfMonthSuffix') }}</NText>
</template>
</NSpace>
</NFormItem>
<NFormItem :label="t('notification.sendTime')">
<NInput
v-model:value="config.sendTime"
:placeholder="t('notification.sendTimePlaceholder')"
style="width: 160px"
/>
</NFormItem>
<NFormItem>
<NSpace justify="end" style="width: 100%;">
<NButton type="primary" :loading="saving" @click="handleSaveConfig">
<template #icon>
<NIcon :component="SaveOutline" />
</template>
{{ t('notification.save') }}
</NButton>
</NSpace>
</NFormItem>
</NForm>
<NDivider />
<div class="section-header">
<span class="section-title">{{ t('notification.channels') }}</span>
<NButton size="small" @click="openChannelModal(null)">
<template #icon>
<NIcon :component="AddOutline" />
</template>
{{ t('notification.addChannel') }}
</NButton>
</div>
<div v-if="channels.length" class="channel-list">
<div v-for="channel in channels" :key="channel.id" class="channel-item">
<div class="channel-main">
<NTag size="small" :bordered="false" :type="channelTagType(channel.type)">
{{ channelTypeLabel(channel.type) }}
</NTag>
<span class="channel-name">{{ channel.name }}</span>
<NTag v-if="channel.enabled" size="small" type="success" :bordered="false">
{{ t('notification.enabledLabel') }}
</NTag>
<NTag v-else size="small" :bordered="false">{{ t('notification.disabledLabel') }}</NTag>
</div>
<NSpace :size="4" align="center">
<NButton size="tiny" quaternary :loading="testingId === channel.id" @click="handleTestChannel(channel)">
{{ t('notification.test') }}
</NButton>
<NButton size="tiny" quaternary @click="openChannelModal(channel)">
<template #icon>
<NIcon :component="CreateOutline" />
</template>
</NButton>
<NButton size="tiny" quaternary type="error" @click="handleDeleteChannel(channel)">
<template #icon>
<NIcon :component="TrashOutline" />
</template>
</NButton>
</NSpace>
</div>
</div>
<NText v-else depth="3">{{ t('notification.noChannels') }}</NText>
<NDivider />
<div class="section-header">
<span class="section-title">{{ t('notification.logs') }}</span>
</div>
<div v-if="logs.length" class="log-list">
<div v-for="log in logs" :key="log.id" class="log-item">
<NTag size="tiny" :bordered="false" :type="log.success ? 'success' : 'error'">
{{ log.success ? t('notification.success') : t('notification.failed') }}
</NTag>
<span class="log-time">{{ formatTime(log.createdAt) }}</span>
<span class="log-channel">{{ channelTypeLabel(log.channelType) }}</span>
<span class="log-message">{{ log.message }}</span>
</div>
</div>
<NText v-else depth="3">{{ t('notification.noLogs') }}</NText>
<NModal
v-model:show="showChannelModal"
preset="card"
:title="editingId ? t('notification.editChannel') : t('notification.addChannel')"
style="width: 560px"
>
<NForm label-placement="left" label-width="120">
<NFormItem :label="t('notification.channelType')">
<NSelect v-model:value="channelForm.type" :options="channelTypeOptions" :disabled="!!editingId" />
</NFormItem>
<NFormItem :label="t('notification.channelName')">
<NInput v-model:value="channelForm.name" :placeholder="t('notification.channelNamePlaceholder')" />
</NFormItem>
<template v-if="channelForm.type === 'SERVERCHAN'">
<NFormItem :label="t('notification.sendKey')">
<NInput v-model:value="channelForm.sendKey" :placeholder="t('notification.sendKeyPlaceholder')" />
</NFormItem>
</template>
<template v-else>
<NFormItem :label="t('notification.smtpHost')">
<NInput v-model:value="channelForm.smtpHost" placeholder="smtp.qq.com" />
</NFormItem>
<NFormItem :label="t('notification.smtpPort')">
<NInputNumber v-model:value="channelForm.smtpPort" :min="1" :max="65535" style="width: 160px" />
</NFormItem>
<NFormItem :label="t('notification.username')">
<NInput v-model:value="channelForm.username" placeholder="example@qq.com" />
</NFormItem>
<NFormItem :label="t('notification.password')">
<NInput
v-model:value="channelForm.password"
type="password"
show-password-on="click"
:placeholder="editingHasPassword ? t('notification.passwordKeep') : t('notification.passwordPlaceholder')"
/>
</NFormItem>
<NFormItem :label="t('notification.to')">
<NInput v-model:value="channelForm.to" :placeholder="t('notification.toPlaceholder')" />
</NFormItem>
<NFormItem :label="t('notification.ssl')">
<NSwitch v-model:value="channelForm.ssl" />
</NFormItem>
</template>
<NFormItem :label="t('notification.enabledLabel')">
<NSwitch v-model:value="channelForm.enabled" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="showChannelModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="savingChannel" @click="handleSaveChannel">
{{ t('common.confirm') }}
</NButton>
</NSpace>
</template>
</NModal>
</NCard>
</template>
<style scoped>
.switch-row {
display: flex;
align-items: center;
gap: 12px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.section-title {
font-weight: 600;
}
.channel-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.channel-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
border: 1px solid #efefef;
border-radius: 6px;
}
.channel-main {
display: flex;
align-items: center;
gap: 8px;
}
.channel-name {
font-weight: 500;
}
.log-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.log-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
}
.log-time {
color: #999;
}
.log-channel {
color: #666;
min-width: 100px;
}
.log-message {
color: #333;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
@@ -73,7 +73,7 @@ onMounted(loadStatus)
</NSpace> </NSpace>
</div> </div>
<NSpace style="margin-top: 16px;"> <NSpace justify="end" style="margin-top: 16px; width: 100%;">
<NButton type="primary" :loading="saving" @click="handleSave"> <NButton type="primary" :loading="saving" @click="handleSave">
<template #icon> <template #icon>
<NIcon :component="SaveOutline" /> <NIcon :component="SaveOutline" />
+68
View File
@@ -128,6 +128,7 @@ export default {
aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' }, aiSection: { zh: 'AI 模型配置', en: 'AI Model', ja: 'AI モデル' },
viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' }, viewUsage: { zh: '使用情况', en: 'Usage', ja: '利用状況' },
registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' }, registrationSection: { zh: '注册设置', en: 'Registration', ja: '登録設定' },
notificationSection: { zh: '定时通知', en: 'Scheduled Notifications', ja: '定期通知' },
}, },
// 注册设置 // 注册设置
@@ -142,6 +143,73 @@ export default {
loadFailed: { zh: '加载注册状态失败', en: 'Failed to load registration status', ja: '登録状態の読み込みに失敗しました' }, loadFailed: { zh: '加载注册状态失败', en: 'Failed to load registration status', ja: '登録状態の読み込みに失敗しました' },
}, },
// 定时通知
notification: {
statusOn: { zh: '已开启', en: 'Enabled', ja: '有効' },
statusOff: { zh: '已关闭', en: 'Disabled', ja: '無効' },
intro: { zh: '按自定义周期,通过邮箱或 Server 酱提醒你及时导入新的账单。', en: 'Get reminded via email or ServerChan to import new bills on your own schedule.', ja: 'カスタム周期でメール・ServerChan を通じて、新しい明細のインポートをお知らせします。' },
linkUrl: { zh: '通知链接地址', en: 'Notification link URL', ja: '通知リンク URL' },
linkUrlPlaceholder: { zh: '如 https://finance.rosercode.cn/bill-import', en: 'e.g. https://finance.rosercode.cn/bill-import', ja: '例:https://finance.rosercode.cn/bill-import' },
linkUrlSaveSuccess: { zh: '通知链接地址已保存', en: 'Notification link URL saved', ja: '通知リンク URL を保存しました' },
linkUrlSaveFailed: { zh: '保存通知链接地址失败', en: 'Failed to save notification link URL', ja: '通知リンク URL の保存に失敗しました' },
enabled: { zh: '启用定时提醒', en: 'Enable reminders', ja: '定期リマインダーを有効化' },
enabledHint: { zh: '开启后按下方周期自动发送提醒', en: 'Sends reminders automatically based on the schedule below', ja: '有効にすると以下の周期で自動送信します' },
frequency: { zh: '提醒周期', en: 'Frequency', ja: '通知周期' },
freqDaily: { zh: '每天', en: 'Daily', ja: '毎日' },
freqWeekly: { zh: '每周', en: 'Weekly', ja: '毎週' },
freqMonthly: { zh: '每月', en: 'Monthly', ja: '毎月' },
weekday1: { zh: '周一', en: 'Monday', ja: '月曜日' },
weekday2: { zh: '周二', en: 'Tuesday', ja: '火曜日' },
weekday3: { zh: '周三', en: 'Wednesday', ja: '水曜日' },
weekday4: { zh: '周四', en: 'Thursday', ja: '木曜日' },
weekday5: { zh: '周五', en: 'Friday', ja: '金曜日' },
weekday6: { zh: '周六', en: 'Saturday', ja: '土曜日' },
weekday7: { zh: '周日', en: 'Sunday', ja: '日曜日' },
dayOfMonthSuffix: { zh: '日', en: 'day', ja: '日' },
sendTime: { zh: '提醒时间', en: 'Time', ja: '通知時刻' },
sendTimePlaceholder: { zh: '例如 09:00', en: 'e.g. 09:00', ja: '例:09:00' },
sendTimeInvalid: { zh: '提醒时间格式应为 HH:mm', en: 'Time must be in HH:mm format', ja: '時刻は HH:mm 形式で入力してください' },
save: { zh: '保存', en: 'Save', ja: '保存' },
saveSuccess: { zh: '通知设置已保存', en: 'Notification settings saved', ja: '通知設定を保存しました' },
sendNow: { zh: '立即发送', en: 'Send now', ja: '今すぐ送信' },
sendNowSuccess: { zh: '已发送 {count} 个渠道', en: 'Sent to {count} channel(s)', ja: '{count} 件のチャネルに送信しました' },
sendNowFailed: { zh: '发送提醒失败', en: 'Failed to send reminder', ja: 'リマインダーの送信に失敗しました' },
channels: { zh: '推送渠道', en: 'Channels', ja: '通知チャネル' },
addChannel: { zh: '添加渠道', en: 'Add channel', ja: 'チャネルを追加' },
editChannel: { zh: '编辑渠道', en: 'Edit channel', ja: 'チャネルを編集' },
channelType: { zh: '渠道类型', en: 'Type', ja: 'チャネル種別' },
channelName: { zh: '渠道名称', en: 'Name', ja: 'チャネル名' },
channelNamePlaceholder: { zh: '便于识别的名称', en: 'A name to identify it', ja: '識別しやすい名前' },
enabledLabel: { zh: '已启用', en: 'Enabled', ja: '有効' },
disabledLabel: { zh: '已停用', en: 'Disabled', ja: '無効' },
typeEmail: { zh: '邮箱', en: 'Email', ja: 'メール' },
typeServerChan: { zh: 'Server 酱', en: 'ServerChan', ja: 'ServerChan' },
smtpHost: { zh: 'SMTP 服务器', en: 'SMTP host', ja: 'SMTP サーバー' },
smtpPort: { zh: 'SMTP 端口', en: 'SMTP port', ja: 'SMTP ポート' },
username: { zh: '邮箱账号', en: 'Email account', ja: 'メールアカウント' },
usernameRequired: { zh: '请填写邮箱账号', en: 'Please enter the email account', ja: 'メールアカウントを入力してください' },
sendKey: { zh: 'SendKey', en: 'SendKey', ja: 'SendKey' },
sendKeyPlaceholder: { zh: '在 sct.ftqq.com 获取的 SendKey', en: 'SendKey obtained from sct.ftqq.com', ja: 'sct.ftqq.com で取得した SendKey' },
sendKeyRequired: { zh: '请填写 Server 酱 SendKey', en: 'Please enter the ServerChan SendKey', ja: 'ServerChan の SendKey を入力してください' },
password: { zh: '授权码', en: 'Auth code', ja: '認証コード' },
passwordPlaceholder: { zh: '邮箱 SMTP 授权码', en: 'SMTP authorization code', ja: 'SMTP 認証コード' },
passwordKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep', ja: '設定済み。空欄なら変更しません' },
to: { zh: '收件人', en: 'Recipient', ja: '宛先' },
toPlaceholder: { zh: '默认与账号相同', en: 'Defaults to the account', ja: '既定ではアカウントと同じ' },
ssl: { zh: '使用 SSL', en: 'Use SSL', ja: 'SSL を使用' },
test: { zh: '测试', en: 'Test', ja: 'テスト' },
testSuccess: { zh: '测试消息已发送', en: 'Test message sent', ja: 'テストメッセージを送信しました' },
testFailed: { zh: '测试发送失败', en: 'Test failed', ja: 'テスト送信に失敗しました' },
confirmDeleteChannel: { zh: '确认删除渠道「{name}」?', en: 'Delete channel "{name}"?', ja: 'チャネル「{name}」を削除しますか?' },
saveChannelSuccess: { zh: '渠道已保存', en: 'Channel saved', ja: 'チャネルを保存しました' },
noChannels: { zh: '暂无渠道,请先添加', en: 'No channels yet, please add one', ja: 'チャネルがありません。追加してください' },
logs: { zh: '最近发送记录', en: 'Recent deliveries', ja: '最近の送信履歴' },
success: { zh: '成功', en: 'Success', ja: '成功' },
failed: { zh: '失败', en: 'Failed', ja: '失敗' },
noLogs: { zh: '暂无发送记录', en: 'No delivery records yet', ja: '送信履歴はまだありません' },
loadFailed: { zh: '加载通知设置失败', en: 'Failed to load notification settings', ja: '通知設定の読み込みに失敗しました' },
},
// 模型使用情况 // 模型使用情况
aiUsage: { aiUsage: {
title: { zh: '模型使用情况', en: 'Model Usage', ja: 'モデル利用状況' }, title: { zh: '模型使用情况', en: 'Model Usage', ja: 'モデル利用状況' },
+7 -2
View File
@@ -23,6 +23,11 @@ const router = createRouter({
name: 'Register', name: 'Register',
component: () => import('../views/Register.vue'), // 注册页 component: () => import('../views/Register.vue'), // 注册页
}, },
{
path: '/bill-import',
name: 'BillImport',
component: () => import('../views/BillImport.vue'), // 账单导入(独立页,无侧边栏)
},
{ {
path: '/', path: '/',
component: () => import('../views/Layout.vue'), // 主布局(带侧边栏) component: () => import('../views/Layout.vue'), // 主布局(带侧边栏)
@@ -101,9 +106,9 @@ router.beforeEach(async (to, _from, next) => {
} }
const userStore = useUserStore() const userStore = useUserStore()
// 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页 // 如果目标页面不是登录/注册页,且没有 token,则重定向到登录页并记录来源,登录后可回跳
if (to.path !== '/login' && to.path !== '/register' && !userStore.token) { if (to.path !== '/login' && to.path !== '/register' && !userStore.token) {
next('/login') next({ path: '/login', query: { redirect: to.fullPath } })
} else { } else {
next() next()
} }
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
// 账单导入页:以独立页面形式承载账单导入面板(非弹窗),可由通知链接直达
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { NCard } from 'naive-ui'
import BillImportPanel from './BillImportPanel.vue'
const router = useRouter()
const { t } = useI18n()
/** 面板关闭/完成后返回收支记录页 */
function handleClose() {
router.push('/transactions')
}
</script>
<template>
<div class="bill-import-page">
<NCard :title="t('import.title')" :bordered="true" class="bill-import-card">
<BillImportPanel @close="handleClose" />
</NCard>
</div>
</template>
<style scoped>
.bill-import-page {
min-height: 100vh;
box-sizing: border-box;
padding: 24px;
background: #f5f7fa;
overflow-y: auto;
}
.bill-import-card {
max-width: 960px;
margin: 0 auto;
}
</style>
+5 -503
View File
@@ -1,17 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
// 账单导入弹窗:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单 // 账单导入弹窗:以弹窗形式承载 BillImportPanel,供收支记录页调用
import { ref, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { import { NModal } from 'naive-ui'
useMessage, NModal, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable, import BillImportPanel from './BillImportPanel.vue'
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
} from 'naive-ui'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
import { parseBill, confirmImport } from '../api/transaction'
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
const props = defineProps<{ show: boolean }>() const props = defineProps<{ show: boolean }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -19,368 +10,12 @@ const emit = defineEmits<{
(e: 'imported'): void (e: 'imported'): void
}>() }>()
const message = useMessage() const { t } = useI18n()
const { t } = useI18n() // 国际化
// 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝与微信
const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
])
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType {
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
}
// 当前账单类型,初始值取上次选择(个人偏好)
const billType = ref<BillType>(loadBillTypePref())
// 各账单类型允许上传的文件扩展名
const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv'],
WECHAT: ['.xlsx'],
}
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
// 当前账单类型的文件格式提示
const fileHint = computed(() => {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
return t('import.fileHint', { label, exts })
})
// 当前账单类型的导出路径提示
const exportHint = computed(() =>
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
)
// 弹窗阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
const file = ref<File | null>(null)
const uploadFileList = ref<UploadFileInfo[]>([])
const parsing = ref(false)
const importing = ref(false)
// 解析结果与勾选状态
const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([])
// 预览筛选条件
const filterKeyword = ref('')
const filterType = ref<string | null>(null)
const filterStatus = ref<string | null>(null)
const filterMinAmount = ref<number | null>(null)
const filterMaxAmount = ref<number | null>(null)
// 类型筛选选项
const typeFilterOptions = computed(() => [
{ label: t('import.typeFilter.income'), value: 'INCOME' },
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
])
// 状态筛选选项
const statusFilterOptions = computed(() => [
{ label: t('import.statusFilter.importable'), value: 'importable' },
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
{ label: t('import.statusFilter.error'), value: 'error' },
])
/** 重置预览筛选条件 */
function resetFilters() {
filterKeyword.value = ''
filterType.value = null
filterStatus.value = null
filterMinAmount.value = null
filterMaxAmount.value = null
}
// 默认分类
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
const expenseCategoryId = ref<number | null>(null)
const incomeCategoryId = ref<number | null>(null)
// 默认标签
const allTags = ref<{ label: string; value: number }[]>([])
const importTagIds = ref<number[]>([])
// 导入结果
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
/** 按筛选条件过滤后的账单行 */
const filteredRows = computed(() => {
const kw = filterKeyword.value.trim().toLowerCase()
return rows.value.filter((r) => {
if (kw) {
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
if (!text.includes(kw)) return false
}
if (filterType.value === 'NEUTRAL') {
if (r.includeInStats) return false
} else if (filterType.value && r.type !== filterType.value) {
return false
}
if (filterStatus.value === 'importable' && !r.importable) return false
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
if (filterStatus.value === 'error' && !r.error) return false
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
return true
})
})
/** 可导入的行(受筛选影响) */
const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable))
/** 已勾选的行 */
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
/** 是否全选(基于当前筛选出的可导入行) */
const allChecked = computed(
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
)
/** 是否存在部分勾选(基于当前筛选出的可导入行) */
const someChecked = computed(() => {
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
return checkedVisible.length > 0 && !allChecked.value
})
/** 表格列定义(随语言切换响应式更新) */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
{
title: () =>
h(NCheckbox, {
checked: allChecked.value,
indeterminate: someChecked.value,
disabled: selectableRows.value.length === 0,
onUpdateChecked: (v: boolean) => toggleAll(v),
}),
key: 'checked',
width: 50,
render: (row) =>
h(NCheckbox, {
checked: checkedRowNos.value.includes(row.rowNo),
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleRow(row, v),
}),
},
{
title: t('import.column.transactionTime'),
key: 'transactionTime',
width: 150,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
},
{
title: t('import.column.type'),
key: 'type',
width: 90,
render: (row) => {
if (!row.importable) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
}
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
}
return h(
NTag,
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
)
},
},
{
title: t('import.column.amount'),
key: 'amount',
width: 110,
render: (row) => {
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
},
},
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
{
title: t('import.column.status'),
key: 'tradeStatus',
width: 150,
render: (row) => {
if (row.error) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
}
if (row.duplicate) {
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
}
return h('span', null, row.tradeStatus || '-')
},
},
])
/** 加载分类选项 */
async function loadCategories() {
try {
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
} catch (e: any) {
message.error(e.message || t('import.loadCategoriesFailed'))
}
}
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 加载标签选项 */
async function loadTags() {
try {
const res: any = await getTagList()
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e: any) {
message.error(e.message || t('import.loadTagsFailed'))
}
}
/** 判断文件名是否符合当前账单类型允许的扩展名 */
function isAllowedFile(name: string) {
const exts = allowedExtensions[billType.value] || []
const lower = name.toLowerCase()
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
}
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
const last = options.fileList[options.fileList.length - 1]
if (last?.file && !isAllowedFile(last.name)) {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
message.error(t('import.errorFileType', { label, exts }))
uploadFileList.value = []
file.value = null
return
}
uploadFileList.value = options.fileList
file.value = last?.file ?? null
}
/** 解析账单 */
async function handleParse() {
if (!file.value) {
message.warning(t('import.selectFileFirst'))
return
}
parsing.value = true
try {
const res: any = await parseBill(file.value, billType.value)
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters()
stage.value = 'preview'
} catch (e: any) {
message.error(e.message || t('import.parseFailed'))
} finally {
parsing.value = false
}
}
/** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) {
if (!checkedRowNos.value.includes(row.rowNo)) {
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
}
} else {
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
}
}
/** 全选/取消全选 */
function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
const items: BillImportItem[] = checkedRows.value.map((r) => ({
externalNo: r.externalNo,
transactionTime: r.transactionTime,
type: r.type,
amount: r.amount,
counterparty: r.counterparty,
product: r.product,
includeInStats: r.includeInStats,
rawData: r.rawData,
}))
importing.value = true
try {
const res: any = await confirmImport({
billType: billType.value,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
tagIds: importTagIds.value.length ? importTagIds.value : undefined,
items,
})
result.value = res.data
stage.value = 'result'
emit('imported')
} catch (e: any) {
message.error(e.message || t('import.importFailed'))
} finally {
importing.value = false
}
}
/** 重置弹窗状态 */
function reset() {
stage.value = 'select'
file.value = null
uploadFileList.value = []
rows.value = []
checkedRowNos.value = []
result.value = null
expenseCategoryId.value = null
incomeCategoryId.value = null
importTagIds.value = []
resetFilters()
}
/** 关闭弹窗 */ /** 关闭弹窗 */
function handleClose() { function handleClose() {
emit('update:show', false) emit('update:show', false)
} }
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
watch(billType, (value) => {
uploadFileList.value = []
file.value = null
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
})
// 打开弹窗时重置并加载分类
watch(
() => props.show,
(visible) => {
if (visible) {
reset()
loadCategories()
loadTags()
}
},
)
</script> </script>
<template> <template>
@@ -392,139 +27,6 @@ watch(
:mask-closable="false" :mask-closable="false"
@update:show="handleClose" @update:show="handleClose"
> >
<!-- 第一步:选择账单类型与文件 --> <BillImportPanel @close="handleClose" @imported="emit('imported')" />
<template v-if="stage === 'select'">
<NSpace vertical :size="16">
<NSpace align="center">
<NText>{{ t('import.billType') }}</NText>
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
</NSpace>
<div>
<NText>{{ t('import.billFile') }}</NText>
<NUpload
:file-list="uploadFileList"
:default-upload="false"
:max="1"
:accept="acceptTypes"
style="margin-top: 8px;"
@change="handleFileChange"
>
<NUploadDragger>
<div style="padding: 24px 0; text-align: center;">
<NIcon size="48" :depth="3">
<CloudUploadOutline />
</NIcon>
<div style="font-size: 15px; margin-top: 8px;">{{ t('import.dropText') }}</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
{{ fileHint }}
</div>
</div>
</NUploadDragger>
</NUpload>
</div>
<NAlert type="info" :show-icon="true">
{{ exportHint }}
</NAlert>
<NSpace justify="end">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
</NSpace>
</NSpace>
</template>
<!-- 第二步:预览并勾选 -->
<template v-else-if="stage === 'preview'">
<NSpace vertical :size="12">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
<NSelect
v-model:value="expenseCategoryId"
:options="expenseCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
<NSelect
v-model:value="incomeCategoryId"
:options="incomeCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.defaultTags') }}</NText>
<NSelect
v-model:value="importTagIds"
:options="allTags"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('import.notSetTag')"
style="width: 200px;"
/>
<NCheckbox
:checked="allChecked"
:indeterminate="someChecked"
:disabled="selectableRows.length === 0"
@update:checked="toggleAll"
>
{{ t('import.selectAll') }}
</NCheckbox>
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
</NSpace>
<!-- 筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
</NSpace>
<NDataTable
:columns="columns"
:data="filteredRows"
:max-height="420"
:row-key="(row) => row.rowNo"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
<NButton type="primary" :loading="importing" @click="handleConfirm">
{{ t('import.confirmImport', { count: checkedRows.length }) }}
</NButton>
</NSpace>
</NSpace>
</template>
<!-- 第三步:导入结果 -->
<template v-else>
<NSpace vertical :size="12">
<NAlert type="success" :show-icon="true">
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
</NAlert>
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
{{ msg }}
</NText>
</div>
<NSpace justify="end">
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
</NSpace>
</NSpace>
</template>
</NModal> </NModal>
</template> </template>
+511
View File
@@ -0,0 +1,511 @@
<script setup lang="ts">
// 账单导入面板:先解析、后勾选导入(默认全选),当前支持支付宝与微信账单
// 以纯内容形式提供,可由弹窗(BillImportModal)或独立页面(BillImport)承载
import { ref, computed, h, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import {
useMessage, NSpace, NButton, NSelect, NUpload, NUploadDragger, NDataTable,
NCheckbox, NTag, NAlert, NText, NIcon, NInput, NInputNumber,
} from 'naive-ui'
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
import { CloudUploadOutline } from '@vicons/ionicons5'
import { parseBill, confirmImport } from '../api/transaction'
import type { BillType, ParsedBillRow, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
const emit = defineEmits<{
(e: 'close'): void
(e: 'imported'): void
}>()
const message = useMessage()
const { t } = useI18n() // 国际化
// 账单类型偏好存储键
const BILL_TYPE_PREFS_KEY = 'finance.billImport.billType'
// 账单类型选项,当前支持支付宝与微信
const billTypeOptions = computed(() => [
{ label: t('import.billTypeALIPAY'), value: 'ALIPAY' },
{ label: t('import.billTypeWECHAT'), value: 'WECHAT' },
])
/** 读取上次选择的账单类型偏好,非法值回退为支付宝 */
function loadBillTypePref(): BillType {
const saved = localStorage.getItem(BILL_TYPE_PREFS_KEY)
return saved === 'ALIPAY' || saved === 'WECHAT' ? saved : 'ALIPAY'
}
// 当前账单类型,初始值取上次选择(个人偏好)
const billType = ref<BillType>(loadBillTypePref())
// 各账单类型允许上传的文件扩展名
const allowedExtensions: Record<BillType, string[]> = {
ALIPAY: ['.csv'],
WECHAT: ['.xlsx'],
}
// 当前账单类型允许的扩展名,用于上传组件 accept 属性
const acceptTypes = computed(() => (allowedExtensions[billType.value] || []).join(','))
// 当前账单类型的文件格式提示
const fileHint = computed(() => {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = (allowedExtensions[billType.value] || []).join(t('import.extSeparator'))
return t('import.fileHint', { label, exts })
})
// 当前账单类型的导出路径提示
const exportHint = computed(() =>
billType.value === 'WECHAT' ? t('import.exportHintWECHAT') : t('import.exportHintALIPAY'),
)
// 面板阶段:选择文件 -> 预览勾选 -> 导入结果
const stage = ref<'select' | 'preview' | 'result'>('select')
const file = ref<File | null>(null)
const uploadFileList = ref<UploadFileInfo[]>([])
const parsing = ref(false)
const importing = ref(false)
// 解析结果与勾选状态
const rows = ref<ParsedBillRow[]>([])
const checkedRowNos = ref<number[]>([])
// 预览筛选条件
const filterKeyword = ref('')
const filterType = ref<string | null>(null)
const filterStatus = ref<string | null>(null)
const filterMinAmount = ref<number | null>(null)
const filterMaxAmount = ref<number | null>(null)
// 类型筛选选项
const typeFilterOptions = computed(() => [
{ label: t('import.typeFilter.income'), value: 'INCOME' },
{ label: t('import.typeFilter.expense'), value: 'EXPENSE' },
{ label: t('import.typeFilter.neutral'), value: 'NEUTRAL' },
])
// 状态筛选选项
const statusFilterOptions = computed(() => [
{ label: t('import.statusFilter.importable'), value: 'importable' },
{ label: t('import.statusFilter.duplicate'), value: 'duplicate' },
{ label: t('import.statusFilter.error'), value: 'error' },
])
/** 重置预览筛选条件 */
function resetFilters() {
filterKeyword.value = ''
filterType.value = null
filterStatus.value = null
filterMinAmount.value = null
filterMaxAmount.value = null
}
// 默认分类
const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([])
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
const expenseCategoryId = ref<number | null>(null)
const incomeCategoryId = ref<number | null>(null)
// 默认标签
const allTags = ref<{ label: string; value: number }[]>([])
const importTagIds = ref<number[]>([])
// 导入结果
const result = ref<{ total: number; imported: number; skipped: number; failed: number; messages: string[] } | null>(null)
/** 按筛选条件过滤后的账单行 */
const filteredRows = computed(() => {
const kw = filterKeyword.value.trim().toLowerCase()
return rows.value.filter((r) => {
if (kw) {
const text = `${r.counterparty || ''} ${r.product || ''} ${r.externalNo || ''}`.toLowerCase()
if (!text.includes(kw)) return false
}
if (filterType.value === 'NEUTRAL') {
if (r.includeInStats) return false
} else if (filterType.value && r.type !== filterType.value) {
return false
}
if (filterStatus.value === 'importable' && !r.importable) return false
if (filterStatus.value === 'duplicate' && !r.duplicate) return false
if (filterStatus.value === 'error' && !r.error) return false
if (filterMinAmount.value != null && Number(r.amount) < filterMinAmount.value) return false
if (filterMaxAmount.value != null && Number(r.amount) > filterMaxAmount.value) return false
return true
})
})
/** 可导入的行(受筛选影响) */
const selectableRows = computed(() => filteredRows.value.filter((r) => r.importable))
/** 已勾选的行 */
const checkedRows = computed(() => rows.value.filter((r) => checkedRowNos.value.includes(r.rowNo)))
/** 是否全选(基于当前筛选出的可导入行) */
const allChecked = computed(
() => selectableRows.value.length > 0 && selectableRows.value.every((r) => checkedRowNos.value.includes(r.rowNo)),
)
/** 是否存在部分勾选(基于当前筛选出的可导入行) */
const someChecked = computed(() => {
const checkedVisible = selectableRows.value.filter((r) => checkedRowNos.value.includes(r.rowNo))
return checkedVisible.length > 0 && !allChecked.value
})
/** 表格列定义(随语言切换响应式更新) */
const columns = computed<DataTableColumns<ParsedBillRow>>(() => [
{
title: () =>
h(NCheckbox, {
checked: allChecked.value,
indeterminate: someChecked.value,
disabled: selectableRows.value.length === 0,
onUpdateChecked: (v: boolean) => toggleAll(v),
}),
key: 'checked',
width: 50,
render: (row) =>
h(NCheckbox, {
checked: checkedRowNos.value.includes(row.rowNo),
disabled: !row.importable,
onUpdateChecked: (v: boolean) => toggleRow(row, v),
}),
},
{
title: t('import.column.transactionTime'),
key: 'transactionTime',
width: 150,
render: (row) => (row.transactionTime ? row.transactionTime.replace('T', ' ').substring(0, 16) : '-'),
},
{
title: t('import.column.type'),
key: 'type',
width: 90,
render: (row) => {
if (!row.importable) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => t('import.notImportable') })
}
if (!row.includeInStats) {
return h(NTag, { type: 'default', size: 'small' }, { default: () => t('import.notIncluded') })
}
return h(
NTag,
{ type: row.type === 'INCOME' ? 'success' : 'error', size: 'small' },
{ default: () => (row.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')) },
)
},
},
{
title: t('import.column.amount'),
key: 'amount',
width: 110,
render: (row) => {
const color = row.type === 'INCOME' ? '#18a058' : '#d03050'
return h('span', { style: { color, fontWeight: 'bold' } }, `${row.type === 'INCOME' ? '+' : '-'}${Number(row.amount).toFixed(2)}`)
},
},
{ title: t('import.column.counterparty'), key: 'counterparty', width: 160, ellipsis: { tooltip: true } },
{ title: t('import.column.product'), key: 'product', ellipsis: { tooltip: true } },
{
title: t('import.column.status'),
key: 'tradeStatus',
width: 150,
render: (row) => {
if (row.error) {
return h(NTag, { type: 'error', size: 'small' }, { default: () => row.error })
}
if (row.duplicate) {
return h(NTag, { type: 'warning', size: 'small' }, { default: () => t('import.statusFilter.duplicate') })
}
return h('span', null, row.tradeStatus || '-')
},
},
])
/** 加载分类选项 */
async function loadCategories() {
try {
const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')])
expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' }))
} catch (e: any) {
message.error(e.message || t('import.loadCategoriesFailed'))
}
}
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 加载标签选项 */
async function loadTags() {
try {
const res: any = await getTagList()
allTags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
} catch (e: any) {
message.error(e.message || t('import.loadTagsFailed'))
}
}
/** 判断文件名是否符合当前账单类型允许的扩展名 */
function isAllowedFile(name: string) {
const exts = allowedExtensions[billType.value] || []
const lower = name.toLowerCase()
return exts.length === 0 || exts.some((ext) => lower.endsWith(ext))
}
/** 文件选择变化(支持点击或拖拽),按账单类型校验扩展名,非法文件直接拒绝 */
function handleFileChange(options: { fileList: UploadFileInfo[] }) {
const last = options.fileList[options.fileList.length - 1]
if (last?.file && !isAllowedFile(last.name)) {
const label = billType.value === 'WECHAT' ? t('import.billTypeWECHAT') : t('import.billTypeALIPAY')
const exts = allowedExtensions[billType.value].join(t('import.extSeparator'))
message.error(t('import.errorFileType', { label, exts }))
uploadFileList.value = []
file.value = null
return
}
uploadFileList.value = options.fileList
file.value = last?.file ?? null
}
/** 解析账单 */
async function handleParse() {
if (!file.value) {
message.warning(t('import.selectFileFirst'))
return
}
parsing.value = true
try {
const res: any = await parseBill(file.value, billType.value)
rows.value = res.data.rows || []
// 默认勾选解析器建议勾选的行
checkedRowNos.value = rows.value.filter((r) => r.selected).map((r) => r.rowNo)
resetFilters()
stage.value = 'preview'
} catch (e: any) {
message.error(e.message || t('import.parseFailed'))
} finally {
parsing.value = false
}
}
/** 切换单行勾选 */
function toggleRow(row: ParsedBillRow, checked: boolean) {
if (checked) {
if (!checkedRowNos.value.includes(row.rowNo)) {
checkedRowNos.value = [...checkedRowNos.value, row.rowNo]
}
} else {
checkedRowNos.value = checkedRowNos.value.filter((n) => n !== row.rowNo)
}
}
/** 全选/取消全选 */
function toggleAll(checked: boolean) {
checkedRowNos.value = checked ? selectableRows.value.map((r) => r.rowNo) : []
}
/** 确认导入 */
async function handleConfirm() {
if (checkedRows.value.length === 0) {
message.warning(t('import.selectAtLeastOne'))
return
}
const items: BillImportItem[] = checkedRows.value.map((r) => ({
externalNo: r.externalNo,
transactionTime: r.transactionTime,
type: r.type,
amount: r.amount,
counterparty: r.counterparty,
product: r.product,
includeInStats: r.includeInStats,
rawData: r.rawData,
}))
importing.value = true
try {
const res: any = await confirmImport({
billType: billType.value,
expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value,
tagIds: importTagIds.value.length ? importTagIds.value : undefined,
items,
})
result.value = res.data
stage.value = 'result'
emit('imported')
} catch (e: any) {
message.error(e.message || t('import.importFailed'))
} finally {
importing.value = false
}
}
/** 重置面板状态 */
function reset() {
stage.value = 'select'
file.value = null
uploadFileList.value = []
rows.value = []
checkedRowNos.value = []
result.value = null
expenseCategoryId.value = null
incomeCategoryId.value = null
importTagIds.value = []
resetFilters()
}
/** 关闭面板(取消或完成) */
function handleClose() {
emit('close')
}
// 切换账单类型时清空已选文件、记录偏好,避免文件格式与类型不匹配
watch(billType, (value) => {
uploadFileList.value = []
file.value = null
localStorage.setItem(BILL_TYPE_PREFS_KEY, value)
})
// 挂载时重置并加载分类/标签(页面进入或弹窗打开时均会触发)
onMounted(() => {
reset()
loadCategories()
loadTags()
})
</script>
<template>
<!-- 第一步:选择账单类型与文件 -->
<NSpace v-if="stage === 'select'" vertical :size="16">
<NSpace align="center">
<NText>{{ t('import.billType') }}</NText>
<NSelect v-model:value="billType" :options="billTypeOptions" style="width: 160px;" />
</NSpace>
<div>
<NText>{{ t('import.billFile') }}</NText>
<NUpload
:file-list="uploadFileList"
:default-upload="false"
:max="1"
:accept="acceptTypes"
style="margin-top: 8px;"
@change="handleFileChange"
>
<NUploadDragger>
<div style="padding: 24px 0; text-align: center;">
<NIcon size="48" :depth="3">
<CloudUploadOutline />
</NIcon>
<div style="font-size: 15px; margin-top: 8px;">{{ t('import.dropText') }}</div>
<div style="font-size: 12px; color: #999; margin-top: 4px;">
{{ fileHint }}
</div>
</div>
</NUploadDragger>
</NUpload>
</div>
<NAlert type="info" :show-icon="true">
{{ exportHint }}
</NAlert>
<NSpace justify="end">
<NButton @click="handleClose">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="parsing" :disabled="!file" @click="handleParse">{{ t('import.parse') }}</NButton>
</NSpace>
</NSpace>
<!-- 第二步:预览并勾选 -->
<NSpace v-else-if="stage === 'preview'" vertical :size="12">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NText>{{ t('import.expenseDefaultCategory') }}</NText>
<NSelect
v-model:value="expenseCategoryId"
:options="expenseCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.incomeDefaultCategory') }}</NText>
<NSelect
v-model:value="incomeCategoryId"
:options="incomeCategories"
:render-label="renderCategoryLabel"
filterable
clearable
:placeholder="t('import.notSetCategory')"
style="width: 180px;"
/>
<NText>{{ t('import.defaultTags') }}</NText>
<NSelect
v-model:value="importTagIds"
:options="allTags"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('import.notSetTag')"
style="width: 200px;"
/>
<NCheckbox
:checked="allChecked"
:indeterminate="someChecked"
:disabled="selectableRows.length === 0"
@update:checked="toggleAll"
>
{{ t('import.selectAll') }}
</NCheckbox>
<NText depth="3">{{ t('import.previewSummary', { total: rows.length, filtered: filteredRows.length, checked: checkedRows.length }) }}</NText>
</NSpace>
<!-- 筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NInput v-model:value="filterKeyword" clearable :placeholder="t('import.searchPlaceholder')" style="width: 220px;" />
<NSelect v-model:value="filterType" :options="typeFilterOptions" clearable :placeholder="t('import.typePlaceholder')" style="width: 120px;" />
<NSelect v-model:value="filterStatus" :options="statusFilterOptions" clearable :placeholder="t('import.statusPlaceholder')" style="width: 130px;" />
<NInputNumber v-model:value="filterMinAmount" :min="0" :precision="2" clearable :placeholder="t('common.min')" style="width: 120px;" />
<span style="color: #999;">~</span>
<NInputNumber v-model:value="filterMaxAmount" :min="0" :precision="2" clearable :placeholder="t('common.max')" style="width: 120px;" />
<NButton size="small" @click="resetFilters">{{ t('common.reset') }}</NButton>
</NSpace>
<NDataTable
:columns="columns"
:data="filteredRows"
:max-height="420"
:row-key="(row) => row.rowNo"
size="small"
:bordered="false"
/>
<NSpace justify="end">
<NButton @click="stage = 'select'">{{ t('import.prevStep') }}</NButton>
<NButton type="primary" :loading="importing" @click="handleConfirm">
{{ t('import.confirmImport', { count: checkedRows.length }) }}
</NButton>
</NSpace>
</NSpace>
<!-- 第三步:导入结果 -->
<NSpace v-else vertical :size="12">
<NAlert type="success" :show-icon="true">
{{ t('import.resultSummary', { imported: result?.imported ?? 0, skipped: result?.skipped ?? 0, failed: result?.failed ?? 0 }) }}
</NAlert>
<div v-if="result?.messages?.length" style="max-height: 320px; overflow: auto;">
<NText v-for="(msg, i) in result.messages" :key="i" depth="3" style="display: block; font-size: 12px;">
{{ msg }}
</NText>
</div>
<NSpace justify="end">
<NButton type="primary" @click="handleClose">{{ t('import.done') }}</NButton>
</NSpace>
</NSpace>
</template>
+13 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// 登录页面组件:提供用户名/密码表单,验证后跳转至首页 // 登录页面组件:提供用户名/密码表单,验证后跳转至首页
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui' import { useMessage, NCard, NForm, NFormItem, NInput, NButton, NSpace } from 'naive-ui'
import { login } from '../api/user' import { login } from '../api/user'
@@ -10,6 +10,7 @@ import { useRegistrationStore } from '../store/registration'
import LocaleSwitch from '../components/LocaleSwitch.vue' import LocaleSwitch from '../components/LocaleSwitch.vue'
const router = useRouter() const router = useRouter()
const route = useRoute()
const message = useMessage() // 消息提示工具 const message = useMessage() // 消息提示工具
const userStore = useUserStore() // 用户状态管理 const userStore = useUserStore() // 用户状态管理
const registrationStore = useRegistrationStore() // 注册状态管理 const registrationStore = useRegistrationStore() // 注册状态管理
@@ -31,6 +32,16 @@ const form = ref({
}) })
const loading = ref(false) // 登录按钮加载状态 const loading = ref(false) // 登录按钮加载状态
/** 解析登录后的回跳地址:仅允许站内路径,避免开放重定向 */
function resolveRedirect(): string {
const redirect = route.query.redirect
const target = Array.isArray(redirect) ? redirect[0] : redirect
if (typeof target === 'string' && target.startsWith('/') && !target.startsWith('//')) {
return target
}
return '/dashboard'
}
/** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */ /** 处理登录操作:校验表单 -> 调用登录接口 -> 保存 token -> 跳转首页 */
async function handleLogin() { async function handleLogin() {
if (!form.value.username || !form.value.password) { if (!form.value.username || !form.value.password) {
@@ -43,7 +54,7 @@ async function handleLogin() {
userStore.setToken(res.data.token) // 保存 token userStore.setToken(res.data.token) // 保存 token
userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息 userStore.setUserInfo(res.data.user.nickname, res.data.user.id) // 保存用户信息
message.success(t('login.success')) message.success(t('login.success'))
router.push('/dashboard') // 跳转到统计面板 router.push(resolveRedirect()) // 跳转到来源页或统计面板
} catch (e: any) { } catch (e: any) {
message.error(e.message || t('login.failed')) message.error(e.message || t('login.failed'))
} finally { } finally {
+10 -3
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
// 系统设置页:上下分区展示数据库配置与 AI 模型配置 // 系统设置页:上下分区展示数据库配置、注册设置、AI 模型配置与定时通知
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue' import DatabaseConfigPanel from '../components/DatabaseConfigPanel.vue'
import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue' import RegistrationConfigPanel from '../components/RegistrationConfigPanel.vue'
import AiConfigPanel from '../components/AiConfigPanel.vue' import AiConfigPanel from '../components/AiConfigPanel.vue'
import NotificationConfigPanel from '../components/NotificationConfigPanel.vue'
const { t } = useI18n() const { t } = useI18n()
</script> </script>
@@ -14,17 +15,23 @@ const { t } = useI18n()
<DatabaseConfigPanel /> <DatabaseConfigPanel />
<RegistrationConfigPanel /> <RegistrationConfigPanel />
<AiConfigPanel /> <AiConfigPanel />
<NotificationConfigPanel />
</div> </div>
</template> </template>
<style scoped> <style scoped>
.settings-page { .settings-page {
max-width: 860px; max-width: 860px;
display: flex;
flex-direction: column;
gap: 20px;
} }
.page-title { .page-title {
margin: 0 0 16px; margin: 0;
} }
.settings-page > :deep(.n-card) { .settings-page > :deep(.n-card) {
margin-bottom: 16px; border: 1px solid #efeff5;
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
} }
</style> </style>
@@ -0,0 +1,16 @@
package cn.rosercode.finance.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 通知调度配置类。
*
* <p>开启 Spring 的定时任务能力,供通知调度器按用户配置的周期检查并推送账单导入提醒。</p>
*
* @date 2026-09-18 12:36:47
*/
@Configuration
@EnableScheduling
public class NotificationSchedulingConfig {
}
@@ -17,4 +17,7 @@ public class AppConfig {
/** 是否开放注册:true 开放,false 关闭(默认关闭) */ /** 是否开放注册:true 开放,false 关闭(默认关闭) */
private boolean registrationEnabled = false; private boolean registrationEnabled = false;
/** 通知链接地址(完整链接,如 https://finance.rosercode.cn/bill-import),为空时通知不附带链接 */
private String notificationUrl;
} }
@@ -0,0 +1,58 @@
package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.config.app.AppConfig;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.dto.NotificationUrlRequest;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 应用配置控制器
*
* <p>提供全局应用级配置的读写接口,当前包含通知链接地址(账单导入提醒中的跳转链接)。</p>
*
* @date 2026-09-18 15:55:15
*/
@RestController
@RequestMapping("/api/app")
@Tag(name = "应用配置", description = "全局应用级配置读写接口")
public class AppConfigController {
private final AppConfigStore appConfigStore;
public AppConfigController(AppConfigStore appConfigStore) {
this.appConfigStore = appConfigStore;
}
/**
* 查询通知链接地址。
*
* @return 通知链接地址,未配置时为空
*/
@GetMapping("/notification-url")
@Operation(summary = "查询通知链接地址", description = "读取账单导入提醒中使用的跳转链接")
public Result<String> getNotificationUrl() {
return Result.success(appConfigStore.load().getNotificationUrl());
}
/**
* 更新通知链接地址。
*
* @param request 通知链接地址更新请求
* @return 更新后的通知链接地址
*/
@PutMapping("/notification-url")
@Operation(summary = "更新通知链接地址", description = "设置账单导入提醒中使用的跳转链接")
public Result<String> updateNotificationUrl(@RequestBody NotificationUrlRequest request) {
AppConfig config = appConfigStore.load();
config.setNotificationUrl(request.getUrl());
appConfigStore.save(config);
return Result.success(config.getNotificationUrl());
}
}
@@ -0,0 +1,182 @@
package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.NotificationChannelRequest;
import cn.rosercode.finance.dto.NotificationConfigRequest;
import cn.rosercode.finance.service.NotificationChannelService;
import cn.rosercode.finance.service.NotificationConfigService;
import cn.rosercode.finance.service.NotificationService;
import cn.rosercode.finance.vo.NotificationChannelVO;
import cn.rosercode.finance.vo.NotificationConfigVO;
import cn.rosercode.finance.vo.NotificationLogVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 通知控制器
*
* <p>提供账单导入提醒的周期配置、推送渠道管理、测试发送与发送日志查询接口。</p>
*
* @date 2026-09-18 12:38:35
*/
@RestController
@RequestMapping("/api/notification")
@Tag(name = "定时通知", description = "账单导入提醒的周期配置与多渠道推送相关接口")
public class NotificationController {
private final NotificationConfigService notificationConfigService;
private final NotificationChannelService notificationChannelService;
private final NotificationService notificationService;
public NotificationController(NotificationConfigService notificationConfigService,
NotificationChannelService notificationChannelService,
NotificationService notificationService) {
this.notificationConfigService = notificationConfigService;
this.notificationChannelService = notificationChannelService;
this.notificationService = notificationService;
}
/**
* 获取当前用户的通知配置。
*
* @param request HTTP请求,从中获取用户ID
* @return 通知配置
*/
@GetMapping("/config")
@Operation(summary = "获取通知配置", description = "获取当前用户的账单导入提醒周期配置")
public Result<NotificationConfigVO> getConfig(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(notificationConfigService.getConfig(userId));
}
/**
* 保存当前用户的通知配置。
*
* @param request HTTP请求,从中获取用户ID
* @param configRequest 配置保存请求
* @return 操作结果
*/
@PutMapping("/config")
@Operation(summary = "保存通知配置", description = "更新当前用户的提醒周期与发送时间")
public Result<Void> saveConfig(HttpServletRequest request,
@RequestBody NotificationConfigRequest configRequest) {
Long userId = (Long) request.getAttribute("userId");
notificationConfigService.saveConfig(userId, configRequest);
return Result.success();
}
/**
* 查询当前用户的通知渠道。
*
* @param request HTTP请求,从中获取用户ID
* @return 渠道列表
*/
@GetMapping("/channels")
@Operation(summary = "查询通知渠道", description = "查询当前用户配置的所有推送渠道")
public Result<List<NotificationChannelVO>> listChannels(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(notificationChannelService.listChannels(userId));
}
/**
* 新增通知渠道。
*
* @param request HTTP请求,从中获取用户ID
* @param channelRequest 渠道保存请求
* @return 新渠道ID
*/
@PostMapping("/channels")
@Operation(summary = "新增通知渠道", description = "新增邮箱或 Server 酱推送渠道")
public Result<Long> createChannel(HttpServletRequest request,
@RequestBody NotificationChannelRequest channelRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(notificationChannelService.createChannel(userId, channelRequest));
}
/**
* 修改通知渠道。
*
* @param request HTTP请求,从中获取用户ID
* @param id 渠道ID
* @param channelRequest 渠道保存请求
* @return 操作结果
*/
@PutMapping("/channels/{id}")
@Operation(summary = "修改通知渠道", description = "更新指定渠道的名称、开关与配置")
public Result<Void> updateChannel(HttpServletRequest request, @PathVariable Long id,
@RequestBody NotificationChannelRequest channelRequest) {
Long userId = (Long) request.getAttribute("userId");
notificationChannelService.updateChannel(userId, id, channelRequest);
return Result.success();
}
/**
* 删除通知渠道。
*
* @param request HTTP请求,从中获取用户ID
* @param id 渠道ID
* @return 操作结果
*/
@DeleteMapping("/channels/{id}")
@Operation(summary = "删除通知渠道", description = "删除指定的推送渠道")
public Result<Void> deleteChannel(HttpServletRequest request, @PathVariable Long id) {
Long userId = (Long) request.getAttribute("userId");
notificationChannelService.deleteChannel(userId, id);
return Result.success();
}
/**
* 测试指定渠道。
*
* @param request HTTP请求,从中获取用户ID
* @param id 渠道ID
* @return 操作结果
*/
@PostMapping("/channels/{id}/test")
@Operation(summary = "测试通知渠道", description = "向指定渠道发送一条测试消息")
public Result<Void> testChannel(HttpServletRequest request, @PathVariable Long id) {
Long userId = (Long) request.getAttribute("userId");
notificationService.sendTest(userId, id);
return Result.success();
}
/**
* 立即发送账单导入提醒。
*
* @param request HTTP请求,从中获取用户ID
* @return 发送成功的渠道数
*/
@PostMapping("/send-now")
@Operation(summary = "立即发送提醒", description = "跳过周期设置,立即向所有已启用渠道发送账单导入提醒")
public Result<Integer> sendNow(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(notificationService.sendReminderNow(userId));
}
/**
* 查询最近的发送日志。
*
* @param request HTTP请求,从中获取用户ID
* @param limit 条数上限,默认 20
* @return 日志列表
*/
@GetMapping("/logs")
@Operation(summary = "查询发送日志", description = "查询当前用户最近的提醒发送结果")
public Result<List<NotificationLogVO>> listLogs(HttpServletRequest request,
@RequestParam(defaultValue = "20") int limit) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(notificationService.listLogs(userId, limit));
}
}
@@ -0,0 +1,26 @@
package cn.rosercode.finance.dto;
import lombok.Data;
import java.util.Map;
/**
* 通知渠道保存请求DTO,用于新增或修改消息推送渠道。
*
* @date 2026-09-18 12:37:12
*/
@Data
public class NotificationChannelRequest {
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
private String type;
/** 渠道名称,便于用户识别 */
private String name;
/** 是否启用 */
private Boolean enabled;
/** 渠道配置,字段随类型不同而变化;EMAIL 的 password 留空表示保留原值 */
private Map<String, Object> config;
}
@@ -0,0 +1,27 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* 通知配置保存请求DTO,用于更新用户的账单导入提醒周期。
*
* @date 2026-09-18 12:37:11
*/
@Data
public class NotificationConfigRequest {
/** 是否启用定时提醒 */
private Boolean enabled;
/** 提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月 */
private String frequency;
/** 每月提醒日(1-28),frequency=MONTHLY 时生效 */
private Integer dayOfMonth;
/** 每周提醒日(1-7,1 为周一),frequency=WEEKLY 时生效 */
private Integer dayOfWeek;
/** 提醒时间(HH:mm) */
private String sendTime;
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.dto;
import lombok.Data;
/**
* 通知链接地址更新请求对象。
*
* @date 2026-09-18 15:55:15
*/
@Data
public class NotificationUrlRequest {
/** 通知链接地址(完整链接,如 https://finance.rosercode.cn/bill-import) */
private String url;
}
@@ -0,0 +1,43 @@
package cn.rosercode.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知渠道实体类,对应数据库表 t_notification_channel,用于记录用户配置的消息推送渠道。
*
* @date 2026-09-18 12:37:06
*/
@Data
@TableName("t_notification_channel")
public class NotificationChannel {
/** 渠道ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
private String type;
/** 渠道名称,便于用户识别 */
private String name;
/** 是否启用 */
private Boolean enabled;
/** 渠道配置(JSON),如 webhook 地址、SMTP 账号等 */
private String config;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,49 @@
package cn.rosercode.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知配置实体类,对应数据库表 t_notification_config,用于记录用户的账单导入提醒周期。
*
* @date 2026-09-18 12:37:06
*/
@Data
@TableName("t_notification_config")
public class NotificationConfig {
/** 配置ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 是否启用定时提醒 */
private Boolean enabled;
/** 提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月 */
private String frequency;
/** 每月提醒日(1-28),frequency=MONTHLY 时生效 */
private Integer dayOfMonth;
/** 每周提醒日(1-7,1 为周一),frequency=WEEKLY 时生效 */
private Integer dayOfWeek;
/** 提醒时间(HH:mm) */
private String sendTime;
/** 最近一次发送时间,用于避免同一周期重复提醒 */
private LocalDateTime lastSentAt;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,40 @@
package cn.rosercode.finance.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知日志实体类,对应数据库表 t_notification_log,用于记录每次提醒的发送结果。
*
* @date 2026-09-18 12:37:07
*/
@Data
@TableName("t_notification_log")
public class NotificationLog {
/** 日志ID,主键自增 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属用户ID */
private Long userId;
/** 渠道ID,测试或渠道已删除时可能为空 */
private Long channelId;
/** 渠道类型 */
private String channelType;
/** 是否成功 */
private Boolean success;
/** 结果描述或失败原因 */
private String message;
/** 创建时间 */
private LocalDateTime createdAt;
}
@@ -0,0 +1,15 @@
package cn.rosercode.finance.enums;
/**
* 通知渠道类型枚举,标识消息推送的途径。
*
* @date 2026-09-18 12:37:05
*/
public enum NotificationChannelType {
/** 邮箱(SMTP,如 QQ 邮箱) */
EMAIL,
/** Server 酱(推送到个人微信) */
SERVERCHAN
}
@@ -0,0 +1,18 @@
package cn.rosercode.finance.enums;
/**
* 通知周期枚举,标识账单导入提醒的触发频率。
*
* @date 2026-09-18 12:37:04
*/
public enum NotificationFrequency {
/** 每天提醒 */
DAILY,
/** 每周提醒 */
WEEKLY,
/** 每月提醒 */
MONTHLY
}
@@ -0,0 +1,14 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.NotificationChannel;
import org.apache.ibatis.annotations.Mapper;
/**
* 通知渠道Mapper接口,继承MyBatis-Plus的BaseMapper,提供通知渠道表的基础CRUD操作。
*
* @date 2026-09-18 12:37:08
*/
@Mapper
public interface NotificationChannelMapper extends BaseMapper<NotificationChannel> {
}
@@ -0,0 +1,14 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.NotificationConfig;
import org.apache.ibatis.annotations.Mapper;
/**
* 通知配置Mapper接口,继承MyBatis-Plus的BaseMapper,提供通知配置表的基础CRUD操作。
*
* @date 2026-09-18 12:37:08
*/
@Mapper
public interface NotificationConfigMapper extends BaseMapper<NotificationConfig> {
}
@@ -0,0 +1,14 @@
package cn.rosercode.finance.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import cn.rosercode.finance.entity.NotificationLog;
import org.apache.ibatis.annotations.Mapper;
/**
* 通知日志Mapper接口,继承MyBatis-Plus的BaseMapper,提供通知日志表的基础CRUD操作。
*
* @date 2026-09-18 12:37:08
*/
@Mapper
public interface NotificationLogMapper extends BaseMapper<NotificationLog> {
}
@@ -0,0 +1,160 @@
package cn.rosercode.finance.notification;
import cn.rosercode.finance.common.BusinessException;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Map;
/**
* HTTP 通知发送器抽象基类。
*
* <p>封装基于 Webhook 的渠道共用的 JSON / 表单 POST 调用与常用取值方法。</p>
*
* @date 2026-09-18 12:37:34
*/
public abstract class AbstractHttpNotificationSender implements NotificationSender {
/** 连接超时时间 */
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** 请求超时时间 */
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(20);
/** 错误信息中保留的响应体最大长度 */
private static final int ERROR_BODY_LIMIT = 300;
/** JSON 映射器,供子类解析响应使用 */
protected final ObjectMapper objectMapper;
private final HttpClient httpClient;
protected AbstractHttpNotificationSender(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
this.httpClient = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build();
}
/**
* 以 JSON 形式发送 POST 请求。
*
* @param url 请求地址
* @param body 请求体对象
* @return 响应体字符串
*/
protected String postJson(String url, Object body) {
String json;
try {
json = objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
throw new BusinessException("构建通知请求失败:" + e.getMessage());
}
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(REQUEST_TIMEOUT)
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
return execute(request);
}
/**
* 以表单形式发送 POST 请求(application/x-www-form-urlencoded)。
*
* @param url 请求地址
* @param form 表单参数
* @return 响应体字符串
*/
protected String postForm(String url, Map<String, String> form) {
StringBuilder body = new StringBuilder();
for (Map.Entry<String, String> entry : form.entrySet()) {
if (body.length() > 0) {
body.append('&');
}
body.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8))
.append('=')
.append(URLEncoder.encode(entry.getValue() == null ? "" : entry.getValue(),
StandardCharsets.UTF_8));
}
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(REQUEST_TIMEOUT)
.header("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(body.toString(), StandardCharsets.UTF_8))
.build();
return execute(request);
}
/**
* 执行 HTTP 请求并校验响应状态码。
*
* @param request 请求对象
* @return 响应体字符串
*/
private String execute(HttpRequest request) {
try {
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new BusinessException("通知请求返回异常(" + response.statusCode() + "):" + truncate(response.body()));
}
return response.body();
} catch (IOException e) {
throw new BusinessException("发送通知失败:" + describe(e));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("发送通知被中断");
}
}
/**
* 提取异常描述,消息为空时回退到异常类名。
*
* @param e 异常
* @return 异常描述
*/
private String describe(Exception e) {
return e.getMessage() == null || e.getMessage().isBlank()
? e.getClass().getSimpleName() : e.getMessage();
}
/**
* 截断过长的响应体,避免错误信息过长。
*
* @param body 响应体
* @return 截断后的字符串
*/
protected String truncate(String body) {
if (body == null) {
return "";
}
return body.length() <= ERROR_BODY_LIMIT ? body : body.substring(0, ERROR_BODY_LIMIT) + "...";
}
/**
* 将配置值转为字符串。
*
* @param value 配置值
* @return 字符串,null 时返回 null
*/
protected String asString(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
protected boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,147 @@
package cn.rosercode.finance.notification;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.enums.NotificationChannelType;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import org.springframework.mail.MailException;
import org.springframework.mail.javamail.JavaMailSenderImpl;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.Properties;
/**
* 邮箱通知发送器,通过 SMTP 发送纯文本邮件(兼容 QQ 邮箱)。
*
* <p>SMTP 参数来自用户配置,因此每次发送动态构建 {@link JavaMailSenderImpl},
* 不依赖全局邮件配置。QQ 邮箱默认使用 465 端口并开启 SSL。</p>
*
* @date 2026-09-18 12:37:41
*/
@Component
public class EmailNotificationSender implements NotificationSender {
/** 默认 SMTP 主机(QQ 邮箱) */
private static final String DEFAULT_HOST = "smtp.qq.com";
/** 默认 SMTP 端口 */
private static final int DEFAULT_PORT = 465;
@Override
public NotificationChannelType type() {
return NotificationChannelType.EMAIL;
}
@Override
public void send(Map<String, Object> config, String title, String content) {
String host = stringOrDefault(config.get("smtpHost"), DEFAULT_HOST);
int port = intOrDefault(config.get("smtpPort"), DEFAULT_PORT);
String username = asString(config.get("username"));
String password = asString(config.get("password"));
String to = stringOrDefault(config.get("to"), username);
boolean ssl = booleanOrDefault(config.get("ssl"), true);
if (isBlank(username) || isBlank(password)) {
throw new BusinessException("请配置邮箱账号与授权码");
}
if (isBlank(to)) {
throw new BusinessException("请配置收件人邮箱");
}
JavaMailSenderImpl sender = new JavaMailSenderImpl();
sender.setHost(host);
sender.setPort(port);
sender.setUsername(username);
sender.setPassword(password);
sender.setDefaultEncoding("UTF-8");
Properties props = sender.getJavaMailProperties();
props.put("mail.transport.protocol", "smtp");
props.put("mail.smtp.auth", "true");
props.put("mail.smtp.ssl.enable", String.valueOf(ssl));
props.put("mail.smtp.connectiontimeout", "10000");
props.put("mail.smtp.timeout", "10000");
props.put("mail.smtp.writetimeout", "10000");
try {
MimeMessage message = sender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, false, "UTF-8");
helper.setFrom(username);
helper.setTo(to);
helper.setSubject(title);
helper.setText(content, false);
sender.send(message);
} catch (MessagingException | MailException e) {
throw new BusinessException("邮件发送失败:" + e.getMessage());
}
}
/**
* 将配置值转为字符串。
*
* @param value 配置值
* @return 字符串,null 时返回 null
*/
private String asString(Object value) {
return value == null ? null : String.valueOf(value);
}
/**
* 取字符串配置,为空时使用默认值。
*
* @param value 配置值
* @param defaultValue 默认值
* @return 结果字符串
*/
private String stringOrDefault(Object value, String defaultValue) {
String text = asString(value);
return isBlank(text) ? defaultValue : text.trim();
}
/**
* 取整数配置,为空或非法时使用默认值。
*
* @param value 配置值
* @param defaultValue 默认值
* @return 结果整数
*/
private int intOrDefault(Object value, int defaultValue) {
if (value == null) {
return defaultValue;
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (NumberFormatException e) {
return defaultValue;
}
}
/**
* 取布尔配置,为空时使用默认值。
*
* @param value 配置值
* @param defaultValue 默认值
* @return 结果布尔值
*/
private boolean booleanOrDefault(Object value, boolean defaultValue) {
if (value == null) {
return defaultValue;
}
if (value instanceof Boolean bool) {
return bool;
}
return Boolean.parseBoolean(String.valueOf(value));
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,32 @@
package cn.rosercode.finance.notification;
import cn.rosercode.finance.enums.NotificationChannelType;
import java.util.Map;
/**
* 通知发送器接口,定义不同渠道的消息推送能力。
*
* <p>各实现类负责将标题与正文转换为对应渠道的消息格式并发送。渠道的配置
* (如 webhook 地址、SMTP 账号)由调用方解析为 Map 后传入。</p>
*
* @date 2026-09-18 12:37:22
*/
public interface NotificationSender {
/**
* 返回该发送器支持的渠道类型。
*
* @return 渠道类型
*/
NotificationChannelType type();
/**
* 发送一条通知消息。
*
* @param config 渠道配置
* @param title 消息标题
* @param content 消息正文
*/
void send(Map<String, Object> config, String title, String content);
}
@@ -0,0 +1,52 @@
package cn.rosercode.finance.notification;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.enums.NotificationChannelType;
import org.springframework.stereotype.Component;
import java.util.EnumMap;
import java.util.List;
import java.util.Map;
/**
* 通知发送器工厂,按渠道类型分发到对应的发送器实现。
*
* @date 2026-09-18 12:37:23
*/
@Component
public class NotificationSenderFactory {
private final Map<NotificationChannelType, NotificationSender> senders = new EnumMap<>(NotificationChannelType.class);
/**
* 构造方法,收集容器中所有发送器实现。
*
* @param senderList 发送器列表
*/
public NotificationSenderFactory(List<NotificationSender> senderList) {
for (NotificationSender sender : senderList) {
senders.put(sender.type(), sender);
}
}
/**
* 根据渠道类型获取发送器。
*
* @param type 渠道类型
* @return 对应的发送器
* @throws BusinessException 类型不支持时抛出
*/
public NotificationSender get(String type) {
NotificationChannelType channelType;
try {
channelType = NotificationChannelType.valueOf(type);
} catch (IllegalArgumentException | NullPointerException e) {
throw new BusinessException("不支持的通知渠道类型:" + type);
}
NotificationSender sender = senders.get(channelType);
if (sender == null) {
throw new BusinessException("不支持的通知渠道类型:" + type);
}
return sender;
}
}
@@ -0,0 +1,73 @@
package cn.rosercode.finance.notification;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.enums.NotificationChannelType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Server 酱通知发送器,通过 Server 酱 Turbo 接口将消息推送到个人微信。
*
* <p>用户只需在 sct.ftqq.com 获取 SendKey,消息以表单形式提交到
* {@code https://sctapi.ftqq.com/{sendKey}.send},由 Server 酱转发到微信。</p>
*
* @date 2026-09-18 12:58:53
*/
@Component
public class ServerChanNotificationSender extends AbstractHttpNotificationSender {
/** Server 酱 Turbo 接口地址 */
private static final String DEFAULT_BASE_URL = "https://sctapi.ftqq.com";
public ServerChanNotificationSender(ObjectMapper objectMapper) {
super(objectMapper);
}
@Override
public NotificationChannelType type() {
return NotificationChannelType.SERVERCHAN;
}
@Override
public void send(Map<String, Object> config, String title, String content) {
String sendKey = asString(config.get("sendKey"));
if (isBlank(sendKey)) {
throw new BusinessException("请配置 Server 酱 SendKey");
}
String baseUrl = asString(config.get("baseUrl"));
if (isBlank(baseUrl)) {
baseUrl = DEFAULT_BASE_URL;
}
// 兼容以斜杠结尾的自定义地址
String url = (baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl)
+ "/" + sendKey.trim() + ".send";
Map<String, String> form = new LinkedHashMap<>();
form.put("title", title);
form.put("desp", content);
checkResponse(postForm(url, form));
}
/**
* 校验 Server 酱响应,code 非 0 视为失败。
*
* @param response 响应体
*/
private void checkResponse(String response) {
try {
JsonNode root = objectMapper.readTree(response);
int code = root.path("code").asInt(0);
if (code != 0) {
throw new BusinessException("Server 酱返回失败:" + root.path("message").asText(""));
}
} catch (JsonProcessingException e) {
throw new BusinessException("解析 Server 酱响应失败:" + e.getMessage());
}
}
}
@@ -0,0 +1,169 @@
package cn.rosercode.finance.scheduler;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.entity.NotificationConfig;
import cn.rosercode.finance.enums.NotificationFrequency;
import cn.rosercode.finance.mapper.NotificationConfigMapper;
import cn.rosercode.finance.service.NotificationChannelService;
import cn.rosercode.finance.service.NotificationService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
/**
* 通知调度器。
*
* <p>每分钟检查一次已启用的通知配置,当到达用户设定的提醒周期且本周期尚未发送时,
* 向该用户所有已启用的渠道推送账单导入提醒,并更新最近发送时间。</p>
*
* @date 2026-09-18 12:38:30
*/
@Component
public class NotificationScheduler {
private static final Logger log = LoggerFactory.getLogger(NotificationScheduler.class);
/** 时间格式 */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
/** 默认提醒时间 */
private static final LocalTime DEFAULT_TIME = LocalTime.of(9, 0);
private final NotificationConfigMapper notificationConfigMapper;
private final NotificationChannelService notificationChannelService;
private final NotificationService notificationService;
public NotificationScheduler(NotificationConfigMapper notificationConfigMapper,
NotificationChannelService notificationChannelService,
NotificationService notificationService) {
this.notificationConfigMapper = notificationConfigMapper;
this.notificationChannelService = notificationChannelService;
this.notificationService = notificationService;
}
/**
* 定时检查并发送提醒。
*
* <p>固定延迟 60 秒,启动后延迟 30 秒执行首次检查。整体异常仅记录调试日志,
* 避免数据库尚未配置时刷屏。</p>
*/
@Scheduled(fixedDelay = 60_000L, initialDelay = 30_000L)
public void checkAndSend() {
List<NotificationConfig> configs;
try {
configs = notificationConfigMapper.selectList(new LambdaQueryWrapper<NotificationConfig>()
.eq(NotificationConfig::getEnabled, true));
} catch (Exception e) {
log.debug("跳过通知调度,读取配置失败:{}", e.getMessage());
return;
}
LocalDateTime now = LocalDateTime.now();
for (NotificationConfig config : configs) {
try {
dispatchIfDue(config, now);
} catch (Exception e) {
log.warn("处理用户 {} 的账单提醒失败:{}", config.getUserId(), e.getMessage());
}
}
}
/**
* 判断并执行单个用户的通知发送。
*
* @param config 通知配置
* @param now 当前时间
*/
private void dispatchIfDue(NotificationConfig config, LocalDateTime now) {
if (!isDue(config, now)) {
return;
}
List<NotificationChannel> channels = notificationChannelService.listEnabledChannels(config.getUserId());
if (channels.isEmpty()) {
// 无可用渠道时不更新最近发送时间,待用户配置后仍可在本周期触发
return;
}
notificationService.sendToChannels(config.getUserId(), channels,
NotificationService.REMINDER_TITLE, notificationService.buildReminderContent());
config.setLastSentAt(now);
notificationConfigMapper.updateById(config);
log.info("已向用户 {} 的 {} 个渠道发送账单导入提醒", config.getUserId(), channels.size());
}
/**
* 判断当前时间是否已到达本周期提醒时间且尚未发送。
*
* @param config 通知配置
* @param now 当前时间
* @return 需要发送时返回 true
*/
boolean isDue(NotificationConfig config, LocalDateTime now) {
LocalDateTime due = computeLatestDueTime(config, now);
LocalDateTime lastSent = config.getLastSentAt();
return lastSent == null || lastSent.isBefore(due);
}
/**
* 计算当前时间之前最近的一个触发时间点。
*
* @param config 通知配置
* @param now 当前时间
* @return 最近的触发时间点
*/
LocalDateTime computeLatestDueTime(NotificationConfig config, LocalDateTime now) {
LocalTime time = parseTime(config.getSendTime());
String frequency = config.getFrequency() == null
? NotificationFrequency.MONTHLY.name() : config.getFrequency();
switch (frequency) {
case "DAILY": {
LocalDateTime today = now.toLocalDate().atTime(time);
return now.isBefore(today) ? today.minusDays(1) : today;
}
case "WEEKLY": {
int target = config.getDayOfWeek() == null ? 1 : config.getDayOfWeek();
LocalDate date = now.toLocalDate();
int diff = (date.getDayOfWeek().getValue() - target + 7) % 7;
LocalDateTime candidate = date.minusDays(diff).atTime(time);
return candidate.isAfter(now) ? candidate.minusWeeks(1) : candidate;
}
case "MONTHLY":
default: {
int target = config.getDayOfMonth() == null ? 1 : config.getDayOfMonth();
LocalDate date = now.toLocalDate();
LocalDateTime candidate = date.withDayOfMonth(Math.min(target, date.lengthOfMonth())).atTime(time);
if (candidate.isAfter(now)) {
LocalDate previous = date.minusMonths(1);
candidate = previous.withDayOfMonth(Math.min(target, previous.lengthOfMonth())).atTime(time);
}
return candidate;
}
}
}
/**
* 解析提醒时间,非法时回退默认值。
*
* @param sendTime 时间字符串
* @return 提醒时间
*/
private LocalTime parseTime(String sendTime) {
if (sendTime == null || sendTime.isBlank()) {
return DEFAULT_TIME;
}
try {
return LocalTime.parse(sendTime, TIME_FORMATTER);
} catch (DateTimeParseException e) {
return DEFAULT_TIME;
}
}
}
@@ -0,0 +1,66 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.NotificationChannelRequest;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.vo.NotificationChannelVO;
import java.util.List;
/**
* 通知渠道服务接口,负责用户消息推送渠道的增删改查。
*
* @date 2026-09-18 12:37:49
*/
public interface NotificationChannelService {
/**
* 查询当前用户的所有通知渠道。
*
* @param userId 用户ID
* @return 渠道视图对象列表
*/
List<NotificationChannelVO> listChannels(Long userId);
/**
* 新增通知渠道。
*
* @param userId 用户ID
* @param request 渠道保存请求
* @return 新渠道ID
*/
Long createChannel(Long userId, NotificationChannelRequest request);
/**
* 修改通知渠道。
*
* @param userId 用户ID
* @param channelId 渠道ID
* @param request 渠道保存请求
*/
void updateChannel(Long userId, Long channelId, NotificationChannelRequest request);
/**
* 删除通知渠道。
*
* @param userId 用户ID
* @param channelId 渠道ID
*/
void deleteChannel(Long userId, Long channelId);
/**
* 获取指定渠道原始实体,并校验归属。
*
* @param userId 用户ID
* @param channelId 渠道ID
* @return 渠道实体
*/
NotificationChannel getRawChannel(Long userId, Long channelId);
/**
* 查询当前用户所有已启用的通知渠道,供调度发送使用。
*
* @param userId 用户ID
* @return 已启用的渠道实体列表
*/
List<NotificationChannel> listEnabledChannels(Long userId);
}
@@ -0,0 +1,37 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.NotificationConfigRequest;
import cn.rosercode.finance.entity.NotificationConfig;
import cn.rosercode.finance.vo.NotificationConfigVO;
/**
* 通知配置服务接口,负责账单导入提醒周期的读写。
*
* @date 2026-09-18 12:37:48
*/
public interface NotificationConfigService {
/**
* 获取当前用户的通知配置,未配置时返回默认值。
*
* @param userId 用户ID
* @return 通知配置视图对象
*/
NotificationConfigVO getConfig(Long userId);
/**
* 保存当前用户的通知配置。
*
* @param userId 用户ID
* @param request 配置保存请求
*/
void saveConfig(Long userId, NotificationConfigRequest request);
/**
* 获取通知配置原始实体,供调度器使用。
*
* @param userId 用户ID
* @return 通知配置实体,未配置时返回 null
*/
NotificationConfig getRawConfig(Long userId);
}
@@ -0,0 +1,62 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.vo.NotificationLogVO;
import java.util.List;
/**
* 通知发送服务接口,负责消息的实际推送与发送日志记录。
*
* @date 2026-09-18 12:37:50
*/
public interface NotificationService {
/** 账单导入提醒的消息标题 */
String REMINDER_TITLE = "账单导入提醒";
/** 账单导入提醒的消息正文 */
String REMINDER_CONTENT = "到了整理账单的时间,请及时导出并导入最新的账单,保持账目完整。";
/**
* 构建账单导入提醒正文,配置了站点地址时附加账单上传页链接。
*
* @return 提醒正文
*/
String buildReminderContent();
/**
* 向指定渠道发送一条测试消息。
*
* @param userId 用户ID
* @param channelId 渠道ID
*/
void sendTest(Long userId, Long channelId);
/**
* 立即向当前用户所有已启用渠道发送账单导入提醒。
*
* @param userId 用户ID
* @return 发送成功的渠道数
*/
int sendReminderNow(Long userId);
/**
* 向给定渠道列表发送提醒消息并记录日志。
*
* @param userId 用户ID
* @param channels 渠道列表
* @param title 消息标题
* @param content 消息正文
*/
void sendToChannels(Long userId, List<NotificationChannel> channels, String title, String content);
/**
* 查询当前用户最近的发送日志。
*
* @param userId 用户ID
* @param limit 条数上限
* @return 日志视图对象列表
*/
List<NotificationLogVO> listLogs(Long userId, int limit);
}
@@ -0,0 +1,233 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.dto.NotificationChannelRequest;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.enums.NotificationChannelType;
import cn.rosercode.finance.mapper.NotificationChannelMapper;
import cn.rosercode.finance.service.NotificationChannelService;
import cn.rosercode.finance.vo.NotificationChannelVO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 通知渠道服务实现类,处理用户消息推送渠道的增删改查与配置脱敏。
*
* @date 2026-09-18 12:38:09
*/
@Service
public class NotificationChannelServiceImpl implements NotificationChannelService {
private final NotificationChannelMapper notificationChannelMapper;
private final ObjectMapper objectMapper;
public NotificationChannelServiceImpl(NotificationChannelMapper notificationChannelMapper,
ObjectMapper objectMapper) {
this.notificationChannelMapper = notificationChannelMapper;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*/
@Override
public List<NotificationChannelVO> listChannels(Long userId) {
List<NotificationChannel> channels = notificationChannelMapper.selectList(
new LambdaQueryWrapper<NotificationChannel>()
.eq(NotificationChannel::getUserId, userId)
.orderByAsc(NotificationChannel::getId));
return channels.stream().map(this::toVO).toList();
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public Long createChannel(Long userId, NotificationChannelRequest request) {
String type = validateType(request.getType());
NotificationChannel channel = new NotificationChannel();
channel.setUserId(userId);
channel.setType(type);
channel.setName(resolveName(request.getName(), type));
channel.setEnabled(request.getEnabled() == null || request.getEnabled());
channel.setConfig(mergeConfig(request.getConfig(), null));
notificationChannelMapper.insert(channel);
return channel.getId();
}
/**
* {@inheritDoc}
*
* EMAIL 渠道的密码留空时保留原值;渠道类型变更时不再沿用旧配置。
*/
@Override
@Transactional
public void updateChannel(Long userId, Long channelId, NotificationChannelRequest request) {
NotificationChannel channel = getRawChannel(userId, channelId);
String type = validateType(request.getType());
String oldConfig = type.equals(channel.getType()) ? channel.getConfig() : null;
channel.setType(type);
channel.setName(resolveName(request.getName(), type));
if (request.getEnabled() != null) {
channel.setEnabled(request.getEnabled());
}
channel.setConfig(mergeConfig(request.getConfig(), oldConfig));
notificationChannelMapper.updateById(channel);
}
/**
* {@inheritDoc}
*/
@Override
@Transactional
public void deleteChannel(Long userId, Long channelId) {
getRawChannel(userId, channelId);
notificationChannelMapper.deleteById(channelId);
}
/**
* {@inheritDoc}
*/
@Override
public NotificationChannel getRawChannel(Long userId, Long channelId) {
NotificationChannel channel = notificationChannelMapper.selectById(channelId);
if (channel == null || !channel.getUserId().equals(userId)) {
throw new BusinessException("通知渠道不存在");
}
return channel;
}
/**
* {@inheritDoc}
*/
@Override
public List<NotificationChannel> listEnabledChannels(Long userId) {
return notificationChannelMapper.selectList(new LambdaQueryWrapper<NotificationChannel>()
.eq(NotificationChannel::getUserId, userId)
.eq(NotificationChannel::getEnabled, true)
.orderByAsc(NotificationChannel::getId));
}
/**
* 转换为视图对象,移除邮箱密码并标记是否已配置。
*
* @param channel 渠道实体
* @return 渠道视图对象
*/
private NotificationChannelVO toVO(NotificationChannel channel) {
NotificationChannelVO vo = new NotificationChannelVO();
vo.setId(channel.getId());
vo.setType(channel.getType());
vo.setName(channel.getName());
vo.setEnabled(Boolean.TRUE.equals(channel.getEnabled()));
vo.setCreatedAt(channel.getCreatedAt());
vo.setUpdatedAt(channel.getUpdatedAt());
Map<String, Object> config = parseConfig(channel.getConfig());
Object password = config.remove("password");
vo.setHasPassword(password != null && !String.valueOf(password).isBlank());
vo.setConfig(config);
return vo;
}
/**
* 校验渠道类型。
*
* @param type 渠道类型
* @return 规范化后的类型名称
*/
private String validateType(String type) {
if (type == null || type.isBlank()) {
throw new BusinessException("请选择通知渠道类型");
}
try {
return NotificationChannelType.valueOf(type.trim().toUpperCase()).name();
} catch (IllegalArgumentException e) {
throw new BusinessException("不支持的通知渠道类型:" + type);
}
}
/**
* 解析渠道名称,为空时按类型给出默认名称。
*
* @param name 用户填写名称
* @param type 渠道类型
* @return 渠道名称
*/
private String resolveName(String name, String type) {
if (name != null && !name.isBlank()) {
return name.trim();
}
return switch (NotificationChannelType.valueOf(type)) {
case EMAIL -> "邮箱";
case SERVERCHAN -> "Server 酱";
};
}
/**
* 合并新旧渠道配置:新值覆盖旧值,邮箱密码为空时不覆盖。
*
* @param newConfig 新配置
* @param oldJson 旧配置 JSON
* @return 合并后的配置 JSON
*/
private String mergeConfig(Map<String, Object> newConfig, String oldJson) {
Map<String, Object> merged = new LinkedHashMap<>();
if (oldJson != null && !oldJson.isBlank()) {
merged.putAll(parseConfig(oldJson));
}
if (newConfig != null) {
for (Map.Entry<String, Object> entry : newConfig.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 密码留空表示保留原值
if ("password".equals(key) && (value == null || String.valueOf(value).isBlank())) {
continue;
}
merged.put(key, value);
}
}
return writeConfig(merged);
}
/**
* 解析配置 JSON。
*
* @param json 配置 JSON
* @return 配置映射,解析失败或为空时返回空映射
*/
private Map<String, Object> parseConfig(String json) {
if (json == null || json.isBlank()) {
return new LinkedHashMap<>();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException e) {
throw new BusinessException("解析渠道配置失败:" + e.getMessage());
}
}
/**
* 序列化配置映射为 JSON。
*
* @param config 配置映射
* @return 配置 JSON
*/
private String writeConfig(Map<String, Object> config) {
try {
return objectMapper.writeValueAsString(config);
} catch (JsonProcessingException e) {
throw new BusinessException("保存渠道配置失败:" + e.getMessage());
}
}
}
@@ -0,0 +1,166 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.dto.NotificationConfigRequest;
import cn.rosercode.finance.entity.NotificationConfig;
import cn.rosercode.finance.enums.NotificationFrequency;
import cn.rosercode.finance.mapper.NotificationConfigMapper;
import cn.rosercode.finance.service.NotificationConfigService;
import cn.rosercode.finance.vo.NotificationConfigVO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
/**
* 通知配置服务实现类,处理用户级账单导入提醒周期的读写。
*
* @date 2026-09-18 12:38:01
*/
@Service
public class NotificationConfigServiceImpl implements NotificationConfigService {
/** 默认提醒周期:每月 */
private static final String DEFAULT_FREQUENCY = NotificationFrequency.MONTHLY.name();
/** 默认每月提醒日 */
private static final int DEFAULT_DAY_OF_MONTH = 1;
/** 默认每周提醒日(周一) */
private static final int DEFAULT_DAY_OF_WEEK = 1;
/** 默认提醒时间 */
private static final String DEFAULT_SEND_TIME = "09:00";
/** 时间格式 */
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm");
private final NotificationConfigMapper notificationConfigMapper;
public NotificationConfigServiceImpl(NotificationConfigMapper notificationConfigMapper) {
this.notificationConfigMapper = notificationConfigMapper;
}
/**
* {@inheritDoc}
*
* 未配置时返回默认值:每月 1 日 09:00 提醒,关闭状态。
*/
@Override
public NotificationConfigVO getConfig(Long userId) {
NotificationConfig config = getRawConfig(userId);
NotificationConfigVO vo = new NotificationConfigVO();
if (config == null) {
vo.setEnabled(false);
vo.setFrequency(DEFAULT_FREQUENCY);
vo.setDayOfMonth(DEFAULT_DAY_OF_MONTH);
vo.setDayOfWeek(DEFAULT_DAY_OF_WEEK);
vo.setSendTime(DEFAULT_SEND_TIME);
return vo;
}
vo.setEnabled(Boolean.TRUE.equals(config.getEnabled()));
vo.setFrequency(config.getFrequency());
vo.setDayOfMonth(config.getDayOfMonth());
vo.setDayOfWeek(config.getDayOfWeek());
vo.setSendTime(config.getSendTime());
vo.setLastSentAt(config.getLastSentAt());
return vo;
}
/**
* {@inheritDoc}
*
* 校验周期、日期与时间;从关闭切到启用时重置最近发送时间,避免保存后立即补发。
*/
@Override
@Transactional
public void saveConfig(Long userId, NotificationConfigRequest request) {
NotificationConfig config = getRawConfig(userId);
boolean wasEnabled = config != null && Boolean.TRUE.equals(config.getEnabled());
if (config == null) {
config = new NotificationConfig();
config.setUserId(userId);
}
boolean enabled = Boolean.TRUE.equals(request.getEnabled());
config.setEnabled(enabled);
config.setFrequency(parseFrequency(request.getFrequency()));
config.setDayOfMonth(clamp(request.getDayOfMonth(), DEFAULT_DAY_OF_MONTH, 1, 28));
config.setDayOfWeek(clamp(request.getDayOfWeek(), DEFAULT_DAY_OF_WEEK, 1, 7));
config.setSendTime(parseTime(request.getSendTime()));
// 首次启用或重新开启时,将最近发送时间设为当前,避免立即触发历史周期
if (enabled && (!wasEnabled || config.getLastSentAt() == null)) {
config.setLastSentAt(LocalDateTime.now());
}
if (config.getId() == null) {
notificationConfigMapper.insert(config);
} else {
notificationConfigMapper.updateById(config);
}
}
/**
* {@inheritDoc}
*/
@Override
public NotificationConfig getRawConfig(Long userId) {
return notificationConfigMapper.selectOne(new LambdaQueryWrapper<NotificationConfig>()
.eq(NotificationConfig::getUserId, userId));
}
/**
* 解析并校验提醒周期。
*
* @param value 周期字符串
* @return 合法的周期名称
*/
private String parseFrequency(String value) {
if (value == null || value.isBlank()) {
return DEFAULT_FREQUENCY;
}
try {
return NotificationFrequency.valueOf(value.trim().toUpperCase()).name();
} catch (IllegalArgumentException e) {
throw new BusinessException("不支持的提醒周期:" + value);
}
}
/**
* 解析并校验提醒时间。
*
* @param value 时间字符串(HH:mm)
* @return 规范化后的时间字符串
*/
private String parseTime(String value) {
if (value == null || value.isBlank()) {
return DEFAULT_SEND_TIME;
}
try {
return LocalTime.parse(value.trim(), TIME_FORMATTER).format(TIME_FORMATTER);
} catch (DateTimeParseException e) {
throw new BusinessException("提醒时间格式不正确,应为 HH:mm");
}
}
/**
* 将数值限制在指定范围内,为空时返回默认值。
*
* @param value 原值
* @param defaultValue 默认值
* @param min 最小值
* @param max 最大值
* @return 限制后的值
*/
private int clamp(Integer value, int defaultValue, int min, int max) {
if (value == null) {
return defaultValue;
}
return Math.max(min, Math.min(max, value));
}
}
@@ -0,0 +1,234 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.config.app.AppConfigStore;
import cn.rosercode.finance.entity.NotificationChannel;
import cn.rosercode.finance.entity.NotificationLog;
import cn.rosercode.finance.mapper.NotificationLogMapper;
import cn.rosercode.finance.notification.NotificationSenderFactory;
import cn.rosercode.finance.service.NotificationChannelService;
import cn.rosercode.finance.service.NotificationService;
import cn.rosercode.finance.vo.NotificationLogVO;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 通知发送服务实现类,负责消息推送编排与发送日志记录。
*
* @date 2026-09-18 12:38:20
*/
@Service
public class NotificationServiceImpl implements NotificationService {
/** 日志正文最大长度 */
private static final int MESSAGE_LIMIT = 500;
/** 查询日志的最大条数 */
private static final int MAX_LOG_LIMIT = 100;
/** 测试消息标题 */
private static final String TEST_TITLE = "通知渠道测试";
/** 测试消息正文 */
private static final String TEST_CONTENT = "这是一条来自收支管理系统的测试消息,收到即表示该渠道配置成功。";
private final NotificationChannelService notificationChannelService;
private final NotificationLogMapper notificationLogMapper;
private final NotificationSenderFactory notificationSenderFactory;
private final ObjectMapper objectMapper;
private final AppConfigStore appConfigStore;
public NotificationServiceImpl(NotificationChannelService notificationChannelService,
NotificationLogMapper notificationLogMapper,
NotificationSenderFactory notificationSenderFactory,
ObjectMapper objectMapper,
AppConfigStore appConfigStore) {
this.notificationChannelService = notificationChannelService;
this.notificationLogMapper = notificationLogMapper;
this.notificationSenderFactory = notificationSenderFactory;
this.objectMapper = objectMapper;
this.appConfigStore = appConfigStore;
}
/**
* {@inheritDoc}
*
* 发送失败时记录日志并抛出异常,便于前端直接提示原因。
*/
@Override
public void sendTest(Long userId, Long channelId) {
NotificationChannel channel = notificationChannelService.getRawChannel(userId, channelId);
try {
send(channel, TEST_TITLE, TEST_CONTENT);
recordLog(userId, channel, true, "发送成功");
} catch (RuntimeException e) {
recordLog(userId, channel, false, e.getMessage());
throw e;
}
}
/**
* {@inheritDoc}
*/
@Override
public int sendReminderNow(Long userId) {
List<NotificationChannel> channels = notificationChannelService.listEnabledChannels(userId);
if (channels.isEmpty()) {
throw new BusinessException("请先添加并启用至少一个通知渠道");
}
String content = buildReminderContent();
int success = 0;
for (NotificationChannel channel : channels) {
if (trySend(userId, channel, REMINDER_TITLE, content)) {
success++;
}
}
return success;
}
/**
* {@inheritDoc}
*
* 配置了通知链接地址时,在提醒正文后附加该完整链接,便于用户点击后直接上传账单;
* 未配置时退化为原始文案。
*/
@Override
public String buildReminderContent() {
String linkUrl = appConfigStore.load().getNotificationUrl();
if (linkUrl == null || linkUrl.isBlank()) {
return REMINDER_CONTENT;
}
return REMINDER_CONTENT + "\n\n" + linkUrl.trim();
}
/**
* {@inheritDoc}
*
* 单个渠道失败不影响其余渠道,失败原因写入日志。
*/
@Override
public void sendToChannels(Long userId, List<NotificationChannel> channels, String title, String content) {
for (NotificationChannel channel : channels) {
trySend(userId, channel, title, content);
}
}
/**
* {@inheritDoc}
*/
@Override
public List<NotificationLogVO> listLogs(Long userId, int limit) {
int safeLimit = Math.max(1, Math.min(MAX_LOG_LIMIT, limit));
List<NotificationLog> logs = notificationLogMapper.selectList(new LambdaQueryWrapper<NotificationLog>()
.eq(NotificationLog::getUserId, userId)
.orderByDesc(NotificationLog::getCreatedAt)
.orderByDesc(NotificationLog::getId)
.last("LIMIT " + safeLimit));
return logs.stream().map(this::toVO).toList();
}
/**
* 向单个渠道发送消息并记录日志,失败时返回 false。
*
* @param userId 用户ID
* @param channel 渠道
* @param title 标题
* @param content 正文
* @return 是否发送成功
*/
private boolean trySend(Long userId, NotificationChannel channel, String title, String content) {
try {
send(channel, title, content);
recordLog(userId, channel, true, "发送成功");
return true;
} catch (RuntimeException e) {
recordLog(userId, channel, false, e.getMessage());
return false;
}
}
/**
* 解析渠道配置并调用对应的发送器。
*
* @param channel 渠道
* @param title 标题
* @param content 正文
*/
private void send(NotificationChannel channel, String title, String content) {
Map<String, Object> config = parseConfig(channel.getConfig());
notificationSenderFactory.get(channel.getType()).send(config, title, content);
}
/**
* 写入一条发送日志。
*
* @param userId 用户ID
* @param channel 渠道
* @param success 是否成功
* @param message 结果描述
*/
private void recordLog(Long userId, NotificationChannel channel, boolean success, String message) {
NotificationLog log = new NotificationLog();
log.setUserId(userId);
log.setChannelId(channel.getId());
log.setChannelType(channel.getType());
log.setSuccess(success);
log.setMessage(truncate(message));
notificationLogMapper.insert(log);
}
/**
* 解析渠道配置 JSON。
*
* @param json 配置 JSON
* @return 配置映射
*/
private Map<String, Object> parseConfig(String json) {
if (json == null || json.isBlank()) {
return new LinkedHashMap<>();
}
try {
return objectMapper.readValue(json, new TypeReference<LinkedHashMap<String, Object>>() {
});
} catch (JsonProcessingException e) {
throw new BusinessException("解析渠道配置失败:" + e.getMessage());
}
}
/**
* 截断过长的日志正文。
*
* @param message 原文
* @return 截断后的字符串
*/
private String truncate(String message) {
if (message == null) {
return "";
}
return message.length() <= MESSAGE_LIMIT ? message : message.substring(0, MESSAGE_LIMIT);
}
/**
* 转换为日志视图对象。
*
* @param log 日志实体
* @return 日志视图对象
*/
private NotificationLogVO toVO(NotificationLog log) {
NotificationLogVO vo = new NotificationLogVO();
vo.setId(log.getId());
vo.setChannelId(log.getChannelId());
vo.setChannelType(log.getChannelType());
vo.setSuccess(Boolean.TRUE.equals(log.getSuccess()));
vo.setMessage(log.getMessage());
vo.setCreatedAt(log.getCreatedAt());
return vo;
}
}
@@ -0,0 +1,39 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 通知渠道视图对象,用于向前端返回渠道信息(敏感字段已脱敏)。
*
* @date 2026-09-18 12:37:13
*/
@Data
public class NotificationChannelVO {
/** 渠道ID */
private Long id;
/** 渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱 */
private String type;
/** 渠道名称 */
private String name;
/** 是否启用 */
private Boolean enabled;
/** 渠道配置(EMAIL 的 password 已移除) */
private Map<String, Object> config;
/** 是否已配置密码/授权码(EMAIL 渠道使用) */
private Boolean hasPassword;
/** 创建时间 */
private LocalDateTime createdAt;
/** 更新时间 */
private LocalDateTime updatedAt;
}
@@ -0,0 +1,32 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知配置视图对象,用于向前端返回账单导入提醒的周期设置。
*
* @date 2026-09-18 12:37:12
*/
@Data
public class NotificationConfigVO {
/** 是否启用定时提醒 */
private Boolean enabled;
/** 提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月 */
private String frequency;
/** 每月提醒日(1-28) */
private Integer dayOfMonth;
/** 每周提醒日(1-7,1 为周一) */
private Integer dayOfWeek;
/** 提醒时间(HH:mm) */
private String sendTime;
/** 最近一次发送时间 */
private LocalDateTime lastSentAt;
}
@@ -0,0 +1,32 @@
package cn.rosercode.finance.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 通知日志视图对象,用于向前端展示历史发送结果。
*
* @date 2026-09-18 12:37:14
*/
@Data
public class NotificationLogVO {
/** 日志ID */
private Long id;
/** 渠道ID */
private Long channelId;
/** 渠道类型 */
private String channelType;
/** 是否成功 */
private Boolean success;
/** 结果描述或失败原因 */
private String message;
/** 创建时间 */
private LocalDateTime createdAt;
}
+40
View File
@@ -96,3 +96,43 @@ CREATE TABLE IF NOT EXISTS t_ai_usage (
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引' INDEX idx_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
); );
-- 通知配置表:存储每个用户的账单导入提醒设置
CREATE TABLE IF NOT EXISTS t_notification_config (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '配置ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否启用定时提醒:1-启用,0-关闭',
frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY' COMMENT '提醒周期:DAILY-每天,WEEKLY-每周,MONTHLY-每月',
day_of_month INT NOT NULL DEFAULT 1 COMMENT '每月提醒日(1-28),frequency=MONTHLY 时生效',
day_of_week INT NOT NULL DEFAULT 1 COMMENT '每周提醒日(1-7,1为周一),frequency=WEEKLY 时生效',
send_time VARCHAR(5) NOT NULL DEFAULT '09:00' COMMENT '提醒时间(HH:mm)',
last_sent_at DATETIME COMMENT '最近一次发送时间,用于避免重复提醒',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
UNIQUE INDEX uk_notification_config_user_id (user_id) COMMENT '用户ID唯一索引,每个用户一条配置'
);
-- 通知渠道表:存储用户配置的邮箱、Server 酱等推送渠道
CREATE TABLE IF NOT EXISTS t_notification_channel (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '渠道ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
type VARCHAR(20) NOT NULL COMMENT '渠道类型:EMAIL-邮箱,SERVERCHAN-Server 酱',
name VARCHAR(50) COMMENT '渠道名称,便于用户识别',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用:1-启用,0-关闭',
config TEXT COMMENT '渠道配置(JSON),如 webhook 地址、SMTP 账号等',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_notification_channel_user_id (user_id) COMMENT '用户ID索引'
);
-- 通知发送日志表:记录每次提醒的发送结果,便于排查问题
CREATE TABLE IF NOT EXISTS t_notification_log (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '日志ID,主键自增',
user_id BIGINT NOT NULL COMMENT '所属用户ID',
channel_id BIGINT COMMENT '渠道ID,测试或渠道已删除时可能为空',
channel_type VARCHAR(20) COMMENT '渠道类型',
success TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否成功:1-成功,0-失败',
message VARCHAR(500) COMMENT '结果描述或失败原因',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
INDEX idx_notification_log_user_created (user_id, created_at) COMMENT '用户ID+创建时间联合索引'
);
+53
View File
@@ -123,3 +123,56 @@ WHEN NEW.updated_at = OLD.updated_at
BEGIN BEGIN
UPDATE t_ai_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id; UPDATE t_ai_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;; END;;
CREATE TABLE IF NOT EXISTS t_notification_config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 0,
frequency VARCHAR(10) NOT NULL DEFAULT 'MONTHLY',
day_of_month INTEGER NOT NULL DEFAULT 1,
day_of_week INTEGER NOT NULL DEFAULT 1,
send_time VARCHAR(5) NOT NULL DEFAULT '09:00',
last_sent_at DATETIME,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE UNIQUE INDEX IF NOT EXISTS uk_notification_config_user_id ON t_notification_config (user_id);;
CREATE TABLE IF NOT EXISTS t_notification_channel (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type VARCHAR(20) NOT NULL,
name VARCHAR(50),
enabled INTEGER NOT NULL DEFAULT 1,
config TEXT,
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime')),
updated_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_notification_channel_user_id ON t_notification_channel (user_id);;
CREATE TABLE IF NOT EXISTS t_notification_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
channel_id INTEGER,
channel_type VARCHAR(20),
success INTEGER NOT NULL DEFAULT 1,
message VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime'))
);;
CREATE INDEX IF NOT EXISTS idx_notification_log_user_created ON t_notification_log (user_id, created_at);;
CREATE TRIGGER IF NOT EXISTS trg_notification_config_updated_at
AFTER UPDATE ON t_notification_config
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_notification_config SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;
CREATE TRIGGER IF NOT EXISTS trg_notification_channel_updated_at
AFTER UPDATE ON t_notification_channel
FOR EACH ROW
WHEN NEW.updated_at = OLD.updated_at
BEGIN
UPDATE t_notification_channel SET updated_at = strftime('%Y-%m-%dT%H:%M:%S', 'now', 'localtime') WHERE id = OLD.id;
END;;