Files
xiaocai/docs/superpowers/plans/2026-06-03-user-profile-and-group-accounting.md
wangxiaogang 21f4d81959 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 警告
2026-06-03 17:50:32 +08:00

590 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 用户信息完善 & 一起记功能 实现计划
> **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 = 群组
- [ ] 个人视图:看到自己所有记录(含群组中记的)
- [ ] 群组视图:看到所有成员在该群组下记的记录
- [ ] 编辑/删除:只能操作自己记的记录
### 一起记 — 退出/解散
- [ ] 退出群组 → 自己的记录回到个人视图,群组看不到
- [ ] 解散群组 → 群组消失,所有人的记录回到个人视图
- [ ] 退出/解散后自动切回个人模式