币商列表

This commit is contained in:
zhx 2026-05-26 03:40:01 +08:00
parent a68d465272
commit 463c208640
23 changed files with 1415 additions and 385 deletions

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,24 @@ message CoinSellerProfile {
int64 updated_at_ms = 6; 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 使 // UserRoleSummary App 使
// hostAgencymanagerBD user-service RPC gateway fanout // hostAgencymanagerBD user-service RPC gateway fanout
message UserRoleSummary { message UserRoleSummary {
@ -243,6 +261,15 @@ message GetCoinSellerProfileResponse {
CoinSellerProfile coin_seller_profile = 1; CoinSellerProfile coin_seller_profile = 1;
} }
message ListActiveCoinSellersInMyRegionRequest {
RequestMeta meta = 1;
int64 user_id = 2;
}
message ListActiveCoinSellersInMyRegionResponse {
repeated CoinSellerListItem coin_sellers = 1;
}
message GetUserRoleSummaryRequest { message GetUserRoleSummaryRequest {
RequestMeta meta = 1; RequestMeta meta = 1;
int64 user_id = 2; int64 user_id = 2;
@ -402,6 +429,7 @@ service UserHostService {
rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse); rpc GetHostProfile(GetHostProfileRequest) returns (GetHostProfileResponse);
rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse); rpc GetBDProfile(GetBDProfileRequest) returns (GetBDProfileResponse);
rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse); rpc GetCoinSellerProfile(GetCoinSellerProfileRequest) returns (GetCoinSellerProfileResponse);
rpc ListActiveCoinSellersInMyRegion(ListActiveCoinSellersInMyRegionRequest) returns (ListActiveCoinSellersInMyRegionResponse);
rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse); rpc GetUserRoleSummary(GetUserRoleSummaryRequest) returns (GetUserRoleSummaryResponse);
rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse); rpc CheckBusinessCapability(CheckBusinessCapabilityRequest) returns (CheckBusinessCapabilityResponse);
rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse); rpc GetAgencyMembers(GetAgencyMembersRequest) returns (GetAgencyMembersResponse);

View File

@ -19,20 +19,21 @@ import (
const _ = grpc.SupportPackageIsVersion9 const _ = grpc.SupportPackageIsVersion9
const ( const (
UserHostService_SearchAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/SearchAgencies" UserHostService_SearchAgencies_FullMethodName = "/hyapp.user.v1.UserHostService/SearchAgencies"
UserHostService_ApplyToAgency_FullMethodName = "/hyapp.user.v1.UserHostService/ApplyToAgency" UserHostService_ApplyToAgency_FullMethodName = "/hyapp.user.v1.UserHostService/ApplyToAgency"
UserHostService_ReviewAgencyApplication_FullMethodName = "/hyapp.user.v1.UserHostService/ReviewAgencyApplication" UserHostService_ReviewAgencyApplication_FullMethodName = "/hyapp.user.v1.UserHostService/ReviewAgencyApplication"
UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost" UserHostService_KickAgencyHost_FullMethodName = "/hyapp.user.v1.UserHostService/KickAgencyHost"
UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency" UserHostService_InviteAgency_FullMethodName = "/hyapp.user.v1.UserHostService/InviteAgency"
UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD" UserHostService_InviteBD_FullMethodName = "/hyapp.user.v1.UserHostService/InviteBD"
UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation" UserHostService_ProcessRoleInvitation_FullMethodName = "/hyapp.user.v1.UserHostService/ProcessRoleInvitation"
UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile" UserHostService_GetHostProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetHostProfile"
UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile" UserHostService_GetBDProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetBDProfile"
UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile" UserHostService_GetCoinSellerProfile_FullMethodName = "/hyapp.user.v1.UserHostService/GetCoinSellerProfile"
UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary" UserHostService_ListActiveCoinSellersInMyRegion_FullMethodName = "/hyapp.user.v1.UserHostService/ListActiveCoinSellersInMyRegion"
UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability" UserHostService_GetUserRoleSummary_FullMethodName = "/hyapp.user.v1.UserHostService/GetUserRoleSummary"
UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers" UserHostService_CheckBusinessCapability_FullMethodName = "/hyapp.user.v1.UserHostService/CheckBusinessCapability"
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications" UserHostService_GetAgencyMembers_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyMembers"
UserHostService_GetAgencyApplications_FullMethodName = "/hyapp.user.v1.UserHostService/GetAgencyApplications"
) )
// UserHostServiceClient is the client API for UserHostService service. // 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) GetHostProfile(ctx context.Context, in *GetHostProfileRequest, opts ...grpc.CallOption) (*GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error) GetBDProfile(ctx context.Context, in *GetBDProfileRequest, opts ...grpc.CallOption) (*GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, in *GetCoinSellerProfileRequest, opts ...grpc.CallOption) (*GetCoinSellerProfileResponse, 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) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error)
CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error) CheckBusinessCapability(ctx context.Context, in *CheckBusinessCapabilityRequest, opts ...grpc.CallOption) (*CheckBusinessCapabilityResponse, error)
GetAgencyMembers(ctx context.Context, in *GetAgencyMembersRequest, opts ...grpc.CallOption) (*GetAgencyMembersResponse, 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 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) { func (c *userHostServiceClient) GetUserRoleSummary(ctx context.Context, in *GetUserRoleSummaryRequest, opts ...grpc.CallOption) (*GetUserRoleSummaryResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(GetUserRoleSummaryResponse) out := new(GetUserRoleSummaryResponse)
@ -221,6 +233,7 @@ type UserHostServiceServer interface {
GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error) GetHostProfile(context.Context, *GetHostProfileRequest) (*GetHostProfileResponse, error)
GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error) GetBDProfile(context.Context, *GetBDProfileRequest) (*GetBDProfileResponse, error)
GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error) GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error)
ListActiveCoinSellersInMyRegion(context.Context, *ListActiveCoinSellersInMyRegionRequest) (*ListActiveCoinSellersInMyRegionResponse, error)
GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error)
CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error) CheckBusinessCapability(context.Context, *CheckBusinessCapabilityRequest) (*CheckBusinessCapabilityResponse, error)
GetAgencyMembers(context.Context, *GetAgencyMembersRequest) (*GetAgencyMembersResponse, 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) { func (UnimplementedUserHostServiceServer) GetCoinSellerProfile(context.Context, *GetCoinSellerProfileRequest) (*GetCoinSellerProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetCoinSellerProfile not implemented") 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) { func (UnimplementedUserHostServiceServer) GetUserRoleSummary(context.Context, *GetUserRoleSummaryRequest) (*GetUserRoleSummaryResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetUserRoleSummary not implemented") 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) 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) { func _UserHostService_GetUserRoleSummary_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetUserRoleSummaryRequest) in := new(GetUserRoleSummaryRequest)
if err := dec(in); err != nil { if err := dec(in); err != nil {
@ -597,6 +631,10 @@ var UserHostService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetCoinSellerProfile", MethodName: "GetCoinSellerProfile",
Handler: _UserHostService_GetCoinSellerProfile_Handler, Handler: _UserHostService_GetCoinSellerProfile_Handler,
}, },
{
MethodName: "ListActiveCoinSellersInMyRegion",
Handler: _UserHostService_ListActiveCoinSellersInMyRegion_Handler,
},
{ {
MethodName: "GetUserRoleSummary", MethodName: "GetUserRoleSummary",
Handler: _UserHostService_GetUserRoleSummary_Handler, Handler: _UserHostService_GetUserRoleSummary_Handler,

View 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` | 币商用户长 IDJSON 中按字符串返回,避免 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`,按登录失效、参数错误、服务错误分别处理。

View File

@ -97,7 +97,7 @@ type AppBanner struct {
CoverURL string `gorm:"size:1024;not null" json:"coverUrl"` 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"` 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"` 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"` 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"` 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"` Platform string `gorm:"size:24;index:idx_admin_app_banners_scope,not null" json:"platform"`

View File

@ -11,19 +11,20 @@ type h5LinkPayload struct {
} }
type bannerRequest struct { type bannerRequest struct {
CoverURL string `json:"coverUrl" binding:"required"` CoverURL string `json:"coverUrl" binding:"required"`
RoomSmallImageURL string `json:"roomSmallImageUrl"` RoomSmallImageURL string `json:"roomSmallImageUrl"`
BannerType string `json:"bannerType" binding:"required"` BannerType string `json:"bannerType" binding:"required"`
DisplayScope string `json:"displayScope"` DisplayScope string `json:"displayScope"`
Param string `json:"param"` DisplayScopes []string `json:"displayScopes"`
Status string `json:"status" binding:"required"` Param string `json:"param"`
Platform string `json:"platform" binding:"required"` Status string `json:"status" binding:"required"`
SortOrder int `json:"sortOrder"` Platform string `json:"platform" binding:"required"`
RegionID int64 `json:"regionId"` SortOrder int `json:"sortOrder"`
CountryCode string `json:"countryCode"` RegionID int64 `json:"regionId"`
Description string `json:"description"` CountryCode string `json:"countryCode"`
StartsAtMs int64 `json:"startsAtMs"` Description string `json:"description"`
EndsAtMs int64 `json:"endsAtMs"` StartsAtMs int64 `json:"startsAtMs"`
EndsAtMs int64 `json:"endsAtMs"`
} }
type appVersionRequest struct { type appVersionRequest struct {

View File

@ -25,6 +25,8 @@ const (
bannerStatusExpired = "expired" bannerStatusExpired = "expired"
) )
var bannerDisplayScopeOrder = []string{bannerDisplayScopeHome, bannerDisplayScopeRoom, bannerDisplayScopeRecharge}
type AppConfigService struct { type AppConfigService struct {
store *repository.Store store *repository.Store
} }
@ -37,23 +39,24 @@ type H5Link struct {
} }
type AppBanner struct { type AppBanner struct {
ID uint `json:"id"` ID uint `json:"id"`
AppCode string `json:"appCode"` AppCode string `json:"appCode"`
CoverURL string `json:"coverUrl"` CoverURL string `json:"coverUrl"`
RoomSmallImageURL string `json:"roomSmallImageUrl"` RoomSmallImageURL string `json:"roomSmallImageUrl"`
BannerType string `json:"bannerType"` BannerType string `json:"bannerType"`
DisplayScope string `json:"displayScope"` DisplayScope string `json:"displayScope"`
Param string `json:"param"` DisplayScopes []string `json:"displayScopes"`
Status string `json:"status"` Param string `json:"param"`
Platform string `json:"platform"` Status string `json:"status"`
SortOrder int `json:"sortOrder"` Platform string `json:"platform"`
RegionID int64 `json:"regionId"` SortOrder int `json:"sortOrder"`
CountryCode string `json:"countryCode"` RegionID int64 `json:"regionId"`
Description string `json:"description"` CountryCode string `json:"countryCode"`
StartsAtMs int64 `json:"startsAtMs"` Description string `json:"description"`
EndsAtMs int64 `json:"endsAtMs"` StartsAtMs int64 `json:"startsAtMs"`
CreatedAtMs int64 `json:"createdAtMs"` EndsAtMs int64 `json:"endsAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"` CreatedAtMs int64 `json:"createdAtMs"`
UpdatedAtMs int64 `json:"updatedAtMs"`
} }
type AppVersion struct { type AppVersion struct {
@ -391,12 +394,13 @@ func h5LinkFromModel(config model.AppConfig) H5Link {
} }
func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) { func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner, error) {
displayScopes := normalizeBannerDisplayScopes(req.DisplayScopes, req.DisplayScope)
item := model.AppBanner{ item := model.AppBanner{
AppCode: appctx.Normalize(appCode), AppCode: appctx.Normalize(appCode),
CoverURL: strings.TrimSpace(req.CoverURL), CoverURL: strings.TrimSpace(req.CoverURL),
RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL), RoomSmallImageURL: strings.TrimSpace(req.RoomSmallImageURL),
BannerType: normalizeBannerType(req.BannerType), BannerType: normalizeBannerType(req.BannerType),
DisplayScope: normalizeBannerDisplayScope(req.DisplayScope), DisplayScope: bannerDisplayScopeValue(displayScopes),
Param: strings.TrimSpace(req.Param), Param: strings.TrimSpace(req.Param),
Status: normalizeBannerStatus(req.Status), Status: normalizeBannerStatus(req.Status),
Platform: normalizeBannerPlatform(req.Platform), Platform: normalizeBannerPlatform(req.Platform),
@ -413,10 +417,10 @@ func bannerModelFromRequest(appCode string, req bannerRequest) (model.AppBanner,
if containsWhitespace(item.CoverURL) { if containsWhitespace(item.CoverURL) {
return model.AppBanner{}, errors.New("banner cover cannot contain whitespace") 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") 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 { if item.RoomSmallImageURL == "" || len(item.RoomSmallImageURL) > 1024 {
return model.AppBanner{}, errors.New("room small image is required") 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") return model.AppBanner{}, errors.New("room small image cannot contain whitespace")
} }
} else { } else {
// 非房间内投放不保留房间小屏图,避免客户端误用其它显示范围的专属素材。 // 未投放到房间内时不保留小屏图,避免客户端误用房间专属素材。
item.RoomSmallImageURL = "" item.RoomSmallImageURL = ""
} }
if item.BannerType != "h5" && item.BannerType != "app" { if item.BannerType != "h5" && item.BannerType != "app" {
@ -476,6 +480,7 @@ func appBannerFromModel(item model.AppBanner) AppBanner {
RoomSmallImageURL: item.RoomSmallImageURL, RoomSmallImageURL: item.RoomSmallImageURL,
BannerType: item.BannerType, BannerType: item.BannerType,
DisplayScope: item.DisplayScope, DisplayScope: item.DisplayScope,
DisplayScopes: bannerDisplayScopeList(item.DisplayScope),
Param: item.Param, Param: item.Param,
Status: item.Status, Status: item.Status,
Platform: item.Platform, 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 { func normalizeBannerPlatform(value string) string {
return strings.ToLower(strings.TrimSpace(value)) return strings.ToLower(strings.TrimSpace(value))
} }

View File

@ -3,6 +3,8 @@ package appconfig
import ( import (
"testing" "testing"
"time" "time"
"hyapp-admin-server/internal/model"
) )
func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) { func TestH5LinkModelFromPayloadValidatesDynamicConfig(t *testing.T) {
@ -52,7 +54,7 @@ func TestBannerModelFromRequestKeepsRoomScopeScheduleFields(t *testing.T) {
CoverURL: "https://cdn.example.com/banner.png", CoverURL: "https://cdn.example.com/banner.png",
RoomSmallImageURL: "https://cdn.example.com/banner-small.png", RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
BannerType: "h5", BannerType: "h5",
DisplayScope: bannerDisplayScopeRoom, DisplayScopes: []string{bannerDisplayScopeHome, bannerDisplayScopeRoom},
Param: "https://h5.example.com/activity", Param: "https://h5.example.com/activity",
Status: bannerStatusActive, Status: bannerStatusActive,
Platform: "android", Platform: "android",
@ -62,7 +64,7 @@ func TestBannerModelFromRequestKeepsRoomScopeScheduleFields(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("banner model should be valid: %v", err) 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) t.Fatalf("room display fields mismatch: %+v", item)
} }
if item.StartsAtMS != startsAtMs || item.EndsAtMS != endsAtMs || item.Status != bannerStatusActive { 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) { func TestExploreTabModelFromRequestValidatesRequiredFields(t *testing.T) {
item, err := exploreTabModelFromRequest("lalu", exploreTabRequest{ item, err := exploreTabModelFromRequest("lalu", exploreTabRequest{
Enabled: true, Enabled: true,

View File

@ -23,7 +23,7 @@ func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner,
query = query.Where("status = ?", strings.TrimSpace(options.Status)) query = query.Where("status = ?", strings.TrimSpace(options.Status))
} }
if options.DisplayScope != "" { 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 != "" { if options.Platform != "" {
query = query.Where("platform = ?", strings.TrimSpace(options.Platform)) query = query.Where("platform = ?", strings.TrimSpace(options.Platform))

View File

@ -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 '显示范围,多选逗号分隔:首页、房间内或充值页';

View File

@ -22,7 +22,7 @@ const listExploreTabsSQL = `
const listAppBannersSQL = ` 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 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 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 (? = '' OR platform = ?)
AND (region_id = 0 OR region_id = ?) AND (region_id = 0 OR region_id = ?)
AND (country_code = '' OR country_code = ?) AND (country_code = '' OR country_code = ?)
@ -66,20 +66,21 @@ type BannerQuery struct {
// Banner 是 gateway 下发给 App 指定显示范围的 banner 配置。 // Banner 是 gateway 下发给 App 指定显示范围的 banner 配置。
type Banner struct { type Banner struct {
ID uint `json:"id"` ID uint `json:"id"`
CoverURL string `json:"cover_url"` CoverURL string `json:"cover_url"`
RoomSmallImageURL string `json:"room_small_image_url,omitempty"` RoomSmallImageURL string `json:"room_small_image_url,omitempty"`
BannerType string `json:"type"` BannerType string `json:"type"`
DisplayScope string `json:"display_scope"` DisplayScope string `json:"display_scope"`
Param string `json:"param"` DisplayScopes []string `json:"display_scopes,omitempty"`
Platform string `json:"platform"` Param string `json:"param"`
SortOrder int `json:"sort_order"` Platform string `json:"platform"`
RegionID int64 `json:"region_id,omitempty"` SortOrder int `json:"sort_order"`
CountryCode string `json:"country_code,omitempty"` RegionID int64 `json:"region_id,omitempty"`
Description string `json:"description,omitempty"` CountryCode string `json:"country_code,omitempty"`
StartsAtMs int64 `json:"starts_at_ms,omitempty"` Description string `json:"description,omitempty"`
EndsAtMs int64 `json:"ends_at_ms,omitempty"` StartsAtMs int64 `json:"starts_at_ms,omitempty"`
UpdatedAtMs int64 `json:"updated_at_ms"` EndsAtMs int64 `json:"ends_at_ms,omitempty"`
UpdatedAtMs int64 `json:"updated_at_ms"`
} }
// VersionQuery 是 App 检查更新的公开筛选条件。 // VersionQuery 是 App 检查更新的公开筛选条件。
@ -237,6 +238,7 @@ func (r *MySQLReader) ListBanners(ctx context.Context, query BannerQuery) ([]Ban
return nil, err return nil, err
} }
item.UpdatedAtMs = updatedAtMS item.UpdatedAtMs = updatedAtMS
item.DisplayScopes = bannerDisplayScopeList(item.DisplayScope)
items = append(items, item) items = append(items, item)
} }
if err := rows.Err(); err != nil { 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)) out := make([]Banner, len(r.Banners))
copy(out, 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 return out, nil
} }
@ -361,3 +368,20 @@ func NormalizeBannerDisplayScope(value string) string {
return "" 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
}

View File

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

View File

@ -71,6 +71,7 @@ type UserHostClient interface {
GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error) GetHostProfile(ctx context.Context, req *userv1.GetHostProfileRequest) (*userv1.GetHostProfileResponse, error)
GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error) GetBDProfile(ctx context.Context, req *userv1.GetBDProfileRequest) (*userv1.GetBDProfileResponse, error)
GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSellerProfileRequest) (*userv1.GetCoinSellerProfileResponse, 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) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error)
CheckBusinessCapability(ctx context.Context, req *userv1.CheckBusinessCapabilityRequest) (*userv1.CheckBusinessCapabilityResponse, 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) 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) { func (c *grpcUserHostClient) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
return c.client.GetUserRoleSummary(ctx, req) return c.client.GetUserRoleSummary(ctx, req)
} }

View File

@ -162,6 +162,7 @@ type WalletHandlers struct {
ApplyWithdrawal http.HandlerFunc ApplyWithdrawal http.HandlerFunc
ListCoinTransactions http.HandlerFunc ListCoinTransactions http.HandlerFunc
ListWalletTransactions http.HandlerFunc ListWalletTransactions http.HandlerFunc
ListCoinSellers http.HandlerFunc
TransferCoinFromSeller http.HandlerFunc TransferCoinFromSeller http.HandlerFunc
GetRedPacketConfig http.HandlerFunc GetRedPacketConfig http.HandlerFunc
ListRoomRedPackets 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/withdrawals/apply", http.MethodPost, h.ApplyWithdrawal)
r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions) r.profile("/wallet/coin-transactions", http.MethodGet, h.ListCoinTransactions)
r.profile("/wallet/transactions", http.MethodGet, h.ListWalletTransactions) 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("/wallet/coin-seller/transfer", "", h.TransferCoinFromSeller)
r.profile("/red-packets/config", http.MethodGet, h.GetRedPacketConfig) r.profile("/red-packets/config", http.MethodGet, h.GetRedPacketConfig)
r.profile("/rooms/{room_id}/red-packets", http.MethodGet, h.ListRoomRedPackets) r.profile("/rooms/{room_id}/red-packets", http.MethodGet, h.ListRoomRedPackets)

View File

@ -319,27 +319,30 @@ type fakeUserCountryQueryClient struct {
} }
type fakeUserHostClient struct { type fakeUserHostClient struct {
lastSearchAgencies *userv1.SearchAgenciesRequest lastSearchAgencies *userv1.SearchAgenciesRequest
searchAgenciesResp *userv1.SearchAgenciesResponse searchAgenciesResp *userv1.SearchAgenciesResponse
searchAgenciesErr error searchAgenciesErr error
lastApplyAgency *userv1.ApplyToAgencyRequest lastApplyAgency *userv1.ApplyToAgencyRequest
applyAgencyResp *userv1.ApplyToAgencyResponse applyAgencyResp *userv1.ApplyToAgencyResponse
applyAgencyErr error applyAgencyErr error
last *userv1.GetCoinSellerProfileRequest last *userv1.GetCoinSellerProfileRequest
profile *userv1.CoinSellerProfile profile *userv1.CoinSellerProfile
err error err error
lastHost *userv1.GetHostProfileRequest lastListCoinSellers *userv1.ListActiveCoinSellersInMyRegionRequest
hostProfile *userv1.HostProfile listCoinSellersResp *userv1.ListActiveCoinSellersInMyRegionResponse
hostErr error listCoinSellersErr error
lastBD *userv1.GetBDProfileRequest lastHost *userv1.GetHostProfileRequest
bdProfile *userv1.BDProfile hostProfile *userv1.HostProfile
bdErr error hostErr error
lastRoleSummary *userv1.GetUserRoleSummaryRequest lastBD *userv1.GetBDProfileRequest
roleSummary *userv1.UserRoleSummary bdProfile *userv1.BDProfile
roleSummaryErr error bdErr error
lastCapability *userv1.CheckBusinessCapabilityRequest lastRoleSummary *userv1.GetUserRoleSummaryRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse roleSummary *userv1.UserRoleSummary
capabilityErr error roleSummaryErr error
lastCapability *userv1.CheckBusinessCapabilityRequest
capabilityResp *userv1.CheckBusinessCapabilityResponse
capabilityErr error
} }
type fakeWalletClient struct { type fakeWalletClient struct {
@ -971,6 +974,17 @@ func (f *fakeUserHostClient) GetCoinSellerProfile(_ context.Context, req *userv1
return &userv1.GetCoinSellerProfileResponse{}, nil 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) { func (f *fakeUserHostClient) GetUserRoleSummary(_ context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
f.lastRoleSummary = req f.lastRoleSummary = req
if f.roleSummaryErr != nil { if f.roleSummaryErr != nil {
@ -2422,7 +2436,8 @@ func TestListAppBannersReturnsAdminAppConfig(t *testing.T) {
CoverURL: "https://cdn.example.com/banner.png", CoverURL: "https://cdn.example.com/banner.png",
RoomSmallImageURL: "https://cdn.example.com/banner-small.png", RoomSmallImageURL: "https://cdn.example.com/banner-small.png",
BannerType: "h5", BannerType: "h5",
DisplayScope: "room", DisplayScope: "home,room",
DisplayScopes: []string{"home", "room"},
Param: "https://h5.example.com/activity", Param: "https://h5.example.com/activity",
Platform: "android", Platform: "android",
SortOrder: 10, 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" { 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) 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) 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 { 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) 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) { func TestApplyToHostAgencyPropagatesClientCommandID(t *testing.T) {
hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{ hostClient := &fakeUserHostClient{applyAgencyResp: &userv1.ApplyToAgencyResponse{Application: &userv1.AgencyApplication{
ApplicationId: 8001, ApplicationId: 8001,

View File

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

View File

@ -45,6 +45,7 @@ func (h *Handler) WalletHandlers() httproutes.WalletHandlers {
ApplyWithdrawal: h.applyWithdrawal, ApplyWithdrawal: h.applyWithdrawal,
ListCoinTransactions: h.listCoinTransactions, ListCoinTransactions: h.listCoinTransactions,
ListWalletTransactions: h.listWalletTransactions, ListWalletTransactions: h.listWalletTransactions,
ListCoinSellers: h.listCoinSellers,
TransferCoinFromSeller: h.transferCoinFromSeller, TransferCoinFromSeller: h.transferCoinFromSeller,
GetRedPacketConfig: h.getRedPacketConfig, GetRedPacketConfig: h.getRedPacketConfig,
ListRoomRedPackets: h.listRoomRedPackets, ListRoomRedPackets: h.listRoomRedPackets,

View File

@ -178,6 +178,25 @@ type CoinSellerProfile struct {
UpdatedAtMs int64 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。 // UserRoleSummary 是面向 App 入口显隐的聚合 read model。
// 这里不承载团队统计或薪资指标,只表达当前用户是否拥有可用身份和待处理邀请数量。 // 这里不承载团队统计或薪资指标,只表达当前用户是否拥有可用身份和待处理邀请数量。
type UserRoleSummary struct { type UserRoleSummary struct {

View File

@ -32,6 +32,7 @@ type Repository interface {
GetHostProfile(ctx context.Context, userID int64) (hostdomain.HostProfile, 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) (hostdomain.BDProfile, error)
GetCoinSellerProfile(ctx context.Context, userID int64) (hostdomain.CoinSellerProfile, 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) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error)
HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error) HasActiveAgencyOwner(ctx context.Context, userID int64) (int64, bool, error)
ListAgencyMembers(ctx context.Context, agencyID int64, status string) ([]hostdomain.AgencyMembership, 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) 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。 // GetUserRoleSummary 读取 App 我的页使用的轻量身份摘要,避免 gateway 为入口显隐发起多个角色 RPC。
func (s *Service) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) { func (s *Service) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {
if s.repository == nil { if s.repository == nil {

View File

@ -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) { func TestGetUserRoleSummaryAggregatesRolesAndPendingInvitations(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)

View File

@ -74,6 +74,82 @@ func (r *Repository) GetCoinSellerProfile(ctx context.Context, userID int64) (ho
return queryCoinSellerProfile(ctx, r.db, "WHERE user_id = ?", userID) 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 用一组索引点查构造我的页角色摘要。 // GetUserRoleSummary 用一组索引点查构造我的页角色摘要。
// 摘要只表达入口显隐需要的当前身份,不加载成员列表、团队统计或薪资数据。 // 摘要只表达入口显隐需要的当前身份,不加载成员列表、团队统计或薪资数据。
func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) { func (r *Repository) GetUserRoleSummary(ctx context.Context, userID int64) (hostdomain.UserRoleSummary, error) {

View File

@ -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 投影。 // toProtoUserRoleSummary 把 host domain 入口显隐摘要转换为 gRPC 投影。
func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary { func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary {
if summary.UserID == 0 { if summary.UserID == 0 {

View File

@ -206,6 +206,24 @@ func (s *Server) GetCoinSellerProfile(ctx context.Context, req *userv1.GetCoinSe
return &userv1.GetCoinSellerProfileResponse{CoinSellerProfile: toProtoCoinSellerProfile(profile)}, nil 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。 // GetUserRoleSummary 返回 App 入口显隐所需的角色摘要,替代 gateway 多角色 fanout。
func (s *Server) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) { func (s *Server) GetUserRoleSummary(ctx context.Context, req *userv1.GetUserRoleSummaryRequest) (*userv1.GetUserRoleSummaryResponse, error) {
if s.hostSvc == nil { if s.hostSvc == nil {