- 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 警告
20 KiB
用户信息完善 & 一起记功能 实现计划
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:只看到 ¥30(B 的记录已不在)
二、数据库设计
现有表(不变)
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)关键逻辑:
-- 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人 │
│ ───────────────────── │
│ + 创建群组 │
│ + 加入群组 │
└─────────────────────────┘
创建群组流程:
- 点击「创建群组」→ 弹出输入框输入名称 → 确认
- 创建成功 → 显示邀请码 → 自动复制到剪贴板
- 群组自动出现在身份列表中
加入群组流程:
- 点击「加入群组」→ 弹出输入框输入邀请码 → 确认
- 加入成功 → 群组自动出现在身份列表中
退出/解散(群组管理页):
- 点击群组卡片进入详情 → 显示成员列表、邀请码
- 成员可见「退出群组」按钮
- 群主可见「解散群组」按钮
五、任务清单
Part 1: 用户信息完善(Task 1-7)
Task 1: 数据库 — users 表扩展
Files: server/src/db/schema.sql, server/src/db/init.ts
- Step 1: 更新 schema.sql users 表
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 添加迁移
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 注册路由 + 静态文件
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,avatarUrlcomputed,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 添加群组迁移
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 清理 members,transactions.group_id 因无外键需手动清理) |
解散群组时手动清理 transactions:
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 / 改造
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
// 核心状态
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 之间添加身份切换卡片
<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: 底部弹出身份选择面板
<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
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 = 群组
- 个人视图:看到自己所有记录(含群组中记的)
- 群组视图:看到所有成员在该群组下记的记录
- 编辑/删除:只能操作自己记的记录
一起记 — 退出/解散
- 退出群组 → 自己的记录回到个人视图,群组看不到
- 解散群组 → 群组消失,所有人的记录回到个人视图
- 退出/解散后自动切回个人模式