From 8dda287d0ad9d5646f9c8a66d31dd37f314b2d11 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 22 Jul 2026 18:36:49 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B8=81=E5=95=86=E6=8F=90=E7=8E=B0=E7=9B=B8?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/proto/activity/v1/activity.pb.go | 58 +++++--- api/proto/activity/v1/activity.proto | 4 + api/proto/wallet/v1/wallet.pb.go | 48 +++++-- api/proto/wallet/v1/wallet.proto | 5 +- server/admin/cmd/server/main.go | 4 +- .../modules/pointwithdrawalconfig/dto.go | 2 + .../modules/pointwithdrawalconfig/service.go | 124 +++++++++++++++++- .../internal/modules/resource/grant_notice.go | 122 +++++++++++++++++ .../internal/modules/resource/handler.go | 83 +++++++++++- .../internal/modules/resource/request.go | 13 ++ .../admin/internal/modules/resource/routes.go | 1 + .../modules/vipconfig/grant_notice.go | 50 +++++++ .../internal/modules/vipconfig/handler.go | 22 +++- .../internal/domain/message/message.go | 38 +++--- .../internal/service/message/notice.go | 2 + .../internal/service/message/service.go | 79 +++++++++++ .../internal/transport/grpc/message_server.go | 7 + .../http/messageapi/message_handler.go | 22 ++-- .../walletapi/point_diamond_wallet_handler.go | 4 +- .../http/walletapi/point_wallet_handler.go | 4 +- .../mysql/initdb/001_wallet_service.sql | 1 + ...withdrawal_coin_seller_service_regions.sql | 16 +++ .../domain/ledger/point_coin_seller.go | 12 +- .../service/wallet/point_coin_seller.go | 8 +- .../service/wallet/ports/repository.go | 2 +- .../internal/service/wallet/service_test.go | 21 ++- .../storage/mysql/point_coin_seller.go | 89 ++++++++++--- .../internal/storage/mysql/schema.go | 13 +- .../internal/testutil/mysqltest/mysqltest.go | 15 ++- .../transport/grpc/point_coin_seller.go | 5 +- vip_packages_response_20260722.txt | 32 +++++ 31 files changed, 788 insertions(+), 118 deletions(-) create mode 100644 server/admin/internal/modules/resource/grant_notice.go create mode 100644 server/admin/internal/modules/vipconfig/grant_notice.go create mode 100644 services/wallet-service/deploy/mysql/migrations/020_point_withdrawal_coin_seller_service_regions.sql create mode 100644 vip_packages_response_20260722.txt diff --git a/api/proto/activity/v1/activity.pb.go b/api/proto/activity/v1/activity.pb.go index 173ef6cf..af9e0268 100644 --- a/api/proto/activity/v1/activity.pb.go +++ b/api/proto/activity/v1/activity.pb.go @@ -472,18 +472,20 @@ func (x *ListMessageTabsResponse) GetSections() []*MessageTabSection { // InboxMessage 是 system/activity 分区列表可展示的消息快照。 type InboxMessage struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` - Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,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"` - 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"` - 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"` - 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"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,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"` + 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"` + 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"` + 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"` + // image_urls 是同一通知卡片的有序图片列表;image_url 保留给旧客户端作为首图兼容字段。 + ImageUrls []string `protobuf:"bytes,12,rep,name=image_urls,json=imageUrls,proto3" json:"image_urls,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -595,6 +597,13 @@ func (x *InboxMessage) GetSentAtMs() int64 { return 0 } +func (x *InboxMessage) GetImageUrls() []string { + if x != nil { + return x.ImageUrls + } + return nil +} + // ListInboxMessagesRequest 按 section 和游标分页读取当前用户 inbox。 type ListInboxMessagesRequest struct { 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"` 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"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // image_urls 由 inbox owner 结构化保存,生产者不能要求客户端解析 metadata_json。 + 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() { @@ -1288,6 +1299,13 @@ func (x *CreateInboxMessageRequest) GetMetadataJson() string { return "" } +func (x *CreateInboxMessageRequest) GetImageUrls() []string { + if x != nil { + return x.ImageUrls + } + return nil +} + // CreateInboxMessageResponse 返回入箱结果;重复同一 producer_event_id 时 created=false。 type CreateInboxMessageResponse struct { 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" + "\auser_id\x18\x02 \x01(\x03R\x06userId\"[\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" + "\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" + " \x01(\bR\x04read\x12\x1c\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" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12\x17\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" + "\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" + "\x04meta\x18\x01 \x01(\v2\x1e.hyapp.activity.v1.RequestMetaR\x04meta\x12$\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" + "\fexpire_at_ms\x18\x14 \x01(\x03R\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" + "\n" + "message_id\x18\x01 \x01(\tR\tmessageId\x12\x18\n" + diff --git a/api/proto/activity/v1/activity.proto b/api/proto/activity/v1/activity.proto index e55bc645..9d2ada38 100644 --- a/api/proto/activity/v1/activity.proto +++ b/api/proto/activity/v1/activity.proto @@ -70,6 +70,8 @@ message InboxMessage { string action_param = 9; bool read = 10; int64 sent_at_ms = 11; + // image_urls 是同一通知卡片的有序图片列表;image_url 保留给旧客户端作为首图兼容字段。 + repeated string image_urls = 12; } // ListInboxMessagesRequest 按 section 和游标分页读取当前用户 inbox。 @@ -151,6 +153,8 @@ message CreateInboxMessageRequest { int64 sent_at_ms = 19; int64 expire_at_ms = 20; string metadata_json = 21; + // image_urls 由 inbox owner 结构化保存,生产者不能要求客户端解析 metadata_json。 + repeated string image_urls = 22; } // CreateInboxMessageResponse 返回入箱结果;重复同一 producer_event_id 时 created=false。 diff --git a/api/proto/wallet/v1/wallet.pb.go b/api/proto/wallet/v1/wallet.pb.go index f3992145..07826757 100644 --- a/api/proto/wallet/v1/wallet.pb.go +++ b/api/proto/wallet/v1/wallet.pb.go @@ -25985,16 +25985,18 @@ func (x *AgencyHostRevenueStats) GetStats() *HostRevenueStats { // PointWithdrawalCoinSellerConfig 是按 App 隔离的 POINT 提现币商白名单与兑换比例。 // 比例用整数分子/分母保存,避免浮点金额在 H5、gateway 和账本间产生舍入分歧。 type PointWithdrawalCoinSellerConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - 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"` - 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"` - 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"` - Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,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"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + 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"` + SellerCoinAmount int64 `protobuf:"varint,5,opt,name=seller_coin_amount,json=sellerCoinAmount,proto3" json:"seller_coin_amount,omitempty"` + // service_country_codes 与 service_region_ids 互斥;两个数组都为空表示服务全部区域和国家。 + ServiceCountryCodes []string `protobuf:"bytes,6,rep,name=service_country_codes,json=serviceCountryCodes,proto3" json:"service_country_codes,omitempty"` + Status string `protobuf:"bytes,7,opt,name=status,proto3" json:"status,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 sizeCache protoimpl.SizeCache } @@ -26092,13 +26094,21 @@ func (x *PointWithdrawalCoinSellerConfig) GetUpdatedAtMs() int64 { return 0 } +func (x *PointWithdrawalCoinSellerConfig) GetServiceRegionIds() []int64 { + if x != nil { + return x.ServiceRegionIds + } + return nil +} + type ListPointWithdrawalCoinSellersRequest struct { state protoimpl.MessageState `protogen:"open.v1"` 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"` - // 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"` 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 sizeCache protoimpl.SizeCache } @@ -26161,6 +26171,13 @@ func (x *ListPointWithdrawalCoinSellersRequest) GetIncludeDisabled() bool { return false } +func (x *ListPointWithdrawalCoinSellersRequest) GetRegionId() int64 { + if x != nil { + return x.RegionId + } + return 0 +} + type ListPointWithdrawalCoinSellersResponse struct { state protoimpl.MessageState `protogen:"open.v1"` 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" + "\fhost_user_id\x18\x01 \x01(\x03R\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" + "\bapp_code\x18\x01 \x01(\tR\aappCode\x12$\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" + "\x06status\x18\a \x01(\tR\x06status\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" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" + "\bapp_code\x18\x02 \x01(\tR\aappCode\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" + "\asellers\x18\x01 \x03(\v20.hyapp.wallet.v1.PointWithdrawalCoinSellerConfigR\asellers\"\x84\x03\n" + " TransferPointToCoinSellerRequest\x12\x1d\n" + diff --git a/api/proto/wallet/v1/wallet.proto b/api/proto/wallet/v1/wallet.proto index cfd926cc..93cb4bfc 100644 --- a/api/proto/wallet/v1/wallet.proto +++ b/api/proto/wallet/v1/wallet.proto @@ -3011,18 +3011,21 @@ message PointWithdrawalCoinSellerConfig { int32 sort_order = 3; int64 point_amount = 4; int64 seller_coin_amount = 5; + // service_country_codes 与 service_region_ids 互斥;两个数组都为空表示服务全部区域和国家。 repeated string service_country_codes = 6; string status = 7; int64 created_at_ms = 8; int64 updated_at_ms = 9; + repeated int64 service_region_ids = 10; } message ListPointWithdrawalCoinSellersRequest { string request_id = 1; string app_code = 2; - // country_code 由 gateway 从登录用户资料注入;为空只用于 Admin/内部查询。 + // country_code 和 region_id 都由 gateway 从登录用户资料注入;空值只用于 Admin/内部查询。 string country_code = 3; bool include_disabled = 4; + int64 region_id = 5; } message ListPointWithdrawalCoinSellersResponse { diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 3b429419..4fe30530 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -450,7 +450,7 @@ func main() { Report: reportmodule.New(userDB, roomClient), RegistrationReward: registrationrewardmodule.New(activityclient.NewGRPC(activityConn), userDB, 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), RoomAdmin: roomadminmodule.New(userDB, store, roomClient, robotClient, auditHandler), RoomRocket: roomrocketmodule.New(roomClient, auditHandler), @@ -462,7 +462,7 @@ func main() { TeamSalarySettlement: teamSalarySettlementHandler, Upload: uploadmodule.New(objectUploader, cfg.TencentCOS.ObjectPrefix, auditHandler), 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), Wheel: wheelmodule.New(activityclient.NewGRPC(activityConn), cfg.ActivityService.RequestTimeout, auditHandler), } diff --git a/server/admin/internal/modules/pointwithdrawalconfig/dto.go b/server/admin/internal/modules/pointwithdrawalconfig/dto.go index e2aa327e..dd7878ff 100644 --- a/server/admin/internal/modules/pointwithdrawalconfig/dto.go +++ b/server/admin/internal/modules/pointwithdrawalconfig/dto.go @@ -10,6 +10,7 @@ type itemDTO struct { SortOrder int `json:"sortOrder"` PointAmount int64 `json:"pointAmount"` SellerCoinAmount int64 `json:"sellerCoinAmount"` + ServiceRegionIDs []int64 `json:"serviceRegionIds"` ServiceCountryCodes []string `json:"serviceCountryCodes"` Status string `json:"status"` CreatedAtMS int64 `json:"createdAtMs"` @@ -21,6 +22,7 @@ type upsertRequest struct { SortOrder int `json:"sortOrder"` PointAmount int64 `json:"pointAmount" binding:"required"` SellerCoinAmount int64 `json:"sellerCoinAmount" binding:"required"` + ServiceRegionIDs []int64 `json:"serviceRegionIds"` ServiceCountryCodes []string `json:"serviceCountryCodes"` Status string `json:"status"` } diff --git a/server/admin/internal/modules/pointwithdrawalconfig/service.go b/server/admin/internal/modules/pointwithdrawalconfig/service.go index 2a0568a8..fcc89099 100644 --- a/server/admin/internal/modules/pointwithdrawalconfig/service.go +++ b/server/admin/internal/modules/pointwithdrawalconfig/service.go @@ -28,7 +28,8 @@ func (s *Service) List(ctx context.Context, appCode string) ([]itemDTO, error) { } rows, err := s.walletDB.QueryContext(ctx, ` 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 WHERE app_code = ? 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() { var item itemDTO var sellerUserID int64 + var regionsJSON []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, ®ionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { return nil, err } 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 { return nil, fmt.Errorf("parse service countries for seller %d: %w", sellerUserID, err) } + item.ServiceRegionIDs = normalizeRegionIDs(item.ServiceRegionIDs) item.ServiceCountryCodes = normalizeCountries(item.ServiceCountryCodes) displayUserID, nickname, avatar, countryName, lookupErr := s.lookupSeller(ctx, item.AppCode, sellerUserID, false) 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 { 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) + 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) if err != nil { return itemDTO{}, err @@ -86,14 +116,16 @@ func (s *Service) Upsert(ctx context.Context, appCode string, req upsertRequest, nowMS := time.Now().UTC().UnixMilli() _, err = s.walletDB.ExecContext(ctx, ` INSERT INTO point_withdrawal_coin_seller_configs ( - app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, service_country_codes, - status, created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + app_code, seller_user_id, sort_order, point_amount, seller_coin_amount, + service_country_codes, service_region_ids, status, + created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE sort_order = VALUES(sort_order), point_amount = VALUES(point_amount), 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)`, - 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) if err != nil { return itemDTO{}, err @@ -158,6 +190,50 @@ func (s *Service) lookupSeller(ctx context.Context, appCode string, sellerUserID 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 { if s == nil || s.walletDB == nil { 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 ( 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, + service_region_ids JSON NULL, 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, PRIMARY KEY (app_code, 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 提现币商白名单与兑换比例'`) + 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 } @@ -192,6 +287,23 @@ func normalizeCountries(values []string) []string { 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 { value = strings.ToLower(strings.TrimSpace(value)) if value == "" { diff --git a/server/admin/internal/modules/resource/grant_notice.go b/server/admin/internal/modules/resource/grant_notice.go new file mode 100644 index 00000000..e220ab79 --- /dev/null +++ b/server/admin/internal/modules/resource/grant_notice.go @@ -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] +} diff --git a/server/admin/internal/modules/resource/handler.go b/server/admin/internal/modules/resource/handler.go index 52dc7e44..c4f119ce 100644 --- a/server/admin/internal/modules/resource/handler.go +++ b/server/admin/internal/modules/resource/handler.go @@ -2,7 +2,9 @@ package resource import ( "context" + "crypto/sha256" "database/sql" + "encoding/hex" "errors" "fmt" "sort" @@ -11,6 +13,7 @@ import ( "time" "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" @@ -23,17 +26,22 @@ import ( type Handler struct { wallet walletclient.Client + activity activityclient.Client store *repository.Store userDB *sql.DB requestTimeout time.Duration 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 { 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) { @@ -727,9 +735,76 @@ func (h *Handler) grantResource(c *gin.Context, external bool) { } grant := grantFromProto(resp.GetGrant()) 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) } +// 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) { var req grantGroupRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -760,6 +835,10 @@ func (h *Handler) GrantResourceGroup(c *gin.Context) { } grant := grantFromProto(resp.GetGrant()) 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) } diff --git a/server/admin/internal/modules/resource/request.go b/server/admin/internal/modules/resource/request.go index eefb4287..0b350875 100644 --- a/server/admin/internal/modules/resource/request.go +++ b/server/admin/internal/modules/resource/request.go @@ -249,6 +249,19 @@ type grantResourceRequest struct { 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 { CommandID string `json:"commandId"` TargetUserID any `json:"targetUserId"` diff --git a/server/admin/internal/modules/resource/routes.go b/server/admin/internal/modules/resource/routes.go index facddfe7..99608009 100644 --- a/server/admin/internal/modules/resource/routes.go +++ b/server/admin/internal/modules/resource/routes.go @@ -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/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/: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) diff --git a/server/admin/internal/modules/vipconfig/grant_notice.go b/server/admin/internal/modules/vipconfig/grant_notice.go new file mode 100644 index 00000000..040b2894 --- /dev/null +++ b/server/admin/internal/modules/vipconfig/grant_notice.go @@ -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 +} diff --git a/server/admin/internal/modules/vipconfig/handler.go b/server/admin/internal/modules/vipconfig/handler.go index cecbfe1d..9473cad8 100644 --- a/server/admin/internal/modules/vipconfig/handler.go +++ b/server/admin/internal/modules/vipconfig/handler.go @@ -5,6 +5,7 @@ import ( "strings" "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/middleware" "hyapp-admin-server/internal/modules/shared" @@ -15,12 +16,17 @@ import ( ) type Handler struct { - wallet walletclient.Client - audit shared.OperationLogger + wallet walletclient.Client + activity activityclient.Client + audit shared.OperationLogger } -func New(wallet walletclient.Client, audit shared.OperationLogger) *Handler { - return &Handler{wallet: wallet, audit: audit} +func New(wallet walletclient.Client, audit shared.OperationLogger, activities ...activityclient.Client) *Handler { + var activity activityclient.Client + if len(activities) > 0 { + activity = activities[0] + } + return &Handler{wallet: wallet, activity: activity, audit: audit} } type configRequest struct { @@ -325,6 +331,10 @@ func (h *Handler) grantVIP(c *gin.Context, grantSource string, defaultCommandPre return } 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)) } @@ -376,6 +386,10 @@ func (h *Handler) grantVIPTrialCard(c *gin.Context, grantSource string, defaultC } // 体验卡是独立背包实例,审计目标使用 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)) + 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)) } diff --git a/services/activity-service/internal/domain/message/message.go b/services/activity-service/internal/domain/message/message.go index f3b6a0d1..801dc90c 100644 --- a/services/activity-service/internal/domain/message/message.go +++ b/services/activity-service/internal/domain/message/message.go @@ -57,17 +57,19 @@ type InboxMessage struct { Body string IconURL string ImageURL string - ActionType string - ActionParam string - Priority int32 - Status string - ReadAtMS int64 - DeletedAtMS int64 - SentAtMS int64 - ExpireAtMS int64 - MetadataJSON string - CreatedAtMS int64 - UpdatedAtMS int64 + // ImageURLs 是一张通知卡片内的有序图片列表;ImageURL 保留首图兼容旧客户端。 + ImageURLs []string + ActionType string + ActionParam string + Priority int32 + Status string + ReadAtMS int64 + DeletedAtMS int64 + SentAtMS int64 + ExpireAtMS int64 + MetadataJSON string + CreatedAtMS int64 + UpdatedAtMS int64 } // Read reports whether the user has marked the message as read. @@ -121,12 +123,14 @@ type CreateInput struct { Body string IconURL string ImageURL string - ActionType string - ActionParam string - Priority int32 - SentAtMS int64 - ExpireAtMS int64 - MetadataJSON string + // ImageURLs 会由 service 写入受控 metadata 快照,MySQL 继续使用现有 JSON 列持久化。 + ImageURLs []string + ActionType string + ActionParam string + Priority int32 + SentAtMS int64 + ExpireAtMS int64 + MetadataJSON string } // FanoutJob stores a background fanout command without executing user selection in the request path. diff --git a/services/activity-service/internal/service/message/notice.go b/services/activity-service/internal/service/message/notice.go index 590fe63c..9475fcd2 100644 --- a/services/activity-service/internal/service/message/notice.go +++ b/services/activity-service/internal/service/message/notice.go @@ -26,6 +26,7 @@ type NoticeCommand struct { Body string IconURL string ImageURL string + ImageURLs []string ActionType string ActionParam string Priority int32 @@ -104,6 +105,7 @@ func (s *Service) CreateNotice(ctx context.Context, messageType string, cmd Noti Body: cmd.Body, IconURL: cmd.IconURL, ImageURL: cmd.ImageURL, + ImageURLs: cmd.ImageURLs, ActionType: cmd.ActionType, ActionParam: cmd.ActionParam, Priority: cmd.Priority, diff --git a/services/activity-service/internal/service/message/service.go b/services/activity-service/internal/service/message/service.go index 78317591..dc06d1dd 100644 --- a/services/activity-service/internal/service/message/service.go +++ b/services/activity-service/internal/service/message/service.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "encoding/json" + "slices" "strings" "time" "unicode/utf8" @@ -143,6 +144,9 @@ func (s *Service) ListInboxMessages(ctx context.Context, userID int64, section s if err != nil { return nil, "", err } + for index := range messages { + messages[index].ImageURLs = inboxImageURLs(messages[index].MetadataJSON, messages[index].ImageURL) + } if len(messages) <= limit { return messages, "", nil } @@ -217,6 +221,7 @@ func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.Cr Body: input.Body, IconURL: input.IconURL, ImageURL: input.ImageURL, + ImageURLs: append([]string(nil), input.ImageURLs...), ActionType: input.ActionType, ActionParam: input.ActionParam, Priority: input.Priority, @@ -235,6 +240,7 @@ func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.Cr if !sameInboxPayload(existing, 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, true, nil @@ -354,6 +360,12 @@ func normalizeCreateInput(input messagedomain.CreateInput) messagedomain.CreateI input.Body = strings.TrimSpace(input.Body) input.IconURL = strings.TrimSpace(input.IconURL) 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.ActionParam = strings.TrimSpace(input.ActionParam) 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") } } + 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 == "" { 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)) { 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 } @@ -495,6 +518,8 @@ func firstPositive(values ...int64) int64 { } 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 && existing.MessageType == next.MessageType && existing.Producer == next.Producer && @@ -509,6 +534,7 @@ func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.In existing.Body == next.Body && existing.IconURL == next.IconURL && existing.ImageURL == next.ImageURL && + slices.Equal(existing.ImageURLs, next.ImageURLs) && existing.ActionType == next.ActionType && existing.ActionParam == next.ActionParam && existing.Priority == next.Priority && @@ -516,6 +542,59 @@ func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.In 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 { return existing.CommandID == next.CommandID && existing.MessageType == next.MessageType && diff --git a/services/activity-service/internal/transport/grpc/message_server.go b/services/activity-service/internal/transport/grpc/message_server.go index 21a35bb3..8e1fda95 100644 --- a/services/activity-service/internal/transport/grpc/message_server.go +++ b/services/activity-service/internal/transport/grpc/message_server.go @@ -2,6 +2,7 @@ package grpc import ( "context" + "strings" activityv1 "hyapp.local/api/proto/activity/v1" "hyapp/pkg/appcode" @@ -96,6 +97,7 @@ func (s *MessageServer) CreateInboxMessage(ctx context.Context, req *activityv1. Body: req.GetBody(), IconURL: req.GetIconUrl(), ImageURL: req.GetImageUrl(), + ImageURLs: req.GetImageUrls(), ActionType: req.GetActionType(), ActionParam: req.GetActionParam(), Priority: req.GetPriority(), @@ -127,6 +129,10 @@ func (s *MessageServer) CreateFanoutJob(ctx context.Context, req *activityv1.Cre } 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{ MessageId: message.MessageID, Section: message.MessageType, @@ -135,6 +141,7 @@ func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMess Body: message.Body, IconUrl: message.IconURL, ImageUrl: message.ImageURL, + ImageUrls: imageURLs, ActionType: message.ActionType, ActionParam: message.ActionParam, Read: message.Read(), diff --git a/services/gateway-service/internal/transport/http/messageapi/message_handler.go b/services/gateway-service/internal/transport/http/messageapi/message_handler.go index 18e11ce6..1903666e 100644 --- a/services/gateway-service/internal/transport/http/messageapi/message_handler.go +++ b/services/gateway-service/internal/transport/http/messageapi/message_handler.go @@ -19,16 +19,17 @@ type messageTabSectionData struct { } type inboxMessageData struct { - MessageID string `json:"message_id"` - Title string `json:"title"` - Summary string `json:"summary"` - Body string `json:"body"` - IconURL string `json:"icon_url"` - ImageURL string `json:"image_url"` - ActionType string `json:"action_type"` - ActionParam string `json:"action_param"` - Read bool `json:"read"` - SentAtMS int64 `json:"sent_at_ms"` + MessageID string `json:"message_id"` + Title string `json:"title"` + Summary string `json:"summary"` + Body string `json:"body"` + IconURL string `json:"icon_url"` + ImageURL string `json:"image_url"` + ImageURLs []string `json:"image_urls"` + ActionType string `json:"action_type"` + ActionParam string `json:"action_param"` + Read bool `json:"read"` + SentAtMS int64 `json:"sent_at_ms"` } type actionConfirmData struct { @@ -114,6 +115,7 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re Body: item.GetBody(), IconURL: item.GetIconUrl(), ImageURL: item.GetImageUrl(), + ImageURLs: item.GetImageUrls(), ActionType: item.GetActionType(), ActionParam: item.GetActionParam(), Read: item.GetRead(), diff --git a/services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go index 93e60a9b..a3fc6fa5 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_diamond_wallet_handler.go @@ -122,6 +122,7 @@ type pointDiamondCoinSellerData struct { Nickname string `json:"nickname"` Avatar string `json:"avatar"` SortOrder int32 `json:"sort_order"` + ServiceRegionIDs []int64 `json:"service_region_ids"` ServiceCountryCodes []string `json:"service_country_codes"` } @@ -147,7 +148,7 @@ func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, requ // 不是资金资格事实,否则页面会展示最终被 wallet 拒绝的币商。 sellerResp, err := pointWallet.ListPointWithdrawalCoinSellers(request.Context(), &walletv1.ListPointWithdrawalCoinSellersRequest{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), - CountryCode: pointWalletCountryCode(profile), + RegionId: profile.GetRegionId(), CountryCode: pointWalletCountryCode(profile), }) if err != nil { httpkit.WriteRPCError(writer, request, err) @@ -163,6 +164,7 @@ func (h *Handler) getPointDiamondWalletOverview(writer http.ResponseWriter, requ sellers = append(sellers, pointDiamondCoinSellerData{ UserID: seller.UserID, DisplayUserID: seller.DisplayUserID, Nickname: seller.Nickname, Avatar: seller.Avatar, SortOrder: seller.SortOrder, + ServiceRegionIDs: append([]int64(nil), seller.ServiceRegionIDs...), ServiceCountryCodes: append([]string(nil), seller.ServiceCountryCodes...), }) } diff --git a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go index e8b528e4..c0079704 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go @@ -56,6 +56,7 @@ type pointWalletCoinSellerData struct { SortOrder int32 `json:"sort_order"` PointAmount int64 `json:"point_amount"` SellerCoinAmount int64 `json:"seller_coin_amount"` + ServiceRegionIDs []int64 `json:"service_region_ids"` 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{ RequestId: httpkit.RequestIDFromContext(request.Context()), AppCode: appcode.FromContext(request.Context()), - CountryCode: pointWalletCountryCode(profile), + RegionId: profile.GetRegionId(), CountryCode: pointWalletCountryCode(profile), }) if err != nil { 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(), Nickname: profile.GetUsername(), Avatar: profile.GetAvatar(), SortOrder: config.GetSortOrder(), PointAmount: config.GetPointAmount(), SellerCoinAmount: config.GetSellerCoinAmount(), + ServiceRegionIDs: append([]int64(nil), config.GetServiceRegionIds()...), ServiceCountryCodes: append([]string(nil), config.GetServiceCountryCodes()...), }) } diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index 7b2168fd..1aaeb855 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -290,6 +290,7 @@ CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs ( point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母', seller_coin_amount BIGINT 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', created_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, updated_by_admin_id BIGINT UNSIGNED NOT NULL DEFAULT 0, diff --git a/services/wallet-service/deploy/mysql/migrations/020_point_withdrawal_coin_seller_service_regions.sql b/services/wallet-service/deploy/mysql/migrations/020_point_withdrawal_coin_seller_service_regions.sql new file mode 100644 index 00000000..4ab34023 --- /dev/null +++ b/services/wallet-service/deploy/mysql/migrations/020_point_withdrawal_coin_seller_service_regions.sql @@ -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; diff --git a/services/wallet-service/internal/domain/ledger/point_coin_seller.go b/services/wallet-service/internal/domain/ledger/point_coin_seller.go index 0c40fa50..6ba0655d 100644 --- a/services/wallet-service/internal/domain/ledger/point_coin_seller.go +++ b/services/wallet-service/internal/domain/ledger/point_coin_seller.go @@ -2,11 +2,13 @@ package ledger // PointWithdrawalCoinSellerConfig 是 POINT 提现可选币商及其服务范围、兑换比例的运行快照。 type PointWithdrawalCoinSellerConfig struct { - AppCode string - SellerUserID int64 - SortOrder int - PointAmount int64 - SellerCoinAmount int64 + AppCode string + SellerUserID int64 + SortOrder int + PointAmount int64 + SellerCoinAmount int64 + // ServiceRegionIDs 与 ServiceCountryCodes 互斥;都为空表示服务当前 App 的全部用户。 + ServiceRegionIDs []int64 ServiceCountryCodes []string Status string CreatedAtMS int64 diff --git a/services/wallet-service/internal/service/wallet/point_coin_seller.go b/services/wallet-service/internal/service/wallet/point_coin_seller.go index cc4badfa..442ca8d7 100644 --- a/services/wallet-service/internal/service/wallet/point_coin_seller.go +++ b/services/wallet-service/internal/service/wallet/point_coin_seller.go @@ -10,16 +10,16 @@ import ( "hyapp/services/wallet-service/internal/domain/ledger" ) -// ListPointWithdrawalCoinSellers 返回服务端过滤后的币商白名单;国家由可信用户资料提供而不是客户端表单。 -func (s *Service) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) { - if strings.TrimSpace(appCode) == "" { +// ListPointWithdrawalCoinSellers 返回服务端过滤后的币商白名单;区域和国家都来自可信用户资料,客户端不能自报服务范围。 +func (s *Service) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, regionID int64, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) { + if strings.TrimSpace(appCode) == "" || regionID < 0 { return nil, xerr.New(xerr.InvalidArgument, "app_code is required") } if s.repository == nil { return nil, xerr.New(xerr.Unavailable, "wallet repository is not configured") } 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 事务内锁定配置解析。 diff --git a/services/wallet-service/internal/service/wallet/ports/repository.go b/services/wallet-service/internal/service/wallet/ports/repository.go index f0fc1b1d..77e7b8d8 100644 --- a/services/wallet-service/internal/service/wallet/ports/repository.go +++ b/services/wallet-service/internal/service/wallet/ports/repository.go @@ -69,7 +69,7 @@ type CoinSellerStore interface { ListCoinSellerSalaryExchangeRateTiers(ctx context.Context, appCode string, regionID int64, includeDisabled bool) ([]ledger.CoinSellerSalaryExchangeRateTier, error) ExchangeSalaryToCoin(ctx context.Context, command ledger.SalaryExchangeCommand) (ledger.SalaryExchangeReceipt, 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) ExchangePointToCoin(ctx context.Context, command ledger.PointToCoinCommand) (ledger.PointToCoinReceipt, error) GetPointWithdrawalConfig(ctx context.Context, appCode string, regionID int64, nowMS int64) (ledger.PointWithdrawalRuntimeConfig, error) diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 14e320bd..92203e33 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -3041,20 +3041,27 @@ func TestSalaryWithdrawalTerminalSerializesConcurrentOppositeDecisions(t *testin } } -// TestTransferPointToCoinSellerUsesServerWhitelist 验证 H5 不能自报比例,wallet 会按 App、国家和 active 配置原子扣 POINT/加币商库存。 +// TestTransferPointToCoinSellerUsesServerWhitelist 验证 H5 不能自报比例,wallet 会按互斥的区域/国家范围和 active 配置原子扣 POINT/加币商库存。 func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) { repository := mysqltest.NewRepository(t) 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) svc := walletservice.New(repository) - sellers, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", "SA", false) - if err != nil || len(sellers) != 1 || sellers[0].SellerUserID != 22502 { + sellers, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 26, "SA", false) + 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) } - if blocked, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", "TR", false); err != nil || len(blocked) != 0 { - t.Fatalf("country-filtered seller must be hidden: sellers=%+v err=%v", blocked, err) + if regional, err := svc.ListPointWithdrawalCoinSellers(context.Background(), "fami", 26, "TR", false); err != nil || len(regional) != 1 || regional[0].SellerUserID != 22502 { + 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{ @@ -3094,7 +3101,7 @@ func TestTransferPointDiamondToCoinSellerUsesGrossUSDAndIsolatesLimitByAsset(t * repository := mysqltest.NewRepository(t) repository.SetAssetBalanceForApp("fami", 22521, ledger.AssetPoint, 100_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.SetHostSalaryPolicyForApp("fami", ledger.HostSalaryPolicy{ PolicyID: 226, PolicyVersion: 226, Name: "point diamond seller policy", PolicyType: ledger.HostPolicyTypePointDiamond, diff --git a/services/wallet-service/internal/storage/mysql/point_coin_seller.go b/services/wallet-service/internal/storage/mysql/point_coin_seller.go index a574ad67..96b91840 100644 --- a/services/wallet-service/internal/storage/mysql/point_coin_seller.go +++ b/services/wallet-service/internal/storage/mysql/point_coin_seller.go @@ -38,8 +38,8 @@ type pointToCoinSellerMetadata struct { CreatedAtMS int64 `json:"created_at_ms"` } -// ListPointWithdrawalCoinSellers 返回当前 App 的白名单;国家过滤在 wallet 内执行,gateway 不能绕过服务范围。 -func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) { +// ListPointWithdrawalCoinSellers 返回当前 App 的白名单;区域/国家过滤在 wallet 内执行,gateway 不能绕过服务范围。 +func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode string, regionID int64, countryCode string, includeDisabled bool) ([]ledger.PointWithdrawalCoinSellerConfig, error) { if r == nil || r.db == nil { 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, ` 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 WHERE `+where+` 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) for rows.Next() { var item ledger.PointWithdrawalCoinSellerConfig + var regionsJSON []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, ®ionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { 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 { return nil, xerr.New(xerr.Internal, "point withdrawal seller country config is invalid") } + item.ServiceRegionIDs = normalizePointWithdrawalRegionIDs(item.ServiceRegionIDs) item.ServiceCountryCodes = normalizeCountryCodes(item.ServiceCountryCodes) if item.PointAmount <= 0 || item.SellerCoinAmount <= 0 { return nil, xerr.New(xerr.Internal, "point withdrawal seller ratio is invalid") } - if countryCode != "" && !countryAllowed(item.ServiceCountryCodes, countryCode) { - continue + // region=0 且 country 为空是 Admin/内部全量读取;用户入口会携带资料中的真实区域和国家。 + 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) } @@ -163,8 +176,12 @@ func (r *Repository) TransferPointToCoinSeller(ctx context.Context, command ledg return ledger.PointToCoinSellerReceipt{}, err } countryCode := strings.ToUpper(strings.TrimSpace(command.SourceCountryCode)) - if !countryAllowed(config.ServiceCountryCodes, countryCode) { - return ledger.PointToCoinSellerReceipt{}, xerr.New(xerr.PermissionDenied, "coin seller does not serve user country") + allowed, err := serviceScopeAllowed(config.ServiceRegionIDs, config.ServiceCountryCodes, command.RegionID, countryCode) + 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 { 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) { var item ledger.PointWithdrawalCoinSellerConfig + var regionsJSON []byte var countriesJSON []byte err := tx.QueryRowContext(ctx, ` 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 WHERE app_code = ? AND seller_user_id = ? AND status = 'active' FOR UPDATE`, appcode.FromContext(ctx), sellerUserID).Scan( &item.AppCode, &item.SellerUserID, &item.SortOrder, &item.PointAmount, &item.SellerCoinAmount, - &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS, + ®ionsJSON, &countriesJSON, &item.Status, &item.CreatedAtMS, &item.UpdatedAtMS, ) if errors.Is(err, sql.ErrNoRows) { 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 { 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 { return item, xerr.New(xerr.Internal, "point withdrawal coin seller config is invalid") } + item.ServiceRegionIDs = normalizePointWithdrawalRegionIDs(item.ServiceRegionIDs) 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 } @@ -421,18 +447,47 @@ func normalizeCountryCodes(values []string) []string { return out } -func countryAllowed(configured []string, countryCode string) bool { - if len(configured) == 0 { - return true +func normalizePointWithdrawalRegionIDs(values []int64) []int64 { + seen := make(map[int64]struct{}, len(values)) + 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)) if countryCode == "" { - return false + return false, nil } - for _, configuredCode := range configured { + for _, configuredCode := range countryCodes { if strings.EqualFold(configuredCode, countryCode) { - return true + return true, nil } } - return false + return false, nil } diff --git a/services/wallet-service/internal/storage/mysql/schema.go b/services/wallet-service/internal/storage/mysql/schema.go index 2424e311..bb42639f 100644 --- a/services/wallet-service/internal/storage/mysql/schema.go +++ b/services/wallet-service/internal/storage/mysql/schema.go @@ -562,6 +562,7 @@ func ensurePointWithdrawalCoinSellerSchema(ctx context.Context, db *sql.DB) erro point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母', seller_coin_amount BIGINT 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', created_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), 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 提现币商白名单与兑换比例'`) - 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 { diff --git a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go index f5f0be6a..4e2a9ce6 100644 --- a/services/wallet-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/wallet-service/internal/testutil/mysqltest/mysqltest.go @@ -17,8 +17,12 @@ import ( ) // 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() + regionsJSON, err := json.Marshal(regionIDs) + if err != nil { + r.t.Fatalf("marshal point withdrawal regions failed: %v", err) + } countriesJSON, err := json.Marshal(countryCodes) if err != nil { 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() if _, err := r.schema.DB.ExecContext(context.Background(), ` 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 - ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?) ON DUPLICATE KEY UPDATE 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)`, - appCode, sellerUserID, sortOrder, pointAmount, sellerCoinAmount, countriesJSON, status, nowMS, nowMS, + service_region_ids = VALUES(service_region_ids), service_country_codes = VALUES(service_country_codes), + status = VALUES(status), updated_at_ms = VALUES(updated_at_ms)`, + appCode, sellerUserID, sortOrder, pointAmount, sellerCoinAmount, regionsJSON, countriesJSON, status, nowMS, nowMS, ); err != nil { r.t.Fatalf("seed point withdrawal coin seller config failed: %v", err) } diff --git a/services/wallet-service/internal/transport/grpc/point_coin_seller.go b/services/wallet-service/internal/transport/grpc/point_coin_seller.go index d907986a..702cfc66 100644 --- a/services/wallet-service/internal/transport/grpc/point_coin_seller.go +++ b/services/wallet-service/internal/transport/grpc/point_coin_seller.go @@ -8,9 +8,9 @@ import ( "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) { - 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 { 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{ AppCode: item.AppCode, SellerUserId: item.SellerUserID, SortOrder: int32(item.SortOrder), PointAmount: item.PointAmount, SellerCoinAmount: item.SellerCoinAmount, + ServiceRegionIds: append([]int64(nil), item.ServiceRegionIDs...), ServiceCountryCodes: append([]string(nil), item.ServiceCountryCodes...), Status: item.Status, CreatedAtMs: item.CreatedAtMS, UpdatedAtMs: item.UpdatedAtMS, }) diff --git a/vip_packages_response_20260722.txt b/vip_packages_response_20260722.txt new file mode 100644 index 00000000..8772337b --- /dev/null +++ b/vip_packages_response_20260722.txt @@ -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 +}