拆分 BD 与 BD Leader 身份模型

This commit is contained in:
zhx 2026-06-10 11:08:46 +08:00
parent f172ed8178
commit 0dea7c9b43
26 changed files with 773 additions and 105 deletions

View File

@ -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" +

View File

@ -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 {

View File

@ -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';

View File

@ -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,
})

View File

@ -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)
}

View File

@ -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

View File

@ -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),
})

View File

@ -653,22 +653,30 @@ type bdProfile struct {
ParentLeaderUserID int64
}
// bdProfilesByUser 按角色取 active profileBD 和 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
}

View File

@ -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)
)`,
}
}

View File

@ -4118,7 +4118,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" {
@ -4165,7 +4165,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
@ -4179,6 +4179,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{})

View File

@ -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
}

View File

@ -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)

View File

@ -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',

View File

@ -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"

View File

@ -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

View File

@ -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 在发起币商转账前校验。

View File

@ -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 {

View File

@ -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")
}

View File

@ -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`

View File

@ -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,

View File

@ -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(

View File

@ -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

View File

@ -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,

View File

@ -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,
})
}

View File

@ -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)
}

View File

@ -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(),