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

20 KiB
Raw Blame History

用户信息完善 & 一起记功能 实现计划

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 表 → transactionsgroup_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)关键逻辑:

-- 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 表
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.tsgetUserInfo, updateNickname, uploadAvatar(用 uni.uploadFile
  • Step 2: stores/user.tsnickname, avatarUrl computedfetchUserInfo, 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 仅 ownerDELETE FROM groupsCASCADE 清理 memberstransactions.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 = 群组
  • 个人视图:看到自己所有记录(含群组中记的)
  • 群组视图:看到所有成员在该群组下记的记录
  • 编辑/删除:只能操作自己记的记录

一起记 — 退出/解散

  • 退出群组 → 自己的记录回到个人视图,群组看不到
  • 解散群组 → 群组消失,所有人的记录回到个人视图
  • 退出/解散后自动切回个人模式