From a4ae7ac9795718305ebcec3892ec8a6b2af5cd20 Mon Sep 17 00:00:00 2001 From: rosercode Date: Mon, 21 Sep 2026 13:25:27 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E7=AB=99=E4=BB=93=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 17 ++ .gitignore | 39 ++++ .vitepress/config.mts | 137 +++++++++++ .vitepress/theme/custom.css | 47 ++++ .vitepress/theme/index.ts | 78 +++++++ README.md | 98 ++++++++ dev/ai.md | 80 +++++++ dev/api.md | 74 ++++++ dev/architecture.md | 105 +++++++++ dev/bill-fields.md | 89 +++++++ dev/bill-parsing.md | 67 ++++++ dev/build-deploy.md | 96 ++++++++ dev/data-model.md | 133 +++++++++++ dev/database.md | 55 +++++ dev/development.md | 58 +++++ dev/index.md | 23 ++ dev/notifications.md | 40 ++++ dev/project-structure.md | 93 ++++++++ dev/rule-engine.md | 53 +++++ guide/ai.md | 95 ++++++++ guide/bills/alipay.md | 63 +++++ guide/bills/bocom.md | 51 ++++ guide/bills/cmb.md | 81 +++++++ guide/bills/jd.md | 47 ++++ guide/bills/meituan.md | 38 +++ guide/bills/overview.md | 69 ++++++ guide/bills/taobao.md | 43 ++++ guide/bills/wechat.md | 49 ++++ guide/categories-tags.md | 55 +++++ guide/dashboard.md | 82 +++++++ guide/email-bills.md | 84 +++++++ guide/faq.md | 94 ++++++++ guide/getting-started.md | 65 ++++++ guide/index.md | 37 +++ guide/introduction.md | 55 +++++ guide/logs.md | 40 ++++ guide/notifications.md | 37 +++ guide/pages.md | 55 +++++ guide/rules.md | 85 +++++++ guide/settings.md | 71 ++++++ guide/transactions.md | 193 +++++++++++++++ index.md | 65 ++++++ package.json | 36 +++ public/favicon.svg | 27 +++ public/logo.svg | 11 + scripts/ts/browser.ts | 111 +++++++++ scripts/ts/capture-all.ts | 43 ++++ scripts/ts/capture-elements.ts | 282 ++++++++++++++++++++++ scripts/ts/capture-pages.ts | 66 ++++++ scripts/ts/config.ts | 26 +++ scripts/ts/fetch-refs.ts | 46 ++++ scripts/ts/make-fake-bills.ts | 412 +++++++++++++++++++++++++++++++++ scripts/ts/search-refs.ts | 48 ++++ scripts/ts/seed-data.ts | 104 +++++++++ scripts/ts/seed-rules.ts | 165 +++++++++++++ scripts/ts/test-browser.ts | 26 +++ scripts/ts/upload-oss.ts | 268 +++++++++++++++++++++ tsconfig.json | 15 ++ 58 files changed, 4622 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .vitepress/config.mts create mode 100644 .vitepress/theme/custom.css create mode 100644 .vitepress/theme/index.ts create mode 100644 README.md create mode 100644 dev/ai.md create mode 100644 dev/api.md create mode 100644 dev/architecture.md create mode 100644 dev/bill-fields.md create mode 100644 dev/bill-parsing.md create mode 100644 dev/build-deploy.md create mode 100644 dev/data-model.md create mode 100644 dev/database.md create mode 100644 dev/development.md create mode 100644 dev/index.md create mode 100644 dev/notifications.md create mode 100644 dev/project-structure.md create mode 100644 dev/rule-engine.md create mode 100644 guide/ai.md create mode 100644 guide/bills/alipay.md create mode 100644 guide/bills/bocom.md create mode 100644 guide/bills/cmb.md create mode 100644 guide/bills/jd.md create mode 100644 guide/bills/meituan.md create mode 100644 guide/bills/overview.md create mode 100644 guide/bills/taobao.md create mode 100644 guide/bills/wechat.md create mode 100644 guide/categories-tags.md create mode 100644 guide/dashboard.md create mode 100644 guide/email-bills.md create mode 100644 guide/faq.md create mode 100644 guide/getting-started.md create mode 100644 guide/index.md create mode 100644 guide/introduction.md create mode 100644 guide/logs.md create mode 100644 guide/notifications.md create mode 100644 guide/pages.md create mode 100644 guide/rules.md create mode 100644 guide/settings.md create mode 100644 guide/transactions.md create mode 100644 index.md create mode 100644 package.json create mode 100644 public/favicon.svg create mode 100644 public/logo.svg create mode 100644 scripts/ts/browser.ts create mode 100644 scripts/ts/capture-all.ts create mode 100644 scripts/ts/capture-elements.ts create mode 100644 scripts/ts/capture-pages.ts create mode 100644 scripts/ts/config.ts create mode 100644 scripts/ts/fetch-refs.ts create mode 100644 scripts/ts/make-fake-bills.ts create mode 100644 scripts/ts/search-refs.ts create mode 100644 scripts/ts/seed-data.ts create mode 100644 scripts/ts/seed-rules.ts create mode 100644 scripts/ts/test-browser.ts create mode 100644 scripts/ts/upload-oss.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..3d7526d --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# 截图上传 OSS 所需的凭证与目标配置。 +# 复制为 .env.local 并填入真实值;.env.local 不纳入版本管理。 +# 上传脚本:npm run oss:upload + +# 用于调用 STS AssumeRole 的长期访问密钥 +OSS_ACCESS_KEY_ID= +OSS_ACCESS_KEY_SECRET= + +# 被扮演的 RAM 角色 ARN +OSS_ROLE_ARN=acs:ram:::role/ + +# 目标 OSS 桶与区域 +OSS_BUCKET= +OSS_REGION=cn-beijing + +# 对象键前缀(截图统一放在该前缀下,保留 elements/ 子目录结构) +OSS_KEY_PREFIX=finance-app-docs/screenshots diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af07797 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# 依赖 +node_modules/ +npm-debug.log* + +# 本地私服配置与依赖锁(不纳入版本管理) +.npmrc +package-lock.json + +# 本地凭证(OSS 上传),模板见 .env.example +.env.local + +# AI 助手说明(本地维护,不纳入版本管理) +AGENTS.md + +# VitePress 构建产物与缓存 +.vitepress/dist/ +.vitepress/cache/ + +# 运行/参考资料(不参与构建) +refs/ +jar-home/ +logs/ +*.log + +# 样例账单(虚构数据,仅本地生成用于截图) +scripts/samples/ + +# 文档配图托管于阿里云 OSS,本地截图不提交 +public/screenshots/ + +# 根目录临时截图(public/ 下的站点资源除外) +/*.png + +# OS / IDE +.DS_Store +Thumbs.db +.idea/ +.vscode/ +*.swp diff --git a/.vitepress/config.mts b/.vitepress/config.mts new file mode 100644 index 0000000..c20a3b6 --- /dev/null +++ b/.vitepress/config.mts @@ -0,0 +1,137 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + lang: 'zh-CN', + title: '收支管理系统', + description: '个人收支管理系统使用与开发文档', + head: [['link', { rel: 'icon', href: '/favicon.svg', type: 'image/svg+xml' }]], + lastUpdated: true, + cleanUrls: true, + ignoreDeadLinks: true, + // 维护类文档不参与站点构建 + srcExclude: ['AGENTS.md'], + // 文档站默认端口从 5173 调整为 5175,把 5173 让给 finance-app 前端 + vite: { + server: { + host: '0.0.0.0', + port: 5175, + }, + }, + themeConfig: { + logo: '/logo.svg', + nav: [ + { text: '使用文档', link: '/guide/introduction', activeMatch: '/guide/' }, + { text: '开发文档', link: '/dev/architecture', activeMatch: '/dev/' }, + ], + sidebar: { + '/guide/': [ + { + text: '开始使用', + items: [ + { text: '项目简介', link: '/guide/introduction' }, + { text: '快速开始', link: '/guide/getting-started' }, + { text: '统计面板', link: '/guide/dashboard' }, + { text: '页面总览', link: '/guide/pages' }, + ], + }, + { + text: '记账与整理', + items: [ + { text: '收支记录', link: '/guide/transactions' }, + { text: '分类与标签', link: '/guide/categories-tags' }, + { text: '规则管理', link: '/guide/rules' }, + { text: '邮箱账单', link: '/guide/email-bills' }, + ], + }, + { + text: '智能与自动化', + items: [ + { text: 'AI 能力', link: '/guide/ai' }, + { text: '定时提醒与通知', link: '/guide/notifications' }, + ], + }, + { + text: '系统', + items: [ + { text: '系统设置', link: '/guide/settings' }, + { text: '系统日志', link: '/guide/logs' }, + ], + }, + { + text: '账单获取', + items: [ + { text: '账单类型总览', link: '/guide/bills/overview' }, + { text: '支付宝账单', link: '/guide/bills/alipay' }, + { text: '微信支付账单', link: '/guide/bills/wechat' }, + { text: '美团账单', link: '/guide/bills/meituan' }, + { text: '淘宝账单', link: '/guide/bills/taobao' }, + { text: '京东账单', link: '/guide/bills/jd' }, + { text: '招商银行账单', link: '/guide/bills/cmb' }, + { text: '交通银行账单', link: '/guide/bills/bocom' }, + ], + }, + { + text: '其他', + items: [{ text: '常见问题', link: '/guide/faq' }], + }, + ], + '/dev/': [ + { + text: '开发文档', + items: [ + { text: '总览', link: '/dev/' }, + { text: '整体架构', link: '/dev/architecture' }, + { text: '数据模型', link: '/dev/data-model' }, + { text: '账单解析', link: '/dev/bill-parsing' }, + { text: '规则引擎', link: '/dev/rule-engine' }, + { text: 'AI 集成', link: '/dev/ai' }, + { text: '定时通知', link: '/dev/notifications' }, + { text: '动态数据源', link: '/dev/database' }, + ], + }, + { + text: '工程与规范', + items: [ + { text: '项目结构', link: '/dev/project-structure' }, + { text: '构建与部署', link: '/dev/build-deploy' }, + { text: '开发环境与规范', link: '/dev/development' }, + { text: '账单字段说明', link: '/dev/bill-fields' }, + { text: 'API 文档', link: '/dev/api' }, + ], + }, + ], + }, + socialLinks: [], + footer: { + message: '基于 Spring Boot 3 + Vue 3 的个人收支管理系统', + }, + docFooter: { + prev: '上一页', + next: '下一页', + }, + outline: { + label: '本页目录', + level: [2, 3], + }, + search: { + provider: 'local', + options: { + translations: { + button: { + buttonText: '搜索文档', + buttonAriaLabel: '搜索文档', + }, + modal: { + noResultsText: '无法找到相关结果', + resetButtonTitle: '清除查询条件', + footer: { + selectText: '选择', + navigateText: '切换', + closeText: '关闭', + }, + }, + }, + }, + }, + }, +}) diff --git a/.vitepress/theme/custom.css b/.vitepress/theme/custom.css new file mode 100644 index 0000000..74e963a --- /dev/null +++ b/.vitepress/theme/custom.css @@ -0,0 +1,47 @@ +/* 文档图片点击全屏查看 */ + +/* 正文图片:加边框、圆角与阴影,避免白色截图与正文融在一起 */ +.vp-doc img { + display: block; + margin: 16px auto; + padding: 6px; + border: 1px solid var(--vp-c-divider); + border-radius: 10px; + background-color: var(--vp-c-bg-soft); + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); +} + +/* 可点击的正文图片:鼠标样式与轻微过渡 */ +.vp-doc img.doc-zoomable { + cursor: zoom-in; + transition: opacity 0.2s ease, box-shadow 0.2s ease; +} + +.vp-doc img.doc-zoomable:hover { + opacity: 0.94; + box-shadow: 0 4px 18px rgba(0, 0, 0, 0.16); +} + +/* 全屏查看遮罩:默认隐藏,激活时铺满视口 */ +.doc-image-viewer { + position: fixed; + inset: 0; + z-index: 9999; + display: none; + align-items: center; + justify-content: center; + background: #000000; + cursor: zoom-out; +} + +.doc-image-viewer.active { + display: flex; +} + +/* 图片按比例铺满屏幕(contain,保持完整不裁剪) */ +.doc-image-viewer img { + width: 100vw; + height: 100vh; + object-fit: contain; + cursor: zoom-out; +} diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts new file mode 100644 index 0000000..270cd2c --- /dev/null +++ b/.vitepress/theme/index.ts @@ -0,0 +1,78 @@ +// 自定义主题:为文档图片启用「点击全屏查看」 +// +// 点击正文(.vp-doc)内的图片后,在全屏遮罩中铺满展示原图; +// 再次点击或按 Esc 关闭。路由切换时重新绑定,避免遗漏动态渲染的图片。 +import { onMounted, watch, nextTick } from 'vue' +import { useRoute } from 'vitepress' +import DefaultTheme from 'vitepress/theme' +import './custom.css' + +// 全屏查看器遮罩元素(全站复用一个) +let viewer: HTMLDivElement | null = null + +/** 关闭全屏查看器 */ +function closeViewer() { + viewer?.classList.remove('active') +} + +/** 打开全屏查看器并展示指定图片 + + * :param src: 图片地址 + * :param alt: 图片替代文本 + */ +function openViewer(src: string, alt: string) { + if (!viewer) return + const img = viewer.querySelector('img') + if (!img) return + img.src = src + img.alt = alt + viewer.classList.add('active') +} + +/** 创建全屏查看器并注册关闭事件 */ +function ensureViewer() { + if (viewer) return + viewer = document.createElement('div') + viewer.className = 'doc-image-viewer' + viewer.innerHTML = '' + // 点击遮罩或图片本身关闭 + viewer.addEventListener('click', closeViewer) + document.addEventListener('keydown', (event) => { + if (event.key === 'Escape') closeViewer() + }) + document.body.appendChild(viewer) +} + +/** 为正文图片绑定点击全屏事件(幂等) */ +function bindImages() { + const images = document.querySelectorAll('.vp-doc img') + images.forEach((img) => { + if (img.dataset.zoomBound === '1') return + img.dataset.zoomBound = '1' + img.classList.add('doc-zoomable') + img.addEventListener('click', () => { + openViewer(img.currentSrc || img.src, img.alt) + }) + }) +} + +export default { + extends: DefaultTheme, + setup() { + const route = useRoute() + + onMounted(() => { + ensureViewer() + bindImages() + }) + + // 路由切换后重新绑定(文章内容异步渲染) + watch( + () => route.path, + () => { + closeViewer() + nextTick(() => bindImages()) + }, + ) + }, +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1d9e2a --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# 收支管理系统 · 文档站 + +本目录是「收支管理系统」(同级目录 `../finance-app`)的**使用文档**与**开发文档**站点,基于 [VitePress](https://vitepress.dev/) 构建,并附带一套 Node/TypeScript 维护脚本(截图、演示数据、资料抓取)。 + +> **在线体验**:[https://finance.rosercode.cn/](https://finance.rosercode.cn/)(演示账号 `demo` / `demo123456`,使用独立的 SQLite 演示库与虚构数据) + +## 快速开始 + +```sh +# 安装依赖(走本地 verdaccio 私服,见 .npmrc) +npm install + +# 本地开发(默认 http://localhost:5175) +npm run docs:dev + +# 构建静态站点到 .vitepress/dist +npm run docs:build + +# 预览构建产物 +npm run docs:preview +``` + +对外访问(局域网/远程): + +```sh +./node_modules/.bin/vitepress dev --host 0.0.0.0 +``` + +## 文档结构 + +站点分为两大板块: + +- **使用文档**(`guide/`):面向使用者,讲「怎么用」。 + - 开始使用、记账与整理、智能与自动化、系统 + - `guide/bills/`:各类账单的获取与字段说明 + - `guide/faq.md`:常见问题 +- **开发文档**(`dev/`):面向开发者,讲「怎么跑、怎么设计、怎么改」。 + - 整体架构、数据模型、账单解析、规则引擎、AI 集成、定时通知、动态数据源 + - 项目结构、构建与部署、开发环境与规范、账单字段说明、API 文档 + +首页 `index.md` 提供两个板块的入口。 + +## 目录结构 + +```text +doc-site/ +├── index.md # 首页 +├── guide/ # 使用文档(含 bills/) +├── dev/ # 开发文档 +├── public/ # 静态资源 +│ ├── logo.svg +│ └── screenshots/ # 文档配图(整页 + elements/ 关键元素) +├── .vitepress/ +│ ├── config.mts # 站点配置(导航、侧边栏、搜索、端口 5175) +│ └── theme/ # 自定义主题(图片全屏查看、图片样式) +├── scripts/ +│ ├── ts/ # 维护脚本(TypeScript) +│ └── samples/ # 样例账单 +├── refs/ # 联网检索到的资料原文(不参与构建) +├── jar-home/ # jar 一体化实例运行目录(测试库副本) +├── package.json +└── tsconfig.json +``` + +## 维护脚本 + +脚本位于 `scripts/ts/`,用 `tsx` 直接运行 TypeScript,浏览器自动化基于 `playwright-core`(Chromium),HTTP 基于 `axios`。 + +```sh +npm run capture # 一键重新截图(整页 + 元素 + 卡片 + 编辑/设置面板 + 解析预览) +npm run capture:pages # 仅整页截图 +npm run capture:elements # 仅元素截图 +npm run seed # 导入样例账单(演示数据) +npm run seed:rules # 补充标签与规则 +npm run refs:search # 检索资料到 refs/ +npm run refs:fetch # 抓取指定 URL 到 refs/ +npm run test:browser # 验证浏览器链路 +npm run typecheck:scripts # 类型检查 +``` + +运行截图前请确保: + +1. 应用实例已启动(默认访问 `jar-home/` 下的 jar 实例 `localhost:18082`); +2. 测试库中已配置 AI(用于截取启用 AI 后的界面)。 + +可通过环境变量覆盖:`FINANCE_WEB`、`FINANCE_API`、`FINANCE_USER`、`FINANCE_PASSWORD`。 + +> **环境安全**:截图与演示数据只操作测试环境,禁止访问真实环境(5173 / 8080 / MySQL 真实库)。 + +## 环境要求 + +- **Node.js**(建议 20+,本项目使用 v24) +- npm 依赖走本地 verdaccio 私服(`.npmrc` 已配置 `registry=http://localhost:4873/`) +- 浏览器:复用 Playwright 缓存的 Chromium,无需系统安装 Chrome/Firefox + +## 约定 + +维护本目录时请遵守 [AGENTS.md](./AGENTS.md)(文档归类、截图规范、构建校验等)。 diff --git a/dev/ai.md b/dev/ai.md new file mode 100644 index 0000000..b3d5f27 --- /dev/null +++ b/dev/ai.md @@ -0,0 +1,80 @@ +# AI 集成 + +系统集成 **DeepSeek** 大模型,用于分类打标、自然语言查询与记账等场景,并完整记录调用用量。 + +## 客户端 + +`DeepSeekClient` 基于 **OpenAI 官方 Java SDK**(`com.openai:openai-java`)调用,通过自定义 `baseUrl` 指向 DeepSeek 的 OpenAI 兼容接口: + +- 使用 `OpenAIOkHttpClient`,按 `(baseUrl, apiKey)` 缓存客户端(上限 32,超出后清空重建)。 +- `temperature = 0.2`,`response_format = {"type":"json_object"}`,非流式,`maxRetries(0)`,请求超时 60 秒。 +- 取 `choices[0].message.content` 与 `usage`(输入/输出/总 token;缓存命中 token 取 `promptTokensDetails.cachedTokens`,DeepSeek 私有字段读不到时为 0)。 +- 对外保持 `chat(baseUrl, apiKey, model, systemPrompt, userPrompt)` 与 `DeepSeekResponse` 不变,各 `Ai*ServiceImpl` 无需改动。 + +配置存储于 `t_ai_config`(每用户一条):`enabled`、`api_key`、`base_url`(默认 `https://api.deepseek.com`)、`model`(默认 `deepseek-chat`)。API Key 对外脱敏,支持连通性测试。 + +## 数据外发范围(隐私) + +调用大模型时**仅发送功能所需的最小字段**,禁止外发敏感数据: + +- **禁止**:`rawData`(账单原始 JSON)、金额、交易号、账户/卡号、姓名、地址、电话等。 +- **允许**:收支类型、交易渠道、备注(必要时截断/脱敏)、分类与标签的名称/ID、用户主动输入的自然语言文本。 + +## 调用类型 + +`AiCallType` 定义了 9 类调用,全部记录用量: + +| 枚举值 | 含义 | +|---|---| +| `SUGGEST` | 交易分类/标签建议(批量 + 单条) | +| `TEST` | 连接测试 | +| `QUERY` | 自然语言查询解析为筛选条件 | +| `RECORD` | 自然语言记账解析为交易记录 | +| `DECORATE` | 分类 Emoji / 标签颜色建议 | +| `CATEGORY` | 自然语言生成分类 | +| `TAG` | 自然语言生成标签 | +| `RULE` | 由已打标签记录生成分类与标签规则 | +| `STATISTICS` | 自然语言统计查询解析 | + +## 分类/标签建议 + +`AiSuggestionServiceImpl`: + +- 先按「类型 + 交易渠道 + 备注」聚合去重,仅将代表记录送模型;单批 ≤ 100 条、总量 ≤ 500 条,超出则分批调用。 +- 支持严格模式(仅从已有分类/标签中选择)与增强模式(可返回新分类/新标签)。 +- 应用时分类覆盖写入、标签追加;新建分类按「类型 + 名称」去重,新建标签按名称去重并校验颜色格式。 + +## 自然语言查询 + +`AiQueryServiceImpl` 在提示词中注入当前分类、标签与当前日期,解析出:类型、交易渠道、分类名、标签名、日期范围预设(今天/昨天/本周/上周/本月/上月/近7天/近30天/今年/去年/自定义/无)、金额区间、备注、是否计入统计、是否未分类等,并在服务端把预设换算为绝对时间。 + +## 自然语言记账 + +`AiRecordServiceImpl` 解析类型、金额、分类、标签、交易渠道、交易时间、备注、计入统计等字段;金额无效直接报错,时间缺失回退当前时间。 + +## 规则生成 + +`AiRuleServiceImpl` 由用户勾选的**已打标签记录**反推分类/标签规则: + +- 仅加载属于当前用户且被勾选的记录(单次 ≤ 500 条),附带其标签名称与分类名;提示词字段为 `type / remark / channel / category / tags`(不含金额与 `rawData`),并对相同组合去重。 +- 解析并校验模型输出(分类 ID 归属与类型、标签归属、名称长度、颜色格式等)。 +- **去重**:按「类型 + 归一化关键词 + 分类 + 排序标签集合」计算签名;与已有规则或本次建议内部重复的条目标记 `duplicate=true`(前端默认不勾选且禁止导入),`apply` 时后端再次校验并跳过。 + +## 统计搜索 + +`AiStatisticsServiceImpl` 在提示词中注入分类、标签与当前日期,将自然语言解析为统计面板参数:时间范围(预设→绝对时间)、收支类型、分类、标签、交易渠道、金额区间、备注、统计口径(`statsScope`)与趋势粒度(`dimension`)。 + +## 用量记录 + +- 每次调用写入 `t_ai_usage`:调用类型、模型、分析条数、输入/输出/总/缓存 token、是否成功、错误信息、耗时。 +- 记录失败仅告警,不影响主流程。 +- `GET /api/ai/usage` 返回汇总与最近明细,对应「模型使用情况」页面。 + +## 说明 + +账单类型识别**不使用 AI**,而是由邮箱账单的**发件人规则**确定,结果确定、可预期。详见[定时通知](/dev/notifications)与[邮箱账单](/guide/email-bills)。 + +## 相关文档 + +- [AI 能力(使用说明)](/guide/ai) +- [整体架构](/dev/architecture) diff --git a/dev/api.md b/dev/api.md new file mode 100644 index 0000000..e9a2463 --- /dev/null +++ b/dev/api.md @@ -0,0 +1,74 @@ +# API 文档 + +系统使用 **Knife4j(OpenAPI 3)** 生成接口文档,后端启动后可直接访问。 + +## 访问入口 + +| 入口 | 地址 | 说明 | +|---|---|---| +| Knife4j UI | `http://:/doc.html` | 增强版接口文档界面 | +| OpenAPI JSON | `http://:/v3/api-docs` | 标准 OpenAPI 描述 | +| Swagger UI | `http://:/swagger-ui/index.html` | 原生界面 | + +开发模式下默认端口为 8080,例如 http://localhost:8080/doc.html。 + +## 认证 + +- 接口统一使用 **JWT Bearer** 鉴权,登录接口返回 token 后,在请求头携带: + + ```text + Authorization: Bearer + ``` + +- Knife4j 配置中已声明全局 `Bearer` 安全方案,可在文档界面直接填入 token 调试。 +- 无需鉴权的接口:`/api/user/login`、`/api/user/register`、`/api/user/registration-status`、`/api/database/status`。 +- 日志实时推送等 SSE 接口因 `EventSource` 无法自定义请求头,兼容通过 `?token=` 查询参数鉴权。 + +## 接口分组 + +| 控制器 | 路径前缀 | 说明 | +|---|---|---| +| `UserController` | `/api/user` | 登录、注册、用户信息、注册开关 | +| `TransactionController` | `/api/transaction` | 收支记录增删改查、批量操作、导出、交易渠道列表 | +| `BillImportController` | `/api/transaction/import` | 账单解析与确认导入 | +| `CategoryController` | `/api/category` | 分类管理 | +| `TagController` | `/api/tag` | 标签管理 | +| `RuleController` | `/api/rule` | 自定义规则(列表、分页查询、增删改、启停) | +| `StatisticsController` | `/api/statistics` | 统计汇总、分类、趋势、分组 | +| `AiController` | `/api/ai` | AI 建议、查询、记账、规则生成、统计搜索、用量 | +| `NotificationController` | `/api/notification` | 通知配置、渠道、发送日志 | +| `EmailBillController` | `/api/email-bill` | 邮箱配置、发件人规则、待处理账单 | +| `LogController` | `/api/log` | 访问日志、登录日志、运行日志与 SSE | +| `DatabaseController` | `/api/database` | 数据库状态、测试与配置 | +| `AppConfigController` | `/api/app` | 应用配置(通知链接、日志保留) | +| `UserSettingController` | `/api/setting` | 用户偏好(账单来源统计默认值、不计入统计展示样式) | + +## 关键接口 + +| 方法 | 路径 | 说明 | +|---|---|---| +| GET | `/api/transaction/channels` | 获取系统支持的交易渠道编码列表 | +| GET | `/api/rule/page` | 分页查询规则(`keyword` / `enabled` / `type`) | +| POST | `/api/ai/rule/suggest` | 依据所选已打标签记录生成规则建议(含 `duplicate` 标记) | +| POST | `/api/ai/rule/apply` | 批量创建规则(自动去重、按需新建分类/标签) | +| POST | `/api/ai/statistics` | 自然语言解析为统计面板参数 | +| GET / PUT | `/api/app/bill-source-include-in-stats` | 查询 / 更新按账单来源的「计入统计」默认值 | + +## 统一响应结构 + +```json +{ + "code": 200, + "message": "success", + "data": {} +} +``` + +- `code` 非 200 表示业务失败,`message` 为错误信息。 +- 分页接口的 `data` 为 `PageResult`(`records` / `total` 等)。 +- 数据库未配置时返回业务码 `5031`,前端据此跳转初始化引导页。 + +## 相关文档 + +- [整体架构](/dev/architecture) +- [项目结构](/dev/project-structure) diff --git a/dev/architecture.md b/dev/architecture.md new file mode 100644 index 0000000..246e82c --- /dev/null +++ b/dev/architecture.md @@ -0,0 +1,105 @@ +# 整体架构 + +本章介绍系统的分层结构与关键技术设计。 + +## 技术栈 + +| 层级 | 技术 | +|---|---| +| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus 3.5.12 | +| 数据库 | SQLite(本地单文件)/ MySQL | +| 前端 | Vue 3 + TypeScript + Vite + Naive UI + ECharts + Pinia + vue-i18n | +| 认证 | JWT(HMAC) | +| 对象映射 | MapStruct 1.6.3 | +| 账单解析 | EasyExcel(xlsx)/ Apache POI(xls)+ commons-csv + PDFBox + Zip4j | +| AI | DeepSeek(OpenAI Java SDK) | +| 通知 | Jakarta Mail(SMTP / IMAP)+ Server 酱 | +| API 文档 | Knife4j(OpenAPI 3) | + +## 后端分层 + +```text +controller 接口层,统一返回 Result,经 UserContext 取当前用户 + │ +service 业务层(接口 + impl),事务、跨表校验、聚合 + │ +mapper MyBatis-Plus BaseMapper,少量自定义 SQL + │ +entity 数据库表映射实体 +``` + +辅助包: + +| 包 | 职责 | +|---|---| +| `dto` / `vo` | 入参请求对象 / 出参视图对象 | +| `converter` | MapStruct 实体↔VO 转换(`VoConverter`) | +| `importer` | 账单解析器(`BillParser` 及实现、`BillArchiveExtractor`) | +| `exporter` | Excel 导出样式处理 | +| `notification` | 通知渠道发送器(工厂 + 各渠道实现) | +| `scheduler` | 定时任务(通知、日志清理、邮箱账单) | +| `interceptor` | JWT 鉴权、访问日志 | +| `config` | Web、JWT、Knife4j、MyBatis-Plus、动态数据源、SPA 路由回退 | +| `client` | DeepSeek 客户端 | +| `email` | IMAP 邮箱账单抓取(监听、拉取、解析、存储) | +| `common` | `Result`、`PageResult`、业务异常、全局异常处理 | +| `util` | `JwtUtil`、`UserContext`、`AppLogUtils` 等 | +| `enums` | `BillType`、`AiCallType`、`StatisticsDimension` 等 | + +## 请求处理流程 + +```text +浏览器 + │ /api/** + ▼ +AccessLogInterceptor 记录访问日志(方法/路径/耗时/状态/IP/用户) + │ +JwtInterceptor 校验 Bearer Token(兼容 ?token= 供 SSE),写入 userId + │ +Controller UserContext.getUserId() + │ +Service → Mapper → 数据库(动态数据源) + │ +GlobalExceptionHandler 统一异常 → Result{code,message,data} +``` + +- 登录、注册、注册状态、数据库状态等接口无需鉴权。 +- 数据库未配置时,`UnconfiguredDataSource` 抛出的异常会转为业务码 `5031`,前端据此跳转初始化引导页。 + +## 认证与鉴权 + +- `JwtUtil` 使用 HMAC 密钥签发 JWT,`subject` 为用户 ID,附带用户名 claim,默认有效期 24 小时。 +- `JwtInterceptor` 从 `Authorization: Bearer ` 解析;由于 `EventSource` 无法自定义请求头,同时兼容 `?token=` 查询参数(用于日志实时推送等 SSE 场景)。 +- `UserContext` 基于 `RequestContextHolder` 读取拦截器写入的 `userId`,使控制器无需声明 `HttpServletRequest`。 + +## 前端结构 + +```text +src/ +├── api/ 各模块 REST 接口封装(axios,统一携带 Token、处理 401/业务码) +├── views/ 页面组件(Dashboard、TransactionList、BillImport 等) +├── components/ 复用组件(账单预览、导入结果、AI 配置、通知配置等) +├── router/ 路由与导航守卫(数据库配置校验、登录校验) +├── store/ Pinia 状态(user / database / registration) +├── locales/ 中/英/日三语文案 +├── composables/ 组合式函数(关键词历史) +└── utils/ 工具(交易渠道标签、SSE 封装等) +``` + +## 关键设计亮点 + +1. **运行时动态数据源**:启动无需数据库,配置后热切换,见[动态数据源](/dev/database)。 +2. **账单两阶段导入**:解析预览 → 确认导入,按交易号去重,见[账单解析](/dev/bill-parsing)。 +3. **规则引擎**:分类取最高优先级、标签取并集,见[规则引擎](/dev/rule-engine)。 +4. **体系化 AI 调用**:9 类调用类型并记录 token 用量,见 [AI 集成](/dev/ai)。 +5. **SSE 实时能力**:运行日志、邮箱检查进度实时推送。 +6. **用户偏好缓存**:`t_user_preference` 的读取走 Spring Cache 进程内缓存(`ConcurrentMapCacheManager`),写入时按用户失效,切换数据源时整体清空。 + +## 相关文档 + +- [数据模型](/dev/data-model) +- [账单解析](/dev/bill-parsing) +- [规则引擎](/dev/rule-engine) +- [AI 集成](/dev/ai) +- [定时通知](/dev/notifications) +- [动态数据源](/dev/database) diff --git a/dev/bill-fields.md b/dev/bill-fields.md new file mode 100644 index 0000000..a7ba4b9 --- /dev/null +++ b/dev/bill-fields.md @@ -0,0 +1,89 @@ +# 账单字段说明 + +本章说明支付宝「交易记录明细查询」导出的 CSV 在导入本系统时,各字段的读取与丢弃情况,可作为开发新解析器或调整字段映射的参考。 + +## 样例与实现位置 + +- 编码:GBK(解析器兼容 UTF-8 / UTF-8 BOM) +- 解析器:`src/main/java/cn/rosercode/finance/importer/AlipayBillParser.java` +- 落库服务:`src/main/java/cn/rosercode/finance/service/impl/BillImportServiceImpl.java` + +## 文件结构 + +| 位置 | 内容 | 是否处理 | +|---|---|---| +| 第 1 行 | `支付宝交易记录明细查询` | 忽略 | +| 第 2 行 | `账号:[...]` | 忽略 | +| 第 3 行 | `起始日期:[...] 终止日期:[...]` | 忽略 | +| 第 4 行 | `---交易记录明细列表---` | 忽略 | +| 第 5 行 | 表头(以「交易号」开头) | 作为列定位依据 | +| 数据行 | 每行一笔交易 | 逐行解析 | +| 结尾统计 | 待收入/已支出/待支出、导出时间、用户 | 忽略 | + +解析器通过「以 `交易号` 开头」定位表头行;遇到空行、`---` 分隔线或 `共` 开头的统计行即结束。 + +## CSV 完整表头(16 列) + +| 序号 | 列名 | 序号 | 列名 | +|---|---|---|---| +| 1 | 交易号 | 9 | 商品名称 | +| 2 | 商家订单号 | 10 | 金额(元) | +| 3 | 交易创建时间 | 11 | 收/支 | +| 4 | 付款时间 | 12 | 交易状态 | +| 5 | 最近修改时间 | 13 | 服务费(元) | +| 6 | 交易来源地 | 14 | 成功退款(元) | +| 7 | 类型 | 15 | 备注 | +| 8 | 交易对方 | 16 | 资金状态 | + +## 完全丢弃的字段(7 个) + +| 列名 | 说明 | +|---|---| +| 商家订单号 | 未使用 | +| 最近修改时间 | 未使用 | +| 交易来源地 | 未使用 | +| 类型 | 未使用,改用「收/支」推断类型 | +| 服务费(元) | 未使用 | +| 成功退款(元) | 未使用 | +| 备注 | 支付宝自带备注被丢弃;系统备注由「交易对方 · 商品名称」拼接生成 | + +## 读取但未落库的字段(3 个) + +| 列名 | 用途 | +|---|---| +| 交易状态 | 仅用于类型推断与预览展示 | +| 资金状态 | 仅用于「不计收支」行的类型推断 | +| 交易创建时间 | 仅当「付款时间」为空时作为交易时间兜底 | + +## 落库映射(写入 `t_transaction`) + +| CSV 列 | 数据库字段 | 说明 | +|---|---|---| +| 交易号 | `external_no` | 导入去重键 | +| 付款时间(缺失时用交易创建时间) | `transaction_time` | 交易发生时间 | +| 金额(元) | `amount` | 去除千分位逗号后解析 | +| 收/支 | `type` + `include_in_stats` | 「不计收支」默认 `include_in_stats = 0` | +| 交易对方 + 商品名称 | `remark` | 拼接为「交易对方 · 商品名称」,截断至 255 字符 | +| — | `channel` | 固定为 `ALIPAY` | +| — | `category_id` | 取用户选择的默认分类;未选择则为 `NULL` | + +### 类型推断规则 + +| 收/支 | 交易状态 / 资金状态 | 推断类型 | +|---|---|---| +| 支出 | — | `EXPENSE` | +| 收入 | — | `INCOME` | +| 不计收支 | 交易状态含「退款」 | `INCOME` | +| 不计收支 | 资金状态为「已收入」 | `INCOME` | +| 不计收支 | 其他(如「交易关闭」) | `EXPENSE` | + +## 可能的后续增强 + +1. 将支付宝「备注」并入系统 `remark`(当前完全丢失)。 +2. 新增字段记录「服务费」/「成功退款」。 +3. 保留「商家订单号」作为辅助去重或展示。 + +## 相关文档 + +- [账单解析](/dev/bill-parsing) +- [支付宝账单(使用文档)](/guide/bills/alipay) diff --git a/dev/bill-parsing.md b/dev/bill-parsing.md new file mode 100644 index 0000000..1d408ef --- /dev/null +++ b/dev/bill-parsing.md @@ -0,0 +1,67 @@ +# 账单解析 + +账单导入采用「**解析预览 → 确认导入**」两阶段流程,解析阶段不落库,用户确认后才写入。 + +## 解析器设计 + +解析器采用策略模式:`BillParser` 接口声明 `getBillType()` 与 `parse(byte[])`,各实现类对应一种账单类型。服务层构造时用 `EnumMap` 注册所有实现,按类型分发,便于扩展新来源。 + +| 解析器 | 格式 | 表头锚点 | 去重键来源 | +|---|---|---|---| +| `AlipayBillParser` | CSV(UTF-8/GBK) | 「交易号」 | 交易号 | +| `WechatBillParser` | xlsx(POI) | 「交易时间」 | 交易单号 | +| `MeituanBillParser` | CSV | 「交易创建时间」 | 交易单号 | +| `TaobaoBillParser` | xlsx(POI) | 「订单号」 | 订单号(按订单号聚合多商品行) | +| `JdBillParser` | CSV(UTF-8/GBK) | 「交易时间」 | 交易订单号 | +| `CmbBillParser` | CSV | 「交易日期」 | 生成 `CMB_日期_类型_金额_余额` | +| `CmbPdfParser` | PDF(PDFBox) | 「记账日期」 | 同上(与 CSV 互通) | +| `BocomBillParser` | xls(POI) | 「记账日期」 | 生成 `BOCOM_日期_时分秒_类型_金额` | + +各解析器统一输出 `ParsedBillRow`:行号、`externalNo`、交易时间、类型、金额、交易对方、商品、交易状态、原始收/支方向、是否计入统计、是否可导入、是否重复、命中规则与建议分类/标签、`rawData`(保留原始列的 JSON)、错误信息。 + +### 解析要点 + +- **编码识别**:CSV 自动识别 UTF-8 / UTF-8 BOM / GBK。 +- **表头定位**:以特定表头文字定位数据区,忽略元信息与统计行。 +- **银行 PDF**:按字符坐标切列、按纵坐标合并折行还原表格;用带符号金额判断收支;按「联机余额」做连续性校验(误差 0.005),不连续的行标记为不可导入。 +- **时间兼容**:微信账单兼容 Excel 日期序列号与文本时间;支付宝在付款时间缺失时回退交易创建时间;淘宝订单同样兼容文本时间与 Excel 日期序列号。 +- **订单聚合**:淘宝订单数据中多商品订单的订单级列(订单号、提交时间、状态、店铺、实付金额、运费)以合并单元格呈现,仅首个商品行有值;解析器按订单号分组聚合,每个订单输出一条记录,金额优先取订单级实付金额,为空时回退「Σ(商品金额×数量)+运费」。 +- **不计收支处理**:京东流水的「不计收支」行(白条还款、小金库转入、全额退款等)标记为可导入但**不计入统计且默认不勾选**,类型按「交易说明含『退款』→ INCOME,否则 EXPENSE」推断。 +- **计入统计默认值**:解析完成后按**账单来源**应用「计入统计」默认值(配置于系统设置,见 [动态数据源](/dev/database) 同级的应用配置)。采用「解析器判定 && 来源默认」语义——来源默认不计入时整批置为不计入;来源默认计入时保留解析器对「不计收支/中性」行的排除。确认导入时若请求未显式提供该字段,同样以来源默认兜底。 + +## 压缩包解密 + +`BillArchiveExtractor` 处理 zip 压缩包: + +- 按魔数 `PK\x03\x04` 判断是否为 zip,并区分「xlsx 本身是 zip」与「账单压缩包」。 +- 支持 ZipCrypto 与 AES 加密;未提供密码返回错误码 `4001`,密码错误返回 `4002`。 +- 限制条目数与总大小,防止 zip bomb;解析完成后清理临时文件。 + +## 两阶段流程 + +### parse(不落库) + +1. 按账单类型分发到对应解析器;若为 zip 则先解密提取。 +2. 批量查询已存在的 `externalNo`,标记重复行并取消勾选。 +3. 调用规则引擎批量匹配,生成建议分类与标签(批量查询分类与标签,避免 N+1)。 + +### confirm(落库,事务) + +1. 校验默认分类归属与类型,过滤默认标签。 +2. 再次批量运行规则匹配。 +3. 逐条校验类型、时间、金额;按 `externalNo` 去重(含本批次内去重)。 +4. **规则建议分类优先于面板默认分类**;标签取「默认标签 ∪ 规则标签」并去重。 +5. 批量插入(每 500 条一批),批次失败时回退逐条插入;标签关联在批量插入未回填主键时按 `externalNo` 回查 ID。 +6. 返回成功 / 跳过 / 失败统计。 + +## 去重策略 + +- 统一使用「用户 + `externalNo`」唯一约束 `uk_user_external` 去重,`external_no` 为 NULL 的手动记录不受影响。 +- 有交易号的账单直接使用交易号。 +- 银行流水无交易号,则用「日期 + 类型 + 金额 + 余额(或时分秒)」生成稳定键,保证同一账单重复导入不会重复;招商银行 CSV 与 PDF 生成规则一致,可混用。 + +## 相关文档 + +- [账单类型与获取](/guide/bills/overview) +- [账单导入(使用说明)](/guide/transactions) +- [规则引擎](/dev/rule-engine) diff --git a/dev/build-deploy.md b/dev/build-deploy.md new file mode 100644 index 0000000..65f0ffb --- /dev/null +++ b/dev/build-deploy.md @@ -0,0 +1,96 @@ +# 构建与部署 + +## 前置条件 + +- **JDK 21**(Temurin 21 LTS) +- **Node.js**(构建前端时需要) + +## 开发模式 + +前后端分离,便于调试: + +```bash +# 后端(默认 8080) +cd finance-app +set JAVA_HOME=D:\venv\jdk-21 +gradlew.bat bootRun + +# 前端(默认 5173,接口代理到 8080) +cd finance-app/frontend +npm run dev +``` + +- 后端地址:http://localhost:8080 +- 后端 API 文档:http://localhost:8080/doc.html +- 前端地址:http://localhost:5173,接口经 Vite 代理到后端 8080 + +## 打包可执行 jar(前后端一体化) + +`bootJar` 会先执行前端 `npm run build`,再把 `frontend/dist` 复制到 jar 内的 `BOOT-INF/classes/static/`,单文件同时提供页面与接口。 + +```bash +gradle bootJar +# 或使用宿主机脚本 +./package.sh +``` + +输出:`build/libs/finance-app-0.0.1-SNAPSHOT.jar` + +启动(默认 8080,可另指定端口): + +```bash +java -jar build/libs/finance-app-0.0.1-SNAPSHOT.jar --server.port=18081 +``` + +访问对应端口即可;刷新 `/dashboard` 等子路由由后端 `SpaErrorController` 回退到 `index.html`。 + +::: tip 提示 +jar 内置的前端为生产构建,访问时与后端同源,无需额外的反向代理。 +::: + +构建任务链(`build.gradle`): + +- `npmInstall`:安装前端依赖(`node_modules` 存在且 `package.json` 未变化时跳过) +- `frontendBuild`:执行 `npm run build` +- `bootJar`:依赖 `frontendBuild`,并将前端产物打入静态资源目录 + +## Docker 部署 + +`run/docker-compose.yml` 定义前后端两个服务,纯复用本地镜像、不构建新镜像: + +| 服务 | 镜像 | 端口 | 说明 | +|---|---|---|---| +| backend | `eclipse-temurin:21-jre` | 18080→8080 | 挂载 `run/backend/*.jar`,持久化 `config/`、`data/`、`logs/` | +| front | `nginx:alpine` | 15180→80 | 挂载 `run/front/dist` 与 Nginx 配置 | + +环境变量 `DB_URL` / `DB_USERNAME` / `DB_PASSWORD` 仅作为首次启动的默认配置来源,也可在界面中完成数据库配置。 + +## 桌面 GUI 启动器 + +`launcher` 包提供系统托盘 + 控制窗口的桌面启动器(启动/停止服务、打开浏览器、查看日志),通过 `bootGuiJar` 打包: + +```bash +gradle bootGuiJar +``` + +- 入口类:`cn.rosercode.finance.launcher.LauncherMain` +- 依赖 JavaFX 与 dorkbox SystemTray,仅加入带 GUI 的 jar(`guiRuntime` 配置),普通 jar 不含这些依赖 +- 平台默认 Windows(可通过 `-PjavafxPlatform=linux` 本地验证) +- JavaFX 与 JNA 原生库需 `requiresUnpack` 从嵌套 jar 解压 + +> 注意:该启动器为规划中的能力,具体实现以当前源码为准。 + +## 分发形态 + +| 形态 | 说明 | +|---|---| +| jar | `bootJar`,需 JDK 21 运行 | +| jar(带 GUI) | `bootGuiJar`,内含桌面启动器 | +| 可执行文件(控制台 / GUI) | 规划中,计划采用 `jpackage`(JDK 自带,各平台分别构建) | +| Docker 镜像 | 通过 `run/docker-compose.yml` 编排 | + +## 相关文档 + +- [项目结构](/dev/project-structure) +- [开发环境与规范](/dev/development) +- [快速开始(使用文档)](/guide/getting-started) diff --git a/dev/data-model.md b/dev/data-model.md new file mode 100644 index 0000000..556583d --- /dev/null +++ b/dev/data-model.md @@ -0,0 +1,133 @@ +# 数据模型 + +系统共 **18 张表**,覆盖用户、记账、标签、规则、AI、通知、邮箱账单与日志审计。所有表主键为自增 `id`,表间关系均为**逻辑外键**(通过 `user_id`、`category_id` 等字段在应用层维护,未声明物理外键)。 + +## 表清单 + +| 业务域 | 表名 | 说明 | +|---|---|---| +| 用户 | `t_user` | 用户表(用户名、BCrypt 密码、昵称) | +| 用户 | `t_user_preference` | 每用户多条的用户偏好(键值) | +| 记账 | `t_category` | 收支分类(收入/支出,含图标与排序) | +| 记账 | `t_transaction` | 收支记录(核心表) | +| 标签 | `t_tag` | 标签 | +| 标签 | `t_transaction_tag` | 交易-标签关联(多对多) | +| 规则 | `t_rule` | 自定义归类规则 | +| 规则 | `t_rule_tag` | 规则-标签关联(多对多) | +| AI | `t_ai_config` | 每用户一条的 AI 配置 | +| AI | `t_ai_usage` | AI 调用用量记录 | +| 通知 | `t_notification_config` | 每用户一条的提醒配置 | +| 通知 | `t_notification_channel` | 通知渠道 | +| 通知 | `t_notification_log` | 通知发送日志 | +| 邮箱账单 | `t_email_bill_config` | IMAP 邮箱配置 | +| 邮箱账单 | `t_email_bill_sender_rule` | 发件人规则 | +| 邮箱账单 | `t_email_bill_message` | 拉取到的账单消息 | +| 日志 | `t_http_log` | 接口访问日志 | +| 日志 | `t_login_log` | 登录日志 | + +## 关系概览 + +### 以用户为中心(一对多) + +| 主体 | 关系 | 对象 | 关联字段 | +|---|---|---|---| +| t_user | 1 : N | t_category | user_id | +| t_user | 1 : N | t_transaction | user_id | +| t_user | 1 : N | t_tag | user_id | +| t_user | 1 : N | t_rule | user_id | +| t_user | 1 : N | t_notification_channel | user_id | +| t_user | 1 : N | t_email_bill_config | user_id | +| t_user | 1 : N | t_email_bill_sender_rule | user_id | +| t_user | 1 : N | t_email_bill_message | user_id | +| t_user | 1 : N | t_user_preference | user_id | +| t_user | 1 : N | t_ai_usage / t_http_log / t_login_log / t_notification_log | user_id | + +### 一对一(每用户一条) + +| 主体 | 关系 | 对象 | 关联字段 | +|---|---|---|---| +| t_user | 1 : 1 | t_ai_config | user_id(唯一) | +| t_user | 1 : 1 | t_notification_config | user_id(唯一) | + +### 记账与标签 + +| 主体 | 关系 | 对象 | 关联字段 / 中间表 | +|---|---|---|---| +| t_category | 1 : N | t_transaction | transaction.category_id(可空=未分类) | +| t_transaction | N : M | t_tag | 中间表 t_transaction_tag(联合唯一) | +| t_category | 1 : N | t_rule | rule.category_id(命中目标分类) | +| t_rule | N : M | t_tag | 中间表 t_rule_tag(联合唯一) | + +### 通知与邮箱账单 + +| 主体 | 关系 | 对象 | 关联字段 | +|---|---|---|---| +| t_notification_channel | 1 : N | t_notification_log | channel_id(可空) | +| t_email_bill_config | 1 : N | t_email_bill_message | config_id | +| t_email_bill_sender_rule | 1 : N | t_email_bill_message | sender_rule_id(可空) | + +> 邮箱配置与发件人规则的默认分类通过 `expense_category_id` / `income_category_id` 逻辑引用 `t_category`;默认标签以 JSON 数组字符串保存(未用关联表)。 + +## 核心表字段 + +### t_transaction(收支记录) + +| 字段 | 说明 | +|---|---| +| user_id | 所属用户 | +| category_id | 分类(可空,未分类) | +| type | `INCOME` / `EXPENSE` | +| amount | 金额(DECIMAL(12,2)) | +| channel | 交易渠道(如 `ALIPAY` / `WECHAT` / `CASH` / `BANK`) | +| remark | 备注 | +| remark_hidden | 备注是否隐藏 | +| external_no | 外部账单交易号,导入去重 | +| transaction_time | 交易发生时间 | +| include_in_stats | 是否计入统计(默认 1) | +| raw_data | 账单导入原始 JSON(手动记录为空) | + +关键索引:唯一索引 `uk_user_external (user_id, external_no)` 实现导入去重;`idx_user_time (user_id, transaction_time)` 支撑按时间查询与统计。 + +### t_category / t_tag + +- 分类:`name`、`type`、`icon`(Emoji)、`sort_order`。 +- 标签:`name`、`color`、`sort_order`,唯一索引 `uk_user_name (user_id, name)` 保证用户内标签名唯一。 + +### t_rule + +`name`、`enabled`、`sort_order`(优先级)、`type`、`keyword`、`min_amount`、`max_amount`、`category_id`。 + +### t_user_preference + +按用户存储的键值偏好:`pref_key` 为偏好键(如 `billSourceIncludeInStats`、`excludedDisplayStyle`),`pref_value` 为标量字符串或 JSON。唯一索引 `uk_user_pref (user_id, pref_key)` 保证每个用户每个偏好仅一条。未配置时由应用回退系统默认值。 + +### t_email_bill_config + +IMAP 连接信息(`host`/`port`/`use_ssl`/`username`/`password`/`folder`)、状态(`status`/`last_error`/`last_connected_at`/`last_uid`)、默认归类(`expense_category_id`/`income_category_id`/`tag_ids`)与拉取范围(`fetch_scope`/`fetch_limit`)。 + +### t_email_bill_message + +`config_id`、`uid`、`message_id`、`subject`、`from_address`、`received_at`、附件信息、命中的 `sender_rule_id` 与 `bill_type`、状态(`PULLED`/`CONFIRMED`/`IGNORED`)、`error`。唯一索引 `uk_email_bill_message (config_id, uid, attachment_index)` 防止重复拉取。 + +## MySQL 与 SQLite 双脚本 + +系统同时维护 `sql/init.sql`(MySQL)与 `sql/schema-sqlite.sql`(SQLite),表结构与语义保持一致,主要差异: + +| 维度 | MySQL | SQLite | +|---|---|---| +| 主键自增 | `BIGINT AUTO_INCREMENT` | `INTEGER PRIMARY KEY AUTOINCREMENT` | +| 布尔 | `TINYINT(1)` | `INTEGER` | +| 金额 | `DECIMAL(12,2)` | `NUMERIC(12,2)` | +| JSON | `JSON` | `TEXT` | +| 时间默认值 | `DEFAULT CURRENT_TIMESTAMP` | `strftime(...,'localtime')` | +| `updated_at` 自动更新 | `ON UPDATE CURRENT_TIMESTAMP` | `AFTER UPDATE` 触发器 | +| 语句分隔符 | `;` | `;;`(兼容触发器内部的分号) | +| 列注释 | 支持 | 不支持 | + +统计模块会依据当前数据源类型,切换 MySQL 与 SQLite 的时间分组函数(`DATE_FORMAT/QUARTER` 与 `strftime`)。 + +## 初始化与迁移 + +- 应用启动时由 `SchemaInitializer` 执行对应建表脚本(幂等)。 +- 建表脚本只做 `CREATE TABLE IF NOT EXISTS`,无法修改已有库的列;需要就地变更列名时由 `SchemaMigrator` 在启动/切换数据源时**幂等迁移**(如 `t_transaction.payment_method` → `channel`,MySQL 用 `information_schema`、SQLite 用 `PRAGMA table_info` 检测)。 +- 新用户注册时由 `DefaultDataInitializer` 初始化默认分类、标签与邮箱发件人规则。 diff --git a/dev/database.md b/dev/database.md new file mode 100644 index 0000000..909e939 --- /dev/null +++ b/dev/database.md @@ -0,0 +1,55 @@ +# 动态数据源 + +系统支持在服务启动**之后**再配置数据库,并可在 SQLite 与 MySQL 之间运行时切换,无需启动前准备。 + +## 设计目标 + +- 未配置数据库时应用仍能正常启动,前端引导进入初始化页面。 +- 支持 SQLite(本地单文件)与 MySQL 两种数据库。 +- 保存配置后立即生效并自动建表,无需手动执行 SQL。 + +## 核心组件 + +| 组件 | 职责 | +|---|---| +| `DynamicDataSource` | 继承 `AbstractRoutingDataSource`,维护目标数据源映射,切换时重建解析缓存并关闭旧连接池 | +| `DataSourceContextHolder` | 保存当前数据源 key 与类型;未配置时使用 `UNCONFIGURED` 占位 | +| `UnconfiguredDataSource` | 占位数据源,任何取连接操作抛「数据库未配置」(业务码 5031) | +| `DatabaseConfig` / `DatabaseConfigStore` | 配置模型与持久化(`config/database.json`) | +| `SchemaInitializer` | 按类型执行 `init.sql`(MySQL)或 `schema-sqlite.sql`(SQLite),幂等建表 | +| `SchemaMigrator` | 建表后的幂等结构迁移(如列重命名),启动与切换数据源时执行 | +| `DatabaseBootstrap` | 应用启动后加载配置并初始化 | + +`FinanceApplication` 刻意排除了 `DataSourceAutoConfiguration`,以便「零配置也能启动」。 + +## 配置来源与顺序 + +1. 优先读取本地 `config/database.json`。 +2. 若无本地配置但存在环境变量 `DB_URL` / `DB_USERNAME` / `DB_PASSWORD`,则作为首次配置并保存。 + +保存流程为:**创建并测试新数据源 → 建表 → 结构迁移 → 先落盘再切换**,确保切换失败时可回退。 + +## 连接池策略 + +- SQLite:连接池固定为 1,避免文件锁冲突。 +- MySQL:最大 10、最小 2。 + +MySQL 的 JDBC URL 会自动附带 UTF-8、关闭 SSL、`Asia/Shanghai` 时区与 `createDatabaseIfNotExist=true`。 + +## 跨库方言适配 + +统计模块依据 `DataSourceContextHolder.getType()` 选择时间分组函数: + +- MySQL:`DATE_FORMAT` / `QUARTER` +- SQLite:`strftime` + +## 切换行为 + +登录后可在「系统设置 → 数据库配置」随时切换数据库。切换后原数据库的数据保留,**不做自动迁移**。 + +切换成功后会清空进程内的用户偏好缓存(见[整体架构](/dev/architecture)),避免读到旧库的数据。 + +## 相关文档 + +- [快速开始](/guide/getting-started) +- [数据模型](/dev/data-model) diff --git a/dev/development.md b/dev/development.md new file mode 100644 index 0000000..22a9707 --- /dev/null +++ b/dev/development.md @@ -0,0 +1,58 @@ +# 开发环境与规范 + +本章记录本项目的开发约定,供开发者与 AI 助手参考。 + +## AI 测试环境 + +为避免污染真实数据,AI 访问网页、截图、操作界面或验证前端功能时,必须使用**测试环境入口**: + +| 环境 | 前端 | 后端 | 数据库 | +|---|---|---|---| +| 测试环境 | 5174 | 8081 | 独立 SQLite 空库(`ai-home/data/ai-finance.db`) | +| 真实环境 | 5173 | 8080 | MySQL(真实数据) | + +- 两个环境共用同一份源码:前端改动经 Vite HMR 两边同步;后端改动执行 `./reload.sh` 后两边 devtools 同时热重启。 +- 启动脚本:`run/ai-dev/start-ai-backend.sh`(8081)、`run/ai-dev/start-ai-frontend.sh`(5174)。 +- `ai-home/`、`run/ai-dev/` 为本地专用,不提交仓库。 + +## 前端开发 + +- 前端改动**不使用 `npm run build` 验证**,直接依赖 Vite 开发服务器的 HMR。 +- `npm run build`(`vue-tsc -b && vite build`)的产物仅用于生产部署,与开发模式无关。 +- 界面文案统一在 `frontend/src/locales/messages.ts` 维护,新增 key 需同时提供中文(zh)、英文(en)、日文(ja)三语。 + +## 后端开发 + +- 后端以开发模式运行在容器中(Gradle `bootRun`,JDK 21,`--network host`)。 +- 修改 Java 代码后执行 `./reload.sh`(容器内增量编译),`spring-boot-devtools` 会在 1–3 秒内热重启。 +- 修改 `src/main/resources` 下的资源文件后,需额外同步资源: + + ```bash + docker exec finance-backend-dev gradle -I /project/nexus-init.gradle processResources + ``` + +- 仅在新增依赖、修改配置等需要彻底重启时才重启容器。 +- 数据库支持运行时配置与切换(SQLite / MySQL),切换数据库或新增 JDBC 驱动后需重启容器。 + +## 代码规范 + +- **Javadoc**:新增 Java 文件的类级 javadoc 末尾必须加一行 `@date`,格式 `yyyy-MM-dd HH:mm:ss`,时间取文件创建时间。 +- **导入顺序**:由 Spotless 管理,三组、组间空行分隔:`java`/`javax` → 第三方 → `cn.rosercode`。 + - 格式化:`docker exec finance-backend-dev gradle -I /project/nexus-init.gradle spotlessApply` + - `spotlessCheck` 已挂到 `check`,`build` 时会校验。 +- **日志**:统一使用 Lombok `@Slf4j`,按级别使用(`error` 未预期异常、`warn` 可预期业务失败、`info` 关键写操作与外部调用、`debug` 流程细节)。 +- **控制器**:方法签名禁止声明 `HttpServletRequest` / `HttpServletResponse`,统一使用 `cn.rosercode.finance.util.UserContext`。 +- **数据库表结构变更**:需同时维护 `sql/init.sql`(MySQL)与 `sql/schema-sqlite.sql`(SQLite),SQLite 以 `;;` 分隔语句。 +- **对象转换**:实体与 VO 之间统一使用 MapStruct(`converter.VoConverter`),不在 Service 中手写 setter。 +- **定时任务**:基于 Spring `@Scheduled`,新增调度器直接标注即可。 + +## Git 提交规范 + +- 提交范围仅包含源码与配置;个人账单原始数据、`docs/`、`reload.sh`、`.gitignore`、`nexus-init.gradle` 及 AI 测试环境基础设施不纳入提交。 +- commit message 使用 `feat: 中文描述` 风格;纯重构/规范调整用 `refactor:`,构建与依赖配置用 `chore:` 或 `build:`。 +- 远端为本地 Gitea,主分支 `master`;推送使用一次性内嵌令牌的 URL,避免令牌写入 `origin` 配置。 + +## 相关文档 + +- [项目结构](/dev/project-structure) +- [构建与部署](/dev/build-deploy) diff --git a/dev/index.md b/dev/index.md new file mode 100644 index 0000000..7bef43a --- /dev/null +++ b/dev/index.md @@ -0,0 +1,23 @@ +# 开发文档 + +本章面向**开发者**,介绍系统的分层架构、关键设计、工程结构与开发规范。 + +## 设计与实现 + +- [整体架构](/dev/architecture):技术栈、后端分层、请求流程、认证与前端结构 +- [数据模型](/dev/data-model):17 张表的字段与关系、双数据库脚本差异 +- [账单解析](/dev/bill-parsing):解析器策略模式、两阶段导入、去重与 zip 解密 +- [规则引擎](/dev/rule-engine):规则模型与匹配逻辑 +- [AI 集成](/dev/ai):OpenAI SDK 客户端、9 类调用类型与用量记录 +- [定时通知](/dev/notifications):调度、渠道与发送日志 +- [动态数据源](/dev/database):SQLite / MySQL 运行时配置与切换 + +## 工程与规范 + +- [项目结构](/dev/project-structure):目录组织与各包职责 +- [构建与部署](/dev/build-deploy):`bootJar`、Docker 编排、桌面 GUI 启动器与 jpackage +- [开发环境与规范](/dev/development):AI 测试环境、前后端开发流程、代码规范、Git 提交规范 +- [账单字段说明](/dev/bill-fields):支付宝账单导入字段的读取与丢弃情况 +- [API 文档](/dev/api):Knife4j(OpenAPI 3)接口文档 + +> 使用说明请查看[使用文档](/guide/)。 diff --git a/dev/notifications.md b/dev/notifications.md new file mode 100644 index 0000000..0cc0cca --- /dev/null +++ b/dev/notifications.md @@ -0,0 +1,40 @@ +# 定时通知 + +系统提供账单导入定时提醒,支持多渠道推送,并记录发送日志。 + +## 调度 + +- `NotificationSchedulingConfig` 开启 `@EnableScheduling`。 +- `NotificationScheduler` 每分钟检查一次(初始延迟 30 秒):对每个启用的通知配置判断「当前时间是否已到达触发点」(依据周期、提醒时间与星期/日期),到达且存在启用渠道时向所有渠道发送提醒,并更新 `last_sent_at`。 +- 默认提醒周期为每月 1 日 09:00。 +- 首次启用时会将 `last_sent_at` 置为当前,避免启用后立即补发历史提醒。 + +## 渠道 + +`NotificationChannelType` 定义支持的渠道: + +| 渠道 | 说明 | +|---|---| +| `EMAIL` | 邮箱 SMTP(如 QQ 邮箱),动态构建 `JavaMailSenderImpl`,默认 `smtp.qq.com:465` + SSL | +| `SERVERCHAN` | Server 酱 Turbo,推送到个人微信,校验返回 `code == 0` | + +发送器采用工厂模式:`NotificationSender` 接口 + `NotificationSenderFactory` 按类型分发,`AbstractHttpNotificationSender` 封装 JSON/表单 POST。 + +## 配置与日志 + +- `t_notification_config`:启用开关、周期、每月日期、每周星期、提醒时间、最近发送时间。 +- `t_notification_channel`:类型、名称、启用、配置 JSON(SMTP 账号或 SendKey)。邮箱密码留空表示不修改,返回时脱敏。 +- `t_notification_log`:每次发送的结果(成功/失败、渠道类型、说明),面板展示最近 20 条。 + +## 接口 + +`NotificationController` 提供:配置读写、渠道增删改查与测试、立即发送、发送日志查询。 + +## 通知内容 + +提醒正文会提示导入新账单,并附带 `AppConfig.notificationUrl` 配置的链接地址(通常指向账单导入页)。 + +## 相关文档 + +- [定时提醒与通知(使用说明)](/guide/notifications) +- [邮箱账单](/guide/email-bills) diff --git a/dev/project-structure.md b/dev/project-structure.md new file mode 100644 index 0000000..aa4d424 --- /dev/null +++ b/dev/project-structure.md @@ -0,0 +1,93 @@ +# 项目结构 + +## 仓库根目录 + +```text +finance-app/ +├── build.gradle # Gradle 构建脚本(依赖、打包、前端集成、Spotless) +├── settings.gradle # Gradle 项目设置 +├── gradlew / gradlew.bat # Gradle Wrapper +├── gradle/wrapper/ # Wrapper 配置与 jar +├── .gitignore +├── README.md # 功能特性与技术栈概览 +├── src/main/java/ # 后端源码 +├── src/main/resources/ # 配置、MyBatis XML、建表脚本 +└── frontend/ # Vue 3 前端 +``` + +> 以上为纳入版本管理的目录与文件;本地开发用的运行编排、脚本与文档(如 `run/`、`reload.sh`、`docs/` 等)不在版本管理中,故不在此列出。 + +## 后端包结构 + +`src/main/java/cn/rosercode/finance/` + +| 包 | 职责 | +|---|---| +| `client` | 外部服务客户端(DeepSeek) | +| `common` | 通用响应(`Result`、`PageResult`)、业务异常与全局异常处理 | +| `config` | 应用配置、动态数据源、Knife4j、MyBatis-Plus、SPA 路由回退 | +| `controller` | REST 接口层 | +| `converter` | MapStruct 对象转换器(`VoConverter`) | +| `dto` | 请求对象 | +| `email` | IMAP 邮箱账单抓取(监听、拉取、解析、存储) | +| `entity` | 数据库表映射实体 | +| `enums` | 枚举(`BillType`、`AiCallType`、`StatisticsDimension` 等) | +| `exporter` | Excel 导出处理 | +| `importer` | 账单解析器(支付宝/微信/美团/淘宝/京东/银行)与压缩包解密 | +| `interceptor` | JWT 鉴权、访问日志 | +| `mapper` | 数据访问层(MyBatis-Plus) | +| `notification` | 通知渠道发送器 | +| `scheduler` | 定时任务(通知、日志清理、邮箱账单) | +| `service` / `service.impl` | 业务层 | +| `util` | 工具类(`JwtUtil`、`UserContext`、`AppLogUtils` 等) | +| `vo` | 响应对象 | +| `launcher` | 桌面 GUI 启动器(仅打入带 GUI 的 jar) | + +## 资源目录 + +`src/main/resources/` + +```text +application.yml # 应用配置(JWT、MyBatis-Plus、springdoc、端口等) +logback-spring.xml # 日志配置(控制台 + 按天滚动文件) +mapper/ # MyBatis XML +sql/ +├── init.sql # MySQL 建表脚本 +├── schema-sqlite.sql # SQLite 建表脚本(以 ;; 分隔语句) +├── init_categories.sql # 默认分类种子(MySQL) +└── init_tags.sql # 默认标签种子(MySQL) +``` + +## 前端结构 + +`frontend/src/` + +```text +App.vue # 应用根组件 +main.ts # 入口(挂载应用与插件) +style.css # 全局样式 +api/ # 各模块 REST 接口封装(axios) +components/ # 复用组件(账单预览、导入结果、AI/通知/数据库配置面板等) +locales/ # 中/英/日三语文案(messages.ts) +router/ # 路由与导航守卫 +store/ # Pinia 状态(user / database / registration) +utils/ # 工具(交易渠道标签、SSE 封装、渲染辅助) +views/ # 页面组件 +``` + +## 运行时目录 + +应用运行目录下会自动创建: + +| 目录/文件 | 说明 | +|---|---| +| `config/database.json` | 数据库配置(SQLite / MySQL) | +| `config/app.json` | 应用配置(注册开关、通知链接、日志保留天数、按账单来源的计入统计默认值) | +| `data/` | SQLite 数据文件与邮箱账单附件 | +| `logs/` | 运行日志文件 | + +## 相关文档 + +- [整体架构](/dev/architecture) +- [构建与部署](/dev/build-deploy) +- [开发环境与规范](/dev/development) diff --git a/dev/rule-engine.md b/dev/rule-engine.md new file mode 100644 index 0000000..e438c77 --- /dev/null +++ b/dev/rule-engine.md @@ -0,0 +1,53 @@ +# 规则引擎 + +规则引擎用于在账单导入与新建记录时,按条件自动应用分类与标签。 + +## 数据模型 + +- `t_rule`:`name`、`enabled`、`sort_order`(优先级,越小越优先)、`type`(限定收支类型,可空)、`keyword`(关键词)、`min_amount` / `max_amount`(金额区间)、`category_id`(目标分类)。 +- `t_rule_tag`:规则与标签的多对多关联。 + +## 匹配逻辑 + +`RuleServiceImpl.matchBatch` 的核心流程: + +1. 一次性加载当前用户**已启用**的规则,按 `sort_order`、`id` 升序排序;预加载规则标签与引用分类。 +2. 拼接匹配文本 = `交易对方 + 商品名称 + 备注`,统一转小写。 +3. 逐条判断是否命中: + - **类型**:规则指定了 type 且与记录类型不一致则跳过。 + - **关键词**:包含匹配(忽略大小写)。 + - **金额区间**:`min_amount ≤ amount ≤ max_amount`(含边界,任一为空表示不限制)。 +4. 命中后: + - **分类**:取首个命中且与记录类型一致的规则分类。 + - **标签**:取所有命中规则标签的并集(去重)。 +5. 返回结果与入参一一对应。 + +## 应用位置 + +| 场景 | 行为 | +|---|---| +| 账单解析预览 | 展示命中的规则名与建议分类/标签 | +| 账单确认导入 | 规则建议分类优先于面板默认分类;标签与默认标签合并去重 | +| 新建/编辑记录 | 不自动应用,但可借助 AI 推荐 | + +## 列表查询与分页 + +- `GET /api/rule/list`:返回当前用户全部规则(按优先级排序),供内部复用。 +- `GET /api/rule/page`:分页查询,支持 `keyword`(匹配规则名称或关键词)、`enabled`、`type` 筛选,返回 `PageResult`。 + +## AI 生成规则 + +`AiRuleServiceImpl` 依据用户勾选的**已打标签记录**生成规则建议: + +- 输入字段为记录的类型、备注、交易渠道、已有分类与标签(不含金额与原始账单数据)。 +- 输出规则经 `RuleService.create` 落库,必要时新建分类/标签,规则默认启用。 +- **去重**:以「类型 + 归一化关键词 + 分类 + 排序标签集合」为签名;与已有规则或同批已创建规则重复时跳过(`apply` 返回实际创建数),建议阶段则标记 `duplicate` 供前端禁止勾选。 + +## 校验 + +`RuleServiceImpl.validate` 会校验:`type` 白名单、金额下限不大于上限、目标分类归属当前用户且类型一致。保存规则标签时采用全量替换。 + +## 相关文档 + +- [规则管理(使用说明)](/guide/rules) +- [账单解析](/dev/bill-parsing) diff --git a/guide/ai.md b/guide/ai.md new file mode 100644 index 0000000..a9b72ae --- /dev/null +++ b/guide/ai.md @@ -0,0 +1,95 @@ +# AI 能力 + +系统集成 **DeepSeek** 大模型,在分类打标、查询、记账等环节提供智能辅助。所有 AI 功能需先在「系统设置 → AI 模型配置」中启用并配置 API Key。 + +## 数据与隐私 + +系统仅向大模型发送完成功能所需的**最小字段**,**不会上传任何敏感数据**。所有 AI 请求均由你自行配置的接口地址发起。 + +**不会发送**: + +- 账单导入的原始数据(`rawData`,可能含交易对方、商品名、订单号、对方账户/卡号等); +- 金额; +- 交易号、账户/卡号、姓名、地址、电话等可识别个人或账户的信息。 + +**实际发送**(按功能所需最小化): + +- 分类/标签建议:记录的收支类型、备注、交易渠道; +- 智能查询 / 统计:你输入的自然语言,以及分类与标签名称; +- 智能记账:你输入的一句话; +- 生成分类/标签、推荐 Emoji/颜色:你输入的名称或描述; +- 生成规则:所选记录的类型、备注、交易渠道、已有分类与标签(不含金额与原始账单数据)。 + +API Key 仅保存在本地数据库中,仅用于向你配置的接口地址发起请求,不会随上述数据一并外发。 + +## 配置 + +在「系统设置 → AI 模型配置」中: + +- 开启「启用 AI 建议」。 +- 填写 **API Key**。 +- 可选修改 **接口地址**(默认 `https://api.deepseek.com`)与**模型**(默认 `deepseek-chat`)。 +- 点击「测试连接」验证配置。 + +![系统设置](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/settings.png) + +## 功能一览 + +| 功能 | 入口 | 说明 | +|---|---|---| +| 分类/标签建议 | 收支记录 → 一键设置 → AI 智能建议 | 对当前页记录批量建议分类与标签,可一键采纳 | +| 单条建议 | 新增/编辑记录 → AI 推荐 | 推荐已有分类或标签 | +| 智能查询 | 收支记录 → AI 智能查询 | 自然语言解析为筛选条件 | +| 智能记账 | 收支记录 → 智能记账 | 一句话解析为一笔记录 | +| 生成分类 | 分类管理 → AI 生成 | 由用途描述生成分类 | +| 推荐 Emoji | 分类管理 → AI 推荐 | 为分类推荐 Emoji 图标 | +| 推荐颜色 | 标签管理 → AI 推荐 | 为标签推荐颜色 | +| 生成规则 | 规则管理 → AI 生成规则 | 由已打标签的记录归纳分类与标签规则 | +| 统计搜索 | 统计面板 → AI 搜索 | 自然语言解析为统计筛选条件与维度 | + +## 分类与标签建议 + +在收支记录页点「一键设置」,选择建议范围(分类与标签 / 仅分类 / 仅标签)后点「AI 智能建议」。系统会按记录的类型、备注、交易渠道等聚合后请求模型,返回建议并进入 AI 建议弹窗。 + +弹窗中每条记录展示建议分类、建议标签与理由,默认全选。若建议了系统中不存在的分类或标签,会以「新建」标记,确认后自动创建。应用时分类会被覆盖、标签会被追加。 + +## 智能查询 + +在收支记录页的 AI 查询框中输入自然语言,例如: + +- 「上个月餐饮」→ 时间范围 + 分类 +- 「超过500的支出」→ 类型 + 金额区间 +- 「待报销的支出」→ 标签 + 类型 +- 「微信支付」→ 交易渠道 + +解析结果会直接应用为筛选条件并立即查询。 + +## 智能记账 + +在「智能记账」弹窗中描述一笔收支,AI 解析出类型、金额、分类、交易渠道、时间、备注、标签等字段,并预填到新增记录表单,确认后保存。 + +![AI 智能记账弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/ai-record.png) + +## AI 统计搜索 + +在统计面板顶部的 AI 搜索框中用一句自然语言描述统计意图,例如「上个月餐饮花了多少」「近半年交通支出趋势,按周看」。系统会解析为**时间范围、收支类型、分类、标签、交易渠道、金额区间、备注、统计口径与趋势粒度**并自动回填、刷新图表。详见[统计面板](/guide/dashboard)。 + +## AI 生成规则 + +在规则管理页点「AI 生成规则」,选择用于归纳的**已打标签记录**(默认全选当前页,支持搜索、筛选与分页),AI 会依据这些记录的**类型、备注、交易渠道、已有分类与标签**生成规则建议,预览编辑后批量创建。与已有规则重复的建议会标记「已存在」并禁止导入。详见[规则管理](/guide/rules)。 + +## 用量统计 + +「模型使用情况」页面(系统设置 → AI 模型配置 →「使用情况」)展示 AI 调用汇总与明细。 + +![模型使用情况](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/ai-usage.png) + +- **调用概览**:调用次数、成功、失败、总 Token。 +- **Token 明细**:输入 Token、输出 Token、缓存命中。 +- **最近调用记录**:时间、类型、分析条数、Token、耗时、状态。 + +## 说明 + +- AI 建议结果仅供参考,应用前可逐条取消勾选。 +- 关闭「启用 AI 建议」后,系统不会发起任何 AI 请求,相关入口也会隐藏。 +- 账单类型识别(邮箱账单)使用的是**发件人规则**而非 AI,结果更确定、可预期。 diff --git a/guide/bills/alipay.md b/guide/bills/alipay.md new file mode 100644 index 0000000..0aae119 --- /dev/null +++ b/guide/bills/alipay.md @@ -0,0 +1,63 @@ +# 支付宝账单 + +支付宝支持导出「交易记录明细」,系统以 **CSV** 或 **zip** 形式导入。 + +## 如何获取 + +### 手机 App(推荐) + +1. 打开支付宝 App,进入 **我的 → 账单**。 +2. 点击账单页面右上角的「**…**」,选择「**开具交易流水证明**」(部分版本显示为「开具银行流水证明」)。 +3. 选择证明用途(如「用于材料证明」),点击「申请」,输入支付密码完成验证。 +4. 设置交易流水范围(全部 / 收入 / 支出)与时间范围:可选 1 个月、3 个月或自定义,**最长支持 1 年**,跨年度需分多次申请。 +5. 填写接收账单的**电子邮箱**并确认发送,账单会以压缩包形式发送到邮箱。 + +### 电脑网页版 + +访问支付宝官网(www.alipay.com)→ 服务大厅 → 自助服务 → 应用服务 → 收支明细证明,按提示申请。 + +> 支付宝导出的压缩包通常未加密,可直接交给系统解析;若设置了密码,在导入时输入。 + +## 导入 + +在[账单导入](/guide/transactions)页选择账单类型「支付宝」,上传 CSV 或 zip 文件。 + +- 支持格式:`.csv`、`.zip` +- 编码:自动识别 UTF-8 / UTF-8 BOM / GBK + +## 字段映射 + +解析器以「交易号」开头的表头行定位数据区,读取以下关键字段: + +| 源字段(CSV 列) | 落库字段 | 处理说明 | +|---|---|---| +| 交易号 | `external_no` | 导入去重键(`uk_user_external`) | +| 付款时间 | `transaction_time` | 交易时间;为空时回退「交易创建时间」 | +| 交易创建时间 | `transaction_time` | 仅作付款时间缺失时的兜底 | +| 金额(元) | `amount` | 去除千分位逗号后解析 | +| 收/支 | `type` | 支出→EXPENSE,收入→INCOME | +| 交易对方 | `remark` | 与商品名称拼接为「交易对方 · 商品名称」,截断至 255 字符 | +| 商品名称 | `remark` | 同上 | +| 交易状态 | 仅推断/预览 | 参与类型推断与预览展示,不落库 | +| 资金状态 | 仅推断 | 用于「不计收支」行的类型推断 | +| 收/支=不计收支 | `include_in_stats` | 默认不计入统计 | +| — | `channel` | 固定为 `ALIPAY` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +以下列**完全丢弃**:商家订单号、最近修改时间、交易来源地、类型、服务费(元)、成功退款(元)、备注(支付宝自带备注)。 + +### 类型推断 + +| 收/支 | 交易状态 / 资金状态 | 推断类型 | +|---|---|---| +| 支出 | — | EXPENSE | +| 收入 | — | INCOME | +| 不计收支 | 含「退款」 | INCOME | +| 不计收支 | 资金状态为「已收入」 | INCOME | +| 不计收支 | 其他(如交易关闭) | EXPENSE | + +## 注意事项 + +- 支付宝 CSV 中自带的「备注」列不会被导入,系统备注由「交易对方 · 商品名称」拼接生成。 +- 建议按年或按季度导出,避免单文件过大。 +- 重复导入同一文件时,相同交易号的记录会被跳过。 diff --git a/guide/bills/bocom.md b/guide/bills/bocom.md new file mode 100644 index 0000000..45194a5 --- /dev/null +++ b/guide/bills/bocom.md @@ -0,0 +1,51 @@ +# 交通银行账单 + +交通银行支持导出「交易明细列表」,系统以 **xls** 或 **zip** 形式导入。 + +## 如何获取 + +### 手机 App(个人) + +1. 打开交通银行 App,在首页点击「**我的账户**」。 +2. 选择需要导出的银行卡,点击「**明细清单**」。 +3. 点击「**发送至邮箱**」,选择时间范围后,交易明细会以电子版发送到指定邮箱。 +4. 导出文件通常为 **Excel(xls)**,并可能以加密压缩包形式发送,解压密码以银行提示为准。 + +时间范围最长可选 1 年,数据量较大时建议分批次导出。 + +### 电脑端 + +登录交通银行个人网银,在账户明细查询中导出交易明细;企业网银可通过「对账单管理」下载。 + +## 导入 + +在[账单导入](/guide/transactions)页选择账单类型「交通银行」,上传 xls 或 zip 文件。 + +- 支持格式:`.xls`、`.zip` + +## 字段映射 + +系统以「记账日期」定位表头,读取以下列: + +| 源字段(xls 列) | 落库字段 | 处理说明 | +|---|---|---| +| 交易时间 | `transaction_time` | 缺失时回退记账日期 | +| 记账日期 | `transaction_time` | 仅作兜底 | +| 对方户名 | `remark` | 作为备注 | +| 摘要 | `remark` | 与对方户名拼接/作为商品名称 | +| 支出金额 | `amount` + `type` | 支出列有值时 type=EXPENSE | +| 收入金额 | `amount` + `type` | 收入列有值时 type=INCOME | +| 余额 | 仅生成去重键 | 参与 `BOCOM_日期_时分秒_类型_金额` 去重键 | +| — | `channel` | 固定为 `BOCOM` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +「--」等占位符会被视为空值。 + +## 去重 + +交通银行流水没有交易号,系统会以「日期 + 时分秒 + 类型 + 金额」生成稳定的去重键(如 `BOCOM_20260916_082932_收入_3406.50`)。 + +## 注意事项 + +- 导出时请选择系统支持的日期范围,单次导出过大时可分多次。 +- 若导出为加密压缩包,导入时需输入密码。 diff --git a/guide/bills/cmb.md b/guide/bills/cmb.md new file mode 100644 index 0000000..06c834c --- /dev/null +++ b/guide/bills/cmb.md @@ -0,0 +1,81 @@ +# 招商银行账单 + +招商银行支持导出「交易流水」,系统以 **CSV**、**PDF** 或 **zip** 形式导入。 + +## 如何获取 + +招商银行提供多种导出渠道,格式与可查询时间范围各不相同,可按需选择。 + +### 手机 App(个人,常用) + +- 打开招商银行 App,在首页搜索「**流水打印**」或「**打印流水**」,或在 **我的 → 全部** 中找到「流水打印」。 +- 选择账户与时间范围,输入接收邮箱提交。 +- **通过 App 导出的通常是带电子印章的 PDF**,适用于个人对账或证明材料。 +- App 可查询较长时间范围(最长近 5 年),但**单次申请跨度一般不超过 1 年**,更早记录需分次申请。 +- 导出的文件为加密压缩包,解压密码在 App 的「**申请记录**」页查看。 + +### 个人网银专业版 / 个人银行 PC 版(可导出 Excel) + +- 登录个人银行 PC 版(专业版),进入 **账户管理 → 活期交易查询**。 +- 选择时间段查询后点击「保存」,文件类型选择 **Excel(.xls)**。 +- **仅支持查询近 13 个月**的交易记录,且仅支持中文活期交易记录;更早的流水需改用 App 或前往网点。 + +### 营业网点 + +- 携带有效身份证件与一卡通,可打印**开户至今**的流水;13 个月以内通常免费,13 个月以上部分网点可能收费。 +- 如需要更早时间且为 Excel 格式,可向柜员明确提出「定制导出电子表格」的需求。 + +### 企业网银 / 企业手机 App + +- 企业网银:**账务管理 → 账务查询 → 交易查询 → 导出 → Excel 快速导出**(可查近 5 年,单次跨度不超过 366 天)。 +- 企业手机 App:首页 → 收支明细 → 右上角下载 → 选择账号与起止日期 → 输入邮箱接收 Excel。 + +## 导入 + +在[账单导入](/guide/transactions)页选择账单类型「招商银行」,上传 CSV、PDF 或 zip 文件。 + +- 支持格式:`.csv`、`.pdf`、`.zip` +- CSV 与 PDF 使用同一套去重键,两种格式可以混用而不重复导入。 + +## 字段映射 + +### CSV + +| 源字段(CSV 列) | 落库字段 | 处理说明 | +|---|---|---| +| 交易日期 | `transaction_time` | 与交易时间组合为完整时间 | +| 交易时间 | `transaction_time` | 同上 | +| 交易备注 | `remark` | 作为备注 | +| 交易类型 | `remark` / 预览 | 作为商品名称或交易状态 | +| 收入金额 | `amount` + `type` | 有值时 type=INCOME | +| 支出金额 | `amount` + `type` | 有值时 type=EXPENSE | +| 余额 | 生成去重键 | 参与 `CMB_日期_类型_金额_余额` | +| — | `channel` | 固定为 `CMB` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +`#` 开头的行视为元信息/合计并结束解析。 + +### PDF + +PDF 为文本型「交易流水」,系统按字符坐标还原表格,读取列: + +| 源字段 | 落库字段 | 处理说明 | +|---|---|---| +| 记账日期 | `transaction_time` | 取零点 | +| 交易金额 | `amount` + `type` | 带符号单列:负数为支出、正数为收入 | +| 联机余额 | 生成去重键 + 校验 | 参与去重键;按余额连续性校验(误差 0.005),不连续则标记为不可导入 | +| 交易摘要 / 对手信息 | `remark` | 作为备注 | +| — | `channel` | 固定为 `CMB` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +## 去重 + +银行流水通常没有交易号,系统会以「日期 + 类型 + 金额 + 余额」生成稳定的去重键(如 `CMB_20260916_支出_3406.50_43233.20`),因此重复导入同一账单不会产生重复记录。 + +## 注意事项 + +- 系统支持导入招商银行的 **CSV** 与 **PDF**(文本型),优先推荐 CSV。 +- 若导出为加密压缩包,导入时需输入密码(密码见 App「申请记录」页)。 +- PDF 账单请使用招商银行 App 导出的文本型 PDF,扫描件无法解析。 +- **个人网银专业版导出的 `.xls` 暂不支持直接导入**:可在 Excel 中另存为 CSV 后再导入,或改用 App 导出 PDF。 +- 招行 CSV 与 PDF 使用同一套去重键,两种格式可混用而不重复导入。 diff --git a/guide/bills/jd.md b/guide/bills/jd.md new file mode 100644 index 0000000..fb2334b --- /dev/null +++ b/guide/bills/jd.md @@ -0,0 +1,47 @@ +# 京东账单 + +京东支持导出「交易流水」,系统以 **CSV** 或 **zip**(加密压缩包)形式导入。 + +## 如何获取 + +1. 打开**京东 App**,进入 **我的 → 设置 → 账单**。 +2. 点击右上角「**…**」,选择「**账单导出**」。 +3. 设置**接收邮箱**,点击「**确定并提交**」,账单会发送到该邮箱。 +4. 从邮件中下载导出的 **CSV** 文件;通常打包为 **zip** 并加密,**解压密码见邮件说明**。 + +::: tip 以实际界面为准 +京东导出入口可能随 App 版本调整,如界面有差异,可在京东帮助中心搜索「账单导出」。 +::: + +## 导入 + +在[账单导入](/guide/transactions)页选择账单类型「京东」,上传 CSV 或 zip 文件。 + +- 支持格式:`.csv`、`.zip` +- 若为加密压缩包,解析时会提示输入解压密码。 + +## 字段映射 + +系统以「交易时间」定位表头,读取以下列: + +| 源字段(CSV 列) | 落库字段 | 处理说明 | +|---|---|---| +| 交易时间 | `transaction_time` | `yyyy-MM-dd HH:mm:ss` | +| 商户名称 | `remark` | 与交易说明拼接为备注 | +| 交易说明 | `remark` | 商品/交易描述 | +| 金额 | `amount` | 支出金额(部分退款已剔除) | +| 交易状态 | 仅推断/预览 | 如「交易成功」「还款成功」 | +| 收/支 | `type` + `include_in_stats` | 支出→EXPENSE,收入→INCOME,不计收支→不计入统计 | +| 交易订单号 | `external_no` | 导入去重键(去除空白) | +| 收/付款方式 | 仅存原始数据 | 如京东白条、京东小金库、储蓄卡 | +| 交易分类 | 仅存原始数据 | 如其他网购、白条、小金库 | +| 商家订单号、备注 | 仅存原始数据 | 不落库 | +| — | `channel` | 固定为 `JD` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +## 注意事项 + +- **不计收支行**:白条还款、小金库转入、全额退款等记为「不计收支」,导入后**不计入统计**且默认**不勾选**,可按需手动勾选。 +- **白条消费与还款**:用京东白条消费时,购买记录为「支出」,后续「白条还款」为「不计收支」,不会重复计入支出。 +- **避免重复记账**:京东快捷支付可能同时在银行账单产生记录,请按来源设置「计入统计」,避免重复统计。 +- **去重**:系统按「用户 + 交易订单号」去重,重复导入同一账单会被自动跳过。 diff --git a/guide/bills/meituan.md b/guide/bills/meituan.md new file mode 100644 index 0000000..a5f025d --- /dev/null +++ b/guide/bills/meituan.md @@ -0,0 +1,38 @@ +# 美团账单 + +美团支持导出账单证明,系统以 **CSV** 或 **zip** 形式导入。 + +## 如何获取 + +1. 打开美团 App,进入 **我的 → 钱包 → 账单**。 +2. 点击右上角的「**下载账单**」(或「开具账单证明」)。 +3. 选择时间范围,导出为 **CSV** 格式;账单导出周期为 3 个月,更早的记录需分段多次导出。 +4. 导出的文件通常打包为 zip 并设置**解压密码**,密码在「申请记录」页查看。 + +## 导入 + +在[账单导入](/guide/transactions)页选择账单类型「美团」,上传 CSV 或 zip 文件。 + +- 支持格式:`.csv`、`.zip` +- 若为加密压缩包,解析时会提示输入解压密码。 + +## 字段映射 + +系统以「交易创建时间」定位表头,读取以下列: + +| 源字段(CSV 列) | 落库字段 | 处理说明 | +|---|---|---| +| 交易成功时间 | `transaction_time` | 缺失时回退交易创建时间 | +| 交易创建时间 | `transaction_time` | 仅作兜底 | +| 订单标题 | `remark` | 作为备注 | +| 交易单号 | `external_no` | 导入去重键(去除空白) | +| 收/支 | `type` | 支出→EXPENSE,收入→INCOME | +| 实付金额 | `amount` | 缺失时回退订单金额 | +| 交易类型 | 仅推断/预览 | 不落库 | +| — | `channel` | 固定为 `MEITUAN` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +## 注意事项 + +- 美团导出的压缩包一般有密码,请先记录「申请记录」页提供的解压密码。 +- 若提示密码错误,请核对密码后重新输入。 diff --git a/guide/bills/overview.md b/guide/bills/overview.md new file mode 100644 index 0000000..8430149 --- /dev/null +++ b/guide/bills/overview.md @@ -0,0 +1,69 @@ +# 账单类型与获取 + +系统当前支持 **7 类账单**的导入。不同平台导出的文件格式不同,导入时需要选择对应的账单类型。 + +## 支持一览 + +| 账单类型 | 应用 | 支持格式 | 典型文件名 | +|---|---|---|---| +| 支付宝 | 支付宝 App | CSV、zip | `alipay_record_YYYYMMDD_HHMM.zip` | +| 微信 | 微信 App | xlsx、zip | `微信支付账单流水文件(起-止)_*.xlsx` | +| 美团 | 美团 App | CSV、zip | `美团账单(起-止).csv` | +| 淘宝 | 淘宝网页版 | xlsx | `订单数据.xlsx` | +| 京东 | 京东 App | CSV、zip | `京东交易流水(*).csv` | +| 招商银行 | 招商银行 App | CSV、PDF、zip | `CMB_*.csv` / `招商银行交易流水(*).pdf` | +| 交通银行 | 交通银行 App | xls、zip | `交易明细列表.xls` | + +## 导出入口速查 + +| 账单类型 | 导出路径 | +|---|---| +| 支付宝 | App → 我的 → 账单 → 右上角「…」→ 开具交易流水证明(最长 1 年,邮箱接收) | +| 微信 | App → 我 → 服务 → 钱包 → 账单 → 常见问题 → 下载账单 → 用于个人对账(单次最长 3 个月,xlsx) | +| 美团 | App → 我的 → 钱包 → 账单 → 右上角「下载账单」(导出周期 3 个月,CSV,需解压密码) | +| 淘宝 | 网页版 → 我的淘宝 → 我的订单 → 已买到的宝贝 → 导出订单,下载 xlsx | +| 京东 | App → 我的 → 设置 → 账单 → 右上角「…」→ 账单导出(设置接收邮箱),邮件接收 CSV | +| 招商银行 | App 首页搜「流水打印」(PDF);或个人网银专业版导出 Excel(近 13 个月) | +| 交通银行 | App → 我的账户 → 明细清单 → 发送至邮箱(Excel,最长 1 年) | + +### 各渠道时间范围与格式对比 + +| 渠道 | 可导出时间范围 | 常见格式 | 备注 | +|---|---|---|---| +| 支付宝 | 最长 1 年(可自定义,跨年分次) | CSV | 需支付密码验证,邮箱接收 | +| 微信 | 单次最长 3 个月 | xlsx | 解压密码在微信支付公众号 | +| 美团 | 单次 3 个月 | CSV | 解压密码在申请记录页 | +| 淘宝 | 由订单导出页面限定 | xlsx | 订单级导出,多商品订单会合并展示 | +| 京东 | 由账单导出页面限定 | CSV | 邮箱接收,通常打包为加密 zip,解压密码见邮件说明 | +| 招商银行 App | 最长近 5 年(单次≤1 年) | PDF(带电子印章) | 解压密码在申请记录页 | +| 招商银行 PC 专业版 | 近 13 个月 | xls / xlsx | 纯数据表格,无印章;xls 需转 CSV 后导入 | +| 交通银行 App | 最长 1 年 | xls | 邮箱接收电子版 | + +::: tip 以 App 实际界面为准 +各平台导出入口可能随 App 版本调整,上述路径为常见版本;如界面有差异,可在对应 App 的帮助中心搜索「账单导出」。 +::: + +## 导出与导入建议 + +1. **按时间范围分批导出**:多数平台单次导出有跨度或条数限制,可按年或按季度导出多个文件,逐个导入。 +2. **记录解压密码**:部分平台(如美团、部分银行)导出的压缩包设有密码,导入时需要输入。 +3. **优先选择系统直接支持的格式**:如招商银行 CSV 与 PDF 都支持,CSV 更易解析;银行账单如同时提供,优先 CSV。 +4. **注意去重**:系统按「用户 + 交易号」去重。同一账单重复导入时,已存在的记录会被自动跳过;银行账单没有交易号时会以「日期+类型+金额+余额」生成稳定的去重键。 +5. **按来源设置计入统计**:避免同一笔消费被重复统计。 + - 来自**微信、支付宝**的账单:默认**计入统计**(除非有特殊情况)。 + - 来自**其他**(银行、美团、淘宝、京东等)的账单:默认**不计入统计**——这些渠道的扣款往往最终走微信/支付宝支付,若都计入会导致统计翻倍。 + - **例外**:工资、奖金等明确收入应计入统计。 + +## 各账单详解 + +- [支付宝账单](/guide/bills/alipay) +- [微信支付账单](/guide/bills/wechat) +- [美团账单](/guide/bills/meituan) +- [淘宝账单](/guide/bills/taobao) +- [京东账单](/guide/bills/jd) +- [招商银行账单](/guide/bills/cmb) +- [交通银行账单](/guide/bills/bocom) + +## 导入操作 + +导出文件后,在「收支记录 → 导入账单」或通知邮件直达的独立页 `/bill-import` 中导入,流程见[收支记录 · 账单导入](/guide/transactions#账单导入)。 diff --git a/guide/bills/taobao.md b/guide/bills/taobao.md new file mode 100644 index 0000000..8b0721f --- /dev/null +++ b/guide/bills/taobao.md @@ -0,0 +1,43 @@ +# 淘宝账单 + +淘宝支持导出「订单数据」,系统以 **xlsx** 形式导入。 + +## 如何获取 + +1. 打开**淘宝网页版**(电脑浏览器),登录后进入 **我的淘宝 → 我的订单 → 已买到的宝贝**。 +2. 点击「**导出订单**」,选择需要导出的时间范围。 +3. 导出为 **xlsx** 文件(通常命名为 `订单数据.xlsx`),按页面提示下载。 + +::: tip 以实际界面为准 +淘宝导出入口可能随网页版本调整,如界面有差异,可在淘宝帮助中心搜索「导出订单」。 +::: + +## 导入 + +在[账单导入](/guide/transactions)页选择账单类型「淘宝」,上传 xlsx 文件。 + +- 支持格式:`.xlsx` +- 淘宝订单均为购买支出,导入时固定记为支出(`EXPENSE`)。 + +## 字段映射 + +系统以「订单号」定位表头,读取以下列: + +| 源字段(xlsx 列) | 落库字段 | 处理说明 | +|---|---|---| +| 订单号 | `external_no` | 导入去重键;多商品订单共用同一订单号 | +| 订单提交时间 | `transaction_time` | 兼容文本时间与 Excel 日期序列号 | +| 店铺名称 | `remark` | 与商品名称拼接为备注 | +| 商品名称 | `remark` | 多商品订单以「、」拼接 | +| 实付金额 | `amount` | 订单级合计;为空时回退「Σ(商品金额×数量)+运费」 | +| 订单状态 | 仅推断/预览 | 「交易关闭」标记为不可导入 | +| — | `type` | 固定为 `EXPENSE`(支出) | +| — | `channel` | 固定为 `TAOBAO` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +## 注意事项 + +- **多商品订单会合并为一条**:淘宝订单数据中,一个订单包含多个商品时会占用多行(订单级列以合并单元格展示),系统按订单号聚合,每个订单只生成一条记录。 +- **交易关闭不导入**:订单状态为「交易关闭」的记录标记为不可导入,默认不勾选。 +- **未完成订单**:如「卖家已发货」且实付金额为 0 的订单,因金额无效标记为不可导入,待订单完成后重新导出即可。 +- **去重**:系统按「用户 + 订单号」去重,重复导入同一订单会被自动跳过。 diff --git a/guide/bills/wechat.md b/guide/bills/wechat.md new file mode 100644 index 0000000..66c3dd2 --- /dev/null +++ b/guide/bills/wechat.md @@ -0,0 +1,49 @@ +# 微信支付账单 + +微信支持导出「微信支付账单流水文件」,系统以 **xlsx** 或 **zip** 形式导入。 + +## 如何获取 + +1. 打开微信 App,进入 **我 → 服务 → 钱包 → 账单**。 +2. 点击右上角「常见问题」。 +3. 选择「下载账单」→ 用途选择「**用于个人对账**」。 +4. 选择交易类型(建议「全部交易」)与时间范围,输入接收邮箱,按提示输入支付密码完成验证。 +5. 账单会以**加密压缩包**形式发送到邮箱,解压密码在**微信支付公众号(服务通知)**中查看。 + +要点: + +- 个人对账账单**单次最长可导出 3 个月**,更早的记录需分段多次导出。 +- 导出的账单**包含已删除的交易记录**。 +- 账单一般在几分钟内到达邮箱,最慢 24 小时内;未收到时请检查垃圾邮件。 +- 解压密码每次导出单独生成,微信登录密码、支付密码均不能用于解压。 + +## 导入 + +在[账单导入](/guide/transactions)页选择账单类型「微信」,上传 xlsx 或 zip 文件。 + +- 支持格式:`.xlsx`、`.zip` +- 系统按「交易单号」去重。 + +## 字段映射 + +系统以首列「交易时间」定位表头,读取以下列: + +| 源字段(xlsx 列) | 落库字段 | 处理说明 | +|---|---|---| +| 交易时间 | `transaction_time` | 兼容 Excel 日期序列号与文本时间 | +| 交易对方 | `remark` | 与商品名称拼接为备注 | +| 商品 | `remark` | 同上 | +| 收/支 | `type` | 支出→EXPENSE,收入→INCOME | +| 金额(元) | `amount` | 去千分位后解析 | +| 交易状态 | 仅推断/预览 | 不落库 | +| 交易单号 | `external_no` | 导入去重键 | +| — | `channel` | 固定为 `WECHAT` | +| — | `category_id` | 取导入面板默认分类;未选为 NULL | + +「/」等占位符会被视为空值。 + +## 注意事项 + +- 「用于个人对账」导出的 xlsx 为对账用途,字段完整,适合导入。 +- 微信账单导出通常有一定延迟(通过邮件下发),收到文件后再导入。 +- 「/」等占位符会被视为空值。 diff --git a/guide/categories-tags.md b/guide/categories-tags.md new file mode 100644 index 0000000..179db24 --- /dev/null +++ b/guide/categories-tags.md @@ -0,0 +1,55 @@ +# 分类与标签 + +分类用于区分收支的**用途**(如餐饮、交通、工资),标签用于给记录附加**自定义维度**(如报销、旅行、大额消费)。两者都可自定义。 + +## 分类管理 + +![分类管理](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/categories.png) + +页面分为「支出分类」与「收入分类」两张表格,展示名称(含 Emoji 图标)、图标、排序与操作。 + +### 新增分类 + +1. 在分类管理页右上角点击「**新增分类**」。 +2. 在弹窗中填写: + - **分类名称** + - **类型**:支出 / 收入 + - **图标**:点击选择 Emoji(启用 AI 后可点「AI 推荐」) + - **排序**:数字越小越靠前 +3. 点击「确定」保存。 + +![新增分类弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/add-category.png) + +> 启用 AI 后,还可在弹窗顶部输入用途描述,点「AI 生成」自动填充名称、类型与图标。 + +### 编辑与删除 + +- **编辑**:在分类行点击「编辑」修改。 +- **删除**:点击「删除」并确认;已使用该分类的记录会变为「未分类」。 +- **图标**:使用 Emoji 选择器挑选,也可自定义输入。 +- **AI 生成分类**:启用 AI 后,输入用途描述(如「日常通勤打车地铁」),点「AI 生成」自动填充名称、类型与图标。 + +新注册用户会自动获得一套默认分类(如餐饮、交通、购物、居住、通讯、娱乐、医疗、教育等)。 + +## 标签管理 + +![标签管理](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/tags.png) + +### 新增标签 + +1. 在标签管理页右上角点击「**新增标签**」。 +2. 填写标签名称、颜色与排序(启用 AI 后可点「AI 推荐」自动配色)。 +3. 点击「确定」保存。 + +![新增标签弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/add-tag.png) + +- **颜色**:使用颜色选择器,内置常用色板;启用 AI 后点「AI 推荐」可按标签名称推荐颜色。 +- 标签以彩色标签形式在收支记录、导入预览等位置展示。 +- 删除标签会从所有交易记录中移除该标签,操作前会二次确认。 + +## 使用建议 + +- 分类尽量保持精简、稳定,便于统计对比;细粒度维度用标签补充。 +- 建议为以下场景建立标签:报销/待报销、旅行、大额消费、订阅、礼物、家庭、工作等。 +- 需要按规则自动归类时,配合[规则管理](/guide/rules)使用:规则命中后会自动应用分类与标签。 +- 分类与标签都支持在[账单导入](/guide/transactions)预览阶段批量指定为默认值。 diff --git a/guide/dashboard.md b/guide/dashboard.md new file mode 100644 index 0000000..1ad6c7b --- /dev/null +++ b/guide/dashboard.md @@ -0,0 +1,82 @@ +# 统计面板 + +「统计面板」是登录后的默认首页,用于查看指定时间范围内的收支汇总与图表。 + +![统计面板](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/dashboard.png) + +## 时间范围 + +顶部下拉可选择统计的时间范围: + +| 选项 | 含义 | +|---|---| +| 本月 | 当月 1 日 00:00:00 ~ 当月最后一天 23:59:59(默认) | +| 近3月 | 含本月在内向前共 3 个月 | +| 近12月 | 含本月在内向前共 12 个月 | +| 自定义 | 手动选择起止日期(结束日按 23:59:59 计算) | + +右侧实时显示当前查询区间。 + +## 时间粒度 + +趋势图右上角可选择粒度:**按日 / 按周 / 按月 / 按季 / 按年**。切换时间范围时会给出默认粒度(近12月→按月,近3月→按周,本月→按日)。区间内没有数据的点会补 0,保证趋势连续。 + +## AI 搜索 + +启用 AI 后,页面顶部会出现「**AI 搜索**」按钮,点击打开搜索弹窗。用一句自然语言描述统计意图,系统会调用大模型解析为统计参数并自动回填、刷新图表,例如: + +- 「上个月餐饮花了多少」→ 时间范围 + 分类 +- 「近半年交通支出趋势,按周看」→ 时间范围 + 分类 + 按周粒度 +- 「今年各类支出占比」→ 时间范围 + 类型 + +![AI 统计搜索弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/ai-search.png) + +可解析并回填的参数包括:**时间范围、收支类型、分类、标签、交易渠道、金额区间、备注、统计口径**与**趋势粒度**。弹窗内提供示例问题,输入框会记住历史记录(可一键清空);回车或点击「AI 查询」即可执行,无可用条件时会提示且不改动当前筛选。详见 [AI 能力](/guide/ai)。 + +## 筛选条件 + +- **统计口径**:仅计入统计(默认)/ 全部记录 / 仅不计入。默认只统计「计入统计」的记录,建议按来源设置(微信/支付宝计入、其他来源不计入),避免多来源重复统计。 +- **类型**:支出 / 收入(多选)。 +- **分类**:多选,含「未分类」。 +- **交易渠道**:多选。 +- **标签**:多选。 +- **金额区间**:最小 ~ 最大。 +- **备注搜索**。 + +点击「重置筛选」可清空筛选(保留时间范围与粒度)。筛选条件会记住,下次进入自动还原。 + +## 汇总卡片 + +第一行展示四项汇总:**总收入**、**总支出**、**结余**、**交易笔数**。 + +第二行展示派生指标:**日均收入**、**日均支出**、**最大单笔支出**(附带备注摘要)、**平均每笔支出**。 + +## 图表 + +| 图表 | 说明 | +|---|---| +| 收支趋势(柱状图) | 按所选粒度展示收入(绿)与支出(红),点击柱子或该列空白处可跳转查看对应明细 | +| 支出分类占比(环形图) | 各分类支出金额占比 | +| 收入分类占比(环形图) | 各分类收入金额占比 | +| 支出交易渠道占比(环形图) | 仅统计支出,按交易渠道 | +| 收入交易渠道占比(环形图) | 仅统计收入,按交易渠道 | + +切换语言时,图表标题、图例与交易渠道名称会实时重绘。 + +### 点击趋势图查看明细 + +「收支趋势」柱状图的**整列都可点击**:点中柱子,或点击柱子顶端上方的空白处均可。点击后跳转到[收支记录](/guide/transactions),并自动带入该列对应的筛选条件: + +- **类型**:点中柱子时取该柱子的收入 / 支出;点击空白处时不限定类型(沿用面板当前类型筛选); +- **日期范围**:该列代表的时间桶——按日→当天、按周→周一至周日、按月→当月、按季→当季、按年→当年; +- **其他筛选**:统计面板当前的统计口径、分类、交易渠道、标签、金额区间与备注关键字。 + +![点击趋势柱状图跳转收支记录](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/dashboard-trend-jump.png) + +跳转后可直接查看该时间段内的对应明细;筛选条件会照常记忆,收支记录页原有的每页条数与排序偏好不受影响。 + +## 小技巧 + +- 想知道「上个月餐饮花了多少」,可把时间范围设为「本月」或自定义,并在分类中只勾选「餐饮」。 +- 不计入统计的记录(如借款、账户间转账)默认不会计入汇总;如需查看全部,把统计口径切换为「全部记录」。 +- 想快速核对某月、某周或某天的明细,直接点击「收支趋势」上对应的列(柱子或该列空白处),即可带着时间范围跳到收支记录。 diff --git a/guide/email-bills.md b/guide/email-bills.md new file mode 100644 index 0000000..d7c4db6 --- /dev/null +++ b/guide/email-bills.md @@ -0,0 +1,84 @@ +# 邮箱账单 + +邮箱账单功能通过 **IMAP** 监听邮箱,当账单邮件到达时自动拉取附件,识别账单类型并进入待处理列表,由你预览后再确认导入。 + +页面包含三个标签页:**邮箱配置**、**发件人规则**、**待处理账单**。 + +## 邮箱配置 + +![邮箱配置](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/email-bills-configs.png) + +配置用于接收账单邮件的 IMAP 邮箱: + +1. 点击右上角「**新增**」,填写邮箱信息: + - **名称**:便于识别的名称 + - **IMAP 服务器**:可从内置列表选择或自定义(选择后自动填端口 993 并开启 SSL) + - **邮箱账号 / 授权码**:授权码在邮箱设置中开启 IMAP 服务后生成 + - **监听文件夹**:默认 `INBOX` + - **默认归类**:默认支出/收入分类与标签 + - **拉取范围**:仅新邮件 / 最近 N 天 / 最近 N 封 + - **启用**:开启后持续监听 +2. 点击「保存」。 + +![邮箱配置弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/add-email-config.png) + +- **IMAP 服务器**:可从内置列表选择(QQ、腾讯企业邮、163、126、网易企业邮、阿里云、新浪、139、189、Gmail、Outlook、iCloud)或自定义,选择内置服务器会自动填端口 993 并开启 SSL。 +- **邮箱账号 / 授权码**:授权码而非登录密码(在邮箱设置中开启 IMAP 服务后生成)。 +- **监听文件夹**:默认 `INBOX`。 +- **默认归类**:默认支出分类、收入分类与标签。 +- **拉取范围**:仅新邮件(增量)/ 最近 N 天 / 最近 N 封。 +- **启用**:开启后系统会持续监听该邮箱。 + +行操作: + +- **测试连接**:验证 IMAP 配置。 +- **立即检查**:实时展示检查进度,完成后提示新增待处理账单数量。 +- **编辑 / 删除**。 + +## 发件人规则 + +![发件人规则](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/email-bills-sender-rules.png) + +根据邮件**发件人**识别账单类型与解析参数: + +- **发件人匹配串**:每行一个,支持完整地址、`@域名` 或关键字,包含匹配且忽略大小写。 +- **账单类型**:支付宝 / 微信 / 美团 / 淘宝 / 京东 / 招商银行 / 交通银行。 +- **解压密码**:账单压缩包加密时填写。 +- **默认归类**:默认支出分类、收入分类与标签。 +- **优先级**:值越小越优先。 + +系统为常见账单邮箱预置了默认规则(如支付宝 `@alipay.com`、微信 `@wechatpay.com`、美团 `@meituan.com`、招行 `@message.cmbchina.com`、交行 `@bankcomm.com`)。 + +## 待处理账单 + +![待处理账单](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/email-bills-messages.png) + +展示已拉取、待处理的邮件账单,可按状态筛选(待解析 / 已导入 / 已忽略),列表每 30 秒自动刷新。 + +行操作: + +- **解析**:选择账单类型与解压密码后解析,进入预览,确认后导入。 +- **忽略**:标记为已忽略,不再处理。 +- **删除**:删除该消息及本地附件。 + +## 工作流程 + +```text +账单邮件到达 + │ + ▼ +IMAP 监听(IDLE 实时 / 轮询降级) + │ 提取附件并保存 + ▼ +发件人规则匹配 → 确定账单类型、解压密码、默认归类 + │ + ▼ +进入「待处理账单」(状态:待解析) + │ 手动点「解析」→ 预览 → 确认导入 + ▼ +写入收支记录(状态:已导入) +``` + +::: tip 先预览再导入 +邮箱账单不会自动写入账本,必须经过预览确认,避免误导入。 +::: diff --git a/guide/faq.md b/guide/faq.md new file mode 100644 index 0000000..8c84392 --- /dev/null +++ b/guide/faq.md @@ -0,0 +1,94 @@ +# 常见问题 + +## 安装与启动 + +### 没有数据库能启动吗? + +可以。系统支持启动后再配置数据库:未配置时前端会引导进入「初始化数据库」页面,选择 SQLite(零配置)或 MySQL 并保存后即可使用。 + +### 前端打包后刷新子路由 404? + +使用 `bootJar` 打包的一体化 jar 已内置 SPA 路由回退:访问 `/dashboard` 等子路径会由后端回退到 `index.html`。若自行用 Nginx 托管前端产物,需配置 history 模式回退。 + +## 账单导入 + +### 支持哪些账单? + +支付宝、微信、美团、招商银行、交通银行,详见[账单类型与获取](/guide/bills/overview)。 + +### 导入后重复了怎么办? + +系统按「用户 + 交易号」去重,重复导入同一账单时已存在的记录会被跳过。若已产生重复,可在[收支记录](/guide/transactions)中勾选后批量删除。 + +### 提示需要解压密码? + +部分平台(如美团、部分银行)导出的账单压缩包设有密码。请到导出时的「申请记录」或相应页面查看密码,在解析弹窗中输入。 + +### 为什么有些行不能导入? + +常见原因:金额或时间字段无法解析、该交易号已存在、或银行 PDF 的余额连续性校验未通过。预览页会显示具体状态。 + +### 银行账单没有交易号,会重复吗? + +不会。银行流水会以「日期 + 类型 + 金额 + 余额」生成稳定去重键;招商银行 CSV 与 PDF 生成规则一致,可混用。 + +## 统计与记录 + +### 借款、转账会计入统计吗? + +默认计入。建议在记录或导入时将「计入统计」关闭,这类记录会从统计中排除,但仍保留在列表中。 + +### 多来源账单如何避免重复统计? + +同一笔消费可能同时出现在多个账单中(例如通过微信付款、实际由招商银行卡扣款),若都计入统计会重复。推荐按来源设置「计入统计」: + +- **微信、支付宝**来源:默认**计入**(除非有特殊情况)。 +- **其他**来源(银行、美团、淘宝、京东等):默认**不计入**——这些渠道的扣款往往最终走微信/支付宝支付。 +- **例外**:工资、奖金等明确收入应计入。 + +可在[账单导入](/guide/transactions)预览页用「设为计入 / 设为不计入」批量设置,或在[收支记录](/guide/transactions)中逐条切换。 + +### 想让备注不显示在界面上? + +可在记录行使用「隐藏」功能,备注会显示为 `******`,需要时再「查看」或「取消隐藏」。 + +### 分类和标签有什么区别? + +分类用于区分收支用途(餐饮、交通、工资),是统计的主要维度;标签用于附加自定义维度(报销、旅行、大额消费),一笔记录可有多个标签。 + +## AI 与通知 + +### AI 功能不可用? + +请先在「系统设置 → AI 模型配置」中开启并填写 API Key,然后点「测试连接」验证。 + +### 邮箱账单会自动入库吗? + +不会。邮箱账单拉取后进入「待处理账单」,需要手动解析、预览并确认后才写入账本。 + +### 账单类型识别用 AI 吗? + +不使用。邮箱账单的类型由**发件人规则**确定,结果确定、可预期;识别不出的可在解析时手动选择类型。 + +### 到点没有收到提醒? + +检查:提醒是否启用、是否配置了启用的渠道、提醒时间是否为 `HH:mm` 格式。可先点「立即发送」验证渠道配置,并查看发送日志。 + +## 账号与数据 + +### 忘记密码怎么办? + +当前版本未提供自助找回。可在数据库中重置对应用户的密码哈希,或新建账号后迁移数据。 + +### 数据存在哪里? + +- SQLite:默认位于服务运行目录的 `data/` 下,配置文件为 `config/database.json`。 +- MySQL:位于所配置的数据库中。 + +### 切换数据库会迁移数据吗? + +不会。切换后原数据库数据保留,需要自行迁移。 + +### 支持哪些界面语言? + +简体中文、English、日本語,可在登录页或主界面顶部切换。 diff --git a/guide/getting-started.md b/guide/getting-started.md new file mode 100644 index 0000000..c3a7191 --- /dev/null +++ b/guide/getting-started.md @@ -0,0 +1,65 @@ +# 快速开始 + +本章面向**已部署好的系统**,介绍初次使用时需要完成的配置与操作。 + +> 如何安装、启动与部署(开发模式、打包可执行 jar、Docker),请见开发文档的[构建与部署](/dev/build-deploy)。 + +## 访问系统 + +使用部署者提供的地址访问,例如: + +- 本机部署:`http://localhost:8080`(端口以实际部署为准) +- 局域网 / 服务器:`http://<服务器 IP>:<端口>` + +### 在线体验 + +不想自行部署?可直接访问在线演示环境: + +- 地址:[https://finance.rosercode.cn/](https://finance.rosercode.cn/) +- 演示账号:`demo` / `demo123456` + +> 在线环境使用独立的 SQLite 演示库与虚构数据,仅供功能体验,请勿录入真实账目。 + +首次访问且数据库尚未配置时,会自动进入「初始化数据库」引导页。 + +## 数据库配置 + +系统支持在服务启动**之后**再配置数据库,无需启动前准备: + +- 未检测到配置时,应用仍可正常启动,前端会引导进入「初始化数据库」页面。 +- 支持 **SQLite**(本地单文件,零配置)与 **MySQL** 两种数据库。 +- 保存配置后立即生效并自动建表,无需手动执行 SQL。 +- 配置持久化在 `config/database.json`,SQLite 数据默认位于 `data/` 目录。 + +![数据库初始化](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/settings.png) + +首次进入时按引导选择数据库类型并填写连接信息,点击「保存并应用」即可。登录后可通过右上角用户菜单 →「数据库设置」随时切换数据库;切换后原数据库的数据保留,不做自动迁移。 + +## 注册与登录 + +- 首次使用需要注册账号。若系统尚无任何用户,注册入口始终可用。 +- 注册成功后使用用户名与密码登录,登录状态通过 JWT 保存。 + +![登录卡片](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/login-card.png) + +![注册卡片](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/register-card.png) + +注册功能可在「系统设置 → 注册设置」中开启或关闭。新注册用户会自动获得一套默认分类、标签与邮箱发件人规则。 + +## 界面语言 + +系统支持**简体中文、English、日本語**三种语言。登录页、注册页右上角以及主界面顶部栏均可切换语言。 + +## 首次使用建议 + +1. 完成数据库配置并注册登录。 +2. 进入「分类管理」「标签管理」按自己的习惯调整分类与标签。 +3. 在「规则管理」配置常用自动归类规则(如「星巴克 → 餐饮」)。 +4. 导出并导入历史账单(见[账单类型与获取](/guide/bills/overview))。 +5. 按需配置「AI 模型」「定时通知」「邮箱账单」。 + +## 下一步 + +- [统计面板](/guide/dashboard) +- [收支记录](/guide/transactions) +- [账单导入](/guide/transactions) diff --git a/guide/index.md b/guide/index.md new file mode 100644 index 0000000..d8b6faa --- /dev/null +++ b/guide/index.md @@ -0,0 +1,37 @@ +# 使用文档 + +本章面向**使用者**,介绍系统的安装启动、各功能的使用方法与各类账单的获取方式。 + +## 开始使用 + +- [项目简介](/guide/introduction):系统定位、核心功能与技术栈 +- [快速开始](/guide/getting-started):数据库配置、注册登录、界面语言与首次使用建议 +- [统计面板](/guide/dashboard):时间范围、筛选条件与图表说明 + +## 记账与整理 + +- [收支记录](/guide/transactions):记录管理、筛选、批量操作、账单导入、AI 建议与记账 +- [分类与标签](/guide/categories-tags):分类与标签的维护与使用建议 +- [规则管理](/guide/rules):按关键词/金额/类型自动归类打标,支持 AI 生成规则、搜索与分页 +- [邮箱账单](/guide/email-bills):IMAP 自动拉取账单邮件 + +## 智能与自动化 + +- [AI 能力](/guide/ai):分类标签建议、自然语言查询与记账、AI 搜索与规则生成、用量统计 +- [定时提醒与通知](/guide/notifications):多渠道账单导入提醒 + +## 系统 + +- [系统设置](/guide/settings):数据库、注册、账单来源统计、外观、AI、通知配置 +- [系统日志](/guide/logs):访问日志、登录日志、运行日志 + +## 账单获取 + +- [账单类型总览](/guide/bills/overview) +- [支付宝账单](/guide/bills/alipay) / [微信支付账单](/guide/bills/wechat) / [美团账单](/guide/bills/meituan) / [淘宝账单](/guide/bills/taobao) / [京东账单](/guide/bills/jd) / [招商银行账单](/guide/bills/cmb) / [交通银行账单](/guide/bills/bocom) + +## 其他 + +- [常见问题](/guide/faq) + +> 想了解系统内部设计与二次开发?请查看[开发文档](/dev/)。 diff --git a/guide/introduction.md b/guide/introduction.md new file mode 100644 index 0000000..1ba6452 --- /dev/null +++ b/guide/introduction.md @@ -0,0 +1,55 @@ +# 项目简介 + +收支管理系统是一个**自托管的个人收支管理应用**,用于把分散在支付宝、微信、美团、银行等平台的账单汇总到一处,统一分类、打标签、统计收支,并通过自定义规则与 AI 能力减少手工整理的工作量。 + +## 解决什么问题 + +日常消费分散在多个平台:扫码用微信/支付宝、外卖在美团、大额消费走银行卡。各平台账单格式不同、导出入口不同,手动记录既繁琐又容易遗漏。本系统提供: + +- **多来源账单导入**:支付宝(CSV)、微信(xlsx)、美团(CSV)、淘宝(xlsx)、京东(CSV)、招商银行(CSV/PDF)、交通银行(xls),支持加密 zip 压缩包;解析预览后再确认导入,按交易号去重。 +- **自动归类**:自定义规则按关键词、收支类型、金额区间自动匹配分类与标签;AI 可对已有记录批量给出分类/标签建议。 +- **统计与图表**:月度汇总、分类占比、收支趋势、交易渠道占比,支持自定义时间范围与多维度筛选。 +- **自动化提醒**:按周期提醒导入新账单;也可通过 IMAP 监听邮箱自动拉取账单邮件。 + +## 核心功能 + +| 模块 | 能力 | +|---|---| +| 收支记录 | 增删改查、分页与多条件筛选、批量设置分类/标签、批量删除、计入统计开关、备注隐藏、导出 Excel | +| 分类与标签 | 收入/支出分类与标签的增删改查,新用户自动获得默认数据,AI 推荐 Emoji 与颜色 | +| 统计图表 | 月度收支、分类占比、收支趋势、交易渠道占比,支持按日/周/月/季/年粒度,点击趋势图可下钻对应明细 | +| 账单导入 | 七类账单解析、zip 解密、解析预览、按交易号去重、规则建议 | +| 自定义规则 | 关键词/收支类型/金额区间组合,优先级排序、启用停用,导入时自动应用;支持 AI 生成规则、搜索与分页 | +| AI 能力 | 分类标签建议、自然语言查询、自然语言记账、AI 统计搜索、AI 生成规则、分类/标签生成、Emoji/颜色推荐、用量统计 | +| 邮箱账单 | 多邮箱 IMAP IDLE 监听、发件人规则识别账单类型、待处理列表先预览再导入 | +| 定时通知 | 每天/每周/每月提醒,邮箱 SMTP 与 Server 酱渠道,发送日志 | +| 系统日志 | 访问日志、登录日志、运行日志在线查看,运行日志实时推送,自动清理 | +| 账号与多语言 | JWT 登录、注册开关、运行时数据库配置(SQLite/MySQL)、中/英/日三语 | + +## 技术栈 + +| 层级 | 技术 | +|---|---| +| 后端 | Spring Boot 3.3.6 + Java 21 + MyBatis-Plus 3.5.12 + MySQL / SQLite | +| 前端 | Vue 3 + TypeScript + Vite + Naive UI + ECharts + Pinia | +| 构建 | Gradle 8.13(后端)/ npm(前端) | +| 认证 | JWT | +| 对象映射 | MapStruct 1.6.3 | +| 账单解析 | EasyExcel 4.0.3 + PDFBox 3.0.3 + Zip4j 2.11.5 | +| AI 能力 | DeepSeek | +| 通知渠道 | 邮箱 SMTP / Server 酱 | +| 多语言 | 中文 / 英文 / 日文(vue-i18n) | +| API 文档 | Knife4j(OpenAPI 3) | + +## 适用场景 + +- 个人或家庭希望把多平台账单集中管理、统一统计。 +- 希望按自己的习惯自定义分类、标签与自动归类规则。 +- 希望借助 AI 快速完成分类打标、自然语言查询与记账。 +- 希望私有部署,数据保存在本地 SQLite 或自有 MySQL 中。 + +## 下一步 + +- [快速开始](/guide/getting-started):初次使用与配置 +- [账单类型与获取](/guide/bills/overview):了解支持哪些账单、如何导出 +- [整体架构](/dev/architecture):了解内部设计 diff --git a/guide/logs.md b/guide/logs.md new file mode 100644 index 0000000..df36fb9 --- /dev/null +++ b/guide/logs.md @@ -0,0 +1,40 @@ +# 系统日志 + +系统日志通过右上角用户菜单 →「系统日志」进入,包含三个标签页:**访问日志**、**登录日志**、**运行日志**。页头右上角可选择日志保留时长(30 天 / 90 天 / 180 天 / 永久保留)。 + +## 访问日志 + +![访问日志](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/logs-http.png) + +记录每一次接口访问: + +- 筛选:关键字(路径/参数/IP)、请求方法、状态码。 +- 表格列:时间、方法、路径、参数、状态码、耗时、用户、IP。 +- 支持分页与刷新。 + +## 登录日志 + +![登录日志](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/logs-login.png) + +记录每次登录尝试(成功与失败): + +- 筛选:关键字(用户名等)、结果(成功/失败)。 +- 表格列:时间、用户名、结果、说明、IP、User-Agent。 + +## 运行日志 + +![运行日志](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/logs-app.png) + +在线查看后端运行日志: + +- 筛选:日期、时间范围(最近 1/6/24 小时或全部)、级别(ERROR/WARN/INFO/DEBUG)、关键字、行数。 +- 开启「实时」后通过 SSE 订阅新增日志,先回放最近内容再增量推送。 +- 页面显示当前日志文件名与命中行数。 + +## 日志清理 + +访问日志与登录日志会按配置的保留天数自动清理(默认 30 天,设为永久保留则不清理),无需手动维护。 + +::: tip 排查问题 +接口报错时,可先在「访问日志」定位失败请求(状态码 4xx/5xx),再到「运行日志」按时间与关键字查看异常堆栈。 +::: diff --git a/guide/notifications.md b/guide/notifications.md new file mode 100644 index 0000000..81daab3 --- /dev/null +++ b/guide/notifications.md @@ -0,0 +1,37 @@ +# 定时提醒与通知 + +系统可按周期提醒你导入新账单,并通过邮箱或 Server 酱推送通知。配置入口为「系统设置 → 定时通知」。 + +![系统设置](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/settings.png) + +## 提醒周期 + +- **通知链接地址**:通知正文中附带的跳转链接(如指向账单导入页 `/bill-import`)。 +- **启用定时提醒**:开启后按周期发送。 +- **提醒周期**:每天 / 每周(选择星期几)/ 每月(选择 1–28 日,默认每月 1 日)。 +- **提醒时间**:`HH:mm` 格式(如 `09:00`)。 + +## 推送渠道 + +点击「添加渠道」新增,支持两种类型: + +| 渠道 | 配置项 | +|---|---| +| Server 酱 | SendKey(在 sct.ftqq.com 获取),可推送到个人微信 | +| 邮箱 | SMTP 服务器、端口、邮箱账号、授权码、收件人、是否使用 SSL | + +![添加渠道弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/add-channel.png) + +每个渠道可单独「测试」「编辑」「删除」,并可通过启用开关控制是否参与发送。 + +面板右上角的「立即发送」可向所有启用渠道立即发送一次提醒,用于验证配置。 + +## 发送日志 + +「定时通知」面板下方展示最近发送记录(最多 20 条),包含成功/失败、时间、渠道类型与说明,便于排查问题。 + +## 说明 + +- 首次启用提醒时,系统会将「上次发送时间」置为当前,避免启用后立即补发历史提醒。 +- 提醒正文默认提示导入新账单,并附带配置的通知链接地址。 +- 若当前没有任何启用的渠道,到点时不会发送,待配置渠道后在本周期内仍可触发。 diff --git a/guide/pages.md b/guide/pages.md new file mode 100644 index 0000000..b19c15d --- /dev/null +++ b/guide/pages.md @@ -0,0 +1,55 @@ +# 页面总览 + +本节汇总系统的全部页面、用途、入口方式与相互之间的跳转关系。 + +## 页面清单 + +| 页面 | 路由 | 用途 | 入口方式 | +|---|---|---|---| +| 登录 | `/login` | 用户名密码登录 | 未登录访问受保护页面时自动跳转 | +| 注册 | `/register` | 创建账号 | 登录页「没有账号?去注册」(注册开启时) | +| 数据库初始化 | `/database-setup` | 首次配置数据库 | 数据库未配置时自动引导 | +| 统计面板 | `/dashboard` | 收支汇总与图表 | 登录后默认页;左侧菜单 | +| 收支记录 | `/transactions` | 记录管理与**账单导入** | 左侧菜单 | +| 分类管理 | `/categories` | 收入/支出分类维护 | 左侧菜单 | +| 标签管理 | `/tags` | 标签维护 | 左侧菜单 | +| 规则管理 | `/rules` | 自动归类规则 | 左侧菜单 | +| 邮箱账单 | `/email-bills/*` | 邮箱配置 / 发件人规则 / 待处理账单 | 左侧菜单 | +| 系统设置 | `/settings` | 数据库 / 注册 / 账单来源统计 / 外观 / AI / 通知配置 | 顶部用户菜单 | +| 模型使用情况 | `/ai-usage` | AI 调用统计与明细 | 设置页「AI 模型配置」→ 使用情况 | +| 系统日志 | `/logs/*` | 访问 / 登录 / 运行日志 | 顶部用户菜单 | +| 账单导入(独立页) | `/bill-import` | 手机端上传账单 | **仅由通知邮件链接直达** | + +## 全局导航 + +### 左侧菜单 + +统计面板 → 收支记录 → 分类管理 → 标签管理 → 规则管理 → 邮箱账单 + +### 顶部用户菜单 + +- **系统日志**:`/logs` +- **系统设置**:`/settings` +- **退出**:清空登录态并返回 `/login` + +> 「模型使用情况」没有独立菜单入口,需从「系统设置 → AI 模型配置 → 使用情况」进入。 + +## 典型跳转路径 + +- **首次使用**:`/database-setup` → `/register` → `/login` → `/dashboard` +- **手动记账**:收支记录 → 点击「新增记录」→ 表单弹窗 → 保存 +- **查看趋势明细**:统计面板 → 点击「收支趋势」某一列(柱子或该列空白处)→ 收支记录(自动带入时间范围、类型与当前筛选条件) +- **账单导入**:收支记录 → 点击「导入账单」→ 解析预览 → 确认导入 → 回到收支记录 +- **手机导入**:通知邮件链接 → `/bill-import`(独立页,非弹窗)→ 上传解析 → 确认导入 +- **自动归类**:规则管理配置规则 → 账单导入预览时命中并给出建议 +- **邮箱账单**:邮箱配置 → 发件人规则 → 待处理账单 → 解析 → 导入到收支记录 +- **AI 辅助**:系统设置启用 AI → 统计面板「AI 搜索」、收支记录「一键设置 / 智能记账 / AI 查询」、分类与标签「AI 推荐」、规则管理「AI 生成规则」 +- **系统维护**:顶部菜单 → 系统设置 / 系统日志 + +## 页面关系说明 + +- **收支记录**是核心页面:手动记账、账单导入、批量设置、AI 能力都从这里进入,导入完成后也回到该页查看。 +- **账单导入**有两条独立路径: + - 收支记录页的「导入账单」**弹窗**(桌面端常用); + - 通知邮件直达的 **`/bill-import` 独立页**(面向手机,非弹窗,元素精简,系统内无其他入口)。 +- **系统设置**聚合了数据库、注册、账单来源统计、外观、AI、通知六类配置;模型用量与日志分别从设置页与顶部菜单进入。 diff --git a/guide/rules.md b/guide/rules.md new file mode 100644 index 0000000..ed51580 --- /dev/null +++ b/guide/rules.md @@ -0,0 +1,85 @@ +# 规则管理 + +规则用于**自动归类**:命中条件后自动应用指定的分类与标签,减少手动整理。 + +![规则管理](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/rules.png) + +## 创建规则 + +1. 在规则管理页右上角点击「**新增规则**」。 +2. 填写规则名称,并按需设置条件与结果: + + | 字段 | 说明 | + |---|---| + | 类型 | 不限类型 / 支出 / 收入 | + | 关键词 | 匹配备注、交易对方、商品名称(包含即命中) | + | 金额区间 | 最小值 ~ 最大值(可只填一端) | + | 目标分类 | 命中后应用的分类 | + | 目标标签 | 命中后应用的标签(可多选) | + | 启用 / 排序 | 是否启用、优先级(越小越优先) | + +3. 点击「确定」保存。 + +![新增规则弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/add-rule.png) + +## 规则条件 + +一条规则可组合以下条件: + +| 条件 | 说明 | +|---|---| +| 类型 | 不限类型 / 支出 / 收入 | +| 关键词 | 匹配备注、交易对方、商品名称,**包含即命中**(忽略大小写) | +| 金额区间 | 最小值 ~ 最大值(含边界,可只填一端) | +| 目标分类 | 命中后应用的分类(可不选) | +| 目标标签 | 命中后应用的标签(多选,可不选) | +| 优先级 | 排序值越小越优先 | +| 启用 | 可随时启用或停用 | + +## 命中规则 + +- **分类**:取优先级最高(排序值最小)且与交易类型一致的命中规则。 +- **标签**:取所有命中规则标签的并集。 +- 规则在**账单导入的解析预览阶段**即会展示命中的规则名与建议分类/标签;确认导入时,**规则建议的分类优先于面板默认分类**,标签与默认标签合并去重。 + +## 搜索与分页 + +规则列表支持按条件检索与分页: + +- **搜索**:按**规则名称或关键词**模糊匹配。 +- **筛选**:**启用状态**(已启用 / 已停用)、**收支类型**(收入 / 支出)。 +- **分页**:每页 10 / 20 / 50 / 100 条。 + +点击「搜索」按条件查询,「清空」重置全部条件。新增规则后回到第一页,便于查看新规则。 + +## AI 生成规则 + +启用 AI 后,规则管理页右上角会出现「**AI 生成规则**」按钮,可让 AI 依据**已打标签的记录**自动归纳出「关键词 → 分类/标签」规则,无需手工逐条编写。 + +流程为两步: + +1. **选择记录**:列出当前用户**已打标签**的记录,默认全选当前页。可按**备注关键字**搜索,并按**收支类型、标签**筛选;支持分页与每页条数切换,可「全选本页」或「清空选择」。跨页选择会保留。 + + ![AI 生成规则 · 选择记录](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/ai-rule-select.png) + +2. **预览规则**:AI 依据所选记录的**类型、备注、交易渠道、已有分类与标签**归纳出规则建议,展示规则名称、关键词、目标分类与标签、理由。名称与关键词可编辑,勾选后点击「创建勾选规则」批量创建;系统会自动创建建议中不存在的新分类与标签。 + + ![AI 生成规则 · 预览规则](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/ai-rule-preview.png) + +::: tip 与已有规则去重 +AI 建议若与**数据库中已有规则重复**(按「类型 + 关键词 + 分类 + 标签集合」判定),会标注「**已存在**」,**默认不勾选且不可勾选**,无法导入,避免产生重复规则。 +::: + +## 示例 + +| 规则名 | 条件 | 结果 | +|---|---|---| +| 星巴克归入餐饮 | 关键词「星巴克」、类型支出 | 分类:餐饮;标签:咖啡 | +| 滴滴出行归入交通 | 关键词「滴滴」、类型支出 | 分类:交通 | +| 大额支出标记 | 金额 ≥ 500、类型支出 | 标签:大额消费 | + +## 使用建议 + +- 先建立少量高频规则(餐饮、交通、外卖等),再逐步补充。 +- 关键词尽量选择稳定、唯一的商户或商品名,避免误命中。 +- 同一笔记录可能命中多条规则,分类取最优先的一条,标签会全部应用。 diff --git a/guide/settings.md b/guide/settings.md new file mode 100644 index 0000000..d5d96c5 --- /dev/null +++ b/guide/settings.md @@ -0,0 +1,71 @@ +# 系统设置 + +系统设置通过右上角用户菜单 →「系统设置」进入,包含六个配置面板。 + +![系统设置](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/settings.png) + +## 数据库配置 + +![数据库配置面板](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/panel-database.png) + +- **数据库类型**:SQLite / MySQL。 +- **SQLite**:数据库文件路径(相对于服务运行目录)。 +- **MySQL**:主机、端口、数据库名、用户名、密码。 +- 提供「测试连接」与「保存并应用」。保存后立即生效并自动建表;已配置时密码留空表示不修改。 + +系统支持启动后再配置数据库:未配置时前端会引导进入初始化页面,无需启动前准备。登录后也可随时切换数据库,切换后原数据库数据保留、不做自动迁移。 + +## 注册设置 + +![注册设置面板](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/panel-registration.png) + +控制是否开放注册: + +- 系统**无任何用户**时始终允许创建首个账号。 +- 关闭后新用户无法注册(登录页不再显示注册入口)。 + +## 账单来源统计默认值 + +![账单来源统计默认值面板](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/panel-bill-source-stats.png) + +按**账单来源**分别设置导入时「是否计入统计」的默认值,用于避免同一笔消费在多来源账单中重复统计: + +- 每个来源一个开关:**计入统计 / 不计入统计**。 +- 默认策略:**微信、支付宝**计入,**美团、淘宝、京东、招商银行、交通银行**不计入。 +- 不计入的记录仍会导入,只是不参与统计;导入预览页仍可逐条或批量修改。 +- 默认值按**当前账号**保存,仅影响当前用户的账单导入。 + +> 解析器判定为「不计收支/中性」的行始终不计入,不受此处默认值影响。 + +## 外观设置 + +![外观设置面板](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/panel-display.png) + +选择「不计入统计」的记录在[收支记录](/guide/transactions)列表中的展示方式: + +- **删除线(默认)**:整行加删除线,类型与金额列保持原样。 +- **灰色标签**:类型列显示灰色「不计入」标签,金额列置灰且不显示正负号。 + +面板内提供**实时预览**,切换选项即可看到效果;该偏好按**当前账号**保存,登录后生效。 + +## AI 模型配置 + +![AI 模型配置面板](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/panel-ai.png) + +- **启用 AI 建议**:总开关,关闭后不发起任何 AI 请求。 +- **API Key**:DeepSeek 的 API Key(已配置时留空表示不修改)。 +- **接口地址**:默认 `https://api.deepseek.com`。 +- **模型**:默认 `deepseek-chat`。 +- 提供「保存」与「测试连接」。 +- 右上角「使用情况」可查看 AI 调用统计与明细,详见 [AI 能力](/guide/ai)。 + +## 定时通知 + +![定时通知面板](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/panel-notification.png) + +配置账单导入提醒的周期、时间与推送渠道(Server 酱 / 邮箱),并可查看发送日志。详见[定时提醒与通知](/guide/notifications)。 + +## 相关 + +- [系统日志](/guide/logs):查看访问、登录与运行日志。 +- [快速开始](/guide/getting-started):数据库配置与注册登录。 diff --git a/guide/transactions.md b/guide/transactions.md new file mode 100644 index 0000000..c25589a --- /dev/null +++ b/guide/transactions.md @@ -0,0 +1,193 @@ +# 收支记录 + +「收支记录」页面用于查看和管理每一笔收入与支出。 + +![收支记录](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/transactions.png) + +## 顶部操作 + +| 按钮 | 功能 | +|---|---| +| 排序 | 交易时间(新→旧 / 旧→新)、金额(高→低 / 低→高) | +| 一键设置 | 对当前表格展示的记录批量设置分类、计入统计与标签 | +| 批量删除 | 删除表格中勾选的记录 | +| 导入账单 | 打开账单导入弹窗 | +| 导出 Excel | 按当前筛选条件导出 | +| 刷新 | 按当前筛选条件与页码重新加载列表 | +| 智能记账 | 用一句话描述收支,AI 解析后预填表单(需启用 AI) | +| 新增记录 | 手动新增一笔记录 | + +## 账单导入 + +「收支记录」页支持把支付宝、微信、美团、淘宝、京东、招商银行、交通银行的账单文件解析并导入为收支记录。 + +![导入账单弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/import-bill.png) + +### 入口 + +| 入口 | 形式 | 说明 | +|---|---|---| +| 收支记录页「**导入账单**」按钮 | 弹窗 | 桌面端常用 | +| 通知邮件中的链接 `/bill-import` | 独立页 | **仅由通知邮件直达**,面向手机;独立页面(非弹窗),元素精简、系统内无其他入口 | + +> `/bill-import` 是「定时通知」邮件里附带的直达上传页,方便在手机上直接上传账单,不会从系统内其他页面跳转进入。 + +### 支持的账单类型与格式 + +| 账单类型 | 支持格式 | +|---|---| +| 支付宝 | `.csv`、`.zip` | +| 微信 | `.xlsx`、`.zip` | +| 美团 | `.csv`、`.zip` | +| 淘宝 | `.xlsx` | +| 京东 | `.csv`、`.zip` | +| 招商银行 | `.csv`、`.pdf`、`.zip` | +| 交通银行 | `.xls`、`.zip` | + +各平台账单的导出方法见[账单类型与获取](/guide/bills/overview)。 + +### 导入流程 + +导入采用「解析预览 → 确认导入」两步,先解析不落库,确认后才写入。 + +**第一步:选择类型与文件** + +1. 选择账单类型(默认记住上次选择)。 +2. 点击或拖拽上传账单文件。 +3. 点击「解析」。 + +切换账单类型会清空已选文件;页面会提示该类型支持的格式与在对应 App 中的导出路径。若上传的是**加密压缩包**,解析时会弹出密码输入框,输入后重新解析。 + +**第二步:解析预览** + +![账单解析预览](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/bill-parse-preview.png) + +- **默认归类**:可为本次导入设置默认的支出分类、收入分类与默认标签。 +- **筛选**:关键字、类型(收入/支出/不计入)、状态(可导入/已存在/不可导入)、金额区间。 +- **逐行勾选**:默认勾选解析器建议的行;只有「可导入」的行可勾选。 +- **计入统计**:可对勾选行批量「设为计入 / 设为不计入」,建议按来源设置(见下)。 +- **预览列**:交易时间、类型、计入统计、金额、交易对方、商品名称、状态、**匹配规则与建议**。 + +::: tip 按来源设置计入统计 +- **微信、支付宝**来源默认**计入**;**其他**来源(银行、美团、淘宝、京东等)默认**不计入**——它们往往最终走微信/支付宝支付。 +- **例外**:工资、奖金等明确收入应计入。 +::: + +::: tip 去重 +系统按「用户 + 交易号」去重。已存在于系统中的交易会标记为「已存在」,默认不勾选,避免重复导入。 +::: + +**第三步:导入结果** + +显示成功、跳过、失败的数量与明细消息。完成后可在下方表格中查看。 + +### 常见问题 + +- **解析失败**:确认账单类型与文件格式是否匹配;银行账单请使用系统支持的导出格式。 +- **需要解压密码**:部分账单压缩包在导出时设置了密码,需在解析时输入。 +- **部分行不可导入**:可能是金额/时间字段无法解析,或该交易号已存在。 +- **重复导入**:同一账单重复导入时,已存在的交易会被跳过。 + +## 筛选与 AI 智能查询 + +**常用筛选**:类型、分类(含「未分类」)、日期范围、备注关键字、交易渠道。 + +**更多筛选**:标签(含「无标签」)、计入统计、金额区间。 + +启用 AI 后,顶部会出现 **AI 智能查询**输入框,可用自然语言查询,例如: + +- 「上个月餐饮」 +- 「超过500的支出」 +- 「待报销的支出」 +- 「微信支付」 + +解析结果会直接应用为筛选条件并立即查询。筛选条件与每页条数、排序方式会自动记忆,下次进入自动还原。 + +## 表格与行操作 + +表格展示:交易时间、类型、分类、金额、交易渠道、备注、标签、计入统计开关与操作。 + +- **备注隐藏**:可将敏感备注持久隐藏为 `******`,需要时临时「查看」或「取消隐藏」。 +- **计入统计**:行内开关可直接切换;关闭后该记录不纳入月度统计(适合借款、转账等),行内会以删除线标示。 +- **行操作**:编辑、查看「原始数据」(账单导入时的原始字段,以 JSON 展示)、删除。 + +分页支持每页 10/20/50/100/200/500 条。 + +## 查看原始数据 + +账单导入时,系统会完整保留每笔交易在原始账单中的字段(以 JSON 存储)。在记录行点击「**原始数据**」即可查看,用于核对金额、交易状态、对方账号、交易类型等明细。 + +- 仅**账单导入**的记录有原始数据;**手动新增**的记录没有,该按钮不可用。 +- 原始数据是排查导入异常、核对账单的重要依据。 + +## 一键设置(批量设置) + +对**当前表格展示的记录**批量操作: + +![一键设置弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/batch-set.png) + +- 设置支出分类 / 收入分类。 +- 设置是否计入统计。 +- 添加标签 / 移除标签。 +- 启用 AI 时,可选择建议范围(分类与标签 / 仅分类 / 仅标签)并点击「AI 智能建议」,由 AI 生成建议后进入 AI 建议弹窗。 + +## AI 建议弹窗 + +展示每条记录的建议分类、建议标签与理由,默认全选,可逐条取消。若建议中包含系统中不存在的分类或标签,会以「新建」标记,确认后自动创建。应用后分类会被覆盖、标签会被追加。 + +## AI 智能记账 + +在「智能记账」弹窗中用自然语言描述一笔收支,例如「昨天中午吃饭 35 支付宝」,AI 会解析为类型、金额、分类、交易渠道、时间、备注、标签等字段,并预填到新增记录表单,确认后保存。 + +![AI 智能记账弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/ai-record.png) + +## 手动添加记录 + +1. 在收支记录页右上角点击「**新增记录**」。 +2. 在弹窗中填写表单: + + | 字段 | 说明 | + |---|---| + | 类型 | 支出 / 收入 | + | 分类 | 新增时必填 | + | 金额 | 大于 0 | + | 交易渠道 | 可选 | + | 交易时间 | 默认当前时间 | + | 备注 | 可选 | + | 标签 | 可选,可多选 | + | 计入统计 | 默认开启;关闭后不纳入统计 | + +3. 点击「确定」保存。 + +![新增记录弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/add-record.png) + +## 编辑记录 + +在记录行点击「编辑」即可修改记录,弹窗字段与新增一致;编辑时允许不设置分类。 + +![编辑记录弹窗](https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/elements/edit-record.png) + +### 编辑时的 AI 辅助 + +启用 AI 后,编辑弹窗中的**分类**与**标签**旁会出现 AI 辅助按钮(与新增记录一致): + +| 按钮 | 作用 | +|---|---| +| 分类 · **AI 推荐** | 根据当前类型、金额、交易渠道、备注,从已有分类中推荐并填入 | +| 分类 · **AI 新建** | 根据当前账单信息生成新分类;确认「保存并选用」后创建并选中,若已存在同名同类型分类则直接选中 | +| 标签 · **AI 推荐** | 推荐已有标签并追加(自动去重) | +| 标签 · **AI 新建** | 生成新标签(含建议颜色),确认后创建并选中;同名标签直接选中 | + +> 「AI 新建」需先填写备注或金额,便于模型理解;生成结果会显示确认条,点击「保存并选用」才真正创建。 + +编辑完成后点击「确定」保存。其他 AI 能力(批量建议、智能记账、智能查询)见 [AI 能力](/guide/ai)。 + +::: tip 计入统计 +「计入统计」用于区分真实消费与资金流转,建议**按账单来源**设置,避免多来源重复统计: + +- **微信、支付宝**来源:默认**计入**(除非有特殊情况)。 +- **其他**来源(银行、美团、淘宝、京东等):默认**不计入**——这些渠道的扣款往往最终走微信/支付宝,重复计入会导致统计翻倍。 +- **例外**:工资、奖金等明确收入应计入。 + +此外,借款、信用卡还款、账户间转账等资金流转也建议关闭该开关,避免统计失真。 +::: diff --git a/index.md b/index.md new file mode 100644 index 0000000..cdf8474 --- /dev/null +++ b/index.md @@ -0,0 +1,65 @@ +--- +layout: home + +hero: + name: 收支管理系统 + text: 个人收支管理 · 多来源账单导入 · AI 智能记账 + tagline: 支持支付宝、微信、美团、淘宝、京东、招商银行、交通银行账单导入,提供分类标签管理、自定义规则、AI 建议与定时提醒 + image: + src: https://adcarac.oss-cn-beijing.aliyuncs.com/finance-app-docs/screenshots/dashboard.png + alt: 统计面板 + actions: + - theme: brand + text: 使用文档 + link: /guide/ + - theme: alt + text: 开发文档 + link: /dev/ + - theme: alt + text: 在线体验 + link: https://finance.rosercode.cn/ + +features: + - title: 多来源账单导入 + details: 支持支付宝、微信、美团、淘宝、京东、招商银行、交通银行账单,解析预览后确认导入,按交易号自动去重。 + - title: 分类与标签 + details: 自定义收入/支出分类与标签,支持批量设置,AI 可推荐 Emoji 图标与标签颜色。 + - title: 自定义规则 + details: 按关键词、收支类型、金额区间组合条件,命中后自动应用分类与标签,导入预览即展示建议。 + - title: AI 智能能力 + details: 基于 DeepSeek 的分类/标签建议、自然语言查询与记账、分类与标签生成,并统计 token 用量。 + - title: 统计与图表 + details: 月度收支汇总、分类占比、收支趋势、支付方式占比,支持多维度筛选与自定义时间范围。 + - title: 定时提醒与通知 + details: 按每天/每周/每月周期提醒导入新账单,支持邮箱 SMTP 与 Server 酱多渠道推送。 + - title: 邮箱账单自动拉取 + details: 通过 IMAP IDLE 监听邮箱,新账单邮件到达后自动拉取附件,先预览再确认导入。 + - title: 系统日志 + details: 接口访问日志、登录日志与后端运行日志在线查看,运行日志支持 SSE 实时推送。 +--- + +## 这是什么 + +收支管理系统是一个自托管的个人记账应用,帮助你把分散在支付宝、微信、银行等平台的账单汇总到一处,统一分类、打标签、统计收支,并通过 AI 与自动化减少手工整理的工作量。 + +> **在线体验**:[https://finance.rosercode.cn/](https://finance.rosercode.cn/)(演示账号 `demo` / `demo123456`,使用独立的 SQLite 演示库与虚构数据) + +- **后端**:Spring Boot 3.3.6 + Java 21 + MyBatis-Plus,支持 SQLite / MySQL +- **前端**:Vue 3 + TypeScript + Vite + Naive UI + ECharts +- **账单解析**:EasyExcel + PDFBox + Zip4j +- **AI**:DeepSeek + +## 从哪里开始 + +### 使用文档 + +- 想开始使用:[快速开始](/guide/getting-started) +- 想知道各类账单怎么导出:[账单类型与获取](/guide/bills/overview) +- 想了解各功能怎么用:[统计面板](/guide/dashboard) 起逐页阅读 +- 遇到问题:[常见问题](/guide/faq) + +### 开发文档 + +- 想了解内部设计:[整体架构](/dev/architecture) +- 想参与开发:[项目结构](/dev/project-structure)、[开发环境与规范](/dev/development) +- 想自行部署:[构建与部署](/dev/build-deploy) diff --git a/package.json b/package.json new file mode 100644 index 0000000..0c32f09 --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "finance-app-docs", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview", + "capture": "tsx scripts/ts/capture-all.ts", + "capture:pages": "tsx scripts/ts/capture-pages.ts", + "capture:elements": "tsx scripts/ts/capture-elements.ts", + "seed": "tsx scripts/ts/seed-data.ts", + "seed:rules": "tsx scripts/ts/seed-rules.ts", + "make:bills": "tsx scripts/ts/make-fake-bills.ts", + "refs:search": "tsx scripts/ts/search-refs.ts", + "refs:fetch": "tsx scripts/ts/fetch-refs.ts", + "test:browser": "tsx scripts/ts/test-browser.ts", + "oss:upload": "tsx scripts/ts/upload-oss.ts", + "typecheck:scripts": "tsc --noEmit" + }, + "devDependencies": { + "@types/ali-oss": "^6.23.3", + "@types/node": "^26.6.2", + "exceljs": "^4.4.0", + "tsx": "^4.23.13", + "typescript": "^7.0.2", + "vitepress": "^1.6.3" + }, + "dependencies": { + "@alicloud/sts20150401": "^1.2.0", + "ali-oss": "^6.23.0", + "axios": "^1.20.0", + "playwright-core": "^1.63.0" + } +} diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..65e2eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/logo.svg b/public/logo.svg new file mode 100644 index 0000000..1c1664c --- /dev/null +++ b/public/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/scripts/ts/browser.ts b/scripts/ts/browser.ts new file mode 100644 index 0000000..25479f5 --- /dev/null +++ b/scripts/ts/browser.ts @@ -0,0 +1,111 @@ +// 浏览器自动化公共模块(Playwright + Chromium)与 HTTP 公共方法(axios)。 +import axios from 'axios' +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright-core' +import { API_URL, BASE_URL, CHROMIUM_BIN, PASSWORD, USERNAME } from './config.js' + +/** 默认视口尺寸 */ +export const DEFAULT_VIEWPORT = { width: 1440, height: 1000 } + +/** + * 启动 headless Chromium(复用 Playwright 缓存的浏览器)。 + * + * @returns Browser 实例 + */ +export async function launchBrowser(): Promise { + return chromium.launch({ + executablePath: CHROMIUM_BIN, + headless: true, + args: ['--no-sandbox', '--disable-dev-shm-usage'], + }) +} + +/** + * 创建浏览器上下文。 + * + * @param browser Browser 实例 + * @param viewport 视口尺寸 + * @returns BrowserContext 实例 + */ +export async function newContext( + browser: Browser, + viewport: { width: number; height: number } = DEFAULT_VIEWPORT, +): Promise { + return browser.newContext({ viewport }) +} + +/** + * 在上下文中注入登录态与中文语言偏好(对之后打开的页面生效)。 + * + * @param context BrowserContext 实例 + * @param token 访问令牌 + */ +export async function injectSession(context: BrowserContext, token: string): Promise { + await context.addInitScript( + ([t]) => { + localStorage.setItem('token', t as string) + localStorage.setItem('locale', 'zh-CN') + }, + [token], + ) +} + +/** + * 等待页面渲染完成:先等文档就绪,再等关键元素可见,最后做短暂稳定等待。 + * + * @param page Page 实例 + * @param options.selector 关键元素选择器(可选) + * @param options.settle 元素出现后的稳定等待毫秒数,默认 600ms + */ +export async function waitPage( + page: Page, + options: { selector?: string; settle?: number } = {}, +): Promise { + await page.waitForLoadState('domcontentloaded').catch(() => undefined) + if (options.selector) { + await page + .locator(options.selector) + .first() + .waitFor({ state: 'visible', timeout: 20000 }) + .catch(() => undefined) + } + // 等待异步数据请求基本完成后再截图,避免表格/图表空白; + // SSE 页面(运行日志)不会进入空闲,超时忽略即可。 + await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined) + await page.waitForTimeout(options.settle ?? 900) +} + +/** + * 按可见文本点击按钮。 + * + * @param page Page 实例 + * @param text 按钮文本 + * @returns 是否点击成功 + */ +export async function clickText(page: Page, text: string): Promise { + const button = page.locator('button', { hasText: text }).first() + try { + // 按钮可能随异步配置(如 AI 启用状态)延迟渲染,等待其出现 + await button.waitFor({ state: 'visible', timeout: 15000 }) + } catch { + return false + } + // 部分 Naive UI 按钮对 Playwright 合成鼠标事件不响应,改用 DOM 原生 click + await button.evaluate((el) => (el as HTMLElement).click()) + return true +} + +/** + * 登录测试账号并返回 JWT。 + * + * @returns 访问令牌 + */ +export async function login(): Promise { + const res = await axios.post(`${API_URL}/user/login`, { username: USERNAME, password: PASSWORD }) + return res.data.data.token +} + +/** 打开首页并等待(用于非截图场景的简单访问) */ +export async function openHome(page: Page): Promise { + await page.goto(BASE_URL) + await page.waitForTimeout(1500) +} diff --git a/scripts/ts/capture-all.ts b/scripts/ts/capture-all.ts new file mode 100644 index 0000000..bddd986 --- /dev/null +++ b/scripts/ts/capture-all.ts @@ -0,0 +1,43 @@ +// 一键重新截图:覆盖文档站全部配图(整页 + 元素 + 卡片 + 账单解析预览)。 +// +// 可重复执行,重新生成并替换已有截图。运行前请确保: +// - 应用实例已启动且测试库中已配置 AI(用于截取启用 AI 后的界面); +// - scripts/samples/alipay_sample.zip 样例账单存在。 +import { mkdir } from 'node:fs/promises' +import { injectSession, launchBrowser, login, newContext } from './browser.js' +import { captureAiRule, captureBillParse, captureCards, captureEditRecord, captureModals, captureSettingsPanels, captureTrendJump } from './capture-elements.js' +import { capturePages } from './capture-pages.js' +import { ELEM_DIR } from './config.js' + +async function main(): Promise { + await mkdir(ELEM_DIR, { recursive: true }) + const browser = await launchBrowser() + try { + // 1. 未登录状态:登录/注册卡片(独立上下文,不注入登录态) + const guestContext = await newContext(browser, { width: 1440, height: 900 }) + const guestPage = await guestContext.newPage() + await captureCards(guestPage) + await guestContext.close() + + // 2. 登录态上下文:整页 + 弹窗 + 账单解析预览 + const token = await login() + const context = await newContext(browser) + await injectSession(context, token) + const page = await context.newPage() + await capturePages(page) + await captureModals(page) + await captureEditRecord(page) + await captureAiRule(page) + await captureSettingsPanels(page) + await captureBillParse(page) + await captureTrendJump(page) + await context.close() + } finally { + await browser.close() + } +} + +main().catch((err) => { + console.error('FAIL', err) + process.exit(1) +}) diff --git a/scripts/ts/capture-elements.ts b/scripts/ts/capture-elements.ts new file mode 100644 index 0000000..bc7747d --- /dev/null +++ b/scripts/ts/capture-elements.ts @@ -0,0 +1,282 @@ +// 元素截图:截取登录/注册卡片、各功能弹窗与账单解析预览,写入 public/screenshots/elements/。 +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Page } from 'playwright-core' +import { clickText, injectSession, launchBrowser, login, newContext, waitPage } from './browser.js' +import { BASE_URL, ELEM_DIR, SAMPLE_BILL } from './config.js' + +/** 页面就绪标志:列表页表格、设置面板或卡片 */ +const PAGE_READY = '.n-data-table, .config-card, .n-card' + +/** 未登录状态下的卡片截图:文件名、路由、CSS 选择器 */ +const CARD_SCENES: Array<[string, string, string]> = [ + ['login-card', '/login', '.login-container .n-card'], + ['register-card', '/register', '.login-container .n-card'], +] + +/** 弹窗截图:文件名、路由、触发按钮文本 */ +const MODAL_SCENES: Array<[string, string, string]> = [ + ['add-record', '/transactions', '新增记录'], + ['batch-set', '/transactions', '一键设置'], + ['import-bill', '/transactions', '导入账单'], + ['ai-record', '/transactions', '智能记账'], + ['ai-search', '/dashboard', 'AI 搜索'], + ['add-category', '/categories', '新增分类'], + ['add-tag', '/tags', '新增标签'], + ['add-rule', '/rules', '新增规则'], + ['add-email-config', '/email-bills/configs', '新增'], + ['add-channel', '/settings', '添加渠道'], +] + +/** + * 截取登录/注册卡片(未登录状态)。 + * + * @param page Page 实例 + */ +export async function captureCards(page: Page): Promise { + for (const [name, path, selector] of CARD_SCENES) { + try { + await page.setViewportSize({ width: 1440, height: 900 }) + await page.goto(BASE_URL + path) + await waitPage(page, { selector: PAGE_READY }) + // 未登录状态默认语言可能为英文,先切换为中文再刷新 + await page.evaluate(() => localStorage.setItem('locale', 'zh-CN')) + await page.reload() + await waitPage(page, { selector, settle: 800 }) + const card = page.locator(selector).first() + await card.waitFor({ state: 'visible', timeout: 20000 }) + await card.screenshot({ path: resolve(ELEM_DIR, `${name}.png`) }) + console.log(`[OK] ${name}`) + } catch (err) { + console.log(`[FAIL] ${name}: ${err}`) + } + } +} + +/** + * 截取各功能弹窗。 + * + * @param page Page 实例 + */ +export async function captureModals(page: Page): Promise { + for (const [name, path, button] of MODAL_SCENES) { + try { + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.goto(BASE_URL + path) + await waitPage(page, { selector: PAGE_READY }) + if (!(await clickText(page, button))) throw new Error(`未找到按钮:${button}`) + const modal = page.locator('.n-modal').first() + try { + await modal.waitFor({ state: 'visible', timeout: 8000 }) + } catch { + // 首次点击可能未生效(页面状态未就绪),重试一次 + await clickText(page, button) + await modal.waitFor({ state: 'visible', timeout: 15000 }) + } + // 弹窗内的数据(如 AI 规则候选记录)为异步加载,等待请求完成后再截图 + await page.waitForLoadState('networkidle', { timeout: 8000 }).catch(() => undefined) + await page.waitForTimeout(900) + await modal.screenshot({ path: resolve(ELEM_DIR, `${name}.png`) }) + console.log(`[OK] modal/${name}`) + } catch (err) { + console.log(`[FAIL] modal/${name}: ${err}`) + } + } +} + +/** + * 截取账单解析预览:上传样例账单后解析。 + * + * @param page Page 实例 + */ +export async function captureBillParse(page: Page): Promise { + try { + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.goto(BASE_URL + '/transactions') + await waitPage(page, { selector: PAGE_READY }) + if (!(await clickText(page, '导入账单'))) throw new Error('未找到「导入账单」按钮') + const modal = page.locator('.n-modal').first() + await modal.waitFor({ state: 'visible', timeout: 20000 }) + await modal.locator("input[type='file']").setInputFiles(SAMPLE_BILL) + await page.waitForTimeout(800) + await modal.locator('button', { hasText: '解析' }).first().click() + // 等待预览表格出现 + await page.locator('.n-modal .n-data-table').first().waitFor({ state: 'visible', timeout: 40000 }) + await page.waitForTimeout(800) + await modal.screenshot({ path: resolve(ELEM_DIR, 'bill-parse-preview.png') }) + console.log('[OK] modal/bill-parse-preview') + } catch (err) { + console.log(`[FAIL] modal/bill-parse-preview: ${err}`) + } +} + +/** + * 截取「点击趋势柱状图跳转收支记录并自动带入筛选条件」的筛选卡片。 + * + * @param page Page 实例 + */ +export async function captureTrendJump(page: Page): Promise { + try { + await page.setViewportSize({ width: 1720, height: 1000 }) + await page.goto(BASE_URL + '/dashboard') + await waitPage(page, { selector: 'canvas' }) + // 切到「近12月 + 按月」,柱子更宽便于稳定点中;同时清空收支记录筛选偏好,避免旧条件干扰 + await page.evaluate(() => { + localStorage.setItem('finance.dashboard.prefs', JSON.stringify({ + rangeType: '12month', granularity: 'month', filterStatsScope: 'INCLUDED', + filterType: [], filterCategory: [], filterChannel: [], filterTag: [], + filterMinAmount: null, filterMaxAmount: null, filterRemark: '', + })) + localStorage.removeItem('finance.transaction.filterPrefs') + }) + await page.reload() + await waitPage(page, { selector: 'canvas' }) + await page.waitForTimeout(1500) + + // 趋势图是页面首个 canvas;沿 x 轴扫描点击柱体,命中后跳转到收支记录 + const canvas = page.locator('canvas').first() + const box = await canvas.boundingBox() + if (!box) throw new Error('趋势图 canvas 不可见') + const left = box.x + 60 + const right = box.x + box.width - 20 + const y = box.y + box.height - 48 + let jumped = false + for (let x = left + 4; x < right && !jumped; x += 8) { + await page.mouse.click(x, y) + await page.waitForTimeout(60) + if (page.url().includes('/transactions')) jumped = true + } + if (!jumped) throw new Error('点击趋势图未跳转到收支记录') + + await waitPage(page, { selector: '.active-filters' }) + const card = page.locator('.n-card').first() + await card.screenshot({ path: resolve(ELEM_DIR, 'dashboard-trend-jump.png') }) + console.log('[OK] dashboard-trend-jump') + } catch (err) { + console.log(`[FAIL] dashboard-trend-jump: ${err}`) + } +} + +/** 系统设置面板:输出文件名与面板标题 */ +const SETTINGS_PANELS: Array<[string, string]> = [ + ['panel-database', '数据库配置'], + ['panel-registration', '注册设置'], + ['panel-bill-source-stats', '账单来源统计默认值'], + ['panel-display', '外观设置'], + ['panel-ai', 'AI 模型配置'], + ['panel-notification', '定时通知'], +] + +/** + * 截取编辑记录弹窗(点击表格首行的「编辑」)。 + * + * @param page Page 实例 + */ +export async function captureEditRecord(page: Page): Promise { + try { + await page.setViewportSize({ width: 1720, height: 1000 }) + await page.goto(BASE_URL + '/transactions') + await waitPage(page, { selector: PAGE_READY }) + const editBtn = page.locator('tbody tr').first().locator('button', { hasText: '编辑' }).first() + await editBtn.waitFor({ state: 'visible', timeout: 20000 }) + await editBtn.click() + const modal = page.locator('.n-modal').first() + await modal.waitFor({ state: 'visible', timeout: 20000 }) + await page.waitForTimeout(800) + await modal.screenshot({ path: resolve(ELEM_DIR, 'edit-record.png') }) + console.log('[OK] modal/edit-record') + } catch (err) { + console.log(`[FAIL] modal/edit-record: ${err}`) + } +} + +/** + * 截取「AI 生成规则」的两步弹窗:步骤一选择记录、步骤二预览规则建议。 + * + * @param page Page 实例 + */ +export async function captureAiRule(page: Page): Promise { + try { + await page.setViewportSize({ width: 1440, height: 1000 }) + await page.goto(BASE_URL + '/rules') + await waitPage(page, { selector: PAGE_READY }) + if (!(await clickText(page, 'AI 生成规则'))) throw new Error('未找到「AI 生成规则」按钮') + const modal = page.locator('.n-modal').first() + await modal.waitFor({ state: 'visible', timeout: 20000 }) + + // 步骤一:候选记录异步加载,等待数据行出现后再截图 + await modal.locator('.n-data-table-tbody tr').first().waitFor({ state: 'visible', timeout: 20000 }) + await page.waitForTimeout(800) + await modal.screenshot({ path: resolve(ELEM_DIR, 'ai-rule-select.png') }) + console.log('[OK] modal/ai-rule-select') + + // 步骤二:点击「生成规则」,等待 AI 返回并渲染规则建议表 + const generateBtn = modal.locator('button', { hasText: '生成规则' }).first() + await generateBtn.waitFor({ state: 'visible', timeout: 15000 }) + await generateBtn.evaluate((el) => (el as HTMLElement).click()) + // 步骤二特有按钮出现,说明已切换到预览步骤 + await modal.locator('button', { hasText: '创建勾选规则' }).first().waitFor({ state: 'visible', timeout: 60000 }) + await modal.locator('.n-data-table-tbody tr').first().waitFor({ state: 'visible', timeout: 60000 }) + // 等待 AI 请求结束与 loading 遮罩消失 + await page.waitForLoadState('networkidle', { timeout: 60000 }).catch(() => undefined) + await page.waitForTimeout(1000) + await modal.screenshot({ path: resolve(ELEM_DIR, 'ai-rule-preview.png') }) + console.log('[OK] modal/ai-rule-preview') + } catch (err) { + console.log(`[FAIL] modal/ai-rule: ${err}`) + } +} + +/** + * 截取系统设置的六个配置面板。 + * + * @param page Page 实例 + */ +export async function captureSettingsPanels(page: Page): Promise { + await page.setViewportSize({ width: 1440, height: 1200 }) + await page.goto(BASE_URL + '/settings') + await waitPage(page, { selector: PAGE_READY }) + for (const [name, title] of SETTINGS_PANELS) { + try { + const card = page.locator('.config-card', { hasText: title }).first() + await card.waitFor({ state: 'visible', timeout: 20000 }) + await card.scrollIntoViewIfNeeded() + await page.waitForTimeout(500) + await card.screenshot({ path: resolve(ELEM_DIR, `${name}.png`) }) + console.log(`[OK] ${name}`) + } catch (err) { + console.log(`[FAIL] ${name}: ${err}`) + } + } +} + +async function main(): Promise { + const browser = await launchBrowser() + try { + const guestContext = await newContext(browser, { width: 1440, height: 900 }) + const guestPage = await guestContext.newPage() + await captureCards(guestPage) + await guestContext.close() + + const token = await login() + const context = await newContext(browser) + await injectSession(context, token) + const page = await context.newPage() + await captureModals(page) + await captureEditRecord(page) + await captureAiRule(page) + await captureSettingsPanels(page) + await captureBillParse(page) + await captureTrendJump(page) + await context.close() + } finally { + await browser.close() + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error('FAIL', err) + process.exit(1) + }) +} diff --git a/scripts/ts/capture-pages.ts b/scripts/ts/capture-pages.ts new file mode 100644 index 0000000..a142a2c --- /dev/null +++ b/scripts/ts/capture-pages.ts @@ -0,0 +1,66 @@ +// 整页截图:遍历页面清单,截取完整页面并写入 public/screenshots/。 +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import type { Page } from 'playwright-core' +import { injectSession, launchBrowser, login, newContext, waitPage } from './browser.js' +import { BASE_URL, OUT_DIR } from './config.js' + +/** 整页截图清单:文件名、路由、视口宽度、视口高度、关键元素选择器 */ +const PAGES: Array<[string, string, number, number, string]> = [ + ['login', '/login', 1440, 900, '.n-card'], + ['register', '/register', 1440, 900, '.n-card'], + ['dashboard', '/dashboard', 1440, 1280, 'canvas'], + ['transactions', '/transactions', 1720, 1200, '.n-data-table-tbody tr'], + ['categories', '/categories', 1440, 1100, '.n-data-table-tbody tr'], + ['tags', '/tags', 1440, 1100, '.n-data-table-tbody tr'], + ['rules', '/rules', 1440, 1100, '.n-data-table-tbody tr'], + ['email-bills-configs', '/email-bills/configs', 1440, 1000, '.n-data-table-tbody tr'], + ['email-bills-sender-rules', '/email-bills/senderRules', 1440, 1000, '.n-data-table-tbody tr'], + ['email-bills-messages', '/email-bills/messages', 1440, 1000, '.n-data-table-tbody tr'], + ['bill-import', '/bill-import', 1440, 1000, '.n-card'], + ['settings', '/settings', 1440, 1320, '.config-card'], + ['ai-usage', '/ai-usage', 1440, 1000, '.n-card'], + ['logs-http', '/logs/http', 1440, 1000, '.n-data-table-tbody tr'], + ['logs-login', '/logs/login', 1440, 1000, '.n-data-table-tbody tr'], + ['logs-app', '/logs/app', 1440, 1000, '.n-log'], +] + +/** + * 截取全部整页。 + * + * @param page Page 实例 + */ +export async function capturePages(page: Page): Promise { + for (const [name, path, width, height, selector] of PAGES) { + try { + await page.setViewportSize({ width, height }) + await page.goto(BASE_URL + path) + await waitPage(page, { selector }) + await page.screenshot({ path: resolve(OUT_DIR, `${name}.png`) }) + console.log(`[OK] page/${name}`) + } catch (err) { + console.log(`[FAIL] page/${name}: ${err}`) + } + } +} + +async function main(): Promise { + const browser = await launchBrowser() + try { + const token = await login() + const context = await newContext(browser) + await injectSession(context, token) + const page = await context.newPage() + await capturePages(page) + await context.close() + } finally { + await browser.close() + } +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((err) => { + console.error('FAIL', err) + process.exit(1) + }) +} diff --git a/scripts/ts/config.ts b/scripts/ts/config.ts new file mode 100644 index 0000000..24db487 --- /dev/null +++ b/scripts/ts/config.ts @@ -0,0 +1,26 @@ +// 文档站维护脚本的公共配置:访问地址、账号、浏览器与输出路径。 +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const currentDir = dirname(fileURLToPath(import.meta.url)) + +/** 应用访问入口(默认 jar 一体化实例) */ +export const BASE_URL = process.env.FINANCE_WEB ?? 'http://localhost:18082' +/** 后端 API 地址 */ +export const API_URL = process.env.FINANCE_API ?? 'http://localhost:18082/api' +/** 测试账号 */ +export const USERNAME = process.env.FINANCE_USER ?? 'docuser' +export const PASSWORD = process.env.FINANCE_PASSWORD ?? 'doc123456' + +/** Chromium 可执行文件(复用 Playwright 缓存,无需系统安装) */ +export const CHROMIUM_BIN = + process.env.CHROMIUM_BIN ?? '/home/neko/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome' + +/** doc-site 根目录 */ +export const ROOT = resolve(currentDir, '..', '..') +/** 整页截图输出目录 */ +export const OUT_DIR = resolve(ROOT, 'public', 'screenshots') +/** 元素截图输出目录 */ +export const ELEM_DIR = resolve(OUT_DIR, 'elements') +/** 样例账单文件(虚构数据、未导入,用于「账单解析预览」截图) */ +export const SAMPLE_BILL = resolve(ROOT, 'scripts', 'samples', 'alipay_fake_preview.csv') diff --git a/scripts/ts/fetch-refs.ts b/scripts/ts/fetch-refs.ts new file mode 100644 index 0000000..a3fb422 --- /dev/null +++ b/scripts/ts/fetch-refs.ts @@ -0,0 +1,46 @@ +// 使用无头浏览器抓取指定页面的可见文本并保存到 refs/。 +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { launchBrowser, newContext } from './browser.js' +import { ROOT } from './config.js' + +/** 待抓取的参考资料:文件名与 URL */ +const URLS: Array<[string, string]> = [ + ['deepseek_share', 'https://chat.deepseek.com/share/mwnjui93cvhspkrs1z'], +] + +const OUT_DIR = resolve(ROOT, 'refs') + +async function main(): Promise { + await mkdir(OUT_DIR, { recursive: true }) + const browser = await launchBrowser() + const context = await newContext(browser, { width: 1440, height: 1200 }) + const page = await context.newPage() + try { + for (const [name, url] of URLS) { + try { + await page.goto(url) + await page.waitForTimeout(10000) + for (let i = 0; i < 6; i++) { + await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight / 6)) + await page.waitForTimeout(1500) + } + await page.evaluate(() => window.scrollTo(0, 0)) + await page.waitForTimeout(1000) + const text = await page.evaluate(() => document.body.innerText) + await writeFile(resolve(OUT_DIR, `${name}.txt`), text, 'utf8') + console.log(`[OK] ${name} -> ${text.length} chars`) + } catch (err) { + console.log(`[FAIL] ${name}: ${err}`) + } + } + } finally { + await context.close() + await browser.close() + } +} + +main().catch((err) => { + console.error('FAIL', err) + process.exit(1) +}) diff --git a/scripts/ts/make-fake-bills.ts b/scripts/ts/make-fake-bills.ts new file mode 100644 index 0000000..335169c --- /dev/null +++ b/scripts/ts/make-fake-bills.ts @@ -0,0 +1,412 @@ +// 生成虚构的支付宝账单样例(用于演示数据与截图,全部为虚假数据)。 +// +// 输出两份文件(UTF-8),格式与支付宝「交易记录明细查询」一致,可被 AlipayBillParser 解析: +// - alipay_fake.csv:导入测试库,作为演示交易数据; +// - alipay_fake_preview.csv:交易号不同、未导入,用于截取「账单解析预览」(显示为可导入)。 +import { writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import ExcelJS from 'exceljs' +import { ROOT } from './config.js' + +const OUT = resolve(ROOT, 'scripts', 'samples', 'alipay_fake.csv') +const OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'alipay_fake_preview.csv') + +// 虚构淘宝订单样例(xlsx):导入用与预览用各一份,全部为虚假数据。 +const TAOBAO_OUT = resolve(ROOT, 'scripts', 'samples', 'taobao_fake.xlsx') +const TAOBAO_OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'taobao_fake_preview.xlsx') + +// 虚构京东交易流水样例(CSV):导入用与预览用各一份,全部为虚假数据。 +const JD_OUT = resolve(ROOT, 'scripts', 'samples', 'jd_fake.csv') +const JD_OUT_PREVIEW = resolve(ROOT, 'scripts', 'samples', 'jd_fake_preview.csv') + +/** 虚构支出商户:交易对方、商品名称、金额区间 [最小, 最大] */ +const EXPENSE_MERCHANTS: Array<[string, string, number, number]> = [ + ['星巴克', '拿铁咖啡', 28, 45], + ['瑞幸咖啡', '生椰拿铁', 15, 25], + ['滴滴出行', '快车行程', 12, 68], + ['麦当劳', '午餐套餐', 22, 55], + ['肯德基', '全家桶', 35, 99], + ['美团外卖', '外卖订单', 18, 68], + ['京东商城', '日用品', 39, 299], + ['淘宝网', '服饰鞋包', 59, 499], + ['永辉超市', '生鲜食品', 35, 220], + ['屈臣氏', '洗护用品', 25, 160], + ['万达影城', '电影票', 35, 80], + ['中国移动', '话费充值', 50, 100], + ['国家电网', '电费缴纳', 60, 200], + ['中石化加油站', '加油', 200, 400], + ['一兆韦德健身房', '健身月卡', 199, 399], + ['当当网', '图书', 30, 120], + ['网易云音乐', '会员订阅', 15, 25], + ['顺丰速运', '快递费', 12, 30], + ['海底捞', '聚餐', 120, 360], + ['全家便利店', '零食饮料', 8, 45], +] + +/** 虚构收入来源:交易对方、商品名称、金额区间 */ +const INCOME_SOURCES: Array<[string, string, number, number]> = [ + ['某某科技有限公司', '工资', 12000, 18000], + ['某某科技有限公司', '季度奖金', 3000, 8000], + ['李娜', '转账', 100, 800], + ['王芳', '还款', 50, 500], +] + +/** 虚构人名(用于转账类支出) */ +const PEOPLE = ['张伟', '李娜', '王芳', '刘洋', '陈静', '赵磊', '孙悦', '周涛'] + +/** 补零 */ +function pad(n: number): string { + return n < 10 ? `0${n}` : `${n}` +} + +/** 格式化时间为 yyyy-MM-dd HH:mm:ss */ +function fmt(d: Date): string { + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad( + d.getMinutes(), + )}:${pad(d.getSeconds())}` +} + +/** 确定性伪随机(保证多次生成结果一致) */ +function makeRandom(seed: number): () => number { + let state = seed + return () => { + state = (state * 1103515245 + 12345) & 0x7fffffff + return state / 0x7fffffff + } +} + +/** 生成一条账单数据行 */ +function buildRow( + seq: number, + time: Date, + counterparty: string, + product: string, + amount: number, + direction: '支出' | '收入' | '不计收支', + fundStatus: string, + prefix: string, +): string { + const tradeNo = `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String( + 100000 + seq, + ).slice(-6)}` + const t = fmt(time) + const status = direction === '不计收支' ? '退款成功' : '交易成功' + const cols = [ + tradeNo, + `M${tradeNo}`, + t, + t, + t, + '', + '即时到账', + counterparty, + product, + amount.toFixed(2), + direction, + status, + '0.00', + '0.00', + '', + fundStatus, + ] + return cols.join(',') +} + +/** + * 生成一份虚构账单文本。 + * + * @param seed 伪随机种子 + * @param prefix 交易号前缀(区分不同文件) + * @param startMonth 起始月份(1-12) + * @param months 覆盖月数 + * @returns 账单文本 + */ +function buildCsv(seed: number, prefix: string, startMonth: number, months: number): string { + const rand = makeRandom(seed) + const lines: string[] = [ + '支付宝交易记录明细查询', + '账号:[demo@example.com]', + '起始日期:[2026-01-01 00:00:00] 终止日期:[2026-12-31 00:00:00]', + '---------------------------------交易记录明细列表------------------------------------', + '交易号,商家订单号,交易创建时间,付款时间,最近修改时间,交易来源地,类型,交易对方,商品名称,金额(元),收/支,交易状态,服务费(元),成功退款(元),备注,资金状态', + ] + + let seq = 0 + for (let m = 0; m < months; m++) { + const month = startMonth + m + const count = 28 + Math.floor(rand() * 7) + for (let i = 0; i < count; i++) { + seq++ + const day = 1 + Math.floor(rand() * 27) + const hour = 7 + Math.floor(rand() * 15) + const minute = Math.floor(rand() * 60) + const time = new Date(2026, month - 1, day, hour, minute, Math.floor(rand() * 60)) + const roll = rand() + if (roll < 0.06) { + const [cp, prod, lo, hi] = INCOME_SOURCES[Math.floor(rand() * INCOME_SOURCES.length)] + const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 + lines.push(buildRow(seq, time, cp, prod, amount, '收入', '已收入', prefix)) + } else if (roll < 0.09) { + const [cp, prod, lo, hi] = EXPENSE_MERCHANTS[Math.floor(rand() * EXPENSE_MERCHANTS.length)] + const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 + lines.push(buildRow(seq, time, cp, prod, amount, '不计收支', '已支出', prefix)) + } else { + const [cp, prod, lo, hi] = EXPENSE_MERCHANTS[Math.floor(rand() * EXPENSE_MERCHANTS.length)] + const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 + lines.push(buildRow(seq, time, cp, prod, amount, '支出', '已支出', prefix)) + } + } + } + + // 若干转账类支出(虚构人名) + for (let i = 0; i < 6; i++) { + seq++ + const day = 3 + i * 25 + const time = new Date(2026, startMonth + 1, day > 28 ? 28 : day, 20, 30, 0) + const person = PEOPLE[i % PEOPLE.length] + lines.push(buildRow(seq, time, person, '转账', 200 + i * 50, '支出', '已支出', prefix)) + } + + lines.push('------------------------------------------------------------------------------------') + lines.push(`共${seq}笔记录`) + return lines.join('\n') +} + +/** 淘宝订单数据表头(与淘宝导出列一致) */ +const TAOBAO_HEADER = [ + '订单号', + '订单提交时间', + '订单状态', + '店铺名称', + '商品名称', + '商品链接', + '型号款式', + '商品数量', + '商品金额', + '实付金额', + '运费', + '物流公司(当前仅支持未完结订单)', + '物流单号(当前仅支持未完结订单)', +] + +/** 虚构淘宝店铺与商品:[店铺名, 商品名, 型号, 单价下限, 单价上限] */ +const TAOBAO_SHOPS: Array<[string, string, string, number, number]> = [ + ['优选数码专营店', '蓝牙接收发射器 二合一', '蓝牙5.3适配器', 15, 60], + ['居家良品旗舰店', '加厚收纳箱 特大号', '150# 奶油白', 40, 130], + ['潮流服饰专营店', '夏季冰丝家居服套装', '雾霾蓝 XL', 45, 99], + ['乐活食品旗舰店', '每日坚果混合装', '30 包整箱', 30, 120], + ['清饮茶业旗舰店', '冷萃乌龙茶包', '混合口味 20 袋', 20, 80], + ['简造数码企业店', '手机壳 磨砂防摔', '透明款', 12, 35], + ['趣玩玩具旗舰店', '益智积木拼装', '1000 颗粒', 59, 199], + ['洁家日用旗舰店', '一次性丁腈手套', 'M 码 100 只', 15, 45], +] + +/** 淘宝订单状态分布:交易成功居多,少量交易关闭与卖家已发货 */ +function pickTaobaoStatus(rand: () => number): string { + const roll = rand() + if (roll < 0.1) return '交易关闭' + if (roll < 0.14) return '卖家已发货' + return '交易成功' +} + +/** + * 生成一份虚构的淘宝订单 xlsx。 + * + * @param path 输出文件路径 + * @param seed 伪随机种子 + * @param prefix 订单号前缀(区分不同文件) + * @param startMonth 起始月份(1-12) + * @param months 覆盖月数 + */ +async function buildTaobaoFile( + path: string, + seed: number, + prefix: string, + startMonth: number, + months: number, +): Promise { + const rand = makeRandom(seed) + const workbook = new ExcelJS.Workbook() + const sheet = workbook.addWorksheet('订单数据') + sheet.addRow(TAOBAO_HEADER) + + let seq = 0 + for (let m = 0; m < months; m++) { + const month = startMonth + m + const count = 10 + Math.floor(rand() * 6) + for (let i = 0; i < count; i++) { + seq++ + const day = 1 + Math.floor(rand() * 27) + const hour = 8 + Math.floor(rand() * 14) + const time = new Date(2026, month - 1, day, hour, Math.floor(rand() * 60), Math.floor(rand() * 60)) + const [shop, product, model, lo, hi] = TAOBAO_SHOPS[Math.floor(rand() * TAOBAO_SHOPS.length)] + const status = pickTaobaoStatus(rand) + + // 每个订单包含 1-3 个商品,多商品订单使用合并单元格(仅首行有订单级字段) + const itemCount = 1 + Math.floor(rand() * 3) + const items: Array<[string, string, number, number]> = [] + let itemsTotal = 0 + for (let k = 0; k < itemCount; k++) { + const quantity = 1 + Math.floor(rand() * 2) + const unitPrice = Math.round((lo + rand() * (hi - lo)) * 100) / 100 + const name = k === 0 ? product : `${product} 配件${k}` + items.push([name, model, quantity, unitPrice]) + itemsTotal += quantity * unitPrice + } + const shipping = rand() < 0.3 ? 2.5 : 0 + // 卖家已发货视为未完成,实付金额为 0 + const paid = status === '卖家已发货' ? 0 : Math.round((itemsTotal + shipping) * 100) / 100 + const orderNo = `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String( + 100000 + seq, + ).slice(-6)}` + const submitTime = fmt(time) + + const firstRow = sheet.rowCount + 1 + items.forEach(([name, spec, quantity, unitPrice], idx) => { + sheet.addRow([ + idx === 0 ? orderNo : null, + idx === 0 ? submitTime : null, + idx === 0 ? status : null, + idx === 0 ? shop : null, + name, + `https://item.taobao.com/item.htm?id=${1000000000000 + seq * 10 + idx}`, + spec, + quantity, + unitPrice, + idx === 0 ? paid : null, + idx === 0 ? shipping : null, + idx === 0 ? '圆通速递' : null, + idx === 0 ? `YT${orderNo.slice(-12)}` : null, + ]) + }) + // 多商品订单合并订单级列(订单号、提交时间、状态、店铺、实付金额、运费) + if (itemCount > 1) { + const lastRow = sheet.rowCount + for (const col of [1, 2, 3, 4, 10, 11]) { + sheet.mergeCells(firstRow, col, lastRow, col) + } + } + } + } + await workbook.xlsx.writeFile(path) +} + +/** 虚构京东消费商户:[商户名称, 交易说明, 交易分类, 金额下限, 金额上限] */ +const JD_MERCHANTS: Array<[string, string, string, number, number]> = [ + ['京东平台商户', '卫加猫咪零食猫条整箱混合口味', '其他网购', 20, 60], + ['京东平台商户', 'tritan塑料水杯大容量便携运动水壶', '日用百货', 20, 60], + ['京东超市', '抽纸整箱家用实惠装', '食品饮料', 30, 90], + ['京东自营', '蓝牙耳机 无线降噪', '数码家电', 80, 400], + ['京东平台商户', '洗衣液 大瓶装补充装', '日用百货', 25, 80], + ['京东自营', '坚果零食大礼包', '食品饮料', 40, 130], +] + +/** 京东流水表头 */ +const JD_HEADER = '交易时间,商户名称,交易说明,金额,收/付款方式,交易状态,收/支,交易分类,交易订单号,商家订单号,备注' + +/** + * 生成一份虚构的京东交易流水 CSV。 + * + * @param seed 伪随机种子 + * @param prefix 交易订单号前缀(区分不同文件) + * @param startMonth 起始月份(1-12) + * @param months 覆盖月数 + * @returns 账单文本(含 BOM) + */ +function buildJdCsv(seed: number, prefix: string, startMonth: number, months: number): string { + const rand = makeRandom(seed) + const dataLines: string[] = [] + let seq = 0 + const counts = { income: 0, expense: 0, neutral: 0 } + const sums = { income: 0, expense: 0, neutral: 0 } + const nextOrderNo = (time: Date): string => + `${prefix}${time.getFullYear()}${pad(time.getMonth() + 1)}${pad(time.getDate())}${String(100000 + seq).slice(-6)}` + + const push = (time: Date, merchant: string, desc: string, amount: number, pay: string, status: string, direction: string, category: string, remark: string) => { + const orderNo = nextOrderNo(time) + dataLines.push( + [fmt(time), merchant, desc, amount.toFixed(2), pay, status, direction, category, orderNo, `112${orderNo}`, remark].join(','), + ) + if (direction === '收入') { + counts.income++ + sums.income += amount + } else if (direction === '不计收支') { + counts.neutral++ + sums.neutral += amount + } else { + counts.expense++ + sums.expense += amount + } + } + + for (let m = 0; m < months; m++) { + const month = startMonth + m + const count = 6 + Math.floor(rand() * 5) + for (let i = 0; i < count; i++) { + seq++ + const day = 1 + Math.floor(rand() * 27) + const hour = 8 + Math.floor(rand() * 14) + const time = new Date(2026, month - 1, day, hour, Math.floor(rand() * 60), Math.floor(rand() * 60)) + const [merchant, desc, category, lo, hi] = JD_MERCHANTS[Math.floor(rand() * JD_MERCHANTS.length)] + const amount = Math.round((lo + rand() * (hi - lo)) * 100) / 100 + const pay = rand() < 0.6 ? '京东白条' : rand() < 0.5 ? '京东小金库' : '招商银行储蓄卡(3702)' + push(time, merchant, desc, amount, pay, '交易成功', '支出', category, '计入当月账单,还款日次月28日') + } + // 每月一笔白条还款与一笔小金库转入(不计收支) + seq++ + const repayTime = new Date(2026, month - 1, 25, 19, 5, 37) + const repayAmount = Math.round((500 + rand() * 800) * 100) / 100 + push(repayTime, '京东白条', '白条主动还款', repayAmount, '京东小金库', '还款成功', '不计收支', '白条', '还款至当月账单') + seq++ + const transferTime = new Date(2026, month - 1, 25, 19, 5, 36) + push(transferTime, '京东小金库', '京东小金库-转入', repayAmount, '招商银行储蓄卡(3702)', '交易成功', '不计收支', '小金库', ' ') + } + // 一笔虚构退款(收入) + seq++ + push(new Date(2026, startMonth, 12, 10, 30, 0), '京东平台商户', '蓝牙耳机 退款', 129.0, '京东白条', '退款成功', '收入', '数码家电', '全额退款') + + const total = counts.income + counts.expense + counts.neutral + const header = [ + '\uFEFF导出信息:', + '京东账号名:demo_jd_user', + '申请时间:2026-09-21 12:28:54', + `日期区间:2026-${pad(startMonth)}-01 至 2026-${pad(startMonth + months)}-21`, + '导出交易类型:全部', + '导出交易场景:全部', + `共:${total}笔记录`, + `收入:${counts.income}笔,${sums.income.toFixed(2)}元`, + `支出:${counts.expense}笔,${sums.expense.toFixed(2)}元`, + `不计收支:${counts.neutral}笔,${sums.neutral.toFixed(2)}元`, + '', + '特别提示', + '1.本明细为每笔订单支付的明细,不包括已删除的记录;', + '2.个人资金互转、全额退款等记为【不计收支】类;', + '3.本明细仅供个人对账使用。', + '', + JD_HEADER, + ] + return [...header, ...dataLines].join('\n') +} + +async function main(): Promise { + // 导入用:2026-03 ~ 2026-09 + const imported = buildCsv(20260920, '1', 3, 7) + await writeFile(OUT, imported, 'utf8') + // 预览用:2026-01 ~ 2026-03,交易号不同(未导入) + const preview = buildCsv(20260101, '9', 1, 3) + await writeFile(OUT_PREVIEW, preview, 'utf8') + // 淘宝订单(xlsx):导入用与预览用各一份 + await buildTaobaoFile(TAOBAO_OUT, 20260921, '1', 3, 7) + await buildTaobaoFile(TAOBAO_OUT_PREVIEW, 20260101, '9', 1, 3) + // 京东交易流水(CSV):导入用与预览用各一份(订单号前缀与淘宝样例区分,避免演示数据去重冲突) + await writeFile(JD_OUT, buildJdCsv(20260921, '2', 3, 7), 'utf8') + await writeFile(JD_OUT_PREVIEW, buildJdCsv(20260101, '8', 1, 3), 'utf8') + console.log( + `已生成虚构账单:\n - ${OUT}\n - ${OUT_PREVIEW}\n - ${TAOBAO_OUT}\n - ${TAOBAO_OUT_PREVIEW}\n - ${JD_OUT}\n - ${JD_OUT_PREVIEW}`, + ) +} + +main().catch((err) => { + console.error('FAIL', err) + process.exit(1) +}) diff --git a/scripts/ts/search-refs.ts b/scripts/ts/search-refs.ts new file mode 100644 index 0000000..8412e44 --- /dev/null +++ b/scripts/ts/search-refs.ts @@ -0,0 +1,48 @@ +// 使用无头浏览器检索资料:按关键词搜索并保存结果页可见文本到 refs/。 +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { launchBrowser, newContext } from './browser.js' +import { ROOT } from './config.js' + +/** 待检索的关键词:输出文件名与搜索词 */ +const SEARCHES: Array<[string, string]> = [ + ['bing_alipay2', '支付宝 我的 账单 开具交易流水证明 下载 csv 邮箱'], + ['bing_meituan2', '美团App 我的 钱包 账单 下载账单 导出 3个月'], + ['bing_bocom2', '交通银行 个人手机银行 我的账户 交易明细 电子版 导出'], +] + +const OUT_DIR = resolve(ROOT, 'refs') + +async function main(): Promise { + await mkdir(OUT_DIR, { recursive: true }) + const browser = await launchBrowser() + const context = await newContext(browser, { width: 1440, height: 1400 }) + const page = await context.newPage() + try { + for (const [name, keyword] of SEARCHES) { + const url = `https://cn.bing.com/search?q=${encodeURIComponent(keyword)}&setlang=zh-CN` + try { + await page.goto(url) + await page.waitForTimeout(6000) + for (let i = 0; i < 4; i++) { + await page.evaluate(() => window.scrollBy(0, document.body.scrollHeight / 4)) + await page.waitForTimeout(1000) + } + const text = await page.evaluate(() => document.body.innerText) + await writeFile(resolve(OUT_DIR, `${name}.txt`), text, 'utf8') + console.log(`[OK] ${name} -> ${text.length} chars`) + } catch (err) { + console.log(`[FAIL] ${name}: ${err}`) + } + await page.waitForTimeout(8000) + } + } finally { + await context.close() + await browser.close() + } +} + +main().catch((err) => { + console.error('FAIL', err) + process.exit(1) +}) diff --git a/scripts/ts/seed-data.ts b/scripts/ts/seed-data.ts new file mode 100644 index 0000000..abf381c --- /dev/null +++ b/scripts/ts/seed-data.ts @@ -0,0 +1,104 @@ +// 向测试环境灌入演示数据:登录测试账号,解析并确认导入样例账单。 +import { readFile } from 'node:fs/promises' +import { basename, join, resolve } from 'node:path' +import axios from 'axios' +import { login } from './browser.js' +import { API_URL, ROOT } from './config.js' + +/** 样例账单目录(doc-site 内的虚构样例,全部为虚假数据,禁止使用真实账单) */ +const BILL_DIR = resolve(ROOT, 'scripts', 'samples') + +/** 待导入样例:账单类型、相对路径、解压密码(仅使用虚构数据) */ +const SAMPLES: Array<[string, string, string | null]> = [ + ['ALIPAY', 'alipay_fake.csv', null], + ['TAOBAO', 'taobao_fake.xlsx', null], + ['JD', 'jd_fake.csv', null], +] + +interface ParsedRow { + externalNo: string + transactionTime: string + type: string + amount: number + counterparty?: string | null + product?: string | null + includeInStats?: boolean + importable?: boolean + rawData?: string | null +} + +/** + * 调用解析接口解析账单文件。 + * + * @param token 访问令牌 + * @param billType 账单类型 + * @param path 账单文件路径 + * @param password 解压密码 + * @returns 解析结果数据 + */ +async function parseBill(token: string, billType: string, path: string, password: string | null): Promise { + const buf = await readFile(path) + const form = new FormData() + form.append('file', new Blob([buf]), basename(path)) + form.append('billType', billType) + if (password) form.append('password', password) + const res = await axios.post(`${API_URL}/transaction/import/parse`, form, { + headers: { Authorization: `Bearer ${token}` }, + timeout: 120000, + }) + if (res.data.code !== 200) throw new Error(res.data.message) + return res.data.data +} + +/** + * 确认导入解析出的账单行。 + * + * @param token 访问令牌 + * @param billType 账单类型 + * @param rows 解析结果行 + * @returns 导入结果数据 + */ +async function confirmBill(token: string, billType: string, rows: ParsedRow[]): Promise { + const items = rows + .filter((r) => r.importable !== false) + .map((r) => ({ + externalNo: r.externalNo, + transactionTime: r.transactionTime, + type: r.type, + amount: r.amount, + counterparty: r.counterparty ?? null, + product: r.product ?? null, + includeInStats: r.includeInStats ?? true, + rawData: r.rawData ?? null, + })) + const res = await axios.post( + `${API_URL}/transaction/import/confirm`, + { billType, items }, + { headers: { Authorization: `Bearer ${token}` }, timeout: 120000 }, + ) + if (res.data.code !== 200) throw new Error(res.data.message) + return res.data.data +} + +async function main(): Promise { + const token = await login() + console.log('登录成功') + for (const [billType, relPath, password] of SAMPLES) { + const path = join(BILL_DIR, relPath) + try { + const data = await parseBill(token, billType, path, password) + const rows: ParsedRow[] = data.rows ?? [] + const importable = rows.filter((r) => r.importable !== false) + console.log(`[解析成功] ${billType}: 共 ${rows.length} 行,可导入 ${importable.length} 行`) + const result = await confirmBill(token, billType, rows) + console.log(`[导入成功] ${billType}: ${JSON.stringify(result)}`) + } catch (err) { + console.log(`[失败] ${billType}: ${err}`) + } + } +} + +main().catch((err) => { + console.error('FAIL', err) + process.exit(1) +}) diff --git a/scripts/ts/seed-rules.ts b/scripts/ts/seed-rules.ts new file mode 100644 index 0000000..6a24a63 --- /dev/null +++ b/scripts/ts/seed-rules.ts @@ -0,0 +1,165 @@ +// 向测试环境补充标签与自定义规则演示数据。 +import axios from 'axios' +import { login } from './browser.js' +import { API_URL } from './config.js' + +/** 需要补充的标签:名称与颜色 */ +const TAGS: Array<{ name: string; color: string }> = [ + { name: '咖啡', color: '#8B5A2B' }, + { name: '外卖', color: '#F97316' }, +] + +/** + * 创建标签并返回其主键。 + * + * @param token 访问令牌 + * @param name 标签名称 + * @param color 标签颜色 + * @returns 标签主键 + */ +async function createTag(token: string, name: string, color: string): Promise { + const headers = { Authorization: `Bearer ${token}` } + const res = await axios.post(`${API_URL}/tag`, { name, color }, { headers }) + console.log(`标签 ${name} -> ${res.data.code}`) + // 创建接口不返回主键,统一从列表中按名称查找 + const list = await axios.get(`${API_URL}/tag/list`, { headers }) + const found = (list.data.data as Array<{ id: number; name: string }>).find((t) => t.name === name) + if (!found) throw new Error(`未找到标签: ${name}`) + return found.id +} + +/** + * 创建自定义归类规则。 + * + * @param token 访问令牌 + * @param payload 规则请求体 + */ +async function createRule(token: string, payload: Record): Promise { + const res = await axios.post(`${API_URL}/rule`, payload, { headers: { Authorization: `Bearer ${token}` } }) + console.log(`规则 ${payload.name} -> ${res.data.code}`) +} + +/** + * 按名称查询分类主键。 + * + * @param token 访问令牌 + * @param name 分类名称 + * @returns 分类主键 + */ +async function getCategoryId(token: string, name: string): Promise { + const headers = { Authorization: `Bearer ${token}` } + const res = await axios.get(`${API_URL}/category/list`, { headers }) + const found = (res.data.data as Array<{ id: number; name: string }>).find((c) => c.name === name) + if (!found) throw new Error(`未找到分类: ${name}`) + return found.id +} + +/** + * 按名称查询标签主键,不存在时创建。 + * + * @param token 访问令牌 + * @param name 标签名称 + * @param color 标签颜色 + * @returns 标签主键 + */ +async function getTagId(token: string, name: string, color: string): Promise { + const headers = { Authorization: `Bearer ${token}` } + const res = await axios.get(`${API_URL}/tag/list`, { headers }) + const found = (res.data.data as Array<{ id: number; name: string }>).find((t) => t.name === name) + if (found) return found.id + return createTag(token, name, color) +} + +/** 交易记录(用于给若干记录打标签,供「AI 生成规则」截图使用) */ +interface TransactionRow { + id: number + categoryId: number | null + type: string + amount: number + channel: string | null + remark: string | null + transactionTime: string + includeInStats: boolean +} + +/** + * 给若干记录打上标签,便于「AI 生成规则」按已打标签记录归纳。 + * + * @param token 访问令牌 + * @param coffeeId 咖啡标签主键 + * @param takeoutId 外卖标签主键 + * @param bigAmountId 大额消费标签主键 + */ +async function tagSomeTransactions( + token: string, + coffeeId: number, + takeoutId: number, + bigAmountId: number, +): Promise { + const headers = { Authorization: `Bearer ${token}` } + const res = await axios.get(`${API_URL}/transaction/page`, { headers, params: { page: 1, size: 50 } }) + const rows = (res.data.data?.records ?? []) as TransactionRow[] + let tagged = 0 + for (const row of rows) { + if (tagged >= 6) break + const remark = row.remark ?? '' + let tagIds: number[] = [] + if (remark.includes('星巴克') || remark.includes('瑞幸') || remark.includes('咖啡')) { + tagIds = [coffeeId] + } else if (remark.includes('美团') || remark.includes('外卖') || remark.includes('饿了么')) { + tagIds = [takeoutId] + } else if (Number(row.amount) >= 500) { + tagIds = [bigAmountId] + } + if (tagIds.length === 0) continue + await axios.put(`${API_URL}/transaction/${row.id}`, { + categoryId: row.categoryId, + type: row.type, + amount: row.amount, + channel: row.channel, + remark: row.remark, + transactionTime: row.transactionTime, + includeInStats: row.includeInStats, + tagIds, + }, { headers }) + tagged++ + } + console.log(`已为 ${tagged} 条记录打标签`) +} + +/** 清空现有规则,避免重复创建 */ +async function clearRules(token: string): Promise { + const headers = { Authorization: `Bearer ${token}` } + const res = await axios.get(`${API_URL}/rule/list`, { headers }) + for (const rule of res.data.data as Array<{ id: number }>) { + await axios.delete(`${API_URL}/rule/${rule.id}`, { headers }) + } +} + +async function main(): Promise { + const token = await login() + await clearRules(token) + const coffeeId = await getTagId(token, '咖啡', '#8B5A2B') + const takeoutId = await getTagId(token, '外卖', '#F97316') + const bigAmountId = await getTagId(token, '大额消费', '#F97316') + const diningId = await getCategoryId(token, '餐饮') + const transportId = await getCategoryId(token, '交通') + console.log('标签:', { 咖啡: coffeeId, 外卖: takeoutId, 大额消费: bigAmountId }) + + const rules: Array> = [ + { name: '星巴克归入餐饮', enabled: true, type: 'EXPENSE', keyword: '星巴克', categoryId: diningId, tagIds: [coffeeId] }, + { name: '滴滴出行归入交通', enabled: true, type: 'EXPENSE', keyword: '滴滴', categoryId: transportId }, + { name: '大额支出标记', enabled: true, type: 'EXPENSE', minAmount: 500, tagIds: [bigAmountId] }, + ] + for (const rule of rules) { + await createRule(token, rule) + } + + // 给若干记录打标签,供「AI 生成规则」截图 + await tagSomeTransactions(token, coffeeId, takeoutId, bigAmountId) +} + +main().catch((err) => { + console.error('FAIL', err) + process.exit(1) +}) diff --git a/scripts/ts/test-browser.ts b/scripts/ts/test-browser.ts new file mode 100644 index 0000000..580cbbc --- /dev/null +++ b/scripts/ts/test-browser.ts @@ -0,0 +1,26 @@ +// 最小验证脚本:确认 Playwright + Chromium 能访问应用并截图。 +import { launchBrowser, newContext } from './browser.js' +import { BASE_URL } from './config.js' + +const OUT = '/tmp/opencode/ts-browser-test.png' + +async function main(): Promise { + const browser = await launchBrowser() + const context = await newContext(browser, { width: 1440, height: 900 }) + const page = await context.newPage() + try { + await page.goto(BASE_URL) + await page.waitForTimeout(4000) + const title = await page.title() + await page.screenshot({ path: OUT }) + console.log('OK title=', title, 'saved=', OUT) + } finally { + await context.close() + await browser.close() + } +} + +main().catch((err) => { + console.error('FAIL', err) + process.exit(1) +}) diff --git a/scripts/ts/upload-oss.ts b/scripts/ts/upload-oss.ts new file mode 100644 index 0000000..ce95c4f --- /dev/null +++ b/scripts/ts/upload-oss.ts @@ -0,0 +1,268 @@ +// 将 public/screenshots 下的文档配图上传到阿里云 OSS。 +// +// 流程:读取 .env.local 配置 -> STS AssumeRole 换取临时凭证 -> 遍历本地截图 -> +// 逐张上传到 /<相对路径> -> HEAD 校验远端大小 -> 打印公共访问 URL。 +// +// 凭证来自 .env.local(不纳入版本管理),也可用同名环境变量覆盖。 +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join, relative, resolve, sep } from 'node:path' +import { fileURLToPath } from 'node:url' +import OSS from 'ali-oss' +import type { AssumeRoleRequest as AssumeRoleRequestType } from '@alicloud/sts20150401' +import { $OpenApiUtil } from '@alicloud/openapi-core' + +// @alicloud/sts20150401 为 CommonJS 包,其类型声明在 NodeNext 下无法直接解析为构造签名, +// 故用 createRequire 加载并显式声明最小类型。 +const require = createRequire(import.meta.url) +const stsSdk = require('@alicloud/sts20150401') as { + default: StsClientConstructor + AssumeRoleRequest: new (options: { + roleArn: string + roleSessionName: string + durationSeconds: number + }) => AssumeRoleRequestType +} +const StsClient = stsSdk.default +const AssumeRoleRequest = stsSdk.AssumeRoleRequest + +const currentDir = dirname(fileURLToPath(import.meta.url)) +/** doc-site 根目录 */ +const ROOT = resolve(currentDir, '..', '..') +/** 截图输出目录(截图脚本的产物,也是本脚本的上传源) */ +const SCREENSHOT_DIR = resolve(ROOT, 'public', 'screenshots') +/** 本地凭证文件(优先级低于环境变量) */ +const ENV_FILE = resolve(ROOT, '.env.local') +/** STS 服务接入点 */ +const STS_ENDPOINT = 'sts.aliyuncs.com' +/** 上传并发数 */ +const CONCURRENCY = 5 + +/** OSS 上传配置 */ +interface OssConfig { + /** 调用 STS 的长期访问密钥 ID */ + accessKeyId: string + /** 调用 STS 的长期访问密钥 Secret */ + accessKeySecret: string + /** 被扮演的 RAM 角色 ARN */ + roleArn: string + /** 目标桶名 */ + bucket: string + /** 区域(如 cn-beijing) */ + region: string + /** 对象键前缀(截图统一放置其下) */ + keyPrefix: string +} + +/** STS 临时凭证 */ +interface StsCredentials { + /** 临时访问密钥 ID */ + accessKeyId: string + /** 临时访问密钥 Secret */ + accessKeySecret: string + /** 临时安全令牌 */ + securityToken: string + /** 过期时间(UTC) */ + expiration: string +} + +/** STS AssumeRole 响应体(仅声明用到的字段) */ +interface AssumeRoleResult { + body?: { + credentials?: { + accessKeyId?: string + accessKeySecret?: string + securityToken?: string + expiration?: string + } + } +} + +/** STS 客户端实例的最小签名 */ +interface StsClientInstance { + assumeRole(request: AssumeRoleRequestType): Promise +} + +/** STS 客户端构造函数 */ +type StsClientConstructor = new (config: $OpenApiUtil.Config) => StsClientInstance + +/** + * 解析 .env 文件为键值对,忽略空行与以 # 开头的注释。 + * + * @param filePath .env 文件路径 + * @returns 键值对映射 + */ +function parseEnvFile(filePath: string): Record { + const result: Record = {} + for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/)) { + const trimmed = line.trim() + if (!trimmed || trimmed.startsWith('#')) continue + const index = trimmed.indexOf('=') + if (index === -1) continue + const key = trimmed.slice(0, index).trim() + let value = trimmed.slice(index + 1).trim() + const quoted = + (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) + if (quoted) value = value.slice(1, -1) + result[key] = value + } + return result +} + +/** + * 读取 OSS 上传配置,环境变量优先于 .env.local。 + * + * @returns OSS 上传配置 + * @throws 当必填项缺失时抛出异常 + */ +function loadConfig(): OssConfig { + const fromFile = existsSync(ENV_FILE) ? parseEnvFile(ENV_FILE) : {} + const pick = (name: string): string => process.env[name] ?? fromFile[name] ?? '' + const config: OssConfig = { + accessKeyId: pick('OSS_ACCESS_KEY_ID'), + accessKeySecret: pick('OSS_ACCESS_KEY_SECRET'), + roleArn: pick('OSS_ROLE_ARN'), + bucket: pick('OSS_BUCKET'), + region: pick('OSS_REGION'), + keyPrefix: pick('OSS_KEY_PREFIX') || 'finance-app-docs/screenshots', + } + const missing = (['accessKeyId', 'accessKeySecret', 'roleArn', 'bucket', 'region'] as const).filter( + (key) => !config[key], + ) + if (missing.length > 0) { + throw new Error(`缺少 OSS 配置: ${missing.join(', ')}(请在 .env.local 中配置)`) + } + return config +} + +/** + * 递归收集目录下的全部文件(按路径排序,保证输出稳定)。 + * + * @param dir 目标目录 + * @returns 文件的绝对路径列表 + */ +function collectFiles(dir: string): string[] { + const files: string[] = [] + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name) + if (entry.isDirectory()) files.push(...collectFiles(full)) + else if (entry.isFile()) files.push(full) + } + return files.sort() +} + +/** + * 通过 STS AssumeRole 获取临时凭证。 + * + * @param config OSS 上传配置 + * @returns STS 临时凭证 + * @throws 当 STS 未返回完整凭证时抛出异常 + */ +async function assumeRole(config: OssConfig): Promise { + const client = new StsClient( + new $OpenApiUtil.Config({ + accessKeyId: config.accessKeyId, + accessKeySecret: config.accessKeySecret, + endpoint: STS_ENDPOINT, + }), + ) + const request = new AssumeRoleRequest({ + roleArn: config.roleArn, + roleSessionName: 'doc-site-screenshot-upload', + durationSeconds: 3600, + }) + const response = await client.assumeRole(request) + const credentials = response.body?.credentials + if (!credentials?.accessKeyId || !credentials.accessKeySecret || !credentials.securityToken) { + throw new Error('STS AssumeRole 未返回有效的临时凭证') + } + return { + accessKeyId: credentials.accessKeyId, + accessKeySecret: credentials.accessKeySecret, + securityToken: credentials.securityToken, + expiration: credentials.expiration ?? '', + } +} + +/** + * 上传单张截图,并通过 HEAD 请求校验远端大小。 + * + * @param client 已认证的 OSS 客户端 + * @param key 对象键 + * @param file 本地文件绝对路径 + * @returns 对象键与文件大小 + * @throws 当远端大小与本地不一致时抛出异常 + */ +async function uploadOne(client: OSS, key: string, file: string): Promise<{ key: string; size: number }> { + const content = readFileSync(file) + await client.put(key, content, { + mime: 'image/png', + headers: { 'Cache-Control': 'public, max-age=86400' }, + }) + const head = await client.head(key) + const headers = head.res.headers as Record + const remoteSize = Number(headers['content-length'] ?? headers['Content-Length']) + if (remoteSize !== content.length) { + throw new Error(`大小校验失败: ${key} 远端=${remoteSize} 本地=${content.length}`) + } + return { key, size: content.length } +} + +/** + * 按并发上限批量执行上传任务。 + * + * @param tasks 返回 Promise 的任务函数列表 + * @returns 各任务结果(顺序与输入一致) + */ +async function runWithConcurrency(tasks: (() => Promise)[]): Promise { + const results: T[] = [] + for (let i = 0; i < tasks.length; i += CONCURRENCY) { + const batch = tasks.slice(i, i + CONCURRENCY) + results.push(...(await Promise.all(batch.map((task) => task())))) + } + return results +} + +/** + * 脚本入口:上传全部截图并打印公共访问 URL。 + */ +async function main(): Promise { + const config = loadConfig() + if (!existsSync(SCREENSHOT_DIR)) { + throw new Error(`截图目录不存在: ${SCREENSHOT_DIR}(请先执行 npm run capture)`) + } + const files = collectFiles(SCREENSHOT_DIR).filter((file) => file.toLowerCase().endsWith('.png')) + if (files.length === 0) { + console.log('未找到可上传的截图') + return + } + + const credentials = await assumeRole(config) + console.log(`STS 凭证获取成功,有效期至 ${credentials.expiration}`) + + const client = new OSS({ + region: `oss-${config.region}`, + accessKeyId: credentials.accessKeyId, + accessKeySecret: credentials.accessKeySecret, + stsToken: credentials.securityToken, + bucket: config.bucket, + secure: true, + }) + + const endpoint = `${config.bucket}.oss-${config.region}.aliyuncs.com` + const tasks = files.map((file) => { + const relativePath = relative(SCREENSHOT_DIR, file).split(sep).join('/') + return () => uploadOne(client, `${config.keyPrefix}/${relativePath}`, file) + }) + + const results = await runWithConcurrency(tasks) + for (const result of results) { + console.log(`OK ${result.key} (${result.size} 字节) https://${endpoint}/${result.key}`) + } + console.log(`\n共上传 ${results.length} 张截图到 oss://${config.bucket}/${config.keyPrefix}/`) +} + +main().catch((error: unknown) => { + console.error(`上传失败: ${error instanceof Error ? error.message : String(error)}`) + process.exit(1) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2059134 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2023", "DOM"], + "types": ["node"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true + }, + "include": ["scripts/ts/**/*.ts"] +}