币商提现相关
This commit is contained in:
parent
a38a3395c1
commit
8dda287d0a
@ -484,6 +484,8 @@ type InboxMessage struct {
|
||||
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,6 +1116,8 @@ 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"`
|
||||
// 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
|
||||
}
|
||||
@ -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" +
|
||||
|
||||
@ -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。
|
||||
|
||||
@ -25991,10 +25991,12 @@ type PointWithdrawalCoinSellerConfig struct {
|
||||
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" +
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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),
|
||||
}
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
122
server/admin/internal/modules/resource/grant_notice.go
Normal file
122
server/admin/internal/modules/resource/grant_notice.go
Normal 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]
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
50
server/admin/internal/modules/vipconfig/grant_notice.go
Normal file
50
server/admin/internal/modules/vipconfig/grant_notice.go
Normal 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
|
||||
}
|
||||
@ -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"
|
||||
@ -16,11 +17,16 @@ import (
|
||||
|
||||
type Handler struct {
|
||||
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))
|
||||
}
|
||||
|
||||
|
||||
@ -57,6 +57,8 @@ type InboxMessage struct {
|
||||
Body string
|
||||
IconURL string
|
||||
ImageURL string
|
||||
// ImageURLs 是一张通知卡片内的有序图片列表;ImageURL 保留首图兼容旧客户端。
|
||||
ImageURLs []string
|
||||
ActionType string
|
||||
ActionParam string
|
||||
Priority int32
|
||||
@ -121,6 +123,8 @@ type CreateInput struct {
|
||||
Body string
|
||||
IconURL string
|
||||
ImageURL string
|
||||
// ImageURLs 会由 service 写入受控 metadata 快照,MySQL 继续使用现有 JSON 列持久化。
|
||||
ImageURLs []string
|
||||
ActionType string
|
||||
ActionParam string
|
||||
Priority int32
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 &&
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -25,6 +25,7 @@ type inboxMessageData struct {
|
||||
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"`
|
||||
@ -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(),
|
||||
|
||||
@ -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...),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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()...),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
@ -7,6 +7,8 @@ type PointWithdrawalCoinSellerConfig struct {
|
||||
SortOrder int
|
||||
PointAmount int64
|
||||
SellerCoinAmount int64
|
||||
// ServiceRegionIDs 与 ServiceCountryCodes 互斥;都为空表示服务当前 App 的全部用户。
|
||||
ServiceRegionIDs []int64
|
||||
ServiceCountryCodes []string
|
||||
Status string
|
||||
CreatedAtMS int64
|
||||
|
||||
@ -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 事务内锁定配置解析。
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,20 +65,32 @@ 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) {
|
||||
// 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)
|
||||
}
|
||||
return items, rows.Err()
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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,8 +571,18 @@ 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 提现币商白名单与兑换比例'`)
|
||||
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 {
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
|
||||
32
vip_packages_response_20260722.txt
Normal file
32
vip_packages_response_20260722.txt
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user