Files
xiaocai/docs/prd-iteration-v2.md
wangxiaogang 61f9b33f8c feat(iter-v2): T01 partial - TOKEN_SECRET check, SQL param, export credential, docs
- 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
2026-06-11 08:58:24 +08:00

38 KiB
Raw Permalink Blame History

小菜记账 — 全量迭代 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 有效期缩短至 2hRefresh Token 有效期 30dRefresh 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/catchcatch 中使用统一错误提示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 删除后底部弹出 Snackbar3 秒内可点击"撤销"恢复;撤销成功数据不变;超时后真正删除
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。

修复步骤

  1. 立即轮换所有已泄露凭据
    • MySQL: 修改 xiaocai_test 用户密码
    • 微信: 在微信公众平台重置 AppSecret
    • TOKEN_SECRET: 生成新的强随机密钥(openssl rand -hex 32
  2. 清除 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")'
    
  3. 强制推送并通知协作者
    git push origin --force --all
    
  4. 验证 .gitignore 生效
    git check-ignore server/.env  # 应输出 server/.env
    
  5. 添加 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

S2logs.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 不同)。

S3TOKEN_SECRET 弱默认值 + 命名不一致

当前状态

  • auth.ts / middleware/auth.tsprocess.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
  • backup.tsprocess.env.JWT_SECRET || 'xiaocai-secret'(命名不一致 + 弱默认值)
  • .env 中:TOKEN_SECRET=xiaocai-prod-secret-2026-change-me(弱密钥)

修复方案

  1. 统一命名为 TOKEN_SECRET

    • backup.tsJWT_SECRETTOKEN_SECRET
    • 删除 const JWT_SECRET = ... 声明,改为从 middleware/auth.ts 导出或统一读取 process.env.TOKEN_SECRET
  2. 移除所有 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)
    }
    
  3. 生成强密钥

    # 在服务器上生成并写入 .env
    echo "TOKEN_SECRET=$(openssl rand -hex 32)" >> server/.env
    
  4. 所有引用处改为

    import { TOKEN_SECRET } from '../config/token'
    

S4Export/Backup URL 短期一次性下载凭证

当前状态

  • backup.ts:下载链接使用 HMAC 签名的 backupId:timestamp:hmac 格式 token通过 query string 传递
  • export.ts:导出接口支持 query string 传 token 兼容

问题:虽然已有 HMAC 签名但无过期时间校验backup 有 timestamp 但未校验时效),且 token 可重复使用。

修复方案

  1. 新增 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)
    );
    
  2. 生成下载凭证

    // 有效期 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]
    )
    
  3. 验证下载凭证

    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])
    
  4. 定期清理过期 token:在 health check 或定时任务中清理 expires_at < NOW() 的记录。

S5Refresh Token 机制

当前状态:单 Token 机制HMAC-SHA256 签名30 天过期,无轮换。

修复方案

  1. 双 Token 机制

    • Access Token有效期 2 小时,用于 API 调用
    • Refresh Token有效期 30 天,仅用于刷新 Access Token
  2. 新增 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)
    );
    
  3. 认证流程

    • 登录:返回 accessToken + refreshToken
    • API 调用Header 带 Authorization: Bearer <accessToken>
    • Token 过期401前端用 refreshToken 调用 /auth/refresh 获取新 accessToken + 新 refreshToken(旧 refresh token 失效)
    • Refresh Token 也过期:需重新登录
  4. 前端适配client/src/utils/request.ts

    • 现有 401 重登录机制改为 401 → refresh → 重试
    • refresh 也失败401→ 清除本地 token → 跳转登录
  5. 安全措施

    • Refresh Token 存储 hash 值SHA-256不存明文
    • 每个 Refresh Token 仅使用一次(用后旧 token 失效)
    • 用户修改密码时撤销所有 Refresh Token

S6Admin 硬删除 → 软删除 + 二次确认

当前状态admin.ts:170 直接 DELETE FROM users WHERE id = ?,无二次确认。

修复方案

  1. DB Schema 变更

    ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL;
    CREATE INDEX idx_users_deleted ON users(deleted_at);
    
  2. 后端修改

    // 软删除
    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: '用户已恢复' } })
    })
    
  3. 全局查询过滤:在 auth.ts 登录时检查 deleted_at IS NULL;其他查询同理。

  4. 前端二次确认

    管理员点击"删除用户" → 弹出确认弹窗:
    "确定要禁用用户「{nickname}」吗?该操作将冻结该用户的所有数据,但不会删除记录。"
    [取消] [确认禁用]
    

S7DB 连接移除默认凭据

当前状态connection.tspassword: 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-3waitForReady 超时处理

交互流程:

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-7Loading 态统一

规范:

1. 页面级加载:使用 Skeleton 骨架屏组件
   - 首次加载 onMounted 时显示
   - 下拉刷新不显示骨架屏(数据已存在)

2. 操作级加载:按钮/操作区域 loading 态
   - 按钮点击后 disabled + 显示 spinner
   - 防止重复提交debounce 300ms 或 loading ref 守卫)

3. 全局 loading
   - 页面切换时的导航栏 loadinguni.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-2groupStore 并行加载

项目 说明
当前问题 refreshAll()fetchCategories()fetchTags()fetchTransactions() 串行执行,总耗时 = 3 次请求之和
优化策略 无依赖的请求改为 Promise.all() 并行执行
实现方式 await Promise.all([fetchCategories(), fetchTags()]); await fetchTransactions()transactions 依赖 categories 的映射)
预期效果 并行部分耗时 = max(单次请求),总体减少 40-60%

Perf-3Stats 聚合接口

项目 说明
当前问题 统计页加载时串行调用 /stats/overview + /stats/category + /stats/trend3 次 HTTP 往返
优化策略 合并为单一 /stats/dashboard 接口,后端并行查询后一次性返回
实现方式 后端新路由中 Promise.all([queryOverview, queryCategory, queryTrend]) → 返回 { overview, category, trend }
预期效果 3 次 HTTP 往返 → 1 次;减少约 200ms 网络开销

Perf-4Track 批量 INSERT

项目 说明
当前问题 前端一次页面访问可能触发多条埋点,每条单独 INSERTN 条 = N 次数据库写入
优化策略 前端批量发送,后端使用 INSERT INTO ... VALUES (?), (?), (?) 批量写入
实现方式 前端 tracker.ts 攒批500ms 或 10 条),批量 POST后端 track.ts 接收数组后构建批量 INSERT
预期效果 10 条埋点从 10 次 INSERT 降至 1 次,减少数据库连接开销 90%

Perf-5Admin 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 请求后记录 lastFetchTime30s 内直接返回缓存值;markAllRead()unreadCount = 0 + 清缓存
预期效果 频繁切换页面时减少通知计数请求 80%+

Perf-8Logs 流式读取P2

项目 说明
当前问题 管理后台查看日志时全量加载到内存
优化策略 使用 Node.js stream 分批返回
实现方式 fs.createReadStream(logPath).pipe(res) 或数据库 queryStream()
预期效果 内存占用从 O(n) 降至 O(buffer_size)

Perf-9ChartWrapper 延迟渲染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

实现步骤

  1. 安装依赖:npm i -D jest ts-jest supertest @types/supertest
  2. 创建测试数据库配置:.env.test 指向独立测试库
  3. 编写 tests/setup.ts:每个测试套件前清空测试库 + seed
  4. 编写路由测试:使用 supertest(app) 发送请求,断言状态码 + 返回格式
  5. 配置 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/leaveGroupBUG-03 回归) P0
user login/logout/token 管理 P1

实现步骤

  1. 安装依赖:npm i -D vitest @pinia/testing @vue/test-utils
  2. 创建 vitest.config.ts
  3. Mock API 调用:使用 vi.mock('@/api/*')
  4. 编写 Store 测试:使用 createTestingPinia()
  5. 配置 npm run test:unit 命令

验收标准Store 核心方法测试覆盖率 ≥ 70%。

阶段三API 契约测试P2预计 1 周)

范围:确保前后端 API 接口格式一致

技术栈Jest + JSON Schema 验证

实现方式

  1. 定义 API 响应 Schematests/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' }
          }
        }
      }
    }
    
  2. 集成测试中同时验证响应格式符合 Schema

  3. 前端 request.ts 添加开发模式响应校验

验收标准:所有已定义 Schema 的接口响应 100% 符合契约。

阶段四工具函数单元测试P2预计 1 周)

范围:前端 utils/ 和后端 utils/ 的纯函数测试

覆盖清单

模块 测试用例
format.ts formatAmountformatDate(含跨年 BUG-02 回归)、formatAmountRaw
request.ts 401 重试队列、超时处理、请求参数格式
app-ready.ts waitForReady 正常/超时场景
backup.ts(后端) 备份文件生成、签名校验
date.ts(后端) getCurrentMonthgetMonthRange

验收标准:工具函数测试覆盖率 ≥ 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-17+ P1 UXUX-14 乐观更新、聚合接口、删除撤销、群组只读
Phase 3 第 6-7 周 P1 测试T-1+ P1 新功能F-1/F-2 集成测试覆盖、财务报告推送、预算预警
Phase 4 第 8-10 周 P2 需求C3/C6/C8/C9 + Perf-89 + UX-57 + T-23 + F-35 离线支持、AA 分账、智能记账
Phase 5 远期 P3 需求C10 + Perf-10 路由中间件统一、数据可视化增强

文档版本: v2.0 | 最后更新: 2026-06-10