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

874 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 小菜记账 — 全量迭代 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` | 移除 `|| 'localhost'``|| 'xiaocai'``|| 'xiaocai123'` 等 fallback未配置时 `createPool` 抛出明确错误 |
#### 代码质量
| 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 历史**
```bash
# 使用 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. **强制推送并通知协作者**
```bash
git push origin --force --all
```
4. **验证 `.gitignore` 生效**
```bash
git check-ignore server/.env # 应输出 server/.env
```
5. **添加 pre-commit hook 防止再次提交**
```bash
# .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
```typescript
// ❌ 当前代码
LIMIT ${pSize} OFFSET ${offset}
```
**修复方案**
```typescript
// ✅ 方案 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.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`(弱密钥)
**修复方案**
1. **统一命名为 `TOKEN_SECRET`**
- `backup.ts` 中 `JWT_SECRET` → `TOKEN_SECRET`
- 删除 `const JWT_SECRET = ...` 声明,改为从 `middleware/auth.ts` 导出或统一读取 `process.env.TOKEN_SECRET`
2. **移除所有 fallback 默认值,强制要求环境变量**
```typescript
// 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. **生成强密钥**
```bash
# 在服务器上生成并写入 .env
echo "TOKEN_SECRET=$(openssl rand -hex 32)" >> server/.env
```
4. **所有引用处改为**
```typescript
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` 表**
```sql
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. **生成下载凭证**
```typescript
// 有效期 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. **验证下载凭证**
```typescript
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` 表**
```sql
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 变更**
```sql
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL;
CREATE INDEX idx_users_deleted ON users(deleted_at);
```
2. **后端修改**
```typescript
// 软删除
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.ts` 中 `password: process.env.DB_PASSWORD || 'xiaocai123'` 等硬编码默认值。
**修复方案**
```typescript
// ✅ 修复后
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/trend`3 次 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` 请求后记录 `lastFetchTime`30s 内直接返回缓存值;`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 响应 Schema`tests/schemas/`
```typescript
// 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` | `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~7+ P1 UXUX-1~4 | 乐观更新、聚合接口、删除撤销、群组只读 |
| **Phase 3** | 第 6-7 周 | P1 测试T-1+ P1 新功能F-1/F-2 | 集成测试覆盖、财务报告推送、预算预警 |
| **Phase 4** | 第 8-10 周 | P2 需求C3/C6/C8/C9 + Perf-8~9 + UX-5~7 + T-2~3 + F-3~5 | 离线支持、AA 分账、智能记账 |
| **Phase 5** | 远期 | P3 需求C10 + Perf-10 | 路由中间件统一、数据可视化增强 |
---
*文档版本: v2.0 | 最后更新: 2026-06-10*