Compare commits
19 Commits
8d25c99361
...
10a3df3a5a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10a3df3a5a | ||
|
|
1b7bc6c917 | ||
|
|
a9456239cc | ||
|
|
46e280c8a8 | ||
|
|
b24cc5a336 | ||
|
|
f5f2b828c6 | ||
|
|
6d35f421c0 | ||
|
|
05881934ad | ||
|
|
db5d6de329 | ||
|
|
10bcfadbc4 | ||
|
|
8c4a3e1754 | ||
|
|
cab826eabd | ||
|
|
f07226d8bd | ||
|
|
afc62eca1e | ||
|
|
fd215eadb6 | ||
|
|
335e9718bb | ||
|
|
f76d2350b1 | ||
|
|
880f151feb | ||
|
|
7433b24411 |
@ -11087,6 +11087,8 @@ type LuckyGiftDrawResult struct {
|
||||
CreatedAtMs int64 `protobuf:"varint,19,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
PoolId string `protobuf:"bytes,20,opt,name=pool_id,json=poolId,proto3" json:"pool_id,omitempty"`
|
||||
MultiplierPpm int64 `protobuf:"varint,21,opt,name=multiplier_ppm,json=multiplierPpm,proto3" json:"multiplier_ppm,omitempty"`
|
||||
WalletTransactionId string `protobuf:"bytes,22,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
|
||||
CoinBalanceAfter int64 `protobuf:"varint,23,opt,name=coin_balance_after,json=coinBalanceAfter,proto3" json:"coin_balance_after,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -11268,6 +11270,20 @@ func (x *LuckyGiftDrawResult) GetMultiplierPpm() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawResult) GetWalletTransactionId() string {
|
||||
if x != nil {
|
||||
return x.WalletTransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawResult) GetCoinBalanceAfter() int64 {
|
||||
if x != nil {
|
||||
return x.CoinBalanceAfter
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type ExecuteLuckyGiftDrawRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
LuckyGift *LuckyGiftMeta `protobuf:"bytes,1,opt,name=lucky_gift,json=luckyGift,proto3" json:"lucky_gift,omitempty"`
|
||||
@ -13101,7 +13117,7 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\frule_version\x18\x05 \x01(\x03R\vruleVersion\x12$\n" +
|
||||
"\x0etarget_rtp_ppm\x18\x06 \x01(\x03R\ftargetRtpPpm\x12'\n" +
|
||||
"\x0fexperience_pool\x18\a \x01(\tR\x0eexperiencePool\x12\x17\n" +
|
||||
"\apool_id\x18\b \x01(\tR\x06poolId\"\xf5\x06\n" +
|
||||
"\apool_id\x18\b \x01(\tR\x06poolId\"\xd7\a\n" +
|
||||
"\x13LuckyGiftDrawResult\x12\x17\n" +
|
||||
"\adraw_id\x18\x01 \x01(\tR\x06drawId\x12\x1d\n" +
|
||||
"\n" +
|
||||
@ -13125,7 +13141,9 @@ const file_proto_activity_v1_activity_proto_rawDesc = "" +
|
||||
"\x0fhigh_multiplier\x18\x12 \x01(\bR\x0ehighMultiplier\x12\"\n" +
|
||||
"\rcreated_at_ms\x18\x13 \x01(\x03R\vcreatedAtMs\x12\x17\n" +
|
||||
"\apool_id\x18\x14 \x01(\tR\x06poolId\x12%\n" +
|
||||
"\x0emultiplier_ppm\x18\x15 \x01(\x03R\rmultiplierPpm\"^\n" +
|
||||
"\x0emultiplier_ppm\x18\x15 \x01(\x03R\rmultiplierPpm\x122\n" +
|
||||
"\x15wallet_transaction_id\x18\x16 \x01(\tR\x13walletTransactionId\x12,\n" +
|
||||
"\x12coin_balance_after\x18\x17 \x01(\x03R\x10coinBalanceAfter\"^\n" +
|
||||
"\x1bExecuteLuckyGiftDrawRequest\x12?\n" +
|
||||
"\n" +
|
||||
"lucky_gift\x18\x01 \x01(\v2 .hyapp.activity.v1.LuckyGiftMetaR\tluckyGift\"^\n" +
|
||||
|
||||
@ -1263,6 +1263,8 @@ message LuckyGiftDrawResult {
|
||||
int64 created_at_ms = 19;
|
||||
string pool_id = 20;
|
||||
int64 multiplier_ppm = 21;
|
||||
string wallet_transaction_id = 22;
|
||||
int64 coin_balance_after = 23;
|
||||
}
|
||||
|
||||
message ExecuteLuckyGiftDrawRequest {
|
||||
|
||||
@ -562,6 +562,8 @@ type LuckyGiftDrawResult struct {
|
||||
StageFeedback bool `protobuf:"varint,15,opt,name=stage_feedback,json=stageFeedback,proto3" json:"stage_feedback,omitempty"`
|
||||
HighMultiplier bool `protobuf:"varint,16,opt,name=high_multiplier,json=highMultiplier,proto3" json:"high_multiplier,omitempty"`
|
||||
CreatedAtMs int64 `protobuf:"varint,17,opt,name=created_at_ms,json=createdAtMs,proto3" json:"created_at_ms,omitempty"`
|
||||
WalletTransactionId string `protobuf:"bytes,18,opt,name=wallet_transaction_id,json=walletTransactionId,proto3" json:"wallet_transaction_id,omitempty"`
|
||||
CoinBalanceAfter int64 `protobuf:"varint,19,opt,name=coin_balance_after,json=coinBalanceAfter,proto3" json:"coin_balance_after,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -715,6 +717,20 @@ func (x *LuckyGiftDrawResult) GetCreatedAtMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawResult) GetWalletTransactionId() string {
|
||||
if x != nil {
|
||||
return x.WalletTransactionId
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *LuckyGiftDrawResult) GetCoinBalanceAfter() int64 {
|
||||
if x != nil {
|
||||
return x.CoinBalanceAfter
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。
|
||||
type RoomTreasureRewardItem struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -6516,8 +6532,10 @@ type SendGiftRequest struct {
|
||||
TargetHostRegionId int64 `protobuf:"varint,9,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"`
|
||||
// target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。
|
||||
TargetAgencyOwnerUserId int64 `protobuf:"varint,10,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// sender_region_id 是 gateway 从 user-service 当前送礼用户资料注入的区域,用于 wallet 匹配礼物钻石比例。
|
||||
SenderRegionId int64 `protobuf:"varint,11,opt,name=sender_region_id,json=senderRegionId,proto3" json:"sender_region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SendGiftRequest) Reset() {
|
||||
@ -6620,6 +6638,13 @@ func (x *SendGiftRequest) GetTargetAgencyOwnerUserId() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *SendGiftRequest) GetSenderRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.SenderRegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||
type SendGiftResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -8881,7 +8906,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\x05score\x18\x02 \x01(\x03R\x05score\x12\x1d\n" +
|
||||
"\n" +
|
||||
"gift_value\x18\x03 \x01(\x03R\tgiftValue\x12\"\n" +
|
||||
"\rupdated_at_ms\x18\x04 \x01(\x03R\vupdatedAtMs\"\xa8\x05\n" +
|
||||
"\rupdated_at_ms\x18\x04 \x01(\x03R\vupdatedAtMs\"\x8a\x06\n" +
|
||||
"\x13LuckyGiftDrawResult\x12\x18\n" +
|
||||
"\aenabled\x18\x01 \x01(\bR\aenabled\x12\x17\n" +
|
||||
"\adraw_id\x18\x02 \x01(\tR\x06drawId\x12\x1d\n" +
|
||||
@ -8901,7 +8926,9 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\rreward_status\x18\x0e \x01(\tR\frewardStatus\x12%\n" +
|
||||
"\x0estage_feedback\x18\x0f \x01(\bR\rstageFeedback\x12'\n" +
|
||||
"\x0fhigh_multiplier\x18\x10 \x01(\bR\x0ehighMultiplier\x12\"\n" +
|
||||
"\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\"\xc0\x01\n" +
|
||||
"\rcreated_at_ms\x18\x11 \x01(\x03R\vcreatedAtMs\x122\n" +
|
||||
"\x15wallet_transaction_id\x18\x12 \x01(\tR\x13walletTransactionId\x12,\n" +
|
||||
"\x12coin_balance_after\x18\x13 \x01(\x03R\x10coinBalanceAfter\"\xc0\x01\n" +
|
||||
"\x16RoomTreasureRewardItem\x12$\n" +
|
||||
"\x0ereward_item_id\x18\x01 \x01(\tR\frewardItemId\x12*\n" +
|
||||
"\x11resource_group_id\x18\x02 \x01(\x03R\x0fresourceGroupId\x12\x16\n" +
|
||||
@ -9372,7 +9399,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\aroom_id\x18\x04 \x01(\tR\x06roomId\x12\x1d\n" +
|
||||
"\n" +
|
||||
"rtc_kicked\x18\x05 \x01(\bR\trtcKicked\x12$\n" +
|
||||
"\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\x98\x03\n" +
|
||||
"\x0ertc_kick_error\x18\x06 \x01(\tR\frtcKickError\"\xc2\x03\n" +
|
||||
"\x0fSendGiftRequest\x12.\n" +
|
||||
"\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" +
|
||||
"\x0etarget_user_id\x18\x02 \x01(\x03R\ftargetUserId\x12\x17\n" +
|
||||
@ -9386,7 +9413,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" +
|
||||
"\x0etarget_is_host\x18\b \x01(\bR\ftargetIsHost\x121\n" +
|
||||
"\x15target_host_region_id\x18\t \x01(\x03R\x12targetHostRegionId\x12<\n" +
|
||||
"\x1btarget_agency_owner_user_id\x18\n" +
|
||||
" \x01(\x03R\x17targetAgencyOwnerUserId\"\xfb\x02\n" +
|
||||
" \x01(\x03R\x17targetAgencyOwnerUserId\x12(\n" +
|
||||
"\x10sender_region_id\x18\v \x01(\x03R\x0esenderRegionId\"\xfb\x02\n" +
|
||||
"\x10SendGiftResponse\x124\n" +
|
||||
"\x06result\x18\x01 \x01(\v2\x1c.hyapp.room.v1.CommandResultR\x06result\x12,\n" +
|
||||
"\x12billing_receipt_id\x18\x02 \x01(\tR\x10billingReceiptId\x12\x1b\n" +
|
||||
|
||||
@ -94,6 +94,8 @@ message LuckyGiftDrawResult {
|
||||
bool stage_feedback = 15;
|
||||
bool high_multiplier = 16;
|
||||
int64 created_at_ms = 17;
|
||||
string wallet_transaction_id = 18;
|
||||
int64 coin_balance_after = 19;
|
||||
}
|
||||
|
||||
// RoomTreasureRewardItem 是后台配置给客户端展示的宝箱奖励候选项。
|
||||
@ -782,6 +784,8 @@ message SendGiftRequest {
|
||||
int64 target_host_region_id = 9;
|
||||
// target_agency_owner_user_id 是送礼瞬间主播上级代理收款用户;为空表示当前无代理或代理关系不可结算。
|
||||
int64 target_agency_owner_user_id = 10;
|
||||
// sender_region_id 是 gateway 从 user-service 当前送礼用户资料注入的区域,用于 wallet 匹配礼物钻石比例。
|
||||
int64 sender_region_id = 11;
|
||||
}
|
||||
|
||||
// SendGiftResponse 返回扣费成功并落到房间后的状态结果。
|
||||
|
||||
@ -41,8 +41,10 @@ type DebitGiftRequest struct {
|
||||
TargetHostRegionId int64 `protobuf:"varint,11,opt,name=target_host_region_id,json=targetHostRegionId,proto3" json:"target_host_region_id,omitempty"`
|
||||
// target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。
|
||||
TargetAgencyOwnerUserId int64 `protobuf:"varint,12,opt,name=target_agency_owner_user_id,json=targetAgencyOwnerUserId,proto3" json:"target_agency_owner_user_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// sender_region_id 是送礼用户所属区域,用于匹配礼物入主播周期钻石比例;不能用房间 visible_region_id 替代。
|
||||
SenderRegionId int64 `protobuf:"varint,13,opt,name=sender_region_id,json=senderRegionId,proto3" json:"sender_region_id,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *DebitGiftRequest) Reset() {
|
||||
@ -159,6 +161,13 @@ func (x *DebitGiftRequest) GetTargetAgencyOwnerUserId() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *DebitGiftRequest) GetSenderRegionId() int64 {
|
||||
if x != nil {
|
||||
return x.SenderRegionId
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||
type DebitGiftResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -8991,6 +9000,9 @@ type VipRewardItem struct {
|
||||
Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"`
|
||||
Quantity int64 `protobuf:"varint,5,opt,name=quantity,proto3" json:"quantity,omitempty"`
|
||||
ExpiresAtMs int64 `protobuf:"varint,6,opt,name=expires_at_ms,json=expiresAtMs,proto3" json:"expires_at_ms,omitempty"`
|
||||
AssetUrl string `protobuf:"bytes,7,opt,name=asset_url,json=assetUrl,proto3" json:"asset_url,omitempty"`
|
||||
PreviewUrl string `protobuf:"bytes,8,opt,name=preview_url,json=previewUrl,proto3" json:"preview_url,omitempty"`
|
||||
AnimationUrl string `protobuf:"bytes,9,opt,name=animation_url,json=animationUrl,proto3" json:"animation_url,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -9067,6 +9079,27 @@ func (x *VipRewardItem) GetExpiresAtMs() int64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *VipRewardItem) GetAssetUrl() string {
|
||||
if x != nil {
|
||||
return x.AssetUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VipRewardItem) GetPreviewUrl() string {
|
||||
if x != nil {
|
||||
return x.PreviewUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *VipRewardItem) GetAnimationUrl() string {
|
||||
if x != nil {
|
||||
return x.AnimationUrl
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type VipLevel struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Level int32 `protobuf:"varint,1,opt,name=level,proto3" json:"level,omitempty"`
|
||||
@ -12495,7 +12528,7 @@ var File_proto_wallet_v1_wallet_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xc2\x03\n" +
|
||||
"\x1cproto/wallet/v1/wallet.proto\x12\x0fhyapp.wallet.v1\"\xec\x03\n" +
|
||||
"\x10DebitGiftRequest\x12\x1d\n" +
|
||||
"\n" +
|
||||
"command_id\x18\x01 \x01(\tR\tcommandId\x12\x17\n" +
|
||||
@ -12511,7 +12544,8 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\x0etarget_is_host\x18\n" +
|
||||
" \x01(\bR\ftargetIsHost\x121\n" +
|
||||
"\x15target_host_region_id\x18\v \x01(\x03R\x12targetHostRegionId\x12<\n" +
|
||||
"\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\"\xff\x03\n" +
|
||||
"\x1btarget_agency_owner_user_id\x18\f \x01(\x03R\x17targetAgencyOwnerUserId\x12(\n" +
|
||||
"\x10sender_region_id\x18\r \x01(\x03R\x0esenderRegionId\"\xff\x03\n" +
|
||||
"\x11DebitGiftResponse\x12,\n" +
|
||||
"\x12billing_receipt_id\x18\x01 \x01(\tR\x10billingReceiptId\x12%\n" +
|
||||
"\x0etransaction_id\x18\x02 \x01(\tR\rtransactionId\x12\x1d\n" +
|
||||
@ -13451,7 +13485,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"withdrawal\x18\x01 \x01(\v2\".hyapp.wallet.v1.WithdrawalRequestR\n" +
|
||||
"withdrawal\x127\n" +
|
||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\"\xce\x01\n" +
|
||||
"\abalance\x18\x02 \x01(\v2\x1d.hyapp.wallet.v1.AssetBalanceR\abalance\"\xb1\x02\n" +
|
||||
"\rVipRewardItem\x12\x1f\n" +
|
||||
"\vresource_id\x18\x01 \x01(\x03R\n" +
|
||||
"resourceId\x12#\n" +
|
||||
@ -13459,7 +13493,11 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
|
||||
"\rresource_type\x18\x03 \x01(\tR\fresourceType\x12\x12\n" +
|
||||
"\x04name\x18\x04 \x01(\tR\x04name\x12\x1a\n" +
|
||||
"\bquantity\x18\x05 \x01(\x03R\bquantity\x12\"\n" +
|
||||
"\rexpires_at_ms\x18\x06 \x01(\x03R\vexpiresAtMs\"\xfc\x04\n" +
|
||||
"\rexpires_at_ms\x18\x06 \x01(\x03R\vexpiresAtMs\x12\x1b\n" +
|
||||
"\tasset_url\x18\a \x01(\tR\bassetUrl\x12\x1f\n" +
|
||||
"\vpreview_url\x18\b \x01(\tR\n" +
|
||||
"previewUrl\x12#\n" +
|
||||
"\ranimation_url\x18\t \x01(\tR\fanimationUrl\"\xfc\x04\n" +
|
||||
"\bVipLevel\x12\x14\n" +
|
||||
"\x05level\x18\x01 \x01(\x05R\x05level\x12\x12\n" +
|
||||
"\x04name\x18\x02 \x01(\tR\x04name\x12\x16\n" +
|
||||
|
||||
@ -23,6 +23,8 @@ message DebitGiftRequest {
|
||||
int64 target_host_region_id = 11;
|
||||
// target_agency_owner_user_id 是送礼时主播归属 Agency 的 owner,用于后续代理工资结算。
|
||||
int64 target_agency_owner_user_id = 12;
|
||||
// sender_region_id 是送礼用户所属区域,用于匹配礼物入主播周期钻石比例;不能用房间 visible_region_id 替代。
|
||||
int64 sender_region_id = 13;
|
||||
}
|
||||
|
||||
// DebitGiftResponse 返回扣费流水、服务端结算值和 sender COIN 账后余额。
|
||||
@ -1007,6 +1009,9 @@ message VipRewardItem {
|
||||
string name = 4;
|
||||
int64 quantity = 5;
|
||||
int64 expires_at_ms = 6;
|
||||
string asset_url = 7;
|
||||
string preview_url = 8;
|
||||
string animation_url = 9;
|
||||
}
|
||||
|
||||
message VipLevel {
|
||||
|
||||
331
docs/flutter对接/幸运礼物Flutter对接.md
Normal file
331
docs/flutter对接/幸运礼物Flutter对接.md
Normal file
@ -0,0 +1,331 @@
|
||||
# 幸运礼物 Flutter 对接
|
||||
|
||||
本文只保留 App 对接需要的信息:地址、参数、返回值、IM 字段。服务端实现细节看 [幸运礼物实现文档](../幸运礼物实现文档.md),礼物面板通用字段看 [礼物 Tab Flutter 对接](礼物TabFlutter对接.md)。
|
||||
|
||||
## 1. 送礼前检查
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
POST /api/v1/activities/lucky-gifts/check
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前房间 ID。 |
|
||||
| `gift_id` | string | 是 | 礼物 ID。 |
|
||||
| `pool_id` | string | 是 | 幸运礼物奖池 ID,例如 `lucky`、`super_lucky` 或后台配置值。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_room_001",
|
||||
"gift_id": "rose",
|
||||
"pool_id": "super_lucky"
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 是否可参与幸运礼物抽奖。 |
|
||||
| `reason` | string | 不可参与原因;可为空。 |
|
||||
| `room_id` | string | 房间 ID。 |
|
||||
| `gift_id` | string | 礼物 ID。 |
|
||||
| `pool_id` | string | 奖池 ID。 |
|
||||
| `gift_price` | int64 | 当前规则参考礼物价格,只展示用,扣费以后端送礼结果为准。 |
|
||||
| `rule_version` | int64 | 命中的幸运礼物规则版本。 |
|
||||
| `target_rtp_ppm` | int64 | 目标 RTP,ppm 口径;`950000` 表示 95%。 |
|
||||
| `experience_pool` | string | 当前用户体验池,例如 `novice`、`normal`、`advanced`。 |
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"enabled": true,
|
||||
"reason": "",
|
||||
"room_id": "lalu_room_001",
|
||||
"gift_id": "rose",
|
||||
"pool_id": "super_lucky",
|
||||
"gift_price": 100,
|
||||
"rule_version": 12,
|
||||
"target_rtp_ppm": 950000,
|
||||
"experience_pool": "novice"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 发送幸运礼物
|
||||
|
||||
地址:
|
||||
|
||||
```http
|
||||
POST /api/v1/rooms/gift/send
|
||||
Authorization: Bearer <access_token>
|
||||
X-App-Code: lalu
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求参数:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `room_id` | string | 是 | 当前房间 ID。 |
|
||||
| `command_id` | string | 是 | 本次送礼幂等键;同一次重试必须复用。 |
|
||||
| `target_type` | string | 否 | 当前固定传 `user`;为空后端按 `user`。 |
|
||||
| `target_user_ids` | int64[] | 是 | 收礼用户列表;当前只传 1 个用户。 |
|
||||
| `target_user_id` | int64 | 否 | 旧单目标兼容字段,新代码优先用 `target_user_ids`。 |
|
||||
| `gift_id` | string | 是 | 礼物 ID。 |
|
||||
| `gift_count` | int32 | 是 | 礼物数量,必须大于 0;批量送礼会聚合返回一次中奖结果。 |
|
||||
| `pool_id` | string | 是 | 幸运礼物奖池 ID;普通礼物不传。 |
|
||||
|
||||
请求示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_id": "lalu_room_001",
|
||||
"command_id": "gift_1779259000000_10001_rose_01",
|
||||
"target_type": "user",
|
||||
"target_user_ids": [10002],
|
||||
"gift_id": "rose",
|
||||
"gift_count": 9,
|
||||
"pool_id": "super_lucky"
|
||||
}
|
||||
```
|
||||
|
||||
返回值:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `result.applied` | bool | 本次命令是否实际落地;重复提交同一 `command_id` 可能为 `false`。 |
|
||||
| `result.room_version` | int64 | 房间版本。 |
|
||||
| `result.server_time_ms` | int64 | 服务端时间。 |
|
||||
| `billing_receipt_id` | string | 送礼扣费回执。 |
|
||||
| `room_heat` | int64 | 送礼后的房间热度。 |
|
||||
| `gift_rank` | array | 当前房间礼物榜。 |
|
||||
| `room` | object | 房间快照。 |
|
||||
| `treasure` | object | 房间宝箱状态。 |
|
||||
| `lucky_gift` | object | 幸运礼物抽奖结果;普通礼物可为空。 |
|
||||
|
||||
`lucky_gift` 返回字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `enabled` | bool | 本次是否触发幸运礼物。 |
|
||||
| `draw_id` | string | 聚合抽奖 ID。 |
|
||||
| `command_id` | string | 本次送礼命令 ID。 |
|
||||
| `pool_id` | string | 奖池 ID。 |
|
||||
| `gift_id` | string | 礼物 ID。 |
|
||||
| `rule_version` | int64 | 规则版本。 |
|
||||
| `experience_pool` | string | 用户体验池。 |
|
||||
| `selected_tier_id` | string | 命中奖档 ID。 |
|
||||
| `multiplier_ppm` | int64 | 倍率 ppm;`2000000` 表示 2x。 |
|
||||
| `base_reward_coins` | int64 | 基础奖池返奖金币。 |
|
||||
| `room_atmosphere_reward_coins` | int64 | 房间气氛池返奖金币。 |
|
||||
| `activity_subsidy_coins` | int64 | 活动补贴返奖金币。 |
|
||||
| `effective_reward_coins` | int64 | 实际中奖金币;0 表示未中奖。 |
|
||||
| `reward_status` | string | `granted` 已入账;`pending`/`granting` 到账处理中;`failed` 需要人工处理。 |
|
||||
| `stage_feedback` | bool | 是否有阶段反馈表现。 |
|
||||
| `high_multiplier` | bool | 是否高倍率中奖。 |
|
||||
| `created_at_ms` | int64 | 抽奖创建时间。 |
|
||||
| `wallet_transaction_id` | string | 返奖钱包交易 ID;已入账时有值。 |
|
||||
| `coin_balance_after` | int64 | 返奖入账后的 COIN 可用余额。 |
|
||||
|
||||
返回示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_abc",
|
||||
"data": {
|
||||
"result": {
|
||||
"applied": true,
|
||||
"room_version": 14,
|
||||
"server_time_ms": 1779259000000
|
||||
},
|
||||
"billing_receipt_id": "receipt-lucky",
|
||||
"room_heat": 123456,
|
||||
"gift_rank": [],
|
||||
"room": {},
|
||||
"treasure": {},
|
||||
"lucky_gift": {
|
||||
"enabled": true,
|
||||
"draw_id": "lucky_draw_test",
|
||||
"command_id": "gift_1779259000000_10001_rose_01",
|
||||
"pool_id": "super_lucky",
|
||||
"gift_id": "rose",
|
||||
"rule_version": 12,
|
||||
"experience_pool": "novice",
|
||||
"selected_tier_id": "novice_2x",
|
||||
"multiplier_ppm": 2000000,
|
||||
"base_reward_coins": 1800,
|
||||
"room_atmosphere_reward_coins": 0,
|
||||
"activity_subsidy_coins": 0,
|
||||
"effective_reward_coins": 1800,
|
||||
"reward_status": "granted",
|
||||
"stage_feedback": false,
|
||||
"high_multiplier": false,
|
||||
"created_at_ms": 1779259000000,
|
||||
"wallet_transaction_id": "wallet_tx_lucky",
|
||||
"coin_balance_after": 8800
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
客户端处理:
|
||||
|
||||
- `reward_status=granted` 且 `effective_reward_coins > 0`:立即展示中奖,并用 `coin_balance_after` 覆盖本地金币余额。
|
||||
- `reward_status=pending` 或 `granting`:展示“到账处理中”,不要本地加金币,不要生成新 `command_id` 重发。
|
||||
- `effective_reward_coins=0`:未中奖,只展示普通送礼表现。
|
||||
- 同一用户同一房间连续发送幸运礼物时,客户端需要本地排队;重试必须复用原 `command_id`。
|
||||
|
||||
## 3. IM 字段
|
||||
|
||||
### 3.1 送礼房间 IM
|
||||
|
||||
用途:房间内播放普通送礼动效、同步热度和礼物榜。幸运礼物也会先产生这条送礼 IM。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `MsgContent.Desc` | `room_gift_sent` |
|
||||
| `Data.event_type` | `room_gift_sent` |
|
||||
|
||||
`Data` 核心字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `event_id` | string | 房间事件 ID,用于去重。 |
|
||||
| `room_id` | string | 房间 ID。 |
|
||||
| `room_version` | int64 | 房间版本。 |
|
||||
| `occurred_at_ms` | int64 | 事件发生时间。 |
|
||||
| `actor_user_id` | string/int64 | 送礼用户 ID。 |
|
||||
| `target_user_id` | string/int64 | 收礼用户 ID。 |
|
||||
| `gift_value` | int64 | 本次送礼房间价值/热度口径。 |
|
||||
| `room_heat` | int64 | 送礼后的房间热度。 |
|
||||
| `attributes.gift_id` | string | 礼物 ID。 |
|
||||
| `attributes.pool_id` | string | 幸运礼物奖池 ID;普通礼物可为空。 |
|
||||
| `attributes.gift_count` | string | 礼物数量,字符串格式。 |
|
||||
| `attributes.billing_receipt_id` | string | 钱包扣费回执。 |
|
||||
| `attributes.coin_spent` | string | 本次实际消耗金币,字符串格式。 |
|
||||
| `attributes.gift_point_added` | string | 主播礼物积分增量,字符串格式。 |
|
||||
| `attributes.price_version` | string | 价格版本。 |
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "room_gift_sent",
|
||||
"event_id": "room_event_xxx",
|
||||
"room_id": "lalu_room_001",
|
||||
"room_version": 14,
|
||||
"occurred_at_ms": 1779259000000,
|
||||
"actor_user_id": "10001",
|
||||
"target_user_id": "10002",
|
||||
"gift_value": 900,
|
||||
"room_heat": 123456,
|
||||
"attributes": {
|
||||
"gift_id": "rose",
|
||||
"pool_id": "super_lucky",
|
||||
"gift_count": "9",
|
||||
"billing_receipt_id": "receipt-lucky",
|
||||
"coin_spent": "900",
|
||||
"gift_point_added": "900",
|
||||
"price_version": "v1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 幸运礼物开奖 IM
|
||||
|
||||
用途:房间公屏展示幸运礼物中奖结果、其他用户同步中奖表现。当前用户自己的余额以 HTTP 返回的 `coin_balance_after` 为准,不等这条 IM。
|
||||
|
||||
| 字段 | 值 |
|
||||
| --- | --- |
|
||||
| `MsgContent.Desc` | `lucky_gift_drawn` |
|
||||
| `Data.event_type` | `lucky_gift_drawn` |
|
||||
|
||||
`Data` 核心字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `event_id` | string | 开奖事件 ID,用于去重。 |
|
||||
| `app_code` | string | App Code。 |
|
||||
| `draw_id` | string | 聚合抽奖 ID。 |
|
||||
| `command_id` | string | 送礼命令 ID。 |
|
||||
| `pool_id` | string | 奖池 ID。 |
|
||||
| `room_id` | string | 房间 ID。 |
|
||||
| `gift_id` | string | 礼物 ID。 |
|
||||
| `gift_count` | int32 | 礼物数量。 |
|
||||
| `user_id` | int64 | 中奖用户 ID。 |
|
||||
| `sender_user_id` | int64 | 送礼用户 ID。 |
|
||||
| `target_user_id` | int64 | 收礼用户 ID。 |
|
||||
| `visible_region_id` | int64 | 展示区域 ID。 |
|
||||
| `coin_spent` | int64 | 本次送礼消耗金币。 |
|
||||
| `rule_version` | int64 | 规则版本。 |
|
||||
| `experience_pool` | string | 用户体验池。 |
|
||||
| `selected_tier_id` | string | 命中奖档 ID。 |
|
||||
| `multiplier_ppm` | int64 | 命中倍率 ppm。 |
|
||||
| `base_reward_coins` | int64 | 基础奖池返奖金币。 |
|
||||
| `room_atmosphere_reward_coins` | int64 | 房间气氛池返奖金币。 |
|
||||
| `activity_subsidy_coins` | int64 | 活动补贴返奖金币。 |
|
||||
| `effective_reward_coins` | int64 | 实际中奖金币。 |
|
||||
| `stage_feedback` | bool | 是否有阶段反馈表现。 |
|
||||
| `high_multiplier` | bool | 是否高倍率中奖。 |
|
||||
| `created_at_ms` | int64 | 抽奖创建时间。 |
|
||||
|
||||
示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_test",
|
||||
"app_code": "lalu",
|
||||
"draw_id": "lucky_draw_test",
|
||||
"command_id": "gift_1779259000000_10001_rose_01",
|
||||
"pool_id": "super_lucky",
|
||||
"room_id": "lalu_room_001",
|
||||
"gift_id": "rose",
|
||||
"gift_count": 9,
|
||||
"user_id": 10001,
|
||||
"sender_user_id": 10001,
|
||||
"target_user_id": 10002,
|
||||
"visible_region_id": 688,
|
||||
"coin_spent": 900,
|
||||
"rule_version": 12,
|
||||
"experience_pool": "novice",
|
||||
"selected_tier_id": "novice_2x",
|
||||
"multiplier_ppm": 2000000,
|
||||
"base_reward_coins": 1800,
|
||||
"room_atmosphere_reward_coins": 0,
|
||||
"activity_subsidy_coins": 0,
|
||||
"effective_reward_coins": 1800,
|
||||
"stage_feedback": false,
|
||||
"high_multiplier": false,
|
||||
"created_at_ms": 1779259000000
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 状态处理
|
||||
|
||||
| 场景 | App 处理 |
|
||||
| --- | --- |
|
||||
| `lucky_gift` 为空 | 当普通礼物处理。 |
|
||||
| `effective_reward_coins=0` | 未中奖,不更新中奖余额。 |
|
||||
| `reward_status=granted` | 已入账;用 `coin_balance_after` 更新余额。 |
|
||||
| `reward_status=pending` / `granting` | 到账处理中;等待钱包通知、刷新余额或后端补偿。 |
|
||||
| `reward_status=failed` | 展示异常状态,记录 `request_id/draw_id/command_id` 方便排查。 |
|
||||
| HTTP 超时 | 保留原 `command_id` 重试,不生成新命令。 |
|
||||
|
||||
@ -10,10 +10,11 @@
|
||||
- 房间送礼面板入口是 `GET /api/v1/rooms/{room_id}/gift-panel`,返回当前房间可送礼物、可收礼人、金币余额和房间内可展示 Tab。
|
||||
- `GET /api/v1/gift-tabs` 不能替代 `GET /api/v1/rooms/{room_id}/gift-panel`;App 真正送礼前必须以房间面板返回的数据为准。
|
||||
- 送礼只调用 `POST /api/v1/rooms/gift/send`。
|
||||
- 金币余额以礼物面板、送礼响应后的钱包通知或 `GET /api/v1/wallet/me/balances` 为准。
|
||||
- 金币余额以礼物面板、送礼响应里的 `lucky_gift.coin_balance_after`、钱包通知或 `GET /api/v1/wallet/me/balances` 为准;普通礼物和未中奖场景仍以后续钱包事实校准。
|
||||
- 房间内其他用户看到的礼物表现以腾讯云 IM 自定义消息 `room_gift_sent` 为准。
|
||||
- `command_id` 是送礼幂等键;同一次用户点击重试必须复用同一个值。
|
||||
- 当前 App 送礼主流程只按单个用户收礼处理:`target_type=user`,`target_user_ids` 只传 1 个用户。
|
||||
- 幸运礼物的专门接入规则见 [幸运礼物 Flutter 对接](幸运礼物Flutter对接.md)。
|
||||
|
||||
## 交互流程
|
||||
|
||||
@ -23,9 +24,9 @@
|
||||
4. 用 `tabs` 渲染当前房间分类,用 `gifts` 渲染礼物网格,用 `recipients` 渲染收礼人。
|
||||
5. 用户选择收礼人、礼物和数量。
|
||||
6. 点击发送时生成稳定 `command_id`,调用 `POST /api/v1/rooms/gift/send`。
|
||||
7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`treasure` 更新房间 UI。
|
||||
7. 发送成功后用响应里的 `room_heat`、`gift_rank`、`treasure` 更新房间 UI;如果有 `lucky_gift` 且已入账,用 `coin_balance_after` 立即刷新金币余额。
|
||||
8. 监听房间 IM `room_gift_sent`,同步播放礼物动效。
|
||||
9. 监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。
|
||||
9. 普通礼物和未中奖余额仍监听私有钱包通知或主动刷新余额,不本地扣减余额作为最终值。
|
||||
|
||||
## 获取礼物 Tab 配置
|
||||
|
||||
@ -294,7 +295,25 @@ Content-Type: application/json
|
||||
}
|
||||
],
|
||||
"room": {},
|
||||
"treasure": {}
|
||||
"treasure": {},
|
||||
"lucky_gift": {
|
||||
"enabled": true,
|
||||
"draw_id": "lucky_draw_test",
|
||||
"command_id": "gift_1779259000000_10001_rose_01",
|
||||
"pool_id": "super_lucky",
|
||||
"gift_id": "rose",
|
||||
"rule_version": 12,
|
||||
"experience_pool": "novice",
|
||||
"selected_tier_id": "novice_2x",
|
||||
"multiplier_ppm": 2000000,
|
||||
"effective_reward_coins": 1800,
|
||||
"reward_status": "granted",
|
||||
"stage_feedback": false,
|
||||
"high_multiplier": false,
|
||||
"created_at_ms": 1779259000000,
|
||||
"wallet_transaction_id": "wallet_tx_lucky",
|
||||
"coin_balance_after": 8800
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@ -311,13 +330,16 @@ Content-Type: application/json
|
||||
| `gift_rank` | 最新房间礼物榜。 |
|
||||
| `room` | 房间快照,字段较大;App 可按已有房间快照解析逻辑局部更新。 |
|
||||
| `treasure` | 房间宝箱状态;宝箱 UI 以该字段或宝箱 IM 事件更新。 |
|
||||
| `lucky_gift` | 幸运礼物抽奖结果;普通礼物可为空。`reward_status=granted` 且中奖时,`coin_balance_after` 是当前用户返奖入账后的余额。 |
|
||||
|
||||
发送后处理:
|
||||
|
||||
- 成功后立即关闭 loading,保留礼物面板或按产品交互关闭。
|
||||
- 用 `room_heat` 和 `gift_rank` 更新房间顶部和贡献榜。
|
||||
- 播放当前用户送礼动效时按 `command_id` 去重,避免 HTTP 响应和 IM 各播放一次。
|
||||
- 不要本地直接写最终余额;等待 `WalletBalanceChanged` 私有 IM 或主动刷新余额。
|
||||
- 如果 `lucky_gift.reward_status=granted` 且 `lucky_gift.effective_reward_coins > 0`,立即用 `lucky_gift.coin_balance_after` 覆盖本地金币余额。
|
||||
- 如果 `lucky_gift.reward_status=pending`,展示中奖到账处理中,不要生成新 `command_id` 重送;补偿 worker 会继续使用同一个 draw 发放。
|
||||
- 普通礼物和未中奖场景不要本地直接写最终余额;等待 `WalletBalanceChanged` 私有 IM 或主动刷新余额。
|
||||
|
||||
## 房间 IM 同步
|
||||
|
||||
|
||||
624
docs/flutter对接/语音房红包Flutter对接.md
Normal file
624
docs/flutter对接/语音房红包Flutter对接.md
Normal file
@ -0,0 +1,624 @@
|
||||
# 语音房红包 Flutter 对接
|
||||
|
||||
本文按当前 gateway、wallet-service 和 activity-service 实现描述 Flutter 需要对接的房内红包 HTTP 接口和腾讯 IM 自定义消息。红包金额、份额、可抢时间、过期退款、领取幂等和房间在线态校验全部以后端为准;Flutter 只负责展示、提交用户动作和处理实时刷新。
|
||||
|
||||
## 1. 基本约定
|
||||
|
||||
- 所有 HTTP 路径都有 `/api/v1` 前缀。
|
||||
- 所有接口都需要 `Authorization: Bearer <access_token>`,并且用户必须已完成资料。
|
||||
- 成功响应统一是 gateway envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
- 失败响应也使用同一个 envelope;Flutter 只按 HTTP 状态和 `code` 分支,`message` 只做兜底展示或日志。
|
||||
- 所有时间字段都是 Unix epoch milliseconds。Flutter 不用本地时区参与业务判断,只做展示格式化。
|
||||
- 红包主键统一使用 `packetNo`。IM 里兼容带 `packetId`,值和 `packetNo` 相同。
|
||||
- Go 返回的部分 ID 是 number,Flutter 模型里建议统一转成 `String` 保存,避免大整数精度和 UI 拼接问题。
|
||||
|
||||
## 2. 接入流程
|
||||
|
||||
进入语音房后:
|
||||
|
||||
1. 走现有进房流程,加入房间 IM 群。
|
||||
2. 调用 `GET /api/v1/app/voice-room/red-packet/active?roomId=<roomId>` 初始化侧边栏红包列表。
|
||||
3. 监听腾讯 IM 房间群 `TIMCustomElem`,解析 `ROOM_RED_PACKET`。
|
||||
4. 如果需要接收延时红包同语区飘窗,调用 `GET /api/v1/app/voice-room/red-packet/im-group` 获取当前语区群并加入。
|
||||
5. 用户点击 IM 红包弹窗或侧边栏红包时,直接用 payload 里的 `packetNo` 调领取或详情接口。
|
||||
|
||||
收到 `CREATED` IM 后,payload 已带发送者头像昵称、金额、份数和剩余份数。Flutter 可以直接弹窗或显示飘窗,不需要为了基础展示再立刻拉 active/detail。断线重连、进房初始化、详情页打开和二级领取记录页仍然走 HTTP 查询。
|
||||
|
||||
## 3. 当前语区 IM 群
|
||||
|
||||
```http
|
||||
GET /api/v1/app/voice-room/red-packet/im-group
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"regionId": 310,
|
||||
"regionCode": "AR",
|
||||
"regionName": "Arabic",
|
||||
"groupId": "hy_lalu_bc_r_310",
|
||||
"groupName": "hy_lalu_bc_r_310"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 用途:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| `groupId` | 腾讯 IM 区域广播群 ID,加入后接收延时红包同语区飘窗。 |
|
||||
| `regionId` | 当前用户所在语区 ID。 |
|
||||
| `regionCode` | UI 或埋点使用。 |
|
||||
|
||||
如果返回 `voice_room_region_missing`,说明用户资料没有有效语区,隐藏区域红包飘窗入口。
|
||||
|
||||
## 4. 红包配置
|
||||
|
||||
```http
|
||||
GET /api/v1/app/voice-room/red-packet/config
|
||||
```
|
||||
|
||||
当前该接口沿用 wallet 配置 DTO,字段是 snake_case:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"config": {
|
||||
"app_code": "lalu",
|
||||
"enabled": true,
|
||||
"count_tiers": [5, 10],
|
||||
"amount_tiers": [15000, 30000],
|
||||
"delayed_open_seconds": 300,
|
||||
"expire_seconds": 86400,
|
||||
"daily_send_limit": 10,
|
||||
"rule_url": "https://example.com/red-packet-rule",
|
||||
"created_at_ms": 1780000000000,
|
||||
"updated_at_ms": 1780000000000
|
||||
},
|
||||
"server_time_ms": 1780000000000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 用途:
|
||||
|
||||
| 字段 | 用途 |
|
||||
| --- | --- |
|
||||
| `enabled` | false 时隐藏发送入口,已收到的历史红包仍可按接口结果展示。 |
|
||||
| `count_tiers` | 发送红包份数选项。 |
|
||||
| `amount_tiers` | 发送红包总金币金额选项。 |
|
||||
| `delayed_open_seconds` | 延时红包等待秒数;Flutter 不提交该值。 |
|
||||
| `expire_seconds` | 过期秒数;当前为 86400。 |
|
||||
| `daily_send_limit` | 每人每日发送上限提示。 |
|
||||
| `rule_url` | 规则说明 H5。 |
|
||||
|
||||
## 5. 发送红包
|
||||
|
||||
```http
|
||||
POST /api/v1/app/voice-room/red-packet/send
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"roomId": "9001",
|
||||
"packetMode": "DELAYED",
|
||||
"totalAmount": 15000,
|
||||
"totalCount": 5,
|
||||
"requestId": "client-uuid-or-action-id"
|
||||
}
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `roomId` | 是 | 当前语音房 ID。 |
|
||||
| `packetMode` | 是 | `IMMEDIATE` 或 `DELAYED`。后端也兼容 `normal/delayed`。 |
|
||||
| `totalAmount` | 是 | 红包总金币,必须命中 `amount_tiers`。 |
|
||||
| `totalCount` | 是 | 红包份数,必须命中 `count_tiers`。 |
|
||||
| `requestId` | 是 | 客户端动作幂等 ID。同一用户同一 `requestId` 重试返回同一红包。 |
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"packet": {
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"regionId": 310,
|
||||
"senderUserId": 10001,
|
||||
"senderAccount": "10001",
|
||||
"senderNickname": "Tom",
|
||||
"senderAvatar": "https://example.com/avatar1.png",
|
||||
"packetMode": "DELAYED",
|
||||
"totalAmount": 15000,
|
||||
"totalCount": 5,
|
||||
"remainingAmount": 15000,
|
||||
"remainingCount": 5,
|
||||
"status": "WAITING_OPEN",
|
||||
"claimStartTime": 1780000000000,
|
||||
"expireTime": 1780086400000,
|
||||
"createdAt": 1779999700000,
|
||||
"claimedByMe": false,
|
||||
"myClaimAmount": 0,
|
||||
"refundAmount": 0,
|
||||
"refundStatus": "",
|
||||
"refundedAt": 0,
|
||||
"claims": []
|
||||
},
|
||||
"balanceAfter": 985000,
|
||||
"serverTime": 1779999700000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
发送成功后服务端会异步发送 IM:
|
||||
|
||||
- 当前房间群:`ROOM_RED_PACKET / CREATED / scope=ROOM`
|
||||
- 延时红包额外发送当前语区广播群:`ROOM_RED_PACKET / CREATED / scope=REGION`
|
||||
- 延时红包到点后当前房间群:`ROOM_RED_PACKET / OPEN_DUE / scope=ROOM`
|
||||
|
||||
Flutter 发送成功后可以立即把返回的 `packet` 放入本地红包列表;后续 IM 可能也会收到同一个红包,需要按 `packetNo + event + scope` 做去重或覆盖更新。
|
||||
|
||||
## 6. 房间有效红包列表
|
||||
|
||||
```http
|
||||
GET /api/v1/app/voice-room/red-packet/active?roomId=9001
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"packets": [
|
||||
{
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"regionId": 310,
|
||||
"senderUserId": 10001,
|
||||
"senderAccount": "10001",
|
||||
"senderNickname": "Tom",
|
||||
"senderAvatar": "https://example.com/avatar1.png",
|
||||
"packetMode": "DELAYED",
|
||||
"totalAmount": 15000,
|
||||
"totalCount": 5,
|
||||
"remainingAmount": 15000,
|
||||
"remainingCount": 5,
|
||||
"status": "WAITING_OPEN",
|
||||
"claimStartTime": 1780000000000,
|
||||
"expireTime": 1780086400000,
|
||||
"createdAt": 1779999700000,
|
||||
"claimedByMe": false,
|
||||
"myClaimAmount": 0
|
||||
}
|
||||
],
|
||||
"serverTime": 1779999700000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 用途:
|
||||
|
||||
- 进房或重连后初始化侧边栏列表。
|
||||
- 侧边栏未领取数量可以用 `packets.where((p) => !p.claimedByMe).length`。
|
||||
- 该接口只返回仍处于 `ACTIVE` 或 `WAITING_OPEN` 的红包。抢完、已退款或过期后的最终状态通过详情或 IM `STATUS_CHANGED` 刷新。
|
||||
|
||||
## 7. 抢红包
|
||||
|
||||
```http
|
||||
POST /api/v1/app/voice-room/red-packet/claim
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"packetNo": "VRRP2026052712340001"
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"claim": {
|
||||
"claimId": "claim_xxx",
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"userId": 10002,
|
||||
"account": "10002",
|
||||
"nickname": "Jerry",
|
||||
"avatar": "https://example.com/avatar2.png",
|
||||
"amount": 3000,
|
||||
"status": "claimed",
|
||||
"createdAt": 1780000010000
|
||||
},
|
||||
"packet": {
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"regionId": 310,
|
||||
"senderUserId": 10001,
|
||||
"senderAccount": "10001",
|
||||
"senderNickname": "Tom",
|
||||
"senderAvatar": "https://example.com/avatar1.png",
|
||||
"remainingAmount": 12000,
|
||||
"remainingCount": 4,
|
||||
"status": "ACTIVE",
|
||||
"claimedByMe": true,
|
||||
"myClaimAmount": 3000
|
||||
},
|
||||
"balanceAfter": 1003000,
|
||||
"serverTime": 1780000010000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
处理规则:
|
||||
|
||||
- 领取成功响应里的 `packet` 结构和详情接口一致,示例只保留弹窗刷新需要的关键字段。
|
||||
- Flutter 只提交 `packetNo`。当前服务端按 `packetNo + 当前用户` 保证同一用户重复领取返回同一结果。
|
||||
- 如果返回 `voice_room_red_packet_room_presence_required`,提示用户需要在房间内操作,并刷新房间在线态。
|
||||
- 如果返回 `voice_room_red_packet_not_started`,展示倒计时,不要重试抢。
|
||||
- 如果返回 `voice_room_red_packet_finished` 或 `voice_room_red_packet_expired`,刷新侧边栏和详情。
|
||||
- 如果返回 `claim_processing`,展示处理中,可让用户稍后重试同一个红包或打开详情。
|
||||
|
||||
## 8. 红包详情
|
||||
|
||||
```http
|
||||
GET /api/v1/app/voice-room/red-packet/detail?packetNo=VRRP2026052712340001
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"packet": {
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"regionId": 310,
|
||||
"senderUserId": 10001,
|
||||
"senderAccount": "10001",
|
||||
"senderNickname": "Tom",
|
||||
"senderAvatar": "https://example.com/avatar1.png",
|
||||
"packetMode": "DELAYED",
|
||||
"totalAmount": 15000,
|
||||
"totalCount": 5,
|
||||
"remainingAmount": 12000,
|
||||
"remainingCount": 4,
|
||||
"status": "ACTIVE",
|
||||
"claimStartTime": 1780000000000,
|
||||
"expireTime": 1780086400000,
|
||||
"createdAt": 1779999700000,
|
||||
"claimedByMe": true,
|
||||
"myClaimAmount": 3000,
|
||||
"refundAmount": 0,
|
||||
"refundStatus": "",
|
||||
"refundedAt": 0,
|
||||
"claims": []
|
||||
},
|
||||
"serverTime": 1780000010000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
详情和领取成功响应里的 `packet` 会补发送者 `senderAccount/senderNickname/senderAvatar`。详情接口里的 `claims` 不作为二级领取记录页的资料来源。需要领取用户头像昵称账号时使用 `claim-records`,因为该接口会按当前页批量补用户资料。
|
||||
|
||||
## 9. 领取记录
|
||||
|
||||
```http
|
||||
GET /api/v1/app/voice-room/red-packet/claim-records?packetNo=VRRP2026052712340001&page=1&pageSize=20
|
||||
```
|
||||
|
||||
当前实现使用 `page/pageSize`,不要按旧文档使用 `cursor/limit`。
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"claimId": "claim_xxx",
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"userId": 10002,
|
||||
"account": "10002",
|
||||
"nickname": "Jerry",
|
||||
"avatar": "https://example.com/avatar2.png",
|
||||
"amount": 3000,
|
||||
"status": "claimed",
|
||||
"createdAt": 1780000010000
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"serverTime": 1780000010000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 用途:
|
||||
|
||||
- 二级领取记录页使用 `items` 渲染头像、昵称、账号、领取金额和领取时间。
|
||||
- `account/nickname/avatar` 已由 gateway 从 user-service 补齐。
|
||||
- 分页加载下一页时递增 `page`。
|
||||
|
||||
## 10. 我发出的红包记录
|
||||
|
||||
```http
|
||||
GET /api/v1/app/voice-room/red-packet/sent-records?page=1&pageSize=20
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "OK",
|
||||
"message": "ok",
|
||||
"request_id": "req_xxx",
|
||||
"data": {
|
||||
"items": [
|
||||
{
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"packetMode": "DELAYED",
|
||||
"totalAmount": 15000,
|
||||
"totalCount": 5,
|
||||
"remainingAmount": 12000,
|
||||
"remainingCount": 4,
|
||||
"status": "ACTIVE",
|
||||
"claimStartTime": 1780000000000,
|
||||
"expireTime": 1780086400000,
|
||||
"createdAt": 1779999700000,
|
||||
"refundAmount": 0,
|
||||
"refundStatus": "",
|
||||
"refundedAt": 0
|
||||
}
|
||||
],
|
||||
"total": 1,
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
"serverTime": 1780000010000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 11. IM 消息
|
||||
|
||||
腾讯 IM 自定义消息的 `TIMCustomElem.Data` 是 JSON 字符串。Flutter 先解析外层:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ROOM_RED_PACKET",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
只有 `type == "ROOM_RED_PACKET"` 才进入红包处理。
|
||||
|
||||
### 11.1 CREATED
|
||||
|
||||
当前房间弹窗和同语区飘窗都使用 `CREATED`,通过 `scope` 区分。修正后的 CREATED payload 已带足够的首屏展示字段。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ROOM_RED_PACKET",
|
||||
"data": {
|
||||
"event": "CREATED",
|
||||
"scope": "ROOM",
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"packetId": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"regionId": 310,
|
||||
"regionCode": "AR",
|
||||
"packetMode": "DELAYED",
|
||||
"senderUserId": "10001",
|
||||
"actualAccount": "10001",
|
||||
"userNickname": "Tom",
|
||||
"userAvatar": "https://example.com/avatar.png",
|
||||
"totalAmount": 15000,
|
||||
"totalCount": 5,
|
||||
"remainAmount": 15000,
|
||||
"remainCount": 5,
|
||||
"status": "WAITING_OPEN",
|
||||
"delaySeconds": 300,
|
||||
"refundedAmount": 0,
|
||||
"claimStartTime": 1780000000000,
|
||||
"expireTime": 1780086400000,
|
||||
"serverTime": 1779999700000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 行为:
|
||||
|
||||
| `scope` | 行为 |
|
||||
| --- | --- |
|
||||
| `ROOM` | 如果当前正在该房间,直接弹抢红包窗口,并更新侧边栏。 |
|
||||
| `REGION` | 作为同语区飘窗展示;点击后进入 `roomId` 对应房间并打开红包弹窗或详情。 |
|
||||
|
||||
字段说明:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `packetNo` / `packetId` | string | 红包 ID,接口参数使用 `packetNo`。 |
|
||||
| `packetMode` | string | `IMMEDIATE` 或 `DELAYED`。 |
|
||||
| `senderUserId` | string | 发送者长 ID。 |
|
||||
| `actualAccount` | string | 发送者展示账号。 |
|
||||
| `userNickname` | string | 发送者昵称。 |
|
||||
| `userAvatar` | string | 发送者头像 URL。 |
|
||||
| `totalAmount` | int64 | 红包总金币。 |
|
||||
| `totalCount` | int | 红包总份数。 |
|
||||
| `remainAmount` | int64 | 剩余总金币。CREATED 时通常等于 `totalAmount`。 |
|
||||
| `remainCount` | int | 剩余可领份数。CREATED 时通常等于 `totalCount`。 |
|
||||
| `status` | string | `ACTIVE`、`WAITING_OPEN`、`FINISHED`、`REFUNDED`。 |
|
||||
| `delaySeconds` | int64 | `DELAYED` 红包的等待秒数,普通红包为 0。 |
|
||||
| `claimStartTime` | int64 | 可领取开始时间。 |
|
||||
| `expireTime` | int64 | 过期时间。 |
|
||||
| `serverTime` | int64 | 该红包事件生成时间。 |
|
||||
|
||||
### 11.2 OPEN_DUE
|
||||
|
||||
延时红包到可领取时间后,服务端向房间群发 `OPEN_DUE`。当前 payload 使用同一结构,Flutter 只需要关心 `event/scope/packetNo/status/claimStartTime/expireTime/remainCount`。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ROOM_RED_PACKET",
|
||||
"data": {
|
||||
"event": "OPEN_DUE",
|
||||
"scope": "ROOM",
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"packetId": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"packetMode": "DELAYED",
|
||||
"senderUserId": "10001",
|
||||
"actualAccount": "10001",
|
||||
"userNickname": "Tom",
|
||||
"userAvatar": "https://example.com/avatar.png",
|
||||
"totalAmount": 15000,
|
||||
"totalCount": 5,
|
||||
"remainAmount": 15000,
|
||||
"remainCount": 5,
|
||||
"status": "WAITING_OPEN",
|
||||
"claimStartTime": 1780000000000,
|
||||
"expireTime": 1780086400000,
|
||||
"serverTime": 1779999700000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 收到后:
|
||||
|
||||
- 如果用户仍在 `roomId` 房间,强制弹出该红包或把已展示红包切换为可抢。
|
||||
- 不要在客户端发放金额;仍然调用领取接口。
|
||||
- 如果本地已经显示该红包,按 `packetNo` 覆盖更新。
|
||||
|
||||
### 11.3 STATUS_CHANGED
|
||||
|
||||
红包抢完、退款完成或状态变化时,服务端向房间群发 `STATUS_CHANGED`。
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "ROOM_RED_PACKET",
|
||||
"data": {
|
||||
"event": "STATUS_CHANGED",
|
||||
"scope": "ROOM",
|
||||
"packetNo": "VRRP2026052712340001",
|
||||
"packetId": "VRRP2026052712340001",
|
||||
"roomId": "9001",
|
||||
"status": "FINISHED",
|
||||
"remainCount": 0,
|
||||
"refundedAmount": 0,
|
||||
"serverTime": 1780000020000
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Flutter 收到后:
|
||||
|
||||
- 更新侧边栏数量。
|
||||
- 如果当前弹窗或详情页是同一个 `packetNo`,同步刷新状态。
|
||||
- `remainCount == 0` 或 `status == FINISHED/REFUNDED` 时不再展示抢按钮。
|
||||
|
||||
## 12. Flutter 状态模型建议
|
||||
|
||||
建议本地以 `packetNo` 为 key 存一份红包 read model:
|
||||
|
||||
```dart
|
||||
class VoiceRoomRedPacket {
|
||||
final String packetNo;
|
||||
final String roomId;
|
||||
final String packetMode;
|
||||
final String status;
|
||||
final String senderUserId;
|
||||
final String senderAccount;
|
||||
final String senderNickname;
|
||||
final String senderAvatar;
|
||||
final int totalAmount;
|
||||
final int totalCount;
|
||||
final int remainingAmount;
|
||||
final int remainingCount;
|
||||
final int claimStartTimeMs;
|
||||
final int expireTimeMs;
|
||||
final bool claimedByMe;
|
||||
final int myClaimAmount;
|
||||
}
|
||||
```
|
||||
|
||||
解析建议:
|
||||
|
||||
- 对 `senderUserId/userId` 这类 ID,兼容 number 和 string,统一转 string。
|
||||
- HTTP 发送成功、active、详情、领取成功里的 `packet` 使用 `senderAccount/senderNickname/senderAvatar`;IM `CREATED/OPEN_DUE` 使用 `actualAccount/userNickname/userAvatar`,落本地模型时归一到发送者字段。
|
||||
- 对金额和时间,兼容 number 和 string,统一转 int。
|
||||
- `CREATED` IM 里的 `remainAmount/remainCount` 对应 HTTP 里的 `remainingAmount/remainingCount`。
|
||||
- 如果 IM 和 HTTP 字段冲突,以 HTTP 最新查询结果为准。
|
||||
|
||||
## 13. 错误码处理
|
||||
|
||||
| HTTP code | `code` | Flutter 处理 |
|
||||
| --- | --- | --- |
|
||||
| 409 | `voice_room_red_packet_disabled` | 隐藏发送入口,提示暂不可用。 |
|
||||
| 400 | `voice_room_red_packet_amount_invalid` | 刷新配置,提示金额档位已变化。 |
|
||||
| 400 | `voice_room_red_packet_count_invalid` | 刷新配置,提示份数档位已变化。 |
|
||||
| 409 | `voice_room_red_packet_daily_limit_exceeded` | 提示今日发送次数已达上限。 |
|
||||
| 409 | `voice_room_red_packet_insufficient_balance` | 拉起充值或余额不足提示。 |
|
||||
| 404 | `voice_room_red_packet_not_found` | 关闭弹窗并刷新 active 列表。 |
|
||||
| 409 | `voice_room_red_packet_not_started` | 展示倒计时,不重试领取。 |
|
||||
| 409 | `voice_room_red_packet_expired` | 提示已过期,刷新列表和详情。 |
|
||||
| 409 | `voice_room_red_packet_finished` | 提示已抢完,刷新列表和详情。 |
|
||||
| 409 | `voice_room_red_packet_room_presence_required` | 提示需在房间内操作,重新走进房或刷新在线态。 |
|
||||
| 403 | `voice_room_region_not_match` | 用户语区和红包语区不一致,禁止领取。 |
|
||||
| 409 | `voice_room_region_missing` | 用户资料缺少语区,提示完善资料或重登刷新。 |
|
||||
| 409 | `claim_processing` | 展示处理中,可稍后重试或打开详情。 |
|
||||
|
||||
## 14. 联调检查清单
|
||||
|
||||
- 发送普通红包后,当前房间收到 `CREATED scope=ROOM`,payload 带 sender 头像昵称、金额、总份数和剩余份数。
|
||||
- 发送延时红包后,当前房间收到 `CREATED scope=ROOM`,当前语区群收到 `CREATED scope=REGION`。
|
||||
- 延时红包未到 `claimStartTime` 调领取返回 `voice_room_red_packet_not_started`。
|
||||
- 到点后房间收到 `OPEN_DUE`,Flutter 可以强弹并允许点击领取。
|
||||
- 用户不在红包所属房间时领取返回 `voice_room_red_packet_room_presence_required`。
|
||||
- 同一用户重复领取同一 `packetNo` 返回同一领取结果。
|
||||
- 抢完后房间收到 `STATUS_CHANGED`,侧边栏数量减少,抢按钮隐藏。
|
||||
- 领取记录页使用 `claim-records?page=&pageSize=`,每条记录有 `account/nickname/avatar`。
|
||||
- 重连或重新进房后调用 `active` 恢复侧边栏,不依赖历史 IM 补全状态。
|
||||
@ -1,6 +1,6 @@
|
||||
# 幸运礼物实现文档
|
||||
|
||||
本文档描述当前代码里的幸运礼物实现。旧的产品方案、重构分步文档、Flutter 单独对接文档和早期 activity-service 开发文档已经下线;后续以本文档为唯一实现说明。
|
||||
本文档描述当前代码里的幸运礼物服务端实现。旧的产品方案、重构分步文档和早期 activity-service 开发文档已经下线;服务端实现以后续本文档为准,Flutter App 对接以 [幸运礼物 Flutter 对接](flutter对接/幸运礼物Flutter对接.md) 为准。
|
||||
|
||||
当前实现不兼容旧玩法假设。幸运礼物以 `pool_id` 为规则维度,以 `activity-service` 为规则、抽奖、奖池、RTP 窗口、风控、抽奖事实和 outbox owner;扣费和返奖入账属于 `wallet-service`;房间状态、热度、礼物榜和房间 outbox 属于 `room-service`。
|
||||
|
||||
@ -8,12 +8,12 @@
|
||||
|
||||
| 功能点 | 当前规则 | 代码函数实现 | 验收 |
|
||||
| --- | --- | --- | --- |
|
||||
| 服务边界 | `activity-service` 只做幸运礼物规则和开奖结果,不改 Room Cell,不直接改钱包余额 | `services/activity-service/internal/service/luckygift.Service.Draw`、`services/activity-service/internal/service/luckygift.Service.processDrawOutbox`、`services/room-service/internal/room/service.Service.SendGift` | 已验收:抽奖在扣费后执行,返奖通过 wallet gRPC,房间表现通过 IM/outbox |
|
||||
| 服务边界 | `activity-service` 只做幸运礼物规则和开奖结果,不改 Room Cell,不直接改钱包表;返奖必须通过 wallet-service gRPC | `services/activity-service/internal/service/luckygift.Service.Draw`、`services/activity-service/internal/service/luckygift.Service.processDrawOutbox`、`services/room-service/internal/room/service.Service.SendGift` | 已验收:抽奖在扣费后执行,正常返奖走同步 wallet gRPC 快路径,outbox 只做补偿和表现 |
|
||||
| 规则维度 | 线上规则按 `pool_id` 读取最新版本,不按真实 `gift_id` 读取旧规则 | `Repository.GetLuckyGiftRuleConfig`、`Repository.getLuckyGiftRuleConfig`、`luckyPoolID` | 已验收:代码读取 `lucky_gift_rule_versions` 和 `lucky_gift_stage_tiers` |
|
||||
| 返奖率控制 | 后台发布时校验阶段期望 RTP 落在 `target_rtp_ppm ± control_band_ppm`,运行时不按 RTP 缺口改概率 | `validateRuleConfig`、`validateRuleStageExpectedRTP`、`selectLuckyCandidate` | 已验收:发布校验函数存在,候选随机只过滤不可支付档 |
|
||||
| 用户体验 | 用户阶段按累计金币流水折算等价抽数,避免不同价格礼物用真实抽数误判阶段 | `luckyEquivalentDraws`、`luckyExperiencePool`、`getLuckyUserStateForUpdate` | 已验收:状态表维护 `cumulative_wager_coins`、`equivalent_draws`、`loss_streak` |
|
||||
| 批量抽奖 | `gift_count=N` 表示抽 N 次;每一抽按单份金币计算奖励,响应返回总中奖倍数和总奖励 | `executeLuckyGiftDrawBatch`、`luckyDrawUnitSpend`、`stageLuckyBatchDraw`、`luckyAggregateDrawResults` | 已验收:单测覆盖 10 抽返回总倍数 11x 的聚合逻辑 |
|
||||
| 聚合发放 | 批量抽奖可以有 N 条抽奖明细,但只写 1 条 `LuckyGiftDrawn` outbox,钱包只加币 1 次 | `insertLuckyAggregateDrawOutbox`、`decodeDrawOutboxPayload`、`MarkLuckyGiftDrawsGranted` | 已验收:worker 单测覆盖 `draw_ids` 批量 granted |
|
||||
| 聚合发放 | 批量抽奖可以有 N 条抽奖明细,但正常路径只同步给钱包加币 1 次;outbox 保留 1 条聚合事件用于补偿和表现 | `insertLuckyAggregateDrawOutbox`、`creditLuckyGiftRewardFastPath`、`decodeDrawOutboxPayload`、`MarkLuckyGiftDrawsGranted` | 已验收:批量只按聚合 draw 发放,worker 遇到已 granted 会跳过钱包 |
|
||||
|
||||
## 二、配置模型
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
| 功能点 | 字段或 RPC | 代码函数实现 | 验收 |
|
||||
| --- | --- | --- | --- |
|
||||
| App/房间抽奖元信息 | `LuckyGiftMeta`,包含 `command_id`、`pool_id`、`gift_count`、`coin_spent`、`paid_at_ms`、`visible_region_id` | `api/proto/activity/v1/activity.proto` `LuckyGiftMeta`、`LuckyGiftServer.ExecuteLuckyGiftDraw` | 已验收:房间送礼只把 wallet 已结算 `coin_spent` 传给 activity |
|
||||
| 抽奖结果 | `LuckyGiftDrawResult`,包含 `multiplier_ppm`、`effective_reward_coins`、`reward_status`、RTP 窗口和阶段字段 | `luckyDrawResultToProto`、`luckyGiftResultFromProto` | 已验收:room 返回 `LuckyGift` 字段给送礼用户 |
|
||||
| 抽奖结果 | `LuckyGiftDrawResult`,包含 `multiplier_ppm`、`effective_reward_coins`、`reward_status`、`wallet_transaction_id`、`coin_balance_after`、RTP 窗口和阶段字段 | `luckyDrawResultToProto`、`luckyGiftResultFromProto` | 已验收:room 返回 `LuckyGift` 字段给送礼用户,中奖同步成功时可直接刷新余额 |
|
||||
| 后台规则 | `LuckyGiftRuleConfig`、`LuckyGiftRuleStage`、`LuckyGiftRuleTier` | `luckyRuleConfigFromProto`、`luckyRuleConfigToProto`、`configToProto`、`configFromProto` | 已验收:后台百分比和倍率在 handler 内转成 ppm |
|
||||
| 后台审计 | `ListLuckyGiftDraws`、`GetLuckyGiftDrawSummary` | `AdminLuckyGiftServer.ListLuckyGiftDraws`、`AdminLuckyGiftServer.GetLuckyGiftDrawSummary` | 已验收:按 `pool_id/gift_id/user_id/room_id/status` 查询 |
|
||||
|
||||
@ -69,7 +69,7 @@
|
||||
| 先扣费 | 房间链路先调用 wallet 扣费,成功后才调用 activity 抽奖 | `Service.SendGift`、`wallet.DebitGift` 调用段 | 已验收:抽奖使用 `billing.GetCoinSpent()`,不信任客户端价格 |
|
||||
| 抽奖命令 | 使用房间命令 ID 作为幂等根 ID | `Service.SendGift`、`LuckyGiftServer.ExecuteLuckyGiftDraw`、`Service.Draw` | 已验收:重复命令由抽奖记录幂等返回 |
|
||||
| 设备和主播 scope | 设备优先取 session_id,主播优先取房主 ID | `luckyGiftDeviceID`、`luckyGiftAnchorID` | 已验收:scope 不为空,风控计数可落表 |
|
||||
| 同步响应 | 当前送礼用户立即拿到抽奖结果 | `luckyGiftResultFromProto`、`roomv1.SendGiftResponse.LuckyGift` | 已验收:room response 带 `lucky_gift` |
|
||||
| 同步响应 | 当前送礼用户立即拿到抽奖结果;中奖快路径成功时同时拿到返奖交易 ID 和账后余额 | `creditLuckyGiftRewardFastPath`、`luckyGiftResultFromProto`、`roomv1.SendGiftResponse.LuckyGift` | 已验收:room response 带 `lucky_gift.wallet_transaction_id` 和 `lucky_gift.coin_balance_after` |
|
||||
| 房间事实 | 送礼本身仍写 Room Cell、热度、榜单和 room outbox | `Service.SendGift`、`outbox.Build` | 已验收:幸运礼物不替代普通送礼事实 |
|
||||
|
||||
## 四、抽奖事务
|
||||
@ -111,16 +111,17 @@
|
||||
| 聚合响应 | 批量响应把金额、倍数、阶段反馈和高倍标记聚合 | `luckyAggregateDrawResults`、`luckyAggregateBudgetSourcesJSON` | 已验收:总倍数为各命中倍数相加,不是平均倍率 |
|
||||
| 聚合发放 | 整批只写一条 outbox,payload 里有聚合 `draw_id` 和全部 `draw_ids` | `insertLuckyAggregateDrawOutbox` | 已验收:待发放队列按送礼命令增长,不按中奖子抽增长 |
|
||||
|
||||
## 五、返奖和房间表现
|
||||
## 五、返奖快路径和房间表现
|
||||
|
||||
| 功能点 | 行为 | 代码函数实现 | 验收 |
|
||||
| --- | --- | --- | --- |
|
||||
| worker 循环 | 持续 claim `LuckyGiftDrawn` outbox | `Service.RunWorker`、`Service.ProcessPendingDrawOutbox`、`Repository.ClaimPendingLuckyGiftOutbox` | 已验收:claim 使用 `delivering` 锁和 lock TTL |
|
||||
| 同步返奖快路径 | 抽奖事务提交后,如果 `effective_reward_coins > 0`,立即调用 wallet-service `CreditLuckyGiftReward`;成功后把 draw 标记为 `granted` 并把交易 ID、账后余额写回响应 | `Service.Draw`、`creditLuckyGiftRewardFastPath`、`creditLuckyGiftReward`、`MarkLuckyGiftDrawsGranted` | 已验收:不在 activity MySQL 事务里调用 wallet,成功响应可直接给 App 刷余额 |
|
||||
| worker 循环 | 持续 claim `LuckyGiftDrawn` outbox,但它现在是补偿和表现投递路径,不是正常发币主路径 | `Service.RunWorker`、`Service.ProcessPendingDrawOutbox`、`Repository.ClaimPendingLuckyGiftOutbox` | 已验收:claim 使用 `delivering` 锁和 lock TTL |
|
||||
| payload 兼容 | 新 payload 支持 `draw_ids`,老 payload 只有单个 `draw_id` 时回填 | `decodeDrawOutboxPayload` | 已验收:单抽旧 payload 仍能发放 |
|
||||
| 钱包返奖 | 用聚合 `draw_id` 生成钱包幂等 command,只加币一次 | `Service.processDrawOutbox`、`walletv1.CreditLuckyGiftReward` 调用 | 已验收:批量 worker 单测覆盖一次钱包请求 |
|
||||
| 房间开奖结果 IM | worker 投递 `lucky_gift_drawn` 房间群消息 | `publishLuckyGiftDrawn` | 已验收:发布失败时 outbox 可重试,不提前 granted |
|
||||
| 钱包补偿 | worker 先检查 draw 是否已 `granted`;已发奖则跳过 wallet,未发奖才用聚合 `draw_id` 生成幂等 command 补发一次 | `Service.processDrawOutbox`、`GetLuckyGiftDrawRewardState`、`walletv1.CreditLuckyGiftReward` 调用 | 已验收:wallet 成功但 activity 标记失败时,worker 可用同一 command 补标;已 granted 不重复加币 |
|
||||
| 房间开奖结果 IM | worker 投递 `lucky_gift_drawn` 房间群消息 | `publishLuckyGiftDrawn` | 已验收:发布失败时 outbox 可重试;已返奖的 draw 不会因为 IM 失败被标 failed |
|
||||
| 区域播报 | 符合条件的中奖写区域播报 outbox | `shouldPublishLuckyGiftRegionBroadcast`、`publishLuckyGiftRegionBroadcast`、`luckyGiftRegionBroadcastPayload` | 已验收:当前阈值为有区域、有效奖励、聚合倍数不低于 1x |
|
||||
| 成功收敛 | 钱包、区域播报、房间 IM 都成功后批量标记 draw records 为 granted | `MarkLuckyGiftDrawsGranted`、`markLuckyGiftDraws`、`MarkLuckyGiftOutboxDelivered` | 已验收:批量更新按 500 个 draw_id 分片 |
|
||||
| 成功收敛 | 钱包成功后 draw records 先收敛为 `granted`;区域播报和房间 IM 成功后 outbox 标记 delivered | `MarkLuckyGiftDrawsGranted`、`markLuckyGiftDraws`、`MarkLuckyGiftOutboxDelivered` | 已验收:批量更新按 500 个 draw_id 分片 |
|
||||
| 失败收敛 | 未返奖且达到最大重试时,批量标记 pending draw failed | `markOutboxFailed`、`MarkLuckyGiftDrawsFailed` | 已验收:已返奖但 IM 失败不会覆盖账务事实 |
|
||||
| 重试退避 | outbox 失败按指数退避重试 | `luckyGiftBackoff`、`MarkLuckyGiftOutboxRetryable` | 已验收:错误保存在 `activity_outbox.last_error` |
|
||||
|
||||
@ -148,7 +149,7 @@
|
||||
| `lucky_user_states` | 用户阶段、累计流水、等价抽数和连亏 | `getLuckyUserState`、`getLuckyUserStateForUpdate`、`persistLuckyBatchDraw`、`applyLuckyDraw` | 已验收:包含 `cumulative_wager_coins`、`equivalent_draws`、`loss_streak` |
|
||||
| `lucky_risk_counters` | 风控窗口累计返奖 | `getLuckyRiskCounters`、`getOrCreateLuckyRiskCounter`、`updateLuckyRiskCounters` | 已验收:用户小时、用户日、设备日、房间小时、主播日五类 scope |
|
||||
| `lucky_draw_records` | 单抽审计事实 | `insertLuckyDrawRecords`、`applyLuckyDraw`、`ListLuckyGiftDraws` | 已验收:批量送礼仍保留单抽粒度 |
|
||||
| `activity_outbox` | 幸运礼物返奖和表现副作用 | `insertLuckyAggregateDrawOutbox`、`ClaimPendingLuckyGiftOutbox`、`MarkLuckyGiftOutboxDelivered` | 已验收:批量送礼只写一条 `LuckyGiftDrawn` outbox |
|
||||
| `activity_outbox` | 幸运礼物钱包补偿和表现副作用,不再承载正常发币主路径 | `insertLuckyAggregateDrawOutbox`、`ClaimPendingLuckyGiftOutbox`、`MarkLuckyGiftOutboxDelivered` | 已验收:批量送礼只写一条 `LuckyGiftDrawn` outbox,已 granted 时 worker 跳过 wallet |
|
||||
|
||||
## 八、运行边界
|
||||
|
||||
@ -159,7 +160,7 @@
|
||||
| 小额礼物 | 阶段按累计流水折算,单抽奖励按单份金额缩放,风控上限不被缩小 | `luckyRuntimeConfig`、`luckyEquivalentDraws`、`luckyRiskCapacity` | 已验收:单测覆盖大额后小额仍可中小奖 |
|
||||
| 高倍奖 | 发布校验要求高倍档 `HighWaterOnly`,运行时仍受奖池和风控过滤 | `validateRuleStage`、`selectLuckyCandidate` | 已验收:高倍率未标高水位会拒绝发布 |
|
||||
| 批量 777 份 | 抽 777 次,保留 777 条明细,状态写入和 outbox 聚合 | `executeOptimizedLuckyGiftDrawBatch`、`stageLuckyBatchDraw`、`persistLuckyBatchDraw` | 已验收:代码结构已移出循环内 SQL 状态写入 |
|
||||
| worker 重试 | 钱包用聚合 `draw_id` 幂等,outbox 失败可重试 | `processDrawOutbox`、`MarkLuckyGiftOutboxRetryable` | 已验收:成功后 delivered,不重复加币 |
|
||||
| worker 重试 | 钱包补偿用聚合 `draw_id` 幂等,outbox 失败可重试;已 `granted` 的 draw 只补 IM/播报,不重复加币 | `processDrawOutbox`、`GetLuckyGiftDrawRewardState`、`MarkLuckyGiftOutboxRetryable` | 已验收:成功后 delivered,不重复加币 |
|
||||
| 已返奖但 IM 失败 | 不把 draw 标 failed,只让 outbox failed 并保留告警空间 | `markOutboxFailed` | 已验收:`alreadyCredited` 分支不覆盖 draw 状态 |
|
||||
|
||||
## 九、代码验收清单
|
||||
|
||||
@ -148,7 +148,7 @@
|
||||
| 活动配置 | `TODO` | 只有 activity status projection | 活动规则、时间、奖池、风控配置未实现 |
|
||||
| LuckyGift Check | `DONE` | `CheckLuckyGift` RPC、gateway 检查接口和 room-service 扣费前检查已实现 | App 礼物面板按 `pool_id` 联调 |
|
||||
| LuckyGift Draw | `DONE` | `ExecuteLuckyGiftDraw`、三层奖池、RTP 窗口、风控计数、批量抽奖和幂等已实现 | 运营配置和长压测继续补 |
|
||||
| 奖励入账 | `DONE` | activity outbox worker 调 wallet `CreditLuckyGiftReward`,批量抽奖按一次送礼聚合发放 | 线上监控和积压告警需补 |
|
||||
| 奖励入账 | `DONE` | 幸运礼物中奖后优先同步调 wallet `CreditLuckyGiftReward` 入账并把余额返回给 App;activity outbox worker 只做钱包补偿、IM 和区域播报 | 同步发奖成功率、补偿 pending age 和播报积压告警需补 |
|
||||
| 幸运礼物审计 | `DONE` | `lucky_draw_records`、后台 draws 和 draw-summary 查询已实现 | 后台可视化维度继续补 |
|
||||
|
||||
## Messaging Product Surface
|
||||
|
||||
@ -40,11 +40,20 @@ type ParsedGroup struct {
|
||||
// GlobalBroadcastGroupID 生成 App 全局播报群 ID。
|
||||
// app_code 先 Normalize 再进入 GroupID,确保 HTTP、gRPC、outbox 和腾讯回调看到的是同一租户标识。
|
||||
func GlobalBroadcastGroupID(value string) (string, error) {
|
||||
return GlobalBroadcastGroupIDWithPrefix("", value)
|
||||
}
|
||||
|
||||
// GlobalBroadcastGroupIDWithPrefix 生成带环境前缀的 App 全局播报群 ID。
|
||||
func GlobalBroadcastGroupIDWithPrefix(prefix string, value string) (string, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if err := validateGroupIDPrefix(prefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
app := appcode.Normalize(value)
|
||||
if err := validateAppCodeForGroupID(app); err != nil {
|
||||
return "", err
|
||||
}
|
||||
groupID := "hy_" + app + globalSuffix
|
||||
groupID := prefix + "hy_" + app + globalSuffix
|
||||
if len(groupID) > roomid.MaxStringIDLength {
|
||||
return "", fmt.Errorf("global broadcast group_id is too long")
|
||||
}
|
||||
@ -54,6 +63,15 @@ func GlobalBroadcastGroupID(value string) (string, error) {
|
||||
// RegionBroadcastGroupID 生成区域播报群 ID。
|
||||
// region_id <= 0 被拒绝,GLOBAL/未知区域不能变成一个可 join 的区域群。
|
||||
func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
|
||||
return RegionBroadcastGroupIDWithPrefix("", value, regionID)
|
||||
}
|
||||
|
||||
// RegionBroadcastGroupIDWithPrefix 生成带环境前缀的区域播报群 ID。
|
||||
func RegionBroadcastGroupIDWithPrefix(prefix string, value string, regionID int64) (string, error) {
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if err := validateGroupIDPrefix(prefix); err != nil {
|
||||
return "", err
|
||||
}
|
||||
app := appcode.Normalize(value)
|
||||
if err := validateAppCodeForGroupID(app); err != nil {
|
||||
return "", err
|
||||
@ -61,7 +79,7 @@ func RegionBroadcastGroupID(value string, regionID int64) (string, error) {
|
||||
if regionID <= 0 {
|
||||
return "", fmt.Errorf("region_id must be positive")
|
||||
}
|
||||
groupID := "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
|
||||
groupID := prefix + "hy_" + app + regionToken + strconv.FormatInt(regionID, 10)
|
||||
if len(groupID) > roomid.MaxStringIDLength {
|
||||
return "", fmt.Errorf("region broadcast group_id is too long")
|
||||
}
|
||||
@ -76,6 +94,42 @@ func Parse(groupID string) ParsedGroup {
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
}
|
||||
|
||||
if parsed := parseBroadcastGroupID(groupID); parsed.Kind != KindUnknown {
|
||||
return parsed
|
||||
}
|
||||
if strings.HasPrefix(groupID, "hy_") && strings.Contains(groupID, "_bc_") {
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
}
|
||||
|
||||
if roomid.ValidStringID(groupID) {
|
||||
return ParsedGroup{Kind: KindRoom, RoomID: groupID}
|
||||
}
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
}
|
||||
|
||||
// ParseWithPrefix 使用配置的环境前缀解析腾讯云回调里的 GroupID。
|
||||
func ParseWithPrefix(groupID string, prefix string) ParsedGroup {
|
||||
groupID = strings.TrimSpace(groupID)
|
||||
prefix = strings.TrimSpace(prefix)
|
||||
if groupID == "" || validateGroupIDPrefix(prefix) != nil {
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
}
|
||||
if prefix == "" {
|
||||
return Parse(groupID)
|
||||
}
|
||||
if after, ok := strings.CutPrefix(groupID, prefix); ok {
|
||||
return parseBroadcastGroupID(after)
|
||||
}
|
||||
if strings.HasPrefix(groupID, "hy_") && strings.Contains(groupID, "_bc_") {
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
}
|
||||
if roomid.ValidStringID(groupID) {
|
||||
return ParsedGroup{Kind: KindRoom, RoomID: groupID}
|
||||
}
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
}
|
||||
|
||||
func parseBroadcastGroupID(groupID string) ParsedGroup {
|
||||
if strings.HasPrefix(groupID, "hy_") && strings.HasSuffix(groupID, globalSuffix) {
|
||||
app := strings.TrimSuffix(strings.TrimPrefix(groupID, "hy_"), globalSuffix)
|
||||
if validateAppCodeForGroupID(app) == nil {
|
||||
@ -98,12 +152,32 @@ func Parse(groupID string) ParsedGroup {
|
||||
}
|
||||
}
|
||||
|
||||
if roomid.ValidStringID(groupID) {
|
||||
return ParsedGroup{Kind: KindRoom, RoomID: groupID}
|
||||
}
|
||||
return ParsedGroup{Kind: KindUnknown}
|
||||
}
|
||||
|
||||
func validateGroupIDPrefix(prefix string) error {
|
||||
if prefix == "" {
|
||||
return nil
|
||||
}
|
||||
for i := 0; i < len(prefix); i++ {
|
||||
char := prefix[i]
|
||||
if char >= 'a' && char <= 'z' {
|
||||
continue
|
||||
}
|
||||
if char >= 'A' && char <= 'Z' {
|
||||
continue
|
||||
}
|
||||
if char >= '0' && char <= '9' {
|
||||
continue
|
||||
}
|
||||
if char == '_' || char == '-' {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("group_id_prefix contains unsupported character")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAppCodeForGroupID(app string) error {
|
||||
if app == "" {
|
||||
return fmt.Errorf("app_code is required")
|
||||
|
||||
@ -42,3 +42,35 @@ func TestParseRoomAndRejectInvalidBroadcast(t *testing.T) {
|
||||
t.Fatal("app_code with spaces must be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBroadcastGroupIDWithPrefix(t *testing.T) {
|
||||
global, err := GlobalBroadcastGroupIDWithPrefix("test_", "lalu")
|
||||
if err != nil {
|
||||
t.Fatalf("GlobalBroadcastGroupIDWithPrefix failed: %v", err)
|
||||
}
|
||||
if global != "test_hy_lalu_bc_g" {
|
||||
t.Fatalf("prefixed global group mismatch: %s", global)
|
||||
}
|
||||
region, err := RegionBroadcastGroupIDWithPrefix("test_", "lalu", 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("RegionBroadcastGroupIDWithPrefix failed: %v", err)
|
||||
}
|
||||
if region != "test_hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("prefixed region group mismatch: %s", region)
|
||||
}
|
||||
|
||||
parsedGlobal := ParseWithPrefix(global, "test_")
|
||||
if parsedGlobal.Kind != KindGlobalBroadcast || parsedGlobal.AppCode != "lalu" {
|
||||
t.Fatalf("prefixed global parse mismatch: %+v", parsedGlobal)
|
||||
}
|
||||
parsedRegion := ParseWithPrefix(region, "test_")
|
||||
if parsedRegion.Kind != KindRegionBroadcast || parsedRegion.AppCode != "lalu" || parsedRegion.RegionID != 1001 {
|
||||
t.Fatalf("prefixed region parse mismatch: %+v", parsedRegion)
|
||||
}
|
||||
if parsed := ParseWithPrefix("hy_lalu_bc_r_1001", "test_"); parsed.Kind != KindUnknown {
|
||||
t.Fatalf("unprefixed broadcast group must be rejected when prefix is configured: %+v", parsed)
|
||||
}
|
||||
if parsed := ParseWithPrefix("room-1001", "test_"); parsed.Kind != KindRoom || parsed.RoomID != "room-1001" {
|
||||
t.Fatalf("room parse with prefix mismatch: %+v", parsed)
|
||||
}
|
||||
}
|
||||
|
||||
92
run/im-c2c-broadcast-20260602-061847.jsonl
Normal file
92
run/im-c2c-broadcast-20260602-061847.jsonl
Normal file
@ -0,0 +1,92 @@
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"424242","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"12345678","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585934","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"880585935","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586014","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"881586015","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586332","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"882586333","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000001","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990000002","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686001","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686002","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686003","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686004","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686005","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686006","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686007","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686008","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686009","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990686010","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687001","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687002","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687003","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687004","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687005","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687006","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687007","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687008","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687009","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990687010","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688001","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688002","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688003","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688004","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688005","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688006","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688007","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688008","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688009","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990688010","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971589","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971590","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971617","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971618","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971718","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900000971719","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651195","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651196","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651197","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"900503651198","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935471","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935472","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935473","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004935474","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936181","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936182","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936183","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"910004936184","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000001","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"930000000002","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912277","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975824912278","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864464","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975825864465","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047904","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975827047905","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064123","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"975828064124","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778061358","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"990778062287","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"991205120003","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991803","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778070991804","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196761","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"1778071196762","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865153","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865154","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865155","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865156","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865157","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865158","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865159","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865160","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865161","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"7049835865162","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318705991371722752","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707093894860800","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707097803952128","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707100643495936","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707103256547328","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"318707105987039232","status":"dry_run"}
|
||||
{"at_ms":1780381127156,"mode":"dry-run","to_account":"320078559995498496","status":"dry_run"}
|
||||
343
scripts/ops/grant_all_users_coin.py
Executable file
343
scripts/ops/grant_all_users_coin.py
Executable file
@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Grant COIN to every current production user.
|
||||
|
||||
This script intentionally creates a fresh command_id for every run. Running it
|
||||
twice credits users twice. If a run fails halfway, re-running it credits the
|
||||
already processed users again, matching the requested non-idempotent behavior.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import shlex
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pymysql
|
||||
import yaml
|
||||
|
||||
|
||||
ASSET_COIN = "COIN"
|
||||
BIZ_TYPE_MANUAL_CREDIT = "manual_credit"
|
||||
OUTBOX_STATUS_PENDING = "pending"
|
||||
TRANSACTION_STATUS_SUCCEEDED = "succeeded"
|
||||
MAX_INT64 = 2**63 - 1
|
||||
|
||||
|
||||
def normalize_app_code(value: str) -> str:
|
||||
value = (value or "").strip().lower()
|
||||
return value or "lalu"
|
||||
|
||||
|
||||
def stable_hash(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def transaction_id(app_code: str, command_id: str) -> str:
|
||||
return "wtx_" + stable_hash(f"{normalize_app_code(app_code)}|{command_id}")
|
||||
|
||||
|
||||
def event_id(tx_id: str, event_type: str, user_id: int, asset_type: str) -> str:
|
||||
return "wev_" + stable_hash(f"{tx_id}|{event_type}|{user_id}|{asset_type}")
|
||||
|
||||
|
||||
def admin_credit_request_hash(
|
||||
app_code: str,
|
||||
target_user_id: int,
|
||||
asset_type: str,
|
||||
amount: int,
|
||||
operator_user_id: int,
|
||||
reason: str,
|
||||
evidence_ref: str,
|
||||
) -> str:
|
||||
return stable_hash(
|
||||
f"admin_credit|{normalize_app_code(app_code)}|{target_user_id}|{asset_type}|{amount}|{operator_user_id}|{reason}|{evidence_ref}"
|
||||
)
|
||||
|
||||
|
||||
def load_yaml(path: str) -> dict[str, Any]:
|
||||
payload = yaml.safe_load(read_config_text(path)) or {}
|
||||
if not isinstance(payload, dict):
|
||||
raise RuntimeError(f"config must be a YAML object: {path}")
|
||||
return payload
|
||||
|
||||
|
||||
def read_config_text(location: str) -> str:
|
||||
location = location.strip()
|
||||
if looks_like_ssh_location(location):
|
||||
host, remote_path = location.split(":", 1)
|
||||
return subprocess.check_output(
|
||||
["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no", "-o", "ConnectTimeout=5", host, "cat " + shlex.quote(remote_path)],
|
||||
text=True,
|
||||
)
|
||||
return Path(location).expanduser().read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def looks_like_ssh_location(location: str) -> bool:
|
||||
if location.startswith("/") or location.startswith("./") or location.startswith("../") or "://" in location:
|
||||
return False
|
||||
head, sep, tail = location.partition(":")
|
||||
return bool(sep and tail and ("@" in head or "." in head or head.replace("-", "").isalnum()))
|
||||
|
||||
|
||||
def parse_go_mysql_dsn(dsn: str) -> dict[str, Any]:
|
||||
# Supports the project form: USER:PASSWORD@tcp(HOST:PORT)/DATABASE?params.
|
||||
prefix, rest = dsn.split("@tcp(", 1)
|
||||
user, password = prefix.split(":", 1) if ":" in prefix else (prefix, "")
|
||||
addr, rest = rest.split(")/", 1)
|
||||
host, port = addr.rsplit(":", 1) if ":" in addr else (addr, "3306")
|
||||
database = rest.split("?", 1)[0]
|
||||
return {
|
||||
"host": host,
|
||||
"port": int(port),
|
||||
"user": user,
|
||||
"password": password,
|
||||
"database": database,
|
||||
"charset": "utf8mb4",
|
||||
"autocommit": False,
|
||||
"connect_timeout": 8,
|
||||
"read_timeout": 60,
|
||||
"write_timeout": 60,
|
||||
}
|
||||
|
||||
|
||||
def connect_from_config(path: str):
|
||||
cfg = load_yaml(path)
|
||||
dsn = str(cfg.get("mysql_dsn") or "").strip()
|
||||
if not dsn:
|
||||
raise RuntimeError(f"mysql_dsn is required in {path}")
|
||||
return pymysql.connect(**parse_go_mysql_dsn(dsn))
|
||||
|
||||
|
||||
def current_ms() -> int:
|
||||
return int(time.time() * 1000)
|
||||
|
||||
|
||||
def fetch_users(conn, app_code: str, statuses: list[str]) -> list[int]:
|
||||
where = ["app_code = %s"]
|
||||
args: list[Any] = [app_code]
|
||||
if statuses:
|
||||
where.append("status IN (" + ",".join(["%s"] * len(statuses)) + ")")
|
||||
args.extend(statuses)
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(f"SELECT user_id FROM users WHERE {' AND '.join(where)} ORDER BY user_id ASC", args)
|
||||
return [int(row[0]) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def fetch_user_status_counts(conn, app_code: str) -> dict[str, int]:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute("SELECT status, COUNT(*) FROM users WHERE app_code = %s GROUP BY status ORDER BY status", (app_code,))
|
||||
return {str(status): int(count) for status, count in cursor.fetchall()}
|
||||
|
||||
|
||||
def grant_one(conn, *, app_code: str, user_id: int, amount: int, operator_user_id: int, reason: str, evidence_ref: str, run_id: str) -> tuple[str, int]:
|
||||
command_id = f"bulk_coin_grant:{run_id}:{user_id}"
|
||||
tx_id = transaction_id(app_code, command_id)
|
||||
req_hash = admin_credit_request_hash(app_code, user_id, ASSET_COIN, amount, operator_user_id, reason, evidence_ref)
|
||||
now_ms = current_ms()
|
||||
metadata = {
|
||||
"app_code": app_code,
|
||||
"target_user_id": user_id,
|
||||
"asset_type": ASSET_COIN,
|
||||
"amount": amount,
|
||||
"operator_user_id": operator_user_id,
|
||||
"reason": reason,
|
||||
"evidence_ref": evidence_ref,
|
||||
}
|
||||
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute("START TRANSACTION")
|
||||
# Positive manual credit may create a missing COIN account; version then advances exactly like wallet-service.
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT IGNORE INTO wallet_accounts (
|
||||
app_code, user_id, asset_type, available_amount, frozen_amount, version, created_at_ms, updated_at_ms
|
||||
) VALUES (%s, %s, %s, 0, 0, 1, %s, %s)
|
||||
""",
|
||||
(app_code, user_id, ASSET_COIN, now_ms, now_ms),
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT available_amount, frozen_amount, version
|
||||
FROM wallet_accounts
|
||||
WHERE app_code = %s AND user_id = %s AND asset_type = %s
|
||||
FOR UPDATE
|
||||
""",
|
||||
(app_code, user_id, ASSET_COIN),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if row is None:
|
||||
raise RuntimeError(f"wallet account creation failed for user_id={user_id}")
|
||||
available, frozen, version = int(row[0]), int(row[1]), int(row[2])
|
||||
available_after = available + amount
|
||||
if available_after < 0 or available_after > MAX_INT64 - frozen:
|
||||
raise RuntimeError(f"wallet balance overflow for user_id={user_id}")
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO wallet_transactions (
|
||||
app_code, transaction_id, command_id, biz_type, status, request_hash, external_ref,
|
||||
metadata_json, created_at_ms, updated_at_ms
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
app_code,
|
||||
tx_id,
|
||||
command_id,
|
||||
BIZ_TYPE_MANUAL_CREDIT,
|
||||
TRANSACTION_STATUS_SUCCEEDED,
|
||||
req_hash,
|
||||
evidence_ref,
|
||||
json.dumps(metadata, separators=(",", ":"), ensure_ascii=False),
|
||||
now_ms,
|
||||
now_ms,
|
||||
),
|
||||
)
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE wallet_accounts
|
||||
SET available_amount = %s, frozen_amount = %s, version = version + 1, updated_at_ms = %s
|
||||
WHERE app_code = %s AND user_id = %s AND asset_type = %s AND version = %s
|
||||
""",
|
||||
(available_after, frozen, now_ms, app_code, user_id, ASSET_COIN, version),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise RuntimeError(f"wallet account version conflict for user_id={user_id}")
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO wallet_entries (
|
||||
app_code, transaction_id, user_id, asset_type, available_delta, frozen_delta,
|
||||
available_after, frozen_after, counterparty_user_id, room_id, created_at_ms
|
||||
) VALUES (%s, %s, %s, %s, %s, 0, %s, %s, 0, '', %s)
|
||||
""",
|
||||
(app_code, tx_id, user_id, ASSET_COIN, amount, available_after, frozen, now_ms),
|
||||
)
|
||||
|
||||
event_payload = {
|
||||
"transaction_id": tx_id,
|
||||
"command_id": command_id,
|
||||
"user_id": user_id,
|
||||
"asset_type": ASSET_COIN,
|
||||
"available_delta": amount,
|
||||
"frozen_delta": 0,
|
||||
"available_after": available_after,
|
||||
"frozen_after": frozen,
|
||||
"balance_version": version + 1,
|
||||
"metadata": metadata,
|
||||
"created_at_ms": now_ms,
|
||||
}
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO wallet_outbox (
|
||||
app_code, event_id, event_type, transaction_id, command_id, user_id, asset_type,
|
||||
available_delta, frozen_delta, payload, status, retry_count, created_at_ms, updated_at_ms
|
||||
) VALUES (%s, %s, 'WalletBalanceChanged', %s, %s, %s, %s, %s, 0, %s, %s, 0, %s, %s)
|
||||
""",
|
||||
(
|
||||
app_code,
|
||||
event_id(tx_id, "WalletBalanceChanged", user_id, ASSET_COIN),
|
||||
tx_id,
|
||||
command_id,
|
||||
user_id,
|
||||
ASSET_COIN,
|
||||
amount,
|
||||
json.dumps(event_payload, separators=(",", ":"), ensure_ascii=False),
|
||||
OUTBOX_STATUS_PENDING,
|
||||
now_ms,
|
||||
now_ms,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return tx_id, available_after
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Non-idempotently grant COIN to every current user.")
|
||||
parser.add_argument(
|
||||
"--user-config",
|
||||
default="services/user-service/configs/config.yaml",
|
||||
help="YAML containing user-service mysql_dsn. Supports local path or user@host:/path/config.yaml.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wallet-config",
|
||||
default="services/wallet-service/configs/config.yaml",
|
||||
help="YAML containing wallet-service mysql_dsn. Supports local path or user@host:/path/config.yaml.",
|
||||
)
|
||||
parser.add_argument("--app-code", default="lalu", help="Tenant app_code to grant.")
|
||||
parser.add_argument("--amount", type=int, default=1_000_000, help="COIN amount credited to each user per execution.")
|
||||
parser.add_argument("--operator-user-id", type=int, default=0, help="Required with --yes; stored in manual credit metadata.")
|
||||
parser.add_argument("--reason", default="bulk coin grant to all users", help="Manual credit reason stored with every transaction.")
|
||||
parser.add_argument("--evidence-ref-prefix", default="ops-bulk-coin-grant", help="Evidence reference prefix; run_id and user_id are appended.")
|
||||
parser.add_argument("--user-status", action="append", default=[], help="Optional repeatable users.status filter. Omit to include all users.")
|
||||
parser.add_argument("--progress-every", type=int, default=100, help="Print progress every N credited users.")
|
||||
parser.add_argument("--yes", action="store_true", help="Actually write wallet credits. Omit for dry-run.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
app_code = normalize_app_code(args.app_code)
|
||||
if args.amount <= 0 or args.amount > MAX_INT64:
|
||||
raise RuntimeError("--amount must be a positive int64")
|
||||
if args.yes and args.operator_user_id <= 0:
|
||||
raise RuntimeError("--operator-user-id is required with --yes")
|
||||
if args.yes and not args.reason.strip():
|
||||
raise RuntimeError("--reason is required with --yes")
|
||||
|
||||
user_conn = connect_from_config(args.user_config)
|
||||
wallet_conn = connect_from_config(args.wallet_config)
|
||||
try:
|
||||
status_counts = fetch_user_status_counts(user_conn, app_code)
|
||||
users = fetch_users(user_conn, app_code, [status.strip() for status in args.user_status if status.strip()])
|
||||
summary = {
|
||||
"mode": "execute" if args.yes else "dry_run",
|
||||
"app_code": app_code,
|
||||
"amount_per_user": args.amount,
|
||||
"target_user_count": len(users),
|
||||
"total_coin_delta": len(users) * args.amount,
|
||||
"user_status_counts": status_counts,
|
||||
"user_status_filter": args.user_status,
|
||||
}
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
if not args.yes:
|
||||
print("dry-run only; rerun with --yes --operator-user-id <id> to credit users")
|
||||
return 0
|
||||
|
||||
run_id = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime()) + "_" + uuid.uuid4().hex[:12]
|
||||
credited = 0
|
||||
for user_id in users:
|
||||
evidence_ref = f"{args.evidence_ref_prefix}:{run_id}:{user_id}"
|
||||
grant_one(
|
||||
wallet_conn,
|
||||
app_code=app_code,
|
||||
user_id=user_id,
|
||||
amount=args.amount,
|
||||
operator_user_id=args.operator_user_id,
|
||||
reason=args.reason.strip(),
|
||||
evidence_ref=evidence_ref,
|
||||
run_id=run_id,
|
||||
)
|
||||
credited += 1
|
||||
if args.progress_every > 0 and (credited % args.progress_every == 0 or credited == len(users)):
|
||||
print(json.dumps({"run_id": run_id, "credited": credited, "total": len(users)}, ensure_ascii=False))
|
||||
print(json.dumps({"ok": True, "run_id": run_id, "credited": credited, "amount_per_user": args.amount}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
finally:
|
||||
user_conn.close()
|
||||
wallet_conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@ -36,6 +36,7 @@ import (
|
||||
dashboardmodule "hyapp-admin-server/internal/modules/dashboard"
|
||||
firstrechargerewardmodule "hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
gamemanagementmodule "hyapp-admin-server/internal/modules/gamemanagement"
|
||||
giftdiamondmodule "hyapp-admin-server/internal/modules/giftdiamond"
|
||||
healthmodule "hyapp-admin-server/internal/modules/health"
|
||||
hostagencypolicymodule "hyapp-admin-server/internal/modules/hostagencypolicy"
|
||||
hostorgmodule "hyapp-admin-server/internal/modules/hostorg"
|
||||
@ -229,6 +230,7 @@ func main() {
|
||||
Dashboard: dashboardmodule.New(store, cfg),
|
||||
FirstRechargeReward: firstrechargerewardmodule.New(activityclient.NewGRPC(activityConn), userDB, auditHandler),
|
||||
Game: gamemanagementmodule.New(gameclient.NewGRPC(gameConn), auditHandler),
|
||||
GiftDiamond: giftdiamondmodule.New(walletDB, auditHandler),
|
||||
Health: healthmodule.New(store, redisClient, jobStatus),
|
||||
HostAgencyPolicy: hostagencypolicymodule.New(store, walletDB, auditHandler),
|
||||
HostOrg: hostorgmodule.New(userclient.NewGRPC(userConn), walletclient.NewGRPC(walletConn), userDB, walletDB, sqlDB, auditHandler),
|
||||
|
||||
@ -37,7 +37,6 @@ type AppUser struct {
|
||||
CountryDisplayName string `json:"countryDisplayName"`
|
||||
CountryName string `json:"countryName"`
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
Diamond int64 `json:"diamond"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
Gender string `json:"gender"`
|
||||
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
||||
@ -464,16 +463,16 @@ func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []i
|
||||
return nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(userIDs)), ",")
|
||||
args := make([]any, 0, len(userIDs)+2)
|
||||
args := make([]any, 0, len(userIDs)+1)
|
||||
args = append(args, appctx.FromContext(ctx))
|
||||
for _, id := range userIDs {
|
||||
args = append(args, id)
|
||||
}
|
||||
args = append(args, "COIN", "DIAMOND")
|
||||
args = append(args, "COIN")
|
||||
rows, err := s.walletDB.QueryContext(ctx, fmt.Sprintf(`
|
||||
SELECT user_id, asset_type, available_amount
|
||||
FROM wallet_accounts
|
||||
WHERE app_code = ? AND user_id IN (%s) AND asset_type IN (?, ?)
|
||||
WHERE app_code = ? AND user_id IN (%s) AND asset_type = ?
|
||||
`, placeholders), args...)
|
||||
if err != nil {
|
||||
return err
|
||||
@ -498,8 +497,6 @@ func (s *Service) fillBalances(ctx context.Context, items []AppUser, userIDs []i
|
||||
switch strings.ToUpper(assetType) {
|
||||
case "COIN":
|
||||
items[i].Coin = amount
|
||||
case "DIAMOND":
|
||||
items[i].Diamond = amount
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
|
||||
20
server/admin/internal/modules/giftdiamond/dto.go
Normal file
20
server/admin/internal/modules/giftdiamond/dto.go
Normal file
@ -0,0 +1,20 @@
|
||||
package giftdiamond
|
||||
|
||||
type ratioDTO struct {
|
||||
GiftTypeCode string `json:"giftTypeCode"`
|
||||
RatioPercent string `json:"ratioPercent"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
EffectiveRegionID int64 `json:"effectiveRegionId"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAtMS int64 `json:"updatedAtMs"`
|
||||
}
|
||||
|
||||
type ratioListDTO struct {
|
||||
RegionID int64 `json:"regionId"`
|
||||
Items []ratioDTO `json:"items"`
|
||||
}
|
||||
|
||||
type updateRequest struct {
|
||||
RegionID int64 `json:"regionId"`
|
||||
Ratios map[string]interface{} `json:"ratios"`
|
||||
}
|
||||
76
server/admin/internal/modules/giftdiamond/handler.go
Normal file
76
server/admin/internal/modules/giftdiamond/handler.go
Normal file
@ -0,0 +1,76 @@
|
||||
package giftdiamond
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp-admin-server/internal/appctx"
|
||||
"hyapp-admin-server/internal/modules/shared"
|
||||
"hyapp-admin-server/internal/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
service *Service
|
||||
audit shared.OperationLogger
|
||||
}
|
||||
|
||||
func New(walletDB *sql.DB, audit shared.OperationLogger) *Handler {
|
||||
return &Handler{service: NewService(walletDB), audit: audit}
|
||||
}
|
||||
|
||||
func (h *Handler) List(c *gin.Context) {
|
||||
regionID, ok := optionalInt64Query(c, "region_id", "regionId")
|
||||
if !ok || regionID < 0 {
|
||||
response.BadRequest(c, "区域不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.List(c.Request.Context(), appctx.FromContext(c.Request.Context()), regionID)
|
||||
if err != nil {
|
||||
response.ServerError(c, "获取礼物钻石比例失败")
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func optionalInt64Query(c *gin.Context, keys ...string) (int64, bool) {
|
||||
for _, key := range keys {
|
||||
value := strings.TrimSpace(c.Query(key))
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
}
|
||||
return 0, true
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
var req updateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "礼物钻石比例参数不正确")
|
||||
return
|
||||
}
|
||||
result, err := h.service.Update(c.Request.Context(), appctx.FromContext(c.Request.Context()), req, int64(shared.ActorFromContext(c).UserID))
|
||||
if err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
shared.OperationLogWithResourceID(c, h.audit, "gift-diamond-ratio-update", "gift_diamond_ratio_configs", fmt.Sprintf("region:%d", result.RegionID), "success",
|
||||
fmt.Sprintf("region_id=%d ratios=%s", result.RegionID, strings.Join(ratioSummary(result.Items), ",")))
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func ratioSummary(items []ratioDTO) []string {
|
||||
result := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
result = append(result, item.GiftTypeCode+"="+item.RatioPercent)
|
||||
}
|
||||
return result
|
||||
}
|
||||
15
server/admin/internal/modules/giftdiamond/routes.go
Normal file
15
server/admin/internal/modules/giftdiamond/routes.go
Normal file
@ -0,0 +1,15 @@
|
||||
package giftdiamond
|
||||
|
||||
import (
|
||||
"hyapp-admin-server/internal/middleware"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/admin/operations/gift-diamond-ratios", middleware.RequirePermission("gift-diamond:view"), h.List)
|
||||
protected.PUT("/admin/operations/gift-diamond-ratios", middleware.RequirePermission("gift-diamond:update"), h.Update)
|
||||
}
|
||||
166
server/admin/internal/modules/giftdiamond/service.go
Normal file
166
server/admin/internal/modules/giftdiamond/service.go
Normal file
@ -0,0 +1,166 @@
|
||||
package giftdiamond
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const statusActive = "active"
|
||||
|
||||
var giftTypes = []string{"normal", "lucky", "super_lucky"}
|
||||
|
||||
type Service struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewService(db *sql.DB) *Service {
|
||||
return &Service{db: db}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, appCode string, regionID int64) (ratioListDTO, error) {
|
||||
if err := s.ensureSchema(ctx); err != nil {
|
||||
return ratioListDTO{}, err
|
||||
}
|
||||
items := make([]ratioDTO, 0, len(giftTypes))
|
||||
for _, giftType := range giftTypes {
|
||||
item, err := s.resolveRatio(ctx, appCode, regionID, giftType)
|
||||
if err != nil {
|
||||
return ratioListDTO{}, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
return ratioListDTO{RegionID: regionID, Items: items}, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, appCode string, req updateRequest, adminID int64) (ratioListDTO, error) {
|
||||
if req.RegionID < 0 {
|
||||
return ratioListDTO{}, errors.New("区域不正确")
|
||||
}
|
||||
if err := s.ensureSchema(ctx); err != nil {
|
||||
return ratioListDTO{}, err
|
||||
}
|
||||
nowMs := time.Now().UnixMilli()
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return ratioListDTO{}, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, giftType := range giftTypes {
|
||||
raw, exists := req.Ratios[giftType]
|
||||
if !exists {
|
||||
return ratioListDTO{}, fmt.Errorf("%s 比例不能为空", giftType)
|
||||
}
|
||||
ratio, err := normalizePercent(raw)
|
||||
if err != nil {
|
||||
return ratioListDTO{}, fmt.Errorf("%s 比例不正确", giftType)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO gift_diamond_ratio_configs (
|
||||
app_code, region_id, gift_type_code, status, ratio_percent,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 'active', ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
ratio_percent = VALUES(ratio_percent),
|
||||
updated_by_admin_id = VALUES(updated_by_admin_id),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
appCode, req.RegionID, giftType, ratio, adminID, adminID, nowMs, nowMs,
|
||||
); err != nil {
|
||||
return ratioListDTO{}, err
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return ratioListDTO{}, err
|
||||
}
|
||||
return s.List(ctx, appCode, req.RegionID)
|
||||
}
|
||||
|
||||
func (s *Service) resolveRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, error) {
|
||||
if item, exists, err := s.getRatio(ctx, appCode, regionID, giftType); err != nil || exists {
|
||||
return item, err
|
||||
}
|
||||
if regionID != 0 {
|
||||
if item, exists, err := s.getRatio(ctx, appCode, 0, giftType); err != nil || exists {
|
||||
item.RegionID = regionID
|
||||
return item, err
|
||||
}
|
||||
}
|
||||
return ratioDTO{GiftTypeCode: giftType, RatioPercent: "100.00", RegionID: regionID, EffectiveRegionID: 0, Status: statusActive}, nil
|
||||
}
|
||||
|
||||
func (s *Service) getRatio(ctx context.Context, appCode string, regionID int64, giftType string) (ratioDTO, bool, error) {
|
||||
var item ratioDTO
|
||||
var percent string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
SELECT region_id, gift_type_code, CAST(ratio_percent AS CHAR), status, updated_at_ms
|
||||
FROM gift_diamond_ratio_configs
|
||||
WHERE app_code = ? AND region_id = ? AND gift_type_code = ?
|
||||
LIMIT 1`, appCode, regionID, giftType).Scan(&item.EffectiveRegionID, &item.GiftTypeCode, &percent, &item.Status, &item.UpdatedAtMS)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return ratioDTO{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return ratioDTO{}, false, err
|
||||
}
|
||||
item.RegionID = regionID
|
||||
item.RatioPercent = formatPercentString(percent)
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
func normalizePercent(raw interface{}) (string, error) {
|
||||
text := strings.TrimSpace(fmt.Sprint(raw))
|
||||
if text == "" {
|
||||
return "", errors.New("empty")
|
||||
}
|
||||
value, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || value > 100 {
|
||||
return "", errors.New("invalid")
|
||||
}
|
||||
return fmt.Sprintf("%.2f", value), nil
|
||||
}
|
||||
|
||||
func formatPercentString(raw string) string {
|
||||
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
|
||||
if err != nil {
|
||||
return "100.00"
|
||||
}
|
||||
return fmt.Sprintf("%.2f", value)
|
||||
}
|
||||
|
||||
func (s *Service) ensureSchema(ctx context.Context) error {
|
||||
if s == nil || s.db == nil {
|
||||
return errors.New("wallet db is not configured")
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域,0 表示全局默认',
|
||||
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
|
||||
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例,百分比',
|
||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
|
||||
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, region_id, gift_type_code),
|
||||
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表'`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO gift_diamond_ratio_configs (
|
||||
app_code, region_id, gift_type_code, status, ratio_percent,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
(?, 0, 'normal', 'active', 100.00, 0, 0, 0, 0),
|
||||
(?, 0, 'lucky', 'active', 100.00, 0, 0, 0, 0),
|
||||
(?, 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0)`,
|
||||
"lalu", "lalu", "lalu")
|
||||
return err
|
||||
}
|
||||
@ -10,9 +10,9 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
if h == nil {
|
||||
return
|
||||
}
|
||||
protected.GET("/resident-activity/voice-room-red-packet/config", middleware.RequirePermission("red-packet:view"), h.GetConfig)
|
||||
protected.POST("/resident-activity/voice-room-red-packet/config", middleware.RequirePermission("red-packet:update"), h.UpdateConfig)
|
||||
protected.GET("/resident-activity/voice-room-red-packet/records", middleware.RequirePermission("red-packet:view"), h.ListPackets)
|
||||
protected.GET("/resident-activity/voice-room-red-packet/records/:packet_id", middleware.RequirePermission("red-packet:view"), h.GetPacket)
|
||||
protected.POST("/resident-activity/voice-room-red-packet/refund/retry", middleware.RequirePermission("red-packet:update"), h.RetryRefund)
|
||||
protected.GET("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:view"), h.GetConfig)
|
||||
protected.PUT("/admin/activity/red-packets/config", middleware.RequirePermission("red-packet:update"), h.UpdateConfig)
|
||||
protected.GET("/admin/activity/red-packets", middleware.RequirePermission("red-packet:view"), h.ListPackets)
|
||||
protected.GET("/admin/activity/red-packets/:packet_id", middleware.RequirePermission("red-packet:view"), h.GetPacket)
|
||||
protected.POST("/admin/activity/red-packets/refund/retry", middleware.RequirePermission("red-packet:update"), h.RetryRefund)
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import (
|
||||
|
||||
const (
|
||||
uploadSniffBytes = 512
|
||||
maxBatchFiles = 10
|
||||
)
|
||||
|
||||
var (
|
||||
@ -62,6 +63,14 @@ func (h *Handler) UploadImage(c *gin.Context) {
|
||||
h.uploadObject(c, imageUploadPolicy)
|
||||
}
|
||||
|
||||
func (h *Handler) UploadFilesBatch(c *gin.Context) {
|
||||
h.uploadObjects(c, fileUploadPolicy)
|
||||
}
|
||||
|
||||
func (h *Handler) UploadImagesBatch(c *gin.Context) {
|
||||
h.uploadObjects(c, imageUploadPolicy)
|
||||
}
|
||||
|
||||
func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
|
||||
if h.uploader == nil {
|
||||
response.ServerError(c, "文件上传未配置")
|
||||
@ -109,9 +118,72 @@ func (h *Handler) uploadObject(c *gin.Context, policy uploadPolicy) {
|
||||
ObjectKey: objectKey,
|
||||
ContentType: contentType,
|
||||
SizeBytes: header.Size,
|
||||
Filename: header.Filename,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) uploadObjects(c *gin.Context, policy uploadPolicy) {
|
||||
if h.uploader == nil {
|
||||
response.ServerError(c, "文件上传未配置")
|
||||
return
|
||||
}
|
||||
headers, ok := parseUploadFiles(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if c.Request.MultipartForm != nil {
|
||||
_ = c.Request.MultipartForm.RemoveAll()
|
||||
}
|
||||
}()
|
||||
|
||||
actor := shared.ActorFromContext(c)
|
||||
results := make([]uploadResult, 0, len(headers))
|
||||
for _, header := range headers {
|
||||
if header.Size <= 0 {
|
||||
response.BadRequest(c, "文件大小不正确")
|
||||
return
|
||||
}
|
||||
file, err := header.Open()
|
||||
if err != nil {
|
||||
response.BadRequest(c, "文件参数不正确")
|
||||
return
|
||||
}
|
||||
head, contentType, detectedContentType, sniffOK := sniffUploadContent(c, file, header)
|
||||
if !sniffOK {
|
||||
_ = file.Close()
|
||||
return
|
||||
}
|
||||
if policy.requireImage {
|
||||
contentType = resolveImageUploadContentType(header.Filename, contentType, detectedContentType)
|
||||
}
|
||||
if !validateUploadContent(policy, header.Filename, contentType) {
|
||||
_ = file.Close()
|
||||
response.BadRequest(c, "文件类型不支持")
|
||||
return
|
||||
}
|
||||
|
||||
objectKey := buildUploadObjectKey(h.objectPrefix, policy, actor.UserID, header.Filename, contentType, time.Now())
|
||||
objectURL, err := h.uploader.PutObject(c.Request.Context(), objectKey, io.MultiReader(bytes.NewReader(head), file), header.Size, contentType)
|
||||
_ = file.Close()
|
||||
if err != nil {
|
||||
response.ServerError(c, "文件上传失败")
|
||||
return
|
||||
}
|
||||
results = append(results, uploadResult{
|
||||
URL: objectURL,
|
||||
ObjectKey: objectKey,
|
||||
ContentType: contentType,
|
||||
SizeBytes: header.Size,
|
||||
Filename: header.Filename,
|
||||
})
|
||||
}
|
||||
|
||||
shared.OperationLogWithResourceID(c, h.audit, "batch-upload-file", "cos_objects", "batch", "success",
|
||||
fmt.Sprintf("kind=%s count=%d", policy.kind, len(results)))
|
||||
response.OK(c, results)
|
||||
}
|
||||
|
||||
func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, bool) {
|
||||
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
||||
response.BadRequest(c, "文件参数不正确")
|
||||
@ -127,6 +199,33 @@ func parseUploadFile(c *gin.Context) (multipart.File, *multipart.FileHeader, boo
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func parseUploadFiles(c *gin.Context) ([]*multipart.FileHeader, bool) {
|
||||
if err := c.Request.ParseMultipartForm(1 << 20); err != nil {
|
||||
response.BadRequest(c, "文件参数不正确")
|
||||
return nil, false
|
||||
}
|
||||
if c.Request.MultipartForm == nil || len(c.Request.MultipartForm.File) == 0 {
|
||||
response.BadRequest(c, "文件参数不正确")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
headers := make([]*multipart.FileHeader, 0, maxBatchFiles)
|
||||
for _, field := range []string{"files", "file", "images", "image"} {
|
||||
for _, header := range c.Request.MultipartForm.File[field] {
|
||||
headers = append(headers, header)
|
||||
}
|
||||
}
|
||||
if len(headers) == 0 {
|
||||
response.BadRequest(c, "文件参数不正确")
|
||||
return nil, false
|
||||
}
|
||||
if len(headers) > maxBatchFiles {
|
||||
response.BadRequest(c, "一次最多上传 10 个素材")
|
||||
return nil, false
|
||||
}
|
||||
return headers, true
|
||||
}
|
||||
|
||||
func sniffUploadContent(c *gin.Context, file multipart.File, header *multipart.FileHeader) ([]byte, string, string, bool) {
|
||||
head := make([]byte, uploadSniffBytes)
|
||||
n, err := io.ReadFull(file, head)
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
type fakeUploader struct {
|
||||
contentType string
|
||||
key string
|
||||
keys []string
|
||||
sizeBytes int64
|
||||
body []byte
|
||||
}
|
||||
@ -30,6 +31,7 @@ func (u *fakeUploader) PutObject(_ context.Context, key string, reader io.Reader
|
||||
return "", err
|
||||
}
|
||||
u.key = key
|
||||
u.keys = append(u.keys, key)
|
||||
u.body = body
|
||||
u.sizeBytes = sizeBytes
|
||||
u.contentType = contentType
|
||||
@ -138,6 +140,75 @@ func TestUploadImageAllowsPayloadOverPreviousImageLimit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadImagesBatchUploadsSeriallyAndLimitsCount(t *testing.T) {
|
||||
uploader := &fakeUploader{}
|
||||
handler := New(uploader, "admin", fakeAudit{})
|
||||
router := ginRouter(handler)
|
||||
body, contentType := multipartBatchUploadBody(t, []string{"one.png", "two.pag"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/upload-images-batch", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(uploader.keys) != 2 {
|
||||
t.Fatalf("expected 2 uploads, got %d", len(uploader.keys))
|
||||
}
|
||||
var envelope struct {
|
||||
Code int `json:"code"`
|
||||
Data []struct {
|
||||
Filename string `json:"filename"`
|
||||
URL string `json:"url"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(rec.Body).Decode(&envelope); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(envelope.Data) != 2 || envelope.Data[0].Filename != "one.png" || envelope.Data[1].Filename != "two.pag" {
|
||||
t.Fatalf("unexpected response order: %+v", envelope.Data)
|
||||
}
|
||||
|
||||
tooMany := make([]string, 11)
|
||||
for index := range tooMany {
|
||||
tooMany[index] = "asset.png"
|
||||
}
|
||||
body, contentType = multipartBatchUploadBody(t, tooMany)
|
||||
req = httptest.NewRequest(http.MethodPost, "/upload-images-batch", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("too many status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadFilesBatchAllowsAnyMaterialExtension(t *testing.T) {
|
||||
uploader := &fakeUploader{}
|
||||
handler := New(uploader, "admin", fakeAudit{})
|
||||
router := ginRouter(handler)
|
||||
body, contentType := multipartBatchUploadBody(t, []string{"gift.mp4", "gift.json"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/upload-files-batch", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if len(uploader.keys) != 2 {
|
||||
t.Fatalf("expected 2 uploads, got %d", len(uploader.keys))
|
||||
}
|
||||
if !strings.HasPrefix(uploader.keys[0], "admin/files/7/") || !strings.HasSuffix(uploader.keys[0], ".mp4") {
|
||||
t.Fatalf("unexpected first key: %s", uploader.keys[0])
|
||||
}
|
||||
}
|
||||
|
||||
func ginRouter(handler *Handler) http.Handler {
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
@ -146,6 +217,8 @@ func ginRouter(handler *Handler) http.Handler {
|
||||
c.Next()
|
||||
})
|
||||
router.POST("/upload-image", handler.UploadImage)
|
||||
router.POST("/upload-files-batch", handler.UploadFilesBatch)
|
||||
router.POST("/upload-images-batch", handler.UploadImagesBatch)
|
||||
return router
|
||||
}
|
||||
|
||||
@ -168,3 +241,37 @@ func multipartUploadBody(t *testing.T, fieldName string, filename string, conten
|
||||
}
|
||||
return body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
func multipartBatchUploadBody(t *testing.T, filenames []string) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
for _, filename := range filenames {
|
||||
contentType := "image/png"
|
||||
payload := append([]byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, bytes.Repeat([]byte{1}, 8)...)
|
||||
if strings.HasSuffix(filename, ".pag") {
|
||||
contentType = "application/octet-stream"
|
||||
payload = []byte("PAG animated asset")
|
||||
} else if strings.HasSuffix(filename, ".mp4") {
|
||||
contentType = "video/mp4"
|
||||
payload = []byte("mp4 animated asset")
|
||||
} else if strings.HasSuffix(filename, ".json") {
|
||||
contentType = "application/json"
|
||||
payload = []byte(`{"asset":true}`)
|
||||
}
|
||||
part, err := writer.CreatePart(map[string][]string{
|
||||
"Content-Disposition": {`form-data; name="files"; filename="` + filename + `"`},
|
||||
"Content-Type": {contentType},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create multipart part: %v", err)
|
||||
}
|
||||
if _, err := part.Write(payload); err != nil {
|
||||
t.Fatalf("write multipart part: %v", err)
|
||||
}
|
||||
}
|
||||
if err := writer.Close(); err != nil {
|
||||
t.Fatalf("close multipart writer: %v", err)
|
||||
}
|
||||
return body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
@ -11,4 +11,5 @@ type uploadResult struct {
|
||||
ObjectKey string `json:"object_key"`
|
||||
ContentType string `json:"content_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
}
|
||||
|
||||
@ -13,4 +13,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
|
||||
protected.POST("/admin/files/upload", middleware.RequirePermission("upload:create"), h.UploadFile)
|
||||
protected.POST("/admin/files/image/upload", middleware.RequirePermission("upload:create"), h.UploadImage)
|
||||
protected.POST("/admin/files/batch-upload", middleware.RequirePermission("upload:create"), h.UploadFilesBatch)
|
||||
protected.POST("/admin/files/image/batch-upload", middleware.RequirePermission("upload:create"), h.UploadImagesBatch)
|
||||
}
|
||||
|
||||
@ -88,6 +88,8 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "金币增减查看", Code: "coin-adjustment:view", Kind: "menu"},
|
||||
{Name: "金币增减创建", Code: "coin-adjustment:create", Kind: "button"},
|
||||
{Name: "举报列表查看", Code: "report:view", Kind: "menu"},
|
||||
{Name: "礼物钻石查看", Code: "gift-diamond:view", Kind: "menu"},
|
||||
{Name: "礼物钻石更新", Code: "gift-diamond:update", Kind: "button"},
|
||||
{Name: "支付账单查看", Code: "payment-bill:view", Kind: "menu"},
|
||||
{Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"},
|
||||
{Name: "内购配置创建", Code: "payment-product:create", Kind: "button"},
|
||||
@ -251,6 +253,7 @@ func (s *Store) seedMenus() error {
|
||||
{ParentID: &operationsID, Title: "金币增减", Code: "operation-coin-adjustment", Path: "/operations/coin-adjustments", Icon: "wallet", PermissionCode: "coin-adjustment:view", Sort: 69, Visible: true},
|
||||
{ParentID: &operationsID, Title: "幸运礼物", Code: "lucky-gift", Path: "/operations/lucky-gift", Icon: "redeem", PermissionCode: "lucky-gift:view", Sort: 70, Visible: true},
|
||||
{ParentID: &operationsID, Title: "举报列表", Code: "operation-reports", Path: "/operations/reports", Icon: "flag", PermissionCode: "report:view", Sort: 71, Visible: true},
|
||||
{ParentID: &operationsID, Title: "礼物钻石", Code: "operation-gift-diamond", Path: "/operations/gift-diamonds", Icon: "diamond", PermissionCode: "gift-diamond:view", Sort: 72, Visible: true},
|
||||
{ParentID: &paymentID, Title: "账单列表", Code: "payment-bill-list", Path: "/payment/bills", Icon: "receipt", PermissionCode: "payment-bill:view", Sort: 68, Visible: true},
|
||||
{ParentID: &activityID, Title: "每日任务", Code: "daily-task-list", Path: "/activities/daily-tasks", Icon: "task", PermissionCode: "daily-task:view", Sort: 69, Visible: true},
|
||||
{ParentID: &activityID, Title: "注册奖励", Code: "registration-reward", Path: "/activities/registration-reward", Icon: "gift", PermissionCode: "registration-reward:view", Sort: 70, Visible: true},
|
||||
@ -497,7 +500,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"agency:view", "agency:create", "agency:status",
|
||||
"bd:view", "bd:create", "bd:update",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
|
||||
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"lucky-gift:view", "lucky-gift:update",
|
||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
@ -511,7 +514,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"upload:create",
|
||||
}
|
||||
case "auditor":
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
|
||||
return []string{"overview:view", "log:view", "user:view", "app-user:view", "level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view", "role:view", "permission:view", "job:view"}
|
||||
case "readonly":
|
||||
return []string{
|
||||
"overview:view",
|
||||
@ -542,6 +545,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"coin-ledger:view",
|
||||
"coin-adjustment:view",
|
||||
"report:view",
|
||||
"gift-diamond:view",
|
||||
"lucky-gift:view",
|
||||
"payment-bill:view",
|
||||
"payment-product:view",
|
||||
@ -584,7 +588,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"region:view", "region:create", "region:update", "region:status",
|
||||
"host-agency-policy:view", "host-agency-policy:create", "host-agency-policy:update", "host-agency-policy:delete", "host-agency-policy:publish", "team-salary-policy:view", "team-salary-policy:create", "team-salary-policy:update", "team-salary-policy:delete", "host-salary-settlement:view", "host-salary-settlement:settle",
|
||||
"coin-seller:view", "coin-seller:create", "coin-seller:update", "coin-seller:stock-credit",
|
||||
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"coin-ledger:view", "coin-adjustment:view", "coin-adjustment:create", "report:view", "gift-diamond:view", "gift-diamond:update", "payment-bill:view", "payment-product:view", "payment-product:create", "payment-product:update", "payment-product:delete",
|
||||
"lucky-gift:view", "lucky-gift:update",
|
||||
"game:view", "game:create", "game:update", "game:status", "game:delete",
|
||||
"daily-task:view", "daily-task:create", "daily-task:update", "daily-task:status",
|
||||
@ -595,7 +599,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
"vip-config:view", "vip-config:update", "vip-config:grant",
|
||||
}
|
||||
case "auditor", "readonly":
|
||||
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
|
||||
return []string{"level-config:view", "region-block:view", "room:view", "room-pin:view", "room-config:view", "app-config:view", "app-version:view", "resource:view", "resource-shop:view", "resource-group:view", "resource-grant:view", "gift:view", "emoji-pack:view", "host-agency-policy:view", "team-salary-policy:view", "host-salary-settlement:view", "coin-seller:view", "coin-ledger:view", "coin-adjustment:view", "report:view", "gift-diamond:view", "lucky-gift:view", "payment-bill:view", "payment-product:view", "game:view", "daily-task:view", "achievement:view", "seven-day-checkin:view", "room-treasure:view", "red-packet:view", "vip-config:view"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"hyapp-admin-server/internal/modules/dashboard"
|
||||
"hyapp-admin-server/internal/modules/firstrechargereward"
|
||||
gamemanagement "hyapp-admin-server/internal/modules/gamemanagement"
|
||||
"hyapp-admin-server/internal/modules/giftdiamond"
|
||||
"hyapp-admin-server/internal/modules/health"
|
||||
"hyapp-admin-server/internal/modules/hostagencypolicy"
|
||||
"hyapp-admin-server/internal/modules/hostorg"
|
||||
@ -60,6 +61,7 @@ type Handlers struct {
|
||||
Dashboard *dashboard.Handler
|
||||
FirstRechargeReward *firstrechargereward.Handler
|
||||
Game *gamemanagement.Handler
|
||||
GiftDiamond *giftdiamond.Handler
|
||||
Health *health.Handler
|
||||
HostAgencyPolicy *hostagencypolicy.Handler
|
||||
HostOrg *hostorg.Handler
|
||||
@ -115,6 +117,7 @@ func New(cfg config.Config, auth *service.AuthService, h Handlers) *gin.Engine {
|
||||
registrationreward.RegisterRoutes(protected, h.RegistrationReward)
|
||||
regionblock.RegisterRoutes(protected, h.RegionBlock)
|
||||
gamemanagement.RegisterRoutes(protected, h.Game)
|
||||
giftdiamond.RegisterRoutes(protected, h.GiftDiamond)
|
||||
roomadmin.RegisterRoutes(protected, h.RoomAdmin)
|
||||
roomtreasure.RegisterRoutes(protected, h.RoomTreasure)
|
||||
dashboard.RegisterRoutes(protected, h.Dashboard)
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED);
|
||||
|
||||
INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES
|
||||
('礼物钻石查看', 'gift-diamond:view', 'menu', '', @now_ms, @now_ms),
|
||||
('礼物钻石更新', 'gift-diamond:update', 'button', '', @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name = VALUES(name),
|
||||
kind = VALUES(kind),
|
||||
description = VALUES(description),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms) VALUES
|
||||
(NULL, '运营管理', 'operations', '', 'operations', '', 68, TRUE, @now_ms, @now_ms)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT INTO admin_menus (parent_id, title, code, path, icon, permission_code, sort, visible, created_at_ms, updated_at_ms)
|
||||
SELECT parent.id, '礼物钻石', 'operation-gift-diamond', '/operations/gift-diamonds', 'diamond', 'gift-diamond:view', 72, TRUE, @now_ms, @now_ms
|
||||
FROM admin_menus parent
|
||||
WHERE parent.code = 'operations'
|
||||
ON DUPLICATE KEY UPDATE
|
||||
parent_id = VALUES(parent_id),
|
||||
title = VALUES(title),
|
||||
path = VALUES(path),
|
||||
icon = VALUES(icon),
|
||||
permission_code = VALUES(permission_code),
|
||||
sort = VALUES(sort),
|
||||
visible = VALUES(visible),
|
||||
updated_at_ms = @now_ms;
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code IN ('platform-admin', 'ops-admin')
|
||||
AND admin_permission.code IN ('gift-diamond:view', 'gift-diamond:update');
|
||||
|
||||
INSERT IGNORE INTO admin_role_permissions (role_id, permission_id)
|
||||
SELECT admin_role.id, admin_permission.id
|
||||
FROM admin_roles admin_role
|
||||
JOIN admin_permissions admin_permission
|
||||
WHERE admin_role.code IN ('auditor', 'readonly')
|
||||
AND admin_permission.code = 'gift-diamond:view';
|
||||
@ -16,7 +16,7 @@ mysql_auto_migrate: true
|
||||
first_recharge_reward_worker:
|
||||
enabled: false
|
||||
red_packet_broadcast_worker:
|
||||
enabled: false
|
||||
enabled: true
|
||||
lucky_gift_worker:
|
||||
enabled: true
|
||||
worker_poll_interval: "1s"
|
||||
@ -34,6 +34,7 @@ tencent_im:
|
||||
admin_user_sig_ttl: "24h"
|
||||
endpoint: "adminapisgp.im.qcloud.com"
|
||||
group_type: "ChatRoom"
|
||||
group_id_prefix: "test_"
|
||||
request_timeout: "5s"
|
||||
broadcast:
|
||||
enabled: true
|
||||
@ -44,9 +45,9 @@ broadcast:
|
||||
worker_max_retry: 8
|
||||
ensure_groups_on_startup: true
|
||||
rocketmq:
|
||||
# Docker 本地默认关闭;接入外部 RocketMQ 后由独立 consumer group 消费房间事实。
|
||||
enabled: false
|
||||
name_servers: []
|
||||
# Docker 测试环境使用 compose RocketMQ 消费 wallet_outbox 红包事实。
|
||||
enabled: true
|
||||
name_servers: ["rocketmq-namesrv:9876"]
|
||||
name_server_domain: ""
|
||||
access_key: ""
|
||||
secret_key: ""
|
||||
@ -57,7 +58,7 @@ rocketmq:
|
||||
consumer_group: "hyapp-activity-room-outbox"
|
||||
consumer_max_reconsume_times: 16
|
||||
wallet_outbox:
|
||||
enabled: false
|
||||
enabled: true
|
||||
topic: "hyapp_wallet_outbox"
|
||||
first_recharge_consumer_group: "hyapp-activity-first-recharge-wallet-outbox"
|
||||
red_packet_broadcast_consumer_group: "hyapp-activity-red-packet-wallet-outbox"
|
||||
|
||||
@ -136,7 +136,9 @@ func New(cfg config.Config) (*App, error) {
|
||||
WorkerMaxRetry: cfg.Broadcast.WorkerMaxRetry,
|
||||
WorkerPollInterval: cfg.Broadcast.WorkerPollInterval,
|
||||
EnsureGroupsOnStartup: cfg.Broadcast.EnsureGroupsOnStartup,
|
||||
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
|
||||
}, repository, broadcastPublisher, client.NewGRPCRegionSource(userConn))
|
||||
broadcastSvc.SetSenderProfileSource(client.NewGRPCUserProfileSource(userConn))
|
||||
luckyGiftSvc := luckygiftservice.New(repository,
|
||||
luckygiftservice.WithWallet(walletv1.NewWalletServiceClient(walletConn)),
|
||||
luckygiftservice.WithRoomPublisher(tencentClient),
|
||||
@ -444,25 +446,32 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall
|
||||
if message.EventType != "WalletRedPacketCreated" && message.EventType != "WalletRedPacketClaimed" && message.EventType != "WalletRedPacketRefunded" {
|
||||
return broadcastdomain.RedPacketWalletEvent{}, false, nil
|
||||
}
|
||||
packetID, packetType, roomID, regionID, status, openAtMS, expiresAtMS := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType)
|
||||
if packetID == "" {
|
||||
fields := redPacketFieldsFromWalletPayload(message.PayloadJSON, message.EventType)
|
||||
if fields.PacketID == "" {
|
||||
return broadcastdomain.RedPacketWalletEvent{}, false, nil
|
||||
}
|
||||
return broadcastdomain.RedPacketWalletEvent{
|
||||
AppCode: appcode.Normalize(message.AppCode),
|
||||
EventID: message.EventID,
|
||||
EventType: message.EventType,
|
||||
TransactionID: message.TransactionID,
|
||||
CommandID: message.CommandID,
|
||||
PayloadJSON: message.PayloadJSON,
|
||||
PacketID: packetID,
|
||||
PacketType: packetType,
|
||||
RoomID: roomID,
|
||||
RegionID: regionID,
|
||||
Status: status,
|
||||
OpenAtMS: openAtMS,
|
||||
ExpiresAtMS: expiresAtMS,
|
||||
CreatedAtMS: message.OccurredAtMS,
|
||||
AppCode: appcode.Normalize(message.AppCode),
|
||||
EventID: message.EventID,
|
||||
EventType: message.EventType,
|
||||
TransactionID: message.TransactionID,
|
||||
CommandID: message.CommandID,
|
||||
PayloadJSON: message.PayloadJSON,
|
||||
PacketID: fields.PacketID,
|
||||
PacketType: fields.PacketType,
|
||||
RoomID: fields.RoomID,
|
||||
RegionID: fields.RegionID,
|
||||
RegionCode: fields.RegionCode,
|
||||
SenderUserID: fields.SenderUserID,
|
||||
TotalAmount: fields.TotalAmount,
|
||||
TotalCount: fields.TotalCount,
|
||||
RemainingAmount: fields.RemainingAmount,
|
||||
RemainingCount: fields.RemainingCount,
|
||||
RefundedAmount: fields.RefundedAmount,
|
||||
Status: fields.Status,
|
||||
OpenAtMS: fields.OpenAtMS,
|
||||
ExpiresAtMS: fields.ExpiresAtMS,
|
||||
CreatedAtMS: message.OccurredAtMS,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
@ -487,10 +496,27 @@ func rechargeFactFromWalletPayload(payload string) (int64, string) {
|
||||
return sequence, rechargeType
|
||||
}
|
||||
|
||||
func redPacketFieldsFromWalletPayload(payload string, eventType string) (string, string, string, int64, string, int64, int64) {
|
||||
type redPacketPayloadFields struct {
|
||||
PacketID string
|
||||
PacketType string
|
||||
RoomID string
|
||||
RegionID int64
|
||||
RegionCode string
|
||||
SenderUserID int64
|
||||
TotalAmount int64
|
||||
TotalCount int32
|
||||
RemainingAmount int64
|
||||
RemainingCount int32
|
||||
RefundedAmount int64
|
||||
Status string
|
||||
OpenAtMS int64
|
||||
ExpiresAtMS int64
|
||||
}
|
||||
|
||||
func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacketPayloadFields {
|
||||
var decoded map[string]any
|
||||
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
|
||||
return "", "", "", 0, "", 0, 0
|
||||
return redPacketPayloadFields{}
|
||||
}
|
||||
packetID := stringFromDecoded(decoded, "packet_id")
|
||||
if packetID == "" {
|
||||
@ -505,7 +531,27 @@ func redPacketFieldsFromWalletPayload(payload string, eventType string) (string,
|
||||
if eventType == "WalletRedPacketClaimed" || eventType == "WalletRedPacketRefunded" {
|
||||
status = stringFromDecoded(decoded, "packet_status")
|
||||
}
|
||||
return packetID, packetType, roomID, regionID, status, openAtMS, expiresAtMS
|
||||
totalCount := int32FromDecoded(decoded, "packet_count")
|
||||
remainingCount := int32FromDecoded(decoded, "remaining_count")
|
||||
if remainingCount == 0 && eventType == "WalletRedPacketCreated" {
|
||||
remainingCount = totalCount
|
||||
}
|
||||
return redPacketPayloadFields{
|
||||
PacketID: packetID,
|
||||
PacketType: packetType,
|
||||
RoomID: roomID,
|
||||
RegionID: regionID,
|
||||
RegionCode: stringFromDecoded(decoded, "region_code"),
|
||||
SenderUserID: int64FromDecoded(decoded, "sender_user_id"),
|
||||
TotalAmount: int64FromDecoded(decoded, "total_amount"),
|
||||
TotalCount: totalCount,
|
||||
RemainingAmount: int64FromDecoded(decoded, "remaining_amount"),
|
||||
RemainingCount: remainingCount,
|
||||
RefundedAmount: int64FromDecoded(decoded, "refund_amount"),
|
||||
Status: status,
|
||||
OpenAtMS: openAtMS,
|
||||
ExpiresAtMS: expiresAtMS,
|
||||
}
|
||||
}
|
||||
|
||||
func stringFromDecoded(decoded map[string]any, key string) string {
|
||||
@ -527,6 +573,10 @@ func int64FromDecoded(decoded map[string]any, key string) int64 {
|
||||
}
|
||||
}
|
||||
|
||||
func int32FromDecoded(decoded map[string]any, key string) int32 {
|
||||
return int32(int64FromDecoded(decoded, key))
|
||||
}
|
||||
|
||||
func luckyGiftWorkerOptions(nodeID string, cfg config.LuckyGiftWorkerConfig) luckygiftservice.WorkerOptions {
|
||||
return luckygiftservice.WorkerOptions{
|
||||
WorkerID: nodeID + "-lucky-gift",
|
||||
|
||||
@ -0,0 +1,50 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hyapp/pkg/walletmq"
|
||||
)
|
||||
|
||||
func TestRedPacketEventFromWalletMessageKeepsDisplayPayloadFacts(t *testing.T) {
|
||||
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
|
||||
AppCode: "lalu",
|
||||
EventID: "wallet-red-packet-created-1",
|
||||
EventType: "WalletRedPacketCreated",
|
||||
TransactionID: "tx-red-packet-1",
|
||||
CommandID: "cmd-red-packet-1",
|
||||
UserID: 10001,
|
||||
PayloadJSON: `{
|
||||
"packet_id":"VRRP2026052712340001",
|
||||
"packet_type":"delayed",
|
||||
"sender_user_id":10001,
|
||||
"room_id":"9001",
|
||||
"region_id":310,
|
||||
"total_amount":15000,
|
||||
"packet_count":5,
|
||||
"remaining_amount":15000,
|
||||
"remaining_count":5,
|
||||
"status":"waiting_open",
|
||||
"open_at_ms":1780000000000,
|
||||
"expires_at_ms":1780086400000
|
||||
}`,
|
||||
OccurredAtMS: 1_779_999_700_000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("encode wallet message failed: %v", err)
|
||||
}
|
||||
|
||||
event, ok, err := redPacketEventFromWalletMessage(body)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("decode red packet event failed: ok=%t err=%v", ok, err)
|
||||
}
|
||||
if event.PacketID != "VRRP2026052712340001" || event.PacketType != "delayed" || event.RoomID != "9001" || event.RegionID != 310 {
|
||||
t.Fatalf("red packet identity fields mismatch: %+v", event)
|
||||
}
|
||||
if event.SenderUserID != 10001 || event.TotalAmount != 15000 || event.TotalCount != 5 || event.RemainingAmount != 15000 || event.RemainingCount != 5 {
|
||||
t.Fatalf("red packet display facts mismatch: %+v", event)
|
||||
}
|
||||
if event.Status != "waiting_open" || event.OpenAtMS != 1_780_000_000_000 || event.ExpiresAtMS != 1_780_086_400_000 || event.CreatedAtMS != 1_779_999_700_000 {
|
||||
t.Fatalf("red packet timing/status fields mismatch: %+v", event)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
broadcastservice "hyapp/services/activity-service/internal/service/broadcast"
|
||||
)
|
||||
|
||||
// GRPCUserProfileSource 从 user-service 读取红包播报所需的发送者展示资料。
|
||||
// activity-service 只消费当前快照组装 IM,不复制或拥有用户资料。
|
||||
type GRPCUserProfileSource struct {
|
||||
client userv1.UserServiceClient
|
||||
}
|
||||
|
||||
func NewGRPCUserProfileSource(conn *grpc.ClientConn) *GRPCUserProfileSource {
|
||||
return &GRPCUserProfileSource{client: userv1.NewUserServiceClient(conn)}
|
||||
}
|
||||
|
||||
func (s *GRPCUserProfileSource) GetSenderProfile(ctx context.Context, userID int64) (broadcastservice.SenderProfile, error) {
|
||||
resp, err := s.client.GetUser(ctx, &userv1.GetUserRequest{
|
||||
Meta: &userv1.RequestMeta{
|
||||
RequestId: "red-packet-broadcast-profile",
|
||||
Caller: "activity-service",
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
},
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return broadcastservice.SenderProfile{}, err
|
||||
}
|
||||
user := resp.GetUser()
|
||||
if user == nil {
|
||||
return broadcastservice.SenderProfile{UserID: userID}, nil
|
||||
}
|
||||
return broadcastservice.SenderProfile{
|
||||
UserID: user.GetUserId(),
|
||||
Account: user.GetDisplayUserId(),
|
||||
Nickname: user.GetUsername(),
|
||||
Avatar: user.GetAvatar(),
|
||||
RegionCode: user.GetRegionCode(),
|
||||
}, nil
|
||||
}
|
||||
@ -87,6 +87,7 @@ type TencentIMConfig struct {
|
||||
AdminUserSigTTL time.Duration `yaml:"admin_user_sig_ttl"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
GroupType string `yaml:"group_type"`
|
||||
GroupIDPrefix string `yaml:"group_id_prefix"`
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
}
|
||||
|
||||
@ -272,6 +273,7 @@ func Load(path string) (Config, error) {
|
||||
if cfg.TencentIM.GroupType == "" {
|
||||
cfg.TencentIM.GroupType = tencentim.DefaultGroupType
|
||||
}
|
||||
cfg.TencentIM.GroupIDPrefix = strings.TrimSpace(cfg.TencentIM.GroupIDPrefix)
|
||||
if cfg.TencentIM.AdminUserSigTTL <= 0 {
|
||||
cfg.TencentIM.AdminUserSigTTL = 24 * time.Hour
|
||||
}
|
||||
|
||||
@ -78,20 +78,30 @@ type ConsumeRoomEventResult struct {
|
||||
|
||||
// RedPacketWalletEvent 是 wallet-service 已提交的红包资金事实。
|
||||
type RedPacketWalletEvent struct {
|
||||
AppCode string
|
||||
EventID string
|
||||
EventType string
|
||||
TransactionID string
|
||||
CommandID string
|
||||
PayloadJSON string
|
||||
PacketID string
|
||||
PacketType string
|
||||
RoomID string
|
||||
RegionID int64
|
||||
Status string
|
||||
OpenAtMS int64
|
||||
ExpiresAtMS int64
|
||||
CreatedAtMS int64
|
||||
AppCode string
|
||||
EventID string
|
||||
EventType string
|
||||
TransactionID string
|
||||
CommandID string
|
||||
PayloadJSON string
|
||||
PacketID string
|
||||
PacketType string
|
||||
RoomID string
|
||||
RegionID int64
|
||||
RegionCode string
|
||||
SenderUserID int64
|
||||
SenderAccount string
|
||||
SenderNickname string
|
||||
SenderAvatar string
|
||||
TotalAmount int64
|
||||
TotalCount int32
|
||||
RemainingAmount int64
|
||||
RemainingCount int32
|
||||
RefundedAmount int64
|
||||
Status string
|
||||
OpenAtMS int64
|
||||
ExpiresAtMS int64
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
// RedPacketCreatedEvent keeps older tests and callers compiling while wallet red-packet facts are generalized.
|
||||
|
||||
@ -164,6 +164,7 @@ type CheckResult struct {
|
||||
|
||||
type DrawResult struct {
|
||||
DrawID string
|
||||
DrawIDs []string
|
||||
CommandID string
|
||||
PoolID string
|
||||
GiftID string
|
||||
@ -184,6 +185,8 @@ type DrawResult struct {
|
||||
StageFeedback bool
|
||||
HighMultiplier bool
|
||||
CreatedAtMS int64
|
||||
WalletTransactionID string
|
||||
CoinBalanceAfter int64
|
||||
}
|
||||
|
||||
type DrawSummary struct {
|
||||
@ -225,3 +228,8 @@ type DrawOutbox struct {
|
||||
RetryCount int
|
||||
CreatedAtMS int64
|
||||
}
|
||||
|
||||
type DrawRewardState struct {
|
||||
AllGranted bool
|
||||
WalletTransactionID string
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ package broadcast
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
@ -39,6 +40,11 @@ func (s *Service) consumeRedPacketCreated(ctx context.Context, event broadcastdo
|
||||
if event.RoomID == "" {
|
||||
return nil
|
||||
}
|
||||
enriched, err := s.withRedPacketSenderProfile(ctx, event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
event = enriched
|
||||
if err := s.publishRedPacketRoomEvent(ctx, event, "CREATED", event.CreatedAtMS); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -65,6 +71,29 @@ func (s *Service) consumeRedPacketCreated(ctx context.Context, event broadcastdo
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) withRedPacketSenderProfile(ctx context.Context, event broadcastdomain.RedPacketWalletEvent) (broadcastdomain.RedPacketWalletEvent, error) {
|
||||
if event.SenderUserID <= 0 || s == nil || s.senderProfileSource == nil {
|
||||
return event, nil
|
||||
}
|
||||
profile, err := s.senderProfileSource.GetSenderProfile(ctx, event.SenderUserID)
|
||||
if err != nil {
|
||||
return event, err
|
||||
}
|
||||
if event.SenderAccount == "" {
|
||||
event.SenderAccount = profile.Account
|
||||
}
|
||||
if event.SenderNickname == "" {
|
||||
event.SenderNickname = profile.Nickname
|
||||
}
|
||||
if event.SenderAvatar == "" {
|
||||
event.SenderAvatar = profile.Avatar
|
||||
}
|
||||
if event.RegionCode == "" {
|
||||
event.RegionCode = profile.RegionCode
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (s *Service) publishRedPacketRoomEvent(ctx context.Context, event broadcastdomain.RedPacketWalletEvent, eventName string, fallbackDeliverAtMS int64) error {
|
||||
if event.RoomID == "" {
|
||||
return nil
|
||||
@ -94,10 +123,22 @@ func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName st
|
||||
"event": eventName,
|
||||
"scope": scope,
|
||||
"packetNo": event.PacketID,
|
||||
"packetId": event.PacketID,
|
||||
"roomId": event.RoomID,
|
||||
"regionId": event.RegionID,
|
||||
"regionCode": event.RegionCode,
|
||||
"packetMode": packetMode,
|
||||
"senderUserId": redPacketUserIDString(event.SenderUserID),
|
||||
"actualAccount": redPacketSenderAccount(event),
|
||||
"userNickname": event.SenderNickname,
|
||||
"userAvatar": event.SenderAvatar,
|
||||
"totalAmount": event.TotalAmount,
|
||||
"totalCount": event.TotalCount,
|
||||
"remainAmount": event.RemainingAmount,
|
||||
"remainCount": event.RemainingCount,
|
||||
"status": status,
|
||||
"delaySeconds": redPacketDelaySeconds(event),
|
||||
"refundedAmount": event.RefundedAmount,
|
||||
"claimStartTime": event.OpenAtMS,
|
||||
"expireTime": event.ExpiresAtMS,
|
||||
"serverTime": event.CreatedAtMS,
|
||||
@ -109,3 +150,24 @@ func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName st
|
||||
}
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func redPacketUserIDString(userID int64) string {
|
||||
if userID <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatInt(userID, 10)
|
||||
}
|
||||
|
||||
func redPacketSenderAccount(event broadcastdomain.RedPacketWalletEvent) string {
|
||||
if strings.TrimSpace(event.SenderAccount) != "" {
|
||||
return event.SenderAccount
|
||||
}
|
||||
return redPacketUserIDString(event.SenderUserID)
|
||||
}
|
||||
|
||||
func redPacketDelaySeconds(event broadcastdomain.RedPacketWalletEvent) int64 {
|
||||
if !strings.EqualFold(event.PacketType, "delayed") || event.OpenAtMS <= event.CreatedAtMS {
|
||||
return 0
|
||||
}
|
||||
return (event.OpenAtMS - event.CreatedAtMS) / 1000
|
||||
}
|
||||
|
||||
@ -46,6 +46,22 @@ type RegionSource interface {
|
||||
ListActiveRegionIDs(ctx context.Context) ([]int64, error)
|
||||
}
|
||||
|
||||
// SenderProfileSource 只读取红包 IM 展示所需的公开资料快照。
|
||||
// 资金和红包状态仍来自 wallet outbox,用户资料 owner 仍是 user-service。
|
||||
type SenderProfileSource interface {
|
||||
GetSenderProfile(ctx context.Context, userID int64) (SenderProfile, error)
|
||||
}
|
||||
|
||||
// SenderProfile 是红包 IM payload 的发送者展示字段。
|
||||
// activity-service 不持久化该资料,只在消费 wallet 事实时组装一次播报消息。
|
||||
type SenderProfile struct {
|
||||
UserID int64
|
||||
Account string
|
||||
Nickname string
|
||||
Avatar string
|
||||
RegionCode string
|
||||
}
|
||||
|
||||
// Config 保存播报策略和 worker 参数。
|
||||
// SuperGiftMinValue 是产品策略阈值;Worker* 是 outbox 投递吞吐和故障恢复参数。
|
||||
type Config struct {
|
||||
@ -56,6 +72,7 @@ type Config struct {
|
||||
WorkerMaxRetry int
|
||||
WorkerPollInterval time.Duration
|
||||
EnsureGroupsOnStartup bool
|
||||
GroupIDPrefix string
|
||||
}
|
||||
|
||||
// PublishInput 是服务端入队播报请求。
|
||||
@ -71,11 +88,12 @@ type PublishInput struct {
|
||||
// Service 拥有播报群建群补偿、播报入队和 outbox 投递决策。
|
||||
// 它不直接向客户端返回 UI 文案,只生产带 action 的结构化 JSON,让客户端自行决定展示形态。
|
||||
type Service struct {
|
||||
cfg Config
|
||||
repository Repository
|
||||
publisher Publisher
|
||||
regionSource RegionSource
|
||||
now func() time.Time
|
||||
cfg Config
|
||||
repository Repository
|
||||
publisher Publisher
|
||||
regionSource RegionSource
|
||||
senderProfileSource SenderProfileSource
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// New 创建播报服务。
|
||||
@ -92,6 +110,14 @@ func (s *Service) SetClock(now func() time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// SetSenderProfileSource 注入 user-service 资料读取边界。
|
||||
// 测试或离线环境可以不注入,此时红包 IM payload 仍会带 user_id/account fallback。
|
||||
func (s *Service) SetSenderProfileSource(source SenderProfileSource) {
|
||||
if s != nil {
|
||||
s.senderProfileSource = source
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBroadcastGroups 幂等创建全局播报群和所有 active 区域播报群。
|
||||
// 这个动作放在启动/定时 reconciler,不放在 /im/usersig 路径,避免用户登录被外部腾讯 REST 拖慢。
|
||||
func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
|
||||
@ -100,7 +126,7 @@ func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
|
||||
}
|
||||
app := appcode.FromContext(ctx)
|
||||
count := 0
|
||||
globalID, err := imgroup.GlobalBroadcastGroupID(app)
|
||||
globalID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app)
|
||||
if err != nil {
|
||||
return 0, xerr.New(xerr.InvalidArgument, err.Error())
|
||||
}
|
||||
@ -122,7 +148,7 @@ func (s *Service) EnsureBroadcastGroups(ctx context.Context) (int, error) {
|
||||
// GLOBAL/未知区域不对应 IM 区域群,避免客户端加入一个语义不清的“0 区域群”。
|
||||
continue
|
||||
}
|
||||
groupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
|
||||
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, app, regionID)
|
||||
if err != nil {
|
||||
return count, xerr.New(xerr.InvalidArgument, err.Error())
|
||||
}
|
||||
@ -140,7 +166,7 @@ func (s *Service) PublishRegionBroadcast(ctx context.Context, input PublishInput
|
||||
if input.RegionID <= 0 {
|
||||
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, "region_id is required")
|
||||
}
|
||||
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(ctx), input.RegionID)
|
||||
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), input.RegionID)
|
||||
if err != nil {
|
||||
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
|
||||
}
|
||||
@ -158,7 +184,7 @@ func (s *Service) PublishRoomBroadcast(ctx context.Context, roomID string, input
|
||||
// PublishGlobalBroadcast 把全局播报写入持久化 outbox。
|
||||
// 全局播报和区域播报共用同一张表,scope/group_id 决定最终投递目标。
|
||||
func (s *Service) PublishGlobalBroadcast(ctx context.Context, input PublishInput) (broadcastdomain.PublishResult, error) {
|
||||
groupID, err := imgroup.GlobalBroadcastGroupID(appcode.FromContext(ctx))
|
||||
groupID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx))
|
||||
if err != nil {
|
||||
return broadcastdomain.PublishResult{}, xerr.New(xerr.InvalidArgument, err.Error())
|
||||
}
|
||||
@ -178,7 +204,7 @@ func (s *Service) RemoveRegionBroadcastMember(ctx context.Context, userID int64,
|
||||
if s == nil || s.publisher == nil {
|
||||
return "", false, xerr.New(xerr.Unavailable, "broadcast publisher is not configured")
|
||||
}
|
||||
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(ctx), regionID)
|
||||
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(s.cfg.GroupIDPrefix, appcode.FromContext(ctx), regionID)
|
||||
if err != nil {
|
||||
return "", false, xerr.New(xerr.InvalidArgument, err.Error())
|
||||
}
|
||||
|
||||
@ -105,6 +105,131 @@ func TestProcessPendingBroadcastsPublishesCustomMessage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
|
||||
repository := newFakeRepository()
|
||||
service := New(Config{NodeID: "node-a"}, repository, nil, nil)
|
||||
service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{
|
||||
UserID: 10001,
|
||||
Account: "10001",
|
||||
Nickname: "Tom",
|
||||
Avatar: "https://example.com/avatar.png",
|
||||
RegionCode: "AR",
|
||||
}})
|
||||
event := broadcastdomain.RedPacketWalletEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: "wallet-red-packet-created-1",
|
||||
EventType: "WalletRedPacketCreated",
|
||||
PacketID: "VRRP2026052712340001",
|
||||
PacketType: "delayed",
|
||||
RoomID: "9001",
|
||||
RegionID: 310,
|
||||
SenderUserID: 10001,
|
||||
TotalAmount: 15000,
|
||||
TotalCount: 5,
|
||||
RemainingAmount: 15000,
|
||||
RemainingCount: 5,
|
||||
Status: "waiting_open",
|
||||
OpenAtMS: 1_780_000_000_000,
|
||||
ExpiresAtMS: 1_780_086_400_000,
|
||||
CreatedAtMS: 1_779_999_700_000,
|
||||
}
|
||||
|
||||
if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil {
|
||||
t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err)
|
||||
}
|
||||
|
||||
record := repository.records[event.EventID]
|
||||
data := redPacketPayloadData(t, record.PayloadJSON)
|
||||
if data["packetNo"] != event.PacketID || data["packetId"] != event.PacketID || data["scope"] != "ROOM" {
|
||||
t.Fatalf("packet identity mismatch: %+v", data)
|
||||
}
|
||||
if data["senderUserId"] != "10001" || data["actualAccount"] != "10001" || data["userNickname"] != "Tom" || data["userAvatar"] != "https://example.com/avatar.png" {
|
||||
t.Fatalf("sender display fields mismatch: %+v", data)
|
||||
}
|
||||
if data["totalAmount"] != float64(15000) || data["totalCount"] != float64(5) || data["remainAmount"] != float64(15000) || data["remainCount"] != float64(5) {
|
||||
t.Fatalf("red packet amount/count fields mismatch: %+v", data)
|
||||
}
|
||||
if data["packetMode"] != "DELAYED" || data["status"] != "WAITING_OPEN" || data["delaySeconds"] != float64(300) || data["regionCode"] != "AR" {
|
||||
t.Fatalf("red packet display state mismatch: %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
|
||||
nowMS := int64(1_779_999_700_000)
|
||||
openAtMS := int64(1_780_000_000_000)
|
||||
repository := newFakeRepository()
|
||||
publisher := &fakePublisher{}
|
||||
service := New(Config{NodeID: "node-a"}, repository, publisher, nil)
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(nowMS) })
|
||||
service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{
|
||||
UserID: 10001,
|
||||
Account: "10001",
|
||||
Nickname: "Tom",
|
||||
Avatar: "https://example.com/avatar.png",
|
||||
RegionCode: "AR",
|
||||
}})
|
||||
event := broadcastdomain.RedPacketWalletEvent{
|
||||
AppCode: "lalu",
|
||||
EventID: "wallet-red-packet-created-1",
|
||||
EventType: "WalletRedPacketCreated",
|
||||
PacketID: "VRRP2026052712340001",
|
||||
PacketType: "delayed",
|
||||
RoomID: "9001",
|
||||
RegionID: 310,
|
||||
SenderUserID: 10001,
|
||||
TotalAmount: 15000,
|
||||
TotalCount: 5,
|
||||
RemainingAmount: 15000,
|
||||
RemainingCount: 5,
|
||||
Status: "waiting_open",
|
||||
OpenAtMS: openAtMS,
|
||||
ExpiresAtMS: 1_780_086_400_000,
|
||||
CreatedAtMS: nowMS,
|
||||
}
|
||||
|
||||
if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil {
|
||||
t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err)
|
||||
}
|
||||
|
||||
roomCreated := repository.records[event.EventID]
|
||||
regionCreated := repository.records[event.EventID+":region_created"]
|
||||
openDue := repository.records[event.EventID+":open_due"]
|
||||
if roomCreated.GroupID != "9001" || roomCreated.Scope != broadcastdomain.ScopeRoom || roomCreated.NextRetryAtMS != nowMS {
|
||||
t.Fatalf("room created outbox mismatch: %+v", roomCreated)
|
||||
}
|
||||
if regionCreated.GroupID != "hy_lalu_bc_r_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS {
|
||||
t.Fatalf("region created outbox mismatch: %+v", regionCreated)
|
||||
}
|
||||
if openDue.GroupID != "9001" || openDue.Scope != broadcastdomain.ScopeRoom || openDue.NextRetryAtMS != openAtMS {
|
||||
t.Fatalf("open due outbox mismatch: %+v", openDue)
|
||||
}
|
||||
regionData := redPacketPayloadData(t, regionCreated.PayloadJSON)
|
||||
if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" {
|
||||
t.Fatalf("region created payload mismatch: %+v", regionData)
|
||||
}
|
||||
openDueData := redPacketPayloadData(t, openDue.PayloadJSON)
|
||||
if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) {
|
||||
t.Fatalf("open due payload mismatch: %+v", openDueData)
|
||||
}
|
||||
|
||||
result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessPendingBroadcasts before open failed: %v", err)
|
||||
}
|
||||
if result.SuccessCount != 2 || len(publisher.messages) != 2 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
|
||||
t.Fatalf("before open only room/region CREATED should be sent: result=%+v messages=%+v", result, publisher.messages)
|
||||
}
|
||||
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(openAtMS) })
|
||||
result, err = service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessPendingBroadcasts after open failed: %v", err)
|
||||
}
|
||||
if result.SuccessCount != 1 || !containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") {
|
||||
t.Fatalf("after open should send OPEN_DUE: result=%+v messages=%+v", result, publisher.messages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
|
||||
publisher := &fakePublisher{}
|
||||
service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil)
|
||||
@ -129,6 +254,36 @@ func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) {
|
||||
repository := newFakeRepository()
|
||||
publisher := &fakePublisher{}
|
||||
service := New(Config{NodeID: "node-a", GroupIDPrefix: "test_"}, repository, publisher, nil)
|
||||
|
||||
result, err := service.PublishRegionBroadcast(appcode.WithContext(context.Background(), "lalu"), PublishInput{
|
||||
EventID: "evt-prefixed",
|
||||
BroadcastType: broadcastdomain.TypeSuperGift,
|
||||
RegionID: 1001,
|
||||
PayloadJSON: `{"ok":true}`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PublishRegionBroadcast failed: %v", err)
|
||||
}
|
||||
if result.GroupID != "test_hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("prefixed publish group mismatch: %+v", result)
|
||||
}
|
||||
if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("prefixed outbox group mismatch: %+v", record)
|
||||
}
|
||||
|
||||
groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001)
|
||||
if err != nil {
|
||||
t.Fatalf("RemoveRegionBroadcastMember failed: %v", err)
|
||||
}
|
||||
if !removed || groupID != "test_hy_lalu_bc_r_1001" || publisher.deletedGroupID != "test_hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%s", groupID, removed, publisher.deletedGroupID)
|
||||
}
|
||||
}
|
||||
|
||||
func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope {
|
||||
t.Helper()
|
||||
body, err := proto.Marshal(gift)
|
||||
@ -146,6 +301,31 @@ func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomevents
|
||||
}
|
||||
}
|
||||
|
||||
func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var payload struct {
|
||||
Type string `json:"type"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
|
||||
t.Fatalf("payload is not json: %v", err)
|
||||
}
|
||||
if payload.Type != "ROOM_RED_PACKET" || payload.Data == nil {
|
||||
t.Fatalf("unexpected red packet payload: %+v", payload)
|
||||
}
|
||||
return payload.Data
|
||||
}
|
||||
|
||||
func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool {
|
||||
for _, message := range messages {
|
||||
if message.EventID == eventID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
records map[string]broadcastdomain.OutboxRecord
|
||||
}
|
||||
@ -162,7 +342,7 @@ func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcast
|
||||
return true, record, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, _ int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) {
|
||||
func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) {
|
||||
records := make([]broadcastdomain.OutboxRecord, 0, limit)
|
||||
for eventID, record := range r.records {
|
||||
if len(records) >= limit {
|
||||
@ -171,6 +351,9 @@ func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID
|
||||
if record.Status != broadcastdomain.StatusPending && record.Status != broadcastdomain.StatusRetryable {
|
||||
continue
|
||||
}
|
||||
if record.NextRetryAtMS > nowMS {
|
||||
continue
|
||||
}
|
||||
record.Status = broadcastdomain.StatusDelivering
|
||||
record.LockedBy = workerID
|
||||
record.LockedUntilMS = lockUntilMS
|
||||
@ -225,3 +408,12 @@ func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, use
|
||||
p.deletedUserID = userID
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeSenderProfileSource struct {
|
||||
profile SenderProfile
|
||||
err error
|
||||
}
|
||||
|
||||
func (s fakeSenderProfileSource) GetSenderProfile(context.Context, int64) (SenderProfile, error) {
|
||||
return s.profile, s.err
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ type Repository interface {
|
||||
ExecuteLuckyGiftDraw(ctx context.Context, cmd domain.DrawCommand, nowMS int64) (domain.DrawResult, error)
|
||||
ListLuckyGiftDraws(ctx context.Context, query domain.DrawQuery) ([]domain.DrawResult, int64, error)
|
||||
GetLuckyGiftDrawSummary(ctx context.Context, query domain.DrawQuery) (domain.DrawSummary, error)
|
||||
GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error)
|
||||
ClaimPendingLuckyGiftOutbox(ctx context.Context, workerID string, nowMS int64, lockTTL time.Duration, batchSize int) ([]domain.DrawOutbox, error)
|
||||
MarkLuckyGiftOutboxDelivered(ctx context.Context, event domain.DrawOutbox, nowMS int64) error
|
||||
MarkLuckyGiftOutboxRetryable(ctx context.Context, event domain.DrawOutbox, retryCount int, nextRetryAtMS int64, lastErr string, nowMS int64) error
|
||||
@ -136,7 +137,11 @@ func (s *Service) Draw(ctx context.Context, cmd domain.DrawCommand) (domain.Draw
|
||||
// paid_at_ms 是扣费事实时间,缺失时用 UTC 服务端时间补齐,避免使用本地时区切风控窗口。
|
||||
cmd.PaidAtMS = s.now().UTC().UnixMilli()
|
||||
}
|
||||
return s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
|
||||
result, err := s.repository.ExecuteLuckyGiftDraw(ctx, cmd, s.now().UTC().UnixMilli())
|
||||
if err != nil {
|
||||
return domain.DrawResult{}, err
|
||||
}
|
||||
return s.creditLuckyGiftRewardFastPath(ctx, cmd, result), nil
|
||||
}
|
||||
|
||||
type WorkerOptions struct {
|
||||
@ -235,30 +240,26 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
|
||||
eventCtx := appcode.WithContext(ctx, event.AppCode)
|
||||
walletTransactionID := ""
|
||||
credited := false
|
||||
alreadyGranted := false
|
||||
if payload.EffectiveRewardCoins > 0 {
|
||||
if s.wallet == nil {
|
||||
return s.markOutboxFailed(eventCtx, event, options, fmt.Errorf("lucky gift wallet client is not configured"), false)
|
||||
}
|
||||
// 返奖 command_id 派生自聚合 draw_id,钱包侧可用它做幂等;批量抽奖不按子抽逐条加币,避免待发放和钱包流水膨胀。
|
||||
resp, err := s.wallet.CreditLuckyGiftReward(eventCtx, &walletv1.CreditLuckyGiftRewardRequest{
|
||||
CommandId: "lucky_reward:" + payload.DrawID,
|
||||
AppCode: event.AppCode,
|
||||
TargetUserId: payload.UserID,
|
||||
Amount: payload.EffectiveRewardCoins,
|
||||
DrawId: payload.DrawID,
|
||||
RoomId: payload.RoomID,
|
||||
GiftId: payload.GiftID,
|
||||
PoolId: payload.PoolID,
|
||||
Reason: "lucky_gift_reward",
|
||||
VisibleRegionId: payload.VisibleRegionID,
|
||||
})
|
||||
state, err := s.repository.GetLuckyGiftDrawRewardState(eventCtx, event.AppCode, payload.DrawIDs)
|
||||
if err != nil {
|
||||
return s.markOutboxFailed(eventCtx, event, options, err, false)
|
||||
}
|
||||
walletTransactionID = resp.GetTransactionId()
|
||||
credited = true
|
||||
alreadyGranted = state.AllGranted
|
||||
if alreadyGranted {
|
||||
walletTransactionID = state.WalletTransactionID
|
||||
credited = true
|
||||
} else {
|
||||
receipt, err := s.creditLuckyGiftReward(eventCtx, event.AppCode, payload)
|
||||
if err != nil {
|
||||
return s.markOutboxFailed(eventCtx, event, options, err, false)
|
||||
}
|
||||
walletTransactionID = receipt.WalletTransactionID
|
||||
credited = true
|
||||
}
|
||||
}
|
||||
if credited {
|
||||
if credited && !alreadyGranted {
|
||||
nowMS := s.now().UTC().UnixMilli()
|
||||
if err := s.repository.MarkLuckyGiftDrawsGranted(eventCtx, event.AppCode, payload.DrawIDs, walletTransactionID, nowMS); err != nil {
|
||||
return err
|
||||
@ -289,6 +290,88 @@ func (s *Service) processDrawOutbox(ctx context.Context, event domain.DrawOutbox
|
||||
return s.repository.MarkLuckyGiftOutboxDelivered(eventCtx, event, nowMS)
|
||||
}
|
||||
|
||||
type luckyGiftRewardReceipt struct {
|
||||
WalletTransactionID string
|
||||
CoinBalanceAfter int64
|
||||
}
|
||||
|
||||
func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.DrawCommand, result domain.DrawResult) domain.DrawResult {
|
||||
if result.EffectiveRewardCoins <= 0 || result.RewardStatus == domain.StatusGranted {
|
||||
return result
|
||||
}
|
||||
payload := luckyGiftDrawnPayload{
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
DrawID: result.DrawID,
|
||||
DrawIDs: luckyDrawResultIDs(result),
|
||||
CommandID: cmd.CommandID,
|
||||
PoolID: result.PoolID,
|
||||
RoomID: cmd.RoomID,
|
||||
GiftID: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
UserID: cmd.UserID,
|
||||
SenderUserID: cmd.UserID,
|
||||
TargetUserID: cmd.TargetUserID,
|
||||
VisibleRegionID: cmd.VisibleRegionID,
|
||||
CoinSpent: cmd.CoinSpent,
|
||||
RuleVersion: result.RuleVersion,
|
||||
ExperiencePool: result.ExperiencePool,
|
||||
SelectedTierID: result.SelectedTierID,
|
||||
MultiplierPPM: result.MultiplierPPM,
|
||||
BaseRewardCoins: result.BaseRewardCoins,
|
||||
RoomAtmosphereRewardCoins: result.RoomAtmosphereRewardCoins,
|
||||
ActivitySubsidyCoins: result.ActivitySubsidyCoins,
|
||||
EffectiveRewardCoins: result.EffectiveRewardCoins,
|
||||
StageFeedback: result.StageFeedback,
|
||||
HighMultiplier: result.HighMultiplier,
|
||||
CreatedAtMS: result.CreatedAtMS,
|
||||
}
|
||||
receipt, err := s.creditLuckyGiftReward(ctx, payload.AppCode, payload)
|
||||
if err != nil {
|
||||
logx.Error(ctx, "lucky_gift_reward_fast_path_failed", err,
|
||||
slog.String("draw_id", result.DrawID),
|
||||
slog.String("command_id", result.CommandID),
|
||||
slog.Int64("reward_coins", result.EffectiveRewardCoins),
|
||||
)
|
||||
return result
|
||||
}
|
||||
result.RewardStatus = domain.StatusGranted
|
||||
result.WalletTransactionID = receipt.WalletTransactionID
|
||||
result.CoinBalanceAfter = receipt.CoinBalanceAfter
|
||||
if err := s.repository.MarkLuckyGiftDrawsGranted(ctx, payload.AppCode, payload.DrawIDs, receipt.WalletTransactionID, s.now().UTC().UnixMilli()); err != nil {
|
||||
logx.Error(ctx, "lucky_gift_reward_fast_path_mark_granted_failed", err,
|
||||
slog.String("draw_id", result.DrawID),
|
||||
slog.String("command_id", result.CommandID),
|
||||
slog.String("wallet_transaction_id", receipt.WalletTransactionID),
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, payload luckyGiftDrawnPayload) (luckyGiftRewardReceipt, error) {
|
||||
if s.wallet == nil {
|
||||
return luckyGiftRewardReceipt{}, fmt.Errorf("lucky gift wallet client is not configured")
|
||||
}
|
||||
resp, err := s.wallet.CreditLuckyGiftReward(appcode.WithContext(ctx, appCode), &walletv1.CreditLuckyGiftRewardRequest{
|
||||
CommandId: "lucky_reward:" + payload.DrawID,
|
||||
AppCode: appCode,
|
||||
TargetUserId: payload.UserID,
|
||||
Amount: payload.EffectiveRewardCoins,
|
||||
DrawId: payload.DrawID,
|
||||
RoomId: payload.RoomID,
|
||||
GiftId: payload.GiftID,
|
||||
PoolId: payload.PoolID,
|
||||
Reason: "lucky_gift_reward",
|
||||
VisibleRegionId: payload.VisibleRegionID,
|
||||
})
|
||||
if err != nil {
|
||||
return luckyGiftRewardReceipt{}, err
|
||||
}
|
||||
return luckyGiftRewardReceipt{
|
||||
WalletTransactionID: resp.GetTransactionId(),
|
||||
CoinBalanceAfter: resp.GetBalance().GetAvailableAmount(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) publishLuckyGiftDrawn(ctx context.Context, payload luckyGiftDrawnPayload, options WorkerOptions) error {
|
||||
body, err := json.Marshal(luckyGiftRoomMessagePayload(payload))
|
||||
if err != nil {
|
||||
@ -554,6 +637,16 @@ func drawIDsFromPayload(payloadJSON string) []string {
|
||||
return payload.DrawIDs
|
||||
}
|
||||
|
||||
func luckyDrawResultIDs(result domain.DrawResult) []string {
|
||||
if len(result.DrawIDs) > 0 {
|
||||
return append([]string(nil), result.DrawIDs...)
|
||||
}
|
||||
if strings.TrimSpace(result.DrawID) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{result.DrawID}
|
||||
}
|
||||
|
||||
func normalizeWorkerOptions(options WorkerOptions) WorkerOptions {
|
||||
options.WorkerID = strings.TrimSpace(options.WorkerID)
|
||||
if options.WorkerID == "" {
|
||||
|
||||
@ -48,6 +48,85 @@ func TestUpsertConfigPreservesPoolScope(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
|
||||
repository := &fakeLuckyGiftRepository{executeResult: domain.DrawResult{
|
||||
DrawID: "lucky_draw_fast_1",
|
||||
DrawIDs: []string{"lucky_draw_fast_1", "lucky_draw_fast_2"},
|
||||
CommandID: "cmd-gift-fast",
|
||||
PoolID: "super_lucky",
|
||||
GiftID: "rose",
|
||||
RuleVersion: 12,
|
||||
ExperiencePool: "novice",
|
||||
SelectedTierID: "batch",
|
||||
MultiplierPPM: 3_000_000,
|
||||
EffectiveRewardCoins: 300,
|
||||
RewardStatus: domain.StatusPending,
|
||||
CreatedAtMS: 1760000000000,
|
||||
}}
|
||||
wallet := &fakeLuckyGiftWallet{balanceAfter: 1300}
|
||||
service := New(repository, WithWallet(wallet))
|
||||
service.SetClock(func() time.Time { return time.UnixMilli(1760000001000).UTC() })
|
||||
|
||||
result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{
|
||||
CommandID: "cmd-gift-fast",
|
||||
PoolID: "super_lucky",
|
||||
UserID: 42,
|
||||
TargetUserID: 99,
|
||||
DeviceID: "device-1",
|
||||
RoomID: "room-1",
|
||||
AnchorID: "99",
|
||||
GiftID: "rose",
|
||||
GiftCount: 2,
|
||||
CoinSpent: 200,
|
||||
PaidAtMS: 1760000000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw failed: %v", err)
|
||||
}
|
||||
if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 {
|
||||
t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
|
||||
}
|
||||
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 {
|
||||
t.Fatalf("fast path result mismatch: %+v", result)
|
||||
}
|
||||
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_fast_1,lucky_draw_fast_2" {
|
||||
t.Fatalf("fast path should mark all draw ids granted, got %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrawKeepsPendingWhenFastPathWalletFails(t *testing.T) {
|
||||
repository := &fakeLuckyGiftRepository{executeResult: domain.DrawResult{
|
||||
DrawID: "lucky_draw_retry",
|
||||
DrawIDs: []string{"lucky_draw_retry"},
|
||||
CommandID: "cmd-gift-retry",
|
||||
PoolID: "super_lucky",
|
||||
GiftID: "rose",
|
||||
EffectiveRewardCoins: 300,
|
||||
RewardStatus: domain.StatusPending,
|
||||
}}
|
||||
wallet := &fakeLuckyGiftWallet{err: errors.New("wallet timeout")}
|
||||
service := New(repository, WithWallet(wallet))
|
||||
|
||||
result, err := service.Draw(appcode.WithContext(context.Background(), "lalu"), domain.DrawCommand{
|
||||
CommandID: "cmd-gift-retry",
|
||||
PoolID: "super_lucky",
|
||||
UserID: 42,
|
||||
DeviceID: "device-1",
|
||||
RoomID: "room-1",
|
||||
AnchorID: "99",
|
||||
GiftID: "rose",
|
||||
GiftCount: 1,
|
||||
CoinSpent: 100,
|
||||
PaidAtMS: 1760000000000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Draw should keep the committed draw fact: %v", err)
|
||||
}
|
||||
if result.RewardStatus != domain.StatusPending || result.WalletTransactionID != "" || len(repository.grantedDrawIDs) != 0 {
|
||||
t.Fatalf("wallet failure must leave draw pending for outbox compensation: result=%+v granted=%v", result, repository.grantedDrawIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxCreditsWalletAndPublishesRoomIM(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
@ -261,9 +340,60 @@ func TestProcessPendingDrawOutboxGrantsRewardWhenRoomIMFails(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessPendingDrawOutboxSkipsWalletWhenDrawAlreadyGranted(t *testing.T) {
|
||||
payload, _ := json.Marshal(map[string]any{
|
||||
"event_type": "lucky_gift_drawn",
|
||||
"event_id": "lucky_gift_drawn:lucky_draw_granted",
|
||||
"app_code": "lalu",
|
||||
"draw_id": "lucky_draw_granted",
|
||||
"command_id": "cmd-gift-granted",
|
||||
"pool_id": "super_lucky",
|
||||
"user_id": 42,
|
||||
"room_id": "room-1",
|
||||
"gift_id": "rose",
|
||||
"gift_count": 1,
|
||||
"coin_spent": 100,
|
||||
"effective_reward_coins": 300,
|
||||
"multiplier_ppm": 3_000_000,
|
||||
"created_at_ms": 1760000000000,
|
||||
})
|
||||
repository := &fakeLuckyGiftRepository{
|
||||
rewardState: domain.DrawRewardState{AllGranted: true, WalletTransactionID: "wallet_tx_existing"},
|
||||
outbox: []domain.DrawOutbox{{
|
||||
AppCode: "lalu",
|
||||
OutboxID: "lucky_lucky_draw_granted",
|
||||
EventType: "LuckyGiftDrawn",
|
||||
PayloadJSON: string(payload),
|
||||
}},
|
||||
}
|
||||
wallet := &fakeLuckyGiftWallet{}
|
||||
publisher := &fakeLuckyGiftPublisher{}
|
||||
service := New(repository, WithWallet(wallet), WithRoomPublisher(publisher), WithRegionBroadcaster(&fakeLuckyGiftRegionBroadcaster{}))
|
||||
|
||||
processed, err := service.ProcessPendingDrawOutbox(context.Background(), WorkerOptions{WorkerID: "worker-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessPendingDrawOutbox failed: %v", err)
|
||||
}
|
||||
if processed != 1 {
|
||||
t.Fatalf("processed count mismatch: %d", processed)
|
||||
}
|
||||
if wallet.last != nil {
|
||||
t.Fatalf("already granted draw must not call wallet again: %+v", wallet.last)
|
||||
}
|
||||
if repository.grantedDrawID != "" {
|
||||
t.Fatalf("already granted draw should not be marked again, got %q", repository.grantedDrawID)
|
||||
}
|
||||
if publisher.last.EventID != "lucky_gift_drawn:lucky_draw_granted" || repository.deliveredOutboxID != "lucky_lucky_draw_granted" {
|
||||
t.Fatalf("already granted draw should still publish IM and deliver outbox: publisher=%+v delivered=%s", publisher.last, repository.deliveredOutboxID)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLuckyGiftRepository struct {
|
||||
getConfigCalls int
|
||||
upserted domain.RuleConfig
|
||||
executeResult domain.DrawResult
|
||||
executeCmd domain.DrawCommand
|
||||
rewardState domain.DrawRewardState
|
||||
outbox []domain.DrawOutbox
|
||||
|
||||
grantedDrawID string
|
||||
@ -290,8 +420,9 @@ func (r *fakeLuckyGiftRepository) CheckLuckyGift(context.Context, domain.CheckCo
|
||||
return domain.CheckResult{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(context.Context, domain.DrawCommand, int64) (domain.DrawResult, error) {
|
||||
return domain.DrawResult{}, nil
|
||||
func (r *fakeLuckyGiftRepository) ExecuteLuckyGiftDraw(_ context.Context, cmd domain.DrawCommand, _ int64) (domain.DrawResult, error) {
|
||||
r.executeCmd = cmd
|
||||
return r.executeResult, nil
|
||||
}
|
||||
|
||||
func (r *fakeLuckyGiftRepository) ListLuckyGiftDraws(context.Context, domain.DrawQuery) ([]domain.DrawResult, int64, error) {
|
||||
@ -302,6 +433,10 @@ func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawSummary(context.Context, domai
|
||||
return domain.DrawSummary{}, nil
|
||||
}
|
||||
|
||||
func (r *fakeLuckyGiftRepository) GetLuckyGiftDrawRewardState(context.Context, string, []string) (domain.DrawRewardState, error) {
|
||||
return r.rewardState, nil
|
||||
}
|
||||
|
||||
func (r *fakeLuckyGiftRepository) ClaimPendingLuckyGiftOutbox(context.Context, string, int64, time.Duration, int) ([]domain.DrawOutbox, error) {
|
||||
return r.outbox, nil
|
||||
}
|
||||
@ -342,12 +477,21 @@ func (r *fakeLuckyGiftRepository) MarkLuckyGiftDrawsFailed(context.Context, stri
|
||||
}
|
||||
|
||||
type fakeLuckyGiftWallet struct {
|
||||
last *walletv1.CreditLuckyGiftRewardRequest
|
||||
last *walletv1.CreditLuckyGiftRewardRequest
|
||||
err error
|
||||
balanceAfter int64
|
||||
}
|
||||
|
||||
func (w *fakeLuckyGiftWallet) CreditLuckyGiftReward(_ context.Context, req *walletv1.CreditLuckyGiftRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditLuckyGiftRewardResponse, error) {
|
||||
w.last = req
|
||||
return &walletv1.CreditLuckyGiftRewardResponse{TransactionId: "wallet_tx_lucky", Amount: req.GetAmount()}, nil
|
||||
if w.err != nil {
|
||||
return nil, w.err
|
||||
}
|
||||
return &walletv1.CreditLuckyGiftRewardResponse{
|
||||
TransactionId: "wallet_tx_lucky",
|
||||
Amount: req.GetAmount(),
|
||||
Balance: &walletv1.AssetBalance{AvailableAmount: w.balanceAfter},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type fakeLuckyGiftPublisher struct {
|
||||
|
||||
@ -1055,6 +1055,12 @@ func luckyAggregateDrawResults(cmd domain.DrawCommand, results []domain.DrawResu
|
||||
}
|
||||
aggregate := results[0]
|
||||
aggregate.CommandID = cmd.CommandID
|
||||
aggregate.DrawIDs = make([]string, 0, len(results))
|
||||
for _, result := range results {
|
||||
if strings.TrimSpace(result.DrawID) != "" {
|
||||
aggregate.DrawIDs = append(aggregate.DrawIDs, result.DrawID)
|
||||
}
|
||||
}
|
||||
if len(results) == 1 {
|
||||
return aggregate
|
||||
}
|
||||
@ -1467,6 +1473,54 @@ func (r *Repository) MarkLuckyGiftDrawGranted(ctx context.Context, appCode strin
|
||||
return r.MarkLuckyGiftDrawsGranted(ctx, appCode, []string{drawID}, walletTransactionID, nowMS)
|
||||
}
|
||||
|
||||
func (r *Repository) GetLuckyGiftDrawRewardState(ctx context.Context, appCode string, drawIDs []string) (domain.DrawRewardState, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return domain.DrawRewardState{}, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
drawIDs = normalizeLuckyDrawIDs(drawIDs)
|
||||
if len(drawIDs) == 0 {
|
||||
return domain.DrawRewardState{}, nil
|
||||
}
|
||||
args := []any{appcode.Normalize(appCode)}
|
||||
for _, drawID := range drawIDs {
|
||||
args = append(args, drawID)
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT reward_status, COALESCE(reward_transaction_id, ''), COUNT(*)
|
||||
FROM lucky_draw_records
|
||||
WHERE app_code = ? AND draw_id IN (`+luckySQLPlaceholders(len(drawIDs))+`)
|
||||
GROUP BY reward_status, reward_transaction_id`, args...)
|
||||
if err != nil {
|
||||
return domain.DrawRewardState{}, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var total int64
|
||||
var granted int64
|
||||
walletTransactionID := ""
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var txID string
|
||||
var count int64
|
||||
if err := rows.Scan(&status, &txID, &count); err != nil {
|
||||
return domain.DrawRewardState{}, err
|
||||
}
|
||||
total += count
|
||||
if status == domain.StatusGranted {
|
||||
granted += count
|
||||
if walletTransactionID == "" {
|
||||
walletTransactionID = txID
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.DrawRewardState{}, err
|
||||
}
|
||||
return domain.DrawRewardState{
|
||||
AllGranted: total == int64(len(drawIDs)) && granted == total,
|
||||
WalletTransactionID: walletTransactionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// MarkLuckyGiftDrawsGranted 是聚合 outbox 的终态收敛入口。
|
||||
// 钱包只加币一次,但 draw_records 仍有多条;这里必须批量 granted,避免后台看到大量已发奖但仍 pending 的明细。
|
||||
func (r *Repository) MarkLuckyGiftDrawsGranted(ctx context.Context, appCode string, drawIDs []string, walletTransactionID string, nowMS int64) error {
|
||||
@ -1683,7 +1737,7 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
|
||||
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
||||
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
COALESCE(reward_transaction_id, ''), rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
FROM lucky_draw_records
|
||||
WHERE `+whereSQL+`
|
||||
ORDER BY created_at_ms DESC, draw_id DESC
|
||||
@ -1698,10 +1752,11 @@ func (r *Repository) ListLuckyGiftDraws(ctx context.Context, query domain.DrawQu
|
||||
if err := rows.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
|
||||
&item.MultiplierPPM,
|
||||
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.WalletTransactionID, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
item.DrawIDs = []string{item.DrawID}
|
||||
items = append(items, item)
|
||||
}
|
||||
return items, total, rows.Err()
|
||||
@ -2505,7 +2560,7 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
|
||||
COALESCE(JSON_UNQUOTE(JSON_EXTRACT(candidate_tiers_json, '$.multiplier_ppm')), '0'),
|
||||
base_reward_coins, room_atmosphere_reward_coins, activity_subsidy_coins,
|
||||
effective_reward_coins, COALESCE(CAST(budget_sources_json AS CHAR), ''), reward_status,
|
||||
rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
COALESCE(reward_transaction_id, ''), rtp_window_index, gift_rtp_window_index, stage_feedback, high_multiplier, created_at_ms
|
||||
FROM lucky_draw_records
|
||||
WHERE app_code = ? AND command_id = ?`+lockSQL,
|
||||
appCode, commandID,
|
||||
@ -2514,13 +2569,14 @@ func (r *Repository) getLuckyDrawByCommand(ctx context.Context, tx *sql.Tx, appC
|
||||
if err := row.Scan(&item.DrawID, &item.CommandID, &item.PoolID, &item.GiftID, &item.RuleVersion, &item.ExperiencePool, &item.SelectedTierID,
|
||||
&item.MultiplierPPM,
|
||||
&item.BaseRewardCoins, &item.RoomAtmosphereRewardCoins, &item.ActivitySubsidyCoins, &item.EffectiveRewardCoins,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.BudgetSourcesJSON, &item.RewardStatus, &item.WalletTransactionID, &item.RTPWindowIndex, &item.GiftRTPWindowIndex,
|
||||
&item.StageFeedback, &item.HighMultiplier, &item.CreatedAtMS); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return domain.DrawResult{}, false, nil
|
||||
}
|
||||
return domain.DrawResult{}, false, err
|
||||
}
|
||||
item.DrawIDs = []string{item.DrawID}
|
||||
return item, true, nil
|
||||
}
|
||||
|
||||
|
||||
@ -265,6 +265,8 @@ func luckyDrawResultToProto(result domain.DrawResult) *activityv1.LuckyGiftDrawR
|
||||
StageFeedback: result.StageFeedback,
|
||||
HighMultiplier: result.HighMultiplier,
|
||||
CreatedAtMs: result.CreatedAtMS,
|
||||
WalletTransactionId: result.WalletTransactionID,
|
||||
CoinBalanceAfter: result.CoinBalanceAfter,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -198,24 +198,3 @@ CREATE TABLE IF NOT EXISTS game_outbox (
|
||||
KEY idx_game_outbox_retention(app_code, status, updated_at_ms, event_id),
|
||||
KEY idx_game_outbox_order(app_code, order_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏统计事件 outbox 表';
|
||||
|
||||
INSERT INTO game_platforms (
|
||||
app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0
|
||||
) ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name);
|
||||
|
||||
INSERT INTO game_catalog (
|
||||
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
|
||||
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
|
||||
'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
|
||||
'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
|
||||
) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name);
|
||||
|
||||
INSERT INTO game_display_rules (
|
||||
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
|
||||
) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled);
|
||||
|
||||
@ -263,7 +263,7 @@ func (s *Service) handleBaishunGetSSToken(ctx context.Context, app string, confi
|
||||
}
|
||||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||||
if err != nil {
|
||||
return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeFromError(err), baishunMessageFromError(err), true, "")
|
||||
return baishunAdapterError(req.GetMeta().GetRequestId(), baishunUserSnapshotCodeFromError(err), baishunUserSnapshotMessageFromError(err), true, "")
|
||||
}
|
||||
response := map[string]any{
|
||||
"code": baishunCodeOK,
|
||||
@ -289,7 +289,7 @@ func (s *Service) handleBaishunUserInfo(ctx context.Context, app string, config
|
||||
}
|
||||
snapshot, err := s.yomiUserSnapshot(ctx, app, req, session.UserID)
|
||||
if err != nil {
|
||||
return baishunAdapterError(req.GetMeta().GetRequestId(), baishunCodeFromError(err), baishunMessageFromError(err), true, "")
|
||||
return baishunAdapterError(req.GetMeta().GetRequestId(), baishunUserSnapshotCodeFromError(err), baishunUserSnapshotMessageFromError(err), true, "")
|
||||
}
|
||||
raw, contentType := baishunJSON(req.GetMeta().GetRequestId(), baishunCodeOK, baishunDefaultSuccessMessage, baishunUserInfoData(session, config, snapshot))
|
||||
return raw, contentType, strconv.Itoa(baishunCodeOK), true, "", nil
|
||||
@ -466,3 +466,17 @@ func baishunMessageFromError(err error) string {
|
||||
return "api server error"
|
||||
}
|
||||
}
|
||||
|
||||
func baishunUserSnapshotCodeFromError(err error) int {
|
||||
if xerr.IsCode(err, xerr.NotFound) {
|
||||
return baishunCodeInvalidData
|
||||
}
|
||||
return baishunCodeFromError(err)
|
||||
}
|
||||
|
||||
func baishunUserSnapshotMessageFromError(err error) string {
|
||||
if xerr.IsCode(err, xerr.NotFound) {
|
||||
return "token invalid"
|
||||
}
|
||||
return baishunMessageFromError(err)
|
||||
}
|
||||
|
||||
@ -706,13 +706,14 @@ func buildLaunchURL(game gamedomain.LaunchableGame, session gamedomain.LaunchSes
|
||||
if strings.EqualFold(game.AdapterType, gamedomain.AdapterBaishunV1) {
|
||||
// 百顺前端规范以 NativeBridge.getConfig 为准;这里把同一份配置也拼到 URL,方便 Flutter/H5 壳层读取。
|
||||
config := baishunConfigFromPlatform(game)
|
||||
query.Set("bridge_keys", "appChannel,appId,code,gameMode,gsp,language,roomId,sceneMode")
|
||||
query.Set("bridge_keys", "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl")
|
||||
query.Set("appChannel", config.AppChannel)
|
||||
if config.AppID > 0 {
|
||||
query.Set("appId", strconv.FormatInt(config.AppID, 10))
|
||||
}
|
||||
query.Set("userId", externalUserID(session, config.UIDMode))
|
||||
query.Set("code", token)
|
||||
query.Set("isDataByUrl", "1")
|
||||
if session.RoomID != "" {
|
||||
query.Set("roomId", session.RoomID)
|
||||
}
|
||||
|
||||
@ -323,7 +323,8 @@ func TestLaunchGameBuildsBaishunURL(t *testing.T) {
|
||||
"gameMode": "3",
|
||||
"language": "2",
|
||||
"sceneMode": "1",
|
||||
"bridge_keys": "appChannel,appId,code,gameMode,gsp,language,roomId,sceneMode",
|
||||
"isDataByUrl": "1",
|
||||
"bridge_keys": "appChannel,appId,userId,code,gameMode,gsp,language,roomId,sceneMode,currencyIcon,isDataByUrl",
|
||||
"currencyIcon": "https://cdn.example/coin.png",
|
||||
"gsp": "101",
|
||||
}
|
||||
@ -1049,6 +1050,51 @@ func TestHandleBaishunAccessTokenIgnoresStaleLaunchSessionGameID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleBaishunGetSSTokenReturnsTokenInvalidWhenUserMissing(t *testing.T) {
|
||||
secret := "baishun-test-app-key"
|
||||
token := "app_access_token_baishun"
|
||||
repo := &fakeRepository{
|
||||
platform: gamedomain.Platform{
|
||||
PlatformCode: "baishun",
|
||||
AdapterType: gamedomain.AdapterBaishunV1,
|
||||
CallbackSecretCiphertext: secret,
|
||||
AdapterConfigJSON: `{"app_id":21397507,"app_channel":"mesh","uid_mode":"display_user_id"}`,
|
||||
},
|
||||
session: gamedomain.LaunchSession{
|
||||
AppCode: "lalu",
|
||||
SessionID: "game_sess_baishun_missing_user",
|
||||
UserID: 42,
|
||||
DisplayUserID: "420001",
|
||||
RoomID: "room_1",
|
||||
PlatformCode: "baishun",
|
||||
GameID: "baishun_greedy",
|
||||
ProviderGameID: "1006",
|
||||
LaunchTokenHash: stableHash(token),
|
||||
Status: gamedomain.SessionActive,
|
||||
ExpiresAtMS: 1700086400000,
|
||||
},
|
||||
}
|
||||
svc := New(Config{}, repo, &fakeWallet{}, &fakeUser{err: xerr.New(xerr.NotFound, "user not found")})
|
||||
svc.now = func() time.Time { return time.UnixMilli(1700000000000) }
|
||||
|
||||
raw, contentType, err := svc.HandleCallback(context.Background(), &gamev1.CallbackRequest{
|
||||
Meta: &gamev1.RequestMeta{RequestId: "req-baishun-missing-user", AppCode: "lalu"},
|
||||
PlatformCode: "baishun",
|
||||
Operation: "get_sstoken",
|
||||
RawBody: baishunTestBody(t, secret, `{"app_id":21397507,"user_id":"420001","code":"app_access_token_baishun"}`, 1700000000),
|
||||
RemoteAddr: "3.1.174.194:443",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleCallback get_sstoken failed: %v", err)
|
||||
}
|
||||
if contentType != "application/json" || !strings.Contains(string(raw), `"code":1001`) || !strings.Contains(string(raw), `"message":"token invalid"`) {
|
||||
t.Fatalf("baishun missing user response mismatch: %s", raw)
|
||||
}
|
||||
if strings.Contains(string(raw), `"code":1002`) || strings.Contains(string(raw), "read data failed") {
|
||||
t.Fatalf("baishun missing user must not look like read data failure: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleCallbackChangeCoinUsesWalletAndOrderIdempotency(t *testing.T) {
|
||||
repo := &fakeRepository{
|
||||
launchable: gamedomain.LaunchableGame{CatalogItem: gamedomain.CatalogItem{GameID: "demo_rocket_001", ProviderGameID: "rocket_001"}},
|
||||
@ -1313,10 +1359,14 @@ func (f *fakeActivity) ConsumeLevelEvent(_ context.Context, req *activityv1.Cons
|
||||
|
||||
type fakeUser struct {
|
||||
lastGet *userv1.GetUserRequest
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeUser) GetUser(_ context.Context, req *userv1.GetUserRequest) (*userv1.GetUserResponse, error) {
|
||||
f.lastGet = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &userv1.GetUserResponse{User: &userv1.User{
|
||||
UserId: req.GetUserId(),
|
||||
DisplayUserId: "420001",
|
||||
|
||||
@ -289,7 +289,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
||||
if err := r.ensureIndexDefinition(ctx, "game_level_event_outbox", "idx_game_level_event_retention", []string{"app_code", "status", "updated_at_ms", "event_id"}, "ALTER TABLE game_level_event_outbox ADD INDEX idx_game_level_event_retention(app_code, status, updated_at_ms, event_id)"); err != nil {
|
||||
return err
|
||||
}
|
||||
return r.seedDefaults(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error {
|
||||
@ -356,33 +356,6 @@ func equalStringSlices(left []string, right []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Repository) seedDefaults(ctx context.Context) error {
|
||||
if _, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO game_platforms (app_code, platform_code, platform_name, status, api_base_url, sort_order, created_at_ms, updated_at_ms)
|
||||
VALUES ('lalu', 'demo', 'Demo Games', 'active', 'https://game.example.local/h5', 10, 0, 0)
|
||||
ON DUPLICATE KEY UPDATE platform_name = VALUES(platform_name)`); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO game_catalog (
|
||||
app_code, game_id, platform_code, provider_game_id, game_name, category, icon_url, cover_url,
|
||||
launch_mode, orientation, min_coin, status, sort_order, tags, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'lalu', 'demo_rocket_001', 'demo', 'rocket_001', 'Rocket', 'casual',
|
||||
'https://cdn.example.com/game/rocket.png', 'https://cdn.example.com/game/rocket-cover.png',
|
||||
'h5_popup', 'portrait', 100, 'active', 10, JSON_ARRAY('demo','voice_room'), 0, 0
|
||||
) ON DUPLICATE KEY UPDATE game_name = VALUES(game_name)`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`INSERT INTO game_display_rules (
|
||||
app_code, rule_id, game_id, scene, region_id, language, platform, visible, enabled, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (
|
||||
'lalu', 'demo_rocket_voice_default', 'demo_rocket_001', 'voice_room', 0, '', '', 1, 1, 10, 0, 0
|
||||
) ON DUPLICATE KEY UPDATE visible = VALUES(visible), enabled = VALUES(enabled)`)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
|
||||
|
||||
@ -99,6 +99,7 @@ tencent_im:
|
||||
user_sig_ttl: "24h"
|
||||
endpoint: "adminapisgp.im.qcloud.com"
|
||||
request_timeout: "5s"
|
||||
group_id_prefix: "test_"
|
||||
callback_url: ""
|
||||
callback_auth_token: ""
|
||||
|
||||
|
||||
@ -15,6 +15,8 @@ cors:
|
||||
- "http://127.0.0.1:3000"
|
||||
- "http://localhost:5173"
|
||||
- "http://127.0.0.1:5173"
|
||||
- "http://localhost:30000"
|
||||
- "http://127.0.0.1:30000"
|
||||
# 本地工具页会用 10300/8787/8788 承载静态 HTML;精确放行测试端口,避免用 "*" 破坏凭证请求边界。
|
||||
- "http://localhost:10300"
|
||||
- "http://127.0.0.1:10300"
|
||||
|
||||
@ -130,6 +130,7 @@ func New(cfg config.Config) (*App, error) {
|
||||
UserSigTTL: cfg.TencentIM.UserSigTTL,
|
||||
AdminIdentifier: cfg.TencentIM.AdminIdentifier,
|
||||
CallbackAuthToken: cfg.TencentIM.CallbackAuthToken,
|
||||
GroupIDPrefix: cfg.TencentIM.GroupIDPrefix,
|
||||
}, userProfileClient)
|
||||
if cfg.TencentIM.SDKAppID > 0 && strings.TrimSpace(cfg.TencentIM.SecretKey) != "" {
|
||||
imClient, err := tencentim.NewRESTClient(cfg.TencentIM.RESTConfig())
|
||||
|
||||
@ -166,6 +166,8 @@ type TencentIMConfig struct {
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
// RequestTimeout 是 gateway 调腾讯 IM REST 的请求超时。
|
||||
RequestTimeout time.Duration `yaml:"request_timeout"`
|
||||
// GroupIDPrefix 给测试服等隔离环境使用,正式服为空以保持既有腾讯 IM 群不变。
|
||||
GroupIDPrefix string `yaml:"group_id_prefix"`
|
||||
// CallbackURL 是在腾讯云 IM 控制台配置的服务端回调地址。
|
||||
CallbackURL string `yaml:"callback_url"`
|
||||
// CallbackAuthToken 是腾讯云 IM 回调鉴权 token,只用于校验回调来源。
|
||||
@ -462,6 +464,7 @@ func (cfg *Config) Normalize() error {
|
||||
if cfg.TencentIM.RequestTimeout <= 0 {
|
||||
cfg.TencentIM.RequestTimeout = 5 * time.Second
|
||||
}
|
||||
cfg.TencentIM.GroupIDPrefix = strings.TrimSpace(cfg.TencentIM.GroupIDPrefix)
|
||||
cfg.TencentIM.CallbackURL = strings.TrimSpace(cfg.TencentIM.CallbackURL)
|
||||
cfg.TencentIM.CallbackAuthToken = strings.TrimSpace(cfg.TencentIM.CallbackAuthToken)
|
||||
|
||||
|
||||
@ -56,7 +56,7 @@ func (h *Handler) getAppBootstrap(writer http.ResponseWriter, request *http.Requ
|
||||
FeatureFlags: map[string]bool{
|
||||
"wallet_recharge": true,
|
||||
"wallet_withdraw": true,
|
||||
"diamond_exchange": true,
|
||||
"diamond_exchange": false,
|
||||
"message_tab": true,
|
||||
"host_center": true,
|
||||
"room_create": true,
|
||||
|
||||
@ -72,6 +72,10 @@ func (h *Handler) UploadAvatar(writer http.ResponseWriter, request *http.Request
|
||||
h.uploadAvatar(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) ProxyEffectMedia(writer http.ResponseWriter, request *http.Request) {
|
||||
h.proxyEffectMedia(writer, request)
|
||||
}
|
||||
|
||||
func (h *Handler) HandlePushToken(writer http.ResponseWriter, request *http.Request) {
|
||||
h.handlePushToken(writer, request)
|
||||
}
|
||||
|
||||
@ -0,0 +1,94 @@
|
||||
package appapi
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
const effectMediaProxyMaxBytes int64 = 30 << 20
|
||||
|
||||
var (
|
||||
effectMediaProxyHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
effectMediaProxyHosts = map[string]struct{}{
|
||||
"media.haiyihy.com": {},
|
||||
}
|
||||
effectMediaProxyExtensions = map[string]string{
|
||||
".pag": "application/octet-stream",
|
||||
".svga": "application/octet-stream",
|
||||
}
|
||||
)
|
||||
|
||||
// proxyEffectMedia streams trusted binary animation assets through gateway so
|
||||
// browser-only SVGA/PAG runtimes can read them even when the media CDN has no CORS.
|
||||
func (h *Handler) proxyEffectMedia(writer http.ResponseWriter, request *http.Request) {
|
||||
rawURL := strings.TrimSpace(request.URL.Query().Get("url"))
|
||||
sourceURL, contentType, ok := validateEffectMediaProxyURL(rawURL)
|
||||
if !ok {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
|
||||
upstreamRequest, err := http.NewRequestWithContext(request.Context(), http.MethodGet, sourceURL.String(), nil)
|
||||
if err != nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
upstreamRequest.Header.Set("Accept", "application/octet-stream,*/*")
|
||||
upstreamRequest.Header.Set("User-Agent", "hyapp-gateway-effect-media-proxy/1.0")
|
||||
|
||||
upstream, err := effectMediaProxyHTTPClient.Do(upstreamRequest)
|
||||
if err != nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
defer upstream.Body.Close()
|
||||
|
||||
if upstream.StatusCode < http.StatusOK || upstream.StatusCode >= http.StatusMultipleChoices {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
if upstream.ContentLength > effectMediaProxyMaxBytes {
|
||||
httpkit.WriteError(writer, request, http.StatusRequestEntityTooLarge, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
if value := strings.TrimSpace(upstream.Header.Get("Content-Type")); value != "" {
|
||||
contentType = value
|
||||
}
|
||||
|
||||
header := writer.Header()
|
||||
header.Set("Content-Type", contentType)
|
||||
header.Set("Cache-Control", "public, max-age=86400")
|
||||
header.Set("X-Content-Type-Options", "nosniff")
|
||||
if upstream.ContentLength >= 0 {
|
||||
header.Set("Content-Length", strconv.FormatInt(upstream.ContentLength, 10))
|
||||
}
|
||||
|
||||
writer.WriteHeader(http.StatusOK)
|
||||
_, _ = io.Copy(writer, io.LimitReader(upstream.Body, effectMediaProxyMaxBytes+1))
|
||||
}
|
||||
|
||||
func validateEffectMediaProxyURL(rawURL string) (*url.URL, string, bool) {
|
||||
sourceURL, err := url.Parse(rawURL)
|
||||
if err != nil || sourceURL == nil {
|
||||
return nil, "", false
|
||||
}
|
||||
if sourceURL.Scheme != "https" {
|
||||
return nil, "", false
|
||||
}
|
||||
host := strings.ToLower(sourceURL.Hostname())
|
||||
if _, ok := effectMediaProxyHosts[host]; !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
contentType, ok := effectMediaProxyExtensions[strings.ToLower(path.Ext(sourceURL.Path))]
|
||||
if !ok {
|
||||
return nil, "", false
|
||||
}
|
||||
return sourceURL, contentType, true
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package appapi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEffectMediaProxyURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
ok bool
|
||||
}{
|
||||
{
|
||||
name: "svga media host",
|
||||
url: "https://media.haiyihy.com/admin/files/1/20260602/file.svga",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "pag media host",
|
||||
url: "https://media.haiyihy.com/admin/files/1/20260602/file.pag?sign=1",
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "reject http",
|
||||
url: "http://media.haiyihy.com/admin/files/1/file.svga",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "reject untrusted host",
|
||||
url: "https://example.com/admin/files/1/file.svga",
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "reject regular image",
|
||||
url: "https://media.haiyihy.com/admin/files/1/file.png",
|
||||
ok: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, ok := validateEffectMediaProxyURL(test.url)
|
||||
if ok != test.ok {
|
||||
t.Fatalf("ok mismatch: got=%t want=%t", ok, test.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,7 @@ type TencentIMConfig struct {
|
||||
SDKAppID int64
|
||||
AdminIdentifier string
|
||||
CallbackAuthToken string
|
||||
GroupIDPrefix string
|
||||
}
|
||||
|
||||
// TencentRTCConfig 是腾讯云 RTC 服务端回调校验需要的最小配置。
|
||||
|
||||
@ -92,7 +92,7 @@ func (h *Handler) handleTencentIMCallback(writer http.ResponseWriter, request *h
|
||||
}
|
||||
|
||||
func (h *Handler) handleTencentIMJoinCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||||
parsed := imgroup.Parse(body.GroupID)
|
||||
parsed := imgroup.ParseWithPrefix(body.GroupID, h.tencentIM.GroupIDPrefix)
|
||||
userID, ok := parseTencentIMUserID(body.Requestor)
|
||||
if !ok {
|
||||
writeTencentCallback(writer, http.StatusOK, 1, "join guard input invalid")
|
||||
@ -149,7 +149,7 @@ func (h *Handler) handleTencentIMBroadcastJoinCallback(writer http.ResponseWrite
|
||||
}
|
||||
|
||||
func (h *Handler) handleTencentIMSendCallback(writer http.ResponseWriter, request *http.Request, body tencentIMCallbackBody) {
|
||||
parsed := imgroup.Parse(body.GroupID)
|
||||
parsed := imgroup.ParseWithPrefix(body.GroupID, h.tencentIM.GroupIDPrefix)
|
||||
switch parsed.Kind {
|
||||
case imgroup.KindGlobalBroadcast, imgroup.KindRegionBroadcast:
|
||||
if h.tencentIMCallbackSenderIsServer(body) {
|
||||
|
||||
@ -63,6 +63,7 @@ type TencentIMConfig struct {
|
||||
UserSigTTL time.Duration
|
||||
AdminIdentifier string
|
||||
CallbackAuthToken string
|
||||
GroupIDPrefix string
|
||||
}
|
||||
|
||||
// TencentIMAccountImporter 是 gateway 在签发 UserSig 前补偿导入账号的最小边界。
|
||||
@ -333,7 +334,7 @@ func (h *Handler) issueTencentIMUserSig(writer http.ResponseWriter, request *htt
|
||||
}
|
||||
}
|
||||
|
||||
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId())
|
||||
joinGroups, ok := imJoinGroupsForUser(app, user.GetRegionId(), h.tencentIM.GroupIDPrefix)
|
||||
if !ok {
|
||||
// 生成 join_groups 失败代表服务端 app_code/region_id 配置异常,不能返回缺失群列表让客户端自行猜。
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
@ -363,8 +364,12 @@ type tencentIMJoinGroup struct {
|
||||
RegionID int64 `json:"region_id,omitempty"`
|
||||
}
|
||||
|
||||
func imJoinGroupsForUser(app string, regionID int64) ([]tencentIMJoinGroup, bool) {
|
||||
globalGroupID, err := imgroup.GlobalBroadcastGroupID(app)
|
||||
func imJoinGroupsForUser(app string, regionID int64, groupIDPrefix ...string) ([]tencentIMJoinGroup, bool) {
|
||||
prefix := ""
|
||||
if len(groupIDPrefix) > 0 {
|
||||
prefix = groupIDPrefix[0]
|
||||
}
|
||||
globalGroupID, err := imgroup.GlobalBroadcastGroupIDWithPrefix(prefix, app)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
@ -375,7 +380,7 @@ func imJoinGroupsForUser(app string, regionID int64) ([]tencentIMJoinGroup, bool
|
||||
}}
|
||||
if regionID > 0 {
|
||||
// 区域群来自 user-service 当前 region_id,客户端不能自选区域群;区域变更后下一次 UserSig 返回新群。
|
||||
regionGroupID, err := imgroup.RegionBroadcastGroupID(app, regionID)
|
||||
regionGroupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(prefix, app, regionID)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@ -57,6 +57,7 @@ type AppHandlers struct {
|
||||
HandleTencentRTCCallback http.HandlerFunc
|
||||
UploadFile http.HandlerFunc
|
||||
UploadAvatar http.HandlerFunc
|
||||
ProxyEffectMedia http.HandlerFunc
|
||||
HandlePushToken http.HandlerFunc
|
||||
}
|
||||
|
||||
@ -276,6 +277,7 @@ func (r routes) registerAppRoutes() {
|
||||
r.public("/tencent-rtc/callback", "", h.HandleTencentRTCCallback)
|
||||
r.auth("/files/upload", "", h.UploadFile)
|
||||
r.auth("/files/avatar/upload", "", h.UploadAvatar)
|
||||
r.public("/media/effects/proxy", http.MethodGet, h.ProxyEffectMedia)
|
||||
r.profile("/devices/push-token", "", h.HandlePushToken)
|
||||
}
|
||||
|
||||
|
||||
@ -313,6 +313,7 @@ type fakeUserProfileClient struct {
|
||||
countryErr error
|
||||
regionID int64
|
||||
regionByUserID map[int64]int64
|
||||
usersByID map[int64]*userv1.User
|
||||
}
|
||||
|
||||
type fakeTencentIMAccountImporter struct {
|
||||
@ -457,6 +458,12 @@ type fakeWalletClient struct {
|
||||
lastGrantResource *walletv1.GrantResourceRequest
|
||||
grantResourceResp *walletv1.ResourceGrantResponse
|
||||
grantResourceErr error
|
||||
lastListRedPackets *walletv1.ListRedPacketsRequest
|
||||
listRedPacketsResp *walletv1.ListRedPacketsResponse
|
||||
lastGetRedPacket *walletv1.GetRedPacketRequest
|
||||
getRedPacketResp *walletv1.GetRedPacketResponse
|
||||
lastClaimRedPacket *walletv1.ClaimRedPacketRequest
|
||||
claimRedPacketResp *walletv1.ClaimRedPacketResponse
|
||||
}
|
||||
|
||||
type fakeMessageInboxClient struct {
|
||||
@ -571,6 +578,11 @@ func (f *fakeUserProfileClient) GetUser(_ context.Context, req *userv1.GetUserRe
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
if f.usersByID != nil {
|
||||
if user := f.usersByID[req.GetUserId()]; user != nil {
|
||||
return &userv1.GetUserResponse{User: user}, nil
|
||||
}
|
||||
}
|
||||
regionID := f.regionID
|
||||
if f.regionByUserID != nil {
|
||||
if mapped := f.regionByUserID[req.GetUserId()]; mapped != 0 {
|
||||
@ -1378,15 +1390,33 @@ func (f *fakeWalletClient) CreateRedPacket(context.Context, *walletv1.CreateRedP
|
||||
return &walletv1.CreateRedPacketResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ClaimRedPacket(context.Context, *walletv1.ClaimRedPacketRequest) (*walletv1.ClaimRedPacketResponse, error) {
|
||||
func (f *fakeWalletClient) ClaimRedPacket(_ context.Context, req *walletv1.ClaimRedPacketRequest) (*walletv1.ClaimRedPacketResponse, error) {
|
||||
f.lastClaimRedPacket = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.claimRedPacketResp != nil {
|
||||
return f.claimRedPacketResp, nil
|
||||
}
|
||||
return &walletv1.ClaimRedPacketResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) ListRedPackets(context.Context, *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) {
|
||||
func (f *fakeWalletClient) ListRedPackets(_ context.Context, req *walletv1.ListRedPacketsRequest) (*walletv1.ListRedPacketsResponse, error) {
|
||||
f.lastListRedPackets = req
|
||||
if f.listRedPacketsResp != nil {
|
||||
return f.listRedPacketsResp, nil
|
||||
}
|
||||
return &walletv1.ListRedPacketsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *fakeWalletClient) GetRedPacket(context.Context, *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) {
|
||||
func (f *fakeWalletClient) GetRedPacket(_ context.Context, req *walletv1.GetRedPacketRequest) (*walletv1.GetRedPacketResponse, error) {
|
||||
f.lastGetRedPacket = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
if f.getRedPacketResp != nil {
|
||||
return f.getRedPacketResp, nil
|
||||
}
|
||||
return &walletv1.GetRedPacketResponse{}, nil
|
||||
}
|
||||
|
||||
@ -1711,7 +1741,9 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
SelectedTierId: "novice_2x",
|
||||
MultiplierPpm: 2_000_000,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "pending",
|
||||
RewardStatus: "granted",
|
||||
WalletTransactionId: "wallet_tx_lucky",
|
||||
CoinBalanceAfter: 8800,
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
},
|
||||
}}
|
||||
@ -1733,13 +1765,16 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
||||
PoolID string `json:"pool_id"`
|
||||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
|
||||
RewardStatus string `json:"reward_status"`
|
||||
WalletTransactionID string `json:"wallet_transaction_id"`
|
||||
CoinBalanceAfter int64 `json:"coin_balance_after"`
|
||||
} `json:"lucky_gift"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode response failed: %v body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 2_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 200 {
|
||||
if envelope.Code != "OK" || envelope.Data.LuckyGift.DrawID != "lucky_draw_test" || envelope.Data.LuckyGift.PoolID != "super_lucky" || envelope.Data.LuckyGift.MultiplierPPM != 2_000_000 || envelope.Data.LuckyGift.EffectiveRewardCoins != 200 || envelope.Data.LuckyGift.RewardStatus != "granted" || envelope.Data.LuckyGift.WalletTransactionID != "wallet_tx_lucky" || envelope.Data.LuckyGift.CoinBalanceAfter != 8800 {
|
||||
t.Fatalf("lucky_gift response mismatch: %+v", envelope.Data.LuckyGift)
|
||||
}
|
||||
}
|
||||
@ -4086,6 +4121,219 @@ func TestBatchUserProfilesReturnsOnlySafeDisplayFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoomRedPacketClaimRecordsIncludesUserProfiles(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{getRedPacketResp: &walletv1.GetRedPacketResponse{
|
||||
ServerTimeMs: 3000,
|
||||
Packet: &walletv1.RedPacket{
|
||||
PacketId: "packet-1",
|
||||
Claims: []*walletv1.RedPacketClaim{
|
||||
{ClaimId: "claim-1", PacketId: "packet-1", UserId: 10002, Amount: 300, Status: "claimed", CreatedAtMs: 1000},
|
||||
{ClaimId: "claim-2", PacketId: "packet-1", UserId: 10003, Amount: 200, Status: "claimed", CreatedAtMs: 2000},
|
||||
},
|
||||
},
|
||||
}}
|
||||
profileClient := &fakeUserProfileClient{}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/claim-records?packetNo=packet-1&page=1&pageSize=1", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-red-packet-claims")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if walletClient.lastGetRedPacket == nil || !walletClient.lastGetRedPacket.GetIncludeClaims() || walletClient.lastGetRedPacket.GetPacketId() != "packet-1" {
|
||||
t.Fatalf("red packet detail request mismatch: %+v", walletClient.lastGetRedPacket)
|
||||
}
|
||||
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
|
||||
t.Fatalf("claim records must batch only visible claim users: %+v", profileClient.lastBatch)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode claim records failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
items, itemsOK := data["items"].([]any)
|
||||
if response.Code != httpkit.CodeOK || !ok || !itemsOK || len(items) != 1 {
|
||||
t.Fatalf("claim records response mismatch: %+v", response)
|
||||
}
|
||||
first, ok := items[0].(map[string]any)
|
||||
if !ok || first["account"] != "110002" || first["nickname"] != "user-10002" || first["avatar"] != "https://cdn.example/avatar.png" {
|
||||
t.Fatalf("claim record should include display profile fields: %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoomRedPacketDetailIncludesSenderProfile(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{getRedPacketResp: &walletv1.GetRedPacketResponse{
|
||||
ServerTimeMs: 3000,
|
||||
Packet: &walletv1.RedPacket{
|
||||
PacketId: "packet-1",
|
||||
RoomId: "room-1",
|
||||
RegionId: 1001,
|
||||
SenderUserId: 10002,
|
||||
PacketType: "normal",
|
||||
TotalAmount: 1000,
|
||||
PacketCount: 5,
|
||||
RemainingCount: 3,
|
||||
Status: "active",
|
||||
},
|
||||
}}
|
||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||
10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001},
|
||||
}}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/detail?packetNo=packet-1", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-red-packet-detail")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if profileClient.lastGet == nil || profileClient.lastGet.GetUserId() != 10002 {
|
||||
t.Fatalf("detail must fetch sender profile: %+v", profileClient.lastGet)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode red-packet detail failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
packet, packetOK := data["packet"].(map[string]any)
|
||||
if response.Code != httpkit.CodeOK || !ok || !packetOK {
|
||||
t.Fatalf("detail response mismatch: %+v", response)
|
||||
}
|
||||
if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" {
|
||||
t.Fatalf("detail packet should include sender display fields: %+v", packet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoomRedPacketClaimIncludesSenderProfile(t *testing.T) {
|
||||
packetBeforeClaim := &walletv1.RedPacket{
|
||||
PacketId: "packet-1",
|
||||
RoomId: "room-1",
|
||||
RegionId: 1001,
|
||||
SenderUserId: 10002,
|
||||
PacketType: "normal",
|
||||
TotalAmount: 1000,
|
||||
PacketCount: 5,
|
||||
RemainingCount: 3,
|
||||
Status: "active",
|
||||
}
|
||||
walletClient := &fakeWalletClient{
|
||||
getRedPacketResp: &walletv1.GetRedPacketResponse{Packet: packetBeforeClaim},
|
||||
claimRedPacketResp: &walletv1.ClaimRedPacketResponse{
|
||||
ServerTimeMs: 4000,
|
||||
BalanceAfter: 700,
|
||||
Packet: &walletv1.RedPacket{
|
||||
PacketId: "packet-1",
|
||||
RoomId: "room-1",
|
||||
RegionId: 1001,
|
||||
SenderUserId: 10002,
|
||||
PacketType: "normal",
|
||||
TotalAmount: 1000,
|
||||
PacketCount: 5,
|
||||
RemainingCount: 2,
|
||||
Status: "active",
|
||||
},
|
||||
Claim: &walletv1.RedPacketClaim{ClaimId: "claim-1", PacketId: "packet-1", UserId: 42, Amount: 200, Status: "claimed", CreatedAtMs: 3500},
|
||||
},
|
||||
}
|
||||
profileClient := &fakeUserProfileClient{usersByID: map[int64]*userv1.User{
|
||||
42: &userv1.User{UserId: 42, DisplayUserId: "880042", Username: "claimer-name", Avatar: "https://cdn.example/claimer.png", RegionId: 1001},
|
||||
10002: &userv1.User{UserId: 10002, DisplayUserId: "880002", Username: "sender-name", Avatar: "https://cdn.example/sender.png", RegionId: 1001},
|
||||
}}
|
||||
guardClient := &fakeRoomGuardClient{presenceResp: &roomv1.VerifyRoomPresenceResponse{Present: true, RoomVersion: 9}}
|
||||
handler := NewHandlerWithConfig(&fakeRoomClient{}, guardClient, nil, nil, TencentIMConfig{}, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/app/voice-room/red-packet/claim", bytes.NewReader([]byte(`{"packetNo":"packet-1"}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-red-packet-claim")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if guardClient.lastPresence == nil || guardClient.lastPresence.GetRoomId() != "room-1" || guardClient.lastPresence.GetUserId() != 42 {
|
||||
t.Fatalf("claim must verify room presence before wallet mutation: %+v", guardClient.lastPresence)
|
||||
}
|
||||
if len(profileClient.getRequests) != 2 || profileClient.getRequests[0].GetUserId() != 42 || profileClient.getRequests[1].GetUserId() != 10002 {
|
||||
t.Fatalf("claim must fetch claimer then sender profiles before response: %+v", profileClient.getRequests)
|
||||
}
|
||||
if walletClient.lastClaimRedPacket == nil || walletClient.lastClaimRedPacket.GetPacketId() != "packet-1" || walletClient.lastClaimRedPacket.GetUserId() != 42 {
|
||||
t.Fatalf("claim request mismatch: %+v", walletClient.lastClaimRedPacket)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode red-packet claim failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
packet, packetOK := data["packet"].(map[string]any)
|
||||
claim, claimOK := data["claim"].(map[string]any)
|
||||
if response.Code != httpkit.CodeOK || !ok || !packetOK || !claimOK {
|
||||
t.Fatalf("claim response mismatch: %+v", response)
|
||||
}
|
||||
if packet["senderAccount"] != "880002" || packet["senderNickname"] != "sender-name" || packet["senderAvatar"] != "https://cdn.example/sender.png" {
|
||||
t.Fatalf("claim packet should include sender display fields: %+v", packet)
|
||||
}
|
||||
if claim["account"] != "880042" || claim["nickname"] != "claimer-name" || claim["avatar"] != "https://cdn.example/claimer.png" {
|
||||
t.Fatalf("claim should keep claimant display fields: %+v", claim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVoiceRoomActiveRedPacketRouteIsRegistered(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{listRedPacketsResp: &walletv1.ListRedPacketsResponse{
|
||||
ServerTimeMs: 4000,
|
||||
Packets: []*walletv1.RedPacket{
|
||||
{PacketId: "packet-active", RoomId: "room-1", RegionId: 1001, SenderUserId: 10002, PacketType: "normal", Status: "active"},
|
||||
{PacketId: "packet-finished", RoomId: "room-1", RegionId: 1001, SenderUserId: 10003, PacketType: "normal", Status: "finished"},
|
||||
},
|
||||
}}
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, profileClient)
|
||||
handler.SetWalletClient(walletClient)
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodGet, "/api/v1/app/voice-room/red-packet/active?roomId=room-1", nil)
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-red-packet-active")
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if walletClient.lastListRedPackets == nil || walletClient.lastListRedPackets.GetRoomId() != "room-1" || walletClient.lastListRedPackets.GetViewerUserId() != 42 {
|
||||
t.Fatalf("active red-packet request mismatch: %+v", walletClient.lastListRedPackets)
|
||||
}
|
||||
if profileClient.lastBatch == nil || len(profileClient.lastBatch.GetUserIds()) != 1 || profileClient.lastBatch.GetUserIds()[0] != 10002 {
|
||||
t.Fatalf("active red-packet should batch only visible sender profiles: %+v", profileClient.lastBatch)
|
||||
}
|
||||
var response httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatalf("decode active red-packet response failed: %v", err)
|
||||
}
|
||||
data, ok := response.Data.(map[string]any)
|
||||
packets, packetsOK := data["packets"].([]any)
|
||||
if response.Code != httpkit.CodeOK || !ok || !packetsOK || len(packets) != 1 {
|
||||
t.Fatalf("active red-packet response mismatch: %+v", response)
|
||||
}
|
||||
first, ok := packets[0].(map[string]any)
|
||||
if !ok || first["packetNo"] != "packet-active" || first["status"] != "ACTIVE" || first["senderNickname"] != "user-10002" || first["senderAvatar"] != "https://cdn.example/avatar.png" {
|
||||
t.Fatalf("active packet item mismatch: %+v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetMyBalancesUsesAuthenticatedUserIDAndDefaultsCoin(t *testing.T) {
|
||||
walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{
|
||||
Balances: []*walletv1.AssetBalance{{AssetType: "COIN", AvailableAmount: 120, FrozenAmount: 3, Version: 2}},
|
||||
@ -5046,6 +5294,36 @@ func TestTencentIMUserSigUsesAuthenticatedUserIDAndFailsClosed(t *testing.T) {
|
||||
t.Fatalf("region join group mismatch: %+v", groups[1])
|
||||
}
|
||||
|
||||
prefixedRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
||||
SDKAppID: 1400000000,
|
||||
SecretKey: "secret",
|
||||
UserSigTTL: time.Hour,
|
||||
GroupIDPrefix: "test_",
|
||||
}, profileClient).Routes(auth.NewVerifier("secret"))
|
||||
prefixedRequest := httptest.NewRequest(http.MethodGet, "/api/v1/im/usersig", nil)
|
||||
prefixedRequest.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
prefixedRecorder := httptest.NewRecorder()
|
||||
prefixedRouter.ServeHTTP(prefixedRecorder, prefixedRequest)
|
||||
if prefixedRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("prefixed status mismatch: got %d body=%s", prefixedRecorder.Code, prefixedRecorder.Body.String())
|
||||
}
|
||||
var prefixedResponse httpkit.ResponseEnvelope
|
||||
if err := json.NewDecoder(prefixedRecorder.Body).Decode(&prefixedResponse); err != nil {
|
||||
t.Fatalf("decode prefixed response failed: %v", err)
|
||||
}
|
||||
prefixedData, ok := prefixedResponse.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("prefixed response data mismatch: %+v", prefixedResponse)
|
||||
}
|
||||
prefixedGroups, ok := prefixedData["join_groups"].([]any)
|
||||
if !ok || len(prefixedGroups) != 2 {
|
||||
t.Fatalf("prefixed join_groups mismatch: %+v", prefixedData["join_groups"])
|
||||
}
|
||||
prefixedRegionGroup, ok := prefixedGroups[1].(map[string]any)
|
||||
if !ok || prefixedRegionGroup["group_id"] != "test_hy_lalu_bc_r_1001" {
|
||||
t.Fatalf("prefixed region join group mismatch: %+v", prefixedGroups[1])
|
||||
}
|
||||
|
||||
missingConfigRouter := NewHandlerWithConfig(&fakeRoomClient{}, nil, nil, nil, TencentIMConfig{
|
||||
SDKAppID: 1400000000,
|
||||
UserSigTTL: time.Hour,
|
||||
@ -5397,6 +5675,27 @@ func TestTencentIMBroadcastCallbacksUseRegionGuardAndServerOnlySend(t *testing.T
|
||||
assertTencentCallback(t, adminSendRecorder, http.StatusOK, 0)
|
||||
}
|
||||
|
||||
func TestTencentIMBroadcastCallbacksUseConfiguredGroupPrefix(t *testing.T) {
|
||||
profileClient := &fakeUserProfileClient{regionID: 1001}
|
||||
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
|
||||
SDKAppID: 1400000000,
|
||||
AdminIdentifier: "administrator",
|
||||
GroupIDPrefix: "test_",
|
||||
}, profileClient).Routes(auth.NewVerifier("secret"))
|
||||
|
||||
joinBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"test_hy_lalu_bc_r_1001","Requestor_Account":"42"}`)
|
||||
joinRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(joinBody))
|
||||
joinRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(joinRecorder, joinRequest)
|
||||
assertTencentCallback(t, joinRecorder, http.StatusOK, 0)
|
||||
|
||||
oldGroupBody := []byte(`{"CallbackCommand":"Group.CallbackBeforeApplyJoinGroup","GroupId":"hy_lalu_bc_r_1001","Requestor_Account":"42"}`)
|
||||
oldGroupRequest := httptest.NewRequest(http.MethodPost, "/api/v1/tencent-im/callback?SdkAppid=1400000000&CallbackCommand=Group.CallbackBeforeApplyJoinGroup", bytes.NewReader(oldGroupBody))
|
||||
oldGroupRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(oldGroupRecorder, oldGroupRequest)
|
||||
assertTencentCallback(t, oldGroupRecorder, http.StatusOK, 1)
|
||||
}
|
||||
|
||||
func TestTencentIMCallbackAuthAndUnknownCommand(t *testing.T) {
|
||||
router := NewHandlerWithConfig(&fakeRoomClient{}, &fakeRoomGuardClient{}, nil, nil, TencentIMConfig{
|
||||
SDKAppID: 1400000000,
|
||||
|
||||
@ -1259,6 +1259,11 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
}
|
||||
senderRegionID, err := h.resolveGiftSenderRegionID(request)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
targetIsHost, targetHostRegionID, targetAgencyOwnerUserID, err := h.resolveGiftTargetHostScope(request, firstUserID(targetUserIDs))
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
@ -1273,6 +1278,7 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
GiftId: body.GiftID,
|
||||
GiftCount: body.GiftCount,
|
||||
PoolId: body.PoolID,
|
||||
SenderRegionId: senderRegionID,
|
||||
TargetIsHost: targetIsHost,
|
||||
TargetHostRegionId: targetHostRegionID,
|
||||
TargetAgencyOwnerUserId: targetAgencyOwnerUserID,
|
||||
@ -1280,6 +1286,23 @@ func (h *Handler) sendGift(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.Write(writer, request, resp, err)
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftSenderRegionID(request *http.Request) (int64, error) {
|
||||
if h.userProfileClient == nil {
|
||||
return 0, xerr.New(xerr.Unavailable, "user service is not configured")
|
||||
}
|
||||
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: auth.UserIDFromContext(request.Context()),
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if resp.GetUser() == nil {
|
||||
return 0, xerr.New(xerr.NotFound, "sender not found")
|
||||
}
|
||||
return resp.GetUser().GetRegionId(), nil
|
||||
}
|
||||
|
||||
func (h *Handler) resolveGiftTargetHostScope(request *http.Request, targetUserID int64) (bool, int64, int64, error) {
|
||||
if targetUserID <= 0 || h.userHostClient == nil {
|
||||
// 未装配 host client 时送礼仍可继续,但工资周期钻石 fail-closed,避免客户端伪造主播入账。
|
||||
|
||||
@ -45,6 +45,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
UserIdentityClient: h.userIdentityClient,
|
||||
UserProfileClient: h.userProfileClient,
|
||||
UserHostClient: h.userHostClient,
|
||||
IMGroupIDPrefix: h.tencentIM.GroupIDPrefix,
|
||||
})
|
||||
userAPI := userapi.New(userapi.Config{
|
||||
UserIdentityClient: h.userIdentityClient,
|
||||
@ -97,6 +98,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
SDKAppID: h.tencentIM.SDKAppID,
|
||||
AdminIdentifier: h.tencentIM.AdminIdentifier,
|
||||
CallbackAuthToken: h.tencentIM.CallbackAuthToken,
|
||||
GroupIDPrefix: h.tencentIM.GroupIDPrefix,
|
||||
},
|
||||
TencentRTC: callbackapi.TencentRTCConfig{
|
||||
SDKAppID: h.tencentRTC.SDKAppID,
|
||||
@ -147,6 +149,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
|
||||
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
|
||||
UploadFile: appAPI.UploadFile,
|
||||
UploadAvatar: appAPI.UploadAvatar,
|
||||
ProxyEffectMedia: appAPI.ProxyEffectMedia,
|
||||
HandlePushToken: appAPI.HandlePushToken,
|
||||
},
|
||||
User: userHandlers,
|
||||
|
||||
@ -175,6 +175,10 @@ type vipRewardItemData struct {
|
||||
Name string `json:"name"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
ExpiresAtMS int64 `json:"expires_at_ms"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
AssetURL string `json:"asset_url"`
|
||||
PreviewURL string `json:"preview_url"`
|
||||
AnimationURL string `json:"animation_url"`
|
||||
}
|
||||
|
||||
// getWalletOverview 返回钱包二级页摘要;我的页首屏使用更轻的 WalletValueSummary。
|
||||
|
||||
@ -16,6 +16,7 @@ type Handler struct {
|
||||
userIdentityClient client.UserIdentityClient
|
||||
userProfileClient client.UserProfileClient
|
||||
userHostClient client.UserHostClient
|
||||
imGroupIDPrefix string
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@ -25,6 +26,7 @@ type Config struct {
|
||||
UserIdentityClient client.UserIdentityClient
|
||||
UserProfileClient client.UserProfileClient
|
||||
UserHostClient client.UserHostClient
|
||||
IMGroupIDPrefix string
|
||||
}
|
||||
|
||||
func New(config Config) *Handler {
|
||||
@ -35,6 +37,7 @@ func New(config Config) *Handler {
|
||||
userIdentityClient: config.UserIdentityClient,
|
||||
userProfileClient: config.UserProfileClient,
|
||||
userHostClient: config.UserHostClient,
|
||||
imGroupIDPrefix: config.IMGroupIDPrefix,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -192,7 +192,7 @@ func (h *Handler) getCurrentRegionIMGroup(writer http.ResponseWriter, request *h
|
||||
httpkit.WriteError(writer, request, http.StatusConflict, "voice_room_region_missing", "conflict")
|
||||
return
|
||||
}
|
||||
groupID, err := imgroup.RegionBroadcastGroupID(appcode.FromContext(request.Context()), user.GetRegionId())
|
||||
groupID, err := imgroup.RegionBroadcastGroupIDWithPrefix(h.imGroupIDPrefix, appcode.FromContext(request.Context()), user.GetRegionId())
|
||||
if err != nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
|
||||
return
|
||||
@ -265,14 +265,14 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"packet": voiceRoomPacketFromProto(resp.GetPacket()),
|
||||
"packet": voiceRoomPacketFromProtoWithSender(resp.GetPacket(), user),
|
||||
"balanceAfter": resp.GetBalanceAfter(),
|
||||
"serverTime": resp.GetServerTimeMs(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) listActiveRedPackets(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
if h.walletClient == nil || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
@ -295,12 +295,21 @@ func (h *Handler) listActiveRedPackets(writer http.ResponseWriter, request *http
|
||||
writeRedPacketRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
packets := make([]map[string]any, 0, len(resp.GetPackets()))
|
||||
activePackets := make([]*walletv1.RedPacket, 0, len(resp.GetPackets()))
|
||||
for _, packet := range resp.GetPackets() {
|
||||
if packet.GetStatus() == "active" || packet.GetStatus() == "waiting_open" {
|
||||
packets = append(packets, voiceRoomPacketFromProto(packet))
|
||||
activePackets = append(activePackets, packet)
|
||||
}
|
||||
}
|
||||
profiles, err := h.redPacketSenderProfiles(request, activePackets)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
packets := make([]map[string]any, 0, len(activePackets))
|
||||
for _, packet := range activePackets {
|
||||
packets = append(packets, voiceRoomPacketFromProtoWithSender(packet, profiles[packet.GetSenderUserId()]))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"packets": packets,
|
||||
"serverTime": resp.GetServerTimeMs(),
|
||||
@ -346,6 +355,11 @@ func (h *Handler) claimVoiceRoomRedPacket(writer http.ResponseWriter, request *h
|
||||
httpkit.WriteError(writer, request, http.StatusForbidden, "voice_room_region_not_match", "permission denied")
|
||||
return
|
||||
}
|
||||
sender, err := h.redPacketSenderProfile(request, packet, user)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
commandID := fmt.Sprintf("red_packet_claim:%s:%d", packetNo, auth.UserIDFromContext(request.Context()))
|
||||
resp, err := h.walletClient.ClaimRedPacket(request.Context(), &walletv1.ClaimRedPacketRequest{
|
||||
AppCode: appcode.FromContext(request.Context()),
|
||||
@ -358,15 +372,15 @@ func (h *Handler) claimVoiceRoomRedPacket(writer http.ResponseWriter, request *h
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"claim": voiceRoomClaimFromProto(resp.GetClaim()),
|
||||
"packet": voiceRoomPacketFromProto(resp.GetPacket()),
|
||||
"claim": voiceRoomClaimFromProtoWithUser(resp.GetClaim(), user),
|
||||
"packet": voiceRoomPacketFromProtoWithSender(resp.GetPacket(), sender),
|
||||
"balanceAfter": resp.GetBalanceAfter(),
|
||||
"serverTime": resp.GetServerTimeMs(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) getVoiceRoomRedPacket(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
if h.walletClient == nil || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
@ -388,13 +402,22 @@ func (h *Handler) getVoiceRoomRedPacket(writer http.ResponseWriter, request *htt
|
||||
writeRedPacketRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
sender, err := h.redPacketSenderProfile(request, resp.GetPacket(), nil)
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"packet": voiceRoomPacketFromProto(resp.GetPacket()),
|
||||
"packet": voiceRoomPacketFromProtoWithSender(resp.GetPacket(), sender),
|
||||
"serverTime": resp.GetServerTimeMs(),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) listRedPacketClaims(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil || h.userProfileClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
return
|
||||
}
|
||||
packetNo := strings.TrimSpace(request.URL.Query().Get("packetNo"))
|
||||
if packetNo == "" {
|
||||
packetNo = strings.TrimSpace(request.URL.Query().Get("packet_id"))
|
||||
@ -435,9 +458,14 @@ func (h *Handler) listRedPacketClaims(writer http.ResponseWriter, request *http.
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
profiles, err := h.redPacketClaimProfiles(request, all[start:end])
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, end-start)
|
||||
for _, claim := range all[start:end] {
|
||||
items = append(items, voiceRoomClaimFromProto(claim))
|
||||
items = append(items, voiceRoomClaimFromProtoWithUser(claim, profiles[claim.GetUserId()]))
|
||||
}
|
||||
httpkit.WriteOK(writer, request, map[string]any{
|
||||
"items": items,
|
||||
@ -448,6 +476,80 @@ func (h *Handler) listRedPacketClaims(writer http.ResponseWriter, request *http.
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) redPacketClaimProfiles(request *http.Request, claims []*walletv1.RedPacketClaim) (map[int64]*userv1.User, error) {
|
||||
userIDs := make([]int64, 0, len(claims))
|
||||
seen := make(map[int64]struct{}, len(claims))
|
||||
for _, claim := range claims {
|
||||
userID := claim.GetUserId()
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[userID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return map[int64]*userv1.User{}, nil
|
||||
}
|
||||
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetUsers(), nil
|
||||
}
|
||||
|
||||
func (h *Handler) redPacketSenderProfiles(request *http.Request, packets []*walletv1.RedPacket) (map[int64]*userv1.User, error) {
|
||||
userIDs := make([]int64, 0, len(packets))
|
||||
seen := make(map[int64]struct{}, len(packets))
|
||||
for _, packet := range packets {
|
||||
userID := packet.GetSenderUserId()
|
||||
if userID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[userID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[userID] = struct{}{}
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return map[int64]*userv1.User{}, nil
|
||||
}
|
||||
// active 列表可能有多个红包,发送者展示资料必须批量拉取,避免房间侧边栏刷新造成 N+1 RPC。
|
||||
resp, err := h.userProfileClient.BatchGetUsers(request.Context(), &userv1.BatchGetUsersRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserIds: userIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetUsers(), nil
|
||||
}
|
||||
|
||||
func (h *Handler) redPacketSenderProfile(request *http.Request, packet *walletv1.RedPacket, currentUser *userv1.User) (*userv1.User, error) {
|
||||
senderUserID := packet.GetSenderUserId()
|
||||
if senderUserID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 发送者资料属于 user-service;claim 流程复用当前用户资料,避免同一用户发抢自己的红包时重复 RPC。
|
||||
if currentUser != nil && currentUser.GetUserId() == senderUserID {
|
||||
return currentUser, nil
|
||||
}
|
||||
resp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
|
||||
Meta: httpkit.UserMeta(request, ""),
|
||||
UserId: senderUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.GetUser(), nil
|
||||
}
|
||||
|
||||
func (h *Handler) listSentRedPackets(writer http.ResponseWriter, request *http.Request) {
|
||||
if h.walletClient == nil {
|
||||
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
|
||||
@ -902,14 +1004,55 @@ func voiceRoomPacketFromProto(packet *walletv1.RedPacket) map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
func voiceRoomPacketFromProtoWithSender(packet *walletv1.RedPacket, sender *userv1.User) map[string]any {
|
||||
data := voiceRoomPacketFromProto(packet)
|
||||
if packet == nil {
|
||||
return data
|
||||
}
|
||||
account := ""
|
||||
nickname := ""
|
||||
avatar := ""
|
||||
if sender != nil {
|
||||
account = sender.GetDisplayUserId()
|
||||
nickname = sender.GetUsername()
|
||||
avatar = sender.GetAvatar()
|
||||
}
|
||||
if account == "" && packet.GetSenderUserId() > 0 {
|
||||
account = strconv.FormatInt(packet.GetSenderUserId(), 10)
|
||||
}
|
||||
// wallet 只拥有红包和账务事实;HTTP 展示字段在 gateway 出口由 user-service 资料补齐。
|
||||
data["senderAccount"] = account
|
||||
data["senderNickname"] = nickname
|
||||
data["senderAvatar"] = avatar
|
||||
return data
|
||||
}
|
||||
|
||||
func voiceRoomClaimFromProto(claim *walletv1.RedPacketClaim) map[string]any {
|
||||
return voiceRoomClaimFromProtoWithUser(claim, nil)
|
||||
}
|
||||
|
||||
func voiceRoomClaimFromProtoWithUser(claim *walletv1.RedPacketClaim, user *userv1.User) map[string]any {
|
||||
if claim == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
account := ""
|
||||
nickname := ""
|
||||
avatar := ""
|
||||
if user != nil {
|
||||
account = user.GetDisplayUserId()
|
||||
nickname = user.GetUsername()
|
||||
avatar = user.GetAvatar()
|
||||
}
|
||||
if account == "" && claim.GetUserId() > 0 {
|
||||
account = strconv.FormatInt(claim.GetUserId(), 10)
|
||||
}
|
||||
return map[string]any{
|
||||
"claimId": claim.GetClaimId(),
|
||||
"packetNo": claim.GetPacketId(),
|
||||
"userId": claim.GetUserId(),
|
||||
"account": account,
|
||||
"nickname": nickname,
|
||||
"avatar": avatar,
|
||||
"amount": claim.GetAmount(),
|
||||
"status": claim.GetStatus(),
|
||||
"createdAt": claim.GetCreatedAtMs(),
|
||||
|
||||
@ -5,9 +5,10 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/services/gateway-service/internal/auth"
|
||||
|
||||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||||
)
|
||||
|
||||
// getMyVIP 返回当前用户 VIP 状态。
|
||||
@ -135,7 +136,21 @@ func vipRewardItemsFromProto(items []*walletv1.VipRewardItem) []vipRewardItemDat
|
||||
Name: item.GetName(),
|
||||
Quantity: item.GetQuantity(),
|
||||
ExpiresAtMS: item.GetExpiresAtMs(),
|
||||
CoverURL: vipRewardCoverURL(item),
|
||||
AssetURL: item.GetAssetUrl(),
|
||||
PreviewURL: item.GetPreviewUrl(),
|
||||
AnimationURL: item.GetAnimationUrl(),
|
||||
})
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func vipRewardCoverURL(item *walletv1.VipRewardItem) string {
|
||||
if item == nil {
|
||||
return ""
|
||||
}
|
||||
if strings.TrimSpace(item.GetPreviewUrl()) != "" {
|
||||
return item.GetPreviewUrl()
|
||||
}
|
||||
return item.GetAssetUrl()
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ node_registry_ttl: "30s"
|
||||
node_registry_heartbeat_interval: "10s"
|
||||
owner_forward_timeout: "2s"
|
||||
lease_ttl: "10s"
|
||||
lucky_gift_send_lock_ttl: "5s"
|
||||
rank_limit: 20
|
||||
snapshot_every_n: 10
|
||||
wallet_service_addr: "wallet-service:13004"
|
||||
|
||||
@ -53,6 +53,7 @@ mysql_auto_migrate: false
|
||||
redis_addr: "TENCENT_REDIS_HOST:6379"
|
||||
redis_password: "TENCENT_REDIS_PASSWORD"
|
||||
redis_db: 0
|
||||
lucky_gift_send_lock_ttl: "5s"
|
||||
presence_stale_after: "2m"
|
||||
presence_stale_scan_interval: "30s"
|
||||
mic_publish_timeout: "15s"
|
||||
|
||||
@ -8,6 +8,7 @@ log:
|
||||
include_response_body: false
|
||||
max_payload_bytes: 2048
|
||||
lease_ttl: "10s"
|
||||
lucky_gift_send_lock_ttl: "5s"
|
||||
rank_limit: 20
|
||||
snapshot_every_n: 10
|
||||
grpc_addr: ":13001"
|
||||
|
||||
@ -205,6 +205,8 @@ func New(cfg config.Config) (*App, error) {
|
||||
RTCUserRemover: rtcUserRemover,
|
||||
RoomTreasureOpenScheduler: treasureOpenScheduler,
|
||||
RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
|
||||
LuckyGiftSendLocker: roomservice.NewRedisLuckyGiftSendLocker(redisClient),
|
||||
LuckyGiftSendLockTTL: cfg.LuckyGiftSendLockTTL,
|
||||
}, directory, repository, integration.NewGRPCWalletClient(walletConn), roomPublisher, outboxPublisher, luckyGiftClient)
|
||||
if cfg.RocketMQ.TreasureOpen.Enabled {
|
||||
treasureConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.TreasureOpen.ConsumerGroup, cfg.RocketMQ.TreasureOpen.ConsumerMaxReconsumeTimes))
|
||||
|
||||
@ -42,6 +42,8 @@ type Config struct {
|
||||
OwnerForwardTimeout time.Duration `yaml:"owner_forward_timeout"`
|
||||
// LeaseTTL 是 room owner 租约有效期,节点故障后必须等它过期才能接管。
|
||||
LeaseTTL time.Duration `yaml:"lease_ttl"`
|
||||
// LuckyGiftSendLockTTL 是同一用户幸运礼物扣费、抽奖、同步返奖链路的 Redis 短锁 TTL。
|
||||
LuckyGiftSendLockTTL time.Duration `yaml:"lucky_gift_send_lock_ttl"`
|
||||
// RankLimit 是房间本地礼物榜 top N。
|
||||
RankLimit int `yaml:"rank_limit"`
|
||||
// SnapshotEveryN 控制按房间版本保存快照的频率。
|
||||
@ -220,19 +222,20 @@ func (c TencentIMConfig) RESTConfig() tencentim.RESTConfig {
|
||||
func Default() Config {
|
||||
// 默认端口遵守项目 13xxx 约束,MySQL/Redis 指向本地 Docker 暴露端口。
|
||||
return Config{
|
||||
ServiceName: "room-service",
|
||||
Environment: "local",
|
||||
NodeID: "room-node-local",
|
||||
GRPCAddr: ":13001",
|
||||
AdvertiseAddr: "127.0.0.1:13001",
|
||||
HealthHTTPAddr: ":13101",
|
||||
NodeRegistryTTL: 30 * time.Second,
|
||||
OwnerForwardTimeout: 2 * time.Second,
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 10,
|
||||
WalletServiceAddr: "127.0.0.1:13004",
|
||||
ActivityServiceAddr: "127.0.0.1:13006",
|
||||
ServiceName: "room-service",
|
||||
Environment: "local",
|
||||
NodeID: "room-node-local",
|
||||
GRPCAddr: ":13001",
|
||||
AdvertiseAddr: "127.0.0.1:13001",
|
||||
HealthHTTPAddr: ":13101",
|
||||
NodeRegistryTTL: 30 * time.Second,
|
||||
OwnerForwardTimeout: 2 * time.Second,
|
||||
LeaseTTL: 10 * time.Second,
|
||||
LuckyGiftSendLockTTL: 5 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 10,
|
||||
WalletServiceAddr: "127.0.0.1:13004",
|
||||
ActivityServiceAddr: "127.0.0.1:13006",
|
||||
TencentIM: TencentIMConfig{
|
||||
Enabled: false,
|
||||
AdminIdentifier: "administrator",
|
||||
@ -348,6 +351,9 @@ func Normalize(cfg Config) (Config, error) {
|
||||
if cfg.OwnerForwardTimeout <= 0 {
|
||||
cfg.OwnerForwardTimeout = 2 * time.Second
|
||||
}
|
||||
if cfg.LuckyGiftSendLockTTL <= 0 {
|
||||
cfg.LuckyGiftSendLockTTL = 5 * time.Second
|
||||
}
|
||||
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
|
||||
if cfg.WalletServiceAddr == "" {
|
||||
cfg.WalletServiceAddr = "127.0.0.1:13004"
|
||||
|
||||
@ -368,6 +368,8 @@ type SendGift struct {
|
||||
PoolID string `json:"pool_id,omitempty"`
|
||||
// GiftCount 是礼物数量,必须为正数。
|
||||
GiftCount int32 `json:"gift_count"`
|
||||
// SenderRegionID 是送礼用户所属区域,由 gateway 从 user-service 注入。
|
||||
SenderRegionID int64 `json:"sender_region_id,omitempty"`
|
||||
// TargetIsHost 是 gateway 从 user-service active host profile 注入的工资入账开关。
|
||||
TargetIsHost bool `json:"target_is_host,omitempty"`
|
||||
// TargetHostRegionID 是工资政策匹配区域;room-service 不自行推导主播身份或区域。
|
||||
|
||||
@ -24,13 +24,14 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
// SendGift 的账务不在 room-service 内结算,但房间表现、热度和榜单由 Room Cell 同步更新。
|
||||
cmd := command.SendGift{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetType: normalizeGiftTargetType(req.GetTargetType()),
|
||||
TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()),
|
||||
GiftID: req.GetGiftId(),
|
||||
PoolID: strings.TrimSpace(req.GetPoolId()),
|
||||
GiftCount: req.GetGiftCount(),
|
||||
TargetIsHost: req.GetTargetIsHost(),
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
TargetType: normalizeGiftTargetType(req.GetTargetType()),
|
||||
TargetUserIDs: normalizeGiftTargetUserIDs(req.GetTargetUserId(), req.GetTargetUserIds()),
|
||||
GiftID: req.GetGiftId(),
|
||||
PoolID: strings.TrimSpace(req.GetPoolId()),
|
||||
GiftCount: req.GetGiftCount(),
|
||||
SenderRegionID: req.GetSenderRegionId(),
|
||||
TargetIsHost: req.GetTargetIsHost(),
|
||||
// 工资区域由 gateway 从 user-service host profile 注入;房间 visible_region_id 只用于房间/礼物可见性。
|
||||
TargetHostRegionID: req.GetTargetHostRegionId(),
|
||||
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
||||
@ -98,74 +99,83 @@ func (s *Service) SendGift(ctx context.Context, req *roomv1.SendGiftRequest) (*r
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
|
||||
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
|
||||
walletStartedAt := time.Now()
|
||||
billing, err := s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
|
||||
CommandId: cmd.ID(),
|
||||
RoomId: cmd.RoomID(),
|
||||
SenderUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RegionId: roomMeta.VisibleRegionID,
|
||||
TargetIsHost: cmd.TargetIsHost,
|
||||
TargetHostRegionId: cmd.TargetHostRegionID,
|
||||
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
|
||||
})
|
||||
walletDebitMS := elapsedMS(walletStartedAt)
|
||||
if err != nil {
|
||||
var billing *walletv1.DebitGiftResponse
|
||||
var walletDebitMS int64
|
||||
settledCommand := cmd
|
||||
var luckyGift *roomv1.LuckyGiftDrawResult
|
||||
if err := s.withLuckyGiftSendLock(ctx, luckyEnabled, cmd.ActorUserID(), func() error {
|
||||
// 钱包扣费在房间状态变更前完成;失败时不写 command log、不进入 Room Cell 提交态。
|
||||
walletStartedAt := time.Now()
|
||||
var err error
|
||||
billing, err = s.wallet.DebitGift(ctx, &walletv1.DebitGiftRequest{
|
||||
CommandId: cmd.ID(),
|
||||
RoomId: cmd.RoomID(),
|
||||
SenderUserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
AppCode: appcode.FromContext(ctx),
|
||||
RegionId: roomMeta.VisibleRegionID,
|
||||
SenderRegionId: cmd.SenderRegionID,
|
||||
TargetIsHost: cmd.TargetIsHost,
|
||||
TargetHostRegionId: cmd.TargetHostRegionID,
|
||||
TargetAgencyOwnerUserId: cmd.TargetAgencyOwnerUserID,
|
||||
})
|
||||
walletDebitMS = elapsedMS(walletStartedAt)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if billing == nil {
|
||||
return xerr.New(xerr.Unavailable, "wallet debit response is empty")
|
||||
}
|
||||
settledCommand.BillingReceiptID = billing.GetBillingReceiptId()
|
||||
settledCommand.CoinSpent = billing.GetCoinSpent()
|
||||
settledCommand.GiftPointAdded = billing.GetGiftPointAdded()
|
||||
settledCommand.HeatValue = billing.GetHeatValue()
|
||||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||||
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
||||
settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded()
|
||||
settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey()
|
||||
if !luckyEnabled {
|
||||
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
|
||||
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
||||
}
|
||||
if luckyEnabled {
|
||||
// 抽奖必须发生在扣费成功之后;activity-service 只接收钱包结算后的 coin_spent,不信任客户端价格。
|
||||
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
|
||||
LuckyGift: &activityv1.LuckyGiftMeta{
|
||||
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
|
||||
CommandId: cmd.ID(),
|
||||
UserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
// 目前没有独立设备 ID 字段,优先用 session_id;没有时退回 command_id,保证风控 scope 不为空。
|
||||
DeviceId: luckyGiftDeviceID(cmd),
|
||||
RoomId: cmd.RoomID(),
|
||||
AnchorId: luckyGiftAnchorID(roomMeta),
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
CoinSpent: billing.GetCoinSpent(),
|
||||
// 房间链路统一使用 UTC epoch ms;不能把本地时区时间传给活动风控窗口。
|
||||
PaidAtMs: now.UTC().UnixMilli(),
|
||||
PoolId: cmd.PoolID,
|
||||
VisibleRegionId: roomMeta.VisibleRegionID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
|
||||
return err
|
||||
}
|
||||
if drawResp == nil {
|
||||
return xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
|
||||
}
|
||||
// 同步返回只给当前送礼用户做即时表现;金币余额以 activity/wallet 快路径回执为准,房间开奖结果仍由 activity outbox 补偿。
|
||||
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if billing == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "wallet debit response is empty")
|
||||
}
|
||||
heatValue := billing.GetHeatValue()
|
||||
settledCommand := cmd
|
||||
settledCommand.BillingReceiptID = billing.GetBillingReceiptId()
|
||||
settledCommand.CoinSpent = billing.GetCoinSpent()
|
||||
settledCommand.GiftPointAdded = billing.GetGiftPointAdded()
|
||||
settledCommand.HeatValue = heatValue
|
||||
settledCommand.PriceVersion = billing.GetPriceVersion()
|
||||
settledCommand.GiftTypeCode = billing.GetGiftTypeCode()
|
||||
settledCommand.HostPeriodDiamondAdded = billing.GetHostPeriodDiamondAdded()
|
||||
settledCommand.HostPeriodCycleKey = billing.GetHostPeriodCycleKey()
|
||||
if !luckyEnabled {
|
||||
// 未显式传 pool_id 的旧客户端仍可通过钱包礼物类型触发默认幸运礼物逻辑;新客户端应传明确 pool_id。
|
||||
luckyEnabled = s.shouldDrawLuckyGift(cmd.PoolID, billing.GetGiftTypeCode())
|
||||
}
|
||||
var luckyGift *roomv1.LuckyGiftDrawResult
|
||||
if luckyEnabled {
|
||||
// 抽奖必须发生在扣费成功之后;activity-service 只接收钱包结算后的 coin_spent,不信任客户端价格。
|
||||
drawResp, err := s.luckyGift.ExecuteLuckyGiftDraw(ctx, &activityv1.ExecuteLuckyGiftDrawRequest{
|
||||
LuckyGift: &activityv1.LuckyGiftMeta{
|
||||
Meta: activityMetaFromRoom(ctx, req.GetMeta()),
|
||||
CommandId: cmd.ID(),
|
||||
UserId: cmd.ActorUserID(),
|
||||
TargetUserId: cmd.TargetUserID,
|
||||
// 目前没有独立设备 ID 字段,优先用 session_id;没有时退回 command_id,保证风控 scope 不为空。
|
||||
DeviceId: luckyGiftDeviceID(cmd),
|
||||
RoomId: cmd.RoomID(),
|
||||
AnchorId: luckyGiftAnchorID(roomMeta),
|
||||
GiftId: cmd.GiftID,
|
||||
GiftCount: cmd.GiftCount,
|
||||
CoinSpent: billing.GetCoinSpent(),
|
||||
// 房间链路统一使用 UTC epoch ms;不能把本地时区时间传给活动风控窗口。
|
||||
PaidAtMs: now.UTC().UnixMilli(),
|
||||
PoolId: cmd.PoolID,
|
||||
VisibleRegionId: roomMeta.VisibleRegionID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
// 抽奖失败时整笔送礼失败并依赖 wallet 幂等重试;不能落普通礼物后丢失幸运礼物抽奖事实。
|
||||
return mutationResult{}, nil, err
|
||||
}
|
||||
if drawResp == nil {
|
||||
return mutationResult{}, nil, xerr.New(xerr.Unavailable, "lucky gift draw response is empty")
|
||||
}
|
||||
// 同步返回只给当前送礼用户做即时表现;返奖到账和房间开奖结果仍由 activity outbox 补偿。
|
||||
luckyGift = luckyGiftResultFromProto(drawResp.GetResult())
|
||||
}
|
||||
|
||||
// 扣费成功后,Room Cell 同步更新热度和本地礼物榜。
|
||||
current.Heat += heatValue
|
||||
@ -314,6 +324,24 @@ func (s *Service) shouldDrawLuckyGift(poolID string, giftTypeCode string) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) withLuckyGiftSendLock(ctx context.Context, enabled bool, userID int64, fn func() error) error {
|
||||
if !enabled || s == nil || s.luckyGiftSendLocker == nil {
|
||||
return fn()
|
||||
}
|
||||
release, err := s.luckyGiftSendLocker.AcquireLuckyGiftSendLock(ctx, appcode.FromContext(ctx), userID, s.luckyGiftSendLockTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if release != nil {
|
||||
defer func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
release(releaseCtx)
|
||||
}()
|
||||
}
|
||||
return fn()
|
||||
}
|
||||
|
||||
func activityMetaFromRoom(ctx context.Context, meta *roomv1.RequestMeta) *activityv1.RequestMeta {
|
||||
return &activityv1.RequestMeta{
|
||||
RequestId: meta.GetRequestId(),
|
||||
@ -360,6 +388,8 @@ func luckyGiftResultFromProto(result *activityv1.LuckyGiftDrawResult) *roomv1.Lu
|
||||
StageFeedback: result.GetStageFeedback(),
|
||||
HighMultiplier: result.GetHighMultiplier(),
|
||||
CreatedAtMs: result.GetCreatedAtMs(),
|
||||
WalletTransactionId: result.GetWalletTransactionId(),
|
||||
CoinBalanceAfter: result.GetCoinBalanceAfter(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/idgen"
|
||||
"hyapp/pkg/xerr"
|
||||
)
|
||||
|
||||
const releaseLuckyGiftSendLockScript = `
|
||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("DEL", KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`
|
||||
|
||||
type LuckyGiftSendLocker interface {
|
||||
AcquireLuckyGiftSendLock(ctx context.Context, appCode string, userID int64, ttl time.Duration) (func(context.Context), error)
|
||||
}
|
||||
|
||||
type RedisLuckyGiftSendLocker struct {
|
||||
client *redis.Client
|
||||
}
|
||||
|
||||
func NewRedisLuckyGiftSendLocker(client *redis.Client) *RedisLuckyGiftSendLocker {
|
||||
return &RedisLuckyGiftSendLocker{client: client}
|
||||
}
|
||||
|
||||
func (l *RedisLuckyGiftSendLocker) AcquireLuckyGiftSendLock(ctx context.Context, appCode string, userID int64, ttl time.Duration) (func(context.Context), error) {
|
||||
if l == nil || l.client == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if userID <= 0 {
|
||||
return nil, xerr.New(xerr.InvalidArgument, "lucky gift sender is invalid")
|
||||
}
|
||||
if ttl <= 0 {
|
||||
ttl = 5 * time.Second
|
||||
}
|
||||
key := luckyGiftSendLockKey(appCode, userID)
|
||||
token := idgen.New("lucky_gift_send_lock")
|
||||
ok, err := l.client.SetNX(ctx, key, token, ttl).Result()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, xerr.New(xerr.Conflict, "lucky gift send is already processing")
|
||||
}
|
||||
return func(releaseCtx context.Context) {
|
||||
_ = l.client.Eval(releaseCtx, releaseLuckyGiftSendLockScript, []string{key}, token).Err()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func luckyGiftSendLockKey(rawAppCode string, userID int64) string {
|
||||
return fmt.Sprintf("lucky_gift_send:%s:%s", appcode.Normalize(rawAppCode), strconv.FormatInt(userID, 10))
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
func TestWithLuckyGiftSendLockWrapsLuckyPath(t *testing.T) {
|
||||
locker := &fakeLuckyGiftSendLocker{}
|
||||
svc := &Service{luckyGiftSendLocker: locker, luckyGiftSendLockTTL: 3 * time.Second}
|
||||
called := false
|
||||
|
||||
err := svc.withLuckyGiftSendLock(appcode.WithContext(context.Background(), "lalu"), true, 42, func() error {
|
||||
if !locker.acquired || locker.released {
|
||||
t.Fatalf("lock must be held while lucky gift send runs: %+v", locker)
|
||||
}
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("withLuckyGiftSendLock failed: %v", err)
|
||||
}
|
||||
if !called || !locker.acquired || !locker.released || locker.appCode != "lalu" || locker.userID != 42 || locker.ttl != 3*time.Second {
|
||||
t.Fatalf("lock lifecycle mismatch: called=%v locker=%+v", called, locker)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithLuckyGiftSendLockBypassesNormalGift(t *testing.T) {
|
||||
locker := &fakeLuckyGiftSendLocker{}
|
||||
svc := &Service{luckyGiftSendLocker: locker, luckyGiftSendLockTTL: 3 * time.Second}
|
||||
|
||||
if err := svc.withLuckyGiftSendLock(context.Background(), false, 42, func() error { return nil }); err != nil {
|
||||
t.Fatalf("normal gift path should bypass lock: %v", err)
|
||||
}
|
||||
if locker.acquired {
|
||||
t.Fatalf("normal gift path must not acquire lucky gift lock")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeLuckyGiftSendLocker struct {
|
||||
acquired bool
|
||||
released bool
|
||||
appCode string
|
||||
userID int64
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
func (l *fakeLuckyGiftSendLocker) AcquireLuckyGiftSendLock(_ context.Context, appCode string, userID int64, ttl time.Duration) (func(context.Context), error) {
|
||||
l.acquired = true
|
||||
l.appCode = appCode
|
||||
l.userID = userID
|
||||
l.ttl = ttl
|
||||
return func(context.Context) {
|
||||
l.released = true
|
||||
}, nil
|
||||
}
|
||||
@ -46,7 +46,9 @@ func (c *luckyGiftTestClient) ExecuteLuckyGiftDraw(_ context.Context, req *activ
|
||||
MultiplierPpm: 2_000_000,
|
||||
BaseRewardCoins: 200,
|
||||
EffectiveRewardCoins: 200,
|
||||
RewardStatus: "pending",
|
||||
RewardStatus: "granted",
|
||||
WalletTransactionId: "wallet_tx_lucky",
|
||||
CoinBalanceAfter: 8800,
|
||||
CreatedAtMs: 1_779_258_000_000,
|
||||
}}, nil
|
||||
}
|
||||
@ -94,7 +96,7 @@ func TestSendGiftReturnsLuckyGiftDrawResult(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("send lucky gift failed: %v", err)
|
||||
}
|
||||
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 {
|
||||
if resp.GetLuckyGift().GetDrawId() != "lucky_draw_test" || resp.GetLuckyGift().GetMultiplierPpm() != 2_000_000 || resp.GetLuckyGift().GetEffectiveRewardCoins() != 200 || resp.GetLuckyGift().GetRewardStatus() != "granted" || resp.GetLuckyGift().GetWalletTransactionId() != "wallet_tx_lucky" || resp.GetLuckyGift().GetCoinBalanceAfter() != 8800 {
|
||||
t.Fatalf("lucky gift result mismatch: %+v", resp.GetLuckyGift())
|
||||
}
|
||||
if len(luckyGift.checks) != 1 || luckyGift.checks[0].GetPoolId() != "super_lucky" || luckyGift.checks[0].GetGiftId() != "rose" {
|
||||
|
||||
@ -36,6 +36,10 @@ type Config struct {
|
||||
RoomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
|
||||
// RoomGiftLeaderboard 是跨房间金币榜读模型,SendGift 提交后 best-effort 写入。
|
||||
RoomGiftLeaderboard RoomGiftLeaderboardStore
|
||||
// LuckyGiftSendLocker 串行化同一用户的幸运礼物扣费、抽奖和同步返奖链路。
|
||||
LuckyGiftSendLocker LuckyGiftSendLocker
|
||||
// LuckyGiftSendLockTTL 是幸运礼物短锁过期时间。
|
||||
LuckyGiftSendLockTTL time.Duration
|
||||
// Clock 允许测试注入稳定时间,生产默认使用系统时钟。
|
||||
Clock clock.Clock
|
||||
}
|
||||
@ -72,6 +76,10 @@ type Service struct {
|
||||
roomTreasureOpenScheduler integration.RoomTreasureOpenScheduler
|
||||
// roomGiftLeaderboard 只保存房间维度金币消耗 zset,不承载 Room Cell 核心状态。
|
||||
roomGiftLeaderboard RoomGiftLeaderboardStore
|
||||
// luckyGiftSendLocker 只保护同一用户幸运礼物扣费到同步返奖的小事务链路。
|
||||
luckyGiftSendLocker LuckyGiftSendLocker
|
||||
// luckyGiftSendLockTTL 控制 Redis 短锁自动释放窗口。
|
||||
luckyGiftSendLockTTL time.Duration
|
||||
// rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。
|
||||
rtcUserRemover integration.RTCUserRemover
|
||||
// draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。
|
||||
@ -159,6 +167,10 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
// MicUp 只是业务占麦,客户端必须在短窗口内确认 RTC 发流成功。
|
||||
micPublishTimeout = 15 * time.Second
|
||||
}
|
||||
luckyGiftSendLockTTL := cfg.LuckyGiftSendLockTTL
|
||||
if luckyGiftSendLockTTL <= 0 {
|
||||
luckyGiftSendLockTTL = 5 * time.Second
|
||||
}
|
||||
|
||||
var luckyGiftClient integration.LuckyGiftClient
|
||||
if len(luckyGift) > 0 {
|
||||
@ -181,6 +193,8 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
||||
outboxPublisher: outboxPublisher,
|
||||
roomTreasureOpenScheduler: cfg.RoomTreasureOpenScheduler,
|
||||
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
||||
luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
|
||||
luckyGiftSendLockTTL: luckyGiftSendLockTTL,
|
||||
rtcUserRemover: cfg.RTCUserRemover,
|
||||
cells: make(map[string]*cell.RoomCell),
|
||||
}
|
||||
|
||||
@ -902,6 +902,20 @@ CREATE TABLE IF NOT EXISTS wallet_diamond_exchange_rules (
|
||||
KEY idx_diamond_exchange_active (app_code, status, sort_order)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包钻石兑换规则表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域,0 表示全局默认',
|
||||
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
|
||||
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例,百分比',
|
||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
|
||||
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, region_id, gift_type_code),
|
||||
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS red_packet_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否启用红包',
|
||||
@ -915,6 +929,15 @@ CREATE TABLE IF NOT EXISTS red_packet_configs (
|
||||
PRIMARY KEY (app_code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='红包配置主表';
|
||||
|
||||
SET @ddl := IF(
|
||||
(SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'red_packet_configs' AND COLUMN_NAME = 'rule_url') = 0,
|
||||
'ALTER TABLE red_packet_configs ADD COLUMN rule_url VARCHAR(512) NOT NULL DEFAULT '''' COMMENT ''红包规则说明链接'' AFTER daily_send_limit',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE stmt FROM @ddl;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS red_packet_count_tiers (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
tier_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '数量档位 ID',
|
||||
@ -1130,13 +1153,6 @@ INSERT IGNORE INTO vip_levels (
|
||||
('lalu', 9, 'VIP9', 'disabled', 0, 604800000, 0, 0, 90, 0, 0),
|
||||
('lalu', 10, 'VIP10', 'disabled', 0, 604800000, 0, 0, 100, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO wallet_diamond_exchange_rules (
|
||||
app_code, exchange_type, status, from_asset_type, to_asset_type,
|
||||
from_amount, to_amount, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 'diamond_to_coin', 'active', 'DIAMOND', 'COIN', 100, 100, 10, 0, 0),
|
||||
('lalu', 'diamond_to_usd_balance', 'active', 'DIAMOND', 'USD_BALANCE', 1000, 100, 20, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO gift_type_configs (
|
||||
app_code, type_code, name, tab_key, status, sort_order,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
@ -1151,3 +1167,11 @@ INSERT IGNORE INTO gift_type_configs (
|
||||
('lalu', 'activity', '活动礼物', 'Activity', 'active', 80, 0, 0, 0, 0),
|
||||
('lalu', 'magic', '魔法礼物', 'Magic', 'active', 90, 0, 0, 0, 0),
|
||||
('lalu', 'custom', '定制礼物', 'Custom', 'active', 100, 0, 0, 0, 0);
|
||||
|
||||
INSERT IGNORE INTO gift_diamond_ratio_configs (
|
||||
app_code, region_id, gift_type_code, status, ratio_percent,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0),
|
||||
('lalu', 0, 'lucky', 'active', 100.00, 0, 0, 0, 0),
|
||||
('lalu', 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0);
|
||||
|
||||
@ -9,8 +9,6 @@ const (
|
||||
AssetCoinSellerCoin = "COIN_SELLER_COIN"
|
||||
// AssetGiftPoint 是主播礼物积分,不能直接消费或提现。
|
||||
AssetGiftPoint = "GIFT_POINT"
|
||||
// AssetDiamond 是后续兑换用资产,当前只在余额模型中保留。
|
||||
AssetDiamond = "DIAMOND"
|
||||
// AssetHostPeriodDiamond 是主播工资周期钻石投影,只参与工资等级结算,不进入通用钱包余额。
|
||||
AssetHostPeriodDiamond = "HOST_PERIOD_DIAMOND"
|
||||
// AssetUSDBalance 是主播可提现美元余额,单位由后续提现策略固定。
|
||||
@ -109,6 +107,8 @@ type DebitGiftCommand struct {
|
||||
GiftCount int32
|
||||
PriceVersion string
|
||||
RegionID int64
|
||||
// SenderRegionID 是送礼用户所属区域;用于按区域匹配礼物入主播周期钻石比例。
|
||||
SenderRegionID int64
|
||||
// TargetIsHost 只能由 gateway 根据 user-service active host profile 注入,客户端输入不可信。
|
||||
TargetIsHost bool
|
||||
// TargetHostRegionID 是主播身份所属区域;工资政策按该区域匹配,不能用房间可见区域替代。
|
||||
@ -504,6 +504,9 @@ type VipRewardItem struct {
|
||||
Name string
|
||||
Quantity int64
|
||||
ExpiresAtMS int64
|
||||
AssetURL string
|
||||
PreviewURL string
|
||||
AnimationURL string
|
||||
}
|
||||
|
||||
// VipLevel 是可购买 VIP 等级配置。
|
||||
@ -783,7 +786,7 @@ type RedPacketExpireResult struct {
|
||||
// ValidAssetType 限定账本允许落账的资产类型,避免写入任意字符串资产。
|
||||
func ValidAssetType(assetType string) bool {
|
||||
switch assetType {
|
||||
case AssetCoin, AssetCoinSellerCoin, AssetGiftPoint, AssetDiamond, AssetUSDBalance:
|
||||
case AssetCoin, AssetCoinSellerCoin, AssetGiftPoint, AssetUSDBalance:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@ -791,12 +794,7 @@ func ValidAssetType(assetType string) bool {
|
||||
}
|
||||
|
||||
func NormalizeGiftChargeAssetType(assetType string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(assetType)) {
|
||||
case AssetDiamond:
|
||||
return AssetDiamond
|
||||
default:
|
||||
return AssetCoin
|
||||
}
|
||||
return AssetCoin
|
||||
}
|
||||
|
||||
func NormalizeVipGrantSource(source string) string {
|
||||
@ -814,7 +812,7 @@ func NormalizeVipGrantSource(source string) string {
|
||||
|
||||
func ValidGiftChargeAssetType(assetType string) bool {
|
||||
switch strings.ToUpper(strings.TrimSpace(assetType)) {
|
||||
case AssetCoin, AssetDiamond:
|
||||
case AssetCoin:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -145,6 +145,53 @@ func TestDebitGiftCreditsHostPeriodDiamondsOnlyForHost(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDebitGiftCreditsHostPeriodDiamondsBySenderRegionAndGiftType(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeNormal, "30.00")
|
||||
repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeLucky, "40.00")
|
||||
repository.SetGiftDiamondRatio(1001, resourcedomain.GiftTypeSuperLucky, "50.00")
|
||||
|
||||
cases := []struct {
|
||||
giftID string
|
||||
giftType string
|
||||
commandID string
|
||||
senderID int64
|
||||
wantDiamond int64
|
||||
}{
|
||||
{giftID: "normal-ratio-gift", giftType: resourcedomain.GiftTypeNormal, commandID: "cmd-normal-ratio", senderID: 11001, wantDiamond: 30},
|
||||
{giftID: "lucky-ratio-gift", giftType: resourcedomain.GiftTypeLucky, commandID: "cmd-lucky-ratio", senderID: 11002, wantDiamond: 40},
|
||||
{giftID: "super-lucky-ratio-gift", giftType: resourcedomain.GiftTypeSuperLucky, commandID: "cmd-super-lucky-ratio", senderID: 11003, wantDiamond: 50},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
repository.SetBalance(tc.senderID, 100)
|
||||
repository.SetGiftPrice(tc.giftID, "v1", 100, 100, 100)
|
||||
repository.SetGiftType(tc.giftID, tc.giftType)
|
||||
receipt, err := svc.DebitGift(context.Background(), ledger.DebitGiftCommand{
|
||||
CommandID: tc.commandID,
|
||||
RoomID: "room-ratio",
|
||||
SenderUserID: tc.senderID,
|
||||
SenderRegionID: 1001,
|
||||
TargetUserID: 12001,
|
||||
GiftID: tc.giftID,
|
||||
GiftCount: 1,
|
||||
PriceVersion: "v1",
|
||||
TargetIsHost: true,
|
||||
TargetHostRegionID: 8801,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DebitGift %s failed: %v", tc.giftType, err)
|
||||
}
|
||||
if receipt.HostPeriodDiamondAdded != tc.wantDiamond {
|
||||
t.Fatalf("%s ratio diamond mismatch: got %+v want %d", tc.giftType, receipt, tc.wantDiamond)
|
||||
}
|
||||
}
|
||||
if got := repository.HostPeriodGiftDiamondTotal(12001); got != 120 {
|
||||
t.Fatalf("host period account total mismatch, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebitGiftRejectsHostPeriodWithoutRegion 锁定工资政策必须有主播区域才能入账。
|
||||
func TestDebitGiftRejectsHostPeriodWithoutRegion(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -1581,7 +1628,7 @@ func TestGiftConfigMustSelectGiftResource(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) {
|
||||
func TestGiftConfigCoercesDiamondChargeToCoinAndEffectiveRange(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := walletservice.New(repository)
|
||||
ctx := context.Background()
|
||||
@ -1606,7 +1653,7 @@ func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) {
|
||||
Status: resourcedomain.StatusActive,
|
||||
Name: "Diamond Magic",
|
||||
PriceVersion: "v1",
|
||||
ChargeAssetType: ledger.AssetDiamond,
|
||||
ChargeAssetType: "DIAMOND",
|
||||
CoinPrice: 7,
|
||||
GiftPointAmount: 3,
|
||||
HeatValue: 9,
|
||||
@ -1620,11 +1667,11 @@ func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("create diamond gift config failed: %v", err)
|
||||
}
|
||||
if gift.ChargeAssetType != ledger.AssetDiamond || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
|
||||
t.Fatalf("diamond gift config mismatch: %+v", gift)
|
||||
if gift.ChargeAssetType != ledger.AssetCoin || gift.GiftTypeCode != resourcedomain.GiftTypeMagic || len(gift.EffectTypes) != 2 {
|
||||
t.Fatalf("gift config must not keep DIAMOND charge: %+v", gift)
|
||||
}
|
||||
|
||||
repository.SetAssetBalance(52001, ledger.AssetDiamond, 100)
|
||||
repository.SetBalance(52001, 100)
|
||||
receipt, err := svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||||
CommandID: "cmd-diamond-gift",
|
||||
RoomID: "room-diamond",
|
||||
@ -1636,8 +1683,8 @@ func TestGiftConfigSupportsDiamondChargeAndEffectiveRange(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("DebitGift with diamond-backed gift failed: %v", err)
|
||||
}
|
||||
if receipt.ChargeAssetType != ledger.AssetDiamond || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
|
||||
t.Fatalf("diamond gift settlement mismatch: %+v", receipt)
|
||||
if receipt.ChargeAssetType != ledger.AssetCoin || receipt.ChargeAmount != 14 || receipt.BalanceAfter != 86 {
|
||||
t.Fatalf("gift settlement must use COIN after DIAMOND removal: %+v", receipt)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1670,7 +1717,7 @@ func TestGiftConfigSyncsConfiguredResourcePrice(t *testing.T) {
|
||||
Status: resourcedomain.StatusActive,
|
||||
Name: "Price Sync Gift",
|
||||
PriceVersion: "v1",
|
||||
ChargeAssetType: ledger.AssetDiamond,
|
||||
ChargeAssetType: "DIAMOND",
|
||||
CoinPrice: 1,
|
||||
GiftPointAmount: 1,
|
||||
HeatValue: 3,
|
||||
@ -2486,6 +2533,15 @@ func TestApplyWithdrawalFreezesUSDBalance(t *testing.T) {
|
||||
func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalance(51001, 20000)
|
||||
repository.SeedVIPRewardGroup(1, 71001, []mysqltest.VipRewardResourceSeed{
|
||||
{
|
||||
ResourceID: 7100101,
|
||||
ResourceCode: "vip1_avatar_frame",
|
||||
ResourceType: resourcedomain.TypeAvatarFrame,
|
||||
Name: "VIP1 Avatar Frame",
|
||||
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
|
||||
},
|
||||
})
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
receipt, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||||
@ -2554,6 +2610,80 @@ func TestPurchaseVipDebitsCoinAndGrantsReward(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurchaseVipRenewalAlignsRewardEntitlementExpiry(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalance(51004, 20000)
|
||||
resources := []mysqltest.VipRewardResourceSeed{
|
||||
{
|
||||
ResourceID: 7100401,
|
||||
ResourceCode: "vip_renew_frame",
|
||||
ResourceType: resourcedomain.TypeAvatarFrame,
|
||||
Name: "VIP Renew Frame",
|
||||
GrantStrategy: resourcedomain.GrantStrategyExtendExpiry,
|
||||
},
|
||||
{
|
||||
ResourceID: 7100402,
|
||||
ResourceCode: "vip_renew_mic",
|
||||
ResourceType: resourcedomain.TypeMicSeatAnimation,
|
||||
Name: "VIP Renew Mic",
|
||||
GrantStrategy: resourcedomain.GrantStrategyIncreaseQuantity,
|
||||
},
|
||||
{
|
||||
ResourceID: 7100403,
|
||||
ResourceCode: "vip_renew_badge",
|
||||
ResourceType: resourcedomain.TypeBadge,
|
||||
Name: "VIP Renew Badge",
|
||||
GrantStrategy: resourcedomain.GrantStrategySetActiveFlag,
|
||||
},
|
||||
}
|
||||
repository.SeedVIPRewardGroup(1, 71004, resources)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
first, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||||
CommandID: "cmd-vip-renew-expiry-first",
|
||||
UserID: 51004,
|
||||
Level: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first VIP purchase failed: %v", err)
|
||||
}
|
||||
if len(first.RewardItems) != len(resources) {
|
||||
t.Fatalf("first reward item count mismatch: %+v", first.RewardItems)
|
||||
}
|
||||
for _, resource := range resources {
|
||||
expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
|
||||
if expiresAt != first.Vip.ExpiresAtMS {
|
||||
t.Fatalf("first entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, first.Vip.ExpiresAtMS)
|
||||
}
|
||||
}
|
||||
|
||||
renew, err := svc.PurchaseVip(context.Background(), ledger.PurchaseVipCommand{
|
||||
CommandID: "cmd-vip-renew-expiry-second",
|
||||
UserID: 51004,
|
||||
Level: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("VIP renewal failed: %v", err)
|
||||
}
|
||||
if renew.Vip.ExpiresAtMS != first.Vip.ExpiresAtMS+604800000 {
|
||||
t.Fatalf("renewed VIP expiry mismatch: first=%d renewed=%d", first.Vip.ExpiresAtMS, renew.Vip.ExpiresAtMS)
|
||||
}
|
||||
if len(renew.RewardItems) != len(resources) {
|
||||
t.Fatalf("renew reward item count mismatch: %+v", renew.RewardItems)
|
||||
}
|
||||
for _, item := range renew.RewardItems {
|
||||
if item.ExpiresAtMS != renew.Vip.ExpiresAtMS {
|
||||
t.Fatalf("renew reward response expiry mismatch for %d: got %d want %d", item.ResourceID, item.ExpiresAtMS, renew.Vip.ExpiresAtMS)
|
||||
}
|
||||
}
|
||||
for _, resource := range resources {
|
||||
expiresAt, _, _ := repository.ActiveResourceEntitlement(51004, resource.ResourceID)
|
||||
if expiresAt != renew.Vip.ExpiresAtMS {
|
||||
t.Fatalf("renew entitlement expiry mismatch for %d: got %d want %d", resource.ResourceID, expiresAt, renew.Vip.ExpiresAtMS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantVipUsesUnifiedActivationAndNoticeOutbox 验证后台和活动赠送 VIP 走统一激活入口并写 IM outbox。
|
||||
func TestGrantVipUsesUnifiedActivationAndNoticeOutbox(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
@ -2705,29 +2835,28 @@ func vipLevelByNumber(levels []ledger.VipLevel, target int32) *ledger.VipLevel {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService 验证我的页开关和钻石兑换配置不再由 gateway 硬编码。
|
||||
func TestWalletOverviewAndDiamondExchangeConfigComeFromWalletService(t *testing.T) {
|
||||
// TestWalletOverviewDisablesDiamondExchange 验证普通 DIAMOND 钱包停用后钱包概览不再返回钻石余额或兑换入口。
|
||||
func TestWalletOverviewDisablesDiamondExchange(t *testing.T) {
|
||||
repository := mysqltest.NewRepository(t)
|
||||
repository.SetBalance(52001, 700)
|
||||
repository.SetAssetBalance(52001, ledger.AssetDiamond, 300)
|
||||
svc := walletservice.New(repository)
|
||||
|
||||
overview, err := svc.GetWalletOverview(context.Background(), 52001)
|
||||
if err != nil {
|
||||
t.Fatalf("GetWalletOverview failed: %v", err)
|
||||
}
|
||||
if !overview.FeatureFlags.RechargeEnabled || !overview.FeatureFlags.DiamondExchangeEnabled || !overview.FeatureFlags.WithdrawEnabled {
|
||||
if !overview.FeatureFlags.RechargeEnabled || overview.FeatureFlags.DiamondExchangeEnabled || !overview.FeatureFlags.WithdrawEnabled {
|
||||
t.Fatalf("wallet feature flags mismatch: %+v", overview.FeatureFlags)
|
||||
}
|
||||
if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, ledger.AssetDiamond) != 300 {
|
||||
if balanceAmount(overview.Balances, ledger.AssetCoin) != 700 || balanceAmount(overview.Balances, "DIAMOND") != 0 {
|
||||
t.Fatalf("overview balances mismatch: %+v", overview.Balances)
|
||||
}
|
||||
rules, err := svc.GetDiamondExchangeConfig(context.Background(), 52001)
|
||||
if err != nil {
|
||||
t.Fatalf("GetDiamondExchangeConfig failed: %v", err)
|
||||
}
|
||||
if len(rules) != 2 || rules[0].FromAssetType != ledger.AssetDiamond || !rules[0].Enabled {
|
||||
t.Fatalf("diamond exchange rules mismatch: %+v", rules)
|
||||
if len(rules) != 0 {
|
||||
t.Fatalf("diamond exchange rules must be empty after DIAMOND removal: %+v", rules)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,7 @@ const (
|
||||
|
||||
// GetWalletOverview 返回钱包首页摘要;开关放在 wallet-service 内,避免 gateway 硬编码账务能力。
|
||||
func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledger.WalletOverview, error) {
|
||||
balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin, ledger.AssetDiamond, ledger.AssetUSDBalance})
|
||||
balances, err := r.GetBalances(ctx, userID, []string{ledger.AssetCoin, ledger.AssetUSDBalance})
|
||||
if err != nil {
|
||||
return ledger.WalletOverview{}, err
|
||||
}
|
||||
@ -32,7 +32,7 @@ func (r *Repository) GetWalletOverview(ctx context.Context, userID int64) (ledge
|
||||
Balances: balances,
|
||||
FeatureFlags: ledger.WalletFeatureFlags{
|
||||
RechargeEnabled: true,
|
||||
DiamondExchangeEnabled: true,
|
||||
DiamondExchangeEnabled: false,
|
||||
WithdrawEnabled: true,
|
||||
},
|
||||
}, nil
|
||||
@ -129,29 +129,7 @@ func (r *Repository) GetDiamondExchangeConfig(ctx context.Context, _ int64) ([]l
|
||||
if r == nil || r.db == nil {
|
||||
return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured")
|
||||
}
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT exchange_type, from_asset_type, to_asset_type, from_amount, to_amount, status
|
||||
FROM wallet_diamond_exchange_rules
|
||||
WHERE app_code = ? AND status = 'active'
|
||||
ORDER BY sort_order ASC, rule_id ASC`,
|
||||
appcode.FromContext(ctx),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
rules := make([]ledger.DiamondExchangeRule, 0)
|
||||
for rows.Next() {
|
||||
var rule ledger.DiamondExchangeRule
|
||||
var status string
|
||||
if err := rows.Scan(&rule.ExchangeType, &rule.FromAssetType, &rule.ToAssetType, &rule.FromAmount, &rule.ToAmount, &status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule.Enabled = status == "active"
|
||||
rules = append(rules, rule)
|
||||
}
|
||||
return rules, rows.Err()
|
||||
return []ledger.DiamondExchangeRule{}, nil
|
||||
}
|
||||
|
||||
// ListWalletTransactions 分页读取当前用户钱包分录,并带出交易业务类型。
|
||||
@ -1067,6 +1045,11 @@ func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, comman
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if inserted.EntitlementID != "" {
|
||||
if err := r.alignEntitlementExpiresAt(ctx, tx, inserted.EntitlementID, expiresAtMS, nowMs); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
}
|
||||
rewards = append(rewards, ledger.VipRewardItem{
|
||||
ResourceID: item.Resource.ResourceID,
|
||||
ResourceCode: item.Resource.ResourceCode,
|
||||
@ -1074,6 +1057,9 @@ func (r *Repository) applyVipRewardGroup(ctx context.Context, tx *sql.Tx, comman
|
||||
Name: item.Resource.Name,
|
||||
Quantity: inserted.Quantity,
|
||||
ExpiresAtMS: expiresAtMS,
|
||||
AssetURL: item.Resource.AssetURL,
|
||||
PreviewURL: item.Resource.PreviewURL,
|
||||
AnimationURL: item.Resource.AnimationURL,
|
||||
})
|
||||
}
|
||||
if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{
|
||||
@ -1104,6 +1090,9 @@ func (r *Repository) vipRewardItems(ctx context.Context, groupID int64, expiresA
|
||||
Name: item.Resource.Name,
|
||||
Quantity: item.Quantity,
|
||||
ExpiresAtMS: expiresAtMS,
|
||||
AssetURL: item.Resource.AssetURL,
|
||||
PreviewURL: item.Resource.PreviewURL,
|
||||
AnimationURL: item.Resource.AnimationURL,
|
||||
})
|
||||
}
|
||||
return rewards, nil
|
||||
@ -1151,6 +1140,9 @@ func vipRewardItemsNoticePayload(items []ledger.VipRewardItem) []map[string]any
|
||||
"name": item.Name,
|
||||
"quantity": item.Quantity,
|
||||
"expires_at_ms": item.ExpiresAtMS,
|
||||
"asset_url": item.AssetURL,
|
||||
"preview_url": item.PreviewURL,
|
||||
"animation_url": item.AnimationURL,
|
||||
})
|
||||
}
|
||||
return payload
|
||||
@ -1217,7 +1209,8 @@ func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
SELECT i.resource_id, r.resource_code, r.resource_type, r.name, i.quantity
|
||||
SELECT i.resource_id, r.resource_code, r.resource_type, r.name, i.quantity,
|
||||
COALESCE(r.asset_url, ''), COALESCE(r.preview_url, ''), COALESCE(r.animation_url, '')
|
||||
FROM resource_grant_items i
|
||||
JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id
|
||||
WHERE i.app_code = ? AND i.grant_id = ? AND i.result_type = ?
|
||||
@ -1231,7 +1224,16 @@ func (r *Repository) rewardItemsByGrant(ctx context.Context, tx *sql.Tx, grantID
|
||||
items := make([]ledger.VipRewardItem, 0)
|
||||
for rows.Next() {
|
||||
var item ledger.VipRewardItem
|
||||
if err := rows.Scan(&item.ResourceID, &item.ResourceCode, &item.ResourceType, &item.Name, &item.Quantity); err != nil {
|
||||
if err := rows.Scan(
|
||||
&item.ResourceID,
|
||||
&item.ResourceCode,
|
||||
&item.ResourceType,
|
||||
&item.Name,
|
||||
&item.Quantity,
|
||||
&item.AssetURL,
|
||||
&item.PreviewURL,
|
||||
&item.AnimationURL,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.ExpiresAtMS = expiresAtMS
|
||||
|
||||
@ -1231,18 +1231,21 @@ func redPacketCreatedEvent(transactionID string, commandID string, metadata redP
|
||||
AvailableDelta: -metadata.TotalAmount,
|
||||
FrozenDelta: 0,
|
||||
Payload: map[string]any{
|
||||
"transaction_id": transactionID,
|
||||
"command_id": commandID,
|
||||
"packet_id": metadata.PacketID,
|
||||
"packet_type": metadata.PacketType,
|
||||
"sender_user_id": metadata.SenderUserID,
|
||||
"room_id": metadata.RoomID,
|
||||
"region_id": metadata.RegionID,
|
||||
"total_amount": metadata.TotalAmount,
|
||||
"packet_count": metadata.PacketCount,
|
||||
"open_at_ms": metadata.OpenAtMS,
|
||||
"expires_at_ms": metadata.ExpiresAtMS,
|
||||
"created_at_ms": metadata.CreatedAtMS,
|
||||
"transaction_id": transactionID,
|
||||
"command_id": commandID,
|
||||
"packet_id": metadata.PacketID,
|
||||
"packet_type": metadata.PacketType,
|
||||
"sender_user_id": metadata.SenderUserID,
|
||||
"room_id": metadata.RoomID,
|
||||
"region_id": metadata.RegionID,
|
||||
"total_amount": metadata.TotalAmount,
|
||||
"packet_count": metadata.PacketCount,
|
||||
"remaining_amount": metadata.RemainingAmount,
|
||||
"remaining_count": metadata.RemainingCount,
|
||||
"status": metadata.Status,
|
||||
"open_at_ms": metadata.OpenAtMS,
|
||||
"expires_at_ms": metadata.ExpiresAtMS,
|
||||
"created_at_ms": metadata.CreatedAtMS,
|
||||
},
|
||||
CreatedAtMS: nowMS,
|
||||
}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hyapp/services/wallet-service/internal/domain/ledger"
|
||||
)
|
||||
|
||||
func TestRedPacketCreatedEventCarriesDisplayFacts(t *testing.T) {
|
||||
event := redPacketCreatedEvent("tx-red-packet-1", "cmd-red-packet-1", redPacketMetadata{
|
||||
PacketID: "VRRP2026052712340001",
|
||||
PacketType: ledger.RedPacketTypeDelayed,
|
||||
SenderUserID: 10001,
|
||||
RoomID: "9001",
|
||||
RegionID: 310,
|
||||
TotalAmount: 15000,
|
||||
PacketCount: 5,
|
||||
RemainingAmount: 15000,
|
||||
RemainingCount: 5,
|
||||
Status: ledger.RedPacketStatusWaitingOpen,
|
||||
OpenAtMS: 1_780_000_000_000,
|
||||
ExpiresAtMS: 1_780_086_400_000,
|
||||
CreatedAtMS: 1_779_999_700_000,
|
||||
}, 1_779_999_700_000)
|
||||
|
||||
payload, ok := event.Payload.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("red packet created payload type mismatch: %T", event.Payload)
|
||||
}
|
||||
if payload["total_amount"] != int64(15000) || payload["packet_count"] != int32(5) || payload["remaining_count"] != int32(5) {
|
||||
t.Fatalf("red packet created payload must carry amount/count facts: %+v", payload)
|
||||
}
|
||||
if payload["status"] != ledger.RedPacketStatusWaitingOpen || payload["sender_user_id"] != int64(10001) {
|
||||
t.Fatalf("red packet created payload must carry status and sender facts: %+v", payload)
|
||||
}
|
||||
}
|
||||
@ -83,10 +83,42 @@ func Open(ctx context.Context, dsn string) (*Repository, error) {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureGiftDiamondRatioSchema(ctx, db); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Repository{db: db}, nil
|
||||
}
|
||||
|
||||
func ensureGiftDiamondRatioSchema(ctx context.Context, db *sql.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS gift_diamond_ratio_configs (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '送礼用户区域,0 表示全局默认',
|
||||
gift_type_code VARCHAR(32) NOT NULL COMMENT '礼物类型编码',
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT '业务状态',
|
||||
ratio_percent DECIMAL(5,2) NOT NULL DEFAULT 100.00 COMMENT '主播周期钻石入账比例,百分比',
|
||||
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建后台用户 ID',
|
||||
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新后台用户 ID',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, region_id, gift_type_code),
|
||||
KEY idx_gift_diamond_ratio_region (app_code, region_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='礼物入主播周期钻石比例配置表'`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT IGNORE INTO gift_diamond_ratio_configs (
|
||||
app_code, region_id, gift_type_code, status, ratio_percent,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES
|
||||
('lalu', 0, 'normal', 'active', 100.00, 0, 0, 0, 0),
|
||||
('lalu', 0, 'lucky', 'active', 100.00, 0, 0, 0, 0),
|
||||
('lalu', 0, 'super_lucky', 'active', 100.00, 0, 0, 0, 0)`)
|
||||
return err
|
||||
}
|
||||
|
||||
// Close 释放 MySQL 连接池。
|
||||
func (r *Repository) Close() error {
|
||||
if r == nil || r.db == nil {
|
||||
@ -135,6 +167,9 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
if command.RegionID < 0 {
|
||||
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "region_id is invalid")
|
||||
}
|
||||
if command.SenderRegionID < 0 {
|
||||
return ledger.Receipt{}, xerr.New(xerr.InvalidArgument, "sender_region_id is invalid")
|
||||
}
|
||||
giftConfig, err := r.resolveActiveGiftConfig(ctx, tx, command.GiftID, command.RegionID)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
@ -157,10 +192,20 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
}
|
||||
hostPeriodDiamondAdded := int64(0)
|
||||
hostPeriodCycleKey := ""
|
||||
giftDiamondRatioPercent := "0.00"
|
||||
giftDiamondRatioRegionID := int64(0)
|
||||
if command.TargetIsHost {
|
||||
// Phase 2 固化“用户礼物金币价值 -> 主播周期钻石”的默认 1:1 口径;后续政策配置只替换这里的换算快照。
|
||||
ratio, ratioRegionID, err := r.resolveGiftDiamondRatio(ctx, tx, command.AppCode, command.SenderRegionID, giftConfig.GiftTypeCode)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
// 非主播不会进入该分支,因此普通用户收礼只增加 GIFT_POINT,不产生工资周期积分。
|
||||
hostPeriodDiamondAdded = chargeAmount
|
||||
hostPeriodDiamondAdded, err = giftDiamondAmount(chargeAmount, ratio.PPM)
|
||||
if err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
}
|
||||
giftDiamondRatioPercent = ratio.Percent
|
||||
giftDiamondRatioRegionID = ratioRegionID
|
||||
hostPeriodCycleKey = hostPeriodCycleKeyFromMS(nowMs)
|
||||
}
|
||||
|
||||
@ -178,34 +223,37 @@ func (r *Repository) DebitGift(ctx context.Context, command ledger.DebitGiftComm
|
||||
|
||||
transactionID := transactionID(command.AppCode, command.CommandID)
|
||||
metadata := giftMetadata{
|
||||
AppCode: command.AppCode,
|
||||
GiftID: command.GiftID,
|
||||
GiftName: giftConfig.Name,
|
||||
ResourceID: giftConfig.ResourceID,
|
||||
ResourceSnapshot: mustJSON(giftConfig.Resource),
|
||||
GiftTypeCode: giftConfig.GiftTypeCode,
|
||||
PresentationJSON: giftConfig.PresentationJSON,
|
||||
SortOrder: giftConfig.SortOrder,
|
||||
GiftCount: command.GiftCount,
|
||||
PriceVersion: price.PriceVersion,
|
||||
ChargeAssetType: price.ChargeAssetType,
|
||||
ChargeAmount: chargeAmount,
|
||||
CoinSpent: chargeAmount,
|
||||
GiftPointAdded: giftPointAdded,
|
||||
HeatValue: heatValue,
|
||||
BalanceAfter: sender.AvailableAmount - chargeAmount,
|
||||
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
|
||||
SenderUserID: command.SenderUserID,
|
||||
TargetUserID: command.TargetUserID,
|
||||
TargetIsHost: command.TargetIsHost,
|
||||
TargetHostRegionID: command.TargetHostRegionID,
|
||||
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
|
||||
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
|
||||
HostPeriodCycleKey: hostPeriodCycleKey,
|
||||
RoomID: command.RoomID,
|
||||
CoinPrice: price.CoinPrice,
|
||||
GiftPointAmount: price.GiftPointAmount,
|
||||
HeatUnitValue: price.HeatValue,
|
||||
AppCode: command.AppCode,
|
||||
GiftID: command.GiftID,
|
||||
GiftName: giftConfig.Name,
|
||||
ResourceID: giftConfig.ResourceID,
|
||||
ResourceSnapshot: mustJSON(giftConfig.Resource),
|
||||
GiftTypeCode: giftConfig.GiftTypeCode,
|
||||
PresentationJSON: giftConfig.PresentationJSON,
|
||||
SortOrder: giftConfig.SortOrder,
|
||||
GiftCount: command.GiftCount,
|
||||
PriceVersion: price.PriceVersion,
|
||||
ChargeAssetType: price.ChargeAssetType,
|
||||
ChargeAmount: chargeAmount,
|
||||
CoinSpent: chargeAmount,
|
||||
GiftPointAdded: giftPointAdded,
|
||||
HeatValue: heatValue,
|
||||
BalanceAfter: sender.AvailableAmount - chargeAmount,
|
||||
BillingReceipt: billingReceiptID(command.AppCode, command.CommandID),
|
||||
SenderUserID: command.SenderUserID,
|
||||
SenderRegionID: command.SenderRegionID,
|
||||
TargetUserID: command.TargetUserID,
|
||||
TargetIsHost: command.TargetIsHost,
|
||||
TargetHostRegionID: command.TargetHostRegionID,
|
||||
TargetAgencyOwnerUserID: command.TargetAgencyOwnerUserID,
|
||||
HostPeriodDiamondAdded: hostPeriodDiamondAdded,
|
||||
GiftDiamondRatioPercent: giftDiamondRatioPercent,
|
||||
GiftDiamondRatioRegionID: giftDiamondRatioRegionID,
|
||||
HostPeriodCycleKey: hostPeriodCycleKey,
|
||||
RoomID: command.RoomID,
|
||||
CoinPrice: price.CoinPrice,
|
||||
GiftPointAmount: price.GiftPointAmount,
|
||||
HeatUnitValue: price.HeatValue,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeGiftDebit, requestHash, fmt.Sprintf("%s:%s", command.GiftID, price.PriceVersion), metadata, nowMs); err != nil {
|
||||
return ledger.Receipt{}, err
|
||||
@ -339,12 +387,7 @@ func (r *Repository) upsertUserGiftWall(ctx context.Context, tx *sql.Tx, metadat
|
||||
// metadata 同时保留 coin_spent 和 charge_amount;投影使用最终收费金额作为礼物价值。
|
||||
chargeAmount = metadata.CoinSpent
|
||||
}
|
||||
totalCoinValue, totalDiamondValue := int64(0), int64(0)
|
||||
if chargeAssetType == ledger.AssetDiamond {
|
||||
totalDiamondValue = chargeAmount
|
||||
} else {
|
||||
totalCoinValue = chargeAmount
|
||||
}
|
||||
totalCoinValue, totalDiamondValue := chargeAmount, int64(0)
|
||||
resourceSnapshotJSON := strings.TrimSpace(metadata.ResourceSnapshot)
|
||||
if resourceSnapshotJSON == "" {
|
||||
resourceSnapshotJSON = "{}"
|
||||
@ -1130,6 +1173,69 @@ func (r *Repository) resolveGiftPrice(ctx context.Context, tx *sql.Tx, giftID st
|
||||
return price, nil
|
||||
}
|
||||
|
||||
type giftDiamondRatioSnapshot struct {
|
||||
Percent string
|
||||
PPM int64
|
||||
}
|
||||
|
||||
func (r *Repository) resolveGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, senderRegionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, int64, error) {
|
||||
giftTypeCode = strings.TrimSpace(giftTypeCode)
|
||||
if giftTypeCode == "" {
|
||||
giftTypeCode = "normal"
|
||||
}
|
||||
regions := []int64{senderRegionID}
|
||||
if senderRegionID != 0 {
|
||||
regions = append(regions, 0)
|
||||
}
|
||||
for _, regionID := range regions {
|
||||
ratio, exists, err := r.getGiftDiamondRatio(ctx, tx, appCode, regionID, giftTypeCode)
|
||||
if err != nil {
|
||||
return giftDiamondRatioSnapshot{}, 0, err
|
||||
}
|
||||
if exists {
|
||||
return ratio, regionID, nil
|
||||
}
|
||||
}
|
||||
return giftDiamondRatioSnapshot{Percent: "100.00", PPM: 1_000_000}, 0, nil
|
||||
}
|
||||
|
||||
func (r *Repository) getGiftDiamondRatio(ctx context.Context, tx *sql.Tx, appCode string, regionID int64, giftTypeCode string) (giftDiamondRatioSnapshot, bool, error) {
|
||||
var percent string
|
||||
var ppm int64
|
||||
err := tx.QueryRowContext(ctx, `
|
||||
SELECT CAST(ratio_percent AS CHAR), CAST(ROUND(ratio_percent * 10000) AS SIGNED)
|
||||
FROM gift_diamond_ratio_configs
|
||||
WHERE app_code = ? AND region_id = ? AND gift_type_code = ? AND status = 'active'
|
||||
LIMIT 1`,
|
||||
appcode.Normalize(appCode), regionID, giftTypeCode,
|
||||
).Scan(&percent, &ppm)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return giftDiamondRatioSnapshot{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return giftDiamondRatioSnapshot{}, false, err
|
||||
}
|
||||
if ppm < 0 || ppm > 1_000_000 {
|
||||
return giftDiamondRatioSnapshot{}, false, xerr.New(xerr.InvalidArgument, "gift diamond ratio is invalid")
|
||||
}
|
||||
percent = strings.TrimSpace(percent)
|
||||
if percent == "" {
|
||||
percent = "100.00"
|
||||
}
|
||||
return giftDiamondRatioSnapshot{Percent: percent, PPM: ppm}, true, nil
|
||||
}
|
||||
|
||||
func giftDiamondAmount(chargeAmount int64, ratioPPM int64) (int64, error) {
|
||||
if chargeAmount < 0 || ratioPPM < 0 {
|
||||
return 0, xerr.New(xerr.InvalidArgument, "gift diamond amount is invalid")
|
||||
}
|
||||
product, err := checkedMul(chargeAmount, ratioPPM)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return product / 1_000_000, nil
|
||||
}
|
||||
|
||||
func (r *Repository) resolveRechargePolicy(ctx context.Context, tx *sql.Tx, regionID int64, nowMs int64) (rechargePolicy, error) {
|
||||
row := tx.QueryRowContext(ctx,
|
||||
`SELECT policy_id, region_id, policy_version, currency_code, coin_amount, usd_minor_amount, effective_from_ms
|
||||
@ -1800,11 +1906,14 @@ type giftMetadata struct {
|
||||
BalanceAfter int64 `json:"balance_after"`
|
||||
BillingReceipt string `json:"billing_receipt_id"`
|
||||
SenderUserID int64 `json:"sender_user_id"`
|
||||
SenderRegionID int64 `json:"sender_region_id"`
|
||||
TargetUserID int64 `json:"target_user_id"`
|
||||
TargetIsHost bool `json:"target_is_host"`
|
||||
TargetHostRegionID int64 `json:"target_host_region_id"`
|
||||
TargetAgencyOwnerUserID int64 `json:"target_agency_owner_user_id"`
|
||||
HostPeriodDiamondAdded int64 `json:"host_period_diamond_added"`
|
||||
GiftDiamondRatioPercent string `json:"gift_diamond_ratio_percent"`
|
||||
GiftDiamondRatioRegionID int64 `json:"gift_diamond_ratio_region_id"`
|
||||
HostPeriodDiamondAfter int64 `json:"host_period_diamond_after"`
|
||||
HostPeriodDiamondVersion int64 `json:"host_period_diamond_version"`
|
||||
HostPeriodCycleKey string `json:"host_period_cycle_key"`
|
||||
@ -2255,22 +2364,24 @@ func hostPeriodDiamondCreditedEvent(transactionID string, commandID string, meta
|
||||
AvailableDelta: metadata.HostPeriodDiamondAdded,
|
||||
FrozenDelta: 0,
|
||||
Payload: map[string]any{
|
||||
"transaction_id": transactionID,
|
||||
"command_id": commandID,
|
||||
"host_user_id": metadata.TargetUserID,
|
||||
"cycle_key": metadata.HostPeriodCycleKey,
|
||||
"region_id": metadata.TargetHostRegionID,
|
||||
"agency_owner_user_id": metadata.TargetAgencyOwnerUserID,
|
||||
"diamond_delta": metadata.HostPeriodDiamondAdded,
|
||||
"diamond_after": metadata.HostPeriodDiamondAfter,
|
||||
"gift_charge_asset_type": metadata.ChargeAssetType,
|
||||
"gift_charge_amount": metadata.ChargeAmount,
|
||||
"gift_id": metadata.GiftID,
|
||||
"gift_count": metadata.GiftCount,
|
||||
"sender_user_id": metadata.SenderUserID,
|
||||
"room_id": metadata.RoomID,
|
||||
"conversion_ratio_snapshot": "1",
|
||||
"created_at_ms": nowMs,
|
||||
"transaction_id": transactionID,
|
||||
"command_id": commandID,
|
||||
"host_user_id": metadata.TargetUserID,
|
||||
"cycle_key": metadata.HostPeriodCycleKey,
|
||||
"region_id": metadata.TargetHostRegionID,
|
||||
"agency_owner_user_id": metadata.TargetAgencyOwnerUserID,
|
||||
"diamond_delta": metadata.HostPeriodDiamondAdded,
|
||||
"diamond_after": metadata.HostPeriodDiamondAfter,
|
||||
"gift_charge_asset_type": metadata.ChargeAssetType,
|
||||
"gift_charge_amount": metadata.ChargeAmount,
|
||||
"gift_id": metadata.GiftID,
|
||||
"gift_count": metadata.GiftCount,
|
||||
"sender_user_id": metadata.SenderUserID,
|
||||
"sender_region_id": metadata.SenderRegionID,
|
||||
"room_id": metadata.RoomID,
|
||||
"gift_diamond_ratio_percent": metadata.GiftDiamondRatioPercent,
|
||||
"gift_diamond_ratio_region_id": metadata.GiftDiamondRatioRegionID,
|
||||
"created_at_ms": nowMs,
|
||||
},
|
||||
CreatedAtMS: nowMs,
|
||||
}
|
||||
@ -2330,7 +2441,7 @@ func hostPeriodCycleKeyFromMS(ms int64) string {
|
||||
|
||||
func debitRequestHash(command ledger.DebitGiftCommand) string {
|
||||
// 哈希覆盖所有账务语义字段;相同 command_id 只能重放完全相同的业务命令。
|
||||
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%t|%d|%d",
|
||||
return stableHash(fmt.Sprintf("gift|%s|%s|%d|%d|%s|%d|%s|%d|%d|%t|%d|%d",
|
||||
appcode.Normalize(command.AppCode),
|
||||
command.RoomID,
|
||||
command.SenderUserID,
|
||||
@ -2339,6 +2450,7 @@ func debitRequestHash(command ledger.DebitGiftCommand) string {
|
||||
command.GiftCount,
|
||||
strings.TrimSpace(command.PriceVersion),
|
||||
command.RegionID,
|
||||
command.SenderRegionID,
|
||||
command.TargetIsHost,
|
||||
command.TargetHostRegionID,
|
||||
command.TargetAgencyOwnerUserID,
|
||||
|
||||
@ -1135,18 +1135,18 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou
|
||||
durationMS := int64(item.DurationDays) * 24 * int64(time.Hour/time.Millisecond)
|
||||
coinBalanceAfter := account.AvailableAmount - item.CoinPrice
|
||||
metadata := map[string]any{
|
||||
"app_code": command.AppCode,
|
||||
"order_id": orderID,
|
||||
"user_id": command.UserID,
|
||||
"shop_item_id": item.ShopItemID,
|
||||
"resource_id": item.ResourceID,
|
||||
"resource_code": item.Resource.ResourceCode,
|
||||
"resource_type": item.Resource.ResourceType,
|
||||
"duration_days": item.DurationDays,
|
||||
"duration_ms": durationMS,
|
||||
"price_type": item.PriceType,
|
||||
"coin_spent": item.CoinPrice,
|
||||
"balance_after": coinBalanceAfter,
|
||||
"app_code": command.AppCode,
|
||||
"order_id": orderID,
|
||||
"user_id": command.UserID,
|
||||
"shop_item_id": item.ShopItemID,
|
||||
"resource_id": item.ResourceID,
|
||||
"resource_code": item.Resource.ResourceCode,
|
||||
"resource_type": item.Resource.ResourceType,
|
||||
"duration_days": item.DurationDays,
|
||||
"duration_ms": durationMS,
|
||||
"price_type": item.PriceType,
|
||||
"coin_spent": item.CoinPrice,
|
||||
"balance_after": coinBalanceAfter,
|
||||
"resource_grant_id": grantID,
|
||||
}
|
||||
if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil {
|
||||
@ -1563,11 +1563,25 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in
|
||||
return entitlementID, nil
|
||||
}
|
||||
|
||||
func (r *Repository) alignEntitlementExpiresAt(ctx context.Context, tx *sql.Tx, entitlementID string, expiresAtMS int64, nowMs int64) error {
|
||||
if strings.TrimSpace(entitlementID) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE user_resource_entitlements
|
||||
SET expires_at_ms = ?, updated_at_ms = ?
|
||||
WHERE app_code = ? AND entitlement_id = ?`,
|
||||
expiresAtMS, nowMs, appcode.FromContext(ctx), entitlementID,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *Repository) queryActiveEntitlementForUpdate(ctx context.Context, tx *sql.Tx, userID int64, resourceID int64, nowMs int64) (resourcedomain.UserResourceEntitlement, bool, error) {
|
||||
row := tx.QueryRowContext(ctx, `
|
||||
SELECT e.app_code, e.entitlement_id, e.user_id, e.resource_id, e.status,
|
||||
e.quantity, e.remaining_quantity, e.effective_at_ms, e.expires_at_ms,
|
||||
e.source_grant_id, e.created_at_ms, e.updated_at_ms,
|
||||
FALSE AS equipped,
|
||||
`+resourceColumnsWithAlias("r")+`
|
||||
FROM user_resource_entitlements e
|
||||
JOIN resources r ON r.app_code = e.app_code AND r.resource_id = e.resource_id
|
||||
@ -2934,7 +2948,7 @@ func normalizeResourceGroupItemInput(item resourcedomain.ResourceGroupItemInput)
|
||||
|
||||
func validGroupWalletAssetType(assetType string) bool {
|
||||
switch resourcedomain.NormalizeWalletAssetType(assetType) {
|
||||
case ledger.AssetCoin, ledger.AssetDiamond:
|
||||
case ledger.AssetCoin:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
@ -21,6 +21,15 @@ type Repository struct {
|
||||
schema *mysqlschema.Schema
|
||||
}
|
||||
|
||||
type VipRewardResourceSeed struct {
|
||||
ResourceID int64
|
||||
ResourceCode string
|
||||
ResourceType string
|
||||
Name string
|
||||
GrantStrategy string
|
||||
SortOrder int
|
||||
}
|
||||
|
||||
// CountRows returns a simple count from a known wallet table for persistence assertions.
|
||||
func (r *Repository) CountRows(table string, where string, args ...any) int {
|
||||
r.t.Helper()
|
||||
@ -36,6 +45,22 @@ func (r *Repository) CountRows(table string, where string, args ...any) int {
|
||||
return count
|
||||
}
|
||||
|
||||
func (r *Repository) ActiveResourceEntitlement(userID int64, resourceID int64) (expiresAtMS int64, quantity int64, remainingQuantity int64) {
|
||||
r.t.Helper()
|
||||
|
||||
err := r.schema.DB.QueryRowContext(context.Background(), `
|
||||
SELECT expires_at_ms, quantity, remaining_quantity
|
||||
FROM user_resource_entitlements
|
||||
WHERE user_id = ? AND resource_id = ? AND status = 'active'
|
||||
ORDER BY expires_at_ms DESC, created_at_ms DESC
|
||||
LIMIT 1
|
||||
`, userID, resourceID).Scan(&expiresAtMS, &quantity, &remainingQuantity)
|
||||
if err != nil {
|
||||
r.t.Fatalf("query active resource entitlement failed: %v", err)
|
||||
}
|
||||
return expiresAtMS, quantity, remainingQuantity
|
||||
}
|
||||
|
||||
// HostSalarySettlementIDs returns the persisted settlement IDs for one host/cycle in creation order.
|
||||
func (r *Repository) HostSalarySettlementIDs(userID int64, cycleKey string) []string {
|
||||
r.t.Helper()
|
||||
@ -142,6 +167,20 @@ func (r *Repository) MoveHostPeriodCycle(userID int64, fromCycle string, toCycle
|
||||
}
|
||||
}
|
||||
|
||||
// HostPeriodGiftDiamondTotal returns the current accumulated gift diamonds for a host.
|
||||
func (r *Repository) HostPeriodGiftDiamondTotal(userID int64) int64 {
|
||||
r.t.Helper()
|
||||
var total int64
|
||||
err := r.schema.DB.QueryRowContext(context.Background(), `
|
||||
SELECT COALESCE(SUM(gift_diamond_total), 0)
|
||||
FROM host_period_diamond_accounts
|
||||
WHERE user_id = ?`, userID).Scan(&total)
|
||||
if err != nil {
|
||||
r.t.Fatalf("query host period gift diamond total failed: %v", err)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// InsertRawGiftPrice allows tests to seed inactive or future prices without service helpers.
|
||||
func (r *Repository) InsertRawGiftPrice(giftID string, priceVersion string, status string, effectiveAtMs int64) {
|
||||
r.t.Helper()
|
||||
@ -191,6 +230,74 @@ func (r *Repository) SetBalance(userID int64, balance int64) {
|
||||
r.SetAssetBalance(userID, "COIN", balance)
|
||||
}
|
||||
|
||||
func (r *Repository) SeedVIPRewardGroup(level int32, groupID int64, resources []VipRewardResourceSeed) {
|
||||
r.t.Helper()
|
||||
|
||||
nowMs := time.Now().UnixMilli()
|
||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO resource_groups (
|
||||
group_id, group_code, name, status, description, sort_order,
|
||||
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, 'active', '', 0, 0, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, groupID, fmt.Sprintf("vip_test_group_%d", groupID), fmt.Sprintf("VIP test group %d", groupID), nowMs, nowMs)
|
||||
if err != nil {
|
||||
r.t.Fatalf("seed vip reward group failed: %v", err)
|
||||
}
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `DELETE FROM resource_group_items WHERE group_id = ?`, groupID); err != nil {
|
||||
r.t.Fatalf("clear vip reward group items failed: %v", err)
|
||||
}
|
||||
for index, resource := range resources {
|
||||
sortOrder := resource.SortOrder
|
||||
if sortOrder == 0 {
|
||||
sortOrder = (index + 1) * 10
|
||||
}
|
||||
name := resource.Name
|
||||
if name == "" {
|
||||
name = resource.ResourceCode
|
||||
}
|
||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO resources (
|
||||
resource_id, resource_code, resource_type, name, status,
|
||||
grantable, manager_grant_enabled, grant_strategy, wallet_asset_type,
|
||||
wallet_asset_amount, price_type, coin_price, gift_point_amount,
|
||||
usage_scope_json, asset_url, preview_url, animation_url,
|
||||
metadata_json, sort_order, created_by_user_id, updated_by_user_id,
|
||||
created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, ?, ?, 'active', TRUE, TRUE, ?, '', 0, '', 0, 0,
|
||||
JSON_ARRAY(), '', '', '', NULL, ?, 0, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
resource_type = VALUES(resource_type),
|
||||
name = VALUES(name),
|
||||
status = VALUES(status),
|
||||
grant_strategy = VALUES(grant_strategy),
|
||||
sort_order = VALUES(sort_order),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`, resource.ResourceID, resource.ResourceCode, resource.ResourceType, name, resource.GrantStrategy, sortOrder, nowMs, nowMs)
|
||||
if err != nil {
|
||||
r.t.Fatalf("seed vip reward resource failed: %v", err)
|
||||
}
|
||||
_, err = r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO resource_group_items (
|
||||
group_id, item_type, resource_id, wallet_asset_type, wallet_asset_amount,
|
||||
quantity, duration_ms, sort_order, created_at_ms, updated_at_ms
|
||||
) VALUES (?, 'resource', ?, '', 0, 1, 0, ?, ?, ?)
|
||||
`, groupID, resource.ResourceID, sortOrder, nowMs, nowMs)
|
||||
if err != nil {
|
||||
r.t.Fatalf("seed vip reward group item failed: %v", err)
|
||||
}
|
||||
}
|
||||
if _, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
UPDATE vip_levels
|
||||
SET reward_resource_group_id = ?, updated_at_ms = ?
|
||||
WHERE level = ?
|
||||
`, groupID, nowMs, level); err != nil {
|
||||
r.t.Fatalf("update vip level reward group failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetAssetBalance prepares a sender account for debit tests.
|
||||
func (r *Repository) SetAssetBalance(userID int64, assetType string, balance int64) {
|
||||
r.t.Helper()
|
||||
@ -277,6 +384,37 @@ func (r *Repository) SetGiftPrice(giftID string, priceVersion string, coinPrice
|
||||
}
|
||||
}
|
||||
|
||||
// SetGiftType updates a seeded gift config to the requested gift type.
|
||||
func (r *Repository) SetGiftType(giftID string, giftTypeCode string) {
|
||||
r.t.Helper()
|
||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
UPDATE gift_configs
|
||||
SET gift_type_code = ?, updated_at_ms = ?
|
||||
WHERE gift_id = ?`, giftTypeCode, time.Now().UnixMilli(), giftID)
|
||||
if err != nil {
|
||||
r.t.Fatalf("set gift type failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetGiftDiamondRatio configures the host-period diamond ratio for a sender region and gift type.
|
||||
func (r *Repository) SetGiftDiamondRatio(regionID int64, giftTypeCode string, ratioPercent string) {
|
||||
r.t.Helper()
|
||||
nowMs := time.Now().UnixMilli()
|
||||
_, err := r.schema.DB.ExecContext(context.Background(), `
|
||||
INSERT INTO gift_diamond_ratio_configs (
|
||||
region_id, gift_type_code, status, ratio_percent,
|
||||
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
|
||||
) VALUES (?, ?, 'active', ?, 0, 0, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
status = VALUES(status),
|
||||
ratio_percent = VALUES(ratio_percent),
|
||||
updated_at_ms = VALUES(updated_at_ms)`,
|
||||
regionID, giftTypeCode, ratioPercent, nowMs, nowMs)
|
||||
if err != nil {
|
||||
r.t.Fatalf("set gift diamond ratio failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// SetRechargePolicy 配置区域充值汇率,用于币商转账充值口径测试。
|
||||
func (r *Repository) SetRechargePolicy(regionID int64, policyVersion string, coinAmount int64, usdMinorAmount int64) {
|
||||
r.t.Helper()
|
||||
@ -396,7 +534,7 @@ func validateTableName(table string) string {
|
||||
case "wallet_transactions", "wallet_entries", "wallet_outbox", "wallet_accounts", "wallet_recharge_records",
|
||||
"host_period_diamond_accounts", "host_period_diamond_entries", "host_agency_salary_policies",
|
||||
"host_agency_salary_policy_levels", "host_salary_settlement_progress", "host_salary_settlement_records",
|
||||
"coin_seller_stock_records", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests",
|
||||
"coin_seller_stock_records", "gift_diamond_ratio_configs", "wallet_diamond_exchange_rules", "wallet_withdrawal_requests",
|
||||
"vip_levels", "user_vip_memberships", "vip_purchase_orders", "user_vip_history",
|
||||
"resources", "resource_groups", "resource_group_items", "resource_shop_items", "resource_shop_purchase_orders", "gift_type_configs", "gift_configs", "gift_config_regions", "user_gift_wall", "wallet_projection_events",
|
||||
"resource_grants", "resource_grant_items", "user_resource_entitlements", "user_resource_equipment":
|
||||
|
||||
@ -27,16 +27,17 @@ func NewServer(svc *walletservice.Service) *Server {
|
||||
func (s *Server) DebitGift(ctx context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||
ctx = appcode.WithContext(ctx, req.GetAppCode())
|
||||
receipt, err := s.svc.DebitGift(ctx, ledger.DebitGiftCommand{
|
||||
AppCode: req.GetAppCode(),
|
||||
CommandID: req.GetCommandId(),
|
||||
RoomID: req.GetRoomId(),
|
||||
SenderUserID: req.GetSenderUserId(),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
GiftID: req.GetGiftId(),
|
||||
GiftCount: req.GetGiftCount(),
|
||||
PriceVersion: req.GetPriceVersion(),
|
||||
RegionID: req.GetRegionId(),
|
||||
TargetIsHost: req.GetTargetIsHost(),
|
||||
AppCode: req.GetAppCode(),
|
||||
CommandID: req.GetCommandId(),
|
||||
RoomID: req.GetRoomId(),
|
||||
SenderUserID: req.GetSenderUserId(),
|
||||
TargetUserID: req.GetTargetUserId(),
|
||||
GiftID: req.GetGiftId(),
|
||||
GiftCount: req.GetGiftCount(),
|
||||
PriceVersion: req.GetPriceVersion(),
|
||||
RegionID: req.GetRegionId(),
|
||||
SenderRegionID: req.GetSenderRegionId(),
|
||||
TargetIsHost: req.GetTargetIsHost(),
|
||||
// 工资政策区域来自 user-service host profile,不能用房间 visible_region_id 兜底。
|
||||
TargetHostRegionID: req.GetTargetHostRegionId(),
|
||||
TargetAgencyOwnerUserID: req.GetTargetAgencyOwnerUserId(),
|
||||
@ -621,6 +622,9 @@ func vipRewardItemsToProto(items []ledger.VipRewardItem) []*walletv1.VipRewardIt
|
||||
Name: item.Name,
|
||||
Quantity: item.Quantity,
|
||||
ExpiresAtMs: item.ExpiresAtMS,
|
||||
AssetUrl: item.AssetURL,
|
||||
PreviewUrl: item.PreviewURL,
|
||||
AnimationUrl: item.AnimationURL,
|
||||
})
|
||||
}
|
||||
return resp
|
||||
|
||||
354
tools/im-c2c-broadcast/main.go
Normal file
354
tools/im-c2c-broadcast/main.go
Normal file
@ -0,0 +1,354 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"hyapp/pkg/configx"
|
||||
"hyapp/pkg/tencentim"
|
||||
)
|
||||
|
||||
const (
|
||||
sendC2CMsgCommand = "v4/openim/sendmsg"
|
||||
confirmSendValue = "SEND_REAL_TENCENT_IM_C2C"
|
||||
)
|
||||
|
||||
type serviceConfig struct {
|
||||
MySQLDSN string `yaml:"mysql_dsn"`
|
||||
TencentIM tencentIMConfig `yaml:"tencent_im"`
|
||||
}
|
||||
|
||||
type tencentIMConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
SDKAppID int64 `yaml:"sdk_app_id"`
|
||||
SecretKey string `yaml:"secret_key"`
|
||||
AdminIdentifier string `yaml:"admin_identifier"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
}
|
||||
|
||||
type sendTextC2CRequest struct {
|
||||
SyncOtherMachine int `json:"SyncOtherMachine"`
|
||||
FromAccount string `json:"From_Account,omitempty"`
|
||||
ToAccount string `json:"To_Account"`
|
||||
MsgRandom uint32 `json:"MsgRandom"`
|
||||
MsgBody []messageElement `json:"MsgBody"`
|
||||
}
|
||||
|
||||
type messageElement struct {
|
||||
MsgType string `json:"MsgType"`
|
||||
MsgContent textMsgContent `json:"MsgContent"`
|
||||
}
|
||||
|
||||
type textMsgContent struct {
|
||||
Text string `json:"Text"`
|
||||
}
|
||||
|
||||
type restResponse struct {
|
||||
ActionStatus string `json:"ActionStatus"`
|
||||
ErrorInfo string `json:"ErrorInfo"`
|
||||
ErrorCode int `json:"ErrorCode"`
|
||||
MsgKey string `json:"MsgKey,omitempty"`
|
||||
}
|
||||
|
||||
type resultLine struct {
|
||||
AtMS int64 `json:"at_ms"`
|
||||
Mode string `json:"mode"`
|
||||
ToAccount string `json:"to_account"`
|
||||
Status string `json:"status"`
|
||||
ErrorCode int `json:"error_code,omitempty"`
|
||||
ErrorInfo string `json:"error_info,omitempty"`
|
||||
MsgKey string `json:"msg_key,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
if err := run(context.Background(), os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) error {
|
||||
var userConfigPath string
|
||||
var imConfigPath string
|
||||
var fromAccount string
|
||||
var appCode string
|
||||
var message string
|
||||
var outputPath string
|
||||
var limit int
|
||||
var sleepMS int
|
||||
var timeoutSeconds int
|
||||
var send bool
|
||||
var confirmSend string
|
||||
var includeSender bool
|
||||
|
||||
flags := flag.NewFlagSet("im-c2c-broadcast", flag.ContinueOnError)
|
||||
flags.StringVar(&userConfigPath, "user-config", "services/user-service/configs/config.yaml", "user-service YAML config used for mysql_dsn")
|
||||
flags.StringVar(&imConfigPath, "im-config", "services/notice-service/configs/config.yaml", "service YAML config used for tencent_im")
|
||||
flags.StringVar(&fromAccount, "from-account", "123456", "Tencent IM From_Account")
|
||||
flags.StringVar(&appCode, "app-code", "lalu", "users.app_code filter")
|
||||
flags.StringVar(&message, "message", "", "plain C2C text message body")
|
||||
flags.StringVar(&outputPath, "output", "", "JSONL audit output path")
|
||||
flags.IntVar(&limit, "limit", 0, "maximum active users to process; 0 means all")
|
||||
flags.IntVar(&sleepMS, "sleep-ms", 25, "sleep between real sends")
|
||||
flags.IntVar(&timeoutSeconds, "timeout-seconds", 10, "Tencent REST request timeout")
|
||||
flags.BoolVar(&send, "send", false, "perform real Tencent IM sends; omitted means dry-run")
|
||||
flags.StringVar(&confirmSend, "confirm-send", "", "must equal "+confirmSendValue+" when --send is set")
|
||||
flags.BoolVar(&includeSender, "include-sender", false, "include from-account when it is also an active numeric user")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fromAccount = strings.TrimSpace(fromAccount)
|
||||
appCode = strings.TrimSpace(appCode)
|
||||
message = strings.TrimSpace(message)
|
||||
if fromAccount == "" || appCode == "" || message == "" {
|
||||
return errors.New("from-account, app-code and message are required")
|
||||
}
|
||||
if send && confirmSend != confirmSendValue {
|
||||
return fmt.Errorf("--send requires --confirm-send=%s", confirmSendValue)
|
||||
}
|
||||
if sleepMS < 0 || timeoutSeconds <= 0 || limit < 0 {
|
||||
return errors.New("sleep-ms, timeout-seconds and limit are invalid")
|
||||
}
|
||||
|
||||
userCfg, err := loadConfig(userConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load user config: %w", err)
|
||||
}
|
||||
imCfg, err := loadConfig(imConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load im config: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(userCfg.MySQLDSN) == "" {
|
||||
return errors.New("user config mysql_dsn is empty")
|
||||
}
|
||||
if err := validateTencentIMConfig(imCfg.TencentIM); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", userCfg.MySQLDSN)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
return fmt.Errorf("ping mysql: %w", err)
|
||||
}
|
||||
|
||||
targets, totalActive, err := listActiveUserIDs(ctx, db, appCode, fromAccount, includeSender, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mode := "dry-run"
|
||||
if send {
|
||||
mode = "send"
|
||||
}
|
||||
if outputPath == "" {
|
||||
outputPath = filepath.Join("run", fmt.Sprintf("im-c2c-broadcast-%s.jsonl", time.Now().UTC().Format("20060102-150405")))
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
output, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer output.Close()
|
||||
|
||||
fmt.Printf("mode=%s app_code=%s sdk_app_id=%d endpoint=%s from_account=%s total_active=%d targets=%d output=%s\n",
|
||||
mode, appCode, imCfg.TencentIM.SDKAppID, firstNonEmpty(imCfg.TencentIM.Endpoint, tencentim.DefaultEndpoint), fromAccount, totalActive, len(targets), outputPath)
|
||||
if len(targets) > 0 {
|
||||
fmt.Printf("first_target=%s last_target=%s\n", targets[0], targets[len(targets)-1])
|
||||
}
|
||||
if !send {
|
||||
for _, target := range targets {
|
||||
if err := writeResult(output, resultLine{AtMS: time.Now().UTC().UnixMilli(), Mode: mode, ToAccount: target, Status: "dry_run"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Println("dry_run_complete=true")
|
||||
return nil
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeoutSeconds) * time.Second}
|
||||
for i, target := range targets {
|
||||
response, err := sendTencentText(ctx, client, imCfg.TencentIM, fromAccount, target, message)
|
||||
line := resultLine{AtMS: time.Now().UTC().UnixMilli(), Mode: mode, ToAccount: target, Status: "ok"}
|
||||
if err != nil {
|
||||
line.Status = "failed"
|
||||
line.ErrorInfo = err.Error()
|
||||
} else if response.ErrorCode != 0 || !strings.EqualFold(firstNonEmpty(response.ActionStatus, "OK"), "OK") {
|
||||
line.Status = "failed"
|
||||
line.ErrorCode = response.ErrorCode
|
||||
line.ErrorInfo = response.ErrorInfo
|
||||
} else {
|
||||
line.MsgKey = response.MsgKey
|
||||
}
|
||||
if err := writeResult(output, line); err != nil {
|
||||
return err
|
||||
}
|
||||
if line.Status != "ok" {
|
||||
fmt.Printf("target=%s status=failed error_code=%d error=%s\n", target, line.ErrorCode, line.ErrorInfo)
|
||||
}
|
||||
if sleepMS > 0 && i+1 < len(targets) {
|
||||
time.Sleep(time.Duration(sleepMS) * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("send_complete=true")
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadConfig(path string) (serviceConfig, error) {
|
||||
var cfg serviceConfig
|
||||
err := configx.LoadYAML(strings.TrimSpace(path), &cfg)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
func validateTencentIMConfig(cfg tencentIMConfig) error {
|
||||
if cfg.SDKAppID <= 0 || strings.TrimSpace(cfg.SecretKey) == "" || strings.TrimSpace(cfg.AdminIdentifier) == "" {
|
||||
return errors.New("tencent_im sdk_app_id, secret_key and admin_identifier are required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func listActiveUserIDs(ctx context.Context, db *sql.DB, appCode string, fromAccount string, includeSender bool, limit int) ([]string, int64, error) {
|
||||
var totalActive int64
|
||||
if err := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE app_code = ? AND status = 'active'`, appCode).Scan(&totalActive); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
excludeUserID := int64(0)
|
||||
if !includeSender {
|
||||
if value, err := strconv.ParseInt(fromAccount, 10, 64); err == nil && value > 0 {
|
||||
excludeUserID = value
|
||||
}
|
||||
}
|
||||
query := `SELECT user_id FROM users WHERE app_code = ? AND status = 'active'`
|
||||
params := []any{appCode}
|
||||
if excludeUserID > 0 {
|
||||
query += ` AND user_id <> ?`
|
||||
params = append(params, excludeUserID)
|
||||
}
|
||||
query += ` ORDER BY user_id ASC`
|
||||
if limit > 0 {
|
||||
query += ` LIMIT ?`
|
||||
params = append(params, limit)
|
||||
}
|
||||
rows, err := db.QueryContext(ctx, query, params...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
targets := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var userID int64
|
||||
if err := rows.Scan(&userID); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
targets = append(targets, strconv.FormatInt(userID, 10))
|
||||
}
|
||||
return targets, totalActive, rows.Err()
|
||||
}
|
||||
|
||||
func sendTencentText(ctx context.Context, client *http.Client, cfg tencentIMConfig, fromAccount string, toAccount string, text string) (restResponse, error) {
|
||||
requestBody := sendTextC2CRequest{
|
||||
SyncOtherMachine: 2,
|
||||
FromAccount: fromAccount,
|
||||
ToAccount: toAccount,
|
||||
MsgRandom: randomUint32(),
|
||||
MsgBody: []messageElement{
|
||||
{MsgType: "TIMTextElem", MsgContent: textMsgContent{Text: text}},
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(requestBody)
|
||||
if err != nil {
|
||||
return restResponse{}, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint(cfg, sendC2CMsgCommand), bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return restResponse{}, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return restResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return restResponse{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return restResponse{}, fmt.Errorf("tencent im http status %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
var parsed restResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return restResponse{}, err
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func endpoint(cfg tencentIMConfig, command string) string {
|
||||
adminSig, _ := tencentim.GenerateUserSig(tencentim.UserSigConfig{
|
||||
SDKAppID: cfg.SDKAppID,
|
||||
SecretKey: cfg.SecretKey,
|
||||
TTL: 24 * time.Hour,
|
||||
}, strings.TrimSpace(cfg.AdminIdentifier), time.Now().UTC())
|
||||
|
||||
values := url.Values{}
|
||||
values.Set("sdkappid", strconv.FormatInt(cfg.SDKAppID, 10))
|
||||
values.Set("identifier", strings.TrimSpace(cfg.AdminIdentifier))
|
||||
values.Set("usersig", adminSig.UserSig)
|
||||
values.Set("random", strconv.FormatUint(uint64(randomUint32()), 10))
|
||||
values.Set("contenttype", "json")
|
||||
|
||||
return "https://" + strings.TrimSuffix(firstNonEmpty(cfg.Endpoint, tencentim.DefaultEndpoint), "/") + "/" + strings.TrimPrefix(command, "/") + "?" + values.Encode()
|
||||
}
|
||||
|
||||
func writeResult(output *os.File, line resultLine) error {
|
||||
body, err := json.Marshal(line)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := output.Write(append(body, '\n')); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func randomUint32() uint32 {
|
||||
var payload [4]byte
|
||||
if _, err := rand.Read(payload[:]); err != nil {
|
||||
return uint32(time.Now().UnixNano())
|
||||
}
|
||||
return binary.BigEndian.Uint32(payload[:])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user