Compare commits

...

19 Commits

Author SHA1 Message Date
zhx
10a3df3a5a 增加批量上传 2026-06-02 18:23:45 +08:00
zhx
1b7bc6c917 Merge branch 'main' into test 2026-06-02 18:23:00 +08:00
zhx
a9456239cc feat: persist game safe height 2026-06-02 17:42:50 +08:00
zhx
46e280c8a8 主播钻石收入 2026-06-02 17:24:13 +08:00
zhx
b24cc5a336 增加相关配置 2026-06-02 16:05:38 +08:00
zhx
f5f2b828c6 Add test IM broadcast group prefix 2026-06-02 15:49:00 +08:00
zhx
6d35f421c0 字段补充 2026-06-01 19:42:53 +08:00
zhx
05881934ad chore: enable red packet broadcast on testbox 2026-06-01 18:20:41 +08:00
zhx
db5d6de329 红包字段返回 2026-06-01 17:25:47 +08:00
zhx
10bcfadbc4 docs: update lucky gift flutter integration 2026-06-01 16:31:13 +08:00
zhx
8c4a3e1754 fix: use admin red packet routes 2026-06-01 16:19:38 +08:00
zhx
cab826eabd fix: keep red packet admin routes canonical 2026-06-01 16:16:43 +08:00
zhx
f07226d8bd fix: restore red packet admin compatibility 2026-06-01 16:14:34 +08:00
zhx
afc62eca1e fix: credit lucky gift rewards synchronously 2026-06-01 16:09:48 +08:00
zhx
fd215eadb6 Merge branch 'main' into test 2026-06-01 15:40:28 +08:00
zhx
335e9718bb 新增对接方案 2026-06-01 14:08:52 +08:00
zhx
f76d2350b1 广播增加用户信息 2026-06-01 14:06:31 +08:00
zhx
880f151feb feat: add voice room red packet APIs 2026-05-29 17:36:33 +08:00
zhx
7433b24411 feat: add user report management 2026-05-29 16:48:00 +08:00
89 changed files with 4996 additions and 440 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 | 目标 RTPppm 口径;`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` 重试,不生成新命令。 |

View File

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

View 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": {}
}
```
- 失败响应也使用同一个 envelopeFlutter 只按 HTTP 状态和 `code` 分支,`message` 只做兜底展示或日志。
- 所有时间字段都是 Unix epoch milliseconds。Flutter 不用本地时区参与业务判断,只做展示格式化。
- 红包主键统一使用 `packetNo`。IM 里兼容带 `packetId`,值和 `packetNo` 相同。
- Go 返回的部分 ID 是 numberFlutter 模型里建议统一转成 `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 补全状态。

View File

@ -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` | 已验收:总倍数为各命中倍数相加,不是平均倍率 |
| 聚合发放 | 整批只写一条 outboxpayload 里有聚合 `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 状态 |
## 九、代码验收清单

View File

@ -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` 入账并把余额返回给 Appactivity outbox worker 只做钱包补偿、IM 和区域播报 | 同步发奖成功率、补偿 pending age 和播报积压告警需补 |
| 幸运礼物审计 | `DONE` | `lucky_draw_records`、后台 draws 和 draw-summary 查询已实现 | 后台可视化维度继续补 |
## Messaging Product Surface

View File

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

View File

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

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

View 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())

View File

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

View File

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

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

View 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
}

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,7 @@ type TencentIMConfig struct {
SDKAppID int64
AdminIdentifier string
CallbackAuthToken string
GroupIDPrefix string
}
// TencentRTCConfig 是腾讯云 RTC 服务端回调校验需要的最小配置。

View File

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

View File

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

View File

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

View File

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

View File

@ -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避免客户端伪造主播入账。

View File

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

View File

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

View File

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

View File

@ -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-serviceclaim 流程复用当前用户资料,避免同一用户发抢自己的红包时重复 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(),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 不自行推导主播身份或区域。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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