- S2: logs.ts LIMIT/OFFSET SQL parameterization (? placeholders) - S3: checkTokenSecret() startup validation, JWT_SECRET→TOKEN_SECRET in backup.ts - S3: Access Token expiry shortened to 2h (from 30d) - S4: Export download uses HMAC signed short-term token (/prepare endpoint) - S4: Frontend export.ts adapted for prepare-then-download flow - .env.example reorganized with security notes - Added PRD and architecture docs for iteration v2
38 KiB
小菜记账 — 全量迭代 PRD v2
版本: v2.0 | 日期: 2026-06-10 类型: 全量迭代 PRD(安全性 · 代码质量 · 性能 · UX · 可测试性 · 新功能) 基线: 已完成三轮迭代(代码审查修复 · UI 优化 · 前后端功能对齐 8 项差距) 范围: 本次审查发现 35 项改进点,覆盖 6 个维度
一、产品目标
| # | 目标 | 说明 | 关联维度 |
|---|---|---|---|
| G1 | 消除生产环境安全风险 | .env 泄露、SQL 注入、弱密钥、Token 暴露等 P0/P1 安全问题必须在上线前全部清零 | 安全性 |
| G2 | 建立可持续的代码质量基线 | 消除重复代码、补齐事务与异常处理、统一规范,使后续迭代不会因技术债减速 | 代码质量 |
| G3 | 核心路径性能感知提升 50% | 首页加载、分类/标签操作、统计查询等高频路径的响应时间减半 | 性能优化 |
| G4 | 交互体验达到主流记账 App 水准 | 删除撤销、群组只读、离线记账、下拉刷新等缺失体验补齐 | 用户体验 |
| G5 | 建立测试体系与核心功能扩展 | 从零测试覆盖到关键路径有保障,并新增财务报告与预算预警等高价值功能 | 可测试性 · 新功能 |
二、用户故事
P0 — 安全紧急
S1 As a 产品负责人, I want .env 文件中的生产凭据从 Git 历史中彻底清除, so that 攻击者无法通过代码仓库获取数据库密码和微信密钥。
S2 As a 用户, I want 后端所有 SQL 查询使用参数化方式, so that 我的财务数据不会被 SQL 注入攻击窃取。
S3 As a 用户, I want 认证 Token 使用强密钥且命名统一, so that 伪造 Token 攻击无法成功。
P1 — 安全高优
S4 As a 用户, I want 导出/备份下载链接使用一次性短期凭证, so that 我的财务数据不会被 URL 泄露导致未授权访问。
S5 As a 用户, I want 登录 Token 支持轮换机制, so that 即使 Token 泄露也能在短时间内失效。
S6 As a 管理员, I want 删除用户时使用软删除并需二次确认, so that 不会因误操作导致用户数据永久丢失。
S7 As a 运维人员, I want 数据库连接不硬编码默认凭据, so that 未配置环境变量时服务直接报错而非以弱凭据连接。
P1 — 代码质量
C1 As a 开发者, I want 消除前后端重复的工具函数, so that 修改逻辑时不需要同步多处代码。
C2 As a 开发者, I want tag-manage 与 category-manage 共用可复用组件, so that 两个管理页面行为一致且维护成本低。
C4 As a 用户, I want 周期记账同步操作在数据库事务中执行, so that 部分失败不会产生脏数据。
C5 As a 开发者, I want Store 的异步操作都有 try/catch, so that 未捕获异常不会导致界面卡死。
C7 As a 开发者, I want 所有 API 地址从 config.ts 统一读取, so that 环境切换不会遗漏。
P1 — 性能优化
Perf-1 As a 用户, I want 添加/编辑分类后列表立即更新而无需等待接口返回, so that 操作反馈无延迟感。
Perf-2 As a 用户, I want 统计页面的多个接口并行请求, so that 页面加载不会串行等待。
P1 — 用户体验
UX-1 As a 用户, I want 删除记录后 3 秒内可以撤销, so that 误删不会导致数据永久丢失。
UX-2 As a 群组成员, I want 看到非本人记录时显示只读标记, so that 我不会误编辑他人记录。
UX-3 As a 用户, I want 预算设置支持群组维度, so that 群组场景下也能设置和跟踪预算。
P1 — 可测试性
T-1 As a 开发者, I want 后端路由有集成测试覆盖, so that 接口变更不会静默破坏功能。
P1 — 新功能
F-1 As a 用户, I want 每周/月收到财务报告推送, so that 我不用主动打开 App 也能了解收支状况。
F-2 As a 用户, I want 预算达到 80%/100%/120% 时收到预警通知, so that 我能及时控制支出。
三、需求池
P0 — Must Have(安全紧急,阻塞上线)
| ID | 维度 | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|---|
| S1 | 安全 | .env 凭据泄露修复 | 全局 | .env 在 .gitignore 中(已有但需验证);Git 历史中不含任何凭据明文;所有已泄露密钥已轮换 |
| S2 | 安全 | logs.ts SQL 拼接修复 | server/routes/logs.ts |
LIMIT ${pSize} OFFSET ${offset} 改为 pool.query + 参数占位符 ?;或改为 parseInt + 范围校验后内插 |
| S3 | 安全 | TOKEN_SECRET 弱默认值 + 命名不一致 | server/middleware/auth.ts, server/routes/auth.ts, server/routes/backup.ts |
统一为 TOKEN_SECRET;移除所有 fallback 默认值,未配置时进程拒绝启动 |
P1 — Should Have
安全
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| S4 | Export/Backup URL 传认证 token → 短期一次性下载凭证 | server/routes/export.ts, server/routes/backup.ts |
下载凭证有效期 5 分钟,一次性使用后失效;不使用 query string 传长期 Token |
| S5 | Refresh Token 机制 | server/routes/auth.ts, server/middleware/auth.ts, client/src/utils/request.ts |
Access Token 有效期缩短至 2h;Refresh Token 有效期 30d;Refresh Token 支持轮换(用后旧 Token 失效) |
| S6 | Admin 硬删除 → 软删除 + 二次确认 | server/routes/admin.ts, DB schema |
users 表新增 deleted_at 字段;删除操作改为 SET deleted_at = NOW();需前端二次确认弹窗;查询自动过滤 deleted_at IS NULL |
| S7 | DB 连接移除默认凭据 | server/src/db/connection.ts |
移除 ` |
代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| C1 | 重复工具函数抽取 | 前后端 utils/ |
识别并合并功能相同的工具函数(如日期格式化、金额处理);抽取为共享模块或分别去重 |
| C2 | tag-manage / category-manage 重复代码 | 前端页面 | 抽取 ManagePageLayout 可复用组件(列表 + 新增 + 编辑 + 删除 + 拖拽排序);两页面基于该组件定制 |
| C4 | Recurring 同步缺事务 | server/routes/recurring.ts |
/sync 接口使用 pool.getConnection() + connection.beginTransaction() 包裹;部分失败时 rollback() |
| C5 | Store 缺 try/catch | client/src/stores/*.ts |
所有 Store 中 await api.xxx() 调用包裹 try/catch;catch 中使用统一错误提示(toast);不吞错误 |
| C7 | 硬编码 URL | 前端全局 | 全局搜索 http:// 和 xiaocai.j35.site,替换为 config.ts 导出的变量 |
性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| Perf-1 | 分类/标签乐观更新 | client/stores/category.ts, client/stores/tag.ts |
新增/编辑/排序操作先更新本地 state 再调接口;失败时回滚并提示;删除仍等接口确认后移除 |
| Perf-2 | groupStore 并行加载 | client/stores/group.ts |
refreshAll() 中多个 fetchXxx() 改为 Promise.all() 并行执行 |
| Perf-3 | Stats 聚合接口 | server/routes/stats.ts |
合并 /stats/overview + /stats/category + /stats/trend 为单一 /stats/dashboard 接口;减少 3 次请求为 1 次 |
| Perf-4 | Track 批量 INSERT | server/routes/track.ts |
多条埋点数据合并为单条 INSERT INTO ... VALUES (?,?), (?) 批量写入 |
| Perf-5 | Admin N+1 查询 | server/routes/admin.ts |
用户列表查询改为 JOIN 单次获取关联数据,消除循环内查询 |
| Perf-6 | 前端数据缓存 | client/stores/*.ts |
分类、标签等低频变更数据增加内存缓存标记,onShow 时判断是否需刷新 |
| Perf-7 | 未读计数缓存 | client/stores/notification.ts |
未读计数结果缓存 30s,避免每次 onShow 都请求 |
用户体验
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| UX-1 | 删除撤销 | client/pages/bills/, client/stores/transaction.ts |
删除后底部弹出 Snackbar,3 秒内可点击"撤销"恢复;撤销成功数据不变;超时后真正删除 |
| UX-2 | 群组只读标记 | client/pages/bills/, client/components/TransactionItem/ |
非本人记录显示小锁图标;点击进入查看模式而非编辑模式;查看模式下字段不可修改 |
| UX-3 | waitForReady 超时处理 | client/utils/app-ready.ts |
超时后 reject 并在页面级捕获,展示"网络异常,请检查网络后重试"提示;不再静默继续 |
| UX-4 | 预算 group_id | server/routes/budget.ts, client/stores/budget.ts, client/pages/budget/ |
预算设置/查询支持 group_id 参数;群组视图下预算卡片显示群组总预算 + 我的份额 |
可测试性
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| T-1 | 后端路由集成测试 | server/ |
使用 Jest + supertest;覆盖 auth、transaction、category、budget 核心 CRUD 路由;断言状态码 + 返回格式 + 权限校验 |
新功能
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| F-1 | 月度/周度财务报告推送 | 后端定时任务 + 微信订阅消息 | 每周一早 9 点推送上周收支摘要;每月 1 号推送上月收支报告;使用微信订阅消息模板;用户可在设置中开关 |
| F-2 | 预算超支预警 | 后端定时任务 + 通知系统 | 80% 时站内通知提醒;100% 时站内通知 + 微信订阅消息;120% 时站内紧急通知 + 微信订阅消息;预算检查在记账后实时触发 |
P2 — Nice to Have
代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| C3 | 上传重复逻辑抽取 | server/routes/user.ts, server/routes/notification.ts |
抽取 handleUpload(dir, file) 公共函数,统一 MIME 校验 + 大小限制 + 文件名生成 |
| C6 | Store 吞错误修复 | client/stores/*.ts |
catch 块中至少 console.error + 用户可见 toast 提示;不再空 catch |
| C8 | Icon 组件 → iconfont | client/components/Icon/ |
将 PNG 图标映射替换为 iconfont 字体图标;减小包体积;支持动态颜色 |
| C9 | 头像 URL 拼接重复 | 前端多处 | 抽取 getAvatarUrl(filename) 工具函数,统一拼接 API_BASE + 路径 + 文件名 |
性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| Perf-8 | Logs 流式读取 | server/routes/logs.ts |
大量日志数据使用 stream 分批返回,避免一次性加载到内存 |
| Perf-9 | ChartWrapper 延迟渲染 | client/components/ChartWrapper/ |
图表组件进入可视区域后才开始渲染(IntersectionObserver);非可视区域显示占位 |
用户体验
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| UX-5 | 下拉刷新统一 | 前端所有数据页面 | 所有列表页面启用 onPullDownRefresh;统一在 pages.json 配置 enablePullDownRefresh |
| UX-6 | 离线支持 | client/ 全局 |
记账操作存入本地队列 + 网络状态监听;网络恢复后自动同步;离线期间操作标记为"待同步"状态 |
| UX-7 | Loading 态统一 | 前端全局 | 所有异步操作添加 loading 态(骨架屏 / spinner / 按钮禁用);操作期间禁止重复提交 |
可测试性
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| T-2 | Composables 测试 | client/ |
抽取可复用逻辑为 composables;使用 Vitest 编写单元测试 |
| T-3 | 依赖注入 | client/ |
API 调用通过 provide/inject 注入,便于测试时 mock |
新功能
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| F-3 | 多设备同步 | 后端 + 前端 | 基于 updated_at 时间戳的增量同步;冲突策略:后写入优先;同步状态可视化 |
| F-4 | AA 分账 | 后端 + 前端 | 群组内选择多笔交易,按人均/自定义比例分摊;生成每人应付/应收金额;支持结算确认 |
| F-5 | 智能记账 | 后端 + 前端 | 备注关键词自动匹配分类(如"滴滴"→交通);历史金额学习建议;最近使用分类优先 |
P3 — 远期规划
代码质量
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| C10 | 路由中间件一致性 | server/routes/*.ts |
统一所有路由的中间件注册方式(统一在 index.ts 注册还是路由内自注册) |
性能优化
| ID | 需求 | 影响范围 | 验收标准 |
|---|---|---|---|
| Perf-10 | 数据可视化增强 | client/pages/stats/ |
支持更丰富的图表类型(饼图、折线图、柱状图);支持图表数据导出为图片 |
四、安全修复方案
S1:.env 凭据泄露修复
当前状态:.env 已在 .gitignore 中,但文件内容已在历史提交中暴露,包含数据库地址 115.120.243.74、密码 dnjwYbpMdmASCxfH、微信 AppID/Secret。
修复步骤:
- 立即轮换所有已泄露凭据:
- MySQL: 修改
xiaocai_test用户密码 - 微信: 在微信公众平台重置 AppSecret
- TOKEN_SECRET: 生成新的强随机密钥(
openssl rand -hex 32)
- MySQL: 修改
- 清除 Git 历史:
# 使用 git-filter-repo(推荐,比 BFG 更安全) pip install git-filter-repo git filter-repo --path server/.env --invert-paths git filter-repo --blob-callback 'blob.data = blob.data.replace(b"dnjwYbpMdmASCxfH", b"REDACTED")' - 强制推送并通知协作者:
git push origin --force --all - 验证
.gitignore生效:git check-ignore server/.env # 应输出 server/.env - 添加 pre-commit hook 防止再次提交:
# .githooks/pre-commit if git diff --cached --name-only | grep -q '\.env'; then echo "ERROR: .env files must not be committed" exit 1 fi
回滚方案:Git 历史重写前创建完整备份 git clone --mirror。
S2:logs.ts SQL 拼接修复
当前状态:server/routes/logs.ts:160-168 使用模板字符串拼接 LIMIT/OFFSET:
// ❌ 当前代码
LIMIT ${pSize} OFFSET ${offset}
修复方案:
// ✅ 方案 A:参数化查询(推荐)
const safeLimit = Math.min(Math.max(parseInt(String(pSize)) || 10, 1), 100)
const safeOffset = Math.max(parseInt(String(offset)) || 0, 0)
const [rows] = await pool.query(
`SELECT t.*, u.nickname
FROM track_events t
LEFT JOIN users u ON t.user_id = u.id
WHERE ${where}
ORDER BY t.created_at DESC
LIMIT ? OFFSET ?`,
[...params, safeLimit, safeOffset]
)
关键点:LIMIT/OFFSET 的值必须先 parseInt + 范围校验,防止非整数或超大值;pool.query 支持 ? 占位符用于整数参数(与 pool.execute 的 prepared statement 不同)。
S3:TOKEN_SECRET 弱默认值 + 命名不一致
当前状态:
auth.ts/middleware/auth.ts:process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'backup.ts:process.env.JWT_SECRET || 'xiaocai-secret'(命名不一致 + 弱默认值).env中:TOKEN_SECRET=xiaocai-prod-secret-2026-change-me(弱密钥)
修复方案:
-
统一命名为
TOKEN_SECRET:backup.ts中JWT_SECRET→TOKEN_SECRET- 删除
const JWT_SECRET = ...声明,改为从middleware/auth.ts导出或统一读取process.env.TOKEN_SECRET
-
移除所有 fallback 默认值,强制要求环境变量:
// server/src/config/token.ts(新增) export const TOKEN_SECRET = process.env.TOKEN_SECRET if (!TOKEN_SECRET || TOKEN_SECRET.length < 32) { console.error('[Security] TOKEN_SECRET must be set and at least 32 characters') process.exit(1) } -
生成强密钥:
# 在服务器上生成并写入 .env echo "TOKEN_SECRET=$(openssl rand -hex 32)" >> server/.env -
所有引用处改为:
import { TOKEN_SECRET } from '../config/token'
S4:Export/Backup URL 短期一次性下载凭证
当前状态:
backup.ts:下载链接使用 HMAC 签名的backupId:timestamp:hmac格式 token,通过 query string 传递export.ts:导出接口支持 query string 传 token 兼容
问题:虽然已有 HMAC 签名,但无过期时间校验(backup 有 timestamp 但未校验时效),且 token 可重复使用。
修复方案:
-
新增
download_tokens表:CREATE TABLE download_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, token VARCHAR(64) NOT NULL UNIQUE, user_id INT NOT NULL, resource_type ENUM('backup', 'export') NOT NULL, resource_id VARCHAR(100), expires_at DATETIME NOT NULL, used_at DATETIME DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_token (token), INDEX idx_expires (expires_at) ); -
生成下载凭证:
// 有效期 5 分钟 const token = randomBytes(32).toString('hex') await pool.query( 'INSERT INTO download_tokens (token, user_id, resource_type, resource_id, expires_at) VALUES (?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 5 MINUTE))', [token, userId, 'backup', backupId] ) -
验证下载凭证:
const [rows] = await pool.query( 'SELECT * FROM download_tokens WHERE token = ? AND user_id = ? AND expires_at > NOW() AND used_at IS NULL', [token, userId] ) if (!rows.length) return res.status(403).json({ code: 40300, message: '下载凭证无效或已过期' }) // 标记为已使用 await pool.query('UPDATE download_tokens SET used_at = NOW() WHERE id = ?', [rows[0].id]) -
定期清理过期 token:在 health check 或定时任务中清理
expires_at < NOW()的记录。
S5:Refresh Token 机制
当前状态:单 Token 机制,HMAC-SHA256 签名,30 天过期,无轮换。
修复方案:
-
双 Token 机制:
- Access Token:有效期 2 小时,用于 API 调用
- Refresh Token:有效期 30 天,仅用于刷新 Access Token
-
新增
refresh_tokens表:CREATE TABLE refresh_tokens ( id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, token_hash VARCHAR(64) NOT NULL, expires_at DATETIME NOT NULL, revoked_at DATETIME DEFAULT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user (user_id), INDEX idx_hash (token_hash) ); -
认证流程:
- 登录:返回
accessToken+refreshToken - API 调用:Header 带
Authorization: Bearer <accessToken> - Token 过期(401):前端用
refreshToken调用/auth/refresh获取新accessToken+ 新refreshToken(旧 refresh token 失效) - Refresh Token 也过期:需重新登录
- 登录:返回
-
前端适配(
client/src/utils/request.ts):- 现有 401 重登录机制改为 401 → refresh → 重试
- refresh 也失败(401)→ 清除本地 token → 跳转登录
-
安全措施:
- Refresh Token 存储 hash 值(SHA-256),不存明文
- 每个 Refresh Token 仅使用一次(用后旧 token 失效)
- 用户修改密码时撤销所有 Refresh Token
S6:Admin 硬删除 → 软删除 + 二次确认
当前状态:admin.ts:170 直接 DELETE FROM users WHERE id = ?,无二次确认。
修复方案:
-
DB Schema 变更:
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL; CREATE INDEX idx_users_deleted ON users(deleted_at); -
后端修改:
// 软删除 router.delete('/users/:id', async (req, res) => { await pool.query('UPDATE users SET deleted_at = NOW() WHERE id = ?', [req.params.id]) res.json({ code: 0, data: { message: '用户已禁用' } }) }) // 恢复 router.post('/users/:id/restore', async (req, res) => { await pool.query('UPDATE users SET deleted_at = NULL WHERE id = ?', [req.params.id]) res.json({ code: 0, data: { message: '用户已恢复' } }) }) -
全局查询过滤:在
auth.ts登录时检查deleted_at IS NULL;其他查询同理。 -
前端二次确认:
管理员点击"删除用户" → 弹出确认弹窗: "确定要禁用用户「{nickname}」吗?该操作将冻结该用户的所有数据,但不会删除记录。" [取消] [确认禁用]
S7:DB 连接移除默认凭据
当前状态:connection.ts 中 password: process.env.DB_PASSWORD || 'xiaocai123' 等硬编码默认值。
修复方案:
// ✅ 修复后
const required = ['DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'] as const
for (const key of required) {
if (!process.env[key]) {
console.error(`[DB] Missing required environment variable: ${key}`)
process.exit(1)
}
}
const pool = mysql.createPool({
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0,
})
五、UI 交互说明
UX-1:删除撤销
交互流程:
1. 用户在账单列表左滑删除某条记录
2. 记录从列表中移除(动画:向左滑出)
3. 底部弹出 Snackbar:
┌───────────────────────────────────────────┐
│ 🗑 已删除 1 条记录 [撤销] │
└───────────────────────────────────────────┘
- 背景色: $surface (#FFFFFF)
- 文字色: $text-sec (#8B7E7E)
- "撤销"按钮: $primary (#FF8C69) + 500 字重
- 自动消失时间: 3 秒
- 从底部上滑动画: 200ms ease-out
4. 3 秒内点击"撤销":
- 记录重新插入列表原位置(动画:从左侧滑入)
- 不调用后端删除接口
- Snackbar 消失
5. 3 秒超时未操作:
- Snackbar 下滑消失
- 调用后端 DELETE /transactions/:id
- 如果接口失败,记录恢复到列表 + 错误提示
实现要点:
- 删除操作先只移除前端列表项,不立即调接口
- 使用 pendingDelete Map 存储待删除记录(id → 原始数据 + 原位置索引)
- 多次快速删除时 Snackbar 累加计数:"已删除 N 条记录"
- 撤销时按 LIFO 顺序恢复
UX-2:群组只读标记
交互设计:
1. TransactionItem 组件变更:
- 新增 props: `isReadOnly: boolean`
- 非本人记录(transaction.user_id !== currentUserId):
├── 右上角显示小锁图标(Lucide Lock, 16rpx, $text-sec 颜色)
├── 左滑不显示删除按钮
└── 整体透明度降为 0.85(区分视觉层级)
2. 点击交互:
- 本人记录:点击 → 编辑页面(现有行为)
- 非本人记录:点击 → 查看页面(只读模式)
├── 页面布局与编辑页相同
├── 所有输入框/选择器为 disabled 态
├── 金额/分类/标签仅展示不可修改
├── 底部无"保存"按钮
└── 顶部标题显示"查看记录"
3. 群组视图下的记账按钮:
- 仍然可以新增自己的记录
- 新增记录自动归属当前用户
UX-3:waitForReady 超时处理
交互流程:
1. 页面 onMounted 调用 await waitForReady()
2. 如果 8 秒内未就绪:
- waitForReady() reject(而非当前 resolve)
- 页面 catch 中显示全屏错误状态:
┌─────────────────────────┐
│ │
│ ⚠️ 网络连接异常 │
│ 请检查网络后点击重试 │
│ │
│ [重新加载] │
│ │
└─────────────────────────┘
- "重新加载"按钮点击后重新执行 waitForReady() + loadData()
- 不再静默以未登录状态加载数据
UX-4:预算 group_id 支持
交互设计:
1. 预算页面(budget/index.vue)变更:
- 个人视图:显示"我的月度预算"(现有行为不变)
- 群组视图:显示两个卡片:
├── 群组总预算卡片:所有成员预算之和 + 群组总支出
│ └── "群组总预算 ¥ 8,000.00"
└── 我的预算卡片:当前用户个人预算 + 个人支出
└── "我的预算 ¥ 3,000.00"
2. 设置预算弹窗变更:
- 个人视图:设置个人月度预算
- 群组视图:设置"我在群组中的月度预算"
- 弹窗标题根据视图切换
3. 预算进度条变更:
- 个人视图:支出/预算(现有)
- 群组视图:
├── 总进度条:群组总支出 / 群组总预算
└── 我的进度条:我的支出 / 我的预算(子进度条样式)
UX-5:下拉刷新统一
实现规范:
1. pages.json 全局配置:
"globalStyle": {
"enablePullDownRefresh": true
}
2. 所有数据页面统一模式:
onPullDownRefresh(async () => {
await loadData()
uni.stopPullDownRefresh()
})
3. 需要启用下拉刷新的页面:
- 首页 index
- 账单页 bills
- 统计页 stats
- 通知中心 notifications
- 群组管理 group-manage
- 标签管理 tag-manage
4. 不启用下拉刷新的页面:
- 表单页(add, profile-edit, budget)
- 设置页
UX-6:离线支持
架构设计:
1. 操作队列(localStorage):
- Key: `xc:pendingOps`
- 数据结构:
[
{ id: 'uuid', type: 'create', data: {...}, createdAt: timestamp, status: 'pending' },
{ id: 'uuid', type: 'update', data: {...}, createdAt: timestamp, status: 'pending' }
]
2. 网络状态监听:
onMounted(() => {
uni.onNetworkStatusChange(({ isConnected }) => {
if (isConnected) processPendingOps()
})
})
3. 操作执行流程:
- 有网络:正常调接口
- 无网络:
├── 写入操作队列
├── 本地 state 立即更新(乐观更新)
├── 记录标记为"待同步"状态(右下角小图标)
└── Toast:"已保存,将在网络恢复后同步"
4. 同步流程(processPendingOps):
- 按时间顺序逐条执行
- 全部成功:清除队列 + toast "同步完成"
- 部分失败:标记失败项 + toast "N 条同步失败" + 保留在队列中重试
5. 限制范围:
- MVP 阶段仅支持"新增记账"离线操作
- 编辑/删除等操作离线时禁用(提示"请连接网络后操作")
UX-7:Loading 态统一
规范:
1. 页面级加载:使用 Skeleton 骨架屏组件
- 首次加载 onMounted 时显示
- 下拉刷新不显示骨架屏(数据已存在)
2. 操作级加载:按钮/操作区域 loading 态
- 按钮点击后 disabled + 显示 spinner
- 防止重复提交(debounce 300ms 或 loading ref 守卫)
3. 全局 loading:
- 页面切换时的导航栏 loading(uni.showNavigationBarLoading)
- 长时间操作(导出/备份)使用全屏 loading + 进度提示
4. 空状态:
- 数据为空时显示空状态插画 + 引导文案
- 与加载中状态明确区分(不是空白页)
六、性能优化方案
Perf-1:分类/标签乐观更新
| 项目 | 说明 |
|---|---|
| 当前问题 | 新增/编辑/排序分类后,等待接口返回才更新列表,用户感知延迟 200-500ms |
| 优化策略 | 先更新本地 Pinia state,再异步调接口;接口失败时回滚 state + toast 提示 |
| 实现方式 | Store 方法内部:先 state.items.push(newItem) → await api.create() → 失败则 state.items.pop() + toast('操作失败') |
| 预期效果 | 操作感知延迟从 200-500ms 降至 < 16ms(一帧) |
| 适用范围 | 新增分类/标签、编辑分类/标签、拖拽排序;删除操作仍等接口确认(防误删) |
Perf-2:groupStore 并行加载
| 项目 | 说明 |
|---|---|
| 当前问题 | refreshAll() 中 fetchCategories() → fetchTags() → fetchTransactions() 串行执行,总耗时 = 3 次请求之和 |
| 优化策略 | 无依赖的请求改为 Promise.all() 并行执行 |
| 实现方式 | await Promise.all([fetchCategories(), fetchTags()]); await fetchTransactions()(transactions 依赖 categories 的映射) |
| 预期效果 | 并行部分耗时 = max(单次请求),总体减少 40-60% |
Perf-3:Stats 聚合接口
| 项目 | 说明 |
|---|---|
| 当前问题 | 统计页加载时串行调用 /stats/overview + /stats/category + /stats/trend,3 次 HTTP 往返 |
| 优化策略 | 合并为单一 /stats/dashboard 接口,后端并行查询后一次性返回 |
| 实现方式 | 后端新路由中 Promise.all([queryOverview, queryCategory, queryTrend]) → 返回 { overview, category, trend } |
| 预期效果 | 3 次 HTTP 往返 → 1 次;减少约 200ms 网络开销 |
Perf-4:Track 批量 INSERT
| 项目 | 说明 |
|---|---|
| 当前问题 | 前端一次页面访问可能触发多条埋点,每条单独 INSERT,N 条 = N 次数据库写入 |
| 优化策略 | 前端批量发送,后端使用 INSERT INTO ... VALUES (?), (?), (?) 批量写入 |
| 实现方式 | 前端 tracker.ts 攒批(500ms 或 10 条),批量 POST;后端 track.ts 接收数组后构建批量 INSERT |
| 预期效果 | 10 条埋点从 10 次 INSERT 降至 1 次,减少数据库连接开销 90% |
Perf-5:Admin N+1 查询
| 项目 | 说明 |
|---|---|
| 当前问题 | 用户列表查询后,循环每个用户查询其交易数/群组数等信息,N 个用户 = 1 + N 次查询 |
| 优化策略 | 使用 LEFT JOIN + GROUP BY 在一次查询中获取所有关联数据 |
| 实现方式 | SELECT u.*, COUNT(DISTINCT t.id) as tx_count, COUNT(DISTINCT gm.group_id) as group_count FROM users u LEFT JOIN transactions t ON ... LEFT JOIN group_members gm ON ... GROUP BY u.id |
| 预期效果 | 1+N 次查询 → 1 次查询;列表加载从 O(n) 降至 O(1) |
Perf-6:前端数据缓存
| 项目 | 说明 |
|---|---|
| 当前问题 | 分类、标签等低频变更数据每次 onShow 都重新请求 |
| 优化策略 | 增加 lastFetchTime 标记,5 分钟内 onShow 跳过请求 |
| 实现方式 | Store 中增加 lastFetchTime: Ref<number>,fetch() 前判断 Date.now() - lastFetchTime.value < 5 * 60 * 1000 则跳过 |
| 预期效果 | 页面切换时减少 60-80% 的冗余请求 |
Perf-7:未读计数缓存
| 项目 | 说明 |
|---|---|
| 当前问题 | 每次进入"我的"页面或 onShow 都请求未读通知计数 |
| 优化策略 | 计数结果缓存 30 秒;读取通知后主动清零缓存 |
| 实现方式 | unreadCount 请求后记录 lastFetchTime,30s 内直接返回缓存值;markAllRead() 后 unreadCount = 0 + 清缓存 |
| 预期效果 | 频繁切换页面时减少通知计数请求 80%+ |
Perf-8:Logs 流式读取(P2)
| 项目 | 说明 |
|---|---|
| 当前问题 | 管理后台查看日志时全量加载到内存 |
| 优化策略 | 使用 Node.js stream 分批返回 |
| 实现方式 | fs.createReadStream(logPath).pipe(res) 或数据库 queryStream() |
| 预期效果 | 内存占用从 O(n) 降至 O(buffer_size) |
Perf-9:ChartWrapper 延迟渲染(P2)
| 项目 | 说明 |
|---|---|
| 当前问题 | 统计页所有图表同时渲染,低端设备卡顿 |
| 优化策略 | 使用 IntersectionObserver,图表进入可视区域后才渲染 |
| 实现方式 | ChartWrapper 组件内监听可视状态,不可见时显示占位(固定高度 div) |
| 预期效果 | 首屏渲染时间减少 30-50% |
七、测试体系建设计划
目标:从零覆盖到核心路径有保障,分 4 阶段逐步建设
阶段一:后端路由集成测试(P1,预计 2 周)
范围:核心 CRUD 路由的集成测试
技术栈:Jest + supertest + 测试数据库
覆盖清单:
| 模块 | 测试用例 | 优先级 |
|---|---|---|
| auth | 登录成功/失败、Token 生成与验证、无效 Token 拒绝 | P0 |
| transaction | CRUD、group_id 过滤、分页、金额精度 | P0 |
| category | CRUD、默认/自定义分类隔离、排序、迁移 | P1 |
| budget | CRUD、月度预算、群组视图 | P1 |
| stats | overview/category/trend 查询、period 参数校验 | P2 |
实现步骤:
- 安装依赖:
npm i -D jest ts-jest supertest @types/supertest - 创建测试数据库配置:
.env.test指向独立测试库 - 编写
tests/setup.ts:每个测试套件前清空测试库 + seed - 编写路由测试:使用
supertest(app)发送请求,断言状态码 + 返回格式 - 配置
npm test命令
验收标准:核心路由测试覆盖率 ≥ 80%,npm test 全绿。
阶段二:前端 Store 单元测试(P1,预计 1.5 周)
范围:Pinia Store 的核心逻辑测试
技术栈:Vitest + @pinia/testing
覆盖清单:
| Store | 测试用例 | 优先级 |
|---|---|---|
| transaction | fetchList/fetchDetail/create/update/delete、乐观更新、删除撤销 | P0 |
| category | fetchAll/create/update/sort/乐观更新 | P1 |
| budget | fetch/set/群组视图 | P1 |
| group | fetchGroups/switchToPersonal/leaveGroup(BUG-03 回归) | P0 |
| user | login/logout/token 管理 | P1 |
实现步骤:
- 安装依赖:
npm i -D vitest @pinia/testing @vue/test-utils - 创建
vitest.config.ts - Mock API 调用:使用
vi.mock('@/api/*') - 编写 Store 测试:使用
createTestingPinia() - 配置
npm run test:unit命令
验收标准:Store 核心方法测试覆盖率 ≥ 70%。
阶段三:API 契约测试(P2,预计 1 周)
范围:确保前后端 API 接口格式一致
技术栈:Jest + JSON Schema 验证
实现方式:
-
定义 API 响应 Schema(
tests/schemas/):// transaction-response.schema.ts export const transactionListSchema = { type: 'object', required: ['code', 'data'], properties: { code: { const: 0 }, data: { type: 'object', required: ['list', 'total'], properties: { list: { type: 'array' }, total: { type: 'number' } } } } } -
集成测试中同时验证响应格式符合 Schema
-
前端
request.ts添加开发模式响应校验
验收标准:所有已定义 Schema 的接口响应 100% 符合契约。
阶段四:工具函数单元测试(P2,预计 1 周)
范围:前端 utils/ 和后端 utils/ 的纯函数测试
覆盖清单:
| 模块 | 测试用例 |
|---|---|
format.ts |
formatAmount、formatDate(含跨年 BUG-02 回归)、formatAmountRaw |
request.ts |
401 重试队列、超时处理、请求参数格式 |
app-ready.ts |
waitForReady 正常/超时场景 |
backup.ts(后端) |
备份文件生成、签名校验 |
date.ts(后端) |
getCurrentMonth、getMonthRange |
验收标准:工具函数测试覆盖率 ≥ 90%。
八、待确认问题
| # | 问题 | 影响范围 | 建议方案 | 风险 |
|---|---|---|---|---|
| Q1 | Refresh Token 存储方式:数据库 vs Redis | S5 | 当前技术栈无 Redis,建议存 MySQL refresh_tokens 表;如果后续并发量增长,迁移到 Redis |
MySQL 写入频率低,短中期可行 |
| Q2 | 软删除的级联范围:是否需要同时软删除该用户的所有交易记录? | S6 | 建议仅软删除用户账号(deleted_at),交易记录保留但通过 deleted_at IS NULL 过滤;历史数据完整性更重要 |
保留交易可能影响群组统计,需在群组视图中标注"已注销用户" |
| Q3 | 删除撤销的批量操作上限:最多支持同时撤销几条? | UX-1 | 建议上限 5 条(Snackbar 最多显示"已删除 5 条记录");超过 5 条时直接删除不走撤销流程 | 5 条覆盖绝大多数场景,过多会增加本地状态管理复杂度 |
| Q4 | 离线记账的冲突策略:如果离线期间同一条记录被其他设备修改了怎么办? | UX-6 | MVP 阶段仅支持离线新增(不涉及修改/删除),新增记录使用服务端生成的 ID,不存在主键冲突 | 如需支持离线编辑,需引入版本号或时间戳对比机制 |
| Q5 | 财务报告推送的微信订阅消息模板审核:微信对金融类模板审核严格,是否需要用户额外授权? | F-1 | 建议先实现站内通知(现有通知系统),微信订阅消息作为增强项;需提前申请微信模板消息审核 | 微信审核可能被拒,需准备备选方案(如短信或仅站内推送) |
| Q6 | 预算预警的检查时机:仅在记账后检查 vs 定时全量扫描? | F-2 | 建议双重策略:记账后实时检查(精准、低开销)+ 每日凌晨全量扫描(覆盖非记账场景如周期记账自动生成) | 仅实时检查可能遗漏周期记账触发的超支 |
| Q7 | 乐观更新的回滚粒度:分类排序失败时,是回滚全部排序还是仅回滚失败项? | Perf-1 | 建议回滚全部:排序是全量操作(传递完整 ID 数组),部分回滚语义不清晰;失败后恢复排序前快照 + toast | 全量回滚用户感知更一致,但可能丢失用户已做的其他排序操作 |
| Q8 | 测试环境数据隔离:集成测试使用独立数据库还是 Docker 容器? | T-1 | 建议独立测试数据库(.env.test),不用 Docker(减少 CI 复杂度);CI 环境中用 GitHub Actions service container 启动 MySQL |
本地开发需额外配置测试数据库,但比 Docker 方案简单 |
九、迭代排期建议
| 阶段 | 时间 | 内容 | 交付物 |
|---|---|---|---|
| Phase 0 | 第 1 周 | P0 安全修复(S1/S2/S3) | 凭据轮换完成、SQL 参数化、TOKEN_SECRET 强制 |
| Phase 1 | 第 2-3 周 | P1 安全(S4-S7)+ P1 代码质量(C1/C2/C4/C5/C7) | 双 Token 机制、软删除、代码去重 |
| Phase 2 | 第 4-5 周 | P1 性能(Perf-1 |
乐观更新、聚合接口、删除撤销、群组只读 |
| Phase 3 | 第 6-7 周 | P1 测试(T-1)+ P1 新功能(F-1/F-2) | 集成测试覆盖、财务报告推送、预算预警 |
| Phase 4 | 第 8-10 周 | P2 需求(C3/C6/C8/C9 + Perf-8 |
离线支持、AA 分账、智能记账 |
| Phase 5 | 远期 | P3 需求(C10 + Perf-10) | 路由中间件统一、数据可视化增强 |
文档版本: v2.0 | 最后更新: 2026-06-10