币商提现相关

This commit is contained in:
zhx 2026-07-22 18:36:49 +08:00
parent a38a3395c1
commit 8dda287d0a
31 changed files with 788 additions and 118 deletions

View File

@ -472,18 +472,20 @@ func (x *ListMessageTabsResponse) GetSections() []*MessageTabSection {
// InboxMessage 是 system/activity 分区列表可展示的消息快照。 // InboxMessage 是 system/activity 分区列表可展示的消息快照。
type InboxMessage struct { type InboxMessage struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
MessageId string `protobuf:"bytes,1,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` MessageId string `protobuf:"bytes,1,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"`
Section string `protobuf:"bytes,2,opt,name=section,proto3" json:"section,omitempty"` Section string `protobuf:"bytes,2,opt,name=section,proto3" json:"section,omitempty"`
Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"` Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"` Summary string `protobuf:"bytes,4,opt,name=summary,proto3" json:"summary,omitempty"`
Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"` Body string `protobuf:"bytes,5,opt,name=body,proto3" json:"body,omitempty"`
IconUrl string `protobuf:"bytes,6,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"` IconUrl string `protobuf:"bytes,6,opt,name=icon_url,json=iconUrl,proto3" json:"icon_url,omitempty"`
ImageUrl string `protobuf:"bytes,7,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"` ImageUrl string `protobuf:"bytes,7,opt,name=image_url,json=imageUrl,proto3" json:"image_url,omitempty"`
ActionType string `protobuf:"bytes,8,opt,name=action_type,json=actionType,proto3" json:"action_type,omitempty"` ActionType string `protobuf:"bytes,8,opt,name=action_type,json=actionType,proto3" json:"action_type,omitempty"`
ActionParam string `protobuf:"bytes,9,opt,name=action_param,json=actionParam,proto3" json:"action_param,omitempty"` ActionParam string `protobuf:"bytes,9,opt,name=action_param,json=actionParam,proto3" json:"action_param,omitempty"`
Read bool `protobuf:"varint,10,opt,name=read,proto3" json:"read,omitempty"` Read bool `protobuf:"varint,10,opt,name=read,proto3" json:"read,omitempty"`
SentAtMs int64 `protobuf:"varint,11,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"` SentAtMs int64 `protobuf:"varint,11,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"`
// image_urls 是同一通知卡片的有序图片列表image_url 保留给旧客户端作为首图兼容字段。
ImageUrls []string `protobuf:"bytes,12,rep,name=image_urls,json=imageUrls,proto3" json:"image_urls,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -595,6 +597,13 @@ func (x *InboxMessage) GetSentAtMs() int64 {
return 0 return 0
} }
func (x *InboxMessage) GetImageUrls() []string {
if x != nil {
return x.ImageUrls
}
return nil
}
// ListInboxMessagesRequest 按 section 和游标分页读取当前用户 inbox。 // ListInboxMessagesRequest 按 section 和游标分页读取当前用户 inbox。
type ListInboxMessagesRequest struct { type ListInboxMessagesRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -1107,8 +1116,10 @@ type CreateInboxMessageRequest struct {
SentAtMs int64 `protobuf:"varint,19,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"` SentAtMs int64 `protobuf:"varint,19,opt,name=sent_at_ms,json=sentAtMs,proto3" json:"sent_at_ms,omitempty"`
ExpireAtMs int64 `protobuf:"varint,20,opt,name=expire_at_ms,json=expireAtMs,proto3" json:"expire_at_ms,omitempty"` ExpireAtMs int64 `protobuf:"varint,20,opt,name=expire_at_ms,json=expireAtMs,proto3" json:"expire_at_ms,omitempty"`
MetadataJson string `protobuf:"bytes,21,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"` MetadataJson string `protobuf:"bytes,21,opt,name=metadata_json,json=metadataJson,proto3" json:"metadata_json,omitempty"`
unknownFields protoimpl.UnknownFields // image_urls 由 inbox owner 结构化保存,生产者不能要求客户端解析 metadata_json。
sizeCache protoimpl.SizeCache ImageUrls []string `protobuf:"bytes,22,rep,name=image_urls,json=imageUrls,proto3" json:"image_urls,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
} }
func (x *CreateInboxMessageRequest) Reset() { func (x *CreateInboxMessageRequest) Reset() {
@ -1288,6 +1299,13 @@ func (x *CreateInboxMessageRequest) GetMetadataJson() string {
return "" return ""
} }
func (x *CreateInboxMessageRequest) GetImageUrls() []string {
if x != nil {
return x.ImageUrls
}
return nil
}
// CreateInboxMessageResponse 返回入箱结果;重复同一 producer_event_id 时 created=false。 // CreateInboxMessageResponse 返回入箱结果;重复同一 producer_event_id 时 created=false。
type CreateInboxMessageResponse struct { type CreateInboxMessageResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
@ -26574,7 +26592,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\"[\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\"[\n" +
"\x17ListMessageTabsResponse\x12@\n" + "\x17ListMessageTabsResponse\x12@\n" +
"\bsections\x18\x01 \x03(\v2$.hyapp.activity.v1.MessageTabSectionR\bsections\"\xb9\x02\n" + "\bsections\x18\x01 \x03(\v2$.hyapp.activity.v1.MessageTabSectionR\bsections\"\xd8\x02\n" +
"\fInboxMessage\x12\x1d\n" + "\fInboxMessage\x12\x1d\n" +
"\n" + "\n" +
"message_id\x18\x01 \x01(\tR\tmessageId\x12\x18\n" + "message_id\x18\x01 \x01(\tR\tmessageId\x12\x18\n" +
@ -26590,7 +26608,9 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x04read\x18\n" + "\x04read\x18\n" +
" \x01(\bR\x04read\x12\x1c\n" + " \x01(\bR\x04read\x12\x1c\n" +
"\n" + "\n" +
"sent_at_ms\x18\v \x01(\x03R\bsentAtMs\"\xbd\x01\n" + "sent_at_ms\x18\v \x01(\x03R\bsentAtMs\x12\x1d\n" +
"\n" +
"image_urls\x18\f \x03(\tR\timageUrls\"\xbd\x01\n" +
"\x18ListInboxMessagesRequest\x122\n" + "\x18ListInboxMessagesRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x18\n" + "\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x18\n" +
@ -26629,7 +26649,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"\x1aDeleteInboxMessageResponse\x12\x1d\n" + "\x1aDeleteInboxMessageResponse\x12\x1d\n" +
"\n" + "\n" +
"message_id\x18\x01 \x01(\tR\tmessageId\x12\x18\n" + "message_id\x18\x01 \x01(\tR\tmessageId\x12\x18\n" +
"\adeleted\x18\x02 \x01(\bR\adeleted\"\xe7\x05\n" + "\adeleted\x18\x02 \x01(\bR\adeleted\"\x86\x06\n" +
"\x19CreateInboxMessageRequest\x122\n" + "\x19CreateInboxMessageRequest\x122\n" +
"\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12$\n" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12$\n" +
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x1a\n" + "\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x1a\n" +
@ -26656,7 +26676,9 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
"sent_at_ms\x18\x13 \x01(\x03R\bsentAtMs\x12 \n" + "sent_at_ms\x18\x13 \x01(\x03R\bsentAtMs\x12 \n" +
"\fexpire_at_ms\x18\x14 \x01(\x03R\n" + "\fexpire_at_ms\x18\x14 \x01(\x03R\n" +
"expireAtMs\x12#\n" + "expireAtMs\x12#\n" +
"\rmetadata_json\x18\x15 \x01(\tR\fmetadataJson\"U\n" + "\rmetadata_json\x18\x15 \x01(\tR\fmetadataJson\x12\x1d\n" +
"\n" +
"image_urls\x18\x16 \x03(\tR\timageUrls\"U\n" +
"\x1aCreateInboxMessageResponse\x12\x1d\n" + "\x1aCreateInboxMessageResponse\x12\x1d\n" +
"\n" + "\n" +
"message_id\x18\x01 \x01(\tR\tmessageId\x12\x18\n" + "message_id\x18\x01 \x01(\tR\tmessageId\x12\x18\n" +

View File

@ -70,6 +70,8 @@ message InboxMessage {
string action_param = 9; string action_param = 9;
bool read = 10; bool read = 10;
int64 sent_at_ms = 11; int64 sent_at_ms = 11;
// image_urls image_url
repeated string image_urls = 12;
} }
// ListInboxMessagesRequest section inbox // ListInboxMessagesRequest section inbox
@ -151,6 +153,8 @@ message CreateInboxMessageRequest {
int64 sent_at_ms = 19; int64 sent_at_ms = 19;
int64 expire_at_ms = 20; int64 expire_at_ms = 20;
string metadata_json = 21; string metadata_json = 21;
// image_urls inbox owner metadata_json
repeated string image_urls = 22;
} }
// CreateInboxMessageResponse producer_event_id created=false // CreateInboxMessageResponse producer_event_id created=false

View File

@ -25985,16 +25985,18 @@ func (x *AgencyHostRevenueStats) GetStats() *HostRevenueStats {
// PointWithdrawalCoinSellerConfig 是按 App 隔离的 POINT 提现币商白名单与兑换比例。 // PointWithdrawalCoinSellerConfig 是按 App 隔离的 POINT 提现币商白名单与兑换比例。
// 比例用整数分子/分母保存,避免浮点金额在 H5、gateway 和账本间产生舍入分歧。 // 比例用整数分子/分母保存,避免浮点金额在 H5、gateway 和账本间产生舍入分歧。
type PointWithdrawalCoinSellerConfig struct { type PointWithdrawalCoinSellerConfig struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` AppCode string `protobuf:"bytes,1,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
SellerUserId int64 `protobuf:"varint,2,opt,name=seller_user_id,json=sellerUserId,proto3" json:"seller_user_id,omitempty"` SellerUserId int64 `protobuf:"varint,2,opt,name=seller_user_id,json=sellerUserId,proto3" json:"seller_user_id,omitempty"`
SortOrder int32 `protobuf:"varint,3,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` SortOrder int32 `protobuf:"varint,3,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"`
PointAmount int64 `protobuf:"varint,4,opt,name=point_amount,json=pointAmount,proto3" json:"point_amount,omitempty"` PointAmount int64 `protobuf:"varint,4,opt,name=point_amount,json=pointAmount,proto3" json:"point_amount,omitempty"`
SellerCoinAmount int64 `protobuf:"varint,5,opt,name=seller_coin_amount,json=sellerCoinAmount,proto3" json:"seller_coin_amount,omitempty"` SellerCoinAmount int64 `protobuf:"varint,5,opt,name=seller_coin_amount,json=sellerCoinAmount,proto3" json:"seller_coin_amount,omitempty"`
ServiceCountryCodes []string `protobuf:"bytes,6,rep,name=service_country_codes,json=serviceCountryCodes,proto3" json:"service_country_codes,omitempty"` // service_country_codes 与 service_region_ids 互斥;两个数组都为空表示服务全部区域和国家。
Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"` ServiceCountryCodes []string `protobuf:"bytes,6,rep,name=service_country_codes,json=serviceCountryCodes,proto3" json:"service_country_codes,omitempty"`
CreatedAtMs int64 `protobuf:"varint,8,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"` Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"` CreatedAtMs int64 `protobuf:"varint,8,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
UpdatedAtMs int64 `protobuf:"varint,9,opt,name=updated_at_ms,json=updatedAtMs,proto3" json:"updated_at_ms,omitempty"`
ServiceRegionIds []int64 `protobuf:"varint,10,rep,packed,name=service_region_ids,json=serviceRegionIds,proto3" json:"service_region_ids,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -26092,13 +26094,21 @@ func (x *PointWithdrawalCoinSellerConfig) GetUpdatedAtMs() int64 {
return 0 return 0
} }
func (x *PointWithdrawalCoinSellerConfig) GetServiceRegionIds() []int64 {
if x != nil {
return x.ServiceRegionIds
}
return nil
}
type ListPointWithdrawalCoinSellersRequest struct { type ListPointWithdrawalCoinSellersRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"` RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"` AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
// country_code 由 gateway 从登录用户资料注入;为空只用于 Admin/内部查询。 // country_code 和 region_id 都由 gateway 从登录用户资料注入;空只用于 Admin/内部查询。
CountryCode string `protobuf:"bytes,3,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"` CountryCode string `protobuf:"bytes,3,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"`
IncludeDisabled bool `protobuf:"varint,4,opt,name=include_disabled,json=includeDisabled,proto3" json:"include_disabled,omitempty"` IncludeDisabled bool `protobuf:"varint,4,opt,name=include_disabled,json=includeDisabled,proto3" json:"include_disabled,omitempty"`
RegionId int64 `protobuf:"varint,5,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -26161,6 +26171,13 @@ func (x *ListPointWithdrawalCoinSellersRequest) GetIncludeDisabled() bool {
return false return false
} }
func (x *ListPointWithdrawalCoinSellersRequest) GetRegionId() int64 {
if x != nil {
return x.RegionId
}
return 0
}
type ListPointWithdrawalCoinSellersResponse struct { type ListPointWithdrawalCoinSellersResponse struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Sellers []*PointWithdrawalCoinSellerConfig `protobuf:"bytes,1,rep,name=sellers,proto3" json:"sellers,omitempty"` Sellers []*PointWithdrawalCoinSellerConfig `protobuf:"bytes,1,rep,name=sellers,proto3" json:"sellers,omitempty"`
@ -29806,7 +29823,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x16AgencyHostRevenueStats\x12 \n" + "\x16AgencyHostRevenueStats\x12 \n" +
"\fhost_user_id\x18\x01 \x01(\x03R\n" + "\fhost_user_id\x18\x01 \x01(\x03R\n" +
"hostUserId\x127\n" + "hostUserId\x127\n" +
"\x05stats\x18\x02 \x01(\v2!.hyapp.wallet.v1.HostRevenueStatsR\x05stats\"\xe6\x02\n" + "\x05stats\x18\x02 \x01(\v2!.hyapp.wallet.v1.HostRevenueStatsR\x05stats\"\x94\x03\n" +
"\x1fPointWithdrawalCoinSellerConfig\x12\x19\n" + "\x1fPointWithdrawalCoinSellerConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12$\n" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12$\n" +
"\x0eseller_user_id\x18\x02 \x01(\x03R\fsellerUserId\x12\x1d\n" + "\x0eseller_user_id\x18\x02 \x01(\x03R\fsellerUserId\x12\x1d\n" +
@ -29817,13 +29834,16 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\x15service_country_codes\x18\x06 \x03(\tR\x13serviceCountryCodes\x12\x16\n" + "\x15service_country_codes\x18\x06 \x03(\tR\x13serviceCountryCodes\x12\x16\n" +
"\x06status\x18\a \x01(\tR\x06status\x12\"\n" + "\x06status\x18\a \x01(\tR\x06status\x12\"\n" +
"\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" + "\rcreated_at_ms\x18\b \x01(\x03R\vcreatedAtMs\x12\"\n" +
"\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\"\xaf\x01\n" + "\rupdated_at_ms\x18\t \x01(\x03R\vupdatedAtMs\x12,\n" +
"\x12service_region_ids\x18\n" +
" \x03(\x03R\x10serviceRegionIds\"\xcc\x01\n" +
"%ListPointWithdrawalCoinSellersRequest\x12\x1d\n" + "%ListPointWithdrawalCoinSellersRequest\x12\x1d\n" +
"\n" + "\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
"\bapp_code\x18\x02 \x01(\tR\aappCode\x12!\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\x12!\n" +
"\fcountry_code\x18\x03 \x01(\tR\vcountryCode\x12)\n" + "\fcountry_code\x18\x03 \x01(\tR\vcountryCode\x12)\n" +
"\x10include_disabled\x18\x04 \x01(\bR\x0fincludeDisabled\"t\n" + "\x10include_disabled\x18\x04 \x01(\bR\x0fincludeDisabled\x12\x1b\n" +
"\tregion_id\x18\x05 \x01(\x03R\bregionId\"t\n" +
"&ListPointWithdrawalCoinSellersResponse\x12J\n" + "&ListPointWithdrawalCoinSellersResponse\x12J\n" +
"\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\x84\x03\n" + "\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\x84\x03\n" +
" TransferPointToCoinSellerRequest\x12\x1d\n" + " TransferPointToCoinSellerRequest\x12\x1d\n" +

View File

@ -3011,18 +3011,21 @@ message PointWithdrawalCoinSellerConfig {
int32 sort_order = 3; int32 sort_order = 3;
int64 point_amount = 4; int64 point_amount = 4;
int64 seller_coin_amount = 5; int64 seller_coin_amount = 5;
// service_country_codes service_region_ids
repeated string service_country_codes = 6; repeated string service_country_codes = 6;
string status = 7; string status = 7;
int64 created_at_ms = 8; int64 created_at_ms = 8;
int64 updated_at_ms = 9; int64 updated_at_ms = 9;
repeated int64 service_region_ids = 10;
} }
message ListPointWithdrawalCoinSellersRequest { message ListPointWithdrawalCoinSellersRequest {
string request_id = 1; string request_id = 1;
string app_code = 2; string app_code = 2;
// country_code gateway Admin/ // country_code region_id gateway Admin/
string country_code = 3; string country_code = 3;
bool include_disabled = 4; bool include_disabled = 4;
int64 region_id = 5;
} }
message ListPointWithdrawalCoinSellersResponse { message ListPointWithdrawalCoinSellersResponse {

View File

@ -450,7 +450,7 @@ func main() {
Report: reportmodule.New(userDB, roomClient), Report: reportmodule.New(userDB, roomClient),
RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler), RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler), RegionBlock: regionblockmodule.New(userDB, userclient.NewGRPC(userConn), auditHandler),
Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler), Resource: resourcemodule.New(walletclient.NewGRPC(walletConn), store, userDB, cfg.WalletService.RequestTimeout, auditHandler, activityclient.NewGRPC(activityConn)),
RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler), RiskConfig: riskconfigmodule.New(userclient.NewGRPC(userConn), auditHandler),
RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler), RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler),
RoomRocket: roomrocketmodule.New(roomClient, auditHandler), RoomRocket: roomrocketmodule.New(roomClient, auditHandler),
@ -462,7 +462,7 @@ func main() {
TeamSalarySettlement: teamSalarySettlementHandler, TeamSalarySettlement: teamSalarySettlementHandler,
Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler),
UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient), UserLeaderboard: userleaderboardmodule.New(walletDB, userDB, roomClient),
VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler), VIPConfig: vipconfigmodule.New(walletclient.NewGRPC(walletConn), auditHandler, activityclient.NewGRPC(activityConn)),
WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler), WeeklyStar: weeklystarmodule.New(activityclient.NewGRPC(activityConn), auditHandler),
Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler), Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler),
} }

View File

@ -10,6 +10,7 @@ type itemDTO struct {
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
PointAmount int64 `json:"pointAmount"` PointAmount int64 `json:"pointAmount"`
SellerCoinAmount int64 `json:"sellerCoinAmount"` SellerCoinAmount int64 `json:"sellerCoinAmount"`
ServiceRegionIDs []int64 `json:"serviceRegionIds"`
ServiceCountryCodes []string `json:"serviceCountryCodes"` ServiceCountryCodes []string `json:"serviceCountryCodes"`
Status string `json:"status"` Status string `json:"status"`
CreatedAtMS int64 `json:"createdAtMs"` CreatedAtMS int64 `json:"createdAtMs"`
@ -21,6 +22,7 @@ type upsertRequest struct {
SortOrder int `json:"sortOrder"` SortOrder int `json:"sortOrder"`
PointAmount int64 `json:"pointAmount" binding:"required"` PointAmount int64 `json:"pointAmount" binding:"required"`
SellerCoinAmount int64 `json:"sellerCoinAmount" binding:"required"` SellerCoinAmount int64 `json:"sellerCoinAmount" binding:"required"`
ServiceRegionIDs []int64 `json:"serviceRegionIds"`
ServiceCountryCodes []string `json:"serviceCountryCodes"` ServiceCountryCodes []string `json:"serviceCountryCodes"`
Status string `json:"status"` Status string `json:"status"`
} }

View File

@ -28,7 +28,8 @@ func (s *Service) List(ctx context.Context, appCode string) ([]itemDTO, error) {
} }
rows, err := s.walletDB.QueryContext(ctx, ` rows, err := s.walletDB.QueryContext(ctx, `
SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
service_country_codes, status, created_at_ms, updated_at_ms COALESCE(service_region_ids, JSON_ARRAY()), service_country_codes,
status, created_at_ms, updated_at_ms
FROM point_withdrawal_coin_seller_configs FROM point_withdrawal_coin_seller_configs
WHERE app_code = ? WHERE app_code = ?
ORDER BY sort_order ASC, seller_user_id ASC`, normalizeAppCode(appCode)) ORDER BY sort_order ASC, seller_user_id ASC`, normalizeAppCode(appCode))
@ -40,14 +41,19 @@ func (s *Service) List(ctx context.Context, appCode string) ([]itemDTO, error) {
for rows.Next() { for rows.Next() {
var item itemDTO var item itemDTO
var sellerUserID int64 var sellerUserID int64
var regionsJSON []byte
var countriesJSON []byte var countriesJSON []byte
if err := rows.Scan(&item.AppCode, &sellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { if err := rows.Scan(&item.AppCode, &sellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &regionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err return nil, err
} }
item.SellerUserID = strconv.FormatInt(sellerUserID, 10) item.SellerUserID = strconv.FormatInt(sellerUserID, 10)
if err := json.Unmarshal(regionsJSON, &item.ServiceRegionIDs); err != nil {
return nil, fmt.Errorf("parse service regions for seller %d: %w", sellerUserID, err)
}
if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil { if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil {
return nil, fmt.Errorf("parse service countries for seller %d: %w", sellerUserID, err) return nil, fmt.Errorf("parse service countries for seller %d: %w", sellerUserID, err)
} }
item.ServiceRegionIDs = normalizeRegionIDs(item.ServiceRegionIDs)
item.ServiceCountryCodes = normalizeCountries(item.ServiceCountryCodes) item.ServiceCountryCodes = normalizeCountries(item.ServiceCountryCodes)
displayUserID, nickname, avatar, countryName, lookupErr := s.lookupSeller(ctx, item.AppCode, sellerUserID, false) displayUserID, nickname, avatar, countryName, lookupErr := s.lookupSeller(ctx, item.AppCode, sellerUserID, false)
if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) { if lookupErr != nil && !errors.Is(lookupErr, sql.ErrNoRows) {
@ -78,7 +84,31 @@ func (s *Service) Upsert(ctx context.Context, appCode string, req upsertRequest,
if err := s.ensureSchema(ctx); err != nil { if err := s.ensureSchema(ctx); err != nil {
return itemDTO{}, err return itemDTO{}, err
} }
// 非法值不能在规范化时被静默丢弃,否则“输入错误”会意外扩大成全部区域、全部国家。
for _, regionID := range req.ServiceRegionIDs {
if regionID <= 0 {
return itemDTO{}, errors.New("服务区域 ID 不正确")
}
}
for _, countryCode := range req.ServiceCountryCodes {
countryCode = strings.TrimSpace(countryCode)
if countryCode == "" || len(countryCode) > 3 {
return itemDTO{}, errors.New("服务国家不正确")
}
}
regions := normalizeRegionIDs(req.ServiceRegionIDs)
countries := normalizeCountries(req.ServiceCountryCodes) countries := normalizeCountries(req.ServiceCountryCodes)
if len(regions) > 0 && len(countries) > 0 {
return itemDTO{}, errors.New("服务区域和服务国家不能同时选择")
}
// 运营只能选择当前 App 的有效主数据;拒绝已停用或不存在的值,避免资金资格配置悬空。
if err := s.validateServiceScope(ctx, appCode, regions, countries); err != nil {
return itemDTO{}, err
}
regionsJSON, err := json.Marshal(regions)
if err != nil {
return itemDTO{}, err
}
countriesJSON, err := json.Marshal(countries) countriesJSON, err := json.Marshal(countries)
if err != nil { if err != nil {
return itemDTO{}, err return itemDTO{}, err
@ -86,14 +116,16 @@ func (s *Service) Upsert(ctx context.Context, appCode string, req upsertRequest,
nowMS := time.Now().UTC().UnixMilli() nowMS := time.Now().UTC().UnixMilli()
_, err = s.walletDB.ExecContext(ctx, ` _, err = s.walletDB.ExecContext(ctx, `
INSERT INTO point_withdrawal_coin_seller_configs ( INSERT INTO point_withdrawal_coin_seller_configs (
app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes, app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms service_country_codes, service_region_ids, status,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
sort_order = VALUES(sort_order), point_amount = VALUES(point_amount), sort_order = VALUES(sort_order), point_amount = VALUES(point_amount),
seller_coin_amount = VALUES(seller_coin_amount), service_country_codes = VALUES(service_country_codes), seller_coin_amount = VALUES(seller_coin_amount), service_country_codes = VALUES(service_country_codes),
service_region_ids = VALUES(service_region_ids),
status = VALUES(status), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`, status = VALUES(status), updated_by_admin_id = VALUES(updated_by_admin_id), updated_at_ms = VALUES(updated_at_ms)`,
appCode, req.SellerUserID, req.SortOrder, req.PointAmount, req.SellerCoinAmount, countriesJSON, appCode, req.SellerUserID, req.SortOrder, req.PointAmount, req.SellerCoinAmount, countriesJSON, regionsJSON,
status, adminID, adminID, nowMS, nowMS) status, adminID, adminID, nowMS, nowMS)
if err != nil { if err != nil {
return itemDTO{}, err return itemDTO{}, err
@ -158,6 +190,50 @@ func (s *Service) lookupSeller(ctx context.Context, appCode string, sellerUserID
return return
} }
func (s *Service) validateServiceScope(ctx context.Context, appCode string, regionIDs []int64, countryCodes []string) error {
if s.userDB == nil {
return errors.New("user db is not configured")
}
if len(regionIDs) > 0 {
args := make([]any, 0, len(regionIDs)+1)
args = append(args, appCode)
for _, regionID := range regionIDs {
args = append(args, regionID)
}
var count int
query := `SELECT COUNT(*) FROM regions WHERE app_code = ? AND status = 'active' AND region_code <> 'GLOBAL' AND region_id IN (` + placeholders(len(regionIDs)) + `)`
if err := s.userDB.QueryRowContext(ctx, query, args...).Scan(&count); err != nil {
return err
}
if count != len(regionIDs) {
return errors.New("服务区域包含不存在或已停用的区域")
}
}
if len(countryCodes) > 0 {
args := make([]any, 0, len(countryCodes)+1)
args = append(args, appCode)
for _, countryCode := range countryCodes {
args = append(args, countryCode)
}
var count int
query := `SELECT COUNT(*) FROM countries WHERE app_code = ? AND enabled = TRUE AND country_code IN (` + placeholders(len(countryCodes)) + `)`
if err := s.userDB.QueryRowContext(ctx, query, args...).Scan(&count); err != nil {
return err
}
if count != len(countryCodes) {
return errors.New("服务国家包含不存在或已停用的国家")
}
}
return nil
}
func placeholders(count int) string {
if count <= 0 {
return ""
}
return strings.TrimSuffix(strings.Repeat("?,", count), ",")
}
func (s *Service) ensureSchema(ctx context.Context) error { func (s *Service) ensureSchema(ctx context.Context) error {
if s == nil || s.walletDB == nil { if s == nil || s.walletDB == nil {
return errors.New("wallet db is not configured") return errors.New("wallet db is not configured")
@ -166,11 +242,30 @@ func (s *Service) ensureSchema(ctx context.Context) error {
CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs ( CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
app_code VARCHAR(32) NOT NULL, seller_user_id BIGINT NOT NULL, sort_order INT NOT NULL DEFAULT 0, app_code VARCHAR(32) NOT NULL, seller_user_id BIGINT NOT NULL, sort_order INT NOT NULL DEFAULT 0,
point_amount BIGINT NOT NULL, seller_coin_amount BIGINT NOT NULL, service_country_codes JSON NOT NULL, point_amount BIGINT NOT NULL, seller_coin_amount BIGINT NOT NULL, service_country_codes JSON NOT NULL,
service_region_ids JSON NULL,
status VARCHAR(24) NOT NULL DEFAULT 'active', created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, status VARCHAR(24) NOT NULL DEFAULT 'active', created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_at_ms BIGINT NOT NULL, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, seller_user_id), PRIMARY KEY (app_code, seller_user_id),
KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id) KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`)
if err != nil {
return err
}
var columnCount int
if err := s.walletDB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = DATABASE() AND table_name = 'point_withdrawal_coin_seller_configs'
AND column_name = 'service_region_ids'`).Scan(&columnCount); err != nil {
return err
}
if columnCount > 0 {
return nil
}
// 这是按币商唯一的小型配置表INSTANT 加 nullable JSON 列不复制表,也不回填历史数据。
_, err = s.walletDB.ExecContext(ctx, `
ALTER TABLE point_withdrawal_coin_seller_configs
ADD COLUMN service_region_ids JSON NULL AFTER service_country_codes,
ALGORITHM=INSTANT`)
return err return err
} }
@ -192,6 +287,23 @@ func normalizeCountries(values []string) []string {
return out return out
} }
func normalizeRegionIDs(values []int64) []int64 {
seen := map[int64]struct{}{}
out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func normalizeAppCode(value string) string { func normalizeAppCode(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = strings.ToLower(strings.TrimSpace(value))
if value == "" { if value == "" {

View File

@ -0,0 +1,122 @@
package resource
import (
"context"
"encoding/json"
"fmt"
"strings"
activityv1 "hyapp.local/api/proto/activity/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
)
const (
resourceGrantNoticeTitle = "System Gift"
resourceGrantNoticeBody = "You received the following system-granted resources."
)
type resourceNoticeSnapshot struct {
ResourceType string
Name string
AssetURL string
PreviewURL string
MetadataJSON string
}
func (h *Handler) createResourceGrantNotice(ctx context.Context, requestID string, appCode string, grant *walletv1.ResourceGrant) error {
if grant == nil {
return fmt.Errorf("resource grant response is empty")
}
return h.createResourceBatchGrantNotice(ctx, requestID, appCode, grant.GetGrantId(), grant.GetTargetUserId(), []*walletv1.ResourceGrant{grant})
}
func (h *Handler) createResourceBatchGrantNotice(ctx context.Context, requestID string, appCode string, commandID string, targetUserID int64, grants []*walletv1.ResourceGrant) error {
if h.activity == nil {
// Tests and isolated local handlers may omit integrations; the production constructor always wires activity-service.
return nil
}
snapshots, err := resourceNoticeSnapshots(grants)
if err != nil {
return err
}
body := resourceGrantNoticeBody
imageURLs := resourceNoticeImageURLs(snapshots)
if len(grants) == 1 && len(snapshots) == 1 && strings.EqualFold(strings.TrimSpace(snapshots[0].ResourceType), resourceTypeVIPTrialCard) {
level := vipTrialLevel(snapshots[0].MetadataJSON)
body = "You received a VIP trial card."
if level > 0 {
body = fmt.Sprintf("You received a VIP%d trial card.", level)
}
imageURLs = nil
}
producerEventID := "resource_grant:" + strings.TrimSpace(commandID)
_, err = h.activity.CreateInboxMessage(ctx, &activityv1.CreateInboxMessageRequest{
Meta: &activityv1.RequestMeta{RequestId: strings.TrimSpace(requestID), Caller: "admin-server", AppCode: strings.TrimSpace(appCode)},
TargetUserId: targetUserID,
Producer: "admin-server",
ProducerEventId: producerEventID,
ProducerEventType: "AdminResourceGranted",
MessageType: "system",
AggregateType: "resource_grant",
AggregateId: strings.TrimSpace(commandID),
Title: resourceGrantNoticeTitle,
Summary: body,
Body: body,
ImageUrl: firstNoticeImageURL(imageURLs),
ImageUrls: imageURLs,
})
return err
}
func resourceNoticeSnapshots(grants []*walletv1.ResourceGrant) ([]resourceNoticeSnapshot, error) {
result := make([]resourceNoticeSnapshot, 0)
for _, grant := range grants {
if grant == nil {
continue
}
for _, item := range grant.GetItems() {
raw := strings.TrimSpace(item.GetResourceSnapshotJson())
if raw == "" || raw == "{}" {
continue
}
var snapshot resourceNoticeSnapshot
if err := json.Unmarshal([]byte(raw), &snapshot); err != nil {
return nil, fmt.Errorf("decode granted resource snapshot: %w", err)
}
result = append(result, snapshot)
}
}
return result, nil
}
func resourceNoticeImageURLs(snapshots []resourceNoticeSnapshot) []string {
result := make([]string, 0, len(snapshots))
for _, snapshot := range snapshots {
url := strings.TrimSpace(snapshot.PreviewURL)
if url == "" {
url = strings.TrimSpace(snapshot.AssetURL)
}
if url == "" {
continue
}
result = append(result, url)
}
return result
}
func vipTrialLevel(metadataJSON string) int32 {
var metadata struct {
VIPLevel int32 `json:"vip_level"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(metadataJSON)), &metadata); err != nil {
return 0
}
return metadata.VIPLevel
}
func firstNoticeImageURL(imageURLs []string) string {
if len(imageURLs) == 0 {
return ""
}
return imageURLs[0]
}

View File

@ -2,7 +2,9 @@ package resource
import ( import (
"context" "context"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"sort" "sort"
@ -11,6 +13,7 @@ import (
"time" "time"
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
@ -23,17 +26,22 @@ import (
type Handler struct { type Handler struct {
wallet walletclient.Client wallet walletclient.Client
activity activityclient.Client
store *repository.Store store *repository.Store
userDB *sql.DB userDB *sql.DB
requestTimeout time.Duration requestTimeout time.Duration
audit shared.OperationLogger audit shared.OperationLogger
} }
func New(wallet walletclient.Client, store *repository.Store, userDB *sql.DB, requestTimeout time.Duration, audit shared.OperationLogger) *Handler { func New(wallet walletclient.Client, store *repository.Store, userDB *sql.DB, requestTimeout time.Duration, audit shared.OperationLogger, activities ...activityclient.Client) *Handler {
if requestTimeout <= 0 { if requestTimeout <= 0 {
requestTimeout = 3 * time.Second requestTimeout = 3 * time.Second
} }
return &Handler{wallet: wallet, store: store, userDB: userDB, requestTimeout: requestTimeout, audit: audit} var activity activityclient.Client
if len(activities) > 0 {
activity = activities[0]
}
return &Handler{wallet: wallet, activity: activity, store: store, userDB: userDB, requestTimeout: requestTimeout, audit: audit}
} }
func (h *Handler) walletRequestContext(c *gin.Context) (context.Context, context.CancelFunc) { func (h *Handler) walletRequestContext(c *gin.Context) (context.Context, context.CancelFunc) {
@ -727,9 +735,76 @@ func (h *Handler) grantResource(c *gin.Context, external bool) {
} }
grant := grantFromProto(resp.GetGrant()) grant := grantFromProto(resp.GetGrant())
h.auditLog(c, "grant-resource", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID)) h.auditLog(c, "grant-resource", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
if err := h.createResourceGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appctx.FromContext(c.Request.Context()), resp.GetGrant()); err != nil {
response.ServerError(c, "资源已赠送,但系统消息发送失败,请使用相同命令重试")
return
}
response.Created(c, grant) response.Created(c, grant)
} }
// GrantResources keeps one Admin selection as one backend command so all resource images can be
// delivered in a single inbox card. Child wallet commands are deterministic, therefore an HTTP
// retry cannot duplicate entitlements that were already committed before a later item failed.
func (h *Handler) GrantResources(c *gin.Context) {
var req grantResourcesRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "资源批量赠送参数不正确")
return
}
targetUserID, err := parseGrantTargetUserID(req.TargetUserID)
if err != nil || strings.TrimSpace(req.CommandID) == "" || len(strings.TrimSpace(req.CommandID)) > 96 || strings.TrimSpace(req.Reason) == "" || len(req.Resources) == 0 || len(req.Resources) > 100 {
response.BadRequest(c, "资源批量赠送参数不正确")
return
}
for _, item := range req.Resources {
if item.ResourceID <= 0 || item.Quantity <= 0 || item.DurationMS < 0 {
response.BadRequest(c, "资源批量赠送参数不正确")
return
}
if err := h.validateGenericGrantResource(c, item.ResourceID, false); err != nil {
response.BadRequest(c, err.Error())
return
}
}
appCode := appctx.FromContext(c.Request.Context())
grants := make([]*walletv1.ResourceGrant, 0, len(req.Resources))
grantDTOs := make([]grantDTO, 0, len(req.Resources))
for index, item := range req.Resources {
resp, grantErr := h.wallet.GrantResource(c.Request.Context(), &walletv1.GrantResourceRequest{
CommandId: resourceBatchChildCommandID(req.CommandID, index, item.ResourceID),
AppCode: appCode,
TargetUserId: targetUserID,
ResourceId: item.ResourceID,
Quantity: item.Quantity,
DurationMs: item.DurationMS,
Reason: strings.TrimSpace(req.Reason),
OperatorUserId: actorID(c),
GrantSource: "admin",
})
if grantErr != nil {
response.BadRequest(c, grantErr.Error())
return
}
grant := resp.GetGrant()
grants = append(grants, grant)
grantDTOs = append(grantDTOs, grantFromProto(grant))
}
for _, grant := range grantDTOs {
h.auditLog(c, "grant-resource", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
}
if err := h.createResourceBatchGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appCode, strings.TrimSpace(req.CommandID), targetUserID, grants); err != nil {
response.ServerError(c, "资源已赠送,但系统消息发送失败,请使用相同命令重试")
return
}
response.Created(c, map[string]any{"grants": grantDTOs})
}
func resourceBatchChildCommandID(batchCommandID string, index int, resourceID int64) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", strings.TrimSpace(batchCommandID), index, resourceID)))
return "admin_resource_grant:" + hex.EncodeToString(sum[:])
}
func (h *Handler) GrantResourceGroup(c *gin.Context) { func (h *Handler) GrantResourceGroup(c *gin.Context) {
var req grantGroupRequest var req grantGroupRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
@ -760,6 +835,10 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) {
} }
grant := grantFromProto(resp.GetGrant()) grant := grantFromProto(resp.GetGrant())
h.auditLog(c, "grant-resource-group", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID)) h.auditLog(c, "grant-resource-group", "resource_grants", grant.GrantID, "success", fmt.Sprintf("target_user_id=%d", grant.TargetUserID))
if err := h.createResourceGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appctx.FromContext(c.Request.Context()), resp.GetGrant()); err != nil {
response.ServerError(c, "资源组已赠送,但系统消息发送失败,请使用相同命令重试")
return
}
response.Created(c, grant) response.Created(c, grant)
} }

View File

@ -249,6 +249,19 @@ type grantResourceRequest struct {
Reason string `json:"reason"` Reason string `json:"reason"`
} }
type grantResourcesRequest struct {
CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"`
Resources []grantResourcesItemRequest `json:"resources"`
Reason string `json:"reason"`
}
type grantResourcesItemRequest struct {
ResourceID int64 `json:"resourceId"`
Quantity int64 `json:"quantity"`
DurationMS int64 `json:"durationMs"`
}
type grantGroupRequest struct { type grantGroupRequest struct {
CommandID string `json:"commandId"` CommandID string `json:"commandId"`
TargetUserID any `json:"targetUserId"` TargetUserID any `json:"targetUserId"`

View File

@ -47,6 +47,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.POST("/admin/gifts/:gift_id/disable", middleware.RequirePermission("gift:status"), h.DisableGift) protected.POST("/admin/gifts/:gift_id/disable", middleware.RequirePermission("gift:status"), h.DisableGift)
protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource) protected.POST("/admin/resource-grants/resource", middleware.RequirePermission("resource-grant:create"), h.GrantResource)
protected.POST("/admin/resource-grants/resources", middleware.RequirePermission("resource-grant:create"), h.GrantResources)
protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup) protected.POST("/admin/resource-grants/group", middleware.RequirePermission("resource-grant:create"), h.GrantResourceGroup)
protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant) protected.POST("/admin/resource-grants/:grant_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeResourceGrant)
protected.POST("/admin/users/:user_id/resources/:entitlement_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeUserResource) protected.POST("/admin/users/:user_id/resources/:entitlement_id/revoke", middleware.RequirePermission("resource-grant:revoke"), h.RevokeUserResource)

View File

@ -0,0 +1,50 @@
package vipconfig
import (
"context"
"fmt"
"strings"
activityv1 "hyapp.local/api/proto/activity/v1"
)
const vipGrantNoticeTitle = "System Gift"
func (h *Handler) createVIPGrantNotice(ctx context.Context, requestID string, appCode string, commandID string, targetUserID int64, level int32, transactionID string) error {
body := fmt.Sprintf("You received VIP%d.", level)
eventID := strings.TrimSpace(transactionID)
if eventID == "" {
eventID = strings.TrimSpace(commandID)
}
return h.createVIPSystemNotice(ctx, requestID, appCode, targetUserID, "vip_grant:"+eventID, "AdminVIPGranted", commandID, body)
}
func (h *Handler) createVIPTrialCardGrantNotice(ctx context.Context, requestID string, appCode string, commandID string, targetUserID int64, level int32, trialCardID string) error {
body := fmt.Sprintf("You received a VIP%d trial card.", level)
eventID := strings.TrimSpace(trialCardID)
if eventID == "" {
eventID = strings.TrimSpace(commandID)
}
return h.createVIPSystemNotice(ctx, requestID, appCode, targetUserID, "vip_trial_card_grant:"+eventID, "AdminVIPTrialCardGranted", commandID, body)
}
func (h *Handler) createVIPSystemNotice(ctx context.Context, requestID string, appCode string, targetUserID int64, producerEventID string, producerEventType string, aggregateID string, body string) error {
if h.activity == nil {
// Tests and isolated local handlers may omit integrations; the production constructor always wires activity-service.
return nil
}
_, err := h.activity.CreateInboxMessage(ctx, &activityv1.CreateInboxMessageRequest{
Meta: &activityv1.RequestMeta{RequestId: strings.TrimSpace(requestID), Caller: "admin-server", AppCode: strings.TrimSpace(appCode)},
TargetUserId: targetUserID,
Producer: "admin-server",
ProducerEventId: strings.TrimSpace(producerEventID),
ProducerEventType: producerEventType,
MessageType: "system",
AggregateType: "vip_grant",
AggregateId: strings.TrimSpace(aggregateID),
Title: vipGrantNoticeTitle,
Summary: body,
Body: body,
})
return err
}

View File

@ -5,6 +5,7 @@ import (
"strings" "strings"
"hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/appctx"
"hyapp-admin-server/internal/integration/activityclient"
"hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/integration/walletclient"
"hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/middleware"
"hyapp-admin-server/internal/modules/shared" "hyapp-admin-server/internal/modules/shared"
@ -15,12 +16,17 @@ import (
) )
type Handler struct { type Handler struct {
wallet walletclient.Client wallet walletclient.Client
audit shared.OperationLogger activity activityclient.Client
audit shared.OperationLogger
} }
func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler { func New(wallet walletclient.Client, audit shared.OperationLogger, activities ...activityclient.Client) *Handler {
return &Handler{wallet: wallet, audit: audit} var activity activityclient.Client
if len(activities) > 0 {
activity = activities[0]
}
return &Handler{wallet: wallet, activity: activity, audit: audit}
} }
type configRequest struct { type configRequest struct {
@ -325,6 +331,10 @@ func (h *Handler) grantVIP(c *gin.Context, grantSource string, defaultCommandPre
return return
} }
shared.OperationLogWithResourceID(c, h.audit, "grant-vip", "user_vip_memberships", strconv.FormatInt(targetUserID, 10), "success", "level="+strconv.Itoa(int(req.Level))) shared.OperationLogWithResourceID(c, h.audit, "grant-vip", "user_vip_memberships", strconv.FormatInt(targetUserID, 10), "success", "level="+strconv.Itoa(int(req.Level)))
if err := h.createVIPGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appctx.FromContext(c.Request.Context()), commandID, targetUserID, req.Level, resp.GetTransactionId()); err != nil {
response.ServerError(c, "VIP已赠送但系统消息发送失败请使用相同命令重试")
return
}
response.Created(c, grantVipFromProto(resp)) response.Created(c, grantVipFromProto(resp))
} }
@ -376,6 +386,10 @@ func (h *Handler) grantVIPTrialCard(c *gin.Context, grantSource string, defaultC
} }
// 体验卡是独立背包实例,审计目标使用 trial_card_id后续切换佩戴不会覆盖本次赠送记录。 // 体验卡是独立背包实例,审计目标使用 trial_card_id后续切换佩戴不会覆盖本次赠送记录。
shared.OperationLogWithResourceID(c, h.audit, "grant-vip-trial-card", "user_vip_trial_cards", resp.GetTrialCard().GetTrialCardId(), "success", "target_user_id="+strconv.FormatInt(targetUserID, 10)) shared.OperationLogWithResourceID(c, h.audit, "grant-vip-trial-card", "user_vip_trial_cards", resp.GetTrialCard().GetTrialCardId(), "success", "target_user_id="+strconv.FormatInt(targetUserID, 10))
if err := h.createVIPTrialCardGrantNotice(c.Request.Context(), middleware.CurrentRequestID(c), appctx.FromContext(c.Request.Context()), commandID, targetUserID, req.Level, resp.GetTrialCard().GetTrialCardId()); err != nil {
response.ServerError(c, "VIP体验卡已赠送但系统消息发送失败请使用相同命令重试")
return
}
response.Created(c, grantVipTrialCardFromProto(resp)) response.Created(c, grantVipTrialCardFromProto(resp))
} }

View File

@ -57,17 +57,19 @@ type InboxMessage struct {
Body string Body string
IconURL string IconURL string
ImageURL string ImageURL string
ActionType string // ImageURLs 是一张通知卡片内的有序图片列表ImageURL 保留首图兼容旧客户端。
ActionParam string ImageURLs []string
Priority int32 ActionType string
Status string ActionParam string
ReadAtMS int64 Priority int32
DeletedAtMS int64 Status string
SentAtMS int64 ReadAtMS int64
ExpireAtMS int64 DeletedAtMS int64
MetadataJSON string SentAtMS int64
CreatedAtMS int64 ExpireAtMS int64
UpdatedAtMS int64 MetadataJSON string
CreatedAtMS int64
UpdatedAtMS int64
} }
// Read reports whether the user has marked the message as read. // Read reports whether the user has marked the message as read.
@ -121,12 +123,14 @@ type CreateInput struct {
Body string Body string
IconURL string IconURL string
ImageURL string ImageURL string
ActionType string // ImageURLs 会由 service 写入受控 metadata 快照MySQL 继续使用现有 JSON 列持久化。
ActionParam string ImageURLs []string
Priority int32 ActionType string
SentAtMS int64 ActionParam string
ExpireAtMS int64 Priority int32
MetadataJSON string SentAtMS int64
ExpireAtMS int64
MetadataJSON string
} }
// FanoutJob stores a background fanout command without executing user selection in the request path. // FanoutJob stores a background fanout command without executing user selection in the request path.

View File

@ -26,6 +26,7 @@ type NoticeCommand struct {
Body string Body string
IconURL string IconURL string
ImageURL string ImageURL string
ImageURLs []string
ActionType string ActionType string
ActionParam string ActionParam string
Priority int32 Priority int32
@ -104,6 +105,7 @@ func (s *Service) CreateNotice(ctx context.Context, messageType string, cmd Noti
Body: cmd.Body, Body: cmd.Body,
IconURL: cmd.IconURL, IconURL: cmd.IconURL,
ImageURL: cmd.ImageURL, ImageURL: cmd.ImageURL,
ImageURLs: cmd.ImageURLs,
ActionType: cmd.ActionType, ActionType: cmd.ActionType,
ActionParam: cmd.ActionParam, ActionParam: cmd.ActionParam,
Priority: cmd.Priority, Priority: cmd.Priority,

View File

@ -5,6 +5,7 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"slices"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
@ -143,6 +144,9 @@ func (s *Service) ListInboxMessages(ctx context.Context, userID int64, section s
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
for index := range messages {
messages[index].ImageURLs = inboxImageURLs(messages[index].MetadataJSON, messages[index].ImageURL)
}
if len(messages) <= limit { if len(messages) <= limit {
return messages, "", nil return messages, "", nil
} }
@ -217,6 +221,7 @@ func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.Cr
Body: input.Body, Body: input.Body,
IconURL: input.IconURL, IconURL: input.IconURL,
ImageURL: input.ImageURL, ImageURL: input.ImageURL,
ImageURLs: append([]string(nil), input.ImageURLs...),
ActionType: input.ActionType, ActionType: input.ActionType,
ActionParam: input.ActionParam, ActionParam: input.ActionParam,
Priority: input.Priority, Priority: input.Priority,
@ -235,6 +240,7 @@ func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.Cr
if !sameInboxPayload(existing, message) { if !sameInboxPayload(existing, message) {
return messagedomain.InboxMessage{}, false, xerr.New(xerr.ProducerEventConflict, "producer event payload conflicts with existing inbox message") return messagedomain.InboxMessage{}, false, xerr.New(xerr.ProducerEventConflict, "producer event payload conflicts with existing inbox message")
} }
existing.ImageURLs = inboxImageURLs(existing.MetadataJSON, existing.ImageURL)
return existing, false, nil return existing, false, nil
} }
return existing, true, nil return existing, true, nil
@ -354,6 +360,12 @@ func normalizeCreateInput(input messagedomain.CreateInput) messagedomain.CreateI
input.Body = strings.TrimSpace(input.Body) input.Body = strings.TrimSpace(input.Body)
input.IconURL = strings.TrimSpace(input.IconURL) input.IconURL = strings.TrimSpace(input.IconURL)
input.ImageURL = strings.TrimSpace(input.ImageURL) input.ImageURL = strings.TrimSpace(input.ImageURL)
input.ImageURLs = normalizeInboxImageURLs(input.ImageURLs, input.ImageURL)
if input.ImageURL == "" && len(input.ImageURLs) > 0 {
input.ImageURL = input.ImageURLs[0]
}
// 多图列表写入 inbox owner 的受控 metadata 快照,避免扩表,同时对客户端仍输出强类型字段。
input.MetadataJSON = inboxMetadataWithImageURLs(input.MetadataJSON, input.ImageURLs)
input.ActionType = strings.TrimSpace(input.ActionType) input.ActionType = strings.TrimSpace(input.ActionType)
input.ActionParam = strings.TrimSpace(input.ActionParam) input.ActionParam = strings.TrimSpace(input.ActionParam)
input.MetadataJSON = strings.TrimSpace(input.MetadataJSON) input.MetadataJSON = strings.TrimSpace(input.MetadataJSON)
@ -391,6 +403,14 @@ func validateCreateInput(input messagedomain.CreateInput) error {
return xerr.New(xerr.InvalidArgument, field.name+" is too long") return xerr.New(xerr.InvalidArgument, field.name+" is too long")
} }
} }
if len(input.ImageURLs) > 100 {
return xerr.New(xerr.InvalidArgument, "image_urls has too many items")
}
for _, imageURL := range input.ImageURLs {
if utf8.RuneCountInString(imageURL) > 512 {
return xerr.New(xerr.InvalidArgument, "image_urls item is too long")
}
}
if input.Title == "" || input.Summary == "" { if input.Title == "" || input.Summary == "" {
return xerr.New(xerr.InvalidArgument, "title and summary are required") return xerr.New(xerr.InvalidArgument, "title and summary are required")
} }
@ -403,6 +423,9 @@ func validateCreateInput(input messagedomain.CreateInput) error {
if input.MetadataJSON != "" && !json.Valid([]byte(input.MetadataJSON)) { if input.MetadataJSON != "" && !json.Valid([]byte(input.MetadataJSON)) {
return xerr.New(xerr.InvalidArgument, "metadata_json is invalid") return xerr.New(xerr.InvalidArgument, "metadata_json is invalid")
} }
if len(input.ImageURLs) > 0 && !slices.Equal(inboxImageURLs(input.MetadataJSON, ""), input.ImageURLs) {
return xerr.New(xerr.InvalidArgument, "metadata_json must be an object when image_urls is present")
}
return nil return nil
} }
@ -495,6 +518,8 @@ func firstPositive(values ...int64) int64 {
} }
func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.InboxMessage) bool { func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.InboxMessage) bool {
existing.ImageURLs = inboxImageURLs(existing.MetadataJSON, existing.ImageURL)
next.ImageURLs = inboxImageURLs(next.MetadataJSON, next.ImageURL)
return existing.UserID == next.UserID && return existing.UserID == next.UserID &&
existing.MessageType == next.MessageType && existing.MessageType == next.MessageType &&
existing.Producer == next.Producer && existing.Producer == next.Producer &&
@ -509,6 +534,7 @@ func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.In
existing.Body == next.Body && existing.Body == next.Body &&
existing.IconURL == next.IconURL && existing.IconURL == next.IconURL &&
existing.ImageURL == next.ImageURL && existing.ImageURL == next.ImageURL &&
slices.Equal(existing.ImageURLs, next.ImageURLs) &&
existing.ActionType == next.ActionType && existing.ActionType == next.ActionType &&
existing.ActionParam == next.ActionParam && existing.ActionParam == next.ActionParam &&
existing.Priority == next.Priority && existing.Priority == next.Priority &&
@ -516,6 +542,59 @@ func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.In
sameJSONText(existing.MetadataJSON, next.MetadataJSON) sameJSONText(existing.MetadataJSON, next.MetadataJSON)
} }
func normalizeInboxImageURLs(values []string, legacyImageURL string) []string {
result := make([]string, 0, len(values)+1)
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
result = append(result, value)
}
legacyImageURL = strings.TrimSpace(legacyImageURL)
if legacyImageURL != "" && (len(result) == 0 || result[0] != legacyImageURL) {
result = append([]string{legacyImageURL}, result...)
}
return result
}
func inboxMetadataWithImageURLs(raw string, imageURLs []string) string {
raw = strings.TrimSpace(raw)
metadata := map[string]any{}
if raw != "" {
if err := json.Unmarshal([]byte(raw), &metadata); err != nil {
// validateCreateInput 继续负责返回稳定的 invalid metadata 错误。
return raw
}
}
if metadata == nil {
metadata = map[string]any{}
}
if len(imageURLs) == 0 {
delete(metadata, "image_urls")
} else {
metadata["image_urls"] = imageURLs
}
if len(metadata) == 0 {
return ""
}
body, err := json.Marshal(metadata)
if err != nil {
return raw
}
return string(body)
}
func inboxImageURLs(raw string, legacyImageURL string) []string {
var metadata struct {
ImageURLs []string `json:"image_urls"`
}
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
return normalizeInboxImageURLs(nil, legacyImageURL)
}
return normalizeInboxImageURLs(metadata.ImageURLs, legacyImageURL)
}
func sameFanoutPayload(existing messagedomain.FanoutJob, next messagedomain.FanoutJob) bool { func sameFanoutPayload(existing messagedomain.FanoutJob, next messagedomain.FanoutJob) bool {
return existing.CommandID == next.CommandID && return existing.CommandID == next.CommandID &&
existing.MessageType == next.MessageType && existing.MessageType == next.MessageType &&

View File

@ -2,6 +2,7 @@ package grpc
import ( import (
"context" "context"
"strings"
activityv1 "hyapp.local/api/proto/activity/v1" activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
@ -96,6 +97,7 @@ func (s *MessageServer) CreateInboxMessage(ctx context.Context, req *activityv1.
Body: req.GetBody(), Body: req.GetBody(),
IconURL: req.GetIconUrl(), IconURL: req.GetIconUrl(),
ImageURL: req.GetImageUrl(), ImageURL: req.GetImageUrl(),
ImageURLs: req.GetImageUrls(),
ActionType: req.GetActionType(), ActionType: req.GetActionType(),
ActionParam: req.GetActionParam(), ActionParam: req.GetActionParam(),
Priority: req.GetPriority(), Priority: req.GetPriority(),
@ -127,6 +129,10 @@ func (s *MessageServer) CreateFanoutJob(ctx context.Context, req *activityv1.Cre
} }
func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMessage { func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMessage {
imageURLs := message.ImageURLs
if len(imageURLs) == 0 && strings.TrimSpace(message.ImageURL) != "" {
imageURLs = []string{strings.TrimSpace(message.ImageURL)}
}
return &activityv1.InboxMessage{ return &activityv1.InboxMessage{
MessageId: message.MessageID, MessageId: message.MessageID,
Section: message.MessageType, Section: message.MessageType,
@ -135,6 +141,7 @@ func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMess
Body: message.Body, Body: message.Body,
IconUrl: message.IconURL, IconUrl: message.IconURL,
ImageUrl: message.ImageURL, ImageUrl: message.ImageURL,
ImageUrls: imageURLs,
ActionType: message.ActionType, ActionType: message.ActionType,
ActionParam: message.ActionParam, ActionParam: message.ActionParam,
Read: message.Read(), Read: message.Read(),

View File

@ -19,16 +19,17 @@ type messageTabSectionData struct {
} }
type inboxMessageData struct { type inboxMessageData struct {
MessageID string `json:"message_id"` MessageID string `json:"message_id"`
Title string `json:"title"` Title string `json:"title"`
Summary string `json:"summary"` Summary string `json:"summary"`
Body string `json:"body"` Body string `json:"body"`
IconURL string `json:"icon_url"` IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"` ImageURL string `json:"image_url"`
ActionType string `json:"action_type"` ImageURLs []string `json:"image_urls"`
ActionParam string `json:"action_param"` ActionType string `json:"action_type"`
Read bool `json:"read"` ActionParam string `json:"action_param"`
SentAtMS int64 `json:"sent_at_ms"` Read bool `json:"read"`
SentAtMS int64 `json:"sent_at_ms"`
} }
type actionConfirmData struct { type actionConfirmData struct {
@ -114,6 +115,7 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
Body: item.GetBody(), Body: item.GetBody(),
IconURL: item.GetIconUrl(), IconURL: item.GetIconUrl(),
ImageURL: item.GetImageUrl(), ImageURL: item.GetImageUrl(),
ImageURLs: item.GetImageUrls(),
ActionType: item.GetActionType(), ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(), ActionParam: item.GetActionParam(),
Read: item.GetRead(), Read: item.GetRead(),

View File

@ -122,6 +122,7 @@ type pointDiamondCoinSellerData struct {
Nickname string `json:"nickname"` Nickname string `json:"nickname"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
ServiceRegionIDs []int64 `json:"service_region_ids"`
ServiceCountryCodes []string `json:"service_country_codes"` ServiceCountryCodes []string `json:"service_country_codes"`
} }
@ -147,7 +148,7 @@ func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, requ
// 不是资金资格事实,否则页面会展示最终被 wallet 拒绝的币商。 // 不是资金资格事实,否则页面会展示最终被 wallet 拒绝的币商。
sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{ sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
CountryCode: pointWalletCountryCode(profile), RegionId: profile.GetRegionId(), CountryCode: pointWalletCountryCode(profile),
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
@ -163,6 +164,7 @@ func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, requ
sellers = append(sellers, pointDiamondCoinSellerData{ sellers = append(sellers, pointDiamondCoinSellerData{
UserID: seller.UserID, DisplayUserID: seller.DisplayUserID, Nickname: seller.Nickname, UserID: seller.UserID, DisplayUserID: seller.DisplayUserID, Nickname: seller.Nickname,
Avatar: seller.Avatar, SortOrder: seller.SortOrder, Avatar: seller.Avatar, SortOrder: seller.SortOrder,
ServiceRegionIDs: append([]int64(nil), seller.ServiceRegionIDs...),
ServiceCountryCodes: append([]string(nil), seller.ServiceCountryCodes...), ServiceCountryCodes: append([]string(nil), seller.ServiceCountryCodes...),
}) })
} }

View File

@ -56,6 +56,7 @@ type pointWalletCoinSellerData struct {
SortOrder int32 `json:"sort_order"` SortOrder int32 `json:"sort_order"`
PointAmount int64 `json:"point_amount"` PointAmount int64 `json:"point_amount"`
SellerCoinAmount int64 `json:"seller_coin_amount"` SellerCoinAmount int64 `json:"seller_coin_amount"`
ServiceRegionIDs []int64 `json:"service_region_ids"`
ServiceCountryCodes []string `json:"service_country_codes"` ServiceCountryCodes []string `json:"service_country_codes"`
} }
@ -231,7 +232,7 @@ func (h *Handler) getPointWalletOverview(writer http.ResponseWriter, request *ht
} }
sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{ sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{
RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()),
CountryCode: pointWalletCountryCode(profile), RegionId: profile.GetRegionId(), CountryCode: pointWalletCountryCode(profile),
}) })
if err != nil { if err != nil {
httpkit.WriteRPCError(writer, request, err) httpkit.WriteRPCError(writer, request, err)
@ -672,6 +673,7 @@ func (h *Handler) pointWalletCoinSellerData(request *http.Request, configs []*wa
UserID: strconv.FormatInt(profile.GetUserId(), 10), DisplayUserID: profile.GetDisplayUserId(), UserID: strconv.FormatInt(profile.GetUserId(), 10), DisplayUserID: profile.GetDisplayUserId(),
Nickname: profile.GetUsername(), Avatar: profile.GetAvatar(), SortOrder: config.GetSortOrder(), Nickname: profile.GetUsername(), Avatar: profile.GetAvatar(), SortOrder: config.GetSortOrder(),
PointAmount: config.GetPointAmount(), SellerCoinAmount: config.GetSellerCoinAmount(), PointAmount: config.GetPointAmount(), SellerCoinAmount: config.GetSellerCoinAmount(),
ServiceRegionIDs: append([]int64(nil), config.GetServiceRegionIds()...),
ServiceCountryCodes: append([]string(nil), config.GetServiceCountryCodes()...), ServiceCountryCodes: append([]string(nil), config.GetServiceCountryCodes()...),
}) })
} }

View File

@ -290,6 +290,7 @@ CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母', point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母',
seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子', seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子',
service_country_codes JSON NOT NULL COMMENT '服务国家码数组;空数组表示不限国家', service_country_codes JSON NOT NULL COMMENT '服务国家码数组;空数组表示不限国家',
service_region_ids JSON NULL COMMENT '服务区域 ID 数组;与国家数组互斥,两个数组都为空表示全量',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,

View File

@ -0,0 +1,16 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
SET time_zone = '+00:00';
USE hyapp_wallet;
-- 配置表按 (app_code, seller_user_id) 唯一且行数很小MySQL 8 的 INSTANT 加 nullable JSON 列不复制表、
-- 不扫描账务流水,也不回填历史行。旧行 NULL 与新写入 [] 都表示未限制区域,并继续读取既有国家白名单。
SET @ddl := IF(
(SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'point_withdrawal_coin_seller_configs' AND COLUMN_NAME = 'service_region_ids') = 0,
'ALTER TABLE point_withdrawal_coin_seller_configs ADD COLUMN service_region_ids JSON NULL COMMENT ''服务区域 ID 数组;与国家数组互斥,两个数组都为空表示全量'' AFTER service_country_codes, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE stmt FROM @ddl;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;

View File

@ -2,11 +2,13 @@ package ledger
// PointWithdrawalCoinSellerConfig 是 POINT 提现可选币商及其服务范围、兑换比例的运行快照。 // PointWithdrawalCoinSellerConfig 是 POINT 提现可选币商及其服务范围、兑换比例的运行快照。
type PointWithdrawalCoinSellerConfig struct { type PointWithdrawalCoinSellerConfig struct {
AppCode string AppCode string
SellerUserID int64 SellerUserID int64
SortOrder int SortOrder int
PointAmount int64 PointAmount int64
SellerCoinAmount int64 SellerCoinAmount int64
// ServiceRegionIDs 与 ServiceCountryCodes 互斥;都为空表示服务当前 App 的全部用户。
ServiceRegionIDs []int64
ServiceCountryCodes []string ServiceCountryCodes []string
Status string Status string
CreatedAtMS int64 CreatedAtMS int64

View File

@ -10,16 +10,16 @@ import (
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
) )
// ListPointWithdrawalCoinSellers 返回服务端过滤后的币商白名单;国家由可信用户资料提供而不是客户端表单 // ListPointWithdrawalCoinSellers 返回服务端过滤后的币商白名单;区域和国家都来自可信用户资料,客户端不能自报服务范围
func (s *Service) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) { func (s *Service) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, regionID int64, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) {
if strings.TrimSpace(appCode) == "" { if strings.TrimSpace(appCode) == "" || regionID < 0 {
return nil, xerr.New(xerr.InvalidArgument, "app_code is required") return nil, xerr.New(xerr.InvalidArgument, "app_code is required")
} }
if s.repository == nil { if s.repository == nil {
return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured")
} }
appCode = appcode.Normalize(appCode) appCode = appcode.Normalize(appCode)
return s.repository.ListPointWithdrawalCoinSellers(appcode.WithContext(ctx, appCode), appCode, strings.ToUpper(strings.TrimSpace(countryCode)), includeDisabled) return s.repository.ListPointWithdrawalCoinSellers(appcode.WithContext(ctx, appCode), appCode, regionID, strings.ToUpper(strings.TrimSpace(countryCode)), includeDisabled)
} }
// TransferPointToCoinSeller 验证客户端允许提交的最小字段;兑换比例由 repository 事务内锁定配置解析。 // TransferPointToCoinSeller 验证客户端允许提交的最小字段;兑换比例由 repository 事务内锁定配置解析。

View File

@ -69,7 +69,7 @@ type CoinSellerStore interface {
ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error)
ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, error)
TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error) TransferSalaryToCoinSeller(ctx context.Context, command ledger.SalaryTransferToCoinSellerCommand) (ledger.SalaryTransferToCoinSellerReceipt, error)
ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, regionID int64, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error)
TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error) TransferPointToCoinSeller(ctx context.Context, command ledger.PointToCoinSellerCommand) (ledger.PointToCoinSellerReceipt, error)
ExchangePointToCoin(ctx context.Context, command ledger.PointToCoinCommand) (ledger.PointToCoinReceipt, error) ExchangePointToCoin(ctx context.Context, command ledger.PointToCoinCommand) (ledger.PointToCoinReceipt, error)
GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error)

View File

@ -3041,20 +3041,27 @@ func TestSalaryWithdrawalTerminalSerializesConcurrentOppositeDecisions(t *testin
} }
} }
// TestTransferPointToCoinSellerUsesServerWhitelist 验证 H5 不能自报比例wallet 会按 App、国家和 active 配置原子扣 POINT/加币商库存。 // TestTransferPointToCoinSellerUsesServerWhitelist 验证 H5 不能自报比例wallet 会按互斥的区域/国家范围和 active 配置原子扣 POINT/加币商库存。
func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) { func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
repository.SetAssetBalanceForApp("fami", 22501, ledger.AssetPoint, 2_000_000) repository.SetAssetBalanceForApp("fami", 22501, ledger.AssetPoint, 2_000_000)
repository.SetPointWithdrawalCoinSellerConfig("fami", 22502, 100_000, 92_000, []string{"SA"}, "active", 1) repository.SetPointWithdrawalCoinSellerConfig("fami", 22502, 100_000, 92_000, []int64{26}, nil, "active", 1)
repository.SetPointWithdrawalCoinSellerConfig("fami", 22503, 100_000, 92_000, nil, []string{"SA"}, "active", 2)
seedPointWithdrawalSalaryPolicy(repository, "fami", 225, 26, "month", 1, "month", 0) seedPointWithdrawalSalaryPolicy(repository, "fami", 225, 26, "month", 1, "month", 0)
svc := walletservice.New(repository) svc := walletservice.New(repository)
sellers, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", "SA", false) sellers, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 26, "SA", false)
if err != nil || len(sellers) != 1 || sellers[0].SellerUserID != 22502 { if err != nil || len(sellers) != 2 || sellers[0].SellerUserID != 22502 || sellers[1].SellerUserID != 22503 {
t.Fatalf("eligible point withdrawal sellers mismatch: sellers=%+v err=%v", sellers, err) t.Fatalf("eligible point withdrawal sellers mismatch: sellers=%+v err=%v", sellers, err)
} }
if blocked, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", "TR", false); err != nil || len(blocked) != 0 { if regional, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 26, "TR", false); err != nil || len(regional) != 1 || regional[0].SellerUserID != 22502 {
t.Fatalf("country-filtered seller must be hidden: sellers=%+v err=%v", blocked, err) t.Fatalf("region-scoped seller mismatch: sellers=%+v err=%v", regional, err)
}
if national, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 27, "SA", false); err != nil || len(national) != 1 || national[0].SellerUserID != 22503 {
t.Fatalf("country-scoped seller mismatch: sellers=%+v err=%v", national, err)
}
if blocked, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 27, "TR", false); err != nil || len(blocked) != 0 {
t.Fatalf("out-of-scope sellers must be hidden: sellers=%+v err=%v", blocked, err)
} }
command := ledger.PointToCoinSellerCommand{ command := ledger.PointToCoinSellerCommand{
@ -3094,7 +3101,7 @@ func TestTransferPointDiamondToCoinSellerUsesGrossUSDAndIsolatesLimitByAsset(t *
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPoint, 100_000) repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPoint, 100_000)
repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPointDiamond, 30_003_000) repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPointDiamond, 30_003_000)
repository.SetPointWithdrawalCoinSellerConfig("fami", 22522, 100_000, 92_000, []string{"SA"}, "active", 1) repository.SetPointWithdrawalCoinSellerConfig("fami", 22522, 100_000, 92_000, nil, []string{"SA"}, "active", 1)
repository.SeedPointWalletPolicy("fami", "legacy-point-for-limit-isolation", 27, "active", 100_000, 1, 0) repository.SeedPointWalletPolicy("fami", "legacy-point-for-limit-isolation", 27, "active", 100_000, 1, 0)
repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{ repository.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{
PolicyID: 226, PolicyVersion: 226, Name: "point diamond seller policy", PolicyType: ledger.HostPolicyTypePointDiamond, PolicyID: 226, PolicyVersion: 226, Name: "point diamond seller policy", PolicyType: ledger.HostPolicyTypePointDiamond,

View File

@ -38,8 +38,8 @@ type pointToCoinSellerMetadata struct {
CreatedAtMS int64 `json:"created_at_ms"` CreatedAtMS int64 `json:"created_at_ms"`
} }
// ListPointWithdrawalCoinSellers 返回当前 App 的白名单;国家过滤在 wallet 内执行gateway 不能绕过服务范围。 // ListPointWithdrawalCoinSellers 返回当前 App 的白名单;区域/国家过滤在 wallet 内执行gateway 不能绕过服务范围。
func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) { func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, regionID int64, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
} }
@ -51,7 +51,8 @@ func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode
} }
rows, err := r.db.QueryContext(ctx, ` rows, err := r.db.QueryContext(ctx, `
SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
service_country_codes, status, created_at_ms, updated_at_ms COALESCE(service_region_ids, JSON_ARRAY()), service_country_codes,
status, created_at_ms, updated_at_ms
FROM point_withdrawal_coin_seller_configs FROM point_withdrawal_coin_seller_configs
WHERE `+where+` WHERE `+where+`
ORDER BY sort_order ASC, seller_user_id ASC`, args...) ORDER BY sort_order ASC, seller_user_id ASC`, args...)
@ -64,19 +65,31 @@ func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode
items := make([]ledger.PointWithdrawalCoinSellerConfig, 0) items := make([]ledger.PointWithdrawalCoinSellerConfig, 0)
for rows.Next() { for rows.Next() {
var item ledger.PointWithdrawalCoinSellerConfig var item ledger.PointWithdrawalCoinSellerConfig
var regionsJSON []byte
var countriesJSON []byte var countriesJSON []byte
if err := rows.Scan(&item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { if err := rows.Scan(&item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &regionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
return nil, err return nil, err
} }
if err := json.Unmarshal(regionsJSON, &item.ServiceRegionIDs); err != nil {
return nil, xerr.New(xerr.Internal, "point withdrawal seller region config is invalid")
}
if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil { if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil {
return nil, xerr.New(xerr.Internal, "point withdrawal seller country config is invalid") return nil, xerr.New(xerr.Internal, "point withdrawal seller country config is invalid")
} }
item.ServiceRegionIDs = normalizePointWithdrawalRegionIDs(item.ServiceRegionIDs)
item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes) item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes)
if item.PointAmount <= 0 || item.SellerCoinAmount <= 0 { if item.PointAmount <= 0 || item.SellerCoinAmount <= 0 {
return nil, xerr.New(xerr.Internal, "point withdrawal seller ratio is invalid") return nil, xerr.New(xerr.Internal, "point withdrawal seller ratio is invalid")
} }
if countryCode != "" && !countryAllowed(item.ServiceCountryCodes, countryCode) { // region=0 且 country 为空是 Admin/内部全量读取;用户入口会携带资料中的真实区域和国家。
continue if regionID > 0 || countryCode != "" {
allowed, scopeErr := serviceScopeAllowed(item.ServiceRegionIDs, item.ServiceCountryCodes, regionID, countryCode)
if scopeErr != nil {
return nil, scopeErr
}
if !allowed {
continue
}
} }
items = append(items, item) items = append(items, item)
} }
@ -163,8 +176,12 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg
return ledger.PointToCoinSellerReceipt{}, err return ledger.PointToCoinSellerReceipt{}, err
} }
countryCode := strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)) countryCode := strings.ToUpper(strings.TrimSpace(command.SourceCountryCode))
if !countryAllowed(config.ServiceCountryCodes, countryCode) { allowed, err := serviceScopeAllowed(config.ServiceRegionIDs, config.ServiceCountryCodes, command.RegionID, countryCode)
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "coin seller does not serve user country") if err != nil {
return ledger.PointToCoinSellerReceipt{}, err
}
if !allowed {
return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "coin seller does not serve user region or country")
} }
if sourceAssetType == ledger.AssetPoint { if sourceAssetType == ledger.AssetPoint {
numerator, err := checkedMul(pointAmount, config.SellerCoinAmount) numerator, err := checkedMul(pointAmount, config.SellerCoinAmount)
@ -358,15 +375,17 @@ func (r *Repository) getPointWithdrawalConfig(ctx context.Context, appCode strin
func (r *Repository) lockPointWithdrawalCoinSellerConfig(ctx context.Context, tx *sql.Tx, sellerUserID int64) (ledger.PointWithdrawalCoinSellerConfig, error) { func (r *Repository) lockPointWithdrawalCoinSellerConfig(ctx context.Context, tx *sql.Tx, sellerUserID int64) (ledger.PointWithdrawalCoinSellerConfig, error) {
var item ledger.PointWithdrawalCoinSellerConfig var item ledger.PointWithdrawalCoinSellerConfig
var regionsJSON []byte
var countriesJSON []byte var countriesJSON []byte
err := tx.QueryRowContext(ctx, ` err := tx.QueryRowContext(ctx, `
SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, SELECT app_code, seller_user_id, sort_order, point_amount, seller_coin_amount,
service_country_codes, status, created_at_ms, updated_at_ms COALESCE(service_region_ids, JSON_ARRAY()), service_country_codes,
status, created_at_ms, updated_at_ms
FROM point_withdrawal_coin_seller_configs FROM point_withdrawal_coin_seller_configs
WHERE app_code = ? AND seller_user_id = ? AND status = 'active' WHERE app_code = ? AND seller_user_id = ? AND status = 'active'
FOR UPDATE`, appcode.FromContext(ctx), sellerUserID).Scan( FOR UPDATE`, appcode.FromContext(ctx), sellerUserID).Scan(
&item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, &item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount,
&countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS, &regionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS,
) )
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return item, xerr.New(xerr.NotFound, "point withdrawal coin seller is not available") return item, xerr.New(xerr.NotFound, "point withdrawal coin seller is not available")
@ -374,10 +393,17 @@ func (r *Repository) lockPointWithdrawalCoinSellerConfig(ctx context.Context, tx
if err != nil { if err != nil {
return item, err return item, err
} }
if err := json.Unmarshal(regionsJSON, &item.ServiceRegionIDs); err != nil {
return item, xerr.New(xerr.Internal, "point withdrawal coin seller region config is invalid")
}
if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil || item.PointAmount <= 0 || item.SellerCoinAmount <= 0 { if err := json.Unmarshal(countriesJSON, &item.ServiceCountryCodes); err != nil || item.PointAmount <= 0 || item.SellerCoinAmount <= 0 {
return item, xerr.New(xerr.Internal, "point withdrawal coin seller config is invalid") return item, xerr.New(xerr.Internal, "point withdrawal coin seller config is invalid")
} }
item.ServiceRegionIDs = normalizePointWithdrawalRegionIDs(item.ServiceRegionIDs)
item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes) item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes)
if len(item.ServiceRegionIDs) > 0 && len(item.ServiceCountryCodes) > 0 {
return item, xerr.New(xerr.Internal, "point withdrawal coin seller service scopes conflict")
}
return item, nil return item, nil
} }
@ -421,18 +447,47 @@ func normalizeCountryCodes(values []string) []string {
return out return out
} }
func countryAllowed(configured []string, countryCode string) bool { func normalizePointWithdrawalRegionIDs(values []int64) []int64 {
if len(configured) == 0 { seen := make(map[int64]struct{}, len(values))
return true out := make([]int64, 0, len(values))
for _, value := range values {
if value <= 0 {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
// serviceScopeAllowed 保持三态边界:区域白名单、国家白名单、两个都为空的全量范围。
// 同时存在两种范围属于损坏配置,必须失败而不是猜测 OR/AND 语义后放宽资金入口。
func serviceScopeAllowed(regionIDs []int64, countryCodes []string, regionID int64, countryCode string) (bool, error) {
if len(regionIDs) > 0 && len(countryCodes) > 0 {
return false, xerr.New(xerr.Internal, "point withdrawal coin seller service scopes conflict")
}
if len(regionIDs) > 0 {
for _, configuredID := range regionIDs {
if configuredID == regionID {
return true, nil
}
}
return false, nil
}
if len(countryCodes) == 0 {
return true, nil
} }
countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) countryCode = strings.ToUpper(strings.TrimSpace(countryCode))
if countryCode == "" { if countryCode == "" {
return false return false, nil
} }
for _, configuredCode := range configured { for _, configuredCode := range countryCodes {
if strings.EqualFold(configuredCode, countryCode) { if strings.EqualFold(configuredCode, countryCode) {
return true return true, nil
} }
} }
return false return false, nil
} }

View File

@ -562,6 +562,7 @@ func ensurePointWithdrawalCoinSellerSchema(ctx context.Context, db *sql.DB) erro
point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母', point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母',
seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子', seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子',
service_country_codes JSON NOT NULL COMMENT '服务国家码数组空数组表示不限国家', service_country_codes JSON NOT NULL COMMENT '服务国家码数组空数组表示不限国家',
service_region_ids JSON NULL COMMENT '服务区域 ID 数组与国家数组互斥两个数组都为空表示全量',
status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled', status VARCHAR(24) NOT NULL DEFAULT 'active' COMMENT 'active/disabled',
created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
@ -570,7 +571,17 @@ func ensurePointWithdrawalCoinSellerSchema(ctx context.Context, db *sql.DB) erro
PRIMARY KEY (app_code, seller_user_id), PRIMARY KEY (app_code, seller_user_id),
KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id) KEY idx_point_withdraw_seller_list (app_code, status, sort_order, seller_user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='POINT 提现币商白名单与兑换比例'`)
return err if err != nil {
return err
}
// 配置表按 App/币商唯一数据量很小INSTANT 补 nullable JSON 列不复制表NULL 与空数组都解释为未限制区域。
if _, err := db.ExecContext(ctx, `
ALTER TABLE point_withdrawal_coin_seller_configs
ADD COLUMN service_region_ids JSON NULL COMMENT '服务区域 ID 数组与国家数组互斥两个数组都为空表示全量'
AFTER service_country_codes, ALGORITHM=INSTANT`); err != nil && !isDuplicateColumnError(err) {
return err
}
return nil
} }
func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error { func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error {

View File

@ -17,8 +17,12 @@ import (
) )
// SetPointWithdrawalCoinSellerConfig seeds one app-scoped whitelist row for real ledger tests. // SetPointWithdrawalCoinSellerConfig seeds one app-scoped whitelist row for real ledger tests.
func (r *Repository) SetPointWithdrawalCoinSellerConfig(appCode string, sellerUserID int64, pointAmount int64, sellerCoinAmount int64, countryCodes []string, status string, sortOrder int) { func (r *Repository) SetPointWithdrawalCoinSellerConfig(appCode string, sellerUserID int64, pointAmount int64, sellerCoinAmount int64, regionIDs []int64, countryCodes []string, status string, sortOrder int) {
r.t.Helper() r.t.Helper()
regionsJSON, err := json.Marshal(regionIDs)
if err != nil {
r.t.Fatalf("marshal point withdrawal regions failed: %v", err)
}
countriesJSON, err := json.Marshal(countryCodes) countriesJSON, err := json.Marshal(countryCodes)
if err != nil { if err != nil {
r.t.Fatalf("marshal point withdrawal countries failed: %v", err) r.t.Fatalf("marshal point withdrawal countries failed: %v", err)
@ -29,13 +33,14 @@ func (r *Repository) SetPointWithdrawalCoinSellerConfig(appCode string, sellerUs
nowMS := time.Now().UnixMilli() nowMS := time.Now().UnixMilli()
if _, err := r.schema.DB.ExecContext(context.Background(), ` if _, err := r.schema.DB.ExecContext(context.Background(), `
INSERT INTO point_withdrawal_coin_seller_configs ( INSERT INTO point_withdrawal_coin_seller_configs (
app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes, app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_region_ids, service_country_codes,
status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
sort_order = VALUES(sort_order), point_amount = VALUES(point_amount), seller_coin_amount = VALUES(seller_coin_amount), sort_order = VALUES(sort_order), point_amount = VALUES(point_amount), seller_coin_amount = VALUES(seller_coin_amount),
service_country_codes = VALUES(service_country_codes), status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, service_region_ids = VALUES(service_region_ids), service_country_codes = VALUES(service_country_codes),
appCode, sellerUserID, sortOrder, pointAmount, sellerCoinAmount, countriesJSON, status, nowMS, nowMS, status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`,
appCode, sellerUserID, sortOrder, pointAmount, sellerCoinAmount, regionsJSON, countriesJSON, status, nowMS, nowMS,
); err != nil { ); err != nil {
r.t.Fatalf("seed point withdrawal coin seller config failed: %v", err) r.t.Fatalf("seed point withdrawal coin seller config failed: %v", err)
} }

View File

@ -8,9 +8,9 @@ import (
"hyapp/services/wallet-service/internal/domain/ledger" "hyapp/services/wallet-service/internal/domain/ledger"
) )
// ListPointWithdrawalCoinSellers 返回 wallet 侧已执行 app/status/国家过滤的可信白名单。 // ListPointWithdrawalCoinSellers 返回 wallet 侧已执行 app/status/区域或国家过滤的可信白名单。
func (s *Server) ListPointWithdrawalCoinSellers(ctx context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) { func (s *Server) ListPointWithdrawalCoinSellers(ctx context.Context, req *walletv1.ListPointWithdrawalCoinSellersRequest) (*walletv1.ListPointWithdrawalCoinSellersResponse, error) {
items, err := s.svc.ListPointWithdrawalCoinSellers(ctx, req.GetAppCode(), req.GetCountryCode(), req.GetIncludeDisabled()) items, err := s.svc.ListPointWithdrawalCoinSellers(ctx, req.GetAppCode(), req.GetRegionId(), req.GetCountryCode(), req.GetIncludeDisabled())
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)
} }
@ -19,6 +19,7 @@ func (s *Server) ListPointWithdrawalCoinSellers(ctx context.Context, req *wallet
response.Sellers = append(response.Sellers, &walletv1.PointWithdrawalCoinSellerConfig{ response.Sellers = append(response.Sellers, &walletv1.PointWithdrawalCoinSellerConfig{
AppCode: item.AppCode, SellerUserId: item.SellerUserID, SortOrder: int32(item.SortOrder), AppCode: item.AppCode, SellerUserId: item.SellerUserID, SortOrder: int32(item.SortOrder),
PointAmount: item.PointAmount, SellerCoinAmount: item.SellerCoinAmount, PointAmount: item.PointAmount, SellerCoinAmount: item.SellerCoinAmount,
ServiceRegionIds: append([]int64(nil), item.ServiceRegionIDs...),
ServiceCountryCodes: append([]string(nil), item.ServiceCountryCodes...), Status: item.Status, ServiceCountryCodes: append([]string(nil), item.ServiceCountryCodes...), Status: item.Status,
CreatedAtMs: item.CreatedAtMS, UpdatedAtMs: item.UpdatedAtMS, CreatedAtMs: item.CreatedAtMS, UpdatedAtMs: item.UpdatedAtMS,
}) })

View File

@ -0,0 +1,32 @@
GET https://api.global-interaction.com/api/v1/vip/packages
Authorization: Bearer [REDACTED]
X-App-Code: fami
X-App-Package: com.hdmoviesallhd.watchmovies
Request Attempts:
Attempt 1: HTTP 409, request_id=req_1784715578056_62f2422ed4dad60f
Attempt 2: HTTP 409, request_id=req_1784715579172_ba7b2da0ad9c41f4
Attempt 3: HTTP 409, request_id=req_1784715580402_6f286436945aca97
Attempt 4: HTTP 409, request_id=req_1784715581644_0c73d0d363d4cc06
Attempt 5: HTTP 409, request_id=req_1784715582838_edb500e9fa2c5505
Selected Response: attempt 5
HTTP Status: 409
Response Headers:
HTTP/1.1 200 Connection established
HTTP/2 409
date: Wed, 22 Jul 2026 10:19:42 GMT
content-type: application/json
content-length: 136
x-request-id: req_1784715582838_edb500e9fa2c5505
Response Body:
{
"code": "LEDGER_CONFLICT",
"message": "wallet state changed, please retry",
"request_id": "req_1784715582838_edb500e9fa2c5505",
"data": null
}