币商提现相关

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 分区列表可展示的消息快照。
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" +

View File

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

View File

@ -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" +

View File

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

View File

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

View File

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

View File

@ -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, &regionsJSON, &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 == "" {

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()...),
})
}

View File

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

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 提现可选币商及其服务范围、兑换比例的运行快照。
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

View File

@ -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 事务内锁定配置解析。

View File

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

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) {
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,

View File

@ -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, &regionsJSON, &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,
&regionsJSON, &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
}

View File

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

View File

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

View File

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

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
}