From 87b3ebf167e390e0d2e7aaf869c3e764f6a92528 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 17 Jun 2026 16:09:44 +0800 Subject: [PATCH 1/4] fix(wallet): sanitize mifapay return url --- .../service/wallet/external_recharge.go | 17 ++++++++--------- .../internal/service/wallet/service_test.go | 4 ++++ 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/services/wallet-service/internal/service/wallet/external_recharge.go b/services/wallet-service/internal/service/wallet/external_recharge.go index 624cc831..f9efc781 100644 --- a/services/wallet-service/internal/service/wallet/external_recharge.go +++ b/services/wallet-service/internal/service/wallet/external_recharge.go @@ -230,7 +230,7 @@ func (s *Service) createMifaPayRechargeOrder(ctx context.Context, command ledger PayWay: order.PayWay, PayType: order.PayType, NotifyURL: notifyURL, - ReturnURL: mifaPayReturnURLWithOrderID(returnURL, order.OrderID), + ReturnURL: mifaPayProviderReturnURL(returnURL), ClientIP: command.ClientIP, Language: command.Language, ProviderAmountMinor: providerAmountMinor, @@ -552,20 +552,19 @@ func mifaPayQueryMatchesOrder(data MifaPayNotifyData, order ledger.ExternalRecha return err == nil && amount == order.ProviderAmountMinor } -func mifaPayReturnURLWithOrderID(returnURL string, orderID string) string { +func mifaPayProviderReturnURL(returnURL string) string { returnURL = strings.TrimSpace(returnURL) - orderID = strings.TrimSpace(orderID) - if returnURL == "" || orderID == "" { + if returnURL == "" { return returnURL } parsed, err := url.Parse(returnURL) - if err != nil { + if err != nil || parsed.Scheme == "" || parsed.Host == "" { return returnURL } - query := parsed.Query() - // hyapp_order_id 是 H5 从三方收银台返回后的恢复锚点;真实归属仍由 gateway/wallet 按 target_user_id 校验。 - query.Set("hyapp_order_id", orderID) - parsed.RawQuery = query.Encode() + // MiFaPay 生产网关会对 payment.resultRedirect 做严格格式校验,动态 query 会在下单阶段被拒。 + // 订单 ID 已经通过创建订单响应返回给 H5 并落在钱包库,回跳页只能作为稳定入口,不能依赖三方回传本地查询参数。 + parsed.RawQuery = "" + parsed.Fragment = "" return parsed.String() } diff --git a/services/wallet-service/internal/service/wallet/service_test.go b/services/wallet-service/internal/service/wallet/service_test.go index c873b1d6..15fb1092 100644 --- a/services/wallet-service/internal/service/wallet/service_test.go +++ b/services/wallet-service/internal/service/wallet/service_test.go @@ -2251,6 +2251,7 @@ func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) { ProductID: product.ProductID, ProviderCode: ledger.PaymentProviderMifaPay, PaymentMethodID: saCardMethodID, + ReturnURL: "https://h5.global-interaction.com/recharge/index.html?app_code=lalu#checkout", Language: "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6", PayerName: "normal55", PayerAccount: "163055", @@ -2265,6 +2266,9 @@ func TestH5MifaPayOrderRejectsUpstreamFailureAsUnavailable(t *testing.T) { if mifaPay.createReq.Language != "zh-CN" { t.Fatalf("mifapay language should be normalized before upstream call: %+v", mifaPay.createReq) } + if mifaPay.createReq.ReturnURL != "https://h5.global-interaction.com/recharge/index.html" { + t.Fatalf("mifapay return url should be sent as stable provider redirect URL: %+v", mifaPay.createReq) + } if len(mifaPay.createReq.OrderID) != 32 { t.Fatalf("mifapay order_id must be 32 chars for upstream compatibility, got %q", mifaPay.createReq.OrderID) } From 2a488daeed289d1e87ea08a77524b4b1f12e516f Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 17 Jun 2026 13:00:10 +0800 Subject: [PATCH 2/4] fix(room): isolate room list by region and sort by country --- api/proto/room/v1/room.pb.go | 20 +++-- api/proto/room/v1/room.proto | 2 + .../admin/internal/modules/roomadmin/pin.go | 3 + .../internal/transport/http/response_test.go | 2 +- .../transport/http/roomapi/room_handler.go | 17 ++-- .../deploy/mysql/initdb/001_room_service.sql | 2 +- .../internal/room/service/admin_config_pin.go | 9 +- .../internal/room/service/list.go | 24 ++--- .../internal/room/service/pin_test.go | 87 ++++++++++--------- .../internal/room/service/repository.go | 2 + .../internal/storage/mysql/repository.go | 58 ++++++++++--- 11 files changed, 145 insertions(+), 81 deletions(-) diff --git a/api/proto/room/v1/room.pb.go b/api/proto/room/v1/room.pb.go index 045db875..5291ec72 100644 --- a/api/proto/room/v1/room.pb.go +++ b/api/proto/room/v1/room.pb.go @@ -8367,9 +8367,11 @@ type ListRoomsRequest struct { Limit int32 `protobuf:"varint,6,opt,name=limit,proto3" json:"limit,omitempty"` Query string `protobuf:"bytes,7,opt,name=query,proto3" json:"query,omitempty"` // country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。 - CountryCode string `protobuf:"bytes,8,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + CountryCode string `protobuf:"bytes,8,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"` + // viewer_country_code 是当前用户国家,用于区域内国家优先排序,不作为客户端可选筛选条件。 + ViewerCountryCode string `protobuf:"bytes,9,opt,name=viewer_country_code,json=viewerCountryCode,proto3" json:"viewer_country_code,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListRoomsRequest) Reset() { @@ -8458,6 +8460,13 @@ func (x *ListRoomsRequest) GetCountryCode() string { return "" } +func (x *ListRoomsRequest) GetViewerCountryCode() string { + if x != nil { + return x.ViewerCountryCode + } + return "" +} + // ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。 // 它和公共房间发现列表分离,避免把用户关系流误建模成房间全集过滤。 type ListRoomFeedsRequest struct { @@ -10948,7 +10957,7 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x1aVerifyRoomPresenceResponse\x12\x18\n" + "\apresent\x18\x01 \x01(\bR\apresent\x12\x16\n" + "\x06reason\x18\x02 \x01(\tR\x06reason\x12!\n" + - "\froom_version\x18\x03 \x01(\x03R\vroomVersion\"\x8d\x02\n" + + "\froom_version\x18\x03 \x01(\x03R\vroomVersion\"\xbd\x02\n" + "\x10ListRoomsRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" + @@ -10957,7 +10966,8 @@ const file_proto_room_v1_room_proto_rawDesc = "" + "\x06cursor\x18\x05 \x01(\tR\x06cursor\x12\x14\n" + "\x05limit\x18\x06 \x01(\x05R\x05limit\x12\x14\n" + "\x05query\x18\a \x01(\tR\x05query\x12!\n" + - "\fcountry_code\x18\b \x01(\tR\vcountryCode\"\xb7\x02\n" + + "\fcountry_code\x18\b \x01(\tR\vcountryCode\x12.\n" + + "\x13viewer_country_code\x18\t \x01(\tR\x11viewerCountryCode\"\xb7\x02\n" + "\x14ListRoomFeedsRequest\x12.\n" + "\x04meta\x18\x01 \x01(\v2\x1a.hyapp.room.v1.RequestMetaR\x04meta\x12$\n" + "\x0eviewer_user_id\x18\x02 \x01(\x03R\fviewerUserId\x12*\n" + diff --git a/api/proto/room/v1/room.proto b/api/proto/room/v1/room.proto index cbe71c4d..975dc6ff 100644 --- a/api/proto/room/v1/room.proto +++ b/api/proto/room/v1/room.proto @@ -1003,6 +1003,8 @@ message ListRoomsRequest { string query = 7; // country_code 是可选国家筛选值;gateway 只能传当前用户区域国家列表内的国家码。 string country_code = 8; + // viewer_country_code 是当前用户国家,用于区域内国家优先排序,不作为客户端可选筛选条件。 + string viewer_country_code = 9; } // ListRoomFeedsRequest 查询 Mine 页 visited/friend/following/followed 房间流。 diff --git a/server/admin/internal/modules/roomadmin/pin.go b/server/admin/internal/modules/roomadmin/pin.go index c6701a08..28fb811c 100644 --- a/server/admin/internal/modules/roomadmin/pin.go +++ b/server/admin/internal/modules/roomadmin/pin.go @@ -17,6 +17,7 @@ const ( roomPinStatusAll = "all" roomPinTypeRegion = "region" roomPinTypeGlobal = "global" + roomPinTypeCountry = "country" defaultRoomPinDays = int64(30) maxRoomPinDays = int64(3650) roomPinDayMillis = int64(24 * 60 * 60 * 1000) @@ -142,6 +143,8 @@ func normalizeRoomPinType(pinType string) string { switch strings.ToLower(strings.TrimSpace(pinType)) { case roomPinTypeGlobal: return roomPinTypeGlobal + case roomPinTypeCountry: + return roomPinTypeCountry case roomPinTypeRegion: return roomPinTypeRegion default: diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index a521a559..1a72f8d6 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -2641,7 +2641,7 @@ func TestListRoomsUsesUserRegionFromUserService(t *testing.T) { if queryClient.lastList == nil { t.Fatal("room query client was not called") } - if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 { + if queryClient.lastList.GetViewerUserId() != 42 || queryClient.lastList.GetVisibleRegionId() != 1001 || queryClient.lastList.GetViewerCountryCode() != "US" { t.Fatalf("ListRooms must use authenticated user and server-side region: %+v", queryClient.lastList) } if queryClient.lastList.GetTab() != "hot" || queryClient.lastList.GetLimit() != 2 || queryClient.lastList.GetCursor() != "cursor-1" || queryClient.lastList.GetQuery() != "room" || queryClient.lastList.GetCountryCode() != "US" { diff --git a/services/gateway-service/internal/transport/http/roomapi/room_handler.go b/services/gateway-service/internal/transport/http/roomapi/room_handler.go index 3806b7b0..fa8c09a6 100644 --- a/services/gateway-service/internal/transport/http/roomapi/room_handler.go +++ b/services/gateway-service/internal/transport/http/roomapi/room_handler.go @@ -108,14 +108,15 @@ func (h *Handler) listRooms(writer http.ResponseWriter, request *http.Request) { } resp, err := h.roomQueryClient.ListRooms(request.Context(), &roomv1.ListRoomsRequest{ - Meta: httpkit.RoomMeta(request, "", ""), - ViewerUserId: viewerUserID, - VisibleRegionId: userResp.GetUser().GetRegionId(), - Tab: tab, - Cursor: request.URL.Query().Get("cursor"), - Limit: limit, - Query: roomListQuery(request), - CountryCode: countryCode, + Meta: httpkit.RoomMeta(request, "", ""), + ViewerUserId: viewerUserID, + VisibleRegionId: userResp.GetUser().GetRegionId(), + ViewerCountryCode: userResp.GetUser().GetCountry(), + Tab: tab, + Cursor: request.URL.Query().Get("cursor"), + Limit: limit, + Query: roomListQuery(request), + CountryCode: countryCode, }) if err != nil { httpkit.WriteRPCError(writer, request, err) diff --git a/services/room-service/deploy/mysql/initdb/001_room_service.sql b/services/room-service/deploy/mysql/initdb/001_room_service.sql index 807db00a..dcb421be 100644 --- a/services/room-service/deploy/mysql/initdb/001_room_service.sql +++ b/services/room-service/deploy/mysql/initdb/001_room_service.sql @@ -183,7 +183,7 @@ CREATE TABLE IF NOT EXISTS room_region_pins ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID', app_code VARCHAR(32) NOT NULL COMMENT '应用编码,用于多租户隔离', visible_region_id BIGINT NOT NULL COMMENT '可见区域 ID', - pin_type VARCHAR(32) NOT NULL DEFAULT 'region' COMMENT '置顶类型:region 区域置顶,global 全区置顶', + pin_type VARCHAR(32) NOT NULL DEFAULT 'region' COMMENT '置顶类型:region 区域置顶,country 国家置顶,global 历史全区置顶', room_id VARCHAR(64) NOT NULL COMMENT '房间 ID', weight BIGINT NOT NULL DEFAULT 0 COMMENT '权重', status VARCHAR(32) NOT NULL COMMENT '业务状态', diff --git a/services/room-service/internal/room/service/admin_config_pin.go b/services/room-service/internal/room/service/admin_config_pin.go index 44a40151..4aa5bef5 100644 --- a/services/room-service/internal/room/service/admin_config_pin.go +++ b/services/room-service/internal/room/service/admin_config_pin.go @@ -13,9 +13,10 @@ import ( var adminSeatCandidates = []int32{10, 15, 20, 25, 30} const ( - roomPinTypeRegion = "region" - roomPinTypeGlobal = "global" - roomPinDayMillis = int64(24 * 60 * 60 * 1000) + roomPinTypeRegion = "region" + roomPinTypeGlobal = "global" + roomPinTypeCountry = "country" + roomPinDayMillis = int64(24 * 60 * 60 * 1000) ) func (s *Service) AdminGetRoomSeatConfig(ctx context.Context, _ *roomv1.AdminGetRoomSeatConfigRequest) (*roomv1.AdminGetRoomSeatConfigResponse, error) { @@ -156,6 +157,8 @@ func normalizeRoomPinType(pinType string) string { switch strings.ToLower(strings.TrimSpace(pinType)) { case roomPinTypeGlobal: return roomPinTypeGlobal + case roomPinTypeCountry: + return roomPinTypeCountry default: // 空值和未知值都回落到区域置顶,兼容旧后台只传 room_id/duration_days 的请求。 return roomPinTypeRegion diff --git a/services/room-service/internal/room/service/list.go b/services/room-service/internal/room/service/list.go index f9bf7841..d4e25759 100644 --- a/services/room-service/internal/room/service/list.go +++ b/services/room-service/internal/room/service/list.go @@ -37,6 +37,7 @@ type roomListCursor struct { Tab string `json:"tab"` Query string `json:"query,omitempty"` CountryCode string `json:"country_code,omitempty"` + ViewerCountry string `json:"viewer_country,omitempty"` SortScore int64 `json:"sort_score,omitempty"` CreatedAtMS int64 `json:"created_at_ms,omitempty"` UpdatedAtMS int64 `json:"updated_at_ms,omitempty"` @@ -68,7 +69,8 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) ( return nil, err } countryCode := normalizeRoomCountryCode(req.GetCountryCode()) - cursor, err := decodeRoomListCursor(tab, query, countryCode, req.GetCursor()) + viewerCountryCode := normalizeRoomCountryCode(req.GetViewerCountryCode()) + cursor, err := decodeRoomListCursor(tab, query, countryCode, viewerCountryCode, req.GetCursor()) if err != nil { return nil, err } @@ -79,6 +81,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) ( Tab: tab, Query: query, CountryCode: countryCode, + ViewerCountryCode: viewerCountryCode, Limit: limit + 1, CursorSortScore: cursor.SortScore, CursorCreatedAtMS: cursor.CreatedAtMS, @@ -93,7 +96,7 @@ func (s *Service) ListRooms(ctx context.Context, req *roomv1.ListRoomsRequest) ( return nil, err } - return roomListResponseFromEntries(tab, query, countryCode, entries, limit), nil + return roomListResponseFromEntries(tab, query, countryCode, viewerCountryCode, entries, limit), nil } // ListRoomFeeds 查询 Mine 页 visited/friend/following/followed 房间流。 @@ -115,7 +118,7 @@ func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRe if err != nil { return nil, err } - cursor, err := decodeRoomListCursor(tab, query, "", req.GetCursor()) + cursor, err := decodeRoomListCursor(tab, query, "", "", req.GetCursor()) if err != nil { return nil, err } @@ -163,7 +166,7 @@ func (s *Service) ListRoomFeeds(ctx context.Context, req *roomv1.ListRoomFeedsRe return nil, err } - return roomListResponseFromEntries(tab, query, "", entries, limit), nil + return roomListResponseFromEntries(tab, query, "", "", entries, limit), nil } // GetMyRoom 查询当前用户自己创建的房间,不依赖 room_list_entries 投影命中。 @@ -196,12 +199,12 @@ func (s *Service) GetMyRoom(ctx context.Context, req *roomv1.GetMyRoomRequest) ( }, nil } -func roomListResponseFromEntries(tab string, query string, countryCode string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse { +func roomListResponseFromEntries(tab string, query string, countryCode string, viewerCountryCode string, entries []RoomListEntry, limit int) *roomv1.ListRoomsResponse { nextCursor := "" if len(entries) > limit { // 多查一条用于判断是否存在下一页;返回数据只包含请求的 limit 条。 last := entries[limit-1] - nextCursor = encodeRoomListCursor(tab, query, countryCode, last) + nextCursor = encodeRoomListCursor(tab, query, countryCode, viewerCountryCode, last) entries = entries[:limit] } @@ -406,10 +409,10 @@ func normalizeRoomCountryCode(countryCode string) string { } // decodeRoomListCursor 校验不透明 cursor 和当前 tab 一致,禁止客户端跨排序维度复用游标。 -func decodeRoomListCursor(tab string, query string, countryCode string, encoded string) (roomListCursor, error) { +func decodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, encoded string) (roomListCursor, error) { encoded = strings.TrimSpace(encoded) if encoded == "" { - return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode}, nil + return roomListCursor{Tab: tab, Query: query, CountryCode: countryCode, ViewerCountry: viewerCountryCode}, nil } payload, err := base64.RawURLEncoding.DecodeString(encoded) @@ -421,7 +424,7 @@ func decodeRoomListCursor(tab string, query string, countryCode string, encoded if err := json.Unmarshal(payload, &cursor); err != nil { return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid") } - if cursor.Tab != tab || cursor.Query != query || cursor.CountryCode != countryCode || cursor.RoomID == "" { + if cursor.Tab != tab || cursor.Query != query || cursor.CountryCode != countryCode || cursor.ViewerCountry != viewerCountryCode || cursor.RoomID == "" { return roomListCursor{}, xerr.New(xerr.InvalidArgument, "cursor is invalid") } @@ -430,11 +433,12 @@ func decodeRoomListCursor(tab string, query string, countryCode string, encoded // encodeRoomListCursor 把最后一条列表记录编码成下一页边界。 // cursor 不暴露契约语义,后续调整排序字段时可以只兼容服务端解析逻辑。 -func encodeRoomListCursor(tab string, query string, countryCode string, entry RoomListEntry) string { +func encodeRoomListCursor(tab string, query string, countryCode string, viewerCountryCode string, entry RoomListEntry) string { cursor := roomListCursor{ Tab: tab, Query: query, CountryCode: countryCode, + ViewerCountry: viewerCountryCode, SortScore: entry.SortScore, CreatedAtMS: entry.CreatedAtMS, UpdatedAtMS: entry.UpdatedAtMS, diff --git a/services/room-service/internal/room/service/pin_test.go b/services/room-service/internal/room/service/pin_test.go index 3bb8c64b..696830ca 100644 --- a/services/room-service/internal/room/service/pin_test.go +++ b/services/room-service/internal/room/service/pin_test.go @@ -64,7 +64,7 @@ func TestRegionalPinOrdersPublicRoomList(t *testing.T) { } } -func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForHot(t *testing.T) { +func TestPinnedRoomListOrdersRegionCountryThenCountryBucketsForHot(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := roomservice.New(roomservice.Config{ @@ -74,42 +74,46 @@ func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForHot(t *testing.T) { SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) - createPinnedListRoom(t, ctx, svc, "room-global-pin", "global-pin", 3101, 9002) - createPinnedListRoom(t, ctx, svc, "room-region-pin", "region-pin", 3102, 9001) - createPinnedListRoom(t, ctx, svc, "room-local-online", "local-online", 3103, 9001) - createPinnedListRoom(t, ctx, svc, "room-other-online", "other-online", 3104, 9002) - createPinnedListRoom(t, ctx, svc, "room-local-empty", "local-empty", 3105, 9001) - createPinnedListRoom(t, ctx, svc, "room-other-empty", "other-empty", 3106, 9002) - repository.SetRoomSortScore("room-global-pin", 1) - repository.SetRoomSortScore("room-region-pin", 2) - repository.SetRoomSortScore("room-local-online", 100) - repository.SetRoomSortScore("room-other-online", 10000) - repository.SetRoomSortScore("room-local-empty", 100000) - repository.SetRoomSortScore("room-other-empty", 200000) - repository.SetRoomPresence("room-local-online", 1, 0) - repository.SetRoomPresence("room-other-online", 1, 0) - repository.SetRoomPresence("room-local-empty", 0, 0) - repository.SetRoomPresence("room-other-empty", 0, 0) + createPinnedListRoom(t, ctx, svc, "room-region-pin", "region-pin", 3101, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-country-pin", "country-pin", 3102, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-viewer-country-online", "viewer-online", 3103, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-other-country-online", "other-online", 3104, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-viewer-country-empty", "viewer-empty", 3105, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-other-country-empty", "other-empty", 3106, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-other-region-online", "other-region", 3107, 9002, "US") + repository.SetRoomSortScore("room-region-pin", 1) + repository.SetRoomSortScore("room-country-pin", 2) + repository.SetRoomSortScore("room-viewer-country-online", 100) + repository.SetRoomSortScore("room-other-country-online", 10000) + repository.SetRoomSortScore("room-viewer-country-empty", 100000) + repository.SetRoomSortScore("room-other-country-empty", 200000) + repository.SetRoomSortScore("room-other-region-online", 300000) + repository.SetRoomPresence("room-viewer-country-online", 1, 0) + repository.SetRoomPresence("room-other-country-online", 1, 0) + repository.SetRoomPresence("room-viewer-country-empty", 0, 0) + repository.SetRoomPresence("room-other-country-empty", 0, 0) + repository.SetRoomPresence("room-other-region-online", 1, 0) expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli() - repository.PinRoomWithType("room-global-pin", "global", 0, 1, expiresAtMS) repository.PinRoom("room-region-pin", 9001, 99, expiresAtMS) + repository.PinRoomWithType("room-country-pin", "country", 9001, 88, expiresAtMS) page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 4001, - VisibleRegionId: 9001, - Tab: "hot", - Limit: 6, + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, + ViewerUserId: 4001, + VisibleRegionId: 9001, + ViewerCountryCode: "US", + Tab: "hot", + Limit: 6, }) if err != nil { t.Fatalf("list hot rooms failed: %v", err) } - if got := roomIDs(page.GetRooms()); len(got) != 6 || got[0] != "room-global-pin" || got[1] != "room-region-pin" || got[2] != "room-local-online" || got[3] != "room-other-online" || got[4] != "room-local-empty" || got[5] != "room-other-empty" { + if got := roomIDs(page.GetRooms()); len(got) != 6 || got[0] != "room-region-pin" || got[1] != "room-country-pin" || got[2] != "room-viewer-country-online" || got[3] != "room-other-country-online" || got[4] != "room-viewer-country-empty" || got[5] != "room-other-country-empty" { t.Fatalf("hot order mismatch: %+v", got) } } -func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForNew(t *testing.T) { +func TestPinnedRoomListOrdersRegionCountryThenCountryBucketsForNew(t *testing.T) { ctx := context.Background() repository := mysqltest.NewRepository(t) svc := roomservice.New(roomservice.Config{ @@ -119,31 +123,34 @@ func TestPinnedRoomListOrdersGlobalRegionLocalThenOtherForNew(t *testing.T) { SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) - createPinnedListRoom(t, ctx, svc, "room-global-new", "global-new", 3201, 9002) - createPinnedListRoom(t, ctx, svc, "room-region-new", "region-new", 3202, 9001) - createPinnedListRoom(t, ctx, svc, "room-local-newer", "local-newer", 3203, 9001) - createPinnedListRoom(t, ctx, svc, "room-local-older", "local-older", 3204, 9001) - createPinnedListRoom(t, ctx, svc, "room-other-newest", "other-newest", 3205, 9002) - repository.SetRoomCreatedAt("room-global-new", 10) + createPinnedListRoom(t, ctx, svc, "room-region-new", "region-new", 3201, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-country-new", "country-new", 3202, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-viewer-newer", "viewer-newer", 3203, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-viewer-older", "viewer-older", 3204, 9001, "US") + createPinnedListRoom(t, ctx, svc, "room-other-newest", "other-newest", 3205, 9001, "AE") + createPinnedListRoom(t, ctx, svc, "room-other-region-newest", "other-region-newest", 3206, 9002, "US") repository.SetRoomCreatedAt("room-region-new", 20) - repository.SetRoomCreatedAt("room-local-newer", 300) - repository.SetRoomCreatedAt("room-local-older", 100) + repository.SetRoomCreatedAt("room-country-new", 10) + repository.SetRoomCreatedAt("room-viewer-newer", 300) + repository.SetRoomCreatedAt("room-viewer-older", 100) repository.SetRoomCreatedAt("room-other-newest", 1000) + repository.SetRoomCreatedAt("room-other-region-newest", 2000) expiresAtMS := time.Now().UTC().Add(24 * time.Hour).UnixMilli() - repository.PinRoomWithType("room-global-new", "global", 0, 1, expiresAtMS) repository.PinRoom("room-region-new", 9001, 99, expiresAtMS) + repository.PinRoomWithType("room-country-new", "country", 9001, 88, expiresAtMS) page, err := svc.ListRooms(ctx, &roomv1.ListRoomsRequest{ - Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, - ViewerUserId: 4001, - VisibleRegionId: 9001, - Tab: "new", - Limit: 5, + Meta: &roomv1.RequestMeta{AppCode: appcode.Default}, + ViewerUserId: 4001, + VisibleRegionId: 9001, + ViewerCountryCode: "US", + Tab: "new", + Limit: 5, }) if err != nil { t.Fatalf("list new rooms failed: %v", err) } - if got := roomIDs(page.GetRooms()); len(got) != 5 || got[0] != "room-global-new" || got[1] != "room-region-new" || got[2] != "room-local-newer" || got[3] != "room-local-older" || got[4] != "room-other-newest" { + if got := roomIDs(page.GetRooms()); len(got) != 5 || got[0] != "room-region-new" || got[1] != "room-country-new" || got[2] != "room-viewer-newer" || got[3] != "room-viewer-older" || got[4] != "room-other-newest" { t.Fatalf("new order mismatch: %+v", got) } } diff --git a/services/room-service/internal/room/service/repository.go b/services/room-service/internal/room/service/repository.go index 8fbeb6a1..4b7386bf 100644 --- a/services/room-service/internal/room/service/repository.go +++ b/services/room-service/internal/room/service/repository.go @@ -423,6 +423,8 @@ type RoomListQuery struct { Query string // CountryCode 非空时只返回房主国家命中的房间;gateway 必须先校验它属于当前用户区域。 CountryCode string + // ViewerCountryCode 是当前用户国家;公共列表不按它过滤,只用它在区域内把本国家房间排到同状态房间前。 + ViewerCountryCode string // Limit 是实际查询数量,service 层会限制最大值。 Limit int // CursorSortScore 是 hot tab 的游标 score。 diff --git a/services/room-service/internal/storage/mysql/repository.go b/services/room-service/internal/storage/mysql/repository.go index 3dec2f4b..8484fe5f 100644 --- a/services/room-service/internal/storage/mysql/repository.go +++ b/services/room-service/internal/storage/mysql/repository.go @@ -1937,7 +1937,7 @@ func (r *Repository) UpsertRoomListEntry(ctx context.Context, entry roomservice. return err } -// ListRoomListEntries 读取公共发现列表读模型,并按查询区域把全区置顶、区域置顶、本区房和外区房排成稳定分页序列。 +// ListRoomListEntries 读取公共发现列表读模型,并先按查询区域隔离,再在区域内部按置顶、用户国家和在线状态排成稳定分页序列。 func (r *Repository) ListRoomListEntries(ctx context.Context, query roomservice.RoomListQuery) ([]roomservice.RoomListEntry, error) { if query.Limit <= 0 { query.Limit = 20 @@ -2354,16 +2354,22 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { if regionID < 0 { regionID = 0 } - regionLiteral := strconv.FormatInt(regionID, 10) - pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL THEN 0 ELSE 1 END" - newPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " THEN 2 ELSE 3 END" - hotPinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 1 WHEN r.visible_region_id = " + regionLiteral + " AND r.online_count > 0 THEN 2 WHEN r.visible_region_id <> " + regionLiteral + " AND r.online_count > 0 THEN 3 WHEN r.visible_region_id = " + regionLiteral + " THEN 4 ELSE 5 END" - pinRankExpr := hotPinRankExpr - if query.Tab == "new" { - pinRankExpr = newPinRankExpr + viewerCountryCode := normalizeRoomListSQLCountryCode(query.ViewerCountryCode) + filterCountryCode := normalizeRoomListSQLCountryCode(query.CountryCode) + sortCountryCode := viewerCountryCode + if filterCountryCode != "" { + // 国家 tab 已经把结果集收敛到一个国家,国家置顶和国家优先桶都应按该 tab 国家解释。 + sortCountryCode = filterCountryCode } - pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight ELSE 0 END" - pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms ELSE 0 END" + normalRankExpr := "CASE WHEN r.online_count > 0 THEN 2 ELSE 4 END" + if sortCountryCode != "" { + countryLiteral := roomListSQLStringLiteral(sortCountryCode) + normalRankExpr = "CASE WHEN r.owner_country_code = " + countryLiteral + " AND r.online_count > 0 THEN 2 WHEN r.owner_country_code <> " + countryLiteral + " AND r.online_count > 0 THEN 3 WHEN r.owner_country_code = " + countryLiteral + " THEN 4 ELSE 5 END" + } + pinnedExpr := "CASE WHEN gp.room_id IS NULL AND rp.room_id IS NULL AND cp.room_id IS NULL THEN 0 ELSE 1 END" + pinRankExpr := "CASE WHEN gp.room_id IS NOT NULL THEN 0 WHEN rp.room_id IS NOT NULL THEN 0 WHEN cp.room_id IS NOT NULL THEN 1 ELSE " + normalRankExpr + " END" + pinWeightExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.weight WHEN rp.room_id IS NOT NULL THEN rp.weight WHEN cp.room_id IS NOT NULL THEN cp.weight ELSE 0 END" + pinExpiresExpr := "CASE WHEN gp.room_id IS NOT NULL THEN gp.expires_at_ms WHEN rp.room_id IS NOT NULL THEN rp.expires_at_ms WHEN cp.room_id IS NOT NULL THEN cp.expires_at_ms ELSE 0 END" selectSQL := ` SELECT r.app_code, r.room_id, r.room_short_id, r.visible_region_id, r.owner_country_code, r.owner_user_id, r.title, r.cover_url, r.mode, r.status, r.locked, r.heat, r.online_count, r.seat_count, r.occupied_seat_count, r.sort_score, r.created_at_ms, r.updated_at_ms, @@ -2384,9 +2390,18 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { AND rp.room_id = r.room_id AND rp.status = ? AND rp.pinned_at_ms <= ? - AND rp.expires_at_ms > ?` - where := []string{"r.app_code = ?", "r.status = ?"} - args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active"} + AND rp.expires_at_ms > ? + LEFT JOIN room_region_pins cp + ON cp.app_code = r.app_code + AND cp.pin_type = 'country' + AND cp.visible_region_id = ? + AND cp.room_id = r.room_id + AND r.owner_country_code = ? + AND cp.status = ? + AND cp.pinned_at_ms <= ? + AND cp.expires_at_ms > ?` + where := []string{"r.app_code = ?", "r.status = ?", "r.visible_region_id = ?"} + args := []any{"active", query.NowMS, query.NowMS, regionID, "active", query.NowMS, query.NowMS, regionID, sortCountryCode, "active", query.NowMS, query.NowMS, appcode.Normalize(query.AppCode), "active", regionID} if query.OwnerUserID > 0 { where = append(where, "r.owner_user_id = ?") args = append(args, query.OwnerUserID) @@ -2417,6 +2432,23 @@ func buildRoomListQuerySQL(query roomservice.RoomListQuery) (string, []any) { return selectSQL + "\n\tWHERE " + strings.Join(where, " AND ") + "\n\tORDER BY " + pinRankExpr + " ASC, " + pinWeightExpr + " DESC, " + pinExpiresExpr + " DESC, r.sort_score DESC, r.room_id ASC\n\tLIMIT ?", args } +func normalizeRoomListSQLCountryCode(countryCode string) string { + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + if len(countryCode) < 2 || len(countryCode) > 3 { + return "" + } + for _, ch := range countryCode { + if ch < 'A' || ch > 'Z' { + return "" + } + } + return countryCode +} + +func roomListSQLStringLiteral(value string) string { + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} + func (r *Repository) AdminListRooms(ctx context.Context, query roomservice.AdminRoomListQuery) ([]roomservice.AdminRoomListEntry, int64, error) { if query.Page <= 0 { query.Page = 1 From d9433647d301a1d1074370725409e1bfcb1a3252 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 17 Jun 2026 15:36:34 +0800 Subject: [PATCH 3/4] fix(game): require coin balance for room rps --- .../internal/service/roomrps/service.go | 96 ++++++++++++- .../internal/service/roomrps/service_test.go | 132 +++++++++++++++++- 2 files changed, 220 insertions(+), 8 deletions(-) diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 64a0e318..6e685f6b 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -62,6 +62,11 @@ type GiftCatalogClient interface { ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) } +// BalanceClient 只暴露 room-rps 入场前需要的钱包余额查询;PK 状态仍由 game-service 管,金币事实仍由 wallet-service 管。 +type BalanceClient interface { + GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) +} + // ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。 type ConfigStore interface { GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) @@ -83,6 +88,7 @@ type Service struct { config Config user UserClient gifts GiftCatalogClient + wallet BalanceClient pub Publisher store ConfigStore @@ -104,13 +110,18 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config.RevealCountdown = 3 * time.Second } var gifts GiftCatalogClient + var wallet BalanceClient var store ConfigStore for _, extra := range extras { - switch typed := extra.(type) { - case GiftCatalogClient: + if typed, ok := extra.(GiftCatalogClient); ok { // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 gifts = typed - case ConfigStore: + } + if typed, ok := extra.(BalanceClient); ok { + // 发起和应战都必须在进入状态机前查 COIN 余额;不做余额查询时会让 0 金币用户进入 PK 链路。 + wallet = typed + } + if typed, ok := extra.(ConfigStore); ok { // MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。 store = typed } @@ -119,6 +130,7 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config: config, user: user, gifts: gifts, + wallet: wallet, pub: publisher, store: store, configs: make(map[string]*gamev1.RoomRPSConfig), @@ -301,6 +313,14 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS if !ok || !gift.GetEnabled() { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled") } + stakeCoin := gift.GetGiftPriceCoin() + if stakeCoin <= 0 { + return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") + } + initiatorBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin) + if err != nil { + return nil, 0, err + } s.mu.Lock() now := s.now() @@ -313,8 +333,8 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS Status: StatusPending, StakeGiftId: legacyRoomRPSGiftID(giftID), StakeGiftIdText: giftID, - StakeCoin: gift.GetGiftPriceCoin(), - Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS}, + 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, @@ -370,9 +390,45 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } + stakeCoin := challenge.GetStakeCoin() + s.mu.Unlock() + + challengerBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin) + if err != nil { + return nil, 0, err + } + + s.mu.Lock() + challenge, ok = s.challenges[challengeID] + if !ok || challenge.GetAppCode() != app { + s.mu.Unlock() + return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") + } + now = s.now() + nowMS = now.UnixMilli() + if challenge.GetStatus() != StatusPending { + // 余额查询期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准。 + s.mu.Unlock() + return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") + } + if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { + // 二次校验也要收敛超时单,避免余额查询耗时跨过 timeout 后仍然匹配成功。 + challenge.Status = StatusTimeout + challenge.SettlementStatus = SettlementRefunded + challenge.UpdatedAtMs = nowMS + cloned := cloneChallenge(challenge) + s.mu.Unlock() + _ = s.publishChallengeEvent(ctx, eventExpired, cloned) + return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") + } + if challenge.GetInitiator().GetUserId() == req.GetUserId() { + // 二次校验保留同一条权限边界,防止测试或未来接口在第一次检查后篡改挑战归属。 + s.mu.Unlock() + return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") + } challenge.Status = StatusFinished - challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS} + challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: challengerBalance, JoinedAtMs: nowMS} challenge.MatchedAtMs = nowMS challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli() challenge.SettledAtMs = nowMS @@ -745,6 +801,34 @@ func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs [] return result, nil } +func (s *Service) ensureCoinBalance(ctx context.Context, requestID string, app string, userID int64, stakeCoin int64) (int64, error) { + if stakeCoin <= 0 { + return 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") + } + if s.wallet == nil { + // 没有 wallet-service 余额事实时不能放用户进入 PK;否则测试服/线上配置缺依赖会退化成免费 PK。 + return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured") + } + resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{ + RequestId: strings.TrimSpace(requestID), + UserId: userID, + AppCode: appcode.Normalize(app), + AssetTypes: []string{"COIN"}, + }) + if err != nil { + return 0, err + } + for _, balance := range resp.GetBalances() { + if strings.EqualFold(balance.GetAssetType(), "COIN") { + if balance.GetAvailableAmount() >= stakeCoin { + return balance.GetAvailableAmount(), nil + } + break + } + } + return 0, xerr.New(xerr.InsufficientBalance, "coin balance is insufficient") +} + func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string { if gift == nil { return "" diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index f628dab0..fe5b7b0a 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -129,7 +129,8 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { "Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 1), "lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 111), }) - svc := New(Config{}, nil, nil, catalog) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 10}) + svc := New(Config{}, nil, nil, catalog, wallet) svc.now = func() time.Time { return time.UnixMilli(1770000000000) } _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ @@ -161,6 +162,50 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 { t.Fatalf("string gift challenge mismatch: %+v", challenge) } + if challenge.GetInitiator().GetBalanceAfter() != 10 { + t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator()) + } +} + +func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) { + catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ + "Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 100), + "Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 200), + "Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 300), + "lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 400), + }) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 99}) + svc := New(Config{}, nil, nil, catalog, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1}, + {GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2}, + {GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3}, + {GiftIdText: "lw1", Enabled: true, SortOrder: 4}, + }, + }) + if err != nil { + t.Fatalf("UpdateConfig() error = %v", err) + } + + _, _, err = svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-low-balance"}, + UserId: 42, + RoomId: "room-1", + GiftIdText: "Gifi-3", + Gesture: "rock", + }) + if !xerr.IsCode(err, xerr.InsufficientBalance) { + t.Fatalf("CreateChallenge() error = %v, want InsufficientBalance", err) + } + if len(svc.challenges) != 0 { + t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges) + } } func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) { @@ -214,7 +259,8 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { CreatedAtMs: 1000, UpdatedAtMs: 2000, } - svc := New(Config{}, nil, nil, store) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 1000}) + svc := New(Config{}, nil, nil, store, wallet) svc.now = func() time.Time { return time.UnixMilli(1770000000000) } challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ @@ -232,6 +278,70 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { } } +func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 99}) + 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-accept-low-balance"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + if !xerr.IsCode(err, xerr.InsufficientBalance) { + t.Fatalf("AcceptChallenge() error = %v, want InsufficientBalance", err) + } + current, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId()) + if err != nil { + t.Fatalf("GetChallenge() error = %v", err) + } + if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 { + t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current) + } +} + +func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 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) + } + + accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + if err != nil { + t.Fatalf("AcceptChallenge() error = %v", err) + } + if accepted.GetStatus() != StatusFinished || accepted.GetChallenger().GetBalanceAfter() != 500 { + t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted) + } +} + type fakeGiftCatalog struct { gifts map[string]*walletv1.GiftConfig requests []*walletv1.ListGiftConfigsRequest @@ -249,6 +359,24 @@ func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListG return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil } +type fakeRoomRPSWallet struct { + balances map[int64]int64 + requests []*walletv1.GetBalancesRequest +} + +func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet { + return &fakeRoomRPSWallet{balances: balances} +} + +func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { + f.requests = append(f.requests, req) + return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{ + AppCode: req.GetAppCode(), + AssetType: "COIN", + AvailableAmount: f.balances[req.GetUserId()], + }}}, nil +} + func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig { return &walletv1.GiftConfig{ AppCode: "lalu", From c59317c6d2ed2290ea81a8bc63781cee5c7f8f81 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 17 Jun 2026 15:52:00 +0800 Subject: [PATCH 4/4] fix(game): settle room rps stake coins --- .../internal/service/roomrps/service.go | 228 +++++++++++++++--- .../internal/service/roomrps/service_test.go | 129 +++++++++- 2 files changed, 324 insertions(+), 33 deletions(-) diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 6e685f6b..d72ea84a 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -3,6 +3,7 @@ package roomrps import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -50,6 +51,10 @@ const ( eventRevealCountdown = "room_rps_reveal_countdown" eventFinished = "room_rps_finished" eventExpired = "room_rps_expired" + + roomRPSWalletPlatform = "room_rps" + roomRPSOpDebit = "debit" + roomRPSOpRefund = "refund" ) // UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。 @@ -62,9 +67,9 @@ type GiftCatalogClient interface { ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) } -// BalanceClient 只暴露 room-rps 入场前需要的钱包余额查询;PK 状态仍由 game-service 管,金币事实仍由 wallet-service 管。 -type BalanceClient interface { - GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) +// WalletClient 只暴露 room-rps 需要的钱包能力;PK 状态仍由 game-service 管,金币事实和幂等改账由 wallet-service 管。 +type WalletClient interface { + ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) } // ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。 @@ -88,7 +93,7 @@ type Service struct { config Config user UserClient gifts GiftCatalogClient - wallet BalanceClient + wallet WalletClient pub Publisher store ConfigStore @@ -110,15 +115,15 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config.RevealCountdown = 3 * time.Second } var gifts GiftCatalogClient - var wallet BalanceClient + var wallet WalletClient var store ConfigStore for _, extra := range extras { if typed, ok := extra.(GiftCatalogClient); ok { // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 gifts = typed } - if typed, ok := extra.(BalanceClient); ok { - // 发起和应战都必须在进入状态机前查 COIN 余额;不做余额查询时会让 0 金币用户进入 PK 链路。 + if typed, ok := extra.(WalletClient); ok { + // 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。 wallet = typed } if typed, ok := extra.(ConfigStore); ok { @@ -317,7 +322,8 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS if stakeCoin <= 0 { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") } - initiatorBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin) + challengeID := newChallengeID(app) + initiatorBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) if err != nil { return nil, 0, err } @@ -327,7 +333,7 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS nowMS := now.UnixMilli() challenge := &gamev1.RoomRPSChallenge{ AppCode: app, - ChallengeId: newChallengeID(app), + ChallengeId: challengeID, RoomId: roomID, RegionId: req.GetRegionId(), Status: StatusPending, @@ -382,6 +388,13 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() + refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if refundErr != nil { + s.markSettlementFailed(app, challengeID, refundErr) + return nil, 0, refundErr + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") } @@ -391,9 +404,10 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } stakeCoin := challenge.GetStakeCoin() + roomID := challenge.GetRoomId() s.mu.Unlock() - challengerBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin) + challengerBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) if err != nil { return nil, 0, err } @@ -402,28 +416,39 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS challenge, ok = s.challenges[challengeID] if !ok || challenge.GetAppCode() != app { s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") } now = s.now() nowMS = now.UnixMilli() if challenge.GetStatus() != StatusPending { - // 余额查询期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准。 + // 钱包扣款期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准,失败时立即退回刚扣的挑战方本金。 s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") } if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { - // 二次校验也要收敛超时单,避免余额查询耗时跨过 timeout 后仍然匹配成功。 + // 二次校验也要收敛超时单,避免扣款耗时跨过 timeout 后仍然匹配成功。 challenge.Status = StatusTimeout challenge.SettlementStatus = SettlementRefunded challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) + refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if refundErr != nil { + s.markSettlementFailed(app, challengeID, refundErr) + return nil, 0, refundErr + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") } if challenge.GetInitiator().GetUserId() == req.GetUserId() { // 二次校验保留同一条权限边界,防止测试或未来接口在第一次检查后篡改挑战归属。 s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } @@ -437,6 +462,32 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS cloned := cloneChallenge(challenge) s.mu.Unlock() + settled, err := s.settleChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if err != nil { + s.mu.Lock() + if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { + current.Status = StatusSettlementFailed + current.SettlementStatus = SettlementFailed + current.FailureReason = err.Error() + current.UpdatedAtMs = s.now().UnixMilli() + } + s.mu.Unlock() + return nil, 0, err + } + s.mu.Lock() + if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { + current.Initiator.BalanceAfter = settled.GetInitiator().GetBalanceAfter() + current.Challenger.BalanceAfter = settled.GetChallenger().GetBalanceAfter() + current.SettlementStatus = settled.GetSettlementStatus() + current.FailureReason = "" + current.SettledAtMs = s.now().UnixMilli() + current.UpdatedAtMs = current.GetSettledAtMs() + cloned = cloneChallenge(current) + } else { + cloned = settled + } + s.mu.Unlock() + if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil { return nil, 0, err } @@ -536,6 +587,14 @@ func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID s cloned := cloneChallenge(challenge) s.mu.Unlock() + refunded, err := s.refundPendingChallenge(ctx, "room-rps-expire:"+challengeID, cloned) + if err != nil { + s.markSettlementFailed(app, challengeID, err) + return nil, 0, err + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded + if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil { return nil, 0, err } @@ -801,32 +860,141 @@ func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs [] return result, nil } -func (s *Service) ensureCoinBalance(ctx context.Context, requestID string, app string, userID int64, stakeCoin int64) (int64, error) { - if stakeCoin <= 0 { - return 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") +func (s *Service) settleChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { + if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetChallenger().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "room rps settlement challenge is incomplete") + } + settled := cloneChallenge(challenge) + app := settled.GetAppCode() + challengeID := settled.GetChallengeId() + roomID := settled.GetRoomId() + stakeCoin := settled.GetStakeCoin() + + if settled.GetInitiator().GetResult() == ResultDraw && settled.GetChallenger().GetResult() == ResultDraw { + // 平局没有赢家,也不应产生任何礼物流;双方前置扣款都按原金额退款,客户端只展示 refunded 事实。 + initiatorBalance, initiatorErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) + if initiatorErr == nil { + settled.Initiator.BalanceAfter = initiatorBalance + } + challengerBalance, challengerErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) + if challengerErr == nil { + settled.Challenger.BalanceAfter = challengerBalance + } + if initiatorErr != nil { + return settled, initiatorErr + } + if challengerErr != nil { + return settled, challengerErr + } + settled.SettlementStatus = SettlementRefunded + return settled, nil + } + + switch settled.GetWinnerUserId() { + case settled.GetInitiator().GetUserId(): + // 胜者本金必须退回;败者在点击 PK 时已经扣款,这笔扣款保留为败者成本,不再追加第二次扣费。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) + if err != nil { + return settled, err + } + settled.Initiator.BalanceAfter = balanceAfter + settled.SettlementStatus = SettlementSettled + return settled, nil + case settled.GetChallenger().GetUserId(): + // 只退赢家本金;输家的余额保持前置扣款后的余额,避免“输了但金币没少”的账务错误。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) + if err != nil { + return settled, err + } + settled.Challenger.BalanceAfter = balanceAfter + settled.SettlementStatus = SettlementSettled + return settled, nil + default: + return settled, xerr.New(xerr.InvalidArgument, "room rps winner is invalid") + } +} + +func (s *Service) refundPendingChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { + if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "room rps pending refund challenge is incomplete") + } + refunded := cloneChallenge(challenge) + // 无人应战或已超时的 pending 单只存在发起人前置扣款;过期时退回发起人,不产生礼物或赢家收益。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, refunded.GetAppCode(), refunded.GetChallengeId(), refunded.GetRoomId(), refunded.GetInitiator().GetUserId(), refunded.GetStakeCoin(), roomRPSOpRefund) + if err != nil { + return refunded, err + } + refunded.Initiator.BalanceAfter = balanceAfter + refunded.SettlementStatus = SettlementRefunded + refunded.FailureReason = "" + refunded.UpdatedAtMs = s.now().UnixMilli() + return refunded, nil +} + +func (s *Service) replaceChallengeSnapshot(app string, challengeID string, snapshot *gamev1.RoomRPSChallenge) { + if s == nil || snapshot == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + current := s.challenges[strings.TrimSpace(challengeID)] + if current == nil || current.GetAppCode() != appcode.Normalize(app) { + return + } + s.challenges[strings.TrimSpace(challengeID)] = cloneChallenge(snapshot) +} + +func (s *Service) markSettlementFailed(app string, challengeID string, cause error) { + if s == nil || cause == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + current := s.challenges[strings.TrimSpace(challengeID)] + if current == nil || current.GetAppCode() != appcode.Normalize(app) { + return + } + current.Status = StatusSettlementFailed + current.SettlementStatus = SettlementFailed + current.FailureReason = cause.Error() + current.UpdatedAtMs = s.now().UnixMilli() +} + +func (s *Service) applyCoinChange(ctx context.Context, requestID string, app string, challengeID string, roomID string, userID int64, amount int64, opType string) (int64, error) { + if amount <= 0 || userID <= 0 || strings.TrimSpace(challengeID) == "" { + return 0, xerr.New(xerr.InvalidArgument, "room rps coin command is incomplete") } if s.wallet == nil { - // 没有 wallet-service 余额事实时不能放用户进入 PK;否则测试服/线上配置缺依赖会退化成免费 PK。 + // 钱包不可用时必须拒绝进入 PK;否则房间状态会成功但 COIN 事实无法同步落账。 return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured") } - resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{ - RequestId: strings.TrimSpace(requestID), - UserId: userID, - AppCode: appcode.Normalize(app), - AssetTypes: []string{"COIN"}, + app = appcode.Normalize(app) + opType = strings.TrimSpace(opType) + providerOrderID := fmt.Sprintf("room_rps:%s:%d:%s", strings.TrimSpace(challengeID), userID, opType) + requestHash := roomRPSStableHash(fmt.Sprintf("%s|%s|%s|%s|%d|%d", app, strings.TrimSpace(challengeID), strings.TrimSpace(roomID), opType, userID, amount)) + resp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{ + RequestId: strings.TrimSpace(requestID), + AppCode: app, + CommandId: "game:" + providerOrderID, + UserId: userID, + PlatformCode: roomRPSWalletPlatform, + GameId: GameID, + ProviderOrderId: providerOrderID, + ProviderRoundId: strings.TrimSpace(challengeID), + OpType: opType, + CoinAmount: amount, + RoomId: strings.TrimSpace(roomID), + RequestHash: requestHash, }) if err != nil { return 0, err } - for _, balance := range resp.GetBalances() { - if strings.EqualFold(balance.GetAssetType(), "COIN") { - if balance.GetAvailableAmount() >= stakeCoin { - return balance.GetAvailableAmount(), nil - } - break - } - } - return 0, xerr.New(xerr.InsufficientBalance, "coin balance is insufficient") + return resp.GetBalanceAfter(), nil +} + +func roomRPSStableHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) } func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string { diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index fe5b7b0a..614c8475 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -2,6 +2,7 @@ package roomrps import ( "context" + "fmt" "testing" "time" @@ -162,7 +163,7 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 { t.Fatalf("string gift challenge mismatch: %+v", challenge) } - if challenge.GetInitiator().GetBalanceAfter() != 10 { + if challenge.GetInitiator().GetBalanceAfter() != 9 || wallet.balances[42] != 9 { t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator()) } } @@ -206,6 +207,9 @@ func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) { if len(svc.challenges) != 0 { t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges) } + if wallet.balances[42] != 99 || len(wallet.applies) != 1 { + t.Fatalf("insufficient create must not mutate wallet balance: balance=%d applies=%d", wallet.balances[42], len(wallet.applies)) + } } func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) { @@ -276,6 +280,9 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 { t.Fatalf("challenge did not use persistent config: %+v", challenge) } + if challenge.GetInitiator().GetBalanceAfter() != 778 || wallet.balances[42] != 778 { + t.Fatalf("persistent config challenge did not debit stake: challenge=%+v wallet=%d", challenge, wallet.balances[42]) + } } func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) { @@ -310,6 +317,9 @@ func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) { if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 { t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current) } + if wallet.balances[41] != 900 || wallet.balances[42] != 99 { + t.Fatalf("accept failure should keep initiator debit and avoid challenger debit: balances=%+v", wallet.balances) + } } func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) { @@ -337,9 +347,88 @@ func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) { if err != nil { t.Fatalf("AcceptChallenge() error = %v", err) } - if accepted.GetStatus() != StatusFinished || accepted.GetChallenger().GetBalanceAfter() != 500 { + if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementSettled || accepted.GetInitiator().GetBalanceAfter() != 900 || accepted.GetChallenger().GetBalanceAfter() != 500 { t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted) } + if wallet.balances[41] != 900 || wallet.balances[42] != 500 { + t.Fatalf("winner refund and loser debit mismatch: balances=%+v", wallet.balances) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund]" { + t.Fatalf("winner settlement should only debit both and refund winner, got ops=%v", got) + } +} + +func TestAcceptChallengeDrawRefundsBothPlayers(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 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-draw"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + + accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-draw"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "rock", + }) + if err != nil { + t.Fatalf("AcceptChallenge() error = %v", err) + } + if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementRefunded || accepted.GetWinnerUserId() != 0 { + t.Fatalf("draw challenge settlement mismatch: %+v", accepted) + } + if accepted.GetInitiator().GetBalanceAfter() != 1000 || accepted.GetChallenger().GetBalanceAfter() != 500 { + t.Fatalf("draw must refund both players in response: %+v", accepted) + } + if wallet.balances[41] != 1000 || wallet.balances[42] != 500 { + t.Fatalf("draw must refund both players in wallet: balances=%+v", wallet.balances) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund refund]" { + t.Fatalf("draw should only debit both and refund both without gift op, got ops=%v", got) + } +} + +func TestExpireChallengeRefundsInitiatorDebit(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000}) + 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-expire"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + if wallet.balances[41] != 900 || created.GetInitiator().GetBalanceAfter() != 900 { + t.Fatalf("create should debit initiator before pending challenge: challenge=%+v wallet=%d", created, wallet.balances[41]) + } + + expired, _, err := svc.ExpireChallenge(context.Background(), "lalu", created.GetChallengeId()) + if err != nil { + t.Fatalf("ExpireChallenge() error = %v", err) + } + if expired.GetStatus() != StatusTimeout || expired.GetSettlementStatus() != SettlementRefunded || expired.GetInitiator().GetBalanceAfter() != 1000 { + t.Fatalf("expired challenge should refund initiator: %+v", expired) + } + if wallet.balances[41] != 1000 { + t.Fatalf("expired challenge wallet balance mismatch: %d", wallet.balances[41]) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit refund]" { + t.Fatalf("expire should only debit then refund initiator, got ops=%v", got) + } } type fakeGiftCatalog struct { @@ -362,10 +451,12 @@ func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListG type fakeRoomRPSWallet struct { balances map[int64]int64 requests []*walletv1.GetBalancesRequest + applies []*walletv1.ApplyGameCoinChangeRequest + replays map[string]*walletv1.ApplyGameCoinChangeResponse } func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet { - return &fakeRoomRPSWallet{balances: balances} + return &fakeRoomRPSWallet{balances: balances, replays: make(map[string]*walletv1.ApplyGameCoinChangeResponse)} } func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { @@ -377,6 +468,38 @@ func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBala }}}, nil } +func (f *fakeRoomRPSWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { + f.applies = append(f.applies, req) + if replay := f.replays[req.GetCommandId()]; replay != nil { + return replay, nil + } + switch req.GetOpType() { + case roomRPSOpDebit: + if f.balances[req.GetUserId()] < req.GetCoinAmount() { + return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + f.balances[req.GetUserId()] -= req.GetCoinAmount() + case roomRPSOpRefund: + f.balances[req.GetUserId()] += req.GetCoinAmount() + default: + return nil, xerr.New(xerr.InvalidArgument, "unsupported op_type") + } + resp := &walletv1.ApplyGameCoinChangeResponse{ + WalletTransactionId: "wtx-" + req.GetCommandId(), + BalanceAfter: f.balances[req.GetUserId()], + } + f.replays[req.GetCommandId()] = resp + return resp, nil +} + +func (f *fakeRoomRPSWallet) ops() []string { + out := make([]string, 0, len(f.applies)) + for _, req := range f.applies { + out = append(out, req.GetOpType()) + } + return out +} + func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig { return &walletv1.GiftConfig{ AppCode: "lalu",