币商列表

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;
}
// 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 使
// hostAgencymanagerBD 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);

View File

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

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

View File

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

View File

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

View File

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

View File

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

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 = `
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
}

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

View File

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

View File

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

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,
ListCoinTransactions: h.listCoinTransactions,
ListWalletTransactions: h.listWalletTransactions,
ListCoinSellers: h.listCoinSellers,
TransferCoinFromSeller: h.transferCoinFromSeller,
GetRedPacketConfig: h.getRedPacketConfig,
ListRoomRedPackets: h.listRoomRedPackets,

View File

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

View File

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

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) {
ctx := context.Background()
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)
}
// 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) {

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 投影。
func toProtoUserRoleSummary(summary hostdomain.UserRoleSummary) *userv1.UserRoleSummary {
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
}
// 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 {