diff --git a/api/proto/user/v1/host.pb.go b/api/proto/user/v1/host.pb.go index 57d6d79e..80e68078 100644 --- a/api/proto/user/v1/host.pb.go +++ b/api/proto/user/v1/host.pb.go @@ -2513,6 +2513,7 @@ type GetBDProfileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` UserId int64 `protobuf:"varint,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Role string `protobuf:"bytes,3,opt,name=role,proto3" json:"role,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2561,6 +2562,13 @@ func (x *GetBDProfileRequest) GetUserId() int64 { return 0 } +func (x *GetBDProfileRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + type GetBDProfileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` BdProfile *BDProfile `protobuf:"bytes,1,opt,name=bd_profile,json=bdProfile,proto3" json:"bd_profile,omitempty"` @@ -3565,6 +3573,7 @@ type SetBDStatusRequest struct { TargetUserId int64 `protobuf:"varint,4,opt,name=target_user_id,json=targetUserId,proto3" json:"target_user_id,omitempty"` Status string `protobuf:"bytes,5,opt,name=status,proto3" json:"status,omitempty"` Reason string `protobuf:"bytes,6,opt,name=reason,proto3" json:"reason,omitempty"` + Role string `protobuf:"bytes,7,opt,name=role,proto3" json:"role,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -3641,6 +3650,13 @@ func (x *SetBDStatusRequest) GetReason() string { return "" } +func (x *SetBDStatusRequest) GetRole() string { + if x != nil { + return x.Role + } + return "" +} + type SetBDStatusResponse struct { state protoimpl.MessageState `protogen:"open.v1"` BdProfile *BDProfile `protobuf:"bytes,1,opt,name=bd_profile,json=bdProfile,proto3" json:"bd_profile,omitempty"` @@ -4717,10 +4733,11 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\"W\n" + "\x16GetHostProfileResponse\x12=\n" + - "\fhost_profile\x18\x01 \x01(\v2\x1a.hyapp.user.v1.HostProfileR\vhostProfile\"^\n" + + "\fhost_profile\x18\x01 \x01(\v2\x1a.hyapp.user.v1.HostProfileR\vhostProfile\"r\n" + "\x13GetBDProfileRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x17\n" + - "\auser_id\x18\x02 \x01(\x03R\x06userId\"O\n" + + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x12\n" + + "\x04role\x18\x03 \x01(\tR\x04role\"O\n" + "\x14GetBDProfileResponse\x127\n" + "\n" + "bd_profile\x18\x01 \x01(\v2\x18.hyapp.user.v1.BDProfileR\tbdProfile\"f\n" + @@ -4785,7 +4802,7 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\x06reason\x18\x06 \x01(\tR\x06reason\"K\n" + "\x10CreateBDResponse\x127\n" + "\n" + - "bd_profile\x18\x01 \x01(\v2\x18.hyapp.user.v1.BDProfileR\tbdProfile\"\xdd\x01\n" + + "bd_profile\x18\x01 \x01(\v2\x18.hyapp.user.v1.BDProfileR\tbdProfile\"\xf1\x01\n" + "\x12SetBDStatusRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x1d\n" + "\n" + @@ -4793,7 +4810,8 @@ const file_proto_user_v1_host_proto_rawDesc = "" + "\radmin_user_id\x18\x03 \x01(\x03R\vadminUserId\x12$\n" + "\x0etarget_user_id\x18\x04 \x01(\x03R\ftargetUserId\x12\x16\n" + "\x06status\x18\x05 \x01(\tR\x06status\x12\x16\n" + - "\x06reason\x18\x06 \x01(\tR\x06reason\"N\n" + + "\x06reason\x18\x06 \x01(\tR\x06reason\x12\x12\n" + + "\x04role\x18\a \x01(\tR\x04role\"N\n" + "\x13SetBDStatusResponse\x127\n" + "\n" + "bd_profile\x18\x01 \x01(\v2\x18.hyapp.user.v1.BDProfileR\tbdProfile\"\xca\x01\n" + diff --git a/api/proto/user/v1/host.proto b/api/proto/user/v1/host.proto index 586ef814..bf22a859 100644 --- a/api/proto/user/v1/host.proto +++ b/api/proto/user/v1/host.proto @@ -284,6 +284,7 @@ message GetHostProfileResponse { message GetBDProfileRequest { RequestMeta meta = 1; int64 user_id = 2; + string role = 3; } message GetBDProfileResponse { @@ -389,6 +390,7 @@ message SetBDStatusRequest { int64 target_user_id = 4; string status = 5; string reason = 6; + string role = 7; } message SetBDStatusResponse { diff --git a/scripts/mysql/038_split_bd_leader_profiles.sql b/scripts/mysql/038_split_bd_leader_profiles.sql new file mode 100644 index 00000000..f6d3771f --- /dev/null +++ b/scripts/mysql/038_split_bd_leader_profiles.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS bd_leader_profiles ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID', + region_id BIGINT NOT NULL COMMENT '区域 ID', + status VARCHAR(32) NOT NULL COMMENT '业务状态', + created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + KEY idx_bd_leader_profiles_region_status (app_code, region_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表'; + +INSERT INTO bd_leader_profiles ( + app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms +) +SELECT app_code, user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms +FROM bd_profiles +WHERE role = 'bd_leader' +ON DUPLICATE KEY UPDATE + region_id = VALUES(region_id), + status = VALUES(status), + created_by_user_id = VALUES(created_by_user_id), + created_at_ms = VALUES(created_at_ms), + updated_at_ms = VALUES(updated_at_ms); + +DELETE FROM bd_profiles +WHERE role = 'bd_leader'; diff --git a/server/admin/internal/integration/userclient/client.go b/server/admin/internal/integration/userclient/client.go index aef89d21..3d2b291c 100644 --- a/server/admin/internal/integration/userclient/client.go +++ b/server/admin/internal/integration/userclient/client.go @@ -317,6 +317,7 @@ type SetBDStatusRequest struct { CommandID string AdminUserID int64 TargetUserID int64 + Role string Status string Reason string } @@ -517,6 +518,7 @@ func (c *GRPCClient) SetBDStatus(ctx context.Context, req SetBDStatusRequest) (* CommandId: req.CommandID, AdminUserId: req.AdminUserID, TargetUserId: req.TargetUserID, + Role: req.Role, Status: req.Status, Reason: req.Reason, }) diff --git a/server/admin/internal/modules/hostorg/handler.go b/server/admin/internal/modules/hostorg/handler.go index e0026ae4..38f3fc8f 100644 --- a/server/admin/internal/modules/hostorg/handler.go +++ b/server/admin/internal/modules/hostorg/handler.go @@ -100,7 +100,7 @@ func (h *Handler) CreateBDLeader(c *gin.Context) { response.BadRequest(c, err.Error()) return } - writeHostOrgAuditLog(c, h.audit, "create-bd-leader", "bd_profiles", profile.UserID, + writeHostOrgAuditLog(c, h.audit, "create-bd-leader", "bd_leader_profiles", profile.UserID, fmt.Sprintf("command_id=%s user_id=%d region_id=%d position_alias=%q reason=%q", strings.TrimSpace(req.CommandID), profile.UserID, profile.RegionID, strings.TrimSpace(req.PositionAlias), strings.TrimSpace(req.Reason))) response.Created(c, profile) } @@ -131,16 +131,24 @@ func (h *Handler) SetBDStatus(c *gin.Context) { response.BadRequest(c, "BD 状态参数不正确") return } - profile, err := h.service.SetBDStatus(c.Request.Context(), adminActorID(c), targetUserID, middleware.CurrentRequestID(c), req) + role, auditTarget := bdRoleFromStatusRoute(c) + profile, err := h.service.SetBDStatus(c.Request.Context(), adminActorID(c), targetUserID, middleware.CurrentRequestID(c), role, req) if err != nil { response.BadRequest(c, err.Error()) return } - writeHostOrgAuditLog(c, h.audit, "set-bd-status", "bd_profiles", profile.UserID, + writeHostOrgAuditLog(c, h.audit, "set-bd-status", auditTarget, profile.UserID, fmt.Sprintf("command_id=%s user_id=%d status=%s reason=%q", strings.TrimSpace(req.CommandID), profile.UserID, profile.Status, strings.TrimSpace(req.Reason))) response.OK(c, profile) } +func bdRoleFromStatusRoute(c *gin.Context) (string, string) { + if strings.HasPrefix(c.FullPath(), "/admin/bd-leaders/") { + return "bd_leader", "bd_leader_profiles" + } + return "bd", "bd_profiles" +} + func (h *Handler) UpdateBDLeaderPositionAlias(c *gin.Context) { targetUserID, ok := parseInt64ID(c, "user_id") if !ok { @@ -171,7 +179,7 @@ func (h *Handler) DeleteBDLeader(c *gin.Context) { response.BadRequest(c, err.Error()) return } - writeHostOrgAuditLog(c, h.audit, "delete-bd-leader", "bd_profiles", profile.UserID, + writeHostOrgAuditLog(c, h.audit, "delete-bd-leader", "bd_leader_profiles", profile.UserID, fmt.Sprintf("user_id=%d", profile.UserID)) response.OK(c, profile) } diff --git a/server/admin/internal/modules/hostorg/reader.go b/server/admin/internal/modules/hostorg/reader.go index c3585640..ee119d99 100644 --- a/server/admin/internal/modules/hostorg/reader.go +++ b/server/admin/internal/modules/hostorg/reader.go @@ -53,11 +53,14 @@ type CoinSellerSalaryRateTier struct { UpdatedAtMs int64 `json:"updatedAtMs"` } -// ListBDProfiles 只读 user-service 的 BD 事实表,避免后台列表需求改动 user-service 发布物。 +// ListBDProfiles 只读 user-service 的 BD/BD Leader 事实表,后台列表按角色选择物理表,避免把两种身份重新混在一起。 func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role string) ([]*userclient.BDProfile, int64, error) { if r == nil || r.db == nil { return nil, 0, errUserDBNotConfigured() } + if role == "bd_leader" { + return r.listBDLeaderProfiles(ctx, query) + } appCode := appctx.FromContext(ctx) whereSQL := ` FROM bd_profiles bp @@ -156,6 +159,101 @@ func (r *Reader) ListBDProfiles(ctx context.Context, query listQuery, role strin return items, total, nil } +func (r *Reader) listBDLeaderProfiles(ctx context.Context, query listQuery) ([]*userclient.BDProfile, int64, error) { + appCode := appctx.FromContext(ctx) + if query.ParentLeaderUserID > 0 { + // Leader 身份没有上级 Leader 字段;带父级筛选时直接返回空结果,避免误用普通 BD 的父级语义。 + return []*userclient.BDProfile{}, 0, nil + } + whereSQL := ` + FROM bd_leader_profiles bp + LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id + LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id + LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id + WHERE bp.app_code = ?` + args := []any{appCode} + if query.Status != "" { + whereSQL += " AND bp.status = ?" + args = append(args, query.Status) + } + if query.RegionID > 0 { + whereSQL += " AND bp.region_id = ?" + args = append(args, query.RegionID) + } + if keyword := strings.TrimSpace(query.Keyword); keyword != "" { + like := "%" + keyword + "%" + whereSQL += " AND (CAST(bp.user_id AS CHAR) LIKE ? OR u.current_display_user_id LIKE ? OR u.username LIKE ? OR r.name LIKE ?)" + args = append(args, like, like, like, like) + } + + total, err := countRows(ctx, r.db, whereSQL, args...) + if err != nil { + return nil, 0, err + } + rows, err := r.db.QueryContext(ctx, fmt.Sprintf(` + SELECT bp.user_id, 'bd_leader', bp.region_id, 0, + bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms, + COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), + COALESCE(u.avatar, ''), COALESCE(r.name, ''), + '', '', '', + COALESCE(creator.current_display_user_id, ''), COALESCE(creator.username, ''), + COALESCE(creator.avatar, ''), + ( + SELECT COUNT(1) + FROM bd_profiles child + WHERE child.app_code = bp.app_code + AND child.role = 'bd' + AND child.parent_leader_user_id = bp.user_id + ) + %s + ORDER BY bp.created_at_ms DESC, bp.user_id DESC + LIMIT ? OFFSET ? + `, whereSQL), append(args, query.PageSize, offset(query.Page, query.PageSize))...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + items := make([]*userclient.BDProfile, 0, query.PageSize) + for rows.Next() { + item := &userclient.BDProfile{} + if err := rows.Scan( + &item.UserID, + &item.Role, + &item.RegionID, + &item.ParentLeaderUserID, + &item.Status, + &item.CreatedByUserID, + &item.CreatedAtMs, + &item.UpdatedAtMs, + &item.DisplayUserID, + &item.Username, + &item.Avatar, + &item.RegionName, + &item.ParentLeaderDisplayID, + &item.ParentLeaderUsername, + &item.ParentLeaderAvatar, + &item.CreatedByDisplayUserID, + &item.CreatedByUsername, + &item.CreatedByAvatar, + &item.SubBDCount, + ); err != nil { + return nil, 0, err + } + items = append(items, item) + } + if err := rows.Err(); err != nil { + return nil, 0, err + } + if err := r.fillBDProfileCreators(ctx, items); err != nil { + return nil, 0, err + } + if err := r.fillBDLeaderPositionAliases(ctx, appCode, items); err != nil { + return nil, 0, err + } + return items, total, nil +} + func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDProfile, error) { if r == nil || r.db == nil { return nil, errUserDBNotConfigured() @@ -163,7 +261,7 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP appCode := appctx.FromContext(ctx) item := &userclient.BDProfile{} err := r.db.QueryRowContext(ctx, ` - SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0), + SELECT bp.user_id, 'bd_leader', bp.region_id, 0, bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms, COALESCE(u.current_display_user_id, ''), COALESCE(u.username, ''), COALESCE(u.avatar, ''), COALESCE(r.name, ''), @@ -176,11 +274,11 @@ func (r *Reader) GetBDLeader(ctx context.Context, userID int64) (*userclient.BDP AND child.role = 'bd' AND child.parent_leader_user_id = bp.user_id ) - FROM bd_profiles bp + FROM bd_leader_profiles bp LEFT JOIN users u ON u.app_code = bp.app_code AND u.user_id = bp.user_id LEFT JOIN users creator ON creator.app_code = bp.app_code AND creator.user_id = bp.created_by_user_id LEFT JOIN regions r ON r.app_code = bp.app_code AND r.region_id = bp.region_id - WHERE bp.app_code = ? AND bp.user_id = ? AND bp.role = 'bd_leader' + WHERE bp.app_code = ? AND bp.user_id = ? `, appCode, userID).Scan( &item.UserID, &item.Role, @@ -351,10 +449,10 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient. appCode := appctx.FromContext(ctx) item := &userclient.BDProfile{} err := r.db.QueryRowContext(ctx, ` - SELECT bp.user_id, bp.role, bp.region_id, COALESCE(bp.parent_leader_user_id, 0), + SELECT bp.user_id, 'bd_leader', bp.region_id, 0, bp.status, bp.created_by_user_id, bp.created_at_ms, bp.updated_at_ms - FROM bd_profiles bp - WHERE bp.app_code = ? AND bp.user_id = ? AND bp.role = 'bd_leader' + FROM bd_leader_profiles bp + WHERE bp.app_code = ? AND bp.user_id = ? `, appCode, userID).Scan( &item.UserID, &item.Role, @@ -372,8 +470,8 @@ func (r *Reader) DeleteBDLeader(ctx context.Context, userID int64) (*userclient. return nil, err } result, err := r.db.ExecContext(ctx, ` - DELETE FROM bd_profiles - WHERE app_code = ? AND user_id = ? AND role = 'bd_leader' + DELETE FROM bd_leader_profiles + WHERE app_code = ? AND user_id = ? `, appCode, userID) if err != nil { return nil, err diff --git a/server/admin/internal/modules/hostorg/service.go b/server/admin/internal/modules/hostorg/service.go index 60e9f418..5722637a 100644 --- a/server/admin/internal/modules/hostorg/service.go +++ b/server/admin/internal/modules/hostorg/service.go @@ -193,13 +193,14 @@ func (s *Service) CreateBD(ctx context.Context, actorID int64, requestID string, }) } -func (s *Service) SetBDStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, req bdStatusRequest) (*userclient.BDProfile, error) { +func (s *Service) SetBDStatus(ctx context.Context, actorID int64, targetUserID int64, requestID string, role string, req bdStatusRequest) (*userclient.BDProfile, error) { return s.userClient.SetBDStatus(ctx, userclient.SetBDStatusRequest{ RequestID: requestID, Caller: "hyapp-admin-server", CommandID: strings.TrimSpace(req.CommandID), AdminUserID: actorID, TargetUserID: targetUserID, + Role: role, Status: strings.TrimSpace(req.Status), Reason: strings.TrimSpace(req.Reason), }) diff --git a/server/admin/internal/modules/teamsalarysettlement/service.go b/server/admin/internal/modules/teamsalarysettlement/service.go index 07af8ce9..b910a269 100644 --- a/server/admin/internal/modules/teamsalarysettlement/service.go +++ b/server/admin/internal/modules/teamsalarysettlement/service.go @@ -653,22 +653,30 @@ type bdProfile struct { ParentLeaderUserID int64 } -// bdProfilesByUser 按角色取 active profile,BD 和 BD Leader 共用一张表但语义不同,调用方必须显式传 role。 +// bdProfilesByUser 按角色取 active profile,普通 BD 和 BD Leader 已拆成两张物理表,调用方必须显式传 role。 func (s *Service) bdProfilesByUser(ctx context.Context, appCode string, userIDs []int64, role string) (map[int64]bdProfile, error) { userIDs = uniquePositiveUserIDs(userIDs) if len(userIDs) == 0 { return map[int64]bdProfile{}, nil } placeholders := placeholders(len(userIDs)) - args := make([]any, 0, len(userIDs)+2) - args = append(args, appCode, role) + args := make([]any, 0, len(userIDs)+1) + args = append(args, appCode) for _, id := range userIDs { args = append(args, id) } - rows, err := s.userDB.QueryContext(ctx, ` + query := ` SELECT user_id, role, region_id, COALESCE(parent_leader_user_id, 0) FROM bd_profiles - WHERE app_code = ? AND role = ? AND status = 'active' AND user_id IN (`+placeholders+`)`, args...) + WHERE app_code = ? AND role = 'bd' AND status = 'active' AND user_id IN (` + placeholders + `)` + if role == "bd_leader" { + query = ` + SELECT user_id, 'bd_leader', region_id, 0 + FROM bd_leader_profiles + WHERE app_code = ? AND status = 'active' AND user_id IN (` + placeholders + `)` + } + rows, err := s.userDB.QueryContext(ctx, ` + `+query, args...) if err != nil { return nil, err } diff --git a/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go b/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go index 98488f07..564dfe1e 100644 --- a/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go +++ b/server/admin/internal/modules/teamsalarysettlement/service_integration_test.go @@ -157,8 +157,9 @@ func seedTeamSalaryFlow(t *testing.T, adminDB *sql.DB, userDB *sql.DB, walletDB ('lalu', 3001, 4001, 686, 'active'), ('lalu', 3002, 4001, 686, 'active')`, `INSERT INTO bd_profiles (app_code, user_id, role, region_id, parent_leader_user_id, status) VALUES - ('lalu', 4001, 'bd', 686, 5001, 'active'), - ('lalu', 5001, 'bd_leader', 686, 0, 'active')`, + ('lalu', 4001, 'bd', 686, 5001, 'active')`, + `INSERT INTO bd_leader_profiles (app_code, user_id, region_id, status) VALUES + ('lalu', 5001, 686, 'active')`, ) execAll(t, walletDB, fmt.Sprintf(`INSERT INTO host_salary_settlement_records (app_code, settlement_id, command_id, transaction_id, settlement_type, user_id, agency_owner_user_id, @@ -255,6 +256,13 @@ func teamSalaryUserDDL() []string { status VARCHAR(24) NOT NULL, PRIMARY KEY (app_code, user_id, role) )`, + `CREATE TABLE bd_leader_profiles ( + app_code VARCHAR(32) NOT NULL, + user_id BIGINT NOT NULL, + region_id BIGINT NOT NULL, + status VARCHAR(24) NOT NULL, + PRIMARY KEY (app_code, user_id) + )`, } } diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index de0d1471..67911736 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -4206,7 +4206,7 @@ func TestBDCenterOverviewUsesBDSalaryAndDirectAgencies(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } - if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBDAgencies == nil || hostClient.lastBDAgencies.GetBdUserId() != 42 { + if hostClient.lastBD == nil || hostClient.lastBD.GetUserId() != 42 || hostClient.lastBD.GetRole() != "bd" || hostClient.lastBDAgencies == nil || hostClient.lastBDAgencies.GetBdUserId() != 42 { t.Fatalf("bd center requests mismatch: profile=%+v agencies=%+v", hostClient.lastBD, hostClient.lastBDAgencies) } if walletClient.last == nil || walletClient.last.GetUserId() != 42 || len(walletClient.last.GetAssetTypes()) != 1 || walletClient.last.GetAssetTypes()[0] != "BD_SALARY_USD" { @@ -4253,7 +4253,7 @@ func TestBDCenterInviteAgencyDirectlyForwardsInviteAgency(t *testing.T) { if recorder.Code != http.StatusOK { t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) } - if hostClient.lastInviteAgency == nil || hostClient.lastInviteAgency.GetInviterUserId() != 42 || hostClient.lastInviteAgency.GetTargetUserId() != 99 || hostClient.lastInviteAgency.GetAgencyName() != "Target Agency" { + if hostClient.lastBD == nil || hostClient.lastBD.GetRole() != "bd" || hostClient.lastInviteAgency == nil || hostClient.lastInviteAgency.GetInviterUserId() != 42 || hostClient.lastInviteAgency.GetTargetUserId() != 99 || hostClient.lastInviteAgency.GetAgencyName() != "Target Agency" { t.Fatalf("bd invite agency request mismatch: %+v", hostClient.lastInviteAgency) } var response httpkit.ResponseEnvelope @@ -4267,6 +4267,47 @@ func TestBDCenterInviteAgencyDirectlyForwardsInviteAgency(t *testing.T) { } } +func TestBDLeaderInviteBDForwardsSelfInvite(t *testing.T) { + hostClient := &fakeUserHostClient{ + bdProfile: &userv1.BDProfile{UserId: 42, Role: "bd_leader", Status: "active", RegionId: 1001}, + inviteBDResp: &userv1.InviteBDResponse{Invitation: &userv1.RoleInvitation{ + InvitationId: 9002, + CommandId: "cmd-bd-self", + InvitationType: "bd", + Status: "accepted", + InviterUserId: 42, + InviterBdUserId: 42, + TargetUserId: 42, + ParentLeaderUserId: 42, + }}, + } + handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) + handler.SetUserHostClient(hostClient) + router := handler.Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/bd-leader/invitations/bd", bytes.NewReader([]byte(`{"command_id":"cmd-bd-self","target_user_id":"42"}`))) + request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42)) + request.Header.Set("X-Request-ID", "req-bd-self") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String()) + } + if hostClient.lastBD == nil || hostClient.lastBD.GetRole() != "bd_leader" || hostClient.lastInviteBD == nil || hostClient.lastInviteBD.GetInviterUserId() != 42 || hostClient.lastInviteBD.GetTargetUserId() != 42 { + t.Fatalf("self invite must be forwarded to user-service: %+v", hostClient.lastInviteBD) + } + var response httpkit.ResponseEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil { + t.Fatalf("decode response failed: %v", err) + } + data := response.Data.(map[string]any) + invitation := data["invitation"].(map[string]any) + if invitation["status"] != "accepted" || invitation["target_user_id"] != "42" || invitation["parent_leader_user_id"] != "42" { + t.Fatalf("bd self invite response mismatch: %+v", data) + } +} + func TestAgencyCenterOverviewRejectsNonOwner(t *testing.T) { hostClient := &fakeUserHostClient{roleSummary: &userv1.UserRoleSummary{UserId: 42, IsManager: false, AgencyId: 7001}} handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{}) diff --git a/services/gateway-service/internal/transport/http/userapi/bd_leader_center_handler.go b/services/gateway-service/internal/transport/http/userapi/bd_leader_center_handler.go index 6904e2ea..62202b2f 100644 --- a/services/gateway-service/internal/transport/http/userapi/bd_leader_center_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/bd_leader_center_handler.go @@ -395,6 +395,7 @@ func (h *Handler) resolveActiveBDLeader(writer http.ResponseWriter, request *htt resp, err := h.userHostClient.GetBDProfile(request.Context(), &userv1.GetBDProfileRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, + Role: "bd_leader", }) if err != nil { httpkit.WriteRPCError(writer, request, err) @@ -416,13 +417,14 @@ func (h *Handler) resolveActiveBD(writer http.ResponseWriter, request *http.Requ resp, err := h.userHostClient.GetBDProfile(request.Context(), &userv1.GetBDProfileRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, + Role: "bd", }) if err != nil { httpkit.WriteRPCError(writer, request, err) return nil, false } profile := resp.GetBdProfile() - if profile == nil || profile.GetStatus() != bdStatusActive || (profile.GetRole() != "bd" && profile.GetRole() != "bd_leader") { + if profile == nil || profile.GetStatus() != bdStatusActive || profile.GetRole() != "bd" { httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "permission denied") return nil, false } diff --git a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go index 289d5705..1bc1d2ce 100644 --- a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go @@ -367,9 +367,14 @@ func (h *Handler) resolveAgencySalaryIdentity(writer http.ResponseWriter, reques } func (h *Handler) resolveBDSalaryIdentity(writer http.ResponseWriter, request *http.Request, userID int64, identity string) (salaryWalletIdentityContext, bool) { + role := "bd" + if identity == salaryIdentityBDLeader { + role = "bd_leader" + } resp, err := h.userHostClient.GetBDProfile(request.Context(), &userv1.GetBDProfileRequest{ Meta: httpkit.UserMeta(request, ""), UserId: userID, + Role: role, }) if err != nil { httpkit.WriteRPCError(writer, request, err) diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 72c936c6..02cd9290 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -750,6 +750,17 @@ CREATE TABLE IF NOT EXISTS bd_profiles ( KEY idx_bd_profiles_parent_leader (app_code, parent_leader_user_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD资料表'; +CREATE TABLE IF NOT EXISTS bd_leader_profiles ( + app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', + user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID', + region_id BIGINT NOT NULL COMMENT '区域 ID', + status VARCHAR(32) NOT NULL COMMENT '业务状态', + created_by_user_id BIGINT NOT NULL COMMENT '创建人用户 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + KEY idx_bd_leader_profiles_region_status (app_code, region_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='BD Leader资料表'; + CREATE TABLE IF NOT EXISTS coin_seller_profiles ( app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离', user_id BIGINT NOT NULL PRIMARY KEY COMMENT '用户 ID', diff --git a/services/user-service/internal/domain/host/host.go b/services/user-service/internal/domain/host/host.go index 874dbedb..36170afd 100644 --- a/services/user-service/internal/domain/host/host.go +++ b/services/user-service/internal/domain/host/host.go @@ -28,7 +28,7 @@ const ( // BDRoleBD 是普通拓展角色。 BDRoleBD = "bd" - // BDRoleLeader 是 BD Leader,具备邀请 BD 的能力,同时也是 BD。 + // BDRoleLeader 是 BD Leader,具备邀请 BD 的能力;它与普通 BD 身份分表保存。 BDRoleLeader = "bd_leader" // CoinSellerStatusActive 表示币商身份有效,可以使用币商金币账户转账能力。 @@ -124,7 +124,7 @@ const ( ResultTypeMembership = "agency_membership" // ResultTypeInvitation 表示命令结果是 role_invitations 行。 ResultTypeInvitation = "role_invitation" - // ResultTypeBDProfile 表示命令结果是 bd_profiles 行。 + // ResultTypeBDProfile 表示命令结果是普通 BD 或 BD Leader 身份行。 ResultTypeBDProfile = "bd_profile" // ResultTypeAgency 表示命令结果是 agencies 行。 ResultTypeAgency = "agency" diff --git a/services/user-service/internal/service/host/commands.go b/services/user-service/internal/service/host/commands.go index d656d60d..e0d8c7e3 100644 --- a/services/user-service/internal/service/host/commands.go +++ b/services/user-service/internal/service/host/commands.go @@ -192,6 +192,7 @@ type SetBDStatusInput struct { CommandID string AdminUserID int64 TargetUserID int64 + Role string Status string Reason string RequestID string @@ -202,6 +203,7 @@ type SetBDStatusCommand struct { CommandID string AdminUserID int64 TargetUserID int64 + Role string Status string Reason string RequestID string diff --git a/services/user-service/internal/service/host/service.go b/services/user-service/internal/service/host/service.go index 7100787d..e1051ee2 100644 --- a/services/user-service/internal/service/host/service.go +++ b/services/user-service/internal/service/host/service.go @@ -34,7 +34,7 @@ type Repository interface { DeleteAgency(ctx context.Context, command DeleteAgencyCommand) (hostdomain.Agency, error) SetAgencyJoinEnabled(ctx context.Context, command SetAgencyJoinEnabledCommand) (hostdomain.Agency, error) GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error) - GetBDProfile(ctx context.Context, userID int64) (hostdomain.BDProfile, error) + GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, error) ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) @@ -368,14 +368,15 @@ func (s *Service) CreateBD(ctx context.Context, command CreateBDInput) (hostdoma }) } -// SetBDStatus 调整 BD 或 BD Leader 状态;Phase 2 只允许 active/disabled 两个稳定状态。 +// SetBDStatus 调整普通 BD 或 BD Leader 状态;调用方必须传 role,避免同一用户两种身份时误改。 func (s *Service) SetBDStatus(ctx context.Context, command SetBDStatusInput) (hostdomain.BDProfile, error) { if err := s.requireWriteDependencies(); err != nil { return hostdomain.BDProfile{}, err } status := normalizeStatus(command.Status) - if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 { - return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id and target_user_id are required") + role := normalizeBDRole(command.Role) + if strings.TrimSpace(command.CommandID) == "" || command.AdminUserID <= 0 || command.TargetUserID <= 0 || role == "" { + return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "command_id, admin_user_id, target_user_id and role are required") } if status != hostdomain.BDStatusActive && status != hostdomain.BDStatusDisabled { return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "bd status is invalid") @@ -386,6 +387,7 @@ func (s *Service) SetBDStatus(ctx context.Context, command SetBDStatusInput) (ho CommandID: strings.TrimSpace(command.CommandID), AdminUserID: command.AdminUserID, TargetUserID: command.TargetUserID, + Role: role, Status: status, Reason: strings.TrimSpace(command.Reason), RequestID: strings.TrimSpace(command.RequestID), @@ -548,16 +550,28 @@ func (s *Service) GetHostProfile(ctx context.Context, userID int64) (hostdomain. return s.repository.GetHostProfile(ctx, userID) } -// GetBDProfile 读取 BD/BD 负责人身份事实。 -func (s *Service) GetBDProfile(ctx context.Context, userID int64) (hostdomain.BDProfile, error) { +// GetBDProfile 按指定 role 读取普通 BD 或 BD Leader 身份事实。 +func (s *Service) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) { if s.repository == nil { return hostdomain.BDProfile{}, xerr.New(xerr.Unavailable, "host repository is not configured") } - if userID <= 0 { - return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "user_id is required") + role = normalizeBDRole(role) + if userID <= 0 || role == "" { + return hostdomain.BDProfile{}, xerr.New(xerr.InvalidArgument, "user_id and role are required") } - return s.repository.GetBDProfile(ctx, userID) + return s.repository.GetBDProfile(ctx, userID, role) +} + +func normalizeBDRole(role string) string { + switch strings.ToLower(strings.TrimSpace(role)) { + case hostdomain.BDRoleBD: + return hostdomain.BDRoleBD + case hostdomain.BDRoleLeader: + return hostdomain.BDRoleLeader + default: + return "" + } } // GetCoinSellerProfile 读取币商身份事实,供 gateway 在发起币商转账前校验。 diff --git a/services/user-service/internal/service/host/service_test.go b/services/user-service/internal/service/host/service_test.go index d0f91a90..9b6d1ffb 100644 --- a/services/user-service/internal/service/host/service_test.go +++ b/services/user-service/internal/service/host/service_test.go @@ -274,7 +274,7 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) { if invitation.Status != hostdomain.InvitationStatusAccepted { t.Fatalf("bd invitation should be accepted immediately: %+v", invitation) } - bdProfile, err := svc.GetBDProfile(ctx, 702) + bdProfile, err := svc.GetBDProfile(ctx, 702, hostdomain.BDRoleBD) if err != nil { t.Fatalf("GetBDProfile failed: %v", err) } @@ -286,6 +286,53 @@ func TestAcceptBDInvitationDoesNotCreateHostProfile(t *testing.T) { } } +func TestInviteBDAcceptsSelfInviteForLeader(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedActiveUser(t, repository, 703, 10) + repository.PutBDProfile(hostdomain.BDProfile{ + UserID: 703, + Role: hostdomain.BDRoleLeader, + RegionID: 10, + Status: hostdomain.BDStatusActive, + }) + svc := newHostService(repository, 3100, 4100) + + invitation, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ + CommandID: "invite-bd-self-703", + InviterUserID: 703, + TargetUserID: 703, + }) + if err != nil { + t.Fatalf("bd leader self invite must be accepted: %v", err) + } + if invitation.Status != hostdomain.InvitationStatusAccepted || invitation.InviterUserID != 703 || invitation.TargetUserID != 703 || invitation.ParentLeaderUserID != 703 { + t.Fatalf("self invite invitation mismatch: %+v", invitation) + } + ordinaryBD, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleBD) + if err != nil { + t.Fatalf("self invite must create ordinary bd profile: %v", err) + } + if ordinaryBD.Role != hostdomain.BDRoleBD || ordinaryBD.ParentLeaderUserID != 703 { + t.Fatalf("self invite ordinary bd mismatch: %+v", ordinaryBD) + } + leaderProfile, err := svc.GetBDProfile(ctx, 703, hostdomain.BDRoleLeader) + if err != nil { + t.Fatalf("self invite must keep existing leader profile unchanged: %v", err) + } + if leaderProfile.Role != hostdomain.BDRoleLeader || leaderProfile.Status != hostdomain.BDStatusActive { + t.Fatalf("self invite leader profile mismatch: %+v", leaderProfile) + } + retried, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ + CommandID: "invite-bd-self-703", + InviterUserID: 703, + TargetUserID: 703, + }) + if err != nil || retried.InvitationID != invitation.InvitationID { + t.Fatalf("self invite retry must replay existing invitation: retried=%+v err=%v", retried, err) + } +} + func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) @@ -327,6 +374,25 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) { t.Fatalf("leader retry must replay existing result: %+v", retriedLeader) } + sameUserBD, err := svc.CreateBD(ctx, hostservice.CreateBDInput{ + CommandID: "admin-create-bd-801-same-user", + AdminUserID: 1, + TargetUserID: 801, + ParentLeaderUserID: 801, + Reason: "same user can also be bd", + RequestID: "req-bd-same-user-1", + }) + if err != nil { + t.Fatalf("CreateBD for existing leader user failed: %v", err) + } + if sameUserBD.Role != hostdomain.BDRoleBD || sameUserBD.ParentLeaderUserID != 801 { + t.Fatalf("same user bd mismatch: %+v", sameUserBD) + } + leaderAfterBD, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader) + if err != nil || leaderAfterBD.Role != hostdomain.BDRoleLeader || leaderAfterBD.Status != hostdomain.BDStatusActive { + t.Fatalf("ordinary bd creation must not rewrite leader row: leader=%+v err=%v", leaderAfterBD, err) + } + bd, err := svc.CreateBD(ctx, hostservice.CreateBDInput{ CommandID: "admin-create-bd-802", AdminUserID: 1, @@ -360,6 +426,7 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) { CommandID: "admin-disable-bd-802", AdminUserID: 1, TargetUserID: 802, + Role: hostdomain.BDRoleBD, Status: hostdomain.BDStatusDisabled, Reason: "stop bd", RequestID: "req-bd-stop-1", @@ -370,6 +437,52 @@ func TestAdminCreateBDLeaderAndBDIdempotently(t *testing.T) { if disabled.Status != hostdomain.BDStatusDisabled { t.Fatalf("disabled bd mismatch: %+v", disabled) } + _, err = svc.InviteBD(ctx, hostservice.InviteBDInput{ + CommandID: "invite-disabled-bd-802-again", + InviterUserID: 801, + TargetUserID: 802, + }) + if got := xerr.CodeOf(err); got != xerr.Conflict { + t.Fatalf("disabled ordinary bd must not be invited again: got %s err=%v", got, err) + } + disabledSameUserBD, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{ + CommandID: "admin-disable-bd-801", + AdminUserID: 1, + TargetUserID: 801, + Role: hostdomain.BDRoleBD, + Status: hostdomain.BDStatusDisabled, + Reason: "stop ordinary bd only", + RequestID: "req-bd-stop-801", + }) + if err != nil { + t.Fatalf("SetBDStatus ordinary bd for same user failed: %v", err) + } + if disabledSameUserBD.Role != hostdomain.BDRoleBD || disabledSameUserBD.Status != hostdomain.BDStatusDisabled { + t.Fatalf("disabled same user bd mismatch: %+v", disabledSameUserBD) + } + stillLeader, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleLeader) + if err != nil || stillLeader.Status != hostdomain.BDStatusActive { + t.Fatalf("disabling ordinary bd must not affect leader: leader=%+v err=%v", stillLeader, err) + } + disabledLeader, err := svc.SetBDStatus(ctx, hostservice.SetBDStatusInput{ + CommandID: "admin-disable-leader-801", + AdminUserID: 1, + TargetUserID: 801, + Role: hostdomain.BDRoleLeader, + Status: hostdomain.BDStatusDisabled, + Reason: "stop leader only", + RequestID: "req-leader-stop-801", + }) + if err != nil { + t.Fatalf("SetBDStatus leader failed: %v", err) + } + if disabledLeader.Role != hostdomain.BDRoleLeader || disabledLeader.Status != hostdomain.BDStatusDisabled { + t.Fatalf("disabled leader mismatch: %+v", disabledLeader) + } + ordinaryStillDisabled, err := svc.GetBDProfile(ctx, 801, hostdomain.BDRoleBD) + if err != nil || ordinaryStillDisabled.Status != hostdomain.BDStatusDisabled { + t.Fatalf("disabling leader must not rewrite ordinary bd: bd=%+v err=%v", ordinaryStillDisabled, err) + } } func TestAdminCreateBDLeaderRejectsDisabledRegion(t *testing.T) { @@ -808,8 +921,22 @@ func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) { if err != nil { t.Fatalf("GetUserRoleSummary failed: %v", err) } - if !summary.IsHost || !summary.IsAgency || !summary.IsManager || !summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 { - t.Fatalf("role summary mismatch: %+v", summary) + if !summary.IsHost || !summary.IsAgency || !summary.IsManager || summary.IsBD || !summary.IsBDLeader || !summary.IsCoinSeller || summary.AgencyID != 933 || summary.BDID != 931 { + t.Fatalf("leader-only role summary mismatch: %+v", summary) + } + if _, err := svc.InviteBD(ctx, hostservice.InviteBDInput{ + CommandID: "invite-bd-self-931", + InviterUserID: 931, + TargetUserID: 931, + }); err != nil { + t.Fatalf("self InviteBD for summary failed: %v", err) + } + combinedSummary, err := svc.GetUserRoleSummary(ctx, 931) + if err != nil { + t.Fatalf("GetUserRoleSummary combined failed: %v", err) + } + if !combinedSummary.IsBD || !combinedSummary.IsBDLeader || combinedSummary.BDID != 931 { + t.Fatalf("bd plus leader role summary mismatch: %+v", combinedSummary) } directBD, err := svc.GetUserRoleSummary(ctx, 932) if err != nil { diff --git a/services/user-service/internal/storage/mysql/host/admin.go b/services/user-service/internal/storage/mysql/host/admin.go index 3c1e5003..21ceaf9f 100644 --- a/services/user-service/internal/storage/mysql/host/admin.go +++ b/services/user-service/internal/storage/mysql/host/admin.go @@ -24,7 +24,7 @@ func (r *Repository) CreateBDLeader(ctx context.Context, command hostservice.Cre if err := requireCommandResult(existing, hostdomain.CommandTypeCreateBDLeader, hostdomain.ResultTypeBDProfile); err != nil { return hostdomain.BDProfile{}, err } - return queryBDProfile(ctx, tx, "WHERE user_id = ?", existing.ResultID) + return queryBDLeaderProfile(ctx, tx, "WHERE user_id = ?", existing.ResultID) } regionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE") @@ -34,10 +34,10 @@ func (r *Repository) CreateBDLeader(ctx context.Context, command hostservice.Cre if err := requireActiveRegion(ctx, tx, regionID); err != nil { return hostdomain.BDProfile{}, err } - if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil { + if _, ok, err := queryBDLeaderProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil { return hostdomain.BDProfile{}, err } else if ok { - return hostdomain.BDProfile{}, xerr.New(xerr.Conflict, "target already has bd profile") + return hostdomain.BDProfile{}, xerr.New(xerr.Conflict, "target already has bd leader profile") } profile := hostdomain.BDProfile{ @@ -49,7 +49,7 @@ func (r *Repository) CreateBDLeader(ctx context.Context, command hostservice.Cre CreatedAtMs: command.NowMs, UpdatedAtMs: command.NowMs, } - if err := insertBDProfile(ctx, tx, profile); err != nil { + if err := insertBDLeaderProfile(ctx, tx, profile); err != nil { return hostdomain.BDProfile{}, mapHostDuplicateError(err) } if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeCreateBDLeader, ResultType: hostdomain.ResultTypeBDProfile, ResultID: profile.UserID, CreatedAtMs: command.NowMs}); err != nil { @@ -86,12 +86,12 @@ func (r *Repository) CreateBD(ctx context.Context, command hostservice.CreateBDC return hostdomain.BDProfile{}, err } if command.ParentLeaderUserID > 0 { - // 独立 BD 不需要父级行锁;只有传入 Leader 时才校验 Leader 身份、启用状态和区域归属。 - leader, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentLeaderUserID) + // 独立 BD 不需要父级行锁;只有传入 Leader 时才锁定独立 Leader 表并校验启用状态和区域归属。 + leader, err := queryBDLeaderProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentLeaderUserID) if err != nil { return hostdomain.BDProfile{}, err } - if leader.Status != hostdomain.BDStatusActive || leader.Role != hostdomain.BDRoleLeader { + if leader.Status != hostdomain.BDStatusActive { return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "parent leader is not active") } if regionID != leader.RegionID { @@ -129,7 +129,7 @@ func (r *Repository) CreateBD(ctx context.Context, command hostservice.CreateBDC return profile, nil } -// SetBDStatus 只改 BD 状态,不迁移角色、区域和上级负责人。 +// SetBDStatus 按调用方明确传入的角色只改对应身份状态,不迁移角色、区域和上级负责人。 func (r *Repository) SetBDStatus(ctx context.Context, command hostservice.SetBDStatusCommand) (hostdomain.BDProfile, error) { tx, err := r.db.BeginTx(ctx, nil) if err != nil { @@ -143,18 +143,37 @@ func (r *Repository) SetBDStatus(ctx context.Context, command hostservice.SetBDS if err := requireCommandResult(existing, hostdomain.CommandTypeSetBDStatus, hostdomain.ResultTypeBDProfile); err != nil { return hostdomain.BDProfile{}, err } + if command.Role == hostdomain.BDRoleLeader { + return queryBDLeaderProfile(ctx, tx, "WHERE user_id = ?", existing.ResultID) + } return queryBDProfile(ctx, tx, "WHERE user_id = ?", existing.ResultID) } - before, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.TargetUserID) + var before hostdomain.BDProfile + switch command.Role { + case hostdomain.BDRoleBD: + // 普通 BD 和 Leader 已经拆表;状态路由传 bd 时只锁普通 BD 行。 + before, err = queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.TargetUserID) + case hostdomain.BDRoleLeader: + // 状态路由传 bd_leader 时只锁 Leader 行,避免同用户的普通 BD 钱包权限被误停。 + before, err = queryBDLeaderProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.TargetUserID) + default: + err = xerr.New(xerr.InvalidArgument, "bd role is invalid") + } if err != nil { return hostdomain.BDProfile{}, err } after := before after.Status = command.Status after.UpdatedAtMs = command.NowMs - if err := updateBDProfileStatus(ctx, tx, after); err != nil { - return hostdomain.BDProfile{}, err + if command.Role == hostdomain.BDRoleLeader { + if err := updateBDLeaderProfileStatus(ctx, tx, after); err != nil { + return hostdomain.BDProfile{}, err + } + } else { + if err := updateBDProfileStatus(ctx, tx, after); err != nil { + return hostdomain.BDProfile{}, err + } } if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{CommandID: command.CommandID, CommandType: hostdomain.CommandTypeSetBDStatus, ResultType: hostdomain.ResultTypeBDProfile, ResultID: after.UserID, CreatedAtMs: command.NowMs}); err != nil { return hostdomain.BDProfile{}, mapHostDuplicateError(err) @@ -278,14 +297,11 @@ func (r *Repository) CreateAgency(ctx context.Context, command hostservice.Creat return hostdomain.CreateAgencyResult{}, err } if command.ParentBDUserID > 0 { - // 独立 Agency 不需要父级行锁;只有传入 BD 时才校验 BD 启用状态和同区域归属。 - parentBD, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.ParentBDUserID) + // 独立 Agency 不需要父级行锁;传入父级时可挂普通 BD 或 Leader 自身,但必须是有效身份且同区域。 + parentBD, err := queryActiveAgencyInviterProfile(ctx, tx, command.ParentBDUserID) if err != nil { return hostdomain.CreateAgencyResult{}, err } - if parentBD.Status != hostdomain.BDStatusActive { - return hostdomain.CreateAgencyResult{}, xerr.New(xerr.PermissionDenied, "parent bd is not active") - } if regionID != parentBD.RegionID { return hostdomain.CreateAgencyResult{}, xerr.New(xerr.PermissionDenied, "owner region does not match parent bd region") } diff --git a/services/user-service/internal/storage/mysql/host/common.go b/services/user-service/internal/storage/mysql/host/common.go index 69dd9e86..6b162793 100644 --- a/services/user-service/internal/storage/mysql/host/common.go +++ b/services/user-service/internal/storage/mysql/host/common.go @@ -47,6 +47,9 @@ const ( bdProfileColumns = ` user_id, role, region_id, COALESCE(parent_leader_user_id, 0), status, created_by_user_id, created_at_ms, updated_at_ms` + bdLeaderProfileColumns = ` + user_id, 'bd_leader', region_id, 0, status, + created_by_user_id, created_at_ms, updated_at_ms` coinSellerProfileColumns = ` user_id, status, merchant_asset_type, created_by_user_id, created_at_ms, updated_at_ms` diff --git a/services/user-service/internal/storage/mysql/host/invitations.go b/services/user-service/internal/storage/mysql/host/invitations.go index b614d3a4..ae466c76 100644 --- a/services/user-service/internal/storage/mysql/host/invitations.go +++ b/services/user-service/internal/storage/mysql/host/invitations.go @@ -28,15 +28,12 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit return queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID) } - // 邀请人的 BD 身份行同时是授权凭证和邀请归属来源; - // 锁住它可以避免 BD 被停用/换区时仍继续发出旧区域邀请。 - inviter, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.InviterUserID) + // InviteAgency 没有显式角色字段;普通 BD 行优先承载 BD Center 邀请,普通 BD 不可用时再使用 Leader 行承载负责人直属邀请。 + // 两种身份都用 FOR UPDATE 串行化停用/换区和邀请创建,避免旧权限在同一事务窗口内继续写入。 + inviter, err := queryActiveAgencyInviterProfile(ctx, tx, command.InviterUserID) if err != nil { return hostdomain.RoleInvitation{}, err } - if inviter.Status != hostdomain.BDStatusActive { - return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "inviter bd is not active") - } // 目标用户还不是 Host/Agency 时没有领域表行,所以锁 users 行来串行化目标身份变更。 targetRegionID, err := r.userRegion(ctx, tx, command.TargetUserID, "FOR UPDATE") if err != nil { @@ -59,7 +56,7 @@ func (r *Repository) InviteAgency(ctx context.Context, command hostservice.Invit } // BD 发出的 Agency 邀请要记录直接上级 BD 和负责人链路; - // 如果邀请人自己就是负责人,则 parent_leader_user_id 指向自己。 + // 如果本次以负责人身份邀请,则直接 BD 和负责人都指向 Leader 自己。 parentLeaderUserID := inviter.ParentLeaderUserID if inviter.Role == hostdomain.BDRoleLeader { parentLeaderUserID = inviter.UserID @@ -146,12 +143,12 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC return queryRoleInvitation(ctx, tx, "WHERE invitation_id = ?", existing.ResultID) } - // 只有有效 BD 负责人能发展 BD;锁住负责人身份行让停用/降级和邀请创建串行。 - inviter, err := queryBDProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.InviterUserID) + // 只有有效 BD 负责人能发展 BD;锁住独立 Leader 表行让停用和邀请创建串行。 + inviter, err := queryBDLeaderProfile(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.InviterUserID) if err != nil { return hostdomain.RoleInvitation{}, err } - if inviter.Status != hostdomain.BDStatusActive || inviter.Role != hostdomain.BDRoleLeader { + if inviter.Status != hostdomain.BDStatusActive { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "inviter is not active bd leader") } // BD 从属于负责人的区域,邀请创建时先锁目标用户当前区域。 @@ -162,11 +159,12 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC if targetRegionID != inviter.RegionID { return hostdomain.RoleInvitation{}, xerr.New(xerr.PermissionDenied, "target region does not match leader region") } - // 目标已有有效 BD 身份行时不再创建新邀请,避免多条 BD 身份事实竞争。 - if _, ok, err := queryBDProfileMaybe(ctx, tx, command.TargetUserID, true); err != nil { + // 目标已有普通 BD 身份行时不再创建新邀请,即使已停用也不能再插入第二条普通 BD 事实。 + // BD Leader 自邀也必须新增普通 BD 行,后续才能获得 BD_SALARY_USD 钱包鉴权。 + if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, command.TargetUserID, true); err != nil { return hostdomain.RoleInvitation{}, err } else if ok { - return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has active bd profile") + return hostdomain.RoleInvitation{}, xerr.New(xerr.Conflict, "target already has bd profile") } invitation := hostdomain.RoleInvitation{ @@ -189,6 +187,15 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC if err := insertRoleInvitation(ctx, tx, invitation); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } + recordCommandResult := func() error { + return insertCommandResult(ctx, tx, hostdomain.CommandResult{ + CommandID: command.CommandID, + CommandType: hostdomain.CommandTypeInviteBD, + ResultType: hostdomain.ResultTypeInvitation, + ResultID: invitation.InvitationID, + CreatedAtMs: command.NowMs, + }) + } result := hostdomain.ProcessRoleInvitationResult{Invitation: invitation} if err := r.acceptBDInvitation(ctx, tx, hostservice.ProcessRoleInvitationCommand{ CommandID: command.CommandID, @@ -204,13 +211,7 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC if err := insertHostOutbox(ctx, tx, command.EventID, "BDCreated", "bd_profile", invitation.TargetUserID, command.NowMs); err != nil { return hostdomain.RoleInvitation{}, err } - if err := insertCommandResult(ctx, tx, hostdomain.CommandResult{ - CommandID: command.CommandID, - CommandType: hostdomain.CommandTypeInviteBD, - ResultType: hostdomain.ResultTypeInvitation, - ResultID: invitation.InvitationID, - CreatedAtMs: command.NowMs, - }); err != nil { + if err := recordCommandResult(); err != nil { return hostdomain.RoleInvitation{}, mapHostDuplicateError(err) } if err := tx.Commit(); err != nil { @@ -220,6 +221,16 @@ func (r *Repository) InviteBD(ctx context.Context, command hostservice.InviteBDC return invitation, nil } +func queryActiveAgencyInviterProfile(ctx context.Context, tx *sql.Tx, userID int64) (hostdomain.BDProfile, error) { + if profile, ok, err := queryBDProfileMaybe(ctx, tx, userID, true); err != nil || ok { + return profile, err + } + if profile, ok, err := queryBDLeaderProfileMaybe(ctx, tx, userID, true); err != nil || ok { + return profile, err + } + return hostdomain.BDProfile{}, xerr.New(xerr.PermissionDenied, "inviter bd is not active") +} + // ProcessRoleInvitation 处理角色邀请,接受路径在同一事务内创建 Agency/BD 角色事实。 func (r *Repository) ProcessRoleInvitation(ctx context.Context, command hostservice.ProcessRoleInvitationCommand) (hostdomain.ProcessRoleInvitationResult, error) { tx, err := r.db.BeginTx(ctx, nil) @@ -397,11 +408,11 @@ func (r *Repository) acceptBDInvitation(ctx context.Context, tx *sql.Tx, command if regionID != invitation.RegionID { return xerr.New(xerr.PermissionDenied, "target region no longer matches invitation") } - // BD 身份是独立角色事实,不依赖 host_profile;只需要阻止重复有效 BD。 - if _, ok, err := queryBDProfileMaybe(ctx, tx, invitation.TargetUserID, true); err != nil { + // BD 身份是独立角色事实,不依赖 host_profile;任何已有普通 BD 行都不能重复插入。 + if _, ok, err := queryBDProfileByUserMaybe(ctx, tx, invitation.TargetUserID, true); err != nil { return err } else if ok { - return xerr.New(xerr.Conflict, "target already has active bd profile") + return xerr.New(xerr.Conflict, "target already has bd profile") } bd := hostdomain.BDProfile{ UserID: invitation.TargetUserID, diff --git a/services/user-service/internal/storage/mysql/host/queries.go b/services/user-service/internal/storage/mysql/host/queries.go index 8fb8dda4..eada8c48 100644 --- a/services/user-service/internal/storage/mysql/host/queries.go +++ b/services/user-service/internal/storage/mysql/host/queries.go @@ -100,8 +100,11 @@ func (r *Repository) GetHostProfile(ctx context.Context, userID int64) (hostdoma return queryHostProfile(ctx, r.db, "WHERE user_id = ?", userID) } -// GetBDProfile 读取 BD 身份事实。 -func (r *Repository) GetBDProfile(ctx context.Context, userID int64) (hostdomain.BDProfile, error) { +// GetBDProfile 按 role 读取普通 BD 或 BD Leader 身份事实。 +func (r *Repository) GetBDProfile(ctx context.Context, userID int64, role string) (hostdomain.BDProfile, error) { + if role == hostdomain.BDRoleLeader { + return queryBDLeaderProfile(ctx, r.db, "WHERE user_id = ?", userID) + } return queryBDProfile(ctx, r.db, "WHERE user_id = ?", userID) } @@ -201,8 +204,9 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host var hostAgencyID int64 var ownerAgencyID int64 var bdUserID int64 - var bdRole string var bdStatus string + var leaderUserID int64 + var leaderStatus string var coinSellerStatus string var merchantAssetType string @@ -213,8 +217,9 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host COALESCE(h.current_agency_id, 0), COALESCE(a.agency_id, 0), COALESCE(b.user_id, 0), - COALESCE(b.role, ''), COALESCE(b.status, ''), + COALESCE(bl.user_id, 0), + COALESCE(bl.status, ''), COALESCE(c.status, ''), COALESCE(c.merchant_asset_type, '') FROM (SELECT ? AS user_id) u @@ -224,6 +229,8 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host ON a.app_code = ? AND a.owner_user_id = u.user_id AND a.status = ? LEFT JOIN bd_profiles b ON b.app_code = ? AND b.user_id = u.user_id + LEFT JOIN bd_leader_profiles bl + ON bl.app_code = ? AND bl.user_id = u.user_id LEFT JOIN coin_seller_profiles c ON c.app_code = ? AND c.user_id = u.user_id`, userID, @@ -231,7 +238,8 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host app, hostdomain.AgencyStatusActive, app, app, - ).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdRole, &bdStatus, &coinSellerStatus, &merchantAssetType) + app, + ).Scan(&hostStatus, &hostSource, &hostAgencyID, &ownerAgencyID, &bdUserID, &bdStatus, &leaderUserID, &leaderStatus, &coinSellerStatus, &merchantAssetType) if err != nil { return hostdomain.UserRoleSummary{}, err } @@ -249,13 +257,16 @@ func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (host summary.BDStatus = bdStatus if bdUserID > 0 && bdStatus == hostdomain.BDStatusActive { summary.BDID = bdUserID - switch bdRole { - case hostdomain.BDRoleLeader: - summary.IsBD = true - summary.IsBDLeader = true - case hostdomain.BDRoleBD: - summary.IsBD = true + summary.IsBD = true + } + if leaderUserID > 0 && leaderStatus == hostdomain.BDStatusActive { + if summary.BDID == 0 { + summary.BDID = leaderUserID } + if summary.BDStatus == "" { + summary.BDStatus = leaderStatus + } + summary.IsBDLeader = true } summary.CoinSellerStatus = coinSellerStatus summary.IsCoinSeller = coinSellerStatus == hostdomain.CoinSellerStatusActive && merchantAssetType == hostdomain.CoinSellerMerchantAssetType @@ -281,13 +292,13 @@ func (r *Repository) HasActiveAgencyOwner(ctx context.Context, userID int64) (in return agency.AgencyID, true, nil } -// ListBDLeaderBDs 读取 BD Leader 直属 BD;调用前先校验 leader 当前仍有效,避免停用后继续读取团队数据。 +// ListBDLeaderBDs 读取 BD Leader 直属普通 BD;调用前先校验独立 Leader 行当前仍有效,避免停用后继续读取团队数据。 func (r *Repository) ListBDLeaderBDs(ctx context.Context, command hostservice.ListBDLeaderBDsCommand) ([]hostdomain.BDProfile, error) { - leader, err := queryBDProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID) + leader, err := queryBDLeaderProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID) if err != nil { return nil, err } - if leader.Status != hostdomain.BDStatusActive || leader.Role != hostdomain.BDRoleLeader { + if leader.Status != hostdomain.BDStatusActive { return nil, xerr.New(xerr.PermissionDenied, "leader is not active") } status := command.Status @@ -298,13 +309,13 @@ func (r *Repository) ListBDLeaderBDs(ctx context.Context, command hostservice.Li return queryBDProfiles(ctx, r.db, clause, args...) } -// ListBDLeaderAgencies 读取 BD Leader 团队 Agency;包含直属 leader 自己创建的 Agency 和下属 BD 创建的 Agency。 +// ListBDLeaderAgencies 读取 BD Leader 团队 Agency;包含直属 leader 自己创建的 Agency 和下属普通 BD 创建的 Agency。 func (r *Repository) ListBDLeaderAgencies(ctx context.Context, command hostservice.ListBDLeaderAgenciesCommand) ([]hostdomain.Agency, error) { - leader, err := queryBDProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID) + leader, err := queryBDLeaderProfile(ctx, r.db, "WHERE user_id = ?", command.LeaderUserID) if err != nil { return nil, err } - if leader.Status != hostdomain.BDStatusActive || leader.Role != hostdomain.BDRoleLeader { + if leader.Status != hostdomain.BDStatusActive { return nil, xerr.New(xerr.PermissionDenied, "leader is not active") } status := command.Status @@ -325,13 +336,13 @@ func (r *Repository) ListBDLeaderAgencies(ctx context.Context, command hostservi return queryAgencies(ctx, r.db, clause, args...) } -// ListBDAgencies 读取一个有效 BD 直接拓展的 Agency,不包含同负责人下其他 BD 的数据。 +// ListBDAgencies 只读取一个有效普通 BD 直接拓展的 Agency,不包含同负责人下其他 BD 的数据。 func (r *Repository) ListBDAgencies(ctx context.Context, command hostservice.ListBDAgenciesCommand) ([]hostdomain.Agency, error) { bd, err := queryBDProfile(ctx, r.db, "WHERE user_id = ?", command.BDUserID) if err != nil { return nil, err } - if bd.Status != hostdomain.BDStatusActive { + if bd.Status != hostdomain.BDStatusActive || bd.Role != hostdomain.BDRoleBD { return nil, xerr.New(xerr.PermissionDenied, "bd is not active") } status := command.Status @@ -558,6 +569,15 @@ func queryBDProfiles(ctx context.Context, q sqlQueryer, clause string, args ...a return profiles, rows.Err() } +func queryBDLeaderProfile(ctx context.Context, q sqlQueryer, clause string, args ...any) (hostdomain.BDProfile, error) { + clause, args = appScopedClause(ctx, clause, args...) + profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_leader_profiles %s`, bdLeaderProfileColumns, clause), args...)) + if err == sql.ErrNoRows { + return hostdomain.BDProfile{}, xerr.New(xerr.NotFound, "bd leader profile not found") + } + return profile, err +} + func queryBDProfileMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.BDProfile, bool, error) { clause := "WHERE user_id = ? AND status = ?" if lock { @@ -574,6 +594,22 @@ func queryBDProfileMaybe(ctx context.Context, q sqlQueryer, userID int64, lock b return profile, true, nil } +func queryBDLeaderProfileMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.BDProfile, bool, error) { + clause := "WHERE user_id = ? AND status = ?" + if lock { + clause += " FOR UPDATE" + } + clause, args := appScopedClause(ctx, clause, userID, hostdomain.BDStatusActive) + profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_leader_profiles %s`, bdLeaderProfileColumns, clause), args...)) + if err == sql.ErrNoRows { + return hostdomain.BDProfile{}, false, nil + } + if err != nil { + return hostdomain.BDProfile{}, false, err + } + return profile, true, nil +} + func queryBDProfileByUserMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.BDProfile, bool, error) { clause := "WHERE user_id = ?" if lock { @@ -591,6 +627,23 @@ func queryBDProfileByUserMaybe(ctx context.Context, q sqlQueryer, userID int64, return profile, true, nil } +func queryBDLeaderProfileByUserMaybe(ctx context.Context, q sqlQueryer, userID int64, lock bool) (hostdomain.BDProfile, bool, error) { + clause := "WHERE user_id = ?" + if lock { + // 后台创建/停用 Leader 使用主键锁,覆盖 active 和 disabled 两种状态。 + clause += " FOR UPDATE" + } + clause, args := appScopedClause(ctx, clause, userID) + profile, err := scanBDProfile(q.QueryRowContext(ctx, fmt.Sprintf(`SELECT %s FROM bd_leader_profiles %s`, bdLeaderProfileColumns, clause), args...)) + if err == sql.ErrNoRows { + return hostdomain.BDProfile{}, false, nil + } + if err != nil { + return hostdomain.BDProfile{}, false, err + } + return profile, true, nil +} + func scanBDProfile(scanner rowScanner) (hostdomain.BDProfile, error) { var profile hostdomain.BDProfile err := scanner.Scan( diff --git a/services/user-service/internal/storage/mysql/host/writes.go b/services/user-service/internal/storage/mysql/host/writes.go index d022fefa..b055da46 100644 --- a/services/user-service/internal/storage/mysql/host/writes.go +++ b/services/user-service/internal/storage/mysql/host/writes.go @@ -148,7 +148,7 @@ func updateAgencyAdminState(ctx context.Context, tx *sql.Tx, agency hostdomain.A } func insertBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { - // BD 身份行和 host_profile 相互独立;BD 用户不会自动成为 Host。 + // 普通 BD 身份行和 host_profile 相互独立;BD 用户不会自动成为 Host,也不会隐式拥有 BD Leader 权限。 _, err := tx.ExecContext(ctx, ` INSERT INTO bd_profiles ( app_code, user_id, role, region_id, parent_leader_user_id, status, @@ -158,11 +158,32 @@ func insertBDProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfi return err } +func insertBDLeaderProfile(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { + // BD Leader 使用独立表承载负责人身份,避免和普通 BD 工资、状态、团队归属混成同一条事实。 + _, err := tx.ExecContext(ctx, ` + INSERT INTO bd_leader_profiles ( + app_code, user_id, region_id, status, + created_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, appcode.FromContext(ctx), profile.UserID, profile.RegionID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs) + return err +} + func updateBDProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { // 状态变更只更新 status/updated_at_ms,不改角色、区域和上级 Leader 这些身份历史字段。 _, err := tx.ExecContext(ctx, ` UPDATE bd_profiles SET status = ?, updated_at_ms = ? + WHERE app_code = ? AND user_id = ? AND role = 'bd' + `, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) + return err +} + +func updateBDLeaderProfileStatus(ctx context.Context, tx *sql.Tx, profile hostdomain.BDProfile) error { + // Leader 状态只影响负责人权限和 ADMIN_SALARY_USD 钱包入口,不影响同用户可能存在的普通 BD 身份。 + _, err := tx.ExecContext(ctx, ` + UPDATE bd_leader_profiles + SET status = ?, updated_at_ms = ? WHERE app_code = ? AND user_id = ? `, profile.Status, profile.UpdatedAtMs, appcode.FromContext(ctx), profile.UserID) return err diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index 17289413..ee33605d 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -634,7 +634,7 @@ func (r *Repository) PutHostProfile(profile hostdomain.HostProfile) { } } -// PutBDProfile seeds a BD or BD Leader profile fact for host-domain tests. +// PutBDProfile seeds ordinary BD rows into bd_profiles and BD Leader rows into bd_leader_profiles. func (r *Repository) PutBDProfile(profile hostdomain.BDProfile) { r.t.Helper() @@ -653,6 +653,22 @@ func (r *Repository) PutBDProfile(profile hostdomain.BDProfile) { } profile.UpdatedAtMs = nowMs + if profile.Role == hostdomain.BDRoleLeader { + _, err := r.schema.DB.ExecContext(context.Background(), ` + INSERT INTO bd_leader_profiles ( + user_id, region_id, status, created_by_user_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + region_id = VALUES(region_id), + status = VALUES(status), + updated_at_ms = VALUES(updated_at_ms) + `, profile.UserID, profile.RegionID, profile.Status, profile.CreatedByUserID, profile.CreatedAtMs, profile.UpdatedAtMs) + if err != nil { + r.t.Fatalf("seed bd leader profile %d failed: %v", profile.UserID, err) + } + return + } + _, err := r.schema.DB.ExecContext(context.Background(), ` INSERT INTO bd_profiles ( user_id, role, region_id, parent_leader_user_id, status, diff --git a/services/user-service/internal/transport/grpc/bd_identity_flow_test.go b/services/user-service/internal/transport/grpc/bd_identity_flow_test.go new file mode 100644 index 00000000..d90063d2 --- /dev/null +++ b/services/user-service/internal/transport/grpc/bd_identity_flow_test.go @@ -0,0 +1,174 @@ +package grpc_test + +import ( + "context" + "testing" + "time" + + userv1 "hyapp.local/api/proto/user/v1" + "hyapp/pkg/xerr" + hostdomain "hyapp/services/user-service/internal/domain/host" + userdomain "hyapp/services/user-service/internal/domain/user" + authservice "hyapp/services/user-service/internal/service/auth" + hostservice "hyapp/services/user-service/internal/service/host" + "hyapp/services/user-service/internal/testutil/mysqltest" + grpcserver "hyapp/services/user-service/internal/transport/grpc" +) + +type grpcHostSequenceIDGenerator struct { + next int64 +} + +func (g *grpcHostSequenceIDGenerator) NewInt64() int64 { + id := g.next + g.next++ + return id +} + +func TestBDLeaderSelfInviteFlowThroughGRPCUsesSplitIdentityTables(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + repository.PutRegion(userdomain.Region{RegionID: 686, RegionCode: "BD-RPC", Name: "BD RPC Region"}) + seedActiveRPCUser(t, repository, 10001, 686) + + hostSvc := hostservice.New(repository.HostRepository(), + hostservice.WithIDGenerator(&grpcHostSequenceIDGenerator{next: 700000}), + hostservice.WithClock(func() time.Time { return time.UnixMilli(1700000000000) }), + ) + server := grpcserver.NewServer(authservice.New(authservice.Config{}), newUserService(repository), hostSvc) + meta := &userv1.RequestMeta{AppCode: "lalu", RequestId: "req-bd-grpc-flow"} + + leaderResp, err := server.CreateBDLeader(ctx, &userv1.CreateBDLeaderRequest{ + Meta: meta, + CommandId: "rpc-create-leader-10001", + AdminUserId: 90001, + TargetUserId: 10001, + Reason: "grpc split identity flow", + }) + if err != nil { + t.Fatalf("CreateBDLeader RPC failed: %v", err) + } + if leaderResp.GetBdProfile().GetRole() != hostdomain.BDRoleLeader || leaderResp.GetBdProfile().GetStatus() != hostdomain.BDStatusActive { + t.Fatalf("leader response mismatch: %+v", leaderResp.GetBdProfile()) + } + + if _, err := server.GetBDProfile(ctx, &userv1.GetBDProfileRequest{Meta: meta, UserId: 10001, Role: hostdomain.BDRoleBD}); xerr.ReasonFromGRPC(err) != xerr.NotFound { + t.Fatalf("leader-only user must not have ordinary bd before self invite: err=%v", err) + } + + inviteResp, err := server.InviteBD(ctx, &userv1.InviteBDRequest{ + Meta: meta, + CommandId: "rpc-self-invite-bd-10001", + InviterUserId: 10001, + TargetUserId: 10001, + }) + if err != nil { + t.Fatalf("InviteBD self RPC failed: %v", err) + } + invitation := inviteResp.GetInvitation() + if invitation.GetStatus() != hostdomain.InvitationStatusAccepted || invitation.GetTargetUserId() != 10001 || invitation.GetParentLeaderUserId() != 10001 { + t.Fatalf("self invite response mismatch: %+v", invitation) + } + retryInviteResp, err := server.InviteBD(ctx, &userv1.InviteBDRequest{ + Meta: meta, + CommandId: "rpc-self-invite-bd-10001", + InviterUserId: 10001, + TargetUserId: 10001, + }) + if err != nil { + t.Fatalf("InviteBD retry RPC failed: %v", err) + } + if retryInviteResp.GetInvitation().GetInvitationId() != invitation.GetInvitationId() { + t.Fatalf("self invite retry must replay same invitation: first=%+v retry=%+v", invitation, retryInviteResp.GetInvitation()) + } + + bdResp, err := server.GetBDProfile(ctx, &userv1.GetBDProfileRequest{Meta: meta, UserId: 10001, Role: hostdomain.BDRoleBD}) + if err != nil { + t.Fatalf("GetBDProfile role=bd RPC failed after self invite: %v", err) + } + if bdResp.GetBdProfile().GetRole() != hostdomain.BDRoleBD || bdResp.GetBdProfile().GetParentLeaderUserId() != 10001 { + t.Fatalf("ordinary bd profile mismatch: %+v", bdResp.GetBdProfile()) + } + leaderCheckResp, err := server.GetBDProfile(ctx, &userv1.GetBDProfileRequest{Meta: meta, UserId: 10001, Role: hostdomain.BDRoleLeader}) + if err != nil { + t.Fatalf("GetBDProfile role=bd_leader RPC failed: %v", err) + } + if leaderCheckResp.GetBdProfile().GetRole() != hostdomain.BDRoleLeader || leaderCheckResp.GetBdProfile().GetStatus() != hostdomain.BDStatusActive { + t.Fatalf("leader profile changed unexpectedly: %+v", leaderCheckResp.GetBdProfile()) + } + + summaryResp, err := server.GetUserRoleSummary(ctx, &userv1.GetUserRoleSummaryRequest{Meta: meta, UserId: 10001}) + if err != nil { + t.Fatalf("GetUserRoleSummary RPC failed: %v", err) + } + if !summaryResp.GetSummary().GetIsBd() || !summaryResp.GetSummary().GetIsBdLeader() { + t.Fatalf("summary must expose both independent identities: %+v", summaryResp.GetSummary()) + } + + disabledBDResp, err := server.SetBDStatus(ctx, &userv1.SetBDStatusRequest{ + Meta: meta, + CommandId: "rpc-disable-bd-10001", + AdminUserId: 90001, + TargetUserId: 10001, + Role: hostdomain.BDRoleBD, + Status: hostdomain.BDStatusDisabled, + Reason: "disable ordinary bd only", + }) + if err != nil { + t.Fatalf("SetBDStatus role=bd RPC failed: %v", err) + } + if disabledBDResp.GetBdProfile().GetRole() != hostdomain.BDRoleBD || disabledBDResp.GetBdProfile().GetStatus() != hostdomain.BDStatusDisabled { + t.Fatalf("disabled ordinary bd response mismatch: %+v", disabledBDResp.GetBdProfile()) + } + leaderStillActiveResp, err := server.GetBDProfile(ctx, &userv1.GetBDProfileRequest{Meta: meta, UserId: 10001, Role: hostdomain.BDRoleLeader}) + if err != nil { + t.Fatalf("GetBDProfile role=bd_leader after bd disable failed: %v", err) + } + if leaderStillActiveResp.GetBdProfile().GetStatus() != hostdomain.BDStatusActive { + t.Fatalf("disabling ordinary bd must not affect leader: %+v", leaderStillActiveResp.GetBdProfile()) + } + + disabledLeaderResp, err := server.SetBDStatus(ctx, &userv1.SetBDStatusRequest{ + Meta: meta, + CommandId: "rpc-disable-leader-10001", + AdminUserId: 90001, + TargetUserId: 10001, + Role: hostdomain.BDRoleLeader, + Status: hostdomain.BDStatusDisabled, + Reason: "disable leader only", + }) + if err != nil { + t.Fatalf("SetBDStatus role=bd_leader RPC failed: %v", err) + } + if disabledLeaderResp.GetBdProfile().GetRole() != hostdomain.BDRoleLeader || disabledLeaderResp.GetBdProfile().GetStatus() != hostdomain.BDStatusDisabled { + t.Fatalf("disabled leader response mismatch: %+v", disabledLeaderResp.GetBdProfile()) + } + + var bdRows int + var leaderRows int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(1) FROM bd_profiles WHERE app_code = 'lalu' AND user_id = 10001 AND role = 'bd'`).Scan(&bdRows); err != nil { + t.Fatalf("count ordinary bd rows failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(1) FROM bd_leader_profiles WHERE app_code = 'lalu' AND user_id = 10001`).Scan(&leaderRows); err != nil { + t.Fatalf("count leader rows failed: %v", err) + } + if bdRows != 1 || leaderRows != 1 { + t.Fatalf("split identity rows mismatch: bd_rows=%d leader_rows=%d", bdRows, leaderRows) + } +} + +func seedActiveRPCUser(t *testing.T, repository *mysqltest.Repository, userID int64, regionID int64) { + t.Helper() + repository.PutUser(userdomain.User{ + UserID: userID, + DefaultDisplayUserID: "910001", + CurrentDisplayUserID: "910001", + CurrentDisplayUserIDKind: userdomain.DisplayUserIDKindDefault, + Country: "CN", + RegionID: regionID, + ProfileCompleted: true, + ProfileCompletedAtMs: 1, + OnboardingStatus: userdomain.OnboardingStatusCompleted, + Status: userdomain.StatusActive, + }) +} diff --git a/services/user-service/internal/transport/grpc/host.go b/services/user-service/internal/transport/grpc/host.go index 9c49870b..c6877162 100644 --- a/services/user-service/internal/transport/grpc/host.go +++ b/services/user-service/internal/transport/grpc/host.go @@ -124,7 +124,7 @@ func (s *Server) InviteBD(ctx context.Context, req *userv1.InviteBDRequest) (*us return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) } ctx = contextWithApp(ctx, req.GetMeta()) - // BD 负责人授权在持久化层内基于 bd_profiles 当前事实判断。 + // BD 负责人授权在持久化层内基于 bd_leader_profiles 当前事实判断。 invitation, err := s.hostSvc.InviteBD(ctx, hostservice.InviteBDInput{ CommandID: req.GetCommandId(), InviterUserID: req.GetInviterUserId(), @@ -244,13 +244,13 @@ func (s *Server) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileR return &userv1.GetHostProfileResponse{HostProfile: toProtoHostProfile(profile)}, nil } -// GetBDProfile 返回当前用户 BD/BD 负责人身份事实。 +// GetBDProfile 按调用方声明的角色返回普通 BD 或 BD Leader 身份事实。 func (s *Server) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) { if s.hostSvc == nil { return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured")) } ctx = contextWithApp(ctx, req.GetMeta()) - profile, err := s.hostSvc.GetBDProfile(ctx, req.GetUserId()) + profile, err := s.hostSvc.GetBDProfile(ctx, req.GetUserId(), req.GetRole()) if err != nil { return nil, xerr.ToGRPCError(err) } diff --git a/services/user-service/internal/transport/grpc/host_admin.go b/services/user-service/internal/transport/grpc/host_admin.go index 7f950bbf..0856a00e 100644 --- a/services/user-service/internal/transport/grpc/host_admin.go +++ b/services/user-service/internal/transport/grpc/host_admin.go @@ -57,6 +57,7 @@ func (s *Server) SetBDStatus(ctx context.Context, req *userv1.SetBDStatusRequest CommandID: req.GetCommandId(), AdminUserID: req.GetAdminUserId(), TargetUserID: req.GetTargetUserId(), + Role: req.GetRole(), Status: req.GetStatus(), Reason: req.GetReason(), RequestID: req.GetMeta().GetRequestId(),