feat: 邮箱账单支持正文链接取件(适配微信账单)
邮箱账单发件人规则新增「取件方式」与「链接匹配规则」,支持从邮件正文提取账单下载链接并抓取文件,解决微信等来源不附附件、账单链接位于正文的取件问题;同时补齐附件文件名超长兜底与前端 i18n 特殊字符转义。 变更内容: - 新增 EmailBillFetchMode 枚举(ATTACHMENT/BODY_LINK),发件人规则实体、DTO、VO、前端表单与列表同步支持取件方式及链接匹配正则,保存时校验正则合法性。 - 新增 EmailBodyExtractor,基于 jsoup 解析 HTML 与纯文本正文提取候选链接,按出现顺序去重并限制数量。 - 新增 EmailBillLinkDownloader,仅允许 http/https 且拒绝回环/私网地址(防 SSRF),限制 20MB 与 HTML 响应,按 Content-Disposition、URL 路径与文件魔数推断文件名。 - EmailBillProcessor 按取件方式分流处理,未配置链接正则时优先选取受支持扩展名链接、否则依次尝试前若干链接,单条下载失败不影响其余。 - SchemaMigrator 幂等为 t_email_bill_sender_rule 补充 fetch_mode、link_pattern 列,并同步 MySQL/SQLite 建表脚本。 - 默认发件人规则中微信账单改为正文链接取件并补充发件人匹配串,其余来源显式指定附件取件。 - EmailBillStorage 对百分号编码后的文件名做长度截断并保留扩展名,避免超过文件系统 255 字节上限。 - 前端示例与提示文案对 `|`、`@` 使用 vue-i18n 字面量转义,修复渲染异常。 - build.gradle 引入 jsoup 1.18.1 用于正文 HTML 解析。
This commit is contained in:
@@ -34,6 +34,8 @@ export interface EmailBillSenderRule {
|
||||
sortOrder: number
|
||||
senderPatterns: string[]
|
||||
billType: BillType
|
||||
fetchMode: string
|
||||
linkPattern: string | null
|
||||
archivePassword: string | null
|
||||
expenseCategoryId: number | null
|
||||
incomeCategoryId: number | null
|
||||
@@ -81,6 +83,8 @@ export interface EmailBillSenderRulePayload {
|
||||
sortOrder: number
|
||||
senderPatterns: string[]
|
||||
billType: BillType
|
||||
fetchMode?: string
|
||||
linkPattern?: string | null
|
||||
archivePassword?: string | null
|
||||
expenseCategoryId?: number | null
|
||||
incomeCategoryId?: number | null
|
||||
|
||||
@@ -1652,9 +1652,9 @@ export default {
|
||||
ja: '自然言語で検索(例:先月の食費はいくら)',
|
||||
},
|
||||
examples: {
|
||||
zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付',
|
||||
en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat',
|
||||
ja: '先月の食費|500を超える支出|精算待ちの支出|WeChat 払い',
|
||||
zh: "上个月餐饮{'|'}超过500的支出{'|'}待报销的支出{'|'}微信支付",
|
||||
en: "Dining last month{'|'}Expenses over 500{'|'}Pending reimbursement{'|'}Paid by WeChat",
|
||||
ja: "先月の食費{'|'}500を超える支出{'|'}精算待ちの支出{'|'}WeChat 払い",
|
||||
},
|
||||
button: { zh: 'AI 查询', en: 'AI Query', ja: 'AI 検索' },
|
||||
entry: { zh: 'AI 搜索', en: 'AI Search', ja: 'AI 検索' },
|
||||
@@ -1706,9 +1706,9 @@ export default {
|
||||
ja: '例:先月の支出合計はいくら?',
|
||||
},
|
||||
examples: {
|
||||
zh: '上个月一共花了多少钱|按分类统计今年的支出|今年收入最高的一个月|各渠道的支出占比|本月支出最高的5笔',
|
||||
en: 'How much did I spend last month?|Expenses by category this year|Month with highest income this year|Spending share by channel|Top 5 expenses this month',
|
||||
ja: '先月の支出合計|今年のカテゴリ別支出|今年の収入が最も多い月|チャネル別支出の割合|今月の支出トップ5',
|
||||
zh: "上个月一共花了多少钱{'|'}按分类统计今年的支出{'|'}今年收入最高的一个月{'|'}各渠道的支出占比{'|'}本月支出最高的5笔",
|
||||
en: "How much did I spend last month?{'|'}Expenses by category this year{'|'}Month with highest income this year{'|'}Spending share by channel{'|'}Top 5 expenses this month",
|
||||
ja: "先月の支出合計{'|'}今年のカテゴリ別支出{'|'}今年の収入が最も多い月{'|'}チャネル別支出の割合{'|'}今月の支出トップ5",
|
||||
},
|
||||
submit: { zh: '查询', en: 'Query', ja: '検索' },
|
||||
submitting: { zh: '正在查询,请稍候…', en: 'Querying, please wait…', ja: '検索中です。しばらくお待ちください…' },
|
||||
@@ -1980,11 +1980,20 @@ export default {
|
||||
name: { zh: '规则名称', en: 'Rule Name', ja: 'ルール名' },
|
||||
patterns: { zh: '发件人匹配串', en: 'Sender Patterns', ja: '送信者パターン' },
|
||||
patternsHint: {
|
||||
zh: '每行一个,支持完整地址、@域名或关键字,包含匹配且忽略大小写',
|
||||
en: 'One per line; full address, @domain or keyword, case-insensitive contains',
|
||||
ja: '1行に1つ。完全なアドレス、@ドメイン、キーワードに対応(大文字小文字を区別しない部分一致)',
|
||||
zh: "每行一个,支持完整地址、{'@'}域名或关键字,包含匹配且忽略大小写",
|
||||
en: "One per line; full address, {'@'}domain or keyword, case-insensitive contains",
|
||||
ja: "1行に1つ。完全なアドレス、{'@'}ドメイン、キーワードに対応(大文字小文字を区別しない部分一致)",
|
||||
},
|
||||
billType: { zh: '账单类型', en: 'Bill Type', ja: '請求タイプ' },
|
||||
fetchMode: { zh: '取件方式', en: 'Fetch Mode', ja: '取得方法' },
|
||||
fetchModeAttachment: { zh: '邮件附件', en: 'Email Attachment', ja: 'メール添付' },
|
||||
fetchModeBodyLink: { zh: '正文链接', en: 'Link in Body', ja: '本文リンク' },
|
||||
linkPattern: { zh: '链接匹配规则', en: 'Link Pattern', ja: 'リンクパターン' },
|
||||
linkPatternPlaceholder: {
|
||||
zh: '可选,正则表达式(忽略大小写);留空时自动选取正文链接',
|
||||
en: 'Optional regex (case-insensitive); auto-select links in body if empty',
|
||||
ja: '任意。正規表現(大文字小文字を区別しない)。空欄時は本文リンクを自動選択',
|
||||
},
|
||||
archivePassword: { zh: '解压密码', en: 'Archive Password', ja: '解凍パスワード' },
|
||||
archivePasswordPlaceholder: {
|
||||
zh: '压缩包加密时填写',
|
||||
|
||||
@@ -516,6 +516,8 @@ function emptyRuleForm() {
|
||||
sortOrder: 0,
|
||||
patternsText: '',
|
||||
billType: defaultBillType(),
|
||||
fetchMode: 'ATTACHMENT',
|
||||
linkPattern: '',
|
||||
archivePassword: '',
|
||||
expenseCategoryId: null,
|
||||
incomeCategoryId: null,
|
||||
@@ -523,6 +525,18 @@ function emptyRuleForm() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 账单取件方式选项:邮件附件 / 正文链接 */
|
||||
const fetchModeOptions = computed(() => [
|
||||
{ label: t('emailBill.senderRule.fetchModeAttachment'), value: 'ATTACHMENT' },
|
||||
{ label: t('emailBill.senderRule.fetchModeBodyLink'), value: 'BODY_LINK' },
|
||||
])
|
||||
|
||||
/** 取件方式编码转当前语言标签 */
|
||||
function fetchModeLabel(value: string | null | undefined) {
|
||||
const option = fetchModeOptions.value.find((item) => item.value === (value || 'ATTACHMENT'))
|
||||
return option ? option.label : t('emailBill.senderRule.fetchModeAttachment')
|
||||
}
|
||||
|
||||
const ruleColumns = computed<DataTableColumns<EmailBillSenderRule>>(() => [
|
||||
{ title: t('emailBill.senderRule.name'), key: 'name', width: 140, ellipsis: { tooltip: true } },
|
||||
{
|
||||
@@ -538,6 +552,12 @@ const ruleColumns = computed<DataTableColumns<EmailBillSenderRule>>(() => [
|
||||
width: 100,
|
||||
render: (row) => h(NTag, { size: 'small', type: 'info' }, { default: () => billTypeLabel(row.billType) }),
|
||||
},
|
||||
{
|
||||
title: t('emailBill.senderRule.fetchMode'),
|
||||
key: 'fetchMode',
|
||||
width: 100,
|
||||
render: (row) => fetchModeLabel(row.fetchMode),
|
||||
},
|
||||
{ title: t('emailBill.senderRule.sortOrder'), key: 'sortOrder', width: 80 },
|
||||
{
|
||||
title: t('emailBill.common.enabled'),
|
||||
@@ -611,6 +631,8 @@ function openRuleModal(row?: EmailBillSenderRule) {
|
||||
sortOrder: row.sortOrder,
|
||||
patternsText: (row.senderPatterns || []).join('\n'),
|
||||
billType: row.billType,
|
||||
fetchMode: row.fetchMode || 'ATTACHMENT',
|
||||
linkPattern: row.linkPattern || '',
|
||||
archivePassword: row.archivePassword || '',
|
||||
expenseCategoryId: row.expenseCategoryId,
|
||||
incomeCategoryId: row.incomeCategoryId,
|
||||
@@ -638,6 +660,8 @@ async function saveRule() {
|
||||
sortOrder: ruleForm.value.sortOrder,
|
||||
senderPatterns: patterns,
|
||||
billType: ruleForm.value.billType,
|
||||
fetchMode: ruleForm.value.fetchMode,
|
||||
linkPattern: ruleForm.value.linkPattern || null,
|
||||
archivePassword: ruleForm.value.archivePassword,
|
||||
expenseCategoryId: ruleForm.value.expenseCategoryId,
|
||||
incomeCategoryId: ruleForm.value.incomeCategoryId,
|
||||
@@ -989,7 +1013,7 @@ onUnmounted(() => {
|
||||
:row-key="(r: EmailBillSenderRule) => r.id"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
:scroll-x="780"
|
||||
:scroll-x="880"
|
||||
:pagination="rulePagination"
|
||||
@update:page="handleRulePageChange"
|
||||
@update:page-size="handleRulePageSizeChange"
|
||||
@@ -1177,6 +1201,15 @@ onUnmounted(() => {
|
||||
<NFormItem :label="t('emailBill.senderRule.billType')">
|
||||
<NSelect v-model:value="ruleForm.billType" :options="billTypeOptions" style="width: 100%" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('emailBill.senderRule.fetchMode')">
|
||||
<NSelect v-model:value="ruleForm.fetchMode" :options="fetchModeOptions" style="width: 100%" />
|
||||
</NFormItem>
|
||||
<NFormItem v-if="ruleForm.fetchMode === 'BODY_LINK'" :label="t('emailBill.senderRule.linkPattern')">
|
||||
<NInput
|
||||
v-model:value="ruleForm.linkPattern"
|
||||
:placeholder="t('emailBill.senderRule.linkPatternPlaceholder')"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('emailBill.senderRule.archivePassword')">
|
||||
<NInput
|
||||
v-model:value="ruleForm.archivePassword"
|
||||
|
||||
Reference in New Issue
Block a user