跳转到内容

V2: 多人使用 —— 「给团队也用上」

V1的HTML记账工具你自己用了两周,老板觉得不错,说让10人的团队都用上。问题来了:localStorage的数据只在你电脑的浏览器里,同事打开同一个HTML文件,看到的是他自己的空数据。更麻烦的是,老板说他要能审批报销,还要能看所有人的记录。

当前状态: V1是纯前端+localStorage,数据只在本地浏览器,无法共享。 目标: 加共享后端,多人能同时用,每个人只看自己的费用,管理员能看全部并审批。 约束: 10个人用,Go+PostgreSQL后端,JWT认证,不用OAuth(内部系统)。


层级问题思考
Why为什么要改?多人使用必须隔离数据,报销需要审批流程
What改什么?加用户体系 + 权限控制 + 审批状态
How (架构)怎么加?JWT中间件拦截请求,从token取user_id做数据隔离
How (实现)具体改哪些?新增User模型、注册登录接口、中间件、Expense加外键和状态
How (迁移)老数据怎么办?V1数据在localStorage,可以导出JSON手动导入;也可以直接重新录入(数量不多)

graph LR
B["浏览器<br/>(React)"] -->|"HTTP/JSON"| G["Go/Gin 后端<br/>+ JWT 认证"]
G -->|"SQL"| DB["PostgreSQL<br/>数据库"]
G -->|"JSON 响应"| B
// 新增 User 表
type User struct {
ID uint `json:"id" gorm:"primaryKey"`
Email string `json:"email" gorm:"uniqueIndex;not null"`
PasswordHash string `json:"-"` // json:"-" 永远不返回给前端
Name string `json:"name"`
Role string `json:"role" gorm:"default:user"` // user / admin
CreatedAt time.Time `json:"created_at"`
}
// Expense 表增加字段
type Expense struct {
// ... 原有字段不变
UserID uint `json:"user_id" gorm:"index;not null"` // 外键
Status string `json:"status" gorm:"default:pending"` // pending/approved/rejected
User User `json:"user,omitempty" gorm:"foreignKey:UserID"`
}
方法路径权限说明
POST/api/auth/register公开注册
POST/api/auth/login公开登录,返回JWT
GET/api/auth/me登录获取当前用户信息
GET/api/expenses登录普通用户只看自己的
GET/api/admin/expensesadmin管理员看全部
PUT/api/admin/expenses/:id/approveadmin审批通过
PUT/api/admin/expenses/:id/rejectadmin审批驳回
决策点选项A选项B选择理由
认证方式Session+CookieJWTJWT前后端分离,无状态更好扩展
密码加密SHA256bcryptbcrypt自带盐值,抗彩虹表,业界标准
权限控制RBAC完整实现简单role字段简单role只有user/admin两种,不需要过度设计
Token过期永不过期24小时+Refresh24小时V2先简单,后续再加refresh token
数据隔离应用层WHERE数据库RLS应用层GORM操作更直观,团队小不需要RLS

我有一个V1版本的记账工具(纯HTML+localStorage,单用户),现在要把它升级成多人共用的系统。
请帮我用 Go + Gin + GORM + PostgreSQL + React + Tailwind CSS 重新实现,并支持以下功能:
## 新增需求
1. 用户注册/登录(JWT认证)
2. 角色:user(普通用户)和 admin(管理员)
3. 数据隔离:普通用户只能看到自己的费用
4. 审批流程:费用有 pending/approved/rejected 三种状态
5. 管理员可以查看所有费用、批准或驳回
## 数据模型变更
新增 User 表:
- id: 主键
- email: string, 唯一索引
- password_hash: string(用bcrypt加密,json标签设为"-"不返回前端)
- name: string
- role: string, 默认"user", 可选"admin"
- created_at
Expense 表新增:
- user_id: uint, 外键关联User,加索引
- status: string, 默认"pending"
## 认证方案
- 注册:POST /api/auth/register,接收email+password+name
- 登录:POST /api/auth/login,返回 { token: "xxx" }
- JWT存在Authorization: Bearer xxx头里
- 写一个Gin中间件解析JWT,把user_id和role塞到gin.Context
- 再写一个admin中间件检查role
## 密码安全要求
- 用golang.org/x/crypto/bcrypt
- cost factor用默认值(10)
- 注册时检查email是否已存在
- 登录失败统一返回"邮箱或密码错误",不要区分是邮箱不存在还是密码错
## API权限
- /api/auth/* 公开
- /api/expenses/* 需要登录(中间件自动注入user_id,查询自动加WHERE user_id=?)
- /api/admin/* 需要admin角色
## 前端改造
- 加登录/注册页面
- 登录后把token存localStorage
- 每次请求在header带上token
- 401时跳转登录页
- 管理员多一个"审批管理"页面
请保持V1的所有功能不变,在其基础上新增。

  • 注册接口能创建用户,密码在数据库里是bcrypt哈希
  • 登录返回有效的JWT token
  • 不带token访问/api/expenses返回401
  • 用户A看不到用户B的费用
  • 新建的费用status默认是pending
  • 管理员能看到所有人的pending费用
  • 审批通过后status变为approved
  • 普通用户访问/api/admin/*返回403
  • 登录失败不泄露是邮箱错还是密码错
  • password_hash不出现在任何API响应中

知识点对应模块
JWT生成、解析、中间件拦截→ Module 12(认证授权)
bcrypt密码加密、cost factor、盐值→ Module 14(密码安全)
基于角色的访问控制(RBAC简化版)→ Module 12(认证授权)
登录错误信息不应区分原因→ Module 14(密码安全)
数据隔离:WHERE user_id = ?→ Module 12(认证授权)

// 严重错误:直接存密码
user.Password = input.Password
// 正确:bcrypt加密
hash, _ := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
user.PasswordHash = string(hash)
// 错误:密钥写死
token.SignedString([]byte("secret123"))
// 正确:从环境变量读取
token.SignedString([]byte(os.Getenv("JWT_SECRET")))
// 并且JWT_SECRET至少32个字符的随机字符串
// 错误:任何人都能看到所有数据
db.Find(&expenses)
// 正确:从context取user_id做过滤
userID := c.GetUint("user_id")
db.Where("user_id = ?", userID).Find(&expenses)
// 错误:告诉攻击者哪个环节出了问题
if user not found { return "用户不存在" }
if wrong password { return "密码错误" }
// 正确:统一错误信息
return "邮箱或密码错误"
// 登录成功后存token
localStorage.setItem('token', data.token)
// 每次请求带上
headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }
// 401时清理并跳转(别忘了这步!不然用户会卡在白屏)
if (res.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
// AutoMigrate只会加字段,不会删或改字段
// 如果你改了字段名(比如password→password_hash),老字段还在
// 开发阶段可以手动DROP TABLE重来
// 生产环境必须写migration脚本