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
This commit is contained in:
@@ -9,24 +9,18 @@ export interface ExportParams {
|
|||||||
format?: 'csv' | 'json'
|
format?: 'csv' | 'json'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 请求服务端导出(返回流) */
|
/** 准备导出:获取短期签名 token */
|
||||||
export function requestExport(params: ExportParams) {
|
export function prepareExport(params: ExportParams) {
|
||||||
return request<{ downloadUrl: string }>({
|
return request<{ token: string }>({
|
||||||
url: '/export',
|
url: '/export/prepare',
|
||||||
data: { ...params, format: params.format || 'csv' }
|
data: { ...params, format: params.format || 'csv' }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取服务端导出下载 URL */
|
/** 获取服务端导出下载 URL(使用短期签名 token) */
|
||||||
export function getExportUrl(params: ExportParams): string {
|
export async function getExportUrl(params: ExportParams): Promise<string> {
|
||||||
|
const { token } = await prepareExport(params)
|
||||||
const query = new URLSearchParams()
|
const query = new URLSearchParams()
|
||||||
query.set('startDate', params.startDate)
|
query.set('token', token)
|
||||||
query.set('endDate', params.endDate)
|
|
||||||
if (params.type) query.set('type', params.type)
|
|
||||||
if (params.format) query.set('format', params.format)
|
|
||||||
|
|
||||||
const token = uni.getStorageSync('xc:token')
|
|
||||||
if (token) query.set('token', token)
|
|
||||||
|
|
||||||
return `${API_BASE}/export?${query.toString()}`
|
return `${API_BASE}/export?${query.toString()}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -457,7 +457,7 @@ async function doExport() {
|
|||||||
exportLoading.value = true
|
exportLoading.value = true
|
||||||
try {
|
try {
|
||||||
const { getExportUrl } = await import('@/api/export')
|
const { getExportUrl } = await import('@/api/export')
|
||||||
const url = getExportUrl({
|
const url = await getExportUrl({
|
||||||
startDate: exportStartDate.value,
|
startDate: exportStartDate.value,
|
||||||
endDate: exportEndDate.value,
|
endDate: exportEndDate.value,
|
||||||
type: exportType.value,
|
type: exportType.value,
|
||||||
|
|||||||
919
docs/arch-iteration-v2.md
Normal file
919
docs/arch-iteration-v2.md
Normal file
@@ -0,0 +1,919 @@
|
|||||||
|
# 小菜记账 — 全量迭代架构设计 v2
|
||||||
|
|
||||||
|
> 版本: v2.0 | 日期: 2026-06-10
|
||||||
|
> 架构师: 高见远(Gao)
|
||||||
|
> 基线: PRD v2.0(35 项改进点,6 个维度)
|
||||||
|
> 技术栈: Uni-app + Vue 3 + Pinia + SCSS (前端) | Node.js + Express + MySQL 8.0 (后端)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、实现方案 + 技术选型
|
||||||
|
|
||||||
|
### 1.1 安全修复方案
|
||||||
|
|
||||||
|
#### S1:.env 凭据泄露修复
|
||||||
|
|
||||||
|
| 项目 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| 核心思路 | 凭据轮换 + Git 历史重写 + pre-commit 防护 |
|
||||||
|
| 工具 | `git-filter-repo`(Python,比 BFG 更安全,支持 blob 回调替换) |
|
||||||
|
| 防护 | `.githooks/pre-commit` 拦截 `.env` 文件提交 |
|
||||||
|
| 回滚 | 操作前 `git clone --mirror` 完整备份 |
|
||||||
|
| 执行顺序 | ① 轮换所有凭据 → ② 重写 Git 历史 → ③ 强制推送 → ④ 验证 .gitignore |
|
||||||
|
|
||||||
|
#### S2:logs.ts SQL 拼接修复
|
||||||
|
|
||||||
|
| 项目 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| 当前问题 | `LIMIT ${pSize} OFFSET ${offset}` 模板字符串直接拼接 |
|
||||||
|
| 修复方案 | `parseInt` + 范围校验后使用 `pool.query` + `?` 占位符 |
|
||||||
|
| 关键点 | `pool.query` 支持 `?` 占位符用于整数参数;`pool.execute` 的 prepared statement 对 LIMIT/OFFSET 类型要求严格,故选用 `pool.query` |
|
||||||
|
|
||||||
|
#### S3:TOKEN_SECRET 弱默认值 + 命名不一致
|
||||||
|
|
||||||
|
| 项目 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| 核心思路 | 新增 `server/src/config/token.ts` 集中管理,移除所有 fallback |
|
||||||
|
| 命名统一 | `backup.ts` 中 `JWT_SECRET` → `TOKEN_SECRET`;所有引用改为 `import { TOKEN_SECRET } from '../config/token'` |
|
||||||
|
| 启动校验 | `TOKEN_SECRET` 未设置或长度 < 32 时 `process.exit(1)` |
|
||||||
|
| 密钥生成 | `openssl rand -hex 32` 生成 64 字符强密钥 |
|
||||||
|
|
||||||
|
#### S4:Export/Backup 短期一次性下载凭证
|
||||||
|
|
||||||
|
| 项目 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| 核心思路 | 新增 `download_tokens` 表,生成随机凭证(`crypto.randomBytes(32)`) |
|
||||||
|
| 有效期 | 5 分钟,一次性使用后标记 `used_at` |
|
||||||
|
| 存储 | MySQL 表(写入频率极低,无需 Redis) |
|
||||||
|
| 清理 | 健康检查时附带清理 `expires_at < NOW()` 的记录 |
|
||||||
|
| 改动 | `backup.ts` 和 `export.ts` 的下载逻辑改为先获取凭证再用凭证下载 |
|
||||||
|
|
||||||
|
#### S5:Refresh Token 机制
|
||||||
|
|
||||||
|
| 项目 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| 架构 | 双 Token 机制:Access Token(2h)+ Refresh Token(30d) |
|
||||||
|
| 存储 | `refresh_tokens` 表,仅存储 SHA-256 hash(不存明文) |
|
||||||
|
| 轮换 | 每次刷新后旧 Refresh Token 立即失效(`revoked_at`),返回新 Token |
|
||||||
|
| 前端适配 | `request.ts` 401 → 尝试 refresh → 成功则重试原请求 → refresh 也失败则清 token 跳登录 |
|
||||||
|
| 安全 | 修改密码时撤销所有 Refresh Token;单用户最多 5 个有效 Refresh Token |
|
||||||
|
| 优势 | 保持现有 HMAC-SHA256 签名方式不变,仅缩短 Access Token 有效期 |
|
||||||
|
|
||||||
|
#### S6:Admin 硬删除 → 软删除 + 二次确认
|
||||||
|
|
||||||
|
| 项目 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| DB 变更 | `users` 表新增 `deleted_at DATETIME DEFAULT NULL` + 索引 |
|
||||||
|
| 删除操作 | `UPDATE users SET deleted_at = NOW() WHERE id = ?` |
|
||||||
|
| 恢复接口 | `POST /api/admin/users/:id/restore` → `SET deleted_at = NULL` |
|
||||||
|
| 全局过滤 | 所有涉及 `users` 表的查询增加 `WHERE deleted_at IS NULL` |
|
||||||
|
| 前端确认 | 管理页点击删除 → 弹窗二次确认 → 调用软删除接口 |
|
||||||
|
|
||||||
|
#### S7:DB 连接移除默认凭据
|
||||||
|
|
||||||
|
| 项目 | 方案 |
|
||||||
|
|------|------|
|
||||||
|
| 修复 | 移除所有 `|| 'localhost'`、`|| 'xiaocai'`、`|| 'xiaocai123'` fallback |
|
||||||
|
| 启动校验 | 遍历必需环境变量,缺失任一即 `process.exit(1)` |
|
||||||
|
|
||||||
|
### 1.2 通用组件抽取方案
|
||||||
|
|
||||||
|
#### ManageList 组件
|
||||||
|
|
||||||
|
```
|
||||||
|
client/src/components/ManageList/ManageList.vue
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- items: T[] // 列表数据
|
||||||
|
- displayField: string // 显示字段名
|
||||||
|
- colorField?: string // 颜色字段名(标签用)
|
||||||
|
- iconField?: string // 图标字段名(分类用)
|
||||||
|
- showDrag?: boolean // 是否显示拖拽排序
|
||||||
|
- showEdit?: boolean // 是否显示编辑按钮
|
||||||
|
- showDelete?: boolean // 是否显示删除按钮
|
||||||
|
- emptyText?: string // 空状态文案
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- add()
|
||||||
|
- edit(item: T)
|
||||||
|
- delete(item: T)
|
||||||
|
- sort(ids: number[])
|
||||||
|
|
||||||
|
Slots:
|
||||||
|
- item-icon="{ item }" // 自定义图标区域(分类用 CategoryIcon)
|
||||||
|
- item-badge="{ item }" // 自定义标签区域
|
||||||
|
```
|
||||||
|
|
||||||
|
#### ColorPicker 组件
|
||||||
|
|
||||||
|
```
|
||||||
|
client/src/components/ColorPicker/ColorPicker.vue
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- modelValue: string // 当前选中颜色
|
||||||
|
- colors?: string[] // 可选颜色列表(默认 8 色)
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- update:modelValue(color: string)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### EditModal 组件
|
||||||
|
|
||||||
|
```
|
||||||
|
client/src/components/EditModal/EditModal.vue
|
||||||
|
|
||||||
|
Props:
|
||||||
|
- visible: boolean
|
||||||
|
- title: string
|
||||||
|
- fields: EditField[] // 动态字段配置
|
||||||
|
- modelValue: Record<string, any>
|
||||||
|
|
||||||
|
Emits:
|
||||||
|
- update:visible(val: boolean)
|
||||||
|
- confirm(data: Record<string, any>)
|
||||||
|
|
||||||
|
interface EditField {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: 'text' | 'color'
|
||||||
|
placeholder?: string
|
||||||
|
maxlength?: number
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 乐观更新实现模式
|
||||||
|
|
||||||
|
```
|
||||||
|
模式:先写本地 state,再异步调接口,失败则回滚
|
||||||
|
|
||||||
|
async function addCategory(data) {
|
||||||
|
// 1. 生成临时 ID(负数,避免与真实 ID 冲突)
|
||||||
|
const tempId = -Date.now()
|
||||||
|
const optimisticItem = { id: tempId, ...data }
|
||||||
|
|
||||||
|
// 2. 立即更新本地 state
|
||||||
|
categories.value.push(optimisticItem)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 3. 调用 API
|
||||||
|
const result = await api.createCategory(data)
|
||||||
|
|
||||||
|
// 4. 用真实 ID 替换临时 ID
|
||||||
|
const idx = categories.value.findIndex(c => c.id === tempId)
|
||||||
|
if (idx !== -1) categories.value[idx].id = result.id
|
||||||
|
} catch (err) {
|
||||||
|
// 5. 回滚:移除临时项
|
||||||
|
categories.value = categories.value.filter(c => c.id !== tempId)
|
||||||
|
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
适用范围:
|
||||||
|
✅ 新增分类/标签、编辑分类/标签、拖拽排序
|
||||||
|
❌ 删除操作仍等接口确认(防误删)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.4 Refresh Token 实现方案
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐ POST /auth/login ┌──────────┐
|
||||||
|
│ Client │ ──────────────────────────>│ Server │
|
||||||
|
│ │<──────────────────────────│ │
|
||||||
|
│ │ { accessToken, refreshToken } │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ GET /api/xxx │ │
|
||||||
|
│ │ Header: Bearer <accessToken> │
|
||||||
|
│ │ ──────────────────────────>│ │
|
||||||
|
│ │<──────────────────────────│ │
|
||||||
|
│ │ 200 OK │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ GET /api/xxx (token 过期) │ │
|
||||||
|
│ │ ──────────────────────────>│ │
|
||||||
|
│ │<──────────────────────────│ │
|
||||||
|
│ │ 401 { code: 40101 } │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ POST /auth/refresh │ │
|
||||||
|
│ │ { refreshToken } │ │
|
||||||
|
│ │ ──────────────────────────>│ │
|
||||||
|
│ │<──────────────────────────│ │
|
||||||
|
│ │ { accessToken, refreshToken } │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 重试原请求 │ │
|
||||||
|
│ │ ──────────────────────────>│ │
|
||||||
|
└──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**后端关键实现**:
|
||||||
|
- `auth.ts` 登录成功时:生成 Access Token(2h)+ Refresh Token(randomBytes(32)),存 hash 到 `refresh_tokens` 表
|
||||||
|
- 新增 `POST /auth/refresh`:验证 Refresh Token hash → 撤销旧 Token → 生成新双 Token
|
||||||
|
- `middleware/auth.ts`:Access Token 过期返回 `40101`(区别于无效 Token 的 `40100`)
|
||||||
|
|
||||||
|
**前端关键实现**:
|
||||||
|
- `request.ts`:收到 `40101` → 调用 `/auth/refresh` → 成功则重试 → 失败则清 token 跳登录
|
||||||
|
- 存储:`xc:accessToken` + `xc:refreshToken` 分开存储
|
||||||
|
- 并发请求时只触发一次 refresh,其他请求排队等待
|
||||||
|
|
||||||
|
### 1.5 软删除方案
|
||||||
|
|
||||||
|
```
|
||||||
|
影响范围:
|
||||||
|
- users 表:新增 deleted_at 字段
|
||||||
|
- 所有 JOIN users 的查询:增加 deleted_at IS NULL 过滤
|
||||||
|
- admin.ts:DELETE → UPDATE SET deleted_at
|
||||||
|
- 新增恢复接口
|
||||||
|
- 登录验证:检查 deleted_at IS NULL
|
||||||
|
|
||||||
|
具体改动位置:
|
||||||
|
1. schema.sql / migrate.sql:ALTER TABLE
|
||||||
|
2. auth.ts 登录:WHERE openid = ? AND deleted_at IS NULL
|
||||||
|
3. admin.ts 用户列表:WHERE deleted_at IS NULL(或增加"已禁用"筛选)
|
||||||
|
4. transaction.ts 列表查询:JOIN users 时增加 u.deleted_at IS NULL
|
||||||
|
5. 群组统计/成员列表:增加 deleted_at 过滤
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.6 iconfont 迁移方案
|
||||||
|
|
||||||
|
```
|
||||||
|
当前状态:Icon.vue 使用 PNG 图片映射
|
||||||
|
迁移方案:
|
||||||
|
1. 在 iconfont.cn 创建项目,上传 SVG 图标
|
||||||
|
2. 生成字体文件(ttf),放入 client/src/static/iconfont/
|
||||||
|
3. 新建 client/src/styles/iconfont.scss,@font-face 声明
|
||||||
|
4. Icon.vue 改为 <text class="iconfont icon-xxx" /> 方式渲染
|
||||||
|
5. 保留 name prop 接口不变,内部映射改为 class 名
|
||||||
|
6. 额外图标(如 lock)直接在 iconfont 项目中添加
|
||||||
|
|
||||||
|
收益:
|
||||||
|
- 包体积减小(字体 < 20KB vs 多个 PNG)
|
||||||
|
- 支持动态颜色(color prop 直接生效)
|
||||||
|
- 新增图标只需上传 SVG,无需切图
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.7 离线队列方案
|
||||||
|
|
||||||
|
```
|
||||||
|
架构设计:
|
||||||
|
|
||||||
|
1. 离线队列管理器(client/src/utils/offline.ts)
|
||||||
|
- pendingOps: 存储于 uni.setStorageSync('xc:pendingOps')
|
||||||
|
- 数据结构: Array<{ id: string, type: 'create', data: object, createdAt: number, status: 'pending' | 'failed' }>
|
||||||
|
- MVP 仅支持 create(新增记账)
|
||||||
|
|
||||||
|
2. 网络状态监听
|
||||||
|
- App.vue onLaunch 中注册 uni.onNetworkStatusChange
|
||||||
|
- 网络恢复时自动调用 processPendingOps()
|
||||||
|
|
||||||
|
3. 操作流程
|
||||||
|
- 有网络:正常调接口
|
||||||
|
- 无网络:
|
||||||
|
├── 写入 pendingOps
|
||||||
|
├── 本地 state 立即更新(乐观更新 + "待同步"标记)
|
||||||
|
└── Toast "已保存,将在网络恢复后同步"
|
||||||
|
|
||||||
|
4. 同步流程
|
||||||
|
- 按时间顺序逐条执行
|
||||||
|
- 全部成功:清除队列 + toast "同步完成"
|
||||||
|
- 部分失败:标记失败项 + 保留在队列 + toast "N 条同步失败"
|
||||||
|
|
||||||
|
5. 网络检测
|
||||||
|
- uni.getNetworkType() 获取当前网络状态
|
||||||
|
- 封装 isOnline(): boolean 工具函数
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.8 财务报告推送方案
|
||||||
|
|
||||||
|
```
|
||||||
|
架构设计:
|
||||||
|
|
||||||
|
1. 定时任务(server/src/services/scheduler.ts)
|
||||||
|
- 使用 node-cron 实现
|
||||||
|
- 每周一 09:00:生成上周收支摘要 → 推送
|
||||||
|
- 每月 1 号 09:00:生成上月收支报告 → 推送
|
||||||
|
|
||||||
|
2. 报告生成(server/src/services/report.ts)
|
||||||
|
- 查询指定时间范围的收支数据
|
||||||
|
- 生成摘要文本(总支出/收入、Top 分类、日均消费等)
|
||||||
|
|
||||||
|
3. 微信订阅消息(server/src/services/wechat-subscribe.ts)
|
||||||
|
- 调用微信 subscribeMessage.send 接口
|
||||||
|
- 模板 ID 需在微信公众平台申请
|
||||||
|
- 用户需先授权订阅(一次性授权,每次推送需用户主动触发授权)
|
||||||
|
|
||||||
|
4. 降级策略
|
||||||
|
- 优先站内通知(现有 notifications 系统)
|
||||||
|
- 微信订阅消息作为增强项
|
||||||
|
- 如果模板审核不通过,仅保留站内通知
|
||||||
|
|
||||||
|
5. 用户设置
|
||||||
|
- user_settings 表存储推送开关
|
||||||
|
- 客户端设置页增加"财务报告推送"开关
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.9 预算预警方案
|
||||||
|
|
||||||
|
```
|
||||||
|
架构设计:
|
||||||
|
|
||||||
|
1. 双重检查策略
|
||||||
|
- 实时检查:记账后立即检查预算(精准、低开销)
|
||||||
|
- 每日全量扫描:凌晨定时扫描所有用户预算(覆盖周期记账等非手动场景)
|
||||||
|
|
||||||
|
2. 预警等级
|
||||||
|
- 80%:站内通知(type: 'personal', is_urgent: false)
|
||||||
|
- 100%:站内通知 + 微信订阅消息
|
||||||
|
- 120%:站内紧急通知(is_urgent: true)+ 微信订阅消息
|
||||||
|
|
||||||
|
3. 去重机制
|
||||||
|
- 同一用户同一月同一等级只推送一次
|
||||||
|
- budget_alerts 表记录已推送的预警(user_id + month + level → UNIQUE)
|
||||||
|
|
||||||
|
4. 触发点
|
||||||
|
- transaction.ts POST 创建记账后 → 调用 checkBudgetAlert(userId, month)
|
||||||
|
- scheduler.ts 每日扫描 → 批量 checkBudgetAlert
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.10 测试框架选型
|
||||||
|
|
||||||
|
| 层级 | 框架 | 理由 |
|
||||||
|
|------|------|------|
|
||||||
|
| 后端集成测试 | Jest + supertest | Express 生态标配;supertest 无需启动真实服务器;与 ts-jest 配合良好 |
|
||||||
|
| 前端 Store 测试 | Vitest + @pinia/testing | Vitest 与 Vite 原生集成(Uni-app 基于 Vite);@pinia/testing 提供 createTestingPinia() |
|
||||||
|
| 工具函数测试 | Jest(后端)+ Vitest(前端) | 纯函数测试最简单,适合作为测试练手 |
|
||||||
|
| API 契约测试 | Jest + ajv(JSON Schema) | 轻量级方案,不需要引入完整的契约测试框架 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、文件列表及相对路径
|
||||||
|
|
||||||
|
### 2.1 新建文件
|
||||||
|
|
||||||
|
| 文件路径 | 用途 |
|
||||||
|
|----------|------|
|
||||||
|
| `server/src/config/token.ts` | TOKEN_SECRET 集中配置与启动校验 |
|
||||||
|
| `server/src/services/scheduler.ts` | 定时任务调度(node-cron) |
|
||||||
|
| `server/src/services/report.ts` | 财务报告生成逻辑 |
|
||||||
|
| `server/src/services/budget-alert.ts` | 预算预警检查与推送逻辑 |
|
||||||
|
| `server/src/services/wechat-subscribe.ts` | 微信订阅消息发送封装 |
|
||||||
|
| `server/src/utils/upload.ts` | 公共上传处理函数(C3 抽取) |
|
||||||
|
| `server/src/routes/report.ts` | 财务报告 API 路由 |
|
||||||
|
| `server/tests/setup.ts` | 测试数据库初始化与清理 |
|
||||||
|
| `server/tests/auth.test.ts` | 认证路由集成测试 |
|
||||||
|
| `server/tests/transaction.test.ts` | 交易路由集成测试 |
|
||||||
|
| `server/tests/category.test.ts` | 分类路由集成测试 |
|
||||||
|
| `server/tests/budget.test.ts` | 预算路由集成测试 |
|
||||||
|
| `server/tests/stats.test.ts` | 统计路由集成测试 |
|
||||||
|
| `server/jest.config.js` | Jest 配置 |
|
||||||
|
| `server/.env.test` | 测试数据库环境变量 |
|
||||||
|
| `client/src/components/ManageList/ManageList.vue` | 通用管理列表组件 |
|
||||||
|
| `client/src/components/ColorPicker/ColorPicker.vue` | 颜色选择器组件 |
|
||||||
|
| `client/src/components/EditModal/EditModal.vue` | 通用编辑弹窗组件 |
|
||||||
|
| `client/src/components/Snackbar/Snackbar.vue` | 删除撤销 Snackbar 组件 |
|
||||||
|
| `client/src/utils/offline.ts` | 离线队列管理器 |
|
||||||
|
| `client/src/utils/avatar.ts` | 头像 URL 拼接工具函数(C9) |
|
||||||
|
| `client/src/static/iconfont/` | iconfont 字体文件目录 |
|
||||||
|
| `client/src/styles/iconfont.scss` | iconfont 样式声明 |
|
||||||
|
| `client/tests/stores/transaction.test.ts` | 交易 Store 单元测试 |
|
||||||
|
| `client/tests/stores/category.test.ts` | 分类 Store 单元测试 |
|
||||||
|
| `client/tests/utils/format.test.ts` | 格式化工具函数测试 |
|
||||||
|
| `client/tests/utils/app-ready.test.ts` | app-ready 工具函数测试 |
|
||||||
|
| `client/vitest.config.ts` | Vitest 配置 |
|
||||||
|
| `.githooks/pre-commit` | 防止 .env 提交的 Git 钩子 |
|
||||||
|
|
||||||
|
### 2.2 修改文件
|
||||||
|
|
||||||
|
| 文件路径 | 主要变更 |
|
||||||
|
|----------|----------|
|
||||||
|
| **后端 - 安全** | |
|
||||||
|
| `server/src/db/connection.ts` | 移除默认凭据 fallback,增加启动校验 |
|
||||||
|
| `server/src/middleware/auth.ts` | 引用 config/token.ts;Access Token 有效期缩短为 2h;区分 40100/40101 错误码 |
|
||||||
|
| `server/src/routes/auth.ts` | 引用 config/token.ts;实现双 Token 机制;新增 POST /refresh 接口 |
|
||||||
|
| `server/src/routes/backup.ts` | JWT_SECRET → TOKEN_SECRET;下载改用 download_tokens 凭证 |
|
||||||
|
| `server/src/routes/admin.ts` | 硬删除 → 软删除;新增恢复接口;N+1 优化 |
|
||||||
|
| `server/src/routes/logs.ts` | SQL 拼接改为参数化查询 |
|
||||||
|
| `server/src/routes/export.ts` | 下载改用 download_tokens 凭证 |
|
||||||
|
| **后端 - 代码质量** | |
|
||||||
|
| `server/src/routes/recurring.ts` | sync 接口增加事务包裹 |
|
||||||
|
| `server/src/routes/user.ts` | 上传逻辑抽取使用公共函数 |
|
||||||
|
| `server/src/routes/notification.ts` | 上传逻辑抽取使用公共函数 |
|
||||||
|
| **后端 - 性能** | |
|
||||||
|
| `server/src/routes/stats.ts` | 新增 GET /dashboard 聚合接口 |
|
||||||
|
| `server/src/routes/track.ts` | 逐条 INSERT → 批量 INSERT |
|
||||||
|
| `server/src/routes/budget.ts` | 支持 group_id 参数;记账后触发预算预警 |
|
||||||
|
| `server/src/routes/transaction.ts` | 创建记账后触发预算预警检查 |
|
||||||
|
| **后端 - DB** | |
|
||||||
|
| `server/src/db/schema.sql` | 新增 download_tokens、refresh_tokens、budget_alerts、user_settings 表;users 增加 deleted_at |
|
||||||
|
| `server/src/db/migrate.sql` | 对应迁移脚本 |
|
||||||
|
| **后端 - 入口** | |
|
||||||
|
| `server/src/index.ts` | 移除 TOKEN_SECRET 警告;注册 report 路由;初始化 scheduler |
|
||||||
|
| **前端 - 代码质量** | |
|
||||||
|
| `client/src/stores/category.ts` | 乐观更新;try/catch;缓存标记 lastFetchTime |
|
||||||
|
| `client/src/stores/tag.ts` | 乐观更新;try/catch;缓存标记 lastFetchTime |
|
||||||
|
| `client/src/stores/transaction.ts` | 删除撤销;try/catch |
|
||||||
|
| `client/src/stores/budget.ts` | try/catch;支持 group_id |
|
||||||
|
| `client/src/stores/notification.ts` | 未读计数缓存 30s |
|
||||||
|
| `client/src/stores/group.ts` | refreshAll 并行加载 |
|
||||||
|
| `client/src/stores/stats.ts` | 使用 dashboard 聚合接口 |
|
||||||
|
| `client/src/stores/user.ts` | Refresh Token 适配 |
|
||||||
|
| `client/src/utils/format.ts` | 去重(getCurrentMonth 已在后端存在) |
|
||||||
|
| `client/src/utils/request.ts` | Refresh Token 重试逻辑 |
|
||||||
|
| `client/src/utils/app-ready.ts` | 超时 reject(非静默 resolve) |
|
||||||
|
| `client/src/utils/tracker.ts` | 攒批发送(500ms 或 10 条) |
|
||||||
|
| `client/src/config.ts` | 已集中化,搜索残留硬编码 URL |
|
||||||
|
| `client/src/components/Icon/Icon.vue` | PNG → iconfont 字体图标 |
|
||||||
|
| `client/src/components/TransactionItem/TransactionItem.vue` | 群组只读标记 |
|
||||||
|
| `client/src/components/ChartWrapper/` | 延迟渲染(IntersectionObserver) |
|
||||||
|
| **前端 - 页面** | |
|
||||||
|
| `client/src/pages/category-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
|
||||||
|
| `client/src/pages/tag-manage/index.vue` | 使用 ManageList/ColorPicker/EditModal 组件 |
|
||||||
|
| `client/src/pages/bills/index.vue` | 删除撤销 Snackbar |
|
||||||
|
| `client/src/pages/budget/index.vue` | 群组预算视图 |
|
||||||
|
| `client/src/pages/stats/index.vue` | 使用 dashboard 接口 |
|
||||||
|
| `client/src/pages/add/index.vue` | 离线记账支持 |
|
||||||
|
| `client/src/pages.json` | 全局 enablePullDownRefresh |
|
||||||
|
| `client/src/App.vue` | 注册网络状态监听 |
|
||||||
|
| **配置** | |
|
||||||
|
| `server/package.json` | 新增 jest、ts-jest、supertest、node-cron 依赖 |
|
||||||
|
| `client/package.json` | 新增 vitest、@pinia/testing、@vue/test-utils 依赖 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据结构和接口
|
||||||
|
|
||||||
|
### 3.1 新增/修改的数据库表结构
|
||||||
|
|
||||||
|
#### 新增表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 一次性下载凭证表(S4)
|
||||||
|
CREATE TABLE IF NOT EXISTS download_tokens (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
token VARCHAR(64) NOT NULL UNIQUE COMMENT '随机凭证',
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
resource_type ENUM('backup', 'export') NOT NULL COMMENT '资源类型',
|
||||||
|
resource_id VARCHAR(100) DEFAULT '' COMMENT '资源标识(备份名等)',
|
||||||
|
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||||
|
used_at DATETIME DEFAULT NULL COMMENT '使用时间(NULL=未使用)',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_token (token),
|
||||||
|
INDEX idx_expires (expires_at),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Refresh Token 表(S5)
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
token_hash VARCHAR(64) NOT NULL COMMENT 'SHA-256(token)',
|
||||||
|
expires_at DATETIME NOT NULL COMMENT '过期时间',
|
||||||
|
revoked_at DATETIME DEFAULT NULL COMMENT '撤销时间(NULL=有效)',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_user (user_id),
|
||||||
|
INDEX idx_hash (token_hash),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 预算预警记录表(F-2,去重用)
|
||||||
|
CREATE TABLE IF NOT EXISTS budget_alerts (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
user_id INT NOT NULL,
|
||||||
|
month VARCHAR(7) NOT NULL COMMENT '格式:2026-05',
|
||||||
|
level ENUM('80', '100', '120') NOT NULL COMMENT '预警等级',
|
||||||
|
group_id INT DEFAULT NULL COMMENT '群组ID(NULL=个人)',
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_user_month_level (user_id, month, level, group_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 用户设置表(F-1 报告推送开关等)
|
||||||
|
CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
|
user_id INT PRIMARY KEY,
|
||||||
|
report_push_enabled TINYINT(1) DEFAULT 1 COMMENT '是否开启财务报告推送',
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 修改表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- users 表:新增软删除字段(S6)
|
||||||
|
ALTER TABLE users ADD COLUMN deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间(NULL=正常)';
|
||||||
|
CREATE INDEX idx_users_deleted ON users(deleted_at);
|
||||||
|
|
||||||
|
-- budgets 表:新增 group_id 支持(UX-4)
|
||||||
|
ALTER TABLE budgets DROP INDEX uk_user_month;
|
||||||
|
ALTER TABLE budgets ADD COLUMN group_id INT DEFAULT NULL COMMENT '群组ID(NULL=个人预算)';
|
||||||
|
ALTER TABLE budgets ADD UNIQUE KEY uk_user_group_month (user_id, group_id, month);
|
||||||
|
ALTER TABLE budgets ADD INDEX idx_group_month (group_id, month);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 新增/修改的 API 端点定义
|
||||||
|
|
||||||
|
#### 新增端点
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 请求体 / 参数 | 响应 |
|
||||||
|
|------|------|------|---------------|------|
|
||||||
|
| POST | `/api/auth/refresh` | 刷新 Access Token | `{ refreshToken: string }` | `{ code: 0, data: { accessToken, refreshToken, expiresIn } }` |
|
||||||
|
| GET | `/api/stats/dashboard` | 聚合统计(一次返回 overview + category + trend) | `?month=2026-06&type=expense&period=month&group_id=null` | `{ code: 0, data: { overview, category, trend } }` |
|
||||||
|
| POST | `/api/admin/users/:id/restore` | 恢复软删除用户 | — | `{ code: 0 }` |
|
||||||
|
| POST | `/api/download-tokens` | 生成下载凭证 | `{ resourceType: 'backup'|'export', resourceId: string }` | `{ code: 0, data: { token, downloadUrl } }` |
|
||||||
|
| GET | `/api/download/:token` | 使用凭证下载文件 | `:token` 路径参数 | 文件流或 403 |
|
||||||
|
| GET | `/api/reports/weekly` | 获取上周报告 | — | `{ code: 0, data: { startDate, endDate, expense, income, topCategories, dailyAvg } }` |
|
||||||
|
| GET | `/api/reports/monthly` | 获取上月报告 | — | `{ code: 0, data: { month, expense, income, topCategories, dailyAvg, monthOverMonth } }` |
|
||||||
|
| GET | `/api/user/settings` | 获取用户设置 | — | `{ code: 0, data: { reportPushEnabled, ... } }` |
|
||||||
|
| PUT | `/api/user/settings` | 更新用户设置 | `{ reportPushEnabled?: boolean }` | `{ code: 0 }` |
|
||||||
|
|
||||||
|
#### 修改端点
|
||||||
|
|
||||||
|
| 方法 | 路径 | 变更说明 |
|
||||||
|
|------|------|----------|
|
||||||
|
| POST | `/api/auth/login` | 返回值新增 `refreshToken` 和 `expiresIn` 字段 |
|
||||||
|
| POST | `/api/auth/demo-login` | 同上 |
|
||||||
|
| DELETE | `/api/admin/users/:id` | 改为软删除(`SET deleted_at = NOW()`) |
|
||||||
|
| GET | `/api/budget` | 支持 `group_id` 参数;群组视图返回 `groupTotal` + `myAmount` |
|
||||||
|
| POST | `/api/budget` | 支持 `group_id` 参数 |
|
||||||
|
| POST | `/api/transactions` | 创建后触发预算预警检查 |
|
||||||
|
| POST | `/api/track` | 支持批量 `events[]` 数组,后端批量 INSERT |
|
||||||
|
| GET | `/api/backup/:id/download` | 改用 download_tokens 凭证 |
|
||||||
|
| GET | `/api/export` | 改用 download_tokens 凭证(GET 带 token 参数改为先获取凭证再下载) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、程序调用流图
|
||||||
|
|
||||||
|
### 4.1 Refresh Token 刷新流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client (request.ts)
|
||||||
|
participant S as Server (auth.ts)
|
||||||
|
participant M as Server (auth middleware)
|
||||||
|
participant DB as MySQL (refresh_tokens)
|
||||||
|
|
||||||
|
Note over C,S: 1. 正常登录
|
||||||
|
C->>S: POST /auth/login { code }
|
||||||
|
S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
|
||||||
|
S-->>C: { accessToken(2h), refreshToken(30d), userId }
|
||||||
|
|
||||||
|
Note over C,S: 2. API 调用(Access Token 有效)
|
||||||
|
C->>M: GET /api/xxx Authorization: Bearer <accessToken>
|
||||||
|
M->>M: 验证 HMAC 签名 + 有效期
|
||||||
|
M-->>C: 200 OK { data }
|
||||||
|
|
||||||
|
Note over C,S: 3. Access Token 过期
|
||||||
|
C->>M: GET /api/xxx Authorization: Bearer <expiredToken>
|
||||||
|
M->>M: 签名有效但已过期
|
||||||
|
M-->>C: 401 { code: 40101, message: 'token已过期' }
|
||||||
|
|
||||||
|
Note over C,S: 4. 自动刷新
|
||||||
|
C->>S: POST /auth/refresh { refreshToken }
|
||||||
|
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
|
||||||
|
DB-->>S: 找到记录
|
||||||
|
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=?
|
||||||
|
S->>DB: INSERT refresh_tokens (新 token_hash)
|
||||||
|
S-->>C: { accessToken, refreshToken, expiresIn }
|
||||||
|
C->>C: 存储新 Token,重试原请求
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 删除撤销流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant U as User
|
||||||
|
participant P as BillsPage
|
||||||
|
participant S as TransactionStore
|
||||||
|
participant SN as Snackbar
|
||||||
|
participant API as Server API
|
||||||
|
|
||||||
|
U->>P: 左滑删除记录
|
||||||
|
P->>S: pendingDelete(id, item, index)
|
||||||
|
S->>S: 从 transactions 列表移除
|
||||||
|
S->>SN: 显示 Snackbar "已删除 1 条记录 [撤销]"
|
||||||
|
SN-->>P: 3秒倒计时
|
||||||
|
|
||||||
|
alt 3秒内点击撤销
|
||||||
|
U->>SN: 点击 [撤销]
|
||||||
|
SN->>S: cancelDelete(id)
|
||||||
|
S->>S: 恢复到列表原位置
|
||||||
|
SN->>SN: 消失
|
||||||
|
else 3秒超时
|
||||||
|
SN->>S: confirmDelete(id)
|
||||||
|
S->>API: DELETE /api/transactions/:id
|
||||||
|
alt 删除成功
|
||||||
|
API-->>S: 200 OK
|
||||||
|
S->>S: 清除 pendingDelete 记录
|
||||||
|
else 删除失败
|
||||||
|
API-->>S: 500 Error
|
||||||
|
S->>S: 恢复到列表原位置
|
||||||
|
S->>U: toast "删除失败"
|
||||||
|
end
|
||||||
|
SN->>SN: 消失
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 预算预警触发流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant C as Client
|
||||||
|
participant T as Transaction Route
|
||||||
|
participant BA as BudgetAlert Service
|
||||||
|
participant DB as MySQL
|
||||||
|
participant NS as Notification System
|
||||||
|
participant WX as WeChat API
|
||||||
|
|
||||||
|
C->>T: POST /api/transactions { amount, type, date }
|
||||||
|
T->>DB: INSERT INTO transactions
|
||||||
|
T->>BA: checkBudgetAlert(userId, month)
|
||||||
|
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
|
||||||
|
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND month=? AND type='expense'
|
||||||
|
|
||||||
|
alt 支出/预算 >= 80% 且未推送过
|
||||||
|
BA->>DB: INSERT budget_alerts (level='80')
|
||||||
|
BA->>NS: 创建站内通知
|
||||||
|
NS->>DB: INSERT INTO notifications
|
||||||
|
else 支出/预算 >= 100%
|
||||||
|
BA->>DB: INSERT budget_alerts (level='100')
|
||||||
|
BA->>NS: 创建站内通知
|
||||||
|
BA->>WX: 发送微信订阅消息
|
||||||
|
else 支出/预算 >= 120%
|
||||||
|
BA->>DB: INSERT budget_alerts (level='120')
|
||||||
|
BA->>NS: 创建紧急站内通知 (is_urgent=true)
|
||||||
|
BA->>WX: 发送微信订阅消息
|
||||||
|
end
|
||||||
|
|
||||||
|
T-->>C: { code: 0, data: { id } }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、任务列表
|
||||||
|
|
||||||
|
> 按批次分组,每批次包含 1 个任务,共 5 个任务
|
||||||
|
> 优先级:P0(阻塞上线)> P1(必须有)> P2(锦上添花)
|
||||||
|
|
||||||
|
### T01: 项目基础设施与安全修复
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **任务 ID** | T01 |
|
||||||
|
| **优先级** | P0 |
|
||||||
|
| **描述** | 搭建项目基础设施(依赖安装、配置文件、入口文件、数据库迁移),并完成所有 P0/P1 安全修复(S1-S7) |
|
||||||
|
| **依赖** | 无 |
|
||||||
|
| **涉及文件** | **新建**:`server/src/config/token.ts`, `.githooks/pre-commit`, `server/.env.test`, `server/jest.config.js`, `client/vitest.config.ts` |
|
||||||
|
| | **修改**:`server/package.json`, `client/package.json`, `server/src/db/schema.sql`, `server/src/db/migrate.sql`, `server/src/db/connection.ts`, `server/src/middleware/auth.ts`, `server/src/routes/auth.ts`, `server/src/routes/backup.ts`, `server/src/routes/admin.ts`, `server/src/routes/logs.ts`, `server/src/routes/export.ts`, `server/src/index.ts`, `client/src/pages.json` |
|
||||||
|
|
||||||
|
**详细子项**:
|
||||||
|
|
||||||
|
1. **S1 .env 凭据泄露修复**:安装 git-filter-repo、重写历史、添加 pre-commit 钩子
|
||||||
|
2. **S2 SQL 参数化**:`logs.ts:167` LIMIT/OFFSET 改为 `?` 占位符 + parseInt 校验
|
||||||
|
3. **S3 TOKEN_SECRET 统一**:新建 `config/token.ts`,统一命名 + 强制启动校验 + 生成强密钥
|
||||||
|
4. **S4 下载凭证**:新建 `download_tokens` 表,改造 backup.ts / export.ts 下载逻辑
|
||||||
|
5. **S5 Refresh Token**:新建 `refresh_tokens` 表,双 Token 机制(Access 2h + Refresh 30d),前端 request.ts 401 自动刷新
|
||||||
|
6. **S6 软删除**:`users` 表新增 `deleted_at`,admin.ts DELETE → UPDATE,新增恢复接口,全局查询过滤
|
||||||
|
7. **S7 DB 连接校验**:connection.ts 移除 fallback,启动时校验必需环境变量
|
||||||
|
8. **基础设施**:package.json 新增依赖(jest, supertest, node-cron, vitest 等),jest/vitest 配置,.env.test
|
||||||
|
|
||||||
|
### T02: 代码质量重构
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **任务 ID** | T02 |
|
||||||
|
| **优先级** | P1 |
|
||||||
|
| **描述** | 抽取通用组件消除重复代码(ManageList/ColorPicker/EditModal),补齐事务与异常处理,统一工具函数与硬编码 URL |
|
||||||
|
| **依赖** | T01 |
|
||||||
|
| **涉及文件** | **新建**:`client/src/components/ManageList/ManageList.vue`, `client/src/components/ColorPicker/ColorPicker.vue`, `client/src/components/EditModal/EditModal.vue`, `server/src/utils/upload.ts`, `client/src/utils/avatar.ts`, `client/src/static/iconfont/*`, `client/src/styles/iconfont.scss` |
|
||||||
|
| | **修改**:`client/src/pages/category-manage/index.vue`, `client/src/pages/tag-manage/index.vue`, `client/src/components/Icon/Icon.vue`, `server/src/routes/recurring.ts`, `server/src/routes/user.ts`, `server/src/routes/notification.ts`, `client/src/utils/format.ts`, `client/src/config.ts` |
|
||||||
|
|
||||||
|
**详细子项**:
|
||||||
|
|
||||||
|
1. **C2 ManageList 抽取**:新建 ManageList 组件,category-manage 和 tag-manage 改为使用该组件,统一行为
|
||||||
|
2. **C2 ColorPicker 抽取**:从两个管理页面的弹窗中抽取颜色选择器
|
||||||
|
3. **C2 EditModal 抽取**:从两个管理页面的弹窗中抽取编辑弹窗
|
||||||
|
4. **C4 Recurring 事务**:`recurring.ts /sync` 接口用 `getConnection() + beginTransaction()` 包裹
|
||||||
|
5. **C3 上传逻辑抽取**:`user.ts` 和 `notification.ts` 的 multer 配置抽取为 `utils/upload.ts`
|
||||||
|
6. **C1 工具函数去重**:前端 `format.ts` 的 `getCurrentMonth` 与后端 `date.ts` 重复,前端保留并标注来源
|
||||||
|
7. **C7 硬编码 URL**:全局搜索 `http://` 和 `xiaocai.j35.site`,统一从 `config.ts` 读取
|
||||||
|
8. **C8 Icon → iconfont**:生成字体图标,Icon.vue 改为字体渲染
|
||||||
|
9. **C9 头像 URL 拼接**:抽取 `getAvatarUrl(filename)` 工具函数
|
||||||
|
|
||||||
|
### T03: 性能优化与 UX 增强
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **任务 ID** | T03 |
|
||||||
|
| **优先级** | P1 |
|
||||||
|
| **描述** | 实现乐观更新、并行加载、聚合接口、删除撤销、群组只读、离线记账、下拉刷新等性能与体验提升 |
|
||||||
|
| **依赖** | T01 |
|
||||||
|
| **涉及文件** | **新建**:`client/src/components/Snackbar/Snackbar.vue`, `client/src/utils/offline.ts` |
|
||||||
|
| | **修改**:`client/src/stores/category.ts`, `client/src/stores/tag.ts`, `client/src/stores/group.ts`, `client/src/stores/stats.ts`, `client/src/stores/notification.ts`, `client/src/stores/transaction.ts`, `client/src/stores/budget.ts`, `client/src/utils/request.ts`, `client/src/utils/app-ready.ts`, `client/src/utils/tracker.ts`, `client/src/components/TransactionItem/TransactionItem.vue`, `client/src/components/ChartWrapper/`, `client/src/pages/bills/index.vue`, `client/src/pages/budget/index.vue`, `client/src/pages/stats/index.vue`, `client/src/pages/add/index.vue`, `client/src/App.vue`, `server/src/routes/stats.ts`, `server/src/routes/track.ts`, `server/src/routes/budget.ts` |
|
||||||
|
|
||||||
|
**详细子项**:
|
||||||
|
|
||||||
|
1. **Perf-1 乐观更新**:category/tag Store 新增/编辑/排序操作先更新本地 state 再调接口,失败回滚
|
||||||
|
2. **Perf-2 并行加载**:groupStore `refreshAll()` 中无依赖的 `fetchCategories()` + `fetchTags()` 改为 `Promise.all()`
|
||||||
|
3. **Perf-3 聚合接口**:后端 `GET /stats/dashboard` 用 `Promise.all` 并行查询 overview + category + trend
|
||||||
|
4. **Perf-4 批量 INSERT**:前端 tracker.ts 攒批(500ms/10条),后端 track.ts 接收数组批量 INSERT
|
||||||
|
5. **Perf-5 Admin N+1**:admin.ts 用户列表改为 LEFT JOIN 单次查询
|
||||||
|
6. **Perf-6 前端缓存**:category/tag Store 增加 `lastFetchTime`,5 分钟内 onShow 跳过请求
|
||||||
|
7. **Perf-7 未读计数缓存**:notification Store 缓存 30s
|
||||||
|
8. **UX-1 删除撤销**:transaction Store 实现 pendingDelete Map + Snackbar 组件 + 3 秒超时
|
||||||
|
9. **UX-2 群组只读**:TransactionItem 新增 `isReadOnly` prop,非本人记录显示锁图标
|
||||||
|
10. **UX-3 waitForReady 超时**:app-ready.ts 超时后 reject,页面级 catch 显示错误提示
|
||||||
|
11. **UX-4 预算 group_id**:budget Store/页面/路由 支持 group_id 参数
|
||||||
|
12. **UX-5 下拉刷新**:pages.json 全局启用,各页面实现 onPullDownRefresh
|
||||||
|
13. **UX-6 离线记账**:offline.ts 队列管理 + App.vue 网络监听 + add 页面适配
|
||||||
|
14. **UX-7 Loading 态**:异步操作添加 loading ref 守卫 + 按钮禁用
|
||||||
|
|
||||||
|
### T04: 测试体系建设
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **任务 ID** | T04 |
|
||||||
|
| **优先级** | P1 |
|
||||||
|
| **描述** | 搭建前后端测试框架,编写核心路由集成测试与 Store 单元测试,建立持续测试基线 |
|
||||||
|
| **依赖** | T01 |
|
||||||
|
| **涉及文件** | **新建**:`server/tests/setup.ts`, `server/tests/auth.test.ts`, `server/tests/transaction.test.ts`, `server/tests/category.test.ts`, `server/tests/budget.test.ts`, `server/tests/stats.test.ts`, `client/tests/stores/transaction.test.ts`, `client/tests/stores/category.test.ts`, `client/tests/utils/format.test.ts`, `client/tests/utils/app-ready.test.ts` |
|
||||||
|
|
||||||
|
**详细子项**:
|
||||||
|
|
||||||
|
1. **后端测试框架搭建**:jest.config.js + setup.ts(测试数据库初始化与清理)
|
||||||
|
2. **auth 测试**:登录成功/失败、Token 生成与验证、Refresh Token 刷新、无效 Token 拒绝
|
||||||
|
3. **transaction 测试**:CRUD、group_id 过滤、分页、金额精度、权限校验
|
||||||
|
4. **category 测试**:CRUD、默认/自定义分类隔离、排序、迁移
|
||||||
|
5. **budget 测试**:CRUD、月度预算、群组视图
|
||||||
|
6. **stats 测试**:overview/category/trend/dashboard 查询、period 参数校验
|
||||||
|
7. **前端 Store 测试**:transaction/category Store 核心方法测试
|
||||||
|
8. **工具函数测试**:format.ts(formatAmount、formatDate、getCurrentMonth)、app-ready.ts
|
||||||
|
|
||||||
|
### T05: 新功能(财务报告 + 预算预警)
|
||||||
|
|
||||||
|
| 字段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **任务 ID** | T05 |
|
||||||
|
| **优先级** | P1 |
|
||||||
|
| **描述** | 实现周/月财务报告推送、预算超支预警、微信订阅消息集成 |
|
||||||
|
| **依赖** | T01 |
|
||||||
|
| **涉及文件** | **新建**:`server/src/services/scheduler.ts`, `server/src/services/report.ts`, `server/src/services/budget-alert.ts`, `server/src/services/wechat-subscribe.ts`, `server/src/routes/report.ts`, `client/src/api/report.ts` |
|
||||||
|
| | **修改**:`server/src/index.ts`, `server/src/routes/transaction.ts`, `server/src/routes/budget.ts` |
|
||||||
|
|
||||||
|
**详细子项**:
|
||||||
|
|
||||||
|
1. **scheduler.ts**:使用 node-cron 注册定时任务(每周一 09:00 推送周报、每月 1 号 09:00 推送月报、每日凌晨全量预算扫描)
|
||||||
|
2. **report.ts**:查询指定时间范围的收支数据,生成摘要文本
|
||||||
|
3. **budget-alert.ts**:实现 checkBudgetAlert 逻辑,80%/100%/120% 分级预警 + 去重
|
||||||
|
4. **wechat-subscribe.ts**:封装微信订阅消息发送接口
|
||||||
|
5. **report 路由**:GET /reports/weekly、GET /reports/monthly
|
||||||
|
6. **用户设置**:GET/PUT /user/settings,report_push_enabled 开关
|
||||||
|
7. **触发集成**:transaction.ts 创建记账后调用 budgetAlert.check();budget.ts 设置预算后调用 budgetAlert.check()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、任务依赖图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
T01["T01: 项目基础设施与安全修复<br/>(P0 · S1-S7 + 依赖安装 + DB迁移)"]
|
||||||
|
T02["T02: 代码质量重构<br/>(P1 · C1-C9 + 通用组件)"]
|
||||||
|
T03["T03: 性能优化与 UX 增强<br/>(P1 · Perf-1~9 + UX-1~7)"]
|
||||||
|
T04["T04: 测试体系建设<br/>(P1 · T-1 + T-2 + T-4)"]
|
||||||
|
T05["T05: 新功能<br/>(P1 · F-1 + F-2)"]
|
||||||
|
|
||||||
|
T01 --> T02
|
||||||
|
T01 --> T03
|
||||||
|
T01 --> T04
|
||||||
|
T01 --> T05
|
||||||
|
|
||||||
|
style T01 fill:#FF6B6B,color:#fff
|
||||||
|
style T02 fill:#FFD700,color:#333
|
||||||
|
style T03 fill:#7BC67E,color:#fff
|
||||||
|
style T04 fill:#5B9BD5,color:#fff
|
||||||
|
style T05 fill:#8B5CF6,color:#fff
|
||||||
|
```
|
||||||
|
|
||||||
|
**并行策略**:T01 完成后,T02/T03/T04/T05 可并行推进。其中 T03 与 T02 有轻微文件冲突(stores/),建议 T02 先行或约定合并策略。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、依赖包列表
|
||||||
|
|
||||||
|
### 后端新增
|
||||||
|
|
||||||
|
```
|
||||||
|
- node-cron@^3.0.3 # 定时任务调度(F-1 财务报告、F-2 预算扫描)
|
||||||
|
- jest@^29.7.0 # 后端测试框架(T-1)
|
||||||
|
- ts-jest@^29.1.1 # Jest TypeScript 支持
|
||||||
|
- supertest@^6.3.3 # HTTP 集成测试(T-1)
|
||||||
|
- @types/supertest@^6.0.2 # supertest 类型定义
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端新增
|
||||||
|
|
||||||
|
```
|
||||||
|
- vitest@^1.2.0 # 前端测试框架(T-2)
|
||||||
|
- @pinia/testing@^0.1.3 # Pinia 测试工具
|
||||||
|
- @vue/test-utils@^2.4.3 # Vue 组件测试工具
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、共享知识(跨文件约定)
|
||||||
|
|
||||||
|
### 8.1 API 响应格式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 所有 API 统一响应格式
|
||||||
|
{ code: 0, data: T, message?: string } // 成功
|
||||||
|
{ code: 40100, message: '未登录' } // Token 无效
|
||||||
|
{ code: 40101, message: 'token已过期' } // Token 过期(可刷新)
|
||||||
|
{ code: 40300, message: '无权访问' } // 权限不足
|
||||||
|
{ code: 40400, message: '资源不存在' } // 找不到
|
||||||
|
{ code: 42900, message: '请求过于频繁' } // 限流
|
||||||
|
{ code: 50000, message: '服务器错误' } // 服务端异常
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 认证相关
|
||||||
|
|
||||||
|
```
|
||||||
|
- Access Token 有效期:2 小时(HMAC-SHA256 签名,格式:base64(userId:timestamp:signature))
|
||||||
|
- Refresh Token 有效期:30 天(随机 32 字节,存 SHA-256 hash)
|
||||||
|
- 前端存储 Key:xc:accessToken, xc:refreshToken
|
||||||
|
- Authorization Header:Bearer <accessToken>
|
||||||
|
- 40101 表示 Token 过期可刷新;40100 表示 Token 无效需重新登录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.3 金额处理
|
||||||
|
|
||||||
|
```
|
||||||
|
- 所有金额以「分」为单位存储(INT 类型)
|
||||||
|
- 前端展示使用 formatAmount() 转为 "¥ 1,234.56" 格式
|
||||||
|
- 输入时以「元」为单位,提交前乘以 100 转为分
|
||||||
|
- 金额校验:1 <= amount <= 999999999(分)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.4 日期处理
|
||||||
|
|
||||||
|
```
|
||||||
|
- 数据库存储 DATE 类型(YYYY-MM-DD)
|
||||||
|
- 月份参数格式:YYYY-MM(如 "2026-06")
|
||||||
|
- 时区统一:服务器 + 客户端均按 UTC+8 处理
|
||||||
|
- getCurrentMonth() 前后端逻辑一致
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.5 软删除约定
|
||||||
|
|
||||||
|
```
|
||||||
|
- users 表使用 deleted_at 字段标记软删除
|
||||||
|
- 所有查询 users 表的 SQL 必须增加 WHERE deleted_at IS NULL
|
||||||
|
- 登录时检查 deleted_at IS NULL
|
||||||
|
- 群组成员查询需过滤已删除用户
|
||||||
|
- 已删除用户在群组统计中标注"已注销用户"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.6 乐观更新约定
|
||||||
|
|
||||||
|
```
|
||||||
|
- 新增/编辑操作:先更新本地 state → 再调 API → 失败回滚 + toast
|
||||||
|
- 删除操作:仍等 API 确认后再移除(防误删)
|
||||||
|
- 排序操作:全量回滚(排序是完整 ID 数组操作)
|
||||||
|
- 临时 ID:使用负数时间戳(-Date.now()),API 成功后替换为真实 ID
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.7 前端缓存约定
|
||||||
|
|
||||||
|
```
|
||||||
|
- 低频变更数据(分类/标签):5 分钟缓存,onShow 判断是否需要刷新
|
||||||
|
- 未读计数:30 秒缓存
|
||||||
|
- 缓存失效:手动操作(新增/编辑/删除)后立即清除对应缓存
|
||||||
|
- 下拉刷新:强制清除缓存并重新请求
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.8 组件命名约定
|
||||||
|
|
||||||
|
```
|
||||||
|
- 组件目录:client/src/components/ComponentName/ComponentName.vue
|
||||||
|
- Store 命名:use{Name}Store
|
||||||
|
- API 模块:client/src/api/{name}.ts
|
||||||
|
- 页面目录:client/src/pages/{name}/index.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、待明确事项
|
||||||
|
|
||||||
|
| # | 问题 | 影响范围 | 当前假设 | 风险 |
|
||||||
|
|---|------|----------|----------|------|
|
||||||
|
| Q1 | Refresh Token 存储方式 | S5 | 存 MySQL `refresh_tokens` 表;短中期可行,高并发时迁移 Redis | 当前写入频率极低,MySQL 足够 |
|
||||||
|
| Q2 | 软删除级联范围 | S6 | 仅软删除用户账号,交易记录保留但通过 `deleted_at IS NULL` 过滤 | 保留交易可能影响群组统计,需标注"已注销用户" |
|
||||||
|
| Q3 | 删除撤销批量上限 | UX-1 | 上限 5 条(Snackbar 最多显示"已删除 5 条记录"),超过直接删除 | 5 条覆盖绝大多数场景 |
|
||||||
|
| Q4 | 离线记账冲突策略 | UX-6 | MVP 仅支持离线新增(不涉及修改/删除),新增使用服务端生成 ID,不存在主键冲突 | 如需离线编辑需引入版本号机制 |
|
||||||
|
| Q5 | 微信订阅消息模板审核 | F-1 | 优先站内通知,微信订阅消息作为增强项;需提前申请模板审核 | 微信审核可能被拒,需准备备选方案 |
|
||||||
|
| Q6 | 预算预警检查时机 | F-2 | 双重策略:记账后实时检查 + 每日凌晨全量扫描 | 仅实时检查可能遗漏周期记账触发的超支 |
|
||||||
|
| Q7 | 乐观更新回滚粒度 | Perf-1 | 排序操作全量回滚(语义清晰),新增/编辑单条回滚 | 全量回滚可能丢失已做的其他排序操作 |
|
||||||
|
| Q8 | 测试环境数据隔离 | T-1 | 独立测试数据库(.env.test),CI 用 GitHub Actions service container | 本地需额外配置测试数据库 |
|
||||||
|
| Q9 | budgets 表 group_id 迁移 | UX-4 | 现有数据 group_id 默认 NULL(个人预算),UNIQUE KEY 从 (user_id, month) 改为 (user_id, group_id, month) | 迁移需确保不破坏现有预算数据 |
|
||||||
|
| Q10 | iconfont 图标清单 | C8 | 需从现有 Icon.vue 的 PNG 映射中提取完整图标列表,并在 iconfont.cn 重新制作 | 部分图标可能无现成 SVG,需设计师配合 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*文档版本: v2.0 | 最后更新: 2026-06-10*
|
||||||
@@ -1,55 +1,212 @@
|
|||||||
classDiagram
|
classDiagram
|
||||||
class Tag {
|
direction TB
|
||||||
+int id
|
|
||||||
+int user_id
|
class TokenConfig {
|
||||||
+string name
|
+TOKEN_SECRET: string
|
||||||
+string color
|
+ACCESS_TOKEN_EXPIRY: number
|
||||||
+string created_at
|
+REFRESH_TOKEN_EXPIRY: number
|
||||||
}
|
}
|
||||||
|
|
||||||
class TransactionTag {
|
class AuthMiddleware {
|
||||||
+int transaction_id
|
+authMiddleware(req, res, next): void
|
||||||
+int tag_id
|
-verifyHmacToken(token): userId
|
||||||
|
-isTokenExpired(timestamp): boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
class Transaction {
|
class AuthRoute {
|
||||||
+int id
|
+POST /login: LoginResult
|
||||||
+int user_id
|
+POST /demo-login: LoginResult
|
||||||
+int amount
|
+POST /refresh: RefreshResult
|
||||||
+string type
|
-signToken(userId): string
|
||||||
+int category_id
|
-generateRefreshToken(userId): string
|
||||||
+string note
|
-hashToken(token): string
|
||||||
+string date
|
-autoSetAdmin(userId): void
|
||||||
+int group_id
|
|
||||||
+int recurring_id
|
|
||||||
+Tag[] tags
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Feedback {
|
class LoginResult {
|
||||||
+int id
|
+accessToken: string
|
||||||
+int user_id
|
+refreshToken: string
|
||||||
+string type
|
+expiresIn: number
|
||||||
+string content
|
+userId: number
|
||||||
+string contact
|
+nickname: string
|
||||||
+string status
|
+avatar_url: string
|
||||||
+string admin_reply
|
+role: string
|
||||||
+string created_at
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Category {
|
class RefreshResult {
|
||||||
+int id
|
+accessToken: string
|
||||||
+int user_id
|
+refreshToken: string
|
||||||
+string name
|
+expiresIn: number
|
||||||
+string icon
|
|
||||||
+string color
|
|
||||||
+string type
|
|
||||||
+int sort_order
|
|
||||||
+int is_custom
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Transaction "1" --o "*" TransactionTag : has
|
class RefreshTokenRow {
|
||||||
Tag "1" --o "*" TransactionTag : referenced_by
|
+id: number
|
||||||
Transaction ..> Category : belongs_to
|
+user_id: number
|
||||||
|
+token_hash: string
|
||||||
|
+expires_at: Date
|
||||||
|
+revoked_at: Date|null
|
||||||
|
+created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
note for Tag "每用户最多 20 个标签\n8 色预设颜色选择器\n标签不区分收支类型"
|
class DownloadTokenRow {
|
||||||
note for TransactionTag "复合主键 (transaction_id, tag_id)\n每笔交易最多 5 个标签"
|
+id: number
|
||||||
|
+token: string
|
||||||
|
+user_id: number
|
||||||
|
+resource_type: string
|
||||||
|
+resource_id: string
|
||||||
|
+expires_at: Date
|
||||||
|
+used_at: Date|null
|
||||||
|
+created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class DownloadTokenRoute {
|
||||||
|
+POST /download-tokens: GenerateDownloadToken
|
||||||
|
+GET /download/:token: FileStream
|
||||||
|
-validateToken(token): DownloadTokenRow
|
||||||
|
-markUsed(tokenId): void
|
||||||
|
-cleanupExpired(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminRoute {
|
||||||
|
+GET /dashboard: DashboardData
|
||||||
|
+GET /users: UserList
|
||||||
|
+PUT /users/:id/status: void
|
||||||
|
+DELETE /users/:id: void~~软删除
|
||||||
|
+POST /users/:id/restore: void~~恢复
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserRow {
|
||||||
|
+id: number
|
||||||
|
+openid: string
|
||||||
|
+nickname: string
|
||||||
|
+avatar_url: string
|
||||||
|
+role: string
|
||||||
|
+deleted_at: Date|null
|
||||||
|
+created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class ManageListComponent {
|
||||||
|
+items: T[]
|
||||||
|
+displayField: string
|
||||||
|
+colorField: string
|
||||||
|
+showDrag: boolean
|
||||||
|
+showEdit: boolean
|
||||||
|
+showDelete: boolean
|
||||||
|
+emptyText: string
|
||||||
|
+emit_add(): void
|
||||||
|
+emit_edit(item): void
|
||||||
|
+emit_delete(item): void
|
||||||
|
+emit_sort(ids): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class ColorPickerComponent {
|
||||||
|
+modelValue: string
|
||||||
|
+colors: string[]
|
||||||
|
+emit_update:modelValue(color): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class EditModalComponent {
|
||||||
|
+visible: boolean
|
||||||
|
+title: string
|
||||||
|
+fields: EditField[]
|
||||||
|
+emit_confirm(data): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class SnackbarComponent {
|
||||||
|
+message: string
|
||||||
|
+actionText: string
|
||||||
|
+duration: number
|
||||||
|
+visible: boolean
|
||||||
|
+emit_action(): void
|
||||||
|
+show(msg, action): void
|
||||||
|
+hide(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfflineQueue {
|
||||||
|
-pendingOps: PendingOp[]
|
||||||
|
-isProcessing: boolean
|
||||||
|
+enqueue(op): void
|
||||||
|
+dequeue(id): void
|
||||||
|
+processPendingOps(): Promise~void~
|
||||||
|
+isOnline(): boolean
|
||||||
|
+startNetworkListener(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class PendingOp {
|
||||||
|
+id: string
|
||||||
|
+type: string
|
||||||
|
+data: object
|
||||||
|
+createdAt: number
|
||||||
|
+status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class SchedulerService {
|
||||||
|
+start(): void
|
||||||
|
+stop(): void
|
||||||
|
-weeklyReportCron(): void
|
||||||
|
-monthlyReportCron(): void
|
||||||
|
-dailyBudgetScanCron(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReportService {
|
||||||
|
+generateWeeklyReport(userId): WeeklyReport
|
||||||
|
+generateMonthlyReport(userId): MonthlyReport
|
||||||
|
-queryPeriodData(userId, start, end): PeriodData
|
||||||
|
}
|
||||||
|
|
||||||
|
class BudgetAlertService {
|
||||||
|
+checkBudgetAlert(userId, month): void
|
||||||
|
-calculateUsageRatio(userId, month): number
|
||||||
|
-sendNotification(userId, level): void
|
||||||
|
-sendWechatMessage(userId, level): void
|
||||||
|
-hasAlerted(userId, month, level): boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class WechatSubscribeService {
|
||||||
|
+sendMessage(userId, templateId, data): void
|
||||||
|
-getAccessToken(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
class BudgetAlertRow {
|
||||||
|
+id: number
|
||||||
|
+user_id: number
|
||||||
|
+month: string
|
||||||
|
+level: string
|
||||||
|
+group_id: number|null
|
||||||
|
+created_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserSettings {
|
||||||
|
+user_id: number
|
||||||
|
+report_push_enabled: boolean
|
||||||
|
+updated_at: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
class StatsRoute {
|
||||||
|
+GET /overview: Overview
|
||||||
|
+GET /category: CategoryStat[]
|
||||||
|
+GET /trend: TrendPoint[]
|
||||||
|
+GET /dashboard: DashboardData
|
||||||
|
-buildWhereClause(userId, groupId): WhereResult
|
||||||
|
}
|
||||||
|
|
||||||
|
class DashboardData {
|
||||||
|
+overview: Overview
|
||||||
|
+category: CategoryStat[]
|
||||||
|
+trend: TrendPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
TokenConfig <-- AuthMiddleware : uses
|
||||||
|
TokenConfig <-- AuthRoute : uses
|
||||||
|
AuthRoute --> LoginResult : returns
|
||||||
|
AuthRoute --> RefreshResult : returns
|
||||||
|
AuthRoute --> RefreshTokenRow : creates/revokes
|
||||||
|
DownloadTokenRoute --> DownloadTokenRow : creates/validates
|
||||||
|
AdminRoute --> UserRow : soft deletes
|
||||||
|
SchedulerService --> ReportService : triggers
|
||||||
|
SchedulerService --> BudgetAlertService : triggers
|
||||||
|
ReportService --> WechatSubscribeService : uses
|
||||||
|
BudgetAlertService --> WechatSubscribeService : uses
|
||||||
|
BudgetAlertService --> BudgetAlertRow : creates
|
||||||
|
BudgetAlertService --> UserSettings : checks
|
||||||
|
StatsRoute --> DashboardData : returns
|
||||||
|
|||||||
873
docs/prd-iteration-v2.md
Normal file
873
docs/prd-iteration-v2.md
Normal file
@@ -0,0 +1,873 @@
|
|||||||
|
# 小菜记账 — 全量迭代 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` | 移除 `|| '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/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。
|
||||||
|
|
||||||
|
**修复步骤**:
|
||||||
|
|
||||||
|
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`。
|
||||||
|
|
||||||
|
### S2:logs.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 不同)。
|
||||||
|
|
||||||
|
### 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`(弱密钥)
|
||||||
|
|
||||||
|
**修复方案**:
|
||||||
|
|
||||||
|
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'
|
||||||
|
```
|
||||||
|
|
||||||
|
### S4:Export/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()` 的记录。
|
||||||
|
|
||||||
|
### S5:Refresh 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
|
||||||
|
|
||||||
|
### S6:Admin 硬删除 → 软删除 + 二次确认
|
||||||
|
|
||||||
|
**当前状态**:`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}」吗?该操作将冻结该用户的所有数据,但不会删除记录。"
|
||||||
|
[取消] [确认禁用]
|
||||||
|
```
|
||||||
|
|
||||||
|
### S7:DB 连接移除默认凭据
|
||||||
|
|
||||||
|
**当前状态**:`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-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 |
|
||||||
|
|
||||||
|
**实现步骤**:
|
||||||
|
|
||||||
|
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/leaveGroup(BUG-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 UX(UX-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*
|
||||||
@@ -1,53 +1,92 @@
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant U as 用户
|
participant C as Client (request.ts)
|
||||||
participant P as category-manage
|
participant S as Server (auth.ts)
|
||||||
participant D as DragSortList
|
participant M as Server (auth middleware)
|
||||||
participant S as categoryStore
|
participant DB as MySQL (refresh_tokens)
|
||||||
participant A as sortCategories API
|
|
||||||
participant B as PUT /categories/sort
|
|
||||||
|
|
||||||
U->>P: 长按分类项进入排序模式
|
Note over C,S: 1. 正常登录
|
||||||
P->>P: showSortMode = true
|
C->>S: POST /auth/login { code }
|
||||||
P->>D: 渲染 DragSortList (items=currentCategories)
|
S->>DB: INSERT refresh_tokens (user_id, token_hash, expires_at)
|
||||||
U->>D: touchstart (记录起始位置)
|
S-->>C: { accessToken(2h), refreshToken(30d), userId }
|
||||||
U->>D: touchmove (计算偏移, 交换元素位置)
|
|
||||||
D->>D: 实时更新 items 数组顺序
|
|
||||||
U->>D: touchend (拖拽结束)
|
|
||||||
D->>P: @change事件 (新顺序ids)
|
|
||||||
P->>S: sortCategories(newIds)
|
|
||||||
S->>A: sortCategories(ids)
|
|
||||||
A->>B: PUT /categories/sort { ids }
|
|
||||||
B-->>A: { code: 0 }
|
|
||||||
A-->>S: 成功
|
|
||||||
S->>S: fetchCategories() 刷新
|
|
||||||
|
|
||||||
%% 数据导入流程
|
Note over C,S: 2. API 调用(Access Token 有效)
|
||||||
|
C->>M: GET /api/xxx Authorization: Bearer accessToken
|
||||||
|
M->>M: 验证 HMAC 签名 + 有效期
|
||||||
|
M-->>C: 200 OK { code: 0, data }
|
||||||
|
|
||||||
U->>P2 as data-import: 选择 JSON 文件
|
Note over C,S: 3. Access Token 过期
|
||||||
P2->>P2: uni.chooseFile / 读取文件内容
|
C->>M: GET /api/xxx Authorization: Bearer expiredToken
|
||||||
P2->>P2: 解析 JSON,显示预览(条数、日期范围)
|
M->>M: 签名有效但已过期
|
||||||
U->>P2: 确认导入
|
M-->>C: 401 { code: 40101, message: token已过期 }
|
||||||
P2->>A2 as transaction API: importTransactions(items)
|
|
||||||
A2->>S2 as POST /transactions/import: POST { items }
|
|
||||||
S2->>S2: 校验每条记录格式
|
|
||||||
S2->>DB as MySQL: 查询已有记录 (去重比对)
|
|
||||||
S2->>DB: 批量 INSERT (每批100条)
|
|
||||||
S2->>DB: 写入 transaction_tags (如有 tag_names)
|
|
||||||
S2-->>A2: { total, imported, skipped, errors }
|
|
||||||
A2-->>P2: 导入结果
|
|
||||||
P2->>U: 显示导入结果(成功X条, 跳过Y条)
|
|
||||||
|
|
||||||
%% 标签关联交易流程
|
Note over C,S: 4. 自动刷新
|
||||||
|
C->>S: POST /auth/refresh { refreshToken }
|
||||||
|
S->>DB: SELECT WHERE token_hash=SHA256(refreshToken) AND revoked_at IS NULL AND expires_at>NOW()
|
||||||
|
DB-->>S: 找到记录
|
||||||
|
S->>DB: UPDATE refresh_tokens SET revoked_at=NOW() WHERE id=oldId
|
||||||
|
S->>DB: INSERT refresh_tokens (新 token_hash)
|
||||||
|
S-->>C: { accessToken, refreshToken, expiresIn }
|
||||||
|
C->>C: 存储新 Token,重试原请求
|
||||||
|
|
||||||
U->>P3 as add/index: 点击"添加标签"
|
Note over C,S: 5. Refresh Token 也过期
|
||||||
P3->>P3: 显示标签选择面板(已有标签 + 新建入口)
|
C->>S: POST /auth/refresh { expiredRefreshToken }
|
||||||
U->>P3: 选择标签(最多5个)
|
S->>DB: SELECT WHERE token_hash=SHA256(...) AND expires_at>NOW()
|
||||||
P3->>P3: selectedTagIds 更新
|
DB-->>S: 未找到记录
|
||||||
U->>P3: 保存交易
|
S-->>C: 401 { code: 40101, message: refresh token已过期 }
|
||||||
P3->>A3 as transaction API: createTransaction({...data, tagIds})
|
C->>C: 清除所有 Token,跳转登录页
|
||||||
A3->>S3 as POST /transactions: POST { amount, type, ..., tagIds }
|
|
||||||
S3->>S3: 校验 tagIds (≤5, 属于当前用户)
|
Note over C,S: 6. 删除撤销流程
|
||||||
S3->>DB: INSERT INTO transactions
|
participant U as User
|
||||||
S3->>DB: INSERT INTO transaction_tags (批量)
|
participant P as BillsPage
|
||||||
S3-->>A3: { id }
|
participant ST as TransactionStore
|
||||||
A3-->>P3: 成功
|
participant SN as Snackbar
|
||||||
|
participant API as Server API
|
||||||
|
|
||||||
|
U->>P: 左滑删除记录
|
||||||
|
P->>ST: pendingDelete(id, item, index)
|
||||||
|
ST->>ST: 从 transactions 列表移除
|
||||||
|
ST->>SN: 显示 Snackbar 已删除1条记录 撤销
|
||||||
|
|
||||||
|
alt 3秒内点击撤销
|
||||||
|
U->>SN: 点击 撤销
|
||||||
|
SN->>ST: cancelDelete(id)
|
||||||
|
ST->>ST: 恢复到列表原位置
|
||||||
|
else 3秒超时
|
||||||
|
SN->>ST: confirmDelete(id)
|
||||||
|
ST->>API: DELETE /api/transactions/id
|
||||||
|
alt 删除成功
|
||||||
|
API-->>ST: 200 OK
|
||||||
|
ST->>ST: 清除 pendingDelete 记录
|
||||||
|
else 删除失败
|
||||||
|
API-->>ST: 500 Error
|
||||||
|
ST->>ST: 恢复到列表原位置
|
||||||
|
ST->>U: toast 删除失败
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Note over C,S: 7. 预算预警流程
|
||||||
|
participant T as Transaction Route
|
||||||
|
participant BA as BudgetAlert Service
|
||||||
|
participant NS as Notification System
|
||||||
|
participant WX as WeChat API
|
||||||
|
|
||||||
|
C->>T: POST /api/transactions { amount, type, date }
|
||||||
|
T->>DB: INSERT INTO transactions
|
||||||
|
T->>BA: checkBudgetAlert(userId, month)
|
||||||
|
BA->>DB: SELECT budget FROM budgets WHERE user_id=? AND month=?
|
||||||
|
BA->>DB: SELECT SUM(amount) FROM transactions WHERE user_id=? AND type=expense
|
||||||
|
|
||||||
|
alt 支出/预算 >= 80% 且未推送过
|
||||||
|
BA->>DB: INSERT budget_alerts (level=80)
|
||||||
|
BA->>NS: 创建站内通知
|
||||||
|
else 支出/预算 >= 100%
|
||||||
|
BA->>DB: INSERT budget_alerts (level=100)
|
||||||
|
BA->>NS: 创建站内通知
|
||||||
|
BA->>WX: 发送微信订阅消息
|
||||||
|
else 支出/预算 >= 120%
|
||||||
|
BA->>DB: INSERT budget_alerts (level=120)
|
||||||
|
BA->>NS: 创建紧急站内通知 (is_urgent=true)
|
||||||
|
BA->>WX: 发送微信订阅消息
|
||||||
|
end
|
||||||
|
|
||||||
|
T-->>C: { code: 0, data: { id } }
|
||||||
|
|||||||
@@ -1,24 +1,21 @@
|
|||||||
# Server
|
# Server
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
# Security
|
|
||||||
TOKEN_SECRET=your_random_secret_here
|
|
||||||
|
|
||||||
# CORS (comma-separated origins, empty = allow all)
|
|
||||||
CORS_ORIGINS=
|
|
||||||
|
|
||||||
# MySQL
|
# MySQL
|
||||||
DB_HOST=your_mysql_host_here
|
DB_HOST=your_db_host
|
||||||
DB_USER=your_mysql_user_here
|
DB_USER=your_db_user
|
||||||
DB_PASSWORD=your_mysql_password_here
|
DB_PASSWORD=your_db_password
|
||||||
DB_NAME=your_mysql_name_here
|
DB_NAME=xiaocai
|
||||||
|
|
||||||
# Uploads
|
# Uploads
|
||||||
UPLOAD_DIR=./uploads
|
UPLOAD_DIR=./uploads
|
||||||
|
|
||||||
# Backup
|
# Backup
|
||||||
BACKUP_DIR=/var/backups/xiaocai
|
BACKUP_DIR=./backups
|
||||||
|
|
||||||
|
# Token (REQUIRED - server will not start with default value)
|
||||||
|
TOKEN_SECRET=change-me-to-a-secure-random-string
|
||||||
|
|
||||||
# WeChat Mini Program
|
# WeChat Mini Program
|
||||||
WX_APPID=your_wx_appid_here
|
WX_APPID=your_wx_appid
|
||||||
WX_SECRET=your_wx_secret_here
|
WX_SECRET=your_wx_secret
|
||||||
|
|||||||
@@ -28,10 +28,8 @@ import tagRoutes from './routes/tag'
|
|||||||
import exportRoutes from './routes/export'
|
import exportRoutes from './routes/export'
|
||||||
import { backupDatabase } from './utils/backup'
|
import { backupDatabase } from './utils/backup'
|
||||||
|
|
||||||
// Warn if token secret is using default fallback
|
// 强制检查 TOKEN_SECRET 安全性
|
||||||
if (!process.env.TOKEN_SECRET) {
|
checkTokenSecret()
|
||||||
console.warn('[Security] TOKEN_SECRET not set — using default fallback. Set TOKEN_SECRET in .env for production!')
|
|
||||||
}
|
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = process.env.PORT || 3000
|
const PORT = process.env.PORT || 3000
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export interface AuthRequest extends Request {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||||
const TOKEN_EXPIRY = 30 * 24 * 60 * 60 * 1000 // 30 days
|
const TOKEN_EXPIRY = 2 * 60 * 60 * 1000 // 2 hours
|
||||||
|
|
||||||
// 不需要认证的路径
|
// 不需要认证的路径
|
||||||
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/health']
|
const PUBLIC_PATHS = ['/api/auth/login', '/api/auth/demo-login', '/api/health']
|
||||||
|
|||||||
@@ -8,6 +8,18 @@ const APPID = process.env.WX_APPID || ''
|
|||||||
const SECRET = process.env.WX_SECRET || ''
|
const SECRET = process.env.WX_SECRET || ''
|
||||||
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||||
|
|
||||||
|
/** 启动时检查 TOKEN_SECRET 是否为默认占位符,是则拒绝启动 */
|
||||||
|
export function checkTokenSecret(): void {
|
||||||
|
const defaults = [
|
||||||
|
'xiaocai-token-secret-change-in-production',
|
||||||
|
'change-me-to-a-secure-random-string',
|
||||||
|
]
|
||||||
|
if (defaults.includes(TOKEN_SECRET)) {
|
||||||
|
console.error('[Auth] FATAL: TOKEN_SECRET is using a default placeholder. Set a secure TOKEN_SECRET in .env before starting the server.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function signToken(userId: number): string {
|
export function signToken(userId: number): string {
|
||||||
const payload = `${userId}:${Date.now()}`
|
const payload = `${userId}:${Date.now()}`
|
||||||
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
const signature = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import path from 'path'
|
|||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
const BACKUP_DIR = process.env.BACKUP_DIR || '/var/backups/xiaocai'
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || 'xiaocai-secret'
|
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||||
|
|
||||||
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
// 所有路由都需要管理员权限(除了 :id/download 使用签名校验)
|
||||||
router.use((req, res, next) => {
|
router.use((req, res, next) => {
|
||||||
@@ -42,7 +42,7 @@ router.get('/:id/download', async (req: AuthRequest, res: Response) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 校验 HMAC
|
// 校验 HMAC
|
||||||
const expectedHmac = createHmac('sha256', JWT_SECRET)
|
const expectedHmac = createHmac('sha256', TOKEN_SECRET)
|
||||||
.update(`${backupId}:${timestamp}`)
|
.update(`${backupId}:${timestamp}`)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
if (hmac !== expectedHmac) {
|
if (hmac !== expectedHmac) {
|
||||||
@@ -89,7 +89,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
// 为每条记录生成下载令牌
|
// 为每条记录生成下载令牌
|
||||||
const listWithToken = list.map(item => {
|
const listWithToken = list.map(item => {
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
const hmac = createHmac('sha256', JWT_SECRET)
|
const hmac = createHmac('sha256', TOKEN_SECRET)
|
||||||
.update(`${item.name}:${timestamp}`)
|
.update(`${item.name}:${timestamp}`)
|
||||||
.digest('hex')
|
.digest('hex')
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { pipeline } from 'stream/promises'
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import os from 'os'
|
import os from 'os'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
|
import { createHmac } from 'crypto'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -14,18 +15,46 @@ const VALID_FORMATS = ['csv', 'json']
|
|||||||
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
const DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/
|
||||||
const LARGE_DATA_THRESHOLD = 10000
|
const LARGE_DATA_THRESHOLD = 10000
|
||||||
const EXPORT_TIMEOUT = 60000
|
const EXPORT_TIMEOUT = 60000
|
||||||
|
const TOKEN_SECRET = process.env.TOKEN_SECRET || 'xiaocai-token-secret-change-in-production'
|
||||||
|
const DOWNLOAD_TOKEN_EXPIRY = 10 * 60 * 1000 // 10 minutes
|
||||||
|
|
||||||
/** 流式导出交易数据 */
|
/** 生成短期下载签名 token */
|
||||||
router.get('/', async (req: AuthRequest, res: Response) => {
|
function createDownloadToken(userId: number, startDate: string, endDate: string, type: string, format: string): string {
|
||||||
|
const timestamp = Date.now()
|
||||||
|
const payload = `${userId}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
|
||||||
|
const hmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||||
|
return Buffer.from(`${payload}:${hmac}`).toString('base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 验证下载签名 token,返回解出的参数或 null */
|
||||||
|
function verifyDownloadToken(token: string): { userId: number; startDate: string; endDate: string; type: string; format: string } | null {
|
||||||
try {
|
try {
|
||||||
// 支持 query token 认证:如果没有 Authorization header,从 query.token 读取
|
const decoded = Buffer.from(token, 'base64').toString('utf-8')
|
||||||
if (!req.headers.authorization && req.query.token) {
|
const parts = decoded.split(':')
|
||||||
req.headers.authorization = `Bearer ${req.query.token}`
|
// 格式:userId:startDate:endDate:type:format:timestamp:hmac
|
||||||
}
|
// 但 type/format 不含冒号,所以按 7 段拆分
|
||||||
|
if (parts.length !== 7) return null
|
||||||
|
const [userIdStr, startDate, endDate, type, format, timestamp, hmac] = parts
|
||||||
|
const userId = parseInt(userIdStr)
|
||||||
|
const ts = parseInt(timestamp)
|
||||||
|
if (isNaN(userId) || isNaN(ts)) return null
|
||||||
|
if (Date.now() - ts > DOWNLOAD_TOKEN_EXPIRY) return null
|
||||||
|
const payload = `${userIdStr}:${startDate}:${endDate}:${type}:${format}:${timestamp}`
|
||||||
|
const expectedHmac = createHmac('sha256', TOKEN_SECRET).update(payload).digest('hex')
|
||||||
|
if (hmac !== expectedHmac) return null
|
||||||
|
if (!VALID_TYPES.includes(type) || !VALID_FORMATS.includes(format)) return null
|
||||||
|
if (!DATE_REGEX.test(startDate) || !DATE_REGEX.test(endDate)) return null
|
||||||
|
return { userId, startDate, endDate, type, format }
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 准备导出:生成短期签名 token(需登录认证) */
|
||||||
|
router.get('/prepare', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
const { startDate, endDate, type = 'all', format = 'csv' } = req.query
|
const { startDate, endDate, type = 'all', format = 'csv' } = req.query
|
||||||
|
|
||||||
// 参数校验
|
|
||||||
if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) {
|
if (!startDate || !endDate || !DATE_REGEX.test(startDate as string) || !DATE_REGEX.test(endDate as string)) {
|
||||||
return res.status(400).json({ code: 40001, message: '日期参数无效' })
|
return res.status(400).json({ code: 40001, message: '日期参数无效' })
|
||||||
}
|
}
|
||||||
@@ -36,13 +65,36 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
return res.status(400).json({ code: 40001, message: '格式参数无效' })
|
return res.status(400).json({ code: 40001, message: '格式参数无效' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const token = createDownloadToken(req.userId!, startDate as string, endDate as string, type as string, format as string)
|
||||||
|
res.json({ code: 0, data: { token } })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Export] prepare error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '服务器错误' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 流式导出交易数据(使用短期签名 token 认证,无需 Authorization header) */
|
||||||
|
router.get('/', async (req: AuthRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
// 从 query.token 读取签名 token 并验证
|
||||||
|
const token = req.query.token as string
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ code: 40100, message: '缺少下载令牌' })
|
||||||
|
}
|
||||||
|
const decoded = verifyDownloadToken(token)
|
||||||
|
if (!decoded) {
|
||||||
|
return res.status(401).json({ code: 40100, message: '下载令牌无效或已过期' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { userId, startDate, endDate, type, format } = decoded
|
||||||
|
|
||||||
const isCsv = format === 'csv'
|
const isCsv = format === 'csv'
|
||||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||||
const filename = isCsv ? `export_${today}.csv` : `export_${today}.json`
|
const filename = isCsv ? `export_${today}.csv` : `export_${today}.json`
|
||||||
|
|
||||||
// 构建查询条件
|
// 构建查询条件
|
||||||
let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?'
|
let where = 'WHERE t.user_id = ? AND t.date >= ? AND t.date <= ?'
|
||||||
const params: any[] = [req.userId, startDate, endDate]
|
const params: any[] = [userId, startDate, endDate]
|
||||||
|
|
||||||
if (type !== 'all') {
|
if (type !== 'all') {
|
||||||
where += ' AND t.type = ?'
|
where += ' AND t.type = ?'
|
||||||
@@ -141,7 +193,7 @@ router.get('/', async (req: AuthRequest, res: Response) => {
|
|||||||
} else {
|
} else {
|
||||||
// 大数据量:临时文件流
|
// 大数据量:临时文件流
|
||||||
const tmpDir = os.tmpdir()
|
const tmpDir = os.tmpdir()
|
||||||
const tmpFile = path.join(tmpDir, `export_${Date.now()}_${req.userId}.${format}`)
|
const tmpFile = path.join(tmpDir, `export_${Date.now()}_${userId}.${format}`)
|
||||||
|
|
||||||
// 分批查询并写入临时文件
|
// 分批查询并写入临时文件
|
||||||
const BATCH_SIZE = 5000
|
const BATCH_SIZE = 5000
|
||||||
|
|||||||
@@ -157,15 +157,14 @@ router.get('/track/list', requireAdmin, async (req, res) => {
|
|||||||
)
|
)
|
||||||
const total = (countResult as any[])[0].total
|
const total = (countResult as any[])[0].total
|
||||||
|
|
||||||
// LIMIT/OFFSET 直接嵌入 SQL(mysql2 execute 对这些参数类型要求严格)
|
|
||||||
const [rows] = await pool.execute(
|
const [rows] = await pool.execute(
|
||||||
`SELECT t.*, u.nickname
|
`SELECT t.*, u.nickname
|
||||||
FROM track_events t
|
FROM track_events t
|
||||||
LEFT JOIN users u ON t.user_id = u.id
|
LEFT JOIN users u ON t.user_id = u.id
|
||||||
WHERE ${where}
|
WHERE ${where}
|
||||||
ORDER BY t.created_at DESC
|
ORDER BY t.created_at DESC
|
||||||
LIMIT ${pSize} OFFSET ${offset}`,
|
LIMIT ? OFFSET ?`,
|
||||||
params
|
[...params, pSize, offset]
|
||||||
)
|
)
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
Reference in New Issue
Block a user