diff --git a/api/proto/user/v1/auth.pb.go b/api/proto/user/v1/auth.pb.go index 6e480cdf..ebf7b843 100644 --- a/api/proto/user/v1/auth.pb.go +++ b/api/proto/user/v1/auth.pb.go @@ -1001,11 +1001,13 @@ func (x *QuickCreateAccountResponse) GetPasswordSet() bool { // RefreshTokenRequest 使用 refresh token 换取新 token。 type RefreshTokenRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` - RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + RefreshToken string `protobuf:"bytes,2,opt,name=refresh_token,json=refreshToken,proto3" json:"refresh_token,omitempty"` + // refresh_request_id 由客户端按一次 refresh 尝试生成,网络重试必须复用;旧客户端可为空。 + RefreshRequestId string `protobuf:"bytes,3,opt,name=refresh_request_id,json=refreshRequestId,proto3" json:"refresh_request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RefreshTokenRequest) Reset() { @@ -1052,6 +1054,13 @@ func (x *RefreshTokenRequest) GetRefreshToken() string { return "" } +func (x *RefreshTokenRequest) GetRefreshRequestId() string { + if x != nil { + return x.RefreshRequestId + } + return "" +} + // RefreshTokenResponse 返回轮换后的令牌。 type RefreshTokenResponse struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -2048,10 +2057,11 @@ const file_proto_user_v1_auth_proto_rawDesc = "" + "\btimezone\x18\x13 \x01(\tR\btimezone\"o\n" + "\x1aQuickCreateAccountResponse\x12.\n" + "\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\x12!\n" + - "\fpassword_set\x18\x02 \x01(\bR\vpasswordSet\"j\n" + + "\fpassword_set\x18\x02 \x01(\bR\vpasswordSet\"\x98\x01\n" + "\x13RefreshTokenRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12#\n" + - "\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\"F\n" + + "\rrefresh_token\x18\x02 \x01(\tR\frefreshToken\x12,\n" + + "\x12refresh_request_id\x18\x03 \x01(\tR\x10refreshRequestId\"F\n" + "\x14RefreshTokenResponse\x12.\n" + "\x05token\x18\x01 \x01(\v2\x18.hyapp.user.v1.AuthTokenR\x05token\"\x83\x01\n" + "\rLogoutRequest\x12.\n" + diff --git a/api/proto/user/v1/auth.proto b/api/proto/user/v1/auth.proto index 01cca86a..b65798eb 100644 --- a/api/proto/user/v1/auth.proto +++ b/api/proto/user/v1/auth.proto @@ -126,6 +126,8 @@ message QuickCreateAccountResponse { message RefreshTokenRequest { RequestMeta meta = 1; string refresh_token = 2; + // refresh_request_id 由客户端按一次 refresh 尝试生成,网络重试必须复用;旧客户端可为空。 + string refresh_request_id = 3; } // RefreshTokenResponse 返回轮换后的令牌。 diff --git a/api/proto/user/v1/user.pb.go b/api/proto/user/v1/user.pb.go index 178ff95f..a3d585e4 100644 --- a/api/proto/user/v1/user.pb.go +++ b/api/proto/user/v1/user.pb.go @@ -3671,8 +3671,10 @@ type CPGiftSnapshot struct { GiftCount int32 `protobuf:"varint,5,opt,name=gift_count,json=giftCount,proto3" json:"gift_count,omitempty"` GiftValue int64 `protobuf:"varint,6,opt,name=gift_value,json=giftValue,proto3" json:"gift_value,omitempty"` BillingReceiptId string `protobuf:"bytes,7,opt,name=billing_receipt_id,json=billingReceiptId,proto3" json:"billing_receipt_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // coin_spent 是 wallet 结算出的礼物金币价值(背包礼物也保留价值),不能用 gift_value(热度/亲密值)替代。 + CoinSpent int64 `protobuf:"varint,8,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *CPGiftSnapshot) Reset() { @@ -3754,6 +3756,13 @@ func (x *CPGiftSnapshot) GetBillingReceiptId() string { return "" } +func (x *CPGiftSnapshot) GetCoinSpent() int64 { + if x != nil { + return x.CoinSpent + } + return 0 +} + // CPApplication 是 A 给 B 发送 CP/兄弟/姐妹礼物后生成的待处理申请。 type CPApplication struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -5677,8 +5686,10 @@ type RoomGiftCPEvent struct { GiftName string `protobuf:"bytes,16,opt,name=gift_name,json=giftName,proto3" json:"gift_name,omitempty"` GiftIconUrl string `protobuf:"bytes,17,opt,name=gift_icon_url,json=giftIconUrl,proto3" json:"gift_icon_url,omitempty"` GiftAnimationUrl string `protobuf:"bytes,18,opt,name=gift_animation_url,json=giftAnimationUrl,proto3" json:"gift_animation_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // coin_spent 来自 RoomGiftSent 的 wallet 结算价值,和 gift_value 的热度口径彼此独立。 + CoinSpent int64 `protobuf:"varint,19,opt,name=coin_spent,json=coinSpent,proto3" json:"coin_spent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoomGiftCPEvent) Reset() { @@ -5837,6 +5848,13 @@ func (x *RoomGiftCPEvent) GetGiftAnimationUrl() string { return "" } +func (x *RoomGiftCPEvent) GetCoinSpent() int64 { + if x != nil { + return x.CoinSpent + } + return 0 +} + type ConsumeRoomGiftCPEventRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` @@ -13068,6 +13086,179 @@ func (x *AdminGrantPrettyDisplayIDResponse) GetLeaseId() string { return "" } +// CPFormationGiftFeedItem 是组 CP 成立时所用礼物超过固定门槛的页面展示快照。 +type CPFormationGiftFeedItem struct { + state protoimpl.MessageState `protogen:"open.v1"` + FormationId string `protobuf:"bytes,1,opt,name=formation_id,json=formationId,proto3" json:"formation_id,omitempty"` + GiftCoinValue int64 `protobuf:"varint,2,opt,name=gift_coin_value,json=giftCoinValue,proto3" json:"gift_coin_value,omitempty"` + Requester *CPUserProfile `protobuf:"bytes,3,opt,name=requester,proto3" json:"requester,omitempty"` + Target *CPUserProfile `protobuf:"bytes,4,opt,name=target,proto3" json:"target,omitempty"` + FormedAtMs int64 `protobuf:"varint,5,opt,name=formed_at_ms,json=formedAtMs,proto3" json:"formed_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CPFormationGiftFeedItem) Reset() { + *x = CPFormationGiftFeedItem{} + mi := &file_proto_user_v1_user_proto_msgTypes[177] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CPFormationGiftFeedItem) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CPFormationGiftFeedItem) ProtoMessage() {} + +func (x *CPFormationGiftFeedItem) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[177] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CPFormationGiftFeedItem.ProtoReflect.Descriptor instead. +func (*CPFormationGiftFeedItem) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{177} +} + +func (x *CPFormationGiftFeedItem) GetFormationId() string { + if x != nil { + return x.FormationId + } + return "" +} + +func (x *CPFormationGiftFeedItem) GetGiftCoinValue() int64 { + if x != nil { + return x.GiftCoinValue + } + return 0 +} + +func (x *CPFormationGiftFeedItem) GetRequester() *CPUserProfile { + if x != nil { + return x.Requester + } + return nil +} + +func (x *CPFormationGiftFeedItem) GetTarget() *CPUserProfile { + if x != nil { + return x.Target + } + return nil +} + +func (x *CPFormationGiftFeedItem) GetFormedAtMs() int64 { + if x != nil { + return x.FormedAtMs + } + return 0 +} + +type ListCPFormationGiftFeedRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + Limit int32 `protobuf:"varint,2,opt,name=limit,proto3" json:"limit,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListCPFormationGiftFeedRequest) Reset() { + *x = ListCPFormationGiftFeedRequest{} + mi := &file_proto_user_v1_user_proto_msgTypes[178] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListCPFormationGiftFeedRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCPFormationGiftFeedRequest) ProtoMessage() {} + +func (x *ListCPFormationGiftFeedRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[178] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCPFormationGiftFeedRequest.ProtoReflect.Descriptor instead. +func (*ListCPFormationGiftFeedRequest) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{178} +} + +func (x *ListCPFormationGiftFeedRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *ListCPFormationGiftFeedRequest) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +type ListCPFormationGiftFeedResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Items []*CPFormationGiftFeedItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListCPFormationGiftFeedResponse) Reset() { + *x = ListCPFormationGiftFeedResponse{} + mi := &file_proto_user_v1_user_proto_msgTypes[179] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListCPFormationGiftFeedResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListCPFormationGiftFeedResponse) ProtoMessage() {} + +func (x *ListCPFormationGiftFeedResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_user_v1_user_proto_msgTypes[179] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListCPFormationGiftFeedResponse.ProtoReflect.Descriptor instead. +func (*ListCPFormationGiftFeedResponse) Descriptor() ([]byte, []int) { + return file_proto_user_v1_user_proto_rawDescGZIP(), []int{179} +} + +func (x *ListCPFormationGiftFeedResponse) GetItems() []*CPFormationGiftFeedItem { + if x != nil { + return x.Items + } + return nil +} + var File_proto_user_v1_user_proto protoreflect.FileDescriptor const file_proto_user_v1_user_proto_rawDesc = "" + @@ -13369,7 +13560,7 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\auser_id\x18\x01 \x01(\x03R\x06userId\x12&\n" + "\x0fdisplay_user_id\x18\x02 \x01(\tR\rdisplayUserId\x12\x1a\n" + "\busername\x18\x03 \x01(\tR\busername\x12\x16\n" + - "\x06avatar\x18\x04 \x01(\tR\x06avatar\"\x84\x02\n" + + "\x06avatar\x18\x04 \x01(\tR\x06avatar\"\xa3\x02\n" + "\x0eCPGiftSnapshot\x12\x17\n" + "\agift_id\x18\x01 \x01(\tR\x06giftId\x12\x1b\n" + "\tgift_name\x18\x02 \x01(\tR\bgiftName\x12\"\n" + @@ -13379,7 +13570,9 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "gift_count\x18\x05 \x01(\x05R\tgiftCount\x12\x1d\n" + "\n" + "gift_value\x18\x06 \x01(\x03R\tgiftValue\x12,\n" + - "\x12billing_receipt_id\x18\a \x01(\tR\x10billingReceiptId\"\xe7\x03\n" + + "\x12billing_receipt_id\x18\a \x01(\tR\x10billingReceiptId\x12\x1d\n" + + "\n" + + "coin_spent\x18\b \x01(\x03R\tcoinSpent\"\xe7\x03\n" + "\rCPApplication\x12%\n" + "\x0eapplication_id\x18\x01 \x01(\tR\rapplicationId\x12#\n" + "\rrelation_type\x18\x02 \x01(\tR\frelationType\x12\x16\n" + @@ -13545,7 +13738,7 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "!CancelBreakCPRelationshipResponse\x12\x1d\n" + "\n" + "command_id\x18\x01 \x01(\tR\tcommandId\x12\x16\n" + - "\x06status\x18\x02 \x01(\tR\x06status\"\x99\x05\n" + + "\x06status\x18\x02 \x01(\tR\x06status\"\xb8\x05\n" + "\x0fRoomGiftCPEvent\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x19\n" + "\bevent_id\x18\x02 \x01(\tR\aeventId\x12\x17\n" + @@ -13568,7 +13761,9 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\x10cp_relation_type\x18\x0f \x01(\tR\x0ecpRelationType\x12\x1b\n" + "\tgift_name\x18\x10 \x01(\tR\bgiftName\x12\"\n" + "\rgift_icon_url\x18\x11 \x01(\tR\vgiftIconUrl\x12,\n" + - "\x12gift_animation_url\x18\x12 \x01(\tR\x10giftAnimationUrl\"\x85\x01\n" + + "\x12gift_animation_url\x18\x12 \x01(\tR\x10giftAnimationUrl\x12\x1d\n" + + "\n" + + "coin_spent\x18\x13 \x01(\x03R\tcoinSpent\"\x85\x01\n" + "\x1dConsumeRoomGiftCPEventRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x124\n" + "\x05event\x18\x02 \x01(\v2\x1e.hyapp.user.v1.RoomGiftCPEventR\x05event\"\xbf\x01\n" + @@ -14177,7 +14372,19 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "!AdminGrantPrettyDisplayIDResponse\x127\n" + "\bidentity\x18\x01 \x01(\v2\x1b.hyapp.user.v1.UserIdentityR\bidentity\x12\x1b\n" + "\tpretty_id\x18\x02 \x01(\tR\bprettyId\x12\x19\n" + - "\blease_id\x18\x03 \x01(\tR\aleaseId*s\n" + + "\blease_id\x18\x03 \x01(\tR\aleaseId\"\xf8\x01\n" + + "\x17CPFormationGiftFeedItem\x12!\n" + + "\fformation_id\x18\x01 \x01(\tR\vformationId\x12&\n" + + "\x0fgift_coin_value\x18\x02 \x01(\x03R\rgiftCoinValue\x12:\n" + + "\trequester\x18\x03 \x01(\v2\x1c.hyapp.user.v1.CPUserProfileR\trequester\x124\n" + + "\x06target\x18\x04 \x01(\v2\x1c.hyapp.user.v1.CPUserProfileR\x06target\x12 \n" + + "\fformed_at_ms\x18\x05 \x01(\x03R\n" + + "formedAtMs\"f\n" + + "\x1eListCPFormationGiftFeedRequest\x12.\n" + + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.user.v1.RequestMetaR\x04meta\x12\x14\n" + + "\x05limit\x18\x02 \x01(\x05R\x05limit\"_\n" + + "\x1fListCPFormationGiftFeedResponse\x12<\n" + + "\x05items\x18\x01 \x03(\v2&.hyapp.user.v1.CPFormationGiftFeedItemR\x05items*s\n" + "\n" + "UserStatus\x12\x1b\n" + "\x17USER_STATUS_UNSPECIFIED\x10\x00\x12\x16\n" + @@ -14222,7 +14429,7 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\fDeleteFriend\x12\".hyapp.user.v1.DeleteFriendRequest\x1a#.hyapp.user.v1.DeleteFriendResponse\x12T\n" + "\vListFriends\x12!.hyapp.user.v1.ListFriendsRequest\x1a\".hyapp.user.v1.ListFriendsResponse\x12u\n" + "\x16ListFriendApplications\x12,.hyapp.user.v1.ListFriendApplicationsRequest\x1a-.hyapp.user.v1.ListFriendApplicationsResponse\x12W\n" + - "\fSubmitReport\x12\".hyapp.user.v1.SubmitReportRequest\x1a#.hyapp.user.v1.SubmitReportResponse2\xcc\a\n" + + "\fSubmitReport\x12\".hyapp.user.v1.SubmitReportRequest\x1a#.hyapp.user.v1.SubmitReportResponse2\xc6\b\n" + "\rUserCPService\x12i\n" + "\x12ListCPApplications\x12(.hyapp.user.v1.ListCPApplicationsRequest\x1a).hyapp.user.v1.ListCPApplicationsResponse\x12l\n" + "\x13AcceptCPApplication\x12).hyapp.user.v1.AcceptCPApplicationRequest\x1a*.hyapp.user.v1.AcceptCPApplicationResponse\x12l\n" + @@ -14231,7 +14438,8 @@ const file_proto_user_v1_user_proto_rawDesc = "" + "\x19ListCPIntimacyLeaderboard\x12/.hyapp.user.v1.ListCPIntimacyLeaderboardRequest\x1a0.hyapp.user.v1.ListCPIntimacyLeaderboardResponse\x12\x81\x01\n" + "\x1aPrepareBreakCPRelationship\x120.hyapp.user.v1.PrepareBreakCPRelationshipRequest\x1a1.hyapp.user.v1.PrepareBreakCPRelationshipResponse\x12\x81\x01\n" + "\x1aConfirmBreakCPRelationship\x120.hyapp.user.v1.ConfirmBreakCPRelationshipRequest\x1a1.hyapp.user.v1.ConfirmBreakCPRelationshipResponse\x12~\n" + - "\x19CancelBreakCPRelationship\x12/.hyapp.user.v1.CancelBreakCPRelationshipRequest\x1a0.hyapp.user.v1.CancelBreakCPRelationshipResponse2\x88\x02\n" + + "\x19CancelBreakCPRelationship\x12/.hyapp.user.v1.CancelBreakCPRelationshipRequest\x1a0.hyapp.user.v1.CancelBreakCPRelationshipResponse\x12x\n" + + "\x17ListCPFormationGiftFeed\x12-.hyapp.user.v1.ListCPFormationGiftFeedRequest\x1a..hyapp.user.v1.ListCPFormationGiftFeedResponse2\x88\x02\n" + "\x15UserCPInternalService\x12u\n" + "\x16ConsumeRoomGiftCPEvent\x12,.hyapp.user.v1.ConsumeRoomGiftCPEventRequest\x1a-.hyapp.user.v1.ConsumeRoomGiftCPEventResponse\x12x\n" + "\x17ListCPWeeklyRankEntries\x12-.hyapp.user.v1.ListCPWeeklyRankEntriesRequest\x1a..hyapp.user.v1.ListCPWeeklyRankEntriesResponse2\xab\x05\n" + @@ -14291,7 +14499,7 @@ func file_proto_user_v1_user_proto_rawDescGZIP() []byte { } var file_proto_user_v1_user_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 180) +var file_proto_user_v1_user_proto_msgTypes = make([]protoimpl.MessageInfo, 183) var file_proto_user_v1_user_proto_goTypes = []any{ (UserStatus)(0), // 0: hyapp.user.v1.UserStatus (*RequestMeta)(nil), // 1: hyapp.user.v1.RequestMeta @@ -14471,9 +14679,12 @@ var file_proto_user_v1_user_proto_goTypes = []any{ (*PrettyDisplayIDResponse)(nil), // 175: hyapp.user.v1.PrettyDisplayIDResponse (*AdminGrantPrettyDisplayIDRequest)(nil), // 176: hyapp.user.v1.AdminGrantPrettyDisplayIDRequest (*AdminGrantPrettyDisplayIDResponse)(nil), // 177: hyapp.user.v1.AdminGrantPrettyDisplayIDResponse - nil, // 178: hyapp.user.v1.BatchGetUsersResponse.UsersEntry - nil, // 179: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry - nil, // 180: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry + (*CPFormationGiftFeedItem)(nil), // 178: hyapp.user.v1.CPFormationGiftFeedItem + (*ListCPFormationGiftFeedRequest)(nil), // 179: hyapp.user.v1.ListCPFormationGiftFeedRequest + (*ListCPFormationGiftFeedResponse)(nil), // 180: hyapp.user.v1.ListCPFormationGiftFeedResponse + nil, // 181: hyapp.user.v1.BatchGetUsersResponse.UsersEntry + nil, // 182: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry + nil, // 183: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry } var file_proto_user_v1_user_proto_depIdxs = []int32{ 1, // 0: hyapp.user.v1.ResolveAppRequest.meta:type_name -> hyapp.user.v1.RequestMeta @@ -14547,15 +14758,15 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{ 1, // 68: hyapp.user.v1.SubmitReportRequest.meta:type_name -> hyapp.user.v1.RequestMeta 78, // 69: hyapp.user.v1.SubmitReportResponse.report:type_name -> hyapp.user.v1.UserReport 1, // 70: hyapp.user.v1.BatchGetUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 178, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry + 181, // 71: hyapp.user.v1.BatchGetUsersResponse.users:type_name -> hyapp.user.v1.BatchGetUsersResponse.UsersEntry 0, // 72: hyapp.user.v1.ActiveUserBanSummary.user_status:type_name -> hyapp.user.v1.UserStatus 5, // 73: hyapp.user.v1.UserAdminProfile.user:type_name -> hyapp.user.v1.User 83, // 74: hyapp.user.v1.UserAdminProfile.ban:type_name -> hyapp.user.v1.ActiveUserBanSummary 1, // 75: hyapp.user.v1.BatchGetUserAdminProfilesRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 179, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry + 182, // 76: hyapp.user.v1.BatchGetUserAdminProfilesResponse.profiles:type_name -> hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry 1, // 77: hyapp.user.v1.AdminIssueUserAccessTokenRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 78: hyapp.user.v1.BatchGetRoomBasicUsersRequest.meta:type_name -> hyapp.user.v1.RequestMeta - 180, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry + 183, // 79: hyapp.user.v1.BatchGetRoomBasicUsersResponse.users:type_name -> hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry 1, // 80: hyapp.user.v1.ListUserIDsRequest.meta:type_name -> hyapp.user.v1.RequestMeta 1, // 81: hyapp.user.v1.UpdateUserProfileRequest.meta:type_name -> hyapp.user.v1.RequestMeta 5, // 82: hyapp.user.v1.UpdateUserProfileResponse.user:type_name -> hyapp.user.v1.User @@ -14639,172 +14850,178 @@ var file_proto_user_v1_user_proto_depIdxs = []int32{ 158, // 160: hyapp.user.v1.PrettyDisplayIDResponse.item:type_name -> hyapp.user.v1.PrettyDisplayID 1, // 161: hyapp.user.v1.AdminGrantPrettyDisplayIDRequest.meta:type_name -> hyapp.user.v1.RequestMeta 146, // 162: hyapp.user.v1.AdminGrantPrettyDisplayIDResponse.identity:type_name -> hyapp.user.v1.UserIdentity - 5, // 163: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User - 84, // 164: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile - 89, // 165: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser - 17, // 166: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest - 9, // 167: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest - 19, // 168: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest - 23, // 169: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest - 81, // 170: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest - 85, // 171: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest - 87, // 172: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest - 90, // 173: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest - 92, // 174: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest - 12, // 175: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest - 94, // 176: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest - 96, // 177: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest - 98, // 178: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest - 100, // 179: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest - 102, // 180: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest - 103, // 181: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest - 105, // 182: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest - 108, // 183: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest - 110, // 184: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest - 113, // 185: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest - 115, // 186: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest - 117, // 187: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest - 119, // 188: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest - 121, // 189: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest - 123, // 190: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest - 25, // 191: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest - 28, // 192: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest - 30, // 193: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest - 32, // 194: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest - 35, // 195: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest - 37, // 196: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest - 39, // 197: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest - 41, // 198: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest - 44, // 199: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest - 47, // 200: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest - 79, // 201: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest - 59, // 202: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest - 61, // 203: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest - 63, // 204: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest - 65, // 205: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest - 67, // 206: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest - 69, // 207: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest - 71, // 208: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest - 73, // 209: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest - 76, // 210: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest - 57, // 211: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest - 14, // 212: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 213: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 214: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 215: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 216: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 217: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest - 14, // 218: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest - 125, // 219: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest - 127, // 220: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest - 3, // 221: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest - 131, // 222: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest - 133, // 223: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest - 135, // 224: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest - 138, // 225: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest - 140, // 226: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest - 142, // 227: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest - 143, // 228: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest - 144, // 229: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest - 147, // 230: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest - 149, // 231: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest - 151, // 232: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest - 153, // 233: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest - 160, // 234: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest - 162, // 235: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest - 155, // 236: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest - 164, // 237: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest - 166, // 238: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest - 167, // 239: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest - 169, // 240: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest - 171, // 241: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest - 173, // 242: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest - 174, // 243: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest - 176, // 244: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest - 18, // 245: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse - 10, // 246: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse - 21, // 247: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse - 24, // 248: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse - 82, // 249: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse - 86, // 250: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse - 88, // 251: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse - 91, // 252: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse - 93, // 253: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse - 13, // 254: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse - 95, // 255: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse - 97, // 256: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse - 99, // 257: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse - 101, // 258: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse - 104, // 259: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse - 104, // 260: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse - 106, // 261: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse - 109, // 262: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse - 111, // 263: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse - 114, // 264: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse - 116, // 265: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse - 118, // 266: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse - 120, // 267: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse - 122, // 268: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse - 124, // 269: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse - 26, // 270: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse - 29, // 271: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse - 31, // 272: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse - 33, // 273: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse - 36, // 274: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse - 38, // 275: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse - 40, // 276: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse - 42, // 277: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse - 45, // 278: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse - 48, // 279: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse - 80, // 280: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse - 60, // 281: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse - 62, // 282: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse - 64, // 283: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse - 66, // 284: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse - 68, // 285: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse - 70, // 286: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse - 72, // 287: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse - 74, // 288: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse - 77, // 289: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse - 58, // 290: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse - 15, // 291: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 292: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 293: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 294: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 295: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 296: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse - 15, // 297: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse - 126, // 298: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse - 128, // 299: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse - 4, // 300: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse - 132, // 301: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse - 134, // 302: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse - 136, // 303: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse - 139, // 304: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse - 141, // 305: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse - 145, // 306: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse - 145, // 307: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse - 145, // 308: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse - 148, // 309: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse - 150, // 310: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse - 152, // 311: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse - 154, // 312: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse - 161, // 313: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse - 163, // 314: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse - 156, // 315: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse - 165, // 316: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse - 168, // 317: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse - 168, // 318: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse - 170, // 319: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse - 172, // 320: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse - 175, // 321: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse - 175, // 322: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse - 177, // 323: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse - 245, // [245:324] is the sub-list for method output_type - 166, // [166:245] is the sub-list for method input_type - 166, // [166:166] is the sub-list for extension type_name - 166, // [166:166] is the sub-list for extension extendee - 0, // [0:166] is the sub-list for field type_name + 49, // 163: hyapp.user.v1.CPFormationGiftFeedItem.requester:type_name -> hyapp.user.v1.CPUserProfile + 49, // 164: hyapp.user.v1.CPFormationGiftFeedItem.target:type_name -> hyapp.user.v1.CPUserProfile + 1, // 165: hyapp.user.v1.ListCPFormationGiftFeedRequest.meta:type_name -> hyapp.user.v1.RequestMeta + 178, // 166: hyapp.user.v1.ListCPFormationGiftFeedResponse.items:type_name -> hyapp.user.v1.CPFormationGiftFeedItem + 5, // 167: hyapp.user.v1.BatchGetUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.User + 84, // 168: hyapp.user.v1.BatchGetUserAdminProfilesResponse.ProfilesEntry.value:type_name -> hyapp.user.v1.UserAdminProfile + 89, // 169: hyapp.user.v1.BatchGetRoomBasicUsersResponse.UsersEntry.value:type_name -> hyapp.user.v1.RoomBasicUser + 17, // 170: hyapp.user.v1.UserService.GetUser:input_type -> hyapp.user.v1.GetUserRequest + 9, // 171: hyapp.user.v1.UserService.GetInviteAttribution:input_type -> hyapp.user.v1.GetInviteAttributionRequest + 19, // 172: hyapp.user.v1.UserService.BusinessUserLookup:input_type -> hyapp.user.v1.BusinessUserLookupRequest + 23, // 173: hyapp.user.v1.UserService.GetMyProfileStats:input_type -> hyapp.user.v1.GetMyProfileStatsRequest + 81, // 174: hyapp.user.v1.UserService.BatchGetUsers:input_type -> hyapp.user.v1.BatchGetUsersRequest + 85, // 175: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:input_type -> hyapp.user.v1.BatchGetUserAdminProfilesRequest + 87, // 176: hyapp.user.v1.UserService.AdminIssueUserAccessToken:input_type -> hyapp.user.v1.AdminIssueUserAccessTokenRequest + 90, // 177: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:input_type -> hyapp.user.v1.BatchGetRoomBasicUsersRequest + 92, // 178: hyapp.user.v1.UserService.ListUserIDs:input_type -> hyapp.user.v1.ListUserIDsRequest + 12, // 179: hyapp.user.v1.UserService.GetUserMicLifetimeStats:input_type -> hyapp.user.v1.GetUserMicLifetimeStatsRequest + 94, // 180: hyapp.user.v1.UserService.UpdateUserProfile:input_type -> hyapp.user.v1.UpdateUserProfileRequest + 96, // 181: hyapp.user.v1.UserService.UpdateUserProfileBackground:input_type -> hyapp.user.v1.UpdateUserProfileBackgroundRequest + 98, // 182: hyapp.user.v1.UserService.UpdateUserContactInfo:input_type -> hyapp.user.v1.UpdateUserContactInfoRequest + 100, // 183: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:input_type -> hyapp.user.v1.UpdateUserWithdrawAddressRequest + 102, // 184: hyapp.user.v1.UserService.ChangeUserCountry:input_type -> hyapp.user.v1.ChangeUserCountryRequest + 103, // 185: hyapp.user.v1.UserService.AdminChangeUserCountry:input_type -> hyapp.user.v1.AdminChangeUserCountryRequest + 105, // 186: hyapp.user.v1.UserService.SetUserStatus:input_type -> hyapp.user.v1.SetUserStatusRequest + 108, // 187: hyapp.user.v1.UserService.AdminBanUser:input_type -> hyapp.user.v1.AdminBanUserRequest + 110, // 188: hyapp.user.v1.UserService.AdminUnbanUser:input_type -> hyapp.user.v1.AdminUnbanUserRequest + 113, // 189: hyapp.user.v1.UserService.CreateManagerUserBlock:input_type -> hyapp.user.v1.CreateManagerUserBlockRequest + 115, // 190: hyapp.user.v1.UserService.ListManagerUserBlocks:input_type -> hyapp.user.v1.ListManagerUserBlocksRequest + 117, // 191: hyapp.user.v1.UserService.UnblockManagerUser:input_type -> hyapp.user.v1.UnblockManagerUserRequest + 119, // 192: hyapp.user.v1.UserService.CompleteOnboarding:input_type -> hyapp.user.v1.CompleteOnboardingRequest + 121, // 193: hyapp.user.v1.UserService.SearchInviteReferrer:input_type -> hyapp.user.v1.SearchInviteReferrerRequest + 123, // 194: hyapp.user.v1.UserService.BindInviteReferrer:input_type -> hyapp.user.v1.BindInviteReferrerRequest + 25, // 195: hyapp.user.v1.UserSocialService.RecordProfileVisit:input_type -> hyapp.user.v1.RecordProfileVisitRequest + 28, // 196: hyapp.user.v1.UserSocialService.ListProfileVisitors:input_type -> hyapp.user.v1.ListProfileVisitorsRequest + 30, // 197: hyapp.user.v1.UserSocialService.FollowUser:input_type -> hyapp.user.v1.FollowUserRequest + 32, // 198: hyapp.user.v1.UserSocialService.UnfollowUser:input_type -> hyapp.user.v1.UnfollowUserRequest + 35, // 199: hyapp.user.v1.UserSocialService.ListFollowing:input_type -> hyapp.user.v1.ListFollowingRequest + 37, // 200: hyapp.user.v1.UserSocialService.ApplyFriend:input_type -> hyapp.user.v1.ApplyFriendRequest + 39, // 201: hyapp.user.v1.UserSocialService.AcceptFriendApplication:input_type -> hyapp.user.v1.AcceptFriendApplicationRequest + 41, // 202: hyapp.user.v1.UserSocialService.DeleteFriend:input_type -> hyapp.user.v1.DeleteFriendRequest + 44, // 203: hyapp.user.v1.UserSocialService.ListFriends:input_type -> hyapp.user.v1.ListFriendsRequest + 47, // 204: hyapp.user.v1.UserSocialService.ListFriendApplications:input_type -> hyapp.user.v1.ListFriendApplicationsRequest + 79, // 205: hyapp.user.v1.UserSocialService.SubmitReport:input_type -> hyapp.user.v1.SubmitReportRequest + 59, // 206: hyapp.user.v1.UserCPService.ListCPApplications:input_type -> hyapp.user.v1.ListCPApplicationsRequest + 61, // 207: hyapp.user.v1.UserCPService.AcceptCPApplication:input_type -> hyapp.user.v1.AcceptCPApplicationRequest + 63, // 208: hyapp.user.v1.UserCPService.RejectCPApplication:input_type -> hyapp.user.v1.RejectCPApplicationRequest + 65, // 209: hyapp.user.v1.UserCPService.ListCPRelationships:input_type -> hyapp.user.v1.ListCPRelationshipsRequest + 67, // 210: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:input_type -> hyapp.user.v1.ListCPIntimacyLeaderboardRequest + 69, // 211: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:input_type -> hyapp.user.v1.PrepareBreakCPRelationshipRequest + 71, // 212: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:input_type -> hyapp.user.v1.ConfirmBreakCPRelationshipRequest + 73, // 213: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:input_type -> hyapp.user.v1.CancelBreakCPRelationshipRequest + 179, // 214: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:input_type -> hyapp.user.v1.ListCPFormationGiftFeedRequest + 76, // 215: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:input_type -> hyapp.user.v1.ConsumeRoomGiftCPEventRequest + 57, // 216: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:input_type -> hyapp.user.v1.ListCPWeeklyRankEntriesRequest + 14, // 217: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 218: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 219: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 220: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 221: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 222: hyapp.user.v1.UserCronService.ExpireAdminUserBans:input_type -> hyapp.user.v1.CronBatchRequest + 14, // 223: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:input_type -> hyapp.user.v1.CronBatchRequest + 125, // 224: hyapp.user.v1.UserDeviceService.BindPushToken:input_type -> hyapp.user.v1.BindPushTokenRequest + 127, // 225: hyapp.user.v1.UserDeviceService.DeletePushToken:input_type -> hyapp.user.v1.DeletePushTokenRequest + 3, // 226: hyapp.user.v1.AppRegistryService.ResolveApp:input_type -> hyapp.user.v1.ResolveAppRequest + 131, // 227: hyapp.user.v1.CountryAdminService.ListCountries:input_type -> hyapp.user.v1.ListCountriesRequest + 133, // 228: hyapp.user.v1.CountryAdminService.UpdateCountry:input_type -> hyapp.user.v1.UpdateCountryRequest + 135, // 229: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:input_type -> hyapp.user.v1.ListRegistrationCountriesRequest + 138, // 230: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:input_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesRequest + 140, // 231: hyapp.user.v1.RegionAdminService.ListRegions:input_type -> hyapp.user.v1.ListRegionsRequest + 142, // 232: hyapp.user.v1.RegionAdminService.GetRegion:input_type -> hyapp.user.v1.GetRegionRequest + 143, // 233: hyapp.user.v1.RegionAdminService.UpdateRegion:input_type -> hyapp.user.v1.UpdateRegionRequest + 144, // 234: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:input_type -> hyapp.user.v1.ReplaceRegionCountriesRequest + 147, // 235: hyapp.user.v1.UserIdentityService.GetUserIdentity:input_type -> hyapp.user.v1.GetUserIdentityRequest + 149, // 236: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:input_type -> hyapp.user.v1.ResolveDisplayUserIDRequest + 151, // 237: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:input_type -> hyapp.user.v1.ChangeDisplayUserIDRequest + 153, // 238: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:input_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDRequest + 160, // 239: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:input_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsRequest + 162, // 240: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:input_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolRequest + 155, // 241: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:input_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDRequest + 164, // 242: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:input_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsRequest + 166, // 243: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:input_type -> hyapp.user.v1.CreatePrettyDisplayIDPoolRequest + 167, // 244: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:input_type -> hyapp.user.v1.UpdatePrettyDisplayIDPoolRequest + 169, // 245: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:input_type -> hyapp.user.v1.GeneratePrettyDisplayIDsRequest + 171, // 246: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:input_type -> hyapp.user.v1.ListPrettyDisplayIDsRequest + 173, // 247: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:input_type -> hyapp.user.v1.SetPrettyDisplayIDStatusRequest + 174, // 248: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:input_type -> hyapp.user.v1.RecyclePrettyDisplayIDRequest + 176, // 249: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:input_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDRequest + 18, // 250: hyapp.user.v1.UserService.GetUser:output_type -> hyapp.user.v1.GetUserResponse + 10, // 251: hyapp.user.v1.UserService.GetInviteAttribution:output_type -> hyapp.user.v1.GetInviteAttributionResponse + 21, // 252: hyapp.user.v1.UserService.BusinessUserLookup:output_type -> hyapp.user.v1.BusinessUserLookupResponse + 24, // 253: hyapp.user.v1.UserService.GetMyProfileStats:output_type -> hyapp.user.v1.GetMyProfileStatsResponse + 82, // 254: hyapp.user.v1.UserService.BatchGetUsers:output_type -> hyapp.user.v1.BatchGetUsersResponse + 86, // 255: hyapp.user.v1.UserService.BatchGetUserAdminProfiles:output_type -> hyapp.user.v1.BatchGetUserAdminProfilesResponse + 88, // 256: hyapp.user.v1.UserService.AdminIssueUserAccessToken:output_type -> hyapp.user.v1.AdminIssueUserAccessTokenResponse + 91, // 257: hyapp.user.v1.UserService.BatchGetRoomBasicUsers:output_type -> hyapp.user.v1.BatchGetRoomBasicUsersResponse + 93, // 258: hyapp.user.v1.UserService.ListUserIDs:output_type -> hyapp.user.v1.ListUserIDsResponse + 13, // 259: hyapp.user.v1.UserService.GetUserMicLifetimeStats:output_type -> hyapp.user.v1.GetUserMicLifetimeStatsResponse + 95, // 260: hyapp.user.v1.UserService.UpdateUserProfile:output_type -> hyapp.user.v1.UpdateUserProfileResponse + 97, // 261: hyapp.user.v1.UserService.UpdateUserProfileBackground:output_type -> hyapp.user.v1.UpdateUserProfileBackgroundResponse + 99, // 262: hyapp.user.v1.UserService.UpdateUserContactInfo:output_type -> hyapp.user.v1.UpdateUserContactInfoResponse + 101, // 263: hyapp.user.v1.UserService.UpdateUserWithdrawAddress:output_type -> hyapp.user.v1.UpdateUserWithdrawAddressResponse + 104, // 264: hyapp.user.v1.UserService.ChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse + 104, // 265: hyapp.user.v1.UserService.AdminChangeUserCountry:output_type -> hyapp.user.v1.ChangeUserCountryResponse + 106, // 266: hyapp.user.v1.UserService.SetUserStatus:output_type -> hyapp.user.v1.SetUserStatusResponse + 109, // 267: hyapp.user.v1.UserService.AdminBanUser:output_type -> hyapp.user.v1.AdminBanUserResponse + 111, // 268: hyapp.user.v1.UserService.AdminUnbanUser:output_type -> hyapp.user.v1.AdminUnbanUserResponse + 114, // 269: hyapp.user.v1.UserService.CreateManagerUserBlock:output_type -> hyapp.user.v1.CreateManagerUserBlockResponse + 116, // 270: hyapp.user.v1.UserService.ListManagerUserBlocks:output_type -> hyapp.user.v1.ListManagerUserBlocksResponse + 118, // 271: hyapp.user.v1.UserService.UnblockManagerUser:output_type -> hyapp.user.v1.UnblockManagerUserResponse + 120, // 272: hyapp.user.v1.UserService.CompleteOnboarding:output_type -> hyapp.user.v1.CompleteOnboardingResponse + 122, // 273: hyapp.user.v1.UserService.SearchInviteReferrer:output_type -> hyapp.user.v1.SearchInviteReferrerResponse + 124, // 274: hyapp.user.v1.UserService.BindInviteReferrer:output_type -> hyapp.user.v1.BindInviteReferrerResponse + 26, // 275: hyapp.user.v1.UserSocialService.RecordProfileVisit:output_type -> hyapp.user.v1.RecordProfileVisitResponse + 29, // 276: hyapp.user.v1.UserSocialService.ListProfileVisitors:output_type -> hyapp.user.v1.ListProfileVisitorsResponse + 31, // 277: hyapp.user.v1.UserSocialService.FollowUser:output_type -> hyapp.user.v1.FollowUserResponse + 33, // 278: hyapp.user.v1.UserSocialService.UnfollowUser:output_type -> hyapp.user.v1.UnfollowUserResponse + 36, // 279: hyapp.user.v1.UserSocialService.ListFollowing:output_type -> hyapp.user.v1.ListFollowingResponse + 38, // 280: hyapp.user.v1.UserSocialService.ApplyFriend:output_type -> hyapp.user.v1.ApplyFriendResponse + 40, // 281: hyapp.user.v1.UserSocialService.AcceptFriendApplication:output_type -> hyapp.user.v1.AcceptFriendApplicationResponse + 42, // 282: hyapp.user.v1.UserSocialService.DeleteFriend:output_type -> hyapp.user.v1.DeleteFriendResponse + 45, // 283: hyapp.user.v1.UserSocialService.ListFriends:output_type -> hyapp.user.v1.ListFriendsResponse + 48, // 284: hyapp.user.v1.UserSocialService.ListFriendApplications:output_type -> hyapp.user.v1.ListFriendApplicationsResponse + 80, // 285: hyapp.user.v1.UserSocialService.SubmitReport:output_type -> hyapp.user.v1.SubmitReportResponse + 60, // 286: hyapp.user.v1.UserCPService.ListCPApplications:output_type -> hyapp.user.v1.ListCPApplicationsResponse + 62, // 287: hyapp.user.v1.UserCPService.AcceptCPApplication:output_type -> hyapp.user.v1.AcceptCPApplicationResponse + 64, // 288: hyapp.user.v1.UserCPService.RejectCPApplication:output_type -> hyapp.user.v1.RejectCPApplicationResponse + 66, // 289: hyapp.user.v1.UserCPService.ListCPRelationships:output_type -> hyapp.user.v1.ListCPRelationshipsResponse + 68, // 290: hyapp.user.v1.UserCPService.ListCPIntimacyLeaderboard:output_type -> hyapp.user.v1.ListCPIntimacyLeaderboardResponse + 70, // 291: hyapp.user.v1.UserCPService.PrepareBreakCPRelationship:output_type -> hyapp.user.v1.PrepareBreakCPRelationshipResponse + 72, // 292: hyapp.user.v1.UserCPService.ConfirmBreakCPRelationship:output_type -> hyapp.user.v1.ConfirmBreakCPRelationshipResponse + 74, // 293: hyapp.user.v1.UserCPService.CancelBreakCPRelationship:output_type -> hyapp.user.v1.CancelBreakCPRelationshipResponse + 180, // 294: hyapp.user.v1.UserCPService.ListCPFormationGiftFeed:output_type -> hyapp.user.v1.ListCPFormationGiftFeedResponse + 77, // 295: hyapp.user.v1.UserCPInternalService.ConsumeRoomGiftCPEvent:output_type -> hyapp.user.v1.ConsumeRoomGiftCPEventResponse + 58, // 296: hyapp.user.v1.UserCPInternalService.ListCPWeeklyRankEntries:output_type -> hyapp.user.v1.ListCPWeeklyRankEntriesResponse + 15, // 297: hyapp.user.v1.UserCronService.ProcessLoginIPRiskBatch:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 298: hyapp.user.v1.UserCronService.ProcessRegionRebuildBatch:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 299: hyapp.user.v1.UserCronService.CompensateMicOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 300: hyapp.user.v1.UserCronService.CompensateRoomOpenSessions:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 301: hyapp.user.v1.UserCronService.ExpireManagerUserBlocks:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 302: hyapp.user.v1.UserCronService.ExpireAdminUserBans:output_type -> hyapp.user.v1.CronBatchResponse + 15, // 303: hyapp.user.v1.UserCronService.RefreshCPIntimacyLeaderboard:output_type -> hyapp.user.v1.CronBatchResponse + 126, // 304: hyapp.user.v1.UserDeviceService.BindPushToken:output_type -> hyapp.user.v1.BindPushTokenResponse + 128, // 305: hyapp.user.v1.UserDeviceService.DeletePushToken:output_type -> hyapp.user.v1.DeletePushTokenResponse + 4, // 306: hyapp.user.v1.AppRegistryService.ResolveApp:output_type -> hyapp.user.v1.ResolveAppResponse + 132, // 307: hyapp.user.v1.CountryAdminService.ListCountries:output_type -> hyapp.user.v1.ListCountriesResponse + 134, // 308: hyapp.user.v1.CountryAdminService.UpdateCountry:output_type -> hyapp.user.v1.CountryResponse + 136, // 309: hyapp.user.v1.CountryQueryService.ListRegistrationCountries:output_type -> hyapp.user.v1.ListRegistrationCountriesResponse + 139, // 310: hyapp.user.v1.CountryQueryService.ListLoginRiskBlockedCountries:output_type -> hyapp.user.v1.ListLoginRiskBlockedCountriesResponse + 141, // 311: hyapp.user.v1.RegionAdminService.ListRegions:output_type -> hyapp.user.v1.ListRegionsResponse + 145, // 312: hyapp.user.v1.RegionAdminService.GetRegion:output_type -> hyapp.user.v1.RegionResponse + 145, // 313: hyapp.user.v1.RegionAdminService.UpdateRegion:output_type -> hyapp.user.v1.RegionResponse + 145, // 314: hyapp.user.v1.RegionAdminService.ReplaceRegionCountries:output_type -> hyapp.user.v1.RegionResponse + 148, // 315: hyapp.user.v1.UserIdentityService.GetUserIdentity:output_type -> hyapp.user.v1.GetUserIdentityResponse + 150, // 316: hyapp.user.v1.UserIdentityService.ResolveDisplayUserID:output_type -> hyapp.user.v1.ResolveDisplayUserIDResponse + 152, // 317: hyapp.user.v1.UserIdentityService.ChangeDisplayUserID:output_type -> hyapp.user.v1.ChangeDisplayUserIDResponse + 154, // 318: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayUserID:output_type -> hyapp.user.v1.ApplyPrettyDisplayUserIDResponse + 161, // 319: hyapp.user.v1.UserIdentityService.ListAvailablePrettyDisplayIDs:output_type -> hyapp.user.v1.ListAvailablePrettyDisplayIDsResponse + 163, // 320: hyapp.user.v1.UserIdentityService.ApplyPrettyDisplayIDFromPool:output_type -> hyapp.user.v1.ApplyPrettyDisplayIDFromPoolResponse + 156, // 321: hyapp.user.v1.UserIdentityService.ExpirePrettyDisplayUserID:output_type -> hyapp.user.v1.ExpirePrettyDisplayUserIDResponse + 165, // 322: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDPools:output_type -> hyapp.user.v1.ListPrettyDisplayIDPoolsResponse + 168, // 323: hyapp.user.v1.UserPrettyDisplayIDAdminService.CreatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse + 168, // 324: hyapp.user.v1.UserPrettyDisplayIDAdminService.UpdatePrettyDisplayIDPool:output_type -> hyapp.user.v1.PrettyDisplayIDPoolResponse + 170, // 325: hyapp.user.v1.UserPrettyDisplayIDAdminService.GeneratePrettyDisplayIDs:output_type -> hyapp.user.v1.GeneratePrettyDisplayIDsResponse + 172, // 326: hyapp.user.v1.UserPrettyDisplayIDAdminService.ListPrettyDisplayIDs:output_type -> hyapp.user.v1.ListPrettyDisplayIDsResponse + 175, // 327: hyapp.user.v1.UserPrettyDisplayIDAdminService.SetPrettyDisplayIDStatus:output_type -> hyapp.user.v1.PrettyDisplayIDResponse + 175, // 328: hyapp.user.v1.UserPrettyDisplayIDAdminService.RecyclePrettyDisplayID:output_type -> hyapp.user.v1.PrettyDisplayIDResponse + 177, // 329: hyapp.user.v1.UserPrettyDisplayIDAdminService.AdminGrantPrettyDisplayID:output_type -> hyapp.user.v1.AdminGrantPrettyDisplayIDResponse + 250, // [250:330] is the sub-list for method output_type + 170, // [170:250] is the sub-list for method input_type + 170, // [170:170] is the sub-list for extension type_name + 170, // [170:170] is the sub-list for extension extendee + 0, // [0:170] is the sub-list for field type_name } func init() { file_proto_user_v1_user_proto_init() } @@ -14820,7 +15037,7 @@ func file_proto_user_v1_user_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_user_v1_user_proto_rawDesc), len(file_proto_user_v1_user_proto_rawDesc)), NumEnums: 1, - NumMessages: 180, + NumMessages: 183, NumExtensions: 0, NumServices: 12, }, diff --git a/api/proto/user/v1/user.proto b/api/proto/user/v1/user.proto index 66e90f54..3f4d21d5 100644 --- a/api/proto/user/v1/user.proto +++ b/api/proto/user/v1/user.proto @@ -416,6 +416,8 @@ message CPGiftSnapshot { int32 gift_count = 5; int64 gift_value = 6; string billing_receipt_id = 7; + // coin_spent 是 wallet 结算出的礼物金币价值(背包礼物也保留价值),不能用 gift_value(热度/亲密值)替代。 + int64 coin_spent = 8; } // CPApplication 是 A 给 B 发送 CP/兄弟/姐妹礼物后生成的待处理申请。 @@ -649,6 +651,8 @@ message RoomGiftCPEvent { string gift_name = 16; string gift_icon_url = 17; string gift_animation_url = 18; + // coin_spent 来自 RoomGiftSent 的 wallet 结算价值,和 gift_value 的热度口径彼此独立。 + int64 coin_spent = 19; } message ConsumeRoomGiftCPEventRequest { @@ -1457,6 +1461,24 @@ message AdminGrantPrettyDisplayIDResponse { string lease_id = 3; } +// CPFormationGiftFeedItem 是组 CP 成立时所用礼物超过固定门槛的页面展示快照。 +message CPFormationGiftFeedItem { + string formation_id = 1; + int64 gift_coin_value = 2; + CPUserProfile requester = 3; + CPUserProfile target = 4; + int64 formed_at_ms = 5; +} + +message ListCPFormationGiftFeedRequest { + RequestMeta meta = 1; + int32 limit = 2; +} + +message ListCPFormationGiftFeedResponse { + repeated CPFormationGiftFeedItem items = 1; +} + // UserService 提供用户主状态、基础资料和注册资料读写接口。 service UserService { rpc GetUser(GetUserRequest) returns (GetUserResponse); @@ -1511,6 +1533,7 @@ service UserCPService { rpc PrepareBreakCPRelationship(PrepareBreakCPRelationshipRequest) returns (PrepareBreakCPRelationshipResponse); rpc ConfirmBreakCPRelationship(ConfirmBreakCPRelationshipRequest) returns (ConfirmBreakCPRelationshipResponse); rpc CancelBreakCPRelationship(CancelBreakCPRelationshipRequest) returns (CancelBreakCPRelationshipResponse); + rpc ListCPFormationGiftFeed(ListCPFormationGiftFeedRequest) returns (ListCPFormationGiftFeedResponse); } // UserCPInternalService 给 owner outbox 消费 worker 调用,不暴露给 App 或后台。 diff --git a/api/proto/user/v1/user_grpc.pb.go b/api/proto/user/v1/user_grpc.pb.go index 8e6627e7..35be14ac 100644 --- a/api/proto/user/v1/user_grpc.pb.go +++ b/api/proto/user/v1/user_grpc.pb.go @@ -1531,6 +1531,7 @@ const ( UserCPService_PrepareBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/PrepareBreakCPRelationship" UserCPService_ConfirmBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/ConfirmBreakCPRelationship" UserCPService_CancelBreakCPRelationship_FullMethodName = "/hyapp.user.v1.UserCPService/CancelBreakCPRelationship" + UserCPService_ListCPFormationGiftFeed_FullMethodName = "/hyapp.user.v1.UserCPService/ListCPFormationGiftFeed" ) // UserCPServiceClient is the client API for UserCPService service. @@ -1547,6 +1548,7 @@ type UserCPServiceClient interface { PrepareBreakCPRelationship(ctx context.Context, in *PrepareBreakCPRelationshipRequest, opts ...grpc.CallOption) (*PrepareBreakCPRelationshipResponse, error) ConfirmBreakCPRelationship(ctx context.Context, in *ConfirmBreakCPRelationshipRequest, opts ...grpc.CallOption) (*ConfirmBreakCPRelationshipResponse, error) CancelBreakCPRelationship(ctx context.Context, in *CancelBreakCPRelationshipRequest, opts ...grpc.CallOption) (*CancelBreakCPRelationshipResponse, error) + ListCPFormationGiftFeed(ctx context.Context, in *ListCPFormationGiftFeedRequest, opts ...grpc.CallOption) (*ListCPFormationGiftFeedResponse, error) } type userCPServiceClient struct { @@ -1637,6 +1639,16 @@ func (c *userCPServiceClient) CancelBreakCPRelationship(ctx context.Context, in return out, nil } +func (c *userCPServiceClient) ListCPFormationGiftFeed(ctx context.Context, in *ListCPFormationGiftFeedRequest, opts ...grpc.CallOption) (*ListCPFormationGiftFeedResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListCPFormationGiftFeedResponse) + err := c.cc.Invoke(ctx, UserCPService_ListCPFormationGiftFeed_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // UserCPServiceServer is the server API for UserCPService service. // All implementations must embed UnimplementedUserCPServiceServer // for forward compatibility. @@ -1651,6 +1663,7 @@ type UserCPServiceServer interface { PrepareBreakCPRelationship(context.Context, *PrepareBreakCPRelationshipRequest) (*PrepareBreakCPRelationshipResponse, error) ConfirmBreakCPRelationship(context.Context, *ConfirmBreakCPRelationshipRequest) (*ConfirmBreakCPRelationshipResponse, error) CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error) + ListCPFormationGiftFeed(context.Context, *ListCPFormationGiftFeedRequest) (*ListCPFormationGiftFeedResponse, error) mustEmbedUnimplementedUserCPServiceServer() } @@ -1685,6 +1698,9 @@ func (UnimplementedUserCPServiceServer) ConfirmBreakCPRelationship(context.Conte func (UnimplementedUserCPServiceServer) CancelBreakCPRelationship(context.Context, *CancelBreakCPRelationshipRequest) (*CancelBreakCPRelationshipResponse, error) { return nil, status.Error(codes.Unimplemented, "method CancelBreakCPRelationship not implemented") } +func (UnimplementedUserCPServiceServer) ListCPFormationGiftFeed(context.Context, *ListCPFormationGiftFeedRequest) (*ListCPFormationGiftFeedResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListCPFormationGiftFeed not implemented") +} func (UnimplementedUserCPServiceServer) mustEmbedUnimplementedUserCPServiceServer() {} func (UnimplementedUserCPServiceServer) testEmbeddedByValue() {} @@ -1850,6 +1866,24 @@ func _UserCPService_CancelBreakCPRelationship_Handler(srv interface{}, ctx conte return interceptor(ctx, in, info, handler) } +func _UserCPService_ListCPFormationGiftFeed_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListCPFormationGiftFeedRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserCPServiceServer).ListCPFormationGiftFeed(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserCPService_ListCPFormationGiftFeed_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserCPServiceServer).ListCPFormationGiftFeed(ctx, req.(*ListCPFormationGiftFeedRequest)) + } + return interceptor(ctx, in, info, handler) +} + // UserCPService_ServiceDesc is the grpc.ServiceDesc for UserCPService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -1889,6 +1923,10 @@ var UserCPService_ServiceDesc = grpc.ServiceDesc{ MethodName: "CancelBreakCPRelationship", Handler: _UserCPService_CancelBreakCPRelationship_Handler, }, + { + MethodName: "ListCPFormationGiftFeed", + Handler: _UserCPService_ListCPFormationGiftFeed_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "proto/user/v1/user.proto", diff --git a/docker-compose.yml b/docker-compose.yml index 226b584a..742ad547 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -132,6 +132,8 @@ services: depends_on: mysql: condition: service_healthy + redis: + condition: service_healthy rocketmq-broker: condition: service_started healthcheck: @@ -381,10 +383,14 @@ services: redis: image: redis:7.4-alpine container_name: hyapp-redis + # Session denylist 不能被内存淘汰;AOF + volume 保证正常重启时先恢复撤销事实再接受连接。 + command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--maxmemory-policy", "noeviction"] environment: TZ: UTC ports: - "13379:6379" + volumes: + - redis-data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s @@ -393,4 +399,5 @@ services: volumes: mysql-data: + redis-data: rocketmq-store: diff --git a/docs/flutter对接/房内猜拳Flutter对接.md b/docs/flutter对接/房内猜拳Flutter对接.md index bb21a0ef..6d70aa2b 100644 --- a/docs/flutter对接/房内猜拳Flutter对接.md +++ b/docs/flutter对接/房内猜拳Flutter对接.md @@ -198,6 +198,18 @@ Content-Type: application/json - `room_rps_reveal_countdown` - `room_rps_finished` +当挑战到达 `timeout_at_ms` 后,应战接口返回 HTTP `409`: + +```json +{ + "code": "ROOM_RPS_CHALLENGE_EXPIRED", + "message": "猜拳已过期", + "request_id": "req_xxx" +} +``` + +Flutter 必须按 `code` 识别该状态,关闭应战弹窗并提示“猜拳已过期”;不要依赖 `message` 判断业务类型。 + ## 7. 查询挑战详情 地址: @@ -319,4 +331,3 @@ IM 示例: } } ``` - diff --git a/docs/房内猜拳接口文档.md b/docs/房内猜拳接口文档.md index d0ff83ea..aa280760 100644 --- a/docs/房内猜拳接口文档.md +++ b/docs/房内猜拳接口文档.md @@ -51,6 +51,7 @@ - `gesture`:`rock` / `paper` / `scissors` - 返回值: - `challenge`:应战后的挑战单 +- 超时错误:HTTP `409`,`code=ROOM_RPS_CHALLENGE_EXPIRED`。客户端应关闭当前挑战弹窗并提示“猜拳已过期”。 - 相关 IM: - `room_rps_challenge_accepted`:服务端通知房间挑战已被锁定,带发起人和应战人头像昵称。 - `room_rps_reveal_countdown`:服务端通知双方展示 3 秒倒计时,带发起人和应战人头像昵称。 diff --git a/docs/钱包服务架构.md b/docs/钱包服务架构.md index 83e85924..f9db21cd 100644 --- a/docs/钱包服务架构.md +++ b/docs/钱包服务架构.md @@ -84,8 +84,8 @@ - 送礼实时加主播 `GIFT_POINT`,但美元奖励由结算任务按政策转换,不能写死在送礼链路。 - 奖励结算生成 `anchor_reward_settlements`,确认后加主播 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`。 -- 提现申请必须先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,审核拒绝解冻,审核通过后进入人工打款流程。 -- 人工打款完成后从冻结余额出账;打款失败回到可重试状态,不能自动解冻后丢失审核上下文。 +- 提现申请必须先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,再依次经过运营初审和财务终审。运营通过只转交财务,不改 frozen;任一阶段拒绝都释放 frozen。 +- 财务确认人工打款凭证并通过终审后从冻结余额出账;外部动作失败时保留当前审核阶段,依靠阶段固定幂等命令重试,不能自动解冻后丢失审核上下文。 ## Component Diagram @@ -333,25 +333,34 @@ flowchart LR 1. 送礼时钱包给主播增加 `GIFT_POINT`。 2. 结算任务读取积分和政策,生成 `anchor_reward_settlements`。 3. 结算单确认后调用钱包 `CreditRewardBalance`,增加主播 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`。 -4. 主播提现时先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,审核拒绝解冻,审核通过后等待人工转账。 -5. 人工转账完成后把冻结余额出账,状态改为 `paid`。 +4. 主播提现时先冻结 `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD`,申请进入运营初审。 +5. 运营通过只把申请转入财务终审,冻结金额保持不变;运营拒绝立即释放 frozen 并结束申请。 +6. 财务结合人工转账凭证终审:通过后扣减 frozen,拒绝后释放 frozen。 提现状态机: ```mermaid stateDiagram-v2 - [*] --> pending_review - pending_review --> rejected - rejected --> refunded - pending_review --> approved - approved --> paying - paying --> paid - paying --> pay_failed - pay_failed --> approved + [*] --> operations_pending + operations_pending --> operations_rejecting + operations_rejecting --> operations_rejecting: retry rejection + operations_rejecting --> operations_rejected: release + notice + finalize + operations_rejected --> released + operations_pending --> finance_pending + finance_pending --> finance_rejected + finance_rejected --> released + finance_pending --> approved + approved --> settled ``` 提现申请时必须从 `available_amount` 转到 `frozen_amount`,不能只写一个待审核单。否则审核期间用户可以重复提现同一笔余额。 +运营拒绝先在 admin 独立短事务把 `operations_status` 从 `pending` 改为 `rejecting`,固定第一次审核人、原因和 operations stage command;再执行 wallet release 与幂等通知,最后以第二个短事务收敛为 operations/overall 双重 `rejected`。因此 release 已成功但通知或最终提交失败时,申请仍保持 `rejecting`,只能重试拒绝,不能改点运营通过或进入财务。 + +运营/财务审核的资金幂等边界是 `(app_code, withdrawal_application_id)`,不是审核人、备注或单一版本的 command id。`wallet_withdrawal_terminal_locks` 先串行化同一申请的 settle/release,再通过 `wallet_transactions.external_ref` 读取已有终局:同决策换审核人或修改备注时返回原回执,相反决策返回 `IDEMPOTENCY_CONFLICT`。这同时兼容旧 `salary-withdrawal::approved|rejected` 和新 `salary-withdrawal::finance` command;财务通知继续使用 `finance-withdrawal::` 事件 ID,避免旧通知已成功但 admin 未终态时重复发送。 + +新审核流程不能与旧 admin 混跑发布:先执行 wallet 终局锁迁移并完成 wallet-service 滚动,再摘流旧 admin、执行 admin 102、部署新 admin,恢复 admin 后才滚动 gateway。admin 102 只把执行时已存在且已经终审的历史行标记为 `operations_status=skipped`;尚未终审的存量申请和发布窗口内由旧 gateway 新建的申请都保持数据库默认 `pending`,必须先进入运营初审。 + ## RPC Surface 当前开发阶段直接使用新的 wallet RPC 契约: @@ -365,7 +374,7 @@ stateDiagram-v2 - `ExchangeDiamond`: 钻石兑换金币或美元余额。 - `CreditRewardBalance`: 结算任务给主播发美元余额奖励。 - `CreateWithdrawRequest`: 主播提现申请并冻结余额。 -- `ReviewWithdrawRequest`: 后台审核提现。 +- `ReviewWithdrawRequest`: 后台按运营初审、财务终审推进提现;运营通过不调用钱包,任一拒绝调用 release,财务通过调用 settle。 - `MarkWithdrawPaid`: 人工打款后确认出账。 外部 HTTP 入口仍在 `gateway-service`,内部统一 gRPC + protobuf。当前处于开发阶段,不做历史兼容保留;修改 `api/proto` 后必须运行 `make proto` 和 `go test ./...`。 @@ -500,8 +509,9 @@ stateDiagram-v2 提现: - 提现申请成功后 `identity salary wallet.available_amount` 减少,`frozen_amount` 增加。 -- 审核拒绝后冻结金额回到 available。 -- 打款完成后 frozen 减少并写出账分录。 +- 运营审核通过后冻结金额保持不变,申请进入财务审核。 +- 运营或财务拒绝后冻结金额回到 available。 +- 财务确认打款并通过后 frozen 减少并写出账分录。 - 打款失败后状态可重试,不丢失冻结关系和审核上下文。 验证命令: @@ -535,6 +545,6 @@ docker compose config - 币商充值分两段:平台给币商发 `COIN_SELLER_COIN` 库存,币商再把库存转成用户 `COIN`;必须有币商金币余额、充值政策快照、限额和审计。 - `HOST_SALARY_USD`, `AGENCY_SALARY_USD`, `BD_SALARY_USD`, `ADMIN_SALARY_USD` 是可提现负债,调账和奖励发放需要更高权限和审计。 - 兑换汇率和奖励政策必须记录快照,不能只存当前 policy id。 -- 提现人工打款前必须冻结余额,打款失败要回到可重新处理状态。 +- 提现人工打款前必须冻结余额并完成运营初审;运营通过不得提前扣减 frozen,财务终审通过才 settle,任一拒绝都 release。 - 钱包事件投递使用 outbox,MQ 投递失败不回滚账务事实。 - 礼物价格和积分比例必须来自服务端配置,不能由客户端决定。 diff --git a/pkg/xerr/catalog.go b/pkg/xerr/catalog.go index f116853e..d039a6e6 100644 --- a/pkg/xerr/catalog.go +++ b/pkg/xerr/catalog.go @@ -27,6 +27,18 @@ var catalog = map[Code]Spec{ NotFound: spec(codes.NotFound, httpStatusNotFound, NotFound, "not found"), Conflict: spec(codes.FailedPrecondition, httpStatusConflict, Conflict, "request cannot be completed in current state"), RoomClosed: spec(codes.FailedPrecondition, httpStatusConflict, RoomClosed, "room closed"), + RoomRPSChallengeTaken: spec( + codes.FailedPrecondition, + httpStatusConflict, + RoomRPSChallengeTaken, + "手慢啦,该 PK 已被其他人接单", + ), + RoomRPSChallengeExpired: spec( + codes.FailedPrecondition, + httpStatusConflict, + RoomRPSChallengeExpired, + "猜拳已过期", + ), Unauthorized: spec(codes.Unauthenticated, httpStatusUnauthorized, Unauthorized, "unauthorized"), PermissionDenied: spec(codes.PermissionDenied, httpStatusForbidden, PermissionDenied, "permission denied"), diff --git a/pkg/xerr/errors.go b/pkg/xerr/errors.go index ff7c1c9e..ca386c2a 100644 --- a/pkg/xerr/errors.go +++ b/pkg/xerr/errors.go @@ -18,6 +18,10 @@ const ( Conflict Code = "CONFLICT" // RoomClosed 表示房间已被后台关闭,客户端不能再进入。 RoomClosed Code = "ROOM_CLOSED" + // RoomRPSChallengeTaken 表示房内猜拳挑战已被其他用户接单,当前用户不能再重复应战。 + RoomRPSChallengeTaken Code = "ROOM_RPS_CHALLENGE_TAKEN" + // RoomRPSChallengeExpired 表示房内猜拳挑战已越过无人应战截止时间,客户端应关闭应战入口并提示过期。 + RoomRPSChallengeExpired Code = "ROOM_RPS_CHALLENGE_EXPIRED" // Unauthorized 表示身份不存在或认证失败。 Unauthorized Code = "UNAUTHORIZED" // PermissionDenied 表示已认证但没有执行权限。 diff --git a/pkg/xerr/grpc_test.go b/pkg/xerr/grpc_test.go index 7af1eee5..8ab7b71e 100644 --- a/pkg/xerr/grpc_test.go +++ b/pkg/xerr/grpc_test.go @@ -151,6 +151,22 @@ func TestCatalogMappings(t *testing.T) { publicCode: string(RegionMismatch), publicMessage: "不是同一个地区", }, + { + name: "room rps taken exposes late challenger prompt", + code: RoomRPSChallengeTaken, + grpcCode: codes.FailedPrecondition, + httpStatus: httpStatusConflict, + publicCode: string(RoomRPSChallengeTaken), + publicMessage: "手慢啦,该 PK 已被其他人接单", + }, + { + name: "room rps expired exposes actionable prompt", + code: RoomRPSChallengeExpired, + grpcCode: codes.FailedPrecondition, + httpStatus: httpStatusConflict, + publicCode: string(RoomRPSChallengeExpired), + publicMessage: "猜拳已过期", + }, } for _, test := range tests { diff --git a/scripts/apply-local-mysql-initdb.sh b/scripts/apply-local-mysql-initdb.sh index e919be9f..eab81850 100755 --- a/scripts/apply-local-mysql-initdb.sh +++ b/scripts/apply-local-mysql-initdb.sh @@ -34,6 +34,8 @@ SQL_FILES=( # binaries may start against a structurally valid-looking but stale table. INCREMENTAL_MIGRATION_FILES=( "services/user-service/deploy/mysql/migrations/012_login_audit_client_version.sql" + "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/wallet-service/deploy/mysql/migrations/002_resource_shop_price_tiers.sql" ) diff --git a/scripts/mysql/068_wallet_withdrawal_terminal_locks.sql b/scripts/mysql/068_wallet_withdrawal_terminal_locks.sql new file mode 100644 index 00000000..de09a5a7 --- /dev/null +++ b/scripts/mysql/068_wallet_withdrawal_terminal_locks.sql @@ -0,0 +1,11 @@ +USE hyapp_wallet; + +-- 该表只按用户提现申请增长,创建空表不扫描 wallet_transactions,不会在现有 10GB 级流水表上建索引或持有行锁。 +-- 必须在部署包含新终局幂等逻辑的 wallet-service 之前执行;CREATE TABLE IF NOT EXISTS 可安全重复执行。 +CREATE TABLE IF NOT EXISTS wallet_withdrawal_terminal_locks ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + withdrawal_application_id VARCHAR(64) NOT NULL COMMENT '后台提现申请 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, withdrawal_application_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提现申请钱包终局串行锁'; diff --git a/server/admin/docs/权限管理.md b/server/admin/docs/权限管理.md index 1d99edca..6dc6c4c0 100644 --- a/server/admin/docs/权限管理.md +++ b/server/admin/docs/权限管理.md @@ -282,9 +282,24 @@ | `wallet:view` | `menu` | 钱包概览、余额查询 | | `wallet:transaction-view` | `menu` | 交易流水 | | `wallet:adjust` | `button` | 后台调账 | -| `withdrawal:view` | `menu` | 提现记录 | -| `withdrawal:review` | `button` | 提现审核 | -| `withdrawal:export` | `button` | 提现导出 | +| `operations-withdrawal:view` | `menu` | 用户提现运营审核列表;不包含 `operations_status=skipped` 的历史单 | +| `operations-withdrawal:audit` | `button` | 运营初审;通过后转交财务,拒绝时释放冻结余额并终止流程 | +| `finance-withdrawal:view` | `menu` | 用户提现财务审核列表;只包含运营已通过和 `skipped` 历史兼容单 | +| `finance-withdrawal:audit` | `button` | 财务终审;通过时扣减 frozen,拒绝时释放 frozen | + +两个提现工作台都以 `admin_user_money_scopes` 作为 App 数据范围:显式选择 App 时必须命中授权,未选 App 的列表只查授权 App 集合,仅 `platform-admin` 的全量资金范围可跨全部 App。审核请求必须显式携带目标行的 `X-App-Code`,后端会在钱包动作前再校验 MoneyAccess。`GET /admin/finance/scope` 仅返回这份授权目录,其读权限为 `finance:view`、`finance-withdrawal:view|audit` 或 `operations-withdrawal:view|audit`,不因此赋予审核写权。 + +运营状态 `rejecting` 表示拒绝 claim 已独立提交、钱包释放或通知/终态收敛仍需重试。该状态只能继续调用原拒绝接口,后端沿用第一次审核人、原因和固定 command;不能改点通过,也不会进入财务列表。运营工作台必须显示“重试拒绝”,不能把它当成普通 `pending`。 + +#### 用户提现两级审核发布顺序 + +`102_user_withdrawal_operations_review.sql` 不能和旧 admin 实例混跑。旧 admin 只识别总体 `status=pending`,会绕过 `operations_status` 直接调钱包终审。生产必须按以下顺序发布: + +1. 先执行 `scripts/mysql/068_wallet_withdrawal_terminal_locks.sql`,完成新 wallet-service 的全量滚动,确保所有钱包实例都能按申请 ID 收敛旧/新 command。 +2. 摘流所有旧 admin 实例,确认无旧版财务审核请求在飞。 +3. 执行 admin 102 迁移。迁移持久化当时的最大申请 ID,仅把该范围内已经终审的历史行标记 `skipped`;尚未终审的 `status=pending` 申请和迁移后新申请都保持运营 `pending`。 +4. 部署新 admin 并恢复流量,验证运营/财务列表和 MoneyAccess 隔离。 +5. 最后滚动 gateway。即使窗口内旧 gateway 不写 `operations_status`,数据库默认也会让新单进入运营待审。 ### 礼物和活动 @@ -350,6 +365,7 @@ `ops-admin` - 负责运营、团队、房间、资源、活动和游戏的日常管理。 +- 负责用户提现运营初审,不拥有用户提现财务终审权限。 - 不包含后台设置、版本管理、财务负责人看板和三方汇率编辑/全局同步。 ### 运营专员 @@ -357,6 +373,7 @@ `operations-specialist` - 可执行用户、团队结构、房间、资源和常规运营动作。 +- 可执行用户提现运营初审,不拥有用户提现财务终审权限。 - 活动配置和游戏列表/自研游戏只读;可管理全站机器人和房内猜拳配置。 ### 产品负责人 @@ -377,13 +394,13 @@ `finance-lead` -- 只进入财务看板、充值对账、提现审核和房内猜拳订单。 +- 只进入财务看板、充值对账、用户提现财务终审和房内猜拳订单;不参与运营初审。 ### 财务专员 `finance-specialist` -- 当前与财务负责人使用同一权限矩阵;保留独立角色用于人员分工和后续数据范围配置。 +- 当前与财务负责人使用同一权限矩阵,负责用户提现财务终审且不参与运营初审;保留独立角色用于人员分工和后续数据范围配置。 固定岗位执行“同步权限”或显式 bootstrap 时按上述矩阵精确重建,清除历史跨模块绑定。历史 `auditor`、`readonly` 和用户自建角色不会被删除或重置。 diff --git a/server/admin/internal/middleware/app_scope_test.go b/server/admin/internal/middleware/app_scope_test.go index 9c0d4f4d..832311df 100644 --- a/server/admin/internal/middleware/app_scope_test.go +++ b/server/admin/internal/middleware/app_scope_test.go @@ -1,12 +1,15 @@ package middleware import ( + "errors" "net/http" "net/http/httptest" "testing" + "time" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" + "hyapp-admin-server/internal/service" "github.com/gin-gonic/gin" ) @@ -15,6 +18,15 @@ type fakeAppAccessStore struct { access repository.AppAccess } +type fakeAuthorizationStore struct { + authorization repository.UserAuthorization + err error +} + +func (store fakeAuthorizationStore) CurrentAuthorizationForUser(uint) (repository.UserAuthorization, error) { + return store.authorization, store.err +} + func (store fakeAppAccessStore) AppAccessForUser(uint) (repository.AppAccess, error) { return store.access, nil } @@ -50,3 +62,67 @@ func TestRequireAppScopeAllowsOnlySelectedApp(t *testing.T) { } } } + +func TestAuthRequiredUsesCurrentPermissionsInsteadOfJWTClaim(t *testing.T) { + gin.SetMode(gin.TestMode) + auth := service.NewAuthService("live-authorization-test-secret", time.Hour) + // This models an access token issued before a role migration: finance audit + // existed in the claim, while operations audit was absent at issue time. + token, _, err := auth.GenerateAccessToken(7, "stale-name", []string{"finance-withdrawal:audit"}) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + router := gin.New() + router.Use(AuthRequired(auth, fakeAuthorizationStore{authorization: repository.UserAuthorization{ + Username: "current-name", + Status: model.UserStatusActive, + Permissions: []string{"operations-withdrawal:audit"}, + }})) + router.GET("/finance", RequirePermission("finance-withdrawal:audit"), func(c *gin.Context) { + c.Status(http.StatusNoContent) + }) + router.GET("/operations", RequirePermission("operations-withdrawal:audit"), func(c *gin.Context) { + if CurrentUsername(c) != "current-name" { + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusNoContent) + }) + + financeRequest := httptest.NewRequest(http.MethodGet, "/finance", nil) + financeRequest.Header.Set("Authorization", "Bearer "+token) + financeResponse := httptest.NewRecorder() + router.ServeHTTP(financeResponse, financeRequest) + if financeResponse.Code != http.StatusForbidden { + t.Fatalf("removed JWT permission status = %d body=%s", financeResponse.Code, financeResponse.Body.String()) + } + + operationsRequest := httptest.NewRequest(http.MethodGet, "/operations", nil) + operationsRequest.Header.Set("Authorization", "Bearer "+token) + operationsResponse := httptest.NewRecorder() + router.ServeHTTP(operationsResponse, operationsRequest) + if operationsResponse.Code != http.StatusNoContent { + t.Fatalf("current database permission status = %d body=%s", operationsResponse.Code, operationsResponse.Body.String()) + } +} + +func TestAuthRequiredFailsClosedWhenAuthorizationCannotBeResolved(t *testing.T) { + gin.SetMode(gin.TestMode) + auth := service.NewAuthService("authorization-failure-test-secret", time.Hour) + token, _, err := auth.GenerateAccessToken(7, "admin", []string{"role:manage"}) + if err != nil { + t.Fatalf("generate access token: %v", err) + } + + router := gin.New() + router.Use(AuthRequired(auth, fakeAuthorizationStore{err: errors.New("database unavailable")})) + router.GET("/protected", func(c *gin.Context) { c.Status(http.StatusNoContent) }) + request := httptest.NewRequest(http.MethodGet, "/protected", nil) + request.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusInternalServerError { + t.Fatalf("authorization database failure status = %d body=%s", response.Code, response.Body.String()) + } +} diff --git a/server/admin/internal/middleware/middleware.go b/server/admin/internal/middleware/middleware.go index 3be22c06..41a606a5 100644 --- a/server/admin/internal/middleware/middleware.go +++ b/server/admin/internal/middleware/middleware.go @@ -8,6 +8,7 @@ import ( "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/config" + "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/response" "hyapp-admin-server/internal/service" @@ -20,6 +21,10 @@ type AppAccessStore interface { AppAccessForUser(userID uint) (repository.AppAccess, error) } +type AuthorizationStore interface { + CurrentAuthorizationForUser(userID uint) (repository.UserAuthorization, error) +} + const ( ContextUserID = "userID" ContextUsername = "username" @@ -61,7 +66,7 @@ func AppCode() gin.HandlerFunc { } } -func AuthRequired(auth *service.AuthService) gin.HandlerFunc { +func AuthRequired(auth *service.AuthService, store AuthorizationStore) gin.HandlerFunc { return func(c *gin.Context) { header := c.GetHeader("Authorization") if !strings.HasPrefix(header, "Bearer ") { @@ -70,6 +75,13 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc { return } + if auth == nil || store == nil { + // Authorization cannot safely fall back to claims.Permissions: doing so + // would restore removed privileges until the access token expires. + response.ServerError(c, "认证权限服务不可用") + c.Abort() + return + } claims, err := auth.ParseAccessToken(strings.TrimPrefix(header, "Bearer ")) if err != nil { response.Unauthorized(c, "访问凭证已失效") @@ -77,9 +89,27 @@ func AuthRequired(auth *service.AuthService) gin.HandlerFunc { return } + authorization, err := store.CurrentAuthorizationForUser(claims.UserID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + response.Unauthorized(c, "用户不存在") + } else { + // Database errors fail closed. A stale JWT permission snapshot is not + // an acceptable availability fallback for privileged admin actions. + response.ServerError(c, "校验登录权限失败") + } + c.Abort() + return + } + if authorization.Status != model.UserStatusActive { + response.Unauthorized(c, "账号不可登录") + c.Abort() + return + } + c.Set(ContextUserID, claims.UserID) - c.Set(ContextUsername, claims.Username) - c.Set(ContextPermissions, claims.Permissions) + c.Set(ContextUsername, authorization.Username) + c.Set(ContextPermissions, authorization.Permissions) c.Next() } } @@ -154,8 +184,9 @@ func HasAnyPermission(c *gin.Context, codes ...string) bool { return false } -// HasPermission reads only the JWT-derived permission list. Database state is -// refreshed by login/refresh, so every request uses one consistent permission snapshot. +// HasPermission reads the request-scoped permission list resolved by +// AuthRequired from current database state. JWT permission claims are retained +// for wire compatibility only and are never trusted for server authorization. func HasPermission(c *gin.Context, code string) bool { return slices.Contains(CurrentPermissions(c), code) } diff --git a/server/admin/internal/migration/migration.go b/server/admin/internal/migration/migration.go index d63a562b..3317b8ba 100644 --- a/server/admin/internal/migration/migration.go +++ b/server/admin/internal/migration/migration.go @@ -148,9 +148,16 @@ func applyFile(ctx context.Context, db *sql.DB, version string, file string) err return err } sum := checksum(body) + // 迁移文件会用 MySQL 会话变量在多条语句之间传递 DDL 决策。database/sql 不保证连续 Exec + // 复用同一底层连接,因此整个文件必须绑定 *sql.Conn,否则 @ddl/@checkpoint 可能在 PREPARE 前丢失。 + conn, err := db.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() var existing migrationRow - err = db.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&existing.Version, &existing.Checksum, &existing.Dirty) + err = conn.QueryRowContext(ctx, "SELECT version, checksum, dirty FROM schema_migrations WHERE version = ?", version).Scan(&existing.Version, &existing.Checksum, &existing.Dirty) if err == nil { if existing.Dirty { return fmt.Errorf("migration %s is dirty; repair it before continuing", version) @@ -164,16 +171,16 @@ func applyFile(ctx context.Context, db *sql.DB, version string, file string) err return err } - if _, err := db.ExecContext(ctx, "INSERT INTO schema_migrations(version, checksum, dirty) VALUES (?, ?, TRUE)", version, sum); err != nil { + if _, err := conn.ExecContext(ctx, "INSERT INTO schema_migrations(version, checksum, dirty) VALUES (?, ?, TRUE)", version, sum); err != nil { return err } for _, statement := range splitSQL(string(body)) { - if _, err := db.ExecContext(ctx, statement); err != nil { + if _, err := conn.ExecContext(ctx, statement); err != nil { return fmt.Errorf("apply migration %s: %w", version, err) } } - if _, err := db.ExecContext(ctx, "UPDATE schema_migrations SET dirty = FALSE, checksum = ?, applied_at_ms = ? WHERE version = ?", sum, time.Now().UTC().UnixMilli(), version); err != nil { + if _, err := conn.ExecContext(ctx, "UPDATE schema_migrations SET dirty = FALSE, checksum = ?, applied_at_ms = ? WHERE version = ?", sum, time.Now().UTC().UnixMilli(), version); err != nil { return err } return nil diff --git a/server/admin/internal/model/models.go b/server/admin/internal/model/models.go index a569cd45..a8ac7a21 100644 --- a/server/admin/internal/model/models.go +++ b/server/admin/internal/model/models.go @@ -38,6 +38,12 @@ const ( WithdrawalApplicationStatusApproved = "approved" WithdrawalApplicationStatusRejected = "rejected" + WithdrawalOperationsStatusSkipped = "skipped" + WithdrawalOperationsStatusPending = "pending" + WithdrawalOperationsStatusRejecting = "rejecting" + WithdrawalOperationsStatusApproved = "approved" + WithdrawalOperationsStatusRejected = "rejected" + ExternalAdminStatusActive = "active" ExternalAdminStatusDisabled = "disabled" ) @@ -720,31 +726,38 @@ func (order CoinSellerRechargeOrder) BusinessTimeMS() int64 { } type UserWithdrawalApplication struct { - ID uint `gorm:"primaryKey" json:"id"` - AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null" json:"appCode"` - UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"` - SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"` - WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"` - WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"` - PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"` - PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"` - PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"` - PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"` - PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"` - WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"` - WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"` - FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"` - FreezeTransactionID string `gorm:"size:128;not null;default:''" json:"freezeTransactionId"` - AuditCommandID string `gorm:"size:128;not null;default:''" json:"auditCommandId"` - AuditTransactionID string `gorm:"size:128;not null;default:''" json:"auditTransactionId"` - Status string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null;default:pending" json:"status"` - ApproverUserID *uint `gorm:"index:idx_admin_withdrawal_approver" json:"approverUserId"` - ApproverName string `gorm:"size:64;not null;default:''" json:"approverName"` - AuditRemark string `gorm:"type:text" json:"auditRemark"` - AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"` - ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"` - CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_withdrawal_app_status_time" json:"createdAtMs"` - UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` + ID uint `gorm:"primaryKey;index:idx_admin_withdrawal_app_operations_status_time,priority:4" json:"id"` + AppCode string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:1;not null" json:"appCode"` + UserID string `gorm:"size:64;index:idx_admin_withdrawal_user;not null" json:"userId"` + SalaryAssetType string `gorm:"size:64;not null;default:''" json:"salaryAssetType"` + WithdrawAmount string `gorm:"type:decimal(18,2);not null;default:0.00" json:"withdrawAmount"` + WithdrawAmountMinor int64 `gorm:"not null;default:0" json:"withdrawAmountMinor"` + PointFeeAmount int64 `gorm:"not null;default:0" json:"pointFeeAmount"` + PointNetAmount int64 `gorm:"not null;default:0" json:"pointNetAmount"` + PointsPerUSD int64 `gorm:"not null;default:100000" json:"pointsPerUsd"` + PointFeeBPS int32 `gorm:"not null;default:500" json:"pointFeeBps"` + PointPolicyInstance string `gorm:"column:point_policy_instance_code;size:96;not null;default:''" json:"pointPolicyInstanceCode"` + WithdrawMethod string `gorm:"size:64;index:idx_admin_withdrawal_method;not null;default:''" json:"withdrawMethod"` + WithdrawAddress string `gorm:"size:255;not null;default:''" json:"withdrawAddress"` + FreezeCommandID string `gorm:"size:128;not null;default:''" json:"freezeCommandId"` + FreezeTransactionID string `gorm:"size:128;not null;default:''" json:"freezeTransactionId"` + AuditCommandID string `gorm:"size:128;not null;default:''" json:"auditCommandId"` + AuditTransactionID string `gorm:"size:128;not null;default:''" json:"auditTransactionId"` + Status string `gorm:"size:32;index:idx_admin_withdrawal_app_status_time;not null;default:pending" json:"status"` + ApproverUserID *uint `gorm:"index:idx_admin_withdrawal_approver" json:"approverUserId"` + ApproverName string `gorm:"size:64;not null;default:''" json:"approverName"` + AuditRemark string `gorm:"type:text" json:"auditRemark"` + AuditImageURL string `gorm:"column:audit_image_url;size:1024;not null;default:''" json:"auditImageUrl"` + ApprovedAtMS *int64 `gorm:"column:approved_at_ms" json:"approvedAtMs"` + OperationsStatus string `gorm:"column:operations_status;size:32;not null;default:pending;index:idx_admin_withdrawal_app_operations_status_time,priority:2" json:"operationsStatus"` + OperationsReviewerUserID *uint `gorm:"column:operations_reviewer_user_id" json:"operationsReviewerUserId"` + OperationsReviewerName string `gorm:"column:operations_reviewer_name;size:64;not null;default:''" json:"operationsReviewerName"` + OperationsAuditRemark string `gorm:"column:operations_audit_remark;type:text" json:"operationsAuditRemark"` + OperationsAuditCommandID string `gorm:"column:operations_audit_command_id;size:128;not null;default:''" json:"operationsAuditCommandId"` + OperationsAuditTransactionID string `gorm:"column:operations_audit_transaction_id;size:128;not null;default:''" json:"operationsAuditTransactionId"` + OperationsReviewedAtMS *int64 `gorm:"column:operations_reviewed_at_ms" json:"operationsReviewedAtMs"` + CreatedAtMS int64 `gorm:"column:created_at_ms;autoCreateTime:milli;index:idx_admin_withdrawal_app_status_time;index:idx_admin_withdrawal_app_operations_status_time,priority:3" json:"createdAtMs"` + UpdatedAtMS int64 `gorm:"column:updated_at_ms;autoUpdateTime:milli" json:"updatedAtMs"` } func (UserWithdrawalApplication) TableName() string { diff --git a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go index 56a83e93..97da66bb 100644 --- a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go +++ b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard.go @@ -27,8 +27,8 @@ const ( aslanMongoGoldWaterCollection = "user_gold_running_water_v2" aslanMongoChunkSize = 800 - // likei GoldOrigin 枚举以 code 形式落在 user_gold_running_water_v2.origin; - // 历史上部分 code 带数字后缀(如 HOT_GAME_200),所以统一用前缀匹配。 + // 幸运礼物返奖和币商出货各自拥有稳定、单一语义的 origin 前缀,因此这两类可按前缀聚合; + // 游戏命名空间混有排行榜/活动奖励,游戏流水必须走下方 exact code 白名单,不能复用此前缀策略。 aslanMongoLuckyGiftPayoutOriginPrefix = "LUCKY_GIFT_GOLD_REWARD" aslanMongoSellerAgentOriginPrefix = "SELLER_AGENT" ) @@ -36,6 +36,41 @@ const ( var ( aslanMongoFreightCommodityTypes = bson.A{"FREIGHT_GOLD", "FREIGHT_GOLD_SUPER"} aslanMongoFreightCommodityFields = []string{"trackCommodityType", "products.code", "products.name", "products.content"} + + // 游戏流水只能消费 likei 已确认的具体 GoldOrigin code,不能用 *_GAME_% 前缀兜底: + // 同一命名空间里已经存在 HOT_GAME_200 排行榜奖励和 BAISHUN_GAME_1085 活动奖励, + // 前缀匹配会把平台发奖误算成游戏返奖。新增游戏必须先在 likei 的游戏配置中确认业务语义, + // 再显式追加到这里,确保 Databi 历史口径不会随着第三方传入任意 gameUid 静默漂移。 + aslanLegacyGameEventTypes = []string{ + "HOT_GAME_201", "HOT_GAME_202", "HOT_GAME_203", "HOT_GAME_204", + "HOT_GAME_205", "HOT_GAME_206", "HOT_GAME_207", "HOT_GAME_208", + "HOT_GAME_209", "HOT_GAME_210", "HOT_GAME_211", "HOT_GAME_212", + "HOT_GAME_213", "HOT_GAME_214", "HOT_GAME_215", "HOT_GAME_220", + "HKYS_GAME_101", "HKYS_GAME_102", "HKYS_GAME_103", "HKYS_GAME_104", + "HKYS_GAME_105", "HKYS_GAME_106", "HKYS_GAME_107", "HKYS_GAME_108", + "HKYS_GAME_109", "HKYS_GAME_110", "HKYS_GAME_111", "HKYS_GAME_112", + "HKYS_GAME_113", "HKYS_GAME_114", "HKYS_GAME_115", "HKYS_GAME_116", + "HKYS_GAME_201", "HKYS_GAME_202", "HKYS_GAME_203", "HKYS_GAME_204", + "HKYS_GAME_205", "HKYS_GAME_206", "HKYS_GAME_207", "HKYS_GAME_208", + "HKYS_GAME_209", "HKYS_GAME_210", "HKYS_GAME_211", "HKYS_GAME_212", + "HKYS_GAME_213", "HKYS_GAME_214", "HKYS_GAME_215", "HKYS_GAME_216", + "BAISHUN_GAME_1001", "BAISHUN_GAME_1004", "BAISHUN_GAME_1005", "BAISHUN_GAME_1006", + "BAISHUN_GAME_1007", "BAISHUN_GAME_1008", "BAISHUN_GAME_1009", "BAISHUN_GAME_1010", + "BAISHUN_GAME_1012", "BAISHUN_GAME_1013", "BAISHUN_GAME_1014", "BAISHUN_GAME_1015", + "BAISHUN_GAME_1016", "BAISHUN_GAME_1017", "BAISHUN_GAME_1018", "BAISHUN_GAME_1019", + "BAISHUN_GAME_1020", "BAISHUN_GAME_1021", "BAISHUN_GAME_1022", "BAISHUN_GAME_1023", + "BAISHUN_GAME_1024", "BAISHUN_GAME_1026", "BAISHUN_GAME_1031", "BAISHUN_GAME_1032", + "BAISHUN_GAME_1035", "BAISHUN_GAME_1037", "BAISHUN_GAME_1040", "BAISHUN_GAME_1041", + "BAISHUN_GAME_1043", "BAISHUN_GAME_1044", "BAISHUN_GAME_1048", "BAISHUN_GAME_1051", + "BAISHUN_GAME_1053", "BAISHUN_GAME_1055", "BAISHUN_GAME_1068", "BAISHUN_GAME_1069", + "BAISHUN_GAME_1075", "BAISHUN_GAME_1078", "BAISHUN_GAME_1083", "BAISHUN_GAME_1084", + "BAISHUN_GAME_1090", "BAISHUN_GAME_1100", "BAISHUN_GAME_1107", "BAISHUN_GAME_1114", + "BAISHUN_GAME_1130", "BAISHUN_GAME_1172", "BAISHUN_GAME_1182", + // Yomi 的 gameUid 由第三方动态下发,代码仓库没有静态枚举;以下 8 个值来自 ATYOU + // wallet_gold_count 近 30 天已出现且经游戏回调链路确认的集合,未知 gameUid 默认不纳入口径。 + "YOMI_GAME_274", "YOMI_GAME_275", "YOMI_GAME_276", "YOMI_GAME_277", + "YOMI_GAME_278", "YOMI_GAME_279", "YOMI_GAME_281", "YOMI_GAME_282", + } ) type AslanMongoDashboardSource struct { @@ -145,6 +180,12 @@ func (s *AslanMongoDashboardSource) AppCode() string { return s.appCode } +func (s *AslanMongoDashboardSource) legacyGameTotalsAvailable(countryFilter externalDashboardCountryFilter) bool { + // wallet_gold_count 只有 App×日×事件类型,没有用户或国家键;区域筛选必须显式关闭该口径, + // 否则区域卡片会混入全 App 游戏流水,数值虽非零却属于错误的数据权限和统计语义。 + return s.legacyDB != nil && strings.TrimSpace(s.legacyWalletDatabase) != "" && !countryFilter.Applied +} + func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, query StatisticsQuery) (map[string]any, error) { if s == nil || s.db == nil { return nil, fmt.Errorf("aslan mongo dashboard source is not configured") @@ -200,12 +241,14 @@ func (s *AslanMongoDashboardSource) StatisticsOverview(ctx context.Context, quer response["country_breakdown"] = current.CountryBreakdown response["daily_country_breakdown"] = current.DailyCountryBreakdown goldWater := s.hasGoldWater(ctx) + legacyGameTotals := s.legacyGameTotalsAvailable(countryFilter) response["retention"] = current.Total.retentionMap() - response["report_metric_sources"] = aslanMongoDashboardMetricSources(goldWater, s.legacyDB != nil) + response["report_metric_sources"] = aslanMongoDashboardMetricSources(goldWater, s.legacyDB != nil, legacyGameTotals, countryFilter.Applied) response["updated_at_ms"] = time.Now().UTC().UnixMilli() applyExternalDashboardDeltas(response, current.Total, previous.Total) response["salary_delta_rate"] = deltaRate(nullableMetricInt64(current.Total.SalaryUSDMinor), nullableMetricInt64(previous.Total.SalaryUSDMinor)) applyAslanMongoUnavailableFields(response) + applyAslanMongoGlobalGameOverview(response, current.Total, previous.Total, legacyGameTotals) applyAslanMongoLegacyUnavailableFields(response, s.legacyDB != nil) if !goldWater { applyAslanMongoGoldWaterUnavailable(response) @@ -296,6 +339,13 @@ type legacyMongoCoinFlow struct { OutputCoin int64 } +// legacyMongoGameFlow 是 wallet_gold_count 已预聚合的全 App 游戏日快照。 +// 该表没有 user_id/国家维度,因此它只能进入总览与每日趋势,绝不能回填到国家或区域行。 +type legacyMongoGameFlow struct { + Turnover int64 + Payout int64 +} + func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDashboardMetric { userRecharge := m.RechargeUSDMinor - m.CoinSellerRechargeUSDMinor metric := externalDashboardMetric{ @@ -332,11 +382,24 @@ func (m *legacyMongoMetric) toExternal(coinFlow *legacyMongoCoinFlow) externalDa return metric } +func (m *legacyMongoMetric) toExternalWithGlobalFlows(coinFlow *legacyMongoCoinFlow, gameFlow *legacyMongoGameFlow) externalDashboardMetric { + metric := m.toExternal(coinFlow) + if gameFlow == nil { + return metric + } + metric.GameTurnover = gameFlow.Turnover + metric.GamePayout = gameFlow.Payout + // 游戏利润和利润率属于投注/返奖的派生口径;在同一个 finalize 入口重算,避免总览与 Databi 二次汇总出现公式分叉。 + metric.finalize() + return metric +} + type legacyMongoGrid struct { days []string daySet map[string]struct{} metrics map[string]map[string]*legacyMongoMetric coinFlows map[string]*legacyMongoCoinFlow + gameFlows map[string]*legacyMongoGameFlow rechargeUsers map[string]map[string]struct{} // 各 loader 并行写入:map 结构由锁保护;不同 loader 写 metric 的不同字段,无字段级竞争。 mu sync.Mutex @@ -347,6 +410,7 @@ func newLegacyMongoGrid(startDate time.Time, endDate time.Time) *legacyMongoGrid daySet: map[string]struct{}{}, metrics: map[string]map[string]*legacyMongoMetric{}, coinFlows: map[string]*legacyMongoCoinFlow{}, + gameFlows: map[string]*legacyMongoGameFlow{}, rechargeUsers: map[string]map[string]struct{}{}, } for day := startDate; day.Before(endDate); day = day.AddDate(0, 0, 1) { @@ -432,6 +496,12 @@ func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate tim func() error { return s.loadCoinFlows(ctx, startDate, endDate, grid) }, ) } + legacyGameTotals := s.legacyGameTotalsAvailable(countryFilter) + if legacyGameTotals { + // wallet_gold_count 已由 likei wallet-service 按日聚合,区间查询只扫描唯一键范围内的小量日快照; + // 这里不回查账变明细,避免 Databi 请求把在线钱包库变成临时报表引擎。 + tasks = append(tasks, func() error { return s.loadLegacyGameFlows(ctx, startDate, endDate, grid) }) + } if includeRetention { tasks = append(tasks, func() error { return s.applyRetention(ctx, startDate, countryFilter, cohorts, grid) }) } @@ -445,14 +515,15 @@ func (s *AslanMongoDashboardSource) loadRange(ctx context.Context, startDate tim } } - return s.assembleRange(grid, countryFilter, goldWater, s.legacyDB != nil), nil + return s.assembleRange(grid, countryFilter, goldWater, s.legacyDB != nil, legacyGameTotals), nil } -func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, countryFilter externalDashboardCountryFilter, goldWater bool, legacyRecharge bool) aslanRangeOverview { +func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, countryFilter externalDashboardCountryFilter, goldWater bool, legacyRecharge bool, legacyGameTotals bool) aslanRangeOverview { dailySeries := []map[string]any{} dailyCountries := []map[string]any{} totalFlow := legacyMongoMetric{} totalCoinFlow := legacyMongoCoinFlow{} + totalGameFlow := legacyMongoGameFlow{} countryTotals := map[string]*legacyMongoMetric{} for _, day := range grid.days { @@ -497,6 +568,11 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country totalCoinFlow.ConsumedCoin += coinFlow.ConsumedCoin totalCoinFlow.OutputCoin += coinFlow.OutputCoin } + gameFlow := grid.gameFlows[day] + if gameFlow != nil { + totalGameFlow.Turnover += gameFlow.Turnover + totalGameFlow.Payout += gameFlow.Payout + } // 工资余额是快照:日行取当日各国快照之和,区间合计不能跨天累加。 daySalary := dayFlow.SalaryUSDMinor if dayRechargeUsers := grid.rechargeUsers[day]; len(dayRechargeUsers) > 0 { @@ -506,10 +582,11 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country totalFlow.add(&dayFlow) totalFlow.SalaryUSDMinor = daySalary - dayRow := dayFlow.toExternal(coinFlow).toMap() + dayRow := dayFlow.toExternalWithGlobalFlows(coinFlow, gameFlow).toMap() dayRow["label"] = day dayRow["stat_day"] = day applyAslanMongoUnavailableFields(dayRow) + applyAslanMongoGlobalGameFields(dayRow, gameFlow, legacyGameTotals) applyAslanMongoLegacyUnavailableFields(dayRow, legacyRecharge) if !goldWater { applyAslanMongoGoldWaterUnavailable(dayRow) @@ -548,8 +625,13 @@ func (s *AslanMongoDashboardSource) assembleRange(grid *legacyMongoGrid, country return left > right }) + var gameTotal *legacyMongoGameFlow + if legacyGameTotals { + // 可用口径即使区间内没有流水也应返回真实 0;nil 只表示数据源或筛选维度不支持。 + gameTotal = &totalGameFlow + } return aslanRangeOverview{ - Total: totalFlow.toExternal(&totalCoinFlow), + Total: totalFlow.toExternalWithGlobalFlows(&totalCoinFlow, gameTotal), DailySeries: dailySeries, CountryBreakdown: countryRows, DailyCountryBreakdown: dailyCountries, @@ -632,6 +714,71 @@ func (s *AslanMongoDashboardSource) loadActiveCounts(ctx context.Context, startD return nil } +// loadLegacyGameFlows 读取 likei wallet-service 已持久化的 App×日×事件累计快照。 +// group_num 的日界线由 likei 固定按 Asia/Riyadh 生成;这里按 [start,end) 的日期号查询并原样映射, +// 不使用 MySQL session 时区做二次转换,避免同一自然日被移到相邻日期。 +func (s *AslanMongoDashboardSource) loadLegacyGameFlows(ctx context.Context, startDate time.Time, endDate time.Time, grid *legacyMongoGrid) error { + if s.legacyDB == nil || strings.TrimSpace(s.legacyWalletDatabase) == "" || len(aslanLegacyGameEventTypes) == 0 { + return nil + } + + // likei 新迁移的唯一键为 (sys_origin, group_num, type, event_type);固定 App、日期范围和 type 后 + // 只扫描目标日快照。event_type 使用 exact IN 白名单收口业务语义,不能改成 LIKE '*_GAME_%', + // 否则 HOT_GAME_200、BAISHUN_GAME_1085 等奖励 code 会被误计。 + placeholders := strings.TrimSuffix(strings.Repeat("?,", len(aslanLegacyGameEventTypes)), ",") + query := fmt.Sprintf(`SELECT group_num, type, COALESCE(SUM(amount), 0) +FROM %s.wallet_gold_count +WHERE sys_origin = ? + AND group_num >= ? + AND group_num < ? + AND type IN (0, 1) + AND event_type IN (%s) +GROUP BY group_num, type`, quoteMySQLIdentifier(s.legacyWalletDatabase), placeholders) + args := make([]any, 0, len(aslanLegacyGameEventTypes)+3) + args = append(args, s.sysOrigin, dashboardDateNumber(startDate), dashboardDateNumber(endDate)) + for _, eventType := range aslanLegacyGameEventTypes { + args = append(args, eventType) + } + + queryCtx, cancel := context.WithTimeout(ctx, s.requestTimeout) + defer cancel() + rows, err := s.legacyDB.QueryContext(queryCtx, query, args...) + if err != nil { + return fmt.Errorf("query legacy game day snapshots: %w", err) + } + defer rows.Close() + for rows.Next() { + var groupNum string + var receiptType int + var pennyAmount int64 + if err := rows.Scan(&groupNum, &receiptType, &pennyAmount); err != nil { + return fmt.Errorf("scan legacy game day snapshot: %w", err) + } + day, ok := dashboardDayFromNumber(groupNum) + if !ok || !grid.hasDay(day) { + continue + } + flow := grid.gameFlows[day] + if flow == nil { + flow = &legacyMongoGameFlow{} + grid.gameFlows[day] = flow + } + // GoldReceiptCmd 以 PennyAmount 写入统计缓存,wallet_gold_count.amount 因此是 1/100 金币; + // 先在数据库按日汇总再统一四舍五入,避免逐 event_type 除法造成累计舍入误差。 + amount := roundMinorToWhole(pennyAmount) + switch receiptType { + case 1: + flow.Turnover += amount + case 0: + flow.Payout += amount + } + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate legacy game day snapshots: %w", err) + } + return nil +} + type aslanMongoDayCountryUsers struct { day string country string @@ -1306,7 +1453,7 @@ func (s *AslanMongoDashboardSource) loadCoinFlows(ctx context.Context, startDate // applyRetention 按“观察日回看”口径用注册 cohort × user_daily_active_log 推 D1/D7/D30: // 区间内每一天 D 的 DN 留存 = D-N 天注册的用户中 D 当天活跃的比例,计数归属观察日 D -//(与 Yumi 的 CDC 预聚合、statistics-service 口径一致)。cohort 日无注册时基数保持 0, +// (与 Yumi 的 CDC 预聚合、statistics-service 口径一致)。cohort 日无注册时基数保持 0, // 上层会把 0 基数转成 nil,避免把“无 cohort”展示成真实 0% 留存。 func (s *AslanMongoDashboardSource) applyRetention(ctx context.Context, startDate time.Time, countryFilter externalDashboardCountryFilter, cohorts map[string]map[string][]int64, grid *legacyMongoGrid) error { location, err := time.LoadLocation(s.statTimezone) @@ -1597,6 +1744,19 @@ func dashboardDateNumber(day time.Time) string { return day.Format("20060102") } +func dashboardDayFromNumber(value string) (string, bool) { + value = strings.TrimSpace(value) + if len(value) != 8 { + return "", false + } + // Parse 不只校验字符长度,还会拒绝 20260231 这类脏 group_num;输出固定 ISO 日期供 grid 精确匹配。 + day, err := time.Parse("20060102", value) + if err != nil { + return "", false + } + return day.Format("2006-01-02"), true +} + // aslanMongoDocument 把聚合结果里的嵌套文档统一转成 bson.M; // driver 默认把嵌套文档解码成 bson.D,直接断言 bson.M 会静默丢行(表现为全零无报错)。 func aslanMongoDocument(value any) bson.M { @@ -1763,8 +1923,9 @@ func aslanMongoInt64Chunks(values []int64, size int) [][]int64 { return out } -// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径; -// 游戏流水的 GoldOrigin code 枚举过杂(大量带后缀的游戏 code 与奖励类混在一起),未确认前不硬凑。 +// aslanMongoUnavailableMetricFields 是 likei Mongo 事实集合仍给不出的口径。 +// 游戏总量先置空,再由 applyAslanMongoGlobalGameFields 仅在“全 App + legacy 日快照可用”时恢复; +// 这样国家/区域行沿用同一序列化路径也不会意外泄漏全局游戏数据。 var aslanMongoUnavailableMetricFields = []string{ "coin_seller_stock_coin", "new_dealer_recharge_usd_minor", @@ -1797,6 +1958,33 @@ func applyAslanMongoUnavailableFields(row map[string]any) { } } +func applyAslanMongoGlobalGameFields(row map[string]any, flow *legacyMongoGameFlow, available bool) { + if !available { + return + } + if flow == nil { + flow = &legacyMongoGameFlow{} + } + profit := flow.Turnover - flow.Payout + row["game_turnover"] = flow.Turnover + row["game_payout"] = flow.Payout + row["game_profit"] = profit + row["game_profit_rate"] = ratioFloat64(profit, flow.Turnover) + // wallet_gold_count 没有 user_id,game_players 必须继续保持 nil,不能用事件类型数量冒充玩家数。 +} + +func applyAslanMongoGlobalGameOverview(row map[string]any, current externalDashboardMetric, previous externalDashboardMetric, available bool) { + if !available { + return + } + applyAslanMongoGlobalGameFields(row, &legacyMongoGameFlow{Turnover: current.GameTurnover, Payout: current.GamePayout}, true) + // 环比基于同一个 Riyadh 日快照口径计算;上一周期真实为 0 时 deltaRate 按公共规则返回 nil, + // 不伪造无穷增长率。 + row["game_turnover_delta_rate"] = deltaRate(current.GameTurnover, previous.GameTurnover) + row["game_payout_delta_rate"] = deltaRate(current.GamePayout, previous.GamePayout) + row["game_profit_delta_rate"] = deltaRate(current.GameProfit, previous.GameProfit) +} + var aslanMongoLegacyMetricFields = []string{ "coin_seller_recharge_usd_minor", } @@ -1830,7 +2018,7 @@ func applyAslanMongoGoldWaterUnavailable(row map[string]any) { } } -func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map[string]any { +func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool, legacyGameTotals bool, countryFiltered bool) []map[string]any { goldWaterSources := []map[string]any{ {"field": "lucky_gift_payout", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=LUCKY_GIFT_GOLD_REWARD*"}, {"field": "coin_seller_transfer_coin", "available": true, "source": "likei Mongo user_gold_running_water_v2 origin=SELLER_AGENT*"}, @@ -1864,6 +2052,29 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map mifaPaySource += " + legacy MySQL order_other_recharge amount" newUserRechargeSource += "/legacy 其他充值/legacy 币商充值" } + gameSources := []map[string]any{} + if legacyGameTotals { + gameSource := "likei legacy MySQL atyou_wallet.wallet_gold_count(Asia/Riyadh 日口径;exact game event_type 白名单;type=1 投注、type=0 返奖)" + gameSources = []map[string]any{ + {"field": "game_turnover", "available": true, "source": gameSource}, + {"field": "game_payout", "available": true, "source": gameSource}, + {"field": "game_profit", "available": true, "source": "game_turnover - game_payout"}, + {"field": "game_profit_rate", "available": true, "source": "game_profit / game_turnover"}, + {"field": "game_players", "available": false, "missing_reason": "wallet_gold_count 没有 user_id,不能从日金额快照推导游戏人数"}, + } + } else { + reason := "未配置 legacy MySQL 钱包库,无法读取 Aslan 游戏日快照" + if countryFiltered { + reason = "wallet_gold_count 只有全 App 日汇总,区域筛选时不能返回全局游戏值" + } + gameSources = []map[string]any{ + {"field": "game_turnover", "available": false, "missing_reason": reason}, + {"field": "game_payout", "available": false, "missing_reason": reason}, + {"field": "game_profit", "available": false, "missing_reason": reason}, + {"field": "game_profit_rate", "available": false, "missing_reason": reason}, + {"field": "game_players", "available": false, "missing_reason": "wallet_gold_count 没有 user_id,不能从日金额快照推导游戏人数"}, + } + } baseSources := []map[string]any{ {"field": "new_users", "available": true, "source": "likei Mongo user_run_profile createTime/countryCode"}, {"field": "active_users", "available": true, "source": "likei Mongo user_daily_active_log activeDate/registerCountryCode"}, @@ -1876,7 +2087,6 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map {"field": "gift_coin_spent", "available": true, "source": "likei Mongo gift_give_running_water giftValue.actualAmount(含幸运礼物)"}, {"field": "lucky_gift_turnover", "available": true, "source": "likei Mongo gift_give_running_water luckyGift=true"}, {"field": "coin_seller_stock_coin", "available": false, "missing_reason": "likei Mongo 未确认币商进货金币口径"}, - {"field": "game_turnover", "available": false, "missing_reason": "likei GoldOrigin 游戏 code 枚举过多且与奖励类混排,未确认口径前不聚合"}, {"field": "super_lucky_gift_turnover", "available": false, "missing_reason": "likei 平台没有超级幸运礼物玩法"}, {"field": "coin_total", "available": false, "missing_reason": "likei Mongo 没有可按国家低成本汇总的金币余额快照"}, {"field": "platform_grant_coin", "available": false, "missing_reason": "likei Mongo 未确认平台发放金币来源枚举"}, @@ -1892,5 +2102,6 @@ func aslanMongoDashboardMetricSources(goldWater bool, legacyRecharge bool) []map {"field": "retention.day30_rate", "available": true, "source": "likei Mongo 注册 cohort × user_daily_active_log D+30"}, } out := append(baseSources, legacySources...) + out = append(out, gameSources...) return append(out, goldWaterSources...) } diff --git a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go index 16d52b80..4ba17382 100644 --- a/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go +++ b/server/admin/internal/modules/dashboard/aslan_mongo_dashboard_test.go @@ -2,6 +2,7 @@ package dashboard import ( "context" + "database/sql/driver" "testing" "time" @@ -288,6 +289,94 @@ func TestAslanMongoListCoinSellerRechargeBills(t *testing.T) { } } +func TestAslanMongoLegacyGameFlowsUseSelectedDayNumberForRiyadhSnapshots(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("sqlmock: %v", err) + } + defer db.Close() + + // Social BI 仍按上海时区选择日期;wallet 游戏快照虽按 Riyadh 切日,但 group_num 是自然日号, + // 查询必须直接使用所选 yyyyMMdd,不能把请求时间点换区后再偏移日期。 + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load Shanghai location: %v", err) + } + start := time.Date(2026, 7, 5, 0, 0, 0, 0, location) + end := start.AddDate(0, 0, 1) + grid := newLegacyMongoGrid(start, end) + // 国家行存在时也不能继承全 App 游戏值;它只用于验证 assembleRange 的维度隔离。 + grid.at("2026-07-05", "SA").NewUsers = 1 + source := &AslanMongoDashboardSource{ + legacyDB: db, + legacyWalletDatabase: "atyou_wallet", + sysOrigin: "ATYOU", + requestTimeout: time.Second, + } + + arguments := []driver.Value{"ATYOU", "20260705", "20260706"} + for _, eventType := range aslanLegacyGameEventTypes { + arguments = append(arguments, eventType) + } + mock.ExpectQuery("FROM `atyou_wallet`\\.wallet_gold_count[\\s\\S]*sys_origin = \\?[\\s\\S]*event_type IN"). + WithArgs(arguments...). + WillReturnRows(sqlmock.NewRows([]string{"group_num", "type", "amount"}). + AddRow(20260705, 1, int64(12_345)). + AddRow(20260705, 0, int64(2_345))) + + if err := source.loadLegacyGameFlows(context.Background(), start, end, grid); err != nil { + t.Fatalf("loadLegacyGameFlows: %v", err) + } + flow := grid.gameFlows["2026-07-05"] + if flow == nil || flow.Turnover != 123 || flow.Payout != 23 { + t.Fatalf("legacy game flow mismatch: %+v", flow) + } + + overview := source.assembleRange(grid, externalDashboardCountryFilter{}, false, true, true) + if overview.Total.GameTurnover != 123 || overview.Total.GamePayout != 23 || overview.Total.GameProfit != 100 { + t.Fatalf("legacy game overview mismatch: %+v", overview.Total) + } + assertInt64(t, overview.DailySeries[0]["game_turnover"], 123) + assertInt64(t, overview.DailySeries[0]["game_payout"], 23) + assertInt64(t, overview.DailySeries[0]["game_profit"], 100) + if overview.DailySeries[0]["game_players"] != nil { + t.Fatalf("game players must stay unavailable: %#v", overview.DailySeries[0]) + } + if len(overview.CountryBreakdown) != 1 || overview.CountryBreakdown[0]["game_turnover"] != nil { + t.Fatalf("country rows must not contain global game totals: %#v", overview.CountryBreakdown) + } + + // 即使 grid 中已有全局快照,区域筛选路径传入 unavailable 后也必须继续输出 nil,防止全局值泄漏。 + regionOverview := source.assembleRange(grid, externalDashboardCountryFilter{Applied: true, RegionID: 9}, false, true, false) + if regionOverview.DailySeries[0]["game_turnover"] != nil || regionOverview.Total.GameTurnover != 0 { + t.Fatalf("region overview must not expose global game totals: daily=%#v total=%+v", regionOverview.DailySeries, regionOverview.Total) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } +} + +func TestAslanMongoLegacyGameWhitelistExcludesRewardOrigins(t *testing.T) { + seen := make(map[string]struct{}, len(aslanLegacyGameEventTypes)) + for _, eventType := range aslanLegacyGameEventTypes { + if _, duplicated := seen[eventType]; duplicated { + t.Fatalf("duplicate game event type %s", eventType) + } + seen[eventType] = struct{}{} + } + for _, required := range []string{"HOT_GAME_201", "HKYS_GAME_101", "BAISHUN_GAME_1182", "YOMI_GAME_282"} { + if _, ok := seen[required]; !ok { + t.Fatalf("confirmed game event type %s missing from whitelist", required) + } + } + for _, reward := range []string{"HOT_GAME_200", "BAISHUN_GAME_1085"} { + if _, ok := seen[reward]; ok { + t.Fatalf("reward origin %s must not be counted as game flow", reward) + } + } +} + func TestAslanMongoRechargeMatchExcludesFreightGold(t *testing.T) { match := bson.M{} aslanMongoExcludeFreightGoldRecharge(match) @@ -340,11 +429,16 @@ func TestAslanMongoLegacyRechargeHandlesZeroAndDeductionAmount(t *testing.T) { } func TestAslanMongoCoinSellerRechargeSourceAvailability(t *testing.T) { - available := aslanMongoDashboardMetricSources(false, true) + available := aslanMongoDashboardMetricSources(false, true, true, false) assertMetricSourceAvailable(t, available, "coin_seller_recharge_usd_minor") + assertMetricSourceAvailable(t, available, "game_turnover") - unavailable := aslanMongoDashboardMetricSources(false, false) + unavailable := aslanMongoDashboardMetricSources(false, false, false, false) assertMetricSourceUnavailable(t, unavailable, "coin_seller_recharge_usd_minor") + assertMetricSourceUnavailable(t, unavailable, "game_turnover") + + regionFiltered := aslanMongoDashboardMetricSources(false, true, false, true) + assertMetricSourceUnavailable(t, regionFiltered, "game_turnover") } func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) { @@ -358,7 +452,7 @@ func TestAslanMongoDailyRechargeUsersDeduplicateAcrossCountries(t *testing.T) { } applyAslanRechargeUserMetrics(nil, grid, distinctUsers, nil) - overview := (&AslanMongoDashboardSource{}).assembleRange(grid, externalDashboardCountryFilter{}, false, true) + overview := (&AslanMongoDashboardSource{}).assembleRange(grid, externalDashboardCountryFilter{}, false, true, false) if overview.Total.PaidUsers != 1 { t.Fatalf("total paid users = %d, want 1", overview.Total.PaidUsers) } @@ -409,11 +503,11 @@ func TestAslanMongoApplyRetentionAttributesToObservationDay(t *testing.T) { "2026-06-11": {"EG": {3}}, } earlier := map[string]map[string][]int64{ - "2026-06-09": {"SA": {10, 11}}, // 观察日 06-10 的 D1 cohort - "2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort - "2026-05-11": {"EG": {30}}, // 观察日 06-10 的 D30 cohort - "2026-06-04": {"EG": {40}}, // 观察日 06-11 的 D7 cohort - "2026-06-10": {"SA": {50}}, // 与主 cohorts 同日:合并成 {1,2,50} + "2026-06-09": {"SA": {10, 11}}, // 观察日 06-10 的 D1 cohort + "2026-06-03": {"SA": {20, 21, 22}}, // 观察日 06-10 的 D7 cohort + "2026-05-11": {"EG": {30}}, // 观察日 06-10 的 D30 cohort + "2026-06-04": {"EG": {40}}, // 观察日 06-11 的 D7 cohort + "2026-06-10": {"SA": {50}}, // 与主 cohorts 同日:合并成 {1,2,50} } activeByDay := map[string]map[int64]struct{}{ "2026-06-10": {10: {}, 20: {}, 30: {}}, diff --git a/server/admin/internal/modules/financewithdrawal/handler.go b/server/admin/internal/modules/financewithdrawal/handler.go index 623cc3ec..02277c73 100644 --- a/server/admin/internal/modules/financewithdrawal/handler.go +++ b/server/admin/internal/modules/financewithdrawal/handler.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/middleware" @@ -26,14 +27,37 @@ func New(store *repository.Store, wallet walletclient.Client, activity activityc } func (h *Handler) ListApplications(c *gin.Context) { + h.listApplications(c, false) +} + +func (h *Handler) ListOperationsApplications(c *gin.Context) { + h.listApplications(c, true) +} + +func (h *Handler) listApplications(c *gin.Context, operations bool) { options := shared.ListOptions(c) - items, total, err := h.service.ListApplications(repository.WithdrawalApplicationListOptions{ + listOptions := repository.WithdrawalApplicationListOptions{ Page: options.Page, PageSize: options.PageSize, AppCode: firstQuery(c, "app_code", "appCode"), Keyword: options.Keyword, - }) + } + var ( + items []withdrawalApplicationDTO + total int64 + err error + ) + actor := shared.ActorFromContext(c) + if operations { + items, total, err = h.service.ListOperationsApplications(actor, listOptions) + } else { + items, total, err = h.service.ListApplications(actor, listOptions) + } if err != nil { + if errors.Is(err, errWithdrawalMoneyScopeForbidden) { + response.Forbidden(c, err.Error()) + return + } response.ServerError(c, "获取用户提现申请列表失败") return } @@ -41,18 +65,31 @@ func (h *Handler) ListApplications(c *gin.Context) { } func (h *Handler) ApproveApplication(c *gin.Context) { - h.auditApplication(c, "approved") + h.auditApplication(c, "approved", false) } func (h *Handler) RejectApplication(c *gin.Context) { - h.auditApplication(c, "rejected") + h.auditApplication(c, "rejected", false) } -func (h *Handler) auditApplication(c *gin.Context, decision string) { +func (h *Handler) ApproveOperationsApplication(c *gin.Context) { + h.auditApplication(c, "approved", true) +} + +func (h *Handler) RejectOperationsApplication(c *gin.Context) { + h.auditApplication(c, "rejected", true) +} + +func (h *Handler) auditApplication(c *gin.Context, decision string, operations bool) { id, ok := shared.ParseID(c, "application_id") if !ok { return } + if strings.TrimSpace(c.GetHeader(appctx.HeaderAppCode)) == "" { + // 财务工作台可跨 App 列表,审核时必须由前端显式选中目标行 App;不能让 appctx 的 lalu 默认值静默决定资金数据域。 + response.BadRequest(c, "缺少 "+appctx.HeaderAppCode) + return + } var req struct { AuditRemark string `json:"auditRemark"` AuditImageURL string `json:"auditImageUrl"` @@ -66,7 +103,11 @@ func (h *Handler) auditApplication(c *gin.Context, decision string) { item *withdrawalApplicationDTO err error ) - if decision == "approved" { + if operations && decision == "approved" { + item, err = h.service.ApproveOperationsApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c)) + } else if operations { + item, err = h.service.RejectOperationsApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c)) + } else if decision == "approved" { item, err = h.service.ApproveApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, firstNonEmpty(req.AuditImageURL, req.AuditImageURLSnake), middleware.CurrentRequestID(c)) } else { item, err = h.service.RejectApplication(c.Request.Context(), shared.ActorFromContext(c), id, req.AuditRemark, middleware.CurrentRequestID(c)) @@ -75,7 +116,11 @@ func (h *Handler) auditApplication(c *gin.Context, decision string) { writeWithdrawalServiceError(c, err) return } - shared.OperationLogWithResourceID(c, h.audit, "audit-user-withdrawal-application", "admin_user_withdrawal_applications", strconv.FormatUint(uint64(item.ID), 10), "success", decision) + action := "audit-user-withdrawal-finance" + if operations { + action = "audit-user-withdrawal-operations" + } + shared.OperationLogWithResourceID(c, h.audit, action, "admin_user_withdrawal_applications", strconv.FormatUint(uint64(item.ID), 10), "success", decision) response.OK(c, item) } @@ -88,6 +133,14 @@ func writeWithdrawalServiceError(c *gin.Context, err error) { response.BadRequest(c, "提现申请已审核") return } + if errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) { + response.BadRequest(c, "提现申请尚未进入当前审核阶段") + return + } + if errors.Is(err, errWithdrawalMoneyScopeForbidden) { + response.Forbidden(c, err.Error()) + return + } switch strings.TrimSpace(err.Error()) { case "没有操作权限": response.Forbidden(c, err.Error()) diff --git a/server/admin/internal/modules/financewithdrawal/permission_test.go b/server/admin/internal/modules/financewithdrawal/permission_test.go index 1ff44f59..0feb140f 100644 --- a/server/admin/internal/modules/financewithdrawal/permission_test.go +++ b/server/admin/internal/modules/financewithdrawal/permission_test.go @@ -1,12 +1,56 @@ package financewithdrawal -import "testing" +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) func TestCanAuditWithdrawalUsesDedicatedPermission(t *testing.T) { if !canAuditWithdrawal([]string{"finance-withdrawal:audit"}) { - t.Fatal("dedicated withdrawal audit permission must allow auditing") + t.Fatal("finance withdrawal permission must allow finance final review") + } + if canAuditWithdrawal([]string{"operations-withdrawal:audit"}) { + t.Fatal("operations permission must not allow finance final review") } if canAuditWithdrawal([]string{"finance-application:audit"}) { t.Fatal("removed finance application permission must not authorize withdrawal auditing") } } + +func TestCanAuditOperationsWithdrawalUsesDedicatedPermission(t *testing.T) { + if !canAuditOperationsWithdrawal([]string{"operations-withdrawal:audit"}) { + t.Fatal("operations withdrawal permission must allow operations initial review") + } + if canAuditOperationsWithdrawal([]string{"finance-withdrawal:audit"}) { + t.Fatal("finance permission must not allow operations initial review") + } +} + +func TestWithdrawalAuditHandlersRequireExplicitAppHeader(t *testing.T) { + gin.SetMode(gin.TestMode) + handler := &Handler{} + router := gin.New() + router.POST("/finance/:application_id/approve", handler.ApproveApplication) + router.POST("/finance/:application_id/reject", handler.RejectApplication) + router.POST("/operations/:application_id/approve", handler.ApproveOperationsApplication) + router.POST("/operations/:application_id/reject", handler.RejectOperationsApplication) + + for _, path := range []string{ + "/finance/77/approve", + "/finance/77/reject", + "/operations/77/approve", + "/operations/77/reject", + } { + request := httptest.NewRequest(http.MethodPost, path, strings.NewReader(`{}`)) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "X-App-Code") { + t.Fatalf("missing App header path %s status=%d body=%s", path, response.Code, response.Body.String()) + } + } +} diff --git a/server/admin/internal/modules/financewithdrawal/response.go b/server/admin/internal/modules/financewithdrawal/response.go index cae15c08..1a9c9616 100644 --- a/server/admin/internal/modules/financewithdrawal/response.go +++ b/server/admin/internal/modules/financewithdrawal/response.go @@ -3,54 +3,66 @@ package financewithdrawal import "hyapp-admin-server/internal/model" type withdrawalApplicationDTO struct { - ID uint `json:"id"` - AppCode string `json:"appCode"` - AppName string `json:"appName"` - UserID string `json:"userId"` - SalaryAssetType string `json:"salaryAssetType"` - WithdrawAmount string `json:"withdrawAmount"` - WithdrawAmountMinor int64 `json:"withdrawAmountMinor"` - PointGrossAmount int64 `json:"pointGrossAmount,omitempty"` - PointFeeAmount int64 `json:"pointFeeAmount,omitempty"` - PointNetAmount int64 `json:"pointNetAmount,omitempty"` - PointsPerUSD int64 `json:"pointsPerUsd,omitempty"` - PointFeeBPS int32 `json:"pointFeeBps,omitempty"` - PointPolicyInstance string `json:"pointPolicyInstanceCode,omitempty"` - WithdrawMethod string `json:"withdrawMethod"` - WithdrawAddress string `json:"withdrawAddress"` - FreezeTransactionID string `json:"freezeTransactionId"` - AuditTransactionID string `json:"auditTransactionId"` - Status string `json:"status"` - ApproverUserID *uint `json:"approverUserId"` - ApproverName string `json:"approverName"` - AuditRemark string `json:"auditRemark"` - AuditImageURL string `json:"auditImageUrl"` - ApprovedAtMS *int64 `json:"approvedAtMs"` - CreatedAtMS int64 `json:"createdAtMs"` - UpdatedAtMS int64 `json:"updatedAtMs"` + ID uint `json:"id"` + AppCode string `json:"appCode"` + AppName string `json:"appName"` + UserID string `json:"userId"` + SalaryAssetType string `json:"salaryAssetType"` + WithdrawAmount string `json:"withdrawAmount"` + WithdrawAmountMinor int64 `json:"withdrawAmountMinor"` + PointGrossAmount int64 `json:"pointGrossAmount,omitempty"` + PointFeeAmount int64 `json:"pointFeeAmount,omitempty"` + PointNetAmount int64 `json:"pointNetAmount,omitempty"` + PointsPerUSD int64 `json:"pointsPerUsd,omitempty"` + PointFeeBPS int32 `json:"pointFeeBps,omitempty"` + PointPolicyInstance string `json:"pointPolicyInstanceCode,omitempty"` + WithdrawMethod string `json:"withdrawMethod"` + WithdrawAddress string `json:"withdrawAddress"` + FreezeTransactionID string `json:"freezeTransactionId"` + AuditTransactionID string `json:"auditTransactionId"` + Status string `json:"status"` + ApproverUserID *uint `json:"approverUserId"` + ApproverName string `json:"approverName"` + AuditRemark string `json:"auditRemark"` + AuditImageURL string `json:"auditImageUrl"` + ApprovedAtMS *int64 `json:"approvedAtMs"` + OperationsStatus string `json:"operationsStatus"` + OperationsReviewerUserID *uint `json:"operationsReviewerUserId"` + OperationsReviewerName string `json:"operationsReviewerName"` + OperationsAuditRemark string `json:"operationsAuditRemark"` + OperationsAuditTransactionID string `json:"operationsAuditTransactionId"` + OperationsReviewedAtMS *int64 `json:"operationsReviewedAtMs"` + CreatedAtMS int64 `json:"createdAtMs"` + UpdatedAtMS int64 `json:"updatedAtMs"` } func withdrawalApplicationDTOFromModel(item model.UserWithdrawalApplication) withdrawalApplicationDTO { dto := withdrawalApplicationDTO{ - ID: item.ID, - AppCode: item.AppCode, - AppName: item.AppCode, - UserID: item.UserID, - SalaryAssetType: item.SalaryAssetType, - WithdrawAmount: item.WithdrawAmount, - WithdrawAmountMinor: item.WithdrawAmountMinor, - WithdrawMethod: item.WithdrawMethod, - WithdrawAddress: item.WithdrawAddress, - FreezeTransactionID: item.FreezeTransactionID, - AuditTransactionID: item.AuditTransactionID, - Status: item.Status, - ApproverUserID: item.ApproverUserID, - ApproverName: item.ApproverName, - AuditRemark: item.AuditRemark, - AuditImageURL: item.AuditImageURL, - ApprovedAtMS: item.ApprovedAtMS, - CreatedAtMS: item.CreatedAtMS, - UpdatedAtMS: item.UpdatedAtMS, + ID: item.ID, + AppCode: item.AppCode, + AppName: item.AppCode, + UserID: item.UserID, + SalaryAssetType: item.SalaryAssetType, + WithdrawAmount: item.WithdrawAmount, + WithdrawAmountMinor: item.WithdrawAmountMinor, + WithdrawMethod: item.WithdrawMethod, + WithdrawAddress: item.WithdrawAddress, + FreezeTransactionID: item.FreezeTransactionID, + AuditTransactionID: item.AuditTransactionID, + Status: item.Status, + ApproverUserID: item.ApproverUserID, + ApproverName: item.ApproverName, + AuditRemark: item.AuditRemark, + AuditImageURL: item.AuditImageURL, + ApprovedAtMS: item.ApprovedAtMS, + OperationsStatus: item.OperationsStatus, + OperationsReviewerUserID: item.OperationsReviewerUserID, + OperationsReviewerName: item.OperationsReviewerName, + OperationsAuditRemark: item.OperationsAuditRemark, + OperationsAuditTransactionID: item.OperationsAuditTransactionID, + OperationsReviewedAtMS: item.OperationsReviewedAtMS, + CreatedAtMS: item.CreatedAtMS, + UpdatedAtMS: item.UpdatedAtMS, } if isPointWithdrawalAssetType(item.SalaryAssetType) { feePoints, netPoints, pointsPerUSD, feeBPS := pointWithdrawalSnapshot(item) diff --git a/server/admin/internal/modules/financewithdrawal/routes.go b/server/admin/internal/modules/financewithdrawal/routes.go index 94cf26fa..6b634743 100644 --- a/server/admin/internal/modules/financewithdrawal/routes.go +++ b/server/admin/internal/modules/financewithdrawal/routes.go @@ -13,4 +13,7 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.GET("/admin/finance/withdrawal-applications", middleware.RequireAnyPermission(permissionViewWithdrawalApplications, permissionAuditWithdrawalApplications), h.ListApplications) protected.POST("/admin/finance/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.ApproveApplication) protected.POST("/admin/finance/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditWithdrawalApplications), h.RejectApplication) + protected.GET("/admin/operations/withdrawal-applications", middleware.RequireAnyPermission(permissionViewOperationsWithdrawals, permissionAuditOperationsWithdrawals), h.ListOperationsApplications) + protected.POST("/admin/operations/withdrawal-applications/:application_id/approve", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.ApproveOperationsApplication) + protected.POST("/admin/operations/withdrawal-applications/:application_id/reject", middleware.RequirePermission(permissionAuditOperationsWithdrawals), h.RejectOperationsApplication) } diff --git a/server/admin/internal/modules/financewithdrawal/service.go b/server/admin/internal/modules/financewithdrawal/service.go index 4b263952..79b4ca48 100644 --- a/server/admin/internal/modules/financewithdrawal/service.go +++ b/server/admin/internal/modules/financewithdrawal/service.go @@ -22,11 +22,15 @@ import ( const ( permissionViewWithdrawalApplications = "finance-withdrawal:view" permissionAuditWithdrawalApplications = "finance-withdrawal:audit" + permissionViewOperationsWithdrawals = "operations-withdrawal:view" + permissionAuditOperationsWithdrawals = "operations-withdrawal:audit" pointWithdrawalDefaultPointsPerUSD = int64(100000) pointWithdrawalDefaultFeeBPS = int32(500) ) +var errWithdrawalMoneyScopeForbidden = errors.New("没有该 App 的财务范围") + type Service struct { store *repository.Store wallet walletclient.Client @@ -43,10 +47,16 @@ func NewService(store *repository.Store, wallet walletclient.Client, activity ac return &Service{store: store, wallet: wallet, activity: activity, now: func() time.Time { return time.Now().UTC() }} } -func (s *Service) ListApplications(options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { +func (s *Service) ListApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { if s == nil || s.store == nil { return nil, 0, errors.New("admin store is not configured") } + var err error + options, err = s.scopeWithdrawalList(actor, options) + if err != nil { + return nil, 0, err + } + options.Stage = repository.WithdrawalApplicationReviewStageFinance items, total, err := s.store.ListWithdrawalApplications(options) if err != nil { return nil, 0, err @@ -54,73 +64,128 @@ func (s *Service) ListApplications(options repository.WithdrawalApplicationListO return withdrawalApplicationDTOsFromModel(items), total, nil } +func (s *Service) ListOperationsApplications(actor shared.Actor, options repository.WithdrawalApplicationListOptions) ([]withdrawalApplicationDTO, int64, error) { + if s == nil || s.store == nil { + return nil, 0, errors.New("admin store is not configured") + } + var err error + options, err = s.scopeWithdrawalList(actor, options) + if err != nil { + return nil, 0, err + } + options.Stage = repository.WithdrawalApplicationReviewStageOperations + items, total, err := s.store.ListWithdrawalApplications(options) + if err != nil { + return nil, 0, err + } + return withdrawalApplicationDTOsFromModel(items), total, nil +} + +func (s *Service) scopeWithdrawalList(actor shared.Actor, options repository.WithdrawalApplicationListOptions) (repository.WithdrawalApplicationListOptions, error) { + access, err := s.store.MoneyAccessForUser(actor.UserID) + if err != nil { + return options, err + } + if requestedApp := strings.TrimSpace(options.AppCode); requestedApp != "" { + options.AppCode = appctx.Normalize(requestedApp) + if !access.AllowsApp(options.AppCode) { + return options, errWithdrawalMoneyScopeForbidden + } + return options, nil + } + options.AppCode = "" + if !access.All { + // 空 app 查询不代表全局;非全量财务用户只能在 admin_user_money_scopes 授权的 App 集合中分页。 + options.RestrictAppCodes = true + options.AllowedAppCodes = access.AppCodes() + } + return options, nil +} + func (s *Service) ApproveApplication(ctx context.Context, actor shared.Actor, id uint, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) { - return s.auditApplication(ctx, actor, id, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID) + return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved, remark, auditImageURL, requestID) } func (s *Service) RejectApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) { - return s.auditApplication(ctx, actor, id, model.WithdrawalApplicationStatusRejected, remark, "", requestID) + return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusRejected, remark, "", requestID) } -func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) { +func (s *Service) ApproveOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) { + return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusApproved, remark, "", requestID) +} + +func (s *Service) RejectOperationsApplication(ctx context.Context, actor shared.Actor, id uint, remark string, requestID string) (*withdrawalApplicationDTO, error) { + return s.auditApplication(ctx, actor, id, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected, remark, "", requestID) +} + +func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id uint, stage repository.WithdrawalApplicationReviewStage, decision string, remark string, auditImageURL string, requestID string) (*withdrawalApplicationDTO, error) { if s == nil || s.store == nil || s.wallet == nil || s.activity == nil { return nil, errors.New("admin finance withdrawal service is not configured") } - if id == 0 || actor.UserID == 0 || !canAuditWithdrawal(actor.Permissions) { + if id == 0 || actor.UserID == 0 || !canAuditWithdrawalStage(stage, actor.Permissions) { return nil, errors.New("没有操作权限") } remark = strings.TrimSpace(remark) auditImageURL = strings.TrimSpace(auditImageURL) - if decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" { + if stage == repository.WithdrawalApplicationReviewStageFinance && decision == model.WithdrawalApplicationStatusApproved && auditImageURL == "" { return nil, errors.New("提现通过凭证图片不能为空") } if decision == model.WithdrawalApplicationStatusRejected && remark == "" { return nil, errors.New("拒绝原因不能为空") } appCode := appctx.FromContext(ctx) - item, err := s.store.GetWithdrawalApplicationForApp(appCode, id) + access, err := s.store.MoneyAccessForUser(actor.UserID) if err != nil { return nil, err } - if item.Status != model.WithdrawalApplicationStatusPending { - return nil, repository.ErrWithdrawalApplicationAlreadyAudited - } - userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64) - if err != nil || userID <= 0 || item.SalaryAssetType == "" || item.WithdrawAmountMinor <= 0 || item.FreezeTransactionID == "" { - return nil, errors.New("提现单冻结信息不完整") - } - commandID := withdrawalAuditCommandID(id, decision) - amountMinor := item.WithdrawAmountMinor - transactionID, err := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, amountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id) - if err != nil { - return nil, err + if !access.AllowsApp(appCode) { + // 审核以目标行的 App header 为数据域;即使 JWT 含按钮权限,也不能跨 admin_user_money_scopes 触发钱包动作。 + return nil, errWithdrawalMoneyScopeForbidden } nowMS := s.now().UnixMilli() - if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ - ApplicationID: id, - AppCode: appCode, - UserID: userID, - Decision: decision, - Remark: remark, - AuditImageURL: auditImageURL, - AuditTransactionID: transactionID, - WithdrawAmount: item.WithdrawAmount, - WithdrawMethod: item.WithdrawMethod, - RequestID: requestID, - SentAtMS: nowMS, - }); err != nil { - // 钱包同意/释放已经用 command id 做幂等;通知失败时不写后台终态,财务重试会复用同一钱包命令和消息事件,避免重复扣款、返还或重复通知。 - return nil, err + commandID := withdrawalAuditCommandID(id, stage) + if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusRejected { + return s.executeOperationsRejection(ctx, actor, id, appCode, remark, requestID, commandID, nowMS) } - updated, err := s.store.AuditWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ - Decision: decision, - ApproverUserID: actor.UserID, - ApproverName: actor.Username, - AuditRemark: remark, - AuditImageURL: auditImageURL, - AuditCommandID: commandID, - AuditTransactionID: transactionID, - ApprovedAtMS: nowMS, + updated, err := s.store.ReviewWithdrawalApplicationForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ + Stage: stage, + Decision: decision, + ApproverUserID: actor.UserID, + ApproverName: actor.Username, + AuditRemark: remark, + AuditImageURL: auditImageURL, + ApprovedAtMS: nowMS, + }, func(item model.UserWithdrawalApplication) (repository.WithdrawalApplicationAuditEffect, error) { + userID, parseErr := withdrawalWalletUserID(item) + if parseErr != nil { + return repository.WithdrawalApplicationAuditEffect{}, parseErr + } + if stage == repository.WithdrawalApplicationReviewStageOperations && decision == model.WithdrawalApplicationStatusApproved { + // 运营通过只确认业务资料并转交财务;申请金额继续保留在 frozen,且不能提前向用户发送最终通过通知。 + return repository.WithdrawalApplicationAuditEffect{}, nil + } + transactionID, walletErr := s.applyWalletDecision(ctx, decision, commandID, appCode, userID, item.SalaryAssetType, item.WithdrawAmountMinor, item.PointFeeAmount, item.PointNetAmount, item.PointsPerUSD, item.PointFeeBPS, actor, strings.TrimSpace(remark), id) + if walletErr != nil { + return repository.WithdrawalApplicationAuditEffect{}, walletErr + } + if noticeErr := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ + ApplicationID: id, + AppCode: appCode, + UserID: userID, + Stage: stage, + Decision: decision, + Remark: remark, + AuditImageURL: auditImageURL, + AuditTransactionID: transactionID, + WithdrawAmount: item.WithdrawAmount, + WithdrawMethod: item.WithdrawMethod, + RequestID: requestID, + SentAtMS: nowMS, + }); noticeErr != nil { + // 钱包扣冻/释放使用阶段固定 command id,通知使用阶段+结果事件 id;回调失败会回滚 admin 状态,重试不会重复资金动作或消息。 + return repository.WithdrawalApplicationAuditEffect{}, noticeErr + } + return repository.WithdrawalApplicationAuditEffect{CommandID: commandID, TransactionID: transactionID}, nil }) if err != nil { return nil, err @@ -129,6 +194,80 @@ func (s *Service) auditApplication(ctx context.Context, actor shared.Actor, id u return &dto, nil } +// executeOperationsRejection 把拒绝拆成 claim -> 外部幂等动作 -> finalize 三段。 +// claim 是独立短事务,因此 wallet 已释放后即使 notice 或最终 admin 提交失败,申请仍停在 rejecting,不能被改点通过或进入财务。 +func (s *Service) executeOperationsRejection(ctx context.Context, actor shared.Actor, id uint, appCode string, remark string, requestID string, commandID string, nowMS int64) (*withdrawalApplicationDTO, error) { + claimed, err := s.store.ClaimWithdrawalOperationsRejectionForApp(appCode, id, repository.WithdrawalApplicationAuditInput{ + Stage: repository.WithdrawalApplicationReviewStageOperations, + Decision: model.WithdrawalApplicationStatusRejected, + ApproverUserID: actor.UserID, + ApproverName: actor.Username, + AuditRemark: remark, + AuditCommandID: commandID, + ApprovedAtMS: nowMS, + }, func(item model.UserWithdrawalApplication) error { + _, err := withdrawalWalletUserID(item) + return err + }) + if err != nil { + return nil, err + } + if claimed.Status == model.WithdrawalApplicationStatusRejected && claimed.OperationsStatus == model.WithdrawalOperationsStatusRejected { + // 已完成的重复请求直接返回终态,避免再次调用 wallet/inbox;前端重试是安全的。 + dto := withdrawalApplicationDTOFromModel(*claimed) + return &dto, nil + } + + userID, err := withdrawalWalletUserID(*claimed) + if err != nil { + return nil, err + } + if claimed.OperationsReviewerUserID == nil || *claimed.OperationsReviewerUserID == 0 || strings.TrimSpace(claimed.OperationsAuditRemark) == "" { + return nil, errors.New("提现单运营拒绝 claim 不完整") + } + // 重试沿用第一次 claim 的审核人和拒绝原因,不能让资金 metadata、用户通知和后台审计快照随重试人改变。 + claimedActor := shared.Actor{UserID: *claimed.OperationsReviewerUserID, Username: claimed.OperationsReviewerName} + claimedRemark := strings.TrimSpace(claimed.OperationsAuditRemark) + transactionID, err := s.applyWalletDecision(ctx, model.WithdrawalApplicationStatusRejected, commandID, appCode, userID, claimed.SalaryAssetType, claimed.WithdrawAmountMinor, claimed.PointFeeAmount, claimed.PointNetAmount, claimed.PointsPerUSD, claimed.PointFeeBPS, claimedActor, claimedRemark, id) + if err != nil { + return nil, err + } + sentAtMS := nowMS + if claimed.OperationsReviewedAtMS != nil && *claimed.OperationsReviewedAtMS > 0 { + sentAtMS = *claimed.OperationsReviewedAtMS + } + if err := s.sendWithdrawalAuditNotice(ctx, auditNoticeInput{ + ApplicationID: id, + AppCode: appCode, + UserID: userID, + Stage: repository.WithdrawalApplicationReviewStageOperations, + Decision: model.WithdrawalApplicationStatusRejected, + Remark: claimedRemark, + AuditTransactionID: transactionID, + WithdrawAmount: claimed.WithdrawAmount, + WithdrawMethod: claimed.WithdrawMethod, + RequestID: requestID, + SentAtMS: sentAtMS, + }); err != nil { + // rejecting 已在前一事务提交;返回错误让运营页展示“重试拒绝”,绝不能回退成 pending。 + return nil, err + } + updated, err := s.store.FinalizeWithdrawalOperationsRejectionForApp(appCode, id, commandID, transactionID, s.now().UnixMilli()) + if err != nil { + return nil, err + } + dto := withdrawalApplicationDTOFromModel(*updated) + return &dto, nil +} + +func withdrawalWalletUserID(item model.UserWithdrawalApplication) (int64, error) { + userID, err := strconv.ParseInt(strings.TrimSpace(item.UserID), 10, 64) + if err != nil || userID <= 0 || strings.TrimSpace(item.SalaryAssetType) == "" || item.WithdrawAmountMinor <= 0 || strings.TrimSpace(item.FreezeTransactionID) == "" { + return 0, errors.New("提现单冻结信息不完整") + } + return userID, nil +} + func (s *Service) applyWalletDecision(ctx context.Context, decision string, commandID string, appCode string, userID int64, assetType string, amountMinor int64, storedFeePoints int64, storedNetPoints int64, storedPointsPerUSD int64, storedFeeBPS int32, actor shared.Actor, remark string, applicationID uint) (string, error) { reason := strings.TrimSpace(remark) if reason == "" { @@ -223,8 +362,9 @@ func (s *Service) applyWalletDecision(ctx context.Context, decision string, comm } } -func withdrawalAuditCommandID(id uint, decision string) string { - return fmt.Sprintf("salary-withdrawal:%d:%s", id, decision) +func withdrawalAuditCommandID(id uint, stage repository.WithdrawalApplicationReviewStage) string { + // 同一阶段的通过和拒绝复用 command id;并发相反决策会在 wallet request hash 校验处冲突,不能先后 settle/release 同一冻结金额。 + return fmt.Sprintf("salary-withdrawal:%d:%s", id, stage) } func isPointWithdrawalAssetType(assetType string) bool { @@ -240,6 +380,7 @@ type auditNoticeInput struct { ApplicationID uint AppCode string UserID int64 + Stage repository.WithdrawalApplicationReviewStage Decision string Remark string AuditImageURL string @@ -259,6 +400,9 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti body = "Your withdrawal request has been rejected. Reason: " + strings.TrimSpace(input.Remark) title = "Withdrawal request rejected" eventType = "finance_withdrawal_rejected" + if input.Stage == repository.WithdrawalApplicationReviewStageOperations { + eventType = "operations_withdrawal_rejected" + } } else { imageURL = strings.TrimSpace(input.AuditImageURL) } @@ -267,6 +411,7 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti "app_code": strings.TrimSpace(input.AppCode), "audit_transaction_id": strings.TrimSpace(input.AuditTransactionID), "decision": strings.TrimSpace(input.Decision), + "review_stage": string(input.Stage), "withdraw_amount": strings.TrimSpace(input.WithdrawAmount), "withdraw_method": strings.TrimSpace(input.WithdrawMethod), }) @@ -282,7 +427,7 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti }, TargetUserId: input.UserID, Producer: "admin-server", - ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Decision), + ProducerEventId: withdrawalAuditNotificationEventID(input.ApplicationID, input.Stage, input.Decision), ProducerEventType: eventType, MessageType: "system", AggregateType: "user_withdrawal_application", @@ -300,14 +445,32 @@ func (s *Service) sendWithdrawalAuditNotice(ctx context.Context, input auditNoti return nil } -func withdrawalAuditNotificationEventID(id uint, decision string) string { - return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision)) +func withdrawalAuditNotificationEventID(id uint, stage repository.WithdrawalApplicationReviewStage, decision string) string { + if stage == repository.WithdrawalApplicationReviewStageFinance { + // 旧财务通知可能已成功而 admin 申请仍 pending;继续使用原 event id,部署后重试才不会重复发用户通知。 + return fmt.Sprintf("finance-withdrawal:%d:%s", id, strings.TrimSpace(decision)) + } + return fmt.Sprintf("operations-withdrawal:%d:%s", id, strings.TrimSpace(decision)) } func canAuditWithdrawal(permissions []string) bool { + return hasWithdrawalPermission(permissions, permissionAuditWithdrawalApplications) +} + +func canAuditOperationsWithdrawal(permissions []string) bool { + return hasWithdrawalPermission(permissions, permissionAuditOperationsWithdrawals) +} + +func canAuditWithdrawalStage(stage repository.WithdrawalApplicationReviewStage, permissions []string) bool { + if stage == repository.WithdrawalApplicationReviewStageOperations { + return canAuditOperationsWithdrawal(permissions) + } + return canAuditWithdrawal(permissions) +} + +func hasWithdrawalPermission(permissions []string, expected string) bool { for _, permission := range permissions { - switch strings.TrimSpace(permission) { - case permissionAuditWithdrawalApplications: + if strings.TrimSpace(permission) == expected { return true } } diff --git a/server/admin/internal/modules/financewithdrawal/service_test.go b/server/admin/internal/modules/financewithdrawal/service_test.go index 30c95036..12e12fe6 100644 --- a/server/admin/internal/modules/financewithdrawal/service_test.go +++ b/server/admin/internal/modules/financewithdrawal/service_test.go @@ -2,14 +2,229 @@ package financewithdrawal import ( "context" + "errors" "testing" + "time" + "hyapp-admin-server/internal/appctx" + "hyapp-admin-server/internal/integration/activityclient" "hyapp-admin-server/internal/integration/walletclient" "hyapp-admin-server/internal/model" "hyapp-admin-server/internal/modules/shared" + "hyapp-admin-server/internal/repository" + activityv1 "hyapp.local/api/proto/activity/v1" walletv1 "hyapp.local/api/proto/wallet/v1" + + "github.com/DATA-DOG/go-sqlmock" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) +func TestOperationsApprovalMovesToFinanceWithoutWalletOrUserNotice(t *testing.T) { + svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + expectWithdrawalMoneyAccess(mock, 8, "lalu") + expectWithdrawalReviewLock(mock, model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending) + mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + dto, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{ + UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals}, + }, 77, "资料无误", "request-ops-approve") + if err != nil { + t.Fatalf("approve operations withdrawal failed: %v", err) + } + if dto.Status != model.WithdrawalApplicationStatusPending || dto.OperationsStatus != model.WithdrawalOperationsStatusApproved || dto.OperationsReviewerName != "operations" { + t.Fatalf("operations approval state mismatch: %+v", dto) + } + if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 { + t.Fatalf("operations approval must not touch wallet or send final notice: wallet=%+v notice_calls=%d", wallet, activity.calls) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestOperationsRejectionReleasesFrozenBalanceAndSendsFinalNotice(t *testing.T) { + svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + expectWithdrawalMoneyAccess(mock, 8, "lalu") + expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending) + expectWithdrawalRejectionFinalize(mock) + + dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{ + UserID: 8, Username: "operations", Permissions: []string{permissionAuditOperationsWithdrawals}, + }, 77, "收款资料不符", "request-ops-reject") + if err != nil { + t.Fatalf("reject operations withdrawal failed: %v", err) + } + if wallet.releaseSalary == nil || wallet.releaseSalary.GetCommandId() != "salary-withdrawal:77:operations" || wallet.releaseSalary.GetSalaryUsdMinor() != 5000 { + t.Fatalf("operations rejection release mismatch: %+v", wallet.releaseSalary) + } + if wallet.settleSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil { + t.Fatalf("operations rejection must only release matching salary asset: %+v", wallet) + } + if activity.calls != 1 || activity.last.GetProducerEventId() != "operations-withdrawal:77:rejected" || activity.last.GetProducerEventType() != "operations_withdrawal_rejected" { + t.Fatalf("operations rejection final notice mismatch: calls=%d request=%+v", activity.calls, activity.last) + } + if dto.Status != model.WithdrawalApplicationStatusRejected || dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || dto.OperationsAuditTransactionID != "salary-release-tx" { + t.Fatalf("operations rejection state mismatch: %+v", dto) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestOperationsRejectionNoticeFailureStaysRejectingAndRetryKeepsFirstAuditContext(t *testing.T) { + svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + noticeFailure := errors.New("activity unavailable") + activity.failures = []error{noticeFailure, nil} + + expectWithdrawalMoneyAccess(mock, 8, "lalu") + expectWithdrawalRejectionClaim(mock, model.WithdrawalOperationsStatusPending) + _, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{ + UserID: 8, Username: "first-operator", Permissions: []string{permissionAuditOperationsWithdrawals}, + }, 77, "first rejection reason", "request-ops-reject-first") + if !errors.Is(err, noticeFailure) { + t.Fatalf("first rejection must surface notice failure, got %v", err) + } + + // 第二个运营人员重试时只能沿用第一次 claim 快照;数据库 rejecting 行禁止改点通过,也不允许改写拒绝原因。 + expectWithdrawalMoneyAccess(mock, 18, "lalu") + expectWithdrawalRejectionRetryClaim(mock, 8, "first-operator", "first rejection reason") + expectWithdrawalRejectionFinalizeWithContext(mock, 8, "first-operator", "first rejection reason") + dto, err := svc.RejectOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{ + UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals}, + }, 77, "changed retry reason", "request-ops-reject-retry") + if err != nil { + t.Fatalf("retry rejecting withdrawal failed: %v", err) + } + if dto.OperationsStatus != model.WithdrawalOperationsStatusRejected || wallet.releaseSalary.GetOperatorUserId() != 8 || wallet.releaseSalary.GetReason() != "first rejection reason" { + t.Fatalf("retry must preserve first claim context: dto=%+v wallet=%+v", dto, wallet.releaseSalary) + } + if activity.calls != 2 || activity.last == nil || activity.last.GetBody() != "Your withdrawal request has been rejected. Reason: first rejection reason" { + t.Fatalf("retry notice must preserve first rejection reason: calls=%d request=%+v", activity.calls, activity.last) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestOperationsApprovalRejectsDurableRejectingClaimBeforeWalletOrNotice(t *testing.T) { + svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + expectWithdrawalMoneyAccess(mock, 18, "lalu") + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("lalu", uint(77), 1). + WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "first-operator", "first rejection reason")) + mock.ExpectRollback() + + _, err := svc.ApproveOperationsApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{ + UserID: 18, Username: "retry-operator", Permissions: []string{permissionAuditOperationsWithdrawals}, + }, 77, "改为通过", "request-ops-approve-after-reject") + if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) { + t.Fatalf("rejecting claim must block operations approval, got %v", err) + } + if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 { + t.Fatalf("rejecting gate must run before integrations: wallet=%+v notices=%d", wallet, activity.calls) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestFinanceReviewRejectsOperationsPendingBeforeWalletOrNotice(t *testing.T) { + svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + expectWithdrawalMoneyAccess(mock, 9, "lalu") + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("lalu", uint(77), 1). + WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)) + mock.ExpectRollback() + + _, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{ + UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications}, + }, 77, "已打款", "https://example.com/proof.png", "request-finance-approve") + if !errors.Is(err, repository.ErrWithdrawalApplicationStageNotReviewable) { + t.Fatalf("finance must reject operations-pending application, got %v", err) + } + if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 { + t.Fatalf("stage gate must run before wallet or notice: wallet=%+v notice_calls=%d", wallet, activity.calls) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestOperationsListRejectsExplicitAppOutsideMoneyScope(t *testing.T) { + svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + expectWithdrawalMoneyAccess(mock, 8, "lalu") + + _, _, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{AppCode: "huwaa"}) + if !errors.Is(err, errWithdrawalMoneyScopeForbidden) { + t.Fatalf("out-of-scope list must be forbidden, got %v", err) + } + if wallet.settleSalary != nil || wallet.releaseSalary != nil || activity.calls != 0 { + t.Fatalf("out-of-scope list must not call integrations: wallet=%+v notices=%d", wallet, activity.calls) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestOperationsListWithoutAppRestrictsToMoneyScopeApps(t *testing.T) { + svc, mock, _, _, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + expectWithdrawalMoneyAccess(mock, 8, "lalu", "fami") + mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?"). + WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*app_code IN.*operations_status <> \\?"). + WithArgs("fami", "lalu", model.WithdrawalOperationsStatusSkipped, 20). + WillReturnRows(sqlmock.NewRows([]string{"id"})) + + items, total, err := svc.ListOperationsApplications(shared.Actor{UserID: 8}, repository.WithdrawalApplicationListOptions{Page: 1, PageSize: 20}) + if err != nil || total != 0 || len(items) != 0 { + t.Fatalf("money-scoped operations list mismatch: total=%d items=%+v err=%v", total, items, err) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestFinanceAuditRejectsHeaderAppOutsideMoneyScopeBeforeWallet(t *testing.T) { + svc, mock, wallet, activity, closeService := newWithdrawalAuditServiceTest(t) + defer closeService() + expectWithdrawalMoneyAccess(mock, 9, "fami") + + _, err := svc.ApproveApplication(appctx.WithContext(context.Background(), "lalu"), shared.Actor{ + UserID: 9, Username: "finance", Permissions: []string{permissionAuditWithdrawalApplications}, + }, 77, "paid", "https://example.com/proof.png", "request-out-of-scope") + if !errors.Is(err, errWithdrawalMoneyScopeForbidden) { + t.Fatalf("out-of-scope audit must be forbidden, got %v", err) + } + if wallet.settleSalary != nil || wallet.releaseSalary != nil || wallet.settlePoint != nil || wallet.releasePoint != nil || activity.calls != 0 { + t.Fatalf("money scope gate must run before row lock, wallet, or notice: wallet=%+v notices=%d", wallet, activity.calls) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestWithdrawalAuditNotificationEventIDPreservesFinanceCompatibility(t *testing.T) { + t.Parallel() + if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageFinance, model.WithdrawalApplicationStatusApproved); got != "finance-withdrawal:77:approved" { + t.Fatalf("finance event id = %q", got) + } + if got := withdrawalAuditNotificationEventID(77, repository.WithdrawalApplicationReviewStageOperations, model.WithdrawalApplicationStatusRejected); got != "operations-withdrawal:77:rejected" { + t.Fatalf("operations event id = %q", got) + } +} + func TestApplyWalletDecisionRoutesPointApprovalToPointSettlement(t *testing.T) { wallet := &fakeAuditWalletClient{} svc := &Service{wallet: wallet} @@ -120,3 +335,121 @@ func (f *fakeAuditWalletClient) ReleaseSalaryWithdrawal(_ context.Context, req * f.releaseSalary = req return &walletv1.ReleaseSalaryWithdrawalResponse{TransactionId: "salary-release-tx"}, nil } + +type fakeAuditActivityClient struct { + activityclient.Client + last *activityv1.CreateInboxMessageRequest + calls int + failures []error +} + +func (f *fakeAuditActivityClient) CreateInboxMessage(_ context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) { + f.last = req + f.calls++ + if index := f.calls - 1; index < len(f.failures) && f.failures[index] != nil { + return nil, f.failures[index] + } + return &activityv1.CreateInboxMessageResponse{}, nil +} + +func newWithdrawalAuditServiceTest(t *testing.T) (*Service, sqlmock.Sqlmock, *fakeAuditWalletClient, *fakeAuditActivityClient, func()) { + t.Helper() + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sql mock failed: %v", err) + } + gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + _ = sqlDB.Close() + t.Fatalf("create gorm db failed: %v", err) + } + wallet := &fakeAuditWalletClient{} + activity := &fakeAuditActivityClient{} + svc := &Service{ + store: repository.New(gormDB), wallet: wallet, activity: activity, + now: func() time.Time { return time.UnixMilli(1_700_000_300_000).UTC() }, + } + return svc, mock, wallet, activity, func() { _ = sqlDB.Close() } +} + +func expectWithdrawalReviewLock(mock sqlmock.Sqlmock, status string, operationsStatus string) { + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("lalu", uint(77), 1). + WillReturnRows(withdrawalAuditRows(status, operationsStatus)) +} + +func expectWithdrawalRejectionClaim(mock sqlmock.Sqlmock, operationsStatus string) { + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("lalu", uint(77), 1). + WillReturnRows(withdrawalAuditRows(model.WithdrawalApplicationStatusPending, operationsStatus)) + if operationsStatus == model.WithdrawalOperationsStatusPending { + mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1)) + } + mock.ExpectCommit() +} + +func expectWithdrawalRejectionRetryClaim(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) { + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("lalu", uint(77), 1). + WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark)) + mock.ExpectCommit() +} + +func expectWithdrawalRejectionFinalize(mock sqlmock.Sqlmock) { + expectWithdrawalRejectionFinalizeWithContext(mock, 8, "operations", "收款资料不符") +} + +func expectWithdrawalRejectionFinalizeWithContext(mock sqlmock.Sqlmock, reviewerID uint, reviewerName string, remark string) { + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("lalu", uint(77), 1). + WillReturnRows(withdrawalAuditRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, reviewerID, reviewerName, remark)) + mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() +} + +func expectWithdrawalMoneyAccess(mock sqlmock.Sqlmock, userID uint, appCodes ...string) { + mock.ExpectQuery("SELECT \\* FROM `admin_users` WHERE `admin_users`.`id` = \\? ORDER BY `admin_users`.`id` LIMIT \\?"). + WillReturnRows(sqlmock.NewRows([]string{"id", "username", "name", "status"}).AddRow(userID, "reviewer", "Reviewer", "active")) + mock.ExpectQuery("SELECT \\* FROM `admin_user_roles` WHERE `admin_user_roles`.`user_id` = \\?"). + WillReturnRows(sqlmock.NewRows([]string{"user_id", "role_id"})) + rows := sqlmock.NewRows([]string{"id", "user_id", "app_code", "region_id", "created_at_ms", "updated_at_ms"}) + for index, appCode := range appCodes { + rows.AddRow(index+1, userID, appCode, 0, int64(1_700_000_000_000), int64(1_700_000_000_000)) + } + mock.ExpectQuery("SELECT \\* FROM `admin_user_money_scopes` WHERE user_id = \\? ORDER BY app_code ASC, region_id ASC"). + WillReturnRows(rows) +} + +func withdrawalAuditRows(status string, operationsStatus string) *sqlmock.Rows { + return withdrawalAuditRowsWithOperationsContext(status, operationsStatus, 0, "", "") +} + +func withdrawalAuditRowsWithOperationsContext(status string, operationsStatus string, reviewerID uint, reviewerName string, remark string) *sqlmock.Rows { + var storedReviewerID any + var reviewedAtMS any + operationsCommandID := "" + if reviewerID > 0 { + storedReviewerID = reviewerID + reviewedAtMS = int64(1_700_000_300_000) + operationsCommandID = "salary-withdrawal:77:operations" + } + return sqlmock.NewRows([]string{ + "id", "app_code", "user_id", "salary_asset_type", "withdraw_amount", "withdraw_amount_minor", + "withdraw_method", "withdraw_address", "freeze_command_id", "freeze_transaction_id", + "audit_command_id", "audit_transaction_id", "status", "operations_status", + "approver_user_id", "approver_name", "audit_remark", "audit_image_url", "approved_at_ms", + "operations_reviewer_user_id", "operations_reviewer_name", "operations_audit_remark", + "operations_audit_command_id", "operations_audit_transaction_id", "operations_reviewed_at_ms", + "created_at_ms", "updated_at_ms", + }).AddRow( + 77, "lalu", "42001", "HOST_SALARY_USD", "50.00", int64(5000), + "usdt_trc20", "TRON-address", "freeze-command", "freeze-tx", + "", "", status, operationsStatus, nil, "", "", "", nil, + storedReviewerID, reviewerName, remark, operationsCommandID, "", reviewedAtMS, + int64(1_700_000_000_000), int64(1_700_000_000_000), + ) +} diff --git a/server/admin/internal/modules/payment/handler_test.go b/server/admin/internal/modules/payment/handler_test.go index d12c34da..26d3ccde 100644 --- a/server/admin/internal/modules/payment/handler_test.go +++ b/server/admin/internal/modules/payment/handler_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "slices" "testing" "hyapp-admin-server/internal/appctx" @@ -20,6 +21,21 @@ import ( "gorm.io/gorm" ) +func TestMoneyScopeReadPermissionsIncludeWithdrawalWorkspaces(t *testing.T) { + t.Parallel() + for _, permission := range []string{ + financeViewPermission, + "finance-withdrawal:view", + "finance-withdrawal:audit", + "operations-withdrawal:view", + "operations-withdrawal:audit", + } { + if !slices.Contains(moneyScopeReadPermissions, permission) { + t.Fatalf("money scope read permission missing %s", permission) + } + } +} + func TestListRechargeBillsResolvesDisplayIDsBeforeWalletFilter(t *testing.T) { db, sqlMock, err := sqlmock.New() if err != nil { diff --git a/server/admin/internal/modules/payment/routes.go b/server/admin/internal/modules/payment/routes.go index 43a44d86..c1638e6f 100644 --- a/server/admin/internal/modules/payment/routes.go +++ b/server/admin/internal/modules/payment/routes.go @@ -6,6 +6,14 @@ import ( "github.com/gin-gonic/gin" ) +var moneyScopeReadPermissions = []string{ + financeViewPermission, + "finance-withdrawal:view", + "finance-withdrawal:audit", + "operations-withdrawal:view", + "operations-withdrawal:audit", +} + func RegisterRoutes(workspaceProtected *gin.RouterGroup, appProtected *gin.RouterGroup, h *Handler) { if h == nil { return @@ -19,7 +27,8 @@ func RegisterRoutes(workspaceProtected *gin.RouterGroup, appProtected *gin.Route workspaceProtected.POST("/admin/payment/recharge-bills/google-paid/refresh", middleware.RequirePermission("payment-bill:refresh"), h.RefreshGoogleRechargePaidDetails) workspaceProtected.GET("/admin/payment/recharge-regions", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillRegions) workspaceProtected.GET("/admin/payment/recharge-apps", middleware.RequirePermission("payment-bill:view"), h.ListRechargeBillApps) - workspaceProtected.GET("/admin/finance/scope", middleware.RequirePermission(financeViewPermission), h.GetMoneyScope) + // 用户提现的运营/财务页共用 MoneyAccess App 目录;这里只放开范围读取,不会赋予任何支付或审核写权限。 + workspaceProtected.GET("/admin/finance/scope", middleware.RequireAnyPermission(moneyScopeReadPermissions...), h.GetMoneyScope) workspaceProtected.GET("/admin/money/scope", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyScope) workspaceProtected.GET("/admin/money/performance", middleware.RequireAnyPermission(financeViewPermission, legacyMoneyViewPermission), h.GetMoneyPerformance) workspaceProtected.GET("/admin/payment/temporary-links", middleware.RequirePermission("payment-temporary-link:view"), h.ListTemporaryPaymentLinks) diff --git a/server/admin/internal/repository/app_scope_repository_test.go b/server/admin/internal/repository/app_scope_repository_test.go index b83c10bf..b2347b12 100644 --- a/server/admin/internal/repository/app_scope_repository_test.go +++ b/server/admin/internal/repository/app_scope_repository_test.go @@ -66,3 +66,31 @@ func TestReplaceUserAppScopesRejectsEmptySelected(t *testing.T) { t.Fatalf("sql expectations: %v", err) } } + +func TestCurrentAuthorizationForUserReadsAndDeduplicatesLivePermissions(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + mock.ExpectQuery("(?s)SELECT admin_users\\.username,.*FROM admin_users.*WHERE admin_users\\.id = \\?.*ORDER BY admin_permissions\\.code ASC"). + WithArgs(uint(7)). + WillReturnRows(sqlmock.NewRows([]string{"username", "status", "permission_code"}). + AddRow("ops", model.UserStatusActive, "operations-withdrawal:audit"). + AddRow("ops", model.UserStatusActive, "operations-withdrawal:audit"). + AddRow("ops", model.UserStatusActive, "operations-withdrawal:view")) + + authorization, err := store.CurrentAuthorizationForUser(7) + if err != nil { + t.Fatalf("resolve current authorization: %v", err) + } + if authorization.Username != "ops" || authorization.Status != model.UserStatusActive { + t.Fatalf("unexpected current user state: %+v", authorization) + } + if len(authorization.Permissions) != 2 || + authorization.Permissions[0] != "operations-withdrawal:audit" || + authorization.Permissions[1] != "operations-withdrawal:view" { + t.Fatalf("unexpected live permissions: %+v", authorization.Permissions) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations: %v", err) + } +} diff --git a/server/admin/internal/repository/auth_repository.go b/server/admin/internal/repository/auth_repository.go index 3f8a9598..76b573e3 100644 --- a/server/admin/internal/repository/auth_repository.go +++ b/server/admin/internal/repository/auth_repository.go @@ -1,12 +1,28 @@ package repository import ( - "hyapp-admin-server/internal/model" "time" + "hyapp-admin-server/internal/model" + "gorm.io/gorm" ) +// UserAuthorization is the current server-side authorization state used for +// every protected request. JWT claims identify the session, but never own RBAC +// state because role changes must take effect before a long-lived token expires. +type UserAuthorization struct { + Username string + Status string + Permissions []string +} + +type userAuthorizationRow struct { + Username string + Status string + PermissionCode *string +} + func (s *Store) FindUserByUsername(username string) (*model.User, error) { var user model.User err := s.db.Preload("Roles.Permissions").Preload("TeamRecord").Where("username = ?", username).First(&user).Error @@ -27,6 +43,54 @@ func (s *Store) FindUserByID(id uint) (*model.User, error) { return &user, nil } +func (s *Store) CurrentAuthorizationForUser(userID uint) (UserAuthorization, error) { + if userID == 0 { + return UserAuthorization{}, gorm.ErrRecordNotFound + } + + var rows []userAuthorizationRow + // The query starts from admin_users.id and follows the two composite-primary-key + // relation tables. It therefore resolves live permissions in one indexed query + // without loading the full user/role models on every protected HTTP request. + result := s.db.Raw(` + SELECT admin_users.username, + admin_users.status, + admin_permissions.code AS permission_code + FROM admin_users + LEFT JOIN admin_user_roles + ON admin_user_roles.user_id = admin_users.id + LEFT JOIN admin_role_permissions + ON admin_role_permissions.role_id = admin_user_roles.role_id + LEFT JOIN admin_permissions + ON admin_permissions.id = admin_role_permissions.permission_id + WHERE admin_users.id = ? + ORDER BY admin_permissions.code ASC + `, userID).Scan(&rows) + if result.Error != nil { + return UserAuthorization{}, result.Error + } + if len(rows) == 0 { + return UserAuthorization{}, gorm.ErrRecordNotFound + } + + authorization := UserAuthorization{ + Username: rows[0].Username, + Status: rows[0].Status, + } + seen := make(map[string]struct{}, len(rows)) + for _, row := range rows { + if row.PermissionCode == nil || *row.PermissionCode == "" { + continue + } + if _, ok := seen[*row.PermissionCode]; ok { + continue + } + seen[*row.PermissionCode] = struct{}{} + authorization.Permissions = append(authorization.Permissions, *row.PermissionCode) + } + return authorization, nil +} + func PermissionsForUser(user model.User) []string { set := map[string]struct{}{} for _, role := range user.Roles { diff --git a/server/admin/internal/repository/money_scope_repository.go b/server/admin/internal/repository/money_scope_repository.go index 8ab9c5d1..26b0c6c3 100644 --- a/server/admin/internal/repository/money_scope_repository.go +++ b/server/admin/internal/repository/money_scope_repository.go @@ -2,6 +2,7 @@ package repository import ( "errors" + "sort" "strconv" "strings" "time" @@ -64,9 +65,24 @@ func (access MoneyAccess) AppCodes() []string { for appCode := range set { out = append(out, appCode) } + sort.Strings(out) return out } +// AllowsApp 用于没有 region 维度的资金事实(例如用户提现):任一归属该 App 的财务范围都可查看和审核该 App 申请。 +func (access MoneyAccess) AllowsApp(appCode string) bool { + if access.All { + return true + } + appCode = appctx.Normalize(appCode) + for _, allowed := range access.AppCodes() { + if allowed == appCode { + return true + } + } + return false +} + func (s *Store) MoneyAccessForUser(userID uint) (MoneyAccess, error) { if userID == 0 { return MoneyAccess{All: true}, nil diff --git a/server/admin/internal/repository/money_scope_repository_test.go b/server/admin/internal/repository/money_scope_repository_test.go index 149c05e3..4318c7b1 100644 --- a/server/admin/internal/repository/money_scope_repository_test.go +++ b/server/admin/internal/repository/money_scope_repository_test.go @@ -135,3 +135,17 @@ func TestMoneyAccessAllowsRoundedRegion(t *testing.T) { t.Fatalf("unrelated region must stay denied") } } + +func TestMoneyAccessAllowsAppIgnoresRegionButKeepsAppBoundary(t *testing.T) { + t.Parallel() + access := MoneyAccess{UserID: 7, Scopes: []model.UserMoneyScope{ + {UserID: 7, AppCode: "lalu", RegionID: 9}, + {UserID: 7, AppCode: "fami", RegionID: 0}, + }} + if !access.AllowsApp("LALU") || !access.AllowsApp("fami") || access.AllowsApp("huwaa") { + t.Fatalf("unexpected app-level money access: %+v", access) + } + if got := access.AppCodes(); len(got) != 2 || got[0] != "fami" || got[1] != "lalu" { + t.Fatalf("money scope app codes must be stable and sorted: %v", got) + } +} diff --git a/server/admin/internal/repository/role_permission_matrix.go b/server/admin/internal/repository/role_permission_matrix.go index b20560fb..bc02bf2c 100644 --- a/server/admin/internal/repository/role_permission_matrix.go +++ b/server/admin/internal/repository/role_permission_matrix.go @@ -32,8 +32,8 @@ var ( "finance-order:coin-seller-recharge:verify", "finance-order:coin-seller-recharge:grant", "finance-order:usdt-address:update", - "finance-withdrawal:view", - "finance-withdrawal:audit", + "operations-withdrawal:view", + "operations-withdrawal:audit", } workbenchFinance = []string{ "finance:view", diff --git a/server/admin/internal/repository/seed.go b/server/admin/internal/repository/seed.go index c5f68b68..c360f1fd 100644 --- a/server/admin/internal/repository/seed.go +++ b/server/admin/internal/repository/seed.go @@ -148,8 +148,10 @@ var defaultPermissions = []model.Permission{ {Name: "币商充值订单校验", Code: "finance-order:coin-seller-recharge:verify", Kind: "button"}, {Name: "币商充值订单发放", Code: "finance-order:coin-seller-recharge:grant", Kind: "button"}, {Name: "USDT 收款地址编辑", Code: "finance-order:usdt-address:update", Kind: "button", Description: "允许按 APP 新增或修改 USDT 收款地址"}, - {Name: "用户提现申请查看", Code: "finance-withdrawal:view", Kind: "menu"}, - {Name: "用户提现申请审核", Code: "finance-withdrawal:audit", Kind: "button"}, + {Name: "用户提现财务审核", Code: "finance-withdrawal:view", Kind: "menu"}, + {Name: "用户提现财务审核操作", Code: "finance-withdrawal:audit", Kind: "button"}, + {Name: "用户提现运营审核", Code: "operations-withdrawal:view", Kind: "menu"}, + {Name: "用户提现运营审核操作", Code: "operations-withdrawal:audit", Kind: "button"}, {Name: "内购配置查看", Code: "payment-product:view", Kind: "menu"}, {Name: "内购配置创建", Code: "payment-product:create", Kind: "button"}, {Name: "内购配置更新", Code: "payment-product:update", Kind: "button"}, diff --git a/server/admin/internal/repository/seed_test.go b/server/admin/internal/repository/seed_test.go index 25d2b888..86229158 100644 --- a/server/admin/internal/repository/seed_test.go +++ b/server/admin/internal/repository/seed_test.go @@ -25,6 +25,9 @@ func TestDefaultPermissionSeedUsesFinancePermissions(t *testing.T) { "payment-temporary-link:create", "finance:view", "finance-withdrawal:view", + "finance-withdrawal:audit", + "operations-withdrawal:view", + "operations-withdrawal:audit", "host-withdrawal:view", } { if _, ok := permissions[code]; !ok { @@ -53,6 +56,8 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) { for _, code := range []string{ "payment-bill:export", "host-withdrawal:view", + "operations-withdrawal:view", + "operations-withdrawal:audit", } { if _, ok := opsPermissions[code]; !ok { t.Fatalf("ops-admin default permissions missing %s", code) @@ -61,6 +66,8 @@ func TestDefaultRoleFinancePermissionTemplates(t *testing.T) { for _, code := range []string{ "finance:view", + "finance-withdrawal:view", + "finance-withdrawal:audit", "payment-temporary-link:create", "payment-third-party:update-rate", "payment-third-party:sync-rates", @@ -126,12 +133,12 @@ func TestManagedDefaultRolePermissionMatrix(t *testing.T) { func TestManagedDefaultRoleModuleBoundaries(t *testing.T) { assertRolePermissions(t, roleCodeOperationsLead, - []string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update", "external-admin-user:view", "external-admin-user:status"}, - []string{"app-version:view", "finance:view", "payment-third-party:update-rate", "role:view", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"}, + []string{"app-config:update", "payment-third-party:sync-methods", "game-catalog:update", "country:update", "external-admin-user:view", "external-admin-user:status", "operations-withdrawal:view", "operations-withdrawal:audit"}, + []string{"app-version:view", "finance:view", "finance-withdrawal:view", "finance-withdrawal:audit", "payment-third-party:update-rate", "role:view", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"}, ) assertRolePermissions(t, roleCodeOperationsSpecialist, - []string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export", "external-admin-user:view", "external-admin-user:status"}, - []string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"}, + []string{"room:update", "game-robot:update", "room-rps-config:update", "payment-bill:export", "external-admin-user:view", "external-admin-user:status", "operations-withdrawal:view", "operations-withdrawal:audit"}, + []string{"activity:update", "daily-task:update", "game-catalog:update", "self-game:update", "coin-adjustment:create", "finance-withdrawal:view", "finance-withdrawal:audit", "external-admin-user:create", "external-admin-user:reset-password", "external-admin-user:permissions"}, ) assertRolePermissions(t, roleCodeProductLead, []string{"app-version:update", "daily-task:update", "game-catalog:update", "resource:update"}, @@ -149,7 +156,7 @@ func TestManagedDefaultRoleModuleBoundaries(t *testing.T) { } assertRolePermissions(t, roleCodeFinanceLead, []string{"finance:view", "payment-bill:view", "payment-bill:export", "finance-withdrawal:audit", "room-rps-order:view"}, - []string{"game-catalog:view", "room-rps-config:view", "app-user:view", "daily-task:view"}, + []string{"operations-withdrawal:view", "operations-withdrawal:audit", "game-catalog:view", "room-rps-config:view", "app-user:view", "daily-task:view"}, ) } @@ -180,6 +187,33 @@ func TestRolePermissionMigrationMatchesManagedMatrix(t *testing.T) { } assertMigrationPermissionCodes(t, roleCodeFinanceLead, financeMatches[1]) assertMigrationPermissionCodes(t, roleCodeFinanceSpecialist, financeMatches[1]) + + correction, err := os.ReadFile("../../migrations/102_user_withdrawal_operations_review.sql") + if err != nil { + t.Fatalf("read withdrawal review correction migration: %v", err) + } + correctionSQL := string(correction) + for _, token := range []string{ + "schema_migration_checkpoints", + "COALESCE(MAX(id), 0)", + "DEFAULT ''pending''", + "application.id <= checkpoint.checkpoint_value", + "application.status IN ('approved', 'rejected')", + "application.operations_status = 'pending'", + "ALGORITHM=INPLACE, LOCK=NONE", + "DELETE role_permission", + "'ops-admin', 'operations-specialist'", + "'finance-withdrawal:view', 'finance-withdrawal:audit'", + "'platform-admin', 'ops-admin', 'operations-specialist'", + "'operations-withdrawal:view', 'operations-withdrawal:audit'", + } { + if !strings.Contains(correctionSQL, token) { + t.Fatalf("withdrawal review correction migration missing %s", token) + } + } + if strings.Contains(correctionSQL, "DEFAULT ''skipped''") || strings.Contains(correctionSQL, "DEFAULT 'skipped'") { + t.Fatal("withdrawal operations database default must be pending so old gateway inserts cannot bypass initial review") + } } func TestGiftRecordPermissionMigrationExtendsOperationsReadRoles(t *testing.T) { @@ -302,7 +336,7 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin actual = append(actual, match[1]) } expected := append([]string(nil), defaultRolePermissionCodes(roleCode)...) - // 093 已在生产执行,不能为了新增页面回写旧迁移;095/096 以增量方式补充页面权限。 + // 093 已在生产执行,不能为了新增页面回写旧迁移;095/096/102 以增量方式补充或收紧页面权限。 // 这里仍校验 093 的原始精确矩阵,新权限由上面的专项迁移测试锁定。 expected = stringSetDifference(expected, []string{ "gift-record:view", @@ -310,6 +344,11 @@ func assertMigrationPermissionCodes(t *testing.T, roleCode string, sqlList strin "activity-template:delete", "activity-template:data", "activity-template:export", "activity-template:retry", "external-admin-user:view", "external-admin-user:create", "external-admin-user:status", "external-admin-user:reset-password", }) + if roleCode == roleCodeOperationsLead || roleCode == roleCodeOperationsSpecialist { + // 093 仍记录旧的财务提现权限;102 才把固定运营岗位切换到独立运营审核权限。 + expected = stringSetDifference(expected, []string{"operations-withdrawal:view", "operations-withdrawal:audit"}) + expected = append(expected, "finance-withdrawal:view", "finance-withdrawal:audit") + } sort.Strings(actual) sort.Strings(expected) if !reflect.DeepEqual(actual, expected) { diff --git a/server/admin/internal/repository/withdrawal_application_repository.go b/server/admin/internal/repository/withdrawal_application_repository.go index dcf63654..0b49fc31 100644 --- a/server/admin/internal/repository/withdrawal_application_repository.go +++ b/server/admin/internal/repository/withdrawal_application_repository.go @@ -10,16 +10,30 @@ import ( "gorm.io/gorm/clause" ) -var ErrWithdrawalApplicationAlreadyAudited = errors.New("withdrawal application already audited") +var ( + ErrWithdrawalApplicationAlreadyAudited = errors.New("withdrawal application already audited") + ErrWithdrawalApplicationStageNotReviewable = errors.New("withdrawal application is not ready for this review stage") +) + +type WithdrawalApplicationReviewStage string + +const ( + WithdrawalApplicationReviewStageFinance WithdrawalApplicationReviewStage = "finance" + WithdrawalApplicationReviewStageOperations WithdrawalApplicationReviewStage = "operations" +) type WithdrawalApplicationListOptions struct { - Page int - PageSize int - AppCode string - Keyword string + Page int + PageSize int + AppCode string + AllowedAppCodes []string + RestrictAppCodes bool + Keyword string + Stage WithdrawalApplicationReviewStage } type WithdrawalApplicationAuditInput struct { + Stage WithdrawalApplicationReviewStage Decision string ApproverUserID uint ApproverName string @@ -30,6 +44,19 @@ type WithdrawalApplicationAuditInput struct { ApprovedAtMS int64 } +// WithdrawalApplicationAuditEffect 是持行锁期间完成的钱包命令结果。 +// 运营通过没有资金副作用,因此允许返回空 command/transaction;任一拒绝和财务通过都会保存可追溯的账务事实。 +type WithdrawalApplicationAuditEffect struct { + CommandID string + TransactionID string +} + +type WithdrawalApplicationAuditAction func(application model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error) + +// WithdrawalApplicationClaimValidator 在拒绝 claim 提交前校验钱包释放所需的冻结快照。 +// 校验必须留在持行锁的短事务内,避免坏数据先进入 rejecting 后再永久卡住人工流程。 +type WithdrawalApplicationClaimValidator func(application model.UserWithdrawalApplication) error + func (s *Store) GetWithdrawalApplication(id uint) (*model.UserWithdrawalApplication, error) { var application model.UserWithdrawalApplication if err := s.db.First(&application, id).Error; err != nil { @@ -56,7 +83,7 @@ func (s *Store) ListWithdrawalApplications(options WithdrawalApplicationListOpti return nil, 0, err } var applications []model.UserWithdrawalApplication - // 提现申请是财务审核场景,默认按最新申请优先展示;id 作为同毫秒写入时的稳定次序,避免分页翻页时记录抖动。 + // 提现申请用于运营初审和财务终审,默认按最新申请优先展示;id 作为同毫秒写入时的稳定次序,避免分页翻页时记录抖动。 err := query. Order("created_at_ms DESC, id DESC"). Limit(pageSize). @@ -66,20 +93,168 @@ func (s *Store) ListWithdrawalApplications(options WithdrawalApplicationListOpti } func (s *Store) AuditWithdrawalApplication(id uint, input WithdrawalApplicationAuditInput) (*model.UserWithdrawalApplication, error) { - return s.AuditWithdrawalApplicationForApp("", id, input) + return s.ReviewWithdrawalApplicationForApp("", id, input, nil) } func (s *Store) AuditWithdrawalApplicationForApp(appCode string, id uint, input WithdrawalApplicationAuditInput) (*model.UserWithdrawalApplication, error) { + return s.ReviewWithdrawalApplicationForApp(appCode, id, input, nil) +} + +// ClaimWithdrawalOperationsRejectionForApp 先独立提交 rejecting,再允许调用 wallet/inbox。 +// 这个持久门禁覆盖“钱包释放成功、但通知或 admin 最终提交失败”的窗口:后续运营通过和财务审核都会被 rejecting 拒绝, +// 只有相同阶段 command 的拒绝重试可以继续,且审核人、拒绝原因以第一次 claim 的快照为准。 +func (s *Store) ClaimWithdrawalOperationsRejectionForApp(appCode string, id uint, input WithdrawalApplicationAuditInput, validate WithdrawalApplicationClaimValidator) (*model.UserWithdrawalApplication, error) { if id == 0 { return nil, errors.New("withdrawal application id is required") } - if input.Decision != model.WithdrawalApplicationStatusApproved && input.Decision != model.WithdrawalApplicationStatusRejected { - return nil, errors.New("withdrawal application decision is invalid") + if input.Stage != WithdrawalApplicationReviewStageOperations || input.Decision != model.WithdrawalApplicationStatusRejected { + return nil, errors.New("withdrawal application rejection claim is invalid") + } + input.AuditCommandID = strings.TrimSpace(input.AuditCommandID) + if input.AuditCommandID == "" || input.ApproverUserID == 0 || strings.TrimSpace(input.AuditRemark) == "" || input.ApprovedAtMS <= 0 { + return nil, errors.New("withdrawal application rejection claim is incomplete") } var application model.UserWithdrawalApplication err := s.db.Transaction(func(tx *gorm.DB) error { - // 审核只能从 pending 进入终态;行锁阻止两个财务同时覆盖同一张提现单的执行结果。 + query := tx.Clauses(clause.Locking{Strength: "UPDATE"}) + if strings.TrimSpace(appCode) != "" { + query = query.Where("app_code = ?", strings.TrimSpace(appCode)) + } + if err := query.First(&application, id).Error; err != nil { + return err + } + + operationsStatus := strings.TrimSpace(application.OperationsStatus) + if application.Status == model.WithdrawalApplicationStatusRejected && operationsStatus == model.WithdrawalOperationsStatusRejected { + // 已完成拒绝是终态幂等响应;不能再次触发 wallet 或 notice。 + return nil + } + if application.Status != model.WithdrawalApplicationStatusPending { + return ErrWithdrawalApplicationAlreadyAudited + } + switch operationsStatus { + case model.WithdrawalOperationsStatusPending: + if validate != nil { + if err := validate(application); err != nil { + return err + } + } + updates := map[string]any{ + "operations_status": model.WithdrawalOperationsStatusRejecting, + "operations_reviewer_user_id": input.ApproverUserID, + "operations_reviewer_name": strings.TrimSpace(input.ApproverName), + "operations_audit_remark": strings.TrimSpace(input.AuditRemark), + "operations_audit_command_id": input.AuditCommandID, + "operations_reviewed_at_ms": input.ApprovedAtMS, + "updated_at_ms": input.ApprovedAtMS, + } + if err := tx.Model(&application).Updates(updates).Error; err != nil { + return err + } + application.OperationsStatus = model.WithdrawalOperationsStatusRejecting + application.OperationsReviewerUserID = &input.ApproverUserID + application.OperationsReviewerName = strings.TrimSpace(input.ApproverName) + application.OperationsAuditRemark = strings.TrimSpace(input.AuditRemark) + application.OperationsAuditCommandID = input.AuditCommandID + application.OperationsReviewedAtMS = &input.ApprovedAtMS + application.UpdatedAtMS = input.ApprovedAtMS + return nil + case model.WithdrawalOperationsStatusRejecting: + // 重试不能改写第一次审核的上下文,否则钱包 metadata、通知原因和后台审计记录会彼此矛盾。 + if strings.TrimSpace(application.OperationsAuditCommandID) != input.AuditCommandID { + return ErrWithdrawalApplicationStageNotReviewable + } + if validate != nil { + return validate(application) + } + return nil + default: + return ErrWithdrawalApplicationStageNotReviewable + } + }) + if err != nil { + return nil, err + } + return &application, nil +} + +// FinalizeWithdrawalOperationsRejectionForApp 只把已持久 claim 的 rejecting 收敛到双重 rejected 终态。 +// wallet transaction id 非空且 stage command 必须与 claim 一致,防止其他流程借最终更新覆盖资金事实。 +func (s *Store) FinalizeWithdrawalOperationsRejectionForApp(appCode string, id uint, commandID string, transactionID string, completedAtMS int64) (*model.UserWithdrawalApplication, error) { + commandID = strings.TrimSpace(commandID) + transactionID = strings.TrimSpace(transactionID) + if id == 0 || commandID == "" || transactionID == "" || completedAtMS <= 0 { + return nil, errors.New("withdrawal application rejection finalization is incomplete") + } + + var application model.UserWithdrawalApplication + err := s.db.Transaction(func(tx *gorm.DB) error { + query := tx.Clauses(clause.Locking{Strength: "UPDATE"}) + if strings.TrimSpace(appCode) != "" { + query = query.Where("app_code = ?", strings.TrimSpace(appCode)) + } + if err := query.First(&application, id).Error; err != nil { + return err + } + + operationsStatus := strings.TrimSpace(application.OperationsStatus) + if application.Status == model.WithdrawalApplicationStatusRejected && operationsStatus == model.WithdrawalOperationsStatusRejected { + if strings.TrimSpace(application.OperationsAuditCommandID) != commandID || strings.TrimSpace(application.OperationsAuditTransactionID) != transactionID { + return ErrWithdrawalApplicationStageNotReviewable + } + return nil + } + if application.Status != model.WithdrawalApplicationStatusPending || operationsStatus != model.WithdrawalOperationsStatusRejecting || strings.TrimSpace(application.OperationsAuditCommandID) != commandID { + return ErrWithdrawalApplicationStageNotReviewable + } + updates := map[string]any{ + "status": model.WithdrawalApplicationStatusRejected, + "operations_status": model.WithdrawalOperationsStatusRejected, + "operations_audit_transaction_id": transactionID, + "updated_at_ms": completedAtMS, + } + if err := tx.Model(&application).Updates(updates).Error; err != nil { + return err + } + application.Status = model.WithdrawalApplicationStatusRejected + application.OperationsStatus = model.WithdrawalOperationsStatusRejected + application.OperationsAuditTransactionID = transactionID + application.UpdatedAtMS = completedAtMS + return nil + }) + if err != nil { + return nil, err + } + return &application, nil +} + +// ReviewWithdrawalApplicationForApp 把普通阶段校验、跨服务资金/通知动作和本地状态更新收敛在同一把申请行锁内。 +// 提现审核是低 QPS、高价值资金边界;这里有意在钱包和 inbox 网络调用期间持有行锁,让运营通过及财务通过/拒绝严格串行。 +// 外部动作必须使用阶段固定的幂等 command/event id:事务回滚后重试不会重复扣款或通知,而相反决策会以同一 command id 的请求哈希冲突失败。 +// 运营拒绝不能走本函数:它必须先用 Claim... 独立提交 rejecting,再在外部动作成功后用 Finalize... 收敛终态。 +func (s *Store) ReviewWithdrawalApplicationForApp(appCode string, id uint, input WithdrawalApplicationAuditInput, action WithdrawalApplicationAuditAction) (*model.UserWithdrawalApplication, error) { + if id == 0 { + return nil, errors.New("withdrawal application id is required") + } + if input.Stage == "" { + // 保留 repository 旧调用者的财务审核语义;新业务入口必须显式传阶段。 + input.Stage = WithdrawalApplicationReviewStageFinance + } + if input.Stage != WithdrawalApplicationReviewStageFinance && input.Stage != WithdrawalApplicationReviewStageOperations { + return nil, errors.New("withdrawal application review stage is invalid") + } + if input.Decision != model.WithdrawalApplicationStatusApproved && input.Decision != model.WithdrawalApplicationStatusRejected { + return nil, errors.New("withdrawal application decision is invalid") + } + if input.Stage == WithdrawalApplicationReviewStageOperations && input.Decision == model.WithdrawalApplicationStatusRejected { + // 运营拒绝必须先提交 rejecting claim;禁止旧调用者继续把钱包释放和本地终态放在一个可整体回滚的事务里。 + return nil, errors.New("operations withdrawal rejection must use durable claim") + } + + var application model.UserWithdrawalApplication + err := s.db.Transaction(func(tx *gorm.DB) error { + // 行锁保护当前阶段门禁:同一申请的运营通过/拒绝与后续财务通过/拒绝按提交顺序串行,不能跨阶段覆盖审核结果。 query := tx.Clauses(clause.Locking{Strength: "UPDATE"}) if strings.TrimSpace(appCode) != "" { // 后台按当前 App 上下文审核,不能只凭全局自增 ID 跨 App 锁定和审核提现单。 @@ -88,28 +263,107 @@ func (s *Store) AuditWithdrawalApplicationForApp(appCode string, id uint, input if err := query.First(&application, id).Error; err != nil { return err } - if application.Status != model.WithdrawalApplicationStatusPending { - return ErrWithdrawalApplicationAlreadyAudited + if reviewErr := withdrawalApplicationReviewError(application, input.Stage); reviewErr != nil { + return reviewErr } - return tx.Model(&application).Updates(map[string]any{ - "status": input.Decision, - "approver_user_id": input.ApproverUserID, - "approver_name": strings.TrimSpace(input.ApproverName), - "audit_remark": strings.TrimSpace(input.AuditRemark), - "audit_image_url": strings.TrimSpace(input.AuditImageURL), - "audit_command_id": strings.TrimSpace(input.AuditCommandID), - "audit_transaction_id": strings.TrimSpace(input.AuditTransactionID), - "approved_at_ms": input.ApprovedAtMS, - "updated_at_ms": input.ApprovedAtMS, - }).Error + effect := WithdrawalApplicationAuditEffect{ + CommandID: strings.TrimSpace(input.AuditCommandID), + TransactionID: strings.TrimSpace(input.AuditTransactionID), + } + if action != nil { + var err error + effect, err = action(application) + if err != nil { + return err + } + } + updates := withdrawalApplicationReviewUpdates(input, effect) + if err := tx.Model(&application).Updates(updates).Error; err != nil { + return err + } + applyWithdrawalApplicationReview(&application, input, effect) + return nil }) if err != nil { return nil, err } - if strings.TrimSpace(appCode) != "" { - return s.GetWithdrawalApplicationForApp(appCode, id) + return &application, nil +} + +func withdrawalApplicationReviewError(application model.UserWithdrawalApplication, stage WithdrawalApplicationReviewStage) error { + if application.Status != model.WithdrawalApplicationStatusPending { + return ErrWithdrawalApplicationAlreadyAudited } - return s.GetWithdrawalApplication(id) + operationsStatus := strings.TrimSpace(application.OperationsStatus) + if operationsStatus == "" { + // 数据库迁移只把历史终态行填成 skipped;空值只兼容迁移前构造的模型和旧测试夹具。 + operationsStatus = model.WithdrawalOperationsStatusSkipped + } + switch stage { + case WithdrawalApplicationReviewStageOperations: + if operationsStatus == model.WithdrawalOperationsStatusPending { + return nil + } + case WithdrawalApplicationReviewStageFinance: + if operationsStatus == model.WithdrawalOperationsStatusApproved || operationsStatus == model.WithdrawalOperationsStatusSkipped { + return nil + } + } + return ErrWithdrawalApplicationStageNotReviewable +} + +func withdrawalApplicationReviewUpdates(input WithdrawalApplicationAuditInput, effect WithdrawalApplicationAuditEffect) map[string]any { + updates := map[string]any{"updated_at_ms": input.ApprovedAtMS} + if input.Stage == WithdrawalApplicationReviewStageOperations { + updates["operations_status"] = input.Decision + updates["operations_reviewer_user_id"] = input.ApproverUserID + updates["operations_reviewer_name"] = strings.TrimSpace(input.ApproverName) + updates["operations_audit_remark"] = strings.TrimSpace(input.AuditRemark) + updates["operations_audit_command_id"] = strings.TrimSpace(effect.CommandID) + updates["operations_audit_transaction_id"] = strings.TrimSpace(effect.TransactionID) + updates["operations_reviewed_at_ms"] = input.ApprovedAtMS + if input.Decision == model.WithdrawalApplicationStatusRejected { + updates["status"] = model.WithdrawalApplicationStatusRejected + } + return updates + } + updates["status"] = input.Decision + updates["approver_user_id"] = input.ApproverUserID + updates["approver_name"] = strings.TrimSpace(input.ApproverName) + updates["audit_remark"] = strings.TrimSpace(input.AuditRemark) + updates["audit_image_url"] = strings.TrimSpace(input.AuditImageURL) + updates["audit_command_id"] = strings.TrimSpace(effect.CommandID) + updates["audit_transaction_id"] = strings.TrimSpace(effect.TransactionID) + updates["approved_at_ms"] = input.ApprovedAtMS + return updates +} + +func applyWithdrawalApplicationReview(application *model.UserWithdrawalApplication, input WithdrawalApplicationAuditInput, effect WithdrawalApplicationAuditEffect) { + if application == nil { + return + } + application.UpdatedAtMS = input.ApprovedAtMS + if input.Stage == WithdrawalApplicationReviewStageOperations { + application.OperationsStatus = input.Decision + application.OperationsReviewerUserID = &input.ApproverUserID + application.OperationsReviewerName = strings.TrimSpace(input.ApproverName) + application.OperationsAuditRemark = strings.TrimSpace(input.AuditRemark) + application.OperationsAuditCommandID = strings.TrimSpace(effect.CommandID) + application.OperationsAuditTransactionID = strings.TrimSpace(effect.TransactionID) + application.OperationsReviewedAtMS = &input.ApprovedAtMS + if input.Decision == model.WithdrawalApplicationStatusRejected { + application.Status = model.WithdrawalApplicationStatusRejected + } + return + } + application.Status = input.Decision + application.ApproverUserID = &input.ApproverUserID + application.ApproverName = strings.TrimSpace(input.ApproverName) + application.AuditRemark = strings.TrimSpace(input.AuditRemark) + application.AuditImageURL = strings.TrimSpace(input.AuditImageURL) + application.AuditCommandID = strings.TrimSpace(effect.CommandID) + application.AuditTransactionID = strings.TrimSpace(effect.TransactionID) + application.ApprovedAtMS = &input.ApprovedAtMS } // ApprovedWithdrawalStats 按审批通过时间聚合用户提现申请的 USDT 金额。 @@ -147,11 +401,26 @@ func (s *Store) ApprovedWithdrawalStats(appCode string, startAtMS int64, endAtMS func applyWithdrawalApplicationFilters(query *gorm.DB, options WithdrawalApplicationListOptions) *gorm.DB { if appCode := strings.TrimSpace(options.AppCode); appCode != "" { query = query.Where("app_code = ?", appCode) + } else if options.RestrictAppCodes { + if len(options.AllowedAppCodes) == 0 { + // 有财务权限但未授予任何资金数据范围时必须返回空集,不能把空 IN 退化成全 App 查询。 + query = query.Where("1 = 0") + } else { + query = query.Where("app_code IN ?", options.AllowedAppCodes) + } } if keyword := strings.TrimSpace(options.Keyword); keyword != "" { like := "%" + keyword + "%" - // 关键词面向财务排查,覆盖用户、收款方式、收款地址和审批人;不做金额模糊,避免 decimal 隐式转换拖慢常规查询。 - query = query.Where("user_id LIKE ? OR withdraw_method LIKE ? OR withdraw_address LIKE ? OR approver_name LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like) + // 关键词面向两级审核排查,覆盖用户、收款方式、收款地址和两阶段审核人;不做金额模糊,避免 decimal 隐式转换拖慢常规查询。 + query = query.Where("user_id LIKE ? OR withdraw_method LIKE ? OR withdraw_address LIKE ? OR approver_name LIKE ? OR operations_reviewer_name LIKE ? OR CAST(id AS CHAR) LIKE ?", like, like, like, like, like, like) + } + switch options.Stage { + case WithdrawalApplicationReviewStageFinance: + // skipped 是 102 迁移前的历史申请;它们保持原财务队列,不倒退补做运营初审。 + query = query.Where("operations_status IN ?", []string{model.WithdrawalOperationsStatusApproved, model.WithdrawalOperationsStatusSkipped}) + case WithdrawalApplicationReviewStageOperations: + // 运营页保留已审核历史,但永远不展示 skipped 旧单,避免把历史财务申请伪装成运营已审。 + query = query.Where("operations_status <> ?", model.WithdrawalOperationsStatusSkipped) } return query } diff --git a/server/admin/internal/repository/withdrawal_application_repository_test.go b/server/admin/internal/repository/withdrawal_application_repository_test.go index 80a2046f..830c93fe 100644 --- a/server/admin/internal/repository/withdrawal_application_repository_test.go +++ b/server/admin/internal/repository/withdrawal_application_repository_test.go @@ -1,21 +1,33 @@ package repository import ( + "database/sql" + "errors" + "fmt" + "os" + "strings" + "sync" "testing" + "time" + + "hyapp-admin-server/internal/model" "github.com/DATA-DOG/go-sqlmock" + mysqlDriver "github.com/go-sql-driver/mysql" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) -func TestListWithdrawalApplicationsFiltersAndSorts(t *testing.T) { +func TestListWithdrawalApplicationsFiltersFinanceStageAndSorts(t *testing.T) { store, mock, closeStore := newRepositorySQLMock(t) defer closeStore() like := "%TRC20%" - mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`"). - WithArgs("lalu", like, like, like, like, like). + mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*operations_status IN"). + WithArgs("lalu", like, like, like, like, like, like, model.WithdrawalOperationsStatusApproved, model.WithdrawalOperationsStatusSkipped). WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(2)) - mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`"). - WithArgs("lalu", like, like, like, like, like, 50, 50). + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*operations_status IN"). + WithArgs("lalu", like, like, like, like, like, like, model.WithdrawalOperationsStatusApproved, model.WithdrawalOperationsStatusSkipped, 50, 50). WillReturnRows(sqlmock.NewRows([]string{ "id", "app_code", @@ -24,18 +36,20 @@ func TestListWithdrawalApplicationsFiltersAndSorts(t *testing.T) { "withdraw_method", "withdraw_address", "status", + "operations_status", "approver_user_id", "approver_name", "approved_at_ms", "created_at_ms", "updated_at_ms", - }).AddRow(3, "lalu", "10003", "22.50", "USDT-TRC20", "addr-3", "approved", 9, "财务", int64(1700000200000), int64(1700000100000), int64(1700000200000))) + }).AddRow(3, "lalu", "10003", "22.50", "USDT-TRC20", "addr-3", "approved", "approved", 9, "财务", int64(1700000200000), int64(1700000100000), int64(1700000200000))) items, total, err := store.ListWithdrawalApplications(WithdrawalApplicationListOptions{ AppCode: " lalu ", Keyword: " TRC20 ", Page: 2, PageSize: 50, + Stage: WithdrawalApplicationReviewStageFinance, }) if err != nil { t.Fatalf("list withdrawal applications failed: %v", err) @@ -51,6 +65,312 @@ func TestListWithdrawalApplicationsFiltersAndSorts(t *testing.T) { } } +func TestListWithdrawalApplicationsOperationsStageExcludesLegacySkipped(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + mock.ExpectQuery("SELECT count\\(\\*\\) FROM `admin_user_withdrawal_applications`.*operations_status <> \\?"). + WithArgs("fami", model.WithdrawalOperationsStatusSkipped). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*operations_status <> \\?"). + WithArgs("fami", model.WithdrawalOperationsStatusSkipped, 20). + WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)) + + items, total, err := store.ListWithdrawalApplications(WithdrawalApplicationListOptions{ + AppCode: "fami", Page: 1, PageSize: 20, Stage: WithdrawalApplicationReviewStageOperations, + }) + if err != nil { + t.Fatalf("list operations withdrawal applications failed: %v", err) + } + if total != 1 || len(items) != 1 || items[0].OperationsStatus != model.WithdrawalOperationsStatusPending { + t.Fatalf("operations withdrawal list mismatch: total=%d items=%+v", total, items) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestReviewWithdrawalApplicationOperationsApprovalKeepsOverallPending(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("fami", uint(77), 1). + WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)) + mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + callbackCalls := 0 + item, err := store.ReviewWithdrawalApplicationForApp("fami", 77, WithdrawalApplicationAuditInput{ + Stage: WithdrawalApplicationReviewStageOperations, + Decision: model.WithdrawalApplicationStatusApproved, + ApproverUserID: 8, + ApproverName: "运营", + AuditRemark: "资料无误", + ApprovedAtMS: 1700000300000, + }, func(application model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error) { + callbackCalls++ + if application.OperationsStatus != model.WithdrawalOperationsStatusPending { + t.Fatalf("callback must observe operations pending row under lock: %+v", application) + } + return WithdrawalApplicationAuditEffect{}, nil + }) + if err != nil { + t.Fatalf("approve operations withdrawal failed: %v", err) + } + if callbackCalls != 1 || item.Status != model.WithdrawalApplicationStatusPending || item.OperationsStatus != model.WithdrawalOperationsStatusApproved || item.OperationsReviewerName != "运营" { + t.Fatalf("operations approval state mismatch: calls=%d item=%+v", callbackCalls, item) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestReviewWithdrawalApplicationRefusesOperationsRejectionWithoutDurableClaim(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + callbackCalled := false + _, err := store.ReviewWithdrawalApplicationForApp("fami", 77, WithdrawalApplicationAuditInput{ + Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected, + ApproverUserID: 8, ApprovedAtMS: 1700000300000, + }, func(model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error) { + callbackCalled = true + return WithdrawalApplicationAuditEffect{}, nil + }) + if err == nil || !strings.Contains(err.Error(), "durable claim") || callbackCalled { + t.Fatalf("operations rejection must use durable claim path: err=%v callback=%t", err, callbackCalled) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("operations rejection bypass touched database: %v", err) + } +} + +func TestClaimAndFinalizeWithdrawalOperationsRejectionPersistsDurableIntermediateState(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + // claim 必须先独立提交 rejecting 和固定审核上下文,外部 wallet/notice 此后失败也不能把行回滚到 pending。 + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("fami", uint(77), 1). + WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)) + mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + validatorCalls := 0 + claimed, err := store.ClaimWithdrawalOperationsRejectionForApp("fami", 77, WithdrawalApplicationAuditInput{ + Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected, + ApproverUserID: 8, ApproverName: "运营", AuditRemark: "资料不符", + AuditCommandID: "salary-withdrawal:77:operations", ApprovedAtMS: 1700000300000, + }, func(application model.UserWithdrawalApplication) error { + validatorCalls++ + if application.FreezeTransactionID != "tx-freeze" { + t.Fatalf("claim validator must observe locked freeze snapshot: %+v", application) + } + return nil + }) + if err != nil { + t.Fatalf("claim operations rejection failed: %v", err) + } + if validatorCalls != 1 || claimed.Status != model.WithdrawalApplicationStatusPending || claimed.OperationsStatus != model.WithdrawalOperationsStatusRejecting || claimed.OperationsAuditCommandID != "salary-withdrawal:77:operations" { + t.Fatalf("operations rejection claim mismatch: validator_calls=%d item=%+v", validatorCalls, claimed) + } + + // wallet 与 notice 成功后只允许相同 command 把 rejecting 收敛成双重 rejected,并保存实际 release transaction。 + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("fami", uint(77), 1). + WillReturnRows(withdrawalApplicationRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "运营", "资料不符")) + mock.ExpectExec("UPDATE `admin_user_withdrawal_applications` SET").WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectCommit() + + item, err := store.FinalizeWithdrawalOperationsRejectionForApp("fami", 77, "salary-withdrawal:77:operations", "release-tx", 1700000300001) + if err != nil { + t.Fatalf("finalize operations rejection failed: %v", err) + } + if item.Status != model.WithdrawalApplicationStatusRejected || item.OperationsStatus != model.WithdrawalOperationsStatusRejected || item.OperationsAuditTransactionID != "release-tx" || item.OperationsAuditRemark != "资料不符" { + t.Fatalf("operations rejection terminal state mismatch: %+v", item) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestClaimWithdrawalOperationsRejectionRetryPreservesFirstAuditContext(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("fami", uint(77), 1). + WillReturnRows(withdrawalApplicationRowsWithOperationsContext(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusRejecting, 8, "第一次运营", "第一次原因")) + mock.ExpectCommit() + + item, err := store.ClaimWithdrawalOperationsRejectionForApp("fami", 77, WithdrawalApplicationAuditInput{ + Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected, + ApproverUserID: 18, ApproverName: "重试运营", AuditRemark: "重试原因不能覆盖", + AuditCommandID: "salary-withdrawal:77:operations", ApprovedAtMS: 1700000400000, + }, nil) + if err != nil { + t.Fatalf("retry operations rejection claim failed: %v", err) + } + if item.OperationsReviewerUserID == nil || *item.OperationsReviewerUserID != 8 || item.OperationsReviewerName != "第一次运营" || item.OperationsAuditRemark != "第一次原因" { + t.Fatalf("retry claim overwrote first audit context: %+v", item) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestWithdrawalOperationsRejectionClaimSerializesConcurrentFirstAndRetryRealMySQL(t *testing.T) { + baseDSN := strings.TrimSpace(os.Getenv("WITHDRAWAL_REVIEW_MYSQL_TEST_DSN")) + if baseDSN == "" { + t.Skip("set WITHDRAWAL_REVIEW_MYSQL_TEST_DSN to run the real MySQL rejection-claim concurrency test") + } + db := newWithdrawalReviewMySQLTestDB(t, baseDSN) + if _, err := db.Exec(`CREATE TABLE admin_user_withdrawal_applications ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + app_code VARCHAR(32) NOT NULL, + user_id VARCHAR(64) NOT NULL, + salary_asset_type VARCHAR(64) NOT NULL DEFAULT '', + withdraw_amount DECIMAL(18,2) NOT NULL DEFAULT 0, + withdraw_amount_minor BIGINT NOT NULL DEFAULT 0, + freeze_transaction_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'pending', + operations_status VARCHAR(32) NOT NULL DEFAULT 'pending', + operations_reviewer_user_id BIGINT UNSIGNED NULL, + operations_reviewer_name VARCHAR(64) NOT NULL DEFAULT '', + operations_audit_remark TEXT NULL, + operations_audit_command_id VARCHAR(128) NOT NULL DEFAULT '', + operations_audit_transaction_id VARCHAR(128) NOT NULL DEFAULT '', + operations_reviewed_at_ms BIGINT NULL, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`); err != nil { + t.Fatalf("create withdrawal claim fixture table: %v", err) + } + if _, err := db.Exec(`INSERT INTO admin_user_withdrawal_applications + (id, app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms) + VALUES (77, 'lalu', '42001', 'HOST_SALARY_USD', 50.00, 5000, 'freeze-tx', 'pending', 'pending', 1700000000000, 1700000000000)`); err != nil { + t.Fatalf("seed withdrawal claim fixture: %v", err) + } + gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: db, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + t.Fatalf("open withdrawal claim gorm connection: %v", err) + } + store := New(gormDB) + + type claimResult struct { + item *model.UserWithdrawalApplication + err error + } + start := make(chan struct{}) + results := make(chan claimResult, 2) + var workers sync.WaitGroup + for index, reviewerID := range []uint{8, 18} { + index, reviewerID := index, reviewerID + workers.Add(1) + go func() { + defer workers.Done() + <-start + item, err := store.ClaimWithdrawalOperationsRejectionForApp("lalu", 77, WithdrawalApplicationAuditInput{ + Stage: WithdrawalApplicationReviewStageOperations, Decision: model.WithdrawalApplicationStatusRejected, + ApproverUserID: reviewerID, ApproverName: fmt.Sprintf("operator-%d", reviewerID), AuditRemark: fmt.Sprintf("reason-%d", reviewerID), + AuditCommandID: "salary-withdrawal:77:operations", ApprovedAtMS: int64(1700000300000 + index), + }, nil) + results <- claimResult{item: item, err: err} + }() + } + close(start) + workers.Wait() + close(results) + + var persistedReviewerID uint + for result := range results { + if result.err != nil { + t.Fatalf("concurrent rejection claim failed: %v", result.err) + } + if result.item == nil || result.item.OperationsStatus != model.WithdrawalOperationsStatusRejecting || result.item.OperationsReviewerUserID == nil { + t.Fatalf("concurrent rejection claim result mismatch: %+v", result.item) + } + if persistedReviewerID == 0 { + persistedReviewerID = *result.item.OperationsReviewerUserID + } else if persistedReviewerID != *result.item.OperationsReviewerUserID { + t.Fatalf("concurrent retry observed different claim owners: first=%d result=%+v", persistedReviewerID, result.item) + } + } + + // 两个请求都可能在外部幂等动作后并发 finalize;相同行锁和 command/transaction 校验必须让二者返回同一终态。 + finalizeResults := make(chan error, 2) + start = make(chan struct{}) + workers = sync.WaitGroup{} + for range 2 { + workers.Add(1) + go func() { + defer workers.Done() + <-start + _, err := store.FinalizeWithdrawalOperationsRejectionForApp("lalu", 77, "salary-withdrawal:77:operations", "release-tx", 1700000400000) + finalizeResults <- err + }() + } + close(start) + workers.Wait() + close(finalizeResults) + for err := range finalizeResults { + if err != nil { + t.Fatalf("concurrent rejection finalize failed: %v", err) + } + } + final, err := store.GetWithdrawalApplicationForApp("lalu", 77) + if err != nil { + t.Fatalf("read final rejection state: %v", err) + } + if final.Status != model.WithdrawalApplicationStatusRejected || final.OperationsStatus != model.WithdrawalOperationsStatusRejected || final.OperationsAuditTransactionID != "release-tx" || final.OperationsReviewerUserID == nil || *final.OperationsReviewerUserID != persistedReviewerID { + t.Fatalf("concurrent rejection terminal mismatch: %+v", final) + } +} + +func TestReviewWithdrawalApplicationFinanceRejectsOperationsPendingBeforeAction(t *testing.T) { + store, mock, closeStore := newRepositorySQLMock(t) + defer closeStore() + + mock.ExpectBegin() + mock.ExpectQuery("SELECT \\* FROM `admin_user_withdrawal_applications`.*FOR UPDATE"). + WithArgs("fami", uint(77), 1). + WillReturnRows(withdrawalApplicationRows(model.WithdrawalApplicationStatusPending, model.WithdrawalOperationsStatusPending)) + mock.ExpectRollback() + + callbackCalled := false + _, err := store.ReviewWithdrawalApplicationForApp("fami", 77, WithdrawalApplicationAuditInput{ + Stage: WithdrawalApplicationReviewStageFinance, Decision: model.WithdrawalApplicationStatusApproved, + ApproverUserID: 9, ApprovedAtMS: 1700000300000, + }, func(model.UserWithdrawalApplication) (WithdrawalApplicationAuditEffect, error) { + callbackCalled = true + return WithdrawalApplicationAuditEffect{}, nil + }) + if !errors.Is(err, ErrWithdrawalApplicationStageNotReviewable) || callbackCalled { + t.Fatalf("finance must reject operations-pending row before callback: err=%v callback=%t", err, callbackCalled) + } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("sql expectations mismatch: %v", err) + } +} + +func TestWithdrawalApplicationReviewErrorAllowsLegacySkippedInFinanceStage(t *testing.T) { + t.Parallel() + + err := withdrawalApplicationReviewError(model.UserWithdrawalApplication{ + Status: model.WithdrawalApplicationStatusPending, + OperationsStatus: model.WithdrawalOperationsStatusSkipped, + }, WithdrawalApplicationReviewStageFinance) + if err != nil { + t.Fatalf("legacy skipped application must remain finance-reviewable: %v", err) + } +} + func TestGetWithdrawalApplicationForAppScopesByAppCode(t *testing.T) { store, mock, closeStore := newRepositorySQLMock(t) defer closeStore() @@ -112,3 +432,66 @@ func TestGetWithdrawalApplicationForAppScopesByAppCode(t *testing.T) { t.Fatalf("sql expectations mismatch: %v", err) } } + +func newWithdrawalReviewMySQLTestDB(t *testing.T, baseDSN string) *sql.DB { + t.Helper() + baseConfig, err := mysqlDriver.ParseDSN(baseDSN) + if err != nil { + t.Fatalf("parse withdrawal review MySQL test DSN: %v", err) + } + databaseName := fmt.Sprintf("hy_admin_withdrawal_review_%d", time.Now().UnixNano()) + adminConfig := baseConfig.Clone() + adminConfig.DBName = "" + adminDB, err := sql.Open("mysql", adminConfig.FormatDSN()) + if err != nil { + t.Fatalf("open withdrawal review MySQL admin connection: %v", err) + } + if _, err := adminDB.Exec("CREATE DATABASE `" + databaseName + "` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); err != nil { + _ = adminDB.Close() + t.Fatalf("create withdrawal review MySQL test database: %v", err) + } + testConfig := baseConfig.Clone() + testConfig.DBName = databaseName + db, err := sql.Open("mysql", testConfig.FormatDSN()) + if err != nil { + _, _ = adminDB.Exec("DROP DATABASE IF EXISTS `" + databaseName + "`") + _ = adminDB.Close() + t.Fatalf("open withdrawal review MySQL test connection: %v", err) + } + t.Cleanup(func() { + _ = db.Close() + _, _ = adminDB.Exec("DROP DATABASE IF EXISTS `" + databaseName + "`") + _ = adminDB.Close() + }) + return db +} + +func withdrawalApplicationRows(status string, operationsStatus string) *sqlmock.Rows { + return withdrawalApplicationRowsWithOperationsContext(status, operationsStatus, 0, "", "") +} + +func withdrawalApplicationRowsWithOperationsContext(status string, operationsStatus string, reviewerID uint, reviewerName string, remark string) *sqlmock.Rows { + var storedReviewerID any + var reviewedAtMS any + operationsCommandID := "" + if reviewerID > 0 { + storedReviewerID = reviewerID + reviewedAtMS = int64(1700000300000) + operationsCommandID = "salary-withdrawal:77:operations" + } + return sqlmock.NewRows([]string{ + "id", "app_code", "user_id", "salary_asset_type", "withdraw_amount", "withdraw_amount_minor", + "withdraw_method", "withdraw_address", "freeze_command_id", "freeze_transaction_id", + "audit_command_id", "audit_transaction_id", "status", "operations_status", + "approver_user_id", "approver_name", "audit_remark", "audit_image_url", "approved_at_ms", + "operations_reviewer_user_id", "operations_reviewer_name", "operations_audit_remark", + "operations_audit_command_id", "operations_audit_transaction_id", "operations_reviewed_at_ms", + "created_at_ms", "updated_at_ms", + }).AddRow( + 77, "fami", "42001", "POINT", "9.50", int64(1000000), + "usdt_trc20", "TQY9pFbZHuR4fUN9WgXPYj5nmB6nQiLQfF", "cmd-point-freeze", "tx-freeze", + "", "", status, operationsStatus, nil, "", "", "", nil, + storedReviewerID, reviewerName, remark, operationsCommandID, "", reviewedAtMS, + int64(1700000000000), int64(1700000000000), + ) +} diff --git a/server/admin/internal/router/router.go b/server/admin/internal/router/router.go index 36f59211..29216dea 100644 --- a/server/admin/internal/router/router.go +++ b/server/admin/internal/router/router.go @@ -149,7 +149,7 @@ func New(cfg config.Config, auth *service.AuthService, store *repository.Store, api := engine.Group("/api/v1") protected := api.Group("") - protected.Use(middleware.AuthRequired(auth), middleware.Audit(h.Audit)) + protected.Use(middleware.AuthRequired(auth, store), middleware.Audit(h.Audit)) appProtected := protected.Group("") appProtected.Use(middleware.RequireAppScope(store)) diff --git a/server/admin/internal/router/router_test.go b/server/admin/internal/router/router_test.go index 1345527e..8823281b 100644 --- a/server/admin/internal/router/router_test.go +++ b/server/admin/internal/router/router_test.go @@ -11,9 +11,13 @@ import ( "hyapp-admin-server/internal/modules/appuser" "hyapp-admin-server/internal/modules/externaladmin" "hyapp-admin-server/internal/modules/opscenter" + "hyapp-admin-server/internal/repository" "hyapp-admin-server/internal/service" + "github.com/DATA-DOG/go-sqlmock" "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" ) func TestOpsCenterAppBootstrapDoesNotRequireAppCode(t *testing.T) { @@ -23,8 +27,21 @@ func TestOpsCenterAppBootstrapDoesNotRequireAppCode(t *testing.T) { if err != nil { t.Fatalf("generate access token: %v", err) } + sqlDB, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("create sql mock: %v", err) + } + defer sqlDB.Close() + gormDB, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{}) + if err != nil { + t.Fatalf("create gorm db: %v", err) + } + store := repository.New(gormDB) + mock.ExpectQuery("(?s)SELECT admin_users\\.username,.*FROM admin_users.*WHERE admin_users\\.id = \\?.*ORDER BY admin_permissions\\.code ASC"). + WithArgs(uint(7)). + WillReturnRows(sqlmock.NewRows([]string{"username", "status", "permission_code"}).AddRow("operator", "active", nil)) - engine := New(config.Config{}, auth, nil, Handlers{OpsCenter: opscenter.New(nil, nil)}) + engine := New(config.Config{}, auth, store, Handlers{OpsCenter: opscenter.New(nil, nil)}) request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/ops-center/apps", nil) request.Header.Set("Authorization", "Bearer "+token) response := httptest.NewRecorder() @@ -45,6 +62,9 @@ func TestOpsCenterAppBootstrapDoesNotRequireAppCode(t *testing.T) { if unauthorized.Code != http.StatusUnauthorized { t.Fatalf("unauthorized status = %d body=%s", unauthorized.Code, unauthorized.Body.String()) } + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("authorization sql expectations: %v", err) + } } func TestExternalPortalRegistersOnlyExplicitAppUserWhitelist(t *testing.T) { diff --git a/server/admin/migrations/102_user_withdrawal_operations_review.sql b/server/admin/migrations/102_user_withdrawal_operations_review.sql new file mode 100644 index 00000000..b38ecef7 --- /dev/null +++ b/server/admin/migrations/102_user_withdrawal_operations_review.sql @@ -0,0 +1,88 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- 这不是可直接混跑旧 admin 的普通滚动迁移:旧 admin 不读 operations_status,会把总体 status=pending 的新单直接财务终审。 +-- 生产必须先摘流所有旧 admin,再执行 102 并部署新 admin,恢复 admin 流量后才能滚动 gateway。 + +-- checkpoint 在首次执行时固定迁移前的最大申请 ID。迁移中断后重放仍使用原边界, +-- 不会把部署窗口内由旧 gateway 新建的单误标为 skipped。该表只保存小型迁移元数据,不承载业务状态。 +CREATE TABLE IF NOT EXISTS schema_migration_checkpoints ( + version VARCHAR(128) NOT NULL, + checkpoint_value BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (version) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='可重放迁移的持久边界'; + +INSERT IGNORE INTO schema_migration_checkpoints (version, checkpoint_value, created_at_ms) +SELECT '102_user_withdrawal_operations_review.sql', COALESCE(MAX(id), 0), CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED) +FROM admin_user_withdrawal_applications; + +-- 新列的数据库默认必须是 pending:旧 gateway 不传新列,在滚动窗口内也会自动进入运营审核。 +-- 单个 ALTER 的列变更具有原子性;信息查询和持久 checkpoint 使整个文件在中断后可安全重放。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND COLUMN_NAME = 'operations_status') = 0, + 'ALTER TABLE admin_user_withdrawal_applications ADD COLUMN operations_status VARCHAR(32) NOT NULL DEFAULT ''pending'' COMMENT ''运营审核状态:skipped/pending/rejecting/approved/rejected'' AFTER status, ADD COLUMN operations_reviewer_user_id BIGINT UNSIGNED NULL AFTER operations_status, ADD COLUMN operations_reviewer_name VARCHAR(64) NOT NULL DEFAULT '''' AFTER operations_reviewer_user_id, ADD COLUMN operations_audit_remark TEXT NULL AFTER operations_reviewer_name, ADD COLUMN operations_audit_command_id VARCHAR(128) NOT NULL DEFAULT '''' AFTER operations_audit_remark, ADD COLUMN operations_audit_transaction_id VARCHAR(128) NOT NULL DEFAULT '''' AFTER operations_audit_command_id, ADD COLUMN operations_reviewed_at_ms BIGINT NULL AFTER operations_audit_transaction_id, ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 兼容早期局部执行过 default=skipped 的开发库;修正默认值是元数据操作,不回写全表。 +SET @ddl := IF( + (SELECT COLUMN_DEFAULT FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND COLUMN_NAME = 'operations_status') <> 'pending', + 'ALTER TABLE admin_user_withdrawal_applications ALTER COLUMN operations_status SET DEFAULT ''pending'', ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 只把迁移前已经终审的历史行标为 skipped,保留 status=pending 的真实待审申请进入运营初审; +-- 按主键范围回填低频 admin 申请,不扫描 wallet/user 流水。大量存量时在低峰执行,并监控行锁和复制延迟。 +UPDATE admin_user_withdrawal_applications application +JOIN schema_migration_checkpoints checkpoint + ON checkpoint.version = '102_user_withdrawal_operations_review.sql' +SET application.operations_status = 'skipped' +WHERE application.id <= checkpoint.checkpoint_value + AND application.status IN ('approved', 'rejected') + AND application.operations_status = 'pending'; + +-- 索引在回填后构建,避免回填期间逐行维护新索引。建索引会扫描该 admin 表并产生额外 IO/磁盘占用, +-- 必须在低峰确认磁盘余量,并监控 metadata lock 和复制延迟。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'admin_user_withdrawal_applications' AND INDEX_NAME = 'idx_admin_withdrawal_app_operations_status_time') = 0, + 'ALTER TABLE admin_user_withdrawal_applications ADD KEY idx_admin_withdrawal_app_operations_status_time (app_code, operations_status, created_at_ms, id), ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @now_ms = CAST(UNIX_TIMESTAMP(UTC_TIMESTAMP(3)) * 1000 AS UNSIGNED); + +-- 财务权限保留原 code,避免存量自建角色和 JWT 契约失效;仅收敛后台展示名称并新增独立运营权限。 +INSERT INTO admin_permissions (name, code, kind, description, created_at_ms, updated_at_ms) VALUES + ('用户提现财务审核', 'finance-withdrawal:view', 'menu', '查看已通过运营审核或历史兼容的用户提现申请', @now_ms, @now_ms), + ('用户提现财务审核操作', 'finance-withdrawal:audit', 'button', '财务终审用户提现并扣减或释放冻结余额', @now_ms, @now_ms), + ('用户提现运营审核', 'operations-withdrawal:view', 'menu', '查看需要运营初审的用户提现申请', @now_ms, @now_ms), + ('用户提现运营审核操作', 'operations-withdrawal:audit', 'button', '运营初审用户提现;通过后转交财务,拒绝时释放冻结余额', @now_ms, @now_ms) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + kind = VALUES(kind), + description = VALUES(description), + updated_at_ms = @now_ms; + +-- 固定运营岗位不能继续持有财务终审权限,否则可绕过运营初审直接结算 frozen;只清理两个精确 role code 和两个精确 permission code。 +DELETE role_permission +FROM admin_role_permissions role_permission +JOIN admin_roles role ON role.id = role_permission.role_id +JOIN admin_permissions permission ON permission.id = role_permission.permission_id +WHERE role.code IN ('ops-admin', 'operations-specialist') + AND permission.code IN ('finance-withdrawal:view', 'finance-withdrawal:audit'); + +INSERT IGNORE INTO admin_role_permissions (role_id, permission_id) +SELECT role.id, permission.id +FROM admin_roles role +JOIN admin_permissions permission +WHERE role.code IN ('platform-admin', 'ops-admin', 'operations-specialist') + AND permission.code IN ('operations-withdrawal:view', 'operations-withdrawal:audit'); diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index d72ea84a..87adeabd 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -342,9 +342,10 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS StakeCoin: stakeCoin, Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: initiatorBalance, JoinedAtMs: nowMS}, SettlementStatus: SettlementPending, - TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(), - CreatedAtMs: nowMS, - UpdatedAtMs: nowMS, + // timeout_at_ms 是订单创建时冻结的运营配置事实;后台后续改配置只影响新挑战,不改写已有订单截止时间。 + TimeoutAtMs: now.Add(time.Duration(config.GetChallengeTimeoutMs()) * time.Millisecond).UnixMilli(), + CreatedAtMs: nowMS, + UpdatedAtMs: nowMS, } s.challenges[challenge.GetChallengeId()] = challenge cloned := cloneChallenge(challenge) @@ -377,11 +378,19 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS now := s.now() nowMS := now.UnixMilli() if challenge.GetStatus() != StatusPending { - // pending 是唯一可应战状态;matched/revealing/finished/timeout 都已经有明确归属,重复点击只能返回冲突。 + // timeout 和“被其他人接单”都需要稳定业务码;其它终态才保留通用冲突,避免客户端按 message 猜状态。 + expired := challenge.GetStatus() == StatusTimeout + takenByAnother := roomRPSChallengeTakenByAnother(challenge, req.GetUserId()) s.mu.Unlock() + if expired { + return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout") + } + if takenByAnother { + return nil, 0, xerr.New(xerr.RoomRPSChallengeTaken, "room rps challenge was accepted by another user") + } return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") } - if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { + if challenge.GetTimeoutAtMs() > 0 && nowMS >= challenge.GetTimeoutAtMs() { // 读到已超时但还未收敛的 pending 时,先在锁内改成 timeout,避免下一个用户还能应战同一单。 challenge.Status = StatusTimeout challenge.SettlementStatus = SettlementRefunded @@ -396,7 +405,7 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS s.replaceChallengeSnapshot(app, challengeID, refunded) cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) - return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") + return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout") } if challenge.GetInitiator().GetUserId() == req.GetUserId() { // 发起人不能应战自己的单;这个判断必须在 service 锁内做,不能依赖前端按钮状态。 @@ -423,11 +432,19 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS nowMS = now.UnixMilli() if challenge.GetStatus() != StatusPending { // 钱包扣款期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准,失败时立即退回刚扣的挑战方本金。 + expired := challenge.GetStatus() == StatusTimeout + takenByAnother := roomRPSChallengeTakenByAnother(challenge, req.GetUserId()) s.mu.Unlock() _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) + if expired { + return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout") + } + if takenByAnother { + return nil, 0, xerr.New(xerr.RoomRPSChallengeTaken, "room rps challenge was accepted by another user") + } return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") } - if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { + if challenge.GetTimeoutAtMs() > 0 && nowMS >= challenge.GetTimeoutAtMs() { // 二次校验也要收敛超时单,避免扣款耗时跨过 timeout 后仍然匹配成功。 challenge.Status = StatusTimeout challenge.SettlementStatus = SettlementRefunded @@ -443,7 +460,7 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS s.replaceChallengeSnapshot(app, challengeID, refunded) cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) - return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") + return nil, 0, xerr.New(xerr.RoomRPSChallengeExpired, "room rps challenge is timeout") } if challenge.GetInitiator().GetUserId() == req.GetUserId() { // 二次校验保留同一条权限边界,防止测试或未来接口在第一次检查后篡改挑战归属。 @@ -500,6 +517,12 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS return cloned, nowMS, nil } +// roomRPSChallengeTakenByAnother 只识别“已有其他挑战者”这一个可操作冲突,避免把超时、结算失败或当事人重试误报为被他人接单。 +func roomRPSChallengeTakenByAnother(challenge *gamev1.RoomRPSChallenge, userID int64) bool { + challengerID := challenge.GetChallenger().GetUserId() + return challengerID > 0 && challengerID != userID +} + func (s *Service) GetChallenge(ctx context.Context, app string, challengeID string) (*gamev1.RoomRPSChallenge, int64, error) { if s == nil { return nil, 0, xerr.New(xerr.Unavailable, "room rps service is not configured") diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index 614c8475..3890f975 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -358,6 +358,136 @@ func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) { } } +func TestAcceptChallengeReturnsTakenWhenAnotherUserAlreadyAccepted(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500, 43: 500}) + svc := New(Config{}, nil, nil, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + _, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-first-accept"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + if err != nil { + t.Fatalf("first AcceptChallenge() error = %v", err) + } + + _, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-late-accept"}, + UserId: 43, + ChallengeId: created.GetChallengeId(), + Gesture: "scissors", + }) + if !xerr.IsCode(err, xerr.RoomRPSChallengeTaken) { + t.Fatalf("late AcceptChallenge() error = %v, want RoomRPSChallengeTaken", err) + } + if wallet.balances[43] != 500 { + t.Fatalf("late challenger must not be debited: balance=%d", wallet.balances[43]) + } +} + +func TestCreateChallengeFreezesConfiguredTimeoutForEachNewOrder(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 1000}) + store := newFakeRoomRPSConfigStore() + svc := New(Config{}, nil, nil, wallet, store) + now := time.UnixMilli(1770000000000) + svc.now = func() time.Time { return now } + + updateTimeout := func(timeoutMS int64) { + t.Helper() + _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ + Status: "active", + ChallengeTimeoutMs: timeoutMS, + RevealCountdownMs: 3000, + StakeGifts: defaultGifts(), + }) + if err != nil { + t.Fatalf("UpdateConfig(%d) error = %v", timeoutMS, err) + } + } + create := func(userID int64, requestID string) *gamev1.RoomRPSChallenge { + t.Helper() + challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: requestID}, + UserId: userID, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + return challenge + } + + updateTimeout(60000) + first := create(41, "req-create-60s") + if got, want := first.GetTimeoutAtMs(), now.UnixMilli()+60000; got != want { + t.Fatalf("first timeout_at_ms = %d, want configured boundary %d", got, want) + } + + updateTimeout(120000) + second := create(42, "req-create-120s") + if got, want := second.GetTimeoutAtMs(), now.UnixMilli()+120000; got != want { + t.Fatalf("second timeout_at_ms = %d, want configured boundary %d", got, want) + } + if got, want := first.GetTimeoutAtMs(), now.UnixMilli()+60000; got != want { + t.Fatalf("existing challenge timeout changed after config update: got %d want %d", got, want) + } +} + +func TestAcceptChallengeReturnsExpiredAtConfiguredBoundary(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 1000}) + svc := New(Config{ChallengeTimeout: 60 * time.Second}, nil, nil, wallet) + now := time.UnixMilli(1770000000000) + svc.now = func() time.Time { return now } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expiring"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + + // 截止毫秒是排他边界:到达 timeout_at_ms 即失效,不能再多放行一个毫秒。 + now = time.UnixMilli(created.GetTimeoutAtMs()) + accept := func(requestID string) error { + t.Helper() + _, _, acceptErr := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: requestID}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + return acceptErr + } + if err := accept("req-accept-expired"); !xerr.IsCode(err, xerr.RoomRPSChallengeExpired) { + t.Fatalf("AcceptChallenge() error = %v, want RoomRPSChallengeExpired", err) + } + if wallet.balances[41] != 1000 || wallet.balances[42] != 1000 { + t.Fatalf("expired challenge must refund initiator without debiting challenger: balances=%+v", wallet.balances) + } + // 已收敛为 timeout 的旧挑战再次点击时仍返回同一业务码,不能退化回通用 CONFLICT。 + if err := accept("req-accept-expired-again"); !xerr.IsCode(err, xerr.RoomRPSChallengeExpired) { + t.Fatalf("repeated AcceptChallenge() error = %v, want RoomRPSChallengeExpired", err) + } +} + func TestAcceptChallengeDrawRefundsBothPlayers(t *testing.T) { wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500}) svc := New(Config{}, nil, nil, wallet) diff --git a/services/gateway-service/internal/app/app.go b/services/gateway-service/internal/app/app.go index f008cc47..bda878ea 100644 --- a/services/gateway-service/internal/app/app.go +++ b/services/gateway-service/internal/app/app.go @@ -478,22 +478,16 @@ func configureUserLeaderboard(handler *httptransport.Handler, cfg config.Leaderb } func configureLoginRisk(handler *httptransport.Handler, cfg config.LoginRiskConfig, riskConfig httptransport.LoginRiskConfig) (func() error, error) { - if !riskConfig.Enabled { - handler.SetLoginRisk(riskConfig) - return nil, nil - } redisAddr := strings.TrimSpace(cfg.RedisAddr) if redisAddr == "" { - handler.SetLoginRisk(riskConfig) - return nil, nil + return nil, errors.New("session denylist redis_addr is required") } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() redisClient, err := httptransport.NewLoginRiskRedisClient(ctx, redisAddr, cfg.RedisPassword, cfg.RedisDB) if err != nil { - // IP 风控缓存按文档 fail-open:Redis 不可用不能阻断 gateway 启动或登录。 - handler.SetLoginRisk(riskConfig) - return nil, nil + // FastGuard 可 fail-open,但 sid denylist 是 access JWT 撤销的唯一入口事实;连接失败必须阻断启动。 + return nil, fmt.Errorf("connect session denylist redis: %w", err) } handler.SetLoginRiskCache(riskConfig, httptransport.NewRedisLoginRiskCache(redisClient, riskConfig)) return redisClient.Close, nil diff --git a/services/gateway-service/internal/app/app_test.go b/services/gateway-service/internal/app/app_test.go index b36e121b..01091e32 100644 --- a/services/gateway-service/internal/app/app_test.go +++ b/services/gateway-service/internal/app/app_test.go @@ -49,3 +49,22 @@ func TestConfigureGiftCapacityLimitDisabledDoesNotRequireRedis(t *testing.T) { t.Fatal("disabled gift capacity limit must not allocate redis client") } } + +func TestConfigureLoginRiskRequiresSessionDenylistRedisEvenWhenRiskDisabled(t *testing.T) { + handler := httptransport.NewHandler(nil) + _, err := configureLoginRisk(handler, config.LoginRiskConfig{Enabled: false}, httptransport.LoginRiskConfig{Enabled: false}) + if err == nil || !strings.Contains(err.Error(), "session denylist redis_addr is required") { + t.Fatalf("mandatory session denylist must reject empty Redis config, err=%v", err) + } +} + +func TestConfigureLoginRiskFailsStartupWhenSessionDenylistRedisUnavailable(t *testing.T) { + handler := httptransport.NewHandler(nil) + _, err := configureLoginRisk(handler, config.LoginRiskConfig{ + Enabled: false, + RedisAddr: "127.0.0.1:1", + }, httptransport.LoginRiskConfig{Enabled: false}) + if err == nil || !strings.Contains(err.Error(), "connect session denylist redis") { + t.Fatalf("unreachable session denylist Redis must fail startup, err=%v", err) + } +} diff --git a/services/gateway-service/internal/auth/jwt.go b/services/gateway-service/internal/auth/jwt.go index dc36e713..4b3378ce 100644 --- a/services/gateway-service/internal/auth/jwt.go +++ b/services/gateway-service/internal/auth/jwt.go @@ -2,6 +2,7 @@ package auth import ( "context" + "errors" "fmt" "strconv" "strings" @@ -10,6 +11,9 @@ import ( "hyapp/pkg/appcode" ) +// ErrAccessTokenExpired 让 transport 精确区分“可通过 refresh 恢复”的 JWT 过期与签名非法/缺失凭证。 +var ErrAccessTokenExpired = errors.New("access token expired") + type contextKey string const ( @@ -63,6 +67,11 @@ func (v *Verifier) Verify(header string) (Claims, error) { return v.secret, nil }) + if errors.Is(err, jwt.ErrTokenExpired) && !errors.Is(err, jwt.ErrTokenNotValidYet) && !errors.Is(err, jwt.ErrInvalidType) { + // jwt 先验签再校验 claims,因此到这里签名已可信;但 exp 过期若同时伴随未来 nbf/畸形时间字段, + // 仍是非法凭证而非单纯可 refresh 的过期 token,不能向客户端发刷新信号。 + return Claims{}, ErrAccessTokenExpired + } if err != nil || !token.Valid { return Claims{}, fmt.Errorf("invalid token") } diff --git a/services/gateway-service/internal/auth/jwt_test.go b/services/gateway-service/internal/auth/jwt_test.go index 80269256..565317dd 100644 --- a/services/gateway-service/internal/auth/jwt_test.go +++ b/services/gateway-service/internal/auth/jwt_test.go @@ -1,8 +1,10 @@ package auth import ( + "errors" "strconv" "testing" + "time" jwt "github.com/golang-jwt/jwt/v5" ) @@ -28,3 +30,35 @@ func TestVerifierParsesLargeUserIDStringWithoutPrecisionLoss(t *testing.T) { t.Fatalf("user_id lost precision: got %d want %d", claims.UserID, userID) } } + +func TestVerifierDistinguishesExpiredAccessTokenFromInvalidCredentials(t *testing.T) { + t.Parallel() + expired := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": "42", + "sid": "sess-expired", + "exp": time.Now().Add(-time.Minute).Unix(), + }) + signed, err := expired.SignedString([]byte("secret")) + if err != nil { + t.Fatalf("sign expired token failed: %v", err) + } + if _, err := NewVerifier("secret").Verify("Bearer " + signed); !errors.Is(err, ErrAccessTokenExpired) { + t.Fatalf("expired token must return ErrAccessTokenExpired, got %v", err) + } + if _, err := NewVerifier("different-secret").Verify("Bearer " + signed); errors.Is(err, ErrAccessTokenExpired) || err == nil { + t.Fatalf("invalid signature must remain generic unauthorized, got %v", err) + } + contradictory := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": "42", + "sid": "sess-invalid-window", + "exp": time.Now().Add(-time.Minute).Unix(), + "nbf": time.Now().Add(time.Hour).Unix(), + }) + contradictorySigned, err := contradictory.SignedString([]byte("secret")) + if err != nil { + t.Fatalf("sign contradictory token failed: %v", err) + } + if _, err := NewVerifier("secret").Verify("Bearer " + contradictorySigned); errors.Is(err, ErrAccessTokenExpired) || err == nil { + t.Fatalf("expired token with another invalid time claim must remain generic unauthorized, got %v", err) + } +} diff --git a/services/gateway-service/internal/client/user_client.go b/services/gateway-service/internal/client/user_client.go index 8b470c9c..bc8229c6 100644 --- a/services/gateway-service/internal/client/user_client.go +++ b/services/gateway-service/internal/client/user_client.go @@ -86,6 +86,12 @@ type UserCPClient interface { CancelBreakCPRelationship(ctx context.Context, req *userv1.CancelBreakCPRelationshipRequest) (*userv1.CancelBreakCPRelationshipResponse, error) } +// UserCPFormationGiftFeedClient 是成立礼物 feed 的独立只读能力。 +// 它不并入历史 UserCPClient,避免只覆盖关系命令的调用方被迫依赖新的展示接口。 +type UserCPFormationGiftFeedClient interface { + ListCPFormationGiftFeed(ctx context.Context, req *userv1.ListCPFormationGiftFeedRequest) (*userv1.ListCPFormationGiftFeedResponse, error) +} + // UserDeviceClient 抽象 gateway 对 user-service 设备 push token 的依赖。 type UserDeviceClient interface { BindPushToken(ctx context.Context, req *userv1.BindPushTokenRequest) (*userv1.BindPushTokenResponse, error) @@ -171,6 +177,8 @@ type grpcUserCPClient struct { client userv1.UserCPServiceClient } +var _ UserCPFormationGiftFeedClient = (*grpcUserCPClient)(nil) + type grpcUserDeviceClient struct { client userv1.UserDeviceServiceClient } @@ -483,6 +491,10 @@ func (c *grpcUserCPClient) ListCPIntimacyLeaderboard(ctx context.Context, req *u return c.client.ListCPIntimacyLeaderboard(ctx, req) } +func (c *grpcUserCPClient) ListCPFormationGiftFeed(ctx context.Context, req *userv1.ListCPFormationGiftFeedRequest) (*userv1.ListCPFormationGiftFeedResponse, error) { + return c.client.ListCPFormationGiftFeed(ctx, req) +} + func (c *grpcUserCPClient) PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) { return c.client.PrepareBreakCPRelationship(ctx, req) } diff --git a/services/gateway-service/internal/config/config.go b/services/gateway-service/internal/config/config.go index a83b5c33..89c950be 100644 --- a/services/gateway-service/internal/config/config.go +++ b/services/gateway-service/internal/config/config.go @@ -578,7 +578,8 @@ func (cfg *Config) Normalize() error { } cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.LoginRisk.RedisAddr) cfg.LoginRisk.KeyPrefix = strings.TrimSpace(cfg.LoginRisk.KeyPrefix) - if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr == "" { + // 地区风控可关闭,但同一 Redis 还承载 mandatory session denylist;始终继承 auth rate-limit 连接。 + if cfg.LoginRisk.RedisAddr == "" { cfg.LoginRisk.RedisAddr = strings.TrimSpace(cfg.AuthRateLimit.RedisAddr) cfg.LoginRisk.RedisPassword = cfg.AuthRateLimit.RedisPassword cfg.LoginRisk.RedisDB = cfg.AuthRateLimit.RedisDB diff --git a/services/gateway-service/internal/config/config_test.go b/services/gateway-service/internal/config/config_test.go index ec193820..0bf40001 100644 --- a/services/gateway-service/internal/config/config_test.go +++ b/services/gateway-service/internal/config/config_test.go @@ -152,6 +152,23 @@ func TestLoadLegacyYAMLWithoutGiftSectionReusesAuthRedis(t *testing.T) { } } +func TestNormalizeLoginRiskDisabledStillInheritsMandatorySessionDenylistRedis(t *testing.T) { + cfg := Default() + cfg.AuthRateLimit.RedisAddr = "shared-redis.internal:6379" + cfg.AuthRateLimit.RedisPassword = "shared-secret" + cfg.AuthRateLimit.RedisDB = 4 + cfg.LoginRisk.Enabled = false + cfg.LoginRisk.RedisAddr = "" + cfg.LoginRisk.RedisPassword = "" + cfg.LoginRisk.RedisDB = 0 + if err := cfg.Normalize(); err != nil { + t.Fatalf("normalize login-risk disabled config failed: %v", err) + } + if cfg.LoginRisk.RedisAddr != "shared-redis.internal:6379" || cfg.LoginRisk.RedisPassword != "shared-secret" || cfg.LoginRisk.RedisDB != 4 { + t.Fatalf("disabled FastGuard must still inherit mandatory session denylist Redis: %+v", cfg.LoginRisk) + } +} + func TestNormalizeRejectsGiftLeaseWithoutRequestSafetyMargin(t *testing.T) { cfg := Default() cfg.GiftCapacityLimit.RequestTimeout = 8 * time.Second diff --git a/services/gateway-service/internal/financewithdrawal/dingtalk_notifier.go b/services/gateway-service/internal/financewithdrawal/dingtalk_notifier.go index 1e282aec..bfbcac9b 100644 --- a/services/gateway-service/internal/financewithdrawal/dingtalk_notifier.go +++ b/services/gateway-service/internal/financewithdrawal/dingtalk_notifier.go @@ -26,14 +26,14 @@ func (n *dingTalkNotifier) NotifyApplicationCreated(ctx context.Context, applica return nil } return n.client.SendMarkdown(ctx, dingtalkrobot.MarkdownMessage{ - Title: "用户提现申请待审核", + Title: "用户提现运营审核待处理", Text: withdrawalApplicationCreatedMarkdown(application), }) } func withdrawalApplicationCreatedMarkdown(application Application) string { return strings.Join([]string{ - "### 用户提现申请待审核", + "### 用户提现运营审核待处理", "", fmt.Sprintf("- 申请ID:%d", application.ID), fmt.Sprintf("- APP:%s", markdownValue(application.AppCode)), @@ -44,7 +44,7 @@ func withdrawalApplicationCreatedMarkdown(application Application) string { fmt.Sprintf("- 提现地址:%s", markdownValue(application.WithdrawAddress)), fmt.Sprintf("- 冻结交易:%s", markdownValue(application.FreezeTransactionID)), fmt.Sprintf("- 申请时间:%s", markdownValue(formatNotifyMillis(application.CreatedAtMS))), - "- 后台地址:https://admin-acc.global-interaction.com/finance/", + "- 后台地址:https://admin-acc.global-interaction.com/finance/?view=withdrawalOperations", }, "\n") } diff --git a/services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go b/services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go index 6b90eea0..728c537f 100644 --- a/services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go +++ b/services/gateway-service/internal/financewithdrawal/dingtalk_notifier_test.go @@ -5,7 +5,7 @@ import ( "testing" ) -func TestWithdrawalApplicationCreatedMarkdownKeepsFinanceReviewDetails(t *testing.T) { +func TestWithdrawalApplicationCreatedMarkdownRoutesToOperationsReview(t *testing.T) { message := withdrawalApplicationCreatedMarkdown(Application{ ID: 77, AppCode: "lalu", @@ -19,14 +19,14 @@ func TestWithdrawalApplicationCreatedMarkdownKeepsFinanceReviewDetails(t *testin }) for _, expected := range []string{ - "用户提现申请待审核", + "用户提现运营审核待处理", "申请ID:77", "APP:lalu", "用户ID:42", "提现金额:$ 50.00", "提现方式:usdt_trc20", "冻结交易:freeze-tx-1", - "https://admin-acc.global-interaction.com/finance/", + "https://admin-acc.global-interaction.com/finance/?view=withdrawalOperations", } { if !strings.Contains(message, expected) { t.Fatalf("withdrawal DingTalk message missing %q: %s", expected, message) diff --git a/services/gateway-service/internal/financewithdrawal/writer.go b/services/gateway-service/internal/financewithdrawal/writer.go index be439750..117bb5b8 100644 --- a/services/gateway-service/internal/financewithdrawal/writer.go +++ b/services/gateway-service/internal/financewithdrawal/writer.go @@ -11,13 +11,15 @@ import ( ) const ( - // StatusPending 与后台 finance 用户提现申请列表的待审核状态保持一致;H5 只创建申请,不在入口层审批。 + // StatusPending 表示申请尚未形成最终财务结论;运营通过后总体状态仍保持 pending。 StatusPending = "pending" + // OperationsStatusPending 保证所有新申请先进入运营初审;102 迁移只把执行前已经终审的历史行标记为 skipped。 + OperationsStatusPending = "pending" // MethodUSDTTRC20 是当前 H5 提现页唯一收款方式,后台列表按该字段展示提现方式。 MethodUSDTTRC20 = "usdt_trc20" ) -// CreateApplicationCommand 是 H5 提交到后台 finance 的提现申请事实。 +// CreateApplicationCommand 是 H5 提交到后台审核链的提现申请事实。 // gateway 在调用前已经完成身份、余额和收款地址校验,这里只保护表字段的持久化边界。 type CreateApplicationCommand struct { AppCode string @@ -37,7 +39,7 @@ type CreateApplicationCommand struct { CreatedAtMS int64 } -// Application 是写入后台申请表后的最小返回值;H5 只需要知道申请已进入财务审核队列。 +// Application 是写入后台申请表后的最小返回值;H5 可以根据总体状态与运营状态区分终态和当前审核阶段。 type Application struct { ID int64 AppCode string @@ -54,17 +56,18 @@ type Application struct { WithdrawAddress string FreezeCommandID string FreezeTransactionID string + OperationsStatus string Status string CreatedAtMS int64 UpdatedAtMS int64 } -// Writer 把 gateway 与后台 finance 存储解耦,测试可以用内存 fake 验证 H5 编排,不 import admin internal 包。 +// Writer 把 gateway 与后台提现审核存储解耦,测试可以用内存 fake 验证 H5 编排,不 import admin internal 包。 type Writer interface { CreateApplication(ctx context.Context, command CreateApplicationCommand) (Application, error) } -// Notifier 负责把用户提现申请通知到财务协作工具;通知失败不能回滚已冻结和已落库的申请。 +// Notifier 负责把新申请通知到运营审核协作工具;通知失败不能回滚已冻结和已落库的申请。 type Notifier interface { NotifyApplicationCreated(ctx context.Context, application Application) error } @@ -96,7 +99,7 @@ func (w *MySQLWriter) Close() error { return w.db.Close() } -// CreateApplication 将用户提现请求落成后台 finance 的待审核申请。 +// CreateApplication 将用户提现请求落成运营待审申请,总体状态在财务终审前一直保持 pending。 // withdraw_amount 使用 decimal 字符串写入,避免 gateway 在 float 和 decimal 之间来回转换造成金额精度漂移。 func (w *MySQLWriter) CreateApplication(ctx context.Context, command CreateApplicationCommand) (Application, error) { if w == nil || w.db == nil { @@ -118,9 +121,9 @@ func (w *MySQLWriter) CreateApplication(ctx context.Context, command CreateAppli INSERT INTO admin_user_withdrawal_applications (app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code, - withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, created_at_ms, updated_at_ms) + withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, status, operations_status, created_at_ms, updated_at_ms) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, command.AppCode, formatUserID(command.UserID), command.SalaryAssetType, @@ -136,6 +139,7 @@ VALUES command.FreezeCommandID, command.FreezeTransactionID, StatusPending, + OperationsStatusPending, command.CreatedAtMS, command.CreatedAtMS, ) @@ -170,6 +174,7 @@ VALUES WithdrawAddress: command.WithdrawAddress, FreezeCommandID: command.FreezeCommandID, FreezeTransactionID: command.FreezeTransactionID, + OperationsStatus: OperationsStatusPending, Status: StatusPending, CreatedAtMS: command.CreatedAtMS, UpdatedAtMS: command.CreatedAtMS, @@ -180,8 +185,8 @@ func (w *MySQLWriter) getApplicationByFreezeCommand(ctx context.Context, appCode row := w.db.QueryRowContext(ctx, ` SELECT id, app_code, user_id, salary_asset_type, withdraw_amount, withdraw_amount_minor, point_fee_amount, point_net_amount, points_per_usd, point_fee_bps, point_policy_instance_code, - withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, - status, created_at_ms, updated_at_ms + withdraw_method, withdraw_address, freeze_command_id, freeze_transaction_id, + status, operations_status, created_at_ms, updated_at_ms FROM admin_user_withdrawal_applications WHERE app_code = ? AND freeze_command_id = ? LIMIT 1`, appCode, freezeCommandID) @@ -204,6 +209,7 @@ LIMIT 1`, appCode, freezeCommandID) &item.FreezeCommandID, &item.FreezeTransactionID, &item.Status, + &item.OperationsStatus, &item.CreatedAtMS, &item.UpdatedAtMS, ); err != nil { diff --git a/services/gateway-service/internal/financewithdrawal/writer_test.go b/services/gateway-service/internal/financewithdrawal/writer_test.go index eaf05ce7..a5af0839 100644 --- a/services/gateway-service/internal/financewithdrawal/writer_test.go +++ b/services/gateway-service/internal/financewithdrawal/writer_test.go @@ -49,6 +49,7 @@ func TestCreateApplicationReturnsExistingByAppAndFreezeCommand(t *testing.T) { "cmd-point-freeze", "tx-freeze", StatusPending, + "skipped", int64(1700000000000), int64(1700000000000), )) @@ -73,7 +74,7 @@ func TestCreateApplicationReturnsExistingByAppAndFreezeCommand(t *testing.T) { if err != nil { t.Fatalf("CreateApplication existing lookup failed: %v", err) } - if got.ID != 77 || got.AppCode != "huwaa" || got.UserID != 42001 || got.FreezeTransactionID != "tx-freeze" { + if got.ID != 77 || got.AppCode != "huwaa" || got.UserID != 42001 || got.FreezeTransactionID != "tx-freeze" || got.OperationsStatus != "skipped" { t.Fatalf("existing withdrawal application mismatch: %+v", got) } if err := mock.ExpectationsWereMet(); err != nil { @@ -106,6 +107,7 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) { "cmd-point-freeze", "tx-freeze", StatusPending, + OperationsStatusPending, int64(1700000000000), int64(1700000000000), ). @@ -130,7 +132,7 @@ func TestCreateApplicationInsertsAfterAppScopedMiss(t *testing.T) { if err != nil { t.Fatalf("CreateApplication insert failed: %v", err) } - if got.ID != 88 || got.AppCode != "huwaa" || got.SalaryAssetType != "POINT" || got.WithdrawMethod != MethodUSDTTRC20 { + if got.ID != 88 || got.AppCode != "huwaa" || got.SalaryAssetType != "POINT" || got.WithdrawMethod != MethodUSDTTRC20 || got.Status != StatusPending || got.OperationsStatus != OperationsStatusPending { t.Fatalf("inserted withdrawal application mismatch: %+v", got) } if err := mock.ExpectationsWereMet(); err != nil { @@ -156,6 +158,7 @@ func withdrawalApplicationRows() *sqlmock.Rows { "freeze_command_id", "freeze_transaction_id", "status", + "operations_status", "created_at_ms", "updated_at_ms", }) diff --git a/services/gateway-service/internal/transport/http/auth_handler.go b/services/gateway-service/internal/transport/http/auth_handler.go index c059c656..3d3d14f1 100644 --- a/services/gateway-service/internal/transport/http/auth_handler.go +++ b/services/gateway-service/internal/transport/http/auth_handler.go @@ -438,10 +438,11 @@ func (h *Handler) setPassword(writer http.ResponseWriter, request *http.Request) // refreshToken 处理 refresh token 轮换。 func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request) { var body struct { - RefreshToken string `json:"refresh_token"` - DeviceID string `json:"device_id"` - AppVersion string `json:"app_version"` - BuildNumber string `json:"build_number"` + RefreshToken string `json:"refresh_token"` + RefreshRequestID string `json:"refresh_request_id"` + DeviceID string `json:"device_id"` + AppVersion string `json:"app_version"` + BuildNumber string `json:"build_number"` } if !decode(writer, request, &body) { return @@ -459,8 +460,9 @@ func (h *Handler) refreshToken(writer http.ResponseWriter, request *http.Request resp, err := h.userClient.RefreshToken(request.Context(), &userv1.RefreshTokenRequest{ // refresh 是登录态自动恢复的一部分,必须携带当前安装包版本;否则升级后画像会一直停留在旧显式登录版本。 - Meta: authRequestMetaWithLoginContext(request, body.DeviceID, "", "", "", body.AppVersion, body.BuildNumber), - RefreshToken: body.RefreshToken, + Meta: authRequestMetaWithLoginContext(request, body.DeviceID, "", "", "", body.AppVersion, body.BuildNumber), + RefreshToken: body.RefreshToken, + RefreshRequestId: body.RefreshRequestID, }) if err != nil { httpkit.WriteRPCError(writer, request, err) diff --git a/services/gateway-service/internal/transport/http/httpkit/response.go b/services/gateway-service/internal/transport/http/httpkit/response.go index 419fe06b..80742825 100644 --- a/services/gateway-service/internal/transport/http/httpkit/response.go +++ b/services/gateway-service/internal/transport/http/httpkit/response.go @@ -26,17 +26,18 @@ import ( type requestIDContextKey struct{} const ( - CodeOK = "OK" - CodeInvalidJSON = "INVALID_JSON" - CodeInvalidArgument = "INVALID_ARGUMENT" - CodeUnauthorized = "UNAUTHORIZED" - CodePermissionDenied = "PERMISSION_DENIED" - CodeProfileRequired = "PROFILE_REQUIRED" - CodeAuthLoginBlocked = "AUTH_LOGIN_BLOCKED" - CodeNotFound = "NOT_FOUND" - CodeRateLimited = "RATE_LIMITED" - CodeUpstreamError = "UPSTREAM_ERROR" - CodeInternalError = "INTERNAL_ERROR" + CodeOK = "OK" + CodeInvalidJSON = "INVALID_JSON" + CodeInvalidArgument = "INVALID_ARGUMENT" + CodeUnauthorized = "UNAUTHORIZED" + CodeAccessTokenExpired = "ACCESS_TOKEN_EXPIRED" + CodePermissionDenied = "PERMISSION_DENIED" + CodeProfileRequired = "PROFILE_REQUIRED" + CodeAuthLoginBlocked = "AUTH_LOGIN_BLOCKED" + CodeNotFound = "NOT_FOUND" + CodeRateLimited = "RATE_LIMITED" + CodeUpstreamError = "UPSTREAM_ERROR" + CodeInternalError = "INTERNAL_ERROR" ) // ResponseEnvelope 是 gateway 暴露给客户端的稳定 JSON 外壳。 diff --git a/services/gateway-service/internal/transport/http/httproutes/router.go b/services/gateway-service/internal/transport/http/httproutes/router.go index 12913cf4..c77c0f71 100644 --- a/services/gateway-service/internal/transport/http/httproutes/router.go +++ b/services/gateway-service/internal/transport/http/httproutes/router.go @@ -129,6 +129,7 @@ type UserHandlers struct { RejectCPApplication http.HandlerFunc ListCPRelationships http.HandlerFunc ListCPIntimacyLeaderboard http.HandlerFunc + ListCPFormationGiftFeed http.HandlerFunc BreakCPRelationship http.HandlerFunc GetMyProfile http.HandlerFunc GetUserProfile http.HandlerFunc @@ -522,6 +523,7 @@ func (r routes) registerUserRoutes() { r.profile("/cp/applications/{application_id}/reject", http.MethodPost, h.RejectCPApplication) r.profile("/cp/relationships", http.MethodGet, h.ListCPRelationships) r.profile("/cp/intimacy-leaderboard", http.MethodGet, h.ListCPIntimacyLeaderboard) + r.profile("/cp/formation-gift-feed", http.MethodGet, h.ListCPFormationGiftFeed) r.profile("/cp/relationships/{relationship_id}/break", http.MethodPost, h.BreakCPRelationship) r.profile("/appearance", http.MethodGet, h.GetMyAppearance) r.profile("/users/me/appearance", http.MethodGet, h.GetMyAppearance) diff --git a/services/gateway-service/internal/transport/http/middleware.go b/services/gateway-service/internal/transport/http/middleware.go index 52e5f669..0af19521 100644 --- a/services/gateway-service/internal/transport/http/middleware.go +++ b/services/gateway-service/internal/transport/http/middleware.go @@ -2,7 +2,7 @@ package http import ( "context" - "hyapp/services/gateway-service/internal/transport/http/httpkit" + "errors" "log/slog" "net/http" "strings" @@ -13,6 +13,7 @@ import ( "hyapp/pkg/logx" "hyapp/pkg/xerr" "hyapp/services/gateway-service/internal/auth" + "hyapp/services/gateway-service/internal/transport/http/httpkit" ) // apiHandler 包装需要 app 解析和 access token 的业务 API。 @@ -42,6 +43,11 @@ func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) ht return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { claims, err := jwtVerifier.Verify(request.Header.Get("Authorization")) if err != nil { + if errors.Is(err, auth.ErrAccessTokenExpired) { + // 只有明确 JWT exp 过期才给客户端刷新信号;签名非法、缺 token 仍保持普通 UNAUTHORIZED。 + httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeAccessTokenExpired, "access token expired") + return + } httpkit.WriteError(writer, request, http.StatusUnauthorized, httpkit.CodeUnauthorized, "unauthorized") return } @@ -95,6 +101,8 @@ func (h *Handler) withOptionalAuth(jwtVerifier *auth.Verifier, next http.Handler func (h *Handler) revokedSession(ctx context.Context, appCode string, sessionID string) (bool, error) { if h.loginRiskCache == nil { + // Handler 允许被纯 transport 单测和轻量工具独立构造;生产 app 的 configureLoginRisk 已把 + // Redis 缺失/连接失败设为启动失败,因此线上进入此分支只可能是非生产装配。 return false, nil } return h.loginRiskCache.RevokedSessionExists(ctx, appCode, sessionID) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 97ad9952..34f9e4f2 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -771,6 +771,9 @@ func (f *fakeWithdrawalApplicationWriter) CreateApplication(_ context.Context, c if resp.Status == "" { resp.Status = financewithdrawal.StatusPending } + if resp.OperationsStatus == "" { + resp.OperationsStatus = financewithdrawal.OperationsStatusPending + } if resp.CreatedAtMS == 0 { resp.CreatedAtMS = command.CreatedAtMS } @@ -6053,7 +6056,7 @@ func TestSalaryWalletWithdrawAddressGetAndSave(t *testing.T) { } } -func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testing.T) { +func TestSalaryWalletWithdrawCreatesOperationsPendingApplicationWithSavedAddress(t *testing.T) { walletClient := &fakeWalletClient{resp: &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{ {AssetType: "HOST_SALARY_USD", AvailableAmount: 6000}, }}} @@ -6119,6 +6122,7 @@ func TestSalaryWalletWithdrawCreatesFinanceApplicationWithSavedAddress(t *testin balanceAfter, _ := data["salary_balance_after"].(map[string]any) if data["application_id"] != "77" || data["status"] != "pending" || + data["operations_status"] != "pending" || data["withdraw_amount"] != "50.00" || data["withdraw_address"] != "TCSTWxGagPpCS8RaZeBXMW9d69gUsK9QGb" || data["freeze_transaction_id"] != "freeze-tx-1" || @@ -7815,7 +7819,7 @@ func TestRefreshRateLimitByDeviceID(t *testing.T) { func TestRefreshPropagatesCurrentClientVersionWithHeaderPriority(t *testing.T) { userClient := &fakeUserAuthClient{} router := NewHandler(&fakeRoomClient{}, userClient).Routes(auth.NewVerifier("secret")) - body := []byte(`{"refresh_token":"refresh-1","device_id":"dev-1","app_version":"1.2.3","build_number":"123"}`) + body := []byte(`{"refresh_token":"refresh-1","refresh_request_id":"refresh-attempt-1","device_id":"dev-1","app_version":"1.2.3","build_number":"123"}`) request := httptest.NewRequest(http.MethodPost, "/api/v1/auth/token/refresh", bytes.NewReader(body)) request.Header.Set("X-Request-ID", "req-refresh-version") request.Header.Set("X-App-Version", "2.0.0") @@ -7831,6 +7835,9 @@ func TestRefreshPropagatesCurrentClientVersionWithHeaderPriority(t *testing.T) { if meta.GetAppVersion() != "2.0.0" || meta.GetBuildNumber() != "200" { t.Fatalf("refresh client version mismatch: %+v", meta) } + if userClient.lastRefresh.GetRefreshRequestId() != "refresh-attempt-1" { + t.Fatalf("refresh_request_id must reach user-service, got %q", userClient.lastRefresh.GetRefreshRequestId()) + } } func TestLogoutRateLimitBySessionIDAndIP(t *testing.T) { @@ -11240,6 +11247,28 @@ func TestRoutesWriteUnauthorizedEnvelope(t *testing.T) { assertEnvelope(t, recorder, http.StatusUnauthorized, httpkit.CodeUnauthorized, "req-auth") } +func TestRoutesWriteAccessTokenExpiredOnlyForValidExpiredJWT(t *testing.T) { + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{ + "user_id": "42", + "sid": "sess-expired", + "app_code": "lalu", + "exp": time.Now().Add(-time.Minute).Unix(), + }) + signed, err := token.SignedString([]byte("secret")) + if err != nil { + t.Fatalf("sign expired gateway token failed: %v", err) + } + router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) + request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{}`))) + request.Header.Set("Authorization", "Bearer "+signed) + request.Header.Set("X-Request-ID", "req-expired-access-token") + recorder := httptest.NewRecorder() + + router.ServeHTTP(recorder, request) + + assertEnvelope(t, recorder, http.StatusUnauthorized, httpkit.CodeAccessTokenExpired, "req-expired-access-token") +} + func TestRoutesWriteInvalidJSONEnvelope(t *testing.T) { router := NewHandler(&fakeRoomClient{}).Routes(auth.NewVerifier("secret")) request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/create", bytes.NewReader([]byte(`{`))) diff --git a/services/gateway-service/internal/transport/http/userapi/cp_handler.go b/services/gateway-service/internal/transport/http/userapi/cp_handler.go index 94792e47..37983727 100644 --- a/services/gateway-service/internal/transport/http/userapi/cp_handler.go +++ b/services/gateway-service/internal/transport/http/userapi/cp_handler.go @@ -8,6 +8,7 @@ import ( userv1 "hyapp.local/api/proto/user/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/services/gateway-service/internal/auth" + "hyapp/services/gateway-service/internal/client" "hyapp/services/gateway-service/internal/transport/http/httpkit" ) @@ -48,6 +49,14 @@ type cpIntimacyLeaderboardItemData struct { UpdatedAtMS int64 `json:"updated_at_ms"` } +type cpFormationGiftFeedItemData struct { + FormationID string `json:"formation_id"` + GiftCoinValue int64 `json:"gift_coin_value"` + Requester *cpUserProfileData `json:"requester,omitempty"` + Target *cpUserProfileData `json:"target,omitempty"` + FormedAtMS int64 `json:"formed_at_ms"` +} + type cpGiftSnapshotData struct { GiftID string `json:"gift_id"` GiftName string `json:"gift_name"` @@ -55,6 +64,7 @@ type cpGiftSnapshotData struct { GiftAnimationURL string `json:"gift_animation_url"` GiftCount int32 `json:"gift_count"` GiftValue int64 `json:"gift_value"` + CoinSpent int64 `json:"coin_spent"` BillingReceiptID string `json:"billing_receipt_id"` } @@ -272,6 +282,59 @@ func (h *Handler) listCPIntimacyLeaderboard(writer http.ResponseWriter, request }) } +// listCPFormationGiftFeed 返回已接受的 CP 成立礼物事件流,金额口径固定为 wallet 结算出的礼物金币价值。 +func (h *Handler) listCPFormationGiftFeed(writer http.ResponseWriter, request *http.Request) { + feedClient, ok := h.userCPClient.(client.UserCPFormationGiftFeedClient) + if h.userCPClient == nil || !ok { + httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error") + return + } + limit, ok := cpFormationGiftFeedLimit(request) + if !ok { + httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument") + return + } + // HTTP 只允许客户端缩小/放大返回条数到 100;严格 >5000、accepted 和 CP 类型都不能由 query 覆盖。 + resp, err := feedClient.ListCPFormationGiftFeed(request.Context(), &userv1.ListCPFormationGiftFeedRequest{ + Meta: httpkit.UserMeta(request, ""), + Limit: limit, + }) + if err != nil { + httpkit.WriteRPCError(writer, request, err) + return + } + items := make([]cpFormationGiftFeedItemData, 0, len(resp.GetItems())) + for _, item := range resp.GetItems() { + if item == nil { + continue + } + items = append(items, cpFormationGiftFeedItemData{ + FormationID: item.GetFormationId(), + GiftCoinValue: item.GetGiftCoinValue(), + Requester: cpUserProfileFromProto(item.GetRequester()), + Target: cpUserProfileFromProto(item.GetTarget()), + FormedAtMS: item.GetFormedAtMs(), + }) + } + httpkit.WriteOK(writer, request, map[string]any{"items": items}) +} + +func cpFormationGiftFeedLimit(request *http.Request) (int32, bool) { + raw := strings.TrimSpace(request.URL.Query().Get("limit")) + if raw == "" { + // 0 由 user-service 归一成默认 20,保证 HTTP 和内部 RPC 共用一套上限语义。 + return 0, true + } + parsed, err := strconv.ParseInt(raw, 10, 32) + if err != nil || parsed <= 0 { + return 0, false + } + if parsed > 100 { + parsed = 100 + } + return int32(parsed), true +} + // breakCPRelationship 先在 user-service 创建解除占位,再扣钱包,最后确认关系 ended,避免并发请求重复扣费。 func (h *Handler) breakCPRelationship(writer http.ResponseWriter, request *http.Request) { if h.userCPClient == nil || h.walletClient == nil { @@ -477,6 +540,7 @@ func cpGiftSnapshotFromProto(item *userv1.CPGiftSnapshot) *cpGiftSnapshotData { GiftAnimationURL: item.GetGiftAnimationUrl(), GiftCount: item.GetGiftCount(), GiftValue: item.GetGiftValue(), + CoinSpent: item.GetCoinSpent(), BillingReceiptID: item.GetBillingReceiptId(), } } diff --git a/services/gateway-service/internal/transport/http/userapi/handler.go b/services/gateway-service/internal/transport/http/userapi/handler.go index a12f296e..38b4d258 100644 --- a/services/gateway-service/internal/transport/http/userapi/handler.go +++ b/services/gateway-service/internal/transport/http/userapi/handler.go @@ -112,6 +112,7 @@ func (h *Handler) UserHandlers() httproutes.UserHandlers { RejectCPApplication: h.rejectCPApplication, ListCPRelationships: h.listCPRelationships, ListCPIntimacyLeaderboard: h.listCPIntimacyLeaderboard, + ListCPFormationGiftFeed: h.listCPFormationGiftFeed, BreakCPRelationship: h.breakCPRelationship, GetMyProfile: h.getMyProfile, GetUserProfile: h.getUserProfile, diff --git a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go index 73e6f51b..02b3eeec 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler.go @@ -337,6 +337,7 @@ func (h *Handler) withdrawPointWallet(writer http.ResponseWriter, request *http. httpkit.WriteOK(writer, request, map[string]any{ "application_id": strconv.FormatInt(application.ID, 10), "status": application.Status, + "operations_status": application.OperationsStatus, "asset_type": pointWalletAssetType, "gross_point_amount": grossPoints, "fee_point_amount": feePoints, diff --git a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go index 0f20e58d..2cfa4bd2 100644 --- a/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go +++ b/services/gateway-service/internal/transport/http/walletapi/point_wallet_handler_test.go @@ -110,6 +110,8 @@ func TestWithdrawPointWalletFreezesWithClientCommandIDAndCreatesApplication(t *t } if response.Code != httpkit.CodeOK || response.Data["application_id"] != "77" || + response.Data["status"] != financewithdrawal.StatusPending || + response.Data["operations_status"] != financewithdrawal.OperationsStatusPending || response.Data["gross_point_amount"] != float64(1_000_000) || response.Data["fee_point_amount"] != float64(50_000) || response.Data["net_usd"] != "9.50" { @@ -247,6 +249,7 @@ func (f *fakePointWithdrawalWriter) CreateApplication(_ context.Context, command WithdrawAddress: command.WithdrawAddress, FreezeCommandID: command.FreezeCommandID, FreezeTransactionID: command.FreezeTransactionID, + OperationsStatus: financewithdrawal.OperationsStatusPending, Status: financewithdrawal.StatusPending, CreatedAtMS: command.CreatedAtMS, UpdatedAtMS: command.CreatedAtMS, diff --git a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go index 871253be..2aa1fd73 100644 --- a/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go +++ b/services/gateway-service/internal/transport/http/walletapi/salary_wallet_handler.go @@ -406,6 +406,7 @@ func (h *Handler) withdrawSalaryWallet(writer http.ResponseWriter, request *http httpkit.WriteOK(writer, request, map[string]any{ "application_id": strconv.FormatInt(application.ID, 10), "status": application.Status, + "operations_status": application.OperationsStatus, "identity": identity.Identity, "salary_asset_type": identity.SalaryAssetType, "salary_usd_minor": amountUSDMinor, diff --git a/services/user-service/configs/config.docker.yaml b/services/user-service/configs/config.docker.yaml index f30e1a93..7bca562e 100644 --- a/services/user-service/configs/config.docker.yaml +++ b/services/user-service/configs/config.docker.yaml @@ -114,5 +114,6 @@ jwt: issuer: "hyapp" access_token_ttl_sec: 604800 refresh_token_ttl_sec: 2592000 + refresh_rotation_grace_sec: 30 signing_alg: "HS256" signing_secret: "dev-shared-secret" diff --git a/services/user-service/configs/config.tencent.example.yaml b/services/user-service/configs/config.tencent.example.yaml index d6bf3dc2..f26cbda5 100644 --- a/services/user-service/configs/config.tencent.example.yaml +++ b/services/user-service/configs/config.tencent.example.yaml @@ -120,5 +120,6 @@ jwt: issuer: "hyapp" access_token_ttl_sec: 604800 refresh_token_ttl_sec: 2592000 + refresh_rotation_grace_sec: 30 signing_alg: "HS256" signing_secret: "${USER_SERVICE_JWT_SECRET}" diff --git a/services/user-service/configs/config.yaml b/services/user-service/configs/config.yaml index c26d979c..e2c98528 100644 --- a/services/user-service/configs/config.yaml +++ b/services/user-service/configs/config.yaml @@ -115,5 +115,6 @@ jwt: issuer: "hyapp" access_token_ttl_sec: 604800 refresh_token_ttl_sec: 2592000 + refresh_rotation_grace_sec: 30 signing_alg: "HS256" signing_secret: "dev-shared-secret" diff --git a/services/user-service/deploy/mysql/initdb/001_user_service.sql b/services/user-service/deploy/mysql/initdb/001_user_service.sql index 7e99825c..2a7f14f8 100644 --- a/services/user-service/deploy/mysql/initdb/001_user_service.sql +++ b/services/user-service/deploy/mysql/initdb/001_user_service.sql @@ -551,6 +551,8 @@ CREATE TABLE IF NOT EXISTS user_cp_applications ( gift_animation_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '触发申请的礼物动画', gift_count INT NOT NULL DEFAULT 0 COMMENT '触发申请的礼物数量', gift_value BIGINT NOT NULL DEFAULT 0 COMMENT '触发申请的礼物亲密值/热度值', + gift_coin_value BIGINT NOT NULL DEFAULT 0 COMMENT '触发申请时 wallet 结算出的礼物金币价值;历史缺失时保持 0', + formation_feed_eligible TINYINT(1) NOT NULL DEFAULT 0 COMMENT '成立礼物 feed 固定资格:金币价值严格大于 5000', billing_receipt_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包扣费回执 ID', source_room_event_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '来源 room outbox 事件 ID', source_command_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '来源房间命令 ID', @@ -565,6 +567,7 @@ CREATE TABLE IF NOT EXISTS user_cp_applications ( KEY idx_user_cp_app_pair_status (app_code, requester_user_id, target_user_id, status, relation_type), KEY idx_user_cp_app_admin_created (app_code, created_at_ms, application_id), KEY idx_user_cp_app_admin_filter (app_code, status, relation_type, created_at_ms, application_id), + KEY idx_user_cp_app_formation_feed (app_code, status, relation_type, formation_feed_eligible, decided_at_ms, application_id), KEY idx_user_cp_app_expire (app_code, status, expires_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户 CP 关系申请表'; @@ -1331,6 +1334,12 @@ CREATE TABLE IF NOT EXISTS auth_sessions ( session_id VARCHAR(96) NOT NULL PRIMARY KEY COMMENT '会话 ID', user_id BIGINT NOT NULL COMMENT '用户 ID', refresh_token_hash VARCHAR(128) NOT NULL COMMENT '刷新令牌哈希值', + token_family_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '令牌族根 session ID', + generation BIGINT NOT NULL DEFAULT 0 COMMENT '令牌族内单调代际,首代为 0', + parent_session_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '直接父代 session ID', + rotated_to_session_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '轮换产生的直接子代 session ID', + rotation_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '轮换时间,UTC epoch ms', + rotation_request_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首次轮换 refresh 幂等键', device_id VARCHAR(128) NOT NULL COMMENT '设备 ID', expires_at_ms BIGINT NOT NULL COMMENT '过期时间,UTC epoch ms', last_heartbeat_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最近 App 心跳时间,UTC epoch ms', @@ -1343,10 +1352,44 @@ CREATE TABLE IF NOT EXISTS auth_sessions ( updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', UNIQUE KEY uk_auth_sessions_refresh_token_hash (app_code, refresh_token_hash), KEY idx_auth_sessions_user_id (app_code, user_id), + KEY idx_auth_sessions_token_family (app_code, token_family_id, generation), KEY idx_auth_sessions_heartbeat (app_code, last_heartbeat_at_ms), KEY idx_auth_sessions_expires_at (app_code, expires_at_ms) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='认证会话表'; +-- refresh 轮换结果必须和 session 替换同事务落 MySQL,才能覆盖多实例并发和提交后响应丢失;这里只保存 AES-GCM 密文。 +CREATE TABLE IF NOT EXISTS auth_refresh_outcomes ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + parent_session_id VARCHAR(96) NOT NULL COMMENT '被消费的父 session ID', + child_session_id VARCHAR(96) NOT NULL COMMENT '首次轮换产生的子 session ID', + refresh_request_id VARCHAR(96) NOT NULL COMMENT '客户端 refresh 幂等键;旧客户端由 token hash 派生', + token_ciphertext VARBINARY(4096) NOT NULL COMMENT 'AES-GCM 加密后的完整 token pair', + created_at_ms BIGINT NOT NULL COMMENT '轮换时间,UTC epoch ms', + expires_at_ms BIGINT NOT NULL COMMENT '幂等密文过期时间,UTC epoch ms', + PRIMARY KEY (app_code, parent_session_id), + KEY idx_auth_refresh_outcomes_request (app_code, refresh_request_id), + KEY idx_auth_refresh_outcomes_expires (expires_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='refresh token 轮换短期幂等结果'; + +CREATE TABLE IF NOT EXISTS auth_session_denylist_jobs ( + job_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '补偿任务 ID', + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + session_id VARCHAR(96) NOT NULL COMMENT '需要写入 gateway Redis denylist 的 session ID', + reason VARCHAR(64) NOT NULL COMMENT 'REFRESH_ROTATED、USER_LOGOUT 或 REFRESH_TOKEN_REUSE', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/processing/retryable/delivered', + attempts INT NOT NULL DEFAULT 0 COMMENT '认领次数', + next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下次重试时间,UTC epoch ms', + locked_by VARCHAR(96) NOT NULL DEFAULT '' COMMENT '当前 worker ID', + locked_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '处理租约截止时间,UTC epoch ms', + last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最近失败摘要,不含 token 原文', + deny_until_ms BIGINT NOT NULL COMMENT '该 sid 历史 access JWT 最晚拒绝时刻(含安全余量),UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_auth_session_denylist_job (app_code, session_id), + KEY idx_auth_session_denylist_pending (status, next_retry_at_ms, job_id), + KEY idx_auth_session_denylist_cleanup (deny_until_ms, job_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='session family 撤销到 Redis denylist 的持久补偿任务'; + -- 老环境已经存在 auth_sessions 时,CREATE TABLE IF NOT EXISTS 不会补 App 心跳字段。 SET @ddl := IF( (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND COLUMN_NAME = 'last_heartbeat_at_ms') = 0, diff --git a/services/user-service/deploy/mysql/migrations/017_cp_application_gift_coin_value.sql b/services/user-service/deploy/mysql/migrations/017_cp_application_gift_coin_value.sql new file mode 100644 index 00000000..24d0058a --- /dev/null +++ b/services/user-service/deploy/mysql/migrations/017_cp_application_gift_coin_value.sql @@ -0,0 +1,56 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_user; + +-- 单列追加在 MySQL 8 上使用 INSTANT 元数据变更,不重写 user_cp_applications 大表。 +-- 默认 0 明确表示历史行没有精确 wallet 礼物金币价值;禁止用 gift_value 热度值回填。 +SET @cp_application_gift_coin_value_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_cp_applications' + AND COLUMN_NAME = 'gift_coin_value' +); +SET @cp_application_gift_coin_value_ddl = IF( + @cp_application_gift_coin_value_exists = 0, + 'ALTER TABLE user_cp_applications ADD COLUMN gift_coin_value BIGINT NOT NULL DEFAULT 0 COMMENT ''触发申请时 wallet 结算出的礼物金币价值;历史缺失时保持 0'' AFTER gift_value, ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE cp_application_gift_coin_value_stmt FROM @cp_application_gift_coin_value_ddl; +EXECUTE cp_application_gift_coin_value_stmt; +DEALLOCATE PREPARE cp_application_gift_coin_value_stmt; + +-- 固定资格列让无命中场景走 eligible=1 的空索引范围;历史默认 0,不扫描也不把热度误当金币回填。 +SET @cp_application_formation_feed_eligible_exists = ( + SELECT COUNT(*) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_cp_applications' + AND COLUMN_NAME = 'formation_feed_eligible' +); +SET @cp_application_formation_feed_eligible_ddl = IF( + @cp_application_formation_feed_eligible_exists = 0, + 'ALTER TABLE user_cp_applications ADD COLUMN formation_feed_eligible TINYINT(1) NOT NULL DEFAULT 0 COMMENT ''成立礼物 feed 固定资格:金币价值严格大于 5000'' AFTER gift_coin_value, ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE cp_application_formation_feed_eligible_stmt FROM @cp_application_formation_feed_eligible_ddl; +EXECUTE cp_application_formation_feed_eligible_stmt; +DEALLOCATE PREPARE cp_application_formation_feed_eligible_stmt; + +-- feed 从 eligible=1 的 accepted CP 申请按决定时间倒序扫描;等值前缀同时避免全表扫描与全表排序。 +-- 在线新增索引仍会扫描整张申请表并消耗 IO/临时空间,生产执行应安排低峰并先确认磁盘余量。 +SET @cp_application_formation_feed_index_exists = ( + SELECT COUNT(*) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'user_cp_applications' + AND INDEX_NAME = 'idx_user_cp_app_formation_feed' +); +SET @cp_application_formation_feed_index_ddl = IF( + @cp_application_formation_feed_index_exists = 0, + 'ALTER TABLE user_cp_applications ADD INDEX idx_user_cp_app_formation_feed (app_code, status, relation_type, formation_feed_eligible, decided_at_ms, application_id), ALGORITHM=INPLACE, LOCK=NONE', + 'SELECT 1' +); +PREPARE cp_application_formation_feed_index_stmt FROM @cp_application_formation_feed_index_ddl; +EXECUTE cp_application_formation_feed_index_stmt; +DEALLOCATE PREPARE cp_application_formation_feed_index_stmt; diff --git a/services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql b/services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql new file mode 100644 index 00000000..17774c54 --- /dev/null +++ b/services/user-service/deploy/mysql/migrations/018_auth_refresh_token_rotation.sql @@ -0,0 +1,150 @@ +SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE hyapp_user; + +-- refresh token rotation family + 30 秒幂等结果。 +-- +-- 性能边界:auth_sessions 是登录热表,新增列均为 NOT NULL 常量默认值,MySQL 8.0 使用 +-- ALGORITHM=INSTANT 只改数据字典,不回填全表。MySQL 8.4 禁止 INSTANT 同时显式指定 LOCK 子句; +-- 若目标实例不支持 INSTANT,语句会直接失败, +-- 不能静默退化成 COPY;应在低峰期用云数据库 Online DDL 工具执行同列变更。 +-- family 索引必须扫描 auth_sessions 构建 B-Tree,因此单独使用 INPLACE/LOCK=NONE;它不复制表,但会消耗 IO/CPU, +-- 6 列合并为一次 INSTANT ALTER,只获取一次 MDL 并消耗一个 instant row version;目标表接近 row-version +-- 上限时仍应先检查 INFORMATION_SCHEMA.INNODB_TABLES.TOTAL_ROW_VERSIONS。 +SET @auth_session_column_ddl := CONCAT_WS(', ', + IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND COLUMN_NAME = 'token_family_id') = 0, + 'ADD COLUMN token_family_id VARCHAR(96) NOT NULL DEFAULT '''' COMMENT ''令牌族根 session ID''', NULL), + IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND COLUMN_NAME = 'generation') = 0, + 'ADD COLUMN generation BIGINT NOT NULL DEFAULT 0 COMMENT ''令牌族内单调代际,首代为 0''', NULL), + IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND COLUMN_NAME = 'parent_session_id') = 0, + 'ADD COLUMN parent_session_id VARCHAR(96) NOT NULL DEFAULT '''' COMMENT ''直接父代 session ID''', NULL), + IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND COLUMN_NAME = 'rotated_to_session_id') = 0, + 'ADD COLUMN rotated_to_session_id VARCHAR(96) NOT NULL DEFAULT '''' COMMENT ''轮换产生的直接子代 session ID''', NULL), + IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND COLUMN_NAME = 'rotation_at_ms') = 0, + 'ADD COLUMN rotation_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''轮换时间,UTC epoch ms''', NULL), + IF((SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND COLUMN_NAME = 'rotation_request_id') = 0, + 'ADD COLUMN rotation_request_id VARCHAR(96) NOT NULL DEFAULT '''' COMMENT ''首次轮换 refresh 幂等键''', NULL) +); +SET @ddl := IF( + @auth_session_column_ddl = '', + 'SELECT 1', + CONCAT('ALTER TABLE auth_sessions ', @auth_session_column_ddl, ', ALGORITHM=INSTANT') +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +CREATE TABLE IF NOT EXISTS auth_refresh_outcomes ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + parent_session_id VARCHAR(96) NOT NULL COMMENT '被消费的父 session ID', + child_session_id VARCHAR(96) NOT NULL COMMENT '首次轮换产生的子 session ID', + refresh_request_id VARCHAR(96) NOT NULL COMMENT '客户端 refresh 幂等键;旧客户端由 token hash 派生', + token_ciphertext VARBINARY(4096) NOT NULL COMMENT 'AES-GCM 加密后的完整 token pair', + created_at_ms BIGINT NOT NULL COMMENT '轮换时间,UTC epoch ms', + expires_at_ms BIGINT NOT NULL COMMENT '幂等密文过期时间,UTC epoch ms', + PRIMARY KEY (app_code, parent_session_id), + KEY idx_auth_refresh_outcomes_request (app_code, refresh_request_id), + KEY idx_auth_refresh_outcomes_expires (expires_at_ms) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='refresh token 轮换短期幂等结果'; + +CREATE TABLE IF NOT EXISTS auth_session_denylist_jobs ( + job_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '补偿任务 ID', + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + session_id VARCHAR(96) NOT NULL COMMENT '需要写入 gateway Redis denylist 的 session ID', + reason VARCHAR(64) NOT NULL COMMENT 'REFRESH_ROTATED、USER_LOGOUT 或 REFRESH_TOKEN_REUSE', + status VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT 'pending/processing/retryable/delivered', + attempts INT NOT NULL DEFAULT 0 COMMENT '认领次数', + next_retry_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '下次重试时间,UTC epoch ms', + locked_by VARCHAR(96) NOT NULL DEFAULT '' COMMENT '当前 worker ID', + locked_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '处理租约截止时间,UTC epoch ms', + last_error VARCHAR(512) NOT NULL DEFAULT '' COMMENT '最近失败摘要,不含 token 原文', + deny_until_ms BIGINT NOT NULL COMMENT '该 sid 历史 access JWT 最晚拒绝时刻(含安全余量),UTC epoch ms', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + UNIQUE KEY uk_auth_session_denylist_job (app_code, session_id), + KEY idx_auth_session_denylist_pending (status, next_retry_at_ms, job_id), + KEY idx_auth_session_denylist_cleanup (deny_until_ms, job_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='session family 撤销到 Redis denylist 的持久补偿任务'; + +-- 本 migration 可能在早期试运行版本创建过 job 表;补列使用 INSTANT,不扫描业务热表。 +SET @ddl := IF( + (SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_session_denylist_jobs' AND COLUMN_NAME = 'deny_until_ms') = 0, + 'ALTER TABLE auth_session_denylist_jobs ADD COLUMN deny_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT ''历史 access JWT 最晚拒绝时刻(含安全余量),UTC epoch ms'', ALGORITHM=INSTANT', + 'SELECT 1' +); +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 仅早期试运行 job 表需要一次有界回填;它不是 auth_sessions 热表。按历史最大 7d access TTL + 60s +-- 安全余量恢复 deadline,避免新增列默认 0 后把仍有效保护事实立即清理;生产仍应在低峰观察受影响行数。 +UPDATE auth_session_denylist_jobs +SET deny_until_ms = created_at_ms + 604860000 +WHERE deny_until_ms = 0; + +-- 上线前版本允许同 sid 按 reason 多行;先合并为 deny_until 最大的一行,再改成每 sid 唯一,避免短 TTL job 覆盖长 TTL。 +DELETE older +FROM auth_session_denylist_jobs older +JOIN auth_session_denylist_jobs keeper + ON keeper.app_code = older.app_code + AND keeper.session_id = older.session_id + AND (keeper.deny_until_ms > older.deny_until_ms + OR (keeper.deny_until_ms = older.deny_until_ms AND keeper.job_id > older.job_id)); + +SET @denylist_unique_columns := ( + SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_session_denylist_jobs' AND INDEX_NAME = 'uk_auth_session_denylist_job' +); +SET @denylist_unique_non_unique := ( + SELECT MIN(NON_UNIQUE) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_session_denylist_jobs' AND INDEX_NAME = 'uk_auth_session_denylist_job' +); +SET @ddl := CASE + WHEN @denylist_unique_columns IS NULL THEN + 'ALTER TABLE auth_session_denylist_jobs ADD UNIQUE INDEX uk_auth_session_denylist_job (app_code, session_id), ALGORITHM=INPLACE, LOCK=NONE' + WHEN @denylist_unique_columns <> 'app_code,session_id' OR @denylist_unique_non_unique <> 0 THEN + 'ALTER TABLE auth_session_denylist_jobs DROP INDEX uk_auth_session_denylist_job, ADD UNIQUE INDEX uk_auth_session_denylist_job (app_code, session_id), ALGORITHM=INPLACE, LOCK=NONE' + ELSE 'SELECT 1' +END; +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @denylist_cleanup_columns := ( + SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_session_denylist_jobs' AND INDEX_NAME = 'idx_auth_session_denylist_cleanup' +); +SET @ddl := CASE + WHEN @denylist_cleanup_columns IS NULL THEN + 'ALTER TABLE auth_session_denylist_jobs ADD INDEX idx_auth_session_denylist_cleanup (deny_until_ms, job_id), ALGORITHM=INPLACE, LOCK=NONE' + WHEN @denylist_cleanup_columns <> 'deny_until_ms,job_id' THEN + 'ALTER TABLE auth_session_denylist_jobs DROP INDEX idx_auth_session_denylist_cleanup, ADD INDEX idx_auth_session_denylist_cleanup (deny_until_ms, job_id), ALGORITHM=INPLACE, LOCK=NONE' + ELSE 'SELECT 1' +END; +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +SET @family_index_columns := ( + SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX SEPARATOR ',') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND INDEX_NAME = 'idx_auth_sessions_token_family' +); +SET @family_index_non_unique := ( + SELECT MIN(NON_UNIQUE) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'auth_sessions' AND INDEX_NAME = 'idx_auth_sessions_token_family' +); +SET @ddl := CASE + WHEN @family_index_columns IS NULL THEN + 'ALTER TABLE auth_sessions ADD INDEX idx_auth_sessions_token_family (app_code, token_family_id, generation), ALGORITHM=INPLACE, LOCK=NONE' + WHEN @family_index_columns <> 'app_code,token_family_id,generation' OR @family_index_non_unique <> 1 THEN + 'ALTER TABLE auth_sessions DROP INDEX idx_auth_sessions_token_family, ADD INDEX idx_auth_sessions_token_family (app_code, token_family_id, generation), ALGORITHM=INPLACE, LOCK=NONE' + ELSE 'SELECT 1' +END; +PREPARE stmt FROM @ddl; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; diff --git a/services/user-service/internal/app/app.go b/services/user-service/internal/app/app.go index 1a2680dc..87444a8c 100644 --- a/services/user-service/internal/app/app.go +++ b/services/user-service/internal/app/app.go @@ -30,6 +30,7 @@ import ( "hyapp/pkg/tencentim" "hyapp/pkg/usermq" "hyapp/pkg/walletmq" + "hyapp/pkg/xerr" "hyapp/services/user-service/internal/config" cpdomain "hyapp/services/user-service/internal/domain/cp" invitedomain "hyapp/services/user-service/internal/domain/invite" @@ -87,8 +88,10 @@ type App struct { roomTimeSvc *roomtimeservice.Service // cpSvc 消费 room gift outbox,维护 CP/兄弟/姐妹申请和亲密值。 cpSvc *cpservice.Service - // loginRiskRedisClose 关闭登录风险 Redis cache;cache 不作为启动硬依赖。 + // loginRiskRedisClose 关闭登录风险/session denylist Redis;denylist 是启动硬依赖。 loginRiskRedisClose func() error + // sessionDenylistEpoch 记录完成全量重水合时的 Redis run_id,变化时必须再次重放。 + sessionDenylistEpoch string // cpLeaderboardRedisClose 关闭 CP 亲密榜 Redis 读模型连接。 cpLeaderboardRedisClose func() error // cfg 保存 worker 运行参数,避免 Run 阶段重新读配置。 @@ -153,18 +156,23 @@ func New(cfg config.Config) (*App, error) { var ipDecisionCache authservice.IPDecisionCache var registerRiskConfigCache authservice.RegisterRiskConfigCache var loginRiskRedisClose func() error - if cfg.LoginRisk.Enabled && cfg.LoginRisk.RedisAddr != "" { - redisClient, redisErr := authservice.NewLoginRiskRedisClient(startupCtx, cfg.LoginRisk.RedisAddr, cfg.LoginRisk.RedisPassword, cfg.LoginRisk.RedisDB) - if redisErr == nil { - riskCache := authservice.NewRedisIPDecisionCache(redisClient) - ipDecisionCache = riskCache - registerRiskConfigCache = riskCache - loginRiskRedisClose = redisClient.Close - } else { - // IP 风控 Redis 缓存按文档 fail-open;启动继续,worker 仍会写 MySQL 审计。 - logx.Warn(startupCtx, "login_risk_redis_unavailable") - } + if cfg.LoginRisk.RedisAddr == "" { + _ = listener.Close() + _ = mysqlRepo.Close() + return nil, xerr.New(xerr.Unavailable, "session denylist Redis address is required") } + // IP 风控可关闭,但 session denylist 已是 refresh/logout 的认证安全依赖,必须独立建连并 fail-fast。 + // 启动 Ping 失败时不对外 healthy;否则 Redis 恢复后本进程也没有 client/worker,只会让全量 refresh 永久 UNAVAILABLE。 + redisClient, redisErr := authservice.NewLoginRiskRedisClient(startupCtx, cfg.LoginRisk.RedisAddr, cfg.LoginRisk.RedisPassword, cfg.LoginRisk.RedisDB) + if redisErr != nil { + _ = listener.Close() + _ = mysqlRepo.Close() + return nil, redisErr + } + riskCache := authservice.NewRedisIPDecisionCache(redisClient) + ipDecisionCache = riskCache + registerRiskConfigCache = riskCache + loginRiskRedisClose = redisClient.Close var cpLeaderboardStore cpservice.IntimacyLeaderboardStore var cpLeaderboardRedisClose func() error if cfg.CPIntimacyLeaderboard.Enabled { @@ -276,11 +284,12 @@ func New(cfg config.Config) (*App, error) { // auth service 负责登录、session 和 token;用户主数据和短号事务仍通过各自领域存储完成。 authSvc := authservice.New(authservice.Config{ - Issuer: cfg.JWT.Issuer, - AccessTokenTTLSec: cfg.JWT.AccessTokenTTLSec, - RefreshTokenTTLSec: cfg.JWT.RefreshTokenTTLSec, - SigningAlg: cfg.JWT.SigningAlg, - SigningSecret: cfg.JWT.SigningSecret, + Issuer: cfg.JWT.Issuer, + AccessTokenTTLSec: cfg.JWT.AccessTokenTTLSec, + RefreshTokenTTLSec: cfg.JWT.RefreshTokenTTLSec, + RefreshRotationGraceSec: cfg.JWT.RefreshRotationGraceSec, + SigningAlg: cfg.JWT.SigningAlg, + SigningSecret: cfg.JWT.SigningSecret, }, authservice.WithAuthRepository(authRepo), authservice.WithUserRepository(userRepo), @@ -466,14 +475,20 @@ func (a *App) Run() error { a.health.MarkStopped() return err } - a.runUserOutboxWorker() - // 只有 listener 进入 Serve 后才标记 serving,避免健康检查提前放行。 - a.health.MarkServing() defer func() { a.workers.StopAndWait() shutdownMQConsumers(a.mqConsumers) a.shutdownUserOutboxProducer() }() + if err := a.rehydrateSessionDenylist(context.Background()); err != nil { + // denylist 是 access JWT 撤销的安全事实;Redis/重水合失败时不能先对外 healthy 再放行旧 token。 + a.health.MarkStopped() + return err + } + a.runUserOutboxWorker() + a.runSessionDenylistWorker() + // 只有 listener 进入 Serve 后才标记 serving,避免健康检查提前放行。 + a.health.MarkServing() err := a.server.Serve(a.listener) a.health.MarkStopped() if errors.Is(err, grpc.ErrServerStopped) { @@ -484,6 +499,135 @@ func (a *App) Run() error { return err } +func (a *App) rehydrateSessionDenylist(ctx context.Context) error { + if a.authSvc == nil || a.loginRiskRedisClose == nil { + return xerr.New(xerr.Unavailable, "session denylist Redis is not configured") + } + workerID := "auth-session-denylist-hydrate-" + a.cfg.NodeID + for epochAttempt := 0; epochAttempt < 3; epochAttempt++ { + epochBefore, err := a.authSvc.SessionDenylistEpoch(ctx) + if err != nil { + return err + } + // 每批只读 1000 行且 cursor 循环到固定 max job_id;hydration 不认领/锁 job,随后 drain 新增 pending 后健康检查才可放行。 + if _, err := a.authSvc.RehydrateAllSessionDenylistJobs(ctx, 1000); err != nil { + return err + } + for { + processed, err := a.authSvc.ProcessSessionDenylistBatch(ctx, workerID, 100) + if err != nil { + return err + } + if processed < 100 { + break + } + } + epochAfter, err := a.authSvc.SessionDenylistEpoch(ctx) + if err != nil { + return err + } + if epochBefore == epochAfter { + a.sessionDenylistEpoch = epochAfter + return nil + } + } + return xerr.New(xerr.Unavailable, "session denylist Redis changed during hydration") +} + +func (a *App) runSessionDenylistWorker() { + if a.authSvc == nil || a.loginRiskRedisClose == nil { + // 生产 New 已强制 Redis 连通并在启动 hydration 失败时退出;这里只防御不完整的测试/工具装配。 + return + } + workerID := "auth-session-denylist-" + a.cfg.NodeID + a.workers.Go(func(ctx context.Context) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for { + processed, err := a.authSvc.ProcessSessionDenylistBatch(ctx, workerID, 100) + if err != nil && !errors.Is(err, context.Canceled) { + logx.Error(ctx, "auth_session_denylist_retry_failed", err, slog.String("worker_id", workerID)) + } + if processed >= 100 { + continue + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }) + // Redis AOF/volume 覆盖正常重启;run_id 变化检测再覆盖独立重启/主从切换,且只有 epoch 变化才全量重水合, + // 不用每小时无条件重写全部 sid。INFO 临时失败只告警并在下一轮重试。 + a.workers.Go(func(ctx context.Context) { + lastEpoch := a.sessionDenylistEpoch + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + epoch, err := a.authSvc.SessionDenylistEpoch(ctx) + if err != nil { + if !errors.Is(err, context.Canceled) { + logx.Error(ctx, "auth_session_denylist_epoch_read_failed", err, slog.String("worker_id", workerID)) + } + } else if lastEpoch == "" { + lastEpoch = epoch + } else if epoch != lastEpoch { + if err := a.rehydrateSessionDenylist(ctx); err != nil { + if !errors.Is(err, context.Canceled) { + logx.Error(ctx, "auth_session_denylist_epoch_rehydrate_failed", err, slog.String("worker_id", workerID)) + } + } else { + // rehydrate 内部会在 Redis run_id 变化时重试到稳定 epoch;必须采用它最终确认的值, + // 不能回写本轮调用前捕获的旧 epoch,否则下一轮会重复全量重水合。 + lastEpoch = a.sessionDenylistEpoch + } + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }) + // 已越过 deny_until 的补偿事实按小时小批清理;多实例各跑一次只会造成可接受的幂等索引扫描, + // 不把清理塞进每秒轮询,避免空队列时持续制造无意义 MySQL DELETE QPS。 + a.workers.Go(func(ctx context.Context) { + ticker := time.NewTicker(time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + // 单轮最多 10x1000,批满继续追赶日增量但限制 IO 峰值;连续打满会留下可观测 backlog 告警。 + if saturated, err := a.cleanupAuthSessionPersistence(ctx, 1000, 10); err != nil && !errors.Is(err, context.Canceled) { + logx.Error(ctx, "auth_session_persistence_cleanup_failed", err, slog.String("worker_id", workerID)) + } else if saturated { + logx.Warn(ctx, "auth_session_persistence_cleanup_backlog", slog.String("worker_id", workerID)) + } + } + } + }) +} + +func (a *App) cleanupAuthSessionPersistence(ctx context.Context, batchSize int, maxBatches int) (bool, error) { + for batch := 0; batch < maxBatches; batch++ { + deletedJobs, err := a.authSvc.CleanupExpiredSessionDenylistJobs(ctx, batchSize) + if err != nil { + return false, err + } + deletedOutcomes, err := a.authSvc.CleanupExpiredRefreshOutcomes(ctx, batchSize) + if err != nil { + return false, err + } + if deletedJobs < batchSize && deletedOutcomes < batchSize { + return false, nil + } + } + return true, nil +} + // Close 关闭 gRPC 服务和底层持久化连接。 func (a *App) Close() { a.closeOnce.Do(func() { @@ -876,7 +1020,7 @@ func roomGiftCPEventFromRoomMessage(body []byte) (cpdomain.GiftEvent, bool, erro return cpdomain.GiftEvent{}, false, err } // CP worker 必须接收所有送礼事实:没有 active 关系时仓储层只让 CP/兄弟/姐妹礼物生成申请; - // 已有 active 关系时,普通、幸运和超级幸运礼物都按 wallet 已结算的 GiftValue 增加亲密值。 + // 已有 active 关系时 GiftValue 继续增加亲密值,而 CoinSpent 独立保留 wallet 结算出的礼物金币价值,二者不能互相回填。 return cpdomain.GiftEvent{ AppCode: appcode.Normalize(envelope.GetAppCode()), EventID: envelope.GetEventId(), @@ -888,6 +1032,7 @@ func roomGiftCPEventFromRoomMessage(body []byte) (cpdomain.GiftEvent, bool, erro GiftID: sent.GetGiftId(), GiftCount: sent.GetGiftCount(), GiftValue: sent.GetGiftValue(), + CoinSpent: sent.GetCoinSpent(), BillingReceiptID: sent.GetBillingReceiptId(), VisibleRegionID: sent.GetVisibleRegionId(), CommandID: sent.GetCommandId(), diff --git a/services/user-service/internal/config/config.go b/services/user-service/internal/config/config.go index 697dca98..126b7474 100644 --- a/services/user-service/internal/config/config.go +++ b/services/user-service/internal/config/config.go @@ -267,6 +267,8 @@ type JWTConfig struct { AccessTokenTTLSec int64 `yaml:"access_token_ttl_sec"` // RefreshTokenTTLSec 是服务端 session/refresh token 有效期。 RefreshTokenTTLSec int64 `yaml:"refresh_token_ttl_sec"` + // RefreshRotationGraceSec 是直接上一代 refresh token 幂等恢复窗口,默认 30 秒。 + RefreshRotationGraceSec int64 `yaml:"refresh_rotation_grace_sec"` // SigningAlg 当前只支持 HS256。 SigningAlg string `yaml:"signing_alg"` // SigningSecret 是 HS256 签名密钥,生产不能使用默认开发值。 @@ -356,11 +358,12 @@ func Default() Config { RequestTimeoutMs: 5000, }, JWT: JWTConfig{ - Issuer: "hyapp", - AccessTokenTTLSec: 604800, - RefreshTokenTTLSec: 2592000, - SigningAlg: "HS256", - SigningSecret: "dev-shared-secret", + Issuer: "hyapp", + AccessTokenTTLSec: 604800, + RefreshTokenTTLSec: 2592000, + RefreshRotationGraceSec: 30, + SigningAlg: "HS256", + SigningSecret: "dev-shared-secret", }, Log: logx.Config{ Level: "info", @@ -458,6 +461,9 @@ func Load(path string) (Config, error) { if cfg.LoginRisk.ProviderTimeoutMs <= 0 { cfg.LoginRisk.ProviderTimeoutMs = 800 } + if cfg.JWT.RefreshRotationGraceSec <= 0 { + cfg.JWT.RefreshRotationGraceSec = 30 + } for index, provider := range cfg.LoginRisk.Providers { cfg.LoginRisk.Providers[index] = strings.TrimSpace(provider) } diff --git a/services/user-service/internal/domain/auth/auth.go b/services/user-service/internal/domain/auth/auth.go index d3351aea..fe6a475e 100644 --- a/services/user-service/internal/domain/auth/auth.go +++ b/services/user-service/internal/domain/auth/auth.go @@ -61,6 +61,18 @@ type Session struct { UserID int64 // RefreshTokenHash 是 refresh token 的 hash,持久化层不得保存原文。 RefreshTokenHash string + // TokenFamilyID 把一次登录后连续轮换出的 session 串成同一个安全边界;检测到重放时整族失效。 + TokenFamilyID string + // Generation 是 session 在 token family 内的单调代际,首个登录 session 为 0。 + Generation int64 + // ParentSessionID 指向直接上一代 session;只有直接父代允许命中短暂轮换宽限期。 + ParentSessionID string + // RotatedToSessionID 只在已成功轮换的父 session 上有值,用于恢复同一次轮换的幂等响应。 + RotatedToSessionID string + // RotationAtMs 是父 session 被轮换的 UTC epoch ms,宽限期只能从该事实时间开始计算。 + RotationAtMs int64 + // RotationRequestID 保存首次消费该 refresh token 的客户端幂等键。 + RotationRequestID string // DeviceID 绑定 refresh token 轮换设备,防止跨设备复用。 DeviceID string // ExpiresAtMs 是 refresh session 过期时间。 @@ -83,6 +95,58 @@ type Session struct { UpdatedAtMs int64 } +// RefreshRotationStatus 是 repository 对一次 refresh 消费的原子判定。 +type RefreshRotationStatus string + +const ( + // RefreshRotationCreated 表示当前 refresh token 首次被消费并成功产生下一代。 + RefreshRotationCreated RefreshRotationStatus = "created" + // RefreshRotationGraceHit 表示直接父代在宽限期内重试,必须复用首次落库的同一 token pair。 + RefreshRotationGraceHit RefreshRotationStatus = "grace_hit" + // RefreshRotationReuseDetected 表示旧 token 已越过直接父代或宽限期,整条 token family 已被吊销。 + RefreshRotationReuseDetected RefreshRotationStatus = "reuse_detected" +) + +// RefreshRotationCommand 把候选下一代和加密幂等响应交给 repository 原子提交。 +// TokenCiphertext 是 AES-GCM 密文,数据库永远不保存 access/refresh token 原文。 +type RefreshRotationCommand struct { + AppCode string + RefreshTokenHash string + DeviceID string + RefreshRequestID string + NowMs int64 + GracePeriodMs int64 + // DenyUntilMs 固化本次撤销涉及 access JWT 的最晚拒绝时刻,不能被未来 TTL 配置缩短。 + DenyUntilMs int64 + NewSession Session + TokenCiphertext []byte + OutcomeExpiresAtMs int64 +} + +// RefreshRotationResult 返回轮换、宽限复用或重放撤销的持久化事实。 +type RefreshRotationResult struct { + Status RefreshRotationStatus + SourceSession Session + ActiveSession Session + RefreshRequestID string + TokenCiphertext []byte + FamilySessionIDs []string + FamilyNewlyRevoked bool +} + +// SessionDenylistJob 是 MySQL 持久化的 gateway Redis denylist 补偿事实。 +// family revoke 与 job 同事务提交,避免 MySQL 已撤销而进程崩溃导致旧 access JWT 永久漏拦截。 +type SessionDenylistJob struct { + JobID int64 + AppCode string + SessionID string + Reason string + Attempts int + // DenyUntilMs 是该 sid 的历史 access JWT 全部自然过期(含安全余量)的时刻。 + DenyUntilMs int64 + CreatedAtMs int64 +} + // ThirdPartyIdentity 是 provider + subject 到用户的稳定绑定。 type ThirdPartyIdentity struct { // AppCode 把 provider subject 限定在当前 App 内,避免多个 App 共享 provider 命名空间。 diff --git a/services/user-service/internal/domain/cp/cp.go b/services/user-service/internal/domain/cp/cp.go index e2be586b..128ebb5c 100644 --- a/services/user-service/internal/domain/cp/cp.go +++ b/services/user-service/internal/domain/cp/cp.go @@ -103,9 +103,19 @@ type GiftSnapshot struct { GiftAnimationURL string GiftCount int32 GiftValue int64 + CoinSpent int64 BillingReceiptID string } +// FormationGiftFeedItem 是组 CP 成立礼物的只读展示模型;金额固定使用 wallet 结算出的礼物金币价值,不复用热度字段。 +type FormationGiftFeedItem struct { + FormationID string + GiftCoinValue int64 + Requester UserProfile + Target UserProfile + FormedAtMS int64 +} + // Application 表达一次关系申请,只有 target 用户可以处理 pending 状态。 type Application struct { ApplicationID string @@ -176,6 +186,7 @@ type GiftEvent struct { GiftID string GiftCount int32 GiftValue int64 + CoinSpent int64 BillingReceiptID string VisibleRegionID int64 CommandID string diff --git a/services/user-service/internal/service/auth/audit.go b/services/user-service/internal/service/auth/audit.go index 6e56b16a..11c9c211 100644 --- a/services/user-service/internal/service/auth/audit.go +++ b/services/user-service/internal/service/auth/audit.go @@ -18,6 +18,8 @@ type Meta struct { AppCode string // RequestID 是 gateway 透传的请求 ID,用于登录审计。 RequestID string + // RefreshRequestID 是客户端为一次 refresh 尝试生成的稳定幂等键;网络重试必须复用,普通链路为空。 + RefreshRequestID string // ClientIP 是客户端 IP。 ClientIP string // UserAgent 是客户端 UA。 diff --git a/services/user-service/internal/service/auth/risk_cache.go b/services/user-service/internal/service/auth/risk_cache.go index 35a11b6b..97a2c9a2 100644 --- a/services/user-service/internal/service/auth/risk_cache.go +++ b/services/user-service/internal/service/auth/risk_cache.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "strings" "time" @@ -16,6 +17,23 @@ type RedisIPDecisionCache struct { client *redis.Client } +var setRevokedSessionMaxTTLScript = redis.NewScript(` +local current_ttl = redis.call('PTTL', KEYS[1]) +local requested_ttl = tonumber(ARGV[2]) +if current_ttl == -2 then + redis.call('PSETEX', KEYS[1], requested_ttl, ARGV[1]) + return requested_ttl +end +if current_ttl == -1 then + return current_ttl +end +if current_ttl < requested_ttl then + redis.call('PSETEX', KEYS[1], requested_ttl, ARGV[1]) + return requested_ttl +end +return current_ttl +`) + func NewRedisIPDecisionCache(client *redis.Client) *RedisIPDecisionCache { return &RedisIPDecisionCache{client: client} } @@ -57,7 +75,33 @@ func (c *RedisIPDecisionCache) SetIPDecision(ctx context.Context, appCode string } func (c *RedisIPDecisionCache) SetRevokedSession(ctx context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error { - return c.client.Set(ctx, revokedSessionKey(appCode, sessionID), strings.TrimSpace(reason), ttl).Err() + if c == nil || c.client == nil { + return errors.New("session denylist redis client is unavailable") + } + ttlMs := ttl.Milliseconds() + if ttlMs <= 0 { + return fmt.Errorf("session denylist TTL must be positive") + } + // 多来源会并发撤销同一 sid(rotation/reuse/logout/risk);普通 SET 会让较短旧任务覆盖较长 deny_until。 + // Lua 原子比较 PTTL,只允许延长绝对保护时间;已有永久 key 也绝不被改短。 + return setRevokedSessionMaxTTLScript.Run(ctx, c.client, []string{revokedSessionKey(appCode, sessionID)}, strings.TrimSpace(reason), ttlMs).Err() +} + +func (c *RedisIPDecisionCache) SessionDenylistEpoch(ctx context.Context) (string, error) { + if c == nil || c.client == nil { + return "", errors.New("session denylist redis client is unavailable") + } + info, err := c.client.Info(ctx, "server").Result() + if err != nil { + return "", err + } + for _, line := range strings.Split(info, "\n") { + key, value, found := strings.Cut(strings.TrimSpace(line), ":") + if found && key == "run_id" && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value), nil + } + } + return "", errors.New("redis run_id is unavailable") } func (c *RedisIPDecisionCache) GetIPWhitelist(ctx context.Context, appCode string) ([]string, bool, error) { diff --git a/services/user-service/internal/service/auth/service.go b/services/user-service/internal/service/auth/service.go index d7480dbb..a35ec872 100644 --- a/services/user-service/internal/service/auth/service.go +++ b/services/user-service/internal/service/auth/service.go @@ -3,9 +3,12 @@ package auth import ( "context" + "log/slog" "time" "hyapp/pkg/idgen" + "hyapp/pkg/logx" + "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" invitedomain "hyapp/services/user-service/internal/domain/invite" userdomain "hyapp/services/user-service/internal/domain/user" @@ -52,18 +55,36 @@ type AuthRepository interface { CreateSession(ctx context.Context, session authdomain.Session) error // FindActiveSessionByRefreshHash 通过 refresh token hash 查找未过期未吊销 session。 FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error) + // FindSessionByRefreshHash 返回包含已轮换 session 在内的完整 lineage,用于判断 grace 与 token reuse。 + FindSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) // FindActiveSessionByID 通过 session_id 查找未过期未吊销 session,用于只重签 access token 的链路。 FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error) // FindLatestActiveSessionByUser 返回用户最新未过期未吊销 session,供后台重签 access token;无会话返回 NotFound。 FindLatestActiveSessionByUser(ctx context.Context, userID int64, nowMs int64) (authdomain.Session, error) - // ReplaceSession 原子吊销旧 session 并创建新 session。 - ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error + // RotateRefreshSession 原子完成旧 session 吊销、新 session 创建和加密幂等响应落库。 + RotateRefreshSession(ctx context.Context, command authdomain.RefreshRotationCommand) (authdomain.RefreshRotationResult, error) // RevokeSession 按 session_id 或 refresh token hash 吊销 session。 RevokeSession(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64) (bool, error) + // RevokeSessionFamily 原子吊销显式 logout 所属 token family,并返回需要写 gateway denylist 的全部 sid。 + RevokeSessionFamily(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64, denyUntilMs int64, requestID string) (string, []string, bool, error) // RevokeSessionWithReason 按 session_id 幂等吊销 session,并持久化撤销来源。 RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error) // RevokeUserDeviceSessionsForRisk 按风险源 session 定位同一设备后续 session,吊销 active session,并返回需要写入 denylist 的 session_id。 RevokeUserDeviceSessionsForRisk(ctx context.Context, sourceSessionID string, userID int64, revokedAtMs int64, reason string, requestID string, revokedBy string) ([]string, error) + // ClaimSessionDenylistJobs 认领 family revoke 同事务写出的 Redis denylist 补偿事实。 + ClaimSessionDenylistJobs(ctx context.Context, workerID string, nowMs int64, lockUntilMs int64, limit int) ([]authdomain.SessionDenylistJob, error) + // MarkSessionDenylistJobDelivered 标记 sid 已写入 gateway Redis denylist。 + MarkSessionDenylistJobDelivered(ctx context.Context, jobID int64, appCode string, sessionID string, reason string, appliedDenyUntilMs int64, nowMs int64, nextRehydrateAtMs int64) error + // MarkSessionDenylistJobRetryable 释放失败 job 并安排下一次重试。 + MarkSessionDenylistJobRetryable(ctx context.Context, jobID int64, errorMessage string, nextRetryAtMs int64, nowMs int64) error + // MaxSessionDenylistJobID 固化一次启动重水合的上界,避免并发新 job 让分页永不结束。 + MaxSessionDenylistJobID(ctx context.Context) (int64, error) + // ListSessionDenylistJobsForHydration 忽略 status/lease 按稳定 cursor 读取未过期事实,启动恢复不依赖通用 claim。 + ListSessionDenylistJobsForHydration(ctx context.Context, nowMs int64, afterJobID int64, throughJobID int64, limit int) ([]authdomain.SessionDenylistJob, int64, error) + // DeleteExpiredSessionDenylistJobs 只按持久化 deny_until 清理已经没有有效 access JWT 的事实。 + DeleteExpiredSessionDenylistJobs(ctx context.Context, nowMs int64, limit int) (int, error) + // DeleteExpiredRefreshOutcomes 按 expires_at 索引清理过宽限期的加密 token pair。 + DeleteExpiredRefreshOutcomes(ctx context.Context, nowMs int64, limit int) (int, error) // UpdateSessionHeartbeat 刷新当前 App 登录会话心跳,必须校验 session 仍属于当前用户且未过期未吊销。 UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error) // FindThirdPartyIdentity 查找 provider + subject 的绑定。 @@ -162,12 +183,26 @@ type Config struct { AccessTokenTTLSec int64 // RefreshTokenTTLSec 是服务端 refresh session 有效期。 RefreshTokenTTLSec int64 + // RefreshRotationGraceSec 允许直接上一代 refresh token 在响应丢失或并发时复用首次结果的秒数。 + RefreshRotationGraceSec int64 // SigningAlg 当前只支持 HS256。 SigningAlg string // SigningSecret 是 HS256 签名密钥。 SigningSecret string } +// RefreshMetricRecorder 是 refresh rotation 四个低基数指标的最小记录边界。 +// 默认实现输出稳定结构化事件,现有日志采集可直接按 metric + app_code 聚合计数。 +type RefreshMetricRecorder interface { + Increment(ctx context.Context, metric string, appCode string) +} + +type logRefreshMetricRecorder struct{} + +func (logRefreshMetricRecorder) Increment(ctx context.Context, metric string, appCode string) { + logx.Info(ctx, "auth_refresh_metric", slog.String("metric", metric), slog.String("app_code", appCode)) +} + // LoginRiskPolicy 保存 IP 风控 worker 的判定和缓存策略。 type LoginRiskPolicy struct { Enabled bool @@ -191,6 +226,11 @@ type IPDecisionCache interface { SetUserWhitelist(ctx context.Context, appCode string, userIDs []int64, ttl time.Duration) error } +// SessionDenylistEpochReader 暴露 Redis 进程 run_id;变化意味着重启/failover,需要从 MySQL 全量重水合未过期 job。 +type SessionDenylistEpochReader interface { + SessionDenylistEpoch(ctx context.Context) (string, error) +} + // RegisterRiskConfigCache 是注册风控配置的 Redis 快取边界。 // 该缓存只做读放大优化,MySQL 仍是配置的持久事实来源。 type RegisterRiskConfigCache interface { @@ -255,6 +295,8 @@ type Service struct { registrationLevelBadgeIssuer RegistrationLevelBadgeIssuer // imAccountImporter 在用户创建成功后补齐腾讯 IM 账号;UserSig 入口仍会兜底补偿历史账号。 imAccountImporter IMAccountImporter + // refreshMetrics 只记录固定指标名和 App 维度,避免 session/user 等高基数标签污染指标系统。 + refreshMetrics RefreshMetricRecorder } // Option 调整 auth service 的依赖和策略。 @@ -271,6 +313,7 @@ func New(cfg Config, options ...Option) *Service { now: time.Now, allocateMaxAttempts: 8, loginRiskPolicy: DefaultLoginRiskPolicy(), + refreshMetrics: logRefreshMetricRecorder{}, } for _, option := range options { @@ -281,6 +324,23 @@ func New(cfg Config, options ...Option) *Service { return svc } +// WithRefreshMetricRecorder 允许测试或后续 metrics exporter 注入计数器实现。 +func WithRefreshMetricRecorder(recorder RefreshMetricRecorder) Option { + return func(s *Service) { + if recorder != nil { + s.refreshMetrics = recorder + } + } +} + +func (s *Service) SessionDenylistEpoch(ctx context.Context) (string, error) { + reader, ok := s.ipDecisionCache.(SessionDenylistEpochReader) + if !ok || reader == nil { + return "", xerr.New(xerr.Unavailable, "session denylist Redis epoch is unavailable") + } + return reader.SessionDenylistEpoch(ctx) +} + func DefaultLoginRiskPolicy() LoginRiskPolicy { return LoginRiskPolicy{ Enabled: true, diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index bd7cca34..b815cfb7 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -5,8 +5,10 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" + "os" "strings" "sync" "testing" @@ -20,6 +22,7 @@ import ( "hyapp/services/user-service/internal/testutil/mysqltest" jwt "github.com/golang-jwt/jwt/v5" + "github.com/redis/go-redis/v9" ) const testAccessTokenTTLSec int64 = 604800 @@ -51,6 +54,36 @@ type memoryDecisionCache struct { err error } +type memoryRefreshMetrics struct { + mu sync.Mutex + counts map[string]int +} + +type failingRotateAuthRepository struct { + // 嵌入真实 repository 让测试只替换 rotation commit,其他查找仍走同一个 MySQL schema。 + authservice.AuthRepository +} + +func (r failingRotateAuthRepository) RotateRefreshSession(context.Context, authdomain.RefreshRotationCommand) (authdomain.RefreshRotationResult, error) { + return authdomain.RefreshRotationResult{}, errors.New("forced rotation commit failure") +} + +func newMemoryRefreshMetrics() *memoryRefreshMetrics { + return &memoryRefreshMetrics{counts: make(map[string]int)} +} + +func (m *memoryRefreshMetrics) Increment(_ context.Context, metric string, appCode string) { + m.mu.Lock() + defer m.mu.Unlock() + m.counts[appCode+":"+metric]++ +} + +func (m *memoryRefreshMetrics) count(appCode string, metric string) int { + m.mu.Lock() + defer m.mu.Unlock() + return m.counts[appCode+":"+metric] +} + func newMemoryDecisionCache() *memoryDecisionCache { return &memoryDecisionCache{ ip: make(map[string]authservice.IPDecision), @@ -420,8 +453,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { t.Fatalf("refresh client version audit mismatch: app_version=%q build_number=%q", auditedAppVersion, auditedBuildNumber) } - if _, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) { - t.Fatalf("expected old refresh token revoked, got %v", err) + graceToken, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{}) + if err != nil || graceToken.AccessToken != refreshToken.AccessToken || graceToken.RefreshToken != refreshToken.RefreshToken || graceToken.SessionID != refreshToken.SessionID { + t.Fatalf("direct parent retry must return the exact first token pair: got=%+v err=%v want=%+v", graceToken, err, refreshToken) } revoked, err := svc.Logout(ctx, refreshToken.SessionID, refreshToken.RefreshToken, authservice.Meta{RequestID: "req-logout"}) @@ -439,8 +473,9 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) { } } -func TestRefreshTokenDenylistFailureDoesNotConsumeSession(t *testing.T) { - // refresh 轮换前必须先让旧 sid 进入 gateway denylist;Redis 失败时不能消耗 refresh token。 +func TestRefreshTokenDenylistFailureRecoversPersistedRotationOnRetry(t *testing.T) { + // MySQL 先原子提交 rotation/outcome/job;Redis 短暂失败时首个请求返回 unavailable, + // 同一个旧 refresh token 的重试必须从 grace outcome 恢复同一 token pair,不能再建 child 或分叉。 ctx := context.Background() repository := mysqltest.NewRepository(t) seedCountry(t, repository, "SG") @@ -454,21 +489,751 @@ func TestRefreshTokenDenylistFailureDoesNotConsumeSession(t *testing.T) { } cache.err = errors.New("redis unavailable") - if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-fail"}); err == nil { - t.Fatal("RefreshToken should fail when access denylist write fails") + refreshMeta := authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-fail", RefreshRequestID: "refresh-after-redis-outage"} + if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", refreshMeta); !xerr.IsCode(err, xerr.Unavailable) { + t.Fatalf("RefreshToken should surface unavailable when access denylist write fails: %v", err) + } + var revokedReason string + var childCount int + var outcomeCount int + var pendingJobs int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, token.SessionID).Scan(&revokedReason); err != nil { + t.Fatalf("query committed parent rotation failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_sessions WHERE parent_session_id = ?`, token.SessionID).Scan(&childCount); err != nil { + t.Fatalf("query committed child failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_refresh_outcomes WHERE parent_session_id = ?`, token.SessionID).Scan(&outcomeCount); err != nil { + t.Fatalf("query committed refresh outcome failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE session_id = ? AND status = 'pending'`, token.SessionID).Scan(&pendingJobs); err != nil { + t.Fatalf("query committed denylist job failed: %v", err) + } + if revokedReason != "REFRESH_ROTATED" || childCount != 1 || outcomeCount != 1 || pendingJobs != 1 { + t.Fatalf("rotation transaction mismatch: reason=%q children=%d outcomes=%d pending_jobs=%d", revokedReason, childCount, outcomeCount, pendingJobs) } cache.err = nil - refreshed, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-retry"}) + refreshed, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", refreshMeta) if err != nil { - t.Fatalf("RefreshToken retry should still use original refresh token: %v", err) + t.Fatalf("RefreshToken retry must recover persisted outcome: %v", err) } if refreshed.SessionID == token.SessionID || refreshed.RefreshToken == token.RefreshToken { - t.Fatalf("retry must rotate session and refresh token: old=%+v new=%+v", token, refreshed) + t.Fatalf("recovered outcome must contain rotated session and refresh token: old=%+v new=%+v", token, refreshed) + } + again, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", refreshMeta) + if err != nil || again.AccessToken != refreshed.AccessToken || again.RefreshToken != refreshed.RefreshToken || again.SessionID != refreshed.SessionID { + t.Fatalf("subsequent grace retry must return exact persisted pair: first=%+v again=%+v err=%v", refreshed, again, err) } if got := cache.revoked["lalu:"+token.SessionID]; got != "REFRESH_ROTATED" { t.Fatalf("retry must denylist original session, got %q", got) } + var deliveredJobs int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE session_id = ? AND status = 'delivered'`, token.SessionID).Scan(&deliveredJobs); err != nil { + t.Fatalf("query delivered denylist job failed: %v", err) + } + if deliveredJobs != 1 { + t.Fatalf("successful retry must close the durable denylist job, got %d", deliveredJobs) + } +} + +func TestRefreshTokenDatabaseFailureDoesNotDenyActiveSession(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + normal := newAuthService(repository, &now, []int64{900044}, []string{"100044"}, authservice.WithIPDecisionCache(cache)) + + token, _, err := normal.LoginThirdParty(ctx, "wechat", "openid-refresh-db-failure", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("login failed: %v", err) + } + failing := newAuthService(repository, &now, []int64{900045}, []string{"100045"}, + authservice.WithIPDecisionCache(cache), + authservice.WithAuthRepository(failingRotateAuthRepository{AuthRepository: repository}), + ) + if _, err := failing.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "db-failure"}); err == nil { + t.Fatal("refresh must surface the forced MySQL failure") + } + if got, ok := cache.revoked["lalu:"+token.SessionID]; ok { + t.Fatalf("failed DB rotation must not deny the still-active sid, got %q", got) + } + var revokedAt *int64 + var childCount int + var jobCount int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_at_ms FROM auth_sessions WHERE session_id = ?`, token.SessionID).Scan(&revokedAt); err != nil { + t.Fatalf("query source session failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_sessions WHERE parent_session_id = ?`, token.SessionID).Scan(&childCount); err != nil { + t.Fatalf("query child sessions failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE session_id = ?`, token.SessionID).Scan(&jobCount); err != nil { + t.Fatalf("query denylist jobs failed: %v", err) + } + if revokedAt != nil || childCount != 0 || jobCount != 0 { + t.Fatalf("failed DB rotation changed durable state: revoked_at=%v children=%d jobs=%d", revokedAt, childCount, jobCount) + } +} + +func TestRefreshDenylistDeadlineKeepsHistoricalSevenDayFloor(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + loginSvc := newAuthService(repository, &now, []int64{900046}, []string{"100046"}, authservice.WithIPDecisionCache(cache)) + token, _, err := loginSvc.LoginThirdParty(ctx, "wechat", "openid-short-access-ttl", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("login failed: %v", err) + } + + // 模拟 access TTL 从历史 7 天降到 1 小时;撤销 deadline 仍须覆盖已签发的历史 token。 + shortTTLSvc := authservice.New(authservice.Config{ + Issuer: "hyapp-test", + AccessTokenTTLSec: 3600, + RefreshTokenTTLSec: 2592000, + SigningAlg: "HS256", + SigningSecret: "test-secret", + }, + authservice.WithAuthRepository(repository), + authservice.WithUserRepository(repository), + authservice.WithIdentityRepository(repository), + authservice.WithCountryRegionRepository(repository), + authservice.WithClock(func() time.Time { return now }), + authservice.WithIPDecisionCache(cache), + ) + if _, err := shortTTLSvc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "short-ttl-refresh"}); err != nil { + t.Fatalf("refresh with shorter current TTL failed: %v", err) + } + if got, want := cache.revTTL["lalu:"+token.SessionID], 7*24*time.Hour+time.Minute; got != want { + t.Fatalf("historical access TTL floor was shortened: got=%s want=%s", got, want) + } +} + +func TestSessionDenylistHydrationPaginatesAllStatesAndFailsOnRedisError(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + now := time.UnixMilli(1000) + const jobCount = 1001 + tx, err := repository.RawDB().BeginTx(ctx, nil) + if err != nil { + t.Fatalf("begin denylist seed transaction failed: %v", err) + } + statement, err := tx.PrepareContext(ctx, ` + INSERT INTO auth_session_denylist_jobs + (app_code, session_id, reason, status, attempts, next_retry_at_ms, locked_by, locked_until_ms, last_error, deny_until_ms, created_at_ms, updated_at_ms) + VALUES ('lalu', ?, 'REFRESH_TOKEN_REUSE', ?, 0, ?, ?, ?, '', ?, ?, ?) + `) + if err != nil { + _ = tx.Rollback() + t.Fatalf("prepare denylist seed failed: %v", err) + } + statuses := []string{"delivered", "processing", "retryable"} + for index := 0; index < jobCount; index++ { + status := statuses[index%len(statuses)] + lockedBy := "" + lockedUntilMs := int64(0) + if status == "processing" { + lockedBy = "old-pod" + lockedUntilMs = now.Add(time.Hour).UnixMilli() + } + // retryable 的 next_retry 在未来,证明 hydration 不受 worker 调度状态约束。 + if _, err := statement.ExecContext(ctx, fmt.Sprintf("hydrate-%04d", index), status, now.Add(time.Hour).UnixMilli(), lockedBy, lockedUntilMs, now.Add(8*24*time.Hour).UnixMilli(), now.UnixMilli(), now.UnixMilli()); err != nil { + _ = statement.Close() + _ = tx.Rollback() + t.Fatalf("seed denylist job %d failed: %v", index, err) + } + } + if err := statement.Close(); err != nil { + _ = tx.Rollback() + t.Fatalf("close denylist seed statement failed: %v", err) + } + if err := tx.Commit(); err != nil { + t.Fatalf("commit denylist seeds failed: %v", err) + } + + cache := newMemoryDecisionCache() + svc := newAuthService(repository, &now, []int64{900047}, []string{"100047"}, authservice.WithIPDecisionCache(cache)) + processed, err := svc.RehydrateAllSessionDenylistJobs(ctx, 100) + if err != nil || processed != jobCount { + t.Fatalf("hydrate all denylist jobs failed: processed=%d err=%v", processed, err) + } + if got := len(cache.revoked); got != jobCount { + t.Fatalf("hydration cursor skipped jobs: got=%d want=%d", got, jobCount) + } + for _, index := range []int{0, 500, 1000} { + if got := cache.revoked[fmt.Sprintf("lalu:hydrate-%04d", index)]; got != "REFRESH_TOKEN_REUSE" { + t.Fatalf("hydrated sid %d missing: %q", index, got) + } + } + + failingCache := newMemoryDecisionCache() + failingCache.err = errors.New("redis unavailable") + failingSvc := newAuthService(repository, &now, []int64{900048}, []string{"100048"}, authservice.WithIPDecisionCache(failingCache)) + processed, err = failingSvc.RehydrateAllSessionDenylistJobs(ctx, 100) + if err == nil || processed != 0 { + t.Fatalf("hydration must fail closed on Redis error: processed=%d err=%v", processed, err) + } +} + +func TestSessionDenylistDeliveredCASDoesNotHideExtendedDeadline(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + nowMs := int64(1000) + oldDenyUntilMs := nowMs + int64(time.Hour/time.Millisecond) + newDenyUntilMs := nowMs + int64((2*time.Hour)/time.Millisecond) + result, err := repository.RawDB().ExecContext(ctx, ` + INSERT INTO auth_session_denylist_jobs + (app_code, session_id, reason, status, attempts, next_retry_at_ms, locked_by, locked_until_ms, last_error, deny_until_ms, created_at_ms, updated_at_ms) + VALUES ('lalu', 'cas-extended-sid', 'REFRESH_ROTATED', 'processing', 1, 0, 'old-worker', 5000, '', ?, ?, ?) + `, oldDenyUntilMs, nowMs, nowMs) + if err != nil { + t.Fatalf("seed claimed denylist job failed: %v", err) + } + jobID, err := result.LastInsertId() + if err != nil { + t.Fatalf("read denylist job id failed: %v", err) + } + // 模拟 worker 写 Redis 旧 TTL 的同时,另一事务把同 sid 延长并重新置 pending。 + if _, err := repository.RawDB().ExecContext(ctx, ` + UPDATE auth_session_denylist_jobs + SET deny_until_ms = ?, reason = 'REFRESH_TOKEN_REUSE', status = 'pending', locked_by = '', locked_until_ms = 0 + WHERE job_id = ? + `, newDenyUntilMs, jobID); err != nil { + t.Fatalf("extend denylist deadline failed: %v", err) + } + if err := repository.MarkSessionDenylistJobDelivered(ctx, jobID, "lalu", "cas-extended-sid", "REFRESH_ROTATED", oldDenyUntilMs, nowMs, nowMs+1000); err != nil { + t.Fatalf("stale delivered CAS failed: %v", err) + } + var status string + var denyUntilMs int64 + if err := repository.RawDB().QueryRowContext(ctx, `SELECT status, deny_until_ms FROM auth_session_denylist_jobs WHERE job_id = ?`, jobID).Scan(&status, &denyUntilMs); err != nil { + t.Fatalf("query extended denylist job failed: %v", err) + } + if status != "pending" || denyUntilMs != newDenyUntilMs { + t.Fatalf("stale worker hid extended deadline: status=%s deny_until=%d want=%d", status, denyUntilMs, newDenyUntilMs) + } +} + +func TestRedisSessionDenylistTTLOnlyExtends(t *testing.T) { + redisAddr := strings.TrimSpace(os.Getenv("HYAPP_TEST_REDIS_ADDR")) + if redisAddr == "" { + t.Skip("HYAPP_TEST_REDIS_ADDR is not configured") + } + ctx := context.Background() + client := redis.NewClient(&redis.Options{Addr: redisAddr}) + t.Cleanup(func() { _ = client.Close() }) + if err := client.Ping(ctx).Err(); err != nil { + t.Fatalf("connect test Redis failed: %v", err) + } + const key = "auth:revoked_session:lalu:ttl-only-extends" + t.Cleanup(func() { _ = client.Del(context.Background(), key).Err() }) + if err := client.Del(ctx, key).Err(); err != nil { + t.Fatalf("clear test denylist key failed: %v", err) + } + cache := authservice.NewRedisIPDecisionCache(client) + if err := cache.SetRevokedSession(ctx, "lalu", "ttl-only-extends", "LONG", 10*time.Second); err != nil { + t.Fatalf("write initial denylist TTL failed: %v", err) + } + if err := cache.SetRevokedSession(ctx, "lalu", "ttl-only-extends", "SHORT", time.Second); err != nil { + t.Fatalf("write shorter denylist TTL failed: %v", err) + } + shortAttemptTTL, err := client.PTTL(ctx, key).Result() + if err != nil { + t.Fatalf("read denylist TTL after shorter attempt failed: %v", err) + } + value, err := client.Get(ctx, key).Result() + if err != nil { + t.Fatalf("read denylist reason after shorter attempt failed: %v", err) + } + if shortAttemptTTL < 9*time.Second || value != "LONG" { + t.Fatalf("shorter stale write reduced protection: ttl=%s value=%q", shortAttemptTTL, value) + } + if err := cache.SetRevokedSession(ctx, "lalu", "ttl-only-extends", "EXTENDED", 20*time.Second); err != nil { + t.Fatalf("extend denylist TTL failed: %v", err) + } + extendedTTL, err := client.PTTL(ctx, key).Result() + if err != nil { + t.Fatalf("read extended denylist TTL failed: %v", err) + } + value, err = client.Get(ctx, key).Result() + if err != nil { + t.Fatalf("read extended denylist reason failed: %v", err) + } + if extendedTTL < 19*time.Second || value != "EXTENDED" { + t.Fatalf("longer write did not extend protection: ttl=%s value=%q", extendedTTL, value) + } + if epoch, err := cache.SessionDenylistEpoch(ctx); err != nil || strings.TrimSpace(epoch) == "" { + t.Fatalf("Redis epoch must expose run_id for restart detection: epoch=%q err=%v", epoch, err) + } +} + +func TestRefreshRotationGraceReplayRevokesFamilyAndDurablyRetriesDenylist(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + metrics := newMemoryRefreshMetrics() + svc := newAuthService(repository, &now, []int64{900016}, []string{"100016"}, + authservice.WithIPDecisionCache(cache), + authservice.WithRefreshMetricRecorder(metrics), + ) + + root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-family", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"}) + if err != nil { + t.Fatalf("LoginThirdParty failed: %v", err) + } + now = now.Add(time.Second) + first, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-1", RefreshRequestID: "refresh-attempt-1"}) + if err != nil { + t.Fatalf("first refresh failed: %v", err) + } + now = now.Add(10 * time.Second) + grace, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-2", RefreshRequestID: "refresh-attempt-network-retry"}) + if err != nil || grace.AccessToken != first.AccessToken || grace.RefreshToken != first.RefreshToken || grace.SessionID != first.SessionID { + t.Fatalf("direct predecessor grace must reuse exact token pair: grace=%+v first=%+v err=%v", grace, first, err) + } + now = now.Add(time.Second) + second, err := svc.RefreshToken(ctx, first.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-3", RefreshRequestID: "refresh-attempt-2"}) + if err != nil { + t.Fatalf("second generation refresh failed: %v", err) + } + + var rootFamily string + var rootGeneration int64 + var firstFamily string + var firstGeneration int64 + var secondFamily string + var secondGeneration int64 + if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation FROM auth_sessions WHERE session_id = ?`, root.SessionID).Scan(&rootFamily, &rootGeneration); err != nil { + t.Fatalf("query root lineage failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation FROM auth_sessions WHERE session_id = ?`, first.SessionID).Scan(&firstFamily, &firstGeneration); err != nil { + t.Fatalf("query first lineage failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation FROM auth_sessions WHERE session_id = ?`, second.SessionID).Scan(&secondFamily, &secondGeneration); err != nil { + t.Fatalf("query second lineage failed: %v", err) + } + if rootFamily != root.SessionID || firstFamily != rootFamily || secondFamily != rootFamily || rootGeneration != 0 || firstGeneration != 1 || secondGeneration != 2 { + t.Fatalf("token family lineage mismatch: root=%s/%d first=%s/%d second=%s/%d", rootFamily, rootGeneration, firstFamily, firstGeneration, secondFamily, secondGeneration) + } + + cache.err = errors.New("redis unavailable") + now = now.Add(time.Second) + if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "trace-reuse", RefreshRequestID: "refresh-attempt-reuse"}); !xerr.IsCode(err, xerr.Unavailable) { + t.Fatalf("second-to-last token must revoke family and surface denylist outage: %v", err) + } + var activeReason string + if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, second.SessionID).Scan(&activeReason); err != nil { + t.Fatalf("query active generation after reuse failed: %v", err) + } + if activeReason != "REFRESH_TOKEN_REUSE" { + t.Fatalf("active generation must be revoked on reuse, got %q", activeReason) + } + var pendingJobs int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE app_code = 'lalu' AND reason = 'REFRESH_TOKEN_REUSE' AND status = 'pending'`).Scan(&pendingJobs); err != nil { + t.Fatalf("query durable denylist jobs failed: %v", err) + } + if pendingJobs != 3 { + t.Fatalf("family revoke must persist one denylist job per sid, got %d", pendingJobs) + } + processed, err := svc.ProcessSessionDenylistBatch(ctx, "denylist-test", 10) + if err == nil || processed != 3 { + t.Fatalf("denylist worker must move failed Redis writes to retryable: processed=%d err=%v", processed, err) + } + var retryableJobs int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE app_code = 'lalu' AND reason = 'REFRESH_TOKEN_REUSE' AND status = 'retryable'`).Scan(&retryableJobs); err != nil { + t.Fatalf("query retryable denylist jobs failed: %v", err) + } + if retryableJobs != 3 { + t.Fatalf("failed compensation must leave all jobs retryable, got %d", retryableJobs) + } + cache.err = nil + now = now.Add(3 * time.Second) + processed, err = svc.ProcessSessionDenylistBatch(ctx, "denylist-test", 10) + if err != nil || processed != 3 { + t.Fatalf("denylist retry compensation failed: processed=%d err=%v", processed, err) + } + for _, sessionID := range []string{root.SessionID, first.SessionID, second.SessionID} { + if got := cache.revoked["lalu:"+sessionID]; got != "REFRESH_TOKEN_REUSE" { + t.Fatalf("family sid %s missing replay denylist, got %q", sessionID, got) + } + } + var deliveredJobs int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_session_denylist_jobs WHERE app_code = 'lalu' AND reason = 'REFRESH_TOKEN_REUSE' AND status = 'delivered'`).Scan(&deliveredJobs); err != nil { + t.Fatalf("query delivered denylist jobs failed: %v", err) + } + if deliveredJobs != 3 { + t.Fatalf("all denylist jobs must be delivered, got %d", deliveredJobs) + } + var outcomeCount int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_refresh_outcomes WHERE app_code = 'lalu'`).Scan(&outcomeCount); err != nil { + t.Fatalf("query refresh outcomes failed: %v", err) + } + if outcomeCount != 0 { + t.Fatalf("family revoke must delete encrypted token outcomes, got %d", outcomeCount) + } + if metrics.count("lalu", "refresh_success") != 2 || metrics.count("lalu", "grace_hit") != 1 || metrics.count("lalu", "token_reuse") != 1 || metrics.count("lalu", "family_revoked") != 1 { + t.Fatalf("refresh metrics mismatch: %+v", metrics.counts) + } +} + +func TestConcurrentRefreshRetriesReturnOnePersistedTokenPair(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + metrics := newMemoryRefreshMetrics() + svc := newAuthService(repository, &now, []int64{900019}, []string{"100019"}, + authservice.WithIPDecisionCache(cache), + authservice.WithRefreshMetricRecorder(metrics), + ) + + root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-concurrent-refresh", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"}) + if err != nil { + t.Fatalf("login failed: %v", err) + } + now = now.Add(time.Second) + + const parallel = 20 + start := make(chan struct{}) + results := make(chan authdomain.Token, parallel) + errorsCh := make(chan error, parallel) + var wg sync.WaitGroup + for i := 0; i < parallel; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start + token, refreshErr := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{ + AppCode: "lalu", + RequestID: "trace-concurrent-refresh", + RefreshRequestID: "refresh-request-single-flight", + }) + if refreshErr != nil { + errorsCh <- refreshErr + return + } + results <- token + }() + } + close(start) + wg.Wait() + close(results) + close(errorsCh) + for refreshErr := range errorsCh { + t.Fatalf("concurrent refresh failed: %v", refreshErr) + } + + var canonical authdomain.Token + count := 0 + for token := range results { + if count == 0 { + canonical = token + } else if token.AccessToken != canonical.AccessToken || token.RefreshToken != canonical.RefreshToken || token.SessionID != canonical.SessionID { + t.Fatalf("concurrent retry forked token pair: canonical=%+v got=%+v", canonical, token) + } + count++ + } + if count != parallel { + t.Fatalf("concurrent result count mismatch: got=%d want=%d", count, parallel) + } + + var childCount int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_sessions WHERE app_code = 'lalu' AND parent_session_id = ?`, root.SessionID).Scan(&childCount); err != nil { + t.Fatalf("query refresh child count failed: %v", err) + } + if childCount != 1 { + t.Fatalf("concurrent refresh must create exactly one child, got %d", childCount) + } + var outcomeCount int + if err := repository.RawDB().QueryRowContext(ctx, `SELECT COUNT(*) FROM auth_refresh_outcomes WHERE app_code = 'lalu' AND parent_session_id = ?`, root.SessionID).Scan(&outcomeCount); err != nil { + t.Fatalf("query refresh outcome count failed: %v", err) + } + if outcomeCount != 1 { + t.Fatalf("concurrent refresh must persist exactly one canonical outcome, got %d", outcomeCount) + } + if metrics.count("lalu", "refresh_success") != 1 || metrics.count("lalu", "grace_hit") != parallel-1 { + t.Fatalf("concurrent refresh metrics mismatch: %+v", metrics.counts) + } +} + +func TestRefreshGraceUsesPersistedWinnerDeadlineAcrossConfigRollout(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + winner := newAuthService(repository, &now, []int64{900043}, []string{"100043"}, authservice.WithIPDecisionCache(cache)) + + root, _, err := winner.LoginThirdParty(ctx, "wechat", "openid-refresh-grace-config-rollout", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("login failed: %v", err) + } + now = now.Add(time.Second) + first, err := winner.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "winner-30s-grace"}) + if err != nil { + t.Fatalf("winner refresh failed: %v", err) + } + + // retry 实例模拟滚动配置从 30s 降到 10s;首次事务固化的 30s deadline 才是这次 token pair 的唯一窗口事实。 + retry := authservice.New(authservice.Config{ + Issuer: "hyapp-test", + AccessTokenTTLSec: testAccessTokenTTLSec, + RefreshTokenTTLSec: 2592000, + RefreshRotationGraceSec: 10, + SigningAlg: "HS256", + SigningSecret: "test-secret", + }, + authservice.WithAuthRepository(repository), + authservice.WithClock(func() time.Time { return now }), + authservice.WithIPDecisionCache(cache), + ) + now = now.Add(5 * time.Second) + grace, err := retry.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "retry-on-10s-instance"}) + if err != nil || grace.AccessToken != first.AccessToken || grace.RefreshToken != first.RefreshToken || grace.SessionID != first.SessionID { + t.Fatalf("retry must trust persisted winner deadline and return exact pair: grace=%+v first=%+v err=%v", grace, first, err) + } +} + +func TestLegacyActiveSessionFirstRotationUsesItselfAsFamilyRoot(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + svc := newAuthService(repository, &now, []int64{900040}, []string{"100040"}, authservice.WithIPDecisionCache(cache)) + + legacy, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-legacy-refresh-family", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("login failed: %v", err) + } + // 模拟 018 上线前已存在的 active 行:新增列默认空值,首次新版轮换必须原地升级 lineage,不能另起错误 family。 + if _, err := repository.RawDB().ExecContext(ctx, ` + UPDATE auth_sessions + SET token_family_id = '', generation = 0, parent_session_id = '', rotated_to_session_id = '', rotation_at_ms = 0, rotation_request_id = '' + WHERE app_code = 'lalu' AND session_id = ? + `, legacy.SessionID); err != nil { + t.Fatalf("downgrade session to legacy lineage failed: %v", err) + } + + now = now.Add(time.Second) + child, err := svc.RefreshToken(ctx, legacy.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "legacy-first-refresh"}) + if err != nil { + t.Fatalf("legacy first rotation failed: %v", err) + } + var parentFamily string + var childFamily string + var childGeneration int64 + var childParent string + if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id FROM auth_sessions WHERE session_id = ?`, legacy.SessionID).Scan(&parentFamily); err != nil { + t.Fatalf("query upgraded legacy parent failed: %v", err) + } + if err := repository.RawDB().QueryRowContext(ctx, `SELECT token_family_id, generation, parent_session_id FROM auth_sessions WHERE session_id = ?`, child.SessionID).Scan(&childFamily, &childGeneration, &childParent); err != nil { + t.Fatalf("query legacy child lineage failed: %v", err) + } + if parentFamily != legacy.SessionID || childFamily != legacy.SessionID || childGeneration != 1 || childParent != legacy.SessionID { + t.Fatalf("legacy lineage upgrade mismatch: parent_family=%q child_family=%q generation=%d parent=%q root=%q", parentFamily, childFamily, childGeneration, childParent, legacy.SessionID) + } +} + +func TestLegacyBinaryRotationReplayDoesNotRevokeUnprovenNewSessions(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + metrics := newMemoryRefreshMetrics() + svc := newAuthService(repository, &now, []int64{900042}, []string{"100042"}, + authservice.WithIPDecisionCache(cache), + authservice.WithRefreshMetricRecorder(metrics), + ) + + root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-legacy-binary-rotation", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("ios login failed: %v", err) + } + android, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-legacy-binary-rotation", thirdPartyRegistration("android"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("android login failed: %v", err) + } + + const legacyChildID = "sess_legacy_binary_child" + const legacyRotationAtMs = int64(2000) + // 精确模拟迁移后仍在滚动运行的旧 ReplaceSession:parent 只写 REFRESH_ROTATED,child 的六个 lineage 列全走 DDL 默认值。 + if _, err := repository.RawDB().ExecContext(ctx, ` + UPDATE auth_sessions + SET token_family_id = '', revoked_at_ms = ?, revoked_reason = 'REFRESH_ROTATED', revoked_by = 'refresh_token', + rotated_to_session_id = '', rotation_at_ms = 0, rotation_request_id = '', updated_at_ms = ? + WHERE app_code = 'lalu' AND session_id = ? + `, legacyRotationAtMs, legacyRotationAtMs, root.SessionID); err != nil { + t.Fatalf("simulate legacy parent rotation failed: %v", err) + } + if _, err := repository.RawDB().ExecContext(ctx, ` + INSERT INTO auth_sessions + (app_code, session_id, user_id, refresh_token_hash, token_family_id, generation, parent_session_id, + rotated_to_session_id, rotation_at_ms, rotation_request_id, device_id, expires_at_ms, + last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, + revoked_by, created_at_ms, updated_at_ms) + SELECT app_code, ?, user_id, 'legacy_binary_child_refresh_hash', '', 0, '', '', 0, '', device_id, expires_at_ms, + 0, '', NULL, '', '', '', ?, ? + FROM auth_sessions WHERE app_code = 'lalu' AND session_id = ? + `, legacyChildID, legacyRotationAtMs, legacyRotationAtMs, root.SessionID); err != nil { + t.Fatalf("simulate legacy child insert failed: %v", err) + } + + now = time.UnixMilli(3000) + if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "legacy-parent-replay"}); !xerr.IsCode(err, xerr.SessionRevoked) { + t.Fatalf("legacy parent replay must reject the old token, got %v", err) + } + var childRevokedAt *int64 + var childReason string + if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_at_ms, revoked_reason FROM auth_sessions WHERE session_id = ?`, legacyChildID).Scan(&childRevokedAt, &childReason); err != nil { + t.Fatalf("query unproven legacy child failed: %v", err) + } + if childRevokedAt != nil || childReason != "" { + t.Fatalf("unproven successor must remain active: revoked_at=%v reason=%q", childRevokedAt, childReason) + } + for _, sessionID := range []string{root.SessionID, legacyChildID} { + if got, ok := cache.revoked["lalu:"+sessionID]; ok { + t.Fatalf("legacy replay without provable lineage must not create denylist for sid %s, got %q", sessionID, got) + } + } + if metrics.count("lalu", "token_reuse") != 0 || metrics.count("lalu", "family_revoked") != 0 { + t.Fatalf("unproven legacy replay must not emit family-reuse metrics: %+v", metrics.counts) + } + // 显式 logout 也不能从 legacy root 按 user/device 猜 successor;它只撤销已锁定 source。 + if _, err := svc.Logout(ctx, root.SessionID, "", authservice.Meta{AppCode: "lalu", RequestID: "legacy-root-logout"}); err != nil { + t.Fatalf("legacy root logout failed: %v", err) + } + childRevokedAt = nil + childReason = "" + if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_at_ms, revoked_reason FROM auth_sessions WHERE session_id = ?`, legacyChildID).Scan(&childRevokedAt, &childReason); err != nil { + t.Fatalf("query unproven child after logout failed: %v", err) + } + if childRevokedAt != nil || childReason != "" { + t.Fatalf("legacy logout must not revoke unproven successor: revoked_at=%v reason=%q", childRevokedAt, childReason) + } + if _, ok := cache.revoked["lalu:"+legacyChildID]; ok { + t.Fatal("legacy logout must not denylist unproven successor") + } + if _, ok := cache.revoked["lalu:"+android.SessionID]; ok { + t.Fatal("legacy replay must not revoke another device") + } + if _, err := svc.RefreshToken(ctx, android.RefreshToken, "dev-android", authservice.Meta{AppCode: "lalu", RefreshRequestID: "android-after-legacy-replay"}); err != nil { + t.Fatalf("other device must remain refreshable after legacy replay: %v", err) + } +} + +func TestCorrectRefreshTokenOnWrongDeviceRevokesItsFamilyAsReuse(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + metrics := newMemoryRefreshMetrics() + svc := newAuthService(repository, &now, []int64{900041}, []string{"100041"}, + authservice.WithIPDecisionCache(cache), + authservice.WithRefreshMetricRecorder(metrics), + ) + + root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-wrong-device", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("login failed: %v", err) + } + now = now.Add(time.Second) + if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-android", authservice.Meta{AppCode: "lalu", RefreshRequestID: "wrong-device-replay"}); !xerr.IsCode(err, xerr.SessionRevoked) { + t.Fatalf("correct refresh token on another device must be treated as reuse, got %v", err) + } + var revokedReason string + if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, root.SessionID).Scan(&revokedReason); err != nil { + t.Fatalf("query wrong-device family revoke failed: %v", err) + } + if revokedReason != "REFRESH_TOKEN_REUSE" { + t.Fatalf("wrong-device refresh must revoke token family as reuse, got %q", revokedReason) + } + if got := cache.revoked["lalu:"+root.SessionID]; got != "REFRESH_TOKEN_REUSE" { + t.Fatalf("wrong-device reuse must denylist family access sid, got %q", got) + } + if metrics.count("lalu", "token_reuse") != 1 || metrics.count("lalu", "family_revoked") != 1 { + t.Fatalf("wrong-device reuse metrics mismatch: %+v", metrics.counts) + } +} + +func TestLogoutUsingRotatedParentRevokesOnlyThatDeviceFamily(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + svc := newAuthService(repository, &now, []int64{900017}, []string{"100017"}, authservice.WithIPDecisionCache(cache)) + + iosRoot, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-logout-family", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-ios-login"}) + if err != nil { + t.Fatalf("ios login failed: %v", err) + } + androidRegistration := thirdPartyRegistration("android") + androidRoot, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-logout-family", androidRegistration, authservice.Meta{AppCode: "lalu", RequestID: "req-android-login"}) + if err != nil { + t.Fatalf("android login failed: %v", err) + } + now = now.Add(time.Second) + iosChild, err := svc.RefreshToken(ctx, iosRoot.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "ios-refresh"}) + if err != nil { + t.Fatalf("ios refresh failed: %v", err) + } + + revoked, err := svc.Logout(ctx, iosRoot.SessionID, iosRoot.RefreshToken, authservice.Meta{AppCode: "lalu", RequestID: "logout-parent"}) + if err != nil || !revoked { + t.Fatalf("logout by rotated parent failed: revoked=%v err=%v", revoked, err) + } + if _, err := svc.RefreshToken(ctx, iosChild.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "ios-after-logout"}); !xerr.IsCode(err, xerr.SessionRevoked) { + t.Fatalf("rotated child must be revoked by parent logout, got %v", err) + } + for _, sessionID := range []string{iosRoot.SessionID, iosChild.SessionID} { + if got := cache.revoked["lalu:"+sessionID]; got != "USER_LOGOUT" { + t.Fatalf("ios family sid %s missing logout denylist, got %q", sessionID, got) + } + } + if _, ok := cache.revoked["lalu:"+androidRoot.SessionID]; ok { + t.Fatal("logout must not revoke a different device token family") + } + now = now.Add(time.Second) + if _, err := svc.RefreshToken(ctx, androidRoot.RefreshToken, "dev-android", authservice.Meta{AppCode: "lalu", RefreshRequestID: "android-refresh"}); err != nil { + t.Fatalf("different device family must remain refreshable: %v", err) + } +} + +func TestDirectParentRefreshAfterGraceRevokesFamily(t *testing.T) { + ctx := context.Background() + repository := mysqltest.NewRepository(t) + seedCountry(t, repository, "SG") + now := time.UnixMilli(1000) + cache := newMemoryDecisionCache() + svc := newAuthService(repository, &now, []int64{900018}, []string{"100018"}, authservice.WithIPDecisionCache(cache)) + root, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-grace-expired", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu"}) + if err != nil { + t.Fatalf("login failed: %v", err) + } + now = now.Add(time.Second) + child, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "first"}) + if err != nil { + t.Fatalf("first refresh failed: %v", err) + } + now = now.Add(31 * time.Second) + if _, err := svc.RefreshToken(ctx, root.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RefreshRequestID: "late-retry"}); !xerr.IsCode(err, xerr.SessionRevoked) { + t.Fatalf("direct parent after grace must be treated as reuse, got %v", err) + } + var reason string + if err := repository.RawDB().QueryRowContext(ctx, `SELECT revoked_reason FROM auth_sessions WHERE session_id = ?`, child.SessionID).Scan(&reason); err != nil { + t.Fatalf("query child revoke reason failed: %v", err) + } + if reason != "REFRESH_TOKEN_REUSE" { + t.Fatalf("late predecessor retry must revoke active child, got %q", reason) + } } func TestRefreshAndLogoutRequireSessionDenylistCache(t *testing.T) { diff --git a/services/user-service/internal/service/auth/session_denylist_worker.go b/services/user-service/internal/service/auth/session_denylist_worker.go new file mode 100644 index 00000000..07f4e71e --- /dev/null +++ b/services/user-service/internal/service/auth/session_denylist_worker.go @@ -0,0 +1,119 @@ +package auth + +import ( + "context" + "errors" + "time" + + "hyapp/pkg/xerr" +) + +const ( + sessionDenylistWorkerLockTTL = 15 * time.Second + sessionDenylistRetryDelay = 2 * time.Second + sessionDenylistRehydrateInterval = time.Hour +) + +// ProcessSessionDenylistBatch 把 family revoke 同事务写出的 MySQL 事实补偿到 gateway Redis denylist。 +// Redis 暂时不可用时 job 会回到 retryable;进程崩溃后 processing lease 到期也会被其他实例重新认领。 +func (s *Service) ProcessSessionDenylistBatch(ctx context.Context, workerID string, batchSize int) (int, error) { + if s.authRepository == nil || s.ipDecisionCache == nil { + return 0, xerr.New(xerr.Unavailable, "session denylist worker is unavailable") + } + if batchSize <= 0 { + batchSize = 100 + } + now := s.now() + jobs, err := s.authRepository.ClaimSessionDenylistJobs(ctx, workerID, now.UnixMilli(), now.Add(sessionDenylistWorkerLockTTL).UnixMilli(), batchSize) + if err != nil { + return 0, err + } + var batchErr error + for _, job := range jobs { + if err := s.writeRevokedAccessSessionUntil(ctx, job.AppCode, job.SessionID, job.Reason, job.DenyUntilMs); err != nil { + nextRetryAtMs := now.Add(sessionDenylistRetryDelay).UnixMilli() + if nextRetryAtMs > job.DenyUntilMs { + nextRetryAtMs = job.DenyUntilMs + } + markErr := s.authRepository.MarkSessionDenylistJobRetryable(ctx, job.JobID, err.Error(), nextRetryAtMs, now.UnixMilli()) + batchErr = errors.Join(batchErr, err, markErr) + continue + } + nextRehydrateAtMs := now.Add(sessionDenylistRehydrateInterval).UnixMilli() + if nextRehydrateAtMs > job.DenyUntilMs { + nextRehydrateAtMs = job.DenyUntilMs + } + if err := s.authRepository.MarkSessionDenylistJobDelivered(ctx, job.JobID, job.AppCode, job.SessionID, job.Reason, job.DenyUntilMs, now.UnixMilli(), nextRehydrateAtMs); err != nil { + // Redis 已成功;DB 标记失败只会导致幂等重放,不会削弱安全性。 + batchErr = errors.Join(batchErr, err) + } + } + return len(jobs), batchErr +} + +// CleanupExpiredSessionDenylistJobs 只由低频维护 ticker 调用,避免空队列时每秒给 MySQL 增加 DELETE QPS。 +// 多实例可同时执行:DELETE 使用 (deny_until_ms, job_id) 索引和 LIMIT;到期后所有状态都已无有效 JWT 需要保护。 +func (s *Service) CleanupExpiredSessionDenylistJobs(ctx context.Context, batchSize int) (int, error) { + if s.authRepository == nil { + return 0, xerr.New(xerr.Unavailable, "session denylist repository is unavailable") + } + if batchSize <= 0 { + batchSize = 1000 + } + // 清理只信任每个 job 在撤销事务内固化的 deny_until,未来配置下调不能提前删除历史 7d JWT 的保护事实。 + return s.authRepository.DeleteExpiredSessionDenylistJobs(ctx, s.now().UnixMilli(), batchSize) +} + +// CleanupExpiredRefreshOutcomes 把短期 token-pair 密文清理移出 refresh 热路径;严格走 expires_at 索引和 LIMIT。 +func (s *Service) CleanupExpiredRefreshOutcomes(ctx context.Context, batchSize int) (int, error) { + if s.authRepository == nil { + return 0, xerr.New(xerr.Unavailable, "refresh outcome repository is unavailable") + } + if batchSize <= 0 { + batchSize = 1000 + } + return s.authRepository.DeleteExpiredRefreshOutcomes(ctx, s.now().UnixMilli(), batchSize) +} + +// RehydrateAllSessionDenylistJobs 按固定 max job_id 分页直读全部未过期事实并同步写 Redis。 +// 它忽略 status/lease,不依赖旧 pod 是否 claim;固定上界和单调 cursor 让 >1000 jobs 也有限终止。 +func (s *Service) RehydrateAllSessionDenylistJobs(ctx context.Context, batchSize int) (int, error) { + if s.authRepository == nil || s.ipDecisionCache == nil { + return 0, xerr.New(xerr.Unavailable, "session denylist repository is unavailable") + } + if batchSize <= 0 { + batchSize = 1000 + } + throughJobID, err := s.authRepository.MaxSessionDenylistJobID(ctx) + if err != nil { + return 0, err + } + nowMs := s.now().UnixMilli() + var cursor int64 + total := 0 + for cursor < throughJobID { + jobs, nextCursor, err := s.authRepository.ListSessionDenylistJobsForHydration(ctx, nowMs, cursor, throughJobID, batchSize) + if err != nil { + return total, err + } + if len(jobs) == 0 || nextCursor <= cursor { + break + } + for _, job := range jobs { + if err := s.writeRevokedAccessSessionUntil(ctx, job.AppCode, job.SessionID, job.Reason, job.DenyUntilMs); err != nil { + return total, err + } + nextRehydrateAtMs := nowMs + sessionDenylistRehydrateInterval.Milliseconds() + if nextRehydrateAtMs > job.DenyUntilMs { + nextRehydrateAtMs = job.DenyUntilMs + } + // CAS 只在 job 未被并发延长时标 delivered;延长后的 pending 事实仍由普通 worker 写更长 TTL。 + if err := s.authRepository.MarkSessionDenylistJobDelivered(ctx, job.JobID, job.AppCode, job.SessionID, job.Reason, job.DenyUntilMs, nowMs, nextRehydrateAtMs); err != nil { + return total, err + } + total++ + } + cursor = nextCursor + } + return total, nil +} diff --git a/services/user-service/internal/service/auth/token.go b/services/user-service/internal/service/auth/token.go index 33b4e93d..3a28f7a7 100644 --- a/services/user-service/internal/service/auth/token.go +++ b/services/user-service/internal/service/auth/token.go @@ -2,11 +2,14 @@ package auth import ( "context" + "crypto/aes" + "crypto/cipher" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "strconv" "strings" @@ -20,7 +23,20 @@ import ( userdomain "hyapp/services/user-service/internal/domain/user" ) -// RefreshToken 轮换 refresh token,旧 token 成功使用后立即失效。 +const ( + defaultRefreshRotationGrace = 30 * time.Second + refreshReplayCipherVersion = "v1" + refreshMetricSuccess = "refresh_success" + refreshMetricGraceHit = "grace_hit" + refreshMetricTokenReuse = "token_reuse" + refreshMetricFamilyRevoked = "family_revoked" + revokeReasonRefreshReuse = "REFRESH_TOKEN_REUSE" + sessionDenylistSafetyMargin = 60 * time.Second + // 仓库历史曾签发 7 天 access JWT;即使配置降到 1 小时,首次撤销也必须覆盖这批尚未到期的历史 token。 + sessionDenylistHistoricalAccessTTLFloor = 7 * 24 * time.Hour +) + +// RefreshToken 轮换 refresh token;直接父代在短暂宽限期内只复用首次提交的 token pair,不能产生分叉。 func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceID string, meta Meta) (authdomain.Token, error) { if strings.TrimSpace(refreshToken) == "" || strings.TrimSpace(deviceID) == "" { // refresh token 和 device_id 共同定位并校验会话归属。 @@ -31,58 +47,104 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI } nowMs := s.now().UnixMilli() - // 持久层只保存 refresh token hash,不能用原文查询。 - session, err := s.authRepository.FindActiveSessionByRefreshHash(ctx, hashRefreshToken(refreshToken), nowMs) + refreshTokenHash := hashRefreshToken(refreshToken) + refreshRequestID, err := normalizeRefreshRequestID(meta.RefreshRequestID, refreshTokenHash) if err != nil { - reason := xerr.SessionRevoked - if xerr.IsCode(err, xerr.SessionExpired) { - reason = xerr.SessionExpired + return authdomain.Token{}, err + } + // 必须读取已轮换行才能区分直接父代 grace 和更早代 replay;数据库仍只用 token hash 查询。 + session, err := s.authRepository.FindSessionByRefreshHash(ctx, refreshTokenHash) + if err != nil { + s.audit(ctx, meta, 0, loginRefresh, "", resultFailed, string(xerr.CodeOf(err))) + return authdomain.Token{}, err + } + grace := s.refreshRotationGrace() + command := authdomain.RefreshRotationCommand{ + AppCode: appcode.Normalize(meta.AppCode), + RefreshTokenHash: refreshTokenHash, + DeviceID: strings.TrimSpace(deviceID), + RefreshRequestID: refreshRequestID, + NowMs: nowMs, + GracePeriodMs: grace.Milliseconds(), + DenyUntilMs: s.sessionDenyUntilMs(nowMs), + } + + // 只有当前仍 active、未过期且设备匹配的 session 才准备新一代;已轮换 token 直接交给 repository 原子判断 grace/replay。 + if session.RevokedAtMs == 0 && session.ExpiresAtMs > nowMs && subtle.ConstantTimeCompare([]byte(session.DeviceID), []byte(strings.TrimSpace(deviceID))) == 1 { + // refresh 时重新读取用户,确保禁用状态和靓号过期恢复生效。 + user, freshErr := s.freshUser(ctx, session.UserID, nowMs, meta.RequestID) + if freshErr != nil { + s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(freshErr))) + return authdomain.Token{}, freshErr } - s.audit(ctx, meta, 0, loginRefresh, "", resultFailed, string(reason)) - return authdomain.Token{}, err + if !user.CanLogin() { + s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.UserDisabled)) + return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled") + } + + newSession, newRefreshToken, createErr := s.newRotatedSession(session, deviceID) + if createErr != nil { + return authdomain.Token{}, createErr + } + candidateToken, issueErr := s.issueToken(user, newSession.SessionID, newRefreshToken, newSession.ExpiresAtMs) + if issueErr != nil { + return authdomain.Token{}, issueErr + } + ciphertext, encryptErr := s.encryptRefreshOutcome(candidateToken, session.SessionID, newSession.SessionID, refreshRequestID) + if encryptErr != nil { + return authdomain.Token{}, encryptErr + } + command.NewSession = newSession + command.TokenCiphertext = ciphertext + command.OutcomeExpiresAtMs = nowMs + grace.Milliseconds() + } - if subtle.ConstantTimeCompare([]byte(session.DeviceID), []byte(deviceID)) != 1 { - // refresh token 绑定设备,跨设备使用按 revoked 处理。 - s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked)) - return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked") - } - if session.AppCode != appcode.Normalize(meta.AppCode) { - // refresh token 不能跨 App 使用;即使 refresh hash 泄漏也不能从另一个包名续期。 - s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked)) - return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked") - } - // refresh 时重新读取用户,确保禁用状态和靓号过期恢复生效。 - user, err := s.freshUser(ctx, session.UserID, nowMs, meta.RequestID) + + rotation, err := s.authRepository.RotateRefreshSession(ctx, command) if err != nil { s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err))) return authdomain.Token{}, err } - if !user.CanLogin() { - // 禁用用户不能续期会话。 - s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.UserDisabled)) - return authdomain.Token{}, xerr.New(xerr.UserDisabled, "user is disabled") - } - // refresh token 成功使用后必须轮换,旧 session 立即吊销。 - newSession, newRefreshToken, err := s.newSession(session.AppCode, session.UserID, deviceID) - if err != nil { - return authdomain.Token{}, err + switch rotation.Status { + case authdomain.RefreshRotationCreated, authdomain.RefreshRotationGraceHit: + // 无论本实例是否赢得轮换锁,都从事务内保存的密文恢复结果,保证并发和崩溃重试拿到完全相同的 token pair。 + token, decryptErr := s.decryptRefreshOutcome(rotation) + if decryptErr != nil { + s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable)) + return authdomain.Token{}, xerr.New(xerr.Unavailable, "refresh outcome is unavailable") + } + // 先由 MySQL 原子提交 parent revoke + child + outcome + durable job,再同步写 Redis。 + // 这样 DB deadlock/失败不会把仍 active 的 sid 单独 deny;Redis 失败则返回 UNAVAILABLE,旧 RT 重试从 outcome 恢复同 pair 并再次补写。 + if err := s.writeRevokedAccessSessionUntil(ctx, rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, revokeReasonRefreshRotated, command.DenyUntilMs); err != nil { + s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable)) + return authdomain.Token{}, xerr.New(xerr.Unavailable, "rotated session denylist is unavailable") + } + _ = s.authRepository.MarkSessionDenylistJobDelivered(ctx, 0, rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, revokeReasonRefreshRotated, command.DenyUntilMs, nowMs, nowMs+sessionDenylistRehydrateInterval.Milliseconds()) + if rotation.Status == authdomain.RefreshRotationCreated { + s.refreshMetrics.Increment(ctx, refreshMetricSuccess, rotation.SourceSession.AppCode) + // refresh 是登录态存续的主路径;老设备升级新客户端后,设备档案主要在这里补齐。 + s.recordUserDevice(ctx, meta, rotation.SourceSession.UserID, deviceID) + } else { + s.refreshMetrics.Increment(ctx, refreshMetricGraceHit, rotation.SourceSession.AppCode) + } + s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultSuccess, "") + return token, nil + case authdomain.RefreshRotationReuseDetected: + s.refreshMetrics.Increment(ctx, refreshMetricTokenReuse, rotation.SourceSession.AppCode) + if rotation.FamilyNewlyRevoked { + s.refreshMetrics.Increment(ctx, refreshMetricFamilyRevoked, rotation.SourceSession.AppCode) + } + if err := s.writeRevokedAccessFamilyUntil(ctx, rotation.SourceSession.AppCode, rotation.FamilySessionIDs, revokeReasonRefreshReuse, command.DenyUntilMs); err != nil { + // MySQL 已是 revoked 事实;返回 unavailable 促使调用方重试,后续重试会再次补齐全部 sid denylist。 + s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.Unavailable)) + return authdomain.Token{}, err + } + s.audit(ctx, meta, rotation.SourceSession.UserID, loginRefresh, "", resultFailed, string(xerr.SessionRevoked)) + return authdomain.Token{}, xerr.New(xerr.SessionRevoked, "session revoked") + default: + return authdomain.Token{}, xerr.New(xerr.Unavailable, "refresh rotation result is invalid") } - if err := s.writeRevokedAccessSession(ctx, session.AppCode, session.SessionID, revokeReasonRefreshRotated); err != nil { - // 7 天 access token 下,refresh 轮换后旧 JWT 必须立刻进入 gateway denylist。 - // 否则客户端或泄漏方仍可拿旧 sid 调业务接口直到 JWT 自然过期。 - s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err))) - return authdomain.Token{}, err - } - if err := s.authRepository.ReplaceSession(ctx, session.SessionID, newSession, nowMs); err != nil { - s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err))) - return authdomain.Token{}, err - } - - s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "") - // refresh 是登录态存续的主路径;老设备升级新客户端后,设备档案主要在这里补齐。 - s.recordUserDevice(ctx, meta, session.UserID, deviceID) - return s.issueToken(user, newSession.SessionID, newRefreshToken, newSession.ExpiresAtMs) } // IssueAccessTokenForSession 基于当前 active session 重签 access token,不轮换 refresh token。 @@ -166,7 +228,7 @@ func (s *Service) AdminIssueUserAccessToken(ctx context.Context, userID int64, m return token, session, nil } -// Logout 失效指定 session 或 refresh token。 +// Logout 失效指定 session 所属 token family;与 refresh 并发时也不能留下可继续登录的 orphan child。 func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken string, meta Meta) (bool, error) { if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(refreshToken) == "" { // 至少需要一种 session 定位方式。 @@ -175,47 +237,25 @@ func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken str if s.authRepository == nil { return false, xerr.New(xerr.Unavailable, "auth repository is not configured") } + if s.ipDecisionCache == nil { + // 没有 Redis 写能力时不进入 DB 撤销;运行中 Redis 瞬断则由事务内 denylist job 持久补偿。 + return false, xerr.New(xerr.Unavailable, "session denylist is not configured") + } nowMs := s.now().UnixMilli() - resolvedSessionID := strings.TrimSpace(sessionID) - resolvedAppCode := appcode.Normalize(meta.AppCode) - if resolvedSessionID == "" && strings.TrimSpace(refreshToken) != "" { - // 仅带 refresh token 的 logout 需要先解析 sid,才能让已签出的 access token 在 gateway 立刻失效。 - session, err := s.authRepository.FindActiveSessionByRefreshHash(ctx, hashRefreshToken(refreshToken), nowMs) - if err == nil { - resolvedSessionID = session.SessionID - resolvedAppCode = session.AppCode - } else if !xerr.IsCode(err, xerr.SessionExpired) && !xerr.IsCode(err, xerr.SessionRevoked) { - s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err))) - return false, err - } - } - if resolvedSessionID != "" { - if resolvedAppCode == "" { - // 兼容只传 session_id、且请求没有 App 头的旧调用;active session 自身才是 denylist 分区的可信来源。 - session, err := s.authRepository.FindActiveSessionByID(ctx, resolvedSessionID, nowMs) - if err == nil { - resolvedAppCode = session.AppCode - } else if !xerr.IsCode(err, xerr.SessionExpired) && !xerr.IsCode(err, xerr.SessionRevoked) { - s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err))) - return false, err - } - } - if resolvedAppCode != "" { - if err := s.writeRevokedAccessSession(ctx, resolvedAppCode, resolvedSessionID, revokeReasonUserLogout); err != nil { - // 先写 Redis denylist 再改 MySQL,避免 Redis 不可用时出现“refresh/session 已吊销但旧 JWT 仍可用 7 天”的状态。 - s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err))) - return false, err - } - } - } - - // refreshToken 可能为空;hashRefreshToken 会保持空值,支持仅用 session_id 登出。 - revoked, err := s.authRepository.RevokeSession(ctx, sessionID, hashRefreshToken(refreshToken), nowMs) + denyUntilMs := s.sessionDenyUntilMs(nowMs) + // repository 读取包含已轮换父代在内的 lineage,并在同事务写每个 sid 的 denylist 补偿 job。 + resolvedAppCode, sessionIDs, revoked, err := s.authRepository.RevokeSessionFamily(ctx, strings.TrimSpace(sessionID), hashRefreshToken(refreshToken), nowMs, denyUntilMs, meta.RequestID) if err != nil { s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err))) return false, err } + if len(sessionIDs) > 0 { + if err := s.writeRevokedAccessFamilyUntil(ctx, resolvedAppCode, sessionIDs, revokeReasonUserLogout, denyUntilMs); err != nil { + s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.Unavailable)) + return false, err + } + } s.audit(ctx, meta, 0, "logout", "", resultSuccess, "") return revoked, nil @@ -261,7 +301,7 @@ func (s *Service) newSession(appCode string, userID int64, deviceID string) (aut nowMs := s.now().UnixMilli() // SessionID 使用独立前缀,便于日志和排障识别。 sessionID := idgen.New("sess") - return authdomain.Session{ + session := authdomain.Session{ AppCode: appcode.Normalize(appCode), SessionID: sessionID, UserID: userID, @@ -270,7 +310,107 @@ func (s *Service) newSession(appCode string, userID int64, deviceID string) (aut ExpiresAtMs: nowMs + s.cfg.RefreshTokenTTLSec*1000, CreatedAtMs: nowMs, UpdatedAtMs: nowMs, - }, refreshToken, nil + } + // 首个登录 session 自己就是 family root;后续轮换只继承该 ID 并递增 generation。 + session.TokenFamilyID = session.SessionID + return session, refreshToken, nil +} + +func (s *Service) newRotatedSession(parent authdomain.Session, deviceID string) (authdomain.Session, string, error) { + child, refreshToken, err := s.newSession(parent.AppCode, parent.UserID, deviceID) + if err != nil { + return authdomain.Session{}, "", err + } + familyID := strings.TrimSpace(parent.TokenFamilyID) + if familyID == "" { + // 兼容迁移前创建、尚未轮换的 active session:第一次新版轮换时把自身升级为 family root。 + familyID = parent.SessionID + } + child.TokenFamilyID = familyID + child.Generation = parent.Generation + 1 + child.ParentSessionID = parent.SessionID + return child, refreshToken, nil +} + +func (s *Service) refreshRotationGrace() time.Duration { + if s.cfg.RefreshRotationGraceSec <= 0 { + return defaultRefreshRotationGrace + } + return time.Duration(s.cfg.RefreshRotationGraceSec) * time.Second +} + +func normalizeRefreshRequestID(refreshRequestID string, refreshTokenHash string) (string, error) { + refreshRequestID = strings.TrimSpace(refreshRequestID) + if refreshRequestID == "" { + // 旧客户端每次 HTTP request_id 都会变化,不能拿它当幂等键;只从已 hash 的 predecessor token 派生稳定兼容键。 + if len(refreshTokenHash) < 48 { + return "", xerr.New(xerr.InvalidArgument, "refresh token hash is invalid") + } + refreshRequestID = "legacy_" + refreshTokenHash[:48] + } + if len(refreshRequestID) > 96 { + return "", xerr.New(xerr.InvalidArgument, "refresh_request_id is too long") + } + return refreshRequestID, nil +} + +func (s *Service) encryptRefreshOutcome(token authdomain.Token, parentSessionID string, childSessionID string, refreshRequestID string) ([]byte, error) { + plaintext, err := json.Marshal(token) + if err != nil { + return nil, err + } + gcm, err := s.refreshReplayCipher() + if err != nil { + return nil, err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, err + } + // nonce 前置于密文;AAD 绑定父子 lineage 和幂等键,数据库行被搬移后无法解密成可用 token。 + return gcm.Seal(nonce, nonce, plaintext, refreshOutcomeAAD(token.AppCode, parentSessionID, childSessionID, refreshRequestID)), nil +} + +func (s *Service) decryptRefreshOutcome(rotation authdomain.RefreshRotationResult) (authdomain.Token, error) { + gcm, err := s.refreshReplayCipher() + if err != nil { + return authdomain.Token{}, err + } + if len(rotation.TokenCiphertext) <= gcm.NonceSize() { + return authdomain.Token{}, fmt.Errorf("refresh outcome ciphertext is empty") + } + nonce := rotation.TokenCiphertext[:gcm.NonceSize()] + ciphertext := rotation.TokenCiphertext[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, refreshOutcomeAAD(rotation.SourceSession.AppCode, rotation.SourceSession.SessionID, rotation.ActiveSession.SessionID, rotation.RefreshRequestID)) + if err != nil { + return authdomain.Token{}, err + } + var token authdomain.Token + if err := json.Unmarshal(plaintext, &token); err != nil { + return authdomain.Token{}, err + } + if token.SessionID != rotation.ActiveSession.SessionID || token.AppCode != rotation.SourceSession.AppCode || token.RefreshToken == "" || token.AccessToken == "" { + // 密文即使能通过认证,也必须和事务返回的 child session 一致,避免错误行关联泄露另一个会话结果。 + return authdomain.Token{}, fmt.Errorf("refresh outcome lineage mismatch") + } + return token, nil +} + +func (s *Service) refreshReplayCipher() (cipher.AEAD, error) { + if strings.TrimSpace(s.cfg.SigningSecret) == "" { + return nil, fmt.Errorf("signing secret is empty") + } + // 使用带用途前缀的 SHA-256 派生独立 AES-256 key,避免把 JWT HMAC key 直接作为分组密码 key。 + key := sha256.Sum256([]byte("hyapp-auth-refresh-replay:" + refreshReplayCipherVersion + "\x00" + s.cfg.SigningSecret)) + block, err := aes.NewCipher(key[:]) + if err != nil { + return nil, err + } + return cipher.NewGCM(block) +} + +func refreshOutcomeAAD(appCode string, parentSessionID string, childSessionID string, refreshRequestID string) []byte { + return []byte(strings.Join([]string{refreshReplayCipherVersion, appcode.Normalize(appCode), parentSessionID, childSessionID, refreshRequestID}, "\x00")) } func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) { @@ -336,6 +476,10 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke } func (s *Service) writeRevokedAccessSession(ctx context.Context, appCode string, sessionID string, reason string) error { + return s.writeRevokedAccessSessionUntil(ctx, appCode, sessionID, reason, s.sessionDenyUntilMs(s.now().UnixMilli())) +} + +func (s *Service) writeRevokedAccessSessionUntil(ctx context.Context, appCode string, sessionID string, reason string, denyUntilMs int64) error { if strings.TrimSpace(sessionID) == "" { return nil } @@ -343,13 +487,39 @@ func (s *Service) writeRevokedAccessSession(ctx context.Context, appCode string, // refresh/logout 一旦成功,gateway 必须能立刻拒绝旧 access token;缺少 denylist 不能假装吊销成功。 return xerr.New(xerr.Unavailable, "session denylist is not configured") } - ttl := s.loginRiskPolicy.DenylistTTL + ttl := time.Until(time.UnixMilli(denyUntilMs)) + if s.now != nil { + ttl = time.UnixMilli(denyUntilMs).Sub(s.now()) + } if ttl <= 0 { - ttl = time.Duration(s.cfg.AccessTokenTTLSec+60) * time.Second + // deny_until 已过说明该 sid 的 access JWT 已全部自然失效,无需再写 Redis。 + return nil } return s.ipDecisionCache.SetRevokedSession(ctx, appcode.Normalize(appCode), sessionID, reason, ttl) } +func (s *Service) writeRevokedAccessFamilyUntil(ctx context.Context, appCode string, sessionIDs []string, reason string, denyUntilMs int64) error { + // replay 是罕见安全事件,可以逐 sid 补齐 denylist;不能只写当前 active child,否则 family 内尚未自然过期的旧 JWT 仍能通过 gateway。 + for _, sessionID := range sessionIDs { + if err := s.writeRevokedAccessSessionUntil(ctx, appCode, sessionID, reason, denyUntilMs); err != nil { + return xerr.New(xerr.Unavailable, "session family denylist is unavailable") + } + // family revoke 事务已经写入持久 job;即时 Redis 成功后尽量标 delivered,标记失败会由 worker 幂等重放。 + nowMs := s.now().UnixMilli() + _ = s.authRepository.MarkSessionDenylistJobDelivered(ctx, 0, appCode, sessionID, reason, denyUntilMs, nowMs, nowMs+sessionDenylistRehydrateInterval.Milliseconds()) + } + return nil +} + +func (s *Service) sessionDenyUntilMs(nowMs int64) int64 { + accessTTL := time.Duration(s.cfg.AccessTokenTTLSec) * time.Second + if accessTTL < sessionDenylistHistoricalAccessTTLFloor { + // 这个 floor 只能随历史最大签发 TTL 上调,不能因当前配置下降而下调。 + accessTTL = sessionDenylistHistoricalAccessTTLFloor + } + return nowMs + (accessTTL + sessionDenylistSafetyMargin).Milliseconds() +} + func tokenOnboardingStatus(user userdomain.User) string { // 旧测试数据或手工种子可能没有显式状态;token 按 profile_completed 推导安全默认值。 if user.OnboardingStatus != "" { diff --git a/services/user-service/internal/service/cp/service.go b/services/user-service/internal/service/cp/service.go index 3eb238c9..8d566306 100644 --- a/services/user-service/internal/service/cp/service.go +++ b/services/user-service/internal/service/cp/service.go @@ -16,6 +16,8 @@ const ( maxIntimacyLeaderboardPageSize = int32(100) defaultIntimacyLeaderboardRows = 10000 maxIntimacyLeaderboardRows = 10000 + defaultFormationGiftFeedLimit = int32(20) + maxFormationGiftFeedLimit = int32(100) ) // Repository 描述 CP 关系 service 需要的持久化能力,便于后续单元测试替换 MySQL 实现。 @@ -32,6 +34,12 @@ type Repository interface { ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int) ([]cpdomain.WeeklyRankEntry, error) } +// FormationGiftFeedRepository 单独描述成立礼物 feed 的只读查询,避免把展示 feed 混进累计亲密榜接口。 +// 该能力保持为独立扩展接口,使不关心 feed 的 CP service 替身不必实现无关读取。 +type FormationGiftFeedRepository interface { + ListFormationGiftFeed(ctx context.Context, limit int) ([]cpdomain.FormationGiftFeedItem, error) +} + // IntimacyLeaderboardStore 是 CP 亲密榜 Redis zset 读模型边界。 type IntimacyLeaderboardStore interface { ReplaceIntimacyLeaderboard(ctx context.Context, appCode string, entries []cpdomain.IntimacyLeaderboardEntry) error @@ -159,9 +167,31 @@ func (s *Service) ConsumeGiftEvent(ctx context.Context, event cpdomain.GiftEvent if event.GiftValue < 0 { event.GiftValue = 0 } + // coin_spent 是 wallet 结算出的礼物金币价值;缺失或非法的历史事件保留 0,绝不能用 gift_value 的热度口径兜底。 + if event.CoinSpent < 0 { + event.CoinSpent = 0 + } return s.repo.ConsumeGiftEvent(ctx, event, s.nowMs()) } +// ListFormationGiftFeed 返回已接受的 CP 成立礼物事件流;门槛由仓储固定,调用方只能控制有上限的条数。 +func (s *Service) ListFormationGiftFeed(ctx context.Context, limit int32) ([]cpdomain.FormationGiftFeedItem, error) { + if s == nil || s.repo == nil { + return nil, xerr.New(xerr.Unavailable, "cp repository is not configured") + } + feedRepo, ok := s.repo.(FormationGiftFeedRepository) + if !ok { + return nil, xerr.New(xerr.Unavailable, "cp formation gift feed repository is not configured") + } + if limit <= 0 { + limit = defaultFormationGiftFeedLimit + } + if limit > maxFormationGiftFeedLimit { + limit = maxFormationGiftFeedLimit + } + return feedRepo.ListFormationGiftFeed(ctx, int(limit)) +} + // ListWeeklyRankEntries 给 activity-service 的结算链路读取 CP 周榜快照;它不写 Redis,也不发奖。 func (s *Service) ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int32) ([]cpdomain.WeeklyRankEntry, int64, error) { if s == nil || s.repo == nil { diff --git a/services/user-service/internal/storage/mysql/auth/session_denylist_jobs.go b/services/user-service/internal/storage/mysql/auth/session_denylist_jobs.go new file mode 100644 index 00000000..3572b3ed --- /dev/null +++ b/services/user-service/internal/storage/mysql/auth/session_denylist_jobs.go @@ -0,0 +1,190 @@ +package auth + +import ( + "context" + "database/sql" + "strings" + + "hyapp/pkg/appcode" + authdomain "hyapp/services/user-service/internal/domain/auth" +) + +func enqueueSessionDenylistJobs(ctx context.Context, tx *sql.Tx, appCode string, sessionIDs []string, reason string, nowMs int64, denyUntilMs int64) error { + if denyUntilMs <= nowMs { + return sql.ErrNoRows + } + for _, sessionID := range sessionIDs { + // unique key 让 repeated replay/logout 复用同一事实。若新撤销把 deny_until 延长,必须重新 pending; + // 否则 DB commit 后进程崩溃会让旧 delivered 状态掩盖尚未写入 Redis 的新 TTL。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO auth_session_denylist_jobs + (app_code, session_id, reason, status, attempts, next_retry_at_ms, locked_by, locked_until_ms, last_error, deny_until_ms, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, 'pending', 0, ?, '', 0, '', ?, ?, ?) + ON DUPLICATE KEY UPDATE + next_retry_at_ms = IF(VALUES(deny_until_ms) > deny_until_ms OR status <> 'delivered', VALUES(next_retry_at_ms), next_retry_at_ms), + locked_by = IF(VALUES(deny_until_ms) > deny_until_ms OR status <> 'delivered', '', locked_by), + locked_until_ms = IF(VALUES(deny_until_ms) > deny_until_ms OR status <> 'delivered', 0, locked_until_ms), + status = IF(VALUES(deny_until_ms) > deny_until_ms OR status <> 'delivered', 'pending', 'delivered'), + reason = IF(VALUES(deny_until_ms) >= deny_until_ms, VALUES(reason), reason), + deny_until_ms = GREATEST(deny_until_ms, VALUES(deny_until_ms)), + updated_at_ms = VALUES(updated_at_ms) + `, appcode.Normalize(appCode), sessionID, reason, nowMs, denyUntilMs, nowMs, nowMs); err != nil { + return err + } + } + return nil +} + +func (r *Repository) ClaimSessionDenylistJobs(ctx context.Context, workerID string, nowMs int64, lockUntilMs int64, limit int) ([]authdomain.SessionDenylistJob, error) { + if limit <= 0 { + limit = 100 + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return nil, err + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, ` + SELECT job_id, app_code, session_id, reason, attempts, deny_until_ms, created_at_ms + FROM auth_session_denylist_jobs + WHERE deny_until_ms > ? AND ((status IN ('pending', 'retryable') AND next_retry_at_ms <= ?) + OR (status = 'processing' AND locked_until_ms <= ?) + ) + ORDER BY next_retry_at_ms, job_id + LIMIT ? + FOR UPDATE SKIP LOCKED + `, nowMs, nowMs, nowMs, limit) + if err != nil { + return nil, err + } + var jobs []authdomain.SessionDenylistJob + for rows.Next() { + var job authdomain.SessionDenylistJob + if err := rows.Scan(&job.JobID, &job.AppCode, &job.SessionID, &job.Reason, &job.Attempts, &job.DenyUntilMs, &job.CreatedAtMs); err != nil { + _ = rows.Close() + return nil, err + } + jobs = append(jobs, job) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + for _, job := range jobs { + if _, err := tx.ExecContext(ctx, ` + UPDATE auth_session_denylist_jobs + SET status = 'processing', attempts = attempts + 1, locked_by = ?, locked_until_ms = ?, updated_at_ms = ? + WHERE job_id = ? + `, strings.TrimSpace(workerID), lockUntilMs, nowMs, job.JobID); err != nil { + return nil, err + } + } + if err := tx.Commit(); err != nil { + return nil, err + } + return jobs, nil +} + +func (r *Repository) MarkSessionDenylistJobDelivered(ctx context.Context, jobID int64, appCode string, sessionID string, reason string, appliedDenyUntilMs int64, nowMs int64, nextRehydrateAtMs int64) error { + if jobID > 0 { + _, err := r.db.ExecContext(ctx, ` + UPDATE auth_session_denylist_jobs + SET status = 'delivered', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0, last_error = '', updated_at_ms = ? + WHERE job_id = ? AND deny_until_ms <= ? + `, nextRehydrateAtMs, nowMs, jobID, appliedDenyUntilMs) + return err + } + _, err := r.db.ExecContext(ctx, ` + UPDATE auth_session_denylist_jobs + SET status = 'delivered', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0, last_error = '', updated_at_ms = ? + WHERE app_code = ? AND session_id = ? AND deny_until_ms <= ? + `, nextRehydrateAtMs, nowMs, appcode.Normalize(appCode), sessionID, appliedDenyUntilMs) + return err +} + +func (r *Repository) MarkSessionDenylistJobRetryable(ctx context.Context, jobID int64, errorMessage string, nextRetryAtMs int64, nowMs int64) error { + if len(errorMessage) > 512 { + errorMessage = errorMessage[:512] + } + _, err := r.db.ExecContext(ctx, ` + UPDATE auth_session_denylist_jobs + SET status = 'retryable', next_retry_at_ms = ?, locked_by = '', locked_until_ms = 0, last_error = ?, updated_at_ms = ? + WHERE job_id = ? + `, nextRetryAtMs, errorMessage, nowMs, jobID) + return err +} + +func (r *Repository) MaxSessionDenylistJobID(ctx context.Context) (int64, error) { + var maxJobID int64 + err := r.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(job_id), 0) FROM auth_session_denylist_jobs`).Scan(&maxJobID) + return maxJobID, err +} + +func (r *Repository) ListSessionDenylistJobsForHydration(ctx context.Context, nowMs int64, afterJobID int64, throughJobID int64, limit int) ([]authdomain.SessionDenylistJob, int64, error) { + if limit <= 0 { + limit = 1000 + } + // hydration 是只读 keyset snapshot:不看 status/lease、不 SKIP LOCKED,也不依赖旧 pod 的 worker 状态。 + rows, err := r.db.QueryContext(ctx, ` + SELECT job_id, app_code, session_id, reason, attempts, deny_until_ms, created_at_ms + FROM auth_session_denylist_jobs + WHERE deny_until_ms > ? AND job_id > ? AND job_id <= ? + ORDER BY job_id + LIMIT ? + `, nowMs, afterJobID, throughJobID, limit) + if err != nil { + return nil, afterJobID, err + } + defer rows.Close() + jobs := make([]authdomain.SessionDenylistJob, 0, limit) + for rows.Next() { + var job authdomain.SessionDenylistJob + if err := rows.Scan(&job.JobID, &job.AppCode, &job.SessionID, &job.Reason, &job.Attempts, &job.DenyUntilMs, &job.CreatedAtMs); err != nil { + return nil, afterJobID, err + } + jobs = append(jobs, job) + } + if err := rows.Err(); err != nil { + return nil, afterJobID, err + } + if len(jobs) == 0 { + return jobs, afterJobID, nil + } + return jobs, jobs[len(jobs)-1].JobID, nil +} + +func (r *Repository) DeleteExpiredSessionDenylistJobs(ctx context.Context, nowMs int64, limit int) (int, error) { + if limit <= 0 { + limit = 1000 + } + result, err := r.db.ExecContext(ctx, ` + DELETE FROM auth_session_denylist_jobs + WHERE deny_until_ms <= ? + ORDER BY deny_until_ms, job_id + LIMIT ? + `, nowMs, limit) + if err != nil { + return 0, err + } + affected, err := result.RowsAffected() + return int(affected), err +} + +func (r *Repository) DeleteExpiredRefreshOutcomes(ctx context.Context, nowMs int64, limit int) (int, error) { + if limit <= 0 { + limit = 1000 + } + result, err := r.db.ExecContext(ctx, ` + DELETE FROM auth_refresh_outcomes + WHERE expires_at_ms < ? + ORDER BY expires_at_ms + LIMIT ? + `, nowMs, limit) + if err != nil { + return 0, err + } + affected, err := result.RowsAffected() + return int(affected), err +} diff --git a/services/user-service/internal/storage/mysql/auth/sessions.go b/services/user-service/internal/storage/mysql/auth/sessions.go index 362cbfe5..252937df 100644 --- a/services/user-service/internal/storage/mysql/auth/sessions.go +++ b/services/user-service/internal/storage/mysql/auth/sessions.go @@ -2,19 +2,23 @@ package auth import ( "context" + "crypto/subtle" "database/sql" + "strings" "hyapp/pkg/appcode" "hyapp/pkg/xerr" authdomain "hyapp/services/user-service/internal/domain/auth" ) +const sessionProjection = `app_code, session_id, user_id, refresh_token_hash, token_family_id, generation, parent_session_id, rotated_to_session_id, rotation_at_ms, rotation_request_id, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms` + func (r *Repository) CreateSession(ctx context.Context, session authdomain.Session) error { // refresh token 只保存 hash,原文不会写入数据库。 _, err := r.db.ExecContext(ctx, ` - INSERT INTO auth_sessions (app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, '', '', '', ?, ?) - `, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.LastHeartbeatAtMs, session.LastHeartbeatRequestID, session.CreatedAtMs, session.UpdatedAtMs) + INSERT INTO auth_sessions (app_code, session_id, user_id, refresh_token_hash, token_family_id, generation, parent_session_id, rotated_to_session_id, rotation_at_ms, rotation_request_id, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, '', 0, '', ?, ?, ?, ?, NULL, '', '', '', ?, ?) + `, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.RefreshTokenHash, normalizedFamilyID(session), session.Generation, session.ParentSessionID, session.DeviceID, session.ExpiresAtMs, session.LastHeartbeatAtMs, session.LastHeartbeatRequestID, session.CreatedAtMs, session.UpdatedAtMs) if err != nil { // session_id 或 refresh hash 冲突统一映射成 auth 领域冲突。 return mapAuthDuplicateError(err) @@ -23,6 +27,11 @@ func (r *Repository) CreateSession(ctx context.Context, session authdomain.Sessi return nil } +// FindSessionByRefreshHash 返回已轮换/吊销行在内的完整 session;调用方必须继续做状态判定。 +func (r *Repository) FindSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) { + return r.findSessionByRefreshHash(ctx, refreshTokenHash) +} + // FindActiveSessionByRefreshHash 查找未过期且未吊销的 session。 func (r *Repository) FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error) { @@ -69,15 +78,13 @@ func (r *Repository) FindActiveSessionByID(ctx context.Context, sessionID string func (r *Repository) FindLatestActiveSessionByUser(ctx context.Context, userID int64, nowMs int64) (authdomain.Session, error) { // idx_auth_sessions_user_id(app_code, user_id) 先收敛到单用户,再在少量行上过滤排序即可。 - var session authdomain.Session - var revokedAt sql.Null[int64] - err := r.db.QueryRowContext(ctx, ` - SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms + session, err := scanSession(r.db.QueryRowContext(ctx, ` + SELECT `+sessionProjection+` FROM auth_sessions WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL AND expires_at_ms > ? ORDER BY created_at_ms DESC, session_id DESC LIMIT 1 - `, appcode.FromContext(ctx), userID, nowMs).Scan(&session.AppCode, &session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &session.LastHeartbeatAtMs, &session.LastHeartbeatRequestID, &revokedAt, &session.RevokedReason, &session.RevokedRequestID, &session.RevokedBy, &session.CreatedAtMs, &session.UpdatedAtMs) + `, appcode.FromContext(ctx), userID, nowMs)) if err == sql.ErrNoRows { // “无活跃会话”是后台要明确展示的业务事实,用 NotFound 与其他存储错误区分。 return authdomain.Session{}, xerr.New(xerr.NotFound, "active session not found") @@ -85,53 +92,230 @@ func (r *Repository) FindLatestActiveSessionByUser(ctx context.Context, userID i if err != nil { return authdomain.Session{}, err } - if revokedAt.Valid { - session.RevokedAtMs = revokedAt.V - } - return session, nil } -// ReplaceSession 原子吊销旧 session 并创建新 session。 +// RotateRefreshSession 在一个 MySQL 事务内串行化同一 refresh token 的首次轮换、grace 重试和 replay 撤销。 +func (r *Repository) RotateRefreshSession(ctx context.Context, command authdomain.RefreshRotationCommand) (authdomain.RefreshRotationResult, error) { + appCode := appcode.Normalize(command.AppCode) + if appCode == "" { + appCode = appcode.FromContext(ctx) + } + if command.RefreshTokenHash == "" || command.NowMs <= 0 || command.GracePeriodMs <= 0 || command.DenyUntilMs <= command.NowMs || strings.TrimSpace(command.RefreshRequestID) == "" { + return authdomain.RefreshRotationResult{}, xerr.New(xerr.InvalidArgument, "invalid refresh rotation command") + } -func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error { - // refresh 轮换必须原子吊销旧 session 并插入新 session。 tx, err := r.db.BeginTx(ctx, nil) if err != nil { - return err + return authdomain.RefreshRotationResult{}, err } defer tx.Rollback() - var oldRevokedAt sql.Null[int64] - err = tx.QueryRowContext(ctx, ` - SELECT revoked_at_ms + // 唯一 refresh hash + FOR UPDATE 保证多实例并发只能有一个 winner;幂等密文和 lineage 与轮换同提交。 + source, err := scanSession(tx.QueryRowContext(ctx, ` + SELECT `+sessionProjection+` + FROM auth_sessions + WHERE app_code = ? AND refresh_token_hash = ? + FOR UPDATE + `, appCode, command.RefreshTokenHash)) + if err == sql.ErrNoRows { + return authdomain.RefreshRotationResult{}, xerr.New(xerr.SessionRevoked, "session revoked") + } + if err != nil { + return authdomain.RefreshRotationResult{}, err + } + + if source.RevokedAtMs == 0 && source.ExpiresAtMs <= command.NowMs { + // 自然过期的当前代不是 token reuse,保持 SESSION_EXPIRED 让客户端重新登录。 + return authdomain.RefreshRotationResult{}, xerr.New(xerr.SessionExpired, "session expired") + } + if source.RevokedAtMs > 0 && source.RevokedReason != "REFRESH_ROTATED" && source.RevokedReason != "REFRESH_TOKEN_REUSE" { + // logout、风控和后台封禁都不允许进入 grace,也不能覆盖其原始撤销原因。 + return authdomain.RefreshRotationResult{}, xerr.New(xerr.SessionRevoked, "session revoked") + } + if source.RevokedReason == "REFRESH_ROTATED" && (strings.TrimSpace(source.RotatedToSessionID) == "" || source.RotationAtMs <= 0) { + // 旧 binary 没有写 parent→child lineage/outcome,时间或设备启发式都可能把并发显式登录误认成 child, + // 造成古老 token 永久踢掉未来新会话。无法证明 lineage 时只拒绝旧 token,不扩大任何撤销范围。 + return authdomain.RefreshRotationResult{}, xerr.New(xerr.SessionRevoked, "session revoked") + } + if subtle.ConstantTimeCompare([]byte(source.DeviceID), []byte(strings.TrimSpace(command.DeviceID))) != 1 { + // token 原文在另一设备出现就是重放;family 查询仍同时限定 app/user/device,不能误伤其他设备登录态。 + result, reuseErr := r.revokeRefreshFamilyForReuse(ctx, tx, source, command) + if reuseErr != nil { + return authdomain.RefreshRotationResult{}, reuseErr + } + if err := tx.Commit(); err != nil { + return authdomain.RefreshRotationResult{}, err + } + return result, nil + } + + if source.RevokedAtMs == 0 { + result, rotateErr := r.createRefreshRotation(ctx, tx, source, command) + if rotateErr != nil { + return authdomain.RefreshRotationResult{}, rotateErr + } + if err := tx.Commit(); err != nil { + return authdomain.RefreshRotationResult{}, err + } + return result, nil + } + + if source.RevokedReason == "REFRESH_TOKEN_REUSE" { + // 上次 denylist 写失败时,后续请求仍返回整族 sid,让 service 可以继续补偿 gateway denylist。 + result, reuseErr := r.revokeRefreshFamilyForReuse(ctx, tx, source, command) + if reuseErr != nil { + return authdomain.RefreshRotationResult{}, reuseErr + } + if err := tx.Commit(); err != nil { + return authdomain.RefreshRotationResult{}, err + } + return result, nil + } + + child, childErr := scanSession(tx.QueryRowContext(ctx, ` + SELECT `+sessionProjection+` FROM auth_sessions WHERE app_code = ? AND session_id = ? FOR UPDATE - `, appcode.FromContext(ctx), oldSessionID).Scan(&oldRevokedAt) - if err == sql.ErrNoRows || oldRevokedAt.Valid { - // 旧 session 不存在或已经吊销时,不能再次轮换。 - return xerr.New(xerr.SessionRevoked, "session revoked") + `, appCode, source.RotatedToSessionID)) + if childErr != nil && childErr != sql.ErrNoRows { + return authdomain.RefreshRotationResult{}, childErr } - if err != nil { - return err + var outcomeRequestID string + var outcomeChildID string + var outcomeCiphertext []byte + var outcomeCreatedAtMs int64 + var outcomeExpiresAtMs int64 + outcomeErr := tx.QueryRowContext(ctx, ` + SELECT child_session_id, refresh_request_id, token_ciphertext, created_at_ms, expires_at_ms + FROM auth_refresh_outcomes + WHERE app_code = ? AND parent_session_id = ? + FOR UPDATE + `, appCode, source.SessionID).Scan(&outcomeChildID, &outcomeRequestID, &outcomeCiphertext, &outcomeCreatedAtMs, &outcomeExpiresAtMs) + if outcomeErr != nil && outcomeErr != sql.ErrNoRows { + return authdomain.RefreshRotationResult{}, outcomeErr } - _, err = tx.ExecContext(ctx, ` + graceValid := childErr == nil && outcomeErr == nil && + child.RevokedAtMs == 0 && child.ExpiresAtMs > command.NowMs && + child.ParentSessionID == source.SessionID && child.TokenFamilyID == normalizedFamilyID(source) && child.Generation == source.Generation+1 && + outcomeChildID == child.SessionID && outcomeCreatedAtMs == source.RotationAtMs && + command.NowMs <= outcomeExpiresAtMs + if graceValid { + // 窗口由首次 winner 与 token pair 同事务固化;滚动配置变更时 retry 实例必须信任该 persisted deadline, + // 不能再用自身当前 grace 配置缩短或扩张,否则 30s/10s 混部会把合法重试误判为 reuse。 + if err := tx.Commit(); err != nil { + return authdomain.RefreshRotationResult{}, err + } + return authdomain.RefreshRotationResult{ + Status: authdomain.RefreshRotationGraceHit, + SourceSession: source, + ActiveSession: child, + RefreshRequestID: outcomeRequestID, + TokenCiphertext: append([]byte(nil), outcomeCiphertext...), + }, nil + } + + // child 已再次轮换(第二上一代)、outcome 过窗或 lineage 不完整都属于不可安全恢复的 reuse。 + result, reuseErr := r.revokeRefreshFamilyForReuse(ctx, tx, source, command) + if reuseErr != nil { + return authdomain.RefreshRotationResult{}, reuseErr + } + if err := tx.Commit(); err != nil { + return authdomain.RefreshRotationResult{}, err + } + return result, nil +} + +func (r *Repository) createRefreshRotation(ctx context.Context, tx *sql.Tx, source authdomain.Session, command authdomain.RefreshRotationCommand) (authdomain.RefreshRotationResult, error) { + child := command.NewSession + familyID := normalizedFamilyID(source) + if child.SessionID == "" || child.RefreshTokenHash == "" || len(command.TokenCiphertext) == 0 || len(command.TokenCiphertext) > 4096 || + child.AppCode != source.AppCode || child.UserID != source.UserID || child.DeviceID != source.DeviceID || + child.TokenFamilyID != familyID || child.ParentSessionID != source.SessionID || child.Generation != source.Generation+1 || + command.OutcomeExpiresAtMs != command.NowMs+command.GracePeriodMs { + return authdomain.RefreshRotationResult{}, xerr.New(xerr.InvalidArgument, "invalid refresh rotation lineage") + } + if source.ParentSessionID != "" { + // source 成为新 parent 后,它的 parent 已是第二上一代;事务内删除旧密文,确保每个 family 最多保留 immediate predecessor 的一份结果。 + if _, err := tx.ExecContext(ctx, ` + DELETE FROM auth_refresh_outcomes + WHERE app_code = ? AND parent_session_id = ? + `, source.AppCode, source.ParentSessionID); err != nil { + return authdomain.RefreshRotationResult{}, err + } + } + + _, err := tx.ExecContext(ctx, ` UPDATE auth_sessions - SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ? - WHERE app_code = ? AND session_id = ? - `, revokedAtMs, "REFRESH_ROTATED", "", "refresh_token", revokedAtMs, appcode.FromContext(ctx), oldSessionID) + SET token_family_id = ?, rotated_to_session_id = ?, rotation_at_ms = ?, rotation_request_id = ?, + revoked_at_ms = ?, revoked_reason = 'REFRESH_ROTATED', revoked_request_id = ?, revoked_by = 'refresh_token', updated_at_ms = ? + WHERE app_code = ? AND session_id = ? AND revoked_at_ms IS NULL + `, familyID, child.SessionID, command.NowMs, command.RefreshRequestID, command.NowMs, command.RefreshRequestID, command.NowMs, source.AppCode, source.SessionID) if err != nil { - return err + return authdomain.RefreshRotationResult{}, err } - - if err := insertSession(ctx, tx, newSession); err != nil { - // 插入新 session 失败会回滚旧 session 吊销。 - return err + if err := insertSession(ctx, tx, child); err != nil { + return authdomain.RefreshRotationResult{}, err } + _, err = tx.ExecContext(ctx, ` + INSERT INTO auth_refresh_outcomes (app_code, parent_session_id, child_session_id, refresh_request_id, token_ciphertext, created_at_ms, expires_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, source.AppCode, source.SessionID, child.SessionID, command.RefreshRequestID, command.TokenCiphertext, command.NowMs, command.OutcomeExpiresAtMs) + if err != nil { + return authdomain.RefreshRotationResult{}, mapAuthDuplicateError(err) + } + // 正常 rotation 的旧 sid 也有尚未过期的 access JWT;与 child/outcome 同事务写 durable job, + // 覆盖 Redis 写成功后进程崩溃、Redis 重启等旧 JWT 重新放行风险。 + if err := enqueueSessionDenylistJobs(ctx, tx, source.AppCode, []string{source.SessionID}, "REFRESH_ROTATED", command.NowMs, command.DenyUntilMs); err != nil { + return authdomain.RefreshRotationResult{}, err + } + source.TokenFamilyID = familyID + source.RotatedToSessionID = child.SessionID + source.RotationAtMs = command.NowMs + source.RotationRequestID = command.RefreshRequestID + source.RevokedAtMs = command.NowMs + source.RevokedReason = "REFRESH_ROTATED" + return authdomain.RefreshRotationResult{ + Status: authdomain.RefreshRotationCreated, + SourceSession: source, + ActiveSession: child, + RefreshRequestID: command.RefreshRequestID, + TokenCiphertext: append([]byte(nil), command.TokenCiphertext...), + }, nil +} - return tx.Commit() +func (r *Repository) revokeRefreshFamilyForReuse(ctx context.Context, tx *sql.Tx, source authdomain.Session, command authdomain.RefreshRotationCommand) (authdomain.RefreshRotationResult, error) { + sessionIDs, err := lockProvenSessionFamily(ctx, tx, source) + if err != nil { + return authdomain.RefreshRotationResult{}, err + } + if len(sessionIDs) == 0 { + // source 已由 refresh hash 锁定,正常 schema 下至少包含自身;空结果视为存储不一致,不能静默成功。 + return authdomain.RefreshRotationResult{}, xerr.New(xerr.Unavailable, "refresh token family is unavailable") + } + result, err := revokeProvenSessionFamily(ctx, tx, source, command.NowMs, "REFRESH_TOKEN_REUSE", command.RefreshRequestID, "refresh_replay") + if err != nil { + return authdomain.RefreshRotationResult{}, err + } + affected, err := result.RowsAffected() + if err != nil { + return authdomain.RefreshRotationResult{}, err + } + // family 已撤销后任何历史 token pair 都不能再被恢复;与撤销同事务清掉全部相关密文。 + if err := deleteProvenSessionFamilyOutcomes(ctx, tx, source); err != nil { + return authdomain.RefreshRotationResult{}, err + } + if err := enqueueSessionDenylistJobs(ctx, tx, source.AppCode, sessionIDs, "REFRESH_TOKEN_REUSE", command.NowMs, command.DenyUntilMs); err != nil { + return authdomain.RefreshRotationResult{}, err + } + return authdomain.RefreshRotationResult{ + Status: authdomain.RefreshRotationReuseDetected, + SourceSession: source, + FamilySessionIDs: sessionIDs, + FamilyNewlyRevoked: affected > 0, + }, nil } // RevokeSession 按 session_id 或 refresh token hash 吊销 session。 @@ -149,6 +333,130 @@ func (r *Repository) RevokeSession(ctx context.Context, sessionID string, refres return r.revokeSessionByID(ctx, sessionID, revokedAtMs, "USER_LOGOUT", "", "user_logout") } +// RevokeSessionFamily 让显式 logout 与并发 refresh 收敛到同一个 family;不同设备拥有不同 family,不会被连带退出。 +func (r *Repository) RevokeSessionFamily(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64, denyUntilMs int64, requestID string) (string, []string, bool, error) { + if revokedAtMs <= 0 || denyUntilMs <= revokedAtMs { + return "", nil, false, xerr.New(xerr.InvalidArgument, "invalid session family denylist deadline") + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return "", nil, false, err + } + defer tx.Rollback() + + var source authdomain.Session + if strings.TrimSpace(sessionID) != "" { + source, err = scanSession(tx.QueryRowContext(ctx, ` + SELECT `+sessionProjection+` FROM auth_sessions WHERE app_code = ? AND session_id = ? FOR UPDATE + `, appcode.FromContext(ctx), strings.TrimSpace(sessionID))) + } else { + source, err = scanSession(tx.QueryRowContext(ctx, ` + SELECT `+sessionProjection+` FROM auth_sessions WHERE app_code = ? AND refresh_token_hash = ? FOR UPDATE + `, appcode.FromContext(ctx), refreshTokenHash)) + } + if err == sql.ErrNoRows { + return "", nil, false, tx.Commit() + } + if err != nil { + return "", nil, false, err + } + + sessionIDs, err := lockProvenSessionFamily(ctx, tx, source) + if err != nil { + return "", nil, false, err + } + result, err := revokeProvenSessionFamily(ctx, tx, source, revokedAtMs, "USER_LOGOUT", requestID, "user_logout") + if err != nil { + return "", nil, false, err + } + affected, err := result.RowsAffected() + if err != nil { + return "", nil, false, err + } + if err := deleteProvenSessionFamilyOutcomes(ctx, tx, source); err != nil { + return "", nil, false, err + } + if err := enqueueSessionDenylistJobs(ctx, tx, source.AppCode, sessionIDs, "USER_LOGOUT", revokedAtMs, denyUntilMs); err != nil { + return "", nil, false, err + } + if err := tx.Commit(); err != nil { + return "", nil, false, err + } + return source.AppCode, sessionIDs, affected > 0, nil +} + +func lockProvenSessionFamily(ctx context.Context, tx *sql.Tx, source authdomain.Session) ([]string, error) { + familyID := strings.TrimSpace(source.TokenFamilyID) + if familyID == "" { + // 迁移前 session 没有可证明 lineage;source 已由调用方 FOR UPDATE 锁定,只处理自身, + // 绝不能按 user/device/time 猜测 successor,否则古老 token 可撤销未来显式登录。 + return []string{source.SessionID}, nil + } + // 禁止写成 token_family_id OR session_id:大账号会让优化器退回 user_id 索引并扫描/锁等待数万行。 + // 启动已强校验 family 索引,因此显式 FORCE INDEX,避免统计信息过旧时又选到高基数 user_id 索引; + // user/device 仅做数据污染防线。 + rows, err := tx.QueryContext(ctx, ` + SELECT session_id + FROM auth_sessions FORCE INDEX (idx_auth_sessions_token_family) + WHERE app_code = ? AND token_family_id = ? AND user_id = ? AND device_id = ? + ORDER BY generation, created_at_ms, session_id + FOR UPDATE + `, source.AppCode, familyID, source.UserID, source.DeviceID) + if err != nil { + return nil, err + } + defer rows.Close() + var sessionIDs []string + for rows.Next() { + var sessionID string + if err := rows.Scan(&sessionID); err != nil { + return nil, err + } + sessionIDs = append(sessionIDs, sessionID) + } + if err := rows.Err(); err != nil { + return nil, err + } + return sessionIDs, nil +} + +func revokeProvenSessionFamily(ctx context.Context, tx *sql.Tx, source authdomain.Session, revokedAtMs int64, reason string, requestID string, revokedBy string) (sql.Result, error) { + familyID := strings.TrimSpace(source.TokenFamilyID) + if familyID == "" { + // legacy 无 lineage 时仅更新已锁定 source;session_id 主键路径为单行更新。 + return tx.ExecContext(ctx, ` + UPDATE auth_sessions + SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ? + WHERE app_code = ? AND session_id = ? AND revoked_at_ms IS NULL + `, revokedAtMs, reason, requestID, revokedBy, revokedAtMs, source.AppCode, source.SessionID) + } + return tx.ExecContext(ctx, ` + UPDATE auth_sessions FORCE INDEX (idx_auth_sessions_token_family) + SET revoked_at_ms = ?, revoked_reason = ?, revoked_request_id = ?, revoked_by = ?, updated_at_ms = ? + WHERE app_code = ? AND token_family_id = ? AND user_id = ? AND device_id = ? AND revoked_at_ms IS NULL + `, revokedAtMs, reason, requestID, revokedBy, revokedAtMs, source.AppCode, familyID, source.UserID, source.DeviceID) +} + +func deleteProvenSessionFamilyOutcomes(ctx context.Context, tx *sql.Tx, source authdomain.Session) error { + familyID := strings.TrimSpace(source.TokenFamilyID) + if familyID == "" { + // 无 lineage 时最多删除 source 自己作为 predecessor 的结果,不通过任何启发式扩大范围。 + _, err := tx.ExecContext(ctx, ` + DELETE FROM auth_refresh_outcomes + WHERE app_code = ? AND parent_session_id = ? + `, source.AppCode, source.SessionID) + return err + } + _, err := tx.ExecContext(ctx, ` + DELETE outcome + FROM auth_sessions session FORCE INDEX (idx_auth_sessions_token_family) + STRAIGHT_JOIN auth_refresh_outcomes outcome + ON session.app_code = outcome.app_code AND session.session_id = outcome.parent_session_id + WHERE session.app_code = ? AND session.token_family_id = ? AND session.user_id = ? AND session.device_id = ? + `, source.AppCode, familyID, source.UserID, source.DeviceID) + return err +} + func (r *Repository) RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error) { return r.revokeSessionByID(ctx, sessionID, revokedAtMs, reason, requestID, revokedBy) } @@ -274,13 +582,11 @@ func (r *Repository) revokeSessionByID(ctx context.Context, sessionID string, re func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) { // refresh token hash 是唯一索引,最多命中一条 session。 - var session authdomain.Session - var revokedAt sql.Null[int64] - err := r.db.QueryRowContext(ctx, ` - SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms + session, err := scanSession(r.db.QueryRowContext(ctx, ` + SELECT `+sessionProjection+` FROM auth_sessions WHERE app_code = ? AND refresh_token_hash = ? - `, appcode.FromContext(ctx), refreshTokenHash).Scan(&session.AppCode, &session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &session.LastHeartbeatAtMs, &session.LastHeartbeatRequestID, &revokedAt, &session.RevokedReason, &session.RevokedRequestID, &session.RevokedBy, &session.CreatedAtMs, &session.UpdatedAtMs) + `, appcode.FromContext(ctx), refreshTokenHash)) if err == sql.ErrNoRows { // 找不到 hash 按 revoked 返回,避免暴露 token 是否存在。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") @@ -288,23 +594,16 @@ func (r *Repository) findSessionByRefreshHash(ctx context.Context, refreshTokenH if err != nil { return authdomain.Session{}, err } - if revokedAt.Valid { - // sql.Null[int64] 转换成领域零值语义。 - session.RevokedAtMs = revokedAt.V - } - return session, nil } func (r *Repository) findSessionByID(ctx context.Context, sessionID string) (authdomain.Session, error) { // session_id 来自已校验 access token 的 sid claim,仍必须回查服务端 session 状态。 - var session authdomain.Session - var revokedAt sql.Null[int64] - err := r.db.QueryRowContext(ctx, ` - SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms + session, err := scanSession(r.db.QueryRowContext(ctx, ` + SELECT `+sessionProjection+` FROM auth_sessions WHERE app_code = ? AND session_id = ? - `, appcode.FromContext(ctx), sessionID).Scan(&session.AppCode, &session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &session.LastHeartbeatAtMs, &session.LastHeartbeatRequestID, &revokedAt, &session.RevokedReason, &session.RevokedRequestID, &session.RevokedBy, &session.CreatedAtMs, &session.UpdatedAtMs) + `, appcode.FromContext(ctx), sessionID)) if err == sql.ErrNoRows { // 不暴露 session_id 是否存在,统一按 revoked 处理。 return authdomain.Session{}, xerr.New(xerr.SessionRevoked, "session revoked") @@ -312,22 +611,63 @@ func (r *Repository) findSessionByID(ctx context.Context, sessionID string) (aut if err != nil { return authdomain.Session{}, err } - if revokedAt.Valid { - session.RevokedAtMs = revokedAt.V - } - return session, nil } func insertSession(ctx context.Context, tx *sql.Tx, session authdomain.Session) error { // insertSession 只在已有事务中使用,保证与 session 替换或三方注册同提交。 _, err := tx.ExecContext(ctx, ` - INSERT INTO auth_sessions (app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, '', '', '', ?, ?) - `, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.RefreshTokenHash, session.DeviceID, session.ExpiresAtMs, session.LastHeartbeatAtMs, session.LastHeartbeatRequestID, session.CreatedAtMs, session.UpdatedAtMs) + INSERT INTO auth_sessions (app_code, session_id, user_id, refresh_token_hash, token_family_id, generation, parent_session_id, rotated_to_session_id, rotation_at_ms, rotation_request_id, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, '', 0, '', ?, ?, ?, ?, NULL, '', '', '', ?, ?) + `, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.RefreshTokenHash, normalizedFamilyID(session), session.Generation, session.ParentSessionID, session.DeviceID, session.ExpiresAtMs, session.LastHeartbeatAtMs, session.LastHeartbeatRequestID, session.CreatedAtMs, session.UpdatedAtMs) if err != nil { return mapAuthDuplicateError(err) } return nil } + +type sessionScanner interface { + Scan(dest ...any) error +} + +func scanSession(scanner sessionScanner) (authdomain.Session, error) { + var session authdomain.Session + var revokedAt sql.Null[int64] + err := scanner.Scan( + &session.AppCode, + &session.SessionID, + &session.UserID, + &session.RefreshTokenHash, + &session.TokenFamilyID, + &session.Generation, + &session.ParentSessionID, + &session.RotatedToSessionID, + &session.RotationAtMs, + &session.RotationRequestID, + &session.DeviceID, + &session.ExpiresAtMs, + &session.LastHeartbeatAtMs, + &session.LastHeartbeatRequestID, + &revokedAt, + &session.RevokedReason, + &session.RevokedRequestID, + &session.RevokedBy, + &session.CreatedAtMs, + &session.UpdatedAtMs, + ) + if err != nil { + return authdomain.Session{}, err + } + if revokedAt.Valid { + session.RevokedAtMs = revokedAt.V + } + return session, nil +} + +func normalizedFamilyID(session authdomain.Session) string { + if familyID := strings.TrimSpace(session.TokenFamilyID); familyID != "" { + return familyID + } + return session.SessionID +} diff --git a/services/user-service/internal/storage/mysql/cp/repository.go b/services/user-service/internal/storage/mysql/cp/repository.go index b0f34508..5bc02179 100644 --- a/services/user-service/internal/storage/mysql/cp/repository.go +++ b/services/user-service/internal/storage/mysql/cp/repository.go @@ -15,6 +15,8 @@ import ( cpdomain "hyapp/services/user-service/internal/domain/cp" ) +const formationGiftCoinThreshold = int64(5000) + // Repository 是 CP 关系在 user-service MySQL 上的 owner 存储。 type Repository struct { db *sql.DB @@ -319,6 +321,72 @@ func (r *Repository) ListActiveIntimacyLeaderboardEntries(ctx context.Context, l return entries, rows.Err() } +// ListFormationGiftFeed 读取“组 CP 时礼物金币价值严格超过 5000”的已接受成立事件,和累计亲密榜保持完全独立。 +func (r *Repository) ListFormationGiftFeed(ctx context.Context, limit int) ([]cpdomain.FormationGiftFeedItem, error) { + if r == nil || r.db == nil { + return nil, xerr.New(xerr.Unavailable, "mysql repository is not configured") + } + if limit <= 0 { + limit = 20 + } + if limit > 100 { + limit = 100 + } + appCode := appcode.FromContext(ctx) + // accepted 申请从 formation_feed_eligible=1 的等值索引范围按决定时间倒序扫描;历史行资格默认 0, + // 无命中时不会退化为扫描全部 accepted CP,查询也不做 COUNT 或 OFFSET。解除关系不会抹掉已发生的成立事件。 + rows, err := r.db.QueryContext(ctx, ` + SELECT + source.application_id, source.gift_coin_value, + requester.user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''), + target.user_id, COALESCE(target.current_display_user_id, ''), COALESCE(target.username, ''), COALESCE(target.avatar, ''), + source.decided_at_ms + FROM user_cp_applications source + JOIN users requester + ON requester.app_code = source.app_code AND requester.user_id = source.requester_user_id + JOIN users target + ON target.app_code = source.app_code AND target.user_id = source.target_user_id + WHERE source.app_code = ? + AND source.status = ? + AND source.relation_type = ? + AND source.formation_feed_eligible = 1 + AND source.gift_coin_value > ? + ORDER BY source.decided_at_ms DESC, source.application_id DESC + LIMIT ?`, + appCode, + cpdomain.ApplicationStatusAccepted, + cpdomain.RelationTypeCP, + formationGiftCoinThreshold, + limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := make([]cpdomain.FormationGiftFeedItem, 0, limit) + for rows.Next() { + var item cpdomain.FormationGiftFeedItem + if err := rows.Scan( + &item.FormationID, + &item.GiftCoinValue, + &item.Requester.UserID, + &item.Requester.DisplayUserID, + &item.Requester.Username, + &item.Requester.Avatar, + &item.Target.UserID, + &item.Target.DisplayUserID, + &item.Target.Username, + &item.Target.Avatar, + &item.FormedAtMS, + ); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + // ListWeeklyRankEntries 按消费事实聚合一个 UTC 周期内的 CP 关系新增亲密值。 // score 只等于窗口内 relationship_intimacy_added gift_value 之和;关系表 intimacy_value 只服务普通亲密榜和等级累计态,不参与活动周榜排名。 func (r *Repository) ListWeeklyRankEntries(ctx context.Context, relationType string, startMS int64, endMS int64, limit int) ([]cpdomain.WeeklyRankEntry, error) { @@ -698,7 +766,7 @@ func applicationSelectSQL(suffix string) string { a.application_id, a.relation_type, a.status, a.room_id, a.room_region_id, a.gift_id, a.gift_name, a.gift_icon_url, a.gift_animation_url, - a.gift_count, a.gift_value, a.billing_receipt_id, + a.gift_count, a.gift_value, a.gift_coin_value, a.billing_receipt_id, a.source_room_event_id, a.source_command_id, a.created_at_ms, a.updated_at_ms, a.expires_at_ms, a.decided_at_ms, requester.user_id, COALESCE(requester.current_display_user_id, ''), COALESCE(requester.username, ''), COALESCE(requester.avatar, ''), @@ -727,7 +795,7 @@ func scanApplication(scanner interface{ Scan(dest ...any) error }) (cpdomain.App &app.ApplicationID, &app.RelationType, &app.Status, &app.RoomID, &app.RoomRegionID, &app.Gift.GiftID, &app.Gift.GiftName, &app.Gift.GiftIconURL, &app.Gift.GiftAnimationURL, - &app.Gift.GiftCount, &app.Gift.GiftValue, &app.Gift.BillingReceiptID, + &app.Gift.GiftCount, &app.Gift.GiftValue, &app.Gift.CoinSpent, &app.Gift.BillingReceiptID, &app.SourceEventID, &app.SourceCommandID, &app.CreatedAtMS, &app.UpdatedAtMS, &app.ExpiresAtMS, &app.DecidedAtMS, &app.Requester.UserID, &app.Requester.DisplayUserID, &app.Requester.Username, &app.Requester.Avatar, @@ -1145,11 +1213,11 @@ func upsertApplicationFromGiftTx(ctx context.Context, tx *sql.Tx, appCode string if _, err := tx.ExecContext(ctx, ` UPDATE user_cp_applications SET room_id = ?, room_region_id = ?, gift_id = ?, gift_name = ?, gift_icon_url = ?, - gift_animation_url = ?, gift_count = ?, gift_value = ?, billing_receipt_id = ?, + 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.GiftAnimationURL, event.GiftCount, event.GiftValue, event.BillingReceiptID, + event.GiftAnimationURL, event.GiftCount, event.GiftValue, event.CoinSpent, formationGiftFeedEligibility(event.CoinSpent), event.BillingReceiptID, event.EventID, event.CommandID, expiresAtMS, nowMs, appCode, application.ApplicationID, ); err != nil { return cpdomain.Application{}, err @@ -1169,12 +1237,12 @@ func upsertApplicationFromGiftTx(ctx context.Context, tx *sql.Tx, appCode string 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, - gift_count, gift_value, billing_receipt_id, source_room_event_id, source_command_id, + 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.GiftCount, event.GiftValue, event.BillingReceiptID, event.EventID, event.CommandID, + event.GiftCount, event.GiftValue, event.CoinSpent, formationGiftFeedEligibility(event.CoinSpent), event.BillingReceiptID, event.EventID, event.CommandID, nowMs, nowMs, expiresAtMS, ); err != nil { return cpdomain.Application{}, err @@ -1288,10 +1356,19 @@ func giftSnapshotFromEvent(event cpdomain.GiftEvent) cpdomain.GiftSnapshot { GiftAnimationURL: strings.TrimSpace(event.GiftAnimationURL), GiftCount: event.GiftCount, GiftValue: event.GiftValue, + CoinSpent: event.CoinSpent, BillingReceiptID: strings.TrimSpace(event.BillingReceiptID), } } +func formationGiftFeedEligibility(coinSpent int64) int8 { + // 资格值和查询中的严格门槛共用同一常量;pending 礼物快照被覆盖时该值也随最新 wallet 结算价值重算,不累计旧礼物。 + if coinSpent > formationGiftCoinThreshold { + return 1 + } + return 0 +} + func clampPageSize(pageSize int32) int32 { if pageSize <= 0 { return 20 diff --git a/services/user-service/internal/storage/mysql/db.go b/services/user-service/internal/storage/mysql/db.go index 5f2550fc..46be30b9 100644 --- a/services/user-service/internal/storage/mysql/db.go +++ b/services/user-service/internal/storage/mysql/db.go @@ -4,6 +4,7 @@ package mysql import ( "context" "database/sql" + "fmt" "strings" "time" @@ -58,6 +59,81 @@ func (r *Repository) Migrate(ctx context.Context) error { if err := r.ensureColumn(ctx, "login_audit", "build_number", "build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端构建号' AFTER app_version"); err != nil { return err } + // auth_sessions 是热表;只允许 INSTANT 常量默认列在启动迁移中执行,绝不在进程启动时隐式 COPY 大表或构建 family 索引。 + instantSessionColumns := []struct { + name string + ddl string + }{ + {name: "token_family_id", ddl: "token_family_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '令牌族根 session ID'"}, + {name: "generation", ddl: "generation BIGINT NOT NULL DEFAULT 0 COMMENT '令牌族内单调代际,首代为 0'"}, + {name: "parent_session_id", ddl: "parent_session_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '直接父代 session ID'"}, + {name: "rotated_to_session_id", ddl: "rotated_to_session_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '轮换产生的直接子代 session ID'"}, + {name: "rotation_at_ms", ddl: "rotation_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '轮换时间,UTC epoch ms'"}, + {name: "rotation_request_id", ddl: "rotation_request_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '首次轮换 refresh 幂等键'"}, + } + for _, column := range instantSessionColumns { + if err := r.ensureInstantColumn(ctx, "auth_sessions", column.name, column.ddl); err != nil { + return err + } + } + // family 索引会扫描登录热表,只能由离线 018 在低峰构建;缺失/同名错列时启动直接失败,不能静默全表查询。 + if err := r.requireIndexDefinition(ctx, "auth_sessions", "idx_auth_sessions_token_family", []string{"app_code", "token_family_id", "generation"}, false); err != nil { + return err + } + // 独立小表创建不扫描 auth_sessions;family 索引仍只允许由离线 018 migration 在低峰用 INPLACE/LOCK=NONE 创建。 + if _, err := r.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS auth_refresh_outcomes ( + app_code VARCHAR(32) NOT NULL, + parent_session_id VARCHAR(96) NOT NULL, + child_session_id VARCHAR(96) NOT NULL, + refresh_request_id VARCHAR(96) NOT NULL, + token_ciphertext VARBINARY(4096) NOT NULL, + created_at_ms BIGINT NOT NULL, + expires_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, parent_session_id), + KEY idx_auth_refresh_outcomes_request (app_code, refresh_request_id), + KEY idx_auth_refresh_outcomes_expires (expires_at_ms) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); err != nil { + return err + } + if _, err := r.db.ExecContext(ctx, ` + CREATE TABLE IF NOT EXISTS auth_session_denylist_jobs ( + job_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + app_code VARCHAR(32) NOT NULL, + session_id VARCHAR(96) NOT NULL, + reason VARCHAR(64) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + attempts INT NOT NULL DEFAULT 0, + next_retry_at_ms BIGINT NOT NULL DEFAULT 0, + locked_by VARCHAR(96) NOT NULL DEFAULT '', + locked_until_ms BIGINT NOT NULL DEFAULT 0, + last_error VARCHAR(512) NOT NULL DEFAULT '', + deny_until_ms BIGINT NOT NULL DEFAULT 0, + created_at_ms BIGINT NOT NULL, + updated_at_ms BIGINT NOT NULL, + UNIQUE KEY uk_auth_session_denylist_job (app_code, session_id), + KEY idx_auth_session_denylist_pending (status, next_retry_at_ms, job_id), + KEY idx_auth_session_denylist_cleanup (deny_until_ms, job_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); err != nil { + return err + } + if err := r.ensureInstantColumn(ctx, "auth_session_denylist_jobs", "deny_until_ms", "deny_until_ms BIGINT NOT NULL DEFAULT 0 COMMENT '历史 access JWT 最晚拒绝时刻(含安全余量),UTC epoch ms'"); err != nil { + return err + } + // 从旧三列 unique 收敛到每 sid 一行前必须先离线去重;启动时不能先 DROP 后因重复 ADD 失败而留下无唯一约束表。 + if err := r.requireIndexDefinition(ctx, "auth_session_denylist_jobs", "uk_auth_session_denylist_job", []string{"app_code", "session_id"}, true); err != nil { + return err + } + if err := r.ensureIndexDefinition(ctx, + "auth_session_denylist_jobs", + "idx_auth_session_denylist_cleanup", + []string{"deny_until_ms", "job_id"}, + "ALTER TABLE auth_session_denylist_jobs ADD INDEX idx_auth_session_denylist_cleanup (deny_until_ms, job_id)", + ); err != nil { + return err + } // Dashboard 必须先按用户分区,再按 created_at_ms/id 取最近成功登录。校验完整列序而不只检查索引名, // 防止历史环境中同名但列定义不完整的索引让查询退化成全表排序。 return r.ensureIndexDefinition(ctx, @@ -68,6 +144,62 @@ func (r *Repository) Migrate(ctx context.Context) error { ) } +func (r *Repository) requireIndexDefinition(ctx context.Context, tableName string, indexName string, columns []string, unique bool) error { + rows, err := r.db.QueryContext(ctx, ` + SELECT COLUMN_NAME, NON_UNIQUE + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ? + ORDER BY SEQ_IN_INDEX + `, tableName, indexName) + if err != nil { + return err + } + defer rows.Close() + existing := make([]string, 0, len(columns)) + nonUnique := -1 + for rows.Next() { + var column string + var rowNonUnique int + if err := rows.Scan(&column, &rowNonUnique); err != nil { + return err + } + if nonUnique == -1 { + nonUnique = rowNonUnique + } else if nonUnique != rowNonUnique { + return fmt.Errorf("required index %s.%s has inconsistent NON_UNIQUE metadata", tableName, indexName) + } + existing = append(existing, column) + } + if err := rows.Err(); err != nil { + return err + } + wantNonUnique := 1 + if unique { + wantNonUnique = 0 + } + if !equalStringSlices(existing, columns) || nonUnique != wantNonUnique { + return fmt.Errorf("required index %s.%s has columns %v and NON_UNIQUE=%d, want columns %v and NON_UNIQUE=%d; apply migration 018 before startup", tableName, indexName, existing, nonUnique, columns, wantNonUnique) + } + return nil +} + +func (r *Repository) ensureInstantColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error { + var count int + if err := r.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`, + tableName, columnName, + ).Scan(&count); err != nil { + return err + } + if count > 0 { + return nil + } + // 固定 DDL 若不支持 INSTANT 会直接失败,让操作者改走低峰 Online DDL,而不是启动时静默全表重建。 + // MySQL 8.4 的 INSTANT 算法不接受显式 LOCK 子句;只指定算法仍保证不退化成 COPY/INPLACE。 + _, err := r.db.ExecContext(ctx, `ALTER TABLE `+tableName+` ADD COLUMN `+columnDDL+`, ALGORITHM=INSTANT`) + return err +} + func (r *Repository) ensureColumn(ctx context.Context, tableName string, columnName string, columnDDL string) error { var count int if err := r.db.QueryRowContext(ctx, diff --git a/services/user-service/internal/storage/mysql/db_test.go b/services/user-service/internal/storage/mysql/db_test.go index b2b5402c..3d4fe059 100644 --- a/services/user-service/internal/storage/mysql/db_test.go +++ b/services/user-service/internal/storage/mysql/db_test.go @@ -27,7 +27,37 @@ func TestMigrateAddsLoginAuditClientVersionColumnsAndIndex(t *testing.T) { WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0)) mock.ExpectExec(regexp.QuoteMeta("ALTER TABLE login_audit ADD COLUMN build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '本次认证客户端构建号' AFTER app_version")). WillReturnResult(sqlmock.NewResult(0, 0)) - mock.ExpectQuery(`(?s)SELECT COLUMN_NAME.*FROM information_schema\.STATISTICS.*TABLE_SCHEMA = DATABASE\(\).*TABLE_NAME = \?.*INDEX_NAME = \?.*ORDER BY SEQ_IN_INDEX`). + for _, column := range []string{"token_family_id", "generation", "parent_session_id", "rotated_to_session_id", "rotation_at_ms", "rotation_request_id"} { + mock.ExpectQuery(columnProbe). + WithArgs("auth_sessions", column). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + } + requiredIndexProbe := `(?s)SELECT COLUMN_NAME, NON_UNIQUE.*FROM information_schema\.STATISTICS.*TABLE_SCHEMA = DATABASE\(\).*TABLE_NAME = \?.*INDEX_NAME = \?.*ORDER BY SEQ_IN_INDEX` + mock.ExpectQuery(requiredIndexProbe). + WithArgs("auth_sessions", "idx_auth_sessions_token_family"). + WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "NON_UNIQUE"}). + AddRow("app_code", 1). + AddRow("token_family_id", 1). + AddRow("generation", 1)) + mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS auth_refresh_outcomes`). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectExec(`(?s)CREATE TABLE IF NOT EXISTS auth_session_denylist_jobs`). + WillReturnResult(sqlmock.NewResult(0, 0)) + mock.ExpectQuery(columnProbe). + WithArgs("auth_session_denylist_jobs", "deny_until_ms"). + WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1)) + mock.ExpectQuery(requiredIndexProbe). + WithArgs("auth_session_denylist_jobs", "uk_auth_session_denylist_job"). + WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME", "NON_UNIQUE"}). + AddRow("app_code", 0). + AddRow("session_id", 0)) + indexProbe := `(?s)SELECT COLUMN_NAME.*FROM information_schema\.STATISTICS.*TABLE_SCHEMA = DATABASE\(\).*TABLE_NAME = \?.*INDEX_NAME = \?.*ORDER BY SEQ_IN_INDEX` + mock.ExpectQuery(indexProbe). + WithArgs("auth_session_denylist_jobs", "idx_auth_session_denylist_cleanup"). + WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME"}). + AddRow("deny_until_ms"). + AddRow("job_id")) + mock.ExpectQuery(indexProbe). WithArgs("login_audit", "idx_login_audit_latest_success"). WillReturnRows(sqlmock.NewRows([]string{"COLUMN_NAME"})) mock.ExpectExec(regexp.QuoteMeta("ALTER TABLE login_audit ADD INDEX idx_login_audit_latest_success (app_code, user_id, result, blocked, login_type, created_at_ms, id)")). diff --git a/services/user-service/internal/testutil/mysqltest/mysqltest.go b/services/user-service/internal/testutil/mysqltest/mysqltest.go index 41ab04ee..cb44b9f4 100644 --- a/services/user-service/internal/testutil/mysqltest/mysqltest.go +++ b/services/user-service/internal/testutil/mysqltest/mysqltest.go @@ -354,6 +354,11 @@ func (r *Repository) FindActiveSessionByRefreshHash(ctx context.Context, refresh return r.Repository.AuthRepository().FindActiveSessionByRefreshHash(ctx, refreshTokenHash, nowMs) } +// FindSessionByRefreshHash 让 refresh rotation 测试读取已轮换 lineage。 +func (r *Repository) FindSessionByRefreshHash(ctx context.Context, refreshTokenHash string) (authdomain.Session, error) { + return r.Repository.AuthRepository().FindSessionByRefreshHash(ctx, refreshTokenHash) +} + // FindLatestActiveSessionByUser 让测试 wrapper 继续满足认证接口。 func (r *Repository) FindLatestActiveSessionByUser(ctx context.Context, userID int64, nowMs int64) (authdomain.Session, error) { return r.Repository.AuthRepository().FindLatestActiveSessionByUser(ctx, userID, nowMs) @@ -369,9 +374,9 @@ func (r *Repository) FindActiveSessionByID(ctx context.Context, sessionID string return r.Repository.AuthRepository().FindActiveSessionByID(ctx, sessionID, nowMs) } -// ReplaceSession 让测试 wrapper 继续满足认证接口。 -func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error { - return r.Repository.AuthRepository().ReplaceSession(ctx, oldSessionID, newSession, revokedAtMs) +// RotateRefreshSession 让测试 wrapper 继续使用生产 MySQL 原子轮换实现。 +func (r *Repository) RotateRefreshSession(ctx context.Context, command authdomain.RefreshRotationCommand) (authdomain.RefreshRotationResult, error) { + return r.Repository.AuthRepository().RotateRefreshSession(ctx, command) } // RevokeSession 让测试 wrapper 继续满足认证接口。 @@ -379,6 +384,11 @@ func (r *Repository) RevokeSession(ctx context.Context, sessionID string, refres return r.Repository.AuthRepository().RevokeSession(ctx, sessionID, refreshTokenHash, revokedAtMs) } +// RevokeSessionFamily 让 logout/refresh 竞态测试覆盖真实 token family 事务。 +func (r *Repository) RevokeSessionFamily(ctx context.Context, sessionID string, refreshTokenHash string, revokedAtMs int64, denyUntilMs int64, requestID string) (string, []string, bool, error) { + return r.Repository.AuthRepository().RevokeSessionFamily(ctx, sessionID, refreshTokenHash, revokedAtMs, denyUntilMs, requestID) +} + // RevokeSessionWithReason 让测试 wrapper 继续满足认证接口。 func (r *Repository) RevokeSessionWithReason(ctx context.Context, sessionID string, revokedAtMs int64, reason string, requestID string, revokedBy string) (bool, error) { return r.Repository.AuthRepository().RevokeSessionWithReason(ctx, sessionID, revokedAtMs, reason, requestID, revokedBy) @@ -389,6 +399,34 @@ func (r *Repository) RevokeUserDeviceSessionsForRisk(ctx context.Context, source return r.Repository.AuthRepository().RevokeUserDeviceSessionsForRisk(ctx, sourceSessionID, userID, revokedAtMs, reason, requestID, revokedBy) } +func (r *Repository) ClaimSessionDenylistJobs(ctx context.Context, workerID string, nowMs int64, lockUntilMs int64, limit int) ([]authdomain.SessionDenylistJob, error) { + return r.Repository.AuthRepository().ClaimSessionDenylistJobs(ctx, workerID, nowMs, lockUntilMs, limit) +} + +func (r *Repository) MarkSessionDenylistJobDelivered(ctx context.Context, jobID int64, appCode string, sessionID string, reason string, appliedDenyUntilMs int64, nowMs int64, nextRehydrateAtMs int64) error { + return r.Repository.AuthRepository().MarkSessionDenylistJobDelivered(ctx, jobID, appCode, sessionID, reason, appliedDenyUntilMs, nowMs, nextRehydrateAtMs) +} + +func (r *Repository) MarkSessionDenylistJobRetryable(ctx context.Context, jobID int64, errorMessage string, nextRetryAtMs int64, nowMs int64) error { + return r.Repository.AuthRepository().MarkSessionDenylistJobRetryable(ctx, jobID, errorMessage, nextRetryAtMs, nowMs) +} + +func (r *Repository) MaxSessionDenylistJobID(ctx context.Context) (int64, error) { + return r.Repository.AuthRepository().MaxSessionDenylistJobID(ctx) +} + +func (r *Repository) ListSessionDenylistJobsForHydration(ctx context.Context, nowMs int64, afterJobID int64, throughJobID int64, limit int) ([]authdomain.SessionDenylistJob, int64, error) { + return r.Repository.AuthRepository().ListSessionDenylistJobsForHydration(ctx, nowMs, afterJobID, throughJobID, limit) +} + +func (r *Repository) DeleteExpiredSessionDenylistJobs(ctx context.Context, nowMs int64, limit int) (int, error) { + return r.Repository.AuthRepository().DeleteExpiredSessionDenylistJobs(ctx, nowMs, limit) +} + +func (r *Repository) DeleteExpiredRefreshOutcomes(ctx context.Context, nowMs int64, limit int) (int, error) { + return r.Repository.AuthRepository().DeleteExpiredRefreshOutcomes(ctx, nowMs, limit) +} + // UpdateSessionHeartbeat 让测试 wrapper 继续满足认证接口。 func (r *Repository) UpdateSessionHeartbeat(ctx context.Context, sessionID string, userID int64, heartbeatAtMs int64, requestID string) (authdomain.Session, error) { return r.Repository.AuthRepository().UpdateSessionHeartbeat(ctx, sessionID, userID, heartbeatAtMs, requestID) diff --git a/services/user-service/internal/transport/grpc/cp.go b/services/user-service/internal/transport/grpc/cp.go index 44cbc183..91ddf63c 100644 --- a/services/user-service/internal/transport/grpc/cp.go +++ b/services/user-service/internal/transport/grpc/cp.go @@ -105,6 +105,26 @@ func (s *Server) ListCPIntimacyLeaderboard(ctx context.Context, req *userv1.List return resp, nil } +// ListCPFormationGiftFeed 返回组 CP 成立时 wallet 结算金币价值严格超过固定门槛的历史成立事件。 +func (s *Server) ListCPFormationGiftFeed(ctx context.Context, req *userv1.ListCPFormationGiftFeedRequest) (*userv1.ListCPFormationGiftFeedResponse, error) { + ctx = contextWithApp(ctx, req.GetMeta()) + if s.cpSvc == nil { + return nil, xerr.ToGRPCError(xerr.New(xerr.Unavailable, "cp service is not configured")) + } + // limit 只控制返回条数;金币门槛、active/accepted 和 relation_type=cp 均由 owner 仓储固定,调用方不能篡改。 + items, err := s.cpSvc.ListFormationGiftFeed(ctx, req.GetLimit()) + if err != nil { + return nil, xerr.ToGRPCError(err) + } + resp := &userv1.ListCPFormationGiftFeedResponse{ + Items: make([]*userv1.CPFormationGiftFeedItem, 0, len(items)), + } + for _, item := range items { + resp.Items = append(resp.Items, toProtoCPFormationGiftFeedItem(item)) + } + return resp, nil +} + // PrepareBreakCPRelationship 创建解除关系占位;gateway 随后按返回费用调用 wallet-service 扣金币。 func (s *Server) PrepareBreakCPRelationship(ctx context.Context, req *userv1.PrepareBreakCPRelationshipRequest) (*userv1.PrepareBreakCPRelationshipResponse, error) { ctx = contextWithApp(ctx, req.GetMeta()) @@ -176,6 +196,7 @@ func (s *Server) ConsumeRoomGiftCPEvent(ctx context.Context, req *userv1.Consume GiftID: event.GetGiftId(), GiftCount: event.GetGiftCount(), GiftValue: event.GetGiftValue(), + CoinSpent: event.GetCoinSpent(), BillingReceiptID: event.GetBillingReceiptId(), VisibleRegionID: event.GetVisibleRegionId(), CommandID: event.GetCommandId(), @@ -259,6 +280,19 @@ func toProtoCPRelationship(relationship cpdomain.Relationship) *userv1.CPRelatio } } +func toProtoCPFormationGiftFeedItem(item cpdomain.FormationGiftFeedItem) *userv1.CPFormationGiftFeedItem { + if item.FormationID == "" { + return nil + } + return &userv1.CPFormationGiftFeedItem{ + FormationId: item.FormationID, + GiftCoinValue: item.GiftCoinValue, + Requester: toProtoCPUserProfile(item.Requester), + Target: toProtoCPUserProfile(item.Target), + FormedAtMs: item.FormedAtMS, + } +} + func toProtoCPUserProfile(profile cpdomain.UserProfile) *userv1.CPUserProfile { if profile.UserID <= 0 { return nil @@ -345,6 +379,7 @@ func toProtoCPGiftSnapshot(gift cpdomain.GiftSnapshot) *userv1.CPGiftSnapshot { GiftAnimationUrl: gift.GiftAnimationURL, GiftCount: gift.GiftCount, GiftValue: gift.GiftValue, + CoinSpent: gift.CoinSpent, BillingReceiptId: gift.BillingReceiptID, } } diff --git a/services/user-service/internal/transport/grpc/server.go b/services/user-service/internal/transport/grpc/server.go index 0ed4b4db..09c473df 100644 --- a/services/user-service/internal/transport/grpc/server.go +++ b/services/user-service/internal/transport/grpc/server.go @@ -235,7 +235,9 @@ func (s *Server) QuickCreateAccount(ctx context.Context, req *userv1.QuickCreate func (s *Server) RefreshToken(ctx context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) { ctx = contextWithApp(ctx, req.GetMeta()) // refresh token 轮换后响应里会返回新的 refresh token,旧 token 立即失效。 - token, err := s.authSvc.RefreshToken(ctx, req.GetRefreshToken(), req.GetMeta().GetDeviceId(), authMeta(req.GetMeta())) + meta := authMeta(req.GetMeta()) + meta.RefreshRequestID = req.GetRefreshRequestId() + token, err := s.authSvc.RefreshToken(ctx, req.GetRefreshToken(), req.GetMeta().GetDeviceId(), meta) if err != nil { return nil, xerr.ToGRPCError(err) } diff --git a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql index bc5b03be..06caa9f6 100644 --- a/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql +++ b/services/wallet-service/deploy/mysql/initdb/001_wallet_service.sql @@ -41,6 +41,16 @@ CREATE TABLE IF NOT EXISTS wallet_transactions ( KEY idx_wallet_tx_external_ref (app_code, external_ref) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钱包交易主表'; +-- 提现终局锁是按申请增长的小表,不在 10GB 级 wallet_transactions 上增建唯一生成列索引。 +-- 首次 INSERT/ON DUPLICATE KEY 在主键上串行化 settle/release,避免两个“未找到终局”的 gap lock 同时通过后双向执行。 +CREATE TABLE IF NOT EXISTS wallet_withdrawal_terminal_locks ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + withdrawal_application_id VARCHAR(64) NOT NULL COMMENT '后台提现申请 ID', + created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, withdrawal_application_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='提现申请钱包终局串行锁'; + -- App 用户送礼榜历史应急查询会按 app_code/biz_type/status/created_at_ms 过滤; -- 老索引缺 status 时会在 10GB 级 wallet_transactions 上读取大量失败/非成功交易再做 JSON 聚合。 SET @ddl := IF( diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index 39732c0f..836b5c5d 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "strings" + "sync" "testing" "time" @@ -2297,6 +2298,156 @@ func TestPointWithdrawalFreezeSettleReleaseIsHuwaaOnly(t *testing.T) { requireWalletBalanceForApp(t, svc, "huwaa", 22002, ledger.AssetPoint, 2_000_000, 0) } +func TestSalaryWithdrawalTerminalReusesLegacyCommandAcrossReviewerRetryAndRejectsOppositeDecision(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetAssetBalanceForApp("lalu", 22003, ledger.AssetHostSalaryUSD, 10_000) + svc := walletservice.New(repository) + + if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: "lalu", CommandID: "salary-withdrawal:legacy-freeze", UserID: 22003, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "legacy-freeze", + }); err != nil { + t.Fatalf("freeze salary withdrawal failed: %v", err) + } + legacy, err := svc.SettleSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: "lalu", CommandID: "salary-withdrawal:901:approved", UserID: 22003, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, + OperatorUserID: 7, Reason: "legacy finance remark", WithdrawalApplicationID: "901", + }) + if err != nil { + t.Fatalf("legacy finance settlement failed: %v", err) + } + + // admin 事务若在 wallet 成功后失败,新版会换用 stage command,并可能由另一审核人修改备注后重试。 + // wallet 必须按 application id 找回旧回执,不再扣减 frozen,也不因审计 metadata 改变产生 hash 冲突。 + retry, err := svc.SettleSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: "lalu", CommandID: "salary-withdrawal:901:finance", UserID: 22003, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, + OperatorUserID: 9, Reason: "new reviewer changed remark", WithdrawalApplicationID: "901", + }) + if err != nil { + t.Fatalf("stage command retry must reuse legacy terminal: %v", err) + } + if retry.TransactionID != legacy.TransactionID { + t.Fatalf("retry transaction = %s, want legacy %s", retry.TransactionID, legacy.TransactionID) + } + if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "lalu", "901", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 { + t.Fatalf("withdrawal application must own one terminal transaction, got %d", got) + } + requireWalletBalanceForApp(t, svc, "lalu", 22003, ledger.AssetHostSalaryUSD, 5_000, 0) + + _, err = svc.ReleaseSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: "lalu", CommandID: "salary-withdrawal:901:finance", UserID: 22003, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, + OperatorUserID: 10, Reason: "opposite decision", WithdrawalApplicationID: "901", + }) + if !xerr.IsCode(err, xerr.IdempotencyConflict) { + t.Fatalf("opposite terminal decision must conflict, got %v", err) + } + if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "lalu", "901", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 { + t.Fatalf("opposite decision must not create a second terminal transaction, got %d", got) + } +} + +func TestPointWithdrawalTerminalReusesLegacyCommandWithNormalizedPolicySnapshot(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetAssetBalanceForApp("huwaa", 22005, ledger.AssetPoint, 2_000_000) + svc := walletservice.New(repository) + + if _, err := svc.FreezePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + AppCode: "huwaa", CommandID: "point-withdrawal:legacy-freeze", UserID: 22005, + AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, + FeePointAmount: 50_000, NetPointAmount: 950_000, PointsPerUSD: 100_000, FeeBPS: 500, + WithdrawalRef: "point-legacy-freeze", + }); err != nil { + t.Fatalf("freeze point withdrawal failed: %v", err) + } + legacy, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + AppCode: "huwaa", CommandID: "salary-withdrawal:903:approved", UserID: 22005, + AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, + FeePointAmount: 50_000, NetPointAmount: 950_000, PointsPerUSD: 100_000, FeeBPS: 500, + OperatorUserID: 7, Reason: "legacy point finance remark", WithdrawalApplicationID: "903", + }) + if err != nil { + t.Fatalf("legacy point withdrawal settlement failed: %v", err) + } + + // 新版重试可由另一审核人发起且省略可由政策快照确定的 fee/net;service 归一化后必须命中旧终局 metadata。 + retry, err := svc.SettlePointWithdrawal(context.Background(), ledger.PointWithdrawalCommand{ + AppCode: "huwaa", CommandID: "salary-withdrawal:903:finance", UserID: 22005, + AssetType: ledger.AssetPoint, GrossPointAmount: 1_000_000, PointsPerUSD: 100_000, FeeBPS: 500, + OperatorUserID: 9, Reason: "new point reviewer remark", WithdrawalApplicationID: "903", + }) + if err != nil { + t.Fatalf("stage point command retry must reuse legacy terminal: %v", err) + } + if retry.TransactionID != legacy.TransactionID || retry.FeePointAmount != 50_000 || retry.NetPointAmount != 950_000 || retry.PointsPerUSD != 100_000 || retry.FeeBPS != 500 { + t.Fatalf("point terminal retry snapshot mismatch: legacy=%+v retry=%+v", legacy, retry) + } + if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "huwaa", "903", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 { + t.Fatalf("point withdrawal application must own one terminal transaction, got %d", got) + } + requireWalletBalanceForApp(t, svc, "huwaa", 22005, ledger.AssetPoint, 1_000_000, 0) +} + +func TestSalaryWithdrawalTerminalSerializesConcurrentOppositeDecisions(t *testing.T) { + repository := mysqltest.NewRepository(t) + repository.SetAssetBalanceForApp("lalu", 22004, ledger.AssetHostSalaryUSD, 10_000) + svc := walletservice.New(repository) + if _, err := svc.FreezeSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: "lalu", CommandID: "salary-withdrawal:concurrent-freeze", UserID: 22004, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, WithdrawalRef: "concurrent-freeze", + }); err != nil { + t.Fatalf("freeze salary withdrawal failed: %v", err) + } + + start := make(chan struct{}) + errs := make(chan error, 2) + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + <-start + _, err := svc.SettleSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: "lalu", CommandID: "salary-withdrawal:902:approved", UserID: 22004, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, + OperatorUserID: 7, Reason: "approve", WithdrawalApplicationID: "902", + }) + errs <- err + }() + go func() { + defer workers.Done() + <-start + _, err := svc.ReleaseSalaryWithdrawal(context.Background(), ledger.SalaryWithdrawalCommand{ + AppCode: "lalu", CommandID: "salary-withdrawal:902:finance", UserID: 22004, + SalaryAssetType: ledger.AssetHostSalaryUSD, SalaryUSDMinor: 5_000, + OperatorUserID: 8, Reason: "reject", WithdrawalApplicationID: "902", + }) + errs <- err + }() + close(start) + workers.Wait() + close(errs) + + succeeded, conflicted := 0, 0 + for err := range errs { + switch { + case err == nil: + succeeded++ + case xerr.IsCode(err, xerr.IdempotencyConflict): + conflicted++ + default: + t.Fatalf("unexpected concurrent terminal error: %v", err) + } + } + if succeeded != 1 || conflicted != 1 { + t.Fatalf("concurrent terminal results: succeeded=%d conflicted=%d", succeeded, conflicted) + } + if got := repository.CountRows("wallet_transactions", "app_code = ? AND external_ref = ? AND biz_type IN (?, ?)", "lalu", "902", "salary_withdrawal_settle", "salary_withdrawal_release"); got != 1 { + t.Fatalf("concurrent opposite decisions created %d terminal rows", got) + } +} + // TestTransferPointToCoinSellerUsesServerWhitelist 验证 H5 不能自报比例,wallet 会按 App、国家和 active 配置原子扣 POINT/加币商库存。 func TestTransferPointToCoinSellerUsesServerWhitelist(t *testing.T) { repository := mysqltest.NewRepository(t) diff --git a/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go index f27a551f..ab460f1c 100644 --- a/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_entitlement_repository.go @@ -372,7 +372,7 @@ func (r *Repository) BatchGetUserEquippedResources(ctx context.Context, query re return result, nil } -func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64, snapshot resourceEntitlementSnapshot) (string, error) { +func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, grantID string, nowMs int64, strategyOverride string, snapshot resourceEntitlementSnapshot) (string, error) { if snapshot.SourceSnapshotID != "" { if !validSHA256(snapshot.ResourceSnapshotHash) || strings.TrimSpace(snapshot.ResourceSnapshotJSON) == "" { return "", xerr.New(xerr.Internal, "pinned entitlement resource snapshot is incomplete") @@ -398,7 +398,13 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in } expiresAt = nowMs + durationMS } - strategy := resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy) + strategy := resourcedomain.NormalizeGrantStrategy(strategyOverride) + if strategy == "" { + strategy = resourcedomain.NormalizeGrantStrategy(resource.GrantStrategy) + } else if !resourcedomain.ValidGrantStrategy(strategy) { + // 覆盖策略只允许由 owner service 的专用业务入口传入;无效值属于服务端编排错误,不能静默回退目录配置。 + return "", xerr.New(xerr.Internal, "entitlement strategy override is invalid") + } if strategy == resourcedomain.GrantStrategyExtendExpiry || strategy == resourcedomain.GrantStrategyIncreaseQuantity || strategy == resourcedomain.GrantStrategySetActiveFlag { existing, exists, err := r.queryActiveEntitlementForUpdate(ctx, tx, userID, resource.ResourceID, snapshot.ResourceSnapshotHash, snapshot.GiftSnapshotHash, nowMs) if err != nil { @@ -410,6 +416,10 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in newExpiresAt := existing.ExpiresAtMS switch strategy { case resourcedomain.GrantStrategyExtendExpiry: + if strategyOverride != "" && durationMS > 0 && existing.ExpiresAtMS == 0 { + // 商城不能向永久权益继续出售有限天数:合并后无法区分永久与付费时长的撤销贡献,直接拒绝并回滚扣费。 + return "", xerr.New(xerr.Conflict, "resource is already permanently owned") + } newRemaining += quantity newQuantity += quantity if durationMS > 0 { @@ -418,7 +428,7 @@ func (r *Repository) applyEntitlement(ctx context.Context, tx *sql.Tx, userID in return "", xerr.New(xerr.InvalidArgument, "duration overflow") } newExpiresAt = base + durationMS - } else { + } else if durationMS == 0 { newExpiresAt = 0 } case resourcedomain.GrantStrategyIncreaseQuantity: diff --git a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go index 81f998b5..1a2ce542 100644 --- a/services/wallet-service/internal/storage/mysql/resource_grant_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_grant_repository.go @@ -240,7 +240,7 @@ func (r *Repository) GrantPinnedResourceGroup(ctx context.Context, command resou return resourcedomain.ResourceGrant{}, err } if _, err := r.applyGrantItemWithEntitlementSnapshot(ctx, tx, grantID, command.CommandID, command.TargetUserID, - item.Resource, item.Quantity, item.DurationMS, nowMS, resourceEntitlementSnapshot{ + item.Resource, item.Quantity, item.DurationMS, nowMS, "", resourceEntitlementSnapshot{ SourceSnapshotID: snapshot.SnapshotID, ResourceSnapshotHash: resourceSnapshotHash, ResourceSnapshotJSON: string(resourceSnapshotJSON), GiftSnapshotHash: giftSnapshotHash, GiftSnapshotJSON: giftSnapshotJSON, @@ -430,7 +430,13 @@ func (r *Repository) GetResourceGrant(ctx context.Context, grantID string) (reso } func (r *Repository) applyGrantItem(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64) (resourcedomain.ResourceGrantItem, error) { - return r.applyGrantItemWithEntitlementSnapshot(ctx, tx, grantID, commandID, targetUserID, resource, quantity, durationMS, nowMs, resourceEntitlementSnapshot{}) + return r.applyGrantItemWithEntitlementSnapshot(ctx, tx, grantID, commandID, targetUserID, resource, quantity, durationMS, nowMs, "", resourceEntitlementSnapshot{}) +} + +// applyGrantItemWithEntitlementStrategy 只覆盖本次发放如何合并用户权益,不改资源目录或 grant item +// 的资源快照。商城售卖的是明确时长,因此需要固定为续期语义,同时保留资源在其他发放场景中的原策略。 +func (r *Repository) applyGrantItemWithEntitlementStrategy(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64, entitlementStrategy string) (resourcedomain.ResourceGrantItem, error) { + return r.applyGrantItemWithEntitlementSnapshot(ctx, tx, grantID, commandID, targetUserID, resource, quantity, durationMS, nowMs, entitlementStrategy, resourceEntitlementSnapshot{}) } type resourceEntitlementSnapshot struct { @@ -441,7 +447,7 @@ type resourceEntitlementSnapshot struct { GiftSnapshotJSON string } -func (r *Repository) applyGrantItemWithEntitlementSnapshot(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64, entitlementSnapshot resourceEntitlementSnapshot) (resourcedomain.ResourceGrantItem, error) { +func (r *Repository) applyGrantItemWithEntitlementSnapshot(ctx context.Context, tx *sql.Tx, grantID string, commandID string, targetUserID int64, resource resourcedomain.Resource, quantity int64, durationMS int64, nowMs int64, entitlementStrategy string, entitlementSnapshot resourceEntitlementSnapshot) (resourcedomain.ResourceGrantItem, error) { if quantity <= 0 { return resourcedomain.ResourceGrantItem{}, xerr.New(xerr.InvalidArgument, "quantity must be positive") } @@ -509,7 +515,7 @@ func (r *Repository) applyGrantItemWithEntitlementSnapshot(ctx context.Context, item.ResultType = resourcedomain.ResultWalletCredit item.WalletTransactionID = transactionID } else { - entitlementID, err := r.applyEntitlement(ctx, tx, targetUserID, resource, quantity, durationMS, grantID, nowMs, entitlementSnapshot) + entitlementID, err := r.applyEntitlement(ctx, tx, targetUserID, resource, quantity, durationMS, grantID, nowMs, entitlementStrategy, entitlementSnapshot) if err != nil { return resourcedomain.ResourceGrantItem{}, err } diff --git a/services/wallet-service/internal/storage/mysql/resource_shop_repository.go b/services/wallet-service/internal/storage/mysql/resource_shop_repository.go index 300584a0..cfb9c540 100644 --- a/services/wallet-service/internal/storage/mysql/resource_shop_repository.go +++ b/services/wallet-service/internal/storage/mysql/resource_shop_repository.go @@ -3,6 +3,7 @@ package mysql import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "hyapp/pkg/appcode" @@ -279,7 +280,9 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou defer func() { _ = tx.Rollback() }() requestHash := resourceShopPurchaseRequestHash(command) - if txRow, exists, err := r.lookupTransaction(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase); err != nil || exists { + // 首次查询不能对不存在的 command_id 加 gap lock:两笔不同订单随后会按资源行串行, + // 若各自先持有交易唯一键间隙,先拿到资源锁的事务在 INSERT 时可能与等待资源锁的事务形成死锁。 + if txRow, exists, err := r.lookupTransactionConsistent(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase, xerr.LedgerConflict); err != nil || exists { if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } @@ -320,9 +323,16 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou "price_type": item.PriceType, "coin_spent": item.CoinPrice, "balance_after": coinBalanceAfter, + "balance_version": account.Version + 1, "resource_grant_id": grantID, } if err := r.insertTransaction(ctx, tx, transactionID, command.CommandID, bizTypeResourceShopPurchase, requestHash, orderID, metadata, nowMs); err != nil { + if isMySQLDuplicateError(err) { + // 同 command_id 的并发首发都可能在一致性读阶段看不到未提交行;唯一键决定胜者, + // 败者必须回滚所有预备锁并重读胜者回执,不能重复扣费或把 duplicate 暴露成 500。 + _ = tx.Rollback() + return r.replayResourceShopPurchase(ctx, command, requestHash) + } return resourcedomain.ResourceShopPurchaseReceipt{}, err } if err := r.applyAccountDelta(ctx, tx, account, -item.CoinPrice, 0, nowMs); err != nil { @@ -343,7 +353,10 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou if err := r.insertResourceGrant(ctx, tx, grantID, command.CommandID, command.UserID, resourcedomain.GrantSourceResourceShop, resourcedomain.GrantSubjectResource, fmt.Sprintf("%d", item.ResourceID), requestHash, "", "resource shop purchase", command.UserID, nowMs); err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } - grantItem, err := r.applyGrantItem(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs) + // 商城商品卖的是 duration_days 对应的使用时长,重复购买必须从当前到期时间继续累加, + // 不能受资源目录在活动、礼物等其他发放场景使用的 increase_quantity/new_entitlement 策略影响。 + // 同一资源的购买事务已由 lockResourceShopItemTx 锁住资源行,权益层再以 FOR UPDATE 合并,避免并发续期丢失时长。 + grantItem, err := r.applyGrantItemWithEntitlementStrategy(ctx, tx, grantID, command.CommandID, command.UserID, item.Resource, 1, durationMS, nowMs, resourcedomain.GrantStrategyExtendExpiry) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } @@ -365,6 +378,22 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } + // 订单回执必须固化本次购买完成时的权益窗口;后续续期仍复用同一 entitlement, + // 若重放时直接读当前行,旧 command_id 会错误返回后续订单已经延长的到期时间和数量。 + metadata["entitlement_snapshot_version"] = 1 + metadata["entitlement_id"] = resource.EntitlementID + metadata["entitlement_status"] = resource.Status + metadata["entitlement_quantity"] = resource.Quantity + metadata["entitlement_remaining_quantity"] = resource.RemainingQuantity + metadata["entitlement_effective_at_ms"] = resource.EffectiveAtMS + metadata["entitlement_expires_at_ms"] = resource.ExpiresAtMS + metadata["entitlement_source_grant_id"] = resource.SourceGrantID + metadata["entitlement_created_at_ms"] = resource.CreatedAtMS + metadata["entitlement_updated_at_ms"] = resource.UpdatedAtMS + metadata["entitlement_equipped"] = resource.Equipped + if err := r.updateTransactionMetadata(ctx, tx, transactionID, metadata, nowMs); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } if err := r.insertWalletOutbox(ctx, tx, []walletOutboxEvent{ balanceChangedEvent(transactionID, command.CommandID, command.UserID, ledger.AssetCoin, -item.CoinPrice, 0, coinBalanceAfter, account.FrozenAmount, account.Version+1, metadata, nowMs, bizTypeResourceShopPurchase), resourceOutboxEvent("ResourceGranted", command.CommandID, command.UserID, item.ResourceID, map[string]any{"grant_id": grantID, "resource": item.Resource, "source": resourcedomain.GrantSourceResourceShop}, nowMs), @@ -403,6 +432,24 @@ func (r *Repository) PurchaseResourceShopItem(ctx context.Context, command resou }, nil } +// replayResourceShopPurchase 在原事务回滚后读取已提交订单,供同 command_id 的并发请求稳定返回同一回执。 +func (r *Repository) replayResourceShopPurchase(ctx context.Context, command resourcedomain.ResourceShopPurchaseCommand, requestHash string) (resourcedomain.ResourceShopPurchaseReceipt, error) { + tx, err := r.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true, Isolation: sql.LevelRepeatableRead}) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + defer func() { _ = tx.Rollback() }() + + txRow, exists, err := r.lookupTransactionConsistent(ctx, tx, command.CommandID, requestHash, bizTypeResourceShopPurchase, xerr.LedgerConflict) + if err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if !exists { + return resourcedomain.ResourceShopPurchaseReceipt{}, xerr.New(xerr.Unavailable, "resource shop purchase idempotent receipt is not visible") + } + return r.resourceShopPurchaseReceiptForTransaction(ctx, tx, txRow.TransactionID, command.UserID) +} + func (r *Repository) getResourceShopItem(ctx context.Context, shopItemID int64) (resourcedomain.ResourceShopItem, error) { return r.getResourceShopItemWithQuerier(ctx, r.db, shopItemID, false) } @@ -464,12 +511,15 @@ func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Conte var priceCoin int64 var grantID string var entitlementID string + var metadataJSON string err := tx.QueryRowContext(ctx, ` - SELECT order_id, shop_item_id, duration_days, price_coin, resource_grant_id, entitlement_id - FROM resource_shop_purchase_orders - WHERE app_code = ? AND wallet_transaction_id = ? AND user_id = ?`, + SELECT po.order_id, po.shop_item_id, po.duration_days, po.price_coin, + po.resource_grant_id, po.entitlement_id, COALESCE(CAST(wt.metadata_json AS CHAR), '{}') + FROM resource_shop_purchase_orders po + JOIN wallet_transactions wt ON wt.app_code = po.app_code AND wt.transaction_id = po.wallet_transaction_id + WHERE po.app_code = ? AND po.wallet_transaction_id = ? AND po.user_id = ?`, appcode.FromContext(ctx), transactionID, userID, - ).Scan(&orderID, &shopItemID, &durationDays, &priceCoin, &grantID, &entitlementID) + ).Scan(&orderID, &shopItemID, &durationDays, &priceCoin, &grantID, &entitlementID, &metadataJSON) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } @@ -484,14 +534,46 @@ func (r *Repository) resourceShopPurchaseReceiptForTransaction(ctx context.Conte if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } + var metadata struct { + EntitlementSnapshotVersion int `json:"entitlement_snapshot_version"` + EntitlementID string `json:"entitlement_id"` + EntitlementStatus string `json:"entitlement_status"` + EntitlementQuantity int64 `json:"entitlement_quantity"` + EntitlementRemaining int64 `json:"entitlement_remaining_quantity"` + EntitlementEffectiveAtMS int64 `json:"entitlement_effective_at_ms"` + EntitlementExpiresAtMS int64 `json:"entitlement_expires_at_ms"` + EntitlementSourceGrantID string `json:"entitlement_source_grant_id"` + EntitlementCreatedAtMS int64 `json:"entitlement_created_at_ms"` + EntitlementUpdatedAtMS int64 `json:"entitlement_updated_at_ms"` + EntitlementEquipped bool `json:"entitlement_equipped"` + BalanceVersion int64 `json:"balance_version"` + } + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return resourcedomain.ResourceShopPurchaseReceipt{}, err + } + if metadata.EntitlementSnapshotVersion > 0 && metadata.EntitlementID == entitlementID { + resource.Status = metadata.EntitlementStatus + resource.Quantity = metadata.EntitlementQuantity + resource.RemainingQuantity = metadata.EntitlementRemaining + resource.EffectiveAtMS = metadata.EntitlementEffectiveAtMS + resource.ExpiresAtMS = metadata.EntitlementExpiresAtMS + resource.SourceGrantID = metadata.EntitlementSourceGrantID + resource.CreatedAtMS = metadata.EntitlementCreatedAtMS + resource.UpdatedAtMS = metadata.EntitlementUpdatedAtMS + resource.Equipped = metadata.EntitlementEquipped + } balance, err := r.balanceAfterTransaction(ctx, tx, transactionID, userID, ledger.AssetCoin) if err != nil { return resourcedomain.ResourceShopPurchaseReceipt{}, err } - if account, exists, err := r.queryAccountForUpdate(ctx, tx, userID, ledger.AssetCoin); err != nil { + if metadata.BalanceVersion > 0 { + balance.Version = metadata.BalanceVersion + } else if err := tx.QueryRowContext(ctx, ` + SELECT version FROM wallet_accounts + WHERE app_code = ? AND user_id = ? AND asset_type = ?`, + appcode.FromContext(ctx), userID, ledger.AssetCoin, + ).Scan(&balance.Version); err != nil && !errors.Is(err, sql.ErrNoRows) { return resourcedomain.ResourceShopPurchaseReceipt{}, err - } else if exists { - balance.Version = account.Version } return resourcedomain.ResourceShopPurchaseReceipt{ OrderID: orderID, diff --git a/services/wallet-service/internal/storage/mysql/salary_withdrawal.go b/services/wallet-service/internal/storage/mysql/salary_withdrawal.go index 884c5721..96dc20ea 100644 --- a/services/wallet-service/internal/storage/mysql/salary_withdrawal.go +++ b/services/wallet-service/internal/storage/mysql/salary_withdrawal.go @@ -2,6 +2,8 @@ package mysql import ( "context" + "database/sql" + "encoding/json" "fmt" "strings" "time" @@ -61,6 +63,15 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S } defer func() { _ = tx.Rollback() }() + // 财务旧版 command id 带 approved/rejected,新版按 finance stage 固定。在 command 幂等前先锁定提现申请的唯一终局, + // 才能让旧 command 成功但 admin 未终态的 partial-pending 重试返回原回执,且阻止另一个 command 执行相反决策。 + if receipt, exists, err := r.lookupSalaryWithdrawalTerminal(ctx, tx, command, mutation.BizType); err != nil || exists { + if err != nil || !exists { + return ledger.SalaryWithdrawalReceipt{}, err + } + return receipt, nil + } + requestHash := salaryWithdrawalRequestHash(command, mutation.BizType) if txRow, exists, err := r.lookupTransactionWithConflictCode(ctx, tx, command.CommandID, requestHash, mutation.BizType, xerr.IdempotencyConflict); err != nil || exists { if err != nil || !exists { @@ -129,6 +140,23 @@ func (r *Repository) applySalaryWithdrawal(ctx context.Context, command ledger.S } func salaryWithdrawalRequestHash(command ledger.SalaryWithdrawalCommand, bizType string) string { + if strings.TrimSpace(command.WithdrawalApplicationID) != "" && (bizType == bizTypeSalaryWithdrawalSettle || bizType == bizTypeSalaryWithdrawalRelease) { + // 审核人和备注是钱包 metadata 快照,不决定资金突变。将它们排除后,admin 事务失败换人/改备注重试仍命中原交易。 + // bizType 仍在哈希中,且 lookupSalaryWithdrawalTerminal 会在申请维度检查相反终局,settle/release 绝不能共存。 + return stableHash(fmt.Sprintf("salary_withdrawal_terminal|%s|%s|%d|%s|%d|%d|%d|%d|%d|%s", + bizType, + appcode.Normalize(command.AppCode), + command.UserID, + strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)), + command.SalaryUSDMinor, + command.PointFeeAmount, + command.PointNetAmount, + command.PointsPerUSD, + command.PointWithdrawFeeBPS, + strings.TrimSpace(command.WithdrawalApplicationID), + )) + } + // 冻结和申请创建失败的回滚没有 application id,继续使用旧哈希保持已有 command 兼容。 return stableHash(fmt.Sprintf("salary_withdrawal|%s|%s|%d|%s|%d|%d|%d|%d|%d|%d|%s|%s|%s", bizType, appcode.Normalize(command.AppCode), @@ -145,3 +173,83 @@ func salaryWithdrawalRequestHash(command ledger.SalaryWithdrawalCommand, bizType strings.TrimSpace(command.WithdrawalApplicationID), )) } + +// lookupSalaryWithdrawalTerminal 先用小型主键锁表串行化同一申请,再使用 (app_code, external_ref) 现有索引查找旧/新终局。 +// InnoDB 纯 gap lock 之间可兼容,不能单独作为“未存在终局”的互斥;显式锁行使首次并发 settle/release 也只有一个能落账。 +func (r *Repository) lookupSalaryWithdrawalTerminal(ctx context.Context, tx *sql.Tx, command ledger.SalaryWithdrawalCommand, requestedBizType string) (ledger.SalaryWithdrawalReceipt, bool, error) { + applicationID := strings.TrimSpace(command.WithdrawalApplicationID) + if applicationID == "" || (requestedBizType != bizTypeSalaryWithdrawalSettle && requestedBizType != bizTypeSalaryWithdrawalRelease) { + return ledger.SalaryWithdrawalReceipt{}, false, nil + } + nowMS := time.Now().UTC().UnixMilli() + if _, err := tx.ExecContext(ctx, ` + INSERT INTO wallet_withdrawal_terminal_locks ( + app_code, withdrawal_application_id, created_at_ms, updated_at_ms + ) VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE withdrawal_application_id = VALUES(withdrawal_application_id)`, + appcode.FromContext(ctx), applicationID, nowMS, nowMS, + ); err != nil { + return ledger.SalaryWithdrawalReceipt{}, false, err + } + rows, err := tx.QueryContext(ctx, ` + SELECT transaction_id, biz_type, COALESCE(CAST(metadata_json AS CHAR), '{}') + FROM wallet_transactions + WHERE app_code = ? + AND external_ref = ? + AND biz_type IN (?, ?) + ORDER BY created_at_ms ASC, transaction_id ASC + FOR UPDATE`, appcode.FromContext(ctx), applicationID, bizTypeSalaryWithdrawalSettle, bizTypeSalaryWithdrawalRelease) + if err != nil { + return ledger.SalaryWithdrawalReceipt{}, false, err + } + defer rows.Close() + + var terminal *struct { + TransactionID string + BizType string + Metadata salaryWithdrawalMetadata + } + for rows.Next() { + var transactionID string + var bizType string + var metadataJSON string + if err := rows.Scan(&transactionID, &bizType, &metadataJSON); err != nil { + return ledger.SalaryWithdrawalReceipt{}, false, err + } + if terminal != nil { + // 历史如果已经存在多个终局,钱包不能猜测哪个是真实决策,必须 fail closed 交由人工对账。 + return ledger.SalaryWithdrawalReceipt{}, true, xerr.New(xerr.IdempotencyConflict, "withdrawal application has multiple wallet terminal transactions") + } + var metadata salaryWithdrawalMetadata + if err := json.Unmarshal([]byte(metadataJSON), &metadata); err != nil { + return ledger.SalaryWithdrawalReceipt{}, false, err + } + terminal = &struct { + TransactionID string + BizType string + Metadata salaryWithdrawalMetadata + }{TransactionID: transactionID, BizType: bizType, Metadata: metadata} + } + if err := rows.Err(); err != nil { + return ledger.SalaryWithdrawalReceipt{}, false, err + } + if terminal == nil { + return ledger.SalaryWithdrawalReceipt{}, false, nil + } + if terminal.BizType != requestedBizType || !salaryWithdrawalTerminalMatches(command, terminal.Metadata) { + return ledger.SalaryWithdrawalReceipt{}, true, xerr.New(xerr.IdempotencyConflict, "withdrawal application terminal idempotency conflict") + } + return receiptFromSalaryWithdrawalMetadata(terminal.TransactionID, terminal.Metadata), true, nil +} + +func salaryWithdrawalTerminalMatches(command ledger.SalaryWithdrawalCommand, metadata salaryWithdrawalMetadata) bool { + return appcode.Normalize(metadata.AppCode) == appcode.Normalize(command.AppCode) && + metadata.UserID == command.UserID && + strings.ToUpper(strings.TrimSpace(metadata.SalaryAssetType)) == strings.ToUpper(strings.TrimSpace(command.SalaryAssetType)) && + metadata.SalaryUSDMinor == command.SalaryUSDMinor && + metadata.PointFeeAmount == command.PointFeeAmount && + metadata.PointNetAmount == command.PointNetAmount && + metadata.PointsPerUSD == command.PointsPerUSD && + metadata.PointWithdrawFeeBPS == command.PointWithdrawFeeBPS && + strings.TrimSpace(metadata.WithdrawalApplicationID) == strings.TrimSpace(command.WithdrawalApplicationID) +}