币商列表
This commit is contained in:
parent
a68d465272
commit
463c208640
File diff suppressed because it is too large
Load Diff
@ -56,6 +56,24 @@ message CoinSellerProfile {
|
||||
int64 updated_at_ms = 6;
|
||||
}
|
||||
|
||||
// CoinSellerListItem 是 App 充值页展示的同区域启用币商投影。
|
||||
message CoinSellerListItem {
|
||||
int64 user_id = 1;
|
||||
string display_user_id = 2;
|
||||
string username = 3;
|
||||
string avatar = 4;
|
||||
int64 country_id = 5;
|
||||
string country_code = 6;
|
||||
string country_name = 7;
|
||||
string country_display_name = 8;
|
||||
int64 region_id = 9;
|
||||
string region_code = 10;
|
||||
string region_name = 11;
|
||||
string status = 12;
|
||||
string merchant_asset_type = 13;
|
||||
int64 updated_at_ms = 14;
|
||||
}
|
||||
|
||||
// UserRoleSummary 是 App 我的页和入口显隐使用的轻量角色摘要。
|
||||
// 它把 host、Agency、manager、BD 和币商身份收敛到一次 user-service RPC,避免 gateway 随角色扩张继续 fanout。
|
||||
message UserRoleSummary {
|
||||
@ -243,6 +261,15 @@ message GetCoinSellerProfileResponse {
|
||||
CoinSellerProfile coin_seller_profile = 1;
|
||||
}
|
||||
|
||||
message ListActiveCoinSellersInMyRegionRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
}
|
||||
|
||||
message ListActiveCoinSellersInMyRegionResponse {
|
||||
repeated CoinSellerListItem coin_sellers = 1;
|
||||
}
|
||||
|
||||
message GetUserRoleSummaryRequest {
|
||||
RequestMeta meta = 1;
|
||||
int64 user_id = 2;
|
||||
@ -402,6 +429,7 @@ service UserHostService {
|
||||
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
|
||||
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
|
||||
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
|
||||
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);
|
||||
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
|
||||
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);
|
||||
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);
|
||||
|
||||
@ -19,20 +19,21 @@ import (
|
||||
const _ = grpc.SupportPackageIsVersion9
|
||||
|
||||
const (
|
||||
UserHostService_SearchAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/SearchAgencies"
|
||||
UserHostService_ApplyToAgency_FullMethodName = "/hyapp.user.v1.UserHostService/ApplyToAgency"
|
||||
UserHostService_ReviewAgencyApplication_FullMethodName = "/hyapp.user.v1.UserHostService/ReviewAgencyApplication"
|
||||
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
|
||||
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
|
||||
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
|
||||
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
|
||||
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
|
||||
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
|
||||
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
|
||||
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
|
||||
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
|
||||
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
|
||||
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
|
||||
UserHostService_SearchAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/SearchAgencies"
|
||||
UserHostService_ApplyToAgency_FullMethodName = "/hyapp.user.v1.UserHostService/ApplyToAgency"
|
||||
UserHostService_ReviewAgencyApplication_FullMethodName = "/hyapp.user.v1.UserHostService/ReviewAgencyApplication"
|
||||
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
|
||||
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
|
||||
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
|
||||
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
|
||||
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
|
||||
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
|
||||
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
|
||||
UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
|
||||
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
|
||||
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
|
||||
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
|
||||
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
|
||||
)
|
||||
|
||||
// UserHostServiceClient is the client API for UserHostService service.
|
||||
@ -51,6 +52,7 @@ type UserHostServiceClient interface {
|
||||
GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
|
||||
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, error)
|
||||
ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error)
|
||||
GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
|
||||
CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error)
|
||||
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, error)
|
||||
@ -165,6 +167,16 @@ func (c *userHostServiceClient) GetCoinSellerProfile(ctx context.Context, in *Ge
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) ListActiveCoinSellersInMyRegion(ctx context.Context, in *ListActiveCoinSellersInMyRegionRequest, opts ...grpc.CallOption) (*ListActiveCoinSellersInMyRegionResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(ListActiveCoinSellersInMyRegionResponse)
|
||||
err := c.cc.Invoke(ctx, UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userHostServiceClient) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(GetUserRoleSummaryResponse)
|
||||
@ -221,6 +233,7 @@ type UserHostServiceServer interface {
|
||||
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
|
||||
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
|
||||
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
|
||||
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
|
||||
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
|
||||
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, error)
|
||||
@ -265,6 +278,9 @@ func (UnimplementedUserHostServiceServer) GetBDProfile(context.Context, *GetBDPr
|
||||
func (UnimplementedUserHostServiceServer) GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetCoinSellerProfile not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method ListActiveCoinSellersInMyRegion not implemented")
|
||||
}
|
||||
func (UnimplementedUserHostServiceServer) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) {
|
||||
return nil, status.Error(codes.Unimplemented, "method GetUserRoleSummary not implemented")
|
||||
}
|
||||
@ -478,6 +494,24 @@ func _UserHostService_GetCoinSellerProfile_Handler(srv interface{}, ctx context.
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_ListActiveCoinSellersInMyRegion_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(ListActiveCoinSellersInMyRegionRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserHostServiceServer).ListActiveCoinSellersInMyRegion(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserHostServiceServer).ListActiveCoinSellersInMyRegion(ctx, req.(*ListActiveCoinSellersInMyRegionRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserHostService_GetUserRoleSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(GetUserRoleSummaryRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -597,6 +631,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "GetCoinSellerProfile",
|
||||
Handler: _UserHostService_GetCoinSellerProfile_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "ListActiveCoinSellersInMyRegion",
|
||||
Handler: _UserHostService_ListActiveCoinSellersInMyRegion_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "GetUserRoleSummary",
|
||||
Handler: _UserHostService_GetUserRoleSummary_Handler,
|
||||
|
||||
149
docs/flutter对接/币商列表Flutter对接.md
Normal file
149
docs/flutter对接/币商列表Flutter对接.md
Normal file
@ -0,0 +1,149 @@
|
||||
# 币商列表 Flutter 对接
|
||||
|
||||
本文描述 Flutter App 获取当前用户所在区域启用币商列表的接口。币商身份、状态和区域过滤由 `user-service` 维护;App 只展示 gateway 返回的列表,不传 `region_id`,也不在本地缓存为跨区域通用数据。
|
||||
|
||||
## 接口
|
||||
|
||||
`GET /api/v1/wallet/coin-sellers`
|
||||
|
||||
登录接口,需要 `Authorization: Bearer <access_token>`。接口按当前登录用户的 `users.region_id` 查询同区域、状态为 `active`、资产类型为 `COIN_SELLER_COIN` 的币商。
|
||||
|
||||
请求头:
|
||||
|
||||
| Header | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization` | 是 | App 登录 access token。 |
|
||||
| `X-App-Code` | 否 | App 编码,默认 `lalu`。 |
|
||||
| `X-Request-ID` | 否 | 客户端链路 ID;服务端也会生成并在响应中返回。 |
|
||||
|
||||
Query:无。不要传 `region_id`、`country_id` 或 `user_id`。
|
||||
|
||||
示例:
|
||||
|
||||
```http
|
||||
GET /api/v1/wallet/coin-sellers
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
```
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"user_id": "801",
|
||||
"display_user_id": "900801",
|
||||
"username": "Seller One",
|
||||
"avatar": "https://cdn.example/avatar.png",
|
||||
"country_id": 86,
|
||||
"country_code": "CN",
|
||||
"country_name": "China",
|
||||
"country_display_name": "中国",
|
||||
"region_id": 30,
|
||||
"region_code": "east-asia",
|
||||
"region_name": "East Asia",
|
||||
"status": "active",
|
||||
"merchant_asset_type": "COIN_SELLER_COIN",
|
||||
"updated_at_ms": 1700000001000
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段规则:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `user_id` | 币商用户长 ID,JSON 中按字符串返回,避免 Dart/JS 大整数精度问题。 |
|
||||
| `display_user_id` | 当前展示短号,可用于转账页展示和输入确认。 |
|
||||
| `username` | 币商昵称;为空时客户端可展示短号。 |
|
||||
| `avatar` | 币商头像 URL;为空时展示默认头像。 |
|
||||
| `country_id` | 币商用户当前国家 ID。 |
|
||||
| `country_code` | 国家编码,例如 `CN`。 |
|
||||
| `country_name` | 英文或标准国家名。 |
|
||||
| `country_display_name` | 客户端展示优先使用的国家名。 |
|
||||
| `region_id` | 币商用户当前区域 ID;应与当前用户区域一致。 |
|
||||
| `status` | 当前只会返回 `active`。 |
|
||||
| `merchant_asset_type` | 当前固定为 `COIN_SELLER_COIN`。 |
|
||||
| `updated_at_ms` | 币商身份最近更新时间,Unix epoch milliseconds。 |
|
||||
|
||||
## Flutter 解析示例
|
||||
|
||||
```dart
|
||||
class CoinSellerList {
|
||||
CoinSellerList({
|
||||
required this.items,
|
||||
required this.total,
|
||||
});
|
||||
|
||||
final List<CoinSeller> items;
|
||||
final int total;
|
||||
|
||||
factory CoinSellerList.fromJson(Map<String, dynamic> json) {
|
||||
return CoinSellerList(
|
||||
items: (json['items'] as List<dynamic>? ?? const [])
|
||||
.map((item) => CoinSeller.fromJson(item as Map<String, dynamic>))
|
||||
.toList(),
|
||||
total: (json['total'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CoinSeller {
|
||||
CoinSeller({
|
||||
required this.userId,
|
||||
required this.displayUserId,
|
||||
required this.username,
|
||||
required this.avatar,
|
||||
required this.countryId,
|
||||
required this.countryDisplayName,
|
||||
required this.regionId,
|
||||
required this.status,
|
||||
required this.merchantAssetType,
|
||||
required this.updatedAtMs,
|
||||
});
|
||||
|
||||
final String userId;
|
||||
final String displayUserId;
|
||||
final String username;
|
||||
final String avatar;
|
||||
final int countryId;
|
||||
final String countryDisplayName;
|
||||
final int regionId;
|
||||
final String status;
|
||||
final String merchantAssetType;
|
||||
final int updatedAtMs;
|
||||
|
||||
String get title => username.isNotEmpty ? username : displayUserId;
|
||||
|
||||
factory CoinSeller.fromJson(Map<String, dynamic> json) {
|
||||
return CoinSeller(
|
||||
userId: json['user_id'] as String? ?? '',
|
||||
displayUserId: json['display_user_id'] as String? ?? '',
|
||||
username: json['username'] as String? ?? '',
|
||||
avatar: json['avatar'] as String? ?? '',
|
||||
countryId: (json['country_id'] as num?)?.toInt() ?? 0,
|
||||
countryDisplayName: json['country_display_name'] as String? ?? '',
|
||||
regionId: (json['region_id'] as num?)?.toInt() ?? 0,
|
||||
status: json['status'] as String? ?? '',
|
||||
merchantAssetType: json['merchant_asset_type'] as String? ?? '',
|
||||
updatedAtMs: (json['updated_at_ms'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 客户端处理
|
||||
|
||||
1. 先解析外层 envelope,只有 `code == "OK"` 时读取 `data.items`。
|
||||
2. 空列表表示当前区域暂无启用币商;展示空状态,不切换到其他区域。
|
||||
3. 点击币商进入转账或联系流程时,使用 `display_user_id` 做用户可见确认,实际后续接口仍以服务端解析结果为准。
|
||||
4. 用户切换国家或完成资料变更后重新拉取本接口,不复用旧区域列表。
|
||||
5. 请求失败时记录 `request_id`,按登录失效、参数错误、服务错误分别处理。
|
||||
@ -97,7 +97,7 @@ type AppBanner struct {
|
||||
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"`
|
||||
RoomSmallImageURL string `gorm:"column:room_small_image_url;size:1024;not null;default:'';comment:房间内小屏图 URL" json:"roomSmallImageUrl"`
|
||||
BannerType string `gorm:"size:16;not null" json:"bannerType"`
|
||||
DisplayScope string `gorm:"size:32;index:idx_admin_app_banners_display,not null;default:home;comment:显示范围:首页、房间内或充值页" json:"displayScope"`
|
||||
DisplayScope string `gorm:"size:128;index:idx_admin_app_banners_display,not null;default:home;comment:显示范围,多选逗号分隔:首页、房间内或充值页" json:"displayScope"`
|
||||
Param string `gorm:"size:2048" json:"param"`
|
||||
Status string `gorm:"size:24;index:idx_admin_app_banners_app_sort,not null;default:active" json:"status"`
|
||||
Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"`
|
||||
|
||||
@ -11,19 +11,20 @@ type h5LinkPayload struct {
|
||||
}
|
||||
|
||||
type bannerRequest struct {
|
||||
CoverURL string `json:"coverUrl" binding:"required"`
|
||||
RoomSmallImageURL string `json:"roomSmallImageUrl"`
|
||||
BannerType string `json:"bannerType" binding:"required"`
|
||||
DisplayScope string `json:"displayScope"`
|
||||
Param string `json:"param"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Description string `json:"description"`
|
||||
StartsAtMs int64 `json:"startsAtMs"`
|
||||
EndsAtMs int64 `json:"endsAtMs"`
|
||||
CoverURL string `json:"coverUrl" binding:"required"`
|
||||
RoomSmallImageURL string `json:"roomSmallImageUrl"`
|
||||
BannerType string `json:"bannerType" binding:"required"`
|
||||
DisplayScope string `json:"displayScope"`
|
||||
DisplayScopes []string `json:"displayScopes"`
|
||||
Param string `json:"param"`
|
||||
Status string `json:"status" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Description string `json:"description"`
|
||||
StartsAtMs int64 `json:"startsAtMs"`
|
||||
EndsAtMs int64 `json:"endsAtMs"`
|
||||
}
|
||||
|
||||
type appVersionRequest struct {
|
||||
|
||||
@ -25,6 +25,8 @@ const (
|
||||
bannerStatusExpired = "expired"
|
||||
)
|
||||
|
||||
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge}
|
||||
|
||||
type AppConfigService struct {
|
||||
store *repository.Store
|
||||
}
|
||||
@ -37,23 +39,24 @@ type H5Link struct {
|
||||
}
|
||||
|
||||
type AppBanner struct {
|
||||
ID uint `json:"id"`
|
||||
AppCode string `json:"appCode"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
RoomSmallImageURL string `json:"roomSmallImageUrl"`
|
||||
BannerType string `json:"bannerType"`
|
||||
DisplayScope string `json:"displayScope"`
|
||||
Param string `json:"param"`
|
||||
Status string `json:"status"`
|
||||
Platform string `json:"platform"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Description string `json:"description"`
|
||||
StartsAtMs int64 `json:"startsAtMs"`
|
||||
EndsAtMs int64 `json:"endsAtMs"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
ID uint `json:"id"`
|
||||
AppCode string `json:"appCode"`
|
||||
CoverURL string `json:"coverUrl"`
|
||||
RoomSmallImageURL string `json:"roomSmallImageUrl"`
|
||||
BannerType string `json:"bannerType"`
|
||||
DisplayScope string `json:"displayScope"`
|
||||
DisplayScopes []string `json:"displayScopes"`
|
||||
Param string `json:"param"`
|
||||
Status string `json:"status"`
|
||||
Platform string `json:"platform"`
|
||||
SortOrder int `json:"sortOrder"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
CountryCode string `json:"countryCode"`
|
||||
Description string `json:"description"`
|
||||
StartsAtMs int64 `json:"startsAtMs"`
|
||||
EndsAtMs int64 `json:"endsAtMs"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type AppVersion struct {
|
||||
@ -391,12 +394,13 @@ func h5LinkFromModel(config model.AppConfig) H5Link {
|
||||
}
|
||||
|
||||
func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
|
||||
displayScopes := normalizeBannerDisplayScopes(req.DisplayScopes, req.DisplayScope)
|
||||
item := model.AppBanner{
|
||||
AppCode: appctx.Normalize(appCode),
|
||||
CoverURL: strings.TrimSpace(req.CoverURL),
|
||||
RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL),
|
||||
BannerType: normalizeBannerType(req.BannerType),
|
||||
DisplayScope: normalizeBannerDisplayScope(req.DisplayScope),
|
||||
DisplayScope: bannerDisplayScopeValue(displayScopes),
|
||||
Param: strings.TrimSpace(req.Param),
|
||||
Status: normalizeBannerStatus(req.Status),
|
||||
Platform: normalizeBannerPlatform(req.Platform),
|
||||
@ -413,10 +417,10 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
|
||||
if containsWhitespace(item.CoverURL) {
|
||||
return model.AppBanner{}, errors.New("banner cover cannot contain whitespace")
|
||||
}
|
||||
if item.DisplayScope != bannerDisplayScopeHome && item.DisplayScope != bannerDisplayScopeRoom && item.DisplayScope != bannerDisplayScopeRecharge {
|
||||
if !validBannerDisplayScopes(displayScopes) {
|
||||
return model.AppBanner{}, errors.New("banner display scope is invalid")
|
||||
}
|
||||
if item.DisplayScope == bannerDisplayScopeRoom {
|
||||
if bannerDisplayScopesContain(displayScopes, bannerDisplayScopeRoom) {
|
||||
if item.RoomSmallImageURL == "" || len(item.RoomSmallImageURL) > 1024 {
|
||||
return model.AppBanner{}, errors.New("room small image is required")
|
||||
}
|
||||
@ -424,7 +428,7 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
|
||||
return model.AppBanner{}, errors.New("room small image cannot contain whitespace")
|
||||
}
|
||||
} else {
|
||||
// 非房间内投放不保留房间小屏图,避免客户端误用其它显示范围的专属素材。
|
||||
// 未投放到房间内时不保留小屏图,避免客户端误用房间专属素材。
|
||||
item.RoomSmallImageURL = ""
|
||||
}
|
||||
if item.BannerType != "h5" && item.BannerType != "app" {
|
||||
@ -476,6 +480,7 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
|
||||
RoomSmallImageURL: item.RoomSmallImageURL,
|
||||
BannerType: item.BannerType,
|
||||
DisplayScope: item.DisplayScope,
|
||||
DisplayScopes: bannerDisplayScopeList(item.DisplayScope),
|
||||
Param: item.Param,
|
||||
Status: item.Status,
|
||||
Platform: item.Platform,
|
||||
@ -599,6 +604,71 @@ func normalizeBannerDisplayScope(value string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBannerDisplayScopes(values []string, fallback string) []string {
|
||||
seen := make(map[string]struct{}, len(bannerDisplayScopeOrder))
|
||||
for _, value := range values {
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
scope := normalizeBannerDisplayScope(part)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
for _, part := range strings.Split(fallback, ",") {
|
||||
scope := normalizeBannerDisplayScope(part)
|
||||
if scope == "" {
|
||||
continue
|
||||
}
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
seen[bannerDisplayScopeHome] = struct{}{}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, scope := range bannerDisplayScopeOrder {
|
||||
if _, ok := seen[scope]; ok {
|
||||
out = append(out, scope)
|
||||
delete(seen, scope)
|
||||
}
|
||||
}
|
||||
for scope := range seen {
|
||||
out = append(out, scope)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func bannerDisplayScopeList(value string) []string {
|
||||
return normalizeBannerDisplayScopes(nil, value)
|
||||
}
|
||||
|
||||
func bannerDisplayScopeValue(scopes []string) string {
|
||||
return strings.Join(scopes, ",")
|
||||
}
|
||||
|
||||
func validBannerDisplayScopes(scopes []string) bool {
|
||||
if len(scopes) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
if scope != bannerDisplayScopeHome && scope != bannerDisplayScopeRoom && scope != bannerDisplayScopeRecharge {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func bannerDisplayScopesContain(scopes []string, target string) bool {
|
||||
for _, scope := range scopes {
|
||||
if scope == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeBannerPlatform(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
@ -3,6 +3,8 @@ package appconfig
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp-admin-server/internal/model"
|
||||
)
|
||||
|
||||
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
|
||||
@ -52,7 +54,7 @@ func TestBannerModelFromRequestKeepsRoomScopeScheduleFields(t *testing.T) {
|
||||
CoverURL: "https://cdn.example.com/banner.png",
|
||||
RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
|
||||
BannerType: "h5",
|
||||
DisplayScope: bannerDisplayScopeRoom,
|
||||
DisplayScopes: []string{bannerDisplayScopeHome, bannerDisplayScopeRoom},
|
||||
Param: "https://h5.example.com/activity",
|
||||
Status: bannerStatusActive,
|
||||
Platform: "android",
|
||||
@ -62,7 +64,7 @@ func TestBannerModelFromRequestKeepsRoomScopeScheduleFields(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("banner model should be valid: %v", err)
|
||||
}
|
||||
if item.DisplayScope != bannerDisplayScopeRoom || item.RoomSmallImageURL == "" {
|
||||
if item.DisplayScope != "home,room" || item.RoomSmallImageURL == "" {
|
||||
t.Fatalf("room display fields mismatch: %+v", item)
|
||||
}
|
||||
if item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs || item.Status != bannerStatusActive {
|
||||
@ -91,6 +93,30 @@ func TestBannerModelFromRequestExpiresEndedActiveBanner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppBannerFromModelReturnsDisplayScopes(t *testing.T) {
|
||||
item := appBannerFromModel(bannerModel("home,room,recharge"))
|
||||
|
||||
if item.DisplayScope != "home,room,recharge" {
|
||||
t.Fatalf("display scope should keep stored value: %+v", item)
|
||||
}
|
||||
if len(item.DisplayScopes) != 3 || item.DisplayScopes[0] != bannerDisplayScopeHome || item.DisplayScopes[1] != bannerDisplayScopeRoom || item.DisplayScopes[2] != bannerDisplayScopeRecharge {
|
||||
t.Fatalf("display scopes mismatch: %+v", item.DisplayScopes)
|
||||
}
|
||||
}
|
||||
|
||||
func bannerModel(displayScope string) model.AppBanner {
|
||||
return model.AppBanner{
|
||||
ID: 1,
|
||||
AppCode: "lalu",
|
||||
CoverURL: "https://cdn.example.com/banner.png",
|
||||
BannerType: "h5",
|
||||
DisplayScope: displayScope,
|
||||
Param: "https://h5.example.com/activity",
|
||||
Status: bannerStatusActive,
|
||||
Platform: "android",
|
||||
}
|
||||
}
|
||||
|
||||
func TestExploreTabModelFromRequestValidatesRequiredFields(t *testing.T) {
|
||||
item, err := exploreTabModelFromRequest("lalu", exploreTabRequest{
|
||||
Enabled: true,
|
||||
|
||||
@ -23,7 +23,7 @@ func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner,
|
||||
query = query.Where("status = ?", strings.TrimSpace(options.Status))
|
||||
}
|
||||
if options.DisplayScope != "" {
|
||||
query = query.Where("display_scope = ?", strings.TrimSpace(options.DisplayScope))
|
||||
query = query.Where("FIND_IN_SET(?, display_scope) > 0", strings.TrimSpace(options.DisplayScope))
|
||||
}
|
||||
if options.Platform != "" {
|
||||
query = query.Where("platform = ?", strings.TrimSpace(options.Platform))
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- Banner 显示范围支持多选,display_scope 以稳定逗号分隔值保存,例如 home,room,recharge。
|
||||
|
||||
ALTER TABLE admin_app_banners
|
||||
MODIFY COLUMN display_scope VARCHAR(128) NOT NULL DEFAULT 'home' COMMENT '显示范围,多选逗号分隔:首页、房间内或充值页';
|
||||
@ -22,7 +22,7 @@ const listExploreTabsSQL = `
|
||||
const listAppBannersSQL = `
|
||||
SELECT id, app_code, cover_url, room_small_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
|
||||
FROM admin_app_banners
|
||||
WHERE app_code = ? AND status = 'active' AND display_scope = ?
|
||||
WHERE app_code = ? AND status = 'active' AND FIND_IN_SET(?, display_scope) > 0
|
||||
AND (? = '' OR platform = ?)
|
||||
AND (region_id = 0 OR region_id = ?)
|
||||
AND (country_code = '' OR country_code = ?)
|
||||
@ -66,20 +66,21 @@ type BannerQuery struct {
|
||||
|
||||
// Banner 是 gateway 下发给 App 指定显示范围的 banner 配置。
|
||||
type Banner struct {
|
||||
ID uint `json:"id"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
|
||||
BannerType string `json:"type"`
|
||||
DisplayScope string `json:"display_scope"`
|
||||
Param string `json:"param"`
|
||||
Platform string `json:"platform"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
RegionID int64 `json:"region_id,omitempty"`
|
||||
CountryCode string `json:"country_code,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartsAtMs int64 `json:"starts_at_ms,omitempty"`
|
||||
EndsAtMs int64 `json:"ends_at_ms,omitempty"`
|
||||
UpdatedAtMs int64 `json:"updated_at_ms"`
|
||||
ID uint `json:"id"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
|
||||
BannerType string `json:"type"`
|
||||
DisplayScope string `json:"display_scope"`
|
||||
DisplayScopes []string `json:"display_scopes,omitempty"`
|
||||
Param string `json:"param"`
|
||||
Platform string `json:"platform"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
RegionID int64 `json:"region_id,omitempty"`
|
||||
CountryCode string `json:"country_code,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
StartsAtMs int64 `json:"starts_at_ms,omitempty"`
|
||||
EndsAtMs int64 `json:"ends_at_ms,omitempty"`
|
||||
UpdatedAtMs int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
// VersionQuery 是 App 检查更新的公开筛选条件。
|
||||
@ -237,6 +238,7 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
|
||||
return nil, err
|
||||
}
|
||||
item.UpdatedAtMs = updatedAtMS
|
||||
item.DisplayScopes = bannerDisplayScopeList(item.DisplayScope)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@ -311,6 +313,11 @@ func (r StaticReader) ListBanners(context.Context, BannerQuery) ([]Banner, error
|
||||
|
||||
out := make([]Banner, len(r.Banners))
|
||||
copy(out, r.Banners)
|
||||
for index := range out {
|
||||
if len(out[index].DisplayScopes) == 0 {
|
||||
out[index].DisplayScopes = bannerDisplayScopeList(out[index].DisplayScope)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -361,3 +368,20 @@ func NormalizeBannerDisplayScope(value string) string {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func bannerDisplayScopeList(value string) []string {
|
||||
seen := make(map[string]struct{}, 3)
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
scope := NormalizeBannerDisplayScope(part)
|
||||
if scope != "" {
|
||||
seen[scope] = struct{}{}
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, scope := range []string{"home", "room", "recharge"} {
|
||||
if _, ok := seen[scope]; ok {
|
||||
out = append(out, scope)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@ -17,3 +17,16 @@ func TestNormalizeBannerDisplayScopeAcceptsChineseLabels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBannerDisplayScopeListParsesMultiScopeValue(t *testing.T) {
|
||||
got := bannerDisplayScopeList("room,home,recharge")
|
||||
want := []string{"home", "room", "recharge"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("scope count mismatch: got %+v want %+v", got, want)
|
||||
}
|
||||
for index := range want {
|
||||
if got[index] != want[index] {
|
||||
t.Fatalf("scope %d mismatch: got %+v want %+v", index, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,6 +71,7 @@ type UserHostClient interface {
|
||||
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
|
||||
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
|
||||
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, error)
|
||||
ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error)
|
||||
GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error)
|
||||
CheckBusinessCapability(ctx context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, error)
|
||||
}
|
||||
@ -316,6 +317,10 @@ func (c *grpcUserHostClient) GetCoinSellerProfile(ctx context.Context, req *user
|
||||
return c.client.GetCoinSellerProfile(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) {
|
||||
return c.client.ListActiveCoinSellersInMyRegion(ctx, req)
|
||||
}
|
||||
|
||||
func (c *grpcUserHostClient) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
|
||||
return c.client.GetUserRoleSummary(ctx, req)
|
||||
}
|
||||
|
||||
@ -162,6 +162,7 @@ type WalletHandlers struct {
|
||||
ApplyWithdrawal http.HandlerFunc
|
||||
ListCoinTransactions http.HandlerFunc
|
||||
ListWalletTransactions http.HandlerFunc
|
||||
ListCoinSellers http.HandlerFunc
|
||||
TransferCoinFromSeller http.HandlerFunc
|
||||
GetRedPacketConfig http.HandlerFunc
|
||||
ListRoomRedPackets http.HandlerFunc
|
||||
@ -373,6 +374,7 @@ func (r routes) registerWalletRoutes() {
|
||||
r.profile("/wallet/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
|
||||
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
|
||||
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions)
|
||||
r.profile("/wallet/coin-sellers", http.MethodGet, h.ListCoinSellers)
|
||||
r.profile("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
|
||||
r.profile("/red-packets/config", http.MethodGet, h.GetRedPacketConfig)
|
||||
r.profile("/rooms/{room_id}/red-packets", http.MethodGet, h.ListRoomRedPackets)
|
||||
|
||||
@ -319,27 +319,30 @@ type fakeUserCountryQueryClient struct {
|
||||
}
|
||||
|
||||
type fakeUserHostClient struct {
|
||||
lastSearchAgencies *userv1.SearchAgenciesRequest
|
||||
searchAgenciesResp *userv1.SearchAgenciesResponse
|
||||
searchAgenciesErr error
|
||||
lastApplyAgency *userv1.ApplyToAgencyRequest
|
||||
applyAgencyResp *userv1.ApplyToAgencyResponse
|
||||
applyAgencyErr error
|
||||
last *userv1.GetCoinSellerProfileRequest
|
||||
profile *userv1.CoinSellerProfile
|
||||
err error
|
||||
lastHost *userv1.GetHostProfileRequest
|
||||
hostProfile *userv1.HostProfile
|
||||
hostErr error
|
||||
lastBD *userv1.GetBDProfileRequest
|
||||
bdProfile *userv1.BDProfile
|
||||
bdErr error
|
||||
lastRoleSummary *userv1.GetUserRoleSummaryRequest
|
||||
roleSummary *userv1.UserRoleSummary
|
||||
roleSummaryErr error
|
||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||
capabilityErr error
|
||||
lastSearchAgencies *userv1.SearchAgenciesRequest
|
||||
searchAgenciesResp *userv1.SearchAgenciesResponse
|
||||
searchAgenciesErr error
|
||||
lastApplyAgency *userv1.ApplyToAgencyRequest
|
||||
applyAgencyResp *userv1.ApplyToAgencyResponse
|
||||
applyAgencyErr error
|
||||
last *userv1.GetCoinSellerProfileRequest
|
||||
profile *userv1.CoinSellerProfile
|
||||
err error
|
||||
lastListCoinSellers *userv1.ListActiveCoinSellersInMyRegionRequest
|
||||
listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
|
||||
listCoinSellersErr error
|
||||
lastHost *userv1.GetHostProfileRequest
|
||||
hostProfile *userv1.HostProfile
|
||||
hostErr error
|
||||
lastBD *userv1.GetBDProfileRequest
|
||||
bdProfile *userv1.BDProfile
|
||||
bdErr error
|
||||
lastRoleSummary *userv1.GetUserRoleSummaryRequest
|
||||
roleSummary *userv1.UserRoleSummary
|
||||
roleSummaryErr error
|
||||
lastCapability *userv1.CheckBusinessCapabilityRequest
|
||||
capabilityResp *userv1.CheckBusinessCapabilityResponse
|
||||
capabilityErr error
|
||||
}
|
||||
|
||||
type fakeWalletClient struct {
|
||||
@ -971,6 +974,17 @@ func (f *fakeUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1
|
||||
return &userv1.GetCoinSellerProfileResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) ListActiveCoinSellersInMyRegion(_ context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) {
|
||||
f.lastListCoinSellers = req
|
||||
if f.listCoinSellersErr != nil {
|
||||
return nil, f.listCoinSellersErr
|
||||
}
|
||||
if f.listCoinSellersResp != nil {
|
||||
return f.listCoinSellersResp, nil
|
||||
}
|
||||
return &userv1.ListActiveCoinSellersInMyRegionResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
|
||||
f.lastRoleSummary = req
|
||||
if f.roleSummaryErr != nil {
|
||||
@ -2422,7 +2436,8 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
|
||||
CoverURL: "https://cdn.example.com/banner.png",
|
||||
RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
|
||||
BannerType: "h5",
|
||||
DisplayScope: "room",
|
||||
DisplayScope: "home,room",
|
||||
DisplayScopes: []string{"home", "room"},
|
||||
Param: "https://h5.example.com/activity",
|
||||
Platform: "android",
|
||||
SortOrder: 10,
|
||||
@ -2468,9 +2483,13 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
|
||||
if !ok || first["cover_url"] != "https://cdn.example.com/banner.png" || first["type"] != "h5" || first["param"] != "https://h5.example.com/activity" {
|
||||
t.Fatalf("banner item mismatch: %+v", first)
|
||||
}
|
||||
if first["display_scope"] != "room" || first["room_small_image_url"] != "https://cdn.example.com/banner-small.png" {
|
||||
if first["display_scope"] != "home,room" || first["room_small_image_url"] != "https://cdn.example.com/banner-small.png" {
|
||||
t.Fatalf("banner display fields mismatch: %+v", first)
|
||||
}
|
||||
displayScopes, ok := first["display_scopes"].([]any)
|
||||
if !ok || len(displayScopes) != 2 || displayScopes[0] != "home" || displayScopes[1] != "room" {
|
||||
t.Fatalf("banner display scopes mismatch: %+v", first)
|
||||
}
|
||||
if first["platform"] != "android" || first["sort_order"].(float64) != 10 || first["starts_at_ms"].(float64) != 1700000000000 || first["ends_at_ms"].(float64) != 1800000000000 || first["updated_at_ms"].(float64) != 1700000002000 {
|
||||
t.Fatalf("banner metadata mismatch: %+v", first)
|
||||
}
|
||||
@ -2636,6 +2655,53 @@ func TestSearchHostAgenciesUsesAuthenticatedUserAndShortID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListCoinSellersUsesAuthenticatedUserRegion(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{listCoinSellersResp: &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: []*userv1.CoinSellerListItem{
|
||||
{
|
||||
UserId: 801,
|
||||
DisplayUserId: "900801",
|
||||
Username: "Seller One",
|
||||
Avatar: "https://cdn.example/avatar.png",
|
||||
CountryId: 86,
|
||||
CountryCode: "CN",
|
||||
CountryName: "China",
|
||||
CountryDisplayName: "中国",
|
||||
RegionId: 30,
|
||||
RegionCode: "east-asia",
|
||||
RegionName: "East Asia",
|
||||
Status: "active",
|
||||
MerchantAssetType: "COIN_SELLER_COIN",
|
||||
UpdatedAtMs: 1700000001000,
|
||||
},
|
||||
}}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
|
||||
handler.SetUserHostClient(hostClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/wallet/coin-sellers", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-coin-sellers")
|
||||
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.lastListCoinSellers == nil || hostClient.lastListCoinSellers.GetUserId() != 42 {
|
||||
t.Fatalf("coin seller list request mismatch: %+v", hostClient.lastListCoinSellers)
|
||||
}
|
||||
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)
|
||||
items := data["items"].([]any)
|
||||
first := items[0].(map[string]any)
|
||||
if data["total"] != float64(1) || first["user_id"] != "801" || first["display_user_id"] != "900801" || first["username"] != "Seller One" || first["country_id"] != float64(86) || first["status"] != "active" {
|
||||
t.Fatalf("coin seller list response mismatch: %+v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
|
||||
hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
|
||||
ApplicationId: 8001,
|
||||
|
||||
@ -0,0 +1,80 @@
|
||||
package walletapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
type coinSellerData struct {
|
||||
UserID string `json:"user_id"`
|
||||
DisplayUserID string `json:"display_user_id"`
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar,omitempty"`
|
||||
CountryID int64 `json:"country_id"`
|
||||
CountryCode string `json:"country_code,omitempty"`
|
||||
CountryName string `json:"country_name,omitempty"`
|
||||
CountryDisplayName string `json:"country_display_name,omitempty"`
|
||||
RegionID int64 `json:"region_id"`
|
||||
RegionCode string `json:"region_code,omitempty"`
|
||||
RegionName string `json:"region_name,omitempty"`
|
||||
Status string `json:"status"`
|
||||
MerchantAssetType string `json:"merchant_asset_type"`
|
||||
UpdatedAtMS int64 `json:"updated_at_ms"`
|
||||
}
|
||||
|
||||
// listCoinSellers 返回当前用户所在区域内所有启用币商。
|
||||
// gateway 不接受 region_id 参数,避免客户端绕过 user-service 的当前区域边界。
|
||||
func (h *Handler) listCoinSellers(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.userHostClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.userHostClient.ListActiveCoinSellersInMyRegion(request.Context(), &userv1.ListActiveCoinSellersInMyRegionRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]coinSellerData, 0, len(resp.GetCoinSellers()))
|
||||
for _, item := range resp.GetCoinSellers() {
|
||||
items = append(items, coinSellerFromProto(item))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{"items": items, "total": len(items)})
|
||||
}
|
||||
|
||||
func coinSellerFromProto(item *userv1.CoinSellerListItem) coinSellerData {
|
||||
if item == nil {
|
||||
return coinSellerData{}
|
||||
}
|
||||
return coinSellerData{
|
||||
UserID: int64IDString(item.GetUserId()),
|
||||
DisplayUserID: item.GetDisplayUserId(),
|
||||
Username: item.GetUsername(),
|
||||
Avatar: item.GetAvatar(),
|
||||
CountryID: item.GetCountryId(),
|
||||
CountryCode: item.GetCountryCode(),
|
||||
CountryName: item.GetCountryName(),
|
||||
CountryDisplayName: item.GetCountryDisplayName(),
|
||||
RegionID: item.GetRegionId(),
|
||||
RegionCode: item.GetRegionCode(),
|
||||
RegionName: item.GetRegionName(),
|
||||
Status: item.GetStatus(),
|
||||
MerchantAssetType: item.GetMerchantAssetType(),
|
||||
UpdatedAtMS: item.GetUpdatedAtMs(),
|
||||
}
|
||||
}
|
||||
|
||||
func int64IDString(value int64) string {
|
||||
if value <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(value, 10)
|
||||
}
|
||||
@ -45,6 +45,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
|
||||
ApplyWithdrawal: h.applyWithdrawal,
|
||||
ListCoinTransactions: h.listCoinTransactions,
|
||||
ListWalletTransactions: h.listWalletTransactions,
|
||||
ListCoinSellers: h.listCoinSellers,
|
||||
TransferCoinFromSeller: h.transferCoinFromSeller,
|
||||
GetRedPacketConfig: h.getRedPacketConfig,
|
||||
ListRoomRedPackets: h.listRoomRedPackets,
|
||||
|
||||
@ -178,6 +178,25 @@ type CoinSellerProfile struct {
|
||||
UpdatedAtMs int64
|
||||
}
|
||||
|
||||
// CoinSellerListItem 是 App 充值入口需要的启用币商展示资料。
|
||||
// 区域来自 users 当前事实,币商状态来自 coin_seller_profiles,不在 gateway 拼装。
|
||||
type CoinSellerListItem struct {
|
||||
UserID int64
|
||||
DisplayUserID string
|
||||
Username string
|
||||
Avatar string
|
||||
CountryID int64
|
||||
CountryCode string
|
||||
CountryName string
|
||||
CountryDisplayName string
|
||||
RegionID int64
|
||||
RegionCode string
|
||||
RegionName string
|
||||
Status string
|
||||
MerchantAssetType string
|
||||
UpdatedAtMs int64
|
||||
}
|
||||
|
||||
// UserRoleSummary 是面向 App 入口显隐的聚合 read model。
|
||||
// 这里不承载团队统计或薪资指标,只表达当前用户是否拥有可用身份和待处理邀请数量。
|
||||
type UserRoleSummary struct {
|
||||
|
||||
@ -32,6 +32,7 @@ type Repository interface {
|
||||
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, error)
|
||||
GetBDProfile(ctx context.Context, userID int64) (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)
|
||||
HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error)
|
||||
ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, error)
|
||||
@ -484,6 +485,19 @@ func (s *Service) GetCoinSellerProfile(ctx context.Context, userID int64) (hostd
|
||||
return s.repository.GetCoinSellerProfile(ctx, userID)
|
||||
}
|
||||
|
||||
// ListActiveCoinSellersInMyRegion 返回当前用户所在区域的所有启用币商。
|
||||
func (s *Service) ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error) {
|
||||
if s.repository == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "host repository is not configured")
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
|
||||
// 当前区域属于 user-service 用户主事实,gateway 不能传入 region_id 来扩大查询范围。
|
||||
return s.repository.ListActiveCoinSellersInMyRegion(ctx, userID)
|
||||
}
|
||||
|
||||
// GetUserRoleSummary 读取 App 我的页使用的轻量身份摘要,避免 gateway 为入口显隐发起多个角色 RPC。
|
||||
func (s *Service) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {
|
||||
if s.repository == nil {
|
||||
|
||||
@ -539,6 +539,100 @@ func TestAdminCreateCoinSellerCanCoexistWithBDAndAgency(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListActiveCoinSellersInMyRegionFiltersByCurrentUserRegion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.PutCountry(userdomain.Country{
|
||||
CountryID: 86,
|
||||
CountryCode: "CN",
|
||||
CountryName: "China",
|
||||
CountryDisplayName: "中国",
|
||||
Enabled: true,
|
||||
})
|
||||
repository.PutRegion(userdomain.Region{
|
||||
RegionID: 70,
|
||||
RegionCode: "east-asia",
|
||||
Name: "East Asia",
|
||||
Status: userdomain.RegionStatusActive,
|
||||
Countries: []string{"CN"},
|
||||
})
|
||||
repository.PutRegion(userdomain.Region{
|
||||
RegionID: 71,
|
||||
RegionCode: "west-asia",
|
||||
Name: "West Asia",
|
||||
Status: userdomain.RegionStatusActive,
|
||||
Countries: []string{"CN"},
|
||||
})
|
||||
seedActiveUser(t, repository, 940, 70)
|
||||
repository.PutUser(userdomain.User{
|
||||
UserID: 941,
|
||||
DefaultDisplayUserID: displayID(941),
|
||||
CurrentDisplayUserID: displayID(941),
|
||||
Username: "Seller One",
|
||||
Avatar: "https://cdn.example/seller-one.png",
|
||||
Country: "CN",
|
||||
RegionID: 70,
|
||||
ProfileCompleted: true,
|
||||
ProfileCompletedAtMs: 1,
|
||||
OnboardingStatus: userdomain.OnboardingStatusCompleted,
|
||||
Status: userdomain.StatusActive,
|
||||
})
|
||||
repository.PutUser(userdomain.User{
|
||||
UserID: 942,
|
||||
DefaultDisplayUserID: displayID(942),
|
||||
CurrentDisplayUserID: displayID(942),
|
||||
Username: "Disabled Seller",
|
||||
Country: "CN",
|
||||
RegionID: 70,
|
||||
ProfileCompleted: true,
|
||||
ProfileCompletedAtMs: 1,
|
||||
OnboardingStatus: userdomain.OnboardingStatusCompleted,
|
||||
Status: userdomain.StatusActive,
|
||||
})
|
||||
repository.PutUser(userdomain.User{
|
||||
UserID: 943,
|
||||
DefaultDisplayUserID: displayID(943),
|
||||
CurrentDisplayUserID: displayID(943),
|
||||
Username: "Other Region Seller",
|
||||
Country: "CN",
|
||||
RegionID: 71,
|
||||
ProfileCompleted: true,
|
||||
ProfileCompletedAtMs: 1,
|
||||
OnboardingStatus: userdomain.OnboardingStatusCompleted,
|
||||
Status: userdomain.StatusActive,
|
||||
})
|
||||
svc := newHostService(repository, 9000, 10000)
|
||||
for _, userID := range []int64{941, 942, 943} {
|
||||
if _, err := svc.CreateCoinSeller(ctx, hostservice.CreateCoinSellerInput{
|
||||
CommandID: fmt.Sprintf("admin-create-coin-seller-%d", userID),
|
||||
AdminUserID: 1,
|
||||
TargetUserID: userID,
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateCoinSeller(%d) failed: %v", userID, err)
|
||||
}
|
||||
}
|
||||
if _, err := svc.SetCoinSellerStatus(ctx, hostservice.SetCoinSellerStatusInput{
|
||||
CommandID: "admin-disable-coin-seller-942",
|
||||
AdminUserID: 1,
|
||||
TargetUserID: 942,
|
||||
Status: hostdomain.CoinSellerStatusDisabled,
|
||||
}); err != nil {
|
||||
t.Fatalf("SetCoinSellerStatus failed: %v", err)
|
||||
}
|
||||
|
||||
items, err := svc.ListActiveCoinSellersInMyRegion(ctx, 940)
|
||||
if err != nil {
|
||||
t.Fatalf("ListActiveCoinSellersInMyRegion failed: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("only same-region active sellers should be returned: %+v", items)
|
||||
}
|
||||
item := items[0]
|
||||
if item.UserID != 941 || item.Username != "Seller One" || item.Avatar == "" || item.CountryID != 86 || item.CountryCode != "CN" || item.RegionID != 70 || item.RegionCode != "east-asia" || item.Status != hostdomain.CoinSellerStatusActive {
|
||||
t.Fatalf("coin seller item mismatch: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -74,6 +74,82 @@ func (r *Repository) GetCoinSellerProfile(ctx context.Context, userID int64) (ho
|
||||
return queryCoinSellerProfile(ctx, r.db, "WHERE user_id = ?", userID)
|
||||
}
|
||||
|
||||
// ListActiveCoinSellersInMyRegion 返回当前用户所在区域的启用币商列表。
|
||||
// 该查询从 users.region_id 出发过滤,避免把区域条件交给 gateway 或客户端传入。
|
||||
func (r *Repository) ListActiveCoinSellersInMyRegion(ctx context.Context, userID int64) ([]hostdomain.CoinSellerListItem, error) {
|
||||
regionID, err := r.userRegion(ctx, r.db, userID, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if regionID <= 0 {
|
||||
// 币商列表是区域内充值入口,缺少当前区域时不能降级成全局列表。
|
||||
return nil, xerr.New(xerr.InvalidArgument, "user region is required")
|
||||
}
|
||||
if err := requireActiveRegion(ctx, r.db, regionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT
|
||||
u.user_id,
|
||||
u.current_display_user_id,
|
||||
COALESCE(u.username, ''),
|
||||
COALESCE(u.avatar, ''),
|
||||
COALESCE(country.country_id, 0),
|
||||
COALESCE(u.country, ''),
|
||||
COALESCE(country.country_name, ''),
|
||||
COALESCE(country.country_display_name, ''),
|
||||
u.region_id,
|
||||
COALESCE(region.region_code, ''),
|
||||
COALESCE(region.name, ''),
|
||||
seller.status,
|
||||
seller.merchant_asset_type,
|
||||
seller.updated_at_ms
|
||||
FROM users u
|
||||
INNER JOIN coin_seller_profiles seller
|
||||
ON seller.app_code = u.app_code AND seller.user_id = u.user_id
|
||||
LEFT JOIN countries country
|
||||
ON country.app_code = u.app_code AND country.country_code = u.country
|
||||
LEFT JOIN regions region
|
||||
ON region.app_code = u.app_code AND region.region_id = u.region_id
|
||||
WHERE u.app_code = ?
|
||||
AND u.region_id = ?
|
||||
AND u.status = 'active'
|
||||
AND seller.status = ?
|
||||
AND seller.merchant_asset_type = ?
|
||||
ORDER BY seller.updated_at_ms DESC, u.user_id DESC
|
||||
`, appcode.FromContext(ctx), regionID, hostdomain.CoinSellerStatusActive, hostdomain.CoinSellerMerchantAssetType)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]hostdomain.CoinSellerListItem, 0)
|
||||
for rows.Next() {
|
||||
var item hostdomain.CoinSellerListItem
|
||||
if err := rows.Scan(
|
||||
&item.UserID,
|
||||
&item.DisplayUserID,
|
||||
&item.Username,
|
||||
&item.Avatar,
|
||||
&item.CountryID,
|
||||
&item.CountryCode,
|
||||
&item.CountryName,
|
||||
&item.CountryDisplayName,
|
||||
&item.RegionID,
|
||||
&item.RegionCode,
|
||||
&item.RegionName,
|
||||
&item.Status,
|
||||
&item.MerchantAssetType,
|
||||
&item.UpdatedAtMs,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
// GetUserRoleSummary 用一组索引点查构造我的页角色摘要。
|
||||
// 摘要只表达入口显隐需要的当前身份,不加载成员列表、团队统计或薪资数据。
|
||||
func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {
|
||||
|
||||
@ -342,6 +342,29 @@ func toProtoCoinSellerProfile(profile hostdomain.CoinSellerProfile) *userv1.Coin
|
||||
}
|
||||
}
|
||||
|
||||
// toProtoCoinSellerListItem 把区域币商列表项转换为 App 读取投影。
|
||||
func toProtoCoinSellerListItem(item hostdomain.CoinSellerListItem) *userv1.CoinSellerListItem {
|
||||
if item.UserID == 0 {
|
||||
return nil
|
||||
}
|
||||
return &userv1.CoinSellerListItem{
|
||||
UserId: item.UserID,
|
||||
DisplayUserId: item.DisplayUserID,
|
||||
Username: item.Username,
|
||||
Avatar: item.Avatar,
|
||||
CountryId: item.CountryID,
|
||||
CountryCode: item.CountryCode,
|
||||
CountryName: item.CountryName,
|
||||
CountryDisplayName: item.CountryDisplayName,
|
||||
RegionId: item.RegionID,
|
||||
RegionCode: item.RegionCode,
|
||||
RegionName: item.RegionName,
|
||||
Status: item.Status,
|
||||
MerchantAssetType: item.MerchantAssetType,
|
||||
UpdatedAtMs: item.UpdatedAtMs,
|
||||
}
|
||||
}
|
||||
|
||||
// toProtoUserRoleSummary 把 host domain 入口显隐摘要转换为 gRPC 投影。
|
||||
func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary {
|
||||
if summary.UserID == 0 {
|
||||
|
||||
@ -206,6 +206,24 @@ func (s *Server) GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSe
|
||||
return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: toProtoCoinSellerProfile(profile)}, nil
|
||||
}
|
||||
|
||||
// ListActiveCoinSellersInMyRegion 返回当前用户同区域启用币商。
|
||||
func (s *Server) ListActiveCoinSellersInMyRegion(ctx context.Context, req *userv1.ListActiveCoinSellersInMyRegionRequest) (*userv1.ListActiveCoinSellersInMyRegionResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "host service is not configured"))
|
||||
}
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
items, err := s.hostSvc.ListActiveCoinSellersInMyRegion(ctx, req.GetUserId())
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
|
||||
resp := &userv1.ListActiveCoinSellersInMyRegionResponse{CoinSellers: make([]*userv1.CoinSellerListItem, 0, len(items))}
|
||||
for _, item := range items {
|
||||
resp.CoinSellers = append(resp.CoinSellers, toProtoCoinSellerListItem(item))
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetUserRoleSummary 返回 App 入口显隐所需的角色摘要,替代 gateway 多角色 fanout。
|
||||
func (s *Server) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
|
||||
if s.hostSvc == nil {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user