Преглед на файлове

feat: 新增更新自身用户信息的接口 /auth/updateInfo

BaiLuoYan преди 4 дни
родител
ревизия
1a87ae868b

+ 28 - 0
README.md

@@ -420,6 +420,7 @@ flowchart LR
 | 产品/部门/角色/用户/成员列表与详情 | 已登录即可 | — |
 | 用户信息 (userInfo) | 已登录即可 | 返回当前登录用户自己的信息 |
 | **认证接口** | | |
+| 修改自身信息 (updateInfo) | 已登录即可 | 仅允许修改昵称/头像/邮箱/手机,userId 从 JWT 获取 |
 | 用户注销 (logout) | 已登录即可 | 递增 tokenVersion,所有已签发令牌即时失效 |
 | **公开接口** | | |
 | 产品端登录 (login) | 无需鉴权 | cap.js 未启用时需携带图片验证码;超级管理员被拒绝,productCode 必传 |
@@ -970,6 +971,33 @@ Content-Type: application/json
 | oldPassword | string | 是 | 原密码 |
 | newPassword | string | 是 | 新密码(6-72 字符,不能与旧密码相同) |
 
+#### POST /api/auth/updateInfo — 修改自身信息
+
+已登录用户修改自己的昵称、头像、邮箱、手机号。userId 从 JWT 令牌中提取,不接受客户端传入,防止越权修改他人信息。
+
+**调用场景:**
+
+- **个人资料编辑**:用户在个人设置页面修改昵称、头像、邮箱或手机号
+- **首次完善资料**:管理员创建用户后,用户首次登录补充个人信息
+- **头像更新**:用户上传新头像后调用此接口保存头像 URL
+
+**安全约束:**
+
+- userId 从 JWT 获取,禁止客户端指定(防 IDOR)
+- 仅允许修改 nickname、avatar、email、phone 四个安全字段
+- 不允许修改 username(登录凭证)、deptId、status、remark 等敏感字段
+- 使用乐观锁(`updateTime` CAS)防止并发覆盖
+- 至少需要传入一个字段,全部为空时返回 400
+
+| 字段 | 类型 | 必填 | 说明 |
+| ------ | ------ | ------ | ------ |
+| nickname | string | 否 | 昵称(上限 64 字符) |
+| avatar | string | 否 | 头像 URL(上限 255 字符) |
+| email | string | 否 | 邮箱(上限 64 字符) |
+| phone | string | 否 | 手机号(上限 32 字符) |
+
+**响应:** 无 data(成功时返回标准 `{"code":0,"msg":"ok"}` 结构)。若信息被其他会话并发修改,返回 409 冲突错误。
+
 ### 产品管理(仅超级管理员)
 
 #### POST /api/product/create — 创建产品

+ 32 - 0
internal/handler/auth/updateSelfInfoHandler.go

@@ -0,0 +1,32 @@
+// Code scaffolded by goctl. Safe to edit.
+// goctl 1.10.1
+
+package auth
+
+import (
+	"net/http"
+
+	"github.com/zeromicro/go-zero/rest/httpx"
+	"perms-system-server/internal/logic/auth"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+)
+
+func UpdateSelfInfoHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		var req types.UpdateSelfInfoReq
+		if err := httpx.Parse(r, &req); err != nil {
+			httpx.ErrorCtx(r.Context(), w, response.ErrBadRequest(err.Error()))
+			return
+		}
+
+		l := auth.NewUpdateSelfInfoLogic(r.Context(), svcCtx)
+		err := l.UpdateSelfInfo(&req)
+		if err != nil {
+			httpx.ErrorCtx(r.Context(), w, err)
+		} else {
+			httpx.Ok(w)
+		}
+	}
+}

+ 5 - 0
internal/handler/routes.go

@@ -34,6 +34,11 @@ func RegisterHandlers(server *rest.Server, serverCtx *svc.ServiceContext) {
 					Path:    "/auth/logout",
 					Handler: auth.LogoutHandler(serverCtx),
 				},
+				{
+					Method:  http.MethodPost,
+					Path:    "/auth/updateInfo",
+					Handler: auth.UpdateSelfInfoHandler(serverCtx),
+				},
 				{
 					Method:  http.MethodPost,
 					Path:    "/auth/userInfo",

+ 89 - 0
internal/logic/auth/updateSelfInfoLogic.go

@@ -0,0 +1,89 @@
+package auth
+
+import (
+	"context"
+	"errors"
+
+	"perms-system-server/internal/loaders"
+	"perms-system-server/internal/middleware"
+	userModel "perms-system-server/internal/model/user"
+	"perms-system-server/internal/response"
+	"perms-system-server/internal/svc"
+	"perms-system-server/internal/types"
+
+	"github.com/zeromicro/go-zero/core/logx"
+)
+
+type UpdateSelfInfoLogic struct {
+	logx.Logger
+	ctx    context.Context
+	svcCtx *svc.ServiceContext
+}
+
+func NewUpdateSelfInfoLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateSelfInfoLogic {
+	return &UpdateSelfInfoLogic{
+		Logger: logx.WithContext(ctx),
+		ctx:    ctx,
+		svcCtx: svcCtx,
+	}
+}
+
+func (l *UpdateSelfInfoLogic) UpdateSelfInfo(req *types.UpdateSelfInfoReq) error {
+	caller := middleware.GetUserDetails(l.ctx)
+	if caller == nil {
+		return response.ErrUnauthorized("未登录")
+	}
+
+	if req.Nickname == nil && req.Avatar == nil && req.Email == nil && req.Phone == nil {
+		return response.ErrBadRequest("至少需要修改一个字段")
+	}
+
+	if req.Nickname != nil && len(*req.Nickname) > 64 {
+		return response.ErrBadRequest("昵称长度不能超过64个字符")
+	}
+	if req.Avatar != nil && len(*req.Avatar) > 255 {
+		return response.ErrBadRequest("头像地址长度不能超过255个字符")
+	}
+	if req.Email != nil && len(*req.Email) > 64 {
+		return response.ErrBadRequest("邮箱长度不能超过64个字符")
+	}
+	if req.Phone != nil && len(*req.Phone) > 32 {
+		return response.ErrBadRequest("手机号长度不能超过32个字符")
+	}
+
+	user, err := l.svcCtx.SysUserModel.FindOne(l.ctx, caller.UserId)
+	if err != nil {
+		return response.ErrNotFound("用户不存在")
+	}
+
+	nickname := user.Nickname
+	avatar := user.Avatar.String
+	email := user.Email
+	phone := user.Phone
+
+	if req.Nickname != nil {
+		nickname = *req.Nickname
+	}
+	if req.Avatar != nil {
+		avatar = *req.Avatar
+	}
+	if req.Email != nil {
+		email = *req.Email
+	}
+	if req.Phone != nil {
+		phone = *req.Phone
+	}
+
+	if err := l.svcCtx.SysUserModel.UpdateSelfInfo(l.ctx, user.Id, user.Username, nickname, avatar, email, phone, user.UpdateTime); err != nil {
+		if errors.Is(err, userModel.ErrUpdateConflict) {
+			return response.ErrConflict("信息已被其他会话修改,请刷新后重试")
+		}
+		return err
+	}
+
+	cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx)
+	defer cancel()
+	l.svcCtx.UserDetailsLoader.Clean(cleanCtx, caller.UserId)
+
+	return nil
+}

+ 27 - 0
internal/model/user/sysUserModel.go

@@ -86,6 +86,9 @@ type (
 		//   - 调用方负责对 ids 去重并控制长度,空切片直接返回 nil;
 		//   - session==nil 返回错误。
 		BatchIncrementTokenVersionWithTx(ctx context.Context, session sqlx.Session, ids []int64) error
+		// UpdateSelfInfo 更新用户自身安全字段(昵称/头像/邮箱/手机),不涉及 deptId/status/tokenVersion。
+		// username 仅用于构造缓存键失效。avatar 使用 sql.NullString 语义写入(空串置 NULL)。
+		UpdateSelfInfo(ctx context.Context, id int64, username string, nickname, avatar, email, phone string, expectedUpdateTime int64) error
 	}
 
 	customSysUserModel struct {
@@ -193,6 +196,30 @@ func (m *customSysUserModel) UpdateProfile(ctx context.Context, id int64, userna
 	return nil
 }
 
+func (m *customSysUserModel) UpdateSelfInfo(ctx context.Context, id int64, username string, nickname, avatar, email, phone string, expectedUpdateTime int64) error {
+	sysUserIdKey := fmt.Sprintf("%s%v", cacheSysUserIdPrefix, id)
+	sysUserUsernameKey := fmt.Sprintf("%s%v", cacheSysUserUsernamePrefix, username)
+	now := time.Now().Unix()
+
+	var avatarVal sql.NullString
+	if avatar != "" {
+		avatarVal = sql.NullString{String: avatar, Valid: true}
+	}
+
+	res, err := m.ExecCtx(ctx, func(ctx context.Context, conn sqlx.SqlConn) (sql.Result, error) {
+		query := fmt.Sprintf("UPDATE %s SET `nickname`=?, `avatar`=?, `email`=?, `phone`=?, `updateTime`=? WHERE `id`=? AND `updateTime`=?", m.table)
+		return conn.ExecCtx(ctx, query, nickname, avatarVal, email, phone, now, id, expectedUpdateTime)
+	}, sysUserIdKey, sysUserUsernameKey)
+	if err != nil {
+		return err
+	}
+	affected, _ := res.RowsAffected()
+	if affected == 0 {
+		return ErrUpdateConflict
+	}
+	return nil
+}
+
 // UpdateProfileWithTx 见接口注释(审计 M-R11-3 + L-R12-1)。
 // 实现上**绕过** m.ExecCtx 的 pre-commit DelCache 语义——仅调用 session.ExecCtx,缓存失效由
 // 调用方在事务 commit 成功后显式走 InvalidateProfileCache。

+ 14 - 0
internal/testutil/mocks/mock_user_model.go

@@ -478,6 +478,20 @@ func (mr *MockSysUserModelMockRecorder) UpdateProfileWithTx(ctx, session, id, us
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateProfileWithTx", reflect.TypeOf((*MockSysUserModel)(nil).UpdateProfileWithTx), ctx, session, id, username, nickname, email, phone, remark, deptId, newStatus, statusChanged, expectedUpdateTime)
 }
 
+// UpdateSelfInfo mocks base method.
+func (m *MockSysUserModel) UpdateSelfInfo(ctx context.Context, id int64, username, nickname, avatar, email, phone string, expectedUpdateTime int64) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "UpdateSelfInfo", ctx, id, username, nickname, avatar, email, phone, expectedUpdateTime)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// UpdateSelfInfo indicates an expected call of UpdateSelfInfo.
+func (mr *MockSysUserModelMockRecorder) UpdateSelfInfo(ctx, id, username, nickname, avatar, email, phone, expectedUpdateTime any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSelfInfo", reflect.TypeOf((*MockSysUserModel)(nil).UpdateSelfInfo), ctx, id, username, nickname, avatar, email, phone, expectedUpdateTime)
+}
+
 // UpdateStatus mocks base method.
 func (m *MockSysUserModel) UpdateStatus(ctx context.Context, id int64, username string, status, expectedUpdateTime int64) error {
 	m.ctrl.T.Helper()

+ 7 - 0
internal/types/types.go

@@ -292,6 +292,13 @@ type UpdateRoleReq struct {
 	Status     int64  `json:"status,optional"`
 }
 
+type UpdateSelfInfoReq struct {
+	Nickname *string `json:"nickname,optional"`
+	Avatar   *string `json:"avatar,optional"`
+	Email    *string `json:"email,optional"`
+	Phone    *string `json:"phone,optional"`
+}
+
 type UpdateUserReq struct {
 	Id       int64   `json:"id"`
 	Nickname *string `json:"nickname,optional"`

+ 10 - 0
perm.api

@@ -77,6 +77,12 @@ type (
 		OldPassword string `json:"oldPassword"`
 		NewPassword string `json:"newPassword"`
 	}
+	UpdateSelfInfoReq {
+		Nickname *string `json:"nickname,optional"`
+		Avatar   *string `json:"avatar,optional"`
+		Email    *string `json:"email,optional"`
+		Phone    *string `json:"phone,optional"`
+	}
 )
 
 // ==================== Product ====================
@@ -435,6 +441,10 @@ service perm-api {
 	// Logout 用户注销。递增 tokenVersion 使所有已签发的 access/refresh 令牌立即失效,并清除用户缓存
 	@handler Logout
 	post /auth/logout
+
+	// UpdateSelfInfo 修改当前登录用户自身信息。仅允许修改昵称、头像、邮箱、手机,userId 从 JWT 获取
+	@handler UpdateSelfInfo
+	post /auth/updateInfo (UpdateSelfInfoReq)
 }
 
 // 产品管理(仅超管可操作)