diff --git a/modules/group/api.go b/modules/group/api.go index 50d922ae..59c039b5 100644 --- a/modules/group/api.go +++ b/modules/group/api.go @@ -29,6 +29,8 @@ import ( spacemod "github.com/Mininglamp-OSS/octo-server/modules/space" "github.com/Mininglamp-OSS/octo-server/modules/user" "github.com/Mininglamp-OSS/octo-server/pkg/auth" + "github.com/Mininglamp-OSS/octo-server/pkg/errcode" + "github.com/Mininglamp-OSS/octo-server/pkg/httperr" octoredis "github.com/Mininglamp-OSS/octo-server/pkg/redis" spacepkg "github.com/Mininglamp-OSS/octo-server/pkg/space" appwkhttp "github.com/Mininglamp-OSS/octo-server/pkg/wkhttp" @@ -171,13 +173,13 @@ func (g *Group) disband(c *wkhttp.Context) { loginUID := c.GetLoginUID() loginName := c.GetLoginName() if groupNo == "" { - c.ResponseError(errors.New("群ID不能为空")) + respondGroupRequestInvalid(c, "group_no") return } group, err := g.db.QueryWithGroupNo(groupNo) if err != nil { g.Error("查询群资料错误", zap.Error(err)) - c.ResponseError(errors.New("查询群资料错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if group == nil || group.Status == GroupStatusDisband { @@ -187,12 +189,12 @@ func (g *Group) disband(c *wkhttp.Context) { loginMember, err := g.db.QueryMemberWithUID(loginUID, groupNo) if err != nil { g.Error("查询用户群内身份错误", zap.Error(err)) - c.ResponseError(errors.New("查询用户群内身份错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if loginMember == nil || loginMember.Role != MemberRoleCreator { g.Error("用户无权执行此操作", zap.Error(err)) - c.ResponseError(errors.New("用户无权执行此操作")) + respondGroupForbidden(c) return } @@ -200,7 +202,7 @@ func (g *Group) disband(c *wkhttp.Context) { tx, err := g.ctx.DB().Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -214,7 +216,7 @@ func (g *Group) disband(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("修改群状态错误", zap.Error(err)) - c.ResponseError(errors.New("修改群状态错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } // err = g.db.deleteMembersWithGroupNOTx(groupNo, tx) @@ -237,13 +239,13 @@ func (g *Group) disband(c *wkhttp.Context) { if err != nil { tx.RollbackUnlessCommitted() g.Error("开启事件失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if err := tx.Commit(); err != nil { tx.RollbackUnlessCommitted() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } g.ctx.EventCommit(eventID) @@ -267,11 +269,11 @@ func (g *Group) membersGet(c *wkhttp.Context) { isMember, err := g.db.ExistMember(loginUID, groupNo) if err != nil { g.Error("查询群成员关系失败", zap.Error(err)) - c.ResponseError(errors.New("查询群成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isMember { - c.ResponseError(errors.New("没有权限查看此群成员列表")) + httperr.ResponseErrorL(c, errcode.ErrGroupViewForbidden, nil, nil) return } @@ -279,7 +281,7 @@ func (g *Group) membersGet(c *wkhttp.Context) { members, err = g.db.queryMembersWithKeyword(groupNo, loginUID, keyword, page, limit) if err != nil { g.Error("查询成员列表失败!", zap.Error(err)) - c.ResponseError(errors.New("查询成员列表失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } @@ -312,18 +314,18 @@ func (g *Group) memberGet(c *wkhttp.Context) { isMember, err := g.db.ExistMember(loginUID, groupNo) if err != nil { g.Error("查询群成员关系失败", zap.Error(err)) - c.ResponseError(errors.New("查询群成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isMember { - c.ResponseError(errors.New("没有权限查看此群成员")) + httperr.ResponseErrorL(c, errcode.ErrGroupViewForbidden, nil, nil) return } detail, err := g.db.queryMemberWithGroupNoAndUID(groupNo, targetUID) if err != nil { g.Error("查询群成员失败", zap.Error(err)) - c.ResponseError(errors.New("查询群成员失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if detail == nil { @@ -405,26 +407,28 @@ func (g *Group) avatarUpload(c *wkhttp.Context) { loginUID := c.GetLoginUID() groupNo := c.Param("group_no") if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } _, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } if c.Request.MultipartForm == nil { err := c.Request.ParseMultipartForm(1024 * 1024 * 20) // 20M if err != nil { g.Error("数据格式不正确!", zap.Error(err)) - c.ResponseError(errors.New("数据格式不正确!")) + respondGroupRequestInvalid(c, "") return } } file, _, err := c.Request.FormFile("file") if err != nil { + // FormFile 在 file 字段缺失/为空时返回 http.ErrMissingFile —— 纯客户端 + // 错误,应为 400,而非内部存储失败。与上面 ParseMultipartForm 分支一致。 g.Error("读取文件失败!", zap.Error(err)) - c.ResponseError(errors.New("读取文件失败!")) + respondGroupRequestInvalid(c, "file") return } defer file.Close() @@ -432,11 +436,11 @@ func (g *Group) avatarUpload(c *wkhttp.Context) { isCreator, err := g.db.QueryIsGroupCreator(groupNo, loginUID) if err != nil { g.Error("查询群创建者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询群创建者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isCreator { - c.ResponseError(errors.New("只有创建者才能修改头像")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOnly, nil, nil) return } @@ -447,13 +451,13 @@ func (g *Group) avatarUpload(c *wkhttp.Context) { }) if err != nil { g.Error("上传文件失败!", zap.Error(err)) - c.ResponseError(errors.New("上传文件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.updateAvatar(groupAvatarPath, groupNo) if err != nil { g.Error("头像修改失败!", zap.String("group_no", groupNo), zap.Error(err)) - c.ResponseError(errors.New("头像修改失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } // 发送群头像更新命令 @@ -467,7 +471,7 @@ func (g *Group) avatarUpload(c *wkhttp.Context) { }) if err != nil { g.Error("发送群头像更新命令失败!", zap.String("groupNo", groupNo), zap.Error(err)) - c.ResponseError(errors.New("发送群头像更新命令失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } c.ResponseOK() @@ -488,28 +492,28 @@ func (g *Group) syncMembers(c *wkhttp.Context) { isMember, err := g.db.ExistMember(loginUID, groupNo) if err != nil { g.Error("查询群成员关系失败", zap.Error(err)) - c.ResponseError(errors.New("查询群成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isMember { - c.ResponseError(errors.New("没有权限同步此群成员")) + httperr.ResponseErrorL(c, errcode.ErrGroupViewForbidden, nil, nil) return } group, err := g.db.QueryWithGroupNo(groupNo) if err != nil { g.Error("查询群信息失败!", zap.Error(err), zap.String("groupNo", groupNo)) - c.ResponseError(errors.New("查询群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if group == nil { g.Error("群不存在不能同步成员!", zap.String("groupNo", groupNo)) - c.ResponseError(errors.New("群不存在不能同步成员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) return } if group.GroupType == int(GroupTypeSuper) { g.Error("超大群不支持同步群成员!", zap.String("groupNo", groupNo)) - c.ResponseError(errors.New("超大群不支持同步群成员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupTooLargeToSync, nil, nil) return } @@ -521,7 +525,7 @@ func (g *Group) syncMembers(c *wkhttp.Context) { memberModels, err := g.db.SyncMembers(groupNo, version, limit) if err != nil { g.Error("同步成员信息失败!", zap.Error(err), zap.String("groupNo", groupNo)) - c.ResponseError(errors.New("同步成员信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } resps := make([]memberDetailResp, 0) @@ -553,17 +557,18 @@ func (g *Group) groupGet(c *wkhttp.Context) { isMember, err := g.db.ExistMember(uid, groupNo) if err != nil { g.Error("查询群成员关系失败", zap.Error(err)) - c.ResponseError(errors.New("查询群成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isMember { - c.ResponseError(errors.New("没有权限查看此群信息")) + httperr.ResponseErrorL(c, errcode.ErrGroupViewForbidden, nil, nil) return } groupResp, err := g.groupService.GetGroupDetail(groupNo, uid) if err != nil { - c.ResponseError(err) + g.Error("查询群详情失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } c.Response(groupResp) @@ -577,11 +582,11 @@ func (g *Group) groupDetailGet(c *wkhttp.Context) { groupModel, err := g.db.QueryWithGroupNo(groupNo) if err != nil { g.Error("查询群信息失败!", zap.Error(err)) - c.ResponseError(errors.New("查询群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if groupModel == nil { - c.ResponseError(errors.New("群不存在!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) return } @@ -589,18 +594,18 @@ func (g *Group) groupDetailGet(c *wkhttp.Context) { isMember, err := g.db.ExistMember(loginUID, groupNo) if err != nil { g.Error("检查群成员失败!", zap.Error(err)) - c.ResponseError(errors.New("检查群成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isMember { - c.ResponseErrorWithStatus(errors.New("无权限查看群详情"), http.StatusForbidden) + httperr.ResponseErrorL(c, errcode.ErrGroupViewForbidden, nil, nil) return } memberCount, err := g.db.QueryMemberCount(groupNo) if err != nil { g.Error("查询成员数量失败!", zap.Error(err)) - c.ResponseError(errors.New("查询成员数量失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } // YUJ-168 / GH #1243: 外部群 H5 邀请 landing 页的信任锚点。 @@ -630,7 +635,7 @@ func (g *Group) list(c *wkhttp.Context) { groups, err := g.db.queryGroupsWithMemberUIDAndSpaceID(loginUID, spaceID) if err != nil { g.Error("查询Space群列表失败", zap.Error(err)) - c.ResponseError(errors.New("查询Space群列表失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } resps := make([]*GroupResp, 0) @@ -651,7 +656,7 @@ func (g *Group) list(c *wkhttp.Context) { models, err := g.db.querySavedGroups(loginUID) if err != nil { g.Error("查询我保存的群聊失败", zap.Error(err)) - c.ResponseError(errors.New("查询我保存的群聊失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } resps := make([]*GroupResp, 0) @@ -668,36 +673,36 @@ func (g *Group) groupCreate(c *wkhttp.Context) { var req groupReq if err := c.BindJSON(&req); err != nil { g.Error(common.ErrData.Error(), zap.Error(err)) - c.ResponseError(common.ErrData) + respondGroupRequestInvalid(c, "") return } if err := req.Check(); err != nil { - c.ResponseError(err) + respondGroupRequestInvalid(c, "") return } // 校验 category_id if req.CategoryID != "" { if req.SpaceID == "" { - c.ResponseError(errors.New("使用群聊分组需要指定 space_id")) + respondGroupRequestInvalid(c, "space_id") return } cat, err := g.db.QueryCategoryByID(req.CategoryID) if err != nil { g.Error("查询群聊分组失败", zap.Error(err)) - c.ResponseError(errors.New("查询群聊分组失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if cat == nil || cat.Status != 1 { - c.ResponseError(errors.New("群聊分组不存在")) + httperr.ResponseErrorL(c, errcode.ErrGroupCategoryNotFound, nil, nil) return } if cat.UID != creator { - c.ResponseError(errors.New("无权限使用此分组")) + httperr.ResponseErrorL(c, errcode.ErrGroupCategoryForbidden, nil, nil) return } if cat.SpaceID != req.SpaceID { - c.ResponseError(errors.New("群聊分组和空间不匹配")) + httperr.ResponseErrorL(c, errcode.ErrGroupCategorySpaceMismatch, nil, nil) return } } @@ -705,11 +710,11 @@ func (g *Group) groupCreate(c *wkhttp.Context) { count, err := g.db.querySameDayCreateCountWitUID(creator, util.Toyyyy_MM_dd(time.Now())) if err != nil { g.Error("查询用户当天建群数量失败!", zap.Error(err)) - c.ResponseError(errors.New("查询用户当天建群数量失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if g.ctx.GetConfig().Group.SameDayCreateMaxCount <= count { - c.ResponseError(errors.New("当天建群数量已达上限")) + httperr.ResponseErrorL(c, errcode.ErrGroupDailyCreateLimit, nil, nil) return } realUids := make([]string, 0) @@ -722,14 +727,14 @@ func (g *Group) groupCreate(c *wkhttp.Context) { friends, err = m.BussDataSource.GetFriends(creator) if err != nil { g.Error("查询用户好友错误", zap.Error(err)) - c.ResponseError(errors.New("查询用户好友错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } break } } if len(friends) == 0 { - c.ResponseError(errors.New("添加用户非好友关系,请先添加好友")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotFriend, nil, nil) return } if len(req.Members) > 0 { @@ -746,14 +751,14 @@ func (g *Group) groupCreate(c *wkhttp.Context) { realUids = req.Members } if len(realUids) == 0 { - c.ResponseError(errors.New("添加用户非好友关系,请先添加好友")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotFriend, nil, nil) return } // 判断是否允许系统账号进入群聊 appConfig, err := g.commonService.GetAppConfig() if err != nil { g.Error("查询应用设置错误", zap.Error(err)) - c.ResponseError(errors.New("查询应用设置错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if appConfig != nil && appConfig.InviteSystemAccountJoinGroupOn == 0 { @@ -765,7 +770,7 @@ func (g *Group) groupCreate(c *wkhttp.Context) { } } if isContainSystemAccount { - c.ResponseError(errors.New("不支持将`文件助手`加入群聊")) + httperr.ResponseErrorL(c, errcode.ErrGroupFileHelperNotAllowed, nil, nil) return } } @@ -780,7 +785,7 @@ func (g *Group) groupCreate(c *wkhttp.Context) { }) if err != nil { g.Error("创建群失败!", zap.Error(err)) - c.ResponseError(errors.New("创建群失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -803,7 +808,7 @@ func (g *Group) groupCreate(c *wkhttp.Context) { groupModel, err := g.db.QueryWithGroupNo(createResp.GroupNo) if err != nil { g.Error("查询群信息失败!", zap.Error(err)) - c.ResponseError(errors.New("查询群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } groupResp := &GroupResp{} @@ -830,28 +835,28 @@ func (g *Group) groupUpdate(c *wkhttp.Context) { var groupMap map[string]string if err := c.BindJSON(&groupMap); err != nil { g.Error("数据格式有误!", zap.Error(err)) - c.ResponseError(errors.New("数据格式有误!")) + respondGroupRequestInvalid(c, "") return } if len(groupMap) <= 0 { - c.ResponseError(errors.New("没有需要更新的属性!")) + respondGroupRequestInvalid(c, "") return } // 查询群信息 group, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } // 查询是否是管理者 isManager, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("查询是否是群管理者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是群管理者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isManager { - c.ResponseError(errors.New("只有群管理者才能修改!")) + httperr.ResponseErrorL(c, errcode.ErrGroupManagerOnly, nil, nil) return } @@ -875,7 +880,7 @@ func (g *Group) groupUpdate(c *wkhttp.Context) { } if err := g.groupService.UpdateGroupInfo(serviceReq); err != nil { g.Error("更新群信息失败!", zap.Error(err)) - c.ResponseError(errors.New("更新群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } @@ -886,7 +891,8 @@ func (g *Group) groupUpdate(c *wkhttp.Context) { group.Invite = int(invite) version, err := g.ctx.GenSeq(common.GroupSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } group.Version = version @@ -894,7 +900,7 @@ func (g *Group) groupUpdate(c *wkhttp.Context) { tx, err := g.ctx.DB().Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -907,7 +913,7 @@ func (g *Group) groupUpdate(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("更新群信息失败!", zap.Error(err), zap.String("group_no", group.GroupNo)) - c.ResponseError(errors.New("更新群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } eventID, err := g.ctx.EventBegin(&wkevent.Data{ @@ -924,13 +930,13 @@ func (g *Group) groupUpdate(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("开启事件失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if err := tx.Commit(); err != nil { tx.RollbackUnlessCommitted() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } g.ctx.EventCommit(eventID) @@ -946,11 +952,11 @@ func (g *Group) memberAdd(c *wkhttp.Context) { var req memberAddReq if err := c.BindJSON(&req); err != nil { g.Error(common.ErrData.Error(), zap.Error(err)) - c.ResponseError(common.ErrData) + respondGroupRequestInvalid(c, "") return } if err := req.Check(); err != nil { - c.ResponseError(err) + respondGroupRequestInvalid(c, "") return } groupNo := c.Param("group_no") @@ -959,18 +965,18 @@ func (g *Group) memberAdd(c *wkhttp.Context) { **/ group, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } // 校验操作者是群成员,防止任意用户向任意群添加成员(issue#1018) isMember, err := g.db.ExistMember(operator, groupNo) if err != nil { g.Error("查询群成员关系失败", zap.Error(err)) - c.ResponseError(errors.New("查询群成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isMember { - c.ResponseError(errors.New("非群成员不能添加群成员")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotMember, nil, nil) return } @@ -983,11 +989,11 @@ func (g *Group) memberAdd(c *wkhttp.Context) { // 攻击面——非授权方对 bot 的探测请求应尽早被拒绝。 if botErr := checkBotOwnership(g.ctx.DB(), operator, req.Members); botErr != nil { if errors.Is(botErr, ErrBotOwnershipDenied) { - c.ResponseErrorWithStatus(ErrBotOwnershipDenied, http.StatusForbidden) + httperr.ResponseErrorL(c, errcode.ErrGroupBotOwnershipDenied, nil, nil) return } g.Error("检查 Bot 归属失败", zap.Error(botErr)) - c.ResponseError(errors.New("检查 Bot 归属失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } @@ -995,7 +1001,7 @@ func (g *Group) memberAdd(c *wkhttp.Context) { appConfig, err := g.commonService.GetAppConfig() if err != nil { g.Error("查询应用设置错误", zap.Error(err)) - c.ResponseError(errors.New("查询应用设置错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if appConfig != nil && appConfig.InviteSystemAccountJoinGroupOn == 0 { @@ -1007,7 +1013,7 @@ func (g *Group) memberAdd(c *wkhttp.Context) { } } if isContainSystemAccount { - c.ResponseError(errors.New("不支持将`文件助手`加入群聊")) + httperr.ResponseErrorL(c, errcode.ErrGroupFileHelperNotAllowed, nil, nil) return } } @@ -1018,11 +1024,11 @@ func (g *Group) memberAdd(c *wkhttp.Context) { creatorOrManager, err := g.db.QueryIsGroupManagerOrCreator(groupNo, operator) if err != nil { g.Error("查询是否是创建者和管理者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是创建者和管理者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !creatorOrManager { - c.ResponseError(errors.New("群开启了邀请模式,不能添加群成员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteModeCannotAdd, nil, nil) return } } @@ -1034,7 +1040,7 @@ func (g *Group) memberAdd(c *wkhttp.Context) { operatorMember, opErr := g.db.QueryMemberWithUID(operator, groupNo) if opErr != nil { g.Error("查询操作者群成员失败", zap.Error(opErr)) - c.ResponseError(errors.New("查询操作者群成员失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if operatorMember != nil && operatorMember.IsExternal == 1 && operatorMember.SourceSpaceID != "" { @@ -1045,18 +1051,18 @@ func (g *Group) memberAdd(c *wkhttp.Context) { err = g.ctx.DB().SelectBySql("SELECT COALESCE((SELECT robot FROM `user` WHERE uid=? LIMIT 1), 0)", memberUID).LoadOne(&isBot) if err != nil { g.Error("查询用户robot状态失败", zap.Error(err), zap.String("memberUID", memberUID)) - c.ResponseError(errors.New("查询用户信息失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if isBot == 1 { inSpace, checkErr := spacepkg.CheckMembership(g.ctx.DB(), inviterSpaceID, memberUID) if checkErr != nil { g.Error("检查Bot Space成员失败", zap.Error(checkErr)) - c.ResponseError(errors.New("检查Bot Space成员失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !inSpace { - c.ResponseError(errors.New("该 Bot 不属于你的 Space")) + httperr.ResponseErrorL(c, errcode.ErrGroupBotNotInSpace, nil, nil) return } } @@ -1071,7 +1077,26 @@ func (g *Group) memberAdd(c *wkhttp.Context) { OperatorName: operatorName, }) if err != nil { - c.ResponseError(err) + // AddGroupMembers 会返回业务拒绝(如 allow_external=0 群里普通成员邀请 + // 外部用户);这类是用户态 403,不能吞成内部错误。沿用 invite.go 的 + // 字符串判定(服务层 sentinel 抽取是 thread/user 同款 follow-up)。 + if strings.Contains(err.Error(), "禁止外部成员") { + httperr.ResponseErrorL(c, errcode.ErrGroupExternalJoinForbidden, nil, nil) + return + } + // 去重 / trim 后无有效成员(如 members 全是空白串)是 400 校验错误, + // 不是存储失败。 + if strings.Contains(err.Error(), "no valid members") { + respondGroupRequestInvalid(c, "members") + return + } + // TOCTOU:getGroupInfo 之后群被解散 → 404,而非内部错误。 + if strings.Contains(err.Error(), "group not found or disbanded") { + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) + return + } + g.Error("添加群成员失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -1526,16 +1551,16 @@ func (g *Group) managerAdd(c *wkhttp.Context) { var memberUIDs []string if err := c.BindJSON(&memberUIDs); err != nil { g.Error("数据格式有误!", zap.Error(err)) - c.ResponseError(errors.New("数据格式有误!")) + respondGroupRequestInvalid(c, "") return } if len(memberUIDs) <= 0 { - c.ResponseError(errors.New("请选择需要添加的成员!")) + respondGroupRequestInvalid(c, "members") return } for _, memberUID := range memberUIDs { if memberUID == loginUID { - c.ResponseError(errors.New("不能将自己设置为管理员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupCannotTargetSelf, nil, nil) return } } @@ -1543,23 +1568,24 @@ func (g *Group) managerAdd(c *wkhttp.Context) { isCreator, err := g.db.QueryIsGroupCreator(groupNo, loginUID) if err != nil { g.Error("查询是否是创建者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是创建者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isCreator { - c.ResponseError(errors.New("只有创建者才能设置管理员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOnly, nil, nil) return } groupModel, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -1572,11 +1598,11 @@ func (g *Group) managerAdd(c *wkhttp.Context) { targetMember, err := g.db.QueryMemberWithUID(uid, groupNo) if err != nil { g.Error("查询群成员关系失败", zap.String("uid", uid), zap.Error(err)) - c.ResponseError(errors.New("查询群成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if targetMember == nil { - c.ResponseError(errors.New("目标用户不是群成员,无法设为管理员")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } if targetMember.IsExternal == 1 { @@ -1584,7 +1610,7 @@ func (g *Group) managerAdd(c *wkhttp.Context) { zap.String("group_no", groupNo), zap.String("target_uid", uid), zap.String("operator", loginUID)) - c.ResponseErrorWithStatus(errors.New("不能提拔外部成员为管理员"), http.StatusForbidden) + httperr.ResponseErrorL(c, errcode.ErrGroupExternalCannotBeAdmin, nil, nil) return } } @@ -1592,14 +1618,14 @@ func (g *Group) managerAdd(c *wkhttp.Context) { err = g.db.UpdateMembersToManager(groupNo, memberUIDs, version) if err != nil { g.Error("更新成员为管理员失败!", zap.Any("memberUIDs", memberUIDs), zap.Error(err)) - c.ResponseError(errors.New("更新成员为管理员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if groupModel.Forbidden == 1 { // 如果是禁言状态,则重置管理员白名单 err = g.setIMWhitelistForGroupManager(groupModel.GroupNo) if err != nil { - c.ResponseError(errors.New("设置白名单失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) g.Error("设置白名单失败!", zap.Error(err)) return } @@ -1615,7 +1641,7 @@ func (g *Group) managerAdd(c *wkhttp.Context) { }) if err != nil { g.Error("发送命令消息失败!", zap.Error(err)) - c.ResponseError(errors.New("发送命令消息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } } else { @@ -1631,7 +1657,7 @@ func (g *Group) managerAdd(c *wkhttp.Context) { }) if err != nil { g.Error("发送命令消息失败!", zap.Error(err)) - c.ResponseError(errors.New("发送命令消息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } } @@ -1645,16 +1671,16 @@ func (g *Group) managerRemove(c *wkhttp.Context) { var memberUIDs []string if err := c.BindJSON(&memberUIDs); err != nil { g.Error("数据格式有误!", zap.Error(err)) - c.ResponseError(errors.New("数据格式有误!")) + respondGroupRequestInvalid(c, "") return } if len(memberUIDs) <= 0 { - c.ResponseError(errors.New("请选择需要添加的成员!")) + respondGroupRequestInvalid(c, "members") return } for _, memberUID := range memberUIDs { if memberUID == loginUID { - c.ResponseError(errors.New("不能将自己移除管理员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupCannotTargetSelf, nil, nil) return } } @@ -1663,37 +1689,38 @@ func (g *Group) managerRemove(c *wkhttp.Context) { isCreator, err := g.db.QueryIsGroupCreator(groupNo, loginUID) if err != nil { g.Error("查询是否是创建者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是创建者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isCreator { - c.ResponseError(errors.New("只有创建者才能设置管理员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOnly, nil, nil) return } groupModel, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.UpdateManagersToMember(groupNo, memberUIDs, version) if err != nil { g.Error("更新成员为管理员失败!", zap.Any("memberUIDs", memberUIDs), zap.Error(err)) - c.ResponseError(errors.New("更新成员为管理员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if groupModel.Forbidden == 1 { // 如果是禁言状态,则重置管理员白名单 err = g.setIMWhitelistForGroupManager(groupModel.GroupNo) if err != nil { - c.ResponseError(errors.New("设置白名单失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) g.Error("设置白名单失败!", zap.Error(err)) return } @@ -1710,7 +1737,7 @@ func (g *Group) managerRemove(c *wkhttp.Context) { }) if err != nil { g.Error("发送命令消息失败!", zap.Error(err)) - c.ResponseError(errors.New("发送命令消息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } } else { @@ -1726,7 +1753,7 @@ func (g *Group) managerRemove(c *wkhttp.Context) { }) if err != nil { g.Error("发送命令消息失败!", zap.Error(err)) - c.ResponseError(errors.New("发送命令消息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } } @@ -1743,16 +1770,16 @@ func (g *Group) groupForbidden(c *wkhttp.Context) { isCreatorOrManager, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("查询是否是创建者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是创建者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isCreatorOrManager { - c.ResponseError(errors.New("只有创建者或管理员才能禁言!")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOrManagerOnly, nil, nil) return } groupModel, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } forbidden, _ := strconv.ParseInt(on, 10, 64) @@ -1762,7 +1789,8 @@ func (g *Group) groupForbidden(c *wkhttp.Context) { if forbidden == 1 { managerOrCreaterUIDs, err := g.db.QueryGroupManagerOrCreatorUIDS(groupNo) if err != nil { - c.ResponseErrorf("查询管理者们的uid失败!", err) + g.Error("查询管理者们的uid失败!", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } whitelistUIDs = managerOrCreaterUIDs @@ -1772,14 +1800,14 @@ func (g *Group) groupForbidden(c *wkhttp.Context) { if err != nil { g.Error("设置禁言失败!", zap.Error(err)) - c.ResponseError(errors.New(err.Error())) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } tx, err := g.ctx.DB().Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -1793,7 +1821,7 @@ func (g *Group) groupForbidden(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("更新群信息失败!", zap.Error(err), zap.String("group_no", groupModel.GroupNo)) - c.ResponseError(errors.New("更新群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } // 发布群信息更新事件 @@ -1813,13 +1841,13 @@ func (g *Group) groupForbidden(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("开启群更新事件失败!", zap.Error(err)) - c.ResponseError(errors.New("开启群更新事件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if err := tx.Commit(); err != nil { tx.RollbackUnlessCommitted() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } g.ctx.EventCommit(eventID) @@ -1860,17 +1888,17 @@ func (g *Group) groupQRCode(c *wkhttp.Context) { groupNo := c.Param("group_no") _, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } exist, err := g.db.ExistMember(loginUID, groupNo) if err != nil { g.Error("查询是否存在群内失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否存在群内失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !exist { - c.ResponseError(errors.New("只有群内用户才能生成二维码!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQRCodeMemberOnly, nil, nil) return } @@ -1881,7 +1909,7 @@ func (g *Group) groupQRCode(c *wkhttp.Context) { })), time.Hour*24*7) if err != nil { g.Error("设置缓存失败!", zap.Error(err)) - c.ResponseError(errors.New("设置缓存失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } baseURL := g.ctx.GetConfig().External.BaseURL @@ -1900,7 +1928,7 @@ func (g *Group) groupQRCode(c *wkhttp.Context) { func (g *Group) groupScanJoin(c *wkhttp.Context) { loginUID := c.GetLoginUID() if loginUID == "" { - c.ResponseError(errors.New("请先登录")) + respondGroupNotLoggedIn(c) return } // GH #1319 / Direction A:零 Space 用户禁止入群。 @@ -1919,118 +1947,119 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { authCode := c.Query("auth_code") groupNo := c.Param("group_no") if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } group, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } if group.Invite == 1 { - c.ResponseError(errors.New("群开启了邀请模式,不能直接加入群聊")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteModeCannotJoin, nil, nil) return } authInfo, err := g.ctx.GetRedisConn().GetString(fmt.Sprintf("%s%s", common.AuthCodeCachePrefix, authCode)) if err != nil { g.Error("获取认证信息数据失败!", zap.Error(err)) - c.ResponseError(errors.New("获取认证信息数据失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if authInfo == "" { - c.ResponseError(errors.New("认证信息不存在或已失效!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } var authMap map[string]interface{} err = util.ReadJsonByByte([]byte(authInfo), &authMap) if err != nil { g.Error("解码认证信息的JSON数据失败!", zap.Error(err)) - c.ResponseError(errors.New("解码认证信息的JSON数据失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } authType, ok := authMap["type"].(string) if !ok { - c.ResponseError(errors.New("无效的授权数据")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } if authType != string(common.AuthCodeTypeJoinGroup) { - c.ResponseError(errors.New("授权码不是入群授权码!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } authGroupNo, ok := authMap["group_no"].(string) if !ok { - c.ResponseError(errors.New("无效的授权数据")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } if authGroupNo != groupNo { - c.ResponseError(errors.New("此授权码非此群的!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } generator, ok := authMap["generator"].(string) if !ok { - c.ResponseError(errors.New("无效的授权数据")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } if strings.TrimSpace(generator) == "" { - c.ResponseError(errors.New("没有二维码生成信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } scaner, ok := authMap["scaner"].(string) if !ok { - c.ResponseError(errors.New("无效的授权数据")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } if strings.TrimSpace(scaner) == "" { - c.ResponseError(errors.New("没有二维码扫码信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } if scaner != loginUID { - c.ResponseError(errors.New("授权码与当前登录用户不匹配")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeUserMismatch, nil, nil) return } existMember, err := g.db.ExistMember(scaner, groupNo) if err != nil { g.Error("查询是否存在群内时失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否存在群内时失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if existMember { - c.ResponseError(errors.New("已经在群内,不能再加入!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAlreadyMember, nil, nil) return } // 查询生成二维码信息 generatorInfo, err := g.userDB.QueryByUID(generator) if err != nil { g.Error("获取生成二维码的用户信息失败!", zap.Error(err)) - c.ResponseError(errors.New("获取生成二维码的用户信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if generatorInfo == nil { - c.ResponseError(errors.New("生成二维码的用户信息不存在!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } // 查询扫码者用户信息 scanerInfo, err := g.userDB.QueryByUID(scaner) if err != nil { g.Error("查询扫码者用户信息失败!", zap.Error(err)) - c.ResponseError(errors.New("查询扫码者用户信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if scanerInfo == nil { - c.ResponseError(errors.New("扫码者信息不存在!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } memberCount, err := g.db.QueryMemberCount(groupNo) if err != nil { g.Error("查询成员数量!", zap.Error(err)) - c.ResponseError(errors.New("查询成员数量!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -2041,13 +2070,13 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { inSpace, checkErr := spacepkg.CheckMembership(g.ctx.DB(), group.SpaceID, scaner) if checkErr != nil { g.Error("检查 Space 成员失败", zap.Error(checkErr)) - c.ResponseError(errors.New("检查成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !inSpace { // 当群禁止外部成员加入时,拒绝跨 Space 扫码入群 if group.AllowExternal == 0 { - c.ResponseError(errors.New("该群已禁止外部成员加入,请联系群管理员")) + httperr.ResponseErrorL(c, errcode.ErrGroupExternalJoinForbidden, nil, nil) return } isExternal = 1 @@ -2102,7 +2131,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { tx, err := g.db.session.Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -2128,7 +2157,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("开启事件事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事件事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } var groupAvatarEventID int64 @@ -2143,7 +2172,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("查询先存成员信息失败!", zap.String("group_no", groupNo), zap.Error(err)) - c.ResponseError(errors.New("查询先存成员信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } members := make([]string, 0, len(oldMembers)+1) @@ -2163,7 +2192,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("开启群成员头像更新事件失败!", zap.Error(err)) - c.ResponseError(errors.New("开启群成员头像更新事件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } @@ -2172,7 +2201,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("查询是否存在删除成员失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否存在删除成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if existDelete { @@ -2183,7 +2212,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("添加群成员失败!", zap.Error(err)) - c.ResponseError(errors.New("添加群成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -2197,7 +2226,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { if updateErr := g.db.UpdateIsExternalGroupTx(groupNo, 1, tx); updateErr != nil { tx.Rollback() g.Error("更新 is_external_group 失败", zap.Error(updateErr), zap.String("group_no", groupNo)) - c.ResponseError(errors.New("更新群外部标记失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } markedExternal = true @@ -2206,7 +2235,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { if err := tx.Commit(); err != nil { tx.Rollback() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -2224,7 +2253,7 @@ func (g *Group) groupScanJoin(c *wkhttp.Context) { // IM 调用失败时记录日志,但不影响已提交的数据库事务 // 后续可通过数据同步机制修复 IM 订阅状态 g.Error("调用IM的订阅接口失败!", zap.Error(err), zap.String("group_no", groupNo), zap.String("scaner", scaner)) - c.ResponseError(errors.New("调用IM的订阅接口失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } @@ -2273,11 +2302,11 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { toUser, err := g.userDB.QueryByUID(toUID) if err != nil { g.Error("查询转让用户失败!", zap.Error(err)) - c.ResponseError(errors.New("查询转让用户失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if toUser == nil || toUser.IsDestroy == user.IsDestroyDone { - c.ResponseError(errors.New("转让用户不存在或已注销!")) + httperr.ResponseErrorL(c, errcode.ErrGroupTransferTargetNotFound, nil, nil) return } @@ -2287,21 +2316,21 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { // exist, err := g.db.ExistMember(toUID, groupNo) // if err != nil { // g.Error("查询是否存在成员失败!", zap.Error(err)) - // c.ResponseError(errors.New("查询是否存在成员失败!")) + // httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) // return // } // if !exist { - // c.ResponseError(errors.New("转让的用户没在群内!")) + // httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) // return // } toMember, err := g.db.QueryMemberWithUID(toUID, groupNo) if err != nil { g.Error("查询是否存在成员失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否存在成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if toMember == nil { - c.ResponseError(errors.New("转让的用户没在群内!")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } // 拦截将群主转让给外部成员 (is_external=1):群主拥有全部敏感操作权限, @@ -2311,7 +2340,7 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { zap.String("group_no", groupNo), zap.String("to_uid", toUID), zap.String("operator", loginUID)) - c.ResponseErrorWithStatus(errors.New("不能将群主转让给外部成员"), http.StatusForbidden) + httperr.ResponseErrorL(c, errcode.ErrGroupExternalCannotBeOwner, nil, nil) return } forbiddenExpirTime := toMember.ForbiddenExpirTime @@ -2321,23 +2350,24 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { isCreator, err := g.db.QueryIsGroupCreator(groupNo, loginUID) if err != nil { g.Error("查询是否是群主失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是群主失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isCreator { - c.ResponseError(errors.New("不是群主,不能转让")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOnly, nil, nil) return } groupModel, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } /** @@ -2346,7 +2376,7 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { tx, err := g.db.session.Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -2369,21 +2399,21 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("开启事件失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.UpdateMemberRoleTx(groupNo, loginUID, MemberRoleCommon, version, tx) if err != nil { tx.Rollback() g.Error("更新成普通成员失败!", zap.Error(err)) - c.ResponseError(errors.New("更新成普通成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.UpdateMemberRoleTx(groupNo, toUID, MemberRoleCreator, version, tx) if err != nil { tx.Rollback() g.Error("更新成创建者失败!", zap.Error(err)) - c.ResponseError(errors.New("更新成创建者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } // 修改普通成员禁言时长 @@ -2391,13 +2421,13 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("修改成员禁言时长失败!", zap.Error(err)) - c.ResponseError(errors.New("修改成员禁言时长失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if err := tx.Commit(); err != nil { tx.Rollback() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } g.ctx.EventCommit(eventID) @@ -2405,7 +2435,7 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { if groupModel.Forbidden == 1 { // 如果是禁言状态,则重置管理员白名单 err = g.setIMWhitelistForGroupManager(groupModel.GroupNo) if err != nil { - c.ResponseError(errors.New("设置白名单失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) g.Error("设置白名单失败!", zap.Error(err)) return } @@ -2421,7 +2451,7 @@ func (g *Group) transferGrouper(c *wkhttp.Context) { UIDs: toUIDs, }) if err != nil { - c.ResponseError(errors.New("新群主添加白名单失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) g.Error("新群主添加白名单失败!", zap.Error(err)) return } @@ -2439,33 +2469,33 @@ func (g *Group) memberUpdate(c *wkhttp.Context) { var memberUpdateMap map[string]interface{} if err := c.BindJSON(&memberUpdateMap); err != nil { g.Error("数据格式有误!", zap.Error(err)) - c.ResponseError(errors.New("数据格式有误!")) + respondGroupRequestInvalid(c, "") return } _, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } isManager, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("查询是否是群管理者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是群管理者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isManager && loginUID != memberUID { g.Error("只有管理员才能修改其他人的成员信息!") - c.ResponseError(errors.New("只有管理员才能修改其他人的成员信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupManagerOnly, nil, nil) return } memberModel, err := g.db.QueryMemberWithUID(memberUID, groupNo) if err != nil { g.Error("查询成员信息失败!", zap.Error(err), zap.String("groupNo", groupNo), zap.String("memberUID", memberUID)) - c.ResponseError(errors.New("查询成员信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if memberModel == nil { - c.ResponseError(errors.New("成员信息不存在!")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } for key, value := range memberUpdateMap { @@ -2473,7 +2503,7 @@ func (g *Group) memberUpdate(c *wkhttp.Context) { case "remark": remark, ok := value.(string) if !ok { - c.ResponseError(errors.New("remark 字段类型错误")) + respondGroupRequestInvalid(c, "remark") return } memberModel.Remark = remark @@ -2481,14 +2511,15 @@ func (g *Group) memberUpdate(c *wkhttp.Context) { } genSeqVal, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } memberModel.Version = genSeqVal err = g.db.UpdateMember(memberModel) if err != nil { g.Error("更新群成员信息失败!", zap.Error(err)) - c.ResponseError(errors.New("更新群成员信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.ctx.SendCMD(config.MsgCMDReq{ @@ -2502,7 +2533,7 @@ func (g *Group) memberUpdate(c *wkhttp.Context) { }) if err != nil { g.Error("发送命令消息失败!", zap.Error(err)) - c.ResponseError(errors.New("发送命令消息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } @@ -2516,11 +2547,11 @@ func (g *Group) memberRemove(c *wkhttp.Context) { var req memberRemoveReq if err := c.BindJSON(&req); err != nil { g.Error(common.ErrData.Error(), zap.Error(err)) - c.ResponseError(common.ErrData) + respondGroupRequestInvalid(c, "") return } if err := req.Check(); err != nil { - c.ResponseError(err) + respondGroupRequestInvalid(c, "") return } groupNo := c.Param("group_no") @@ -2529,7 +2560,7 @@ func (g *Group) memberRemove(c *wkhttp.Context) { // 判断群是否存在 _, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } var loginMember *MemberModel @@ -2539,22 +2570,22 @@ func (g *Group) memberRemove(c *wkhttp.Context) { loginMember, err = g.db.QueryMemberWithUID(operator, groupNo) if err != nil { g.Error("查询操作者群成员信息错误", zap.Error(err)) - c.ResponseError(errors.New("查询操作者群成员信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if loginMember == nil { - c.ResponseError(errors.New("操作者不再此群")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotMember, nil, nil) return } if loginMember.Role != int(common.GroupMemberRoleCreater) && loginMember.Role != int(common.GroupMemberRoleManager) { - c.ResponseError(errors.New("普通成员无法删除群成员")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberCannotRemove, nil, nil) return } } // 验证删除者是否包含自己 for _, uid := range req.Members { if uid == operator { - c.ResponseError(errors.New("不能删除自己")) + httperr.ResponseErrorL(c, errcode.ErrGroupCannotTargetSelf, nil, nil) return } } @@ -2563,21 +2594,21 @@ func (g *Group) memberRemove(c *wkhttp.Context) { deleteMembers, err := g.db.QueryMembersWithUids(req.Members, groupNo) if err != nil { g.Error("查询被删除的群成员信息错误", zap.Error(err)) - c.ResponseError(errors.New("查询被删除的群成员信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if len(deleteMembers) == 0 { - c.ResponseError(errors.New("被删除者不在此群内")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } for _, member := range deleteMembers { if loginMember.Role == int(common.GroupMemberRoleManager) { if member.Role == int(common.GroupMemberRoleManager) { - c.ResponseError(errors.New("管理员不能删除管理员")) + httperr.ResponseErrorL(c, errcode.ErrGroupCannotRemoveAdmin, nil, nil) return } if member.Role == int(common.GroupMemberRoleCreater) { - c.ResponseError(errors.New("管理员不能删除群主")) + httperr.ResponseErrorL(c, errcode.ErrGroupCannotRemoveOwner, nil, nil) return } } @@ -2592,7 +2623,19 @@ func (g *Group) memberRemove(c *wkhttp.Context) { OperatorName: operatorName, }) if err != nil { - c.ResponseError(err) + // 后台管理路径会跳过普通成员的目标预校验;若删除的 UID 全不在群内, + // RemoveGroupMembers 返回业务错误,应是 404 而非存储失败。 + if strings.Contains(err.Error(), "none of the members are in this group") { + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) + return + } + // TOCTOU:getGroupInfo 之后群被解散 → 404,而非内部错误。 + if strings.Contains(err.Error(), "group not found or disbanded") { + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) + return + } + g.Error("移除群成员失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -2608,7 +2651,7 @@ func (g *Group) groupSettingUpdate(c *wkhttp.Context) { var resultMap map[string]interface{} if err := c.BindJSON(&resultMap); err != nil { g.Error("数据格式有误!", zap.Error(err)) - c.ResponseError(common.ErrData) + respondGroupRequestInvalid(c, "") return } if len(resultMap) == 0 { @@ -2617,7 +2660,7 @@ func (g *Group) groupSettingUpdate(c *wkhttp.Context) { } _, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } getSettingFnc := func() (*Setting, bool, error) { @@ -2650,8 +2693,9 @@ func (g *Group) groupSettingUpdate(c *wkhttp.Context) { return nil, err } if group == nil { - g.Error("修改的群不存在", zap.Error(err)) - return nil, errors.New("修改的群不存在") + // 缺失 / 已解散群 → 404,复用 getGroupInfo 的 not-found sentinel, + // 由 respondGroupInfoError 分流,而非塌缩成内部查询失败。 + return nil, errGroupInfoNotFound } return group, nil } @@ -2662,7 +2706,7 @@ func (g *Group) groupSettingUpdate(c *wkhttp.Context) { setting, newSetting, err := getSettingFnc() if err != nil { g.Error("获取设置信息失败!", zap.Error(err)) - c.ResponseError(errors.New("获取设置信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } ctx := &settingContext{ @@ -2674,8 +2718,13 @@ func (g *Group) groupSettingUpdate(c *wkhttp.Context) { } err = settingActionFnc(ctx, value) if err != nil { + // 错类型的设置值是 400 校验错误,不是存储失败。 + if errors.Is(err, errSettingInvalidValueType) { + respondGroupRequestInvalid(c, key) + return + } g.Error("修改群设置信息错误", zap.Error(err)) - c.ResponseError(err) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } continue @@ -2684,8 +2733,8 @@ func (g *Group) groupSettingUpdate(c *wkhttp.Context) { if groupUpdateActionFnc != nil { group, err := getGroupFnc() if err != nil { - g.Error("获取群信息失败!", zap.Error(err)) - c.ResponseError(err) + // 群不存在 → 404,查询失败 → 500;getGroupFnc 已记录 DB 错误。 + respondGroupInfoError(c, err) return } ctx := &groupUpdateContext{ @@ -2696,8 +2745,18 @@ func (g *Group) groupSettingUpdate(c *wkhttp.Context) { } err = groupUpdateActionFnc(ctx, value) if err != nil { + // 非管理员/群主 → 403;错类型 / allow_external 越界 → 400 校验错误。 + // 仅真正的 DB / 事务 / 事件失败才落到 Internal=true 的 store_failed。 + if errors.Is(err, errGroupUpdateForbidden) { + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOrManagerOnly, nil, nil) + return + } + if errors.Is(err, errSettingInvalidValueType) || errors.Is(err, errSettingAllowExternalRange) { + respondGroupRequestInvalid(c, key) + return + } g.Error("修改群设置信息错误", zap.Error(err)) - c.ResponseError(err) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } continue @@ -2713,12 +2772,8 @@ func (g *Group) groupExit(c *wkhttp.Context) { groupNo := c.Param("group_no") groupInfo, err := g.getGroupInfo(groupNo) if err != nil { - g.Error("查询群资料错误", zap.Error(err)) - c.ResponseError(errors.New("查询群资料错误")) - return - } - if groupInfo == nil { - c.ResponseError(errors.New("群不存在")) + // 不存在 / 已解散群 → 404;查询失败 → 500。getGroupInfo 已记录 DB 错误。 + respondGroupInfoError(c, err) return } // 调用IM的移除订阅者 @@ -2729,24 +2784,24 @@ func (g *Group) groupExit(c *wkhttp.Context) { }) if err != nil { g.Error("移除订阅者失败!", zap.Error(err)) - c.ResponseError(errors.New("移除订阅者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } loginMember, err := g.db.QueryMemberWithUID(loginUID, groupNo) if err != nil { g.Error("查询是否存在群成员失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否存在群成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if loginMember == nil { - c.ResponseError(errors.New("群成员不存在群内!")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } // 查询群的管理员和群主 adminAndCreatorUIDS, err := g.db.QueryGroupManagerOrCreatorUIDS(groupNo) if err != nil { g.Error("查询群管理员失败!", zap.Error(err)) - c.ResponseError(errors.New("查询群管理员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } visiblesUids := make([]string, 0) @@ -2768,7 +2823,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { newGrouper, err = g.db.QuerySecondOldestMember(groupNo) if err != nil { g.Error("查询第二元老成员失败!", zap.Error(err)) - c.ResponseError(errors.New("查询第二元老成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } } @@ -2777,14 +2832,15 @@ func (g *Group) groupExit(c *wkhttp.Context) { **/ version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } tx, err := g.db.session.Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -2805,7 +2861,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("开启事件事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事件事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if newGrouper != nil { @@ -2813,7 +2869,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("更换新的群主失败!", zap.Error(err)) - c.ResponseError(errors.New("更换新的群主失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } @@ -2821,7 +2877,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("删除群成员失败!", zap.Error(err)) - c.ResponseError(errors.New("删除群成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -2834,7 +2890,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if cerr != nil { tx.Rollback() g.Error("级联移除 bot 成员失败", zap.Error(cerr)) - c.ResponseError(errors.New("级联移除 bot 成员失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } for _, botUID := range cascadedUIDs { @@ -2853,7 +2909,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if updateErr := g.db.UpdateIsExternalGroupTx(groupNo, 0, tx); updateErr != nil { tx.Rollback() g.Error("更新 is_external_group 失败", zap.Error(updateErr)) - c.ResponseError(errors.New("更新 is_external_group 失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } resetExternalGroup = true @@ -2864,7 +2920,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("查询用户群设置错误", zap.Error(err)) - c.ResponseError(errors.New("查询用户群设置错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if groupSetting != nil && groupSetting.Save == 1 { @@ -2874,7 +2930,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("修改群设置信息错误", zap.Error(err)) - c.ResponseError(errors.New("修改群设置信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } @@ -2886,7 +2942,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { if err := tx.Commit(); err != nil { tx.RollbackUnlessCommitted() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } g.ctx.EventCommit(eventID) @@ -2913,7 +2969,7 @@ func (g *Group) groupExit(c *wkhttp.Context) { }) if err != nil { g.Error("发送群更新命令失败!", zap.Error(err), zap.String("groupNo", groupNo)) - c.ResponseError(errors.New("发送群更新命令失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } var showName = loginMember.Remark @@ -3060,41 +3116,41 @@ func (g *Group) blacklist(c *wkhttp.Context) { var req blacklistReq if err := c.BindJSON(&req); err != nil { g.Error(common.ErrData.Error(), zap.Error(err)) - c.ResponseError(common.ErrData) + respondGroupRequestInvalid(c, "") return } if len(req.Uids) == 0 { - c.ResponseError(errors.New("群成员不能为空")) + respondGroupRequestInvalid(c, "members") return } if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } if action == "" { - c.ResponseError(errors.New("操作类型不能为空")) + respondGroupRequestInvalid(c, "action") return } group, err := g.db.QueryDetailWithGroupNo(groupNo, loginUID) if err != nil { g.Error("查询群详情错误", zap.Error(err)) - c.ResponseError(errors.New("查询群详情错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if group == nil || group.Status == GroupStatusDisband { g.Error("群不存在", zap.Error(err)) - c.ResponseError(errors.New("群不存在")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) return } // 查询是否是管理者 isManager, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("查询是否是群管理者失败!", zap.Error(err)) - c.ResponseError(errors.New("查询是否是群管理者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isManager { - c.ResponseError(errors.New("只有群管理者才能修改!")) + httperr.ResponseErrorL(c, errcode.ErrGroupManagerOnly, nil, nil) return } status := 0 @@ -3106,31 +3162,32 @@ func (g *Group) blacklist(c *wkhttp.Context) { version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.updateMembersStatus(version, groupNo, status, req.Uids) if err != nil { g.Error("添加或移除群成员黑名单错误", zap.Error(err)) - c.ResponseError(errors.New("添加或移除群成员黑名单错误!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if status == int(common.GroupMemberStatusBlacklist) { err = g.setGroupBlacklist(groupNo, req.Uids, status == int(common.GroupMemberStatusBlacklist)) if err != nil { g.Error("添加IM黑名单错误", zap.Error(err)) - c.ResponseError(errors.New("添加IM黑名单错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } else { members, err := g.db.QueryMembersWithUids(req.Uids, groupNo) if err != nil { g.Error("查询移除黑名单成员错误", zap.Error(err)) - c.ResponseError(errors.New("查询移除黑名单成员错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if len(members) == 0 { - c.ResponseError(errors.New("移除成员不存在")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } removeUIDs := make([]string, 0) @@ -3143,7 +3200,7 @@ func (g *Group) blacklist(c *wkhttp.Context) { err = g.setGroupBlacklist(groupNo, removeUIDs, false) if err != nil { g.Error("移除IM黑名单错误", zap.Error(err)) - c.ResponseError(errors.New("移除IM黑名单错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } @@ -3160,7 +3217,7 @@ func (g *Group) blacklist(c *wkhttp.Context) { }) if err != nil { g.Error("发送更新群成员消息错误", zap.Error(err)) - c.ResponseError(errors.New("发送更新群成员消息错误!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } } else { @@ -3177,7 +3234,7 @@ func (g *Group) blacklist(c *wkhttp.Context) { }) if err != nil { g.Error("发送更新群成员消息错误", zap.Error(err)) - c.ResponseError(errors.New("发送更新群成员消息错误!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } } @@ -3230,56 +3287,57 @@ func (g *Group) forbiddenWithGroupMember(c *wkhttp.Context) { var req forbiddenWithGroupMemberReq if err := c.BindJSON(&req); err != nil { g.Error("数据格式有误!", zap.Error(err)) - c.ResponseError(errors.New("数据格式有误!")) + respondGroupRequestInvalid(c, "") return } loginUID := c.GetLoginUID() groupNo := c.Param("group_no") if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } if req.MemberUID == "" { - c.ResponseError(errors.New("群成员ID不能为空")) + respondGroupRequestInvalid(c, "member_uid") return } if req.Action != 0 && req.Action != 1 { - c.ResponseError(errors.New("操作类型错误")) + respondGroupRequestInvalid(c, "action") return } group, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } loginGroupMember, err := g.db.QueryMemberWithUID(loginUID, group.GroupNo) if err != nil { g.Error("查询登录用户群内信息错误", zap.Error(err)) - c.ResponseError(errors.New("查询登录用户群内信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if loginGroupMember == nil { - c.ResponseError(errors.New("登录用户不在本群内无法操作")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotMember, nil, nil) return } member, err := g.db.QueryMemberWithUID(req.MemberUID, group.GroupNo) if err != nil { g.Error("查询成员信息错误", zap.Error(err)) - c.ResponseError(errors.New("查询成员信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if member == nil { - c.ResponseError(errors.New("该成员不在群内")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } if loginGroupMember.Role == MemberRoleCommon || member.Role == MemberRoleCreator || loginGroupMember.Role == member.Role { - c.ResponseError(errors.New("操作用户权限不够")) + respondGroupForbidden(c) return } genSeqVal, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { - c.ResponseError(err) + g.Error("生成序列号失败", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } member.Version = genSeqVal @@ -3289,7 +3347,7 @@ func (g *Group) forbiddenWithGroupMember(c *wkhttp.Context) { err := g.db.UpdateMember(member) if err != nil { g.Error("解除用户禁言错误", zap.Error(err)) - c.ResponseError(errors.New("解除用户禁言错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } else { @@ -3311,14 +3369,14 @@ func (g *Group) forbiddenWithGroupMember(c *wkhttp.Context) { expirationTime = 0 } if expirationTime == 0 { - c.ResponseError(errors.New("禁言成员时长参数错误")) + respondGroupRequestInvalid(c, "key") return } member.ForbiddenExpirTime = expirationTime err = g.db.UpdateMember(member) if err != nil { g.Error("禁言用户错误", zap.Error(err)) - c.ResponseError(errors.New("禁言用户错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } @@ -3328,7 +3386,7 @@ func (g *Group) forbiddenWithGroupMember(c *wkhttp.Context) { uids = append(uids, req.MemberUID) err = g.setGroupBlacklist(groupNo, uids, req.Action == 1) if err != nil { - c.ResponseError(errors.New("设置IM黑名单错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.ctx.SendCMD(config.MsgCMDReq{ @@ -3342,7 +3400,7 @@ func (g *Group) forbiddenWithGroupMember(c *wkhttp.Context) { }) if err != nil { g.Error("发送命令消息失败!", zap.Error(err)) - c.ResponseError(errors.New("发送命令消息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } c.ResponseOK() @@ -3429,10 +3487,10 @@ func (g *Group) getGroupInfo(groupNo string) (*Model, error) { group, err := g.db.QueryWithGroupNo(groupNo) if err != nil { g.Error("查询群资料错误", zap.Error(err)) - return nil, errors.New("查询群资料错误") + return nil, errGroupInfoQueryFailed } if group == nil || group.Status == GroupStatusDisband { - return nil, errors.New("群不存在") + return nil, errGroupInfoNotFound } return group, nil } @@ -3461,18 +3519,18 @@ func (g *Group) groupMdGet(c *wkhttp.Context) { isMember, err := g.db.ExistMember(loginUID, groupNo) if err != nil { g.Error("check group member failed", zap.Error(err)) - c.ResponseError(errors.New("check group member failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isMember { - c.ResponseError(errors.New("no permission")) + httperr.ResponseErrorL(c, errcode.ErrGroupViewForbidden, nil, nil) return } result, err := g.db.QueryGroupMd(groupNo) if err != nil { g.Error("query GROUP.md failed", zap.Error(err)) - c.ResponseError(errors.New("query GROUP.md failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if result == nil { @@ -3500,11 +3558,11 @@ func (g *Group) groupMdUpdate(c *wkhttp.Context) { isManagerOrCreator, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("check permission failed", zap.Error(err)) - c.ResponseError(errors.New("check permission failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isManagerOrCreator { - c.ResponseError(errors.New("only creator or manager can edit GROUP.md")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOrManagerOnly, nil, nil) return } @@ -3512,20 +3570,20 @@ func (g *Group) groupMdUpdate(c *wkhttp.Context) { Content string `json:"content"` } if err := c.BindJSON(&req); err != nil { - c.ResponseError(errors.New("invalid request body")) + respondGroupRequestInvalid(c, "") return } maxSize := getGroupMdMaxSize() if len(req.Content) > maxSize { - c.ResponseError(fmt.Errorf("GROUP.md content exceeds max size %d bytes", maxSize)) + respondGroupMdContentTooLarge(c, maxSize) return } newVersion, err := g.db.UpdateGroupMd(groupNo, req.Content, loginUID) if err != nil { g.Error("update GROUP.md failed", zap.Error(err)) - c.ResponseError(errors.New("update GROUP.md failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -3552,18 +3610,18 @@ func (g *Group) groupMdDelete(c *wkhttp.Context) { isManagerOrCreator, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("check permission failed", zap.Error(err)) - c.ResponseError(errors.New("check permission failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isManagerOrCreator { - c.ResponseError(errors.New("only creator or manager can delete GROUP.md")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOrManagerOnly, nil, nil) return } newVersion, err := g.db.DeleteGroupMd(groupNo) if err != nil { g.Error("delete GROUP.md failed", zap.Error(err)) - c.ResponseError(errors.New("delete GROUP.md failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -3589,11 +3647,11 @@ func (g *Group) botAdminSet(c *wkhttp.Context) { isManagerOrCreator, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("check permission failed", zap.Error(err)) - c.ResponseError(errors.New("check permission failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isManagerOrCreator { - c.ResponseError(errors.New("only creator or manager can set bot admin")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOrManagerOnly, nil, nil) return } @@ -3601,29 +3659,29 @@ func (g *Group) botAdminSet(c *wkhttp.Context) { member, err := g.db.QueryMemberWithUID(targetUID, groupNo) if err != nil { g.Error("query member failed", zap.Error(err)) - c.ResponseError(errors.New("query member failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if member == nil { - c.ResponseError(errors.New("member not found in group")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } if member.Robot != 1 { - c.ResponseError(errors.New("target member is not a bot")) + httperr.ResponseErrorL(c, errcode.ErrGroupTargetNotBot, nil, nil) return } version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { g.Error("GenSeq failed", zap.Error(err)) - c.ResponseError(errors.New("generate version failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.UpdateBotAdmin(groupNo, targetUID, 1, version) if err != nil { g.Error("set bot admin failed", zap.Error(err)) - c.ResponseError(errors.New("set bot admin failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } c.ResponseOK() @@ -3638,11 +3696,11 @@ func (g *Group) botAdminRemove(c *wkhttp.Context) { isManagerOrCreator, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("check permission failed", zap.Error(err)) - c.ResponseError(errors.New("check permission failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !isManagerOrCreator { - c.ResponseError(errors.New("only creator or manager can remove bot admin")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOrManagerOnly, nil, nil) return } @@ -3650,25 +3708,25 @@ func (g *Group) botAdminRemove(c *wkhttp.Context) { member, err := g.db.QueryMemberWithUID(targetUID, groupNo) if err != nil { g.Error("query member failed", zap.Error(err)) - c.ResponseError(errors.New("query member failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if member == nil { - c.ResponseError(errors.New("member not found in group")) + httperr.ResponseErrorL(c, errcode.ErrGroupMemberNotInGroup, nil, nil) return } version, err := g.ctx.GenSeq(common.GroupMemberSeqKey) if err != nil { g.Error("GenSeq failed", zap.Error(err)) - c.ResponseError(errors.New("generate version failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.UpdateBotAdmin(groupNo, targetUID, 0, version) if err != nil { g.Error("remove bot admin failed", zap.Error(err)) - c.ResponseError(errors.New("remove bot admin failed")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } c.ResponseOK() @@ -4035,7 +4093,7 @@ func (g *Group) groupInvitePage(c *wkhttp.Context) { htmlBytes, err := os.ReadFile("./assets/web/group_invite.html") if err != nil { g.Error("加载群邀请落地页失败", zap.Error(err)) - c.ResponseError(errors.New("页面加载失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } safeBaseURL := strconv.Quote(g.ctx.GetConfig().External.BaseURL) @@ -4052,7 +4110,7 @@ func (g *Group) groupInvitePage(c *wkhttp.Context) { func (g *Group) groupInviteDetail(c *wkhttp.Context) { code := strings.TrimSpace(c.Query("code")) if code == "" { - c.ResponseError(errors.New("邀请码不能为空")) + respondGroupRequestInvalid(c, "code") return } @@ -4060,7 +4118,7 @@ func (g *Group) groupInviteDetail(c *wkhttp.Context) { qrcodeContent, err := g.ctx.GetRedisConn().GetString(fmt.Sprintf("%s%s", common.QRCodeCachePrefix, code)) if err != nil { g.Error("获取邀请码缓存失败", zap.Error(err), zap.String("code", code)) - c.ResponseError(errors.New("获取邀请码信息失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if qrcodeContent == "" { @@ -4088,7 +4146,7 @@ func (g *Group) groupInviteDetail(c *wkhttp.Context) { groupModel, err := g.db.QueryWithGroupNo(groupNo) if err != nil { g.Error("查询群资料失败", zap.Error(err), zap.String("group_no", groupNo)) - c.ResponseError(errors.New("查询群资料失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if groupModel == nil || groupModel.Status == GroupStatusDisband { @@ -4099,7 +4157,7 @@ func (g *Group) groupInviteDetail(c *wkhttp.Context) { memberCount, err := g.db.QueryMemberCount(groupNo) if err != nil { g.Error("查询群成员数失败", zap.Error(err), zap.String("group_no", groupNo)) - c.ResponseError(errors.New("查询群成员数失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } @@ -4206,7 +4264,7 @@ func (g *Group) groupInviteDetail(c *wkhttp.Context) { func (g *Group) groupInviteAuthorize(c *wkhttp.Context) { loginUID := c.GetLoginUID() if loginUID == "" { - c.ResponseError(errors.New("请先登录")) + respondGroupNotLoggedIn(c) return } // GH #1319 / Direction A:零 Space 用户禁止入群。 @@ -4223,50 +4281,50 @@ func (g *Group) groupInviteAuthorize(c *wkhttp.Context) { } code := strings.TrimSpace(c.Query("code")) if code == "" { - c.ResponseError(errors.New("邀请码不能为空")) + respondGroupRequestInvalid(c, "code") return } qrcodeContent, err := g.ctx.GetRedisConn().GetString(fmt.Sprintf("%s%s", common.QRCodeCachePrefix, code)) if err != nil { g.Error("获取邀请码缓存失败", zap.Error(err), zap.String("code", code)) - c.ResponseError(errors.New("获取邀请码信息失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if qrcodeContent == "" { - c.ResponseError(errors.New("邀请链接已过期")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteExpired, nil, nil) return } var qrCodeModel common.QRCodeModel if err := util.ReadJsonByByte([]byte(qrcodeContent), &qrCodeModel); err != nil { g.Error("解析邀请码缓存失败", zap.Error(err), zap.String("code", code)) - c.ResponseError(errors.New("邀请链接已过期")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteExpired, nil, nil) return } if qrCodeModel.Type != common.QRCodeTypeGroup { - c.ResponseError(errors.New("邀请链接已过期")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteExpired, nil, nil) return } groupNo, _ := qrCodeModel.Data["group_no"].(string) if groupNo == "" { - c.ResponseError(errors.New("邀请链接已过期")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteExpired, nil, nil) return } generator, _ := qrCodeModel.Data["generator"].(string) if strings.TrimSpace(generator) == "" { - c.ResponseError(errors.New("邀请链接已过期")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteExpired, nil, nil) return } groupModel, err := g.db.QueryWithGroupNo(groupNo) if err != nil { g.Error("查询群资料失败", zap.Error(err), zap.String("group_no", groupNo)) - c.ResponseError(errors.New("查询群资料失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if groupModel == nil || groupModel.Status == GroupStatusDisband { - c.ResponseError(errors.New("群不存在或已解散")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) return } // 群属于某 Space 且 allow_external=0:提前拦截,和 handleJoinGroup 的预检保持一致。 @@ -4278,7 +4336,7 @@ func (g *Group) groupInviteAuthorize(c *wkhttp.Context) { inSpace, checkErr := spacepkg.CheckMembership(g.ctx.DB(), groupModel.SpaceID, loginUID) if checkErr != nil { g.Error("检查 Space 成员失败", zap.Error(checkErr), zap.String("group_no", groupNo)) - c.ResponseError(errors.New("检查成员关系失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !inSpace { @@ -4299,7 +4357,7 @@ func (g *Group) groupInviteAuthorize(c *wkhttp.Context) { existMember, err := g.db.ExistMember(loginUID, groupNo) if err != nil { g.Error("查询群成员失败", zap.Error(err), zap.String("group_no", groupNo)) - c.ResponseError(errors.New("查询群成员失败,请稍后重试")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if existMember { @@ -4312,7 +4370,7 @@ func (g *Group) groupInviteAuthorize(c *wkhttp.Context) { if groupModel.Invite == 1 { // 与 groupScanJoin 保持一致:开启邀请审批的群不支持直接扫码入群, // 也不在 H5 落地页生成 auth_code(避免后续 scanjoin 失败时的语义含糊)。 - c.ResponseError(errors.New("群开启了邀请模式,不能直接加入群聊")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteModeCannotJoin, nil, nil) return } @@ -4325,7 +4383,7 @@ func (g *Group) groupInviteAuthorize(c *wkhttp.Context) { }), time.Minute*30) if err != nil { g.Error("生成入群授权码失败", zap.Error(err), zap.String("group_no", groupNo)) - c.ResponseError(errors.New("生成入群授权码失败,请稍后重试")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } c.Response(gin.H{ diff --git a/modules/group/api_bot_ownership_test.go b/modules/group/api_bot_ownership_test.go index 0c39628e..c7243452 100644 --- a/modules/group/api_bot_ownership_test.go +++ b/modules/group/api_bot_ownership_test.go @@ -81,6 +81,7 @@ func setupBotOwnershipGroup(t *testing.T) (*Group, http.Handler) { Vercode: fmt.Sprintf("%s@1", util.GenerUUID()), }) assert.NoError(t, err) + wireI18nRendererForGroupTest(s) return f, s.GetRoute() } @@ -124,9 +125,11 @@ func TestGroupMemberAdd_BotOwnedByOther(t *testing.T) { insertBotUser(t, f, "yutestspacebot1_bot", "YuTestSpaceBot1", "yts1_bot_sn", "user_a") w := postAddMembers(t, h, "g_bot_own", []string{"yutestspacebot1_bot"}) - assert.Equal(t, http.StatusForbidden, w.Code, "user-c 邀请别人的 bot 应返回 403, got body=%s", w.Body.String()) - assert.True(t, strings.Contains(w.Body.String(), "no permission to invite this bot"), - "响应体应包含明确的权限错误信息, got=%s", w.Body.String()) + // D14: wire status is fixed at 400 during the compat window; the 403 + // semantics now live in error.http_status / error.code. + assert.Equal(t, http.StatusBadRequest, w.Code, "user-c 邀请别人的 bot 应返回 400 信封, got body=%s", w.Body.String()) + assert.True(t, strings.Contains(w.Body.String(), "err.server.group.bot_ownership_denied"), + "响应体应包含 bot 归属错误码, got=%s", w.Body.String()) exist, err := f.db.ExistMember("yutestspacebot1_bot", "g_bot_own") assert.NoError(t, err) @@ -144,7 +147,8 @@ func TestGroupMemberAdd_BotThirdPartyNoCreator(t *testing.T) { insertBotUser(t, f, "ppt_bot", "PPT Bot", "ppt_bot_sn", "") w := postAddMembers(t, h, "g_bot_own", []string{"ppt_bot"}) - assert.Equal(t, http.StatusForbidden, w.Code, "第三方 bot 应返回 403, got body=%s", w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code, "第三方 bot 应返回 400 信封, got body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.bot_ownership_denied", "got=%s", w.Body.String()) exist, err := f.db.ExistMember("ppt_bot", "g_bot_own") assert.NoError(t, err) @@ -164,7 +168,8 @@ func TestGroupMemberAdd_BotOwnershipBlocksMixedBatch(t *testing.T) { insertBotUser(t, f, "spacebottest1_bot", "SpaceBotTest1", "sbt1_bot_sn", "user_b") w := postAddMembers(t, h, "g_bot_own", []string{"human_friend", "spacebottest1_bot"}) - assert.Equal(t, http.StatusForbidden, w.Code, "mixed batch with foreign bot 应返回 403, got body=%s", w.Body.String()) + assert.Equal(t, http.StatusBadRequest, w.Code, "mixed batch with foreign bot 应返回 400 信封, got body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.bot_ownership_denied", "got=%s", w.Body.String()) // 混合批次被拒时,所有成员都不应被写入(避免半提交) exist, err := f.db.ExistMember("spacebottest1_bot", "g_bot_own") diff --git a/modules/group/api_i18n.go b/modules/group/api_i18n.go new file mode 100644 index 00000000..20e0ee8e --- /dev/null +++ b/modules/group/api_i18n.go @@ -0,0 +1,95 @@ +package group + +import ( + "errors" + + "github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp" + "github.com/Mininglamp-OSS/octo-server/pkg/errcode" + "github.com/Mininglamp-OSS/octo-server/pkg/httperr" + "github.com/Mininglamp-OSS/octo-server/pkg/i18n" + "github.com/Mininglamp-OSS/octo-server/pkg/i18n/codes" +) + +// respond helpers for modules/group. Most migrated sites call +// httperr.ResponseErrorL(c, errcode.ErrGroupXxx, nil, nil) directly; the +// helpers below exist only for the high-frequency shapes that either carry a +// Detail field (so the SafeDetailKeys contract stays in one place) or resolve a +// shared err.shared.* code at init. +// +// Internal=true codes (ErrGroupQueryFailed / ErrGroupStoreFailed / +// ErrGroupNotifyFailed) are intentionally NOT wrapped: each call site keeps its +// existing g.Error(..., zap.Error(err)) log so ops can debug from logs, and the +// wire response carries no message. + +// respondGroupRequestInvalid covers the common "X 不能为空" / "数据格式有误" / +// BindJSON-failure shape — one code, one optional field detail. An empty field +// is omitted so the renderer does not surface a noisy empty key to clients. +func respondGroupRequestInvalid(c *wkhttp.Context, field string) { + details := i18n.Details{} + if field != "" { + details["field"] = field + } + httperr.ResponseErrorL(c, errcode.ErrGroupRequestInvalid, nil, details) +} + +// respondGroupMdContentTooLarge surfaces the GROUP.md size cap so the client can +// render a localized hint without hard-coding the limit. +func respondGroupMdContentTooLarge(c *wkhttp.Context, maxSize int) { + httperr.ResponseErrorL(c, errcode.ErrGroupMdContentTooLarge, nil, i18n.Details{ + "field": "content", + "max_size": maxSize, + }) +} + +// errSharedAuthRequired / errSharedForbidden cache the shared auth codes so the +// per-handler login / permission guards do not pay a registry lookup on every +// miss. Looked up at package init; a missing registration panics loudly rather +// than silently rendering an empty envelope at request time. +var ( + errSharedAuthRequired = mustLookupSharedCode("err.shared.auth.required") + errSharedForbidden = mustLookupSharedCode("err.shared.auth.forbidden") +) + +func mustLookupSharedCode(id string) codes.Code { + c, ok := codes.Lookup(id) + if !ok { + panic("modules/group: shared code not registered: " + id) + } + return c +} + +// respondGroupNotLoggedIn renders the shared 401 envelope for the public routes +// (group scan-join / invite authorize) whose legacy "请先登录" guard runs before +// AuthMiddleware would reject the request. +func respondGroupNotLoggedIn(c *wkhttp.Context) { + httperr.ResponseErrorL(c, errSharedAuthRequired, nil, nil) +} + +// respondGroupForbidden renders the shared 403 envelope for the generic +// "用户无权执行此操作" / "操作用户权限不够" guards that carry no role-specific +// hint. Role-specific gates use the dedicated err.server.group.creator_only / +// manager_only / creator_or_manager_only codes instead. +func respondGroupForbidden(c *wkhttp.Context) { + httperr.ResponseErrorL(c, errSharedForbidden, nil, nil) +} + +// errGroupInfoQueryFailed / errGroupInfoNotFound are getGroupInfo's sentinel +// returns. Call sites map them to the right envelope via errors.Is +// (respondGroupInfoError) instead of leaking the underlying Chinese string +// behind a fixed HTTP 400. +var ( + errGroupInfoQueryFailed = errors.New("query group failed") + errGroupInfoNotFound = errors.New("group not found or disbanded") +) + +// respondGroupInfoError maps getGroupInfo's sentinel error to the localized +// envelope: a missing / disbanded group is 404, any other (query) failure is +// 500 (Internal). getGroupInfo already logged the underlying DB error, so the +// query branch does not log again. +func respondGroupInfoError(c *wkhttp.Context, err error) { + if errors.Is(err, errGroupInfoNotFound) { + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) + return + } + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) +} diff --git a/modules/group/api_i18n_regression_test.go b/modules/group/api_i18n_regression_test.go new file mode 100644 index 00000000..9b344c8c --- /dev/null +++ b/modules/group/api_i18n_regression_test.go @@ -0,0 +1,199 @@ +package group + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/Mininglamp-OSS/octo-lib/pkg/util" + "github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp" + "github.com/Mininglamp-OSS/octo-lib/testutil" + "github.com/Mininglamp-OSS/octo-server/modules/user" + "github.com/stretchr/testify/assert" +) + +// putGroupSetting issues a PUT /setting with the given JSON body using the test +// caller's token. +func putGroupSetting(t *testing.T, handler http.Handler, groupNo, body string) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + req, err := http.NewRequest("PUT", "/v1/groups/"+groupNo+"/setting", bytes.NewReader([]byte(body))) + assert.NoError(t, err) + req.Header.Set("token", testutil.Token) + handler.ServeHTTP(w, req) + return w +} + +// TestGroupExit_NotFoundGroup pins the fix for the review finding that +// groupExit returned 500 (query_failed) for a missing / disbanded group +// because it ignored getGroupInfo's not-found sentinel. The exit of a +// non-existent group is a user-facing 404, not an internal error. +func TestGroupExit_NotFoundGroup(t *testing.T) { + s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) + _ = New(ctx) + + err := testutil.CleanAllTables(ctx) + assert.NoError(t, err) + + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/v1/groups/does-not-exist/exit", nil) + assert.NoError(t, err) + req.Header.Set("token", testutil.Token) + s.GetRoute().ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.not_found", + "退不存在的群应是 404 业务错误而非内部错误, body=%s", w.Body.String()) +} + +// TestGroupMemberInviteSure_ExpiredCode pins the fix for the review finding +// that an expired / missing auth_code (Redis returns "") fell through to a +// JSON-decode failure mapped to store_failed (500). An expired authorization +// code is a normal user-facing state and must surface as auth_code_invalid. +func TestGroupMemberInviteSure_ExpiredCode(t *testing.T) { + s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) + _ = New(ctx) + + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/v1/group/invite/sure?auth_code=expired-"+util.GenerUUID(), nil) + assert.NoError(t, err) + s.GetRoute().ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.auth_code_invalid", + "过期/无效 auth_code 应是用户态错误而非内部错误, body=%s", w.Body.String()) +} + +// TestGroupMemberAdd_BlankMembersIsRequestInvalid pins the fix for the review +// finding that members consisting solely of blank strings pass Check() but +// AddGroupMembers returns "no valid members after deduplication" — a 400 +// validation error, not the store_failed (500) it was being mapped to. +func TestGroupMemberAdd_BlankMembersIsRequestInvalid(t *testing.T) { + f, h := setupBotOwnershipGroup(t) + _ = f + + w := postAddMembers(t, h, "g_bot_own", []string{" "}) + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.request_invalid", + "全空白成员应是 400 校验错误而非内部错误, body=%s", w.Body.String()) +} + +// TestManagerMemberRemove_NotInGroupIsNotFound pins the fix for the review +// finding that the management (CheckLoginRole==nil) delete path skips the +// per-member pre-check, so removing UIDs that are not in the group made +// RemoveGroupMembers return "none of the members are in this group" — a 404 +// business error, not the store_failed (500) it was being mapped to. +func TestManagerMemberRemove_NotInGroupIsNotFound(t *testing.T) { + s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) + f := New(ctx) + + err := testutil.CleanAllTables(ctx) + assert.NoError(t, err) + + // Promote the test caller to SuperAdmin so memberRemove takes the + // management path that skips the normal-member pre-check. + cfg := ctx.GetConfig() + assert.NoError(t, ctx.Cache().Set( + cfg.Cache.TokenCachePrefix+testutil.Token, + testutil.UID+"@test@"+string(wkhttp.SuperAdmin), + )) + + groupNo := "g-ghost-rm" + err = f.userDB.Insert(&user.Model{UID: testutil.UID, Name: "admin", ShortNo: "ghost_admin"}) + assert.NoError(t, err) + err = f.db.Insert(&Model{GroupNo: groupNo, Name: "ghost rm", Creator: testutil.UID, Status: GroupStatusNormal, Version: 1}) + assert.NoError(t, err) + + body := util.ToJson(map[string]any{"members": []string{"ghost-not-in-group"}}) + w := httptest.NewRecorder() + req, err := http.NewRequest("DELETE", "/v1/groups/"+groupNo+"/members", bytes.NewReader([]byte(body))) + assert.NoError(t, err) + req.Header.Set("token", testutil.Token) + s.GetRoute().ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.member_not_in_group", + "删除非群成员应是 404 业务错误而非内部错误, body=%s", w.Body.String()) +} + +// TestGroupSettingUpdate_InvalidValueTypeIsRequestInvalid pins the fix for the +// review finding that a wrong-typed setting value (e.g. a string for the +// numeric "mute" toggle) returned by settingActionMap was collapsed into +// store_failed (500). A malformed value is a 400 client error. +func TestGroupSettingUpdate_InvalidValueTypeIsRequestInvalid(t *testing.T) { + _, h := setupBotOwnershipGroup(t) + + // "mute" expects a number; sending a string trips safeIntFromFloat64. + w := putGroupSetting(t, h, "g_bot_own", `{"mute":"not-a-number"}`) + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.request_invalid", + "错类型的设置值应是 400 校验错误而非内部错误, body=%s", w.Body.String()) +} + +// TestGroupSettingUpdate_AllowExternalRangeIsRequestInvalid pins the fix for the +// review finding that an out-of-range allow_external value returned by the +// group-attr action was collapsed into store_failed (500). The test caller is +// the creator, so checkPermissions passes and the range check is what rejects. +func TestGroupSettingUpdate_AllowExternalRangeIsRequestInvalid(t *testing.T) { + _, h := setupBotOwnershipGroup(t) + + w := putGroupSetting(t, h, "g_bot_own", `{"allow_external":2}`) + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.request_invalid", + "allow_external 越界应是 400 校验错误而非内部错误, body=%s", w.Body.String()) +} + +// TestGroupSettingUpdate_NonManagerForbidden pins the fix for the review finding +// that a non-manager/creator toggling a group-level attribute had +// checkPermissions's "没有权限!" collapsed into store_failed (500). Updating a +// group attribute without permission is a 403, not an internal error. +func TestGroupSettingUpdate_NonManagerForbidden(t *testing.T) { + s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) + f := New(ctx) + + err := testutil.CleanAllTables(ctx) + assert.NoError(t, err) + + // Group is owned by someone else; the test caller (testutil.UID) is neither + // creator nor manager, so checkPermissions returns errGroupUpdateForbidden. + groupNo := "g-perm-deny" + err = f.db.Insert(&Model{GroupNo: groupNo, Name: "perm deny", Creator: "other-owner", Status: GroupStatusNormal, Version: 1}) + assert.NoError(t, err) + + w := putGroupSetting(t, s.GetRoute(), groupNo, `{"forbidden":1}`) + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.creator_or_manager_only", + "非管理员改群属性应是 403 而非内部错误, body=%s", w.Body.String()) +} + +// TestGroupAvatarUpload_MissingFileIsRequestInvalid pins the fix for the review +// finding that avatarUpload mapped a missing multipart "file" field +// (http.ErrMissingFile) to store_failed (500). Forgetting to attach the file is +// a 400 client mistake, mirroring the sibling ParseMultipartForm branch. +func TestGroupAvatarUpload_MissingFileIsRequestInvalid(t *testing.T) { + _, handler := setupBotOwnershipGroup(t) + + // Build a valid multipart body that carries a field but no "file", so + // ParseMultipartForm succeeds and FormFile("file") returns ErrMissingFile. + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + assert.NoError(t, mw.WriteField("other", "x")) + assert.NoError(t, mw.Close()) + + w := httptest.NewRecorder() + req, err := http.NewRequest("POST", "/v1/groups/g_bot_own/avatar", &buf) + assert.NoError(t, err) + req.Header.Set("token", testutil.Token) + req.Header.Set("Content-Type", mw.FormDataContentType()) + handler.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, "wire status 固定 400, body=%s", w.Body.String()) + assert.Contains(t, w.Body.String(), "err.server.group.request_invalid", + "缺少上传文件应是 400 校验错误而非内部错误, body=%s", w.Body.String()) +} diff --git a/modules/group/api_i18n_test.go b/modules/group/api_i18n_test.go new file mode 100644 index 00000000..80d00ea2 --- /dev/null +++ b/modules/group/api_i18n_test.go @@ -0,0 +1,286 @@ +package group + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp" + "github.com/Mininglamp-OSS/octo-lib/server" + "github.com/Mininglamp-OSS/octo-server/pkg/errcode" + "github.com/Mininglamp-OSS/octo-server/pkg/httperr" + "github.com/Mininglamp-OSS/octo-server/pkg/i18n" + "github.com/Mininglamp-OSS/octo-server/pkg/i18n/codes" +) + +// httperrL is a terse test shim for the no-params/no-details ResponseErrorL +// call shape exercised by the direct-code cases below. +func httperrL(c *wkhttp.Context, code codes.Code) { + httperr.ResponseErrorL(c, code, nil, nil) +} + +// TestGroupNoLegacyResponseError pins the Phase 2.1 contract that the migrated +// modules/group handlers do not regress to legacy octo-lib error responses. +// Comments are stripped first so commented-out breadcrumbs do not trip the +// guard. The c.ResponseError(common.ErrData.Error(), ...) zap LOG calls are not +// responses and are intentionally allowed (they match neither banned token). +func TestGroupNoLegacyResponseError(t *testing.T) { + files := []string{"api.go", "api_manager.go", "invite.go"} + banned := []string{".ResponseError(", ".ResponseErrorf(", ".ResponseErrorWithStatus(", "c.Response(\""} + for _, f := range files { + t.Run(f, func(t *testing.T) { + data, err := os.ReadFile(f) + if err != nil { + t.Fatalf("read %s: %v", f, err) + } + var clean strings.Builder + for _, line := range strings.Split(string(data), "\n") { + if idx := strings.Index(line, "//"); idx >= 0 { + line = line[:idx] + } + clean.WriteString(line) + clean.WriteByte('\n') + } + cleaned := clean.String() + for _, b := range banned { + if strings.Contains(cleaned, b) { + t.Fatalf("modules/group/%s must use httperr.ResponseErrorL via respondGroup* helpers / errcode.ErrGroup* instead of legacy %s", f, b) + } + } + }) + } +} + +// wireI18nRendererForGroupTest injects the i18n ErrorRenderer onto the route +// returned by testutil.NewTestServer, mirroring what main.go does at boot. +// Post-Phase-2.1, modules/group handlers respond via httperr.ResponseErrorL → +// c.RenderError; without a renderer wired the route falls back to the legacy +// {msg,status} envelope carrying the English source DefaultMessage instead of +// the localized zh-CN copy production clients receive. testutil.NewTestServer +// lives in octo-lib and is intentionally not touched from this PR. +func wireI18nRendererForGroupTest(s *server.Server) { + s.GetRoute().SetErrorRenderer(i18n.NewErrorRenderer(i18n.NewLocalizer(i18n.DefaultLanguage))) +} + +// envelope is the partial shape of an httperr.ResponseErrorL response. The +// renderer emits both the legacy {msg,status} and the v2 {error.{...}} blocks +// unconditionally (v7.2 dual-envelope contract). +type envelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + HTTPStatus int `json:"http_status"` + } `json:"error"` + Msg string `json:"msg"` + Status int `json:"status"` +} + +func decodeEnvelope(t *testing.T, body []byte) envelope { + t.Helper() + var env envelope + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode envelope: %v\nbody: %s", err, body) + } + return env +} + +// helperHarness mounts a single GET /probe route that invokes the supplied +// helper with the i18n renderer wired, so tests can assert the rendered +// envelope without paying the DB / auth setup cost. +func helperHarness(probe func(c *wkhttp.Context)) *wkhttp.WKHttp { + r := wkhttp.New() + r.SetErrorRenderer(i18n.NewErrorRenderer(i18n.NewLocalizer(i18n.DefaultLanguage))) + r.GET("/probe", probe) + return r +} + +func TestRespondGroupHelpers(t *testing.T) { + cases := []struct { + name string + probe func(c *wkhttp.Context) + wantCodeID string + wantSemStatus int + wantTransStatus int // always 400 for legacy compat (D14) + wantContains string // zh-CN substring expected in error.message + wantNotContains string // forbid leaked English DefaultMessage when Internal=true + wantDetails map[string]any + }{ + { + name: "respondGroupRequestInvalid carries the field detail", + probe: func(c *wkhttp.Context) { respondGroupRequestInvalid(c, "group_no") }, + wantCodeID: "err.server.group.request_invalid", + wantSemStatus: http.StatusBadRequest, + wantTransStatus: http.StatusBadRequest, + wantContains: "请求参数有误", + wantDetails: map[string]any{"field": "group_no"}, + }, + { + name: "respondGroupRequestInvalid drops empty field key", + probe: func(c *wkhttp.Context) { respondGroupRequestInvalid(c, "") }, + wantCodeID: "err.server.group.request_invalid", + wantSemStatus: http.StatusBadRequest, + wantTransStatus: http.StatusBadRequest, + wantContains: "请求参数有误", + wantDetails: map[string]any{}, + }, + { + name: "respondGroupForbidden routes to shared.auth.forbidden", + probe: func(c *wkhttp.Context) { respondGroupForbidden(c) }, + wantCodeID: "err.shared.auth.forbidden", + wantSemStatus: http.StatusForbidden, + wantTransStatus: http.StatusBadRequest, + wantContains: "无权执行此操作", + }, + { + name: "respondGroupNotLoggedIn routes to shared.auth.required", + probe: func(c *wkhttp.Context) { respondGroupNotLoggedIn(c) }, + wantCodeID: "err.shared.auth.required", + wantSemStatus: http.StatusUnauthorized, + wantTransStatus: http.StatusBadRequest, + wantContains: "请先登录", + }, + { + name: "respondGroupMdContentTooLarge surfaces the size cap", + probe: func(c *wkhttp.Context) { respondGroupMdContentTooLarge(c, 4096) }, + wantCodeID: "err.server.group.group_md_content_too_large", + wantSemStatus: http.StatusBadRequest, + wantTransStatus: http.StatusBadRequest, + wantContains: "GROUP.md", + wantDetails: map[string]any{"field": "content", "max_size": float64(4096)}, + }, + { + name: "respondGroupInfoError maps not-found sentinel to 404", + probe: func(c *wkhttp.Context) { respondGroupInfoError(c, errGroupInfoNotFound) }, + wantCodeID: "err.server.group.not_found", + wantSemStatus: http.StatusNotFound, + wantTransStatus: http.StatusBadRequest, + wantContains: "群不存在", + }, + { + name: "respondGroupInfoError maps query sentinel to 500 internal", + probe: func(c *wkhttp.Context) { respondGroupInfoError(c, errGroupInfoQueryFailed) }, + wantCodeID: "err.server.group.query_failed", + wantSemStatus: http.StatusInternalServerError, + wantTransStatus: http.StatusBadRequest, + wantContains: "服务器内部错误", + wantNotContains: "query group data", + }, + { + name: "ErrGroupCreatorOnly surfaces 403 zh-CN copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupCreatorOnly) }, + wantCodeID: "err.server.group.creator_only", + wantSemStatus: http.StatusForbidden, + wantTransStatus: http.StatusBadRequest, + wantContains: "只有群主", + }, + { + name: "ErrGroupBotOwnershipDenied surfaces 403 zh-CN copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupBotOwnershipDenied) }, + wantCodeID: "err.server.group.bot_ownership_denied", + wantSemStatus: http.StatusForbidden, + wantTransStatus: http.StatusBadRequest, + wantContains: "邀请该机器人", + }, + { + name: "ErrGroupExternalCannotBeAdmin surfaces 403 zh-CN copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupExternalCannotBeAdmin) }, + wantCodeID: "err.server.group.external_cannot_be_admin", + wantSemStatus: http.StatusForbidden, + wantTransStatus: http.StatusBadRequest, + wantContains: "外部成员设为管理员", + }, + { + name: "ErrGroupMemberNotInGroup surfaces 404 zh-CN copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupMemberNotInGroup) }, + wantCodeID: "err.server.group.member_not_in_group", + wantSemStatus: http.StatusNotFound, + wantTransStatus: http.StatusBadRequest, + wantContains: "该成员不在群内", + }, + { + name: "ErrGroupAlreadyMember surfaces 409 zh-CN copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupAlreadyMember) }, + wantCodeID: "err.server.group.already_member", + wantSemStatus: http.StatusConflict, + wantTransStatus: http.StatusBadRequest, + wantContains: "已在群内", + }, + { + name: "ErrGroupInviteModeCannotJoin surfaces 403 zh-CN copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupInviteModeCannotJoin) }, + wantCodeID: "err.server.group.invite_mode_cannot_join", + wantSemStatus: http.StatusForbidden, + wantTransStatus: http.StatusBadRequest, + wantContains: "邀请模式", + }, + { + name: "ErrGroupStoreFailed (Internal=true) collapses to shared internal copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupStoreFailed) }, + wantCodeID: "err.server.group.store_failed", + wantSemStatus: http.StatusInternalServerError, + wantTransStatus: http.StatusBadRequest, + wantContains: "服务器内部错误", + wantNotContains: "update group data", + }, + { + name: "ErrGroupNotifyFailed (Internal=true) collapses to shared internal copy", + probe: func(c *wkhttp.Context) { httperrL(c, errcode.ErrGroupNotifyFailed) }, + wantCodeID: "err.server.group.notify_failed", + wantSemStatus: http.StatusInternalServerError, + wantTransStatus: http.StatusBadRequest, + wantContains: "服务器内部错误", + wantNotContains: "notification", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := helperHarness(tc.probe) + req := httptest.NewRequest(http.MethodGet, "/probe", nil) + req.Header.Set("Accept-Language", "zh-CN") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if rec.Code != tc.wantTransStatus { + t.Fatalf("HTTP status = %d, want %d; body=%s", rec.Code, tc.wantTransStatus, rec.Body.String()) + } + env := decodeEnvelope(t, rec.Body.Bytes()) + if env.Error.Code != tc.wantCodeID { + t.Fatalf("error.code = %q, want %q", env.Error.Code, tc.wantCodeID) + } + if env.Error.HTTPStatus != tc.wantSemStatus { + t.Fatalf("error.http_status = %d, want %d", env.Error.HTTPStatus, tc.wantSemStatus) + } + if env.Status != tc.wantTransStatus { + t.Fatalf("legacy status = %d, want %d (D14 transport=400 compat)", env.Status, tc.wantTransStatus) + } + if env.Msg != env.Error.Message { + t.Fatalf("legacy msg %q != error.message %q (dual envelope must agree)", env.Msg, env.Error.Message) + } + if !strings.Contains(env.Error.Message, tc.wantContains) { + t.Fatalf("error.message = %q, want substring %q", env.Error.Message, tc.wantContains) + } + if tc.wantNotContains != "" && strings.Contains(env.Error.Message, tc.wantNotContains) { + t.Fatalf("error.message = %q must not contain %q (Internal leak)", env.Error.Message, tc.wantNotContains) + } + if tc.wantDetails != nil { + got := env.Error.Details + if got == nil { + got = map[string]any{} + } + if len(got) != len(tc.wantDetails) { + t.Fatalf("error.details = %#v, want %#v", got, tc.wantDetails) + } + for k, v := range tc.wantDetails { + if got[k] != v { + t.Fatalf("error.details[%q] = %#v, want %#v", k, got[k], v) + } + } + } + }) + } +} diff --git a/modules/group/api_invite_h5_test.go b/modules/group/api_invite_h5_test.go index bd34f0b1..698c4f5e 100644 --- a/modules/group/api_invite_h5_test.go +++ b/modules/group/api_invite_h5_test.go @@ -303,6 +303,7 @@ func TestGroupInviteAuthorize_OK(t *testing.T) { // invite=1 的群(需审批)不应通过 authorize 生成 auth_code。 func TestGroupInviteAuthorize_InviteRequired(t *testing.T) { s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) f := New(ctx) err := testutil.CleanAllTables(ctx) @@ -343,6 +344,7 @@ func TestGroupInviteAuthorize_InviteRequired(t *testing.T) { // code 已过期 / 不存在:返回错误。 func TestGroupInviteAuthorize_Expired(t *testing.T) { s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) _ = New(ctx) err := testutil.CleanAllTables(ctx) @@ -671,6 +673,7 @@ func TestGroupInviteAuthorize_Invite1_AlreadyMember(t *testing.T) { // 当 already_member 判定 miss 时,invite 判定仍然生效,顺序重排不影响非成员路径)。 func TestGroupInviteAuthorize_Invite1_NonMember(t *testing.T) { s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) f := New(ctx) err := testutil.CleanAllTables(ctx) diff --git a/modules/group/api_manager.go b/modules/group/api_manager.go index bc09e777..254a07d6 100644 --- a/modules/group/api_manager.go +++ b/modules/group/api_manager.go @@ -2,21 +2,23 @@ package group import ( "bytes" - "os" - "runtime/debug" "encoding/json" "errors" "fmt" "io" + "os" + "runtime/debug" "strconv" - "github.com/Mininglamp-OSS/octo-server/modules/base/event" - "github.com/Mininglamp-OSS/octo-server/modules/user" "github.com/Mininglamp-OSS/octo-lib/common" "github.com/Mininglamp-OSS/octo-lib/config" "github.com/Mininglamp-OSS/octo-lib/pkg/log" "github.com/Mininglamp-OSS/octo-lib/pkg/wkevent" "github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp" + "github.com/Mininglamp-OSS/octo-server/modules/base/event" + "github.com/Mininglamp-OSS/octo-server/modules/user" + "github.com/Mininglamp-OSS/octo-server/pkg/errcode" + "github.com/Mininglamp-OSS/octo-server/pkg/httperr" "go.uber.org/zap" ) @@ -58,7 +60,7 @@ func (m *Manager) Route(r *wkhttp.WKHttp) { func (m *Manager) list(c *wkhttp.Context) { err := c.CheckLoginRole() if err != nil { - c.ResponseError(err) + respondGroupForbidden(c) return } keyword := c.Query("keyword") @@ -69,33 +71,33 @@ func (m *Manager) list(c *wkhttp.Context) { list, err = m.managerDB.listWithPage(uint64(pageSize), uint64(pageIndex)) if err != nil { m.Error("查询群列表错误", zap.Error(err)) - c.ResponseError(errors.New("查询群列表错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } count, err = m.db.queryGroupCount() if err != nil { m.Error("查询群数量错误", zap.Error(err)) - c.ResponseError(errors.New("查询群数量错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } } else { list, err = m.managerDB.listWithPageAndKeyword(keyword, uint64(pageSize), uint64(pageIndex)) if err != nil { m.Error("查询群列表错误", zap.Error(err)) - c.ResponseError(errors.New("查询群列表错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } count, err = m.managerDB.queryGroupCountWithKeyWord(keyword) if err != nil { m.Error("查询群数量错误", zap.Error(err)) - c.ResponseError(errors.New("查询群数量错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } } result, err := m.getRespList(list) if err != nil { - c.ResponseError(err) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } c.Response(map[string]interface{}{ @@ -165,25 +167,25 @@ func (m *Manager) getRespList(list []*managerGroupModel) ([]*managerGroupResp, e func (m *Manager) disablelist(c *wkhttp.Context) { err := c.CheckLoginRole() if err != nil { - c.ResponseError(err) + respondGroupForbidden(c) return } pageIndex, pageSize := c.GetPage() list, err := m.managerDB.queryGroupsWithStatus(GroupStatusDisabled, uint64(pageSize), uint64(pageIndex)) if err != nil { m.Error("查询群列表错误", zap.Error(err)) - c.ResponseError(errors.New("查询群列表错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } result, err := m.getRespList(list) if err != nil { - c.ResponseError(err) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } count, err := m.managerDB.queryGroupCountWithStatus(GroupStatusDisabled) if err != nil { m.Error("查询群总数错误", zap.Error(err)) - c.ResponseError(errors.New("查询群总数错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } c.Response(map[string]interface{}{ @@ -196,32 +198,32 @@ func (m *Manager) disablelist(c *wkhttp.Context) { func (m *Manager) leftbangroup(c *wkhttp.Context) { err := c.CheckLoginRoleIsSuperAdmin() if err != nil { - c.ResponseError(err) + respondGroupForbidden(c) return } groupNo := c.Param("groupNo") status := c.Param("status") if groupNo == "" { - c.ResponseError(errors.New("操作群ID不能为空")) + respondGroupRequestInvalid(c, "group_no") return } if status == "" { - c.ResponseError(errors.New("操作状态不能为空")) + respondGroupRequestInvalid(c, "status") return } group, err := m.db.QueryWithGroupNo(groupNo) if err != nil { m.Error("查询群信息错误", zap.Error(err)) - c.ResponseError(errors.New("查询群信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if group == nil { - c.ResponseError(errors.New("操作的群不存在")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) return } groupStatus, _ := strconv.Atoi(status) if groupStatus != GroupStatusNormal && groupStatus != GroupStatusDisabled { - c.ResponseError(errors.New("未知操作类型")) + respondGroupRequestInvalid(c, "status") return } @@ -241,7 +243,7 @@ func (m *Manager) leftbangroup(c *wkhttp.Context) { }) if err != nil { m.Error("调用IM修改channel信息服务失败!", zap.Error(err)) - c.ResponseError(errors.New("调用IM修改channel信息服务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotifyFailed, nil, nil) return } group.Status = groupStatus @@ -250,7 +252,7 @@ func (m *Manager) leftbangroup(c *wkhttp.Context) { tx, err := m.ctx.DB().Begin() if err != nil { m.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -265,7 +267,7 @@ func (m *Manager) leftbangroup(c *wkhttp.Context) { if err != nil { tx.Rollback() m.Error("更新群信息失败!", zap.Error(err), zap.String("group_no", group.GroupNo), zap.Any("groupMap", groupMap)) - c.ResponseError(errors.New("更新群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } // 发布群创建事件 @@ -283,7 +285,7 @@ func (m *Manager) leftbangroup(c *wkhttp.Context) { if err := tx.Commit(); err != nil { tx.RollbackUnlessCommitted() m.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } m.ctx.EventCommit(eventID) @@ -295,23 +297,23 @@ func (m *Manager) leftbangroup(c *wkhttp.Context) { func (m *Manager) forbidden(c *wkhttp.Context) { err := c.CheckLoginRoleIsSuperAdmin() if err != nil { - c.ResponseError(err) + respondGroupForbidden(c) return } groupNo := c.Param("group_no") on := c.Param("on") if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } groupModel, err := m.db.QueryWithGroupNo(groupNo) if err != nil { m.Error("查询群信息失败!", zap.Error(err)) - c.ResponseError(errors.New("查询群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if groupModel == nil { - c.ResponseError(errors.New("群不存在!")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) return } forbidden, _ := strconv.ParseInt(on, 10, 64) @@ -321,7 +323,8 @@ func (m *Manager) forbidden(c *wkhttp.Context) { if forbidden == 1 { managerOrCreaterUIDs, err := m.db.QueryGroupManagerOrCreatorUIDS(groupNo) if err != nil { - c.ResponseErrorf("查询管理者们的uid失败!", err) + m.Error("查询管理者们的uid失败!", zap.Error(err)) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } whitelistUIDs = managerOrCreaterUIDs @@ -330,7 +333,7 @@ func (m *Manager) forbidden(c *wkhttp.Context) { tx, err := m.ctx.DB().Begin() if err != nil { m.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -344,7 +347,7 @@ func (m *Manager) forbidden(c *wkhttp.Context) { if err != nil { tx.Rollback() m.Error("更新群信息失败!", zap.Error(err), zap.String("group_no", groupModel.GroupNo)) - c.ResponseError(errors.New("更新群信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } // 发布群信息更新事件 @@ -364,14 +367,14 @@ func (m *Manager) forbidden(c *wkhttp.Context) { if err != nil { tx.Rollback() m.Error("开启群更新事件失败!", zap.Error(err)) - c.ResponseError(errors.New("开启群更新事件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } if err := tx.Commit(); err != nil { tx.RollbackUnlessCommitted() m.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } m.ctx.EventCommit(eventID) @@ -386,7 +389,7 @@ func (m *Manager) forbidden(c *wkhttp.Context) { }) if err != nil { m.Error("设置禁言失败!", zap.Error(err)) - c.ResponseError(errors.New(err.Error())) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -397,7 +400,7 @@ func (m *Manager) forbidden(c *wkhttp.Context) { func (m *Manager) removeMember(c *wkhttp.Context) { err := c.CheckLoginRoleIsSuperAdmin() if err != nil { - c.ResponseError(err) + respondGroupForbidden(c) return } type memberRemoveReq struct { @@ -406,11 +409,11 @@ func (m *Manager) removeMember(c *wkhttp.Context) { var req memberRemoveReq if err := c.BindJSON(&req); err != nil { m.Error(common.ErrData.Error(), zap.Error(err)) - c.ResponseError(common.ErrData) + respondGroupRequestInvalid(c, "") return } if len(req.UID) == 0 { - c.ResponseError(errors.New("群成员不能为空!")) + respondGroupRequestInvalid(c, "members") return } type deleteReq struct { @@ -422,7 +425,7 @@ func (m *Manager) removeMember(c *wkhttp.Context) { jsonData, err := json.Marshal(req2) if err != nil { m.Error("json序列化失败!", zap.Error(err)) - c.ResponseError(errors.New("json序列化失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } c.Request.Body = io.NopCloser(bytes.NewReader(jsonData)) @@ -433,24 +436,24 @@ func (m *Manager) removeMember(c *wkhttp.Context) { func (m *Manager) members(c *wkhttp.Context) { err := c.CheckLoginRole() if err != nil { - c.ResponseError(err) + respondGroupForbidden(c) return } groupNo := c.Param("group_no") pageIndex, pageSize := c.GetPage() if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } keyword := c.Query("keyword") groupModel, err := m.db.QueryWithGroupNo(groupNo) if err != nil { m.Error("查询群信息错误", zap.Error(err)) - c.ResponseError(errors.New("查询群信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if groupModel == nil { - c.ResponseError(errors.New("操作的群不存在")) + httperr.ResponseErrorL(c, errcode.ErrGroupNotFound, nil, nil) return } var list []*managerMemberModel @@ -459,26 +462,26 @@ func (m *Manager) members(c *wkhttp.Context) { list, err = m.managerDB.queryGroupMembers(groupNo, uint64(pageSize), uint64(pageIndex)) if err != nil { m.Error("查询群成员错误", zap.Error(err)) - c.ResponseError(errors.New("查询群成员错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } count, err = m.managerDB.queryGroupMemberCount(groupNo) if err != nil { m.Error("查询群成员总数错误", zap.Error(err)) - c.ResponseError(errors.New("查询群成员总数错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } } else { list, err = m.managerDB.queryGroupMembersWithKeyWord(groupNo, keyword, uint64(pageSize), uint64(pageIndex)) if err != nil { m.Error("查询群成员错误", zap.Error(err)) - c.ResponseError(errors.New("查询群成员错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } count, err = m.managerDB.queryGroupMemberCountWithKeyword(groupNo, keyword) if err != nil { m.Error("查询群成员总数错误", zap.Error(err)) - c.ResponseError(errors.New("查询群成员总数错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } } @@ -493,25 +496,25 @@ func (m *Manager) members(c *wkhttp.Context) { func (m *Manager) blacklist(c *wkhttp.Context) { err := c.CheckLoginRole() if err != nil { - c.ResponseError(err) + respondGroupForbidden(c) return } groupNo := c.Param("group_no") pageIndex, pageSize := c.GetPage() if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } list, err := m.managerDB.queryGroupMembersWithStatus(groupNo, int(common.GroupMemberStatusBlacklist), uint64(pageSize), uint64(pageIndex)) if err != nil { m.Error("查询群成员错误", zap.Error(err)) - c.ResponseError(errors.New("查询群成员错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } count, err := m.managerDB.queryGroupMemberCountWithStatus(groupNo, int(common.GroupMemberStatusBlacklist)) if err != nil { m.Error("查询群成员总数错误", zap.Error(err)) - c.ResponseError(errors.New("查询群成员总数错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } c.Response(map[string]interface{}{ diff --git a/modules/group/api_setting_action.go b/modules/group/api_setting_action.go index 249d0da9..f8337863 100644 --- a/modules/group/api_setting_action.go +++ b/modules/group/api_setting_action.go @@ -13,6 +13,19 @@ import ( "go.uber.org/zap" ) +// Action-layer sentinel errors. groupSettingUpdate's dispatch classifies these +// via errors.Is so client-facing failures are not collapsed into Internal=true +// 500 (ErrGroupStoreFailed). Genuine DB/event failures keep returning their own +// errors and fall through to the store_failed fallback. +var ( + // errSettingInvalidValueType marks a malformed / wrong-typed setting value (client 400). + errSettingInvalidValueType = errors.New("invalid value type") + // errSettingAllowExternalRange marks an out-of-range allow_external value (client 400). + errSettingAllowExternalRange = errors.New("allow_external only accepts 0 or 1") + // errGroupUpdateForbidden marks a non-manager/creator attempting a group-attr update (client 403). + errGroupUpdateForbidden = errors.New("没有权限!") +) + // safeIntFromFloat64 safely converts an interface{} to int via float64. func safeIntFromFloat64(v interface{}) (int, bool) { f, ok := v.(float64) @@ -91,7 +104,7 @@ func (g *groupUpdateContext) checkPermissions() error { return err } if !isManager { - return errors.New("没有权限!") + return errGroupUpdateForbidden } return nil } @@ -153,7 +166,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "mute": func(ctx *settingContext, value interface{}) error { // 免打扰 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.Mute = val return ctx.updateSettingAndSendCMD() @@ -161,7 +174,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "top": func(ctx *settingContext, value interface{}) error { // 会话置顶 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.Top = val return ctx.updateSettingAndSendCMD() @@ -169,7 +182,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "save": func(ctx *settingContext, value interface{}) error { // 保存群 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.Save = val return ctx.updateSettingAndSendCMD() @@ -177,7 +190,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "show_nick": func(ctx *settingContext, value interface{}) error { // 是否显示昵称 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.ShowNick = val return ctx.updateSettingAndSendCMD() @@ -185,7 +198,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "chat_pwd_on": func(ctx *settingContext, value interface{}) error { // 聊天密码 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.ChatPwdOn = val return ctx.updateSettingAndSendCMD() @@ -193,7 +206,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "screenshot": func(ctx *settingContext, value interface{}) error { // 截屏 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.Screenshot = val return ctx.updateSettingAndSendCMD() @@ -201,7 +214,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "join_group_remind": func(ctx *settingContext, value interface{}) error { // 进群提醒 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.JoinGroupRemind = val return ctx.updateSettingAndSendCMD() @@ -209,7 +222,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "revoke_remind": func(ctx *settingContext, value interface{}) error { // 撤回提醒 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.RevokeRemind = val return ctx.updateSettingAndSendCMD() @@ -217,7 +230,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "receipt": func(ctx *settingContext, value interface{}) error { // 消息已读回执 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.Receipt = val return ctx.updateSettingAndSendCMD() @@ -225,7 +238,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "remark": func(ctx *settingContext, value interface{}) error { // 群备注 val, ok := safeString(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.Remark = val return ctx.updateSettingAndSendCMD() @@ -233,7 +246,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "flame": func(ctx *settingContext, value interface{}) error { // 阅后即焚开启 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.Flame = val return ctx.updateSettingAndSendCMD() @@ -241,7 +254,7 @@ var settingActionMap = map[string]groupSettingActionFnc{ "flame_second": func(ctx *settingContext, value interface{}) error { // 阅后即焚时间 val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupSetting.FlameSecond = val return ctx.updateSettingAndSendCMD() @@ -255,7 +268,7 @@ var groupUpdateActionMap = map[string]groupUpdateActionFnc{ } val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupModel.Forbidden = val @@ -296,7 +309,7 @@ var groupUpdateActionMap = map[string]groupUpdateActionFnc{ } val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupModel.ForbiddenAddFriend = val err := ctx.updateGroup() @@ -315,7 +328,7 @@ var groupUpdateActionMap = map[string]groupUpdateActionFnc{ } val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupModel.Invite = val @@ -332,7 +345,7 @@ var groupUpdateActionMap = map[string]groupUpdateActionFnc{ } val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupModel.AllowViewHistoryMsg = val @@ -350,7 +363,7 @@ var groupUpdateActionMap = map[string]groupUpdateActionFnc{ } val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } ctx.groupModel.AllowMemberPinnedMessage = val err := ctx.updateGroup() @@ -367,10 +380,10 @@ var groupUpdateActionMap = map[string]groupUpdateActionFnc{ } val, ok := safeIntFromFloat64(value) if !ok { - return errors.New("invalid value type") + return errSettingInvalidValueType } if val != 0 && val != 1 { - return errors.New("allow_external only accepts 0 or 1") + return errSettingAllowExternalRange } ctx.groupModel.AllowExternal = val if err := ctx.updateGroup(); err != nil { diff --git a/modules/group/api_test.go b/modules/group/api_test.go index 5a5c31f8..542ffd41 100644 --- a/modules/group/api_test.go +++ b/modules/group/api_test.go @@ -1241,6 +1241,7 @@ func TestGroupMemberGet_Self(t *testing.T) { // TestGroupMemberGet_CallerNotMember 调用方非该群成员 → 403/error func TestGroupMemberGet_CallerNotMember(t *testing.T) { s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) f := New(ctx) err := testutil.CleanAllTables(ctx) diff --git a/modules/group/invite.go b/modules/group/invite.go index e581ab5a..ea6fe4ae 100644 --- a/modules/group/invite.go +++ b/modules/group/invite.go @@ -15,6 +15,8 @@ import ( "github.com/Mininglamp-OSS/octo-lib/pkg/wkevent" "github.com/Mininglamp-OSS/octo-lib/pkg/wkhttp" "github.com/Mininglamp-OSS/octo-server/modules/base/event" + "github.com/Mininglamp-OSS/octo-server/pkg/errcode" + "github.com/Mininglamp-OSS/octo-server/pkg/httperr" spacepkg "github.com/Mininglamp-OSS/octo-server/pkg/space" "github.com/gin-gonic/gin" "go.uber.org/zap" @@ -28,17 +30,17 @@ func (g *Group) groupMemberInviteAdd(c *wkhttp.Context) { var req InviteReq if err := c.BindJSON(&req); err != nil { g.Error("数据格式有误!", zap.Error(err)) - c.ResponseError(errors.New("数据格式有误!")) + respondGroupRequestInvalid(c, "") return } if err := req.Check(); err != nil { - c.ResponseError(err) + respondGroupRequestInvalid(c, "") return } _, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } @@ -47,18 +49,18 @@ func (g *Group) groupMemberInviteAdd(c *wkhttp.Context) { // 真正的兜底在 addMembersTx 里,这里只是为了更友好的错误提示。 if botErr := checkBotOwnership(g.ctx.DB(), loginUID, req.UIDS); botErr != nil { if errors.Is(botErr, ErrBotOwnershipDenied) { - c.ResponseErrorWithStatus(ErrBotOwnershipDenied, http.StatusForbidden) + httperr.ResponseErrorL(c, errcode.ErrGroupBotOwnershipDenied, nil, nil) return } g.Error("检查 Bot 归属失败", zap.Error(botErr)) - c.ResponseError(errors.New("检查 Bot 归属失败")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } creatorOrManagerUIDS, err := g.db.QueryGroupManagerOrCreatorUIDS(groupNo) if err != nil { g.Error("查询创建者或管理员的uid失败!", zap.String("group_no", groupNo), zap.Error(err)) - c.ResponseError(errors.New("查询创建者或管理员的uid失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } @@ -67,7 +69,7 @@ func (g *Group) groupMemberInviteAdd(c *wkhttp.Context) { tx, err := g.db.session.Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -91,7 +93,7 @@ func (g *Group) groupMemberInviteAdd(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("开启事件失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事件失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } inviteModel := &InviteModel{ @@ -105,7 +107,7 @@ func (g *Group) groupMemberInviteAdd(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("添加邀请数据失败!", zap.Error(err)) - c.ResponseError(errors.New("添加邀请数据失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } for _, uid := range req.UIDS { @@ -120,7 +122,7 @@ func (g *Group) groupMemberInviteAdd(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("添加邀请项失败!", zap.Error(err)) - c.ResponseError(errors.New("添加邀请项失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } } @@ -128,7 +130,7 @@ func (g *Group) groupMemberInviteAdd(c *wkhttp.Context) { if err := tx.Commit(); err != nil { tx.Rollback() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } g.ctx.EventCommit(eventID) @@ -141,23 +143,23 @@ func (g *Group) getToGroupMemberConfirmInviteDetailH5(c *wkhttp.Context) { inviteNo := c.Query("invite_no") loginUID := c.MustGet("uid").(string) if groupNo == "" { - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } _, err := g.getGroupInfo(groupNo) if err != nil { - c.ResponseError(err) + respondGroupInfoError(c, err) return } managerOrCreator, err := g.db.QueryIsGroupManagerOrCreator(groupNo, loginUID) if err != nil { g.Error("查询是否管理者或创建者失败!") - c.ResponseError(errors.New("查询是否管理者或创建者失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if !managerOrCreator { - c.ResponseError(errors.New("你不是群主或管理员!")) + httperr.ResponseErrorL(c, errcode.ErrGroupCreatorOrManagerOnly, nil, nil) return } authCode := util.GenerUUID() @@ -169,7 +171,7 @@ func (g *Group) getToGroupMemberConfirmInviteDetailH5(c *wkhttp.Context) { }), time.Minute*5) if err != nil { g.Error("缓存授权码失败!", zap.Error(err)) - c.ResponseError(errors.New("缓存授权码失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -185,33 +187,39 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { authInfo, err := g.ctx.GetRedisConn().GetString(fmt.Sprintf("%s%s", common.AuthCodeCachePrefix, authCode)) if err != nil { g.Error("获取授权信息失败!", zap.Error(err)) - c.ResponseError(errors.New("获取授权信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) + return + } + // 空串 = auth_code 不存在 / 已过期(30min TTL),是正常用户态而非解码失败; + // 必须在 decode 前拦截,否则会落到下面的 store_failed 内部错误分支。 + if authInfo == "" { + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } var authMap map[string]interface{} err = util.ReadJsonByByte([]byte(authInfo), &authMap) if err != nil { g.Error("解码认证信息的JSON数据失败!", zap.Error(err)) - c.ResponseError(errors.New("解码认证信息的JSON数据失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } authType, ok := authMap["type"].(string) if !ok { - c.ResponseError(errors.New("授权码类型无效!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } if authType != string(common.AuthCodeTypeGroupMemberInvite) { - c.ResponseError(errors.New("授权码不是确认邀请!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } inviteNo, ok := authMap["invite_no"].(string) if !ok { - c.ResponseError(errors.New("邀请编号无效!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } allower, ok := authMap["allower"].(string) if !ok { - c.ResponseError(errors.New("授权者信息无效!")) + httperr.ResponseErrorL(c, errcode.ErrGroupAuthCodeInvalid, nil, nil) return } /** @@ -220,7 +228,7 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { tx, err := g.ctx.DB().Begin() if err != nil { g.Error("开启事务失败!", zap.Error(err)) - c.ResponseError(errors.New("开启事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } defer func() { @@ -236,17 +244,17 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("查询邀请详情失败!", zap.Error(err)) - c.ResponseError(errors.New("查询邀请详情失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if inviteDetailModel == nil { tx.Rollback() - c.ResponseError(errors.New("没有查询到邀请信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteNotFound, nil, nil) return } if inviteDetailModel.Status != InviteStatusWait { tx.Rollback() - c.ResponseError(errors.New("邀请信息不是待邀请状态!")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteStatusInvalid, nil, nil) return } /** @@ -256,12 +264,12 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("查询邀请详情失败!", zap.Error(err)) - c.ResponseError(errors.New("查询邀请详情失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if inviteItemDetilModels == nil || len(inviteItemDetilModels) <= 0 { tx.Rollback() - c.ResponseError(errors.New("没有查到邀请信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteNotFound, nil, nil) return } members := make([]string, 0, len(inviteItemDetilModels)) @@ -272,13 +280,13 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { } if groupNo == "" { tx.Rollback() - c.ResponseError(errors.New("群编号不能为空")) + respondGroupRequestInvalid(c, "group_no") return } _, err = g.getGroupInfo(groupNo) if err != nil { tx.Rollback() - c.ResponseError(err) + respondGroupInfoError(c, err) return } /** @@ -288,27 +296,27 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { if err != nil { tx.Rollback() g.Error("查询邀请者的用户信息失败!", zap.Error(err)) - c.ResponseError(errors.New("查询邀请者的用户信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if inviterUser == nil { tx.Rollback() g.Error("没有查到邀请者的用户信息!") - c.ResponseError(errors.New("没有查到邀请者的用户信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteNotFound, nil, nil) return } err = g.db.UpdateInviteStatusTx(allower, InviteStatusOK, inviteNo, tx) if err != nil { tx.Rollback() g.Error("更新邀请信息状态失败!", zap.Error(err)) - c.ResponseError(errors.New("更新邀请信息状态失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } err = g.db.UpdateInviteItemStatusTx(InviteStatusOK, inviteNo, tx) if err != nil { tx.Rollback() g.Error("更新邀请信息项状态失败!", zap.Error(err)) - c.ResponseError(errors.New("更新邀请信息项状态失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } // YUJ-199 / GH#1265:邀请确认调用方携带的 X-Space-ID 才是邀请发起 @@ -322,16 +330,16 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { g.Error("添加成员失败!", zap.Error(err)) // 透出 allow_external 等策略拒绝的具体错误,方便管理员定位;其他底层错误走兜底文案 if strings.Contains(err.Error(), "禁止外部成员") { - c.ResponseError(err) + httperr.ResponseErrorL(c, errcode.ErrGroupExternalJoinForbidden, nil, nil) } else { - c.ResponseError(errors.New("添加成员失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) } return } if err := tx.Commit(); err != nil { tx.Rollback() g.Error("提交事务失败!", zap.Error(err)) - c.ResponseError(errors.New("提交事务失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupStoreFailed, nil, nil) return } @@ -344,24 +352,24 @@ func (g *Group) groupMemberInviteSure(c *wkhttp.Context) { func (g *Group) groupMemberInviteDetail(c *wkhttp.Context) { loginUID := c.GetLoginUID() if loginUID == "" { - c.ResponseError(errors.New("请先登录!")) + respondGroupNotLoggedIn(c) return } inviteNo := c.Param("invite_no") inviteDetilModel, err := g.db.QueryInviteDetail(inviteNo) if err != nil { g.Error("查询邀请详情失败!", zap.Error(err)) - c.ResponseError(errors.New("查询邀请详情失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if inviteDetilModel == nil { - c.ResponseError(errors.New("没有查到邀请信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupInviteNotFound, nil, nil) return } inviteItems, err := g.db.QueryInviteItemDetail(inviteNo) if err != nil { g.Error("获取邀请项失败!", zap.Error(err)) - c.ResponseError(errors.New("获取邀请项失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } @@ -380,13 +388,13 @@ func (g *Group) groupMemberInviteDetail(c *wkhttp.Context) { isManager, err := g.db.QueryIsGroupManagerOrCreator(inviteDetilModel.GroupNo, loginUID) if err != nil { g.Error("查询管理员信息失败!", zap.Error(err)) - c.ResponseError(errors.New("查询管理员信息失败!")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } isAuthorized = isManager } if !isAuthorized { - c.ResponseError(errors.New("您没有权限查看该邀请信息!")) + httperr.ResponseErrorL(c, errcode.ErrGroupViewForbidden, nil, nil) return } @@ -398,7 +406,7 @@ func (g *Group) groupMemberInviteDetail(c *wkhttp.Context) { members, err := g.db.QueryMembersWithUids(uids, inviteDetilModel.GroupNo) if err != nil { g.Error("查询成员信息错误", zap.Error(err)) - c.ResponseError(errors.New("查询成员信息错误")) + httperr.ResponseErrorL(c, errcode.ErrGroupQueryFailed, nil, nil) return } if len(members) == len(inviteItems) { diff --git a/modules/group/managerAdd_external_test.go b/modules/group/managerAdd_external_test.go index 35173db2..d29e8443 100644 --- a/modules/group/managerAdd_external_test.go +++ b/modules/group/managerAdd_external_test.go @@ -21,9 +21,9 @@ import ( // 期望:返回 403 且拒绝写入(两人角色都保持 common)。 func TestManagerAdd_RejectsExternalMember(t *testing.T) { s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) f := New(ctx) - err := testutil.CleanAllTables(ctx) assert.NoError(t, err) @@ -83,10 +83,10 @@ func TestManagerAdd_RejectsExternalMember(t *testing.T) { req.Header.Set("token", testutil.Token) s.GetRoute().ServeHTTP(w, req) - // 期望 403 + 中文错误提示 - assert.Equal(t, http.StatusForbidden, w.Code, "外部成员应被 403 拦截,body=%s", w.Body.String()) - assert.True(t, strings.Contains(w.Body.String(), "不能提拔外部成员为管理员"), - "响应缺少拒绝提示,body=%s", w.Body.String()) + // D14: wire status 固定 400;403 语义落在 error.http_status / error.code。 + assert.Equal(t, http.StatusBadRequest, w.Code, "外部成员应被拦截(400 信封),body=%s", w.Body.String()) + assert.True(t, strings.Contains(w.Body.String(), "err.server.group.external_cannot_be_admin"), + "响应缺少拒绝错误码,body=%s", w.Body.String()) // 双重保险:确认两个目标用户 role 都没被改动(事务未执行) internalAfter, err := f.db.QueryMemberWithUID("internal-target", groupNo) @@ -108,9 +108,9 @@ func TestManagerAdd_RejectsExternalMember(t *testing.T) { // (YUJ-231 / GH#1289)。 func TestTransferGrouper_RejectsExternalMember(t *testing.T) { s, ctx := testutil.NewTestServer() + wireI18nRendererForGroupTest(s) f := New(ctx) - err := testutil.CleanAllTables(ctx) assert.NoError(t, err) @@ -154,10 +154,11 @@ func TestTransferGrouper_RejectsExternalMember(t *testing.T) { req.Header.Set("token", testutil.Token) s.GetRoute().ServeHTTP(w, req) - assert.Equal(t, http.StatusForbidden, w.Code, - "转让给外部成员应被 403 拦截,body=%s", w.Body.String()) - assert.True(t, strings.Contains(w.Body.String(), "不能将群主转让给外部成员"), - "响应缺少拒绝提示,body=%s", w.Body.String()) + // D14: wire status 固定 400;403 语义落在 error.http_status / error.code。 + assert.Equal(t, http.StatusBadRequest, w.Code, + "转让给外部成员应被拦截(400 信封),body=%s", w.Body.String()) + assert.True(t, strings.Contains(w.Body.String(), "err.server.group.external_cannot_be_owner"), + "响应缺少拒绝错误码,body=%s", w.Body.String()) // creator 未易主,外部成员仍是 common creatorAfter, err := f.db.QueryMemberWithUID(testutil.UID, groupNo) diff --git a/pkg/errcode/group.go b/pkg/errcode/group.go new file mode 100644 index 00000000..785fc70a --- /dev/null +++ b/pkg/errcode/group.go @@ -0,0 +1,247 @@ +package errcode + +import ( + "net/http" + + "github.com/Mininglamp-OSS/octo-server/pkg/i18n/codes" +) + +// err.server.group.* — modules/group business error codes (api.go / api_manager.go +// / invite.go). DefaultMessage holds the en-US source (D4); the zh-CN runtime +// translation lives in pkg/i18n/locales/active.zh-CN.toml. Internal=true codes +// never surface their message on the wire — callers MUST log the underlying err +// with full context via the module logger before responding. +var ( + // ---- validation (400) ---------------------------------------------------- + + // ErrGroupRequestInvalid is the catch-all for missing/malformed request + // input (empty group_no, BindJSON failure, common.ErrData, bad action type, + // nothing-to-update, etc.). The offending field is surfaced via Details when + // the caller can identify it. + ErrGroupRequestInvalid = register(codes.Code{ + ID: "err.server.group.request_invalid", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "Invalid request.", + SafeDetailKeys: []string{"field"}, + }) + ErrGroupMemberNotFriend = register(codes.Code{ + ID: "err.server.group.member_not_friend", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "You can only add a friend to the group. Please add this user as a friend first.", + }) + ErrGroupFileHelperNotAllowed = register(codes.Code{ + ID: "err.server.group.file_helper_not_allowed", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "The File Helper cannot be added to a group.", + }) + ErrGroupCategorySpaceMismatch = register(codes.Code{ + ID: "err.server.group.category_space_mismatch", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "The group category does not match the space.", + }) + ErrGroupTargetNotBot = register(codes.Code{ + ID: "err.server.group.target_not_bot", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "The target member is not a bot.", + }) + ErrGroupMdContentTooLarge = register(codes.Code{ + ID: "err.server.group.group_md_content_too_large", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "The GROUP.md content exceeds the maximum size.", + SafeDetailKeys: []string{"field", "max_size"}, + }) + ErrGroupAuthCodeInvalid = register(codes.Code{ + ID: "err.server.group.auth_code_invalid", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "The authorization code is invalid or has expired.", + }) + ErrGroupInviteExpired = register(codes.Code{ + ID: "err.server.group.invite_expired", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "The invite link has expired.", + }) + + // ---- permission / authorization (403) ------------------------------------ + + ErrGroupCreatorOnly = register(codes.Code{ + ID: "err.server.group.creator_only", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Only the group owner can perform this action.", + }) + ErrGroupManagerOnly = register(codes.Code{ + ID: "err.server.group.manager_only", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Only a group administrator can perform this action.", + }) + ErrGroupCreatorOrManagerOnly = register(codes.Code{ + ID: "err.server.group.creator_or_manager_only", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Only the group owner or an administrator can perform this action.", + }) + ErrGroupNotMember = register(codes.Code{ + ID: "err.server.group.not_group_member", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "You are not a member of this group.", + }) + ErrGroupViewForbidden = register(codes.Code{ + ID: "err.server.group.view_forbidden", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "You do not have permission to view this.", + }) + ErrGroupMemberCannotRemove = register(codes.Code{ + ID: "err.server.group.member_cannot_remove", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Regular members cannot remove group members.", + }) + ErrGroupCannotRemoveAdmin = register(codes.Code{ + ID: "err.server.group.cannot_remove_admin", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "An administrator cannot remove another administrator.", + }) + ErrGroupCannotRemoveOwner = register(codes.Code{ + ID: "err.server.group.cannot_remove_owner", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "An administrator cannot remove the group owner.", + }) + ErrGroupExternalCannotBeAdmin = register(codes.Code{ + ID: "err.server.group.external_cannot_be_admin", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "External members cannot be promoted to administrator.", + }) + ErrGroupExternalCannotBeOwner = register(codes.Code{ + ID: "err.server.group.external_cannot_be_owner", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Group ownership cannot be transferred to an external member.", + }) + ErrGroupExternalJoinForbidden = register(codes.Code{ + ID: "err.server.group.external_join_forbidden", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "This group does not allow external members to join. Please contact a group administrator.", + }) + ErrGroupInviteModeCannotAdd = register(codes.Code{ + ID: "err.server.group.invite_mode_cannot_add", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Invite mode is enabled; members cannot be added directly.", + }) + ErrGroupInviteModeCannotJoin = register(codes.Code{ + ID: "err.server.group.invite_mode_cannot_join", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Invite mode is enabled; you cannot join the group directly.", + }) + ErrGroupCategoryForbidden = register(codes.Code{ + ID: "err.server.group.category_forbidden", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "You do not have permission to use this group category.", + }) + ErrGroupBotOwnershipDenied = register(codes.Code{ + ID: "err.server.group.bot_ownership_denied", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "You do not have permission to invite this bot.", + }) + ErrGroupBotNotInSpace = register(codes.Code{ + ID: "err.server.group.bot_not_in_space", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "This bot does not belong to your space.", + }) + ErrGroupAuthCodeUserMismatch = register(codes.Code{ + ID: "err.server.group.auth_code_user_mismatch", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "The authorization code does not match the current user.", + }) + ErrGroupQRCodeMemberOnly = register(codes.Code{ + ID: "err.server.group.qrcode_member_only", + HTTPStatus: http.StatusForbidden, + DefaultMessage: "Only group members can generate a QR code.", + }) + + // ---- not found (404) ----------------------------------------------------- + + ErrGroupNotFound = register(codes.Code{ + ID: "err.server.group.not_found", + HTTPStatus: http.StatusNotFound, + DefaultMessage: "Group not found.", + }) + ErrGroupMemberNotInGroup = register(codes.Code{ + ID: "err.server.group.member_not_in_group", + HTTPStatus: http.StatusNotFound, + DefaultMessage: "The member is not in this group.", + }) + ErrGroupCategoryNotFound = register(codes.Code{ + ID: "err.server.group.category_not_found", + HTTPStatus: http.StatusNotFound, + DefaultMessage: "Group category not found.", + }) + ErrGroupTransferTargetNotFound = register(codes.Code{ + ID: "err.server.group.transfer_target_not_found", + HTTPStatus: http.StatusNotFound, + DefaultMessage: "The target user does not exist or has been deactivated.", + }) + ErrGroupInviteNotFound = register(codes.Code{ + ID: "err.server.group.invite_not_found", + HTTPStatus: http.StatusNotFound, + DefaultMessage: "Invite information not found.", + }) + + // ---- conflict (409) ------------------------------------------------------ + + ErrGroupCannotTargetSelf = register(codes.Code{ + ID: "err.server.group.cannot_target_self", + HTTPStatus: http.StatusConflict, + DefaultMessage: "You cannot perform this action on yourself.", + }) + ErrGroupAlreadyMember = register(codes.Code{ + ID: "err.server.group.already_member", + HTTPStatus: http.StatusConflict, + DefaultMessage: "You are already a member of this group.", + }) + ErrGroupInviteStatusInvalid = register(codes.Code{ + ID: "err.server.group.invite_status_invalid", + HTTPStatus: http.StatusConflict, + DefaultMessage: "The invite is not in a pending state.", + }) + + // ---- too large (400) ----------------------------------------------------- + + ErrGroupTooLargeToSync = register(codes.Code{ + ID: "err.server.group.too_large_to_sync", + HTTPStatus: http.StatusBadRequest, + DefaultMessage: "This group is too large to sync members.", + }) + + // ---- rate limit (429) ---------------------------------------------------- + + ErrGroupDailyCreateLimit = register(codes.Code{ + ID: "err.server.group.daily_create_limit", + HTTPStatus: http.StatusTooManyRequests, + DefaultMessage: "You have reached the daily group creation limit.", + }) + + // ---- internal (500, Internal=true) --------------------------------------- + + // ErrGroupQueryFailed covers read-path failures (DB SELECT/exist/count, + // cache GET). Log the underlying err before responding. + ErrGroupQueryFailed = register(codes.Code{ + ID: "err.server.group.query_failed", + HTTPStatus: http.StatusInternalServerError, + DefaultMessage: "Failed to query group data.", + Internal: true, + }) + // ErrGroupStoreFailed covers mutation-path failures (DB write, transaction + // begin/commit/rollback, event begin/commit, cache SET, serialization, file + // upload, whitelist setup). Log the underlying err before responding. + ErrGroupStoreFailed = register(codes.Code{ + ID: "err.server.group.store_failed", + HTTPStatus: http.StatusInternalServerError, + DefaultMessage: "Failed to update group data.", + Internal: true, + }) + // ErrGroupNotifyFailed covers outbound IM-side failures (send command + // message, channel update, subscribe/unsubscribe). Log the underlying err + // before responding. + ErrGroupNotifyFailed = register(codes.Code{ + ID: "err.server.group.notify_failed", + HTTPStatus: http.StatusInternalServerError, + DefaultMessage: "Failed to send group notification.", + Internal: true, + }) +) diff --git a/pkg/i18n/locales/active.zh-CN.toml b/pkg/i18n/locales/active.zh-CN.toml index 0f6ec2c2..3edc55b2 100644 --- a/pkg/i18n/locales/active.zh-CN.toml +++ b/pkg/i18n/locales/active.zh-CN.toml @@ -358,3 +358,120 @@ other = "签名校验失败。" ["err.server.user.verify_type_invalid"] other = "验证类型不匹配。" + +["err.server.group.request_invalid"] +other = "请求参数有误。" + +["err.server.group.member_not_friend"] +other = "添加的用户不是好友,请先添加好友。" + +["err.server.group.file_helper_not_allowed"] +other = "不支持将“文件助手”加入群聊。" + +["err.server.group.category_space_mismatch"] +other = "群聊分组与空间不匹配。" + +["err.server.group.target_not_bot"] +other = "目标成员不是机器人。" + +["err.server.group.group_md_content_too_large"] +other = "GROUP.md 内容超过大小上限。" + +["err.server.group.auth_code_invalid"] +other = "授权码无效或已失效。" + +["err.server.group.invite_expired"] +other = "邀请链接已过期。" + +["err.server.group.creator_only"] +other = "只有群主才能执行此操作。" + +["err.server.group.manager_only"] +other = "只有群管理员才能执行此操作。" + +["err.server.group.creator_or_manager_only"] +other = "只有群主或管理员才能执行此操作。" + +["err.server.group.not_group_member"] +other = "你不是该群成员。" + +["err.server.group.view_forbidden"] +other = "你没有权限查看。" + +["err.server.group.member_cannot_remove"] +other = "普通成员无法移除群成员。" + +["err.server.group.cannot_remove_admin"] +other = "管理员不能移除其他管理员。" + +["err.server.group.cannot_remove_owner"] +other = "管理员不能移除群主。" + +["err.server.group.external_cannot_be_admin"] +other = "不能将外部成员设为管理员。" + +["err.server.group.external_cannot_be_owner"] +other = "不能将群主转让给外部成员。" + +["err.server.group.external_join_forbidden"] +other = "该群已禁止外部成员加入,请联系群管理员。" + +["err.server.group.invite_mode_cannot_add"] +other = "群已开启邀请模式,不能直接添加成员。" + +["err.server.group.invite_mode_cannot_join"] +other = "群已开启邀请模式,不能直接加入群聊。" + +["err.server.group.category_forbidden"] +other = "无权限使用此群聊分组。" + +["err.server.group.bot_ownership_denied"] +other = "你没有权限邀请该机器人。" + +["err.server.group.bot_not_in_space"] +other = "该机器人不属于你的空间。" + +["err.server.group.auth_code_user_mismatch"] +other = "授权码与当前登录用户不匹配。" + +["err.server.group.qrcode_member_only"] +other = "只有群成员才能生成二维码。" + +["err.server.group.not_found"] +other = "群不存在。" + +["err.server.group.member_not_in_group"] +other = "该成员不在群内。" + +["err.server.group.category_not_found"] +other = "群聊分组不存在。" + +["err.server.group.transfer_target_not_found"] +other = "转让的用户不存在或已注销。" + +["err.server.group.invite_not_found"] +other = "未查询到邀请信息。" + +["err.server.group.cannot_target_self"] +other = "不能对自己执行此操作。" + +["err.server.group.already_member"] +other = "你已在群内,无需重复加入。" + +["err.server.group.invite_status_invalid"] +other = "邀请信息不是待邀请状态。" + +["err.server.group.too_large_to_sync"] +other = "超大群不支持同步群成员。" + +["err.server.group.daily_create_limit"] +other = "今日建群数量已达上限。" + +["err.server.group.query_failed"] +other = "查询群数据失败。" + +["err.server.group.store_failed"] +other = "保存群数据失败。" + +["err.server.group.notify_failed"] +other = "发送群通知失败。" diff --git a/tools/i18nmarkers/server/active.en-US.toml b/tools/i18nmarkers/server/active.en-US.toml index 17a8546e..47cfd6af 100644 --- a/tools/i18nmarkers/server/active.en-US.toml +++ b/tools/i18nmarkers/server/active.en-US.toml @@ -6,6 +6,123 @@ # # CI rejects PRs that change codes.Register input without re-running extract. +["err.server.group.already_member"] +other = "You are already a member of this group." + +["err.server.group.auth_code_invalid"] +other = "The authorization code is invalid or has expired." + +["err.server.group.auth_code_user_mismatch"] +other = "The authorization code does not match the current user." + +["err.server.group.bot_not_in_space"] +other = "This bot does not belong to your space." + +["err.server.group.bot_ownership_denied"] +other = "You do not have permission to invite this bot." + +["err.server.group.cannot_remove_admin"] +other = "An administrator cannot remove another administrator." + +["err.server.group.cannot_remove_owner"] +other = "An administrator cannot remove the group owner." + +["err.server.group.cannot_target_self"] +other = "You cannot perform this action on yourself." + +["err.server.group.category_forbidden"] +other = "You do not have permission to use this group category." + +["err.server.group.category_not_found"] +other = "Group category not found." + +["err.server.group.category_space_mismatch"] +other = "The group category does not match the space." + +["err.server.group.creator_only"] +other = "Only the group owner can perform this action." + +["err.server.group.creator_or_manager_only"] +other = "Only the group owner or an administrator can perform this action." + +["err.server.group.daily_create_limit"] +other = "You have reached the daily group creation limit." + +["err.server.group.external_cannot_be_admin"] +other = "External members cannot be promoted to administrator." + +["err.server.group.external_cannot_be_owner"] +other = "Group ownership cannot be transferred to an external member." + +["err.server.group.external_join_forbidden"] +other = "This group does not allow external members to join. Please contact a group administrator." + +["err.server.group.file_helper_not_allowed"] +other = "The File Helper cannot be added to a group." + +["err.server.group.group_md_content_too_large"] +other = "The GROUP.md content exceeds the maximum size." + +["err.server.group.invite_expired"] +other = "The invite link has expired." + +["err.server.group.invite_mode_cannot_add"] +other = "Invite mode is enabled; members cannot be added directly." + +["err.server.group.invite_mode_cannot_join"] +other = "Invite mode is enabled; you cannot join the group directly." + +["err.server.group.invite_not_found"] +other = "Invite information not found." + +["err.server.group.invite_status_invalid"] +other = "The invite is not in a pending state." + +["err.server.group.manager_only"] +other = "Only a group administrator can perform this action." + +["err.server.group.member_cannot_remove"] +other = "Regular members cannot remove group members." + +["err.server.group.member_not_friend"] +other = "You can only add a friend to the group. Please add this user as a friend first." + +["err.server.group.member_not_in_group"] +other = "The member is not in this group." + +["err.server.group.not_found"] +other = "Group not found." + +["err.server.group.not_group_member"] +other = "You are not a member of this group." + +["err.server.group.notify_failed"] +other = "Failed to send group notification." + +["err.server.group.qrcode_member_only"] +other = "Only group members can generate a QR code." + +["err.server.group.query_failed"] +other = "Failed to query group data." + +["err.server.group.request_invalid"] +other = "Invalid request." + +["err.server.group.store_failed"] +other = "Failed to update group data." + +["err.server.group.target_not_bot"] +other = "The target member is not a bot." + +["err.server.group.too_large_to_sync"] +other = "This group is too large to sync members." + +["err.server.group.transfer_target_not_found"] +other = "The target user does not exist or has been deactivated." + +["err.server.group.view_forbidden"] +other = "You do not have permission to view this." + ["err.server.thread.creator_cannot_leave"] other = "Thread creator cannot leave the thread."