feat: 用户信息完善 + 一起记功能

- users 表添加 nickname、avatar_url 字段
- 用户信息 API(GET/PUT /me)+ 头像上传(multer)
- 头像通过 API 路由获取(公开,不经过 auth)
- 登录接口返回用户昵称和头像
- 用户信息 Store + 个人资料编辑页面
- 我的页面和首页显示真实用户信息
- 群组表(groups、group_members)+ transactions 添加 group_id
- 群组 API(创建/加入/退出/解散)
- 交易和统计 API 支持 group_id 视图切换
- 群组客户端 Store + 身份切换
- 群组管理页面
- App 初始化群组 Store
- UPLOAD_DIR 配置化
- Node.js 备份替代 mysqldump
- 统一前端 BASE_URL(config.ts)
- uploads 加入 .gitignore
- 修复 trust proxy 警告
This commit is contained in:
2026-06-03 17:04:22 +08:00
parent 9162b2c87c
commit 21f4d81959
45 changed files with 3145 additions and 348 deletions

View File

@@ -0,0 +1,589 @@
# 用户信息完善 & 一起记功能 实现计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 两个功能:① 用户可编辑昵称和上传头像,替换全局硬编码的"小菜";② 用户可创建/加入群组,在「我的」页切换身份,群组仅作为数据聚合视图。
**Architecture:**
- 用户信息:扩展 `users` 表 → 服务端 multer 上传头像 → `/api/user` 路由 → 前端 `userStore` + 编辑页
- 一起记:新建 `groups`/`group_members` 表 → `transactions``group_id` 标签 → 「我的」页身份切换 → 群组仅做数据聚合
**Tech Stack:** Vue 3 Composition API (uni-app), Express + mysql2 + multer, SCSS Claymorphism
---
## 一、核心数据模型
### 所有权 vs 聚合
```
transactions 表:
user_id = 谁记的(数据所有权,永远不变)
group_id = 群组标签(仅用于聚合视图,可变)
```
**个人视图:** `WHERE user_id = 我`
- 看到我的**所有记录**,不管 group_id 是什么
- 记录始终属于我
**群组视图:** `WHERE group_id = X AND user_id IN (当前成员)`
- 只看到当前成员在该群组下记的记录
- 离开的成员的记录自动消失
**加入群组:** 新记的账可选择记到群组group_id = X仅自己可见的旧记录不动
**退出群组:** 该用户所有 `group_id = X` 的记录,`group_id` 设为 NULL
- 记录回到个人视图
- 群组其他人看不到
**解散群组:** CASCADE 删除 groups → group_members 自动清理transactions.group_id 设为 NULL
### 举例
```
用户 A 创建群组 G用户 B 加入
A 在群组模式下记了午餐 ¥30
→ { user_id: A, group_id: G, amount: 3000 }
B 在群组模式下记了打车 ¥20
→ { user_id: B, group_id: G, amount: 2000 }
A 的个人视图:看到自己所有记录(包括这笔 ¥30
B 的个人视图:看到自己所有记录(包括这笔 ¥20
群组 G 视图: A 和 B 都看到 ¥30 + ¥20 = ¥50
B 退出群组:
→ B 的记录 group_id 设为 NULL
→ B 的个人视图:仍然看到 ¥20记录属于 B
→ A 看群组 G只看到 ¥30B 的记录已不在)
```
---
## 二、数据库设计
### 现有表(不变)
```
users(id, openid, session_key, created_at, updated_at)
categories(id, user_id, name, icon, color, type, sort_order, is_custom, created_at)
transactions(id, user_id, amount, type, category_id, note, date, created_at, updated_at)
budgets(id, user_id, amount, month, created_at, updated_at)
```
### 变更
```
users 表新增:
nickname VARCHAR(50) DEFAULT '小菜'
avatar_url VARCHAR(500) DEFAULT '' -- 服务端相对路径
新建 groups 表:
id INT AUTO_INCREMENT PK
name VARCHAR(100) NOT NULL
invite_code VARCHAR(10) UNIQUE NOT NULL -- 6位邀请码
created_by INT NOT NULL FK→users.id
created_at TIMESTAMP
updated_at TIMESTAMP
新建 group_members 表:
id INT AUTO_INCREMENT PK
group_id INT NOT NULL FK→groups.id ON DELETE CASCADE
user_id INT NOT NULL FK→users.id ON DELETE CASCADE
role ENUM('owner','member') DEFAULT 'member'
nickname VARCHAR(50) DEFAULT '' -- 群内昵称(预留)
created_at TIMESTAMP
UNIQUE KEY (group_id, user_id)
transactions 表新增:
group_id INT DEFAULT NULL -- 群组标签NULL=纯个人记录
INDEX idx_group_date (group_id, date)
-- 注意:不要 FK ON DELETE CASCADE因为退出群组时需要保留记录
```
---
## 三、API 设计
### 新增接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/user/me` | 获取当前用户信息 |
| PUT | `/api/user/me` | 更新昵称 |
| POST | `/api/user/avatar` | 上传头像 |
| POST | `/api/groups` | 创建群组 |
| GET | `/api/groups` | 获取用户的群组列表 |
| GET | `/api/groups/:id` | 获取群组详情+成员 |
| POST | `/api/groups/join` | 通过邀请码加入群组 |
| POST | `/api/groups/:id/leave` | 退出群组(清除该用户的 group_id 标记) |
| DELETE | `/api/groups/:id` | 解散群组(仅 owner |
### 变更接口
**交易接口(`/api/transactions`**
| 参数 | 说明 |
|------|------|
| `group_id` 不传 | 个人视图:`WHERE user_id = ?`(所有记录) |
| `group_id = 数字` | 群组视图:`WHERE group_id = ? AND user_id IN (当前成员)` |
**统计接口(`/api/stats`** 同上逻辑
**退出群组(`POST /api/groups/:id/leave`)关键逻辑:**
```sql
-- 1. 清除该用户在该群组的记录标签
UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?
-- 2. 删除成员关系
DELETE FROM group_members WHERE group_id = ? AND user_id = ?
```
---
## 四、前端架构
### 新增文件
```
client/src/
├── api/user.ts -- 用户信息 API含头像上传
├── api/group.ts -- 群组 API
├── stores/user.ts -- 用户信息 Store
├── stores/group.ts -- 群组 Store身份切换状态
├── pages/profile-edit/index.vue -- 编辑资料页
└── pages/group-manage/index.vue -- 群组管理页(创建/加入/列表/退出/解散)
```
### 修改文件
```
server/src/
├── db/schema.sql, db/init.ts -- 迁移
├── index.ts -- 注册路由 + 静态文件
├── routes/auth.ts -- 登录返回用户信息
├── routes/user.ts -- [新建]
├── routes/group.ts -- [新建]
├── routes/transaction.ts -- 查询逻辑改造
└── routes/stats.ts -- 统计逻辑改造
client/src/
├── api/auth.ts, api/transaction.ts, api/stats.ts -- 类型扩展
├── stores/transaction.ts, stores/stats.ts -- 自动传 group_id
├── App.vue -- 初始化
├── pages.json -- 注册页面
├── pages/index/index.vue -- 显示真实昵称
└── pages/profile/index.vue -- 身份切换 + 头像/昵称
```
### 身份切换交互(「我的」页)
```
┌─────────────────────────┐
│ profile-card (点击编辑) │
│ [头像] 昵称 > │
├─────────────────────────┤
│ identity-card (点击切换) │
│ 👤 个人账本 仅自己 > │
├─────────────────────────┤
│ quick-stats │
│ menu-card │
└─────────────────────────┘
点击 identity-card → 底部弹出 ActionSheet
┌─────────────────────────┐
│ 切换账本 │
│ ✓ 个人账本 │
│ 家庭账本 3人 │
│ 情侣账本 2人 │
│ ───────────────────── │
创建群组 │
加入群组 │
└─────────────────────────┘
```
**创建群组流程:**
1. 点击「创建群组」→ 弹出输入框输入名称 → 确认
2. 创建成功 → 显示邀请码 → 自动复制到剪贴板
3. 群组自动出现在身份列表中
**加入群组流程:**
1. 点击「加入群组」→ 弹出输入框输入邀请码 → 确认
2. 加入成功 → 群组自动出现在身份列表中
**退出/解散(群组管理页):**
- 点击群组卡片进入详情 → 显示成员列表、邀请码
- 成员可见「退出群组」按钮
- 群主可见「解散群组」按钮
---
## 五、任务清单
### Part 1: 用户信息完善Task 1-7
#### Task 1: 数据库 — users 表扩展
**Files:** `server/src/db/schema.sql`, `server/src/db/init.ts`
- [ ] **Step 1: 更新 schema.sql users 表**
```sql
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
openid VARCHAR(100) UNIQUE,
session_key VARCHAR(100),
nickname VARCHAR(50) DEFAULT '小菜' COMMENT '用户昵称',
avatar_url VARCHAR(500) DEFAULT '' COMMENT '头像URL',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
- [ ] **Step 2: init.ts runMigrations 添加迁移**
```typescript
const hasNickname = await columnExists(conn, 'users', 'nickname')
if (!hasNickname) {
await conn.query("ALTER TABLE users ADD COLUMN nickname VARCHAR(50) DEFAULT '小菜'")
}
const hasAvatarUrl = await columnExists(conn, 'users', 'avatar_url')
if (!hasAvatarUrl) {
await conn.query("ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) DEFAULT ''")
}
```
- [ ] **Step 3: 验证** `cd server && npm run db:init`
- [ ] **Step 4: Commit** `feat: users 表添加 nickname、avatar_url`
---
#### Task 2: 用户信息 API + 头像上传
**Files:** Create `server/src/routes/user.ts`, Modify `server/src/index.ts`
- [ ] **Step 1: 安装 multer** `cd server && npm install multer && npm install -D @types/multer`
- [ ] **Step 2: 创建 user 路由**
三个接口:
- `GET /me` — 查询用户信息
- `PUT /me` — 更新昵称验证非空、≤50字符
- `POST /avatar` — multer 接收 `file` 字段,存到 `uploads/avatars/`,更新 `users.avatar_url`,删除旧文件
- [ ] **Step 3: index.ts 注册路由 + 静态文件**
```typescript
import userRoutes from './routes/user'
import path from 'path'
app.use('/uploads', express.static(path.join(__dirname, '../uploads')))
app.use('/api/user', apiLimiter, userRoutes)
```
- [ ] **Step 4: Commit** `feat: 用户信息 API + 头像上传`
---
#### Task 3: 登录返回用户信息
**Files:** `server/src/routes/auth.ts`, `client/src/api/auth.ts`, `client/src/App.vue`
- [ ] **Step 1: auth.ts 两个登录路由返回 `nickname`, `avatar_url`**
- [ ] **Step 2: auth.ts LoginResult 类型扩展**
- [ ] **Step 3: App.vue 登录后存储 `xc:nickname`, `xc:avatar_url`**
- [ ] **Step 4: Commit** `feat: 登录返回用户信息`
---
#### Task 4: 用户信息 Store
**Files:** Create `client/src/api/user.ts`, Create `client/src/stores/user.ts`
- [ ] **Step 1: api/user.ts**`getUserInfo`, `updateNickname`, `uploadAvatar`(用 `uni.uploadFile`
- [ ] **Step 2: stores/user.ts**`nickname`, `avatarUrl` computed`fetchUserInfo`, `updateProfile`, `uploadAvatar`
- [ ] **Step 3: Commit** `feat: 用户信息 Store`
---
#### Task 5: 个人资料编辑页面
**Files:** Create `client/src/pages/profile-edit/index.vue`, Modify `client/src/pages.json`
- [ ] **Step 1: pages.json 注册页面**
- [ ] **Step 2: 创建编辑页** — 头像点击选择+上传,昵称输入框,保存按钮
- [ ] **Step 3: Commit** `feat: 个人资料编辑页面`
---
#### Task 6: 「我的」页面显示真实用户信息
**Files:** `client/src/pages/profile/index.vue`
- [ ] **Step 1: 引入 userStore替换硬编码头像和昵称**
- [ ] **Step 2: 点击 profile-card 跳转编辑页**
- [ ] **Step 3: Commit** `feat: 我的页面显示真实头像和昵称`
---
#### Task 7: 首页显示真实昵称
**Files:** `client/src/pages/index/index.vue`
- [ ] **Step 1: 引入 userStore替换 `小菜` 为 `userStore.nickname`**
- [ ] **Step 2: Commit** `feat: 首页问候语显示真实昵称`
---
### Part 2: 一起记功能Task 8-14
#### Task 8: 数据库 — 群组表
**Files:** `server/src/db/schema.sql`, `server/src/db/init.ts`
- [ ] **Step 1: schema.sql 添加 groups, group_members 表transactions 添加 group_id**
注意:`transactions.group_id` **不加外键约束**因为退出群组时需要保留记录group_id 设为 NULL
- [ ] **Step 2: init.ts runMigrations 添加群组迁移**
```typescript
const hasGroupId = await columnExists(conn, 'transactions', 'group_id')
if (!hasGroupId) {
// 创建 groups 表
// 创建 group_members 表
// ALTER TABLE transactions ADD COLUMN group_id INT DEFAULT NULL
// ALTER TABLE transactions ADD INDEX idx_group_date (group_id, date)
}
```
- [ ] **Step 3: 验证** `cd server && npm run db:init`
- [ ] **Step 4: Commit** `feat: 群组表创建transactions 添加 group_id`
---
#### Task 9: 群组 API
**Files:** Create `server/src/routes/group.ts`, Modify `server/src/index.ts`
- [ ] **Step 1: 创建 group 路由**
| 接口 | 关键逻辑 |
|------|---------|
| `POST /` | 生成邀请码,事务插入 groups + group_members(role=owner) |
| `GET /` | JOIN group_members含 member_count |
| `GET /:id` | 验证成员身份,返回群组信息 + 成员列表(含 nickname, avatar_url |
| `POST /join` | 验证邀请码,检查非成员,插入 group_members(role=member) |
| `POST /:id/leave` | **关键:先 `UPDATE transactions SET group_id = NULL WHERE user_id = ? AND group_id = ?`,再 `DELETE FROM group_members`** |
| `DELETE /:id` | 仅 owner`DELETE FROM groups`CASCADE 清理 memberstransactions.group_id 因无外键需手动清理) |
**解散群组时手动清理 transactions**
```sql
UPDATE transactions SET group_id = NULL WHERE group_id = ?
DELETE FROM groups WHERE id = ?
```
- [ ] **Step 2: index.ts 注册路由**
- [ ] **Step 3: Commit** `feat: 群组 API`
---
#### Task 10: 交易和统计 API 支持群组视图
**Files:** `server/src/routes/transaction.ts`, `server/src/routes/stats.ts`
**核心改造:查询逻辑根据 `group_id` 参数切换视图模式。**
- [ ] **Step 1: transaction.ts GET / 改造**
```typescript
const { group_id } = req.query
if (group_id && group_id !== 'null') {
// 群组视图:群组下所有成员的记录
// 先验证当前用户是群组成员
const [memberCheck] = await pool.query(
'SELECT id FROM group_members WHERE group_id = ? AND user_id = ?',
[group_id, req.userId]
)
if ((memberCheck as any[]).length === 0) {
return res.status(403).json({ code: 40300, message: '无权访问此群组' })
}
where = `WHERE t.group_id = ? AND t.user_id IN (SELECT user_id FROM group_members WHERE group_id = ?)`
params = [group_id, group_id]
} else {
// 个人视图:我的所有记录(不管 group_id
where = 'WHERE t.user_id = ?'
params = [req.userId]
}
```
- [ ] **Step 2: transaction.ts POST / 改造**
创建记录时接受可选 `group_id` 参数,写入数据库。
- [ ] **Step 3: transaction.ts PUT/DELETE 改造**
编辑/删除权限:只能操作自己 `user_id` 的记录(不管 group_id
- [ ] **Step 4: stats.ts 三个路由改造**
与 transaction 相同的视图切换逻辑。
- [ ] **Step 5: Commit** `feat: 交易和统计 API 支持群组视图`
---
#### Task 11: 群组客户端 Store
**Files:** Create `client/src/api/group.ts`, Create `client/src/stores/group.ts`, Modify `client/src/stores/transaction.ts`, Modify `client/src/stores/stats.ts`
- [ ] **Step 1: api/group.ts** — 封装群组 API
- [ ] **Step 2: stores/group.ts**
```typescript
// 核心状态
const currentGroupId = ref<number | null>(null) // null = 个人模式
const isGroupMode = computed(() => currentGroupId.value !== null)
// 切换方法
function switchToPersonal() {
currentGroupId.value = null
uni.removeStorageSync('xc:currentGroupId')
}
function switchToGroup(id: number) {
currentGroupId.value = id
uni.setStorageSync('xc:currentGroupId', id)
}
```
- [ ] **Step 3: 更新 transaction/stats Store**
每个 fetch 方法自动读取 `useGroupStore().currentGroupId` 并传给 API。
- [ ] **Step 4: Commit** `feat: 群组 Store + 数据层自动跟随`
---
#### Task 12: 「我的」页身份切换
**Files:** `client/src/pages/profile/index.vue`
- [ ] **Step 1: 在 profile-card 和 quick-stats 之间添加身份切换卡片**
```vue
<view class="identity-card" @tap="showIdentityPicker">
<Icon :name="groupStore.isGroupMode ? 'users' : 'user'" :size="32" color="#FF8C69" />
<view class="id-info">
<text class="id-name">{{ groupStore.isGroupMode ? groupStore.currentGroup?.name : '个人账本' }}</text>
<text class="id-desc">{{ groupStore.isGroupMode ? groupStore.currentGroup?.member_count + ' 人共享' : '仅自己可见' }}</text>
</view>
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
```
- [ ] **Step 2: 底部弹出身份选择面板**
```vue
<view class="identity-modal" v-if="showIdentity" @tap="showIdentity = false">
<view class="modal-content" @tap.stop>
<text class="modal-title">切换账本</text>
<!-- 个人账本 -->
<view class="id-option" :class="{ active: !groupStore.isGroupMode }" @tap="switchToPersonal">
<Icon name="user" :size="36" ... />
<text>个人账本</text>
<Icon v-if="!groupStore.isGroupMode" name="check" ... />
</view>
<!-- 群组列表 -->
<view v-for="g in groupStore.groups" ... @tap="switchToGroup(g.id)">
...
</view>
<!-- 操作按钮 -->
<view class="id-actions">
<view @tap="promptCreateGroup">创建群组</view>
<view @tap="promptJoinGroup">加入群组</view>
</view>
</view>
</view>
```
- [ ] **Step 3: 创建群组交互**
点击「创建群组」→ `uni.showModal` 输入名称 → 调用 `groupStore.createGroup()` → 成功后 `uni.setClipboardData` 复制邀请码 + Toast
- [ ] **Step 4: 加入群组交互**
点击「加入群组」→ `uni.showModal` 输入邀请码 → 调用 `groupStore.joinGroup()` → 成功后 Toast
- [ ] **Step 5: 切换后刷新数据**
切换身份后调用 `refreshData()` 重新加载当前页的 stats 和 transactions。
- [ ] **Step 6: Commit** `feat: 我的页身份切换`
---
#### Task 13: 群组管理页面
**Files:** Create `client/src/pages/group-manage/index.vue`, Modify `client/src/pages.json`
- [ ] **Step 1: pages.json 注册页面**
- [ ] **Step 2: 创建群组管理页面**
页面功能:
- 群组列表(卡片形式,显示名称、人数、邀请码)
- 点击邀请码可复制
- 成员列表
- 「退出群组」按钮(普通成员)— 二次确认:"退出后你在该群组的记录将回到个人账本"
- 「解散群组」按钮(群主)— 二次确认:"所有群组记录将被清除"
- [ ] **Step 3: Commit** `feat: 群组管理页面`
---
#### Task 14: App.vue 初始化 + 最终集成
**Files:** `client/src/App.vue`, `client/src/pages/index/index.vue`
- [ ] **Step 1: App.vue 初始化 groupStore**
登录成功后 `groupStore.init()` + `groupStore.fetchGroups()`
- [ ] **Step 2: 首页 fetchTodayData 传 group_id**
```typescript
const groupStore = useGroupStore()
const data = await getOverview({ startDate: today, endDate: today, group_id: groupStore.currentGroupId })
```
- [ ] **Step 3: 验证完整流程**
- [ ] **Step 4: Commit** `feat: 集成完成`
---
## 六、验证清单
### 用户信息
- [ ] 「我的」页显示真实头像和昵称
- [ ] 点击头像区域跳转编辑页,可上传头像(存到服务端)、修改昵称
- [ ] 保存后首页/我的页同步更新
### 一起记 — 创建/加入
- [ ] 身份切换面板中点击「创建群组」→ 输入名称 → 获得邀请码(自动复制)
- [ ] 点击「加入群组」→ 输入邀请码 → 加入成功
- [ ] 身份列表中出现新群组
### 一起记 — 记账归属
- [ ] 群组模式下记账 → 记录归属自己user_id = 我group_id = 群组
- [ ] 个人视图:看到自己所有记录(含群组中记的)
- [ ] 群组视图:看到所有成员在该群组下记的记录
- [ ] 编辑/删除:只能操作自己记的记录
### 一起记 — 退出/解散
- [ ] 退出群组 → 自己的记录回到个人视图,群组看不到
- [ ] 解散群组 → 群组消失,所有人的记录回到个人视图
- [ ] 退出/解散后自动切回个人模式