From 22a4de03838bddc5122ae32b4ecb94dc4e51cc82 Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 21 Mar 2026 16:15:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E7=94=A8=E6=88=B7=E7=9A=84=E4=B8=BB=E7=BB=84?= =?UTF-8?q?=E7=BB=87=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/model/dto/response/ojRankingResp.go | 1 + internal/service/system/ojRanking_test.go | 339 +++++++++++++++++++ internal/service/system/ojSvc.go | 46 ++- pkg/rankingcache/projection.go | 14 + 4 files changed, 397 insertions(+), 3 deletions(-) diff --git a/internal/model/dto/response/ojRankingResp.go b/internal/model/dto/response/ojRankingResp.go index 049402a..7734e74 100644 --- a/internal/model/dto/response/ojRankingResp.go +++ b/internal/model/dto/response/ojRankingResp.go @@ -12,6 +12,7 @@ type OJRankingListItem struct { RealName string `json:"real_name"` Avatar string `json:"avatar"` TotalPassed int `json:"total_passed"` + CurrentOrg *OrgSimpleItem `json:"current_org,omitempty"` PlatformDetails *OJRankingPlatformDetails `json:"platform_details,omitempty"` } diff --git a/internal/service/system/ojRanking_test.go b/internal/service/system/ojRanking_test.go index b6e89b1..ee784bb 100644 --- a/internal/service/system/ojRanking_test.go +++ b/internal/service/system/ojRanking_test.go @@ -11,6 +11,7 @@ import ( "personal_assistant/internal/model/entity" readmodel "personal_assistant/internal/model/readmodel" "personal_assistant/internal/repository/interfaces" + "personal_assistant/pkg/rankingcache" "personal_assistant/pkg/rediskey" "github.com/alicebob/miniredis/v2" @@ -81,6 +82,9 @@ func TestGetRankingListCurrentOrgFallsBackToAllMembersScope(t *testing.T) { if out.List[0].Avatar != "luogu-avatar" { t.Fatalf("avatar = %q, want %q", out.List[0].Avatar, "luogu-avatar") } + if out.List[0].CurrentOrg != nil { + t.Fatalf("CurrentOrg = %+v, want nil for current org scope", out.List[0].CurrentOrg) + } values, err := global.Redis.HGetAll(context.Background(), rediskey.RankingUserHashKey(2)).Result() if err != nil { @@ -193,6 +197,341 @@ func TestGetRankingListLanqiaoUsesLanqiaoScore(t *testing.T) { if out.List[0].Avatar != "base-avatar" { t.Fatalf("avatar = %q, want %q", out.List[0].Avatar, "base-avatar") } + if out.List[0].CurrentOrg != nil { + t.Fatalf("CurrentOrg = %+v, want nil for current org scope", out.List[0].CurrentOrg) + } +} + +func TestGetRankingListAllMembersIncludesCurrentOrg(t *testing.T) { + setupRankingRedis(t) + + if err := global.Redis.ZAdd(context.Background(), rediskey.RankingAllMembersZSetKey("leetcode"), &redis.Z{ + Score: 88, + Member: "2", + }).Err(); err != nil { + t.Fatalf("seed ranking zset error = %v", err) + } + + allMembersKey := consts.OrgBuiltinKeyAllMembers + currentOrgID := uint(100) + memberOrgID := uint(201) + svc := &OJService{ + userRepo: &stubRankingUserRepository{ + users: map[uint]*entity.User{ + 1: { + MODEL: entity.MODEL{ID: 1}, + Status: consts.UserStatusActive, + CurrentOrgID: ¤tOrgID, + CurrentOrg: &entity.Org{ + MODEL: entity.MODEL{ID: currentOrgID}, + IsBuiltin: true, + BuiltinKey: &allMembersKey, + }, + }, + }, + }, + roleRepo: &stubRankingRoleRepository{}, + rankingReadModelRepo: &stubRankingReadModelRepository{ + items: map[uint]*readmodel.Ranking{ + 2: { + UserID: 2, + Username: "alice", + Avatar: "base-avatar", + Status: consts.UserStatusActive, + CurrentOrgID: &memberOrgID, + CurrentOrgName: "算法一组", + LeetcodeIdentifier: "alice-lc", + LeetcodeAvatar: "leetcode-avatar", + LeetcodeScore: 88, + }, + }, + }, + } + + out, err := svc.GetRankingList(context.Background(), 1, &request.OJRankingListReq{ + Platform: "leetcode", + Scope: rankingScopeAllMembers, + Page: 1, + PageSize: 10, + }) + if err != nil { + t.Fatalf("GetRankingList() error = %v", err) + } + if out.Total != 1 || len(out.List) != 1 { + t.Fatalf("unexpected ranking result: total=%d len=%d", out.Total, len(out.List)) + } + if out.List[0].CurrentOrg == nil { + t.Fatal("CurrentOrg = nil, want current org info") + } + if out.List[0].CurrentOrg.ID != memberOrgID || out.List[0].CurrentOrg.Name != "算法一组" { + t.Fatalf("CurrentOrg = %+v, want id=%d name=%q", out.List[0].CurrentOrg, memberOrgID, "算法一组") + } +} + +func TestGetRankingListAllMembersSkipsEmptyCurrentOrg(t *testing.T) { + setupRankingRedis(t) + + if err := global.Redis.ZAdd(context.Background(), rediskey.RankingAllMembersZSetKey("luogu"), &redis.Z{ + Score: 55, + Member: "2", + }).Err(); err != nil { + t.Fatalf("seed ranking zset error = %v", err) + } + + allMembersKey := consts.OrgBuiltinKeyAllMembers + currentOrgID := uint(100) + svc := &OJService{ + userRepo: &stubRankingUserRepository{ + users: map[uint]*entity.User{ + 1: { + MODEL: entity.MODEL{ID: 1}, + Status: consts.UserStatusActive, + CurrentOrgID: ¤tOrgID, + CurrentOrg: &entity.Org{ + MODEL: entity.MODEL{ID: currentOrgID}, + IsBuiltin: true, + BuiltinKey: &allMembersKey, + }, + }, + }, + }, + roleRepo: &stubRankingRoleRepository{}, + rankingReadModelRepo: &stubRankingReadModelRepository{ + items: map[uint]*readmodel.Ranking{ + 2: { + UserID: 2, + Username: "alice", + Avatar: "base-avatar", + Status: consts.UserStatusActive, + LuoguIdentifier: "lg-2", + LuoguAvatar: "luogu-avatar", + LuoguScore: 55, + }, + }, + }, + } + + out, err := svc.GetRankingList(context.Background(), 1, &request.OJRankingListReq{ + Platform: "luogu", + Scope: rankingScopeAllMembers, + Page: 1, + PageSize: 10, + }) + if err != nil { + t.Fatalf("GetRankingList() error = %v", err) + } + if out.Total != 1 || len(out.List) != 1 { + t.Fatalf("unexpected ranking result: total=%d len=%d", out.Total, len(out.List)) + } + if out.List[0].CurrentOrg != nil { + t.Fatalf("CurrentOrg = %+v, want nil when user has no current org", out.List[0].CurrentOrg) + } +} + +func TestGetRankingListAllMembersBackfillsLegacyProjectionCache(t *testing.T) { + setupRankingRedis(t) + + if err := global.Redis.ZAdd(context.Background(), rediskey.RankingAllMembersZSetKey("leetcode"), &redis.Z{ + Score: 88, + Member: "2", + }).Err(); err != nil { + t.Fatalf("seed ranking zset error = %v", err) + } + if err := global.Redis.HSet(context.Background(), rediskey.RankingUserHashKey(2), map[string]any{ + "username": "alice", + "avatar": "base-avatar", + "active": "1", + "leetcode_identifier": "alice-lc", + "leetcode_avatar": "leetcode-avatar", + "leetcode_score": 88, + "luogu_identifier": "", + "luogu_avatar": "", + "luogu_score": 0, + "lanqiao_identifier": "", + "lanqiao_avatar": "", + "lanqiao_score": 0, + }).Err(); err != nil { + t.Fatalf("seed legacy projection hash error = %v", err) + } + + allMembersKey := consts.OrgBuiltinKeyAllMembers + currentOrgID := uint(100) + memberOrgID := uint(201) + svc := &OJService{ + userRepo: &stubRankingUserRepository{ + users: map[uint]*entity.User{ + 1: { + MODEL: entity.MODEL{ID: 1}, + Status: consts.UserStatusActive, + CurrentOrgID: ¤tOrgID, + CurrentOrg: &entity.Org{ + MODEL: entity.MODEL{ID: currentOrgID}, + IsBuiltin: true, + BuiltinKey: &allMembersKey, + }, + }, + }, + }, + roleRepo: &stubRankingRoleRepository{}, + rankingReadModelRepo: &stubRankingReadModelRepository{ + items: map[uint]*readmodel.Ranking{ + 2: { + UserID: 2, + Username: "alice", + Avatar: "base-avatar", + Status: consts.UserStatusActive, + CurrentOrgID: &memberOrgID, + CurrentOrgName: "智能小组", + LeetcodeIdentifier: "alice-lc", + LeetcodeAvatar: "leetcode-avatar", + LeetcodeScore: 88, + }, + }, + }, + } + + out, err := svc.GetRankingList(context.Background(), 1, &request.OJRankingListReq{ + Platform: "leetcode", + Scope: rankingScopeAllMembers, + Page: 1, + PageSize: 10, + }) + if err != nil { + t.Fatalf("GetRankingList() error = %v", err) + } + if out.Total != 1 || len(out.List) != 1 { + t.Fatalf("unexpected ranking result: total=%d len=%d", out.Total, len(out.List)) + } + if out.List[0].CurrentOrg == nil || out.List[0].CurrentOrg.Name != "智能小组" { + t.Fatalf("CurrentOrg = %+v, want 智能小组", out.List[0].CurrentOrg) + } + + values, err := global.Redis.HGetAll(context.Background(), rediskey.RankingUserHashKey(2)).Result() + if err != nil { + t.Fatalf("HGetAll() error = %v", err) + } + if strings.TrimSpace(values["current_org_name"]) != "智能小组" { + t.Fatalf("cached current_org_name = %q, want %q", values["current_org_name"], "智能小组") + } + if strings.TrimSpace(values["current_org_id"]) != "201" { + t.Fatalf("cached current_org_id = %q, want %q", values["current_org_id"], "201") + } +} + +func TestBuildRankingListKeepsValidPlatformAvatar(t *testing.T) { + list := buildRankingList( + []rankingEntry{{UserID: 7, Score: 42}}, + 0, + "leetcode", + rankingScopeCurrentOrg, + map[uint]*rankingcache.UserProjection{ + 7: { + UserID: 7, + Username: "alice", + Avatar: "https://cdn.example.com/base-avatar.png", + Active: true, + Leetcode: rankingcache.PlatformProfile{ + Identifier: "alice-lc", + Avatar: "https://cdn.example.com/alice-profile.png", + Score: 42, + }, + }, + }, + ) + + if len(list) != 1 { + t.Fatalf("len(list) = %d, want 1", len(list)) + } + if list[0].Avatar != "https://cdn.example.com/alice-profile.png" { + t.Fatalf("avatar = %q, want valid platform avatar", list[0].Avatar) + } +} + +func TestBuildRankingListFallsBackToBaseAvatarWhenPlatformAvatarLooksPlaceholder(t *testing.T) { + list := buildRankingList( + []rankingEntry{{UserID: 8, Score: 21}}, + 0, + "leetcode", + rankingScopeCurrentOrg, + map[uint]*rankingcache.UserProjection{ + 8: { + UserID: 8, + Username: "bob", + Avatar: "https://cdn.example.com/users/bob-real.png", + Active: true, + Leetcode: rankingcache.PlatformProfile{ + Identifier: "bob-lc", + Avatar: "https://assets.example.com/default-avatar.png", + Score: 21, + }, + }, + }, + ) + + if len(list) != 1 { + t.Fatalf("len(list) = %d, want 1", len(list)) + } + if list[0].Avatar != "https://cdn.example.com/users/bob-real.png" { + t.Fatalf("avatar = %q, want fallback base avatar", list[0].Avatar) + } +} + +func TestBuildRankingListClearsAvatarWhenAllCandidatesLookPlaceholder(t *testing.T) { + list := buildRankingList( + []rankingEntry{{UserID: 9, Score: 13}}, + 0, + "luogu", + rankingScopeCurrentOrg, + map[uint]*rankingcache.UserProjection{ + 9: { + UserID: 9, + Username: "carol", + Avatar: "https://cdn.example.com/img/no-avatar.svg", + Active: true, + Luogu: rankingcache.PlatformProfile{ + Identifier: "lg-9", + Avatar: "https://cdn.example.com/img/default_user.png", + Score: 13, + }, + }, + }, + ) + + if len(list) != 1 { + t.Fatalf("len(list) = %d, want 1", len(list)) + } + if list[0].Avatar != "" { + t.Fatalf("avatar = %q, want empty avatar", list[0].Avatar) + } +} + +func TestBuildRankingListDoesNotMisclassifyNormalAvatarURL(t *testing.T) { + list := buildRankingList( + []rankingEntry{{UserID: 10, Score: 8}}, + 0, + "luogu", + rankingScopeCurrentOrg, + map[uint]*rankingcache.UserProjection{ + 10: { + UserID: 10, + Username: "dave", + Avatar: "https://cdn.example.com/avatar/dave.png", + Active: true, + Luogu: rankingcache.PlatformProfile{ + Identifier: "lg-10", + Avatar: "https://cdn.example.com/avatar/contest/dave-final.png", + Score: 8, + }, + }, + }, + ) + + if len(list) != 1 { + t.Fatalf("len(list) = %d, want 1", len(list)) + } + if list[0].Avatar != "https://cdn.example.com/avatar/contest/dave-final.png" { + t.Fatalf("avatar = %q, want normal avatar url preserved", list[0].Avatar) + } } func setupRankingRedis(t *testing.T) { diff --git a/internal/service/system/ojSvc.go b/internal/service/system/ojSvc.go index 56a69ca..72fac38 100644 --- a/internal/service/system/ojSvc.go +++ b/internal/service/system/ojSvc.go @@ -1293,7 +1293,7 @@ func (s *OJService) GetRankingList( return nil, err } - list := buildRankingList(entries, int(start), platform, projectionMap) + list := buildRankingList(entries, int(start), platform, scope, projectionMap) myRank, err := loadMyRank(ctx, key, userID) if err != nil { return nil, err @@ -1318,6 +1318,19 @@ const ( rankingScopeOrg = "org" ) +var rankingPlaceholderAvatarKeywords = []string{ + "default-avatar", + "default_avatar", + "avatar-default", + "default-user", + "default_user", + "placeholder-avatar", + "placeholder_avatar", + "noavatar", + "no-avatar", + "usericon", +} + // normalizeRankingRequest 校验请求并规范分页与平台参数 func normalizeRankingRequest( userID uint, @@ -1569,6 +1582,7 @@ func buildRankingList( entries []rankingEntry, start int, platform string, + scope string, projectionMap map[uint]*rankingcache.UserProjection, ) []*resp.OJRankingListItem { list := make([]*resp.OJRankingListItem, 0, len(entries)) @@ -1581,9 +1595,9 @@ func buildRankingList( if strings.TrimSpace(profile.Identifier) == "" { continue } - avatar := strings.TrimSpace(profile.Avatar) + avatar := sanitizeRankingAvatar(profile.Avatar) if avatar == "" { - avatar = projection.Avatar + avatar = sanitizeRankingAvatar(projection.Avatar) } item := &resp.OJRankingListItem{ Rank: start + len(list) + 1, @@ -1592,6 +1606,15 @@ func buildRankingList( Avatar: avatar, TotalPassed: entry.Score, } + if scope == rankingScopeAllMembers && + projection.CurrentOrgID != nil && + *projection.CurrentOrgID > 0 && + strings.TrimSpace(projection.CurrentOrgName) != "" { + item.CurrentOrg = &resp.OrgSimpleItem{ + ID: *projection.CurrentOrgID, + Name: strings.TrimSpace(projection.CurrentOrgName), + } + } if platform == "luogu" { item.PlatformDetails = &resp.OJRankingPlatformDetails{ Luogu: entry.Score, @@ -1610,6 +1633,23 @@ func buildRankingList( return list } +// sanitizeRankingAvatar only affects leaderboard responses so stored avatar values remain untouched. +func sanitizeRankingAvatar(raw string) string { + avatar := strings.TrimSpace(raw) + if avatar == "" { + return "" + } + + lowerAvatar := strings.ToLower(avatar) + for _, keyword := range rankingPlaceholderAvatarKeywords { + if strings.Contains(lowerAvatar, keyword) { + return "" + } + } + + return avatar +} + // loadMyRank 获取当前用户在排行榜中的名次与分数 func loadMyRank( ctx context.Context, diff --git a/pkg/rankingcache/projection.go b/pkg/rankingcache/projection.go index 796f18e..afd442c 100644 --- a/pkg/rankingcache/projection.go +++ b/pkg/rankingcache/projection.go @@ -119,6 +119,11 @@ func ProjectionFromHash(userID uint, values map[string]string) (*UserProjection, if userID == 0 || len(values) == 0 { return nil, false } + // 兼容旧版缓存:current_org 字段是后来加入的,如果 hash 里缺少这些字段, + // 说明当前缓存不是完整投影,需要回源读模型并回填 Redis。 + if !hasProjectionOrgFields(values) { + return nil, false + } out := &UserProjection{ UserID: userID, @@ -153,6 +158,15 @@ func ProjectionFromHash(userID uint, values map[string]string) (*UserProjection, return out, true } +func hasProjectionOrgFields(values map[string]string) bool { + if len(values) == 0 { + return false + } + _, hasOrgID := values[hashFieldCurrentOrgID] + _, hasOrgName := values[hashFieldCurrentOrgName] + return hasOrgID && hasOrgName +} + // Platform 返回指定平台的用户资料,默认为洛谷。 func (p *UserProjection) Platform(platform string) PlatformProfile { switch NormalizePlatform(platform) { From def442d448aa109d04fde33acd547a22649f2e4e Mon Sep 17 00:00:00 2001 From: wang Date: Sat, 21 Mar 2026 17:10:03 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=BB=84=E7=BB=87?= =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/controller/system/orgCtrl.go | 2 + internal/controller/system/userCtrl.go | 1 + internal/model/dto/response/orgResp.go | 22 +-- internal/model/dto/response/userResp.go | 1 + internal/model/entity/user.go | 2 + internal/model/readmodel/org.go | 2 + internal/service/system/orgSvc.go | 13 +- .../service/system/orgSvc_builtin_test.go | 152 ++++++++++++++++++ internal/service/system/userSvc.go | 30 ++++ .../system/userSvc_super_admin_test.go | 121 ++++++++++++++ 10 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 internal/service/system/orgSvc_builtin_test.go create mode 100644 internal/service/system/userSvc_super_admin_test.go diff --git a/internal/controller/system/orgCtrl.go b/internal/controller/system/orgCtrl.go index 3e45e2a..e14d205 100644 --- a/internal/controller/system/orgCtrl.go +++ b/internal/controller/system/orgCtrl.go @@ -280,6 +280,8 @@ func readModelToOrgItem(org *readmodel.OrgWithMemberCount) *resp.OrgItem { Avatar: org.Avatar, AvatarID: org.AvatarID, OwnerID: org.OwnerID, + IsBuiltin: org.IsBuiltin, + BuiltinKey: org.BuiltinKey, MemberCount: org.MemberCount, CreatedAt: org.CreatedAt.Format(time.DateTime), UpdatedAt: org.UpdatedAt.Format(time.DateTime), diff --git a/internal/controller/system/userCtrl.go b/internal/controller/system/userCtrl.go index d971298..fc41c72 100644 --- a/internal/controller/system/userCtrl.go +++ b/internal/controller/system/userCtrl.go @@ -386,6 +386,7 @@ func entityToUserDetail(user *entity.User) *resp.UserDetailItem { Register: int(user.Register), Freeze: user.Freeze, Status: int(user.Status), + IsSuperAdmin: user.IsSuperAdmin, CreatedAt: user.CreatedAt.Format(time.DateTime), UpdatedAt: user.UpdatedAt.Format(time.DateTime), CurrentOrgID: user.CurrentOrgID, diff --git a/internal/model/dto/response/orgResp.go b/internal/model/dto/response/orgResp.go index fb0bc7b..962c3fe 100644 --- a/internal/model/dto/response/orgResp.go +++ b/internal/model/dto/response/orgResp.go @@ -2,16 +2,18 @@ package response // OrgItem 组织信息项(用于列表/详情响应) type OrgItem struct { - ID uint `json:"id"` // 组织ID - Name string `json:"name"` // 组织名称 - Description string `json:"description"` // 组织描述 - Code string `json:"code"` // 加入邀请码 - Avatar string `json:"avatar"` // 组织头像URL - AvatarID *uint `json:"avatar_id"` // 组织头像图片ID(可空) - OwnerID uint `json:"owner_id"` // 创建者/负责人ID - MemberCount int64 `json:"member_count"` // 组织活跃成员数 - CreatedAt string `json:"created_at"` // 创建时间 - UpdatedAt string `json:"updated_at"` // 更新时间 + ID uint `json:"id"` // 组织ID + Name string `json:"name"` // 组织名称 + Description string `json:"description"` // 组织描述 + Code string `json:"code"` // 加入邀请码 + Avatar string `json:"avatar"` // 组织头像URL + AvatarID *uint `json:"avatar_id"` // 组织头像图片ID(可空) + OwnerID uint `json:"owner_id"` // 创建者/负责人ID + IsBuiltin bool `json:"is_builtin"` // 是否系统内置组织 + BuiltinKey *string `json:"builtin_key,omitempty"` // 内置组织标识 + MemberCount int64 `json:"member_count"` // 组织活跃成员数 + CreatedAt string `json:"created_at"` // 创建时间 + UpdatedAt string `json:"updated_at"` // 更新时间 } // OrgListResp 组织列表响应 diff --git a/internal/model/dto/response/userResp.go b/internal/model/dto/response/userResp.go index a8b9900..da2f52f 100644 --- a/internal/model/dto/response/userResp.go +++ b/internal/model/dto/response/userResp.go @@ -20,6 +20,7 @@ type UserDetailItem struct { Register int `json:"register"` Freeze bool `json:"freeze"` Status int `json:"status"` + IsSuperAdmin bool `json:"is_super_admin"` DisabledAt string `json:"disabled_at,omitempty"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` diff --git a/internal/model/entity/user.go b/internal/model/entity/user.go index d087998..b8fff10 100644 --- a/internal/model/entity/user.go +++ b/internal/model/entity/user.go @@ -36,4 +36,6 @@ type User struct { CurrentOrgID *uint `json:"current_org_id" gorm:"index;comment:'当前组织ID(可空)'"` CurrentOrg *Org `json:"current_org,omitempty" gorm:"foreignKey:CurrentOrgID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;"` + // IsSuperAdmin 仅用于响应序列化,不持久化到数据库。 + IsSuperAdmin bool `json:"is_super_admin" gorm:"-"` } diff --git a/internal/model/readmodel/org.go b/internal/model/readmodel/org.go index 4fad4fa..8b7f873 100644 --- a/internal/model/readmodel/org.go +++ b/internal/model/readmodel/org.go @@ -11,6 +11,8 @@ type OrgWithMemberCount struct { Avatar string AvatarID *uint OwnerID uint + IsBuiltin bool + BuiltinKey *string MemberCount int64 CreatedAt time.Time UpdatedAt time.Time diff --git a/internal/service/system/orgSvc.go b/internal/service/system/orgSvc.go index 04b7321..a29c4c6 100644 --- a/internal/service/system/orgSvc.go +++ b/internal/service/system/orgSvc.go @@ -326,7 +326,16 @@ func (s *OrgService) UpdateOrg( return errors.New(errors.CodeOrgNotFound) } if isAllMembersBuiltinOrg(org) { - return errors.New(errors.CodeOrgBuiltinProtected) + if s.authorizationService == nil { + return errors.NewWithMsg(errors.CodeInternalError, "授权服务未初始化") + } + isSuperAdmin, err := s.authorizationService.IsSuperAdmin(ctx, userID) + if err != nil { + return errors.Wrap(errors.CodeDBError, err) + } + if !isSuperAdmin { + return errors.New(errors.CodeOrgBuiltinProtected) + } } // 记录旧头像ID(后续若更换头像,用于软删除旧头像资源)。 @@ -1164,6 +1173,8 @@ func (s *OrgService) buildOrgReadModels( Avatar: org.Avatar, AvatarID: org.AvatarID, OwnerID: org.OwnerID, + IsBuiltin: org.IsBuiltin, + BuiltinKey: org.BuiltinKey, MemberCount: counts[org.ID], CreatedAt: org.CreatedAt, UpdatedAt: org.UpdatedAt, diff --git a/internal/service/system/orgSvc_builtin_test.go b/internal/service/system/orgSvc_builtin_test.go new file mode 100644 index 0000000..5e4a82d --- /dev/null +++ b/internal/service/system/orgSvc_builtin_test.go @@ -0,0 +1,152 @@ +package system + +import ( + "context" + "testing" + + "personal_assistant/internal/model/consts" + "personal_assistant/internal/model/dto/request" + "personal_assistant/internal/model/entity" + bizerrors "personal_assistant/pkg/errors" +) + +func TestOrgServiceGetOrgQueriesIncludeBuiltinMetadata(t *testing.T) { + ctx := context.Background() + env := newAuthorizationTestEnv(t) + + owner := createUser(t, env, "3101") + org := createOrg(t, env, owner.ID) + builtinKey := consts.OrgBuiltinKeyAllMembers + org.IsBuiltin = true + org.BuiltinKey = &builtinKey + if err := env.db.Save(org).Error; err != nil { + t.Fatalf("save builtin org: %v", err) + } + seedOrgMember(t, env, org.ID, owner.ID, consts.OrgMemberStatusActive) + + items, total, err := env.orgService.GetOrgList(ctx, owner.ID, 1, 10, "") + if err != nil { + t.Fatalf("GetOrgList() error = %v", err) + } + if total != 1 || len(items) != 1 { + t.Fatalf("GetOrgList() returned total=%d len=%d, want 1/1", total, len(items)) + } + if !items[0].IsBuiltin { + t.Fatal("GetOrgList() is_builtin = false, want true") + } + if items[0].BuiltinKey == nil || *items[0].BuiltinKey != builtinKey { + t.Fatalf("GetOrgList() builtin_key = %v, want %q", items[0].BuiltinKey, builtinKey) + } + + detail, err := env.orgService.GetOrgDetail(ctx, owner.ID, org.ID) + if err != nil { + t.Fatalf("GetOrgDetail() error = %v", err) + } + if !detail.IsBuiltin { + t.Fatal("GetOrgDetail() is_builtin = false, want true") + } + if detail.BuiltinKey == nil || *detail.BuiltinKey != builtinKey { + t.Fatalf("GetOrgDetail() builtin_key = %v, want %q", detail.BuiltinKey, builtinKey) + } +} + +func TestOrgServiceUpdateOrgAllowsOwnerForRegularOrg(t *testing.T) { + ctx := context.Background() + env := newAuthorizationTestEnv(t) + + owner := createUser(t, env, "3102") + org := createOrg(t, env, owner.ID) + + name := "更新后的组织" + description := "新的组织描述" + code := "ORG-3102-NEW" + req := &request.UpdateOrgReq{ + Name: &name, + Description: &description, + Code: &code, + } + + if err := env.orgService.UpdateOrg(ctx, owner.ID, org.ID, req); err != nil { + t.Fatalf("UpdateOrg() error = %v", err) + } + + var refreshed entity.Org + if err := env.db.First(&refreshed, org.ID).Error; err != nil { + t.Fatalf("reload org: %v", err) + } + if refreshed.Name != name || refreshed.Description != description || refreshed.Code != code { + t.Fatalf("updated org = %+v, want name=%q description=%q code=%q", refreshed, name, description, code) + } +} + +func TestOrgServiceUpdateOrgRejectsBuiltinAllMembersForNonSuperAdmin(t *testing.T) { + ctx := context.Background() + env := newAuthorizationTestEnv(t) + + owner := createUser(t, env, "3103") + org := createBuiltinAllMembersOrg(t, env, owner.ID) + name := "禁止修改" + + err := env.orgService.UpdateOrg(ctx, owner.ID, org.ID, &request.UpdateOrgReq{Name: &name}) + assertBizCode(t, err, bizerrors.CodeOrgBuiltinProtected) +} + +func TestOrgServiceUpdateOrgAllowsBuiltinAllMembersForSuperAdmin(t *testing.T) { + ctx := context.Background() + env := newAuthorizationTestEnv(t) + + owner := createUser(t, env, "3104") + admin := createUser(t, env, "3105") + grantGlobalRole(t, env, admin.ID, consts.RoleCodeSuperAdmin) + org := createBuiltinAllMembersOrg(t, env, owner.ID) + + description := "超级管理员已更新" + code := "ORG-3105-SUPER" + req := &request.UpdateOrgReq{ + Description: &description, + Code: &code, + } + + if err := env.orgService.UpdateOrg(ctx, admin.ID, org.ID, req); err != nil { + t.Fatalf("UpdateOrg() error = %v", err) + } + + var refreshed entity.Org + if err := env.db.First(&refreshed, org.ID).Error; err != nil { + t.Fatalf("reload org: %v", err) + } + if refreshed.Description != description || refreshed.Code != code { + t.Fatalf("updated builtin org = %+v, want description=%q code=%q", refreshed, description, code) + } +} + +func TestOrgServiceBuiltinOrgProtectionStillBlocksDeleteAndMemberMutationForSuperAdmin(t *testing.T) { + ctx := context.Background() + env := newAuthorizationTestEnv(t) + + owner := createUser(t, env, "3106") + admin := createUser(t, env, "3107") + target := createUser(t, env, "3108") + grantGlobalRole(t, env, admin.ID, consts.RoleCodeSuperAdmin) + org := createBuiltinAllMembersOrg(t, env, owner.ID) + seedOrgMember(t, env, org.ID, target.ID, consts.OrgMemberStatusActive) + + err := env.orgService.DeleteOrg(ctx, admin.ID, org.ID, true) + assertBizCode(t, err, bizerrors.CodeOrgBuiltinProtected) + + err = env.orgService.KickMember(ctx, admin.ID, org.ID, target.ID, "kick") + assertBizCode(t, err, bizerrors.CodeOrgBuiltinProtected) +} + +func createBuiltinAllMembersOrg(t *testing.T, env *authorizationTestEnv, ownerID uint) *entity.Org { + t.Helper() + + org := createOrg(t, env, ownerID) + builtinKey := consts.OrgBuiltinKeyAllMembers + org.IsBuiltin = true + org.BuiltinKey = &builtinKey + if err := env.db.Save(org).Error; err != nil { + t.Fatalf("save builtin org: %v", err) + } + return org +} diff --git a/internal/service/system/userSvc.go b/internal/service/system/userSvc.go index 8bc719e..70cc061 100644 --- a/internal/service/system/userSvc.go +++ b/internal/service/system/userSvc.go @@ -59,6 +59,21 @@ func NewUserService( } } +func (u *UserService) populateUserSuperAdminFlag(ctx context.Context, user *entity.User) error { + if user == nil { + return nil + } + if u.authorizationService == nil { + return bizerrors.NewWithMsg(bizerrors.CodeInternalError, "授权服务未初始化") + } + isSuperAdmin, err := u.authorizationService.IsSuperAdmin(ctx, user.ID) + if err != nil { + return bizerrors.Wrap(bizerrors.CodeDBError, err) + } + user.IsSuperAdmin = isSuperAdmin + return nil +} + type userRoleMatrixLevel string const ( @@ -221,6 +236,9 @@ func (u *UserService) Register( if err != nil { return nil, bizerrors.Wrap(bizerrors.CodeDBError, err) } + if err := u.populateUserSuperAdminFlag(ctx, fullUser); err != nil { + return nil, err + } return fullUser, nil } @@ -256,6 +274,9 @@ func (u *UserService) PhoneLogin( return nil, bizerrors.New(bizerrors.CodeUserDisabled) } + if err := u.populateUserSuperAdminFlag(ctx, user); err != nil { + return nil, err + } return user, nil } @@ -354,6 +375,9 @@ func (u *UserService) UpdateProfile( if err != nil { return nil, err } + if err := u.populateUserSuperAdminFlag(ctx, user); err != nil { + return nil, err + } return user, nil } @@ -450,6 +474,9 @@ func (u *UserService) ChangePhone( return nil, errors.New("更新失败") } + if err := u.populateUserSuperAdminFlag(ctx, user); err != nil { + return nil, err + } return user, nil } @@ -552,6 +579,9 @@ func (u *UserService) GetUserDetail( if err != nil { return nil, err } + if err := u.populateUserSuperAdminFlag(ctx, user); err != nil { + return nil, err + } return user, nil } diff --git a/internal/service/system/userSvc_super_admin_test.go b/internal/service/system/userSvc_super_admin_test.go new file mode 100644 index 0000000..56d57bb --- /dev/null +++ b/internal/service/system/userSvc_super_admin_test.go @@ -0,0 +1,121 @@ +package system + +import ( + "context" + "fmt" + "testing" + + "personal_assistant/internal/model/consts" + "personal_assistant/internal/model/dto/request" + "personal_assistant/pkg/util" + + "github.com/mojocn/base64Captcha" +) + +func TestUserServicePhoneLoginPopulatesIsSuperAdmin(t *testing.T) { + ctx := context.Background() + + cases := []struct { + name string + label string + grantSuperAdmin bool + want bool + }{ + {name: "super admin", label: "3201", grantSuperAdmin: true, want: true}, + {name: "regular user", label: "3202", grantSuperAdmin: false, want: false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + env := newAuthorizationTestEnv(t) + user := createUser(t, env, tc.label) + rawPassword := "Pass1234" + user.Password = util.BcryptHash(rawPassword) + if err := env.db.Save(user).Error; err != nil { + t.Fatalf("save user password: %v", err) + } + if tc.grantSuperAdmin { + grantGlobalRole(t, env, user.ID, consts.RoleCodeSuperAdmin) + } + + captchaID := fmt.Sprintf("%s-login", t.Name()) + captchaAnswer := "123456" + mustSetCaptcha(t, captchaID, captchaAnswer) + + loggedIn, err := env.userService.PhoneLogin(ctx, &request.LoginReq{ + Phone: user.Phone, + Password: rawPassword, + Captcha: captchaAnswer, + CaptchaID: captchaID, + }) + if err != nil { + t.Fatalf("PhoneLogin() error = %v", err) + } + if loggedIn.IsSuperAdmin != tc.want { + t.Fatalf("PhoneLogin() is_super_admin = %v, want %v", loggedIn.IsSuperAdmin, tc.want) + } + }) + } +} + +func TestUserServiceUpdateProfilePreservesIsSuperAdmin(t *testing.T) { + ctx := context.Background() + env := newAuthorizationTestEnv(t) + + user := createUser(t, env, "3203") + grantGlobalRole(t, env, user.ID, consts.RoleCodeSuperAdmin) + signature := "新的签名" + + updated, err := env.userService.UpdateProfile(ctx, user.ID, &request.UpdateProfileReq{ + Signature: &signature, + }) + if err != nil { + t.Fatalf("UpdateProfile() error = %v", err) + } + if !updated.IsSuperAdmin { + t.Fatal("UpdateProfile() is_super_admin = false, want true") + } + if updated.Signature != signature { + t.Fatalf("UpdateProfile() signature = %q, want %q", updated.Signature, signature) + } +} + +func TestUserServiceChangePhonePreservesIsSuperAdmin(t *testing.T) { + ctx := context.Background() + env := newAuthorizationTestEnv(t) + + user := createUser(t, env, "3204") + rawPassword := "Pass5678" + user.Password = util.BcryptHash(rawPassword) + if err := env.db.Save(user).Error; err != nil { + t.Fatalf("save user password: %v", err) + } + grantGlobalRole(t, env, user.ID, consts.RoleCodeSuperAdmin) + + captchaID := fmt.Sprintf("%s-change-phone", t.Name()) + captchaAnswer := "654321" + mustSetCaptcha(t, captchaID, captchaAnswer) + + updated, err := env.userService.ChangePhone(ctx, user.ID, &request.ChangePhoneReq{ + Password: rawPassword, + NewPhone: "13900003204", + Captcha: captchaAnswer, + CaptchaID: captchaID, + }) + if err != nil { + t.Fatalf("ChangePhone() error = %v", err) + } + if !updated.IsSuperAdmin { + t.Fatal("ChangePhone() is_super_admin = false, want true") + } + if updated.Phone != "13900003204" { + t.Fatalf("ChangePhone() phone = %q, want %q", updated.Phone, "13900003204") + } +} + +func mustSetCaptcha(t *testing.T, captchaID, answer string) { + t.Helper() + if err := base64Captcha.DefaultMemStore.Set(captchaID, answer); err != nil { + t.Fatalf("set captcha: %v", err) + } +}