im与动态头像和幸运礼物策略

This commit is contained in:
zhx 2026-07-21 17:17:02 +08:00
parent 48507a3861
commit 202fc9e7cd
99 changed files with 3879 additions and 991 deletions

View File

@ -34,7 +34,7 @@ SERVICE_ID := $(patsubst mq,rocketmq-broker,$(SERVICE_ID))
SERVICE_ID := $(patsubst broker,rocketmq-broker,$(SERVICE_ID))
ADMIN_CONFIG ?= configs/config.yaml
DEV_RUN_SERVICES ?= all
DEV_RUN_MQ ?= 0
DEV_RUN_MQ ?= 1
DEV_RUN_COMPOSE_ONLY := mysql redis rocketmq-namesrv rocketmq-broker
DEV_RUN_SERVICE_LIST := $(strip $(if $(filter $(DEV_RUN_COMPOSE_ONLY),$(SERVICE_ID)),,$(if $(SERVICE_ID),$(SERVICE_ID),$(DEV_RUN_SERVICES))))
ROCKETMQ_BROKER_CONFIG ?= ./tmp/rocketmq/broker.local.conf
@ -82,8 +82,8 @@ build:
go build ./server/admin/...
go build ./server/luck-gateway/...
# `run` 默认只拉 MySQL/Redis 并让 go run 服务读取内置默认配置,避免本地开发被 MQ topic/consumer 初始化拖慢
# 需要验证 MQ fanout 时使用 `DEV_RUN_MQ=1 make run`,该模式会恢复 RocketMQ 配置生成和 topic 初始化
# `run` 默认拉起 MySQL/Redis/RocketMQ并在启动业务服务前补齐 schema 与固定 owner topic
# 仅做无 MQ 的显式隔离验证时使用 `DEV_RUN_MQ=0 make run`;普通本地启动必须覆盖真实 outbox/consumer 链路
# 可用 `make run rs`、`make run SERVICE=room-service` 或 `make run DEV_RUN_SERVICES=user-service,gateway-service` 只跑子集。
run:
./scripts/resolve-compose-container-conflicts.sh

View File

@ -3289,8 +3289,10 @@ type RoomRocketIgnited struct {
RoomShortId string `protobuf:"bytes,12,opt,name=room_short_id,json=roomShortId,proto3" json:"room_short_id,omitempty"`
RocketCoverUrl string `protobuf:"bytes,13,opt,name=rocket_cover_url,json=rocketCoverUrl,proto3" json:"rocket_cover_url,omitempty"`
ResetAtMs int64 `protobuf:"varint,14,opt,name=reset_at_ms,json=resetAtMs,proto3" json:"reset_at_ms,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// room_locked 固化点火礼物在 Room Cell 串行执行时的锁房状态,跨房点击不能异步回查后覆盖该次展示事实。
RoomLocked bool `protobuf:"varint,15,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RoomRocketIgnited) Reset() {
@ -3421,6 +3423,13 @@ func (x *RoomRocketIgnited) GetResetAtMs() int64 {
return 0
}
func (x *RoomRocketIgnited) GetRoomLocked() bool {
if x != nil {
return x.RoomLocked
}
return false
}
// RoomRocketLaunched 表达火箭已经发射,在线用户名单按发射瞬间 Room Cell presence 结算。
type RoomRocketLaunched struct {
state protoimpl.MessageState `protogen:"open.v1"`
@ -3953,7 +3962,7 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"gift_count\x18\f \x01(\x05R\tgiftCount\x12\x1d\n" +
"\n" +
"command_id\x18\r \x01(\tR\tcommandId\x12*\n" +
"\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\x82\x04\n" +
"\x11visible_region_id\x18\x0e \x01(\x03R\x0fvisibleRegionId\"\xa3\x04\n" +
"\x11RoomRocketIgnited\x12\x1b\n" +
"\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" +
"\x05level\x18\x02 \x01(\x05R\x05level\x12!\n" +
@ -3972,7 +3981,9 @@ const file_proto_events_room_v1_events_proto_rawDesc = "" +
"command_id\x18\v \x01(\tR\tcommandId\x12\"\n" +
"\rroom_short_id\x18\f \x01(\tR\vroomShortId\x12(\n" +
"\x10rocket_cover_url\x18\r \x01(\tR\x0erocketCoverUrl\x12\x1e\n" +
"\vreset_at_ms\x18\x0e \x01(\x03R\tresetAtMs\"\xe6\x02\n" +
"\vreset_at_ms\x18\x0e \x01(\x03R\tresetAtMs\x12\x1f\n" +
"\vroom_locked\x18\x0f \x01(\bR\n" +
"roomLocked\"\xe6\x02\n" +
"\x12RoomRocketLaunched\x12\x1b\n" +
"\trocket_id\x18\x01 \x01(\tR\brocketId\x12\x14\n" +
"\x05level\x18\x02 \x01(\x05R\x05level\x12\x1d\n" +

View File

@ -431,6 +431,8 @@ message RoomRocketIgnited {
string room_short_id = 12;
string rocket_cover_url = 13;
int64 reset_at_ms = 14;
// room_locked Room Cell
bool room_locked = 15;
}
// RoomRocketLaunched 线 Room Cell presence

View File

@ -543,8 +543,12 @@ type LuckyGiftRuleConfig struct {
V2HighWaterBoostFactorPpm int64 `protobuf:"varint,42,opt,name=v2_high_water_boost_factor_ppm,json=v2HighWaterBoostFactorPpm,proto3" json:"v2_high_water_boost_factor_ppm,omitempty"`
// 恢复水位金币;水位低于该值后退出加权。启用时必须为正且不高于阈值。
V2HighWaterBoostRecoverCoins int64 `protobuf:"varint,43,opt,name=v2_high_water_boost_recover_coins,json=v2HighWaterBoostRecoverCoins,proto3" json:"v2_high_water_boost_recover_coins,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
// app+pool 全局滚动爆奖窗口RTP 补偿和累计消费大奖共用,所有规则版本也共用实际命中事实。
JackpotRateLimitWindowMinutes int64 `protobuf:"varint,44,opt,name=jackpot_rate_limit_window_minutes,json=jackpotRateLimitWindowMinutes,proto3" json:"jackpot_rate_limit_window_minutes,omitempty"`
// 上述滚动窗口内最多允许的大奖次数;达到上限后消费 token 保留RTP 补偿资格按本次已尝试处理。
MaxJackpotHitsPerWindow int64 `protobuf:"varint,45,opt,name=max_jackpot_hits_per_window,json=maxJackpotHitsPerWindow,proto3" json:"max_jackpot_hits_per_window,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *LuckyGiftRuleConfig) Reset() {
@ -878,6 +882,20 @@ func (x *LuckyGiftRuleConfig) GetV2HighWaterBoostRecoverCoins() int64 {
return 0
}
func (x *LuckyGiftRuleConfig) GetJackpotRateLimitWindowMinutes() int64 {
if x != nil {
return x.JackpotRateLimitWindowMinutes
}
return 0
}
func (x *LuckyGiftRuleConfig) GetMaxJackpotHitsPerWindow() int64 {
if x != nil {
return x.MaxJackpotHitsPerWindow
}
return 0
}
type CheckLuckyGiftRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"`
@ -5673,7 +5691,7 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
"\x05stage\x18\x01 \x01(\tR\x05stage\x12;\n" +
"\x05tiers\x18\x02 \x03(\v2%.hyapp.luckygift.v1.LuckyGiftRuleTierR\x05tiers\x121\n" +
"\x15min_recharge_7d_coins\x18\x03 \x01(\x03R\x12minRecharge7dCoins\x123\n" +
"\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\xe3\x11\n" +
"\x16min_recharge_30d_coins\x18\x04 \x01(\x03R\x13minRecharge30dCoins\"\xeb\x12\n" +
"\x13LuckyGiftRuleConfig\x12\x19\n" +
"\bapp_code\x18\x01 \x01(\tR\aappCode\x12\x17\n" +
"\apool_id\x18\x02 \x01(\tR\x06poolId\x12!\n" +
@ -5718,7 +5736,9 @@ const file_proto_luckygift_v1_luckygift_proto_rawDesc = "" +
" user_24h_ordinary_win_factor_ppm\x18( \x01(\x03R\x1buser24hOrdinaryWinFactorPpm\x12K\n" +
"#v2_high_water_boost_threshold_coins\x18) \x01(\x03R\x1ev2HighWaterBoostThresholdCoins\x12A\n" +
"\x1ev2_high_water_boost_factor_ppm\x18* \x01(\x03R\x19v2HighWaterBoostFactorPpm\x12G\n" +
"!v2_high_water_boost_recover_coins\x18+ \x01(\x03R\x1cv2HighWaterBoostRecoverCoins\"\xcd\x01\n" +
"!v2_high_water_boost_recover_coins\x18+ \x01(\x03R\x1cv2HighWaterBoostRecoverCoins\x12H\n" +
"!jackpot_rate_limit_window_minutes\x18, \x01(\x03R\x1djackpotRateLimitWindowMinutes\x12<\n" +
"\x1bmax_jackpot_hits_per_window\x18- \x01(\x03R\x17maxJackpotHitsPerWindow\"\xcd\x01\n" +
"\x15CheckLuckyGiftRequest\x123\n" +
"\x04meta\x18\x01 \x01(\v2\x1f.hyapp.luckygift.v1.RequestMetaR\x04meta\x12\x17\n" +
"\auser_id\x18\x02 \x01(\x03R\x06userId\x12\x17\n" +

View File

@ -120,6 +120,10 @@ message LuckyGiftRuleConfig {
int64 v2_high_water_boost_factor_ppm = 42;
// 退
int64 v2_high_water_boost_recover_coins = 43;
// app+pool RTP
int64 jackpot_rate_limit_window_minutes = 44;
// token RTP
int64 max_jackpot_hits_per_window = 45;
}
message CheckLuckyGiftRequest {

File diff suppressed because it is too large Load Diff

View File

@ -819,6 +819,8 @@ message UpdateUserProfileRequest {
optional string avatar = 4;
optional string birth = 5;
optional string gender = 6;
// avatar_upload_id App URL
optional string avatar_upload_id = 7;
}
// UpdateUserProfileResponse
@ -826,6 +828,53 @@ message UpdateUserProfileResponse {
User user = 1;
}
// UserAvatarMedia gateway
// user-service
message UserAvatarMedia {
string url = 1;
string object_key = 2;
string content_type = 3;
string format = 4;
int64 size_bytes = 5;
int32 width = 6;
int32 height = 7;
bool animated = 8;
int32 frame_count = 9;
int64 duration_ms = 10;
string sha256 = 11;
string status = 12;
}
// AuthorizeUserAvatarUploadRequest COS PutObject command_id
message AuthorizeUserAvatarUploadRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string command_id = 3;
UserAvatarMedia media = 4;
}
message AuthorizeUserAvatarUploadResponse {
bool allowed = 1;
string avatar_upload_id = 2;
string object_key = 3;
int64 server_time_ms = 4;
}
// CompleteUserAvatarUploadRequest gateway COS URL
message CompleteUserAvatarUploadRequest {
RequestMeta meta = 1;
int64 user_id = 2;
string avatar_upload_id = 3;
UserAvatarMedia media = 4;
}
message CompleteUserAvatarUploadResponse {
bool active = 1;
string avatar_upload_id = 2;
UserAvatarMedia media = 3;
int64 server_time_ms = 4;
}
// UpdateUserProfileBackgroundRequest
message UpdateUserProfileBackgroundRequest {
RequestMeta meta = 1;
@ -1500,6 +1549,8 @@ service UserService {
rpc BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
rpc AuthorizeUserAvatarUpload(AuthorizeUserAvatarUploadRequest) returns (AuthorizeUserAvatarUploadResponse);
rpc CompleteUserAvatarUpload(CompleteUserAvatarUploadRequest) returns (CompleteUserAvatarUploadResponse);
rpc UpdateUserProfile(UpdateUserProfileRequest) returns (UpdateUserProfileResponse);
rpc UpdateUserProfileBackground(UpdateUserProfileBackgroundRequest) returns (UpdateUserProfileBackgroundResponse);
rpc UpdateUserContactInfo(UpdateUserContactInfoRequest) returns (UpdateUserContactInfoResponse);

View File

@ -29,6 +29,8 @@ const (
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
UserService_AuthorizeUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/AuthorizeUserAvatarUpload"
UserService_CompleteUserAvatarUpload_FullMethodName = "/hyapp.user.v1.UserService/CompleteUserAvatarUpload"
UserService_UpdateUserProfile_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfile"
UserService_UpdateUserProfileBackground_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserProfileBackground"
UserService_UpdateUserContactInfo_FullMethodName = "/hyapp.user.v1.UserService/UpdateUserContactInfo"
@ -62,6 +64,8 @@ type UserServiceClient interface {
BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error)
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
AuthorizeUserAvatarUpload(ctx context.Context, in *AuthorizeUserAvatarUploadRequest, opts ...grpc.CallOption) (*AuthorizeUserAvatarUploadResponse, error)
CompleteUserAvatarUpload(ctx context.Context, in *CompleteUserAvatarUploadRequest, opts ...grpc.CallOption) (*CompleteUserAvatarUploadResponse, error)
UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(ctx context.Context, in *UpdateUserProfileBackgroundRequest, opts ...grpc.CallOption) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(ctx context.Context, in *UpdateUserContactInfoRequest, opts ...grpc.CallOption) (*UpdateUserContactInfoResponse, error)
@ -187,6 +191,26 @@ func (c *userServiceClient) GetUserMicLifetimeStats(ctx context.Context, in *Get
return out, nil
}
func (c *userServiceClient) AuthorizeUserAvatarUpload(ctx context.Context, in *AuthorizeUserAvatarUploadRequest, opts ...grpc.CallOption) (*AuthorizeUserAvatarUploadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(AuthorizeUserAvatarUploadResponse)
err := c.cc.Invoke(ctx, UserService_AuthorizeUserAvatarUpload_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) CompleteUserAvatarUpload(ctx context.Context, in *CompleteUserAvatarUploadRequest, opts ...grpc.CallOption) (*CompleteUserAvatarUploadResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(CompleteUserAvatarUploadResponse)
err := c.cc.Invoke(ctx, UserService_CompleteUserAvatarUpload_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *userServiceClient) UpdateUserProfile(ctx context.Context, in *UpdateUserProfileRequest, opts ...grpc.CallOption) (*UpdateUserProfileResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(UpdateUserProfileResponse)
@ -353,6 +377,8 @@ type UserServiceServer interface {
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
AuthorizeUserAvatarUpload(context.Context, *AuthorizeUserAvatarUploadRequest) (*AuthorizeUserAvatarUploadResponse, error)
CompleteUserAvatarUpload(context.Context, *CompleteUserAvatarUploadRequest) (*CompleteUserAvatarUploadResponse, error)
UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error)
UpdateUserProfileBackground(context.Context, *UpdateUserProfileBackgroundRequest) (*UpdateUserProfileBackgroundResponse, error)
UpdateUserContactInfo(context.Context, *UpdateUserContactInfoRequest) (*UpdateUserContactInfoResponse, error)
@ -408,6 +434,12 @@ func (UnimplementedUserServiceServer) ListUserIDs(context.Context, *ListUserIDsR
func (UnimplementedUserServiceServer) GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method GetUserMicLifetimeStats not implemented")
}
func (UnimplementedUserServiceServer) AuthorizeUserAvatarUpload(context.Context, *AuthorizeUserAvatarUploadRequest) (*AuthorizeUserAvatarUploadResponse, error) {
return nil, status.Error(codes.Unimplemented, "method AuthorizeUserAvatarUpload not implemented")
}
func (UnimplementedUserServiceServer) CompleteUserAvatarUpload(context.Context, *CompleteUserAvatarUploadRequest) (*CompleteUserAvatarUploadResponse, error) {
return nil, status.Error(codes.Unimplemented, "method CompleteUserAvatarUpload not implemented")
}
func (UnimplementedUserServiceServer) UpdateUserProfile(context.Context, *UpdateUserProfileRequest) (*UpdateUserProfileResponse, error) {
return nil, status.Error(codes.Unimplemented, "method UpdateUserProfile not implemented")
}
@ -654,6 +686,42 @@ func _UserService_GetUserMicLifetimeStats_Handler(srv interface{}, ctx context.C
return interceptor(ctx, in, info, handler)
}
func _UserService_AuthorizeUserAvatarUpload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(AuthorizeUserAvatarUploadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).AuthorizeUserAvatarUpload(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_AuthorizeUserAvatarUpload_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).AuthorizeUserAvatarUpload(ctx, req.(*AuthorizeUserAvatarUploadRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_CompleteUserAvatarUpload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(CompleteUserAvatarUploadRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(UserServiceServer).CompleteUserAvatarUpload(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: UserService_CompleteUserAvatarUpload_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(UserServiceServer).CompleteUserAvatarUpload(ctx, req.(*CompleteUserAvatarUploadRequest))
}
return interceptor(ctx, in, info, handler)
}
func _UserService_UpdateUserProfile_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(UpdateUserProfileRequest)
if err := dec(in); err != nil {
@ -971,6 +1039,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
MethodName: "GetUserMicLifetimeStats",
Handler: _UserService_GetUserMicLifetimeStats_Handler,
},
{
MethodName: "AuthorizeUserAvatarUpload",
Handler: _UserService_AuthorizeUserAvatarUpload_Handler,
},
{
MethodName: "CompleteUserAvatarUpload",
Handler: _UserService_CompleteUserAvatarUpload_Handler,
},
{
MethodName: "UpdateUserProfile",
Handler: _UserService_UpdateUserProfile_Handler,

View File

@ -23592,16 +23592,18 @@ func (x *UpdateRedPacketConfigResponse) GetServerTimeMs() int64 {
}
type CreateRedPacketRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
CommandId string `protobuf:"bytes,3,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
SenderUserId int64 `protobuf:"varint,4,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
RoomId string `protobuf:"bytes,5,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
RegionId int64 `protobuf:"varint,6,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
PacketType string `protobuf:"bytes,7,opt,name=packet_type,json=packetType,proto3" json:"packet_type,omitempty"`
TotalAmount int64 `protobuf:"varint,8,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"`
PacketCount int32 `protobuf:"varint,9,opt,name=packet_count,json=packetCount,proto3" json:"packet_count,omitempty"`
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
AppCode string `protobuf:"bytes,2,opt,name=app_code,json=appCode,proto3" json:"app_code,omitempty"`
CommandId string `protobuf:"bytes,3,opt,name=command_id,json=commandId,proto3" json:"command_id,omitempty"`
SenderUserId int64 `protobuf:"varint,4,opt,name=sender_user_id,json=senderUserId,proto3" json:"sender_user_id,omitempty"`
RoomId string `protobuf:"bytes,5,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"`
RegionId int64 `protobuf:"varint,6,opt,name=region_id,json=regionId,proto3" json:"region_id,omitempty"`
PacketType string `protobuf:"bytes,7,opt,name=packet_type,json=packetType,proto3" json:"packet_type,omitempty"`
TotalAmount int64 `protobuf:"varint,8,opt,name=total_amount,json=totalAmount,proto3" json:"total_amount,omitempty"`
PacketCount int32 `protobuf:"varint,9,opt,name=packet_count,json=packetCount,proto3" json:"packet_count,omitempty"`
// room_locked 由 gateway 从本次发红包前读取的 room-service snapshot 固化wallet 只随资金事实透传。
RoomLocked bool `protobuf:"varint,10,opt,name=room_locked,json=roomLocked,proto3" json:"room_locked,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -23699,6 +23701,13 @@ func (x *CreateRedPacketRequest) GetPacketCount() int32 {
return 0
}
func (x *CreateRedPacketRequest) GetRoomLocked() bool {
if x != nil {
return x.RoomLocked
}
return false
}
type CreateRedPacketResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Packet *RedPacket `protobuf:"bytes,1,opt,name=packet,proto3" json:"packet,omitempty"`
@ -28940,7 +28949,7 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
" \x01(\tR\aruleUrl\"\x7f\n" +
"\x1dUpdateRedPacketConfigResponse\x128\n" +
"\x06config\x18\x01 \x01(\v2 .hyapp.wallet.v1.RedPacketConfigR\x06config\x12$\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xb4\x02\n" +
"\x0eserver_time_ms\x18\x02 \x01(\x03R\fserverTimeMs\"\xd5\x02\n" +
"\x16CreateRedPacketRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x19\n" +
@ -28953,7 +28962,10 @@ const file_proto_wallet_v1_wallet_proto_rawDesc = "" +
"\vpacket_type\x18\a \x01(\tR\n" +
"packetType\x12!\n" +
"\ftotal_amount\x18\b \x01(\x03R\vtotalAmount\x12!\n" +
"\fpacket_count\x18\t \x01(\x05R\vpacketCount\"\x98\x01\n" +
"\fpacket_count\x18\t \x01(\x05R\vpacketCount\x12\x1f\n" +
"\vroom_locked\x18\n" +
" \x01(\bR\n" +
"roomLocked\"\x98\x01\n" +
"\x17CreateRedPacketResponse\x122\n" +
"\x06packet\x18\x01 \x01(\v2\x1a.hyapp.wallet.v1.RedPacketR\x06packet\x12#\n" +
"\rbalance_after\x18\x02 \x01(\x03R\fbalanceAfter\x12$\n" +

View File

@ -2730,6 +2730,8 @@ message CreateRedPacketRequest {
string packet_type = 7;
int64 total_amount = 8;
int32 packet_count = 9;
// room_locked gateway room-service snapshot wallet
bool room_locked = 10;
}
message CreateRedPacketResponse {

View File

@ -0,0 +1,169 @@
# Fami 动态头像上传 Flutter 接口对接
## 接入结论
Fami 修改非空头像必须先调用 `POST /api/v1/users/me/avatar/upload`,再把返回的 `avatar_upload_id` 提交到 `POST /api/v1/users/me/profile/update`。上传响应里的 `url` 只用于本地预览,不能继续作为 `avatar` 提交。
服务端从原始字节识别静态或动态图片。动态 GIF/WebP 会调用 Wallet `CheckVipBenefit(animated_avatar)`Fami 当前配置为 VIP4 解锁,但 Flutter 不得写 `vipLevel >= 4` 作为最终准入。静态头像也走同一上传接口,只是不触发动态权益校验。
## 1. 上传头像
```http
POST /api/v1/users/me/avatar/upload
Authorization: Bearer {access_token}
Content-Type: multipart/form-data
command_id={本次选择文件的上传幂等键}
file={原始文件字节}
```
| 项目 | 规则 |
| --- | --- |
| 格式 | JPEG、PNG、GIF、WebP不支持 APNG |
| 一致性 | 魔数、文件扩展名、multipart Content-Type 必须一致 |
| 大小 | 最大 5 MiB读取字节数必须与 multipart 文件大小一致 |
| 尺寸 | 宽、高均不超过 4096总像素不超过 16,777,216 |
| 动画 | 仅多帧 GIF/WebP最多 120 帧,总时长 `(0, 15000]ms` |
| 静态 | `frame_count=1``animated=false``duration_ms=0` |
同一文件因超时重试时复用原 `command_id`;重新选择文件时生成新值。不要发送 `application/octet-stream`
成功响应:
```json
{
"code": "OK",
"message": "ok",
"request_id": "req_xxx",
"data": {
"avatar_upload_id": "avatar_12ab34cd56ef789012ab34cd56ef7890",
"url": "https://cdn.example.com/user-media/fami/avatars/10001/x.gif",
"sha256": "64位小写十六进制",
"frame_count": 18,
"animated": true,
"content_type": "image/gif",
"format": "gif",
"size_bytes": 1256789,
"width": 640,
"height": 640,
"duration_ms": 3600,
"server_time_ms": 1784592000000
}
}
```
## 2. 保存头像
```http
POST /api/v1/users/me/profile/update
Content-Type: application/json
{
"avatar_upload_id": "avatar_12ab34cd56ef789012ab34cd56ef7890"
}
```
服务端在同一数据库事务内校验凭证属于当前 `app_code + user_id`、状态为 `active`,然后更新 `users.avatar` 并把凭证推进为 `consumed``avatar_upload_id``avatar` 互斥。清空头像仍提交:
```json
{"avatar": ""}
```
## 3. 当前 Flutter 代码需要调整
当前 `AppUserService._avatarUploadPath` 仍指向 `/api/v1/files/avatar/upload``uploadProfileAvatar` 只返回 URL`updateMyProfile` 也只接受 `avatar`。Fami 接入时应改为:
1. 新增 `/api/v1/users/me/avatar/upload` 路径和 `AppAvatarUploadResult`,至少保存 `avatarUploadId``url``animated``frameCount``sha256`
2. multipart 增加 `command_id`;同一次网络重试复用,用户重新选图后重新生成。
3. `updateMyProfile` 增加 `avatarUploadId`,保存非空头像时只发送 `avatar_upload_id`
4. `pickVipGatedProfileImage` 改用 `preserveGifOrWebp: true`,否则动态 WebP 会进入裁剪并被压成 JPEG。
5. 客户端大小上限同步为 5 MiB现有 `maxAvatarGifBytes`/10 MiB 提示不能继续用于本接口。
静态图片可以继续裁剪为 JPEG但裁剪后必须使用 `.jpg/.jpeg` 文件名和 `image/jpeg`。GIF/WebP 动画必须保留选择器返回的原始字节,不裁剪、不压缩、不改扩展名。
## 4. GetConnect 风格示例
```dart
class AppAvatarUploadResult {
const AppAvatarUploadResult({
required this.avatarUploadId,
required this.url,
required this.animated,
required this.frameCount,
required this.sha256,
});
final String avatarUploadId;
final String url;
final bool animated;
final int frameCount;
final String sha256;
factory AppAvatarUploadResult.fromJson(Map<String, dynamic> json) {
return AppAvatarUploadResult(
avatarUploadId: json['avatar_upload_id'] as String,
url: json['url'] as String,
animated: json['animated'] as bool,
frameCount: json['frame_count'] as int,
sha256: json['sha256'] as String,
);
}
}
Future<AppAvatarUploadResult> uploadProfileAvatar({
required AppPickedImageFile image,
required String commandId,
}) async {
final String fileName = _safeFileName(image.fileName);
final String contentType = _contentTypeForFileName(fileName);
final FormData formData = FormData(<String, dynamic>{
'command_id': commandId,
'file': MultipartFile(
image.bytes,
filename: fileName,
contentType: contentType,
),
});
final Map<String, dynamic> data = await _networkService
.postEnvelope<Map<String, dynamic>>(
'/api/v1/users/me/avatar/upload',
body: formData,
decoder: _asMap,
);
return AppAvatarUploadResult.fromJson(data);
}
Future<AppUserProfile> saveProfileAvatar(
AppAvatarUploadResult uploaded,
) async {
final Map<String, dynamic> data = await _networkService
.postEnvelope<Map<String, dynamic>>(
'/api/v1/users/me/profile/update',
body: <String, String>{
'avatar_upload_id': uploaded.avatarUploadId,
},
decoder: _asMap,
);
return AppUserProfile.fromJson(data);
}
```
## 5. 错误处理
| HTTP / code | 含义 | Flutter 动作 |
| --- | --- | --- |
| `403 / VIP_BENEFIT_REQUIRED` | 动态头像缺少 `animated_avatar` | 使用错误 metadata 的 `required_level` 展示升级入口;不要写死 VIP4 |
| `413 / FILE_TOO_LARGE` | 超过 5 MiB | 保留选择页并提示重新选择 |
| `415 / UNSUPPORTED_MEDIA_TYPE` | 魔数、扩展名、MIME 或文件结构不一致 | 不重试;修正 MIME 或重新选择 |
| `409 / IDEMPOTENCY_CONFLICT` | 同一 `command_id` 换了文件 | 为新文件生成新 `command_id` |
| `409 / CONFLICT` | 凭证未完成、已被其他资料更新消费 | 丢弃凭证并重新上传 |
| `400 / INVALID_ARGUMENT` | 保存时缺凭证,或同时提交 `avatar``avatar_upload_id` | 修正请求体 |
| `503 / UPSTREAM_ERROR` | Wallet/COS/user-service 暂不可用 | 保留同一文件和 `command_id` 后重试 |
服务端错误是最终权限事实。Flutter 本地 VIP 数据只用于提前展示锁态,不能跳过上传请求,也不能把动态图片 URL 直接写入用户资料。
## 6. 兼容边界
公开注册头像入口继续只接受静态 JPEG/PNG/WebP因为注册阶段还没有可校验的用户 VIP。注册完成后用户要设置动态头像必须重新走新接口。
历史上已经被转成单帧 PNG 的伪 GIF 不含动画帧,无法恢复;修复上线后需要用户重新上传原始 GIF/WebP并按现有 CDN 流程刷新旧 URL 缓存。

View File

@ -586,7 +586,7 @@ paths:
- files
summary: 上传用户头像
operationId: uploadAvatar
description: 上传头像到腾讯云 COS服务端按文件头嗅探,只接受 JPEG、PNG、WebP单文件最大 5 MiB。
description: 旧版静态头像上传入口,只接受 JPEG、PNG、WebP单文件最大 5 MiB;权益制 App 修改头像必须改用 `/api/v1/users/me/avatar/upload` 并消费凭证
consumes:
- multipart/form-data
security:
@ -868,7 +868,7 @@ paths:
- users
summary: 修改当前用户基础资料
operationId: updateMyProfile
description: 只修改 `username`、`avatar`、`birth`;主页背景图和国家修改都走独立接口
description: 修改 `username`、头像、`birth`。权益制 App 设置非空头像必须提交 `avatar_upload_id`,不能提交任意 `avatar` URL清空头像仍可传 `avatar=""`
security:
- BearerAuth: []
parameters:
@ -892,6 +892,63 @@ paths:
$ref: "#/responses/Internal"
"502":
$ref: "#/responses/UpstreamError"
/api/v1/users/me/avatar/upload:
post:
tags:
- users
summary: 上传用户头像并生成一次性资料绑定凭证
operationId: uploadUserAvatar
description: |
服务端按完整文件字节识别 JPEG/PNG/GIF/WebP并要求魔数、扩展名和 multipart Content-Type 一致。
原始字节不裁剪、不转码,实际格式决定 COS 后缀和 Content-Type。只有多帧 GIF/WebP 会调用 Wallet
`CheckVipBenefit(animated_avatar)`Fami 当前配置为 VIP4 解锁,但 gateway/user-service 不判断等级数字。
consumes:
- multipart/form-data
security:
- BearerAuth: []
parameters:
- name: command_id
in: formData
required: true
type: string
maxLength: 128
description: 上传幂等键;同一文件重试必须复用,更换文件必须生成新值。
- name: file
in: formData
required: true
type: file
description: 最大 5MiB宽高不超过 4096总像素不超过 16777216动画最多 120 帧且最长 15 秒。
responses:
"200":
description: 上传完成url 只用于预览,资料更新必须提交 avatar_upload_id。
schema:
$ref: "#/definitions/UserAvatarUploadEnvelope"
"400":
$ref: "#/responses/BadRequest"
"401":
$ref: "#/responses/Unauthorized"
"403":
description: 动态头像缺少 animated_avatarcode=VIP_BENEFIT_REQUIRED。
schema:
$ref: "#/definitions/ErrorEnvelope"
"409":
description: 同一 command_id 更换文件code=IDEMPOTENCY_CONFLICT。
schema:
$ref: "#/definitions/ErrorEnvelope"
"413":
description: 文件超过 5MiBcode=FILE_TOO_LARGE。
schema:
$ref: "#/definitions/ErrorEnvelope"
"415":
description: 魔数、Content-Type、扩展名或媒体结构不一致code=UNSUPPORTED_MEDIA_TYPE。
schema:
$ref: "#/definitions/ErrorEnvelope"
"502":
$ref: "#/responses/UpstreamError"
"503":
description: COS、Wallet 或上传授权依赖不可用。
schema:
$ref: "#/definitions/ErrorEnvelope"
/api/v1/users/me/profile-bg-img:
post:
tags:
@ -4473,6 +4530,11 @@ definitions:
type: string
avatar:
type: string
description: legacy_timed App 的历史 URL 字段;权益制 App 仅允许传空字符串清空,不能与 avatar_upload_id 同时提交。
avatar_upload_id:
type: string
maxLength: 64
description: 来自 `/api/v1/users/me/avatar/upload` 的一次性凭证,必须属于当前 app_code 和登录用户。
birth:
type: string
description: yyyy-mm-dd传空字符串表示清空生日。
@ -7736,6 +7798,65 @@ definitions:
properties:
data:
$ref: "#/definitions/RoomMediaUploadData"
UserAvatarUploadData:
type: object
required:
- avatar_upload_id
- url
- sha256
- frame_count
- animated
- content_type
- format
- size_bytes
- width
- height
- duration_ms
- server_time_ms
properties:
avatar_upload_id:
type: string
description: 一次性头像绑定凭证;只能由同 app_code、同用户的资料更新事务消费。
url:
type: string
description: COS 预览地址;权益制 App 禁止把该 URL 作为 profile/update 的 avatar 输入。
sha256:
type: string
pattern: "^[0-9a-f]{64}$"
frame_count:
type: integer
format: int32
animated:
type: boolean
content_type:
type: string
enum: [image/jpeg, image/png, image/gif, image/webp]
format:
type: string
enum: [jpeg, png, gif, webp]
size_bytes:
type: integer
format: int64
width:
type: integer
format: int32
height:
type: integer
format: int32
duration_ms:
type: integer
format: int64
description: 静态图固定为 0。
server_time_ms:
type: integer
format: int64
UserAvatarUploadEnvelope:
allOf:
- $ref: "#/definitions/GatewayOKEnvelopeBase"
- type: object
properties:
data:
$ref: "#/definitions/UserAvatarUploadData"
RoomCoverUploadData:
type: object
required:

View File

@ -196,6 +196,46 @@ paths:
$ref: "#/definitions/ListUserIDsResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.UserService/AuthorizeUserAvatarUpload:
post:
tags:
- users
summary: 授权用户头像上传
operationId: userAuthorizeUserAvatarUpload
x-grpc-full-method: /hyapp.user.v1.UserService/AuthorizeUserAvatarUpload
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/AuthorizeUserAvatarUploadRequest"
responses:
"200":
description: 权益和媒体事实校验成功,返回确定性对象键与消费凭证。
schema:
$ref: "#/definitions/AuthorizeUserAvatarUploadResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.UserService/CompleteUserAvatarUpload:
post:
tags:
- users
summary: 完成用户头像上传
operationId: userCompleteUserAvatarUpload
x-grpc-full-method: /hyapp.user.v1.UserService/CompleteUserAvatarUpload
parameters:
- name: body
in: body
required: true
schema:
$ref: "#/definitions/CompleteUserAvatarUploadRequest"
responses:
"200":
description: COS URL 与首次授权事实一致,上传记录进入 active。
schema:
$ref: "#/definitions/CompleteUserAvatarUploadResponse"
default:
$ref: "#/responses/GRPCError"
/hyapp.user.v1.UserService/UpdateUserProfile:
post:
tags:
@ -1080,15 +1120,69 @@ definitions:
description: optional未传表示不修改。
avatar:
type: string
description: optional未传表示不修改
description: optionallegacy_timed App 的历史 URL 字段,权益制 App 非空头像必须改传 avatar_upload_id
birth:
type: string
description: optional yyyy-mm-dd空字符串表示清空生日。
gender:
type: string
description: optional未传表示不修改。
avatar_upload_id:
type: string
description: optional专用上传生成的一次性凭证不能与 avatar 同时传。
UpdateUserProfileResponse:
type: object
properties:
user:
$ref: "#/definitions/User"
UserAvatarMedia:
type: object
properties:
url: {type: string}
object_key: {type: string}
content_type: {type: string}
format: {type: string}
size_bytes: {type: integer, format: int64}
width: {type: integer, format: int32}
height: {type: integer, format: int32}
animated: {type: boolean}
frame_count: {type: integer, format: int32}
duration_ms: {type: integer, format: int64}
sha256: {type: string}
status: {type: string}
AuthorizeUserAvatarUploadRequest:
type: object
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id: {type: integer, format: int64}
command_id: {type: string}
media:
$ref: "#/definitions/UserAvatarMedia"
AuthorizeUserAvatarUploadResponse:
type: object
properties:
allowed: {type: boolean}
avatar_upload_id: {type: string}
object_key: {type: string}
server_time_ms: {type: integer, format: int64}
CompleteUserAvatarUploadRequest:
type: object
properties:
meta:
$ref: "#/definitions/RequestMeta"
user_id: {type: integer, format: int64}
avatar_upload_id: {type: string}
media:
$ref: "#/definitions/UserAvatarMedia"
CompleteUserAvatarUploadResponse:
type: object
properties:
active: {type: boolean}
avatar_upload_id: {type: string}
media:
$ref: "#/definitions/UserAvatarMedia"
server_time_ms: {type: integer, format: int64}
UpdateUserProfileBackgroundRequest:
type: object
properties:

View File

@ -6,7 +6,7 @@
- `strategy_version=fixed_v2`:继续按历史不可变规则执行;旧请求或旧记录缺少 `strategy_version` 时必须归一为 `fixed_v2`
- `strategy_version=dynamic_v3`:只有显式发布并启用的规则才进入动态水位、充值加权、保底、大奖和六维风控。不能因为服务升级而把存量奖池隐式切换到 V3。
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐普通/高阶充值门槛及六项金额上限,才允许启用;独立的“累计消费达到门槛就获得大奖”机制已经停用,不再是发布必填项,也不能绕过 RTP 资格
- 新奖池未配置时返回 `dynamic_v3``disabled` 草稿。各 App 必须先补齐普通/高阶充值门槛、累计消费大奖门槛、大奖滚动窗口及六项金额上限,才允许启用;累计消费 token 仍必须通过已结算全局 RTP、日次数、滚动窗口、奖池和六维风控不能绕过安全门
- 启用前还必须确认 user/gateway/room 已升级到由 `auth_sessions.device_id` 签入 access JWT再通过 `room.RequestMeta` 和可恢复 command 原样传递设备作用域。旧 JWT 缺 `device_id``fixed_v2` 仍兼容,`dynamic_v3` 必须 fail-close不得用 `session_id`/`command_id` 伪造设备。
- 启用前还必须确认 user/gateway/room/wallet 已按顺序升级access JWT 和 room meta 携带 auth session 绑定的可信 `device_id`wallet 回执携带交易 `paid_at_ms`、充值快照和 `gift_income_coins`。可信 `device_id`、owner `paid_at_ms` 缺失,或真实收礼返币比例与规则 `anchor_rate_ppm` 不一致时,整次动态开奖 fail-close`user_registered_at_ms` 缺失或晚于扣费时间时普通开奖继续,但本轮补偿大奖资格 fail-close。
- 比例、概率和倍率统一使用 ppm`1_000_000=100%=1x`;钱只使用整数金币;业务时间只使用 UTC epoch milliseconds。
@ -69,7 +69,7 @@ public_pool_coins + profit_coins + anchor_return_coins = coin_spent
5. 按 `app_code + pool_id + rule_version` 读取不可变规则锁定公共池、已结算大盘轮次、用户同轮次状态、48 小时桶/边界事件、连 0 状态和六维风控计数。
6. 用钱包快照选择充值层级,再对所有非 0 基础档应用水位和最近充值因子0 档接收剩余概率。
7. 若用户有一个尚未尝试的已结算轮次,先按“全局 `<=98%`、用户同轮次 `<96%`、注册满 48 小时、用户滚动 48 小时总 RTP `<96%`”完整判断资格;否则直接进入普通概率抽奖。
8. 有资格时只在用户下一次子抽尝试一次大奖,并对选中的 `W` 执行奖池、单用户日大奖次数和六维风控检查;成功时本抽直接结束,奖池不足或风控阻断时消费该轮资格并回落普通规则。普通路径即使命中同倍率也不是大奖,不增加或受制于日大奖次数。
8. 有资格时只在用户下一次子抽尝试一次 RTP 补偿大奖;随后再尝试当前规则版本的累计消费 token。两种大奖都受单用户日次数、`app_code+pool_id` 滚动爆奖次数、奖池和六维风控约束。补偿资格只尝试一次;消费 token 只有实际支付大奖才消耗,阻断后保留到后续抽奖。普通路径即使命中同倍率也不是大奖,不计入这些大奖次数。
9. 写入抽奖事实、决策快照、奖池/窗口/风控/用户状态及 outbox。钱包返奖状态独立收敛为 `pending/granted/failed`
这套顺序的核心约束是:先有已落账的扣费事实,再入池,再开奖;任何体验规则都不能制造不存在的资金。
@ -142,6 +142,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
- 该用户在轮次关闭时已经注册满 48 小时;判断的是账号注册年龄,不是累计活跃、累计游戏或累计有流水的时长;
- `closed_at_ms` 保存触发结算的末笔支付毫秒;汇总使用 `[closed_at_ms+1ms-48h, closed_at_ms+1ms)`,因此会纳入这笔结算事实。全部真实消费与全部真实返奖相除后,用户滚动 48 小时总 RTP **严格 `< jackpot_user_48h_rtp_max_ppm`**,默认严格小于 96%
- 用户本 UTC 日通过补偿路径命中的大奖总次数 `< max_jackpot_hits_per_user_day`,默认 5所有 `jackpot_*` 倍率合计计数,`multiplier_*` 普通命中不计数;
- 当前 `app_code + pool_id``[paid_at-window, paid_at+1ms)` 内两种大奖的合计次数 `< max_jackpot_hits_per_window`;新草稿默认近 10 分钟最多 3 次,具体值必须按 App 显式发布;
- 候选 `W<=P` 且不超过六维风控的最小剩余额度。
用户两条 RTP 门是逻辑 **AND**:同轮次 95% 但滚动 48 小时 97% 不通过;同轮次 97% 但滚动 48 小时 95% 也不通过。两个 96% 边界都使用精确分子/分母交叉相乘判断,不能先向下取整成 ppm 后把实际略高于或等于 96% 误判为通过。
@ -164,7 +165,16 @@ adjusted_weight = base_weight × water_factor × recharge_factor
- 用户迟迟不送礼:该轮资格可以等到下一次个人送礼再尝试,但不会因后续关闭更多轮次而叠加多个机会;只保留当前规则版本下最新待尝试轮次,旧轮次失效;
- 规则版本改变:旧版本资格不跨版本继承。
独立的累计消费门槛/里程碑 token 机制在新版规则中停用。历史字段只用于旧数据兼容读取,生产 `dynamic_v3` 不产生、不优先、不保留这类 token也不能把消费金额作为第三条获奖通道。
### 6.4 累计消费 token 的版本和过期边界
- `spend_coins` 按用户 UTC 日累计,每跨过一个 `jackpot_spend_threshold_coins` 产生一个 token新 token 从下一子抽开始可用,不回头改变产生它的本抽结果。
- token 绑定产生它的不可变 `rule_version`。规则版本切换时,历史 token 和尚未跨完门槛的旧版当日消费进度都在该用户下一次抽奖事务内懒失效;不执行全表更新,也不按新门槛重新定价旧资格。
- 版本切换只重置 token/消费进度,不重置用户当日 wager、payout 和大奖次数,避免通过发版绕过 RTP 或日次数限制。
- 当前版本 token 依次检查最近已关闭大盘 RTP、用户日次数、全局滚动爆奖次数、奖池和六维风控任何条件阻断时 token 保留,只有大奖实际支付后才减一。
### 6.5 全局滚动爆奖次数
`jackpot_rate_limit_window_minutes``max_jackpot_hits_per_window` 共同定义 `app_code + pool_id` 全局窗口。统计源是与开奖同事务写入的 `lucky_gift_user_jackpot_hits`,同时包含内部/外部入口、RTP 补偿和累计消费大奖,且跨规则版本、实验组和用户合计。开奖事务先锁共享奖池行,再按 `(app_code,pool_id,occurred_at_ms)` 索引读取短窗口;同一批后续子抽在内存增加计数,因此并发和 `gift_count=N` 都不能超发。
## 7. 六维风控与时间口径
@ -231,7 +241,7 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| 资金拆分 | `pool_rate_ppm`, `profit_rate_ppm`, `anchor_rate_ppm`, `initial_pool_coins` |
| 保底和水位 | `loss_streak_guarantee`, `low_watermark_coins`, `low_water_nonzero_factor_ppm`, `high_watermark_coins`, `high_water_nonzero_factor_ppm` |
| 最近充值 | `recharge_boost_window_ms`, `recharge_boost_factor_ppm` |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_round_rtp_max_ppm`, `jackpot_user_48h_rtp_max_ppm`, `max_jackpot_hits_per_user_day`;历史 `jackpot_spend_threshold_coins` 在新版规则中停用 |
| 大奖 | `jackpot_multiplier_ppms`, `jackpot_global_rtp_max_ppm`, `jackpot_user_round_rtp_max_ppm`, `jackpot_user_48h_rtp_max_ppm`, `jackpot_spend_threshold_coins`, `max_jackpot_hits_per_user_day`, `jackpot_rate_limit_window_minutes`, `max_jackpot_hits_per_window` |
| 六维风控 | `max_single_payout`, `user_hourly_payout_cap`, `user_daily_payout_cap`, `device_daily_payout_cap`, `room_hourly_payout_cap`, `anchor_daily_payout_cap` |
| 充值层级 | `stages[].min_recharge_7d_coins`, `stages[].min_recharge_30d_coins`, `stages[].tiers[]` |
@ -248,8 +258,9 @@ adjusted_weight = base_weight × water_factor × recharge_factor
| `lucky_user_rtp_windows` | 每个用户在同一大盘 `rule_version + window_index` 内的总消费/总返奖,以及 `pending/attempted/ineligible/expired` 资格状态;一轮只尝试一次 |
| `lucky_user_rtp_hour_buckets` | 用户 UTC 小时流水/返奖桶,用于汇总严格滚动 48 小时 |
| `lucky_user_rtp_boundary_events` | 只补齐 `[closed_at+1ms-48h,closed_at+1ms)` 首尾两个不足一小时的片段;与完整小时桶组合后不近似、不回扫全量 draw |
| `lucky_user_strategy_days` | 用户 UTC 日消费和已命中大奖次数;不再作为用户 RTP 资格来源,也不再生成消费里程碑资格 |
| `lucky_user_states` | 累计抽数、等价流水和 `loss_streak`;历史 token 字段仅兼容旧记录,新版规则不产生或消费 |
| `lucky_user_strategy_days` | 用户 UTC 日 wager/payout、消费进度、消费进度所属规则版本和已命中大奖次数版本切换只清消费进度 |
| `lucky_user_states` | 累计抽数、等价流水、`loss_streak`、待消费大奖 token 及其规则版本;版本不匹配时下一抽懒失效 |
| `lucky_gift_user_jackpot_hits` | 两种大奖的内部/外部命中事实,也是 `app_code+pool_id+occurred_at_ms` 滚动次数限制的索引来源 |
| `lucky_risk_counters` | 用户/设备/房间/主播的 UTC 小时或日累计返奖 |
| `lucky_draw_records` | 单抽最终奖档、金额、规则版本、候选/奖池/RTP 决策快照和钱包状态 |
| `external_lucky_gift_draw_items` | 外部 App `gift_count=N` 的 N 条顺序子抽事实;外部聚合行只负责幂等响应 |
@ -318,6 +329,6 @@ go run ./services/lucky-gift-service/cmd/strategy-sim \
1. 存量规则空 `strategy_version` 读取和重放仍为 `fixed_v2`,且无需补 V3 金额字段。
2. 新 `dynamic_v3` 启用规则的三项拆分必须合计 100%,每层基础概率必须合计 100%,静态 EV 必须落在目标 RTP±控制带内。
3. 低/高水位、普通/高阶充值门槛、大奖集合、日次数和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。累计消费大奖门槛保持停用,不能成为独立大奖通道。
3. 低/高水位、普通/高阶充值门槛、累计消费门槛、大奖集合、日次数、滚动爆奖窗口和六项风控都必须按 App 明确配置;不能依赖代码默认值直接上线。
4. user JWT、gateway/room meta、wallet 回执、lucky-gift 请求、protobuf 生成代码、配置读写和 MySQL schema 必须端到端包含新增字段;动态入口缺可信 `device_id` 或 owner `paid_at_ms` 时整次开奖 fail-close缺失/无效 `user_registered_at_ms` 时只关闭补偿大奖资格,普通开奖继续。
5. 本地单测、完整模拟、`make proto``make test` 通过后,仍需用隔离奖池灰度;灰度验收重点是奖池不为负、幂等不重复发奖、审计可回放,而不是短窗口 RTP 恰好等于 98%。

View File

@ -29,6 +29,7 @@
| POST | `/api/v1/game-callbacks/{platform_code}/{operation}` | games | `handleGameCallback` | 第三方游戏平台公网回调 |
| POST | `/api/v1/files/upload` | files | `uploadFile` | 上传 App 通用文件 |
| POST | `/api/v1/files/avatar/upload` | files | `uploadAvatar` | 上传用户头像 |
| POST | `/api/v1/users/me/avatar/upload` | users | `uploadUserAvatar` | 校验实际格式和动态头像 VIP 权益,原字节上传并返回一次性 `avatar_upload_id` |
| POST | `/api/v1/devices/push-token` | devices | `bindPushToken` | 绑定设备推送 token |
| DELETE | `/api/v1/devices/push-token` | devices | `deletePushToken` | 删除设备推送 token |
| POST | `/api/v1/tencent-im/callback` | tencent-im | `handleTencentIMCallback` | 腾讯云 IM 服务端回调 |

View File

@ -102,12 +102,14 @@ type AccountProfile struct {
// RoomEvent 是 room-service 发给腾讯 IM 的房间系统消息负载。
type RoomEvent struct {
GroupID string `json:"-"`
EventID string `json:"event_id"`
RoomID string `json:"room_id"`
EventType string `json:"event_type"`
ActorUserID int64 `json:"actor_user_id,omitempty"`
TargetUserID int64 `json:"target_user_id,omitempty"`
GroupID string `json:"-"`
EventID string `json:"event_id"`
RoomID string `json:"room_id"`
EventType string `json:"event_type"`
// ActorUserID 和 TargetUserID 是客户端协议字段,必须保持十进制字符串;
// JavaScript Number 无法安全承载 Snowflake-like int64不能在 IM JSON 边界输出数字。
ActorUserID string `json:"actor_user_id,omitempty"`
TargetUserID string `json:"target_user_id,omitempty"`
SeatNo int32 `json:"seat_no,omitempty"`
GiftValue int64 `json:"gift_value,omitempty"`
RoomHeat int64 `json:"room_heat,omitempty"`
@ -119,14 +121,39 @@ type RoomEvent struct {
}
func (event RoomEvent) MarshalJSON() ([]byte, error) {
type roomEventAlias RoomEvent
payload, err := json.Marshal(roomEventAlias(event))
if err != nil {
return nil, err
// 直接构造最终负载,不能先反序列化到 map[string]anyencoding/json 会把其中
// 的整数转成 float64使任何超过 2^53-1 的长整数字段在后端二次序列化时失真。
merged := map[string]any{
"event_id": event.EventID,
"room_id": event.RoomID,
"event_type": event.EventType,
}
var merged map[string]any
if err := json.Unmarshal(payload, &merged); err != nil {
return nil, err
if actorUserID := strings.TrimSpace(event.ActorUserID); actorUserID != "" {
merged["actor_user_id"] = actorUserID
}
if targetUserID := strings.TrimSpace(event.TargetUserID); targetUserID != "" {
merged["target_user_id"] = targetUserID
}
if event.SeatNo != 0 {
merged["seat_no"] = event.SeatNo
}
if event.GiftValue != 0 {
merged["gift_value"] = event.GiftValue
}
if event.RoomHeat != 0 {
merged["room_heat"] = event.RoomHeat
}
if event.RoomVersion != 0 {
merged["room_version"] = event.RoomVersion
}
if event.Text != "" {
merged["text"] = event.Text
}
if event.EntryVehicle != nil {
merged["entry_vehicle"] = event.EntryVehicle
}
if len(event.Attributes) > 0 {
merged["attributes"] = event.Attributes
}
for key, value := range event.Attributes {
if strings.TrimSpace(key) == "" {
@ -459,7 +486,26 @@ func FormatUserID(userID int64) string {
return strconv.FormatInt(userID, 10)
}
func (c *RESTClient) post(ctx context.Context, command string, payload any, out *restResponse) error {
// FormatOptionalUserID 把可选内部 ID 转成客户端协议字符串;零值保持为空,延续 JSON omitempty 语义。
func FormatOptionalUserID(userID int64) string {
if userID <= 0 {
return ""
}
return FormatUserID(userID)
}
// FormatUserIDsJSON 把用户 ID 集合编码成 JSON 字符串数组,避免客户端二次解析数组时重新落入 Number 精度边界。
func FormatUserIDsJSON(userIDs []int64) string {
formatted := make([]string, 0, len(userIDs))
for _, userID := range userIDs {
if value := FormatOptionalUserID(userID); value != "" {
formatted = append(formatted, strconv.Quote(value))
}
}
return "[" + strings.Join(formatted, ",") + "]"
}
func (c *RESTClient) post(ctx context.Context, command string, payload any, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return err

View File

@ -22,10 +22,11 @@ func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
t.Fatalf("NewRESTClient failed: %v", err)
}
nowMS := time.Now().UTC().UnixMilli()
now := time.Now().UTC()
nowMS := now.UnixMilli()
groupID := fmt.Sprintf("hy_codex_bc_r_%d", nowMS)
eventID := fmt.Sprintf("codex_real_im_%d", nowMS)
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
defer cancel()
// 真实 IM 测试必须使用临时 GroupID避免污染业务全局/区域播报群。
@ -48,11 +49,21 @@ func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
if err := realImportAccount(ctx, client, memberUserID); err != nil {
t.Fatalf("real Tencent IM account import failed: %v", err)
}
if err := realAddGroupMember(ctx, client, groupID, memberUserID); err != nil {
t.Fatalf("real Tencent IM add group member failed: %v", err)
}
const actorUserID = "323411852102995968"
const targetUserID = "323411852102995969"
c2cEventID := fmt.Sprintf("codex_real_im_c2c_%d", nowMS)
c2cPayload, err := json.Marshal(map[string]any{
"event_id": c2cEventID,
"event_type": "codex_real_im_c2c_probe",
"sent_at_ms": nowMS,
"event_id": c2cEventID,
"event_type": "room_user_kicked",
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
"sender_user_id": actorUserID,
"receiver_user_id": targetUserID,
"sent_at_ms": nowMS,
})
if err != nil {
t.Fatalf("marshal c2c smoke payload failed: %v", err)
@ -66,41 +77,169 @@ func TestRealTencentIMCreateSendAndDestroyGroup(t *testing.T) {
}); err != nil {
t.Fatalf("real Tencent IM send C2C custom message failed: %v", err)
}
if err := realAddGroupMember(ctx, client, groupID, memberUserID); err != nil {
t.Fatalf("real Tencent IM add group member failed: %v", err)
}
payload, err := json.Marshal(map[string]any{
"event_id": eventID,
"broadcast_type": "codex_real_im_smoke",
"scope": "region",
"app_code": "codex",
"region_id": 210,
"sent_at_ms": nowMS,
"action": map[string]any{
"type": "enter_room",
"room_id": "codex-room-smoke",
},
// SyncOtherMachine=2 不同步管理员发送端,因此漫游消息必须从实际接收用户视角回读。
c2cData, c2cCloudData := realWaitForC2CPayload(t, ctx, client, strconv.FormatInt(memberUserID, 10), cfg.AdminIdentifier, c2cEventID, now.Unix())
realAssertStringUserIDs(t, c2cData, map[string]string{
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
"sender_user_id": actorUserID,
"receiver_user_id": targetUserID,
})
if err != nil {
t.Fatalf("marshal smoke payload failed: %v", err)
realAssertStringUserIDs(t, c2cCloudData, map[string]string{
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
"sender_user_id": actorUserID,
"receiver_user_id": targetUserID,
})
roomEvent := RoomEvent{
GroupID: groupID,
EventID: eventID,
RoomID: "codex-room-smoke",
EventType: "room_mic_change",
ActorUserID: actorUserID,
TargetUserID: targetUserID,
SeatNo: 3,
RoomVersion: 22,
Attributes: map[string]string{
"action": "change",
"from_seat": "1",
"to_seat": "3",
"mic_session_id": "codex-real-im-mic-session",
"target_user_ids": `["323411852102995968","323411852102995969"]`,
},
}
// 真正向腾讯云 IM REST 发送 TIMCustomElem成功只表示腾讯服务端接受消息不代表有客户端在线展示。
if err := client.PublishGroupCustomMessage(ctx, CustomGroupMessage{
GroupID: groupID,
EventID: eventID,
Desc: "codex_real_im_smoke",
Ext: "im_broadcast",
PayloadJSON: payload,
}); err != nil {
// 使用生产 RoomEvent 序列化和发送入口,避免真实测试绕开本次修复的代码路径。
if err := client.PublishRoomEvent(ctx, roomEvent); err != nil {
t.Fatalf("real Tencent IM send group custom message failed: %v", err)
}
groupData, groupCloudData := realWaitForGroupPayload(t, ctx, client, groupID, eventID)
for source, payload := range map[string]string{"Data": groupData, "CloudCustomData": groupCloudData} {
realAssertStringUserIDs(t, payload, map[string]string{
"actor_user_id": actorUserID,
"target_user_id": targetUserID,
})
var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
t.Fatalf("decode group %s payload failed: %v", source, err)
}
attributes, ok := decoded["attributes"].(map[string]any)
if !ok {
t.Fatalf("group %s payload attributes type = %T, want object", source, decoded["attributes"])
}
var targetUserIDs []any
if err := json.Unmarshal([]byte(fmt.Sprint(attributes["target_user_ids"])), &targetUserIDs); err != nil {
t.Fatalf("decode group %s target_user_ids failed: %v", source, err)
}
if len(targetUserIDs) != 2 || targetUserIDs[0] != actorUserID || targetUserIDs[1] != targetUserID {
t.Fatalf("group %s target_user_ids = %#v, want exact string IDs", source, targetUserIDs)
}
}
if err := client.DeleteGroupMember(ctx, groupID, memberUserID); err != nil {
t.Fatalf("real Tencent IM delete group member failed: %v", err)
}
t.Logf("real Tencent IM smoke passed: sdk_app_id=%d group_id=%s group_event_id=%s c2c_event_id=%s removed_user_id=%d", cfg.SDKAppID, groupID, eventID, c2cEventID, memberUserID)
t.Logf("real Tencent IM transport and history readback passed: sdk_app_id=%d group_id=%s group_event_id=%s c2c_event_id=%s exact_actor_user_id=%s removed_user_id=%d", cfg.SDKAppID, groupID, eventID, c2cEventID, actorUserID, memberUserID)
}
type realHistoryMessage struct {
MsgBody []messageElement `json:"MsgBody"`
CloudCustomData string `json:"CloudCustomData"`
}
type realGroupHistoryResponse struct {
restResponse
RspMsgList []realHistoryMessage `json:"RspMsgList"`
}
type realC2CHistoryResponse struct {
restResponse
MsgList []realHistoryMessage `json:"MsgList"`
}
func realWaitForGroupPayload(t *testing.T, ctx context.Context, client *RESTClient, groupID, eventID string) (string, string) {
t.Helper()
for attempt := 0; attempt < 10; attempt++ {
var response realGroupHistoryResponse
if err := client.post(ctx, "v4/group_open_http_svc/group_msg_get_simple", map[string]any{
"GroupId": groupID,
"ReqMsgNumber": 20,
}, &response); err != nil {
t.Fatalf("real Tencent IM group history request failed: %v", err)
}
if err := response.err(); err != nil {
t.Fatalf("real Tencent IM group history failed: %v", err)
}
if data, cloudData, ok := realFindHistoryPayload(response.RspMsgList, eventID); ok {
return data, cloudData
}
select {
case <-ctx.Done():
t.Fatalf("real Tencent IM group history readback timed out: %v", ctx.Err())
case <-time.After(500 * time.Millisecond):
}
}
t.Fatalf("real Tencent IM group history did not contain event_id=%s", eventID)
return "", ""
}
func realWaitForC2CPayload(t *testing.T, ctx context.Context, client *RESTClient, operatorAccount, peerAccount, eventID string, sentAtSeconds int64) (string, string) {
t.Helper()
for attempt := 0; attempt < 10; attempt++ {
var response realC2CHistoryResponse
if err := client.post(ctx, "v4/openim/admin_getroammsg", map[string]any{
"Operator_Account": operatorAccount,
"Peer_Account": peerAccount,
"MaxCnt": 100,
"MinTime": sentAtSeconds - 60,
"MaxTime": time.Now().UTC().Unix() + 60,
}, &response); err != nil {
t.Fatalf("real Tencent IM C2C history request failed: %v", err)
}
if err := response.err(); err != nil {
t.Fatalf("real Tencent IM C2C history failed: %v", err)
}
if data, cloudData, ok := realFindHistoryPayload(response.MsgList, eventID); ok {
return data, cloudData
}
select {
case <-ctx.Done():
t.Fatalf("real Tencent IM C2C history readback timed out: %v", ctx.Err())
case <-time.After(500 * time.Millisecond):
}
}
t.Fatalf("real Tencent IM C2C history did not contain event_id=%s", eventID)
return "", ""
}
func realFindHistoryPayload(messages []realHistoryMessage, eventID string) (string, string, bool) {
for _, message := range messages {
for _, element := range message.MsgBody {
if element.MsgType != "TIMCustomElem" || !strings.Contains(element.MsgContent.Data, eventID) {
continue
}
return element.MsgContent.Data, message.CloudCustomData, true
}
}
return "", "", false
}
func realAssertStringUserIDs(t *testing.T, payload string, expected map[string]string) {
t.Helper()
var decoded map[string]any
if err := json.Unmarshal([]byte(payload), &decoded); err != nil {
t.Fatalf("decode Tencent IM payload failed: %v", err)
}
for key, want := range expected {
got, ok := decoded[key].(string)
if !ok {
t.Fatalf("Tencent IM payload %s type = %T, want string", key, decoded[key])
}
if got != want {
t.Fatalf("Tencent IM payload %s = %q, want %q", key, got, want)
}
}
}
func realImportAccount(ctx context.Context, client *RESTClient, userID int64) error {

View File

@ -63,8 +63,8 @@ func TestRESTClientPublishRoomEventBuildsCustomMessage(t *testing.T) {
EventID: "evt-1",
RoomID: "room-1001",
EventType: "room_gift_sent",
ActorUserID: 10001,
TargetUserID: 10002,
ActorUserID: "323411852102995968",
TargetUserID: "323411852102996032",
GiftValue: 20,
RoomVersion: 7,
})
@ -96,6 +96,13 @@ func TestRESTClientPublishRoomEventBuildsCustomMessage(t *testing.T) {
if !strings.Contains(content["Data"].(string), `"room_id":"room-1001"`) {
t.Fatalf("event payload must keep internal room_id: %s", content["Data"])
}
var eventPayload map[string]any
if err := json.Unmarshal([]byte(content["Data"].(string)), &eventPayload); err != nil {
t.Fatalf("decode room event payload failed: %v", err)
}
if eventPayload["actor_user_id"] != "323411852102995968" || eventPayload["target_user_id"] != "323411852102996032" {
t.Fatalf("room event user IDs must remain exact JSON strings: %+v", eventPayload)
}
}
func TestRESTClientPublishRoomEventFlattensAttributesForClientPayload(t *testing.T) {
@ -112,8 +119,8 @@ func TestRESTClientPublishRoomEventFlattensAttributesForClientPayload(t *testing
EventID: "evt-robot-lucky-1",
RoomID: "room-1001",
EventType: "lucky_gift_drawn",
ActorUserID: 10001,
TargetUserID: 10002,
ActorUserID: "10001",
TargetUserID: "10002",
GiftValue: 500,
Attributes: map[string]string{
"event_type": "should_not_override",

View File

@ -37,12 +37,17 @@ INCREMENTAL_MIGRATION_FILES=(
"services/user-service/deploy/mysql/migrations/017_cp_application_gift_coin_value.sql"
"services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql"
"services/user-service/deploy/mysql/migrations/019_user_profile_anonymous_visits.sql"
"services/user-service/deploy/mysql/migrations/020_user_avatar_upload_registrations.sql"
"services/room-service/deploy/mysql/migrations/003_room_vip_media_and_decorations.sql"
"services/room-service/deploy/mysql/migrations/004_room_list_owner_vip_level.sql"
"services/activity-service/deploy/mysql/migrations/013_game_win_broadcast_config.sql"
"services/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql"
"services/wallet-service/deploy/mysql/migrations/003_vip_resource_equipment_contract.sql"
"services/wallet-service/deploy/mysql/migrations/010_fami_agency_host_income_share.sql"
"services/wallet-service/deploy/mysql/migrations/011_vip_user_settings_defaults.sql"
"services/wallet-service/deploy/mysql/migrations/012_wallet_outbox_archive_receipts.sql"
"services/wallet-service/deploy/mysql/migrations/013_wallet_outbox_archive_purge_receipts.sql"
"services/lucky-gift-service/deploy/mysql/migrations/013_jackpot_token_version_rate_limit.sql"
)
# Keep MySQL as the only required dependency for this script. Business services

View File

@ -82,17 +82,19 @@ type configRequest struct {
JackpotUser48hRTPMaxPercent float64 `json:"jackpot_user_48h_rtp_max_percent"`
// Deprecated in-process aliases keep historical handler builders source-compatible. They are not
// accepted or emitted on HTTP; the deployed admin UI and protobuf use the unique round/48h names.
JackpotUserDayRTPMaxPercent float64 `json:"-"`
JackpotUser72hRTPMaxPercent float64 `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
JackpotUserDayRTPMaxPercent float64 `json:"-"`
JackpotUser72hRTPMaxPercent float64 `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
JackpotRateLimitWindowMinutes int64 `json:"jackpot_rate_limit_window_minutes"`
MaxJackpotHitsPerWindow int64 `json:"max_jackpot_hits_per_window"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []stageDTO `json:"stages"`
}
type configRollbackRequest struct {
@ -625,6 +627,8 @@ func configToProto(req configRequest) *luckygiftv1.LuckyGiftRuleConfig {
JackpotUser_48HRtpMaxPpm: percentToPPM(rolling48hRTPMaxPercent),
JackpotSpendThresholdCoins: req.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: req.MaxJackpotHitsPerUserDay,
JackpotRateLimitWindowMinutes: req.JackpotRateLimitWindowMinutes,
MaxJackpotHitsPerWindow: req.MaxJackpotHitsPerWindow,
MaxSinglePayout: req.MaxSinglePayout,
UserHourlyPayoutCap: req.UserHourlyPayoutCap,
UserDailyPayoutCap: req.UserDailyPayoutCap,
@ -711,6 +715,8 @@ func configFromProto(config *luckygiftv1.LuckyGiftRuleConfig) configDTO {
JackpotUser72hRTPMaxPercent: ppmToPercent(config.GetJackpotUser_48HRtpMaxPpm()),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
JackpotRateLimitWindowMinutes: config.GetJackpotRateLimitWindowMinutes(),
MaxJackpotHitsPerWindow: config.GetMaxJackpotHitsPerWindow(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),

View File

@ -491,6 +491,8 @@ func luckyGiftJackpotConditionValues(condition *luckygiftv1.LuckyGiftUserJackpot
return luckyGiftDurationHours(condition.GetNumerator()), luckyGiftDurationHours(condition.GetDenominator()), "≥"
case "daily_jackpot_limit", "spend_daily_jackpot_limit":
return fmt.Sprintf("%d 次", condition.GetNumerator()), fmt.Sprintf("%d 次", condition.GetLimitPpm()), "<"
case "jackpot_rate_limit":
return fmt.Sprintf("%d 次(近 %s", condition.GetNumerator(), luckyGiftDurationMinutes(condition.GetDenominator())), fmt.Sprintf("%d 次", condition.GetLimitPpm()), "<"
case "settled_round_qualification", "payable_jackpot_candidate":
if condition.GetPassed() {
return "已满足", "必须满足", "="
@ -520,6 +522,7 @@ func luckyGiftJackpotConditionLabel(name string) string {
"user_72h_rtp": "用户近 72 小时返奖率",
"daily_jackpot_limit": "用户当天已中大奖次数",
"spend_daily_jackpot_limit": "累计消费大奖的当天次数限制",
"jackpot_rate_limit": "奖池滚动窗口内已爆大奖次数",
"payable_jackpot_candidate": "奖池余额是否足够支付所选大奖",
}
if label := labels[name]; label != "" {
@ -582,6 +585,12 @@ func luckyGiftDurationHours(milliseconds int64) string {
return value + " 小时"
}
func luckyGiftDurationMinutes(milliseconds int64) string {
value := strconv.FormatFloat(float64(milliseconds)/60_000, 'f', 1, 64)
value = strings.TrimRight(strings.TrimRight(value, "0"), ".")
return value + " 分钟"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {

View File

@ -1,5 +1,8 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 显式选择 Activity owner 库,避免单文件迁移依赖调用方隐式指定数据库。
USE hyapp_activity;
-- 新表只有按 app_code 主键点查和单行 upsert不扫描或改写既有高频活动流水表。
CREATE TABLE IF NOT EXISTS game_win_broadcast_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',

View File

@ -20,6 +20,7 @@ func TestRedPacketEventFromWalletMessageKeepsDisplayPayloadFacts(t *testing.T) {
"sender_user_id":10001,
"room_id":"9001",
"region_id":310,
"room_locked":true,
"total_amount":15000,
"packet_count":5,
"remaining_amount":15000,
@ -38,7 +39,7 @@ func TestRedPacketEventFromWalletMessageKeepsDisplayPayloadFacts(t *testing.T) {
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 {
if event.PacketID != "VRRP2026052712340001" || event.PacketType != "delayed" || event.RoomID != "9001" || event.RegionID != 310 || !event.RoomLocked {
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 {

View File

@ -122,6 +122,7 @@ func redPacketEventFromWalletMessage(body []byte) (broadcastdomain.RedPacketWall
RoomID: fields.RoomID,
RegionID: fields.RegionID,
RegionCode: fields.RegionCode,
RoomLocked: fields.RoomLocked,
SenderUserID: fields.SenderUserID,
TotalAmount: fields.TotalAmount,
TotalCount: fields.TotalCount,
@ -289,6 +290,7 @@ type redPacketPayloadFields struct {
RoomID string
RegionID int64
RegionCode string
RoomLocked bool
SenderUserID int64
TotalAmount int64
TotalCount int32
@ -329,6 +331,7 @@ func redPacketFieldsFromWalletPayload(payload string, eventType string) redPacke
RoomID: roomID,
RegionID: regionID,
RegionCode: stringFromDecoded(decoded, "region_code"),
RoomLocked: boolFromDecoded(decoded, "room_locked"),
SenderUserID: int64FromDecoded(decoded, "sender_user_id"),
TotalAmount: int64FromDecoded(decoded, "total_amount"),
TotalCount: totalCount,

View File

@ -126,6 +126,7 @@ type RedPacketWalletEvent struct {
RoomID string
RegionID int64
RegionCode string
RoomLocked bool
SenderUserID int64
SenderAccount string
SenderNickname string

View File

@ -127,6 +127,7 @@ func redPacketIMPayload(event broadcastdomain.RedPacketWalletEvent, eventName st
"roomId": event.RoomID,
"regionId": event.RegionID,
"regionCode": event.RegionCode,
"room_locked": event.RoomLocked,
"packetMode": packetMode,
"senderUserId": redPacketUserIDString(event.SenderUserID),
"actualAccount": redPacketSenderAccount(event),

View File

@ -7,8 +7,10 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"sort"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -816,7 +818,12 @@ func normalizedPayloadJSON(input string, forced map[string]any) (string, error)
input = "{}"
}
var payload map[string]any
if err := json.Unmarshal([]byte(input), &payload); err != nil {
decoder := json.NewDecoder(strings.NewReader(input))
decoder.UseNumber()
if err := decoder.Decode(&payload); err != nil {
return "", xerr.New(xerr.InvalidArgument, "payload_json is invalid")
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
return "", xerr.New(xerr.InvalidArgument, "payload_json is invalid")
}
if payload == nil {
@ -832,6 +839,9 @@ func normalizedPayloadJSON(input string, forced map[string]any) (string, error)
// forced 字段覆盖调用方 payload保证 event_id/scope/app_code/sent_at_ms 不被外部请求伪造。
payload[key] = value
}
// 播报 payload 可能来自多个 owner service在唯一出站归一化边界递归收敛所有 user_id/user_ids
// 并用 UseNumber 保留原始十进制文本,避免先落为 float64 后再转字符串已经无法恢复。
normalizeBroadcastUserIDs(payload)
encoded, err := json.Marshal(payload)
if err != nil {
return "", err
@ -839,6 +849,50 @@ func normalizedPayloadJSON(input string, forced map[string]any) (string, error)
return string(encoded), nil
}
func normalizeBroadcastUserIDs(value any) any {
switch typed := value.(type) {
case map[string]any:
for key, item := range typed {
if broadcastUserIDKey(key) {
typed[key] = broadcastUserIDValue(item)
continue
}
typed[key] = normalizeBroadcastUserIDs(item)
}
return typed
case []any:
for index, item := range typed {
typed[index] = normalizeBroadcastUserIDs(item)
}
return typed
default:
return value
}
}
func broadcastUserIDKey(key string) bool {
key = strings.ToLower(strings.TrimSpace(key))
return key == "user_id" || key == "user_ids" || strings.HasSuffix(key, "_user_id") || strings.HasSuffix(key, "_user_ids")
}
func broadcastUserIDValue(value any) any {
switch typed := value.(type) {
case json.Number:
return typed.String()
case int64:
return strconv.FormatInt(typed, 10)
case int:
return strconv.Itoa(typed)
case []any:
for index, item := range typed {
typed[index] = broadcastUserIDValue(item)
}
return typed
default:
return value
}
}
func superGiftBroadcastEventID(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) string {
if strings.TrimSpace(gift.GetCommandId()) != "" {
// command_id 是 SendGift 幂等键,用它生成播报 event_id 可避免同一房间事件重放时重复播报。
@ -956,6 +1010,7 @@ func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv
"launch_at_ms": rocket.GetLaunchAtMs(),
"top1_user_id": rocket.GetTop1UserId(),
"igniter_user_id": rocket.GetIgniterUserId(),
"room_locked": rocket.GetRoomLocked(),
"igniter": map[string]any{
"user_id": fmt.Sprintf("%d", rocket.GetIgniterUserId()),
"short_id": strings.TrimSpace(igniter.Account),
@ -969,8 +1024,9 @@ func roomRocketPayload(envelope *roomeventsv1.EventEnvelope, rocket *roomeventsv
"fuel_threshold": rocket.GetFuelThreshold(),
"ignited_at_ms": rocket.GetIgnitedAtMs(),
"action": map[string]any{
"type": "enter_room",
"room_id": envelope.GetRoomId(),
"type": "enter_room",
"room_id": envelope.GetRoomId(),
"room_locked": rocket.GetRoomLocked(),
},
}
encoded, err := json.Marshal(payload)

View File

@ -341,7 +341,7 @@ func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
service := New(Config{NodeID: "node-a"}, repository, nil, nil)
service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) })
envelope := mustPasswordChangedEnvelope(t, &roomeventsv1.RoomPasswordChanged{
ActorUserId: 42,
ActorUserId: 323411852102995968,
Locked: true,
VisibleRegionId: 1001,
})
@ -361,7 +361,7 @@ func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil {
t.Fatalf("password payload is not json: %v", err)
}
if payload["room_id"] != "room-1001" || payload["scope"] != "region" || payload["locked"] != true || payload["actor_user_id"] != float64(42) {
if payload["room_id"] != "room-1001" || payload["scope"] != "region" || payload["locked"] != true || payload["actor_user_id"] != "323411852102995968" {
t.Fatalf("password payload mismatch: %+v", payload)
}
action, ok := payload["action"].(map[string]any)
@ -370,6 +370,29 @@ func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) {
}
}
func TestRoomRocketPayloadCarriesRoomLockSnapshot(t *testing.T) {
payloadJSON, err := roomRocketPayload(&roomeventsv1.EventEnvelope{
AppCode: "lalu",
RoomId: "room-rocket-locked",
RoomVersion: 9,
}, &roomeventsv1.RoomRocketIgnited{
RocketId: "rocket-1",
BroadcastScope: broadcastdomain.ScopeGlobal,
RoomLocked: true,
}, "rocket-event-1", 1_700_000_000_000, SenderProfile{})
if err != nil {
t.Fatalf("roomRocketPayload failed: %v", err)
}
var payload map[string]any
if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil {
t.Fatalf("rocket payload is not json: %v", err)
}
action, _ := payload["action"].(map[string]any)
if payload["room_locked"] != true || action["room_locked"] != true {
t.Fatalf("rocket payload and action must carry room lock snapshot: %+v", payload)
}
}
func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) {
service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil)
belowThreshold := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001})
@ -485,6 +508,7 @@ func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
PacketType: "delayed",
RoomID: "9001",
RegionID: 310,
RoomLocked: true,
SenderUserID: 10001,
TotalAmount: 15000,
TotalCount: 5,
@ -514,6 +538,9 @@ func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) {
if data["packetMode"] != "DELAYED" || data["status"] != "WAITING_OPEN" || data["delaySeconds"] != float64(300) || data["regionCode"] != "AR" {
t.Fatalf("red packet display state mismatch: %+v", data)
}
if data["room_locked"] != true {
t.Fatalf("red packet payload must carry room lock snapshot: %+v", data)
}
}
func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
@ -538,6 +565,7 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
PacketType: "delayed",
RoomID: "9001",
RegionID: 310,
RoomLocked: true,
SenderUserID: 10001,
TotalAmount: 15000,
TotalCount: 5,
@ -566,11 +594,11 @@ func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) {
t.Fatalf("open due outbox mismatch: %+v", openDue)
}
regionData := redPacketPayloadData(t, regionCreated.PayloadJSON)
if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" {
if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" || regionData["room_locked"] != true {
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) {
if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) || openDueData["room_locked"] != true {
t.Fatalf("open due payload mismatch: %+v", openDueData)
}

View File

@ -52,6 +52,13 @@ type UserProfileClient interface {
UnblockManagerUser(ctx context.Context, req *userv1.UnblockManagerUserRequest) (*userv1.UnblockManagerUserResponse, error)
}
// UserAvatarUploadClient 抽象专用头像上传的授权与完成阶段。
// 与资料读取接口分离,避免不涉及上传的 gateway 测试替身被迫实现媒体 RPC。
type UserAvatarUploadClient interface {
AuthorizeUserAvatarUpload(ctx context.Context, req *userv1.AuthorizeUserAvatarUploadRequest) (*userv1.AuthorizeUserAvatarUploadResponse, error)
CompleteUserAvatarUpload(ctx context.Context, req *userv1.CompleteUserAvatarUploadRequest) (*userv1.CompleteUserAvatarUploadResponse, error)
}
// UserInviteClient 抽象 H5 邀请页对 user-service 邀请人搜索和绑定能力的依赖。
type UserInviteClient interface {
GetInviteAttribution(ctx context.Context, req *userv1.GetInviteAttributionRequest) (*userv1.GetInviteAttributionResponse, error)
@ -165,6 +172,8 @@ type grpcUserProfileClient struct {
client userv1.UserServiceClient
}
var _ UserAvatarUploadClient = (*grpcUserProfileClient)(nil)
type grpcUserInviteClient struct {
client userv1.UserServiceClient
}
@ -379,6 +388,14 @@ func (c *grpcUserProfileClient) CompleteOnboarding(ctx context.Context, req *use
return c.client.CompleteOnboarding(ctx, req)
}
func (c *grpcUserProfileClient) AuthorizeUserAvatarUpload(ctx context.Context, req *userv1.AuthorizeUserAvatarUploadRequest) (*userv1.AuthorizeUserAvatarUploadResponse, error) {
return c.client.AuthorizeUserAvatarUpload(ctx, req)
}
func (c *grpcUserProfileClient) CompleteUserAvatarUpload(ctx context.Context, req *userv1.CompleteUserAvatarUploadRequest) (*userv1.CompleteUserAvatarUploadResponse, error) {
return c.client.CompleteUserAvatarUpload(ctx, req)
}
func (c *grpcUserProfileClient) UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
return c.client.UpdateUserProfile(ctx, req)
}

View File

@ -0,0 +1,395 @@
// Package mediaimage 对 gateway 收到的完整图片字节做不解码像素的结构校验。
// GIF/WebP 只扫描容器和帧边界,避免攻击者用巨大画布或高帧数触发解压内存放大。
package mediaimage
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"fmt"
"image/jpeg"
"image/png"
"mime"
"path/filepath"
"strings"
)
const (
MaxFrames = int32(120)
MaxDurationMS = int64(15_000)
MaxDimension = int32(4096)
MaxPixels = int64(16_777_216)
)
// Info 是从真实文件内容解析出的规范化事实;调用方必须原样保存这些字节,不能转码后复用本结果。
type Info struct {
ContentType string
Format string
SizeBytes int64
Width int32
Height int32
Animated bool
FrameCount int32
DurationMS int64
SHA256 string
}
// Inspect 严格要求魔数、扩展名和 multipart Content-Type 三者一致,并拒绝 APNG 及畸形动画容器。
func Inspect(content []byte, filename string, partContentType string) (Info, string, error) {
var info Info
var extension string
switch {
case len(content) >= 3 && content[0] == 0xff && content[1] == 0xd8 && content[2] == 0xff:
cfg, err := jpeg.DecodeConfig(bytes.NewReader(content))
if err != nil {
return info, "", fmt.Errorf("invalid jpeg")
}
info.Format, info.ContentType, info.Width, info.Height, info.FrameCount = "jpeg", "image/jpeg", int32(cfg.Width), int32(cfg.Height), 1
extension = "jpg"
case len(content) >= 8 && bytes.Equal(content[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}):
if pngHasAnimationChunk(content) {
return info, "", fmt.Errorf("animated png is not supported")
}
cfg, err := png.DecodeConfig(bytes.NewReader(content))
if err != nil {
return info, "", fmt.Errorf("invalid png")
}
info.Format, info.ContentType, info.Width, info.Height, info.FrameCount = "png", "image/png", int32(cfg.Width), int32(cfg.Height), 1
extension = "png"
case len(content) >= 6 && (string(content[:6]) == "GIF87a" || string(content[:6]) == "GIF89a"):
width, height, frames, durationMS, err := inspectGIF(content)
if err != nil {
return info, "", err
}
info.Format, info.ContentType, info.Width, info.Height = "gif", "image/gif", width, height
info.FrameCount, info.Animated, info.DurationMS = frames, frames > 1, durationMS
if !info.Animated {
info.DurationMS = 0
}
extension = "gif"
case len(content) >= 12 && string(content[:4]) == "RIFF" && string(content[8:12]) == "WEBP":
width, height, frames, durationMS, animated, err := inspectWebP(content)
if err != nil {
return info, "", err
}
info.Format, info.ContentType, info.Width, info.Height = "webp", "image/webp", width, height
info.FrameCount, info.DurationMS, info.Animated = frames, durationMS, animated
extension = "webp"
default:
return info, "", fmt.Errorf("only jpeg, png, gif and webp are supported")
}
if err := validateGeometry(info); err != nil {
return Info{}, "", err
}
declared, _, _ := mime.ParseMediaType(strings.TrimSpace(partContentType))
if !strings.EqualFold(declared, info.ContentType) {
return Info{}, "", fmt.Errorf("file content_type does not match signature")
}
fileExtension := strings.ToLower(strings.TrimPrefix(filepath.Ext(filename), "."))
if fileExtension != extension && !(info.Format == "jpeg" && fileExtension == "jpeg") {
return Info{}, "", fmt.Errorf("filename extension does not match signature")
}
sum := sha256.Sum256(content)
info.SizeBytes, info.SHA256 = int64(len(content)), hex.EncodeToString(sum[:])
return info, extension, nil
}
func validateGeometry(info Info) error {
if info.Width <= 0 || info.Height <= 0 || info.Width > MaxDimension || info.Height > MaxDimension ||
int64(info.Width)*int64(info.Height) > MaxPixels || info.FrameCount <= 0 || info.FrameCount > MaxFrames {
return fmt.Errorf("image dimensions or frame count exceed limit")
}
if info.Animated {
if info.FrameCount < 2 || info.DurationMS <= 0 || info.DurationMS > MaxDurationMS {
return fmt.Errorf("animation exceeds frame or duration limit")
}
} else if info.FrameCount != 1 || info.DurationMS != 0 {
return fmt.Errorf("static image properties are invalid")
}
return nil
}
func pngHasAnimationChunk(content []byte) bool {
for offset := 8; offset+12 <= len(content); {
size := int(binary.BigEndian.Uint32(content[offset : offset+4]))
if size < 0 || offset+12+size > len(content) {
return false
}
if string(content[offset+4:offset+8]) == "acTL" {
return true
}
offset += 12 + size
}
return false
}
func inspectGIF(content []byte) (int32, int32, int32, int64, error) {
if len(content) < 13 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif")
}
width := int32(binary.LittleEndian.Uint16(content[6:8]))
height := int32(binary.LittleEndian.Uint16(content[8:10]))
if width <= 0 || height <= 0 || width > MaxDimension || height > MaxDimension || int64(width)*int64(height) > MaxPixels {
return 0, 0, 0, 0, fmt.Errorf("gif dimensions exceed limit")
}
offset := 13
if content[10]&0x80 != 0 {
colorTableBytes := 3 * (1 << (int(content[10]&0x07) + 1))
if offset+colorTableBytes > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif color table")
}
offset += colorTableBytes
}
var frames int32
var durationMS, pendingDelayMS int64
for offset < len(content) {
marker := content[offset]
offset++
switch marker {
case 0x3b:
if frames <= 0 {
return 0, 0, 0, 0, fmt.Errorf("gif has no image frame")
}
if frames == 1 {
durationMS = 0
}
return width, height, frames, durationMS, nil
case 0x21:
if offset >= len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif extension")
}
label := content[offset]
offset++
if label == 0xf9 {
if offset+6 > len(content) || content[offset] != 4 || content[offset+5] != 0 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif graphic control extension")
}
pendingDelayMS = int64(binary.LittleEndian.Uint16(content[offset+2:offset+4])) * 10
offset += 6
continue
}
var err error
offset, _, err = skipGIFSubBlocks(content, offset)
if err != nil {
return 0, 0, 0, 0, err
}
case 0x2c:
if offset+9 > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif image descriptor")
}
left := int(binary.LittleEndian.Uint16(content[offset : offset+2]))
top := int(binary.LittleEndian.Uint16(content[offset+2 : offset+4]))
frameWidth := int(binary.LittleEndian.Uint16(content[offset+4 : offset+6]))
frameHeight := int(binary.LittleEndian.Uint16(content[offset+6 : offset+8]))
packed := content[offset+8]
if frameWidth <= 0 || frameHeight <= 0 || left+frameWidth > int(width) || top+frameHeight > int(height) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif frame dimensions")
}
offset += 9
if packed&0x80 != 0 {
colorTableBytes := 3 * (1 << (int(packed&0x07) + 1))
if offset+colorTableBytes > len(content) {
return 0, 0, 0, 0, fmt.Errorf("invalid gif local color table")
}
offset += colorTableBytes
}
if offset >= len(content) || content[offset] < 2 || content[offset] > 8 {
return 0, 0, 0, 0, fmt.Errorf("invalid gif lzw code size")
}
offset++
var hasData bool
var err error
offset, hasData, err = skipGIFSubBlocks(content, offset)
if err != nil || !hasData {
return 0, 0, 0, 0, fmt.Errorf("invalid gif image data")
}
frames++
durationMS += pendingDelayMS
pendingDelayMS = 0
if frames > MaxFrames || durationMS > MaxDurationMS {
return 0, 0, 0, 0, fmt.Errorf("gif animation exceeds frame or duration limit")
}
default:
return 0, 0, 0, 0, fmt.Errorf("invalid gif block marker")
}
}
return 0, 0, 0, 0, fmt.Errorf("gif trailer is missing")
}
func skipGIFSubBlocks(content []byte, offset int) (int, bool, error) {
hasData := false
for {
if offset >= len(content) {
return 0, false, fmt.Errorf("invalid gif sub-block")
}
blockSize := int(content[offset])
offset++
if blockSize == 0 {
return offset, hasData, nil
}
if offset+blockSize > len(content) {
return 0, false, fmt.Errorf("invalid gif sub-block")
}
hasData = true
offset += blockSize
}
}
func inspectWebP(content []byte) (int32, int32, int32, int64, bool, error) {
if len(content) < 20 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp header")
}
if uint64(binary.LittleEndian.Uint32(content[4:8]))+8 != uint64(len(content)) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp riff size")
}
var width, height int32
var frames int32
var durationMS int64
var animatedFlag, hasVP8X, hasANIM, hasStaticImage bool
for offset := 12; offset < len(content); {
if offset+8 > len(content) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp trailing chunk")
}
chunkType := string(content[offset : offset+4])
size := uint64(binary.LittleEndian.Uint32(content[offset+4 : offset+8]))
start, end := uint64(offset+8), uint64(offset+8)+size
paddedEnd := end + size%2
if end < start || paddedEnd > uint64(len(content)) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp chunk")
}
if size%2 == 1 && content[end] != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp chunk padding")
}
chunk := content[int(start):int(end)]
switch chunkType {
case "VP8X":
if hasVP8X || offset != 12 || len(chunk) != 10 || chunk[0]&0xc1 != 0 || chunk[1] != 0 || chunk[2] != 0 || chunk[3] != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp header")
}
hasVP8X, animatedFlag = true, chunk[0]&0x02 != 0
width, height = int32(readUint24LE(chunk[4:7]))+1, int32(readUint24LE(chunk[7:10]))+1
case "VP8 ", "VP8L":
if hasStaticImage || (!hasVP8X && offset != 12) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp image payload order")
}
imageWidth, imageHeight, err := inspectWebPImagePayload(chunkType, chunk)
if err != nil {
return 0, 0, 0, 0, false, err
}
hasStaticImage = true
if width == 0 {
width, height = imageWidth, imageHeight
} else if imageWidth > width || imageHeight > height {
return 0, 0, 0, 0, false, fmt.Errorf("webp image exceeds canvas")
}
case "ANIM":
if !hasVP8X || !animatedFlag || hasANIM || len(chunk) != 6 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation header")
}
hasANIM = true
case "ANMF":
if !hasVP8X || !animatedFlag || !hasANIM || width <= 0 || height <= 0 || len(chunk) < 16 || chunk[15]&0xfc != 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation frame")
}
frameX, frameY := int64(readUint24LE(chunk[0:3]))*2, int64(readUint24LE(chunk[3:6]))*2
frameWidth, frameHeight := int32(readUint24LE(chunk[6:9]))+1, int32(readUint24LE(chunk[9:12]))+1
if frameWidth <= 0 || frameHeight <= 0 || frameX+int64(frameWidth) > int64(width) || frameY+int64(frameHeight) > int64(height) {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp animation frame geometry")
}
if err := inspectWebPFramePayload(chunk[16:], frameWidth, frameHeight); err != nil {
return 0, 0, 0, 0, false, err
}
frames++
durationMS += int64(readUint24LE(chunk[12:15]))
if frames > MaxFrames || durationMS > MaxDurationMS {
return 0, 0, 0, 0, false, fmt.Errorf("webp animation exceeds frame or duration limit")
}
}
offset = int(paddedEnd)
}
if width <= 0 || height <= 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid webp dimensions")
}
if animatedFlag {
if !hasANIM || hasStaticImage || frames < 2 || durationMS <= 0 {
return 0, 0, 0, 0, false, fmt.Errorf("invalid animated webp")
}
return width, height, frames, durationMS, true, nil
}
if hasANIM || frames != 0 || !hasStaticImage {
return 0, 0, 0, 0, false, fmt.Errorf("invalid static webp")
}
return width, height, 1, 0, false, nil
}
func inspectWebPImagePayload(chunkType string, chunk []byte) (int32, int32, error) {
switch chunkType {
case "VP8 ":
if len(chunk) <= 10 || chunk[0]&0x01 != 0 || !bytes.Equal(chunk[3:6], []byte{0x9d, 0x01, 0x2a}) {
return 0, 0, fmt.Errorf("invalid webp vp8 payload")
}
width, height := int32(binary.LittleEndian.Uint16(chunk[6:8])&0x3fff), int32(binary.LittleEndian.Uint16(chunk[8:10])&0x3fff)
if width <= 0 || height <= 0 {
return 0, 0, fmt.Errorf("invalid webp vp8 dimensions")
}
return width, height, nil
case "VP8L":
if len(chunk) <= 5 || chunk[0] != 0x2f {
return 0, 0, fmt.Errorf("invalid webp vp8l payload")
}
bits := binary.LittleEndian.Uint32(chunk[1:5])
if bits>>29 != 0 {
return 0, 0, fmt.Errorf("invalid webp vp8l version")
}
return int32(bits&0x3fff) + 1, int32((bits>>14)&0x3fff) + 1, nil
default:
return 0, 0, fmt.Errorf("unsupported webp image payload")
}
}
func inspectWebPFramePayload(content []byte, expectedWidth int32, expectedHeight int32) error {
if len(content) < 8 {
return fmt.Errorf("webp animation frame has no image payload")
}
foundImage := false
for offset := 0; offset < len(content); {
if offset+8 > len(content) {
return fmt.Errorf("invalid webp animation frame chunk")
}
chunkType := string(content[offset : offset+4])
size := uint64(binary.LittleEndian.Uint32(content[offset+4 : offset+8]))
start, end := uint64(offset+8), uint64(offset+8)+size
paddedEnd := end + size%2
if end < start || paddedEnd > uint64(len(content)) || (size%2 == 1 && content[end] != 0) {
return fmt.Errorf("invalid webp animation frame chunk")
}
chunk := content[int(start):int(end)]
switch chunkType {
case "ALPH":
if foundImage || len(chunk) == 0 {
return fmt.Errorf("invalid webp animation alpha payload")
}
case "VP8 ", "VP8L":
if foundImage {
return fmt.Errorf("webp animation frame has multiple image payloads")
}
width, height, err := inspectWebPImagePayload(chunkType, chunk)
if err != nil || width != expectedWidth || height != expectedHeight {
return fmt.Errorf("webp animation image dimensions mismatch")
}
foundImage = true
default:
return fmt.Errorf("unsupported webp animation frame chunk")
}
offset = int(paddedEnd)
}
if !foundImage {
return fmt.Errorf("webp animation frame has no image payload")
}
return nil
}
func readUint24LE(value []byte) uint32 {
return uint32(value[0]) | uint32(value[1])<<8 | uint32(value[2])<<16
}

View File

@ -0,0 +1,122 @@
package appapi
import (
"bytes"
"io"
"net/http"
"strings"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/giftlimits"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/mediaimage"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type userAvatarUploadData struct {
AvatarUploadID string `json:"avatar_upload_id"`
URL string `json:"url"`
SHA256 string `json:"sha256"`
FrameCount int32 `json:"frame_count"`
Animated bool `json:"animated"`
ContentType string `json:"content_type"`
Format string `json:"format"`
SizeBytes int64 `json:"size_bytes"`
Width int32 `json:"width"`
Height int32 `json:"height"`
DurationMS int64 `json:"duration_ms"`
ServerTimeMS int64 `json:"server_time_ms"`
}
// uploadUserAvatar 是登录用户唯一可信头像上传入口。所有原始字节在 gateway 完成解析和权益授权后
// 直接写入 COSContent-Type 与后缀都使用实际格式,禁止图片库转码或从客户端文件名推断。
func (h *Handler) uploadUserAvatar(writer http.ResponseWriter, request *http.Request) {
if h.objectUploader == nil || h.objectURLVerifier == nil || h.userAvatarUploadClient == nil {
httpkit.WriteError(writer, request, http.StatusServiceUnavailable, httpkit.CodeUpstreamError, "avatar upload is unavailable")
return
}
request.Body = http.MaxBytesReader(writer, request.Body, avatarUploadMaxBytes+uploadMultipartOverheadLimit)
if err := request.ParseMultipartForm(avatarUploadMaxBytes + uploadMultipartOverheadLimit); err != nil {
httpkit.WriteError(writer, request, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds upload limit")
return
}
if request.MultipartForm != nil {
defer request.MultipartForm.RemoveAll()
}
commandID := strings.TrimSpace(request.FormValue("command_id"))
if !giftlimits.ValidCommandID(commandID) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "command_id is invalid")
return
}
file, header, err := request.FormFile("file")
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "file is required")
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, avatarUploadMaxBytes+1))
if err != nil || len(content) == 0 {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "file is invalid")
return
}
if int64(len(content)) > avatarUploadMaxBytes {
httpkit.WriteError(writer, request, http.StatusRequestEntityTooLarge, "FILE_TOO_LARGE", "file exceeds upload limit")
return
}
if header.Size >= 0 && header.Size != int64(len(content)) {
// multipart 大小、SHA 和 COS ContentLength 必须基于同一份原始字节,截断或额外拼接均拒绝。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "file size does not match multipart metadata")
return
}
info, _, err := mediaimage.Inspect(content, header.Filename, header.Header.Get("Content-Type"))
if err != nil {
httpkit.WriteError(writer, request, http.StatusUnsupportedMediaType, "UNSUPPORTED_MEDIA_TYPE", err.Error())
return
}
media := &userv1.UserAvatarMedia{
ContentType: info.ContentType, Format: info.Format, SizeBytes: info.SizeBytes, Width: info.Width, Height: info.Height,
Animated: info.Animated, FrameCount: info.FrameCount, DurationMs: info.DurationMS, Sha256: info.SHA256, Status: "active",
}
userID := auth.UserIDFromContext(request.Context())
authorized, err := h.userAvatarUploadClient.AuthorizeUserAvatarUpload(request.Context(), &userv1.AuthorizeUserAvatarUploadRequest{
Meta: httpkit.UserMeta(request, ""), UserId: userID, CommandId: commandID, Media: media,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if authorized == nil || !authorized.GetAllowed() || !giftlimits.ValidCommandID(authorized.GetAvatarUploadId()) || strings.TrimSpace(authorized.GetObjectKey()) == "" {
httpkit.WriteError(writer, request, http.StatusForbidden, httpkit.CodePermissionDenied, "avatar upload denied")
return
}
objectKey := strings.TrimLeft(strings.TrimSpace(authorized.GetObjectKey()), "/")
objectURL, err := h.objectUploader.PutObject(request.Context(), objectKey, bytes.NewReader(content), int64(len(content)), info.ContentType)
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "object upload failed")
return
}
if !h.objectURLVerifier.OwnsObjectURL(objectURL, objectKey) {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "object upload url verification failed")
return
}
media.Url, media.ObjectKey = objectURL, objectKey
completed, err := h.userAvatarUploadClient.CompleteUserAvatarUpload(request.Context(), &userv1.CompleteUserAvatarUploadRequest{
Meta: httpkit.UserMeta(request, ""), UserId: userID,
AvatarUploadId: authorized.GetAvatarUploadId(), Media: media,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if completed == nil || !completed.GetActive() || completed.GetAvatarUploadId() != authorized.GetAvatarUploadId() || completed.GetMedia() == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "avatar upload completion failed")
return
}
result := completed.GetMedia()
httpkit.WriteOK(writer, request, userAvatarUploadData{
AvatarUploadID: completed.GetAvatarUploadId(), URL: result.GetUrl(), SHA256: result.GetSha256(),
FrameCount: result.GetFrameCount(), Animated: result.GetAnimated(), ContentType: result.GetContentType(),
Format: result.GetFormat(), SizeBytes: result.GetSizeBytes(), Width: result.GetWidth(), Height: result.GetHeight(),
DurationMS: result.GetDurationMs(), ServerTimeMS: completed.GetServerTimeMs(),
})
}

View File

@ -25,34 +25,45 @@ type ObjectUploader interface {
PutObject(ctx context.Context, key string, reader io.Reader, sizeBytes int64, contentType string) (string, error)
}
// ObjectURLVerifier 验证上传返回 URL 确实属于当前 COS access_url 和指定对象键。
type ObjectURLVerifier interface {
OwnsObjectURL(objectURL string, objectKey string) bool
}
// Handler owns app platform endpoints such as bootstrap, config, upload and device token binding.
// It does not own user identity or resource semantics; those remain in their owner services/packages.
type Handler struct {
appConfigReader ConfigReader
userAuthClient client.UserAuthClient
userProfileClient client.UserProfileClient
userDeviceClient client.UserDeviceClient
statisticsClient client.StatisticsClient
objectUploader ObjectUploader
appConfigReader ConfigReader
userAuthClient client.UserAuthClient
userProfileClient client.UserProfileClient
userAvatarUploadClient client.UserAvatarUploadClient
userDeviceClient client.UserDeviceClient
statisticsClient client.StatisticsClient
objectUploader ObjectUploader
objectURLVerifier ObjectURLVerifier
}
type Config struct {
AppConfigReader ConfigReader
UserAuthClient client.UserAuthClient
UserProfileClient client.UserProfileClient
UserDeviceClient client.UserDeviceClient
StatisticsClient client.StatisticsClient
ObjectUploader ObjectUploader
AppConfigReader ConfigReader
UserAuthClient client.UserAuthClient
UserProfileClient client.UserProfileClient
UserAvatarUploadClient client.UserAvatarUploadClient
UserDeviceClient client.UserDeviceClient
StatisticsClient client.StatisticsClient
ObjectUploader ObjectUploader
}
func New(config Config) *Handler {
objectURLVerifier, _ := config.ObjectUploader.(ObjectURLVerifier)
return &Handler{
appConfigReader: config.AppConfigReader,
userAuthClient: config.UserAuthClient,
userProfileClient: config.UserProfileClient,
userDeviceClient: config.UserDeviceClient,
statisticsClient: config.StatisticsClient,
objectUploader: config.ObjectUploader,
appConfigReader: config.AppConfigReader,
userAuthClient: config.UserAuthClient,
userProfileClient: config.UserProfileClient,
userAvatarUploadClient: config.UserAvatarUploadClient,
userDeviceClient: config.UserDeviceClient,
statisticsClient: config.StatisticsClient,
objectUploader: config.ObjectUploader,
objectURLVerifier: objectURLVerifier,
}
}
@ -96,6 +107,10 @@ func (h *Handler) UploadRegistrationAvatar(writer http.ResponseWriter, request *
h.uploadRegistrationAvatar(writer, request)
}
func (h *Handler) UploadUserAvatar(writer http.ResponseWriter, request *http.Request) {
h.uploadUserAvatar(writer, request)
}
func (h *Handler) ProxyEffectMedia(writer http.ResponseWriter, request *http.Request) {
h.proxyEffectMedia(writer, request)
}

View File

@ -105,6 +105,11 @@ func (h *Handler) uploadObject(writer http.ResponseWriter, request *http.Request
if policy.RequireImage {
// 头像用文件头嗅探结果做安全判断,不信任客户端传入的 multipart Content-Type。
contentType = detectedContentType
if !avatarExtensionMatchesContentType(header.Filename, detectedContentType) || animatedWebPHeader(head) {
// 注册和旧头像入口只允许静态图片;动态 WebP 的 MIME 与静态相同,必须读取 VP8X 动画位拦截。
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
}
if !validateUploadContent(policy, header.Filename, contentType) {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
@ -185,6 +190,26 @@ func isAvatarContentType(contentType string) bool {
}
}
func avatarExtensionMatchesContentType(filename string, contentType string) bool {
extension := strings.ToLower(path.Ext(filename))
switch strings.ToLower(strings.TrimSpace(contentType)) {
case "image/jpeg":
return extension == ".jpg" || extension == ".jpeg"
case "image/png":
return extension == ".png"
case "image/webp":
return extension == ".webp"
default:
return false
}
}
func animatedWebPHeader(head []byte) bool {
// 有动画的合法 WebP 必须以 VP8X 扩展头声明 ANIMATION flag该字段位于前 21 字节内。
return len(head) >= 21 && string(head[:4]) == "RIFF" && string(head[8:12]) == "WEBP" &&
string(head[12:16]) == "VP8X" && head[20]&0x02 != 0
}
func buildUploadObjectKey(policy uploadPolicy, ownerSegment string, filename string, contentType string, now time.Time) string {
date := now.UTC().Format("20060102")
extension := normalizedUploadExtension(filename, contentType)

View File

@ -64,6 +64,7 @@ type AppHandlers struct {
HandleTencentRTCCallback http.HandlerFunc
UploadFile http.HandlerFunc
UploadAvatar http.HandlerFunc
UploadUserAvatar http.HandlerFunc
UploadRegistrationAvatar http.HandlerFunc
ProxyEffectMedia http.HandlerFunc
HandlePushToken http.HandlerFunc
@ -464,6 +465,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.profile("/users/me/avatar/upload", http.MethodPost, h.UploadUserAvatar)
r.public("/files/registration/avatar/upload", http.MethodPost, h.UploadRegistrationAvatar)
r.public("/media/effects/proxy", http.MethodGet, h.ProxyEffectMedia)
r.auth("/app/heartbeat", http.MethodPost, h.AppHeartbeat)

View File

@ -5,6 +5,7 @@ import (
"hyapp/pkg/tencentrtc"
"hyapp/services/gateway-service/internal/auth"
gatewayclient "hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/activityapi"
"hyapp/services/gateway-service/internal/transport/http/appapi"
"hyapp/services/gateway-service/internal/transport/http/callbackapi"
@ -126,13 +127,15 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
GrowthLevelClient: h.growthLevelClient,
BroadcastClient: h.broadcastClient,
})
userAvatarUploadClient, _ := h.userProfileClient.(gatewayclient.UserAvatarUploadClient)
appAPI := appapi.New(appapi.Config{
AppConfigReader: h.appConfigReader,
UserAuthClient: h.userClient,
UserProfileClient: h.userProfileClient,
UserDeviceClient: h.userDeviceClient,
StatisticsClient: h.statisticsClient,
ObjectUploader: h.objectUploader,
AppConfigReader: h.appConfigReader,
UserAuthClient: h.userClient,
UserProfileClient: h.userProfileClient,
UserAvatarUploadClient: userAvatarUploadClient,
UserDeviceClient: h.userDeviceClient,
StatisticsClient: h.statisticsClient,
ObjectUploader: h.objectUploader,
})
callbackAPI := callbackapi.New(callbackapi.Config{
RoomClient: h.roomClient,
@ -215,6 +218,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
HandleTencentRTCCallback: callbackAPI.HandleTencentRTCCallback,
UploadFile: appAPI.UploadFile,
UploadAvatar: appAPI.UploadAvatar,
UploadUserAvatar: appAPI.UploadUserAvatar,
UploadRegistrationAvatar: appAPI.UploadRegistrationAvatar,
ProxyEffectMedia: appAPI.ProxyEffectMedia,
HandlePushToken: appAPI.HandlePushToken,

View File

@ -353,10 +353,11 @@ func (h *Handler) getMyInviteOverview(writer http.ResponseWriter, request *http.
// updateMyProfile 修改当前用户的用户名、头像、性别和生日;主页背景图走专用上传接口。
func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Request) {
var body struct {
Username *string `json:"username"`
Avatar *string `json:"avatar"`
Gender *string `json:"gender"`
Birth *string `json:"birth"`
Username *string `json:"username"`
Avatar *string `json:"avatar"`
AvatarUploadID *string `json:"avatar_upload_id"`
Gender *string `json:"gender"`
Birth *string `json:"birth"`
}
if !httpkit.Decode(writer, request, &body) {
return
@ -367,12 +368,13 @@ func (h *Handler) updateMyProfile(writer http.ResponseWriter, request *http.Requ
}
resp, err := h.userProfileClient.UpdateUserProfile(request.Context(), &userv1.UpdateUserProfileRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Username: body.Username,
Avatar: body.Avatar,
Gender: body.Gender,
Birth: body.Birth,
Meta: httpkit.UserMeta(request, ""),
UserId: auth.UserIDFromContext(request.Context()),
Username: body.Username,
Avatar: body.Avatar,
AvatarUploadId: body.AvatarUploadID,
Gender: body.Gender,
Birth: body.Birth,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)

View File

@ -85,7 +85,7 @@ func TestTriggerVIPOnlineNoticeChecksBenefitAndPublishesTrustedGlobalPayload(t *
if err := json.Unmarshal([]byte(publish.GetPayloadJson()), &payload); err != nil {
t.Fatalf("decode broadcast payload: %v", err)
}
if payload["user_id"] != float64(42) || payload["nickname"] != "I am a Rich" || payload["avatar"] != "https://cdn.example/vip9.png" || payload["display_user_id"] != "251145" || payload["vip_level"] != float64(9) || payload["vip_name"] != "VIP9" || payload["message"] != "欢迎进入Fami祝你有美好的一天" {
if payload["user_id"] != "42" || payload["sender_user_id"] != "42" || payload["nickname"] != "I am a Rich" || payload["avatar"] != "https://cdn.example/vip9.png" || payload["display_user_id"] != "251145" || payload["vip_level"] != float64(9) || payload["vip_name"] != "VIP9" || payload["message"] != "欢迎进入Fami祝你有美好的一天" {
t.Fatalf("broadcast payload mismatch: %+v", payload)
}
if payload["avatar_frame_url"] != "https://cdn.example/frame.svga" || payload["avatar_frame_resource_id"] != float64(7001) || payload["avatar_frame_code"] != "vip9_gold_frame" || payload["avatar_frame_asset_url"] != "https://cdn.example/frame.svga" || payload["avatar_frame_preview_url"] != "https://cdn.example/frame.png" || payload["avatar_frame_animation_url"] != "https://cdn.example/frame.json" {

View File

@ -88,12 +88,13 @@ type directGiftMessageData struct {
}
type directGiftIMPayload struct {
Type string `json:"type"`
EventID string `json:"event_id"`
CommandID string `json:"command_id"`
AppCode string `json:"app_code"`
SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"`
Type string `json:"type"`
EventID string `json:"event_id"`
CommandID string `json:"command_id"`
AppCode string `json:"app_code"`
// 私聊礼物同样由客户端 JSON 解码,用户 ID 必须是字符串,不能暴露 Go int64 为 JSON number。
SenderUserID string `json:"sender_user_id"`
TargetUserID string `json:"target_user_id"`
GiftID string `json:"gift_id"`
GiftName string `json:"gift_name"`
GiftIconURL string `json:"gift_icon_url"`
@ -345,8 +346,8 @@ func directGiftPayloadJSON(settlement directGiftSettlementData) (json.RawMessage
EventID: settlement.EventID,
CommandID: settlement.CommandID,
AppCode: settlement.AppCode,
SenderUserID: settlement.SenderUserID,
TargetUserID: settlement.TargetUserID,
SenderUserID: tencentim.FormatOptionalUserID(settlement.SenderUserID),
TargetUserID: tencentim.FormatOptionalUserID(settlement.TargetUserID),
GiftID: settlement.GiftID,
GiftName: settlement.GiftName,
GiftIconURL: settlement.GiftIconURL,

View File

@ -260,6 +260,7 @@ func (h *Handler) sendVoiceRoomRedPacket(writer http.ResponseWriter, request *ht
PacketType: packetType,
TotalAmount: body.amount(),
PacketCount: body.count(),
RoomLocked: snapshotResp.GetRoom().GetLocked(),
})
if err != nil {
writeRedPacketRPCError(writer, request, err)
@ -704,6 +705,7 @@ func (h *Handler) createRoomRedPacket(writer http.ResponseWriter, request *http.
PacketType: packetType,
TotalAmount: totalAmount,
PacketCount: packetCount,
RoomLocked: snapshotResp.GetRoom().GetLocked(),
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)

View File

@ -11,6 +11,7 @@ import (
userv1 "hyapp.local/api/proto/user/v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/tencentim"
"hyapp/pkg/xerr"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
@ -274,8 +275,9 @@ func (h *Handler) triggerVIPOnlineNotice(writer http.ResponseWriter, request *ht
displayID = strings.TrimSpace(profile.GetDisplayUserId())
}
payload, err := json.Marshal(map[string]any{
"user_id": userID,
"sender_user_id": userID,
// VIP 全服播报进入腾讯 IM长用户 ID 必须在首次 JSON 编码时就是字符串。
"user_id": tencentim.FormatOptionalUserID(userID),
"sender_user_id": tencentim.FormatOptionalUserID(userID),
"nickname": profile.GetUsername(),
"avatar": profile.GetAvatar(),
"display_user_id": displayID,

View File

@ -112,6 +112,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_rule_versions (
jackpot_user_48h_rtp_max_ppm BIGINT NOT NULL DEFAULT 0 COMMENT '动态大奖用户滚动 48 小时 RTP 严格上限ppm',
jackpot_spend_threshold_coins BIGINT NOT NULL DEFAULT 0 COMMENT '规则版本动态配置的用户 UTC 日累计消费大奖门槛金币',
max_jackpot_hits_per_user_day BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日动态大奖命中上限',
jackpot_rate_limit_window_minutes BIGINT NOT NULL DEFAULT 0 COMMENT 'app+pool 全局滚动爆奖窗口分钟数0仅兼容历史规则',
max_jackpot_hits_per_window BIGINT NOT NULL DEFAULT 0 COMMENT '全局滚动窗口内两类大奖合计上限0仅兼容历史规则',
max_single_payout BIGINT NOT NULL DEFAULT 0 COMMENT '单次返奖金币上限',
user_hourly_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 小时返奖上限',
user_daily_payout_cap BIGINT NOT NULL DEFAULT 0 COMMENT '单用户 UTC 日返奖上限',
@ -273,6 +275,7 @@ CREATE TABLE IF NOT EXISTS lucky_user_strategy_days (
wager_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日抽奖流水金币',
payout_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日返奖金币',
spend_coins BIGINT NOT NULL DEFAULT 0 COMMENT '当日用于动态大奖资格的累计消费金币',
spend_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT 'spend_coins 所属不可变规则版本切换版本时从0重新累计',
jackpot_hits BIGINT NOT NULL DEFAULT 0 COMMENT '当日动态大奖已命中次数',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -290,6 +293,7 @@ CREATE TABLE IF NOT EXISTS lucky_user_states (
loss_streak BIGINT NOT NULL DEFAULT 0 COMMENT '连续未获得可见奖励次数,中奖后清零',
pending_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '旧版消费资格;新版运行时仅惰性清零,永不参与开奖',
pending_spend_jackpot_tokens BIGINT NOT NULL DEFAULT 0 COMMENT '新版累计消费大奖资格受阻时跨轮次和UTC日保留成功开奖仅扣1',
pending_spend_jackpot_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT '消费大奖资格所属不可变规则版本0及版本不匹配均在下一抽安全过期',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, gift_id)
@ -520,7 +524,8 @@ CREATE TABLE IF NOT EXISTS lucky_gift_user_jackpot_hits (
occurred_at_ms BIGINT NOT NULL COMMENT '支付事实时间',
created_at_ms BIGINT NOT NULL COMMENT '读模型写入时间',
PRIMARY KEY (app_code, source_type, source_draw_id),
KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC)
KEY idx_lucky_user_jackpot_profile (app_code, pool_id, strategy_version, identity_type, identity_key, occurred_at_ms DESC, source_type DESC, source_draw_id DESC),
KEY idx_lucky_jackpot_rate_window (app_code, pool_id, occurred_at_ms, source_type, source_draw_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物用户画像大奖解释';
CREATE TABLE IF NOT EXISTS external_lucky_gift_request_locks (

View File

@ -0,0 +1,37 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_lucky_gift;
-- 四个新增标量都使用 INSTANT 元数据变更,不扫描用户状态或规则历史。历史 token 不在迁移中
-- 全表 UPDATE新运行时锁定单用户主键行后发现 version=0/不匹配即清零,避免上线 DDL 后再锁大表。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_rule_versions' AND COLUMN_NAME='jackpot_rate_limit_window_minutes')=0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN jackpot_rate_limit_window_minutes BIGINT NOT NULL DEFAULT 0 COMMENT ''app+pool 全局滚动爆奖窗口分钟数0仅兼容历史规则'' AFTER max_jackpot_hits_per_user_day, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_rule_versions' AND COLUMN_NAME='max_jackpot_hits_per_window')=0,
'ALTER TABLE lucky_gift_rule_versions ADD COLUMN max_jackpot_hits_per_window BIGINT NOT NULL DEFAULT 0 COMMENT ''全局滚动窗口内两类大奖合计上限0仅兼容历史规则'' AFTER jackpot_rate_limit_window_minutes, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_user_states' AND COLUMN_NAME='pending_spend_jackpot_rule_version')=0,
'ALTER TABLE lucky_user_states ADD COLUMN pending_spend_jackpot_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT ''消费大奖资格所属不可变规则版本0及版本不匹配均在下一抽安全过期'' AFTER pending_spend_jackpot_tokens, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_user_strategy_days' AND COLUMN_NAME='spend_rule_version')=0,
'ALTER TABLE lucky_user_strategy_days ADD COLUMN spend_rule_version BIGINT NOT NULL DEFAULT 0 COMMENT ''spend_coins 所属不可变规则版本切换版本时从0重新累计'' AFTER spend_coins, ALGORITHM=INSTANT',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 滚动限制只读取最近 N 分钟的大奖事实。该索引避免 JSON/开奖大表扫描;创建索引会读取
-- lucky_gift_user_jackpot_hits生产执行前必须检查表大小并安排在低峰使用 INPLACE/LOCK=NONE 保持写入可用。
SET @ddl := IF((SELECT COUNT(*) FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='lucky_gift_user_jackpot_hits' AND INDEX_NAME='idx_lucky_jackpot_rate_window')=0,
'ALTER TABLE lucky_gift_user_jackpot_hits ADD KEY idx_lucky_jackpot_rate_window (app_code, pool_id, occurred_at_ms, source_type, source_draw_id), ALGORITHM=INPLACE, LOCK=NONE',
'SELECT 1');
PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt;

View File

@ -92,16 +92,20 @@ type Config struct {
JackpotUser72hRTPMaxPPM int64 `json:"-"`
// SettledRoundRTPEligibility marks configs received through the round/48h contract.
// It is transport metadata only: persistence stores the two canonical limits and reconstructs this marker on read.
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
MultiplierPPMs []int64 `json:"multiplier_ppms"`
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
// JackpotRateLimitWindowMinutes/MaxJackpotHitsPerWindow 是 app+pool 全局滚动窗口;
// 两类大奖和所有并行规则版本共享额度,防止通过切换用户、实验组或大奖机制绕过爆奖节流。
JackpotRateLimitWindowMinutes int64 `json:"jackpot_rate_limit_window_minutes"`
MaxJackpotHitsPerWindow int64 `json:"max_jackpot_hits_per_window"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
MultiplierPPMs []int64 `json:"multiplier_ppms"`
// Stages 保留 dynamic_v3 的充值门槛Tiers 仍是 fixed_v2 候选选择使用的扁平运行结构。
Stages []RuleStage `json:"stages"`
Tiers []Tier `json:"tiers"`
@ -169,18 +173,20 @@ type RuleConfig struct {
JackpotUserRoundRTPMaxPPM int64 `json:"jackpot_user_round_rtp_max_ppm"`
JackpotUser48hRTPMaxPPM int64 `json:"jackpot_user_48h_rtp_max_ppm"`
// Deprecated aliases are normalized at service/repository boundaries and are never persisted as separate values.
JackpotUserDayRTPMaxPPM int64 `json:"-"`
JackpotUser72hRTPMaxPPM int64 `json:"-"`
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []RuleStage `json:"stages"`
JackpotUserDayRTPMaxPPM int64 `json:"-"`
JackpotUser72hRTPMaxPPM int64 `json:"-"`
SettledRoundRTPEligibility bool `json:"-"`
JackpotSpendThresholdCoins int64 `json:"jackpot_spend_threshold_coins"`
MaxJackpotHitsPerUserDay int64 `json:"max_jackpot_hits_per_user_day"`
JackpotRateLimitWindowMinutes int64 `json:"jackpot_rate_limit_window_minutes"`
MaxJackpotHitsPerWindow int64 `json:"max_jackpot_hits_per_window"`
MaxSinglePayout int64 `json:"max_single_payout"`
UserHourlyPayoutCap int64 `json:"user_hourly_payout_cap"`
UserDailyPayoutCap int64 `json:"user_daily_payout_cap"`
DeviceDailyPayoutCap int64 `json:"device_daily_payout_cap"`
RoomHourlyPayoutCap int64 `json:"room_hourly_payout_cap"`
AnchorDailyPayoutCap int64 `json:"anchor_daily_payout_cap"`
Stages []RuleStage `json:"stages"`
}
type CheckCommand struct {

View File

@ -23,6 +23,7 @@ const (
StrategyReasonRTPCompensationJackpot = "rtp_compensation_jackpot"
StrategyReasonMilestoneTokenRetained = "milestone_token_retained_no_payable_jackpot"
StrategyReasonDailyJackpotLimit = "daily_jackpot_limit"
StrategyReasonJackpotRateLimit = "jackpot_rate_limit"
StrategyReasonPoolInsufficient = "pool_insufficient_w_gt_p"
StrategyReasonRiskCapacity = "risk_capacity_exceeded"
StrategyReasonNoPayableJackpot = "no_payable_jackpot_candidate"
@ -38,6 +39,7 @@ const (
StrategyConditionGlobalRTP = "global_rtp"
StrategyConditionSpendGlobalRTP = "spend_global_rtp"
StrategyConditionSpendDailyJackpotLimit = "spend_daily_jackpot_limit"
StrategyConditionJackpotRateLimit = "jackpot_rate_limit"
StrategyConditionSettledRound = "settled_round_qualification"
StrategyConditionUserRoundRTP = "user_round_rtp"
StrategyConditionUser48HourRTP = "user_48h_rtp"
@ -112,7 +114,10 @@ type StrategyRechargeStage struct {
// StrategyConfig contains only immutable rule-version data. Runtime counters,
// pool money and persisted milestone tokens live in StrategyState instead.
type StrategyConfig struct {
Tiers []StrategyTier `json:"tiers"`
// RuleVersion binds durable spend tokens to the immutable rule that minted them. Zero keeps the
// isolated legacy kernel source-compatible; production dynamic_v3 always supplies a positive version.
RuleVersion int64 `json:"rule_version"`
Tiers []StrategyTier `json:"tiers"`
PublicPoolRatePPM int64 `json:"public_pool_rate_ppm"`
ProfitPoolRatePPM int64 `json:"profit_pool_rate_ppm"`
@ -131,9 +136,11 @@ type StrategyConfig struct {
User24HourRTPThresholdPPM int64 `json:"user_24h_rtp_threshold_ppm"`
User24HourOrdinaryWinFactorPPM int64 `json:"user_24h_ordinary_win_factor_ppm"`
MissProtectionZeroDraws int64 `json:"miss_protection_zero_draws"`
DailyJackpotLimit int64 `json:"daily_jackpot_limit"`
MilestoneSpendCoins int64 `json:"milestone_spend_coins"`
MissProtectionZeroDraws int64 `json:"miss_protection_zero_draws"`
DailyJackpotLimit int64 `json:"daily_jackpot_limit"`
MilestoneSpendCoins int64 `json:"milestone_spend_coins"`
JackpotRateLimitWindowMS int64 `json:"jackpot_rate_limit_window_ms"`
JackpotRateLimit int64 `json:"jackpot_rate_limit"`
JackpotMechanism1Enabled bool `json:"jackpot_mechanism_1_enabled"`
JackpotMechanism2Enabled bool `json:"jackpot_mechanism_2_enabled"`
@ -169,8 +176,14 @@ type StrategyState struct {
// a jackpot promise after the dual-strategy rollout.
PendingMilestoneTokens int64 `json:"pending_milestone_tokens"`
PendingSpendJackpotTokens int64 `json:"pending_spend_jackpot_tokens"`
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
// PendingSpendJackpotRuleVersion makes a token promise meaningful only under the immutable rule
// that created it. A rule switch expires the old backlog instead of repricing it at a new threshold.
PendingSpendJackpotRuleVersion int64 `json:"pending_spend_jackpot_rule_version"`
UserDailyJackpotWins int64 `json:"user_daily_jackpot_wins"`
// RecentJackpotWins is the app+pool global count in the configured rolling window, loaded while
// the pool row is locked and advanced after each subdraw in the current batch.
RecentJackpotWins int64 `json:"recent_jackpot_wins"`
UserDaySpendCoins int64 `json:"user_day_spend_coins"`
// SpendGlobalRTP is the latest fully closed platform window observed before this subdraw. It is
// deliberately independent from GlobalRTP, which is frozen with a user's one-shot 48h qualification.
SpendGlobalRTP StrategyRTP `json:"spend_global_rtp"`
@ -557,12 +570,16 @@ func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input
Name: StrategyConditionSpendDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit,
Passed: dailyPassed, Reason: boolReason(dailyPassed, "below_limit", StrategyReasonDailyJackpotLimit),
})
if !globalPassed || !dailyPassed {
rateCondition, ratePassed := jackpotRateLimitCondition(config, state)
trace.Conditions = append(trace.Conditions, rateCondition)
if !globalPassed || !dailyPassed || !ratePassed {
trace.MilestoneTokenRetained = true
if trace.BlockedReason == "" {
trace.BlockedReason = StrategyReasonSpendGlobalRTPBlocked
if globalPassed {
if globalPassed && !dailyPassed {
trace.BlockedReason = StrategyReasonDailyJackpotLimit
} else if globalPassed && dailyPassed {
trace.BlockedReason = StrategyReasonJackpotRateLimit
}
}
return StrategyDecision{}, false, nil
@ -591,13 +608,32 @@ func trySpendMilestoneJackpot(config StrategyConfig, state StrategyState, input
func pendingSpendJackpotTokens(config StrategyConfig, state StrategyState) int64 {
if config.SettledRoundRTPEligibility {
if config.RuleVersion > 0 && state.PendingSpendJackpotRuleVersion != config.RuleVersion {
return 0
}
return state.PendingSpendJackpotTokens
}
return state.PendingMilestoneTokens
}
// jackpotRateLimitCondition is shared by both jackpot mechanisms. A 0/0 pair is the rolling-upgrade
// sentinel for immutable rules published before this gate existed; new drafts carry an explicit window.
func jackpotRateLimitCondition(config StrategyConfig, state StrategyState) (StrategyConditionTrace, bool) {
enabled := config.JackpotRateLimitWindowMS > 0 && config.JackpotRateLimit > 0
passed := !enabled || state.RecentJackpotWins < config.JackpotRateLimit
reason := "disabled_for_historical_rule"
if enabled {
reason = boolReason(passed, "below_limit", StrategyReasonJackpotRateLimit)
}
return StrategyConditionTrace{
Name: StrategyConditionJackpotRateLimit, Numerator: state.RecentJackpotWins,
Denominator: config.JackpotRateLimitWindowMS, LimitPPM: config.JackpotRateLimit,
Passed: passed, Reason: reason,
}, passed
}
func validateStrategy(config StrategyConfig, state StrategyState, input StrategyInput, requireTiers bool) error {
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 0 || state.UserDailyJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
if state.PoolBalanceCoins < 0 || state.ConsecutiveZeroDraws < 0 || state.PendingMilestoneTokens < 0 || state.PendingSpendJackpotTokens < 0 || state.PendingSpendJackpotRuleVersion < 0 || state.UserDailyJackpotWins < 0 || state.RecentJackpotWins < 0 || state.UserDaySpendCoins < 0 || state.Version < 0 {
return fmt.Errorf("%w: state counters and money cannot be negative", ErrStrategyInput)
}
for _, rtp := range []StrategyRTP{state.SpendGlobalRTP, state.GlobalRTP, state.UserRoundRTP, state.User48HourRTP, state.User24HourRTP, state.UserDayRTP, state.User72HourRTP} {
@ -622,6 +658,9 @@ func validateStrategy(config StrategyConfig, state StrategyState, input Strategy
if config.RechargeBoostStartMS < 0 || config.RechargeBoostEndMS <= config.RechargeBoostStartMS || config.MissProtectionZeroDraws < 0 || config.DailyJackpotLimit < 0 || config.MilestoneSpendCoins < 0 || config.ColdStartPoolCoins < 0 {
return fmt.Errorf("%w: recharge window, miss protection or daily limit is invalid", ErrStrategyConfig)
}
if (config.JackpotRateLimitWindowMS == 0) != (config.JackpotRateLimit == 0) || config.JackpotRateLimitWindowMS < 0 || config.JackpotRateLimit < 0 {
return fmt.Errorf("%w: jackpot rolling-window duration and hit limit must both be zero or positive", ErrStrategyConfig)
}
if config.GlobalRTPMaxPPM < 0 || config.GlobalRTPMaxPPM > StrategyPPMScale ||
config.UserRoundRTPMaxPPM < 0 || config.UserRoundRTPMaxPPM > StrategyPPMScale ||
config.User48HourRTPMaxPPM < 0 || config.User48HourRTPMaxPPM > StrategyPPMScale ||
@ -873,7 +912,7 @@ func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]Strat
{name: StrategyConditionUserDayRTP, rtp: state.UserDayRTP, limit: config.UserDayRTPMaxPPM},
{name: StrategyConditionUser72HourRTP, rtp: state.User72HourRTP, limit: config.User72HourRTPMaxPPM},
}
traces := make([]StrategyConditionTrace, 0, len(checks)+1)
traces := make([]StrategyConditionTrace, 0, len(checks)+2)
eligible := true
for _, check := range checks {
ratio, valid := check.rtp.RatioPPM()
@ -889,11 +928,13 @@ func mechanismOneConditions(config StrategyConfig, state StrategyState) ([]Strat
}
limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)})
return traces, eligible && limitPassed
rateCondition, ratePassed := jackpotRateLimitCondition(config, state)
traces = append(traces, rateCondition)
return traces, eligible && limitPassed && ratePassed
}
func settledRoundJackpotConditions(config StrategyConfig, state StrategyState) ([]StrategyConditionTrace, bool) {
traces := make([]StrategyConditionTrace, 0, 6)
traces := make([]StrategyConditionTrace, 0, 7)
pending := state.SettledRoundPending
traces = append(traces, StrategyConditionTrace{
Name: StrategyConditionSettledRound, Passed: pending,
@ -932,7 +973,9 @@ func settledRoundJackpotConditions(config StrategyConfig, state StrategyState) (
limitPassed := state.UserDailyJackpotWins < config.DailyJackpotLimit
traces = append(traces, StrategyConditionTrace{Name: StrategyConditionDailyJackpotLimit, Numerator: state.UserDailyJackpotWins, LimitPPM: config.DailyJackpotLimit, Passed: limitPassed, Reason: boolReason(limitPassed, "below_limit", StrategyReasonDailyJackpotLimit)})
return traces, eligible && limitPassed
rateCondition, ratePassed := jackpotRateLimitCondition(config, state)
traces = append(traces, rateCondition)
return traces, eligible && limitPassed && ratePassed
}
func rtpConditionTrace(name string, rtp StrategyRTP, ratio, limit int64, valid, passed bool) StrategyConditionTrace {
@ -975,6 +1018,9 @@ func selectPayableJackpot(config StrategyConfig, state StrategyState, input Stra
if state.UserDailyJackpotWins >= config.DailyJackpotLimit {
return StrategyTier{}, 0, false, StrategyReasonDailyJackpotLimit, nil, nil
}
if _, passed := jackpotRateLimitCondition(config, state); !passed {
return StrategyTier{}, 0, false, StrategyReasonJackpotRateLimit, nil, nil
}
candidates := make([]weightedStrategyTier, 0, len(config.Tiers))
removed := make([]StrategyRemovalTrace, 0, len(config.Tiers))
for _, tier := range config.Tiers {
@ -1138,6 +1184,7 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
jackpot := payout > 0 && tier.Jackpot
if jackpot {
next.UserDailyJackpotWins++
next.RecentJackpotWins++
}
var err error
previousDaySpend := next.UserDaySpendCoins
@ -1153,6 +1200,9 @@ func finalizeStrategyDecision(config StrategyConfig, state StrategyState, input
}
if earnedTokens > 0 {
if config.SettledRoundRTPEligibility {
// Production tokens are repriced only by the immutable rule that creates them. The repository
// expires a prior version before decision; assigning here makes newly earned tokens auditable.
next.PendingSpendJackpotRuleVersion = config.RuleVersion
next.PendingSpendJackpotTokens, err = safeAdd(next.PendingSpendJackpotTokens, earnedTokens)
} else {
next.PendingMilestoneTokens, err = safeAdd(next.PendingMilestoneTokens, earnedTokens)

View File

@ -49,6 +49,10 @@ func DefaultRuleConfig(appCode, poolID string) domain.RuleConfig {
JackpotUserDayRTPMaxPPM: 960_000,
JackpotUser72hRTPMaxPPM: 960_000,
MaxJackpotHitsPerUserDay: 5,
// 新草稿默认开启奖池级滚动爆奖节流;运营可按 App 风险预算调整,但不能用 0/0
// 发布新的 dynamic_v3。历史不可变版本仍以数据库中的 0/0 sentinel 保持原语义。
JackpotRateLimitWindowMinutes: 10,
MaxJackpotHitsPerWindow: 3,
// 用户阶段按累计金币流水折算的等价抽数推进;后台仍看到“抽数”,运行侧不会再被不同价格礼物误导。
NoviceMaxEquivalentDraws: 2_000,
NormalMaxEquivalentDraws: 20_000,
@ -280,6 +284,9 @@ func validateDynamicRuleConfig(config domain.RuleConfig, stages map[string]domai
if config.MaxJackpotHitsPerUserDay <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 daily jackpot hit cap must be positive")
}
if config.JackpotRateLimitWindowMinutes <= 0 || config.JackpotRateLimitWindowMinutes > 24*60 || config.MaxJackpotHitsPerWindow <= 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot rolling window must be 1-1440 minutes and its hit cap must be positive")
}
if config.JackpotSpendThresholdCoins < 0 {
return xerr.New(xerr.InvalidArgument, "dynamic_v3 jackpot spend threshold coins cannot be negative")
}

View File

@ -89,6 +89,12 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil {
return nil, err
}
if userState.PendingSpendJackpotRuleVersion != config.RuleVersion {
// 旧行 version=0 与任何历史版本 backlog 都不能进入新规则。只在本次用户主键事务中
// 懒清零,避免发布时全表 UPDATE若本抽重新跨过阈值会立刻生成当前版本 token。
userState.PendingSpendJackpotTokens = 0
userState.PendingSpendJackpotRuleVersion = config.RuleVersion
}
windowScopeID := luckyRuleWindowScopeID(config)
globalWindow, err := r.getOpenLuckyDynamicRTPWindow(ctx, tx, appCode, "pool", windowScopeID, config.SettlementWindowWager, config.GlobalWindowDraws, config.TargetRTPPPM, paidAtMS, nowMS)
if err != nil {
@ -109,11 +115,23 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
if err != nil {
return nil, err
}
var recentJackpotWins int64
if config.JackpotRateLimitWindowMinutes > 0 || config.MaxJackpotHitsPerWindow > 0 {
if config.JackpotRateLimitWindowMinutes <= 0 || config.MaxJackpotHitsPerWindow <= 0 {
return nil, xerr.New(xerr.Internal, "dynamic lucky gift jackpot rate limit is incomplete")
}
recentJackpotWins, err = r.countLuckyJackpotHitsInWindow(
ctx, tx, appCode, config.PoolID, paidAtMS, config.JackpotRateLimitWindowMinutes,
)
if err != nil {
return nil, err
}
}
riskCounters, err := r.getLuckyDynamicRiskCounters(ctx, tx, appCode, config.PoolID, cmd, nowMS)
if err != nil {
return nil, err
}
dayState, err := r.getOrCreateLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, cmd.UserID, cmd, nowMS)
dayState, err := r.getOrCreateLuckyUserStrategyDay(ctx, tx, appCode, config.PoolID, config.RuleVersion, cmd.UserID, cmd, nowMS)
if err != nil {
return nil, err
}
@ -175,13 +193,15 @@ func (r *Repository) executeDynamicLuckyGiftDrawBatch(ctx context.Context, tx *s
ConsecutiveZeroDraws: userState.LossStreak,
// The old pending_jackpot_tokens column is intentionally not copied. It is cleared in the same
// transaction while only the new column can authorize the restored spend strategy.
PendingSpendJackpotTokens: userState.PendingSpendJackpotTokens,
UserDailyJackpotWins: dayState.JackpotHits,
UserDaySpendCoins: dayState.SpendCoins,
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User24HourRTP: rolling24HourRTP,
Version: userState.PaidDraws,
PendingSpendJackpotTokens: userState.PendingSpendJackpotTokens,
PendingSpendJackpotRuleVersion: userState.PendingSpendJackpotRuleVersion,
RecentJackpotWins: recentJackpotWins,
UserDailyJackpotWins: dayState.JackpotHits,
UserDaySpendCoins: dayState.SpendCoins,
UserRegisteredAtMS: cmd.UserRegisteredAtMS,
UserDayRTP: domain.StrategyRTP{WagerCoins: dayState.WagerCoins, PayoutCoins: dayState.PayoutCoins},
User24HourRTP: rolling24HourRTP,
Version: userState.PaidDraws,
},
}
if hasSpendGlobalWindow {
@ -404,6 +424,7 @@ func (r *Repository) stageLuckyDynamicDecision(appCode string, config domain.Con
state.UserState.LossStreak = decision.NextState.ConsecutiveZeroDraws
state.UserState.PendingJackpotTokens = 0
state.UserState.PendingSpendJackpotTokens = decision.NextState.PendingSpendJackpotTokens
state.UserState.PendingSpendJackpotRuleVersion = decision.NextState.PendingSpendJackpotRuleVersion
state.DayState.WagerCoins += cmd.CoinSpent
state.DayState.PayoutCoins += decision.PayoutCoins
state.DayState.SpendCoins = decision.NextState.UserDaySpendCoins
@ -495,6 +516,7 @@ func luckySettledRoundConditionsPassed(trace domain.DecisionTrace) bool {
domain.StrategyConditionUser48HourMaturity: false,
domain.StrategyConditionUser48HourRTP: false,
domain.StrategyConditionDailyJackpotLimit: false,
domain.StrategyConditionJackpotRateLimit: false,
}
seen := make(map[string]bool, len(required))
for _, condition := range trace.Conditions {
@ -547,10 +569,12 @@ func (r *Repository) persistLuckyDynamicBatch(ctx context.Context, tx *sql.Tx, a
if _, err := tx.ExecContext(ctx, `
UPDATE lucky_user_states
SET paid_draws = ?, cumulative_wager_coins = ?, equivalent_draws = ?, loss_streak = ?,
pending_jackpot_tokens = 0, pending_spend_jackpot_tokens = ?, updated_at_ms = ?
pending_jackpot_tokens = 0, pending_spend_jackpot_tokens = ?,
pending_spend_jackpot_rule_version = ?, updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
state.UserState.PaidDraws, state.UserState.CumulativeWagerCoins, state.UserState.EquivalentDraws, state.UserState.LossStreak,
state.UserState.PendingSpendJackpotTokens, nowMS, appCode, cmd.UserID, config.GiftID,
state.UserState.PendingSpendJackpotTokens, state.UserState.PendingSpendJackpotRuleVersion,
nowMS, appCode, cmd.UserID, config.GiftID,
); err != nil {
return err
}

View File

@ -21,11 +21,12 @@ type luckyDynamicRiskCounter struct {
}
type luckyUserStrategyDay struct {
StatDay string
WagerCoins int64
PayoutCoins int64
SpendCoins int64
JackpotHits int64
StatDay string
WagerCoins int64
PayoutCoins int64
SpendCoins int64
SpendRuleVersion int64
JackpotHits int64
}
type luckyUserRTPHour struct {
@ -260,34 +261,62 @@ func luckyDynamicRiskCounterKey(scopeType, windowType string) string {
return scopeType + ":" + windowType
}
func (r *Repository) getOrCreateLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserStrategyDay, error) {
// countLuckyJackpotHitsInWindow reads the bounded app+pool jackpot ledger after the caller has
// locked the pool row. That shared lock serializes competing draw decisions, while the composite
// time index keeps the query proportional to jackpots inside this short window rather than draws.
func (r *Repository) countLuckyJackpotHitsInWindow(ctx context.Context, tx *sql.Tx, appCode, poolID string, endMS, windowMinutes int64) (int64, error) {
if windowMinutes <= 0 || windowMinutes > 24*60 {
return 0, fmt.Errorf("lucky jackpot rate window minutes must be within 1..1440")
}
windowMS := windowMinutes * int64(time.Minute/time.Millisecond)
startMS := endMS - windowMS
if startMS < 0 {
startMS = 0
}
var count int64
err := tx.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM lucky_gift_user_jackpot_hits FORCE INDEX (idx_lucky_jackpot_rate_window)
WHERE app_code = ? AND pool_id = ? AND occurred_at_ms >= ? AND occurred_at_ms < ?`,
appCode, poolID, startMS, luckyExclusiveEndMS(endMS),
).Scan(&count)
return count, err
}
func (r *Repository) getOrCreateLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, ruleVersion, userID int64, cmd domain.DrawCommand, nowMS int64) (luckyUserStrategyDay, error) {
day := luckyPaidTime(cmd, nowMS).Format("2006-01-02")
state := luckyUserStrategyDay{StatDay: day}
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_strategy_days (
app_code, pool_id, user_id, stat_day, wager_coins, payout_coins, spend_coins,
jackpot_hits, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, 0, 0, 0, ?, ?)
spend_rule_version, jackpot_hits, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, 0, 0, 0, ?, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, poolID, userID, day, nowMS, nowMS,
appCode, poolID, userID, day, ruleVersion, nowMS, nowMS,
); err != nil {
return luckyUserStrategyDay{}, err
}
err := tx.QueryRowContext(ctx, `
SELECT wager_coins, payout_coins, spend_coins, jackpot_hits
SELECT wager_coins, payout_coins, spend_coins, spend_rule_version, jackpot_hits
FROM lucky_user_strategy_days
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?
FOR UPDATE`, appCode, poolID, userID, day,
).Scan(&state.WagerCoins, &state.PayoutCoins, &state.SpendCoins, &state.JackpotHits)
).Scan(&state.WagerCoins, &state.PayoutCoins, &state.SpendCoins, &state.SpendRuleVersion, &state.JackpotHits)
if err == nil && state.SpendRuleVersion != ruleVersion {
// 每日累计消费只服务于产生当前规则版本 token。规则发布后清零旧版本进度但保留
// wager/payout/jackpot_hits防止换版本同时重置用户 RTP 与当天大奖额度。
state.SpendCoins = 0
state.SpendRuleVersion = ruleVersion
}
return state, err
}
func (r *Repository) persistLuckyUserStrategyDay(ctx context.Context, tx *sql.Tx, appCode, poolID string, userID int64, state luckyUserStrategyDay, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
UPDATE lucky_user_strategy_days
SET wager_coins = ?, payout_coins = ?, spend_coins = ?, jackpot_hits = ?, updated_at_ms = ?
SET wager_coins = ?, payout_coins = ?, spend_coins = ?, spend_rule_version = ?, jackpot_hits = ?, updated_at_ms = ?
WHERE app_code = ? AND pool_id = ? AND user_id = ? AND stat_day = ?`,
state.WagerCoins, state.PayoutCoins, state.SpendCoins, state.JackpotHits, nowMS,
state.WagerCoins, state.PayoutCoins, state.SpendCoins, state.SpendRuleVersion, state.JackpotHits, nowMS,
appCode, poolID, userID, state.StatDay,
)
return err

View File

@ -5,6 +5,7 @@ import (
"fmt"
"math/big"
"sort"
"time"
domain "hyapp/services/lucky-gift-service/internal/domain/luckygift"
)
@ -117,6 +118,7 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
tiers = append(tiers, jackpotByMultiplier[multiplier])
}
return domain.StrategyConfig{
RuleVersion: config.RuleVersion,
Tiers: tiers,
PublicPoolRatePPM: config.PoolRatePPM,
ProfitPoolRatePPM: config.ProfitRatePPM,
@ -134,6 +136,8 @@ func luckyDynamicStrategyConfig(config domain.Config) domain.StrategyConfig {
MissProtectionZeroDraws: config.LossStreakGuarantee,
DailyJackpotLimit: config.MaxJackpotHitsPerUserDay,
MilestoneSpendCoins: config.JackpotSpendThresholdCoins,
JackpotRateLimitWindowMS: config.JackpotRateLimitWindowMinutes * int64(time.Minute/time.Millisecond),
JackpotRateLimit: config.MaxJackpotHitsPerWindow,
JackpotMechanism1Enabled: true,
JackpotMechanism2Enabled: config.JackpotSpendThresholdCoins > 0,
GlobalRTPMaxPPM: config.JackpotGlobalRTPMaxPPM,

View File

@ -84,9 +84,10 @@ type luckyUserState struct {
// PendingJackpotTokens is the retired token column. Dynamic V3 reads it only so the next paid
// transaction can clear it; its value must never become a new spend jackpot qualification.
PendingJackpotTokens int64
// PendingSpendJackpotTokens belongs to the app/pool/user lifetime. UTC-day spend creates tokens,
// but a blocked token survives midnight until one spend jackpot is actually paid.
PendingSpendJackpotTokens int64
// PendingSpendJackpotTokens belongs to one immutable lucky-gift rule version. A token blocked by
// risk controls can cross UTC midnight, but publishing a new rule invalidates the old backlog.
PendingSpendJackpotTokens int64
PendingSpendJackpotRuleVersion int64
}
type luckyCandidate struct {
@ -1044,7 +1045,7 @@ func luckyDrawStatDay(ms int64) string {
return time.UnixMilli(ms).In(luckyDayStatZone).Format("2006-01-02")
}
// ListLuckyGiftDailyStats 只读 lucky_draw_pool_day_stats 的奖池汇总行gift_id=''
// ListLuckyGiftDailyStats 只读 lucky_draw_pool_day_stats 的奖池汇总行gift_id=
// 不触碰抽奖事实表;数据新鲜度受后台刷新游标节奏限制,不保证到秒级实时。
func (r *Repository) ListLuckyGiftDailyStats(ctx context.Context, query domain.DailyStatsQuery) ([]domain.DailyStat, error) {
if r == nil || r.db == nil {
@ -2544,6 +2545,8 @@ func luckyRuntimeConfigFromRuleConfig(ruleConfig domain.RuleConfig) (domain.Conf
SettledRoundRTPEligibility: true,
JackpotSpendThresholdCoins: ruleConfig.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: ruleConfig.MaxJackpotHitsPerUserDay,
JackpotRateLimitWindowMinutes: ruleConfig.JackpotRateLimitWindowMinutes,
MaxJackpotHitsPerWindow: ruleConfig.MaxJackpotHitsPerWindow,
MaxSinglePayout: ruleConfig.MaxSinglePayout,
UserHourlyPayoutCap: ruleConfig.UserHourlyPayoutCap,
UserDailyPayoutCap: ruleConfig.UserDailyPayoutCap,
@ -2622,12 +2625,12 @@ func (r *Repository) getLuckyUserState(ctx context.Context, appCode string, user
var state luckyUserState
err := r.db.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
pending_jackpot_tokens, pending_spend_jackpot_tokens
pending_jackpot_tokens, pending_spend_jackpot_tokens, pending_spend_jackpot_rule_version
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens)
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens, &state.PendingSpendJackpotRuleVersion)
if errors.Is(err, sql.ErrNoRows) {
return luckyUserState{}, nil
}
@ -2640,8 +2643,8 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
if _, err := tx.ExecContext(ctx, `
INSERT INTO lucky_user_states (
app_code, user_id, gift_id, paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
pending_jackpot_tokens, pending_spend_jackpot_tokens, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, ?, ?)
pending_jackpot_tokens, pending_spend_jackpot_tokens, pending_spend_jackpot_rule_version, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, 0, 0, 0, 0, 0, 0, 0, ?, ?)
ON DUPLICATE KEY UPDATE updated_at_ms = updated_at_ms`,
appCode, userID, giftID, nowMS, nowMS,
); err != nil {
@ -2650,13 +2653,13 @@ func (r *Repository) getLuckyUserStateForUpdate(ctx context.Context, tx *sql.Tx,
var state luckyUserState
err := tx.QueryRowContext(ctx, `
SELECT paid_draws, cumulative_wager_coins, equivalent_draws, loss_streak,
pending_jackpot_tokens, pending_spend_jackpot_tokens
pending_jackpot_tokens, pending_spend_jackpot_tokens, pending_spend_jackpot_rule_version
FROM lucky_user_states
WHERE app_code = ? AND user_id = ? AND gift_id = ?
FOR UPDATE`,
appCode, userID, giftID,
).Scan(&state.PaidDraws, &state.CumulativeWagerCoins, &state.EquivalentDraws, &state.LossStreak,
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens)
&state.PendingJackpotTokens, &state.PendingSpendJackpotTokens, &state.PendingSpendJackpotRuleVersion)
return state, err
}

View File

@ -133,9 +133,10 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
jackpot_user_day_rtp_max_ppm, jackpot_user_round_rtp_max_ppm,
jackpot_user_72h_rtp_max_ppm, jackpot_user_48h_rtp_max_ppm,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
jackpot_rate_limit_window_minutes, max_jackpot_hits_per_window,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
config.AppCode, config.PoolID, config.RuleVersion, config.Enabled, config.StrategyVersion, config.TargetRTPPPM, config.PoolRatePPM,
config.ProfitRatePPM, config.AnchorRatePPM,
config.SettlementWindowWager, config.ControlBandPPM, config.GiftPriceReference,
@ -151,6 +152,7 @@ func (r *Repository) publishLuckyGiftRuleConfigTx(ctx context.Context, tx *sql.T
config.JackpotUserDayRTPMaxPPM, config.JackpotUserRoundRTPMaxPPM,
config.JackpotUser72hRTPMaxPPM, config.JackpotUser48hRTPMaxPPM,
config.JackpotSpendThresholdCoins, config.MaxJackpotHitsPerUserDay,
config.JackpotRateLimitWindowMinutes, config.MaxJackpotHitsPerWindow,
config.MaxSinglePayout, config.UserHourlyPayoutCap, config.UserDailyPayoutCap,
config.DeviceDailyPayoutCap, config.RoomHourlyPayoutCap, config.AnchorDailyPayoutCap,
); err != nil {
@ -219,6 +221,7 @@ func (r *Repository) ListLuckyGiftRuleConfigs(ctx context.Context, appCode strin
CASE WHEN v.jackpot_user_48h_rtp_max_ppm = 0 AND v.jackpot_user_72h_rtp_max_ppm <> 0
THEN v.jackpot_user_72h_rtp_max_ppm ELSE v.jackpot_user_48h_rtp_max_ppm END,
v.jackpot_spend_threshold_coins, v.max_jackpot_hits_per_user_day,
v.jackpot_rate_limit_window_minutes, v.max_jackpot_hits_per_window,
v.max_single_payout, v.user_hourly_payout_cap, v.user_daily_payout_cap,
v.device_daily_payout_cap, v.room_hourly_payout_cap, v.anchor_daily_payout_cap
FROM lucky_gift_rule_versions v
@ -358,6 +361,7 @@ const luckyRuleConfigSelectSQL = `
CASE WHEN jackpot_user_48h_rtp_max_ppm = 0 AND jackpot_user_72h_rtp_max_ppm <> 0
THEN jackpot_user_72h_rtp_max_ppm ELSE jackpot_user_48h_rtp_max_ppm END,
jackpot_spend_threshold_coins, max_jackpot_hits_per_user_day,
jackpot_rate_limit_window_minutes, max_jackpot_hits_per_window,
max_single_payout, user_hourly_payout_cap, user_daily_payout_cap,
device_daily_payout_cap, room_hourly_payout_cap, anchor_daily_payout_cap
FROM lucky_gift_rule_versions
@ -499,6 +503,8 @@ func scanLuckyGiftRuleConfig(scanner luckyScanner) (domain.RuleConfig, error) {
&config.JackpotUser48hRTPMaxPPM,
&config.JackpotSpendThresholdCoins,
&config.MaxJackpotHitsPerUserDay,
&config.JackpotRateLimitWindowMinutes,
&config.MaxJackpotHitsPerWindow,
&config.MaxSinglePayout,
&config.UserHourlyPayoutCap,
&config.UserDailyPayoutCap,

View File

@ -482,7 +482,10 @@ func (r *Repository) syncLuckyGiftUserProfileSortState(ctx context.Context, tx *
p.jackpot_count = p.rtp_compensation_jackpot_count + p.cumulative_spend_jackpot_count,
p.current_equivalent_draws = COALESCE(s.equivalent_draws, 0),
p.current_loss_streak = COALESCE(s.loss_streak, 0),
p.current_pending_spend_jackpot_tokens = COALESCE(s.pending_spend_jackpot_tokens, 0)
p.current_pending_spend_jackpot_tokens = CASE
WHEN s.pending_spend_jackpot_rule_version = p.rule_version THEN COALESCE(s.pending_spend_jackpot_tokens, 0)
ELSE 0
END
WHERE p.app_code = ? AND p.pool_id = ? AND p.strategy_version = ?
AND p.identity_type = ? AND p.identity_key = ?`,
key.AppCode, key.PoolID, key.StrategyVersion, key.IdentityType, key.IdentityKey)
@ -862,6 +865,11 @@ func applyLuckyGiftUserProfileCurrentControls(rule domain.RuleConfig, profiles [
}
for index := range profiles {
profile := &profiles[index]
if profile.RuleVersion != rule.RuleVersion {
// 列表聚合行可能停留在用户最后一次开奖版本。当前规则已切换时不展示旧 token
// 与下一次抽奖在用户状态锁内执行的懒过期语义保持一致。
profile.PendingSpendJackpotTokens = 0
}
profile.DownweightFactorPPM = luckyPPMScale
profile.User24HourRTPThresholdPPM = rule.User24HourRTPThresholdPPM
if rule.LossStreakGuarantee > profile.LossStreak {

View File

@ -627,6 +627,8 @@ func luckyRuleConfigFromProto(config *luckygiftv1.LuckyGiftRuleConfig) domain.Ru
JackpotUser48hRTPMaxPPM: config.GetJackpotUser_48HRtpMaxPpm(),
JackpotSpendThresholdCoins: config.GetJackpotSpendThresholdCoins(),
MaxJackpotHitsPerUserDay: config.GetMaxJackpotHitsPerUserDay(),
JackpotRateLimitWindowMinutes: config.GetJackpotRateLimitWindowMinutes(),
MaxJackpotHitsPerWindow: config.GetMaxJackpotHitsPerWindow(),
MaxSinglePayout: config.GetMaxSinglePayout(),
UserHourlyPayoutCap: config.GetUserHourlyPayoutCap(),
UserDailyPayoutCap: config.GetUserDailyPayoutCap(),
@ -703,6 +705,8 @@ func luckyRuleConfigToProto(config domain.RuleConfig) *luckygiftv1.LuckyGiftRule
JackpotUser_48HRtpMaxPpm: rolling48hRTPMaxPPM,
JackpotSpendThresholdCoins: config.JackpotSpendThresholdCoins,
MaxJackpotHitsPerUserDay: config.MaxJackpotHitsPerUserDay,
JackpotRateLimitWindowMinutes: config.JackpotRateLimitWindowMinutes,
MaxJackpotHitsPerWindow: config.MaxJackpotHitsPerWindow,
MaxSinglePayout: config.MaxSinglePayout,
UserHourlyPayoutCap: config.UserHourlyPayoutCap,
UserDailyPayoutCap: config.UserDailyPayoutCap,

View File

@ -16,6 +16,7 @@ type cpApplication struct {
Target cpUser
RoomID string
RoomRegionID int64
RoomLocked bool
Gift cpGift
ExpiresAtMS int64
DecidedAtMS int64
@ -61,6 +62,7 @@ func applicationFromObject(obj map[string]any) (cpApplication, error) {
Target: userFromObject(firstObject(obj, "target", "Target")),
RoomID: stringValue(obj, "room_id", "RoomID"),
RoomRegionID: int64Value(obj, "room_region_id", "RoomRegionID"),
RoomLocked: boolValue(obj, "room_locked", "RoomLocked"),
Gift: giftFromObject(firstObject(obj, "gift", "Gift")),
ExpiresAtMS: int64Value(obj, "expires_at_ms", "ExpiresAtMS"),
DecidedAtMS: int64Value(obj, "decided_at_ms", "DecidedAtMS"),
@ -151,3 +153,22 @@ func int64Value(obj map[string]any, keys ...string) int64 {
}
return 0
}
func boolValue(obj map[string]any, keys ...string) bool {
for _, key := range keys {
switch value := obj[key].(type) {
case bool:
return value
case float64:
return value != 0
case int64:
return value != 0
case int:
return value != 0
case string:
parsed, _ := strconv.ParseBool(strings.TrimSpace(value))
return parsed
}
}
return false
}

View File

@ -516,6 +516,7 @@ func cpApplicationPayload(message usermq.UserOutboxMessage, application cpApplic
"target": userPayload(application.Target),
"room_id": application.RoomID,
"room_region_id": application.RoomRegionID,
"room_locked": application.RoomLocked,
"gift": giftPayload(application.Gift),
"expires_at_ms": application.ExpiresAtMS,
"decided_at_ms": application.DecidedAtMS,
@ -540,6 +541,7 @@ func cpRelationshipBroadcastPayload(message usermq.UserOutboxMessage, applicatio
"gift": giftPayload(application.Gift),
"room_id": application.RoomID,
"room_region_id": application.RoomRegionID,
"room_locked": application.RoomLocked,
"group_id": groupID,
"source_created_at_ms": message.OccurredAtMS,
}, nil

View File

@ -189,6 +189,10 @@ func TestProcessRelationshipCreatedPublishesRegionIM(t *testing.T) {
if len(publisher.groupMessages) != 2 || publisher.groupMessages[0].GroupID != "test_hy_lalu_bc_r_v2_86" {
t.Fatalf("expected region group message, got %+v", publisher.groupMessages)
}
var relationshipPayload map[string]any
if err := json.Unmarshal(publisher.groupMessages[0].PayloadJSON, &relationshipPayload); err != nil || relationshipPayload["room_locked"] != true {
t.Fatalf("relationship broadcast must carry room lock snapshot: payload=%+v err=%v", relationshipPayload, err)
}
if publisher.groupMessages[1].GroupID != "test_hy_lalu_bc_r_86" || publisher.groupMessages[1].EventID != "cp_relationship:created:1002:1_legacy" {
t.Fatalf("expected legacy region copy, got %+v", publisher.groupMessages[1])
}
@ -283,6 +287,7 @@ func applicationPayloadJSON() string {
"Target":{"UserID":1002,"DisplayUserID":"163002","Username":"Bob","Avatar":"https://cdn.example/b.png"},
"RoomID":"room-100",
"RoomRegionID":86,
"RoomLocked":true,
"Gift":{"GiftID":"cp_ring","GiftName":"CP Ring","GiftIconURL":"https://cdn.example/gift.png","GiftAnimationURL":"","GiftCount":1,"GiftValue":100,"BillingReceiptID":"tx-1"},
"ExpiresAtMS":1700003600000,
"DecidedAtMS":0

View File

@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"testing"
"time"
@ -135,7 +136,7 @@ func TestMySQLRepositoryProcessesRealRoomKickOutbox(t *testing.T) {
if noticePayload["event_id"] != eventID || noticePayload["room_id"] != roomID || noticePayload["event_type"] != "room_user_kicked" {
t.Fatalf("unexpected stored notice payload: %+v", noticePayload)
}
if noticePayload["target_user_id"].(float64) != float64(targetUserID) || noticePayload["actor_user_id"].(float64) != float64(actorUserID) {
if noticePayload["target_user_id"] != strconv.FormatInt(targetUserID, 10) || noticePayload["actor_user_id"] != strconv.FormatInt(actorUserID, 10) {
t.Fatalf("unexpected stored user payload: %+v", noticePayload)
}
t.Logf("real room notice validation event_id=%s status=%s delivered_at_ms=%d", eventID, noticeStatus, deliveredAtMS)

View File

@ -95,9 +95,9 @@ func (s *Service) publishRoomKickEvent(ctx context.Context, event RoomKickEvent,
publishCtx, cancel := context.WithTimeout(ctx, options.PublishTimeout)
defer cancel()
err = s.publisher.PublishUserCustomMessage(publishCtx, tencentim.CustomUserMessage{
ToAccount: tencentim.FormatUserID(event.TargetUserID),
EventID: event.EventID,
Desc: "room_user_kicked",
ToAccount: tencentim.FormatUserID(event.TargetUserID),
EventID: event.EventID,
Desc: "room_user_kicked",
// 被踢用户可能已先被移出腾讯 IM 房间群,因此必须用 C2C 兜底Ext 沿用
// Flutter 已消费的房间系统消息协议,保证当前线上客户端不会把私有通知过滤掉。
Ext: "room_system_message",
@ -152,14 +152,15 @@ func (s *Service) markFailed(ctx context.Context, event RoomKickEvent, options R
func roomKickNoticePayload(event RoomKickEvent) (json.RawMessage, error) {
payload := map[string]any{
"event_id": event.EventID,
"event_type": "room_user_kicked",
"source_event_type": event.EventType,
"app_code": event.AppCode,
"room_id": event.RoomID,
"room_version": event.RoomVersion,
"actor_user_id": event.ActorUserID,
"target_user_id": event.TargetUserID,
"event_id": event.EventID,
"event_type": "room_user_kicked",
"source_event_type": event.EventType,
"app_code": event.AppCode,
"room_id": event.RoomID,
"room_version": event.RoomVersion,
// C2C 房间通知与群内 RoomEvent 使用同一字符串 ID 契约,避免被踢用户走私有通道时再次落入 JavaScript Number。
"actor_user_id": tencentim.FormatOptionalUserID(event.ActorUserID),
"target_user_id": tencentim.FormatOptionalUserID(event.TargetUserID),
"occurred_at_ms": event.OccurredAtMS,
"source_created_at_ms": event.CreatedAtMS,
}

View File

@ -61,7 +61,7 @@ func TestProcessRoomKickNoticesPublishesPrivateIM(t *testing.T) {
if payload["event_type"] != "room_user_kicked" || payload["room_id"] != "room_10001" {
t.Fatalf("unexpected payload: %+v", payload)
}
if payload["target_user_id"].(float64) != 20001 || payload["actor_user_id"].(float64) != 10001 {
if payload["target_user_id"] != "20001" || payload["actor_user_id"] != "10001" {
t.Fatalf("unexpected user payload: %+v", payload)
}
}

View File

@ -1,5 +1,10 @@
-- 房主 VIP 只作为房间列表读模型,不进入 Room Cell 核心状态。
-- 两列均不参与索引MySQL 8 INSTANT DDL 只更新数据字典,不扫描或重写 room_list_entries。
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 显式选择 Room owner 库,保证本地完整迁移脚本和生产单文件执行落到同一边界。
USE hyapp_room;
DROP PROCEDURE IF EXISTS hyapp_migrate_004_room_list_owner_vip_level;
DELIMITER $$

View File

@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
@ -87,7 +88,11 @@ func (p *TencentIMPublisher) PublishOutboxEvent(ctx context.Context, envelope *r
if err != nil {
return err
}
if err := p.client.DeleteGroupMember(ctx, groupID, event.TargetUserID); err != nil {
targetUserID, parseErr := strconv.ParseInt(event.TargetUserID, 10, 64)
if parseErr != nil || targetUserID <= 0 {
return fmt.Errorf("invalid room kick target_user_id %q", event.TargetUserID)
}
if err := p.client.DeleteGroupMember(ctx, groupID, targetUserID); err != nil {
if !tencentim.IsRESTErrorCode(err, 10010, 10015) {
return err
}
@ -150,8 +155,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetUserId()
base.TargetUserID = body.GetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetUserId())
// RoomUserJoined 的座驾必须从 outbox body 取快照,不能在补偿投递时按当前佩戴重算。
base.EntryVehicle = roomEntryVehicleFromEvent(body.GetEntryVehicle())
base.Attributes = roomUserJoinedAttributesFromEvent(&body)
@ -162,8 +167,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetUserId()
base.TargetUserID = body.GetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetUserId())
return base, true, nil
case "RoomClosed":
// 关房事件提示仍在群内的客户端刷新房间终态;私有通知由 RoomUserKicked 事实另行投递。
@ -171,7 +176,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.Attributes = map[string]string{"reason": body.GetReason()}
return base, true, nil
case "RoomMicChanged":
@ -180,8 +185,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
base.SeatNo = body.GetToSeat()
if base.SeatNo == 0 {
base.SeatNo = body.GetFromSeat()
@ -214,7 +219,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.SeatNo = body.GetSeatNo()
base.Attributes = map[string]string{"locked": fmt.Sprintf("%t", body.GetLocked())}
return base, true, nil
@ -224,7 +229,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.Attributes = map[string]string{
"room_name": body.GetRoomName(),
"room_avatar": body.GetRoomAvatar(),
@ -239,7 +244,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.Attributes = map[string]string{
"background_id": fmt.Sprintf("%d", body.GetBackgroundId()),
"room_background_url": body.GetRoomBackgroundUrl(),
@ -255,7 +260,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
// 装扮应用事实继续留在 outbox/MQ但补偿 IM 在资格到期后不再让客户端恢复失效视觉。
return tencentim.RoomEvent{}, false, nil
}
base.ActorUserID = body.GetActorUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
colors, err := json.Marshal(body.GetTextColors())
if err != nil {
return tencentim.RoomEvent{}, false, err
@ -282,7 +287,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
base.Attributes = map[string]string{
"message_id": body.GetMessageId(), "command_id": body.GetCommandId(),
"sender_user_id": fmt.Sprintf("%d", body.GetSenderUserId()),
@ -295,7 +300,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
return base, true, nil
case "RoomPasswordChanged":
@ -311,8 +316,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
base.Attributes = map[string]string{"enabled": fmt.Sprintf("%t", body.GetEnabled())}
return base, true, nil
case "RoomUserMuted":
@ -321,8 +326,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
base.Attributes = map[string]string{"muted": fmt.Sprintf("%t", body.GetMuted())}
return base, true, nil
case "RoomUserKicked":
@ -331,8 +336,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
return base, true, nil
case "RoomUserUnbanned":
// 解封只恢复再次 JoinRoom 的资格,不代表目标已经回到房间 presence。
@ -340,8 +345,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetActorUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetActorUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
return base, true, nil
case "RoomGiftSent":
// 送礼客户端消息只用 GiftSent 主事件承载Heat/Rank 变化作为字段随同一条消息展示。
@ -353,8 +358,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
// batch-send 仍写逐目标 RoomGiftSent 作为 durable 业务事实,但房间展示只发 RoomGiftBatchSent否则新 Flutter 会同时收到 batch 和 N 条旧单目标 IM。
return tencentim.RoomEvent{}, false, nil
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
base.GiftValue = body.GetGiftValue()
// 房间内 IM 也透传 effect_types客户端可以在房间流和区域广播流中使用同一套礼物展示判断。
effectTypesJSON, err := json.Marshal(body.GetGiftEffectTypes())
@ -398,12 +403,10 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
targetUserIDsJSON, err := json.Marshal(body.GetTargetUserIds())
if err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = firstInt64(body.GetTargetUserIds())
// 数组元素同样属于用户标识,必须保持字符串;仅把外层 attributes 设为字符串并不能避免客户端二次 jsonDecode 丢精度。
targetUserIDsJSON := tencentim.FormatUserIDsJSON(body.GetTargetUserIds())
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(firstInt64(body.GetTargetUserIds()))
base.GiftValue = body.GetTotalGiftValue()
base.RoomHeat = body.GetRoomHeat()
// 房间 IM 只保留客户端房内展示需要的小字段:飞座位依赖 target_user_ids礼物图标/名称用于公屏和飞行图标兜底。
@ -419,7 +422,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"gift_name": body.GetGiftName(),
"gift_icon_url": body.GetGiftIconUrl(),
"target_count": fmt.Sprintf("%d", len(body.GetTargetUserIds())),
"target_user_ids": string(targetUserIDsJSON),
"target_user_ids": targetUserIDsJSON,
"command_id": body.GetCommandId(),
"visible_region_id": fmt.Sprintf("%d", body.GetVisibleRegionId()),
"sender_name": body.GetSenderName(),
@ -447,8 +450,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetUserId()
base.TargetUserID = body.GetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetUserId())
base.GiftValue = body.GetGiftValue()
base.Attributes = map[string]string{
"user_id": fmt.Sprintf("%d", body.GetUserId()),
@ -462,8 +465,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
base.GiftValue = body.GetEffectiveRewardCoins()
base.Attributes = map[string]string{
"event_type": "lucky_gift_drawn",
@ -502,8 +505,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.TargetUserID = body.GetTargetUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTargetUserId())
// 中奖金额保留在 effective_reward_coins 供动画展示;顶层 gift_value 必须为 0
// 否则 Flutter 会把纯展示中奖额累计进机器人发送者的成员贡献。
base.GiftValue = 0
@ -533,7 +536,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetSenderUserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetSenderUserId())
base.Attributes = map[string]string{
"rocket_id": body.GetRocketId(),
"level": fmt.Sprintf("%d", body.GetLevel()),
@ -554,8 +557,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err := proto.Unmarshal(envelope.GetBody(), &body); err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetIgniterUserId()
base.TargetUserID = body.GetTop1UserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetIgniterUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTop1UserId())
base.Attributes = map[string]string{
"rocket_id": body.GetRocketId(),
"level": fmt.Sprintf("%d", body.GetLevel()),
@ -572,6 +575,7 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
"top1_user_id": fmt.Sprintf("%d", body.GetTop1UserId()),
"igniter_user_id": fmt.Sprintf("%d", body.GetIgniterUserId()),
"command_id": body.GetCommandId(),
"room_locked": fmt.Sprintf("%t", body.GetRoomLocked()),
}
return base, true, nil
case "RoomRocketLaunched":
@ -583,8 +587,8 @@ func roomEventFromEnvelope(envelope *roomeventsv1.EventEnvelope) (tencentim.Room
if err != nil {
return tencentim.RoomEvent{}, false, err
}
base.ActorUserID = body.GetIgniterUserId()
base.TargetUserID = body.GetTop1UserId()
base.ActorUserID = tencentim.FormatOptionalUserID(body.GetIgniterUserId())
base.TargetUserID = tencentim.FormatOptionalUserID(body.GetTop1UserId())
base.Attributes = map[string]string{
"rocket_id": body.GetRocketId(),
"level": fmt.Sprintf("%d", body.GetLevel()),

View File

@ -260,10 +260,10 @@ func TestRoomGiftBatchSentPublishesSingleBatchIMEvent(t *testing.T) {
if err != nil {
t.Fatalf("RoomGiftBatchSent should decode: %v", err)
}
if !publish || event.EventType != "room_gift_batch_sent" || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 600 || event.RoomHeat != 6600 {
if !publish || event.EventType != "room_gift_batch_sent" || event.ActorUserID != "1001" || event.TargetUserID != "1002" || event.GiftValue != 600 || event.RoomHeat != 6600 {
t.Fatalf("batch gift IM event mismatch: publish=%t event=%+v", publish, event)
}
if event.Attributes["target_user_ids"] != `[1002,1003]` || event.Attributes["target_count"] != "2" {
if event.Attributes["target_user_ids"] != `["1002","1003"]` || event.Attributes["target_count"] != "2" {
t.Fatalf("batch gift IM target attributes mismatch: %+v", event.Attributes)
}
if event.Attributes["gift_id"] != "gift_clover" || event.Attributes["gift_count"] != "2" || event.Attributes["command_id"] != "cmd-batch" {
@ -318,7 +318,7 @@ func TestRoomHeatAndRankChangedPublishDisplayIMEvents(t *testing.T) {
if err != nil {
t.Fatalf("RoomRankChanged should decode: %v", err)
}
if !publish || rankEvent.EventType != "room_rank_changed" || rankEvent.TargetUserID != 1002 || rankEvent.GiftValue != 360 || rankEvent.Attributes["score"] != "360" {
if !publish || rankEvent.EventType != "room_rank_changed" || rankEvent.TargetUserID != "1002" || rankEvent.GiftValue != 360 || rankEvent.Attributes["score"] != "360" {
t.Fatalf("rank IM event mismatch: publish=%t event=%+v", publish, rankEvent)
}
}
@ -348,7 +348,7 @@ func TestRoomRobotLuckyGiftDrawnPublishesLuckyGiftIMEvent(t *testing.T) {
if err != nil {
t.Fatalf("RoomRobotLuckyGiftDrawn should decode: %v", err)
}
if !publish || event.EventType != "lucky_gift_drawn" || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 0 {
if !publish || event.EventType != "lucky_gift_drawn" || event.ActorUserID != "1001" || event.TargetUserID != "1002" || event.GiftValue != 0 {
t.Fatalf("robot lucky gift IM event mismatch: publish=%t event=%+v", publish, event)
}
if event.Attributes["draw_id"] != "robot_lucky_draw_1" ||
@ -411,7 +411,7 @@ func TestRoomLuckyGiftDrawnPublishesGrantedOwnerFactAsLuckyGiftIMEvent(t *testin
if err != nil {
t.Fatalf("RoomLuckyGiftDrawn should decode: %v", err)
}
if !publish || event.EventType != "lucky_gift_drawn" || event.EventID != record.EventID || event.ActorUserID != 1001 || event.TargetUserID != 1002 || event.GiftValue != 5000 {
if !publish || event.EventType != "lucky_gift_drawn" || event.EventID != record.EventID || event.ActorUserID != "1001" || event.TargetUserID != "1002" || event.GiftValue != 5000 {
t.Fatalf("owner lucky gift IM event mismatch: publish=%t event=%+v", publish, event)
}
if event.Attributes["sender_name"] != "Lucky Sender" ||

View File

@ -229,7 +229,7 @@ func (s *Service) applyAdminRoomMutation(ctx context.Context, logCommand command
}
result.roomStatus = *cmd.Status
result.closeInfo = &RoomCloseInfo{Reason: cmd.CloseReason, AdminID: cmd.AdminID, AdminName: cmd.AdminName, ClosedAtMS: now.UnixMilli()}
result.syncEvent = &tencentim.RoomEvent{EventID: closedEvent.EventID, RoomID: current.RoomID, EventType: "room_closed", ActorUserID: cmd.ActorUserID(), RoomVersion: current.Version + 1}
result.syncEvent = &tencentim.RoomEvent{EventID: closedEvent.EventID, RoomID: current.RoomID, EventType: "room_closed", ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()), RoomVersion: current.Version + 1}
case state.RoomStatusActive:
result.roomStatus = state.RoomStatusActive
result.closeInfo = &RoomCloseInfo{ClearOnOpen: true}

View File

@ -224,7 +224,7 @@ func (s *Service) SetRoomBackground(ctx context.Context, req *roomv1.SetRoomBack
EventID: backgroundEvent.EventID,
RoomID: current.RoomID,
EventType: "room_background_changed",
ActorUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
RoomVersion: current.Version,
Attributes: map[string]string{
"background_id": int64String(cmd.BackgroundID),

View File

@ -418,6 +418,8 @@ func (f *giftFlow) buildMutationResult(now time.Time, current *state.RoomState)
}
}
if f.rocketApply.ignited != nil {
// 点火飘屏可能在其他房间展示;锁状态必须复用本次送礼命令已固化的 Room Cell 快照。
f.rocketApply.ignited.RoomLocked = f.roomLocked
ignitedEvent, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, f.rocketApply.ignited)
if err != nil {
return mutationResult{}, nil, err
@ -516,8 +518,8 @@ func (f *giftFlow) toMutationResult(now time.Time, current *state.RoomState) mut
EventID: f.giftEvents[0].EventID,
RoomID: current.RoomID,
EventType: "room_gift_sent",
ActorUserID: f.cmd.ActorUserID(),
TargetUserID: f.cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(f.cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(f.cmd.TargetUserID),
GiftValue: f.heatValue,
RoomHeat: current.Heat,
RoomVersion: current.Version,

View File

@ -1,10 +1,10 @@
package service
import (
"strconv"
"strings"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/tencentim"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/state"
)
@ -151,14 +151,8 @@ func giftDisplayName(profile command.GiftDisplayProfile) string {
}
func giftTargetUserIDsAttribute(targetUserIDs []int64) string {
if len(targetUserIDs) == 0 {
return ""
}
parts := make([]string, 0, len(targetUserIDs))
for _, targetUserID := range targetUserIDs {
parts = append(parts, strconv.FormatInt(targetUserID, 10))
}
return strings.Join(parts, ",")
// direct IM 与 outbox 补偿必须使用相同的字符串数组协议,不能让同一事件因投递路径不同而出现 number/string 两种用户 ID。
return tencentim.FormatUserIDsJSON(targetUserIDs)
}
func findRankItem(items []state.RankItem, userID int64) state.RankItem {

View File

@ -162,7 +162,7 @@ func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (
EventID: closedEvent.EventID,
RoomID: current.RoomID,
EventType: "room_closed",
ActorUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
RoomVersion: current.Version,
Attributes: map[string]string{
"reason": cmd.Reason,

View File

@ -66,7 +66,7 @@ func (s *Service) SetMicSeatLock(ctx context.Context, req *roomv1.SetMicSeatLock
EventID: lockEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_seat_locked",
ActorUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
SeatNo: cmd.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{
@ -125,7 +125,7 @@ func (s *Service) SetChatEnabled(ctx context.Context, req *roomv1.SetChatEnabled
EventID: chatEvent.EventID,
RoomID: current.RoomID,
EventType: "room_chat_enabled_changed",
ActorUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
RoomVersion: current.Version,
Attributes: map[string]string{
"enabled": boolString(cmd.Enabled),
@ -201,8 +201,8 @@ func (s *Service) SetRoomAdmin(ctx context.Context, req *roomv1.SetRoomAdminRequ
EventID: adminEvent.EventID,
RoomID: current.RoomID,
EventType: "room_admin_changed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
RoomVersion: current.Version,
Attributes: map[string]string{
"enabled": boolString(cmd.Enabled),

View File

@ -112,8 +112,8 @@ func (s *Service) MicUp(ctx context.Context, req *roomv1.MicUpRequest) (*roomv1.
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_up",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
SeatNo: cmd.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),
@ -224,8 +224,8 @@ func (s *Service) micDown(ctx context.Context, cmd command.MicDown) (mutationRes
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_down",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),
@ -327,8 +327,8 @@ func (s *Service) ChangeMicSeat(ctx context.Context, req *roomv1.ChangeMicSeatRe
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_change",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
SeatNo: cmd.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),
@ -451,8 +451,8 @@ func (s *Service) ConfirmMicPublishing(ctx context.Context, req *roomv1.ConfirmM
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_publish_confirmed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: attributes,
@ -655,8 +655,8 @@ func (s *Service) SetMicMute(ctx context.Context, req *roomv1.SetMicMuteRequest)
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_mute_changed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: roomMicChangedIMAttributes(micChanged),

View File

@ -110,8 +110,8 @@ func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*r
EventID: muteEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_muted",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
RoomVersion: current.Version,
},
}, records, nil
@ -232,8 +232,8 @@ func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*r
EventID: kickEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_kicked",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
RoomVersion: current.Version,
},
}, records, nil
@ -298,8 +298,8 @@ func (s *Service) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (
EventID: unbanEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_unbanned",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
RoomVersion: current.Version,
},
}, []outbox.Record{unbanEvent}, nil

View File

@ -154,8 +154,8 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
EventID: joinedEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_joined",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
RoomVersion: current.Version,
// syncEvent 与 outbox 事件保持同一份快照,避免低延迟路径和补偿路径 payload 不一致。
EntryVehicle: imEntryVehicleFromCommand(cmd.EntryVehicle),
@ -498,8 +498,8 @@ func (s *Service) leaveRoom(ctx context.Context, cmd command.LeaveRoom, reason s
EventID: leftEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_left",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
RoomVersion: current.Version,
Attributes: attributes,
},

View File

@ -97,7 +97,7 @@ func (s *Service) UpdateRoomProfile(ctx context.Context, req *roomv1.UpdateRoomP
EventID: profileEvent.EventID,
RoomID: current.RoomID,
EventType: "room_profile_updated",
ActorUserID: cmd.ActorUserID(),
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
RoomVersion: current.Version,
Attributes: map[string]string{
"room_name": current.RoomExt[roomExtTitleKey],

View File

@ -92,8 +92,8 @@ func (s *Service) RobotVirtualMicUp(ctx context.Context, input RobotMicUpInput)
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_publish_confirmed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
SeatNo: cmd.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{

View File

@ -1293,6 +1293,13 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
viewerID := int64(202)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, viewerID)
if _, err := svc.SetRoomPassword(ctx, &roomv1.SetRoomPasswordRequest{
Meta: rocketMeta(roomID, ownerID, "lock-before-ignite"),
Locked: true,
Password: "1234",
}); err != nil {
t.Fatalf("lock rocket room failed: %v", err)
}
fillResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "fill"),
@ -1331,6 +1338,11 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
t.Fatalf("progress event must describe the filled level, got %+v", progressEvents[0])
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1})
ignitedRecords := directIM.recordsByType("RoomRocketIgnited")
var ignited roomeventsv1.RoomRocketIgnited
if len(ignitedRecords) != 1 || proto.Unmarshal(ignitedRecords[0].GetBody(), &ignited) != nil || !ignited.GetRoomLocked() {
t.Fatalf("rocket ignition must carry the Room Cell lock snapshot: records=%+v event=%+v", ignitedRecords, &ignited)
}
if got := outboxEventCounts(t, ctx, repository)["RoomRocketIgnited"]; got != 1 {
t.Fatalf("fill gift must keep one RoomRocketIgnited outbox for launch scheduling, got %d", got)
}

View File

@ -72,6 +72,8 @@ func (s *Service) ApplyRoomRPSGift(ctx context.Context, req *roomv1.ApplyRoomRPS
progress = &roomRocketProgressPending{record: progressRecord, rocketID: rocketApply.progressEvent.GetRocketId(), level: rocketApply.progressEvent.GetLevel(), currentFuel: rocketApply.progressEvent.GetCurrentFuel(), roomVersion: current.Version}
}
if rocketApply.ignited != nil {
// RPS 礼物同样可能触发跨房火箭飘屏,点火事实使用当前 Room Cell 内的锁房快照。
rocketApply.ignited.RoomLocked = strings.TrimSpace(current.RoomPasswordHash) != ""
ignitedRecord, err := outbox.Build(current.RoomID, "RoomRocketIgnited", current.Version, now, rocketApply.ignited)
if err != nil {
return mutationResult{}, nil, err

View File

@ -148,8 +148,8 @@ func (s *Service) applyRTCAudioStarted(now time.Time, current *state.RoomState,
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_publish_confirmed",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{
@ -209,8 +209,8 @@ func (s *Service) applyRTCAudioStopped(now time.Time, current *state.RoomState,
EventID: micEvent.EventID,
RoomID: current.RoomID,
EventType: "room_mic_down",
ActorUserID: cmd.ActorUserID(),
TargetUserID: cmd.TargetUserID,
ActorUserID: tencentim.FormatOptionalUserID(cmd.ActorUserID()),
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
SeatNo: seat.SeatNo,
RoomVersion: current.Version,
Attributes: map[string]string{

View File

@ -121,7 +121,7 @@ func (s *Service) SystemEvictUser(ctx context.Context, req *roomv1.SystemEvictUs
EventID: evictEvent.EventID,
RoomID: current.RoomID,
EventType: "room_user_kicked",
TargetUserID: cmd.TargetUserID,
TargetUserID: tencentim.FormatOptionalUserID(cmd.TargetUserID),
RoomVersion: current.Version,
Attributes: map[string]string{"reason": cmd.Reason},
},

View File

@ -94,6 +94,32 @@ CREATE TABLE IF NOT EXISTS users (
KEY idx_users_country_region (app_code, country, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户主数据表';
CREATE TABLE IF NOT EXISTS user_avatar_upload_registrations (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '上传用户 ID',
avatar_upload_id VARCHAR(64) NOT NULL COMMENT '资料更新消费凭证',
client_command_id VARCHAR(128) NOT NULL COMMENT '客户端上传幂等键',
media_payload_sha256 CHAR(64) NOT NULL COMMENT '规范化媒体事实摘要',
expected_object_key VARCHAR(512) NOT NULL COMMENT 'user-service 授权的 COS 对象键',
object_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'COS 上传完成后的受信访问地址',
status VARCHAR(16) NOT NULL COMMENT 'authorized/active/consumed',
content_type VARCHAR(64) NOT NULL COMMENT '按魔数识别的 MIME',
media_format VARCHAR(16) NOT NULL COMMENT 'jpeg/png/gif/webp',
size_bytes BIGINT NOT NULL COMMENT '原始文件字节数',
width INT NOT NULL COMMENT '画布宽度',
height INT NOT NULL COMMENT '画布高度',
animated TINYINT(1) NOT NULL COMMENT '是否多帧动画',
frame_count INT NOT NULL COMMENT '帧数',
duration_ms BIGINT NOT NULL COMMENT '动画总时长,静态为 0',
sha256 CHAR(64) NOT NULL COMMENT '原始文件 SHA-256',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, avatar_upload_id),
UNIQUE KEY uk_user_avatar_upload_command (app_code, user_id, client_command_id),
UNIQUE KEY uk_user_avatar_upload_object (app_code, expected_object_key),
KEY idx_user_avatar_upload_status_time (app_code, status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户头像上传授权与消费记录';
CREATE TABLE IF NOT EXISTS user_profile_stats (
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '用户 ID',
@ -559,6 +585,7 @@ CREATE TABLE IF NOT EXISTS user_cp_applications (
status VARCHAR(32) NOT NULL COMMENT '申请状态pending、accepted、rejected、expired、blocked',
room_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '触发礼物所在房间 ID',
room_region_id BIGINT NOT NULL DEFAULT 0 COMMENT '触发礼物所在房间区域 ID',
room_locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT '触发送礼命令在 Room Cell 内固化的锁房状态',
gift_id VARCHAR(64) NOT NULL DEFAULT '' COMMENT '触发申请的礼物 ID',
gift_name VARCHAR(128) NOT NULL DEFAULT '' COMMENT '触发申请的礼物名称',
gift_icon_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '触发申请的礼物图标',

View File

@ -0,0 +1,31 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
USE hyapp_user;
-- 新建独立小表,不扫描或改写 users 热表。三组索引分别覆盖凭证主键、客户端幂等键和对象键唯一归属;
-- 资料更新的消费 SQL 始终命中主键,不会按 URL、状态或媒体字段全表查找。
CREATE TABLE IF NOT EXISTS user_avatar_upload_registrations (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离',
user_id BIGINT NOT NULL COMMENT '上传用户 ID',
avatar_upload_id VARCHAR(64) NOT NULL COMMENT '资料更新消费凭证',
client_command_id VARCHAR(128) NOT NULL COMMENT '客户端上传幂等键',
media_payload_sha256 CHAR(64) NOT NULL COMMENT '规范化媒体事实摘要',
expected_object_key VARCHAR(512) NOT NULL COMMENT 'user-service 授权的 COS 对象键',
object_url VARCHAR(1024) NOT NULL DEFAULT '' COMMENT 'COS 上传完成后的受信访问地址',
status VARCHAR(16) NOT NULL COMMENT 'authorized/active/consumed',
content_type VARCHAR(64) NOT NULL COMMENT '按魔数识别的 MIME',
media_format VARCHAR(16) NOT NULL COMMENT 'jpeg/png/gif/webp',
size_bytes BIGINT NOT NULL COMMENT '原始文件字节数',
width INT NOT NULL COMMENT '画布宽度',
height INT NOT NULL COMMENT '画布高度',
animated TINYINT(1) NOT NULL COMMENT '是否多帧动画',
frame_count INT NOT NULL COMMENT '帧数',
duration_ms BIGINT NOT NULL COMMENT '动画总时长,静态为 0',
sha256 CHAR(64) NOT NULL COMMENT '原始文件 SHA-256',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, avatar_upload_id),
UNIQUE KEY uk_user_avatar_upload_command (app_code, user_id, client_command_id),
UNIQUE KEY uk_user_avatar_upload_object (app_code, expected_object_key),
KEY idx_user_avatar_upload_status_time (app_code, status, created_at_ms)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户头像上传授权与消费记录';

View File

@ -0,0 +1,19 @@
-- CP 关系成立飘屏需要复用申请来源送礼时的锁房快照,不能在用户同意申请时回查并改写历史房间状态。
-- 单列追加不参与索引或查询过滤MySQL 8 使用 INSTANT 元数据变更,不扫描或重写申请表数据。
SET @room_locked_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_cp_applications'
AND COLUMN_NAME = 'room_locked'
);
SET @room_locked_sql = IF(
@room_locked_exists = 0,
'ALTER TABLE user_cp_applications ADD COLUMN room_locked TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''触发送礼命令在 Room Cell 内固化的锁房状态'' AFTER room_region_id, ALGORITHM=INSTANT',
'SELECT 1'
);
PREPARE room_locked_stmt FROM @room_locked_sql;
EXECUTE room_locked_stmt;
DEALLOCATE PREPARE room_locked_stmt;

View File

@ -257,6 +257,7 @@ func New(cfg config.Config) (*App, error) {
var walletConn *grpc.ClientConn
var avatarFrameReader cpservice.AvatarFrameReader
var privacyBenefitChecker userservice.PrivacyBenefitChecker
var vipBenefitDecisionChecker userservice.VIPBenefitDecisionChecker
if cfg.WalletService.Enabled {
// 头像框佩戴事实归 wallet-serviceuser-service 刷新榜单前必须能读取该快照,否则榜单会缺少双方头像框。
walletConn, err = grpcclient.Dial(cfg.WalletService.Addr, grpcclient.Config{
@ -284,6 +285,7 @@ func New(cfg config.Config) (*App, error) {
walletClient := walletclient.NewGRPC(walletConn)
avatarFrameReader = walletClient
privacyBenefitChecker = walletClient
vipBenefitDecisionChecker = walletClient
}
// auth service 负责登录、session 和 token用户主数据和短号事务仍通过各自领域存储完成。
@ -332,6 +334,7 @@ func New(cfg config.Config) (*App, error) {
userservice.WithDeviceRepository(deviceRepo),
userservice.WithModerationRepository(userRepo),
userservice.WithPrivacyBenefitChecker(privacyBenefitChecker),
userservice.WithVIPBenefitDecisionChecker(vipBenefitDecisionChecker),
userservice.WithSessionDenylist(ipDecisionCache, time.Duration(cfg.JWT.AccessTokenTTLSec+60)*time.Second),
userservice.WithIMLoginKicker(imLoginKicker),
userservice.WithRoomEvictor(roomEvictor),
@ -1040,6 +1043,7 @@ func roomGiftCPEventFromRoomMessage(body []byte) (cpdomain.GiftEvent, bool, erro
CoinSpent: sent.GetCoinSpent(),
BillingReceiptID: sent.GetBillingReceiptId(),
VisibleRegionID: sent.GetVisibleRegionId(),
RoomLocked: sent.GetRoomLocked(),
CommandID: sent.GetCommandId(),
GiftTypeCode: sent.GetGiftTypeCode(),
CPRelationType: sent.GetCpRelationType(),

View File

@ -19,6 +19,7 @@ func TestRoomGiftCPEventFromRoomMessageIncludesNormalGift(t *testing.T) {
CommandId: "cmd-normal",
GiftTypeCode: "normal",
GiftName: "Rose",
RoomLocked: true,
})
if err != nil {
t.Fatalf("marshal room gift sent failed: %v", err)
@ -43,7 +44,7 @@ func TestRoomGiftCPEventFromRoomMessageIncludesNormalGift(t *testing.T) {
if !ok {
t.Fatal("normal gifts must reach CP consumer so active relationships can gain intimacy")
}
if event.CPRelationType != "" || event.GiftTypeCode != "normal" || event.GiftValue != 100 || event.TargetUserID != 1002 {
if event.CPRelationType != "" || event.GiftTypeCode != "normal" || event.GiftValue != 100 || event.TargetUserID != 1002 || !event.RoomLocked {
t.Fatalf("normal gift event mismatch: %+v", event)
}
}

View File

@ -119,13 +119,15 @@ type FormationGiftFeedItem struct {
// Application 表达一次关系申请,只有 target 用户可以处理 pending 状态。
type Application struct {
ApplicationID string
RelationType string
Status string
Requester UserProfile
Target UserProfile
RoomID string
RoomRegionID int64
ApplicationID string
RelationType string
Status string
Requester UserProfile
Target UserProfile
RoomID string
RoomRegionID int64
// RoomLocked 是触发关系申请的送礼命令在 Room Cell 内固化的锁房快照,供跨房关系飘屏安全进入来源房间。
RoomLocked bool
Gift GiftSnapshot
CreatedAtMS int64
UpdatedAtMS int64
@ -190,6 +192,8 @@ type GiftEvent struct {
CoinSpent int64
BillingReceiptID string
VisibleRegionID int64
// RoomLocked 直接来自 RoomGiftSent owner factuser-service 不异步回查房间当前状态。
RoomLocked bool
CommandID string
GiftTypeCode string
CPRelationType string

View File

@ -2,6 +2,7 @@
package user
import (
"errors"
"regexp"
"strings"
"unicode"
@ -652,6 +653,8 @@ type ProfileUpdateCommand struct {
Username *string
// Avatar 非 nil 时覆盖头像地址nil 表示本次不修改。
Avatar *string
// AvatarUploadID 非空时必须与 Avatar 指向同一条 active 上传记录repository 在资料事务内单次消费。
AvatarUploadID string
// Gender 非 nil 时覆盖性别枚举原值nil 表示本次不修改。
Gender *string
// BirthDate 非 nil 时覆盖生日 yyyy-mm-dd空字符串表示清空生日。
@ -660,6 +663,46 @@ type ProfileUpdateCommand struct {
UpdatedAtMs int64
}
// AvatarMedia 是 user-service 保存的头像原始媒体事实。所有字段都来自 gateway 对实际字节的解析,
// 后续链路不得再根据 URL 后缀猜测格式或动画状态。
type AvatarMedia struct {
URL string
ObjectKey string
ContentType string
Format string
SizeBytes int64
Width int32
Height int32
Animated bool
FrameCount int32
DurationMS int64
SHA256 string
Status string
}
// AvatarUploadRegistration 是 COS 写入前由 user owner 持久化的授权事实。
// UploadID 同时是资料更新的单次消费凭证,必须按 app_code + user_id 隔离。
type AvatarUploadRegistration struct {
AppCode string
UserID int64
UploadID string
ClientCommandID string
MediaPayloadSHA256 string
ExpectedObjectKey string
ObjectURL string
Status string
Media AvatarMedia
CreatedAtMS int64
UpdatedAtMS int64
}
var (
// ErrAvatarUploadCommandConflict 表示同一上传命令已绑定另一份媒体载荷。
ErrAvatarUploadCommandConflict = errors.New("avatar upload command payload conflict")
// ErrAvatarUploadNotConsumable 表示凭证不存在、归属不符、未完成或已被另一轮资料更新消费。
ErrAvatarUploadNotConsumable = errors.New("avatar upload is not consumable")
)
// ProfileBackgroundUpdateCommand 描述用户个人信息页背景图的专用修改。
type ProfileBackgroundUpdateCommand struct {
// AppCode 是资料修改所属 App。

View File

@ -10,6 +10,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
cpdomain "hyapp/services/user-service/internal/domain/cp"
userservice "hyapp/services/user-service/internal/service/user"
)
const avatarFrameResourceType = "avatar_frame"
@ -42,6 +43,33 @@ func (c *Client) CheckVipBenefit(ctx context.Context, requestID string, userID i
return resp.GetAllowed(), nil
}
// CheckVIPBenefitDecision 返回动态头像门禁需要的完整判定结果。
// program_type 必须来自 wallet 的 App 配置,调用方不能按 app_code 或固定 VIP 数字推断。
func (c *Client) CheckVIPBenefitDecision(ctx context.Context, requestID string, userID int64, benefitCode string) (userservice.VIPBenefitDecision, error) {
if c == nil || c.client == nil {
return userservice.VIPBenefitDecision{}, grpc.ErrClientConnClosing
}
requestID = strings.TrimSpace(requestID)
if requestID == "" {
requestID = idgen.New("user_avatar")
}
resp, err := c.client.CheckVipBenefit(ctx, &walletv1.CheckVipBenefitRequest{
RequestId: requestID, AppCode: appcode.FromContext(ctx), UserId: userID, BenefitCode: strings.TrimSpace(benefitCode),
})
if err != nil {
return userservice.VIPBenefitDecision{}, err
}
if resp == nil || resp.GetState() == nil || resp.GetState().GetProgramConfig() == nil {
return userservice.VIPBenefitDecision{}, grpc.ErrClientConnClosing
}
return userservice.VIPBenefitDecision{
ProgramType: strings.TrimSpace(resp.GetState().GetProgramConfig().GetProgramType()),
Allowed: resp.GetAllowed(),
RequiredLevel: resp.GetRequiredLevel(),
CurrentEffectiveLevel: resp.GetCurrentEffectiveLevel(),
}, nil
}
// NewGRPC 基于共享 gRPC 连接创建 wallet-service 资源读取客户端。
func NewGRPC(conn grpc.ClientConnInterface) *Client {
return &Client{client: walletv1.NewWalletServiceClient(conn)}

View File

@ -0,0 +1,261 @@
package user
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"time"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/giftlimits"
"hyapp/pkg/xerr"
userdomain "hyapp/services/user-service/internal/domain/user"
)
const (
animatedAvatarBenefitCode = "animated_avatar"
tieredPrivilegeVIPProgram = "tiered_privilege_v1"
avatarUploadStatusActive = "active"
avatarUploadMaxBytes = int64(5 << 20)
avatarUploadMaxFrames = int32(120)
avatarUploadMaxDurationMS = int64(15_000)
avatarVIPLookupTimeout = time.Second
)
// AuthorizeUserAvatarUpload 在对象写入前持久化媒体事实,并只对服务端解析出的动态 GIF/WebP 查询精确权益。
func (s *Service) AuthorizeUserAvatarUpload(ctx context.Context, requestID string, userID int64, commandID string, media userdomain.AvatarMedia) (userdomain.AvatarUploadRegistration, error) {
if userID <= 0 || !giftlimits.ValidCommandID(strings.TrimSpace(commandID)) {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.InvalidArgument, "avatar upload request is invalid")
}
if s.avatarUploadRepository == nil {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Unavailable, "avatar upload repository is unavailable")
}
if err := s.requireCompletedProfile(ctx, userID); err != nil {
// 注册头像仍走 public 静态入口VIP 动态头像只能由已完成资料且已登录的用户上传。
return userdomain.AvatarUploadRegistration{}, err
}
media = normalizeAvatarMedia(media)
if err := validateAvatarMedia(media, false); err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
appCode := appcode.FromContext(ctx)
uploadID := deriveAvatarUploadID(appCode, userID, commandID)
payloadSHA := avatarMediaPayloadSHA256(media)
registration := userdomain.AvatarUploadRegistration{
AppCode: appCode, UserID: userID, UploadID: uploadID, ClientCommandID: strings.TrimSpace(commandID),
MediaPayloadSHA256: payloadSHA, Media: media,
}
registration.ExpectedObjectKey = avatarObjectKey(appCode, userID, uploadID, media)
if existing, exists, err := s.avatarUploadRepository.GetAvatarUpload(ctx, userID, uploadID); err != nil {
return userdomain.AvatarUploadRegistration{}, err
} else if exists {
if !sameAvatarUploadIdentity(existing, registration) {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload command payload conflict")
}
if existing.Status == avatarUploadStatusActive {
// 已完成上传的同载荷重试返回首次结果,不能因用户 VIP 后续变化破坏成功请求的幂等性。
return existing, nil
}
if existing.Status != "authorized" {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Conflict, "avatar upload is already consumed")
}
}
if media.Animated {
if err := s.requireAnimatedAvatarBenefit(ctx, requestID, userID, "upload_animated_avatar"); err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
}
nowMS := s.now().UnixMilli()
registration.CreatedAtMS, registration.UpdatedAtMS = nowMS, nowMS
saved, err := s.avatarUploadRepository.RegisterAvatarUpload(ctx, registration)
if errors.Is(err, userdomain.ErrAvatarUploadCommandConflict) {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload command payload conflict")
}
return saved, err
}
// CompleteUserAvatarUpload 只推进首次授权的对象键URL 和媒体属性必须与授权载荷逐字段一致。
func (s *Service) CompleteUserAvatarUpload(ctx context.Context, userID int64, uploadID string, media userdomain.AvatarMedia) (userdomain.AvatarUploadRegistration, error) {
uploadID = strings.TrimSpace(uploadID)
if userID <= 0 || !giftlimits.ValidCommandID(uploadID) {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.InvalidArgument, "avatar upload completion is invalid")
}
if s.avatarUploadRepository == nil {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Unavailable, "avatar upload repository is unavailable")
}
media = normalizeAvatarMedia(media)
if err := validateAvatarMedia(media, true); err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
existing, exists, err := s.avatarUploadRepository.GetAvatarUpload(ctx, userID, uploadID)
if err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
if !exists {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.NotFound, "avatar upload authorization not found")
}
candidate := userdomain.AvatarUploadRegistration{
AppCode: appcode.FromContext(ctx), UserID: userID, UploadID: uploadID,
MediaPayloadSHA256: avatarMediaPayloadSHA256(media), ExpectedObjectKey: media.ObjectKey, ObjectURL: media.URL, Media: media,
}
if !sameAvatarUploadIdentity(existing, candidate) || !sameAvatarMediaProperties(existing.Media, media) {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload payload conflict")
}
if existing.Status == avatarUploadStatusActive {
if strings.TrimSpace(existing.ObjectURL) != media.URL {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload completion conflict")
}
return existing, nil
}
if existing.Status != "authorized" {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.Conflict, "avatar upload is already consumed")
}
existing.ObjectURL, existing.Media.URL, existing.Media.ObjectKey = media.URL, media.URL, media.ObjectKey
existing.Status, existing.Media.Status, existing.UpdatedAtMS = avatarUploadStatusActive, avatarUploadStatusActive, s.now().UnixMilli()
completed, err := s.avatarUploadRepository.CompleteAvatarUpload(ctx, existing)
if errors.Is(err, userdomain.ErrAvatarUploadCommandConflict) {
return userdomain.AvatarUploadRegistration{}, xerr.New(xerr.IdempotencyConflict, "avatar upload completion conflict")
}
return completed, err
}
// requireAnimatedAvatarBenefit 只对 wallet 配置为权益制的 App 执行动态头像门禁。
// Fami 当前所需等级由 Wallet benefit 配置返回user-service 不比较 vip_level。
func (s *Service) requireAnimatedAvatarBenefit(ctx context.Context, requestID string, userID int64, action string) error {
if s.vipBenefitDecisionChecker == nil {
return xerr.New(xerr.Unavailable, "wallet vip benefit checker is unavailable")
}
lookupCtx, cancel := context.WithTimeout(ctx, avatarVIPLookupTimeout)
defer cancel()
decision, err := s.vipBenefitDecisionChecker.CheckVIPBenefitDecision(lookupCtx, strings.TrimSpace(requestID), userID, animatedAvatarBenefitCode)
if err != nil {
return xerr.New(xerr.Unavailable, "check vip benefit failed")
}
if strings.TrimSpace(decision.ProgramType) != tieredPrivilegeVIPProgram {
return nil
}
if decision.Allowed {
return nil
}
return xerr.NewWithMetadata(xerr.VIPBenefitRequired, "vip benefit is required", map[string]string{
"benefit_code": animatedAvatarBenefitCode, "required_level": fmt.Sprintf("%d", decision.RequiredLevel),
"current_effective_level": fmt.Sprintf("%d", decision.CurrentEffectiveLevel), "action": strings.TrimSpace(action),
})
}
// avatarUploadRequiredForRawURL 判断当前 App 是否禁止直接写非空头像 URL。
// nil checker 仅兼容尚未装配 Wallet 的旧测试/本地构造;生产启用 Wallet 后,权益制 App 必须提交上传凭证。
func (s *Service) avatarUploadRequiredForRawURL(ctx context.Context, requestID string, userID int64) (bool, error) {
if s.vipBenefitDecisionChecker == nil {
return false, nil
}
lookupCtx, cancel := context.WithTimeout(ctx, avatarVIPLookupTimeout)
defer cancel()
decision, err := s.vipBenefitDecisionChecker.CheckVIPBenefitDecision(lookupCtx, strings.TrimSpace(requestID), userID, animatedAvatarBenefitCode)
if err != nil {
return false, xerr.New(xerr.Unavailable, "check vip program failed")
}
return strings.TrimSpace(decision.ProgramType) == tieredPrivilegeVIPProgram, nil
}
func validateAvatarMedia(media userdomain.AvatarMedia, requireUploaded bool) error {
if media.SizeBytes <= 0 || media.SizeBytes > avatarUploadMaxBytes || media.Width <= 0 || media.Height <= 0 ||
media.Width > 4096 || media.Height > 4096 || int64(media.Width)*int64(media.Height) > 16_777_216 ||
media.FrameCount <= 0 || media.FrameCount > avatarUploadMaxFrames || len(media.SHA256) != 64 {
return xerr.New(xerr.InvalidArgument, "avatar media properties are invalid")
}
validType := (media.Format == "jpeg" && media.ContentType == "image/jpeg") ||
(media.Format == "png" && media.ContentType == "image/png") ||
(media.Format == "gif" && media.ContentType == "image/gif") ||
(media.Format == "webp" && media.ContentType == "image/webp")
if !validType || (media.Animated && media.Format != "gif" && media.Format != "webp") {
return xerr.New(xerr.InvalidArgument, "avatar media format is invalid")
}
if media.Animated {
if media.FrameCount < 2 || media.DurationMS <= 0 || media.DurationMS > avatarUploadMaxDurationMS {
return xerr.New(xerr.InvalidArgument, "avatar animation properties are invalid")
}
} else if media.FrameCount != 1 || media.DurationMS != 0 {
return xerr.New(xerr.InvalidArgument, "static avatar properties are invalid")
}
if requireUploaded {
if media.URL == "" || media.ObjectKey == "" || media.Status != avatarUploadStatusActive {
return xerr.New(xerr.InvalidArgument, "uploaded avatar media is incomplete")
}
} else if media.URL != "" || media.ObjectKey != "" || media.Status != avatarUploadStatusActive {
return xerr.New(xerr.InvalidArgument, "avatar authorization requires pre-upload metadata")
}
return nil
}
func normalizeAvatarMedia(media userdomain.AvatarMedia) userdomain.AvatarMedia {
media.URL = strings.TrimSpace(media.URL)
media.ObjectKey = strings.TrimLeft(strings.TrimSpace(media.ObjectKey), "/")
media.ContentType = strings.ToLower(strings.TrimSpace(media.ContentType))
media.Format = strings.ToLower(strings.TrimSpace(media.Format))
media.SHA256 = strings.ToLower(strings.TrimSpace(media.SHA256))
media.Status = strings.ToLower(strings.TrimSpace(media.Status))
return media
}
func deriveAvatarUploadID(appCode string, userID int64, commandID string) string {
sum := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%s", appcode.Normalize(appCode), userID, strings.TrimSpace(commandID))))
return "avatar_" + hex.EncodeToString(sum[:16])
}
func avatarObjectKey(appCode string, userID int64, uploadID string, media userdomain.AvatarMedia) string {
extension := media.Format
if extension == "jpeg" {
extension = "jpg"
}
return fmt.Sprintf("user-media/%s/avatars/%d/%s_%s.%s", appcode.Normalize(appCode), userID, uploadID, media.SHA256[:16], extension)
}
func avatarMediaPayloadSHA256(media userdomain.AvatarMedia) string {
value := fmt.Sprintf("%s|%s|%d|%d|%d|%t|%d|%d|%s", media.ContentType, media.Format, media.SizeBytes,
media.Width, media.Height, media.Animated, media.FrameCount, media.DurationMS, media.SHA256)
sum := sha256.Sum256([]byte(value))
return hex.EncodeToString(sum[:])
}
func sameAvatarUploadIdentity(left userdomain.AvatarUploadRegistration, right userdomain.AvatarUploadRegistration) bool {
return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) && left.UserID == right.UserID &&
strings.TrimSpace(left.UploadID) == strings.TrimSpace(right.UploadID) &&
strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) &&
strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/")
}
func sameAvatarMediaProperties(left userdomain.AvatarMedia, right userdomain.AvatarMedia) bool {
left, right = normalizeAvatarMedia(left), normalizeAvatarMedia(right)
return left.ObjectKey == right.ObjectKey && left.ContentType == right.ContentType && left.Format == right.Format &&
left.SizeBytes == right.SizeBytes && left.Width == right.Width && left.Height == right.Height &&
left.Animated == right.Animated && left.FrameCount == right.FrameCount && left.DurationMS == right.DurationMS && left.SHA256 == right.SHA256
}
// AvatarMediaFromProto 把内部 gRPC contract 转成领域媒体事实。
func AvatarMediaFromProto(media *userv1.UserAvatarMedia) userdomain.AvatarMedia {
if media == nil {
return userdomain.AvatarMedia{}
}
return userdomain.AvatarMedia{
URL: media.GetUrl(), ObjectKey: media.GetObjectKey(), ContentType: media.GetContentType(), Format: media.GetFormat(),
SizeBytes: media.GetSizeBytes(), Width: media.GetWidth(), Height: media.GetHeight(), Animated: media.GetAnimated(),
FrameCount: media.GetFrameCount(), DurationMS: media.GetDurationMs(), SHA256: media.GetSha256(), Status: media.GetStatus(),
}
}
// AvatarMediaToProto 返回已由 user owner 接受的媒体事实。
func AvatarMediaToProto(media userdomain.AvatarMedia) *userv1.UserAvatarMedia {
return &userv1.UserAvatarMedia{
Url: media.URL, ObjectKey: media.ObjectKey, ContentType: media.ContentType, Format: media.Format, SizeBytes: media.SizeBytes,
Width: media.Width, Height: media.Height, Animated: media.Animated, FrameCount: media.FrameCount,
DurationMs: media.DurationMS, Sha256: media.SHA256, Status: media.Status,
}
}

View File

@ -2,6 +2,7 @@ package user
import (
"context"
"errors"
"strings"
"hyapp/pkg/appcode"
@ -11,8 +12,8 @@ import (
userdomain "hyapp/services/user-service/internal/domain/user"
)
// UpdateUserProfile 修改用户名、头像、性别和生日;国家修改走独立接口和冷却期
func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username *string, avatar *string, gender *string, birth *string) (userdomain.User, error) {
// UpdateUserProfile 修改用户名、头像、性别和生日;权益制 App 的非空头像只能消费专用上传凭证
func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username *string, avatar *string, gender *string, birth *string, avatarUploadID *string, requestID string) (userdomain.User, error) {
if userID <= 0 {
// user_id 必须来自 gateway 鉴权上下文service 层仍兜底校验。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "user_id is required")
@ -26,12 +27,33 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
}
username = trimmedStringPtr(username)
avatar = trimmedStringPtr(avatar)
avatarUploadID = trimmedStringPtr(avatarUploadID)
gender = trimmedStringPtr(gender)
birth = trimmedStringPtr(birth)
if username == nil && avatar == nil && gender == nil && birth == nil {
if username == nil && avatar == nil && gender == nil && birth == nil && avatarUploadID == nil {
// PATCH 语义要求至少有一个字段,否则客户端通常是传参错误。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "profile update field is required")
}
if avatar != nil && avatarUploadID != nil {
// URL 与凭证二选一,防止客户端提交合法凭证却用另一个任意 URL 覆盖其媒体事实。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar and avatar_upload_id cannot be used together")
}
resolvedUploadID := ""
if avatarUploadID != nil {
resolvedUploadID = strings.TrimSpace(*avatarUploadID)
if resolvedUploadID == "" || s.avatarUploadRepository == nil {
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar_upload_id is invalid")
}
registration, exists, err := s.avatarUploadRepository.GetAvatarUpload(ctx, userID, resolvedUploadID)
if err != nil {
return userdomain.User{}, err
}
if !exists || (registration.Status != avatarUploadStatusActive && registration.Status != "consumed") || strings.TrimSpace(registration.ObjectURL) == "" {
return userdomain.User{}, xerr.New(xerr.Conflict, "avatar upload is not active")
}
resolvedAvatar := strings.TrimSpace(registration.ObjectURL)
avatar = &resolvedAvatar
}
if username != nil && *username == "" {
// 用户名是展示资料,禁止写成空字符串;头像和生日允许清空。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "username is required")
@ -49,6 +71,16 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
// 非 HTTP(S) 或相对地址不应写入展示资料,避免客户端渲染和安全策略不一致。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar must be an absolute http or https URL")
}
if resolvedUploadID == "" {
required, err := s.avatarUploadRequiredForRawURL(ctx, requestID, userID)
if err != nil {
return userdomain.User{}, err
}
if required {
// 权益制 App 的静态和动态头像都先走同一上传入口,服务端才能从字节判断是否需要 VIP。
return userdomain.User{}, xerr.New(xerr.InvalidArgument, "avatar_upload_id is required")
}
}
}
if gender != nil {
if *gender == "" {
@ -65,15 +97,20 @@ func (s *Service) UpdateUserProfile(ctx context.Context, userID int64, username
}
}
return s.userRepository.UpdateUserProfile(ctx, userdomain.ProfileUpdateCommand{
AppCode: appcode.FromContext(ctx),
UserID: userID,
Username: username,
Avatar: avatar,
Gender: gender,
BirthDate: birth,
UpdatedAtMs: s.now().UnixMilli(),
updated, err := s.userRepository.UpdateUserProfile(ctx, userdomain.ProfileUpdateCommand{
AppCode: appcode.FromContext(ctx),
UserID: userID,
Username: username,
Avatar: avatar,
AvatarUploadID: resolvedUploadID,
Gender: gender,
BirthDate: birth,
UpdatedAtMs: s.now().UnixMilli(),
})
if errors.Is(err, userdomain.ErrAvatarUploadNotConsumable) {
return userdomain.User{}, xerr.New(xerr.Conflict, "avatar upload is not consumable")
}
return updated, err
}
// UpdateUserProfileBackground 只修改个人信息页背景图;客户端传入已上传完成的图片 URL。

View File

@ -87,6 +87,28 @@ type PrivacyBenefitChecker interface {
CheckVipBenefit(ctx context.Context, requestID string, userID int64, benefitCode string) (bool, error)
}
// VIPBenefitDecision 是 wallet 对 App VIP 策略和单项权益计算后的最小结果。
// user-service 只消费 program_type 与授权等级事实,不复制 Fami 的等级配置。
type VIPBenefitDecision struct {
ProgramType string
Allowed bool
RequiredLevel int32
CurrentEffectiveLevel int32
}
// VIPBenefitDecisionChecker 为头像上传门禁保留升级页所需的结构化权益事实。
type VIPBenefitDecisionChecker interface {
CheckVIPBenefitDecision(ctx context.Context, requestID string, userID int64, benefitCode string) (VIPBenefitDecision, error)
}
// AvatarUploadRepository 持久化头像上传授权,并在 users 更新事务里单次消费凭证。
// 独立于 UserRepository 可避免基础测试替身被迫实现媒体表方法;生产 MySQL repository 必须实现。
type AvatarUploadRepository interface {
GetAvatarUpload(ctx context.Context, userID int64, uploadID string) (userdomain.AvatarUploadRegistration, bool, error)
RegisterAvatarUpload(ctx context.Context, registration userdomain.AvatarUploadRegistration) (userdomain.AvatarUploadRegistration, error)
CompleteAvatarUpload(ctx context.Context, registration userdomain.AvatarUploadRegistration) (userdomain.AvatarUploadRegistration, error)
}
// AppRegistryRepository 负责把客户端包名解析成内部 app_code。
type AppRegistryRepository interface {
ResolveApp(ctx context.Context, appCode string, packageName string, platform string) (userdomain.App, error)
@ -238,6 +260,10 @@ type Service struct {
anonymousProfileVisitRepository AnonymousProfileVisitRepository
// privacyBenefitChecker 实时读取 wallet-service 计算后的隐私权益RPC 失败时业务层按保护隐私处理。
privacyBenefitChecker PrivacyBenefitChecker
// vipBenefitDecisionChecker 执行动态头像精确权益门禁并提供 required/current level 元数据。
vipBenefitDecisionChecker VIPBenefitDecisionChecker
// avatarUploadRepository 保存上传授权;资料更新消费仍由 userRepository 的同库事务完成。
avatarUploadRepository AvatarUploadRepository
// identityRepository 持有短号、靓号和变更日志事务能力。
identityRepository IdentityRepository
// levelProfileReader 读取 activity-service 等级投影,靓号池申请必须依赖它判断等级区间。
@ -299,6 +325,10 @@ func New(userRepository UserRepository, options ...Option) *Service {
// MySQL 用户 repository 同时持有公开和匿名足迹表,写入路径在同一 owner 数据库内保持原子。
svc.anonymousProfileVisitRepository = repository
}
if repository, ok := userRepository.(AvatarUploadRepository); ok {
// 生产 MySQL 用户 repository 同时拥有 users 与头像授权表,凭证消费才能和资料更新保持原子。
svc.avatarUploadRepository = repository
}
for _, option := range options {
// option 只改依赖或策略,不做 IO。
option(svc)
@ -316,6 +346,15 @@ func WithPrivacyBenefitChecker(checker PrivacyBenefitChecker) Option {
}
}
// WithVIPBenefitDecisionChecker 注入 wallet-service 对精确权益和 App VIP 策略的最终判断。
func WithVIPBenefitDecisionChecker(checker VIPBenefitDecisionChecker) Option {
return func(s *Service) {
if checker != nil {
s.vipBenefitDecisionChecker = checker
}
}
}
// WithModerationRepository 注入用户治理事务 repository。
func WithModerationRepository(repository ModerationRepository) Option {
return func(s *Service) {

View File

@ -793,7 +793,7 @@ func applicationSelectSQL(suffix string) string {
return `
SELECT
a.application_id, a.relation_type, a.status,
a.room_id, a.room_region_id,
a.room_id, a.room_region_id, a.room_locked,
a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url,
a.gift_count, a.gift_value, a.gift_coin_value, a.billing_receipt_id,
a.source_room_event_id, a.source_command_id,
@ -822,7 +822,7 @@ func scanApplication(scanner interface{ Scan(dest ...any) error }) (cpdomain.App
var app cpdomain.Application
err := scanner.Scan(
&app.ApplicationID, &app.RelationType, &app.Status,
&app.RoomID, &app.RoomRegionID,
&app.RoomID, &app.RoomRegionID, &app.RoomLocked,
&app.Gift.GiftID, &app.Gift.GiftName, &app.Gift.GiftIconURL, &app.Gift.GiftAnimationURL,
&app.Gift.GiftCount, &app.Gift.GiftValue, &app.Gift.CoinSpent, &app.Gift.BillingReceiptID,
&app.SourceEventID, &app.SourceCommandID,
@ -1265,11 +1265,11 @@ func upsertApplicationFromGiftTx(ctx context.Context, tx *sql.Tx, appCode string
// 同一对用户、同一类型已有 pending 申请时刷新礼物快照和过期时间,不生成多条待处理卡片。
if _, err := tx.ExecContext(ctx, `
UPDATE user_cp_applications
SET room_id = ?, room_region_id = ?, gift_id = ?, gift_name = ?, gift_icon_url = ?,
SET room_id = ?, room_region_id = ?, room_locked = ?, gift_id = ?, gift_name = ?, gift_icon_url = ?,
gift_animation_url = ?, gift_count = ?, gift_value = ?, gift_coin_value = ?, formation_feed_eligible = ?, billing_receipt_id = ?,
source_room_event_id = ?, source_command_id = ?, expires_at_ms = ?, updated_at_ms = ?
WHERE app_code = ? AND application_id = ?`,
event.RoomID, event.VisibleRegionID, event.GiftID, event.GiftName, event.GiftIconURL,
event.RoomID, event.VisibleRegionID, event.RoomLocked, event.GiftID, event.GiftName, event.GiftIconURL,
event.GiftAnimationURL, event.GiftCount, event.GiftValue, event.CoinSpent, formationGiftFeedEligibility(event.CoinSpent), event.BillingReceiptID,
event.EventID, event.CommandID, expiresAtMS, nowMs, appCode, application.ApplicationID,
); err != nil {
@ -1277,6 +1277,7 @@ func upsertApplicationFromGiftTx(ctx context.Context, tx *sql.Tx, appCode string
}
application.RoomID = event.RoomID
application.RoomRegionID = event.VisibleRegionID
application.RoomLocked = event.RoomLocked
application.Gift = giftSnapshotFromEvent(event)
application.SourceEventID = event.EventID
application.SourceCommandID = event.CommandID
@ -1289,12 +1290,12 @@ func upsertApplicationFromGiftTx(ctx context.Context, tx *sql.Tx, appCode string
if _, err := tx.ExecContext(ctx, `
INSERT INTO user_cp_applications (
app_code, application_id, requester_user_id, target_user_id, relation_type, status,
room_id, room_region_id, gift_id, gift_name, gift_icon_url, gift_animation_url,
room_id, room_region_id, room_locked, gift_id, gift_name, gift_icon_url, gift_animation_url,
gift_count, gift_value, gift_coin_value, formation_feed_eligible, billing_receipt_id, source_room_event_id, source_command_id,
created_at_ms, updated_at_ms, expires_at_ms, decided_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)`,
appCode, applicationID, event.SenderUserID, event.TargetUserID, relationType, cpdomain.ApplicationStatusPending,
event.RoomID, event.VisibleRegionID, event.GiftID, event.GiftName, event.GiftIconURL, event.GiftAnimationURL,
event.RoomID, event.VisibleRegionID, event.RoomLocked, event.GiftID, event.GiftName, event.GiftIconURL, event.GiftAnimationURL,
event.GiftCount, event.GiftValue, event.CoinSpent, formationGiftFeedEligibility(event.CoinSpent), event.BillingReceiptID, event.EventID, event.CommandID,
nowMs, nowMs, expiresAtMS,
); err != nil {

View File

@ -237,6 +237,7 @@ func TestConsumeGiftEventAddsIntimacyForNormalGiftWhenRelationshipExists(t *test
EventID: "room_evt_normal_gift_cp",
RoomID: "room-normal",
RoomVersion: 3,
RoomLocked: true,
OccurredAtMS: nowMS + 1,
SenderUserID: 4201,
TargetUserID: 4202,
@ -425,6 +426,7 @@ func TestConsumeGiftEventWritesDistinctOutboxEventsInSameMillisecond(t *testing.
EventID: "room_evt_cp_outbox_1",
RoomID: "room-cp-outbox",
RoomVersion: 3,
RoomLocked: true,
OccurredAtMS: nowMS + 1,
SenderUserID: 4501,
TargetUserID: 4502,
@ -461,6 +463,9 @@ func TestConsumeGiftEventWritesDistinctOutboxEventsInSameMillisecond(t *testing.
if first.Application.ApplicationID != second.Application.ApplicationID {
t.Fatalf("same pending application should be refreshed before a decision: first=%s second=%s", first.Application.ApplicationID, second.Application.ApplicationID)
}
if !first.Application.RoomLocked {
t.Fatalf("first CP application must persist room lock snapshot: %+v", first.Application)
}
var outboxCount int64
if err := schema.DB.QueryRowContext(ctx, `

View File

@ -0,0 +1,120 @@
package user
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"hyapp/pkg/appcode"
userdomain "hyapp/services/user-service/internal/domain/user"
)
// GetAvatarUpload 按完整租户主键读取上传凭证PRIMARY KEY 保证资料更新和上传重试不会扫描媒体表。
func (r *Repository) GetAvatarUpload(ctx context.Context, userID int64, uploadID string) (userdomain.AvatarUploadRegistration, bool, error) {
row := r.db.QueryRowContext(ctx, `
SELECT app_code, user_id, avatar_upload_id, client_command_id, media_payload_sha256,
expected_object_key, object_url, status, content_type, media_format, size_bytes,
width, height, animated, frame_count, duration_ms, sha256, created_at_ms, updated_at_ms
FROM user_avatar_upload_registrations
WHERE app_code = ? AND user_id = ? AND avatar_upload_id = ?
LIMIT 1`, appcode.FromContext(ctx), userID, strings.TrimSpace(uploadID))
registration, err := scanAvatarUpload(row)
if errors.Is(err, sql.ErrNoRows) {
return userdomain.AvatarUploadRegistration{}, false, nil
}
return registration, err == nil, err
}
// RegisterAvatarUpload 原子占用客户端 command_id 派生的凭证;并发重试只允许完全相同的媒体载荷。
func (r *Repository) RegisterAvatarUpload(ctx context.Context, registration userdomain.AvatarUploadRegistration) (userdomain.AvatarUploadRegistration, error) {
registration.AppCode = appcode.FromContext(ctx)
registration.UploadID = strings.TrimSpace(registration.UploadID)
registration.ClientCommandID = strings.TrimSpace(registration.ClientCommandID)
registration.MediaPayloadSHA256 = strings.ToLower(strings.TrimSpace(registration.MediaPayloadSHA256))
registration.ExpectedObjectKey = strings.TrimLeft(strings.TrimSpace(registration.ExpectedObjectKey), "/")
registration.ObjectURL, registration.Status = "", "authorized"
if registration.CreatedAtMS <= 0 {
registration.CreatedAtMS = time.Now().UTC().UnixMilli()
}
if registration.UpdatedAtMS <= 0 {
registration.UpdatedAtMS = registration.CreatedAtMS
}
_, err := r.db.ExecContext(ctx, `
INSERT INTO user_avatar_upload_registrations (
app_code, user_id, avatar_upload_id, client_command_id, media_payload_sha256,
expected_object_key, object_url, status, content_type, media_format, size_bytes,
width, height, animated, frame_count, duration_ms, sha256, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, '', 'authorized', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE avatar_upload_id = user_avatar_upload_registrations.avatar_upload_id`,
registration.AppCode, registration.UserID, registration.UploadID, registration.ClientCommandID,
registration.MediaPayloadSHA256, registration.ExpectedObjectKey, registration.Media.ContentType,
registration.Media.Format, registration.Media.SizeBytes, registration.Media.Width, registration.Media.Height,
registration.Media.Animated, registration.Media.FrameCount, registration.Media.DurationMS, registration.Media.SHA256,
registration.CreatedAtMS, registration.UpdatedAtMS)
if err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
saved, exists, err := r.GetAvatarUpload(ctx, registration.UserID, registration.UploadID)
if err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
if !exists || !sameAvatarUploadRecord(saved, registration) {
return userdomain.AvatarUploadRegistration{}, userdomain.ErrAvatarUploadCommandConflict
}
return saved, nil
}
// CompleteAvatarUpload 只允许 authorized -> active并发完成以首次受信 COS URL 为准。
func (r *Repository) CompleteAvatarUpload(ctx context.Context, registration userdomain.AvatarUploadRegistration) (userdomain.AvatarUploadRegistration, error) {
registration.AppCode = appcode.FromContext(ctx)
registration.ObjectURL = strings.TrimSpace(registration.ObjectURL)
registration.UpdatedAtMS = time.Now().UTC().UnixMilli()
_, err := r.db.ExecContext(ctx, `
UPDATE user_avatar_upload_registrations
SET object_url = ?, status = 'active', updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND avatar_upload_id = ? AND status = 'authorized'`,
registration.ObjectURL, registration.UpdatedAtMS, registration.AppCode, registration.UserID, registration.UploadID)
if err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
saved, exists, err := r.GetAvatarUpload(ctx, registration.UserID, registration.UploadID)
if err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
if !exists || !sameAvatarUploadRecord(saved, registration) || saved.Status != "active" || saved.ObjectURL != registration.ObjectURL {
return userdomain.AvatarUploadRegistration{}, userdomain.ErrAvatarUploadCommandConflict
}
return saved, nil
}
func scanAvatarUpload(scanner interface{ Scan(dest ...any) error }) (userdomain.AvatarUploadRegistration, error) {
var registration userdomain.AvatarUploadRegistration
err := scanner.Scan(
&registration.AppCode, &registration.UserID, &registration.UploadID, &registration.ClientCommandID,
&registration.MediaPayloadSHA256, &registration.ExpectedObjectKey, &registration.ObjectURL, &registration.Status,
&registration.Media.ContentType, &registration.Media.Format, &registration.Media.SizeBytes,
&registration.Media.Width, &registration.Media.Height, &registration.Media.Animated,
&registration.Media.FrameCount, &registration.Media.DurationMS, &registration.Media.SHA256,
&registration.CreatedAtMS, &registration.UpdatedAtMS,
)
if err != nil {
return userdomain.AvatarUploadRegistration{}, err
}
registration.Media.URL, registration.Media.ObjectKey = registration.ObjectURL, registration.ExpectedObjectKey
registration.Media.Status = registration.Status
return registration, nil
}
func sameAvatarUploadRecord(left userdomain.AvatarUploadRegistration, right userdomain.AvatarUploadRegistration) bool {
return appcode.Normalize(left.AppCode) == appcode.Normalize(right.AppCode) && left.UserID == right.UserID &&
strings.TrimSpace(left.UploadID) == strings.TrimSpace(right.UploadID) &&
strings.TrimSpace(left.ClientCommandID) == strings.TrimSpace(right.ClientCommandID) &&
strings.EqualFold(strings.TrimSpace(left.MediaPayloadSHA256), strings.TrimSpace(right.MediaPayloadSHA256)) &&
strings.TrimLeft(strings.TrimSpace(left.ExpectedObjectKey), "/") == strings.TrimLeft(strings.TrimSpace(right.ExpectedObjectKey), "/") &&
strings.EqualFold(left.Media.ContentType, right.Media.ContentType) && strings.EqualFold(left.Media.Format, right.Media.Format) &&
left.Media.SizeBytes == right.Media.SizeBytes && left.Media.Width == right.Media.Width && left.Media.Height == right.Media.Height &&
left.Media.Animated == right.Media.Animated && left.Media.FrameCount == right.Media.FrameCount &&
left.Media.DurationMS == right.Media.DurationMS && strings.EqualFold(left.Media.SHA256, right.Media.SHA256)
}

View File

@ -313,7 +313,7 @@ func (r *Repository) UpdateUserProfile(ctx context.Context, command userdomain.P
}
defer tx.Rollback()
user, err := QueryUser(ctx, tx, "WHERE user_id = ? FOR UPDATE", command.UserID)
user, err := QueryUser(ctx, tx, "WHERE app_code = ? AND user_id = ? FOR UPDATE", appcode.Normalize(command.AppCode), command.UserID)
if err == sql.ErrNoRows {
return userdomain.User{}, xerr.New(xerr.NotFound, "user not found")
}
@ -324,6 +324,46 @@ func (r *Repository) UpdateUserProfile(ctx context.Context, command userdomain.P
user.Username = *command.Username
}
if command.Avatar != nil {
if strings.TrimSpace(command.AvatarUploadID) != "" {
// 上传凭证与 users 行在同一事务锁定和消费;仅在 service 层先查会留下并发复用窗口。
var objectURL, status string
err := tx.QueryRowContext(ctx, `
SELECT object_url, status
FROM user_avatar_upload_registrations
WHERE app_code = ? AND user_id = ? AND avatar_upload_id = ?
FOR UPDATE`, appcode.Normalize(command.AppCode), command.UserID, strings.TrimSpace(command.AvatarUploadID)).Scan(&objectURL, &status)
if err == sql.ErrNoRows {
return userdomain.User{}, userdomain.ErrAvatarUploadNotConsumable
}
if err != nil {
return userdomain.User{}, err
}
objectURL = strings.TrimSpace(objectURL)
if objectURL == "" || objectURL != strings.TrimSpace(*command.Avatar) {
return userdomain.User{}, userdomain.ErrAvatarUploadNotConsumable
}
switch status {
case "active":
result, err := tx.ExecContext(ctx, `
UPDATE user_avatar_upload_registrations
SET status = 'consumed', updated_at_ms = ?
WHERE app_code = ? AND user_id = ? AND avatar_upload_id = ? AND status = 'active'`,
command.UpdatedAtMs, appcode.Normalize(command.AppCode), command.UserID, strings.TrimSpace(command.AvatarUploadID))
if err != nil {
return userdomain.User{}, err
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
return userdomain.User{}, userdomain.ErrAvatarUploadNotConsumable
}
case "consumed":
if strings.TrimSpace(user.Avatar) != objectURL {
// 已消费凭证只允许重放首次成功结果,不能把用户后来设置的新头像回滚成旧对象。
return userdomain.User{}, userdomain.ErrAvatarUploadNotConsumable
}
default:
return userdomain.User{}, userdomain.ErrAvatarUploadNotConsumable
}
}
user.Avatar = *command.Avatar
}
if command.Gender != nil {

View File

@ -4,6 +4,7 @@ package grpc
import (
"context"
"strings"
"time"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
@ -836,11 +837,44 @@ func (s *Server) GetUserMicLifetimeStats(ctx context.Context, req *userv1.GetUse
return &userv1.GetUserMicLifetimeStatsResponse{Stats: toProtoMicLifetimeStats(stats)}, nil
}
// AuthorizeUserAvatarUpload 在 gateway 写 COS 前占用上传凭证并执行动态头像权益校验。
func (s *Server) AuthorizeUserAvatarUpload(ctx context.Context, req *userv1.AuthorizeUserAvatarUploadRequest) (*userv1.AuthorizeUserAvatarUploadResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
requestID := ""
if req.GetMeta() != nil {
requestID = req.GetMeta().GetRequestId()
}
registration, err := s.userSvc.AuthorizeUserAvatarUpload(ctx, requestID, req.GetUserId(), req.GetCommandId(), userservice.AvatarMediaFromProto(req.GetMedia()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.AuthorizeUserAvatarUploadResponse{
Allowed: true, AvatarUploadId: registration.UploadID, ObjectKey: registration.ExpectedObjectKey, ServerTimeMs: time.Now().UTC().UnixMilli(),
}, nil
}
// CompleteUserAvatarUpload 固化 COS 返回地址;后续资料更新只能按 avatar_upload_id 消费该记录。
func (s *Server) CompleteUserAvatarUpload(ctx context.Context, req *userv1.CompleteUserAvatarUploadRequest) (*userv1.CompleteUserAvatarUploadResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
registration, err := s.userSvc.CompleteUserAvatarUpload(ctx, req.GetUserId(), req.GetAvatarUploadId(), userservice.AvatarMediaFromProto(req.GetMedia()))
if err != nil {
return nil, xerr.ToGRPCError(err)
}
return &userv1.CompleteUserAvatarUploadResponse{
Active: registration.Status == "active", AvatarUploadId: registration.UploadID,
Media: userservice.AvatarMediaToProto(registration.Media), ServerTimeMs: time.Now().UTC().UnixMilli(),
}, nil
}
// UpdateUserProfile 修改当前用户基础资料。
func (s *Server) UpdateUserProfile(ctx context.Context, req *userv1.UpdateUserProfileRequest) (*userv1.UpdateUserProfileResponse, error) {
ctx = contextWithApp(ctx, req.GetMeta())
// 可选字段由 proto optional 表达service 层根据 nil 区分“不修改”和“清空”。
user, err := s.userSvc.UpdateUserProfile(ctx, req.GetUserId(), req.Username, req.Avatar, req.Gender, req.Birth)
requestID := ""
if req.GetMeta() != nil {
requestID = req.GetMeta().GetRequestId()
}
user, err := s.userSvc.UpdateUserProfile(ctx, req.GetUserId(), req.Username, req.Avatar, req.Gender, req.Birth, req.AvatarUploadId, requestID)
if err != nil {
return nil, xerr.ToGRPCError(err)
}

View File

@ -1,3 +1,8 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 归档回执属于 Wallet owner 库;单文件迁移必须显式固定数据库边界。
USE hyapp_wallet;
-- wallet_outbox archive-only 回执表。本迁移不修改、不删除 wallet_outbox对热表无重写和锁表风险。
CREATE TABLE IF NOT EXISTS wallet_outbox_archive_receipts (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',

View File

@ -1,3 +1,8 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 清理授权和回执都属于 Wallet owner 库;禁止依赖客户端默认数据库。
USE hyapp_wallet;
-- wallet_outbox 自动清理只新增独立窄表,不 ALTER 热 outbox也不会触发表重写member 随未清理归档行增减。
-- 授权行必须由部署流程在隔离库完成 RESTORE_VERIFIED 并核对归档回执后手工写入;迁移本身绝不生成清理授权。
CREATE TABLE IF NOT EXISTS wallet_outbox_archive_members (

View File

@ -69,6 +69,8 @@ type RedPacketCreateCommand struct {
PacketType string
TotalAmount int64
PacketCount int32
// RoomLocked 是 gateway 从 room-service snapshot 读取的展示快照,只随红包资金事实透传。
RoomLocked bool
}
type RedPacketCreateReceipt struct {

View File

@ -26,6 +26,7 @@ type redPacketMetadata struct {
SenderUserID int64 `json:"sender_user_id"`
RoomID string `json:"room_id"`
RegionID int64 `json:"region_id"`
RoomLocked bool `json:"room_locked"`
PacketType string `json:"packet_type"`
TotalAmount int64 `json:"total_amount"`
PacketCount int32 `json:"packet_count"`

View File

@ -89,6 +89,7 @@ func (r *Repository) CreateRedPacket(ctx context.Context, command ledger.RedPack
SenderUserID: command.SenderUserID,
RoomID: command.RoomID,
RegionID: command.RegionID,
RoomLocked: command.RoomLocked,
PacketType: command.PacketType,
TotalAmount: command.TotalAmount,
PacketCount: command.PacketCount,
@ -226,6 +227,7 @@ func redPacketCreatedEvent(transactionID string, commandID string, metadata redP
"sender_user_id": metadata.SenderUserID,
"room_id": metadata.RoomID,
"region_id": metadata.RegionID,
"room_locked": metadata.RoomLocked,
"total_amount": metadata.TotalAmount,
"packet_count": metadata.PacketCount,
"remaining_amount": metadata.RemainingAmount,
@ -240,6 +242,7 @@ func redPacketCreatedEvent(transactionID string, commandID string, metadata redP
}
func redPacketCreateRequestHash(command ledger.RedPacketCreateCommand) string {
// room_locked 是非资金展示快照,不进入幂等冲突哈希:未知结果重试时房间可能已改密,首笔已提交事实仍应原样返回。
return stableHash(fmt.Sprintf("red_packet_create|%s|%s|%d|%s|%d|%s|%d|%d",
appcode.Normalize(command.AppCode),
command.CommandID,

View File

@ -13,6 +13,7 @@ func TestRedPacketCreatedEventCarriesDisplayFacts(t *testing.T) {
SenderUserID: 10001,
RoomID: "9001",
RegionID: 310,
RoomLocked: true,
TotalAmount: 15000,
PacketCount: 5,
RemainingAmount: 15000,
@ -33,4 +34,7 @@ func TestRedPacketCreatedEventCarriesDisplayFacts(t *testing.T) {
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)
}
if payload["room_locked"] != true {
t.Fatalf("red packet created payload must carry room lock snapshot: %+v", payload)
}
}

View File

@ -55,6 +55,7 @@ func (s *Server) CreateRedPacket(ctx context.Context, req *walletv1.CreateRedPac
PacketType: req.GetPacketType(),
TotalAmount: req.GetTotalAmount(),
PacketCount: req.GetPacketCount(),
RoomLocked: req.GetRoomLocked(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)